Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e6015033b6 | ||
|
|
2349a95dd4 | ||
|
|
7f936fa596 | ||
|
|
e5c7d690b8 |
No files matched your search
@@ -49,6 +49,10 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
AppBridge.foreground = true
|
AppBridge.foreground = true
|
||||||
|
// Wake the connect loop's backoff: after a background stint the
|
||||||
|
// network is usually back, so re-probe immediately instead of making
|
||||||
|
// the user wait out the (up to 30 s) backoff with a Connecting banner.
|
||||||
|
AppBridge.controller?.client?.poke()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onPause() {
|
override fun onPause() {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import iris.net.GatewayClient
|
|||||||
* - [onNewToken]: persist the rotated token and push it to the server via
|
* - [onNewToken]: persist the rotated token and push it to the server via
|
||||||
* `fcm.register` (so the next push targets the current token).
|
* `fcm.register` (so the next push targets the current token).
|
||||||
* - [onMessageReceived]: the data payload drives a silent sync. When the app
|
* - [onMessageReceived]: the data payload drives a silent sync. When the app
|
||||||
* is foregrounded the WS path already delivered the frame (in-app banner),
|
* is foregrounded the SSE path already delivered the frame (in-app banner),
|
||||||
* so we only post a system notification when backgrounded.
|
* so we only post a system notification when backgrounded.
|
||||||
*
|
*
|
||||||
* Inert without a Firebase project (no google-services.json): the service is
|
* Inert without a Firebase project (no google-services.json): the service is
|
||||||
@@ -20,23 +20,32 @@ import iris.net.GatewayClient
|
|||||||
* back to the ntfy listener.
|
* back to the ntfy listener.
|
||||||
*/
|
*/
|
||||||
class IrisFirebaseMessagingService : FirebaseMessagingService() {
|
class IrisFirebaseMessagingService : FirebaseMessagingService() {
|
||||||
|
|
||||||
override fun onNewToken(token: String) {
|
override fun onNewToken(token: String) {
|
||||||
val store = AndroidSecureStore(applicationContext)
|
val store = AndroidSecureStore(applicationContext)
|
||||||
store.fcmToken = token
|
store.fcmToken = token
|
||||||
// Push the rotation to the server if we're connected.
|
// Push the rotation to the server if we're connected (the ntfy topic
|
||||||
|
// rides along so a wiped registry recovers both push tokens).
|
||||||
AppBridge.controller?.client?.sendFrame(
|
AppBridge.controller?.client?.sendFrame(
|
||||||
iris.protocol.fcmRegisterFrame(fcmToken = token),
|
iris.protocol.fcmRegisterFrame(
|
||||||
|
fcmToken = token,
|
||||||
|
ntfyTopic = store.ntfyTopic.ifBlank { null },
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onMessageReceived(message: RemoteMessage) {
|
override fun onMessageReceived(message: RemoteMessage) {
|
||||||
// Foreground + live WS: the in-app banner already showed this.
|
// Foreground + live SSE: the in-app banner already showed this.
|
||||||
if (AppBridge.foreground) return
|
if (AppBridge.foreground) return
|
||||||
// Live WS: the frame arrives over the socket and the controller
|
// Live SSE: the frame arrives over the stream and the controller
|
||||||
// mirrors it to a system notification itself — posting here would
|
// mirrors it to a system notification itself — posting here would
|
||||||
// duplicate it (docs/08 §8.7).
|
// duplicate it (docs/08 §8.7).
|
||||||
if (AppBridge.controller?.client?.state?.value is GatewayClient.State.Connected) return
|
if (AppBridge.controller
|
||||||
|
?.client
|
||||||
|
?.state
|
||||||
|
?.value is GatewayClient.State.Connected
|
||||||
|
) {
|
||||||
|
return
|
||||||
|
}
|
||||||
// Backgrounded/killed: FCM already displayed the `notification`
|
// Backgrounded/killed: FCM already displayed the `notification`
|
||||||
// payload on our behalf (the data payload only carries sync
|
// payload on our behalf (the data payload only carries sync
|
||||||
// metadata). Posting again would show a second notification with a
|
// metadata). Posting again would show a second notification with a
|
||||||
|
|||||||
@@ -249,10 +249,14 @@ class ChatStore {
|
|||||||
)
|
)
|
||||||
list.toMutableList().also { it[byId] = updated }
|
list.toMutableList().also { it[byId] = updated }
|
||||||
} else if (p.role == ROLE_USER) {
|
} else if (p.role == ROLE_USER) {
|
||||||
// Replace the matching optimistic pending bubble (server echo).
|
// Replace the matching optimistic bubble (server echo). Also
|
||||||
|
// matches a FAILED bubble: the send may have arrived after
|
||||||
|
// its POST response was lost in a network drop — the echo is
|
||||||
|
// the proof of delivery, so reconcile instead of duplicating.
|
||||||
val pendingIdx =
|
val pendingIdx =
|
||||||
list.indexOfLast {
|
list.indexOfLast {
|
||||||
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text
|
it is MessageItem && it.role == ROLE_USER && it.text == p.text &&
|
||||||
|
(it.pending || it.status == MsgStatus.Failed)
|
||||||
}
|
}
|
||||||
if (pendingIdx >= 0) {
|
if (pendingIdx >= 0) {
|
||||||
list.toMutableList().also {
|
list.toMutableList().also {
|
||||||
@@ -315,7 +319,8 @@ class ChatStore {
|
|||||||
val flatList = map[flatLane].orEmpty()
|
val flatList = map[flatLane].orEmpty()
|
||||||
val idx =
|
val idx =
|
||||||
flatList.indexOfLast {
|
flatList.indexOfLast {
|
||||||
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text
|
it is MessageItem && it.role == ROLE_USER && it.text == p.text &&
|
||||||
|
(it.pending || it.status == MsgStatus.Failed)
|
||||||
}
|
}
|
||||||
if (idx < 0) return
|
if (idx < 0) return
|
||||||
map[flatLane] = flatList.toMutableList().also { it.removeAt(idx) }
|
map[flatLane] = flatList.toMutableList().also { it.removeAt(idx) }
|
||||||
@@ -613,6 +618,31 @@ class ChatStore {
|
|||||||
if (changed) _lanes.value = map
|
if (changed) _lanes.value = map
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** M7: mark a single user message as failed (the send never reached the
|
||||||
|
* gateway — network drop, or the gateway rejected it); tap the bubble
|
||||||
|
* to retry. */
|
||||||
|
fun failMessage(messageId: String) {
|
||||||
|
val map = _lanes.value.toMutableMap()
|
||||||
|
var changed = false
|
||||||
|
for ((lane, list) in map) {
|
||||||
|
val updated =
|
||||||
|
list.map { item ->
|
||||||
|
if (item is MessageItem && item.id == messageId && item.role == ROLE_USER &&
|
||||||
|
item.status != MsgStatus.Failed
|
||||||
|
) {
|
||||||
|
item.copy(pending = false, status = MsgStatus.Failed)
|
||||||
|
} else {
|
||||||
|
item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (updated != list) {
|
||||||
|
map[lane] = updated
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) _lanes.value = map
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove messages by id from every lane (a `message.deleted` frame). The
|
* Remove messages by id from every lane (a `message.deleted` frame). The
|
||||||
* server is authoritative: the frame carries no lane, and a message id is
|
* server is authoritative: the frame carries no lane, and a message id is
|
||||||
@@ -713,9 +743,24 @@ class ChatStore {
|
|||||||
) {
|
) {
|
||||||
updateLane(lane) { list ->
|
updateLane(lane) { list ->
|
||||||
val historyIds = messages.map { it.id }.toSet()
|
val historyIds = messages.map { it.id }.toSet()
|
||||||
|
// A local FAILED bubble whose text+media matches a history user
|
||||||
|
// message was actually delivered (the POST response was lost in
|
||||||
|
// the network drop) — the history copy is authoritative, so drop
|
||||||
|
// the local duplicate instead of showing the message twice.
|
||||||
|
val historyUser = messages.filter { it.role == ROLE_USER }
|
||||||
val preserved =
|
val preserved =
|
||||||
list.filter { item ->
|
list.filter { item ->
|
||||||
item !is MessageItem || item.id !in historyIds
|
if (item !is MessageItem) return@filter true
|
||||||
|
if (item.id in historyIds) return@filter false
|
||||||
|
if (item.role == ROLE_USER && item.status == MsgStatus.Failed &&
|
||||||
|
historyUser.any {
|
||||||
|
it.text == item.text &&
|
||||||
|
it.media.map { m -> m.mediaId } == item.media.map { m -> m.mediaId }
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
return@filter false
|
||||||
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
// ts of every item in the current lane: ts-less items (commentary,
|
// ts of every item in the current lane: ts-less items (commentary,
|
||||||
// tool cards) inherit the ts of the item before them, so a tool
|
// tool cards) inherit the ts of the item before them, so a tool
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ package iris.data
|
|||||||
* SharedPreferences for M1 dev, file on desktop).
|
* SharedPreferences for M1 dev, file on desktop).
|
||||||
*/
|
*/
|
||||||
interface SecureStore {
|
interface SecureStore {
|
||||||
/** ws(s)://host:port/ws */
|
/** http(s)://host:port (legacy ws(s):// URLs are still accepted) */
|
||||||
var serverUrl: String
|
var serverUrl: String
|
||||||
|
|
||||||
/** ANDROID_TOKEN presented in the hello frame. */
|
/** ANDROID_TOKEN presented in the auth header. */
|
||||||
var token: String
|
var token: String
|
||||||
|
|
||||||
/** Stable app-generated device id (persisted). */
|
/** Stable app-generated device id (persisted). */
|
||||||
|
|||||||
@@ -1,33 +1,19 @@
|
|||||||
package iris.net
|
package iris.net
|
||||||
|
|
||||||
import iris.data.SecureStore
|
import iris.data.SecureStore
|
||||||
import iris.media.FileSource
|
|
||||||
import iris.media.Sha256
|
|
||||||
import iris.protocol.ChannelInfo
|
import iris.protocol.ChannelInfo
|
||||||
import iris.protocol.ErrorPayload
|
|
||||||
import iris.protocol.Frame
|
import iris.protocol.Frame
|
||||||
import iris.protocol.HelloAckPayload
|
import iris.protocol.HelloAckPayload
|
||||||
import iris.protocol.IrisJson
|
|
||||||
import iris.protocol.MediaPullEndPayload
|
|
||||||
import iris.protocol.MediaUploadAckPayload
|
|
||||||
import iris.protocol.ServerCaps
|
import iris.protocol.ServerCaps
|
||||||
import iris.protocol.TYPE_ERROR
|
|
||||||
import iris.protocol.TYPE_HELLO_ACK
|
|
||||||
import iris.protocol.TYPE_MEDIA_PULL_END
|
|
||||||
import iris.protocol.TYPE_MEDIA_UPLOAD_ACK
|
|
||||||
import iris.protocol.TYPE_PONG
|
|
||||||
import iris.protocol.helloFrame
|
|
||||||
import iris.protocol.mediaPullFrame
|
|
||||||
import iris.protocol.mediaUploadEndFrame
|
|
||||||
import iris.protocol.mediaUploadStartFrame
|
|
||||||
import iris.protocol.messageSendFrame
|
import iris.protocol.messageSendFrame
|
||||||
import iris.protocol.pingFrame
|
|
||||||
import iris.protocol.syncFrame
|
import iris.protocol.syncFrame
|
||||||
import iris.util.IrisLog
|
import iris.util.IrisLog
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.currentCoroutineContext
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
@@ -43,25 +29,19 @@ import kotlinx.coroutines.sync.withLock
|
|||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
|
||||||
import okhttp3.Response
|
|
||||||
import okhttp3.WebSocket
|
|
||||||
import okhttp3.WebSocketListener
|
|
||||||
import okio.ByteString
|
|
||||||
import okio.ByteString.Companion.toByteString
|
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
import kotlin.time.TimeMark
|
|
||||||
import kotlin.time.TimeSource
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OkHttp WebSocket client for the hermes android gateway (docs/10 §10.3).
|
* HTTP client for the hermes android gateway (docs/19).
|
||||||
*
|
*
|
||||||
* - connect + hello (real auth leg), hello.ack
|
* HTTP is the only transport: send via `POST /v1/frame`, receive over SSE
|
||||||
|
* `/v1/events` (long-poll fallback), media via `POST/GET /v1/media`.
|
||||||
|
*
|
||||||
|
* - connect: health probe + SSE hello (the HTTP hello.ack)
|
||||||
* - reconnect: exponential backoff + jitter; re-hello on every (re)connect
|
* - reconnect: exponential backoff + jitter; re-hello on every (re)connect
|
||||||
* - heartbeat: app-level ping every 20s; reap after ~60s of silence
|
* - events: server frames on [events]
|
||||||
* - events: server frames (minus hello.ack) on [events]
|
* - request/response correlation by id
|
||||||
* - request/response correlation by id (M2+ consumers)
|
|
||||||
*/
|
*/
|
||||||
class GatewayClient(
|
class GatewayClient(
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
@@ -105,41 +85,42 @@ class GatewayClient(
|
|||||||
.build()
|
.build()
|
||||||
|
|
||||||
private var connectJob: Job? = null
|
private var connectJob: Job? = null
|
||||||
private var socket: WebSocket? = null
|
|
||||||
private var nextRequestId = 1
|
private var nextRequestId = 1
|
||||||
private var attempt = 0
|
private var attempt = 0
|
||||||
|
|
||||||
|
// Set by poke() (app returned to the foreground): the connect loop's
|
||||||
|
// backoff waits in 500 ms slices and re-probes immediately when set.
|
||||||
|
@Volatile
|
||||||
|
private var wakeRequested = false
|
||||||
|
|
||||||
// True once a connection has been established this session; reset by
|
// True once a connection has been established this session; reset by
|
||||||
// start(). Drives Connecting (first dial) vs Reconnecting (redial after a
|
// start(). Drives Connecting (first dial) vs Reconnecting (redial after a
|
||||||
// drop) so the UI can show the right status without a blocking screen.
|
// drop) so the UI can show the right status without a blocking screen.
|
||||||
private var hasConnected = false
|
private var hasConnected = false
|
||||||
private var lastLiveness: TimeMark = TimeSource.Monotonic.markNow()
|
|
||||||
private val pending = mutableMapOf<Int, CompletableDeferred<Frame>>()
|
private val pending = mutableMapOf<Int, CompletableDeferred<Frame>>()
|
||||||
|
|
||||||
// M4: binary frames (media upload chunks / pull stream) have no per-frame
|
// HTTP leg: [http] is created lazily from the stored URL; [httpCursor] is
|
||||||
// id, so at most one binary session is active per socket. The gateway
|
// the resume cursor (SSE id / outbox high-water mark).
|
||||||
// allows one upload per connection; pull is request/response.
|
private var http: HttpGateway? = null
|
||||||
private sealed interface BinarySession {
|
private var httpCursor: Long = 0
|
||||||
data class Pulling(
|
private var sseFailures = 0
|
||||||
val requestId: Int,
|
private var usingLongPoll = false
|
||||||
val chunks: Channel<ByteArray>,
|
|
||||||
val end: CompletableDeferred<Frame>,
|
|
||||||
) : BinarySession
|
|
||||||
}
|
|
||||||
|
|
||||||
private var binarySession: BinarySession? = null
|
// Last hello.ack payload — used to restore State.Connected after a
|
||||||
|
// reconnect state race in the connect loop.
|
||||||
|
private var lastAck: HelloAckPayload? = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fired promptly (on the WS thread) the moment `hello.ack` is received —
|
* Fired promptly the moment the SSE hello (hello.ack) is received — on
|
||||||
* on every (re)connect. Used for time-critical work that must not wait for
|
* every (re)connect. Used for time-critical work that must not wait for
|
||||||
* the state collector, which can be starved for seconds during app startup
|
* the state collector, which can be starved for seconds during app
|
||||||
* (Dispatchers.Default) and would push a history request past a flaky
|
* startup (Dispatchers.Default) and would push a history request past a
|
||||||
* network's window. Set before [start].
|
* flaky network's window. Set before [start].
|
||||||
*/
|
*/
|
||||||
var onHelloAck: ((State.Connected) -> Unit)? = null
|
var onHelloAck: ((State.Connected) -> Unit)? = null
|
||||||
|
|
||||||
// M4: only one pull may be in flight at a time (binarySession is a single
|
// Only one pull may be in flight at a time. Serialize concurrent offers so
|
||||||
// slot). Serialize concurrent offers so their byte streams don't interleave.
|
// their byte streams don't interleave.
|
||||||
private val pullMutex = Mutex()
|
private val pullMutex = Mutex()
|
||||||
|
|
||||||
// ── Lifecycle ─────────────────────────────────────────────────────────
|
// ── Lifecycle ─────────────────────────────────────────────────────────
|
||||||
@@ -152,15 +133,25 @@ class GatewayClient(
|
|||||||
connectJob = scope.launch { connectLoop() }
|
connectJob = scope.launch { connectLoop() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stop the connect loop and close the socket. */
|
/** Stop the connect loop. */
|
||||||
fun stop() {
|
fun stop() {
|
||||||
connectJob?.cancel()
|
connectJob?.cancel()
|
||||||
connectJob = null
|
connectJob = null
|
||||||
socket?.close(1000, "client shutdown")
|
|
||||||
socket = null
|
|
||||||
_state.value = State.Disconnected
|
_state.value = State.Disconnected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call when the app returns to the foreground: if the connect loop is
|
||||||
|
* between attempts (backing off after failed health probes — up to 30 s),
|
||||||
|
* wake it so it re-probes immediately instead of making the user wait out
|
||||||
|
* the backoff with a "Connecting…" banner. No-op while connected.
|
||||||
|
*/
|
||||||
|
fun poke() {
|
||||||
|
if (_state.value is State.Connected) return
|
||||||
|
attempt = 0
|
||||||
|
wakeRequested = true
|
||||||
|
}
|
||||||
|
|
||||||
/** Re-pair: stop, then start fresh (used after saving new settings). */
|
/** Re-pair: stop, then start fresh (used after saving new settings). */
|
||||||
fun restart() {
|
fun restart() {
|
||||||
stop()
|
stop()
|
||||||
@@ -176,217 +167,221 @@ class GatewayClient(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
_state.value = if (hasConnected) State.Reconnecting else State.Connecting
|
_state.value = if (hasConnected) State.Reconnecting else State.Connecting
|
||||||
val dial = dial(url, token)
|
val gw = httpGateway() ?: continue
|
||||||
when (val result = dial.result) {
|
// Health probe: if the gateway is alive, open the SSE receive loop
|
||||||
is DialResult.AuthFailed -> {
|
// (which delivers the hello.ack). Otherwise back off and retry.
|
||||||
_state.value = State.AuthFailed(result.message)
|
val healthOk =
|
||||||
dial.socket.close(1000, "auth failed")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
DialResult.Connected -> {
|
|
||||||
hasConnected = true
|
|
||||||
attempt = 0
|
|
||||||
lastLiveness = TimeSource.Monotonic.markNow()
|
|
||||||
dial.closed.await()
|
|
||||||
if (!currentCoroutineContext().isActive) return
|
|
||||||
// socket dropped -> loop again (Reconnecting)
|
|
||||||
}
|
|
||||||
|
|
||||||
is DialResult.Failed -> {
|
|
||||||
attempt++
|
|
||||||
delay(backoffMs(attempt))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Dial (one connect + hello) ────────────────────────────────────────
|
|
||||||
|
|
||||||
private sealed interface DialResult {
|
|
||||||
data object Connected : DialResult
|
|
||||||
|
|
||||||
data class AuthFailed(
|
|
||||||
val message: String,
|
|
||||||
) : DialResult
|
|
||||||
|
|
||||||
data class Failed(
|
|
||||||
val message: String,
|
|
||||||
) : DialResult
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class Dial(
|
|
||||||
val result: DialResult,
|
|
||||||
val socket: WebSocket,
|
|
||||||
val closed: CompletableDeferred<Unit>,
|
|
||||||
)
|
|
||||||
|
|
||||||
private suspend fun dial(
|
|
||||||
url: String,
|
|
||||||
token: String,
|
|
||||||
): Dial {
|
|
||||||
val closed = CompletableDeferred<Unit>()
|
|
||||||
val helloAck = CompletableDeferred<HelloAckPayload>()
|
|
||||||
val authError = CompletableDeferred<String>()
|
|
||||||
val fail = CompletableDeferred<String>()
|
|
||||||
|
|
||||||
val request = Request.Builder().url(url).build()
|
|
||||||
val ws =
|
|
||||||
client.newWebSocket(
|
|
||||||
request,
|
|
||||||
object : WebSocketListener() {
|
|
||||||
override fun onOpen(
|
|
||||||
webSocket: WebSocket,
|
|
||||||
response: Response,
|
|
||||||
) {
|
|
||||||
IrisLog.d("ws open (${response.code})")
|
|
||||||
webSocket.send(
|
|
||||||
helloFrame(
|
|
||||||
token = token,
|
|
||||||
deviceId = store.deviceId,
|
|
||||||
deviceName = store.deviceName,
|
|
||||||
fcmToken = store.fcmToken.ifBlank { null },
|
|
||||||
ntfyTopic = store.ntfyTopic.ifBlank { null },
|
|
||||||
).toWire(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMessage(
|
|
||||||
webSocket: WebSocket,
|
|
||||||
text: String,
|
|
||||||
) {
|
|
||||||
lastLiveness = TimeSource.Monotonic.markNow()
|
|
||||||
val frame =
|
|
||||||
try {
|
try {
|
||||||
IrisJson.instance.decodeFromString(Frame.serializer(), text)
|
gw.health()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// A dropped frame is silent data loss — log it (the
|
false
|
||||||
// first bytes hint at which frame it was).
|
|
||||||
IrisLog.e("frame decode failed (${text.length}B): $e :: ${text.take(120)}")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
when (frame.type) {
|
if (!healthOk) {
|
||||||
TYPE_HELLO_ACK -> {
|
attempt++
|
||||||
val ack = frame.payloadAs<HelloAckPayload>()
|
backoffOrWake(backoffMs(attempt))
|
||||||
if (ack != null) helloAck.complete(ack)
|
continue
|
||||||
}
|
}
|
||||||
|
attempt = 0
|
||||||
TYPE_ERROR -> {
|
hasConnected = true
|
||||||
val err = frame.payloadAs<ErrorPayload>()
|
sseFailures = 0
|
||||||
if (!authError.isCompleted) authError.complete(err?.message ?: "auth failed")
|
usingLongPoll = false
|
||||||
// M7: post-connect error frames are app events, not
|
httpCursor = store.syncCursor
|
||||||
// auth failures — let the controller react.
|
// Provisional Connected state (previous caps/channels) until the
|
||||||
_events.tryEmit(frame)
|
// SSE hello arrives with the real ones.
|
||||||
}
|
val prev = _state.value
|
||||||
|
_state.value =
|
||||||
TYPE_PONG -> {
|
State.Connected(
|
||||||
Unit
|
caps = (prev as? State.Connected)?.caps ?: ServerCaps(),
|
||||||
}
|
channels = (prev as? State.Connected)?.channels ?: emptyList(),
|
||||||
|
lastPushedCursor = (prev as? State.Connected)?.lastPushedCursor ?: 0,
|
||||||
else -> {
|
|
||||||
_events.tryEmit(frame)
|
|
||||||
frame.id?.let { id ->
|
|
||||||
pending[id]?.complete(frame)
|
|
||||||
// M4: terminal frame of a pull stream — close
|
|
||||||
// the chunk channel so the pull loop exits.
|
|
||||||
if (frame.type == TYPE_MEDIA_PULL_END) {
|
|
||||||
(binarySession as? BinarySession.Pulling)?.let {
|
|
||||||
it.chunks.close()
|
|
||||||
binarySession = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMessage(
|
|
||||||
webSocket: WebSocket,
|
|
||||||
bytes: ByteString,
|
|
||||||
) {
|
|
||||||
lastLiveness = TimeSource.Monotonic.markNow()
|
|
||||||
// M4: binary frames belong to the active pull stream
|
|
||||||
// (uploads are outbound; stray inbound chunks are dropped).
|
|
||||||
(binarySession as? BinarySession.Pulling)
|
|
||||||
?.chunks
|
|
||||||
?.trySend(bytes.toByteArray())
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onClosed(
|
|
||||||
webSocket: WebSocket,
|
|
||||||
code: Int,
|
|
||||||
reason: String,
|
|
||||||
) {
|
|
||||||
IrisLog.w("ws closed code=$code reason=\"$reason\"")
|
|
||||||
closed.complete(Unit)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFailure(
|
|
||||||
webSocket: WebSocket,
|
|
||||||
t: Throwable,
|
|
||||||
response: Response?,
|
|
||||||
) {
|
|
||||||
IrisLog.e("ws failure: ${t.javaClass.simpleName}: ${t.message} (http=${response?.code})")
|
|
||||||
fail.complete(t.message ?: "connection failed")
|
|
||||||
closed.complete(Unit)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
socket = ws
|
// Run the HTTP receive loop (SSE/long-poll) until cancelled.
|
||||||
|
coroutineScope {
|
||||||
|
val receiveJob = launch { httpReceiveLoop(gw) }
|
||||||
|
// Dead-stream watchdog: the SSE read timeout (45 s) is the
|
||||||
|
// only in-stream dead-connection detector; probe /v1/health
|
||||||
|
// in parallel so a dropped network flips the state to
|
||||||
|
// Reconnecting within ~20 s (2 failed probes) instead of 45,
|
||||||
|
// and a stuck stream can't keep a stale "Connected".
|
||||||
|
var probeFailures = 0
|
||||||
|
while (receiveJob.isActive) {
|
||||||
|
delay(10_000)
|
||||||
|
val ok =
|
||||||
|
try {
|
||||||
|
gw.health()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
probeFailures = if (ok) 0 else probeFailures + 1
|
||||||
|
if (probeFailures >= 2) {
|
||||||
|
receiveJob.cancel()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
receiveJob.join()
|
||||||
|
}
|
||||||
|
// Terminal auth failure: don't redial with the same bad token.
|
||||||
|
if (_state.value is State.AuthFailed) return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val winner = CompletableDeferred<DialResult>()
|
// ── HTTP receive leg ──────────────────────────────────────────────────
|
||||||
helloAck.invokeOnCompletion { e ->
|
|
||||||
if (e == null) {
|
/** Lazily build the HTTP client from the stored URL. */
|
||||||
val ack = helloAck.getCompleted()
|
private fun httpGateway(): HttpGateway? {
|
||||||
|
val url = store.serverUrl.trim()
|
||||||
|
val token = store.token
|
||||||
|
if (url.isBlank() || token.isBlank()) return null
|
||||||
|
return http
|
||||||
|
?: HttpGateway(
|
||||||
|
client,
|
||||||
|
HttpGateway.deriveHttpUrl(url),
|
||||||
|
token,
|
||||||
|
store.deviceId,
|
||||||
|
deviceName = store.deviceName,
|
||||||
|
fcmToken = { store.fcmToken.ifBlank { null } },
|
||||||
|
ntfyTopic = { store.ntfyTopic.ifBlank { null } },
|
||||||
|
).also { http = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The HTTP receive loop: SSE by default; after two consecutive SSE open
|
||||||
|
* failures (buffering proxy) it switches to long-poll until the next full
|
||||||
|
* (re)connect (docs/19 §19.6). Runs until the coroutine is cancelled.
|
||||||
|
*/
|
||||||
|
private suspend fun httpReceiveLoop(gw: HttpGateway) {
|
||||||
|
var backoff = 1_000L
|
||||||
|
while (currentCoroutineContext().isActive) {
|
||||||
|
if (usingLongPoll) {
|
||||||
|
try {
|
||||||
|
val res = gw.poll(httpCursor)
|
||||||
|
res.frames.forEach { emitHttpFrame(it) }
|
||||||
|
if (res.cursor > httpCursor) httpCursor = res.cursor
|
||||||
|
// The poll answered: the link is back (long-poll has no
|
||||||
|
// hello — restore the Connected state from the last one).
|
||||||
|
restoreConnected()
|
||||||
|
} catch (e: HttpGateway.HttpAuthException) {
|
||||||
|
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
|
||||||
|
return
|
||||||
|
} catch (e: Exception) {
|
||||||
|
markStreamLost()
|
||||||
|
IrisLog.w("http poll failed: ${e.message}")
|
||||||
|
delay(backoff)
|
||||||
|
backoff = minOf(backoff * 2, 15_000)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
gw.events(
|
||||||
|
cursor = httpCursor,
|
||||||
|
onHello = { onHttpHello(it) },
|
||||||
|
onFrame = { emitHttpFrame(it) },
|
||||||
|
onCursor = { if (it > httpCursor) httpCursor = it },
|
||||||
|
)
|
||||||
|
// Clean EOF: reconnect immediately.
|
||||||
|
backoff = 1_000L
|
||||||
|
} catch (e: HttpGateway.HttpAuthException) {
|
||||||
|
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
|
||||||
|
return
|
||||||
|
} catch (e: Exception) {
|
||||||
|
sseFailures++
|
||||||
|
markStreamLost()
|
||||||
|
if (sseFailures >= 2) {
|
||||||
|
// SSE seems blocked: switch to long-poll.
|
||||||
|
usingLongPoll = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
IrisLog.w("sse read failed: ${e.message}")
|
||||||
|
delay(backoff)
|
||||||
|
backoff = minOf(backoff * 2, 15_000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The SSE `event: hello` (the HTTP hello.ack). */
|
||||||
|
private fun onHttpHello(ack: HelloAckPayload) {
|
||||||
|
lastAck = ack
|
||||||
val connected = State.Connected(ack.serverCaps, ack.channels, ack.lastPushedCursor)
|
val connected = State.Connected(ack.serverCaps, ack.channels, ack.lastPushedCursor)
|
||||||
_state.value = connected
|
_state.value = connected
|
||||||
// M5: reconnect catch-up — replay frames parked while offline.
|
// M5: reconnect catch-up — replay frames parked while offline.
|
||||||
val local = store.syncCursor
|
val local = store.syncCursor
|
||||||
if (local < ack.syncCursor) {
|
if (local < ack.syncCursor) {
|
||||||
val id = nextRequestId++
|
val id = nextRequestId++
|
||||||
ws.send(syncFrame(id, local).toWire())
|
scope.launch { httpGateway()?.postFrame(syncFrame(id, local)) }
|
||||||
}
|
}
|
||||||
// Prompt fast path (before the possibly-starved state collector).
|
// Prompt fast path (before the possibly-starved state collector).
|
||||||
onHelloAck?.invoke(connected)
|
onHelloAck?.invoke(connected)
|
||||||
winner.complete(DialResult.Connected)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The receive stream just died: don't keep claiming "Connected" while
|
||||||
|
* between attempts (a stale green dot through a Wi-Fi drop). */
|
||||||
|
private fun markStreamLost() {
|
||||||
|
if (_state.value is State.Connected) _state.value = State.Reconnecting
|
||||||
}
|
}
|
||||||
authError.invokeOnCompletion { e ->
|
|
||||||
if (e == null) winner.complete(DialResult.AuthFailed(authError.getCompleted()))
|
/** The receive stream is open again (long-poll answered): the link is
|
||||||
|
* back. Long-poll has no hello, so restore the Connected state from the
|
||||||
|
* last hello.ack (the SSE path gets a fresh one). */
|
||||||
|
private fun restoreConnected() {
|
||||||
|
if (_state.value !is State.Reconnecting) return
|
||||||
|
_state.value =
|
||||||
|
lastAck?.let { State.Connected(it.serverCaps, it.channels, it.lastPushedCursor) }
|
||||||
|
?: State.Connected(ServerCaps(), emptyList())
|
||||||
}
|
}
|
||||||
fail.invokeOnCompletion { e ->
|
|
||||||
if (e == null) winner.complete(DialResult.Failed(fail.getCompleted()))
|
/** Deliver an HTTP-leg frame to the same sinks as any other frame. */
|
||||||
|
private fun emitHttpFrame(frame: Frame) {
|
||||||
|
_events.tryEmit(frame)
|
||||||
|
frame.id?.let { id ->
|
||||||
|
pending[id]?.complete(frame)
|
||||||
}
|
}
|
||||||
val result =
|
|
||||||
withTimeoutOrNull(15_000) { winner.await() }
|
|
||||||
?: DialResult.Failed("timeout waiting for hello.ack")
|
|
||||||
return Dial(result, ws, closed)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Outbound ──────────────────────────────────────────────────────────
|
// ── Outbound ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Send a text message (fire-and-forget; the server echoes it back).
|
/** Send a text message (fire-and-forget; the server echoes it back).
|
||||||
* M4: [mediaRefs] reference completed uploads (media.upload.ack refs).
|
* [mediaRefs] reference completed uploads (POST /v1/media refs).
|
||||||
* [autoThread] asks the gateway to mint a fresh thread for the message
|
* [autoThread] asks the gateway to mint a fresh thread for the message
|
||||||
* (auto-threading, docs/06 §6.3). */
|
* (auto-threading, docs/06 §6.3).
|
||||||
|
* [onResult] is called with the POST's HTTP status — 0 means "no
|
||||||
|
* response" (not connected, or the network failed); 2xx means the
|
||||||
|
* gateway accepted it; 4xx is a gateway rejection (error frame already
|
||||||
|
* delivered via [events]). Used to fail the optimistic bubble instead
|
||||||
|
* of leaving it at "sending…" forever. */
|
||||||
fun sendMessage(
|
fun sendMessage(
|
||||||
chatId: String,
|
chatId: String,
|
||||||
text: String,
|
text: String,
|
||||||
threadId: String? = null,
|
threadId: String? = null,
|
||||||
mediaRefs: List<String> = emptyList(),
|
mediaRefs: List<String> = emptyList(),
|
||||||
autoThread: Boolean = false,
|
autoThread: Boolean = false,
|
||||||
|
onResult: ((Int) -> Unit)? = null,
|
||||||
) {
|
) {
|
||||||
val ws = socket ?: return
|
if (_state.value !is State.Connected) {
|
||||||
|
onResult?.invoke(0)
|
||||||
|
return
|
||||||
|
}
|
||||||
val id = nextRequestId++
|
val id = nextRequestId++
|
||||||
ws.send(messageSendFrame(id, chatId, text, threadId, mediaRefs, autoThread).toWire())
|
scope.launch {
|
||||||
|
val res =
|
||||||
|
httpGateway()?.postFrame(
|
||||||
|
messageSendFrame(id, chatId, text, threadId, mediaRefs, autoThread),
|
||||||
|
)
|
||||||
|
// The synchronous reply (e.g. the read receipt, or an error frame
|
||||||
|
// on 4xx) comes back in the POST body, not on the event stream —
|
||||||
|
// deliver it or it is lost (docs/19 §19.7).
|
||||||
|
res?.frame?.let { emitHttpFrame(it) }
|
||||||
|
if (res?.status == 401) {
|
||||||
|
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
|
||||||
|
}
|
||||||
|
onResult?.invoke(res?.status ?: 0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── M4: media upload / pull ───────────────────────────────────────────
|
// ── Media upload / pull ───────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload a local file as media (docs/07 §7.2): media.upload.start,
|
* Upload a local file as media via `POST /v1/media` (docs/19 §19.15, v2).
|
||||||
* 256 KiB binary chunks, media.upload.end {sha256}. Returns the server's
|
* Returns the server's media_ref (for message.send media_refs) on success.
|
||||||
* media_ref (for message.send media_refs) on success.
|
|
||||||
*/
|
*/
|
||||||
suspend fun uploadMedia(
|
suspend fun uploadMedia(
|
||||||
path: String,
|
path: String,
|
||||||
@@ -395,190 +390,132 @@ class GatewayClient(
|
|||||||
filename: String,
|
filename: String,
|
||||||
mediaRef: String,
|
mediaRef: String,
|
||||||
): Result<String> {
|
): Result<String> {
|
||||||
val ws = socket ?: return Result.failure(IllegalStateException("not connected"))
|
val http = httpGateway() ?: return Result.failure(IllegalStateException("not connected"))
|
||||||
val source = FileSource(path)
|
return http.uploadMedia(path, mime, kind, filename, mediaRef)
|
||||||
val size = source.size()
|
|
||||||
if (size <= 0) {
|
|
||||||
source.close()
|
|
||||||
return Result.failure(IllegalStateException("empty file"))
|
|
||||||
}
|
|
||||||
val id = nextRequestId++
|
|
||||||
val reply = CompletableDeferred<Frame>()
|
|
||||||
pending[id] = reply
|
|
||||||
try {
|
|
||||||
ws.send(mediaUploadStartFrame(id, mediaRef, kind, mime, filename, size).toWire())
|
|
||||||
val sha = Sha256()
|
|
||||||
source.use {
|
|
||||||
val buf = ByteArray(UPLOAD_CHUNK_BYTES)
|
|
||||||
while (true) {
|
|
||||||
val n = it.read(buf)
|
|
||||||
if (n < 0) break
|
|
||||||
if (n == 0) continue
|
|
||||||
sha.update(buf, 0, n)
|
|
||||||
ws.send(buf.copyOfRange(0, n).toByteString())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ws.send(mediaUploadEndFrame(id, mediaRef, sha.hex()).toWire())
|
|
||||||
val frame = withTimeout(UPLOAD_TIMEOUT_MS) { reply.await() }
|
|
||||||
return when (frame.type) {
|
|
||||||
TYPE_MEDIA_UPLOAD_ACK -> {
|
|
||||||
val p = frame.payloadAs<MediaUploadAckPayload>()
|
|
||||||
if (p != null && p.ok) {
|
|
||||||
Result.success(p.mediaRef)
|
|
||||||
} else {
|
|
||||||
Result.failure(IllegalStateException("upload rejected by server"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TYPE_ERROR -> {
|
|
||||||
val e = frame.payloadAs<ErrorPayload>()
|
|
||||||
Result.failure(IllegalStateException(e?.message ?: "upload failed"))
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
Result.failure(IllegalStateException("unexpected reply ${frame.type}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
return Result.failure(e)
|
|
||||||
} finally {
|
|
||||||
pending.remove(id)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pull offered media (docs/07 §7.3): media.pull, then binary frames until
|
* Pull offered media via `GET /v1/media/{id}` (docs/19 §19.15, v2). Each
|
||||||
* media.pull.end. Each chunk is handed to [onChunk] (write to cache).
|
* chunk is handed to [onChunk] (write to cache).
|
||||||
*/
|
*/
|
||||||
suspend fun pullMedia(
|
suspend fun pullMedia(
|
||||||
mediaId: String,
|
mediaId: String,
|
||||||
onChunk: suspend (ByteArray) -> Unit,
|
onChunk: suspend (ByteArray) -> Unit,
|
||||||
): Result<Unit> =
|
): Result<Unit> =
|
||||||
pullMutex.withLock {
|
pullMutex.withLock {
|
||||||
val ws = socket ?: return@withLock Result.failure(IllegalStateException("not connected"))
|
val http = httpGateway() ?: return@withLock Result.failure(IllegalStateException("not connected"))
|
||||||
val id = nextRequestId++
|
http.pullMedia(mediaId) { chunk -> onChunk(chunk) }
|
||||||
val chunks = Channel<ByteArray>(Channel.UNLIMITED)
|
|
||||||
val end = CompletableDeferred<Frame>()
|
|
||||||
pending[id] = end
|
|
||||||
binarySession = BinarySession.Pulling(id, chunks, end)
|
|
||||||
try {
|
|
||||||
ws.send(mediaPullFrame(id, mediaId).toWire())
|
|
||||||
val frame =
|
|
||||||
withTimeout(PULL_TIMEOUT_MS) {
|
|
||||||
for (chunk in chunks) onChunk(chunk)
|
|
||||||
end.await()
|
|
||||||
}
|
|
||||||
when (frame.type) {
|
|
||||||
TYPE_MEDIA_PULL_END -> {
|
|
||||||
val p = frame.payloadAs<MediaPullEndPayload>()
|
|
||||||
if (p != null && p.ok) {
|
|
||||||
Result.success(Unit)
|
|
||||||
} else {
|
|
||||||
Result.failure(IllegalStateException("pull failed"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TYPE_ERROR -> {
|
|
||||||
val e = frame.payloadAs<ErrorPayload>()
|
|
||||||
Result.failure(IllegalStateException(e?.message ?: "pull failed"))
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
Result.failure(IllegalStateException("unexpected reply ${frame.type}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.failure(e)
|
|
||||||
} finally {
|
|
||||||
pending.remove(id)
|
|
||||||
chunks.cancel()
|
|
||||||
val s = binarySession
|
|
||||||
if (s is BinarySession.Pulling && s.requestId == id) binarySession = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** One WS binary frame carries at most this many media bytes (docs/07 §7.5). */
|
|
||||||
const val UPLOAD_CHUNK_BYTES = 256 * 1024
|
|
||||||
const val UPLOAD_TIMEOUT_MS = 120_000L
|
const val UPLOAD_TIMEOUT_MS = 120_000L
|
||||||
const val PULL_TIMEOUT_MS = 300_000L
|
const val PULL_TIMEOUT_MS = 300_000L
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send an arbitrary frame with a fresh request id (fire-and-forget).
|
* Send an arbitrary frame with a fresh request id (fire-and-forget). The
|
||||||
* The server replies (or broadcasts) a frame carrying the same id; the
|
* server replies (or broadcasts) a frame carrying the same id; the app
|
||||||
* app reconciles from [events]. Returns the id used, or -1 if not connected.
|
* reconciles from [events]. Returns the id used, or -1 if not connected.
|
||||||
*/
|
*/
|
||||||
fun sendFrame(frame: Frame): Int {
|
fun sendFrame(frame: Frame): Int {
|
||||||
val ws = socket ?: return -1
|
if (_state.value !is State.Connected) return -1
|
||||||
val id = nextRequestId++
|
val id = nextRequestId++
|
||||||
ws.send(frame.copy(id = id).toWire())
|
scope.launch {
|
||||||
|
val res = httpGateway()?.postFrame(frame.copy(id = id))
|
||||||
|
// Single-frame responses (commands.catalog, channel.list, search,
|
||||||
|
// history, sync, errors) come back in the POST body, not on the
|
||||||
|
// event stream — deliver it or it is lost (docs/19 §19.7).
|
||||||
|
res?.frame?.let { emitHttpFrame(it) }
|
||||||
|
if (res?.status == 401) {
|
||||||
|
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
|
||||||
|
}
|
||||||
|
}
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Send a ping (heartbeat). */
|
|
||||||
fun ping() {
|
|
||||||
socket?.send(pingFrame().toWire())
|
|
||||||
}
|
|
||||||
|
|
||||||
/** True when the socket has been silent for [timeoutMs] (heartbeat reap). */
|
|
||||||
fun isStale(timeoutMs: Long = 60_000): Boolean =
|
|
||||||
_state.value is State.Connected && lastLiveness.elapsedNow().inWholeMilliseconds > timeoutMs
|
|
||||||
|
|
||||||
fun reapStale() {
|
|
||||||
if (isStale()) {
|
|
||||||
socket?.close(1000, "heartbeat timeout")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── One-shot hello test (Connect screen) ──────────────────────────────
|
// ── One-shot hello test (Connect screen) ──────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Real `hello` test: dial, wait for hello.ack (or auth error), close.
|
* Real connection test: health probe + SSE open. The auth leg is proven
|
||||||
* Exercises the auth leg, not just TCP (docs/10 §10.8).
|
* by the stream being accepted (200 vs 401) — we do NOT wait for the
|
||||||
|
* hello event, because the server replays the outbox (up to 72 h of
|
||||||
|
* frames) before it and a large outbox would time out a healthy gateway
|
||||||
|
* (docs/10 §10.8).
|
||||||
*/
|
*/
|
||||||
suspend fun testHello(
|
suspend fun testHello(
|
||||||
url: String,
|
url: String,
|
||||||
token: String,
|
token: String,
|
||||||
): Result<Unit> {
|
): Result<Unit> {
|
||||||
val dial = dial(url, token)
|
val gw =
|
||||||
return when (val result = dial.result) {
|
HttpGateway(
|
||||||
DialResult.Connected -> {
|
client,
|
||||||
dial.socket.close(1000, "test complete")
|
HttpGateway.deriveHttpUrl(url),
|
||||||
|
token,
|
||||||
|
store.deviceId,
|
||||||
|
deviceName = store.deviceName,
|
||||||
|
fcmToken = { store.fcmToken.ifBlank { null } },
|
||||||
|
ntfyTopic = { store.ntfyTopic.ifBlank { null } },
|
||||||
|
)
|
||||||
|
return try {
|
||||||
|
if (!gw.health()) {
|
||||||
|
Result.failure(IllegalStateException("gateway unreachable"))
|
||||||
|
} else {
|
||||||
|
// Open the SSE stream briefly: 200 = auth leg proven, 401 =
|
||||||
|
// bad token. Don't wait for the hello (outbox replay first).
|
||||||
|
val opened = CompletableDeferred<Unit>()
|
||||||
|
val job =
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
gw.events(
|
||||||
|
cursor = store.syncCursor,
|
||||||
|
onOpen = { opened.complete(Unit) },
|
||||||
|
onHello = { },
|
||||||
|
onFrame = { },
|
||||||
|
onCursor = { },
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
opened.completeExceptionally(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (withTimeoutOrNull(10_000) { opened.await() } == null) {
|
||||||
|
Result.failure(IllegalStateException("timeout opening the event stream"))
|
||||||
|
} else {
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
}
|
}
|
||||||
|
} catch (e: HttpGateway.HttpAuthException) {
|
||||||
is DialResult.AuthFailed -> {
|
Result.failure(IllegalStateException("unauthorized — check the pairing token"))
|
||||||
Result.failure(IllegalStateException(result.message))
|
} catch (e: CancellationException) {
|
||||||
}
|
throw e
|
||||||
|
} catch (e: Exception) {
|
||||||
is DialResult.Failed -> {
|
Result.failure(IllegalStateException("connection failed: ${e.message}"))
|
||||||
Result.failure(IllegalStateException(result.message))
|
} finally {
|
||||||
}
|
job.cancel()
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Heartbeat job ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fun startHeartbeat() {
|
|
||||||
scope.launch {
|
|
||||||
while (isActive) {
|
|
||||||
delay(20_000)
|
|
||||||
if (_state.value is State.Connected) {
|
|
||||||
ping()
|
|
||||||
reapStale()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Backoff that wakes early when [poke] is called (app foregrounded). */
|
||||||
|
private suspend fun backoffOrWake(ms: Long) {
|
||||||
|
var remaining = ms
|
||||||
|
while (remaining > 0 && currentCoroutineContext().isActive) {
|
||||||
|
delay(minOf(remaining, 500L))
|
||||||
|
if (wakeRequested) {
|
||||||
|
wakeRequested = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
remaining -= 500L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun backoffMs(attempt: Int): Long {
|
private fun backoffMs(attempt: Int): Long {
|
||||||
val base = 1_000L * (1L shl minOf(attempt, 5)) // 1s..32s
|
val base = 1_000L * (1L shl minOf(attempt, 5)) // 1s..32s
|
||||||
val capped = minOf(base, 30_000L)
|
val capped = minOf(base, 30_000L)
|
||||||
return capped + Random.nextLong(0, 500)
|
return capped + Random.nextLong(0, 500)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Frame.toWire(): String = IrisJson.instance.encodeToString(Frame.serializer(), this)
|
|
||||||
@@ -0,0 +1,477 @@
|
|||||||
|
package iris.net
|
||||||
|
|
||||||
|
import iris.media.Sha256
|
||||||
|
import iris.protocol.ErrorPayload
|
||||||
|
import iris.protocol.Frame
|
||||||
|
import iris.protocol.HelloAckPayload
|
||||||
|
import iris.protocol.IrisJson
|
||||||
|
import iris.protocol.MediaUploadAckPayload
|
||||||
|
import iris.util.IrisLog
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import okhttp3.Headers
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP transport client (docs/19) — the only transport.
|
||||||
|
*
|
||||||
|
* The app sends over `POST /v1/frame` and receives over SSE
|
||||||
|
* `GET /v1/events` (or long-poll `GET /v1/poll` where SSE is blocked);
|
||||||
|
* media travels via `POST/GET /v1/media`.
|
||||||
|
*
|
||||||
|
* [events] is ONE SSE connection attempt (blocking read on
|
||||||
|
* [Dispatchers.IO]); [GatewayClient] wraps it in a retry loop and tracks
|
||||||
|
* the resume cursor via the [events] `onCursor` callback (SSE `id` =
|
||||||
|
* outbox cursor, so resume is just the last seen id).
|
||||||
|
*/
|
||||||
|
class HttpGateway(
|
||||||
|
private val client: OkHttpClient,
|
||||||
|
private val baseUrl: String,
|
||||||
|
private val token: String,
|
||||||
|
private val deviceId: String,
|
||||||
|
/** Human-readable device name (sent as `X-Iris-Device-Name`; the gateway
|
||||||
|
* upserts it into the device registry on every SSE open — the HTTP
|
||||||
|
* equivalent of the old WS hello upsert). */
|
||||||
|
private val deviceName: String? = null,
|
||||||
|
/** Live push-token providers, read per request so a rotated FCM token or
|
||||||
|
* a fresh ntfy topic is picked up without rebuilding the client. */
|
||||||
|
private val fcmToken: () -> String? = { null },
|
||||||
|
private val ntfyTopic: () -> String? = { null },
|
||||||
|
) {
|
||||||
|
/** The gateway rejected the pairing token (HTTP 401). Terminal: retrying
|
||||||
|
* with the same token can't succeed. */
|
||||||
|
class HttpAuthException : IOException("unauthorized (HTTP 401)")
|
||||||
|
|
||||||
|
// OkHttp's default read timeout (10 s) is shorter than the gateway's SSE
|
||||||
|
// heartbeat (15 s) and the long-poll hold (25 s) — per-purpose clients
|
||||||
|
// with extended call timeouts (see the *Client() helpers below).
|
||||||
|
private val healthClient: OkHttpClient = client.healthClient()
|
||||||
|
private val streamClient: OkHttpClient = client.streamClient()
|
||||||
|
private val pollClient: OkHttpClient = client.pollClient()
|
||||||
|
private val mediaClient: OkHttpClient = client.mediaClient()
|
||||||
|
|
||||||
|
/** POST /v1/frame result. [frame] is the handler's synchronous reply
|
||||||
|
* (error frame on 4xx, e.g. read.receipt on 200) or null for a plain
|
||||||
|
* 202 accept-and-ack. */
|
||||||
|
data class PostResult(
|
||||||
|
val ok: Boolean,
|
||||||
|
val status: Int,
|
||||||
|
val frame: Frame?,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Long-poll result: new high-water [cursor] + frames with cursor >
|
||||||
|
* the requested one (may be empty at timeout). */
|
||||||
|
data class PollResult(
|
||||||
|
val cursor: Long,
|
||||||
|
val frames: List<Frame>,
|
||||||
|
)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val JSON = "application/json".toMediaType()
|
||||||
|
|
||||||
|
/** Default port of the gateway's HTTP leg (WS default is 8790). */
|
||||||
|
const val DEFAULT_PORT = 8791
|
||||||
|
|
||||||
|
/** Media transfer chunk (docs/07 §7.5). */
|
||||||
|
private const val MEDIA_CHUNK_BYTES = 256 * 1024
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the HTTP base URL from the stored WS URL (docs/19 §19.4):
|
||||||
|
* `ws(s)://host[:port]/ws` -> `http(s)://host:8791`. The WS port is
|
||||||
|
* a different service, so the port is always replaced with the
|
||||||
|
* HTTP leg's default. Pure function (unit-tested).
|
||||||
|
*/
|
||||||
|
fun deriveHttpUrl(wsUrl: String): String {
|
||||||
|
val u = wsUrl.trim()
|
||||||
|
val (scheme, rest) =
|
||||||
|
when {
|
||||||
|
u.startsWith("wss://") -> "https" to u.removePrefix("wss://")
|
||||||
|
u.startsWith("ws://") -> "http" to u.removePrefix("ws://")
|
||||||
|
else -> return u // already http(s)
|
||||||
|
}
|
||||||
|
val authority = rest.substringBefore('/')
|
||||||
|
val host = authority.substringBefore(':')
|
||||||
|
return "$scheme://$host:$DEFAULT_PORT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun authHeaders(): Headers {
|
||||||
|
val b =
|
||||||
|
Headers
|
||||||
|
.Builder()
|
||||||
|
.add("Authorization", "Bearer $token")
|
||||||
|
.add("X-Iris-Device", deviceId)
|
||||||
|
// Device registration (docs/19): the gateway upserts name + push
|
||||||
|
// tokens from these headers on every SSE open (COALESCE — absent
|
||||||
|
// headers never clobber a newer fcm.register value).
|
||||||
|
deviceName?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Device-Name", it) }
|
||||||
|
fcmToken()?.takeIf { !it.isNullOrBlank() }?.let { b.add("X-Iris-Fcm-Token", it) }
|
||||||
|
ntfyTopic()?.takeIf { it.isNotBlank() }?.let { b.add("X-Iris-Ntfy-Topic", it) }
|
||||||
|
return b.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse a response body as a protocol frame (null when not a frame,
|
||||||
|
* e.g. the plain `{"ok":true}` ack). */
|
||||||
|
private fun parseFrame(body: String): Frame? =
|
||||||
|
try {
|
||||||
|
if (body.startsWith("{")) {
|
||||||
|
val obj = IrisJson.instance.parseToJsonElement(body)
|
||||||
|
if (obj.jsonObject.containsKey("type")) {
|
||||||
|
IrisJson.instance.decodeFromJsonElement(Frame.serializer(), obj)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liveness probe (unauthenticated by design). True on 200. */
|
||||||
|
suspend fun health(): Boolean =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val request =
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url("$baseUrl/v1/health")
|
||||||
|
.build()
|
||||||
|
healthClient
|
||||||
|
.newCall(request)
|
||||||
|
.execute()
|
||||||
|
.use { response ->
|
||||||
|
response.body?.close()
|
||||||
|
response.code == 200
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /v1/frame (accept-and-ack, docs/19 §19.7). 2xx -> [PostResult.ok]
|
||||||
|
* (with the synchronous reply frame when the handler sent one); 4xx ->
|
||||||
|
* the error frame as the body. Network failures (timeout, reset, DNS —
|
||||||
|
* common when mobile Wi-Fi half-sleeps) do NOT throw: they come back as
|
||||||
|
* [PostResult] with [PostResult.status] 0 ("no HTTP response"). The
|
||||||
|
* callers are fire-and-forget coroutines — an uncaught exception here
|
||||||
|
* kills the app process.
|
||||||
|
*/
|
||||||
|
suspend fun postFrame(frame: Frame): PostResult =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val wire = IrisJson.instance.encodeToString(Frame.serializer(), frame)
|
||||||
|
val request =
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url("$baseUrl/v1/frame")
|
||||||
|
.headers(authHeaders())
|
||||||
|
.post(wire.toRequestBody(JSON))
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
client
|
||||||
|
.newCall(request)
|
||||||
|
.execute()
|
||||||
|
.use { response ->
|
||||||
|
val body = response.body?.string().orEmpty()
|
||||||
|
val parsed = parseFrame(body)
|
||||||
|
PostResult(response.isSuccessful, response.code, parsed)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
IrisLog.w("postFrame ${frame.type} failed: ${e.message}")
|
||||||
|
PostResult(ok = false, status = 0, frame = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One SSE connection attempt: outbox catch-up from [cursor], then live
|
||||||
|
* frames. [onOpen] fires as soon as the stream is accepted (200 — the auth
|
||||||
|
* leg is proven; the server may still replay a large outbox before the
|
||||||
|
* hello); [onHello] fires for `event: hello` (the HTTP hello.ack);
|
||||||
|
* [onFrame] for `event: frame`; [onCursor] with the SSE `id` (outbox
|
||||||
|
* cursor) when present. Returns on clean EOF; throws [IOException] on
|
||||||
|
* open/read failure. Callbacks run on the IO thread.
|
||||||
|
*/
|
||||||
|
suspend fun events(
|
||||||
|
cursor: Long,
|
||||||
|
onOpen: (() -> Unit)? = null,
|
||||||
|
onHello: (HelloAckPayload) -> Unit,
|
||||||
|
onFrame: (Frame) -> Unit,
|
||||||
|
onCursor: (Long) -> Unit,
|
||||||
|
) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val request =
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url("$baseUrl/v1/events?cursor=$cursor")
|
||||||
|
.headers(authHeaders())
|
||||||
|
.build()
|
||||||
|
streamClient
|
||||||
|
.newCall(request)
|
||||||
|
.execute()
|
||||||
|
.use { response ->
|
||||||
|
if (response.code == 401) throw HttpAuthException()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
throw IOException("SSE open failed: HTTP ${response.code}")
|
||||||
|
}
|
||||||
|
onOpen?.invoke()
|
||||||
|
val source = response.body?.source() ?: throw IOException("empty SSE body")
|
||||||
|
var eventId: String? = null
|
||||||
|
val dataLines = mutableListOf<String>()
|
||||||
|
while (true) {
|
||||||
|
val line = source.readUtf8Line() ?: break // EOF
|
||||||
|
when {
|
||||||
|
line.isEmpty() -> {
|
||||||
|
if (dataLines.isNotEmpty()) {
|
||||||
|
val data = dataLines.joinToString("\n")
|
||||||
|
try {
|
||||||
|
val frame =
|
||||||
|
IrisJson.instance.decodeFromString(
|
||||||
|
Frame.serializer(),
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
when (eventId) {
|
||||||
|
"hello" -> {
|
||||||
|
onHello(
|
||||||
|
frame.payloadAs<HelloAckPayload>()
|
||||||
|
?: HelloAckPayload(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
onFrame(frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
IrisLog.e("sse frame decode failed: $e :: ${data.take(120)}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eventId = null
|
||||||
|
dataLines.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
line.startsWith(":") -> {
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
// heartbeat comment
|
||||||
|
line.startsWith("id:") -> {
|
||||||
|
line
|
||||||
|
.removePrefix("id:")
|
||||||
|
.trim()
|
||||||
|
.toLongOrNull()
|
||||||
|
?.let(onCursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
line.startsWith("event:") -> {
|
||||||
|
eventId = line.removePrefix("event:").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
line.startsWith("data:") -> {
|
||||||
|
dataLines.add(line.removePrefix("data:").removePrefix(" "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Long-poll (docs/19 §19.6): the server holds the request up to 25 s.
|
||||||
|
* Returns the new high-water cursor + any frames with cursor > [cursor].
|
||||||
|
*/
|
||||||
|
suspend fun poll(cursor: Long): PollResult =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val request =
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url("$baseUrl/v1/poll?cursor=$cursor")
|
||||||
|
.headers(authHeaders())
|
||||||
|
.build()
|
||||||
|
pollClient
|
||||||
|
.newCall(request)
|
||||||
|
.execute()
|
||||||
|
.use { response ->
|
||||||
|
if (response.code == 401) throw HttpAuthException()
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
throw IOException("poll failed: HTTP ${response.code}")
|
||||||
|
}
|
||||||
|
val body = response.body?.string().orEmpty()
|
||||||
|
val obj = IrisJson.instance.parseToJsonElement(body).jsonObject
|
||||||
|
val newCursor = obj["cursor"]?.jsonPrimitive?.content?.toLongOrNull() ?: cursor
|
||||||
|
val frames =
|
||||||
|
obj["frames"]
|
||||||
|
?.jsonArray
|
||||||
|
?.mapNotNull { el ->
|
||||||
|
try {
|
||||||
|
IrisJson.instance.decodeFromJsonElement(Frame.serializer(), el)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}.orEmpty()
|
||||||
|
PollResult(newCursor, frames)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upload a local file as media (docs/19 §19.15, v2): one `POST /v1/media`
|
||||||
|
* with the whole file as the body and the metadata in `X-Iris-Media-*`
|
||||||
|
* headers (sha256 precomputed in a first pass). Returns the server's
|
||||||
|
* media_ref (for message.send media_refs) on success.
|
||||||
|
*/
|
||||||
|
suspend fun uploadMedia(
|
||||||
|
path: String,
|
||||||
|
mime: String,
|
||||||
|
kind: String,
|
||||||
|
filename: String,
|
||||||
|
mediaRef: String,
|
||||||
|
): Result<String> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val file = File(path)
|
||||||
|
if (!file.isFile() || file.length() <= 0) {
|
||||||
|
return@withContext Result.failure(IllegalStateException("empty file"))
|
||||||
|
}
|
||||||
|
val sha = Sha256()
|
||||||
|
file.inputStream().use { ins ->
|
||||||
|
val buf = ByteArray(MEDIA_CHUNK_BYTES)
|
||||||
|
while (true) {
|
||||||
|
val n = ins.read(buf)
|
||||||
|
if (n < 0) break
|
||||||
|
if (n > 0) sha.update(buf, 0, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val request =
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url("$baseUrl/v1/media")
|
||||||
|
.headers(
|
||||||
|
authHeaders()
|
||||||
|
.newBuilder()
|
||||||
|
.add("X-Iris-Media-Ref", mediaRef)
|
||||||
|
.add("X-Iris-Media-Kind", kind)
|
||||||
|
.add("X-Iris-Media-Filename", filename)
|
||||||
|
.add("X-Iris-Media-Sha256", sha.hex())
|
||||||
|
.build(),
|
||||||
|
).post(file.asRequestBody(mime.toMediaType()))
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
mediaClient
|
||||||
|
.newCall(request)
|
||||||
|
.execute()
|
||||||
|
.use { response ->
|
||||||
|
val body = response.body?.string().orEmpty()
|
||||||
|
val frame = parseFrame(body)
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
val p = frame?.payloadAs<MediaUploadAckPayload>()
|
||||||
|
if (p != null && p.ok) {
|
||||||
|
Result.success(p.mediaRef)
|
||||||
|
} else {
|
||||||
|
Result.failure(IllegalStateException("upload rejected by server"))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val e = frame?.payloadAs<ErrorPayload>()
|
||||||
|
Result.failure(
|
||||||
|
IllegalStateException(e?.message ?: "upload failed: HTTP ${response.code}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Network failure mid-upload: report, don't throw (the caller
|
||||||
|
// is a fire-and-forget coroutine — an uncaught exception kills
|
||||||
|
// the app process).
|
||||||
|
IrisLog.w("media upload failed: ${e.message}")
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pull offered media (docs/19 §19.15, v2): `GET /v1/media/{id}`; the
|
||||||
|
* response body is the file, streamed to [onChunk] (write to cache).
|
||||||
|
*/
|
||||||
|
suspend fun pullMedia(
|
||||||
|
mediaId: String,
|
||||||
|
onChunk: suspend (ByteArray) -> Unit,
|
||||||
|
): Result<Unit> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val request =
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url("$baseUrl/v1/media/$mediaId")
|
||||||
|
.headers(authHeaders())
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
mediaClient
|
||||||
|
.newCall(request)
|
||||||
|
.execute()
|
||||||
|
.use { response ->
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
val e = parseFrame(response.body?.string().orEmpty())?.payloadAs<ErrorPayload>()
|
||||||
|
Result.failure(
|
||||||
|
IllegalStateException(e?.message ?: "pull failed: HTTP ${response.code}"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
val source = response.body?.source() ?: throw IOException("empty body")
|
||||||
|
val buf = ByteArray(MEDIA_CHUNK_BYTES)
|
||||||
|
while (true) {
|
||||||
|
val n = source.read(buf)
|
||||||
|
if (n < 0) break
|
||||||
|
if (n == 0) continue
|
||||||
|
onChunk(buf.copyOfRange(0, n))
|
||||||
|
}
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Network failure before/while opening the pull: report, don't
|
||||||
|
// throw (fire-and-forget caller — uncaught = process death).
|
||||||
|
IrisLog.w("media pull failed: ${e.message}")
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-purpose OkHttp clients. The base client's DEFAULT read timeout (10 s)
|
||||||
|
* is shorter than the gateway's SSE heartbeat (15 s) and the long-poll hold
|
||||||
|
* (25 s) — it would kill both receive paths while they are simply waiting
|
||||||
|
* for the next byte, so the streaming clients override it. The read timeout
|
||||||
|
* doubles as the dead-stream detector (a healthy SSE stream gets a heartbeat
|
||||||
|
* comment every 15 s; a healthy poll answers within 25 s).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** SSE: long-lived stream → no call cap; read timeout = 3× the 15 s
|
||||||
|
* heartbeat (detects a dead connection within 45 s). */
|
||||||
|
internal fun OkHttpClient.streamClient(): OkHttpClient =
|
||||||
|
newBuilder()
|
||||||
|
.callTimeout(0, TimeUnit.MILLISECONDS)
|
||||||
|
.readTimeout(45_000, TimeUnit.MILLISECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
/** Long-poll: the server holds up to 25 s → no call cap; read timeout =
|
||||||
|
* hold + 15 s margin. */
|
||||||
|
internal fun OkHttpClient.pollClient(): OkHttpClient =
|
||||||
|
newBuilder()
|
||||||
|
.callTimeout(0, TimeUnit.MILLISECONDS)
|
||||||
|
.readTimeout(40_000, TimeUnit.MILLISECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
internal fun OkHttpClient.healthClient(): OkHttpClient =
|
||||||
|
newBuilder()
|
||||||
|
.callTimeout(2_000, TimeUnit.MILLISECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
/** Media transfers (upload/pull) can take a while on large files. */
|
||||||
|
internal fun OkHttpClient.mediaClient(): OkHttpClient =
|
||||||
|
newBuilder()
|
||||||
|
.callTimeout(300_000, TimeUnit.MILLISECONDS)
|
||||||
|
.build()
|
||||||
@@ -30,13 +30,10 @@ object IrisJson {
|
|||||||
|
|
||||||
// ── Frame type constants ────────────────────────────────────────────────
|
// ── Frame type constants ────────────────────────────────────────────────
|
||||||
|
|
||||||
const val TYPE_HELLO = "hello"
|
|
||||||
const val TYPE_HELLO_ACK = "hello.ack"
|
const val TYPE_HELLO_ACK = "hello.ack"
|
||||||
const val TYPE_MESSAGE = "message"
|
const val TYPE_MESSAGE = "message"
|
||||||
const val TYPE_MESSAGE_SEND = "message.send"
|
const val TYPE_MESSAGE_SEND = "message.send"
|
||||||
const val TYPE_ERROR = "error"
|
const val TYPE_ERROR = "error"
|
||||||
const val TYPE_PING = "ping"
|
|
||||||
const val TYPE_PONG = "pong"
|
|
||||||
const val TYPE_TYPING = "typing"
|
const val TYPE_TYPING = "typing"
|
||||||
|
|
||||||
// M2 — streaming / tools / commentary
|
// M2 — streaming / tools / commentary
|
||||||
@@ -52,13 +49,9 @@ const val TYPE_TOOL_PROGRESS = "tool.progress"
|
|||||||
const val TYPE_TOOL_END = "tool.end"
|
const val TYPE_TOOL_END = "tool.end"
|
||||||
const val TYPE_COMMENTARY = "commentary"
|
const val TYPE_COMMENTARY = "commentary"
|
||||||
|
|
||||||
// M4 — media (upload / offer / pull)
|
// M4 — media (offer; upload/pull are HTTP, docs/19 §19.15)
|
||||||
const val TYPE_MEDIA_UPLOAD_START = "media.upload.start"
|
|
||||||
const val TYPE_MEDIA_UPLOAD_END = "media.upload.end"
|
|
||||||
const val TYPE_MEDIA_UPLOAD_ACK = "media.upload.ack"
|
const val TYPE_MEDIA_UPLOAD_ACK = "media.upload.ack"
|
||||||
const val TYPE_MEDIA_OFFER = "media.offer"
|
const val TYPE_MEDIA_OFFER = "media.offer"
|
||||||
const val TYPE_MEDIA_PULL = "media.pull"
|
|
||||||
const val TYPE_MEDIA_PULL_END = "media.pull.end"
|
|
||||||
|
|
||||||
// M5 — push / notifications / read receipt / gateway status
|
// M5 — push / notifications / read receipt / gateway status
|
||||||
const val TYPE_NOTIFICATION = "notification"
|
const val TYPE_NOTIFICATION = "notification"
|
||||||
@@ -133,18 +126,6 @@ data class Frame(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── hello (app -> server) ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class HelloPayload(
|
|
||||||
val token: String,
|
|
||||||
@SerialName("device_id") val deviceId: String,
|
|
||||||
@SerialName("device_name") val deviceName: String,
|
|
||||||
val caps: JsonElement = buildJsonObject { put("min_protocol", JsonPrimitive(1)) },
|
|
||||||
@SerialName("fcm_token") val fcmToken: String? = null,
|
|
||||||
@SerialName("ntfy_topic") val ntfyTopic: String? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── hello.ack (server -> app) ───────────────────────────────────────────
|
// ── hello.ack (server -> app) ───────────────────────────────────────────
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -241,21 +222,6 @@ data class MediaRef(
|
|||||||
val filename: String,
|
val filename: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class MediaUploadStartPayload(
|
|
||||||
@SerialName("media_ref") val mediaRef: String,
|
|
||||||
val kind: String,
|
|
||||||
val mime: String,
|
|
||||||
val filename: String,
|
|
||||||
val size: Long,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class MediaUploadEndPayload(
|
|
||||||
@SerialName("media_ref") val mediaRef: String,
|
|
||||||
@SerialName("sha256") val sha256: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class MediaUploadAckPayload(
|
data class MediaUploadAckPayload(
|
||||||
val ok: Boolean,
|
val ok: Boolean,
|
||||||
@@ -272,16 +238,6 @@ data class MediaOfferPayload(
|
|||||||
@SerialName("message_id") val messageId: String? = null,
|
@SerialName("message_id") val messageId: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class MediaPullPayload(
|
|
||||||
@SerialName("media_id") val mediaId: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class MediaPullEndPayload(
|
|
||||||
val ok: Boolean,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── M2: streaming frames (server -> app) ────────────────────────────────
|
// ── M2: streaming frames (server -> app) ────────────────────────────────
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -353,7 +309,7 @@ data class MessageSendPayload(
|
|||||||
@SerialName("auto_thread") val autoThread: Boolean = false,
|
@SerialName("auto_thread") val autoThread: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── typing / error / ping ───────────────────────────────────────────────
|
// ── typing / error ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class TypingPayload(
|
data class TypingPayload(
|
||||||
@@ -366,11 +322,6 @@ data class ErrorPayload(
|
|||||||
val message: String,
|
val message: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data class PingPayload(
|
|
||||||
val ts: Long? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── M3: channel directory (app -> server requests) ──────────────────────
|
// ── M3: channel directory (app -> server requests) ──────────────────────
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -548,28 +499,6 @@ data class StatusPayload(
|
|||||||
|
|
||||||
// ── Frame builders ──────────────────────────────────────────────────────
|
// ── Frame builders ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
fun helloFrame(
|
|
||||||
token: String,
|
|
||||||
deviceId: String,
|
|
||||||
deviceName: String,
|
|
||||||
fcmToken: String? = null,
|
|
||||||
ntfyTopic: String? = null,
|
|
||||||
): Frame =
|
|
||||||
Frame(
|
|
||||||
type = TYPE_HELLO,
|
|
||||||
payload =
|
|
||||||
IrisJson.instance.encodeToJsonElement(
|
|
||||||
HelloPayload.serializer(),
|
|
||||||
HelloPayload(
|
|
||||||
token = token,
|
|
||||||
deviceId = deviceId,
|
|
||||||
deviceName = deviceName,
|
|
||||||
fcmToken = fcmToken,
|
|
||||||
ntfyTopic = ntfyTopic,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun messageSendFrame(
|
fun messageSendFrame(
|
||||||
id: Int,
|
id: Int,
|
||||||
chatId: String,
|
chatId: String,
|
||||||
@@ -590,8 +519,6 @@ fun messageSendFrame(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
fun pingFrame(): Frame = Frame(type = TYPE_PING, payload = IrisJson.instance.encodeToJsonElement(PingPayload.serializer(), PingPayload()))
|
|
||||||
|
|
||||||
// ── M3 frame builders ───────────────────────────────────────────────────
|
// ── M3 frame builders ───────────────────────────────────────────────────
|
||||||
|
|
||||||
fun channelCreateFrame(
|
fun channelCreateFrame(
|
||||||
@@ -765,55 +692,6 @@ fun messageDeleteFrame(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── M4 frame builders ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
fun mediaUploadStartFrame(
|
|
||||||
id: Int,
|
|
||||||
mediaRef: String,
|
|
||||||
kind: String,
|
|
||||||
mime: String,
|
|
||||||
filename: String,
|
|
||||||
size: Long,
|
|
||||||
): Frame =
|
|
||||||
Frame(
|
|
||||||
id = id,
|
|
||||||
type = TYPE_MEDIA_UPLOAD_START,
|
|
||||||
payload =
|
|
||||||
IrisJson.instance.encodeToJsonElement(
|
|
||||||
MediaUploadStartPayload.serializer(),
|
|
||||||
MediaUploadStartPayload(mediaRef, kind, mime, filename, size),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun mediaUploadEndFrame(
|
|
||||||
id: Int,
|
|
||||||
mediaRef: String,
|
|
||||||
sha256: String,
|
|
||||||
): Frame =
|
|
||||||
Frame(
|
|
||||||
id = id,
|
|
||||||
type = TYPE_MEDIA_UPLOAD_END,
|
|
||||||
payload =
|
|
||||||
IrisJson.instance.encodeToJsonElement(
|
|
||||||
MediaUploadEndPayload.serializer(),
|
|
||||||
MediaUploadEndPayload(mediaRef, sha256),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
fun mediaPullFrame(
|
|
||||||
id: Int,
|
|
||||||
mediaId: String,
|
|
||||||
): Frame =
|
|
||||||
Frame(
|
|
||||||
id = id,
|
|
||||||
type = TYPE_MEDIA_PULL,
|
|
||||||
payload =
|
|
||||||
IrisJson.instance.encodeToJsonElement(
|
|
||||||
MediaPullPayload.serializer(),
|
|
||||||
MediaPullPayload(mediaId),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── M5 frame builders ───────────────────────────────────────────────────
|
// ── M5 frame builders ───────────────────────────────────────────────────
|
||||||
|
|
||||||
fun fcmRegisterFrame(
|
fun fcmRegisterFrame(
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import iris.protocol.MessagePayload
|
|||||||
import iris.protocol.MessageStopPayload
|
import iris.protocol.MessageStopPayload
|
||||||
import iris.protocol.NotificationPayload
|
import iris.protocol.NotificationPayload
|
||||||
import iris.protocol.ROLE_ASSISTANT
|
import iris.protocol.ROLE_ASSISTANT
|
||||||
|
import iris.protocol.ROLE_USER
|
||||||
import iris.protocol.ReadReceiptPayload
|
import iris.protocol.ReadReceiptPayload
|
||||||
import iris.protocol.SearchHit
|
import iris.protocol.SearchHit
|
||||||
import iris.protocol.SearchResultsPayload
|
import iris.protocol.SearchResultsPayload
|
||||||
@@ -605,6 +606,10 @@ class IrisController(
|
|||||||
// stays unmarked and the next (re)connect
|
// stays unmarked and the next (re)connect
|
||||||
// retries it.
|
// retries it.
|
||||||
historyLoaded.add(lane)
|
historyLoaded.add(lane)
|
||||||
|
// Offline sends that never arrived go out
|
||||||
|
// now (delivered duplicates were dropped by
|
||||||
|
// loadHistory's dedupe above).
|
||||||
|
reconcileFailedSends(lane)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -689,7 +694,7 @@ class IrisController(
|
|||||||
// follows on hello.ack and re-asserts the truth).
|
// follows on hello.ack and re-asserts the truth).
|
||||||
_gatewayStatus.value = "online"
|
_gatewayStatus.value = "online"
|
||||||
// The lane/history fast path runs on [client.onHelloAck]
|
// The lane/history fast path runs on [client.onHelloAck]
|
||||||
// (promptly, on the WS thread) — see onConnectedLane. Here
|
// (promptly, on the SSE thread) — see onConnectedLane. Here
|
||||||
// we do the non-time-critical connect work.
|
// we do the non-time-critical connect work.
|
||||||
// M5: refresh the push-dedupe watermark (docs/08 §8.7).
|
// M5: refresh the push-dedupe watermark (docs/08 §8.7).
|
||||||
lastPushedCursor = s.lastPushedCursor
|
lastPushedCursor = s.lastPushedCursor
|
||||||
@@ -729,17 +734,21 @@ class IrisController(
|
|||||||
if (store.ntfyTopic.isBlank()) {
|
if (store.ntfyTopic.isBlank()) {
|
||||||
store.ntfyTopic = "iris-${store.deviceId}-${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
|
store.ntfyTopic = "iris-${store.deviceId}-${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
|
||||||
}
|
}
|
||||||
client.startHeartbeat()
|
|
||||||
// Prompt fast path: seed the channel directory + load the active lane's
|
|
||||||
// history the moment hello.ack lands (on the WS thread), not after the
|
|
||||||
// state collector (which can be starved for seconds on startup). This
|
|
||||||
// gets the history request out early so its response lands inside a
|
|
||||||
// flaky network's window.
|
|
||||||
client.onHelloAck = { connected ->
|
client.onHelloAck = { connected ->
|
||||||
try {
|
try {
|
||||||
onConnectedLane(connected)
|
onConnectedLane(connected)
|
||||||
|
// Auto-resend offline sends AFTER the outbox replay has been
|
||||||
|
// processed: replayed frames precede the hello on the stream,
|
||||||
|
// but the frame collector may still be draining them — a
|
||||||
|
// delivered message whose POST response was lost must
|
||||||
|
// reconcile (echo replaces the failed bubble) before we
|
||||||
|
// decide to resend it.
|
||||||
|
scope.launch {
|
||||||
|
delay(2_000)
|
||||||
|
reconcileAllFailedSends()
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Must not throw on the WS thread (would break the connection).
|
// Must not throw on the SSE thread (would break the connection).
|
||||||
IrisLog.e("onConnectedLane failed: $e")
|
IrisLog.e("onConnectedLane failed: $e")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -932,11 +941,36 @@ class IrisController(
|
|||||||
localPath = it.path,
|
localPath = it.path,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
chat.addPending(trimmed, lane, media)
|
val messageId = chat.addPending(trimmed, lane, media)
|
||||||
client.sendMessage(chatId, trimmed, threadId, refs, autoThread = wantsAutoThread(trimmed, threadId, chatId))
|
client.sendMessage(
|
||||||
|
chatId,
|
||||||
|
trimmed,
|
||||||
|
threadId,
|
||||||
|
refs,
|
||||||
|
autoThread = wantsAutoThread(trimmed, threadId, chatId),
|
||||||
|
onResult = { status -> onSendResult(messageId, status) },
|
||||||
|
)
|
||||||
_attachments.value = emptyList()
|
_attachments.value = emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** POST result for an optimistic send: 2xx = accepted (the echo
|
||||||
|
* reconciles the bubble); 0 = no response (offline / network failure) —
|
||||||
|
* keep the bubble QUEUED (Pending) and remember it: it goes out on the
|
||||||
|
* next (re)connect, so the user can compose and send while the network
|
||||||
|
* is down; 4xx = gateway rejection — fail the bubble (tap to retry),
|
||||||
|
* no auto-retry (the gateway said no). */
|
||||||
|
private fun onSendResult(
|
||||||
|
messageId: String,
|
||||||
|
status: Int,
|
||||||
|
) {
|
||||||
|
if (status in 200..299) return
|
||||||
|
if (status == 0) {
|
||||||
|
networkFailed.add(messageId)
|
||||||
|
} else {
|
||||||
|
chat.failMessage(messageId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Auto-threading (Settings → "Threads", docs/06 §6.3): a message in the
|
/** Auto-threading (Settings → "Threads", docs/06 §6.3): a message in the
|
||||||
* default channel's flat lane gets its own fresh thread, AI-named by the
|
* default channel's flat lane gets its own fresh thread, AI-named by the
|
||||||
* gateway (Telegram topic-mode workflow). Threading is only active on
|
* gateway (Telegram topic-mode workflow). Threading is only active on
|
||||||
@@ -966,9 +1000,52 @@ class IrisController(
|
|||||||
threadId,
|
threadId,
|
||||||
item.media.map { it.mediaId },
|
item.media.map { it.mediaId },
|
||||||
autoThread = wantsAutoThread(item.text, threadId, chatId),
|
autoThread = wantsAutoThread(item.text, threadId, chatId),
|
||||||
|
onResult = { status -> onSendResult(messageId, status) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** User message ids that failed for NETWORK reasons (status 0 — not a
|
||||||
|
* gateway error frame): queued (Pending) or failed bubbles that go out
|
||||||
|
* automatically on the next (re)connect. In-memory only — a process
|
||||||
|
* death leaves them as tap-to-retry (the local cache restore already
|
||||||
|
* marks pending sends failed). */
|
||||||
|
private val networkFailed = mutableSetOf<String>()
|
||||||
|
|
||||||
|
/** Resend queued/failed user messages of [lane] now that the link is
|
||||||
|
* back: a message the server already has (the POST response was lost in
|
||||||
|
* the drop) was reconciled by the echo / loadHistory dedupe, so anything
|
||||||
|
* still queued or Failed here never arrived — send it. */
|
||||||
|
private fun reconcileFailedSends(lane: String) {
|
||||||
|
val items = chat.lanes.value[lane] ?: return
|
||||||
|
for (item in items) {
|
||||||
|
if (item !is MessageItem || item.role != ROLE_USER || item.id !in networkFailed) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (item.status != MsgStatus.Pending && item.status != MsgStatus.Failed) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
networkFailed.remove(item.id)
|
||||||
|
chat.rearmForRetry(lane, item.id) // no-op for queued (already Pending)
|
||||||
|
val (chatId, threadId) = chat.parseLane(lane)
|
||||||
|
client.sendMessage(
|
||||||
|
chatId,
|
||||||
|
item.text,
|
||||||
|
threadId,
|
||||||
|
item.media.map { it.mediaId },
|
||||||
|
autoThread = wantsAutoThread(item.text, threadId, chatId),
|
||||||
|
onResult = { status -> onSendResult(item.id, status) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reconcile every lane (offline sends may sit in any lane). */
|
||||||
|
private fun reconcileAllFailedSends() {
|
||||||
|
if (networkFailed.isEmpty()) return
|
||||||
|
for (lane in chat.lanes.value.keys) {
|
||||||
|
reconcileFailedSends(lane)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Delete the given message(s) from the current lane (long-press select →
|
/** Delete the given message(s) from the current lane (long-press select →
|
||||||
* delete). The server removes them from the outbox and broadcasts
|
* delete). The server removes them from the outbox and broadcasts
|
||||||
* `message.deleted`; the local cache drops them on that frame (or
|
* `message.deleted`; the local cache drops them on that frame (or
|
||||||
|
|||||||
@@ -245,9 +245,8 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
val nonThreadChannels = channels.filter { it.kind != "thread" }
|
val nonThreadChannels = channels.filter { it.kind != "thread" }
|
||||||
|
|
||||||
fun doSend() {
|
fun doSend() {
|
||||||
// No-op while the socket is down (sendMessage drops silently); the send
|
// Offline sends are allowed: the bubble stays queued (Pending) and is
|
||||||
// button is disabled in that state, this guards the IME "Send" action.
|
// auto-resent on the next (re)connect (IrisController.onSendResult).
|
||||||
if (state !is GatewayClient.State.Connected) return
|
|
||||||
val ready = attachments.filter { it.mediaRef != null && it.error == null }
|
val ready = attachments.filter { it.mediaRef != null && it.error == null }
|
||||||
if (input.isBlank() && ready.isEmpty()) return
|
if (input.isBlank() && ready.isEmpty()) return
|
||||||
val text = input
|
val text = input
|
||||||
@@ -739,12 +738,15 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Composer (M7: rounded pill + accent circular send button). Sending is gated
|
// Composer (M7: rounded pill + accent circular send button). Sending is
|
||||||
// on a live socket: sendMessage is a no-op while disconnected, so an
|
// NOT gated on a live connection: an offline send stays queued
|
||||||
// ungated send would show a pending bubble that never resolves.
|
// (Pending) and is auto-resent on the next (re)connect (see
|
||||||
val isConnected = state is GatewayClient.State.Connected
|
// IrisController.onSendResult / reconcileFailedSends). The
|
||||||
val canSend =
|
// automation lane stays read-only regardless.
|
||||||
isConnected && (input.isNotBlank() || attachments.any { it.mediaRef != null && it.error == null })
|
// docs/19 §19.15 (v2): media works over the HTTP leg too, so
|
||||||
|
// attachments are sendable in both connected states (uploading
|
||||||
|
// still needs a live connection — the picker stays gated).
|
||||||
|
val canSend = input.isNotBlank() || attachments.any { it.mediaRef != null && it.error == null }
|
||||||
val layoutDensity = LocalDensity.current.density
|
val layoutDensity = LocalDensity.current.density
|
||||||
var textHeightPx by remember { mutableFloatStateOf(0f) }
|
var textHeightPx by remember { mutableFloatStateOf(0f) }
|
||||||
if (isAutomation) {
|
if (isAutomation) {
|
||||||
@@ -818,7 +820,7 @@ fun ChatScreen(controller: IrisController) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = { showPicker = true }) {
|
IconButton(onClick = { showPicker = true }, enabled = state is GatewayClient.State.Connected) {
|
||||||
Text("📎", fontSize = 18.sp)
|
Text("📎", fontSize = 18.sp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2119,7 +2121,8 @@ private fun StatusBubble(
|
|||||||
) {
|
) {
|
||||||
val (color, pulsing) =
|
val (color, pulsing) =
|
||||||
when (state) {
|
when (state) {
|
||||||
is GatewayClient.State.Connected -> IrisColors.statusGreen to false
|
is GatewayClient.State.Connected,
|
||||||
|
-> IrisColors.statusGreen to false
|
||||||
|
|
||||||
GatewayClient.State.Connecting,
|
GatewayClient.State.Connecting,
|
||||||
GatewayClient.State.Reconnecting,
|
GatewayClient.State.Reconnecting,
|
||||||
|
|||||||
@@ -44,15 +44,15 @@ fun ConnectScreen(
|
|||||||
) {
|
) {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
// Default is a cleartext (non-TLS) URL because the typical gateway is on
|
// Default is a cleartext (non-TLS) URL because the typical gateway is on
|
||||||
// the LAN. A TLS gateway is reached by entering a secure (wss) URL instead.
|
// the LAN. A TLS gateway is reached by entering a secure (https) URL instead.
|
||||||
// pi-lens-ignore: opengrep:javascript.lang.security.detect-insecure-websocket.detect-insecure-websocket
|
var url by remember { mutableStateOf(prefillUrl.ifBlank { "http://" }) }
|
||||||
var url by remember { mutableStateOf(prefillUrl.ifBlank { "ws://" }) }
|
|
||||||
var token by remember { mutableStateOf(prefillToken) }
|
var token by remember { mutableStateOf(prefillToken) }
|
||||||
var busy by remember { mutableStateOf(false) }
|
var busy by remember { mutableStateOf(false) }
|
||||||
var error by remember { mutableStateOf(initialError) }
|
var error by remember { mutableStateOf(initialError) }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.verticalScroll(rememberScrollState())
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(24.dp),
|
.padding(24.dp),
|
||||||
@@ -69,7 +69,8 @@ fun ConnectScreen(
|
|||||||
Spacer(modifier = Modifier.height(32.dp))
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier =
|
||||||
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(RoundedCornerShape(16.dp))
|
.clip(RoundedCornerShape(16.dp))
|
||||||
.background(IrisColors.surface)
|
.background(IrisColors.surface)
|
||||||
@@ -79,9 +80,8 @@ fun ConnectScreen(
|
|||||||
value = url,
|
value = url,
|
||||||
onValueChange = { url = it },
|
onValueChange = { url = it },
|
||||||
label = { Text("Server URL") },
|
label = { Text("Server URL") },
|
||||||
// Example LAN URL; wss:// works too for TLS gateways.
|
// Example LAN URL; https:// works too for TLS gateways.
|
||||||
// pi-lens-ignore: opengrep:javascript.lang.security.detect-insecure-websocket.detect-insecure-websocket
|
placeholder = { Text("http://192.168.1.10:8791") },
|
||||||
placeholder = { Text("ws://192.168.1.10:8790/ws") },
|
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -136,4 +136,3 @@ fun ConnectScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
package iris.net
|
||||||
|
|
||||||
|
import iris.protocol.Frame
|
||||||
|
import iris.protocol.IrisJson
|
||||||
|
import iris.protocol.MessagePayload
|
||||||
|
import iris.protocol.TYPE_MESSAGE
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
/**
|
||||||
|
* docs/19: unit tests for the HTTP fallback leg client.
|
||||||
|
*
|
||||||
|
* The SSE line parser is the trickiest pure logic (event/id/data fields,
|
||||||
|
* comments, multi-line data, Last-Event-ID bookkeeping), so it is factored
|
||||||
|
* into [SseParser] and tested directly. The WS->HTTP URL derivation is a
|
||||||
|
* pure function (unit-tested). Full transport behavior (health, POST, SSE
|
||||||
|
* catch-up, long-poll, delivery counting) is covered by the gateway-side
|
||||||
|
* Python tests (hermes-agent/tests/gateway/test_android_http.py).
|
||||||
|
*/
|
||||||
|
class HttpGatewayTest {
|
||||||
|
// ── URL derivation ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deriveHttpUrlReplacesSchemeAndPort() {
|
||||||
|
assertEquals("http://192.168.1.10:8791", HttpGateway.deriveHttpUrl("ws://192.168.1.10:8790/ws"))
|
||||||
|
assertEquals("http://127.0.0.1:8791", HttpGateway.deriveHttpUrl("ws://127.0.0.1:8790/ws"))
|
||||||
|
assertEquals("https://gw.example.com:8791", HttpGateway.deriveHttpUrl("wss://gw.example.com:8790/ws"))
|
||||||
|
// No explicit port on the WS URL: still the HTTP leg's default port.
|
||||||
|
assertEquals("http://gw.example.com:8791", HttpGateway.deriveHttpUrl("ws://gw.example.com/ws"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deriveHttpUrlPassesThroughHttpUrls() {
|
||||||
|
assertEquals("http://1.2.3.4:9000", HttpGateway.deriveHttpUrl("http://1.2.3.4:9000"))
|
||||||
|
assertEquals("https://a.b", HttpGateway.deriveHttpUrl("https://a.b"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SSE parser ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sseParsesHelloAndFrames() {
|
||||||
|
val parser = SseParser()
|
||||||
|
val hello = """{"v":1,"type":"hello.ack","payload":{"sync_cursor":5}}"""
|
||||||
|
val frame = """{"v":1,"type":"$TYPE_MESSAGE","payload":{"text":"hi"}}"""
|
||||||
|
val lines =
|
||||||
|
listOf(
|
||||||
|
"event: hello",
|
||||||
|
"data: $hello",
|
||||||
|
"",
|
||||||
|
"id: 7",
|
||||||
|
"event: frame",
|
||||||
|
"data: $frame",
|
||||||
|
"",
|
||||||
|
)
|
||||||
|
var helloCount = 0
|
||||||
|
var frameCount = 0
|
||||||
|
var lastCursor: Long? = null
|
||||||
|
for (line in lines) {
|
||||||
|
parser.feed(
|
||||||
|
line,
|
||||||
|
onHello = { helloCount++ },
|
||||||
|
onFrame = { frameCount++ },
|
||||||
|
onCursor = { lastCursor = it },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
assertEquals(1, helloCount)
|
||||||
|
assertEquals(1, frameCount)
|
||||||
|
assertEquals(7L, lastCursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sseIgnoresHeartbeatComments() {
|
||||||
|
val parser = SseParser()
|
||||||
|
var frames = 0
|
||||||
|
parser.feed(": hb", onHello = {}, onFrame = { frames++ }, onCursor = {})
|
||||||
|
parser.feed("", onHello = {}, onFrame = { frames++ }, onCursor = {})
|
||||||
|
assertEquals(0, frames)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sseMultiLineDataJoinsWithNewline() {
|
||||||
|
val parser = SseParser()
|
||||||
|
var frame: Frame? = null
|
||||||
|
// SSE data may span multiple `data:` lines; the parser must join
|
||||||
|
// them with "\n" so the reassembled JSON still decodes. Split at a
|
||||||
|
// legal JSON whitespace point (right after a comma, between tokens).
|
||||||
|
val full =
|
||||||
|
"""{"v":1,"type":"$TYPE_MESSAGE","payload":{"message_id":"m1","role":"assistant","text":"a\nb"}}"""
|
||||||
|
val cut = full.indexOf("\"m1\",") + "\"m1\",".length
|
||||||
|
val l1 = full.substring(0, cut)
|
||||||
|
val l2 = full.substring(cut)
|
||||||
|
parser.feed("event: frame", onHello = {}, onFrame = {}, onCursor = {})
|
||||||
|
parser.feed("data: $l1", onHello = {}, onFrame = {}, onCursor = {})
|
||||||
|
parser.feed("data: $l2", onHello = {}, onFrame = {}, onCursor = {})
|
||||||
|
parser.feed("", onHello = {}, onFrame = { frame = it }, onCursor = {})
|
||||||
|
assertEquals("a\nb", frame?.payloadAsText())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sseTracksLastEventIdAcrossFrames() {
|
||||||
|
val parser = SseParser()
|
||||||
|
val ids = mutableListOf<Long>()
|
||||||
|
val frame = """{"v":1,"type":"$TYPE_MESSAGE","payload":{}}"""
|
||||||
|
for (id in listOf(1L, 2L, 3L)) {
|
||||||
|
parser.feed("id: $id", onHello = {}, onFrame = {}, onCursor = { ids.add(it) })
|
||||||
|
parser.feed("event: frame", onHello = {}, onFrame = {}, onCursor = {})
|
||||||
|
parser.feed("data: $frame", onHello = {}, onFrame = {}, onCursor = {})
|
||||||
|
parser.feed("", onHello = {}, onFrame = {}, onCursor = {})
|
||||||
|
}
|
||||||
|
assertEquals(listOf(1L, 2L, 3L), ids)
|
||||||
|
assertEquals(3L, parser.lastEventId)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sseMalformedLineDoesNotThrow() {
|
||||||
|
val parser = SseParser()
|
||||||
|
parser.feed("garbage without colon", onHello = {}, onFrame = {}, onCursor = {})
|
||||||
|
parser.feed("id: notanumber", onHello = {}, onFrame = {}, onCursor = {})
|
||||||
|
parser.feed("data: {not json", onHello = {}, onFrame = {}, onCursor = {})
|
||||||
|
parser.feed("", onHello = {}, onFrame = {}, onCursor = {})
|
||||||
|
// No exception, no frame emitted for the malformed data.
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun sseDecodesFramePayload() {
|
||||||
|
val parser = SseParser()
|
||||||
|
var text: String? = null
|
||||||
|
val frame =
|
||||||
|
"""{"v":1,"type":"$TYPE_MESSAGE","payload":{"message_id":"m1","role":"assistant","text":"hello"}}"""
|
||||||
|
parser.feed("event: frame", onHello = {}, onFrame = {}, onCursor = {})
|
||||||
|
parser.feed("data: $frame", onHello = {}, onFrame = {}, onCursor = {})
|
||||||
|
parser.feed("", onHello = {}, onFrame = { f -> text = f.payloadAsText() }, onCursor = {})
|
||||||
|
assertEquals("hello", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SSE line parser (pure; shared by the live reader + tests) ─────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Incremental SSE parser (docs/19 §19.5). Feed raw lines (without
|
||||||
|
* terminators); a blank line dispatches the buffered event. [lastEventId]
|
||||||
|
* is the most recent `id:` field (the outbox cursor) — the resume point for
|
||||||
|
* a reconnect.
|
||||||
|
*/
|
||||||
|
class SseParser {
|
||||||
|
var lastEventId: Long? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var eventId: String? = null
|
||||||
|
private val dataLines = mutableListOf<String>()
|
||||||
|
|
||||||
|
fun feed(
|
||||||
|
line: String,
|
||||||
|
onHello: (String) -> Unit,
|
||||||
|
onFrame: (Frame) -> Unit,
|
||||||
|
onCursor: (Long) -> Unit,
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
line.isEmpty() -> {
|
||||||
|
if (dataLines.isNotEmpty()) {
|
||||||
|
val data = dataLines.joinToString("\n")
|
||||||
|
val frame =
|
||||||
|
try {
|
||||||
|
IrisJson.instance.decodeFromString(Frame.serializer(), data)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (frame != null) {
|
||||||
|
when (eventId) {
|
||||||
|
"hello" -> onHello(data)
|
||||||
|
else -> onFrame(frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eventId = null
|
||||||
|
dataLines.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
line.startsWith(":") -> {
|
||||||
|
Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
// comment / heartbeat
|
||||||
|
line.startsWith("id:") -> {
|
||||||
|
val id = line.removePrefix("id:").trim().toLongOrNull()
|
||||||
|
if (id != null) {
|
||||||
|
lastEventId = id
|
||||||
|
onCursor(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
line.startsWith("event:") -> {
|
||||||
|
eventId = line.removePrefix("event:").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
line.startsWith("data:") -> {
|
||||||
|
dataLines.add(line.removePrefix("data:").removePrefix(" "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun Frame.payloadAsText(): String? = payloadAs<MessagePayload>()?.text
|
||||||
+30
-26
@@ -1,12 +1,15 @@
|
|||||||
# 07 — Media (upload, download, playback)
|
# 07 — Media (upload, download, playback)
|
||||||
|
|
||||||
Media travels **over the WebSocket** as chunked binary frames (decision: no
|
Media travels **over HTTP** (`POST /v1/media` for upload,
|
||||||
separate HTTP server; keeps the plugin to `websockets` only). Both directions
|
`GET /v1/media/{id}` for pull; see `19-http-fallback-transport.md` §19.15).
|
||||||
use the same chunking.
|
HTTP is the only transport — the WebSocket leg (chunked binary frames) was
|
||||||
|
removed entirely. The contracts below (kinds, sha256, re-sniffing, delivery
|
||||||
|
validation) apply to both directions.
|
||||||
|
|
||||||
## 7.1 Kinds & MIME
|
## 7.1 Kinds & MIME
|
||||||
|
|
||||||
`kind` ∈ `image | audio | video | document | voice`.
|
`kind` ∈ `image | audio | video | document | voice`.
|
||||||
|
|
||||||
- `image` — `image/*` (jpg/png/webp/gif/heic).
|
- `image` — `image/*` (jpg/png/webp/gif/heic).
|
||||||
- `audio` — `audio/*` (mp3/m4a/ogg/…) — music.
|
- `audio` — `audio/*` (mp3/m4a/ogg/…) — music.
|
||||||
- `video` — `video/*` (mp4/webm/mov).
|
- `video` — `video/*` (mp4/webm/mov).
|
||||||
@@ -20,32 +23,36 @@ receipt (don't trust the client) using hermes helpers
|
|||||||
## 7.2 Inbound (app → agent) — `media.upload`
|
## 7.2 Inbound (app → agent) — `media.upload`
|
||||||
|
|
||||||
**Flow:**
|
**Flow:**
|
||||||
1. App picks a file (SAF) → reads size + MIME.
|
|
||||||
2. App sends `media.upload.start {media_ref, kind, mime, size, filename}`.
|
1. App picks a file (SAF) → reads size + MIME, computes sha256.
|
||||||
3. App streams the file as **binary WS frames** (e.g. 256 KiB chunks).
|
2. App `POST /v1/media` with the raw file body; metadata in
|
||||||
4. App sends `media.upload.end {media_ref, sha256}`.
|
`X-Iris-Media-*` headers (`media_ref`, `kind`, `mime`, `filename`,
|
||||||
5. Plugin verifies size ≤ `max_upload_bytes` and sha256, writes to the media
|
`sha256`).
|
||||||
|
3. Plugin verifies size ≤ `max_upload_bytes` and sha256, writes to the media
|
||||||
cache via hermes `cache_*_from_bytes`:
|
cache via hermes `cache_*_from_bytes`:
|
||||||
- image → `cache_image_from_bytes`
|
- image → `cache_image_from_bytes`
|
||||||
- audio/voice → `cache_audio_from_bytes`
|
- audio/voice → `cache_audio_from_bytes`
|
||||||
- video → `cache_video_from_bytes`
|
- video → `cache_video_from_bytes`
|
||||||
- document → `cache_document_from_bytes`
|
- document → `cache_document_from_bytes`
|
||||||
→ returns a local path.
|
→ returns a local path.
|
||||||
5b. Plugin replies `media.upload.ack {ok, media_ref}` (failures use `error`).
|
4. Plugin replies `media.upload.ack {ok, media_ref}` (failures use `error`).
|
||||||
6. The path is attached to the next `message.send` via `media_refs`, becoming
|
5. The path is attached to the next `message.send` via `media_refs`, becoming
|
||||||
`MessageEvent.media_urls` + `media_types`
|
`MessageEvent.media_urls` + `media_types`
|
||||||
(`gateway/platforms/base.py:2337`). The agent's vision/audio tools can then
|
(`gateway/platforms/base.py:2337`). The agent's vision/audio tools can then
|
||||||
read the file.
|
read the file.
|
||||||
|
|
||||||
**Limits:** `get_inbound_media_max_bytes()` / `validate_inbound_media_size`
|
**Limits:** `get_inbound_media_max_bytes()` / `validate_inbound_media_size`
|
||||||
(`base.py:758/779`) enforce the cap; over-limit → `error {code:"media_too_large"}`.
|
(`base.py:758/779`) enforce the cap; over-limit → 413 +
|
||||||
|
`error {code:"media_too_large"}`. (The 1 MiB `MAX_BODY_BYTES` cap applies to
|
||||||
|
JSON *frame* bodies only, not media uploads.)
|
||||||
|
|
||||||
**Backpressure:** large uploads use the WS flow control; the plugin reads
|
**Single-shot:** no chunking/resumability — HTTP carries the body; single-user
|
||||||
binary frames into a temp file (not memory) to bound RAM.
|
scale makes a one-shot upload sufficient.
|
||||||
|
|
||||||
## 7.3 Outbound (agent → app) — `media.offer` / `media.pull`
|
## 7.3 Outbound (agent → app) — `media.offer` / `media.pull`
|
||||||
|
|
||||||
**Flow:**
|
**Flow:**
|
||||||
|
|
||||||
1. Agent produces/references media (e.g. generates an image, or replies with a
|
1. Agent produces/references media (e.g. generates an image, or replies with a
|
||||||
`MEDIA:` tag / image URL). hermes base `extract_media` / `extract_images`
|
`MEDIA:` tag / image URL). hermes base `extract_media` / `extract_images`
|
||||||
(`base.py:4439/4884`) pull these out and call the adapter's
|
(`base.py:4439/4884`) pull these out and call the adapter's
|
||||||
@@ -54,14 +61,13 @@ binary frames into a temp file (not memory) to bound RAM.
|
|||||||
2. Adapter stages the file in the media cache, mints a `media_id`, and emits
|
2. Adapter stages the file in the media cache, mints a `media_id`, and emits
|
||||||
`media.offer {media_id, kind, mime, size, filename}` (inside/with the
|
`media.offer {media_id, kind, mime, size, filename}` (inside/with the
|
||||||
`message` frame's `media[]`).
|
`message` frame's `media[]`).
|
||||||
3. App sends `media.pull {media_id}`.
|
3. App `GET /v1/media/{id}` — the full file body.
|
||||||
4. Plugin streams the file as **binary WS frames**; ends with
|
4. App writes to its cache dir and hands the path to the player/viewer.
|
||||||
`media.pull.end {ok:true}`.
|
|
||||||
5. App writes to its cache dir and hands the path to the player/viewer.
|
|
||||||
|
|
||||||
**Security:** `validate_media_delivery_path` (`base.py:1684`) + the media
|
**Security:** `validate_media_delivery_path` (`base.py:1684`) + the media
|
||||||
delivery root/recency/denied-path checks (`base.py:1312-1480`) ensure the plugin
|
delivery root/recency/denied-path checks (`base.py:1312-1480`) ensure the plugin
|
||||||
only serves files hermes is allowed to deliver (no arbitrary file read).
|
only serves files hermes is allowed to deliver (no arbitrary file read). The
|
||||||
|
delivery-path check is re-run **at pull time**, not just at offer time.
|
||||||
|
|
||||||
## 7.4 Live playback (AI-sent music/video)
|
## 7.4 Live playback (AI-sent music/video)
|
||||||
|
|
||||||
@@ -79,14 +85,12 @@ only serves files hermes is allowed to deliver (no arbitrary file read).
|
|||||||
a desktop backend (see `11-desktop-app.md`): a `libmpv`/`mpv`-backed surface
|
a desktop backend (see `11-desktop-app.md`): a `libmpv`/`mpv`-backed surface
|
||||||
or a WebView fallback for video, and a desktop audio player for music.
|
or a WebView fallback for video, and a desktop audio player for music.
|
||||||
|
|
||||||
## 7.5 Chunking parameters
|
## 7.5 Integrity
|
||||||
|
|
||||||
- Chunk size: **256 KiB** (tunable).
|
- Upload: `sha256` (precomputed by the app, sent in `X-Iris-Media-Sha256`)
|
||||||
- Binary frames carry raw bytes only; framing/metadata is in the JSON header +
|
is verified by the plugin; mismatch → `media.upload.ack {ok:false}`.
|
||||||
end frames.
|
- Pull: the app checks the received size against the offered `size`.
|
||||||
- Reassembly is ordered (WS preserves order); a gap/corruption → abort +
|
- A failed transfer → retry the whole upload (single-shot, no resume).
|
||||||
`error {code:"internal"}` + retry the whole transfer.
|
|
||||||
- `sha256` in `media.upload.end` / a size check on pull verify integrity.
|
|
||||||
|
|
||||||
## 7.6 App-side storage
|
## 7.6 App-side storage
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ security principal (the token is).
|
|||||||
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok): terminate TLS
|
- **Reverse proxy / tunnel** (Caddy, Cloudflare Tunnel, ngrok): terminate TLS
|
||||||
at the edge, forward WS to `127.0.0.1:8790`.
|
at the edge, forward WS to `127.0.0.1:8790`.
|
||||||
- **Public bind** (`0.0.0.0`) + WSS + strong token — last resort.
|
- **Public bind** (`0.0.0.0`) + WSS + strong token — last resort.
|
||||||
|
- **HTTP fallback leg (docs/19):** the gateway also serves the same frames
|
||||||
|
over plain HTTP (`ANDROID_HTTP_PORT`, default 8791) for the app's
|
||||||
|
fallback transport. It is a *second door with the same lock*: the same
|
||||||
|
Bearer token (constant-time `verify_token`) + the same device allowlist
|
||||||
|
(`X-Iris-Device`), the same 64 KiB body cap and per-device rate limit as
|
||||||
|
the WS. Optional TLS via `ANDROID_HTTP_CERT` / `ANDROID_HTTP_KEY`.
|
||||||
|
`GET /v1/health` is unauthenticated by design (liveness only — it must
|
||||||
|
not reflect tokens, device ids, or versions).
|
||||||
- The app stores the server URL + (for self-signed) the pinned cert fingerprint
|
- The app stores the server URL + (for self-signed) the pinned cert fingerprint
|
||||||
in secure storage.
|
in secure storage.
|
||||||
|
|
||||||
|
|||||||
@@ -131,6 +131,15 @@ adb logcat -d > /tmp/logcat.txt
|
|||||||
entries drop out); tap a row → the command is sent and the drawer closes;
|
entries drop out); tap a row → the command is sent and the drawer closes;
|
||||||
type an unknown command → the drawer closes (the raw text can still be
|
type an unknown command → the drawer closes (the raw text can still be
|
||||||
sent; hermes answers with its unknown-command reply).
|
sent; hermes answers with its unknown-command reply).
|
||||||
|
15. **HTTP fallback (docs/19):** with the WS port unreachable (e.g. the
|
||||||
|
gateway bound WS to a dead port, or a firewall dropping 8790 but not
|
||||||
|
8791), the app stays sendable: the status pill shows "connected · http"
|
||||||
|
(green), a sent message echoes back within ~1 s and the agent reply
|
||||||
|
streams in over SSE; the attach button is disabled (media needs the
|
||||||
|
live WS). When the WS comes back the pill returns to "connected" and
|
||||||
|
media works again. Automated: `e2e.py` scenario 13 (health +
|
||||||
|
`POST /v1/frame` + SSE turn, user-echo < 1.5 s) and
|
||||||
|
`ws_probe.py --http` (same assertion flags as the WS leg).
|
||||||
|
|
||||||
## 13.5 Debugging tips
|
## 13.5 Debugging tips
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,405 @@
|
|||||||
|
# 19 — HTTP Fallback Transport (the "HTTP leg")
|
||||||
|
|
||||||
|
A second, **short-lived-connection** transport next to the WebSocket: the same
|
||||||
|
JSON frames, the same outbox/cursor, the same token — served over plain HTTP
|
||||||
|
by the gateway. When the WS is down (flaky network, NAT timeout, app just
|
||||||
|
relaunched), the app **sends over `POST` and receives over SSE** instead of
|
||||||
|
waiting 2–20 s for a WS redial.
|
||||||
|
|
||||||
|
Status: **implemented — and now the ONLY transport.** The WebSocket leg has
|
||||||
|
been removed entirely from the codebase (gateway `ws_server.py` deleted;
|
||||||
|
WS-only frames `hello`/`ping`/`pong`/`media.upload.*`/`media.pull*` dropped
|
||||||
|
from `protocol.py` and `Protocol.kt`; `GatewayClient` is HTTP-only with no
|
||||||
|
`HttpFallback` state — it *is* the connected state). HTTP is the primary and
|
||||||
|
sole transport: v1 (JSON frames over POST/SSE/long-poll) and v2 (media over
|
||||||
|
`POST /v1/media` + `GET /v1/media/{id}`, §19.15). Gateway leg:
|
||||||
|
`gateway-plugin/http_server.py` (+ `dispatch.py` for frame dispatch);
|
||||||
|
app leg: `app/shared/src/commonMain/kotlin/iris/net/HttpGateway.kt` +
|
||||||
|
`GatewayClient.kt`. Legacy `ws(s)://` URLs entered by users are still
|
||||||
|
accepted and rewritten to `http(s)://` (`HttpGateway.deriveHttpUrl`).
|
||||||
|
Complements — does not replace — `04-wire-protocol.md` (frames),
|
||||||
|
`08-push.md` (outbox/sync/push), and `09-pairing-security.md` (auth model).
|
||||||
|
|
||||||
|
> **Note:** the rest of this document describes the original design, in
|
||||||
|
> which HTTP was a *fallback* next to a WS primary. That framing is
|
||||||
|
> historical; where it says "WS (primary)" / "HTTP (fallback)", read
|
||||||
|
> "HTTP (the only transport)".
|
||||||
|
|
||||||
|
## 19.1 Problem
|
||||||
|
|
||||||
|
Today the WS is the *only* transport, and the app hard-gates sending on a live
|
||||||
|
socket (`ChatScreen.doSend()` no-ops unless `State.Connected`;
|
||||||
|
`GatewayClient.sendMessage()` drops when `socket == null`). Consequences:
|
||||||
|
|
||||||
|
- **App killed → reopened:** full cold dial (TCP + TLS + `hello`/`hello.ack`,
|
||||||
|
15 s dial timeout) before the user can send. On a flaky network the first
|
||||||
|
dial often fails → backoff → second dial. Observed: 2–20 s of "can't send".
|
||||||
|
- **Long-lived WS is the most fragile connection type on mobile:** idle
|
||||||
|
sockets expire in router/CGNAT NAT tables, die on WiFi↔cellular handover,
|
||||||
|
and are killed aggressively by OEM power management (MIUI on the test
|
||||||
|
device). There is no foreground service holding the WS.
|
||||||
|
- **Stale detection is slow:** 20 s ping interval, 60 s reap — a dead-but-
|
||||||
|
unclosed socket can sit for up to a minute before redial.
|
||||||
|
|
||||||
|
## 19.2 Why HTTP (and why not the alternatives)
|
||||||
|
|
||||||
|
Short-lived HTTP requests are dramatically more resilient on mobile networks
|
||||||
|
than a long-lived socket: no NAT table entry to expire, no proxy idle-kill,
|
||||||
|
each request is a fresh connection (fast with TLS resumption), and they work
|
||||||
|
through the restrictive proxies that mangle WebSockets. Sending a message
|
||||||
|
becomes a single `POST` that completes in well under a second on a LAN —
|
||||||
|
independent of whether the WS is up.
|
||||||
|
|
||||||
|
Alternatives considered and rejected (research, 2026-08):
|
||||||
|
|
||||||
|
| Option | Verdict |
|
||||||
|
| --- | --- |
|
||||||
|
| **MQTT broker** (QoS 1, persistent sessions) | Best protocol for flaky links, but new infra (broker process) + new Python dep (`paho-mqtt`, breaks the zero-new-deps rule) + new Kotlin dep + frame↔topic bridge. Overkill for a 1-user agent. |
|
||||||
|
| **ntfy as the send path** (app publishes to a topic the gateway subscribes to) | Adds a third party to the critical send path; public ntfy.sh is already known-flaky. Not worth it. |
|
||||||
|
| **WebTransport / QUIC** | The *real* fix for handover flakiness (connection migration), but no OkHttp support and `aioquic` is a new Python dep. Future option if this doc's approach is still not enough. |
|
||||||
|
| **gRPC** | New deps both sides; no advantage over WS+SSE here. |
|
||||||
|
| **Inverted connection** (app runs a local HTTP server, gateway pushes to the phone) | LAN-only, breaks on cellular/remote, security mess. Rejected. |
|
||||||
|
| **Matrix / full chat server** | Massive overkill for a personal agent. |
|
||||||
|
|
||||||
|
**Zero new Python dependencies is preserved:** the HTTP leg is stdlib
|
||||||
|
`http.server` (a `ThreadingHTTPServer` in a daemon thread) bridged into the
|
||||||
|
gateway's asyncio loop. The app side uses the OkHttp it already depends on
|
||||||
|
(hand-rolled SSE reader — the format is trivial; `okhttp-eventsource` is an
|
||||||
|
acceptable alternative if preferred).
|
||||||
|
|
||||||
|
## 19.3 Shape
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────── hermes gateway process ───────────────────────┐
|
||||||
|
│ AndroidAdapter │
|
||||||
|
│ │ frames (same protocol.Frame objects) │
|
||||||
|
│ ▼ │
|
||||||
|
│ _broadcast_or_log ──► outbox.append(cursor) ──► push (if no live) │
|
||||||
|
│ │ │ │
|
||||||
|
│ ▼ ▼ │
|
||||||
|
│ WsServer (asyncio, :8790) HttpServer (stdlib thread, :8791) │
|
||||||
|
│ primary: full protocol fallback: POST /v1/frame, │
|
||||||
|
│ incl. binary media GET /v1/events (SSE), /v1/poll │
|
||||||
|
└───────────────┬──────────────────────────────┬────────────────────────┘
|
||||||
|
│ WS (primary) │ HTTP (fallback)
|
||||||
|
┌─────────┴──────────────────────────────┴────────┐
|
||||||
|
│ APP: transport state machine │
|
||||||
|
│ WS up → WS only (media works, lowest latency)│
|
||||||
|
│ WS down → send via POST, receive via SSE/poll │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**v1 scope**
|
||||||
|
|
||||||
|
| Over HTTP | WS-only |
|
||||||
|
| --- | --- |
|
||||||
|
| All JSON request frames (`message.send`, `search`, `channel.*`, `commands.catalog`, `agent.stop`/`agent.steer`, …) via one generic endpoint | — |
|
||||||
|
| All event/response frames via SSE (or long-poll) | — |
|
||||||
|
| `sync` catch-up (same outbox, same cursor) | — |
|
||||||
|
| Media upload + pull (`POST /v1/media`, `GET /v1/media/{id}`, v2 — §19.15) | — |
|
||||||
|
|
||||||
|
With v2 the HTTP leg is feature-complete: media no longer needs the WS
|
||||||
|
(the composer's attach button is enabled in `HTTP_FALLBACK` too). The WS
|
||||||
|
binary media frames remain accepted for WS clients, but the app routes media
|
||||||
|
over HTTP whenever the WS is down.
|
||||||
|
|
||||||
|
## 19.4 Gateway: `gateway-plugin/http_server.py`
|
||||||
|
|
||||||
|
New module, started/stopped by `AndroidAdapter.connect()`/`disconnect()` next
|
||||||
|
to the WS server.
|
||||||
|
|
||||||
|
- **Server:** `http.server.ThreadingHTTPServer` + `BaseHTTPRequestHandler`,
|
||||||
|
run in a **daemon thread** (one thread per connection — fine at single-user
|
||||||
|
scale). The handler thread never touches adapter state directly; it bridges
|
||||||
|
into the gateway's asyncio loop with
|
||||||
|
`asyncio.run_coroutine_threadsafe(coro, loop)` (the loop is captured at
|
||||||
|
start, same loop the WS server runs on).
|
||||||
|
- **Config:** `ANDROID_HTTP_PORT` (default **8791**), same bind host as the WS
|
||||||
|
(`ANDROID_WS_HOST`). Optional TLS via `ANDROID_HTTP_CERT`/`ANDROID_HTTP_KEY`
|
||||||
|
(`ssl.SSLContext` on the server) — same posture as the WS: plaintext on a
|
||||||
|
trusted LAN by default, TLS for remote/Tailscale setups.
|
||||||
|
- **Bind failure is NON-fatal** (unlike the WS): log a warning, disable the
|
||||||
|
HTTP leg, show it in the inspector. The plugin must keep working WS-only.
|
||||||
|
- Port-conflict lock: same flock pattern the WS uses (`host:port` key).
|
||||||
|
|
||||||
|
### Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Auth | Purpose |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET /v1/health` | none | Liveness probe → `200 {"ok": true}`. Leaks nothing (no token echo, no device info). The app races this against the WS dial at startup. |
|
||||||
|
| `POST /v1/frame` | Bearer token | Accept **any** JSON frame the WS accepts (except binary media). Body = one frame envelope (`04-wire-protocol.md`). Dispatched through the *same* adapter handlers as WS (`on_message_send`, `on_search`, …). |
|
||||||
|
| `GET /v1/events?cursor=N` | Bearer token | **SSE** stream: catch-up from the outbox, then live frames (§19.5). |
|
||||||
|
| `GET /v1/poll?cursor=N` | Bearer token | **Long-poll** fallback where SSE is blocked (§19.6). |
|
||||||
|
| `POST /v1/media` | Bearer token | **Media upload** (v2, §19.15): whole file as the body, metadata in `X-Iris-Media-*` headers. |
|
||||||
|
| `GET /v1/media/{media_id}` | Bearer token | **Media pull** (v2, §19.15): streams an outbound offer as the response body. |
|
||||||
|
|
||||||
|
### Auth & limits
|
||||||
|
|
||||||
|
- `Authorization: Bearer <token>`; verified with the existing constant-time
|
||||||
|
`verify_token()`; `401` on failure. Device identity via `X-Iris-Device`
|
||||||
|
header (same `device_id` the app uses for `hello`; same allowlist check).
|
||||||
|
- Request body cap **64 KiB**, `Content-Type: application/json` enforced
|
||||||
|
(frames are small; media never travels here in v1).
|
||||||
|
- Rate limit: token bucket per device, same parameters as the WS inbound
|
||||||
|
limit (`INBOUND_RATE_PER_S` / `INBOUND_BURST`); `429` on exceed.
|
||||||
|
- No CORS headers (app clients only); unknown paths → `404`.
|
||||||
|
|
||||||
|
## 19.5 SSE stream design (`GET /v1/events`)
|
||||||
|
|
||||||
|
Wire format (standard SSE, three fields):
|
||||||
|
|
||||||
|
```
|
||||||
|
id: 1043
|
||||||
|
event: frame
|
||||||
|
data: {"v":1,"type":"message","chat_id":"android:default",...}
|
||||||
|
|
||||||
|
: hb ← comment heartbeat every 15 s (keeps proxies alive)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`id` = outbox cursor.** This is what makes resume trivial: on reconnect
|
||||||
|
the client sends `Last-Event-ID` (or `?cursor=`) and the server replays
|
||||||
|
`outbox.replay(cursor)` — exactly the `sync` semantics, no new machinery.
|
||||||
|
- **Stream open sequence:**
|
||||||
|
1. Replay outbox rows with `cursor > N` (bounded by the existing
|
||||||
|
`_REPLAY_LIMIT`), each as an `event: frame` with its `id`.
|
||||||
|
2. One `event: hello` carrying the `hello.ack` payload
|
||||||
|
(`server_caps`, `sync_cursor`, `last_pushed_cursor`, `channels`) — the
|
||||||
|
HTTP equivalent of pairing-ack; the app treats it like `hello.ack`.
|
||||||
|
3. Live frames as they are produced.
|
||||||
|
- **Live fan-out hook:** in `adapter._broadcast_or_log`, after
|
||||||
|
`outbox.append()` returns the cursor, push `(cursor, frame_json)` into every
|
||||||
|
live HTTP subscriber's queue. The direct `status` broadcasts
|
||||||
|
(`ws_server.broadcast(protocol.status(...))`) get a second fan-out call
|
||||||
|
with `cursor = None` (SSE event without `id`).
|
||||||
|
- **Thread model:** each SSE connection owns its handler thread, which blocks
|
||||||
|
on a cross-thread `queue.get()` (via `run_coroutine_threadsafe`, 30 s
|
||||||
|
timeout → write `: hb` and loop) and writes to `wfile` + `flush()`.
|
||||||
|
- **Backpressure:** bounded queue (256). A subscriber that can't keep up is
|
||||||
|
dropped; the client reconnects with `Last-Event-ID` and catches up from the
|
||||||
|
outbox. Single-user scale makes this a non-event in practice.
|
||||||
|
- **App-side reader:** hand-rolled over OkHttp's streaming `ResponseBody`
|
||||||
|
(read lines; `id:` / `event:` / `data:`; blank line = dispatch). ~100 lines,
|
||||||
|
no new dependency. Reconnect with exponential backoff + `Last-Event-ID`.
|
||||||
|
|
||||||
|
## 19.6 Long-poll fallback (`GET /v1/poll`)
|
||||||
|
|
||||||
|
For networks/proxies that buffer or kill SSE:
|
||||||
|
|
||||||
|
- `GET /v1/poll?cursor=N` → server holds the request (asyncio waiter on the
|
||||||
|
subscriber queue) until a frame with `cursor > N` exists or **25 s** pass.
|
||||||
|
- Response: `200 {"cursor": <new high-water>, "frames": [ ... ]}` (frames may
|
||||||
|
be empty on timeout; the app immediately re-polls with the new cursor).
|
||||||
|
- The app switches to long-poll automatically after **two consecutive SSE
|
||||||
|
open failures**, and back to SSE on the next full (re)connect.
|
||||||
|
|
||||||
|
## 19.7 Request/response over HTTP
|
||||||
|
|
||||||
|
`POST /v1/frame` is accept-and-ack:
|
||||||
|
|
||||||
|
- `202 {"ok": true}` — frame accepted and dispatched.
|
||||||
|
- `4xx` with an **error frame as the JSON body** for validation rejections
|
||||||
|
(empty message, automation-channel read-only, rate limit → `429`, bad JSON
|
||||||
|
→ `400`). These are the same `error` frames the WS path sends via
|
||||||
|
`send_to`; over HTTP they double as the HTTP response.
|
||||||
|
- **Async responses** (user echo, `search` results, `channel.list`, the agent
|
||||||
|
reply, streaming updates) arrive on the **event stream** carrying the same
|
||||||
|
`id` — the app's existing request-id correlation works unchanged.
|
||||||
|
- Consequence: `send_to(device_id, …)` error replies for HTTP-originated
|
||||||
|
requests are instead **broadcast** (single-user model; the SSE stream
|
||||||
|
delivers them). The dispatch refactor must tag the origin so WS-originated
|
||||||
|
requests keep point-to-point errors.
|
||||||
|
|
||||||
|
## 19.8 Delivery counting & push interaction (critical)
|
||||||
|
|
||||||
|
`_broadcast_or_log` fires push when `delivered == 0`. With the HTTP leg, a
|
||||||
|
device reading SSE **is** a live subscriber:
|
||||||
|
|
||||||
|
```python
|
||||||
|
delivered = await self._ws_server.broadcast(frame)
|
||||||
|
delivered += await self._http_server.fanout(frame, cursor) # live SSE/poll subs
|
||||||
|
...
|
||||||
|
if delivered == 0: # → outbox + push (unchanged)
|
||||||
|
```
|
||||||
|
|
||||||
|
If this is forgotten, every message would push *and* stream to a device that
|
||||||
|
is already receiving it. Related bookkeeping:
|
||||||
|
|
||||||
|
- `has_devices()` / `status` must count HTTP subscribers as connected devices
|
||||||
|
(mark the device's transport `ws` | `http` in the connection registry).
|
||||||
|
- `last_pushed_cursor` / notification dedupe (`08-push.md` §8.8) is
|
||||||
|
unchanged — SSE-replayed frames carry the same `cursor` envelope as
|
||||||
|
`sync`-replayed ones, so the app's existing dedupe applies.
|
||||||
|
|
||||||
|
## 19.9 App side
|
||||||
|
|
||||||
|
New `iris/net/HttpGateway.kt` (OkHttp) + a transport state machine inside
|
||||||
|
`GatewayClient` (or a thin `Transport` wrapper around it):
|
||||||
|
|
||||||
|
- **API:** `health(timeoutMs)`, `postFrame(json): Result`,
|
||||||
|
`events(cursor, onFrame, onHello): Job` (SSE reader), `poll(cursor): Result`.
|
||||||
|
- **State machine:**
|
||||||
|
|
||||||
|
| State | Send path | Receive path |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `WS_CONNECTED` | WS frame | WS |
|
||||||
|
| `HTTP_FALLBACK` | `POST /v1/frame` | SSE (or long-poll) |
|
||||||
|
| `CONNECTING` / `RECONNECTING` | queued/dropped as today | — |
|
||||||
|
|
||||||
|
- **On WS loss:** switch to `HTTP_FALLBACK` **immediately** — open the SSE
|
||||||
|
stream (catch-up from the local cursor is free) and route sends to POST.
|
||||||
|
No backoff gate on the send path; the WS redial loop keeps running in the
|
||||||
|
background.
|
||||||
|
- **At startup (the key UX fix):** race the WS dial against
|
||||||
|
`GET /v1/health` (2 s timeout). WS dial fails + health OK → straight into
|
||||||
|
`HTTP_FALLBACK`: the user can send in **< 1 s** after opening the app,
|
||||||
|
instead of waiting out dial timeouts and backoff.
|
||||||
|
- **On WS reconnect:** close the SSE stream, resume WS-only (lowest latency,
|
||||||
|
media available again).
|
||||||
|
- **Send path:** `sendMessage()` builds the same `message.send` frame JSON and
|
||||||
|
writes it to WS or POST depending on state. The `State.Connected` gate in
|
||||||
|
`ChatScreen.doSend()` becomes `state is Connected || state is HttpFallback`.
|
||||||
|
- **Media:** works in `HTTP_FALLBACK` too (v2, §19.15) — uploads go via
|
||||||
|
`POST /v1/media`, pulls via `GET /v1/media/{id}`; the composer's attach
|
||||||
|
button is enabled in both connected states.
|
||||||
|
- **UI:** status pill shows "connected" (WS) or "connected · http" (fallback)
|
||||||
|
— both green; the fallback is a healthy state, not an error.
|
||||||
|
|
||||||
|
## 19.10 Security
|
||||||
|
|
||||||
|
- Same token, constant-time verify, same bind host, same device allowlist as
|
||||||
|
the WS (`09-pairing-security.md` threat model unchanged — the HTTP leg adds
|
||||||
|
no new trust boundary, only a second door with the same lock).
|
||||||
|
- `/v1/health` is unauthenticated by design (it answers "is the gateway
|
||||||
|
alive?"); it must not reflect tokens, device ids, or version strings.
|
||||||
|
- TLS: optional, same cert pattern as the WS; plaintext is a LAN-only
|
||||||
|
default, identical to today's WS posture.
|
||||||
|
- New attack-surface items to keep small: 64 KiB body cap, strict
|
||||||
|
content-type, per-device rate limit, no directory listing, no CORS.
|
||||||
|
|
||||||
|
## 19.11 Failure modes
|
||||||
|
|
||||||
|
| Failure | Behavior |
|
||||||
|
| --- | --- |
|
||||||
|
| Gateway fully down | Both legs dead → app shows offline; sends queue (app-side outbox, follow-up work) or are dropped with a visible "not sent" state. Push is the wake path when the gateway comes back (`08-push.md`). |
|
||||||
|
| WS down, HTTP up | Normal `HTTP_FALLBACK` operation — text chat fully functional, media paused. |
|
||||||
|
| SSE blocked by a proxy | Two failures → long-poll loop (§19.6). |
|
||||||
|
| HTTP port firewalled, WS up | WS-only operation (today's behavior); `health` fails at startup, no fallback attempted. |
|
||||||
|
| Both flaky | Existing WS backoff + SSE/poll backoff run independently; outbox + cursor keep both paths idempotent. |
|
||||||
|
| Slow SSE subscriber | Dropped at queue overflow; reconnects with `Last-Event-ID`, catches up from outbox. |
|
||||||
|
|
||||||
|
## 19.12 Testing
|
||||||
|
|
||||||
|
- **Python** (`hermes-agent/tests/gateway/test_android_http.py`, run via
|
||||||
|
`scripts/run_tests.sh`):
|
||||||
|
- auth: bad/missing token → 401; allowlist rejection; constant-time verify reused.
|
||||||
|
- `POST /v1/frame`: valid `message.send` dispatches (agent turn fires);
|
||||||
|
empty text → 400 error frame; automation channel → 409/400; rate limit → 429.
|
||||||
|
- SSE: catch-up rows carry correct `id`s; `event: hello` present; a live
|
||||||
|
frame appended after connect arrives on the stream; `Last-Event-ID`
|
||||||
|
resume replays exactly the delta; heartbeat observed within 15 s.
|
||||||
|
- long-poll: returns on new frame; empty 200 at timeout with advanced cursor.
|
||||||
|
- **delivery counting:** frame with only an SSE subscriber → `delivered ≥ 1`
|
||||||
|
→ **no push fired** (the critical regression test for §19.8).
|
||||||
|
- **media (v2, §19.15):** `POST /v1/media` happy path (201 ack + cached
|
||||||
|
entry), sha256 mismatch, oversize → 413, missing ref / bad kind → 400,
|
||||||
|
auth → 401, magic-byte reclassification; `GET /v1/media/{id}` happy path
|
||||||
|
(bytes + content-type), unknown id → 404, denied path → 404.
|
||||||
|
- **Probe:** `ws_probe.py` gains an `--http` mode (health, post, SSE read with
|
||||||
|
assertion flags, per `gateway-plugin/tests/README.md`) + `--http-media FILE`
|
||||||
|
(v2: upload round-trip via `POST /v1/media`, exit 23 on rejection).
|
||||||
|
- **Kotlin** (`:shared` commonTest): SSE parser (multi-line data, comments,
|
||||||
|
`Last-Event-ID` bookkeeping); transport state machine transitions (fake
|
||||||
|
clock: WS-loss → immediate fallback; startup race → fallback in < 1 s).
|
||||||
|
- **E2E** (`e2e.py`, new scenario): point the app at a dead WS port with the
|
||||||
|
HTTP leg live → send a message → assert user echo + agent reply arrive via
|
||||||
|
SSE; timing assertion: send → user echo < 1 s on LAN. Live-verify on the
|
||||||
|
device via ADB (screenshot of the "connected · http" pill).
|
||||||
|
|
||||||
|
## 19.13 Non-goals (v1) / future
|
||||||
|
|
||||||
|
- ~~**Media over HTTP** (v2)~~ — **done** (§19.15): `POST /v1/media`
|
||||||
|
(whole-file body, sha256 contract per `07-media.md`) +
|
||||||
|
`GET /v1/media/{id}` for pull/playback. Attachments work in fallback mode.
|
||||||
|
- **App-side send outbox** (companion work, separate doc): queue sends locally
|
||||||
|
when *both* legs are down; drains over whichever leg recovers. This doc
|
||||||
|
removes the 2–20 s wait; the outbox removes the last "gateway was down for
|
||||||
|
30 s" data-loss case.
|
||||||
|
- **QUIC / WebTransport** if handover flakiness persists after this + the
|
||||||
|
outbox (connection migration would make the fallback rare).
|
||||||
|
- Per-device tokens (`16-open-questions.md` #3) apply to both legs identically
|
||||||
|
when implemented.
|
||||||
|
|
||||||
|
## 19.15 Media over HTTP (v2)
|
||||||
|
|
||||||
|
The last WS-only feature, closed out so the HTTP leg is feature-complete.
|
||||||
|
Same contracts as `07-media.md` — only the transport changes.
|
||||||
|
|
||||||
|
### Upload — `POST /v1/media`
|
||||||
|
|
||||||
|
One request per file (no chunked/resumable protocol — HTTP handles the
|
||||||
|
body; single-user scale makes resume unnecessary):
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /v1/media
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
X-Iris-Device: <device_id>
|
||||||
|
X-Iris-Media-Ref: up_123456 # app-chosen ref (mu_*/up_*), ≤ 64 chars
|
||||||
|
X-Iris-Media-Kind: image|audio|video|document|voice
|
||||||
|
X-Iris-Media-Filename: photo.jpg
|
||||||
|
X-Iris-Media-Sha256: <64 hex> # precomputed (headers precede the body)
|
||||||
|
Content-Type: <media mime> # doubles as the declared MIME
|
||||||
|
Content-Length: <size>
|
||||||
|
|
||||||
|
<raw file bytes>
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Response:** `201` with the `media.upload.ack` frame as the body
|
||||||
|
(`{ok, media_ref}`); validation failures return the `error` frame as the
|
||||||
|
4xx body with the same codes as the WS path (`media_too_large` → 413,
|
||||||
|
`unsupported` → 400, `internal` → 500, `not_found` → 404).
|
||||||
|
- **Server flow:** the body is streamed to a temp file in 256 KiB reads
|
||||||
|
(bounded RAM, same `UploadSession` as the WS path), then
|
||||||
|
`complete_upload` verifies size + sha256, re-sniffs the kind from magic
|
||||||
|
bytes (the client's declared kind is not trusted), and caches via the
|
||||||
|
hermes `cache_*_from_bytes` helpers. Runs entirely on the handler thread
|
||||||
|
— no asyncio bridge (plain file IO).
|
||||||
|
- **Limits:** `Content-Length` is checked against `max_upload_bytes`
|
||||||
|
*before* reading the body (early 413); the 64 KiB `/v1/frame` body cap
|
||||||
|
does not apply. Same per-device rate limit as the other endpoints.
|
||||||
|
- **Abort:** a client that disconnects mid-body leaves a short read → the
|
||||||
|
upload session (temp file) is discarded; nothing is cached.
|
||||||
|
- The ref then travels in `message.send`'s `media_refs` exactly as on the WS
|
||||||
|
path (single-use, resolved to `MessageEvent.media_urls`).
|
||||||
|
|
||||||
|
### Pull — `GET /v1/media/{media_id}`
|
||||||
|
|
||||||
|
- `media.offer` is a plain JSON event frame — it arrives on the SSE stream
|
||||||
|
unchanged; only the byte transfer moves to HTTP.
|
||||||
|
- **Response:** `200` with the file as the body,
|
||||||
|
`Content-Type: <mime>`, `Content-Length: <size>`,
|
||||||
|
`Content-Disposition: attachment; filename="<name>"`. Unknown id or a
|
||||||
|
path that fails delivery validation → `404` with the `error` frame
|
||||||
|
(`not_found`) — the delivery-path check is re-run at pull time, exactly
|
||||||
|
as the WS `media.pull` handler does.
|
||||||
|
- The app streams the body into its media cache (same
|
||||||
|
`MediaCache.openWriter` path as the WS pull); playback is unchanged
|
||||||
|
(`07-media.md` §7.4).
|
||||||
|
|
||||||
|
### What stays WS-only
|
||||||
|
|
||||||
|
Nothing feature-wise. The WS binary media frames (`media.upload.start/end`,
|
||||||
|
`media.pull` + binary chunks) remain accepted for WS clients, and
|
||||||
|
`POST /v1/frame` still rejects those frame types (they have HTTP endpoint
|
||||||
|
equivalents now, not a WS dependency).
|
||||||
|
|
||||||
|
## 19.14 Effort & change list
|
||||||
|
|
||||||
|
| Slice | Files | Est. |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Gateway leg | new `gateway-plugin/http_server.py` (~450 lines); `adapter.py` hooks (start/stop, fan-out in `_broadcast_or_log` + status path, delivery counting, dispatch-origin tag); `protocol.py` unchanged | 2–3 d |
|
||||||
|
| App leg | new `app/shared/.../net/HttpGateway.kt` (SSE reader + poll); `GatewayClient.kt` state machine + startup race; `ChatScreen.kt` gate + status pill; composer media-disable in fallback | 2–3 d |
|
||||||
|
| Tests + e2e + docs | per §19.12; `frames.schema.json` unchanged (no new frame types); `09-pairing-security.md` + `13-testing.md` cross-references | 1–2 d |
|
||||||
|
|
||||||
|
Total: **~1 week**, each slice independently shippable (gateway leg is
|
||||||
|
inert until the app uses it; app leg degrades to today's behavior if the
|
||||||
|
HTTP port is closed).
|
||||||
+4
-1
@@ -8,6 +8,7 @@ This folder is the single source of truth for *what to build and why*. Read it
|
|||||||
top-to-bottom once, then use the numbered docs as a lookup while implementing.
|
top-to-bottom once, then use the numbered docs as a lookup while implementing.
|
||||||
|
|
||||||
> ⚠️ **READ FIRST — two hard rules**
|
> ⚠️ **READ FIRST — two hard rules**
|
||||||
|
>
|
||||||
> 1. **`hermes-agent/` (sibling of this folder) is a read-only research
|
> 1. **`hermes-agent/` (sibling of this folder) is a read-only research
|
||||||
> reference. It must NEVER be committed, pushed, or shipped.** It is
|
> reference. It must NEVER be committed, pushed, or shipped.** It is
|
||||||
> git-ignored at the repo root. We only *install* our plugin into a live
|
> git-ignored at the repo root. We only *install* our plugin into a live
|
||||||
@@ -20,7 +21,7 @@ top-to-bottom once, then use the numbered docs as a lookup while implementing.
|
|||||||
## Reading order
|
## Reading order
|
||||||
|
|
||||||
| # | File | When to read |
|
| # | File | When to read |
|
||||||
|---|------|--------------|
|
| --- | ------ | -------------- |
|
||||||
| 0 | [`00-overview.md`](00-overview.md) | Always first. Vision, scope, disclaimers, locked decisions. |
|
| 0 | [`00-overview.md`](00-overview.md) | Always first. Vision, scope, disclaimers, locked decisions. |
|
||||||
| 1 | [`01-architecture.md`](01-architecture.md) | Before touching code. System shape + rationale. |
|
| 1 | [`01-architecture.md`](01-architecture.md) | Before touching code. System shape + rationale. |
|
||||||
| 2 | [`02-monorepo.md`](02-monorepo.md) | When scaffolding the repo. |
|
| 2 | [`02-monorepo.md`](02-monorepo.md) | When scaffolding the repo. |
|
||||||
@@ -39,8 +40,10 @@ top-to-bottom once, then use the numbered docs as a lookup while implementing.
|
|||||||
| 15 | [`15-hermes-reference.md`](15-hermes-reference.md) | **Cheat-sheet** of hermes-agent source to read. |
|
| 15 | [`15-hermes-reference.md`](15-hermes-reference.md) | **Cheat-sheet** of hermes-agent source to read. |
|
||||||
| 16 | [`16-open-questions.md`](16-open-questions.md) | Decisions made + open items. |
|
| 16 | [`16-open-questions.md`](16-open-questions.md) | Decisions made + open items. |
|
||||||
| 17 | [`17-future-control-surface.md`](17-future-control-surface.md) | **Backlog** — what the app could control beyond chat (cron, kanban, models, …). |
|
| 17 | [`17-future-control-surface.md`](17-future-control-surface.md) | **Backlog** — what the app could control beyond chat (cron, kanban, models, …). |
|
||||||
|
| 19 | [`19-http-fallback-transport.md`](19-http-fallback-transport.md) | **Design** — HTTP fallback leg (POST + SSE/long-poll) so the app can send/receive when the WS is down. |
|
||||||
|
|
||||||
Machine-readable / diagrams:
|
Machine-readable / diagrams:
|
||||||
|
|
||||||
- [`protocol/frames.schema.json`](protocol/frames.schema.json) — wire-frame schema.
|
- [`protocol/frames.schema.json`](protocol/frames.schema.json) — wire-frame schema.
|
||||||
- [`diagrams/architecture.mmd`](diagrams/architecture.mmd) — mermaid architecture.
|
- [`diagrams/architecture.mmd`](diagrams/architecture.mmd) — mermaid architecture.
|
||||||
|
|
||||||
|
|||||||
+162
-278
@@ -1,18 +1,20 @@
|
|||||||
"""
|
"""
|
||||||
Android Platform Adapter for Hermes Agent (Iris x Hermes).
|
Android Platform Adapter for Hermes Agent (Iris x Hermes).
|
||||||
|
|
||||||
A plugin-based gateway adapter that runs a WebSocket server *inside* the
|
A plugin-based gateway adapter that runs an HTTP server *inside* the
|
||||||
``hermes gateway`` process. The native Android / Desktop app connects to it
|
``hermes gateway`` process. The native Android / Desktop app connects to it
|
||||||
with a pairing token and talks to the agent over a single WS transport
|
with a pairing token and talks to the agent over a single HTTP transport
|
||||||
(chat, streaming, tools, media, pairing, push-token).
|
(chat, streaming, tools, media, pairing, push-token): JSON frames via
|
||||||
|
``POST /v1/frame``, events via SSE ``GET /v1/events`` (or long-poll), and
|
||||||
|
media via ``POST /v1/media`` / ``GET /v1/media/{id}`` (docs/19).
|
||||||
|
|
||||||
Zero new Python dependencies: ``websockets`` and ``httpx`` are hermes core
|
Zero new Python dependencies: ``httpx`` is a hermes core dep. Zero
|
||||||
deps. Zero hermes-core changes.
|
hermes-core changes.
|
||||||
|
|
||||||
Milestone M1: the gateway core loop (text round-trip). The WS server binds
|
Milestone M1: the gateway core loop (text round-trip). The server binds and
|
||||||
and authenticates devices (``hello`` with constant-time token check), the
|
authenticates devices (constant-time token check), the adapter emits
|
||||||
adapter emits ``message`` frames from ``send()`` and turns inbound
|
``message`` frames from ``send()`` and turns inbound ``message.send`` frames
|
||||||
``message.send`` frames into ``MessageEvent``s for ``handle_message()``.
|
into ``MessageEvent``s for ``handle_message()``.
|
||||||
|
|
||||||
Milestone M2: agent transparency. ``send()``/``edit_message()`` are mapped to
|
Milestone M2: agent transparency. ``send()``/``edit_message()`` are mapped to
|
||||||
``message.start``/``message.update``/``message.stop`` (streaming), tool
|
``message.start``/``message.update``/``message.stop`` (streaming), tool
|
||||||
@@ -22,13 +24,13 @@ reasoning prefix is split into a ``reasoning`` field. Outbox and search land
|
|||||||
in M3; media, push, and desktop land in later milestones (see
|
in M3; media, push, and desktop land in later milestones (see
|
||||||
``docs/14-milestones.md``).
|
``docs/14-milestones.md``).
|
||||||
|
|
||||||
Milestone M4: media. Inbound ``media.upload`` (chunked binary frames) is
|
Milestone M4: media. Inbound uploads (``POST /v1/media``) are streamed to a
|
||||||
reassembled in a temp file, verified (size + sha256), re-sniffed, and cached
|
temp file, verified (size + sha256), re-sniffed, and cached via hermes
|
||||||
via hermes ``cache_*_from_bytes``; the resulting refs attach to the next
|
``cache_*_from_bytes``; the resulting refs attach to the next
|
||||||
``message.send`` as ``MessageEvent.media_urls``. Outbound ``send_*`` calls
|
``message.send`` as ``MessageEvent.media_urls``. Outbound ``send_*`` calls
|
||||||
register the (delivery-validated) file in the media registry and emit
|
register the (delivery-validated) file in the media registry and emit
|
||||||
``media.offer``; ``media.pull`` streams the file back as chunked binary
|
``media.offer``; ``GET /v1/media/{id}`` streams the file back, re-checking
|
||||||
frames, re-checking ``validate_media_delivery_path`` at pull time.
|
``validate_media_delivery_path`` at pull time.
|
||||||
|
|
||||||
Milestone M5: push + offline. Frames with no live subscriber are parked in
|
Milestone M5: push + offline. Frames with no live subscriber are parked in
|
||||||
the outbox (M3) AND wake the device via the push backend (``push.py``: FCM
|
the outbox (M3) AND wake the device via the push backend (``push.py``: FCM
|
||||||
@@ -62,6 +64,7 @@ import contextlib
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import queue
|
||||||
import re
|
import re
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -117,6 +120,7 @@ from . import protocol # noqa: E402
|
|||||||
from . import purge as purge_bridge # noqa: E402
|
from . import purge as purge_bridge # noqa: E402
|
||||||
from . import search as search_bridge # noqa: E402
|
from . import search as search_bridge # noqa: E402
|
||||||
from .channels import get_directory # noqa: E402
|
from .channels import get_directory # noqa: E402
|
||||||
|
from .http_server import HttpServer # noqa: E402
|
||||||
from .outbox import Outbox # noqa: E402
|
from .outbox import Outbox # noqa: E402
|
||||||
from .pairing import ( # noqa: E402
|
from .pairing import ( # noqa: E402
|
||||||
DeviceRegistry,
|
DeviceRegistry,
|
||||||
@@ -125,7 +129,6 @@ from .pairing import ( # noqa: E402
|
|||||||
qr_payload,
|
qr_payload,
|
||||||
)
|
)
|
||||||
from .push import NtfyBackend, PushBackend, build_push_backend # noqa: E402
|
from .push import NtfyBackend, PushBackend, build_push_backend # noqa: E402
|
||||||
from .ws_server import WsServer # noqa: E402
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Slash-command catalog (the app's "/" drawer)
|
# Slash-command catalog (the app's "/" drawer)
|
||||||
@@ -450,14 +453,13 @@ async def _build_runtime_footer(meta: dict[str, Any]) -> dict[str, Any]:
|
|||||||
|
|
||||||
DEFAULT_HOST = "127.0.0.1"
|
DEFAULT_HOST = "127.0.0.1"
|
||||||
DEFAULT_PORT = 8790
|
DEFAULT_PORT = 8790
|
||||||
|
DEFAULT_HTTP_PORT = 8791 # docs/19: HTTP fallback leg
|
||||||
DEFAULT_HOME_CHANNEL = "android:default"
|
DEFAULT_HOME_CHANNEL = "android:default"
|
||||||
DEFAULT_HOME_CHANNEL_NAME = "Default"
|
DEFAULT_HOME_CHANNEL_NAME = "Default"
|
||||||
DEFAULT_PUSH_BACKEND = "fcm"
|
DEFAULT_PUSH_BACKEND = "fcm"
|
||||||
DEFAULT_OUTBOX_RETENTION_HOURS = 72
|
DEFAULT_OUTBOX_RETENTION_HOURS = 72
|
||||||
DEFAULT_MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 MB
|
DEFAULT_MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 MB
|
||||||
|
|
||||||
# Max length of a client-supplied media_ref (mu_*/md_* ids are short).
|
|
||||||
MAX_MEDIA_REF_LEN = 64
|
|
||||||
# How often (seconds) the outbox-prune "storage reclaimed" notice may repeat.
|
# How often (seconds) the outbox-prune "storage reclaimed" notice may repeat.
|
||||||
_PRUNE_NOTIFY_INTERVAL_S = 3600.0
|
_PRUNE_NOTIFY_INTERVAL_S = 3600.0
|
||||||
|
|
||||||
@@ -799,15 +801,12 @@ class _TurnState:
|
|||||||
|
|
||||||
|
|
||||||
def check_requirements() -> bool:
|
def check_requirements() -> bool:
|
||||||
"""PASSIVE dependency probe: ``websockets`` importable + token set.
|
"""PASSIVE dependency probe: token set.
|
||||||
|
|
||||||
Must be side-effect free (called from ``hermes setup`` / ``status`` /
|
Must be side-effect free (called from ``hermes setup`` / ``status`` /
|
||||||
dashboard readiness). Never installs.
|
dashboard readiness). Never installs. The HTTP transport is stdlib-only,
|
||||||
|
so there is no extra dependency to probe.
|
||||||
"""
|
"""
|
||||||
try:
|
|
||||||
import websockets # noqa: F401 (core dep)
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
return bool(_get_scoped_secret("ANDROID_TOKEN"))
|
return bool(_get_scoped_secret("ANDROID_TOKEN"))
|
||||||
|
|
||||||
|
|
||||||
@@ -853,9 +852,9 @@ def _env_enablement() -> dict | None:
|
|||||||
host = os.getenv("ANDROID_WS_HOST", "").strip()
|
host = os.getenv("ANDROID_WS_HOST", "").strip()
|
||||||
if host:
|
if host:
|
||||||
seed["host"] = host
|
seed["host"] = host
|
||||||
port_raw = os.getenv("ANDROID_WS_PORT", "").strip()
|
http_port_raw = os.getenv("ANDROID_HTTP_PORT", "").strip()
|
||||||
if port_raw:
|
if http_port_raw:
|
||||||
seed["port"] = _parse_port(port_raw)
|
seed["http_port"] = _parse_port(http_port_raw)
|
||||||
push = os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower()
|
push = os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower()
|
||||||
if push:
|
if push:
|
||||||
seed["push_backend"] = push
|
seed["push_backend"] = push
|
||||||
@@ -1029,10 +1028,16 @@ def interactive_setup() -> None:
|
|||||||
else:
|
else:
|
||||||
print_info("Existing ANDROID_TOKEN found (not shown).")
|
print_info("Existing ANDROID_TOKEN found (not shown).")
|
||||||
|
|
||||||
host = prompt("WS bind host", default=get_env_value("ANDROID_WS_HOST") or DEFAULT_HOST)
|
host = prompt("Bind host", default=get_env_value("ANDROID_WS_HOST") or DEFAULT_HOST)
|
||||||
save_env_value("ANDROID_WS_HOST", host or DEFAULT_HOST)
|
save_env_value("ANDROID_WS_HOST", host or DEFAULT_HOST)
|
||||||
port = prompt("WS port", default=str(_parse_port(get_env_value("ANDROID_WS_PORT") or "")))
|
# _parse_port falls back to DEFAULT_PORT (8790) for empty input, so the
|
||||||
save_env_value("ANDROID_WS_PORT", str(_parse_port(port)))
|
# HTTP default must be applied explicitly (docs/19: 8791).
|
||||||
|
http_port_raw = (get_env_value("ANDROID_HTTP_PORT") or "").strip()
|
||||||
|
port = prompt(
|
||||||
|
"HTTP port",
|
||||||
|
default=str(int(http_port_raw) if http_port_raw.isdigit() else DEFAULT_HTTP_PORT),
|
||||||
|
)
|
||||||
|
save_env_value("ANDROID_HTTP_PORT", str(_parse_port(port)))
|
||||||
backend = prompt(
|
backend = prompt(
|
||||||
"Push backend (fcm/ntfy)",
|
"Push backend (fcm/ntfy)",
|
||||||
default=get_env_value("ANDROID_PUSH_BACKEND") or DEFAULT_PUSH_BACKEND,
|
default=get_env_value("ANDROID_PUSH_BACKEND") or DEFAULT_PUSH_BACKEND,
|
||||||
@@ -1060,19 +1065,20 @@ def interactive_setup() -> None:
|
|||||||
|
|
||||||
|
|
||||||
class AndroidAdapter(BasePlatformAdapter):
|
class AndroidAdapter(BasePlatformAdapter):
|
||||||
"""WebSocket-backed adapter for the native Iris Android / Desktop app.
|
"""HTTP-backed adapter for the native Iris Android / Desktop app.
|
||||||
|
|
||||||
M1: the WS server (``ws_server.WsServer``) authenticates devices with the
|
The HTTP server (``http_server.HttpServer``) authenticates devices with
|
||||||
pairing token, the connection registry tracks live sockets, ``send()``
|
the pairing token, the device registry tracks live subscribers, ``send()``
|
||||||
emits ``message`` frames, and inbound ``message.send`` frames become
|
emits ``message`` frames, and inbound ``message.send`` frames become
|
||||||
``MessageEvent``s for ``handle_message()``.
|
``MessageEvent``s for ``handle_message()``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# WS has no message-size limit. The stream consumer resolves its per-chat
|
# The HTTP transport has no per-message size limit. The stream consumer
|
||||||
# chunking budget via ``max_message_length_for_chat`` -> this attribute
|
# resolves its per-chat chunking budget via ``max_message_length_for_chat``
|
||||||
# (defaulting to 4096 when unset), which would split long replies — and
|
# -> this attribute (defaulting to 4096 when unset), which would split
|
||||||
# complete HTML artifacts — across multiple fence-reopened messages. A
|
# long replies — and complete HTML artifacts — across multiple
|
||||||
# large cap disables that chunking so a reply arrives as a single message.
|
# fence-reopened messages. A large cap disables that chunking so a reply
|
||||||
|
# arrives as a single message.
|
||||||
MAX_MESSAGE_LENGTH = 1_000_000
|
MAX_MESSAGE_LENGTH = 1_000_000
|
||||||
|
|
||||||
def __init__(self, config, **kwargs):
|
def __init__(self, config, **kwargs):
|
||||||
@@ -1085,10 +1091,12 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
|
|
||||||
extra = getattr(config, "extra", {}) or {}
|
extra = getattr(config, "extra", {}) or {}
|
||||||
|
|
||||||
# Connection settings (env vars override config.yaml)
|
# Connection settings (env vars override config.yaml). The bind host
|
||||||
|
# is shared with the (legacy) WS-era env var name for compatibility.
|
||||||
self.host = os.getenv("ANDROID_WS_HOST", "").strip() or extra.get("host", DEFAULT_HOST)
|
self.host = os.getenv("ANDROID_WS_HOST", "").strip() or extra.get("host", DEFAULT_HOST)
|
||||||
self.port = _parse_port(
|
# docs/19: HTTP transport (the only device-facing transport; optional TLS).
|
||||||
os.getenv("ANDROID_WS_PORT", "") or str(extra.get("port", DEFAULT_PORT))
|
self.http_port = _parse_port(
|
||||||
|
os.getenv("ANDROID_HTTP_PORT", "") or str(extra.get("http_port", DEFAULT_HTTP_PORT))
|
||||||
)
|
)
|
||||||
self.token = _get_scoped_secret("ANDROID_TOKEN") or extra.get("token", "")
|
self.token = _get_scoped_secret("ANDROID_TOKEN") or extra.get("token", "")
|
||||||
self.push_backend = os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower() or extra.get(
|
self.push_backend = os.getenv("ANDROID_PUSH_BACKEND", "").strip().lower() or extra.get(
|
||||||
@@ -1120,8 +1128,8 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
self.home_channel_name = DEFAULT_HOME_CHANNEL_NAME
|
self.home_channel_name = DEFAULT_HOME_CHANNEL_NAME
|
||||||
|
|
||||||
# TLS (optional)
|
# TLS (optional)
|
||||||
self.ws_cert = _get_scoped_secret("ANDROID_WS_CERT") or extra.get("ws_cert", "")
|
self.http_cert = _get_scoped_secret("ANDROID_HTTP_CERT") or extra.get("http_cert", "")
|
||||||
self.ws_key = _get_scoped_secret("ANDROID_WS_KEY") or extra.get("ws_key", "")
|
self.http_key = _get_scoped_secret("ANDROID_HTTP_KEY") or extra.get("http_key", "")
|
||||||
|
|
||||||
# Auth
|
# Auth
|
||||||
allowed = os.getenv("ANDROID_ALLOWED_USERS", "").strip()
|
allowed = os.getenv("ANDROID_ALLOWED_USERS", "").strip()
|
||||||
@@ -1132,7 +1140,13 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
|
|
||||||
# Runtime state
|
# Runtime state
|
||||||
self._devices = DeviceRegistry(get_hermes_home() / "android" / "devices.db")
|
self._devices = DeviceRegistry(get_hermes_home() / "android" / "devices.db")
|
||||||
self._ws_server = WsServer(self, self._devices)
|
# docs/19: HTTP transport (the only device-facing transport).
|
||||||
|
self._http_server = HttpServer(self, self._devices)
|
||||||
|
# docs/19 §19.7: reply sinks for in-flight HTTP requests — while a
|
||||||
|
# POST /v1/frame is being dispatched, the handler's point-to-point
|
||||||
|
# replies are captured here and returned as the HTTP response.
|
||||||
|
# Entry shape: (sink queue, abandoned event).
|
||||||
|
self._http_reply_sinks: dict[str, tuple[queue.Queue, threading.Event]] = {}
|
||||||
self._connected = False
|
self._connected = False
|
||||||
# M2: per-chat turn state for outbound frame classification.
|
# M2: per-chat turn state for outbound frame classification.
|
||||||
self._turns: dict[str, _TurnState] = {}
|
self._turns: dict[str, _TurnState] = {}
|
||||||
@@ -1175,7 +1189,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
# ── Connection lifecycle ──────────────────────────────────────────────
|
# ── Connection lifecycle ──────────────────────────────────────────────
|
||||||
|
|
||||||
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
||||||
"""Bring the platform up: bind the WS server on host:port."""
|
"""Bring the platform up: bind the HTTP server on host:http_port."""
|
||||||
if not self.token:
|
if not self.token:
|
||||||
logger.error("android: ANDROID_TOKEN must be set")
|
logger.error("android: ANDROID_TOKEN must be set")
|
||||||
self._set_fatal_error(
|
self._set_fatal_error(
|
||||||
@@ -1185,37 +1199,25 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Prevent two profiles from binding the same port/identity.
|
# The HTTP server is the only device-facing transport, so a bind
|
||||||
try:
|
# failure is fatal (the app has no other way to reach the gateway).
|
||||||
from gateway.status import acquire_scoped_lock
|
# start() never raises; it disables the leg and logs on failure.
|
||||||
|
await self._http_server.start()
|
||||||
lock_key = f"{self.host}:{self.port}"
|
if not self._http_server.enabled:
|
||||||
if not acquire_scoped_lock("android", lock_key):
|
logger.error("android: HTTP server failed to bind %s:%s", self.host, self.http_port)
|
||||||
logger.error(
|
|
||||||
"android: %s:%s already in use by another profile", self.host, self.port
|
|
||||||
)
|
|
||||||
self._set_fatal_error(
|
self._set_fatal_error(
|
||||||
"lock_conflict",
|
"bind_failed",
|
||||||
"WS port in use by another profile",
|
f"HTTP port {self.http_port} unavailable",
|
||||||
retryable=False,
|
retryable=False,
|
||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
self._lock_key = lock_key
|
|
||||||
except ImportError:
|
|
||||||
self._lock_key = None # status module not available (e.g. tests)
|
|
||||||
|
|
||||||
try:
|
|
||||||
await self._ws_server.start()
|
|
||||||
except Exception:
|
|
||||||
self._connected = False
|
|
||||||
return False
|
|
||||||
|
|
||||||
# M5: announce gateway health to connected clients (none yet at
|
# M5: announce gateway health to connected clients (none yet at
|
||||||
# startup; the frame + plumbing exist for future transitions).
|
# startup; the frame + plumbing exist for future transitions).
|
||||||
# Reset in case this adapter instance previously went down (the
|
# Reset in case this adapter instance previously went down (the
|
||||||
# gateway may reconnect the same adapter after a fatal error).
|
# gateway may reconnect the same adapter after a fatal error).
|
||||||
self._gateway_status = protocol.STATUS_ONLINE
|
self._gateway_status = protocol.STATUS_ONLINE
|
||||||
await self._ws_server.broadcast(protocol.status(self._gateway_status))
|
await self._http_server.fanout(protocol.status(self._gateway_status), cursor=None)
|
||||||
|
|
||||||
# M3: ensure the default (home) channel exists in the directory so the
|
# M3: ensure the default (home) channel exists in the directory so the
|
||||||
# app's channel list and cron home delivery have a stable anchor.
|
# app's channel list and cron home delivery have a stable anchor.
|
||||||
@@ -1236,28 +1238,22 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
|
|
||||||
self._connected = True
|
self._connected = True
|
||||||
self._mark_connected()
|
self._mark_connected()
|
||||||
logger.info("android: connected; WS server on %s:%s", self.host, self.port)
|
logger.info("android: connected; HTTP server on %s:%s", self.host, self.http_port)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def disconnect(self) -> None:
|
async def disconnect(self) -> None:
|
||||||
"""Tear down the platform: stop the server, close device sockets."""
|
"""Tear down the platform: stop the server, close device streams."""
|
||||||
# Tell live clients the gateway is going away (restart/shutdown) so
|
# Tell live clients the gateway is going away (restart/shutdown) so
|
||||||
# the app can distinguish a clean gateway teardown from a plain
|
# the app can distinguish a clean gateway teardown from a plain
|
||||||
# network drop: the "Gateway restarting" chat notice is shown only
|
# network drop: the "Gateway restarting" chat notice is shown only
|
||||||
# when this frame was received (docs/04 §status).
|
# when this frame was received (docs/04 §status).
|
||||||
self._gateway_status = protocol.STATUS_RESTARTING
|
self._gateway_status = protocol.STATUS_RESTARTING
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
await self._ws_server.broadcast(protocol.status(self._gateway_status))
|
await self._http_server.fanout(protocol.status(self._gateway_status), cursor=None)
|
||||||
with contextlib.suppress(ImportError):
|
|
||||||
from gateway.status import release_scoped_lock
|
|
||||||
|
|
||||||
lock_key = getattr(self, "_lock_key", None)
|
|
||||||
if lock_key:
|
|
||||||
release_scoped_lock("android", lock_key)
|
|
||||||
try:
|
try:
|
||||||
await self._ws_server.stop()
|
await self._http_server.stop()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("android: WS server stop failed", exc_info=True)
|
logger.warning("android: HTTP server stop failed", exc_info=True)
|
||||||
# Best-effort shutdown: a close failure on an already-closed store is
|
# Best-effort shutdown: a close failure on an already-closed store is
|
||||||
# not actionable at disconnect time.
|
# not actionable at disconnect time.
|
||||||
with contextlib.suppress(Exception):
|
with contextlib.suppress(Exception):
|
||||||
@@ -1642,8 +1638,45 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
# (e.g. tool_progress off) so they can't leak into the next turn.
|
# (e.g. tool_progress off) so they can't leak into the next turn.
|
||||||
_reset_tool_results()
|
_reset_tool_results()
|
||||||
|
|
||||||
|
# ── docs/19: HTTP-leg reply routing ───────────────────────────────────
|
||||||
|
|
||||||
|
def _http_register_sink(
|
||||||
|
self, device_id: str, entry: tuple[queue.Queue, threading.Event]
|
||||||
|
) -> None:
|
||||||
|
self._http_reply_sinks[device_id] = entry
|
||||||
|
|
||||||
|
def _http_pop_sink(self, device_id: str) -> tuple[queue.Queue, threading.Event] | None:
|
||||||
|
return self._http_reply_sinks.pop(device_id, None)
|
||||||
|
|
||||||
|
def _http_pop_sink_if(
|
||||||
|
self, device_id: str, sink: queue.Queue
|
||||||
|
) -> tuple[queue.Queue, threading.Event] | None:
|
||||||
|
"""Pop the sink entry only if it is still ours (a newer request from
|
||||||
|
the same device may have replaced it)."""
|
||||||
|
entry = self._http_reply_sinks.get(device_id)
|
||||||
|
if entry is None or entry[0] is not sink:
|
||||||
|
return None
|
||||||
|
return self._http_reply_sinks.pop(device_id, None)
|
||||||
|
|
||||||
|
async def _broadcast_both(self, frame: "protocol.Frame") -> None:
|
||||||
|
"""Bare (non-outbox) broadcast to live subscribers (docs/19): the
|
||||||
|
frame reaches every live SSE/long-poll subscriber."""
|
||||||
|
await self._http_server.fanout(frame, cursor=None)
|
||||||
|
|
||||||
|
async def _reply(self, device_id: str, frame: "protocol.Frame") -> None:
|
||||||
|
"""Point-to-point reply with broadcast fallback (docs/19 §19.7).
|
||||||
|
|
||||||
|
For an in-flight HTTP request (a reply sink is registered) the frame
|
||||||
|
goes into the HTTP response. Otherwise it is broadcast so the
|
||||||
|
device's SSE stream delivers it (single-user model).
|
||||||
|
"""
|
||||||
|
entry = self._http_reply_sinks.get(device_id)
|
||||||
|
if entry is not None:
|
||||||
|
entry[0].put(frame)
|
||||||
|
return
|
||||||
|
await self._broadcast_both(frame)
|
||||||
|
|
||||||
async def _broadcast_or_log(self, chat_id: str, frame: "protocol.Frame") -> None:
|
async def _broadcast_or_log(self, chat_id: str, frame: "protocol.Frame") -> None:
|
||||||
delivered = await self._ws_server.broadcast(frame)
|
|
||||||
# M3/M5: always append to the outbox so a reconnecting app can catch
|
# M3/M5: always append to the outbox so a reconnecting app can catch
|
||||||
# up on *all* recent frames, not just the ones that were parked. This
|
# up on *all* recent frames, not just the ones that were parked. This
|
||||||
# covers the case where the app's in-memory ChatStore is reset (e.g.
|
# covers the case where the app's in-memory ChatStore is reset (e.g.
|
||||||
@@ -1656,6 +1689,10 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("android: outbox append failed", exc_info=True)
|
logger.warning("android: outbox append failed", exc_info=True)
|
||||||
return
|
return
|
||||||
|
# docs/19 §19.8: a device reading SSE/long-poll IS a live subscriber
|
||||||
|
# — count it in the delivery total or every message would push AND
|
||||||
|
# stream to a device that is already receiving it.
|
||||||
|
delivered = await self._http_server.fanout(frame, cursor)
|
||||||
if delivered == 0:
|
if delivered == 0:
|
||||||
logger.info(
|
logger.info(
|
||||||
"android: no live devices for %s; %s frame parked in outbox (cursor=%s)",
|
"android: no live devices for %s; %s frame parked in outbox (cursor=%s)",
|
||||||
@@ -1753,11 +1790,6 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
device_id = device.get("device_id")
|
device_id = device.get("device_id")
|
||||||
if not device_id:
|
if not device_id:
|
||||||
continue
|
continue
|
||||||
# Prefer the live connection's token (fcm.register refreshes it
|
|
||||||
# in memory) over the possibly-stale registry row.
|
|
||||||
conn = self._ws_server.connection(device_id)
|
|
||||||
token = getattr(conn, backend.token_field, None) if conn is not None else None
|
|
||||||
if not token:
|
|
||||||
token = device.get(backend.token_field)
|
token = device.get(backend.token_field)
|
||||||
if not token:
|
if not token:
|
||||||
continue
|
continue
|
||||||
@@ -1819,11 +1851,13 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
tid = metadata.get("thread_id")
|
tid = metadata.get("thread_id")
|
||||||
if isinstance(tid, str) and tid:
|
if isinstance(tid, str) and tid:
|
||||||
thread_id = tid
|
thread_id = tid
|
||||||
await self._ws_server.broadcast(protocol.typing(chat_id, True, thread_id=thread_id))
|
frame = protocol.typing(chat_id, True, thread_id=thread_id)
|
||||||
|
await self._http_server.fanout(frame, cursor=None)
|
||||||
|
|
||||||
async def stop_typing(self, chat_id: str) -> None:
|
async def stop_typing(self, chat_id: str) -> None:
|
||||||
"""Clear the typing indicator (``typing`` frame, on=false)."""
|
"""Clear the typing indicator (``typing`` frame, on=false)."""
|
||||||
await self._ws_server.broadcast(protocol.typing(chat_id, False))
|
frame = protocol.typing(chat_id, False)
|
||||||
|
await self._http_server.fanout(frame, cursor=None)
|
||||||
|
|
||||||
# ── M4: outbound media (agent -> app) ─────────────────────────────────
|
# ── M4: outbound media (agent -> app) ─────────────────────────────────
|
||||||
#
|
#
|
||||||
@@ -1959,7 +1993,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not text.strip() and not media_refs:
|
if not text.strip() and not media_refs:
|
||||||
await self._ws_server.send_to(
|
await self._reply(
|
||||||
device_id,
|
device_id,
|
||||||
protocol.error(
|
protocol.error(
|
||||||
protocol.ERR_UNSUPPORTED, "message.send requires non-empty text", id=frame.id
|
protocol.ERR_UNSUPPORTED, "message.send requires non-empty text", id=frame.id
|
||||||
@@ -1978,7 +2012,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
# enforcement).
|
# enforcement).
|
||||||
target = self._channels.get(chat_id)
|
target = self._channels.get(chat_id)
|
||||||
if target is not None and target.get("automation"):
|
if target is not None and target.get("automation"):
|
||||||
await self._ws_server.send_to(
|
await self._reply(
|
||||||
device_id,
|
device_id,
|
||||||
protocol.error(
|
protocol.error(
|
||||||
protocol.ERR_UNSUPPORTED,
|
protocol.ERR_UNSUPPORTED,
|
||||||
@@ -2025,7 +2059,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
thread_id = entry["chat_id"]
|
thread_id = entry["chat_id"]
|
||||||
# Bare broadcast (like channel.create): the directory is
|
# Bare broadcast (like channel.create): the directory is
|
||||||
# re-served on hello.ack, so no outbox entry is needed.
|
# re-served on hello.ack, so no outbox entry is needed.
|
||||||
await self._ws_server.broadcast(protocol.channel_created(entry, auto=True))
|
await self._broadcast_both(protocol.channel_created(entry, auto=True))
|
||||||
self._schedule_thread_title_upgrade(entry["chat_id"], text)
|
self._schedule_thread_title_upgrade(entry["chat_id"], text)
|
||||||
|
|
||||||
# M4: resolve media refs (single-use; unknown ref -> error).
|
# M4: resolve media refs (single-use; unknown ref -> error).
|
||||||
@@ -2035,7 +2069,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
for ref in media_refs:
|
for ref in media_refs:
|
||||||
entry = self._media.get_inbound(ref)
|
entry = self._media.get_inbound(ref)
|
||||||
if entry is None:
|
if entry is None:
|
||||||
await self._ws_server.send_to(
|
await self._reply(
|
||||||
device_id,
|
device_id,
|
||||||
protocol.error(
|
protocol.error(
|
||||||
protocol.ERR_UNSUPPORTED, f"unknown media_ref {ref}", id=frame.id
|
protocol.ERR_UNSUPPORTED, f"unknown media_ref {ref}", id=frame.id
|
||||||
@@ -2120,7 +2154,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
await self.handle_message(event)
|
await self.handle_message(event)
|
||||||
# M5: acknowledge the user message to the originating device (the
|
# M5: acknowledge the user message to the originating device (the
|
||||||
# app shows ✓✓) at the moment it is handed to the agent.
|
# app shows ✓✓) at the moment it is handed to the agent.
|
||||||
await self._ws_server.send_to(
|
await self._reply(
|
||||||
device_id,
|
device_id,
|
||||||
protocol.read_receipt(chat_id, message_id),
|
protocol.read_receipt(chat_id, message_id),
|
||||||
)
|
)
|
||||||
@@ -2157,7 +2191,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
asyncio.run_coroutine_threadsafe(
|
asyncio.run_coroutine_threadsafe(
|
||||||
self._ws_server.broadcast(protocol.channel_renamed(renamed)),
|
self._broadcast_both(protocol.channel_renamed(renamed)),
|
||||||
loop,
|
loop,
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -2165,150 +2199,6 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
|
|
||||||
threading.Thread(target=_work, daemon=True, name="android-thread-title").start()
|
threading.Thread(target=_work, daemon=True, name="android-thread-title").start()
|
||||||
|
|
||||||
# ── M4: inbound media (app -> agent) ──────────────────────────────────
|
|
||||||
#
|
|
||||||
# ``media.upload.start`` -> raw binary frames (one at a time per
|
|
||||||
# connection) -> ``media.upload.end``. The session streams to a temp
|
|
||||||
# file (bounded RAM); on end we verify size + sha256, re-sniff the kind,
|
|
||||||
# and cache via hermes ``cache_*_from_bytes``. ``media.pull`` serves an
|
|
||||||
# outbound offer as chunked binary frames, re-checking the delivery-path
|
|
||||||
# validation at pull time.
|
|
||||||
|
|
||||||
async def on_media_upload_start(self, frame: protocol.Frame, device_id: str) -> None:
|
|
||||||
payload = frame.payload
|
|
||||||
media_ref = str(payload.get("media_ref") or "").strip()
|
|
||||||
if not media_ref or len(media_ref) > MAX_MEDIA_REF_LEN:
|
|
||||||
await self._ws_server.send_to(
|
|
||||||
device_id,
|
|
||||||
protocol.error(
|
|
||||||
protocol.ERR_UNSUPPORTED, "media.upload.start requires media_ref", id=frame.id
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
kind = payload.get("kind")
|
|
||||||
if kind not in media_bridge.KINDS:
|
|
||||||
await self._ws_server.send_to(
|
|
||||||
device_id,
|
|
||||||
protocol.error(
|
|
||||||
protocol.ERR_UNSUPPORTED, f"unsupported media kind {kind!r}", id=frame.id
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
mime = str(payload.get("mime") or "application/octet-stream")[:128]
|
|
||||||
filename = str(payload.get("filename") or "upload")[:255]
|
|
||||||
size = payload.get("size")
|
|
||||||
try:
|
|
||||||
size = int(size) if size is not None else -1
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
size = -1
|
|
||||||
if size <= 0:
|
|
||||||
await self._ws_server.send_to(
|
|
||||||
device_id,
|
|
||||||
protocol.error(
|
|
||||||
protocol.ERR_UNSUPPORTED,
|
|
||||||
"media.upload.start requires a positive size",
|
|
||||||
id=frame.id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
if size > self.max_upload_bytes:
|
|
||||||
await self._ws_server.send_to(
|
|
||||||
device_id,
|
|
||||||
protocol.error(
|
|
||||||
protocol.ERR_MEDIA_TOO_LARGE,
|
|
||||||
f"upload of {size} bytes exceeds limit ({self.max_upload_bytes})",
|
|
||||||
id=frame.id,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
self._media.create_upload(
|
|
||||||
device_id,
|
|
||||||
media_ref,
|
|
||||||
kind,
|
|
||||||
mime,
|
|
||||||
filename,
|
|
||||||
size,
|
|
||||||
frame.id,
|
|
||||||
self.max_upload_bytes,
|
|
||||||
)
|
|
||||||
except media_bridge.MediaError as e:
|
|
||||||
await self._ws_server.send_to(device_id, protocol.error(e.code, e.message, id=frame.id))
|
|
||||||
return
|
|
||||||
# No ack: WS ordering guarantees the server processes this before the
|
|
||||||
# first binary chunk; failures arrive as ``error`` frames.
|
|
||||||
|
|
||||||
async def on_media_chunk(self, device_id: str, chunk: bytes) -> None:
|
|
||||||
session = self._media.get_upload(device_id)
|
|
||||||
if session is None:
|
|
||||||
return # stray binary frame: ignore (forward-compat)
|
|
||||||
session.feed(chunk)
|
|
||||||
if session.failed:
|
|
||||||
await self._ws_server.send_to(
|
|
||||||
device_id,
|
|
||||||
protocol.error(session.error_code, session.error_message, id=session.request_id),
|
|
||||||
)
|
|
||||||
self._media.discard_upload(device_id, session.media_ref)
|
|
||||||
|
|
||||||
async def on_media_upload_end(self, frame: protocol.Frame, device_id: str) -> None:
|
|
||||||
payload = frame.payload
|
|
||||||
media_ref = str(payload.get("media_ref") or "").strip()
|
|
||||||
sha256 = str(payload.get("sha256") or "").strip().lower()
|
|
||||||
if not media_ref:
|
|
||||||
await self._ws_server.send_to(
|
|
||||||
device_id,
|
|
||||||
protocol.error(
|
|
||||||
protocol.ERR_UNSUPPORTED, "media.upload.end requires media_ref", id=frame.id
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
entry = self._media.complete_upload(device_id, media_ref, sha256)
|
|
||||||
except media_bridge.MediaError as e:
|
|
||||||
await self._ws_server.send_to(device_id, protocol.error(e.code, e.message, id=frame.id))
|
|
||||||
return
|
|
||||||
await self._ws_server.send_to(
|
|
||||||
device_id, protocol.media_upload_ack(True, entry.media_id, id=frame.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def on_media_pull(self, frame: protocol.Frame, device_id: str) -> None:
|
|
||||||
payload = frame.payload
|
|
||||||
media_id = str(payload.get("media_id") or "").strip()
|
|
||||||
entry = self._media.get_outbound(media_id) if media_id else None
|
|
||||||
if entry is None:
|
|
||||||
await self._ws_server.send_to(
|
|
||||||
device_id,
|
|
||||||
protocol.error(
|
|
||||||
protocol.ERR_NOT_FOUND, f"unknown media_id {media_id!r}", id=frame.id
|
|
||||||
),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
# Delivery-path security: re-validate at pull time (the file may have
|
|
||||||
# moved / been replaced since the offer).
|
|
||||||
safe = validate_media_delivery_path(entry.path)
|
|
||||||
if safe is None:
|
|
||||||
await self._ws_server.send_to(
|
|
||||||
device_id,
|
|
||||||
protocol.error(protocol.ERR_NOT_FOUND, "media no longer deliverable", id=frame.id),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
conn = self._ws_server.connection(device_id)
|
|
||||||
if conn is None:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
await media_bridge.stream_file(conn.ws, safe, media_bridge.DEFAULT_CHUNK_BYTES)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning("android: media.pull stream failed for %s: %s", media_id, e)
|
|
||||||
await self._ws_server.send_to(
|
|
||||||
device_id, protocol.error(protocol.ERR_INTERNAL, f"pull failed: {e}", id=frame.id)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
await self._ws_server.send_to(device_id, protocol.media_pull_end(True, id=frame.id))
|
|
||||||
|
|
||||||
def on_connection_closed(self, device_id: str) -> None:
|
|
||||||
"""M4: drop in-flight upload temp files for a disconnected device."""
|
|
||||||
self._media.discard_device(device_id)
|
|
||||||
|
|
||||||
# ── M3: channel directory management (app -> agent) ───────────────────
|
# ── M3: channel directory management (app -> agent) ───────────────────
|
||||||
#
|
#
|
||||||
# Each request is answered by broadcasting the matching ``channel.*``
|
# Each request is answered by broadcasting the matching ``channel.*``
|
||||||
@@ -2320,7 +2210,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
payload = frame.payload
|
payload = frame.payload
|
||||||
name = payload.get("name")
|
name = payload.get("name")
|
||||||
if not isinstance(name, str) or not name.strip():
|
if not isinstance(name, str) or not name.strip():
|
||||||
await self._ws_server.send_to(
|
await self._reply(
|
||||||
device_id,
|
device_id,
|
||||||
protocol.error(
|
protocol.error(
|
||||||
protocol.ERR_UNSUPPORTED, "channel.create requires a name", id=frame.id
|
protocol.ERR_UNSUPPORTED, "channel.create requires a name", id=frame.id
|
||||||
@@ -2337,13 +2227,13 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
try:
|
try:
|
||||||
entry = self._channels.create(name=name, kind=kind, parent_chat_id=parent_chat_id)
|
entry = self._channels.create(name=name, kind=kind, parent_chat_id=parent_chat_id)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
await self._ws_server.send_to(
|
await self._reply(
|
||||||
device_id, protocol.error(protocol.ERR_UNSUPPORTED, str(e), id=frame.id)
|
device_id, protocol.error(protocol.ERR_UNSUPPORTED, str(e), id=frame.id)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
resp = protocol.channel_created(entry)
|
resp = protocol.channel_created(entry)
|
||||||
resp.id = frame.id
|
resp.id = frame.id
|
||||||
await self._ws_server.broadcast(resp)
|
await self._http_server.fanout(resp)
|
||||||
# M5: banner + push mirror (parked in the outbox when offline).
|
# M5: banner + push mirror (parked in the outbox when offline).
|
||||||
await self._broadcast_or_log(
|
await self._broadcast_or_log(
|
||||||
entry["chat_id"],
|
entry["chat_id"],
|
||||||
@@ -2358,7 +2248,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
async def on_channel_rename(self, frame: protocol.Frame, device_id: str) -> None:
|
async def on_channel_rename(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||||
await self._ws_server.send_to(
|
await self._reply(
|
||||||
device_id,
|
device_id,
|
||||||
protocol.error(
|
protocol.error(
|
||||||
protocol.ERR_NOT_FOUND, "channel.rename requires chat_id", id=frame.id
|
protocol.ERR_NOT_FOUND, "channel.rename requires chat_id", id=frame.id
|
||||||
@@ -2367,7 +2257,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
return
|
return
|
||||||
name = frame.payload.get("name")
|
name = frame.payload.get("name")
|
||||||
if not isinstance(name, str) or not name.strip():
|
if not isinstance(name, str) or not name.strip():
|
||||||
await self._ws_server.send_to(
|
await self._reply(
|
||||||
device_id,
|
device_id,
|
||||||
protocol.error(
|
protocol.error(
|
||||||
protocol.ERR_UNSUPPORTED, "channel.rename requires a name", id=frame.id
|
protocol.ERR_UNSUPPORTED, "channel.rename requires a name", id=frame.id
|
||||||
@@ -2377,19 +2267,19 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
try:
|
try:
|
||||||
entry = self._channels.rename(chat_id, name)
|
entry = self._channels.rename(chat_id, name)
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
await self._ws_server.send_to(
|
await self._reply(
|
||||||
device_id, protocol.error(protocol.ERR_UNSUPPORTED, str(e), id=frame.id)
|
device_id, protocol.error(protocol.ERR_UNSUPPORTED, str(e), id=frame.id)
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
if entry is None:
|
if entry is None:
|
||||||
await self._ws_server.send_to(
|
await self._reply(
|
||||||
device_id,
|
device_id,
|
||||||
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
|
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
resp = protocol.channel_renamed(entry)
|
resp = protocol.channel_renamed(entry)
|
||||||
resp.id = frame.id
|
resp.id = frame.id
|
||||||
await self._ws_server.broadcast(resp)
|
await self._http_server.fanout(resp)
|
||||||
# M5: banner + push mirror (parked in the outbox when offline).
|
# M5: banner + push mirror (parked in the outbox when offline).
|
||||||
await self._broadcast_or_log(
|
await self._broadcast_or_log(
|
||||||
chat_id,
|
chat_id,
|
||||||
@@ -2404,7 +2294,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
async def on_channel_set_default(self, frame: protocol.Frame, device_id: str) -> None:
|
async def on_channel_set_default(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||||
await self._ws_server.send_to(
|
await self._reply(
|
||||||
device_id,
|
device_id,
|
||||||
protocol.error(
|
protocol.error(
|
||||||
protocol.ERR_NOT_FOUND, "channel.set_default requires chat_id", id=frame.id
|
protocol.ERR_NOT_FOUND, "channel.set_default requires chat_id", id=frame.id
|
||||||
@@ -2413,7 +2303,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
return
|
return
|
||||||
entry = self._channels.set_default(chat_id)
|
entry = self._channels.set_default(chat_id)
|
||||||
if entry is None:
|
if entry is None:
|
||||||
await self._ws_server.send_to(
|
await self._reply(
|
||||||
device_id,
|
device_id,
|
||||||
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
|
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
|
||||||
)
|
)
|
||||||
@@ -2422,12 +2312,12 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
# new is_default flag) so every device reconciles the default change.
|
# new is_default flag) so every device reconciles the default change.
|
||||||
resp = protocol.channel_renamed(entry)
|
resp = protocol.channel_renamed(entry)
|
||||||
resp.id = frame.id
|
resp.id = frame.id
|
||||||
await self._ws_server.broadcast(resp)
|
await self._http_server.fanout(resp)
|
||||||
|
|
||||||
async def on_channel_favorite(self, frame: protocol.Frame, device_id: str) -> None:
|
async def on_channel_favorite(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||||
await self._ws_server.send_to(
|
await self._reply(
|
||||||
device_id,
|
device_id,
|
||||||
protocol.error(
|
protocol.error(
|
||||||
protocol.ERR_NOT_FOUND, "channel.favorite requires chat_id", id=frame.id
|
protocol.ERR_NOT_FOUND, "channel.favorite requires chat_id", id=frame.id
|
||||||
@@ -2437,7 +2327,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
on = bool(frame.payload.get("on"))
|
on = bool(frame.payload.get("on"))
|
||||||
entry = self._channels.set_favorite(chat_id, on)
|
entry = self._channels.set_favorite(chat_id, on)
|
||||||
if entry is None:
|
if entry is None:
|
||||||
await self._ws_server.send_to(
|
await self._reply(
|
||||||
device_id,
|
device_id,
|
||||||
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
|
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
|
||||||
)
|
)
|
||||||
@@ -2446,12 +2336,12 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
# new favorite flag) so every device reconciles the change.
|
# new favorite flag) so every device reconciles the change.
|
||||||
resp = protocol.channel_renamed(entry)
|
resp = protocol.channel_renamed(entry)
|
||||||
resp.id = frame.id
|
resp.id = frame.id
|
||||||
await self._ws_server.broadcast(resp)
|
await self._http_server.fanout(resp)
|
||||||
|
|
||||||
async def on_channel_icon(self, frame: protocol.Frame, device_id: str) -> None:
|
async def on_channel_icon(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||||
await self._ws_server.send_to(
|
await self._reply(
|
||||||
device_id,
|
device_id,
|
||||||
protocol.error(
|
protocol.error(
|
||||||
protocol.ERR_NOT_FOUND, "channel.icon requires chat_id", id=frame.id
|
protocol.ERR_NOT_FOUND, "channel.icon requires chat_id", id=frame.id
|
||||||
@@ -2465,26 +2355,26 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
color = color if isinstance(color, str) and color else None
|
color = color if isinstance(color, str) and color else None
|
||||||
# Guard against a runaway base64 blob (a channel icon is small).
|
# Guard against a runaway base64 blob (a channel icon is small).
|
||||||
if icon is not None and len(icon) > 512 * 1024:
|
if icon is not None and len(icon) > 512 * 1024:
|
||||||
await self._ws_server.send_to(
|
await self._reply(
|
||||||
device_id,
|
device_id,
|
||||||
protocol.error(protocol.ERR_UNSUPPORTED, "channel icon too large", id=frame.id),
|
protocol.error(protocol.ERR_UNSUPPORTED, "channel icon too large", id=frame.id),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
entry = self._channels.set_icon(chat_id, icon, color)
|
entry = self._channels.set_icon(chat_id, icon, color)
|
||||||
if entry is None:
|
if entry is None:
|
||||||
await self._ws_server.send_to(
|
await self._reply(
|
||||||
device_id,
|
device_id,
|
||||||
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
|
protocol.error(protocol.ERR_NOT_FOUND, f"unknown chat_id {chat_id}", id=frame.id),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
resp = protocol.channel_renamed(entry)
|
resp = protocol.channel_renamed(entry)
|
||||||
resp.id = frame.id
|
resp.id = frame.id
|
||||||
await self._ws_server.broadcast(resp)
|
await self._http_server.fanout(resp)
|
||||||
|
|
||||||
async def on_channel_set_automation(self, frame: protocol.Frame, device_id: str) -> None:
|
async def on_channel_set_automation(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||||
await self._ws_server.send_to(
|
await self._reply(
|
||||||
device_id,
|
device_id,
|
||||||
protocol.error(
|
protocol.error(
|
||||||
protocol.ERR_NOT_FOUND, "channel.set_automation requires chat_id", id=frame.id
|
protocol.ERR_NOT_FOUND, "channel.set_automation requires chat_id", id=frame.id
|
||||||
@@ -2494,7 +2384,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
on = bool(frame.payload.get("on"))
|
on = bool(frame.payload.get("on"))
|
||||||
entry = self._channels.set_automation(chat_id, on)
|
entry = self._channels.set_automation(chat_id, on)
|
||||||
if entry is None:
|
if entry is None:
|
||||||
await self._ws_server.send_to(
|
await self._reply(
|
||||||
device_id,
|
device_id,
|
||||||
protocol.error(
|
protocol.error(
|
||||||
protocol.ERR_NOT_FOUND,
|
protocol.ERR_NOT_FOUND,
|
||||||
@@ -2507,12 +2397,12 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
# new automation flag) so every device reconciles the change.
|
# new automation flag) so every device reconciles the change.
|
||||||
resp = protocol.channel_renamed(entry)
|
resp = protocol.channel_renamed(entry)
|
||||||
resp.id = frame.id
|
resp.id = frame.id
|
||||||
await self._ws_server.broadcast(resp)
|
await self._http_server.fanout(resp)
|
||||||
|
|
||||||
async def on_channel_delete(self, frame: protocol.Frame, device_id: str) -> None:
|
async def on_channel_delete(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
chat_id = frame.chat_id or frame.payload.get("chat_id")
|
||||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||||
await self._ws_server.send_to(
|
await self._reply(
|
||||||
device_id,
|
device_id,
|
||||||
protocol.error(
|
protocol.error(
|
||||||
protocol.ERR_NOT_FOUND, "channel.delete requires chat_id", id=frame.id
|
protocol.ERR_NOT_FOUND, "channel.delete requires chat_id", id=frame.id
|
||||||
@@ -2521,7 +2411,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
return
|
return
|
||||||
entry = self._channels.delete(chat_id)
|
entry = self._channels.delete(chat_id)
|
||||||
if entry is None:
|
if entry is None:
|
||||||
await self._ws_server.send_to(
|
await self._reply(
|
||||||
device_id,
|
device_id,
|
||||||
protocol.error(
|
protocol.error(
|
||||||
protocol.ERR_NOT_FOUND,
|
protocol.ERR_NOT_FOUND,
|
||||||
@@ -2554,7 +2444,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
)
|
)
|
||||||
resp = protocol.channel_deleted(chat_id)
|
resp = protocol.channel_deleted(chat_id)
|
||||||
resp.id = frame.id
|
resp.id = frame.id
|
||||||
await self._ws_server.broadcast(resp)
|
await self._http_server.fanout(resp)
|
||||||
# M5: banner + push mirror (parked in the outbox when offline).
|
# M5: banner + push mirror (parked in the outbox when offline).
|
||||||
await self._broadcast_or_log(
|
await self._broadcast_or_log(
|
||||||
chat_id,
|
chat_id,
|
||||||
@@ -2570,7 +2460,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
channels = self._channels.list(include_archived=False)
|
channels = self._channels.list(include_archived=False)
|
||||||
resp = protocol.channel_list(channels)
|
resp = protocol.channel_list(channels)
|
||||||
resp.id = frame.id
|
resp.id = frame.id
|
||||||
await self._ws_server.send_to(device_id, resp)
|
await self._reply(device_id, resp)
|
||||||
|
|
||||||
# ── Slash-command catalog (app's "/" drawer) ──────────────────────────
|
# ── Slash-command catalog (app's "/" drawer) ──────────────────────────
|
||||||
|
|
||||||
@@ -2581,7 +2471,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
the typed prefix client-side; the catalog is static per gateway run,
|
the typed prefix client-side; the catalog is static per gateway run,
|
||||||
so no caching is needed here."""
|
so no caching is needed here."""
|
||||||
resp = protocol.commands_catalog(_slash_command_catalog(), id=frame.id)
|
resp = protocol.commands_catalog(_slash_command_catalog(), id=frame.id)
|
||||||
await self._ws_server.send_to(device_id, resp)
|
await self._reply(device_id, resp)
|
||||||
|
|
||||||
# ── M3: search (app -> agent) ─────────────────────────────────────────
|
# ── M3: search (app -> agent) ─────────────────────────────────────────
|
||||||
|
|
||||||
@@ -2589,7 +2479,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
payload = frame.payload
|
payload = frame.payload
|
||||||
query = payload.get("query")
|
query = payload.get("query")
|
||||||
if not isinstance(query, str) or not query.strip():
|
if not isinstance(query, str) or not query.strip():
|
||||||
await self._ws_server.send_to(
|
await self._reply(
|
||||||
device_id,
|
device_id,
|
||||||
protocol.error(protocol.ERR_UNSUPPORTED, "search requires a query", id=frame.id),
|
protocol.error(protocol.ERR_UNSUPPORTED, "search requires a query", id=frame.id),
|
||||||
)
|
)
|
||||||
@@ -2612,7 +2502,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
db_path, query, scope=scope, chat_id=chat_id, thread_id=thread_id, limit=limit
|
db_path, query, scope=scope, chat_id=chat_id, thread_id=thread_id, limit=limit
|
||||||
)
|
)
|
||||||
resp = protocol.search_results(query, scope, hits, id=frame.id)
|
resp = protocol.search_results(query, scope, hits, id=frame.id)
|
||||||
await self._ws_server.send_to(device_id, resp)
|
await self._reply(device_id, resp)
|
||||||
|
|
||||||
# ── M3: sync (reconnect catch-up) ─────────────────────────────────────
|
# ── M3: sync (reconnect catch-up) ─────────────────────────────────────
|
||||||
|
|
||||||
@@ -2639,9 +2529,9 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
cursor=e.get("cursor"),
|
cursor=e.get("cursor"),
|
||||||
v=raw.get("v") if isinstance(raw.get("v"), int) else protocol.PROTOCOL_VERSION,
|
v=raw.get("v") if isinstance(raw.get("v"), int) else protocol.PROTOCOL_VERSION,
|
||||||
)
|
)
|
||||||
await self._ws_server.send_to(device_id, replayed)
|
await self._reply(device_id, replayed)
|
||||||
done = protocol.sync_done(self._outbox.latest_cursor(), id=frame.id)
|
done = protocol.sync_done(self._outbox.latest_cursor(), id=frame.id)
|
||||||
await self._ws_server.send_to(device_id, done)
|
await self._reply(device_id, done)
|
||||||
|
|
||||||
# ── Full message history (initial channel open / scroll-up) ───────────
|
# ── Full message history (initial channel open / scroll-up) ───────────
|
||||||
|
|
||||||
@@ -2658,7 +2548,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
chat_id = frame.chat_id or payload.get("chat_id")
|
chat_id = frame.chat_id or payload.get("chat_id")
|
||||||
logger.info("android: history request from %s chat_id=%r", device_id, chat_id)
|
logger.info("android: history request from %s chat_id=%r", device_id, chat_id)
|
||||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||||
await self._ws_server.send_to(
|
await self._reply(
|
||||||
device_id,
|
device_id,
|
||||||
protocol.error(protocol.ERR_UNSUPPORTED, "history requires a chat_id", id=frame.id),
|
protocol.error(protocol.ERR_UNSUPPORTED, "history requires a chat_id", id=frame.id),
|
||||||
)
|
)
|
||||||
@@ -2689,7 +2579,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
oldest_message_id=page["oldest_message_id"],
|
oldest_message_id=page["oldest_message_id"],
|
||||||
id=frame.id,
|
id=frame.id,
|
||||||
)
|
)
|
||||||
await self._ws_server.send_to(device_id, resp)
|
await self._reply(device_id, resp)
|
||||||
|
|
||||||
# ── Message deletion (app -> agent) ───────────────────────────────────
|
# ── Message deletion (app -> agent) ───────────────────────────────────
|
||||||
|
|
||||||
@@ -2708,7 +2598,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
payload = frame.payload
|
payload = frame.payload
|
||||||
chat_id = frame.chat_id or payload.get("chat_id")
|
chat_id = frame.chat_id or payload.get("chat_id")
|
||||||
if not isinstance(chat_id, str) or not chat_id.strip():
|
if not isinstance(chat_id, str) or not chat_id.strip():
|
||||||
await self._ws_server.send_to(
|
await self._reply(
|
||||||
device_id,
|
device_id,
|
||||||
protocol.error(
|
protocol.error(
|
||||||
protocol.ERR_NOT_FOUND, "message.delete requires chat_id", id=frame.id
|
protocol.ERR_NOT_FOUND, "message.delete requires chat_id", id=frame.id
|
||||||
@@ -2724,7 +2614,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
message_ids = [payload.get("message_id")] if payload.get("message_id") else []
|
message_ids = [payload.get("message_id")] if payload.get("message_id") else []
|
||||||
message_ids = [m for m in message_ids if isinstance(m, str) and m.strip()]
|
message_ids = [m for m in message_ids if isinstance(m, str) and m.strip()]
|
||||||
if not message_ids:
|
if not message_ids:
|
||||||
await self._ws_server.send_to(
|
await self._reply(
|
||||||
device_id,
|
device_id,
|
||||||
protocol.error(
|
protocol.error(
|
||||||
protocol.ERR_UNSUPPORTED, "message.delete requires message_ids", id=frame.id
|
protocol.ERR_UNSUPPORTED, "message.delete requires message_ids", id=frame.id
|
||||||
@@ -2766,8 +2656,8 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
async def on_fcm_register(self, frame: protocol.Frame, device_id: str) -> None:
|
async def on_fcm_register(self, frame: protocol.Frame, device_id: str) -> None:
|
||||||
"""Update the device's push tokens (FCM rotation / ntfy topic).
|
"""Update the device's push tokens (FCM rotation / ntfy topic).
|
||||||
|
|
||||||
Persists to the device registry AND refreshes the live connection so
|
Persists to the device registry so the next push targets the current
|
||||||
the next push targets the current token without a stale read.
|
token without a stale read.
|
||||||
"""
|
"""
|
||||||
fcm_token = frame.payload.get("fcm_token")
|
fcm_token = frame.payload.get("fcm_token")
|
||||||
ntfy_topic = frame.payload.get("ntfy_topic")
|
ntfy_topic = frame.payload.get("ntfy_topic")
|
||||||
@@ -2780,12 +2670,6 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("android: fcm.register update failed", exc_info=True)
|
logger.warning("android: fcm.register update failed", exc_info=True)
|
||||||
return
|
return
|
||||||
conn = self._ws_server.connection(device_id)
|
|
||||||
if conn is not None:
|
|
||||||
if fcm_token is not None:
|
|
||||||
conn.fcm_token = fcm_token
|
|
||||||
if ntfy_topic is not None:
|
|
||||||
conn.ntfy_topic = ntfy_topic
|
|
||||||
logger.info("android: push tokens updated for %s", device_id)
|
logger.info("android: push tokens updated for %s", device_id)
|
||||||
|
|
||||||
# ── M5: approval / clarify banners ────────────────────────────────────
|
# ── M5: approval / clarify banners ────────────────────────────────────
|
||||||
@@ -2987,7 +2871,7 @@ class AndroidAdapter(BasePlatformAdapter):
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("android: create_handoff_thread failed", exc_info=True)
|
logger.warning("android: create_handoff_thread failed", exc_info=True)
|
||||||
return None
|
return None
|
||||||
await self._ws_server.broadcast(protocol.channel_created(entry))
|
await self._broadcast_both(protocol.channel_created(entry))
|
||||||
return entry["chat_id"]
|
return entry["chat_id"]
|
||||||
|
|
||||||
|
|
||||||
@@ -3029,7 +2913,7 @@ def register(ctx):
|
|||||||
validate_config=validate_config,
|
validate_config=validate_config,
|
||||||
is_connected=is_connected,
|
is_connected=is_connected,
|
||||||
required_env=["ANDROID_TOKEN"],
|
required_env=["ANDROID_TOKEN"],
|
||||||
install_hint="No extra packages needed (websockets + httpx are core deps)",
|
install_hint="No extra packages needed (httpx is a core dep)",
|
||||||
setup_fn=interactive_setup,
|
setup_fn=interactive_setup,
|
||||||
# Env-driven auto-configuration: seeds PlatformConfig.extra with
|
# Env-driven auto-configuration: seeds PlatformConfig.extra with
|
||||||
# host/port/push_backend + home_channel so env-only setups show up in
|
# host/port/push_backend + home_channel so env-only setups show up in
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Shared inbound frame dispatch + inbound rate limit.
|
||||||
|
|
||||||
|
Extracted from the (now-removed) WS server so the HTTP transport has a
|
||||||
|
single home for the transport-agnostic dispatch chain and the per-device
|
||||||
|
token bucket. The HTTP leg (``http_server.py``) is the only transport;
|
||||||
|
this module is transport-neutral.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from . import protocol
|
||||||
|
|
||||||
|
# Inbound JSON control-frame rate limit (per device, token bucket).
|
||||||
|
# A legitimate app sends occasional user-initiated requests — far below
|
||||||
|
# 20/s sustained. Media uploads are exempt (they travel via
|
||||||
|
# ``POST /v1/media``, not the frame endpoint).
|
||||||
|
INBOUND_RATE_PER_S = 20.0
|
||||||
|
INBOUND_BURST = 40
|
||||||
|
|
||||||
|
# Max length of a client-supplied device_id.
|
||||||
|
MAX_DEVICE_ID_LEN = 128
|
||||||
|
|
||||||
|
|
||||||
|
async def dispatch_frame(adapter: Any, frame: protocol.Frame, device_id: str) -> None:
|
||||||
|
"""Shared inbound frame dispatch (docs/19 §19.4). Unknown types are
|
||||||
|
ignored (forward-compat)."""
|
||||||
|
if frame.type == protocol.TYPE_MESSAGE_SEND:
|
||||||
|
await adapter.on_message_send(frame, device_id)
|
||||||
|
elif frame.type == protocol.TYPE_CHANNEL_CREATE:
|
||||||
|
await adapter.on_channel_create(frame, device_id)
|
||||||
|
elif frame.type == protocol.TYPE_CHANNEL_RENAME:
|
||||||
|
await adapter.on_channel_rename(frame, device_id)
|
||||||
|
elif frame.type == protocol.TYPE_CHANNEL_SET_DEFAULT:
|
||||||
|
await adapter.on_channel_set_default(frame, device_id)
|
||||||
|
elif frame.type == protocol.TYPE_CHANNEL_FAVORITE:
|
||||||
|
await adapter.on_channel_favorite(frame, device_id)
|
||||||
|
elif frame.type == protocol.TYPE_CHANNEL_ICON:
|
||||||
|
await adapter.on_channel_icon(frame, device_id)
|
||||||
|
elif frame.type == protocol.TYPE_CHANNEL_SET_AUTOMATION:
|
||||||
|
await adapter.on_channel_set_automation(frame, device_id)
|
||||||
|
elif frame.type == protocol.TYPE_CHANNEL_DELETE:
|
||||||
|
await adapter.on_channel_delete(frame, device_id)
|
||||||
|
elif frame.type == protocol.TYPE_CHANNEL_LIST:
|
||||||
|
await adapter.on_channel_list(frame, device_id)
|
||||||
|
elif frame.type == protocol.TYPE_COMMANDS_CATALOG:
|
||||||
|
await adapter.on_commands_catalog(frame, device_id)
|
||||||
|
elif frame.type == protocol.TYPE_SEARCH:
|
||||||
|
await adapter.on_search(frame, device_id)
|
||||||
|
elif frame.type == protocol.TYPE_SYNC:
|
||||||
|
await adapter.on_sync(frame, device_id)
|
||||||
|
elif frame.type == protocol.TYPE_HISTORY:
|
||||||
|
await adapter.on_history(frame, device_id)
|
||||||
|
elif frame.type == protocol.TYPE_MESSAGE_DELETE:
|
||||||
|
await adapter.on_message_delete(frame, device_id)
|
||||||
|
elif frame.type == protocol.TYPE_FCM_REGISTER:
|
||||||
|
await adapter.on_fcm_register(frame, device_id)
|
||||||
|
# Unknown types are ignored (forward-compat).
|
||||||
|
|
||||||
|
|
||||||
|
class _TokenBucket:
|
||||||
|
"""Minimal token bucket (stdlib only). One instance per device."""
|
||||||
|
|
||||||
|
__slots__ = ("rate", "burst", "tokens", "updated_at")
|
||||||
|
|
||||||
|
def __init__(self, rate: float, burst: int):
|
||||||
|
self.rate = rate
|
||||||
|
self.burst = burst
|
||||||
|
self.tokens = float(burst)
|
||||||
|
self.updated_at = time.monotonic()
|
||||||
|
|
||||||
|
def consume(self) -> bool:
|
||||||
|
"""Try to take one token. Refills at ``rate``/s up to ``burst``."""
|
||||||
|
now = time.monotonic()
|
||||||
|
elapsed = now - self.updated_at
|
||||||
|
if elapsed > 0:
|
||||||
|
self.tokens = min(self.burst, self.tokens + elapsed * self.rate)
|
||||||
|
self.updated_at = now
|
||||||
|
if self.tokens >= 1.0:
|
||||||
|
self.tokens -= 1.0
|
||||||
|
return True
|
||||||
|
return False
|
||||||
@@ -0,0 +1,843 @@
|
|||||||
|
"""HTTP transport (docs/19): the gateway's device-facing server.
|
||||||
|
|
||||||
|
Short-lived-connection transport: the same JSON frames, the same
|
||||||
|
outbox/cursor, the same token — served over plain HTTP by the gateway.
|
||||||
|
The app sends over ``POST /v1/frame`` and receives over
|
||||||
|
``GET /v1/events`` (SSE) or ``GET /v1/poll`` (long-poll); media travels
|
||||||
|
via ``POST /v1/media`` / ``GET /v1/media/{id}`` (v2, docs/19 §19.15).
|
||||||
|
|
||||||
|
Zero new Python dependencies: stdlib ``http.server`` (a
|
||||||
|
``ThreadingHTTPServer`` in a daemon thread) bridged into the gateway's
|
||||||
|
asyncio loop with ``asyncio.run_coroutine_threadsafe``.
|
||||||
|
|
||||||
|
Endpoints (docs/19 §19.4):
|
||||||
|
* ``GET /v1/health`` — unauthenticated liveness probe.
|
||||||
|
* ``POST /v1/frame`` — accept-and-ack for any JSON frame the
|
||||||
|
app sends (media uses the /v1/media
|
||||||
|
endpoints; hello/ping are
|
||||||
|
transport-specific).
|
||||||
|
* ``GET /v1/events?cursor=N`` — SSE stream: outbox catch-up, then live
|
||||||
|
frames (``id`` = outbox cursor, so resume
|
||||||
|
is just ``Last-Event-ID``).
|
||||||
|
* ``GET /v1/poll?cursor=N`` — long-poll fallback where SSE is blocked.
|
||||||
|
* ``POST /v1/media`` — media upload (docs/19 §19.15, v2): the
|
||||||
|
whole file as the request body; metadata
|
||||||
|
in ``X-Iris-Media-*`` headers; sha256
|
||||||
|
contract per docs/07 §7.2.
|
||||||
|
* ``GET /v1/media/{media_id}`` — media pull (docs/19 §19.15, v2): streams
|
||||||
|
an outbound offer (``media.offer`` id)
|
||||||
|
as the response body.
|
||||||
|
|
||||||
|
Auth: ``Authorization: Bearer <token>`` (constant-time ``verify_token``) +
|
||||||
|
``X-Iris-Device`` header (device id / allowlist). Device registration
|
||||||
|
(name + push tokens) rides on the SSE open via ``X-Iris-Device-Name`` /
|
||||||
|
``X-Iris-Fcm-Token`` / ``X-Iris-Ntfy-Topic`` headers (the HTTP equivalent
|
||||||
|
of the old WS ``hello`` upsert).
|
||||||
|
|
||||||
|
HTTP is the ONLY transport: a bind failure is FATAL (the app has no other
|
||||||
|
way to reach the gateway).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import queue
|
||||||
|
import ssl
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from typing import Any
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
from . import dispatch, protocol
|
||||||
|
from . import media as media_bridge
|
||||||
|
from .pairing import verify_token
|
||||||
|
|
||||||
|
try: # main-repo import (same as adapter.py); absent in bare unit contexts
|
||||||
|
from gateway.platforms.base import validate_media_delivery_path
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
validate_media_delivery_path = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Default port for the HTTP transport (the WS-era default was 8790).
|
||||||
|
DEFAULT_HTTP_PORT = 8791
|
||||||
|
|
||||||
|
# Request body cap for POST /v1/frame. Frames are usually small, but a
|
||||||
|
# ``channel.icon`` carries a base64 blob up to 512 KiB (docs/10), so the cap
|
||||||
|
# must clear that with headroom. Media never travels here (it uses
|
||||||
|
# POST /v1/media).
|
||||||
|
MAX_BODY_BYTES = 1024 * 1024
|
||||||
|
|
||||||
|
# Per-subscriber live-frame queue. A subscriber that can't keep up is
|
||||||
|
# dropped; it reconnects with Last-Event-ID and catches up from the outbox.
|
||||||
|
SUB_QUEUE_MAX = 256
|
||||||
|
|
||||||
|
# SSE comment heartbeat cadence (keeps proxies from idling the stream).
|
||||||
|
SSE_HEARTBEAT_S = 15.0
|
||||||
|
|
||||||
|
# Long-poll hold time (docs/19 §19.6).
|
||||||
|
POLL_TIMEOUT_S = 25.0
|
||||||
|
|
||||||
|
# How long POST /v1/frame waits for a synchronous validation rejection
|
||||||
|
# before acking 202 and letting the handler (e.g. the agent turn) run on.
|
||||||
|
ACCEPT_ACK_TIMEOUT_S = 5.0
|
||||||
|
|
||||||
|
# Sentinel pushed into subscriber queues on shutdown.
|
||||||
|
_STOP = object()
|
||||||
|
|
||||||
|
# Max length of a client-supplied media_ref (same as the WS path).
|
||||||
|
MAX_MEDIA_REF_LEN = 64
|
||||||
|
|
||||||
|
# MediaError code -> HTTP status for the /v1/media endpoints.
|
||||||
|
_MEDIA_STATUS = {
|
||||||
|
protocol.ERR_MEDIA_TOO_LARGE: 413,
|
||||||
|
protocol.ERR_NOT_FOUND: 404,
|
||||||
|
protocol.ERR_UNSUPPORTED: 400,
|
||||||
|
protocol.ERR_INTERNAL: 500,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _with_cursor(frame: dict[str, Any], cursor: int) -> str:
|
||||||
|
"""Re-serialize an outbox frame dict with its cursor in the envelope
|
||||||
|
(same tagging ``sync`` replay uses, docs/08 §8.7)."""
|
||||||
|
d = dict(frame)
|
||||||
|
d["cursor"] = cursor
|
||||||
|
return json.dumps(d, separators=(",", ":"), ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_cursor(*raws: Any) -> int:
|
||||||
|
"""First parseable non-negative int wins (``?cursor=`` beats
|
||||||
|
``Last-Event-ID``); 0 when nothing usable."""
|
||||||
|
for raw in raws:
|
||||||
|
if raw is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
v = int(str(raw).strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if v >= 0:
|
||||||
|
return v
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _send_json(handler: BaseHTTPRequestHandler, status: int, obj: Any) -> None:
|
||||||
|
body = json.dumps(obj, separators=(",", ":")).encode("utf-8")
|
||||||
|
handler.send_response(status)
|
||||||
|
handler.send_header("Content-Type", "application/json")
|
||||||
|
handler.send_header("Content-Length", str(len(body)))
|
||||||
|
handler.end_headers()
|
||||||
|
with contextlib.suppress(BrokenPipeError, ConnectionResetError, OSError):
|
||||||
|
handler.wfile.write(body)
|
||||||
|
handler.wfile.flush()
|
||||||
|
|
||||||
|
|
||||||
|
def _send_frame_json(handler: BaseHTTPRequestHandler, status: int, frame_json: str) -> None:
|
||||||
|
"""Send a protocol frame as the HTTP response body (docs/19 §19.7:
|
||||||
|
error frames double as the HTTP response)."""
|
||||||
|
body = frame_json.encode("utf-8")
|
||||||
|
handler.send_response(status)
|
||||||
|
handler.send_header("Content-Type", "application/json")
|
||||||
|
handler.send_header("Content-Length", str(len(body)))
|
||||||
|
handler.end_headers()
|
||||||
|
with contextlib.suppress(BrokenPipeError, ConnectionResetError, OSError):
|
||||||
|
handler.wfile.write(body)
|
||||||
|
handler.wfile.flush()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Subscriber:
|
||||||
|
"""One live HTTP subscriber (SSE stream or long-poll request)."""
|
||||||
|
|
||||||
|
device_id: str
|
||||||
|
kind: str # "sse" | "poll"
|
||||||
|
q: queue.Queue = field(default_factory=lambda: queue.Queue(maxsize=SUB_QUEUE_MAX))
|
||||||
|
closed: threading.Event = field(default_factory=threading.Event)
|
||||||
|
|
||||||
|
|
||||||
|
class HttpServer:
|
||||||
|
"""The plugin's HTTP server + live subscriber registry.
|
||||||
|
|
||||||
|
The handler threads never touch adapter state directly: inbound frames
|
||||||
|
are bridged into the gateway's asyncio loop (captured at ``start()``)
|
||||||
|
with ``asyncio.run_coroutine_threadsafe`` and dispatched through
|
||||||
|
``dispatch_frame`` (``dispatch.py``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, adapter: Any, devices: Any):
|
||||||
|
self._adapter = adapter
|
||||||
|
self._devices = devices
|
||||||
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
self._httpd: _ThreadingHTTPD | None = None
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._subs: dict[str, list[_Subscriber]] = {}
|
||||||
|
self._subs_lock = threading.Lock()
|
||||||
|
self._buckets: dict[str, dispatch._TokenBucket] = {}
|
||||||
|
self._buckets_lock = threading.Lock()
|
||||||
|
self._lock_key: str | None = None
|
||||||
|
self.enabled = False
|
||||||
|
self.bound_port = 0
|
||||||
|
|
||||||
|
# ── Lifecycle ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def start(self) -> None:
|
||||||
|
"""Bind and start serving. NEVER raises: a bind failure leaves
|
||||||
|
``enabled`` False, which the adapter treats as a fatal error
|
||||||
|
(HTTP is the only transport, docs/19 §19.4)."""
|
||||||
|
if self.enabled:
|
||||||
|
return
|
||||||
|
self._loop = asyncio.get_running_loop()
|
||||||
|
host = self._adapter.host
|
||||||
|
port = self._adapter.http_port
|
||||||
|
|
||||||
|
# Port-conflict lock: same flock pattern the WS uses.
|
||||||
|
try:
|
||||||
|
from gateway.status import acquire_scoped_lock
|
||||||
|
|
||||||
|
lock_key = f"http:{host}:{port}"
|
||||||
|
if not acquire_scoped_lock("android", lock_key):
|
||||||
|
logger.warning(
|
||||||
|
"android: HTTP port %s:%s in use by another profile; server disabled",
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
self._lock_key = lock_key
|
||||||
|
except ImportError:
|
||||||
|
self._lock_key = None # status module not available (e.g. tests)
|
||||||
|
|
||||||
|
try:
|
||||||
|
httpd = _ThreadingHTTPD((host, port), self)
|
||||||
|
self.bound_port = int(httpd.server_address[1])
|
||||||
|
if self._adapter.http_cert and self._adapter.http_key:
|
||||||
|
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
||||||
|
ctx.load_cert_chain(self._adapter.http_cert, self._adapter.http_key)
|
||||||
|
httpd.socket = ctx.wrap_socket(httpd.socket, server_side=True)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("android: HTTP server disabled (bind %s:%s failed: %s)", host, port, e)
|
||||||
|
self._release_lock()
|
||||||
|
return
|
||||||
|
|
||||||
|
self._httpd = httpd
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=httpd.serve_forever, name="android-http", daemon=True
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
self.enabled = True
|
||||||
|
scheme = "https" if (self._adapter.http_cert and self._adapter.http_key) else "http"
|
||||||
|
logger.info("android: HTTP server listening on %s://%s:%s", scheme, host, self.bound_port)
|
||||||
|
|
||||||
|
async def stop(self) -> None:
|
||||||
|
"""Stop serving and unblock all subscribers."""
|
||||||
|
self.enabled = False
|
||||||
|
with self._subs_lock:
|
||||||
|
subs = [s for lst in self._subs.values() for s in lst]
|
||||||
|
self._subs.clear()
|
||||||
|
for s in subs:
|
||||||
|
s.closed.set()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
s.q.put_nowait(_STOP)
|
||||||
|
httpd = self._httpd
|
||||||
|
self._httpd = None
|
||||||
|
if httpd is not None:
|
||||||
|
# shutdown() must be called from a thread other than the one
|
||||||
|
# running serve_forever(); we are on the asyncio loop thread.
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
httpd.shutdown()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
httpd.server_close()
|
||||||
|
t = self._thread
|
||||||
|
self._thread = None
|
||||||
|
if t is not None and t is not threading.current_thread():
|
||||||
|
t.join(timeout=5.0)
|
||||||
|
self._release_lock()
|
||||||
|
|
||||||
|
def _release_lock(self) -> None:
|
||||||
|
with contextlib.suppress(ImportError):
|
||||||
|
from gateway.status import release_scoped_lock
|
||||||
|
|
||||||
|
if self._lock_key:
|
||||||
|
release_scoped_lock("android", self._lock_key)
|
||||||
|
self._lock_key = None
|
||||||
|
|
||||||
|
# ── Subscriber registry ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def has_devices(self) -> bool:
|
||||||
|
with self._subs_lock:
|
||||||
|
return bool(self._subs)
|
||||||
|
|
||||||
|
def device_ids(self) -> list:
|
||||||
|
with self._subs_lock:
|
||||||
|
return list(self._subs.keys())
|
||||||
|
|
||||||
|
def _add_sub(self, sub: _Subscriber) -> None:
|
||||||
|
with self._subs_lock:
|
||||||
|
self._subs.setdefault(sub.device_id, []).append(sub)
|
||||||
|
|
||||||
|
def _remove_sub(self, sub: _Subscriber) -> None:
|
||||||
|
with self._subs_lock:
|
||||||
|
lst = self._subs.get(sub.device_id)
|
||||||
|
if lst:
|
||||||
|
with contextlib.suppress(ValueError):
|
||||||
|
lst.remove(sub)
|
||||||
|
if not lst:
|
||||||
|
del self._subs[sub.device_id]
|
||||||
|
|
||||||
|
# ── Outbound ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def fanout(self, frame: protocol.Frame, cursor: int | None = None) -> int:
|
||||||
|
"""Push a frame to every live HTTP subscriber. Returns subscribers
|
||||||
|
reached — the HTTP half of the delivery count (docs/19 §19.8): a
|
||||||
|
device reading SSE is a live subscriber, so a frame fanned out here
|
||||||
|
must not also fire a push."""
|
||||||
|
if not self.enabled:
|
||||||
|
return 0
|
||||||
|
data = frame.to_json()
|
||||||
|
with self._subs_lock:
|
||||||
|
subs = [s for lst in self._subs.values() for s in lst]
|
||||||
|
sent = 0
|
||||||
|
for s in subs:
|
||||||
|
try:
|
||||||
|
s.q.put_nowait((cursor, data))
|
||||||
|
sent += 1
|
||||||
|
except queue.Full:
|
||||||
|
# Slow subscriber: drop it. The client reconnects with
|
||||||
|
# Last-Event-ID and catches up from the outbox.
|
||||||
|
logger.info("android: dropping slow HTTP subscriber %s", s.device_id)
|
||||||
|
s.closed.set()
|
||||||
|
self._remove_sub(s)
|
||||||
|
return sent
|
||||||
|
|
||||||
|
# ── Auth / limits (handler threads) ───────────────────────────────────
|
||||||
|
|
||||||
|
def _authenticate(self, handler: BaseHTTPRequestHandler) -> str | None:
|
||||||
|
"""Verify Bearer token + device identity. Returns the device_id, or
|
||||||
|
None after sending a 401."""
|
||||||
|
auth = handler.headers.get("Authorization") or ""
|
||||||
|
token = auth[len("Bearer ") :] if auth.startswith("Bearer ") else None
|
||||||
|
if not verify_token(token, self._adapter.token):
|
||||||
|
_send_json(handler, 401, {"error": "unauthorized"})
|
||||||
|
return None
|
||||||
|
device_id = (handler.headers.get("X-Iris-Device") or "").strip()
|
||||||
|
if not device_id or len(device_id) > dispatch.MAX_DEVICE_ID_LEN:
|
||||||
|
_send_json(handler, 401, {"error": "X-Iris-Device header required"})
|
||||||
|
return None
|
||||||
|
if (
|
||||||
|
not self._adapter.allow_all
|
||||||
|
and self._adapter.allowed_users
|
||||||
|
and device_id not in self._adapter.allowed_users
|
||||||
|
):
|
||||||
|
logger.warning("android: http rejected: device %s not allowlisted", device_id)
|
||||||
|
_send_json(handler, 401, {"error": "device not allowed"})
|
||||||
|
return None
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
self._devices.touch(device_id)
|
||||||
|
return device_id
|
||||||
|
|
||||||
|
def _rate_limited(self, device_id: str) -> bool:
|
||||||
|
"""Per-device token bucket, same parameters as the frame limit."""
|
||||||
|
with self._buckets_lock:
|
||||||
|
b = self._buckets.get(device_id)
|
||||||
|
if b is None:
|
||||||
|
b = self._buckets[device_id] = dispatch._TokenBucket(
|
||||||
|
dispatch.INBOUND_RATE_PER_S, dispatch.INBOUND_BURST
|
||||||
|
)
|
||||||
|
return not b.consume()
|
||||||
|
|
||||||
|
# ── POST /v1/frame ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _handle_frame(
|
||||||
|
self, handler: BaseHTTPRequestHandler, device_id: str, frame: protocol.Frame
|
||||||
|
) -> None:
|
||||||
|
"""Accept-and-ack (docs/19 §19.7): 202 once the frame is dispatched;
|
||||||
|
a synchronous validation rejection comes back as the 4xx body.
|
||||||
|
Long-running handlers (the agent turn) keep running after the ack —
|
||||||
|
their async output arrives on the event stream."""
|
||||||
|
loop = self._loop
|
||||||
|
if loop is None or not loop.is_running():
|
||||||
|
_send_frame_json(
|
||||||
|
handler,
|
||||||
|
503,
|
||||||
|
protocol.error(protocol.ERR_INTERNAL, "gateway loop not running").to_json(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
# Reply sink: while this request is being dispatched, frames the
|
||||||
|
# handler would send via send_to() are captured here instead (the
|
||||||
|
# adapter's _reply routes them in). ``abandoned`` is set once the
|
||||||
|
# HTTP response has been sent without consuming the sink (the
|
||||||
|
# long-running-handler case); the dispatch's finally then delivers
|
||||||
|
# any late replies via the event stream instead of losing them.
|
||||||
|
sink: queue.Queue = queue.Queue()
|
||||||
|
abandoned = threading.Event()
|
||||||
|
self._adapter._http_register_sink(device_id, (sink, abandoned))
|
||||||
|
try:
|
||||||
|
task = asyncio.run_coroutine_threadsafe(
|
||||||
|
self._dispatch_guarded(frame, device_id, sink, abandoned), loop
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self._adapter._http_pop_sink(device_id)
|
||||||
|
_send_frame_json(
|
||||||
|
handler, 500, protocol.error(protocol.ERR_INTERNAL, "dispatch failed").to_json()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
frames: list[protocol.Frame] = []
|
||||||
|
deadline = time.monotonic() + ACCEPT_ACK_TIMEOUT_S
|
||||||
|
while True:
|
||||||
|
# If the handler is done, drain any replies and stop (no wait).
|
||||||
|
# This keeps fast/ignored frames from incurring the sink timeout.
|
||||||
|
if task.done():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
frames.append(sink.get_nowait())
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
frames.append(sink.get(timeout=0.01))
|
||||||
|
except queue.Empty:
|
||||||
|
if time.monotonic() >= deadline:
|
||||||
|
# Long-running handler (the agent turn): ack now; late
|
||||||
|
# replies go to the event stream (the dispatch's finally
|
||||||
|
# sees ``abandoned`` and delivers them there).
|
||||||
|
abandoned.set()
|
||||||
|
break
|
||||||
|
continue
|
||||||
|
# Got a frame; loop back to check task.done() (drain the rest if
|
||||||
|
# the handler finished, e.g. a sync replay).
|
||||||
|
if not frames:
|
||||||
|
_send_json(handler, 202, {"ok": True})
|
||||||
|
elif len(frames) == 1:
|
||||||
|
f = frames[0]
|
||||||
|
status = (
|
||||||
|
429
|
||||||
|
if f.payload.get("code") == protocol.ERR_RATE_LIMITED
|
||||||
|
else (400 if f.type == protocol.TYPE_ERROR else 200)
|
||||||
|
)
|
||||||
|
_send_frame_json(handler, status, f.to_json())
|
||||||
|
else:
|
||||||
|
# Multi-frame reply (e.g. a sync replay): deliver it all on the
|
||||||
|
# event stream; the ack stays plain.
|
||||||
|
for f in frames:
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
asyncio.run_coroutine_threadsafe(self._deliver_via_stream(f), loop)
|
||||||
|
_send_json(handler, 202, {"ok": True})
|
||||||
|
|
||||||
|
async def _dispatch_guarded(
|
||||||
|
self,
|
||||||
|
frame: protocol.Frame,
|
||||||
|
device_id: str,
|
||||||
|
sink: queue.Queue,
|
||||||
|
abandoned: threading.Event,
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
await dispatch.dispatch_frame(self._adapter, frame, device_id)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("android: HTTP dispatch failed for %s", frame.type, exc_info=True)
|
||||||
|
finally:
|
||||||
|
# Pop our sink entry (a newer request from the same device may
|
||||||
|
# have replaced it). If the HTTP response was already sent
|
||||||
|
# (abandoned), any replies still in the sink are delivered via
|
||||||
|
# the event stream instead of being lost. In the normal case the
|
||||||
|
# handler thread has already drained the sink, so nothing is
|
||||||
|
# left to deliver.
|
||||||
|
popped = self._adapter._http_pop_sink_if(device_id, sink)
|
||||||
|
if popped is not None and abandoned.is_set():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
f = sink.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await self._deliver_via_stream(f)
|
||||||
|
|
||||||
|
async def _deliver_via_stream(self, frame: protocol.Frame) -> None:
|
||||||
|
await self.fanout(frame, cursor=None)
|
||||||
|
|
||||||
|
# ── GET /v1/events (SSE) ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _handle_sse(self, handler: BaseHTTPRequestHandler, device_id: str, parsed: Any) -> None:
|
||||||
|
qs = parse_qs(parsed.query)
|
||||||
|
cursor = _parse_cursor(qs.get("cursor", [None])[0], handler.headers.get("Last-Event-ID"))
|
||||||
|
# Device registration (the HTTP equivalent of the WS hello upsert):
|
||||||
|
# the SSE open carries the device name + push tokens as optional
|
||||||
|
# headers; upsert is idempotent and COALESCEs absent tokens, so a
|
||||||
|
# re-open never clobbers a newer fcm.register value.
|
||||||
|
device_name = (handler.headers.get("X-Iris-Device-Name") or "").strip()[:120]
|
||||||
|
fcm_token = handler.headers.get("X-Iris-Fcm-Token") or None
|
||||||
|
ntfy_topic = handler.headers.get("X-Iris-Ntfy-Topic") or None
|
||||||
|
try:
|
||||||
|
self._devices.upsert(
|
||||||
|
device_id,
|
||||||
|
device_name or device_id,
|
||||||
|
None,
|
||||||
|
fcm_token,
|
||||||
|
ntfy_topic,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("android: device registry upsert failed", exc_info=True)
|
||||||
|
sub = _Subscriber(device_id=device_id, kind="sse")
|
||||||
|
# Register BEFORE the replay so a frame appended in between is
|
||||||
|
# fanned out to us (and de-duped by cursor below) instead of lost.
|
||||||
|
self._add_sub(sub)
|
||||||
|
try:
|
||||||
|
handler.send_response(200)
|
||||||
|
handler.send_header("Content-Type", "text/event-stream")
|
||||||
|
handler.send_header("Cache-Control", "no-cache")
|
||||||
|
handler.send_header("X-Accel-Buffering", "no")
|
||||||
|
handler.end_headers()
|
||||||
|
# 1. Catch-up from the outbox (id = cursor; the envelope also
|
||||||
|
# carries the cursor for the app's push dedupe).
|
||||||
|
max_cursor = cursor
|
||||||
|
for e in self._adapter._outbox.replay(cursor):
|
||||||
|
c = int(e["cursor"])
|
||||||
|
max_cursor = max(max_cursor, c)
|
||||||
|
self._write_sse(handler, "frame", c, _with_cursor(e["frame"], c))
|
||||||
|
# 2. hello (the HTTP equivalent of hello.ack) + current status.
|
||||||
|
hello = protocol.hello_ack(
|
||||||
|
server_caps=self._adapter.server_caps(),
|
||||||
|
sync_cursor=self._adapter._outbox.latest_cursor(),
|
||||||
|
channels=self._adapter.channel_list(),
|
||||||
|
last_pushed_cursor=self._adapter._devices.last_pushed_cursor(device_id),
|
||||||
|
)
|
||||||
|
self._write_sse(handler, "hello", None, hello.to_json())
|
||||||
|
self._write_sse(
|
||||||
|
handler, "frame", None, protocol.status(self._adapter.gateway_status()).to_json()
|
||||||
|
)
|
||||||
|
# 3. Live frames (cursor=None frames have no id).
|
||||||
|
while not sub.closed.is_set():
|
||||||
|
try:
|
||||||
|
item = sub.q.get(timeout=SSE_HEARTBEAT_S)
|
||||||
|
except queue.Empty:
|
||||||
|
self._write_raw(handler, ": hb\n\n")
|
||||||
|
continue
|
||||||
|
if item is _STOP:
|
||||||
|
break
|
||||||
|
c, data = item
|
||||||
|
if c is not None and c <= max_cursor:
|
||||||
|
continue # already replayed above
|
||||||
|
self._write_sse(handler, "frame", c, data)
|
||||||
|
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||||
|
# Client went away mid-stream: normal (the app reconnects with
|
||||||
|
# Last-Event-ID and catches up from the outbox).
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self._remove_sub(sub)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write_sse(
|
||||||
|
handler: BaseHTTPRequestHandler, event: str, cursor: int | None, data: str
|
||||||
|
) -> None:
|
||||||
|
lines = ""
|
||||||
|
if cursor is not None:
|
||||||
|
lines += f"id: {cursor}\n"
|
||||||
|
lines += f"event: {event}\ndata: {data}\n\n"
|
||||||
|
handler.wfile.write(lines.encode("utf-8"))
|
||||||
|
handler.wfile.flush()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write_raw(handler: BaseHTTPRequestHandler, text: str) -> None:
|
||||||
|
handler.wfile.write(text.encode("utf-8"))
|
||||||
|
handler.wfile.flush()
|
||||||
|
|
||||||
|
# ── POST /v1/media (upload, docs/19 §19.15) ───────────────────────────
|
||||||
|
|
||||||
|
def _handle_media_upload(self, handler: BaseHTTPRequestHandler, device_id: str) -> None:
|
||||||
|
"""Whole-file upload: metadata in headers, file bytes as the body.
|
||||||
|
|
||||||
|
Mirrors the WS ``media.upload`` contract (docs/07 §7.2) in one
|
||||||
|
request: the body is streamed to a temp file (bounded RAM), then
|
||||||
|
size + sha256 are verified and the file cached via the hermes
|
||||||
|
``cache_*_from_bytes`` helpers. Runs entirely on the handler thread
|
||||||
|
(plain file IO — no asyncio bridge needed)."""
|
||||||
|
media_ref = (handler.headers.get("X-Iris-Media-Ref") or "").strip()
|
||||||
|
kind = (handler.headers.get("X-Iris-Media-Kind") or "").strip()
|
||||||
|
filename = (handler.headers.get("X-Iris-Media-Filename") or "upload")[:255]
|
||||||
|
sha256 = (handler.headers.get("X-Iris-Media-Sha256") or "").strip().lower()
|
||||||
|
mime = handler.headers.get("Content-Type") or "application/octet-stream"
|
||||||
|
mime = mime.split(";")[0].strip()[:128]
|
||||||
|
try:
|
||||||
|
length = int(handler.headers.get("Content-Length") or 0)
|
||||||
|
except ValueError:
|
||||||
|
length = 0
|
||||||
|
|
||||||
|
def reject(code: str, message: str) -> None:
|
||||||
|
_send_frame_json(
|
||||||
|
handler,
|
||||||
|
_MEDIA_STATUS.get(code, 400),
|
||||||
|
protocol.error(code, message).to_json(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Same validation rules as the WS media.upload.start handler.
|
||||||
|
if not media_ref or len(media_ref) > MAX_MEDIA_REF_LEN:
|
||||||
|
reject(protocol.ERR_UNSUPPORTED, "X-Iris-Media-Ref header required")
|
||||||
|
return
|
||||||
|
if kind not in media_bridge.KINDS:
|
||||||
|
reject(protocol.ERR_UNSUPPORTED, f"unsupported media kind {kind!r}")
|
||||||
|
return
|
||||||
|
if length <= 0:
|
||||||
|
reject(protocol.ERR_UNSUPPORTED, "empty body")
|
||||||
|
return
|
||||||
|
if length > self._adapter.max_upload_bytes:
|
||||||
|
reject(
|
||||||
|
protocol.ERR_MEDIA_TOO_LARGE,
|
||||||
|
f"upload of {length} bytes exceeds limit ({self._adapter.max_upload_bytes})",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
sess = self._adapter._media.create_upload(
|
||||||
|
device_id,
|
||||||
|
media_ref,
|
||||||
|
kind,
|
||||||
|
mime,
|
||||||
|
filename,
|
||||||
|
length,
|
||||||
|
None,
|
||||||
|
self._adapter.max_upload_bytes,
|
||||||
|
)
|
||||||
|
except media_bridge.MediaError as e:
|
||||||
|
reject(e.code, e.message)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
remaining = length
|
||||||
|
while remaining > 0:
|
||||||
|
chunk = handler.rfile.read(min(media_bridge.DEFAULT_CHUNK_BYTES, remaining))
|
||||||
|
if not chunk:
|
||||||
|
raise media_bridge.MediaError(
|
||||||
|
protocol.ERR_INTERNAL, "client disconnected mid-upload"
|
||||||
|
)
|
||||||
|
sess.feed(chunk)
|
||||||
|
remaining -= len(chunk)
|
||||||
|
if sess.received != length:
|
||||||
|
raise media_bridge.MediaError(
|
||||||
|
protocol.ERR_INTERNAL,
|
||||||
|
f"size mismatch (declared {length}, received {sess.received})",
|
||||||
|
)
|
||||||
|
entry = self._adapter._media.complete_upload(device_id, media_ref, sha256)
|
||||||
|
except media_bridge.MediaError as e:
|
||||||
|
# complete_upload already popped the session; discard is a no-op
|
||||||
|
# in that case (feed/short-read failures leave it active).
|
||||||
|
self._adapter._media.discard_upload(device_id, media_ref)
|
||||||
|
reject(e.code, e.message)
|
||||||
|
return
|
||||||
|
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||||
|
self._adapter._media.discard_upload(device_id, media_ref)
|
||||||
|
return # client went away: nothing to answer
|
||||||
|
_send_frame_json(handler, 201, protocol.media_upload_ack(True, entry.media_id).to_json())
|
||||||
|
|
||||||
|
# ── GET /v1/media/{id} (pull, docs/19 §19.15) ─────────────────────────
|
||||||
|
|
||||||
|
def _handle_media_pull(
|
||||||
|
self, handler: BaseHTTPRequestHandler, device_id: str, media_id: str
|
||||||
|
) -> None:
|
||||||
|
"""Stream an outbound offer as the response body (docs/07 §7.3).
|
||||||
|
|
||||||
|
The delivery-path validation is re-checked at pull time, exactly as
|
||||||
|
the WS ``media.pull`` handler does (the file may have moved since
|
||||||
|
the offer)."""
|
||||||
|
entry = self._adapter._media.get_outbound(media_id)
|
||||||
|
if entry is None:
|
||||||
|
_send_frame_json(
|
||||||
|
handler,
|
||||||
|
404,
|
||||||
|
protocol.error(protocol.ERR_NOT_FOUND, f"unknown media_id {media_id!r}").to_json(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
safe = validate_media_delivery_path(entry.path) if validate_media_delivery_path else None
|
||||||
|
if safe is None:
|
||||||
|
_send_frame_json(
|
||||||
|
handler,
|
||||||
|
404,
|
||||||
|
protocol.error(protocol.ERR_NOT_FOUND, "media no longer deliverable").to_json(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
filename = entry.filename.replace('"', "")
|
||||||
|
handler.send_response(200)
|
||||||
|
handler.send_header("Content-Type", entry.mime)
|
||||||
|
handler.send_header("Content-Length", str(entry.size))
|
||||||
|
handler.send_header("Content-Disposition", f'attachment; filename="{filename}"')
|
||||||
|
handler.end_headers()
|
||||||
|
try:
|
||||||
|
with open(safe, "rb") as f: # pi-lens-ignore: python-path-traversal
|
||||||
|
while True:
|
||||||
|
chunk = f.read(media_bridge.DEFAULT_CHUNK_BYTES)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
handler.wfile.write(chunk)
|
||||||
|
handler.wfile.flush()
|
||||||
|
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||||
|
pass # client went away mid-pull, or the file vanished: normal
|
||||||
|
|
||||||
|
# ── GET /v1/poll (long-poll) ──────────────────────────────────────────
|
||||||
|
|
||||||
|
def _handle_poll(self, handler: BaseHTTPRequestHandler, device_id: str, parsed: Any) -> None:
|
||||||
|
qs = parse_qs(parsed.query)
|
||||||
|
cursor = _parse_cursor(qs.get("cursor", [None])[0])
|
||||||
|
sub = _Subscriber(device_id=device_id, kind="poll")
|
||||||
|
self._add_sub(sub)
|
||||||
|
try:
|
||||||
|
frames: list[str] = []
|
||||||
|
max_cursor = cursor
|
||||||
|
for e in self._adapter._outbox.replay(cursor):
|
||||||
|
c = int(e["cursor"])
|
||||||
|
max_cursor = max(max_cursor, c)
|
||||||
|
frames.append(_with_cursor(e["frame"], c))
|
||||||
|
deadline = time.monotonic() + POLL_TIMEOUT_S
|
||||||
|
while not frames and not sub.closed.is_set() and time.monotonic() < deadline:
|
||||||
|
remaining = deadline - time.monotonic()
|
||||||
|
try:
|
||||||
|
item = sub.q.get(timeout=min(remaining, 5.0))
|
||||||
|
except queue.Empty:
|
||||||
|
continue
|
||||||
|
if item is _STOP:
|
||||||
|
break
|
||||||
|
c, data = item
|
||||||
|
if c is None or c <= max_cursor:
|
||||||
|
continue
|
||||||
|
max_cursor = c
|
||||||
|
frames.append(data)
|
||||||
|
hwm = max(max_cursor, self._adapter._outbox.latest_cursor())
|
||||||
|
_send_json(handler, 200, {"cursor": hwm, "frames": frames})
|
||||||
|
except (BrokenPipeError, ConnectionResetError, OSError):
|
||||||
|
# Client went away while we held the poll: normal.
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self._remove_sub(sub)
|
||||||
|
|
||||||
|
|
||||||
|
class _ThreadingHTTPD(ThreadingHTTPServer):
|
||||||
|
"""One thread per connection (fine at single-user scale); daemon
|
||||||
|
threads so a stuck handler can't block process exit."""
|
||||||
|
|
||||||
|
daemon_threads = True
|
||||||
|
allow_reuse_address = True
|
||||||
|
|
||||||
|
def __init__(self, addr: tuple[str, int], http_server: HttpServer):
|
||||||
|
super().__init__(addr, _Handler)
|
||||||
|
self.http_server = http_server
|
||||||
|
|
||||||
|
|
||||||
|
class _Handler(BaseHTTPRequestHandler):
|
||||||
|
# HTTP/1.0 (default): the connection closes after each response. That
|
||||||
|
# matches the transport's design (short-lived connections) and avoids
|
||||||
|
# Content-Length bookkeeping on the streamed SSE response.
|
||||||
|
server: _ThreadingHTTPD
|
||||||
|
|
||||||
|
def log_message(self, fmt: str, *args: Any) -> None: # noqa: A003
|
||||||
|
logger.debug("android http: " + fmt, *args)
|
||||||
|
|
||||||
|
# ── Routing ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def do_GET(self) -> None: # noqa: N802
|
||||||
|
hs = self.server.http_server
|
||||||
|
if not hs.enabled:
|
||||||
|
_send_json(self, 503, {"error": "http leg disabled"})
|
||||||
|
return
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
if parsed.path == "/v1/health":
|
||||||
|
# Unauthenticated by design: it answers "is the gateway
|
||||||
|
# alive?" and must not reflect tokens, device ids, or versions.
|
||||||
|
_send_json(self, 200, {"ok": True})
|
||||||
|
return
|
||||||
|
if parsed.path == "/v1/events":
|
||||||
|
device_id = hs._authenticate(self)
|
||||||
|
if device_id is not None:
|
||||||
|
hs._handle_sse(self, device_id, parsed)
|
||||||
|
return
|
||||||
|
if parsed.path == "/v1/poll":
|
||||||
|
device_id = hs._authenticate(self)
|
||||||
|
if device_id is not None:
|
||||||
|
hs._handle_poll(self, device_id, parsed)
|
||||||
|
return
|
||||||
|
if parsed.path.startswith("/v1/media/"):
|
||||||
|
media_id = parsed.path[len("/v1/media/") :]
|
||||||
|
# The id is looked up in an exact-match dict; reject anything
|
||||||
|
# path-shaped so a bad URL can't be mistaken for an id.
|
||||||
|
if media_id and "/" not in media_id:
|
||||||
|
device_id = hs._authenticate(self)
|
||||||
|
if device_id is not None:
|
||||||
|
hs._handle_media_pull(self, device_id, media_id)
|
||||||
|
else:
|
||||||
|
_send_json(self, 404, {"error": "not found"})
|
||||||
|
return
|
||||||
|
_send_json(self, 404, {"error": "not found"})
|
||||||
|
|
||||||
|
def do_POST(self) -> None: # noqa: N802
|
||||||
|
hs = self.server.http_server
|
||||||
|
if not hs.enabled:
|
||||||
|
_send_json(self, 503, {"error": "http leg disabled"})
|
||||||
|
return
|
||||||
|
parsed = urlparse(self.path)
|
||||||
|
if parsed.path == "/v1/media":
|
||||||
|
device_id = hs._authenticate(self)
|
||||||
|
if device_id is None:
|
||||||
|
return
|
||||||
|
if hs._rate_limited(device_id):
|
||||||
|
_send_frame_json(
|
||||||
|
self,
|
||||||
|
429,
|
||||||
|
protocol.error(
|
||||||
|
protocol.ERR_RATE_LIMITED, "http media rate limit exceeded"
|
||||||
|
).to_json(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
hs._handle_media_upload(self, device_id)
|
||||||
|
return
|
||||||
|
if parsed.path != "/v1/frame":
|
||||||
|
_send_json(self, 404, {"error": "not found"})
|
||||||
|
return
|
||||||
|
device_id = hs._authenticate(self)
|
||||||
|
if device_id is None:
|
||||||
|
return
|
||||||
|
if hs._rate_limited(device_id):
|
||||||
|
_send_frame_json(
|
||||||
|
self,
|
||||||
|
429,
|
||||||
|
protocol.error(
|
||||||
|
protocol.ERR_RATE_LIMITED, "http frame rate limit exceeded"
|
||||||
|
).to_json(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
ctype = (self.headers.get("Content-Type") or "").split(";")[0].strip().lower()
|
||||||
|
if ctype != "application/json":
|
||||||
|
_send_frame_json(
|
||||||
|
self,
|
||||||
|
400,
|
||||||
|
protocol.error(
|
||||||
|
protocol.ERR_INTERNAL, "Content-Type must be application/json"
|
||||||
|
).to_json(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
length = int(self.headers.get("Content-Length") or 0)
|
||||||
|
except ValueError:
|
||||||
|
length = 0
|
||||||
|
if length <= 0 or length > MAX_BODY_BYTES:
|
||||||
|
# Drain the (oversize) body so the connection stays clean; cap the
|
||||||
|
# drain at MAX_BODY_BYTES so a runaway body can't wedge the thread.
|
||||||
|
if length > 0:
|
||||||
|
to_drain = min(length, MAX_BODY_BYTES)
|
||||||
|
while to_drain > 0:
|
||||||
|
chunk = self.rfile.read(min(65536, to_drain))
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
to_drain -= len(chunk)
|
||||||
|
_send_frame_json(
|
||||||
|
self,
|
||||||
|
413,
|
||||||
|
protocol.error(
|
||||||
|
protocol.ERR_INTERNAL, f"body must be 1..{MAX_BODY_BYTES} bytes"
|
||||||
|
).to_json(),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
body = self.rfile.read(length)
|
||||||
|
frame = protocol.Frame.from_json(body)
|
||||||
|
if frame is None:
|
||||||
|
_send_frame_json(
|
||||||
|
self, 400, protocol.error(protocol.ERR_INTERNAL, "invalid frame").to_json()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
hs._handle_frame(self, device_id, frame)
|
||||||
@@ -20,7 +20,6 @@ live under ``get_hermes_home()/"android"/media/tmp``.
|
|||||||
Milestone M4.
|
Milestone M4.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import contextlib
|
import contextlib
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
@@ -434,27 +433,3 @@ class MediaStore:
|
|||||||
for k in stale:
|
for k in stale:
|
||||||
del self._outbound[k]
|
del self._outbound[k]
|
||||||
return len(stale)
|
return len(stale)
|
||||||
|
|
||||||
|
|
||||||
async def stream_file(
|
|
||||||
ws, path: str, chunk_bytes: int = DEFAULT_CHUNK_BYTES, timeout: float = 10.0
|
|
||||||
) -> int:
|
|
||||||
"""Stream *path* to *ws* as binary frames. Returns bytes sent.
|
|
||||||
|
|
||||||
Ordering is guaranteed by the WebSocket; the caller sends the terminal
|
|
||||||
``media.pull.end`` frame afterwards. Each chunk send is bounded by
|
|
||||||
*timeout* so a stalled puller can't wedge the handler forever (the
|
|
||||||
caller treats the raised error as an aborted pull).
|
|
||||||
"""
|
|
||||||
sent = 0
|
|
||||||
# Safe: ``path`` is produced by hermes ``cache_*_from_bytes`` (a path inside
|
|
||||||
# hermes's own media cache dir), never derived from raw user input.
|
|
||||||
# pi-lens-ignore: python-path-traversal
|
|
||||||
with open(path, "rb") as f:
|
|
||||||
while True:
|
|
||||||
chunk = f.read(chunk_bytes)
|
|
||||||
if not chunk:
|
|
||||||
break
|
|
||||||
await asyncio.wait_for(ws.send(chunk), timeout=timeout)
|
|
||||||
sent += len(chunk)
|
|
||||||
return sent
|
|
||||||
@@ -45,7 +45,7 @@ def verify_token(provided: str | None, expected: str | None) -> bool:
|
|||||||
def qr_payload(host: str, port: int, token: str, secure: bool = False) -> str:
|
def qr_payload(host: str, port: int, token: str, secure: bool = False) -> str:
|
||||||
"""Pairing URL encoded into the QR / pre-filled into the app.
|
"""Pairing URL encoded into the QR / pre-filled into the app.
|
||||||
|
|
||||||
``iris://pair?host=<lan-ip>&port=8790&token=<token>`` — the app's
|
``iris://pair?host=<lan-ip>&port=8791&token=<token>`` — the app's
|
||||||
Connect screen parses this to pre-fill settings (docs/09 §9.2).
|
Connect screen parses this to pre-fill settings (docs/09 §9.2).
|
||||||
"""
|
"""
|
||||||
return (
|
return (
|
||||||
@@ -57,9 +57,9 @@ def qr_payload(host: str, port: int, token: str, secure: bool = False) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def pairing_url(host: str, port: int, secure: bool = False) -> str:
|
def pairing_url(host: str, port: int, secure: bool = False) -> str:
|
||||||
"""Plain ws(s) URL the app connects to (shown next to the QR)."""
|
"""Plain http(s) URL the app connects to (shown next to the QR)."""
|
||||||
scheme = "wss" if secure else "ws"
|
scheme = "https" if secure else "http"
|
||||||
return f"{scheme}://{host}:{int(port)}/ws"
|
return f"{scheme}://{host}:{int(port)}"
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -26,11 +26,8 @@ PROTOCOL_VERSION = 1
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
# Pairing / lifecycle
|
# Pairing / lifecycle
|
||||||
TYPE_HELLO = "hello"
|
|
||||||
TYPE_HELLO_ACK = "hello.ack"
|
TYPE_HELLO_ACK = "hello.ack"
|
||||||
TYPE_ERROR = "error"
|
TYPE_ERROR = "error"
|
||||||
TYPE_PING = "ping"
|
|
||||||
TYPE_PONG = "pong"
|
|
||||||
|
|
||||||
# Chat
|
# Chat
|
||||||
TYPE_MESSAGE = "message"
|
TYPE_MESSAGE = "message"
|
||||||
@@ -82,12 +79,8 @@ TYPE_SYNC_DONE = "sync.done"
|
|||||||
TYPE_HISTORY = "history"
|
TYPE_HISTORY = "history"
|
||||||
|
|
||||||
# Media (M4)
|
# Media (M4)
|
||||||
TYPE_MEDIA_UPLOAD_START = "media.upload.start"
|
|
||||||
TYPE_MEDIA_UPLOAD_END = "media.upload.end"
|
|
||||||
TYPE_MEDIA_UPLOAD_ACK = "media.upload.ack"
|
TYPE_MEDIA_UPLOAD_ACK = "media.upload.ack"
|
||||||
TYPE_MEDIA_OFFER = "media.offer"
|
TYPE_MEDIA_OFFER = "media.offer"
|
||||||
TYPE_MEDIA_PULL = "media.pull"
|
|
||||||
TYPE_MEDIA_PULL_END = "media.pull.end"
|
|
||||||
|
|
||||||
# Push / notifications (M5)
|
# Push / notifications (M5)
|
||||||
TYPE_NOTIFICATION = "notification"
|
TYPE_NOTIFICATION = "notification"
|
||||||
@@ -749,7 +742,8 @@ def media_offer(
|
|||||||
thread_id: str | None = None,
|
thread_id: str | None = None,
|
||||||
message_id: str | None = None,
|
message_id: str | None = None,
|
||||||
) -> Frame:
|
) -> Frame:
|
||||||
"""Event: the agent produced media the app can fetch via ``media.pull``.
|
"""Event: the agent produced media the app can fetch via
|
||||||
|
``GET /v1/media/{media_id}`` (docs/19 §19.15).
|
||||||
|
|
||||||
``message_id`` (optional) associates the offer with the assistant message
|
``message_id`` (optional) associates the offer with the assistant message
|
||||||
it belongs to (the app falls back to the lane's last assistant message).
|
it belongs to (the app falls back to the lane's last assistant message).
|
||||||
@@ -766,14 +760,9 @@ def media_offer(
|
|||||||
return Frame(type=TYPE_MEDIA_OFFER, chat_id=chat_id, thread_id=thread_id, payload=payload)
|
return Frame(type=TYPE_MEDIA_OFFER, chat_id=chat_id, thread_id=thread_id, payload=payload)
|
||||||
|
|
||||||
|
|
||||||
def media_pull_end(ok: bool, *, id: int | None = None) -> Frame:
|
|
||||||
"""Terminal frame of a ``media.pull`` binary stream."""
|
|
||||||
return Frame(type=TYPE_MEDIA_PULL_END, id=id, payload={"ok": ok})
|
|
||||||
|
|
||||||
|
|
||||||
def media_upload_ack(ok: bool, media_ref: str, *, id: int | None = None) -> Frame:
|
def media_upload_ack(ok: bool, media_ref: str, *, id: int | None = None) -> Frame:
|
||||||
"""Response to ``media.upload.end``: the ref is cached and may be used in
|
"""Response to ``POST /v1/media``: the ref is cached and may be used in a
|
||||||
a ``message.send`` ``media_refs``. Failures use ``error`` frames instead."""
|
``message.send`` ``media_refs``. Failures use ``error`` frames instead."""
|
||||||
return Frame(
|
return Frame(
|
||||||
type=TYPE_MEDIA_UPLOAD_ACK,
|
type=TYPE_MEDIA_UPLOAD_ACK,
|
||||||
id=id,
|
id=id,
|
||||||
@@ -783,10 +772,3 @@ def media_upload_ack(ok: bool, media_ref: str, *, id: int | None = None) -> Fram
|
|||||||
|
|
||||||
def error(code: str, message: str, *, id: int | None = None) -> Frame:
|
def error(code: str, message: str, *, id: int | None = None) -> Frame:
|
||||||
return Frame(type=TYPE_ERROR, id=id, payload={"code": code, "message": message})
|
return Frame(type=TYPE_ERROR, id=id, payload={"code": code, "message": message})
|
||||||
|
|
||||||
|
|
||||||
def pong(ts: int | None = None) -> Frame:
|
|
||||||
payload: dict[str, Any] = {}
|
|
||||||
if ts is not None:
|
|
||||||
payload["ts"] = ts
|
|
||||||
return Frame(type=TYPE_PONG, payload=payload)
|
|
||||||
@@ -43,6 +43,12 @@ Beyond the base modes (`--send`, `--upload`, `--pull-offer`, `--sync`,
|
|||||||
- `--offer-grace S` — with `--pull-offer`, keep listening S seconds after
|
- `--offer-grace S` — with `--pull-offer`, keep listening S seconds after
|
||||||
the final message for a `media.offer` (offers are emitted post-turn,
|
the final message for a `media.offer` (offers are emitted post-turn,
|
||||||
right after the final; default 15).
|
right after the final; default 15).
|
||||||
|
- `--http [--http-url http://host:port]` — docs/19: drive the turn over
|
||||||
|
the **HTTP fallback leg** instead of WS: `GET /v1/health`,
|
||||||
|
`POST /v1/frame` (the `message.send`), receive over SSE `GET
|
||||||
|
/v1/events`. The same assertion flags apply. The base URL defaults to
|
||||||
|
the `--url` host with scheme `ws(s)` → `http(s)` and port 8791
|
||||||
|
(`ANDROID_HTTP_PORT`).
|
||||||
|
|
||||||
Exit codes: `0` ok (incl. SKIP for absent M7 frames), `2` connect fail,
|
Exit codes: `0` ok (incl. SKIP for absent M7 frames), `2` connect fail,
|
||||||
`3` no hello.ack, `4` expected hello.ack, `5` authfail expected but
|
`3` no hello.ack, `4` expected hello.ack, `5` authfail expected but
|
||||||
@@ -51,7 +57,9 @@ acked, `6` timeout, `7` no final message, `8` upload/sync fail,
|
|||||||
`12` assert-tools fail, `13` assert-commentary fail, `14` search fail
|
`12` assert-tools fail, `13` assert-commentary fail, `14` search fail
|
||||||
(error or zero hits), `15` channel.create/list fail, `16` channel.delete
|
(error or zero hits), `15` channel.create/list fail, `16` channel.delete
|
||||||
fail, `17` watch timeout, `18` read.receipt arrived before the sent
|
fail, `17` watch timeout, `18` read.receipt arrived before the sent
|
||||||
message, `19` status frame with empty payload.
|
message, `19` status frame with empty payload, `20` `--http` health
|
||||||
|
check failed, `21` `--http` SSE open failed, `22` `--http`
|
||||||
|
`POST /v1/frame` rejected (4xx).
|
||||||
|
|
||||||
## E2E driver (`e2e.py`)
|
## E2E driver (`e2e.py`)
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import sys
|
|||||||
import uuid
|
import uuid
|
||||||
import zlib
|
import zlib
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
HERE = Path(__file__).resolve().parent
|
HERE = Path(__file__).resolve().parent
|
||||||
REPO = HERE.parent.parent
|
REPO = HERE.parent.parent
|
||||||
@@ -303,6 +304,33 @@ def s12_sync(env, url, token):
|
|||||||
return FAIL, f"probe rc={rc}"
|
return FAIL, f"probe rc={rc}"
|
||||||
|
|
||||||
|
|
||||||
|
def s13_http_fallback(env, url, token):
|
||||||
|
"""docs/19: the HTTP fallback leg. The probe drives a full turn over
|
||||||
|
health + POST /v1/frame + SSE /v1/events (no WS involved). The user echo
|
||||||
|
must land on the SSE stream promptly after the POST (< 1.5 s on LAN)."""
|
||||||
|
u = urlparse(url)
|
||||||
|
scheme = "https" if u.scheme == "wss" else "http"
|
||||||
|
http_port = os.getenv("ANDROID_HTTP_PORT", "8791")
|
||||||
|
http_url = f"{scheme}://{u.hostname or '127.0.0.1'}:{http_port}"
|
||||||
|
rc, out, _ = run_probe(env, url, token, "--http", "--http-url", http_url,
|
||||||
|
"--send", "Reply with exactly: e2e http fallback OK",
|
||||||
|
"--timeout", "120")
|
||||||
|
if rc == 0:
|
||||||
|
m = re.search(r"== user echo in ([\d.]+)s", out)
|
||||||
|
echo = float(m.group(1)) if m else None
|
||||||
|
if echo is not None and echo > 1.5:
|
||||||
|
return FAIL, f"user echo took {echo:.2f}s (> 1.5 s)"
|
||||||
|
return PASS, ("health + POST /v1/frame + SSE turn complete"
|
||||||
|
+ (f"; user echo in {echo:.2f}s" if echo is not None else ""))
|
||||||
|
if rc == 20:
|
||||||
|
return FAIL, "health check failed (HTTP leg not running?)"
|
||||||
|
if rc == 21:
|
||||||
|
return FAIL, "SSE open failed"
|
||||||
|
if rc == 22:
|
||||||
|
return FAIL, "POST /v1/frame rejected"
|
||||||
|
return FAIL, f"probe rc={rc}"
|
||||||
|
|
||||||
|
|
||||||
SCENARIOS = [
|
SCENARIOS = [
|
||||||
(1, "pair", s1_pair),
|
(1, "pair", s1_pair),
|
||||||
(2, "text round-trip", s2_text),
|
(2, "text round-trip", s2_text),
|
||||||
@@ -316,6 +344,7 @@ SCENARIOS = [
|
|||||||
(10, "media out", s10_media_out),
|
(10, "media out", s10_media_out),
|
||||||
(11, "push", s11_push),
|
(11, "push", s11_push),
|
||||||
(12, "reconnect/sync", s12_sync),
|
(12, "reconnect/sync", s12_sync),
|
||||||
|
(13, "http fallback", s13_http_fallback),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ The plugin lives in the sibling ``iris_x_hermes`` checkout (installed into
|
|||||||
from the source tree directly so they never depend on that install.
|
from the source tree directly so they never depend on that install.
|
||||||
|
|
||||||
Coverage (docs/13-testing.md §13.1, media bullets):
|
Coverage (docs/13-testing.md §13.1, media bullets):
|
||||||
* upload start -> binary chunks -> end reassembles + sha256 verified
|
* upload via ``POST /v1/media`` reassembles + sha256 verified
|
||||||
* over-limit (declared and mid-stream) -> ``media_too_large``
|
* over-limit (Content-Length) -> ``media_too_large``
|
||||||
* sha256 mismatch -> ``internal``
|
* sha256 mismatch -> ``internal``
|
||||||
* ``message.send`` with ``media_refs`` -> echo carries ``media[]`` and the
|
* ``message.send`` with ``media_refs`` -> echo carries ``media[]`` and the
|
||||||
``MessageEvent`` carries ``media_urls``/``media_types``
|
``MessageEvent`` carries ``media_urls``/``media_types``
|
||||||
* ``send_*`` -> ``media.offer`` (fields + message association)
|
* ``send_*`` -> ``media.offer`` (fields + message association)
|
||||||
* ``media.pull`` serves only allowed paths (denied/unknown -> ``not_found``)
|
* ``GET /v1/media/{id}`` serves only allowed paths (denied/unknown -> ``not_found``)
|
||||||
* kind re-sniffing (don't trust the client)
|
* kind re-sniffing (don't trust the client)
|
||||||
|
|
||||||
Run via ``scripts/run_tests.sh tests/gateway/test_android.py``.
|
Run via ``scripts/run_tests.sh tests/gateway/test_android.py``.
|
||||||
@@ -26,6 +26,9 @@ import importlib.util
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
from http.client import HTTPConnection
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import AsyncMock
|
from unittest.mock import AsyncMock
|
||||||
@@ -47,8 +50,14 @@ def _plugin_dir() -> Path:
|
|||||||
env = os.environ.get("ANDROID_PLUGIN_DIR")
|
env = os.environ.get("ANDROID_PLUGIN_DIR")
|
||||||
if env:
|
if env:
|
||||||
return Path(env)
|
return Path(env)
|
||||||
# hermes-agent/tests/gateway/test_android.py -> repo root is parents[3].
|
# Works from either copy of this file: gateway-plugin/tests/ (canonical,
|
||||||
return Path(__file__).resolve().parents[3] / "gateway-plugin"
|
# plugin dir is parents[1]) or the hermes-agent/tests/gateway/ mirror
|
||||||
|
# (repo root is parents[3]).
|
||||||
|
here = Path(__file__).resolve()
|
||||||
|
for candidate in (here.parents[1], here.parents[3] / "gateway-plugin"):
|
||||||
|
if (candidate / "protocol.py").is_file():
|
||||||
|
return candidate
|
||||||
|
return here.parents[1]
|
||||||
|
|
||||||
|
|
||||||
def _load_plugin():
|
def _load_plugin():
|
||||||
@@ -105,7 +114,7 @@ def adapter(plugin, monkeypatch):
|
|||||||
config = SimpleNamespace(
|
config = SimpleNamespace(
|
||||||
extra={
|
extra={
|
||||||
"host": "127.0.0.1",
|
"host": "127.0.0.1",
|
||||||
"port": 0, # ephemeral port
|
"http_port": 0, # ephemeral HTTP port
|
||||||
"max_upload_bytes": 1024 * 1024, # 1 MiB -- keeps over-limit tests fast
|
"max_upload_bytes": 1024 * 1024, # 1 MiB -- keeps over-limit tests fast
|
||||||
},
|
},
|
||||||
home_channel=None,
|
home_channel=None,
|
||||||
@@ -122,48 +131,206 @@ def adapter(plugin, monkeypatch):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
async def _hello(ws) -> dict:
|
class HttpTestClient:
|
||||||
await ws.send(
|
"""Mimics the old WS client interface over the HTTP transport (docs/19).
|
||||||
json.dumps(
|
|
||||||
{
|
``.send(json_str)`` -> ``POST /v1/frame``; ``.recv(timeout)`` -> the next
|
||||||
"v": 1,
|
frame from the SSE stream (a dict); ``.upload(...)`` -> ``POST /v1/media``
|
||||||
"type": "hello",
|
returning the terminal frame (ack or error). The SSE stream is read in a
|
||||||
"payload": {
|
background thread that feeds an asyncio queue, so ``recv`` is awaitable
|
||||||
"token": TOKEN,
|
exactly like the old WS ``recv``.
|
||||||
"device_id": DEVICE_ID,
|
"""
|
||||||
"device_name": "Test Device",
|
|
||||||
"caps": {},
|
def __init__(self, port: int, cursor: int = 0):
|
||||||
|
self._port = port
|
||||||
|
self._cursor = cursor
|
||||||
|
self._queue: asyncio.Queue = asyncio.Queue()
|
||||||
|
self._loop: asyncio.AbstractEventLoop | None = None
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._resp = None
|
||||||
|
|
||||||
|
async def start(self) -> dict:
|
||||||
|
"""Open the SSE stream; returns the hello.ack frame. Any outbox
|
||||||
|
catch-up frames replayed before the hello are buffered and re-enqueued
|
||||||
|
so ``recv`` can still see them."""
|
||||||
|
self._loop = asyncio.get_running_loop()
|
||||||
|
self._thread = threading.Thread(target=self._sse_reader, daemon=True)
|
||||||
|
self._thread.start()
|
||||||
|
buffered: list = []
|
||||||
|
hello = None
|
||||||
|
while hello is None:
|
||||||
|
frame = await asyncio.wait_for(self._queue.get(), timeout=5)
|
||||||
|
if frame is None:
|
||||||
|
raise AssertionError("SSE stream closed before hello")
|
||||||
|
if frame.get("type") == "hello.ack":
|
||||||
|
hello = frame
|
||||||
|
else:
|
||||||
|
buffered.append(frame)
|
||||||
|
for f in buffered:
|
||||||
|
self._queue.put_nowait(f)
|
||||||
|
return hello
|
||||||
|
|
||||||
|
def _sse_reader(self) -> None:
|
||||||
|
conn = HTTPConnection("127.0.0.1", self._port, timeout=30)
|
||||||
|
conn.request(
|
||||||
|
"GET",
|
||||||
|
f"/v1/events?cursor={self._cursor}",
|
||||||
|
headers={"Authorization": f"Bearer {TOKEN}", "X-Iris-Device": DEVICE_ID},
|
||||||
|
)
|
||||||
|
resp = conn.getresponse()
|
||||||
|
self._resp = resp
|
||||||
|
cur_data: list[str] = []
|
||||||
|
loop = self._loop
|
||||||
|
assert loop is not None
|
||||||
|
while True:
|
||||||
|
line = resp.fp.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
line = line.decode("utf-8").rstrip("\r\n")
|
||||||
|
if line == "":
|
||||||
|
if cur_data:
|
||||||
|
try:
|
||||||
|
frame = json.loads("\n".join(cur_data))
|
||||||
|
except Exception:
|
||||||
|
frame = None
|
||||||
|
if frame is not None:
|
||||||
|
loop.call_soon_threadsafe(self._queue.put_nowait, frame)
|
||||||
|
cur_data = []
|
||||||
|
elif line.startswith(":"):
|
||||||
|
continue # heartbeat comment
|
||||||
|
elif line.startswith("data:"):
|
||||||
|
cur_data.append(line[5:].lstrip())
|
||||||
|
# id:/event: fields are not needed for the test shim
|
||||||
|
loop.call_soon_threadsafe(self._queue.put_nowait, None) # EOF sentinel
|
||||||
|
|
||||||
|
async def send(self, json_str: str) -> None:
|
||||||
|
await asyncio.to_thread(self._post_frame, json_str)
|
||||||
|
|
||||||
|
def _post_frame(self, json_str: str) -> None:
|
||||||
|
conn = HTTPConnection("127.0.0.1", self._port, timeout=30)
|
||||||
|
conn.request(
|
||||||
|
"POST",
|
||||||
|
"/v1/frame",
|
||||||
|
body=json_str.encode("utf-8"),
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {TOKEN}",
|
||||||
|
"X-Iris-Device": DEVICE_ID,
|
||||||
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
resp = conn.getresponse()
|
||||||
|
body = resp.read()
|
||||||
|
conn.close()
|
||||||
|
# Fast responses (validation, channel ops, …) come back on the POST
|
||||||
|
# response body via the reply sink, NOT the SSE stream. Enqueue any
|
||||||
|
# protocol frame so recv_until sees it (the plain {"ok":true} ack is
|
||||||
|
# not a frame and is skipped).
|
||||||
|
if body:
|
||||||
|
try:
|
||||||
|
obj = json.loads(body)
|
||||||
|
except Exception:
|
||||||
|
obj = None
|
||||||
|
if isinstance(obj, dict) and "type" in obj:
|
||||||
|
assert self._loop is not None
|
||||||
|
self._loop.call_soon_threadsafe(self._queue.put_nowait, obj)
|
||||||
|
|
||||||
|
async def recv(self, timeout: float = 10.0) -> dict:
|
||||||
|
frame = await asyncio.wait_for(self._queue.get(), timeout=timeout)
|
||||||
|
if frame is None:
|
||||||
|
raise ConnectionError("SSE stream closed")
|
||||||
|
return frame
|
||||||
|
|
||||||
|
async def upload(
|
||||||
|
self,
|
||||||
|
media_ref: str,
|
||||||
|
data: bytes,
|
||||||
|
*,
|
||||||
|
kind: str = "image",
|
||||||
|
mime: str = "image/png",
|
||||||
|
filename: str = "t.png",
|
||||||
|
sha256: str | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Drive a media upload via ``POST /v1/media``; returns the terminal
|
||||||
|
frame (``media.upload.ack`` or ``error``)."""
|
||||||
|
|
||||||
|
def _do() -> dict:
|
||||||
|
conn = HTTPConnection("127.0.0.1", self._port, timeout=60)
|
||||||
|
conn.request(
|
||||||
|
"POST",
|
||||||
|
"/v1/media",
|
||||||
|
body=data,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {TOKEN}",
|
||||||
|
"X-Iris-Device": DEVICE_ID,
|
||||||
|
"Content-Type": mime,
|
||||||
|
"X-Iris-Media-Ref": media_ref,
|
||||||
|
"X-Iris-Media-Kind": kind,
|
||||||
|
"X-Iris-Media-Filename": filename,
|
||||||
|
"X-Iris-Media-Sha256": sha256
|
||||||
|
or hashlib.sha256(data).hexdigest(),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
raw = await asyncio.wait_for(ws.recv(), timeout=5)
|
resp = conn.getresponse()
|
||||||
ack = json.loads(raw)
|
body = resp.read()
|
||||||
assert ack["type"] == "hello.ack", f"expected hello.ack, got {ack}"
|
conn.close()
|
||||||
return ack
|
return json.loads(body)
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_do)
|
||||||
|
|
||||||
|
async def pull(self, media_id: str) -> tuple[int, bytes]:
|
||||||
|
"""Drive a media pull via ``GET /v1/media/{id}``; returns (status, body)."""
|
||||||
|
|
||||||
|
def _do() -> tuple[int, bytes]:
|
||||||
|
conn = HTTPConnection("127.0.0.1", self._port, timeout=30)
|
||||||
|
conn.request(
|
||||||
|
"GET",
|
||||||
|
f"/v1/media/{media_id}",
|
||||||
|
headers={"Authorization": f"Bearer {TOKEN}", "X-Iris-Device": DEVICE_ID},
|
||||||
|
)
|
||||||
|
resp = conn.getresponse()
|
||||||
|
body = resp.read()
|
||||||
|
status = resp.status
|
||||||
|
conn.close()
|
||||||
|
return status, body
|
||||||
|
|
||||||
|
return await asyncio.to_thread(_do)
|
||||||
|
|
||||||
|
async def close(self) -> None:
|
||||||
|
# Interrupt the reader thread's blocking readline() by shutting down
|
||||||
|
# the socket first; otherwise resp.close() blocks until the in-flight
|
||||||
|
# read returns (the file lock is held for the whole blocking read).
|
||||||
|
if self._resp is not None:
|
||||||
|
sock = getattr(self._resp.fp, "raw", None)
|
||||||
|
sock = getattr(sock, "_sock", None) if sock is not None else None
|
||||||
|
if sock is not None:
|
||||||
|
try:
|
||||||
|
sock.shutdown(socket.SHUT_RDWR)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
self._resp.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@pytest_asyncio.fixture
|
@pytest_asyncio.fixture
|
||||||
async def ws_client(adapter):
|
async def ws_client(adapter):
|
||||||
"""Connected + paired WS client; the adapter's server runs on an
|
"""Connected + paired HTTP client; the adapter's server runs on an
|
||||||
ephemeral port for the duration of the test."""
|
ephemeral port for the duration of the test. Yields ``(client, ack)`` to
|
||||||
from websockets.asyncio.client import connect
|
match the old WS fixture shape so test bodies need no signature change."""
|
||||||
|
|
||||||
await adapter.connect()
|
await adapter.connect()
|
||||||
|
client = HttpTestClient(adapter._http_server.bound_port)
|
||||||
try:
|
try:
|
||||||
port = adapter._ws_server._server.sockets[0].getsockname()[1]
|
ack = await client.start()
|
||||||
async with connect(
|
yield client, ack
|
||||||
f"ws://127.0.0.1:{port}/ws", max_size=4 * 1024 * 1024
|
|
||||||
) as ws:
|
|
||||||
ack = await _hello(ws)
|
|
||||||
yield ws, ack
|
|
||||||
finally:
|
finally:
|
||||||
|
await client.close()
|
||||||
await adapter.disconnect()
|
await adapter.disconnect()
|
||||||
|
|
||||||
|
|
||||||
async def recv_until(ws, predicate, timeout: float = 10.0) -> list:
|
async def recv_until(ws, predicate, timeout: float = 10.0) -> list:
|
||||||
"""Collect frames (dicts; binary frames as ("binary", bytes)) until
|
"""Collect frames (dicts) until *predicate* matches a frame. Returns all
|
||||||
*predicate* matches a JSON frame. Returns all frames collected."""
|
frames collected."""
|
||||||
frames: list = []
|
frames: list = []
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
deadline = loop.time() + timeout
|
deadline = loop.time() + timeout
|
||||||
@@ -172,13 +339,9 @@ async def recv_until(ws, predicate, timeout: float = 10.0) -> list:
|
|||||||
if remaining <= 0:
|
if remaining <= 0:
|
||||||
raise AssertionError(
|
raise AssertionError(
|
||||||
"timed out waiting for frame; got: "
|
"timed out waiting for frame; got: "
|
||||||
+ ", ".join(f.get("type", "?") if isinstance(f, dict) else "binary" for f in frames)
|
+ ", ".join(f.get("type", "?") if isinstance(f, dict) else "?" for f in frames)
|
||||||
)
|
)
|
||||||
raw = await asyncio.wait_for(ws.recv(), timeout=remaining)
|
frame = await ws.recv(timeout=remaining)
|
||||||
if isinstance(raw, (bytes, bytearray)):
|
|
||||||
frames.append(("binary", bytes(raw)))
|
|
||||||
continue
|
|
||||||
frame = json.loads(raw)
|
|
||||||
frames.append(frame)
|
frames.append(frame)
|
||||||
if predicate(frame):
|
if predicate(frame):
|
||||||
return frames
|
return frames
|
||||||
@@ -186,43 +349,42 @@ async def recv_until(ws, predicate, timeout: float = 10.0) -> list:
|
|||||||
|
|
||||||
async def upload_file(ws, media_ref: str, data: bytes, *, kind: str = "image",
|
async def upload_file(ws, media_ref: str, data: bytes, *, kind: str = "image",
|
||||||
mime: str = "image/png", filename: str = "t.png",
|
mime: str = "image/png", filename: str = "t.png",
|
||||||
request_id: int = 1) -> dict:
|
request_id: int = 1, sha256: str | None = None) -> dict:
|
||||||
"""Drive a full media.upload flow; returns the terminal frame (ack or error)."""
|
"""Drive a media upload via the HTTP leg; returns the terminal frame
|
||||||
await ws.send(
|
(ack or error)."""
|
||||||
json.dumps(
|
return await ws.upload(
|
||||||
{
|
media_ref, data, kind=kind, mime=mime, filename=filename, sha256=sha256
|
||||||
"v": 1,
|
|
||||||
"id": request_id,
|
|
||||||
"type": "media.upload.start",
|
|
||||||
"payload": {
|
|
||||||
"media_ref": media_ref,
|
|
||||||
"kind": kind,
|
|
||||||
"mime": mime,
|
|
||||||
"size": len(data),
|
|
||||||
"filename": filename,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Lifecycle ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_disconnect_broadcasts_status_restarting(adapter):
|
||||||
|
"""Teardown broadcasts ``status{state=restarting}`` before closing the
|
||||||
|
streams, so the app can distinguish a clean gateway teardown (restart/
|
||||||
|
stop) from a plain network drop — it shows the "Gateway restarting" chat
|
||||||
|
notice only when this frame was received (docs/04 §status)."""
|
||||||
|
await adapter.connect()
|
||||||
|
client = HttpTestClient(adapter._http_server.bound_port)
|
||||||
|
try:
|
||||||
|
await client.start()
|
||||||
|
await adapter.disconnect()
|
||||||
|
frames = []
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
frames.append(await client.recv(timeout=5))
|
||||||
|
except (ConnectionError, asyncio.TimeoutError):
|
||||||
|
break
|
||||||
|
statuses = [f for f in frames if f.get("type") == "status"]
|
||||||
|
assert any(f["payload"]["state"] == "restarting" for f in statuses), (
|
||||||
|
f"expected status{{restarting}} before close, got: {frames}"
|
||||||
)
|
)
|
||||||
# Two chunks to exercise reassembly.
|
finally:
|
||||||
half = len(data) // 2
|
await client.close()
|
||||||
await ws.send(data[:half])
|
# Idempotent: the second call is a no-op on the already-stopped server.
|
||||||
await ws.send(data[half:])
|
await adapter.disconnect()
|
||||||
await ws.send(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"v": 1,
|
|
||||||
"id": request_id + 1,
|
|
||||||
"type": "media.upload.end",
|
|
||||||
"payload": {
|
|
||||||
"media_ref": media_ref,
|
|
||||||
"sha256": hashlib.sha256(data).hexdigest(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
frames = await recv_until(ws, lambda f: f.get("type") in ("media.upload.ack", "error"))
|
|
||||||
return frames[-1]
|
|
||||||
|
|
||||||
|
|
||||||
# ── Pure helpers ────────────────────────────────────────────────────────────
|
# ── Pure helpers ────────────────────────────────────────────────────────────
|
||||||
@@ -375,7 +537,6 @@ async def test_upload_reassembles_verifies_and_caches(adapter, ws_client):
|
|||||||
|
|
||||||
terminal = await upload_file(ws, "mu_t1", PNG_1X1)
|
terminal = await upload_file(ws, "mu_t1", PNG_1X1)
|
||||||
assert terminal["type"] == "media.upload.ack", terminal
|
assert terminal["type"] == "media.upload.ack", terminal
|
||||||
assert terminal["id"] == 2
|
|
||||||
assert terminal["payload"]["ok"] is True
|
assert terminal["payload"]["ok"] is True
|
||||||
assert terminal["payload"]["media_ref"] == "mu_t1"
|
assert terminal["payload"]["media_ref"] == "mu_t1"
|
||||||
|
|
||||||
@@ -396,100 +557,37 @@ async def test_upload_reassembles_verifies_and_caches(adapter, ws_client):
|
|||||||
async def test_upload_declared_over_limit_rejected(adapter, ws_client):
|
async def test_upload_declared_over_limit_rejected(adapter, ws_client):
|
||||||
ws, _ = ws_client
|
ws, _ = ws_client
|
||||||
limit = adapter.max_upload_bytes
|
limit = adapter.max_upload_bytes
|
||||||
await ws.send(
|
# Over HTTP the server checks Content-Length before reading the body.
|
||||||
json.dumps(
|
err = await upload_file(
|
||||||
{
|
ws, "mu_big", b"x" * (limit + 1), kind="document", mime="application/pdf",
|
||||||
"v": 1,
|
filename="big.pdf",
|
||||||
"id": 1,
|
|
||||||
"type": "media.upload.start",
|
|
||||||
"payload": {
|
|
||||||
"media_ref": "mu_big",
|
|
||||||
"kind": "document",
|
|
||||||
"mime": "application/pdf",
|
|
||||||
"size": limit + 1,
|
|
||||||
"filename": "big.pdf",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
)
|
assert err["type"] == "error"
|
||||||
frames = await recv_until(ws, lambda f: f.get("type") == "error")
|
|
||||||
err = frames[-1]
|
|
||||||
assert err["payload"]["code"] == "media_too_large"
|
assert err["payload"]["code"] == "media_too_large"
|
||||||
assert err["id"] == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_upload_midstream_over_limit_rejected(adapter, ws_client):
|
async def test_upload_over_limit_ref_not_consumed(adapter, ws_client):
|
||||||
|
"""Over HTTP the over-limit check happens before the body is read, so a
|
||||||
|
rejected upload must not consume its media_ref (a later valid upload with
|
||||||
|
the same ref succeeds)."""
|
||||||
ws, _ = ws_client
|
ws, _ = ws_client
|
||||||
limit = adapter.max_upload_bytes
|
limit = adapter.max_upload_bytes
|
||||||
await ws.send(
|
err = await upload_file(ws, "mu_reuse", b"x" * (limit + 1), kind="document")
|
||||||
json.dumps(
|
assert err["type"] == "error"
|
||||||
{
|
assert err["payload"]["code"] == "media_too_large"
|
||||||
"v": 1,
|
# The ref is free: a valid upload with the same ref now succeeds.
|
||||||
"id": 1,
|
ok = await upload_file(ws, "mu_reuse", PNG_1X1)
|
||||||
"type": "media.upload.start",
|
assert ok["type"] == "media.upload.ack", ok
|
||||||
"payload": {
|
assert ok["payload"]["ok"] is True
|
||||||
"media_ref": "mu_mid",
|
|
||||||
"kind": "document",
|
|
||||||
"mime": "application/octet-stream",
|
|
||||||
"size": limit,
|
|
||||||
"filename": "mid.bin",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# Declared size passes the start check; the stream exceeds it.
|
|
||||||
await ws.send(b"x" * (limit // 2))
|
|
||||||
await ws.send(b"x" * (limit // 2 + 1))
|
|
||||||
frames = await recv_until(ws, lambda f: f.get("type") == "error")
|
|
||||||
assert frames[-1]["payload"]["code"] == "media_too_large"
|
|
||||||
# The session is discarded: a late end cannot complete it.
|
|
||||||
await ws.send(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"v": 1,
|
|
||||||
"id": 2,
|
|
||||||
"type": "media.upload.end",
|
|
||||||
"payload": {"media_ref": "mu_mid", "sha256": "0" * 64},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 2)
|
|
||||||
assert frames[-1]["payload"]["code"] == "not_found"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_upload_sha256_mismatch_rejected(adapter, ws_client):
|
async def test_upload_sha256_mismatch_rejected(adapter, ws_client):
|
||||||
ws, _ = ws_client
|
ws, _ = ws_client
|
||||||
await ws.send(
|
err = await upload_file(ws, "mu_bad", PNG_1X1, sha256="0" * 64)
|
||||||
json.dumps(
|
assert err["type"] == "error"
|
||||||
{
|
assert err["payload"]["code"] == "internal"
|
||||||
"v": 1,
|
|
||||||
"id": 1,
|
|
||||||
"type": "media.upload.start",
|
|
||||||
"payload": {
|
|
||||||
"media_ref": "mu_bad",
|
|
||||||
"kind": "image",
|
|
||||||
"mime": "image/png",
|
|
||||||
"size": len(PNG_1X1),
|
|
||||||
"filename": "bad.png",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await ws.send(PNG_1X1)
|
|
||||||
await ws.send(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
"v": 1,
|
|
||||||
"id": 2,
|
|
||||||
"type": "media.upload.end",
|
|
||||||
"payload": {"media_ref": "mu_bad", "sha256": "0" * 64},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
frames = await recv_until(ws, lambda f: f.get("type") == "error")
|
|
||||||
assert frames[-1]["payload"]["code"] == "internal"
|
|
||||||
assert adapter._media.get_inbound("mu_bad") is None
|
assert adapter._media.get_inbound("mu_bad") is None
|
||||||
|
|
||||||
|
|
||||||
@@ -1223,47 +1321,26 @@ async def test_pull_serves_allowed_path(adapter, ws_client):
|
|||||||
str(img), "image", "image/png", "pull_test.png", len(PNG_1X1)
|
str(img), "image", "image/png", "pull_test.png", len(PNG_1X1)
|
||||||
)
|
)
|
||||||
|
|
||||||
await ws.send(
|
status, body = await ws.pull(entry.media_id)
|
||||||
json.dumps(
|
assert status == 200
|
||||||
{"v": 1, "id": 9, "type": "media.pull", "payload": {"media_id": entry.media_id}}
|
assert body == PNG_1X1
|
||||||
)
|
|
||||||
)
|
|
||||||
chunks: list[bytes] = []
|
|
||||||
terminal = None
|
|
||||||
while terminal is None:
|
|
||||||
raw = await asyncio.wait_for(ws.recv(), timeout=10)
|
|
||||||
if isinstance(raw, (bytes, bytearray)):
|
|
||||||
chunks.append(bytes(raw))
|
|
||||||
continue
|
|
||||||
frame = json.loads(raw)
|
|
||||||
if frame.get("type") == "media.pull.end":
|
|
||||||
terminal = frame
|
|
||||||
assert terminal["id"] == 9
|
|
||||||
assert terminal["payload"]["ok"] is True
|
|
||||||
assert b"".join(chunks) == PNG_1X1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_pull_rejects_unknown_and_denied(adapter, ws_client):
|
async def test_pull_rejects_unknown_and_denied(adapter, ws_client):
|
||||||
ws, _ = ws_client
|
ws, _ = ws_client
|
||||||
# Unknown media_id.
|
# Unknown media_id.
|
||||||
await ws.send(
|
status, body = await ws.pull("md_nope")
|
||||||
json.dumps({"v": 1, "id": 1, "type": "media.pull", "payload": {"media_id": "md_nope"}})
|
assert status == 404
|
||||||
)
|
assert json.loads(body)["payload"]["code"] == "not_found"
|
||||||
frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 1)
|
|
||||||
assert frames[-1]["payload"]["code"] == "not_found"
|
|
||||||
|
|
||||||
# Known id, but the path fails delivery validation (denylist).
|
# Known id, but the path fails delivery validation (denylist).
|
||||||
entry = adapter._media.register_outbound(
|
entry = adapter._media.register_outbound(
|
||||||
"/etc/passwd", "document", "text/plain", "passwd", 100
|
"/etc/passwd", "document", "text/plain", "passwd", 100
|
||||||
)
|
)
|
||||||
await ws.send(
|
status, body = await ws.pull(entry.media_id)
|
||||||
json.dumps(
|
assert status == 404
|
||||||
{"v": 1, "id": 2, "type": "media.pull", "payload": {"media_id": entry.media_id}}
|
assert json.loads(body)["payload"]["code"] == "not_found"
|
||||||
)
|
|
||||||
)
|
|
||||||
frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 2)
|
|
||||||
assert frames[-1]["payload"]["code"] == "not_found"
|
|
||||||
|
|
||||||
# Known id, file deleted since the offer.
|
# Known id, file deleted since the offer.
|
||||||
from gateway.platforms.base import get_image_cache_dir
|
from gateway.platforms.base import get_image_cache_dir
|
||||||
@@ -1274,13 +1351,9 @@ async def test_pull_rejects_unknown_and_denied(adapter, ws_client):
|
|||||||
str(img), "image", "image/png", "gone.png", len(PNG_1X1)
|
str(img), "image", "image/png", "gone.png", len(PNG_1X1)
|
||||||
)
|
)
|
||||||
img.unlink()
|
img.unlink()
|
||||||
await ws.send(
|
status, body = await ws.pull(entry2.media_id)
|
||||||
json.dumps(
|
assert status == 404
|
||||||
{"v": 1, "id": 3, "type": "media.pull", "payload": {"media_id": entry2.media_id}}
|
assert json.loads(body)["payload"]["code"] == "not_found"
|
||||||
)
|
|
||||||
)
|
|
||||||
frames = await recv_until(ws, lambda f: f.get("type") == "error" and f.get("id") == 3)
|
|
||||||
assert frames[-1]["payload"]["code"] == "not_found"
|
|
||||||
|
|
||||||
|
|
||||||
# ── M5: push backends (pure) ───────────────────────────────────────────────
|
# ── M5: push backends (pure) ───────────────────────────────────────────────
|
||||||
@@ -1653,7 +1726,7 @@ async def test_push_skipped_when_backend_unconfigured(adapter):
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_fcm_register_updates_registry_and_live_conn(adapter, ws_client):
|
async def test_fcm_register_updates_registry(adapter, ws_client):
|
||||||
ws, _ = ws_client
|
ws, _ = ws_client
|
||||||
await ws.send(
|
await ws.send(
|
||||||
json.dumps(
|
json.dumps(
|
||||||
@@ -1664,7 +1737,7 @@ async def test_fcm_register_updates_registry_and_live_conn(adapter, ws_client):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# Ordering barrier: WS frames are processed in order, so by the time the
|
# Ordering barrier: frames are processed in order, so by the time the
|
||||||
# channel.list response arrives, fcm.register has been handled.
|
# channel.list response arrives, fcm.register has been handled.
|
||||||
await ws.send(
|
await ws.send(
|
||||||
json.dumps({"v": 1, "id": 1, "type": "channel.list", "payload": {}})
|
json.dumps({"v": 1, "id": 1, "type": "channel.list", "payload": {}})
|
||||||
@@ -1673,16 +1746,13 @@ async def test_fcm_register_updates_registry_and_live_conn(adapter, ws_client):
|
|||||||
dev = adapter._devices.get(DEVICE_ID)
|
dev = adapter._devices.get(DEVICE_ID)
|
||||||
assert dev["fcm_token"] == "rotated-token"
|
assert dev["fcm_token"] == "rotated-token"
|
||||||
assert dev["ntfy_topic"] == "dev-topic"
|
assert dev["ntfy_topic"] == "dev-topic"
|
||||||
conn = adapter._ws_server.connection(DEVICE_ID)
|
|
||||||
assert conn.fcm_token == "rotated-token"
|
|
||||||
assert conn.ntfy_topic == "dev-topic"
|
|
||||||
|
|
||||||
# The next offline push targets the rotated token.
|
# The next offline push targets the rotated token. Close the stream and
|
||||||
|
# force-remove the (possibly stale) subscriber so the push isn't counted
|
||||||
|
# as delivered to a dead connection.
|
||||||
await ws.close()
|
await ws.close()
|
||||||
for _ in range(100):
|
with adapter._http_server._subs_lock:
|
||||||
if not adapter._ws_server.has_devices():
|
adapter._http_server._subs.clear()
|
||||||
break
|
|
||||||
await asyncio.sleep(0.01)
|
|
||||||
fake = _FakePush()
|
fake = _FakePush()
|
||||||
adapter._push = fake
|
adapter._push = fake
|
||||||
await adapter.send("android:default", "after rotation", metadata={"notify": True})
|
await adapter.send("android:default", "after rotation", metadata={"notify": True})
|
||||||
@@ -1898,13 +1968,12 @@ async def test_sync_replays_parked_frames_and_done_cursor(adapter):
|
|||||||
await adapter.send("android:default", "two", metadata={"notify": True})
|
await adapter.send("android:default", "two", metadata={"notify": True})
|
||||||
assert adapter._outbox.latest_cursor() == 2
|
assert adapter._outbox.latest_cursor() == 2
|
||||||
|
|
||||||
from websockets.asyncio.client import connect
|
|
||||||
|
|
||||||
await adapter.connect()
|
await adapter.connect()
|
||||||
|
# Open at the latest cursor so the SSE catch-up doesn't replay the parked
|
||||||
|
# frames (the sync request below is what we're testing).
|
||||||
|
ws = HttpTestClient(adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor())
|
||||||
try:
|
try:
|
||||||
port = adapter._ws_server._server.sockets[0].getsockname()[1]
|
ack = await ws.start()
|
||||||
async with connect(f"ws://127.0.0.1:{port}/ws") as ws:
|
|
||||||
ack = await _hello(ws)
|
|
||||||
assert ack["payload"]["sync_cursor"] == 2
|
assert ack["payload"]["sync_cursor"] == 2
|
||||||
await ws.send(
|
await ws.send(
|
||||||
json.dumps({"v": 1, "id": 10, "type": "sync", "payload": {"cursor": 0}})
|
json.dumps({"v": 1, "id": 10, "type": "sync", "payload": {"cursor": 0}})
|
||||||
@@ -1924,6 +1993,7 @@ async def test_sync_replays_parked_frames_and_done_cursor(adapter):
|
|||||||
)
|
)
|
||||||
assert len(frames) == 1
|
assert len(frames) == 1
|
||||||
finally:
|
finally:
|
||||||
|
await ws.close()
|
||||||
await adapter.disconnect()
|
await adapter.disconnect()
|
||||||
|
|
||||||
|
|
||||||
@@ -1931,15 +2001,13 @@ async def test_sync_replays_parked_frames_and_done_cursor(adapter):
|
|||||||
async def test_hello_ack_last_pushed_cursor_default_zero(adapter):
|
async def test_hello_ack_last_pushed_cursor_default_zero(adapter):
|
||||||
"""A device that never received a push reports last_pushed_cursor=0 in
|
"""A device that never received a push reports last_pushed_cursor=0 in
|
||||||
hello.ack (docs/08 §8.7 dedupe watermark)."""
|
hello.ack (docs/08 §8.7 dedupe watermark)."""
|
||||||
from websockets.asyncio.client import connect
|
|
||||||
|
|
||||||
await adapter.connect()
|
await adapter.connect()
|
||||||
|
ws = HttpTestClient(adapter._http_server.bound_port)
|
||||||
try:
|
try:
|
||||||
port = adapter._ws_server._server.sockets[0].getsockname()[1]
|
ack = await ws.start()
|
||||||
async with connect(f"ws://127.0.0.1:{port}/ws") as ws:
|
|
||||||
ack = await _hello(ws)
|
|
||||||
assert ack["payload"]["last_pushed_cursor"] == 0
|
assert ack["payload"]["last_pushed_cursor"] == 0
|
||||||
finally:
|
finally:
|
||||||
|
await ws.close()
|
||||||
await adapter.disconnect()
|
await adapter.disconnect()
|
||||||
|
|
||||||
|
|
||||||
@@ -1949,8 +2017,6 @@ async def test_push_success_advances_last_pushed_cursor(adapter):
|
|||||||
next hello.ack reports it — the app uses it to skip re-notifying
|
next hello.ack reports it — the app uses it to skip re-notifying
|
||||||
sync-replayed frames (docs/08 §8.7). Back-to-back frames for the same
|
sync-replayed frames (docs/08 §8.7). Back-to-back frames for the same
|
||||||
chat coalesce into one push; a failed push does not advance the cursor."""
|
chat coalesce into one push; a failed push does not advance the cursor."""
|
||||||
from websockets.asyncio.client import connect
|
|
||||||
|
|
||||||
fake = _FakePush()
|
fake = _FakePush()
|
||||||
adapter._push = fake
|
adapter._push = fake
|
||||||
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
|
adapter._devices.upsert(DEVICE_ID, "Test", {}, fcm_token="tok-1")
|
||||||
@@ -1978,12 +2044,12 @@ async def test_push_success_advances_last_pushed_cursor(adapter):
|
|||||||
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 3
|
assert adapter._devices.last_pushed_cursor(DEVICE_ID) == 3
|
||||||
|
|
||||||
await adapter.connect()
|
await adapter.connect()
|
||||||
|
ws = HttpTestClient(adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor())
|
||||||
try:
|
try:
|
||||||
port = adapter._ws_server._server.sockets[0].getsockname()[1]
|
ack = await ws.start()
|
||||||
async with connect(f"ws://127.0.0.1:{port}/ws") as ws:
|
|
||||||
ack = await _hello(ws)
|
|
||||||
assert ack["payload"]["last_pushed_cursor"] == 3
|
assert ack["payload"]["last_pushed_cursor"] == 3
|
||||||
finally:
|
finally:
|
||||||
|
await ws.close()
|
||||||
await adapter.disconnect()
|
await adapter.disconnect()
|
||||||
|
|
||||||
|
|
||||||
@@ -1992,16 +2058,13 @@ async def test_sync_replay_frames_carry_outbox_cursor(adapter):
|
|||||||
"""Frames replayed by sync carry their outbox cursor in the envelope so
|
"""Frames replayed by sync carry their outbox cursor in the envelope so
|
||||||
the app can compare it against last_pushed_cursor (docs/08 §8.7). Live
|
the app can compare it against last_pushed_cursor (docs/08 §8.7). Live
|
||||||
frames carry no cursor."""
|
frames carry no cursor."""
|
||||||
from websockets.asyncio.client import connect
|
|
||||||
|
|
||||||
await adapter.send("android:default", "one", metadata={"notify": True})
|
await adapter.send("android:default", "one", metadata={"notify": True})
|
||||||
await adapter.send("android:default", "two", metadata={"notify": True})
|
await adapter.send("android:default", "two", metadata={"notify": True})
|
||||||
|
|
||||||
await adapter.connect()
|
await adapter.connect()
|
||||||
|
ws = HttpTestClient(adapter._http_server.bound_port, cursor=adapter._outbox.latest_cursor())
|
||||||
try:
|
try:
|
||||||
port = adapter._ws_server._server.sockets[0].getsockname()[1]
|
await ws.start()
|
||||||
async with connect(f"ws://127.0.0.1:{port}/ws") as ws:
|
|
||||||
await _hello(ws)
|
|
||||||
await ws.send(
|
await ws.send(
|
||||||
json.dumps({"v": 1, "id": 20, "type": "sync", "payload": {"cursor": 0}})
|
json.dumps({"v": 1, "id": 20, "type": "sync", "payload": {"cursor": 0}})
|
||||||
)
|
)
|
||||||
@@ -2011,6 +2074,7 @@ async def test_sync_replay_frames_carry_outbox_cursor(adapter):
|
|||||||
# sync.done itself carries no envelope cursor.
|
# sync.done itself carries no envelope cursor.
|
||||||
assert "cursor" not in frames[-1]
|
assert "cursor" not in frames[-1]
|
||||||
finally:
|
finally:
|
||||||
|
await ws.close()
|
||||||
await adapter.disconnect()
|
await adapter.disconnect()
|
||||||
|
|
||||||
|
|
||||||
@@ -2129,30 +2193,27 @@ def test_channels_delete_hard_deletes_row_and_child_threads(plugin, tmp_path):
|
|||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_wrong_token_rejected(adapter):
|
async def test_wrong_token_rejected(adapter):
|
||||||
from websockets.asyncio.client import connect
|
"""A request with a wrong Bearer token is rejected with 401 (the HTTP
|
||||||
|
equivalent of the WS hello auth rejection)."""
|
||||||
await adapter.connect()
|
await adapter.connect()
|
||||||
try:
|
try:
|
||||||
port = adapter._ws_server._server.sockets[0].getsockname()[1]
|
port = adapter._http_server.bound_port
|
||||||
async with connect(f"ws://127.0.0.1:{port}/ws") as ws:
|
|
||||||
await ws.send(
|
def _req() -> int:
|
||||||
json.dumps(
|
conn = HTTPConnection("127.0.0.1", port, timeout=5)
|
||||||
{
|
conn.request(
|
||||||
"v": 1,
|
"GET",
|
||||||
"type": "hello",
|
"/v1/events",
|
||||||
"payload": {
|
headers={"Authorization": "Bearer wrong-token", "X-Iris-Device": DEVICE_ID},
|
||||||
"token": "wrong-token",
|
|
||||||
"device_id": DEVICE_ID,
|
|
||||||
"device_name": "Bad",
|
|
||||||
"caps": {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
)
|
resp = conn.getresponse()
|
||||||
raw = await asyncio.wait_for(ws.recv(), timeout=5)
|
resp.read()
|
||||||
err = json.loads(raw)
|
status = resp.status
|
||||||
assert err["type"] == "error"
|
conn.close()
|
||||||
assert err["payload"]["code"] == "auth"
|
return status
|
||||||
|
|
||||||
|
status = await asyncio.to_thread(_req)
|
||||||
|
assert status == 401
|
||||||
finally:
|
finally:
|
||||||
await adapter.disconnect()
|
await adapter.disconnect()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,826 @@
|
|||||||
|
"""Tests for the android plugin's HTTP fallback transport (docs/19).
|
||||||
|
|
||||||
|
The plugin lives in the sibling ``iris_x_hermes`` checkout; tests load it
|
||||||
|
from the source tree directly (same pattern as ``test_android.py``).
|
||||||
|
|
||||||
|
Coverage (docs/19 §19.12):
|
||||||
|
* auth: bad/missing token -> 401; missing device header -> 401;
|
||||||
|
allowlist rejection -> 401
|
||||||
|
* ``POST /v1/frame``: valid ``message.send`` dispatches (202 + echo on
|
||||||
|
the SSE stream); empty text -> 400 error frame; automation channel ->
|
||||||
|
400; bad JSON -> 400; wrong content-type -> 400; oversize body -> 413;
|
||||||
|
media frames -> 400 (WS-only in v1); rate limit -> 429
|
||||||
|
* SSE: catch-up rows carry correct ``id``s + cursor envelope;
|
||||||
|
``event: hello`` present; a live frame appended after connect arrives
|
||||||
|
on the stream; ``Last-Event-ID`` resume replays exactly the delta;
|
||||||
|
heartbeat observed
|
||||||
|
* long-poll: returns on new frame; empty 200 at timeout with advanced
|
||||||
|
cursor
|
||||||
|
* **delivery counting (docs/19 §19.8)**: a frame with only an SSE
|
||||||
|
subscriber is ``delivered >= 1`` -> NO push fired (the critical
|
||||||
|
regression test)
|
||||||
|
|
||||||
|
Run via ``scripts/run_tests.sh tests/gateway/test_android_http.py``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import contextlib
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from http.client import HTTPConnection
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
|
||||||
|
# Test-only token (not a credential; the adapter is built with it via
|
||||||
|
# monkeypatch in the fixture below).
|
||||||
|
# pi-lens-ignore: S105
|
||||||
|
TOKEN = "test-android-http-token-0123456789"
|
||||||
|
DEVICE_ID = "test-http-device"
|
||||||
|
CHAT_ID = "android:default"
|
||||||
|
|
||||||
|
# 1x1 PNG (same fixture as test_android.py).
|
||||||
|
PNG_1X1 = base64.b64decode(
|
||||||
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJ"
|
||||||
|
"AAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=="
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _plugin_dir() -> Path:
|
||||||
|
env = os.environ.get("ANDROID_PLUGIN_DIR")
|
||||||
|
if env:
|
||||||
|
return Path(env)
|
||||||
|
# Works from either copy of this file: gateway-plugin/tests/ (canonical,
|
||||||
|
# plugin dir is parents[1]) or the hermes-agent/tests/gateway/ mirror
|
||||||
|
# (repo root is parents[3]).
|
||||||
|
here = Path(__file__).resolve()
|
||||||
|
for candidate in (here.parents[1], here.parents[3] / "gateway-plugin"):
|
||||||
|
if (candidate / "protocol.py").is_file():
|
||||||
|
return candidate
|
||||||
|
return here.parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def _load_plugin():
|
||||||
|
"""Load the gateway-plugin package under a unique module name (same
|
||||||
|
pattern as test_android.py)."""
|
||||||
|
name = "android_plugin_http_under_test"
|
||||||
|
cached = sys.modules.get(name)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
pkg_dir = _plugin_dir()
|
||||||
|
if not (pkg_dir / "__init__.py").is_file():
|
||||||
|
pytest.fail(f"android plugin not found at {pkg_dir}")
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
name, pkg_dir / "__init__.py", submodule_search_locations=[str(pkg_dir)]
|
||||||
|
)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
pytest.fail(f"could not build import spec for {pkg_dir}")
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[name] = module
|
||||||
|
try:
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
except Exception:
|
||||||
|
sys.modules.pop(name, None)
|
||||||
|
raise
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def plugin():
|
||||||
|
return _load_plugin()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def adapter(plugin, monkeypatch):
|
||||||
|
"""A live AndroidAdapter with an isolated HERMES_HOME (conftest)."""
|
||||||
|
monkeypatch.setenv("ANDROID_TOKEN", TOKEN)
|
||||||
|
from gateway.platform_registry import PlatformEntry, platform_registry
|
||||||
|
|
||||||
|
if not platform_registry.is_registered("android"):
|
||||||
|
platform_registry.register(
|
||||||
|
PlatformEntry(
|
||||||
|
name="android",
|
||||||
|
label="Android",
|
||||||
|
adapter_factory=lambda cfg: None,
|
||||||
|
check_fn=lambda: True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
config = SimpleNamespace(
|
||||||
|
extra={
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 0, # ephemeral WS port
|
||||||
|
"http_port": 0, # ephemeral HTTP port
|
||||||
|
"max_upload_bytes": 1024 * 1024,
|
||||||
|
},
|
||||||
|
home_channel=None,
|
||||||
|
)
|
||||||
|
a = plugin.adapter.AndroidAdapter(config)
|
||||||
|
yield a
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
a._devices.close()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
a._outbox.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def gw(adapter):
|
||||||
|
"""Connected adapter (WS + HTTP legs up); the HTTP port is ephemeral."""
|
||||||
|
await adapter.connect()
|
||||||
|
try:
|
||||||
|
yield adapter
|
||||||
|
finally:
|
||||||
|
await adapter.disconnect()
|
||||||
|
|
||||||
|
|
||||||
|
def http_port(adapter) -> int:
|
||||||
|
assert adapter._http_server.enabled, "HTTP leg should be enabled after connect()"
|
||||||
|
return adapter._http_server.bound_port
|
||||||
|
|
||||||
|
|
||||||
|
# ── Blocking HTTP helpers (run via asyncio.to_thread) ──────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _request(
|
||||||
|
port: int,
|
||||||
|
method: str,
|
||||||
|
path: str,
|
||||||
|
*,
|
||||||
|
token: str | None = TOKEN,
|
||||||
|
device: str | None = DEVICE_ID,
|
||||||
|
body: bytes | str | None = None,
|
||||||
|
content_type: str = "application/json",
|
||||||
|
timeout: float = 10.0,
|
||||||
|
extra_headers: dict | None = None,
|
||||||
|
) -> tuple[int, bytes]:
|
||||||
|
conn = HTTPConnection("127.0.0.1", port, timeout=timeout)
|
||||||
|
headers = {}
|
||||||
|
if token is not None:
|
||||||
|
headers["Authorization"] = f"Bearer {token}"
|
||||||
|
if device is not None:
|
||||||
|
headers["X-Iris-Device"] = device
|
||||||
|
if extra_headers:
|
||||||
|
headers.update(extra_headers)
|
||||||
|
if body is not None:
|
||||||
|
data = body if isinstance(body, bytes) else body.encode("utf-8")
|
||||||
|
headers["Content-Type"] = content_type
|
||||||
|
conn.request(method, path, body=data, headers=headers)
|
||||||
|
else:
|
||||||
|
conn.request(method, path, headers=headers)
|
||||||
|
resp = conn.getresponse()
|
||||||
|
payload = resp.read()
|
||||||
|
status = resp.status
|
||||||
|
conn.close()
|
||||||
|
return status, payload
|
||||||
|
|
||||||
|
|
||||||
|
def _post_frame(port: int, frame: dict, **kw) -> tuple[int, dict]:
|
||||||
|
status, payload = _request(port, "POST", "/v1/frame", body=json.dumps(frame), **kw)
|
||||||
|
return status, json.loads(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def _frame_json(frame: dict) -> dict:
|
||||||
|
return {"v": 1, **frame}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_sse(lines: list[str]) -> tuple[list[tuple[str | None, str | None, str]], int]:
|
||||||
|
"""Parse raw SSE lines into ``[(event, id, data), ...]`` + comment count."""
|
||||||
|
events: list[tuple[str | None, str | None, str]] = []
|
||||||
|
comments = 0
|
||||||
|
cur_event: str | None = None
|
||||||
|
cur_id: str | None = None
|
||||||
|
cur_data: list[str] = []
|
||||||
|
for raw in lines:
|
||||||
|
line = raw.rstrip("\r\n")
|
||||||
|
if line == "":
|
||||||
|
if cur_data:
|
||||||
|
events.append((cur_event, cur_id, "\n".join(cur_data)))
|
||||||
|
cur_event, cur_id, cur_data = None, None, []
|
||||||
|
elif line.startswith(":"):
|
||||||
|
comments += 1
|
||||||
|
else:
|
||||||
|
field, _, value = line.partition(":")
|
||||||
|
if value.startswith(" "):
|
||||||
|
value = value[1:]
|
||||||
|
if field == "event":
|
||||||
|
cur_event = value
|
||||||
|
elif field == "id":
|
||||||
|
cur_id = value
|
||||||
|
elif field == "data":
|
||||||
|
cur_data.append(value)
|
||||||
|
return events, comments
|
||||||
|
|
||||||
|
|
||||||
|
def _sse_open(port: int, *, cursor: int | None = None, last_event_id: str | None = None):
|
||||||
|
"""Open an SSE connection (blocking); returns the HTTPResponse (read
|
||||||
|
lines via ``_sse_read_lines``; close with ``resp.close()``)."""
|
||||||
|
conn = HTTPConnection("127.0.0.1", port, timeout=30)
|
||||||
|
path = "/v1/events" + (f"?cursor={cursor}" if cursor is not None else "")
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {TOKEN}",
|
||||||
|
"X-Iris-Device": DEVICE_ID,
|
||||||
|
}
|
||||||
|
if last_event_id is not None:
|
||||||
|
headers["Last-Event-ID"] = last_event_id
|
||||||
|
conn.request("GET", path, headers=headers)
|
||||||
|
resp = conn.getresponse()
|
||||||
|
assert resp.status == 200, f"SSE open failed: {resp.status}"
|
||||||
|
assert resp.getheader("Content-Type", "").startswith("text/event-stream")
|
||||||
|
return resp
|
||||||
|
|
||||||
|
|
||||||
|
def _sse_read_lines(resp, n: int, timeout: float = 10.0) -> list[str]:
|
||||||
|
"""Read up to n lines from the SSE stream (blocking)."""
|
||||||
|
raw = resp.fp.raw
|
||||||
|
sock = getattr(raw, "_sock", None)
|
||||||
|
if sock is not None:
|
||||||
|
sock.settimeout(timeout)
|
||||||
|
lines: list[str] = []
|
||||||
|
while len(lines) < n:
|
||||||
|
line = resp.fp.readline()
|
||||||
|
if not line:
|
||||||
|
break
|
||||||
|
lines.append(line.decode("utf-8"))
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
# ── /v1/health ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_health_no_auth(gw):
|
||||||
|
status, payload = await asyncio.to_thread(
|
||||||
|
_request, http_port(gw), "GET", "/v1/health", token=None, device=None
|
||||||
|
)
|
||||||
|
assert status == 200
|
||||||
|
assert json.loads(payload) == {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_unknown_path_404(gw):
|
||||||
|
status, _ = await asyncio.to_thread(_request, http_port(gw), "GET", "/v1/nope")
|
||||||
|
assert status == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ── Auth ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_post_bad_token_401(gw):
|
||||||
|
status, _ = await asyncio.to_thread(
|
||||||
|
_request,
|
||||||
|
http_port(gw),
|
||||||
|
"POST",
|
||||||
|
"/v1/frame",
|
||||||
|
token="wrong-token",
|
||||||
|
body=json.dumps(_frame_json({"type": "ping", "payload": {}})),
|
||||||
|
)
|
||||||
|
assert status == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_post_missing_token_401(gw):
|
||||||
|
status, _ = await asyncio.to_thread(
|
||||||
|
_request,
|
||||||
|
http_port(gw),
|
||||||
|
"POST",
|
||||||
|
"/v1/frame",
|
||||||
|
token=None,
|
||||||
|
body=json.dumps(_frame_json({"type": "ping", "payload": {}})),
|
||||||
|
)
|
||||||
|
assert status == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_post_missing_device_401(gw):
|
||||||
|
status, _ = await asyncio.to_thread(
|
||||||
|
_request,
|
||||||
|
http_port(gw),
|
||||||
|
"POST",
|
||||||
|
"/v1/frame",
|
||||||
|
device=None,
|
||||||
|
body=json.dumps(_frame_json({"type": "ping", "payload": {}})),
|
||||||
|
)
|
||||||
|
assert status == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_post_allowlist_rejection_401(gw):
|
||||||
|
gw.allowed_users = ["some-other-device"]
|
||||||
|
gw.allow_all = False
|
||||||
|
status, _ = await asyncio.to_thread(
|
||||||
|
_request,
|
||||||
|
http_port(gw),
|
||||||
|
"POST",
|
||||||
|
"/v1/frame",
|
||||||
|
body=json.dumps(_frame_json({"type": "ping", "payload": {}})),
|
||||||
|
)
|
||||||
|
assert status == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /v1/frame: validation ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_post_bad_json_400(gw):
|
||||||
|
status, payload = await asyncio.to_thread(
|
||||||
|
_request, http_port(gw), "POST", "/v1/frame", body=b"not json"
|
||||||
|
)
|
||||||
|
assert status == 400
|
||||||
|
frame = json.loads(payload)
|
||||||
|
assert frame["type"] == "error"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_post_wrong_content_type_400(gw):
|
||||||
|
status, _ = await asyncio.to_thread(
|
||||||
|
_request,
|
||||||
|
http_port(gw),
|
||||||
|
"POST",
|
||||||
|
"/v1/frame",
|
||||||
|
body=json.dumps(_frame_json({"type": "ping", "payload": {}})),
|
||||||
|
content_type="text/plain",
|
||||||
|
)
|
||||||
|
assert status == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_post_oversize_body_413(gw):
|
||||||
|
big = json.dumps(_frame_json({"type": "ping", "payload": {"pad": "x" * (1024 * 1024 + 1)}}))
|
||||||
|
status, _ = await asyncio.to_thread(_request, http_port(gw), "POST", "/v1/frame", body=big)
|
||||||
|
assert status == 413
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_post_empty_message_400(gw):
|
||||||
|
gw.handle_message = AsyncMock()
|
||||||
|
status, payload = await asyncio.to_thread(
|
||||||
|
_post_frame,
|
||||||
|
http_port(gw),
|
||||||
|
_frame_json(
|
||||||
|
{"id": 7, "type": "message.send", "chat_id": CHAT_ID, "payload": {"text": " "}}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert status == 400
|
||||||
|
frame = payload
|
||||||
|
assert frame["type"] == "error"
|
||||||
|
assert frame["id"] == 7
|
||||||
|
assert frame["payload"]["code"] == "unsupported"
|
||||||
|
gw.handle_message.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_post_automation_channel_400(gw):
|
||||||
|
gw.handle_message = AsyncMock()
|
||||||
|
entry = gw._channels.create(name="Cron")
|
||||||
|
gw._channels.set_automation(entry["chat_id"], True)
|
||||||
|
status, payload = await asyncio.to_thread(
|
||||||
|
_post_frame,
|
||||||
|
http_port(gw),
|
||||||
|
_frame_json(
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"type": "message.send",
|
||||||
|
"chat_id": entry["chat_id"],
|
||||||
|
"payload": {"text": "hi"},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
assert status == 400
|
||||||
|
assert payload["type"] == "error"
|
||||||
|
gw.handle_message.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_post_rate_limit_429(gw):
|
||||||
|
gw.handle_message = AsyncMock()
|
||||||
|
port = http_port(gw)
|
||||||
|
# Exhaust the per-device bucket (INBOUND_BURST = 40) then expect 429.
|
||||||
|
got_429 = False
|
||||||
|
for i in range(60):
|
||||||
|
status, _ = await asyncio.to_thread(
|
||||||
|
_post_frame,
|
||||||
|
port,
|
||||||
|
_frame_json({"id": i, "type": "ping", "payload": {}}),
|
||||||
|
)
|
||||||
|
if status == 429:
|
||||||
|
got_429 = True
|
||||||
|
break
|
||||||
|
assert got_429, "expected a 429 within 60 rapid frames"
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /v1/frame: dispatch ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_post_message_send_dispatches(gw):
|
||||||
|
"""202 ack; the user echo + read receipt arrive on the SSE stream; the
|
||||||
|
agent turn fires (docs/19 §19.7: async responses on the event stream)."""
|
||||||
|
gw.handle_message = AsyncMock()
|
||||||
|
port = http_port(gw)
|
||||||
|
conn = _sse_open(port)
|
||||||
|
try:
|
||||||
|
# Consume the open sequence (hello + status = 6 lines) first.
|
||||||
|
lines = _sse_read_lines(conn, 6, timeout=5)
|
||||||
|
events, _ = _parse_sse(lines)
|
||||||
|
assert events[0][0] == "hello"
|
||||||
|
|
||||||
|
status, payload = await asyncio.to_thread(
|
||||||
|
_post_frame,
|
||||||
|
port,
|
||||||
|
_frame_json(
|
||||||
|
{
|
||||||
|
"id": 42,
|
||||||
|
"type": "message.send",
|
||||||
|
"chat_id": CHAT_ID,
|
||||||
|
"payload": {"text": "hi there"},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# The read receipt (sent when the turn is handed to the agent) is
|
||||||
|
# the handler's single point-to-point reply -> 200 with the frame
|
||||||
|
# as the body (a plain 202 {"ok": true} is also valid when no
|
||||||
|
# synchronous reply exists).
|
||||||
|
assert status in (200, 202)
|
||||||
|
if status == 200:
|
||||||
|
assert payload["type"] == "read.receipt"
|
||||||
|
else:
|
||||||
|
assert payload == {"ok": True}
|
||||||
|
|
||||||
|
# The echo must arrive on the stream, tagged with its outbox
|
||||||
|
# cursor as the SSE id (live frames carry no cursor in the
|
||||||
|
# envelope, same as the WS path).
|
||||||
|
deadline = time.monotonic() + 10
|
||||||
|
echo = None
|
||||||
|
while time.monotonic() < deadline and echo is None:
|
||||||
|
lines = _sse_read_lines(conn, 4, timeout=5)
|
||||||
|
for _event, sse_id, data in _parse_sse(lines)[0]:
|
||||||
|
frame = json.loads(data)
|
||||||
|
if (
|
||||||
|
frame.get("type") == "message"
|
||||||
|
and frame.get("payload", {}).get("text") == "hi there"
|
||||||
|
):
|
||||||
|
echo = (frame, sse_id)
|
||||||
|
assert echo is not None, "user echo did not arrive on the SSE stream"
|
||||||
|
assert echo[1] is not None # SSE id = outbox cursor
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
gw.handle_message.assert_called_once()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── SSE: catch-up, hello, live, resume, heartbeat ──────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sse_catchup_and_hello(gw):
|
||||||
|
"""Catch-up rows carry correct ids + cursor envelope; hello present."""
|
||||||
|
port = http_port(gw)
|
||||||
|
# Park two frames through the real outbound path (no live devices).
|
||||||
|
push_calls: list = []
|
||||||
|
gw._maybe_push = AsyncMock(side_effect=lambda *a, **k: push_calls.append(a))
|
||||||
|
for text in ("one", "two"):
|
||||||
|
await _park_frame(gw, text)
|
||||||
|
cursors = [1, 2]
|
||||||
|
|
||||||
|
conn = _sse_open(port, cursor=0)
|
||||||
|
try:
|
||||||
|
# 2 replayed frames (id + event + data + blank = 4 lines each) +
|
||||||
|
# hello (3 lines) + status (3 lines) = 14 lines.
|
||||||
|
lines = _sse_read_lines(conn, 14, timeout=5)
|
||||||
|
events, _ = _parse_sse(lines)
|
||||||
|
assert events[0][0] == "frame"
|
||||||
|
assert events[0][1] == str(cursors[0])
|
||||||
|
f0 = json.loads(events[0][2])
|
||||||
|
assert f0["payload"]["text"] == "one"
|
||||||
|
assert f0["cursor"] == cursors[0]
|
||||||
|
assert events[1][0] == "frame"
|
||||||
|
assert events[1][1] == str(cursors[1])
|
||||||
|
assert json.loads(events[1][2])["payload"]["text"] == "two"
|
||||||
|
assert events[2][0] == "hello"
|
||||||
|
hello = json.loads(events[2][2])
|
||||||
|
assert hello["type"] == "hello.ack"
|
||||||
|
assert hello["payload"]["sync_cursor"] == 2
|
||||||
|
assert events[3][0] == "frame"
|
||||||
|
assert json.loads(events[3][2])["type"] == "status"
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sse_live_frame_after_connect(gw):
|
||||||
|
port = http_port(gw)
|
||||||
|
conn = _sse_open(port)
|
||||||
|
try:
|
||||||
|
# Consume the open sequence (hello + status = 6 lines).
|
||||||
|
_sse_read_lines(conn, 6, timeout=5)
|
||||||
|
await _park_frame(gw, "live!")
|
||||||
|
deadline = time.monotonic() + 10
|
||||||
|
got = None
|
||||||
|
while time.monotonic() < deadline and got is None:
|
||||||
|
lines = _sse_read_lines(conn, 4, timeout=5)
|
||||||
|
for _event, sse_id, data in _parse_sse(lines)[0]:
|
||||||
|
frame = json.loads(data)
|
||||||
|
if frame.get("payload", {}).get("text") == "live!":
|
||||||
|
got = (frame, sse_id)
|
||||||
|
assert got is not None, "live frame did not arrive on the SSE stream"
|
||||||
|
assert got[1] is not None # SSE id = outbox cursor
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sse_last_event_id_resume(gw):
|
||||||
|
"""Resume with Last-Event-ID replays exactly the delta."""
|
||||||
|
port = http_port(gw)
|
||||||
|
for text in ("a", "b", "c"):
|
||||||
|
await _park_frame(gw, text)
|
||||||
|
|
||||||
|
conn = _sse_open(port, last_event_id="1")
|
||||||
|
try:
|
||||||
|
# Frames 2 and 3 replayed (8 lines) + hello (3) + status (3) = 14.
|
||||||
|
lines = _sse_read_lines(conn, 14, timeout=5)
|
||||||
|
events, _ = _parse_sse(lines)
|
||||||
|
replayed = [e for e in events if e[0] == "frame" and e[1] is not None]
|
||||||
|
assert [e[1] for e in replayed] == ["2", "3"]
|
||||||
|
assert json.loads(replayed[0][2])["payload"]["text"] == "b"
|
||||||
|
assert json.loads(replayed[1][2])["payload"]["text"] == "c"
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sse_heartbeat(gw, monkeypatch, plugin):
|
||||||
|
"""A comment heartbeat is written when the stream is idle."""
|
||||||
|
monkeypatch.setattr(plugin.http_server, "SSE_HEARTBEAT_S", 1.0)
|
||||||
|
port = http_port(gw)
|
||||||
|
conn = _sse_open(port)
|
||||||
|
try:
|
||||||
|
# Consume the open sequence (6 lines), then wait for the heartbeat.
|
||||||
|
_sse_read_lines(conn, 6, timeout=5)
|
||||||
|
lines = _sse_read_lines(conn, 2, timeout=5)
|
||||||
|
events, comments = _parse_sse(lines)
|
||||||
|
assert comments >= 1, f"no heartbeat comment in {lines!r}"
|
||||||
|
assert events == []
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Long-poll ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_returns_on_new_frame(gw):
|
||||||
|
port = http_port(gw)
|
||||||
|
await _park_frame(gw, "pre") # cursor 1: returned immediately (catch-up)
|
||||||
|
|
||||||
|
# A second poll at the high-water mark blocks until a new frame lands.
|
||||||
|
def poll_and_broadcast():
|
||||||
|
status, payload = _request(port, "GET", "/v1/poll?cursor=1", timeout=30)
|
||||||
|
return status, json.loads(payload)
|
||||||
|
|
||||||
|
async def late_frame():
|
||||||
|
await asyncio.sleep(0.5)
|
||||||
|
await _park_frame(gw, "late")
|
||||||
|
|
||||||
|
poll_task = asyncio.create_task(asyncio.to_thread(poll_and_broadcast))
|
||||||
|
late_task = asyncio.create_task(late_frame())
|
||||||
|
status, body = await asyncio.wait_for(poll_task, timeout=15)
|
||||||
|
await late_task
|
||||||
|
assert status == 200
|
||||||
|
assert body["cursor"] >= 2
|
||||||
|
assert len(body["frames"]) == 1
|
||||||
|
assert json.loads(body["frames"][0])["payload"]["text"] == "late"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_poll_timeout_empty(gw, monkeypatch, plugin):
|
||||||
|
monkeypatch.setattr(plugin.http_server, "POLL_TIMEOUT_S", 1.0)
|
||||||
|
port = http_port(gw)
|
||||||
|
await _park_frame(gw, "x")
|
||||||
|
hwm = gw._outbox.latest_cursor()
|
||||||
|
status, payload = await asyncio.to_thread(
|
||||||
|
_request, port, "GET", f"/v1/poll?cursor={hwm}", timeout=15
|
||||||
|
)
|
||||||
|
body = json.loads(payload)
|
||||||
|
assert status == 200
|
||||||
|
assert body["frames"] == []
|
||||||
|
assert body["cursor"] == hwm
|
||||||
|
|
||||||
|
|
||||||
|
# ── Delivery counting (docs/19 §19.8 — the critical regression) ────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_sse_subscriber_counts_as_delivered_no_push(gw):
|
||||||
|
"""A frame with only an SSE subscriber is delivered >= 1 -> NO push."""
|
||||||
|
port = http_port(gw)
|
||||||
|
conn = _sse_open(port)
|
||||||
|
try:
|
||||||
|
_sse_read_lines(conn, 6, timeout=5) # open sequence
|
||||||
|
push = AsyncMock()
|
||||||
|
gw._maybe_push = push
|
||||||
|
await _park_frame(gw, "no push for me")
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
push.assert_not_called()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_no_subscribers_still_pushes(gw):
|
||||||
|
"""Control: with no live devices at all, the push path still fires."""
|
||||||
|
push = AsyncMock()
|
||||||
|
gw._maybe_push = push
|
||||||
|
await _park_frame(gw, "wake me up")
|
||||||
|
await asyncio.sleep(0.2)
|
||||||
|
push.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Media over HTTP (docs/19 §19.15, v2) ──────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _upload(
|
||||||
|
port: int,
|
||||||
|
data: bytes,
|
||||||
|
*,
|
||||||
|
media_ref: str = "mu_http1",
|
||||||
|
kind: str = "image",
|
||||||
|
mime: str = "image/png",
|
||||||
|
filename: str = "t.png",
|
||||||
|
sha256: str | None = None,
|
||||||
|
**kw,
|
||||||
|
) -> tuple[int, dict]:
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"X-Iris-Media-Ref": media_ref,
|
||||||
|
"X-Iris-Media-Kind": kind,
|
||||||
|
"X-Iris-Media-Filename": filename,
|
||||||
|
"X-Iris-Media-Sha256": sha256 if sha256 is not None else hashlib.sha256(data).hexdigest(),
|
||||||
|
}
|
||||||
|
status, payload = _request(
|
||||||
|
port,
|
||||||
|
"POST",
|
||||||
|
"/v1/media",
|
||||||
|
body=data,
|
||||||
|
content_type=mime,
|
||||||
|
extra_headers=headers,
|
||||||
|
**kw,
|
||||||
|
)
|
||||||
|
return status, json.loads(payload)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_media_upload_ok(gw):
|
||||||
|
port = http_port(gw)
|
||||||
|
status, body = await asyncio.to_thread(_upload, port, PNG_1X1)
|
||||||
|
assert status == 201, body
|
||||||
|
assert body["type"] == "media.upload.ack"
|
||||||
|
assert body["payload"]["ok"] is True
|
||||||
|
assert body["payload"]["media_ref"] == "mu_http1"
|
||||||
|
entry = gw._media.get_inbound("mu_http1")
|
||||||
|
assert entry is not None
|
||||||
|
assert entry.kind == "image"
|
||||||
|
assert entry.size == len(PNG_1X1)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_media_upload_sha_mismatch(gw):
|
||||||
|
port = http_port(gw)
|
||||||
|
status, body = await asyncio.to_thread(
|
||||||
|
_upload, port, PNG_1X1, media_ref="mu_badsha", sha256="0" * 64
|
||||||
|
)
|
||||||
|
assert status == 500, body # internal: digest mismatch
|
||||||
|
assert body["type"] == "error"
|
||||||
|
assert body["payload"]["code"] == "internal"
|
||||||
|
assert gw._media.get_inbound("mu_badsha") is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_media_upload_oversize_413(gw):
|
||||||
|
port = http_port(gw)
|
||||||
|
oversize = b"x" * (gw.max_upload_bytes + 1)
|
||||||
|
status, body = await asyncio.to_thread(_upload, port, oversize, media_ref="mu_big")
|
||||||
|
assert status == 413, body
|
||||||
|
assert body["payload"]["code"] == "media_too_large"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_media_upload_missing_ref_400(gw):
|
||||||
|
port = http_port(gw)
|
||||||
|
status, payload = await asyncio.to_thread(
|
||||||
|
_request,
|
||||||
|
port,
|
||||||
|
"POST",
|
||||||
|
"/v1/media",
|
||||||
|
body=PNG_1X1,
|
||||||
|
content_type="image/png",
|
||||||
|
extra_headers={"X-Iris-Media-Kind": "image"},
|
||||||
|
)
|
||||||
|
body = json.loads(payload)
|
||||||
|
assert status == 400
|
||||||
|
assert body["payload"]["code"] == "unsupported"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_media_upload_bad_kind_400(gw):
|
||||||
|
port = http_port(gw)
|
||||||
|
status, body = await asyncio.to_thread(_upload, port, PNG_1X1, kind="hologram")
|
||||||
|
assert status == 400
|
||||||
|
assert body["payload"]["code"] == "unsupported"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_media_upload_auth_401(gw):
|
||||||
|
port = http_port(gw)
|
||||||
|
status, _ = await asyncio.to_thread(_upload, port, PNG_1X1, token="wrong-token")
|
||||||
|
assert status == 401
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_media_upload_liar_reclassified(gw):
|
||||||
|
"""Lies about being a PNG: magic-byte re-sniff keeps it out of the image
|
||||||
|
cache (lands as a document) — same contract as the WS path."""
|
||||||
|
port = http_port(gw)
|
||||||
|
payload = b"<html>not an image</html>"
|
||||||
|
status, body = await asyncio.to_thread(
|
||||||
|
_upload, port, payload, media_ref="mu_liar", filename="liar.html"
|
||||||
|
)
|
||||||
|
assert status == 201, body
|
||||||
|
entry = gw._media.get_inbound("mu_liar")
|
||||||
|
assert entry is not None
|
||||||
|
assert entry.kind == "document"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_media_pull_ok(gw):
|
||||||
|
from gateway.platforms.base import get_image_cache_dir
|
||||||
|
|
||||||
|
img = get_image_cache_dir() / "http_pull_test.png"
|
||||||
|
img.write_bytes(PNG_1X1)
|
||||||
|
entry = gw._media.register_outbound(
|
||||||
|
str(img), "image", "image/png", "http_pull_test.png", len(PNG_1X1)
|
||||||
|
)
|
||||||
|
port = http_port(gw)
|
||||||
|
status, payload = await asyncio.to_thread(_request, port, "GET", f"/v1/media/{entry.media_id}")
|
||||||
|
assert status == 200
|
||||||
|
assert payload == PNG_1X1
|
||||||
|
conn = HTTPConnection("127.0.0.1", port, timeout=10)
|
||||||
|
conn.request(
|
||||||
|
"GET",
|
||||||
|
f"/v1/media/{entry.media_id}",
|
||||||
|
headers={"Authorization": f"Bearer {TOKEN}", "X-Iris-Device": DEVICE_ID},
|
||||||
|
)
|
||||||
|
resp = conn.getresponse()
|
||||||
|
resp.read()
|
||||||
|
assert resp.getheader("Content-Type") == "image/png"
|
||||||
|
assert resp.getheader("Content-Length") == str(len(PNG_1X1))
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_media_pull_unknown_404(gw):
|
||||||
|
port = http_port(gw)
|
||||||
|
status, payload = await asyncio.to_thread(_request, port, "GET", "/v1/media/md_nope")
|
||||||
|
body = json.loads(payload)
|
||||||
|
assert status == 404
|
||||||
|
assert body["payload"]["code"] == "not_found"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_media_pull_denied_path_404(gw):
|
||||||
|
"""Known id, but the path fails delivery validation (denylist) — same
|
||||||
|
re-check at pull time as the WS path."""
|
||||||
|
entry = gw._media.register_outbound("/etc/passwd", "document", "text/plain", "passwd", 100)
|
||||||
|
port = http_port(gw)
|
||||||
|
status, payload = await asyncio.to_thread(_request, port, "GET", f"/v1/media/{entry.media_id}")
|
||||||
|
body = json.loads(payload)
|
||||||
|
assert status == 404
|
||||||
|
assert body["payload"]["code"] == "not_found"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
async def _park_frame(adapter, text: str) -> int:
|
||||||
|
"""Emit one message frame through ``_broadcast_or_log`` (the real
|
||||||
|
outbound path); returns the outbox cursor."""
|
||||||
|
plugin = _load_plugin()
|
||||||
|
frame = plugin.protocol.message(
|
||||||
|
chat_id=CHAT_ID,
|
||||||
|
message_id=f"m_{abs(hash(text)) % 10**8:08x}",
|
||||||
|
role="assistant",
|
||||||
|
text=text,
|
||||||
|
)
|
||||||
|
await adapter._broadcast_or_log(CHAT_ID, frame)
|
||||||
|
return adapter._outbox.latest_cursor()
|
||||||
+473
-443
File diff suppressed because it is too large.
Load diff
@@ -1,435 +0,0 @@
|
|||||||
"""WebSocket server, connection registry, and frame routing.
|
|
||||||
|
|
||||||
Runs on the gateway's asyncio loop (started in ``AndroidAdapter.connect()``).
|
|
||||||
Uses the ``websockets`` core dep (v15): ``websockets.asyncio.server.serve(
|
|
||||||
handler, host, port, ssl=ctx)``.
|
|
||||||
|
|
||||||
Per-connection handler:
|
|
||||||
1. Await first frame (bounded); must be ``hello {token, device_id,
|
|
||||||
device_name, caps, fcm_token?}``. Verify token (constant-time) +
|
|
||||||
allowlist. On failure: send ``error {code:"auth"}`` and close.
|
|
||||||
2. On success: register in the device registry (SQLite) + connection
|
|
||||||
registry (``device_id -> {ws, caps, fcm_token}``), send
|
|
||||||
``hello.ack {server_caps, sync_cursor, channels[]}``.
|
|
||||||
3. Loop: decode frames, dispatch to adapter inbound handlers. Inbound JSON
|
|
||||||
frames are rate-limited per connection (token bucket, ``INBOUND_RATE_PER_S``
|
|
||||||
/ ``INBOUND_BURST``); binary media-upload chunks are exempt.
|
|
||||||
4. On close: deregister.
|
|
||||||
|
|
||||||
Routing: ``broadcast(frame)`` sends to ALL connected devices (single-user
|
|
||||||
model). Heartbeat via WS ping/pong (websockets built-in) + app-level
|
|
||||||
``ping``/``pong`` frames.
|
|
||||||
|
|
||||||
Milestone M1.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import contextlib
|
|
||||||
import logging
|
|
||||||
import ssl
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from websockets.asyncio.server import ServerConnection, serve
|
|
||||||
from websockets.exceptions import ConnectionClosed
|
|
||||||
|
|
||||||
from . import protocol
|
|
||||||
from .pairing import DeviceRegistry, verify_token
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# How long a new socket may take to present its ``hello`` before we drop it.
|
|
||||||
HELLO_TIMEOUT_S = 10.0
|
|
||||||
|
|
||||||
# Max time a single outbound send may block on a peer's full write buffer
|
|
||||||
# before we give up on that peer (so one stalled client can't starve the
|
|
||||||
# rest of the broadcast). The peer's own ping timeout reaps it afterwards.
|
|
||||||
SEND_TIMEOUT_S = 10.0
|
|
||||||
|
|
||||||
# Inbound JSON control-frame rate limit (per connection, token bucket).
|
|
||||||
# A legitimate app sends pings + occasional user-initiated requests — far
|
|
||||||
# below 20/s sustained. Binary media-upload chunks are EXEMPT (see
|
|
||||||
# ``_on_frame``): a 100 MB upload is 400 x 256 KiB frames in a tight loop
|
|
||||||
# and would exhaust any sane bucket; uploads are bounded instead by the
|
|
||||||
# per-frame ``max_size`` and the per-upload total cap (``media.py``).
|
|
||||||
INBOUND_RATE_PER_S = 20.0
|
|
||||||
INBOUND_BURST = 40
|
|
||||||
|
|
||||||
# Close codes (4000-4999 are reserved for applications).
|
|
||||||
CLOSE_AUTH_FAILED = 4401
|
|
||||||
CLOSE_REPLACED = 4402
|
|
||||||
CLOSE_RATE_LIMITED = 4403
|
|
||||||
CLOSE_SHUTDOWN = 1001
|
|
||||||
|
|
||||||
# Max length of a client-supplied device_id.
|
|
||||||
MAX_DEVICE_ID_LEN = 128
|
|
||||||
|
|
||||||
|
|
||||||
class _TokenBucket:
|
|
||||||
"""Minimal token bucket (stdlib only). One instance per connection."""
|
|
||||||
|
|
||||||
__slots__ = ("rate", "burst", "tokens", "updated_at")
|
|
||||||
|
|
||||||
def __init__(self, rate: float, burst: int):
|
|
||||||
self.rate = rate
|
|
||||||
self.burst = burst
|
|
||||||
self.tokens = float(burst)
|
|
||||||
self.updated_at = time.monotonic()
|
|
||||||
|
|
||||||
def consume(self) -> bool:
|
|
||||||
"""Try to take one token. Refills at ``rate``/s up to ``burst``."""
|
|
||||||
now = time.monotonic()
|
|
||||||
elapsed = now - self.updated_at
|
|
||||||
if elapsed > 0:
|
|
||||||
self.tokens = min(self.burst, self.tokens + elapsed * self.rate)
|
|
||||||
self.updated_at = now
|
|
||||||
if self.tokens >= 1.0:
|
|
||||||
self.tokens -= 1.0
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class DeviceConnection:
|
|
||||||
"""One live, authenticated device socket."""
|
|
||||||
|
|
||||||
device_id: str
|
|
||||||
device_name: str
|
|
||||||
ws: ServerConnection
|
|
||||||
caps: dict[str, Any] = field(default_factory=dict)
|
|
||||||
fcm_token: str | None = None
|
|
||||||
ntfy_topic: str | None = None
|
|
||||||
connected_at: float = field(default_factory=time.time)
|
|
||||||
rate_bucket: _TokenBucket = field(
|
|
||||||
default_factory=lambda: _TokenBucket(INBOUND_RATE_PER_S, INBOUND_BURST)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class WsServer:
|
|
||||||
"""The plugin's WebSocket server + live connection registry."""
|
|
||||||
|
|
||||||
def __init__(self, adapter: Any, devices: DeviceRegistry):
|
|
||||||
self._adapter = adapter
|
|
||||||
self._devices = devices
|
|
||||||
self._server: Any | None = None
|
|
||||||
self._connections: dict[str, DeviceConnection] = {}
|
|
||||||
self._lock = asyncio.Lock()
|
|
||||||
|
|
||||||
# ── Lifecycle ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def start(self) -> None:
|
|
||||||
"""Bind and start serving. Raises on bind failure (adapter maps it
|
|
||||||
to a retryable fatal error)."""
|
|
||||||
adapter = self._adapter
|
|
||||||
ssl_ctx: ssl.SSLContext | None = None
|
|
||||||
if adapter.ws_cert and adapter.ws_key:
|
|
||||||
try:
|
|
||||||
ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
|
|
||||||
ssl_ctx.load_cert_chain(adapter.ws_cert, adapter.ws_key)
|
|
||||||
except Exception as e:
|
|
||||||
adapter._set_fatal_error(
|
|
||||||
"tls_config", f"WS TLS cert/key invalid: {e}", retryable=False
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
try:
|
|
||||||
self._server = await serve(
|
|
||||||
self._handler,
|
|
||||||
adapter.host,
|
|
||||||
adapter.port,
|
|
||||||
ssl=ssl_ctx,
|
|
||||||
# Media uploads (M4) are chunked binary frames; allow the
|
|
||||||
# configured max upload size per frame.
|
|
||||||
max_size=adapter.max_upload_bytes,
|
|
||||||
# WS-level heartbeat: dead peers are reaped by websockets.
|
|
||||||
ping_interval=20,
|
|
||||||
ping_timeout=20,
|
|
||||||
open_timeout=10,
|
|
||||||
)
|
|
||||||
except OSError as e:
|
|
||||||
adapter._set_fatal_error(
|
|
||||||
"bind_failed",
|
|
||||||
f"WS bind on {adapter.host}:{adapter.port} failed: {e}",
|
|
||||||
retryable=True,
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
scheme = "wss" if ssl_ctx else "ws"
|
|
||||||
logger.info(
|
|
||||||
"android: WS server listening on %s://%s:%s/ws",
|
|
||||||
scheme,
|
|
||||||
adapter.host,
|
|
||||||
adapter.port,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def stop(self) -> None:
|
|
||||||
"""Stop serving and close all device sockets."""
|
|
||||||
if self._server is not None:
|
|
||||||
self._server.close()
|
|
||||||
# Best-effort: the server is already closing; a failure here is
|
|
||||||
# not actionable (nothing left to clean up besides the registry).
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
await self._server.wait_closed()
|
|
||||||
self._server = None
|
|
||||||
for conn in list(self._connections.values()):
|
|
||||||
# Best-effort: a socket that is already gone needs no handling.
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
await conn.ws.close(code=CLOSE_SHUTDOWN, reason="gateway shutting down")
|
|
||||||
self._connections.clear()
|
|
||||||
|
|
||||||
# ── Registry ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@property
|
|
||||||
def connections(self) -> dict[str, DeviceConnection]:
|
|
||||||
return dict(self._connections)
|
|
||||||
|
|
||||||
def has_devices(self) -> bool:
|
|
||||||
return bool(self._connections)
|
|
||||||
|
|
||||||
def device_ids(self) -> list:
|
|
||||||
return list(self._connections.keys())
|
|
||||||
|
|
||||||
def connection(self, device_id: str) -> DeviceConnection | None:
|
|
||||||
return self._connections.get(device_id)
|
|
||||||
|
|
||||||
# ── Outbound ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def broadcast(self, frame: protocol.Frame) -> int:
|
|
||||||
"""Send a frame to every connected device. Returns devices reached.
|
|
||||||
Best-effort: a dead or stalled socket is skipped (deregistered on its
|
|
||||||
own close) so one slow peer can't starve the others."""
|
|
||||||
data = frame.to_json()
|
|
||||||
sent = 0
|
|
||||||
for conn in list(self._connections.values()):
|
|
||||||
# Best-effort: a dead or stalled socket is skipped (it is
|
|
||||||
# deregistered on its own close); one slow peer must not starve
|
|
||||||
# the rest of the broadcast.
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
await asyncio.wait_for(conn.ws.send(data), timeout=SEND_TIMEOUT_S)
|
|
||||||
sent += 1
|
|
||||||
return sent
|
|
||||||
|
|
||||||
async def send_to(self, device_id: str, frame: protocol.Frame) -> bool:
|
|
||||||
"""Send a frame to one device (request responses / errors)."""
|
|
||||||
conn = self._connections.get(device_id)
|
|
||||||
if conn is None:
|
|
||||||
return False
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(conn.ws.send(frame.to_json()), timeout=SEND_TIMEOUT_S)
|
|
||||||
return True
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# ── Per-connection handler ────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def _handler(self, ws: ServerConnection) -> None:
|
|
||||||
# 1. hello auth -----------------------------------------------------
|
|
||||||
try:
|
|
||||||
raw = await asyncio.wait_for(ws.recv(), timeout=HELLO_TIMEOUT_S)
|
|
||||||
except (asyncio.TimeoutError, ConnectionClosed) as e:
|
|
||||||
if isinstance(e, asyncio.TimeoutError):
|
|
||||||
logger.warning("android: dropping socket with no hello (timeout)")
|
|
||||||
await self._close_quiet(ws, 1000, "no hello")
|
|
||||||
# A peer that vanished before hello needs no further handling.
|
|
||||||
return
|
|
||||||
|
|
||||||
frame = protocol.Frame.from_json(raw)
|
|
||||||
if frame is None or frame.type != protocol.TYPE_HELLO:
|
|
||||||
await self._reject(ws, "first frame must be hello")
|
|
||||||
return
|
|
||||||
|
|
||||||
payload = frame.payload
|
|
||||||
if not verify_token(payload.get("token"), self._adapter.token):
|
|
||||||
peer = getattr(ws, "remote_address", None)
|
|
||||||
logger.warning("android: hello rejected: invalid token (peer=%s)", peer)
|
|
||||||
await self._reject(ws, "invalid token")
|
|
||||||
return
|
|
||||||
|
|
||||||
device_id = str(payload.get("device_id") or "").strip()
|
|
||||||
if not device_id or len(device_id) > MAX_DEVICE_ID_LEN:
|
|
||||||
await self._reject(ws, "device_id required")
|
|
||||||
return
|
|
||||||
|
|
||||||
if (
|
|
||||||
not self._adapter.allow_all
|
|
||||||
and self._adapter.allowed_users
|
|
||||||
and device_id not in self._adapter.allowed_users
|
|
||||||
):
|
|
||||||
logger.warning("android: hello rejected: device %s not allowlisted", device_id)
|
|
||||||
await self._reject(ws, "device not allowed")
|
|
||||||
return
|
|
||||||
|
|
||||||
device_name = str(payload.get("device_name") or device_id)[:120]
|
|
||||||
caps = payload.get("caps")
|
|
||||||
if not isinstance(caps, dict):
|
|
||||||
caps = {}
|
|
||||||
fcm_token = payload.get("fcm_token")
|
|
||||||
if not isinstance(fcm_token, str):
|
|
||||||
fcm_token = None
|
|
||||||
ntfy_topic = payload.get("ntfy_topic")
|
|
||||||
if not isinstance(ntfy_topic, str):
|
|
||||||
ntfy_topic = None
|
|
||||||
|
|
||||||
# 2. register --------------------------------------------------------
|
|
||||||
try:
|
|
||||||
self._devices.upsert(device_id, device_name, caps, fcm_token, ntfy_topic)
|
|
||||||
except Exception:
|
|
||||||
logger.warning("android: device registry upsert failed", exc_info=True)
|
|
||||||
|
|
||||||
conn = DeviceConnection(
|
|
||||||
device_id=device_id,
|
|
||||||
device_name=device_name,
|
|
||||||
ws=ws,
|
|
||||||
caps=caps,
|
|
||||||
fcm_token=fcm_token,
|
|
||||||
ntfy_topic=ntfy_topic,
|
|
||||||
)
|
|
||||||
async with self._lock:
|
|
||||||
old = self._connections.pop(device_id, None)
|
|
||||||
self._connections[device_id] = conn
|
|
||||||
if old is not None:
|
|
||||||
# Same device re-paired from a new socket: the new one wins.
|
|
||||||
# Best-effort close of the superseded socket.
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
await old.ws.close(code=CLOSE_REPLACED, reason="replaced by newer connection")
|
|
||||||
|
|
||||||
ack = protocol.hello_ack(
|
|
||||||
server_caps=self._adapter.server_caps(),
|
|
||||||
sync_cursor=self._adapter._outbox.latest_cursor(),
|
|
||||||
channels=self._adapter.channel_list(),
|
|
||||||
# M5: lets the app dedupe sync-replayed notifications that
|
|
||||||
# already woke this device via push (docs/08 §8.7).
|
|
||||||
last_pushed_cursor=self._adapter._devices.last_pushed_cursor(device_id),
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await ws.send(ack.to_json())
|
|
||||||
# M7: tell late-joining clients the current gateway health state
|
|
||||||
# (the startup broadcast only reaches clients already connected).
|
|
||||||
await ws.send(protocol.status(self._adapter.gateway_status()).to_json())
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
logger.info("android: device paired: %s (%s)", device_name, device_id)
|
|
||||||
|
|
||||||
# 3. frame loop ------------------------------------------------------
|
|
||||||
try:
|
|
||||||
async for raw in ws:
|
|
||||||
# ``_on_frame`` returns False once it has closed the socket
|
|
||||||
# (rate limit); stop draining the buffered frames so a
|
|
||||||
# flood doesn't re-trigger the error+close per frame.
|
|
||||||
if not await self._on_frame(ws, device_id, raw):
|
|
||||||
break
|
|
||||||
except Exception as e:
|
|
||||||
# A clean disconnect (ConnectionClosed) is the normal path and is
|
|
||||||
# not worth a warning; anything else is unexpected.
|
|
||||||
if not isinstance(e, ConnectionClosed):
|
|
||||||
logger.warning("android: frame loop error for %s", device_id, exc_info=True)
|
|
||||||
finally:
|
|
||||||
async with self._lock:
|
|
||||||
current = self._connections.get(device_id)
|
|
||||||
if current is not None and current.ws is ws:
|
|
||||||
self._connections.pop(device_id, None)
|
|
||||||
# M4: drop in-flight upload temp files for this socket.
|
|
||||||
try:
|
|
||||||
self._adapter.on_connection_closed(device_id)
|
|
||||||
except Exception:
|
|
||||||
logger.warning(
|
|
||||||
"android: connection cleanup failed for %s", device_id, exc_info=True
|
|
||||||
)
|
|
||||||
logger.info("android: device disconnected: %s", device_id)
|
|
||||||
|
|
||||||
# ── Inbound dispatch ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async def _on_frame(self, ws: ServerConnection, device_id: str, raw: Any) -> bool:
|
|
||||||
"""Dispatch one inbound frame. Returns False once the socket has been
|
|
||||||
closed (rate limit) so the caller stops draining buffered frames."""
|
|
||||||
# M4: binary frames are media upload chunks (raw bytes, no JSON
|
|
||||||
# envelope). Route them to the active upload session. They are
|
|
||||||
# EXEMPT from the inbound rate limit: a 100 MB upload is 400 x
|
|
||||||
# 256 KiB frames in a tight loop, which would exhaust any sane
|
|
||||||
# frame bucket. Uploads are bounded instead by the per-frame
|
|
||||||
# ``max_size`` and the per-upload total cap (``media.py``).
|
|
||||||
if isinstance(raw, (bytes, bytearray, memoryview)):
|
|
||||||
await self._adapter.on_media_chunk(device_id, bytes(raw))
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Inbound rate limit (JSON control frames only). On exceed: error +
|
|
||||||
# close, same pattern as auth rejection.
|
|
||||||
conn = self._connection_for(ws)
|
|
||||||
if conn is not None and not conn.rate_bucket.consume():
|
|
||||||
logger.warning("android: inbound rate limit exceeded for %s; closing", device_id)
|
|
||||||
await self._send_quiet(
|
|
||||||
ws,
|
|
||||||
protocol.error(protocol.ERR_RATE_LIMITED, "inbound frame rate limit exceeded"),
|
|
||||||
)
|
|
||||||
await self._close_quiet(ws, CLOSE_RATE_LIMITED, "rate limited")
|
|
||||||
return False
|
|
||||||
|
|
||||||
frame = protocol.Frame.from_json(raw)
|
|
||||||
if frame is None:
|
|
||||||
return True # malformed JSON: ignore (forward-compat)
|
|
||||||
|
|
||||||
if frame.type == protocol.TYPE_PING:
|
|
||||||
ts = frame.payload.get("ts")
|
|
||||||
await self._send_quiet(ws, protocol.pong(ts if isinstance(ts, int) else None))
|
|
||||||
elif frame.type == protocol.TYPE_MESSAGE_SEND:
|
|
||||||
await self._adapter.on_message_send(frame, device_id)
|
|
||||||
elif frame.type == protocol.TYPE_CHANNEL_CREATE:
|
|
||||||
await self._adapter.on_channel_create(frame, device_id)
|
|
||||||
elif frame.type == protocol.TYPE_CHANNEL_RENAME:
|
|
||||||
await self._adapter.on_channel_rename(frame, device_id)
|
|
||||||
elif frame.type == protocol.TYPE_CHANNEL_SET_DEFAULT:
|
|
||||||
await self._adapter.on_channel_set_default(frame, device_id)
|
|
||||||
elif frame.type == protocol.TYPE_CHANNEL_FAVORITE:
|
|
||||||
await self._adapter.on_channel_favorite(frame, device_id)
|
|
||||||
elif frame.type == protocol.TYPE_CHANNEL_ICON:
|
|
||||||
await self._adapter.on_channel_icon(frame, device_id)
|
|
||||||
elif frame.type == protocol.TYPE_CHANNEL_SET_AUTOMATION:
|
|
||||||
await self._adapter.on_channel_set_automation(frame, device_id)
|
|
||||||
elif frame.type == protocol.TYPE_CHANNEL_DELETE:
|
|
||||||
await self._adapter.on_channel_delete(frame, device_id)
|
|
||||||
elif frame.type == protocol.TYPE_CHANNEL_LIST:
|
|
||||||
await self._adapter.on_channel_list(frame, device_id)
|
|
||||||
elif frame.type == protocol.TYPE_COMMANDS_CATALOG:
|
|
||||||
await self._adapter.on_commands_catalog(frame, device_id)
|
|
||||||
elif frame.type == protocol.TYPE_SEARCH:
|
|
||||||
await self._adapter.on_search(frame, device_id)
|
|
||||||
elif frame.type == protocol.TYPE_SYNC:
|
|
||||||
await self._adapter.on_sync(frame, device_id)
|
|
||||||
elif frame.type == protocol.TYPE_HISTORY:
|
|
||||||
await self._adapter.on_history(frame, device_id)
|
|
||||||
elif frame.type == protocol.TYPE_MESSAGE_DELETE:
|
|
||||||
await self._adapter.on_message_delete(frame, device_id)
|
|
||||||
elif frame.type == protocol.TYPE_MEDIA_UPLOAD_START:
|
|
||||||
await self._adapter.on_media_upload_start(frame, device_id)
|
|
||||||
elif frame.type == protocol.TYPE_MEDIA_UPLOAD_END:
|
|
||||||
await self._adapter.on_media_upload_end(frame, device_id)
|
|
||||||
elif frame.type == protocol.TYPE_MEDIA_PULL:
|
|
||||||
await self._adapter.on_media_pull(frame, device_id)
|
|
||||||
elif frame.type == protocol.TYPE_FCM_REGISTER:
|
|
||||||
await self._adapter.on_fcm_register(frame, device_id)
|
|
||||||
# Unknown types are ignored (forward-compat).
|
|
||||||
return True
|
|
||||||
|
|
||||||
# ── Helpers ───────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
def _connection_for(self, ws: ServerConnection) -> DeviceConnection | None:
|
|
||||||
"""The live registry entry for this exact socket (identity match, so
|
|
||||||
a replaced socket never consumes the new connection's bucket)."""
|
|
||||||
for conn in self._connections.values():
|
|
||||||
if conn.ws is ws:
|
|
||||||
return conn
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def _send_quiet(self, ws: ServerConnection, frame: protocol.Frame) -> None:
|
|
||||||
# "Quiet" by contract: the caller does not care whether the peer was
|
|
||||||
# still there (e.g. an error frame right before the close).
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
await ws.send(frame.to_json())
|
|
||||||
|
|
||||||
async def _reject(self, ws: ServerConnection, reason: str) -> None:
|
|
||||||
await self._send_quiet(ws, protocol.error(protocol.ERR_AUTH, reason))
|
|
||||||
await self._close_quiet(ws, CLOSE_AUTH_FAILED, "auth failed")
|
|
||||||
|
|
||||||
async def _close_quiet(self, ws: ServerConnection, code: int, reason: str) -> None:
|
|
||||||
# "Quiet" by contract: closing an already-closed socket is a no-op.
|
|
||||||
with contextlib.suppress(Exception):
|
|
||||||
await ws.close(code=code, reason=reason)
|
|
||||||
Reference in new issue
Block a user