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.
139 lines
5.7 KiB
Kotlin
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()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|