From b9d5f803b25062275287c4b0ffc7a75b9efe059f Mon Sep 17 00:00:00 2001 From: weishu Date: Tue, 18 Aug 2026 16:27:32 +0800 Subject: [PATCH] fix(android): device-feedback round 1 - decode fs.stat-derived epoch fields leniently (fractional mtimeMs from real hubs broke machines/files decode and pinned the offline banner) - offline banner: only a failed sessions fetch counts; machines/baseline failures are advisory; live SSE emission clears it and seeds the unread baseline (all-rows-unread gray dot cascade) - session row: single weighted title (short names no longer truncated at half width), unread dot moved beside the timestamp - composer restyle: borderless input pill with inline mic, hand-drawn vector glyphs replacing emoji icons, 42dp round actions, park-draft relocated to the session overflow menu --- .../hapi/companion/feature/chat/ChatScreen.kt | 19 +- .../feature/chat/composer/ChatComposer.kt | 184 +++++++++--------- .../feature/chat/composer/ComposerGlyphs.kt | 57 ++++++ .../feature/sessions/SessionListScreen.kt | 9 +- .../feature/sessions/SessionListViewModel.kt | 39 +++- .../src/main/res/values-zh-rCN/strings.xml | 6 + android/app/src/main/res/values/strings.xml | 6 + .../app/hapi/protocol/wire/ApiResponses.kt | 3 +- .../kotlin/app/hapi/protocol/wire/FilesApi.kt | 9 +- .../app/hapi/protocol/wire/LenientEpochMs.kt | 30 +++ .../kotlin/app/hapi/protocol/wire/Machine.kt | 2 + .../hapi/protocol/wire/LenientEpochMsTest.kt | 69 +++++++ 12 files changed, 330 insertions(+), 103 deletions(-) create mode 100644 android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/ComposerGlyphs.kt create mode 100644 android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/LenientEpochMs.kt create mode 100644 android/core/protocol/src/test/kotlin/app/hapi/protocol/wire/LenientEpochMsTest.kt diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatScreen.kt index fcf0569d..ef144d76 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatScreen.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/ChatScreen.kt @@ -330,6 +330,13 @@ fun ChatScreen( onRename = { renameDialogOpen = true }, onReopen = viewModel::reopenSession, onDelete = { deleteDialogOpen = true }, + // Draft-level action, relocated from the composer's + // own overflow (one less button in the input bar). + onParkDraft = if (viewModel.scratchlistEnabled && composerState.text.isNotBlank()) { + viewModel::parkComposerDraft + } else { + null + }, ) }, ) @@ -361,7 +368,6 @@ fun ChatScreen( dictation = if (dictation != null) dictationState else null, onDictationToggle = onDictationToggle, onDictationCancel = { dictation?.cancel() }, - onParkDraft = if (viewModel.scratchlistEnabled) viewModel::parkComposerDraft else null, ) } }, @@ -462,6 +468,8 @@ private fun SessionOverflowMenu( onRename: () -> Unit, onReopen: () -> Unit, onDelete: () -> Unit, + /** null ⇒ hidden (scratchlist off or empty composer). */ + onParkDraft: (() -> Unit)? = null, ) { var open by remember { mutableStateOf(false) } IconButton(onClick = { open = true }) { @@ -475,6 +483,15 @@ private fun SessionOverflowMenu( onRename() }, ) + if (onParkDraft != null) { + DropdownMenuItem( + text = { Text(stringResource(R.string.chat_park_draft)) }, + onClick = { + open = false + onParkDraft() + }, + ) + } if (!active) { DropdownMenuItem( text = { Text(stringResource(R.string.sessions_action_reopen)) }, diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/ChatComposer.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/ChatComposer.kt index 2cdcc168..72259c15 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/ChatComposer.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/ChatComposer.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape @@ -22,8 +23,8 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -37,6 +38,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.stringResource @@ -84,8 +86,6 @@ fun ChatComposer( dictation: DictationState? = null, onDictationToggle: () -> Unit = {}, onDictationCancel: () -> Unit = {}, - /** null ⇒ composer overflow hidden; set = "Park draft to scratchlist" (B-M4d). */ - onParkDraft: (() -> Unit)? = null, ) { Surface(color = MaterialTheme.colorScheme.surface, modifier = modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) { @@ -120,30 +120,63 @@ fun ChatComposer( modifier = Modifier.padding(bottom = 6.dp), ) } - Row(verticalAlignment = Alignment.Bottom) { + Row( + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { if (onAddAttachment != null) { - AddAttachmentButton(onClick = onAddAttachment) - } - if (onParkDraft != null) { - ComposerOverflowButton( - hasText = state.text.isNotBlank(), - onParkDraft = onParkDraft, + ComposerRoundButton( + glyph = PlusGlyph, + contentDescription = stringResource(R.string.chat_composer_add_attachment), + onClick = onAddAttachment, ) } - OutlinedTextField( - value = state.text, - onValueChange = onTextChange, - placeholder = { Text(stringResource(R.string.chat_composer_placeholder)) }, - minLines = 1, - maxLines = 6, - shape = RoundedCornerShape(20.dp), + // The input pill: borderless multiline field with the mic + // inline at its trailing edge (chat-bar idiom, not a form + // field — OutlinedTextField's label chrome read as broken). + Surface( + color = MaterialTheme.colorScheme.surfaceContainerHigh, + shape = RoundedCornerShape(22.dp), modifier = Modifier.weight(1f), - ) - if (dictation != null) { - MicButton(state = dictation, onToggle = onDictationToggle) + ) { + Row(verticalAlignment = Alignment.Bottom) { + BasicTextField( + value = state.text, + onValueChange = onTextChange, + textStyle = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface, + ), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + maxLines = 6, + modifier = Modifier + .weight(1f) + .padding(start = 14.dp, end = 4.dp, top = 11.dp, bottom = 11.dp), + decorationBox = { inner -> + Box { + if (state.text.isEmpty()) { + Text( + text = stringResource(R.string.chat_composer_placeholder), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.hapi.hint, + ) + } + inner() + } + }, + ) + if (dictation != null) { + MicButton(state = dictation, onToggle = onDictationToggle) + } + } } if (state.canSteer) { - AbortButton(onAbort) + ComposerRoundButton( + glyph = StopGlyph, + contentDescription = stringResource(R.string.chat_composer_abort), + onClick = onAbort, + color = MaterialTheme.colorScheme.errorContainer, + contentColor = MaterialTheme.colorScheme.onErrorContainer, + ) } SendButton(state = state, attachments = attachments, onSend = onSend, onSendSteer = onSendSteer) } @@ -254,20 +287,27 @@ internal fun formatChipSize(bytes: Long): String = when { else -> "$bytes B" } -/** The "+" button opening the attachment picker sheet. */ +/** Shared 42 dp round action button (glyph icon, tinted via contentColor). */ @Composable -private fun AddAttachmentButton(onClick: () -> Unit) { +private fun ComposerRoundButton( + glyph: androidx.compose.ui.graphics.vector.ImageVector, + contentDescription: String?, + onClick: () -> Unit, + modifier: Modifier = Modifier, + color: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurfaceVariant, + enabled: Boolean = true, +) { Surface( - color = MaterialTheme.colorScheme.surfaceContainerHigh, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + color = color, + contentColor = contentColor, shape = CircleShape, + enabled = enabled, onClick = onClick, - modifier = Modifier - .padding(end = 6.dp, bottom = 4.dp) - .size(44.dp), + modifier = modifier.size(42.dp), ) { Box(contentAlignment = Alignment.Center) { - Text(text = "+", fontSize = 22.sp) + Icon(glyph, contentDescription = contentDescription, modifier = Modifier.size(20.dp)) } } } @@ -379,7 +419,7 @@ private fun MicButton(state: DictationState, onToggle: () -> Unit) { color = if (recording) { MaterialTheme.colorScheme.errorContainer } else { - MaterialTheme.colorScheme.surfaceContainerHigh + androidx.compose.ui.graphics.Color.Transparent }, contentColor = if (recording) { MaterialTheme.colorScheme.onErrorContainer @@ -390,18 +430,26 @@ private fun MicButton(state: DictationState, onToggle: () -> Unit) { enabled = !busy, onClick = onToggle, modifier = Modifier - .padding(start = 6.dp, bottom = 4.dp) - .size(44.dp), + .padding(end = 4.dp, bottom = 4.dp) + .size(38.dp), ) { Box(contentAlignment = Alignment.Center) { when { busy -> CircularProgressIndicator( - modifier = Modifier.size(18.dp), + modifier = Modifier.size(16.dp), strokeWidth = 2.dp, color = MaterialTheme.hapi.hint, ) - recording -> Text(text = "■", fontSize = 16.sp) - else -> Text(text = "🎙", fontSize = 17.sp) + recording -> Icon( + StopGlyph, + contentDescription = stringResource(R.string.chat_composer_stop_recording), + modifier = Modifier.size(18.dp), + ) + else -> Icon( + MicGlyph, + contentDescription = stringResource(R.string.chat_composer_mic), + modifier = Modifier.size(19.dp), + ) } } } @@ -409,60 +457,6 @@ private fun MicButton(state: DictationState, onToggle: () -> Unit) { // ---------------------------------------------------------------- actions -- -/** - * Composer overflow (B-M4d): draft-level actions that are not sends. Only - * "Park draft to scratchlist" for now — it moves the current text into the - * session's scratchlist and clears the composer. - */ -@Composable -private fun ComposerOverflowButton( - hasText: Boolean, - onParkDraft: () -> Unit, -) { - var open by remember { mutableStateOf(false) } - Surface( - color = MaterialTheme.colorScheme.surfaceContainerHigh, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant, - shape = CircleShape, - onClick = { open = true }, - modifier = Modifier - .padding(end = 6.dp, bottom = 4.dp) - .size(44.dp), - ) { - Box(contentAlignment = Alignment.Center) { - Text(text = "⋯", fontSize = 18.sp) - } - } - DropdownMenu(expanded = open, onDismissRequest = { open = false }) { - DropdownMenuItem( - text = { Text(stringResource(R.string.chat_park_draft)) }, - enabled = hasText, - onClick = { - open = false - onParkDraft() - }, - ) - } -} - -@Composable -private fun AbortButton(onAbort: () -> Unit) { - Surface( - color = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer, - shape = CircleShape, - onClick = onAbort, - modifier = Modifier - .padding(start = 6.dp, bottom = 4.dp) - .size(44.dp), - ) { - Box(contentAlignment = Alignment.Center) { - // Stop square. - Text(text = "■", fontSize = 16.sp) - } - } -} - @OptIn(ExperimentalFoundationApi::class) @Composable private fun SendButton( @@ -482,8 +476,7 @@ private fun SendButton( Box( modifier = Modifier - .padding(start = 6.dp, bottom = 4.dp) - .size(44.dp) + .size(42.dp) .clip(CircleShape), ) { Surface( @@ -499,7 +492,7 @@ private fun SendButton( }, shape = CircleShape, modifier = Modifier - .size(44.dp) + .size(42.dp) .combinedClickable( enabled = enabled, onClick = onSend, @@ -516,7 +509,11 @@ private fun SendButton( color = MaterialTheme.hapi.hint, ) } else { - Text(text = "➤", fontSize = 18.sp) + Icon( + ArrowUpGlyph, + contentDescription = stringResource(R.string.chat_composer_send), + modifier = Modifier.size(20.dp), + ) } } } @@ -553,7 +550,6 @@ private fun ChatComposerPreview() { ), onTextChange = {}, onSend = {}, onSendSteer = {}, onAbort = {}, dictation = DictationState.Idle, - onParkDraft = {}, ) ChatComposer( state = ComposerUiState(text = "Sending…", isSending = true, canSteer = false), diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/ComposerGlyphs.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/ComposerGlyphs.kt new file mode 100644 index 00000000..fe92ff2d --- /dev/null +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/chat/composer/ComposerGlyphs.kt @@ -0,0 +1,57 @@ +package app.hapi.companion.feature.chat.composer + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.addPathNodes +import androidx.compose.ui.unit.dp + +// Hand-drawn 24 dp stroke glyphs (the FolderGlyph precedent) — no +// material-icons-extended dependency, and no emoji-as-icon (device fonts +// render those inconsistently, which is exactly what made the first composer +// build look broken). + +private fun strokeIcon(name: String, pathData: String, strokeWidth: Float = 1.8f): ImageVector = + ImageVector.Builder( + name = name, + defaultWidth = 24.dp, + defaultHeight = 24.dp, + viewportWidth = 24f, + viewportHeight = 24f, + ).apply { + addPath( + pathData = addPathNodes(pathData), + fill = null, + // Any opaque stroke works: Icon() recolors via ColorFilter tint. + stroke = SolidColor(Color.Black), + strokeLineWidth = strokeWidth, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) + }.build() + +/** "+" — opens the attachment picker sheet. */ +internal val PlusGlyph: ImageVector by lazy { + strokeIcon("HapiPlus", "M12 5 L12 19 M5 12 L19 12", 2f) +} + +/** Microphone — dictation toggle. */ +internal val MicGlyph: ImageVector by lazy { + strokeIcon( + "HapiMic", + "M12 3 a3 3 0 0 1 3 3 v5 a3 3 0 0 1 -6 0 v-5 a3 3 0 0 1 3 -3 " + + "M6.5 11 a5.5 5.5 0 0 0 11 0 M12 16.5 L12 20.5 M9 20.5 L15 20.5", + ) +} + +/** Stop square — abort while a turn runs, and the recording-stop state. */ +internal val StopGlyph: ImageVector by lazy { + strokeIcon("HapiStop", "M8 8 h8 v8 h-8 z", 2f) +} + +/** Upward arrow — send. */ +internal val ArrowUpGlyph: ImageVector by lazy { + strokeIcon("HapiArrowUp", "M12 19 L12 5.5 M6.5 11 L12 5.5 L17.5 11", 2.2f) +} diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListScreen.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListScreen.kt index f3a48e0b..ffc39909 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListScreen.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListScreen.kt @@ -121,6 +121,7 @@ fun SessionListScreen( is SessionListError.RenameFailed -> R.string.sessions_error_rename is SessionListError.DeleteFailed -> R.string.sessions_error_delete is SessionListError.ReopenFailed -> R.string.sessions_error_reopen + is SessionListError.MachinesRefreshFailed -> R.string.sessions_error_machines }, ) // The 409-on-delete conflict gets explicit wording; other messages @@ -311,19 +312,23 @@ private fun SessionRow( Spacer(modifier = Modifier.width(10.dp)) Column(modifier = Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { + // One weighted element only: the title takes ALL leftover + // width (start-aligned, ellipsis on true overflow). Splitting + // the slack with a weighted trailing spacer truncated even + // short names at ~half the row (device-observed). Text( text = row.title, style = MaterialTheme.typography.bodyLarge, fontWeight = if (row.unread) FontWeight.SemiBold else FontWeight.Normal, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), + modifier = Modifier.weight(1f), ) if (row.unread) { Spacer(modifier = Modifier.width(6.dp)) UnreadDot() } - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.width(8.dp)) Text( text = localizedRelativeAge(summary.updatedAt), style = MaterialTheme.typography.labelSmall, diff --git a/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListViewModel.kt b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListViewModel.kt index 7522a923..6ce811c6 100644 --- a/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListViewModel.kt +++ b/android/app/src/main/kotlin/app/hapi/companion/feature/sessions/SessionListViewModel.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -97,6 +98,24 @@ class SessionListViewModel( private var refreshJob: Job? = null + init { + // Live SSE data is proof of connectivity: any list emission after a + // failed refresh clears the stale offline banner (a device-observed + // contradiction — active sessions updating under an "offline" banner). + scope.launch { + sessionStore.sessions.drop(1).collect { sessions -> + if (isOffline.value) isOffline.value = false + // Seed the unread baseline from SSE data too — when REST + // refresh fails but the stream works, unseeded watermarks + // would light every row's unread dot (once-per-scope inside + // the store, so repeated calls are no-ops). + if (sessions.isNotEmpty()) { + runCatching { lastSeenStore.initializeBaseline(hubKey, sessions) } + } + } + } + } + val uiState: StateFlow = combine( sessionStore.sessions, machineStore.machines, @@ -149,13 +168,24 @@ class SessionListViewModel( refreshJob = scope.launch { isRefreshing.value = true try { + // Only a failed *sessions* fetch means "offline". Machines and + // the unread baseline are secondary: their failures must not + // pin the offline banner over a perfectly live list (this + // exact cascade shipped once — a machines decode error kept + // the banner up while SSE streamed active sessions). sessionStore.refresh() - machineStore.refresh() isOffline.value = false hasRefreshedOnce.value = true // First successful list for this hub seeds the unread baseline // so historical sessions do not all light up as unread. - lastSeenStore.initializeBaseline(hubKey, sessionStore.sessions.value) + runCatching { lastSeenStore.initializeBaseline(hubKey, sessionStore.sessions.value) } + try { + machineStore.refresh() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + _errors.tryEmit(SessionListError.MachinesRefreshFailed(error.message)) + } } catch (cancellation: CancellationException) { throw cancellation } catch (_: Exception) { @@ -360,4 +390,9 @@ sealed interface SessionListError { val stillActive: Boolean = false, ) : SessionListError data class ReopenFailed(override val sessionId: String, override val message: String?) : SessionListError + + /** Machines list refresh failed — advisory only, never the offline banner. */ + data class MachinesRefreshFailed(override val message: String?) : SessionListError { + override val sessionId: String get() = "" + } } diff --git a/android/app/src/main/res/values-zh-rCN/strings.xml b/android/app/src/main/res/values-zh-rCN/strings.xml index 0ae2b475..3c12600a 100644 --- a/android/app/src/main/res/values-zh-rCN/strings.xml +++ b/android/app/src/main/res/values-zh-rCN/strings.xml @@ -221,6 +221,7 @@ 重命名失败 删除失败 重新打开失败 + 无法加载机器列表 会话仍在运行 — 请先归档 无法重新打开会话 重命名会话 @@ -277,6 +278,11 @@ ● 录音中… %1$s 取消 将草稿存入草稿夹 + 添加附件 + 开始语音输入 + 停止录音 + 发送 + 中止当前回合 发送并插入当前回合 1 条队列消息 %1$d 条队列消息 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 34e53edb..6fe819ac 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -214,6 +214,7 @@ Rename failed Delete failed Reopen failed + Could not load machines Session is still active — archive it first Failed to reopen session Rename session @@ -270,6 +271,11 @@ ● Recording… %1$s Cancel Park draft to scratchlist + Add attachment + Start dictation + Stop recording + Send + Stop the current turn Send & steer into current turn 1 queued message %1$d queued messages diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/ApiResponses.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/ApiResponses.kt index 5ea82b6c..0b078314 100644 --- a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/ApiResponses.kt +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/ApiResponses.kt @@ -207,7 +207,8 @@ data class MachineDirectoryEntry( /** `'file' | 'directory' | 'other'`. */ val type: String, val size: Long? = null, - /** Epoch ms. */ + /** Epoch ms (fs mtime — may arrive fractional; see [LenientEpochMs]). */ + @Serializable(with = LenientEpochMs::class) val modified: Long? = null, val isGitRepo: Boolean? = null, ) diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/FilesApi.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/FilesApi.kt index c85f8b65..f74e973e 100644 --- a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/FilesApi.kt +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/FilesApi.kt @@ -29,7 +29,8 @@ data class FileReadResponse( val content: String? = null, /** Bytes on disk. */ val size: Long? = null, - /** Epoch ms. */ + /** Epoch ms (fs mtime — may arrive fractional; see [LenientEpochMs]). */ + @Serializable(with = LenientEpochMs::class) val modified: Long? = null, val error: String? = null, ) @@ -51,7 +52,8 @@ data class FileSearchItem( /** `'file' | 'folder'` (search results are files in practice). */ val fileType: String, val size: Long? = null, - /** Epoch ms. */ + /** Epoch ms (fs mtime — may arrive fractional; see [LenientEpochMs]). */ + @Serializable(with = LenientEpochMs::class) val modified: Long? = null, ) @@ -73,6 +75,7 @@ data class DirectoryEntry( /** `'file' | 'directory' | 'other'`. */ val type: String, val size: Long? = null, - /** Epoch ms. */ + /** Epoch ms (fs mtime — may arrive fractional; see [LenientEpochMs]). */ + @Serializable(with = LenientEpochMs::class) val modified: Long? = null, ) diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/LenientEpochMs.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/LenientEpochMs.kt new file mode 100644 index 00000000..2db994ac --- /dev/null +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/LenientEpochMs.kt @@ -0,0 +1,30 @@ +package app.hapi.protocol.wire + +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * Epoch-milliseconds field that the hub may deliver as a **fractional** + * number: anything derived from `fs.stat` `mtimeMs` (file `modified`, + * machine `startedCliMtimeMs`/`installedCliMtimeMs`) carries sub-millisecond + * precision, e.g. `1786932205158.1177` — observed in real hub data. zod's + * `z.number()` accepts that; a plain Kotlin `Long` decode throws and (before + * this) took the whole machines/files response down with it. + * + * Decodes any JSON number and truncates to ms. Encoding writes a plain long + * (we never need to reproduce the fraction). + */ +object LenientEpochMs : KSerializer { + override val descriptor: SerialDescriptor = + PrimitiveSerialDescriptor("app.hapi.protocol.wire.LenientEpochMs", PrimitiveKind.DOUBLE) + + override fun deserialize(decoder: Decoder): Long = decoder.decodeDouble().toLong() + + override fun serialize(encoder: Encoder, value: Long) { + encoder.encodeLong(value) + } +} diff --git a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/Machine.kt b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/Machine.kt index 44a32d94..55f20ada 100644 --- a/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/Machine.kt +++ b/android/core/protocol/src/main/kotlin/app/hapi/protocol/wire/Machine.kt @@ -37,7 +37,9 @@ data class MachineMetadata( val workspaceRoots: List? = null, /** Machine-scoped RPC capability ids this runner registers. */ val capabilities: List? = null, + @Serializable(with = LenientEpochMs::class) val startedCliMtimeMs: Long? = null, + @Serializable(with = LenientEpochMs::class) val installedCliMtimeMs: Long? = null, val supervisedRestart: Boolean? = null, ) diff --git a/android/core/protocol/src/test/kotlin/app/hapi/protocol/wire/LenientEpochMsTest.kt b/android/core/protocol/src/test/kotlin/app/hapi/protocol/wire/LenientEpochMsTest.kt new file mode 100644 index 00000000..cea16822 --- /dev/null +++ b/android/core/protocol/src/test/kotlin/app/hapi/protocol/wire/LenientEpochMsTest.kt @@ -0,0 +1,69 @@ +package app.hapi.protocol.wire + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Regression: `fs.stat`-derived epoch fields arrive **fractional** from real + * hubs (`"startedCliMtimeMs":1786932205158.1177` observed in a live DB); a + * plain `Long` decode threw and took the whole machines/files response down — + * on device that pinned an "offline" banner over a perfectly live list. + */ +class LenientEpochMsTest { + @Test + fun `machine metadata decodes fractional cli mtimes`() { + val machine = HapiJson.decodeFromString( + Machine.serializer(), + """ + {"id":"m1","namespace":"default","seq":1,"createdAt":1,"updatedAt":2, + "active":true,"activeAt":3,"metadataVersion":1,"runnerStateVersion":0, + "metadata":{"host":"h","platform":"linux","happyCliVersion":"0.28.0", + "startedCliMtimeMs":1786932205158.1177, + "installedCliMtimeMs":1786501585709.12}} + """.trimIndent(), + ) + assertEquals(1786932205158L, machine.metadata?.startedCliMtimeMs) + assertEquals(1786501585709L, machine.metadata?.installedCliMtimeMs) + } + + @Test + fun `integral mtimes still decode`() { + val machine = HapiJson.decodeFromString( + Machine.serializer(), + """ + {"id":"m1","namespace":"default","seq":1,"createdAt":1,"updatedAt":2, + "active":true,"activeAt":3,"metadataVersion":1,"runnerStateVersion":0, + "metadata":{"host":"h","platform":"linux","happyCliVersion":"0.28.0", + "startedCliMtimeMs":1786932205158}} + """.trimIndent(), + ) + assertEquals(1786932205158L, machine.metadata?.startedCliMtimeMs) + } + + @Test + fun `directory entries and file reads decode fractional modified`() { + val dir = HapiJson.decodeFromString( + DirectoryEntry.serializer(), + """{"name":"src","type":"directory","modified":1786932205158.9}""", + ) + assertEquals(1786932205158L, dir.modified) + + val machineDir = HapiJson.decodeFromString( + MachineDirectoryEntry.serializer(), + """{"name":"repo","type":"directory","modified":1786932205158.5,"isGitRepo":true}""", + ) + assertEquals(1786932205158L, machineDir.modified) + + val read = HapiJson.decodeFromString( + FileReadResponse.serializer(), + """{"success":true,"content":"aGk=","size":2,"modified":1786932205158.0001}""", + ) + assertEquals(1786932205158L, read.modified) + + val search = HapiJson.decodeFromString( + FileSearchItem.serializer(), + """{"fileName":"a.ts","filePath":"src","fullPath":"src/a.ts","fileType":"file","modified":1786932205159.75}""", + ) + assertEquals(1786932205159L, search.modified) + } +}