git.iko.soy/opencode-android
on master help sign in
243 files changed+8068−39400
since splitsnapshots
unifiedsplit
.direnv/bin/nix-direnv-reload−19 .direnv/flake-inputs/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-source−1 .direnv/flake-inputs/kfs4nfy1705ksbn0gv15ls921cc90h2z-source−1 .direnv/flake-inputs/y4nm07b4ag6b9l0db8himn2xr1hyxfwr-source−1 .direnv/flake-inputs/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-source−1 .direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa−1 .direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rc−2250 .github/workflows/build.yml+12−16 .github/workflows/release.yml+113 .gitignore+20 AGENTS.md−197 CLAUDE.md+40−1 README.md+126−64 app/build.gradle.kts+44−98 app/detekt-baseline.xml−34 app/proguard-rules.pro+13−44 app/src/androidTest/java/soy/iko/opencode/ApplicationBootstrapTest.kt−36 app/src/androidTest/java/soy/iko/opencode/data/repo/CrashLoggerTest.kt−67 app/src/androidTest/java/soy/iko/opencode/data/repo/DraftStoreTest.kt−51 app/src/main/AndroidManifest.xml+7−110 app/src/main/java/soy/iko/opencode/CoreViewModel.kt+66 app/src/main/java/soy/iko/opencode/MainActivity.kt+15−344 app/src/main/java/soy/iko/opencode/OpencodeApp.kt−136 app/src/main/java/soy/iko/opencode/core/HttpClient.kt+75 app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt+668 app/src/main/java/soy/iko/opencode/core/Protocol.kt+148 app/src/main/java/soy/iko/opencode/core/SseClient.kt+63 app/src/main/java/soy/iko/opencode/core/ViewModel.kt+77 app/src/main/java/soy/iko/opencode/data/model/Agent.kt−37 app/src/main/java/soy/iko/opencode/data/model/BusEvent.kt−109 app/src/main/java/soy/iko/opencode/data/model/Common.kt−41 app/src/main/java/soy/iko/opencode/data/model/Files.kt−44 app/src/main/java/soy/iko/opencode/data/model/Message.kt−69 app/src/main/java/soy/iko/opencode/data/model/Part.kt−117 app/src/main/java/soy/iko/opencode/data/model/Permission.kt−58 app/src/main/java/soy/iko/opencode/data/model/Project.kt−32 app/src/main/java/soy/iko/opencode/data/model/Prompt.kt−68 app/src/main/java/soy/iko/opencode/data/model/Provider.kt−84 app/src/main/java/soy/iko/opencode/data/model/Search.kt−90 app/src/main/java/soy/iko/opencode/data/model/ServerProfile.kt−27 app/src/main/java/soy/iko/opencode/data/model/Session.kt−68 app/src/main/java/soy/iko/opencode/data/model/Todo.kt−95 app/src/main/java/soy/iko/opencode/data/model/ToolState.kt−62 app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt−318 app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt−190 app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt−273 app/src/main/java/soy/iko/opencode/data/network/NsdDiscovery.kt−195 app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt−540 app/src/main/java/soy/iko/opencode/data/network/OpencodeJson.kt−37 app/src/main/java/soy/iko/opencode/data/repo/AttachmentDraftStore.kt−114 app/src/main/java/soy/iko/opencode/data/repo/BackupManager.kt−135 app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt−274 app/src/main/java/soy/iko/opencode/data/repo/DraftStore.kt−260 app/src/main/java/soy/iko/opencode/data/repo/ErrorKind.kt−78 app/src/main/java/soy/iko/opencode/data/repo/MessageCacheStore.kt−137 app/src/main/java/soy/iko/opencode/data/repo/OutboxStore.kt−168 app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt−251 app/src/main/java/soy/iko/opencode/data/repo/RecentSessionsStore.kt−64 app/src/main/java/soy/iko/opencode/data/repo/SessionPrefsStore.kt−64 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt−647 app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.kt−152 app/src/main/java/soy/iko/opencode/di/AppContainer.kt−1390 app/src/main/java/soy/iko/opencode/di/OpencodeConnection.kt−47 app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.kt−175 app/src/main/java/soy/iko/opencode/notification/NotificationChannels.kt−65 app/src/main/java/soy/iko/opencode/notification/RunForegroundService.kt−187 app/src/main/java/soy/iko/opencode/notification/SessionNotifications.kt−438 app/src/main/java/soy/iko/opencode/platform/AppShortcuts.kt−58 app/src/main/java/soy/iko/opencode/platform/NewSessionTileService.kt−33 app/src/main/java/soy/iko/opencode/platform/SessionsWidgetProvider.kt−83 app/src/main/java/soy/iko/opencode/platform/SessionsWidgetService.kt−68 app/src/main/java/soy/iko/opencode/ui/AppLockGate.kt−290 app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+91−434 app/src/main/java/soy/iko/opencode/ui/Routes.kt−30 app/src/main/java/soy/iko/opencode/ui/VmFactory.kt−16 app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.kt−275 app/src/main/java/soy/iko/opencode/ui/chat/AttachmentResult.kt−12 app/src/main/java/soy/iko/opencode/ui/chat/Attachments.kt−115 app/src/main/java/soy/iko/opencode/ui/chat/ChatAttachments.kt−133 app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt−2604 app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt−1596 app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.kt−223 app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.kt−203 app/src/main/java/soy/iko/opencode/ui/chat/CostFormat.kt−60 app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.kt−888 app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.kt−235 app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.kt−676 app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.kt−169 app/src/main/java/soy/iko/opencode/ui/chat/TodoPlan.kt−205 app/src/main/java/soy/iko/opencode/ui/chat/TtsController.kt−240 app/src/main/java/soy/iko/opencode/ui/components/AppTopBar.kt−84 app/src/main/java/soy/iko/opencode/ui/components/Autofill.kt−49 app/src/main/java/soy/iko/opencode/ui/components/ChatTextPrefs.kt−51 app/src/main/java/soy/iko/opencode/ui/components/CodeHighlighter.kt−357 app/src/main/java/soy/iko/opencode/ui/components/CommandPalette.kt−172 app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.kt−177 app/src/main/java/soy/iko/opencode/ui/components/DiffView.kt−276 app/src/main/java/soy/iko/opencode/ui/components/EmptyState.kt−68 app/src/main/java/soy/iko/opencode/ui/components/LargeScreenNavRail.kt−74 app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt−433 app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt+479 app/src/main/java/soy/iko/opencode/ui/components/PaletteAction.kt−11 app/src/main/java/soy/iko/opencode/ui/components/QrCode.kt−174 app/src/main/java/soy/iko/opencode/ui/components/ReducedMotion.kt−99 app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.kt−384 app/src/main/java/soy/iko/opencode/ui/components/SectionHeader.kt−25 app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.kt−200 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt−813 app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt−279 app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.kt−63 app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt−921 app/src/main/java/soy/iko/opencode/ui/file/TwoPaneFileBrowser.kt−92 app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.kt−350 app/src/main/java/soy/iko/opencode/ui/mcp/McpServerInfo.kt−68 app/src/main/java/soy/iko/opencode/ui/mcp/McpViewModel.kt−160 app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.kt+590 app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.kt+222 app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.kt+402 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.kt−252 app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.kt−213 app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt−554 app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt−439 app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.kt−704 app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.kt−198 app/src/main/java/soy/iko/opencode/ui/session/NewSessionDialog.kt−315 app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt−1466 app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt−988 app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.kt−297 app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt−596 app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt−942 app/src/main/java/soy/iko/opencode/ui/theme/Color.kt+19 app/src/main/java/soy/iko/opencode/ui/theme/Shapes.kt−19 app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+38−156 app/src/main/java/soy/iko/opencode/ui/theme/Type.kt+15 app/src/main/java/soy/iko/opencode/ui/theme/Typography.kt−133 app/src/main/java/soy/iko/opencode/ui/usage/UsageAggregation.kt−111 app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.kt−343 app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.kt−100 app/src/main/java/soy/iko/opencode/util/Coroutines.kt−44 app/src/main/res/drawable/ic_action_block.xml−10 app/src/main/res/drawable/ic_action_check.xml−9 app/src/main/res/drawable/ic_action_refresh.xml−9 app/src/main/res/drawable/ic_action_reply.xml−9 app/src/main/res/drawable/ic_action_stop.xml−9 app/src/main/res/drawable/ic_launcher_foreground.xml−13 app/src/main/res/drawable/ic_stat_notify.xml−9 app/src/main/res/drawable/widget_background.xml−6 app/src/main/res/layout/widget_session_item.xml−21 app/src/main/res/layout/widget_sessions.xml−64 app/src/main/res/mipmap-anydpi/ic_launcher.xml−6 app/src/main/res/values-es/strings.xml−704 app/src/main/res/values-es/strings_cross_ux.xml−7 app/src/main/res/values-es/strings_diag_ux.xml−4 app/src/main/res/values-es/strings_notif_ux.xml−5 app/src/main/res/values-es/strings_settings_ux.xml−12 app/src/main/res/values-night/colors.xml+1−5 app/src/main/res/values-night/themes.xml+2−2 app/src/main/res/values/colors.xml+1−5 app/src/main/res/values/strings.xml−700 app/src/main/res/values/strings_cross_ux.xml−7 app/src/main/res/values/strings_diag_ux.xml−4 app/src/main/res/values/strings_notif_ux.xml−8 app/src/main/res/values/strings_settings_ux.xml−12 app/src/main/res/values/themes.xml+7−16 app/src/main/res/xml/data_extraction_rules.xml−15 app/src/main/res/xml/file_paths.xml−9 app/src/main/res/xml/network_security_config.xml−15 app/src/main/res/xml/sessions_widget_info.xml−10 app/src/test/java/soy/iko/opencode/M5DecodingTest.kt−144 app/src/test/java/soy/iko/opencode/M6Test.kt−102 app/src/test/java/soy/iko/opencode/OpencodeJsonTest.kt−122 app/src/test/java/soy/iko/opencode/UxTest.kt−91 app/src/test/java/soy/iko/opencode/core/ConnectUrlTest.kt+69 app/src/test/java/soy/iko/opencode/data/model/TodoPlanTest.kt−90 app/src/test/java/soy/iko/opencode/data/network/EventStreamClientTest.kt−37 app/src/test/java/soy/iko/opencode/data/network/NormalizeBaseUrlTest.kt−42 app/src/test/java/soy/iko/opencode/data/network/OpencodeApiClientTest.kt−115 app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.kt−254 app/src/test/java/soy/iko/opencode/data/repo/CrashLoggerScrubTest.kt−46 app/src/test/java/soy/iko/opencode/data/repo/ErrorKindTest.kt−60 app/src/test/java/soy/iko/opencode/data/repo/MessageStoreTest.kt−385 app/src/test/java/soy/iko/opencode/data/repo/OutboxStoreTest.kt−44 app/src/test/java/soy/iko/opencode/data/repo/SessionRepositoryTest.kt−73 app/src/test/java/soy/iko/opencode/di/UnreadTrackingTest.kt−56 app/src/test/java/soy/iko/opencode/ui/chat/ChatViewModelTest.kt−601 app/src/test/java/soy/iko/opencode/ui/chat/ConversationExportTest.kt−120 app/src/test/java/soy/iko/opencode/ui/chat/ToolDurationFormatTest.kt−45 app/src/test/java/soy/iko/opencode/ui/components/CodeHighlighterLanguageTest.kt−66 app/src/test/java/soy/iko/opencode/ui/components/DiffParseTest.kt−55 app/src/test/java/soy/iko/opencode/ui/components/ExtractCodeTextTest.kt−56 app/src/test/java/soy/iko/opencode/ui/components/ExtractFenceLanguageTest.kt−28 app/src/test/java/soy/iko/opencode/ui/file/FileBrowserViewModelTest.kt−219 app/src/test/java/soy/iko/opencode/ui/file/FileViewModelTest.kt−70 app/src/test/java/soy/iko/opencode/ui/mcp/McpParserTest.kt−52 app/src/test/java/soy/iko/opencode/ui/search/GlobalSearchViewModelTest.kt−120 app/src/test/java/soy/iko/opencode/ui/server/CertPinValidationTest.kt−39 app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.kt−460 app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.kt−634 app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt−475 app/src/test/java/soy/iko/opencode/ui/usage/UsageAggregationTest.kt−48 build.gradle.kts+1−1 config/detekt/detekt.yml−68 flake.lock+3−3 flake.nix+27−17 generated/com/novi/bincode/BincodeDeserializer.java+38 generated/com/novi/bincode/BincodeSerializer.java+33 generated/com/novi/serde/ArrayLen.java+12 generated/com/novi/serde/BinaryDeserializer.java+195 generated/com/novi/serde/BinarySerializer.java+146 generated/com/novi/serde/Bytes.java+54 generated/com/novi/serde/DeserializationError.java+11 generated/com/novi/serde/Deserializer.java+56 generated/com/novi/serde/Int128.java+11 generated/com/novi/serde/SerializationError.java+11 generated/com/novi/serde/Serializer.java+58 generated/com/novi/serde/Slice.java+40 generated/com/novi/serde/Tuple2.java+40 generated/com/novi/serde/Tuple3.java+46 generated/com/novi/serde/Tuple4.java+54 generated/com/novi/serde/Tuple5.java+61 generated/com/novi/serde/Tuple6.java+67 generated/com/novi/serde/Unit.java+20 generated/com/novi/serde/Unsigned.java+11 generated/soy/iko/opencode/shared/MessageStatus.java+150 generated/soy/iko/opencode/shared/MessageView.java+131 generated/soy/iko/opencode/shared/Requests.java+27 generated/soy/iko/opencode/shared/Screen.java+150 generated/soy/iko/opencode/shared/SessionView.java+77 generated/soy/iko/opencode/shared/ToolView.java+86 generated/soy/iko/opencode/shared/TraitHelpers.java+71 generated/soy/iko/opencode/shared/ViewModel.java+185 gradle.properties+5−2 gradle/libs.versions.toml+25−54 renovate.json−32 scripts/run-device.sh−106 scripts/run.sh−95 settings.gradle.kts+3 shared/Cargo.lock+1358 shared/Cargo.toml+31 shared/src/app.rs+1051 shared/src/bin/codegen.rs+35 shared/src/lib.rs+17 shared/src/protocol.rs+248
.direnv/bin/nix-direnv-reloaddeleted−19
@@ -1,19 +0,0 @@
1−#!/usr/bin/env bash
2−set -e
3−if [[ ! -d "/home/iko/opencode-android" ]]; then
4− echo "Cannot find source directory; Did you move it?"
5− echo "(Looking for "/home/iko/opencode-android")"
6− echo 'Cannot force reload with this script - use "direnv reload" manually and then try again'
7− exit 1
8−fi
9−
10−# rebuild the cache forcefully
11−_nix_direnv_force_reload=1 direnv exec "/home/iko/opencode-android" true
12−
13−# Update the mtime for .envrc.
14−# This will cause direnv to reload again - but without re-building.
15−touch "/home/iko/opencode-android/.envrc"
16−
17−# Also update the timestamp of whatever profile_rc we have.
18−# This makes sure that we know we are up to date.
19−touch -r "/home/iko/opencode-android/.envrc" "/home/iko/opencode-android/.direnv"/*.rc
.direnv/flake-inputs/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-sourcedeleted−1
@@ -1 +0,0 @@
1−/nix/store/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-source
.direnv/flake-inputs/kfs4nfy1705ksbn0gv15ls921cc90h2z-sourcedeleted−1
@@ -1 +0,0 @@
1−/nix/store/kfs4nfy1705ksbn0gv15ls921cc90h2z-source
.direnv/flake-inputs/y4nm07b4ag6b9l0db8himn2xr1hyxfwr-sourcedeleted−1
@@ -1 +0,0 @@
1−/nix/store/y4nm07b4ag6b9l0db8himn2xr1hyxfwr-source
.direnv/flake-inputs/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-sourcedeleted−1
@@ -1 +0,0 @@
1−/nix/store/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-source
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfadeleted−1
@@ -1 +0,0 @@
1−/nix/store/n53brq0w7p7wvbsxw4i8y3sxraf91hci-nix-shell-env
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rcdeleted−2250
@@ -1,2250 +0,0 @@
1−unset shellHook
2−PATH=${PATH:-}
3−nix_saved_PATH="$PATH"
4−XDG_DATA_DIRS=${XDG_DATA_DIRS:-}
5−nix_saved_XDG_DATA_DIRS="$XDG_DATA_DIRS"
6−ANDROID_HOME='/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/libexec/android-sdk'
7−export ANDROID_HOME
8−ANDROID_SDK_ROOT='/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/libexec/android-sdk'
9−export ANDROID_SDK_ROOT
10−AR='ar'
11−export AR
12−AS='as'
13−export AS
14−BASH='/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin/bash'
15−CC='gcc'
16−export CC
17−CLASSPATH=''
18−export CLASSPATH
19−CONFIG_SHELL='/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin/bash'
20−export CONFIG_SHELL
21−CXX='g++'
22−export CXX
23−GRADLE_OPTS='-Dorg.gradle.project.android.aapt2FromMavenOverride=/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/libexec/android-sdk/build-tools/35.0.0/aapt2'
24−export GRADLE_OPTS
25−HOSTTYPE='x86_64'
26−HOST_PATH='/nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2/bin:/nix/store/hwcbpab3i98nbx7alfq4ggsfgv3i2kc3-gradle-8.14.4/bin:/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/bin:/nix/store/sr26flm2nkfa12dkrwj2630kqsfakky4-coreutils-9.11/bin:/nix/store/zjwih3d9rrrw4ixll52wsy0538p7vdfd-findutils-4.10.0/bin:/nix/store/3c05s0vxy8wafaa7lkj4bfh69wa0ch10-diffutils-3.12/bin:/nix/store/0hamsiy8hsyfw1hmizbc3bf93ad7fa1v-gnused-4.9/bin:/nix/store/w8xlvapzxcz23ba312q119p57bnc7200-gnugrep-3.12/bin:/nix/store/cdd73p8qiphg0scs02i6lrg1sjsdvbkx-gawk-5.4.0/bin:/nix/store/066fzlsj0wdq2sqxjrksysfxyr4r5f72-gnutar-1.35/bin:/nix/store/h304h5a5y9h1pl6crhz65jlri91nxyay-gzip-1.14/bin:/nix/store/lc20sfvskz0my6phs73nmsnlrxp06kgb-bzip2-1.0.8-bin/bin:/nix/store/i4jf7w00lr5q85r55rc35n83l7iwys73-gnumake-4.4.1/bin:/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin:/nix/store/vm55g5jg7ra19gdsxsxr092vpb52ayiz-patch-2.8/bin:/nix/store/y5zljns1fbcdgh5pp1szjlw0j11xxvcy-xz-5.8.3-bin/bin:/nix/store/y1fmsp7vhpdaclpqbsgm2qyl7yixx4ci-file-5.47/bin'
27−export HOST_PATH
28−IFS='
29−'
30−IN_NIX_SHELL='impure'
31−export IN_NIX_SHELL
32−JAVA_HOME='/nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2'
33−export JAVA_HOME
34−LD='ld'
35−export LD
36−LINENO='80'
37−MACHTYPE='x86_64-pc-linux-gnu'
38−NIX_BINTOOLS='/nix/store/z4zcd87nx2hrsdayd0vl2mx5ncj3ikd8-binutils-wrapper-2.46'
39−export NIX_BINTOOLS
40−NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu='1'
41−export NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu
42−NIX_BUILD_CORES='4'
43−export NIX_BUILD_CORES
44−NIX_CC='/nix/store/xcnqqnhw9hb4j5rjgds2yjryi8qki5f3-gcc-wrapper-15.2.0'
45−export NIX_CC
46−NIX_CC_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu='1'
47−export NIX_CC_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu
48−NIX_CFLAGS_COMPILE=' -frandom-seed=n53brq0w7p -isystem /nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2/include -isystem /nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2/include'
49−export NIX_CFLAGS_COMPILE
50−NIX_ENFORCE_NO_NATIVE='1'
51−export NIX_ENFORCE_NO_NATIVE
52−NIX_HARDENING_ENABLE='bindnow format fortify fortify3 libcxxhardeningfast pic relro stackclashprotection stackprotector strictflexarrays1 strictoverflow zerocallusedregs'
53−export NIX_HARDENING_ENABLE
54−NIX_LDFLAGS='-rpath /home/iko/opencode-android/outputs/out/lib '
55−export NIX_LDFLAGS
56−NIX_NO_SELF_RPATH='1'
57−NIX_STORE='/nix/store'
58−export NIX_STORE
59−NM='nm'
60−export NM
61−OBJCOPY='objcopy'
62−export OBJCOPY
63−OBJDUMP='objdump'
64−export OBJDUMP
65−OLDPWD=''
66−export OLDPWD
67−OPTERR='1'
68−OSTYPE='linux-gnu'
69−PATH='/nix/store/jbav217344pndjbghzyjmh0mkzy1973i-patchelf-0.15.2/bin:/nix/store/xcnqqnhw9hb4j5rjgds2yjryi8qki5f3-gcc-wrapper-15.2.0/bin:/nix/store/sq0nrnfwhkc5ljvklnrk5ps4358g4nbj-gcc-15.2.0/bin:/nix/store/521dd0054ifhzmjfmpx9mz0hr7wh7sig-glibc-2.42-67-bin/bin:/nix/store/sr26flm2nkfa12dkrwj2630kqsfakky4-coreutils-9.11/bin:/nix/store/z4zcd87nx2hrsdayd0vl2mx5ncj3ikd8-binutils-wrapper-2.46/bin:/nix/store/39lyf5g2iz4jd2r6q4g5whk9vw09zglj-binutils-2.46/bin:/nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2/bin:/nix/store/hwcbpab3i98nbx7alfq4ggsfgv3i2kc3-gradle-8.14.4/bin:/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/bin:/nix/store/sr26flm2nkfa12dkrwj2630kqsfakky4-coreutils-9.11/bin:/nix/store/zjwih3d9rrrw4ixll52wsy0538p7vdfd-findutils-4.10.0/bin:/nix/store/3c05s0vxy8wafaa7lkj4bfh69wa0ch10-diffutils-3.12/bin:/nix/store/0hamsiy8hsyfw1hmizbc3bf93ad7fa1v-gnused-4.9/bin:/nix/store/w8xlvapzxcz23ba312q119p57bnc7200-gnugrep-3.12/bin:/nix/store/cdd73p8qiphg0scs02i6lrg1sjsdvbkx-gawk-5.4.0/bin:/nix/store/066fzlsj0wdq2sqxjrksysfxyr4r5f72-gnutar-1.35/bin:/nix/store/h304h5a5y9h1pl6crhz65jlri91nxyay-gzip-1.14/bin:/nix/store/lc20sfvskz0my6phs73nmsnlrxp06kgb-bzip2-1.0.8-bin/bin:/nix/store/i4jf7w00lr5q85r55rc35n83l7iwys73-gnumake-4.4.1/bin:/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin:/nix/store/vm55g5jg7ra19gdsxsxr092vpb52ayiz-patch-2.8/bin:/nix/store/y5zljns1fbcdgh5pp1szjlw0j11xxvcy-xz-5.8.3-bin/bin:/nix/store/y1fmsp7vhpdaclpqbsgm2qyl7yixx4ci-file-5.47/bin'
70−export PATH
71−PS4='+ '
72−RANLIB='ranlib'
73−export RANLIB
74−READELF='readelf'
75−export READELF
76−SHELL='/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin/bash'
77−export SHELL
78−SIZE='size'
79−export SIZE
80−SOURCE_DATE_EPOCH='315532800'
81−export SOURCE_DATE_EPOCH
82−STRINGS='strings'
83−export STRINGS
84−STRIP='strip'
85−export STRIP
86−XDG_DATA_DIRS='/nix/store/jbav217344pndjbghzyjmh0mkzy1973i-patchelf-0.15.2/share'
87−export XDG_DATA_DIRS
88−__structuredAttrs=''
89−export __structuredAttrs
90−_substituteStream_has_warned_replace_deprecation='false'
91−buildInputs='/nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2 /nix/store/hwcbpab3i98nbx7alfq4ggsfgv3i2kc3-gradle-8.14.4 /nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk'
92−export buildInputs
93−buildPhase='{ echo "------------------------------------------------------------";
94− echo " WARNING: the existence of this path is not guaranteed.";
95− echo " It is an internal implementation detail for pkgs.mkShell.";
96− echo "------------------------------------------------------------";
97− echo;
98− # Record all build inputs as runtime dependencies
99− export;
100−} >> "$out"
101−'
102−export buildPhase
103−builder='/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin/bash'
104−export builder
105−checkPhase='gradleCheckPhase'
106−cmakeFlags=''
107−export cmakeFlags
108−configureFlags=''
109−export configureFlags
110−defaultBuildInputs=''
111−defaultNativeBuildInputs='/nix/store/jbav217344pndjbghzyjmh0mkzy1973i-patchelf-0.15.2 /nix/store/iymsdj1yqiy7d8pylqvjqxqiqb9lqq94-update-autotools-gnu-config-scripts-hook /nix/store/0y5xmdb7qfvimjwbq7ibg1xdgkgjwqng-no-broken-symlinks.sh /nix/store/cv1d7p48379km6a85h4zp6kr86brh32q-audit-tmpdir.sh /nix/store/85clx3b0xkdf58jn161iy80y5223ilbi-compress-man-pages.sh /nix/store/p3l1a5y7nllfyrjn2krlwgcc3z0cd3fq-make-symlinks-relative.sh /nix/store/5yzw0vhkyszf2d179m0qfkgxmp5wjjx4-move-docs.sh /nix/store/fyaryjvghbkpfnsyw97hb3lyb37s1pd6-move-lib64.sh /nix/store/kd4xwxjpjxi71jkm6ka0np72if9rm3y0-move-sbin.sh /nix/store/pag6l61paj1dc9sv15l7bm5c17xn5kyk-move-systemd-user-units.sh /nix/store/cmzya9irvxzlkh7lfy6i82gbp0saxqj3-multiple-outputs.sh /nix/store/x8c40nfigps493a07sdr2pm5s9j1cdc0-patch-shebangs.sh /nix/store/cickvswrvann041nqxb0rxilc46svw1n-prune-libtool-files.sh /nix/store/xyff06pkhki3qy1ls77w10s0v79c9il0-reproducible-builds.sh /nix/store/z7k98578dfzi6l3hsvbivzm7hfqlk0zc-set-source-date-epoch-to-latest.sh /nix/store/pilsssjjdxvdphlg2h19p0bfx5q0jzkn-strip.sh /nix/store/xcnqqnhw9hb4j5rjgds2yjryi8qki5f3-gcc-wrapper-15.2.0'
112−depsBuildBuild=''
113−export depsBuildBuild
114−depsBuildBuildPropagated=''
115−export depsBuildBuildPropagated
116−depsBuildTarget=''
117−export depsBuildTarget
118−depsBuildTargetPropagated=''
119−export depsBuildTargetPropagated
120−depsHostHost=''
121−export depsHostHost
122−depsHostHostPropagated=''
123−export depsHostHostPropagated
124−depsTargetTarget=''
125−export depsTargetTarget
126−depsTargetTargetPropagated=''
127−export depsTargetTargetPropagated
128−doCheck=''
129−export doCheck
130−doInstallCheck=''
131−export doInstallCheck
132−dontAddDisableDepTrack='1'
133−export dontAddDisableDepTrack
134−declare -a envBuildBuildHooks=()
135−declare -a envBuildHostHooks=()
136−declare -a envBuildTargetHooks=()
137−declare -a envHostHostHooks=('ccWrapper_addCVars' 'bintoolsWrapper_addLDVars' )
138−declare -a envHostTargetHooks=('ccWrapper_addCVars' 'bintoolsWrapper_addLDVars' )
139−declare -a envTargetTargetHooks=('addPkgToClassPath' )
140−declare -a fixupOutputHooks=('if [ -z "${dontPatchELF-}" ]; then patchELF "$prefix"; fi' 'if [[ -z "${noAuditTmpdir-}" && -e "$prefix" ]]; then auditTmpdir "$prefix"; fi' 'if [ -z "${dontGzipMan-}" ]; then compressManPages "$prefix"; fi' '_moveLib64' '_moveSbin' '_moveSystemdUserUnits' 'patchShebangsAuto' '_pruneLibtoolFiles' '_doStrip' )
141−initialPath='/nix/store/sr26flm2nkfa12dkrwj2630kqsfakky4-coreutils-9.11 /nix/store/zjwih3d9rrrw4ixll52wsy0538p7vdfd-findutils-4.10.0 /nix/store/3c05s0vxy8wafaa7lkj4bfh69wa0ch10-diffutils-3.12 /nix/store/0hamsiy8hsyfw1hmizbc3bf93ad7fa1v-gnused-4.9 /nix/store/w8xlvapzxcz23ba312q119p57bnc7200-gnugrep-3.12 /nix/store/cdd73p8qiphg0scs02i6lrg1sjsdvbkx-gawk-5.4.0 /nix/store/066fzlsj0wdq2sqxjrksysfxyr4r5f72-gnutar-1.35 /nix/store/h304h5a5y9h1pl6crhz65jlri91nxyay-gzip-1.14 /nix/store/lc20sfvskz0my6phs73nmsnlrxp06kgb-bzip2-1.0.8-bin /nix/store/i4jf7w00lr5q85r55rc35n83l7iwys73-gnumake-4.4.1 /nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9 /nix/store/vm55g5jg7ra19gdsxsxr092vpb52ayiz-patch-2.8 /nix/store/y5zljns1fbcdgh5pp1szjlw0j11xxvcy-xz-5.8.3-bin /nix/store/y1fmsp7vhpdaclpqbsgm2qyl7yixx4ci-file-5.47'
142−mesonFlags=''
143−export mesonFlags
144−name='nix-shell-env'
145−export name
146−nativeBuildInputs=''
147−export nativeBuildInputs
148−out='/home/iko/opencode-android/outputs/out'
149−export out
150−outputBin='out'
151−outputDev='out'
152−outputDevdoc='REMOVE'
153−outputDevman='out'
154−outputDoc='out'
155−outputInclude='out'
156−outputInfo='out'
157−outputLib='out'
158−outputMan='out'
159−outputs='out'
160−export outputs
161−patches=''
162−export patches
163−phases='buildPhase'
164−export phases
165−pkg='/nix/store/xcnqqnhw9hb4j5rjgds2yjryi8qki5f3-gcc-wrapper-15.2.0'
166−declare -a pkgsBuildBuild=()
167−declare -a pkgsBuildHost=('/nix/store/jbav217344pndjbghzyjmh0mkzy1973i-patchelf-0.15.2' '/nix/store/iymsdj1yqiy7d8pylqvjqxqiqb9lqq94-update-autotools-gnu-config-scripts-hook' '/nix/store/0y5xmdb7qfvimjwbq7ibg1xdgkgjwqng-no-broken-symlinks.sh' '/nix/store/cv1d7p48379km6a85h4zp6kr86brh32q-audit-tmpdir.sh' '/nix/store/85clx3b0xkdf58jn161iy80y5223ilbi-compress-man-pages.sh' '/nix/store/p3l1a5y7nllfyrjn2krlwgcc3z0cd3fq-make-symlinks-relative.sh' '/nix/store/5yzw0vhkyszf2d179m0qfkgxmp5wjjx4-move-docs.sh' '/nix/store/fyaryjvghbkpfnsyw97hb3lyb37s1pd6-move-lib64.sh' '/nix/store/kd4xwxjpjxi71jkm6ka0np72if9rm3y0-move-sbin.sh' '/nix/store/pag6l61paj1dc9sv15l7bm5c17xn5kyk-move-systemd-user-units.sh' '/nix/store/cmzya9irvxzlkh7lfy6i82gbp0saxqj3-multiple-outputs.sh' '/nix/store/x8c40nfigps493a07sdr2pm5s9j1cdc0-patch-shebangs.sh' '/nix/store/cickvswrvann041nqxb0rxilc46svw1n-prune-libtool-files.sh' '/nix/store/xyff06pkhki3qy1ls77w10s0v79c9il0-reproducible-builds.sh' '/nix/store/z7k98578dfzi6l3hsvbivzm7hfqlk0zc-set-source-date-epoch-to-latest.sh' '/nix/store/pilsssjjdxvdphlg2h19p0bfx5q0jzkn-strip.sh' '/nix/store/xcnqqnhw9hb4j5rjgds2yjryi8qki5f3-gcc-wrapper-15.2.0' '/nix/store/z4zcd87nx2hrsdayd0vl2mx5ncj3ikd8-binutils-wrapper-2.46' )
168−declare -a pkgsBuildTarget=()
169−declare -a pkgsHostHost=()
170−declare -a pkgsHostTarget=('/nix/store/q6zvjlnhypm88y989l8fq0hjqrz9agda-openjdk-17.0.20+2' '/nix/store/2dsv152lhz5rxl24fkb6sis3q165swv6-set-java-classpath-hook' '/nix/store/hwcbpab3i98nbx7alfq4ggsfgv3i2kc3-gradle-8.14.4' '/nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk' )
171−declare -a pkgsTargetTarget=()
172−declare -a postFixupHooks=('noBrokenSymlinksInAllOutputs' '_makeSymlinksRelative' '_multioutPropagateDev' )
173−declare -a postUnpackHooks=('_updateSourceDateEpochFromSourceRoot' )
174−declare -a preConfigureHooks=('mitmCacheConfigureHook' '_multioutConfig' 'gradleConfigureHook' )
175−preConfigurePhases=' updateAutotoolsGnuConfigScriptsPhase'
176−declare -a preFixupHooks=('_moveToShare' '_multioutDocs' '_multioutDevs' )
177−preferLocalBuild='1'
178−export preferLocalBuild
179−prefix='/home/iko/opencode-android/outputs/out'
180−declare -a propagatedBuildDepFiles=('propagated-build-build-deps' 'propagated-native-build-inputs' 'propagated-build-target-deps' )
181−propagatedBuildInputs=''
182−export propagatedBuildInputs
183−declare -a propagatedHostDepFiles=('propagated-host-host-deps' 'propagated-build-inputs' )
184−propagatedNativeBuildInputs=''
185−export propagatedNativeBuildInputs
186−declare -a propagatedTargetDepFiles=('propagated-target-target-deps' )
187−shell='/nix/store/zh1ijdhb6gng1509b1zrilb6xlzx60j6-bash-5.3p9/bin/bash'
188−export shell
189−shellHook='echo "opencode-android dev shell"
190−echo " JDK: $(java -version 2>&1 | head -1)"
191−echo " ANDROID_HOME: $ANDROID_HOME"
192−echo " aapt2: /nix/store/absfk78zd45lgyjp0nh19y6gpdnd3lpi-androidsdk/libexec/android-sdk/build-tools/35.0.0/aapt2"
193−echo ""
194−echo "Build: ./gradlew assembleDebug"
195−echo "Device: adb devices"
196−'
197−export shellHook
198−stdenv='/nix/store/xknwpg5yjxxsffmwdmpys269543wpdy5-stdenv-linux'
199−export stdenv
200−strictDeps=''
201−export strictDeps
202−system='x86_64-linux'
203−export system
204−declare -a unpackCmdHooks=('_defaultUnpack' )
205−_activatePkgs ()
206−{
207−
208− local hostOffset targetOffset;
209− local pkg;
210− for hostOffset in "${allPlatOffsets[@]}";
211− do
212− local pkgsVar="${pkgAccumVarVars[hostOffset + 1]}";
213− for targetOffset in "${allPlatOffsets[@]}";
214− do
215− (( hostOffset <= targetOffset )) || continue;
216− local pkgsRef="${pkgsVar}[$targetOffset - $hostOffset]";
217− local pkgsSlice="${!pkgsRef}[@]";
218− for pkg in ${!pkgsSlice+"${!pkgsSlice}"};
219− do
220− activatePackage "$pkg" "$hostOffset" "$targetOffset";
221− done;
222− done;
223− done
224−}
225−_addRpathPrefix ()
226−{
227−
228− if [ "${NIX_NO_SELF_RPATH:-0}" != 1 ]; then
229− export NIX_LDFLAGS="-rpath $1/lib ${NIX_LDFLAGS-}";
230− fi
231−}
232−_addToEnv ()
233−{
234−
235− local depHostOffset depTargetOffset;
236− local pkg;
237− for depHostOffset in "${allPlatOffsets[@]}";
238− do
239− local hookVar="${pkgHookVarVars[depHostOffset + 1]}";
240− local pkgsVar="${pkgAccumVarVars[depHostOffset + 1]}";
241− for depTargetOffset in "${allPlatOffsets[@]}";
242− do
243− (( depHostOffset <= depTargetOffset )) || continue;
244− local hookRef="${hookVar}[$depTargetOffset - $depHostOffset]";
245− if [[ -z "${strictDeps-}" ]]; then
246− local visitedPkgs="";
247− for pkg in "${pkgsBuildBuild[@]}" "${pkgsBuildHost[@]}" "${pkgsBuildTarget[@]}" "${pkgsHostHost[@]}" "${pkgsHostTarget[@]}" "${pkgsTargetTarget[@]}";
248− do
249− if [[ "$visitedPkgs" = *"$pkg"* ]]; then
250− continue;
251− fi;
252− runHook "${!hookRef}" "$pkg";
253− visitedPkgs+=" $pkg";
254− done;
255− else
256− local pkgsRef="${pkgsVar}[$depTargetOffset - $depHostOffset]";
257− local pkgsSlice="${!pkgsRef}[@]";
258− for pkg in ${!pkgsSlice+"${!pkgsSlice}"};
259− do
260− runHook "${!hookRef}" "$pkg";
261− done;
262− fi;
263− done;
264− done
265−}
266−_allFlags ()
267−{
268−
269− export system pname name version;
270− while IFS='' read -r varName; do
271− nixTalkativeLog "@${varName}@ -> ${!varName}";
272− args+=("--subst-var" "$varName");
273− done < <(awk 'BEGIN { for (v in ENVIRON) if (v ~ /^[a-z][a-zA-Z0-9_]*$/) print v }')
274−}
275−_assignFirst ()
276−{
277−
278− local varName="$1";
279− local _var;
280− local REMOVE=REMOVE;
281− shift;
282− for _var in "$@";
283− do
284− if [ -n "${!_var-}" ]; then
285− eval "${varName}"="${_var}";
286− return;
287− fi;
288− done;
289− echo;
290− echo "error: _assignFirst: could not find a non-empty variable whose name to assign to ${varName}.";
291− echo " The following variables were all unset or empty:";
292− echo " $*";
293− if [ -z "${out:-}" ]; then
294− echo ' If you do not want an "out" output in your derivation, make sure to define';
295− echo ' the other specific required outputs. This can be achieved by picking one';
296− echo " of the above as an output.";
297− echo ' You do not have to remove "out" if you want to have a different default';
298− echo ' output, because the first output is taken as a default.';
299− echo;
300− fi;
301− return 1
302−}
303−_callImplicitHook ()
304−{
305−
306− local def="$1";
307− local hookName="$2";
308− if declare -F "$hookName" > /dev/null; then
309− nixTalkativeLog "calling implicit '$hookName' function hook";
310− "$hookName";
311− else
312− if type -p "$hookName" > /dev/null; then
313− nixTalkativeLog "sourcing implicit '$hookName' script hook";
314− source "$hookName";
315− else
316− if [ -n "${!hookName:-}" ]; then
317− nixTalkativeLog "evaling implicit '$hookName' string hook";
318− eval "${!hookName}";
319− else
320− return "$def";
321− fi;
322− fi;
323− fi
324−}
325−_defaultUnpack ()
326−{
327−
328− local fn="$1";
329− local destination;
330− if [ -d "$fn" ]; then
331− destination="$(stripHash "$fn")";
332− if [ -e "$destination" ]; then
333− echo "Cannot copy $fn to $destination: destination already exists!";
334− echo "Did you specify two \"srcs\" with the same \"name\"?";
335− return 1;
336− fi;
337− cp -r --preserve=timestamps --reflink=auto -- "$fn" "$destination";
338− else
339− case "$fn" in
340− *.tar.xz | *.tar.lzma | *.txz)
341− ( XZ_OPT="--threads=$NIX_BUILD_CORES" xz -d < "$fn";
342− true ) | tar xf - --mode=+w --warning=no-timestamp
343− ;;
344− *.tar | *.tar.* | *.tgz | *.tbz2 | *.tbz)
345− tar xf "$fn" --mode=+w --warning=no-timestamp
346− ;;
347− *)
348− return 1
349− ;;
350− esac;
351− fi
352−}
353−_doStrip ()
354−{
355−
356− local -ra flags=(dontStripHost dontStripTarget);
357− local -ra debugDirs=(stripDebugList stripDebugListTarget);
358− local -ra allDirs=(stripAllList stripAllListTarget);
359− local -ra stripCmds=(STRIP STRIP_FOR_TARGET);
360− local -ra ranlibCmds=(RANLIB RANLIB_FOR_TARGET);
361− stripDebugList=${stripDebugList[*]:-lib lib32 lib64 libexec bin sbin Applications Library/Frameworks};
362− stripDebugListTarget=${stripDebugListTarget[*]:-};
363− stripAllList=${stripAllList[*]:-};
364− stripAllListTarget=${stripAllListTarget[*]:-};
365− local i;
366− for i in ${!stripCmds[@]};
367− do
368− local -n flag="${flags[$i]}";
369− local -n debugDirList="${debugDirs[$i]}";
370− local -n allDirList="${allDirs[$i]}";
371− local -n stripCmd="${stripCmds[$i]}";
372− local -n ranlibCmd="${ranlibCmds[$i]}";
373− if [[ -n "${dontStrip-}" || -n "${flag-}" ]] || ! type -f "${stripCmd-}" 2> /dev/null 1>&2; then
374− continue;
375− fi;
376− stripDirs "$stripCmd" "$ranlibCmd" "$debugDirList" "${stripDebugFlags[*]:--S -p}";
377− stripDirs "$stripCmd" "$ranlibCmd" "$allDirList" "${stripAllFlags[*]:--s -p}";
378− done
379−}
380−_eval ()
381−{
382−
383− if declare -F "$1" > /dev/null 2>&1; then
384− "$@";
385− else
386− eval "$1";
387− fi
388−}
389−_logHook ()
390−{
391−
392− if [[ -z ${NIX_LOG_FD-} ]]; then
393− return;
394− fi;
395− local hookKind="$1";
396− local hookExpr="$2";
397− shift 2;
398− if declare -F "$hookExpr" > /dev/null 2>&1; then
399− nixTalkativeLog "calling '$hookKind' function hook '$hookExpr'" "$@";
400− else
401− if type -p "$hookExpr" > /dev/null; then
402− nixTalkativeLog "sourcing '$hookKind' script hook '$hookExpr'";
403− else
404− if [[ "$hookExpr" != "_callImplicitHook"* ]]; then
405− local exprToOutput;
406− if [[ ${NIX_DEBUG:-0} -ge 5 ]]; then
407− exprToOutput="$hookExpr";
408− else
409− local hookExprLine;
410− while IFS= read -r hookExprLine; do
411− hookExprLine="${hookExprLine#"${hookExprLine%%[![:space:]]*}"}";
412− if [[ -n "$hookExprLine" ]]; then
413− exprToOutput+="$hookExprLine\\n ";
414− fi;
415− done <<< "$hookExpr";
416− exprToOutput="${exprToOutput%%\\n }";
417− fi;
418− nixTalkativeLog "evaling '$hookKind' string hook '$exprToOutput'";
419− fi;
420− fi;
421− fi
422−}
423−_makeSymlinksRelative ()
424−{
425−
426− local prefixes;
427− prefixes=();
428− for output in $(getAllOutputNames);
429− do
430− [ ! -e "${!output}" ] && continue;
431− prefixes+=("${!output}");
432− done;
433− find "${prefixes[@]}" -type l -printf '%H\0%p\0' | xargs -0 -n2 -r -P "$NIX_BUILD_CORES" sh -c '
434− output="$1"
435− link="$2"
436−
437− linkTarget=$(readlink "$link")
438−
439− # only touch links that point inside the same output tree
440− [[ $linkTarget == "$output"/* ]] || exit 0
441−
442− if [ ! -e "$linkTarget" ]; then
443− echo "the symlink $link is broken, it points to $linkTarget (which is missing)"
444− fi
445−
446− echo "making symlink relative: $link"
447− ln -snrf "$linkTarget" "$link"
448− ' _
449−}
450−_moveLib64 ()
451−{
452−
453− if [ "${dontMoveLib64-}" = 1 ]; then
454− return;
455− fi;
456− if [ ! -e "$prefix/lib64" -o -L "$prefix/lib64" ]; then
457− return;
458− fi;
459− echo "moving $prefix/lib64/* to $prefix/lib";
460− mkdir -p $prefix/lib;
461− shopt -s dotglob;
462− for i in $prefix/lib64/*;
463− do
464− mv --no-clobber "$i" $prefix/lib;
465− done;
466− shopt -u dotglob;
467− rmdir $prefix/lib64;
468− ln -s lib $prefix/lib64
469−}
470−_moveSbin ()
471−{
472−
473− if [ "${dontMoveSbin-}" = 1 ]; then
474− return;
475− fi;
476− if [ ! -e "$prefix/sbin" -o -L "$prefix/sbin" ]; then
477− return;
478− fi;
479− echo "moving $prefix/sbin/* to $prefix/bin";
480− mkdir -p $prefix/bin;
481− shopt -s dotglob;
482− for i in $prefix/sbin/*;
483− do
484− mv "$i" $prefix/bin;
485− done;
486− shopt -u dotglob;
487− rmdir $prefix/sbin;
488− ln -s bin $prefix/sbin
489−}
490−_moveSystemdUserUnits ()
491−{
492−
493− if [ "${dontMoveSystemdUserUnits:-0}" = 1 ]; then
494− return;
495− fi;
496− if [ ! -e "${prefix:?}/lib/systemd/user" ]; then
497− return;
498− fi;
499− local source="$prefix/lib/systemd/user";
500− local target="$prefix/share/systemd/user";
501− echo "moving $source/* to $target";
502− mkdir -p "$target";
503− ( shopt -s dotglob;
504− for i in "$source"/*;
505− do
506− mv "$i" "$target";
507− done );
508− rmdir "$source";
509− ln -s "$target" "$source"
510−}
511−_moveToShare ()
512−{
513−
514− if [ -n "$__structuredAttrs" ]; then
515− if [ -z "${forceShare-}" ]; then
516− forceShare=(man doc info);
517− fi;
518− else
519− forceShare=(${forceShare:-man doc info});
520− fi;
521− if [[ -z "$out" ]]; then
522− return;
523− fi;
524− for d in "${forceShare[@]}";
525− do
526− if [ -d "$out/$d" ]; then
527− if [ -d "$out/share/$d" ]; then
528− echo "both $d/ and share/$d/ exist!";
529− else
530− echo "moving $out/$d to $out/share/$d";
531− mkdir -p $out/share;
532− mv $out/$d $out/share/;
533− fi;
534− fi;
535− done
536−}
537−_multioutConfig ()
538−{
539−
540− if [ "$(getAllOutputNames)" = "out" ] || [ -z "${setOutputFlags-1}" ]; then
541− return;
542− fi;
543− if [ -z "${shareDocName:-}" ]; then
544− local confScript="${configureScript:-}";
545− if [ -z "$confScript" ] && [ -x ./configure ]; then
546− confScript=./configure;
547− fi;
548− if [ -f "$confScript" ]; then
549− local shareDocName="$(sed -n "s/^PACKAGE_TARNAME='\(.*\)'$/\1/p" < "$confScript")";
550− fi;
551− if [ -z "$shareDocName" ] || echo "$shareDocName" | grep -q '[^a-zA-Z0-9_-]'; then
552− shareDocName="$(echo "$name" | sed 's/-[^a-zA-Z].*//')";
553− fi;
554− fi;
555− prependToVar configureFlags --bindir="${!outputBin}"/bin --sbindir="${!outputBin}"/sbin --includedir="${!outputInclude}"/include --mandir="${!outputMan}"/share/man --infodir="${!outputInfo}"/share/info --docdir="${!outputDoc}"/share/doc/"${shareDocName}" --libdir="${!outputLib}"/lib --libexecdir="${!outputLib}"/libexec --localedir="${!outputLib}"/share/locale;
556− prependToVar installFlags pkgconfigdir="${!outputDev}"/lib/pkgconfig m4datadir="${!outputDev}"/share/aclocal aclocaldir="${!outputDev}"/share/aclocal
557−}
558−_multioutDevs ()
559−{
560−
561− if [ "$(getAllOutputNames)" = "out" ] || [ -z "${moveToDev-1}" ]; then
562− return;
563− fi;
564− moveToOutput include "${!outputInclude}";
565− moveToOutput lib/pkgconfig "${!outputDev}";
566− moveToOutput share/pkgconfig "${!outputDev}";
567− moveToOutput lib/cmake "${!outputDev}";
568− moveToOutput share/aclocal "${!outputDev}";
569− for f in "${!outputDev}"/{lib,share}/pkgconfig/*.pc;
570− do
571− echo "Patching '$f' includedir to output ${!outputInclude}";
572− sed -i "/^includedir=/s,=\${prefix},=${!outputInclude}," "$f";
573− done
574−}
575−_multioutDocs ()
576−{
577−
578− local REMOVE=REMOVE;
579− moveToOutput share/info "${!outputInfo}";
580− moveToOutput share/doc "${!outputDoc}";
581− moveToOutput share/gtk-doc "${!outputDevdoc}";
582− moveToOutput share/devhelp/books "${!outputDevdoc}";
583− moveToOutput share/man "${!outputMan}";
584− moveToOutput share/man/man3 "${!outputDevman}"
585−}
586−_multioutPropagateDev ()
587−{
588−
589− if [ "$(getAllOutputNames)" = "out" ]; then
590− return;
591− fi;
592− local outputFirst;
593− for outputFirst in $(getAllOutputNames);
594− do
595− break;
596− done;
597− local propagaterOutput="$outputDev";
598− if [ -z "$propagaterOutput" ]; then
599− propagaterOutput="$outputFirst";
600− fi;
601− if [ -z "${propagatedBuildOutputs+1}" ]; then
602− local po_dirty="$outputBin $outputInclude $outputLib";
603− set +o pipefail;
604− propagatedBuildOutputs=`echo "$po_dirty" | tr -s ' ' '\n' | grep -v -F "$propagaterOutput" | sort -u | tr '\n' ' ' `;
605− set -o pipefail;
606− fi;
607− if [ -z "$propagatedBuildOutputs" ]; then
608− return;
609− fi;
610− mkdir -p "${!propagaterOutput}"/nix-support;
611− for output in $propagatedBuildOutputs;
612− do
613− echo -n " ${!output}" >> "${!propagaterOutput}"/nix-support/propagated-build-inputs;
614− done
615−}
616−_nixLogWithLevel ()
617−{
618−
619− [[ -z ${NIX_LOG_FD-} || ${NIX_DEBUG:-0} -lt ${1:?} ]] && return 0;
620− local logLevel;
621− case "${1:?}" in
622− 0)
623− logLevel=ERROR
624− ;;
625− 1)
626− logLevel=WARN
627− ;;
628− 2)
629− logLevel=NOTICE
630− ;;
631− 3)
632− logLevel=INFO
633− ;;
634− 4)
635− logLevel=TALKATIVE
636− ;;
637− 5)
638− logLevel=CHATTY
639− ;;
640− 6)
641− logLevel=DEBUG
642− ;;
643− 7)
644− logLevel=VOMIT
645− ;;
646− *)
647− echo "_nixLogWithLevel: called with invalid log level: ${1:?}" >&"$NIX_LOG_FD";
648− return 1
649− ;;
650− esac;
651− local callerName="${FUNCNAME[2]}";
652− if [[ $callerName == "_callImplicitHook" ]]; then
653− callerName="${hookName:?}";
654− fi;
655− printf "%s: %s: %s\n" "$logLevel" "$callerName" "${2:?}" >&"$NIX_LOG_FD"
656−}
657−_overrideFirst ()
658−{
659−
660− if [ -z "${!1-}" ]; then
661− _assignFirst "$@";
662− fi
663−}
664−_pruneLibtoolFiles ()
665−{
666−
667− if [ "${dontPruneLibtoolFiles-}" ] || [ ! -e "$prefix" ]; then
668− return;
669− fi;
670− find "$prefix" -type f -name '*.la' -exec grep -q '^# Generated by .*libtool' {} \; -exec grep -q "^old_library=''" {} \; -exec sed -i {} -e "/^dependency_libs='[^']/ c dependency_libs='' #pruned" \;
671−}
672−_updateSourceDateEpochFromSourceRoot ()
673−{
674−
675− if [ -n "$sourceRoot" ]; then
676− updateSourceDateEpoch "$sourceRoot";
677− fi
678−}
679−activatePackage ()
680−{
681−
682− local pkg="$1";
683− local -r hostOffset="$2";
684− local -r targetOffset="$3";
685− (( hostOffset <= targetOffset )) || exit 1;
686− if [ -f "$pkg" ]; then
687− nixTalkativeLog "sourcing setup hook '$pkg'";
688− source "$pkg";
689− fi;
690− if [[ -z "${strictDeps-}" || "$hostOffset" -le -1 ]]; then
691− addToSearchPath _PATH "$pkg/bin";
692− fi;
693− if (( hostOffset <= -1 )); then
694− addToSearchPath _XDG_DATA_DIRS "$pkg/share";
695− fi;
696− if [[ "$hostOffset" -eq 0 && -d "$pkg/bin" ]]; then
697− addToSearchPath _HOST_PATH "$pkg/bin";
698− fi;
699− if [[ -f "$pkg/nix-support/setup-hook" ]]; then
700− nixTalkativeLog "sourcing setup hook '$pkg/nix-support/setup-hook'";
701− source "$pkg/nix-support/setup-hook";
702− fi
703−}
704−addEnvHooks ()
705−{
706−
707− local depHostOffset="$1";
708− shift;
709− local pkgHookVarsSlice="${pkgHookVarVars[$depHostOffset + 1]}[@]";
710− local pkgHookVar;
711− for pkgHookVar in "${!pkgHookVarsSlice}";
712− do
713− eval "${pkgHookVar}s"'+=("$@")';
714− done
715−}
716−addPkgToClassPath ()
717−{
718−
719− local jar;
720− for jar in $1/share/java/*.jar;
721− do
722− export CLASSPATH=''${CLASSPATH-}''${CLASSPATH:+:}''${jar};
723− done
724−}
725−addToSearchPath ()
726−{
727−
728− addToSearchPathWithCustomDelimiter ":" "$@"
729−}
730−addToSearchPathWithCustomDelimiter ()
731−{
732−
733− local delimiter="$1";
734− local varName="$2";
735− local dir="$3";
736− if [[ -d "$dir" && "${!varName:+${delimiter}${!varName}${delimiter}}" != *"${delimiter}${dir}${delimiter}"* ]]; then
737− export "${varName}=${!varName:+${!varName}${delimiter}}${dir}";
738− fi
739−}
740−appendToVar ()
741−{
742−
743− local -n nameref="$1";
744− local useArray type;
745− if [ -n "$__structuredAttrs" ]; then
746− useArray=true;
747− else
748− useArray=false;
749− fi;
750− if type=$(declare -p "$1" 2> /dev/null); then
751− case "${type#* }" in
752− -A*)
753− echo "appendToVar(): ERROR: trying to use appendToVar on an associative array, use variable+=([\"X\"]=\"Y\") instead." 1>&2;
754− return 1
755− ;;
756− -a*)
757− useArray=true
758− ;;
759− *)
760− useArray=false
761− ;;
762− esac;
763− fi;
764− shift;
765− if $useArray; then
766− nameref=(${nameref+"${nameref[@]}"} "$@");
767− else
768− nameref="${nameref-} $*";
769− fi
770−}
771−auditTmpdir ()
772−{
773−
774− local dir="$1";
775− [ -e "$dir" ] || return 0;
776− echo "checking for references to $TMPDIR/ in $dir...";
777− local tmpdir elf_fifo script_fifo;
778− tmpdir="$(mktemp -d)";
779− elf_fifo="$tmpdir/elf";
780− script_fifo="$tmpdir/script";
781− mkfifo "$elf_fifo" "$script_fifo";
782− ( find "$dir" -type f -not -path '*/.build-id/*' -print0 | while IFS= read -r -d '' file; do
783− if isELF "$file"; then
784− printf '%s\0' "$file" 1>&3;
785− else
786− if isScript "$file"; then
787− filename=${file##*/};
788− dir=${file%/*};
789− if [ -e "$dir/.$filename-wrapped" ]; then
790− printf '%s\0' "$file" 1>&4;
791− fi;
792− fi;
793− fi;
794− done;
795− exec 3>&- 4>&- ) 3> "$elf_fifo" 4> "$script_fifo" & ( xargs -0 -r -P "$NIX_BUILD_CORES" -n 1 sh -c '
796− if { printf :; patchelf --print-rpath "$1"; } | grep -q -F ":$TMPDIR/"; then
797− echo "RPATH of binary $1 contains a forbidden reference to $TMPDIR/"
798− exit 1
799− fi
800− ' _ < "$elf_fifo" ) & local pid_elf=$!;
801− local pid_script;
802− ( xargs -0 -r -P "$NIX_BUILD_CORES" -n 1 sh -c '
803− if grep -q -F "$TMPDIR/" "$1"; then
804− echo "wrapper script $1 contains a forbidden reference to $TMPDIR/"
805− exit 1
806− fi
807− ' _ < "$script_fifo" ) & local pid_script=$!;
808− wait "$pid_elf" || {
809− echo "Some binaries contain forbidden references to $TMPDIR/. Check the error above!";
810− exit 1
811− };
812− wait "$pid_script" || {
813− echo "Some scripts contain forbidden references to $TMPDIR/. Check the error above!";
814− exit 1
815− };
816− rm -r "$tmpdir"
817−}
818−bintoolsWrapper_addLDVars ()
819−{
820−
821− local role_post;
822− getHostRoleEnvHook;
823− if [[ -d "$1/lib64" && ! -L "$1/lib64" ]]; then
824− export NIX_LDFLAGS${role_post}+=" -L$1/lib64";
825− fi;
826− if [[ -d "$1/lib" ]]; then
827− local -a glob=($1/lib/lib*);
828− if [ "${#glob[*]}" -gt 0 ]; then
829− export NIX_LDFLAGS${role_post}+=" -L$1/lib";
830− fi;
831− fi
832−}
833−buildPhase ()
834−{
835−
836− runHook preBuild;
837− if [[ -z "${makeFlags-}" && -z "${makefile:-}" && ! ( -e Makefile || -e makefile || -e GNUmakefile ) ]]; then
838− echo "no Makefile or custom buildPhase, doing nothing";
839− else
840− foundMakefile=1;
841− local flagsArray=(${enableParallelBuilding:+-j${NIX_BUILD_CORES}} SHELL="$SHELL");
842− concatTo flagsArray makeFlags makeFlagsArray buildFlags buildFlagsArray;
843− echoCmd 'build flags' "${flagsArray[@]}";
844− make ${makefile:+-f $makefile} "${flagsArray[@]}";
845− unset flagsArray;
846− fi;
847− runHook postBuild
848−}
849−ccWrapper_addCVars ()
850−{
851−
852− local role_post;
853− getHostRoleEnvHook;
854− local found=;
855− if [ -d "$1/include" ]; then
856− export NIX_CFLAGS_COMPILE${role_post}+=" -isystem $1/include";
857− found=1;
858− fi;
859− if [ -d "$1/Library/Frameworks" ]; then
860− export NIX_CFLAGS_COMPILE${role_post}+=" -iframework $1/Library/Frameworks";
861− found=1;
862− fi;
863− if [[ -n "" && -n ${NIX_STORE:-} && -n $found ]]; then
864− local scrubbed="$NIX_STORE/eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee-${1#"$NIX_STORE"/*-}";
865− export NIX_CFLAGS_COMPILE${role_post}+=" -fmacro-prefix-map=$1=$scrubbed";
866− fi
867−}
868−checkPhase ()
869−{
870−
871− runHook preCheck;
872− if [[ -z "${foundMakefile:-}" ]]; then
873− echo "no Makefile or custom checkPhase, doing nothing";
874− runHook postCheck;
875− return;
876− fi;
877− if [[ -z "${checkTarget:-}" ]]; then
878− if make -n ${makefile:+-f $makefile} check > /dev/null 2>&1; then
879− checkTarget="check";
880− else
881− if make -n ${makefile:+-f $makefile} test > /dev/null 2>&1; then
882− checkTarget="test";
883− fi;
884− fi;
885− fi;
886− if [[ -z "${checkTarget:-}" ]]; then
887− echo "no check/test target in ${makefile:-Makefile}, doing nothing";
888− else
889− local flagsArray=(${enableParallelChecking:+-j${NIX_BUILD_CORES}} SHELL="$SHELL");
890− concatTo flagsArray makeFlags makeFlagsArray checkFlags=VERBOSE=y checkFlagsArray checkTarget;
891− echoCmd 'check flags' "${flagsArray[@]}";
892− make ${makefile:+-f $makefile} "${flagsArray[@]}";
893− unset flagsArray;
894− fi;
895− runHook postCheck
896−}
897−compressManPages ()
898−{
899−
900− local dir="$1";
901− if [ -L "$dir"/share ] || [ -L "$dir"/share/man ] || [ ! -d "$dir/share/man" ]; then
902− return;
903− fi;
904− echo "gzipping man pages under $dir/share/man/";
905− find "$dir"/share/man/ -type f -a '!' -regex '.*\.\(bz2\|gz\|xz\)$' -print0 | xargs -0 -n1 -P "$NIX_BUILD_CORES" gzip -n -f;
906− find "$dir"/share/man/ -type l -a '!' -regex '.*\.\(bz2\|gz\|xz\)$' -print0 | sort -z | while IFS= read -r -d '' f; do
907− local target;
908− target="$(readlink -f "$f")";
909− if [ -f "$target".gz ]; then
910− ln -sf "$target".gz "$f".gz && rm "$f";
911− fi;
912− done
913−}
914−concatStringsSep ()
915−{
916−
917− local sep="$1";
918− local name="$2";
919− local type oldifs;
920− if type=$(declare -p "$name" 2> /dev/null); then
921− local -n nameref="$name";
922− case "${type#* }" in
923− -A*)
924− echo "concatStringsSep(): ERROR: trying to use concatStringsSep on an associative array." 1>&2;
925− return 1
926− ;;
927− -a*)
928− local IFS="$(printf '\036')"
929− ;;
930− *)
931− local IFS=" "
932− ;;
933− esac;
934− local ifs_separated="${nameref[*]}";
935− echo -n "${ifs_separated//"$IFS"/"$sep"}";
936− fi
937−}
938−concatTo ()
939−{
940−
941− local -;
942− set -o noglob;
943− local -n targetref="$1";
944− shift;
945− local arg default name type;
946− for arg in "$@";
947− do
948− IFS="=" read -r name default <<< "$arg";
949− local -n nameref="$name";
950− if [[ -z "${nameref[*]}" && -n "$default" ]]; then
951− targetref+=("$default");
952− else
953− if type=$(declare -p "$name" 2> /dev/null); then
954− case "${type#* }" in
955− -A*)
956− echo "concatTo(): ERROR: trying to use concatTo on an associative array." 1>&2;
957− return 1
958− ;;
959− -a*)
960− targetref+=("${nameref[@]}")
961− ;;
962− *)
963− if [[ "$name" = *"Array" ]]; then
964− nixErrorLog "concatTo(): $name is not declared as array, treating as a singleton. This will become an error in future";
965− targetref+=(${nameref+"${nameref[@]}"});
966− else
967− targetref+=(${nameref-});
968− fi
969− ;;
970− esac;
971− fi;
972− fi;
973− done
974−}
975−configurePhase ()
976−{
977−
978− runHook preConfigure;
979− : "${configureScript=}";
980− if [[ -z "$configureScript" && -x ./configure ]]; then
981− configureScript=./configure;
982− fi;
983− if [ -z "${dontFixLibtool:-}" ]; then
984− export lt_cv_deplibs_check_method="${lt_cv_deplibs_check_method-pass_all}";
985− local i;
986− find . -iname "ltmain.sh" -print0 | while IFS='' read -r -d '' i; do
987− echo "fixing libtool script $i";
988− fixLibtool "$i";
989− done;
990− CONFIGURE_MTIME_REFERENCE=$(mktemp configure.mtime.reference.XXXXXX);
991− find . -executable -type f -name configure -exec grep -l 'GNU Libtool is free software; you can redistribute it and/or modify' {} \; -exec touch -r {} "$CONFIGURE_MTIME_REFERENCE" \; -exec sed -i s_/usr/bin/file_file_g {} \; -exec touch -r "$CONFIGURE_MTIME_REFERENCE" {} \;;
992− rm -f "$CONFIGURE_MTIME_REFERENCE";
993− fi;
994− if [[ -z "${dontAddPrefix:-}" && -n "$prefix" ]]; then
995− local -r prefixKeyOrDefault="${prefixKey:---prefix=}";
996− if [ "${prefixKeyOrDefault: -1}" = " " ]; then
997− prependToVar configureFlags "$prefix";
998− prependToVar configureFlags "${prefixKeyOrDefault::-1}";
999− else
1000− prependToVar configureFlags "$prefixKeyOrDefault$prefix";
1001− fi;
1002− fi;
1003− if [[ -f "$configureScript" ]]; then
1004− if [ -z "${dontAddDisableDepTrack:-}" ]; then
1005− if grep -q dependency-tracking "$configureScript"; then
1006− prependToVar configureFlags --disable-dependency-tracking;
1007− fi;
1008− fi;
1009− if [ -z "${dontDisableStatic:-}" ]; then
1010− if grep -q enable-static "$configureScript"; then
1011− prependToVar configureFlags --disable-static;
1012− fi;
1013− fi;
1014− if [ -z "${dontPatchShebangsInConfigure:-}" ]; then
1015− patchShebangs --build "$configureScript";
1016− fi;
1017− fi;
1018− if [ -n "$configureScript" ]; then
1019− local -a flagsArray;
1020− concatTo flagsArray configureFlags configureFlagsArray;
1021− echoCmd 'configure flags' "${flagsArray[@]}";
1022− $configureScript "${flagsArray[@]}";
1023− unset flagsArray;
1024− else
1025− echo "no configure script, doing nothing";
1026− fi;
1027− runHook postConfigure
1028−}
1029−consumeEntire ()
1030−{
1031−
1032− if IFS='' read -r -d '' "$1"; then
1033− echo "consumeEntire(): ERROR: Input null bytes, won't process" 1>&2;
1034− return 1;
1035− fi
1036−}
1037−definePhases ()
1038−{
1039−
1040− if [ -z "${phases[*]:-}" ]; then
1041− phases="${prePhases[*]:-} unpackPhase patchPhase ${preConfigurePhases[*]:-} configurePhase ${preBuildPhases[*]:-} buildPhase checkPhase ${preInstallPhases[*]:-} installPhase ${preFixupPhases[*]:-} fixupPhase installCheckPhase ${preDistPhases[*]:-} distPhase ${postPhases[*]:-}";
1042− fi
1043−}
1044−distPhase ()
1045−{
1046−
1047− runHook preDist;
1048− local flagsArray=();
1049− concatTo flagsArray distFlags distFlagsArray distTarget=dist;
1050− echo 'dist flags: %q' "${flagsArray[@]}";
1051− make ${makefile:+-f $makefile} "${flagsArray[@]}";
1052− if [ "${dontCopyDist:-0}" != 1 ]; then
1053− mkdir -p "$out/tarballs";
1054− cp -pvd ${tarballs[*]:-*.tar.gz} "$out/tarballs";
1055− fi;
1056− runHook postDist
1057−}
1058−dumpVars ()
1059−{
1060−
1061− if [[ "${noDumpEnvVars:-0}" != 1 && -d "$NIX_BUILD_TOP" ]]; then
1062− local old_umask;
1063− old_umask=$(umask);
1064− umask 0077;
1065− export 2> /dev/null > "$NIX_BUILD_TOP/env-vars";
1066− umask "$old_umask";
1067− fi
1068−}
1069−echoCmd ()
1070−{
1071−
1072− printf "%s:" "$1";
1073− shift;
1074− printf ' %q' "$@";
1075− echo
1076−}
1077−exitHandler ()
1078−{
1079−
1080− exitCode="$?";
1081− set +e;
1082− if [ -n "${showBuildStats:-}" ]; then
1083− read -r -d '' -a buildTimes < <(times);
1084− echo "build times:";
1085− echo "user time for the shell ${buildTimes[0]}";
1086− echo "system time for the shell ${buildTimes[1]}";
1087− echo "user time for all child processes ${buildTimes[2]}";
1088− echo "system time for all child processes ${buildTimes[3]}";
1089− fi;
1090− if (( "$exitCode" != 0 )); then
1091− runHook failureHook;
1092− if [ -n "${succeedOnFailure:-}" ]; then
1093− echo "build failed with exit code $exitCode (ignored)";
1094− mkdir -p "$out/nix-support";
1095− printf "%s" "$exitCode" > "$out/nix-support/failed";
1096− exit 0;
1097− fi;
1098− else
1099− runHook exitHook;
1100− fi;
1101− return "$exitCode"
1102−}
1103−findInputs ()
1104−{
1105−
1106− local -r pkg="$1";
1107− local -r hostOffset="$2";
1108− local -r targetOffset="$3";
1109− (( hostOffset <= targetOffset )) || exit 1;
1110− local varVar="${pkgAccumVarVars[hostOffset + 1]}";
1111− local varRef="$varVar[$((targetOffset - hostOffset))]";
1112− local var="${!varRef}";
1113− unset -v varVar varRef;
1114− local varSlice="$var[*]";
1115− case " ${!varSlice-} " in
1116− *" $pkg "*)
1117− return 0
1118− ;;
1119− esac;
1120− unset -v varSlice;
1121− eval "$var"'+=("$pkg")';
1122− if ! [ -e "$pkg" ]; then
1123− echo "build input $pkg does not exist" 1>&2;
1124− exit 1;
1125− fi;
1126− function mapOffset ()
1127− {
1128− local -r inputOffset="$1";
1129− local -n outputOffset="$2";
1130− if (( inputOffset <= 0 )); then
1131− outputOffset=$((inputOffset + hostOffset));
1132− else
1133− outputOffset=$((inputOffset - 1 + targetOffset));
1134− fi
1135− };
1136− local relHostOffset;
1137− for relHostOffset in "${allPlatOffsets[@]}";
1138− do
1139− local files="${propagatedDepFilesVars[relHostOffset + 1]}";
1140− local hostOffsetNext;
1141− mapOffset "$relHostOffset" hostOffsetNext;
1142− (( -1 <= hostOffsetNext && hostOffsetNext <= 1 )) || continue;
1143− local relTargetOffset;
1144− for relTargetOffset in "${allPlatOffsets[@]}";
1145− do
1146− (( "$relHostOffset" <= "$relTargetOffset" )) || continue;
1147− local fileRef="${files}[$relTargetOffset - $relHostOffset]";
1148− local file="${!fileRef}";
1149− unset -v fileRef;
1150− local targetOffsetNext;
1151− mapOffset "$relTargetOffset" targetOffsetNext;
1152− (( -1 <= hostOffsetNext && hostOffsetNext <= 1 )) || continue;
1153− [[ -f "$pkg/nix-support/$file" ]] || continue;
1154− local pkgNext;
1155− read -r -d '' pkgNext < "$pkg/nix-support/$file" || true;
1156− for pkgNext in $pkgNext;
1157− do
1158− findInputs "$pkgNext" "$hostOffsetNext" "$targetOffsetNext";
1159− done;
1160− done;
1161− done
1162−}
1163−fixLibtool ()
1164−{
1165−
1166− local search_path;
1167− for flag in $NIX_LDFLAGS;
1168− do
1169− case $flag in
1170− -L*)
1171− search_path+=" ${flag#-L}"
1172− ;;
1173− esac;
1174− done;
1175− sed -i "$1" -e "s^eval \(sys_lib_search_path=\).*^\1'${search_path:-}'^" -e 's^eval sys_lib_.+search_path=.*^^'
1176−}
1177−fixupPhase ()
1178−{
1179−
1180− local output;
1181− for output in $(getAllOutputNames);
1182− do
1183− if [ -e "${!output}" ]; then
1184− chmod -R u+w,u-s,g-s "${!output}";
1185− fi;
1186− done;
1187− runHook preFixup;
1188− local output;
1189− for output in $(getAllOutputNames);
1190− do
1191− prefix="${!output}" runHook fixupOutput;
1192− done;
1193− recordPropagatedDependencies;
1194− if [ -n "${setupHook:-}" ]; then
1195− mkdir -p "${!outputDev}/nix-support";
1196− substituteAll "$setupHook" "${!outputDev}/nix-support/setup-hook";
1197− fi;
1198− if [ -n "${setupHooks:-}" ]; then
1199− mkdir -p "${!outputDev}/nix-support";
1200− local hook;
1201− for hook in ${setupHooks[@]};
1202− do
1203− local content;
1204− consumeEntire content < "$hook";
1205− substituteAllStream content "file '$hook'" >> "${!outputDev}/nix-support/setup-hook";
1206− unset -v content;
1207− done;
1208− unset -v hook;
1209− fi;
1210− if [ -n "${propagatedUserEnvPkgs[*]:-}" ]; then
1211− mkdir -p "${!outputBin}/nix-support";
1212− printWords "${propagatedUserEnvPkgs[@]}" > "${!outputBin}/nix-support/propagated-user-env-packages";
1213− fi;
1214− runHook postFixup
1215−}
1216−genericBuild ()
1217−{
1218−
1219− export GZIP_NO_TIMESTAMPS=1;
1220− if [ -f "${buildCommandPath:-}" ]; then
1221− source "$buildCommandPath";
1222− return;
1223− fi;
1224− if [ -n "${buildCommand:-}" ]; then
1225− eval "$buildCommand";
1226− return;
1227− fi;
1228− definePhases;
1229− for curPhase in ${phases[*]};
1230− do
1231− runPhase "$curPhase";
1232− done
1233−}
1234−getAllOutputNames ()
1235−{
1236−
1237− if [ -n "$__structuredAttrs" ]; then
1238− echo "${!outputs[*]}";
1239− else
1240− echo "$outputs";
1241− fi
1242−}
1243−getHostRole ()
1244−{
1245−
1246− getRole "$hostOffset"
1247−}
1248−getHostRoleEnvHook ()
1249−{
1250−
1251− getRole "$depHostOffset"
1252−}
1253−getRole ()
1254−{
1255−
1256− case $1 in
1257− -1)
1258− role_post='_FOR_BUILD'
1259− ;;
1260− 0)
1261− role_post=''
1262− ;;
1263− 1)
1264− role_post='_FOR_TARGET'
1265− ;;
1266− *)
1267− echo "binutils-wrapper-2.46: used as improper sort of dependency" 1>&2;
1268− return 1
1269− ;;
1270− esac
1271−}
1272−getTargetRole ()
1273−{
1274−
1275− getRole "$targetOffset"
1276−}
1277−getTargetRoleEnvHook ()
1278−{
1279−
1280− getRole "$depTargetOffset"
1281−}
1282−getTargetRoleWrapper ()
1283−{
1284−
1285− case $targetOffset in
1286− -1)
1287− export NIX_BINTOOLS_WRAPPER_TARGET_BUILD_x86_64_unknown_linux_gnu=1
1288− ;;
1289− 0)
1290− export NIX_BINTOOLS_WRAPPER_TARGET_HOST_x86_64_unknown_linux_gnu=1
1291− ;;
1292− 1)
1293− export NIX_BINTOOLS_WRAPPER_TARGET_TARGET_x86_64_unknown_linux_gnu=1
1294− ;;
1295− *)
1296− echo "binutils-wrapper-2.46: used as improper sort of dependency" 1>&2;
1297− return 1
1298− ;;
1299− esac
1300−}
1301−gradle ()
1302−{
1303−
1304− local flagsArray=();
1305− concatTo flagsArray gradleFlags gradleFlagsArray;
1306− command gradle "${flagsArray[@]}" "$@"
1307−}
1308−gradleBuildPhase ()
1309−{
1310−
1311− runHook preBuild;
1312− gradle ${enableParallelBuilding:+--parallel} ${gradleBuildTask:-assemble};
1313− runHook postBuild
1314−}
1315−gradleCheckPhase ()
1316−{
1317−
1318− runHook preCheck;
1319− gradle ${enableParallelChecking:+--parallel} ${gradleCheckTask:-test};
1320− runHook postCheck
1321−}
1322−gradleConfigureHook ()
1323−{
1324−
1325− if [ -z "${GRADLE_USER_HOME-}" ]; then
1326− GRADLE_USER_HOME="$(mktemp -d)";
1327− fi;
1328− export GRADLE_USER_HOME;
1329− export TERM=dumb;
1330− gradleFlagsArray+=(--no-daemon -Dorg.gradle.console=plain --init-script "${gradleInitScript:-/nix/store/qyybvyq09wv36br9lfd8jac92fgwy8f2-init-build.gradle}");
1331− if [ -n "${MITM_CACHE_CA-}" ]; then
1332− if [ -z "${MITM_CACHE_KEYSTORE-}" ]; then
1333− MITM_CACHE_KEYSTORE="$MITM_CACHE_CERT_DIR/keystore";
1334− MITM_CACHE_KS_PWD="$(head -c10 /dev/random | base32)";
1335− echo y | /nix/store/vs2j17dr25ph3h1k4w1ngxnn8y5br1sa-openjdk-21.0.12+2/bin/keytool -importcert -file "$MITM_CACHE_CA" -alias alias -keystore "$MITM_CACHE_KEYSTORE" -storepass "$MITM_CACHE_KS_PWD";
1336− fi;
1337− gradleFlagsArray+=(-Dhttp.proxyHost="$MITM_CACHE_HOST" -Dhttp.proxyPort="$MITM_CACHE_PORT");
1338− gradleFlagsArray+=(-Dhttps.proxyHost="$MITM_CACHE_HOST" -Dhttps.proxyPort="$MITM_CACHE_PORT");
1339− gradleFlagsArray+=(-Djavax.net.ssl.trustStore="$MITM_CACHE_KEYSTORE" -Djavax.net.ssl.trustStorePassword="$MITM_CACHE_KS_PWD");
1340− else
1341− gradleFlagsArray+=(--offline);
1342− fi;
1343− if ! [[ -v enableParallelBuilding ]]; then
1344− enableParallelBuilding=1;
1345− fi;
1346− if ! [[ -v enableParallelChecking ]]; then
1347− enableParallelChecking=1;
1348− fi;
1349− if ! [[ -v enableParallelUpdating ]]; then
1350− enableParallelUpdating=1;
1351− fi
1352−}
1353−gradleUpdateScript ()
1354−{
1355−
1356− runHook preBuild;
1357− runHook preGradleUpdate;
1358− gradle ${enableParallelUpdating:+--parallel} ${gradleUpdateTask:-nixDownloadDeps};
1359− runHook postGradleUpdate
1360−}
1361−installCheckPhase ()
1362−{
1363−
1364− runHook preInstallCheck;
1365− if [[ -z "${foundMakefile:-}" ]]; then
1366− echo "no Makefile or custom installCheckPhase, doing nothing";
1367− else
1368− if [[ -z "${installCheckTarget:-}" ]] && ! make -n ${makefile:+-f $makefile} "${installCheckTarget:-installcheck}" > /dev/null 2>&1; then
1369− echo "no installcheck target in ${makefile:-Makefile}, doing nothing";
1370− else
1371− local flagsArray=(${enableParallelChecking:+-j${NIX_BUILD_CORES}} SHELL="$SHELL");
1372− concatTo flagsArray makeFlags makeFlagsArray installCheckFlags installCheckFlagsArray installCheckTarget=installcheck;
1373− echoCmd 'installcheck flags' "${flagsArray[@]}";
1374− make ${makefile:+-f $makefile} "${flagsArray[@]}";
1375− unset flagsArray;
1376− fi;
1377− fi;
1378− runHook postInstallCheck
1379−}
1380−installPhase ()
1381−{
1382−
1383− runHook preInstall;
1384− if [[ -z "${makeFlags-}" && -z "${makefile:-}" && ! ( -e Makefile || -e makefile || -e GNUmakefile ) ]]; then
1385− echo "no Makefile or custom installPhase, doing nothing";
1386− runHook postInstall;
1387− return;
1388− else
1389− foundMakefile=1;
1390− fi;
1391− if [ -n "$prefix" ]; then
1392− mkdir -p "$prefix";
1393− fi;
1394− local flagsArray=(${enableParallelInstalling:+-j${NIX_BUILD_CORES}} SHELL="$SHELL");
1395− concatTo flagsArray makeFlags makeFlagsArray installFlags installFlagsArray installTargets=install;
1396− echoCmd 'install flags' "${flagsArray[@]}";
1397− make ${makefile:+-f $makefile} "${flagsArray[@]}";
1398− unset flagsArray;
1399− runHook postInstall
1400−}
1401−isELF ()
1402−{
1403−
1404− local fn="$1";
1405− local fd;
1406− local magic;
1407− exec {fd}< "$fn";
1408− LANG=C read -r -n 4 -u "$fd" magic;
1409− exec {fd}>&-;
1410− if [ "$magic" = 'ELF' ]; then
1411− return 0;
1412− else
1413− return 1;
1414− fi
1415−}
1416−isMachO ()
1417−{
1418−
1419− local fn="$1";
1420− local fd;
1421− local magic;
1422− exec {fd}< "$fn";
1423− LANG=C read -r -n 4 -u "$fd" magic;
1424− exec {fd}>&-;
1425− if [[ "$magic" = $(echo -ne "\xfe\xed\xfa\xcf") || "$magic" = $(echo -ne "\xcf\xfa\xed\xfe") ]]; then
1426− return 0;
1427− else
1428− if [[ "$magic" = $(echo -ne "\xfe\xed\xfa\xce") || "$magic" = $(echo -ne "\xce\xfa\xed\xfe") ]]; then
1429− return 0;
1430− else
1431− if [[ "$magic" = $(echo -ne "\xca\xfe\xba\xbe") || "$magic" = $(echo -ne "\xbe\xba\xfe\xca") ]]; then
1432− return 0;
1433− else
1434− return 1;
1435− fi;
1436− fi;
1437− fi
1438−}
1439−isScript ()
1440−{
1441−
1442− local fn="$1";
1443− local fd;
1444− local magic;
1445− exec {fd}< "$fn";
1446− LANG=C read -r -n 2 -u "$fd" magic;
1447− exec {fd}>&-;
1448− if [[ "$magic" =~ \#! ]]; then
1449− return 0;
1450− else
1451− return 1;
1452− fi
1453−}
1454−mapOffset ()
1455−{
1456−
1457− local -r inputOffset="$1";
1458− local -n outputOffset="$2";
1459− if (( inputOffset <= 0 )); then
1460− outputOffset=$((inputOffset + hostOffset));
1461− else
1462− outputOffset=$((inputOffset - 1 + targetOffset));
1463− fi
1464−}
1465−mitmCacheConfigureHook ()
1466−{
1467−
1468− if [ -d "$mitmCache" ] && [ -z "$MITM_CACHE_CERT_DIR" ]; then
1469− MITM_CACHE_CERT_DIR="$(mktemp -d)";
1470− pushd "$MITM_CACHE_CERT_DIR";
1471− MITM_CACHE_CA="$MITM_CACHE_CERT_DIR/ca.cer";
1472− /nix/store/clvbx203wyqkdnqc5ngyhjlhcqn5s9x9-openssl-3.6.2-bin/bin/openssl genrsa -out ca.key 2048;
1473− /nix/store/clvbx203wyqkdnqc5ngyhjlhcqn5s9x9-openssl-3.6.2-bin/bin/openssl req -x509 -new -nodes -key ca.key -sha256 -days 1 -out ca.cer -subj "/C=AL/ST=a/L=a/O=a/OU=a/CN=example.org";
1474− MITM_CACHE_HOST="127.0.0.1";
1475− MITM_CACHE_PORT="${mitmCachePort:-$(/nix/store/ibdb2kp2gkqqbicwxp663s39sd5824zg-python3.13-ephemeral-port-reserve-1.1.4/bin/ephemeral-port-reserve "$MITM_CACHE_HOST")}";
1476− MITM_CACHE_ADDRESS="$MITM_CACHE_HOST:$MITM_CACHE_PORT";
1477− export http_proxy="$MITM_CACHE_ADDRESS";
1478− export https_proxy="$MITM_CACHE_ADDRESS";
1479− export SSL_CERT_FILE="$MITM_CACHE_CA";
1480− export NIX_SSL_CERT_FILE="$MITM_CACHE_CA";
1481− mitm-cache -l"$MITM_CACHE_ADDRESS" replay "$mitmCache" > /dev/null 2> /dev/null & popd;
1482− fi
1483−}
1484−moveToOutput ()
1485−{
1486−
1487− local patt="$1";
1488− local dstOut="$2";
1489− local output;
1490− for output in $(getAllOutputNames);
1491− do
1492− if [ "${!output}" = "$dstOut" ]; then
1493− continue;
1494− fi;
1495− local srcPath;
1496− for srcPath in "${!output}"/$patt;
1497− do
1498− if [ ! -e "$srcPath" ] && [ ! -L "$srcPath" ]; then
1499− continue;
1500− fi;
1501− if [ "$dstOut" = REMOVE ]; then
1502− echo "Removing $srcPath";
1503− rm -r "$srcPath";
1504− else
1505− local dstPath="$dstOut${srcPath#${!output}}";
1506− echo "Moving $srcPath to $dstPath";
1507− if [ -d "$dstPath" ] && [ -d "$srcPath" ]; then
1508− rmdir "$srcPath" --ignore-fail-on-non-empty;
1509− if [ -d "$srcPath" ]; then
1510− mv -t "$dstPath" "$srcPath"/*;
1511− rmdir "$srcPath";
1512− fi;
1513− else
1514− mkdir -p "$(readlink -m "$dstPath/..")";
1515− mv "$srcPath" "$dstPath";
1516− fi;
1517− fi;
1518− local srcParent="$(readlink -m "$srcPath/..")";
1519− if [ -n "$(find "$srcParent" -maxdepth 0 -type d -empty 2> /dev/null)" ]; then
1520− echo "Removing empty $srcParent/ and (possibly) its parents";
1521− rmdir -p --ignore-fail-on-non-empty "$srcParent" 2> /dev/null || true;
1522− fi;
1523− done;
1524− done
1525−}
1526−nixChattyLog ()
1527−{
1528−
1529− _nixLogWithLevel 5 "$*"
1530−}
1531−nixDebugLog ()
1532−{
1533−
1534− _nixLogWithLevel 6 "$*"
1535−}
1536−nixErrorLog ()
1537−{
1538−
1539− _nixLogWithLevel 0 "$*"
1540−}
1541−nixInfoLog ()
1542−{
1543−
1544− _nixLogWithLevel 3 "$*"
1545−}
1546−nixLog ()
1547−{
1548−
1549− [[ -z ${NIX_LOG_FD-} ]] && return 0;
1550− local callerName="${FUNCNAME[1]}";
1551− if [[ $callerName == "_callImplicitHook" ]]; then
1552− callerName="${hookName:?}";
1553− fi;
1554− printf "%s: %s\n" "$callerName" "$*" >&"$NIX_LOG_FD"
1555−}
1556−nixNoticeLog ()
1557−{
1558−
1559− _nixLogWithLevel 2 "$*"
1560−}
1561−nixTalkativeLog ()
1562−{
1563−
1564− _nixLogWithLevel 4 "$*"
1565−}
1566−nixVomitLog ()
1567−{
1568−
1569− _nixLogWithLevel 7 "$*"
1570−}
1571−nixWarnLog ()
1572−{
1573−
1574− _nixLogWithLevel 1 "$*"
1575−}
1576−noBrokenSymlinks ()
1577−{
1578−
1579− local -r output="${1:?}";
1580− local path;
1581− local pathParent;
1582− local symlinkTarget;
1583− local -i numDanglingSymlinks=0;
1584− local -i numReflexiveSymlinks=0;
1585− local -i numUnreadableSymlinks=0;
1586− if [[ ! -e $output ]]; then
1587− nixWarnLog "skipping non-existent output $output";
1588− return 0;
1589− fi;
1590− nixInfoLog "running on $output";
1591− while IFS= read -r -d '' path; do
1592− pathParent="$(dirname "$path")";
1593− if ! symlinkTarget="$(readlink "$path")"; then
1594− nixErrorLog "the symlink $path is unreadable";
1595− numUnreadableSymlinks+=1;
1596− continue;
1597− fi;
1598− if [[ $symlinkTarget == /* ]]; then
1599− nixInfoLog "symlink $path points to absolute target $symlinkTarget";
1600− else
1601− nixInfoLog "symlink $path points to relative target $symlinkTarget";
1602− symlinkTarget="$(realpath --no-symlinks --canonicalize-missing "$pathParent/$symlinkTarget")";
1603− fi;
1604− if [[ $symlinkTarget = "$TMPDIR"/* ]]; then
1605− nixErrorLog "the symlink $path points to $TMPDIR directory: $symlinkTarget";
1606− numDanglingSymlinks+=1;
1607− continue;
1608− fi;
1609− if [[ $symlinkTarget != "$NIX_STORE"/* ]]; then
1610− nixInfoLog "symlink $path points outside the Nix store; ignoring";
1611− continue;
1612− fi;
1613− if [[ $path == "$symlinkTarget" ]]; then
1614− nixErrorLog "the symlink $path is reflexive";
1615− numReflexiveSymlinks+=1;
1616− else
1617− if [[ ! -e $symlinkTarget ]]; then
1618− nixErrorLog "the symlink $path points to a missing target: $symlinkTarget";
1619− numDanglingSymlinks+=1;
1620− else
1621− nixDebugLog "the symlink $path is irreflexive and points to a target which exists";
1622− fi;
1623− fi;
1624− done < <(find "$output" -type l -print0);
1625− if ((numDanglingSymlinks > 0 || numReflexiveSymlinks > 0 || numUnreadableSymlinks > 0)); then
1626− nixErrorLog "found $numDanglingSymlinks dangling symlinks, $numReflexiveSymlinks reflexive symlinks and $numUnreadableSymlinks unreadable symlinks";
1627− exit 1;
1628− fi;
1629− return 0
1630−}
1631−noBrokenSymlinksInAllOutputs ()
1632−{
1633−
1634− if [[ -z ${dontCheckForBrokenSymlinks-} ]]; then
1635− for output in $(getAllOutputNames);
1636− do
1637− noBrokenSymlinks "${!output}";
1638− done;
1639− fi
1640−}
1641−patchELF ()
1642−{
1643−
1644− local dir="$1";
1645− [ -e "$dir" ] || return 0;
1646− echo "shrinking RPATHs of ELF executables and libraries in $dir";
1647− local i;
1648− while IFS= read -r -d '' i; do
1649− if [[ "$i" =~ .build-id ]]; then
1650− continue;
1651− fi;
1652− if ! isELF "$i"; then
1653− continue;
1654− fi;
1655− echo "shrinking $i";
1656− patchelf --shrink-rpath "$i" || true;
1657− done < <(find "$dir" -type f -print0)
1658−}
1659−patchPhase ()
1660−{
1661−
1662− runHook prePatch;
1663− local -a patchesArray;
1664− concatTo patchesArray patches;
1665− local -a flagsArray;
1666− concatTo flagsArray patchFlags=-p1;
1667− for i in "${patchesArray[@]}";
1668− do
1669− echo "applying patch $i";
1670− local uncompress=cat;
1671− case "$i" in
1672− *.gz)
1673− uncompress="gzip -d"
1674− ;;
1675− *.bz2)
1676− uncompress="bzip2 -d"
1677− ;;
1678− *.xz)
1679− uncompress="xz -d"
1680− ;;
1681− *.lzma)
1682− uncompress="lzma -d"
1683− ;;
1684− esac;
1685− $uncompress < "$i" 2>&1 | patch "${flagsArray[@]}";
1686− done;
1687− runHook postPatch
1688−}
1689−patchShebangs ()
1690−{
1691−
1692− local pathName;
1693− local update=false;
1694− while [[ $# -gt 0 ]]; do
1695− case "$1" in
1696− --host)
1697− pathName=HOST_PATH;
1698− shift
1699− ;;
1700− --build)
1701− pathName=PATH;
1702− shift
1703− ;;
1704− --update)
1705− update=true;
1706− shift
1707− ;;
1708− --)
1709− shift;
1710− break
1711− ;;
1712− -* | --*)
1713− echo "Unknown option $1 supplied to patchShebangs" 1>&2;
1714− return 1
1715− ;;
1716− *)
1717− break
1718− ;;
1719− esac;
1720− done;
1721− echo "patching script interpreter paths in $@";
1722− local f;
1723− local oldPath;
1724− local newPath;
1725− local arg0;
1726− local args;
1727− local oldInterpreterLine;
1728− local newInterpreterLine;
1729− if [[ $# -eq 0 ]]; then
1730− echo "No arguments supplied to patchShebangs" 1>&2;
1731− return 0;
1732− fi;
1733− local f;
1734− while IFS= read -r -d '' f; do
1735− isScript "$f" || continue;
1736− read -r oldInterpreterLine < "$f" || [ "$oldInterpreterLine" ];
1737− read -r oldPath arg0 args <<< "${oldInterpreterLine:2}";
1738− if [[ -z "${pathName:-}" ]]; then
1739− if [[ -n $strictDeps && $f == "$NIX_STORE"* ]]; then
1740− pathName=HOST_PATH;
1741− else
1742− pathName=PATH;
1743− fi;
1744− fi;
1745− if [[ "$oldPath" == *"/bin/env" ]]; then
1746− if [[ $arg0 == "-S" ]]; then
1747− arg0=${args%% *};
1748− [[ "$args" == *" "* ]] && args=${args#* } || args=;
1749− newPath="$(PATH="${!pathName}" type -P "env" || true)";
1750− args="-S $(PATH="${!pathName}" type -P "$arg0" || true) $args";
1751− else
1752− if [[ $arg0 == "-"* || $arg0 == *"="* ]]; then
1753− echo "$f: unsupported interpreter directive \"$oldInterpreterLine\" (set dontPatchShebangs=1 and handle shebang patching yourself)" 1>&2;
1754− exit 1;
1755− else
1756− newPath="$(PATH="${!pathName}" type -P "$arg0" || true)";
1757− fi;
1758− fi;
1759− else
1760− if [[ -z $oldPath ]]; then
1761− oldPath="/bin/sh";
1762− fi;
1763− newPath="$(PATH="${!pathName}" type -P "$(basename "$oldPath")" || true)";
1764− args="$arg0 $args";
1765− fi;
1766− newInterpreterLine="$newPath $args";
1767− newInterpreterLine=${newInterpreterLine%${newInterpreterLine##*[![:space:]]}};
1768− if [[ -n "$oldPath" && ( "$update" == true || "${oldPath:0:${#NIX_STORE}}" != "$NIX_STORE" ) ]]; then
1769− if [[ -n "$newPath" && "$newPath" != "$oldPath" ]]; then
1770− echo "$f: interpreter directive changed from \"$oldInterpreterLine\" to \"$newInterpreterLine\"";
1771− escapedInterpreterLine=${newInterpreterLine//\\/\\\\};
1772− timestamp=$(stat --printf "%y" "$f");
1773− tmpFile=$(mktemp -t patchShebangs.XXXXXXXXXX);
1774− sed -e "1 s|.*|#\!$escapedInterpreterLine|" "$f" > "$tmpFile";
1775− local restoreReadOnly;
1776− if [[ ! -w "$f" ]]; then
1777− chmod +w "$f";
1778− restoreReadOnly=true;
1779− fi;
1780− cat "$tmpFile" > "$f";
1781− rm "$tmpFile";
1782− if [[ -n "${restoreReadOnly:-}" ]]; then
1783− chmod -w "$f";
1784− fi;
1785− touch --date "$timestamp" "$f";
1786− fi;
1787− fi;
1788− done < <(find "$@" -type f -perm -0100 -print0)
1789−}
1790−patchShebangsAuto ()
1791−{
1792−
1793− if [[ -z "${dontPatchShebangs-}" && -e "$prefix" ]]; then
1794− if [[ "$output" != out && "$output" = "$outputDev" ]]; then
1795− patchShebangs --build "$prefix";
1796− else
1797− patchShebangs --host "$prefix";
1798− fi;
1799− fi
1800−}
1801−prependToVar ()
1802−{
1803−
1804− local -n nameref="$1";
1805− local useArray type;
1806− if [ -n "$__structuredAttrs" ]; then
1807− useArray=true;
1808− else
1809− useArray=false;
1810− fi;
1811− if type=$(declare -p "$1" 2> /dev/null); then
1812− case "${type#* }" in
1813− -A*)
1814− echo "prependToVar(): ERROR: trying to use prependToVar on an associative array." 1>&2;
1815− return 1
1816− ;;
1817− -a*)
1818− useArray=true
1819− ;;
1820− *)
1821− useArray=false
1822− ;;
1823− esac;
1824− fi;
1825− shift;
1826− if $useArray; then
1827− nameref=("$@" ${nameref+"${nameref[@]}"});
1828− else
1829− nameref="$* ${nameref-}";
1830− fi
1831−}
1832−printLines ()
1833−{
1834−
1835− (( "$#" > 0 )) || return 0;
1836− printf '%s\n' "$@"
1837−}
1838−printPhases ()
1839−{
1840−
1841− definePhases;
1842− local phase;
1843− for phase in ${phases[*]};
1844− do
1845− printf '%s\n' "$phase";
1846− done
1847−}
1848−printWords ()
1849−{
1850−
1851− (( "$#" > 0 )) || return 0;
1852− printf '%s ' "$@"
1853−}
1854−recordPropagatedDependencies ()
1855−{
1856−
1857− declare -ra flatVars=(depsBuildBuildPropagated propagatedNativeBuildInputs depsBuildTargetPropagated depsHostHostPropagated propagatedBuildInputs depsTargetTargetPropagated);
1858− declare -ra flatFiles=("${propagatedBuildDepFiles[@]}" "${propagatedHostDepFiles[@]}" "${propagatedTargetDepFiles[@]}");
1859− local propagatedInputsIndex;
1860− for propagatedInputsIndex in "${!flatVars[@]}";
1861− do
1862− local propagatedInputsSlice="${flatVars[$propagatedInputsIndex]}[@]";
1863− local propagatedInputsFile="${flatFiles[$propagatedInputsIndex]}";
1864− [[ -n "${!propagatedInputsSlice}" ]] || continue;
1865− mkdir -p "${!outputDev}/nix-support";
1866− printWords ${!propagatedInputsSlice} > "${!outputDev}/nix-support/$propagatedInputsFile";
1867− done
1868−}
1869−runHook ()
1870−{
1871−
1872− local hookName="$1";
1873− shift;
1874− local hooksSlice="${hookName%Hook}Hooks[@]";
1875− local hook;
1876− for hook in "_callImplicitHook 0 $hookName" ${!hooksSlice+"${!hooksSlice}"};
1877− do
1878− _logHook "$hookName" "$hook" "$@";
1879− _eval "$hook" "$@";
1880− done;
1881− return 0
1882−}
1883−runOneHook ()
1884−{
1885−
1886− local hookName="$1";
1887− shift;
1888− local hooksSlice="${hookName%Hook}Hooks[@]";
1889− local hook ret=1;
1890− for hook in "_callImplicitHook 1 $hookName" ${!hooksSlice+"${!hooksSlice}"};
1891− do
1892− _logHook "$hookName" "$hook" "$@";
1893− if _eval "$hook" "$@"; then
1894− ret=0;
1895− break;
1896− fi;
1897− done;
1898− return "$ret"
1899−}
1900−runPhase ()
1901−{
1902−
1903− local curPhase="$*";
1904− if [[ "$curPhase" = unpackPhase && -n "${dontUnpack:-}" ]]; then
1905− return;
1906− fi;
1907− if [[ "$curPhase" = patchPhase && -n "${dontPatch:-}" ]]; then
1908− return;
1909− fi;
1910− if [[ "$curPhase" = configurePhase && -n "${dontConfigure:-}" ]]; then
1911− return;
1912− fi;
1913− if [[ "$curPhase" = buildPhase && -n "${dontBuild:-}" ]]; then
1914− return;
1915− fi;
1916− if [[ "$curPhase" = checkPhase && -z "${doCheck:-}" ]]; then
1917− return;
1918− fi;
1919− if [[ "$curPhase" = installPhase && -n "${dontInstall:-}" ]]; then
1920− return;
1921− fi;
1922− if [[ "$curPhase" = fixupPhase && -n "${dontFixup:-}" ]]; then
1923− return;
1924− fi;
1925− if [[ "$curPhase" = installCheckPhase && -z "${doInstallCheck:-}" ]]; then
1926− return;
1927− fi;
1928− if [[ "$curPhase" = distPhase && -z "${doDist:-}" ]]; then
1929− return;
1930− fi;
1931− showPhaseHeader "$curPhase";
1932− dumpVars;
1933− local startTime endTime;
1934− startTime=$(date +"%s");
1935− eval "${!curPhase:-$curPhase}";
1936− endTime=$(date +"%s");
1937− showPhaseFooter "$curPhase" "$startTime" "$endTime";
1938− if [ "$curPhase" = unpackPhase ]; then
1939− [ -n "${sourceRoot:-}" ] && chmod +x -- "${sourceRoot}";
1940− cd -- "${sourceRoot:-.}";
1941− fi
1942−}
1943−showPhaseFooter ()
1944−{
1945−
1946− local phase="$1";
1947− local startTime="$2";
1948− local endTime="$3";
1949− local delta=$(( endTime - startTime ));
1950− (( delta < 30 )) && return;
1951− local H=$((delta/3600));
1952− local M=$((delta%3600/60));
1953− local S=$((delta%60));
1954− echo -n "$phase completed in ";
1955− (( H > 0 )) && echo -n "$H hours ";
1956− (( M > 0 )) && echo -n "$M minutes ";
1957− echo "$S seconds"
1958−}
1959−showPhaseHeader ()
1960−{
1961−
1962− local phase="$1";
1963− echo "Running phase: $phase";
1964− if [[ -z ${NIX_LOG_FD-} ]]; then
1965− return;
1966− fi;
1967− printf "@nix { \"action\": \"setPhase\", \"phase\": \"%s\" }\n" "$phase" >&"$NIX_LOG_FD"
1968−}
1969−stripDirs ()
1970−{
1971−
1972− local cmd="$1";
1973− local ranlibCmd="$2";
1974− local paths="$3";
1975− local stripFlags="$4";
1976− local excludeFlags=();
1977− local pathsNew=;
1978− [ -z "$cmd" ] && echo "stripDirs: Strip command is empty" 1>&2 && exit 1;
1979− [ -z "$ranlibCmd" ] && echo "stripDirs: Ranlib command is empty" 1>&2 && exit 1;
1980− local pattern;
1981− if [ -n "${stripExclude:-}" ]; then
1982− for pattern in "${stripExclude[@]}";
1983− do
1984− excludeFlags+=(-a '!' '(' -name "$pattern" -o -wholename "$prefix/$pattern" ')');
1985− done;
1986− fi;
1987− local p;
1988− for p in ${paths};
1989− do
1990− if [ -e "$prefix/$p" ]; then
1991− pathsNew="${pathsNew} $prefix/$p";
1992− fi;
1993− done;
1994− paths=${pathsNew};
1995− if [ -n "${paths}" ]; then
1996− echo "stripping (with command $cmd and flags $stripFlags) in $paths";
1997− local striperr;
1998− striperr="$(mktemp --tmpdir="$TMPDIR" 'striperr.XXXXXX')";
1999− find $paths -type f "${excludeFlags[@]}" -a '!' -path "$prefix/lib/debug/*" -printf '%D-%i,%p\0' | sort -t, -k1,1 -u -z | cut -d, -f2- -z | xargs -r -0 -n1 -P "$NIX_BUILD_CORES" -- $cmd $stripFlags 2> "$striperr" || exit_code=$?;
2000− [[ "$exit_code" = 123 || -z "$exit_code" ]] || ( cat "$striperr" 1>&2 && exit 1 );
2001− rm "$striperr";
2002− find $paths -name '*.a' -type f -exec $ranlibCmd '{}' \; 2> /dev/null;
2003− fi
2004−}
2005−stripHash ()
2006−{
2007−
2008− local strippedName casematchOpt=0;
2009− strippedName="$(basename -- "$1")";
2010− shopt -q nocasematch && casematchOpt=1;
2011− shopt -u nocasematch;
2012− if [[ "$strippedName" =~ ^[a-z0-9]{32}- ]]; then
2013− echo "${strippedName:33}";
2014− else
2015− echo "$strippedName";
2016− fi;
2017− if (( casematchOpt )); then
2018− shopt -s nocasematch;
2019− fi
2020−}
2021−substitute ()
2022−{
2023−
2024− local input="$1";
2025− local output="$2";
2026− shift 2;
2027− if [ ! -f "$input" ]; then
2028− echo "substitute(): ERROR: file '$input' does not exist" 1>&2;
2029− return 1;
2030− fi;
2031− local content;
2032− consumeEntire content < "$input";
2033− if [ -e "$output" ]; then
2034− chmod +w "$output";
2035− fi;
2036− substituteStream content "file '$input'" "$@" > "$output"
2037−}
2038−substituteAll ()
2039−{
2040−
2041− local input="$1";
2042− local output="$2";
2043− local -a args=();
2044− _allFlags;
2045− substitute "$input" "$output" "${args[@]}"
2046−}
2047−substituteAllInPlace ()
2048−{
2049−
2050− local fileName="$1";
2051− shift;
2052− substituteAll "$fileName" "$fileName" "$@"
2053−}
2054−substituteAllStream ()
2055−{
2056−
2057− local -a args=();
2058− _allFlags;
2059− substituteStream "$1" "$2" "${args[@]}"
2060−}
2061−substituteInPlace ()
2062−{
2063−
2064− local -a fileNames=();
2065− for arg in "$@";
2066− do
2067− if [[ "$arg" = "--"* ]]; then
2068− break;
2069− fi;
2070− fileNames+=("$arg");
2071− shift;
2072− done;
2073− if ! [[ "${#fileNames[@]}" -gt 0 ]]; then
2074− echo "substituteInPlace called without any files to operate on (files must come before options!)" 1>&2;
2075− return 1;
2076− fi;
2077− for file in "${fileNames[@]}";
2078− do
2079− substitute "$file" "$file" "$@";
2080− done
2081−}
2082−substituteStream ()
2083−{
2084−
2085− local var=$1;
2086− local description=$2;
2087− shift 2;
2088− while (( "$#" )); do
2089− local replace_mode="$1";
2090− case "$1" in
2091− --replace)
2092− if ! "$_substituteStream_has_warned_replace_deprecation"; then
2093− echo "substituteStream() in derivation $name: WARNING: '--replace' is deprecated, use --replace-{fail,warn,quiet}. ($description)" 1>&2;
2094− _substituteStream_has_warned_replace_deprecation=true;
2095− fi;
2096− replace_mode='--replace-warn'
2097− ;&
2098− --replace-quiet | --replace-warn | --replace-fail)
2099− pattern="$2";
2100− replacement="$3";
2101− shift 3;
2102− if ! [[ "${!var}" == *"$pattern"* ]]; then
2103− if [ "$replace_mode" == --replace-warn ]; then
2104− printf "substituteStream() in derivation $name: WARNING: pattern %q doesn't match anything in %s\n" "$pattern" "$description" 1>&2;
2105− else
2106− if [ "$replace_mode" == --replace-fail ]; then
2107− printf "substituteStream() in derivation $name: ERROR: pattern %q doesn't match anything in %s\n" "$pattern" "$description" 1>&2;
2108− return 1;
2109− fi;
2110− fi;
2111− fi;
2112− eval "$var"'=${'"$var"'//"$pattern"/"$replacement"}'
2113− ;;
2114− --subst-var)
2115− local varName="$2";
2116− shift 2;
2117− if ! [[ "$varName" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]]; then
2118− echo "substituteStream() in derivation $name: ERROR: substitution variables must be valid Bash names, \"$varName\" isn't." 1>&2;
2119− return 1;
2120− fi;
2121− if [ -z ${!varName+x} ]; then
2122− echo "substituteStream() in derivation $name: ERROR: variable \$$varName is unset" 1>&2;
2123− return 1;
2124− fi;
2125− pattern="@$varName@";
2126− replacement="${!varName}";
2127− eval "$var"'=${'"$var"'//"$pattern"/"$replacement"}'
2128− ;;
2129− --subst-var-by)
2130− pattern="@$2@";
2131− replacement="$3";
2132− eval "$var"'=${'"$var"'//"$pattern"/"$replacement"}';
2133− shift 3
2134− ;;
2135− *)
2136− echo "substituteStream() in derivation $name: ERROR: Invalid command line argument: $1" 1>&2;
2137− return 1
2138− ;;
2139− esac;
2140− done;
2141− printf "%s" "${!var}"
2142−}
2143−unpackFile ()
2144−{
2145−
2146− curSrc="$1";
2147− echo "unpacking source archive $curSrc";
2148− if ! runOneHook unpackCmd "$curSrc"; then
2149− echo "do not know how to unpack source archive $curSrc";
2150− exit 1;
2151− fi
2152−}
2153−unpackPhase ()
2154−{
2155−
2156− runHook preUnpack;
2157− if [ -z "${srcs:-}" ]; then
2158− if [ -z "${src:-}" ]; then
2159− echo 'variable $src or $srcs should point to the source';
2160− exit 1;
2161− fi;
2162− srcs="$src";
2163− fi;
2164− local -a srcsArray;
2165− concatTo srcsArray srcs;
2166− local dirsBefore="";
2167− for i in *;
2168− do
2169− if [ -d "$i" ]; then
2170− dirsBefore="$dirsBefore $i ";
2171− fi;
2172− done;
2173− for i in "${srcsArray[@]}";
2174− do
2175− unpackFile "$i";
2176− done;
2177− : "${sourceRoot=}";
2178− if [ -n "${setSourceRoot:-}" ]; then
2179− runOneHook setSourceRoot;
2180− else
2181− if [ -z "$sourceRoot" ]; then
2182− for i in *;
2183− do
2184− if [ -d "$i" ]; then
2185− case $dirsBefore in
2186− *\ $i\ *)
2187−
2188− ;;
2189− *)
2190− if [ -n "$sourceRoot" ]; then
2191− echo "unpacker produced multiple directories";
2192− exit 1;
2193− fi;
2194− sourceRoot="$i"
2195− ;;
2196− esac;
2197− fi;
2198− done;
2199− fi;
2200− fi;
2201− if [ -z "$sourceRoot" ]; then
2202− echo "unpacker appears to have produced no directories";
2203− exit 1;
2204− fi;
2205− echo "source root is $sourceRoot";
2206− if [ "${dontMakeSourcesWritable:-0}" != 1 ]; then
2207− chmod -R u+w -- "$sourceRoot";
2208− fi;
2209− runHook postUnpack
2210−}
2211−updateAutotoolsGnuConfigScriptsPhase ()
2212−{
2213−
2214− if [ -n "${dontUpdateAutotoolsGnuConfigScripts-}" ]; then
2215− return;
2216− fi;
2217− for script in config.sub config.guess;
2218− do
2219− for f in $(find . -type f -name "$script");
2220− do
2221− echo "Updating Autotools / GNU config script to a newer upstream version: $f";
2222− cp -f "/nix/store/4i7gvqwiq5mbl5pxh43frzq2nibxvwb5-gnu-config-2024-01-01/$script" "$f";
2223− done;
2224− done
2225−}
2226−updateSourceDateEpoch ()
2227−{
2228−
2229− local path="$1";
2230− [[ $path == -* ]] && path="./$path";
2231− local -a res=($(find "$path" -type f -not -newer "$NIX_BUILD_TOP/.." -printf '%T@ "%p"\0' | sort -n --zero-terminated | tail -n1 --zero-terminated | head -c -1));
2232− local time="${res[0]//\.[0-9]*/}";
2233− local newestFile="${res[1]}";
2234− if [ "${time:-0}" -gt "$SOURCE_DATE_EPOCH" ]; then
2235− echo "setting SOURCE_DATE_EPOCH to timestamp $time of file $newestFile";
2236− export SOURCE_DATE_EPOCH="$time";
2237− local now="$(date +%s)";
2238− if [ "$time" -gt $((now - 60)) ]; then
2239− echo "warning: file $newestFile may be generated; SOURCE_DATE_EPOCH may be non-deterministic";
2240− fi;
2241− fi
2242−}
2243−PATH="$PATH${nix_saved_PATH:+:$nix_saved_PATH}"
2244−XDG_DATA_DIRS="$XDG_DATA_DIRS${nix_saved_XDG_DATA_DIRS:+:$nix_saved_XDG_DATA_DIRS}"
2245−export NIX_BUILD_TOP="$(mktemp -d -t nix-shell.XXXXXX)"
2246−export TMP="$NIX_BUILD_TOP"
2247−export TMPDIR="$NIX_BUILD_TOP"
2248−export TEMP="$NIX_BUILD_TOP"
2249−export TEMPDIR="$NIX_BUILD_TOP"
2250−eval "${shellHook:-}"
.github/workflows/build.yml+12−16
@@ -12,7 +12,19 @@ concurrency:
1212 cancel-in-progress: true
1313
1414jobs:
15+ # The Crux core is plain Rust — build and unit-test it natively (the Android
16+ # shell mirrors this logic; see README).
17+ core:
18+ name: Crux core
19+ runs-on: ubuntu-latest
20+ steps:
21+ - uses: actions/checkout@v4
22+ - name: Test the Crux core
23+ working-directory: shared
24+ run: cargo test
25+
1526 build:
27+ name: Android app
1628 runs-on: ubuntu-latest
1729
1830 steps:
@@ -30,31 +42,15 @@ jobs:
3042 - name: Run unit tests
3143 run: ./gradlew testDebugUnitTest --no-daemon
3244
33− - name: Static analysis (detekt)
34− run: ./gradlew detekt --no-daemon
35−
3645 - name: Lint
3746 run: ./gradlew lintDebug --no-daemon
3847
3948 - name: Build debug APK
4049 run: ./gradlew assembleDebug --no-daemon
4150
42− - name: Build release APK
43− run: ./gradlew assembleRelease --no-daemon
44−
4551 - name: Upload debug APK
4652 uses: actions/upload-artifact@v4
4753 with:
4854 name: opencode-debug.apk
4955 path: app/build/outputs/apk/debug/*.apk
5056 if-no-files-found: error
51−
52− # The release output dir is `release/` when unsigned but `release-signed/` (or a
53− # variant name) once a signing config is applied. Match both so CI keeps working
54− # once the OPENCODE_STORE_* secrets are wired up.
55− - name: Upload release APK
56− uses: actions/upload-artifact@v4
57− with:
58− name: opencode-release.apk
59− path: app/build/outputs/apk/**/*.apk
60− if-no-files-found: error
.github/workflows/release.ymlnew file+113
@@ -0,0 +1,113 @@
1+name: Release
2+
3+# Build a minified, signed release APK and publish it as a GitHub Release.
4+#
5+# Runs automatically on every push to `master` (e.g. a merged PR), on a pushed
6+# version tag (e.g. `git tag v1.2.0 && git push --tags`), or manually from the
7+# Actions tab (workflow_dispatch). For a branch push or manual run the version
8+# is derived from the current UTC time as YYYY.MM.DD.HHMM; a tag push uses the
9+# tag name (with the leading `v` stripped) as the version.
10+#
11+# The `v*` tag this workflow pushes when publishing a release does NOT retrigger
12+# it: events created with the default GITHUB_TOKEN don't start new workflow runs.
13+#
14+# Signing: if the `OPENCODE_STORE_*` secrets are configured the APK is signed
15+# with that keystore; otherwise the Gradle build falls back to the debug key so
16+# the released APK is still installable (see app/build.gradle.kts). The version
17+# is passed to Gradle as `-PversionName/-PversionCode` and stamped into the APK.
18+on:
19+ push:
20+ branches: [master]
21+ tags: ["v*"]
22+ workflow_dispatch:
23+
24+# Never abort a release mid-flight — a half-published release with a missing
25+# artifact is worse than a slightly delayed one.
26+concurrency:
27+ group: ${{ github.workflow }}-${{ github.ref }}
28+ cancel-in-progress: false
29+
30+jobs:
31+ release:
32+ runs-on: ubuntu-latest
33+ permissions:
34+ contents: write # push the tag and create the release
35+ id-token: write # OIDC so GitHub can sign the provenance attestation
36+ attestations: write # publish the attestation record
37+ env:
38+ OPENCODE_STORE_BASE64: ${{ secrets.OPENCODE_STORE_BASE64 }}
39+ OPENCODE_STORE_PASSWORD: ${{ secrets.OPENCODE_STORE_PASSWORD }}
40+ OPENCODE_KEY_ALIAS: ${{ secrets.OPENCODE_KEY_ALIAS }}
41+ OPENCODE_KEY_PASSWORD: ${{ secrets.OPENCODE_KEY_PASSWORD }}
42+ steps:
43+ - uses: actions/checkout@v4
44+ with:
45+ # Full history so the versionCode (git rev-list --count HEAD) is
46+ # accurate instead of collapsing to 1 on a shallow clone.
47+ fetch-depth: 0
48+
49+ - name: Compute version
50+ id: version
51+ run: |
52+ if [ "${{ github.ref_type }}" = "tag" ]; then
53+ VERSION="${GITHUB_REF_NAME#v}"
54+ TAG="${GITHUB_REF_NAME}"
55+ else
56+ VERSION="$(TZ=UTC date +%Y.%m.%d.%H%M)"
57+ TAG="v${VERSION}"
58+ fi
59+ CODE="$(git rev-list --count HEAD)"
60+ {
61+ echo "version=$VERSION"
62+ echo "tag=$TAG"
63+ echo "code=$CODE"
64+ } >> "$GITHUB_OUTPUT"
65+ echo "Releasing $TAG (version $VERSION, code $CODE)"
66+
67+ - uses: actions/setup-java@v4
68+ with:
69+ distribution: temurin
70+ java-version: 17
71+
72+ - uses: android-actions/setup-android@v3
73+
74+ - uses: gradle/actions/setup-gradle@v4
75+
76+ # The keystore ships as base64 in a secret so it never touches the repo.
77+ # Job-level env makes OPENCODE_STORE_BASE64 available to this `if`.
78+ - name: Decode signing keystore
79+ if: env.OPENCODE_STORE_BASE64 != ''
80+ run: |
81+ STORE_FILE="$RUNNER_TEMP/release.keystore"
82+ printf '%s' "$OPENCODE_STORE_BASE64" | base64 -d > "$STORE_FILE"
83+ echo "OPENCODE_STORE_FILE=$STORE_FILE" >> "$GITHUB_ENV"
84+
85+ - name: Build release APK
86+ run: |
87+ ./gradlew assembleRelease \
88+ -PversionName="${{ steps.version.outputs.version }}" \
89+ -PversionCode="${{ steps.version.outputs.code }}" \
90+ --no-daemon
91+
92+ # Rename to a stable, version-tagged filename for the release attachment.
93+ - name: Stage APK
94+ run: |
95+ mkdir -p staging
96+ APK="$(find app/build/outputs/apk/release -name '*.apk' | head -n1)"
97+ cp "$APK" "staging/opencode-${{ steps.version.outputs.version }}.apk"
98+
99+ # Signed build-provenance attestation for the exact artifact being
100+ # published; later verifiable with `gh attestation verify <apk>`.
101+ - name: Attest build provenance
102+ uses: actions/attest-build-provenance@v2
103+ with:
104+ subject-path: staging/*.apk
105+
106+ - name: Create release
107+ uses: softprops/action-gh-release@v2
108+ with:
109+ tag_name: ${{ steps.version.outputs.tag }}
110+ name: ${{ steps.version.outputs.version }}
111+ make_latest: true
112+ generate_release_notes: true
113+ files: staging/*.apk
.gitignore+20
@@ -1,6 +1,8 @@
11# Gradle
22.gradle/
33build/
4+*.apk
5+*.aab
46
57# Kotlin
68.kotlin/
@@ -29,3 +31,21 @@ Thumbs.db
2931
3032# Nix
3133result
34+result-*
35+.direnv/
36+
37+# Local tooling / editor state (not part of the project)
38+.crush/
39+.crush.db
40+*.log
41+
42+# Rust
43+shared/target/
44+/target/
45+
46+# The `generated/` shared type sources ARE committed (regenerable via
47+# `cargo run --features typegen --bin codegen`); only the built JNI .so
48+# artifacts are ignored.
49+generated/jniLibs/
50+
51+# Gradle wrapper jar is committed so CI can run ./gradlew
AGENTS.mddeleted−197
@@ -1,197 +0,0 @@
1−# AGENTS.md
2−
3−Guidance for AI coding agents (and humans) working in this repository.
4−
5−## Project
6−
7−opencode-android is a native Android client (Kotlin + Jetpack Compose) for the
8−[`opencode serve`](https://opencode.ai/docs/server/) HTTP API. It talks to a
9−remote opencode server over REST plus a long-lived Server-Sent Events stream,
10−and renders conversations (markdown, diffs, tool calls, reasoning, images) with
11−an adaptive single-pane / two-pane Material 3 UI.
12−
13−- Application id / namespace: `soy.iko.opencode`
14−- Package root: `app/src/main/java/soy/iko/opencode`
15−- minSdk 26, compileSdk/targetSdk 35, JDK 17, Kotlin 2.0.x, AGP 8.7.x
16−- Single Gradle module (`:app`); no dynamic feature modules.
17−
18−## Build & environment
19−
20−A Nix flake provides the exact toolchain (JDK 17, Gradle, Android SDK 35, and a
21−Nix-built `aapt2` override because Gradle's bundled aapt2 segfaults on NixOS).
22−
23−```bash
24−nix develop # enter the dev shell (sets ANDROID_HOME, JAVA_HOME, GRADLE_OPTS)
25−./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
26−./gradlew assembleRelease # R8-minified + resource-shrunk; signed only when OPENCODE_STORE_* env vars are set
27−```
28−
29−Outside Nix, you need JDK 17 and the Android SDK (platform 35, build-tools
30−35.0.0) installed manually; `ANDROID_HOME` / `ANDROID_SDK_ROOT` must point at it.
31−
32−Install on a connected device/emulator:
33−
34−```bash
35−adb install -r app/build/outputs/apk/debug/app-debug.apk
36−adb shell am start -n soy.iko.opencode/.MainActivity
37−```
38−
39−## Test, lint, and static analysis
40−
41−```bash
42−./gradlew testDebugUnitTest # JVM unit tests (JSON/reducer/ViewModel logic)
43−./gradlew detekt # static analysis; config: config/detekt/detekt.yml, baseline: app/detekt-baseline.xml
44−./gradlew lintDebug # Android Lint (abortOnError = true)
45−./gradlew connectedAndroidTest # instrumented tests; needs a running AVD or device
46−```
47−
48−- Unit tests live in `app/src/test/...` and run on the JVM. They use fakes from
49− `app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt` (Fake* subclasses
50− that override the `open` members of the real stores/clients). JUnit 4 +
51− `kotlinx-coroutines-test`; no Robolectric — `testOptions.unitTests.isReturnDefaultValues = true`
52− stubs Android framework calls so tests brushing `Log.d` etc. don't need it.
53−- Instrumented tests live in `app/src/androidTest/...` and need a device.
54−- detekt runs with a baseline (`app/detekt-baseline.xml`) so only **new**
55− findings block CI; existing ones are grandfathered.
56−- CI (`.github/workflows/build.yml`) runs: `testDebugUnitTest` → `detekt` →
57− `lintDebug` → `assembleDebug` → `assembleRelease`, then uploads both APKs.
58−
59−## Running a server to develop against
60−
61−```bash
62−opencode serve --hostname 0.0.0.0 --port 4096
63−```
64−
65−Connect the app over LAN (`http://<host-ip>:4096`) or USB
66−(`adb reverse tcp:4096 tcp:4096` then `http://127.0.0.1:4096`). Auth is optional
67−(`OPENCODE_SERVER_PASSWORD`; username defaults to `opencode`). See the README's
68−"Set up a server and connect to it" section for full details.
69−
70−## Architecture
71−
72−Unidirectional data flow with manual DI, no annotation processing (no Hilt/KSP).
73−
74−```
75−OpencodeApp (Application)
76− └─ AppContainer (service locator: profile/settings/draft stores, active connection,
77− unread tracker, auto-reconnect, pending share / open-session signals)
78− └─ OpencodeConnection (one per active server profile)
79− ├─ HttpClient (Ktor + OkHttp, base URL + Basic auth + JSON + SSE)
80− ├─ OpencodeApiClient (REST: sessions, messages, files, providers, agents, commands, permissions)
81− ├─ EventStreamClient (long-lived GET /event SSE -> hot SharedFlow<BusEvent>)
82− └─ SessionRepository (reduces BusEvents + REST seeds -> Flow<List<MessageWithParts>>)
83− └─ MessageStore (in-memory reducer; internal, unit-tested)
84−
85−ViewModels (ui/<feature>/<Feature>ViewModel.kt)
86− └─ expose StateFlow; Compose collects with collectAsStateWithLifecycle
87− └─ built via ui/VmFactory.kt (viewModelFactory { initializer { ... } }) from the AppContainer
88−```
89−
90−Key invariants:
91−
92−- **Subscribe before you prompt.** `SessionRepository.observeMessages` collects
93− the SSE stream *before* the initial REST load and before any prompt POST, so
94− early streamed parts aren't missed. Preserve this ordering if you touch it.
95−- **Resilient decoding.** `OpencodeJson` registers `polymorphicDefaultDeserializer`
96− for `Part`, `BusEvent`, `MessageInfo`, and `ToolState`, so an unknown
97− discriminator decodes to an `Unknown*` / `ToolUnknown` variant instead of
98− throwing. Never add a `when (event)` that isn't exhaustive or that crashes on
99− `UnknownEvent` — the whole point is that a new server release can't break the
100− client. Add new modeled variants alongside the `Unknown` fallback.
101−- **Conflated message stream.** `observeMessages` is `.conflate()`d so a burst of
102− per-token snapshots doesn't back-pressure the reducer. Don't remove this.
103−- **Re-seed on SSE reconnect.** When `EventStreamClient.state` returns to
104− `Connected` after a prior connection, the repository re-fetches messages from
105− REST and merges (without pruning) under a generation counter so a stale fetch
106− can't clobber a newer one. See `SessionRepository.kt` for the contract.
107−- **One Ktor client per profile.** `HttpClientFactory.create(profile)` carries
108− the base URL, JSON negotiation, SSE plugin, and auth. Basic auth is sent
109− eagerly (no 401 challenge round-trip) and only installed for HTTPS profiles;
110− for HTTP profiles the `Authorization` header is set proactively in
111− `defaultRequest` instead, and the reactive Auth plugin is deliberately NOT
112− installed so credentials can't be re-sent silently on a cleartext 401.
113−- **Process lifetime.** `OpencodeApp` installs a `CrashLogger`, builds the
114− `AppContainer`, and registers a JVM shutdown hook (since `onTerminate()` isn't
115− called on real devices) that cancels the app scope and closes the active
116− connection. `AppContainer.shutdown()` is guarded against double-invocation.
117−
118−## Source map
119−
120−```
121−data/model Wire models (kotlinx.serialization, sealed polymorphism, @Immutable)
122−data/network Ktor: HttpClientFactory, OpencodeApiClient (REST), EventStreamClient (SSE),
123− OpencodeJson (shared Json), NetworkConfig (all tuning constants)
124−data/repo SessionRepository + MessageStore (reducer), ProfileStore (DataStore +
125− EncryptedSharedPreferences), SettingsStore (theme/dynamic color),
126− DraftStore (per-session draft persistence), CrashLogger, ErrorKind
127−di AppContainer (service locator) + OpencodeConnection (per-server bundle)
128−notification NotificationChannels, RunForegroundService (holds process priority
129− during a run), SessionNotifications (completion notifications)
130−ui OpencodeApp (NavHost), Routes, VmFactory, theme/
131−ui/chat ChatScreen + ChatViewModel, message bubbles, part rendering,
132− agent/model/command picker sheets, permission dialog, export
133−ui/session SessionListScreen + ViewModel, TwoPaneSessionChat (≥840dp)
134−ui/server ServerListScreen + ServerEditScreen (+ ViewModels)
135−ui/file FileBrowserScreen + FileViewScreen (+ ViewModels)
136−ui/settings SettingsScreen, DiagnosticsScreen (crash reports)
137−util Coroutines.kt (runCatchingCancellable, safeExceptionSummary)
138−```
139−
140−## Conventions
141−
142−- **No comments unless asked** — existing code has extensive `//` comments
143− explaining *why* (invariants, race conditions, security rationale). When you
144− add code, match that style only for genuinely non-obvious invariants; don't
145− narrate *what* the code does.
146−- **`runCatchingCancellable`** (`util/Coroutines.kt`) instead of `runCatching`
147− inside coroutines — plain `runCatching` swallows `CancellationException` and
148− breaks structured concurrency. Use it for any try/catch in a suspend context.
149−- **`safeExceptionSummary`** for logging Ktor exceptions — `ClientRequestException`
150− messages embed the full request URL (may contain auth/paths); never log the
151− raw exception from an HTTP call. Crash reports scrub URLs via a regex too.
152−- **`open` classes with protected no-arg test constructors.** Stores, the API
153− client, `EventStreamClient`, `OpencodeConnection`, and `AppContainer` are
154− `open` and have a `protected constructor()` so tests can subclass and override
155− individual members without instantiating Android frameworks. Keep this pattern
156− when adding new injectable components.
157−- **`NetworkConfig`** holds all tuning constants (timeouts, backoffs, buffer
158− sizes, layout thresholds, debounce delays). Add new magic numbers here rather
159− than scattering literals.
160−- **REST path encoding.** `OpencodeApiClient` URL-encodes path segments via
161− `encode()` (e.g. `session/${encode(id)}`). Deep-link session ids are validated
162− against `[A-Za-z0-9_-]+` in `MainActivity` to prevent path traversal.
163−- **Idempotency keys.** `sendPrompt` and `runCommand` generate the
164− `Idempotency-Key` header *before* `withRetry` so all retry attempts share it.
165− Preserve this when adding mutating endpoints.
166−- **`withRetry`** wraps all REST reads and the permission response. Transient
167− failures back off with jitter; see `NetworkConfig` for the parameters.
168−- **Compose:** `collectAsStateWithLifecycle` everywhere; `WhileSubscribed` with a
169− 5s grace period (`stateFlowSubscriptionTimeoutMs`). `@Composable` functions
170− use PascalCase (detekt `FunctionNaming` disabled for this reason). Long
171− composables and long parameter lists are accepted (detekt `LongMethod` /
172− `LongParameterList` disabled).
173−- **Manifest:** cleartext HTTP is permitted via `network_security_config.xml`
174− (opencode serves plain `http://`). `enableOnBackInvokedCallback="true"` for
175− Android 14 predictive back. Deep link scheme `opencode://session/{id}`.
176−- **`@Immutable`** on data models exposed to Compose so they skip recomposition
177− correctly.
178−- **Kotlin code style: `official`** (see `gradle.properties`).
179−
180−## Things to avoid
181−
182−- Don't add Hilt/Dagger/KSP — DI is intentionally hand-written (`AppContainer`).
183−- Don't add Robolectric — JVM tests use `isReturnDefaultValues = true` stubs and
184− fakes; only device-dependent code goes in `androidTest`.
185−- Don't run `nix flake check` manually (CI only — per repo convention).
186−- Don't read or query `/nix/store` (extremely large — per repo convention).
187−- Don't introduce new detekt findings; the baseline only grandfathers existing
188− ones. Run `./gradlew detekt` before considering work done.
189−- Don't log raw Ktor/OkHttp exceptions — use `safeExceptionSummary`.
190−- Don't make a `when (BusEvent)` / `when (Part)` non-exhaustive; the `Unknown*`
191− variants exist for forward compatibility and must remain reachable.
192−
193−## Renovate
194−
195−`renovate.json` keeps dependencies current: Kotlin+AGP and Compose are grouped;
196−Ktor is grouped; AndroidX majors are held for manual review (pinned to
197−compileSdk 35). Dependency upgrades land via Renovate PRs, not manual bumps.
CLAUDE.mdmode 120000 → 100644+40−1
@@ -1 +0,0 @@
1−AGENTS.md
@@ -0,0 +1,40 @@
1+# opencode-android
2+
3+Native Android client for the opencode server, built with Crux (Rust core), Kotlin, and Jetpack Compose.
4+
5+## Architecture
6+
7+- **shared/** — Rust Crux core (canonical spec, unit-tested with `cargo test`)
8+- **app/** — Android shell with Kotlin port of the reducer (`OpencodeCore`)
9+- All app logic in `OpencodeCore.kt`; UI is a thin Compose shell
10+- Three screens: Connect → Sessions → Chat
11+
12+## Key Files
13+
14+- `app/src/main/java/soy/iko/opencode/core/OpencodeCore.kt` — reducer, all state management
15+- `app/src/main/java/soy/iko/opencode/core/ViewModel.kt` — UiState, Event, data classes
16+- `app/src/main/java/soy/iko/opencode/core/Protocol.kt` — wire types, SSE event parser
17+- `app/src/main/java/soy/iko/opencode/core/HttpClient.kt` — OkHttp wrapper
18+- `app/src/main/java/soy/iko/opencode/core/SseClient.kt` — SSE stream client
19+- `app/src/main/java/soy/iko/opencode/ui/screens/` — ConnectScreen, SessionsScreen, ChatScreen
20+- `app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.kt` — message rendering
21+
22+## Build
23+
24+```sh
25+source .buildenv.sh
26+nix develop -c ./gradlew assembleDebug
27+```
28+
29+## Code Conventions
30+
31+- Kotlin + Jetpack Compose (Material 3)
32+- State flows through `MutableStateFlow<UiState>` → `collectAsStateWithLifecycle`
33+- All state mutations on main dispatcher (viewModelScope)
34+- Network I/O on `Dispatchers.IO` inside HttpClient/SseClient
35+- Keep the Rust core and Kotlin port behaviourally identical
36+
37+## Version Control
38+
39+- Uses `jj` (jujutsu) colocated with git
40+- Push to master: `jj git push --change @` to create a bookmark, or commit then push
README.md+126−64
@@ -1,91 +1,153 @@
11# opencode-android
22
3−A native Android client (Kotlin + Jetpack Compose) for the [`opencode serve`](https://opencode.ai/docs/server/) HTTP API.
3+A native Android client for the [**opencode**](https://opencode.ai) server (the
4+`sst/opencode` AI coding agent), built with **Crux** (a Rust core), **Kotlin**,
5+and **Jetpack Compose**.
46
5−Application id: `soy.iko.opencode`
7+Connect to a running `opencode serve`, browse and create sessions, and chat with
8+the agent — assistant replies (text, reasoning, and tool calls) stream in live
9+over the server's SSE event stream.
610
7−## Set up a server and connect to it
11+<p align="center"><em>Connect → Sessions → Chat, with token-by-token streaming.</em></p>
812
9−This app is a client for the [`opencode serve`](https://opencode.ai/docs/server/) HTTP API, so you
10−need an opencode server running somewhere reachable from your device.
11−
12−### 1. Start the server
13+## Architecture
1314
14−On the host that has your project and opencode installed:
15+The app follows the Crux pattern: **all application logic lives in a pure,
16+platform-agnostic core; the platform is a thin shell** that renders the core's
17+view model, sends it events, and performs the side effects it requests.
1518
16−```bash
17−opencode serve --hostname 0.0.0.0 --port 4096
19+```
20+┌─────────────────────────── shared/ (Rust) ───────────────────────────┐
21+│ Crux core — the single source of truth │
22+│ • Model / Event / Effect / ViewModel │
23+│ • update(): connect+auth, sessions, chat, SSE stream application │
24+│ • crux_http for HTTP effects; unit-tested with `cargo test` │
25+└───────────────────────────────────────────────────────────────────────┘
26+ ▲ mirrored, behaviour-for-behaviour
27+┌─────────────────────────── app/ (Kotlin) ────────────────────────────┐
28+│ Jetpack Compose shell │
29+│ • OpencodeCore — Kotlin port of the reducer (same ViewModel) │
30+│ • HttpClient (OkHttp) + SseClient (okhttp-sse) — the effects │
31+│ • Compose UI: Connect / Sessions / Chat │
32+└───────────────────────────────────────────────────────────────────────┘
1833```
1934
20−Useful flags:
35+### Why a Kotlin port instead of a JNI `.so`?
2136
22−| Flag | Default | Description |
23−| --- | --- | --- |
24−| `--port` | `4096` | Port to listen on |
25−| `--hostname` | `127.0.0.1` | Hostname/interface to bind (`0.0.0.0` exposes it on your LAN) |
26−| `--mdns` | `false` | Enable mDNS discovery |
27−| `--cors` | `[]` | Extra browser origins to allow (repeatable) |
37+Crux normally compiles the Rust core to a native library and the shell talks to
38+it over an FFI bridge. That requires an Android cross-compilation toolchain
39+(NDK + a Rust `std` for the `*-linux-android` targets). This project's dev
40+environment intentionally ships **without** an NDK, so instead:
2841
29−The server publishes an OpenAPI 3.1 spec at `http://<hostname>:<port>/doc`.
42+- The **Rust core is the canonical specification** of behaviour and is fully
43+ unit-tested (`cd shared && cargo test`).
44+- The **Android shell runs `OpencodeCore`**, a faithful Kotlin port of the same
45+ reducer, driving an identical `UiState`/`ViewModel`.
3046
31−### 2. (Optional) Add authentication
47+The FFI boundary is kept clean, so the day an NDK is available the Kotlin port
48+can be swapped for the compiled `.so` behind the same seam. The crate already
49+builds as a `cdylib`/`staticlib` and a `codegen` binary is wired for Crux
50+type-generation.
3251
33−opencode has no auth by default. To protect the server with HTTP Basic auth:
52+## Project layout
3453
35−```bash
36−OPENCODE_SERVER_PASSWORD=your-password opencode serve --hostname 0.0.0.0 --port 4096
54+```
55+flake.nix Reproducible dev shell (JDK 17, Gradle, Rust, Android SDK 35)
56+shared/ Crux core (Rust)
57+ src/app.rs App: Model, Event, Effect, ViewModel, update, view, tests
58+ src/protocol.rs Lenient serde wire types for the opencode HTTP + SSE API
59+ src/bin/codegen.rs Type-generation entry point (feature = "typegen")
60+generated/ Crux-generated FFI-shared JVM types (from `codegen`)
61+app/ Android app (Kotlin + Compose)
62+ src/main/java/soy/iko/opencode/
63+ core/ OpencodeCore (reducer port), Protocol, HttpClient, SseClient
64+ ui/ Compose screens (Connect/Sessions/Chat), theme, components
65+ MainActivity.kt, CoreViewModel.kt
3766```
3867
39−The username defaults to `opencode` (override with `OPENCODE_SERVER_USERNAME`). Enter these in the
40−app's add-server dialog.
41−
42−### 3. Connect from the app
68+## Getting started
4369
44−**Over Wi-Fi / LAN** — add a server with base URL `http://<host-LAN-ip>:4096`.
70+### Dev shell
4571
46−**Over USB only** — forward the port to the device and point the app at localhost:
72+Everything you need is provided by the Nix flake:
4773
48−```bash
49−adb reverse tcp:4096 tcp:4096
74+```sh
75+nix develop # or: direnv allow (an .envrc with `use flake` is included)
5076```
5177
52−Then use base URL `http://127.0.0.1:4096` in the app.
78+This puts JDK 17, Gradle, `rustc`/`cargo`, and the Android SDK (platform 35,
79+build-tools 35.0.0) on your `PATH`.
5380
54−> The app permits cleartext HTTP because opencode serves plain `http://`. If you terminate TLS with
55−> a reverse proxy, use an `https://` base URL instead.
81+### Build & test
5682
57−## Architecture
83+```sh
84+# Rust core — logic + streaming unit tests
85+cd shared && cargo test
86+
87+# Android app — debug APK
88+./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
89+
90+# Android unit tests + lint
91+./gradlew testDebugUnitTest lintDebug
92+```
93+
94+### Run against a server
95+
96+Start opencode's server on your machine or LAN:
5897
59−- **Ktor 3 (OkHttp engine)** for REST + the long-lived `GET /event` SSE stream.
60−- **kotlinx.serialization** with sealed polymorphism for opencode's discriminated unions
61− (`Part`/`type`, `MessageInfo`/`role`, `ToolState`/`status`, `BusEvent`/`type`). Unknown
62− discriminator values fall back to `Unknown*` variants (`OpencodeJson`) so a new server release
63− can't break decoding.
64−- **Manual DI** (`AppContainer` on the `Application`) — no Hilt/KSP.
65−- **Unidirectional state**: `EventStreamClient` exposes the SSE stream as a hot `SharedFlow`;
66− `SessionRepository` reduces events into per-session message state; ViewModels expose `StateFlow`;
67− Compose collects with `collectAsStateWithLifecycle`.
68−- The repository subscribes to `/event` **before** the prompt POST so no early streamed part is missed.
69−
70−Source map: `data/network` (Ktor, SSE, JSON), `data/model` (wire models), `data/repo`
71−(reducer + profile persistence), `di` (container/connection), `ui/<feature>` (Compose screens).
72−
73−## Build
74−
75−```bash
76−nix develop # JDK 17, Gradle 8.14.4, Android SDK 35, aapt2 override
77−./gradlew assembleDebug # -> app/build/outputs/apk/debug/app-debug.apk
78−./gradlew assembleRelease # R8-minified + resource-shrunk release APK
79−./gradlew testDebugUnitTest # JVM tests for the JSON/serialization layer
80−./gradlew detekt # static analysis (config: config/detekt/detekt.yml)
81−./gradlew lintDebug # Android Lint
82−./gradlew connectedAndroidTest # device/emulator tests (needs an AVD)
98+```sh
99+opencode serve --port 4096 # optionally behind OPENCODE_SERVER_PASSWORD
83100```
84101
85−## Install & run
102+Install the APK, launch the app, and enter the server URL (e.g.
103+`http://192.168.1.10:4096`). If the server was started with a password the app
104+detects the `401`, shows credential fields, and connects with HTTP Basic auth.
105+
106+> Cleartext HTTP is enabled so you can reach a `http://` server on your LAN.
107+
108+## The opencode API this client speaks
109+
110+- **Connect / auth** — `GET /global/health` probes reachability; a `401` triggers
111+ HTTP Basic auth.
112+- **Sessions** — `GET /session`, `POST /session`, `DELETE /session/{id}`.
113+- **Messages** — `GET /session/{id}/message` (each item is `{ info, parts }`).
114+- **Send** — `POST /session/{id}/prompt_async` (returns immediately; the reply
115+ streams over SSE).
116+- **Cancel** — `POST /session/{id}/abort`.
117+- **Stream** — `GET /event` (SSE). The client applies `message.updated`,
118+ `message.part.updated` (text/reasoning/tool, with deltas), `message.removed`,
119+ `session.idle` (clears the "generating" state), `session.error`, and
120+ `session.{created,updated,deleted}` incrementally. Unknown event types are
121+ ignored so new server versions don't break the stream.
122+
123+## Continuous integration & releases
124+
125+- **CI** (`.github/workflows/build.yml`) runs on pushes to `master` and PRs: it
126+ unit-tests the Crux core (`cargo test`), runs the Android unit tests + lint,
127+ and builds the debug APK (uploaded as an artifact).
128+- **Releases** (`.github/workflows/release.yml`): push a version tag to cut a
129+ release — it builds a minified, signed release APK and publishes a GitHub
130+ Release with the APK attached and a signed build-provenance attestation.
131+
132+ ```sh
133+ git tag v1.0.0 && git push origin v1.0.0 # or run the workflow manually
134+ ```
135+
136+ The tag name becomes the version (a manual run uses a `YYYY.MM.DD.HHMM`
137+ timestamp), stamped into the APK via `-PversionName/-PversionCode`.
138+
139+ **Signing:** set the repo secrets `OPENCODE_STORE_BASE64` (base64 of your
140+ keystore), `OPENCODE_STORE_PASSWORD`, `OPENCODE_KEY_ALIAS`, and
141+ `OPENCODE_KEY_PASSWORD` to sign with your own key. Without them the release
142+ build falls back to the debug key, so the APK is still installable.
143+
144+## Notes & limitations
145+
146+- Rendering covers text, reasoning, and tool-call parts. Interactive tool
147+ **permission** prompts (`permission.updated`) are not yet surfaced.
148+- Model/agent selection uses the server's configured default (no picker yet).
149+- No `.so` is built in this environment (see *Architecture* above).
150+
151+## License
86152
87−```bash
88−adb install -r app/build/outputs/apk/debug/app-debug.apk
89−adb shell am start -n soy.iko.opencode/.MainActivity
90−adb logcat --pid=$(adb shell pidof soy.iko.opencode)
91−```
153+Apache-2.0.
app/build.gradle.kts+44−98
@@ -1,62 +1,59 @@
11plugins {
22 alias(libs.plugins.android.application)
33 alias(libs.plugins.kotlin.android)
4− alias(libs.plugins.kotlin.serialization)
54 alias(libs.plugins.kotlin.compose)
6− alias(libs.plugins.detekt)
5+ alias(libs.plugins.kotlin.serialization)
76}
87
9−/**
10− * Monotonically increasing versionCode: the git commit count on HEAD, so each release
11− * ships a strictly higher code (never a "downgrade" block) without manual bookkeeping.
12− * Falls back to 1 when git or the history isn't available (e.g. a plain source export).
13− */
14−val gitCommitCount: Int = runCatching {
15− providers.exec {
16− commandLine("git", "rev-list", "--count", "HEAD")
17− }.standardOutput.asText.get().trim().toInt()
18−}.getOrDefault(1)
8+// Version is overridable from the release workflow via
9+// `-PversionName=... -PversionCode=...`; falls back to sensible defaults locally.
10+val appVersionName = (project.findProperty("versionName") as String?) ?: "0.1.0"
11+val appVersionCode = (project.findProperty("versionCode") as String?)?.toIntOrNull() ?: 1
12+
13+// A release keystore is supplied by CI (decoded from a secret) via env vars.
14+// When it's absent — local builds, or CI before signing is configured — the
15+// release build falls back to the debug signing config so the APK is still
16+// installable, rather than producing an uninstallable unsigned APK.
17+val releaseStorePath: String? = System.getenv("OPENCODE_STORE_FILE")
18+val hasReleaseKeystore = releaseStorePath != null && file(releaseStorePath).exists()
1919
2020android {
2121 namespace = "soy.iko.opencode"
2222 compileSdk = 35
23− buildToolsVersion = "35.0.0"
2423
2524 defaultConfig {
2625 applicationId = "soy.iko.opencode"
2726 minSdk = 26
2827 targetSdk = 35
29− // Monotonically increasing across builds: derived from the git commit count so
30− // each release ships a strictly higher versionCode without manual bookkeeping
31− // (and never triggers a "downgrade" install block). Falls back to 1 when the
32− // .git history isn't available (e.g. a plain source export).
33− versionCode = gitCommitCount
34− versionName = "0.1.0"
28+ versionCode = appVersionCode
29+ versionName = appVersionName
30+
3531 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
3632 }
3733
38− // Release builds are signed when keystore credentials are supplied via env vars
39− // (so CI can publish an installable APK). When the env vars are absent, the
40− // release build falls back to unsigned — same behavior as before — so local
41− // builds keep working without any keystore setup.
42− val releaseSigning = providers.environmentVariable("OPENCODE_STORE_FILE").orNull?.let { path ->
43− signingConfigs.create("release") {
44− storeFile = file(path)
45− storePassword = providers.environmentVariable("OPENCODE_STORE_PASSWORD").orNull
46− keyAlias = providers.environmentVariable("OPENCODE_KEY_ALIAS").orNull
47− keyPassword = providers.environmentVariable("OPENCODE_KEY_PASSWORD").orNull
34+ signingConfigs {
35+ if (hasReleaseKeystore) {
36+ create("release") {
37+ storeFile = file(releaseStorePath!!)
38+ storePassword = System.getenv("OPENCODE_STORE_PASSWORD")
39+ keyAlias = System.getenv("OPENCODE_KEY_ALIAS")
40+ keyPassword = System.getenv("OPENCODE_KEY_PASSWORD")
41+ }
4842 }
4943 }
5044
5145 buildTypes {
5246 release {
5347 isMinifyEnabled = true
54− isShrinkResources = true
5548 proguardFiles(
5649 getDefaultProguardFile("proguard-android-optimize.txt"),
5750 "proguard-rules.pro",
5851 )
59− releaseSigning?.let { signingConfig = it }
52+ signingConfig = if (hasReleaseKeystore) {
53+ signingConfigs.getByName("release")
54+ } else {
55+ signingConfigs.getByName("debug")
56+ }
6057 }
6158 }
6259
@@ -65,93 +62,42 @@ android {
6562 targetCompatibility = JavaVersion.VERSION_17
6663 }
6764
68− // Use the kotlin { compilerOptions {} } block (the kotlinOptions DSL is deprecated
69− // in favor of the type-safe extension exposed by the Kotlin Gradle plugin).
70− kotlin {
71− compilerOptions {
72− jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
73− }
65+ kotlinOptions {
66+ jvmTarget = "17"
7467 }
7568
7669 buildFeatures {
7770 compose = true
7871 }
7972
80− lint {
81− // Fail CI on real errors. Warnings are reported but don't break the build so
82− // minor/style issues don't gate development.
83− abortOnError = true
84− // Dependency upgrades are tracked by Renovate, so don't lint for them here.
85− // ProduceStateDoesNotAssignValue false-positives on valid produceState calls that
86− // assign `value` via a conditional/`withContext` expression (see FileViewScreen's
87− // match-index scan), so it's disabled rather than worked around at each call site.
88− disable += setOf("GradleDependency", "AndroidGradlePluginVersion", "ProduceStateDoesNotAssignValue")
89− }
90−
9173 packaging {
9274 resources {
9375 excludes += "/META-INF/{AL2.0,LGPL2.1}"
9476 }
9577 }
96−
97− testOptions {
98− unitTests {
99− // Return default values for Android framework stubs (e.g. Log.d, Context.getString)
100− // instead of throwing "not mocked" exceptions, so JVM unit tests that brush
101− // against Android APIs don't need Robolectric.
102− isReturnDefaultValues = true
103− isIncludeAndroidResources = true
104− }
105− }
10678}
10779
10880dependencies {
10981 implementation(libs.androidx.core.ktx)
110− implementation(libs.androidx.core.splashscreen)
111− implementation(libs.androidx.activity.compose)
112− implementation(libs.androidx.lifecycle.viewmodel.compose)
82+ implementation(libs.androidx.lifecycle.runtime.ktx)
11383 implementation(libs.androidx.lifecycle.runtime.compose)
114− implementation(libs.androidx.navigation.compose)
115−
116− implementation(platform(libs.compose.bom))
117− implementation(libs.compose.ui)
118− implementation(libs.compose.ui.graphics)
119− implementation(libs.compose.ui.tooling.preview)
120− implementation(libs.compose.material3)
121− implementation(libs.compose.material.icons.extended)
122− debugImplementation(libs.compose.ui.tooling)
84+ implementation(libs.androidx.lifecycle.viewmodel.ktx)
85+ implementation(libs.androidx.lifecycle.viewmodel.compose)
86+ implementation(libs.androidx.activity.compose)
12387
124− implementation(libs.ktor.client.core)
125− implementation(libs.ktor.client.okhttp)
126− implementation(libs.ktor.client.content.negotiation)
127− implementation(libs.ktor.serialization.kotlinx.json)
128− implementation(libs.ktor.client.auth)
88+ implementation(platform(libs.androidx.compose.bom))
89+ implementation(libs.androidx.compose.ui)
90+ implementation(libs.androidx.compose.ui.graphics)
91+ implementation(libs.androidx.compose.ui.tooling.preview)
92+ implementation(libs.androidx.compose.material3)
93+ implementation(libs.androidx.compose.material.icons.extended)
94+ debugImplementation(libs.androidx.compose.ui.tooling)
12995
130− implementation(libs.kotlinx.serialization.json)
13196 implementation(libs.kotlinx.coroutines.android)
132−
133− implementation(libs.androidx.datastore.preferences)
134− implementation(libs.androidx.security.crypto)
135− implementation(libs.androidx.biometric)
136− implementation(libs.coil.compose)
137− implementation(libs.markdown.renderer)
138− implementation(libs.markdown.renderer.m3)
139− implementation(libs.zxing.core)
97+ implementation(libs.kotlinx.serialization.json)
98+ implementation(libs.okhttp)
99+ implementation(libs.okhttp.sse)
140100
141101 testImplementation(libs.junit)
142102 testImplementation(libs.kotlinx.coroutines.test)
143− testImplementation(libs.ktor.client.mock)
144−
145− androidTestImplementation(libs.androidx.test.ext.junit)
146− androidTestImplementation(libs.androidx.test.runner)
147− androidTestImplementation(libs.androidx.test.core)
148− androidTestImplementation(libs.kotlinx.coroutines.test)
149−}
150−
151−detekt {
152− buildUponDefaultConfig = true
153− // Carry forward the current baseline so CI only fails on *new* findings;
154− // existing issues are grandfathered rather than blocking the migration.
155− baseline = file("detekt-baseline.xml")
156− config.setFrom("$rootDir/config/detekt/detekt.yml")
157103}
app/detekt-baseline.xmldeleted−34
@@ -1,34 +0,0 @@
1−<?xml version="1.0" ?>
2−<SmellBaseline>
3− <ManuallySuppressedIssues/>
4− <CurrentIssues>
5− <ID>ComplexCondition:ChatScreen.kt$searchActive &amp;&amp; searchQuery.isNotEmpty() &amp;&amp; searchMessages.isEmpty() &amp;&amp; messages.isNotEmpty()</ID>
6− <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
7− <ID>ComplexCondition:SessionListViewModel.kt$SessionListState$q.isEmpty() &amp;&amp; pinnedIds.isEmpty() &amp;&amp; noArchivedHidden &amp;&amp; directoryFilter == null</ID>
8− <ID>CyclomaticComplexMethod:AppContainer.kt$AppContainer$@OptIn(ExperimentalCoroutinesApi::class) private fun observeMessageActivity()</ID>
9− <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatScreen( container: AppContainer, sessionId: String, onBack: () -&gt; Unit, onOpenFile: ((String) -&gt; Unit)? = null, onOpenSession: ((String) -&gt; Unit)? = null, )</ID>
10− <ID>CyclomaticComplexMethod:ChatScreen.kt$@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable private fun ChatInputBar( value: String, onValueChange: (String) -&gt; Unit, running: Boolean, aborting: Boolean, enabled: Boolean, sendOnEnter: Boolean, onSend: () -&gt; Unit, onAbort: () -&gt; Unit, queuedFollowUp: String?, onQueueFollowUp: (String) -&gt; Unit, onCancelQueue: () -&gt; Unit, focusRequester: androidx.compose.ui.focus.FocusRequester, attachments: List&lt;PendingAttachment&gt;, staging: Boolean, onRemoveAttachment: (String) -&gt; Unit, onPickPhoto: () -&gt; Unit, onPickFile: () -&gt; Unit, onCamera: () -&gt; Unit, onVoice: () -&gt; Unit, onPasteImage: (List&lt;Uri&gt;) -&gt; Unit, )</ID>
11− <ID>CyclomaticComplexMethod:ChatViewModel.kt$ChatViewModel$private fun send(text: String, includeAttachments: Boolean, idempotencyKey: String? = null): Boolean</ID>
12− <ID>CyclomaticComplexMethod:CodeHighlighter.kt$private fun findNumberEnd(line: String, start: Int): Int</ID>
13− <ID>CyclomaticComplexMethod:ConversationExport.kt$fun buildConversationMarkdown(messages: List&lt;MessageWithParts&gt;, title: String?): String</ID>
14− <ID>CyclomaticComplexMethod:DiagnosticsScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun DiagnosticsScreen(onBack: () -&gt; Unit)</ID>
15− <ID>CyclomaticComplexMethod:EventStreamClient.kt$EventStreamClient$private fun stream(): Flow&lt;BusEvent&gt;</ID>
16− <ID>CyclomaticComplexMethod:FileBrowserScreen.kt$@OptIn(ExperimentalFoundationApi::class) @Composable private fun FileRow( icon: Boolean, label: String, onClick: () -&gt; Unit, status: FileStatusEntry? = null, sublabel: String? = null, size: Long? = null, mtime: Long? = null, onCopyPath: (() -&gt; Unit)? = null, modifier: Modifier = Modifier, )</ID>
17− <ID>CyclomaticComplexMethod:FileViewScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun FileViewScreen( container: AppContainer, path: String, onBack: () -&gt; Unit, initialLine: Int? = null, )</ID>
18− <ID>CyclomaticComplexMethod:MainActivity.kt$MainActivity$private fun handleIntent(intent: Intent?)</ID>
19− <ID>CyclomaticComplexMethod:OpencodeApp.kt$@Composable fun OpencodeApp(container: AppContainer)</ID>
20− <ID>CyclomaticComplexMethod:ServerListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun ServerListScreen( container: AppContainer, onConnected: () -&gt; Unit, onAddProfile: () -&gt; Unit, onEditProfile: (String) -&gt; Unit, onDuplicateProfile: (String) -&gt; Unit, )</ID>
21− <ID>CyclomaticComplexMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable fun SessionListScreen( container: AppContainer, onOpenSession: (String) -&gt; Unit, onDisconnect: () -&gt; Unit, onOpenFiles: () -&gt; Unit, onOpenSettings: () -&gt; Unit, onAddServer: () -&gt; Unit, onOpenSearch: () -&gt; Unit = {}, // Incremented by a host (the two-pane empty-detail pane) to open the new-session // directory picker, so that pane shares this screen's dialog instead of bypassing it. externalNewSessionTrigger: Int = 0, selectedSessionId: String? = null, )</ID>
22− <ID>CyclomaticComplexMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class) @Composable private fun androidx.compose.foundation.layout.BoxScope.SessionListBody( state: SessionListState, unread: Map&lt;String, Int&gt;, refreshing: Boolean, haptics: androidx.compose.ui.hapticfeedback.HapticFeedback, selectedSessionId: String?, onRefresh: () -&gt; Unit, onQueryChange: (String) -&gt; Unit, onOpenSession: (String) -&gt; Unit, onCreateSession: () -&gt; Unit, onRename: (String) -&gt; Unit, onDelete: (String) -&gt; Unit, onPin: (Session) -&gt; Unit, onArchive: (Session) -&gt; Unit, onFilterByDirectory: (String) -&gt; Unit, inSelection: Boolean, selectedIds: Set&lt;String&gt;, onToggleSelect: (String) -&gt; Unit, )</ID>
23− <ID>CyclomaticComplexMethod:SessionListScreen.kt$@OptIn(ExperimentalMaterial3Api::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable private fun SessionCard( session: Session, preview: String?, unreadCount: Int, onClick: () -&gt; Unit, onRename: () -&gt; Unit, onDelete: () -&gt; Unit, onPin: () -&gt; Unit, onArchive: () -&gt; Unit, modifier: Modifier = Modifier, isSelected: Boolean = false, isPinned: Boolean = false, isArchived: Boolean = false, onFilterByDirectory: ((String) -&gt; Unit)? = null, isDirectoryFiltered: Boolean = false, inSelection: Boolean = false, onToggleSelect: (() -&gt; Unit)? = null, )</ID>
24− <ID>CyclomaticComplexMethod:SettingsScreen.kt$@OptIn(ExperimentalMaterial3Api::class, ExperimentalCoroutinesApi::class) @Composable fun SettingsScreen( container: AppContainer, onBack: () -&gt; Unit, onManageServers: () -&gt; Unit, onOpenDiagnostics: () -&gt; Unit, onOpenUsage: () -&gt; Unit = {}, onOpenMcp: () -&gt; Unit = {}, )</ID>
25− <ID>LargeClass:AppContainer.kt$AppContainer</ID>
26− <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
27− <ID>MatchingDeclarationName:RemoteImage.kt$ImageLoadContext</ID>
28− <ID>TooManyFunctions:AppContainer.kt$AppContainer</ID>
29− <ID>TooManyFunctions:ChatViewModel.kt$ChatViewModel : ViewModel</ID>
30− <ID>TooManyFunctions:CrashLogger.kt$CrashLogger</ID>
31− <ID>TooManyFunctions:OpencodeApiClient.kt$OpencodeApiClient</ID>
32− <ID>TooManyFunctions:SessionNotifications.kt$SessionNotifications</ID>
33− </CurrentIssues>
34−</SmellBaseline>
app/proguard-rules.pro+13−44
@@ -1,47 +1,16 @@
1−# --- kotlinx.serialization ---
2−# The plugin generates serializers; keep them and the $$serializer companion members.
3−-keepattributes *Annotation*, InnerClasses, Signature, Exceptions
1+# kotlinx.serialization — keep generated serializers and @Serializable metadata.
2+-keepattributes *Annotation*, InnerClasses
43-dontnote kotlinx.serialization.**
5−-keepclassmembers class soy.iko.opencode.** {
6− *** Companion;
7−}
8−-keepclasseswithmembers class soy.iko.opencode.** {
9− kotlinx.serialization.KSerializer serializer(...);
10−}
11−# Keep @Serializable classes themselves so their generated serializers can reference them.
12−-keep,includedescriptorclasses class soy.iko.opencode.**$$serializer { *; }
13−-keepclassmembers class soy.iko.opencode.** {
14− *** Companion;
15−}
16−# Keep the serializable model classes (reflection-free serializer lookup needs the class).
17−-keep @kotlinx.serialization.Serializable class soy.iko.opencode.data.model.** { *; }
184
19−# --- Ktor / OkHttp (HTTP client + SSE) ---
20−# Don't warn about missing optional dependencies; keep only the classes that are
21−# accessed via reflection (Ktor engine registration, OkHttp internals that R8
22−# would otherwise strip). Narrow keeps instead of `-keep class io.ktor.** { *; }`
23−# so R8 can still eliminate dead code and shrink the APK.
24−-dontwarn io.ktor.**
25−-dontwarn okhttp3.**
26−-dontwarn okio.**
27−-dontwarn org.slf4j.**
28−-dontwarn org.jetbrains.annotations.**
29−# Ktor loads engines and plugins via ServiceLoader — keep the service files.
30−-keep class io.ktor.client.engine.** { *; }
31−-keep class io.ktor.client.plugins.** { *; }
32−-keep class io.ktor.serialization.** { *; }
33−-keep class io.ktor.http.** { *; }
34−# OkHttp uses reflection for platform detection and internal classes.
35−-keep class okhttp3.internal.platform.** { *; }
36−-keep class okhttp3.** { *; }
37−-keep class okio.** { *; }
38−
39−# --- Coil (image loading) ---
40−# Coil uses reflection for loadable decoders and the Compose integration.
41−-keep class coil.** { *; }
42−-keep class coil3.** { *; }
43−-dontwarn coil.**
5+-keepclassmembers class **$$serializer { *; }
6+-keepclasseswithmembers class * {
7+ @kotlinx.serialization.Serializable <methods>;
8+}
9+-if @kotlinx.serialization.Serializable class **
10+-keep, allowobfuscation, allowshrinking class <1>
4411
45−# --- androidx.security.crypto pulls in Tink, which references compile-only
46−# com.google.errorprone annotations. They aren't on the runtime classpath.
47−-dontwarn com.google.errorprone.annotations.**
12+# OkHttp / okio — platform-specific bits referenced reflectively.
13+-dontwarn okhttp3.internal.platform.**
14+-dontwarn org.conscrypt.**
15+-dontwarn org.bouncycastle.**
16+-dontwarn org.openjsse.**
app/src/androidTest/java/soy/iko/opencode/ApplicationBootstrapTest.ktdeleted−36
@@ -1,36 +0,0 @@
1−package soy.iko.opencode
2−
3−import androidx.test.core.app.ApplicationProvider
4−import androidx.test.ext.junit.runners.AndroidJUnit4
5−import org.junit.Assert.assertNotNull
6−import org.junit.Assert.assertTrue
7−import org.junit.Test
8−import org.junit.runner.RunWith
9−
10−/**
11− * Smoke test for the [OpencodeApp] Application bootstrap: the container and crash logger
12− * are initialized on startup, and the notification channels are created.
13− */
14−@RunWith(AndroidJUnit4::class)
15−class ApplicationBootstrapTest {
16−
17− @Test
18− fun containerIsInitialized() {
19− val app = ApplicationProvider.getApplicationContext<OpencodeApp>()
20− assertNotNull(app.container)
21− assertNotNull(app.container.settingsStore)
22− }
23−
24− @Test
25− fun notificationChannelsExist() {
26− if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.O) return
27− val context = ApplicationProvider.getApplicationContext<android.content.Context>()
28− // Touching the app instance runs Application.onCreate, which creates the channels.
29− ApplicationProvider.getApplicationContext<OpencodeApp>()
30− val nm = context.getSystemService(android.content.Context.NOTIFICATION_SERVICE)
31− as android.app.NotificationManager
32− val ids = nm.notificationChannels.map { it.id }
33− assertTrue("status channel should exist, got $ids", ids.contains("run_status"))
34− assertTrue("completed channel should exist, got $ids", ids.contains("session_completed"))
35− }
36−}
app/src/androidTest/java/soy/iko/opencode/data/repo/CrashLoggerTest.ktdeleted−67
@@ -1,67 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import androidx.test.core.app.ApplicationProvider
4−import androidx.test.ext.junit.runners.AndroidJUnit4
5−import org.junit.Assert.assertEquals
6−import org.junit.Assert.assertNotNull
7−import org.junit.Assert.assertNull
8−import org.junit.Assert.assertTrue
9−import org.junit.Test
10−import org.junit.runner.RunWith
11−
12−/**
13− * Instrumented test for [CrashLogger]. Runs on a device/emulator so it can exercise the
14− * real app-private filesystem: a simulated crash is written, then read back, listed, and
15− * deleted through the public API.
16− */
17−@RunWith(AndroidJUnit4::class)
18−class CrashLoggerTest {
19−
20− private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
21− private val logger = CrashLogger.get(context)
22−
23− @Test
24− fun writeReadDeleteRoundTrip() {
25− logger.clearAll()
26−
27− // Simulate an uncaught exception by invoking the report path directly.
28− val report = writeFakeReport()
29−
30− logger.refresh()
31− assertEquals(1, logger.reportCount())
32−
33− val content = logger.readReport(report)
34− assertNotNull(content)
35− assertTrue("report should contain the stack trace", content!!.contains("IllegalStateException"))
36− assertTrue("report should contain device metadata", content.contains("Android:"))
37−
38− logger.deleteReport(report)
39− assertEquals(0, logger.reportCount())
40− assertNull(logger.readReport(report))
41− }
42−
43− @Test
44− fun clearAllRemovesEveryReport() {
45− writeFakeReport("a")
46− writeFakeReport("b")
47− logger.refresh()
48− assertTrue(logger.reportCount() >= 2)
49−
50− logger.clearAll()
51− assertEquals(0, logger.reportCount())
52− }
53−
54− private fun writeFakeReport(tag: String = "test"): String {
55− val dir = java.io.File(context.filesDir, "crashes").apply { mkdirs() }
56− val name = "crash-test-${tag}-${System.nanoTime()}.txt"
57− java.io.File(dir, name).writeText(
58− """
59− opencode-android crash report
60− Android: test
61− java.lang.IllegalStateException: boom $tag
62− at Crasher.crash(Crasher.kt:1)
63− """.trimIndent(),
64− )
65− return name
66− }
67−}
app/src/androidTest/java/soy/iko/opencode/data/repo/DraftStoreTest.ktdeleted−51
@@ -1,51 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import androidx.test.core.app.ApplicationProvider
4−import androidx.test.ext.junit.runners.AndroidJUnit4
5−import kotlinx.coroutines.CoroutineScope
6−import kotlinx.coroutines.Dispatchers
7−import kotlinx.coroutines.SupervisorJob
8−import kotlinx.coroutines.cancel
9−import kotlinx.coroutines.runBlocking
10−import org.junit.Assert.assertEquals
11−import org.junit.Test
12−import org.junit.runner.RunWith
13−
14−@RunWith(AndroidJUnit4::class)
15−class DraftStoreTest {
16−
17− private val context = ApplicationProvider.getApplicationContext<android.content.Context>()
18−
19− @Test
20− fun setGetRemoveRoundTrip() = runBlocking {
21− val scope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
22− val store = DraftStore(context, scope)
23−
24− // Use a unique session ID so it doesn't collide with other tests.
25− val sid = "test-${System.nanoTime()}"
26−
27− store.set(sid, "hello world")
28− assertEquals("hello world", store.get(sid))
29−
30− // Overwrite
31− store.set(sid, "updated")
32− assertEquals("updated", store.get(sid))
33−
34− // Blank clears the entry
35− store.set(sid, " ")
36− assertEquals("", store.get(sid))
37−
38− // Explicit remove
39− store.set(sid, "text")
40− store.remove(sid)
41− assertEquals("", store.get(sid))
42−
43− scope.cancel()
44− }
45−
46− @Test
47− fun getReturnsEmptyForUnknownSession() {
48− val store = DraftStore(context, CoroutineScope(SupervisorJob()))
49− assertEquals("", store.get("never-set-${System.nanoTime()}"))
50− }
51−}
app/src/main/AndroidManifest.xml+7−110
@@ -1,129 +1,26 @@
11<?xml version="1.0" encoding="utf-8"?>
2−<manifest xmlns:android="http://schemas.android.com/apk/res/android"
3− xmlns:tools="http://schemas.android.com/tools">
2+<manifest xmlns:android="http://schemas.android.com/apk/res/android">
43
54 <uses-permission android:name="android.permission.INTERNET" />
65 <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
7− <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
8− <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
9− <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
10−
11− <!-- Camera is used opportunistically for attachment capture via the system camera app
12− (ACTION_IMAGE_CAPTURE), so it's optional — devices without a camera still install. -->
13− <uses-feature android:name="android.hardware.camera.any" android:required="false" />
146
157 <application
16− android:name=".OpencodeApp"
17− android:allowBackup="false"
18− android:dataExtractionRules="@xml/data_extraction_rules"
19− android:fullBackupContent="false"
20− android:icon="@mipmap/ic_launcher"
8+ android:allowBackup="true"
9+ android:icon="@android:drawable/sym_def_app_icon"
2110 android:label="@string/app_name"
22− android:networkSecurityConfig="@xml/network_security_config"
2311 android:supportsRtl="true"
24− android:theme="@style/Theme.OpencodeAndroid">
12+ android:theme="@style/Theme.OpencodeAndroid"
13+ android:usesCleartextTraffic="true">
2514
2615 <activity
2716 android:name=".MainActivity"
2817 android:exported="true"
29− android:launchMode="singleTop"
30− android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode|navigation"
31− android:enableOnBackInvokedCallback="true"
32− tools:targetApi="33"
33− android:theme="@style/Theme.OpencodeAndroid.Splash">
18+ android:windowSoftInputMode="adjustResize"
19+ android:theme="@style/Theme.OpencodeAndroid">
3420 <intent-filter>
3521 <action android:name="android.intent.action.MAIN" />
3622 <category android:name="android.intent.category.LAUNCHER" />
3723 </intent-filter>
38− <intent-filter>
39− <action android:name="android.intent.action.SEND" />
40− <category android:name="android.intent.category.DEFAULT" />
41− <data android:mimeType="text/plain" />
42− </intent-filter>
43− <!-- Receive images shared from other apps (single or multiple) to attach to a prompt. -->
44− <intent-filter>
45− <action android:name="android.intent.action.SEND" />
46− <category android:name="android.intent.category.DEFAULT" />
47− <data android:mimeType="image/*" />
48− </intent-filter>
49− <intent-filter>
50− <action android:name="android.intent.action.SEND_MULTIPLE" />
51− <category android:name="android.intent.category.DEFAULT" />
52− <data android:mimeType="image/*" />
53− </intent-filter>
54− <!-- Deep link to a session: opencode://session/{sessionId} -->
55− <intent-filter>
56− <action android:name="android.intent.action.VIEW" />
57− <category android:name="android.intent.category.DEFAULT" />
58− <category android:name="android.intent.category.BROWSABLE" />
59− <data android:scheme="opencode" android:host="session" />
60− </intent-filter>
61− <!-- Deep links: opencode://file/{path} and opencode://new -->
62− <intent-filter>
63− <action android:name="android.intent.action.VIEW" />
64− <category android:name="android.intent.category.DEFAULT" />
65− <category android:name="android.intent.category.BROWSABLE" />
66− <data android:scheme="opencode" android:host="file" />
67− </intent-filter>
68− <intent-filter>
69− <action android:name="android.intent.action.VIEW" />
70− <category android:name="android.intent.category.DEFAULT" />
71− <category android:name="android.intent.category.BROWSABLE" />
72− <data android:scheme="opencode" android:host="new" />
73− </intent-filter>
7424 </activity>
75−
76− <service
77− android:name=".notification.RunForegroundService"
78− android:exported="false"
79− android:foregroundServiceType="dataSync" />
80−
81− <!-- Handles notification action buttons (permission responses, inline reply). Not
82− exported: only the app's own PendingIntents target it. -->
83− <receiver
84− android:name=".notification.NotificationActionReceiver"
85− android:exported="false" />
86−
87− <!-- Home-screen widget listing recent sessions. -->
88− <receiver
89− android:name=".platform.SessionsWidgetProvider"
90− android:exported="true">
91− <intent-filter>
92− <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
93− </intent-filter>
94− <meta-data
95− android:name="android.appwidget.provider"
96− android:resource="@xml/sessions_widget_info" />
97− </receiver>
98−
99− <!-- Serves the widget's session list rows. Bound by the system with BIND_REMOTEVIEWS. -->
100− <service
101− android:name=".platform.SessionsWidgetService"
102− android:permission="android.permission.BIND_REMOTEVIEWS"
103− android:exported="false" />
104−
105− <!-- Quick-settings tile: start a new session. Bound by the system. -->
106− <service
107− android:name=".platform.NewSessionTileService"
108− android:icon="@drawable/ic_launcher_foreground"
109− android:label="@string/tile_new_session"
110− android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
111− android:exported="true">
112− <intent-filter>
113− <action android:name="android.service.quicksettings.action.QS_TILE" />
114− </intent-filter>
115− </service>
116−
117− <!-- Serves camera-capture temp files (cache/captures) to the system camera app. -->
118− <provider
119− android:name="androidx.core.content.FileProvider"
120− android:authorities="${applicationId}.fileprovider"
121− android:exported="false"
122− android:grantUriPermissions="true">
123− <meta-data
124− android:name="android.support.FILE_PROVIDER_PATHS"
125− android:resource="@xml/file_paths" />
126− </provider>
12725 </application>
128−
12926</manifest>
app/src/main/java/soy/iko/opencode/CoreViewModel.ktnew file+66
@@ -0,0 +1,66 @@
1+package soy.iko.opencode
2+
3+import android.app.Application
4+import android.content.Context
5+import androidx.core.content.edit
6+import androidx.lifecycle.AndroidViewModel
7+import androidx.lifecycle.viewModelScope
8+import kotlinx.coroutines.flow.distinctUntilChangedBy
9+import kotlinx.coroutines.launch
10+import soy.iko.opencode.core.Event
11+import soy.iko.opencode.core.OpencodeCore
12+
13+/**
14+ * Lifecycle-scoped host for the [OpencodeCore] so a single core (and its SSE
15+ * connection) survives configuration changes. It also persists the last server
16+ * URL / username so reconnecting is one tap.
17+ */
18+class CoreViewModel(app: Application) : AndroidViewModel(app) {
19+ private val prefs = app.getSharedPreferences("opencode", Context.MODE_PRIVATE)
20+
21+ val core = OpencodeCore(viewModelScope)
22+
23+ init {
24+ prefs.getString(KEY_URL, null)?.let { core.dispatch(Event.ServerUrlChanged(it)) }
25+ prefs.getString(KEY_USER, null)?.let { core.dispatch(Event.UsernameChanged(it)) }
26+ // The password is stored in plain text in SharedPreferences — a deliberate
27+ // convenience trade-off for a local dev tool so reconnecting is one tap.
28+ prefs.getString(KEY_PASSWORD, null)?.let { core.dispatch(Event.PasswordChanged(it)) }
29+
30+ // On a fresh process after a configuration change, resume the SSE stream if
31+ // the core is already connected (no-op otherwise — it guards on `connected`).
32+ core.resumeStreamingIfConnected()
33+
34+ // Returning users shouldn't have to tap Connect: if a URL was restored and we
35+ // aren't already connected, kick off the connection attempt on launch. The core
36+ // handles an empty/invalid URL, so this only fires when there's something to try.
37+ core.view.value.let { restored ->
38+ if (restored.serverUrl.isNotEmpty() && !restored.connected) {
39+ core.dispatch(Event.Connect)
40+ }
41+ }
42+
43+ // Persist connection details once a connection succeeds.
44+ viewModelScope.launch {
45+ core.view.distinctUntilChangedBy { it.connected to it.serverUrl }.collect { state ->
46+ if (state.connected) {
47+ prefs.edit {
48+ putString(KEY_URL, state.serverUrl)
49+ putString(KEY_USER, state.username)
50+ putString(KEY_PASSWORD, state.password)
51+ }
52+ }
53+ }
54+ }
55+ }
56+
57+ override fun onCleared() {
58+ core.shutdown()
59+ }
60+
61+ private companion object {
62+ const val KEY_URL = "serverUrl"
63+ const val KEY_USER = "username"
64+ const val KEY_PASSWORD = "password"
65+ }
66+}
app/src/main/java/soy/iko/opencode/MainActivity.kt+15−344
@@ -1,360 +1,31 @@
11package soy.iko.opencode
22
3−import android.Manifest
4−import android.content.Intent
5−import android.content.pm.PackageManager
6−import android.net.Uri
7−import android.os.Build
83import android.os.Bundle
9−import android.view.WindowManager
10−import androidx.core.content.IntentCompat
11−import soy.iko.opencode.data.network.NetworkConfig
12−import soy.iko.opencode.notification.NotificationActionReceiver
4+import androidx.activity.ComponentActivity
5+import androidx.activity.SystemBarStyle
136import androidx.activity.compose.setContent
147import androidx.activity.enableEdgeToEdge
15−import androidx.activity.result.contract.ActivityResultContracts
16−import androidx.compose.foundation.isSystemInDarkTheme
17−import androidx.compose.material3.AlertDialog
18−import androidx.compose.material3.Text
19−import androidx.compose.material3.TextButton
20−import androidx.compose.runtime.LaunchedEffect
218import androidx.compose.runtime.getValue
22−import androidx.compose.runtime.mutableStateOf
23−import androidx.compose.runtime.remember
24−import androidx.compose.runtime.setValue
25−import androidx.compose.ui.res.stringResource
26−import kotlinx.coroutines.flow.combine
27−import androidx.core.content.ContextCompat
28−import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
29−import androidx.fragment.app.FragmentActivity
309import androidx.lifecycle.compose.collectAsStateWithLifecycle
31−import soy.iko.opencode.data.repo.SettingsStore
32−import soy.iko.opencode.data.repo.ThemeMode
33−import soy.iko.opencode.ui.AppLockGate
34−import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
10+import androidx.lifecycle.viewmodel.compose.viewModel
11+import soy.iko.opencode.ui.OpencodeApp
3512import soy.iko.opencode.ui.theme.OpencodeTheme
3613
37−// FragmentActivity (not ComponentActivity) so androidx.biometric BiometricPrompt can attach
38−// for the app-lock gate; FragmentActivity extends ComponentActivity, so Compose/edge-to-edge
39−// /activity-result APIs are unaffected.
40−class MainActivity : FragmentActivity() {
41−
42− private val requestNotificationPermission = registerForActivityResult(
43− ActivityResultContracts.RequestPermission(),
44− ) { /* Result is best-effort; notifications are silently skipped if denied. */ }
45−
46− private var shareIntentHandled = false
47− private var openSessionHandled = false
48− private var newSessionHandled = false
49− private var openFileHandled = false
50− // Cold-start prompts (crash report, notification rationale) rendered as Compose M3
51− // dialogs. Hoisted as Activity-level mutableState so the non-composable trigger logic
52− // (onCreate/onResume) can flip them and the Compose tree (inside OpencodeTheme) reads
53− // them and renders an M3 AlertDialog that picks up the app palette. The triggers
54− // themselves are idempotent per instance (crashes are acknowledged on first fire;
55− // notificationPermissionRequested is restored from saved state), so a plain
56− // mutableStateOf (not saveable) preserves the original dismissal semantics across
57− // config-change recreation: the prompt simply doesn't re-fire after a rotate.
58− private var showCrashPrompt by mutableStateOf(false)
59− private var showNotifRationale by mutableStateOf(false)
60− // Track whether the notification-permission prompt has been shown for this Activity
61− // instance. onCreate only requests when savedInstanceState == null (a fresh process),
62− // but an Activity recreated by a config change NOT listed in the manifest's configChanges
63− // (e.g. a locale change) has a non-null savedInstanceState and would otherwise skip the
64− // prompt forever. onResume re-requests once per instance when still ungranted so the user
65− // is never silently left without notifications after such a recreation.
66− private var notificationPermissionRequested = false
67−
14+class MainActivity : ComponentActivity() {
6815 override fun onCreate(savedInstanceState: Bundle?) {
69− val splash = installSplashScreen()
16+ enableEdgeToEdge(
17+ statusBarStyle = SystemBarStyle.auto(
18+ lightScrim = 0xFF0F1115.toInt(),
19+ darkScrim = 0xFF0F1115.toInt(),
20+ ),
21+ )
7022 super.onCreate(savedInstanceState)
71− enableEdgeToEdge()
72− savedInstanceState?.let {
73− shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false)
74− openSessionHandled = it.getBoolean(KEY_OPEN_SESSION_HANDLED, false)
75− newSessionHandled = it.getBoolean(KEY_NEW_SESSION_HANDLED, false)
76− openFileHandled = it.getBoolean(KEY_OPEN_FILE_HANDLED, false)
77− }
78− val container = (application as OpencodeApp).container
79− handleIntent(intent)
80− if (savedInstanceState == null) {
81− maybeRequestNotificationPermission()
82− } else {
83− // Restored instance: the permission may have been requested pre-recreation, but
84− // we can't know the result from here. onResume will re-check and request if still
85− // ungranted (once per Activity instance).
86− notificationPermissionRequested = savedInstanceState.getBoolean(KEY_NOTIF_PERM_REQUESTED, false)
87− }
88− // Hold the splash until the persisted theme and app-lock settings load, so we never
89− // paint a frame with the defaults (dynamicColor=false / SYSTEM, app lock off) that then
90− // snap to the user's real choice — including a brief unlocked, un-FLAG_SECURE'd frame
91− // before app lock resolves. Mirrors the SettingsScreen's null-gate.
92− var settingsLoaded = false
93− splash.setKeepOnScreenCondition { !settingsLoaded }
9423 setContent {
95− val theme by remember(container) {
96− container.settingsStore.themeMode
97− .combine(container.settingsStore.dynamicColor, ::Pair)
98− }.collectAsStateWithLifecycle(initialValue = null)
99− val appLock by container.settingsStore.appLock.collectAsStateWithLifecycle(initialValue = null)
100− val appLockReLockSeconds by container.settingsStore.appLockReLockSeconds
101− .collectAsStateWithLifecycle(initialValue = SettingsStore.DEFAULT_APP_LOCK_RELOCK_SECONDS)
102− LaunchedEffect(theme, appLock) { if (theme != null && appLock != null) settingsLoaded = true }
103− // Hide the app's content from screenshots / the recents thumbnail while app lock is
104− // enabled — and while its value is still unknown (null) at cold start, so a protected
105− // launch can't flash into the recents thumbnail before the setting resolves.
106− LaunchedEffect(appLock) {
107− if (appLock != false) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
108− else window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
109− }
110− val themePair = theme
111− val locked = appLock
112− if (themePair != null && locked != null) {
113− val (themeMode, dynamicColor) = themePair
114− val amoled = themeMode == ThemeMode.AMOLED
115− val dark = when (themeMode) {
116− ThemeMode.SYSTEM -> isSystemInDarkTheme()
117− ThemeMode.LIGHT -> false
118− ThemeMode.DARK -> true
119− ThemeMode.AMOLED -> true
120− }
121− OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor, amoled = amoled) {
122− AppLockGate(enabled = locked, reLockDelaySeconds = appLockReLockSeconds) {
123− OpencodeAppUi(container = container)
124− }
125− // First-launch prompts as Compose M3 dialogs (siblings of AppLockGate so they
126− // still surface over the lock screen, matching the prior framework-dialog
127− // behavior) and inside OpencodeTheme so they use the app palette.
128− if (showCrashPrompt) {
129− AlertDialog(
130− onDismissRequest = { showCrashPrompt = false },
131− title = { Text(stringResource(R.string.crash_last_run_title)) },
132− text = { Text(stringResource(R.string.crash_last_run_text)) },
133− confirmButton = {
134− TextButton(onClick = {
135− showCrashPrompt = false
136− container.requestDiagnostics()
137− }) { Text(stringResource(R.string.crash_last_run_view)) }
138− },
139− dismissButton = {
140− TextButton(onClick = { showCrashPrompt = false }) {
141− Text(stringResource(R.string.crash_last_run_dismiss))
142− }
143− },
144− )
145− }
146− if (showNotifRationale) {
147− AlertDialog(
148− // Mirrors the original setCancelable(false): not dismissible by
149− // back/outside tap, only by the buttons below.
150− onDismissRequest = { },
151− title = { Text(stringResource(R.string.notif_rationale_title)) },
152− text = { Text(stringResource(R.string.notif_rationale_text)) },
153− confirmButton = {
154− TextButton(onClick = {
155− showNotifRationale = false
156− runCatching { requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS) }
157− }) { Text(stringResource(R.string.notif_rationale_allow)) }
158− },
159− dismissButton = {
160− TextButton(onClick = { showNotifRationale = false }) {
161− Text(stringResource(R.string.notif_rationale_skip))
162− }
163− },
164− )
165− }
166− }
167− }
168− }
169− // Surface a "crashed last time" prompt if a crash report from a previous run
170− // hasn't been acknowledged. Acknowledges immediately so it doesn't re-fire on
171− // every rotation/resume; the user can always find reports in Settings → Diagnostics.
172− maybeShowCrashPrompt()
173− }
174−
175− private fun maybeShowCrashPrompt() {
176− val crashLogger = soy.iko.opencode.data.repo.CrashLogger.get(this)
177− if (!crashLogger.hasUnacknowledgedCrash()) return
178− crashLogger.acknowledgeCrashes()
179− showCrashPrompt = true
180− }
181−
182− override fun onNewIntent(intent: Intent) {
183− super.onNewIntent(intent)
184− setIntent(intent)
185− shareIntentHandled = false
186− openSessionHandled = false
187− newSessionHandled = false
188− openFileHandled = false
189− handleIntent(intent)
190− }
191−
192− // Track real foreground state so background notifications (permission requests,
193− // completions, errors) still fire for a session the user "has open" but has walked
194− // away from — the chat screen isn't disposed when the phone is locked, so
195− // container.currentSession stays set and can't be used as a foreground signal.
196− override fun onStart() {
197− super.onStart()
198− (application as OpencodeApp).container.setForeground(true)
199− }
200−
201− override fun onStop() {
202− super.onStop()
203− (application as OpencodeApp).container.setForeground(false)
204− }
205−
206− override fun onResume() {
207− super.onResume()
208− // Re-check on resume: an Activity recreated by a config change not in configChanges
209− // (e.g. locale) has savedInstanceState != null, so onCreate skipped the request. This
210− // ensures the user is prompted once per Activity instance when still ungranted rather
211− // than silently losing all notifications.
212− maybeRequestNotificationPermission()
213− }
214−
215− /** Capture text shared from another app so it can be prefilled into a session draft,
216− * and session ids from notification taps / deep links so we can open them. */
217− private fun handleIntent(intent: Intent?) {
218− val container = (application as OpencodeApp).container
219− val action = intent?.action
220− // "New session" from a launcher shortcut or the quick-settings tile. Guarded so a
221− // process-death restore (which re-delivers the intent) doesn't spawn a second session.
222− if (action == ACTION_NEW_SESSION && !newSessionHandled) {
223− container.requestNewSession()
224− newSessionHandled = true
225− }
226− if ((action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE) && !shareIntentHandled) {
227− val type = intent.type.orEmpty()
228− if (action == Intent.ACTION_SEND && type.startsWith("image/")) {
229− // A single shared image → stage as an attachment in the next opened session.
230− val uri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
231− if (uri != null) {
232− container.setPendingSharedMedia(listOf(uri.toString()))
233− shareIntentHandled = true
234− }
235− } else if (action == Intent.ACTION_SEND_MULTIPLE && type.startsWith("image/")) {
236− val uris = IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
237− ?.take(NetworkConfig.maxAttachments)
238− .orEmpty()
239− if (uris.isNotEmpty()) {
240− container.setPendingSharedMedia(uris.map { it.toString() })
241− shareIntentHandled = true
242− }
243− } else {
244− val text = intent.getStringExtra(Intent.EXTRA_TEXT)
245− ?.takeIf { it.isNotBlank() }
246− ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
247− if (text != null) {
248− container.setPendingShare(text)
249− shareIntentHandled = true
250− }
251− }
252− }
253− // Deep links under the opencode:// scheme. The manifest exposes several hosts:
254− // session/{id} → open (and switch to) that conversation
255− // file/{path} → open the file viewer at that workspace path
256− // new → start a fresh session
257− // The file path is percent-encoded by the link source; decode it here and validate the
258− // segment to keep path-traversal out of the REST path (mirroring the session guard).
259− val data = intent?.takeIf { it.action == Intent.ACTION_VIEW }?.data
260− when (data?.host) {
261− "session" -> {
262− val seg = data.lastPathSegment
263− if (!openSessionHandled) {
264− seg?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
265− val profileId = intent.getStringExtra(NotificationActionReceiver.EXTRA_PROFILE_ID)
266− ?.takeIf { it.isNotBlank() }
267− container.requestOpenSession(it, profileId)
268− openSessionHandled = true
269− }
270− }
271− }
272− "file" -> {
273− // Reconstruct the path from the path segments after "file", preserving internal
274− // slashes. Decoded + validated against traversal/control characters.
275− val rawPath = data.path?.removePrefix("/file/")?.let { android.net.Uri.decode(it) }
276− if (!openFileHandled) {
277− rawPath?.takeIf { it.isNotBlank() && VALID_FILE_PATH.containsMatchIn(it) && !it.contains("..") }
278− ?.let { container.requestOpenFile(it); openFileHandled = true }
279− }
280− }
281− "new" -> {
282− if (!newSessionHandled) {
283− container.requestNewSession()
284− newSessionHandled = true
285− }
24+ OpencodeTheme {
25+ val vm: CoreViewModel = viewModel()
26+ val state by vm.core.view.collectAsStateWithLifecycle()
27+ OpencodeApp(state = state, dispatch = vm.core::dispatch)
28628 }
28729 }
288− // Fall back to the EXTRA_SESSION_ID extra (notifications / widget) when no deep link matched.
289− if (!openSessionHandled) {
290− intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
291− val profileId = intent.getStringExtra(NotificationActionReceiver.EXTRA_PROFILE_ID)
292− ?.takeIf { it.isNotBlank() }
293− container.requestOpenSession(it, profileId)
294− openSessionHandled = true
295− }
296− }
297− }
298−
299− companion object {
300− /** Intent extra carrying a session id to open (notifications / deep links / widget). */
301− const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
302−
303− /** Action for the "New session" launcher shortcut and quick-settings tile. */
304− const val ACTION_NEW_SESSION = "soy.iko.opencode.action.NEW_SESSION"
305−
306− /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
307− private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
308−
309− /** A file path must be made of path-ish characters and not carry traversal. Used to
310− * sanity-check the file deep link before handing it to the viewer. */
311− private val VALID_FILE_PATH = Regex("[A-Za-z0-9_\\-./ ]+")
312−
313− private const val KEY_SHARE_HANDLED = "shareIntentHandled"
314− private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
315− private const val KEY_NEW_SESSION_HANDLED = "newSessionHandled"
316− private const val KEY_OPEN_FILE_HANDLED = "openFileHandled"
317− private const val KEY_NOTIF_PERM_REQUESTED = "notificationPermissionRequested"
318− }
319−
320− override fun onSaveInstanceState(outState: Bundle) {
321− super.onSaveInstanceState(outState)
322− val container = (application as OpencodeApp).container
323− // Persist each intent-handled flag as "consumed", not merely "dispatched". The pending
324− // signals live only in the in-memory AppContainer, which is recreated EMPTY on process
325− // death. Persisting a plain "dispatched = true" would, after a kill *before* the UI
326− // consumed the signal, restore the flag as true so the fresh container never receives it —
327− // the deep-linked / new / shared session would silently never open. So AND each flag with
328− // "the container no longer holds the signal": still-pending at save time → persist false so
329− // the re-delivered intent re-dispatches it into the new container on restore; already
330− // consumed → persist true so we don't re-open it unexpectedly on every later restore.
331− val openConsumed = container.pendingOpenSession.value == null
332− val newConsumed = container.pendingNewSession.value == 0
333− val shareConsumed = container.pendingShare.value == null && container.pendingSharedMedia.value.isEmpty()
334− val fileConsumed = container.pendingOpenFile.value == null
335− outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled && shareConsumed)
336− outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled && openConsumed)
337− outState.putBoolean(KEY_NEW_SESSION_HANDLED, newSessionHandled && newConsumed)
338− outState.putBoolean(KEY_OPEN_FILE_HANDLED, openFileHandled && fileConsumed)
339− outState.putBoolean(KEY_NOTIF_PERM_REQUESTED, notificationPermissionRequested)
340− }
341−
342− /** Ask for POST_NOTIFICATIONS on Android 13+ so run/completion notifications show.
343− * Idempotent per Activity instance via [notificationPermissionRequested].
344− * Shows a rationale dialog first (explaining what the user gains) before the system
345− * permission prompt, so a user who denies understands what they're missing. */
346− private fun maybeRequestNotificationPermission() {
347− if (notificationPermissionRequested) return
348− if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
349− val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
350− PackageManager.PERMISSION_GRANTED
351− if (!granted) {
352− notificationPermissionRequested = true
353− showNotificationRationaleDialog()
354− }
355− }
356−
357− private fun showNotificationRationaleDialog() {
358− showNotifRationale = true
35930 }
36031}
app/src/main/java/soy/iko/opencode/OpencodeApp.ktdeleted−136
@@ -1,136 +0,0 @@
1−package soy.iko.opencode
2−
3−import android.app.Application
4−import coil.ImageLoader
5−import coil.ImageLoaderFactory
6−import coil.disk.DiskCache
7−import coil.memory.MemoryCache
8−import okhttp3.Call
9−import okhttp3.CertificatePinner
10−import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
11−import okhttp3.OkHttpClient
12−import okhttp3.Request
13−import soy.iko.opencode.data.model.ServerProfile
14−import soy.iko.opencode.data.network.HttpClientFactory
15−import soy.iko.opencode.data.repo.CrashLogger
16−import soy.iko.opencode.di.AppContainer
17−import java.util.concurrent.atomic.AtomicBoolean
18−
19−class OpencodeApp : Application(), ImageLoaderFactory {
20− lateinit var container: AppContainer
21− private set
22−
23− private val shutdownCalled = AtomicBoolean(false)
24−
25− override fun onCreate() {
26− super.onCreate()
27− CrashLogger.get(this).install()
28− container = AppContainer(this)
29− // onTerminate() is only called in emulated process environments, not on real
30− // devices. Register a JVM shutdown hook so cleanup (network callback unregister,
31− // scope cancellation, connection close) runs when the process is actually killed.
32− Runtime.getRuntime().addShutdownHook(Thread {
33− shutdown()
34− })
35− }
36−
37− // Process-wide Coil singleton. The defaults are sane; the explicit factory adds a
38− // disk cache so scrolling back to an image-bearing message doesn't re-fetch it over
39− // the (auth-protected) network every time. Data-URI (base64) images skip the disk
40− // cache automatically; only resolved HTTP/S URLs are cached, keyed by URL.
41− //
42− // Route fetches through a Call.Factory that applies the SAME per-profile certificate
43− // pinning as the REST/SSE client (see HttpClientFactory). Server-hosted images carry the
44− // server's HTTP Basic auth header (see RemoteImage), so without this they would travel over
45− // an UNPINNED TLS connection, silently defeating the pin the user opted into for that host.
46− override fun newImageLoader(): ImageLoader = ImageLoader.Builder(this)
47− .memoryCache {
48− MemoryCache.Builder(this)
49− .maxSizePercent(0.25)
50− .build()
51− }
52− .diskCache {
53− DiskCache.Builder()
54− .directory(cacheDir.resolve("image_cache"))
55− .maxSizeBytes(50L * 1024 * 1024)
56− .build()
57− }
58− .callFactory { PinnedImageCallFactory(container) }
59− .build()
60−
61− override fun onTerminate() {
62− super.onTerminate()
63− shutdown()
64− }
65−
66− private fun shutdown() {
67− // Guard against double shutdown — onTerminate and the shutdown hook can both
68− // fire in emulated environments.
69− if (!shutdownCalled.compareAndSet(false, true)) return
70− container.shutdown()
71− }
72−}
73−
74−/**
75− * A Coil [Call.Factory] that pins image loads exactly like [HttpClientFactory] pins the
76− * REST/SSE channel. The [ImageLoader] is a process singleton built once, but the active
77− * [ServerProfile] — and therefore its pins — changes when the user switches servers, so the
78− * certificate pinner is resolved *per request* from [AppContainer.activeConnection] rather than
79− * baked in at build time. This fails closed: a profile that configures pins always gets a pinned
80− * connection for its images (an unparseable pinned host throws, matching HttpClientFactory), so
81− * image loads for a pinned host are never silently downgraded to an unpinned channel — and a
82− * later server switch is picked up immediately instead of retaining the first profile's pins.
83− */
84−private class PinnedImageCallFactory(private val container: AppContainer) : Call.Factory {
85− // A single shared base client; each derived pinned client reuses this one's connection pool
86− // and dispatcher via newBuilder(), so per-profile pinning doesn't spin up parallel resources.
87− private val base = OkHttpClient()
88−
89− // Cache the last derived client keyed by (host, pins) so we don't rebuild it on every request;
90− // a server switch changes the signature and triggers a rebuild. Guarded because Coil invokes
91− // newCall from arbitrary background threads, possibly concurrently.
92− private val lock = Any()
93− private var cachedSignature: String? = null
94− private var cachedClient: OkHttpClient = base
95−
96− override fun newCall(request: Request): Call =
97− clientFor(container.activeConnection.value?.profile).newCall(request)
98−
99− private fun clientFor(profile: ServerProfile?): OkHttpClient {
100− val pins = parsePins(profile?.certPin)
101− if (pins.isEmpty()) return base
102− // effectiveBaseUrl upgrades http->https when pinning is on, so the pinned host matches the
103− // https origin RemoteImage resolves image URLs against. Fail closed on an unparseable host
104− // (never fall back to an unpinned client), mirroring HttpClientFactory.create().
105− val effectiveUrl = effectiveBaseUrl(profile!!)
106− val host = effectiveUrl.toHttpUrlOrNull()?.host
107− ?: throw IllegalStateException(
108− "Certificate pinning is configured but the server host could not be parsed from $effectiveUrl",
109− )
110− val signature = "$host\n${pins.sorted().joinToString(" ")}"
111− synchronized(lock) {
112− if (signature != cachedSignature) {
113− val pinner = CertificatePinner.Builder().apply { pins.forEach { add(host, it) } }.build()
114− cachedClient = base.newBuilder().certificatePinner(pinner).build()
115− cachedSignature = signature
116− }
117− return cachedClient
118− }
119− }
120−
121− // parsePins / effectiveBaseUrl mirror the private helpers in HttpClientFactory (the source of
122− // truth). This file may not edit HttpClientFactory and those helpers aren't exposed, so the
123− // pin-parsing and http->https upgrade logic is replicated here and must be kept in sync.
124− private fun parsePins(raw: String?): List<String> =
125− raw?.split(Regex("[\\s,]+"))?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
126−
127− private fun effectiveBaseUrl(profile: ServerProfile): String {
128− val normalized = HttpClientFactory.normalizeBaseUrl(profile.baseUrl)
129− val forceHttps = profile.requireHttps || parsePins(profile.certPin).isNotEmpty()
130− return if (forceHttps && normalized.lowercase().startsWith("http://")) {
131− "https://" + normalized.substring("http://".length)
132− } else {
133− normalized
134− }
135− }
136−}
app/src/main/java/soy/iko/opencode/core/HttpClient.ktnew file+75
@@ -0,0 +1,75 @@
1+package soy.iko.opencode.core
2+
3+import kotlinx.coroutines.Dispatchers
4+import kotlinx.coroutines.suspendCancellableCoroutine
5+import kotlinx.coroutines.withContext
6+import okhttp3.Call
7+import okhttp3.Callback
8+import okhttp3.MediaType.Companion.toMediaType
9+import okhttp3.OkHttpClient
10+import okhttp3.Request
11+import okhttp3.RequestBody.Companion.toRequestBody
12+import okhttp3.Response
13+import java.io.IOException
14+import java.util.concurrent.TimeUnit
15+import kotlin.coroutines.resume
16+import kotlin.coroutines.resumeWithException
17+
18+/** A completed HTTP response (any status code — non-2xx is not an error here). */
19+data class HttpResp(val code: Int, val body: String)
20+
21+/**
22+ * Thin OkHttp wrapper for the opencode REST calls. Transport failures surface as
23+ * [Result.failure]; every actual HTTP response (including 4xx/5xx) is a success
24+ * carrying its status code, matching the Rust core's `HttpResult` handling.
25+ */
26+class HttpClient(
27+ val okhttp: OkHttpClient = OkHttpClient.Builder()
28+ .connectTimeout(15, TimeUnit.SECONDS)
29+ .readTimeout(30, TimeUnit.SECONDS)
30+ .build(),
31+) {
32+ private val jsonMedia = "application/json".toMediaType()
33+
34+ suspend fun get(url: String, auth: String?): Result<HttpResp> =
35+ exec(url, auth) { get() }
36+
37+ suspend fun postJson(url: String, body: String, auth: String?): Result<HttpResp> =
38+ exec(url, auth) { post(body.toRequestBody(jsonMedia)) }
39+
40+ suspend fun delete(url: String, auth: String?): Result<HttpResp> =
41+ exec(url, auth) { delete() }
42+
43+ // Build and run the request off the main thread. Building it — parsing [url]
44+ // in particular — can throw (e.g. a URL with no scheme), so that happens
45+ // inside [runCatching] too: a bad URL then surfaces as [Result.failure]
46+ // instead of crashing the caller's coroutine.
47+ private suspend fun exec(
48+ url: String,
49+ auth: String?,
50+ method: Request.Builder.() -> Request.Builder,
51+ ): Result<HttpResp> = withContext(Dispatchers.IO) {
52+ runCatching {
53+ val builder = Request.Builder().url(url).method()
54+ builder.header("User-Agent", "opencode-android/1.0")
55+ if (auth != null) builder.header("Authorization", auth)
56+ await(okhttp.newCall(builder.build()))
57+ }
58+ }
59+
60+ private suspend fun await(call: Call): HttpResp = suspendCancellableCoroutine { cont ->
61+ cont.invokeOnCancellation { runCatching { call.cancel() } }
62+ call.enqueue(object : Callback {
63+ override fun onFailure(call: Call, e: IOException) {
64+ if (cont.isActive) cont.resumeWithException(e)
65+ }
66+
67+ override fun onResponse(call: Call, response: Response) {
68+ response.use {
69+ val body = runCatching { it.body?.string() }.getOrNull().orEmpty()
70+ if (cont.isActive) cont.resume(HttpResp(it.code, body))
71+ }
72+ }
73+ })
74+ }
75+}
app/src/main/java/soy/iko/opencode/core/OpencodeCore.ktnew file+668
@@ -0,0 +1,668 @@
1+package soy.iko.opencode.core
2+
3+import kotlinx.coroutines.CoroutineScope
4+import kotlinx.coroutines.Job
5+import kotlinx.coroutines.delay
6+import kotlinx.coroutines.flow.MutableStateFlow
7+import kotlinx.coroutines.flow.StateFlow
8+import kotlinx.coroutines.flow.asStateFlow
9+import kotlinx.coroutines.launch
10+import java.util.Base64
11+
12+/**
13+ * The application core: a Kotlin port of the Rust Crux core (`shared/`).
14+ *
15+ * It owns all state, turns [Event]s into state changes + effects, and exposes an
16+ * observable [view]. Effects are executed here in the shell: REST calls through
17+ * [HttpClient] and the long-lived `/event` stream through [SseClient]. Every
18+ * server-sent event is fed back in and applied incrementally, so assistant
19+ * replies stream in token-by-token.
20+ *
21+ * All state mutation happens on [scope]'s (main) dispatcher; only the blocking
22+ * network I/O hops to a background thread inside the clients.
23+ */
24+class OpencodeCore(
25+ private val scope: CoroutineScope,
26+ private val http: HttpClient = HttpClient(),
27+) {
28+ // ── Model ────────────────────────────────────────────────────────────────
29+ private var serverUrl = ""
30+ private var username = ""
31+ private var password = ""
32+ private var authRequired = false
33+ private var connected = false
34+ private var loading = false
35+ private var error: String? = null
36+ private var screen = Screen.Connect
37+ private val sessions = mutableListOf<SessionView>()
38+ private var currentSessionId: String? = null
39+ private var currentSessionTitle = ""
40+ private val messages = mutableListOf<MsgState>()
41+ private var draft = ""
42+ private var generating = false
43+ private var sseConnected = true
44+ private var localSeq = 0L
45+
46+ private val _view = MutableStateFlow(snapshot())
47+ val view: StateFlow<UiState> = _view.asStateFlow()
48+
49+ private var sse: SseClient? = null
50+ private var sseClosedIntentionally = false
51+ private var reconnectAttempts = 0
52+ private var reconnectJob: Job? = null
53+
54+ // Guards against overlapping session refreshes: rapid taps launch multiple
55+ // loadSessions coroutines and the slowest-to-return would otherwise win.
56+ private var loadSessionsJob: Job? = null
57+ // Tracks the in-flight createSession coroutine so navigating away can cancel it
58+ // before it dispatches SelectSession into a screen the user already left.
59+ private var createSessionJob: Job? = null
60+ // Tracks the in-flight loadMessages coroutine. Rapidly switching sessions launches
61+ // several; cancel the prior one so only the newest fetch runs (saves needless requests).
62+ private var loadMessagesJob: Job? = null
63+ // Tracks the in-flight probeHealth coroutine. Rapid double-taps on Connect would
64+ // otherwise launch two concurrent health probes; cancel the prior one first.
65+ private var connectJob: Job? = null
66+ // Tracks the in-flight DeleteSession coroutine so navigating away can cancel it
67+ // before its response handler mutates the sessions list / screen the user already left.
68+ private var deleteSessionJob: Job? = null
69+ // Tracks the in-flight sendMessage coroutine so navigating away or switching sessions
70+ // can cancel it before its response handler touches stale message state.
71+ private var sendMessageJob: Job? = null
72+ // Tracks the in-flight abort coroutine (CancelGeneration) so rapid cancel taps don't
73+ // fire multiple abort requests, and navigating away can cancel it.
74+ private var abortJob: Job? = null
75+ // Message IDs removed via SSE; a late MessageUpdated for one must not resurrect it.
76+ private val removedMessageIds = mutableSetOf<String>()
77+
78+ // ── Event entry point ──────────────────────────────────────────────────────
79+ fun dispatch(event: Event) {
80+ when (event) {
81+ // Store the field verbatim while typing; normalizing here would fight
82+ // the user's edits (e.g. eat the "://" as they type it). The URL is
83+ // normalized once, on Connect.
84+ // Clear any prior error as soon as the user edits a field, so a stale
85+ // message (e.g. "Invalid credentials") doesn't linger while they correct it.
86+ is Event.ServerUrlChanged -> { serverUrl = event.url; if (error != null) error = null; emit() }
87+ is Event.UsernameChanged -> { username = event.value; if (error != null) error = null; emit() }
88+ is Event.PasswordChanged -> { password = event.value; if (error != null) error = null; emit() }
89+
90+ Event.Connect -> {
91+ serverUrl = normalizeUrl(serverUrl)
92+ if (serverUrl.isEmpty()) { error = "Enter a server URL"; emit(); return }
93+ // The auth fields are showing but empty — sending the request now
94+ // would just 401 again in a loop. Prompt for credentials instead.
95+ if (authRequired && !hasCredentials()) { error = "Enter username and password"; emit(); return }
96+ // Leave authRequired as-is; probeHealth sets it from the response (true on
97+ // 401, false on success). Resetting it here just flickers the auth fields.
98+ loading = true; error = null; emit()
99+ connectJob?.cancel()
100+ connectJob = scope.launch { probeHealth() }
101+ }
102+
103+ Event.LoadSessions -> {
104+ if (!connected) { error = "Not connected"; emit(); return }
105+ loading = true; emit()
106+ loadSessionsJob?.cancel()
107+ loadSessionsJob = scope.launch { loadSessions() }
108+ }
109+
110+ is Event.SelectSession -> {
111+ // The session may have been deleted (e.g. via SSE) while the list was on
112+ // screen; opening it would only 404 on loadMessages. Bail with an error.
113+ val session = sessions.firstOrNull { it.id == event.id }
114+ if (session == null) { error = "Session no longer exists"; emit(); return }
115+ currentSessionTitle = session.title
116+ currentSessionId = event.id
117+ messages.clear()
118+ removedMessageIds.clear()
119+ screen = Screen.Chat
120+ loading = true
121+ generating = false
122+ error = null
123+ emit()
124+ connectJob?.cancel()
125+ abortJob?.cancel()
126+ sendMessageJob?.cancel()
127+ deleteSessionJob?.cancel()
128+ loadMessagesJob?.cancel()
129+ loadMessagesJob = scope.launch { loadMessages(event.id) }
130+ }
131+
132+ Event.CreateSession -> {
133+ if (!connected) { error = "Not connected"; emit(); return }
134+ loading = true; error = null; emit()
135+ createSessionJob?.cancel()
136+ createSessionJob = scope.launch { createSession() }
137+ }
138+
139+ is Event.DeleteSession -> {
140+ if (!connected) { error = "Not connected"; emit(); return }
141+ val id = event.id
142+ // Delete on the server first; only drop it from the UI once that
143+ // succeeds, so a failed request leaves the session recoverable.
144+ loading = true; error = null; emit()
145+ deleteSessionJob?.cancel()
146+ deleteSessionJob = scope.launch {
147+ http.delete("$serverUrl/session/$id", authHeader()).fold(
148+ onSuccess = { resp ->
149+ if (resp.code in 200..299) {
150+ sessions.removeAll { it.id == id }
151+ if (isCurrent(id)) {
152+ currentSessionId = null
153+ messages.clear()
154+ screen = Screen.Sessions
155+ }
156+ } else {
157+ error = "Failed to delete session"
158+ }
159+ loading = false
160+ },
161+ onFailure = {
162+ error = "Failed to delete session"
163+ loading = false
164+ },
165+ )
166+ emit()
167+ }
168+ }
169+
170+ is Event.DraftChanged -> { draft = event.text; emit() }
171+
172+ is Event.SendMessage -> sendMessage(event.text)
173+
174+ Event.CancelGeneration -> {
175+ generating = false
176+ messages.forEach { it.streaming = false }
177+ emit()
178+ val id = currentSessionId ?: return
179+ // Debounce rapid cancel taps: cancel any in-flight abort before firing
180+ // another so we don't stack duplicate abort requests.
181+ abortJob?.cancel()
182+ abortJob = scope.launch { http.postJson("$serverUrl/session/$id/abort", "{}", authHeader()) }
183+ }
184+
185+ Event.NavigateToSessions -> {
186+ screen = Screen.Sessions
187+ currentSessionId = null
188+ messages.clear()
189+ removedMessageIds.clear()
190+ draft = ""
191+ generating = false
192+ // Clear any error from the chat (e.g. a failed send) so it doesn't
193+ // linger on the sessions list after the user navigates back.
194+ error = null
195+ loading = true
196+ emit()
197+ connectJob?.cancel()
198+ abortJob?.cancel()
199+ createSessionJob?.cancel()
200+ deleteSessionJob?.cancel()
201+ sendMessageJob?.cancel()
202+ loadMessagesJob?.cancel()
203+ loadSessionsJob?.cancel()
204+ loadSessionsJob = scope.launch { loadSessions() }
205+ }
206+
207+ Event.NavigateToConnect -> {
208+ screen = Screen.Connect
209+ connected = false
210+ loading = false
211+ generating = false
212+ sessions.clear()
213+ messages.clear()
214+ removedMessageIds.clear()
215+ currentSessionId = null
216+ draft = ""
217+ // Reset to the default so a late-firing close callback from the old
218+ // SSE connection can't flash the reconnecting banner on the next connect.
219+ sseConnected = true
220+ connectJob?.cancel()
221+ abortJob?.cancel()
222+ createSessionJob?.cancel()
223+ deleteSessionJob?.cancel()
224+ sendMessageJob?.cancel()
225+ loadMessagesJob?.cancel()
226+ closeSse()
227+ emit()
228+ }
229+
230+ Event.DismissError -> { error = null; emit() }
231+ }
232+ }
233+
234+ /** Reconnect the stream on a fresh process (e.g. after rotation) if already connected. */
235+ fun resumeStreamingIfConnected() {
236+ if (connected && sse == null) startSse()
237+ }
238+
239+ fun shutdown() {
240+ // Cancel every tracked coroutine before tearing down the stream so none can
241+ // emit into a cancelled scope after the ViewModel is cleared.
242+ connectJob?.cancel()
243+ loadSessionsJob?.cancel()
244+ createSessionJob?.cancel()
245+ loadMessagesJob?.cancel()
246+ deleteSessionJob?.cancel()
247+ sendMessageJob?.cancel()
248+ abortJob?.cancel()
249+ closeSse()
250+ }
251+
252+ // ── Effects ────────────────────────────────────────────────────────────────
253+ private fun sendMessage(raw: String) {
254+ val text = raw.trim()
255+ val session = currentSessionId ?: return
256+ if (text.isEmpty()) return
257+ // Debounce rapid double-taps on send: a send is already in flight, so a second
258+ // tap would create a duplicate user message and fire a duplicate request.
259+ if (generating) return
260+ localSeq += 1
261+ val user = MsgState("local-$localSeq", "user", System.currentTimeMillis()).apply {
262+ status = MessageStatus.Pending
263+ textParts["local"] = text
264+ }
265+ messages.add(user)
266+ // Keep the draft text so we can restore it if the send fails; otherwise the
267+ // user's message is lost and they'd have to retype it.
268+ val savedDraft = draft
269+ draft = ""
270+ generating = true
271+ error = null
272+ emit()
273+
274+ val body = Protocol.json.encodeToString(PromptBody.serializer(), PromptBody(listOf(TextInput(text = text))))
275+ sendMessageJob?.cancel()
276+ sendMessageJob = scope.launch {
277+ val result = http.postJson("$serverUrl/session/$session/prompt_async", body, authHeader())
278+ result.fold(
279+ onSuccess = { resp ->
280+ if (resp.code in 200..299) {
281+ messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Sent
282+ } else {
283+ messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
284+ generating = false
285+ messages.forEach { it.streaming = false }
286+ draft = savedDraft
287+ error = "Server returned status ${resp.code}"
288+ }
289+ },
290+ onFailure = { e ->
291+ messages.firstOrNull { it.id == user.id }?.status = MessageStatus.Failed
292+ generating = false
293+ messages.forEach { it.streaming = false }
294+ draft = savedDraft
295+ error = "Request failed: ${e.message}"
296+ },
297+ )
298+ emit()
299+ }
300+ }
301+
302+ private suspend fun probeHealth() {
303+ val hasCreds = hasCredentials()
304+ http.get("$serverUrl/global/health", authHeader()).fold(
305+ onSuccess = { resp ->
306+ when {
307+ resp.code == 401 -> {
308+ loading = false
309+ // A prior connection may have succeeded (connected = true); a
310+ // re-probe returning 401 (e.g. expired credentials) means we're
311+ // no longer authenticated, so drop the connected flag.
312+ connected = false
313+ // Always reveal the auth fields so the user can see/edit
314+ // their credentials — even when the ones supplied were wrong.
315+ authRequired = true
316+ if (hasCreds) error = "Invalid credentials"
317+ emit()
318+ }
319+ resp.code in 200..299 -> {
320+ connected = true
321+ authRequired = false
322+ error = null
323+ screen = Screen.Sessions
324+ loading = true
325+ emit()
326+ startSse()
327+ // Route through loadSessionsJob so a prior in-flight loadSessions
328+ // (e.g. from a rapid double-connect) is cancelled first.
329+ loadSessionsJob?.cancel()
330+ loadSessionsJob = scope.launch { loadSessions() }
331+ }
332+ else -> {
333+ loading = false
334+ connected = false
335+ error = "Server returned status ${resp.code}"
336+ emit()
337+ }
338+ }
339+ },
340+ onFailure = { e ->
341+ loading = false
342+ connected = false
343+ error = "Connection failed: ${e.message}"
344+ emit()
345+ },
346+ )
347+ }
348+
349+ private suspend fun loadSessions() {
350+ http.get("$serverUrl/session", authHeader()).fold(
351+ onSuccess = { resp ->
352+ if (resp.code in 200..299) {
353+ sessions.clear()
354+ sessions.addAll(Protocol.parseSessions(resp.body).map { SessionView(it.id, displayTitle(it.title)) })
355+ // Impose a stable alphabetical order so the list doesn't shuffle with
356+ // whatever order the server happens to return sessions in.
357+ sessions.sortBy { it.title.lowercase() }
358+ } else {
359+ error = "Server returned status ${resp.code}"
360+ }
361+ },
362+ onFailure = { e -> error = "Request failed: ${e.message}" },
363+ )
364+ loading = false
365+ emit()
366+ }
367+
368+ private suspend fun createSession() {
369+ http.postJson("$serverUrl/session", "{}", authHeader()).fold(
370+ onSuccess = { resp ->
371+ val session = if (resp.code in 200..299) Protocol.parseSession(resp.body) else null
372+ if (session != null) {
373+ if (sessions.none { it.id == session.id }) {
374+ sessions.add(0, SessionView(session.id, displayTitle(session.title)))
375+ // Restore the alphabetical order immediately (matching loadSessions /
376+ // SessionUpserted) so the new session doesn't sit out-of-order at the
377+ // top until the SSE SessionUpserted event arrives to re-sort.
378+ sessions.sortBy { it.title.lowercase() }
379+ }
380+ emit()
381+ // SelectSession takes over the loading state (loads messages).
382+ dispatch(Event.SelectSession(session.id))
383+ return
384+ }
385+ error = "Server returned status ${resp.code}"
386+ },
387+ onFailure = { e -> error = "Request failed: ${e.message}" },
388+ )
389+ loading = false
390+ emit()
391+ }
392+
393+ private suspend fun loadMessages(sessionId: String) {
394+ http.get("$serverUrl/session/$sessionId/message", authHeader()).fold(
395+ onSuccess = { resp ->
396+ if (!isCurrent(sessionId)) { loading = false; return } // user navigated away while loading
397+ loading = false
398+ if (resp.code in 200..299) {
399+ messages.clear()
400+ removedMessageIds.clear()
401+ messages.addAll(Protocol.parseMessages(resp.body).map(::messageFromWire))
402+ generating = messages.any { it.streaming }
403+ } else {
404+ error = "Server returned status ${resp.code}"
405+ }
406+ emit()
407+ },
408+ onFailure = { e ->
409+ if (!isCurrent(sessionId)) { loading = false; return }
410+ loading = false
411+ generating = false
412+ error = "Request failed: ${e.message}"
413+ emit()
414+ },
415+ )
416+ }
417+
418+ // ── SSE ─────────────────────────────────────────────────────────────────────
419+ private fun startSse() {
420+ closeSse()
421+ sseClosedIntentionally = false
422+ val client = SseClient(
423+ url = "$serverUrl/event",
424+ auth = authHeader(),
425+ onEvent = { json -> scope.launch { handleServerEvent(json) } },
426+ onStateChange = { open ->
427+ scope.launch {
428+ sseConnected = open
429+ if (open) reconnectAttempts = 0 else scheduleReconnect()
430+ emit()
431+ }
432+ },
433+ )
434+ sse = client
435+ client.connect()
436+ }
437+
438+ private fun closeSse() {
439+ sseClosedIntentionally = true
440+ reconnectJob?.cancel()
441+ reconnectJob = null
442+ sse?.close()
443+ sse = null
444+ }
445+
446+ private fun scheduleReconnect() {
447+ if (sseClosedIntentionally || !connected) return
448+ // Exponential backoff: 2s, 4s, 8s, 16s, capped at 30s. Reset to 0 once the
449+ // stream reconnects (onStateChange(true)) so a healthy server starts fresh.
450+ val delayMs = (2000L * (1L shl reconnectAttempts)).coerceAtMost(30_000L)
451+ if (delayMs < 30_000L) reconnectAttempts += 1
452+ // Cancel any pending reconnect so rapid network flapping can't stack multiple
453+ // coroutines that each open a new SSE connection.
454+ reconnectJob?.cancel()
455+ reconnectJob = scope.launch {
456+ delay(delayMs)
457+ if (!sseClosedIntentionally && connected && screen != Screen.Connect) startSse()
458+ }
459+ }
460+
461+ private fun handleServerEvent(json: String) {
462+ val event = Protocol.parseEvent(json) ?: return
463+ // Only re-render if the event actually changed our state; applyEvent returns
464+ // false for events that don't apply (other session, unknown message, …).
465+ if (applyEvent(event)) emit()
466+ }
467+
468+ /** Apply a server event to the model. Returns true iff any state actually changed. */
469+ private fun applyEvent(event: Protocol.Event): Boolean {
470+ when (event) {
471+ is Protocol.Event.MessageUpdated -> {
472+ val info = event.info
473+ if (info.role != "assistant" || !isCurrent(info.sessionId)) return false
474+ // A MessageRemoved may have arrived before this (late/out-of-order) update;
475+ // don't re-create a message the server has already deleted.
476+ if (info.id in removedMessageIds) return false
477+ val streaming = info.time.completed == null && info.error == null
478+ val existing = messages.firstOrNull { it.id == info.id }
479+ if (existing != null) {
480+ existing.streaming = streaming
481+ if (existing.time == 0L) existing.time = info.time.created
482+ } else {
483+ messages.add(MsgState(info.id, "assistant", info.time.created).apply { this.streaming = streaming })
484+ }
485+ info.error?.let { error = it.message(); messages.forEach { m -> m.streaming = false }; generating = false }
486+ return true
487+ }
488+
489+ is Protocol.Event.PartUpdated -> {
490+ val part = event.part
491+ val msg = messages.firstOrNull { it.id == part.messageId } ?: return false
492+ // Synthetic text parts are intentionally skipped; report no change so we
493+ // don't trigger a needless re-render/emit for them.
494+ return when (part) {
495+ is Protocol.Part.Text -> if (part.synthetic) {
496+ false
497+ } else {
498+ msg.textParts[part.id] = pickText(part.text, event.delta, msg.textParts[part.id])
499+ true
500+ }
501+ is Protocol.Part.Reasoning -> {
502+ msg.reasoningParts[part.id] = pickText(part.text, event.delta, msg.reasoningParts[part.id])
503+ true
504+ }
505+ is Protocol.Part.Tool -> {
506+ msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
507+ true
508+ }
509+ }
510+ }
511+
512+ is Protocol.Event.PartRemoved -> {
513+ val msg = messages.firstOrNull { it.id == event.messageId } ?: return false
514+ msg.remove(event.partId)
515+ return true
516+ }
517+
518+ is Protocol.Event.MessageRemoved -> {
519+ // Remember the ID so a late MessageUpdated can't resurrect the message.
520+ removedMessageIds.add(event.messageId)
521+ return messages.removeAll { it.id == event.messageId }
522+ }
523+
524+ is Protocol.Event.SessionIdle -> {
525+ if (!isCurrent(event.sessionId)) return false
526+ generating = false
527+ messages.forEach { it.streaming = false }
528+ return true
529+ }
530+
531+ is Protocol.Event.SessionError -> {
532+ val scoped = event.sessionId == null || isCurrent(event.sessionId)
533+ if (!scoped) return false
534+ event.error?.let { error = it.message() }
535+ generating = false
536+ messages.forEach { it.streaming = false }
537+ return true
538+ }
539+
540+ is Protocol.Event.SessionUpserted -> {
541+ val title = displayTitle(event.info.title)
542+ val existing = sessions.firstOrNull { it.id == event.info.id }
543+ if (existing != null) {
544+ sessions[sessions.indexOf(existing)] = SessionView(event.info.id, title)
545+ } else {
546+ sessions.add(0, SessionView(event.info.id, title))
547+ }
548+ // Keep the list alphabetically sorted (matching loadSessions) so an
549+ // upserted session doesn't jump to the top and break the ordering.
550+ sessions.sortBy { it.title.lowercase() }
551+ if (isCurrent(event.info.id)) currentSessionTitle = title
552+ return true
553+ }
554+
555+ is Protocol.Event.SessionDeleted -> {
556+ val id = event.sessionId ?: return false
557+ val removed = sessions.removeAll { it.id == id }
558+ if (isCurrent(id)) {
559+ currentSessionId = null
560+ messages.clear()
561+ screen = Screen.Sessions
562+ return true
563+ }
564+ return removed
565+ }
566+
567+ Protocol.Event.Other -> return false
568+ }
569+ }
570+
571+ // ── View / helpers ──────────────────────────────────────────────────────────
572+ private fun snapshot() = UiState(
573+ screen = screen,
574+ serverUrl = serverUrl,
575+ username = username,
576+ password = password,
577+ authRequired = authRequired,
578+ connected = connected,
579+ loading = loading,
580+ error = error,
581+ sessions = sessions.toList(),
582+ currentSessionId = currentSessionId,
583+ currentSessionTitle = currentSessionTitle,
584+ messages = messages.map { it.toView() },
585+ draft = draft,
586+ generating = generating,
587+ sseConnected = sseConnected,
588+ )
589+
590+ private fun emit() { _view.value = snapshot() }
591+
592+ private fun isCurrent(sessionId: String?) = sessionId != null && currentSessionId == sessionId
593+ private fun hasCredentials() = username.isNotEmpty() || password.isNotEmpty()
594+
595+ private fun authHeader(): String? {
596+ if (!hasCredentials()) return null
597+ val encoded = Base64.getEncoder().encodeToString("$username:$password".toByteArray())
598+ return "Basic $encoded"
599+ }
600+}
601+
602+// ── Internal message state (parts tracked by id for streaming) ─────────────────
603+private class MsgState(val id: String, val role: String, var time: Long) {
604+ var status = MessageStatus.Sent
605+ var streaming = false
606+ val textParts = LinkedHashMap<String, String>()
607+ val reasoningParts = LinkedHashMap<String, String>()
608+ val toolParts = LinkedHashMap<String, ToolView>()
609+
610+ fun remove(partId: String) {
611+ textParts.remove(partId)
612+ reasoningParts.remove(partId)
613+ toolParts.remove(partId)
614+ }
615+
616+ fun toView(): MessageView {
617+ val reasoning = reasoningParts.values.joinToString("")
618+ return MessageView(
619+ id = id,
620+ role = role,
621+ text = textParts.values.joinToString(""),
622+ reasoning = reasoning.ifEmpty { null },
623+ tools = toolParts.values.toList(),
624+ time = time,
625+ status = status,
626+ streaming = streaming,
627+ )
628+ }
629+}
630+
631+private fun messageFromWire(env: Protocol.WireMessageEnvelope): MsgState {
632+ val info = env.info
633+ val msg = MsgState(info.id, info.role, info.time.created)
634+ msg.streaming = info.role == "assistant" && info.time.completed == null && info.error == null
635+ for (raw in env.parts) {
636+ val obj = (raw as? kotlinx.serialization.json.JsonObject) ?: continue
637+ when (val part = Protocol.parsePart(obj)) {
638+ is Protocol.Part.Text -> if (!part.synthetic) msg.textParts[part.id] = part.text
639+ is Protocol.Part.Reasoning -> msg.reasoningParts[part.id] = part.text
640+ is Protocol.Part.Tool -> msg.toolParts[part.id] = ToolView(part.tool, part.status, part.title)
641+ null -> Unit
642+ }
643+ }
644+ return msg
645+}
646+
647+private fun pickText(text: String, delta: String?, prior: String?): String =
648+ if (text.isNotEmpty()) text else (prior.orEmpty() + (delta ?: ""))
649+
650+private fun normalizeUrl(url: String): String {
651+ val trimmed = url.trim().trimEnd('/')
652+ // Default to http:// when the user didn't type a scheme (e.g. "192.168.1.10:4096").
653+ // Without a scheme OkHttp's URL parser throws and the connect attempt crashes.
654+ return when {
655+ trimmed.isEmpty() -> ""
656+ "://" in trimmed -> trimmed
657+ else -> "http://$trimmed"
658+ }
659+}
660+
661+private fun displayTitle(title: String): String = title.trim().ifEmpty { "Untitled" }
662+
663+// ── Prompt request body (POST /session/{id}/prompt_async) ──────────────────────
664+@kotlinx.serialization.Serializable
665+private data class PromptBody(val parts: List<TextInput>)
666+
667+@kotlinx.serialization.Serializable
668+private data class TextInput(val type: String = "text", val text: String)
app/src/main/java/soy/iko/opencode/core/Protocol.ktnew file+148
@@ -0,0 +1,148 @@
1+package soy.iko.opencode.core
2+
3+import kotlinx.serialization.SerialName
4+import kotlinx.serialization.Serializable
5+import kotlinx.serialization.json.Json
6+import kotlinx.serialization.json.JsonElement
7+import kotlinx.serialization.json.JsonObject
8+import kotlinx.serialization.json.JsonPrimitive
9+import kotlinx.serialization.json.booleanOrNull
10+import kotlinx.serialization.json.contentOrNull
11+import kotlinx.serialization.json.decodeFromJsonElement
12+import kotlinx.serialization.json.jsonObject
13+
14+/**
15+ * Wire types for the opencode HTTP + SSE API, and lenient parsers for them.
16+ *
17+ * This is the Kotlin counterpart of the Rust core's `protocol.rs`. It is
18+ * deliberately forgiving: unknown JSON fields are ignored and every union has a
19+ * catch-all so new server versions never break the stream parser.
20+ */
21+object Protocol {
22+ val json = Json {
23+ ignoreUnknownKeys = true
24+ isLenient = true
25+ coerceInputValues = true
26+ }
27+
28+ // ─── Sessions ────────────────────────────────────────────────────────────
29+
30+ @Serializable
31+ data class WireSession(val id: String, val title: String = "")
32+
33+ fun parseSessions(body: String): List<WireSession> =
34+ runCatching { json.decodeFromString<List<WireSession>>(body) }.getOrDefault(emptyList())
35+
36+ fun parseSession(body: String): WireSession? =
37+ runCatching { json.decodeFromString<WireSession>(body) }.getOrNull()
38+
39+ // ─── Messages ────────────────────────────────────────────────────────────
40+
41+ @Serializable
42+ data class WireTime(val created: Long = 0, val completed: Long? = null)
43+
44+ @Serializable
45+ data class WireError(val name: String = "", val data: JsonElement? = null) {
46+ fun message(): String {
47+ val msg = ((data as? JsonObject)?.get("message") as? JsonPrimitive)?.contentOrNull
48+ return when {
49+ !msg.isNullOrEmpty() -> msg
50+ name.isNotEmpty() -> name
51+ else -> "Unknown error"
52+ }
53+ }
54+ }
55+
56+ @Serializable
57+ data class WireMessageInfo(
58+ val id: String,
59+ val role: String = "",
60+ @SerialName("sessionID") val sessionId: String = "",
61+ val time: WireTime = WireTime(),
62+ val error: WireError? = null,
63+ )
64+
65+ @Serializable
66+ data class WireMessageEnvelope(
67+ val info: WireMessageInfo,
68+ val parts: List<JsonElement> = emptyList(),
69+ )
70+
71+ fun parseMessages(body: String): List<WireMessageEnvelope> =
72+ runCatching { json.decodeFromString<List<WireMessageEnvelope>>(body) }.getOrDefault(emptyList())
73+
74+ // ─── Parts (content union, parsed by hand for robustness) ─────────────────
75+
76+ sealed interface Part {
77+ val id: String
78+ val messageId: String
79+
80+ data class Text(override val id: String, override val messageId: String, val text: String, val synthetic: Boolean) : Part
81+ data class Reasoning(override val id: String, override val messageId: String, val text: String) : Part
82+ data class Tool(override val id: String, override val messageId: String, val tool: String, val status: String, val title: String?) : Part
83+ }
84+
85+ /** Parse a single part JSON object; returns null for kinds we don't render. */
86+ fun parsePart(obj: JsonObject): Part? {
87+ val id = obj.str("id")
88+ val messageId = obj.str("messageID")
89+ return when (obj.str("type")) {
90+ "text" -> Part.Text(id, messageId, obj.str("text"), obj.bool("synthetic"))
91+ "reasoning" -> Part.Reasoning(id, messageId, obj.str("text"))
92+ "tool" -> {
93+ val state = obj["state"] as? JsonObject
94+ val status = state?.str("status")?.ifEmpty { "pending" } ?: "pending"
95+ val title = state?.str("title")?.ifEmpty { null } ?: state?.str("error")?.ifEmpty { null }
96+ Part.Tool(id, messageId, obj.str("tool"), status, title)
97+ }
98+ else -> null
99+ }
100+ }
101+
102+ // ─── SSE `/event` stream ──────────────────────────────────────────────────
103+
104+ sealed interface Event {
105+ data class MessageUpdated(val info: WireMessageInfo) : Event
106+ data class PartUpdated(val part: Part, val delta: String?) : Event
107+ data class PartRemoved(val messageId: String, val partId: String) : Event
108+ data class MessageRemoved(val messageId: String) : Event
109+ data class SessionIdle(val sessionId: String) : Event
110+ data class SessionError(val sessionId: String?, val error: WireError?) : Event
111+ data class SessionUpserted(val info: WireSession) : Event
112+ data class SessionDeleted(val sessionId: String?) : Event
113+ data object Other : Event
114+ }
115+
116+ /** Parse one raw SSE `data:` payload into an [Event]; null if malformed. */
117+ fun parseEvent(raw: String): Event? {
118+ val root = runCatching { json.parseToJsonElement(raw).jsonObject }.getOrNull() ?: return null
119+ val props = root["properties"] as? JsonObject ?: JsonObject(emptyMap())
120+ return when (root.str("type")) {
121+ "message.updated" -> decode<WireMessageInfo>(props["info"])?.let { Event.MessageUpdated(it) }
122+ "message.part.updated" -> {
123+ val partObj = props["part"] as? JsonObject ?: return Event.Other
124+ parsePart(partObj)?.let { Event.PartUpdated(it, props.str("delta").ifEmpty { null }) } ?: Event.Other
125+ }
126+ "message.part.removed" -> Event.PartRemoved(props.str("messageID"), props.str("partID"))
127+ "message.removed" -> Event.MessageRemoved(props.str("messageID"))
128+ "session.idle" -> Event.SessionIdle(props.str("sessionID"))
129+ "session.error" -> Event.SessionError(props.str("sessionID").ifEmpty { null }, decode<WireError>(props["error"]))
130+ "session.created", "session.updated" -> decode<WireSession>(props["info"])?.let { Event.SessionUpserted(it) }
131+ "session.deleted" -> Event.SessionDeleted(
132+ props.str("sessionID").ifEmpty { null } ?: (props["info"] as? JsonObject)?.str("id")?.ifEmpty { null },
133+ )
134+ else -> Event.Other
135+ }
136+ }
137+
138+ private inline fun <reified T> decode(el: JsonElement?): T? =
139+ el?.let { runCatching { json.decodeFromJsonElement<T>(it) }.getOrNull() }
140+
141+ // ─── Small JSON helpers ───────────────────────────────────────────────────
142+
143+ private fun JsonObject.str(key: String): String =
144+ (this[key] as? JsonPrimitive)?.contentOrNull ?: ""
145+
146+ private fun JsonObject.bool(key: String): Boolean =
147+ (this[key] as? JsonPrimitive)?.let { it.booleanOrNull ?: it.contentOrNull?.toBoolean() } ?: false
148+}
app/src/main/java/soy/iko/opencode/core/SseClient.ktnew file+63
@@ -0,0 +1,63 @@
1+package soy.iko.opencode.core
2+
3+import okhttp3.OkHttpClient
4+import okhttp3.Request
5+import okhttp3.Response
6+import okhttp3.sse.EventSource
7+import okhttp3.sse.EventSourceListener
8+import okhttp3.sse.EventSources
9+import java.util.concurrent.TimeUnit
10+
11+/**
12+ * Long-lived subscription to the opencode `/event` SSE stream.
13+ *
14+ * Crux only models request/response HTTP, so the streaming connection lives here
15+ * in the shell: each decoded event's `data` payload (a JSON object whose own
16+ * `type` field is the discriminant) is handed to [onEvent], which the core then
17+ * parses via [Protocol.parseEvent].
18+ */
19+class SseClient(
20+ private val url: String,
21+ private val auth: String?,
22+ private val onEvent: (String) -> Unit,
23+ private val onStateChange: (Boolean) -> Unit,
24+) {
25+ // A dedicated client with no read timeout — the stream is meant to stay open.
26+ private val client = OkHttpClient.Builder()
27+ .connectTimeout(15, TimeUnit.SECONDS)
28+ .readTimeout(0, TimeUnit.SECONDS)
29+ .retryOnConnectionFailure(true)
30+ .build()
31+
32+ private var source: EventSource? = null
33+
34+ fun connect() {
35+ val builder = Request.Builder().url(url).header("Accept", "text/event-stream")
36+ builder.header("User-Agent", "opencode-android/1.0")
37+ if (auth != null) builder.header("Authorization", auth)
38+ source = EventSources.createFactory(client).newEventSource(builder.build(), listener)
39+ }
40+
41+ fun close() {
42+ source?.cancel()
43+ source = null
44+ }
45+
46+ private val listener = object : EventSourceListener() {
47+ override fun onOpen(eventSource: EventSource, response: Response) {
48+ onStateChange(true)
49+ }
50+
51+ override fun onEvent(eventSource: EventSource, id: String?, type: String?, data: String) {
52+ if (data.isNotBlank()) onEvent(data)
53+ }
54+
55+ override fun onClosed(eventSource: EventSource) {
56+ onStateChange(false)
57+ }
58+
59+ override fun onFailure(eventSource: EventSource, t: Throwable?, response: Response?) {
60+ onStateChange(false)
61+ }
62+ }
63+}
app/src/main/java/soy/iko/opencode/core/ViewModel.ktnew file+77
@@ -0,0 +1,77 @@
1+package soy.iko.opencode.core
2+
3+/**
4+ * Immutable snapshot the UI renders. Mirrors the Rust core's `ViewModel`, so
5+ * the two implementations stay behaviourally identical.
6+ */
7+
8+enum class Screen { Connect, Sessions, Chat }
9+
10+enum class MessageStatus { Sent, Pending, Failed }
11+
12+data class SessionView(
13+ val id: String,
14+ val title: String,
15+)
16+
17+data class ToolView(
18+ val name: String,
19+ val status: String,
20+ val title: String?,
21+)
22+
23+data class MessageView(
24+ val id: String,
25+ val role: String,
26+ val text: String,
27+ val reasoning: String?,
28+ val tools: List<ToolView>,
29+ val time: Long,
30+ val status: MessageStatus,
31+ val streaming: Boolean,
32+) {
33+ val isUser: Boolean get() = role == "user"
34+}
35+
36+data class UiState(
37+ val screen: Screen = Screen.Connect,
38+ val serverUrl: String = "",
39+ val username: String = "",
40+ val password: String = "",
41+ val authRequired: Boolean = false,
42+ val connected: Boolean = false,
43+ val loading: Boolean = false,
44+ val error: String? = null,
45+ val sessions: List<SessionView> = emptyList(),
46+ val currentSessionId: String? = null,
47+ val currentSessionTitle: String = "",
48+ val messages: List<MessageView> = emptyList(),
49+ val draft: String = "",
50+ val generating: Boolean = false,
51+ val sseConnected: Boolean = true,
52+)
53+
54+/**
55+ * Everything the shell can ask the core to do. Internal HTTP results are not
56+ * events here (unlike the Rust core, which routes them through `then_send`);
57+ * the Kotlin driver awaits them inline in coroutines instead.
58+ */
59+sealed interface Event {
60+ data class ServerUrlChanged(val url: String) : Event
61+ data class UsernameChanged(val value: String) : Event
62+ data class PasswordChanged(val value: String) : Event
63+ data object Connect : Event
64+
65+ data object LoadSessions : Event
66+ data class SelectSession(val id: String) : Event
67+ data object CreateSession : Event
68+ data class DeleteSession(val id: String) : Event
69+
70+ data class DraftChanged(val text: String) : Event
71+ data class SendMessage(val text: String) : Event
72+ data object CancelGeneration : Event
73+
74+ data object NavigateToSessions : Event
75+ data object NavigateToConnect : Event
76+ data object DismissError : Event
77+}
app/src/main/java/soy/iko/opencode/data/model/Agent.ktdeleted−37
@@ -1,37 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.Serializable
5−
6−/** Entry from `GET /agent`. */
7−@Immutable
8−@Serializable
9−data class Agent(
10− // Defaulted so one name-less element in GET /agent doesn't fail the whole list decode
11− // (emptying the agent picker) — matches the resilient-decoding philosophy. A blank name is
12− // degraded but recoverable; a thrown decode is not.
13− val name: String = "",
14− val description: String? = null,
15− val mode: String? = null,
16− val builtIn: Boolean = false,
17− val prompt: String? = null,
18−) {
19− val isPrimary: Boolean get() = mode == "primary"
20− val displayDescription: String get() = description?.takeIf { it.isNotBlank() } ?: "Custom agent"
21−}
22−
23−/** Entry from `GET /command`. */
24−@Immutable
25−@Serializable
26−data class Command(
27− // Defaulted so one name-less element in GET /command doesn't fail the whole list decode
28− // (emptying the command picker) — matches the resilient-decoding philosophy.
29− val name: String = "",
30− val description: String? = null,
31− val agent: String? = null,
32− val model: String? = null,
33− val template: String = "",
34− val subtask: Boolean = false,
35−) {
36− val displayDescription: String get() = description?.takeIf { it.isNotBlank() } ?: template.take(80)
37−}
app/src/main/java/soy/iko/opencode/data/model/BusEvent.ktdeleted−109
@@ -1,109 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import kotlinx.serialization.SerialName
4−import kotlinx.serialization.Serializable
5−
6−/**
7− * The SSE envelope from `GET /event`: `{ "type": "<event>", "properties": { ... } }`.
8− * Discriminated on `type` (the global discriminator). Unknown events decode to
9− * [UnknownEvent] so the stream never dies on a new event type.
10− *
11− * Property shapes are kept lenient (nullable fields + ignoreUnknownKeys) because they
12− * drift across opencode versions; the reducer only reads the fields it needs.
13− */
14−@Serializable
15−sealed interface BusEvent
16−
17−@Serializable
18−@SerialName("server.connected")
19−data object ServerConnected : BusEvent
20−
21−@Serializable
22−@SerialName("message.updated")
23−data class MessageUpdated(val properties: Props) : BusEvent {
24− @Serializable
25− data class Props(val info: MessageInfo)
26−}
27−
28−@Serializable
29−@SerialName("message.part.updated")
30−data class MessagePartUpdated(val properties: Props) : BusEvent {
31− @Serializable
32− data class Props(
33− val part: Part,
34− val sessionID: String? = null,
35− val messageID: String? = null,
36− )
37−}
38−
39−@Serializable
40−@SerialName("message.part.removed")
41−data class MessagePartRemoved(val properties: Props) : BusEvent {
42− @Serializable
43− data class Props(
44− val sessionID: String? = null,
45− val messageID: String? = null,
46− val partID: String? = null,
47− )
48−}
49−
50−@Serializable
51−@SerialName("message.removed")
52−data class MessageRemoved(val properties: Props) : BusEvent {
53− @Serializable
54− data class Props(
55− val sessionID: String? = null,
56− val messageID: String? = null,
57− )
58−}
59−
60−@Serializable
61−@SerialName("session.idle")
62−data class SessionIdle(val properties: Props) : BusEvent {
63− @Serializable
64− data class Props(val sessionID: String? = null)
65−}
66−
67−@Serializable
68−@SerialName("session.error")
69−data class SessionError(val properties: Props) : BusEvent {
70− @Serializable
71− data class Props(
72− val sessionID: String? = null,
73− val error: kotlinx.serialization.json.JsonElement? = null,
74− )
75−}
76−
77−@Serializable
78−@SerialName("session.updated")
79−data class SessionUpdated(val properties: Props) : BusEvent {
80− @Serializable
81− data class Props(val info: Session)
82−}
83−
84−@Serializable
85−@SerialName("session.deleted")
86−data class SessionDeleted(val properties: Props) : BusEvent {
87− @Serializable
88− data class Props(val info: Session? = null, val sessionID: String? = null)
89−}
90−
91−@Serializable
92−@SerialName("permission.updated")
93−data class PermissionUpdated(val properties: Permission) : BusEvent
94−
95−@Serializable
96−@SerialName("permission.replied")
97−data class PermissionReplied(val properties: Props) : BusEvent {
98− @Serializable
99− data class Props(
100− val sessionID: String? = null,
101− val permissionID: String? = null,
102− val response: String? = null,
103− )
104−}
105−
106−/** Fallback for any event type we don't model. */
107−@Serializable
108−@SerialName("__unknown")
109−data object UnknownEvent : BusEvent
app/src/main/java/soy/iko/opencode/data/model/Common.ktdeleted−41
@@ -1,41 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.Serializable
5−
6−@Immutable
7−@Serializable
8−data class TimeInfo(
9− val created: Long? = null,
10− val updated: Long? = null,
11− val completed: Long? = null,
12−)
13−
14−/** Timing carried by message parts and tool states. The opencode server sends `time: { start,
15− * end?, compacted? }` for these (unlike Session/MessageInfo/Permission, which use the
16− * created/updated/completed shape of [TimeInfo]). With `ignoreUnknownKeys`, modelling these
17− * fields with [TimeInfo] would silently discard start/end/compacted — every part/tool time
18− * would decode to all-nulls — so this separate shape exists for them. */
19−@Immutable
20−@Serializable
21−data class PartTimeInfo(
22− val start: Long? = null,
23− val end: Long? = null,
24− val compacted: Long? = null,
25−)
26−
27−@Immutable
28−@Serializable
29−data class Tokens(
30− val input: Long = 0,
31− val output: Long = 0,
32− val reasoning: Long = 0,
33− val cache: Cache = Cache(),
34−) {
35− @Serializable
36− @Immutable
37− data class Cache(
38− val read: Long = 0,
39− val write: Long = 0,
40− )
41−}
app/src/main/java/soy/iko/opencode/data/model/Files.ktdeleted−44
@@ -1,44 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.Serializable
5−
6−/** Entry from `GET /file?path=` (directory listing). */
7−@Immutable
8−@Serializable
9−data class FileNode(
10− val name: String = "",
11− val path: String = "",
12− val type: String? = null,
13− // Size in bytes and last-modified epoch-ms. The server doesn't currently emit these, so
14− // they default to null and the browser renders nothing extra; optional + defaulted keeps
15− // the client forward-compatible (resilient decoding) so the moment the server adds them
16− // the listing shows size/date with no client change.
17− val size: Long? = null,
18− val mtime: Long? = null,
19−) {
20− val isDirectory: Boolean get() = type == "directory"
21−}
22−
23−/** Response of `GET /file/content?path=`. */
24−@Immutable
25−@Serializable
26−data class FileContent(
27− val type: String? = null, // "text" | "binary"
28− val content: String = "",
29− val diff: String? = null,
30− val mimeType: String? = null,
31− val encoding: String? = null,
32−) {
33− val isBinary: Boolean get() = type == "binary" || encoding == "base64"
34−}
35−
36−/** Entry from `GET /file/status` (VCS status). */
37−@Immutable
38−@Serializable
39−data class FileStatusEntry(
40− val path: String = "",
41− val added: Int = 0,
42− val removed: Int = 0,
43− val status: String? = null, // "added" | "deleted" | "modified"
44−)
app/src/main/java/soy/iko/opencode/data/model/Message.ktdeleted−69
@@ -1,69 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.SerialName
5−import kotlinx.serialization.Serializable
6−import kotlinx.serialization.json.JsonElement
7−import kotlinx.serialization.json.JsonClassDiscriminator
8−
9−/**
10− * The `info` object of a message. Discriminated on `role`, which differs from the
11− * global `type` discriminator used by [Part], so this hierarchy overrides it.
12− */
13−@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
14−@Serializable
15−@JsonClassDiscriminator("role")
16−sealed interface MessageInfo {
17− val id: String
18− val sessionID: String
19− val time: TimeInfo?
20−}
21−
22−@Immutable
23−@Serializable
24−@SerialName("user")
25−data class UserMessage(
26− override val id: String = "",
27− override val sessionID: String = "",
28− override val time: TimeInfo? = null,
29−) : MessageInfo
30−
31−@Immutable
32−@Serializable
33−@SerialName("assistant")
34−data class AssistantMessage(
35− override val id: String = "",
36− override val sessionID: String = "",
37− val parentID: String? = null,
38− val providerID: String? = null,
39− val modelID: String? = null,
40− val cost: Double? = null,
41− val tokens: Tokens? = null,
42− // JsonElement (not JsonObject) so a server that sends `error` as a bare string/array — or any
43− // non-object shape — decodes instead of throwing mid-AssistantMessage (the MessageInfo
44− // polymorphic fallback can't catch that, since `role` already matched). Mirrors
45− // SessionError.error. Not currently read by the UI, but kept tolerant for forward-compat.
46− val error: JsonElement? = null,
47− override val time: TimeInfo? = null,
48−) : MessageInfo {
49− /** A run is complete once the server stamps a completion time. */
50− val isComplete: Boolean get() = time?.completed != null
51−}
52−
53−/** Fallback for an unrecognized `role`, so new server roles never crash decoding. */
54−@Immutable
55−@Serializable
56−@SerialName("__unknown")
57−data class UnknownMessage(
58− override val id: String = "",
59− override val sessionID: String = "",
60− override val time: TimeInfo? = null,
61−) : MessageInfo
62−
63−/** A message together with its parts — the shape returned by message endpoints and the prompt response. */
64−@Immutable
65−@Serializable
66−data class MessageWithParts(
67− val info: MessageInfo,
68− val parts: List<Part> = emptyList(),
69−)
app/src/main/java/soy/iko/opencode/data/model/Part.ktdeleted−117
@@ -1,117 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.SerialName
5−import kotlinx.serialization.Serializable
6−import kotlinx.serialization.json.JsonElement
7−import kotlinx.serialization.json.JsonObject
8−import kotlinx.serialization.json.JsonPrimitive
9−
10−/**
11− * A message part — a discriminated union on `type` (the global class discriminator).
12− * Unknown types decode to [UnknownPart] via the registered polymorphic default,
13− * so the stream survives server additions.
14− *
15− * Every part carries [id] plus its parent [messageID]/[sessionID], which the reducer
16− * uses to attach streamed parts to the right message.
17− */
18−@Serializable
19−sealed interface Part {
20− val id: String
21− val messageID: String?
22− val sessionID: String?
23−}
24−
25−@Immutable
26−@Serializable
27−@SerialName("text")
28−data class TextPart(
29− override val id: String = "",
30− override val messageID: String? = null,
31− override val sessionID: String? = null,
32− val text: String = "",
33− val synthetic: Boolean = false,
34− val ignored: Boolean = false,
35− val time: PartTimeInfo? = null,
36−) : Part
37−
38−@Immutable
39−@Serializable
40−@SerialName("reasoning")
41−data class ReasoningPart(
42− override val id: String = "",
43− override val messageID: String? = null,
44− override val sessionID: String? = null,
45− val text: String = "",
46− val time: PartTimeInfo? = null,
47−) : Part
48−
49−@Immutable
50−@Serializable
51−@SerialName("tool")
52−data class ToolPart(
53− override val id: String = "",
54− override val messageID: String? = null,
55− override val sessionID: String? = null,
56− val callID: String = "",
57− val tool: String = "",
58− val state: ToolState = ToolPending,
59−) : Part
60−
61−@Immutable
62−@Serializable
63−@SerialName("file")
64−data class FilePart(
65− override val id: String = "",
66− override val messageID: String? = null,
67− override val sessionID: String? = null,
68− val mime: String? = null,
69− val filename: String? = null,
70− val url: String? = null,
71− // opencode's message-v2 schema sends `source` as a structured FilePartSource OBJECT
72− // ({ type, path, text, ... }), not a string, for @-file mentions. Typing it as String
73− // threw a SerializationException on decode — which the SSE path silently swallows, so the
74− // whole part vanished. Keep it as a tolerant JsonElement (mirroring Permission.pattern) and
75− // read the path via [sourcePath]; a bare-string source is still tolerated.
76− val source: JsonElement? = null,
77−) : Part
78−
79−/** The file path referenced by a [FilePart.source]. opencode sends `source` as a FilePartSource
80− * object whose `path` field holds the file path; older/other shapes may send a bare string.
81− * Returns null when there's no usable path. */
82−val FilePart.sourcePath: String?
83− get() = when (val s = source) {
84− is JsonPrimitive -> if (s.isString) s.content else null
85− is JsonObject -> (s["path"] as? JsonPrimitive)?.let { if (it.isString) it.content else null }
86− else -> null
87− }
88−
89−@Immutable
90−@Serializable
91−@SerialName("step-start")
92−data class StepStartPart(
93− override val id: String = "",
94− override val messageID: String? = null,
95− override val sessionID: String? = null,
96−) : Part
97−
98−@Immutable
99−@Serializable
100−@SerialName("step-finish")
101−data class StepFinishPart(
102− override val id: String = "",
103− override val messageID: String? = null,
104− override val sessionID: String? = null,
105− val cost: Double? = null,
106− val tokens: Tokens? = null,
107−) : Part
108−
109−/** Fallback for an unrecognized `type`. */
110−@Immutable
111−@Serializable
112−@SerialName("__unknown")
113−data class UnknownPart(
114− override val id: String = "",
115− override val messageID: String? = null,
116− override val sessionID: String? = null,
117−) : Part
app/src/main/java/soy/iko/opencode/data/model/Permission.ktdeleted−58
@@ -1,58 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.Serializable
5−import kotlinx.serialization.json.JsonElement
6−import kotlinx.serialization.json.JsonArray
7−import kotlinx.serialization.json.JsonPrimitive
8−
9−/**
10− * A permission request from `permission.updated`. The tool detail lives in [type] +
11− * [pattern] + [metadata]; [pattern] is a string or array of strings on the wire, so
12− * it's kept as a [JsonElement] and flattened for display.
13− */
14−@Immutable
15−@Serializable
16−data class Permission(
17− val id: String = "",
18− val sessionID: String = "",
19− val type: String? = null,
20− val pattern: JsonElement? = null,
21− val messageID: String? = null,
22− val callID: String? = null,
23− val title: String? = null,
24− // JsonElement (not JsonObject) so a non-object `metadata` shape decodes instead of throwing,
25− // matching [pattern]. Not read by the UI today, but kept tolerant for forward-compat.
26− val metadata: JsonElement? = null,
27− val time: TimeInfo? = null,
28−) {
29− val patternText: String?
30− get() = when (val p = pattern) {
31− null -> null
32− is JsonPrimitive -> p.content
33− // Only a JSON array is flattened; any other shape (e.g. an object) would make
34− // the previous unguarded `p.jsonArray` throw during composition, so treat it
35− // as "no displayable pattern" instead.
36− is JsonArray -> p.mapNotNull { (it as? JsonPrimitive)?.content }.joinToString(", ").ifEmpty { null }
37− else -> null
38− }
39−}
40−
41−/** Allowed values for the permission response body.
42− *
43− * [SESSION] is a client-side scope: it auto-responds [ONCE] to subsequent permission requests
44− * whose (type, pattern) match a remembered set, without showing the dialog. The set lives in
45− * [soy.iko.opencode.ui.chat.ChatViewModel] and is cleared on connection change. It is NOT sent
46− * to the server (the server only knows `once`/`always`/`reject`); the VM maps it to [ONCE] on
47− * the wire and tracks the grant locally. This gives users a middle ground between "allow once"
48− * (re-prompts on every file read) and "always" (persists in the server config outliving the app). */
49−enum class PermissionResponse(val wire: String) {
50− ONCE("once"),
51− ALWAYS("always"),
52− REJECT("reject"),
53− SESSION("once"),
54−}
55−
56−/** Body for `POST /session/:id/permissions/:permissionID`. */
57−@Serializable
58−data class PermissionReplyBody(val response: String)
app/src/main/java/soy/iko/opencode/data/model/Project.ktdeleted−32
@@ -1,32 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.Serializable
5−
6−/**
7− * A project known to the server, from `GET /project`. A project corresponds to a git
8− * worktree root; [worktree] is the absolute path the agent can run in. Used to populate
9− * the directory picker when creating a session in a specific directory.
10− */
11−@Immutable
12−@Serializable
13−data class Project(
14− val id: String = "",
15− val worktree: String = "",
16− val vcs: String? = null,
17− val time: TimeInfo? = null,
18−)
19−
20−/**
21− * The server's current path info, from `GET /path`. [directory] is the working directory
22− * a request with no `directory` override resolves to (the server's launch cwd), shown in
23− * the picker as the "server default" option.
24− */
25−@Immutable
26−@Serializable
27−data class PathInfo(
28− val directory: String? = null,
29− val worktree: String? = null,
30− val state: String? = null,
31− val config: String? = null,
32−)
app/src/main/java/soy/iko/opencode/data/model/Prompt.ktdeleted−68
@@ -1,68 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import kotlinx.serialization.SerialName
4−import kotlinx.serialization.Serializable
5−
6−/** Body for `POST /session/:id/message`. */
7−@Serializable
8−data class PromptRequest(
9− val parts: List<PromptPart>,
10− val model: ModelRef? = null,
11− val agent: String? = null,
12−)
13−
14−/**
15− * An input part for a prompt — a discriminated union on `type` (the global class
16− * discriminator). We compose text and file (image/document attachment) parts; the server
17− * models more (agent, subtask) that this client doesn't send.
18− */
19−@Serializable
20−sealed interface PromptPart
21−
22−@Serializable
23−@SerialName("text")
24−data class TextPromptPart(val text: String) : PromptPart
25−
26−/**
27− * A file/image attachment. [url] must be a data URL carrying the base64 payload —
28− * `data:<mime>;base64,<bytes>` — which is how the server embeds media for the model to
29− * see (see opencode's message-v2.ts). [mime] must match the payload; the server forwards
30− * it as the model's mediaType.
31− */
32−@Serializable
33−@SerialName("file")
34−data class FilePromptPart(
35− val mime: String,
36− val url: String,
37− val filename: String? = null,
38−) : PromptPart
39−
40−/** Body for `POST /session/:id/command`. */
41−@Serializable
42−data class CommandRequest(
43− val command: String,
44− val arguments: String = "",
45− val agent: String? = null,
46−)
47−
48−/** Body for `POST /session/:id/revert`. */
49−@Serializable
50−data class RevertRequest(
51− val messageID: String,
52− val partID: String? = null,
53−)
54−
55−/** Body for `POST /session/:id/summarize`. */
56−@Serializable
57−data class SummarizeRequest(
58− val providerID: String,
59− val modelID: String,
60−)
61−
62−/** Body for `POST /session/:id/shell`. */
63−@Serializable
64−data class ShellRequest(
65− val agent: String,
66− val command: String,
67− val model: ModelRef? = null,
68−)
app/src/main/java/soy/iko/opencode/data/model/Provider.ktdeleted−84
@@ -1,84 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.Serializable
5−
6−/** Response of `GET /config/providers`. */
7−@Immutable
8−@Serializable
9−data class ProvidersResponse(
10− val providers: List<Provider> = emptyList(),
11− val default: Map<String, String> = emptyMap(),
12−)
13−
14−@Immutable
15−@Serializable
16−data class Provider(
17− // Defaulted so one id-less element in GET /config/providers doesn't fail the whole list decode
18− // (emptying the model picker) — matches MessageInfo's defaulted id and the resilient-decoding
19− // philosophy. A blank id is degraded but recoverable; a thrown decode is not.
20− val id: String = "",
21− val name: String? = null,
22− val models: Map<String, ModelInfo> = emptyMap(),
23−) {
24− val displayName: String get() = name?.takeIf { it.isNotBlank() } ?: id
25−}
26−
27−@Immutable
28−@Serializable
29−data class ModelInfo(
30− val id: String? = null,
31− val name: String? = null,
32−) {
33− fun displayName(modelKey: String): String =
34− name?.takeIf { it.isNotBlank() } ?: id ?: modelKey
35−}
36−
37−/** A flattened provider/model selection used by the prompt body and the model picker. */
38−@Immutable
39−@Serializable
40−data class ModelRef(
41− val providerID: String,
42− val modelID: String,
43−)
44−
45−/** A single selectable entry in the model picker, flattened from [ProvidersResponse]. */
46−@Immutable
47−data class ModelOption(
48− val providerID: String,
49− val modelID: String,
50− val providerLabel: String,
51− val modelLabel: String,
52−) {
53− val ref: ModelRef get() = ModelRef(providerID, modelID)
54−}
55−
56−/** Flatten providers into a sorted list of pickable options. */
57−fun ProvidersResponse.toOptions(): List<ModelOption> =
58− providers.flatMap { provider ->
59− provider.models.map { (modelKey, info) ->
60− ModelOption(
61− providerID = provider.id,
62− modelID = info.id ?: modelKey,
63− providerLabel = provider.displayName,
64− modelLabel = info.displayName(modelKey),
65− )
66− }
67− }.sortedWith(
68− // CASE_INSENSITIVE_ORDER compares in place; compareBy { it.x.lowercase() } would
69− // allocate a fresh lowercased string on every comparison (O(N log N) allocations).
70− compareBy<ModelOption, String>(String.CASE_INSENSITIVE_ORDER) { it.providerLabel }
71− .thenBy(String.CASE_INSENSITIVE_ORDER) { it.modelLabel },
72− )
73−
74−/** The default option to preselect: the server's default for some provider, else the first model. */
75−fun ProvidersResponse.defaultOption(options: List<ModelOption> = toOptions()): ModelOption? {
76− // Try each configured provider default in turn and take the first whose model actually
77− // survives in `options` (the list may be filtered). Checking only the first `default` entry
78− // would mispreselect an arbitrary first option whenever that one provider's default model
79− // isn't present, ignoring the other configured defaults.
80− for ((providerID, modelID) in default) {
81− options.firstOrNull { it.providerID == providerID && it.modelID == modelID }?.let { return it }
82− }
83− return options.firstOrNull()
84−}
app/src/main/java/soy/iko/opencode/data/model/Search.ktdeleted−90
@@ -1,90 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.SerialName
5−import kotlinx.serialization.Serializable
6−
7−/**
8− * One ripgrep match from `GET /find?pattern=` (content search). [path] and [lines] are
9− * wrapped `{ text }` objects on the wire; [submatches] carry the column offsets of each
10− * hit within the line so the UI can highlight them.
11− */
12−@Immutable
13−@Serializable
14−data class FindMatch(
15− val path: TextField = TextField(),
16− val lines: TextField = TextField(),
17− @SerialName("line_number") val lineNumber: Int = 0,
18− @SerialName("absolute_offset") val absoluteOffset: Long = 0,
19− val submatches: List<SubMatch> = emptyList(),
20−) {
21− @Immutable
22− @Serializable
23− data class TextField(val text: String = "")
24−
25− @Immutable
26− @Serializable
27− data class SubMatch(
28− val start: Int = 0,
29− val end: Int = 0,
30− )
31−
32− /** The matched file's path (relative to the search root). */
33− val filePath: String get() = path.text
34−
35− /** The matching line's text, trimmed of a trailing newline for display. */
36− val lineText: String get() = lines.text.trimEnd('\n', '\r')
37−}
38−
39−/**
40− * One workspace symbol from `GET /find/symbol?query=`. [kind] is the LSP `SymbolKind`
41− * integer; [location.uri] is a file URI and [location.range] the symbol's span.
42− */
43−@Immutable
44−@Serializable
45−data class SymbolResult(
46− val name: String = "",
47− val kind: Int = 0,
48− val location: Location = Location(),
49−) {
50− @Immutable
51− @Serializable
52− data class Location(
53− val uri: String = "",
54− val range: Range = Range(),
55− )
56−
57− @Immutable
58− @Serializable
59− data class Range(
60− val start: Pos = Pos(),
61− val end: Pos = Pos(),
62− )
63−
64− @Immutable
65− @Serializable
66− data class Pos(
67− val line: Int = 0,
68− val character: Int = 0,
69− )
70−
71− /** The file path portion of [Location.uri], stripping a `file://` scheme when present. */
72− val filePath: String
73− get() = location.uri.removePrefix("file://").ifBlank { location.uri }
74−
75− /** 1-based line for display; the wire range is 0-based. */
76− val displayLine: Int get() = location.range.start.line + 1
77−}
78−
79−// LSP SymbolKind → label. A map lookup (rather than a large `when`) keeps the accessor
80−// trivial. Indices follow the LSP SymbolKind enum.
81−private val symbolKindLabels = mapOf(
82− 1 to "file", 2 to "module", 3 to "namespace", 4 to "package", 5 to "class",
83− 6 to "method", 7 to "property", 8 to "field", 9 to "constructor", 10 to "enum",
84− 11 to "interface", 12 to "function", 13 to "variable", 14 to "constant", 15 to "string",
85− 16 to "number", 17 to "boolean", 18 to "array", 19 to "object", 20 to "key",
86− 22 to "enum member", 23 to "struct", 26 to "type",
87−)
88−
89−/** Human-readable label for an LSP [SymbolResult.kind]. */
90−fun symbolKindLabel(kind: Int): String = symbolKindLabels[kind] ?: "symbol"
app/src/main/java/soy/iko/opencode/data/model/ServerProfile.ktdeleted−27
@@ -1,27 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−
5−/**
6− * A saved opencode server connection. The [password] for HTTP Basic auth is resolved
7− * separately from EncryptedSharedPreferences and is null when not loaded/needed.
8− */
9−@Immutable
10−data class ServerProfile(
11− val id: String,
12− val label: String,
13− val baseUrl: String,
14− val username: String? = null,
15− val password: String? = null,
16− val lastUsed: Long = 0,
17− // When true, the client upgrades a cleartext http:// URL to https:// so credentials and
18− // traffic are never sent over the wire in the clear for this server.
19− val requireHttps: Boolean = false,
20− // Optional OkHttp certificate pin(s) ("sha256/<base64>", whitespace/comma-separated for
21− // multiple) applied to this server's TLS connection for defense against a rogue CA.
22− val certPin: String? = null,
23−) {
24− val hasAuth: Boolean get() = !username.isNullOrBlank()
25−
26− val displayLabel: String get() = label.takeIf { it.isNotBlank() } ?: baseUrl
27−}
app/src/main/java/soy/iko/opencode/data/model/Session.ktdeleted−68
@@ -1,68 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.Serializable
5−
6−/** A session as returned by `GET /session` and `POST /session`. */
7−@Immutable
8−@Serializable
9−data class Session(
10− // Defaulted so one id-less element in GET /session doesn't fail the whole list decode
11− // (emptying the session list) — matches MessageInfo's defaulted id and the resilient-decoding
12− // philosophy. A blank id is degraded but recoverable; a thrown decode is not.
13− val id: String = "",
14− val parentID: String? = null,
15− val title: String? = null,
16− val version: String? = null,
17− val time: TimeInfo? = null,
18− // The absolute worktree/directory the agent runs in for this session. Set server-side
19− // from the `directory` query param at creation; persisted per session so the client
20− // needn't re-send it on follow-up calls (see OpencodeApiClient.createSession).
21− val directory: String? = null,
22− val projectID: String? = null,
23− // Present only while the session is shared (POST /session/:id/share); cleared on unshare.
24− val share: Share? = null,
25− // Present only while a revert checkpoint is active (POST /session/:id/revert); cleared on unrevert.
26− val revert: Revert? = null,
27−) {
28− val displayTitle: String get() = title?.takeIf { it.isNotBlank() } ?: "Untitled session"
29−
30− /** A compact form of [directory] for list/subtitle display: the last path segment,
31− * or the full path when it has no separators. Null/blank when no directory is set. */
32− val displayDirectory: String?
33− get() = directory?.takeIf { it.isNotBlank() }?.trimEnd('/')?.substringAfterLast('/')?.takeIf { it.isNotBlank() }
34−
35− /** True while the session has an active public share link. */
36− val isShared: Boolean get() = !share?.url.isNullOrBlank()
37−
38− /** True while a revert checkpoint is active (messages after it are hidden server-side). */
39− val isReverted: Boolean get() = revert != null
40−
41− /** Public share link for the session, from `POST /session/:id/share`. */
42− @Immutable
43− @Serializable
44− data class Share(val url: String = "")
45−
46− /** The active revert checkpoint, from `POST /session/:id/revert`. */
47− @Immutable
48− @Serializable
49− data class Revert(
50− val messageID: String = "",
51− val partID: String? = null,
52− val snapshot: String? = null,
53− val diff: String? = null,
54− )
55−}
56−
57−/** Body for `POST /session`. */
58−@Serializable
59−data class CreateSessionRequest(
60− val parentID: String? = null,
61− val title: String? = null,
62−)
63−
64−/** Body for `PATCH /session/:id`. */
65−@Serializable
66−data class UpdateSessionRequest(
67− val title: String? = null,
68−)
app/src/main/java/soy/iko/opencode/data/model/Todo.ktdeleted−95
@@ -1,95 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.Serializable
5−import kotlinx.serialization.builtins.ListSerializer
6−import kotlinx.serialization.json.Json
7−import kotlinx.serialization.json.JsonElement
8−
9−/** The tool name opencode uses to write the agent's task plan. */
10−const val TODO_WRITE_TOOL = "todowrite"
11−
12−/**
13− * A single entry in an agent's task plan, as written by opencode's `todowrite` tool.
14− * Every field is defaulted and decoding ignores unknown keys (see [parseTodos]) so a
15− * change to the tool's payload shape degrades to a best-effort render instead of
16− * dropping the whole plan.
17− */
18−@Immutable
19−@Serializable
20−data class TodoItem(
21− val id: String = "",
22− val content: String = "",
23− val status: String = "pending",
24− val priority: String? = null,
25−)
26−
27−/** Normalized lifecycle of a [TodoItem]; [UNKNOWN] covers any status the client doesn't model. */
28−enum class TodoStatus { PENDING, IN_PROGRESS, COMPLETED, CANCELLED, UNKNOWN }
29−
30−/** Normalized priority of a [TodoItem]; [NONE] covers null/unmodelled priorities. The order
31− * matters: HIGH > MEDIUM > LOW, used by [TodoPlan] tinting and a11y labels. */
32−enum class TodoPriority { HIGH, MEDIUM, LOW, NONE }
33−
34−fun TodoItem.priorityEnum(): TodoPriority = when ((priority ?: "").trim().lowercase()) {
35− "high", "h", "1", "urgent", "critical" -> TodoPriority.HIGH
36− "medium", "med", "m", "normal", "2" -> TodoPriority.MEDIUM
37− "low", "l", "3", "minor", "backlog" -> TodoPriority.LOW
38− else -> if (priority.isNullOrBlank()) TodoPriority.NONE else TodoPriority.NONE
39−}
40−
41−fun TodoItem.statusEnum(): TodoStatus = when (status.trim().lowercase()) {
42− "pending", "todo", "" -> TodoStatus.PENDING
43− "in_progress", "in-progress", "inprogress", "running", "active" -> TodoStatus.IN_PROGRESS
44− "completed", "complete", "done" -> TodoStatus.COMPLETED
45− "cancelled", "canceled", "skipped" -> TodoStatus.CANCELLED
46− else -> TodoStatus.UNKNOWN
47−}
48−
49−@Serializable
50−private data class TodoWriteInput(val todos: List<TodoItem> = emptyList())
51−
52−// A lenient decoder mirroring OpencodeJson's resilience (unknown keys ignored, nulls
53−// coerced to defaults) without pulling the network layer into data/model.
54−private val todoJson = Json {
55− ignoreUnknownKeys = true
56− isLenient = true
57− coerceInputValues = true
58− explicitNulls = false
59−}
60−
61−/**
62− * Extract the todo list from a `todowrite` tool call's input JSON. The tool sends the
63− * full list on every write, so a single call's input is a complete plan snapshot.
64− * Accepts either the documented `{ "todos": [...] }` object or a bare array, and returns
65− * an empty list on anything unparseable.
66− */
67−fun parseTodos(input: JsonElement?): List<TodoItem> {
68− if (input == null) return emptyList()
69− return runCatching {
70− todoJson.decodeFromJsonElement(TodoWriteInput.serializer(), input).todos
71− }.recoverCatching {
72− todoJson.decodeFromJsonElement(ListSerializer(TodoItem.serializer()), input)
73− }.getOrDefault(emptyList())
74−}
75−
76−/**
77− * The current task plan for a conversation: the todos of the most recent `todowrite`
78− * tool call that carries a non-empty, parseable list. opencode rewrites the whole list
79− * on every write, so a single call is a complete snapshot; scanning newest-first and
80− * skipping empties avoids a mid-stream write (input not yet arrived) briefly blanking a
81− * plan that's still valid.
82− */
83−fun currentTodoPlan(messages: List<MessageWithParts>): List<TodoItem> {
84− for (mi in messages.indices.reversed()) {
85− val parts = messages[mi].parts
86− for (pi in parts.indices.reversed()) {
87− val part = parts[pi]
88− if (part is ToolPart && part.tool.equals(TODO_WRITE_TOOL, ignoreCase = true)) {
89− val todos = parseTodos(part.state.inputElement())
90− if (todos.isNotEmpty()) return todos
91− }
92− }
93− }
94− return emptyList()
95−}
app/src/main/java/soy/iko/opencode/data/model/ToolState.ktdeleted−62
@@ -1,62 +0,0 @@
1−package soy.iko.opencode.data.model
2−
3−import androidx.compose.runtime.Immutable
4−import kotlinx.serialization.SerialName
5−import kotlinx.serialization.Serializable
6−import kotlinx.serialization.json.JsonElement
7−import kotlinx.serialization.json.JsonClassDiscriminator
8−
9−/**
10− * The state machine of a tool call, discriminated on `status`.
11− * pending -> running -> completed | error
12− */
13−@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
14−@Serializable
15−@JsonClassDiscriminator("status")
16−sealed interface ToolState
17−
18−@Serializable
19−@SerialName("pending")
20−data object ToolPending : ToolState
21−
22−@Immutable
23−@Serializable
24−@SerialName("running")
25−data class ToolRunning(
26− val input: JsonElement? = null,
27− val title: String? = null,
28− val time: PartTimeInfo? = null,
29−) : ToolState
30−
31−@Immutable
32−@Serializable
33−@SerialName("completed")
34−data class ToolCompleted(
35− val input: JsonElement? = null,
36− val output: String? = null,
37− val title: String? = null,
38− val time: PartTimeInfo? = null,
39−) : ToolState
40−
41−@Immutable
42−@Serializable
43−@SerialName("error")
44−data class ToolError(
45− val input: JsonElement? = null,
46− val error: String? = null,
47− val time: PartTimeInfo? = null,
48−) : ToolState
49−
50−/** Fallback for an unrecognized `status`. */
51−@Serializable
52−@SerialName("__unknown")
53−data object ToolUnknown : ToolState
54−
55−/** The raw input arguments carried by a tool call, present on the running/completed/error
56− * states (pending/unknown carry none). */
57−fun ToolState.inputElement(): JsonElement? = when (this) {
58− is ToolRunning -> input
59− is ToolCompleted -> input
60− is ToolError -> input
61− else -> null
62−}
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.ktdeleted−318
@@ -1,318 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−import soy.iko.opencode.data.model.BusEvent
4−import soy.iko.opencode.util.safeExceptionSummary
5−import android.util.Log
6−import io.ktor.client.HttpClient
7−import io.ktor.client.plugins.timeout
8−import io.ktor.client.plugins.sse.sse
9−import io.ktor.sse.ServerSentEvent
10−import kotlinx.coroutines.CancellationException
11−import kotlinx.coroutines.CoroutineScope
12−import kotlin.coroutines.EmptyCoroutineContext
13−import kotlinx.coroutines.channels.BufferOverflow
14−import kotlinx.coroutines.channels.Channel
15−import kotlinx.coroutines.channels.ChannelResult
16−import kotlinx.coroutines.flow.Flow
17−import kotlinx.coroutines.flow.MutableStateFlow
18−import kotlinx.coroutines.flow.SharedFlow
19−import kotlinx.coroutines.flow.StateFlow
20−import kotlinx.coroutines.flow.SharingStarted
21−import kotlinx.coroutines.flow.asStateFlow
22−import kotlinx.coroutines.flow.buffer
23−import kotlinx.coroutines.flow.channelFlow
24−import kotlinx.coroutines.flow.shareIn
25−import kotlinx.coroutines.isActive
26−import kotlinx.coroutines.launch
27−import kotlinx.coroutines.selects.onTimeout
28−import kotlinx.coroutines.selects.select
29−import java.io.IOException
30−import kotlin.random.Random
31−
32−/**
33− * Owns the single long-lived `GET /event` SSE subscription and exposes it as a hot
34− * [SharedFlow] of [BusEvent]. The connection is opened when the first collector
35− * subscribes (WhileSubscribed) and reconnects with backoff on drop.
36− *
37− * Ordering contract: callers that send a prompt must already be collecting [events]
38− * (directly or via the repository) so early `message.part.updated` deltas aren't missed.
39− */
40−@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
41−open class EventStreamClient(
42− private val client: HttpClient,
43− private val scope: CoroutineScope,
44− private val idleTimeoutMs: Long = NetworkConfig.sseIdleTimeoutMs,
45− private val initialBackoffMs: Long = NetworkConfig.sseInitialBackoffMs,
46− private val maxBackoffMs: Long = NetworkConfig.sseMaxBackoffMs,
47− private val bufferCapacity: Int = NetworkConfig.sseEventBufferCapacity,
48−) {
49− protected constructor() : this(
50− HttpClient(io.ktor.client.engine.okhttp.OkHttp) {},
51− CoroutineScope(EmptyCoroutineContext),
52− )
53− enum class ConnectionState { Disconnected, Connecting, Connected, Failed, AuthFailed }
54−
55− private val _state = MutableStateFlow(ConnectionState.Disconnected)
56− open val state: StateFlow<ConnectionState> = _state.asStateFlow()
57−
58− open val events: SharedFlow<BusEvent> by lazy {
59− stream()
60− .buffer(bufferCapacity, BufferOverflow.DROP_OLDEST)
61− .shareIn(
62− scope = scope,
63− started = SharingStarted.WhileSubscribed(stopTimeoutMillis = NetworkConfig.stateFlowSubscriptionTimeoutMs),
64− replay = 0,
65− )
66− }
67−
68− /** Conflated channel used to cut the reconnect backoff short when network returns. */
69− private val reconnectSignal = Channel<Unit>(Channel.CONFLATED)
70−
71− /** Classifies a non-retryable SSE failure so the UI can show the right recovery hint:
72− * an auth failure (401/403) reads "check credentials", while other 4xx (400/404/405…)
73− * reads "check the server URL and version". Returns null for transient/retryable errors. */
74− private enum class SseFailureKind { Auth, Other }
75−
76− private fun classifyNonRetryableSseFailure(e: Throwable): SseFailureKind? =
77− generateSequence(e as Throwable?) { it.cause.takeIf { c -> c !== it } }
78− .take(16)
79− .mapNotNull { t ->
80− when (t) {
81− is io.ktor.client.plugins.sse.SSEClientException -> t.response?.status?.value
82− is io.ktor.client.plugins.ClientRequestException -> t.response.status.value
83− else -> null
84− }
85− }
86− // Non-408/429 4xx = permanent; 408 and 429 stay in the transient retry path.
87− .map { code -> when {
88− code in 400..499 && code != 408 && code != 429 ->
89− if (code == 401 || code == 403) SseFailureKind.Auth else SseFailureKind.Other
90− else -> null
91− } }
92− .firstOrNull { it != null }
93−
94− /** Request an immediate reconnect, skipping any in-progress backoff. */
95− open fun triggerReconnect() { reconnectSignal.trySend(Unit) }
96−
97− /**
98− * Read SSE events from [events], racing each read against an idle-timeout watchdog.
99− * Throws [IOException] on idle timeout so the outer reconnect loop fires; breaks on
100− * clean stream close or scope cancellation.
101− *
102− * If the idle watchdog tripped because a [triggerReconnect] signal was consumed
103− * (rather than a genuine idle timeout), [onReconnectConsumed] is invoked so the outer
104− * reconnect loop can reconnect immediately instead of waiting out the backoff — the
105− * signal is already consumed, so the backoff `select` couldn't be cut short.
106− */
107− private suspend fun readSseEvents(
108− scope: CoroutineScope,
109− events: kotlinx.coroutines.channels.ReceiveChannel<ServerSentEvent>,
110− onReconnectConsumed: () -> Unit,
111− send: suspend (BusEvent) -> Unit,
112− ) {
113− while (scope.isActive) {
114− // The OkHttp engine has readTimeout=0 (the SSE stream must never time out on
115− // read), so a socket the peer never closed would otherwise hang until OS TCP
116− // keepalive kicks in — minutes to hours on stock Linux. If nothing arrives
117− // within the idle timeout, treat the connection as half-open and drop it.
118− var consumedReconnectSignal = false
119− val result: ChannelResult<ServerSentEvent>? = select {
120− events.onReceiveCatching { it }
121− onTimeout(idleTimeoutMs) { null }
122− // A triggerReconnect() issued while a read is active must interrupt it now:
123− // return the same null sentinel as the idle timeout so a half-open socket
124− // (no TCP RST) is dropped and reconnected promptly instead of waiting out
125− // idleTimeoutMs (~90s).
126− reconnectSignal.onReceive { consumedReconnectSignal = true; null }
127− }
128− if (result == null) {
129− if (consumedReconnectSignal) onReconnectConsumed()
130− throw IOException("SSE read dropped (idle timeout or reconnect requested), reconnecting")
131− }
132− val sse = result.getOrNull()
133− if (sse == null) {
134− // The producer channel is closed. A non-null cause means `incoming`
135− // failed (e.g. a socket reset bridged from the producer) — rethrow it so
136− // the outer loop logs and retries with backoff. A clean close (null
137− // cause) just ends this attempt and lets the loop reconnect.
138− result.exceptionOrNull()?.let { throw it }
139− break
140− }
141− val data = sse.data
142− if (data == null) {
143− // A comment or keep-alive event (no data field) resets the idle watchdog
144− // — servers send `: ping` comments to keep the connection alive through
145− // proxies. Without this, a server that only sends comments would still
146− // hit the idle timeout and reconnect unnecessarily.
147− continue
148− }
149− val event = try {
150− OpencodeJson.decodeFromString(BusEvent.serializer(), data)
151− } catch (e: kotlinx.serialization.SerializationException) {
152− Log.d("EventStream", "Skipping unparseable SSE event", e)
153− continue
154− }
155− if (_state.value != ConnectionState.Connected) {
156− _state.value = ConnectionState.Connected
157− }
158− send(event)
159− }
160− }
161−
162− private fun stream(): Flow<BusEvent> = channelFlow {
163− val scope = this
164− // Drain any stale reconnect signal from a previous collection cycle so it
165− // doesn't spuriously reset the backoff on startup (WhileSubscribed may have
166− // stopped the upstream while triggerReconnect() queued a signal).
167− while (reconnectSignal.tryReceive().isSuccess) { /* drain */ }
168− var backoffMs = initialBackoffMs
169− while (isActive) {
170− _state.value = ConnectionState.Connecting
171− // Set by readSseEvents when a triggerReconnect() signal interrupted an active
172− // read. The signal is consumed to drop the socket, so the backoff select below
173− // can't also see it — without this flag the reconnect would wait the full
174− // backoff despite the caller asking for an immediate reconnect (network
175− // handoff, manual refresh, ...).
176− var reconnectConsumedDuringRead = false
177− try {
178− // The SSE stream is long-lived: disable the request-level and socket
179− // timeouts (the client default is 60s for REST) in the request config
180− // so the connection isn't killed mid-stream. The idle-timeout watchdog
181− // below handles half-open connections instead.
182− client.sse(
183− "event",
184− request = {
185− timeout {
186− requestTimeoutMillis = Long.MAX_VALUE
187− // Disable the socket (read) timeout entirely for the SSE stream.
188− // A finite value would kill the connection whenever the server
189− // is quiet for longer than that (idle sessions, slow agent runs),
190− // causing a reconnect storm. Half-open detection is handled by
191− // the idle-timeout watchdog below plus OkHttp's pingInterval on
192− // HTTP/2 connections.
193− socketTimeoutMillis = Long.MAX_VALUE
194− }
195− },
196− ) {
197− // The SSE block is open — the connection is established. Mark as
198− // Connected immediately so downstream re-seed logic fires even when
199− // the server only sends keep-alive comments (no data events).
200− if (_state.value != ConnectionState.Connected) {
201− _state.value = ConnectionState.Connected
202− }
203− // Clear any stale reconnect signal from a triggerReconnect() call
204− // made while the previous stream was healthy, so it doesn't suppress
205− // the next legitimate backoff.
206− while (reconnectSignal.tryReceive().isSuccess) { /* drain */ }
207− // Bridge ktor's `incoming` flow to a channel so each element can be
208− // raced against the idle-timeout watchdog via select. We collect it
209− // ourselves instead of `incoming.produceIn(scope)`: produceIn launches
210− // the collector as a child of this channelFlow, so when `incoming`
211− // throws on an abnormal socket close — a TCP reset, i.e. the common
212− // mobile case: Wi-Fi⇄cellular handoff, server or proxy RST — the child's
213− // failure cancels the whole channelFlow and bypasses the retry `catch`
214− // below. That killed reconnect outright and surfaced the IOException as
215− // an uncaught crash through the (handler-less) shareIn scope. Collecting
216− // into our own channel and closing it with the cause keeps the failure
217− // local: readSseEvents sees the closed channel, rethrows the cause, and
218− // the normal retry/backoff path handles it. (A clean EOF closes the
219− // channel with no cause → readSseEvents breaks and reconnects as before.)
220− val events = Channel<ServerSentEvent>(Channel.BUFFERED)
221− val producer = scope.launch {
222− try {
223− incoming.collect { events.send(it) }
224− events.close()
225− } catch (c: CancellationException) {
226− events.close(c)
227− throw c
228− } catch (t: Throwable) {
229− // Deliver the failure via the channel rather than letting it
230− // propagate up and cancel the parent channelFlow.
231− events.close(t)
232− }
233− }
234− // Reset the backoff only once real data actually arrives, not on
235− // block entry: a server that accepts the connection then immediately
236− // drops it would otherwise loop forever at the initial backoff and
237− // never escalate the exponential ramp.
238− var receivedAny = false
239− try {
240− readSseEvents(scope, events, { reconnectConsumedDuringRead = true }) {
241− if (!receivedAny) {
242− receivedAny = true
243− backoffMs = initialBackoffMs
244− }
245− send(it)
246− }
247− } finally {
248− producer.cancel()
249− events.cancel()
250− }
251− }
252− } catch (c: CancellationException) {
253− _state.value = ConnectionState.Disconnected
254− throw c
255− } catch (e: Exception) {
256− // If the scope was cancelled (e.g. connection close), the closed-client
257− // exception is expected — don't log a spurious "stream error" warning.
258− val failureKind = classifyNonRetryableSseFailure(e)
259− if (failureKind != null) {
260− // The server rejected the request with a non-retryable 4xx (bad
261− // credentials, missing endpoint, ...) — retrying won't help. Log a
262− // scrubbed summary (class + status) instead of the full exception, whose
263− // message carries the request URL and may include auth or paths.
264− Log.w("EventStream", "SSE non-retryable 4xx (${if (failureKind == SseFailureKind.Auth) "auth" else "endpoint"}), awaiting explicit reconnect: ${safeExceptionSummary(e)}")
265− _state.value = if (failureKind == SseFailureKind.Auth) ConnectionState.AuthFailed else ConnectionState.Failed
266− if (!isActive) {
267− // Final teardown (last subscriber left) while a 4xx surfaced: end in
268− // Disconnected like every other loop-exit path. Failed must stay
269− // observable only while the flow is live/retrying, not after teardown.
270− _state.value = ConnectionState.Disconnected
271− break
272− }
273− // Don't complete the flow: a subscriber may fix the profile and call
274− // triggerReconnect(). Suspend until an explicit reconnect signal
275− // arrives (no backoff delay), then re-attempt the connection. This receive
276− // is outside the try above, so reset state to Disconnected if the last
277− // subscriber leaves (cancellation) while we're parked here — otherwise the
278− // stream would be torn down with state stuck at Failed.
279− try {
280− reconnectSignal.receive()
281− } catch (c: CancellationException) {
282− _state.value = ConnectionState.Disconnected
283− throw c
284− }
285− continue
286− } else if (isActive) {
287− Log.w("EventStream", "SSE stream error, will retry: ${safeExceptionSummary(e)}")
288− }
289− }
290− _state.value = ConnectionState.Disconnected
291− if (!isActive) break
292− // Wait for the backoff, but allow a reconnect signal to cut it short. When
293− // readSseEvents consumed a triggerReconnect() signal to drop an active read,
294− // the signal is already spent so the select below can't receive it — pass a
295− // zero backoff in that case so the reconnect is immediate (the whole point of
296− // triggerReconnect: network handoff, manual refresh, ...).
297− val signaled = waitForBackoffOrSignal(reconnectConsumedDuringRead, backoffMs)
298− backoffMs = if (signaled) initialBackoffMs else (backoffMs * 2).coerceAtMost(maxBackoffMs)
299− }
300− }
301−
302− /** Suspend for the backoff unless [reconnectConsumedDuringRead] (immediate) or a new
303− * [triggerReconnect] arrives during the wait. Returns true when the reconnect should
304− * skip the backoff ramp (signal-driven), false when the full backoff elapsed. */
305− private suspend fun waitForBackoffOrSignal(
306− reconnectConsumedDuringRead: Boolean,
307− backoffMs: Long,
308− ): Boolean {
309− if (reconnectConsumedDuringRead) return true
310− var signaled = false
311− val jitter = ((backoffMs * NetworkConfig.retryJitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
312− select {
313− reconnectSignal.onReceive { signaled = true }
314− onTimeout((backoffMs + jitter).coerceAtLeast(0)) { /* normal backoff elapsed */ }
315− }
316− return signaled
317− }
318−}
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.ktdeleted−190
@@ -1,190 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−import soy.iko.opencode.data.model.ServerProfile
4−import io.ktor.client.HttpClient
5−import io.ktor.client.engine.okhttp.OkHttp
6−import io.ktor.client.plugins.HttpTimeout
7−import io.ktor.client.plugins.api.Send
8−import io.ktor.client.plugins.api.createClientPlugin
9−import io.ktor.client.plugins.auth.Auth
10−import io.ktor.client.plugins.auth.providers.BasicAuthCredentials
11−import io.ktor.client.plugins.auth.providers.basic
12−import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
13−import io.ktor.client.plugins.defaultRequest
14−import io.ktor.client.plugins.sse.SSE
15−import io.ktor.http.HttpHeaders
16−import io.ktor.http.URLBuilder
17−import io.ktor.http.takeFrom
18−import io.ktor.serialization.kotlinx.json.json
19−import okhttp3.CertificatePinner
20−import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
21−import java.util.concurrent.TimeUnit
22−
23−/**
24− * Builds a Ktor client bound to a single [ServerProfile]. One client per active
25− * profile: it carries the base URL, optional Basic auth, JSON negotiation, and the
26− * SSE plugin used for `GET /event`.
27− */
28−object HttpClientFactory {
29−
30− fun create(profile: ServerProfile): HttpClient {
31− // Resolve per-profile TLS options up front so they're available inside the engine
32− // config: optionally upgrade a cleartext http:// URL to https:// ("Require HTTPS"),
33− // and pin the server certificate(s) for HTTPS connections.
34− val normalizedUrl = effectiveBaseUrl(profile)
35− val isHttps = normalizedUrl.lowercase().startsWith("https://")
36− // Parse the host with OkHttp's own parser (not java.net.URI, which rejects hosts OkHttp
37− // accepts, e.g. underscores in "my_server.local"). If pinning is configured over HTTPS
38− // but the host can't be parsed, fail closed: never build an unpinned client — that would
39− // be a silent security downgrade for a user who opted into pinning.
40− val pinHost = normalizedUrl.toHttpUrlOrNull()?.host
41− val pins = parsePins(profile.certPin)
42− if (pins.isNotEmpty() && isHttps && pinHost == null) {
43− throw IllegalStateException(
44− "Certificate pinning is configured but the server host could not be parsed from $normalizedUrl",
45− )
46− }
47−
48− return HttpClient(OkHttp) {
49− expectSuccess = true
50−
51− engine {
52− config {
53− // A normal read timeout for REST calls. The SSE stream overrides this
54− // to infinite inside the sse {} request block (see EventStreamClient),
55− // so the long-lived event connection is never killed mid-stream.
56− readTimeout(NetworkConfig.readTimeoutSeconds, TimeUnit.SECONDS)
57− connectTimeout(NetworkConfig.connectTimeoutSeconds, TimeUnit.SECONDS)
58− retryOnConnectionFailure(true)
59− pingInterval(NetworkConfig.pingIntervalSeconds, TimeUnit.SECONDS)
60− // Pin the server certificate(s) when configured and connecting over TLS.
61− // A malformed pin throws here (surfaced as a connection failure), which is
62− // the right fail-closed behavior for a security control the user opted into.
63− if (pins.isNotEmpty() && pinHost != null && isHttps) {
64− certificatePinner(
65− CertificatePinner.Builder().apply { pins.forEach { add(pinHost, it) } }.build(),
66− )
67− }
68− }
69− }
70−
71− install(ContentNegotiation) {
72− json(OpencodeJson)
73− }
74−
75− install(SSE)
76−
77− // Request-level timeout for REST calls. The SSE stream overrides this to
78− // infinite inside the sse {} request block (see EventStreamClient) so the
79− // long-lived /event connection isn't killed after 60s. The engine's connect
80− // timeout and the SSE idle-timeout watchdog handle stuck SSE connections.
81− install(HttpTimeout) {
82− requestTimeoutMillis = NetworkConfig.restRequestTimeoutMs
83− }
84−
85− if (profile.hasAuth) {
86− if (isHttps) {
87− install(Auth) {
88− basic {
89− credentials {
90− BasicAuthCredentials(
91− username = profile.username.orEmpty(),
92− password = profile.password.orEmpty(),
93− )
94− }
95− // Send eagerly so opencode doesn't need a 401 challenge round-trip, but
96− // only to the configured host: a cross-host redirect must not carry the
97− // credentials off-origin (the plugin would still answer a genuine 401 from
98− // the real host reactively). The Auth plugin is only installed for HTTPS
99− // profiles — over HTTP, even reactive credential sending would leak
100− // passwords in cleartext, so we don't install it at all.
101− sendWithoutRequest { request ->
102− pinHost != null && request.url.host.equals(pinHost, ignoreCase = true)
103− }
104− }
105− }
106− // Defense in depth mirroring the HTTP branch below: sendWithoutRequest only gates
107− // *eager* attachment on the outgoing request — it neither strips an Authorization
108− // header that HttpRedirect copies from the original (configured-host) request onto
109− // a cross-origin redirect target, nor stops the reactive Basic provider answering a
110− // 401 from such a target. followRedirects is enabled by default, so re-check the
111− // host on every physical send and remove the header whenever it isn't the pinned
112− // host, guaranteeing credentials never leave the configured origin.
113− install(
114− createClientPlugin("HttpsBasicAuthHostScoped") {
115− on(Send) { request ->
116− if (pinHost == null || !request.url.host.equals(pinHost, ignoreCase = true)) {
117− request.headers.remove(HttpHeaders.Authorization)
118− }
119− proceed(request)
120− }
121− },
122− )
123− } else if (pinHost != null) {
124− // For HTTP profiles with auth, attach the Basic header proactively (the reactive
125− // Auth plugin is skipped for non-HTTPS so a 401 can't silently re-send credentials
126− // in cleartext), but scope it to the configured host and re-check on EVERY physical
127− // send. Redirects matter: Ktor builds the redirect request by copying the original
128− // headers, so an unconditional header would carry the cleartext credentials to a
129− // cross-host redirect target. Setting it here (and stripping it off-host) mirrors
130− // the HTTPS path's sendWithoutRequest host guard so credentials never go off-origin.
131− val credentials = java.util.Base64.getEncoder().encodeToString(
132− "${profile.username.orEmpty()}:${profile.password.orEmpty()}".toByteArray(),
133− )
134− install(
135− createClientPlugin("HttpBasicAuthHostScoped") {
136− on(Send) { request ->
137− if (request.url.host.equals(pinHost, ignoreCase = true)) {
138− request.headers[HttpHeaders.Authorization] = "Basic $credentials"
139− } else {
140− request.headers.remove(HttpHeaders.Authorization)
141− }
142− proceed(request)
143− }
144− },
145− )
146− }
147− }
148−
149− defaultRequest {
150− url.takeFrom(URLBuilder().takeFrom(normalizedUrl))
151− }
152− }
153− }
154−
155− /** The base URL after applying [ServerProfile.requireHttps]: a cleartext http:// URL is
156− * upgraded to https:// so the "Require HTTPS" choice is enforced at the transport layer.
157− * A configured certificate pin also forces TLS: a pin is meaningless over cleartext and
158− * would otherwise be silently dropped, so we honor the opt-in security control by upgrading
159− * rather than connecting unpinned in the clear. */
160− private fun effectiveBaseUrl(profile: ServerProfile): String {
161− val normalized = normalizeBaseUrl(profile.baseUrl)
162− val forceHttps = profile.requireHttps || parsePins(profile.certPin).isNotEmpty()
163− return if (forceHttps && normalized.lowercase().startsWith("http://")) {
164− "https://" + normalized.substring("http://".length)
165− } else {
166− normalized
167− }
168− }
169−
170− /** Split a certificate-pin field into individual OkHttp pins. Accepts whitespace- or
171− * comma-separated "sha256/<base64>" entries; blank entries are dropped. */
172− private fun parsePins(raw: String?): List<String> =
173− raw?.split(Regex("[\\s,]+"))?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
174−
175− /** Ensure a scheme and a single trailing slash so relative paths resolve correctly.
176− * Defaults to [https] when no scheme is given so credentials aren't accidentally
177− * sent over cleartext. Users who need plain HTTP must type the scheme explicitly. */
178− fun normalizeBaseUrl(raw: String): String {
179− val trimmed = raw.trim()
180− // Compare the scheme prefix case-insensitively (a lowercased copy) while
181− // preserving the original casing of the rest of the URL in the output.
182− val lowerTrimmed = trimmed.lowercase()
183− val withScheme = if (lowerTrimmed.startsWith("http://") || lowerTrimmed.startsWith("https://")) {
184− trimmed
185− } else {
186− "https://$trimmed"
187− }
188− return if (withScheme.endsWith("/")) withScheme else "$withScheme/"
189− }
190−}
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.ktdeleted−273
@@ -1,273 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−/**
4− * Centralized tuning parameters for the network and presentation layers. Keeping these
5− * in one place makes them easy to find and adjust without hunting through source files,
6− * and makes the intent of each value explicit rather than a bare magic number.
7− */
8−object NetworkConfig {
9−
10− // --- OkHttp engine timeouts (HttpClientFactory) ---
11−
12− /** Socket connect timeout for all HTTP calls, including the SSE handshake. */
13− const val connectTimeoutSeconds = 30L
14− /** Socket read timeout for ordinary REST calls. The SSE stream overrides this to INFINITE. */
15− const val readTimeoutSeconds = 60L
16− /** OkHttp WebSocket/interval ping, keeping the SSE socket alive through proxies. */
17− const val pingIntervalSeconds = 20L
18− /** Request-level timeout for REST calls (covers connect + headers + body). The SSE stream overrides this to INFINITE. */
19− const val restRequestTimeoutMs = 60_000L
20−
21− // --- REST retry (OpencodeApiClient.withRetry) ---
22−
23− /** Maximum number of attempts for a retriable REST call. */
24− const val retryMaxAttempts = 3
25− /** Initial backoff delay; doubles on each successive failure (exponential backoff). */
26− const val retryInitialDelayMs = 500L
27− /** Cap on a single retry backoff delay, guarding against overflow at high attempt counts. */
28− const val retryMaxDelayMs = 30_000L
29− /** Jitter factor for retry backoff (0.2 = ±20% symmetric random jitter on each delay). */
30− const val retryJitterFactor = 0.2
31−
32− // --- SSE stream (EventStreamClient) ---
33−
34− /** Initial reconnect backoff after an SSE stream drop; doubles up to [sseMaxBackoffMs]. */
35− const val sseInitialBackoffMs = 500L
36− /** Cap on the SSE reconnect backoff. */
37− const val sseMaxBackoffMs = 10_000L
38− /** Max gap between events before a silent/half-open SSE connection is dropped and reconnected. */
39− const val sseIdleTimeoutMs = 90_000L
40− /** Buffer capacity for the SSE events SharedFlow; prevents a slow subscriber stalling the read loop. */
41− const val sseEventBufferCapacity = 64
42−
43− // --- Connectivity monitor (AppContainer) ---
44−
45− /** Grace period after a network is lost before reporting offline. Absorbs the transient
46− * window during a Wi-Fi→cellular handoff where onLost(WiFi) fires before the replacement
47− * network becomes active (cm.activeNetwork is briefly null), so a handoff doesn't flash
48− * offline while a genuine loss (no active network after the grace) is still caught. */
49− const val networkOfflineGraceMs = 2_000L
50−
51− // --- Catalog cache (OpencodeApiClient) ---
52−
53− /** How long cached catalog responses (providers/agents/commands) are considered fresh. */
54− const val catalogCacheTtlMs = 60_000L
55−
56− // --- Session list (SessionListViewModel) ---
57−
58− /** Max sessions to fetch previews for in one batch. */
59− const val maxPreviewSessions = 50
60− /** Max concurrent preview fetches (prevents flooding the server with parallel requests). */
61− const val maxConcurrentPreviews = 8
62− /** Max characters of a session's last message to keep as a list preview. */
63− const val previewTextMaxLength = 200
64− /** Debounce delay before fetching a session preview after an SSE update, so a burst
65− * of SessionUpdated events (e.g. during active streaming) coalesces into one fetch
66− * instead of launching one per event (each downloading the full message history). */
67− const val previewDebounceMs = 500L
68− /** Debounce for applying SessionUpdated SSE events to the session list state. During
69− * active streaming the server emits these frequently; without debouncing each one
70− * triggers a full list filter + sort + recomposition, causing scroll jank. */
71− const val sessionUpdateDebounceMs = 300L
72−
73− // --- Global search (GlobalSearchViewModel) ---
74−
75− /** Max sessions whose message history is scanned in one global-search pass. Global
76− * search downloads each session's messages, so this bounds the work; if there are more
77− * sessions, the result set notes that the search was truncated. */
78− const val maxSearchSessions = 50
79− /** Debounce before a global search fires, so typing doesn't launch a fetch per keystroke. */
80− const val searchDebounceMs = 350L
81− /** Minimum query length before a global search runs (shorter queries match too much). */
82− const val minSearchQueryLength = 2
83− /** Characters of context to show around a global-search match. */
84− const val searchSnippetLength = 160
85−
86− // --- Message cache (MessageCacheStore) ---
87−
88− /** Minimum interval between on-disk writes of a session's message snapshot, so a fast
89− * token stream doesn't hammer the disk. The cache only needs to be recent enough for an
90− * instant/offline first paint; the network corrects it on the next open. */
91− const val messageCacheWriteThrottleMs = 2_000L
92−
93− /** Grace period before a deferred session delete actually fires, during which an
94− * Undo snackbar lets the user cancel it. */
95− const val undoDeleteDelayMs = 5_000L
96−
97− /** Grace period before a deferred server-profile delete actually fires, during which
98− * an Undo snackbar lets the user cancel it. Same UX rationale as [undoDeleteDelayMs]. */
99− const val undoServerDeleteDelayMs = 5_000L
100−
101− /** Grace period before a deferred crash-report delete actually fires, during which
102− * an Undo snackbar lets the user cancel it. */
103− const val undoReportDeleteDelayMs = 5_000L
104−
105− // --- In-memory message store (SessionRepository.MessageStore) ---
106−
107− /** Max messages to keep in memory per observed session; oldest are evicted beyond this. */
108− const val maxInMemoryMessages = 500
109−
110− // --- File viewer (FileViewScreen) ---
111−
112− /** Cap on how many lines the raw viewer renders and searches. A multi-megabyte file is
113− * split once (not twice) into this many lines at most, so it can't hold two full line
114− * lists in memory or stall the LazyColumn; beyond the cap a truncation banner is shown. */
115− const val maxRenderedFileLines = 5000
116−
117− // --- File browser (FileBrowserViewModel) ---
118−
119− /** Debounce delay before firing a file-search request after the user stops typing. */
120− const val fileSearchDebounceMs = 250L
121−
122− // --- Attachments (chat composer) ---
123−
124− /** Max size of a single attachment (image/file) before it's rejected. Base64 inflates
125− * the payload ~33%, and the whole prompt body is held in memory, so cap generously but
126− * finitely to avoid OOM / oversized requests. */
127− const val maxAttachmentBytes = 10L * 1024 * 1024
128−
129− /** Max number of attachments staged for one prompt, to bound memory and request size. */
130− const val maxAttachments = 8
131−
132− /** Max cumulative size of all attachments staged for one prompt. The per-file
133− * [maxAttachmentBytes] cap alone allows [maxAttachments] max-size files (~80MB raw,
134− * ~106MB as base64) to pile up in memory and be re-serialized on every add/remove and
135− * again when the request body is built — a realistic OOM. This bounds the whole set. */
136− const val maxTotalAttachmentBytes = 25L * 1024 * 1024
137−
138− // --- StateFlow sharing (WhileSubscribed) ---
139−
140− /** Grace period before a cold StateFlow is stopped after its last subscriber leaves. */
141− const val stateFlowSubscriptionTimeoutMs = 5_000L
142−
143− /** Maximum time a notification action BroadcastReceiver may hold its goAsync()
144− * PendingResult open. The system ANRs a receiver that doesn't call finish() within
145− * ~10s, so this must stay below that; a slow permission-respond retry (withRetry
146− * exponential backoff up to ~90s) or a blocked outbox enqueue could otherwise blow
147− * past the window. The watchdog finishes the PendingResult at this deadline so the
148− * receiver never ANRs; the underlying network call keeps running on the app scope. */
149− const val notificationReceiverTimeoutMs = 8_000L
150−
151− // --- Draft persistence (ChatViewModel) ---
152−
153− /** Debounce delay before persisting a draft to disk after the user stops typing. */
154− const val draftDebounceMs = 500L
155−
156− /** Maximum characters allowed in the chat input field. A generous cap that prevents
157− * a huge paste from stalling the UI (the field would otherwise buffer and lay out
158− * an unbounded string) while leaving plenty of room for long prompts. */
159− const val maxDraftLengthChars = 32_000
160−
161− /** Show the "remaining characters" supportingText under the chat input once the
162− * draft length crosses this fraction of [maxDraftLengthChars], so the cap is
163− * visible before it silently kicks in but doesn't clutter a normal short prompt. */
164− const val draftCountdownThresholdFraction = 0.8f
165−
166− /** Maximum characters allowed in a session title (rename dialog). Prevents the
167− * server from rejecting an overly long title and keeps list rows readable. */
168− const val maxSessionTitleChars = 200
169−
170− // --- Profile store (ServerEditViewModel) ---
171−
172− /** Timeout for loading a profile from DataStore before giving up. */
173− const val profileLoadTimeoutMs = 5_000L
174−
175− /** How long mDNS discovery searches before showing a "no servers found" terminal state.
176− * Discovery continues running in the background (a late server can still appear), but the
177− * spinner is replaced with the no-results message so the user isn't left looking at an
178− * infinite spinner on a network with no opencode servers. */
179− const val nsdDiscoveryNoResultTimeoutMs = 8_000L
180−
181− /** Timeout for a credential test (probe) before giving up, so a hung/unresponsive server
182− * doesn't leave the "Testing…" spinner spinning indefinitely. */
183− const val testCredentialsTimeoutMs = 15_000L
184−
185− // --- SSE stream (EventStreamClient, AppContainer) ---
186−
187− /** Delay before retrying the message-activity observer after a failure. */
188− const val observerRetryDelayMs = 5_000L
189− /** Debounce before the run foreground-service is started/stopped in response to
190− * run-activity changes. A run that starts and then fails/idles almost immediately
191− * would otherwise dispatch startForegroundService() then stopService() back-to-back;
192− * if the stop wins the race before startForeground() runs, Android raises the
193− * "did not then call startForeground()" crash. Debouncing collapses that window. */
194− const val runForegroundDebounceMs = 500L
195−
196− // --- Markdown rendering (MarkdownText) ---
197−
198− /** Throttle delay to coalesce streaming tokens into one re-parse. The markdown
199− * library re-parses the full AST on every content change, so during a long streaming
200− * response this is O(n²) work. 50ms (~20fps) is smooth for progressively appearing
201− * text while cutting parse work ~3x versus a per-frame (16ms) throttle. */
202− const val streamingThrottleMs = 50L
203−
204− // --- Snackbar one-shot events (ViewModels) ---
205−
206− /** Buffer capacity for transient error SharedFlows that drive snackbars. */
207− const val snackbarEventBufferCapacity = 16
208−
209− /** How long the chat top-bar refresh icon stays in its "refreshing" spinner state
210− * after a tap, giving immediate feedback that the refresh was triggered even when
211− * the connection state is already Connected (so the ConnectionBanner doesn't change). */
212− const val refreshFeedbackMs = 1_200L
213−
214− // --- UI layout constants ---
215−
216− /** Minimum window width (dp) for two-pane layout on tablets / unfolded foldables. */
217− const val twoPaneWidthThresholdDp = 840
218− /** Maximum width fraction a user message bubble can occupy (keeps it readable). */
219− const val userBubbleWidthFraction = 0.85f
220− /** Minimum number of options in a picker sheet (model/agent/command) before its search
221− * field auto-focuses and raises the keyboard on open. A tiny catalog is faster to scan by
222− * eye, so popping the keyboard for it is just friction. */
223− const val pickerSearchAutofocusThreshold = 8
224−
225− /** Minimum number of saved server profiles before the server list shows its search/filter
226− * field. A typical user has one or two servers, where scanning by eye beats typing; power
227− * users juggling many profiles get the filter once it earns its space. */
228− const val serverListSearchThreshold = 5
229−
230− /** Minimum number of crash reports before the diagnostics list shows its search field. */
231− const val diagnosticsSearchThreshold = 8
232−
233− /** Minimum number of sessions in the usage report before the per-session list shows its
234− * filter field. A short history is faster to scan than type into. */
235− const val usageSessionSearchThreshold = 8
236−
237− /** Minimum number of sessions before the session list shows its search/filter field.
238− * Matches the sibling screens' convention: a short list is faster to scan by eye than
239− * type into, so the field earns its vertical space only once the list grows. */
240− const val sessionListSearchThreshold = 6
241− /** Extra bottom content padding (dp) for the chat message list, reserving room for the
242− * jump-to-latest FAB so it never floats over the last message. */
243− const val chatListFabInsetDp = 80
244− /** Left pane weight in the two-pane layout (session list). */
245− const val twoPaneLeftWeight = 0.38f
246− /** Right pane weight in the two-pane layout (chat detail). */
247− const val twoPaneRightWeight = 0.62f
248−
249− /** Minimum window width (dp) at which a NavigationRail is shown alongside the
250− * two-pane layout for top-level destination discoverability on large screens. */
251− const val navigationRailThresholdDp = 840
252−
253− /** Maximum width (dp) for the chat message list on large screens. On a tablet or
254− * unfolded foldable, full-width message bubbles stretch edge-to-edge, hurting
255− * readability — capping and centering the list (like Gmail/Telegram on tablets)
256− * keeps line lengths comfortable. Applied only above [twoPaneWidthThresholdDp]. */
257− const val chatContentMaxWidthDp = 800
258−
259− // --- Motion tokens (shared across screens) ---
260−
261− /** Duration (ms) for horizontal slide push/pop nav transitions. */
262− const val motionSlideDurationMs = 220
263− /** Duration (ms) for fade in/out nav and content transitions. */
264− const val motionFadeDurationMs = 180
265−
266− // --- Font scaling ---
267−
268− /** Upper bound on the *combined* app×OS font scale. The chat text-size preference
269− * multiplies on top of the system font scale, so a user at a large system size and
270− * a large app size can reach ~2× and break tight layouts. The provided app scale is
271− * clamped so the product with the OS fontScale never exceeds this. */
272− const val maxCombinedFontScale = 1.8f
273−}
app/src/main/java/soy/iko/opencode/data/network/NsdDiscovery.ktdeleted−195
@@ -1,195 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−import android.content.Context
4−import android.net.nsd.NsdManager
5−import android.net.nsd.NsdServiceInfo
6−import android.os.Build
7−import android.util.Log
8−import kotlinx.coroutines.channels.Channel
9−import kotlinx.coroutines.flow.Flow
10−import kotlinx.coroutines.channels.awaitClose
11−import kotlinx.coroutines.flow.callbackFlow
12−import kotlinx.coroutines.launch
13−import kotlinx.coroutines.suspendCancellableCoroutine
14−import kotlinx.coroutines.withTimeoutOrNull
15−import kotlin.coroutines.resume
16−
17−/** A server discovered on the local network via mDNS/DNS-SD. */
18−data class DiscoveredServer(val name: String, val host: String, val port: Int) {
19− val baseUrl: String get() {
20− // IPv6 literals (e.g. "fe80::1%wlan0", "2001:db8::1") must be bracketed to form a valid
21− // URL authority; a link-local zone id's '%' is percent-encoded to "%25" per RFC 6874
22− // ("[fe80::1%25wlan0]") rather than dropped, so link-local hosts stay dialable. IPv4 unchanged.
23− val formattedHost = if (host.contains(':')) "[${host.replace("%", "%25")}]" else host
24− return "http://$formattedHost:$port"
25− }
26−}
27−
28−/**
29− * Discovers opencode servers advertised on the LAN. opencode's `--mdns` publishes a DNS-SD
30− * `_http._tcp` service named `opencode-<port>` (with a `path=/` TXT record), so we browse
31− * that service type and keep only names that look like opencode. Requires no extra
32− * permission — [NsdManager] manages the multicast lock internally.
33− */
34−open class NsdDiscovery(context: Context?) {
35−
36− protected constructor() : this(null)
37−
38− private val appContext = context?.applicationContext
39−
40− /**
41− * A cold [Flow] that starts a discovery when collected and stops it when the collector
42− * cancels. Emits the growing/shrinking set of discovered servers. Resolutions are
43− * serialized through a channel because [NsdManager.resolveService] can only process one
44− * at a time on older platforms (a concurrent resolve fails with ALREADY_ACTIVE).
45− */
46− open fun discover(): Flow<List<DiscoveredServer>> = callbackFlow {
47− val nsd = appContext?.getSystemService(Context.NSD_SERVICE) as? NsdManager
48− if (nsd == null) {
49− trySend(emptyList())
50− close()
51− return@callbackFlow
52− }
53− // Thread-safe: mutated by the resolver coroutine and the (main-thread) lost callback.
54− val found = java.util.concurrent.ConcurrentHashMap<String, DiscoveredServer>()
55− // Per-name epoch, bumped on every onServiceLost. Each queued resolve is stamped with the
56− // epoch that was current when it was enqueued; the resolver discards its result if the
57− // epoch has advanced since. This makes loss ordering authoritative: a resolve queued
58− // BEFORE a loss — a stale duplicate onServiceFound (stock NsdManager can emit these on
59− // multi-homed devices), or a resolve still in flight when the service departs — is
60− // dropped, while a resolve queued AFTER a loss (a genuine re-announcement of a restarted
61− // server) still inserts. It fixes the phantom a plain "lost-pending" flag missed:
62− // onServiceLost removing an already-resolved entry couldn't tell a second, still-queued
63− // duplicate resolve to skip re-inserting the departed service — yet it also avoids that
64− // flag's converse hazard of leaking a marker that suppresses a later legitimate re-appearance.
65− val epoch = java.util.concurrent.ConcurrentHashMap<String, Int>()
66− val toResolve = Channel<Pair<NsdServiceInfo, Int>>(Channel.UNLIMITED)
67−
68− val resolver = launch {
69− for ((info, stampedEpoch) in toResolve) {
70− // Key the map by the name from discovery: onServiceFound/onServiceLost both use
71− // NsdServiceInfo.serviceName, whereas the resolved info's serviceName can differ —
72− // keying by the resolved name would leave a departed server that onServiceLost
73− // can't match, so it would linger in the emitted list forever.
74− val discoveredName = info.serviceName.orEmpty()
75− // NsdManager.resolveService occasionally never invokes its listener. Without a
76− // timeout, one stuck resolve would hang this single serialized resolver forever,
77− // so every later-discovered service queued behind it is never processed and the
78− // emitted list silently stops updating. Abandon a stuck resolve and keep draining.
79− val resolved = withTimeoutOrNull(RESOLVE_TIMEOUT_MS) { resolveService(nsd, info) }
80− // Drop the result if the service was reported lost since this resolve was queued
81− // (its epoch advanced), or if the resolve failed/timed out (the service is gone).
82− if ((epoch[discoveredName] ?: 0) != stampedEpoch || resolved == null) continue
83− val name = resolved.serviceName.orEmpty()
84− // Keep only opencode's own advertisements, not every _http._tcp service
85− // (printers, routers, other web servers) on the network.
86− if (!name.startsWith("opencode", ignoreCase = true)) continue
87− @Suppress("DEPRECATION")
88− val host = resolved.host?.hostAddress ?: continue
89− found[discoveredName] = DiscoveredServer(name = name, host = host, port = resolved.port)
90− // Close the check-then-insert race: an onServiceLost firing in the non-suspending
91− // window since the check above bumped the epoch but couldn't remove this name from
92− // `found` (nothing was inserted yet). Re-check and evict now — the epoch is
93− // monotonic, so any loss that raced the insert is still visible here.
94− if ((epoch[discoveredName] ?: 0) != stampedEpoch) found.remove(discoveredName)
95− trySend(found.values.sortedBy { it.name })
96− }
97− }
98−
99− val listener = object : NsdManager.DiscoveryListener {
100− override fun onDiscoveryStarted(serviceType: String?) {}
101− override fun onServiceFound(serviceInfo: NsdServiceInfo) {
102− // Stamp the resolve with the name's current epoch so a loss after this point (but
103− // before the serialized resolver reaches it) invalidates the result.
104− val name = serviceInfo.serviceName.orEmpty()
105− toResolve.trySend(serviceInfo to (epoch[name] ?: 0))
106− }
107− override fun onServiceLost(serviceInfo: NsdServiceInfo) {
108− val name = serviceInfo.serviceName ?: return
109− // Advance the epoch so any resolve queued before now (including a duplicate
110− // onServiceFound whose result hasn't landed yet) is discarded and can't resurrect
111− // this service, then drop it from the emitted list if it was already resolved.
112− epoch.merge(name, 1) { old, _ -> old + 1 }
113− if (found.remove(name) != null) {
114− trySend(found.values.sortedBy { it.name })
115− }
116− }
117− override fun onDiscoveryStopped(serviceType: String?) {}
118− override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) {
119− Log.w(TAG, "mDNS discovery start failed: $errorCode")
120− close()
121− }
122− override fun onStopDiscoveryFailed(serviceType: String?, errorCode: Int) {}
123− }
124−
125− runCatching { nsd.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener) }
126− .onFailure { close(it) }
127−
128− awaitClose {
129− resolver.cancel()
130− toResolve.close()
131− runCatching { nsd.stopServiceDiscovery(listener) }
132− }
133− }
134−
135− private suspend fun resolveService(nsd: NsdManager, info: NsdServiceInfo): NsdServiceInfo? =
136− suspendCancellableCoroutine { cont ->
137− if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
138− // API 34 deprecated the one-shot resolveService (which has no cancel API and
139− // whose listener leaks on timeout, wedging the platform's serialized slot so the
140− // next resolve fails with ALREADY_ACTIVE) in favour of a persistent callback that
141− // can be detached. A timed-out (cancelled) resolve unregisters its callback in
142− // invokeOnCancellation, and a settled one unregisters itself, so nothing lingers.
143− val settled = java.util.concurrent.atomic.AtomicBoolean(false)
144− lateinit var callback: NsdManager.ServiceInfoCallback
145− callback = object : NsdManager.ServiceInfoCallback {
146− override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
147− if (settled.compareAndSet(false, true) && cont.isActive) cont.resume(null)
148− }
149− override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
150− if (settled.compareAndSet(false, true)) {
151− runCatching { nsd.unregisterServiceInfoCallback(callback) }
152− if (cont.isActive) cont.resume(serviceInfo)
153− }
154− }
155− override fun onServiceLost() {
156− if (settled.compareAndSet(false, true)) {
157− runCatching { nsd.unregisterServiceInfoCallback(callback) }
158− if (cont.isActive) cont.resume(null)
159− }
160− }
161− override fun onServiceInfoCallbackUnregistered() {}
162− }
163− cont.invokeOnCancellation {
164− if (settled.compareAndSet(false, true)) {
165− runCatching { nsd.unregisterServiceInfoCallback(callback) }
166− }
167− }
168− runCatching { nsd.registerServiceInfoCallback(info, { it.run() }, callback) }
169− .onFailure { if (settled.compareAndSet(false, true) && cont.isActive) cont.resume(null) }
170− } else {
171− // Pre-34: the deprecated one-shot resolveService has no cancel API, so a stuck
172− // resolve abandoned by RESOLVE_TIMEOUT_MS can still wedge the platform's single
173− // serialized slot; this is a platform limitation we can only bound, not clear.
174− val listener = object : NsdManager.ResolveListener {
175− override fun onResolveFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) {
176− if (cont.isActive) cont.resume(null)
177− }
178− override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
179− if (cont.isActive) cont.resume(serviceInfo)
180− }
181− }
182− runCatching { @Suppress("DEPRECATION") nsd.resolveService(info, listener) }
183− .onFailure { if (cont.isActive) cont.resume(null) }
184− }
185− }
186−
187− private companion object {
188− const val TAG = "NsdDiscovery"
189− // opencode advertises bonjour type "http" → DNS-SD "_http._tcp.".
190− const val SERVICE_TYPE = "_http._tcp."
191− // Upper bound on a single mDNS resolve before it's abandoned so a stuck platform
192− // callback can't wedge the serialized resolver. Local-network resolves are sub-second.
193− const val RESOLVE_TIMEOUT_MS = 5_000L
194− }
195−}
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.ktdeleted−540
@@ -1,540 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−import soy.iko.opencode.data.model.Agent
4−import soy.iko.opencode.data.model.Command
5−import soy.iko.opencode.data.model.CommandRequest
6−import soy.iko.opencode.data.model.CreateSessionRequest
7−import soy.iko.opencode.data.model.FileContent
8−import soy.iko.opencode.data.model.FileNode
9−import soy.iko.opencode.data.model.FileStatusEntry
10−import soy.iko.opencode.data.model.MessageWithParts
11−import soy.iko.opencode.data.model.ModelRef
12−import soy.iko.opencode.data.model.PathInfo
13−import soy.iko.opencode.data.model.FilePromptPart
14−import soy.iko.opencode.data.model.FindMatch
15−import soy.iko.opencode.data.model.PermissionReplyBody
16−import soy.iko.opencode.data.model.PermissionResponse
17−import soy.iko.opencode.data.model.Project
18−import soy.iko.opencode.data.model.PromptPart
19−import soy.iko.opencode.data.model.PromptRequest
20−import soy.iko.opencode.data.model.ProvidersResponse
21−import soy.iko.opencode.data.model.RevertRequest
22−import soy.iko.opencode.data.model.Session
23−import soy.iko.opencode.data.model.ShellRequest
24−import soy.iko.opencode.data.model.SummarizeRequest
25−import soy.iko.opencode.data.model.SymbolResult
26−import soy.iko.opencode.data.model.TextPromptPart
27−import soy.iko.opencode.data.model.UpdateSessionRequest
28−import io.ktor.client.HttpClient
29−import io.ktor.client.call.body
30−import io.ktor.client.plugins.ClientRequestException
31−import io.ktor.client.plugins.ResponseException
32−import io.ktor.client.plugins.ServerResponseException
33−import io.ktor.client.request.delete
34−import io.ktor.client.request.get
35−import io.ktor.client.request.header
36−import io.ktor.client.request.parameter
37−import io.ktor.client.request.patch
38−import io.ktor.client.request.post
39−import io.ktor.client.request.setBody
40−import io.ktor.http.ContentType
41−import io.ktor.http.contentType
42−import kotlinx.coroutines.delay
43−import kotlinx.coroutines.sync.Mutex
44−import kotlinx.coroutines.sync.withLock
45−import kotlinx.serialization.json.buildJsonObject
46−import kotlinx.serialization.json.put
47−import kotlin.random.Random
48−import java.net.URLEncoder
49−
50−/**
51− * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
52− * SSE stream lives in [EventStreamClient]; everything else is here.
53− */
54−open class OpencodeApiClient private constructor(
55− private val client: HttpClient?,
56− @Suppress("unused") private val testMode: Boolean,
57−) {
58− constructor(client: HttpClient) : this(client, false)
59− protected constructor() : this(null, true)
60−
61− // --- Catalog cache (providers/agents/commands) ---
62− // These change rarely, but the ChatViewModel re-fetches them on every creation.
63− // A short-lived in-memory cache avoids redundant network calls when navigating
64− // between sessions on the same server.
65− private data class CachedEntry<T>(val value: T, val fetchedAt: Long)
66− // Per-catalog mutexes so a slow fetch of one catalog doesn't block the others.
67− // The fetch runs OUTSIDE the lock (holding it across the network call would serialize
68− // all callers and stall invalidation). To keep that correct, each catalog carries a
69− // generation counter, bumped under the mutex by its invalidateXCache(): a fetch captures
70− // the generation before it starts and, when it completes, only writes its result back if
71− // the generation is unchanged — so an invalidation that raced the in-flight fetch is not
72− // defeated by the pre-invalidation result being stored afterwards.
73− private val providersMutex = Mutex()
74− private val agentsMutex = Mutex()
75− private val commandsMutex = Mutex()
76− private var cachedProviders: CachedEntry<ProvidersResponse>? = null
77− private var cachedAgents: CachedEntry<List<Agent>>? = null
78− private var cachedCommands: CachedEntry<List<Command>>? = null
79− // Generation counters (guarded by the matching mutex above) for the invalidation-race guard.
80− private var providersGeneration = 0
81− private var agentsGeneration = 0
82− private var commandsGeneration = 0
83−
84− /** Lightweight connectivity check. Throws on non-2xx / network failure. */
85− open suspend fun ping() {
86− withRetry { client!!.get("global/health").body<String>() }
87− }
88−
89− open suspend fun listSessions(): List<Session> = withRetry {
90− client!!.get("session").body()
91− }
92−
93− /**
94− * Create a session, optionally in a specific worktree [directory]. The directory is a
95− * query parameter (not a body field): the server records it on the session and resolves
96− * it by session id thereafter, so follow-up calls (messages, commands, abort, …) don't
97− * — and shouldn't — re-send it. A null/blank [directory] lets the server fall back to
98− * its own launch cwd, preserving the pre-directory behavior.
99− */
100− open suspend fun createSession(title: String? = null, directory: String? = null): Session {
101− // Generate the idempotency key before entering withRetry so every retry attempt
102− // shares it. Without this, a POST that reached the server but whose response was
103− // lost (reset/timeout) would be retried and create a second orphan session.
104− val idempotencyKey = java.util.UUID.randomUUID().toString()
105− val dir = directory?.takeIf { it.isNotBlank() }
106− return withRetry {
107− client!!.post("session") {
108− contentType(ContentType.Application.Json)
109− header("Idempotency-Key", idempotencyKey)
110− if (dir != null) parameter("directory", dir)
111− setBody(CreateSessionRequest(title = title))
112− }.body()
113− }
114− }
115−
116− /** List the projects (worktree roots) the server knows about, for the directory picker. */
117− open suspend fun listProjects(): List<Project> = withRetry {
118− client!!.get("project").body()
119− }
120−
121− /** The server's current path info; its [PathInfo.directory] is the default (cwd) a
122− * session with no directory override runs in. */
123− open suspend fun currentPath(): PathInfo = withRetry {
124− client!!.get("path").body()
125− }
126−
127− open suspend fun updateSession(id: String, title: String): Session = withRetry {
128− client!!.patch("session/${encode(id)}") {
129− contentType(ContentType.Application.Json)
130− setBody(UpdateSessionRequest(title = title))
131− }.body()
132− }
133−
134− open suspend fun deleteSession(id: String) {
135− try {
136− withRetry { client!!.delete("session/${encode(id)}").body<String>() }
137− } catch (t: ClientRequestException) {
138− // A DELETE that succeeded but whose response was lost gets retried; the retry
139− // hits an already-gone session and returns 404. Deleting a non-existent session
140− // is effectively success, so swallow 404 rather than surfacing a spurious failure.
141− if (t.response.status.value != 404) throw t
142− }
143− }
144−
145− open suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
146− client!!.get("session/${encode(sessionId)}/message").body()
147− }
148−
149− open suspend fun sendPrompt(
150− sessionId: String,
151− text: String,
152− attachments: List<FilePromptPart> = emptyList(),
153− model: ModelRef? = null,
154− agent: String? = null,
155− // A caller-stable idempotency key. Callers that may re-invoke sendPrompt across
156− // *separate* calls for the same logical message (the offline outbox, which re-sends
157− // a message that stayed queued after an earlier flush failed) must pass a stable key
158− // derived from the persistent message id — otherwise each attempt mints a fresh key
159− // and a POST that reached the server but whose response was lost is re-created as a
160− // duplicate run. When null, a fresh key is generated (correct for one-shot sends).
161− idempotencyKey: String? = null,
162− ): MessageWithParts {
163− // Resolve the key *before* entering withRetry so all retry attempts share it. If the
164− // key were generated inside withRetry, each attempt would get a fresh UUID and the
165− // server couldn't deduplicate a request that reached it but whose response was lost.
166− val resolvedKey = idempotencyKey ?: java.util.UUID.randomUUID().toString()
167− // A text part is only included when there's actual text, so an image-only prompt
168− // (attachments, empty text) doesn't send an empty text part. Attachments follow
169− // the text so the model reads the instruction first.
170− val parts = buildList<PromptPart> {
171− if (text.isNotEmpty()) add(TextPromptPart(text))
172− addAll(attachments)
173− }
174− return withRetry {
175− client!!.post("session/${encode(sessionId)}/message") {
176− contentType(ContentType.Application.Json)
177− header("Idempotency-Key", resolvedKey)
178− setBody(
179− PromptRequest(
180− parts = parts,
181− model = model,
182− agent = agent,
183− ),
184− )
185− }.body()
186− }
187− }
188−
189− open suspend fun abort(sessionId: String) {
190− withRetry { client!!.post("session/${encode(sessionId)}/abort").body<String>() }
191− }
192−
193− /** Invoke a slash-command by name via `POST /session/:id/command`. */
194− open suspend fun runCommand(
195− sessionId: String,
196− command: String,
197− arguments: String = "",
198− agent: String? = null,
199− ): MessageWithParts {
200− // Generate the idempotency key before entering withRetry so all retry
201− // attempts share the same key, preventing duplicate command execution if
202− // the server processed the request but the response was lost.
203− val idempotencyKey = java.util.UUID.randomUUID().toString()
204− return withRetry {
205− client!!.post("session/${encode(sessionId)}/command") {
206− contentType(ContentType.Application.Json)
207− header("Idempotency-Key", idempotencyKey)
208− setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
209− }.body()
210− }
211− }
212−
213− /** Revert the session to just before [messageId] (optionally part [partId]); the
214− * server hides everything after the checkpoint. Returns the updated session. Naturally
215− * idempotent (reverting to the same point twice yields the same state). */
216− open suspend fun revert(sessionId: String, messageId: String, partId: String? = null): Session = withRetry {
217− client!!.post("session/${encode(sessionId)}/revert") {
218− contentType(ContentType.Application.Json)
219− setBody(RevertRequest(messageID = messageId, partID = partId))
220− }.body()
221− }
222−
223− /** Undo the active revert checkpoint, restoring the hidden messages. */
224− open suspend fun unrevert(sessionId: String): Session = withRetry {
225− client!!.post("session/${encode(sessionId)}/unrevert").body()
226− }
227−
228− /** Create a public share link for the session. Returns the session with [Session.share] set.
229− * Mint the Idempotency-Key *before* withRetry so a retried POST (first request reached the
230− * server but its response was lost) doesn't create a second, orphaned, still-valid share. */
231− open suspend fun shareSession(sessionId: String): Session {
232− val idempotencyKey = java.util.UUID.randomUUID().toString()
233− return withRetry {
234− client!!.post("session/${encode(sessionId)}/share") {
235− header("Idempotency-Key", idempotencyKey)
236− }.body()
237− }
238− }
239−
240− /** Revoke the session's public share link. Returns the session with share cleared.
241− * Like [deleteSession], a retried DELETE (first reached the server but the response was
242− * lost) hits an already-unshared session and gets 404 — revoking a non-shared session is
243− * effectively success, so swallow 404 rather than surfacing a spurious failure. */
244− open suspend fun unshareSession(sessionId: String): Session {
245− return try {
246− withRetry { client!!.delete("session/${encode(sessionId)}/share").body() }
247− } catch (t: ClientRequestException) {
248− if (t.response.status.value != 404) throw t
249− withRetry { client!!.get("session/${encode(sessionId)}").body() }
250− }
251− }
252−
253− /** Ask the agent to summarize/compact the conversation to reclaim context. The compacted
254− * summary streams back via SSE like any run; this just triggers it. */
255− open suspend fun summarize(sessionId: String, model: ModelRef) {
256− val idempotencyKey = java.util.UUID.randomUUID().toString()
257− withRetry {
258− client!!.post("session/${encode(sessionId)}/summarize") {
259− contentType(ContentType.Application.Json)
260− header("Idempotency-Key", idempotencyKey)
261− setBody(SummarizeRequest(providerID = model.providerID, modelID = model.modelID))
262− }.body<String>()
263− }
264− }
265−
266− /** Analyze the project and (re)generate its AGENTS.md. The body is omitted so the server
267− * assigns the message id and uses its default model; the run streams back via SSE. */
268− open suspend fun initSession(sessionId: String) {
269− val idempotencyKey = java.util.UUID.randomUUID().toString()
270− withRetry {
271− client!!.post("session/${encode(sessionId)}/init") {
272− header("Idempotency-Key", idempotencyKey)
273− }.body<String>()
274− }
275− }
276−
277− /** Run a one-off shell [command] in the session's worktree. Output streams back via SSE.
278− * [agent] scopes the run (the server requires it). */
279− open suspend fun shell(sessionId: String, command: String, agent: String, model: ModelRef? = null) {
280− val idempotencyKey = java.util.UUID.randomUUID().toString()
281− withRetry {
282− client!!.post("session/${encode(sessionId)}/shell") {
283− contentType(ContentType.Application.Json)
284− header("Idempotency-Key", idempotencyKey)
285− setBody(ShellRequest(agent = agent, command = command, model = model))
286− }.body<String>()
287− }
288− }
289−
290− open suspend fun providers(): ProvidersResponse {
291− val generation: Int
292− providersMutex.withLock {
293− val cached = cachedProviders
294− if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
295− return cached.value
296− }
297− generation = providersGeneration
298− }
299− // Fetch outside the lock so a slow/retrying request (and its backoff) can't stall other
300− // callers or cache invalidation. Concurrent cold callers may double-fetch — acceptable
301− // for a rarely-fetched catalog — and the last store wins.
302− val value = withRetry { client!!.get("config/providers").body<ProvidersResponse>() }
303− providersMutex.withLock {
304− // FIX 15: skip the store if an invalidateProvidersCache() raced this fetch — the
305− // result predates the invalidation and would otherwise resurrect stale data.
306− if (providersGeneration == generation) cachedProviders = CachedEntry(value, System.currentTimeMillis())
307− }
308− return value
309− }
310−
311− /** The server's resolved configuration as a raw JSON object. Parsed loosely by callers
312− * (e.g. the MCP viewer) so a schema addition on the server never breaks decoding. */
313− open suspend fun config(): kotlinx.serialization.json.JsonObject = withRetry {
314− client!!.get("config").body()
315− }
316−
317− /** Live MCP server status (`GET /mcp`): a map of server name to its status (connected,
318− * available tools, any error). Decoded as a raw JSON object so a schema change on the
319− * server never breaks the viewer. */
320− open suspend fun mcpStatus(): kotlinx.serialization.json.JsonObject = withRetry {
321− client!!.get("mcp").body()
322− }
323−
324− /** Dynamically register an MCP server (`POST /mcp`). [config] is the server's config object
325− * (type/command-or-url/env…), sent as `{ "name": [name], "config": [config] }`. Mint the
326− * Idempotency-Key *before* withRetry so a retried POST (first reached the server but the
327− * response was lost) doesn't register a duplicate. */
328− open suspend fun addMcp(name: String, config: kotlinx.serialization.json.JsonObject): kotlinx.serialization.json.JsonObject {
329− val idempotencyKey = java.util.UUID.randomUUID().toString()
330− return withRetry {
331− client!!.post("mcp") {
332− contentType(ContentType.Application.Json)
333− header("Idempotency-Key", idempotencyKey)
334− setBody(
335− buildJsonObject {
336− put("name", name)
337− put("config", config)
338− },
339− )
340− }.body()
341− }
342− }
343−
344− open suspend fun agents(): List<Agent> {
345− val generation: Int
346− agentsMutex.withLock {
347− val cached = cachedAgents
348− if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
349− return cached.value
350− }
351− generation = agentsGeneration
352− }
353− // Fetch outside the lock (see providers()): a slow/retrying request can't stall other
354− // callers or cache invalidation; a rare concurrent double-fetch is acceptable here.
355− val value = withRetry { client!!.get("agent").body<List<Agent>>() }
356− agentsMutex.withLock {
357− // FIX 15: skip the store if an invalidateAgentsCache() raced this fetch (see providers()).
358− if (agentsGeneration == generation) cachedAgents = CachedEntry(value, System.currentTimeMillis())
359− }
360− return value
361− }
362−
363− open suspend fun commands(): List<Command> {
364− val generation: Int
365− commandsMutex.withLock {
366− val cached = cachedCommands
367− if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
368− return cached.value
369− }
370− generation = commandsGeneration
371− }
372− // Fetch outside the lock (see providers()): a slow/retrying request can't stall other
373− // callers or cache invalidation; a rare concurrent double-fetch is acceptable here.
374− val value = withRetry { client!!.get("command").body<List<Command>>() }
375− commandsMutex.withLock {
376− // FIX 15: skip the store if an invalidateCommandsCache() raced this fetch (see providers()).
377− if (commandsGeneration == generation) cachedCommands = CachedEntry(value, System.currentTimeMillis())
378− }
379− return value
380− }
381−
382− /** Invalidate the catalog cache (e.g. on server switch). */
383− open suspend fun invalidateCache() {
384− // FIX 15: bump each generation so an in-flight fetch drops its now-stale result.
385− providersMutex.withLock { cachedProviders = null; providersGeneration++ }
386− agentsMutex.withLock { cachedAgents = null; agentsGeneration++ }
387− commandsMutex.withLock { cachedCommands = null; commandsGeneration++ }
388− }
389−
390− open suspend fun invalidateProvidersCache() {
391− providersMutex.withLock { cachedProviders = null; providersGeneration++ }
392− }
393−
394− open suspend fun invalidateAgentsCache() {
395− agentsMutex.withLock { cachedAgents = null; agentsGeneration++ }
396− }
397−
398− open suspend fun invalidateCommandsCache() {
399− commandsMutex.withLock { cachedCommands = null; commandsGeneration++ }
400− }
401−
402− /** Respond to a permission request so a paused tool run can proceed. */
403− open suspend fun respondPermission(
404− sessionId: String,
405− permissionId: String,
406− response: PermissionResponse,
407− ): String = try {
408− withRetry {
409− client!!.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
410− contentType(ContentType.Application.Json)
411− setBody(PermissionReplyBody(response.wire))
412− }.body<String>()
413− }
414− } catch (t: ClientRequestException) {
415− // A response that reached the server but whose reply was lost gets retried; the
416− // retry finds the permission already resolved (404 gone / 409 conflict). Both mean
417− // the answer landed, so treat them as success instead of a spurious failure.
418− val code = t.response.status.value
419− if (code != 404 && code != 409) throw t
420− ""
421− }
422−
423− // --- Files ---
424−
425− open suspend fun findFiles(query: String): List<String> = withRetry {
426− client!!.get("find/file") { parameter("query", query) }.body()
427− }
428−
429− /** Ripgrep content search across the project (`GET /find?pattern=`). */
430− open suspend fun findText(pattern: String): List<FindMatch> = withRetry {
431− client!!.get("find") { parameter("pattern", pattern) }.body()
432− }
433−
434− /** LSP workspace symbol search (`GET /find/symbol?query=`). */
435− open suspend fun findSymbol(query: String): List<SymbolResult> = withRetry {
436− client!!.get("find/symbol") { parameter("query", query) }.body()
437− }
438−
439− open suspend fun listDirectory(path: String): List<FileNode> = withRetry {
440− client!!.get("file") { parameter("path", path) }.body()
441− }
442−
443− open suspend fun readFile(path: String): FileContent = withRetry {
444− client!!.get("file/content") { parameter("path", path) }.body()
445− }
446−
447− open suspend fun fileStatus(): List<FileStatusEntry> = withRetry {
448− client!!.get("file/status").body()
449− }
450−}
451−
452−private suspend fun <T> withRetry(
453− maxAttempts: Int = NetworkConfig.retryMaxAttempts,
454− initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
455− block: suspend () -> T,
456−): T = withRetryInternal(maxAttempts, initialDelayMs, jitterFactor = NetworkConfig.retryJitterFactor, block)
457−
458−/**
459− * Retry a suspending block up to [maxAttempts] times with exponential backoff.
460− * Cancellation exceptions are re-thrown immediately. Client errors (4xx) are
461− * non-retryable — a 401 or 404 won't succeed on retry, so they surface immediately
462− * instead of wasting up to 3.5 s of backoff. Network failures, timeouts, and 5xx
463− * server errors are retried. Exposed as `internal` so the retry/skip rules are
464− * unit-testable without an HTTP server.
465− */
466−internal suspend fun <T> withRetryInternal(
467− maxAttempts: Int = NetworkConfig.retryMaxAttempts,
468− initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
469− jitterFactor: Double = NetworkConfig.retryJitterFactor,
470− block: suspend () -> T,
471−): T {
472− var lastError: Throwable? = null
473− for (attempt in 1..maxAttempts) {
474− try {
475− return block()
476− } catch (c: kotlinx.coroutines.CancellationException) {
477− throw c
478− } catch (t: ClientRequestException) {
479− // 408 (Request Timeout) and 429 (Too Many Requests) are transient — retry with
480− // backoff. All other 4xx are non-retryable (a 401/404 won't succeed on retry).
481− val status = t.response.status.value
482− if (status != 408 && status != 429) throw t
483− lastError = t
484− if (attempt < maxAttempts) {
485− delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
486− }
487− } catch (t: ServerResponseException) {
488− // 5xx server errors are retryable with backoff.
489− lastError = t
490− if (attempt < maxAttempts) {
491− delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
492− }
493− } catch (t: ResponseException) {
494− // 3xx (redirect limit exceeded) is non-retryable.
495− throw t
496− } catch (t: io.ktor.serialization.ContentConvertException) {
497− // A malformed/unexpected response body (deserialization failure via content
498− // negotiation) is deterministic — it fails identically on every attempt — so
499− // retrying only burns the whole backoff budget re-parsing the same bytes. Fail
500− // fast instead.
501− throw t
502− } catch (t: IllegalArgumentException) {
503− // Also deterministic: a bad argument, or a raw kotlinx SerializationException
504− // (which extends IllegalArgumentException) thrown outside content negotiation.
505− throw t
506− } catch (t: Exception) {
507− lastError = t
508− if (attempt < maxAttempts) {
509− delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
510− }
511− }
512− }
513− throw lastError ?: IllegalStateException("withRetry failed without error")
514−}
515−
516−/** Computes an exponential backoff delay with symmetric jitter. Exposed as `internal`
517− * so the backoff formula is unit-testable without an HTTP server. */
518−internal fun jitteredBackoff(
519− attempt: Int,
520− initialDelayMs: Long,
521− jitterFactor: Double,
522−): Long {
523− // Guard against Int overflow from the shift and Long overflow from
524− // the multiplication at high attempt counts.
525− val shift = (attempt - 1).coerceAtMost(30)
526− val baseDelay = (initialDelayMs * (1L shl shift)).coerceAtMost(NetworkConfig.retryMaxDelayMs)
527− // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
528− // shorter, sometimes longer — spreads concurrent client retries and
529− // prevents thundering-herd reconnection storms.
530− val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
531− return (baseDelay + jitter).coerceAtLeast(0)
532−}
533−
534−/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding).
535− * URLEncoder leaves '.' unencoded, so a "." or ".." segment would otherwise survive as a real
536− * path-traversal component; percent-encode a segment that is nothing but dots. */
537−private fun encode(segment: String): String {
538− val encoded = URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
539− return if (encoded.isNotEmpty() && encoded.all { it == '.' }) encoded.replace(".", "%2E") else encoded
540−}
app/src/main/java/soy/iko/opencode/data/network/OpencodeJson.ktdeleted−37
@@ -1,37 +0,0 @@
1−package soy.iko.opencode.data.network
2−
3−import soy.iko.opencode.data.model.BusEvent
4−import soy.iko.opencode.data.model.MessageInfo
5−import soy.iko.opencode.data.model.Part
6−import soy.iko.opencode.data.model.ToolState
7−import soy.iko.opencode.data.model.UnknownEvent
8−import soy.iko.opencode.data.model.UnknownMessage
9−import soy.iko.opencode.data.model.UnknownPart
10−import soy.iko.opencode.data.model.ToolUnknown
11−import kotlinx.serialization.ExperimentalSerializationApi
12−import kotlinx.serialization.json.Json
13−import kotlinx.serialization.modules.SerializersModule
14−
15−/**
16− * Single shared Json for the whole app.
17− *
18− * The polymorphic default deserializers are the resilience layer: when the server
19− * sends a `type` / `role` / `status` we don't model, decoding falls back to the
20− * Unknown variant instead of throwing — so a new opencode release can't break the
21− * event stream or message decoding.
22− */
23−@OptIn(ExperimentalSerializationApi::class)
24−val OpencodeJson: Json = Json {
25− ignoreUnknownKeys = true
26− isLenient = true
27− coerceInputValues = true
28− encodeDefaults = true
29− explicitNulls = false
30− classDiscriminator = "type"
31− serializersModule = SerializersModule {
32− polymorphicDefaultDeserializer(Part::class) { UnknownPart.serializer() }
33− polymorphicDefaultDeserializer(BusEvent::class) { UnknownEvent.serializer() }
34− polymorphicDefaultDeserializer(MessageInfo::class) { UnknownMessage.serializer() }
35− polymorphicDefaultDeserializer(ToolState::class) { ToolUnknown.serializer() }
36− }
37−}
app/src/main/java/soy/iko/opencode/data/repo/AttachmentDraftStore.ktdeleted−114
@@ -1,114 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import android.util.Log
5−import kotlinx.coroutines.Dispatchers
6−import kotlinx.coroutines.sync.Mutex
7−import kotlinx.coroutines.sync.withLock
8−import kotlinx.coroutines.withContext
9−import kotlinx.serialization.Serializable
10−import kotlinx.serialization.decodeFromString
11−import kotlinx.serialization.encodeToString
12−import kotlinx.serialization.json.Json
13−import soy.iko.opencode.util.runCatchingCancellable
14−import java.io.File
15−
16−/**
17− * One staged (not-yet-sent) attachment, in the minimal form needed to re-send and preview
18− * it after a process restart. Only the self-contained wire payload is kept — the base64
19− * `data:` [url] — because the original content Uri's read permission does NOT survive a
20− * process restart, whereas the data URL is self-sufficient (and Coil re-renders it as the
21− * thumbnail for images).
22− */
23−@Serializable
24−data class PersistedAttachment(
25− val id: String,
26− val name: String,
27− val mime: String,
28− val url: String,
29− val filename: String? = null,
30−)
31−
32−/**
33− * Persists attachments staged for each session so an interrupted compose survives process
34− * death, mirroring how [DraftStore] persists the text draft.
35− *
36− * Backed by one small JSON file per session under filesDir (not cacheDir, which the OS may
37− * evict) rather than SharedPreferences: a base64 payload can be several megabytes and
38− * SharedPreferences loads its whole file into memory on first access.
39− */
40−open class AttachmentDraftStore private constructor(
41− private val appContext: Context?,
42− @Suppress("unused") private val testMode: Boolean,
43−) {
44− constructor(context: Context) : this(context.applicationContext, false)
45− protected constructor() : this(null, true)
46−
47− private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
48−
49− /** Session ids are validated tokens upstream, but sanitize anyway so a stray id can't
50− * escape the directory. */
51− private val idRegex = Regex("[^A-Za-z0-9_-]")
52−
53− private val dir: File? by lazy {
54− appContext?.let { File(it.filesDir, "attachment_drafts").apply { mkdirs() } }
55− }
56−
57− private fun fileFor(sessionId: String): File? =
58− dir?.let { File(it, idRegex.replace(sessionId, "_") + ".json") }
59−
60− /** Per-session write lock so rapid add/remove/clear mutations (each launched as an
61− * independent coroutine by the ViewModel) serialize their writes to the same file
62− * instead of racing — otherwise an older snapshot could land last and overwrite the
63− * newer one on disk, or two writes could tear the file. */
64− private val locks = java.util.concurrent.ConcurrentHashMap<String, Mutex>()
65− private fun lockFor(sessionId: String): Mutex = locks.getOrPut(sessionId) { Mutex() }
66−
67− /** Load the staged attachments for [sessionId], or empty if none / unreadable. */
68− open suspend fun load(sessionId: String): List<PersistedAttachment> = lockFor(sessionId).withLock {
69− withContext(Dispatchers.IO) {
70− val file = fileFor(sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
71− runCatchingCancellable { json.decodeFromString<List<PersistedAttachment>>(file.readText()) }
72− .getOrDefault(emptyList())
73− }
74− }
75−
76− /** Persist [attachments] for [sessionId] (an empty list deletes the file). */
77− open suspend fun save(sessionId: String, attachments: List<PersistedAttachment>): Unit = lockFor(sessionId).withLock {
78− withContext(Dispatchers.IO) {
79− val file = fileFor(sessionId) ?: return@withContext
80− runCatchingCancellable {
81− if (attachments.isEmpty()) file.delete()
82− else {
83− // FIX: write atomically (temp file + rename) like RecentSessionsStore so a
84− // process kill mid-write can't leave a truncated file that fails to decode
85− // and silently drops the staged attachments on reload.
86− val encoded = json.encodeToString(attachments)
87− val tmp = File(file.parentFile, file.name + ".tmp")
88− tmp.writeText(encoded)
89− if (!tmp.renameTo(file)) {
90− file.writeText(encoded)
91− tmp.delete()
92− }
93− }
94− }.onFailure { Log.w("AttachmentDraftStore", "Failed to persist attachments for $sessionId", it) }
95− Unit
96− }
97− }
98−
99− /** Remove staged attachments for a session (call on session deletion). */
100− open suspend fun remove(sessionId: String) {
101− // Keep the per-session Mutex in [locks] rather than evicting it: remove() is not always
102− // terminal — a concurrent teardown save() for the same session must serialize against this
103− // delete under the *same* Mutex instance. Evicting here would let that save() acquire a
104− // fresh Mutex, so the two operations no longer serialize (resurrecting an orphan file, or
105− // theoretically tearing a write). The bounded per-session map growth matches the other
106− // lock-keyed stores.
107− lockFor(sessionId).withLock {
108− withContext(Dispatchers.IO) {
109− runCatchingCancellable { fileFor(sessionId)?.delete() }
110− Unit
111− }
112− }
113− }
114−}
app/src/main/java/soy/iko/opencode/data/repo/BackupManager.ktdeleted−135
@@ -1,135 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import kotlinx.coroutines.flow.first
4−import kotlinx.serialization.Serializable
5−import kotlinx.serialization.decodeFromString
6−import kotlinx.serialization.encodeToString
7−import kotlinx.serialization.json.Json
8−import soy.iko.opencode.data.model.ServerProfile
9−
10−/** A server entry in a backup. [password] is present only when the user opted to include it. */
11−@Serializable
12−data class BackupServer(
13− val id: String,
14− val label: String,
15− val baseUrl: String,
16− val username: String? = null,
17− val password: String? = null,
18− val requireHttps: Boolean = false,
19− val certPin: String? = null,
20−)
21−
22−/** App preferences in a backup. */
23−@Serializable
24−data class BackupSettings(
25− // Every field is defaulted (to SettingsStore's own defaults) so a backup written by an older
26− // app version — one whose settings block predates a field added later — still decodes. Without
27− // defaults a missing field throws MissingFieldException in import() BEFORE the server-restore
28− // loop, aborting the ENTIRE restore (servers/pins/archives included) despite the best-effort
29− // contract. ignoreUnknownKeys only tolerates EXTRA keys, not missing required ones.
30− val themeMode: String = ThemeMode.SYSTEM.name,
31− val dynamicColor: Boolean = true,
32− val sendOnEnter: Boolean = true,
33− val appLock: Boolean = false,
34− val chatTextScale: Float = SettingsStore.DEFAULT_CHAT_TEXT_SCALE,
35− val codeWrap: Boolean = false,
36−)
37−
38−/** The full backup document. [version] guards against future format changes. */
39−@Serializable
40−data class BackupData(
41− val version: Int = 1,
42− val servers: List<BackupServer> = emptyList(),
43− val settings: BackupSettings? = null,
44− val pinned: List<String> = emptyList(),
45− val archived: List<String> = emptyList(),
46−)
47−
48−/**
49− * Exports/imports the app's servers, settings, and per-session flags as a single JSON
50− * document (via the Storage Access Framework in the UI). Passwords are only written when the
51− * user explicitly opts in, since the exported file is plaintext and leaves the app's encrypted
52− * store.
53− */
54−class BackupManager(
55− private val profileStore: ProfileStore,
56− private val settingsStore: SettingsStore,
57− private val sessionPrefsStore: SessionPrefsStore,
58−) {
59− private val json = Json { prettyPrint = true; ignoreUnknownKeys = true; encodeDefaults = true }
60−
61− /** Build the backup JSON. Reads the current profiles/settings/flags. */
62− suspend fun export(includePasswords: Boolean): String {
63− val servers = profileStore.profiles.first().map {
64− BackupServer(
65− id = it.id,
66− label = it.label,
67− baseUrl = it.baseUrl,
68− username = it.username,
69− password = if (includePasswords) it.password else null,
70− requireHttps = it.requireHttps,
71− certPin = it.certPin,
72− )
73− }
74− val settings = BackupSettings(
75− themeMode = settingsStore.themeMode.first().name,
76− dynamicColor = settingsStore.dynamicColor.first(),
77− sendOnEnter = settingsStore.sendOnEnter.first(),
78− appLock = settingsStore.appLock.first(),
79− chatTextScale = settingsStore.chatTextScale.first(),
80− codeWrap = settingsStore.codeWrap.first(),
81− )
82− val data = BackupData(
83− servers = servers,
84− settings = settings,
85− pinned = sessionPrefsStore.pinned.first().toList(),
86− archived = sessionPrefsStore.archived.first().toList(),
87− )
88− return json.encodeToString(data)
89− }
90−
91− /** Apply a backup document. Throws on malformed JSON so the caller can report it. Servers
92− * are upserted (existing ids overwritten); settings and flags are restored best-effort. */
93− suspend fun import(text: String) {
94− val data = json.decodeFromString<BackupData>(text)
95− for (s in data.servers) {
96− val username = s.username?.takeIf { it.isNotBlank() }
97− val incomingPassword = s.password?.takeIf { it.isNotEmpty() }
98− // A backup exported *without* passwords carries null. This is an upsert, so don't let
99− // that null wipe a password already stored for this server id — preserve the existing
100− // secret when the backup didn't include one (only the backup explicitly carrying a
101− // password should overwrite it). Re-importing a password-less backup would otherwise
102− // silently break auth for every matching server.
103− val password = incomingPassword ?: username?.let {
104− profileStore.resolve(
105− ServerProfile(id = s.id, label = s.label, baseUrl = s.baseUrl, username = it),
106− ).password
107− }
108− profileStore.save(
109− ServerProfile(
110− id = s.id,
111− label = s.label,
112− baseUrl = s.baseUrl,
113− username = username,
114− password = password,
115− lastUsed = 0,
116− requireHttps = s.requireHttps,
117− certPin = s.certPin?.takeIf { it.isNotBlank() },
118− ),
119− )
120− }
121− data.settings?.let { settings ->
122− // Parse the enum defensively (a bad name from an edited backup shouldn't abort the
123− // import) WITHOUT wrapping the suspend setThemeMode call — wrapping it would swallow
124− // a CancellationException and break structured concurrency.
125− ThemeMode.entries.find { it.name == settings.themeMode }?.let { settingsStore.setThemeMode(it) }
126− settingsStore.setDynamicColor(settings.dynamicColor)
127− settingsStore.setSendOnEnter(settings.sendOnEnter)
128− settingsStore.setAppLock(settings.appLock)
129− settingsStore.setChatTextScale(settings.chatTextScale)
130− settingsStore.setCodeWrap(settings.codeWrap)
131− }
132− data.pinned.forEach { sessionPrefsStore.setPinned(it, true) }
133− data.archived.forEach { sessionPrefsStore.setArchived(it, true) }
134− }
135−}
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.ktdeleted−274
@@ -1,274 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import android.content.pm.PackageManager
5−import android.os.Build
6−import android.util.Log
7−import androidx.core.content.pm.PackageInfoCompat
8−import kotlinx.coroutines.CoroutineScope
9−import kotlinx.coroutines.Dispatchers
10−import kotlinx.coroutines.Job
11−import kotlinx.coroutines.SupervisorJob
12−import kotlinx.coroutines.cancel
13−import kotlinx.coroutines.delay
14−import kotlinx.coroutines.flow.MutableStateFlow
15−import kotlinx.coroutines.flow.StateFlow
16−import kotlinx.coroutines.flow.asStateFlow
17−import kotlinx.coroutines.launch
18−import java.io.File
19−import java.io.PrintWriter
20−import java.io.StringWriter
21−import java.text.SimpleDateFormat
22−import java.util.Date
23−import java.util.Locale
24−import soy.iko.opencode.util.runCatchingCancellable
25−
26−/**
27− * A self-contained crash reporter with no network or third-party backend. An uncaught
28− * exception is written to app-private storage ([Context.filesDir]/crashes) as a plain
29− * text report (timestamp, device/app metadata, and the full stack trace), then the
30− * previous handler is re-invoked so the process still dies normally.
31− *
32− * Reports are surfaced in the in-app Diagnostics screen, where they can be viewed,
33− * shared, or deleted. This closes the "no idea what's crashing in the field" gap
34− * without pulling in a hosted analytics SDK.
35− */
36−class CrashLogger private constructor(private val appContext: Context) {
37−
38− data class CrashReport(
39− val fileName: String,
40− val timestamp: Long,
41− val preview: String,
42− )
43−
44− private val crashDir = File(appContext.filesDir, "crashes").apply { mkdirs() }
45− private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
46− private val crashPrefs = appContext.getSharedPreferences("crash_logger", android.content.Context.MODE_PRIVATE)
47−
48− private val _reports = MutableStateFlow<List<CrashReport>>(emptyList())
49− val reports: StateFlow<List<CrashReport>> = _reports.asStateFlow()
50−
51− fun install() {
52− val previous = Thread.getDefaultUncaughtExceptionHandler()
53− Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
54− runCatching { writeReport(thread, throwable) }
55− .onFailure { Log.e("CrashLogger", "Failed to write crash report", it) }
56− previous?.uncaughtException(thread, throwable)
57− }
58− // Load crash reports off the main thread so startup isn't blocked by file I/O
59− // (listFiles + reading the first line of each report). The StateFlow updates
60− // whenever the scan completes, so the Diagnostics screen reflects the result.
61− scope.launch { runCatchingCancellable { refresh() } }
62− }
63−
64− /** Monotonic guard so a slower earlier scan can't overwrite a newer one's result.
65− * refresh() runs on the multi-threaded IO dispatcher from several call sites
66− * (install/deleteReport/scheduleDelete/clearAll), so two scans can overlap. */
67− private val refreshGeneration = java.util.concurrent.atomic.AtomicLong(0)
68−
69− fun refresh() {
70− val generation = refreshGeneration.incrementAndGet()
71− val scanned = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }
72− ?.sortedByDescending { it.lastModified() }
73− ?.map { f ->
74− CrashReport(
75− fileName = f.name,
76− timestamp = f.lastModified(),
77− preview = runCatching { f.useLines { it.firstOrNull() ?: "" } }.getOrDefault(""),
78− )
79− }
80− .orEmpty()
81− // Publish only if no newer refresh has started meanwhile, so a stale scan (e.g. one
82− // that started before a delete) can't momentarily re-show a just-removed report.
83− if (generation == refreshGeneration.get()) _reports.value = scanned
84− }
85−
86− fun readReport(fileName: String): String? {
87− val file = File(crashDir, fileName).canonicalFile
88− if (!file.path.startsWith(crashDir.canonicalPath + File.separator) || !file.isFile) return null
89− return runCatching { file.readText() }.getOrNull()
90− }
91−
92− fun deleteReport(fileName: String) {
93− scope.launch {
94− val file = File(crashDir, fileName).canonicalFile
95− if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
96− file.delete()
97− }
98− refresh()
99− }
100− }
101−
102− /** Whether any crash report is newer than the last-acknowledged timestamp — i.e. a
103− * crash happened in a previous run that the user hasn't been prompted about yet.
104− * Called on cold start to surface a "crashed last time" dialog. */
105− fun hasUnacknowledgedCrash(): Boolean {
106− val acknowledgedAt = crashPrefs.getLong(KEY_ACKNOWLEDGED_AT, 0L)
107− return crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }
108− ?.any { it.lastModified() > acknowledgedAt } == true
109− }
110−
111− /** Mark all current crash reports as acknowledged so the cold-start prompt doesn't
112− * re-fire on the next launch. */
113− fun acknowledgeCrashes() {
114− crashPrefs.edit().putLong(KEY_ACKNOWLEDGED_AT, System.currentTimeMillis()).apply()
115− }
116−
117− /** Deferred deletions keyed by file name, so an Undo can cancel one before it fires. */
118− private val pendingDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
119−
120− /**
121− * Schedule [fileName] for deletion after [delayMs], cancellable via
122− * [cancelScheduledDelete]. Runs on the logger's own long-lived scope — not the
123− * Diagnostics screen's composition scope — so navigating away during the undo window
124− * still commits the delete instead of silently dropping it. A repeated schedule for
125− * the same name replaces the prior timer.
126− */
127− fun scheduleDelete(fileName: String, delayMs: Long) {
128− val job = scope.launch {
129− delay(delayMs)
130− // Atomically claim the delete by removing our own map entry BEFORE deleting.
131− // The remove is the synchronization point with cancelScheduledDelete: whichever
132− // removes the entry first wins. If an undo raced the timer and removed it first,
133− // this returns false and we skip the delete — so the file survives, matching the
134− // `true` that cancel returned to the UI. Deleting first (then removing) would let
135− // a cancel land in the gap and falsely report "undo succeeded" for a gone file.
136− // remove(name, thisJob) also no-ops for a stale job after a reschedule.
137− val claimed = coroutineContext[Job]?.let { pendingDeletes.remove(fileName, it) } == true
138− if (!claimed) return@launch
139− val file = File(crashDir, fileName).canonicalFile
140− if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
141− file.delete()
142− }
143− refresh()
144− }
145− pendingDeletes.put(fileName, job)?.cancel()
146− }
147−
148− /** Cancel a pending deferred delete (the Undo action). Returns true if it was still
149− * pending (undo succeeded), false if the delete had already fired. */
150− fun cancelScheduledDelete(fileName: String): Boolean {
151− val job = pendingDeletes.remove(fileName) ?: return false
152− job.cancel()
153− return true
154− }
155−
156− fun clearAll() {
157− scope.launch {
158− crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }?.forEach { it.delete() }
159− refresh()
160− }
161− }
162−
163− /** Deferred whole-directory clear, cancellable via [cancelScheduledClearAll] so the bulk
164− * action gets the same undo window as a single-report delete. Runs on the logger's own
165− * scope so it commits even if the user leaves the Diagnostics screen. */
166− private var pendingClearAll: Job? = null
167−
168− fun scheduleClearAll(delayMs: Long) {
169− pendingClearAll?.cancel()
170− pendingClearAll = scope.launch {
171− delay(delayMs)
172− pendingClearAll = null
173− crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }?.forEach { it.delete() }
174− refresh()
175− }
176− }
177−
178− /** Cancel a pending clear-all (the Undo action). Returns true if it was still pending. */
179− fun cancelScheduledClearAll(): Boolean {
180− val job = pendingClearAll ?: return false
181− pendingClearAll = null
182− job.cancel()
183− return true
184− }
185−
186− fun reportCount(): Int = _reports.value.size
187−
188− /** Cancel the internal coroutine scope so background work doesn't outlive the
189− * process. Called from [AppContainer.shutdown]. The UncaughtExceptionHandler is
190− * intentionally left installed — a crash during shutdown still needs to be logged. */
191− fun shutdown() {
192− scope.cancel()
193− }
194−
195− private fun writeReport(thread: Thread, throwable: Throwable) {
196− val now = Date()
197− val stamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss-SSS", Locale.US).format(now)
198− // Append a short random suffix so two crashes in the same millisecond don't
199− // overwrite each other.
200− val suffix = java.lang.Long.toHexString(System.nanoTime())
201− val file = File(crashDir, "crash-$stamp-$suffix.txt")
202− val sw = StringWriter()
203− PrintWriter(sw).use { pw ->
204− pw.println("opencode-android crash report")
205− pw.println("Time: ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss z", Locale.US).format(now)}")
206− pw.println("Thread: ${thread.name}")
207− pw.println("App version: ${appVersion()}")
208− pw.println("Device: ${Build.MANUFACTURER} ${Build.MODEL}")
209− pw.println("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
210− pw.println("ABI: ${Build.SUPPORTED_ABIS.joinToString()}")
211− pw.println()
212− pw.println(throwable.javaClass.name + ": " + throwable.message)
213− throwable.printStackTrace(pw)
214− }
215− // Scrub URLs from the *entire* report. printStackTrace re-emits the exception
216− // message (and every "Caused by:" message) verbatim, so scrubbing only the
217− // top-level message above is futile — Ktor/OkHttp embed the full request URL
218− // in those messages, which may contain auth or internal paths. Scrubbing the
219− // assembled text guarantees no URL survives anywhere in the stored report.
220− file.writeText(scrubUrls(sw.toString()))
221− trimOldReports()
222− }
223−
224− /** Keep only the [MAX_REPORTS] most recent crash reports, deleting older ones. Runs inside
225− * the uncaught-exception handler (the whole call is wrapped in runCatching upstream), so it
226− * must not throw; each delete is guarded. */
227− private fun trimOldReports() {
228− val files = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") } ?: return
229− if (files.size <= MAX_REPORTS) return
230− files.sortedByDescending { it.lastModified() }
231− .drop(MAX_REPORTS)
232− .forEach { runCatching { it.delete() } }
233− }
234−
235− private fun appVersion(): String = runCatching {
236− val pi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
237− appContext.packageManager.getPackageInfo(
238− appContext.packageName,
239− PackageManager.PackageInfoFlags.of(0),
240− )
241− } else {
242− @Suppress("DEPRECATION")
243− appContext.packageManager.getPackageInfo(appContext.packageName, 0)
244− }
245− "${pi.versionName} (${PackageInfoCompat.getLongVersionCode(pi)})"
246− }.getOrDefault("unknown")
247−
248− companion object {
249− /** Cap on retained crash report files. A deterministic crash-loop (e.g. a crash on
250− * startup) writes one report per crash with no other bound, so without this the
251− * crashes/ dir grows without limit until the user manually clears it. */
252− private const val MAX_REPORTS = 20
253−
254− private const val KEY_ACKNOWLEDGED_AT = "acknowledged_at"
255−
256− /** Regex matching http(s) URLs, shared by report writing and [scrubUrls]. */
257− internal val SCRUB_URL_REGEX = Regex("https?://[^\\s\"']+")
258−
259− /**
260− * Replace every http(s) URL in [text] with `[url]`. Exposed as `internal` so the
261− * redaction (used on the whole crash report, including exception messages and
262− * stack traces produced by printStackTrace) can be unit-tested in isolation.
263− */
264− internal fun scrubUrls(text: String): String = SCRUB_URL_REGEX.replace(text, "[url]")
265−
266− @Volatile
267− private var instance: CrashLogger? = null
268−
269− fun get(context: Context): CrashLogger =
270− instance ?: synchronized(this) {
271− instance ?: CrashLogger(context.applicationContext).also { instance = it }
272− }
273− }
274−}
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.ktdeleted−260
@@ -1,260 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import kotlinx.coroutines.CoroutineScope
5−import kotlinx.coroutines.Dispatchers
6−import kotlinx.coroutines.flow.MutableStateFlow
7−import kotlinx.coroutines.flow.StateFlow
8−import kotlinx.coroutines.flow.asStateFlow
9−import kotlinx.coroutines.flow.update
10−import android.util.Log
11−import kotlinx.coroutines.launch
12−import java.util.concurrent.Executors
13−
14−/**
15− * Persists the in-progress message draft for each session so it survives
16− * back-navigation and process death. Backed by SharedPreferences because the
17− * UI needs the initial value synchronously and drafts are low-stakes data.
18− *
19− * The prefs file is loaded on a background thread at construction time. Until
20− * the load completes, [drafts] reports an empty map and [get] returns "" rather
21− * than blocking the caller (previously a [CountDownLatch] could stall the main
22− * thread for up to 2 seconds on a slow disk).
23− */
24−open class DraftStore private constructor(
25− private val appContext: Context?,
26− private val scope: CoroutineScope?,
27− @Suppress("unused") private val testMode: Boolean,
28−) {
29− constructor(context: Context, scope: CoroutineScope) : this(context.applicationContext, scope, false)
30− protected constructor() : this(null, null, true)
31−
32− private val prefs by lazy {
33− appContext?.getSharedPreferences("drafts", Context.MODE_PRIVATE)
34− ?: error("No context — override methods in test subclass")
35− }
36−
37− /** Single-thread executor for flushDraft when prefs aren't initialized yet,
38− * so the main thread is never blocked on disk I/O. */
39− private val flushExecutor by lazy {
40− Executors.newSingleThreadExecutor { r ->
41− Thread(r, "DraftStore-flush").apply { isDaemon = true }
42− }
43− }
44−
45− private val _drafts = MutableStateFlow<Map<String, String>>(emptyMap())
46− /** The full in-memory snapshot of all drafts, updated when prefs finish loading. */
47− open val drafts: StateFlow<Map<String, String>> = _drafts.asStateFlow()
48−
49− /** Queued follow-up messages per session, persisted under a namespaced key so a
50− * follow-up queued mid-run survives process death (the in-memory-only StateFlow in
51− * ChatViewModel would otherwise lose it, along with the cleared draft). */
52− private val _followUps = MutableStateFlow<Map<String, String>>(emptyMap())
53−
54− private val _ready = MutableStateFlow(false)
55− /** True once the background prefs load has completed. */
56− open val ready: StateFlow<Boolean> = _ready.asStateFlow()
57−
58− /** Keys written in-memory (set / cleared / removed) *before* the async prefs load
59− * finished. For these the in-memory state is authoritative — the load must not
60− * resurrect a persisted value the user has since cleared, which a plain putAll(current)
61− * merge cannot express (it can add/override, but not delete). Consulted once, in the
62− * init merge. Separate sets because drafts and follow-ups share the session-id key space
63− * but live under different prefs keys. */
64− // ConcurrentHashMap-backed sets: weakly-consistent iteration so the init merge's forEach
65− // can't throw ConcurrentModificationException if a mutator adds a key concurrently.
66− private val touchedDraftKeys: MutableSet<String> = java.util.concurrent.ConcurrentHashMap.newKeySet()
67− private val touchedFollowUpKeys: MutableSet<String> = java.util.concurrent.ConcurrentHashMap.newKeySet()
68−
69− private fun markDraftTouched(sessionId: String) {
70− if (!_ready.value) touchedDraftKeys.add(sessionId)
71− }
72− private fun markFollowUpTouched(sessionId: String) {
73− if (!_ready.value) touchedFollowUpKeys.add(sessionId)
74− }
75−
76− init {
77− // Eagerly load the prefs file on a background thread so the first main-thread
78− // get() doesn't block on disk I/O. The loaded snapshot is published via [_drafts]
79− // so callers can observe readiness without blocking.
80− if (scope != null && appContext != null) {
81− scope.launch(Dispatchers.IO) {
82− val all = runCatching {
83− prefs.all.mapValues { it.value as? String ?: "" }
84− }.getOrDefault(emptyMap())
85− // Partition persisted keys: follow-up keys carry a prefix, everything else
86− // is a plain per-session draft.
87− val drafts = mutableMapOf<String, String>()
88− val followUps = mutableMapOf<String, String>()
89− all.forEach { (k, v) ->
90− if (k.startsWith(FOLLOWUP_PREFIX)) followUps[k.removePrefix(FOLLOWUP_PREFIX)] = v
91− else drafts[k] = v
92− }
93− // Merge instead of overwriting: any in-memory writes that arrived before
94− // the background load completed (e.g. a share-intent draft injected via
95− // setImmediate) take priority over the persisted snapshot so they aren't
96− // clobbered. For keys the user explicitly touched before the load finished,
97− // drop the persisted entry first so a value they *cleared* isn't resurrected
98− // (the cleared key is absent from `current`, so putAll alone can't remove it).
99− _drafts.update { current ->
100− drafts.apply {
101− touchedDraftKeys.forEach { remove(it) }
102− putAll(current)
103− }
104− }
105− _followUps.update { current ->
106− followUps.apply {
107− touchedFollowUpKeys.forEach { remove(it) }
108− putAll(current)
109− }
110− }
111− _ready.value = true
112− }
113− } else {
114− _ready.value = true
115− }
116− }
117−
118− /** Returns the draft for [sessionId], or "" if not found or still loading. */
119− open fun get(sessionId: String): String = _drafts.value[sessionId].orEmpty()
120−
121− /** Returns the queued follow-up for [sessionId], or "" if none / still loading. */
122− open fun getFollowUp(sessionId: String): String = _followUps.value[sessionId].orEmpty()
123−
124− /** Persist a queued follow-up immediately (fire-and-forget, main-thread-safe).
125− * Mirrors [flushDraft]: async apply() when prefs are open, else a background thread,
126− * so a follow-up queued just before process death is flushed before the process exits.
127− * A blank [text] clears the stored follow-up. */
128− open fun flushFollowUp(sessionId: String, text: String) {
129− markFollowUpTouched(sessionId)
130− _followUps.update { current ->
131− val updated = current.toMutableMap()
132− if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
133− updated
134− }
135− writeAsync(FOLLOWUP_PREFIX + sessionId, text)
136− }
137−
138− /** Persist [key]=[text] (or remove it when blank) without blocking the main thread on
139− * first-time disk I/O — see [flushDraft] for the prefs-not-yet-open rationale. */
140− private fun writeAsync(key: String, text: String) {
141− val write = {
142− runCatching {
143− prefs.edit().apply {
144− if (text.isBlank()) remove(key) else putString(key, text)
145− }.apply()
146− }.onFailure { Log.w("DraftStore", "Failed to persist $key", it) }
147− }
148− // FIX: always submit to the single-thread flushExecutor (never apply directly) so
149− // writes persist in submission order. Previously the post-init fast path applied
150− // synchronously on the caller's thread, which could land BEFORE an earlier still-queued
151− // write, persisting stale text. The task resolves the prefs lazy and calls apply().
152− runCatching { flushExecutor.execute { write() } }
153− }
154−
155− /** Update the in-memory draft immediately without persisting to disk.
156− * Used when navigation must see the new value synchronously (e.g. share-intent
157− * injection before ChatScreen composes). Call [set] afterwards to persist. */
158− open fun setImmediate(sessionId: String, text: String) {
159− markDraftTouched(sessionId)
160− _drafts.update { current ->
161− val updated = current.toMutableMap()
162− if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
163− updated
164− }
165− }
166−
167− open suspend fun set(sessionId: String, text: String) {
168− markDraftTouched(sessionId)
169− _drafts.update { current ->
170− val updated = current.toMutableMap()
171− if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
172− updated
173− }
174− // Route persistence through the single-thread flushExecutor (like the other writers)
175− // rather than the shared IO pool, so this write can't race ahead of an earlier queued
176− // flush and land stale. writeAsync persists sessionId=text (or removes it when blank),
177− // which is exactly this draft's persistence.
178− writeAsync(sessionId, text)
179− }
180−
181− /** Remove the draft for a session (call on session deletion to avoid orphaned entries).
182− * Also clears any queued follow-up so a deleted session leaves nothing behind. */
183− open suspend fun remove(sessionId: String) {
184− markDraftTouched(sessionId)
185− markFollowUpTouched(sessionId)
186− _drafts.update { current ->
187− val updated = current.toMutableMap()
188− updated.remove(sessionId)
189− updated
190− }
191− _followUps.update { current ->
192− val updated = current.toMutableMap()
193− updated.remove(sessionId)
194− updated
195− }
196− // Submit the (combined draft + follow-up) removal to the same single-thread
197− // flushExecutor the other writers use, so it can't race ahead of an earlier queued
198− // write and resurrect stale text. Mirrors flushDraft's submission pattern.
199− runCatching {
200− flushExecutor.execute {
201− runCatching {
202− prefs.edit().remove(sessionId).remove(FOLLOWUP_PREFIX + sessionId).apply()
203− }.onFailure { Log.w("DraftStore", "Failed to remove draft for $sessionId", it) }
204− }
205− }
206− }
207−
208− /**
209− * Synchronously persist a draft. Intended for [ViewModel.onCleared] where the
210− * viewModelScope is already cancelled and a suspending write can't be used.
211− *
212− * If the SharedPreferences file has already been opened (the normal case — the
213− * background init load touches it shortly after construction), the write uses
214− * [apply] (asynchronous) and returns immediately without blocking the main thread.
215− *
216− * If the prefs file has NOT been opened yet (a rare race where onCleared fires
217− * before the background init), the write is dispatched to a background thread
218− * instead of blocking the main thread on a synchronous disk read. The draft
219− * may be lost if the process exits before the write completes, but this is
220− * preferable to an ANR.
221− */
222− open fun flushDraft(sessionId: String, text: String) {
223− markDraftTouched(sessionId)
224− _drafts.update { current ->
225− val updated = current.toMutableMap()
226− if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
227− updated
228− }
229− // FIX: always route the persistence through the single-thread flushExecutor so writes
230− // land in submission order. Previously, once prefs were open this applied synchronously
231− // on the calling (main) thread, so a newer direct write could persist BEFORE an older
232− // still-queued write completed, clobbering the draft with stale text. The executor task
233− // resolves the prefs lazy and uses apply() (async) exactly as before; the write may be
234− // lost only if the process exits before the queued task runs (as previously).
235− runCatching {
236− flushExecutor.execute {
237− runCatching {
238− prefs.edit().apply {
239− if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
240− }.apply()
241− }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
242− }
243− }
244− }
245−
246− private companion object {
247− /** Key prefix distinguishing persisted follow-ups from plain drafts in the shared
248− * prefs file. The leading/trailing spaces can't collide with a real opencode session
249− * id (which are non-blank, space-free tokens like "ses_..."). */
250− const val FOLLOWUP_PREFIX = " followup "
251− }
252−
253− /** Shut down the background flush executor. Call from AppContainer.shutdown(). */
254− open fun shutdown() {
255− flushExecutor.shutdown()
256− // Await pending flushDraft writes so drafts aren't lost if the process exits
257− // immediately after shutdown (e.g. ANR-triggered process kill).
258− runCatching { flushExecutor.awaitTermination(2, java.util.concurrent.TimeUnit.SECONDS) }
259− }
260−}
app/src/main/java/soy/iko/opencode/data/repo/ErrorKind.ktdeleted−78
@@ -1,78 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import io.ktor.client.plugins.ClientRequestException
4−import io.ktor.client.plugins.HttpRequestTimeoutException
5−import io.ktor.client.plugins.RedirectResponseException
6−import io.ktor.client.plugins.ResponseException
7−import io.ktor.client.plugins.ServerResponseException
8−import java.io.IOException
9−import java.net.ConnectException
10−import java.net.SocketTimeoutException
11−import java.net.UnknownHostException
12−
13−/**
14− * User-facing categories for a thrown error. Extracted so the classification logic is
15− * unit-testable without an Android [android.content.Context] (the mapping from a kind
16− * to a localized string lives in [friendlyError]).
17− */
18−enum class ErrorKind {
19− /** Server host could not be resolved / connection refused. */
20− NOT_REACHABLE,
21−
22− /** Connect or read timed out. */
23− TIMEOUT,
24−
25− /** Server returned a 5xx response. */
26− SERVER,
27−
28− /** Server returned a 4xx response (auth, not-found, etc.). */
29− CLIENT,
30−
31− /** Any other network-level I/O failure. */
32− NETWORK,
33−
34− /** Anything that isn't network-related. */
35− UNKNOWN,
36−}
37−
38−/**
39− * Classify a throwable into an [ErrorKind] by inspecting concrete exception types rather
40− * than string-matching class names. Unwraps [cause] chains so an exception surface-wrapped
41− * by a library still classifies correctly.
42− */
43−fun classifyError(t: Throwable): ErrorKind {
44− var current: Throwable? = t
45− // Walk a bounded cause chain so wrappers (e.g. an IllegalStateException around an
46− // IOException) still classify by their root.
47− var hops = 0
48− while (current != null && hops < 8) {
49− when (current) {
50− is UnknownHostException, is ConnectException -> return ErrorKind.NOT_REACHABLE
51− is SocketTimeoutException, is HttpRequestTimeoutException ->
52− return ErrorKind.TIMEOUT
53− is ServerResponseException -> return ErrorKind.SERVER
54− is ClientRequestException -> return ErrorKind.CLIENT
55− is RedirectResponseException -> return ErrorKind.CLIENT
56− is IOException -> return ErrorKind.NETWORK
57− else -> {
58− // Any other ResponseException (unrecognized status family).
59− if (current is ResponseException) return ErrorKind.SERVER
60− }
61− }
62− current = current.cause
63− hops++
64− }
65− return ErrorKind.UNKNOWN
66−}
67−
68−/** The HTTP status code carried by a client/server error, if any. */
69−fun responseStatusCode(t: Throwable): Int? {
70− var current: Throwable? = t
71− var hops = 0
72− while (current != null && hops < 8) {
73− if (current is ResponseException) return current.response.status.value
74− current = current.cause
75− hops++
76− }
77− return null
78−}
app/src/main/java/soy/iko/opencode/data/repo/MessageCacheStore.ktdeleted−137
@@ -1,137 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import android.util.Log
5−import kotlinx.coroutines.Dispatchers
6−import kotlinx.coroutines.sync.Mutex
7−import kotlinx.coroutines.sync.withLock
8−import kotlinx.coroutines.withContext
9−import kotlinx.serialization.builtins.ListSerializer
10−import soy.iko.opencode.data.model.MessageWithParts
11−import soy.iko.opencode.data.network.OpencodeJson
12−import soy.iko.opencode.util.runCatchingCancellable
13−import java.io.File
14−
15−/**
16− * On-disk cache of a session's reduced message list, so a conversation shows instantly on
17− * open (and remains readable while offline / before the network responds) instead of a blank
18− * screen until `GET /session/:id/message` returns.
19− *
20− * One JSON file per session under filesDir, encoded with [OpencodeJson] so the same sealed
21− * polymorphism used on the wire round-trips (unknown parts/messages are concrete
22− * `__unknown` variants, so they encode too). Every operation is best-effort — a cache
23− * miss or a write failure just falls back to the network — so problems never surface to
24− * the user. No annotation processing (Room/KSP) per the project's manual-DI convention.
25− */
26−open class MessageCacheStore private constructor(
27− private val appContext: Context?,
28− @Suppress("unused") private val testMode: Boolean,
29−) {
30− constructor(context: Context) : this(context.applicationContext, false)
31− protected constructor() : this(null, true)
32−
33− private val serializer = ListSerializer(MessageWithParts.serializer())
34−
35− /** Session ids are validated tokens upstream, but sanitize anyway so a stray id can't
36− * escape the directory. */
37− private val idRegex = Regex("[^A-Za-z0-9_-]")
38−
39− private val dir: File? by lazy {
40− appContext?.let { File(it.filesDir, "message_cache").apply { mkdirs() } }
41− }
42−
43− /** Per-profile subdirectory: two servers with a colliding session id must not share a cache
44− * file, so entries are namespaced by profile id under [dir]. */
45− private fun profileDir(profileId: String): File? =
46− dir?.let { File(it, idRegex.replace(profileId, "_")).apply { mkdirs() } }
47−
48− private fun fileFor(profileId: String, sessionId: String): File? =
49− profileDir(profileId)?.let { File(it, idRegex.replace(sessionId, "_") + ".json") }
50−
51− /** Per-session write lock so concurrent save/remove/load on the same file (e.g. both
52− * panes of two-pane mode saving the same session, or a save racing a delete's remove)
53− * can't tear or half-write the cache file. Keyed by session id; the map grows only with
54− * the number of distinct sessions touched, and each [Mutex] is a few bytes. */
55− private val locks = java.util.concurrent.ConcurrentHashMap<String, Mutex>()
56− private fun lockFor(sessionId: String): Mutex = locks.getOrPut(sessionId) { Mutex() }
57−
58− /** Sessions whose cache file has been removed by [remove]. A teardown flush of an
59− * observer (CacheWriter.flushOnTeardown) runs under NonCancellable and can race with
60− * remove(): both serialize on [lockFor], but their relative order is nondeterministic.
61− * If the flush's save lands after remove, it re-creates the deleted session's cache
62− * file, defeating the deletion (a privacy/disk-leak with no background cleanup). The
63− * tombstone lets a post-remove save detect the deletion and skip. Keyed by
64− * "$profileId/$sessionId" — a bare sessionId would suppress a *different* server's
65− * saves if two servers share a colliding session id (the very case the per-profile
66− * namespacing at [profileDir] exists to defend against). Cleared by [load] so
67− * re-opening a session re-enables saving. Bounded by distinct deleted sessions. */
68− private val tombstones: MutableSet<String> =
69− java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap())
70−
71− private fun tombstoneKey(profileId: String, sessionId: String): String = "$profileId/$sessionId"
72−
73− /** Load the cached messages for [sessionId] under [profileId], or empty if none / unreadable. */
74− open suspend fun load(profileId: String, sessionId: String): List<MessageWithParts> = lockFor(sessionId).withLock {
75− // Re-opening a session re-enables saving: clear any tombstone so a fresh conversation
76− // write isn't suppressed by a prior deletion of the same (reused) id.
77− tombstones.remove(tombstoneKey(profileId, sessionId))
78− withContext(Dispatchers.IO) {
79− val file = fileFor(profileId, sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
80− runCatchingCancellable { OpencodeJson.decodeFromString(serializer, file.readText()) }
81− .getOrDefault(emptyList())
82− }
83− }
84−
85− /** Persist [messages] for [sessionId] under [profileId] (an empty list deletes the file). */
86− open suspend fun save(profileId: String, sessionId: String, messages: List<MessageWithParts>): Unit = lockFor(sessionId).withLock {
87− // Skip if this session was deleted since the observer started: a teardown flush racing
88− // after remove() must not re-create the file. (Held under the same per-session lock as
89− // remove, so the tombstone is visible to a save that lost the race.)
90− if (tombstoneKey(profileId, sessionId) in tombstones) return@withLock
91− withContext(Dispatchers.IO) {
92− val file = fileFor(profileId, sessionId) ?: return@withContext
93− runCatchingCancellable {
94− if (messages.isEmpty()) file.delete()
95− else {
96− // FIX: write atomically (temp file + rename) like RecentSessionsStore so a
97− // process kill mid-write can't leave a truncated file that fails to decode,
98− // wiping the cached conversation on the next open.
99− val encoded = OpencodeJson.encodeToString(serializer, messages)
100− val tmp = File(file.parentFile, file.name + ".tmp")
101− tmp.writeText(encoded)
102− if (!tmp.renameTo(file)) {
103− file.writeText(encoded)
104− tmp.delete()
105− }
106− }
107− }.onFailure { Log.w("MessageCacheStore", "Failed to cache messages for $sessionId", it) }
108− Unit
109− }
110− }
111−
112− /** Remove a session's cached messages (call on deletion). Clears the entry in every profile
113− * namespace: session ids are unique per server and deletion is terminal, so the caller
114− * needn't know which profile owned it. The per-session lock is intentionally NOT evicted —
115− * a concurrent teardown save() for this id must serialize against the same [Mutex] instance,
116− * and the map is bounded by the number of distinct sessions touched.
117− * [profileId] namespaces the tombstone so a delete on one server doesn't suppress another
118− * server's saves for a colliding session id. */
119− open suspend fun remove(profileId: String, sessionId: String) {
120− lockFor(sessionId).withLock {
121− // Set the tombstone before deleting so a concurrent/loser save sees it and skips,
122− // instead of re-creating the file we're about to delete.
123− tombstones.add(tombstoneKey(profileId, sessionId))
124− withContext(Dispatchers.IO) {
125− runCatchingCancellable {
126− val name = idRegex.replace(sessionId, "_") + ".json"
127− dir?.listFiles()?.forEach { entry ->
128− if (entry.isDirectory) File(entry, name).delete()
129− }
130− // Back-compat: pre-namespacing caches were written flat under [dir].
131− dir?.let { File(it, name).delete() }
132− }
133− Unit
134− }
135− }
136− }
137−}
app/src/main/java/soy/iko/opencode/data/repo/OutboxStore.ktdeleted−168
@@ -1,168 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import android.util.Log
5−import kotlinx.coroutines.Dispatchers
6−import kotlinx.coroutines.flow.MutableStateFlow
7−import kotlinx.coroutines.flow.StateFlow
8−import kotlinx.coroutines.flow.asStateFlow
9−import kotlinx.coroutines.sync.Mutex
10−import kotlinx.coroutines.sync.withLock
11−import kotlinx.coroutines.withContext
12−import kotlinx.serialization.Serializable
13−import kotlinx.serialization.decodeFromString
14−import kotlinx.serialization.encodeToString
15−import kotlinx.serialization.json.Json
16−import soy.iko.opencode.data.model.ModelRef
17−import soy.iko.opencode.util.runCatchingCancellable
18−import java.io.File
19−
20−/**
21− * One prompt composed while offline (or with no active connection), queued to send when
22− * connectivity returns. Tagged with the [profileId] it was composed for so a reconnect to a
23− * *different* server doesn't misfire it against a session id that only exists on the
24− * original server. [attachments] carry their self-contained base64 data URLs, so a queued
25− * image survives a process restart just like a staged attachment.
26− */
27−@Serializable
28−data class OutboxMessage(
29− val id: String,
30− val profileId: String,
31− val sessionId: String,
32− val text: String,
33− val attachments: List<PersistedAttachment> = emptyList(),
34− val model: ModelRef? = null,
35− val agent: String? = null,
36− val createdAt: Long,
37−)
38−
39−/**
40− * A persisted send-queue: prompts the user composed while disconnected, flushed on
41− * reconnect (see [soy.iko.opencode.di.AppContainer.observeOutbox]). Backed by one JSON file
42− * under filesDir (not SharedPreferences) because a queued attachment's base64 payload can be
43− * several megabytes. The in-memory [messages] StateFlow is the source of truth the UI and the
44− * flusher observe; disk is written through under a mutex on every mutation.
45− */
46−open class OutboxStore private constructor(
47− private val appContext: Context?,
48− @Suppress("unused") private val testMode: Boolean,
49−) {
50− constructor(context: Context) : this(context.applicationContext, false)
51− protected constructor() : this(null, true)
52−
53− private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true }
54− private val ioMutex = Mutex()
55−
56− private val _messages = MutableStateFlow<List<OutboxMessage>>(emptyList())
57− /** All queued messages across sessions/servers, in enqueue order. */
58− open val messages: StateFlow<List<OutboxMessage>> = _messages.asStateFlow()
59−
60− /** Set once the on-disk queue has been read into [_messages] (under [ioMutex]). Until this
61− * is true, a [mutate] must read disk first — otherwise a mutation that beats [load] would
62− * persist from an empty in-memory snapshot and overwrite (or, for a removal, delete) the
63− * persisted queue, losing every message the user composed offline in a previous run. */
64− private var loaded = false
65−
66− private val file: File? by lazy {
67− appContext?.let { File(it.filesDir, "outbox.json") }
68− }
69−
70− /** Read the persisted queue into [messages]. Call once at startup. Idempotent. */
71− open suspend fun load() {
72− ioMutex.withLock { loadLocked() }
73− }
74−
75− /** Read the on-disk queue into memory. MUST be called while holding [ioMutex]. Idempotent
76− * (guarded by [loaded]) so a [mutate] that arrives before [load] can call it to guarantee
77− * disk is read before any write, then a later [load] is a no-op. Returns true once the queue
78− * is in memory (or the file is genuinely absent), false if an existing file couldn't be
79− * read — the caller MUST NOT persist on false or it would overwrite the still-valid queue. */
80− private suspend fun loadLocked(): Boolean {
81− if (loaded) return true
82− val f = file?.takeIf { it.exists() }
83− if (f == null) {
84− // A genuinely absent file is a normal empty queue: mark loaded so mutations proceed.
85− loaded = true
86− return true
87− }
88− // Only mark [loaded] AFTER a successful read. A transient read/decode failure (or a
89− // cancelled read) of an *existing* file must not clobber the still-valid on-disk queue:
90− // leave loaded=false and report failure so the caller skips its write and a later
91− // mutate/load retries the read, instead of persisting an empty snapshot over the user's
92− // offline-composed messages.
93− val diskList = withContext(Dispatchers.IO) {
94− runCatchingCancellable { json.decodeFromString<List<OutboxMessage>>(f.readText()) }
95− }.getOrElse { return false }
96− // Don't clobber items enqueued while the load was in flight: merge by id, keeping
97− // the in-memory version on conflict (it's newer).
98− if (_messages.value.isEmpty()) {
99− _messages.value = diskList.sortedBy { it.createdAt }
100− } else {
101− val known = _messages.value.mapTo(mutableSetOf()) { it.id }
102− val merged = (diskList.filter { it.id !in known } + _messages.value).sortedBy { it.createdAt }
103− _messages.value = merged
104− // The merge reconciled items enqueued while this load was in flight with the
105− // on-disk set; write the merged set back so a later process kill can't drop the
106− // raced enqueue. persist() doesn't take ioMutex, so this won't deadlock.
107− persist(merged)
108− }
109− loaded = true
110− return true
111− }
112−
113− open suspend fun enqueue(message: OutboxMessage) = mutate { current ->
114− current.filterNot { it.id == message.id } + message
115− }
116−
117− open suspend fun remove(id: String) = mutate { current -> current.filterNot { it.id == id } }
118−
119− /** Remove every queued message for [sessionId] (e.g. the user discarded them, or the
120− * session was deleted). */
121− open suspend fun removeForSession(sessionId: String) =
122− mutate { current -> current.filterNot { it.sessionId == sessionId } }
123−
124− private suspend fun mutate(transform: (List<OutboxMessage>) -> List<OutboxMessage>) {
125− // Persist *under* the lock so concurrent mutations (e.g. the flusher removing sent
126− // messages while the UI enqueues a new one) can't race their disk writes: without
127− // this, the older snapshot could land last and drop/resurrect a queued prompt, or two
128− // overlapping writeText/delete calls could tear the file. The lock serializes both the
129− // in-memory update and the write, guaranteeing on-disk order matches memory order.
130− ioMutex.withLock {
131− // Ensure the persisted queue has been read into memory before mutating: a mutation
132− // that races ahead of load() would otherwise transform an empty in-memory snapshot
133− // and persist()/delete() the file, destroying messages queued in a previous run.
134− val loadedOk = loadLocked()
135− val updated = transform(_messages.value).sortedBy { it.createdAt }
136− _messages.value = updated
137− // Only write when the existing on-disk queue was actually read. If the read of an
138− // existing file failed (loadedOk == false), _messages is missing whatever it held, so
139− // persisting `updated` would overwrite the still-valid file with an incomplete
140− // snapshot — losing the user's prior offline-composed messages. Keep the transform in
141− // memory (so the UI reflects the new item) but leave disk untouched; loaded stays
142− // false, so the next mutate/load retries the read and then merges + persists.
143− if (loadedOk) persist(updated)
144− }
145− }
146−
147− private suspend fun persist(list: List<OutboxMessage>) {
148− val f = file ?: return
149− withContext(Dispatchers.IO) {
150− runCatchingCancellable {
151− if (list.isEmpty()) {
152− f.delete()
153− } else {
154− // FIX: write atomically (temp file + rename) like RecentSessionsStore so a
155− // process kill mid-write can't leave a truncated file — a torn file fails to
156− // decode and load() would then drop ALL queued offline messages.
157− val encoded = json.encodeToString(list)
158− val tmp = File(f.parentFile, f.name + ".tmp")
159− tmp.writeText(encoded)
160− if (!tmp.renameTo(f)) {
161− f.writeText(encoded)
162− tmp.delete()
163− }
164− }
165− }.onFailure { Log.w("OutboxStore", "Failed to persist outbox", it) }
166− }
167− }
168−}
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.ktdeleted−251
@@ -1,251 +0,0 @@
1−// Jetpack Security (androidx.security:security-crypto) was deprecated wholesale in 1.1.0 with
2−// no first-party drop-in replacement; we intentionally keep using EncryptedSharedPreferences /
3−// MasterKey for the encrypted password store (on-disk-compatible, still functional). Suppress
4−// the expected deprecation warnings here so the build log stays clean until a future migration.
5−@file:Suppress("DEPRECATION")
6−
7−package soy.iko.opencode.data.repo
8−
9−import android.content.Context
10−import android.content.SharedPreferences
11−import android.util.Log
12−import androidx.datastore.preferences.core.edit
13−import androidx.datastore.preferences.core.stringPreferencesKey
14−import androidx.datastore.preferences.preferencesDataStore
15−import androidx.security.crypto.EncryptedSharedPreferences
16−import androidx.security.crypto.MasterKey
17−import soy.iko.opencode.data.model.ServerProfile
18−import soy.iko.opencode.data.network.OpencodeJson
19−import kotlinx.coroutines.Dispatchers
20−import kotlinx.coroutines.sync.Mutex
21−import kotlinx.coroutines.sync.withLock
22−import kotlinx.coroutines.flow.Flow
23−import kotlinx.coroutines.flow.flowOn
24−import kotlinx.coroutines.flow.map
25−import kotlinx.coroutines.withContext
26−import kotlinx.serialization.Serializable
27−import kotlinx.serialization.builtins.ListSerializer
28−
29−private val Context.dataStore by preferencesDataStore(name = "server_profiles")
30−
31−/**
32− * Persists server profiles. Non-secret fields go to DataStore (as a JSON list);
33− * the Basic-auth password is stored separately in EncryptedSharedPreferences, keyed
34− * by profile id, and merged back in on read.
35− */
36−open class ProfileStore private constructor(
37− private val appContext: Context?,
38− @Suppress("unused") private val testMode: Boolean,
39−) {
40− constructor(context: Context) : this(context.applicationContext, false)
41− protected constructor() : this(null, true)
42−
43− private val profilesKey = stringPreferencesKey("profiles_json")
44−
45− /** Guards [migrateFallbackPasswords] so it runs at most once per process, not on every
46− * DataStore emission. The mutex serializes concurrent callers so a second collector waits
47− * for the migration's writes to land (rather than skipping ahead and reading empty secure
48− * prefs mid-migration); [migrationDone] is only set true *after* the writes complete. */
49− private val migrationMutex = Mutex()
50− @Volatile private var migrationDone = false
51−
52− private val securePrefs: SharedPreferences? by lazy {
53− val ctx = appContext ?: return@lazy null
54− runCatching {
55− val masterKey = MasterKey.Builder(ctx)
56− .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
57− .build()
58− EncryptedSharedPreferences.create(
59− ctx,
60− "server_secrets",
61− masterKey,
62− EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
63− EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
64− )
65− }.getOrElse {
66− Log.e("ProfileStore", "Encrypted prefs unavailable; passwords will not be persisted", it)
67− null
68− }
69− }
70−
71− /** True when EncryptedSharedPreferences could not be initialized. */
72− open val securePrefsUnavailable: Boolean get() = securePrefs == null
73−
74− /** Plaintext fallback used only when encrypted prefs are unavailable, so the user
75− * can still connect — but passwords won't survive an app reinstall. */
76− private val fallbackPrefs: SharedPreferences by lazy {
77− appContext!!.getSharedPreferences("server_secrets_fallback", Context.MODE_PRIVATE)
78− }
79−
80− private fun prefsForPasswords(): SharedPreferences =
81− securePrefs ?: fallbackPrefs
82−
83− /** Migrate any plaintext passwords from the fallback prefs to secure prefs,
84− * then clear the fallback. Runs at most once per process — subsequent calls
85− * are no-ops — so DataStore re-emissions don't repeatedly scan SharedPreferences. */
86− private suspend fun migrateFallbackPasswords() {
87− if (migrationDone) return
88− migrationMutex.withLock {
89− // Re-check inside the lock: a concurrent caller may have completed the migration
90− // while we were waiting. Serializing here (rather than a bare compareAndSet that
91− // marks done before doing the work) ensures a second collector doesn't read the
92− // secure store before the first caller's write has populated it.
93− if (migrationDone) return
94− val secure = securePrefs
95− if (secure == null) {
96− // No secure store to migrate into; nothing to do, and no point retrying.
97− migrationDone = true
98− return
99− }
100− withContext(Dispatchers.IO) {
101− val fallbackKeys = fallbackPrefs.all.keys.filter { it.startsWith("pw_") }
102− if (fallbackKeys.isNotEmpty()) {
103− // Write passwords to the secure store FIRST, then remove them from the
104− // plaintext fallback. SharedPreferences.apply() persists to disk
105− // asynchronously, so removing from fallback before the secure batch lands
106− // could lose the password if the process dies between the two writes.
107− // Writing secure first makes the worst case a harmless duplicate (both
108− // stores hold it; the migration is idempotent) rather than data loss.
109− secure.edit().apply {
110− for (key in fallbackKeys) {
111− val pw = fallbackPrefs.getString(key, null) ?: continue
112− putString(key, pw)
113− }
114− }.apply()
115− fallbackPrefs.edit().apply {
116− for (key in fallbackKeys) remove(key)
117− }.apply()
118− }
119− }
120− // Only mark done once the writes are in the secure store's in-memory cache (apply()
121− // updates it synchronously), so the next collector past the mutex reads real values.
122− migrationDone = true
123− }
124− }
125−
126− /** Stored shape on DataStore (everything except the secret password). */
127− @Serializable
128− private data class StoredProfile(
129− val id: String,
130− val label: String,
131− val baseUrl: String,
132− val username: String? = null,
133− val lastUsed: Long = 0,
134− // Non-secret TLS options; default to prior behavior for profiles saved before these
135− // fields existed (deserialization uses these defaults for missing keys).
136− val requireHttps: Boolean = false,
137− val certPin: String? = null,
138− )
139−
140− open val profiles: Flow<List<ServerProfile>> = appContext?.dataStore?.data?.map { prefs ->
141− // Migrate any orphaned plaintext passwords to secure prefs on first load.
142− migrateFallbackPasswords()
143− val json = prefs[profilesKey] ?: return@map emptyList()
144− val stored = runCatching {
145− OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), json)
146− }.onFailure { Log.w("ProfileStore", "Failed to decode stored profiles, ignoring", it) }
147− .getOrDefault(emptyList())
148− .sortedByDescending { it.lastUsed }
149− // Batch all password lookups into a single IO dispatch instead of one
150− // withContext round-trip per profile (N dispatches → 1). DataStore already
151− // runs this map block on IO, but each passwordFor() call still pays the
152− // suspend + redispatch overhead.
153− val pwPrefs = prefsForPasswords()
154− stored.map { it.toProfile(pwPrefs) }
155− }
156− // The .map above runs in the *collector's* context, and every consumer collects on
157− // viewModelScope (Main). Without this, kotlinx JSON decode + EncryptedSharedPreferences
158− // AES decryption would run on the UI thread on each emission. flowOn moves that work to IO.
159− ?.flowOn(Dispatchers.IO)
160− ?: kotlinx.coroutines.flow.flowOf(emptyList())
161−
162− private fun StoredProfile.toProfile(pwPrefs: SharedPreferences): ServerProfile {
163− val pw = username?.let { pwPrefs.getString(passwordKey(id), null) }
164− return ServerProfile(
165− id = id,
166− label = label,
167− baseUrl = baseUrl,
168− username = username,
169− password = pw,
170− lastUsed = lastUsed,
171− requireHttps = requireHttps,
172− certPin = certPin,
173− )
174− }
175−
176− open suspend fun resolve(profile: ServerProfile): ServerProfile {
177− val pw = if (profile.hasAuth) {
178− withContext(Dispatchers.IO) { prefsForPasswords().getString(passwordKey(profile.id), null) }
179− } else {
180− null
181− }
182− return profile.copy(password = pw)
183− }
184−
185− open suspend fun save(profile: ServerProfile) {
186− // Write the password first so that if the DataStore write fails, we're left
187− // with an orphaned password (harmless, cleaned up on next save/delete) rather
188− // than a profile with no password (which would break authentication).
189− withContext(Dispatchers.IO) {
190− prefsForPasswords().edit().apply {
191− val pw = profile.password
192− if (!profile.username.isNullOrBlank() && !pw.isNullOrEmpty()) {
193− putString(passwordKey(profile.id), pw)
194− } else {
195− remove(passwordKey(profile.id))
196− }
197− }.apply()
198− }
199− appContext!!.dataStore.edit { prefs ->
200− val current = prefs[profilesKey]?.let {
201− runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
202− .getOrDefault(emptyList())
203− } ?: emptyList()
204− // If the incoming profile has lastUsed=0 (e.g. ServerEditViewModel.save()
205− // couldn't read the existing value due to a DataStore timeout), preserve
206− // the existing nonzero lastUsed so saving an edit doesn't reset the
207− // profile's sort position in the server list.
208− val preservedLastUsed = if (profile.lastUsed == 0L) {
209− current.firstOrNull { it.id == profile.id }?.lastUsed ?: 0L
210− } else {
211− profile.lastUsed
212− }
213− val stored = StoredProfile(
214− id = profile.id,
215− label = profile.label,
216− baseUrl = profile.baseUrl,
217− username = profile.username?.takeIf { it.isNotBlank() },
218− lastUsed = preservedLastUsed,
219− requireHttps = profile.requireHttps,
220− certPin = profile.certPin?.takeIf { it.isNotBlank() },
221− )
222− val updated = current.filterNot { it.id == profile.id } + stored
223− prefs[profilesKey] = OpencodeJson.encodeToString(ListSerializer(StoredProfile.serializer()), updated)
224− }
225− }
226−
227− open suspend fun delete(id: String) {
228− // Delete the profile from DataStore first, then the password. If the process
229− // dies between the two, an orphaned password remains (harmless, cleaned up on
230− // next save/delete) rather than a profile with no password.
231− appContext!!.dataStore.edit { prefs ->
232− val current = prefs[profilesKey]?.let {
233− runCatching { OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), it) }
234− .getOrDefault(emptyList())
235− } ?: emptyList()
236− prefs[profilesKey] = OpencodeJson.encodeToString(
237− ListSerializer(StoredProfile.serializer()),
238− current.filterNot { it.id == id },
239− )
240− }
241− // Clean up the password in both secure and fallback prefs so no orphaned
242− // plaintext password lingers if secure prefs were temporarily unavailable.
243− withContext(Dispatchers.IO) {
244− val key = passwordKey(id)
245− securePrefs?.edit()?.remove(key)?.apply()
246− fallbackPrefs.edit().remove(key).apply()
247− }
248− }
249−
250− private fun passwordKey(id: String) = "pw_$id"
251−}
app/src/main/java/soy/iko/opencode/data/repo/RecentSessionsStore.ktdeleted−64
@@ -1,64 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import kotlinx.coroutines.Dispatchers
5−import kotlinx.coroutines.sync.Mutex
6−import kotlinx.coroutines.sync.withLock
7−import kotlinx.coroutines.withContext
8−import kotlinx.serialization.Serializable
9−import kotlinx.serialization.decodeFromString
10−import kotlinx.serialization.encodeToString
11−import kotlinx.serialization.json.Json
12−import java.io.File
13−
14−/** A minimal session summary persisted for the home-screen widget and launcher shortcuts. */
15−@Serializable
16−data class RecentSession(val id: String, val title: String)
17−
18−/**
19− * Persists a short list of recent sessions to a small JSON file under filesDir so app-external
20− * surfaces (the home-screen widget's [android.widget.RemoteViewsService] and the "Resume last"
21− * launcher shortcut) can read them without a live connection or in-memory state. Both the app
22− * and the widget's RemoteViews factory run in the same process, so a plain file is sufficient.
23− */
24−object RecentSessionsStore {
25−
26− private const val FILE = "recent_sessions.json"
27− const val MAX = 8
28−
29− private val json = Json { ignoreUnknownKeys = true }
30−
31− /** Serializes concurrent [write]s. The synchronous [read] (called from the widget's
32− * RemoteViewsService) can't take a suspend lock, so [write] instead swaps the file in
33− * atomically via a temp-file rename — the reader always sees either the old or the new
34− * complete file, never a half-written one. */
35− private val writeMutex = Mutex()
36−
37− private fun file(context: Context) = File(context.applicationContext.filesDir, FILE)
38−
39− /** Synchronous read — safe to call from the widget factory (bounded, tiny file). */
40− fun read(context: Context): List<RecentSession> {
41− val f = file(context).takeIf { it.exists() } ?: return emptyList()
42− return runCatching { json.decodeFromString<List<RecentSession>>(f.readText()) }
43− .getOrDefault(emptyList())
44− }
45−
46− /** Persist [sessions] (capped to [MAX]); a no-op empty list writes an empty file. */
47− suspend fun write(context: Context, sessions: List<RecentSession>) = withContext(Dispatchers.IO) {
48− writeMutex.withLock {
49− runCatching {
50− val target = file(context)
51− val encoded = json.encodeToString(sessions.take(MAX))
52− val tmp = File(target.parentFile, "$FILE.tmp")
53− tmp.writeText(encoded)
54− // Atomic replace on POSIX filesystems (app's internal storage); the reader
55− // never observes a torn file. Fall back to a direct write if rename fails.
56− if (!tmp.renameTo(target)) {
57− target.writeText(encoded)
58− tmp.delete()
59− }
60− }
61− Unit
62− }
63− }
64−}
app/src/main/java/soy/iko/opencode/data/repo/SessionPrefsStore.ktdeleted−64
@@ -1,64 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import androidx.datastore.preferences.core.edit
5−import androidx.datastore.preferences.core.stringSetPreferencesKey
6−import androidx.datastore.preferences.preferencesDataStore
7−import kotlinx.coroutines.flow.Flow
8−import kotlinx.coroutines.flow.flowOf
9−import kotlinx.coroutines.flow.map
10−
11−private val Context.sessionPrefsDataStore by preferencesDataStore(name = "session_prefs")
12−
13−/**
14− * Local, per-session organization flags the opencode server doesn't track: **pinned**
15− * (sorted to the top of the session list) and **archived** (hidden from the list unless the
16− * user opts to show them). Keyed by opencode session id.
17− *
18− * `open` with a `protected` no-arg constructor so tests can subclass and override the flows
19− * without a real DataStore (mirrors the other stores). The flow accessors are getters (not
20− * eager initializers) and null-guard the context so the test constructor never touches disk.
21− */
22−open class SessionPrefsStore private constructor(
23− private val appContext: Context?,
24− @Suppress("unused") private val testMode: Boolean,
25−) {
26− constructor(context: Context) : this(context.applicationContext, false)
27− protected constructor() : this(null, true)
28−
29− private val pinnedKey = stringSetPreferencesKey("pinned")
30− private val archivedKey = stringSetPreferencesKey("archived")
31−
32− open val pinned: Flow<Set<String>>
33− get() = appContext?.sessionPrefsDataStore?.data?.map { it[pinnedKey] ?: emptySet() }
34− ?: flowOf(emptySet())
35−
36− open val archived: Flow<Set<String>>
37− get() = appContext?.sessionPrefsDataStore?.data?.map { it[archivedKey] ?: emptySet() }
38− ?: flowOf(emptySet())
39−
40− open suspend fun setPinned(sessionId: String, pinned: Boolean) = toggle(pinnedKey, sessionId, pinned)
41−
42− open suspend fun setArchived(sessionId: String, archived: Boolean) = toggle(archivedKey, sessionId, archived)
43−
44− /** Drop a session from both sets — call on deletion so ids don't accumulate forever. */
45− open suspend fun forget(sessionId: String) {
46− val ctx = appContext ?: return
47− ctx.sessionPrefsDataStore.edit { prefs ->
48− prefs[pinnedKey] = (prefs[pinnedKey] ?: emptySet()) - sessionId
49− prefs[archivedKey] = (prefs[archivedKey] ?: emptySet()) - sessionId
50− }
51− }
52−
53− private suspend fun toggle(
54− key: androidx.datastore.preferences.core.Preferences.Key<Set<String>>,
55− sessionId: String,
56− on: Boolean,
57− ) {
58− val ctx = appContext ?: return
59− ctx.sessionPrefsDataStore.edit { prefs ->
60− val current = prefs[key] ?: emptySet()
61− prefs[key] = if (on) current + sessionId else current - sessionId
62− }
63− }
64−}
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.ktdeleted−647
@@ -1,647 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import soy.iko.opencode.data.model.AssistantMessage
4−import soy.iko.opencode.data.model.BusEvent
5−import soy.iko.opencode.data.model.FilePromptPart
6−import soy.iko.opencode.data.model.MessageInfo
7−import soy.iko.opencode.data.model.MessagePartRemoved
8−import soy.iko.opencode.data.model.MessagePartUpdated
9−import soy.iko.opencode.data.model.MessageRemoved
10−import soy.iko.opencode.data.model.MessageUpdated
11−import soy.iko.opencode.data.model.MessageWithParts
12−import soy.iko.opencode.data.model.ModelRef
13−import soy.iko.opencode.data.model.Part
14−import soy.iko.opencode.data.model.SessionError
15−import soy.iko.opencode.data.model.SessionIdle
16−import soy.iko.opencode.data.model.UnknownMessage
17−import soy.iko.opencode.data.network.EventStreamClient
18−import soy.iko.opencode.data.network.NetworkConfig
19−import soy.iko.opencode.data.network.OpencodeApiClient
20−import soy.iko.opencode.util.runCatchingCancellable
21−import soy.iko.opencode.util.safeExceptionSummary
22−import android.util.Log
23−import kotlinx.coroutines.CoroutineScope
24−import kotlinx.coroutines.Dispatchers
25−import kotlinx.coroutines.Job
26−import kotlinx.coroutines.NonCancellable
27−import kotlinx.coroutines.delay
28−import kotlinx.coroutines.withContext
29−import kotlinx.coroutines.channels.Channel
30−import kotlinx.coroutines.flow.Flow
31−import kotlinx.coroutines.flow.channelFlow
32−import kotlinx.coroutines.flow.consumeAsFlow
33−import kotlinx.coroutines.flow.conflate
34−import kotlinx.coroutines.flow.flowOn
35−import kotlinx.coroutines.launch
36−import kotlinx.coroutines.sync.Mutex
37−import kotlinx.coroutines.sync.withLock
38−
39−/**
40− * Source of truth for a session's conversation. It seeds from `GET /session/:id/message`
41− * and then reduces [BusEvent]s from the SSE stream into an ordered list of messages,
42− * each keyed by message id with parts keyed by part id (idempotent upserts).
43− */
44−open class SessionRepository(
45− private val api: OpencodeApiClient,
46− private val eventStream: EventStreamClient,
47− // Optional on-disk cache so a conversation renders instantly on open and stays readable
48− // offline. Null in tests (and any path that doesn't wire it), where caching is skipped.
49− private val messageCache: MessageCacheStore? = null,
50− // Profile the cache is namespaced under, so two servers with a colliding session id don't
51− // share a cache file. Empty in tests / when no cache is wired.
52− private val profileId: String = "",
53−) {
54− open suspend fun listSessions() = api.listSessions()
55− open suspend fun createSession(title: String? = null, directory: String? = null) =
56− api.createSession(title, directory)
57− open suspend fun deleteSession(id: String) = api.deleteSession(id)
58− open suspend fun abort(sessionId: String) = api.abort(sessionId)
59−
60− open suspend fun sendPrompt(
61− sessionId: String,
62− text: String,
63− attachments: List<FilePromptPart> = emptyList(),
64− model: ModelRef?,
65− agent: String? = null,
66− // Stable key for retriable/re-sendable messages (the offline outbox). See
67− // [OpencodeApiClient.sendPrompt]. Null generates a fresh key per call.
68− idempotencyKey: String? = null,
69− ) = api.sendPrompt(sessionId, text, attachments, model, agent, idempotencyKey)
70−
71− open suspend fun runCommand(
72− sessionId: String,
73− command: String,
74− arguments: String = "",
75− agent: String? = null,
76− ) = api.runCommand(sessionId, command, arguments = arguments, agent = agent)
77−
78− /**
79− * A live, ordered view of [sessionId]'s messages. Begins collecting the event
80− * stream before the initial REST load so no streamed part is missed, then reduces
81− * events into the in-memory state. The flow is conflated so a fast burst of
82− * streaming tokens (each publishing a fresh snapshot) never back-pressures the
83− * event reducer — a slow collector simply sees the most recent snapshot.
84− */
85− /** Per-subscription helper that persists reduced snapshots to [messageCache], throttled so a
86− * fast token stream doesn't hammer the disk, plus a NonCancellable teardown flush. Extracted
87− * from [observeMessages] so this cache-write branching lives in its own methods. [scope] is
88− * the channelFlow scope, so scheduled saves are torn down with the flow — the teardown flush
89− * re-persists a trailing write the cancellation dropped. */
90− private inner class CacheWriter(private val scope: CoroutineScope, private val sessionId: String) {
91− private var lastWrite = 0L
92− private var pending: Job? = null
93− // Most recent snapshot handed to [schedule], and the most recent one whose on-disk write
94− // completed. `saved` is assigned from inside the launched save *after* it returns, so a
95− // save cancelled mid-IO by teardown is NOT marked saved and [flushOnTeardown] re-persists
96− // it. Written from the save coroutines, read from the collector/teardown; the only race is
97− // a stale read causing one redundant, idempotent re-save (atomic temp+rename makes a
98− // duplicate write of identical data harmless) — never data loss.
99− private var latest: List<MessageWithParts>? = null
100− private var saved: List<MessageWithParts>? = null
101−
102− fun schedule(snapshot: List<MessageWithParts>) {
103− val cache = messageCache ?: return
104− if (snapshot.isEmpty()) return
105− latest = snapshot
106− val now = System.currentTimeMillis()
107− pending?.cancel()
108− // Immediate write once the throttle window has elapsed; otherwise a trailing-edge
109− // write so a burst that ends inside the window (a run's final tokens right after a
110− // write) still persists its last snapshot. A newer snapshot cancels and replaces a
111− // pending trailing write, so continuous streaming coalesces to one write per window.
112− val delayMs = if (now - lastWrite >= NetworkConfig.messageCacheWriteThrottleMs) {
113− lastWrite = now
114− 0L
115− } else {
116− NetworkConfig.messageCacheWriteThrottleMs
117− }
118− pending = scope.launch {
119− if (delayMs > 0) delay(delayMs)
120− cache.save(profileId, sessionId, snapshot)
121− saved = snapshot
122− }
123− }
124−
125− suspend fun flushOnTeardown() {
126− // Scheduled saves are children of [scope], so teardown cancels a pending trailing
127− // write before its delay elapses — a run's final tokens would never reach disk. Flush
128− // the latest un-saved snapshot under NonCancellable so it survives the cancellation.
129− val cache = messageCache ?: return
130− val pendingSnapshot = latest ?: return
131− if (pendingSnapshot !== saved) {
132− withContext(NonCancellable) { cache.save(profileId, sessionId, pendingSnapshot) }
133− }
134− }
135− }
136−
137− open fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> = channelFlow {
138− val store = MessageStore()
139− val lock = Mutex()
140− // Conflated dirty signal: a burst of reduce()/seed() calls coalesces into a
141− // single snapshot+send per demand cycle. Previously every changed event called
142− // snapshot() — allocating an O(N) copy of the whole message list — and ~97% of
143− // those copies were immediately discarded by the outer .conflate() during fast
144− // streaming (hundreds of tokens/sec). Marking dirty and snapshotting in one
145− // drain loop means N events in a burst allocate ~1 snapshot, not N.
146− val dirty = Channel<Unit>(Channel.CONFLATED)
147−
148− // Mark the store dirty; the drain loop below snapshots and publishes. CONFLATED
149− // collapses repeated marks into a single pending signal, so a tight burst of
150− // events doesn't even allocate N marks.
151− fun publish() { dirty.trySend(Unit) }
152−
153− // Subscribe to events first so we don't miss early deltas during the initial load.
154− launch {
155− eventStream.events.collect { event ->
156− if (lock.withLock { store.reduce(sessionId, event) }) publish()
157− }
158− }
159−
160− // Seed generation counter, shared between the initial load and the reconnect
161− // re-seed. Guards against a stale fetch (from an earlier reconnect, or from a slow
162− // initial load) clobbering a newer seed. Only mutated/read under [lock].
163− var seedGeneration = 0
164−
165− // Message ids introduced by the on-disk cache seed below. The initial REST seed uses
166− // these to prune ghosts: a cache-seeded message absent from the authoritative REST
167− // snapshot was deleted server-side while the app was closed. Restricting the prune to
168− // cache-seeded ids means live SSE messages added since subscribe (not yet indexed by
169− // REST) are never dropped. Written/read only in this collector coroutine.
170− var cacheSeededIds: Set<String> = emptySet()
171−
172− // Seed from the on-disk cache first so the conversation renders instantly — and stays
173− // readable offline — before the network responds. Guarded on generation 0 so a
174− // reconnect re-seed that somehow raced ahead isn't clobbered; the initial REST seed
175− // (also generation 0) then overrides this with authoritative data when it succeeds.
176− messageCache?.let { cache ->
177− val cached = cache.load(profileId, sessionId)
178− if (cached.isNotEmpty()) {
179− val changed = lock.withLock {
180− if (seedGeneration == 0) {
181− cacheSeededIds = cached.mapTo(mutableSetOf()) { it.info.id }
182− store.seed(cached, clearPivot = false)
183− } else false
184− }
185− if (changed) publish()
186− }
187− }
188−
189− // Re-seed from REST when the SSE stream reconnects after a drop. The REST fetch
190− // is performed *outside* the lock so events arriving via SSE during the fetch are
191− // not blocked. The seed itself is under the lock; a seed generation counter ensures
192− // a stale fetch (from an earlier reconnect) doesn't clobber a newer one.
193− launch {
194− // Re-seed from REST when the SSE stream reconnects after a drop. The
195− // hasConnectedBefore flag is NOT reset on disconnect — it stays true after
196− // the first successful connection so a subsequent reconnect triggers the
197− // re-seed. Resetting it on disconnect would make the condition always false
198− // after the first cycle, turning the re-seed into dead code.
199− //
200− // A seed generation counter guards against a stale re-seed clobbering a
201− // newer one: if two reconnects fire in quick succession, the older fetch
202− // (which returns later) is discarded because its generation no longer matches.
203− var hasConnectedBefore = false
204− eventStream.state.collect { state ->
205− if (state == EventStreamClient.ConnectionState.Connected) {
206− if (hasConnectedBefore) {
207− // Pivot before the fetch: parts already in memory predate this
208− // snapshot and must yield to it, while parts that stream in during
209− // the fetch (added to streamedSincePivot via reduce) outrank it.
210− // Bump the generation under the same lock so the compare in the
211− // initial seed sees a consistent value.
212− val generation = lock.withLock {
213− store.beginReseed()
214− ++seedGeneration
215− }
216− val fresh = runCatchingCancellable { api.listMessages(sessionId) }
217− .onFailure { Log.w("SessionRepository", "Re-seed message load failed for $sessionId; relying on SSE: ${safeExceptionSummary(it)}") }
218− .getOrDefault(emptyList())
219− var seedChanged = false
220− lock.withLock {
221− if (generation == seedGeneration) {
222− // Merge without pruning: SSE events arriving during the
223− // REST fetch may have added messages not yet indexed by
224− // the REST endpoint. Pruning would silently delete them.
225− // Stale messages from deletions during the disconnect
226− // gap are eventually removed via MessageRemoved events
227− // or on the next app restart.
228− seedChanged = store.seed(fresh, prune = false)
229− }
230− }
231− if (seedChanged) publish()
232− }
233− hasConnectedBefore = true
234− }
235− }
236− }
237−
238− // Load the authoritative message list concurrently with the drain loop below so the
239− // cache seed and any early SSE-reduced parts are emitted immediately. Running it inline
240− // here (before the drain starts) would withhold the already-reduced cached conversation
241− // until this fetch resolved — a blank screen for the whole withRetry backoff when offline.
242− launch {
243− val initialResult = runCatchingCancellable { api.listMessages(sessionId) }
244− .onFailure { Log.w("SessionRepository", "Initial message load failed for $sessionId; relying on cache/SSE: ${safeExceptionSummary(it)}") }
245− // Apply the initial seed only if no reconnect re-seed has run yet (generation still 0).
246− // A re-seed is always a fresher full snapshot, so if the (possibly slow) initial fetch
247− // returns after one has applied, seeding this older REST data would clobber the newer
248− // state — reverting a completed run to "running", regressing cost/token/part state —
249− // until the next SSE event or reconnect healed it. Only seed on SUCCESS: on failure
250− // (e.g. offline) keep whatever's shown (the cache seed above) rather than clearing it.
251− val seeded = lock.withLock {
252− val list = initialResult.getOrNull()
253− if (seedGeneration == 0 && list != null) {
254− // Prune ghosts first: cache-seeded messages absent from this authoritative
255− // REST snapshot were deleted server-side while the app was closed. Without
256− // this they'd survive restart forever (re-seeded from cache, never pruned by
257− // the reconnect path, and re-persisted by the drain loop).
258− val pruned = store.pruneStaleCacheSeeded(cacheSeededIds, list)
259− val changed = store.seed(list)
260− pruned || changed
261− } else false
262− }
263− if (seeded) publish()
264− }
265−
266− // Single drain: one snapshot+send per conflated dirty signal. The snapshot is
267− // taken under the lock so a reader never sees a half-reduced state; send() runs
268− // outside the lock so a slow downstream collector can't stall the drain. This
269− // loop also keeps the flow alive until the collector cancels; the launched jobs
270− // above are children of this scope and are torn down with it.
271− // Per-subscription cache writer: throttled snapshot persistence + a NonCancellable
272− // teardown flush. `this` is the channelFlow scope, so its scheduled saves are torn down
273− // with the flow (the teardown flush re-persists a cancelled trailing write).
274− val cacheWriter = CacheWriter(this, sessionId)
275− // Guarantee at least one emission even when nothing seeds the store: with no on-disk
276− // cache, a failing initial REST load (swallowed above), and a quiet SSE stream, none of
277− // the seed paths publish, so the flow would emit nothing at all. The sole collector
278− // (ChatViewModel) only clears its loading spinner on an emission, so without this a
279− // first-open of an erroring/empty session spins forever with no way to reach the
280− // "Failed to load / Retry" affordance. CONFLATED dirty coalesces this with any seed
281− // publish into a single send.
282− publish()
283− try {
284− dirty.consumeAsFlow().collect {
285− val snapshot = lock.withLock { store.snapshot() }
286− send(snapshot)
287− // Persist to the on-disk cache, throttled so a fast token stream doesn't hammer
288− // the disk — the cache only needs to be "recent enough" for an instant/offline
289− // first paint; the network corrects it on the next open.
290− cacheWriter.schedule(snapshot)
291− }
292− } finally {
293− cacheWriter.flushOnTeardown()
294− }
295− // Run the whole reducer pipeline — event reduction, list rebuilds, and the O(N)
296− // snapshot copy — on Dispatchers.Default rather than the collector's context. The
297− // sole consumer collects via stateIn(viewModelScope), i.e. Main.immediate, so without
298− // this every streamed token's reduce()/snapshot() would execute on the UI thread
299− // (hundreds/sec on a fast run). The seed REST calls here only suspend, so moving them
300− // off Main is harmless. JSON decoding already runs off-Main upstream in EventStreamClient.
301− }.flowOn(Dispatchers.Default).conflate()
302−
303− companion object {
304− /** Convenience: is this event a run-completion signal for [sessionId]?
305− * A null sessionID is NOT treated as a wildcard — an idle event with no
306− * session id must not reset the running state of every open chat. */
307− fun isIdle(event: BusEvent, sessionId: String): Boolean =
308− event is SessionIdle && event.properties.sessionID == sessionId
309−
310− fun isError(event: BusEvent, sessionId: String): Boolean =
311− event is SessionError && event.properties.sessionID == sessionId
312−
313− /** Does this event indicate an in-progress run for [sessionId]? A live streamed part or
314− * an assistant message that isn't yet complete/errored means the agent is still working.
315− * Used to restore the "running" indicator after an SSE reconnect: if the run is still
316− * going, such live events keep arriving; a run that finished during the outage produces
317− * none (its completion arrives via the REST re-seed, not the event stream). */
318− fun isRunActivity(event: BusEvent, sessionId: String): Boolean = when (event) {
319− // The session id rides on the part for message.part.updated; properties.sessionID
320− // is null on the wire. Mirror reduce()'s `part.sessionID ?: properties.sessionID`
321− // so a run that's only streaming parts still re-lights the indicator on reconnect.
322− is MessagePartUpdated -> (event.properties.part.sessionID ?: event.properties.sessionID) == sessionId
323− is MessageUpdated -> {
324− val info = event.properties.info
325− info.sessionID == sessionId && info is AssistantMessage && !info.isComplete && info.error == null
326− }
327− else -> false
328− }
329− }
330−}
331−
332−/**
333− * In-memory reduction state for one observed session. Not thread-safe; guard with a Mutex.
334− * Exposed as `internal` so the reducer logic can be unit-tested directly.
335− */
336−internal class MessageStore {
337− // messageId -> mutable holder (info + insertion-ordered parts); the map itself is
338− // insertion-ordered.
339− private val messages = LinkedHashMap<String, Holder>()
340−
341− /**
342− * Mutable per-message holder. Parts live in an insertion-ordered map keyed by part id, so a
343− * streamed part update is an O(1) put instead of an O(P) list rebuild. The immutable
344− * [MessageWithParts] view is materialized lazily and cached until the next mutation — so a
345− * burst of streamed token updates rebuilds a message's part list at most once per published
346− * [snapshot], not once per token. The cached view is also reference-stable across snapshots
347− * while unchanged, so Compose skips recomposing bubbles that didn't change.
348− */
349− private class Holder(var info: MessageInfo, val parts: LinkedHashMap<String, Part>) {
350− private var cached: MessageWithParts? = null
351− fun view(): MessageWithParts =
352− cached ?: MessageWithParts(info, parts.values.toList()).also { cached = it }
353− /** Adopt an existing immutable instance as the cache — avoids an immediate rebuild and
354− * preserves its identity (e.g. when seeding an authoritative REST snapshot). */
355− fun adopt(m: MessageWithParts) { cached = m }
356− fun invalidate() { cached = null }
357− }
358−
359− /** Build a [Holder] from an immutable message, reusing [m] itself as the cached view. */
360− private fun holderOf(m: MessageWithParts): Holder {
361− val parts = LinkedHashMap<String, Part>(m.parts.size)
362− for (p in m.parts) parts[p.id] = p
363− return Holder(m.info, parts).also { it.adopt(m) }
364− }
365−
366− /** Part ids upserted via [reduce] since the last reseed pivot ([beginReseed]) or seed.
367− * On a reconnect re-seed these are the parts that streamed in *during* the REST fetch,
368− * so they're newer than the snapshot and must win over it; every other part is taken
369− * from the authoritative REST snapshot. Without this distinction the merge kept every
370− * overlapping in-memory part, so a part that changed server-side during the disconnect
371− * (e.g. a tool going running -> completed) stayed stale until app restart. */
372− private val streamedSincePivot = mutableSetOf<String>()
373−
374− /** Message ids whose `info` was updated via a live SSE [reduce] since the last reseed
375− * pivot ([beginReseed]) or seed. Parallels [streamedSincePivot] but for message info:
376− * on a reconnect re-seed these are the messages whose info changed *during* the REST
377− * fetch, so the in-memory info is newer than the snapshot and must win; every other
378− * message's info is taken from the authoritative REST snapshot. Without this the merge
379− * kept the in-memory info, so a run that finished server-side during the disconnect
380− * (e.g. cost/token totals, completion time) stayed stale until app restart. */
381− private val messageInfoUpdatedSincePivot = mutableSetOf<String>()
382−
383− /** Mark the point a reconnect re-seed's REST fetch begins: parts/info streamed from here
384− * on are newer than the fetched snapshot. Call under the same lock as [seed]/[reduce]. */
385− fun beginReseed() {
386− streamedSincePivot.clear()
387− messageInfoUpdatedSincePivot.clear()
388− }
389−
390− /** Maximum number of messages to keep in memory; oldest are evicted when exceeded. */
391− internal val maxMessages = NetworkConfig.maxInMemoryMessages
392−
393− fun snapshot(): List<MessageWithParts> = messages.values.map { it.view() }
394−
395− fun seed(initial: List<MessageWithParts>, prune: Boolean = false, clearPivot: Boolean = true): Boolean {
396− var changed = false
397− // On re-seed (after SSE reconnect), remove messages that are no longer in the
398− // server snapshot (e.g. deleted during the disconnection gap). This keeps the
399− // in-memory state in sync with the authoritative REST snapshot rather than
400− // accumulating stale messages forever. Skipped on the initial seed to avoid
401− // racing with just-arrived SSE events whose messages may not yet be in REST.
402− if (prune) {
403− val incomingIds = initial.mapTo(mutableSetOf()) { it.info.id }
404− val before = messages.size
405− messages.keys.retainAll(incomingIds)
406− if (messages.size != before) changed = true
407− }
408−
409− for (m in initial) {
410− val existing = messages[m.info.id]
411− if (existing == null) {
412− messages[m.info.id] = holderOf(m)
413− changed = true
414− } else {
415− // A part streamed in between subscribe and this initial load may already
416− // have populated this message (see observeMessages). Merge instead of
417− // overwriting so that newer streamed part isn't discarded: take the
418− // snapshot's part order, swap in the streamed version where ids overlap,
419− // append any streamed-only parts, and adopt the authoritative REST info.
420− // existing.parts is already keyed by part id (insertion-ordered).
421− val streamedById = existing.parts
422− val snapshotIds = m.parts.mapTo(mutableSetOf()) { it.id }
423− // Prefer the in-memory version of an overlapping part ONLY if it streamed
424− // in since the reseed pivot (i.e. during this fetch), so it's newer than
425− // the snapshot. Otherwise the REST snapshot is authoritative and wins,
426− // discarding an in-memory part that went stale during a disconnect.
427− val ordered = m.parts.map { p ->
428− if (p.id in streamedSincePivot) streamedById[p.id] ?: p else p
429− }.toMutableList()
430− // Append only parts that streamed in *since the pivot* and are absent from the
431− // snapshot — i.e. genuinely newer than this REST fetch. A part in memory from
432− // before the pivot that the snapshot omits was removed server-side (its
433− // MessagePartRemoved missed during a disconnect gap, or it was deleted while the
434− // app was closed and only survives from the cache seed); the authoritative REST
435− // snapshot must be allowed to drop it, else it reappears as a ghost — and, once
436− // the drain loop persists this merge, gets baked into the on-disk cache forever
437− // (the message still exists, so pruneStaleCacheSeeded never removes it). This
438− // mirrors the overlapping-part branch above and the info branch below, which both
439− // already gate on the pivot.
440− for (p in existing.parts.values) {
441− if (p.id !in snapshotIds && p.id in streamedSincePivot) ordered.add(p)
442− }
443− // Adopt the authoritative REST info unless the in-memory info was itself
444− // updated live since the pivot (i.e. during this fetch), in which case it's
445− // newer than the snapshot. Otherwise the REST snapshot wins, discarding
446− // in-memory info that went stale during a disconnect.
447− val info = if (existing.info is UnknownMessage || m.info.id !in messageInfoUpdatedSincePivot) m.info else existing.info
448− val merged = MessageWithParts(info = info, parts = ordered)
449− if (merged != existing.view()) {
450− messages[m.info.id] = holderOf(merged)
451− changed = true
452− }
453− }
454− }
455− // A part that streamed in *before* this initial load (observeMessages subscribes to
456− // SSE before fetching) inserts its message at the front of the insertion-ordered map,
457− // and the merge above keeps that position — pinning a running message above the older
458− // history that seed() inserts afterward. Restore chronological order by creation time
459− // FIRST, so eviction below drops the genuinely-oldest entries: evictOldMessages()
460− // removes from the map's front, which before reordering is the newest streamed message.
461− if (reorderByTime()) changed = true
462− if (evictOldMessages()) changed = true
463− // Reset the pivot: everything just merged is now the baseline, so only parts/info
464− // that stream in *after* this seed can outrank the next reconnect's snapshot. The
465− // on-disk cache seed passes clearPivot = false: it runs *before* the authoritative
466− // initial REST seed, so clearing here would strip pivot protection from SSE parts/info
467− // that streamed in since subscribe, letting that REST seed regress them.
468− if (clearPivot) {
469− streamedSincePivot.clear()
470− messageInfoUpdatedSincePivot.clear()
471− }
472− return changed
473− }
474−
475− /** Remove messages that were seeded from the on-disk cache ([cacheSeededIds]) but are
476− * absent from the authoritative REST snapshot [rest] — i.e. deleted server-side while the
477− * app was closed. Restricted to cache-seeded ids so a message added by a live SSE event
478− * since subscribe (which the REST endpoint may not have indexed yet) is never pruned.
479− * This is what actually removes ghosts on restart; the reconnect re-seed deliberately
480− * keeps prune=false to avoid racing just-arrived SSE. Call under the same lock as [seed].
481− * Returns true if anything was removed. */
482− fun pruneStaleCacheSeeded(cacheSeededIds: Set<String>, rest: List<MessageWithParts>): Boolean {
483− if (cacheSeededIds.isEmpty()) return false
484− val restIds = rest.mapTo(mutableSetOf()) { it.info.id }
485− var changed = false
486− for (id in cacheSeededIds) {
487− if (id !in restIds && messages.remove(id) != null) changed = true
488− }
489− return changed
490− }
491−
492− /** Reorder the message map by message creation time (stable). Ties and entries with no
493− * server time yet (e.g. a brand-new message still streaming and not in REST) keep their
494− * relative order, so such a message stays last. Preserves each entry's map key so the
495− * synthetic UnknownMessage keys aren't collapsed. Returns true if the order changed. */
496− private fun reorderByTime(): Boolean {
497− val entries = messages.entries.map { it.key to it.value }
498− val sorted = entries.sortedBy { it.second.info.time?.created ?: Long.MAX_VALUE }
499− // Detect a reordering by comparing keys positionally, instead of allocating two more
500− // N-sized key lists just to compare them.
501− var changed = false
502− for (i in sorted.indices) {
503− if (sorted[i].first != entries[i].first) { changed = true; break }
504− }
505− if (!changed) return false
506− messages.clear()
507− for ((k, v) in sorted) messages[k] = v
508− return true
509− }
510−
511− /** Returns true if the state changed (and a new snapshot should be published). */
512− fun reduce(sessionId: String, event: BusEvent): Boolean {
513− return when (event) {
514− is MessageUpdated -> handleMessageUpdated(sessionId, event)
515−
516− is MessagePartUpdated -> {
517− val part = event.properties.part
518− val messageId = part.messageID ?: event.properties.messageID
519− val partSession = part.sessionID ?: event.properties.sessionID
520− when {
521− messageId == null -> false
522− partSession != null -> if (partSession == sessionId) upsertPart(messageId, part) else false
523− // No session id anywhere on the event: a null session is NOT a wildcard
524− // (mirroring isIdle/isError). Every open session's store sees the shared
525− // event stream, so accepting it unconditionally would leak the part into
526− // unrelated conversations (e.g. both panes in two-pane mode). Attribute it
527− // only if this store already holds the parent message — MessageUpdated,
528− // which always carries a session id, will have created it here first.
529− else -> if (messages.containsKey(messageId)) upsertPart(messageId, part) else false
530− }
531− }
532−
533− is MessagePartRemoved -> handlePartRemoved(sessionId, event)
534−
535− is MessageRemoved -> handleMessageRemoved(sessionId, event)
536−
537− else -> false
538− }
539− }
540−
541− private fun handleMessageUpdated(sessionId: String, event: MessageUpdated): Boolean {
542− val info = event.properties.info
543− if (info.sessionID != sessionId) return false
544− // An UnknownMessage with an empty id (e.g. from an unrecognized server role with no id
545− // field) would collide with other such messages in the map. It carries no id, only
546− // sessionID/time, so derive a synthetic key. Prefer the immutable creation timestamp: it's
547− // stable across updates, so a later update to the SAME unknown message (whose `time.updated`
548− // or `time.completed` changes) coalesces onto its holder via the existing==info guard below
549− // instead of forking a duplicate — the failure mode a full-content hash has, since any
550− // content change moves the hash. Distinct unknown messages still separate by their distinct
551− // creation times; only when even `created` is absent do we fall back to the content hash.
552− val key = if (info.id.isEmpty() && info is UnknownMessage) {
553− info.time?.created?.let { "unknown-c$it" } ?: "unknown-h${info.hashCode()}"
554− } else {
555− info.id
556− }
557− val existing = messages[key]
558− // A server re-sending byte-identical info is a no-op: skip it so we don't publish a
559− // redundant snapshot (mirrors upsertPart's identical-part guard).
560− if (existing != null && existing.info == info) return false
561− if (existing == null) {
562− messages[key] = Holder(info, LinkedHashMap())
563− } else {
564− existing.info = info
565− existing.invalidate()
566− }
567− // Record that this message's info streamed in live, so a re-seed after a reconnect knows
568− // it's newer than the REST snapshot and keeps it (see seed()/beginReseed()).
569− messageInfoUpdatedSincePivot.add(key)
570− if (existing == null) evictOldMessages()
571− return true
572− }
573−
574− /** Returns false if [eventSession] is non-null and doesn't match [sessionId]. */
575− private fun matchesSession(eventSession: String?, sessionId: String): Boolean =
576− eventSession == null || eventSession == sessionId
577−
578− private fun handlePartRemoved(sessionId: String, event: MessagePartRemoved): Boolean {
579− val messageId = event.properties.messageID ?: return false
580− val partId = event.properties.partID ?: return false
581− if (!matchesSession(event.properties.sessionID, sessionId)) return false
582− return removePart(messageId, partId)
583− }
584−
585− private fun handleMessageRemoved(sessionId: String, event: MessageRemoved): Boolean {
586− val id = event.properties.messageID ?: return false
587− if (!matchesSession(event.properties.sessionID, sessionId)) return false
588− val removed = messages.remove(id) ?: return false
589− messageInfoUpdatedSincePivot.remove(id)
590− removed.parts.keys.forEach { streamedSincePivot.remove(it) }
591− return true
592− }
593−
594− private fun upsertPart(messageId: String, part: Part): Boolean {
595− val current = messages[messageId]
596− if (current != null) {
597− // O(1) map lookup + put, vs the former O(P) indexOfFirst scan and full-list copy
598− // on every streamed token. A LinkedHashMap re-put keeps the part's original
599− // position (replace-in-place); a new id is appended — matching the old list order.
600− if (current.parts[part.id] == part) return false
601− current.parts[part.id] = part
602− current.invalidate()
603− } else {
604− val parts = LinkedHashMap<String, Part>()
605− parts[part.id] = part
606− messages[messageId] = Holder(
607− info = UnknownMessage(id = messageId, sessionID = part.sessionID ?: ""),
608− parts = parts,
609− )
610− }
611− // Record that this part streamed in, so a re-seed after a reconnect knows it's
612− // newer than the REST snapshot and keeps it (see seed()/beginReseed()).
613− streamedSincePivot.add(part.id)
614− if (current == null) evictOldMessages()
615− return true
616− }
617−
618− private fun removePart(messageId: String, partId: String): Boolean {
619− val current = messages[messageId] ?: return false
620− if (current.parts.remove(partId) == null) return false
621− // Drop the pivot entry too so the set doesn't accumulate ids of removed parts for
622− // the rest of the session (it's otherwise only cleared on reseed/eviction). No
623− // correctness impact today — a stale entry just falls back to the REST snapshot's
624− // version in seed() — but keeping it symmetric with handleMessageRemoved/eviction
625− // bounds the set's growth over a long-lived session.
626− streamedSincePivot.remove(partId)
627− current.invalidate()
628− return true
629− }
630−
631− /** Evict the oldest messages when the store exceeds [maxMessages], keeping memory bounded.
632− * Returns true if any messages were evicted. */
633− private fun evictOldMessages(): Boolean {
634− var evicted = false
635− while (messages.size > maxMessages) {
636− val oldestKey = messages.keys.iterator().next()
637− val removed = messages.remove(oldestKey)
638− // Prune the evicted message's pivot bookkeeping so these sets don't grow
639− // unbounded over a long-running session that never reconnects (they're only
640− // otherwise cleared on reseed/seed).
641− messageInfoUpdatedSincePivot.remove(oldestKey)
642− removed?.parts?.keys?.forEach { streamedSincePivot.remove(it) }
643− evicted = true
644− }
645− return evicted
646− }
647−}
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.ktdeleted−152
@@ -1,152 +0,0 @@
1−package soy.iko.opencode.data.repo
2−
3−import android.content.Context
4−import androidx.datastore.preferences.core.booleanPreferencesKey
5−import androidx.datastore.preferences.core.edit
6−import androidx.datastore.preferences.core.floatPreferencesKey
7−import androidx.datastore.preferences.core.intPreferencesKey
8−import androidx.datastore.preferences.core.stringPreferencesKey
9−import androidx.datastore.preferences.preferencesDataStore
10−import kotlinx.coroutines.flow.Flow
11−import kotlinx.coroutines.flow.map
12−
13−enum class ThemeMode { SYSTEM, LIGHT, DARK, AMOLED }
14−
15−private val Context.settingsDataStore by preferencesDataStore(name = "settings")
16−
17−/** App-preferences store: theme mode, dynamic color (Material You), input behavior. */
18−class SettingsStore(context: Context) {
19−
20− private val appContext = context.applicationContext
21− private val themeKey = stringPreferencesKey("theme_mode")
22− private val dynamicColorKey = booleanPreferencesKey("dynamic_color")
23− private val sendOnEnterKey = booleanPreferencesKey("send_on_enter")
24− private val appLockKey = booleanPreferencesKey("app_lock")
25− private val appLockReLockSecondsKey = intPreferencesKey("app_lock_relock_seconds")
26− private val chatTextScaleKey = floatPreferencesKey("chat_text_scale")
27− private val codeWrapKey = booleanPreferencesKey("code_wrap")
28− private val sessionSortModeKey = stringPreferencesKey("session_sort_mode")
29− private val sessionSortDescendingKey = booleanPreferencesKey("session_sort_descending")
30− private val sessionShowArchivedKey = booleanPreferencesKey("session_show_archived")
31−
32− companion object {
33− /** Bounds for the chat text-size multiplier so the UI can't be scaled into
34− * unreadability or an unusable micro-font. 1.0 is the design default. */
35− const val MIN_CHAT_TEXT_SCALE = 0.8f
36− const val MAX_CHAT_TEXT_SCALE = 1.6f
37− const val DEFAULT_CHAT_TEXT_SCALE = 1.0f
38− /** 0 means re-lock the instant the app backgrounds. Other entries are the grace
39− * period (seconds) during which a quick app-switch does NOT re-prompt for auth. */
40− val APP_LOCK_RELOCK_OPTIONS_SECONDS: List<Int> = listOf(0, 30, 60, 300, 1800, 3600)
41− const val DEFAULT_APP_LOCK_RELOCK_SECONDS = 0
42− }
43−
44− val themeMode: Flow<ThemeMode> = appContext.settingsDataStore.data.map { prefs ->
45− runCatching { ThemeMode.valueOf(prefs[themeKey] ?: ThemeMode.SYSTEM.name) }
46− .getOrDefault(ThemeMode.SYSTEM)
47− }
48−
49− // Default OFF so the app ships with its hand-built "Tokyo Night" brand palette rather than
50− // the wallpaper-derived Material You scheme, which fully replaces the brand colours. Users
51− // on Android 12+ can still opt into dynamic color from Settings.
52− val dynamicColor: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
53− prefs[dynamicColorKey] ?: false
54− }
55−
56− /** When true, the hardware Enter key sends the prompt; when false, Enter inserts a
57− * newline and Ctrl+Enter sends. Soft-keyboard IME action always sends. Defaults to
58− * true to match the prior hardcoded behavior. */
59− val sendOnEnter: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
60− prefs[sendOnEnterKey] ?: true
61− }
62−
63− /** When true, the app requires device biometric/credential authentication on launch
64− * (and when returning from the background) before the UI is shown, protecting the
65− * stored server credentials. Defaults to false so the app is usable out of the box. */
66− val appLock: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
67− prefs[appLockKey] ?: false
68− }
69−
70− /** Grace period (seconds) before re-locking after the app backgrounds. 0 = re-lock
71− * immediately (the prior behavior). Lets a quick app-switch not re-prompt, which is the
72− * standard reason users disable biometric locks in messaging/banking apps. */
73− val appLockReLockSeconds: Flow<Int> = appContext.settingsDataStore.data.map { prefs ->
74− prefs[appLockReLockSecondsKey] ?: DEFAULT_APP_LOCK_RELOCK_SECONDS
75− }
76−
77− /** Multiplier applied to chat message (markdown) and code text so users can size the
78− * conversation for readability independently of the system font scale. Clamped to
79− * [[MIN_CHAT_TEXT_SCALE], [MAX_CHAT_TEXT_SCALE]] on read so a corrupt/out-of-range
80− * stored value can never produce an unreadable UI. Defaults to 1.0 (design size). */
81− val chatTextScale: Flow<Float> = appContext.settingsDataStore.data.map { prefs ->
82− (prefs[chatTextScaleKey] ?: DEFAULT_CHAT_TEXT_SCALE)
83− .coerceIn(MIN_CHAT_TEXT_SCALE, MAX_CHAT_TEXT_SCALE)
84− }
85−
86− /** When true, fenced/indented code blocks soft-wrap long lines instead of scrolling
87− * horizontally. Defaults to false (horizontal scroll) to preserve code formatting. */
88− val codeWrap: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
89− prefs[codeWrapKey] ?: false
90− }
91−
92− /** Persisted session-list sort mode so a chosen ordering survives process death (not just
93− * rotation). Stored as the enum name; an unknown value falls back to RECENT. */
94− val sessionSortMode: Flow<String> = appContext.settingsDataStore.data.map { prefs ->
95− prefs[sessionSortModeKey] ?: "RECENT"
96− }
97−
98− /** Persisted session-list sort direction. Defaults to descending (newest first). */
99− val sessionSortDescending: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
100− prefs[sessionSortDescendingKey] ?: true
101− }
102−
103− /** Persisted "show archived sessions" toggle so the choice survives process death. */
104− val sessionShowArchived: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
105− prefs[sessionShowArchivedKey] ?: false
106− }
107−
108− suspend fun setThemeMode(mode: ThemeMode) {
109− appContext.settingsDataStore.edit { it[themeKey] = mode.name }
110− }
111−
112− suspend fun setDynamicColor(enabled: Boolean) {
113− appContext.settingsDataStore.edit { it[dynamicColorKey] = enabled }
114− }
115−
116− suspend fun setSendOnEnter(enabled: Boolean) {
117− appContext.settingsDataStore.edit { it[sendOnEnterKey] = enabled }
118− }
119−
120− suspend fun setAppLock(enabled: Boolean) {
121− appContext.settingsDataStore.edit { it[appLockKey] = enabled }
122− }
123−
124− suspend fun setAppLockReLockSeconds(seconds: Int) {
125− appContext.settingsDataStore.edit {
126− it[appLockReLockSecondsKey] = APP_LOCK_RELOCK_OPTIONS_SECONDS.minBy { k -> Math.abs(k - seconds) }
127− }
128− }
129−
130− suspend fun setChatTextScale(scale: Float) {
131− appContext.settingsDataStore.edit {
132− it[chatTextScaleKey] = scale.coerceIn(MIN_CHAT_TEXT_SCALE, MAX_CHAT_TEXT_SCALE)
133− }
134− }
135− suspend fun setCodeWrap(enabled: Boolean) {
136− appContext.settingsDataStore.edit {
137− it[codeWrapKey] = enabled
138− }
139− }
140−
141− suspend fun setSessionSortMode(mode: String) {
142− appContext.settingsDataStore.edit { it[sessionSortModeKey] = mode }
143− }
144−
145− suspend fun setSessionSortDescending(descending: Boolean) {
146− appContext.settingsDataStore.edit { it[sessionSortDescendingKey] = descending }
147− }
148−
149− suspend fun setSessionShowArchived(show: Boolean) {
150− appContext.settingsDataStore.edit { it[sessionShowArchivedKey] = show }
151− }
152−}
app/src/main/java/soy/iko/opencode/di/AppContainer.ktdeleted−1390
@@ -1,1390 +0,0 @@
1−package soy.iko.opencode.di
2−
3−import android.content.Context
4−import android.net.ConnectivityManager
5−import android.net.Network
6−import android.net.NetworkCapabilities
7−import android.net.NetworkRequest
8−import android.util.Log
9−import soy.iko.opencode.data.model.AssistantMessage
10−import soy.iko.opencode.data.model.BusEvent
11−import soy.iko.opencode.data.model.FilePromptPart
12−import soy.iko.opencode.data.model.MessagePartUpdated
13−import soy.iko.opencode.data.model.MessageUpdated
14−import soy.iko.opencode.data.model.Permission
15−import soy.iko.opencode.data.model.PermissionReplied
16−import soy.iko.opencode.data.model.PermissionResponse
17−import soy.iko.opencode.data.model.PermissionUpdated
18−import soy.iko.opencode.data.model.StepFinishPart
19−import soy.iko.opencode.data.model.ServerProfile
20−import soy.iko.opencode.data.model.SessionError
21−import soy.iko.opencode.data.model.SessionIdle
22−import soy.iko.opencode.data.model.ToolPart
23−import soy.iko.opencode.data.model.TODO_WRITE_TOOL
24−import soy.iko.opencode.data.model.parseTodos
25−import soy.iko.opencode.data.model.statusEnum
26−import soy.iko.opencode.data.model.TodoStatus
27−import soy.iko.opencode.data.model.inputElement
28−import soy.iko.opencode.data.repo.AttachmentDraftStore
29−import soy.iko.opencode.data.repo.BackupManager
30−import soy.iko.opencode.data.repo.DraftStore
31−import soy.iko.opencode.data.repo.ErrorKind
32−import soy.iko.opencode.data.repo.MessageCacheStore
33−import soy.iko.opencode.data.repo.OutboxMessage
34−import soy.iko.opencode.data.repo.OutboxStore
35−import soy.iko.opencode.data.repo.SessionPrefsStore
36−import soy.iko.opencode.data.repo.SessionRepository
37−import soy.iko.opencode.data.repo.ProfileStore
38−import soy.iko.opencode.data.repo.SettingsStore
39−import soy.iko.opencode.data.repo.classifyError
40−import soy.iko.opencode.data.repo.responseStatusCode
41−import soy.iko.opencode.data.network.EventStreamClient
42−import soy.iko.opencode.data.network.HttpClientFactory
43−import soy.iko.opencode.data.network.NetworkConfig
44−import soy.iko.opencode.data.network.OpencodeApiClient
45−import soy.iko.opencode.notification.NotificationChannels
46−import soy.iko.opencode.notification.RunForegroundService
47−import soy.iko.opencode.util.safeExceptionSummary
48−import soy.iko.opencode.notification.SessionNotifications
49−import soy.iko.opencode.R
50−import soy.iko.opencode.util.runCatchingCancellable
51−import kotlinx.coroutines.CoroutineScope
52−import kotlinx.coroutines.Dispatchers
53−import kotlinx.coroutines.ExperimentalCoroutinesApi
54−import kotlinx.coroutines.SupervisorJob
55−import kotlinx.coroutines.cancel
56−import kotlinx.coroutines.delay
57−import kotlinx.coroutines.flow.MutableSharedFlow
58−import kotlinx.coroutines.flow.MutableStateFlow
59−import kotlinx.coroutines.flow.SharedFlow
60−import kotlinx.coroutines.flow.StateFlow
61−import kotlinx.coroutines.flow.asSharedFlow
62−import kotlinx.coroutines.flow.asStateFlow
63−import kotlinx.coroutines.flow.collectLatest
64−import kotlinx.coroutines.flow.combine
65−import kotlinx.coroutines.flow.debounce
66−import kotlinx.coroutines.flow.distinctUntilChanged
67−import kotlinx.coroutines.flow.drop
68−import kotlinx.coroutines.flow.emptyFlow
69−import kotlinx.coroutines.flow.first
70−import kotlinx.coroutines.Job
71−import kotlinx.coroutines.flow.flatMapLatest
72−import kotlinx.coroutines.flow.map
73−import kotlinx.coroutines.flow.update
74−import kotlinx.coroutines.isActive
75−import kotlinx.coroutines.launch
76−import kotlinx.coroutines.sync.Mutex
77−import kotlinx.coroutines.sync.withLock
78−import kotlinx.coroutines.withTimeoutOrNull
79−import java.util.concurrent.atomic.AtomicBoolean
80−
81−/**
82− * Result of probing a server URL to check connectivity and whether authentication
83− * is required, without persisting a profile or opening a long-lived connection.
84− */
85−sealed class ProbeResult {
86− /** Server is reachable and accepts unauthenticated requests. */
87− object Reachable : ProbeResult()
88−
89− /** Server is reachable but rejected the probe with an authentication error. */
90− object NeedsAuth : ProbeResult()
91−
92− /** Server could not be reached (host unresolved, connection refused, timeout, etc.). */
93− data class Unreachable(val error: String) : ProbeResult()
94−}
95−
96−/**
97− * Hand-written service locator held by [soy.iko.opencode.OpencodeApp]. Owns the
98− * process-wide singletons and the currently active [OpencodeConnection].
99− */
100−open class AppContainer private constructor(
101− private val appContext: Context?,
102− private val skipInit: Boolean,
103− @Suppress("unused") private val testMode: Boolean,
104−) {
105− constructor(context: Context) : this(context.applicationContext, false, false)
106− protected constructor() : this(null, true, true)
107−
108− private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
109− open val profileStore: ProfileStore by lazy { ProfileStore(appContext!!) }
110− open val settingsStore: SettingsStore by lazy { SettingsStore(appContext!!) }
111− open val draftStore: DraftStore by lazy { DraftStore(appContext!!, appScope) }
112− open val attachmentDraftStore: AttachmentDraftStore by lazy { AttachmentDraftStore(appContext!!) }
113− open val sessionPrefsStore: SessionPrefsStore by lazy { SessionPrefsStore(appContext!!) }
114− open val messageCacheStore: MessageCacheStore by lazy { MessageCacheStore(appContext!!) }
115− open val outboxStore: OutboxStore by lazy { OutboxStore(appContext!!) }
116− open val backupManager: BackupManager by lazy { BackupManager(profileStore, settingsStore, sessionPrefsStore) }
117−
118− /** True while the outbox is actively flushing queued messages to the server, so the UI
119− * can show a "sending queued messages…" indicator. */
120− private val _outboxSending = MutableStateFlow(false)
121− open val outboxSending: StateFlow<Boolean> = _outboxSending.asStateFlow()
122−
123− /** Bumped to nudge an immediate outbox flush (after enqueue, or a manual "Send now"),
124− * independent of connection/online changes. */
125− private val _outboxFlushTrigger = MutableStateFlow(0)
126− open fun flushOutbox() { _outboxFlushTrigger.update { it + 1 } }
127−
128− private val _activeConnection = MutableStateFlow<OpencodeConnection?>(null)
129− open val activeConnection: StateFlow<OpencodeConnection?> = _activeConnection.asStateFlow()
130−
131− /** True while a background auto-reconnect to the most-recent server is in flight. */
132− private val _reconnecting = MutableStateFlow(false)
133− open val reconnecting: StateFlow<Boolean> = _reconnecting.asStateFlow()
134−
135− /**
136− * Set once a startup auto-reconnect succeeds. The server list collects this and
137− * (once) navigates straight to the session list so a returning user skips it.
138− * [consumeAutoConnect] guards against re-firing on screen re-entry.
139− */
140− private val _autoConnectDone = MutableStateFlow(false)
141− open val autoConnectDone: StateFlow<Boolean> = _autoConnectDone.asStateFlow()
142− private val autoConnectConsumed = AtomicBoolean(false)
143− open fun consumeAutoConnect(): Boolean = autoConnectConsumed.compareAndSet(false, true)
144−
145− /** Text shared into the app (ACTION_SEND), prefilled into a session draft. */
146− private val _pendingShare = MutableStateFlow<String?>(null)
147− open val pendingShare: StateFlow<String?> = _pendingShare.asStateFlow()
148− open fun setPendingShare(text: String) { _pendingShare.value = text }
149− open fun consumePendingShare(): String? {
150− val current = _pendingShare.value ?: return null
151− return if (_pendingShare.compareAndSet(current, null)) current else null
152− }
153−
154− /** Image/file Uris (as strings) shared into the app, staged as attachments in the next
155− * opened session. Consumed once by the first ChatScreen to compose after the share. */
156− private val _pendingSharedMedia = MutableStateFlow<List<String>>(emptyList())
157− open val pendingSharedMedia: StateFlow<List<String>> = _pendingSharedMedia.asStateFlow()
158− open fun setPendingSharedMedia(uris: List<String>) { _pendingSharedMedia.value = uris }
159− open fun consumePendingSharedMedia(): List<String> {
160− val current = _pendingSharedMedia.value
161− if (current.isEmpty()) return emptyList()
162− return if (_pendingSharedMedia.compareAndSet(current, emptyList())) current else emptyList()
163− }
164−
165− /**
166− * Set from an external "New session" trigger (launcher shortcut / QS tile). The nav host
167− * consumes it once connected, creating a fresh session and opening it. Uses a counter
168− * (not a boolean) so re-tapping the shortcut while a prior request is still pending
169− * increments the value and re-fires the LaunchedEffect — a boolean StateFlow wouldn't
170− * emit when set to the same value, making the request unrecoverable by re-tapping.
171− */
172− private val _pendingNewSession = MutableStateFlow(0)
173− open val pendingNewSession: StateFlow<Int> = _pendingNewSession.asStateFlow()
174− open fun requestNewSession() { _pendingNewSession.update { it + 1 } }
175− open fun consumePendingNewSession(): Boolean {
176− while (true) {
177− val current = _pendingNewSession.value
178− if (current == 0) return false
179− if (_pendingNewSession.compareAndSet(current, current - 1)) return true
180− }
181− }
182−
183− /** A one-shot signal to navigate to the Diagnostics screen (e.g. from the crash-relaunch
184− * prompt). Consumed by OpencodeApp's NavHost. */
185− private val _pendingDiagnostics = MutableStateFlow(false)
186− open val pendingDiagnostics: StateFlow<Boolean> = _pendingDiagnostics.asStateFlow()
187− open fun requestDiagnostics() { _pendingDiagnostics.value = true }
188− open fun consumePendingDiagnostics(): Boolean = _pendingDiagnostics.compareAndSet(true, false)
189−
190− /**
191− * A session id to open from an external trigger (a notification tap or a deep link),
192− * paired with the originating server's profile id (if known) so a tap after the user
193− * has switched servers routes back to the server that ran the session. The nav host
194− * consumes it once a connection is active (switching to [profileId] first if needed).
195− */
196− data class PendingOpenSession(val sessionId: String, val profileId: String?)
197−
198− private val _pendingOpenSession = MutableStateFlow<PendingOpenSession?>(null)
199− open val pendingOpenSession: StateFlow<PendingOpenSession?> = _pendingOpenSession.asStateFlow()
200− open fun requestOpenSession(id: String, profileId: String? = null) {
201− _pendingOpenSession.value = PendingOpenSession(id, profileId)
202− }
203− open fun consumePendingOpenSession(): PendingOpenSession? {
204− val current = _pendingOpenSession.value ?: return null
205− return if (_pendingOpenSession.compareAndSet(current, null)) current else null
206− }
207−
208− /**
209− * A file path requested to be opened (via deep link `opencode://file/{path}` or future
210− * entry points), consumed by the NavHost to navigate to the file viewer. Mirrors the
211− * session open-signal pattern: a StateFlow the NavHost observes, drained atomically.
212− */
213− private val _pendingOpenFile = MutableStateFlow<String?>(null)
214− open val pendingOpenFile: StateFlow<String?> = _pendingOpenFile.asStateFlow()
215− open fun requestOpenFile(path: String) { _pendingOpenFile.value = path }
216− open fun consumePendingOpenFile(): String? {
217− val current = _pendingOpenFile.value ?: return null
218− return if (_pendingOpenFile.compareAndSet(current, null)) current else null
219− }
220−
221− /**
222− * The session the user is currently viewing (or null when not in a chat). Drives the
223− * unread tracker: messages arriving for any *other* session mark it unread, and
224− * opening a session clears its unread state.
225− */
226− private val _currentSession = MutableStateFlow<String?>(null)
227− open val currentSession: StateFlow<String?> = _currentSession.asStateFlow()
228−
229− /** Session ids that received activity while not being viewed, mapped to the count
230− * of unread message events. A count (not just a presence set) lets the session
231− * list badge show "3 unread" instead of a bare dot, so the user can tell whether
232− * one reply or a whole burst arrived. */
233− private val _unread = MutableStateFlow<Map<String, Int>>(emptyMap())
234− open val unread: StateFlow<Map<String, Int>> = _unread.asStateFlow()
235−
236− /** True when any assistant run is actively streaming across all sessions. Drives the
237− * disconnect confirmation on the session list (disconnecting mid-run kills it). */
238− private val _anyRunActive = MutableStateFlow(false)
239− open val anyRunActive: StateFlow<Boolean> = _anyRunActive.asStateFlow()
240−
241− /** Latest task-plan progress text for the active run (e.g. "Step 2 of 5"), surfaced in
242− * the foreground-service notification so the user can see what the agent is working on
243− * without opening the app. Null when no plan is available or no run is active. */
244− private val _runProgressText = MutableStateFlow<String?>(null)
245− open val runProgressText: StateFlow<String?> = _runProgressText.asStateFlow()
246−
247− /** Whether the device currently has network connectivity. Distinct from the SSE
248− * connection state so the UI can tell "you're offline" (device) from "server
249− * unreachable" (credentials/host). Updated by the [networkCallback]. */
250− private val _isOnline = MutableStateFlow(true)
251− open val isOnline: StateFlow<Boolean> = _isOnline.asStateFlow()
252−
253− /** Whether a foreground Activity is currently showing. Distinct from [currentSession],
254− * which stays set while the app is merely backgrounded (the chat screen isn't disposed
255− * when the user locks their phone). Permission/completion notifications need the real
256− * foreground signal so they still fire for a session the user "has open" but has walked
257− * away from — the core of the run-in-the-background use case. Set from MainActivity's
258− * onStart/onStop. */
259− private val _isForeground = MutableStateFlow(false)
260− open val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
261− open fun setForeground(foreground: Boolean) { _isForeground.value = foreground }
262−
263− /** True when the user is actively looking at [sessionId] (app foregrounded AND that
264− * session is the one on screen), so an in-app affordance (the permission dialog) is
265− * handling it and a notification would be redundant. */
266− private fun isActivelyViewing(sessionId: String): Boolean =
267− _isForeground.value && sessionId == _currentSession.value
268−
269− open fun setCurrentSession(id: String?) {
270− _currentSession.value = id
271− if (id != null) {
272− _unread.update { it - id }
273− unreadMessageIds.remove(id)
274− appContext?.let {
275− SessionNotifications.cancel(it, id)
276− SessionNotifications.cancelPermission(it, id)
277− SessionNotifications.cancelError(it, id)
278− }
279− }
280− }
281−
282− /** Clear unread badge and dedup set for a deleted session so its stale count doesn't
283− * linger in the session list badge or leak memory until the next connect/disconnect. */
284− open fun clearUnread(id: String) {
285− _unread.update { it - id }
286− unreadMessageIds.remove(id)
287− }
288−
289− /** Mark a session as read (clear its unread badge) — the "Mark read" notification action. */
290− open fun markSessionRead(id: String) = clearUnread(id)
291−
292− /** Clear every session's unread badge at once — the session list's "Mark all read" action. */
293− open fun clearAllUnread() {
294− _unread.update { emptyMap() }
295− }
296−
297− /** Restore a session's unread badge after a failed server switch reconnects.
298− * Preserves the prior [count] so a session badged with "5 unread" before the
299− * switch attempt still shows 5 (not 1) after the restore. No-op for the session
300− * the user is currently viewing, since a viewed session is never badged. */
301− open fun restoreUnread(id: String, count: Int) {
302− if (id != _currentSession.value) {
303− _unread.update { it + (id to count) }
304− }
305− }
306−
307− /** Resolve a localized string — view models reach resources through the container. */
308− open fun string(id: Int, vararg formatArgs: Any): String {
309− if (appContext == null) return ""
310− return if (formatArgs.isEmpty()) appContext.getString(id) else appContext.getString(id, *formatArgs)
311− }
312−
313− /**
314− * Convert a throwable into a user-facing message. Classifies the error by concrete
315− * type (network, timeout, HTTP status) rather than string-matching class names, so
316− * the message reflects what actually went wrong without leaking internal URLs/state.
317− */
318− open fun friendlyError(t: Throwable): String =
319− friendlyErrorFor(t, activeConnection.value?.profile?.baseUrl.orEmpty())
320−
321− /**
322− * Same classification as [friendlyError] but accepts an explicit base URL, so callers
323− * that aren't operating on the active connection (e.g. [probeServer]) can still
324− * produce a user-facing message with the right server address.
325− */
326− open fun friendlyErrorFor(t: Throwable, baseUrl: String): String =
327− when (classifyError(t)) {
328− ErrorKind.NOT_REACHABLE, ErrorKind.NETWORK ->
329− string(R.string.error_not_reachable, baseUrl)
330− ErrorKind.TIMEOUT -> string(R.string.error_timeout)
331− ErrorKind.SERVER -> string(R.string.error_server)
332− // Show only the HTTP status (e.g. 401, 404) — never the request URL,
333− // which a ClientRequestException carries in its message and which we
334− // promised not to leak (it can include auth or internal paths).
335− ErrorKind.CLIENT -> {
336− val code = responseStatusCode(t)
337− when (code) {
338− // Auth failures reuse the connection banner text: the request itself is
339− // fine, the credentials aren't, so the fix is re-editing the profile —
340− // not "request failed (401)" which implies an opaque program error.
341− 401, 403 -> string(R.string.connection_failed)
342− 404 -> string(R.string.error_not_found)
343− else -> code?.let { string(R.string.error_client_status, it) }
344− ?: string(R.string.error_generic)
345− }
346− }
347− ErrorKind.UNKNOWN -> string(R.string.error_generic)
348− }
349−
350− /**
351− * Drive the [RunForegroundService] from the process-lived [appScope] rather than from the
352− * Activity's composition. Previously the start/stop was wired to a
353− * `collectAsStateWithLifecycle` collector in the root composable, which pauses while the
354− * Activity is STOPPED — so a run *started while backgrounded* (e.g. via the notification
355− * inline-reply) never acquired foreground priority and its long-lived SSE stream could be
356− * killed by Doze, and a run that *ended* while backgrounded left the "working…"
357− * notification lingering until the app was reopened. Collecting here reacts to
358− * [anyRunActive] regardless of Activity state.
359− */
360− private fun observeRunForegroundService() {
361− val ctx = appContext ?: return
362− appScope.launch {
363− // Debounce so a run that starts then fails/idles almost immediately doesn't
364− // dispatch startForegroundService() then stopService() back-to-back — if the stop
365− // wins that race before the service calls startForeground(), Android raises the
366− // "did not then call startForeground()" crash ~5s later. Collapsing the window
367− // emits only the settled state; distinctUntilChanged drops a no-op re-emit when a
368− // brief true→false→true flurry settles back to the value already applied.
369− anyRunActive
370− .debounce(NetworkConfig.runForegroundDebounceMs)
371− .distinctUntilChanged()
372− .collect { active ->
373− if (active) {
374− // When exactly one run is active, resolve its title (and id, for the
375− // Stop action) so the notification identifies which session is running
376− // and can be aborted from the notification. Multiple concurrent runs
377− // fall back to the generic title (naming one would be misleading).
378− val activeId = synchronized(activeRuns) {
379− activeRuns.toList().singleOrNull()
380− }
381− val title = activeId?.let { sid ->
382− runCatchingCancellable {
383− activeConnection.value?.repository?.listSessions()
384− ?.firstOrNull { it.id == sid }?.displayTitle
385− }.getOrNull()
386− }
387− RunForegroundService.start(ctx, title, activeId, _runProgressText.value)
388− } else {
389− RunForegroundService.stop(ctx)
390− }
391− }
392− }
393− // Separate collector: update the FGS notification's progress text ("Step 2 of 5")
394− // as the agent works through its task plan. Re-sends the service intent with the
395− // updated progress, which the service uses to rebuild the notification.
396− appScope.launch {
397− _runProgressText
398− .drop(1) // skip initial value — the anyRunActive collector handles the first start
399− .distinctUntilChanged()
400− .collect { progress ->
401− if (_anyRunActive.value) {
402− val activeId = synchronized(activeRuns) { activeRuns.toList().singleOrNull() }
403− val title = activeId?.let { sid ->
404− runCatchingCancellable {
405− activeConnection.value?.repository?.listSessions()
406− ?.firstOrNull { it.id == sid }?.displayTitle
407− }.getOrNull()
408− }
409− RunForegroundService.start(ctx, title, activeId, progress)
410− }
411− }
412− }
413− }
414−
415− /**
416− * Reconcile active-run tracking on an SSE *reconnect*. A run that completes during a stream
417− * outage never delivers its `SessionIdle`, so its id would stay in [activeRuns] and pin
418− * [anyRunActive] (and the foreground service + "working…" notification) on indefinitely. On a
419− * reconnect we clear the tracking and let live streaming events re-populate it: a genuinely
420− * in-progress run re-adds itself within moments via [isLiveRunActivity], while a run that
421− * finished during the outage produces no further live events (its completion arrives only via
422− * the REST re-seed), so it correctly stays cleared. The first connect of a connection is not a
423− * reconnect; `connect()` already resets [activeRuns], so only subsequent Connected transitions
424− * trigger the sweep.
425− */
426− private fun observeRunReconcileOnReconnect() {
427− appScope.launch {
428− activeConnection.collectLatest { conn ->
429− if (conn == null) return@collectLatest
430− var hasConnectedBefore = false
431− conn.events.state.collect { state ->
432− if (state == EventStreamClient.ConnectionState.Connected) {
433− if (hasConnectedBefore) {
434− synchronized(activeRuns) {
435− activeRuns.clear()
436− _anyRunActive.value = false
437− }
438− }
439− hasConnectedBefore = true
440− }
441− }
442− }
443− }
444− }
445−
446− /**
447− * Flush the offline outbox whenever there's an active connection and the device is
448− * online (and on any manual [flushOutbox] nudge). Queued messages are only sent to the
449− * server they were composed for (matched by profile id), so a reconnect to a different
450− * server never misfires them against a session id that doesn't exist there. Uses a plain
451− * `collect` (not collectLatest) so an in-flight send is never cancelled mid-flight and
452− * send order is preserved; each message carries a stable idempotency key (its persistent
453− * outbox id) so even a re-send across flushes is deduplicated server-side.
454− */
455− private fun observeOutbox() {
456− appScope.launch {
457− combine(activeConnection, isOnline, _outboxFlushTrigger) { conn, online, _ -> conn to online }
458− .collect { (conn, online) ->
459− if (conn == null || !online) return@collect
460− flushOutboxNow(conn)
461− }
462− }
463− }
464−
465− private suspend fun flushOutboxNow(conn: OpencodeConnection) {
466− val pending = outboxStore.messages.value
467− .filter { it.profileId == conn.profile.id }
468− .sortedBy { it.createdAt }
469− if (pending.isEmpty()) return
470− _outboxSending.value = true
471− try {
472− for (msg in pending) {
473− val result = runCatchingCancellable {
474− conn.repository.sendPrompt(
475− msg.sessionId,
476− msg.text,
477− attachments = msg.attachments.map {
478− FilePromptPart(mime = it.mime, url = it.url, filename = it.filename)
479− },
480− model = msg.model,
481− agent = msg.agent,
482− // Reuse the persistent outbox id as a stable idempotency key so a
483− // later flush of a message whose earlier POST reached the server (but
484− // whose response was lost) is deduplicated server-side instead of
485− // starting a duplicate agent run.
486− idempotencyKey = msg.id,
487− )
488− }.onFailure {
489− Log.w("AppContainer", "Outbox flush failed for ${msg.sessionId}: ${safeExceptionSummary(it)}")
490− }
491− if (result.isSuccess) {
492− outboxStore.remove(msg.id)
493− continue
494− }
495− // A permanently-undeliverable message (a non-408/429 4xx — e.g. the target
496− // session was deleted server-side, yielding a 404) must be dropped, not left at
497− // the head of the queue. The loop stops at the first failure to preserve send
498− // order, so a poison message would otherwise block every later queued message
499− // for this profile forever. Transient failures (offline again, 5xx, 408/429)
500− // keep the queue intact and retry on the next trigger.
501− // 401/403 (bad/expired credentials) are NOT permanent: the message itself is
502− // deliverable once the user fixes the profile. Dropping it would silently lose
503− // the user's composed offline prompt on a recoverable auth failure. They stay
504− // in the queue and retry on the next flush after auth is restored.
505− val status = result.exceptionOrNull()?.let { responseStatusCode(it) }
506− // Permanent = a non-408/429/401/403 4xx, matching withRetry's non-retryable rule
507− // except for auth failures, which are recoverable by re-editing the profile.
508− val permanent = when (status) {
509− null, 408, 429, 401, 403 -> false
510− in 400..499 -> true
511− else -> false
512− }
513− if (permanent) {
514− Log.w(
515− "AppContainer",
516− "Dropping undeliverable outbox message ${msg.id} for ${msg.sessionId} (HTTP $status): ${string(R.string.outbox_dropped_text)}",
517− )
518− outboxStore.remove(msg.id)
519− // Previously this silent drop only hit logcat, so the user got no signal
520− // that their composed reply never landed. Surface it on the ERROR channel.
521− // postError is the only public entry point that posts there; it renders a
522− // fixed title/text and interpolates its title arg, so the localized
523− // outbox_dropped_title is passed as that arg (a user-facing message rather
524− // than an opaque session id). Tap routes back to the originating profile.
525− val ctx = appContext
526− if (ctx != null) {
527− runCatchingCancellable {
528− SessionNotifications.postError(
529− ctx, msg.sessionId,
530− string(R.string.outbox_dropped_title),
531− conn.profile.id,
532− )
533− }.onFailure {
534− Log.w("AppContainer", "outbox drop notification failed: ${safeExceptionSummary(it)}")
535− }
536− }
537− } else {
538− break
539− }
540− }
541− } finally {
542− _outboxSending.value = false
543− }
544− }
545−
546− /** Release resources held for the process lifetime (network callback, app scope). */
547− open fun shutdown() {
548− // Cancel the app scope first so any coroutine holding the connection mutex
549− // (e.g. a connect() suspended on profileStore) is cancelled and releases the
550− // mutex. Otherwise the runBlocking below would deadlock waiting for a coroutine
551− // that can't be cancelled until after the runBlocking completes.
552− appScope.cancel()
553− // Bounded runBlocking so a stuck mutex or a slow close() can't ANR the app.
554− // 2 seconds is generous: appScope.cancel() already triggered cancellation of
555− // any coroutine holding the mutex; we're just waiting for it to unwind.
556− kotlinx.coroutines.runBlocking {
557− withTimeoutOrNull(2_000) {
558− connectionMutex.withLock {
559− runCatching { _activeConnection.value?.close() }
560− _activeConnection.value = null
561− }
562− }
563− // If the timeout fired, force-clear the connection so it isn't left dangling.
564− _activeConnection.value = null
565− }
566− val cm = appContext?.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
567− networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
568− if (!skipInit) soy.iko.opencode.data.repo.CrashLogger.get(appContext!!).shutdown()
569− if (!skipInit) draftStore.shutdown()
570− }
571−
572− /**
573− * Watch the SSE bus for new message activity and badge any session that isn't
574− * currently open. This powers the unread dot on the session list so the user can
575− * tell which conversations got a reply while they were elsewhere.
576− *
577− * Uses [flatMapLatest] so a server switch (which replaces the active connection)
578− * re-subscribes to the new event stream and drops the old one.
579− */
580− @OptIn(ExperimentalCoroutinesApi::class)
581− private fun observeMessageActivity() {
582− appScope.launch {
583− // Retry the observer if it fails for any reason (transient flow exception,
584− // cancellation due to scope issues). Without this, a single failure would
585− // permanently disable unread tracking and completion notifications.
586− while (isActive) {
587− runCatchingCancellable {
588− activeConnection
589− .flatMapLatest { conn ->
590− // Capture the originating connection so notification coroutines
591− // resolve titles and route permission responses against THIS
592− // server, not whichever happens to be active when they resume.
593− // A server switch during a listSessions() round-trip would
594− // otherwise resolve the title (or POST the permission reply)
595− // against the new server, where the session/permission doesn't
596− // exist — showing an opaque id or silently losing the reply.
597− conn?.events?.events?.map { conn to it } ?: emptyFlow()
598− }
599− .collect { (conn, event) ->
600− // A permission request needs the user's approval. Post a heads-up
601− // notification (with Allow/Reject actions) unless the user is actively
602− // viewing this session, where the in-app permission dialog handles it.
603− if (event is PermissionUpdated) {
604− val psid = event.properties.sessionID.takeIf { it.isNotBlank() } ?: return@collect
605− if (!isActivelyViewing(psid)) appScope.launch { notifyPermission(event.properties, conn) }
606− return@collect
607− }
608− // The permission was answered (in-app, from the notification, or
609− // auto-resolved): clear its heads-up notification.
610− if (event is PermissionReplied) {
611− val psid = event.properties.sessionID?.takeIf { it.isNotBlank() } ?: return@collect
612− appContext?.let { SessionNotifications.cancelPermission(it, psid) }
613− return@collect
614− }
615− // A run failed. Clear its active-run tracking (an error isn't always
616− // followed by a SessionIdle) and notify unless it's being viewed.
617− if (event is SessionError) {
618− val esid = event.properties.sessionID ?: return@collect
619− val wasRunning = synchronized(activeRuns) {
620− activeRuns.remove(esid).also { removed ->
621− if (removed) _anyRunActive.value = activeRuns.isNotEmpty()
622− }
623− }
624− if (wasRunning && !isActivelyViewing(esid)) {
625− appScope.launch { notifySessionError(esid, conn.repository, conn.profile.id) }
626− }
627− return@collect
628− }
629− // SessionIdle is not "message activity" (don't badge as unread)
630− // but signals a run finished — fire a completion notification if the
631− // session isn't currently being viewed and was actively streaming.
632− if (event is SessionIdle) {
633− val idleSid = event.properties.sessionID ?: return@collect
634− // Clear the run state unconditionally — even for the session on
635− // screen. Gating the removal on `idleSid != currentSession` (as
636− // this once did) leaks the id in activeRuns and pins anyRunActive
637− // true forever whenever a run finishes while being viewed. Only
638− // the completion *notification* is suppressed for the viewed one.
639− val wasRunning = synchronized(activeRuns) {
640− activeRuns.remove(idleSid).also { removed ->
641− if (removed) _anyRunActive.value = activeRuns.isNotEmpty()
642− }
643− }
644− // Clear the progress text when the run finishes.
645− _runProgressText.value = null
646− if (wasRunning && !isActivelyViewing(idleSid)) {
647− // Fire this off the collector's coroutine: notifySessionCompleted
648− // does a network listSessions() to resolve the title, and blocking
649− // the shared-event collector here would stall the SharedFlow,
650− // overflowing its DROP_OLDEST buffer and silently dropping live
651− // parts/updates for every other subscriber (e.g. the message reducer).
652− appScope.launch { notifySessionCompleted(idleSid, conn.repository, conn.profile.id) }
653− }
654− return@collect
655− }
656− // Track the agent's task-plan progress (from todowrite tool calls) so the
657− // foreground-service notification can show "Step 2 of 5" instead of just a
658− // chronometer. The todowrite tool sends the full plan on every call, so the
659− // latest call's input is a complete snapshot.
660− if (event is MessagePartUpdated) {
661− val part = event.properties.part
662− if (part is ToolPart && part.tool.equals(TODO_WRITE_TOOL, ignoreCase = true)) {
663− val todos = parseTodos(part.state.inputElement())
664− if (todos.isNotEmpty()) {
665− val completed = todos.count { it.statusEnum() == TodoStatus.COMPLETED }
666− _runProgressText.value = appContext?.getString(
667− soy.iko.opencode.R.string.notif_running_progress,
668− completed + 1,
669− todos.size,
670− )
671− }
672− }
673− }
674− val sid = sessionOf(event) ?: return@collect
675− // Re-read currentSession here, right before mutating the badge state:
676− // the user may have opened this session between when this event was
677− // enqueued and now (setCurrentSession clears its badge and dedup set).
678− // A stale earlier check would race a badge — and a dedup entry — back
679− // in for the session that's actually on screen.
680− if (sid != _currentSession.value) {
681− // Increment the unread count once per distinct *message*, not per
682− // event: a single reply emits many message.updated / message.part.
683− // updated events (one per streamed token, plus cost/token refreshes),
684− // which would otherwise inflate the badge into the dozens/hundreds.
685− val messageId = messageIdOfEvent(event)
686− val counted = unreadMessageIds.getOrPut(sid) {
687− java.util.Collections.synchronizedSet(mutableSetOf())
688− }
689− if (messageId == null || counted.add(messageId)) {
690− _unread.update { current ->
691− // Guard again inside the atomic update lambda so a retry
692− // (or an open that landed just now) can't reintroduce the
693− // badge for the session on screen.
694− if (sid == _currentSession.value) current
695− else current + (sid to (current[sid] ?: 0) + 1)
696− }
697− }
698− }
699− // Track sessions actively streaming so we know which idle events
700− // represent a finished run worth notifying about.
701− if (isLiveRunActivity(event)) {
702− // The add-under-lock is required both for thread-safety and for the
703− // access-order refresh (keeps a continuously-streaming session from
704− // being evicted), so it runs per event.
705− synchronized(activeRuns) {
706− activeRuns.add(sid)
707− // Derive the flag from the set state *inside* the same lock that
708− // guards the set, so a concurrent reconnect sweep (which clears
709− // both under this lock) can't interleave and leave the flag pinned
710− // true with an empty set — a stuck foreground service + "working…".
711− _anyRunActive.value = activeRuns.isNotEmpty()
712− }
713− }
714− } }.onFailure { Log.w("AppContainer", "Message activity observer failed, will retry: ${safeExceptionSummary(it)}") }
715− if (!isActive) break
716− delay(NetworkConfig.observerRetryDelayMs)
717− }
718− }
719− }
720−
721− /** True if [event] is live streaming activity worth tracking as an active run. A
722− * trailing message.updated that lands *after* the run finished (final cost/token totals,
723− * carrying a completion time) is NOT live streaming: with no further SessionIdle to
724− * follow it, re-adding the session would pin anyRunActive true indefinitely. */
725− private fun isLiveRunActivity(event: BusEvent): Boolean {
726− if (event !is MessagePartUpdated && event !is MessageUpdated) return false
727− // For MessageUpdated, only an in-flight *assistant* message counts as live activity.
728− // A UserMessage or UnknownMessage update (e.g. a metadata refresh of an existing user
729− // message) would otherwise add the session to activeRuns with no following SessionIdle
730− // to clear it — pinning anyRunActive true and the foreground notification indefinitely.
731− // Mirrors SessionRepository.isRunActivity's guard exactly.
732− if (event is MessageUpdated) {
733− val info = event.properties.info
734− return info is AssistantMessage && !info.isComplete && info.error == null
735− }
736− // A step-finish part is the trailing completion marker for a run (it carries the
737− // final cost/token totals) and arrives after the last stream delta. Like a completed
738− // message.updated, treating it as live would re-add the session to activeRuns with no
739− // following SessionIdle to clear it — pinning anyRunActive true indefinitely when such
740− // a part is replayed/reordered after the run already went idle.
741− val part = (event as MessagePartUpdated).properties.part
742− return part !is StepFinishPart
743− }
744−
745− /** Session ids currently streaming an assistant run (best-effort, in-process). Backed by
746− * an access-order [java.util.LinkedHashMap] so re-adding an already-present id on each
747− * streaming event refreshes its recency (a plain LinkedHashSet.add is a no-op that does
748− * NOT reorder). Entries are drained by [SessionIdle] (and cleared wholesale on reconnect
749− * reconcile); we intentionally do NOT cap the set, because evicting the eldest would drop
750− * its completion notification when its SessionIdle eventually arrives — a busy server
751− * with many concurrent runs is still bounded by the server's own concurrency, so growth
752− * is not unbounded in practice. */
753− private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(
754− java.util.Collections.newSetFromMap(java.util.LinkedHashMap<String, Boolean>(16, 0.75f, true)),
755− )
756−
757− /** Per non-viewed session, the set of message ids already reflected in its unread
758− * count. De-duplicates the many streaming events that share one message id so the
759− * badge counts messages, not deltas. Cleared when a session is viewed or on connect. */
760− private val unreadMessageIds = java.util.concurrent.ConcurrentHashMap<String, MutableSet<String>>()
761−
762− /** Guards connect/disconnect so concurrent callers can't leak an old connection. */
763− private val connectionMutex = Mutex()
764−
765− /** Only persist the `lastUsed` timestamp if it's older than this, to avoid
766− * a DataStore + encrypted-prefs write on every rapid reconnect. */
767− private companion object {
768− const val LAST_USED_SAVE_THRESHOLD_MS = 60_000L
769− }
770−
771− /** Resolve the human-readable title for [sessionId] (best-effort), falling back to the id.
772− * Uses [originRepo] — the repository of the connection that emitted the event — so a
773− * server switch during the listSessions() round-trip doesn't resolve the title against
774− * an unrelated server (showing an opaque id, or worse, a colliding session's title). */
775− private suspend fun resolveSessionTitle(sessionId: String, originRepo: SessionRepository): String =
776− runCatchingCancellable {
777− originRepo.listSessions()?.firstOrNull { it.id == sessionId }?.displayTitle
778− }.getOrNull() ?: sessionId
779−
780− /** Resolve the title for [sessionId] (best-effort) and post a completion notification.
781− * [originProfileId] is the id of the server that ran the session; it's embedded in the
782− * inline-reply action so a follow-up routes back to THIS server even after the user switches
783− * connections (mirrors [notifyPermission]). */
784− private suspend fun notifySessionCompleted(sessionId: String, originRepo: SessionRepository, originProfileId: String) {
785− val title = resolveSessionTitle(sessionId, originRepo)
786− // Re-check viewing right before posting: the call site checks !isActivelyViewing(sid)
787− // at event time, but resolveSessionTitle() suspends on a listSessions() round-trip.
788− // If the user opened the session in that gap, setCurrentSession already cancelled the
789− // prior notification — posting a fresh one here would leave a "session ready" heads-up
790− // lingering while they're already looking at the finished run.
791− if (isActivelyViewing(sessionId)) return
792− appContext?.let { SessionNotifications.postCompleted(it, sessionId, title, originProfileId) }
793− }
794−
795− /** Post a heads-up permission notification for [permission], resolving its session title.
796− * [originConn] is the connection that emitted the permission event — its profile id is
797− * embedded in the notification so the receiver routes the response back to THIS server. */
798− private suspend fun notifyPermission(permission: Permission, originConn: OpencodeConnection) {
799− val sessionId = permission.sessionID.takeIf { it.isNotBlank() } ?: return
800− val title = resolveSessionTitle(sessionId, originConn.repository)
801− // Re-check viewing just before posting — see notifySessionCompleted for the race.
802− if (isActivelyViewing(sessionId)) return
803− appContext?.let { SessionNotifications.postPermission(it, permission, title, originConn.profile.id) }
804− }
805−
806− /** Post an error notification for a failed background run. [originProfileId] is embedded
807− * in the tap intent so the session opens under the server that ran it, not whichever
808− * is active when the user taps — mirroring [notifySessionCompleted]. */
809− private suspend fun notifySessionError(sessionId: String, originRepo: SessionRepository, originProfileId: String) {
810− val title = resolveSessionTitle(sessionId, originRepo)
811− // Re-check viewing just before posting — see notifySessionCompleted for the race.
812− if (isActivelyViewing(sessionId)) return
813− appContext?.let { SessionNotifications.postError(it, sessionId, title, originProfileId) }
814− }
815−
816− /** Respond to a permission from a notification action (Allow once / Always / Reject),
817− * off any UI. Runs on the process-lived app scope; [onDone] fires when the call resolves
818− * so the receiver's goAsync() result can finish. Its Boolean is true only when a live
819− * connection existed AND respondPermission succeeded — the receiver uses it to decide
820− * whether to dismiss the notification. A missing connection makes the respond a no-op,
821− * so it must report failure (not success) rather than let the notification vanish with
822− * the tool left unanswered.
823− *
824− * [profileId] is the id of the server that posted the permission request (embedded in
825− * the notification's PendingIntent). If it doesn't match the currently active
826− * connection's profile, the response is NOT sent: posting it to a different server
827− * would 404 (treated as "already resolved" success by respondPermission), dismissing
828− * the notification while the original server's tool stays paused forever. Reporting
829− * failure instead leaves the notification up so the user can switch back and retry. */
830− open fun respondToPermissionFromNotification(
831− sessionId: String,
832− permissionId: String,
833− response: PermissionResponse,
834− profileId: String?,
835− onDone: (Boolean) -> Unit,
836− ) {
837− appScope.launch {
838− // Guarantee onDone (so the BroadcastReceiver's goAsync() finishes) even if the
839− // scope is cancelled mid-call (process shutdown cancels appScope first). Without
840− // this, a cancelled scope leaves pending.finish() never called → a receiver ANR
841− // and the permission response silently lost.
842− var success = false
843− try {
844− success = runCatchingCancellable {
845− val conn = activeConnection.value ?: return@runCatchingCancellable false
846− // Route to the originating server only. A mismatch means the user
847− // switched servers while the permission was pending; sending the reply
848− // to the wrong server would be silently swallowed as a 404-success.
849− if (profileId != null && conn.profile.id != profileId) return@runCatchingCancellable false
850− conn.api.respondPermission(sessionId, permissionId, response)
851− true
852− }.onFailure {
853− Log.w("AppContainer", "notif permission respond failed: ${safeExceptionSummary(it)}")
854− }.getOrDefault(false)
855− } finally {
856− onDone(success)
857− }
858− }
859− }
860−
861− /** Send a follow-up prompt from a notification's inline reply, off any UI. Uses the
862− * server's default model/agent. Routes through the durable [outboxStore] — the same path
863− * the in-app composer uses ([soy.iko.opencode.ui.chat.ChatViewModel.enqueueOffline]) —
864− * rather than a direct repository send, so a reply composed while disconnected survives to
865− * be flushed on reconnect instead of being silently dropped. [flushOutbox] nudges an
866− * immediate send when a connection is live, so an online reply still goes out now. [onDone]
867− * reports whether the reply was durably enqueued so the receiver only dismisses the
868− * notification on success. */
869− open fun sendPromptFromNotification(sessionId: String, text: String, originProfileId: String?, onDone: (Boolean) -> Unit) {
870− appScope.launch {
871− // Guarantee onDone (so the BroadcastReceiver's goAsync() finishes) even if the
872− // scope is cancelled mid-call — see respondToPermissionFromNotification above.
873− var enqueued = false
874− try {
875− // Attribute to the profile that POSTED the notification (embedded in the reply
876− // intent) so the follow-up is always enqueued against the server that owns
877− // [sessionId] — never whichever server happens to be active now, which would POST
878− // A's session id to B, 404, and silently drop the reply. Fall back to the active/
879− // most-recent profile only for a legacy intent that predates the embedded id.
880− val profileId = originProfileId
881− ?: activeConnection.value?.profile?.id
882− ?: runCatchingCancellable { profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
883− enqueued = if (profileId == null) {
884− false
885− } else {
886− runCatchingCancellable {
887− outboxStore.enqueue(
888− OutboxMessage(
889− id = java.util.UUID.randomUUID().toString(),
890− profileId = profileId,
891− sessionId = sessionId,
892− text = text,
893− model = null,
894− createdAt = System.currentTimeMillis(),
895− ),
896− )
897− }.onFailure {
898− Log.w("AppContainer", "notif reply enqueue failed: ${safeExceptionSummary(it)}")
899− }.isSuccess
900− }
901− if (enqueued) flushOutbox()
902− } finally {
903− onDone(enqueued)
904− }
905− }
906− }
907−
908− /** Abort the active run for [sessionId] from a notification Stop action. Routes to the
909− * originating profile (or the active connection) so the abort reaches the server that
910− * owns the session. Runs on the app scope so it survives the BroadcastReceiver's lifetime. */
911− open fun abortRunFromNotification(sessionId: String, originProfileId: String?, onDone: (Boolean) -> Unit) {
912− appScope.launch {
913− var success = false
914− try {
915− val conn = activeConnection.value?.takeIf {
916− originProfileId == null || it.profile.id == originProfileId
917− } ?: activeConnection.value
918− if (conn != null) {
919− success = runCatchingCancellable { conn.repository.abort(sessionId) }.isSuccess
920− }
921− } finally {
922− onDone(success)
923− }
924− }
925− }
926−
927− /**
928− * Retry the last user prompt for [sessionId] from an error notification's "Retry last"
929− * action. Fetches the session's messages, re-sends the most recent user prompt, and
930− * cancels the error notification on success. Routes to the originating profile's
931− * connection (falling back to the active one) so the send reaches the server that owns
932− * the session. A no-op (reported as false) when there's no connection or no prior prompt.
933− */
934− open fun retryLastFromNotification(sessionId: String, originProfileId: String?, onDone: (Boolean) -> Unit) {
935− appScope.launch {
936− var success = false
937− try {
938− val conn = activeConnection.value?.takeIf {
939− originProfileId == null || it.profile.id == originProfileId
940− } ?: activeConnection.value
941− if (conn != null) {
942− success = runCatchingCancellable {
943− val messages = conn.api.listMessages(sessionId)
944− val lastPrompt = messages
945− .lastOrNull { it.info is soy.iko.opencode.data.model.UserMessage }
946− ?.parts
947− ?.filterIsInstance<soy.iko.opencode.data.model.TextPart>()
948− ?.joinToString("\n\n") { it.text }
949− ?.takeIf { it.isNotBlank() }
950− if (lastPrompt != null) {
951− // model=null lets the server reuse the session's current model; we
952− // don't know the originally-selected model from a notification context.
953− conn.repository.sendPrompt(sessionId, lastPrompt, model = null)
954− true
955− } else {
956− false
957− }
958− }.isSuccess
959− }
960− } finally {
961− onDone(success)
962− }
963− }
964− }
965−
966− /** Extract the session id an event pertains to, for message-activity events. */
967− private fun sessionOf(event: BusEvent): String? = sessionOfEvent(event)
968−
969− /**
970− * On cold start, transparently reconnect to the most recently used server so a
971− * returning user lands in their session list instead of the empty server screen.
972− */
973− private suspend fun autoConnect() {
974− val recent = runCatchingCancellable { profileStore.profiles.first() }
975− .getOrDefault(emptyList())
976− .firstOrNull() ?: return
977− // The profile read above suspends on DataStore. A manual connect() to a different
978− // server during that window establishes a connection the user explicitly chose; we must
979− // not clobber it by connecting to `recent` on top. connectIfIdle() checks "is anything
980− // active?" and connects atomically under connectionMutex, returning null if the cold-start
981− // slot was already taken — closing the TOCTOU window an unlocked null-check plus connect()'s
982− // unconditional close would leave. (disconnectIf below similarly guards the failed-ping
983− // teardown by identity.)
984− _reconnecting.value = true
985− var conn: OpencodeConnection? = null
986− try {
987− val ok = runCatchingCancellable {
988− conn = connectIfIdle(recent) ?: return@runCatchingCancellable false
989− conn!!.api.ping()
990− true
991− }.getOrDefault(false)
992− when {
993− conn == null -> Unit // slot already taken by a manual connect — leave it be
994− ok -> _autoConnectDone.value = true
995− else -> disconnectIf(conn!!)
996− }
997− } finally {
998− // Reset in a finally so a CancellationException (e.g. scope cancelled during
999− // shutdown) can't leave the flag pinned true — runCatchingCancellable rethrows it,
1000− // skipping the line below the try block.
1001− _reconnecting.value = false
1002− }
1003− }
1004−
1005− /**
1006− * When the device regains connectivity, nudge the active SSE stream to reconnect
1007− * immediately instead of waiting out the exponential backoff.
1008− */
1009− private val networkCallback: ConnectivityManager.NetworkCallback? = registerNetworkMonitor()
1010−
1011− private fun registerNetworkMonitor(): ConnectivityManager.NetworkCallback? {
1012− if (appContext == null) return null
1013− val cm = appContext!!.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
1014− ?: return null
1015− // Seed the online state from the current active network so the indicator is
1016− // correct on cold start rather than defaulting to "online" until a callback fires.
1017− _isOnline.value = runCatching { cm.activeNetwork != null }.getOrDefault(true)
1018− val callback = object : ConnectivityManager.NetworkCallback() {
1019− override fun onAvailable(network: Network) { _isOnline.value = true; activeConnection.value?.events?.triggerReconnect() }
1020− // A matched (internet-capable) network was lost. Don't eagerly flip offline: during a
1021− // Wi-Fi→cellular handoff onLost(WiFi) fires before onAvailable(cellular) and
1022− // cm.activeNetwork is transiently null in that window — flashing offline there would
1023− // skip outbox flushing and blink the indicator. Instead re-check activeNetwork after a
1024− // short grace period and only report offline if there's still no active network.
1025− //
1026− // This MUST live in onLost, not onUnavailable: onUnavailable is delivered only for
1027− // requestNetwork() with a timeout, never for registerNetworkCallback() — relying on it
1028− // (as this once did) meant offline was never detected after the first onAvailable.
1029− override fun onLost(network: Network) {
1030− appScope.launch {
1031− delay(NetworkConfig.networkOfflineGraceMs)
1032− _isOnline.value = runCatching { cm.activeNetwork != null }.getOrDefault(true)
1033− }
1034− }
1035− }
1036− // Only match networks that actually provide internet — a capability-less request
1037− // fires onAvailable for transports that can't reach the server (and would trigger
1038− // spurious reconnects), so require NET_CAPABILITY_INTERNET.
1039− val request = NetworkRequest.Builder()
1040− .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
1041− .build()
1042− runCatching { cm.registerNetworkCallback(request, callback) }
1043− .onFailure { Log.w("AppContainer", "Failed to register network callback", it) }
1044− return callback
1045− }
1046−
1047− /** Open (or re-open) a connection to [profile], replacing any current one.
1048− * Only persists the updated `lastUsed` timestamp if it's stale (older than a
1049− * minute), so rapid reconnect storms don't each trigger a DataStore write. */
1050− open suspend fun connect(profile: ServerProfile): OpencodeConnection =
1051− connectionMutex.withLock { connectLocked(profile) }
1052−
1053− /** Connect to the profile with [profileId], looking it up from [profileStore]. Returns
1054− * true on success, false if the profile wasn't found or connect threw. Used by the
1055− * notification body-tap path to route a session open back to the originating server. */
1056− open suspend fun connectByProfileId(profileId: String): Boolean {
1057− val profile = profileStore.profiles.first().find { it.id == profileId } ?: return false
1058− return runCatchingCancellable { connect(profile) }.isSuccess
1059− }
1060−
1061− /** Connect to [profile] only if nothing is currently active, checking-and-connecting
1062− * atomically under [connectionMutex]. Returns null (touching nothing) when a connection is
1063− * already active, so autoConnect() can't clobber a manual connect() the user made during its
1064− * suspending profile read — closing the TOCTOU window an unlocked null-check would leave. */
1065− private suspend fun connectIfIdle(profile: ServerProfile): OpencodeConnection? =
1066− connectionMutex.withLock {
1067− if (_activeConnection.value != null) null else connectLocked(profile)
1068− }
1069−
1070− /** Build and install a connection to [profile], replacing any current one. Caller MUST hold
1071− * [connectionMutex]. */
1072− private suspend fun connectLocked(profile: ServerProfile): OpencodeConnection {
1073− // Capture the previous profile id before closing, so we can preserve unread badges
1074− // when reconnecting to the same server — a brief network blip or manual reconnect
1075− // shouldn't erase "5 unread". Only a genuine server switch clears them.
1076− val previousProfileId = _activeConnection.value?.profile?.id
1077− _activeConnection.value?.close()
1078− _activeConnection.value = null
1079− // Mutate the run set and its derived flag together under activeRuns' monitor: the SSE
1080− // event handler adds ids under the same lock, so an unlocked clear() here could
1081− // interleave with an add()+flag=true and leave _anyRunActive pinned true with a torn-
1082− // down connection and no SessionIdle to clear it — a stuck foreground service.
1083− synchronized(activeRuns) {
1084− activeRuns.clear()
1085− _anyRunActive.value = false
1086− }
1087− if (previousProfileId != profile.id) {
1088− _unread.value = emptyMap()
1089− unreadMessageIds.clear()
1090− }
1091− val now = System.currentTimeMillis()
1092− val resolved = profileStore.resolve(profile)
1093− val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
1094− val finalProfile = if (needsSave) resolved.copy(lastUsed = now) else resolved
1095− if (needsSave) profileStore.save(finalProfile)
1096− return OpencodeConnection(finalProfile, messageCacheStore).also { _activeConnection.value = it }
1097− }
1098−
1099− open suspend fun disconnect() {
1100− // Acquire the connection mutex so disconnect is serialized with connect().
1101− // Previously this was fire-and-forget (launch on appScope), which raced with
1102− // a subsequent connect(): connect() could acquire the mutex, create a new
1103− // connection, and then the pending disconnect() would close it.
1104− connectionMutex.withLock { teardownActiveLocked() }
1105− }
1106−
1107− /** Tear down [expected] only if it is still the active connection, with the identity
1108− * check performed *under* the mutex. A caller that checks `activeConnection.value === x`
1109− * itself and then calls [disconnect] has a TOCTOU race: a concurrent connect() to a
1110− * different server can replace the active connection in the gap, and [disconnect] would
1111− * then close that new connection. autoConnect()'s failed-ping teardown uses this. */
1112− private suspend fun disconnectIf(expected: OpencodeConnection) {
1113− connectionMutex.withLock {
1114− if (_activeConnection.value === expected) teardownActiveLocked()
1115− }
1116− }
1117−
1118− /** Close and reset the active connection. Caller MUST hold [connectionMutex]. */
1119− private suspend fun teardownActiveLocked() {
1120− _activeConnection.value?.close()
1121− _activeConnection.value = null
1122− // Reset run/unread state on disconnect too. With no SSE stream there's no
1123− // SessionIdle to drain activeRuns, so without this the working indicator
1124− // (anyRunActive) and unread badges would stay pinned until the next connect().
1125− // Mutate under activeRuns' monitor (see connect()) so a concurrent SSE add() can't
1126− // re-pin _anyRunActive after the clear.
1127− synchronized(activeRuns) {
1128− activeRuns.clear()
1129− _anyRunActive.value = false
1130− }
1131− _unread.value = emptyMap()
1132− unreadMessageIds.clear()
1133− }
1134−
1135− /** Deferred profile deletions keyed by id, run on the process-lived [appScope] (not a
1136− * ViewModel scope) so navigating away during the undo window still commits the delete
1137− * instead of silently cancelling it — mirroring [CrashLogger.scheduleDelete]. */
1138− private val pendingProfileDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
1139−
1140− /**
1141− * Schedule the profile [id] for deletion after [delayMs], cancellable via
1142− * [cancelProfileDelete] (the Undo action). [onDeleted]/[onError] run on [appScope]
1143− * after the delete resolves so the caller can clear its optimistic-hide state. A
1144− * repeated schedule for the same id replaces the prior timer.
1145− */
1146− open fun scheduleProfileDelete(
1147− id: String,
1148− delayMs: Long,
1149− onDeleted: () -> Unit,
1150− onError: (Throwable) -> Unit,
1151− ) {
1152− val job = appScope.launch {
1153− delay(delayMs)
1154− // Claim the delete by removing our own entry BEFORE committing it — the atomic
1155− // remove is the sync point with cancelProfileDelete. Losing the race (an undo
1156− // removed us first) means we skip, honouring cancel's `true`; deleting first
1157− // would let a cancel land in the gap and falsely report undo of a gone profile.
1158− // remove(id, thisJob) also no-ops for a stale job after a reschedule.
1159− val claimed = coroutineContext[Job]?.let { pendingProfileDeletes.remove(id, it) } == true
1160− if (!claimed) return@launch
1161− runCatchingCancellable { profileStore.delete(id) }
1162− .onSuccess { onDeleted() }
1163− .onFailure { onError(it) }
1164− }
1165− pendingProfileDeletes[id]?.cancel()
1166− pendingProfileDeletes[id] = job
1167− }
1168−
1169− /** Cancel a pending deferred profile delete (the Undo action). Returns true if it was
1170− * still pending (undo succeeded), false if the delete had already fired. */
1171− open fun cancelProfileDelete(id: String): Boolean {
1172− val job = pendingProfileDeletes.remove(id) ?: return false
1173− job.cancel()
1174− return true
1175− }
1176−
1177− /** Deferred session deletions keyed by id, run on the process-lived [appScope] (not a
1178− * ViewModel scope) so navigating away during the undo window still commits the delete
1179− * instead of silently cancelling it (which would leave the session deleted-in-UI but
1180− * alive on the server, reappearing on the next refresh). */
1181− private val pendingSessionDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
1182−
1183− /** One-shot events carrying the id of a session scheduled for deferred deletion from
1184− * outside the session list (e.g. from the chat screen's Delete). The SessionListScreen
1185− * collects these to show an Undo snackbar, mirroring its own VM-level undo events. */
1186− private val _externalSessionUndoEvents = MutableSharedFlow<String>(
1187− extraBufferCapacity = NetworkConfig.snackbarEventBufferCapacity,
1188− onBufferOverflow = kotlinx.coroutines.channels.BufferOverflow.DROP_OLDEST,
1189− )
1190− val externalSessionUndoEvents: SharedFlow<String> = _externalSessionUndoEvents.asSharedFlow()
1191−
1192− /** Emit an undo event for a session delete initiated outside the session list (e.g.
1193− * from the chat screen). The SessionListScreen collects these to show an Undo snackbar. */
1194− fun emitExternalSessionUndo(sessionId: String) {
1195− _externalSessionUndoEvents.tryEmit(sessionId)
1196− }
1197−
1198− /**
1199− * Schedule session [id] for deletion after [delayMs], cancellable via [cancelSessionDelete]
1200− * (the Undo action). Also clears the session's stored draft on a successful delete.
1201− * [onDeleted]/[onError] run on [appScope] after the delete resolves so the caller can update
1202− * its optimistic-hide state. A repeated schedule for the same id replaces the prior timer.
1203− *
1204− * The owning connection is captured now, at schedule time — NOT re-resolved when the timer
1205− * fires. Otherwise a server switch during the undo window would send the delete to whichever
1206− * server happens to be active at fire time: the original session would never be deleted (its
1207− * row reappears on the next refresh) and, in the rare id-collision case, an unrelated session
1208− * on the new server could be deleted instead. A disconnect closes the captured connection's
1209− * HTTP client, so a delete deferred across a disconnect fails and is surfaced via [onError]
1210− * (the session cannot be deleted on a server we're no longer connected to). No-ops (as an
1211− * error) if there is no active connection at schedule time.
1212− */
1213− open fun scheduleSessionDelete(
1214− id: String,
1215− delayMs: Long,
1216− onDeleted: () -> Unit,
1217− onError: (Throwable) -> Unit,
1218− ) {
1219− val conn = _activeConnection.value
1220− val job = appScope.launch {
1221− delay(delayMs)
1222− // Claim the delete by removing our own entry BEFORE committing it — the atomic
1223− // remove is the sync point with cancelSessionDelete. Losing the race (an undo
1224− // removed us first) means we skip, honouring cancel's `true`; deleting first
1225− // would let a cancel land in the gap and falsely report undo of a gone session.
1226− // remove(id, thisJob) also no-ops for a stale job after a reschedule.
1227− val claimed = coroutineContext[Job]?.let { pendingSessionDeletes.remove(id, it) } == true
1228− if (!claimed) return@launch
1229− if (conn == null) {
1230− onError(IllegalStateException("No active connection"))
1231− } else {
1232− runCatchingCancellable { conn.repository.deleteSession(id) }
1233− .onSuccess {
1234− draftStore.remove(id)
1235− attachmentDraftStore.remove(id)
1236− sessionPrefsStore.forget(id)
1237− messageCacheStore.remove(conn.profile.id, id)
1238− outboxStore.removeForSession(id)
1239− clearUnread(id)
1240− onDeleted()
1241− }
1242− .onFailure { onError(it) }
1243− }
1244− }
1245− pendingSessionDeletes[id]?.cancel()
1246− pendingSessionDeletes[id] = job
1247− }
1248−
1249− /** Cancel a pending deferred session delete (the Undo action). Returns true if it was
1250− * still pending (undo succeeded), false if the delete had already fired. */
1251− open fun cancelSessionDelete(id: String): Boolean {
1252− val job = pendingSessionDeletes.remove(id) ?: return false
1253− job.cancel()
1254− return true
1255− }
1256−
1257− /**
1258− * Probe a server URL (without credentials) to check reachability and detect whether
1259− * the server requires authentication. Builds a short-lived HTTP client with the same
1260− * base URL normalization as [connect] but no auth, calls the health endpoint, and
1261− * classifies the outcome:
1262− *
1263− * - 2xx → [ProbeResult.Reachable] (no auth needed)
1264− * - 401/403 → [ProbeResult.NeedsAuth]
1265− * - anything else → [ProbeResult.Unreachable] with a user-facing message
1266− *
1267− * The probe client is always closed afterwards so no resources linger. This does not
1268− * touch the active connection or the profile store.
1269− */
1270− open suspend fun probeServer(baseUrl: String, requireHttps: Boolean, certPin: String?): ProbeResult {
1271− return probeWithProfile(ServerProfile(
1272− id = "probe",
1273− label = "",
1274− baseUrl = baseUrl.trim(),
1275− username = null,
1276− password = null,
1277− // Probe the same effective URL save()/connect() will use: HttpClientFactory upgrades
1278− // http->https and installs the cert pinner when these are set, so a probe without them
1279− // would validate a plain-http endpoint the real connection never talks to.
1280− requireHttps = requireHttps,
1281− certPin = certPin,
1282− ))
1283− }
1284−
1285− /**
1286− * Probe a server URL *with* credentials to validate them. Returns true if the
1287− * server accepts the credentials (ping succeeds), false on 401/403, and throws
1288− * on other failures (unreachable, timeout) so the caller can surface a friendly
1289− * message. Mirrors [probeServer]'s short-lived-client pattern.
1290− */
1291− open suspend fun probeWithCredentials(
1292− baseUrl: String,
1293− username: String,
1294− password: String,
1295− requireHttps: Boolean,
1296− certPin: String?,
1297− ): Boolean {
1298− val probeProfile = ServerProfile(
1299− id = "probe-auth",
1300− label = "",
1301− baseUrl = baseUrl.trim(),
1302− username = username.trim().takeIf { it.isNotBlank() },
1303− password = password.trim().takeIf { it.isNotEmpty() },
1304− // Match the effective URL/pinning save()/connect() will use (see probeServer).
1305− requireHttps = requireHttps,
1306− certPin = certPin,
1307− )
1308− val client = HttpClientFactory.create(probeProfile)
1309− return try {
1310− val api = OpencodeApiClient(client)
1311− api.ping()
1312− true
1313− } catch (c: kotlinx.coroutines.CancellationException) {
1314− throw c
1315− } catch (e: Exception) {
1316− val status = responseStatusCode(e)
1317− if (status == 401 || status == 403) false else throw e
1318− } finally {
1319− runCatching { client.close() }
1320− }
1321− }
1322−
1323− private suspend fun probeWithProfile(profile: ServerProfile): ProbeResult {
1324− val client = HttpClientFactory.create(profile)
1325− return try {
1326− val api = OpencodeApiClient(client)
1327− api.ping()
1328− ProbeResult.Reachable
1329− } catch (c: kotlinx.coroutines.CancellationException) {
1330− throw c
1331− } catch (e: Exception) {
1332− val status = responseStatusCode(e)
1333− if (status == 401 || status == 403) {
1334− ProbeResult.NeedsAuth
1335− } else {
1336− ProbeResult.Unreachable(friendlyErrorFor(e, profile.baseUrl))
1337− }
1338− } finally {
1339− runCatching { client.close() }
1340− }
1341− }
1342−
1343− // Declared last so every property above is fully initialized before these observers start.
1344− // They only launch coroutines into appScope (Dispatchers.IO) — nothing here runs
1345− // synchronously during construction — so placing init at the end removes any dependence on
1346− // the textual declaration order of the fields those observers reference.
1347− init {
1348− if (!skipInit) {
1349− NotificationChannels.create(appContext!!)
1350− observeMessageActivity()
1351− observeRunReconcileOnReconnect()
1352− observeRunForegroundService()
1353− // Flush right after load() populates the queue. observeOutbox()'s combine is keyed
1354− // on activeConnection/isOnline/_outboxFlushTrigger — NOT on outboxStore.messages — so
1355− // if connect() sets activeConnection before load() finishes reading a (possibly
1356− // multi-MB) outbox.json, the connect-triggered flush sees an empty queue and nothing
1357− // re-triggers it. Nudging the flush here guarantees a loaded queue is sent.
1358− appScope.launch {
1359− runCatchingCancellable { outboxStore.load() }
1360− flushOutbox()
1361− }
1362− observeOutbox()
1363− appScope.launch { autoConnect() }
1364− }
1365− }
1366−}
1367−
1368−/**
1369− * Extract the session id a message-activity event pertains to, or null for events that
1370− * don't describe message activity. This drives the unread badge: any session that
1371− * receives activity while it isn't the currently-viewed one gets badged. Extracted as
1372− * a top-level `internal` function so the rule is unit-testable without an Android
1373− * [Context] (the surrounding [AppContainer] needs one).
1374− */
1375−internal fun sessionOfEvent(event: BusEvent): String? = when (event) {
1376− is MessageUpdated -> event.properties.info.sessionID
1377− is MessagePartUpdated -> event.properties.part.sessionID ?: event.properties.sessionID
1378− else -> null
1379−}
1380−
1381−/**
1382− * The message id a message-activity event pertains to, used to de-duplicate the unread
1383− * badge so a reply's many streaming events count once. Null for events that carry no
1384− * message id (those fall back to being counted individually).
1385− */
1386−internal fun messageIdOfEvent(event: BusEvent): String? = when (event) {
1387− is MessageUpdated -> event.properties.info.id
1388− is MessagePartUpdated -> event.properties.messageID ?: event.properties.part.messageID
1389− else -> null
1390−}
app/src/main/java/soy/iko/opencode/di/OpencodeConnection.ktdeleted−47
@@ -1,47 +0,0 @@
1−package soy.iko.opencode.di
2−
3−import soy.iko.opencode.data.model.ServerProfile
4−import soy.iko.opencode.data.network.EventStreamClient
5−import soy.iko.opencode.data.network.HttpClientFactory
6−import soy.iko.opencode.data.network.OpencodeApiClient
7−import soy.iko.opencode.data.repo.MessageCacheStore
8−import soy.iko.opencode.data.repo.SessionRepository
9−import kotlinx.coroutines.CoroutineScope
10−import kotlinx.coroutines.Dispatchers
11−import kotlinx.coroutines.SupervisorJob
12−import kotlinx.coroutines.cancel
13−
14−/**
15− * An active connection to one opencode server. Bundles the Ktor client, REST API,
16− * SSE event stream, and the reducing repository, all sharing a connection-scoped
17− * coroutine scope that is cancelled on [close].
18− */
19−open class OpencodeConnection(
20− val profile: ServerProfile,
21− // Optional on-disk message cache, supplied by AppContainer, so conversations render
22− // instantly and stay readable offline. Null in the test constructor.
23− private val messageCache: MessageCacheStore? = null,
24−) {
25−
26− protected constructor() : this(ServerProfile(id = "", label = "", baseUrl = "http://localhost"), null)
27−
28− private val scopeJob = SupervisorJob()
29− private val scope by lazy { CoroutineScope(scopeJob + Dispatchers.IO) }
30− private val client by lazy { HttpClientFactory.create(profile) }
31−
32− open val api: OpencodeApiClient by lazy { OpencodeApiClient(client) }
33− open val events: EventStreamClient by lazy { EventStreamClient(client, scope) }
34− open val repository: SessionRepository by lazy { SessionRepository(api, events, messageCache, profile.id) }
35−
36− /**
37− * Cancel the coroutine scope and wait for in-flight work (including the SSE reader)
38− * to wind down before closing the HTTP client. Calling client.close() while the SSE
39− * coroutine is still mid-request produces a spurious "stream error, will retry"
40− * warning during normal teardown.
41− */
42− open suspend fun close() {
43− scope.cancel()
44− scopeJob.join()
45− runCatching { client.close() }
46− }
47−}
app/src/main/java/soy/iko/opencode/notification/NotificationActionReceiver.ktdeleted−175
@@ -1,175 +0,0 @@
1−package soy.iko.opencode.notification
2−
3−import android.content.BroadcastReceiver
4−import android.content.Context
5−import android.content.Intent
6−import androidx.core.app.RemoteInput
7−import soy.iko.opencode.OpencodeApp
8−import soy.iko.opencode.data.model.PermissionResponse
9−import soy.iko.opencode.data.network.NetworkConfig
10−import java.util.concurrent.atomic.AtomicBoolean
11−
12−/**
13− * Handles the action buttons on the app's notifications so the user can respond without
14− * opening the app — the core of the "kick it off and walk away" flow:
15− *
16− * - [ACTION_PERMISSION] with a [PermissionResponse] → replies to a paused tool's permission
17− * request (Allow once / Always / Reject) via the active connection.
18− * - [ACTION_REPLY] with a [RemoteInput] text payload → sends a follow-up prompt to a session
19− * that just finished a run.
20− *
21− * The receiver is not exported (see the manifest); the [PendingIntent]s that target it are
22− * created by the app itself. Work is done on the process-lived app scope via [OpencodeApp]'s
23− * container, and [goAsync] keeps the receiver alive until the (network) call resolves.
24− *
25− * A watchdog finishes the [PendingResult] within [NetworkConfig.notificationReceiverTimeoutMs]
26− * so a slow network call (e.g. a permission respond retried with exponential backoff) can't
27− * ANR the receiver. The underlying call keeps running on the app scope; only the receiver's
28− * lifetime is bounded. [finishOnce] guards against a double finish() (which would throw).
29− */
30−class NotificationActionReceiver : BroadcastReceiver() {
31−
32− override fun onReceive(context: Context, intent: Intent) {
33− val container = (context.applicationContext as? OpencodeApp)?.container ?: return
34− if (intent.action == ACTION_STOP_RUN) {
35− // Stop action from the foreground-service notification. The session id may be
36− // absent if multiple runs were active (generic notification); in that case we
37− // can't target a specific run — just let it ride.
38− val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() } ?: return
39− val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
40− val pending = goAsync()
41− val finished = AtomicBoolean(false)
42− var watchdogThread: Thread? = null
43− val finishOnce = {
44− if (finished.compareAndSet(false, true)) {
45− watchdogThread?.interrupt()
46− pending.finish()
47− }
48− }
49− watchdogThread = watchdog(finishOnce)
50− container.abortRunFromNotification(sessionId, profileId) { _ -> finishOnce() }
51− return
52− }
53− val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() } ?: return
54− when (intent.action) {
55− ACTION_MARK_READ -> {
56− // "Mark read" from a completion notification — clear the unread badge and
57− // dismiss the notification without opening the session.
58− container.markSessionRead(sessionId)
59− SessionNotifications.cancel(context, sessionId)
60− return
61− }
62− ACTION_PERMISSION -> {
63− val permissionId = intent.getStringExtra(EXTRA_PERMISSION_ID)?.takeIf { it.isNotBlank() } ?: return
64− val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
65− val response = PermissionResponse.entries.firstOrNull {
66− it.wire == intent.getStringExtra(EXTRA_RESPONSE)
67− } ?: return
68− val pending = goAsync()
69− val finished = AtomicBoolean(false)
70− var watchdogThread: Thread? = null
71− val finishOnce = {
72− if (finished.compareAndSet(false, true)) {
73− watchdogThread?.interrupt()
74− pending.finish()
75− }
76− }
77− watchdogThread = watchdog(finishOnce)
78− container.respondToPermissionFromNotification(sessionId, permissionId, response, profileId) { success ->
79− // Only dismiss the notification once the tool was actually answered. If
80− // there was no live connection (respond is a no-op) or the call failed,
81− // leave it up so the user can retry — otherwise it vanishes with the
82− // permission request left unanswered.
83− if (success) SessionNotifications.cancelPermission(context, sessionId)
84− finishOnce()
85− }
86− }
87− ACTION_REPLY -> {
88− val text = RemoteInput.getResultsFromIntent(intent)
89− ?.getCharSequence(KEY_REPLY_TEXT)?.toString()?.trim()
90− ?.takeIf { it.isNotEmpty() }
91− if (text == null) {
92− // The RemoteInput fired but the reply was empty/whitespace. SystemUI locks the
93− // inline field in a "sending…" state until the notification is updated or
94− // cancelled — cancel it so the field doesn't stay stuck with nothing to send.
95− SessionNotifications.cancel(context, sessionId)
96− return
97− }
98− val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
99− val pending = goAsync()
100− val finished = AtomicBoolean(false)
101− var watchdogThread: Thread? = null
102− val finishOnce = {
103− if (finished.compareAndSet(false, true)) {
104− watchdogThread?.interrupt()
105− pending.finish()
106− }
107− }
108− watchdogThread = watchdog(finishOnce)
109− container.sendPromptFromNotification(sessionId, text, profileId) { enqueued ->
110− // The reply is durably queued (and flushed now if online). Show a brief
111− // "Sent" confirmation by updating the completion notification's text, then
112− // let it auto-cancel on tap — instead of vanishing silently. If the enqueue
113− // failed, post a "reply not sent" error: leaving the completion notification
114− // up strands SystemUI's RemoteInput field in a permanent "sending…" spinner
115− // that can't be resubmitted, so "leave it up to retry" doesn't actually let
116− // the user retry.
117− if (enqueued) {
118− SessionNotifications.postReplySent(context, sessionId)
119− } else {
120− SessionNotifications.postReplyFailed(context, sessionId, profileId)
121− }
122− finishOnce()
123− }
124− }
125− ACTION_RETRY_LAST -> {
126− // "Retry last" from an error notification: re-send the most recent user prompt
127− // for this session without opening the app. Cancels the error notification on
128− // success so the user sees the retry took effect.
129− val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
130− val pending = goAsync()
131− val finished = AtomicBoolean(false)
132− var watchdogThread: Thread? = null
133− val finishOnce = {
134− if (finished.compareAndSet(false, true)) {
135− watchdogThread?.interrupt()
136− pending.finish()
137− }
138− }
139− watchdogThread = watchdog(finishOnce)
140− container.retryLastFromNotification(sessionId, profileId) { success ->
141− if (success) SessionNotifications.cancel(context, sessionId)
142− finishOnce()
143− }
144− }
145− }
146− }
147−
148− /** Schedule a deadline after which the receiver's [PendingResult] is finished even if
149− * the network call hasn't resolved, so the system can't ANR the receiver. The
150− * underlying work continues on the app scope. Returns the watchdog thread so the
151− * caller can [interrupt][Thread.interrupt] it when work completes early, releasing
152− * the sleeping thread immediately instead of leaving it for the full timeout. */
153− private fun watchdog(finishOnce: () -> Unit): Thread =
154− Thread {
155− try {
156− Thread.sleep(NetworkConfig.notificationReceiverTimeoutMs)
157− } catch (_: InterruptedException) {
158− return@Thread
159− }
160− finishOnce()
161− }.apply { isDaemon = true; name = "notif-receiver-watchdog"; start() }
162−
163− companion object {
164− const val ACTION_PERMISSION = "soy.iko.opencode.action.PERMISSION"
165− const val ACTION_REPLY = "soy.iko.opencode.action.REPLY"
166− const val ACTION_STOP_RUN = "soy.iko.opencode.action.STOP_RUN"
167− const val ACTION_MARK_READ = "soy.iko.opencode.action.MARK_READ"
168− const val ACTION_RETRY_LAST = "soy.iko.opencode.action.RETRY_LAST"
169− const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
170− const val EXTRA_PERMISSION_ID = "soy.iko.opencode.extra.PERMISSION_ID"
171− const val EXTRA_PROFILE_ID = "soy.iko.opencode.extra.PROFILE_ID"
172− const val EXTRA_RESPONSE = "soy.iko.opencode.extra.RESPONSE"
173− const val KEY_REPLY_TEXT = "soy.iko.opencode.key.REPLY_TEXT"
174− }
175−}
app/src/main/java/soy/iko/opencode/notification/NotificationChannels.ktdeleted−65
@@ -1,65 +0,0 @@
1−package soy.iko.opencode.notification
2−
3−import android.app.NotificationChannel
4−import android.app.NotificationManager
5−import android.content.Context
6−import soy.iko.opencode.R
7−
8−/**
9− * The app's notification channels. Created once at startup (idempotent — recreating an
10− * existing channel is a no-op). Two channels keep user control granular:
11− *
12− * - [STATUS]: low-importance, no sound. Used by the foreground service's persistent
13− * "agent running" indicator.
14− * - [COMPLETED]: default importance. Used when a background session finishes a run.
15− * - [PERMISSION]: high importance (heads-up + sound). Used when a paused tool run needs
16− * the user's approval — the whole point is to reach the user who has walked away, so it
17− * interrupts rather than sitting silently in the shade.
18− * - [ERROR]: default importance. Used when a background run fails.
19− */
20−object NotificationChannels {
21− const val STATUS = "run_status"
22− const val COMPLETED = "session_completed"
23− const val PERMISSION = "permission_request"
24− const val ERROR = "session_error"
25− const val UNREAD = "unread_badge"
26−
27− fun create(context: Context) {
28− val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return
29− nm.createNotificationChannel(
30− NotificationChannel(STATUS, context.getString(R.string.notif_channel_status), NotificationManager.IMPORTANCE_LOW).apply {
31− description = context.getString(R.string.notif_channel_status_desc)
32− setShowBadge(false)
33− },
34− )
35− nm.createNotificationChannel(
36− NotificationChannel(COMPLETED, context.getString(R.string.notif_channel_completed), NotificationManager.IMPORTANCE_DEFAULT).apply {
37− description = context.getString(R.string.notif_channel_completed_desc)
38− },
39− )
40− nm.createNotificationChannel(
41− NotificationChannel(PERMISSION, context.getString(R.string.notif_channel_permission), NotificationManager.IMPORTANCE_HIGH).apply {
42− description = context.getString(R.string.notif_channel_permission_desc)
43− },
44− )
45− nm.createNotificationChannel(
46− // HIGH importance so a failed run stands out from a completed one by
47− // sound/vibration — both are actionable, but a failure is time-sensitive.
48− NotificationChannel(ERROR, context.getString(R.string.notif_channel_error), NotificationManager.IMPORTANCE_HIGH).apply {
49− description = context.getString(R.string.notif_channel_error_desc)
50− },
51− )
52− // A silent, badge-only channel that carries the launcher icon's unread count (the only
53− // way to badge the launcher icon on Android is via a notification on a badge-enabled
54− // channel). Low importance + no sound/vibration so it never alerts; setShowBadge(true)
55− // so its number renders on the launcher icon.
56− nm.createNotificationChannel(
57− NotificationChannel(UNREAD, context.getString(R.string.notif_channel_unread), NotificationManager.IMPORTANCE_MIN).apply {
58− description = context.getString(R.string.notif_channel_unread_desc)
59− setShowBadge(true)
60− setSound(null, null)
61− enableVibration(false)
62− },
63− )
64− }
65−}
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.ktdeleted−187
@@ -1,187 +0,0 @@
1−package soy.iko.opencode.notification
2−
3−import android.app.Notification
4−import android.app.PendingIntent
5−import android.app.Service
6−import android.content.Context
7−import android.content.Intent
8−import android.content.pm.ServiceInfo
9−import android.os.Build
10−import android.os.IBinder
11−import android.util.Log
12−import android.annotation.SuppressLint
13−import androidx.core.app.NotificationCompat
14−import androidx.core.app.NotificationManagerCompat
15−import androidx.core.app.ServiceCompat
16−import soy.iko.opencode.MainActivity
17−import soy.iko.opencode.R
18−
19−/**
20− * A foreground service kept alive while an opencode agent is actively running. Holding a
21− * foreground priority lets the long-lived SSE `/event` subscription survive backgrounding
22− * (Doze/app-standby would otherwise choke the socket and stall streaming mid-run).
23− *
24− * The service is started when a run begins ([start]) and stopped when it goes idle
25− * ([stop]). It shows a low-importance "Agent is working…" notification in [NotificationChannels.STATUS].
26− * Started/stopped via Compose from the chat screen based on the `running` state.
27− */
28−class RunForegroundService : Service() {
29−
30− override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
31− // The session title (when a single run is active) is passed via the intent extra so
32− // the notification can identify which session is running, instead of a generic
33− // "Agent is working…". Null/blank falls back to the generic title.
34− val sessionTitle = intent?.getStringExtra(EXTRA_SESSION_TITLE)?.takeIf { it.isNotBlank() }
35− val sessionId = intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
36− val progress = intent?.getStringExtra(EXTRA_PROGRESS)?.takeIf { it.isNotBlank() }
37− val notification = buildNotification(sessionTitle, sessionId, progress)
38− // startForeground can throw ForegroundServiceStartNotAllowedException on
39− // Android 12+ if the app is in the background when the service starts. Wrap
40− // it so a backgrounded start (e.g. the user navigates away at the wrong
41− // moment) doesn't crash the app — the SSE stream will just continue without
42− // foreground priority and may be killed by the system sooner.
43− runCatching {
44− ServiceCompat.startForeground(
45− this,
46− NOTIF_ID,
47− notification,
48− if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
49− ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
50− } else {
51− 0
52− },
53− )
54− }.onFailure {
55− Log.w(TAG, "startForeground failed; running without foreground priority", it)
56− stopSelf()
57− }
58− return START_NOT_STICKY
59− }
60−
61− private fun mainContentIntent(): PendingIntent {
62− // Tapping the notification opens the app so the user can see the running session.
63− val openIntent = Intent(this, MainActivity::class.java).apply {
64− flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
65− }
66− return PendingIntent.getActivity(
67− this, 0, openIntent,
68− PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
69− )
70− }
71−
72− private fun buildNotification(sessionTitle: String?, sessionId: String?, progress: String? = null): Notification {
73− val pendingIntent = mainContentIntent()
74− val contentText = progress ?: sessionTitle ?: getString(R.string.notif_running_text)
75− val builder = NotificationCompat.Builder(this, NotificationChannels.STATUS)
76− .setSmallIcon(R.drawable.ic_stat_notify)
77− .setColor(BRAND_COLOR)
78− .setContentTitle(getString(R.string.notif_running_title))
79− .setContentText(contentText)
80− .setContentIntent(pendingIntent)
81− .setOngoing(true)
82− // Show a live elapsed timer so the user can see how long the current run has
83− // been going — no per-tick plumbing needed; the system renders the chronometer.
84− .setWhen(System.currentTimeMillis())
85− .setUsesChronometer(true)
86− .setShowWhen(true)
87− .setPriority(NotificationCompat.PRIORITY_LOW)
88− .setCategory(NotificationCompat.CATEGORY_PROGRESS)
89− // An indeterminate progress bar so the notification reads as actively "in progress"
90− // at a glance (alongside the chronometer), matching the conventional run affordance.
91− .setProgress(0, 0, true)
92− // Add a Stop action so the user can cancel the run without opening the app — the
93− // core of the "kick it off and walk away" flow. Only when a specific session is
94− // known (multiple concurrent runs can't be targeted individually).
95− if (sessionId != null) {
96− val stopIntent = Intent(this, NotificationActionReceiver::class.java).apply {
97− action = NotificationActionReceiver.ACTION_STOP_RUN
98− putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
99− }
100− val stopPending = PendingIntent.getBroadcast(
101− this, sessionId.hashCode(), stopIntent,
102− PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
103− )
104− builder.addAction(
105− R.drawable.ic_action_stop,
106− getString(R.string.notif_action_stop),
107− stopPending,
108− )
109− }
110− return builder.build()
111− }
112−
113− // Android 14+ can time out a foreground service (notably the ~6h/day cumulative cap on
114− // dataSync in Android 15) and calls onTimeout expecting a prompt stop; not stopping risks
115− // the system force-stopping/ANR-ing the app. Stop cleanly — a still-active run just
116− // continues without foreground priority. Post a low-priority notification so the user
117− // knows their long-running task may be paused in the background, rather than silently
118− // losing foreground priority. Both overloads are covered (the 2-arg form is API 35+).
119− override fun onTimeout(startId: Int) {
120− Log.w(TAG, "Foreground service timed out; stopping")
121− postTimeoutNotification()
122− stopSelf()
123− }
124−
125− override fun onTimeout(startId: Int, fgsType: Int) {
126− Log.w(TAG, "Foreground service timed out (type=$fgsType); stopping")
127− postTimeoutNotification()
128− stopSelf()
129− }
130−
131− /** Post a low-priority notification informing the user that the foreground service was
132− * timed out by the system — the run continues but may be killed by Doze without warning. */
133− @SuppressLint("MissingPermission")
134− private fun postTimeoutNotification() {
135− runCatching {
136− val notification = NotificationCompat.Builder(this, NotificationChannels.STATUS)
137− .setSmallIcon(R.drawable.ic_stat_notify)
138− .setColor(BRAND_COLOR)
139− .setContentTitle(getString(R.string.notif_running_title))
140− .setContentText(getString(R.string.notif_fg_timeout_text))
141− .setContentIntent(mainContentIntent())
142− .setAutoCancel(true)
143− .setPriority(NotificationCompat.PRIORITY_LOW)
144− .build()
145− NotificationManagerCompat.from(this).notify(NOTIF_TIMEOUT_ID, notification)
146− }
147− }
148−
149− override fun onDestroy() {
150− // Safety net: if the process is being killed while a run is in progress,
151− // ensure the notification is removed rather than lingering.
152− ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
153− super.onDestroy()
154− }
155−
156− override fun onBind(intent: Intent?): IBinder? = null
157−
158− companion object {
159− private const val TAG = "RunForegroundService"
160− private const val NOTIF_ID = 1
161− private const val NOTIF_TIMEOUT_ID = 2
162− // Brand accent for the foreground notifications' small-icon tint circle.
163− private val BRAND_COLOR = 0xFF34548A.toInt()
164− const val EXTRA_SESSION_TITLE = "soy.iko.opencode.extra.SESSION_TITLE"
165− const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
166− const val EXTRA_PROGRESS = "soy.iko.opencode.extra.PROGRESS"
167−
168− fun start(context: Context, sessionTitle: String? = null, sessionId: String? = null, progress: String? = null) {
169− // startForegroundService can throw ForegroundServiceStartNotAllowedException
170− // on Android 12+ if the app is in the background. Wrap it so a backgrounded
171− // start (e.g. the user navigates away at the wrong moment) doesn't crash.
172− runCatching {
173− val intent = Intent(context, RunForegroundService::class.java)
174− sessionTitle?.let { intent.putExtra(EXTRA_SESSION_TITLE, it) }
175− sessionId?.let { intent.putExtra(EXTRA_SESSION_ID, it) }
176− progress?.let { intent.putExtra(EXTRA_PROGRESS, it) }
177− context.startForegroundService(intent)
178− }.onFailure {
179− Log.w(TAG, "startForegroundService failed; running without foreground priority", it)
180− }
181− }
182−
183− fun stop(context: Context) {
184− context.stopService(Intent(context, RunForegroundService::class.java))
185− }
186− }
187−}
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.ktdeleted−438
@@ -1,438 +0,0 @@
1−package soy.iko.opencode.notification
2−
3−import android.annotation.SuppressLint
4−import android.app.Notification
5−import android.app.NotificationManager
6−import android.app.PendingIntent
7−import android.content.Context
8−import android.content.Intent
9−import android.os.Build
10−import android.util.Log
11−import androidx.core.app.NotificationCompat
12−import androidx.core.app.NotificationManagerCompat
13−import androidx.core.app.RemoteInput
14−import soy.iko.opencode.MainActivity
15−import soy.iko.opencode.R
16−import soy.iko.opencode.data.model.Permission
17−import soy.iko.opencode.data.model.PermissionResponse
18−import java.security.MessageDigest
19−
20−/**
21− * Builds and posts the app's session notifications:
22− *
23− * - [postCompleted]: a "session ready" notification when a background run finishes, with an
24− * inline [RemoteInput] Reply action so a follow-up can be sent without opening the app.
25− * - [postPermission]: a heads-up notification when a paused tool needs approval, with
26− * Allow once / Always / Reject action buttons handled by [NotificationActionReceiver].
27− * - [postError]: a notification when a background run fails.
28− *
29− * Tapping a notification body opens the session via [MainActivity] with an
30− * [MainActivity.EXTRA_SESSION_ID] extra. Notification posting is guarded for Android 13+
31− * runtime permission: if the user hasn't granted POST_NOTIFICATIONS, the post is silently
32− * skipped rather than throwing.
33− */
34−object SessionNotifications {
35−
36− private const val NOTIF_ID_PREFIX = 4000
37− private const val TAG = "SessionNotifications"
38− private const val UNREAD_BADGE_ID = NOTIF_ID_PREFIX + 9999
39−
40− // Distinct namespaces so a session can have a completion, a permission, and an error
41− // notification outstanding at once without their ids colliding.
42− private const val NS_COMPLETED = "done"
43− private const val NS_PERMISSION = "perm"
44− private const val NS_ERROR = "err"
45−
46− private val notifIdRegex = Regex("[^A-Za-z0-9_-]")
47−
48− /** Derive a stable, collision-resistant notification id from a namespace + session id.
49− * String.hashCode() can collide for different inputs; a SHA-256 truncated to 31
50− * bits makes accidental collisions astronomically unlikely. The namespace keeps the
51− * three notification kinds for one session in separate id spaces. */
52− private fun notifId(namespace: String, sessionId: String): Int {
53− val digest = runCatching { MessageDigest.getInstance("SHA-256") }
54− .getOrNull()?.digest("$namespace:$sessionId".toByteArray()) ?: return NOTIF_ID_PREFIX
55− // Take the first 4 bytes, mask to 31 bits (always positive) to fit an Int id.
56− // Kotlin's shl/or/and are equal-precedence infix functions evaluated strictly
57− // left-to-right, so the shifts MUST be parenthesized individually — otherwise
58− // `a shl 24 or b shl 16` parses as `((a shl 24) or b) shl 16`, shifting the first
59− // byte out of the Int entirely and collapsing the id's entropy.
60− val hash = (((digest[0].toInt() and 0xFF) shl 24) or
61− ((digest[1].toInt() and 0xFF) shl 16) or
62− ((digest[2].toInt() and 0xFF) shl 8) or
63− (digest[3].toInt() and 0xFF)).and(0x7FFFFFFF)
64− // Offset by NOTIF_ID_PREFIX, staying within the positive Int range.
65− // Using modulo ensures the prefix is preserved without Int overflow.
66− return NOTIF_ID_PREFIX + (hash % (0x7FFFFFFF - NOTIF_ID_PREFIX))
67− }
68−
69− /** Respect the POST_NOTIFICATIONS runtime permission (Android 13+): if the user denied
70− * it, callers should skip posting instead of throwing. Foreground-service notifications
71− * are exempt, so the running indicator still shows during a run. */
72− private fun canPost(context: Context): Boolean =
73− Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
74− androidx.core.content.ContextCompat.checkSelfPermission(
75− context, android.Manifest.permission.POST_NOTIFICATIONS,
76− ) == android.content.pm.PackageManager.PERMISSION_GRANTED
77−
78− /** A PendingIntent that opens [sessionId] in the app when the notification body is tapped.
79− * [profileId] (the originating server) is embedded so a tap after the user has switched
80− * servers routes back to the server that ran the session, not whichever is active —
81− * mirroring the Reply and permission action intents. */
82− private fun openSessionIntent(
83− context: Context, sessionId: String, requestCode: Int, profileId: String?,
84− ): PendingIntent {
85− // Sanitize the session id before embedding it in the deep-link URI so characters like
86− // /, ?, # can't inject path segments or query parameters.
87− val safeId = notifIdRegex.replace(sessionId, "")
88− val openIntent = Intent(context, MainActivity::class.java).apply {
89− action = Intent.ACTION_VIEW
90− data = android.net.Uri.parse("opencode://session/$safeId")
91− flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
92− profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
93− }
94− return PendingIntent.getActivity(
95− context, requestCode, openIntent,
96− PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
97− )
98− }
99−
100− // canPost() checks POST_NOTIFICATIONS before every notify(); lint can't follow the
101− // check through the helper, so suppress its MissingPermission flag on these posts.
102− @SuppressLint("MissingPermission")
103− fun postCompleted(context: Context, sessionId: String, title: String, profileId: String?) {
104− if (!canPost(context)) return
105− val notifId = notifId(NS_COMPLETED, sessionId)
106−
107− // Inline reply: a RemoteInput-backed action that broadcasts the typed follow-up to
108− // NotificationActionReceiver. The PendingIntent MUST be mutable so the system can
109− // fill in the RemoteInput results before delivering it. [profileId] is embedded so the
110− // receiver enqueues the reply against the server that ran the session, not whichever
111− // server is active when the user taps (mirrors postPermission).
112− val remoteInput = RemoteInput.Builder(NotificationActionReceiver.KEY_REPLY_TEXT)
113− .setLabel(context.getString(R.string.notif_reply_hint))
114− .build()
115− val replyIntent = Intent(context, NotificationActionReceiver::class.java).apply {
116− action = NotificationActionReceiver.ACTION_REPLY
117− putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
118− profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
119− }
120− val replyPending = PendingIntent.getBroadcast(
121− context, notifId, replyIntent,
122− PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
123− )
124− val replyAction = NotificationCompat.Action.Builder(
125− R.drawable.ic_action_reply,
126− context.getString(R.string.notif_action_reply),
127− replyPending,
128− ).addRemoteInput(remoteInput).setAllowGeneratedReplies(true).build()
129−
130− // "Mark read" action so the user can triage a completion without opening the session.
131− val markReadIntent = Intent(context, NotificationActionReceiver::class.java).apply {
132− action = NotificationActionReceiver.ACTION_MARK_READ
133− putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
134− profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
135− }
136− val markReadPending = PendingIntent.getBroadcast(
137− context, notifId + 100, markReadIntent,
138− PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
139− )
140− val markReadAction = NotificationCompat.Action.Builder(
141− R.drawable.ic_action_check,
142− context.getString(R.string.notif_action_mark_read),
143− markReadPending,
144− ).build()
145−
146− val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
147− .setSmallIcon(R.drawable.ic_stat_notify)
148− .setContentTitle(context.getString(R.string.notif_completed_title))
149− // No % escaping here: getString(id, arg) inserts the argument verbatim and
150− // never re-scans it for format specifiers, so escaping the title would show
151− // literal doubled percent signs (e.g. a "50% done" title as "50%% done").
152− .setContentText(context.getString(R.string.notif_completed_text, title))
153− .setAutoCancel(true)
154− .setOnlyAlertOnce(true)
155− .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
156− .addAction(replyAction)
157− .addAction(markReadAction)
158− .setCategory(NotificationCompat.CATEGORY_MESSAGE)
159− .setPriority(NotificationCompat.PRIORITY_DEFAULT)
160− // Group notifications from the same server so multiple completions collapse into
161− // a stackable group on Android 7+ instead of filling the shade with individual entries.
162− .setGroup(GROUP_COMPLETED)
163− .build()
164−
165− runCatching {
166− NotificationManagerCompat.from(context).notify(notifId, notification)
167− // Post a summary notification so the group has a single top-level entry in the shade.
168− postCompletedSummary(context)
169− }.onFailure { Log.w(TAG, "Failed to post completion notification", it) }
170− }
171−
172− /** Post (or update) a summary notification for the completed-sessions group so multiple
173− * completions collapse into one stackable entry instead of filling the shade. */
174− @SuppressLint("MissingPermission")
175− private fun postCompletedSummary(context: Context) {
176− val summary = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
177− .setSmallIcon(R.drawable.ic_stat_notify)
178− .setContentTitle(context.getString(R.string.notif_completed_title))
179− .setContentText(context.getString(R.string.app_name))
180− .setSmallIcon(R.drawable.ic_stat_notify)
181− .setAutoCancel(true)
182− .setCategory(NotificationCompat.CATEGORY_MESSAGE)
183− .setGroup(GROUP_COMPLETED)
184− .setGroupSummary(true)
185− .build()
186− runCatching {
187− NotificationManagerCompat.from(context).notify(SUMMARY_COMPLETED_ID, summary)
188− }.onFailure { Log.w(TAG, "Failed to post completion summary notification", it) }
189− }
190−
191− /** Post a heads-up notification for a permission request with Allow once / Always /
192− * Reject action buttons. [sessionTitle] labels the notification; [permission]'s title/
193− * pattern/type supplies the detail line. [profileId] is embedded in the action intents
194− * so the receiver routes the response back to the server that posted the request, not
195− * whichever server happens to be active when the user taps. */
196− @SuppressLint("MissingPermission")
197− fun postPermission(context: Context, permission: Permission, sessionTitle: String, profileId: String?) {
198− if (!canPost(context)) return
199− val sessionId = permission.sessionID.takeIf { it.isNotBlank() } ?: return
200− val notifId = notifId(NS_PERMISSION, sessionId)
201− val detail = permission.title?.takeIf { it.isNotBlank() }
202− ?: permission.patternText?.takeIf { it.isNotBlank() }
203− ?: permission.type?.takeIf { it.isNotBlank() }
204− ?: context.getString(R.string.notif_permission_fallback)
205−
206− val builder = NotificationCompat.Builder(context, NotificationChannels.PERMISSION)
207− .setSmallIcon(R.drawable.ic_stat_notify)
208− .setContentTitle(context.getString(R.string.notif_permission_title, sessionTitle))
209− .setContentText(detail)
210− .setStyle(NotificationCompat.BigTextStyle().bigText(detail))
211− .setAutoCancel(true)
212− .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
213− .setPriority(NotificationCompat.PRIORITY_HIGH)
214− .setGroup(GROUP_PERMISSION)
215− .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
216− .setPublicVersion(
217− NotificationCompat.Builder(context, NotificationChannels.PERMISSION)
218− .setSmallIcon(R.drawable.ic_stat_notify)
219− .setContentTitle(context.getString(R.string.notif_permission_public))
220− .build(),
221− )
222−
223− // One action per response. Each PendingIntent needs a distinct request code or
224− // FLAG_UPDATE_CURRENT would collapse them into one (the last extras win).
225− listOf(
226− Triple(PermissionResponse.ONCE, R.string.notif_action_allow_once, R.drawable.ic_action_check),
227− Triple(PermissionResponse.ALWAYS, R.string.notif_action_always, R.drawable.ic_action_check),
228− Triple(PermissionResponse.REJECT, R.string.notif_action_reject, R.drawable.ic_action_block),
229− ).forEach { (response, labelRes, iconRes) ->
230− val intent = Intent(context, NotificationActionReceiver::class.java).apply {
231− action = NotificationActionReceiver.ACTION_PERMISSION
232− putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
233− putExtra(NotificationActionReceiver.EXTRA_PERMISSION_ID, permission.id)
234− putExtra(NotificationActionReceiver.EXTRA_RESPONSE, response.wire)
235− profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
236− }
237− val pending = PendingIntent.getBroadcast(
238− context, notifId + response.ordinal + 1, intent,
239− PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
240− )
241− builder.addAction(iconRes, context.getString(labelRes), pending)
242− }
243−
244− runCatching { NotificationManagerCompat.from(context).notify(notifId, builder.build()) }
245− .onFailure { Log.w(TAG, "Failed to post permission notification", it) }
246− // Group permission notifications so a multi-session burst collapses into a stackable
247− // group instead of filling the shade — mirroring the completed-sessions grouping.
248− runCatching { postGroupSummary(context, NotificationChannels.PERMISSION, GROUP_PERMISSION, SUMMARY_PERMISSION_ID) }
249− .onFailure { Log.w(TAG, "Failed to post permission summary notification", it) }
250− }
251−
252− /** Post a notification when a background run fails. */
253− @SuppressLint("MissingPermission")
254− fun postError(context: Context, sessionId: String, title: String, profileId: String? = null) {
255− if (!canPost(context)) return
256− val notifId = notifId(NS_ERROR, sessionId)
257− val builder = NotificationCompat.Builder(context, NotificationChannels.ERROR)
258− .setSmallIcon(R.drawable.ic_stat_notify)
259− .setContentTitle(context.getString(R.string.notif_error_title))
260− .setContentText(context.getString(R.string.notif_error_text, title))
261− .setAutoCancel(true)
262− .setOnlyAlertOnce(true)
263− .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
264− .setCategory(NotificationCompat.CATEGORY_ERROR)
265− .setPriority(NotificationCompat.PRIORITY_DEFAULT)
266− // Group error notifications so a burst of failures collapses into a stackable group.
267− .setGroup(GROUP_ERROR)
268− .setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
269− .setPublicVersion(
270− NotificationCompat.Builder(context, NotificationChannels.ERROR)
271− .setSmallIcon(R.drawable.ic_stat_notify)
272− .setContentTitle(context.getString(R.string.notif_error_public))
273− .build(),
274− )
275− // A "Retry last" action lets the user re-run the failed prompt straight from the shade
276− // without opening the app — a failed run is exactly when a one-tap retry is most useful.
277− val retryIntent = Intent(context, NotificationActionReceiver::class.java).apply {
278− action = NotificationActionReceiver.ACTION_RETRY_LAST
279− putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
280− profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
281− }
282− val retryPending = PendingIntent.getBroadcast(
283− context, notifId + 10, retryIntent,
284− PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
285− )
286− builder.addAction(R.drawable.ic_action_refresh, context.getString(R.string.retry_last), retryPending)
287− val notification = builder.build()
288− runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
289− .onFailure { Log.w(TAG, "Failed to post error notification", it) }
290− runCatching { postGroupSummary(context, NotificationChannels.ERROR, GROUP_ERROR, SUMMARY_ERROR_ID) }
291− .onFailure { Log.w(TAG, "Failed to post error summary notification", it) }
292− }
293−
294− /** Update the completion notification to show a brief "Sent" confirmation after a
295− * successful inline reply, replacing the completion notification's content. Auto-cancels
296− * on tap so it doesn't linger. */
297− @SuppressLint("MissingPermission")
298− fun postReplySent(context: Context, sessionId: String) {
299− if (!canPost(context)) {
300− cancel(context, sessionId)
301− return
302− }
303− val notifId = notifId(NS_COMPLETED, sessionId)
304− val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
305− .setSmallIcon(R.drawable.ic_stat_notify)
306− .setContentTitle(context.getString(R.string.notif_reply_sent))
307− .setAutoCancel(true)
308− .setPriority(NotificationCompat.PRIORITY_LOW)
309− .setGroup(GROUP_COMPLETED)
310− .build()
311− runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
312− .onFailure { Log.w(TAG, "Failed to post reply-sent notification", it) }
313− }
314−
315− /** An inline reply couldn't be durably enqueued (no resolvable profile, or the outbox write
316− * threw). Cancel the completion notification so SystemUI's RemoteInput field doesn't stay
317− * stuck in the "sending…" spinner — a stuck field can't be resubmitted — and post an error
318− * notification so the reply isn't silently lost and the user can reopen the session to retry. */
319− @SuppressLint("MissingPermission")
320− fun postReplyFailed(context: Context, sessionId: String, profileId: String? = null) {
321− NotificationManagerCompat.from(context).cancel(notifId(NS_COMPLETED, sessionId))
322− maybeCancelSummary(context, GROUP_COMPLETED, SUMMARY_COMPLETED_ID)
323− if (!canPost(context)) return
324− val notifId = notifId(NS_ERROR, sessionId)
325− val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
326− .setSmallIcon(R.drawable.ic_stat_notify)
327− .setContentTitle(context.getString(R.string.notif_reply_failed_title))
328− .setContentText(context.getString(R.string.notif_reply_failed_text))
329− .setAutoCancel(true)
330− .setContentIntent(openSessionIntent(context, sessionId, notifId, profileId))
331− .setCategory(NotificationCompat.CATEGORY_ERROR)
332− .setPriority(NotificationCompat.PRIORITY_DEFAULT)
333− .build()
334− runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
335− .onFailure { Log.w(TAG, "Failed to post reply-failed notification", it) }
336− }
337−
338− /** FLAG_MUTABLE where required (Android 12+) so RemoteInput results can be injected. */
339− private fun mutableFlag(): Int =
340− if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
341−
342− /** Cancel a session's completion notification (e.g. when the user opens it). */
343− fun cancel(context: Context, sessionId: String) {
344− NotificationManagerCompat.from(context).cancel(notifId(NS_COMPLETED, sessionId))
345− maybeCancelSummary(context, GROUP_COMPLETED, SUMMARY_COMPLETED_ID)
346− }
347−
348− /**
349− * Reflect the total number of unread sessions as a launcher-icon badge. Android only badges
350− * the launcher icon via a notification on a badge-enabled channel, so this posts a silent,
351− * min-importance notification with [.setNumber] on the [NotificationChannels.UNREAD] channel
352− * when [totalUnread] > 0, and cancels it when it drops to 0. Skipped entirely without
353− * POST_NOTIFICATIONS permission (Android 13+) — the badge just won't show, no error.
354− */
355− @SuppressLint("MissingPermission")
356− fun updateUnreadBadge(context: Context, totalUnread: Int) {
357− val nm = NotificationManagerCompat.from(context)
358− if (totalUnread <= 0) {
359− nm.cancel(UNREAD_BADGE_ID)
360− return
361− }
362− if (!canPost(context)) return
363− val notification = NotificationCompat.Builder(context, NotificationChannels.UNREAD)
364− .setSmallIcon(R.drawable.ic_stat_notify)
365− .setContentTitle(context.getString(R.string.app_name))
366− .setContentText(context.resources.getQuantityString(R.plurals.unread_sessions, totalUnread, totalUnread))
367− .setNumber(totalUnread.coerceAtMost(999))
368− .setOnlyAlertOnce(true)
369− .setSilent(true)
370− .setShowWhen(false)
371− .setPriority(NotificationCompat.PRIORITY_MIN)
372− .build()
373− runCatching { nm.notify(UNREAD_BADGE_ID, notification) }
374− .onFailure { Log.w(TAG, "Failed to post unread badge notification", it) }
375− }
376−
377− /** Cancel a session's permission notification (on reply, or when the user opens it). */
378− fun cancelPermission(context: Context, sessionId: String) {
379− NotificationManagerCompat.from(context).cancel(notifId(NS_PERMISSION, sessionId))
380− maybeCancelSummary(context, GROUP_PERMISSION, SUMMARY_PERMISSION_ID)
381− }
382−
383− /** Cancel a session's error notification (e.g. when the user opens it). Without this a
384− * stale "run failed" notification lingers when the session is opened by any route other
385− * than tapping the notification itself (setAutoCancel only clears it on a direct tap). */
386− fun cancelError(context: Context, sessionId: String) {
387− NotificationManagerCompat.from(context).cancel(notifId(NS_ERROR, sessionId))
388− maybeCancelSummary(context, GROUP_ERROR, SUMMARY_ERROR_ID)
389− }
390−
391− /** Build the lock-screen (public) version of a notification: only a generic title, no
392− * detail line, so sensitive file paths / commands in the real content aren't revealed
393− * while the device is locked. */
394− /** When the last child of a group is dismissed, cancel its lingering summary so it
395− * doesn't sit empty in the shade. NotificationManagerCompat lacks getActiveNotifications,
396− * so reach for the platform NotificationManager. The children/summary are posted without
397− * a tag, so matching by [android.app.Notification.getGroup] (plus excluding the summary
398− * id) is the reliable way to tell if any child remains. */
399− private fun maybeCancelSummary(context: Context, group: String, summaryId: Int) {
400− val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager
401− ?: return
402− val active = try {
403− nm.activeNotifications
404− } catch (e: Exception) {
405− Log.w(TAG, "Failed to query active notifications for summary cleanup", e)
406− return
407− }
408− val hasChild = active.any { it.id != summaryId && it.notification?.group == group }
409− if (!hasChild) {
410− runCatching { NotificationManagerCompat.from(context).cancel(summaryId) }
411− .onFailure { Log.w(TAG, "Failed to cancel empty group summary", it) }
412− }
413− }
414−
415− private const val GROUP_COMPLETED = "soy.iko.opencode.COMPLETED"
416− private const val GROUP_PERMISSION = "soy.iko.opencode.PERMISSION"
417− private const val GROUP_ERROR = "soy.iko.opencode.ERROR"
418− private const val SUMMARY_COMPLETED_ID = -1
419− private const val SUMMARY_PERMISSION_ID = -2
420− private const val SUMMARY_ERROR_ID = -3
421−
422− /** Post a summary notification for a group so multiple entries collapse into one stackable
423− * entry in the shade instead of filling it. */
424− @SuppressLint("MissingPermission")
425− private fun postGroupSummary(context: Context, channelId: String, group: String, summaryId: Int) {
426− if (!canPost(context)) return
427− val summary = NotificationCompat.Builder(context, channelId)
428− .setSmallIcon(R.drawable.ic_stat_notify)
429− .setContentTitle(context.getString(R.string.app_name))
430− .setAutoCancel(true)
431− .setGroup(group)
432− .setGroupSummary(true)
433− .build()
434− runCatching {
435− NotificationManagerCompat.from(context).notify(summaryId, summary)
436− }.onFailure { Log.w(TAG, "Failed to post group summary notification", it) }
437− }
438−}
app/src/main/java/soy/iko/opencode/platform/AppShortcuts.ktdeleted−58
@@ -1,58 +0,0 @@
1−package soy.iko.opencode.platform
2−
3−import android.content.Context
4−import android.content.Intent
5−import android.net.Uri
6−import androidx.core.content.pm.ShortcutInfoCompat
7−import androidx.core.content.pm.ShortcutManagerCompat
8−import androidx.core.graphics.drawable.IconCompat
9−import soy.iko.opencode.MainActivity
10−import soy.iko.opencode.R
11−import soy.iko.opencode.data.repo.RecentSession
12−
13−/**
14− * Maintains the app's dynamic launcher shortcuts (long-press the icon): "New session" and
15− * "Resume last". Rebuilt whenever the recent-session list changes so "Resume last" points at
16− * the most recent conversation via the existing `opencode://session/{id}` deep link.
17− */
18−object AppShortcuts {
19−
20− private const val ID_NEW = "new_session"
21− private const val ID_RESUME = "resume_last"
22− private val idRegex = Regex("[^A-Za-z0-9_-]")
23−
24− fun update(context: Context, lastSession: RecentSession?) {
25− val ctx = context.applicationContext
26− val shortcuts = mutableListOf<ShortcutInfoCompat>()
27−
28− shortcuts += ShortcutInfoCompat.Builder(ctx, ID_NEW)
29− .setShortLabel(ctx.getString(R.string.shortcut_new_short))
30− .setLongLabel(ctx.getString(R.string.shortcut_new_long))
31− .setIcon(IconCompat.createWithResource(ctx, R.drawable.ic_launcher_foreground))
32− .setIntent(
33− Intent(ctx, MainActivity::class.java).apply { action = MainActivity.ACTION_NEW_SESSION },
34− )
35− .build()
36−
37− if (lastSession != null) {
38− val safeId = idRegex.replace(lastSession.id, "")
39− if (safeId.isNotEmpty()) {
40− shortcuts += ShortcutInfoCompat.Builder(ctx, ID_RESUME)
41− .setShortLabel(ctx.getString(R.string.shortcut_resume_short))
42− .setLongLabel(
43− lastSession.title.ifBlank { ctx.getString(R.string.shortcut_resume_long) }.take(40),
44− )
45− .setIcon(IconCompat.createWithResource(ctx, R.drawable.ic_launcher_foreground))
46− .setIntent(
47− Intent(ctx, MainActivity::class.java).apply {
48− action = Intent.ACTION_VIEW
49− data = Uri.parse("opencode://session/$safeId")
50− },
51− )
52− .build()
53− }
54− }
55−
56− runCatching { ShortcutManagerCompat.setDynamicShortcuts(ctx, shortcuts) }
57− }
58−}
app/src/main/java/soy/iko/opencode/platform/NewSessionTileService.ktdeleted−33
@@ -1,33 +0,0 @@
1−package soy.iko.opencode.platform
2−
3−import android.app.PendingIntent
4−import android.content.Intent
5−import android.os.Build
6−import android.service.quicksettings.TileService
7−import soy.iko.opencode.MainActivity
8−
9−/**
10− * Quick-settings tile that starts a new opencode session. Tapping launches [MainActivity]
11− * with [MainActivity.ACTION_NEW_SESSION]; the app creates and opens a fresh session once
12− * connected. On Android 14+ [startActivityAndCollapse] requires a PendingIntent.
13− */
14−class NewSessionTileService : TileService() {
15−
16− override fun onClick() {
17− super.onClick()
18− val intent = Intent(this, MainActivity::class.java).apply {
19− action = MainActivity.ACTION_NEW_SESSION
20− addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
21− }
22− if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
23− val pending = PendingIntent.getActivity(
24− this, 0, intent,
25− PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
26− )
27− startActivityAndCollapse(pending)
28− } else {
29− @Suppress("DEPRECATION", "StartActivityAndCollapseDeprecated")
30− startActivityAndCollapse(intent)
31− }
32− }
33−}
app/src/main/java/soy/iko/opencode/platform/SessionsWidgetProvider.ktdeleted−83
@@ -1,83 +0,0 @@
1−package soy.iko.opencode.platform
2−
3−import android.app.PendingIntent
4−import android.appwidget.AppWidgetManager
5−import android.appwidget.AppWidgetProvider
6−import android.content.ComponentName
7−import android.content.Context
8−import android.content.Intent
9−import android.net.Uri
10−import android.os.Build
11−import android.widget.RemoteViews
12−import soy.iko.opencode.MainActivity
13−import soy.iko.opencode.R
14−
15−/**
16− * Home-screen widget listing recent opencode sessions (fed by
17− * [soy.iko.opencode.data.repo.RecentSessionsStore]) with a "New session" action. Each row deep-links
18− * into its session; the header opens the app. The list is backed by [SessionsWidgetService].
19− */
20−class SessionsWidgetProvider : AppWidgetProvider() {
21−
22− override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
23− for (id in appWidgetIds) render(context, appWidgetManager, id)
24− }
25−
26− companion object {
27− /** Re-render all widgets and tell them to reload their list data (call after the
28− * recent-session list changes). No-op when no widget is placed. */
29− fun refresh(context: Context) {
30− val manager = AppWidgetManager.getInstance(context) ?: return
31− val ids = runCatching {
32− manager.getAppWidgetIds(ComponentName(context, SessionsWidgetProvider::class.java))
33− }.getOrNull() ?: return
34− if (ids.isEmpty()) return
35− for (id in ids) render(context, manager, id)
36− manager.notifyAppWidgetViewDataChanged(ids, R.id.widget_list)
37− }
38−
39− private fun render(context: Context, manager: AppWidgetManager, widgetId: Int) {
40− val views = RemoteViews(context.packageName, R.layout.widget_sessions)
41−
42− // Header: title opens the app; the "+" starts a new session.
43− views.setOnClickPendingIntent(
44− R.id.widget_title,
45− activityPending(context, requestCode = 2, Intent(context, MainActivity::class.java)),
46− )
47− views.setOnClickPendingIntent(
48− R.id.widget_new,
49− activityPending(
50− context, requestCode = 1,
51− Intent(context, MainActivity::class.java).apply { action = MainActivity.ACTION_NEW_SESSION },
52− ),
53− )
54−
55− // Bind the list to the RemoteViewsService. A unique data Uri per widget id keeps
56− // the framework from reusing one adapter across widgets.
57− val serviceIntent = Intent(context, SessionsWidgetService::class.java).apply {
58− putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, widgetId)
59− data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
60− }
61− views.setRemoteAdapter(R.id.widget_list, serviceIntent)
62− views.setEmptyView(R.id.widget_list, R.id.widget_empty)
63−
64− // Template intent filled in per row (fill-in carries EXTRA_SESSION_ID). Must be
65− // mutable on Android 12+ so the row's fill-in can be merged.
66− val templateIntent = Intent(context, MainActivity::class.java).apply { action = Intent.ACTION_VIEW }
67− val mutableFlag = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
68− val templatePending = PendingIntent.getActivity(
69− context, 3, templateIntent, PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag,
70− )
71− views.setPendingIntentTemplate(R.id.widget_list, templatePending)
72−
73− manager.updateAppWidget(widgetId, views)
74− manager.notifyAppWidgetViewDataChanged(widgetId, R.id.widget_list)
75− }
76−
77− private fun activityPending(context: Context, requestCode: Int, intent: Intent): PendingIntent =
78− PendingIntent.getActivity(
79− context, requestCode, intent,
80− PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
81− )
82− }
83−}
app/src/main/java/soy/iko/opencode/platform/SessionsWidgetService.ktdeleted−68
@@ -1,68 +0,0 @@
1−package soy.iko.opencode.platform
2−
3−import android.content.Context
4−import android.content.Intent
5−import android.widget.RemoteViews
6−import android.widget.RemoteViewsService
7−import soy.iko.opencode.MainActivity
8−import soy.iko.opencode.R
9−import soy.iko.opencode.data.repo.RecentSession
10−import soy.iko.opencode.data.repo.RecentSessionsStore
11−import java.security.MessageDigest
12−
13−/** Serves the list rows for [SessionsWidgetProvider] from [RecentSessionsStore]. */
14−class SessionsWidgetService : RemoteViewsService() {
15− override fun onGetViewFactory(intent: Intent): RemoteViewsFactory =
16− SessionsWidgetFactory(applicationContext)
17−}
18−
19−private class SessionsWidgetFactory(private val context: Context) : RemoteViewsService.RemoteViewsFactory {
20−
21− private var items: List<RecentSession> = emptyList()
22−
23− override fun onCreate() { items = RecentSessionsStore.read(context) }
24−
25− // Re-read from disk whenever the app signals the data changed (notifyAppWidgetViewDataChanged).
26− override fun onDataSetChanged() { items = RecentSessionsStore.read(context) }
27−
28− override fun onDestroy() { items = emptyList() }
29−
30− override fun getCount(): Int = items.size
31−
32− override fun getViewAt(position: Int): RemoteViews {
33− val row = RemoteViews(context.packageName, R.layout.widget_session_item)
34− val session = items.getOrNull(position) ?: return row
35− row.setTextViewText(R.id.widget_item_title, session.title.ifBlank { context.getString(R.string.session) })
36− // Fill-in intent merged into the list's template: carries the session id the app opens.
37− row.setOnClickFillInIntent(
38− R.id.widget_item_root,
39− Intent().putExtra(MainActivity.EXTRA_SESSION_ID, session.id),
40− )
41− return row
42− }
43−
44− override fun getLoadingView(): RemoteViews? = null
45− override fun getViewTypeCount(): Int = 1
46− // Derive a stable id from the session id via a collision-resistant SHA-256 digest
47− // (truncated to 63 bits) rather than String.hashCode(), whose 32-bit output can
48− // collide for distinct session ids — with hasStableIds=true the RemoteViews framework
49− // would then mis-recycle two sessions' row content. Mirrors SessionNotifications.notifId.
50− // Falls back to the position for an out-of-range index.
51− override fun getItemId(position: Int): Long {
52− val id = items.getOrNull(position)?.id ?: return position.toLong()
53− val digest = runCatching { MessageDigest.getInstance("SHA-256").digest(id.toByteArray()) }
54− .getOrNull() ?: return position.toLong()
55− // Take the first 8 bytes, mask to 63 bits (always positive) to fit a Long id.
56− // The shl/or/and infix precedence requires parenthesizing each shift individually
57− // (see SessionNotifications.notifId for the rationale).
58− return (((digest[0].toLong() and 0xFF) shl 56) or
59− ((digest[1].toLong() and 0xFF) shl 48) or
60− ((digest[2].toLong() and 0xFF) shl 40) or
61− ((digest[3].toLong() and 0xFF) shl 32) or
62− ((digest[4].toLong() and 0xFF) shl 24) or
63− ((digest[5].toLong() and 0xFF) shl 16) or
64− ((digest[6].toLong() and 0xFF) shl 8) or
65− (digest[7].toLong() and 0xFF)).and(0x7FFFFFFFFFFFFFFF)
66− }
67− override fun hasStableIds(): Boolean = true
68−}
app/src/main/java/soy/iko/opencode/ui/AppLockGate.ktdeleted−290
@@ -1,290 +0,0 @@
1−package soy.iko.opencode.ui
2−
3−import android.content.Context
4−import android.content.ContextWrapper
5−import android.os.Build
6−import androidx.biometric.BiometricManager
7−import androidx.biometric.BiometricPrompt
8−import androidx.compose.foundation.clickable
9−import androidx.compose.foundation.layout.Arrangement
10−import androidx.compose.foundation.layout.Column
11−import androidx.compose.foundation.layout.Spacer
12−import androidx.compose.foundation.layout.fillMaxSize
13−import androidx.compose.foundation.layout.padding
14−import androidx.compose.foundation.layout.size
15−import androidx.compose.material.icons.Icons
16−import androidx.compose.material.icons.filled.Fingerprint
17−import androidx.compose.material.icons.filled.Lock
18−import androidx.compose.material3.Button
19−import androidx.compose.material3.CircularProgressIndicator
20−import androidx.compose.material3.Icon
21−import androidx.compose.material3.MaterialTheme
22−import androidx.compose.material3.Surface
23−import androidx.compose.material3.Text
24−import androidx.compose.runtime.Composable
25−import androidx.compose.runtime.DisposableEffect
26−import androidx.compose.runtime.getValue
27−import androidx.compose.runtime.mutableStateOf
28−import androidx.compose.runtime.remember
29−import androidx.compose.runtime.saveable.rememberSaveableStateHolder
30−import androidx.compose.runtime.setValue
31−import androidx.compose.ui.Alignment
32−import androidx.compose.ui.Modifier
33−import androidx.compose.ui.platform.LocalContext
34−import androidx.compose.ui.res.stringResource
35−import androidx.compose.ui.semantics.LiveRegionMode
36−import androidx.compose.ui.semantics.Role
37−import androidx.compose.ui.semantics.liveRegion
38−import androidx.compose.ui.semantics.semantics
39−import androidx.compose.ui.unit.dp
40−import androidx.core.content.ContextCompat
41−import androidx.fragment.app.FragmentActivity
42−import androidx.lifecycle.Lifecycle
43−import androidx.lifecycle.LifecycleEventObserver
44−import androidx.lifecycle.compose.LocalLifecycleOwner
45−import soy.iko.opencode.R
46−
47−/**
48− * The set of authenticators the app lock accepts. Device credential (PIN/pattern/password)
49− * can only be combined with a biometric class from Android 11 (API 30) on; on 26–29 we ask
50− * for a strong biometric alone, so the lock is only offered (in Settings) when one is enrolled.
51− */
52−private val lockAuthenticators: Int
53− get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
54− BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL
55− } else {
56− BiometricManager.Authenticators.BIOMETRIC_STRONG
57− }
58−
59−/** Whether device authentication is currently possible, so Settings can gate the toggle. */
60−fun canAuthenticateForAppLock(context: Context): Boolean =
61− BiometricManager.from(context).canAuthenticate(lockAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS
62−
63−/**
64− * Whether app-lock auth is *permanently* impossible on this device (no hardware, nothing
65− * enrolled, or a pending security update). Only these states may fail the gate open. A transient
66− * error (sensor busy / temporarily unavailable) must NOT fail open — that would unlock the app on
67− * a momentary glitch — so the gate stays locked and lets the user retry.
68− */
69−private fun appLockPermanentlyUnavailable(context: Context): Boolean =
70− when (BiometricManager.from(context).canAuthenticate(lockAuthenticators)) {
71− BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED,
72− BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE,
73− BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> true
74− else -> false
75− }
76−
77−/** Unwrap a [FragmentActivity] from a (possibly wrapped) Compose [Context]. */
78−private fun Context.findFragmentActivity(): FragmentActivity? {
79− var ctx: Context? = this
80− while (ctx is ContextWrapper) {
81− if (ctx is FragmentActivity) return ctx
82− ctx = ctx.baseContext
83− }
84− return null
85−}
86−
87−/**
88− * Gates [content] behind device authentication when [enabled]. The content stays composed
89− * underneath an opaque lock overlay (so navigation state survives a re-lock), and the app
90− * re-locks whenever it's sent to the background. If no [FragmentActivity] is available or the
91− * device can't authenticate, the gate is a no-op — the setting is only offered when
92− * [canAuthenticateForAppLock] is true, so this fallback just prevents a hard lock-out.
93− */
94−@Composable
95−fun AppLockGate(enabled: Boolean, reLockDelaySeconds: Int = 0, content: @Composable () -> Unit) {
96− if (!enabled) {
97− content()
98− return
99− }
100− val context = LocalContext.current
101− val activity = remember(context) { context.findFragmentActivity() }
102− if (activity == null) {
103− content()
104− return
105− }
106− // Fail open only if device authentication is *permanently* impossible (e.g. the user enabled
107− // app-lock while a credential was enrolled and later removed their PIN/biometric): authenticate()
108− // would otherwise always hit onAuthenticationError and strand the user on a permanent lockout.
109− // A transient authenticator error keeps the lock up (the user retries) rather than unlocking.
110− if (appLockPermanentlyUnavailable(context)) {
111− content()
112− return
113− }
114−
115− // Deliberately NOT rememberSaveable: the unlocked flag must never be persisted into the
116− // saved-instance-state Bundle. On API 26-27 onSaveInstanceState runs *before* onStop, so a
117− // saveable `true` would be written to the Bundle before ON_STOP resets it; after a background
118− // process-kill and restore the app would come back unlocked with no biometric prompt — an
119− // app-lock bypass. Rotation is handled by the activity's configChanges (no recreation), so a
120− // plain remember still avoids re-prompting on rotation while re-locking on real recreation.
121− var unlocked by remember { mutableStateOf(false) }
122− // Wall-clock time of the most recent ON_STOP. Used with [reLockDelaySeconds] so a quick
123− // app-switch (e.g. glancing at another app for a few seconds) doesn't re-prompt — the most
124− // common reason users disable biometric locks. A killed process resets unlocked anyway, so the
125− // grace period only governs the in-memory re-lock decision across a surviving background stint.
126− val lastStopTimeMs = remember { longArrayOf(0L) }
127− val reLockDelayMs = reLockDelaySeconds * 1000L
128−
129− val title = stringResource(R.string.app_lock_prompt_title)
130− val subtitle = stringResource(R.string.app_lock_prompt_subtitle)
131−
132− // Track an in-flight prompt so a recomposition (or the retry button) can't stack two
133− // BiometricPrompts, which throws.
134− var authInFlight by remember { mutableStateOf(false) }
135− // Monotonic id for the current prompt. The system dismisses an in-flight BiometricPrompt when
136− // the app backgrounds and posts its onAuthenticationError asynchronously; without this, that
137− // stale error could land after ON_START already started a NEW prompt and clear authInFlight
138− // mid-prompt — letting a recomposition or the "Unlock" button (enabled = !authInFlight) stack
139− // a second prompt. Each prompt captures its id and only mutates state while it's still current.
140− val promptId = remember { intArrayOf(0) }
141−
142− fun authenticate() {
143− if (authInFlight || unlocked) return
144− authInFlight = true
145− val myPrompt = ++promptId[0]
146− val executor = ContextCompat.getMainExecutor(activity)
147− val callback = object : BiometricPrompt.AuthenticationCallback() {
148− override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
149− if (myPrompt != promptId[0]) return
150− authInFlight = false
151− unlocked = true
152− }
153−
154− override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
155− // User cancelled / lockout / hardware error: stay locked and let them retry
156− // via the button rather than looping the system prompt. Ignore a stale error
157− // from a prompt already superseded by a newer one (see promptId above).
158− if (myPrompt != promptId[0]) return
159− authInFlight = false
160− }
161−
162− override fun onAuthenticationFailed() {
163− // A single non-matching attempt; the system UI lets the user try again.
164− }
165− }
166− val info = BiometricPrompt.PromptInfo.Builder()
167− .setTitle(title)
168− .setSubtitle(subtitle)
169− .setAllowedAuthenticators(lockAuthenticators)
170− .build()
171− runCatching { BiometricPrompt(activity, executor, callback).authenticate(info) }
172− .onFailure { if (myPrompt == promptId[0]) authInFlight = false }
173− }
174−
175− // Re-lock when the app is backgrounded, and re-prompt when it returns to the foreground.
176− // The re-prompt fires on ON_START rather than by keying an effect on `unlocked`: ON_STOP
177− // already cleared `unlocked` while backgrounded, so an effect keyed on it would see no
178− // change on return and never re-fire, stranding the user on the manual "Unlock" button.
179− // addObserver also delivers the current state's ON_START to a freshly-added observer, so
180− // this covers the initial prompt on first show too; the authInFlight/unlocked guards in
181− // authenticate() make the overlapping triggers safe.
182− val lifecycleOwner = LocalLifecycleOwner.current
183− DisposableEffect(lifecycleOwner) {
184− val observer = LifecycleEventObserver { _, event ->
185− when (event) {
186− Lifecycle.Event.ON_STOP -> {
187− // Record the stop time but DON'T clear unlocked yet — the re-lock decision is
188− // made on the next ON_START, so a brief background stint under the grace period
189− // returns the user straight to their session without a re-prompt. authInFlight
190− // is still cleared because the system dismisses an in-flight prompt on background.
191− lastStopTimeMs[0] = System.currentTimeMillis()
192− authInFlight = false
193− }
194− Lifecycle.Event.ON_START -> {
195− // Only re-lock (and re-prompt) if more than the grace period elapsed since the
196− // last ON_STOP, or the app is freshly starting (lastStopTimeMs == 0).
197− val elapsed = System.currentTimeMillis() - lastStopTimeMs[0]
198− if (unlocked && lastStopTimeMs[0] != 0L && elapsed < reLockDelayMs) {
199− // Within the grace period — stay unlocked, no re-prompt.
200− } else {
201− unlocked = false
202− if (!unlocked) authenticate()
203− }
204− }
205− else -> {}
206− }
207− }
208− lifecycleOwner.lifecycle.addObserver(observer)
209− onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
210− }
211−
212− // Compose content ONLY while unlocked. Drawing the lock as an overlay *over* a still-composed
213− // content() cannot cover a Dialog / ModalBottomSheet / DropdownMenu / Popup: those render in
214− // separate windows z-ordered above the Activity content, so an open permission dialog or picker
215− // sheet would stay visible and interactive above the lock — an authentication bypass. Not
216− // composing content while locked removes those sub-windows entirely. The SaveableStateHolder
217− // preserves navigation/scroll/input state across the re-lock so unlocking returns the user where
218− // they were, replacing the previous "keep composed underneath" approach.
219− val stateHolder = rememberSaveableStateHolder()
220− if (unlocked) {
221− stateHolder.SaveableStateProvider(APP_LOCK_CONTENT_KEY) { content() }
222− } else {
223− // A Fingerprint glyph hints at the primary unlock method when a biometric is enrolled;
224− // fall back to the generic lock on credential-only setups. Computed once: enrollment
225− // doesn't change while the gate is shown, and re-prompting on each frame is wasteful.
226− val biometricAvailable = remember(context) {
227− BiometricManager.from(context)
228− .canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
229− BiometricManager.BIOMETRIC_SUCCESS
230− }
231− val unlockLabel = stringResource(R.string.app_unlock)
232− Surface(
233− modifier = Modifier.fillMaxSize(),
234− color = MaterialTheme.colorScheme.background,
235− ) {
236− // The whole surface is tappable to re-trigger the prompt (in addition to the button),
237− // so a user who dismissed the system prompt can retry by tapping anywhere — not just
238− // by finding the Unlock button.
239− Column(
240− modifier = Modifier
241− .fillMaxSize()
242− .clickable(
243− enabled = !authInFlight,
244− role = Role.Button,
245− onClickLabel = unlockLabel,
246− ) { authenticate() }
247− .padding(32.dp),
248− horizontalAlignment = Alignment.CenterHorizontally,
249− verticalArrangement = Arrangement.Center,
250− ) {
251− Icon(
252− if (biometricAvailable) Icons.Filled.Fingerprint else Icons.Filled.Lock,
253− contentDescription = null,
254− modifier = Modifier.size(56.dp),
255− tint = MaterialTheme.colorScheme.primary,
256− )
257− Text(
258− stringResource(R.string.app_locked),
259− style = MaterialTheme.typography.titleMedium,
260− modifier = Modifier
261− .padding(top = 16.dp)
262− .semantics { liveRegion = LiveRegionMode.Polite },
263− )
264− Text(
265− stringResource(R.string.app_locked_hint),
266− style = MaterialTheme.typography.bodyMedium,
267− color = MaterialTheme.colorScheme.onSurfaceVariant,
268− modifier = Modifier.padding(top = 4.dp),
269− )
270− Button(
271− onClick = { authenticate() },
272− enabled = !authInFlight,
273− modifier = Modifier.padding(top = 24.dp),
274− ) {
275− if (authInFlight) {
276− CircularProgressIndicator(
277− modifier = Modifier.size(18.dp),
278− strokeWidth = 2.dp,
279− color = MaterialTheme.colorScheme.primary,
280− )
281− Spacer(Modifier.size(8.dp))
282− }
283− Text(unlockLabel)
284− }
285− }
286− }
287− }
288−}
289−
290−private const val APP_LOCK_CONTENT_KEY = "app_lock_content"
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+91−434
@@ -1,467 +1,124 @@
11package soy.iko.opencode.ui
22
3−import androidx.compose.animation.EnterTransition
4−import androidx.compose.animation.ExitTransition
5−import androidx.compose.animation.core.tween
6−import androidx.compose.animation.fadeIn
7−import androidx.compose.animation.fadeOut
3+import androidx.activity.compose.BackHandler
4+import androidx.compose.animation.AnimatedContent
85import androidx.compose.animation.slideInHorizontally
96import androidx.compose.animation.slideOutHorizontally
10−import androidx.compose.foundation.layout.Box
11−import androidx.compose.foundation.layout.BoxWithConstraints
12−import androidx.compose.foundation.layout.Row
13−import androidx.compose.foundation.layout.fillMaxHeight
7+import androidx.compose.animation.togetherWith
148import androidx.compose.foundation.layout.fillMaxSize
9+import androidx.compose.foundation.layout.padding
10+import androidx.compose.material3.AlertDialog
11+import androidx.compose.material3.Scaffold
12+import androidx.compose.material3.SnackbarDuration
13+import androidx.compose.material3.SnackbarHost
14+import androidx.compose.material3.SnackbarHostState
15+import androidx.compose.material3.Text
16+import androidx.compose.material3.TextButton
1517import androidx.compose.runtime.Composable
16−import androidx.compose.runtime.CompositionLocalProvider
1718import androidx.compose.runtime.LaunchedEffect
1819import androidx.compose.runtime.getValue
1920import androidx.compose.runtime.mutableStateOf
20−import androidx.compose.runtime.rememberCoroutineScope
21−import androidx.compose.runtime.saveable.rememberSaveable
21+import androidx.compose.runtime.remember
2222import androidx.compose.runtime.setValue
2323import androidx.compose.ui.Modifier
24−import androidx.compose.ui.platform.LocalDensity
25−import androidx.compose.ui.unit.dp
26−import androidx.compose.ui.unit.LayoutDirection
27−import androidx.compose.ui.platform.LocalLayoutDirection
28−import androidx.navigation.NavType
29−import androidx.navigation.compose.NavHost
30−import androidx.navigation.compose.composable
31−import androidx.navigation.compose.currentBackStackEntryAsState
32−import androidx.navigation.compose.rememberNavController
33−import androidx.navigation.navArgument
34−import androidx.lifecycle.compose.collectAsStateWithLifecycle
35−import kotlinx.coroutines.launch
36−import soy.iko.opencode.di.AppContainer
37−import soy.iko.opencode.data.network.NetworkConfig
38−import soy.iko.opencode.ui.chat.ChatScreen
39−import soy.iko.opencode.ui.file.FileBrowserScreen
40−import soy.iko.opencode.ui.file.FileViewScreen
41−import soy.iko.opencode.ui.file.TwoPaneFileBrowser
42−import soy.iko.opencode.ui.server.ServerEditScreen
43−import soy.iko.opencode.ui.server.ServerListScreen
44−import soy.iko.opencode.ui.search.GlobalSearchScreen
45−import soy.iko.opencode.ui.components.LargeScreenNavRail
46−import soy.iko.opencode.ui.components.LocalChatTextScale
47−import soy.iko.opencode.ui.components.LocalCodeWrap
48−import soy.iko.opencode.ui.components.LocalReducedMotion
49−import soy.iko.opencode.ui.components.isReducedMotion
50−import soy.iko.opencode.ui.session.SessionListScreen
51−import soy.iko.opencode.ui.session.TwoPaneSessionChat
52−import soy.iko.opencode.ui.settings.DiagnosticsScreen
53−import soy.iko.opencode.ui.settings.SettingsScreen
54−import soy.iko.opencode.ui.usage.UsageScreen
55−import soy.iko.opencode.ui.mcp.McpScreen
56−import soy.iko.opencode.util.runCatchingCancellable
24+import soy.iko.opencode.core.Event
25+import soy.iko.opencode.core.Screen
26+import soy.iko.opencode.core.UiState
27+import soy.iko.opencode.ui.screens.ChatScreen
28+import soy.iko.opencode.ui.screens.ConnectScreen
29+import soy.iko.opencode.ui.screens.SessionsScreen
5730
5831@Composable
59−fun OpencodeApp(container: AppContainer) {
60− val navController = rememberNavController()
61− val scope = rememberCoroutineScope()
62− val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
63− val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
64− val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
65− val pendingNewSession by container.pendingNewSession.collectAsStateWithLifecycle()
66− val pendingOpenFile by container.pendingOpenFile.collectAsStateWithLifecycle()
67− val pendingDiagnostics by container.pendingDiagnostics.collectAsStateWithLifecycle()
68− val connection by container.activeConnection.collectAsStateWithLifecycle()
69− // Chat presentation preferences, provided to the whole nav graph so the markdown/code
70− // renderers honor the user's text-size and code-wrap choices everywhere.
71− val chatTextScale by container.settingsStore.chatTextScale.collectAsStateWithLifecycle(initialValue = 1f)
72− val codeWrap by container.settingsStore.codeWrap.collectAsStateWithLifecycle(initialValue = false)
73−
74− // The foreground service that holds priority while any run is active is driven from the
75− // process-lived AppContainer scope (see AppContainer.observeRunForegroundService), not
76− // from here — a composition-scoped collector pauses while backgrounded, which is exactly
77− // when a run started via the notification reply needs the service to start.
78−
79− // When text is shared into the app, surface the session list so the user can pick
80− // (or create) a conversation to drop it into. The chosen session's draft is set
81− // in [onOpenSession] below. Keyed on `connection` too so a share that arrives
82− // before auto-reconnect completes is retried once the connection is established.
83− // Track the last-handled share payloads (there's no share nonce on the container) so a
84− // *new* share arriving while already connected — a warm start where onNewIntent re-fires
85− // setPendingShare under an unchanged connection id — still navigates, while a re-trigger
86− // carrying no new share (a bare server switch, which routes through a null connection, or
87− // a share the user already navigated for) leaves them where they are.
88− //
89− // Reset the remembered media when the pending list is drained (the chosen ChatScreen
90− // consumes it via consumePendingSharedMedia). Without this, re-sharing the *same* images
91− // after consumption compares equal structurally ([A,B] == [A,B]) and would be treated as
92− // "already handled", silently skipping the session-list navigation the second time.
93− //
94− // rememberSaveable (not remember) so a process-kill restore doesn't reset the dedup
95− // state and re-navigate to SESSIONS for a share the user already handled before the kill.
96− var handledShareText by rememberSaveable { mutableStateOf<String?>(null) }
97− var handledSharedMedia by rememberSaveable { mutableStateOf<List<String>>(emptyList()) }
98− LaunchedEffect(pendingShare, pendingSharedMedia, connection) {
99− // Reset the remembered handled state for each channel independently as it drains,
100− // so re-sharing the same payload after consumption is still recognized as new. A
101− // joint reset (only when both are empty) would drop a text-only re-share that
102− // follows a text+media share of the same text: the text channel matches its
103− // remembered value while the media channel is empty (not "new"), so neither clause
104− // fires and the share is silently swallowed.
105− if (pendingShare == null) handledShareText = null
106− if (pendingSharedMedia.isEmpty()) handledSharedMedia = emptyList()
107− if (pendingShare == null && pendingSharedMedia.isEmpty()) return@LaunchedEffect
108− if (connection == null) return@LaunchedEffect
109− val newShare = (pendingShare != null && pendingShare != handledShareText) ||
110− (pendingSharedMedia.isNotEmpty() && pendingSharedMedia != handledSharedMedia)
111− if (!newShare) return@LaunchedEffect
112− handledShareText = pendingShare
113− handledSharedMedia = pendingSharedMedia
114− if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
115− navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
32+fun OpencodeApp(state: UiState, dispatch: (Event) -> Unit) {
33+ val snackbar = remember { SnackbarHostState() }
34+ var showStopConfirm by remember { mutableStateOf(false) }
35+ var showDisconnectConfirm by remember { mutableStateOf(false) }
36+
37+ LaunchedEffect(state.error) {
38+ state.error?.let {
39+ snackbar.showSnackbar(
40+ message = it,
41+ actionLabel = "Dismiss",
42+ duration = SnackbarDuration.Short,
43+ )
44+ // Always clear the error from state after showing.
45+ dispatch(Event.DismissError)
11646 }
11747 }
11848
119− // Crash-relaunch prompt: navigate to the Diagnostics screen when the user taps
120− // "View report" on the cold-start crash dialog. Doesn't require a connection.
121− LaunchedEffect(pendingDiagnostics) {
122− if (!pendingDiagnostics) return@LaunchedEffect
123− container.consumePendingDiagnostics()
124− navController.navigate(Routes.DIAGNOSTICS) { launchSingleTop = true }
125− }
126−
127− // "New session" from a launcher shortcut / QS tile: once connected, create a fresh
128− // session and open it. The pending counter is consumed only *after* createSession
129− // succeeds, so a transient failure leaves the request pending to retry (on the next
130− // connection change or a re-tap that increments the counter) instead of silently
131− // dropping the tap.
132− LaunchedEffect(pendingNewSession, connection) {
133− if (pendingNewSession == 0) return@LaunchedEffect
134− val conn = connection ?: return@LaunchedEffect
135− val session = runCatchingCancellable { conn.repository.createSession() }.getOrNull()
136− ?: return@LaunchedEffect
137− if (!container.consumePendingNewSession()) return@LaunchedEffect
138− if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
139− navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
49+ BackHandler(enabled = state.screen != Screen.Connect) {
50+ when (state.screen) {
51+ // Leaving mid-generation would silently drop the reply, so confirm first.
52+ Screen.Chat -> if (state.generating) showStopConfirm = true else dispatch(Event.NavigateToSessions)
53+ // Disconnecting drops the server session, so guard the back button.
54+ Screen.Sessions -> showDisconnectConfirm = true
55+ Screen.Connect -> Unit
14056 }
141− navController.navigate(Routes.chat(session.id)) { launchSingleTop = true }
142− }
143−
144− // The chat text-size preference multiplies on top of the OS font scale (sp already
145− // honors the system setting), so a large system size × large app size can reach ~2×
146− // and break tight layouts. Clamp the provided app scale so the product with the OS
147− // fontScale never exceeds NetworkConfig.maxCombinedFontScale, while never scaling
148− // below the user's chosen value's intent (floor of 1× app scale is preserved when the
149− // system size alone already exceeds the cap).
150− val systemFontScale = LocalDensity.current.fontScale
151− val clampedChatTextScale = if (systemFontScale <= 0f) {
152− chatTextScale
153− } else {
154− chatTextScale.coerceAtMost(
155− (NetworkConfig.maxCombinedFontScale / systemFontScale).coerceAtLeast(1f)
156− )
15757 }
15858
159− // Capture the reduced-motion preference once (in the composable scope) so the
160− // non-composable NavHost transition lambdas can use it to skip animations, and provide
161− // it via LocalReducedMotion so in-composition animations (chat row placement, banners,
162− // image zoom) honor the same accessibility preference.
163− val reducedMotion = isReducedMotion()
164− CompositionLocalProvider(
165− LocalChatTextScale provides clampedChatTextScale,
166− LocalCodeWrap provides codeWrap,
167− LocalReducedMotion provides reducedMotion,
168− ) {
169− // Slide direction for push/pop transitions, mirrored in RTL so the animation reads
170− // naturally instead of "going the wrong way". +1 = LTR (push from right), -1 = RTL.
171− val slideDir = if (LocalLayoutDirection.current == LayoutDirection.Rtl) -1 else 1
172− // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
173− // the open conversation side by side instead of a single-pane back stack.
174− BoxWithConstraints {
175− val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp && connection != null
176− // On large screens, surface a navigation rail alongside the content so top-level
177− // destinations (Sessions, Files, Search, …) are one tap away instead of buried in a
178− // screen's overflow menu. Mirrors the standard large-screen M3 layout.
179− val showNavRail = maxWidth >= NetworkConfig.navigationRailThresholdDp.dp
180− // Observe the current destination reactively so the rail's selected item tracks navigation
181− // (reading currentDestination once would go stale after the first composition).
182− val currentEntry by navController.currentBackStackEntryAsState()
183− val currentRoute = currentEntry?.destination?.route
184−
185− // Open a session requested by a notification tap or deep link, once connected.
186− LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
187− val pending = pendingOpenSession ?: return@LaunchedEffect
188− if (connection == null) return@LaunchedEffect
189− // If the notification body-tap carries a profile id from a different server,
190− // switch to it first so the session opens under the server that ran it, not
191− // whichever is active. The effect re-runs when connection changes (keyed on it),
192− // so on the second run the profile matches and the open proceeds.
193− if (pending.profileId != null && connection?.profile?.id != pending.profileId) {
194− if (!container.connectByProfileId(pending.profileId)) return@LaunchedEffect
195− return@LaunchedEffect // re-run on the new connection
196− }
197− // In two-pane mode the detail pane (TwoPaneSessionChat, hosted on the SESSIONS
198− // route) consumes the request and opens it in the detail pane. But it only
199− // exists while the NavHost is on SESSIONS, so if the user is currently on
200− // Files/Settings/FileView bring SESSIONS back to front first — otherwise the
201− // pending request is never consumed and the session never opens. Don't consume
202− // here; let TwoPaneSessionChat's effect do it once it's composed.
203− if (isTwoPane) {
204− if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
205− navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
206− }
207− return@LaunchedEffect
208− }
209− container.consumePendingOpenSession()
210− // Ensure SESSIONS sits on the back stack below the chat destination so backing
211− // out of the chat lands on the session list (not the server list). Bring SESSIONS
212− // to front (or push it if a cold-start deep link left only SERVERS on the stack),
213− // then push chat on top — yielding SERVERS -> SESSIONS -> chat.
214− if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
215− navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
216− }
217− navController.navigate(Routes.chat(pending.sessionId)) { launchSingleTop = true }
218− }
219−
220− // Deep-linked file open (opencode://file/{path}): navigate to the file viewer once a
221− // connection exists (the viewer fetches content from the server). Path comes pre-validated
222− // by MainActivity's deep-link guard. In two-pane mode the detail pane (TwoPaneFileBrowser,
223− // hosted on the FILES route) consumes the request into its detail pane, mirroring how
224− // session deep links are routed — so don't consume or push the full-screen viewer here.
225− LaunchedEffect(pendingOpenFile, connection, isTwoPane) {
226− val path = pendingOpenFile ?: return@LaunchedEffect
227− if (connection == null) return@LaunchedEffect
228− if (isTwoPane) {
229− if (!navController.popBackStack(Routes.FILES, inclusive = false)) {
230− navController.navigate(Routes.FILES) { launchSingleTop = true }
59+ if (showStopConfirm) {
60+ AlertDialog(
61+ onDismissRequest = { showStopConfirm = false },
62+ title = { Text("Stop generation?") },
63+ text = { Text("Leaving will stop the current generation.") },
64+ confirmButton = {
65+ TextButton(onClick = {
66+ showStopConfirm = false
67+ dispatch(Event.CancelGeneration)
68+ dispatch(Event.NavigateToSessions)
69+ }) {
70+ Text("Stop and leave")
23171 }
232− return@LaunchedEffect
233− }
234− container.consumePendingOpenFile()
235− if (!navController.popBackStack(Routes.FILES, inclusive = false)) {
236− navController.navigate(Routes.FILES) { launchSingleTop = true }
237− }
238− navController.navigate(Routes.fileView(path)) { launchSingleTop = true }
239− }
240−
241− val navHost = @Composable {
242− NavHost(
243− modifier = Modifier.fillMaxSize(),
244− navController = navController,
245− startDestination = Routes.SERVERS,
246− // Motion-aware transitions: respect the user's reduced-motion setting
247− // (Developer Options → animator scale 0) by skipping animations entirely.
248− // Per-destination tuning: a chat→chat navigation (switching peer conversations
249− // within the same surface) crossfades instead of sliding, so it reads as a peer
250− // switch rather than drilling into a new section. Top-level destinations and
251− // detail pushes keep the horizontal slide.
252− enterTransition = {
253− if (reducedMotion) {
254− EnterTransition.None
255− } else if (targetState.destination.route?.startsWith("${Routes.CHAT}/") == true) {
256− fadeIn(tween(NetworkConfig.motionFadeDurationMs))
257− } else {
258− slideInHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it * slideDir } +
259− fadeIn(tween(NetworkConfig.motionFadeDurationMs))
72+ },
73+ dismissButton = {
74+ TextButton(onClick = { showStopConfirm = false }) {
75+ Text("Stay")
26076 }
26177 },
262− exitTransition = {
263− if (reducedMotion) {
264− ExitTransition.None
265− } else {
266− fadeOut(tween(NetworkConfig.motionFadeDurationMs))
78+ )
79+ }
80+
81+ if (showDisconnectConfirm) {
82+ AlertDialog(
83+ onDismissRequest = { showDisconnectConfirm = false },
84+ title = { Text("Disconnect?") },
85+ text = { Text("You will be disconnected from the server.") },
86+ confirmButton = {
87+ TextButton(onClick = {
88+ showDisconnectConfirm = false
89+ dispatch(Event.NavigateToConnect)
90+ }) {
91+ Text("Disconnect")
26792 }
26893 },
269− popEnterTransition = {
270− if (reducedMotion) {
271− EnterTransition.None
272− } else {
273− fadeIn(tween(NetworkConfig.motionFadeDurationMs))
94+ dismissButton = {
95+ TextButton(onClick = { showDisconnectConfirm = false }) {
96+ Text("Cancel")
27497 }
27598 },
276− popExitTransition = {
277− if (reducedMotion) {
278− ExitTransition.None
99+ )
100+ }
101+
102+ Scaffold(snackbarHost = { SnackbarHost(snackbar) }) { padding ->
103+ AnimatedContent(
104+ targetState = state.screen,
105+ modifier = Modifier.fillMaxSize().padding(padding),
106+ transitionSpec = {
107+ // Compare screen ordinals to decide slide direction: forward navigation
108+ // (Connect → Sessions → Chat) slides in from the right, back slides from the left.
109+ if (targetState.ordinal > initialState.ordinal) {
110+ slideInHorizontally { it } togetherWith slideOutHorizontally { -it }
279111 } else {
280− slideOutHorizontally(animationSpec = tween(NetworkConfig.motionSlideDurationMs)) { it * slideDir } +
281− fadeOut(tween(NetworkConfig.motionFadeDurationMs))
112+ slideInHorizontally { -it } togetherWith slideOutHorizontally { it }
282113 }
283114 },
284− ) {
285−
286− composable(Routes.SERVERS) {
287− ServerListScreen(
288− container = container,
289− onConnected = {
290− // Only leave the server list if we're still on it. A pending deep-link /
291− // notification open sets activeConnection (which drives the open-session
292− // effect: SERVERS -> SESSIONS -> chat) *before* autoConnectDone fires this
293− // callback, so a blind navigate(SESSIONS) here would slam a second SESSIONS
294− // on top of the chat it just opened, burying it. Order-independent: if the
295− // deep link already navigated away from SERVERS, this is a no-op.
296− if (navController.currentDestination?.route == Routes.SERVERS) {
297− navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
298− }
299− },
300− onAddProfile = { navController.navigate(Routes.serverEdit()) },
301− onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
302− onDuplicateProfile = { id -> navController.navigate(Routes.serverEditDuplicate(id)) },
303− )
304− }
305−
306− composable(
307− route = "${Routes.SERVER_EDIT}?id={id}&dup={dup}",
308− arguments = listOf(
309− navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null },
310− navArgument("dup") { type = NavType.StringType; nullable = true; defaultValue = null },
311− ),
312− ) { entry ->
313− ServerEditScreen(
314− container = container,
315− profileId = entry.arguments?.getString("id"),
316− sourceId = entry.arguments?.getString("dup"),
317− onDone = { navController.popBackStack() },
318− )
319− }
320−
321− composable(Routes.SESSIONS) {
322− if (isTwoPane) {
323− TwoPaneSessionChat(
324− container = container,
325− onOpenFiles = { navController.navigate(Routes.FILES) },
326− onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
327− onOpenSettings = { navController.navigate(Routes.SETTINGS) },
328− onOpenSearch = { navController.navigate(Routes.SEARCH) },
329− onDisconnect = {
330− scope.launch { runCatchingCancellable { container.disconnect() } }
331− navController.popBackStack(Routes.SERVERS, inclusive = false)
332− },
333− onAddServer = { navController.navigate(Routes.serverEdit()) },
334− )
335− } else {
336− SessionListScreen(
337− container = container,
338− onOpenSession = { id ->
339− // Set the draft synchronously in-memory before navigating so the
340− // ChatScreen sees it on first composition. The async persistence
341− // to disk happens in the background via draftStore.set.
342− container.consumePendingShare()?.let { shareText ->
343− container.draftStore.setImmediate(id, shareText)
344− scope.launch { runCatchingCancellable { container.draftStore.set(id, shareText) } }
345− }
346− navController.navigate(Routes.chat(id))
347− },
348− onDisconnect = {
349− scope.launch { runCatchingCancellable { container.disconnect() } }
350− navController.popBackStack(Routes.SERVERS, inclusive = false)
351− },
352− onOpenFiles = { navController.navigate(Routes.FILES) },
353− onOpenSettings = { navController.navigate(Routes.SETTINGS) },
354− onOpenSearch = { navController.navigate(Routes.SEARCH) },
355− onAddServer = { navController.navigate(Routes.serverEdit()) },
356− )
357− }
358− }
359−
360− composable(
361− route = "${Routes.CHAT}/{sessionId}",
362− arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
363− ) { entry ->
364− ChatScreen(
365− container = container,
366− sessionId = entry.arguments?.getString("sessionId").orEmpty(),
367− onBack = { navController.popBackStack() },
368− onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
369− onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
370− )
371− }
372−
373− composable(Routes.FILES) {
374− if (isTwoPane) {
375− TwoPaneFileBrowser(
376− container = container,
377− onBack = { navController.popBackStack() },
378− )
379− } else {
380− FileBrowserScreen(
381− container = container,
382− onOpenFile = { path, line -> navController.navigate(Routes.fileView(path, line)) },
383− onBack = { navController.popBackStack() },
384− )
385− }
386− }
387−
388− composable(
389− route = "${Routes.FILE_VIEW}?path={path}&line={line}",
390− arguments = listOf(
391− navArgument("path") { type = NavType.StringType; defaultValue = "" },
392− navArgument("line") { type = NavType.IntType; defaultValue = -1 },
393− ),
394− ) { entry ->
395− val line = entry.arguments?.getInt("line") ?: -1
396− FileViewScreen(
397− container = container,
398− path = entry.arguments?.getString("path").orEmpty(),
399− initialLine = line.takeIf { it > 0 },
400− onBack = { navController.popBackStack() },
401− )
402− }
403−
404− composable(Routes.SETTINGS) {
405− SettingsScreen(
406− container = container,
407− onBack = { navController.popBackStack() },
408− // Navigate to the server list as a new entry on top of Sessions so the
409− // user can back out to their conversations. Previously this popped back
410− // to the root SERVERS destination, which removed Sessions from the stack
411− // and left the user unable to return to their conversations without
412− // reconnecting. Pop only Settings, then push SERVERS; back from the
413− // server list lands on Sessions (the screen the user was on before
414− // Settings), and connecting pushes a fresh SESSIONS as usual.
415− onManageServers = {
416− navController.navigate(Routes.SERVERS) {
417− popUpTo(Routes.SETTINGS) { inclusive = true }
418− launchSingleTop = true
419− }
420− },
421− onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
422− onOpenUsage = { navController.navigate(Routes.USAGE) },
423− onOpenMcp = { navController.navigate(Routes.MCP) },
424− )
425− }
426−
427− composable(Routes.DIAGNOSTICS) {
428− DiagnosticsScreen(onBack = { navController.popBackStack() })
429− }
430−
431− composable(Routes.USAGE) {
432− UsageScreen(
433− container = container,
434− onBack = { navController.popBackStack() },
435− onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
436− )
437− }
438−
439− composable(Routes.MCP) {
440− McpScreen(container = container, onBack = { navController.popBackStack() })
441− }
442−
443− composable(Routes.SEARCH) {
444− GlobalSearchScreen(
445− container = container,
446− onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
447− onBack = { navController.popBackStack() },
448− )
449− }
450− }
451− }
452−
453− if (showNavRail) {
454− Row(modifier = Modifier.fillMaxSize()) {
455− LargeScreenNavRail(
456− currentRoute = currentRoute,
457− connected = connection != null,
458− onNavigate = { route -> navController.navigate(route) { launchSingleTop = true } },
459− )
460− Box(modifier = Modifier.weight(1f).fillMaxHeight()) { navHost() }
115+ label = "screen",
116+ ) { screen ->
117+ when (screen) {
118+ Screen.Connect -> ConnectScreen(state, dispatch)
119+ Screen.Sessions -> SessionsScreen(state, dispatch)
120+ Screen.Chat -> ChatScreen(state, dispatch)
461121 }
462− } else {
463− navHost()
464122 }
465123 }
466− }
467124}
app/src/main/java/soy/iko/opencode/ui/Routes.ktdeleted−30
@@ -1,30 +0,0 @@
1−package soy.iko.opencode.ui
2−
3−/** String routes for the app's NavHost. Kept simple and centralized. */
4−object Routes {
5− const val SERVERS = "servers"
6− const val SESSIONS = "sessions"
7− const val SETTINGS = "settings"
8− const val DIAGNOSTICS = "diagnostics"
9− const val USAGE = "usage"
10− const val MCP = "mcp"
11− const val FILES = "files"
12− const val SEARCH = "search"
13−
14− const val SERVER_EDIT = "server_edit"
15− fun serverEdit(id: String? = null) =
16− if (id.isNullOrEmpty()) SERVER_EDIT else "$SERVER_EDIT?id=${android.net.Uri.encode(id)}"
17−
18− /** Open the editor seeded with [sourceId]'s fields but creating a new profile. */
19− fun serverEditDuplicate(sourceId: String) = "$SERVER_EDIT?dup=${android.net.Uri.encode(sourceId)}"
20−
21− const val CHAT = "chat"
22− fun chat(sessionId: String) = "$CHAT/${android.net.Uri.encode(sessionId)}"
23−
24− const val FILE_VIEW = "file_view"
25− fun fileView(path: String, line: Int? = null): String {
26− val base = "$FILE_VIEW?path=${android.net.Uri.encode(path)}"
27− // A positive [line] scrolls the viewer to that 1-based line (from a search hit).
28− return if (line != null && line > 0) "$base&line=$line" else base
29− }
30−}
app/src/main/java/soy/iko/opencode/ui/VmFactory.ktdeleted−16
@@ -1,16 +0,0 @@
1−package soy.iko.opencode.ui
2−
3−import androidx.lifecycle.ViewModel
4−import androidx.lifecycle.viewmodel.CreationExtras
5−import androidx.lifecycle.viewmodel.initializer
6−import androidx.lifecycle.viewmodel.viewModelFactory
7−
8−/**
9− * Tiny bridge between manual DI and Compose's `viewModel()`. Builds a
10− * [androidx.lifecycle.ViewModelProvider.Factory] from a plain constructor lambda.
11− */
12−inline fun <reified VM : ViewModel> vmFactory(
13− crossinline create: (CreationExtras) -> VM,
14−) = viewModelFactory {
15− initializer { create(this) }
16−}
app/src/main/java/soy/iko/opencode/ui/chat/AgentPickerSheet.ktdeleted−275
@@ -1,275 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.compose.foundation.clickable
4−import androidx.compose.foundation.layout.Column
5−import androidx.compose.foundation.layout.Row
6−import androidx.compose.foundation.layout.fillMaxWidth
7−import androidx.compose.foundation.layout.heightIn
8−import androidx.compose.foundation.layout.padding
9−import androidx.compose.foundation.layout.size
10−import androidx.compose.foundation.lazy.LazyColumn
11−import androidx.compose.foundation.lazy.items
12−import androidx.compose.foundation.lazy.rememberLazyListState
13−import androidx.compose.foundation.text.KeyboardActions
14−import androidx.compose.foundation.text.KeyboardOptions
15−import androidx.compose.material.icons.Icons
16−import androidx.compose.material.icons.filled.Check
17−import androidx.compose.material.icons.filled.Close
18−import androidx.compose.material.icons.filled.Search
19−import androidx.compose.material3.CircularProgressIndicator
20−import androidx.compose.material3.ExperimentalMaterial3Api
21−import androidx.compose.material3.Icon
22−import androidx.compose.material3.IconButton
23−import androidx.compose.material3.MaterialTheme
24−import androidx.compose.material3.ModalBottomSheet
25−import androidx.compose.material3.OutlinedTextField
26−import androidx.compose.material3.Text
27−import androidx.compose.material3.TextButton
28−import androidx.compose.runtime.Composable
29−import androidx.compose.runtime.LaunchedEffect
30−import androidx.compose.runtime.getValue
31−import androidx.compose.runtime.mutableStateOf
32−import androidx.compose.runtime.remember
33−import androidx.compose.runtime.saveable.rememberSaveable
34−import androidx.compose.runtime.setValue
35−import androidx.compose.ui.Alignment
36−import androidx.compose.ui.Modifier
37−import androidx.compose.ui.focus.FocusRequester
38−import androidx.compose.ui.focus.focusRequester
39−import androidx.compose.ui.hapticfeedback.HapticFeedbackType
40−import androidx.compose.ui.platform.LocalHapticFeedback
41−import androidx.compose.ui.platform.LocalSoftwareKeyboardController
42−import androidx.compose.ui.res.stringResource
43−import androidx.compose.ui.semantics.Role
44−import androidx.compose.ui.semantics.contentDescription
45−import androidx.compose.ui.semantics.role
46−import androidx.compose.ui.semantics.selected
47−import androidx.compose.ui.semantics.semantics
48−import androidx.compose.ui.text.input.ImeAction
49−import androidx.compose.ui.unit.dp
50−import androidx.compose.foundation.layout.Box
51−import androidx.compose.foundation.layout.fillMaxHeight
52−import soy.iko.opencode.data.model.Agent
53−import soy.iko.opencode.data.network.NetworkConfig
54−import soy.iko.opencode.R
55−
56−/**
57− * Flat LazyColumn index of the selected agent, offset by the leading "Default" row when it's
58− * shown. A null selection targets that Default row (index 0). -1 when the selection isn't in
59− * the current list.
60− */
61−/** Whether the synthetic "Default" agent row matches the current search query. */
62−private fun defaultAgentMatches(query: String, label: String, desc: String): Boolean =
63− query.isEmpty() ||
64− label.contains(query, ignoreCase = true) ||
65− desc.contains(query, ignoreCase = true)
66−
67−private fun selectedAgentIndex(
68− distinctAgents: List<Agent>,
69− selected: String?,
70− defaultMatches: Boolean,
71−): Int {
72− if (selected == null) return if (defaultMatches) 0 else -1
73− val base = if (defaultMatches) 1 else 0
74− val pos = distinctAgents.indexOfFirst { it.name == selected }
75− return if (pos >= 0) base + pos else -1
76−}
77−
78−/** Bottom sheet that lists every agent and lets the user pick one. */
79−@OptIn(ExperimentalMaterial3Api::class)
80−@Composable
81−fun AgentPickerSheet(
82− agents: List<Agent>,
83− selected: String?,
84− loading: Boolean,
85− error: Boolean,
86− onSelect: (Agent?) -> Unit,
87− onRetry: () -> Unit,
88− onDismiss: () -> Unit,
89−) {
90− ModalBottomSheet(onDismissRequest = onDismiss) {
91− Text(
92− stringResource(R.string.agent),
93− style = MaterialTheme.typography.titleMedium,
94− modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
95− )
96− if (loading) {
97− val loadingLabel = stringResource(R.string.loading)
98− Box(
99− modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
100− contentAlignment = Alignment.Center,
101− ) {
102− CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
103− }
104− } else if (error && agents.isEmpty()) {
105− Column(
106− modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
107− horizontalAlignment = Alignment.CenterHorizontally,
108− ) {
109− Text(
110− stringResource(R.string.load_failed),
111− style = MaterialTheme.typography.bodyMedium,
112− color = MaterialTheme.colorScheme.error,
113− )
114− TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
115− }
116− } else if (agents.isEmpty()) {
117− Text(
118− stringResource(R.string.no_agents),
119− style = MaterialTheme.typography.bodyMedium,
120− color = MaterialTheme.colorScheme.onSurfaceVariant,
121− modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
122− )
123− } else {
124− var query by rememberSaveable { mutableStateOf("") }
125− val keyboardController = LocalSoftwareKeyboardController.current
126− val haptics = LocalHapticFeedback.current
127− // Auto-focus the search field (and raise the keyboard) when the sheet opens, but skip
128− // it for a short catalog that's faster to scan by eye than to filter.
129− val searchFocus = remember { FocusRequester() }
130− LaunchedEffect(Unit) {
131− if (agents.size > NetworkConfig.pickerSearchAutofocusThreshold) {
132− runCatching { searchFocus.requestFocus() }
133− }
134− }
135− OutlinedTextField(
136− value = query,
137− onValueChange = { query = it },
138− modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp).focusRequester(searchFocus),
139− placeholder = { Text(stringResource(R.string.search_agents)) },
140− leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
141− trailingIcon = if (query.isNotEmpty()) {
142− {
143− IconButton(onClick = { query = "" }) {
144− Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
145− }
146− }
147− } else null,
148− singleLine = true,
149− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
150− keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
151− )
152− val filtered = remember(agents, query) {
153− val q = query.trim()
154− if (q.isEmpty()) agents
155− else agents.filter {
156− it.name.contains(q, ignoreCase = true) ||
157− it.displayDescription.contains(q, ignoreCase = true)
158− }
159− }
160− // The "Default" option participates in the search too: show it when the query is
161− // empty or matches its label/description. Without this it either always showed
162− // (ignoring the filter) or, when a query matched no named agent, disappeared with
163− // the whole list — leaving the default unselectable.
164− val defaultLabel = stringResource(R.string.default_agent)
165− val defaultDesc = stringResource(R.string.default_agent_desc)
166− val q = query.trim()
167− val defaultMatches = defaultAgentMatches(q, defaultLabel, defaultDesc)
168− if (filtered.isEmpty() && !defaultMatches) {
169− Text(
170− stringResource(R.string.no_agents_match, query.trim()),
171− style = MaterialTheme.typography.bodyMedium,
172− color = MaterialTheme.colorScheme.onSurfaceVariant,
173− modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
174− )
175− } else {
176− val listState = rememberLazyListState()
177− val distinctAgents = remember(filtered) { filtered.distinctBy { it.name } }
178− // Flat index of the selected agent, offset by the leading "Default" row when
179− // it's shown. selected == null targets that Default row (index 0). -1 when the
180− // selection isn't in the current list.
181− val selectedIndex = remember(distinctAgents, selected, defaultMatches) {
182− selectedAgentIndex(distinctAgents, selected, defaultMatches)
183− }
184− LaunchedEffect(Unit) {
185− if (selectedIndex >= 0) listState.scrollToItem(selectedIndex)
186− }
187− // Responsive height: half the viewport rather than a hardcoded 420dp cap, so
188− // the sheet doesn't cramp in landscape or leave empty space on a tall phone.
189− LazyColumn(state = listState, modifier = Modifier.fillMaxHeight(0.5f)) {
190− if (defaultMatches) {
191− item(key = "__default") {
192− Column(
193− modifier = Modifier
194− .fillMaxWidth()
195− .semantics(mergeDescendants = true) {
196− if (selected == null) this.selected = true
197− }
198− .clickable(role = Role.RadioButton) {
199− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
200− onSelect(null); onDismiss()
201− }
202− .padding(horizontal = 24.dp, vertical = 12.dp),
203− ) {
204− Row(verticalAlignment = Alignment.CenterVertically) {
205− Text(
206− stringResource(R.string.default_agent),
207− style = MaterialTheme.typography.bodyLarge,
208− color = if (selected == null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
209− modifier = Modifier.weight(1f),
210− )
211− if (selected == null) {
212− Icon(
213− Icons.Filled.Check,
214− contentDescription = null,
215− tint = MaterialTheme.colorScheme.primary,
216− modifier = Modifier.size(18.dp),
217− )
218− }
219− }
220− Text(
221− stringResource(R.string.default_agent_desc),
222− style = MaterialTheme.typography.bodySmall,
223− color = MaterialTheme.colorScheme.onSurfaceVariant,
224− )
225− }
226− }
227− }
228− // distinctBy the name so a server that returns two agents with the same name
229− // (e.g. a built-in colliding with a project-level one) can't crash the list
230− // with LazyColumn's "Key was already used".
231− // Namespace the agent keys so an agent literally named "__default" can't
232− // collide with the sentinel key of the default item above (a duplicate key
233− // crashes LazyColumn).
234− items(distinctAgents, key = { "agent_" + it.name }) { agent ->
235− val isSelected = agent.name == selected
236− Column(
237− modifier = Modifier
238− .fillMaxWidth()
239− .semantics(mergeDescendants = true) {
240− if (isSelected) this.selected = true
241− }
242− .clickable(role = Role.RadioButton) {
243− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
244− onSelect(agent); onDismiss()
245− }
246− .padding(horizontal = 24.dp, vertical = 12.dp),
247− ) {
248− Row(verticalAlignment = Alignment.CenterVertically) {
249− Text(
250− agent.name,
251− style = MaterialTheme.typography.bodyLarge,
252− color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
253− modifier = Modifier.weight(1f),
254− )
255− if (isSelected) {
256− Icon(
257− Icons.Filled.Check,
258− contentDescription = null,
259− tint = MaterialTheme.colorScheme.primary,
260− modifier = Modifier.size(18.dp),
261− )
262− }
263− }
264− Text(
265− agent.displayDescription,
266− style = MaterialTheme.typography.bodySmall,
267− color = MaterialTheme.colorScheme.onSurfaceVariant,
268− )
269− }
270− }
271− }
272− }
273− }
274− }
275−}
app/src/main/java/soy/iko/opencode/ui/chat/AttachmentResult.ktdeleted−12
@@ -1,12 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−/** Outcome of turning a picked Uri into a [PendingAttachment] (see `Uri.toAttachmentResult`). */
4−sealed interface AttachmentResult {
5− data class Ok(val attachment: PendingAttachment) : AttachmentResult
6−
7− /** The file exceeded the size cap ([soy.iko.opencode.data.network.NetworkConfig.maxAttachmentBytes]). */
8− data object TooLarge : AttachmentResult
9−
10− /** The file couldn't be read/resolved. */
11− data object Failed : AttachmentResult
12−}
app/src/main/java/soy/iko/opencode/ui/chat/Attachments.ktdeleted−115
@@ -1,115 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import android.content.Context
4−import android.net.Uri
5−import android.provider.OpenableColumns
6−import android.util.Base64
7−import androidx.core.content.FileProvider
8−import soy.iko.opencode.data.model.FilePromptPart
9−import soy.iko.opencode.data.network.NetworkConfig
10−import soy.iko.opencode.util.runCatchingCancellable
11−import kotlinx.coroutines.Dispatchers
12−import kotlinx.coroutines.withContext
13−import java.io.File
14−
15−/**
16− * Read [uri]'s bytes off the main thread, base64-encode them into a `data:` URL, and wrap
17− * the result as a [PendingAttachment] ready to send. Enforces the size cap. The original
18− * [uri] is kept as the thumbnail model for images (Coil loads content Uris directly).
19− */
20−suspend fun Uri.toAttachmentResult(context: Context): AttachmentResult = withContext(Dispatchers.IO) {
21− val resolver = context.contentResolver
22− val mime = resolver.getType(this@toAttachmentResult) ?: "application/octet-stream"
23− val name = displayName(context) ?: defaultName(mime)
24− val max = NetworkConfig.maxAttachmentBytes
25− // Cheap pre-check: if the provider reports a size, reject an oversize file before reading
26− // a single byte into memory. The picker can hand back arbitrarily large files (e.g. a
27− // multi-hundred-MB video), so reading first and checking after would OOM the app.
28− val declaredSize = fileSize(context)
29− if (declaredSize != null && declaredSize > max) return@withContext AttachmentResult.TooLarge
30− // Read with a hard cap so a provider that under-reports or omits its size still can't OOM
31− // us: stop as soon as we've read one byte past the cap, then reject.
32− val bytes = runCatchingCancellable {
33− resolver.openInputStream(this@toAttachmentResult)?.use { it.readAtMost(max + 1) }
34− }.getOrNull() ?: return@withContext AttachmentResult.Failed
35− if (bytes.size > max) return@withContext AttachmentResult.TooLarge
36− val b64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
37− val dataUrl = "data:$mime;base64,$b64"
38− AttachmentResult.Ok(
39− PendingAttachment(
40− id = java.util.UUID.randomUUID().toString(),
41− name = name,
42− mime = mime,
43− previewModel = if (mime.startsWith("image/")) this@toAttachmentResult.toString() else null,
44− part = FilePromptPart(mime = mime, url = dataUrl, filename = name),
45− ),
46− )
47−}
48−
49−/** The provider-reported byte size of the content [uri], or null if it isn't exposed. */
50−private fun Uri.fileSize(context: Context): Long? = runCatching {
51− context.contentResolver.query(this, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor ->
52− val idx = cursor.getColumnIndex(OpenableColumns.SIZE)
53− if (idx >= 0 && cursor.moveToFirst() && !cursor.isNull(idx)) cursor.getLong(idx) else null
54− }
55−}.getOrNull()
56−
57−/** Read up to [limit] bytes from the stream without materializing the whole file, so an
58− * oversize source can be rejected on a bounded amount of memory. Reaching [limit] bytes is
59− * the caller's signal that the source exceeds the cap. */
60−private fun java.io.InputStream.readAtMost(limit: Long): ByteArray {
61− val out = java.io.ByteArrayOutputStream()
62− val chunk = ByteArray(8 * 1024)
63− var total = 0L
64− while (total < limit) {
65− val want = minOf(chunk.size.toLong(), limit - total).toInt()
66− val read = read(chunk, 0, want)
67− if (read < 0) break
68− out.write(chunk, 0, read)
69− total += read
70− }
71− return out.toByteArray()
72−}
73−
74−/** The raw (pre-base64) byte size encoded in a `data:<mime>;base64,<payload>` URL, derived from
75− * the payload length (len / 4 * 3, less any '=' padding) without allocating or decoding the
76− * payload — important since the payload can be many MB. Returns 0 when the URL has no base64
77− * payload. Lets the cumulative attachment-size cap use the same raw-byte measure as the per-file
78− * [NetworkConfig.maxAttachmentBytes] check above (which counts raw `bytes.size`). */
79−internal fun base64DataUrlByteSize(dataUrl: String): Long {
80− val marker = "base64,"
81− val start = dataUrl.indexOf(marker)
82− if (start < 0) return 0L
83− val payloadLen = dataUrl.length - (start + marker.length)
84− if (payloadLen <= 0) return 0L
85− val padding = when {
86− dataUrl.endsWith("==") -> 2
87− dataUrl.endsWith("=") -> 1
88− else -> 0
89− }
90− return payloadLen.toLong() / 4 * 3 - padding
91−}
92−
93−/** Resolve a human-readable display name for the content [uri], if the provider exposes one. */
94−private fun Uri.displayName(context: Context): String? = runCatching {
95− context.contentResolver.query(this, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
96− val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
97− if (idx >= 0 && cursor.moveToFirst()) cursor.getString(idx) else null
98− }
99−}.getOrNull()?.takeIf { it.isNotBlank() }
100−
101−private fun defaultName(mime: String): String {
102− val ext = mime.substringAfterLast('/', "bin").takeIf { it.isNotBlank() } ?: "bin"
103− return "attachment.$ext"
104−}
105−
106−/**
107− * Create a temp file in the cache and a shareable content [Uri] for it (via the app's
108− * [FileProvider]) to hand to the camera as the capture target. Returns null if it can't be
109− * created. The file is overwritten on each capture; the OS cleans the cache dir.
110− */
111−fun newCameraCaptureUri(context: Context): Uri? = runCatching {
112− val dir = File(context.cacheDir, "captures").apply { mkdirs() }
113− val file = File(dir, "capture_${System.currentTimeMillis()}.jpg")
114− FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
115−}.getOrNull()
app/src/main/java/soy/iko/opencode/ui/chat/ChatAttachments.ktdeleted−133
@@ -1,133 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.compose.foundation.layout.Arrangement
4−import androidx.compose.foundation.layout.Row
5−import androidx.compose.foundation.layout.fillMaxWidth
6−import androidx.compose.foundation.layout.padding
7−import androidx.compose.foundation.layout.size
8−import androidx.compose.foundation.layout.widthIn
9−import androidx.compose.foundation.horizontalScroll
10−import androidx.compose.foundation.rememberScrollState
11−import androidx.compose.material.icons.Icons
12−import androidx.compose.material.icons.filled.Close
13−import androidx.compose.material.icons.filled.Description
14−import androidx.compose.material3.CircularProgressIndicator
15−import androidx.compose.material3.Icon
16−import androidx.compose.material3.IconButton
17−import androidx.compose.material3.MaterialTheme
18−import androidx.compose.material3.Surface
19−import androidx.compose.material3.Text
20−import androidx.compose.runtime.Composable
21−import androidx.compose.ui.Alignment
22−import androidx.compose.ui.Modifier
23−import androidx.compose.ui.draw.clip
24−import androidx.compose.ui.layout.ContentScale
25−import androidx.compose.ui.res.stringResource
26−import androidx.compose.ui.semantics.contentDescription
27−import androidx.compose.ui.semantics.semantics
28−import androidx.compose.ui.text.style.TextOverflow
29−import androidx.compose.ui.unit.dp
30−import coil.compose.AsyncImage
31−import soy.iko.opencode.R
32−
33−/**
34− * The staged-attachment UI for the chat composer — extracted from ChatScreen so that (large)
35− * file stays focused on the conversation surface. `internal` so ChatScreen can call it.
36− */
37−@Composable
38−internal fun AttachmentStrip(
39− attachments: List<PendingAttachment>,
40− onRemove: (String) -> Unit,
41− staging: Boolean = false,
42−) {
43− // Show the strip while an encode is in flight even before the first chip exists, so the
44− // user gets immediate feedback that their pick is being processed.
45− if (attachments.isEmpty() && !staging) return
46− // Horizontally-scrollable strip of staged attachment thumbnails/chips, each removable.
47− Row(
48− modifier = Modifier
49− .fillMaxWidth()
50− .horizontalScroll(rememberScrollState())
51− .padding(start = 8.dp, end = 8.dp, top = 8.dp),
52− horizontalArrangement = Arrangement.spacedBy(8.dp),
53− ) {
54− attachments.forEach { att ->
55− AttachmentChip(att, onRemove = { onRemove(att.id) })
56− }
57− // A placeholder chip with an indeterminate spinner while any pick is still being
58− // read + base64-encoded off the main thread (chips only materialize once done).
59− if (staging) StagingChip()
60− }
61−}
62−
63−/** A placeholder chip shown while an attachment is being encoded. */
64−@Composable
65−private fun StagingChip() {
66− val stagingLabel = stringResource(R.string.attachment_staging)
67− Surface(
68− shape = MaterialTheme.shapes.small,
69− color = MaterialTheme.colorScheme.surfaceVariant,
70− tonalElevation = 1.dp,
71− ) {
72− Row(
73− verticalAlignment = Alignment.CenterVertically,
74− modifier = Modifier.padding(horizontal = 10.dp),
75− ) {
76− CircularProgressIndicator(
77− Modifier.size(18.dp).semantics { contentDescription = stagingLabel },
78− strokeWidth = 2.dp,
79− )
80− Text(
81− stagingLabel,
82− style = MaterialTheme.typography.labelMedium,
83− maxLines = 1,
84− modifier = Modifier.padding(start = 8.dp, top = 10.dp, bottom = 10.dp),
85− )
86− }
87− }
88−}
89−
90−/** A single staged attachment: an image thumbnail (or a generic file icon) with its name
91− * and a remove button. */
92−@Composable
93−private fun AttachmentChip(attachment: PendingAttachment, onRemove: () -> Unit) {
94− Surface(
95− shape = MaterialTheme.shapes.small,
96− color = MaterialTheme.colorScheme.surfaceVariant,
97− tonalElevation = 1.dp,
98− ) {
99− Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 6.dp)) {
100− if (attachment.previewModel != null) {
101− AsyncImage(
102− model = attachment.previewModel,
103− contentDescription = attachment.name,
104− contentScale = ContentScale.Crop,
105− modifier = Modifier.size(36.dp).clip(MaterialTheme.shapes.extraSmall),
106− )
107− } else {
108− Icon(
109− Icons.Filled.Description,
110− contentDescription = null,
111− modifier = Modifier.size(24.dp),
112− tint = MaterialTheme.colorScheme.onSurfaceVariant,
113− )
114− }
115− Text(
116− attachment.name,
117− style = MaterialTheme.typography.labelMedium,
118− maxLines = 1,
119− overflow = TextOverflow.Ellipsis,
120− modifier = Modifier.widthIn(max = 120.dp).padding(horizontal = 6.dp),
121− )
122− // No .size() override so the IconButton keeps its default 48dp touch target
123− // (a11y minimum) while the visual X stays small (16dp).
124− IconButton(onClick = onRemove) {
125− Icon(
126− Icons.Filled.Close,
127− contentDescription = stringResource(R.string.remove),
128− modifier = Modifier.size(16.dp),
129− )
130− }
131− }
132− }
133−}
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.ktdeleted−2604
@@ -1,2604 +0,0 @@
1−@file:Suppress("TooManyFunctions")
2−
3−package soy.iko.opencode.ui.chat
4−
5−import android.app.Activity
6−import android.content.Intent
7−import android.net.Uri
8−import android.speech.RecognizerIntent
9−import androidx.activity.compose.BackHandler
10−import androidx.activity.compose.rememberLauncherForActivityResult
11−import androidx.activity.result.PickVisualMediaRequest
12−import androidx.activity.result.contract.ActivityResultContracts
13−import androidx.compose.animation.AnimatedVisibility
14−import androidx.compose.animation.core.RepeatMode
15−import androidx.compose.animation.core.animateFloat
16−import androidx.compose.animation.core.infiniteRepeatable
17−import androidx.compose.animation.core.rememberInfiniteTransition
18−import androidx.compose.animation.core.tween
19−import androidx.compose.foundation.background
20−import androidx.compose.foundation.clickable
21−import androidx.compose.foundation.content.contentReceiver
22−import androidx.compose.foundation.layout.Arrangement
23−import androidx.compose.foundation.layout.Box
24−import androidx.compose.foundation.layout.Column
25−import androidx.compose.foundation.layout.FlowRow
26−import androidx.compose.foundation.layout.PaddingValues
27−import androidx.compose.foundation.layout.Row
28−import androidx.compose.foundation.layout.Spacer
29−import androidx.compose.foundation.layout.WindowInsets
30−import androidx.compose.foundation.layout.fillMaxSize
31−import androidx.compose.foundation.layout.fillMaxWidth
32−import androidx.compose.foundation.layout.height
33−import androidx.compose.foundation.layout.imePadding
34−import androidx.compose.foundation.layout.navigationBars
35−import androidx.compose.foundation.layout.padding
36−import androidx.compose.foundation.layout.size
37−import androidx.compose.foundation.layout.union
38−import androidx.compose.foundation.layout.widthIn
39−import androidx.compose.foundation.layout.windowInsetsPadding
40−import androidx.compose.foundation.layout.wrapContentWidth
41−import androidx.compose.foundation.lazy.LazyColumn
42−import androidx.compose.foundation.lazy.items
43−import androidx.compose.foundation.lazy.rememberLazyListState
44−import androidx.compose.foundation.shape.RoundedCornerShape
45−import androidx.compose.material.icons.Icons
46−import androidx.compose.material.icons.automirrored.filled.ArrowBack
47−import androidx.compose.material.icons.automirrored.filled.Send
48−import androidx.compose.material.icons.filled.Add
49−import androidx.compose.material.icons.filled.AutoAwesome
50−import androidx.compose.material.icons.filled.Delete
51−import androidx.compose.material.icons.filled.Description
52−import androidx.compose.material.icons.filled.Close
53−import androidx.compose.material.icons.filled.Edit
54−import androidx.compose.material.icons.filled.Expand
55−import androidx.compose.material.icons.filled.ExpandLess
56−import androidx.compose.material.icons.filled.ExpandMore
57−import androidx.compose.material.icons.filled.Search
58−import androidx.compose.material.icons.filled.KeyboardArrowDown
59−import androidx.compose.material.icons.filled.KeyboardArrowUp
60−import androidx.compose.material.icons.filled.Mic
61−import androidx.compose.material.icons.filled.MoreVert
62−import androidx.compose.material.icons.filled.PhotoCamera
63−import androidx.compose.material.icons.filled.PhotoLibrary
64−import androidx.compose.material.icons.filled.Refresh
65−import androidx.compose.material.icons.filled.Restore
66−import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
67−import androidx.compose.material.icons.automirrored.filled.Reply
68−import androidx.compose.material.icons.filled.Schedule
69−import androidx.compose.material.icons.filled.Stop
70−import androidx.compose.material3.AlertDialog
71−import androidx.compose.material3.Badge
72−import androidx.compose.material3.BadgedBox
73−import androidx.compose.material3.Button
74−import androidx.compose.material3.CircularProgressIndicator
75−import androidx.compose.material3.SmallFloatingActionButton
76−import androidx.compose.material3.DropdownMenu
77−import androidx.compose.material3.DropdownMenuItem
78−import androidx.compose.material3.ExperimentalMaterial3Api
79−import androidx.compose.material3.ExtendedFloatingActionButton
80−import androidx.compose.material3.FloatingActionButton
81−import androidx.compose.material3.Icon
82−import androidx.compose.material3.IconButton
83−import androidx.compose.material3.MaterialTheme
84−import androidx.compose.material3.OutlinedTextField
85−import androidx.compose.material3.Scaffold
86−import androidx.compose.material3.SnackbarDuration
87−import androidx.compose.material3.SwipeToDismissBox
88−import androidx.compose.material3.SwipeToDismissBoxValue
89−import androidx.compose.material3.rememberSwipeToDismissBoxState
90−import androidx.compose.material3.SnackbarHost
91−import androidx.compose.material3.SnackbarHostState
92−import androidx.compose.material3.SnackbarResult
93−import androidx.compose.material3.Surface
94−import androidx.compose.material3.Text
95−import androidx.compose.material3.TextButton
96−import androidx.compose.material3.TopAppBar
97−import androidx.compose.material3.pulltorefresh.PullToRefreshBox
98−import androidx.compose.runtime.Composable
99−import androidx.compose.runtime.CompositionLocalProvider
100−import androidx.compose.runtime.DisposableEffect
101−import androidx.compose.runtime.LaunchedEffect
102−import androidx.compose.runtime.derivedStateOf
103−import androidx.compose.runtime.getValue
104−import androidx.compose.runtime.mutableIntStateOf
105−import androidx.compose.runtime.mutableLongStateOf
106−import androidx.compose.runtime.mutableStateOf
107−import androidx.compose.runtime.produceState
108−import androidx.compose.runtime.remember
109−import androidx.compose.runtime.rememberCoroutineScope
110−import androidx.compose.runtime.rememberUpdatedState
111−import androidx.compose.runtime.saveable.rememberSaveable
112−import androidx.compose.runtime.setValue
113−import androidx.compose.runtime.snapshotFlow
114−import androidx.compose.foundation.text.KeyboardActions
115−import androidx.compose.foundation.text.KeyboardOptions
116−import androidx.compose.ui.text.input.KeyboardCapitalization
117−import androidx.compose.ui.Alignment
118−import androidx.compose.ui.Modifier
119−import androidx.compose.ui.draw.alpha
120−import androidx.compose.ui.draw.clip
121−import androidx.compose.ui.focus.focusRequester
122−import androidx.compose.ui.input.key.Key
123−import androidx.compose.ui.input.key.KeyEventType
124−import androidx.compose.ui.input.key.isCtrlPressed
125−import androidx.compose.ui.input.key.isShiftPressed
126−import androidx.compose.ui.input.key.key
127−import androidx.compose.ui.input.key.onPreviewKeyEvent
128−import androidx.compose.ui.input.key.type
129−import androidx.compose.ui.layout.onSizeChanged
130−import androidx.compose.ui.platform.LocalContext
131−import androidx.compose.ui.platform.LocalDensity
132−import androidx.compose.ui.platform.LocalHapticFeedback
133−import androidx.compose.ui.platform.LocalView
134−import androidx.compose.ui.platform.testTag
135−import androidx.compose.ui.res.stringResource
136−import androidx.compose.ui.semantics.Role
137−import androidx.compose.ui.semantics.contentDescription
138−import androidx.compose.ui.semantics.heading
139−import androidx.compose.ui.semantics.semantics
140−import androidx.compose.ui.text.input.ImeAction
141−import androidx.compose.ui.text.style.TextOverflow
142−import androidx.compose.ui.unit.dp
143−import androidx.lifecycle.compose.collectAsStateWithLifecycle
144−import androidx.lifecycle.viewmodel.compose.viewModel
145−import kotlinx.coroutines.Dispatchers
146−import kotlinx.coroutines.coroutineScope
147−import kotlinx.coroutines.delay
148−import kotlinx.coroutines.flow.distinctUntilChanged
149−import kotlinx.coroutines.launch
150−import kotlinx.coroutines.withContext
151−import soy.iko.opencode.data.model.Part
152−import soy.iko.opencode.data.model.MessageWithParts
153−import soy.iko.opencode.data.model.ReasoningPart
154−import soy.iko.opencode.data.model.TextPart
155−import soy.iko.opencode.data.model.ToolCompleted
156−import soy.iko.opencode.data.model.ToolError
157−import soy.iko.opencode.data.model.ToolPart
158−import soy.iko.opencode.data.model.ToolRunning
159−import soy.iko.opencode.data.network.EventStreamClient
160−import soy.iko.opencode.data.network.NetworkConfig
161−import soy.iko.opencode.di.AppContainer
162−import soy.iko.opencode.R
163−import soy.iko.opencode.ui.components.CommandPalette
164−import soy.iko.opencode.ui.components.ConnectionBanner
165−import soy.iko.opencode.ui.components.DiffView
166−import soy.iko.opencode.ui.components.PaletteAction
167−import soy.iko.opencode.ui.components.LocalReducedMotion
168−import soy.iko.opencode.ui.components.copyToClipboard
169−import soy.iko.opencode.ui.components.LocalRelativeTimeTick
170−import soy.iko.opencode.ui.components.rememberRelativeTimeTick
171−import soy.iko.opencode.ui.components.rememberVisibilityTransitions
172−import soy.iko.opencode.ui.components.showToast
173−import soy.iko.opencode.ui.components.toImageContext
174−import soy.iko.opencode.ui.vmFactory
175−import soy.iko.opencode.util.runCatchingCancellable
176−
177−@OptIn(ExperimentalMaterial3Api::class)
178−@Composable
179−fun ChatScreen(
180− container: AppContainer,
181− sessionId: String,
182− onBack: () -> Unit,
183− onOpenFile: ((String) -> Unit)? = null,
184− onOpenSession: ((String) -> Unit)? = null,
185−) {
186− val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
187− val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
188− val running by vm.running.collectAsStateWithLifecycle()
189− val aborting by vm.aborting.collectAsStateWithLifecycle()
190− val loading by vm.loading.collectAsStateWithLifecycle()
191− val loadError by vm.loadError.collectAsStateWithLifecycle()
192− val refreshing by vm.refreshing.collectAsStateWithLifecycle()
193− val models by vm.models.collectAsStateWithLifecycle()
194− val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
195− val modelsError by vm.modelsError.collectAsStateWithLifecycle()
196− val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
197− val connectionState by vm.connectionState.collectAsStateWithLifecycle()
198− val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
199− val permissionProgress by vm.permissionProgress.collectAsStateWithLifecycle()
200− val agents by vm.agents.collectAsStateWithLifecycle()
201− val agentsLoading by vm.agentsLoading.collectAsStateWithLifecycle()
202− val agentsError by vm.agentsError.collectAsStateWithLifecycle()
203− val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
204− val commands by vm.commands.collectAsStateWithLifecycle()
205− val commandsLoading by vm.commandsLoading.collectAsStateWithLifecycle()
206− val commandsError by vm.commandsError.collectAsStateWithLifecycle()
207− val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
208− val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
209− val draft by vm.draft.collectAsStateWithLifecycle()
210− val attachments by vm.attachments.collectAsStateWithLifecycle()
211− val reverted by vm.reverted.collectAsStateWithLifecycle()
212− val revertDiff by vm.revertDiff.collectAsStateWithLifecycle()
213− val shareUrl by vm.shareUrl.collectAsStateWithLifecycle()
214− val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
215− val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
216− val isOnline by container.isOnline.collectAsStateWithLifecycle()
217− val haptics = LocalHapticFeedback.current
218− val scope = rememberCoroutineScope()
219− val shareContext = LocalContext.current
220− val defaultShareSubject = stringResource(R.string.share_subject)
221− val sessionLabel = stringResource(R.string.session)
222− val defaultModelLabel = stringResource(R.string.default_model)
223− val subtitle = remember(selectedModel?.modelLabel, selectedAgent, defaultModelLabel) {
224− buildString {
225− append(selectedModel?.modelLabel ?: defaultModelLabel)
226− selectedAgent?.let { append(" · $it") }
227− }
228− }
229−
230− // Connection profile (with auth) for rendering inline image attachments.
231− // Collect as state so a server switch recomposes the image context.
232− val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
233− val imageContext = remember(activeConnection?.profile) {
234− activeConnection?.profile?.toImageContext()
235− }
236−
237− // Mark this session as read while the user is viewing it; clear on leave so new
238− // background activity can badge it again.
239− DisposableEffect(sessionId) {
240− container.setCurrentSession(sessionId)
241− onDispose { if (container.currentSession.value == sessionId) container.setCurrentSession(null) }
242− }
243−
244− // Navigate away when the session is deleted via SSE so the user isn't left
245− // on a zombie screen showing a conversation that no longer exists. Surface a
246− // toast (not a snackbar) because the screen is about to be popped — a snackbar
247− // would be torn down before the user could read it, while a toast survives.
248− // Covers both SSE-driven deletion (deleted on another device) and a user-
249− // initiated delete from the chat overflow menu (deleteSession in ChatViewModel
250− // sets the same flag on success).
251− val deletedContext = LocalContext.current
252− LaunchedEffect(sessionDeleted) {
253− if (sessionDeleted) {
254− showToast(deletedContext, deletedContext.getString(R.string.session_deleted_chat))
255− onBack()
256− }
257− }
258−
259− val snackbar = remember { SnackbarHostState() }
260− val retryLabel = stringResource(R.string.retry)
261− val inputFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
262− var showModelPicker by rememberSaveable { mutableStateOf(false) }
263− var showAgentPicker by rememberSaveable { mutableStateOf(false) }
264− var showTitleMenu by rememberSaveable { mutableStateOf(false) }
265− var showCommandPicker by rememberSaveable { mutableStateOf(false) }
266− var showExitConfirm by rememberSaveable { mutableStateOf(false) }
267− var showStopConfirm by rememberSaveable { mutableStateOf(false) }
268− var showOverflowMenu by rememberSaveable { mutableStateOf(false) }
269− var showRenameDialog by rememberSaveable { mutableStateOf(false) }
270− var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
271− var showShellDialog by rememberSaveable { mutableStateOf(false) }
272− var showPalette by rememberSaveable { mutableStateOf(false) }
273− var showShortcutsDialog by rememberSaveable { mutableStateOf(false) }
274− var searchActive by rememberSaveable { mutableStateOf(false) }
275− var chatSearch by rememberSaveable { mutableStateOf("") }
276− // Index of the currently-focused in-conversation search match (into the filtered list).
277− // Drives the up/down navigation: the search bar's arrows step through matches and scroll
278− // the list to each one, mirroring desktop chat apps (the bare filtered list previously
279− // required reading each message to find the term).
280− var searchPos by rememberSaveable { mutableIntStateOf(0) }
281− // Summarize and init (generate AGENTS.md) are irreversible, so a single tap is gated behind
282− // a confirmation dialog (from both the overflow menu and the command palette).
283− var showSummarizeConfirm by rememberSaveable { mutableStateOf(false) }
284− var showInitConfirm by rememberSaveable { mutableStateOf(false) }
285−
286− // Command-palette entries (keyboard-first: Ctrl+K). Labels resolved here so the remembered
287− // action list doesn't need a Composable context. Actions map to the same handlers the
288− // overflow menu drives, so the palette is a keyboard shortcut to them, not a parallel path.
289− val paletteModelLabel = stringResource(R.string.palette_switch_model)
290− val paletteAgentLabel = stringResource(R.string.palette_choose_agent)
291− val paletteCommandLabel = stringResource(R.string.palette_run_command)
292− val paletteSummarizeLabel = stringResource(R.string.palette_summarize)
293− val paletteRenameLabel = stringResource(R.string.rename_session_chat)
294− val paletteShellLabel = stringResource(R.string.run_shell_command)
295− val paletteActions = remember(
296− paletteModelLabel, paletteAgentLabel, paletteCommandLabel,
297− paletteSummarizeLabel, paletteRenameLabel, paletteShellLabel, hasMessages, running,
298− ) {
299− buildList {
300− add(PaletteAction("model", paletteModelLabel) { showModelPicker = true })
301− add(PaletteAction("agent", paletteAgentLabel) { showAgentPicker = true })
302− add(PaletteAction("command", paletteCommandLabel) { showCommandPicker = true })
303− if (hasMessages && !running) add(PaletteAction("summarize", paletteSummarizeLabel) { showSummarizeConfirm = true })
304− if (!running) add(PaletteAction("shell", paletteShellLabel) { showShellDialog = true })
305− add(PaletteAction("rename", paletteRenameLabel) { showRenameDialog = true })
306− }
307− }
308−
309− // Keep the screen awake while the agent is working in *this* session. The foreground
310− // service that holds process priority during a run is managed app-wide off
311− // container.anyRunActive (see OpencodeApp) so it survives navigating away from the chat
312− // mid-run — otherwise leaving the screen would drop priority and Doze could choke the
313− // SSE stream before the completion notification fires.
314− val currentView = LocalView.current
315− val appContext = LocalContext.current.applicationContext
316− DisposableEffect(running) {
317− currentView.keepScreenOn = running
318− onDispose { currentView.keepScreenOn = false }
319− }
320−
321− LaunchedEffect(Unit) {
322− vm.errorEvents.collect { event ->
323− // Only a failed *send* is retryable; attaching Retry to any other error (e.g. a
324− // message-load failure) would silently re-submit the last prompt on tap.
325− val result = if (event.retryable) {
326− snackbar.showSnackbar(message = event.message, actionLabel = retryLabel)
327− } else {
328− snackbar.showSnackbar(event.message)
329− }
330− if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
331− }
332− }
333−
334− // In-place Undo for a user-initiated session delete. The delete is deferred for an undo
335− // window on the container; show the snackbar over the conversation the user is viewing so
336− // they can cancel without being navigated to the session list first. If the window expires
337− // the scheduled delete sets `sessionDeleted` and the effect below navigates away.
338− val undoLabel = stringResource(R.string.undo)
339− val sessionDeletedLabel = stringResource(R.string.session_deleted_chat)
340− LaunchedEffect(Unit) {
341− vm.deleteUndoEvents.collect {
342− coroutineScope {
343− val dismisser = launch {
344− delay(NetworkConfig.undoDeleteDelayMs)
345− snackbar.currentSnackbarData?.dismiss()
346− }
347− val result = snackbar.showSnackbar(
348− message = sessionDeletedLabel,
349− actionLabel = undoLabel,
350− duration = SnackbarDuration.Indefinite,
351− )
352− dismisser.cancel()
353− if (result == SnackbarResult.ActionPerformed) vm.cancelSessionDelete()
354− }
355− }
356− }
357−
358− // --- Attachments, voice, and share-link plumbing ---
359− val attachTooLargeMsg = stringResource(R.string.attachment_too_large)
360− val attachFailedMsg = stringResource(R.string.attachment_failed)
361− val attachLimitMsg = stringResource(R.string.attachment_limit)
362− val noCameraMsg = stringResource(R.string.no_camera_app)
363− val noVoiceMsg = stringResource(R.string.no_voice_app)
364− val voicePrompt = stringResource(R.string.voice_prompt)
365− val linkCopiedMsg = stringResource(R.string.link_copied)
366− val ttsUnavailableMsg = stringResource(R.string.tts_unavailable)
367−
368− // Count of picks currently being read + base64-encoded off the main thread, so the
369− // composer can show a staging placeholder immediately (chips only materialize once done).
370− var stagingCount by remember { mutableStateOf(0) }
371−
372− // Convert each picked Uri to a base64 attachment off the main thread, honoring the
373− // per-prompt count cap and surfacing per-file errors without aborting the batch.
374− fun stageUris(uris: List<Uri>) {
375− if (uris.isEmpty()) return
376− scope.launch {
377− stagingCount++
378− try {
379− for (uri in uris) {
380− if (vm.attachments.value.size >= NetworkConfig.maxAttachments) {
381− snackbar.showSnackbar(attachLimitMsg)
382− break
383− }
384− when (val result = uri.toAttachmentResult(appContext)) {
385− is AttachmentResult.Ok -> vm.addAttachment(result.attachment)
386− AttachmentResult.TooLarge -> snackbar.showSnackbar(attachTooLargeMsg)
387− AttachmentResult.Failed -> snackbar.showSnackbar(attachFailedMsg)
388− }
389− }
390− } finally {
391− stagingCount--
392− }
393− }
394− }
395−
396− val photoPicker = rememberLauncherForActivityResult(
397− ActivityResultContracts.PickMultipleVisualMedia(NetworkConfig.maxAttachments),
398− ) { uris -> stageUris(uris) }
399−
400− val filePicker = rememberLauncherForActivityResult(
401− ActivityResultContracts.GetMultipleContents(),
402− ) { uris -> stageUris(uris) }
403−
404− // Uri survives a config change mid-capture so the TakePicture result still resolves.
405− var pendingCaptureUri by rememberSaveable { mutableStateOf<Uri?>(null) }
406− val cameraLauncher = rememberLauncherForActivityResult(
407− ActivityResultContracts.TakePicture(),
408− ) { success ->
409− val uri = pendingCaptureUri
410− pendingCaptureUri = null
411− if (success && uri != null) stageUris(listOf(uri))
412− }
413−
414− val voiceLauncher = rememberLauncherForActivityResult(
415− ActivityResultContracts.StartActivityForResult(),
416− ) { result ->
417− if (result.resultCode == Activity.RESULT_OK) {
418− val spoken = result.data
419− ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
420− ?.firstOrNull()
421− ?.takeIf { it.isNotBlank() }
422− if (spoken != null) {
423− // Append to the existing draft rather than replacing, so dictation adds to
424− // whatever the user already typed.
425− val current = vm.draft.value
426− val combined = if (current.isBlank()) spoken else "$current $spoken"
427− vm.updateDraft(combined.take(NetworkConfig.maxDraftLengthChars))
428− }
429− }
430− }
431−
432− fun launchCamera() {
433− val uri = newCameraCaptureUri(appContext)
434− if (uri == null) {
435− scope.launch { snackbar.showSnackbar(attachFailedMsg) }
436− return
437− }
438− pendingCaptureUri = uri
439− runCatching { cameraLauncher.launch(uri) }
440− .onFailure { pendingCaptureUri = null; scope.launch { snackbar.showSnackbar(noCameraMsg) } }
441− }
442−
443− fun launchVoice() {
444− val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
445− putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
446− putExtra(RecognizerIntent.EXTRA_PROMPT, voicePrompt)
447− }
448− runCatching { voiceLauncher.launch(intent) }
449− .onFailure { scope.launch { snackbar.showSnackbar(noVoiceMsg) } }
450− }
451−
452− // A freshly-created share link is copied to the clipboard so the user can paste it right away.
453− LaunchedEffect(Unit) {
454− vm.shareLinkEvents.collect { url ->
455− copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_share), url)
456− snackbar.showSnackbar(linkCopiedMsg)
457− }
458− }
459−
460− // Navigate to a session freshly branched from a message (branchFrom), once it's created.
461− // Keyed on Unit (like the sibling one-shot collectors above): keying on the onOpenSession
462− // lambda would cancel/restart this collector whenever the lambda's identity changes, risking
463− // a dropped branch-navigation event in the gap.
464− val branchedMsg = stringResource(R.string.branch_created)
465− LaunchedEffect(Unit) {
466− vm.branchEvents.collect { newId ->
467− showToast(shareContext, branchedMsg)
468− onOpenSession?.invoke(newId)
469− }
470− }
471−
472− // Stage images shared into the app (via the system share sheet) as attachments.
473− // Keyed on pendingSharedMedia (not Unit) so the effect re-fires when new media arrives
474− // while this ChatScreen is already composed — which happens in two-pane mode, where the
475− // detail ChatScreen is persistent (keyed on sessionId) and a LaunchedEffect(Unit) would
476− // only fire on first composition. consumePendingSharedMedia() atomically drains the
477− // pending list (compareAndSet to empty), so re-firing on the now-empty value is a no-op.
478− val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
479− LaunchedEffect(pendingSharedMedia) {
480− val shared = container.consumePendingSharedMedia()
481− if (shared.isNotEmpty()) stageUris(shared.map { Uri.parse(it) })
482− }
483−
484− fun doSend() {
485− if (vm.send(draft)) {
486− // TextHandleMove (a light tick) is semantically right for a send commit;
487− // LongPress is reserved for long-press/swipe gestures.
488− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
489− }
490− }
491−
492− BackHandler(enabled = running && !showModelPicker && !showAgentPicker && !showCommandPicker) { showExitConfirm = true }
493−
494− Scaffold(
495− // Hardware-keyboard shortcuts (tablets / DeX / Chromebooks): Ctrl+K opens the command
496− // palette; Escape closes it, or stops a run. onPreviewKeyEvent on the Scaffold root sees
497− // events before the focused text field, but only Ctrl+K / Escape are consumed so normal
498− // typing (and the composer's own Enter-to-send handling) is untouched.
499− modifier = Modifier.onPreviewKeyEvent { ev ->
500− if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
501− when {
502− ev.isCtrlPressed && ev.key == Key.K -> { showPalette = true; true }
503− // Ctrl+F opens in-conversation search (the standard find shortcut), focusing
504− // the field so the user can type immediately.
505− ev.isCtrlPressed && ev.key == Key.F -> { searchActive = true; true }
506− ev.key == Key.Escape && showPalette -> { showPalette = false; true }
507− ev.key == Key.Escape && searchActive -> { searchActive = false; chatSearch = ""; true }
508− ev.key == Key.Escape && running && !showStopConfirm && !showExitConfirm -> {
509− showStopConfirm = true; true
510− }
511− else -> false
512− }
513− },
514− topBar = {
515− TopAppBar(
516− title = {
517− val changeLabel = stringResource(R.string.change_model_agent)
518− // The subtitle reads "model · agent", so tapping the title offers *both*
519− // — a small menu picks which picker to open. Previously the tap went
520− // straight to the model picker, leaving the agent changeable only via
521− // the overflow menu despite the title implying otherwise.
522− Box {
523− Row(
524− // mergeDescendants so TalkBack reads the title/subtitle Texts as one
525− // node; the click label rides on `clickable` (onClickLabel) instead of
526− // an explicit contentDescription, which would have SUPPRESSED the child
527− // Texts and announced only "Change model and agent".
528− modifier = Modifier
529− .clickable(
530− role = Role.Button,
531− onClickLabel = changeLabel,
532− ) { showTitleMenu = true }
533− .semantics(mergeDescendants = true) {},
534− verticalAlignment = Alignment.CenterVertically,
535− ) {
536− Column(modifier = Modifier.weight(1f, fill = false)) {
537− // maxLines=1 + Ellipsis so a long session title can't wrap and
538− // push the top bar to multiple lines, shifting the whole layout.
539− Text(
540− sessionTitle ?: sessionLabel,
541− maxLines = 1,
542− overflow = TextOverflow.Ellipsis,
543− )
544− Text(
545− subtitle,
546− style = MaterialTheme.typography.labelSmall,
547− color = MaterialTheme.colorScheme.onSurfaceVariant,
548− maxLines = 1,
549− overflow = TextOverflow.Ellipsis,
550− )
551− }
552− // Always show the affordance so the title reads as tappable even when
553− // the model catalog failed to load (models empty). Without it the title
554− // is indistinguishable from plain text and the pickers are effectively
555− // undiscoverable — and the dropdown items already disable themselves
556− // while a catalog is loading or unavailable.
557− Icon(
558− Icons.Filled.KeyboardArrowDown,
559− contentDescription = null,
560− tint = MaterialTheme.colorScheme.onSurfaceVariant,
561− modifier = Modifier.size(20.dp),
562− )
563− }
564− DropdownMenu(expanded = showTitleMenu, onDismissRequest = { showTitleMenu = false }) {
565− DropdownMenuItem(
566− text = { Text(stringResource(R.string.palette_switch_model)) },
567− enabled = !modelsLoading || models.isNotEmpty(),
568− onClick = { showTitleMenu = false; showModelPicker = true },
569− )
570− DropdownMenuItem(
571− text = { Text(stringResource(R.string.palette_choose_agent)) },
572− enabled = !agentsLoading || agents.isNotEmpty(),
573− onClick = { showTitleMenu = false; showAgentPicker = true },
574− )
575− }
576− }
577− },
578− navigationIcon = {
579− IconButton(onClick = {
580− if (running) showExitConfirm = true else onBack()
581− }) {
582− Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
583− }
584− },
585− actions = {
586− // At-a-glance run status in the top bar. When the user is scrolled
587− // up (jump-to-latest FAB visible), the input-bar Stop button and the
588− // trailing "working…" row are off-screen, so a top-bar spinner is the
589− // only signal the agent is still working.
590− if (running) {
591− val workingLabel = stringResource(R.string.working)
592− CircularProgressIndicator(
593− Modifier.size(18.dp).semantics { contentDescription = workingLabel },
594− strokeWidth = 2.dp,
595− )
596− }
597− // Manual refresh: forces an SSE reconnect, which re-seeds messages
598− // from REST. A recovery path when the stream silently drops and the
599− // auto-reconnect re-seed is slow or fails. Shows a brief spinner as
600− // immediate tap feedback — the SSE reconnect may not visibly change
601− // the connection state when already Connected, so without it the tap
602− // appears to do nothing.
603− val refreshLabel = stringResource(R.string.refresh)
604− IconButton(
605− onClick = { vm.refreshMessages() },
606− enabled = activeConnection != null && !refreshing,
607− ) {
608− if (refreshing) {
609− CircularProgressIndicator(
610− Modifier.size(18.dp).semantics { contentDescription = refreshLabel },
611− strokeWidth = 2.dp,
612− )
613− } else {
614− Icon(Icons.Filled.Refresh, contentDescription = refreshLabel)
615− }
616− }
617− // Overflow menu consolidates the less-frequent actions (share,
618− // commands, agent, model, rename, delete) so the top bar stays
619− // scannable on narrow phones — previously 5 icons crowded the bar
620− // alongside the back button and tappable title. Catalog-loading
621− // spinners stay inline (next to the overflow) so a load state is
622− // visible without opening the menu.
623− val moreLabel = stringResource(R.string.more)
624− val loadingLabel = stringResource(R.string.loading)
625− val shareLabel = stringResource(R.string.share_conversation)
626− val commandsLabel = stringResource(R.string.commands)
627− val renameLabel = stringResource(R.string.rename_session_chat)
628− val deleteLabel = stringResource(R.string.delete_session_chat)
629− val createShareLabel = stringResource(R.string.create_share_link)
630− val copyShareLabel = stringResource(R.string.copy_share_link)
631− val stopShareLabel = stringResource(R.string.stop_sharing)
632− val summarizeLabel = stringResource(R.string.summarize_conversation)
633− val initLabel = stringResource(R.string.generate_agents_md)
634− val shellLabel = stringResource(R.string.run_shell_command)
635− Box {
636− IconButton(onClick = { showOverflowMenu = true }) {
637− Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
638− }
639− DropdownMenu(
640− expanded = showOverflowMenu,
641− onDismissRequest = { showOverflowMenu = false },
642− ) {
643− DropdownMenuItem(
644− text = { Text(shareLabel) },
645− enabled = hasMessages,
646− onClick = {
647− showOverflowMenu = false
648− scope.launch {
649− val md = withContext(Dispatchers.Default) {
650− buildConversationMarkdown(vm.messages.value, sessionTitle)
651− }
652− val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
653− type = "text/markdown"
654− putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
655− putExtra(android.content.Intent.EXTRA_TEXT, md)
656− }
657− runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
658− .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
659− }
660− },
661− )
662− DropdownMenuItem(
663− text = { Text(commandsLabel) },
664− enabled = !commandsLoading || commands.isNotEmpty(),
665− onClick = { showOverflowMenu = false; showCommandPicker = true },
666− )
667− DropdownMenuItem(
668− text = { Text(stringResource(R.string.keyboard_shortcuts)) },
669− onClick = { showOverflowMenu = false; showShortcutsDialog = true },
670− )
671− DropdownMenuItem(
672− text = { Text(stringResource(R.string.find_in_conversation)) },
673− onClick = {
674− showOverflowMenu = false
675− searchActive = true
676− runCatching { inputFocusRequester.requestFocus() }
677− },
678− )
679− androidx.compose.material3.HorizontalDivider()
680− // Session sharing: create a public link, copy an existing one, or revoke it.
681− if (shareUrl == null) {
682− DropdownMenuItem(
683− text = { Text(createShareLabel) },
684− enabled = hasMessages,
685− onClick = { showOverflowMenu = false; vm.shareSession() },
686− )
687− } else {
688− DropdownMenuItem(
689− text = { Text(copyShareLabel) },
690− onClick = {
691− showOverflowMenu = false
692− shareUrl?.let {
693− copyToClipboard(shareContext, shareContext.getString(R.string.clip_label_share), it)
694− scope.launch { snackbar.showSnackbar(linkCopiedMsg) }
695− }
696− },
697− )
698− DropdownMenuItem(
699− text = { Text(stopShareLabel) },
700− onClick = { showOverflowMenu = false; vm.unshareSession() },
701− )
702− }
703− DropdownMenuItem(
704− text = { Text(summarizeLabel) },
705− enabled = hasMessages && !running,
706− onClick = { showOverflowMenu = false; showSummarizeConfirm = true },
707− )
708− DropdownMenuItem(
709− text = { Text(initLabel) },
710− enabled = !running,
711− onClick = { showOverflowMenu = false; showInitConfirm = true },
712− )
713− DropdownMenuItem(
714− text = { Text(shellLabel) },
715− enabled = !running,
716− onClick = { showOverflowMenu = false; showShellDialog = true },
717− )
718− androidx.compose.material3.HorizontalDivider()
719− DropdownMenuItem(
720− text = { Text(renameLabel) },
721− onClick = { showOverflowMenu = false; showRenameDialog = true },
722− )
723− DropdownMenuItem(
724− text = { Text(deleteLabel, color = MaterialTheme.colorScheme.error) },
725− onClick = { showOverflowMenu = false; showDeleteDialog = true },
726− )
727− }
728− // While a catalog is initially loading (empty list + loading flag),
729− // show a tiny inline spinner beside the overflow so the user can see
730− // a load is in progress without opening the menu. Once loaded the
731− // spinner disappears; the menu items are always enabled (tapping a
732− // picker with no items shows the "no models/agents/commands" state).
733− val catalogLoading = (commandsLoading && commands.isEmpty()) ||
734− (agentsLoading && agents.isEmpty()) ||
735− (modelsLoading && models.isEmpty())
736− if (catalogLoading) {
737− CircularProgressIndicator(
738− Modifier
739− .align(Alignment.TopEnd)
740− .size(10.dp)
741− .semantics { contentDescription = loadingLabel },
742− strokeWidth = 1.dp,
743− )
744− }
745− }
746− },
747− )
748− },
749− snackbarHost = { SnackbarHost(snackbar) },
750− bottomBar = {
751− val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
752− val todoPlan by vm.todoPlan.collectAsStateWithLifecycle()
753− val queuedOffline by vm.outbox.collectAsStateWithLifecycle()
754− val outboxSending by container.outboxSending.collectAsStateWithLifecycle()
755− Column {
756− // Pinned above the composer so the agent's plan and progress stay visible
757− // while scrolled up mid-run; renders nothing when there's no plan.
758− TodoPlanBar(todoPlan)
759− val bannerMotion = rememberVisibilityTransitions()
760− AnimatedVisibility(
761− visible = reverted,
762− enter = bannerMotion.enter,
763− exit = bannerMotion.exit,
764− ) {
765− RevertBanner(
766− diff = revertDiff,
767− onUndo = { vm.unrevert() },
768− )
769− }
770− AnimatedVisibility(
771− visible = queuedOffline.isNotEmpty(),
772− enter = bannerMotion.enter,
773− exit = bannerMotion.exit,
774− ) {
775− OutboxBanner(
776− count = queuedOffline.size,
777− sending = outboxSending,
778− onFlush = { vm.flushQueued() },
779− onDiscard = { vm.discardAllQueued() },
780− )
781− }
782− ChatInputBar(
783− value = draft,
784− onValueChange = vm::updateDraft,
785− running = running,
786− aborting = aborting,
787− enabled = activeConnection != null,
788− sendOnEnter = sendOnEnter,
789− onSend = ::doSend,
790− onAbort = { showStopConfirm = true },
791− queuedFollowUp = queuedFollowUp,
792− onQueueFollowUp = vm::queueFollowUp,
793− onCancelQueue = { vm.queueFollowUp("") },
794− focusRequester = inputFocusRequester,
795− attachments = attachments,
796− staging = stagingCount > 0,
797− onRemoveAttachment = vm::removeAttachment,
798− onPickPhoto = {
799− photoPicker.launch(
800− PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
801− )
802− },
803− onPickFile = { filePicker.launch("*/*") },
804− onCamera = { launchCamera() },
805− onVoice = { launchVoice() },
806− onPasteImage = { stageUris(it) },
807− )
808− }
809− },
810− ) { padding ->
811− // Collect messages inside the content lambda so streaming token updates
812− // recompose only this subtree, not the top bar / input bar / 20+ other
813− // state reads in the ChatScreen body.
814− val messages by vm.messages.collectAsStateWithLifecycle()
815− val optimisticStatuses by vm.optimisticStatuses.collectAsStateWithLifecycle()
816− val listState = rememberLazyListState()
817− val contentScope = rememberCoroutineScope()
818− val timeTick = rememberRelativeTimeTick()
819− // Dismiss the soft keyboard when the user scrolls the message list, a standard Android
820− // chat affordance for reading while the IME is open. Watching isScrollInProgress via
821− // snapshotFlow avoids wiring a nested scroll connection and fires on any scroll gesture.
822− val keyboardController = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current
823− androidx.compose.runtime.LaunchedEffect(listState) {
824− androidx.compose.runtime.snapshotFlow { listState.isScrollInProgress }
825− .distinctUntilChanged()
826− .collect { scrolling -> if (scrolling) keyboardController?.hide() }
827− } // Group messages by day and interleave date separators so a long conversation
828− // has visual "Today"/"Yesterday"/date breaks. Computed once per messages
829− // emission (memoized) so a scroll-induced recomposition doesn't re-scan. Lives
830− // outside the PullToRefreshBox so the auto-scroll effects below can reference
831− // the same list the LazyColumn renders (separators shift the indices vs. the
832− // raw messages list, so scroll targets must be in listItems space).
833− val todayLabel = stringResource(R.string.today)
834− val yesterdayLabel = stringResource(R.string.yesterday)
835− // In-conversation search: when active, narrow to messages whose text contains the query.
836− // Computed on the filtered set so day separators still align with the visible messages.
837− val searchQuery = chatSearch.trim()
838− val searchMessages = remember(messages, searchQuery) {
839− if (searchQuery.isEmpty()) messages
840− else messages.filter { m ->
841− m.parts.any { p -> (p as? TextPart)?.text?.contains(searchQuery, ignoreCase = true) == true }
842− }
843− }
844− // Keep the focused-match index in range as the query (and thus the match set) changes.
845− // A new query resets to the first match; a shrunk set clamps to the last valid index.
846− LaunchedEffect(searchQuery, searchMessages.size) {
847− if (searchPos >= searchMessages.size) searchPos = 0
848− }
849− val listItems = remember(searchMessages, todayLabel, yesterdayLabel) {
850− buildMessageListItems(searchMessages, todayLabel, yesterdayLabel)
851− }
852− // listItems is a plain (non-snapshot) local, rebuilt each recomposition. The
853− // derivedStateOf and snapshotFlow lambdas below are created once (keyless
854− // remember / LaunchedEffect(Unit)); reading `listItems` directly inside them
855− // would capture the first composition's list — which is empty, since `messages`
856− // starts empty — freezing it forever. That pinned isPinnedToBottom at true and
857− // AutoScrollSignal.size at 0, breaking streaming auto-scroll and the
858− // jump-to-latest FAB. rememberUpdatedState hands those lambdas a stable State
859− // whose value tracks the latest list.
860− val currentListItems by rememberUpdatedState(listItems)
861−
862− // New-content signal for the jump-to-latest FAB: counts how many new items arrived
863− // while the user was scrolled away from the bottom, cleared once they return. A count
864− // (vs. a bare dot) tells the user how much they missed at a glance.
865− var newContentCount by remember { mutableIntStateOf(0) }
866−
867− val isPinnedToBottom by remember {
868− derivedStateOf {
869− val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
870− // When a run is active, the LazyColumn has one trailing "__typing"
871− // item after the last list item. Account for it so "pinned to bottom"
872− // includes the working indicator — otherwise the pin check targets
873− // listItems.lastIndex, the typing row sits just below the viewport,
874− // and the user never sees the progress indicator even when pinned.
875− val items = currentListItems
876− val effectiveLast = if (running) items.size else items.lastIndex
877− lastVisible >= effectiveLast || items.isEmpty()
878− }
879− }
880−
881− // Jump to the newest message once, when the conversation first loads. Guarded by a
882− // saveable one-shot flag so it doesn't re-fire on recomposition after a config change
883− // (rotation) — rememberLazyListState restores the user's scroll offset, and an
884− // unconditional scrollToItem here would clobber it, snapping them back to the bottom.
885− var didInitialScroll by rememberSaveable { mutableStateOf(false) }
886− LaunchedEffect(listItems.isNotEmpty()) {
887− if (listItems.isNotEmpty() && !didInitialScroll) {
888− // When a run is active the LazyColumn has a trailing "__typing" row at
889− // listItems.size; target it (not listItems.lastIndex) so deep-linking into a
890− // running session lands truly pinned to bottom and streaming auto-scroll engages.
891− listState.scrollToItem(if (running) listItems.size else listItems.lastIndex)
892− didInitialScroll = true
893− }
894− }
895−
896− // When a run starts, the LazyColumn gains a trailing "__typing" row, so
897− // effectiveLast jumps from listItems.lastIndex to listItems.size and the
898− // isPinnedToBottom check flips to false the instant you send — freezing
899− // auto-scroll. If the user was pinned to the last item just before the run
900− // began, bring the typing row into view so the pin (and streaming follow) is
901− // preserved. Guard on the raw last-visible index (isPinnedToBottom has already
902− // recomputed to false by now) so we don't scroll when the user had scrolled up.
903− LaunchedEffect(running) {
904− if (running && listItems.isNotEmpty()) {
905− val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
906− if (lastVisible >= listItems.lastIndex) {
907− runCatchingCancellable { listState.animateScrollToItem(listItems.size) }
908− }
909− }
910− }
911−
912− // Scroll the focused search match into view whenever the user steps through results with
913− // the up/down arrows. Resolves the filtered message to its slot in listItems (which
914− // includes date separators) and animates there.
915− LaunchedEffect(searchPos, searchQuery, listItems) {
916− if (!searchActive || searchQuery.isEmpty()) return@LaunchedEffect
917− val target = searchMessages.getOrNull(searchPos) ?: return@LaunchedEffect
918− val index = listItems.indexOfFirst {
919− it is MessageListItem.Message && it.message.info.id == target.info.id
920− }
921− if (index >= 0) runCatchingCancellable { listState.animateScrollToItem(index) }
922− }
923−
924− LaunchedEffect(Unit) {
925− var prevSize = 0
926− var prevLen = 0
927− snapshotFlow {
928− // Track list size + the streaming length of the last part so we auto-scroll
929− // as content arrives, plus the pinned flag. A small data class with primitive
930− // fields avoids the per-frame boxing that Triple<Int,Int,Boolean> pays
931− // (snapshotFlow re-evaluates this lambda every snapshot), and reading only the
932− // last part's length is O(1) vs. a sumOf over every part. Covering reasoning
933− // and tool output — not just TextPart — keeps a pinned view following a long
934− // reasoning/tool block while it streams (previously it stalled at 0).
935− val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
936− AutoScrollSignal(currentListItems.size, lastLen, isPinnedToBottom)
937− }.collect { signal ->
938− if (signal.pinned) {
939− // Back at the bottom: clear the new-content badge.
940− newContentCount = 0
941− if (signal.size > 0) {
942− // Scroll to the effective last index, including the trailing
943− // "__typing" row when a run is active so the working indicator
944− // is brought into view (not just the last message).
945− val items = currentListItems
946− val target = if (running) items.size else items.lastIndex
947− listState.scrollToItem(target)
948− }
949− } else if (signal.size > prevSize || signal.lastTextLength > prevLen) {
950− // Content arrived while scrolled up — increment the jump-to-latest badge.
951− // Only count whole new items (not per-token growth) so the number reflects
952− // new messages, not a token count.
953− if (signal.size > prevSize) newContentCount += signal.size - prevSize
954− else if (newContentCount == 0) newContentCount = 1
955− }
956− prevSize = signal.size
957− prevLen = signal.lastTextLength
958− }
959− }
960−
961− CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
962− // The bottomBar (ChatInputBar) already has imePadding, and the Scaffold's
963− // content padding accounts for the bottomBar's raised height, so the message
964− // list is already above the keyboard. Adding imePadding here would double-apply
965− // the IME inset and push messages too far up.
966− Box(modifier = Modifier.fillMaxSize().padding(padding)) {
967− if (activeConnection == null) {
968− Column(
969− modifier = Modifier.align(Alignment.Center).padding(24.dp),
970− horizontalAlignment = Alignment.CenterHorizontally,
971− ) {
972− Text(
973− stringResource(R.string.not_connected),
974− color = MaterialTheme.colorScheme.error,
975− )
976− Spacer(Modifier.size(12.dp))
977− Button(onClick = { vm.reconnect() }, enabled = !reconnecting) {
978− if (reconnecting) {
979− CircularProgressIndicator(
980− Modifier.size(20.dp),
981− color = MaterialTheme.colorScheme.onPrimary,
982− strokeWidth = 2.dp,
983− )
984− } else {
985− Text(stringResource(R.string.reconnect))
986− }
987− }
988− }
989− } else {
990− val bannerVisible = !isOnline || connectionState != EventStreamClient.ConnectionState.Connected
991− // Measure the banner's actual height instead of hardcoding 44dp. At large
992− // accessibility font scales the banner's labelMedium text + spinner + Retry
993− // button exceed 44dp, so a fixed inset lets the first message hide behind it
994− // (the same hazard FileViewScreen's overlay calls out). onSizeChanged only
995− // fires while the banner is composed (visible), so gate the inset on
996− // bannerVisible and fall back to 44dp until the first measurement arrives.
997− var bannerHeightPx by remember { mutableIntStateOf(0) }
998− val density = LocalDensity.current
999− val topPad = if (bannerVisible) {
1000− if (bannerHeightPx > 0) {
1001− with(density) { bannerHeightPx.toDp() } + 4.dp
1002− } else {
1003− 44.dp
1004− }
1005− } else {
1006− 16.dp
1007− }
1008− ConnectionBanner(
1009− state = connectionState,
1010− modifier = Modifier.align(Alignment.TopCenter).onSizeChanged { bannerHeightPx = it.height },
1011− isOnline = isOnline,
1012− // On a hard failure, retry by forcing an SSE reconnect (which
1013− // re-seeds from REST). refreshMessages is the right recovery path
1014− // when the connection is present but the stream died; reconnect()
1015− // is the path when the whole connection is gone (handled by the
1016− // separate "Not connected" state below).
1017− onRetry = { vm.refreshMessages() },
1018− )
1019− // Pull-to-refresh wraps ALL content states (loading, error, empty, list) so
1020− // recovery is a swipe away everywhere — not just when the message list is
1021− // populated. Previously PTR was only around the populated list, leaving the
1022− // error/loading states reachable only via a button tap.
1023− PullToRefreshBox(
1024− isRefreshing = refreshing,
1025− onRefresh = { vm.refreshMessages() },
1026− // Cap width and center on large screens for readability — full-width
1027− // bubbles stretch edge-to-edge on tablets, hurting line-length comfort.
1028− modifier = Modifier.fillMaxSize().wrapContentWidth(Alignment.CenterHorizontally)
1029− .widthIn(max = NetworkConfig.chatContentMaxWidthDp.dp),
1030− ) {
1031− // Reserve top space when the banner is visible so it doesn't overlap the
1032− // first message / state content. (topPad computed above.)
1033− if (loading && messages.isEmpty()) {
1034− val loadingLabel = stringResource(R.string.loading)
1035− // Skeleton bubbles pre-structure the layout so the perceived load is faster
1036− // than a bare centered spinner and the first real messages pop into an
1037− // already-shaped list instead of a blank-then-pop. Alpha pulses (gated by
1038− // reduced-motion) to convey ongoing work; the column carries the a11y label.
1039− val reducedMotion = LocalReducedMotion.current
1040− val transition = rememberInfiniteTransition(label = "skeleton")
1041− val pulse by transition.animateFloat(
1042− initialValue = 0.35f,
1043− targetValue = 0.7f,
1044− animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse),
1045− label = "skeletonAlpha",
1046− )
1047− val skeletonAlpha = if (reducedMotion) 0.5f else pulse
1048− val skeletonColor = MaterialTheme.colorScheme.surfaceVariant
1049− @Composable
1050− fun bar(widthFraction: Float) {
1051− Box(
1052− Modifier
1053− .fillMaxWidth(widthFraction)
1054− .height(14.dp)
1055− .clip(RoundedCornerShape(4.dp))
1056− .background(skeletonColor)
1057− .alpha(skeletonAlpha),
1058− )
1059− }
1060− Column(
1061− modifier = Modifier
1062− .align(Alignment.TopCenter)
1063− .padding(top = topPad)
1064− .padding(16.dp)
1065− .fillMaxWidth()
1066− .semantics { contentDescription = loadingLabel },
1067− verticalArrangement = Arrangement.spacedBy(14.dp),
1068− ) {
1069− repeat(4) { i ->
1070− val left = i % 2 == 0
1071− Row(
1072− modifier = Modifier.fillMaxWidth(),
1073− horizontalArrangement = if (left) Arrangement.Start else Arrangement.End,
1074− ) {
1075− Column(
1076− modifier = Modifier.fillMaxWidth(if (left) 0.7f else 0.55f),
1077− verticalArrangement = Arrangement.spacedBy(6.dp),
1078− ) {
1079− bar(1f)
1080− bar(0.8f)
1081− }
1082− }
1083− }
1084− }
1085− } else if (loadError && messages.isEmpty()) {
1086− // A failed load with nothing to show. Distinct from the empty
1087− // conversation state below — the conversation isn't empty, it
1088− // failed to load, and offering "Start a conversation" here is
1089− // misleading. Offer a Retry instead, mirroring SessionListScreen.
1090− Column(
1091− modifier = Modifier.align(Alignment.Center).padding(top = topPad).padding(24.dp),
1092− horizontalAlignment = Alignment.CenterHorizontally,
1093− ) {
1094− Text(
1095− stringResource(R.string.load_messages_failed),
1096− color = MaterialTheme.colorScheme.error,
1097− )
1098− Spacer(Modifier.size(12.dp))
1099− Button(onClick = { vm.refreshMessages() }) {
1100− Text(stringResource(R.string.retry))
1101− }
1102− }
1103− } else if (messages.isEmpty() && running) {
1104− // A run just started but no parts have arrived yet — the empty
1105− // list with only the trailing "working…" row looks broken, so
1106− // surface a clear starting state until the first part streams in.
1107− val workingText = stringResource(R.string.working)
1108− Column(
1109− modifier = Modifier.align(Alignment.Center).padding(top = topPad).padding(24.dp),
1110− horizontalAlignment = Alignment.CenterHorizontally,
1111− ) {
1112− CircularProgressIndicator(Modifier.size(28.dp), strokeWidth = 3.dp)
1113− Spacer(Modifier.size(12.dp))
1114− Text(workingText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
1115− }
1116− } else if (messages.isEmpty()) {
1117− EmptyConversation(
1118− onSuggestion = {
1119− vm.updateDraft(it)
1120− // Focus the input so the user can edit the suggestion before
1121− // sending, instead of having to manually tap the field.
1122− runCatching { inputFocusRequester.requestFocus() }
1123− },
1124− modifier = Modifier.align(Alignment.Center).padding(top = topPad),
1125− )
1126− } else {
1127− val lastMessageId = messages.lastOrNull()?.info?.id
1128− // Text-to-speech for reading assistant replies aloud; shuts down when the
1129− // message list leaves composition.
1130− val tts = rememberTtsController()
1131− val speakingMessageId by tts.speakingId
1132− val ttsState by tts.state
1133− LazyColumn(
1134− state = listState,
1135− modifier = Modifier.fillMaxSize(),
1136− // Extra bottom padding reserves room for the jump-to-latest FAB so it
1137− // never floats over the last message. Top padding grows when the
1138− // connection banner is visible so it doesn't overlap the first message.
1139− contentPadding = PaddingValues(
1140− start = 16.dp,
1141− top = topPad,
1142− end = 16.dp,
1143− bottom = 16.dp + NetworkConfig.chatListFabInsetDp.dp,
1144− ),
1145− verticalArrangement = Arrangement.spacedBy(16.dp),
1146− ) {
1147− items(
1148− items = listItems,
1149− key = { it.key },
1150− contentType = { it.contentType },
1151− ) { item ->
1152− // When in-conversation search is stepping through matches, highlight the
1153− // focused one's row so the user can spot it at a glance among the filtered
1154− // results (full in-text term highlighting would require threading the query
1155− // through the markdown renderer; the row highlight + scroll-to-match covers
1156− // the navigation use case desktop chat apps solve with next/prev).
1157− val focusedMatchId = if (searchActive && searchQuery.isNotEmpty()) {
1158− searchMessages.getOrNull(searchPos)?.info?.id
1159− } else null
1160− val isFocusedMatch = focusedMatchId != null &&
1161− item is MessageListItem.Message &&
1162− item.message.info.id == focusedMatchId
1163− // Default placement animation so inserted/moved rows glide in. Skipped
1164− // entirely under reduced motion so a streaming message growing in place
1165− // doesn't animate on every token (and respects the a11y preference).
1166− Box(
1167− Modifier
1168− .then(if (LocalReducedMotion.current) Modifier else Modifier.animateItem())
1169− .then(
1170− if (isFocusedMatch) {
1171− Modifier.background(
1172− MaterialTheme.colorScheme.secondaryContainer,
1173− MaterialTheme.shapes.medium,
1174− )
1175− } else {
1176− Modifier
1177− },
1178− ),
1179− ) {
1180− when (item) {
1181− is MessageListItem.Separator -> DateSeparator(item.label)
1182− is MessageListItem.Message -> {
1183− val message = item.message
1184− // Only the last (streaming) message needs isRunning — it drives the
1185− // reasoning-block spinner. Passing the live flag to every bubble
1186− // makes all visible messages recompose whenever a run starts or stops.
1187− val modelLabel = remember(message.info, models) {
1188− (message.info as? soy.iko.opencode.data.model.AssistantMessage)
1189− ?.let { resolveModelLabel(it, models) }
1190− }
1191− // Text used to drive swipe-to-reply (and pre-fill the quote). Empty for
1192− // image/code-only messages, which then opt out of the swipe gesture.
1193− val quoteText = remember(message.parts) {
1194− message.parts
1195− .filterIsInstance<TextPart>()
1196− .joinToString("\n\n") { it.text }
1197− .trim()
1198− .takeIf { it.isNotEmpty() }
1199− }
1200− // Swipe-start-to-end reveals a reply affordance and triggers quote-reply,
1201− // snapping back (the message isn't dismissed — the quote fills the composer).
1202− // Only enabled when there's text to quote, so non-text messages don't
1203− // capture horizontal drags (e.g. inside a horizontally-scrollable code block).
1204− val swipeState = rememberSwipeToDismissBoxState(
1205− confirmValueChange = { value ->
1206− if (value == SwipeToDismissBoxValue.StartToEnd && quoteText != null) {
1207− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1208− vm.quoteReply(quoteText)
1209− runCatching { inputFocusRequester.requestFocus() }
1210− }
1211− false
1212− },
1213− )
1214− SwipeToDismissBox(
1215− state = swipeState,
1216− enableDismissFromEndToStart = false,
1217− enableDismissFromStartToEnd = quoteText != null,
1218− backgroundContent = {
1219− Box(
1220− Modifier.fillMaxSize().padding(horizontal = 16.dp),
1221− contentAlignment = Alignment.CenterStart,
1222− ) {
1223− Icon(
1224− Icons.AutoMirrored.Filled.Reply,
1225− contentDescription = null,
1226− tint = MaterialTheme.colorScheme.onSurfaceVariant,
1227− )
1228− }
1229− },
1230− ) {
1231− MessageBubble(
1232− message,
1233− isRunning = running && message.info.id == lastMessageId,
1234− imageContext = imageContext,
1235− modelLabel = modelLabel,
1236− onOpenFile = onOpenFile,
1237− onRevert = { vm.revertTo(message.info.id) },
1238− onEdit = { text ->
1239− // editMessage reverts to before this message (hiding it and
1240− // everything after); the revert banner surfaces the rewind
1241− // with an Undo. Focus + scroll the composer into view like the
1242− // Quote action so the prefilled draft isn't off-screen.
1243− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1244− vm.editMessage(message.info.id, text)
1245− runCatching { inputFocusRequester.requestFocus() }
1246− contentScope.launch {
1247− runCatchingCancellable {
1248− listState.animateScrollToItem(listItems.lastIndex.coerceAtLeast(0))
1249− }
1250− }
1251− },
1252− onSpeak = { text ->
1253− if (!tts.toggle(message.info.id, text)) {
1254− // TTS engine unavailable (init failed / not yet ready
1255− // / blank text): surface feedback so the button doesn't
1256− // appear dead. Throttled so a rapid double-tap (stop
1257− // then immediate re-tap before ready) doesn't spam.
1258− scope.launch { snackbar.showSnackbar(ttsUnavailableMsg) }
1259− }
1260− },
1261− isSpeaking = message.info.id == speakingMessageId,
1262− ttsState = if (message.info.id == speakingMessageId) ttsState else TtsState.IDLE,
1263− onPause = { tts.pause() },
1264− onResume = { tts.resume() },
1265− onQuote = { text ->
1266− vm.quoteReply(text)
1267− runCatching { inputFocusRequester.requestFocus() }
1268− },
1269− onBranch = { text -> vm.branchFrom(text) },
1270− // Regenerate is only meaningful for a finished assistant reply
1271− // (not the actively-streaming one), so gate it on !running.
1272− onRegenerate = if (!running) {
1273− { vm.regenerate(message.info.id) }
1274− } else null,
1275− sendStatus = optimisticStatuses[message.info.id]?.let { failed ->
1276− if (failed) soy.iko.opencode.ui.chat.MessageSendStatus.FAILED
1277− else soy.iko.opencode.ui.chat.MessageSendStatus.SENDING
1278− },
1279− isEdited = message.info.time?.let { it.updated != null && it.updated != it.created } == true,
1280− onRetry = optimisticStatuses[message.info.id]?.let { failed ->
1281− if (failed) {
1282− { vm.retryOptimisticMessage(message.info.id) }
1283− } else {
1284− null
1285− }
1286− },
1287− onDismiss = optimisticStatuses[message.info.id]?.let { failed ->
1288− if (failed) {
1289− { vm.dismissOptimistic(message.info.id) }
1290− } else {
1291− null
1292− }
1293− },
1294− onShare = {
1295− // Per-message share: build a single-message Markdown
1296− // transcript off-thread and fire ACTION_SEND, mirroring the
1297− // whole-conversation share but scoped to just this message.
1298− scope.launch {
1299− val md = withContext(Dispatchers.Default) {
1300− buildMessageMarkdown(message)
1301− } ?: return@launch
1302− val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
1303− type = "text/markdown"
1304− putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
1305− putExtra(android.content.Intent.EXTRA_TEXT, md)
1306− }
1307− runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareContext.getString(R.string.share_message))) }
1308− .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
1309− }
1310− },
1311− )
1312− }
1313− }
1314− }
1315− }
1316− }
1317− if (running) {
1318− item(key = "__typing") {
1319− val workingText = stringResource(R.string.working)
1320− // Elapsed-since-run chip: the typing row only composes while a run is
1321− // active, so anchoring the timer to first composition approximates the
1322− // run start and gives the user a sense of how long the agent has been
1323− // working — distinguishing a healthy long run from a hung one. Updates
1324− // once per second; the value is decorative (the a11y label is the row's).
1325− var startMs by remember { mutableLongStateOf(0L) }
1326− LaunchedEffect(Unit) { startMs = System.currentTimeMillis() }
1327− val elapsedMs by produceState(0L, startMs) {
1328− if (startMs == 0L) return@produceState
1329− while (true) {
1330− value = (System.currentTimeMillis() - startMs).coerceAtLeast(0L)
1331− delay(1000)
1332− }
1333− }
1334− val elapsedText = remember(elapsedMs) {
1335− val total = elapsedMs / 1000
1336− // Locale.US for stable digit formatting: on locales with
1337− // non-ASCII digits (ar-/fa-) the default locale would render
1338− // localized digits inside the Monospace block, breaking the
1339− // fixed-width alignment the monospace family was chosen for.
1340− String.format(java.util.Locale.US, "%d:%02d", total / 60, total % 60)
1341− }
1342− Row(
1343− verticalAlignment = Alignment.CenterVertically,
1344− // Animate this slot in/out so the working row doesn't pop in and
1345− // jolt the message list when a run starts/stops — matching the
1346− // AnimatedVisibility used by every other transient banner/FAB here.
1347− modifier = Modifier
1348− .animateItem()
1349− .padding(vertical = 4.dp),
1350− ) {
1351− // Merge the indicator + label + clock into one TalkBack node so
1352− // it's announced once (the working label, then the elapsed time)
1353− // instead of "working… / working… / 0:14" with the label read
1354− // twice. The Stop IconButton below stays separately focusable.
1355− Row(
1356− verticalAlignment = Alignment.CenterVertically,
1357− modifier = Modifier.semantics(mergeDescendants = true) {
1358− contentDescription = "$workingText $elapsedText"
1359− },
1360− ) {
1361− CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
1362− Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
1363− Spacer(Modifier.size(8.dp))
1364− Icon(
1365− Icons.Filled.Schedule,
1366− contentDescription = null,
1367− modifier = Modifier.size(14.dp),
1368− tint = MaterialTheme.colorScheme.onSurfaceVariant,
1369− )
1370− Text(
1371− elapsedText,
1372− style = MaterialTheme.typography.labelMedium,
1373− fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
1374− color = MaterialTheme.colorScheme.onSurfaceVariant,
1375− modifier = Modifier.padding(start = 4.dp),
1376− )
1377− }
1378− // A trailing Stop on the working row so the run can be aborted
1379− // without scrolling back to the composer (which may be off-screen
1380− // while the user reads the streaming output up-thread).
1381− Spacer(Modifier.weight(1f))
1382− val stopLabel = stringResource(R.string.stop)
1383− IconButton(
1384− onClick = { showStopConfirm = true },
1385− modifier = Modifier.semantics { contentDescription = stopLabel },
1386− ) {
1387− Icon(Icons.Filled.Stop, contentDescription = null)
1388− }
1389− }
1390− }
1391− }
1392− }
1393− }
1394− }
1395− // Jump-to-latest affordance when the user has scrolled away during a stream.
1396− val fabMotion = rememberVisibilityTransitions()
1397− AnimatedVisibility(
1398− visible = !isPinnedToBottom && listItems.isNotEmpty(),
1399− enter = fabMotion.enter,
1400− exit = fabMotion.exit,
1401− modifier = Modifier.align(Alignment.BottomEnd),
1402− ) {
1403− // Badge the FAB when new content arrived while the user was scrolled up, so
1404− // they know there's something new to jump to (cleared once back at bottom).
1405− BadgedBox(
1406− badge = {
1407− if (newContentCount > 0) {
1408− val newContentLabel = stringResource(R.string.new_messages)
1409− Badge(modifier = Modifier.semantics { contentDescription = newContentLabel }) {
1410− Text(if (newContentCount > 99) "99+" else newContentCount.toString())
1411− }
1412− }
1413− },
1414− modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
1415− ) {
1416− ExtendedFloatingActionButton(
1417− onClick = {
1418− newContentCount = 0
1419− if (listItems.isNotEmpty()) {
1420− // Scroll to the effective last index, including the
1421− // trailing "__typing" row when a run is active.
1422− val target = if (running) listItems.size else listItems.lastIndex
1423− contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
1424− }
1425− },
1426− // contentDescription null: the visible "Latest" text already labels
1427− // the FAB, so a description here makes TalkBack announce "Latest" twice.
1428− icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = null) },
1429− text = { Text(stringResource(R.string.latest)) },
1430− )
1431− }
1432− }
1433− // Scroll-to-top affordance: a small FAB at the start edge, visible when the
1434− // user has scrolled down past the first few messages. Symmetric to the
1435− // jump-to-latest FAB, for long conversations where manual fling back is tedious.
1436− val scrolledDown by remember {
1437− derivedStateOf { listState.firstVisibleItemIndex > 0 || listState.firstVisibleItemScrollOffset > 200 }
1438− }
1439− AnimatedVisibility(
1440− visible = scrolledDown && listItems.size > 4,
1441− enter = fabMotion.enter,
1442− exit = fabMotion.exit,
1443− modifier = Modifier.align(Alignment.BottomStart),
1444− ) {
1445− SmallFloatingActionButton(
1446− onClick = {
1447− contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(0) } }
1448− },
1449− modifier = Modifier.padding(start = 16.dp, bottom = 16.dp),
1450− ) {
1451− Icon(
1452− Icons.Filled.KeyboardArrowUp,
1453− contentDescription = stringResource(R.string.scroll_to_top),
1454− )
1455− }
1456− }
1457− // In-conversation search bar. Overlays the list so typing filters messages in
1458− // place; the match count + a no-matches note give immediate feedback.
1459− val searchMotion = rememberVisibilityTransitions()
1460− AnimatedVisibility(
1461− visible = searchActive,
1462− enter = searchMotion.enter,
1463− exit = searchMotion.exit,
1464− modifier = Modifier.align(Alignment.TopCenter),
1465− ) {
1466− Surface(
1467− tonalElevation = 3.dp,
1468− modifier = Modifier
1469− .padding(horizontal = 12.dp, vertical = 4.dp)
1470− .fillMaxWidth(),
1471− ) {
1472− Row(
1473− modifier = Modifier.padding(start = 8.dp, end = 4.dp),
1474− verticalAlignment = Alignment.CenterVertically,
1475− ) {
1476− OutlinedTextField(
1477− value = chatSearch,
1478− onValueChange = { chatSearch = it },
1479− modifier = Modifier.weight(1f),
1480− placeholder = { Text(stringResource(R.string.search_in_conversation)) },
1481− singleLine = true,
1482− leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
1483− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
1484− )
1485− // Match counter: "N of M" where N is the focused match (1-based) and
1486− // M is the total matching messages. Only shown when there's a query.
1487− val matchCount = searchMessages.size
1488− Text(
1489− if (searchQuery.isNotEmpty() && matchCount > 0) {
1490− stringResource(R.string.search_match_count, searchPos + 1, matchCount)
1491− } else if (searchQuery.isNotEmpty()) {
1492− stringResource(R.string.search_match_count, 0, messages.size)
1493− } else "",
1494− style = MaterialTheme.typography.labelSmall,
1495− color = MaterialTheme.colorScheme.onSurfaceVariant,
1496− modifier = Modifier.padding(horizontal = 4.dp),
1497− )
1498− // Step through matches (Previous / Next), disabled when there are
1499− // fewer than two to navigate. Wrap around at the ends so repeated
1500− // taps cycle through every hit.
1501− val prevLabel = stringResource(R.string.find_previous)
1502− val nextLabel = stringResource(R.string.find_next)
1503− IconButton(
1504− onClick = {
1505− if (matchCount > 0) {
1506− searchPos = (searchPos - 1 + matchCount) % matchCount
1507− }
1508− },
1509− enabled = matchCount > 1,
1510− ) {
1511− Icon(Icons.Filled.KeyboardArrowUp, contentDescription = prevLabel)
1512− }
1513− IconButton(
1514− onClick = {
1515− if (matchCount > 0) {
1516− searchPos = (searchPos + 1) % matchCount
1517− }
1518− },
1519− enabled = matchCount > 1,
1520− ) {
1521− Icon(Icons.Filled.KeyboardArrowDown, contentDescription = nextLabel)
1522− }
1523− IconButton(onClick = {
1524− searchActive = false
1525− chatSearch = ""
1526− }) {
1527− Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
1528− }
1529− }
1530− }
1531− }
1532− // When a search yields no matches (but the conversation isn't empty), say so
1533− // instead of showing a blank list.
1534− if (searchActive && searchQuery.isNotEmpty() && searchMessages.isEmpty() && messages.isNotEmpty()) {
1535− Text(
1536− stringResource(R.string.search_no_matches),
1537− style = MaterialTheme.typography.bodyMedium,
1538− color = MaterialTheme.colorScheme.onSurfaceVariant,
1539− modifier = Modifier.align(Alignment.Center),
1540− )
1541− }
1542− }
1543− }
1544− }
1545− }
1546−
1547− if (showExitConfirm) {
1548− AlertDialog(
1549− onDismissRequest = { showExitConfirm = false },
1550− title = { Text(stringResource(R.string.stop_and_exit_title)) },
1551− text = { Text(stringResource(R.string.stop_and_exit_text)) },
1552− confirmButton = {
1553− TextButton(onClick = {
1554− showExitConfirm = false
1555− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1556− vm.abort()
1557− onBack()
1558− }) { Text(stringResource(R.string.stop_and_exit)) }
1559− },
1560− dismissButton = {
1561− TextButton(onClick = { showExitConfirm = false }) { Text(stringResource(R.string.stay)) }
1562− },
1563− )
1564− }
1565−
1566− if (showStopConfirm) {
1567− AlertDialog(
1568− onDismissRequest = { showStopConfirm = false },
1569− title = { Text(stringResource(R.string.stop_run_title)) },
1570− text = { Text(stringResource(R.string.stop_run_text)) },
1571− confirmButton = {
1572− TextButton(onClick = {
1573− showStopConfirm = false
1574− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
1575− vm.abort()
1576− }) { Text(stringResource(R.string.stop), color = MaterialTheme.colorScheme.error) }
1577− },
1578− dismissButton = {
1579− TextButton(onClick = { showStopConfirm = false }) { Text(stringResource(R.string.cancel)) }
1580− },
1581− )
1582− }
1583−
1584− if (showSummarizeConfirm) {
1585− AlertDialog(
1586− onDismissRequest = { showSummarizeConfirm = false },
1587− title = { Text(stringResource(R.string.summarize_confirm_title)) },
1588− text = { Text(stringResource(R.string.summarize_confirm_text)) },
1589− confirmButton = {
1590− TextButton(onClick = {
1591− showSummarizeConfirm = false
1592− vm.summarize()
1593− }) { Text(stringResource(R.string.action_continue)) }
1594− },
1595− dismissButton = {
1596− TextButton(onClick = { showSummarizeConfirm = false }) { Text(stringResource(R.string.cancel)) }
1597− },
1598− )
1599− }
1600−
1601− if (showInitConfirm) {
1602− AlertDialog(
1603− onDismissRequest = { showInitConfirm = false },
1604− title = { Text(stringResource(R.string.init_confirm_title)) },
1605− text = { Text(stringResource(R.string.init_confirm_text)) },
1606− confirmButton = {
1607− TextButton(onClick = {
1608− showInitConfirm = false
1609− vm.initProject()
1610− }) { Text(stringResource(R.string.action_continue)) }
1611− },
1612− dismissButton = {
1613− TextButton(onClick = { showInitConfirm = false }) { Text(stringResource(R.string.cancel)) }
1614− },
1615− )
1616− }
1617−
1618− if (showRenameDialog) {
1619− RenameSessionChatDialog(
1620− initialTitle = sessionTitle ?: "",
1621− onDismiss = { showRenameDialog = false },
1622− onConfirm = { newName ->
1623− showRenameDialog = false
1624− vm.renameSession(newName)
1625− },
1626− )
1627− }
1628−
1629− if (showDeleteDialog) {
1630− AlertDialog(
1631− onDismissRequest = { showDeleteDialog = false },
1632− title = { Text(stringResource(R.string.delete_session_chat_title)) },
1633− text = { Text(stringResource(R.string.delete_session_chat_text, sessionTitle ?: sessionLabel)) },
1634− confirmButton = {
1635− TextButton(onClick = {
1636− showDeleteDialog = false
1637− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
1638− vm.deleteSession()
1639− }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
1640− },
1641− dismissButton = {
1642− TextButton(onClick = { showDeleteDialog = false }) { Text(stringResource(R.string.cancel)) }
1643− },
1644− )
1645− }
1646−
1647− if (showShellDialog) {
1648− ShellCommandDialog(
1649− onDismiss = { showShellDialog = false },
1650− onRun = { cmd ->
1651− showShellDialog = false
1652− vm.runShell(cmd)
1653− },
1654− )
1655− }
1656−
1657− if (showModelPicker) {
1658− ModelPickerSheet(
1659− options = models,
1660− selected = selectedModel,
1661− loading = modelsLoading,
1662− error = modelsError,
1663− onSelect = { vm.selectModel(it) },
1664− onRetry = { vm.reloadModels() },
1665− onDismiss = { showModelPicker = false },
1666− )
1667− }
1668−
1669− if (showAgentPicker) {
1670− AgentPickerSheet(
1671− agents = agents,
1672− selected = selectedAgent,
1673− loading = agentsLoading,
1674− error = agentsError,
1675− onSelect = { vm.selectAgent(it?.name) },
1676− onRetry = { vm.reloadAgents() },
1677− onDismiss = { showAgentPicker = false },
1678− )
1679− }
1680−
1681− if (showCommandPicker) {
1682− CommandPickerSheet(
1683− commands = commands,
1684− loading = commandsLoading,
1685− error = commandsError,
1686− onSelect = { vm.runCommand(it) },
1687− onRetry = { vm.reloadCommands() },
1688− onDismiss = { showCommandPicker = false },
1689− )
1690− }
1691−
1692− pendingPermission?.let { permission ->
1693− PermissionDialog(
1694− permission = permission,
1695− position = permissionProgress.position,
1696− total = permissionProgress.total,
1697− onRespond = { response -> vm.respondPermission(permission, response) },
1698− )
1699− }
1700−
1701− if (showPalette) {
1702− CommandPalette(actions = paletteActions, onDismiss = { showPalette = false })
1703− }
1704−
1705− if (showShortcutsDialog) {
1706− // Surfaces the hardware-keyboard shortcuts (Ctrl+K palette, Escape stop, Enter send)
1707− // that are otherwise wired but undiscoverable. A simple key/value list; Enter behavior
1708− // depends on the "Send on Enter" setting, noted inline.
1709− AlertDialog(
1710− onDismissRequest = { showShortcutsDialog = false },
1711− title = { Text(stringResource(R.string.keyboard_shortcuts_title)) },
1712− text = {
1713− Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
1714− ShortcutRow("Ctrl + K", stringResource(R.string.shortcut_open_palette))
1715− ShortcutRow("Esc", stringResource(R.string.shortcut_stop_run))
1716− ShortcutRow("Esc", stringResource(R.string.shortcut_close))
1717− ShortcutRow("Enter", stringResource(R.string.shortcut_send))
1718− ShortcutRow("Shift + Enter / Ctrl + Enter", stringResource(R.string.shortcut_newline))
1719− }
1720− },
1721− confirmButton = {
1722− TextButton(onClick = { showShortcutsDialog = false }) { Text(stringResource(R.string.action_continue)) }
1723− },
1724− )
1725− }
1726−}
1727−
1728−@Composable
1729−private fun ShortcutRow(keys: String, desc: String) {
1730− Row(verticalAlignment = Alignment.CenterVertically) {
1731− Text(
1732− keys,
1733− style = MaterialTheme.typography.labelLarge,
1734− fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
1735− modifier = Modifier.widthIn(min = 160.dp),
1736− )
1737− Text(
1738− desc,
1739− style = MaterialTheme.typography.bodyMedium,
1740− color = MaterialTheme.colorScheme.onSurfaceVariant,
1741− )
1742− }
1743−}
1744−
1745−@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
1746−@Composable
1747−private fun ChatInputBar(
1748− value: String,
1749− onValueChange: (String) -> Unit,
1750− running: Boolean,
1751− aborting: Boolean,
1752− enabled: Boolean,
1753− sendOnEnter: Boolean,
1754− onSend: () -> Unit,
1755− onAbort: () -> Unit,
1756− queuedFollowUp: String?,
1757− onQueueFollowUp: (String) -> Unit,
1758− onCancelQueue: () -> Unit,
1759− focusRequester: androidx.compose.ui.focus.FocusRequester,
1760− attachments: List<PendingAttachment>,
1761− staging: Boolean,
1762− onRemoveAttachment: (String) -> Unit,
1763− onPickPhoto: () -> Unit,
1764− onPickFile: () -> Unit,
1765− onCamera: () -> Unit,
1766− onVoice: () -> Unit,
1767− onPasteImage: (List<Uri>) -> Unit,
1768−) {
1769− // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
1770− val hasContent = value.isNotBlank() || attachments.isNotEmpty()
1771− // Full-screen editor state: opened when the user wants more space for a long prompt.
1772− var showFullScreenEditor by rememberSaveable { mutableStateOf(false) }
1773− Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
1774− Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
1775− // A queued follow-up replaces the Stop button with a "queued" chip so the
1776− // user sees their message will be sent when the run finishes, and can cancel.
1777− if (queuedFollowUp != null) {
1778− val editLabel = stringResource(R.string.edit)
1779− Row(
1780− modifier = Modifier
1781− .fillMaxWidth()
1782− .padding(start = 12.dp, top = 6.dp, end = 4.dp),
1783− verticalAlignment = Alignment.CenterVertically,
1784− ) {
1785− // Tapping the chip reloads the queued text into the draft (and clears the
1786− // queue) so a typo spotted while queuing can be fixed without retyping.
1787− Column(
1788− modifier = Modifier
1789− .weight(1f)
1790− .clip(MaterialTheme.shapes.small)
1791− .clickable(role = Role.Button, onClickLabel = editLabel) {
1792− onValueChange(queuedFollowUp)
1793− onCancelQueue()
1794− }
1795− .padding(vertical = 4.dp),
1796− ) {
1797− Text(
1798− stringResource(R.string.queued),
1799− style = MaterialTheme.typography.labelSmall,
1800− color = MaterialTheme.colorScheme.onSurfaceVariant,
1801− )
1802− Text(
1803− queuedFollowUp,
1804− style = MaterialTheme.typography.bodySmall,
1805− color = MaterialTheme.colorScheme.onSurfaceVariant,
1806− maxLines = 1,
1807− overflow = TextOverflow.Ellipsis,
1808− )
1809− }
1810− TextButton(onClick = onCancelQueue, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
1811− Text(stringResource(R.string.cancel))
1812− }
1813− }
1814− }
1815− // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
1816− AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging)
1817− Row(
1818− modifier = Modifier.fillMaxWidth().padding(8.dp),
1819− verticalAlignment = Alignment.Bottom,
1820− ) {
1821− // Attach menu: photo picker, camera, or any file.
1822− var showAttachMenu by remember { mutableStateOf(false) }
1823− Box {
1824− IconButton(onClick = { showAttachMenu = true }, enabled = enabled) {
1825− Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_attachment))
1826− }
1827− DropdownMenu(expanded = showAttachMenu, onDismissRequest = { showAttachMenu = false }) {
1828− DropdownMenuItem(
1829− leadingIcon = { Icon(Icons.Filled.PhotoLibrary, contentDescription = null) },
1830− text = { Text(stringResource(R.string.attach_photo)) },
1831− onClick = { showAttachMenu = false; onPickPhoto() },
1832− )
1833− DropdownMenuItem(
1834− leadingIcon = { Icon(Icons.Filled.PhotoCamera, contentDescription = null) },
1835− text = { Text(stringResource(R.string.attach_camera)) },
1836− onClick = { showAttachMenu = false; onCamera() },
1837− )
1838− DropdownMenuItem(
1839− leadingIcon = { Icon(Icons.Filled.Description, contentDescription = null) },
1840− text = { Text(stringResource(R.string.attach_file)) },
1841− onClick = { showAttachMenu = false; onPickFile() },
1842− )
1843− }
1844− }
1845− // Voice dictation: appends recognized speech to the draft.
1846− IconButton(onClick = onVoice, enabled = enabled) {
1847− Icon(Icons.Filled.Mic, contentDescription = stringResource(R.string.voice_input))
1848− }
1849− // Expand to a full-screen editor for long prompts (the inline field caps at 6 lines).
1850− IconButton(onClick = { showFullScreenEditor = true }, enabled = enabled) {
1851− Icon(Icons.Filled.Expand, contentDescription = stringResource(R.string.expand_editor))
1852− }
1853− OutlinedTextField(
1854− value = value,
1855− onValueChange = { v ->
1856− // Cap input length so a huge paste can't stall the UI. Shares are
1857− // capped separately in MainActivity; this guards typed/pasted drafts.
1858− onValueChange(v.take(NetworkConfig.maxDraftLengthChars))
1859− },
1860− modifier = Modifier
1861− .weight(1f)
1862− .focusRequester(focusRequester)
1863− .testTag("chat_input")
1864− // Intercept image content pasted/dropped into the field (keyboard paste
1865− // of a copied screenshot, drag from Files, etc.) and route it through the
1866− // same staging path as the attach menu. Non-image content (text) falls
1867− // through unchanged so normal typing/paste works.
1868− .contentReceiver(
1869− remember {
1870− androidx.compose.foundation.content.ReceiveContentListener { transferable ->
1871− val clipData = transferable.clipEntry?.clipData
1872− if (clipData != null) {
1873− val imageUris = (0 until clipData.itemCount).mapNotNull { i ->
1874− val item = clipData.getItemAt(i)
1875− item.uri?.takeIf { uri ->
1876− item.text == null && uri.toString().let { s ->
1877− s.startsWith("content:") || s.startsWith("file:")
1878− }
1879− }
1880− }
1881− if (imageUris.isNotEmpty()) {
1882− onPasteImage(imageUris)
1883− }
1884− }
1885− transferable
1886− }
1887− },
1888− )
1889− .onPreviewKeyEvent { event ->
1890− if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
1891− // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
1892− // With it off, Enter inserts a newline and Ctrl+Enter sends.
1893− val send = enterShouldSend(
1894− enabled = enabled,
1895− hasContent = hasContent,
1896− sendOnEnter = sendOnEnter,
1897− shift = event.isShiftPressed,
1898− ctrl = event.isCtrlPressed,
1899− )
1900− if (send) {
1901− if (running) onQueueFollowUp(value) else onSend()
1902− true
1903− } else {
1904− false
1905− }
1906− },
1907− // While a run is active a typed message only queues (sends after the run), so
1908− // the placeholder says so instead of the plain "Message…".
1909− placeholder = {
1910− Text(stringResource(if (running) R.string.composer_hint_running else R.string.message_placeholder))
1911− },
1912− enabled = enabled,
1913− maxLines = 6,
1914− // Show a "N / max" countdown once the draft crosses a high fraction of
1915− // the cap, so the user knows a paste is about to be truncated instead
1916− // of being silently cut off. Hidden for normal short prompts to avoid
1917− // clutter under a typical one-line message.
1918− supportingText = {
1919− val threshold = (NetworkConfig.maxDraftLengthChars * NetworkConfig.draftCountdownThresholdFraction).toInt()
1920− if (value.length >= threshold) {
1921− Text(
1922− stringResource(
1923− R.string.draft_chars_remaining,
1924− NetworkConfig.maxDraftLengthChars - value.length,
1925− NetworkConfig.maxDraftLengthChars,
1926− ),
1927− style = MaterialTheme.typography.labelSmall,
1928− color = if (value.length >= NetworkConfig.maxDraftLengthChars) MaterialTheme.colorScheme.error
1929− else MaterialTheme.colorScheme.onSurfaceVariant,
1930− )
1931− }
1932− },
1933− keyboardOptions = KeyboardOptions(
1934− // When "Send on Enter" is on, expose ImeAction.Send so the soft
1935− // keyboard shows a send key — matching the setting's intent and
1936− // giving touch-only users a keyboard send affordance. When off,
1937− // ImeAction.Default keeps the enter/newline key for multi-line input.
1938− imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
1939− capitalization = KeyboardCapitalization.Sentences,
1940− ),
1941− keyboardActions = KeyboardActions(
1942− onSend = {
1943− if (enabled && hasContent) onSend()
1944− },
1945− ),
1946− )
1947− ComposerTrailingButton(
1948− running = running,
1949− aborting = aborting,
1950− canSend = enabled && hasContent,
1951− canQueue = enabled && value.isNotBlank(),
1952− onSend = onSend,
1953− onAbort = onAbort,
1954− onQueue = { onQueueFollowUp(value) },
1955− )
1956− }
1957− }
1958− if (showFullScreenEditor) {
1959− FullScreenEditor(
1960− value = value,
1961− onValueChange = onValueChange,
1962− onDismiss = { showFullScreenEditor = false },
1963− onSend = {
1964− showFullScreenEditor = false
1965− // Mirror the inline composer's behavior: while a run is active a send
1966− // can't go immediately, so queue the follow-up instead of silently
1967− // dropping it (vm.send() returns false during a run, which previously
1968− // lost the typed message with no feedback).
1969− if (running) onQueueFollowUp(value) else onSend()
1970− },
1971− canSend = enabled && hasContent,
1972− running = running,
1973− sendOnEnter = sendOnEnter,
1974− attachments = attachments,
1975− onRemoveAttachment = onRemoveAttachment,
1976− staging = staging,
1977− )
1978− }
1979− }
1980−}
1981−
1982−/** Full-screen editor dialog for composing long prompts. Gives the full viewport height to
1983− * the text field (vs the inline 6-line cap) with a Send button and a collapse action. */
1984−@OptIn(ExperimentalMaterial3Api::class)
1985−@Composable
1986−private fun FullScreenEditor(
1987− value: String,
1988− onValueChange: (String) -> Unit,
1989− onDismiss: () -> Unit,
1990− onSend: () -> Unit,
1991− canSend: Boolean,
1992− running: Boolean = false,
1993− sendOnEnter: Boolean = false,
1994− attachments: List<PendingAttachment> = emptyList(),
1995− onRemoveAttachment: (String) -> Unit = {},
1996− staging: Boolean = false,
1997−) {
1998− androidx.compose.ui.window.Dialog(
1999− onDismissRequest = onDismiss,
2000− properties = androidx.compose.ui.window.DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
2001− ) {
2002− Scaffold(
2003− topBar = {
2004− androidx.compose.material3.TopAppBar(
2005− title = { Text(stringResource(R.string.message_placeholder)) },
2006− navigationIcon = {
2007− androidx.compose.material3.TextButton(onClick = onDismiss) {
2008− Text(stringResource(R.string.collapse_editor))
2009− }
2010− },
2011− )
2012− },
2013− bottomBar = {
2014− Surface(tonalElevation = 3.dp) {
2015− Row(
2016− modifier = Modifier.fillMaxWidth().imePadding().padding(12.dp),
2017− horizontalArrangement = Arrangement.End,
2018− ) {
2019− androidx.compose.material3.Button(onClick = onSend, enabled = canSend) {
2020− Text(stringResource(if (running) R.string.queue else R.string.send))
2021− }
2022− }
2023− }
2024− },
2025− ) { padding ->
2026− Column(modifier = Modifier.fillMaxSize().padding(padding)) {
2027− // Show staged attachments in the full-screen editor too, so a user who staged
2028− // images then expanded for a long prompt can still see and remove them without
2029− // collapsing first (mirrors the inline composer's AttachmentStrip).
2030− AttachmentStrip(attachments, onRemove = onRemoveAttachment, staging = staging)
2031− OutlinedTextField(
2032− value = value,
2033− onValueChange = { v -> onValueChange(v.take(NetworkConfig.maxDraftLengthChars)) },
2034− modifier = Modifier
2035− .fillMaxSize()
2036− .weight(1f)
2037− // Honor the "Send on Enter" setting here too, so the full-screen
2038− // editor matches the inline composer: Enter sends (Shift+Enter
2039− // newlines) when on; Enter inserts a newline and Ctrl+Enter sends
2040− // when off. Previously this hard-coded ImeAction.Default and had no
2041− // key handler, so a hardware-keyboard user got a different behaviour
2042− // from the inline composer depending on which they happened to open.
2043− .onPreviewKeyEvent { event ->
2044− if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
2045− val send = enterShouldSend(
2046− enabled = canSend,
2047− hasContent = canSend,
2048− sendOnEnter = sendOnEnter,
2049− shift = event.isShiftPressed,
2050− ctrl = event.isCtrlPressed,
2051− )
2052− if (send) { onSend(); true } else false
2053− },
2054− placeholder = { Text(stringResource(R.string.message_placeholder)) },
2055− maxLines = Int.MAX_VALUE,
2056− keyboardOptions = KeyboardOptions(
2057− imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
2058− capitalization = KeyboardCapitalization.Sentences,
2059− ),
2060− keyboardActions = KeyboardActions(
2061− onSend = { if (canSend) onSend() },
2062− ),
2063− )
2064− }
2065− }
2066− }
2067−}
2068−
2069−/** Whether a hardware Enter keypress should send: never when disabled/empty; otherwise
2070− * Enter sends (unless Shift) when send-on-Enter is on, else only Ctrl+Enter sends. */
2071−private fun enterShouldSend(
2072− enabled: Boolean,
2073− hasContent: Boolean,
2074− sendOnEnter: Boolean,
2075− shift: Boolean,
2076− ctrl: Boolean,
2077−): Boolean = when {
2078− !enabled || !hasContent -> false
2079− sendOnEnter -> !shift
2080− else -> ctrl
2081−}
2082−
2083−/** The composer's trailing button: while a run is active a Stop (with in-flight spinner),
2084− * preceded by a Queue button when the user has typed a follow-up; otherwise Send. Extracted so
2085− * [ChatInputBar] stays under the complexity threshold. */
2086−@Composable
2087−private fun ComposerTrailingButton(
2088− running: Boolean,
2089− aborting: Boolean,
2090− canSend: Boolean,
2091− canQueue: Boolean,
2092− onSend: () -> Unit,
2093− onAbort: () -> Unit,
2094− onQueue: () -> Unit,
2095−) {
2096− if (running) {
2097− Row(verticalAlignment = Alignment.CenterVertically) {
2098− // While a run is active the trailing action is Stop, so the only send affordance is
2099− // the keyboard's IME action — which is a plain newline when "Send on Enter" is off.
2100− // That leaves a touch-only user with the setting off no way to queue a follow-up
2101− // (Ctrl+Enter needs a hardware keyboard). Surface an explicit Queue button whenever
2102− // there's typed TEXT so queuing is always reachable, without changing the user's
2103− // Enter-inserts-a-newline preference. Gate on typed text — not the send-oriented
2104− // canSend, which is also true for an attachment-only composer — because queuing "" just
2105− // calls setQueuedFollowUp(null), silently cancelling any already-queued follow-up and
2106− // dropping the staged attachment (queued follow-ups don't carry attachments anyway).
2107− if (canQueue) {
2108− IconButton(
2109− onClick = onQueue,
2110− modifier = Modifier.testTag("queue_button"),
2111− ) {
2112− // PlaylistAdd reads as "add to a queue/list" more clearly than a clock
2113− // icon (which can be read as "snooze" or "later"), and isn't confused with
2114− // the Send action sitting right beside the Stop button.
2115− Icon(
2116− Icons.AutoMirrored.Filled.PlaylistAdd,
2117− contentDescription = stringResource(R.string.queue_followup),
2118− )
2119− }
2120− }
2121− // Show a spinner while the abort REST call is in flight so the user sees the stop
2122− // was sent, and disable to prevent a double-tap.
2123− IconButton(
2124− onClick = onAbort,
2125− enabled = !aborting,
2126− modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
2127− ) {
2128− if (aborting) {
2129− val stopLabel = stringResource(R.string.stop)
2130− CircularProgressIndicator(
2131− Modifier.size(18.dp).semantics { contentDescription = stopLabel },
2132− strokeWidth = 2.dp,
2133− )
2134− } else {
2135− Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
2136− }
2137− }
2138− }
2139− } else {
2140− IconButton(
2141− onClick = onSend,
2142− enabled = canSend,
2143− modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
2144− ) {
2145− Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
2146− }
2147− }
2148−}
2149−
2150−@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
2151−@Composable
2152−private fun EmptyConversation(
2153− onSuggestion: (String) -> Unit,
2154− modifier: Modifier = Modifier,
2155−) {
2156− // A few example prompts so a first-time user has a one-tap path to their first
2157− // message instead of staring at a blank input. Tapping a chip fills the draft
2158− // (without sending) so the user can edit it before sending.
2159− val suggestions = remember {
2160− listOf(
2161− R.string.empty_chat_suggest_1,
2162− R.string.empty_chat_suggest_2,
2163− R.string.empty_chat_suggest_3,
2164− )
2165− }
2166− Column(
2167− modifier = modifier.padding(32.dp),
2168− horizontalAlignment = Alignment.CenterHorizontally,
2169− ) {
2170− Icon(
2171− Icons.Filled.AutoAwesome,
2172− contentDescription = null,
2173− modifier = Modifier.size(48.dp),
2174− tint = MaterialTheme.colorScheme.onSurfaceVariant,
2175− )
2176− Spacer(Modifier.size(16.dp))
2177− Text(
2178− stringResource(R.string.empty_chat_title),
2179− style = MaterialTheme.typography.titleMedium,
2180− )
2181− Spacer(Modifier.size(8.dp))
2182− Text(
2183− stringResource(R.string.empty_chat_hint),
2184− style = MaterialTheme.typography.bodyMedium,
2185− color = MaterialTheme.colorScheme.onSurfaceVariant,
2186− )
2187− Spacer(Modifier.size(20.dp))
2188− // Suggestion chips wrap across rows on narrow screens instead of stacking full-width,
2189− // so a longer localized prompt doesn't push the chips off-screen on a phone.
2190− FlowRow(
2191− modifier = Modifier.fillMaxWidth(),
2192− horizontalArrangement = Arrangement.spacedBy(8.dp),
2193− verticalArrangement = Arrangement.spacedBy(8.dp),
2194− ) {
2195− suggestions.forEach { resId ->
2196− val text = stringResource(resId)
2197− androidx.compose.material3.AssistChip(
2198− onClick = { onSuggestion(text) },
2199− label = { Text(text, style = MaterialTheme.typography.bodyMedium) },
2200− )
2201− }
2202− }
2203− }
2204−}
2205−
2206−/** A banner shown while a revert checkpoint is active, offering an Undo (unrevert). When the
2207− * server included a diff of the revert, a collapsible "Show what changed" affordance reveals
2208− * it inline via [DiffView] so the user can see what was rolled back before deciding to undo. */
2209−@Composable
2210−private fun RevertBanner(diff: String?, onUndo: () -> Unit) {
2211− val haptics = LocalHapticFeedback.current
2212− var showDiff by rememberSaveable { mutableStateOf(false) }
2213− val expandMotion = rememberVisibilityTransitions()
2214− Surface(
2215− color = MaterialTheme.colorScheme.tertiaryContainer,
2216− tonalElevation = 2.dp,
2217− modifier = Modifier.fillMaxWidth(),
2218− ) {
2219− Column {
2220− Row(
2221− modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
2222− verticalAlignment = Alignment.CenterVertically,
2223− horizontalArrangement = Arrangement.SpaceBetween,
2224− ) {
2225− Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
2226− Icon(
2227− Icons.Filled.Restore,
2228− contentDescription = null,
2229− tint = MaterialTheme.colorScheme.onTertiaryContainer,
2230− modifier = Modifier.size(18.dp),
2231− )
2232− Text(
2233− stringResource(R.string.reverted_banner),
2234− style = MaterialTheme.typography.bodySmall,
2235− color = MaterialTheme.colorScheme.onTertiaryContainer,
2236− modifier = Modifier.padding(start = 8.dp),
2237− )
2238− // "Show what changed" toggle, shown only when the server provided a diff.
2239− // Collapsible so the banner stays compact by default; a user curious about
2240− // what the revert rolled back can expand it inline without leaving the chat.
2241− if (!diff.isNullOrBlank()) {
2242− TextButton(
2243− onClick = {
2244− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2245− showDiff = !showDiff
2246− },
2247− contentPadding = androidx.compose.foundation.layout.PaddingValues(start = 8.dp, end = 4.dp),
2248− ) {
2249− Icon(
2250− if (showDiff) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
2251− contentDescription = null,
2252− modifier = Modifier.size(16.dp),
2253− )
2254− Text(
2255− stringResource(if (showDiff) R.string.revert_hide_diff else R.string.revert_show_diff),
2256− style = MaterialTheme.typography.labelSmall,
2257− modifier = Modifier.padding(start = 4.dp),
2258− )
2259− }
2260− }
2261− }
2262− TextButton(onClick = {
2263− // Haptic on undo to match the other destructive/important confirmations: a
2264− // revert restores potentially hundreds of hidden messages.
2265− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
2266− onUndo()
2267− }) { Text(stringResource(R.string.undo)) }
2268− }
2269− // The diff render lives inside the banner's Surface so it inherits the tertiary
2270− // tonal container and reads as part of the banner, not a floating panel.
2271− if (!diff.isNullOrBlank()) {
2272− AnimatedVisibility(
2273− visible = showDiff,
2274− enter = expandMotion.enter,
2275− exit = expandMotion.exit,
2276− ) {
2277− Column(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 8.dp)) {
2278− Text(
2279− stringResource(R.string.revert_diff_header),
2280− style = MaterialTheme.typography.labelSmall,
2281− color = MaterialTheme.colorScheme.onTertiaryContainer,
2282− modifier = Modifier.padding(bottom = 4.dp),
2283− )
2284− DiffView(diff, saveKey = "revertDiff")
2285− }
2286− }
2287− }
2288− }
2289− }
2290−}
2291−
2292−/** A banner shown while messages composed offline are queued in the outbox, with a
2293− * "Send now" nudge and a discard. Shows a spinner while the queue is flushing. */
2294−@Composable
2295−private fun OutboxBanner(
2296− count: Int,
2297− sending: Boolean,
2298− onFlush: () -> Unit,
2299− onDiscard: () -> Unit,
2300−) {
2301− Surface(
2302− color = MaterialTheme.colorScheme.secondaryContainer,
2303− tonalElevation = 2.dp,
2304− modifier = Modifier.fillMaxWidth(),
2305− ) {
2306− Row(
2307− modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
2308− verticalAlignment = Alignment.CenterVertically,
2309− horizontalArrangement = Arrangement.SpaceBetween,
2310− ) {
2311− Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
2312− if (sending) {
2313− CircularProgressIndicator(
2314− Modifier.size(16.dp),
2315− strokeWidth = 2.dp,
2316− color = MaterialTheme.colorScheme.onSecondaryContainer,
2317− )
2318− Spacer(Modifier.size(8.dp))
2319− }
2320− Text(
2321− if (sending) {
2322− stringResource(R.string.outbox_sending)
2323− } else {
2324− val res = if (count == 1) R.string.outbox_pending_one else R.string.outbox_pending_many
2325− stringResource(res, count)
2326− },
2327− style = MaterialTheme.typography.bodySmall,
2328− color = MaterialTheme.colorScheme.onSecondaryContainer,
2329− )
2330− }
2331− if (!sending) {
2332− val haptics = LocalHapticFeedback.current
2333− TextButton(onClick = onFlush) { Text(stringResource(R.string.outbox_flush)) }
2334− TextButton(onClick = {
2335− // Haptic on the irreversible "Discard queued" (drops every queued message
2336− // for this session), matching the error-colored destructive actions.
2337− haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
2338− onDiscard()
2339− }) {
2340− Text(stringResource(R.string.outbox_discard_all), color = MaterialTheme.colorScheme.error)
2341− }
2342− }
2343− }
2344− }
2345−}
2346−
2347−/** Prompt for a one-off shell command to run in the session's worktree. */
2348−@Composable
2349−private fun ShellCommandDialog(onDismiss: () -> Unit, onRun: (String) -> Unit) {
2350− var command by rememberSaveable { mutableStateOf("") }
2351− AlertDialog(
2352− onDismissRequest = onDismiss,
2353− title = { Text(stringResource(R.string.run_shell_command)) },
2354− text = {
2355− OutlinedTextField(
2356− value = command,
2357− onValueChange = { command = it },
2358− modifier = Modifier.fillMaxWidth(),
2359− placeholder = { Text(stringResource(R.string.shell_command_hint)) },
2360− label = { Text(stringResource(R.string.shell_command_label)) },
2361− singleLine = true,
2362− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
2363− keyboardActions = KeyboardActions(onDone = { if (command.isNotBlank()) onRun(command.trim()) }),
2364− )
2365− },
2366− confirmButton = {
2367− TextButton(
2368− onClick = { if (command.isNotBlank()) onRun(command.trim()) },
2369− enabled = command.isNotBlank(),
2370− ) { Text(stringResource(R.string.run)) }
2371− },
2372− dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
2373− )
2374−}
2375−
2376−/**
2377− * Change signal for the auto-scroll-on-stream watcher. Primitive fields avoid the
2378− * boxing `Triple<Int, Int, Boolean>` would impose on every snapshot evaluation, and
2379− * equality is structural so `snapshotFlow` emits only when something relevant changes.
2380− */
2381−private data class AutoScrollSignal(val size: Int, val lastTextLength: Int, val pinned: Boolean)
2382−
2383−/** Length of the streaming content carried by [part], driving auto-scroll as it grows.
2384− * Covers text, reasoning, and tool output so a pinned view keeps following non-text
2385− * streaming, not just plain assistant text. O(1). */
2386−private fun streamingContentLength(part: Part?): Int = when (part) {
2387− is TextPart -> part.text.length
2388− is ReasoningPart -> part.text.length
2389− is ToolPart -> when (val st = part.state) {
2390− is ToolCompleted -> st.output?.length ?: 0
2391− is ToolError -> st.error?.length ?: 0
2392− is ToolRunning -> st.title?.length ?: 0
2393− else -> 0
2394− }
2395− else -> 0
2396−}
2397−
2398−/** Rename dialog for the currently-open session. Mirrors the SessionListScreen rename
2399− * dialog but lives in the chat screen so the user can rename without backing out to
2400− * the list. Pre-filled with the current title; the cap matches the list dialog. */
2401−@Composable
2402−private fun RenameSessionChatDialog(
2403− initialTitle: String,
2404− onDismiss: () -> Unit,
2405− onConfirm: (String) -> Unit,
2406−) {
2407− // Seed the field once (no key input) so a later external title change — e.g. opencode
2408− // auto-generating a session title after the first prompt, arriving via SessionUpdated —
2409− // doesn't re-initialize the box and discard the user's in-progress edit.
2410− var title by rememberSaveable { mutableStateOf(initialTitle) }
2411− AlertDialog(
2412− onDismissRequest = onDismiss,
2413− title = { Text(stringResource(R.string.rename_session_chat)) },
2414− text = {
2415− OutlinedTextField(
2416− value = title,
2417− onValueChange = { v -> title = v.take(NetworkConfig.maxSessionTitleChars) },
2418− modifier = Modifier.fillMaxWidth(),
2419− placeholder = { Text(stringResource(R.string.session_title_hint)) },
2420− label = { Text(stringResource(R.string.rename_session_chat)) },
2421− singleLine = true,
2422− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
2423− keyboardActions = KeyboardActions(onDone = { if (title.isNotBlank()) onConfirm(title.trim()) }),
2424− )
2425− },
2426− confirmButton = {
2427− TextButton(onClick = { if (title.isNotBlank()) onConfirm(title.trim()) }, enabled = title.isNotBlank()) {
2428− Text(stringResource(R.string.save))
2429− }
2430− },
2431− dismissButton = {
2432− TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
2433− },
2434− )
2435−}
2436−
2437−/**
2438− * A single item in the chat message list — either a [Message] bubble or a day
2439− * [Separator] inserted before the first message of a new day. The separator shifts
2440− * the LazyColumn indices vs. the raw messages list, so scroll-target math must use
2441− * the [List] of these items (not `messages.size`/`lastIndex`).
2442− */
2443−private sealed interface MessageListItem {
2444− val key: Any
2445− val contentType: Any
2446−
2447− data class Message(val message: MessageWithParts, override val key: Any) : MessageListItem {
2448− override val contentType: Any get() = message.info::class
2449− }
2450−
2451− data class Separator(val label: String, val ordinal: Int) : MessageListItem {
2452− // Key on the day-occurrence ordinal, not the label: two non-contiguous groups
2453− // can share a label (e.g. an untimestamped message — empty label — between two
2454− // same-day messages, or two untimestamped groups), and a label-only key would
2455− // then collide, which makes LazyColumn throw on duplicate keys. The ordinal is
2456− // stable for a given message ordering so slots aren't needlessly recreated.
2457− override val key: Any get() = "sep_${ordinal}_$label"
2458− // Separators share a contentType so the LazyColumn can recycle their slots.
2459− override val contentType: Any get() = "separator"
2460− }
2461−}
2462−
2463−/**
2464− * Build the interleaved list of [MessageListItem]s for the message list, inserting a
2465− * [MessageListItem.Separator] before the first message of each new calendar day. Uses
2466− * the message's `time.created` (falling back to `updated` then `completed`) to bucket.
2467− * Messages with no timestamp are grouped under an empty-label separator only if they
2468− * start the list, so a server that omits timestamps doesn't suppress the first divider.
2469− *
2470− * [todayLabel]/[yesterdayLabel] are resolved by the caller (a @Composable can't call
2471− * stringResource from inside this plain function) and used for the "Today"/"Yesterday"
2472− * labels; older days fall back to a locale-stable medium-date format.
2473− */
2474−private fun buildMessageListItems(
2475− messages: List<MessageWithParts>,
2476− todayLabel: String,
2477− yesterdayLabel: String,
2478−): List<MessageListItem> {
2479− if (messages.isEmpty()) return emptyList()
2480− val today = java.time.LocalDate.now(java.time.ZoneId.systemDefault())
2481− val dateFmt = mediumDateFormat()
2482− val result = ArrayList<MessageListItem>(messages.size + 4)
2483− val seenKeys = HashSet<String>(messages.size * 2)
2484− var lastDayKey: String? = null
2485− var sepOrdinal = 0
2486− var emptyIdOrdinal = 0
2487− var first = true
2488− for (message in messages) {
2489− val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed
2490− val dayKey = ts?.let { dayKey(it) } ?: ""
2491− // Emit a separator before the very first message (so a leading un-timestamped message
2492− // still gets the first divider, empty label and all), and thereafter only when crossing
2493− // into a new NON-empty calendar day. An un-timestamped message mid-list (empty key)
2494− // neither emits its own separator nor resets the day — otherwise a following same-day
2495− // message would see a "" -> "<today>" transition and emit a duplicate "Today" header.
2496− if (first || (dayKey.isNotEmpty() && dayKey != lastDayKey)) {
2497− result.add(MessageListItem.Separator(dayLabel(dayKey, ts ?: 0L, today, todayLabel, yesterdayLabel, dateFmt), sepOrdinal++))
2498− }
2499− if (dayKey.isNotEmpty()) lastDayKey = dayKey
2500− first = false
2501− // Most messages have a unique non-empty id. An unrecognized-role message can carry an
2502− // empty id — and the reducer intentionally keeps several such messages as distinct
2503− // holders — so give each a stable synthetic key and defensively de-collide any remaining
2504− // duplicate; otherwise LazyColumn throws on a duplicate key and the whole screen crashes.
2505− var key = message.info.id.ifEmpty { "msg-empty-${emptyIdOrdinal++}" }
2506− if (!seenKeys.add(key)) {
2507− var suffix = 1
2508− while (!seenKeys.add("$key#$suffix")) suffix++
2509− key = "$key#$suffix"
2510− }
2511− result.add(MessageListItem.Message(message, key))
2512− }
2513− return result
2514−}
2515−
2516−/**
2517− * A stable bucket key for a timestamp's calendar day (epoch-days as a string).
2518− *
2519− * [buildMessageListItems] re-runs on every streaming snapshot (the memo is keyed on the
2520− * whole `messages` list, which must stay so the live message's fresh parts render), so a
2521− * naive implementation would re-allocate an `Instant`/`ZonedDateTime`/`LocalDate` for
2522− * every message on every token. Message timestamps are immutable once assigned, so we
2523− * memoize the parse per epoch-millis: after the first snapshot, each unchanged message is
2524− * a cheap hash lookup. Bounded LRU (access-order) caps memory; only touched on the main
2525− * thread during composition, so the coarse lock is uncontended.
2526− */
2527−private val dayKeyCache = object : LinkedHashMap<Long, String>(128, 0.75f, true) {
2528− override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Long, String>): Boolean = size > 1024
2529−}
2530−
2531−private fun dayKey(epochMillis: Long): String = synchronized(dayKeyCache) {
2532− dayKeyCache.getOrPut(epochMillis) {
2533− java.time.Instant.ofEpochMilli(epochMillis)
2534− .atZone(java.time.ZoneId.systemDefault()).toLocalDate().toEpochDay().toString()
2535− }
2536−}
2537−
2538−// The medium-date formatter is an ICU locale lookup; cache one instance instead of
2539−// allocating a fresh one per buildMessageListItems call, rebuilding only if the default
2540−// locale changes at runtime. Main-thread-only, so no synchronization is needed.
2541−private var cachedDateFmt: java.text.DateFormat? = null
2542−private var cachedDateFmtLocale: java.util.Locale? = null
2543−
2544−private fun mediumDateFormat(): java.text.DateFormat {
2545− val locale = java.util.Locale.getDefault()
2546− val existing = cachedDateFmt
2547− if (existing != null && cachedDateFmtLocale == locale) return existing
2548− return java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM).also {
2549− cachedDateFmt = it
2550− cachedDateFmtLocale = locale
2551− }
2552−}
2553−
2554−/**
2555− * Resolve a day-key back to a human-readable label. "Today" / "Yesterday" for the
2556− * recent days, otherwise a locale-stable medium-date format. Returns an empty string
2557− * when [dayKey] is empty (no timestamp) so no separator is rendered for un-timestamped
2558− * messages.
2559− */
2560−private fun dayLabel(
2561− dayKey: String,
2562− ts: Long,
2563− today: java.time.LocalDate,
2564− todayLabel: String,
2565− yesterdayLabel: String,
2566− dateFmt: java.text.DateFormat,
2567−): String {
2568− if (dayKey.isEmpty()) return ""
2569− val epochDay = dayKey.toLongOrNull() ?: return ""
2570− val date = java.time.LocalDate.ofEpochDay(epochDay)
2571− return when {
2572− date == today -> todayLabel
2573− date == today.minusDays(1) -> yesterdayLabel
2574− else -> dateFmt.format(java.util.Date(ts))
2575− }
2576−}
2577−
2578−/**
2579− * A centered day divider in the message list. Renders nothing for an empty label
2580− * (the no-timestamp case) so un-timestamped messages don't get a stray blank divider.
2581− */
2582−@Composable
2583−private fun DateSeparator(label: String) {
2584− if (label.isBlank()) return
2585− Row(
2586− modifier = Modifier.fillMaxWidth(),
2587− horizontalArrangement = Arrangement.Center,
2588− ) {
2589− Surface(
2590− color = MaterialTheme.colorScheme.surfaceVariant,
2591− shape = MaterialTheme.shapes.small,
2592− // Marked as a heading so TalkBack users can navigate day-by-day with the
2593− // heading-skip gesture — the canonical affordance for date dividers in a list.
2594− modifier = Modifier.semantics(mergeDescendants = true) { heading() },
2595− ) {
2596− Text(
2597− label,
2598− style = MaterialTheme.typography.labelSmall,
2599− color = MaterialTheme.colorScheme.onSurfaceVariant,
2600− modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
2601− )
2602− }
2603− }
2604−}
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.ktdeleted−1596
@@ -1,1596 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.lifecycle.ViewModel
4−import androidx.lifecycle.viewModelScope
5−import soy.iko.opencode.data.model.Agent
6−import soy.iko.opencode.data.model.Command
7−import soy.iko.opencode.data.model.FilePromptPart
8−import soy.iko.opencode.data.model.MessageWithParts
9−import soy.iko.opencode.data.model.ModelOption
10−import soy.iko.opencode.data.model.UserMessage
11−import soy.iko.opencode.data.model.Permission
12−import soy.iko.opencode.data.model.PermissionReplied
13−import soy.iko.opencode.data.model.PermissionResponse
14−import soy.iko.opencode.data.model.PermissionUpdated
15−import soy.iko.opencode.data.model.SessionDeleted
16−import soy.iko.opencode.data.model.SessionUpdated
17−import soy.iko.opencode.data.model.TextPart
18−import soy.iko.opencode.data.model.TodoItem
19−import soy.iko.opencode.data.model.currentTodoPlan
20−import soy.iko.opencode.data.model.defaultOption
21−import soy.iko.opencode.data.model.toOptions
22−import soy.iko.opencode.data.network.EventStreamClient
23−import soy.iko.opencode.data.network.NetworkConfig
24−import soy.iko.opencode.data.repo.OutboxMessage
25−import soy.iko.opencode.data.repo.SessionRepository
26−import soy.iko.opencode.di.AppContainer
27−import soy.iko.opencode.R
28−import soy.iko.opencode.util.runCatchingCancellable
29−import soy.iko.opencode.util.safeExceptionSummary
30−import android.util.Log
31−import kotlinx.coroutines.ExperimentalCoroutinesApi
32−import kotlinx.coroutines.channels.BufferOverflow
33−import kotlinx.coroutines.channels.Channel
34−import kotlinx.coroutines.flow.Flow
35−import kotlinx.coroutines.flow.MutableSharedFlow
36−import kotlinx.coroutines.flow.MutableStateFlow
37−import kotlinx.coroutines.flow.SharedFlow
38−import kotlinx.coroutines.flow.SharingStarted
39−import kotlinx.coroutines.flow.StateFlow
40−import kotlinx.coroutines.flow.asSharedFlow
41−import kotlinx.coroutines.flow.asStateFlow
42−import kotlinx.coroutines.flow.receiveAsFlow
43−import kotlinx.coroutines.CancellationException
44−import kotlinx.coroutines.flow.collectLatest
45−import kotlinx.coroutines.flow.combine
46−import kotlinx.coroutines.flow.debounce
47−import kotlinx.coroutines.flow.distinctUntilChanged
48−import kotlinx.coroutines.flow.drop
49−import kotlinx.coroutines.flow.first
50−import kotlinx.coroutines.flow.filterIsInstance
51−import kotlinx.coroutines.flow.flatMapLatest
52−import kotlinx.coroutines.flow.flowOf
53−import kotlinx.coroutines.flow.flowOn
54−import kotlinx.coroutines.flow.map
55−import kotlinx.coroutines.flow.merge
56−import kotlinx.coroutines.flow.onEach
57−import kotlinx.coroutines.flow.retryWhen
58−import kotlinx.coroutines.flow.stateIn
59−import kotlinx.coroutines.flow.update
60−import kotlinx.coroutines.Dispatchers
61−import kotlinx.coroutines.Job
62−import kotlinx.coroutines.NonCancellable
63−import kotlinx.coroutines.delay
64−import kotlinx.coroutines.isActive
65−import kotlinx.coroutines.currentCoroutineContext
66−import kotlinx.coroutines.launch
67−import kotlinx.coroutines.withContext
68−
69−/**
70− * A user-facing error to surface as a snackbar. [retryable] is true only for a failed
71− * message *send* (whose draft is held in [ChatViewModel.failedDraft]); the snackbar attaches
72− * a Retry action solely for those so an unrelated error (e.g. a message-load failure) can't
73− * inherit a Retry that silently re-submits the last prompt.
74− */
75−data class ChatError(val message: String, val retryable: Boolean = false)
76−
77−/**
78− * Progress of the current permission within a burst of requests. [total] is the max number
79− * simultaneously pending since the queue was last empty; [position] advances as the user clears
80− * the backlog. A [total] of 0/1 means there's no meaningful backlog to surface.
81− */
82−@androidx.compose.runtime.Immutable
83−data class PermissionProgress(val position: Int, val total: Int)
84−
85−/**
86− * An attachment staged to send with the next prompt. [previewModel] is a Coil-loadable model
87− * for the thumbnail (the source content Uri, as a string) for images, or null for non-image
88− * files (rendered with a generic icon). [part] is the wire form (a base64 data URL) sent to
89− * the server.
90− */
91−@androidx.compose.runtime.Immutable
92−data class PendingAttachment(
93− val id: String,
94− val name: String,
95− val mime: String,
96− val previewModel: Any?,
97− val part: FilePromptPart,
98−) {
99− val isImage: Boolean get() = mime.startsWith("image/")
100−}
101−
102−/** Reduce a staged attachment to its persistable form (the self-contained data URL). */
103−private fun PendingAttachment.toPersisted() =
104− soy.iko.opencode.data.repo.PersistedAttachment(id, name, mime, part.url, part.filename)
105−
106−/** Rebuild a staged attachment from persistence. The base64 data URL doubles as the Coil
107− * preview model for images (the original content Uri didn't survive the process restart). */
108−private fun soy.iko.opencode.data.repo.PersistedAttachment.toPending() = PendingAttachment(
109− id = id,
110− name = name,
111− mime = mime,
112− previewModel = if (mime.startsWith("image/")) url else null,
113− part = FilePromptPart(mime = mime, url = url, filename = filename),
114−)
115−
116−// Large by design: this VM owns the whole chat surface (streaming, drafts, attachments,
117−// catalogs, permissions, revert/edit, sharing, summarize/init/shell, TTS wiring). It's
118−// already grandfathered for TooManyFunctions in the detekt baseline; the class-size rule is
119−// suppressed for the same reason. A future split into sub-controllers is tracked separately.
120−@Suppress("LargeClass")
121−@OptIn(ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
122−class ChatViewModel(
123− private val container: AppContainer,
124− private val sessionId: String,
125−) : ViewModel() {
126−
127− private val connection get() = container.activeConnection.value
128−
129− val connected: Boolean get() = connection != null
130−
131− private val _loading = MutableStateFlow(true)
132− val loading: StateFlow<Boolean> = _loading.asStateFlow()
133−
134− /** Set when the messages flow failed to load (and the list is empty), so the UI
135− * can render a distinct error state instead of masquerading as an empty
136− * conversation. Cleared on a successful non-empty emission. */
137− private val _loadError = MutableStateFlow(false)
138− val loadError: StateFlow<Boolean> = _loadError.asStateFlow()
139−
140− /** Tracks whether the messages flow has ever emitted a non-empty list, so
141− * [messages]' retryWhen can decide whether a re-fetch failure should set
142− * [loadError] (nothing shown yet → error screen) or just emit a snackbar
143− * (already showing messages → don't replace the conversation with an error). */
144− private var hasShownMessages = false
145−
146− /** True once we've surfaced a snackbar for the current message-load failure streak.
147− * Reset on any successful emission so a persistent failure doesn't spam a fresh
148− * snackbar every retry cycle (every few seconds). */
149− private var loadErrorSnackbarShown = false
150−
151− /** Separate from [loading]: tracks the manual reconnect() flow so its spinner
152− * doesn't conflict with the messages flow's loading state. */
153− private val _reconnecting = MutableStateFlow(false)
154− val reconnecting: StateFlow<Boolean> = _reconnecting.asStateFlow()
155−
156− /** When true, the debounced draft collector skips persisting an empty draft so
157− * [send]'s deliberate non-persistence of the cleared draft isn't undone by the
158− * debounce timer firing before the send completes. */
159− private val suppressDraftPersist = java.util.concurrent.atomic.AtomicBoolean(false)
160−
161− /** The draft value this VM last persisted, so the drafts observer can tell its own
162− * (debounced, possibly stale) echo apart from a genuine external write. Without this
163− * a debounced persist of an older value could echo back and overwrite newer text the
164− * user has since typed. */
165− @Volatile private var lastPersistedDraft: String? = null
166−
167− /** Per-catalog reload triggers: incrementing one causes [observeCatalog]'s
168− * collectLatest to cancel any in-flight fetch and start a fresh one, so a
169− * manual reload supersedes a stale observeCatalog fetch. */
170− private val _modelsReload = MutableStateFlow(0)
171− private val _agentsReload = MutableStateFlow(0)
172− private val _commandsReload = MutableStateFlow(0)
173−
174− /** An optimistically-injected user message shown immediately on send, before the server
175− * echoes the real message back via SSE. Removed once a matching UserMessage arrives. */
176− data class OptimisticEntry(val tempId: String, val text: String, val timestamp: Long, val failed: Boolean)
177−
178− private val _optimisticMessages = MutableStateFlow<List<OptimisticEntry>>(emptyList())
179−
180− /** SSE-driven message stream with retry/backoff and loading/error side effects. Combined
181− * with [_optimisticMessages] by [messages] so the user sees their outgoing prompt instantly. */
182− private val sseMessages: Flow<List<MessageWithParts>> =
183− container.activeConnection
184− .flatMapLatest { conn ->
185− conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
186− }
187− .onEach {
188− _loading.value = false
189− // A value flowed through: the stream recovered, so allow the next distinct
190− // failure to surface a fresh snackbar again.
191− loadErrorSnackbarShown = false
192− // Clear the error on ANY successful emission, including an empty list. An empty
193− // session that hit one transient error would otherwise stay latched on the
194− // "Failed to load / Retry" screen forever, because the later successful EMPTY
195− // emission couldn't clear the flag. hasShownMessages stays gated on non-empty
196− // (an empty session has genuinely shown nothing yet).
197− _loadError.value = false
198− if (it.isNotEmpty()) {
199− hasShownMessages = true
200− }
201− // Reconcile optimistic messages: drop any whose text now matches a real SSE
202− // UserMessage (the server echoed the prompt back). Matching by trimmed text,
203− // removing the oldest match first so duplicate texts are handled in order.
204− reconcileOptimistic(it)
205− }
206− .retryWhen { cause, attempt ->
207− _loading.value = false
208− // Only surface the persistent error state when there's nothing to
209− // show — if we already have messages, a transient re-fetch failure
210− // is better surfaced as a snackbar (via errorEvents) than by
211− // replacing the visible conversation with an error screen.
212− if (!hasShownMessages) _loadError.value = true
213− // Surface the snackbar only once per failure streak — retryWhen loops on a
214− // backoff, so emitting here unconditionally would spam a new snackbar every
215− // retry on a persistent load failure.
216− if (!loadErrorSnackbarShown) {
217− _errorEvents.trySend(ChatError(container.friendlyError(cause)))
218− loadErrorSnackbarShown = true
219− }
220− // Exponential backoff capped at retryMaxDelayMs so a persistently failing
221− // server is retried with growing delays (retryInitialDelayMs, then doubling)
222− // instead of hammered at a fixed interval forever. Clamp the shift so a long
223− // failure streak can't overflow the Long delay.
224− val backoffMs = (NetworkConfig.retryInitialDelayMs shl attempt.coerceAtMost(16L).toInt())
225− .coerceAtMost(NetworkConfig.retryMaxDelayMs)
226− delay(backoffMs)
227− true
228− }
229−
230− val messages: StateFlow<List<MessageWithParts>> =
231− combine(sseMessages, _optimisticMessages) { sse, optimistic ->
232− mergeOptimistic(sse, optimistic)
233− }.stateIn(
234− scope = viewModelScope,
235− started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
236− initialValue = emptyList(),
237− )
238−
239− /** Whether the conversation has any messages. Derived separately so the top bar
240− * (share button enabled state) can observe this cheap boolean instead of the
241− * full messages list, avoiding per-token recomposition of the app bar during
242− * streaming. */
243− val hasMessages: StateFlow<Boolean> = messages
244− .map { it.isNotEmpty() }
245− .stateIn(
246− scope = viewModelScope,
247− started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
248− initialValue = false,
249− )
250−
251− /** Map of optimistic message tempId → failed flag, so the UI can render a "Sending…"
252− * or "Failed to send" indicator on the corresponding message bubble. */
253− val optimisticStatuses: StateFlow<Map<String, Boolean>> = _optimisticMessages
254− .map { entries -> entries.associate { it.tempId to it.failed } }
255− .stateIn(
256− scope = viewModelScope,
257− started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
258− initialValue = emptyMap(),
259− )
260−
261− /** Merge SSE-driven messages with optimistic entries. An optimistic entry whose trimmed
262− * text matches a real [UserMessage] in [sse] is dropped (the server echoed it back).
263− * Remaining entries are appended as synthetic [UserMessage]s at the end. Failed entries
264− * are kept so the user sees the failed indicator until they retry or dismiss. */
265− private fun mergeOptimistic(
266− sse: List<MessageWithParts>,
267− optimistic: List<OptimisticEntry>,
268− ): List<MessageWithParts> {
269− if (optimistic.isEmpty()) return sse
270− // Collect the text of real user messages so optimistic entries can be matched.
271− val realUserTexts = mutableSetOf<String>()
272− for (msg in sse) {
273− if (msg.info !is UserMessage) continue
274− val text = msg.parts.filterIsInstance<TextPart>()
275− .joinToString("\n\n") { it.text }
276− .trim()
277− if (text.isNotEmpty()) realUserTexts.add(text)
278− }
279− val surviving = optimistic.filter { it.text.trim() !in realUserTexts }
280− if (surviving.isEmpty()) return sse
281− return sse + surviving.map { it.toMessageWithParts() }
282− }
283−
284− /** Remove optimistic entries whose text now matches a real user message delivered by SSE.
285− * Called from the SSE flow's onEach so stale optimistic entries are cleaned up promptly. */
286− private fun reconcileOptimistic(sse: List<MessageWithParts>) {
287− if (_optimisticMessages.value.isEmpty()) return
288− val realUserTexts = mutableSetOf<String>()
289− for (msg in sse) {
290− if (msg.info !is UserMessage) continue
291− val text = msg.parts.filterIsInstance<TextPart>()
292− .joinToString("\n\n") { it.text }
293− .trim()
294− if (text.isNotEmpty()) realUserTexts.add(text)
295− }
296− if (realUserTexts.isEmpty()) return
297− _optimisticMessages.update { entries ->
298− entries.filterNot { it.text.trim() in realUserTexts }
299− }
300− }
301−
302− /** Convert an optimistic entry to a [MessageWithParts] for display. */
303− private fun OptimisticEntry.toMessageWithParts(): MessageWithParts = MessageWithParts(
304− info = UserMessage(
305− id = tempId,
306− sessionID = sessionId,
307− time = soy.iko.opencode.data.model.TimeInfo(created = timestamp),
308− ),
309− parts = listOf(TextPart(id = "$tempId-text", text = text)),
310− )
311−
312− /** The agent's current task plan (the latest `todowrite`), surfaced so the chat can pin a
313− * live progress checklist above the composer. distinctUntilChanged so a per-token messages
314− * emission that doesn't change the plan doesn't recompose the bar. */
315− val todoPlan: StateFlow<List<TodoItem>> = messages
316− .map { currentTodoPlan(it) }
317− // Run currentTodoPlan off the main thread: it's an O(messages×parts) scan (worst case
318− // when the conversation has no todowrite) and runs on every conflated messages emission.
319− // distinctUntilChanged is downstream so it dedupes the result but doesn't prevent the
320− // scan — without flowOn this scans on Main.immediate per streamed token → jank.
321− .flowOn(Dispatchers.Default)
322− .distinctUntilChanged()
323− .stateIn(
324− scope = viewModelScope,
325− started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
326− initialValue = emptyList(),
327− )
328−
329− private val _running = MutableStateFlow(false)
330− val running: StateFlow<Boolean> = _running.asStateFlow()
331−
332− /** True once the current run ended via SessionIdle/SessionError, until the next run starts
333− * (a send) or an SSE reconnect. Gates the run-indicator re-light below: isRunActivity
334− * returns true for ANY message.part.updated, so without this a trailing/replayed part
335− * arriving after a legitimate SessionIdle would re-light the "working" indicator with no
336− * further idle to clear it — a stuck spinner + foreground service. Cleared on reconnect so
337− * genuine reconnect-recovery re-lighting (its actual purpose) still works. */
338− @Volatile private var runEndedByIdle = false
339−
340− /** The in-flight REST title resolution for the current connection. Launched from — and
341− * cancelled by — the per-connection reset block so it can't observe or restore a previous
342− * server's stale title. */
343− private var sessionTitleJob: Job? = null
344−
345− /** True while an abort REST call is in flight, so the Stop button can show a
346− * spinner and prevent double-taps from firing a second abort. */
347− private val _aborting = MutableStateFlow(false)
348− val aborting: StateFlow<Boolean> = _aborting.asStateFlow()
349−
350− /** A follow-up the user typed while a run was active. send() queues it here and
351− * auto-sends once the run completes (SessionIdle), so the user isn't blocked with
352− * no way to send and no indication why the Send button is gone. */
353− private val _queuedFollowUp = MutableStateFlow<String?>(null)
354− val queuedFollowUp: StateFlow<String?> = _queuedFollowUp.asStateFlow()
355−
356− /** One-shot error events surfaced as snackbars. A Channel (not SharedFlow) so an event
357− * emitted before the UI subscribes is buffered and still delivered — a SharedFlow with
358− * replay=0 would drop it (e.g. a VM-init catalog fetch failing before first
359− * composition). Each event is delivered exactly once and not replayed to a
360− * re-subscribing collector, so rotation doesn't re-show a stale snackbar. */
361− private val _errorEvents = Channel<ChatError>(
362− capacity = NetworkConfig.snackbarEventBufferCapacity,
363− onBufferOverflow = BufferOverflow.DROP_OLDEST,
364− )
365− val errorEvents: Flow<ChatError> = _errorEvents.receiveAsFlow()
366−
367− /** One-shot events signaling a user-initiated delete was scheduled and is undoable in-place.
368− * The ChatScreen collects these to show an Undo snackbar over the conversation the user is
369− * already viewing, instead of navigating away first and surfacing Undo on the session list. */
370− private val _deleteUndoEvents = MutableSharedFlow<Unit>(
371− extraBufferCapacity = 1,
372− onBufferOverflow = BufferOverflow.DROP_OLDEST,
373− )
374− val deleteUndoEvents: SharedFlow<Unit> = _deleteUndoEvents.asSharedFlow()
375−
376− private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
377− val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
378−
379− private val _modelsLoading = MutableStateFlow(true)
380− val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
381−
382− private val _modelsError = MutableStateFlow(false)
383− val modelsError: StateFlow<Boolean> = _modelsError.asStateFlow()
384−
385− private val _selectedModel = MutableStateFlow<ModelOption?>(null)
386− val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
387−
388− val connectionState: StateFlow<EventStreamClient.ConnectionState> =
389− container.activeConnection
390− .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
391− .stateIn(
392− scope = viewModelScope,
393− started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
394− initialValue = EventStreamClient.ConnectionState.Disconnected,
395− )
396−
397− private val _pendingPermission = MutableStateFlow<Permission?>(null)
398− val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
399−
400− /** Backlog indicator for the permission dialog (see [PermissionProgress]). */
401− private val _permissionProgress = MutableStateFlow(PermissionProgress(0, 0))
402− val permissionProgress: StateFlow<PermissionProgress> = _permissionProgress.asStateFlow()
403−
404− /** Max number of permissions simultaneously pending since the queue was last empty. Reset
405− * to 0 when the queue drains, so a fresh burst starts its count over. */
406− private var maxPendingSeen = 0
407−
408− // Unanswered permission requests keyed by id, insertion-ordered. The dialog shows the most
409− // recent; when one is answered/replied — or a response fails — we fall back to the next so a
410− // request that arrived while another was in flight isn't silently dropped (its tool run would
411− // otherwise stay paused server-side with no UI affordance). Only touched on the VM's main
412− // dispatcher (event collector + viewModelScope launches), so no extra synchronization is needed.
413− private val pendingPermissions = LinkedHashMap<String, Permission>()
414−
415− // Session-scoped permission grants (Allow-for-this-session). Each entry is a (type, patternText)
416− // pair the user approved with PermissionResponse.SESSION. Subsequent matching requests are
417− // auto-responded ONCE without showing the dialog, so e.g. a user granting "read" for one path
418− // isn't re-prompted for every other file read in the same conversation. Cleared on connection
419− // change (see the activeConnection collector) so it never leaks across servers. The server only
420− // knows once/always/reject; SESSION is a client-side scope that maps to ONCE on the wire.
421− private val sessionAllowed = mutableSetOf<Pair<String, String>>()
422−
423− private fun enqueuePermission(permission: Permission) {
424− // Auto-respond to a permission whose (type, pattern) the user previously granted for this
425− // session, instead of showing the dialog again. Resolved immediately (not via the queue) so
426− // the tool run isn't paused waiting for a dialog the user will never see.
427− val type = permission.type.orEmpty()
428− val pattern = permission.patternText.orEmpty()
429− if (type.isNotEmpty() && sessionAllowed.contains(type to pattern)) {
430− respondPermission(permission, PermissionResponse.ONCE)
431− return
432− }
433− pendingPermissions[permission.id] = permission
434− _pendingPermission.value = pendingPermissions.values.lastOrNull()
435− updatePermissionProgress()
436− }
437−
438− private fun resolvePermission(id: String) {
439− pendingPermissions.remove(id)
440− _pendingPermission.value = pendingPermissions.values.lastOrNull()
441− updatePermissionProgress()
442− }
443−
444− private fun clearPermissions() {
445− pendingPermissions.clear()
446− _pendingPermission.value = null
447− updatePermissionProgress()
448− }
449−
450− /** Recompute the "N of M" backlog indicator. [maxPendingSeen] grows with the queue and
451− * resets only when it drains, so position = total - remaining + 1 advances as requests are
452− * answered. */
453− private fun updatePermissionProgress() {
454− val remaining = pendingPermissions.size
455− if (remaining == 0) {
456− maxPendingSeen = 0
457− _permissionProgress.value = PermissionProgress(0, 0)
458− return
459− }
460− if (remaining > maxPendingSeen) maxPendingSeen = remaining
461− _permissionProgress.value = PermissionProgress(
462− position = maxPendingSeen - remaining + 1,
463− total = maxPendingSeen,
464− )
465− }
466−
467− private val _agents = MutableStateFlow<List<Agent>>(emptyList())
468− val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
469−
470− private val _agentsLoading = MutableStateFlow(true)
471− val agentsLoading: StateFlow<Boolean> = _agentsLoading.asStateFlow()
472−
473− private val _agentsError = MutableStateFlow(false)
474− val agentsError: StateFlow<Boolean> = _agentsError.asStateFlow()
475−
476− private val _selectedAgent = MutableStateFlow<String?>(null)
477− val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
478−
479− private val _commands = MutableStateFlow<List<Command>>(emptyList())
480− val commands: StateFlow<List<Command>> = _commands.asStateFlow()
481−
482− private val _commandsLoading = MutableStateFlow(true)
483− val commandsLoading: StateFlow<Boolean> = _commandsLoading.asStateFlow()
484−
485− private val _commandsError = MutableStateFlow(false)
486− val commandsError: StateFlow<Boolean> = _commandsError.asStateFlow()
487−
488− private val _sessionTitle = MutableStateFlow<String?>(null)
489− val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
490−
491− /** Set when the current session is deleted via SSE, so the UI can navigate away. */
492− private val _sessionDeleted = MutableStateFlow(false)
493− val sessionDeleted: StateFlow<Boolean> = _sessionDeleted.asStateFlow()
494−
495− /** The text of the last send that failed, surfaced so the UI can offer a retry. */
496− private val _failedDraft = MutableStateFlow<String?>(null)
497− val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
498−
499− /** The idempotency key of the last failed online send, stashed alongside [_failedDraft] so
500− * [retryFailed] re-submits with the SAME key. A retry after a lost response is then
501− * deduplicated server-side instead of starting a duplicate agent run. */
502− @Volatile private var failedIdempotencyKey: String? = null
503−
504− /** Per-session draft, persisted so it survives navigation/process death. */
505− private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
506− val draft: StateFlow<String> = _draft.asStateFlow()
507−
508− /** Messages composed for this session while offline/disconnected, queued in the outbox and
509− * awaiting an automatic flush on reconnect. Surfaced so the composer can show a "queued"
510− * chip with a discard/send-now affordance. */
511− val outbox: StateFlow<List<OutboxMessage>> =
512− container.outboxStore.messages
513− .map { list -> list.filter { it.sessionId == sessionId } }
514− .stateIn(
515− scope = viewModelScope,
516− started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
517− initialValue = emptyList(),
518− )
519−
520− /** Attachments staged for the next prompt (images/files). Persisted per-session (as their
521− * self-contained base64 data URLs) via [AttachmentDraftStore] so an interrupted compose
522− * survives process death — the source content Uris wouldn't survive, but the data URLs
523− * do. Cleared on a successful send. */
524− private val _attachments = MutableStateFlow<List<PendingAttachment>>(emptyList())
525− val attachments: StateFlow<List<PendingAttachment>> = _attachments.asStateFlow()
526−
527− /** True while a revert checkpoint is active for this session (messages after it are hidden
528− * server-side). Drives the "reverted" banner with its Undo. */
529− private val _reverted = MutableStateFlow(false)
530− val reverted: StateFlow<Boolean> = _reverted.asStateFlow()
531−
532− /** The diff of the active revert checkpoint, when the server includes one. Shown in the
533− * revert banner so the user can see what changed before deciding to undo. Null when the
534− * session isn't reverted or the server didn't send a diff. */
535− private val _revertDiff = MutableStateFlow<String?>(null)
536− val revertDiff: StateFlow<String?> = _revertDiff.asStateFlow()
537−
538− /** The active public share URL for this session, or null when not shared. */
539− private val _shareUrl = MutableStateFlow<String?>(null)
540− val shareUrl: StateFlow<String?> = _shareUrl.asStateFlow()
541−
542− /** One-shot events carrying a freshly-created share URL so the UI can copy/share it. */
543− private val _shareLinkEvents = Channel<String>(
544− capacity = NetworkConfig.snackbarEventBufferCapacity,
545− onBufferOverflow = BufferOverflow.DROP_OLDEST,
546− )
547− val shareLinkEvents: Flow<String> = _shareLinkEvents.receiveAsFlow()
548−
549− /** One-shot events carrying the id of a session freshly branched from a message, so the
550− * UI can navigate to it. */
551− private val _branchEvents = Channel<String>(
552− capacity = NetworkConfig.snackbarEventBufferCapacity,
553− onBufferOverflow = BufferOverflow.DROP_OLDEST,
554− )
555− val branchEvents: Flow<String> = _branchEvents.receiveAsFlow()
556−
557− init {
558− // DraftStore loads SharedPreferences asynchronously; on a cold start the
559− // synchronous get() above returns "" until the background load completes.
560− // Observe the ready signal and re-seed the draft so it appears in the UI.
561− viewModelScope.launch {
562− container.draftStore.ready.collect { ready ->
563− if (!ready) return@collect
564− if (_draft.value.isEmpty()) {
565− _draft.value = container.draftStore.get(sessionId)
566− }
567− // Recover a follow-up queued before process death (its in-memory StateFlow
568− // is gone). If the input is now free, drop it back there for review/send and
569− // consume the stored copy; otherwise re-queue it so it auto-sends when the
570− // current run next idles. Guarded on == null so it never clobbers a
571− // follow-up the user queued after opening this session.
572− val savedFollowUp = container.draftStore.getFollowUp(sessionId)
573− if (savedFollowUp.isNotEmpty() && _queuedFollowUp.value == null) {
574− if (_draft.value.isEmpty()) {
575− _draft.value = savedFollowUp
576− container.draftStore.flushFollowUp(sessionId, "")
577− } else {
578− _queuedFollowUp.value = savedFollowUp
579− }
580− }
581− }
582− }
583− // Observe the drafts store for external mutations to this session's draft
584− // (e.g. a share injected via draftStore.set in two-pane mode, or setImmediate
585− // before navigation in single-pane mode). Without this, a share injected into
586− // an already-open session never appears in the input field — the init-time
587− // read and the ready-only re-seed miss it when the draft is non-empty.
588− // The VM's own debounced persist writes the same value _draft already holds,
589− // so those updates are no-ops here (storeValue == _draft.value). The
590− // suppressDraftPersist guard prevents a stale store value from clobbering
591− // the deliberate draft clear during a send (the store still holds the
592− // pre-send draft until the send resolves).
593− viewModelScope.launch {
594− container.draftStore.drafts.collect { drafts ->
595− // Ignore emissions until the initial disk load completes: the load itself
596− // emits the persisted snapshot, and applying it here would clobber text the
597− // user typed during the async load (the `ready` observer above seeds an
598− // empty draft). After ready, emissions are external writes.
599− if (!container.draftStore.ready.value) return@collect
600− val storeValue = drafts[sessionId].orEmpty()
601− // Ignore this VM's own (debounced, possibly stale) persistence echo — only a
602− // genuine external write (e.g. a two-pane share injection) should overwrite
603− // the live draft.
604− if (storeValue == lastPersistedDraft) return@collect
605− if (storeValue != _draft.value && !suppressDraftPersist.get()) {
606− _draft.value = storeValue
607− }
608− }
609− }
610− }
611−
612− init {
613− // Restore attachments staged before process death. They were persisted as
614− // self-contained data URLs, so they re-send and (for images) re-preview intact.
615− // Guarded on empty so it never clobbers attachments the user staged after opening.
616− viewModelScope.launch {
617− val restored = runCatchingCancellable { container.attachmentDraftStore.load(sessionId) }
618− .getOrDefault(emptyList())
619− if (restored.isNotEmpty() && _attachments.value.isEmpty()) {
620− _attachments.value = restored.map { it.toPending() }
621− }
622− }
623− }
624−
625− fun selectModel(option: ModelOption) { _selectedModel.value = option }
626−
627− fun selectAgent(name: String?) { _selectedAgent.value = name }
628−
629− fun reloadModels() {
630− val conn = connection ?: return
631− viewModelScope.launch {
632− conn.api.invalidateProvidersCache()
633− _modelsReload.value++
634− }
635− }
636−
637− fun reloadAgents() {
638− val conn = connection ?: return
639− viewModelScope.launch {
640− conn.api.invalidateAgentsCache()
641− _agentsReload.value++
642− }
643− }
644−
645− fun reloadCommands() {
646− val conn = connection ?: return
647− viewModelScope.launch {
648− conn.api.invalidateCommandsCache()
649− _commandsReload.value++
650− }
651− }
652−
653− /**
654− * Shared helper for init-block catalog observers: re-fetches whenever the active
655− * connection or the catalog's reload trigger changes, invoking [onNull] to clear
656− * state on null so stale data from the old server doesn't persist. The
657− * [reloadTrigger] is merged with [activeConnection] so a manual reload (via
658− * reloadModels/reloadAgents/reloadCommands) cancels any in-flight observe fetch
659− * via collectLatest, preventing a stale observe result from overwriting a fresh
660− * reload result. Failure is non-fatal — the error flag is surfaced to the UI.
661− */
662− private fun <T> observeCatalog(
663− tag: String,
664− loading: MutableStateFlow<Boolean>,
665− error: MutableStateFlow<Boolean>,
666− fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
667− onSuccess: (T) -> Unit,
668− onNull: () -> Unit,
669− reloadTrigger: StateFlow<Int>,
670− ) {
671− viewModelScope.launch {
672− merge(container.activeConnection, reloadTrigger).collectLatest { _ ->
673− val conn = container.activeConnection.value
674− if (conn == null) { onNull(); loading.value = false; error.value = false; return@collectLatest }
675− loading.value = true
676− error.value = false
677− runCatchingCancellable { fetch(conn.api) }
678− // fetch() does HTTP via withRetry, so the failure can be a
679− // ClientRequestException whose message embeds the full request URL
680− // (may contain auth/paths). Log only a scrubbed summary.
681− .onFailure { Log.w("ChatViewModel", "Failed to load $tag: ${safeExceptionSummary(it)}"); error.value = true }
682− .getOrNull()?.let(onSuccess)
683− loading.value = false
684− }
685− }
686− }
687−
688− fun updateDraft(text: String) {
689− _draft.value = text
690− }
691−
692− init {
693− // Debounce draft persistence so we don't write to disk on every keystroke.
694− // Skip persisting empty drafts that were set by send() (suppressed via
695− // suppressDraftPersist) — send() clears the in-memory draft immediately for
696− // UI feedback but deliberately doesn't persist the clear until the send
697− // succeeds, so a failed send can restore the draft.
698− viewModelScope.launch {
699− _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
700− if (text.isBlank() && suppressDraftPersist.get()) return@collect
701− lastPersistedDraft = text
702− runCatchingCancellable { container.draftStore.set(sessionId, text) }
703− .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
704− }
705− }
706− // Reset per-connection state when the active server changes so stale spinners,
707− // permission dialogs, errors, and agent selections from the old server don't
708− // persist into the new one.
709− viewModelScope.launch {
710− container.activeConnection.collectLatest { conn ->
711− if (conn == null) {
712− // The connection dropped (e.g. disconnect() mid-run). No SSE stream will
713− // arrive to deliver SessionIdle/SessionError, so reset the run state here —
714− // otherwise the working spinner sticks on, ChatScreen keeps keepScreenOn +
715− // the RunForegroundService alive, and the Stop button no-ops (abort() early-
716− // returns with no connection) until a later reconnect happens to clear it.
717− _running.value = false
718− _aborting.value = false
719− clearPermissions()
720− sessionAllowed.clear()
721− sessionTitleJob?.cancel()
722− _revertDiff.value = null
723− return@collectLatest
724− }
725− _running.value = false
726− // Allow the re-light below to recover a still-running run after this (re)connect;
727− // a run that actually finished during the outage emits no live parts, so this
728− // won't spuriously re-light.
729− runEndedByIdle = false
730− clearPermissions()
731− sessionAllowed.clear()
732− _failedDraft.value = null
733− _revertDiff.value = null
734− // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
735− // user intent that is now persisted; wiping it on every (re)connect — which
736− // this collector does, including transient SSE drops and the cold-start
737− // restore path — would drop a legitimately queued/recovered follow-up.
738− _selectedAgent.value = null
739− _sessionTitle.value = null
740− // Resolve the app-bar title via REST for THIS connection. Launched from the same
741− // block that just nulled _sessionTitle (and cancelled the previous fetch) so it can
742− // never observe or restore the previous server's stale title — the old separate
743− // collector could, racing that reset. The assignment is guarded on a still-null
744− // title so a fresher SSE SessionUpdated (from the event collector below) wins.
745− sessionTitleJob?.cancel()
746− sessionTitleJob = viewModelScope.launch {
747− runCatchingCancellable { conn.repository.listSessions() }
748− .getOrNull()
749− ?.firstOrNull { it.id == sessionId }
750− ?.let { session ->
751− if (_sessionTitle.value == null) {
752− _sessionTitle.value = session.displayTitle
753− _reverted.value = session.isReverted
754− _revertDiff.value = session.revert?.diff?.takeIf { it.isNotBlank() }
755− _shareUrl.value = session.share?.url?.takeIf { it.isNotBlank() }
756− }
757− }
758− }
759− try {
760− // Loop the inner collect so a transient non-cancellation exception (a
761− // malformed SSE event decoding error, an unexpected channel close)
762− // doesn't terminate the event collector for this connection. Without
763− // the loop, the catch returns the collectLatest lambda normally, so
764− // collectLatest considers this connection's block complete and never
765− // re-runs it — the chat would go permanently silent (no streaming,
766− // no permission prompts, no title updates) until a server switch.
767− while (currentCoroutineContext().isActive) {
768− try {
769− conn.events.events.collect { event ->
770− if (SessionRepository.isIdle(event, sessionId)) {
771− _running.value = false
772− runEndedByIdle = true
773− // Auto-send a queued follow-up once the previous run finishes,
774− // so the user's drafted-while-running message isn't lost.
775− val queued = _queuedFollowUp.value
776− if (queued != null) {
777− setQueuedFollowUp(null)
778− send(queued, includeAttachments = false)
779− }
780− }
781− if (SessionRepository.isError(event, sessionId)) {
782− _running.value = false
783− runEndedByIdle = true
784− // The run errored, so the queued follow-up won't auto-send.
785− // Restore it to the input (if free) so the user's typed text
786− // isn't silently lost; otherwise just clear the queue.
787− val queued = _queuedFollowUp.value
788− if (!queued.isNullOrEmpty() && _draft.value.isEmpty()) {
789− _draft.value = queued
790− }
791− setQueuedFollowUp(null)
792− _errorEvents.trySend(ChatError(container.string(R.string.error_agent_reported)))
793− }
794− // Restore the run indicator if a live streaming event arrives while it's
795− // cleared — e.g. an SSE reconnect mid-run reset _running to false, but the
796− // agent is still working. Don't revive it once the user has hit Stop.
797− val relightRunIndicator = SessionRepository.isRunActivity(event, sessionId) &&
798− !_aborting.value && !_running.value && !runEndedByIdle
799− if (relightRunIndicator) {
800− _running.value = true
801− }
802− when (event) {
803− is PermissionUpdated ->
804− if (event.properties.sessionID == sessionId) enqueuePermission(event.properties)
805− is PermissionReplied ->
806− if (event.properties.sessionID == sessionId) event.properties.permissionID?.let { resolvePermission(it) }
807− is SessionUpdated ->
808− if (event.properties.info.id == sessionId) {
809− val info = event.properties.info
810− _sessionTitle.value = info.displayTitle
811− _reverted.value = info.isReverted
812− _revertDiff.value = info.revert?.diff?.takeIf { it.isNotBlank() }
813− _shareUrl.value = info.share?.url?.takeIf { it.isNotBlank() }
814− }
815− is SessionDeleted ->
816− if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
817− _sessionDeleted.value = true
818− }
819− else -> {}
820− }
821− }
822− } catch (e: CancellationException) {
823− throw e
824− } catch (e: Exception) {
825− Log.w("ChatViewModel", "SSE event collector error, restarting", e)
826− }
827− }
828− } catch (e: CancellationException) {
829− throw e
830− } catch (e: Exception) {
831− Log.w("ChatViewModel", "SSE event collector loop error", e)
832− }
833− }
834− }
835− // Load the model catalog; preselect the server default. Failure is non-fatal —
836− // sending with no model just uses the server's default agent/model.
837− observeCatalog(
838− tag = "model catalog",
839− loading = _modelsLoading,
840− error = _modelsError,
841− fetch = { it.providers() },
842− onSuccess = { resp ->
843− val options = resp.toOptions()
844− _models.value = options
845− // Only (re)apply the server default when there's no valid current
846− // selection. On re-fetch/reconnect/Retry the user's chosen model must
847− // survive — keep it as long as it's still present in the refreshed list.
848− val current = _selectedModel.value
849− if (current == null || options.none { it.ref == current.ref }) {
850− _selectedModel.value = resp.defaultOption(options)
851− }
852− },
853− onNull = { _models.value = emptyList(); _selectedModel.value = null },
854− reloadTrigger = _modelsReload,
855− )
856− // Load the agent catalog (non-fatal).
857− observeCatalog(
858− tag = "agent catalog",
859− loading = _agentsLoading,
860− error = _agentsError,
861− fetch = { it.agents() },
862− onSuccess = { _agents.value = it },
863− onNull = { _agents.value = emptyList() },
864− reloadTrigger = _agentsReload,
865− )
866− // Load the command catalog (non-fatal).
867− observeCatalog(
868− tag = "command catalog",
869− loading = _commandsLoading,
870− error = _commandsError,
871− fetch = { it.commands() },
872− onSuccess = { _commands.value = it },
873− onNull = { _commands.value = emptyList() },
874− reloadTrigger = _commandsReload,
875− )
876− // If the SSE stream drops mid-run, the run indicator would spin forever;
877− // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
878− viewModelScope.launch {
879− container.activeConnection.collectLatest { conn ->
880− if (conn == null) return@collectLatest
881− try {
882− // Loop so a transient non-cancellation exception doesn't terminate the
883− // state watcher for this connection. Without the loop, a single thrown
884− // error returns the collectLatest lambda and the _running reset on
885− // Disconnected/Failed never fires again for this connection — a dropped
886− // stream leaves the working spinner (and the RunForegroundService) stuck.
887− while (currentCoroutineContext().isActive) {
888− try {
889− conn.events.state.collect { state ->
890− val isDown = state == EventStreamClient.ConnectionState.Disconnected ||
891− state == EventStreamClient.ConnectionState.Failed ||
892− state == EventStreamClient.ConnectionState.AuthFailed
893− if (isDown && _running.value) {
894− _running.value = false
895− }
896− }
897− } catch (e: CancellationException) {
898− throw e
899− } catch (e: Exception) {
900− Log.w("ChatViewModel", "SSE state collector error, restarting", e)
901− }
902− }
903− } catch (e: CancellationException) {
904− throw e
905− } catch (e: Exception) {
906− Log.w("ChatViewModel", "SSE state collector loop error", e)
907− }
908− }
909− }
910− }
911−
912− /** Sends [text] together with any staged attachments; returns true on success so the
913− * caller can clear the draft only then. */
914− fun send(text: String): Boolean = send(text, includeAttachments = true)
915−
916− /**
917− * Core send. [includeAttachments] is false for the internal auto-send paths (a queued
918− * follow-up, a retry) so staged attachments aren't silently re-sent with unrelated text.
919− */
920− private fun send(text: String, includeAttachments: Boolean, idempotencyKey: String? = null): Boolean {
921− val trimmed = text.trim()
922− val attachments = if (includeAttachments) _attachments.value else emptyList()
923− // An image-only prompt (attachments, no text) is valid; a blank prompt with nothing
924− // attached is not.
925− if (trimmed.isEmpty() && attachments.isEmpty()) return false
926− // Offline, or no active connection: queue the prompt to the outbox (flushed on
927− // reconnect) instead of failing the send. Device-offline is caught even when a stale
928− // connection object lingers, so a phone that lost signal mid-conversation still
929− // captures the message rather than bouncing it off a dead socket.
930− val conn = connection
931− if (conn == null || !container.isOnline.value) {
932− return enqueueOffline(trimmed, attachments)
933− }
934− if (!_running.compareAndSet(false, true)) return false
935− // A new run is starting: reopen the re-light gate so this run's streamed parts keep the
936− // indicator lit (and a later SessionIdle re-closes it).
937− runEndedByIdle = false
938− // A plain manual Send of the auto-restored failed draft must reuse the stashed key too
939− // (retryFailed passes it explicitly; the composer's Send does not), so a lost-response
940− // re-send stays deduplicated server-side instead of starting a duplicate agent run.
941− // Capture the match BEFORE clearing _failedDraft just below.
942− val reuseKey = if (idempotencyKey == null && trimmed == _failedDraft.value?.trim()) {
943− failedIdempotencyKey
944− } else {
945− null
946− }
947− _failedDraft.value = null
948− // Stable idempotency key for THIS online attempt: a brand-new message gets a fresh key,
949− // while retryFailed() (or a manual re-send of the restored failed draft) re-submits with the
950− // key it stashed on the original failure — so a retry after a lost response is deduplicated
951− // server-side (no duplicate agent run).
952− val key = idempotencyKey ?: reuseKey ?: java.util.UUID.randomUUID().toString()
953− // Clear the staged attachments optimistically so the composer empties immediately;
954− // restore them if the send fails (mirrors the draft handling below).
955− if (attachments.isNotEmpty()) _attachments.value = emptyList()
956− // Clear the in-memory draft for the UI immediately, but don't persist the clear
957− // yet — if the send fails and the process dies before we restore, the draft
958− // would be lost forever. The persisted draft is cleared only on success.
959− // suppressDraftPersist prevents the debounced collector from persisting the
960− // empty draft before the send resolves.
961− //
962− // Only clear when the text being sent IS the current draft. send() is also
963− // invoked for auto-sent queued follow-ups (SessionIdle) and retryFailed(), where
964− // the user may have typed a NEW draft since — wiping it would lose that text.
965− if (_draft.value.trim() == trimmed) {
966− suppressDraftPersist.set(true)
967− _draft.value = ""
968− }
969− // Inject an optimistic user message so the outgoing prompt is visible immediately,
970− // before the server echoes it back via SSE. Removed by reconcileOptimistic once the
971− // real UserMessage arrives. Only for text-bearing prompts (an image-only prompt has
972− // no text to show).
973− val optimisticId = if (trimmed.isNotEmpty()) "__optimistic_${System.nanoTime()}" else null
974− if (optimisticId != null) {
975− // Clear any prior failed optimistic entry with the same text (a retry re-sends).
976− _optimisticMessages.update { entries -> entries.filterNot { it.failed && it.text.trim() == trimmed } }
977− _optimisticMessages.update { it + OptimisticEntry(optimisticId, trimmed, System.currentTimeMillis(), failed = false) }
978− }
979− viewModelScope.launch {
980− runCatchingCancellable {
981− conn.repository.sendPrompt(
982− sessionId,
983− trimmed,
984− attachments = attachments.map { it.part },
985− model = _selectedModel.value?.ref,
986− agent = _selectedAgent.value,
987− idempotencyKey = key,
988− )
989− }.onFailure {
990− // Mark the optimistic message as failed so the user sees a failure indicator
991− // instead of a perpetual "sending" state.
992− if (optimisticId != null) {
993− _optimisticMessages.update { entries ->
994− entries.map { if (it.tempId == optimisticId) it.copy(failed = true) else it }
995− }
996− }
997− suppressDraftPersist.set(false)
998− _failedDraft.value = trimmed
999− failedIdempotencyKey = key
1000− // Only restore the draft if the user hasn't typed anything new since.
1001− if (_draft.value.isBlank()) updateDraft(trimmed)
1002− // Restore the staged attachments too, so a failed send doesn't lose them —
1003− // but only if the user hasn't staged new ones in the meantime.
1004− if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
1005− // Re-persist so the restored attachments survive process death after a failure.
1006− persistAttachments()
1007− // Retryable: this is the failed send whose prompt Retry re-submits.
1008− _errorEvents.trySend(ChatError(container.friendlyError(it), retryable = true))
1009− _running.value = false
1010− }.onSuccess {
1011− suppressDraftPersist.set(false)
1012− // This attempt reached the server, so the stashed retry key is spent — drop it so a
1013− // later unrelated send can't reuse it (retryFailed clears it on success too).
1014− failedIdempotencyKey = null
1015− // Send succeeded — the attachments rode along, so clear their persisted copy
1016− // (or keep any the user staged after the optimistic clear).
1017− persistAttachments()
1018− // Send succeeded — persist the cleared draft, but only if the user hasn't
1019− // typed a new one while the send was in flight. Otherwise clearing the
1020− // store echoes back through the draft observer and wipes the in-progress
1021− // text (data loss). Mirrors the guard on the failure path above.
1022− if (_draft.value.isBlank()) {
1023− lastPersistedDraft = ""
1024− container.draftStore.set(sessionId, "")
1025− }
1026− // Don't reset _running here: the agent continues streaming via SSE.
1027− // _running is cleared on SessionIdle/SessionError (see event collector)
1028− // or when the SSE stream drops (see connection state watcher below).
1029− }
1030− }
1031− return true
1032− }
1033−
1034− /**
1035− * Queue [trimmed] (+ [attachments]) to the offline outbox, clearing the composer
1036− * optimistically like a real send. AppContainer flushes the outbox automatically on
1037− * reconnect. The prompt is attributed to the active (or most-recent) server profile so a
1038− * reconnect to a different server doesn't misfire it. On any failure the draft/attachments
1039− * are restored so nothing is lost.
1040− */
1041− private fun enqueueOffline(trimmed: String, attachments: List<PendingAttachment>): Boolean {
1042− _failedDraft.value = null
1043− if (attachments.isNotEmpty()) _attachments.value = emptyList()
1044− val clearDraft = _draft.value.trim() == trimmed
1045− if (clearDraft) {
1046− suppressDraftPersist.set(true)
1047− _draft.value = ""
1048− }
1049− viewModelScope.launch {
1050− val profileId = connection?.profile?.id
1051− ?: runCatchingCancellable { container.profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
1052− if (profileId == null) {
1053− // No server to attribute the message to — restore the composer and report it
1054− // rather than silently dropping the prompt.
1055− suppressDraftPersist.set(false)
1056− if (_draft.value.isBlank()) updateDraft(trimmed)
1057− if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
1058− persistAttachments()
1059− _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
1060− return@launch
1061− }
1062− runCatchingCancellable {
1063− container.outboxStore.enqueue(
1064− OutboxMessage(
1065− id = java.util.UUID.randomUUID().toString(),
1066− profileId = profileId,
1067− sessionId = sessionId,
1068− text = trimmed,
1069− attachments = attachments.map { it.toPersisted() },
1070− model = _selectedModel.value?.ref,
1071− agent = _selectedAgent.value,
1072− createdAt = System.currentTimeMillis(),
1073− ),
1074− )
1075− }.onSuccess {
1076− suppressDraftPersist.set(false)
1077− // The staged attachments now live in the outbox item; clear their draft copy.
1078− persistAttachments()
1079− if (_draft.value.isBlank()) {
1080− lastPersistedDraft = ""
1081− container.draftStore.set(sessionId, "")
1082− }
1083− // Nudge a flush in case we're actually online with a live connection.
1084− container.flushOutbox()
1085− _errorEvents.trySend(ChatError(container.string(R.string.outbox_queued)))
1086− }.onFailure {
1087− suppressDraftPersist.set(false)
1088− if (_draft.value.isBlank()) updateDraft(trimmed)
1089− if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
1090− persistAttachments()
1091− _errorEvents.trySend(ChatError(container.friendlyError(it)))
1092− }
1093− }
1094− return true
1095− }
1096−
1097− /** Discard a single queued (offline) message. */
1098− fun discardQueued(id: String) {
1099− viewModelScope.launch { runCatchingCancellable { container.outboxStore.remove(id) } }
1100− }
1101−
1102− /** Discard every queued (offline) message for this session. */
1103− fun discardAllQueued() {
1104− viewModelScope.launch { runCatchingCancellable { container.outboxStore.removeForSession(sessionId) } }
1105− }
1106−
1107− /** Attempt to flush the outbox now (e.g. the user tapped "Send now"). */
1108− fun flushQueued() { container.flushOutbox() }
1109−
1110− /** Re-send the last draft whose send failed, if any. */
1111− fun retryFailed() {
1112− val draft = _failedDraft.value ?: return
1113− // Reuse the failed send's idempotency key so a retry after a lost response is
1114− // deduplicated server-side rather than starting a duplicate run.
1115− val key = failedIdempotencyKey
1116− // Don't clear _failedDraft until send() accepts the text — if _running is
1117− // already true, send() returns false and the draft would be lost forever.
1118− // Attachments aren't re-sent on retry (they were optimistically restored to the
1119− // composer on the original failure, so they'll ride the next manual send).
1120− if (send(draft, includeAttachments = false, idempotencyKey = key)) {
1121− _failedDraft.value = null
1122− failedIdempotencyKey = null
1123− }
1124− }
1125−
1126− /** Dismiss a single failed optimistic message by its tempId so it no longer lingers in the
1127− * list. Without this there's no recovery path for a failed message the user chose to abandon
1128− * (its text won't ever match a real server message, so reconcileOptimistic never clears it). */
1129− fun dismissOptimistic(tempId: String) {
1130− _optimisticMessages.update { entries -> entries.filterNot { it.tempId == tempId } }
1131− }
1132−
1133− /** Re-send a specific failed optimistic message by its tempId. Loads its text into the
1134− * composer and fires a send, mirroring the snackbar's Retry path. Returns false (and is a
1135− * no-op) if the entry can't be found or a run is already active. */
1136− fun retryOptimisticMessage(tempId: String): Boolean {
1137− val entry = _optimisticMessages.value.firstOrNull { it.tempId == tempId && it.failed } ?: return false
1138− return send(entry.text, includeAttachments = false)
1139− }
1140−
1141− /**
1142− * Queue [text] to be sent automatically when the current run finishes, or clear any
1143− * queued follow-up when [text] is blank. Used when the user taps Send while a run
1144− * is already active — the Send button is replaced by Stop during a run, but the
1145− * input field stays enabled, so a follow-up typed mid-run would otherwise have
1146− * nowhere to go.
1147− */
1148− fun queueFollowUp(text: String) {
1149− val trimmed = text.trim()
1150− setQueuedFollowUp(trimmed.takeIf { it.isNotEmpty() })
1151− // The text now lives in the queued chip, so clear the input field just as a
1152− // normal send would. No-op for the cancel case (blank text), which must leave
1153− // whatever the user has since typed untouched.
1154− if (trimmed.isNotEmpty()) {
1155− _draft.value = ""
1156− // Persist the cleared draft IMMEDIATELY, not just in memory: the follow-up was already
1157− // persisted synchronously above, so if the process is killed before the debounced
1158− // draft-persist fires, the same text would remain under BOTH the draft and follow-up
1159− // keys — and cold-start recovery would restore it to the composer AND re-queue it (a
1160− // duplicate). Mirror send()'s lastPersistedDraft update so the drafts observer treats
1161− // this store change as our own echo and doesn't write the cleared value back.
1162− lastPersistedDraft = ""
1163− container.draftStore.flushDraft(sessionId, "")
1164− }
1165− }
1166−
1167− /** Set or clear the queued follow-up and mirror it to persistence, so a follow-up
1168− * queued mid-run survives process death (recovered on the next open of this session).
1169− * Passing null / blank clears both the in-memory value and the persisted copy. */
1170− private fun setQueuedFollowUp(text: String?) {
1171− _queuedFollowUp.value = text
1172− container.draftStore.flushFollowUp(sessionId, text.orEmpty())
1173− }
1174−
1175− /** Transient flag set by [refreshMessages] so the top-bar refresh icon can show
1176− * a brief spinner as immediate tap feedback. The SSE reconnect triggered by
1177− * refreshMessages may not visibly change [connectionState] (it's already
1178− * Connected), so without this the tap appears to do nothing. Clears after a
1179− * short delay or when the connection state next becomes Connected. */
1180− private val _refreshing = MutableStateFlow(false)
1181− val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
1182−
1183− /** Manually re-fetch messages by forcing the SSE stream to reconnect, which
1184− * triggers the repository's re-seed-from-REST logic. Gives the user a recovery
1185− * path when the SSE stream silently drops and the auto-reconnect re-seed is slow
1186− * or fails — a pull-to-refresh or top-bar refresh forces a fresh fetch under the
1187− * existing generation-guarded merge. Also clears [loadError] optimistically and
1188− * sets [refreshing] for immediate tap feedback. */
1189− fun refreshMessages() {
1190− val conn = connection ?: return
1191− _loadError.value = false
1192− _refreshing.value = true
1193− conn.events.triggerReconnect()
1194− viewModelScope.launch {
1195− delay(NetworkConfig.refreshFeedbackMs)
1196− _refreshing.value = false
1197− }
1198− }
1199−
1200− /** Invoke a slash-command by name via the server's /command endpoint. */
1201− fun runCommand(command: Command) {
1202− val conn = connection ?: return
1203− // A run is already in flight — surface feedback instead of silently dropping the
1204− // command (the picker stays openable during a run and dismisses on select).
1205− if (!_running.compareAndSet(false, true)) {
1206− _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1207− return
1208− }
1209− // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1210− // reconnect keeps the working indicator lit instead of leaving it stuck off.
1211− runEndedByIdle = false
1212− viewModelScope.launch {
1213− runCatchingCancellable {
1214− conn.repository.runCommand(sessionId, command.name, agent = command.agent)
1215− }.onFailure {
1216− _errorEvents.trySend(ChatError(container.friendlyError(it)))
1217− _running.value = false
1218− }
1219− }
1220− }
1221−
1222− fun abort() {
1223− val conn = connection ?: return
1224− // Drop any queued follow-up so the SessionIdle that follows the abort doesn't
1225− // auto-send it — the user tapped Stop to halt work, not to trigger the next turn.
1226− setQueuedFollowUp(null)
1227− if (!_aborting.compareAndSet(false, true)) return
1228− viewModelScope.launch {
1229− try {
1230− // Run the abort POST under NonCancellable so it survives this scope being
1231− // cancelled. The "Stop and exit" path calls abort() then navigates back
1232− // immediately, which pops this ViewModel's back-stack entry → onCleared() →
1233− // viewModelScope.cancel(); without this the in-flight abort is cancelled (often
1234− // before the request even reaches the server) and the run keeps burning tokens
1235− // server-side despite the user explicitly stopping it.
1236− runCatchingCancellable { withContext(NonCancellable) { conn.repository.abort(sessionId) } }
1237− .onSuccess { _running.value = false }
1238− .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1239− } finally {
1240− _aborting.value = false
1241− }
1242− }
1243− }
1244−
1245− fun addAttachment(attachment: PendingAttachment) {
1246− // Enforce the cumulative size cap across the whole staged set (the per-file size and the
1247− // count caps are checked upstream). The base64 data URLs are held in memory and
1248− // re-serialized on every add/remove and again when the request body is built, so an
1249− // unbounded set OOMs. Reject rather than add, mirroring the other attachment limits'
1250− // snackbar via errorEvents. Size is the raw (pre-base64) byte count, matching the
1251− // per-file check, and includes attachments already staged (incl. restored ones).
1252− val staged = _attachments.value.sumOf { base64DataUrlByteSize(it.part.url) }
1253− if (staged + base64DataUrlByteSize(attachment.part.url) > NetworkConfig.maxTotalAttachmentBytes) {
1254− _errorEvents.trySend(ChatError(container.string(R.string.attachment_limit)))
1255− return
1256− }
1257− _attachments.update { it + attachment }
1258− persistAttachments()
1259− }
1260−
1261− fun removeAttachment(id: String) {
1262− _attachments.update { list -> list.filterNot { it.id == id } }
1263− persistAttachments()
1264− }
1265−
1266− fun clearAttachments() {
1267− _attachments.value = emptyList()
1268− persistAttachments()
1269− }
1270−
1271− /** Persist the current staged attachments so an interrupted compose survives process
1272− * death (mirrors draft persistence). Fire-and-forget on [viewModelScope]. */
1273− private fun persistAttachments() {
1274− val snapshot = _attachments.value
1275− viewModelScope.launch {
1276− runCatchingCancellable { container.attachmentDraftStore.save(sessionId, snapshot.map { it.toPersisted() }) }
1277− .onFailure { Log.w("ChatViewModel", "Failed to persist attachments", it) }
1278− }
1279− }
1280−
1281− /** Revert the conversation to just before [messageId], hiding everything after it. The
1282− * authoritative state also arrives via SessionUpdated; we flag it here for immediate
1283− * feedback. */
1284− fun revertTo(messageId: String) {
1285− val conn = connection ?: return
1286− viewModelScope.launch {
1287− runCatchingCancellable { conn.api.revert(sessionId, messageId) }
1288− .onSuccess {
1289− _reverted.value = it.isReverted
1290− _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1291− }
1292− .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1293− }
1294− }
1295−
1296− /**
1297− * Regenerate an assistant reply: revert the conversation to that assistant message (removing
1298− * it and everything after), then re-send the user prompt that immediately preceded it. A
1299− * one-tap "try again" mirroring ChatGPT/Claude, instead of the two-step revert + re-Send.
1300− *
1301− * Captures the preceding user text *before* the revert (the messages list updates
1302− * asynchronously via SSE once the revert lands). No-op if there's no preceding user prompt
1303− * or the assistant message isn't found.
1304− */
1305− fun regenerate(assistantMessageId: String) {
1306− val conn = connection ?: return
1307− val msgs = messages.value
1308− val idx = msgs.indexOfFirst { it.info.id == assistantMessageId }
1309− if (idx < 0) return
1310− val precedingUserText = (idx - 1 downTo 0)
1311− .asSequence()
1312− .map { msgs[it] }
1313− .firstOrNull { it.info is UserMessage }
1314− ?.parts
1315− ?.filterIsInstance<TextPart>()
1316− ?.joinToString("\n\n") { it.text }
1317− ?.takeIf { it.isNotBlank() }
1318− ?: return
1319− viewModelScope.launch {
1320− runCatchingCancellable { conn.api.revert(sessionId, assistantMessageId) }
1321− .onSuccess { send(precedingUserText, includeAttachments = false) }
1322− .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1323− }
1324− }
1325−
1326− /** Load a previously-sent user prompt back into the composer to edit and re-send.
1327− * Reverts the conversation to just before [messageId] (hiding it and everything after)
1328− * and prefills the draft with [text]. The next Send continues from that checkpoint,
1329− * effectively replacing the edited message; the reverted banner's Undo restores it.
1330− * Overwrites the current draft — this is an explicit edit action on that message. */
1331− fun editMessage(messageId: String, text: String) {
1332− revertTo(messageId)
1333− _draft.value = text.take(NetworkConfig.maxDraftLengthChars)
1334− }
1335−
1336− /** Prefill the composer with [text] as a Markdown blockquote so the user can respond to a
1337− * specific message inline. Appends to any existing draft (with a blank line) rather than
1338− * overwriting, so quoting doesn't discard text already being typed. */
1339− fun quoteReply(text: String) {
1340− val quoted = text.trim().lineSequence().joinToString("\n") { "> $it" }
1341− if (quoted.isBlank()) return
1342− val current = _draft.value
1343− val combined = if (current.isBlank()) "$quoted\n\n" else "$current\n\n$quoted\n\n"
1344− // Cap like the composer's typed/pasted/dictated input so quoting onto a near-full draft
1345− // can't push it past the limit (negative remaining counter + an over-cap prompt sent).
1346− _draft.value = combined.take(NetworkConfig.maxDraftLengthChars)
1347− }
1348−
1349− /** Fork the conversation by creating a brand-new session seeded with [text] as its first
1350− * prompt (using the currently-selected model/agent). Emits the new session id via
1351− * [branchEvents] so the UI can navigate to it. There's no server-side "branch" endpoint,
1352− * so this starts a fresh session from the chosen prompt rather than copying history. */
1353− fun branchFrom(text: String) {
1354− val conn = connection ?: return
1355− val prompt = text.trim()
1356− if (prompt.isEmpty()) return
1357− viewModelScope.launch {
1358− runCatchingCancellable {
1359− val session = conn.repository.createSession(title = null)
1360− conn.repository.sendPrompt(
1361− session.id,
1362− prompt,
1363− model = _selectedModel.value?.ref,
1364− agent = _selectedAgent.value,
1365− )
1366− session.id
1367− }
1368− .onSuccess { newId -> _branchEvents.trySend(newId) }
1369− .onFailure { _errorEvents.trySend(ChatError(container.string(R.string.branch_failed))) }
1370− }
1371− }
1372−
1373− /** Undo the active revert checkpoint, restoring the hidden messages. */
1374− fun unrevert() {
1375− val conn = connection ?: return
1376− viewModelScope.launch {
1377− runCatchingCancellable { conn.api.unrevert(sessionId) }
1378− .onSuccess {
1379− _reverted.value = it.isReverted
1380− _revertDiff.value = it.revert?.diff?.takeIf { diff -> diff.isNotBlank() }
1381− }
1382− .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1383− }
1384− }
1385−
1386− /** Create (or fetch the existing) public share link. The URL is exposed via [shareUrl]
1387− * and also emitted once via [shareLinkEvents] so the UI can copy it to the clipboard. */
1388− fun shareSession() {
1389− val conn = connection ?: return
1390− viewModelScope.launch {
1391− runCatchingCancellable { conn.api.shareSession(sessionId) }
1392− .onSuccess { session ->
1393− val url = session.share?.url?.takeIf { it.isNotBlank() }
1394− _shareUrl.value = url
1395− if (url != null) _shareLinkEvents.trySend(url)
1396− }
1397− .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1398− }
1399− }
1400−
1401− /** Revoke the session's public share link. */
1402− fun unshareSession() {
1403− val conn = connection ?: return
1404− viewModelScope.launch {
1405− runCatchingCancellable { conn.api.unshareSession(sessionId) }
1406− .onSuccess { _shareUrl.value = it.share?.url?.takeIf { it.isNotBlank() } }
1407− .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1408− }
1409− }
1410−
1411− /** Compact the conversation via the summarize endpoint; the summary streams back via SSE.
1412− * Uses the currently-selected model (required by the endpoint). */
1413− fun summarize() {
1414− val conn = connection ?: return
1415− val model = _selectedModel.value?.ref ?: run {
1416− _errorEvents.trySend(ChatError(container.string(R.string.needs_model)))
1417− return
1418− }
1419− if (!_running.compareAndSet(false, true)) {
1420− _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1421− return
1422− }
1423− // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1424− // reconnect keeps the working indicator lit instead of leaving it stuck off.
1425− runEndedByIdle = false
1426− viewModelScope.launch {
1427− runCatchingCancellable { conn.api.summarize(sessionId, model) }
1428− .onFailure {
1429− _errorEvents.trySend(ChatError(container.friendlyError(it)))
1430− _running.value = false
1431− }
1432− }
1433− }
1434−
1435− /** Analyze the project and (re)generate its AGENTS.md; the run streams back via SSE. */
1436− fun initProject() {
1437− val conn = connection ?: return
1438− if (!_running.compareAndSet(false, true)) {
1439− _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1440− return
1441− }
1442− // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1443− // reconnect keeps the working indicator lit instead of leaving it stuck off.
1444− runEndedByIdle = false
1445− viewModelScope.launch {
1446− runCatchingCancellable { conn.api.initSession(sessionId) }
1447− .onFailure {
1448− _errorEvents.trySend(ChatError(container.friendlyError(it)))
1449− _running.value = false
1450− }
1451− }
1452− }
1453−
1454− /** Run a one-off shell [command] in the session's worktree; output streams back via SSE.
1455− * The server requires an agent to scope the run — use the selected one, else the primary. */
1456− fun runShell(command: String) {
1457− val conn = connection ?: return
1458− val cmd = command.trim()
1459− if (cmd.isEmpty()) return
1460− val agent = _selectedAgent.value
1461− ?: _agents.value.firstOrNull { it.isPrimary }?.name
1462− ?: _agents.value.firstOrNull()?.name
1463− ?: DEFAULT_SHELL_AGENT
1464− if (!_running.compareAndSet(false, true)) {
1465− _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1466− return
1467− }
1468− // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1469− // reconnect keeps the working indicator lit instead of leaving it stuck off.
1470− runEndedByIdle = false
1471− viewModelScope.launch {
1472− runCatchingCancellable { conn.api.shell(sessionId, cmd, agent, _selectedModel.value?.ref) }
1473− .onFailure {
1474− _errorEvents.trySend(ChatError(container.friendlyError(it)))
1475− _running.value = false
1476− }
1477− }
1478− }
1479−
1480− /** Rename the current session via PATCH /session/:id. On success updates [sessionTitle]
1481− * so the top bar reflects the new name immediately. A failure surfaces as a snackbar;
1482− * the caller keeps the dialog open so the user can retry without retyping. */
1483− fun renameSession(newTitle: String) {
1484− val conn = connection ?: return
1485− val title = newTitle.trim()
1486− if (title.isEmpty() || title == _sessionTitle.value) return
1487− viewModelScope.launch {
1488− runCatchingCancellable { conn.api.updateSession(sessionId, title) }
1489− .onSuccess { _sessionTitle.value = it.displayTitle }
1490− .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1491− }
1492− }
1493−
1494− /** Delete the current session via DELETE /session/:id. Schedules a deferred delete with an
1495− * undo window and signals [deleteUndoEvents] so the ChatScreen shows an Undo snackbar
1496− * *over the conversation the user is viewing* — they can cancel without first being
1497− * navigated back to the session list. The actual REST delete runs after
1498− * [NetworkConfig.undoDeleteDelayMs]; cancel via [cancelSessionDelete]. If the window
1499− * expires, the scheduled delete's onDeleted sets [sessionDeleted] so the UI navigates away.
1500− * A failure surfaces as a snackbar. */
1501− fun deleteSession() {
1502− val conn = connection ?: return
1503− container.scheduleSessionDelete(
1504− id = sessionId,
1505− delayMs = NetworkConfig.undoDeleteDelayMs,
1506− // Navigate away only once the deferred delete actually commits — so an in-place Undo
1507− // before the window expires keeps the user on this conversation.
1508− onDeleted = { _sessionDeleted.value = true },
1509− onError = { _errorEvents.trySend(ChatError(container.friendlyError(it))) },
1510− )
1511− _deleteUndoEvents.tryEmit(Unit)
1512− }
1513−
1514− /** Cancel a delete scheduled by [deleteSession] (the in-chat Undo action). Returns true if the
1515− * delete was still pending (Undo succeeded). */
1516− fun cancelSessionDelete(): Boolean = container.cancelSessionDelete(sessionId)
1517−
1518− /** Reconnect to the most recently used server profile (used when the connection is gone). */
1519− fun reconnect() {
1520− if (container.activeConnection.value != null) return
1521− // Guard against concurrent reconnect calls: the user can tap the button
1522− // multiple times while _reconnecting is true, which would launch parallel
1523− // connect() coroutines that race to set the active connection, leaking the
1524− // intermediate connections.
1525− if (!_reconnecting.compareAndSet(false, true)) return
1526− viewModelScope.launch {
1527− try {
1528− val recent = runCatchingCancellable { container.profileStore.profiles.first() }
1529− .getOrDefault(emptyList())
1530− .firstOrNull()
1531− if (recent == null) {
1532− _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
1533− return@launch
1534− }
1535− runCatchingCancellable {
1536− val conn = container.connect(recent)
1537− conn.api.ping()
1538− }.onFailure {
1539− container.disconnect()
1540− _errorEvents.trySend(ChatError(container.friendlyError(it)))
1541− }
1542− } finally {
1543− _reconnecting.value = false
1544− }
1545− }
1546− }
1547−
1548− fun respondPermission(permission: Permission, response: PermissionResponse) {
1549− val conn = connection ?: return
1550− // Record a session-scoped grant so subsequent matching requests are auto-answered.
1551− // SESSION maps to ONCE on the wire (the server has no session-scope concept); the
1552− // client-side set is what gives it the "for this session" semantics.
1553− if (response == PermissionResponse.SESSION) {
1554− val type = permission.type.orEmpty()
1555− val pattern = permission.patternText.orEmpty()
1556− if (type.isNotEmpty()) sessionAllowed.add(type to pattern)
1557− }
1558− // Dismiss optimistically (revealing any queued request); permission.replied will confirm.
1559− // If the call fails we re-surface this request so the user can retry instead of being stuck
1560− // with a dismissed dialog and a tool run that's still paused server-side.
1561− resolvePermission(permission.id)
1562− viewModelScope.launch {
1563− runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
1564− .onFailure {
1565− _errorEvents.trySend(ChatError(container.friendlyError(it)))
1566− enqueuePermission(permission)
1567− }
1568− }
1569− }
1570−
1571− private companion object {
1572− /** Fallback agent for the shell endpoint when none is selected and no agent catalog
1573− * loaded. opencode's default primary agent is "build". */
1574− const val DEFAULT_SHELL_AGENT = "build"
1575− }
1576−
1577− override fun onCleared() {
1578− super.onCleared()
1579− // Flush any pending debounced draft so it survives navigation. Uses an
1580− // asynchronous apply() (not a synchronous commit) so the main thread isn't
1581− // blocked on disk I/O — Android's SharedPreferences framework guarantees
1582− // pending apply() writes are flushed before the process exits.
1583− // Flush the pending draft — including an empty one. Persistence is otherwise debounced,
1584− // so if the user cleared the input and navigated back within the debounce window, the
1585− // debounce coroutine was cancelled with viewModelScope and the prefs still hold the
1586− // previous non-empty draft. flushDraft() removes the key when the text is blank, so
1587− // flushing commits the clear instead of resurrecting the deleted text.
1588− //
1589− // BUT respect the send-in-flight guard: send() optimistically clears _draft to "" WITHOUT
1590− // persisting the clear so a failed send can restore the text. If the user taps Send then
1591− // immediately navigates back (cancelling the in-flight send), flushing the blank _draft here
1592− // would remove the persisted key and erase the pre-send draft with no recovery. Skip the
1593− // flush while a send is in flight so the pre-send draft stays persisted (recoverable).
1594− if (!suppressDraftPersist.get()) container.draftStore.flushDraft(sessionId, _draft.value)
1595− }
1596−}
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.ktdeleted−223
@@ -1,223 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.compose.foundation.clickable
4−import androidx.compose.foundation.layout.Column
5−import androidx.compose.foundation.layout.fillMaxWidth
6−import androidx.compose.foundation.layout.heightIn
7−import androidx.compose.foundation.layout.padding
8−import androidx.compose.foundation.lazy.LazyColumn
9−import androidx.compose.foundation.lazy.items
10−import androidx.compose.foundation.text.KeyboardActions
11−import androidx.compose.foundation.text.KeyboardOptions
12−import androidx.compose.material.icons.Icons
13−import androidx.compose.material.icons.automirrored.filled.PlaylistPlay
14−import androidx.compose.material.icons.filled.Close
15−import androidx.compose.material.icons.filled.Search
16−import androidx.compose.material3.CircularProgressIndicator
17−import androidx.compose.material3.ExperimentalMaterial3Api
18−import androidx.compose.material3.Icon
19−import androidx.compose.material3.IconButton
20−import androidx.compose.material3.MaterialTheme
21−import androidx.compose.material3.ModalBottomSheet
22−import androidx.compose.material3.OutlinedTextField
23−import androidx.compose.material3.Text
24−import androidx.compose.material3.AlertDialog
25−import androidx.compose.material3.TextButton
26−import androidx.compose.runtime.Composable
27−import androidx.compose.runtime.LaunchedEffect
28−import androidx.compose.runtime.getValue
29−import androidx.compose.runtime.mutableStateOf
30−import androidx.compose.runtime.remember
31−import androidx.compose.runtime.saveable.rememberSaveable
32−import androidx.compose.runtime.setValue
33−import androidx.compose.ui.Alignment
34−import androidx.compose.ui.Modifier
35−import androidx.compose.ui.focus.FocusRequester
36−import androidx.compose.ui.focus.focusRequester
37−import androidx.compose.ui.hapticfeedback.HapticFeedbackType
38−import androidx.compose.ui.platform.LocalHapticFeedback
39−import androidx.compose.ui.platform.LocalSoftwareKeyboardController
40−import androidx.compose.ui.res.stringResource
41−import androidx.compose.ui.semantics.Role
42−import androidx.compose.ui.semantics.contentDescription
43−import androidx.compose.ui.semantics.semantics
44−import androidx.compose.ui.text.input.ImeAction
45−import androidx.compose.ui.text.style.TextOverflow
46−import androidx.compose.ui.unit.dp
47−import androidx.compose.foundation.layout.Box
48−import androidx.compose.foundation.layout.fillMaxHeight
49−import androidx.compose.ui.platform.LocalConfiguration
50−import soy.iko.opencode.data.model.Command
51−import soy.iko.opencode.data.network.NetworkConfig
52−import soy.iko.opencode.R
53−
54−/** Bottom sheet that lists slash-commands; selecting one invokes it. */
55−@OptIn(ExperimentalMaterial3Api::class)
56−@Composable
57−fun CommandPickerSheet(
58− commands: List<Command>,
59− loading: Boolean,
60− error: Boolean,
61− onSelect: (Command) -> Unit,
62− onRetry: () -> Unit,
63− onDismiss: () -> Unit,
64−) {
65− // Persist only the command *name* (a savable String), not the Command itself: a
66− // kotlinx @Serializable Command is neither Parcelable nor java.io.Serializable, so
67− // routing it through rememberSaveable crashes on rotation while the confirm dialog
68− // is open. Re-resolve the full Command from the current list for rendering.
69− var pendingCommandName by rememberSaveable { mutableStateOf<String?>(null) }
70− val pendingCommand = pendingCommandName?.let { name -> commands.firstOrNull { it.name == name } }
71− ModalBottomSheet(onDismissRequest = onDismiss) {
72− Text(
73− stringResource(R.string.commands),
74− style = MaterialTheme.typography.titleMedium,
75− modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
76− )
77− if (loading) {
78− val loadingLabel = stringResource(R.string.loading)
79− Box(
80− modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
81− contentAlignment = Alignment.Center,
82− ) {
83− CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
84− }
85− } else if (error && commands.isEmpty()) {
86− Column(
87− modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
88− horizontalAlignment = Alignment.CenterHorizontally,
89− ) {
90− Text(
91− stringResource(R.string.load_failed),
92− style = MaterialTheme.typography.bodyMedium,
93− color = MaterialTheme.colorScheme.error,
94− )
95− TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
96− }
97− } else if (commands.isEmpty()) {
98− Text(
99− stringResource(R.string.no_commands),
100− style = MaterialTheme.typography.bodyMedium,
101− color = MaterialTheme.colorScheme.onSurfaceVariant,
102− modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
103− )
104− } else {
105− var query by rememberSaveable { mutableStateOf("") }
106− val keyboardController = LocalSoftwareKeyboardController.current
107− val haptics = LocalHapticFeedback.current
108− // Auto-focus the search field (and raise the keyboard) when the sheet opens, but skip
109− // it for a short list that's faster to scan by eye than to filter.
110− val searchFocus = remember { FocusRequester() }
111− LaunchedEffect(Unit) {
112− if (commands.size > NetworkConfig.pickerSearchAutofocusThreshold) {
113− runCatching { searchFocus.requestFocus() }
114− }
115− }
116− OutlinedTextField(
117− value = query,
118− onValueChange = { query = it },
119− modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp).focusRequester(searchFocus),
120− placeholder = { Text(stringResource(R.string.search_commands)) },
121− leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
122− trailingIcon = if (query.isNotEmpty()) {
123− {
124− IconButton(onClick = { query = "" }) {
125− Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
126− }
127− }
128− } else null,
129− singleLine = true,
130− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
131− keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
132− )
133− val filtered = remember(commands, query) {
134− val q = query.trim()
135− if (q.isEmpty()) commands
136− else commands.filter {
137− it.name.contains(q, ignoreCase = true) ||
138− it.displayDescription.contains(q, ignoreCase = true)
139− }
140− }
141− if (filtered.isEmpty()) {
142− Text(
143− stringResource(R.string.no_commands_match, query.trim()),
144− style = MaterialTheme.typography.bodyMedium,
145− color = MaterialTheme.colorScheme.onSurfaceVariant,
146− modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
147− )
148− } else {
149− // Responsive height: half the viewport rather than a hardcoded 420dp cap.
150− LazyColumn(modifier = Modifier.fillMaxHeight(0.5f)) {
151− // distinctBy the name so duplicate command names from the server can't
152− // crash the list with LazyColumn's "Key was already used".
153− items(filtered.distinctBy { it.name }, key = { it.name }) { cmd ->
154− Column(
155− modifier = Modifier
156− .fillMaxWidth()
157− .clickable(role = Role.Button) {
158− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
159− if (isDestructiveCommand(cmd.name)) {
160− pendingCommandName = cmd.name
161− } else {
162− onSelect(cmd)
163− onDismiss()
164− }
165− }
166− .padding(horizontal = 24.dp, vertical = 12.dp),
167− ) {
168− androidx.compose.foundation.layout.Row(
169− verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
170− ) {
171− Icon(Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = null)
172− Text(
173− "/${cmd.name}",
174− style = MaterialTheme.typography.bodyLarge,
175− modifier = Modifier.padding(start = 8.dp),
176− )
177− }
178− Text(
179− cmd.displayDescription,
180− style = MaterialTheme.typography.bodySmall,
181− color = MaterialTheme.colorScheme.onSurfaceVariant,
182− maxLines = 2,
183− overflow = TextOverflow.Ellipsis,
184− modifier = Modifier.padding(start = 28.dp, top = 2.dp),
185− )
186− }
187− }
188− }
189− }
190− }
191− }
192−
193− // Confirmation for commands whose name suggests they may discard work or reset state,
194− // so a tap doesn't irrevocably run e.g. /clear or /compact without consent.
195− pendingCommand?.let { cmd ->
196− AlertDialog(
197− onDismissRequest = { pendingCommandName = null },
198− title = { Text(stringResource(R.string.run_command_title, cmd.name)) },
199− text = { Text(stringResource(R.string.run_command_text, "/${cmd.name}")) },
200− confirmButton = {
201− TextButton(onClick = {
202− pendingCommandName = null
203− onSelect(cmd)
204− onDismiss()
205− }) { Text(stringResource(R.string.run)) }
206− },
207− dismissButton = {
208− TextButton(onClick = { pendingCommandName = null }) { Text(stringResource(R.string.cancel)) }
209− },
210− )
211− }
212−}
213−
214−/**
215− * Heuristic for commands that may be destructive (discard work / reset state) and so
216− * warrant a confirmation step before invocation. Matches common opencode slash-command
217− * names; non-matching commands invoke immediately.
218− */
219−private fun isDestructiveCommand(name: String): Boolean {
220− val n = name.lowercase()
221− return n in setOf("clear", "compact", "delete", "reset", "remove", "wipe", "drop", "purge") ||
222− n.startsWith("clear") || n.startsWith("compact") || n.startsWith("reset")
223−}
app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.ktdeleted−203
@@ -1,203 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import soy.iko.opencode.data.model.AssistantMessage
4−import soy.iko.opencode.data.model.FilePart
5−import soy.iko.opencode.data.model.MessageWithParts
6−import soy.iko.opencode.data.model.ReasoningPart
7−import soy.iko.opencode.data.model.TextPart
8−import soy.iko.opencode.data.model.ToolCompleted
9−import soy.iko.opencode.data.model.ToolError
10−import soy.iko.opencode.data.model.ToolPart
11−import soy.iko.opencode.data.model.ToolRunning
12−import soy.iko.opencode.data.model.UserMessage
13−import soy.iko.opencode.data.model.sourcePath
14−import java.text.SimpleDateFormat
15−import java.util.Date
16−import java.util.Locale
17−
18−/**
19− * Render a conversation as Markdown for sharing/exporting. Each message becomes a
20− * role-prefixed section containing its text parts; reasoning is exported as a blockquote
21− * and tool calls are summarized so the transcript shows what the agent *did*, not just
22− * what it said. Pure (no Android deps) so it can be unit-tested directly.
23− */
24−fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?): String {
25− val sb = StringBuilder(messages.size * 128)
26− if (!title.isNullOrBlank()) {
27− sb.append("# ").append(escapeMarkdown(title.trim())).append("\n\n")
28− }
29− appendMetadataHeader(sb, messages)
30− for (message in messages) {
31− val heading = when (message.info) {
32− is UserMessage -> "## You"
33− is AssistantMessage -> "## opencode"
34− else -> null
35− } ?: continue
36− // The assistant's reply text is already Markdown (code fences, bold, headings);
37− // escaping it would render the export as backslash noise. Only escape user-authored
38− // text, which isn't expected to be Markdown.
39− val escapeText = message.info is UserMessage
40− val body = message.parts
41− .mapNotNull { part ->
42− when (part) {
43− is TextPart -> part.text.takeIf { it.isNotBlank() }
44− ?.let { if (escapeText) escapeMarkdown(it) else it }
45− is ReasoningPart -> part.text.takeIf { it.isNotBlank() }
46− ?.let { text -> text.trim().lines().joinToString("\n") { "> _${escapeMarkdown(it)}_" } }
47− is ToolPart -> formatToolCall(part)
48− is FilePart -> {
49− // Image/file attachments aren't portable in a text export; emit a
50− // labeled placeholder so the transcript records that one was present
51− // (useful for debugging) instead of silently dropping it.
52− val name = part.sourcePath ?: part.url ?: part.filename ?: "attachment"
53− if (part.mime?.startsWith("image/") == true) "_[image: ${escapeMarkdown(name)}]_"
54− else "_[file: ${escapeMarkdown(name)}]_"
55− }
56− else -> null
57− }
58− }
59− .joinToString("\n\n")
60− .trim()
61− if (body.isEmpty()) continue
62− sb.append(heading).append("\n\n")
63− // Per-message timestamp (ISO, local zone) so an exported transcript is useful for
64− // support/debugging — without it only the metadata header's global range has timing.
65− message.info.time?.created?.let { ts -> sb.append("_").append(formatExportTimestamp(ts)).append("_\n\n") }
66− sb.append(body).append("\n\n")
67− }
68− return sb.toString().trimEnd()
69−}
70−
71−/**
72− * Render a single message as Markdown for per-message sharing. A trimmed-down version of
73− * [buildConversationMarkdown] that emits just this message's role heading + body (no metadata
74− * header), so a shared single reply/exchange is self-contained without dragging the whole
75− * transcript. Returns null when the message has no exportable body (e.g. an image-only prompt).
76− */
77−fun buildMessageMarkdown(message: MessageWithParts): String? {
78− val heading = when (message.info) {
79− is UserMessage -> "## You"
80− is AssistantMessage -> "## opencode"
81− else -> return null
82− }
83− val escapeText = message.info is UserMessage
84− val body = message.parts
85− .mapNotNull { part ->
86− when (part) {
87− is TextPart -> part.text.takeIf { it.isNotBlank() }
88− ?.let { if (escapeText) escapeMarkdown(it) else it }
89− is ReasoningPart -> part.text.takeIf { it.isNotBlank() }
90− ?.let { text -> text.trim().lines().joinToString("\n") { "> _${escapeMarkdown(it)}_" } }
91− is ToolPart -> formatToolCall(part)
92− is FilePart -> {
93− val name = part.sourcePath ?: part.url ?: part.filename ?: "attachment"
94− if (part.mime?.startsWith("image/") == true) "_[image: ${escapeMarkdown(name)}]_"
95− else "_[file: ${escapeMarkdown(name)}]_"
96− }
97− else -> null
98− }
99− }
100− .joinToString("\n\n")
101− .trim()
102− if (body.isEmpty()) return null
103− return buildString {
104− append(heading).append("\n\n")
105− message.info.time?.created?.let { ts -> append("_").append(formatExportTimestamp(ts)).append("_\n\n") }
106− append(body)
107− }
108−}
109−
110−/** Format an epoch-millis as a locale-stable ISO-ish timestamp for the export. Thread-confined
111− * to the exporting coroutine, so a shared SimpleDateFormat (not thread-safe) is fine. */
112−private fun formatExportTimestamp(epochMillis: Long): String =
113− SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(epochMillis))
114−
115−/** Append a metadata header (message counts + timestamp range) so a shared transcript has
116− * context for support/debugging. Skipped when there are no messages. */
117−private fun appendMetadataHeader(sb: StringBuilder, messages: List<MessageWithParts>) {
118− if (messages.isEmpty()) return
119− val userCount = messages.count { it.info is UserMessage }
120− val assistantCount = messages.count { it.info is AssistantMessage }
121− val firstTime = messages.firstOrNull()?.info?.time?.created
122− val lastTime = messages.lastOrNull()?.info?.time?.created
123− sb.append("> ")
124− sb.append("$userCount user · $assistantCount assistant message")
125− if (userCount != 1) sb.append("s")
126− if (firstTime != null && lastTime != null) {
127− sb.append(" · $firstTime → $lastTime")
128− }
129− sb.append("\n\n---\n\n")
130−}
131−
132−/** Render a tool call as a compact, readable blockquote summary: the tool name, its
133− * human-readable title (if any), and a truncated output/error. Keeps the exported
134− * transcript useful when the agent's work (edits, command runs) is the substance. */
135−private fun formatToolCall(part: ToolPart): String? {
136− val title = when (val s = part.state) {
137− is ToolRunning -> s.title
138− is ToolCompleted -> s.title
139− else -> null
140− }
141− val detail = when (val s = part.state) {
142− is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }?.let { truncateOutput(it) }
143− is ToolError -> s.error?.takeIf { it.isNotBlank() }?.let { "Error: ${truncateOutput(it)}" }
144− else -> null
145− }
146− val status = when (part.state) {
147− is ToolCompleted -> null
148− is ToolError -> " — error"
149− is ToolRunning -> " — running"
150− else -> null
151− }
152− val head = "**${part.tool}**${status ?: ""}"
153− val titleLine = title?.takeIf { it.isNotBlank() }?.let { escapeMarkdown(it) }
154− return buildList {
155− add("> $head")
156− if (titleLine != null) add("> $titleLine")
157− // Prefix every detail line with "> " so the fenced code block stays inside the
158− // blockquote — otherwise lines after the first fall out of the quote.
159− if (detail != null) {
160− val quoted = detail.lines().joinToString("\n") { "> $it" }
161− // Use a fence longer than the longest backtick run in the content, so tool output
162− // that itself contains a ``` sequence (shell output echoing markdown, etc.) can't
163− // prematurely close the block and make the rest of the transcript render broken.
164− val fence = "`".repeat(maxOf(3, longestBacktickRun(detail) + 1))
165− add(">\n> $fence\n$quoted\n> $fence")
166− }
167− }.joinToString("\n").takeIf { it.isNotBlank() }
168−}
169−
170−/** Length of the longest consecutive run of backticks in [text] (0 if none). */
171−private fun longestBacktickRun(text: String): Int {
172− var max = 0
173− var current = 0
174− for (c in text) {
175− if (c == '`') {
176− current++
177− if (current > max) max = current
178− } else {
179− current = 0
180− }
181− }
182− return max
183−}
184−
185−private fun truncateOutput(output: String): String {
186− if (output.length <= TOOL_OUTPUT_LIMIT) return output
187− return output.take(TOOL_OUTPUT_LIMIT) + "\n… (truncated)"
188−}
189−
190−private const val TOOL_OUTPUT_LIMIT = 2000
191−
192−/** Escape markdown special characters so user/model text doesn't produce malformed markdown. */
193−private fun escapeMarkdown(text: String): String {
194− if (text.isEmpty()) return text
195− val sb = StringBuilder(text.length + 16)
196− for (c in text) {
197− when (c) {
198− '\\', '*', '_', '#', '`', '>' -> sb.append('\\')
199− }
200− sb.append(c)
201− }
202− return sb.toString()
203−}
app/src/main/java/soy/iko/opencode/ui/chat/CostFormat.ktdeleted−60
@@ -1,60 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import soy.iko.opencode.data.model.Tokens
4−
5−// NumberFormat.getNumberInstance performs an expensive ICU locale lookup + object
6−// construction on every call. Reuse a thread-local instance so repeated calls (e.g.
7−// when the message list re-seeds after a reconnect and every visible assistant bubble
8−// recomposes at once) don't each pay that cost. Thread-local because NumberFormat is
9−// not thread-safe.
10−internal val tokenNumberFormat: ThreadLocal<java.text.NumberFormat> = ThreadLocal.withInitial {
11− java.text.NumberFormat.getNumberInstance(java.util.Locale.US)
12−}
13−
14−internal fun formatTokens(tokens: Tokens, format: String): String {
15− val nf = tokenNumberFormat.get()!!
16− return format.format(nf.format(tokens.input), nf.format(tokens.output))
17−}
18−
19−internal fun formatCost(cost: Double, shortFormat: String, longFormat: String): String =
20− // Locale.US so the formatting is stable regardless of device locale (avoids
21− // non-ASCII digits or comma decimal separators in a dollar amount). The format
22− // string itself is localized via strings.xml so the currency symbol can be
23− // adapted by translators.
24− if (cost < 0.01) String.format(java.util.Locale.US, longFormat, cost)
25− else String.format(java.util.Locale.US, shortFormat, cost)
26−
27−/** Assemble the single-line cost/tokens summary for an assistant bubble. Returns null when the
28− * message isn't complete, when neither cost nor tokens were reported, or when every figure is
29− * zero (so the bubble doesn't show a meaningless "0 in · 0 out • $0.0000"). Extracted from
30− * [AssistantBlock] to keep that function's cyclomatic complexity under the detekt threshold.
31− * Reasoning and cache segments are added only when non-zero. */
32−internal fun buildCostSummary(
33− isComplete: Boolean,
34− tokens: Tokens?,
35− cost: Double?,
36− tokenFormat: String,
37− reasoningFormat: String,
38− cacheReadFormat: String,
39− cacheWriteFormat: String,
40− costShort: String,
41− costLong: String,
42−): String? {
43− if (!isComplete || (cost == null && tokens == null)) return null
44− val nf = tokenNumberFormat.get()!!
45− return buildList {
46− // Skip an all-zero token count (e.g. a completed message that reported no usage) so
47− // the bubble doesn't show a meaningless "0 in · 0 out".
48− tokens?.takeIf { it.input > 0 || it.output > 0 }?.let { add(formatTokens(it, tokenFormat)) }
49− tokens?.takeIf { it.reasoning > 0 }?.let {
50− add(reasoningFormat.format(nf.format(it.reasoning)))
51− }
52− tokens?.cache?.takeIf { it.read > 0 }?.let {
53− add(cacheReadFormat.format(nf.format(it.read)))
54− }
55− tokens?.cache?.takeIf { it.write > 0 }?.let {
56− add(cacheWriteFormat.format(nf.format(it.write)))
57− }
58− cost?.takeIf { it > 0 }?.let { add(formatCost(it, costShort, costLong)) }
59− }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
60−}
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.ktdeleted−888
@@ -1,888 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.compose.foundation.background
4−import androidx.compose.foundation.clickable
5−import androidx.compose.foundation.gestures.detectTapGestures
6−import androidx.compose.foundation.layout.Arrangement
7−import androidx.compose.foundation.layout.Box
8−import androidx.compose.foundation.layout.BoxWithConstraints
9−import androidx.compose.foundation.layout.Column
10−import androidx.compose.foundation.layout.Row
11−import androidx.compose.foundation.layout.fillMaxWidth
12−import androidx.compose.foundation.layout.padding
13−import androidx.compose.foundation.layout.size
14−import androidx.compose.foundation.layout.widthIn
15−import androidx.compose.material.icons.Icons
16−import androidx.compose.material.icons.automirrored.filled.HelpOutline
17−import androidx.compose.material.icons.automirrored.filled.PlaylistAdd
18−import androidx.compose.material.icons.automirrored.filled.VolumeUp
19−import androidx.compose.material.icons.filled.Close
20−import androidx.compose.material.icons.filled.ContentCopy
21−import androidx.compose.material.icons.filled.Edit
22−import androidx.compose.material.icons.filled.Error
23−import androidx.compose.material.icons.filled.FormatQuote
24−import androidx.compose.material.icons.filled.MoreVert
25−import androidx.compose.material.icons.filled.Pause
26−import androidx.compose.material.icons.filled.PlayArrow
27−import androidx.compose.material.icons.filled.Refresh
28−import androidx.compose.material.icons.filled.Restore
29−import androidx.compose.material.icons.filled.Share
30−import androidx.compose.material.icons.filled.SmartToy
31−import androidx.compose.material3.DropdownMenu
32−import androidx.compose.material3.DropdownMenuItem
33−import androidx.compose.material3.CircularProgressIndicator
34−import androidx.compose.material3.Icon
35−import androidx.compose.material3.IconButton
36−import androidx.compose.material3.MaterialTheme
37−import androidx.compose.material3.ripple
38−import androidx.compose.material3.Text
39−import androidx.compose.runtime.Composable
40−import androidx.compose.runtime.getValue
41−import androidx.compose.runtime.key
42−import androidx.compose.runtime.mutableStateOf
43−import androidx.compose.runtime.remember
44−import androidx.compose.runtime.setValue
45−import androidx.compose.ui.Alignment
46−import androidx.compose.ui.Modifier
47−import androidx.compose.ui.draw.clip
48−import androidx.compose.ui.hapticfeedback.HapticFeedbackType
49−import androidx.compose.ui.input.pointer.pointerInput
50−import androidx.compose.ui.platform.LocalContext
51−import androidx.compose.ui.platform.LocalHapticFeedback
52−import androidx.compose.ui.res.stringResource
53−import androidx.compose.ui.semantics.Role
54−import androidx.compose.ui.semantics.contentDescription
55−import androidx.compose.ui.semantics.semantics
56−import androidx.compose.ui.text.style.TextOverflow
57−import androidx.compose.ui.unit.dp
58−import androidx.compose.foundation.interaction.MutableInteractionSource
59−import soy.iko.opencode.R
60−import soy.iko.opencode.data.model.AssistantMessage
61−import soy.iko.opencode.data.model.MessageWithParts
62−import soy.iko.opencode.data.model.ModelOption
63−import soy.iko.opencode.data.model.FilePart
64−import soy.iko.opencode.data.model.TextPart
65−import soy.iko.opencode.data.model.UnknownMessage
66−import soy.iko.opencode.data.model.UserMessage
67−import soy.iko.opencode.data.network.NetworkConfig
68−import soy.iko.opencode.ui.components.ImageLoadContext
69−import soy.iko.opencode.ui.components.copyToClipboard
70−import soy.iko.opencode.ui.components.showToast
71−import soy.iko.opencode.ui.components.RelativeTimeText
72−
73−/**
74− * Resolve an assistant message's model id to a friendly label from the loaded catalog,
75− * falling back to the raw id (so a model not in the catalog still shows something
76− * meaningful instead of being hidden). Returns null only when the message has no model.
77− */
78−fun resolveModelLabel(info: AssistantMessage, models: List<ModelOption>): String? {
79− val id = info.modelID ?: return null
80− if (models.isEmpty()) return id
81− val byPair = models.firstOrNull { it.providerID == info.providerID && it.modelID == id }
82− return byPair?.modelLabel
83− ?: models.firstOrNull { it.modelID == id }?.modelLabel
84− ?: id
85−}
86−
87−/** Status of an outgoing message, shown as a small indicator on the user bubble. */
88−enum class MessageSendStatus { SENDING, FAILED }
89−
90−/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
91−@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
92−@Composable
93−fun MessageBubble(
94− message: MessageWithParts,
95− modifier: Modifier = Modifier,
96− isRunning: Boolean = false,
97− imageContext: ImageLoadContext? = null,
98− modelLabel: String? = null,
99− onOpenFile: ((String) -> Unit)? = null,
100− onRevert: (() -> Unit)? = null,
101− onEdit: ((String) -> Unit)? = null,
102− onSpeak: ((String) -> Unit)? = null,
103− isSpeaking: Boolean = false,
104− ttsState: TtsState = TtsState.IDLE,
105− onPause: (() -> Unit)? = null,
106− onResume: (() -> Unit)? = null,
107− onQuote: ((String) -> Unit)? = null,
108− onBranch: ((String) -> Unit)? = null,
109− onRegenerate: (() -> Unit)? = null,
110− sendStatus: MessageSendStatus? = null,
111− isEdited: Boolean = false,
112− onRetry: (() -> Unit)? = null,
113− onDismiss: (() -> Unit)? = null,
114− onShare: (() -> Unit)? = null,
115−) {
116− when (message.info) {
117− is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit, onQuote, onBranch, sendStatus, isEdited, onRetry, onDismiss, onShare)
118− is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
119− else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile, onRevert, onSpeak, isSpeaking, ttsState, onPause, onResume, onQuote, onBranch, onRegenerate, isEdited, onShare)
120− }
121−}
122−
123−/** A long-press context menu consolidating all per-message actions (copy, quote, branch,
124− * revert). Surfacing these via long-press matches the conventional Android pattern so the
125− * actions are discoverable without spotting the small 18dp inline icon row. The menu is
126− * anchored to the long-press position via [offset], and only items with non-null callbacks
127− * (and text to act on) are shown. */
128−@Composable
129−private fun MessageLongPressMenu(
130− expanded: Boolean,
131− onDismiss: () -> Unit,
132− offset: androidx.compose.ui.unit.DpOffset,
133− text: String?,
134− onCopy: (() -> Unit)?,
135− onQuote: ((String) -> Unit)?,
136− onBranch: ((String) -> Unit)?,
137− onRevert: (() -> Unit)?,
138− onShare: (() -> Unit)?,
139−) {
140− DropdownMenu(expanded = expanded, onDismissRequest = onDismiss, offset = offset) {
141− if (text != null && onCopy != null) {
142− DropdownMenuItem(
143− leadingIcon = { Icon(Icons.Filled.ContentCopy, contentDescription = null) },
144− text = { Text(stringResource(R.string.copy)) },
145− onClick = { onDismiss(); onCopy() },
146− )
147− }
148− // Per-message share: fire an ACTION_SEND with this message's Markdown, so a user can
149− // forward a single reply (e.g. a code snippet) without exporting the whole transcript.
150− // Gated on text so an image-only prompt doesn't show a no-op Share entry.
151− if (text != null && onShare != null) {
152− DropdownMenuItem(
153− leadingIcon = { Icon(Icons.Filled.Share, contentDescription = null) },
154− text = { Text(stringResource(R.string.share_message)) },
155− onClick = { onDismiss(); onShare() },
156− )
157− }
158− if (text != null && onQuote != null) {
159− DropdownMenuItem(
160− leadingIcon = { Icon(Icons.Filled.FormatQuote, contentDescription = null) },
161− text = { Text(stringResource(R.string.quote_reply)) },
162− onClick = { onDismiss(); onQuote(text) },
163− )
164− }
165− if (text != null && onBranch != null) {
166− DropdownMenuItem(
167− leadingIcon = { Icon(Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = null) },
168− text = { Text(stringResource(R.string.branch_session)) },
169− onClick = { onDismiss(); onBranch(text) },
170− )
171− }
172− if (onRevert != null) {
173− DropdownMenuItem(
174− leadingIcon = { Icon(Icons.Filled.Restore, contentDescription = null) },
175− text = { Text(stringResource(R.string.revert_to_here)) },
176− onClick = { onDismiss(); onRevert() },
177− )
178− }
179− }
180−}
181−
182−/** Overflow menu of secondary per-message actions (quote-reply, branch-a-new-session).
183− * Shown when at least one action is available and there's text to act on. Keeps the
184− * inline action row (copy/edit/revert/speak) uncluttered while still surfacing the extras. */
185−@Composable
186−private fun MessageOverflow(
187− text: String,
188− onQuote: ((String) -> Unit)?,
189− onBranch: ((String) -> Unit)?,
190−) {
191− if (onQuote == null && onBranch == null) return
192− var expanded by remember { mutableStateOf(false) }
193− val moreLabel = stringResource(R.string.message_actions)
194− val haptics = LocalHapticFeedback.current
195− androidx.compose.foundation.layout.Box {
196− IconButton(onClick = { expanded = true }) {
197− Icon(
198− Icons.Filled.MoreVert,
199− contentDescription = moreLabel,
200− modifier = Modifier.size(18.dp),
201− tint = MaterialTheme.colorScheme.onSurfaceVariant,
202− )
203− }
204− DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
205− onQuote?.let { quote ->
206− DropdownMenuItem(
207− leadingIcon = { Icon(Icons.Filled.FormatQuote, contentDescription = null) },
208− text = { Text(stringResource(R.string.quote_reply)) },
209− onClick = {
210− expanded = false
211− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
212− quote(text)
213− },
214− )
215− }
216− onBranch?.let { branch ->
217− DropdownMenuItem(
218− leadingIcon = { Icon(Icons.AutoMirrored.Filled.PlaylistAdd, contentDescription = null) },
219− text = { Text(stringResource(R.string.branch_session)) },
220− onClick = {
221− expanded = false
222− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
223− branch(text)
224− },
225− )
226− }
227− }
228− }
229−}
230−
231−/** A small "revert to before this message" icon button, shown next to Copy when a revert
232− * handler is supplied. Reverting is undoable (see the chat's revert banner), so it acts
233− * immediately without a confirmation dialog. */
234−@Composable
235−private fun RevertButton(onRevert: () -> Unit) {
236− val label = stringResource(R.string.revert_to_here)
237− val haptics = LocalHapticFeedback.current
238− IconButton(onClick = {
239− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
240− onRevert()
241− }) {
242− Icon(
243− Icons.Filled.Restore,
244− contentDescription = label,
245− modifier = Modifier.size(18.dp),
246− tint = MaterialTheme.colorScheme.onSurfaceVariant,
247− )
248− }
249−}
250−
251−@Composable
252−private fun UnknownMessageBlock(
253− message: MessageWithParts,
254− imageContext: ImageLoadContext?,
255− modifier: Modifier,
256− onOpenFile: ((String) -> Unit)? = null,
257−) {
258− // Forward-compat: a role the client doesn't model. Render a muted note so the user
259− // sees *something* rather than an unlabeled block, plus any parts (e.g. text) the
260− // server attached, so content isn't silently dropped.
261− Column(
262− modifier = modifier.fillMaxWidth(),
263− verticalArrangement = Arrangement.spacedBy(8.dp),
264− ) {
265− Row(verticalAlignment = Alignment.CenterVertically) {
266− Icon(
267− Icons.AutoMirrored.Filled.HelpOutline,
268− contentDescription = null,
269− modifier = Modifier.size(14.dp),
270− tint = MaterialTheme.colorScheme.onSurfaceVariant,
271− )
272− Text(
273− stringResource(R.string.unknown_message),
274− style = MaterialTheme.typography.labelSmall,
275− color = MaterialTheme.colorScheme.onSurfaceVariant,
276− modifier = Modifier.padding(start = 6.dp),
277− )
278− }
279− // key() per part so each PartView gets a distinct saveable registry slot: rememberSaveable
280− // inside PartView keys its RESET on part.id but its registry key is the positional
281− // compositeKeyHash, which collides for sibling parts in a keyless loop — mis-restoring
282− // expand/collapse state across parts after process death. (index disambiguates blank ids.)
283− message.parts.forEachIndexed { index, part ->
284− key(part.id, index) {
285− PartView(part, imageContext = imageContext, onOpenFile = onOpenFile)
286− }
287− }
288− MessageTimestampText(message.info)
289− }
290−}
291−
292−@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
293−@Composable
294−private fun UserBubble(
295− message: MessageWithParts,
296− imageContext: ImageLoadContext?,
297− modifier: Modifier,
298− onOpenFile: ((String) -> Unit)? = null,
299− onRevert: (() -> Unit)? = null,
300− onEdit: ((String) -> Unit)? = null,
301− onQuote: ((String) -> Unit)? = null,
302− onBranch: ((String) -> Unit)? = null,
303− sendStatus: MessageSendStatus? = null,
304− isEdited: Boolean = false,
305− onRetry: (() -> Unit)? = null,
306− onDismiss: (() -> Unit)? = null,
307− onShare: (() -> Unit)? = null,
308−) {
309− val context = LocalContext.current
310− val haptics = LocalHapticFeedback.current
311− val density = androidx.compose.ui.platform.LocalDensity.current
312− val copyLabel = stringResource(R.string.copy)
313− val editLabel = stringResource(R.string.edit_message)
314− // Long-press context menu state: consolidates copy/quote/branch/revert into a standard
315− // Android long-press menu so the actions are discoverable without spotting the 18dp icons.
316− // The menu anchors at the actual touch point rather than a fixed corner.
317− var longPressMenu by remember { mutableStateOf(false) }
318− var longPressOffset by remember { mutableStateOf(androidx.compose.ui.unit.DpOffset(0.dp, 0.dp)) }
319− // Collect text from all TextParts for copying, so a user can reuse/repost their
320− // own prompt. Memoized so a scroll-induced recomposition doesn't re-scan the list.
321− val textToCopy = remember(message.parts) {
322− message.parts
323− .filterIsInstance<TextPart>()
324− .joinToString("\n\n") { it.text }
325− .takeIf { it.isNotBlank() }
326− }
327− val onCopy: (() -> Unit)? = textToCopy?.let { text ->
328− {
329− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
330− copyToClipboard(context, context.getString(R.string.clip_label_message), text)
331− }
332− }
333− // Wrap-content bubble capped at the width fraction (end-aligned), so a one-word prompt is
334− // snug instead of a wide empty box while long prompts still cap at the readable fraction.
335− BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
336− Column(
337− modifier = Modifier
338− .align(Alignment.TopEnd)
339− .widthIn(max = maxWidth * NetworkConfig.userBubbleWidthFraction)
340− .clip(MaterialTheme.shapes.medium)
341− .background(MaterialTheme.colorScheme.primaryContainer)
342− .padding(12.dp),
343− verticalArrangement = Arrangement.spacedBy(6.dp),
344− ) {
345− // key() per part so each PartView gets its own saveable registry slot (see AssistantBlock).
346− message.parts.forEachIndexed { index, part ->
347− key(part.id, index) { PartView(part, imageContext = imageContext, onOpenFile = onOpenFile) }
348− }
349− // Footer row with timestamp + inline actions. Long-press opens a context menu
350− // consolidating copy/quote/branch/revert — the conventional Android pattern — so
351− // the actions are discoverable without spotting the 18dp inline icons. Uses
352− // pointerInput/detectTapGestures (not combinedClickable) so no misleading "Button"
353− // semantics are announced to TalkBack for an area whose tap does nothing, and the
354− // menu anchors at the actual long-press position instead of a fixed corner.
355− Box {
356− Row(
357− modifier = Modifier
358− .fillMaxWidth()
359− .pointerInput(Unit) {
360− detectTapGestures(onLongPress = { offset ->
361− haptics.performHapticFeedback(HapticFeedbackType.LongPress)
362− longPressOffset = androidx.compose.ui.unit.DpOffset(
363− with(density) { offset.x.toDp() },
364− with(density) { offset.y.toDp() },
365− )
366− longPressMenu = true
367− })
368− },
369− horizontalArrangement = Arrangement.SpaceBetween,
370− verticalAlignment = Alignment.CenterVertically,
371− ) {
372− Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
373− when (sendStatus) {
374− MessageSendStatus.SENDING -> {
375− CircularProgressIndicator(
376− Modifier.size(12.dp),
377− strokeWidth = 1.5.dp,
378− color = MaterialTheme.colorScheme.onSurfaceVariant,
379− )
380− Text(
381− stringResource(R.string.message_sending),
382− style = MaterialTheme.typography.labelSmall,
383− color = MaterialTheme.colorScheme.onSurfaceVariant,
384− )
385− }
386− MessageSendStatus.FAILED -> {
387− // Tap-to-retry: the whole failed label is tappable to re-send (far more
388− // discoverable than relying on the transient snackbar). A dismiss (×) button
389− // removes the abandoned message entirely, since its text will never match a
390− // real server message and would otherwise linger forever.
391− val retryModifier = if (onRetry != null) {
392− Modifier.clickable(
393− interactionSource = remember { MutableInteractionSource() },
394− indication = ripple(),
395− role = Role.Button,
396− onClick = {
397− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
398− onRetry()
399− },
400− ).padding(vertical = 2.dp)
401− } else {
402− Modifier.padding(vertical = 2.dp)
403− }
404− Row(
405− verticalAlignment = Alignment.CenterVertically,
406− modifier = retryModifier.semantics(mergeDescendants = true) {
407− contentDescription = context.getString(R.string.message_send_failed) +
408− ". " + context.getString(R.string.tap_to_retry)
409− },
410− ) {
411− Icon(
412− Icons.Filled.Error,
413− contentDescription = null,
414− modifier = Modifier.size(12.dp),
415− tint = MaterialTheme.colorScheme.error,
416− )
417− Text(
418− stringResource(R.string.message_send_failed),
419− style = MaterialTheme.typography.labelSmall,
420− color = MaterialTheme.colorScheme.error,
421− modifier = Modifier.padding(start = 4.dp),
422− )
423− }
424− if (onDismiss != null) {
425− // No size override on the IconButton: it keeps the default 48dp
426− // touch target (only the inner Icon is shrunk to 14dp). An earlier
427− // `.size(20.dp)` here clamped the target to 20dp — well under the
428− // M3 minimum — which made the discard action hard to hit. Matches
429− // the pattern used by every other inline IconButton in this file.
430− IconButton(
431− onClick = {
432− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
433− onDismiss()
434− },
435− ) {
436− Icon(
437− Icons.Filled.Close,
438− contentDescription = stringResource(R.string.outbox_discard),
439− modifier = Modifier.size(14.dp),
440− tint = MaterialTheme.colorScheme.onSurfaceVariant,
441− )
442− }
443− }
444− }
445− null -> {}
446− }
447− if (isEdited && sendStatus == null) {
448− Text(
449− stringResource(R.string.message_edited),
450− style = MaterialTheme.typography.labelSmall,
451− color = MaterialTheme.colorScheme.onSurfaceVariant,
452− )
453− }
454− MessageTimestampText(message.info)
455− }
456− Row(verticalAlignment = Alignment.CenterVertically) {
457− // Edit affordance: reload this prompt into the composer, reverting the
458− // conversation to before it so a Send replaces it. Only when there's text
459− // to edit (an image-only prompt has nothing to reload).
460− if (onEdit != null && textToCopy != null) {
461− val dropsAttachmentsMsg = stringResource(R.string.edit_drops_attachments)
462− val hasAttachments = message.parts.any { it is FilePart }
463− IconButton(onClick = {
464− onEdit(textToCopy)
465− // Editing reloads only the text; if this prompt carried images or
466− // files, tell the user they won't be reattached rather than
467− // silently dropping them.
468− if (hasAttachments) showToast(context, dropsAttachmentsMsg)
469− }) {
470− Icon(
471− Icons.Filled.Edit,
472− contentDescription = editLabel,
473− modifier = Modifier.size(18.dp),
474− tint = MaterialTheme.colorScheme.onSurfaceVariant,
475− )
476− }
477− }
478− onRevert?.let { RevertButton(it) }
479− // Copy affordance for user prompts, mirroring the assistant block.
480− // Without it, reusing a prior prompt requires discovering the
481− // long-press on the markdown text (and only TextParts support that).
482− if (textToCopy != null) {
483− IconButton(
484− onClick = {
485− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
486− copyToClipboard(context, context.getString(R.string.clip_label_message), textToCopy)
487− },
488− ) {
489− Icon(
490− Icons.Filled.ContentCopy,
491− contentDescription = copyLabel,
492− modifier = Modifier.size(18.dp),
493− tint = MaterialTheme.colorScheme.onSurfaceVariant,
494− )
495− }
496− }
497− // Quote-reply / branch-a-new-session, tucked behind an overflow so the
498− // inline row stays compact. Only when there's prompt text to act on.
499− if (textToCopy != null) {
500− MessageOverflow(text = textToCopy, onQuote = onQuote, onBranch = onBranch)
501− }
502− }
503− }
504− MessageLongPressMenu(
505− expanded = longPressMenu,
506− onDismiss = { longPressMenu = false },
507− offset = longPressOffset,
508− text = textToCopy,
509− onCopy = onCopy,
510− onQuote = onQuote,
511− onBranch = onBranch,
512− onRevert = onRevert,
513− onShare = onShare,
514− )
515− }
516− }
517− }
518−}
519−
520−@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
521−@Composable
522−private fun AssistantBlock(
523− message: MessageWithParts,
524− isRunning: Boolean,
525− imageContext: ImageLoadContext?,
526− modifier: Modifier,
527− modelLabel: String? = null,
528− onOpenFile: ((String) -> Unit)? = null,
529− onRevert: (() -> Unit)? = null,
530− onSpeak: ((String) -> Unit)? = null,
531− isSpeaking: Boolean = false,
532− ttsState: TtsState = TtsState.IDLE,
533− onPause: (() -> Unit)? = null,
534− onResume: (() -> Unit)? = null,
535− onQuote: ((String) -> Unit)? = null,
536− onBranch: ((String) -> Unit)? = null,
537− onRegenerate: (() -> Unit)? = null,
538− isEdited: Boolean = false,
539− onShare: (() -> Unit)? = null,
540−) {
541− Column(
542− modifier = modifier.fillMaxWidth(),
543− verticalArrangement = Arrangement.spacedBy(8.dp),
544− ) {
545− val info = message.info
546− if (info is AssistantMessage) {
547− // Avatar + model label row: the robot icon gives the assistant a consistent visual
548− // identity (left-aligned, mirroring the user's right-aligned bubble) so on a long
549− // scroll the speaker is unambiguous without reading the label.
550− AssistantHeader(label = modelLabel ?: info.modelID)
551− }
552− message.parts.forEachIndexed { index, part ->
553− // Only the final part of a running message is actively streaming; earlier parts
554− // (a finished reasoning block, a completed tool call) are already done, so passing
555− // isRunning to all of them would keep a completed reasoning block showing the
556− // "Thinking…" spinner until the whole message finishes.
557− val partStreaming = isRunning && index == message.parts.lastIndex
558− // key() per part so each PartView gets a distinct saveable registry slot: without it,
559− // rememberSaveable expand/collapse state (keyed only positionally) is shared across
560− // sibling parts and mis-restored after process death. (index disambiguates blank ids.)
561− key(part.id, index) {
562− PartView(part, isRunning = partStreaming, modifier = Modifier.fillMaxWidth(), imageContext = imageContext, onOpenFile = onOpenFile)
563− }
564− }
565− // Agent-reported error: the server sets AssistantMessage.error when the run finished
566− // with a model/agent error (distinct from a transport failure, which surfaces as a
567− // snackbar). Render it as an error-tinted note above the footer so a reader can tell
568− // the reply is incomplete/failed by glancing at the bubble, not just from the absence
569− // of content. Folded into the same `info is AssistantMessage` branch as the cost
570− // summary below to avoid an extra conditional raising this function's complexity.
571− if (info is AssistantMessage) {
572− info.error?.let { err -> AgentErrorNote(err) }
573− val cost = info.cost
574− val tokens = info.tokens
575− val tokenFormat = stringResource(R.string.tokens_in_out)
576− val reasoningFormat = stringResource(R.string.tokens_reasoning_format)
577− val cacheReadFormat = stringResource(R.string.tokens_cache_read_format)
578− val cacheWriteFormat = stringResource(R.string.tokens_cache_write_format)
579− val costShort = stringResource(R.string.cost_format_short)
580− val costLong = stringResource(R.string.cost_format_long)
581− // Memoize the formatted cost/tokens line so a scroll-induced or unrelated
582− // state-flip recomposition doesn't re-run NumberFormat + buildList +
583− // joinToString for every visible assistant bubble. The assembly logic lives in
584− // [buildCostSummary] (extracted to keep this function's complexity in check).
585− //
586− // Reasoning and cache figures are only added when non-zero, so a model that
587− // doesn't report them (or a request that didn't use them) shows the same compact
588− // "1.2k in · 540 out • $0.012" line as before; a reasoning-model reply gains a
589− // "3.1k reasoning" and/or "8.0k cache read" segment. The single-line cap and
590− // ellipsis below keep the line tidy at narrow widths even with all four segments.
591− val costSummary = remember(info.isComplete, tokens, cost, tokenFormat, reasoningFormat, cacheReadFormat, cacheWriteFormat, costShort, costLong) {
592− buildCostSummary(info.isComplete, tokens, cost, tokenFormat, reasoningFormat, cacheReadFormat, cacheWriteFormat, costShort, costLong)
593− }
594− // Long-press context menu state (see UserBubble for rationale).
595− var longPressMenu by remember { mutableStateOf(false) }
596− var longPressOffset by remember { mutableStateOf(androidx.compose.ui.unit.DpOffset(0.dp, 0.dp)) }
597− // Collect text from all TextParts for copy/read-aloud. Memoized so a
598− // scroll-induced recomposition doesn't re-scan the parts list.
599− val textToCopy = remember(message.parts) {
600− message.parts
601− .filterIsInstance<TextPart>()
602− .joinToString("\n\n") { it.text }
603− .takeIf { it.isNotBlank() }
604− }
605− val assistantHaptics = LocalHapticFeedback.current
606− val assistantContext = LocalContext.current
607− val assistantDensity = androidx.compose.ui.platform.LocalDensity.current
608− val onCopy: (() -> Unit)? = textToCopy?.let { text ->
609− {
610− assistantHaptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
611− copyToClipboard(assistantContext, assistantContext.getString(R.string.clip_label_message), text)
612− }
613− }
614− Box {
615− Row(
616− modifier = Modifier
617− .fillMaxWidth()
618− .pointerInput(Unit) {
619− detectTapGestures(onLongPress = { offset ->
620− assistantHaptics.performHapticFeedback(HapticFeedbackType.LongPress)
621− longPressOffset = androidx.compose.ui.unit.DpOffset(
622− with(assistantDensity) { offset.x.toDp() },
623− with(assistantDensity) { offset.y.toDp() },
624− )
625− longPressMenu = true
626− })
627− },
628− horizontalArrangement = Arrangement.SpaceBetween,
629− verticalAlignment = Alignment.CenterVertically,
630− ) {
631− Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
632− costSummary?.let {
633− Text(
634− it,
635− style = MaterialTheme.typography.labelSmall,
636− color = MaterialTheme.colorScheme.onSurfaceVariant,
637− // Cap to a single line so a long "1.2k in · 540 out • $0.012"
638− // summary doesn't wrap the "•" awkwardly at narrow widths.
639− maxLines = 1,
640− overflow = TextOverflow.Ellipsis,
641− )
642− }
643− if (isEdited) {
644− Text(
645− stringResource(R.string.message_edited),
646− style = MaterialTheme.typography.labelSmall,
647− color = MaterialTheme.colorScheme.onSurfaceVariant,
648− )
649− }
650− MessageTimestampText(message.info)
651− }
652− AssistantActions(
653− textToCopy = textToCopy,
654− isSpeaking = isSpeaking,
655− ttsState = ttsState,
656− onSpeak = onSpeak,
657− onPause = onPause,
658− onResume = onResume,
659− onRevert = onRevert,
660− onQuote = onQuote,
661− onBranch = onBranch,
662− onRegenerate = onRegenerate,
663− )
664− }
665− MessageLongPressMenu(
666− expanded = longPressMenu,
667− onDismiss = { longPressMenu = false },
668− offset = longPressOffset,
669− text = textToCopy,
670− onCopy = onCopy,
671− onQuote = onQuote,
672− onBranch = onBranch,
673− onRevert = onRevert,
674− onShare = onShare,
675− )
676− }
677− } else {
678− MessageTimestampText(message.info)
679− }
680− }
681−}
682−
683−/** Avatar + model label row for an assistant message. Extracted from [AssistantBlock] to keep
684− * that function's cyclomatic complexity under the detekt threshold. */
685−@Composable
686−private fun AssistantHeader(label: String?) {
687− Row(verticalAlignment = Alignment.CenterVertically) {
688− Box(
689− modifier = Modifier
690− .size(28.dp)
691− .clip(MaterialTheme.shapes.extraSmall)
692− .background(MaterialTheme.colorScheme.secondaryContainer),
693− contentAlignment = Alignment.Center,
694− ) {
695− Icon(
696− Icons.Filled.SmartToy,
697− contentDescription = null,
698− modifier = Modifier.size(18.dp),
699− tint = MaterialTheme.colorScheme.onSecondaryContainer,
700− )
701− }
702− if (label != null) {
703− Text(
704− label,
705− style = MaterialTheme.typography.labelSmall,
706− color = MaterialTheme.colorScheme.onSurfaceVariant,
707− maxLines = 1,
708− overflow = TextOverflow.Ellipsis,
709− modifier = Modifier
710− .padding(start = 6.dp)
711− .weight(1f, fill = false),
712− )
713− }
714− }
715−}
716−
717−/** Trailing action buttons for an assistant message: read-aloud (TTS), revert, copy.
718− * Extracted from [AssistantBlock] to keep that function under the complexity threshold. */
719−@Composable
720−private fun AssistantActions(
721− textToCopy: String?,
722− isSpeaking: Boolean,
723− ttsState: TtsState,
724− onSpeak: ((String) -> Unit)?,
725− onPause: (() -> Unit)?,
726− onResume: (() -> Unit)?,
727− onRevert: (() -> Unit)?,
728− onQuote: ((String) -> Unit)? = null,
729− onBranch: ((String) -> Unit)? = null,
730− onRegenerate: (() -> Unit)? = null,
731−) {
732− val context = LocalContext.current
733− val haptics = LocalHapticFeedback.current
734− val copyLabel = stringResource(R.string.copy)
735− Row(verticalAlignment = Alignment.CenterVertically) {
736− // Regenerate: re-run the preceding user prompt to get a fresh reply. Only meaningful
737− // for a completed, non-streaming message, so the caller gates it on !isRunning.
738− if (onRegenerate != null) {
739− val regenerateLabel = stringResource(R.string.regenerate)
740− IconButton(onClick = {
741− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
742− onRegenerate()
743− }) {
744− Icon(
745− Icons.Filled.Refresh,
746− contentDescription = regenerateLabel,
747− modifier = Modifier.size(18.dp),
748− tint = MaterialTheme.colorScheme.onSurfaceVariant,
749− )
750− }
751− }
752− // Read-aloud control with pause/resume. Tapping cycles through play → pause → resume,
753− // so a user listening to a long reply can step away and continue from roughly where
754− // they stopped without re-playing the whole message. Extracted to [TtsButton] to keep
755− // this function's cyclomatic complexity under the detekt threshold.
756− if (onSpeak != null && textToCopy != null) {
757− TtsButton(
758− text = textToCopy,
759− isSpeaking = isSpeaking,
760− ttsState = ttsState,
761− onSpeak = onSpeak,
762− onPause = onPause,
763− onResume = onResume,
764− )
765− }
766− onRevert?.let { RevertButton(it) }
767− if (textToCopy != null) {
768− IconButton(onClick = {
769− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
770− copyToClipboard(context, context.getString(R.string.clip_label_message), textToCopy)
771− }) {
772− Icon(
773− Icons.Filled.ContentCopy,
774− contentDescription = copyLabel,
775− modifier = Modifier.size(18.dp),
776− tint = MaterialTheme.colorScheme.onSurfaceVariant,
777− )
778− }
779− }
780− // Assistant replies can be quoted into the composer or branched into a new session.
781− if (textToCopy != null && (onQuote != null || onBranch != null)) {
782− MessageOverflow(text = textToCopy, onQuote = onQuote, onBranch = onBranch)
783− }
784− }
785−}
786−
787−/** Read-aloud button with play/pause/resume states. Extracted from [AssistantActions] to keep
788− * that function's cyclomatic complexity under the detekt threshold. The icon reflects the
789− * next action: VolumeUp (play) when idle, Pause when playing, PlayArrow (resume) when paused. */
790−@Composable
791−private fun TtsButton(
792− text: String,
793− isSpeaking: Boolean,
794− ttsState: TtsState,
795− onSpeak: (String) -> Unit,
796− onPause: (() -> Unit)?,
797− onResume: (() -> Unit)?,
798−) {
799− val haptics = LocalHapticFeedback.current
800− val label = when {
801− isSpeaking && ttsState == TtsState.PAUSED -> stringResource(R.string.resume_reading)
802− isSpeaking -> stringResource(R.string.pause_reading)
803− else -> stringResource(R.string.read_aloud)
804− }
805− IconButton(onClick = {
806− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
807− when {
808− isSpeaking && ttsState == TtsState.PAUSED -> onResume?.invoke()
809− isSpeaking -> onPause?.invoke()
810− else -> onSpeak(text)
811− }
812− }) {
813− val icon = when {
814− isSpeaking && ttsState == TtsState.PAUSED -> Icons.Filled.PlayArrow
815− isSpeaking -> Icons.Filled.Pause
816− else -> Icons.AutoMirrored.Filled.VolumeUp
817− }
818− val tint = if (isSpeaking) MaterialTheme.colorScheme.primary
819− else MaterialTheme.colorScheme.onSurfaceVariant
820− Icon(
821− icon,
822− contentDescription = label,
823− modifier = Modifier.size(18.dp),
824− tint = tint,
825− )
826− }
827−}
828−
829−@Composable
830−private fun MessageTimestampText(info: soy.iko.opencode.data.model.MessageInfo) {
831− // Compact relative label by default; long-press reveals the full absolute timestamp
832− // (RelativeTimeText renders nothing when the time is absent, so no isNotEmpty guard).
833− RelativeTimeText(info.time?.created)
834−}
835−
836−/**
837− * Render an [AssistantMessage.error] as an error-tinted note on the bubble. The error is a
838− * tolerant-decoded [JsonElement]; we extract a readable string from it (a primitive's content
839− * for a bare string, a pretty-printed object otherwise, trimmed to a reasonable length so a
840− * verbose error object doesn't dominate the bubble).
841− */
842−@Composable
843−private fun AgentErrorNote(error: kotlinx.serialization.json.JsonElement) {
844− // Extract a readable string from the tolerant-decoded JsonElement: a bare string
845− // primitive returns its content (the common `{"error": "..."}` case); an object/array
846− // is pretty-printed and trimmed so a verbose error doesn't dominate the bubble.
847− val text = remember(error) {
848− val raw = if (error is kotlinx.serialization.json.JsonPrimitive) {
849− error.content
850− } else {
851− runCatching {
852− soy.iko.opencode.data.network.OpencodeJson.encodeToString(
853− kotlinx.serialization.json.JsonElement.serializer(),
854− error,
855− )
856− }.getOrDefault(error.toString())
857− }
858− if (raw.length <= 500) raw else raw.take(500) + "…"
859− }.takeIf { it.isNotBlank() } ?: return
860− val label = stringResource(R.string.agent_error_label)
861− Row(
862− modifier = Modifier
863− .fillMaxWidth()
864− .clip(MaterialTheme.shapes.small)
865− .background(MaterialTheme.colorScheme.errorContainer)
866− .padding(10.dp),
867− verticalAlignment = Alignment.Top,
868− ) {
869− Icon(
870− Icons.Filled.Error,
871− contentDescription = null,
872− tint = MaterialTheme.colorScheme.onErrorContainer,
873− modifier = Modifier.size(16.dp),
874− )
875− Column(modifier = Modifier.padding(start = 8.dp)) {
876− Text(
877− label,
878− style = MaterialTheme.typography.labelMedium,
879− color = MaterialTheme.colorScheme.onErrorContainer,
880− )
881− Text(
882− text,
883− style = MaterialTheme.typography.bodySmall,
884− color = MaterialTheme.colorScheme.onErrorContainer,
885− )
886− }
887− }
888−}
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.ktdeleted−235
@@ -1,235 +0,0 @@
1−package soy.iko.opencode.ui.chat
2−
3−import androidx.compose.foundation.clickable
4−import androidx.compose.foundation.layout.Column
5−import androidx.compose.foundation.layout.Row
6−import androidx.compose.foundation.layout.fillMaxWidth
7−import androidx.compose.foundation.layout.heightIn
8−import androidx.compose.foundation.layout.padding
9−import androidx.compose.foundation.layout.size
10−import androidx.compose.foundation.lazy.LazyColumn
11−import androidx.compose.foundation.lazy.items
12−import androidx.compose.foundation.lazy.rememberLazyListState
13−import androidx.compose.foundation.text.KeyboardActions
14−import androidx.compose.foundation.text.KeyboardOptions
15−import androidx.compose.material.icons.Icons
16−import androidx.compose.material.icons.filled.Check
17−import androidx.compose.material.icons.filled.Close
18−import androidx.compose.material.icons.filled.Search
19−import androidx.compose.material3.CircularProgressIndicator
20−import androidx.compose.material3.ExperimentalMaterial3Api
21−import androidx.compose.material3.Icon
22−import androidx.compose.material3.IconButton
23−import androidx.compose.material3.MaterialTheme
24−import androidx.compose.material3.ModalBottomSheet
25−import androidx.compose.material3.OutlinedTextField
26−import androidx.compose.material3.Text
27−import androidx.compose.material3.TextButton
28−import androidx.compose.runtime.Composable
29−import androidx.compose.runtime.LaunchedEffect
30−import androidx.compose.runtime.getValue
31−import androidx.compose.runtime.mutableStateOf
32−import androidx.compose.runtime.remember
33−import androidx.compose.runtime.saveable.rememberSaveable
34−import androidx.compose.runtime.setValue
35−import androidx.compose.ui.Alignment
36−import androidx.compose.ui.Modifier
37−import androidx.compose.ui.focus.FocusRequester
38−import androidx.compose.ui.focus.focusRequester
39−import androidx.compose.ui.hapticfeedback.HapticFeedbackType
40−import androidx.compose.ui.platform.LocalHapticFeedback
41−import androidx.compose.ui.platform.LocalSoftwareKeyboardController
42−import androidx.compose.ui.res.stringResource
43−import androidx.compose.ui.semantics.Role
44−import androidx.compose.ui.semantics.contentDescription
45−import androidx.compose.ui.semantics.role
46−import androidx.compose.ui.semantics.selected
47−import androidx.compose.ui.semantics.semantics
48−import androidx.compose.ui.text.input.ImeAction
49−import androidx.compose.ui.unit.dp
50−import androidx.compose.foundation.layout.Box
51−import androidx.compose.foundation.layout.fillMaxHeight
52−import androidx.compose.ui.platform.LocalConfiguration
53−import soy.iko.opencode.data.model.ModelOption
54−import soy.iko.opencode.data.network.NetworkConfig
55−import soy.iko.opencode.R
56−
57−/**
58− * Flat LazyColumn index of the selected model within the provider-grouped list (each group
59− * preceded by a header row), so the sheet can scroll it into view on open. -1 when nothing
60− * is selected or the selection isn't in the current list.
61− */
62−private fun selectedModelIndex(
63− grouped: Map<String, List<ModelOption>>,
64− selected: ModelOption?,
65−): Int {
66− if (selected == null) return -1
67− var idx = 0
68− for ((_, opts) in grouped) {
69− idx++ // provider header
70− val distinct = opts.distinctBy { it.providerID to it.modelID }
71− val pos = distinct.indexOfFirst {
72− it.providerID == selected.providerID && it.modelID == selected.modelID
73− }
74− if (pos >= 0) return idx + pos
75− idx += distinct.size
76− }
77− return -1
78−}
79−
80−/** Bottom sheet that lists every provider/model and lets the user pick one. */
81−@OptIn(ExperimentalMaterial3Api::class)
82−@Composable
83−fun ModelPickerSheet(
84− options: List<ModelOption>,
85− selected: ModelOption?,
86− loading: Boolean,
87− error: Boolean,
88− onSelect: (ModelOption) -> Unit,
89− onRetry: () -> Unit,
90− onDismiss: () -> Unit,
91−) {
92− ModalBottomSheet(onDismissRequest = onDismiss) {
93− Text(
94− stringResource(R.string.model),
95− style = MaterialTheme.typography.titleMedium,
96− modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
97− )
98− if (loading) {
99− val loadingLabel = stringResource(R.string.loading)
100− Box(
101− modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
102− contentAlignment = Alignment.Center,
103− ) {
104− CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
105− }
106− } else if (error && options.isEmpty()) {
107− Column(
108− modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
109− horizontalAlignment = Alignment.CenterHorizontally,
110− ) {
111− Text(
112− stringResource(R.string.load_failed),
113− style = MaterialTheme.typography.bodyMedium,
114− color = MaterialTheme.colorScheme.error,
115− )
116− TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
117− }
118− } else if (options.isEmpty()) {
119− Text(
120− stringResource(R.string.no_models),
121− style = MaterialTheme.typography.bodyMedium,
122− color = MaterialTheme.colorScheme.onSurfaceVariant,
123− modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
124− )
125− } else {
126− var query by rememberSaveable { mutableStateOf("") }
127− val keyboardController = LocalSoftwareKeyboardController.current
128− val haptics = LocalHapticFeedback.current
129− // Auto-focus the search field (and raise the keyboard) when the sheet opens so the
130− // user can start typing to filter a long catalog without an extra tap. Skip it for a
131− // short catalog that's faster to scan by eye than to filter.
132− val searchFocus = remember { FocusRequester() }
133− LaunchedEffect(Unit) {
134− if (options.size > NetworkConfig.pickerSearchAutofocusThreshold) {
135− runCatching { searchFocus.requestFocus() }
136− }
137− }
138− OutlinedTextField(
139− value = query,
140− onValueChange = { query = it },
141− modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp).focusRequester(searchFocus),
142− placeholder = { Text(stringResource(R.string.search_models)) },
143− leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
144− trailingIcon = if (query.isNotEmpty()) {
145− {
146− IconButton(onClick = { query = "" }) {
147− Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
148− }
149− }
150− } else null,
151− singleLine = true,
152− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
153− keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
154− )
155− val filtered = remember(options, query) {
156− val q = query.trim()
157− if (q.isEmpty()) options
158− else options.filter {
159− it.modelLabel.contains(q, ignoreCase = true) ||
160− it.providerLabel.contains(q, ignoreCase = true)
161− }
162− }
163− if (filtered.isEmpty()) {
164− Text(
165− stringResource(R.string.no_models_match, query.trim()),
166− style = MaterialTheme.typography.bodyMedium,
167− color = MaterialTheme.colorScheme.onSurfaceVariant,
168− modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
169− )
170− } else {
171− // Group by provider so a long catalog is scannable: a provider header
172− // followed by its models, instead of a flat list mixing providers.
173− val grouped = remember(filtered) { filtered.groupBy { it.providerID } }
174− val listState = rememberLazyListState()
175− // Flat LazyColumn index of the selected model (accounting for the provider
176− // header rows), so we can scroll it into view when the sheet opens. -1 when
177− // nothing is selected or the selection isn't in the current list.
178− val selectedIndex = remember(grouped, selected) { selectedModelIndex(grouped, selected) }
179− LaunchedEffect(Unit) {
180− if (selectedIndex >= 0) listState.scrollToItem(selectedIndex)
181− }
182− // Responsive height: half the viewport rather than a hardcoded 420dp cap, so
183− // the sheet doesn't cramp in landscape or leave empty space on a tall phone.
184− LazyColumn(state = listState, modifier = Modifier.fillMaxHeight(0.5f)) {
185− grouped.forEach { (providerID, opts) ->
186− item(key = "header_$providerID") {
187− Text(
188− opts.firstOrNull()?.providerLabel ?: providerID,
189− style = MaterialTheme.typography.labelMedium,
190− color = MaterialTheme.colorScheme.primary,
191− modifier = Modifier.padding(start = 24.dp, top = 12.dp, bottom = 4.dp),
192− )
193− }
194− // distinctBy the (provider, model) pair so a duplicate entry from the
195− // server can't crash the list with LazyColumn's "Key was already used".
196− items(opts.distinctBy { it.providerID to it.modelID }, key = { it.providerID to it.modelID }) { option ->
197− val isSelected = option.providerID == selected?.providerID &&
198− option.modelID == selected?.modelID
199− Column(
200− modifier = Modifier
201− .fillMaxWidth()
202− .semantics(mergeDescendants = true) {
203− if (isSelected) this.selected = true
204− }
205− .clickable(role = Role.RadioButton) {
206− haptics.performHapticFeedback(HapticFeedbackType.TextHandleMove)
207− onSelect(option)
208− onDismiss()
209− }
210− .padding(horizontal = 24.dp, vertical = 12.dp),
211− ) {
212− Row(verticalAlignment = Alignment.CenterVertically) {
213− Text(
214− option.modelLabel,
215− style = MaterialTheme.typography.bodyLarge,
216− color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
217− modifier = Modifier.weight(1f),
218− )
219− if (isSelected) {
220− Icon(
221− Icons.Filled.Check,
222− contentDescription = null,
223− tint = MaterialTheme.colorScheme.primary,
224− modifier = Modifier.size(18.dp),
225− )
226− }
227− }
228− }
229− }
230− }
231− }
232− }
233− }
234− }
235−}
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.ktdeleted−676

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.ktdeleted−169

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/chat/TodoPlan.ktdeleted−205

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/chat/TtsController.ktdeleted−240

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/AppTopBar.ktdeleted−84

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/Autofill.ktdeleted−49

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/ChatTextPrefs.ktdeleted−51

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/CodeHighlighter.ktdeleted−357

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/CommandPalette.ktdeleted−172

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.ktdeleted−177

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/DiffView.ktdeleted−276

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/EmptyState.ktdeleted−68

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/LargeScreenNavRail.ktdeleted−74

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.ktdeleted−433

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.ktnew file+479

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/PaletteAction.ktdeleted−11

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/QrCode.ktdeleted−174

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/ReducedMotion.ktdeleted−99

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.ktdeleted−384

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/SectionHeader.ktdeleted−25

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.ktdeleted−200

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.ktdeleted−813

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.ktdeleted−279

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.ktdeleted−63

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.ktdeleted−921

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/file/TwoPaneFileBrowser.ktdeleted−92

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.ktdeleted−350

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/mcp/McpServerInfo.ktdeleted−68

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/mcp/McpViewModel.ktdeleted−160

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.ktnew file+590

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.ktnew file+222

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.ktnew file+402

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.ktdeleted−252

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.ktdeleted−213

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.ktdeleted−554

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.ktdeleted−439

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.ktdeleted−704

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.ktdeleted−198

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/session/NewSessionDialog.ktdeleted−315

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.ktdeleted−1466

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.ktdeleted−988

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.ktdeleted−297

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.ktdeleted−596

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.ktdeleted−942

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/theme/Color.ktnew file+19

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/theme/Shapes.ktdeleted−19

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/theme/Theme.kt+38−156

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/theme/Type.ktnew file+15

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/theme/Typography.ktdeleted−133

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/usage/UsageAggregation.ktdeleted−111

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.ktdeleted−343

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.ktdeleted−100

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/util/Coroutines.ktdeleted−44

This diff is too big to show here.

app/src/main/res/drawable/ic_action_block.xmldeleted−10

This diff is too big to show here.

app/src/main/res/drawable/ic_action_check.xmldeleted−9

This diff is too big to show here.

app/src/main/res/drawable/ic_action_refresh.xmldeleted−9

This diff is too big to show here.

app/src/main/res/drawable/ic_action_reply.xmldeleted−9

This diff is too big to show here.

app/src/main/res/drawable/ic_action_stop.xmldeleted−9

This diff is too big to show here.

app/src/main/res/drawable/ic_launcher_foreground.xmldeleted−13

This diff is too big to show here.

app/src/main/res/drawable/ic_stat_notify.xmldeleted−9

This diff is too big to show here.

app/src/main/res/drawable/widget_background.xmldeleted−6

This diff is too big to show here.

app/src/main/res/layout/widget_session_item.xmldeleted−21

This diff is too big to show here.

app/src/main/res/layout/widget_sessions.xmldeleted−64

This diff is too big to show here.

app/src/main/res/mipmap-anydpi/ic_launcher.xmldeleted−6

This diff is too big to show here.

app/src/main/res/values-es/strings.xmldeleted−704

This diff is too big to show here.

app/src/main/res/values-es/strings_cross_ux.xmldeleted−7

This diff is too big to show here.

app/src/main/res/values-es/strings_diag_ux.xmldeleted−4

This diff is too big to show here.

app/src/main/res/values-es/strings_notif_ux.xmldeleted−5

This diff is too big to show here.

app/src/main/res/values-es/strings_settings_ux.xmldeleted−12

This diff is too big to show here.

app/src/main/res/values-night/colors.xml+1−5

This diff is too big to show here.

app/src/main/res/values-night/themes.xml+2−2

This diff is too big to show here.

app/src/main/res/values/colors.xml+1−5

This diff is too big to show here.

app/src/main/res/values/strings.xml−700

This diff is too big to show here.

app/src/main/res/values/strings_cross_ux.xmldeleted−7

This diff is too big to show here.

app/src/main/res/values/strings_diag_ux.xmldeleted−4

This diff is too big to show here.

app/src/main/res/values/strings_notif_ux.xmldeleted−8

This diff is too big to show here.

app/src/main/res/values/strings_settings_ux.xmldeleted−12

This diff is too big to show here.

app/src/main/res/values/themes.xml+7−16

This diff is too big to show here.

app/src/main/res/xml/data_extraction_rules.xmldeleted−15

This diff is too big to show here.

app/src/main/res/xml/file_paths.xmldeleted−9

This diff is too big to show here.

app/src/main/res/xml/network_security_config.xmldeleted−15

This diff is too big to show here.

app/src/main/res/xml/sessions_widget_info.xmldeleted−10

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/M5DecodingTest.ktdeleted−144

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/M6Test.ktdeleted−102

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/OpencodeJsonTest.ktdeleted−122

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/UxTest.ktdeleted−91

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/core/ConnectUrlTest.ktnew file+69

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/model/TodoPlanTest.ktdeleted−90

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/network/EventStreamClientTest.ktdeleted−37

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/network/NormalizeBaseUrlTest.ktdeleted−42

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/network/OpencodeApiClientTest.ktdeleted−115

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.ktdeleted−254

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/CrashLoggerScrubTest.ktdeleted−46

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/ErrorKindTest.ktdeleted−60

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/MessageStoreTest.ktdeleted−385

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/OutboxStoreTest.ktdeleted−44

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/SessionRepositoryTest.ktdeleted−73

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/di/UnreadTrackingTest.ktdeleted−56

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/chat/ChatViewModelTest.ktdeleted−601

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/chat/ConversationExportTest.ktdeleted−120

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/chat/ToolDurationFormatTest.ktdeleted−45

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/components/CodeHighlighterLanguageTest.ktdeleted−66

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/components/DiffParseTest.ktdeleted−55

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/components/ExtractCodeTextTest.ktdeleted−56

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/components/ExtractFenceLanguageTest.ktdeleted−28

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/file/FileBrowserViewModelTest.ktdeleted−219

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/file/FileViewModelTest.ktdeleted−70

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/mcp/McpParserTest.ktdeleted−52

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/search/GlobalSearchViewModelTest.ktdeleted−120

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/server/CertPinValidationTest.ktdeleted−39

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.ktdeleted−460

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.ktdeleted−634

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.ktdeleted−475

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/usage/UsageAggregationTest.ktdeleted−48

This diff is too big to show here.

build.gradle.kts+1−1

This diff is too big to show here.

config/detekt/detekt.ymldeleted−68

This diff is too big to show here.

flake.lock+3−3

This diff is too big to show here.

flake.nix+27−17

This diff is too big to show here.

generated/com/novi/bincode/BincodeDeserializer.javanew file+38

This diff is too big to show here.

generated/com/novi/bincode/BincodeSerializer.javanew file+33

This diff is too big to show here.

generated/com/novi/serde/ArrayLen.javanew file+12

This diff is too big to show here.

generated/com/novi/serde/BinaryDeserializer.javanew file+195

This diff is too big to show here.

generated/com/novi/serde/BinarySerializer.javanew file+146

This diff is too big to show here.

generated/com/novi/serde/Bytes.javanew file+54

This diff is too big to show here.

generated/com/novi/serde/DeserializationError.javanew file+11

This diff is too big to show here.

generated/com/novi/serde/Deserializer.javanew file+56

This diff is too big to show here.

generated/com/novi/serde/Int128.javanew file+11

This diff is too big to show here.

generated/com/novi/serde/SerializationError.javanew file+11

This diff is too big to show here.

generated/com/novi/serde/Serializer.javanew file+58

This diff is too big to show here.

generated/com/novi/serde/Slice.javanew file+40

This diff is too big to show here.

generated/com/novi/serde/Tuple2.javanew file+40

This diff is too big to show here.

generated/com/novi/serde/Tuple3.javanew file+46

This diff is too big to show here.

generated/com/novi/serde/Tuple4.javanew file+54

This diff is too big to show here.

generated/com/novi/serde/Tuple5.javanew file+61

This diff is too big to show here.

generated/com/novi/serde/Tuple6.javanew file+67

This diff is too big to show here.

generated/com/novi/serde/Unit.javanew file+20

This diff is too big to show here.

generated/com/novi/serde/Unsigned.javanew file+11

This diff is too big to show here.

generated/soy/iko/opencode/shared/MessageStatus.javanew file+150

This diff is too big to show here.

generated/soy/iko/opencode/shared/MessageView.javanew file+131

This diff is too big to show here.

generated/soy/iko/opencode/shared/Requests.javanew file+27

This diff is too big to show here.

generated/soy/iko/opencode/shared/Screen.javanew file+150

This diff is too big to show here.

generated/soy/iko/opencode/shared/SessionView.javanew file+77

This diff is too big to show here.

generated/soy/iko/opencode/shared/ToolView.javanew file+86

This diff is too big to show here.

generated/soy/iko/opencode/shared/TraitHelpers.javanew file+71

This diff is too big to show here.

generated/soy/iko/opencode/shared/ViewModel.javanew file+185

This diff is too big to show here.

gradle.properties+5−2

This diff is too big to show here.

gradle/libs.versions.toml+25−54

This diff is too big to show here.

renovate.jsondeleted−32

This diff is too big to show here.

scripts/run-device.shdeleted−106

This diff is too big to show here.

scripts/run.shdeleted−95

This diff is too big to show here.

settings.gradle.kts+3

This diff is too big to show here.

shared/Cargo.locknew file+1358

This diff is too big to show here.

shared/Cargo.tomlnew file+31

This diff is too big to show here.

shared/src/app.rsnew file+1051

This diff is too big to show here.

shared/src/bin/codegen.rsnew file+35

This diff is too big to show here.

shared/src/lib.rsnew file+17

This diff is too big to show here.

shared/src/protocol.rsnew file+248

This diff is too big to show here.