Files
iris_x_hermes/app/shared/src/commonMain/kotlin/iris/IrisApp.kt
T
ARIA 863ab34915
CI / Gateway plugin tests (push) Failing after 6m35s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m57s
fix(app): apply whole-review fixes (HIGH/MEDIUM/LOW) + dead code & stale comments
HIGH:
- ntfy listener: replace blocking exhausted() loop with SSE read + capped
  exponential-backoff reconnect; 60s read timeout
- GatewayClient.stop(): reset HTTP leg (http, httpCursor, sseFailures,
  usingLongPoll, lastAck)
- non-atomic shared state -> synchronized/@Volatile/AtomicLong/
  CopyOnWriteArrayList

MEDIUM:
- mediaId path-traversal guard (isValidMediaId) at network/app/fs boundaries
- loadFromCache: move ts=0 pending bubbles to end, keep stored order
- attachment placeholder tracked by identity, not filename
- optimistic ChannelStore updates on favorite/icon/automation/default
- secure-store caching (desktop map, android store)
- secret passed to keyring via stdin (macOS + Linux)
- SecureStore.clear() clears deviceId/syncCursor/fcmToken/ntfy*
- random ids for system messages; SSE EOF reconnect delay
- PowerShell $ escaping; dispose() cancels job before saving flows
- wire up "Forget pairing" in Settings
- move machine-specific org.gradle.java.home to user-level gradle.properties

LOW + dead code + stale comments:
- .aac->audio/aac; locale-fixed cost/size; 3-digit hex; hour+ latency
- Backdrop.DEFAULT defined once; notification id 24-bit; channel id cap
- remove dead FileSource, unused protocol/theme/media constants, empty
  onDispose, SDK_INT<O guard, hostFromUrl
- fix stale WS/SSE, M1/M5, and milestone KDoc comments

Verified: Kotlin desktop+android host tests, 100/100 Python gateway tests,
LSP clean, installed & running on device.
2026-08-23 12:57:35 +02:00

139 lines
5.7 KiB
Kotlin

package iris
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Density
import iris.data.SecureStore
import iris.net.GatewayClient
import iris.platform.setActiveController
import iris.state.IrisController
import iris.ui.ArtifactPreviewOverlay
import iris.ui.screens.ChatScreen
import iris.ui.screens.ConnectScreen
import iris.ui.theme.IrisColors
import iris.ui.theme.IrisTheme
import iris.ui.theme.LocalUserTheme
import iris.ui.theme.rememberBackgroundImage
import iris.util.PairLink
/**
* Root composable shared by the Android and Desktop shells.
*
* Routes between the Connect screen (unpaired / auth failed) and the main
* app (paired), which hosts the channel list, chat, search, settings, and
* media (docs/10-android-app.md).
*/
@Composable
fun IrisApp(
store: SecureStore,
deepLinkChatId: String? = null,
deepLinkThreadId: String? = null,
deepLinkPair: PairLink? = null,
) {
val controller = remember(store) { IrisController(store) }
DisposableEffect(controller) {
setActiveController(controller)
onDispose {
setActiveController(null)
controller.dispose()
}
}
// M5: open the chat referenced by a push notification tap (applied now or
// on the next hello.ack, whichever is later).
LaunchedEffect(deepLinkChatId, deepLinkThreadId) {
if (!deepLinkChatId.isNullOrBlank()) {
controller.openDeepLink(deepLinkChatId, deepLinkThreadId)
}
}
val state by controller.client.state.collectAsState()
val theme by controller.theme.collectAsState()
val fontScale by controller.fontSizeScale.collectAsState()
val baseDensity = LocalDensity.current
IrisTheme {
// Font-size setting (Settings → Appearance): a multiplier on top of the
// system font scale. fontScale affects sp text only — dp layout is
// untouched, so the UI keeps its proportions at any size.
CompositionLocalProvider(
LocalUserTheme provides theme,
LocalDensity provides
Density(
density = baseDensity.density,
fontScale = baseDensity.fontScale * fontScale,
),
) {
// The color goes through Surface's `color` parameter: a
// Modifier.background on the Surface would be painted *under* the
// Surface's own colorScheme.surface fill and never show through.
// contentColor is pinned to the app's light text color — Surface
// would otherwise derive it from the user's background (black
// text on a light background), which would also darken text that
// sits on the app's own dark panels.
Surface(
modifier = Modifier.fillMaxSize(),
color = theme.background,
contentColor = IrisColors.onBackground,
) {
val bgImage = rememberBackgroundImage(theme)
Box(modifier = Modifier.fillMaxSize()) {
val bmp = bgImage
if (bmp != null) {
Image(
bitmap = bmp,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
val s = state
// QR pairing (docs/20): an iris://pair deep link prefills
// the Connect screen, taking priority over stored creds.
val pairUrl = deepLinkPair?.url ?: store.serverUrl
val pairToken = deepLinkPair?.token ?: store.token
when (s) {
GatewayClient.State.Disconnected -> {
key(deepLinkPair) {
ConnectScreen(controller, prefillUrl = pairUrl, prefillToken = pairToken)
}
}
is GatewayClient.State.AuthFailed -> {
key(deepLinkPair) {
ConnectScreen(
controller,
prefillUrl = pairUrl,
prefillToken = pairToken,
initialError = "Pairing rejected: ${s.message}",
)
}
}
// Connecting / Reconnecting / Connected all render the chat; the header
// status bubble + connection banner show the link state
// without blocking the view (M7's full-screen spinner is gone).
else -> {
ChatScreen(controller)
}
}
// M9: full-screen HTML artifact preview (opened from an
// artifact card in the chat); covers everything while open.
ArtifactPreviewOverlay()
}
}
}
}
}