git.iko.soy/opencode-android
on 59aa992 help sign in
.direnv/bin/nix-direnv-reloadnew file+19
since splitsnapshots
unifiedsplit
@@ -0,0 +1,19 @@
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-sourcenew file+1
@@ -0,0 +1 @@
1+/nix/store/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-source
.direnv/flake-inputs/kfs4nfy1705ksbn0gv15ls921cc90h2z-sourcenew file+1
@@ -0,0 +1 @@
1+/nix/store/kfs4nfy1705ksbn0gv15ls921cc90h2z-source
.direnv/flake-inputs/y4nm07b4ag6b9l0db8himn2xr1hyxfwr-sourcenew file+1
@@ -0,0 +1 @@
1+/nix/store/y4nm07b4ag6b9l0db8himn2xr1hyxfwr-source
.direnv/flake-inputs/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-sourcenew file+1
@@ -0,0 +1 @@
1+/nix/store/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-source
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfanew file+1
@@ -0,0 +1 @@
1+/nix/store/n53brq0w7p7wvbsxw4i8y3sxraf91hci-nix-shell-env
.direnv/flake-profile-a5d5b61aa8a61b7d9d765e1daf971a9a578f1cfa.rcnew file+2250
@@ -0,0 +1,2250 @@
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+16−12
@@ -12,19 +12,7 @@ concurrency:
12 cancel-in-progress: true
12 cancel-in-progress: true
13
13
14jobs:
14jobs:
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−
26 build:
15 build:
27− name: Android app
28 runs-on: ubuntu-latest
16 runs-on: ubuntu-latest
29
17
30 steps:
18 steps:
@@ -42,15 +30,31 @@ jobs:
42 - name: Run unit tests
30 - name: Run unit tests
43 run: ./gradlew testDebugUnitTest --no-daemon
31 run: ./gradlew testDebugUnitTest --no-daemon
44
32
33+ - name: Static analysis (detekt)
34+ run: ./gradlew detekt --no-daemon
35+
45 - name: Lint
36 - name: Lint
46 run: ./gradlew lintDebug --no-daemon
37 run: ./gradlew lintDebug --no-daemon
47
38
48 - name: Build debug APK
39 - name: Build debug APK
49 run: ./gradlew assembleDebug --no-daemon
40 run: ./gradlew assembleDebug --no-daemon
50
41
42+ - name: Build release APK
43+ run: ./gradlew assembleRelease --no-daemon
44+
51 - name: Upload debug APK
45 - name: Upload debug APK
52 uses: actions/upload-artifact@v4
46 uses: actions/upload-artifact@v4
53 with:
47 with:
54 name: opencode-debug.apk
48 name: opencode-debug.apk
55 path: app/build/outputs/apk/debug/*.apk
49 path: app/build/outputs/apk/debug/*.apk
56 if-no-files-found: error
50 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.ymldeleted−113
@@ -1,113 +0,0 @@
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,8 +1,6 @@
1# Gradle
1# Gradle
2.gradle/
2.gradle/
3build/
3build/
4−*.apk
5−*.aab
6
4
7# Kotlin
5# Kotlin
8.kotlin/
6.kotlin/
@@ -31,21 +29,3 @@ Thumbs.db
31
29
32# Nix
30# Nix
33result
31result
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.mdnew file+197
@@ -0,0 +1,197 @@
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.mddeleted−40
@@ -1,40 +0,0 @@
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+64−126
@@ -1,153 +1,91 @@
1# opencode-android
1# opencode-android
2
2
3−A native Android client for the [**opencode**](https://opencode.ai) server (the
3+A native Android client (Kotlin + Jetpack Compose) for the [`opencode serve`](https://opencode.ai/docs/server/) HTTP API.
4−`sst/opencode` AI coding agent), built with **Crux** (a Rust core), **Kotlin**,
5−and **Jetpack Compose**.
6
4
7−Connect to a running `opencode serve`, browse and create sessions, and chat with
5+Application id: `soy.iko.opencode`
8−the agent — assistant replies (text, reasoning, and tool calls) stream in live
9−over the server's SSE event stream.
10
6
11−<p align="center"><em>Connect → Sessions → Chat, with token-by-token streaming.</em></p>
7+## Set up a server and connect to it
12
8
13−## Architecture
9+This app is a client for the [`opencode serve`](https://opencode.ai/docs/server/) HTTP API, so you
14−
10+need an opencode server running somewhere reachable from your device.
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.
18−
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−└───────────────────────────────────────────────────────────────────────┘
33−```
34−
35−### Why a Kotlin port instead of a JNI `.so`?
36−
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:
41−
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`.
46−
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.
51−
52−## Project layout
53−
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
66−```
67−
68−## Getting started
69
11
70−### Dev shell
12+### 1. Start the server
71
13
72−Everything you need is provided by the Nix flake:
14+On the host that has your project and opencode installed:
73
15
74−```sh
16+```bash
75−nix develop # or: direnv allow (an .envrc with `use flake` is included)
17+opencode serve --hostname 0.0.0.0 --port 4096
76```
18```
77
19
78−This puts JDK 17, Gradle, `rustc`/`cargo`, and the Android SDK (platform 35,
20+Useful flags:
79−build-tools 35.0.0) on your `PATH`.
80
21
81−### Build & test
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) |
82
28
83−```sh
29+The server publishes an OpenAPI 3.1 spec at `http://<hostname>:<port>/doc`.
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
30
94−### Run against a server
31+### 2. (Optional) Add authentication
95
32
96−Start opencode's server on your machine or LAN:
33+opencode has no auth by default. To protect the server with HTTP Basic auth:
97
34
98−```sh
35+```bash
99−opencode serve --port 4096 # optionally behind OPENCODE_SERVER_PASSWORD
36+OPENCODE_SERVER_PASSWORD=your-password opencode serve --hostname 0.0.0.0 --port 4096
100```
37```
101
38
102−Install the APK, launch the app, and enter the server URL (e.g.
39+The username defaults to `opencode` (override with `OPENCODE_SERVER_USERNAME`). Enter these in the
103−`http://192.168.1.10:4096`). If the server was started with a password the app
40+app's add-server dialog.
104−detects the `401`, shows credential fields, and connects with HTTP Basic auth.
105
41
106−> Cleartext HTTP is enabled so you can reach a `http://` server on your LAN.
42+### 3. Connect from the app
107
43
108−## The opencode API this client speaks
44+**Over Wi-Fi / LAN** — add a server with base URL `http://<host-LAN-ip>:4096`.
109
45
110−- **Connect / auth** — `GET /global/health` probes reachability; a `401` triggers
46+**Over USB only** — forward the port to the device and point the app at localhost:
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
47
123−## Continuous integration & releases
48+```bash
124−
49+adb reverse tcp:4096 tcp:4096
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− ```
50+```
135
51
136− The tag name becomes the version (a manual run uses a `YYYY.MM.DD.HHMM`
52+Then use base URL `http://127.0.0.1:4096` in the app.
137− timestamp), stamped into the APK via `-PversionName/-PversionCode`.
138
53
139− **Signing:** set the repo secrets `OPENCODE_STORE_BASE64` (base64 of your
54+> The app permits cleartext HTTP because opencode serves plain `http://`. If you terminate TLS with
140− keystore), `OPENCODE_STORE_PASSWORD`, `OPENCODE_KEY_ALIAS`, and
55+> a reverse proxy, use an `https://` base URL instead.
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
56
144−## Notes & limitations
57+## Architecture
145
58
146−- Rendering covers text, reasoning, and tool-call parts. Interactive tool
59+- **Ktor 3 (OkHttp engine)** for REST + the long-lived `GET /event` SSE stream.
147− **permission** prompts (`permission.updated`) are not yet surfaced.
60+- **kotlinx.serialization** with sealed polymorphism for opencode's discriminated unions
148−- Model/agent selection uses the server's configured default (no picker yet).
61+ (`Part`/`type`, `MessageInfo`/`role`, `ToolState`/`status`, `BusEvent`/`type`). Unknown
149−- No `.so` is built in this environment (see *Architecture* above).
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)
83+```
150
84
151−## License
85+## Install & run
152
86
153−Apache-2.0.
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+```
app/build.gradle.kts+96−44
@@ -1,21 +1,21 @@
1plugins {
1plugins {
2 alias(libs.plugins.android.application)
2 alias(libs.plugins.android.application)
3 alias(libs.plugins.kotlin.android)
3 alias(libs.plugins.kotlin.android)
4− alias(libs.plugins.kotlin.compose)
5 alias(libs.plugins.kotlin.serialization)
4 alias(libs.plugins.kotlin.serialization)
5+ alias(libs.plugins.kotlin.compose)
6+ alias(libs.plugins.detekt)
6}
7}
7
8
8−// Version is overridable from the release workflow via
9+/**
9−// `-PversionName=... -PversionCode=...`; falls back to sensible defaults locally.
10+ * Monotonically increasing versionCode: the git commit count on HEAD, so each release
10−val appVersionName = (project.findProperty("versionName") as String?) ?: "0.1.0"
11+ * ships a strictly higher code (never a "downgrade" block) without manual bookkeeping.
11−val appVersionCode = (project.findProperty("versionCode") as String?)?.toIntOrNull() ?: 1
12+ * Falls back to 1 when git or the history isn't available (e.g. a plain source export).
12−
13+ */
13−// A release keystore is supplied by CI (decoded from a secret) via env vars.
14+val gitCommitCount: Int = runCatching {
14−// When it's absent — local builds, or CI before signing is configured — the
15+ providers.exec {
15−// release build falls back to the debug signing config so the APK is still
16+ commandLine("git", "rev-list", "--count", "HEAD")
16−// installable, rather than producing an uninstallable unsigned APK.
17+ }.standardOutput.asText.get().trim().toInt()
17−val releaseStorePath: String? = System.getenv("OPENCODE_STORE_FILE")
18+}.getOrDefault(1)
18−val hasReleaseKeystore = releaseStorePath != null && file(releaseStorePath).exists()
19
19
20android {
20android {
21 namespace = "soy.iko.opencode"
21 namespace = "soy.iko.opencode"
@@ -25,35 +25,37 @@ android {
25 applicationId = "soy.iko.opencode"
25 applicationId = "soy.iko.opencode"
26 minSdk = 26
26 minSdk = 26
27 targetSdk = 35
27 targetSdk = 35
28− versionCode = appVersionCode
28+ // Monotonically increasing across builds: derived from the git commit count so
29+ // each release ships a strictly higher versionCode without manual bookkeeping
30+ // (and never triggers a "downgrade" install block). Falls back to 1 when the
31+ // .git history isn't available (e.g. a plain source export).
32+ versionCode = gitCommitCount
29− versionName = appVersionName
33+ versionName = "0.1.0"
30−
31 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
34 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
32 }
35 }
33
36
34− signingConfigs {
37+ // Release builds are signed when keystore credentials are supplied via env vars
35− if (hasReleaseKeystore) {
38+ // (so CI can publish an installable APK). When the env vars are absent, the
39+ // release build falls back to unsigned — same behavior as before — so local
40+ // builds keep working without any keystore setup.
41+ val releaseSigning = providers.environmentVariable("OPENCODE_STORE_FILE").orNull?.let { path ->
36− create("release") {
42+ signingConfigs.create("release") {
37− storeFile = file(releaseStorePath!!)
43+ storeFile = file(path)
38− storePassword = System.getenv("OPENCODE_STORE_PASSWORD")
44+ storePassword = providers.environmentVariable("OPENCODE_STORE_PASSWORD").orNull
39− keyAlias = System.getenv("OPENCODE_KEY_ALIAS")
45+ keyAlias = providers.environmentVariable("OPENCODE_KEY_ALIAS").orNull
40− keyPassword = System.getenv("OPENCODE_KEY_PASSWORD")
46+ keyPassword = providers.environmentVariable("OPENCODE_KEY_PASSWORD").orNull
41− }
42 }
47 }
43 }
48 }
44
49
45 buildTypes {
50 buildTypes {
46 release {
51 release {
47 isMinifyEnabled = true
52 isMinifyEnabled = true
53+ isShrinkResources = true
48 proguardFiles(
54 proguardFiles(
49 getDefaultProguardFile("proguard-android-optimize.txt"),
55 getDefaultProguardFile("proguard-android-optimize.txt"),
50 "proguard-rules.pro",
56 "proguard-rules.pro",
51 )
57 )
52− signingConfig = if (hasReleaseKeystore) {
58+ releaseSigning?.let { signingConfig = it }
53− signingConfigs.getByName("release")
54− } else {
55− signingConfigs.getByName("debug")
56− }
57 }
59 }
58 }
60 }
59
61
@@ -62,42 +64,92 @@ android {
62 targetCompatibility = JavaVersion.VERSION_17
64 targetCompatibility = JavaVersion.VERSION_17
63 }
65 }
64
66
65− kotlinOptions {
67+ // Use the kotlin { compilerOptions {} } block (the kotlinOptions DSL is deprecated
66− jvmTarget = "17"
68+ // in favor of the type-safe extension exposed by the Kotlin Gradle plugin).
69+ kotlin {
70+ compilerOptions {
71+ jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
72+ }
67 }
73 }
68
74
69 buildFeatures {
75 buildFeatures {
70 compose = true
76 compose = true
71 }
77 }
72
78
79+ lint {
80+ // Fail CI on real errors. Warnings are reported but don't break the build so
81+ // minor/style issues don't gate development.
82+ abortOnError = true
83+ // Dependency upgrades are tracked by Renovate, so don't lint for them here.
84+ // ProduceStateDoesNotAssignValue false-positives on valid produceState calls that
85+ // assign `value` via a conditional/`withContext` expression (see FileViewScreen's
86+ // match-index scan), so it's disabled rather than worked around at each call site.
87+ disable += setOf("GradleDependency", "AndroidGradlePluginVersion", "ProduceStateDoesNotAssignValue")
88+ }
89+
73 packaging {
90 packaging {
74 resources {
91 resources {
75 excludes += "/META-INF/{AL2.0,LGPL2.1}"
92 excludes += "/META-INF/{AL2.0,LGPL2.1}"
76 }
93 }
77 }
94 }
95+
96+ testOptions {
97+ unitTests {
98+ // Return default values for Android framework stubs (e.g. Log.d, Context.getString)
99+ // instead of throwing "not mocked" exceptions, so JVM unit tests that brush
100+ // against Android APIs don't need Robolectric.
101+ isReturnDefaultValues = true
102+ isIncludeAndroidResources = true
103+ }
104+ }
78}
105}
79
106
80dependencies {
107dependencies {
81 implementation(libs.androidx.core.ktx)
108 implementation(libs.androidx.core.ktx)
82− implementation(libs.androidx.lifecycle.runtime.ktx)
109+ implementation(libs.androidx.core.splashscreen)
83− implementation(libs.androidx.lifecycle.runtime.compose)
84− implementation(libs.androidx.lifecycle.viewmodel.ktx)
85− implementation(libs.androidx.lifecycle.viewmodel.compose)
86 implementation(libs.androidx.activity.compose)
110 implementation(libs.androidx.activity.compose)
111+ implementation(libs.androidx.lifecycle.viewmodel.compose)
112+ implementation(libs.androidx.lifecycle.runtime.compose)
113+ implementation(libs.androidx.navigation.compose)
87
114
88− implementation(platform(libs.androidx.compose.bom))
115+ implementation(platform(libs.compose.bom))
89− implementation(libs.androidx.compose.ui)
116+ implementation(libs.compose.ui)
90− implementation(libs.androidx.compose.ui.graphics)
117+ implementation(libs.compose.ui.graphics)
91− implementation(libs.androidx.compose.ui.tooling.preview)
118+ implementation(libs.compose.ui.tooling.preview)
92− implementation(libs.androidx.compose.material3)
119+ implementation(libs.compose.material3)
93− implementation(libs.androidx.compose.material.icons.extended)
120+ implementation(libs.compose.material.icons.extended)
94− debugImplementation(libs.androidx.compose.ui.tooling)
121+ debugImplementation(libs.compose.ui.tooling)
122+
123+ implementation(libs.ktor.client.core)
124+ implementation(libs.ktor.client.okhttp)
125+ implementation(libs.ktor.client.content.negotiation)
126+ implementation(libs.ktor.serialization.kotlinx.json)
127+ implementation(libs.ktor.client.auth)
95
128
96− implementation(libs.kotlinx.coroutines.android)
97 implementation(libs.kotlinx.serialization.json)
129 implementation(libs.kotlinx.serialization.json)
98− implementation(libs.okhttp)
130+ implementation(libs.kotlinx.coroutines.android)
131+
132+ implementation(libs.androidx.datastore.preferences)
99− implementation(libs.okhttp.sse)
133+ implementation(libs.androidx.security.crypto)
134+ implementation(libs.androidx.biometric)
135+ implementation(libs.coil.compose)
136+ implementation(libs.markdown.renderer)
137+ implementation(libs.markdown.renderer.m3)
100
138
101 testImplementation(libs.junit)
139 testImplementation(libs.junit)
102 testImplementation(libs.kotlinx.coroutines.test)
140 testImplementation(libs.kotlinx.coroutines.test)
141+ testImplementation(libs.ktor.client.mock)
142+
143+ androidTestImplementation(libs.androidx.test.ext.junit)
144+ androidTestImplementation(libs.androidx.test.runner)
145+ androidTestImplementation(libs.androidx.test.core)
146+ androidTestImplementation(libs.kotlinx.coroutines.test)
147+}
148+
149+detekt {
150+ buildUponDefaultConfig = true
151+ // Carry forward the current baseline so CI only fails on *new* findings;
152+ // existing issues are grandfathered rather than blocking the migration.
153+ baseline = file("detekt-baseline.xml")
154+ config.setFrom("$rootDir/config/detekt/detekt.yml")
103}
155}
app/detekt-baseline.xmlnew file+17
@@ -0,0 +1,17 @@
1+<?xml version="1.0" ?>
2+<SmellBaseline>
3+ <ManuallySuppressedIssues/>
4+ <CurrentIssues>
5+ <ID>ComplexCondition:PartRenderer.kt$part.isImage &amp;&amp; imageContext != null &amp;&amp; (part.source != null || !part.url.isNullOrBlank())</ID>
6+ <ID>CyclomaticComplexMethod:AppContainer.kt$AppContainer$@OptIn(ExperimentalCoroutinesApi::class) private fun observeMessageActivity()</ID>
7+ <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>
8+ <ID>CyclomaticComplexMethod:CodeHighlighter.kt$private fun findNumberEnd(line: String, start: Int): Int</ID>
9+ <ID>CyclomaticComplexMethod:OpencodeApp.kt$@Composable fun OpencodeApp(container: AppContainer)</ID>
10+ <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>
11+ <ID>MatchingDeclarationName:DiffView.kt$DiffLine</ID>
12+ <ID>MatchingDeclarationName:RemoteImage.kt$ImageLoadContext</ID>
13+ <ID>TooManyFunctions:AppContainer.kt$AppContainer</ID>
14+ <ID>TooManyFunctions:ChatViewModel.kt$ChatViewModel : ViewModel</ID>
15+ <ID>TooManyFunctions:OpencodeApiClient.kt$OpencodeApiClient</ID>
16+ </CurrentIssues>
17+</SmellBaseline>
app/proguard-rules.pro+44−13
@@ -1,16 +1,47 @@
1−# kotlinx.serialization — keep generated serializers and @Serializable metadata.
1+# --- kotlinx.serialization ---
2+# The plugin generates serializers; keep them and the $$serializer companion members.
2−-keepattributes *Annotation*, InnerClasses
3+-keepattributes *Annotation*, InnerClasses, Signature, Exceptions
3-dontnote kotlinx.serialization.**
4-dontnote kotlinx.serialization.**
4−
5−-keepclassmembers class **$$serializer { *; }
5+-keepclassmembers class soy.iko.opencode.** {
6+ *** Companion;
7+}
6−-keepclasseswithmembers class * {
8+-keepclasseswithmembers class soy.iko.opencode.** {
7− @kotlinx.serialization.Serializable <methods>;
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;
8}
15}
16+# Keep the serializable model classes (reflection-free serializer lookup needs the class).
9−-if @kotlinx.serialization.Serializable class **
17+-keep @kotlinx.serialization.Serializable class soy.iko.opencode.data.model.** { *; }
10−-keep, allowobfuscation, allowshrinking class <1>
18+
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.**
11
44
12−# OkHttp / okio — platform-specific bits referenced reflectively.
45+# --- androidx.security.crypto pulls in Tink, which references compile-only
13−-dontwarn okhttp3.internal.platform.**
46+# com.google.errorprone annotations. They aren't on the runtime classpath.
14−-dontwarn org.conscrypt.**
47+-dontwarn com.google.errorprone.annotations.**
15−-dontwarn org.bouncycastle.**
16−-dontwarn org.openjsse.**
app/src/androidTest/java/soy/iko/opencode/ApplicationBootstrapTest.ktnew file+36
@@ -0,0 +1,36 @@
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.ktnew file+67
@@ -0,0 +1,67 @@
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.ktnew file+51
@@ -0,0 +1,51 @@
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+97−7
@@ -1,26 +1,116 @@
1<?xml version="1.0" encoding="utf-8"?>
1<?xml version="1.0" encoding="utf-8"?>
2−<manifest xmlns:android="http://schemas.android.com/apk/res/android">
2+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
3+ xmlns:tools="http://schemas.android.com/tools">
3
4
4 <uses-permission android:name="android.permission.INTERNET" />
5 <uses-permission android:name="android.permission.INTERNET" />
5 <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
6 <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" />
6
14
7 <application
15 <application
16+ android:name=".OpencodeApp"
8− android:allowBackup="true"
17+ android:allowBackup="false"
9− android:icon="@android:drawable/sym_def_app_icon"
18+ android:dataExtractionRules="@xml/data_extraction_rules"
19+ android:fullBackupContent="false"
20+ android:icon="@mipmap/ic_launcher"
10 android:label="@string/app_name"
21 android:label="@string/app_name"
22+ android:networkSecurityConfig="@xml/network_security_config"
11 android:supportsRtl="true"
23 android:supportsRtl="true"
12− android:theme="@style/Theme.OpencodeAndroid"
24+ android:theme="@style/Theme.OpencodeAndroid">
13− android:usesCleartextTraffic="true">
14
25
15 <activity
26 <activity
16 android:name=".MainActivity"
27 android:name=".MainActivity"
17 android:exported="true"
28 android:exported="true"
18− android:windowSoftInputMode="adjustResize"
29+ android:launchMode="singleTop"
30+ android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden|uiMode|navigation"
31+ android:enableOnBackInvokedCallback="true"
32+ tools:targetApi="33"
19− android:theme="@style/Theme.OpencodeAndroid">
33+ android:theme="@style/Theme.OpencodeAndroid.Splash">
20 <intent-filter>
34 <intent-filter>
21 <action android:name="android.intent.action.MAIN" />
35 <action android:name="android.intent.action.MAIN" />
22 <category android:name="android.intent.category.LAUNCHER" />
36 <category android:name="android.intent.category.LAUNCHER" />
23 </intent-filter>
37 </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>
24 </activity>
61 </activity>
62+
63+ <service
64+ android:name=".notification.RunForegroundService"
65+ android:exported="false"
66+ android:foregroundServiceType="dataSync" />
67+
68+ <!-- Handles notification action buttons (permission responses, inline reply). Not
69+ exported: only the app's own PendingIntents target it. -->
70+ <receiver
71+ android:name=".notification.NotificationActionReceiver"
72+ android:exported="false" />
73+
74+ <!-- Home-screen widget listing recent sessions. -->
75+ <receiver
76+ android:name=".platform.SessionsWidgetProvider"
77+ android:exported="true">
78+ <intent-filter>
79+ <action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
80+ </intent-filter>
81+ <meta-data
82+ android:name="android.appwidget.provider"
83+ android:resource="@xml/sessions_widget_info" />
84+ </receiver>
85+
86+ <!-- Serves the widget's session list rows. Bound by the system with BIND_REMOTEVIEWS. -->
87+ <service
88+ android:name=".platform.SessionsWidgetService"
89+ android:permission="android.permission.BIND_REMOTEVIEWS"
90+ android:exported="false" />
91+
92+ <!-- Quick-settings tile: start a new session. Bound by the system. -->
93+ <service
94+ android:name=".platform.NewSessionTileService"
95+ android:icon="@drawable/ic_launcher_foreground"
96+ android:label="@string/tile_new_session"
97+ android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
98+ android:exported="true">
99+ <intent-filter>
100+ <action android:name="android.service.quicksettings.action.QS_TILE" />
101+ </intent-filter>
102+ </service>
103+
104+ <!-- Serves camera-capture temp files (cache/captures) to the system camera app. -->
105+ <provider
106+ android:name="androidx.core.content.FileProvider"
107+ android:authorities="${applicationId}.fileprovider"
108+ android:exported="false"
109+ android:grantUriPermissions="true">
110+ <meta-data
111+ android:name="android.support.FILE_PROVIDER_PATHS"
112+ android:resource="@xml/file_paths" />
113+ </provider>
25 </application>
114 </application>
115+
26</manifest>
116</manifest>
app/src/main/java/soy/iko/opencode/CoreViewModel.ktdeleted−66
@@ -1,66 +0,0 @@
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+216−15
@@ -1,31 +1,232 @@
1package soy.iko.opencode
1package soy.iko.opencode
2
2
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
3import android.os.Bundle
8import android.os.Bundle
4−import androidx.activity.ComponentActivity
9+import android.view.WindowManager
5−import androidx.activity.SystemBarStyle
10+import androidx.core.content.IntentCompat
11+import soy.iko.opencode.data.network.NetworkConfig
6import androidx.activity.compose.setContent
12import androidx.activity.compose.setContent
7import androidx.activity.enableEdgeToEdge
13import androidx.activity.enableEdgeToEdge
14+import androidx.activity.result.contract.ActivityResultContracts
15+import androidx.compose.foundation.isSystemInDarkTheme
16+import androidx.compose.runtime.LaunchedEffect
8import androidx.compose.runtime.getValue
17import androidx.compose.runtime.getValue
18+import androidx.compose.runtime.remember
19+import kotlinx.coroutines.flow.combine
20+import androidx.core.content.ContextCompat
21+import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
22+import androidx.fragment.app.FragmentActivity
9import androidx.lifecycle.compose.collectAsStateWithLifecycle
23import androidx.lifecycle.compose.collectAsStateWithLifecycle
10−import androidx.lifecycle.viewmodel.compose.viewModel
24+import soy.iko.opencode.data.repo.ThemeMode
25+import soy.iko.opencode.ui.AppLockGate
11−import soy.iko.opencode.ui.OpencodeApp
26+import soy.iko.opencode.ui.OpencodeApp as OpencodeAppUi
12import soy.iko.opencode.ui.theme.OpencodeTheme
27import soy.iko.opencode.ui.theme.OpencodeTheme
13
28
29+// FragmentActivity (not ComponentActivity) so androidx.biometric BiometricPrompt can attach
30+// for the app-lock gate; FragmentActivity extends ComponentActivity, so Compose/edge-to-edge
31+// /activity-result APIs are unaffected.
14−class MainActivity : ComponentActivity() {
32+class MainActivity : FragmentActivity() {
33+
34+ private val requestNotificationPermission = registerForActivityResult(
35+ ActivityResultContracts.RequestPermission(),
36+ ) { /* Result is best-effort; notifications are silently skipped if denied. */ }
37+
38+ private var shareIntentHandled = false
39+ private var openSessionHandled = false
40+ private var newSessionHandled = false
41+ // Track whether the notification-permission prompt has been shown for this Activity
42+ // instance. onCreate only requests when savedInstanceState == null (a fresh process),
43+ // but an Activity recreated by a config change NOT listed in the manifest's configChanges
44+ // (e.g. a locale change) has a non-null savedInstanceState and would otherwise skip the
45+ // prompt forever. onResume re-requests once per instance when still ungranted so the user
46+ // is never silently left without notifications after such a recreation.
47+ private var notificationPermissionRequested = false
48+
15 override fun onCreate(savedInstanceState: Bundle?) {
49 override fun onCreate(savedInstanceState: Bundle?) {
16− enableEdgeToEdge(
50+ val splash = installSplashScreen()
17− statusBarStyle = SystemBarStyle.auto(
18− lightScrim = 0xFF0F1115.toInt(),
19− darkScrim = 0xFF0F1115.toInt(),
20− ),
21− )
22 super.onCreate(savedInstanceState)
51 super.onCreate(savedInstanceState)
52+ enableEdgeToEdge()
53+ savedInstanceState?.let {
54+ shareIntentHandled = it.getBoolean(KEY_SHARE_HANDLED, false)
55+ openSessionHandled = it.getBoolean(KEY_OPEN_SESSION_HANDLED, false)
56+ newSessionHandled = it.getBoolean(KEY_NEW_SESSION_HANDLED, false)
57+ }
58+ val container = (application as OpencodeApp).container
59+ handleIntent(intent)
60+ if (savedInstanceState == null) {
61+ maybeRequestNotificationPermission()
62+ } else {
63+ // Restored instance: the permission may have been requested pre-recreation, but
64+ // we can't know the result from here. onResume will re-check and request if still
65+ // ungranted (once per Activity instance).
66+ notificationPermissionRequested = savedInstanceState.getBoolean(KEY_NOTIF_PERM_REQUESTED, false)
67+ }
68+ // Hold the splash until the persisted theme and app-lock settings load, so we never
69+ // paint a frame with the defaults (dynamicColor=true / SYSTEM, app lock off) that then
70+ // snap to the user's real choice — including a brief unlocked, un-FLAG_SECURE'd frame
71+ // before app lock resolves. Mirrors the SettingsScreen's null-gate.
72+ var settingsLoaded = false
73+ splash.setKeepOnScreenCondition { !settingsLoaded }
23 setContent {
74 setContent {
24− OpencodeTheme {
75+ val theme by remember(container) {
25− val vm: CoreViewModel = viewModel()
76+ container.settingsStore.themeMode
26− val state by vm.core.view.collectAsStateWithLifecycle()
77+ .combine(container.settingsStore.dynamicColor, ::Pair)
27− OpencodeApp(state = state, dispatch = vm.core::dispatch)
78+ }.collectAsStateWithLifecycle(initialValue = null)
79+ val appLock by container.settingsStore.appLock.collectAsStateWithLifecycle(initialValue = null)
80+ LaunchedEffect(theme, appLock) { if (theme != null && appLock != null) settingsLoaded = true }
81+ // Hide the app's content from screenshots / the recents thumbnail while app lock is
82+ // enabled — and while its value is still unknown (null) at cold start, so a protected
83+ // launch can't flash into the recents thumbnail before the setting resolves.
84+ LaunchedEffect(appLock) {
85+ if (appLock != false) window.addFlags(WindowManager.LayoutParams.FLAG_SECURE)
86+ else window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
87+ }
88+ val themePair = theme
89+ val locked = appLock
90+ if (themePair != null && locked != null) {
91+ val (themeMode, dynamicColor) = themePair
92+ val dark = when (themeMode) {
93+ ThemeMode.SYSTEM -> isSystemInDarkTheme()
94+ ThemeMode.LIGHT -> false
95+ ThemeMode.DARK -> true
96+ }
97+ OpencodeTheme(darkTheme = dark, dynamicColor = dynamicColor) {
98+ AppLockGate(enabled = locked) {
99+ OpencodeAppUi(container = container)
100+ }
101+ }
28 }
102 }
29 }
103 }
30 }
104 }
105+
106+ override fun onNewIntent(intent: Intent) {
107+ super.onNewIntent(intent)
108+ setIntent(intent)
109+ shareIntentHandled = false
110+ openSessionHandled = false
111+ newSessionHandled = false
112+ handleIntent(intent)
113+ }
114+
115+ // Track real foreground state so background notifications (permission requests,
116+ // completions, errors) still fire for a session the user "has open" but has walked
117+ // away from — the chat screen isn't disposed when the phone is locked, so
118+ // container.currentSession stays set and can't be used as a foreground signal.
119+ override fun onStart() {
120+ super.onStart()
121+ (application as OpencodeApp).container.setForeground(true)
122+ }
123+
124+ override fun onStop() {
125+ super.onStop()
126+ (application as OpencodeApp).container.setForeground(false)
127+ }
128+
129+ override fun onResume() {
130+ super.onResume()
131+ // Re-check on resume: an Activity recreated by a config change not in configChanges
132+ // (e.g. locale) has savedInstanceState != null, so onCreate skipped the request. This
133+ // ensures the user is prompted once per Activity instance when still ungranted rather
134+ // than silently losing all notifications.
135+ maybeRequestNotificationPermission()
136+ }
137+
138+ /** Capture text shared from another app so it can be prefilled into a session draft,
139+ * and session ids from notification taps / deep links so we can open them. */
140+ private fun handleIntent(intent: Intent?) {
141+ val container = (application as OpencodeApp).container
142+ val action = intent?.action
143+ // "New session" from a launcher shortcut or the quick-settings tile. Guarded so a
144+ // process-death restore (which re-delivers the intent) doesn't spawn a second session.
145+ if (action == ACTION_NEW_SESSION && !newSessionHandled) {
146+ container.requestNewSession()
147+ newSessionHandled = true
148+ }
149+ if ((action == Intent.ACTION_SEND || action == Intent.ACTION_SEND_MULTIPLE) && !shareIntentHandled) {
150+ val type = intent.type.orEmpty()
151+ if (action == Intent.ACTION_SEND && type.startsWith("image/")) {
152+ // A single shared image → stage as an attachment in the next opened session.
153+ val uri = IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
154+ if (uri != null) {
155+ container.setPendingSharedMedia(listOf(uri.toString()))
156+ shareIntentHandled = true
157+ }
158+ } else if (action == Intent.ACTION_SEND_MULTIPLE && type.startsWith("image/")) {
159+ val uris = IntentCompat.getParcelableArrayListExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
160+ ?.take(NetworkConfig.maxAttachments)
161+ .orEmpty()
162+ if (uris.isNotEmpty()) {
163+ container.setPendingSharedMedia(uris.map { it.toString() })
164+ shareIntentHandled = true
165+ }
166+ } else {
167+ val text = intent.getStringExtra(Intent.EXTRA_TEXT)
168+ ?.takeIf { it.isNotBlank() }
169+ ?.take(10_000) // cap to prevent unbounded memory usage from malicious shares
170+ if (text != null) {
171+ container.setPendingShare(text)
172+ shareIntentHandled = true
173+ }
174+ }
175+ }
176+ // Deep link: opencode://session/{sessionId} (or the EXTRA_SESSION_ID extra).
177+ val deepLinkId = intent?.takeIf { it.action == Intent.ACTION_VIEW }
178+ ?.data
179+ ?.takeIf { it.host == "session" }
180+ ?.lastPathSegment
181+ val id = deepLinkId ?: intent?.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() }
182+ // Validate: session ids are opaque server-generated identifiers. Reject anything
183+ // with path separators or other traversal/control characters so a malicious deep
184+ // link can't inject path components into the REST URL path.
185+ // Guard against re-firing the retained ACTION_VIEW intent after process-death
186+ // restore: onCreate calls handleIntent again with the original intent, so without
187+ // this flag (mirroring shareIntentHandled) the session would re-open unexpectedly.
188+ if (!openSessionHandled) {
189+ id?.takeIf { it.isNotBlank() && it.matches(VALID_SESSION_ID) }?.let {
190+ container.requestOpenSession(it)
191+ openSessionHandled = true
192+ }
193+ }
194+ }
195+
196+ companion object {
197+ /** Intent extra carrying a session id to open (notifications / deep links / widget). */
198+ const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
199+
200+ /** Action for the "New session" launcher shortcut and quick-settings tile. */
201+ const val ACTION_NEW_SESSION = "soy.iko.opencode.action.NEW_SESSION"
202+
203+ /** Session ids are alphanumeric with dashes/underscores. Reject path traversal. */
204+ private val VALID_SESSION_ID = Regex("[A-Za-z0-9_-]+")
205+
206+ private const val KEY_SHARE_HANDLED = "shareIntentHandled"
207+ private const val KEY_OPEN_SESSION_HANDLED = "openSessionHandled"
208+ private const val KEY_NEW_SESSION_HANDLED = "newSessionHandled"
209+ private const val KEY_NOTIF_PERM_REQUESTED = "notificationPermissionRequested"
210+ }
211+
212+ override fun onSaveInstanceState(outState: Bundle) {
213+ super.onSaveInstanceState(outState)
214+ outState.putBoolean(KEY_SHARE_HANDLED, shareIntentHandled)
215+ outState.putBoolean(KEY_OPEN_SESSION_HANDLED, openSessionHandled)
216+ outState.putBoolean(KEY_NEW_SESSION_HANDLED, newSessionHandled)
217+ outState.putBoolean(KEY_NOTIF_PERM_REQUESTED, notificationPermissionRequested)
218+ }
219+
220+ /** Ask for POST_NOTIFICATIONS on Android 13+ so run/completion notifications show.
221+ * Idempotent per Activity instance via [notificationPermissionRequested]. */
222+ private fun maybeRequestNotificationPermission() {
223+ if (notificationPermissionRequested) return
224+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return
225+ val granted = ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) ==
226+ PackageManager.PERMISSION_GRANTED
227+ if (!granted) {
228+ notificationPermissionRequested = true
229+ requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
230+ }
231+ }
31}
232}
app/src/main/java/soy/iko/opencode/OpencodeApp.ktnew file+136
@@ -0,0 +1,136 @@
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.ktdeleted−75
@@ -1,75 +0,0 @@
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.ktdeleted−668
@@ -1,668 +0,0 @@
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.ktdeleted−148
@@ -1,148 +0,0 @@
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.ktdeleted−63
@@ -1,63 +0,0 @@
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.ktdeleted−77
@@ -1,77 +0,0 @@
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.ktnew file+32
@@ -0,0 +1,32 @@
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+ val name: String,
11+ val description: String? = null,
12+ val mode: String? = null,
13+ val builtIn: Boolean = false,
14+ val prompt: String? = null,
15+) {
16+ val isPrimary: Boolean get() = mode == "primary"
17+ val displayDescription: String get() = description?.takeIf { it.isNotBlank() } ?: "Custom agent"
18+}
19+
20+/** Entry from `GET /command`. */
21+@Immutable
22+@Serializable
23+data class Command(
24+ val name: String,
25+ val description: String? = null,
26+ val agent: String? = null,
27+ val model: String? = null,
28+ val template: String = "",
29+ val subtask: Boolean = false,
30+) {
31+ val displayDescription: String get() = description?.takeIf { it.isNotBlank() } ?: template.take(80)
32+}
app/src/main/java/soy/iko/opencode/data/model/BusEvent.ktnew file+109
@@ -0,0 +1,109 @@
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.ktnew file+41
@@ -0,0 +1,41 @@
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.ktnew file+38
@@ -0,0 +1,38 @@
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+) {
14+ val isDirectory: Boolean get() = type == "directory"
15+}
16+
17+/** Response of `GET /file/content?path=`. */
18+@Immutable
19+@Serializable
20+data class FileContent(
21+ val type: String? = null, // "text" | "binary"
22+ val content: String = "",
23+ val diff: String? = null,
24+ val mimeType: String? = null,
25+ val encoding: String? = null,
26+) {
27+ val isBinary: Boolean get() = type == "binary" || encoding == "base64"
28+}
29+
30+/** Entry from `GET /file/status` (VCS status). */
31+@Immutable
32+@Serializable
33+data class FileStatusEntry(
34+ val path: String = "",
35+ val added: Int = 0,
36+ val removed: Int = 0,
37+ val status: String? = null, // "added" | "deleted" | "modified"
38+)
app/src/main/java/soy/iko/opencode/data/model/Message.ktnew file+65
@@ -0,0 +1,65 @@
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.JsonObject
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+ val error: JsonObject? = null,
43+ override val time: TimeInfo? = null,
44+) : MessageInfo {
45+ /** A run is complete once the server stamps a completion time. */
46+ val isComplete: Boolean get() = time?.completed != null
47+}
48+
49+/** Fallback for an unrecognized `role`, so new server roles never crash decoding. */
50+@Immutable
51+@Serializable
52+@SerialName("__unknown")
53+data class UnknownMessage(
54+ override val id: String = "",
55+ override val sessionID: String = "",
56+ override val time: TimeInfo? = null,
57+) : MessageInfo
58+
59+/** A message together with its parts — the shape returned by message endpoints and the prompt response. */
60+@Immutable
61+@Serializable
62+data class MessageWithParts(
63+ val info: MessageInfo,
64+ val parts: List<Part> = emptyList(),
65+)
app/src/main/java/soy/iko/opencode/data/model/Part.ktnew file+117
@@ -0,0 +1,117 @@
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.ktnew file+49
@@ -0,0 +1,49 @@
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.JsonObject
7+import kotlinx.serialization.json.JsonArray
8+import kotlinx.serialization.json.JsonPrimitive
9+
10+/**
11+ * A permission request from `permission.updated`. The tool detail lives in [type] +
12+ * [pattern] + [metadata]; [pattern] is a string or array of strings on the wire, so
13+ * it's kept as a [JsonElement] and flattened for display.
14+ */
15+@Immutable
16+@Serializable
17+data class Permission(
18+ val id: String,
19+ val sessionID: String = "",
20+ val type: String? = null,
21+ val pattern: JsonElement? = null,
22+ val messageID: String? = null,
23+ val callID: String? = null,
24+ val title: String? = null,
25+ val metadata: JsonObject? = null,
26+ val time: TimeInfo? = null,
27+) {
28+ val patternText: String?
29+ get() = when (val p = pattern) {
30+ null -> null
31+ is JsonPrimitive -> p.content
32+ // Only a JSON array is flattened; any other shape (e.g. an object) would make
33+ // the previous unguarded `p.jsonArray` throw during composition, so treat it
34+ // as "no displayable pattern" instead.
35+ is JsonArray -> p.mapNotNull { (it as? JsonPrimitive)?.content }.joinToString(", ").ifEmpty { null }
36+ else -> null
37+ }
38+}
39+
40+/** Allowed values for the permission response body. */
41+enum class PermissionResponse(val wire: String) {
42+ ONCE("once"),
43+ ALWAYS("always"),
44+ REJECT("reject"),
45+}
46+
47+/** Body for `POST /session/:id/permissions/:permissionID`. */
48+@Serializable
49+data class PermissionReplyBody(val response: String)
app/src/main/java/soy/iko/opencode/data/model/Project.ktnew file+32
@@ -0,0 +1,32 @@
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.ktnew file+68
@@ -0,0 +1,68 @@
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.ktnew file+77
@@ -0,0 +1,77 @@
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+ val id: String,
18+ val name: String? = null,
19+ val models: Map<String, ModelInfo> = emptyMap(),
20+) {
21+ val displayName: String get() = name?.takeIf { it.isNotBlank() } ?: id
22+}
23+
24+@Immutable
25+@Serializable
26+data class ModelInfo(
27+ val id: String? = null,
28+ val name: String? = null,
29+) {
30+ fun displayName(modelKey: String): String =
31+ name?.takeIf { it.isNotBlank() } ?: id ?: modelKey
32+}
33+
34+/** A flattened provider/model selection used by the prompt body and the model picker. */
35+@Immutable
36+@Serializable
37+data class ModelRef(
38+ val providerID: String,
39+ val modelID: String,
40+)
41+
42+/** A single selectable entry in the model picker, flattened from [ProvidersResponse]. */
43+@Immutable
44+data class ModelOption(
45+ val providerID: String,
46+ val modelID: String,
47+ val providerLabel: String,
48+ val modelLabel: String,
49+) {
50+ val ref: ModelRef get() = ModelRef(providerID, modelID)
51+}
52+
53+/** Flatten providers into a sorted list of pickable options. */
54+fun ProvidersResponse.toOptions(): List<ModelOption> =
55+ providers.flatMap { provider ->
56+ provider.models.map { (modelKey, info) ->
57+ ModelOption(
58+ providerID = provider.id,
59+ modelID = info.id ?: modelKey,
60+ providerLabel = provider.displayName,
61+ modelLabel = info.displayName(modelKey),
62+ )
63+ }
64+ }.sortedWith(
65+ // CASE_INSENSITIVE_ORDER compares in place; compareBy { it.x.lowercase() } would
66+ // allocate a fresh lowercased string on every comparison (O(N log N) allocations).
67+ compareBy<ModelOption, String>(String.CASE_INSENSITIVE_ORDER) { it.providerLabel }
68+ .thenBy(String.CASE_INSENSITIVE_ORDER) { it.modelLabel },
69+ )
70+
71+/** The default option to preselect: the server's default for some provider, else the first model. */
72+fun ProvidersResponse.defaultOption(options: List<ModelOption> = toOptions()): ModelOption? {
73+ default.entries.firstOrNull()?.let { (providerID, modelID) ->
74+ options.firstOrNull { it.providerID == providerID && it.modelID == modelID }?.let { return it }
75+ }
76+ return options.firstOrNull()
77+}
app/src/main/java/soy/iko/opencode/data/model/Search.ktnew file+90
@@ -0,0 +1,90 @@
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.ktnew file+27
@@ -0,0 +1,27 @@
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.ktnew file+65
@@ -0,0 +1,65 @@
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+ val id: String,
11+ val parentID: String? = null,
12+ val title: String? = null,
13+ val version: String? = null,
14+ val time: TimeInfo? = null,
15+ // The absolute worktree/directory the agent runs in for this session. Set server-side
16+ // from the `directory` query param at creation; persisted per session so the client
17+ // needn't re-send it on follow-up calls (see OpencodeApiClient.createSession).
18+ val directory: String? = null,
19+ val projectID: String? = null,
20+ // Present only while the session is shared (POST /session/:id/share); cleared on unshare.
21+ val share: Share? = null,
22+ // Present only while a revert checkpoint is active (POST /session/:id/revert); cleared on unrevert.
23+ val revert: Revert? = null,
24+) {
25+ val displayTitle: String get() = title?.takeIf { it.isNotBlank() } ?: "Untitled session"
26+
27+ /** A compact form of [directory] for list/subtitle display: the last path segment,
28+ * or the full path when it has no separators. Null/blank when no directory is set. */
29+ val displayDirectory: String?
30+ get() = directory?.takeIf { it.isNotBlank() }?.trimEnd('/')?.substringAfterLast('/')?.takeIf { it.isNotBlank() }
31+
32+ /** True while the session has an active public share link. */
33+ val isShared: Boolean get() = !share?.url.isNullOrBlank()
34+
35+ /** True while a revert checkpoint is active (messages after it are hidden server-side). */
36+ val isReverted: Boolean get() = revert != null
37+
38+ /** Public share link for the session, from `POST /session/:id/share`. */
39+ @Immutable
40+ @Serializable
41+ data class Share(val url: String = "")
42+
43+ /** The active revert checkpoint, from `POST /session/:id/revert`. */
44+ @Immutable
45+ @Serializable
46+ data class Revert(
47+ val messageID: String = "",
48+ val partID: String? = null,
49+ val snapshot: String? = null,
50+ val diff: String? = null,
51+ )
52+}
53+
54+/** Body for `POST /session`. */
55+@Serializable
56+data class CreateSessionRequest(
57+ val parentID: String? = null,
58+ val title: String? = null,
59+)
60+
61+/** Body for `PATCH /session/:id`. */
62+@Serializable
63+data class UpdateSessionRequest(
64+ val title: String? = null,
65+)
app/src/main/java/soy/iko/opencode/data/model/Todo.ktnew file+84
@@ -0,0 +1,84 @@
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+fun TodoItem.statusEnum(): TodoStatus = when (status.trim().lowercase()) {
31+ "pending", "todo", "" -> TodoStatus.PENDING
32+ "in_progress", "in-progress", "inprogress", "running", "active" -> TodoStatus.IN_PROGRESS
33+ "completed", "complete", "done" -> TodoStatus.COMPLETED
34+ "cancelled", "canceled", "skipped" -> TodoStatus.CANCELLED
35+ else -> TodoStatus.UNKNOWN
36+}
37+
38+@Serializable
39+private data class TodoWriteInput(val todos: List<TodoItem> = emptyList())
40+
41+// A lenient decoder mirroring OpencodeJson's resilience (unknown keys ignored, nulls
42+// coerced to defaults) without pulling the network layer into data/model.
43+private val todoJson = Json {
44+ ignoreUnknownKeys = true
45+ isLenient = true
46+ coerceInputValues = true
47+ explicitNulls = false
48+}
49+
50+/**
51+ * Extract the todo list from a `todowrite` tool call's input JSON. The tool sends the
52+ * full list on every write, so a single call's input is a complete plan snapshot.
53+ * Accepts either the documented `{ "todos": [...] }` object or a bare array, and returns
54+ * an empty list on anything unparseable.
55+ */
56+fun parseTodos(input: JsonElement?): List<TodoItem> {
57+ if (input == null) return emptyList()
58+ return runCatching {
59+ todoJson.decodeFromJsonElement(TodoWriteInput.serializer(), input).todos
60+ }.recoverCatching {
61+ todoJson.decodeFromJsonElement(ListSerializer(TodoItem.serializer()), input)
62+ }.getOrDefault(emptyList())
63+}
64+
65+/**
66+ * The current task plan for a conversation: the todos of the most recent `todowrite`
67+ * tool call that carries a non-empty, parseable list. opencode rewrites the whole list
68+ * on every write, so a single call is a complete snapshot; scanning newest-first and
69+ * skipping empties avoids a mid-stream write (input not yet arrived) briefly blanking a
70+ * plan that's still valid.
71+ */
72+fun currentTodoPlan(messages: List<MessageWithParts>): List<TodoItem> {
73+ for (mi in messages.indices.reversed()) {
74+ val parts = messages[mi].parts
75+ for (pi in parts.indices.reversed()) {
76+ val part = parts[pi]
77+ if (part is ToolPart && part.tool.equals(TODO_WRITE_TOOL, ignoreCase = true)) {
78+ val todos = parseTodos(part.state.inputElement())
79+ if (todos.isNotEmpty()) return todos
80+ }
81+ }
82+ }
83+ return emptyList()
84+}
app/src/main/java/soy/iko/opencode/data/model/ToolState.ktnew file+62
@@ -0,0 +1,62 @@
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.ktnew file+316
@@ -0,0 +1,316 @@
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 }
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+ /** Returns true if the exception is a non-retryable SSE failure that should stop the retry
72+ * loop and park until an explicit reconnect. This covers any 4xx except 408 (Request Timeout)
73+ * and 429 (Too Many Requests), which are transient — mirroring the REST layer's withRetry
74+ * classification (401/403 bad credentials, 404 renamed/removed endpoint, 400 bad request, ...
75+ * won't succeed on retry).
76+ * ktor's SSE builder wraps establishment failures in [io.ktor.client.plugins.sse.SSEClientException]
77+ * (an IllegalStateException, NOT a ClientRequestException), attaching the response and keeping any
78+ * underlying ClientRequestException only as the cause — so we inspect the wrapper's response status
79+ * and walk the whole cause chain rather than matching a single exception type. */
80+ private fun isNonRetryableSseFailure(e: Throwable): Boolean =
81+ generateSequence(e as Throwable?) { it.cause.takeIf { c -> c !== it } }
82+ .take(16)
83+ .mapNotNull { t ->
84+ when (t) {
85+ is io.ktor.client.plugins.sse.SSEClientException -> t.response?.status?.value
86+ is io.ktor.client.plugins.ClientRequestException -> t.response.status.value
87+ else -> null
88+ }
89+ }
90+ // Non-408/429 4xx = permanent; 408 and 429 stay in the transient retry path.
91+ .any { it in 400..499 && it != 408 && it != 429 }
92+
93+ /** Request an immediate reconnect, skipping any in-progress backoff. */
94+ open fun triggerReconnect() { reconnectSignal.trySend(Unit) }
95+
96+ /**
97+ * Read SSE events from [events], racing each read against an idle-timeout watchdog.
98+ * Throws [IOException] on idle timeout so the outer reconnect loop fires; breaks on
99+ * clean stream close or scope cancellation.
100+ *
101+ * If the idle watchdog tripped because a [triggerReconnect] signal was consumed
102+ * (rather than a genuine idle timeout), [onReconnectConsumed] is invoked so the outer
103+ * reconnect loop can reconnect immediately instead of waiting out the backoff — the
104+ * signal is already consumed, so the backoff `select` couldn't be cut short.
105+ */
106+ private suspend fun readSseEvents(
107+ scope: CoroutineScope,
108+ events: kotlinx.coroutines.channels.ReceiveChannel<ServerSentEvent>,
109+ onReconnectConsumed: () -> Unit,
110+ send: suspend (BusEvent) -> Unit,
111+ ) {
112+ while (scope.isActive) {
113+ // The OkHttp engine has readTimeout=0 (the SSE stream must never time out on
114+ // read), so a socket the peer never closed would otherwise hang until OS TCP
115+ // keepalive kicks in — minutes to hours on stock Linux. If nothing arrives
116+ // within the idle timeout, treat the connection as half-open and drop it.
117+ var consumedReconnectSignal = false
118+ val result: ChannelResult<ServerSentEvent>? = select {
119+ events.onReceiveCatching { it }
120+ onTimeout(idleTimeoutMs) { null }
121+ // A triggerReconnect() issued while a read is active must interrupt it now:
122+ // return the same null sentinel as the idle timeout so a half-open socket
123+ // (no TCP RST) is dropped and reconnected promptly instead of waiting out
124+ // idleTimeoutMs (~90s).
125+ reconnectSignal.onReceive { consumedReconnectSignal = true; null }
126+ }
127+ if (result == null) {
128+ if (consumedReconnectSignal) onReconnectConsumed()
129+ throw IOException("SSE read dropped (idle timeout or reconnect requested), reconnecting")
130+ }
131+ val sse = result.getOrNull()
132+ if (sse == null) {
133+ // The producer channel is closed. A non-null cause means `incoming`
134+ // failed (e.g. a socket reset bridged from the producer) — rethrow it so
135+ // the outer loop logs and retries with backoff. A clean close (null
136+ // cause) just ends this attempt and lets the loop reconnect.
137+ result.exceptionOrNull()?.let { throw it }
138+ break
139+ }
140+ val data = sse.data
141+ if (data == null) {
142+ // A comment or keep-alive event (no data field) resets the idle watchdog
143+ // — servers send `: ping` comments to keep the connection alive through
144+ // proxies. Without this, a server that only sends comments would still
145+ // hit the idle timeout and reconnect unnecessarily.
146+ continue
147+ }
148+ val event = try {
149+ OpencodeJson.decodeFromString(BusEvent.serializer(), data)
150+ } catch (e: kotlinx.serialization.SerializationException) {
151+ Log.d("EventStream", "Skipping unparseable SSE event", e)
152+ continue
153+ }
154+ if (_state.value != ConnectionState.Connected) {
155+ _state.value = ConnectionState.Connected
156+ }
157+ send(event)
158+ }
159+ }
160+
161+ private fun stream(): Flow<BusEvent> = channelFlow {
162+ val scope = this
163+ // Drain any stale reconnect signal from a previous collection cycle so it
164+ // doesn't spuriously reset the backoff on startup (WhileSubscribed may have
165+ // stopped the upstream while triggerReconnect() queued a signal).
166+ while (reconnectSignal.tryReceive().isSuccess) { /* drain */ }
167+ var backoffMs = initialBackoffMs
168+ while (isActive) {
169+ _state.value = ConnectionState.Connecting
170+ // Set by readSseEvents when a triggerReconnect() signal interrupted an active
171+ // read. The signal is consumed to drop the socket, so the backoff select below
172+ // can't also see it — without this flag the reconnect would wait the full
173+ // backoff despite the caller asking for an immediate reconnect (network
174+ // handoff, manual refresh, ...).
175+ var reconnectConsumedDuringRead = false
176+ try {
177+ // The SSE stream is long-lived: disable the request-level and socket
178+ // timeouts (the client default is 60s for REST) in the request config
179+ // so the connection isn't killed mid-stream. The idle-timeout watchdog
180+ // below handles half-open connections instead.
181+ client.sse(
182+ "event",
183+ request = {
184+ timeout {
185+ requestTimeoutMillis = Long.MAX_VALUE
186+ // Disable the socket (read) timeout entirely for the SSE stream.
187+ // A finite value would kill the connection whenever the server
188+ // is quiet for longer than that (idle sessions, slow agent runs),
189+ // causing a reconnect storm. Half-open detection is handled by
190+ // the idle-timeout watchdog below plus OkHttp's pingInterval on
191+ // HTTP/2 connections.
192+ socketTimeoutMillis = Long.MAX_VALUE
193+ }
194+ },
195+ ) {
196+ // The SSE block is open — the connection is established. Mark as
197+ // Connected immediately so downstream re-seed logic fires even when
198+ // the server only sends keep-alive comments (no data events).
199+ if (_state.value != ConnectionState.Connected) {
200+ _state.value = ConnectionState.Connected
201+ }
202+ // Clear any stale reconnect signal from a triggerReconnect() call
203+ // made while the previous stream was healthy, so it doesn't suppress
204+ // the next legitimate backoff.
205+ while (reconnectSignal.tryReceive().isSuccess) { /* drain */ }
206+ // Bridge ktor's `incoming` flow to a channel so each element can be
207+ // raced against the idle-timeout watchdog via select. We collect it
208+ // ourselves instead of `incoming.produceIn(scope)`: produceIn launches
209+ // the collector as a child of this channelFlow, so when `incoming`
210+ // throws on an abnormal socket close — a TCP reset, i.e. the common
211+ // mobile case: Wi-Fi⇄cellular handoff, server or proxy RST — the child's
212+ // failure cancels the whole channelFlow and bypasses the retry `catch`
213+ // below. That killed reconnect outright and surfaced the IOException as
214+ // an uncaught crash through the (handler-less) shareIn scope. Collecting
215+ // into our own channel and closing it with the cause keeps the failure
216+ // local: readSseEvents sees the closed channel, rethrows the cause, and
217+ // the normal retry/backoff path handles it. (A clean EOF closes the
218+ // channel with no cause → readSseEvents breaks and reconnects as before.)
219+ val events = Channel<ServerSentEvent>(Channel.BUFFERED)
220+ val producer = scope.launch {
221+ try {
222+ incoming.collect { events.send(it) }
223+ events.close()
224+ } catch (c: CancellationException) {
225+ events.close(c)
226+ throw c
227+ } catch (t: Throwable) {
228+ // Deliver the failure via the channel rather than letting it
229+ // propagate up and cancel the parent channelFlow.
230+ events.close(t)
231+ }
232+ }
233+ // Reset the backoff only once real data actually arrives, not on
234+ // block entry: a server that accepts the connection then immediately
235+ // drops it would otherwise loop forever at the initial backoff and
236+ // never escalate the exponential ramp.
237+ var receivedAny = false
238+ try {
239+ readSseEvents(scope, events, { reconnectConsumedDuringRead = true }) {
240+ if (!receivedAny) {
241+ receivedAny = true
242+ backoffMs = initialBackoffMs
243+ }
244+ send(it)
245+ }
246+ } finally {
247+ producer.cancel()
248+ events.cancel()
249+ }
250+ }
251+ } catch (c: CancellationException) {
252+ _state.value = ConnectionState.Disconnected
253+ throw c
254+ } catch (e: Exception) {
255+ // If the scope was cancelled (e.g. connection close), the closed-client
256+ // exception is expected — don't log a spurious "stream error" warning.
257+ if (isNonRetryableSseFailure(e)) {
258+ // The server rejected the request with a non-retryable 4xx (bad
259+ // credentials, missing endpoint, ...) — retrying won't help. Log a
260+ // scrubbed summary (class + status) instead of the full exception, whose
261+ // message carries the request URL and may include auth or paths.
262+ Log.w("EventStream", "SSE non-retryable 4xx, awaiting explicit reconnect: ${safeExceptionSummary(e)}")
263+ _state.value = ConnectionState.Failed
264+ if (!isActive) {
265+ // Final teardown (last subscriber left) while a 4xx surfaced: end in
266+ // Disconnected like every other loop-exit path. Failed must stay
267+ // observable only while the flow is live/retrying, not after teardown.
268+ _state.value = ConnectionState.Disconnected
269+ break
270+ }
271+ // Don't complete the flow: a subscriber may fix the profile and call
272+ // triggerReconnect(). Suspend until an explicit reconnect signal
273+ // arrives (no backoff delay), then re-attempt the connection. This receive
274+ // is outside the try above, so reset state to Disconnected if the last
275+ // subscriber leaves (cancellation) while we're parked here — otherwise the
276+ // stream would be torn down with state stuck at Failed.
277+ try {
278+ reconnectSignal.receive()
279+ } catch (c: CancellationException) {
280+ _state.value = ConnectionState.Disconnected
281+ throw c
282+ }
283+ continue
284+ } else if (isActive) {
285+ Log.w("EventStream", "SSE stream error, will retry: ${safeExceptionSummary(e)}")
286+ }
287+ }
288+ _state.value = ConnectionState.Disconnected
289+ if (!isActive) break
290+ // Wait for the backoff, but allow a reconnect signal to cut it short. When
291+ // readSseEvents consumed a triggerReconnect() signal to drop an active read,
292+ // the signal is already spent so the select below can't receive it — pass a
293+ // zero backoff in that case so the reconnect is immediate (the whole point of
294+ // triggerReconnect: network handoff, manual refresh, ...).
295+ val signaled = waitForBackoffOrSignal(reconnectConsumedDuringRead, backoffMs)
296+ backoffMs = if (signaled) initialBackoffMs else (backoffMs * 2).coerceAtMost(maxBackoffMs)
297+ }
298+ }
299+
300+ /** Suspend for the backoff unless [reconnectConsumedDuringRead] (immediate) or a new
301+ * [triggerReconnect] arrives during the wait. Returns true when the reconnect should
302+ * skip the backoff ramp (signal-driven), false when the full backoff elapsed. */
303+ private suspend fun waitForBackoffOrSignal(
304+ reconnectConsumedDuringRead: Boolean,
305+ backoffMs: Long,
306+ ): Boolean {
307+ if (reconnectConsumedDuringRead) return true
308+ var signaled = false
309+ val jitter = ((backoffMs * NetworkConfig.retryJitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
310+ select {
311+ reconnectSignal.onReceive { signaled = true }
312+ onTimeout((backoffMs + jitter).coerceAtLeast(0)) { /* normal backoff elapsed */ }
313+ }
314+ return signaled
315+ }
316+}
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.ktnew file+164
@@ -0,0 +1,164 @@
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.auth.Auth
8+import io.ktor.client.plugins.auth.providers.BasicAuthCredentials
9+import io.ktor.client.plugins.auth.providers.basic
10+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
11+import io.ktor.client.plugins.defaultRequest
12+import io.ktor.client.plugins.sse.SSE
13+import io.ktor.client.request.header
14+import io.ktor.http.URLBuilder
15+import io.ktor.http.takeFrom
16+import io.ktor.serialization.kotlinx.json.json
17+import okhttp3.CertificatePinner
18+import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
19+import java.util.concurrent.TimeUnit
20+
21+/**
22+ * Builds a Ktor client bound to a single [ServerProfile]. One client per active
23+ * profile: it carries the base URL, optional Basic auth, JSON negotiation, and the
24+ * SSE plugin used for `GET /event`.
25+ */
26+object HttpClientFactory {
27+
28+ fun create(profile: ServerProfile): HttpClient {
29+ // Resolve per-profile TLS options up front so they're available inside the engine
30+ // config: optionally upgrade a cleartext http:// URL to https:// ("Require HTTPS"),
31+ // and pin the server certificate(s) for HTTPS connections.
32+ val normalizedUrl = effectiveBaseUrl(profile)
33+ val isHttps = normalizedUrl.lowercase().startsWith("https://")
34+ // Parse the host with OkHttp's own parser (not java.net.URI, which rejects hosts OkHttp
35+ // accepts, e.g. underscores in "my_server.local"). If pinning is configured over HTTPS
36+ // but the host can't be parsed, fail closed: never build an unpinned client — that would
37+ // be a silent security downgrade for a user who opted into pinning.
38+ val pinHost = normalizedUrl.toHttpUrlOrNull()?.host
39+ val pins = parsePins(profile.certPin)
40+ if (pins.isNotEmpty() && isHttps && pinHost == null) {
41+ throw IllegalStateException(
42+ "Certificate pinning is configured but the server host could not be parsed from $normalizedUrl",
43+ )
44+ }
45+
46+ return HttpClient(OkHttp) {
47+ expectSuccess = true
48+
49+ engine {
50+ config {
51+ // A normal read timeout for REST calls. The SSE stream overrides this
52+ // to infinite inside the sse {} request block (see EventStreamClient),
53+ // so the long-lived event connection is never killed mid-stream.
54+ readTimeout(NetworkConfig.readTimeoutSeconds, TimeUnit.SECONDS)
55+ connectTimeout(NetworkConfig.connectTimeoutSeconds, TimeUnit.SECONDS)
56+ retryOnConnectionFailure(true)
57+ pingInterval(NetworkConfig.pingIntervalSeconds, TimeUnit.SECONDS)
58+ // Pin the server certificate(s) when configured and connecting over TLS.
59+ // A malformed pin throws here (surfaced as a connection failure), which is
60+ // the right fail-closed behavior for a security control the user opted into.
61+ if (pins.isNotEmpty() && pinHost != null && isHttps) {
62+ certificatePinner(
63+ CertificatePinner.Builder().apply { pins.forEach { add(pinHost, it) } }.build(),
64+ )
65+ }
66+ }
67+ }
68+
69+ install(ContentNegotiation) {
70+ json(OpencodeJson)
71+ }
72+
73+ install(SSE)
74+
75+ // Request-level timeout for REST calls. The SSE stream overrides this to
76+ // infinite inside the sse {} request block (see EventStreamClient) so the
77+ // long-lived /event connection isn't killed after 60s. The engine's connect
78+ // timeout and the SSE idle-timeout watchdog handle stuck SSE connections.
79+ install(HttpTimeout) {
80+ requestTimeoutMillis = NetworkConfig.restRequestTimeoutMs
81+ }
82+
83+ if (profile.hasAuth) {
84+ if (isHttps) {
85+ install(Auth) {
86+ basic {
87+ credentials {
88+ BasicAuthCredentials(
89+ username = profile.username.orEmpty(),
90+ password = profile.password.orEmpty(),
91+ )
92+ }
93+ // Send eagerly so opencode doesn't need a 401 challenge round-trip, but
94+ // only to the configured host: a cross-host redirect must not carry the
95+ // credentials off-origin (the plugin would still answer a genuine 401 from
96+ // the real host reactively). The Auth plugin is only installed for HTTPS
97+ // profiles — over HTTP, even reactive credential sending would leak
98+ // passwords in cleartext, so we don't install it at all.
99+ sendWithoutRequest { request ->
100+ pinHost != null && request.url.host.equals(pinHost, ignoreCase = true)
101+ }
102+ }
103+ }
104+ } else {
105+ // For HTTP profiles with auth, send credentials proactively in the
106+ // Authorization header via defaultRequest (below) so the user's intent
107+ // to connect over cleartext is honored, but we never install the Auth
108+ // plugin's reactive challenge-response which would silently re-send
109+ // credentials on any 401 without checking the protocol.
110+ }
111+ }
112+
113+ defaultRequest {
114+ url.takeFrom(URLBuilder().takeFrom(normalizedUrl))
115+ // For HTTP profiles with auth, attach the Basic auth header proactively on
116+ // every request (the Auth plugin is skipped for non-HTTPS). The user chose
117+ // cleartext explicitly, so we honor that — but without the reactive challenge
118+ // logic that could re-send credentials silently.
119+ if (profile.hasAuth && !isHttps) {
120+ val credentials = java.util.Base64.getEncoder().encodeToString(
121+ "${profile.username.orEmpty()}:${profile.password.orEmpty()}".toByteArray(),
122+ )
123+ header("Authorization", "Basic $credentials")
124+ }
125+ }
126+ }
127+ }
128+
129+ /** The base URL after applying [ServerProfile.requireHttps]: a cleartext http:// URL is
130+ * upgraded to https:// so the "Require HTTPS" choice is enforced at the transport layer.
131+ * A configured certificate pin also forces TLS: a pin is meaningless over cleartext and
132+ * would otherwise be silently dropped, so we honor the opt-in security control by upgrading
133+ * rather than connecting unpinned in the clear. */
134+ private fun effectiveBaseUrl(profile: ServerProfile): String {
135+ val normalized = normalizeBaseUrl(profile.baseUrl)
136+ val forceHttps = profile.requireHttps || parsePins(profile.certPin).isNotEmpty()
137+ return if (forceHttps && normalized.lowercase().startsWith("http://")) {
138+ "https://" + normalized.substring("http://".length)
139+ } else {
140+ normalized
141+ }
142+ }
143+
144+ /** Split a certificate-pin field into individual OkHttp pins. Accepts whitespace- or
145+ * comma-separated "sha256/<base64>" entries; blank entries are dropped. */
146+ private fun parsePins(raw: String?): List<String> =
147+ raw?.split(Regex("[\\s,]+"))?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
148+
149+ /** Ensure a scheme and a single trailing slash so relative paths resolve correctly.
150+ * Defaults to [https] when no scheme is given so credentials aren't accidentally
151+ * sent over cleartext. Users who need plain HTTP must type the scheme explicitly. */
152+ fun normalizeBaseUrl(raw: String): String {
153+ val trimmed = raw.trim()
154+ // Compare the scheme prefix case-insensitively (a lowercased copy) while
155+ // preserving the original casing of the rest of the URL in the output.
156+ val lowerTrimmed = trimmed.lowercase()
157+ val withScheme = if (lowerTrimmed.startsWith("http://") || lowerTrimmed.startsWith("https://")) {
158+ trimmed
159+ } else {
160+ "https://$trimmed"
161+ }
162+ return if (withScheme.endsWith("/")) withScheme else "$withScheme/"
163+ }
164+}
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.ktnew file+199
@@ -0,0 +1,199 @@
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+ // --- Catalog cache (OpencodeApiClient) ---
44+
45+ /** How long cached catalog responses (providers/agents/commands) are considered fresh. */
46+ const val catalogCacheTtlMs = 60_000L
47+
48+ // --- Session list (SessionListViewModel) ---
49+
50+ /** Max sessions to fetch previews for in one batch. */
51+ const val maxPreviewSessions = 50
52+ /** Max concurrent preview fetches (prevents flooding the server with parallel requests). */
53+ const val maxConcurrentPreviews = 8
54+ /** Max characters of a session's last message to keep as a list preview. */
55+ const val previewTextMaxLength = 200
56+ /** Debounce delay before fetching a session preview after an SSE update, so a burst
57+ * of SessionUpdated events (e.g. during active streaming) coalesces into one fetch
58+ * instead of launching one per event (each downloading the full message history). */
59+ const val previewDebounceMs = 500L
60+ /** Debounce for applying SessionUpdated SSE events to the session list state. During
61+ * active streaming the server emits these frequently; without debouncing each one
62+ * triggers a full list filter + sort + recomposition, causing scroll jank. */
63+ const val sessionUpdateDebounceMs = 300L
64+
65+ // --- Global search (GlobalSearchViewModel) ---
66+
67+ /** Max sessions whose message history is scanned in one global-search pass. Global
68+ * search downloads each session's messages, so this bounds the work; if there are more
69+ * sessions, the result set notes that the search was truncated. */
70+ const val maxSearchSessions = 50
71+ /** Debounce before a global search fires, so typing doesn't launch a fetch per keystroke. */
72+ const val searchDebounceMs = 350L
73+ /** Minimum query length before a global search runs (shorter queries match too much). */
74+ const val minSearchQueryLength = 2
75+ /** Characters of context to show around a global-search match. */
76+ const val searchSnippetLength = 160
77+
78+ // --- Message cache (MessageCacheStore) ---
79+
80+ /** Minimum interval between on-disk writes of a session's message snapshot, so a fast
81+ * token stream doesn't hammer the disk. The cache only needs to be recent enough for an
82+ * instant/offline first paint; the network corrects it on the next open. */
83+ const val messageCacheWriteThrottleMs = 2_000L
84+
85+ /** Grace period before a deferred session delete actually fires, during which an
86+ * Undo snackbar lets the user cancel it. */
87+ const val undoDeleteDelayMs = 5_000L
88+
89+ /** Grace period before a deferred server-profile delete actually fires, during which
90+ * an Undo snackbar lets the user cancel it. Same UX rationale as [undoDeleteDelayMs]. */
91+ const val undoServerDeleteDelayMs = 5_000L
92+
93+ /** Grace period before a deferred crash-report delete actually fires, during which
94+ * an Undo snackbar lets the user cancel it. */
95+ const val undoReportDeleteDelayMs = 5_000L
96+
97+ // --- In-memory message store (SessionRepository.MessageStore) ---
98+
99+ /** Max messages to keep in memory per observed session; oldest are evicted beyond this. */
100+ const val maxInMemoryMessages = 500
101+
102+ // --- File browser (FileBrowserViewModel) ---
103+
104+ /** Debounce delay before firing a file-search request after the user stops typing. */
105+ const val fileSearchDebounceMs = 250L
106+
107+ // --- Attachments (chat composer) ---
108+
109+ /** Max size of a single attachment (image/file) before it's rejected. Base64 inflates
110+ * the payload ~33%, and the whole prompt body is held in memory, so cap generously but
111+ * finitely to avoid OOM / oversized requests. */
112+ const val maxAttachmentBytes = 10L * 1024 * 1024
113+
114+ /** Max number of attachments staged for one prompt, to bound memory and request size. */
115+ const val maxAttachments = 8
116+
117+ /** Max cumulative size of all attachments staged for one prompt. The per-file
118+ * [maxAttachmentBytes] cap alone allows [maxAttachments] max-size files (~80MB raw,
119+ * ~106MB as base64) to pile up in memory and be re-serialized on every add/remove and
120+ * again when the request body is built — a realistic OOM. This bounds the whole set. */
121+ const val maxTotalAttachmentBytes = 25L * 1024 * 1024
122+
123+ // --- StateFlow sharing (WhileSubscribed) ---
124+
125+ /** Grace period before a cold StateFlow is stopped after its last subscriber leaves. */
126+ const val stateFlowSubscriptionTimeoutMs = 5_000L
127+
128+ /** Maximum time a notification action BroadcastReceiver may hold its goAsync()
129+ * PendingResult open. The system ANRs a receiver that doesn't call finish() within
130+ * ~10s, so this must stay below that; a slow permission-respond retry (withRetry
131+ * exponential backoff up to ~90s) or a blocked outbox enqueue could otherwise blow
132+ * past the window. The watchdog finishes the PendingResult at this deadline so the
133+ * receiver never ANRs; the underlying network call keeps running on the app scope. */
134+ const val notificationReceiverTimeoutMs = 8_000L
135+
136+ // --- Draft persistence (ChatViewModel) ---
137+
138+ /** Debounce delay before persisting a draft to disk after the user stops typing. */
139+ const val draftDebounceMs = 500L
140+
141+ /** Maximum characters allowed in the chat input field. A generous cap that prevents
142+ * a huge paste from stalling the UI (the field would otherwise buffer and lay out
143+ * an unbounded string) while leaving plenty of room for long prompts. */
144+ const val maxDraftLengthChars = 32_000
145+
146+ /** Show the "remaining characters" supportingText under the chat input once the
147+ * draft length crosses this fraction of [maxDraftLengthChars], so the cap is
148+ * visible before it silently kicks in but doesn't clutter a normal short prompt. */
149+ const val draftCountdownThresholdFraction = 0.8f
150+
151+ /** Maximum characters allowed in a session title (rename dialog). Prevents the
152+ * server from rejecting an overly long title and keeps list rows readable. */
153+ const val maxSessionTitleChars = 200
154+
155+ // --- Profile store (ServerEditViewModel) ---
156+
157+ /** Timeout for loading a profile from DataStore before giving up. */
158+ const val profileLoadTimeoutMs = 5_000L
159+
160+ // --- SSE stream (EventStreamClient, AppContainer) ---
161+
162+ /** Delay before retrying the message-activity observer after a failure. */
163+ const val observerRetryDelayMs = 5_000L
164+ /** Debounce before the run foreground-service is started/stopped in response to
165+ * run-activity changes. A run that starts and then fails/idles almost immediately
166+ * would otherwise dispatch startForegroundService() then stopService() back-to-back;
167+ * if the stop wins the race before startForeground() runs, Android raises the
168+ * "did not then call startForeground()" crash. Debouncing collapses that window. */
169+ const val runForegroundDebounceMs = 500L
170+
171+ // --- Markdown rendering (MarkdownText) ---
172+
173+ /** Throttle delay to coalesce streaming tokens into one re-parse. The markdown
174+ * library re-parses the full AST on every content change, so during a long streaming
175+ * response this is O(n²) work. 50ms (~20fps) is smooth for progressively appearing
176+ * text while cutting parse work ~3x versus a per-frame (16ms) throttle. */
177+ const val streamingThrottleMs = 50L
178+
179+ // --- Snackbar one-shot events (ViewModels) ---
180+
181+ /** Buffer capacity for transient error SharedFlows that drive snackbars. */
182+ const val snackbarEventBufferCapacity = 16
183+
184+ /** How long the chat top-bar refresh icon stays in its "refreshing" spinner state
185+ * after a tap, giving immediate feedback that the refresh was triggered even when
186+ * the connection state is already Connected (so the ConnectionBanner doesn't change). */
187+ const val refreshFeedbackMs = 1_200L
188+
189+ // --- UI layout constants ---
190+
191+ /** Minimum window width (dp) for two-pane layout on tablets / unfolded foldables. */
192+ const val twoPaneWidthThresholdDp = 840
193+ /** Maximum width fraction a user message bubble can occupy (keeps it readable). */
194+ const val userBubbleWidthFraction = 0.85f
195+ /** Left pane weight in the two-pane layout (session list). */
196+ const val twoPaneLeftWeight = 0.38f
197+ /** Right pane weight in the two-pane layout (chat detail). */
198+ const val twoPaneRightWeight = 0.62f
199+}
app/src/main/java/soy/iko/opencode/data/network/NsdDiscovery.ktnew file+179
@@ -0,0 +1,179 @@
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+ // Names reported lost before the serialized resolver reached them. Without this, a
56+ // service that disappears between onServiceFound and the resolver dequeuing it would
57+ // be re-inserted into `found` after onServiceLost already fired (and returned null),
58+ // leaving a phantom entry that lingers until discovery stops — the lost callback never
59+ // fires twice for the same name. The resolver consults this set and skips the insert.
60+ val lostPending = java.util.Collections.newSetFromMap(java.util.concurrent.ConcurrentHashMap<String, Boolean>())
61+ val toResolve = Channel<NsdServiceInfo>(Channel.UNLIMITED)
62+
63+ val resolver = launch {
64+ for (info in toResolve) {
65+ // Key the map by the name from discovery: onServiceFound/onServiceLost both use
66+ // NsdServiceInfo.serviceName, whereas the resolved info's serviceName can differ —
67+ // keying by the resolved name would leave a departed server that onServiceLost
68+ // can't match, so it would linger in the emitted list forever.
69+ val discoveredName = info.serviceName.orEmpty()
70+ // NsdManager.resolveService occasionally never invokes its listener. Without a
71+ // timeout, one stuck resolve would hang this single serialized resolver forever,
72+ // so every later-discovered service queued behind it is never processed and the
73+ // emitted list silently stops updating. Abandon a stuck resolve and keep draining.
74+ val resolved = withTimeoutOrNull(RESOLVE_TIMEOUT_MS) { resolveService(nsd, info) } ?: continue
75+ // The service was reported lost while queued behind other resolves: don't
76+ // resurrect it. Clear the marker so the name is free for a future re-appearance.
77+ if (lostPending.remove(discoveredName)) continue
78+ val name = resolved.serviceName.orEmpty()
79+ // Keep only opencode's own advertisements, not every _http._tcp service
80+ // (printers, routers, other web servers) on the network.
81+ if (!name.startsWith("opencode", ignoreCase = true)) continue
82+ @Suppress("DEPRECATION")
83+ val host = resolved.host?.hostAddress ?: continue
84+ found[discoveredName] = DiscoveredServer(name = name, host = host, port = resolved.port)
85+ trySend(found.values.sortedBy { it.name })
86+ }
87+ }
88+
89+ val listener = object : NsdManager.DiscoveryListener {
90+ override fun onDiscoveryStarted(serviceType: String?) {}
91+ override fun onServiceFound(serviceInfo: NsdServiceInfo) {
92+ toResolve.trySend(serviceInfo)
93+ }
94+ override fun onServiceLost(serviceInfo: NsdServiceInfo) {
95+ val name = serviceInfo.serviceName ?: return
96+ // Mark the name lost so the resolver skips it if it hasn't been processed yet.
97+ // If it's already in `found` (resolved before the lost), remove and emit now.
98+ lostPending.add(name)
99+ if (found.remove(name) != null) trySend(found.values.sortedBy { it.name })
100+ }
101+ override fun onDiscoveryStopped(serviceType: String?) {}
102+ override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) {
103+ Log.w(TAG, "mDNS discovery start failed: $errorCode")
104+ close()
105+ }
106+ override fun onStopDiscoveryFailed(serviceType: String?, errorCode: Int) {}
107+ }
108+
109+ runCatching { nsd.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener) }
110+ .onFailure { close(it) }
111+
112+ awaitClose {
113+ resolver.cancel()
114+ toResolve.close()
115+ runCatching { nsd.stopServiceDiscovery(listener) }
116+ }
117+ }
118+
119+ private suspend fun resolveService(nsd: NsdManager, info: NsdServiceInfo): NsdServiceInfo? =
120+ suspendCancellableCoroutine { cont ->
121+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
122+ // API 34 deprecated the one-shot resolveService (which has no cancel API and
123+ // whose listener leaks on timeout, wedging the platform's serialized slot so the
124+ // next resolve fails with ALREADY_ACTIVE) in favour of a persistent callback that
125+ // can be detached. A timed-out (cancelled) resolve unregisters its callback in
126+ // invokeOnCancellation, and a settled one unregisters itself, so nothing lingers.
127+ val settled = java.util.concurrent.atomic.AtomicBoolean(false)
128+ lateinit var callback: NsdManager.ServiceInfoCallback
129+ callback = object : NsdManager.ServiceInfoCallback {
130+ override fun onServiceInfoCallbackRegistrationFailed(errorCode: Int) {
131+ if (settled.compareAndSet(false, true) && cont.isActive) cont.resume(null)
132+ }
133+ override fun onServiceUpdated(serviceInfo: NsdServiceInfo) {
134+ if (settled.compareAndSet(false, true)) {
135+ runCatching { nsd.unregisterServiceInfoCallback(callback) }
136+ if (cont.isActive) cont.resume(serviceInfo)
137+ }
138+ }
139+ override fun onServiceLost() {
140+ if (settled.compareAndSet(false, true)) {
141+ runCatching { nsd.unregisterServiceInfoCallback(callback) }
142+ if (cont.isActive) cont.resume(null)
143+ }
144+ }
145+ override fun onServiceInfoCallbackUnregistered() {}
146+ }
147+ cont.invokeOnCancellation {
148+ if (settled.compareAndSet(false, true)) {
149+ runCatching { nsd.unregisterServiceInfoCallback(callback) }
150+ }
151+ }
152+ runCatching { nsd.registerServiceInfoCallback(info, { it.run() }, callback) }
153+ .onFailure { if (settled.compareAndSet(false, true) && cont.isActive) cont.resume(null) }
154+ } else {
155+ // Pre-34: the deprecated one-shot resolveService has no cancel API, so a stuck
156+ // resolve abandoned by RESOLVE_TIMEOUT_MS can still wedge the platform's single
157+ // serialized slot; this is a platform limitation we can only bound, not clear.
158+ val listener = object : NsdManager.ResolveListener {
159+ override fun onResolveFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) {
160+ if (cont.isActive) cont.resume(null)
161+ }
162+ override fun onServiceResolved(serviceInfo: NsdServiceInfo) {
163+ if (cont.isActive) cont.resume(serviceInfo)
164+ }
165+ }
166+ runCatching { @Suppress("DEPRECATION") nsd.resolveService(info, listener) }
167+ .onFailure { if (cont.isActive) cont.resume(null) }
168+ }
169+ }
170+
171+ private companion object {
172+ const val TAG = "NsdDiscovery"
173+ // opencode advertises bonjour type "http" → DNS-SD "_http._tcp.".
174+ const val SERVICE_TYPE = "_http._tcp."
175+ // Upper bound on a single mDNS resolve before it's abandoned so a stuck platform
176+ // callback can't wedge the serialized resolver. Local-network resolves are sub-second.
177+ const val RESOLVE_TIMEOUT_MS = 5_000L
178+ }
179+}
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.ktnew file+511
@@ -0,0 +1,511 @@
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 kotlin.random.Random
46+import java.net.URLEncoder
47+
48+/**
49+ * Request/response wrapper over the opencode REST endpoints. The long-lived `/event`
50+ * SSE stream lives in [EventStreamClient]; everything else is here.
51+ */
52+open class OpencodeApiClient private constructor(
53+ private val client: HttpClient?,
54+ @Suppress("unused") private val testMode: Boolean,
55+) {
56+ constructor(client: HttpClient) : this(client, false)
57+ protected constructor() : this(null, true)
58+
59+ // --- Catalog cache (providers/agents/commands) ---
60+ // These change rarely, but the ChatViewModel re-fetches them on every creation.
61+ // A short-lived in-memory cache avoids redundant network calls when navigating
62+ // between sessions on the same server.
63+ private data class CachedEntry<T>(val value: T, val fetchedAt: Long)
64+ // Per-catalog mutexes so a slow fetch of one catalog doesn't block the others.
65+ // The fetch runs OUTSIDE the lock (holding it across the network call would serialize
66+ // all callers and stall invalidation). To keep that correct, each catalog carries a
67+ // generation counter, bumped under the mutex by its invalidateXCache(): a fetch captures
68+ // the generation before it starts and, when it completes, only writes its result back if
69+ // the generation is unchanged — so an invalidation that raced the in-flight fetch is not
70+ // defeated by the pre-invalidation result being stored afterwards.
71+ private val providersMutex = Mutex()
72+ private val agentsMutex = Mutex()
73+ private val commandsMutex = Mutex()
74+ private var cachedProviders: CachedEntry<ProvidersResponse>? = null
75+ private var cachedAgents: CachedEntry<List<Agent>>? = null
76+ private var cachedCommands: CachedEntry<List<Command>>? = null
77+ // Generation counters (guarded by the matching mutex above) for the invalidation-race guard.
78+ private var providersGeneration = 0
79+ private var agentsGeneration = 0
80+ private var commandsGeneration = 0
81+
82+ /** Lightweight connectivity check. Throws on non-2xx / network failure. */
83+ open suspend fun ping() {
84+ withRetry { client!!.get("global/health").body<String>() }
85+ }
86+
87+ open suspend fun listSessions(): List<Session> = withRetry {
88+ client!!.get("session").body()
89+ }
90+
91+ /**
92+ * Create a session, optionally in a specific worktree [directory]. The directory is a
93+ * query parameter (not a body field): the server records it on the session and resolves
94+ * it by session id thereafter, so follow-up calls (messages, commands, abort, …) don't
95+ * — and shouldn't — re-send it. A null/blank [directory] lets the server fall back to
96+ * its own launch cwd, preserving the pre-directory behavior.
97+ */
98+ open suspend fun createSession(title: String? = null, directory: String? = null): Session {
99+ // Generate the idempotency key before entering withRetry so every retry attempt
100+ // shares it. Without this, a POST that reached the server but whose response was
101+ // lost (reset/timeout) would be retried and create a second orphan session.
102+ val idempotencyKey = java.util.UUID.randomUUID().toString()
103+ val dir = directory?.takeIf { it.isNotBlank() }
104+ return withRetry {
105+ client!!.post("session") {
106+ contentType(ContentType.Application.Json)
107+ header("Idempotency-Key", idempotencyKey)
108+ if (dir != null) parameter("directory", dir)
109+ setBody(CreateSessionRequest(title = title))
110+ }.body()
111+ }
112+ }
113+
114+ /** List the projects (worktree roots) the server knows about, for the directory picker. */
115+ open suspend fun listProjects(): List<Project> = withRetry {
116+ client!!.get("project").body()
117+ }
118+
119+ /** The server's current path info; its [PathInfo.directory] is the default (cwd) a
120+ * session with no directory override runs in. */
121+ open suspend fun currentPath(): PathInfo = withRetry {
122+ client!!.get("path").body()
123+ }
124+
125+ open suspend fun updateSession(id: String, title: String): Session = withRetry {
126+ client!!.patch("session/${encode(id)}") {
127+ contentType(ContentType.Application.Json)
128+ setBody(UpdateSessionRequest(title = title))
129+ }.body()
130+ }
131+
132+ open suspend fun deleteSession(id: String) {
133+ try {
134+ withRetry { client!!.delete("session/${encode(id)}").body<String>() }
135+ } catch (t: ClientRequestException) {
136+ // A DELETE that succeeded but whose response was lost gets retried; the retry
137+ // hits an already-gone session and returns 404. Deleting a non-existent session
138+ // is effectively success, so swallow 404 rather than surfacing a spurious failure.
139+ if (t.response.status.value != 404) throw t
140+ }
141+ }
142+
143+ open suspend fun listMessages(sessionId: String): List<MessageWithParts> = withRetry {
144+ client!!.get("session/${encode(sessionId)}/message").body()
145+ }
146+
147+ open suspend fun sendPrompt(
148+ sessionId: String,
149+ text: String,
150+ attachments: List<FilePromptPart> = emptyList(),
151+ model: ModelRef? = null,
152+ agent: String? = null,
153+ // A caller-stable idempotency key. Callers that may re-invoke sendPrompt across
154+ // *separate* calls for the same logical message (the offline outbox, which re-sends
155+ // a message that stayed queued after an earlier flush failed) must pass a stable key
156+ // derived from the persistent message id — otherwise each attempt mints a fresh key
157+ // and a POST that reached the server but whose response was lost is re-created as a
158+ // duplicate run. When null, a fresh key is generated (correct for one-shot sends).
159+ idempotencyKey: String? = null,
160+ ): MessageWithParts {
161+ // Resolve the key *before* entering withRetry so all retry attempts share it. If the
162+ // key were generated inside withRetry, each attempt would get a fresh UUID and the
163+ // server couldn't deduplicate a request that reached it but whose response was lost.
164+ val resolvedKey = idempotencyKey ?: java.util.UUID.randomUUID().toString()
165+ // A text part is only included when there's actual text, so an image-only prompt
166+ // (attachments, empty text) doesn't send an empty text part. Attachments follow
167+ // the text so the model reads the instruction first.
168+ val parts = buildList<PromptPart> {
169+ if (text.isNotEmpty()) add(TextPromptPart(text))
170+ addAll(attachments)
171+ }
172+ return withRetry {
173+ client!!.post("session/${encode(sessionId)}/message") {
174+ contentType(ContentType.Application.Json)
175+ header("Idempotency-Key", resolvedKey)
176+ setBody(
177+ PromptRequest(
178+ parts = parts,
179+ model = model,
180+ agent = agent,
181+ ),
182+ )
183+ }.body()
184+ }
185+ }
186+
187+ open suspend fun abort(sessionId: String) {
188+ withRetry { client!!.post("session/${encode(sessionId)}/abort").body<String>() }
189+ }
190+
191+ /** Invoke a slash-command by name via `POST /session/:id/command`. */
192+ open suspend fun runCommand(
193+ sessionId: String,
194+ command: String,
195+ arguments: String = "",
196+ agent: String? = null,
197+ ): MessageWithParts {
198+ // Generate the idempotency key before entering withRetry so all retry
199+ // attempts share the same key, preventing duplicate command execution if
200+ // the server processed the request but the response was lost.
201+ val idempotencyKey = java.util.UUID.randomUUID().toString()
202+ return withRetry {
203+ client!!.post("session/${encode(sessionId)}/command") {
204+ contentType(ContentType.Application.Json)
205+ header("Idempotency-Key", idempotencyKey)
206+ setBody(CommandRequest(command = command, arguments = arguments, agent = agent))
207+ }.body()
208+ }
209+ }
210+
211+ /** Revert the session to just before [messageId] (optionally part [partId]); the
212+ * server hides everything after the checkpoint. Returns the updated session. Naturally
213+ * idempotent (reverting to the same point twice yields the same state). */
214+ open suspend fun revert(sessionId: String, messageId: String, partId: String? = null): Session = withRetry {
215+ client!!.post("session/${encode(sessionId)}/revert") {
216+ contentType(ContentType.Application.Json)
217+ setBody(RevertRequest(messageID = messageId, partID = partId))
218+ }.body()
219+ }
220+
221+ /** Undo the active revert checkpoint, restoring the hidden messages. */
222+ open suspend fun unrevert(sessionId: String): Session = withRetry {
223+ client!!.post("session/${encode(sessionId)}/unrevert").body()
224+ }
225+
226+ /** Create a public share link for the session. Returns the session with [Session.share] set.
227+ * Mint the Idempotency-Key *before* withRetry so a retried POST (first request reached the
228+ * server but its response was lost) doesn't create a second, orphaned, still-valid share. */
229+ open suspend fun shareSession(sessionId: String): Session {
230+ val idempotencyKey = java.util.UUID.randomUUID().toString()
231+ return withRetry {
232+ client!!.post("session/${encode(sessionId)}/share") {
233+ header("Idempotency-Key", idempotencyKey)
234+ }.body()
235+ }
236+ }
237+
238+ /** Revoke the session's public share link. Returns the session with share cleared.
239+ * Like [deleteSession], a retried DELETE (first reached the server but the response was
240+ * lost) hits an already-unshared session and gets 404 — revoking a non-shared session is
241+ * effectively success, so swallow 404 rather than surfacing a spurious failure. */
242+ open suspend fun unshareSession(sessionId: String): Session {
243+ return try {
244+ withRetry { client!!.delete("session/${encode(sessionId)}/share").body() }
245+ } catch (t: ClientRequestException) {
246+ if (t.response.status.value != 404) throw t
247+ withRetry { client!!.get("session/${encode(sessionId)}").body() }
248+ }
249+ }
250+
251+ /** Ask the agent to summarize/compact the conversation to reclaim context. The compacted
252+ * summary streams back via SSE like any run; this just triggers it. */
253+ open suspend fun summarize(sessionId: String, model: ModelRef) {
254+ val idempotencyKey = java.util.UUID.randomUUID().toString()
255+ withRetry {
256+ client!!.post("session/${encode(sessionId)}/summarize") {
257+ contentType(ContentType.Application.Json)
258+ header("Idempotency-Key", idempotencyKey)
259+ setBody(SummarizeRequest(providerID = model.providerID, modelID = model.modelID))
260+ }.body<String>()
261+ }
262+ }
263+
264+ /** Analyze the project and (re)generate its AGENTS.md. The body is omitted so the server
265+ * assigns the message id and uses its default model; the run streams back via SSE. */
266+ open suspend fun initSession(sessionId: String) {
267+ val idempotencyKey = java.util.UUID.randomUUID().toString()
268+ withRetry {
269+ client!!.post("session/${encode(sessionId)}/init") {
270+ header("Idempotency-Key", idempotencyKey)
271+ }.body<String>()
272+ }
273+ }
274+
275+ /** Run a one-off shell [command] in the session's worktree. Output streams back via SSE.
276+ * [agent] scopes the run (the server requires it). */
277+ open suspend fun shell(sessionId: String, command: String, agent: String, model: ModelRef? = null) {
278+ val idempotencyKey = java.util.UUID.randomUUID().toString()
279+ withRetry {
280+ client!!.post("session/${encode(sessionId)}/shell") {
281+ contentType(ContentType.Application.Json)
282+ header("Idempotency-Key", idempotencyKey)
283+ setBody(ShellRequest(agent = agent, command = command, model = model))
284+ }.body<String>()
285+ }
286+ }
287+
288+ open suspend fun providers(): ProvidersResponse {
289+ val generation: Int
290+ providersMutex.withLock {
291+ val cached = cachedProviders
292+ if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
293+ return cached.value
294+ }
295+ generation = providersGeneration
296+ }
297+ // Fetch outside the lock so a slow/retrying request (and its backoff) can't stall other
298+ // callers or cache invalidation. Concurrent cold callers may double-fetch — acceptable
299+ // for a rarely-fetched catalog — and the last store wins.
300+ val value = withRetry { client!!.get("config/providers").body<ProvidersResponse>() }
301+ providersMutex.withLock {
302+ // FIX 15: skip the store if an invalidateProvidersCache() raced this fetch — the
303+ // result predates the invalidation and would otherwise resurrect stale data.
304+ if (providersGeneration == generation) cachedProviders = CachedEntry(value, System.currentTimeMillis())
305+ }
306+ return value
307+ }
308+
309+ /** The server's resolved configuration as a raw JSON object. Parsed loosely by callers
310+ * (e.g. the MCP viewer) so a schema addition on the server never breaks decoding. */
311+ open suspend fun config(): kotlinx.serialization.json.JsonObject = withRetry {
312+ client!!.get("config").body()
313+ }
314+
315+ open suspend fun agents(): List<Agent> {
316+ val generation: Int
317+ agentsMutex.withLock {
318+ val cached = cachedAgents
319+ if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
320+ return cached.value
321+ }
322+ generation = agentsGeneration
323+ }
324+ // Fetch outside the lock (see providers()): a slow/retrying request can't stall other
325+ // callers or cache invalidation; a rare concurrent double-fetch is acceptable here.
326+ val value = withRetry { client!!.get("agent").body<List<Agent>>() }
327+ agentsMutex.withLock {
328+ // FIX 15: skip the store if an invalidateAgentsCache() raced this fetch (see providers()).
329+ if (agentsGeneration == generation) cachedAgents = CachedEntry(value, System.currentTimeMillis())
330+ }
331+ return value
332+ }
333+
334+ open suspend fun commands(): List<Command> {
335+ val generation: Int
336+ commandsMutex.withLock {
337+ val cached = cachedCommands
338+ if (cached != null && System.currentTimeMillis() - cached.fetchedAt < NetworkConfig.catalogCacheTtlMs) {
339+ return cached.value
340+ }
341+ generation = commandsGeneration
342+ }
343+ // Fetch outside the lock (see providers()): a slow/retrying request can't stall other
344+ // callers or cache invalidation; a rare concurrent double-fetch is acceptable here.
345+ val value = withRetry { client!!.get("command").body<List<Command>>() }
346+ commandsMutex.withLock {
347+ // FIX 15: skip the store if an invalidateCommandsCache() raced this fetch (see providers()).
348+ if (commandsGeneration == generation) cachedCommands = CachedEntry(value, System.currentTimeMillis())
349+ }
350+ return value
351+ }
352+
353+ /** Invalidate the catalog cache (e.g. on server switch). */
354+ open suspend fun invalidateCache() {
355+ // FIX 15: bump each generation so an in-flight fetch drops its now-stale result.
356+ providersMutex.withLock { cachedProviders = null; providersGeneration++ }
357+ agentsMutex.withLock { cachedAgents = null; agentsGeneration++ }
358+ commandsMutex.withLock { cachedCommands = null; commandsGeneration++ }
359+ }
360+
361+ open suspend fun invalidateProvidersCache() {
362+ providersMutex.withLock { cachedProviders = null; providersGeneration++ }
363+ }
364+
365+ open suspend fun invalidateAgentsCache() {
366+ agentsMutex.withLock { cachedAgents = null; agentsGeneration++ }
367+ }
368+
369+ open suspend fun invalidateCommandsCache() {
370+ commandsMutex.withLock { cachedCommands = null; commandsGeneration++ }
371+ }
372+
373+ /** Respond to a permission request so a paused tool run can proceed. */
374+ open suspend fun respondPermission(
375+ sessionId: String,
376+ permissionId: String,
377+ response: PermissionResponse,
378+ ): String = try {
379+ withRetry {
380+ client!!.post("session/${encode(sessionId)}/permissions/${encode(permissionId)}") {
381+ contentType(ContentType.Application.Json)
382+ setBody(PermissionReplyBody(response.wire))
383+ }.body<String>()
384+ }
385+ } catch (t: ClientRequestException) {
386+ // A response that reached the server but whose reply was lost gets retried; the
387+ // retry finds the permission already resolved (404 gone / 409 conflict). Both mean
388+ // the answer landed, so treat them as success instead of a spurious failure.
389+ val code = t.response.status.value
390+ if (code != 404 && code != 409) throw t
391+ ""
392+ }
393+
394+ // --- Files ---
395+
396+ open suspend fun findFiles(query: String): List<String> = withRetry {
397+ client!!.get("find/file") { parameter("query", query) }.body()
398+ }
399+
400+ /** Ripgrep content search across the project (`GET /find?pattern=`). */
401+ open suspend fun findText(pattern: String): List<FindMatch> = withRetry {
402+ client!!.get("find") { parameter("pattern", pattern) }.body()
403+ }
404+
405+ /** LSP workspace symbol search (`GET /find/symbol?query=`). */
406+ open suspend fun findSymbol(query: String): List<SymbolResult> = withRetry {
407+ client!!.get("find/symbol") { parameter("query", query) }.body()
408+ }
409+
410+ open suspend fun listDirectory(path: String): List<FileNode> = withRetry {
411+ client!!.get("file") { parameter("path", path) }.body()
412+ }
413+
414+ open suspend fun readFile(path: String): FileContent = withRetry {
415+ client!!.get("file/content") { parameter("path", path) }.body()
416+ }
417+
418+ open suspend fun fileStatus(): List<FileStatusEntry> = withRetry {
419+ client!!.get("file/status").body()
420+ }
421+}
422+
423+private suspend fun <T> withRetry(
424+ maxAttempts: Int = NetworkConfig.retryMaxAttempts,
425+ initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
426+ block: suspend () -> T,
427+): T = withRetryInternal(maxAttempts, initialDelayMs, jitterFactor = NetworkConfig.retryJitterFactor, block)
428+
429+/**
430+ * Retry a suspending block up to [maxAttempts] times with exponential backoff.
431+ * Cancellation exceptions are re-thrown immediately. Client errors (4xx) are
432+ * non-retryable — a 401 or 404 won't succeed on retry, so they surface immediately
433+ * instead of wasting up to 3.5 s of backoff. Network failures, timeouts, and 5xx
434+ * server errors are retried. Exposed as `internal` so the retry/skip rules are
435+ * unit-testable without an HTTP server.
436+ */
437+internal suspend fun <T> withRetryInternal(
438+ maxAttempts: Int = NetworkConfig.retryMaxAttempts,
439+ initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
440+ jitterFactor: Double = NetworkConfig.retryJitterFactor,
441+ block: suspend () -> T,
442+): T {
443+ var lastError: Throwable? = null
444+ for (attempt in 1..maxAttempts) {
445+ try {
446+ return block()
447+ } catch (c: kotlinx.coroutines.CancellationException) {
448+ throw c
449+ } catch (t: ClientRequestException) {
450+ // 408 (Request Timeout) and 429 (Too Many Requests) are transient — retry with
451+ // backoff. All other 4xx are non-retryable (a 401/404 won't succeed on retry).
452+ val status = t.response.status.value
453+ if (status != 408 && status != 429) throw t
454+ lastError = t
455+ if (attempt < maxAttempts) {
456+ delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
457+ }
458+ } catch (t: ServerResponseException) {
459+ // 5xx server errors are retryable with backoff.
460+ lastError = t
461+ if (attempt < maxAttempts) {
462+ delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
463+ }
464+ } catch (t: ResponseException) {
465+ // 3xx (redirect limit exceeded) is non-retryable.
466+ throw t
467+ } catch (t: io.ktor.serialization.ContentConvertException) {
468+ // A malformed/unexpected response body (deserialization failure via content
469+ // negotiation) is deterministic — it fails identically on every attempt — so
470+ // retrying only burns the whole backoff budget re-parsing the same bytes. Fail
471+ // fast instead.
472+ throw t
473+ } catch (t: IllegalArgumentException) {
474+ // Also deterministic: a bad argument, or a raw kotlinx SerializationException
475+ // (which extends IllegalArgumentException) thrown outside content negotiation.
476+ throw t
477+ } catch (t: Exception) {
478+ lastError = t
479+ if (attempt < maxAttempts) {
480+ delay(jitteredBackoff(attempt, initialDelayMs, jitterFactor))
481+ }
482+ }
483+ }
484+ throw lastError ?: IllegalStateException("withRetry failed without error")
485+}
486+
487+/** Computes an exponential backoff delay with symmetric jitter. Exposed as `internal`
488+ * so the backoff formula is unit-testable without an HTTP server. */
489+internal fun jitteredBackoff(
490+ attempt: Int,
491+ initialDelayMs: Long,
492+ jitterFactor: Double,
493+): Long {
494+ // Guard against Int overflow from the shift and Long overflow from
495+ // the multiplication at high attempt counts.
496+ val shift = (attempt - 1).coerceAtMost(30)
497+ val baseDelay = (initialDelayMs * (1L shl shift)).coerceAtMost(NetworkConfig.retryMaxDelayMs)
498+ // Symmetric jitter: vary by ±jitterFactor so the delay is sometimes
499+ // shorter, sometimes longer — spreads concurrent client retries and
500+ // prevents thundering-herd reconnection storms.
501+ val jitter = ((baseDelay * jitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
502+ return (baseDelay + jitter).coerceAtLeast(0)
503+}
504+
505+/** URL-encode a path segment, replacing + with %20 (URLEncoder uses query-param encoding).
506+ * URLEncoder leaves '.' unencoded, so a "." or ".." segment would otherwise survive as a real
507+ * path-traversal component; percent-encode a segment that is nothing but dots. */
508+private fun encode(segment: String): String {
509+ val encoded = URLEncoder.encode(segment, "UTF-8").replace("+", "%20")
510+ return if (encoded.isNotEmpty() && encoded.all { it == '.' }) encoded.replace(".", "%2E") else encoded
511+}
app/src/main/java/soy/iko/opencode/data/network/OpencodeJson.ktnew file+37
@@ -0,0 +1,37 @@
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.ktnew file+114
@@ -0,0 +1,114 @@
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.ktnew file+130
@@ -0,0 +1,130 @@
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+ val themeMode: String,
26+ val dynamicColor: Boolean,
27+ val sendOnEnter: Boolean,
28+ val appLock: Boolean,
29+ val chatTextScale: Float,
30+ val codeWrap: Boolean,
31+)
32+
33+/** The full backup document. [version] guards against future format changes. */
34+@Serializable
35+data class BackupData(
36+ val version: Int = 1,
37+ val servers: List<BackupServer> = emptyList(),
38+ val settings: BackupSettings? = null,
39+ val pinned: List<String> = emptyList(),
40+ val archived: List<String> = emptyList(),
41+)
42+
43+/**
44+ * Exports/imports the app's servers, settings, and per-session flags as a single JSON
45+ * document (via the Storage Access Framework in the UI). Passwords are only written when the
46+ * user explicitly opts in, since the exported file is plaintext and leaves the app's encrypted
47+ * store.
48+ */
49+class BackupManager(
50+ private val profileStore: ProfileStore,
51+ private val settingsStore: SettingsStore,
52+ private val sessionPrefsStore: SessionPrefsStore,
53+) {
54+ private val json = Json { prettyPrint = true; ignoreUnknownKeys = true; encodeDefaults = true }
55+
56+ /** Build the backup JSON. Reads the current profiles/settings/flags. */
57+ suspend fun export(includePasswords: Boolean): String {
58+ val servers = profileStore.profiles.first().map {
59+ BackupServer(
60+ id = it.id,
61+ label = it.label,
62+ baseUrl = it.baseUrl,
63+ username = it.username,
64+ password = if (includePasswords) it.password else null,
65+ requireHttps = it.requireHttps,
66+ certPin = it.certPin,
67+ )
68+ }
69+ val settings = BackupSettings(
70+ themeMode = settingsStore.themeMode.first().name,
71+ dynamicColor = settingsStore.dynamicColor.first(),
72+ sendOnEnter = settingsStore.sendOnEnter.first(),
73+ appLock = settingsStore.appLock.first(),
74+ chatTextScale = settingsStore.chatTextScale.first(),
75+ codeWrap = settingsStore.codeWrap.first(),
76+ )
77+ val data = BackupData(
78+ servers = servers,
79+ settings = settings,
80+ pinned = sessionPrefsStore.pinned.first().toList(),
81+ archived = sessionPrefsStore.archived.first().toList(),
82+ )
83+ return json.encodeToString(data)
84+ }
85+
86+ /** Apply a backup document. Throws on malformed JSON so the caller can report it. Servers
87+ * are upserted (existing ids overwritten); settings and flags are restored best-effort. */
88+ suspend fun import(text: String) {
89+ val data = json.decodeFromString<BackupData>(text)
90+ for (s in data.servers) {
91+ val username = s.username?.takeIf { it.isNotBlank() }
92+ val incomingPassword = s.password?.takeIf { it.isNotEmpty() }
93+ // A backup exported *without* passwords carries null. This is an upsert, so don't let
94+ // that null wipe a password already stored for this server id — preserve the existing
95+ // secret when the backup didn't include one (only the backup explicitly carrying a
96+ // password should overwrite it). Re-importing a password-less backup would otherwise
97+ // silently break auth for every matching server.
98+ val password = incomingPassword ?: username?.let {
99+ profileStore.resolve(
100+ ServerProfile(id = s.id, label = s.label, baseUrl = s.baseUrl, username = it),
101+ ).password
102+ }
103+ profileStore.save(
104+ ServerProfile(
105+ id = s.id,
106+ label = s.label,
107+ baseUrl = s.baseUrl,
108+ username = username,
109+ password = password,
110+ lastUsed = 0,
111+ requireHttps = s.requireHttps,
112+ certPin = s.certPin?.takeIf { it.isNotBlank() },
113+ ),
114+ )
115+ }
116+ data.settings?.let { settings ->
117+ // Parse the enum defensively (a bad name from an edited backup shouldn't abort the
118+ // import) WITHOUT wrapping the suspend setThemeMode call — wrapping it would swallow
119+ // a CancellationException and break structured concurrency.
120+ ThemeMode.entries.find { it.name == settings.themeMode }?.let { settingsStore.setThemeMode(it) }
121+ settingsStore.setDynamicColor(settings.dynamicColor)
122+ settingsStore.setSendOnEnter(settings.sendOnEnter)
123+ settingsStore.setAppLock(settings.appLock)
124+ settingsStore.setChatTextScale(settings.chatTextScale)
125+ settingsStore.setCodeWrap(settings.codeWrap)
126+ }
127+ data.pinned.forEach { sessionPrefsStore.setPinned(it, true) }
128+ data.archived.forEach { sessionPrefsStore.setArchived(it, true) }
129+ }
130+}
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.ktnew file+233
@@ -0,0 +1,233 @@
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+
47+ private val _reports = MutableStateFlow<List<CrashReport>>(emptyList())
48+ val reports: StateFlow<List<CrashReport>> = _reports.asStateFlow()
49+
50+ fun install() {
51+ val previous = Thread.getDefaultUncaughtExceptionHandler()
52+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
53+ runCatching { writeReport(thread, throwable) }
54+ .onFailure { Log.e("CrashLogger", "Failed to write crash report", it) }
55+ previous?.uncaughtException(thread, throwable)
56+ }
57+ // Load crash reports off the main thread so startup isn't blocked by file I/O
58+ // (listFiles + reading the first line of each report). The StateFlow updates
59+ // whenever the scan completes, so the Diagnostics screen reflects the result.
60+ scope.launch { runCatchingCancellable { refresh() } }
61+ }
62+
63+ /** Monotonic guard so a slower earlier scan can't overwrite a newer one's result.
64+ * refresh() runs on the multi-threaded IO dispatcher from several call sites
65+ * (install/deleteReport/scheduleDelete/clearAll), so two scans can overlap. */
66+ private val refreshGeneration = java.util.concurrent.atomic.AtomicLong(0)
67+
68+ fun refresh() {
69+ val generation = refreshGeneration.incrementAndGet()
70+ val scanned = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }
71+ ?.sortedByDescending { it.lastModified() }
72+ ?.map { f ->
73+ CrashReport(
74+ fileName = f.name,
75+ timestamp = f.lastModified(),
76+ preview = runCatching { f.useLines { it.firstOrNull() ?: "" } }.getOrDefault(""),
77+ )
78+ }
79+ .orEmpty()
80+ // Publish only if no newer refresh has started meanwhile, so a stale scan (e.g. one
81+ // that started before a delete) can't momentarily re-show a just-removed report.
82+ if (generation == refreshGeneration.get()) _reports.value = scanned
83+ }
84+
85+ fun readReport(fileName: String): String? {
86+ val file = File(crashDir, fileName).canonicalFile
87+ if (!file.path.startsWith(crashDir.canonicalPath + File.separator) || !file.isFile) return null
88+ return runCatching { file.readText() }.getOrNull()
89+ }
90+
91+ fun deleteReport(fileName: String) {
92+ scope.launch {
93+ val file = File(crashDir, fileName).canonicalFile
94+ if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
95+ file.delete()
96+ }
97+ refresh()
98+ }
99+ }
100+
101+ /** Deferred deletions keyed by file name, so an Undo can cancel one before it fires. */
102+ private val pendingDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
103+
104+ /**
105+ * Schedule [fileName] for deletion after [delayMs], cancellable via
106+ * [cancelScheduledDelete]. Runs on the logger's own long-lived scope — not the
107+ * Diagnostics screen's composition scope — so navigating away during the undo window
108+ * still commits the delete instead of silently dropping it. A repeated schedule for
109+ * the same name replaces the prior timer.
110+ */
111+ fun scheduleDelete(fileName: String, delayMs: Long) {
112+ val job = scope.launch {
113+ delay(delayMs)
114+ // Atomically claim the delete by removing our own map entry BEFORE deleting.
115+ // The remove is the synchronization point with cancelScheduledDelete: whichever
116+ // removes the entry first wins. If an undo raced the timer and removed it first,
117+ // this returns false and we skip the delete — so the file survives, matching the
118+ // `true` that cancel returned to the UI. Deleting first (then removing) would let
119+ // a cancel land in the gap and falsely report "undo succeeded" for a gone file.
120+ // remove(name, thisJob) also no-ops for a stale job after a reschedule.
121+ val claimed = coroutineContext[Job]?.let { pendingDeletes.remove(fileName, it) } == true
122+ if (!claimed) return@launch
123+ val file = File(crashDir, fileName).canonicalFile
124+ if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
125+ file.delete()
126+ }
127+ refresh()
128+ }
129+ pendingDeletes.put(fileName, job)?.cancel()
130+ }
131+
132+ /** Cancel a pending deferred delete (the Undo action). Returns true if it was still
133+ * pending (undo succeeded), false if the delete had already fired. */
134+ fun cancelScheduledDelete(fileName: String): Boolean {
135+ val job = pendingDeletes.remove(fileName) ?: return false
136+ job.cancel()
137+ return true
138+ }
139+
140+ fun clearAll() {
141+ scope.launch {
142+ crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") }?.forEach { it.delete() }
143+ refresh()
144+ }
145+ }
146+
147+ fun reportCount(): Int = _reports.value.size
148+
149+ /** Cancel the internal coroutine scope so background work doesn't outlive the
150+ * process. Called from [AppContainer.shutdown]. The UncaughtExceptionHandler is
151+ * intentionally left installed — a crash during shutdown still needs to be logged. */
152+ fun shutdown() {
153+ scope.cancel()
154+ }
155+
156+ private fun writeReport(thread: Thread, throwable: Throwable) {
157+ val now = Date()
158+ val stamp = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss-SSS", Locale.US).format(now)
159+ // Append a short random suffix so two crashes in the same millisecond don't
160+ // overwrite each other.
161+ val suffix = java.lang.Long.toHexString(System.nanoTime())
162+ val file = File(crashDir, "crash-$stamp-$suffix.txt")
163+ val sw = StringWriter()
164+ PrintWriter(sw).use { pw ->
165+ pw.println("opencode-android crash report")
166+ pw.println("Time: ${SimpleDateFormat("yyyy-MM-dd HH:mm:ss z", Locale.US).format(now)}")
167+ pw.println("Thread: ${thread.name}")
168+ pw.println("App version: ${appVersion()}")
169+ pw.println("Device: ${Build.MANUFACTURER} ${Build.MODEL}")
170+ pw.println("Android: ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})")
171+ pw.println("ABI: ${Build.SUPPORTED_ABIS.joinToString()}")
172+ pw.println()
173+ pw.println(throwable.javaClass.name + ": " + throwable.message)
174+ throwable.printStackTrace(pw)
175+ }
176+ // Scrub URLs from the *entire* report. printStackTrace re-emits the exception
177+ // message (and every "Caused by:" message) verbatim, so scrubbing only the
178+ // top-level message above is futile — Ktor/OkHttp embed the full request URL
179+ // in those messages, which may contain auth or internal paths. Scrubbing the
180+ // assembled text guarantees no URL survives anywhere in the stored report.
181+ file.writeText(scrubUrls(sw.toString()))
182+ trimOldReports()
183+ }
184+
185+ /** Keep only the [MAX_REPORTS] most recent crash reports, deleting older ones. Runs inside
186+ * the uncaught-exception handler (the whole call is wrapped in runCatching upstream), so it
187+ * must not throw; each delete is guarded. */
188+ private fun trimOldReports() {
189+ val files = crashDir.listFiles { f -> f.isFile && f.name.endsWith(".txt") } ?: return
190+ if (files.size <= MAX_REPORTS) return
191+ files.sortedByDescending { it.lastModified() }
192+ .drop(MAX_REPORTS)
193+ .forEach { runCatching { it.delete() } }
194+ }
195+
196+ private fun appVersion(): String = runCatching {
197+ val pi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
198+ appContext.packageManager.getPackageInfo(
199+ appContext.packageName,
200+ PackageManager.PackageInfoFlags.of(0),
201+ )
202+ } else {
203+ @Suppress("DEPRECATION")
204+ appContext.packageManager.getPackageInfo(appContext.packageName, 0)
205+ }
206+ "${pi.versionName} (${PackageInfoCompat.getLongVersionCode(pi)})"
207+ }.getOrDefault("unknown")
208+
209+ companion object {
210+ /** Cap on retained crash report files. A deterministic crash-loop (e.g. a crash on
211+ * startup) writes one report per crash with no other bound, so without this the
212+ * crashes/ dir grows without limit until the user manually clears it. */
213+ private const val MAX_REPORTS = 20
214+
215+ /** Regex matching http(s) URLs, shared by report writing and [scrubUrls]. */
216+ internal val SCRUB_URL_REGEX = Regex("https?://[^\\s\"']+")
217+
218+ /**
219+ * Replace every http(s) URL in [text] with `[url]`. Exposed as `internal` so the
220+ * redaction (used on the whole crash report, including exception messages and
221+ * stack traces produced by printStackTrace) can be unit-tested in isolation.
222+ */
223+ internal fun scrubUrls(text: String): String = SCRUB_URL_REGEX.replace(text, "[url]")
224+
225+ @Volatile
226+ private var instance: CrashLogger? = null
227+
228+ fun get(context: Context): CrashLogger =
229+ instance ?: synchronized(this) {
230+ instance ?: CrashLogger(context.applicationContext).also { instance = it }
231+ }
232+ }
233+}
app/src/main/java/soy/iko/opencode/data/repo/DraftStore.ktnew file+268
@@ -0,0 +1,268 @@
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+ val sp = appContext?.getSharedPreferences("drafts", Context.MODE_PRIVATE)
34+ ?: error("No context — override methods in test subclass")
35+ prefsInitialized.set(true)
36+ sp
37+ }
38+
39+ /** Tracks whether the [prefs] lazy has been resolved (i.e. the file has been
40+ * opened from disk). Accessed from [flushDraft] to decide whether a synchronous
41+ * write on the main thread is safe or whether the write must be deferred to a
42+ * background thread to avoid blocking on first-time disk I/O. */
43+ private val prefsInitialized = java.util.concurrent.atomic.AtomicBoolean(false)
44+
45+ /** Single-thread executor for flushDraft when prefs aren't initialized yet,
46+ * so the main thread is never blocked on disk I/O. */
47+ private val flushExecutor by lazy {
48+ Executors.newSingleThreadExecutor { r ->
49+ Thread(r, "DraftStore-flush").apply { isDaemon = true }
50+ }
51+ }
52+
53+ private val _drafts = MutableStateFlow<Map<String, String>>(emptyMap())
54+ /** The full in-memory snapshot of all drafts, updated when prefs finish loading. */
55+ open val drafts: StateFlow<Map<String, String>> = _drafts.asStateFlow()
56+
57+ /** Queued follow-up messages per session, persisted under a namespaced key so a
58+ * follow-up queued mid-run survives process death (the in-memory-only StateFlow in
59+ * ChatViewModel would otherwise lose it, along with the cleared draft). */
60+ private val _followUps = MutableStateFlow<Map<String, String>>(emptyMap())
61+
62+ private val _ready = MutableStateFlow(false)
63+ /** True once the background prefs load has completed. */
64+ open val ready: StateFlow<Boolean> = _ready.asStateFlow()
65+
66+ /** Keys written in-memory (set / cleared / removed) *before* the async prefs load
67+ * finished. For these the in-memory state is authoritative — the load must not
68+ * resurrect a persisted value the user has since cleared, which a plain putAll(current)
69+ * merge cannot express (it can add/override, but not delete). Consulted once, in the
70+ * init merge. Separate sets because drafts and follow-ups share the session-id key space
71+ * but live under different prefs keys. */
72+ // ConcurrentHashMap-backed sets: weakly-consistent iteration so the init merge's forEach
73+ // can't throw ConcurrentModificationException if a mutator adds a key concurrently.
74+ private val touchedDraftKeys: MutableSet<String> = java.util.concurrent.ConcurrentHashMap.newKeySet()
75+ private val touchedFollowUpKeys: MutableSet<String> = java.util.concurrent.ConcurrentHashMap.newKeySet()
76+
77+ private fun markDraftTouched(sessionId: String) {
78+ if (!_ready.value) touchedDraftKeys.add(sessionId)
79+ }
80+ private fun markFollowUpTouched(sessionId: String) {
81+ if (!_ready.value) touchedFollowUpKeys.add(sessionId)
82+ }
83+
84+ init {
85+ // Eagerly load the prefs file on a background thread so the first main-thread
86+ // get() doesn't block on disk I/O. The loaded snapshot is published via [_drafts]
87+ // so callers can observe readiness without blocking.
88+ if (scope != null && appContext != null) {
89+ scope.launch(Dispatchers.IO) {
90+ val all = runCatching {
91+ prefs.all.mapValues { it.value as? String ?: "" }
92+ }.getOrDefault(emptyMap())
93+ // Partition persisted keys: follow-up keys carry a prefix, everything else
94+ // is a plain per-session draft.
95+ val drafts = mutableMapOf<String, String>()
96+ val followUps = mutableMapOf<String, String>()
97+ all.forEach { (k, v) ->
98+ if (k.startsWith(FOLLOWUP_PREFIX)) followUps[k.removePrefix(FOLLOWUP_PREFIX)] = v
99+ else drafts[k] = v
100+ }
101+ // Merge instead of overwriting: any in-memory writes that arrived before
102+ // the background load completed (e.g. a share-intent draft injected via
103+ // setImmediate) take priority over the persisted snapshot so they aren't
104+ // clobbered. For keys the user explicitly touched before the load finished,
105+ // drop the persisted entry first so a value they *cleared* isn't resurrected
106+ // (the cleared key is absent from `current`, so putAll alone can't remove it).
107+ _drafts.update { current ->
108+ drafts.apply {
109+ touchedDraftKeys.forEach { remove(it) }
110+ putAll(current)
111+ }
112+ }
113+ _followUps.update { current ->
114+ followUps.apply {
115+ touchedFollowUpKeys.forEach { remove(it) }
116+ putAll(current)
117+ }
118+ }
119+ _ready.value = true
120+ }
121+ } else {
122+ _ready.value = true
123+ }
124+ }
125+
126+ /** Returns the draft for [sessionId], or "" if not found or still loading. */
127+ open fun get(sessionId: String): String = _drafts.value[sessionId].orEmpty()
128+
129+ /** Returns the queued follow-up for [sessionId], or "" if none / still loading. */
130+ open fun getFollowUp(sessionId: String): String = _followUps.value[sessionId].orEmpty()
131+
132+ /** Persist a queued follow-up immediately (fire-and-forget, main-thread-safe).
133+ * Mirrors [flushDraft]: async apply() when prefs are open, else a background thread,
134+ * so a follow-up queued just before process death is flushed before the process exits.
135+ * A blank [text] clears the stored follow-up. */
136+ open fun flushFollowUp(sessionId: String, text: String) {
137+ markFollowUpTouched(sessionId)
138+ _followUps.update { current ->
139+ val updated = current.toMutableMap()
140+ if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
141+ updated
142+ }
143+ writeAsync(FOLLOWUP_PREFIX + sessionId, text)
144+ }
145+
146+ /** Persist [key]=[text] (or remove it when blank) without blocking the main thread on
147+ * first-time disk I/O — see [flushDraft] for the prefs-not-yet-open rationale. */
148+ private fun writeAsync(key: String, text: String) {
149+ val write = {
150+ runCatching {
151+ prefs.edit().apply {
152+ if (text.isBlank()) remove(key) else putString(key, text)
153+ }.apply()
154+ }.onFailure { Log.w("DraftStore", "Failed to persist $key", it) }
155+ }
156+ // FIX: always submit to the single-thread flushExecutor (never apply directly) so
157+ // writes persist in submission order. Previously the post-init fast path applied
158+ // synchronously on the caller's thread, which could land BEFORE an earlier still-queued
159+ // write, persisting stale text. The task resolves the prefs lazy and calls apply().
160+ runCatching { flushExecutor.execute { write() } }
161+ }
162+
163+ /** Update the in-memory draft immediately without persisting to disk.
164+ * Used when navigation must see the new value synchronously (e.g. share-intent
165+ * injection before ChatScreen composes). Call [set] afterwards to persist. */
166+ open fun setImmediate(sessionId: String, text: String) {
167+ markDraftTouched(sessionId)
168+ _drafts.update { current ->
169+ val updated = current.toMutableMap()
170+ if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
171+ updated
172+ }
173+ }
174+
175+ open suspend fun set(sessionId: String, text: String) {
176+ markDraftTouched(sessionId)
177+ _drafts.update { current ->
178+ val updated = current.toMutableMap()
179+ if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
180+ updated
181+ }
182+ // Route persistence through the single-thread flushExecutor (like the other writers)
183+ // rather than the shared IO pool, so this write can't race ahead of an earlier queued
184+ // flush and land stale. writeAsync persists sessionId=text (or removes it when blank),
185+ // which is exactly this draft's persistence.
186+ writeAsync(sessionId, text)
187+ }
188+
189+ /** Remove the draft for a session (call on session deletion to avoid orphaned entries).
190+ * Also clears any queued follow-up so a deleted session leaves nothing behind. */
191+ open suspend fun remove(sessionId: String) {
192+ markDraftTouched(sessionId)
193+ markFollowUpTouched(sessionId)
194+ _drafts.update { current ->
195+ val updated = current.toMutableMap()
196+ updated.remove(sessionId)
197+ updated
198+ }
199+ _followUps.update { current ->
200+ val updated = current.toMutableMap()
201+ updated.remove(sessionId)
202+ updated
203+ }
204+ // Submit the (combined draft + follow-up) removal to the same single-thread
205+ // flushExecutor the other writers use, so it can't race ahead of an earlier queued
206+ // write and resurrect stale text. Mirrors flushDraft's submission pattern.
207+ runCatching {
208+ flushExecutor.execute {
209+ runCatching {
210+ prefs.edit().remove(sessionId).remove(FOLLOWUP_PREFIX + sessionId).apply()
211+ }.onFailure { Log.w("DraftStore", "Failed to remove draft for $sessionId", it) }
212+ }
213+ }
214+ }
215+
216+ /**
217+ * Synchronously persist a draft. Intended for [ViewModel.onCleared] where the
218+ * viewModelScope is already cancelled and a suspending write can't be used.
219+ *
220+ * If the SharedPreferences file has already been opened (the normal case — the
221+ * background init load touches it shortly after construction), the write uses
222+ * [apply] (asynchronous) and returns immediately without blocking the main thread.
223+ *
224+ * If the prefs file has NOT been opened yet (a rare race where onCleared fires
225+ * before the background init), the write is dispatched to a background thread
226+ * instead of blocking the main thread on a synchronous disk read. The draft
227+ * may be lost if the process exits before the write completes, but this is
228+ * preferable to an ANR.
229+ */
230+ open fun flushDraft(sessionId: String, text: String) {
231+ markDraftTouched(sessionId)
232+ _drafts.update { current ->
233+ val updated = current.toMutableMap()
234+ if (text.isBlank()) updated.remove(sessionId) else updated[sessionId] = text
235+ updated
236+ }
237+ // FIX: always route the persistence through the single-thread flushExecutor so writes
238+ // land in submission order. Previously, once prefs were open this applied synchronously
239+ // on the calling (main) thread, so a newer direct write could persist BEFORE an older
240+ // still-queued write completed, clobbering the draft with stale text. The executor task
241+ // resolves the prefs lazy and uses apply() (async) exactly as before; the write may be
242+ // lost only if the process exits before the queued task runs (as previously).
243+ runCatching {
244+ flushExecutor.execute {
245+ runCatching {
246+ prefs.edit().apply {
247+ if (text.isBlank()) remove(sessionId) else putString(sessionId, text)
248+ }.apply()
249+ }.onFailure { Log.w("DraftStore", "Failed to flush draft for $sessionId", it) }
250+ }
251+ }
252+ }
253+
254+ private companion object {
255+ /** Key prefix distinguishing persisted follow-ups from plain drafts in the shared
256+ * prefs file. The leading/trailing spaces can't collide with a real opencode session
257+ * id (which are non-blank, space-free tokens like "ses_..."). */
258+ const val FOLLOWUP_PREFIX = " followup "
259+ }
260+
261+ /** Shut down the background flush executor. Call from AppContainer.shutdown(). */
262+ open fun shutdown() {
263+ flushExecutor.shutdown()
264+ // Await pending flushDraft writes so drafts aren't lost if the process exits
265+ // immediately after shutdown (e.g. ANR-triggered process kill).
266+ runCatching { flushExecutor.awaitTermination(2, java.util.concurrent.TimeUnit.SECONDS) }
267+ }
268+}
app/src/main/java/soy/iko/opencode/data/repo/ErrorKind.ktnew file+78
@@ -0,0 +1,78 @@
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.ktnew file+110
@@ -0,0 +1,110 @@
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+ /** Load the cached messages for [sessionId] under [profileId], or empty if none / unreadable. */
59+ open suspend fun load(profileId: String, sessionId: String): List<MessageWithParts> = lockFor(sessionId).withLock {
60+ withContext(Dispatchers.IO) {
61+ val file = fileFor(profileId, sessionId)?.takeIf { it.exists() } ?: return@withContext emptyList()
62+ runCatchingCancellable { OpencodeJson.decodeFromString(serializer, file.readText()) }
63+ .getOrDefault(emptyList())
64+ }
65+ }
66+
67+ /** Persist [messages] for [sessionId] under [profileId] (an empty list deletes the file). */
68+ open suspend fun save(profileId: String, sessionId: String, messages: List<MessageWithParts>): Unit = lockFor(sessionId).withLock {
69+ withContext(Dispatchers.IO) {
70+ val file = fileFor(profileId, sessionId) ?: return@withContext
71+ runCatchingCancellable {
72+ if (messages.isEmpty()) file.delete()
73+ else {
74+ // FIX: write atomically (temp file + rename) like RecentSessionsStore so a
75+ // process kill mid-write can't leave a truncated file that fails to decode,
76+ // wiping the cached conversation on the next open.
77+ val encoded = OpencodeJson.encodeToString(serializer, messages)
78+ val tmp = File(file.parentFile, file.name + ".tmp")
79+ tmp.writeText(encoded)
80+ if (!tmp.renameTo(file)) {
81+ file.writeText(encoded)
82+ tmp.delete()
83+ }
84+ }
85+ }.onFailure { Log.w("MessageCacheStore", "Failed to cache messages for $sessionId", it) }
86+ Unit
87+ }
88+ }
89+
90+ /** Remove a session's cached messages (call on deletion). Clears the entry in every profile
91+ * namespace: session ids are unique per server and deletion is terminal, so the caller
92+ * needn't know which profile owned it. The per-session lock is intentionally NOT evicted —
93+ * a concurrent teardown save() for this id must serialize against the same [Mutex] instance,
94+ * and the map is bounded by the number of distinct sessions touched. */
95+ open suspend fun remove(sessionId: String) {
96+ lockFor(sessionId).withLock {
97+ withContext(Dispatchers.IO) {
98+ runCatchingCancellable {
99+ val name = idRegex.replace(sessionId, "_") + ".json"
100+ dir?.listFiles()?.forEach { entry ->
101+ if (entry.isDirectory) File(entry, name).delete()
102+ }
103+ // Back-compat: pre-namespacing caches were written flat under [dir].
104+ dir?.let { File(it, name).delete() }
105+ }
106+ Unit
107+ }
108+ }
109+ }
110+}
app/src/main/java/soy/iko/opencode/data/repo/OutboxStore.ktnew file+158
@@ -0,0 +1,158 @@
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. */
78+ private suspend fun loadLocked() {
79+ if (loaded) return
80+ val f = file?.takeIf { it.exists() }
81+ if (f == null) {
82+ // A genuinely absent file is a normal empty queue: mark loaded so mutations proceed.
83+ loaded = true
84+ return
85+ }
86+ // Only mark [loaded] AFTER a successful read. A transient read/decode failure (or a
87+ // cancelled read) of an *existing* file must not clobber the still-valid on-disk queue:
88+ // leave loaded=false and return so a later mutate/load retries the read before any write,
89+ // instead of persisting an empty snapshot over the user's offline-composed messages.
90+ val diskList = withContext(Dispatchers.IO) {
91+ runCatchingCancellable { json.decodeFromString<List<OutboxMessage>>(f.readText()) }
92+ }.getOrElse { return }
93+ // Don't clobber items enqueued while the load was in flight: merge by id, keeping
94+ // the in-memory version on conflict (it's newer).
95+ if (_messages.value.isEmpty()) {
96+ _messages.value = diskList.sortedBy { it.createdAt }
97+ } else {
98+ val known = _messages.value.mapTo(mutableSetOf()) { it.id }
99+ val merged = (diskList.filter { it.id !in known } + _messages.value).sortedBy { it.createdAt }
100+ _messages.value = merged
101+ // The merge reconciled items enqueued while this load was in flight with the
102+ // on-disk set; write the merged set back so a later process kill can't drop the
103+ // raced enqueue. persist() doesn't take ioMutex, so this won't deadlock.
104+ persist(merged)
105+ }
106+ loaded = true
107+ }
108+
109+ open suspend fun enqueue(message: OutboxMessage) = mutate { current ->
110+ current.filterNot { it.id == message.id } + message
111+ }
112+
113+ open suspend fun remove(id: String) = mutate { current -> current.filterNot { it.id == id } }
114+
115+ /** Remove every queued message for [sessionId] (e.g. the user discarded them, or the
116+ * session was deleted). */
117+ open suspend fun removeForSession(sessionId: String) =
118+ mutate { current -> current.filterNot { it.sessionId == sessionId } }
119+
120+ private suspend fun mutate(transform: (List<OutboxMessage>) -> List<OutboxMessage>) {
121+ // Persist *under* the lock so concurrent mutations (e.g. the flusher removing sent
122+ // messages while the UI enqueues a new one) can't race their disk writes: without
123+ // this, the older snapshot could land last and drop/resurrect a queued prompt, or two
124+ // overlapping writeText/delete calls could tear the file. The lock serializes both the
125+ // in-memory update and the write, guaranteeing on-disk order matches memory order.
126+ ioMutex.withLock {
127+ // Ensure the persisted queue has been read into memory before mutating: a mutation
128+ // that races ahead of load() would otherwise transform an empty in-memory snapshot
129+ // and persist()/delete() the file, destroying messages queued in a previous run.
130+ loadLocked()
131+ val updated = transform(_messages.value).sortedBy { it.createdAt }
132+ _messages.value = updated
133+ persist(updated)
134+ }
135+ }
136+
137+ private suspend fun persist(list: List<OutboxMessage>) {
138+ val f = file ?: return
139+ withContext(Dispatchers.IO) {
140+ runCatchingCancellable {
141+ if (list.isEmpty()) {
142+ f.delete()
143+ } else {
144+ // FIX: write atomically (temp file + rename) like RecentSessionsStore so a
145+ // process kill mid-write can't leave a truncated file — a torn file fails to
146+ // decode and load() would then drop ALL queued offline messages.
147+ val encoded = json.encodeToString(list)
148+ val tmp = File(f.parentFile, f.name + ".tmp")
149+ tmp.writeText(encoded)
150+ if (!tmp.renameTo(f)) {
151+ f.writeText(encoded)
152+ tmp.delete()
153+ }
154+ }
155+ }.onFailure { Log.w("OutboxStore", "Failed to persist outbox", it) }
156+ }
157+ }
158+}
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.ktnew file+251
@@ -0,0 +1,251 @@
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.ktnew file+64
@@ -0,0 +1,64 @@
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.ktnew file+64
@@ -0,0 +1,64 @@
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.ktnew file+635
@@ -0,0 +1,635 @@
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+ /** Monotonic counter for synthetic UnknownMessage keys, avoiding nanoTime collisions. */
391+ private val unknownCounter = java.util.concurrent.atomic.AtomicLong(0)
392+
393+ /** Maximum number of messages to keep in memory; oldest are evicted when exceeded. */
394+ internal val maxMessages = NetworkConfig.maxInMemoryMessages
395+
396+ fun snapshot(): List<MessageWithParts> = messages.values.map { it.view() }
397+
398+ fun seed(initial: List<MessageWithParts>, prune: Boolean = false, clearPivot: Boolean = true): Boolean {
399+ var changed = false
400+ // On re-seed (after SSE reconnect), remove messages that are no longer in the
401+ // server snapshot (e.g. deleted during the disconnection gap). This keeps the
402+ // in-memory state in sync with the authoritative REST snapshot rather than
403+ // accumulating stale messages forever. Skipped on the initial seed to avoid
404+ // racing with just-arrived SSE events whose messages may not yet be in REST.
405+ if (prune) {
406+ val incomingIds = initial.mapTo(mutableSetOf()) { it.info.id }
407+ val before = messages.size
408+ messages.keys.retainAll(incomingIds)
409+ if (messages.size != before) changed = true
410+ }
411+
412+ for (m in initial) {
413+ val existing = messages[m.info.id]
414+ if (existing == null) {
415+ messages[m.info.id] = holderOf(m)
416+ changed = true
417+ } else {
418+ // A part streamed in between subscribe and this initial load may already
419+ // have populated this message (see observeMessages). Merge instead of
420+ // overwriting so that newer streamed part isn't discarded: take the
421+ // snapshot's part order, swap in the streamed version where ids overlap,
422+ // append any streamed-only parts, and adopt the authoritative REST info.
423+ // existing.parts is already keyed by part id (insertion-ordered).
424+ val streamedById = existing.parts
425+ val snapshotIds = m.parts.mapTo(mutableSetOf()) { it.id }
426+ // Prefer the in-memory version of an overlapping part ONLY if it streamed
427+ // in since the reseed pivot (i.e. during this fetch), so it's newer than
428+ // the snapshot. Otherwise the REST snapshot is authoritative and wins,
429+ // discarding an in-memory part that went stale during a disconnect.
430+ val ordered = m.parts.map { p ->
431+ if (p.id in streamedSincePivot) streamedById[p.id] ?: p else p
432+ }.toMutableList()
433+ for (p in existing.parts.values) {
434+ if (p.id !in snapshotIds) ordered.add(p)
435+ }
436+ // Adopt the authoritative REST info unless the in-memory info was itself
437+ // updated live since the pivot (i.e. during this fetch), in which case it's
438+ // newer than the snapshot. Otherwise the REST snapshot wins, discarding
439+ // in-memory info that went stale during a disconnect.
440+ val info = if (existing.info is UnknownMessage || m.info.id !in messageInfoUpdatedSincePivot) m.info else existing.info
441+ val merged = MessageWithParts(info = info, parts = ordered)
442+ if (merged != existing.view()) {
443+ messages[m.info.id] = holderOf(merged)
444+ changed = true
445+ }
446+ }
447+ }
448+ // A part that streamed in *before* this initial load (observeMessages subscribes to
449+ // SSE before fetching) inserts its message at the front of the insertion-ordered map,
450+ // and the merge above keeps that position — pinning a running message above the older
451+ // history that seed() inserts afterward. Restore chronological order by creation time
452+ // FIRST, so eviction below drops the genuinely-oldest entries: evictOldMessages()
453+ // removes from the map's front, which before reordering is the newest streamed message.
454+ if (reorderByTime()) changed = true
455+ if (evictOldMessages()) changed = true
456+ // Reset the pivot: everything just merged is now the baseline, so only parts/info
457+ // that stream in *after* this seed can outrank the next reconnect's snapshot. The
458+ // on-disk cache seed passes clearPivot = false: it runs *before* the authoritative
459+ // initial REST seed, so clearing here would strip pivot protection from SSE parts/info
460+ // that streamed in since subscribe, letting that REST seed regress them.
461+ if (clearPivot) {
462+ streamedSincePivot.clear()
463+ messageInfoUpdatedSincePivot.clear()
464+ }
465+ return changed
466+ }
467+
468+ /** Remove messages that were seeded from the on-disk cache ([cacheSeededIds]) but are
469+ * absent from the authoritative REST snapshot [rest] — i.e. deleted server-side while the
470+ * app was closed. Restricted to cache-seeded ids so a message added by a live SSE event
471+ * since subscribe (which the REST endpoint may not have indexed yet) is never pruned.
472+ * This is what actually removes ghosts on restart; the reconnect re-seed deliberately
473+ * keeps prune=false to avoid racing just-arrived SSE. Call under the same lock as [seed].
474+ * Returns true if anything was removed. */
475+ fun pruneStaleCacheSeeded(cacheSeededIds: Set<String>, rest: List<MessageWithParts>): Boolean {
476+ if (cacheSeededIds.isEmpty()) return false
477+ val restIds = rest.mapTo(mutableSetOf()) { it.info.id }
478+ var changed = false
479+ for (id in cacheSeededIds) {
480+ if (id !in restIds && messages.remove(id) != null) changed = true
481+ }
482+ return changed
483+ }
484+
485+ /** Reorder the message map by message creation time (stable). Ties and entries with no
486+ * server time yet (e.g. a brand-new message still streaming and not in REST) keep their
487+ * relative order, so such a message stays last. Preserves each entry's map key so the
488+ * synthetic UnknownMessage keys aren't collapsed. Returns true if the order changed. */
489+ private fun reorderByTime(): Boolean {
490+ val entries = messages.entries.map { it.key to it.value }
491+ val sorted = entries.sortedBy { it.second.info.time?.created ?: Long.MAX_VALUE }
492+ // Detect a reordering by comparing keys positionally, instead of allocating two more
493+ // N-sized key lists just to compare them.
494+ var changed = false
495+ for (i in sorted.indices) {
496+ if (sorted[i].first != entries[i].first) { changed = true; break }
497+ }
498+ if (!changed) return false
499+ messages.clear()
500+ for ((k, v) in sorted) messages[k] = v
501+ return true
502+ }
503+
504+ /** Returns true if the state changed (and a new snapshot should be published). */
505+ fun reduce(sessionId: String, event: BusEvent): Boolean {
506+ return when (event) {
507+ is MessageUpdated -> handleMessageUpdated(sessionId, event)
508+
509+ is MessagePartUpdated -> {
510+ val part = event.properties.part
511+ val messageId = part.messageID ?: event.properties.messageID
512+ val partSession = part.sessionID ?: event.properties.sessionID
513+ when {
514+ messageId == null -> false
515+ partSession != null -> if (partSession == sessionId) upsertPart(messageId, part) else false
516+ // No session id anywhere on the event: a null session is NOT a wildcard
517+ // (mirroring isIdle/isError). Every open session's store sees the shared
518+ // event stream, so accepting it unconditionally would leak the part into
519+ // unrelated conversations (e.g. both panes in two-pane mode). Attribute it
520+ // only if this store already holds the parent message — MessageUpdated,
521+ // which always carries a session id, will have created it here first.
522+ else -> if (messages.containsKey(messageId)) upsertPart(messageId, part) else false
523+ }
524+ }
525+
526+ is MessagePartRemoved -> handlePartRemoved(sessionId, event)
527+
528+ is MessageRemoved -> handleMessageRemoved(sessionId, event)
529+
530+ else -> false
531+ }
532+ }
533+
534+ private fun handleMessageUpdated(sessionId: String, event: MessageUpdated): Boolean {
535+ val info = event.properties.info
536+ if (info.sessionID != sessionId) return false
537+ // An UnknownMessage with an empty id (e.g. from an unrecognized server role with no id
538+ // field) would collide with other such messages in the map. Generate a unique synthetic
539+ // key so each unknown message gets its own entry instead of overwriting others.
540+ val key = if (info.id.isEmpty() && info is UnknownMessage) {
541+ "unknown-${unknownCounter.incrementAndGet()}"
542+ } else {
543+ info.id
544+ }
545+ val existing = messages[key]
546+ // A server re-sending byte-identical info is a no-op: skip it so we don't publish a
547+ // redundant snapshot (mirrors upsertPart's identical-part guard).
548+ if (existing != null && existing.info == info) return false
549+ if (existing == null) {
550+ messages[key] = Holder(info, LinkedHashMap())
551+ } else {
552+ existing.info = info
553+ existing.invalidate()
554+ }
555+ // Record that this message's info streamed in live, so a re-seed after a reconnect knows
556+ // it's newer than the REST snapshot and keeps it (see seed()/beginReseed()).
557+ messageInfoUpdatedSincePivot.add(key)
558+ if (existing == null) evictOldMessages()
559+ return true
560+ }
561+
562+ /** Returns false if [eventSession] is non-null and doesn't match [sessionId]. */
563+ private fun matchesSession(eventSession: String?, sessionId: String): Boolean =
564+ eventSession == null || eventSession == sessionId
565+
566+ private fun handlePartRemoved(sessionId: String, event: MessagePartRemoved): Boolean {
567+ val messageId = event.properties.messageID ?: return false
568+ val partId = event.properties.partID ?: return false
569+ if (!matchesSession(event.properties.sessionID, sessionId)) return false
570+ return removePart(messageId, partId)
571+ }
572+
573+ private fun handleMessageRemoved(sessionId: String, event: MessageRemoved): Boolean {
574+ val id = event.properties.messageID ?: return false
575+ if (!matchesSession(event.properties.sessionID, sessionId)) return false
576+ val removed = messages.remove(id) ?: return false
577+ messageInfoUpdatedSincePivot.remove(id)
578+ removed.parts.keys.forEach { streamedSincePivot.remove(it) }
579+ return true
580+ }
581+
582+ private fun upsertPart(messageId: String, part: Part): Boolean {
583+ val current = messages[messageId]
584+ if (current != null) {
585+ // O(1) map lookup + put, vs the former O(P) indexOfFirst scan and full-list copy
586+ // on every streamed token. A LinkedHashMap re-put keeps the part's original
587+ // position (replace-in-place); a new id is appended — matching the old list order.
588+ if (current.parts[part.id] == part) return false
589+ current.parts[part.id] = part
590+ current.invalidate()
591+ } else {
592+ val parts = LinkedHashMap<String, Part>()
593+ parts[part.id] = part
594+ messages[messageId] = Holder(
595+ info = UnknownMessage(id = messageId, sessionID = part.sessionID ?: ""),
596+ parts = parts,
597+ )
598+ }
599+ // Record that this part streamed in, so a re-seed after a reconnect knows it's
600+ // newer than the REST snapshot and keeps it (see seed()/beginReseed()).
601+ streamedSincePivot.add(part.id)
602+ if (current == null) evictOldMessages()
603+ return true
604+ }
605+
606+ private fun removePart(messageId: String, partId: String): Boolean {
607+ val current = messages[messageId] ?: return false
608+ if (current.parts.remove(partId) == null) return false
609+ // Drop the pivot entry too so the set doesn't accumulate ids of removed parts for
610+ // the rest of the session (it's otherwise only cleared on reseed/eviction). No
611+ // correctness impact today — a stale entry just falls back to the REST snapshot's
612+ // version in seed() — but keeping it symmetric with handleMessageRemoved/eviction
613+ // bounds the set's growth over a long-lived session.
614+ streamedSincePivot.remove(partId)
615+ current.invalidate()
616+ return true
617+ }
618+
619+ /** Evict the oldest messages when the store exceeds [maxMessages], keeping memory bounded.
620+ * Returns true if any messages were evicted. */
621+ private fun evictOldMessages(): Boolean {
622+ var evicted = false
623+ while (messages.size > maxMessages) {
624+ val oldestKey = messages.keys.iterator().next()
625+ val removed = messages.remove(oldestKey)
626+ // Prune the evicted message's pivot bookkeeping so these sets don't grow
627+ // unbounded over a long-running session that never reconnects (they're only
628+ // otherwise cleared on reseed/seed).
629+ messageInfoUpdatedSincePivot.remove(oldestKey)
630+ removed?.parts?.keys?.forEach { streamedSincePivot.remove(it) }
631+ evicted = true
632+ }
633+ return evicted
634+ }
635+}
app/src/main/java/soy/iko/opencode/data/repo/SettingsStore.ktnew file+98
@@ -0,0 +1,98 @@
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.stringPreferencesKey
8+import androidx.datastore.preferences.preferencesDataStore
9+import kotlinx.coroutines.flow.Flow
10+import kotlinx.coroutines.flow.map
11+
12+enum class ThemeMode { SYSTEM, LIGHT, DARK }
13+
14+private val Context.settingsDataStore by preferencesDataStore(name = "settings")
15+
16+/** App-preferences store: theme mode, dynamic color (Material You), input behavior. */
17+class SettingsStore(context: Context) {
18+
19+ private val appContext = context.applicationContext
20+ private val themeKey = stringPreferencesKey("theme_mode")
21+ private val dynamicColorKey = booleanPreferencesKey("dynamic_color")
22+ private val sendOnEnterKey = booleanPreferencesKey("send_on_enter")
23+ private val appLockKey = booleanPreferencesKey("app_lock")
24+ private val chatTextScaleKey = floatPreferencesKey("chat_text_scale")
25+ private val codeWrapKey = booleanPreferencesKey("code_wrap")
26+
27+ companion object {
28+ /** Bounds for the chat text-size multiplier so the UI can't be scaled into
29+ * unreadability or an unusable micro-font. 1.0 is the design default. */
30+ const val MIN_CHAT_TEXT_SCALE = 0.8f
31+ const val MAX_CHAT_TEXT_SCALE = 1.6f
32+ const val DEFAULT_CHAT_TEXT_SCALE = 1.0f
33+ }
34+
35+ val themeMode: Flow<ThemeMode> = appContext.settingsDataStore.data.map { prefs ->
36+ runCatching { ThemeMode.valueOf(prefs[themeKey] ?: ThemeMode.SYSTEM.name) }
37+ .getOrDefault(ThemeMode.SYSTEM)
38+ }
39+
40+ val dynamicColor: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
41+ prefs[dynamicColorKey] ?: true
42+ }
43+
44+ /** When true, the hardware Enter key sends the prompt; when false, Enter inserts a
45+ * newline and Ctrl+Enter sends. Soft-keyboard IME action always sends. Defaults to
46+ * true to match the prior hardcoded behavior. */
47+ val sendOnEnter: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
48+ prefs[sendOnEnterKey] ?: true
49+ }
50+
51+ /** When true, the app requires device biometric/credential authentication on launch
52+ * (and when returning from the background) before the UI is shown, protecting the
53+ * stored server credentials. Defaults to false so the app is usable out of the box. */
54+ val appLock: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
55+ prefs[appLockKey] ?: false
56+ }
57+
58+ /** Multiplier applied to chat message (markdown) and code text so users can size the
59+ * conversation for readability independently of the system font scale. Clamped to
60+ * [[MIN_CHAT_TEXT_SCALE], [MAX_CHAT_TEXT_SCALE]] on read so a corrupt/out-of-range
61+ * stored value can never produce an unreadable UI. Defaults to 1.0 (design size). */
62+ val chatTextScale: Flow<Float> = appContext.settingsDataStore.data.map { prefs ->
63+ (prefs[chatTextScaleKey] ?: DEFAULT_CHAT_TEXT_SCALE)
64+ .coerceIn(MIN_CHAT_TEXT_SCALE, MAX_CHAT_TEXT_SCALE)
65+ }
66+
67+ /** When true, fenced/indented code blocks soft-wrap long lines instead of scrolling
68+ * horizontally. Defaults to false (horizontal scroll) to preserve code formatting. */
69+ val codeWrap: Flow<Boolean> = appContext.settingsDataStore.data.map { prefs ->
70+ prefs[codeWrapKey] ?: false
71+ }
72+
73+ suspend fun setThemeMode(mode: ThemeMode) {
74+ appContext.settingsDataStore.edit { it[themeKey] = mode.name }
75+ }
76+
77+ suspend fun setDynamicColor(enabled: Boolean) {
78+ appContext.settingsDataStore.edit { it[dynamicColorKey] = enabled }
79+ }
80+
81+ suspend fun setSendOnEnter(enabled: Boolean) {
82+ appContext.settingsDataStore.edit { it[sendOnEnterKey] = enabled }
83+ }
84+
85+ suspend fun setAppLock(enabled: Boolean) {
86+ appContext.settingsDataStore.edit { it[appLockKey] = enabled }
87+ }
88+
89+ suspend fun setChatTextScale(scale: Float) {
90+ appContext.settingsDataStore.edit {
91+ it[chatTextScaleKey] = scale.coerceIn(MIN_CHAT_TEXT_SCALE, MAX_CHAT_TEXT_SCALE)
92+ }
93+ }
94+
95+ suspend fun setCodeWrap(enabled: Boolean) {
96+ appContext.settingsDataStore.edit { it[codeWrapKey] = enabled }
97+ }
98+}
app/src/main/java/soy/iko/opencode/di/AppContainer.ktnew file+1109
@@ -0,0 +1,1109 @@
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.repo.AttachmentDraftStore
23+import soy.iko.opencode.data.repo.BackupManager
24+import soy.iko.opencode.data.repo.DraftStore
25+import soy.iko.opencode.data.repo.ErrorKind
26+import soy.iko.opencode.data.repo.MessageCacheStore
27+import soy.iko.opencode.data.repo.OutboxMessage
28+import soy.iko.opencode.data.repo.OutboxStore
29+import soy.iko.opencode.data.repo.SessionPrefsStore
30+import soy.iko.opencode.data.repo.SessionRepository
31+import soy.iko.opencode.data.repo.ProfileStore
32+import soy.iko.opencode.data.repo.SettingsStore
33+import soy.iko.opencode.data.repo.classifyError
34+import soy.iko.opencode.data.repo.responseStatusCode
35+import soy.iko.opencode.data.network.EventStreamClient
36+import soy.iko.opencode.data.network.HttpClientFactory
37+import soy.iko.opencode.data.network.NetworkConfig
38+import soy.iko.opencode.data.network.OpencodeApiClient
39+import soy.iko.opencode.notification.NotificationChannels
40+import soy.iko.opencode.notification.RunForegroundService
41+import soy.iko.opencode.util.safeExceptionSummary
42+import soy.iko.opencode.notification.SessionNotifications
43+import soy.iko.opencode.R
44+import soy.iko.opencode.util.runCatchingCancellable
45+import kotlinx.coroutines.CoroutineScope
46+import kotlinx.coroutines.Dispatchers
47+import kotlinx.coroutines.ExperimentalCoroutinesApi
48+import kotlinx.coroutines.SupervisorJob
49+import kotlinx.coroutines.cancel
50+import kotlinx.coroutines.delay
51+import kotlinx.coroutines.flow.MutableStateFlow
52+import kotlinx.coroutines.flow.StateFlow
53+import kotlinx.coroutines.flow.asStateFlow
54+import kotlinx.coroutines.flow.collectLatest
55+import kotlinx.coroutines.flow.combine
56+import kotlinx.coroutines.flow.debounce
57+import kotlinx.coroutines.flow.distinctUntilChanged
58+import kotlinx.coroutines.flow.emptyFlow
59+import kotlinx.coroutines.flow.first
60+import kotlinx.coroutines.Job
61+import kotlinx.coroutines.flow.flatMapLatest
62+import kotlinx.coroutines.flow.map
63+import kotlinx.coroutines.flow.update
64+import kotlinx.coroutines.isActive
65+import kotlinx.coroutines.launch
66+import kotlinx.coroutines.sync.Mutex
67+import kotlinx.coroutines.sync.withLock
68+import kotlinx.coroutines.withTimeoutOrNull
69+import java.util.concurrent.atomic.AtomicBoolean
70+
71+/**
72+ * Result of probing a server URL to check connectivity and whether authentication
73+ * is required, without persisting a profile or opening a long-lived connection.
74+ */
75+sealed class ProbeResult {
76+ /** Server is reachable and accepts unauthenticated requests. */
77+ object Reachable : ProbeResult()
78+
79+ /** Server is reachable but rejected the probe with an authentication error. */
80+ object NeedsAuth : ProbeResult()
81+
82+ /** Server could not be reached (host unresolved, connection refused, timeout, etc.). */
83+ data class Unreachable(val error: String) : ProbeResult()
84+}
85+
86+/**
87+ * Hand-written service locator held by [soy.iko.opencode.OpencodeApp]. Owns the
88+ * process-wide singletons and the currently active [OpencodeConnection].
89+ */
90+open class AppContainer private constructor(
91+ private val appContext: Context?,
92+ private val skipInit: Boolean,
93+ @Suppress("unused") private val testMode: Boolean,
94+) {
95+ constructor(context: Context) : this(context.applicationContext, false, false)
96+ protected constructor() : this(null, true, true)
97+
98+ private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
99+ open val profileStore: ProfileStore by lazy { ProfileStore(appContext!!) }
100+ open val settingsStore: SettingsStore by lazy { SettingsStore(appContext!!) }
101+ open val draftStore: DraftStore by lazy { DraftStore(appContext!!, appScope) }
102+ open val attachmentDraftStore: AttachmentDraftStore by lazy { AttachmentDraftStore(appContext!!) }
103+ open val sessionPrefsStore: SessionPrefsStore by lazy { SessionPrefsStore(appContext!!) }
104+ open val messageCacheStore: MessageCacheStore by lazy { MessageCacheStore(appContext!!) }
105+ open val outboxStore: OutboxStore by lazy { OutboxStore(appContext!!) }
106+ open val backupManager: BackupManager by lazy { BackupManager(profileStore, settingsStore, sessionPrefsStore) }
107+
108+ /** True while the outbox is actively flushing queued messages to the server, so the UI
109+ * can show a "sending queued messages…" indicator. */
110+ private val _outboxSending = MutableStateFlow(false)
111+ open val outboxSending: StateFlow<Boolean> = _outboxSending.asStateFlow()
112+
113+ /** Bumped to nudge an immediate outbox flush (after enqueue, or a manual "Send now"),
114+ * independent of connection/online changes. */
115+ private val _outboxFlushTrigger = MutableStateFlow(0)
116+ open fun flushOutbox() { _outboxFlushTrigger.update { it + 1 } }
117+
118+ private val _activeConnection = MutableStateFlow<OpencodeConnection?>(null)
119+ open val activeConnection: StateFlow<OpencodeConnection?> = _activeConnection.asStateFlow()
120+
121+ /** True while a background auto-reconnect to the most-recent server is in flight. */
122+ private val _reconnecting = MutableStateFlow(false)
123+ open val reconnecting: StateFlow<Boolean> = _reconnecting.asStateFlow()
124+
125+ /**
126+ * Set once a startup auto-reconnect succeeds. The server list collects this and
127+ * (once) navigates straight to the session list so a returning user skips it.
128+ * [consumeAutoConnect] guards against re-firing on screen re-entry.
129+ */
130+ private val _autoConnectDone = MutableStateFlow(false)
131+ open val autoConnectDone: StateFlow<Boolean> = _autoConnectDone.asStateFlow()
132+ private val autoConnectConsumed = AtomicBoolean(false)
133+ open fun consumeAutoConnect(): Boolean = autoConnectConsumed.compareAndSet(false, true)
134+
135+ /** Text shared into the app (ACTION_SEND), prefilled into a session draft. */
136+ private val _pendingShare = MutableStateFlow<String?>(null)
137+ open val pendingShare: StateFlow<String?> = _pendingShare.asStateFlow()
138+ open fun setPendingShare(text: String) { _pendingShare.value = text }
139+ open fun consumePendingShare(): String? {
140+ val current = _pendingShare.value ?: return null
141+ return if (_pendingShare.compareAndSet(current, null)) current else null
142+ }
143+
144+ /** Image/file Uris (as strings) shared into the app, staged as attachments in the next
145+ * opened session. Consumed once by the first ChatScreen to compose after the share. */
146+ private val _pendingSharedMedia = MutableStateFlow<List<String>>(emptyList())
147+ open val pendingSharedMedia: StateFlow<List<String>> = _pendingSharedMedia.asStateFlow()
148+ open fun setPendingSharedMedia(uris: List<String>) { _pendingSharedMedia.value = uris }
149+ open fun consumePendingSharedMedia(): List<String> {
150+ val current = _pendingSharedMedia.value
151+ if (current.isEmpty()) return emptyList()
152+ return if (_pendingSharedMedia.compareAndSet(current, emptyList())) current else emptyList()
153+ }
154+
155+ /**
156+ * Set from an external "New session" trigger (launcher shortcut / QS tile). The nav host
157+ * consumes it once connected, creating a fresh session and opening it.
158+ */
159+ private val _pendingNewSession = MutableStateFlow(false)
160+ open val pendingNewSession: StateFlow<Boolean> = _pendingNewSession.asStateFlow()
161+ open fun requestNewSession() { _pendingNewSession.value = true }
162+ open fun consumePendingNewSession(): Boolean = _pendingNewSession.compareAndSet(true, false)
163+
164+ /**
165+ * A session id to open from an external trigger (a notification tap or a deep link).
166+ * The nav host consumes it once a connection is active.
167+ */
168+ private val _pendingOpenSession = MutableStateFlow<String?>(null)
169+ open val pendingOpenSession: StateFlow<String?> = _pendingOpenSession.asStateFlow()
170+ open fun requestOpenSession(id: String) { _pendingOpenSession.value = id }
171+ open fun consumePendingOpenSession(): String? {
172+ val current = _pendingOpenSession.value ?: return null
173+ return if (_pendingOpenSession.compareAndSet(current, null)) current else null
174+ }
175+
176+ /**
177+ * The session the user is currently viewing (or null when not in a chat). Drives the
178+ * unread tracker: messages arriving for any *other* session mark it unread, and
179+ * opening a session clears its unread state.
180+ */
181+ private val _currentSession = MutableStateFlow<String?>(null)
182+ open val currentSession: StateFlow<String?> = _currentSession.asStateFlow()
183+
184+ /** Session ids that received activity while not being viewed, mapped to the count
185+ * of unread message events. A count (not just a presence set) lets the session
186+ * list badge show "3 unread" instead of a bare dot, so the user can tell whether
187+ * one reply or a whole burst arrived. */
188+ private val _unread = MutableStateFlow<Map<String, Int>>(emptyMap())
189+ open val unread: StateFlow<Map<String, Int>> = _unread.asStateFlow()
190+
191+ /** True when any assistant run is actively streaming across all sessions. Drives the
192+ * disconnect confirmation on the session list (disconnecting mid-run kills it). */
193+ private val _anyRunActive = MutableStateFlow(false)
194+ open val anyRunActive: StateFlow<Boolean> = _anyRunActive.asStateFlow()
195+
196+ /** Whether the device currently has network connectivity. Distinct from the SSE
197+ * connection state so the UI can tell "you're offline" (device) from "server
198+ * unreachable" (credentials/host). Updated by the [networkCallback]. */
199+ private val _isOnline = MutableStateFlow(true)
200+ open val isOnline: StateFlow<Boolean> = _isOnline.asStateFlow()
201+
202+ /** Whether a foreground Activity is currently showing. Distinct from [currentSession],
203+ * which stays set while the app is merely backgrounded (the chat screen isn't disposed
204+ * when the user locks their phone). Permission/completion notifications need the real
205+ * foreground signal so they still fire for a session the user "has open" but has walked
206+ * away from — the core of the run-in-the-background use case. Set from MainActivity's
207+ * onStart/onStop. */
208+ private val _isForeground = MutableStateFlow(false)
209+ open val isForeground: StateFlow<Boolean> = _isForeground.asStateFlow()
210+ open fun setForeground(foreground: Boolean) { _isForeground.value = foreground }
211+
212+ /** True when the user is actively looking at [sessionId] (app foregrounded AND that
213+ * session is the one on screen), so an in-app affordance (the permission dialog) is
214+ * handling it and a notification would be redundant. */
215+ private fun isActivelyViewing(sessionId: String): Boolean =
216+ _isForeground.value && sessionId == _currentSession.value
217+
218+ open fun setCurrentSession(id: String?) {
219+ _currentSession.value = id
220+ if (id != null) {
221+ _unread.update { it - id }
222+ unreadMessageIds.remove(id)
223+ appContext?.let {
224+ SessionNotifications.cancel(it, id)
225+ SessionNotifications.cancelPermission(it, id)
226+ SessionNotifications.cancelError(it, id)
227+ }
228+ }
229+ }
230+
231+ /** Restore a session's unread badge after a failed server switch reconnects.
232+ * Preserves the prior [count] so a session badged with "5 unread" before the
233+ * switch attempt still shows 5 (not 1) after the restore. No-op for the session
234+ * the user is currently viewing, since a viewed session is never badged. */
235+ open fun restoreUnread(id: String, count: Int) {
236+ if (id != _currentSession.value) {
237+ _unread.update { it + (id to count) }
238+ }
239+ }
240+
241+ /** Resolve a localized string — view models reach resources through the container. */
242+ open fun string(id: Int, vararg formatArgs: Any): String {
243+ if (appContext == null) return ""
244+ return if (formatArgs.isEmpty()) appContext.getString(id) else appContext.getString(id, *formatArgs)
245+ }
246+
247+ /**
248+ * Convert a throwable into a user-facing message. Classifies the error by concrete
249+ * type (network, timeout, HTTP status) rather than string-matching class names, so
250+ * the message reflects what actually went wrong without leaking internal URLs/state.
251+ */
252+ open fun friendlyError(t: Throwable): String =
253+ friendlyErrorFor(t, activeConnection.value?.profile?.baseUrl.orEmpty())
254+
255+ /**
256+ * Same classification as [friendlyError] but accepts an explicit base URL, so callers
257+ * that aren't operating on the active connection (e.g. [probeServer]) can still
258+ * produce a user-facing message with the right server address.
259+ */
260+ open fun friendlyErrorFor(t: Throwable, baseUrl: String): String =
261+ when (classifyError(t)) {
262+ ErrorKind.NOT_REACHABLE, ErrorKind.NETWORK ->
263+ string(R.string.error_not_reachable, baseUrl)
264+ ErrorKind.TIMEOUT -> string(R.string.error_timeout)
265+ ErrorKind.SERVER -> string(R.string.error_server)
266+ // Show only the HTTP status (e.g. 401, 404) — never the request URL,
267+ // which a ClientRequestException carries in its message and which we
268+ // promised not to leak (it can include auth or internal paths).
269+ ErrorKind.CLIENT -> responseStatusCode(t)?.let { string(R.string.error_client_status, it) }
270+ ?: string(R.string.error_generic)
271+ ErrorKind.UNKNOWN -> string(R.string.error_generic)
272+ }
273+
274+ /**
275+ * Drive the [RunForegroundService] from the process-lived [appScope] rather than from the
276+ * Activity's composition. Previously the start/stop was wired to a
277+ * `collectAsStateWithLifecycle` collector in the root composable, which pauses while the
278+ * Activity is STOPPED — so a run *started while backgrounded* (e.g. via the notification
279+ * inline-reply) never acquired foreground priority and its long-lived SSE stream could be
280+ * killed by Doze, and a run that *ended* while backgrounded left the "working…"
281+ * notification lingering until the app was reopened. Collecting here reacts to
282+ * [anyRunActive] regardless of Activity state.
283+ */
284+ private fun observeRunForegroundService() {
285+ val ctx = appContext ?: return
286+ appScope.launch {
287+ // Debounce so a run that starts then fails/idles almost immediately doesn't
288+ // dispatch startForegroundService() then stopService() back-to-back — if the stop
289+ // wins that race before the service calls startForeground(), Android raises the
290+ // "did not then call startForeground()" crash ~5s later. Collapsing the window
291+ // emits only the settled state; distinctUntilChanged drops a no-op re-emit when a
292+ // brief true→false→true flurry settles back to the value already applied.
293+ anyRunActive
294+ .debounce(NetworkConfig.runForegroundDebounceMs)
295+ .distinctUntilChanged()
296+ .collect { active ->
297+ if (active) RunForegroundService.start(ctx) else RunForegroundService.stop(ctx)
298+ }
299+ }
300+ }
301+
302+ /**
303+ * Reconcile active-run tracking on an SSE *reconnect*. A run that completes during a stream
304+ * outage never delivers its `SessionIdle`, so its id would stay in [activeRuns] and pin
305+ * [anyRunActive] (and the foreground service + "working…" notification) on indefinitely. On a
306+ * reconnect we clear the tracking and let live streaming events re-populate it: a genuinely
307+ * in-progress run re-adds itself within moments via [isLiveRunActivity], while a run that
308+ * finished during the outage produces no further live events (its completion arrives only via
309+ * the REST re-seed), so it correctly stays cleared. The first connect of a connection is not a
310+ * reconnect; `connect()` already resets [activeRuns], so only subsequent Connected transitions
311+ * trigger the sweep.
312+ */
313+ private fun observeRunReconcileOnReconnect() {
314+ appScope.launch {
315+ activeConnection.collectLatest { conn ->
316+ if (conn == null) return@collectLatest
317+ var hasConnectedBefore = false
318+ conn.events.state.collect { state ->
319+ if (state == EventStreamClient.ConnectionState.Connected) {
320+ if (hasConnectedBefore) {
321+ synchronized(activeRuns) {
322+ activeRuns.clear()
323+ _anyRunActive.value = false
324+ }
325+ }
326+ hasConnectedBefore = true
327+ }
328+ }
329+ }
330+ }
331+ }
332+
333+ /**
334+ * Flush the offline outbox whenever there's an active connection and the device is
335+ * online (and on any manual [flushOutbox] nudge). Queued messages are only sent to the
336+ * server they were composed for (matched by profile id), so a reconnect to a different
337+ * server never misfires them against a session id that doesn't exist there. Uses a plain
338+ * `collect` (not collectLatest) so an in-flight send is never cancelled mid-flight and
339+ * send order is preserved; each message carries a stable idempotency key (its persistent
340+ * outbox id) so even a re-send across flushes is deduplicated server-side.
341+ */
342+ private fun observeOutbox() {
343+ appScope.launch {
344+ combine(activeConnection, isOnline, _outboxFlushTrigger) { conn, online, _ -> conn to online }
345+ .collect { (conn, online) ->
346+ if (conn == null || !online) return@collect
347+ flushOutboxNow(conn)
348+ }
349+ }
350+ }
351+
352+ private suspend fun flushOutboxNow(conn: OpencodeConnection) {
353+ val pending = outboxStore.messages.value
354+ .filter { it.profileId == conn.profile.id }
355+ .sortedBy { it.createdAt }
356+ if (pending.isEmpty()) return
357+ _outboxSending.value = true
358+ try {
359+ for (msg in pending) {
360+ val result = runCatchingCancellable {
361+ conn.repository.sendPrompt(
362+ msg.sessionId,
363+ msg.text,
364+ attachments = msg.attachments.map {
365+ FilePromptPart(mime = it.mime, url = it.url, filename = it.filename)
366+ },
367+ model = msg.model,
368+ agent = msg.agent,
369+ // Reuse the persistent outbox id as a stable idempotency key so a
370+ // later flush of a message whose earlier POST reached the server (but
371+ // whose response was lost) is deduplicated server-side instead of
372+ // starting a duplicate agent run.
373+ idempotencyKey = msg.id,
374+ )
375+ }.onFailure {
376+ Log.w("AppContainer", "Outbox flush failed for ${msg.sessionId}: ${safeExceptionSummary(it)}")
377+ }
378+ if (result.isSuccess) {
379+ outboxStore.remove(msg.id)
380+ continue
381+ }
382+ // A permanently-undeliverable message (a non-408/429 4xx — e.g. the target
383+ // session was deleted server-side, yielding a 404) must be dropped, not left at
384+ // the head of the queue. The loop stops at the first failure to preserve send
385+ // order, so a poison message would otherwise block every later queued message
386+ // for this profile forever. Transient failures (offline again, 5xx, 408/429)
387+ // keep the queue intact and retry on the next trigger.
388+ // 401/403 (bad/expired credentials) are NOT permanent: the message itself is
389+ // deliverable once the user fixes the profile. Dropping it would silently lose
390+ // the user's composed offline prompt on a recoverable auth failure. They stay
391+ // in the queue and retry on the next flush after auth is restored.
392+ val status = result.exceptionOrNull()?.let { responseStatusCode(it) }
393+ // Permanent = a non-408/429/401/403 4xx, matching withRetry's non-retryable rule
394+ // except for auth failures, which are recoverable by re-editing the profile.
395+ val permanent = when (status) {
396+ null, 408, 429, 401, 403 -> false
397+ in 400..499 -> true
398+ else -> false
399+ }
400+ if (permanent) {
401+ Log.w("AppContainer", "Dropping undeliverable outbox message ${msg.id} for ${msg.sessionId} (HTTP $status)")
402+ outboxStore.remove(msg.id)
403+ } else {
404+ break
405+ }
406+ }
407+ } finally {
408+ _outboxSending.value = false
409+ }
410+ }
411+
412+ /** Release resources held for the process lifetime (network callback, app scope). */
413+ open fun shutdown() {
414+ // Cancel the app scope first so any coroutine holding the connection mutex
415+ // (e.g. a connect() suspended on profileStore) is cancelled and releases the
416+ // mutex. Otherwise the runBlocking below would deadlock waiting for a coroutine
417+ // that can't be cancelled until after the runBlocking completes.
418+ appScope.cancel()
419+ // Bounded runBlocking so a stuck mutex or a slow close() can't ANR the app.
420+ // 2 seconds is generous: appScope.cancel() already triggered cancellation of
421+ // any coroutine holding the mutex; we're just waiting for it to unwind.
422+ kotlinx.coroutines.runBlocking {
423+ withTimeoutOrNull(2_000) {
424+ connectionMutex.withLock {
425+ runCatching { _activeConnection.value?.close() }
426+ _activeConnection.value = null
427+ }
428+ }
429+ // If the timeout fired, force-clear the connection so it isn't left dangling.
430+ _activeConnection.value = null
431+ }
432+ val cm = appContext?.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
433+ networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
434+ if (!skipInit) soy.iko.opencode.data.repo.CrashLogger.get(appContext!!).shutdown()
435+ if (!skipInit) draftStore.shutdown()
436+ }
437+
438+ /**
439+ * Watch the SSE bus for new message activity and badge any session that isn't
440+ * currently open. This powers the unread dot on the session list so the user can
441+ * tell which conversations got a reply while they were elsewhere.
442+ *
443+ * Uses [flatMapLatest] so a server switch (which replaces the active connection)
444+ * re-subscribes to the new event stream and drops the old one.
445+ */
446+ @OptIn(ExperimentalCoroutinesApi::class)
447+ private fun observeMessageActivity() {
448+ appScope.launch {
449+ // Retry the observer if it fails for any reason (transient flow exception,
450+ // cancellation due to scope issues). Without this, a single failure would
451+ // permanently disable unread tracking and completion notifications.
452+ while (isActive) {
453+ runCatchingCancellable {
454+ activeConnection
455+ .flatMapLatest { conn ->
456+ // Capture the originating connection so notification coroutines
457+ // resolve titles and route permission responses against THIS
458+ // server, not whichever happens to be active when they resume.
459+ // A server switch during a listSessions() round-trip would
460+ // otherwise resolve the title (or POST the permission reply)
461+ // against the new server, where the session/permission doesn't
462+ // exist — showing an opaque id or silently losing the reply.
463+ conn?.events?.events?.map { conn to it } ?: emptyFlow()
464+ }
465+ .collect { (conn, event) ->
466+ // A permission request needs the user's approval. Post a heads-up
467+ // notification (with Allow/Reject actions) unless the user is actively
468+ // viewing this session, where the in-app permission dialog handles it.
469+ if (event is PermissionUpdated) {
470+ val psid = event.properties.sessionID.takeIf { it.isNotBlank() } ?: return@collect
471+ if (!isActivelyViewing(psid)) appScope.launch { notifyPermission(event.properties, conn) }
472+ return@collect
473+ }
474+ // The permission was answered (in-app, from the notification, or
475+ // auto-resolved): clear its heads-up notification.
476+ if (event is PermissionReplied) {
477+ val psid = event.properties.sessionID?.takeIf { it.isNotBlank() } ?: return@collect
478+ appContext?.let { SessionNotifications.cancelPermission(it, psid) }
479+ return@collect
480+ }
481+ // A run failed. Clear its active-run tracking (an error isn't always
482+ // followed by a SessionIdle) and notify unless it's being viewed.
483+ if (event is SessionError) {
484+ val esid = event.properties.sessionID ?: return@collect
485+ val wasRunning = synchronized(activeRuns) {
486+ activeRuns.remove(esid).also { removed ->
487+ if (removed) _anyRunActive.value = activeRuns.isNotEmpty()
488+ }
489+ }
490+ if (wasRunning && !isActivelyViewing(esid)) {
491+ appScope.launch { notifySessionError(esid, conn.repository) }
492+ }
493+ return@collect
494+ }
495+ // SessionIdle is not "message activity" (don't badge as unread)
496+ // but signals a run finished — fire a completion notification if the
497+ // session isn't currently being viewed and was actively streaming.
498+ if (event is SessionIdle) {
499+ val idleSid = event.properties.sessionID ?: return@collect
500+ // Clear the run state unconditionally — even for the session on
501+ // screen. Gating the removal on `idleSid != currentSession` (as
502+ // this once did) leaks the id in activeRuns and pins anyRunActive
503+ // true forever whenever a run finishes while being viewed. Only
504+ // the completion *notification* is suppressed for the viewed one.
505+ val wasRunning = synchronized(activeRuns) {
506+ activeRuns.remove(idleSid).also { removed ->
507+ if (removed) _anyRunActive.value = activeRuns.isNotEmpty()
508+ }
509+ }
510+ if (wasRunning && !isActivelyViewing(idleSid)) {
511+ // Fire this off the collector's coroutine: notifySessionCompleted
512+ // does a network listSessions() to resolve the title, and blocking
513+ // the shared-event collector here would stall the SharedFlow,
514+ // overflowing its DROP_OLDEST buffer and silently dropping live
515+ // parts/updates for every other subscriber (e.g. the message reducer).
516+ appScope.launch { notifySessionCompleted(idleSid, conn.repository) }
517+ }
518+ return@collect
519+ }
520+ val sid = sessionOf(event) ?: return@collect
521+ // Re-read currentSession here, right before mutating the badge state:
522+ // the user may have opened this session between when this event was
523+ // enqueued and now (setCurrentSession clears its badge and dedup set).
524+ // A stale earlier check would race a badge — and a dedup entry — back
525+ // in for the session that's actually on screen.
526+ if (sid != _currentSession.value) {
527+ // Increment the unread count once per distinct *message*, not per
528+ // event: a single reply emits many message.updated / message.part.
529+ // updated events (one per streamed token, plus cost/token refreshes),
530+ // which would otherwise inflate the badge into the dozens/hundreds.
531+ val messageId = messageIdOfEvent(event)
532+ val counted = unreadMessageIds.getOrPut(sid) {
533+ java.util.Collections.synchronizedSet(mutableSetOf())
534+ }
535+ if (messageId == null || counted.add(messageId)) {
536+ _unread.update { current ->
537+ // Guard again inside the atomic update lambda so a retry
538+ // (or an open that landed just now) can't reintroduce the
539+ // badge for the session on screen.
540+ if (sid == _currentSession.value) current
541+ else current + (sid to (current[sid] ?: 0) + 1)
542+ }
543+ }
544+ }
545+ // Track sessions actively streaming so we know which idle events
546+ // represent a finished run worth notifying about.
547+ if (isLiveRunActivity(event)) {
548+ // The add-under-lock is required both for thread-safety and for the
549+ // access-order refresh (keeps a continuously-streaming session from
550+ // being evicted), so it runs per event.
551+ synchronized(activeRuns) {
552+ activeRuns.add(sid)
553+ // Derive the flag from the set state *inside* the same lock that
554+ // guards the set, so a concurrent reconnect sweep (which clears
555+ // both under this lock) can't interleave and leave the flag pinned
556+ // true with an empty set — a stuck foreground service + "working…".
557+ _anyRunActive.value = activeRuns.isNotEmpty()
558+ }
559+ }
560+ } }.onFailure { Log.w("AppContainer", "Message activity observer failed, will retry: ${safeExceptionSummary(it)}") }
561+ if (!isActive) break
562+ delay(NetworkConfig.observerRetryDelayMs)
563+ }
564+ }
565+ }
566+
567+ /** True if [event] is live streaming activity worth tracking as an active run. A
568+ * trailing message.updated that lands *after* the run finished (final cost/token totals,
569+ * carrying a completion time) is NOT live streaming: with no further SessionIdle to
570+ * follow it, re-adding the session would pin anyRunActive true indefinitely. */
571+ private fun isLiveRunActivity(event: BusEvent): Boolean {
572+ if (event !is MessagePartUpdated && event !is MessageUpdated) return false
573+ val info = (event as? MessageUpdated)?.properties?.info
574+ if (info is AssistantMessage && info.isComplete) return false
575+ // A step-finish part is the trailing completion marker for a run (it carries the
576+ // final cost/token totals) and arrives after the last stream delta. Like a completed
577+ // message.updated, treating it as live would re-add the session to activeRuns with no
578+ // following SessionIdle to clear it — pinning anyRunActive true indefinitely when such
579+ // a part is replayed/reordered after the run already went idle.
580+ val part = (event as? MessagePartUpdated)?.properties?.part
581+ if (part is StepFinishPart) return false
582+ return true
583+ }
584+
585+ /** Session ids currently streaming an assistant run (best-effort, in-process). Backed by
586+ * an access-order [java.util.LinkedHashMap] so re-adding an already-present id on each
587+ * streaming event refreshes its recency (a plain LinkedHashSet.add is a no-op that does
588+ * NOT reorder). Entries are drained by [SessionIdle] (and cleared wholesale on reconnect
589+ * reconcile); we intentionally do NOT cap the set, because evicting the eldest would drop
590+ * its completion notification when its SessionIdle eventually arrives — a busy server
591+ * with many concurrent runs is still bounded by the server's own concurrency, so growth
592+ * is not unbounded in practice. */
593+ private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(
594+ java.util.Collections.newSetFromMap(java.util.LinkedHashMap<String, Boolean>(16, 0.75f, true)),
595+ )
596+
597+ /** Per non-viewed session, the set of message ids already reflected in its unread
598+ * count. De-duplicates the many streaming events that share one message id so the
599+ * badge counts messages, not deltas. Cleared when a session is viewed or on connect. */
600+ private val unreadMessageIds = java.util.concurrent.ConcurrentHashMap<String, MutableSet<String>>()
601+
602+ /** Guards connect/disconnect so concurrent callers can't leak an old connection. */
603+ private val connectionMutex = Mutex()
604+
605+ /** Only persist the `lastUsed` timestamp if it's older than this, to avoid
606+ * a DataStore + encrypted-prefs write on every rapid reconnect. */
607+ private companion object {
608+ const val LAST_USED_SAVE_THRESHOLD_MS = 60_000L
609+ }
610+
611+ /** Resolve the human-readable title for [sessionId] (best-effort), falling back to the id.
612+ * Uses [originRepo] — the repository of the connection that emitted the event — so a
613+ * server switch during the listSessions() round-trip doesn't resolve the title against
614+ * an unrelated server (showing an opaque id, or worse, a colliding session's title). */
615+ private suspend fun resolveSessionTitle(sessionId: String, originRepo: SessionRepository): String =
616+ runCatchingCancellable {
617+ originRepo.listSessions()?.firstOrNull { it.id == sessionId }?.displayTitle
618+ }.getOrNull() ?: sessionId
619+
620+ /** Resolve the title for [sessionId] (best-effort) and post a completion notification. */
621+ private suspend fun notifySessionCompleted(sessionId: String, originRepo: SessionRepository) {
622+ val title = resolveSessionTitle(sessionId, originRepo)
623+ // Re-check viewing right before posting: the call site checks !isActivelyViewing(sid)
624+ // at event time, but resolveSessionTitle() suspends on a listSessions() round-trip.
625+ // If the user opened the session in that gap, setCurrentSession already cancelled the
626+ // prior notification — posting a fresh one here would leave a "session ready" heads-up
627+ // lingering while they're already looking at the finished run.
628+ if (isActivelyViewing(sessionId)) return
629+ appContext?.let { SessionNotifications.postCompleted(it, sessionId, title) }
630+ }
631+
632+ /** Post a heads-up permission notification for [permission], resolving its session title.
633+ * [originConn] is the connection that emitted the permission event — its profile id is
634+ * embedded in the notification so the receiver routes the response back to THIS server. */
635+ private suspend fun notifyPermission(permission: Permission, originConn: OpencodeConnection) {
636+ val sessionId = permission.sessionID.takeIf { it.isNotBlank() } ?: return
637+ val title = resolveSessionTitle(sessionId, originConn.repository)
638+ // Re-check viewing just before posting — see notifySessionCompleted for the race.
639+ if (isActivelyViewing(sessionId)) return
640+ appContext?.let { SessionNotifications.postPermission(it, permission, title, originConn.profile.id) }
641+ }
642+
643+ /** Post an error notification for a failed background run. */
644+ private suspend fun notifySessionError(sessionId: String, originRepo: SessionRepository) {
645+ val title = resolveSessionTitle(sessionId, originRepo)
646+ // Re-check viewing just before posting — see notifySessionCompleted for the race.
647+ if (isActivelyViewing(sessionId)) return
648+ appContext?.let { SessionNotifications.postError(it, sessionId, title) }
649+ }
650+
651+ /** Respond to a permission from a notification action (Allow once / Always / Reject),
652+ * off any UI. Runs on the process-lived app scope; [onDone] fires when the call resolves
653+ * so the receiver's goAsync() result can finish. Its Boolean is true only when a live
654+ * connection existed AND respondPermission succeeded — the receiver uses it to decide
655+ * whether to dismiss the notification. A missing connection makes the respond a no-op,
656+ * so it must report failure (not success) rather than let the notification vanish with
657+ * the tool left unanswered.
658+ *
659+ * [profileId] is the id of the server that posted the permission request (embedded in
660+ * the notification's PendingIntent). If it doesn't match the currently active
661+ * connection's profile, the response is NOT sent: posting it to a different server
662+ * would 404 (treated as "already resolved" success by respondPermission), dismissing
663+ * the notification while the original server's tool stays paused forever. Reporting
664+ * failure instead leaves the notification up so the user can switch back and retry. */
665+ open fun respondToPermissionFromNotification(
666+ sessionId: String,
667+ permissionId: String,
668+ response: PermissionResponse,
669+ profileId: String?,
670+ onDone: (Boolean) -> Unit,
671+ ) {
672+ appScope.launch {
673+ // Guarantee onDone (so the BroadcastReceiver's goAsync() finishes) even if the
674+ // scope is cancelled mid-call (process shutdown cancels appScope first). Without
675+ // this, a cancelled scope leaves pending.finish() never called → a receiver ANR
676+ // and the permission response silently lost.
677+ var success = false
678+ try {
679+ success = runCatchingCancellable {
680+ val conn = activeConnection.value ?: return@runCatchingCancellable false
681+ // Route to the originating server only. A mismatch means the user
682+ // switched servers while the permission was pending; sending the reply
683+ // to the wrong server would be silently swallowed as a 404-success.
684+ if (profileId != null && conn.profile.id != profileId) return@runCatchingCancellable false
685+ conn.api.respondPermission(sessionId, permissionId, response)
686+ true
687+ }.onFailure {
688+ Log.w("AppContainer", "notif permission respond failed: ${safeExceptionSummary(it)}")
689+ }.getOrDefault(false)
690+ } finally {
691+ onDone(success)
692+ }
693+ }
694+ }
695+
696+ /** Send a follow-up prompt from a notification's inline reply, off any UI. Uses the
697+ * server's default model/agent. Routes through the durable [outboxStore] — the same path
698+ * the in-app composer uses ([soy.iko.opencode.ui.chat.ChatViewModel.enqueueOffline]) —
699+ * rather than a direct repository send, so a reply composed while disconnected survives to
700+ * be flushed on reconnect instead of being silently dropped. [flushOutbox] nudges an
701+ * immediate send when a connection is live, so an online reply still goes out now. [onDone]
702+ * reports whether the reply was durably enqueued so the receiver only dismisses the
703+ * notification on success. */
704+ open fun sendPromptFromNotification(sessionId: String, text: String, onDone: (Boolean) -> Unit) {
705+ appScope.launch {
706+ // Guarantee onDone (so the BroadcastReceiver's goAsync() finishes) even if the
707+ // scope is cancelled mid-call — see respondToPermissionFromNotification above.
708+ var enqueued = false
709+ try {
710+ // Attribute to the active (or most-recent) profile so a reconnect to a different
711+ // server doesn't misfire this against a session id that only exists elsewhere.
712+ val profileId = activeConnection.value?.profile?.id
713+ ?: runCatchingCancellable { profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
714+ enqueued = if (profileId == null) {
715+ false
716+ } else {
717+ runCatchingCancellable {
718+ outboxStore.enqueue(
719+ OutboxMessage(
720+ id = java.util.UUID.randomUUID().toString(),
721+ profileId = profileId,
722+ sessionId = sessionId,
723+ text = text,
724+ model = null,
725+ createdAt = System.currentTimeMillis(),
726+ ),
727+ )
728+ }.onFailure {
729+ Log.w("AppContainer", "notif reply enqueue failed: ${safeExceptionSummary(it)}")
730+ }.isSuccess
731+ }
732+ if (enqueued) flushOutbox()
733+ } finally {
734+ onDone(enqueued)
735+ }
736+ }
737+ }
738+
739+ /** Extract the session id an event pertains to, for message-activity events. */
740+ private fun sessionOf(event: BusEvent): String? = sessionOfEvent(event)
741+
742+ /**
743+ * On cold start, transparently reconnect to the most recently used server so a
744+ * returning user lands in their session list instead of the empty server screen.
745+ */
746+ private suspend fun autoConnect() {
747+ val recent = runCatchingCancellable { profileStore.profiles.first() }
748+ .getOrDefault(emptyList())
749+ .firstOrNull() ?: return
750+ // The profile read above suspends on DataStore. A manual connect() to a different
751+ // server during that window establishes a connection the user explicitly chose; we
752+ // must not clobber it by connecting to `recent` on top. connect() unconditionally
753+ // closes the active connection, so guard here: if anything is already active, the
754+ // user (or another connector) already took the cold-start slot — abort autoConnect.
755+ // (disconnectIf below similarly guards the failed-ping teardown by identity.)
756+ if (_activeConnection.value != null) return
757+ _reconnecting.value = true
758+ var conn: OpencodeConnection? = null
759+ val ok = runCatchingCancellable {
760+ conn = connect(recent)
761+ conn!!.api.ping()
762+ }.isSuccess
763+ _reconnecting.value = false
764+ if (ok) {
765+ _autoConnectDone.value = true
766+ } else {
767+ val created = conn
768+ // Only tear down the connection we created: a manual connect to a different server
769+ // during the ping window may have already replaced it, and disconnect() closes
770+ // whatever is currently active — which would silently drop that new connection.
771+ // disconnectIf re-checks identity under the mutex to close the check-then-close race.
772+ if (created != null) disconnectIf(created)
773+ }
774+ }
775+
776+ /**
777+ * When the device regains connectivity, nudge the active SSE stream to reconnect
778+ * immediately instead of waiting out the exponential backoff.
779+ */
780+ private val networkCallback: ConnectivityManager.NetworkCallback? = registerNetworkMonitor()
781+
782+ private fun registerNetworkMonitor(): ConnectivityManager.NetworkCallback? {
783+ if (appContext == null) return null
784+ val cm = appContext!!.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
785+ ?: return null
786+ // Seed the online state from the current active network so the indicator is
787+ // correct on cold start rather than defaulting to "online" until a callback fires.
788+ _isOnline.value = runCatching { cm.activeNetwork != null }.getOrDefault(true)
789+ val callback = object : ConnectivityManager.NetworkCallback() {
790+ override fun onAvailable(network: Network) { _isOnline.value = true; activeConnection.value?.events?.triggerReconnect() }
791+ // Don't flip offline on onLost: during a Wi-Fi→cellular handoff, onLost(WiFi) fires
792+ // before onAvailable(cellular), and cm.activeNetwork is transiently null in that
793+ // window — eagerly marking offline would skip outbox flushing and flash an offline
794+ // indicator. onUnavailable (no networks left at all) is the reliable offline signal.
795+ override fun onLost(network: Network) { /* handled by onUnavailable */ }
796+ override fun onUnavailable() { _isOnline.value = false }
797+ }
798+ // Only match networks that actually provide internet — a capability-less request
799+ // fires onAvailable for transports that can't reach the server (and would trigger
800+ // spurious reconnects), so require NET_CAPABILITY_INTERNET.
801+ val request = NetworkRequest.Builder()
802+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
803+ .build()
804+ runCatching { cm.registerNetworkCallback(request, callback) }
805+ .onFailure { Log.w("AppContainer", "Failed to register network callback", it) }
806+ return callback
807+ }
808+
809+ /** Open (or re-open) a connection to [profile], replacing any current one.
810+ * Only persists the updated `lastUsed` timestamp if it's stale (older than a
811+ * minute), so rapid reconnect storms don't each trigger a DataStore write. */
812+ open suspend fun connect(profile: ServerProfile): OpencodeConnection =
813+ connectionMutex.withLock {
814+ _activeConnection.value?.close()
815+ _activeConnection.value = null
816+ // Mutate the run set and its derived flag together under activeRuns' monitor: the SSE
817+ // event handler adds ids under the same lock, so an unlocked clear() here could
818+ // interleave with an add()+flag=true and leave _anyRunActive pinned true with a torn-
819+ // down connection and no SessionIdle to clear it — a stuck foreground service.
820+ synchronized(activeRuns) {
821+ activeRuns.clear()
822+ _anyRunActive.value = false
823+ }
824+ _unread.value = emptyMap()
825+ unreadMessageIds.clear()
826+ val now = System.currentTimeMillis()
827+ val resolved = profileStore.resolve(profile)
828+ val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
829+ val finalProfile = if (needsSave) resolved.copy(lastUsed = now) else resolved
830+ if (needsSave) profileStore.save(finalProfile)
831+ OpencodeConnection(finalProfile, messageCacheStore).also { _activeConnection.value = it }
832+ }
833+
834+ open suspend fun disconnect() {
835+ // Acquire the connection mutex so disconnect is serialized with connect().
836+ // Previously this was fire-and-forget (launch on appScope), which raced with
837+ // a subsequent connect(): connect() could acquire the mutex, create a new
838+ // connection, and then the pending disconnect() would close it.
839+ connectionMutex.withLock { teardownActiveLocked() }
840+ }
841+
842+ /** Tear down [expected] only if it is still the active connection, with the identity
843+ * check performed *under* the mutex. A caller that checks `activeConnection.value === x`
844+ * itself and then calls [disconnect] has a TOCTOU race: a concurrent connect() to a
845+ * different server can replace the active connection in the gap, and [disconnect] would
846+ * then close that new connection. autoConnect()'s failed-ping teardown uses this. */
847+ private suspend fun disconnectIf(expected: OpencodeConnection) {
848+ connectionMutex.withLock {
849+ if (_activeConnection.value === expected) teardownActiveLocked()
850+ }
851+ }
852+
853+ /** Close and reset the active connection. Caller MUST hold [connectionMutex]. */
854+ private suspend fun teardownActiveLocked() {
855+ _activeConnection.value?.close()
856+ _activeConnection.value = null
857+ // Reset run/unread state on disconnect too. With no SSE stream there's no
858+ // SessionIdle to drain activeRuns, so without this the working indicator
859+ // (anyRunActive) and unread badges would stay pinned until the next connect().
860+ // Mutate under activeRuns' monitor (see connect()) so a concurrent SSE add() can't
861+ // re-pin _anyRunActive after the clear.
862+ synchronized(activeRuns) {
863+ activeRuns.clear()
864+ _anyRunActive.value = false
865+ }
866+ _unread.value = emptyMap()
867+ unreadMessageIds.clear()
868+ }
869+
870+ /** Deferred profile deletions keyed by id, run on the process-lived [appScope] (not a
871+ * ViewModel scope) so navigating away during the undo window still commits the delete
872+ * instead of silently cancelling it — mirroring [CrashLogger.scheduleDelete]. */
873+ private val pendingProfileDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
874+
875+ /**
876+ * Schedule the profile [id] for deletion after [delayMs], cancellable via
877+ * [cancelProfileDelete] (the Undo action). [onDeleted]/[onError] run on [appScope]
878+ * after the delete resolves so the caller can clear its optimistic-hide state. A
879+ * repeated schedule for the same id replaces the prior timer.
880+ */
881+ open fun scheduleProfileDelete(
882+ id: String,
883+ delayMs: Long,
884+ onDeleted: () -> Unit,
885+ onError: (Throwable) -> Unit,
886+ ) {
887+ val job = appScope.launch {
888+ delay(delayMs)
889+ // Claim the delete by removing our own entry BEFORE committing it — the atomic
890+ // remove is the sync point with cancelProfileDelete. Losing the race (an undo
891+ // removed us first) means we skip, honouring cancel's `true`; deleting first
892+ // would let a cancel land in the gap and falsely report undo of a gone profile.
893+ // remove(id, thisJob) also no-ops for a stale job after a reschedule.
894+ val claimed = coroutineContext[Job]?.let { pendingProfileDeletes.remove(id, it) } == true
895+ if (!claimed) return@launch
896+ runCatchingCancellable { profileStore.delete(id) }
897+ .onSuccess { onDeleted() }
898+ .onFailure { onError(it) }
899+ }
900+ pendingProfileDeletes[id]?.cancel()
901+ pendingProfileDeletes[id] = job
902+ }
903+
904+ /** Cancel a pending deferred profile delete (the Undo action). Returns true if it was
905+ * still pending (undo succeeded), false if the delete had already fired. */
906+ open fun cancelProfileDelete(id: String): Boolean {
907+ val job = pendingProfileDeletes.remove(id) ?: return false
908+ job.cancel()
909+ return true
910+ }
911+
912+ /** Deferred session deletions keyed by id, run on the process-lived [appScope] (not a
913+ * ViewModel scope) so navigating away during the undo window still commits the delete
914+ * instead of silently cancelling it (which would leave the session deleted-in-UI but
915+ * alive on the server, reappearing on the next refresh). */
916+ private val pendingSessionDeletes = java.util.concurrent.ConcurrentHashMap<String, Job>()
917+
918+ /**
919+ * Schedule session [id] for deletion after [delayMs], cancellable via [cancelSessionDelete]
920+ * (the Undo action). Also clears the session's stored draft on a successful delete.
921+ * [onDeleted]/[onError] run on [appScope] after the delete resolves so the caller can update
922+ * its optimistic-hide state. A repeated schedule for the same id replaces the prior timer.
923+ *
924+ * The owning connection is captured now, at schedule time — NOT re-resolved when the timer
925+ * fires. Otherwise a server switch during the undo window would send the delete to whichever
926+ * server happens to be active at fire time: the original session would never be deleted (its
927+ * row reappears on the next refresh) and, in the rare id-collision case, an unrelated session
928+ * on the new server could be deleted instead. A disconnect closes the captured connection's
929+ * HTTP client, so a delete deferred across a disconnect fails and is surfaced via [onError]
930+ * (the session cannot be deleted on a server we're no longer connected to). No-ops (as an
931+ * error) if there is no active connection at schedule time.
932+ */
933+ open fun scheduleSessionDelete(
934+ id: String,
935+ delayMs: Long,
936+ onDeleted: () -> Unit,
937+ onError: (Throwable) -> Unit,
938+ ) {
939+ val conn = _activeConnection.value
940+ val job = appScope.launch {
941+ delay(delayMs)
942+ // Claim the delete by removing our own entry BEFORE committing it — the atomic
943+ // remove is the sync point with cancelSessionDelete. Losing the race (an undo
944+ // removed us first) means we skip, honouring cancel's `true`; deleting first
945+ // would let a cancel land in the gap and falsely report undo of a gone session.
946+ // remove(id, thisJob) also no-ops for a stale job after a reschedule.
947+ val claimed = coroutineContext[Job]?.let { pendingSessionDeletes.remove(id, it) } == true
948+ if (!claimed) return@launch
949+ if (conn == null) {
950+ onError(IllegalStateException("No active connection"))
951+ } else {
952+ runCatchingCancellable { conn.repository.deleteSession(id) }
953+ .onSuccess {
954+ draftStore.remove(id)
955+ attachmentDraftStore.remove(id)
956+ sessionPrefsStore.forget(id)
957+ messageCacheStore.remove(id)
958+ outboxStore.removeForSession(id)
959+ onDeleted()
960+ }
961+ .onFailure { onError(it) }
962+ }
963+ }
964+ pendingSessionDeletes[id]?.cancel()
965+ pendingSessionDeletes[id] = job
966+ }
967+
968+ /** Cancel a pending deferred session delete (the Undo action). Returns true if it was
969+ * still pending (undo succeeded), false if the delete had already fired. */
970+ open fun cancelSessionDelete(id: String): Boolean {
971+ val job = pendingSessionDeletes.remove(id) ?: return false
972+ job.cancel()
973+ return true
974+ }
975+
976+ /**
977+ * Probe a server URL (without credentials) to check reachability and detect whether
978+ * the server requires authentication. Builds a short-lived HTTP client with the same
979+ * base URL normalization as [connect] but no auth, calls the health endpoint, and
980+ * classifies the outcome:
981+ *
982+ * - 2xx → [ProbeResult.Reachable] (no auth needed)
983+ * - 401/403 → [ProbeResult.NeedsAuth]
984+ * - anything else → [ProbeResult.Unreachable] with a user-facing message
985+ *
986+ * The probe client is always closed afterwards so no resources linger. This does not
987+ * touch the active connection or the profile store.
988+ */
989+ open suspend fun probeServer(baseUrl: String, requireHttps: Boolean, certPin: String?): ProbeResult {
990+ return probeWithProfile(ServerProfile(
991+ id = "probe",
992+ label = "",
993+ baseUrl = baseUrl.trim(),
994+ username = null,
995+ password = null,
996+ // Probe the same effective URL save()/connect() will use: HttpClientFactory upgrades
997+ // http->https and installs the cert pinner when these are set, so a probe without them
998+ // would validate a plain-http endpoint the real connection never talks to.
999+ requireHttps = requireHttps,
1000+ certPin = certPin,
1001+ ))
1002+ }
1003+
1004+ /**
1005+ * Probe a server URL *with* credentials to validate them. Returns true if the
1006+ * server accepts the credentials (ping succeeds), false on 401/403, and throws
1007+ * on other failures (unreachable, timeout) so the caller can surface a friendly
1008+ * message. Mirrors [probeServer]'s short-lived-client pattern.
1009+ */
1010+ open suspend fun probeWithCredentials(
1011+ baseUrl: String,
1012+ username: String,
1013+ password: String,
1014+ requireHttps: Boolean,
1015+ certPin: String?,
1016+ ): Boolean {
1017+ val probeProfile = ServerProfile(
1018+ id = "probe-auth",
1019+ label = "",
1020+ baseUrl = baseUrl.trim(),
1021+ username = username.trim().takeIf { it.isNotBlank() },
1022+ password = password.trim().takeIf { it.isNotEmpty() },
1023+ // Match the effective URL/pinning save()/connect() will use (see probeServer).
1024+ requireHttps = requireHttps,
1025+ certPin = certPin,
1026+ )
1027+ val client = HttpClientFactory.create(probeProfile)
1028+ return try {
1029+ val api = OpencodeApiClient(client)
1030+ api.ping()
1031+ true
1032+ } catch (c: kotlinx.coroutines.CancellationException) {
1033+ throw c
1034+ } catch (e: Exception) {
1035+ val status = responseStatusCode(e)
1036+ if (status == 401 || status == 403) false else throw e
1037+ } finally {
1038+ runCatching { client.close() }
1039+ }
1040+ }
1041+
1042+ private suspend fun probeWithProfile(profile: ServerProfile): ProbeResult {
1043+ val client = HttpClientFactory.create(profile)
1044+ return try {
1045+ val api = OpencodeApiClient(client)
1046+ api.ping()
1047+ ProbeResult.Reachable
1048+ } catch (c: kotlinx.coroutines.CancellationException) {
1049+ throw c
1050+ } catch (e: Exception) {
1051+ val status = responseStatusCode(e)
1052+ if (status == 401 || status == 403) {
1053+ ProbeResult.NeedsAuth
1054+ } else {
1055+ ProbeResult.Unreachable(friendlyErrorFor(e, profile.baseUrl))
1056+ }
1057+ } finally {
1058+ runCatching { client.close() }
1059+ }
1060+ }
1061+
1062+ // Declared last so every property above is fully initialized before these observers start.
1063+ // They only launch coroutines into appScope (Dispatchers.IO) — nothing here runs
1064+ // synchronously during construction — so placing init at the end removes any dependence on
1065+ // the textual declaration order of the fields those observers reference.
1066+ init {
1067+ if (!skipInit) {
1068+ NotificationChannels.create(appContext!!)
1069+ observeMessageActivity()
1070+ observeRunReconcileOnReconnect()
1071+ observeRunForegroundService()
1072+ // Flush right after load() populates the queue. observeOutbox()'s combine is keyed
1073+ // on activeConnection/isOnline/_outboxFlushTrigger — NOT on outboxStore.messages — so
1074+ // if connect() sets activeConnection before load() finishes reading a (possibly
1075+ // multi-MB) outbox.json, the connect-triggered flush sees an empty queue and nothing
1076+ // re-triggers it. Nudging the flush here guarantees a loaded queue is sent.
1077+ appScope.launch {
1078+ runCatchingCancellable { outboxStore.load() }
1079+ flushOutbox()
1080+ }
1081+ observeOutbox()
1082+ appScope.launch { autoConnect() }
1083+ }
1084+ }
1085+}
1086+
1087+/**
1088+ * Extract the session id a message-activity event pertains to, or null for events that
1089+ * don't describe message activity. This drives the unread badge: any session that
1090+ * receives activity while it isn't the currently-viewed one gets badged. Extracted as
1091+ * a top-level `internal` function so the rule is unit-testable without an Android
1092+ * [Context] (the surrounding [AppContainer] needs one).
1093+ */
1094+internal fun sessionOfEvent(event: BusEvent): String? = when (event) {
1095+ is MessageUpdated -> event.properties.info.sessionID
1096+ is MessagePartUpdated -> event.properties.part.sessionID ?: event.properties.sessionID
1097+ else -> null
1098+}
1099+
1100+/**
1101+ * The message id a message-activity event pertains to, used to de-duplicate the unread
1102+ * badge so a reply's many streaming events count once. Null for events that carry no
1103+ * message id (those fall back to being counted individually).
1104+ */
1105+internal fun messageIdOfEvent(event: BusEvent): String? = when (event) {
1106+ is MessageUpdated -> event.properties.info.id
1107+ is MessagePartUpdated -> event.properties.messageID ?: event.properties.part.messageID
1108+ else -> null
1109+}
app/src/main/java/soy/iko/opencode/di/OpencodeConnection.ktnew file+47
@@ -0,0 +1,47 @@
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.ktnew file+97
@@ -0,0 +1,97 @@
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+ val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.takeIf { it.isNotBlank() } ?: return
35+ when (intent.action) {
36+ ACTION_PERMISSION -> {
37+ val permissionId = intent.getStringExtra(EXTRA_PERMISSION_ID)?.takeIf { it.isNotBlank() } ?: return
38+ val profileId = intent.getStringExtra(EXTRA_PROFILE_ID)?.takeIf { it.isNotBlank() }
39+ val response = PermissionResponse.entries.firstOrNull {
40+ it.wire == intent.getStringExtra(EXTRA_RESPONSE)
41+ } ?: return
42+ val pending = goAsync()
43+ val finished = AtomicBoolean(false)
44+ val finishOnce = { if (finished.compareAndSet(false, true)) pending.finish() }
45+ watchdog(finishOnce)
46+ container.respondToPermissionFromNotification(sessionId, permissionId, response, profileId) { success ->
47+ // Only dismiss the notification once the tool was actually answered. If
48+ // there was no live connection (respond is a no-op) or the call failed,
49+ // leave it up so the user can retry — otherwise it vanishes with the
50+ // permission request left unanswered.
51+ if (success) SessionNotifications.cancelPermission(context, sessionId)
52+ finishOnce()
53+ }
54+ }
55+ ACTION_REPLY -> {
56+ val text = RemoteInput.getResultsFromIntent(intent)
57+ ?.getCharSequence(KEY_REPLY_TEXT)?.toString()?.trim()
58+ ?.takeIf { it.isNotEmpty() } ?: return
59+ val pending = goAsync()
60+ val finished = AtomicBoolean(false)
61+ val finishOnce = { if (finished.compareAndSet(false, true)) pending.finish() }
62+ watchdog(finishOnce)
63+ container.sendPromptFromNotification(sessionId, text) { enqueued ->
64+ // The reply is durably queued (and flushed now if online); clear the
65+ // "session ready" notification. If the enqueue failed, leave it up so the
66+ // reply isn't silently lost and the user can retry.
67+ if (enqueued) SessionNotifications.cancel(context, sessionId)
68+ finishOnce()
69+ }
70+ }
71+ }
72+ }
73+
74+ /** Schedule a deadline after which the receiver's [PendingResult] is finished even if
75+ * the network call hasn't resolved, so the system can't ANR the receiver. The
76+ * underlying work continues on the app scope. */
77+ private fun watchdog(finishOnce: () -> Unit) {
78+ Thread {
79+ try {
80+ Thread.sleep(NetworkConfig.notificationReceiverTimeoutMs)
81+ } catch (_: InterruptedException) {
82+ return@Thread
83+ }
84+ finishOnce()
85+ }.apply { isDaemon = true; name = "notif-receiver-watchdog"; start() }
86+ }
87+
88+ companion object {
89+ const val ACTION_PERMISSION = "soy.iko.opencode.action.PERMISSION"
90+ const val ACTION_REPLY = "soy.iko.opencode.action.REPLY"
91+ const val EXTRA_SESSION_ID = "soy.iko.opencode.extra.SESSION_ID"
92+ const val EXTRA_PERMISSION_ID = "soy.iko.opencode.extra.PERMISSION_ID"
93+ const val EXTRA_PROFILE_ID = "soy.iko.opencode.extra.PROFILE_ID"
94+ const val EXTRA_RESPONSE = "soy.iko.opencode.extra.RESPONSE"
95+ const val KEY_REPLY_TEXT = "soy.iko.opencode.key.REPLY_TEXT"
96+ }
97+}
app/src/main/java/soy/iko/opencode/notification/NotificationChannels.ktnew file+50
@@ -0,0 +1,50 @@
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+
26+ fun create(context: Context) {
27+ val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as? NotificationManager ?: return
28+ nm.createNotificationChannel(
29+ NotificationChannel(STATUS, context.getString(R.string.notif_channel_status), NotificationManager.IMPORTANCE_LOW).apply {
30+ description = context.getString(R.string.notif_channel_status_desc)
31+ setShowBadge(false)
32+ },
33+ )
34+ nm.createNotificationChannel(
35+ NotificationChannel(COMPLETED, context.getString(R.string.notif_channel_completed), NotificationManager.IMPORTANCE_DEFAULT).apply {
36+ description = context.getString(R.string.notif_channel_completed_desc)
37+ },
38+ )
39+ nm.createNotificationChannel(
40+ NotificationChannel(PERMISSION, context.getString(R.string.notif_channel_permission), NotificationManager.IMPORTANCE_HIGH).apply {
41+ description = context.getString(R.string.notif_channel_permission_desc)
42+ },
43+ )
44+ nm.createNotificationChannel(
45+ NotificationChannel(ERROR, context.getString(R.string.notif_channel_error), NotificationManager.IMPORTANCE_DEFAULT).apply {
46+ description = context.getString(R.string.notif_channel_error_desc)
47+ },
48+ )
49+ }
50+}
app/src/main/java/soy/iko/opencode/notification/RunForegroundService.ktnew file+121
@@ -0,0 +1,121 @@
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 androidx.core.app.NotificationCompat
13+import androidx.core.app.ServiceCompat
14+import soy.iko.opencode.MainActivity
15+import soy.iko.opencode.R
16+
17+/**
18+ * A foreground service kept alive while an opencode agent is actively running. Holding a
19+ * foreground priority lets the long-lived SSE `/event` subscription survive backgrounding
20+ * (Doze/app-standby would otherwise choke the socket and stall streaming mid-run).
21+ *
22+ * The service is started when a run begins ([start]) and stopped when it goes idle
23+ * ([stop]). It shows a low-importance "Agent is working…" notification in [NotificationChannels.STATUS].
24+ * Started/stopped via Compose from the chat screen based on the `running` state.
25+ */
26+class RunForegroundService : Service() {
27+
28+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
29+ val notification = buildNotification()
30+ // startForeground can throw ForegroundServiceStartNotAllowedException on
31+ // Android 12+ if the app is in the background when the service starts. Wrap
32+ // it so a backgrounded start (e.g. the user navigates away at the wrong
33+ // moment) doesn't crash the app — the SSE stream will just continue without
34+ // foreground priority and may be killed by the system sooner.
35+ runCatching {
36+ ServiceCompat.startForeground(
37+ this,
38+ NOTIF_ID,
39+ notification,
40+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
41+ ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
42+ } else {
43+ 0
44+ },
45+ )
46+ }.onFailure {
47+ Log.w(TAG, "startForeground failed; running without foreground priority", it)
48+ stopSelf()
49+ }
50+ return START_NOT_STICKY
51+ }
52+
53+ private fun buildNotification(): Notification {
54+ // Tapping the notification opens the app so the user can see the running session.
55+ val openIntent = Intent(this, MainActivity::class.java).apply {
56+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
57+ }
58+ val pendingIntent = PendingIntent.getActivity(
59+ this, 0, openIntent,
60+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
61+ )
62+ return NotificationCompat.Builder(this, NotificationChannels.STATUS)
63+ .setSmallIcon(R.drawable.ic_launcher_foreground)
64+ .setContentTitle(getString(R.string.notif_running_title))
65+ .setContentText(getString(R.string.notif_running_text))
66+ .setContentIntent(pendingIntent)
67+ .setOngoing(true)
68+ // Show a live elapsed timer so the user can see how long the current run has
69+ // been going — no per-tick plumbing needed; the system renders the chronometer.
70+ .setWhen(System.currentTimeMillis())
71+ .setUsesChronometer(true)
72+ .setShowWhen(true)
73+ .setPriority(NotificationCompat.PRIORITY_LOW)
74+ .setCategory(NotificationCompat.CATEGORY_PROGRESS)
75+ .build()
76+ }
77+
78+ // Android 14+ can time out a foreground service (notably the ~6h/day cumulative cap on
79+ // dataSync in Android 15) and calls onTimeout expecting a prompt stop; not stopping risks
80+ // the system force-stopping/ANR-ing the app. Stop cleanly — a still-active run just
81+ // continues without foreground priority. Both overloads are covered (the 2-arg form is
82+ // API 35+).
83+ override fun onTimeout(startId: Int) {
84+ Log.w(TAG, "Foreground service timed out; stopping")
85+ stopSelf()
86+ }
87+
88+ override fun onTimeout(startId: Int, fgsType: Int) {
89+ Log.w(TAG, "Foreground service timed out (type=$fgsType); stopping")
90+ stopSelf()
91+ }
92+
93+ override fun onDestroy() {
94+ // Safety net: if the process is being killed while a run is in progress,
95+ // ensure the notification is removed rather than lingering.
96+ ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
97+ super.onDestroy()
98+ }
99+
100+ override fun onBind(intent: Intent?): IBinder? = null
101+
102+ companion object {
103+ private const val TAG = "RunForegroundService"
104+ private const val NOTIF_ID = 1
105+
106+ fun start(context: Context) {
107+ // startForegroundService can throw ForegroundServiceStartNotAllowedException
108+ // on Android 12+ if the app is in the background. Wrap it so a backgrounded
109+ // start (e.g. the user navigates away at the wrong moment) doesn't crash.
110+ runCatching {
111+ context.startForegroundService(Intent(context, RunForegroundService::class.java))
112+ }.onFailure {
113+ Log.w(TAG, "startForegroundService failed; running without foreground priority", it)
114+ }
115+ }
116+
117+ fun stop(context: Context) {
118+ context.stopService(Intent(context, RunForegroundService::class.java))
119+ }
120+ }
121+}
app/src/main/java/soy/iko/opencode/notification/SessionNotifications.ktnew file+225
@@ -0,0 +1,225 @@
1+package soy.iko.opencode.notification
2+
3+import android.annotation.SuppressLint
4+import android.app.PendingIntent
5+import android.content.Context
6+import android.content.Intent
7+import android.os.Build
8+import android.util.Log
9+import androidx.core.app.NotificationCompat
10+import androidx.core.app.NotificationManagerCompat
11+import androidx.core.app.RemoteInput
12+import soy.iko.opencode.MainActivity
13+import soy.iko.opencode.R
14+import soy.iko.opencode.data.model.Permission
15+import soy.iko.opencode.data.model.PermissionResponse
16+import java.security.MessageDigest
17+
18+/**
19+ * Builds and posts the app's session notifications:
20+ *
21+ * - [postCompleted]: a "session ready" notification when a background run finishes, with an
22+ * inline [RemoteInput] Reply action so a follow-up can be sent without opening the app.
23+ * - [postPermission]: a heads-up notification when a paused tool needs approval, with
24+ * Allow once / Always / Reject action buttons handled by [NotificationActionReceiver].
25+ * - [postError]: a notification when a background run fails.
26+ *
27+ * Tapping a notification body opens the session via [MainActivity] with an
28+ * [MainActivity.EXTRA_SESSION_ID] extra. Notification posting is guarded for Android 13+
29+ * runtime permission: if the user hasn't granted POST_NOTIFICATIONS, the post is silently
30+ * skipped rather than throwing.
31+ */
32+object SessionNotifications {
33+
34+ private const val NOTIF_ID_PREFIX = 4000
35+ private const val TAG = "SessionNotifications"
36+
37+ // Distinct namespaces so a session can have a completion, a permission, and an error
38+ // notification outstanding at once without their ids colliding.
39+ private const val NS_COMPLETED = "done"
40+ private const val NS_PERMISSION = "perm"
41+ private const val NS_ERROR = "err"
42+
43+ private val notifIdRegex = Regex("[^A-Za-z0-9_-]")
44+
45+ /** Derive a stable, collision-resistant notification id from a namespace + session id.
46+ * String.hashCode() can collide for different inputs; a SHA-256 truncated to 31
47+ * bits makes accidental collisions astronomically unlikely. The namespace keeps the
48+ * three notification kinds for one session in separate id spaces. */
49+ private fun notifId(namespace: String, sessionId: String): Int {
50+ val digest = runCatching { MessageDigest.getInstance("SHA-256") }
51+ .getOrNull()?.digest("$namespace:$sessionId".toByteArray()) ?: return NOTIF_ID_PREFIX
52+ // Take the first 4 bytes, mask to 31 bits (always positive) to fit an Int id.
53+ // Kotlin's shl/or/and are equal-precedence infix functions evaluated strictly
54+ // left-to-right, so the shifts MUST be parenthesized individually — otherwise
55+ // `a shl 24 or b shl 16` parses as `((a shl 24) or b) shl 16`, shifting the first
56+ // byte out of the Int entirely and collapsing the id's entropy.
57+ val hash = (((digest[0].toInt() and 0xFF) shl 24) or
58+ ((digest[1].toInt() and 0xFF) shl 16) or
59+ ((digest[2].toInt() and 0xFF) shl 8) or
60+ (digest[3].toInt() and 0xFF)).and(0x7FFFFFFF)
61+ // Offset by NOTIF_ID_PREFIX, staying within the positive Int range.
62+ // Using modulo ensures the prefix is preserved without Int overflow.
63+ return NOTIF_ID_PREFIX + (hash % (0x7FFFFFFF - NOTIF_ID_PREFIX))
64+ }
65+
66+ /** Respect the POST_NOTIFICATIONS runtime permission (Android 13+): if the user denied
67+ * it, callers should skip posting instead of throwing. Foreground-service notifications
68+ * are exempt, so the running indicator still shows during a run. */
69+ private fun canPost(context: Context): Boolean =
70+ Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
71+ androidx.core.content.ContextCompat.checkSelfPermission(
72+ context, android.Manifest.permission.POST_NOTIFICATIONS,
73+ ) == android.content.pm.PackageManager.PERMISSION_GRANTED
74+
75+ /** Sanitize a session id before embedding it in the deep-link URI so characters like
76+ * /, ?, # can't inject path segments or query parameters. */
77+ private fun safeId(sessionId: String): String = notifIdRegex.replace(sessionId, "")
78+
79+ /** A PendingIntent that opens [sessionId] in the app when the notification body is tapped. */
80+ private fun openSessionIntent(context: Context, sessionId: String, requestCode: Int): PendingIntent {
81+ val openIntent = Intent(context, MainActivity::class.java).apply {
82+ action = Intent.ACTION_VIEW
83+ data = android.net.Uri.parse("opencode://session/${safeId(sessionId)}")
84+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
85+ }
86+ return PendingIntent.getActivity(
87+ context, requestCode, openIntent,
88+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
89+ )
90+ }
91+
92+ // canPost() checks POST_NOTIFICATIONS before every notify(); lint can't follow the
93+ // check through the helper, so suppress its MissingPermission flag on these posts.
94+ @SuppressLint("MissingPermission")
95+ fun postCompleted(context: Context, sessionId: String, title: String) {
96+ if (!canPost(context)) return
97+ val notifId = notifId(NS_COMPLETED, sessionId)
98+
99+ // Inline reply: a RemoteInput-backed action that broadcasts the typed follow-up to
100+ // NotificationActionReceiver. The PendingIntent MUST be mutable so the system can
101+ // fill in the RemoteInput results before delivering it.
102+ val remoteInput = RemoteInput.Builder(NotificationActionReceiver.KEY_REPLY_TEXT)
103+ .setLabel(context.getString(R.string.notif_reply_hint))
104+ .build()
105+ val replyIntent = Intent(context, NotificationActionReceiver::class.java).apply {
106+ action = NotificationActionReceiver.ACTION_REPLY
107+ putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
108+ }
109+ val replyPending = PendingIntent.getBroadcast(
110+ context, notifId, replyIntent,
111+ PendingIntent.FLAG_UPDATE_CURRENT or mutableFlag(),
112+ )
113+ val replyAction = NotificationCompat.Action.Builder(
114+ R.drawable.ic_launcher_foreground,
115+ context.getString(R.string.notif_action_reply),
116+ replyPending,
117+ ).addRemoteInput(remoteInput).setAllowGeneratedReplies(true).build()
118+
119+ val notification = NotificationCompat.Builder(context, NotificationChannels.COMPLETED)
120+ .setSmallIcon(R.drawable.ic_launcher_foreground)
121+ .setContentTitle(context.getString(R.string.notif_completed_title))
122+ // No % escaping here: getString(id, arg) inserts the argument verbatim and
123+ // never re-scans it for format specifiers, so escaping the title would show
124+ // literal doubled percent signs (e.g. a "50% done" title as "50%% done").
125+ .setContentText(context.getString(R.string.notif_completed_text, title))
126+ .setAutoCancel(true)
127+ .setContentIntent(openSessionIntent(context, sessionId, notifId))
128+ .addAction(replyAction)
129+ .setCategory(NotificationCompat.CATEGORY_MESSAGE)
130+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
131+ .build()
132+
133+ runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
134+ .onFailure { Log.w(TAG, "Failed to post completion notification", it) }
135+ }
136+
137+ /** Post a heads-up notification for a permission request with Allow once / Always /
138+ * Reject action buttons. [sessionTitle] labels the notification; [permission]'s title/
139+ * pattern/type supplies the detail line. [profileId] is embedded in the action intents
140+ * so the receiver routes the response back to the server that posted the request, not
141+ * whichever server happens to be active when the user taps. */
142+ @SuppressLint("MissingPermission")
143+ fun postPermission(context: Context, permission: Permission, sessionTitle: String, profileId: String?) {
144+ if (!canPost(context)) return
145+ val sessionId = permission.sessionID.takeIf { it.isNotBlank() } ?: return
146+ val notifId = notifId(NS_PERMISSION, sessionId)
147+ val detail = permission.title?.takeIf { it.isNotBlank() }
148+ ?: permission.patternText?.takeIf { it.isNotBlank() }
149+ ?: permission.type?.takeIf { it.isNotBlank() }
150+ ?: context.getString(R.string.notif_permission_fallback)
151+
152+ val builder = NotificationCompat.Builder(context, NotificationChannels.PERMISSION)
153+ .setSmallIcon(R.drawable.ic_launcher_foreground)
154+ .setContentTitle(context.getString(R.string.notif_permission_title, sessionTitle))
155+ .setContentText(detail)
156+ .setStyle(NotificationCompat.BigTextStyle().bigText(detail))
157+ .setAutoCancel(true)
158+ .setContentIntent(openSessionIntent(context, sessionId, notifId))
159+ .setCategory(NotificationCompat.CATEGORY_CALL)
160+ .setPriority(NotificationCompat.PRIORITY_HIGH)
161+
162+ // One action per response. Each PendingIntent needs a distinct request code or
163+ // FLAG_UPDATE_CURRENT would collapse them into one (the last extras win).
164+ listOf(
165+ PermissionResponse.ONCE to R.string.notif_action_allow_once,
166+ PermissionResponse.ALWAYS to R.string.notif_action_always,
167+ PermissionResponse.REJECT to R.string.notif_action_reject,
168+ ).forEach { (response, labelRes) ->
169+ val intent = Intent(context, NotificationActionReceiver::class.java).apply {
170+ action = NotificationActionReceiver.ACTION_PERMISSION
171+ putExtra(NotificationActionReceiver.EXTRA_SESSION_ID, sessionId)
172+ putExtra(NotificationActionReceiver.EXTRA_PERMISSION_ID, permission.id)
173+ putExtra(NotificationActionReceiver.EXTRA_RESPONSE, response.wire)
174+ profileId?.let { putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, it) }
175+ }
176+ val pending = PendingIntent.getBroadcast(
177+ context, notifId + response.ordinal + 1, intent,
178+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
179+ )
180+ builder.addAction(R.drawable.ic_launcher_foreground, context.getString(labelRes), pending)
181+ }
182+
183+ runCatching { NotificationManagerCompat.from(context).notify(notifId, builder.build()) }
184+ .onFailure { Log.w(TAG, "Failed to post permission notification", it) }
185+ }
186+
187+ /** Post a notification when a background run fails. */
188+ @SuppressLint("MissingPermission")
189+ fun postError(context: Context, sessionId: String, title: String) {
190+ if (!canPost(context)) return
191+ val notifId = notifId(NS_ERROR, sessionId)
192+ val notification = NotificationCompat.Builder(context, NotificationChannels.ERROR)
193+ .setSmallIcon(R.drawable.ic_launcher_foreground)
194+ .setContentTitle(context.getString(R.string.notif_error_title))
195+ .setContentText(context.getString(R.string.notif_error_text, title))
196+ .setAutoCancel(true)
197+ .setContentIntent(openSessionIntent(context, sessionId, notifId))
198+ .setCategory(NotificationCompat.CATEGORY_ERROR)
199+ .setPriority(NotificationCompat.PRIORITY_DEFAULT)
200+ .build()
201+ runCatching { NotificationManagerCompat.from(context).notify(notifId, notification) }
202+ .onFailure { Log.w(TAG, "Failed to post error notification", it) }
203+ }
204+
205+ /** FLAG_MUTABLE where required (Android 12+) so RemoteInput results can be injected. */
206+ private fun mutableFlag(): Int =
207+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) PendingIntent.FLAG_MUTABLE else 0
208+
209+ /** Cancel a session's completion notification (e.g. when the user opens it). */
210+ fun cancel(context: Context, sessionId: String) {
211+ NotificationManagerCompat.from(context).cancel(notifId(NS_COMPLETED, sessionId))
212+ }
213+
214+ /** Cancel a session's permission notification (on reply, or when the user opens it). */
215+ fun cancelPermission(context: Context, sessionId: String) {
216+ NotificationManagerCompat.from(context).cancel(notifId(NS_PERMISSION, sessionId))
217+ }
218+
219+ /** Cancel a session's error notification (e.g. when the user opens it). Without this a
220+ * stale "run failed" notification lingers when the session is opened by any route other
221+ * than tapping the notification itself (setAutoCancel only clears it on a direct tap). */
222+ fun cancelError(context: Context, sessionId: String) {
223+ NotificationManagerCompat.from(context).cancel(notifId(NS_ERROR, sessionId))
224+ }
225+}
app/src/main/java/soy/iko/opencode/platform/AppShortcuts.ktnew file+58
@@ -0,0 +1,58 @@
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.ktnew file+33
@@ -0,0 +1,33 @@
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.ktnew file+83
@@ -0,0 +1,83 @@
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.ktnew file+68
@@ -0,0 +1,68 @@
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.ktnew file+220
@@ -0,0 +1,220 @@
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.layout.Arrangement
9+import androidx.compose.foundation.layout.Column
10+import androidx.compose.foundation.layout.fillMaxSize
11+import androidx.compose.foundation.layout.padding
12+import androidx.compose.foundation.layout.size
13+import androidx.compose.material.icons.Icons
14+import androidx.compose.material.icons.filled.Lock
15+import androidx.compose.material3.Button
16+import androidx.compose.material3.Icon
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.runtime.DisposableEffect
22+import androidx.compose.runtime.getValue
23+import androidx.compose.runtime.mutableStateOf
24+import androidx.compose.runtime.remember
25+import androidx.compose.runtime.saveable.rememberSaveableStateHolder
26+import androidx.compose.runtime.setValue
27+import androidx.compose.ui.Alignment
28+import androidx.compose.ui.Modifier
29+import androidx.compose.ui.platform.LocalContext
30+import androidx.compose.ui.res.stringResource
31+import androidx.compose.ui.unit.dp
32+import androidx.core.content.ContextCompat
33+import androidx.fragment.app.FragmentActivity
34+import androidx.lifecycle.Lifecycle
35+import androidx.lifecycle.LifecycleEventObserver
36+import androidx.lifecycle.compose.LocalLifecycleOwner
37+import soy.iko.opencode.R
38+
39+/**
40+ * The set of authenticators the app lock accepts. Device credential (PIN/pattern/password)
41+ * can only be combined with a biometric class from Android 11 (API 30) on; on 26–29 we ask
42+ * for a strong biometric alone, so the lock is only offered (in Settings) when one is enrolled.
43+ */
44+private val lockAuthenticators: Int
45+ get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
46+ BiometricManager.Authenticators.BIOMETRIC_STRONG or BiometricManager.Authenticators.DEVICE_CREDENTIAL
47+ } else {
48+ BiometricManager.Authenticators.BIOMETRIC_STRONG
49+ }
50+
51+/** Whether device authentication is currently possible, so Settings can gate the toggle. */
52+fun canAuthenticateForAppLock(context: Context): Boolean =
53+ BiometricManager.from(context).canAuthenticate(lockAuthenticators) == BiometricManager.BIOMETRIC_SUCCESS
54+
55+/**
56+ * Whether app-lock auth is *permanently* impossible on this device (no hardware, nothing
57+ * enrolled, or a pending security update). Only these states may fail the gate open. A transient
58+ * error (sensor busy / temporarily unavailable) must NOT fail open — that would unlock the app on
59+ * a momentary glitch — so the gate stays locked and lets the user retry.
60+ */
61+private fun appLockPermanentlyUnavailable(context: Context): Boolean =
62+ when (BiometricManager.from(context).canAuthenticate(lockAuthenticators)) {
63+ BiometricManager.BIOMETRIC_ERROR_NONE_ENROLLED,
64+ BiometricManager.BIOMETRIC_ERROR_NO_HARDWARE,
65+ BiometricManager.BIOMETRIC_ERROR_SECURITY_UPDATE_REQUIRED -> true
66+ else -> false
67+ }
68+
69+/** Unwrap a [FragmentActivity] from a (possibly wrapped) Compose [Context]. */
70+private fun Context.findFragmentActivity(): FragmentActivity? {
71+ var ctx: Context? = this
72+ while (ctx is ContextWrapper) {
73+ if (ctx is FragmentActivity) return ctx
74+ ctx = ctx.baseContext
75+ }
76+ return null
77+}
78+
79+/**
80+ * Gates [content] behind device authentication when [enabled]. The content stays composed
81+ * underneath an opaque lock overlay (so navigation state survives a re-lock), and the app
82+ * re-locks whenever it's sent to the background. If no [FragmentActivity] is available or the
83+ * device can't authenticate, the gate is a no-op — the setting is only offered when
84+ * [canAuthenticateForAppLock] is true, so this fallback just prevents a hard lock-out.
85+ */
86+@Composable
87+fun AppLockGate(enabled: Boolean, content: @Composable () -> Unit) {
88+ if (!enabled) {
89+ content()
90+ return
91+ }
92+ val context = LocalContext.current
93+ val activity = remember(context) { context.findFragmentActivity() }
94+ if (activity == null) {
95+ content()
96+ return
97+ }
98+ // Fail open only if device authentication is *permanently* impossible (e.g. the user enabled
99+ // app-lock while a credential was enrolled and later removed their PIN/biometric): authenticate()
100+ // would otherwise always hit onAuthenticationError and strand the user on a permanent lockout.
101+ // A transient authenticator error keeps the lock up (the user retries) rather than unlocking.
102+ if (appLockPermanentlyUnavailable(context)) {
103+ content()
104+ return
105+ }
106+
107+ // Deliberately NOT rememberSaveable: the unlocked flag must never be persisted into the
108+ // saved-instance-state Bundle. On API 26-27 onSaveInstanceState runs *before* onStop, so a
109+ // saveable `true` would be written to the Bundle before ON_STOP resets it; after a background
110+ // process-kill and restore the app would come back unlocked with no biometric prompt — an
111+ // app-lock bypass. Rotation is handled by the activity's configChanges (no recreation), so a
112+ // plain remember still avoids re-prompting on rotation while re-locking on real recreation.
113+ var unlocked by remember { mutableStateOf(false) }
114+
115+ val title = stringResource(R.string.app_lock_prompt_title)
116+ val subtitle = stringResource(R.string.app_lock_prompt_subtitle)
117+
118+ // Track an in-flight prompt so a recomposition (or the retry button) can't stack two
119+ // BiometricPrompts, which throws.
120+ var authInFlight by remember { mutableStateOf(false) }
121+
122+ fun authenticate() {
123+ if (authInFlight || unlocked) return
124+ authInFlight = true
125+ val executor = ContextCompat.getMainExecutor(activity)
126+ val callback = object : BiometricPrompt.AuthenticationCallback() {
127+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
128+ authInFlight = false
129+ unlocked = true
130+ }
131+
132+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
133+ // User cancelled / lockout / hardware error: stay locked and let them retry
134+ // via the button rather than looping the system prompt.
135+ authInFlight = false
136+ }
137+
138+ override fun onAuthenticationFailed() {
139+ // A single non-matching attempt; the system UI lets the user try again.
140+ }
141+ }
142+ val info = BiometricPrompt.PromptInfo.Builder()
143+ .setTitle(title)
144+ .setSubtitle(subtitle)
145+ .setAllowedAuthenticators(lockAuthenticators)
146+ .build()
147+ runCatching { BiometricPrompt(activity, executor, callback).authenticate(info) }
148+ .onFailure { authInFlight = false }
149+ }
150+
151+ // Re-lock when the app is backgrounded, and re-prompt when it returns to the foreground.
152+ // The re-prompt fires on ON_START rather than by keying an effect on `unlocked`: ON_STOP
153+ // already cleared `unlocked` while backgrounded, so an effect keyed on it would see no
154+ // change on return and never re-fire, stranding the user on the manual "Unlock" button.
155+ // addObserver also delivers the current state's ON_START to a freshly-added observer, so
156+ // this covers the initial prompt on first show too; the authInFlight/unlocked guards in
157+ // authenticate() make the overlapping triggers safe.
158+ val lifecycleOwner = LocalLifecycleOwner.current
159+ DisposableEffect(lifecycleOwner) {
160+ val observer = LifecycleEventObserver { _, event ->
161+ when (event) {
162+ Lifecycle.Event.ON_STOP -> {
163+ unlocked = false
164+ // The system dismisses an in-flight prompt on background and posts its
165+ // onAuthenticationError asynchronously; clear the flag now so ON_START re-prompts
166+ // instead of no-opping on a stale authInFlight and stranding the manual button.
167+ authInFlight = false
168+ }
169+ Lifecycle.Event.ON_START -> if (!unlocked) authenticate()
170+ else -> {}
171+ }
172+ }
173+ lifecycleOwner.lifecycle.addObserver(observer)
174+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
175+ }
176+
177+ // Compose content ONLY while unlocked. Drawing the lock as an overlay *over* a still-composed
178+ // content() cannot cover a Dialog / ModalBottomSheet / DropdownMenu / Popup: those render in
179+ // separate windows z-ordered above the Activity content, so an open permission dialog or picker
180+ // sheet would stay visible and interactive above the lock — an authentication bypass. Not
181+ // composing content while locked removes those sub-windows entirely. The SaveableStateHolder
182+ // preserves navigation/scroll/input state across the re-lock so unlocking returns the user where
183+ // they were, replacing the previous "keep composed underneath" approach.
184+ val stateHolder = rememberSaveableStateHolder()
185+ if (unlocked) {
186+ stateHolder.SaveableStateProvider(APP_LOCK_CONTENT_KEY) { content() }
187+ } else {
188+ Surface(
189+ modifier = Modifier.fillMaxSize(),
190+ color = MaterialTheme.colorScheme.background,
191+ ) {
192+ Column(
193+ modifier = Modifier.fillMaxSize().padding(32.dp),
194+ horizontalAlignment = Alignment.CenterHorizontally,
195+ verticalArrangement = Arrangement.Center,
196+ ) {
197+ Icon(
198+ Icons.Filled.Lock,
199+ contentDescription = null,
200+ modifier = Modifier.size(48.dp),
201+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
202+ )
203+ Text(
204+ stringResource(R.string.app_locked),
205+ style = MaterialTheme.typography.titleMedium,
206+ modifier = Modifier.padding(top = 16.dp),
207+ )
208+ Button(
209+ onClick = { authenticate() },
210+ enabled = !authInFlight,
211+ modifier = Modifier.padding(top = 24.dp),
212+ ) {
213+ Text(stringResource(R.string.app_unlock))
214+ }
215+ }
216+ }
217+ }
218+}
219+
220+private const val APP_LOCK_CONTENT_KEY = "app_lock_content"
app/src/main/java/soy/iko/opencode/ui/OpencodeApp.kt+297−100
@@ -1,124 +1,321 @@
1package soy.iko.opencode.ui
1package soy.iko.opencode.ui
2
2
3−import androidx.activity.compose.BackHandler
3+import androidx.compose.animation.core.tween
4+import androidx.compose.animation.fadeIn
4−import androidx.compose.animation.AnimatedContent
5+import androidx.compose.animation.fadeOut
5import androidx.compose.animation.slideInHorizontally
6import androidx.compose.animation.slideInHorizontally
6import androidx.compose.animation.slideOutHorizontally
7import androidx.compose.animation.slideOutHorizontally
7−import androidx.compose.animation.togetherWith
8+import androidx.compose.foundation.layout.BoxWithConstraints
8−import 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
17import androidx.compose.runtime.Composable
9import androidx.compose.runtime.Composable
10+import androidx.compose.runtime.CompositionLocalProvider
18import androidx.compose.runtime.LaunchedEffect
11import androidx.compose.runtime.LaunchedEffect
19import androidx.compose.runtime.getValue
12import androidx.compose.runtime.getValue
20import androidx.compose.runtime.mutableStateOf
13import androidx.compose.runtime.mutableStateOf
21−import androidx.compose.runtime.remember
14+import androidx.compose.runtime.rememberCoroutineScope
15+import androidx.compose.runtime.saveable.rememberSaveable
22import androidx.compose.runtime.setValue
16import androidx.compose.runtime.setValue
23−import androidx.compose.ui.Modifier
17+import androidx.compose.ui.unit.dp
24−import soy.iko.opencode.core.Event
18+import androidx.navigation.NavType
19+import androidx.navigation.compose.NavHost
20+import androidx.navigation.compose.composable
21+import androidx.navigation.compose.rememberNavController
22+import androidx.navigation.navArgument
23+import androidx.lifecycle.compose.collectAsStateWithLifecycle
24+import kotlinx.coroutines.launch
25−import soy.iko.opencode.core.Screen
25+import soy.iko.opencode.di.AppContainer
26−import soy.iko.opencode.core.UiState
26+import soy.iko.opencode.data.network.NetworkConfig
27−import soy.iko.opencode.ui.screens.ChatScreen
27+import soy.iko.opencode.ui.chat.ChatScreen
28−import soy.iko.opencode.ui.screens.ConnectScreen
28+import soy.iko.opencode.ui.file.FileBrowserScreen
29+import soy.iko.opencode.ui.file.FileViewScreen
30+import soy.iko.opencode.ui.server.ServerEditScreen
31+import soy.iko.opencode.ui.server.ServerListScreen
32+import soy.iko.opencode.ui.search.GlobalSearchScreen
33+import soy.iko.opencode.ui.components.LocalChatTextScale
34+import soy.iko.opencode.ui.components.LocalCodeWrap
35+import soy.iko.opencode.ui.session.SessionListScreen
36+import soy.iko.opencode.ui.session.TwoPaneSessionChat
29−import soy.iko.opencode.ui.screens.SessionsScreen
37+import soy.iko.opencode.ui.settings.DiagnosticsScreen
38+import soy.iko.opencode.ui.settings.SettingsScreen
39+import soy.iko.opencode.ui.usage.UsageScreen
40+import soy.iko.opencode.ui.mcp.McpScreen
41+import soy.iko.opencode.util.runCatchingCancellable
30
42
31@Composable
43@Composable
32−fun OpencodeApp(state: UiState, dispatch: (Event) -> Unit) {
44+fun OpencodeApp(container: AppContainer) {
33− val snackbar = remember { SnackbarHostState() }
45+ val navController = rememberNavController()
34− var showStopConfirm by remember { mutableStateOf(false) }
46+ val scope = rememberCoroutineScope()
35− var showDisconnectConfirm by remember { mutableStateOf(false) }
47+ val pendingShare by container.pendingShare.collectAsStateWithLifecycle()
36−
48+ val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
37− LaunchedEffect(state.error) {
49+ val pendingOpenSession by container.pendingOpenSession.collectAsStateWithLifecycle()
38− state.error?.let {
50+ val pendingNewSession by container.pendingNewSession.collectAsStateWithLifecycle()
39− snackbar.showSnackbar(
51+ val connection by container.activeConnection.collectAsStateWithLifecycle()
40− message = it,
52+ // Chat presentation preferences, provided to the whole nav graph so the markdown/code
41− actionLabel = "Dismiss",
53+ // renderers honor the user's text-size and code-wrap choices everywhere.
42− duration = SnackbarDuration.Short,
54+ val chatTextScale by container.settingsStore.chatTextScale.collectAsStateWithLifecycle(initialValue = 1f)
43− )
55+ val codeWrap by container.settingsStore.codeWrap.collectAsStateWithLifecycle(initialValue = false)
44− // Always clear the error from state after showing.
56+
45− dispatch(Event.DismissError)
57+ // The foreground service that holds priority while any run is active is driven from the
58+ // process-lived AppContainer scope (see AppContainer.observeRunForegroundService), not
59+ // from here — a composition-scoped collector pauses while backgrounded, which is exactly
60+ // when a run started via the notification reply needs the service to start.
61+
62+ // When text is shared into the app, surface the session list so the user can pick
63+ // (or create) a conversation to drop it into. The chosen session's draft is set
64+ // in [onOpenSession] below. Keyed on `connection` too so a share that arrives
65+ // before auto-reconnect completes is retried once the connection is established.
66+ // Track the last-handled share payloads (there's no share nonce on the container) so a
67+ // *new* share arriving while already connected — a warm start where onNewIntent re-fires
68+ // setPendingShare under an unchanged connection id — still navigates, while a re-trigger
69+ // carrying no new share (a bare server switch, which routes through a null connection, or
70+ // a share the user already navigated for) leaves them where they are.
71+ //
72+ // Reset the remembered media when the pending list is drained (the chosen ChatScreen
73+ // consumes it via consumePendingSharedMedia). Without this, re-sharing the *same* images
74+ // after consumption compares equal structurally ([A,B] == [A,B]) and would be treated as
75+ // "already handled", silently skipping the session-list navigation the second time.
76+ //
77+ // rememberSaveable (not remember) so a process-kill restore doesn't reset the dedup
78+ // state and re-navigate to SESSIONS for a share the user already handled before the kill.
79+ var handledShareText by rememberSaveable { mutableStateOf<String?>(null) }
80+ var handledSharedMedia by rememberSaveable { mutableStateOf<List<String>>(emptyList()) }
81+ LaunchedEffect(pendingShare, pendingSharedMedia, connection) {
82+ // Reset the remembered handled state for each channel independently as it drains,
83+ // so re-sharing the same payload after consumption is still recognized as new. A
84+ // joint reset (only when both are empty) would drop a text-only re-share that
85+ // follows a text+media share of the same text: the text channel matches its
86+ // remembered value while the media channel is empty (not "new"), so neither clause
87+ // fires and the share is silently swallowed.
88+ if (pendingShare == null) handledShareText = null
89+ if (pendingSharedMedia.isEmpty()) handledSharedMedia = emptyList()
90+ if (pendingShare == null && pendingSharedMedia.isEmpty()) return@LaunchedEffect
91+ if (connection == null) return@LaunchedEffect
92+ val newShare = (pendingShare != null && pendingShare != handledShareText) ||
93+ (pendingSharedMedia.isNotEmpty() && pendingSharedMedia != handledSharedMedia)
94+ if (!newShare) return@LaunchedEffect
95+ handledShareText = pendingShare
96+ handledSharedMedia = pendingSharedMedia
97+ if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
98+ navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
46 }
99 }
47 }
100 }
48
101
49− BackHandler(enabled = state.screen != Screen.Connect) {
102+ // "New session" from a launcher shortcut / QS tile: once connected, create a fresh
50− when (state.screen) {
103+ // session and open it. The pending flag is consumed (compareAndSet) only *after*
51− // Leaving mid-generation would silently drop the reply, so confirm first.
104+ // createSession succeeds, so a transient failure leaves the request pending to retry
52− Screen.Chat -> if (state.generating) showStopConfirm = true else dispatch(Event.NavigateToSessions)
105+ // (on the next connection change / trigger) instead of silently dropping the tap.
53− // Disconnecting drops the server session, so guard the back button.
106+ LaunchedEffect(pendingNewSession, connection) {
54− Screen.Sessions -> showDisconnectConfirm = true
107+ if (!pendingNewSession) return@LaunchedEffect
55− Screen.Connect -> Unit
108+ val conn = connection ?: return@LaunchedEffect
109+ val session = runCatchingCancellable { conn.repository.createSession() }.getOrNull()
110+ ?: return@LaunchedEffect
111+ if (!container.consumePendingNewSession()) return@LaunchedEffect
112+ if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
113+ navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
56 }
114 }
115+ navController.navigate(Routes.chat(session.id)) { launchSingleTop = true }
57 }
116 }
58
117
59− if (showStopConfirm) {
118+ CompositionLocalProvider(
60− AlertDialog(
119+ LocalChatTextScale provides chatTextScale,
61− onDismissRequest = { showStopConfirm = false },
120+ LocalCodeWrap provides codeWrap,
62− title = { Text("Stop generation?") },
121+ ) {
63− text = { Text("Leaving will stop the current generation.") },
122+ // Adaptive: on wide screens (tablets / unfolded foldables) show the session list and
64− confirmButton = {
123+ // the open conversation side by side instead of a single-pane back stack.
65− TextButton(onClick = {
124+ BoxWithConstraints {
66− showStopConfirm = false
125+ val isTwoPane = maxWidth >= NetworkConfig.twoPaneWidthThresholdDp.dp && connection != null
67− dispatch(Event.CancelGeneration)
68− dispatch(Event.NavigateToSessions)
69− }) {
70− Text("Stop and leave")
71− }
72− },
73− dismissButton = {
74− TextButton(onClick = { showStopConfirm = false }) {
75− Text("Stay")
76− }
77− },
78− )
79− }
80
126
81− if (showDisconnectConfirm) {
127+ // Open a session requested by a notification tap or deep link, once connected.
82− AlertDialog(
128+ LaunchedEffect(pendingOpenSession, connection, isTwoPane) {
83− onDismissRequest = { showDisconnectConfirm = false },
129+ val id = pendingOpenSession ?: return@LaunchedEffect
84− title = { Text("Disconnect?") },
130+ if (connection == null) return@LaunchedEffect
85− text = { Text("You will be disconnected from the server.") },
131+ // In two-pane mode the detail pane (TwoPaneSessionChat, hosted on the SESSIONS
86− confirmButton = {
132+ // route) consumes the request and opens it in the detail pane. But it only
87− TextButton(onClick = {
133+ // exists while the NavHost is on SESSIONS, so if the user is currently on
88− showDisconnectConfirm = false
134+ // Files/Settings/FileView bring SESSIONS back to front first — otherwise the
89− dispatch(Event.NavigateToConnect)
135+ // pending request is never consumed and the session never opens. Don't consume
90− }) {
136+ // here; let TwoPaneSessionChat's effect do it once it's composed.
91− Text("Disconnect")
137+ if (isTwoPane) {
138+ if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
139+ navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
92 }
140 }
141+ return@LaunchedEffect
93− },
142+ }
94− dismissButton = {
143+ container.consumePendingOpenSession()
95− TextButton(onClick = { showDisconnectConfirm = false }) {
144+ // Ensure SESSIONS sits on the back stack below the chat destination so backing
96− Text("Cancel")
145+ // out of the chat lands on the session list (not the server list). Bring SESSIONS
146+ // to front (or push it if a cold-start deep link left only SERVERS on the stack),
147+ // then push chat on top — yielding SERVERS -> SESSIONS -> chat.
148+ if (!navController.popBackStack(Routes.SESSIONS, inclusive = false)) {
149+ navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
97− }
150+ }
98− },
151+ navController.navigate(Routes.chat(id)) { launchSingleTop = true }
99− )
100− }
152+ }
101
153
102− Scaffold(snackbarHost = { SnackbarHost(snackbar) }) { padding ->
154+ NavHost(
103− AnimatedContent(
155+ navController = navController,
104− targetState = state.screen,
156+ startDestination = Routes.SERVERS,
105− modifier = Modifier.fillMaxSize().padding(padding),
157+ // Slide horizontally for forward/back navigation to feel native; fade for
106− transitionSpec = {
158+ // the root so the first frame doesn't slide in from off-screen.
107− // Compare screen ordinals to decide slide direction: forward navigation
159+ enterTransition = { slideInHorizontally(animationSpec = tween(220)) { it } + fadeIn(tween(180)) },
108− // (Connect → Sessions → Chat) slides in from the right, back slides from the left.
160+ exitTransition = { fadeOut(tween(180)) },
109− if (targetState.ordinal > initialState.ordinal) {
161+ popEnterTransition = { fadeIn(tween(180)) },
110− slideInHorizontally { it } togetherWith slideOutHorizontally { -it }
162+ popExitTransition = { slideOutHorizontally(animationSpec = tween(220)) { it } + fadeOut(tween(180)) },
163+ ) {
164+
165+ composable(Routes.SERVERS) {
166+ ServerListScreen(
167+ container = container,
168+ onConnected = {
169+ // Only leave the server list if we're still on it. A pending deep-link /
170+ // notification open sets activeConnection (which drives the open-session
171+ // effect: SERVERS -> SESSIONS -> chat) *before* autoConnectDone fires this
172+ // callback, so a blind navigate(SESSIONS) here would slam a second SESSIONS
173+ // on top of the chat it just opened, burying it. Order-independent: if the
174+ // deep link already navigated away from SERVERS, this is a no-op.
175+ if (navController.currentDestination?.route == Routes.SERVERS) {
176+ navController.navigate(Routes.SESSIONS) { launchSingleTop = true }
177+ }
178+ },
179+ onAddProfile = { navController.navigate(Routes.serverEdit()) },
180+ onEditProfile = { id -> navController.navigate(Routes.serverEdit(id)) },
181+ onDuplicateProfile = { id -> navController.navigate(Routes.serverEditDuplicate(id)) },
182+ )
183+ }
184+
185+ composable(
186+ route = "${Routes.SERVER_EDIT}?id={id}&dup={dup}",
187+ arguments = listOf(
188+ navArgument("id") { type = NavType.StringType; nullable = true; defaultValue = null },
189+ navArgument("dup") { type = NavType.StringType; nullable = true; defaultValue = null },
190+ ),
191+ ) { entry ->
192+ ServerEditScreen(
193+ container = container,
194+ profileId = entry.arguments?.getString("id"),
195+ sourceId = entry.arguments?.getString("dup"),
196+ onDone = { navController.popBackStack() },
197+ )
198+ }
199+
200+ composable(Routes.SESSIONS) {
201+ if (isTwoPane) {
202+ TwoPaneSessionChat(
203+ container = container,
204+ onOpenFiles = { navController.navigate(Routes.FILES) },
205+ onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
206+ onOpenSettings = { navController.navigate(Routes.SETTINGS) },
207+ onOpenSearch = { navController.navigate(Routes.SEARCH) },
208+ onDisconnect = {
209+ scope.launch { runCatchingCancellable { container.disconnect() } }
210+ navController.popBackStack(Routes.SERVERS, inclusive = false)
211+ },
212+ onAddServer = { navController.navigate(Routes.serverEdit()) },
213+ )
111− } else {
214+ } else {
112− slideInHorizontally { -it } togetherWith slideOutHorizontally { it }
215+ SessionListScreen(
113− }
216+ container = container,
114− },
217+ onOpenSession = { id ->
115− label = "screen",
218+ // Set the draft synchronously in-memory before navigating so the
116− ) { screen ->
219+ // ChatScreen sees it on first composition. The async persistence
117− when (screen) {
220+ // to disk happens in the background via draftStore.set.
118− Screen.Connect -> ConnectScreen(state, dispatch)
221+ container.consumePendingShare()?.let { shareText ->
119− Screen.Sessions -> SessionsScreen(state, dispatch)
222+ container.draftStore.setImmediate(id, shareText)
120− Screen.Chat -> ChatScreen(state, dispatch)
223+ scope.launch { runCatchingCancellable { container.draftStore.set(id, shareText) } }
224+ }
225+ navController.navigate(Routes.chat(id))
226+ },
227+ onDisconnect = {
228+ scope.launch { runCatchingCancellable { container.disconnect() } }
229+ navController.popBackStack(Routes.SERVERS, inclusive = false)
230+ },
231+ onOpenFiles = { navController.navigate(Routes.FILES) },
232+ onOpenSettings = { navController.navigate(Routes.SETTINGS) },
233+ onOpenSearch = { navController.navigate(Routes.SEARCH) },
234+ onAddServer = { navController.navigate(Routes.serverEdit()) },
235+ )
121 }
236 }
122 }
237 }
238+
239+ composable(
240+ route = "${Routes.CHAT}/{sessionId}",
241+ arguments = listOf(navArgument("sessionId") { type = NavType.StringType }),
242+ ) { entry ->
243+ ChatScreen(
244+ container = container,
245+ sessionId = entry.arguments?.getString("sessionId").orEmpty(),
246+ onBack = { navController.popBackStack() },
247+ onOpenFile = { path -> navController.navigate(Routes.fileView(path)) },
248+ onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
249+ )
250+ }
251+
252+ composable(Routes.FILES) {
253+ FileBrowserScreen(
254+ container = container,
255+ onOpenFile = { path, line -> navController.navigate(Routes.fileView(path, line)) },
256+ onBack = { navController.popBackStack() },
257+ )
258+ }
259+
260+ composable(
261+ route = "${Routes.FILE_VIEW}?path={path}&line={line}",
262+ arguments = listOf(
263+ navArgument("path") { type = NavType.StringType; defaultValue = "" },
264+ navArgument("line") { type = NavType.IntType; defaultValue = -1 },
265+ ),
266+ ) { entry ->
267+ val line = entry.arguments?.getInt("line") ?: -1
268+ FileViewScreen(
269+ container = container,
270+ path = entry.arguments?.getString("path").orEmpty(),
271+ initialLine = line.takeIf { it > 0 },
272+ onBack = { navController.popBackStack() },
273+ )
274+ }
275+
276+ composable(Routes.SETTINGS) {
277+ SettingsScreen(
278+ container = container,
279+ onBack = { navController.popBackStack() },
280+ // Navigate to the server list as a new entry on top of Sessions so the
281+ // user can back out to their conversations. Previously this popped back
282+ // to the root SERVERS destination, which removed Sessions from the stack
283+ // and left the user unable to return to their conversations without
284+ // reconnecting. Pop only Settings, then push SERVERS; back from the
285+ // server list lands on Sessions (the screen the user was on before
286+ // Settings), and connecting pushes a fresh SESSIONS as usual.
287+ onManageServers = {
288+ navController.navigate(Routes.SERVERS) {
289+ popUpTo(Routes.SETTINGS) { inclusive = true }
290+ launchSingleTop = true
291+ }
292+ },
293+ onOpenDiagnostics = { navController.navigate(Routes.DIAGNOSTICS) },
294+ onOpenUsage = { navController.navigate(Routes.USAGE) },
295+ onOpenMcp = { navController.navigate(Routes.MCP) },
296+ )
297+ }
298+
299+ composable(Routes.DIAGNOSTICS) {
300+ DiagnosticsScreen(onBack = { navController.popBackStack() })
301+ }
302+
303+ composable(Routes.USAGE) {
304+ UsageScreen(container = container, onBack = { navController.popBackStack() })
305+ }
306+
307+ composable(Routes.MCP) {
308+ McpScreen(container = container, onBack = { navController.popBackStack() })
309+ }
310+
311+ composable(Routes.SEARCH) {
312+ GlobalSearchScreen(
313+ container = container,
314+ onOpenSession = { id -> navController.navigate(Routes.chat(id)) },
315+ onBack = { navController.popBackStack() },
316+ )
317+ }
318+ }
319+ }
123 }
320 }
124}
321}
app/src/main/java/soy/iko/opencode/ui/Routes.ktnew file+30
@@ -0,0 +1,30 @@
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.ktnew file+16
@@ -0,0 +1,16 @@
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.ktnew file+219
@@ -0,0 +1,219 @@
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.text.KeyboardActions
13+import androidx.compose.foundation.text.KeyboardOptions
14+import androidx.compose.material.icons.Icons
15+import androidx.compose.material.icons.filled.Check
16+import androidx.compose.material.icons.filled.Close
17+import androidx.compose.material.icons.filled.Search
18+import androidx.compose.material3.CircularProgressIndicator
19+import androidx.compose.material3.ExperimentalMaterial3Api
20+import androidx.compose.material3.Icon
21+import androidx.compose.material3.IconButton
22+import androidx.compose.material3.MaterialTheme
23+import androidx.compose.material3.ModalBottomSheet
24+import androidx.compose.material3.OutlinedTextField
25+import androidx.compose.material3.Text
26+import androidx.compose.material3.TextButton
27+import androidx.compose.runtime.Composable
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.platform.LocalSoftwareKeyboardController
36+import androidx.compose.ui.res.stringResource
37+import androidx.compose.ui.semantics.Role
38+import androidx.compose.ui.semantics.contentDescription
39+import androidx.compose.ui.semantics.role
40+import androidx.compose.ui.semantics.selected
41+import androidx.compose.ui.semantics.semantics
42+import androidx.compose.ui.text.input.ImeAction
43+import androidx.compose.ui.unit.dp
44+import androidx.compose.foundation.layout.Box
45+import soy.iko.opencode.data.model.Agent
46+import soy.iko.opencode.R
47+
48+/** Bottom sheet that lists every agent and lets the user pick one. */
49+@OptIn(ExperimentalMaterial3Api::class)
50+@Composable
51+fun AgentPickerSheet(
52+ agents: List<Agent>,
53+ selected: String?,
54+ loading: Boolean,
55+ error: Boolean,
56+ onSelect: (Agent?) -> Unit,
57+ onRetry: () -> Unit,
58+ onDismiss: () -> Unit,
59+) {
60+ ModalBottomSheet(onDismissRequest = onDismiss) {
61+ Text(
62+ stringResource(R.string.agent),
63+ style = MaterialTheme.typography.titleMedium,
64+ modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
65+ )
66+ if (loading) {
67+ val loadingLabel = stringResource(R.string.loading)
68+ Box(
69+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
70+ contentAlignment = Alignment.Center,
71+ ) {
72+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
73+ }
74+ } else if (error && agents.isEmpty()) {
75+ Column(
76+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
77+ horizontalAlignment = Alignment.CenterHorizontally,
78+ ) {
79+ Text(
80+ stringResource(R.string.load_failed),
81+ style = MaterialTheme.typography.bodyMedium,
82+ color = MaterialTheme.colorScheme.error,
83+ )
84+ TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
85+ }
86+ } else if (agents.isEmpty()) {
87+ Text(
88+ stringResource(R.string.no_agents),
89+ style = MaterialTheme.typography.bodyMedium,
90+ color = MaterialTheme.colorScheme.onSurfaceVariant,
91+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
92+ )
93+ } else {
94+ var query by rememberSaveable { mutableStateOf("") }
95+ val keyboardController = LocalSoftwareKeyboardController.current
96+ OutlinedTextField(
97+ value = query,
98+ onValueChange = { query = it },
99+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp),
100+ placeholder = { Text(stringResource(R.string.search_agents)) },
101+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
102+ trailingIcon = if (query.isNotEmpty()) {
103+ {
104+ IconButton(onClick = { query = "" }) {
105+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
106+ }
107+ }
108+ } else null,
109+ singleLine = true,
110+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
111+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
112+ )
113+ val filtered = remember(agents, query) {
114+ val q = query.trim()
115+ if (q.isEmpty()) agents
116+ else agents.filter {
117+ it.name.contains(q, ignoreCase = true) ||
118+ it.displayDescription.contains(q, ignoreCase = true)
119+ }
120+ }
121+ // The "Default" option participates in the search too: show it when the query is
122+ // empty or matches its label/description. Without this it either always showed
123+ // (ignoring the filter) or, when a query matched no named agent, disappeared with
124+ // the whole list — leaving the default unselectable.
125+ val defaultLabel = stringResource(R.string.default_agent)
126+ val defaultDesc = stringResource(R.string.default_agent_desc)
127+ val q = query.trim()
128+ val defaultMatches = q.isEmpty() ||
129+ defaultLabel.contains(q, ignoreCase = true) ||
130+ defaultDesc.contains(q, ignoreCase = true)
131+ if (filtered.isEmpty() && !defaultMatches) {
132+ Text(
133+ stringResource(R.string.no_agents_match, query.trim()),
134+ style = MaterialTheme.typography.bodyMedium,
135+ color = MaterialTheme.colorScheme.onSurfaceVariant,
136+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
137+ )
138+ } else {
139+ LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
140+ if (defaultMatches) {
141+ item(key = "__default") {
142+ Column(
143+ modifier = Modifier
144+ .fillMaxWidth()
145+ .semantics(mergeDescendants = true) {
146+ if (selected == null) this.selected = true
147+ }
148+ .clickable(role = Role.RadioButton) { onSelect(null); onDismiss() }
149+ .padding(horizontal = 24.dp, vertical = 12.dp),
150+ ) {
151+ Row(verticalAlignment = Alignment.CenterVertically) {
152+ Text(
153+ stringResource(R.string.default_agent),
154+ style = MaterialTheme.typography.bodyLarge,
155+ color = if (selected == null) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
156+ modifier = Modifier.weight(1f),
157+ )
158+ if (selected == null) {
159+ Icon(
160+ Icons.Filled.Check,
161+ contentDescription = null,
162+ tint = MaterialTheme.colorScheme.primary,
163+ modifier = Modifier.size(18.dp),
164+ )
165+ }
166+ }
167+ Text(
168+ stringResource(R.string.default_agent_desc),
169+ style = MaterialTheme.typography.bodySmall,
170+ color = MaterialTheme.colorScheme.onSurfaceVariant,
171+ )
172+ }
173+ }
174+ }
175+ // distinctBy the name so a server that returns two agents with the same name
176+ // (e.g. a built-in colliding with a project-level one) can't crash the list
177+ // with LazyColumn's "Key was already used".
178+ // Namespace the agent keys so an agent literally named "__default" can't
179+ // collide with the sentinel key of the default item above (a duplicate key
180+ // crashes LazyColumn).
181+ items(filtered.distinctBy { it.name }, key = { "agent_" + it.name }) { agent ->
182+ val isSelected = agent.name == selected
183+ Column(
184+ modifier = Modifier
185+ .fillMaxWidth()
186+ .semantics(mergeDescendants = true) {
187+ if (isSelected) this.selected = true
188+ }
189+ .clickable(role = Role.RadioButton) { onSelect(agent); onDismiss() }
190+ .padding(horizontal = 24.dp, vertical = 12.dp),
191+ ) {
192+ Row(verticalAlignment = Alignment.CenterVertically) {
193+ Text(
194+ agent.name,
195+ style = MaterialTheme.typography.bodyLarge,
196+ color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
197+ modifier = Modifier.weight(1f),
198+ )
199+ if (isSelected) {
200+ Icon(
201+ Icons.Filled.Check,
202+ contentDescription = null,
203+ tint = MaterialTheme.colorScheme.primary,
204+ modifier = Modifier.size(18.dp),
205+ )
206+ }
207+ }
208+ Text(
209+ agent.displayDescription,
210+ style = MaterialTheme.typography.bodySmall,
211+ color = MaterialTheme.colorScheme.onSurfaceVariant,
212+ )
213+ }
214+ }
215+ }
216+ }
217+ }
218+ }
219+}
app/src/main/java/soy/iko/opencode/ui/chat/AttachmentResult.ktnew file+12
@@ -0,0 +1,12 @@
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.ktnew file+114
@@ -0,0 +1,114 @@
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 kotlinx.coroutines.Dispatchers
11+import kotlinx.coroutines.withContext
12+import java.io.File
13+
14+/**
15+ * Read [uri]'s bytes off the main thread, base64-encode them into a `data:` URL, and wrap
16+ * the result as a [PendingAttachment] ready to send. Enforces the size cap. The original
17+ * [uri] is kept as the thumbnail model for images (Coil loads content Uris directly).
18+ */
19+suspend fun Uri.toAttachmentResult(context: Context): AttachmentResult = withContext(Dispatchers.IO) {
20+ val resolver = context.contentResolver
21+ val mime = resolver.getType(this@toAttachmentResult) ?: "application/octet-stream"
22+ val name = displayName(context) ?: defaultName(mime)
23+ val max = NetworkConfig.maxAttachmentBytes
24+ // Cheap pre-check: if the provider reports a size, reject an oversize file before reading
25+ // a single byte into memory. The picker can hand back arbitrarily large files (e.g. a
26+ // multi-hundred-MB video), so reading first and checking after would OOM the app.
27+ val declaredSize = fileSize(context)
28+ if (declaredSize != null && declaredSize > max) return@withContext AttachmentResult.TooLarge
29+ // Read with a hard cap so a provider that under-reports or omits its size still can't OOM
30+ // us: stop as soon as we've read one byte past the cap, then reject.
31+ val bytes = runCatching {
32+ resolver.openInputStream(this@toAttachmentResult)?.use { it.readAtMost(max + 1) }
33+ }.getOrNull() ?: return@withContext AttachmentResult.Failed
34+ if (bytes.size > max) return@withContext AttachmentResult.TooLarge
35+ val b64 = Base64.encodeToString(bytes, Base64.NO_WRAP)
36+ val dataUrl = "data:$mime;base64,$b64"
37+ AttachmentResult.Ok(
38+ PendingAttachment(
39+ id = java.util.UUID.randomUUID().toString(),
40+ name = name,
41+ mime = mime,
42+ previewModel = if (mime.startsWith("image/")) this@toAttachmentResult.toString() else null,
43+ part = FilePromptPart(mime = mime, url = dataUrl, filename = name),
44+ ),
45+ )
46+}
47+
48+/** The provider-reported byte size of the content [uri], or null if it isn't exposed. */
49+private fun Uri.fileSize(context: Context): Long? = runCatching {
50+ context.contentResolver.query(this, arrayOf(OpenableColumns.SIZE), null, null, null)?.use { cursor ->
51+ val idx = cursor.getColumnIndex(OpenableColumns.SIZE)
52+ if (idx >= 0 && cursor.moveToFirst() && !cursor.isNull(idx)) cursor.getLong(idx) else null
53+ }
54+}.getOrNull()
55+
56+/** Read up to [limit] bytes from the stream without materializing the whole file, so an
57+ * oversize source can be rejected on a bounded amount of memory. Reaching [limit] bytes is
58+ * the caller's signal that the source exceeds the cap. */
59+private fun java.io.InputStream.readAtMost(limit: Long): ByteArray {
60+ val out = java.io.ByteArrayOutputStream()
61+ val chunk = ByteArray(8 * 1024)
62+ var total = 0L
63+ while (total < limit) {
64+ val want = minOf(chunk.size.toLong(), limit - total).toInt()
65+ val read = read(chunk, 0, want)
66+ if (read < 0) break
67+ out.write(chunk, 0, read)
68+ total += read
69+ }
70+ return out.toByteArray()
71+}
72+
73+/** The raw (pre-base64) byte size encoded in a `data:<mime>;base64,<payload>` URL, derived from
74+ * the payload length (len / 4 * 3, less any '=' padding) without allocating or decoding the
75+ * payload — important since the payload can be many MB. Returns 0 when the URL has no base64
76+ * payload. Lets the cumulative attachment-size cap use the same raw-byte measure as the per-file
77+ * [NetworkConfig.maxAttachmentBytes] check above (which counts raw `bytes.size`). */
78+internal fun base64DataUrlByteSize(dataUrl: String): Long {
79+ val marker = "base64,"
80+ val start = dataUrl.indexOf(marker)
81+ if (start < 0) return 0L
82+ val payloadLen = dataUrl.length - (start + marker.length)
83+ if (payloadLen <= 0) return 0L
84+ val padding = when {
85+ dataUrl.endsWith("==") -> 2
86+ dataUrl.endsWith("=") -> 1
87+ else -> 0
88+ }
89+ return payloadLen.toLong() / 4 * 3 - padding
90+}
91+
92+/** Resolve a human-readable display name for the content [uri], if the provider exposes one. */
93+private fun Uri.displayName(context: Context): String? = runCatching {
94+ context.contentResolver.query(this, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
95+ val idx = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
96+ if (idx >= 0 && cursor.moveToFirst()) cursor.getString(idx) else null
97+ }
98+}.getOrNull()?.takeIf { it.isNotBlank() }
99+
100+private fun defaultName(mime: String): String {
101+ val ext = mime.substringAfterLast('/', "bin").takeIf { it.isNotBlank() } ?: "bin"
102+ return "attachment.$ext"
103+}
104+
105+/**
106+ * Create a temp file in the cache and a shareable content [Uri] for it (via the app's
107+ * [FileProvider]) to hand to the camera as the capture target. Returns null if it can't be
108+ * created. The file is overwritten on each capture; the OS cleans the cache dir.
109+ */
110+fun newCameraCaptureUri(context: Context): Uri? = runCatching {
111+ val dir = File(context.cacheDir, "captures").apply { mkdirs() }
112+ val file = File(dir, "capture_${System.currentTimeMillis()}.jpg")
113+ FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
114+}.getOrNull()
app/src/main/java/soy/iko/opencode/ui/chat/ChatAttachments.ktnew file+92
@@ -0,0 +1,92 @@
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.Icon
15+import androidx.compose.material3.IconButton
16+import androidx.compose.material3.MaterialTheme
17+import androidx.compose.material3.Surface
18+import androidx.compose.material3.Text
19+import androidx.compose.runtime.Composable
20+import androidx.compose.ui.Alignment
21+import androidx.compose.ui.Modifier
22+import androidx.compose.ui.draw.clip
23+import androidx.compose.ui.layout.ContentScale
24+import androidx.compose.ui.res.stringResource
25+import androidx.compose.ui.text.style.TextOverflow
26+import androidx.compose.ui.unit.dp
27+import coil.compose.AsyncImage
28+import soy.iko.opencode.R
29+
30+/**
31+ * The staged-attachment UI for the chat composer — extracted from ChatScreen so that (large)
32+ * file stays focused on the conversation surface. `internal` so ChatScreen can call it.
33+ */
34+@Composable
35+internal fun AttachmentStrip(attachments: List<PendingAttachment>, onRemove: (String) -> Unit) {
36+ if (attachments.isEmpty()) return
37+ // Horizontally-scrollable strip of staged attachment thumbnails/chips, each removable.
38+ Row(
39+ modifier = Modifier
40+ .fillMaxWidth()
41+ .horizontalScroll(rememberScrollState())
42+ .padding(start = 8.dp, end = 8.dp, top = 8.dp),
43+ horizontalArrangement = Arrangement.spacedBy(8.dp),
44+ ) {
45+ attachments.forEach { att ->
46+ AttachmentChip(att, onRemove = { onRemove(att.id) })
47+ }
48+ }
49+}
50+
51+/** A single staged attachment: an image thumbnail (or a generic file icon) with its name
52+ * and a remove button. */
53+@Composable
54+private fun AttachmentChip(attachment: PendingAttachment, onRemove: () -> Unit) {
55+ Surface(
56+ shape = MaterialTheme.shapes.small,
57+ color = MaterialTheme.colorScheme.surfaceVariant,
58+ tonalElevation = 1.dp,
59+ ) {
60+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 6.dp)) {
61+ if (attachment.previewModel != null) {
62+ AsyncImage(
63+ model = attachment.previewModel,
64+ contentDescription = attachment.name,
65+ contentScale = ContentScale.Crop,
66+ modifier = Modifier.size(36.dp).clip(MaterialTheme.shapes.extraSmall),
67+ )
68+ } else {
69+ Icon(
70+ Icons.Filled.Description,
71+ contentDescription = null,
72+ modifier = Modifier.size(24.dp),
73+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
74+ )
75+ }
76+ Text(
77+ attachment.name,
78+ style = MaterialTheme.typography.labelMedium,
79+ maxLines = 1,
80+ overflow = TextOverflow.Ellipsis,
81+ modifier = Modifier.widthIn(max = 120.dp).padding(horizontal = 6.dp),
82+ )
83+ IconButton(onClick = onRemove, modifier = Modifier.size(28.dp)) {
84+ Icon(
85+ Icons.Filled.Close,
86+ contentDescription = stringResource(R.string.remove),
87+ modifier = Modifier.size(16.dp),
88+ )
89+ }
90+ }
91+ }
92+}
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.ktnew file+1678
@@ -0,0 +1,1678 @@
1+package soy.iko.opencode.ui.chat
2+
3+import android.app.Activity
4+import android.content.Intent
5+import android.net.Uri
6+import android.speech.RecognizerIntent
7+import androidx.activity.compose.BackHandler
8+import androidx.activity.compose.rememberLauncherForActivityResult
9+import androidx.activity.result.PickVisualMediaRequest
10+import androidx.activity.result.contract.ActivityResultContracts
11+import androidx.compose.animation.AnimatedVisibility
12+import androidx.compose.foundation.clickable
13+import androidx.compose.foundation.layout.Arrangement
14+import androidx.compose.foundation.layout.Box
15+import androidx.compose.foundation.layout.Column
16+import androidx.compose.foundation.layout.FlowRow
17+import androidx.compose.foundation.layout.PaddingValues
18+import androidx.compose.foundation.layout.Row
19+import androidx.compose.foundation.layout.Spacer
20+import androidx.compose.foundation.layout.WindowInsets
21+import androidx.compose.foundation.layout.fillMaxSize
22+import androidx.compose.foundation.layout.fillMaxWidth
23+import androidx.compose.foundation.layout.imePadding
24+import androidx.compose.foundation.layout.navigationBars
25+import androidx.compose.foundation.layout.padding
26+import androidx.compose.foundation.layout.size
27+import androidx.compose.foundation.layout.union
28+import androidx.compose.foundation.layout.windowInsetsPadding
29+import androidx.compose.foundation.lazy.LazyColumn
30+import androidx.compose.foundation.lazy.items
31+import androidx.compose.foundation.lazy.rememberLazyListState
32+import androidx.compose.material.icons.Icons
33+import androidx.compose.material.icons.automirrored.filled.ArrowBack
34+import androidx.compose.material.icons.automirrored.filled.Send
35+import androidx.compose.material.icons.filled.Add
36+import androidx.compose.material.icons.filled.AutoAwesome
37+import androidx.compose.material.icons.filled.Delete
38+import androidx.compose.material.icons.filled.Description
39+import androidx.compose.material.icons.filled.Edit
40+import androidx.compose.material.icons.filled.KeyboardArrowDown
41+import androidx.compose.material.icons.filled.Mic
42+import androidx.compose.material.icons.filled.MoreVert
43+import androidx.compose.material.icons.filled.PhotoCamera
44+import androidx.compose.material.icons.filled.PhotoLibrary
45+import androidx.compose.material.icons.filled.Refresh
46+import androidx.compose.material.icons.filled.Restore
47+import androidx.compose.material.icons.filled.Stop
48+import androidx.compose.material3.AlertDialog
49+import androidx.compose.material3.Button
50+import androidx.compose.material3.CircularProgressIndicator
51+import androidx.compose.material3.DropdownMenu
52+import androidx.compose.material3.DropdownMenuItem
53+import androidx.compose.material3.ExperimentalMaterial3Api
54+import androidx.compose.material3.ExtendedFloatingActionButton
55+import androidx.compose.material3.FloatingActionButton
56+import androidx.compose.material3.Icon
57+import androidx.compose.material3.IconButton
58+import androidx.compose.material3.MaterialTheme
59+import androidx.compose.material3.OutlinedTextField
60+import androidx.compose.material3.Scaffold
61+import androidx.compose.material3.SnackbarHost
62+import androidx.compose.material3.SnackbarHostState
63+import androidx.compose.material3.Surface
64+import androidx.compose.material3.Text
65+import androidx.compose.material3.TextButton
66+import androidx.compose.material3.TopAppBar
67+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
68+import androidx.compose.runtime.Composable
69+import androidx.compose.runtime.CompositionLocalProvider
70+import androidx.compose.runtime.DisposableEffect
71+import androidx.compose.runtime.LaunchedEffect
72+import androidx.compose.runtime.derivedStateOf
73+import androidx.compose.runtime.getValue
74+import androidx.compose.runtime.mutableStateOf
75+import androidx.compose.runtime.remember
76+import androidx.compose.runtime.rememberCoroutineScope
77+import androidx.compose.runtime.rememberUpdatedState
78+import androidx.compose.runtime.saveable.rememberSaveable
79+import androidx.compose.runtime.setValue
80+import androidx.compose.runtime.snapshotFlow
81+import androidx.compose.foundation.text.KeyboardActions
82+import androidx.compose.foundation.text.KeyboardOptions
83+import androidx.compose.ui.text.input.KeyboardCapitalization
84+import androidx.compose.ui.Alignment
85+import androidx.compose.ui.Modifier
86+import androidx.compose.ui.focus.focusRequester
87+import androidx.compose.ui.input.key.Key
88+import androidx.compose.ui.input.key.KeyEventType
89+import androidx.compose.ui.input.key.isCtrlPressed
90+import androidx.compose.ui.input.key.isShiftPressed
91+import androidx.compose.ui.input.key.key
92+import androidx.compose.ui.input.key.onPreviewKeyEvent
93+import androidx.compose.ui.input.key.type
94+import androidx.compose.ui.platform.LocalContext
95+import androidx.compose.ui.platform.LocalHapticFeedback
96+import androidx.compose.ui.platform.LocalView
97+import androidx.compose.ui.platform.testTag
98+import androidx.compose.ui.res.stringResource
99+import androidx.compose.ui.semantics.Role
100+import androidx.compose.ui.semantics.contentDescription
101+import androidx.compose.ui.semantics.semantics
102+import androidx.compose.ui.text.input.ImeAction
103+import androidx.compose.ui.text.style.TextOverflow
104+import androidx.compose.ui.unit.dp
105+import androidx.lifecycle.compose.collectAsStateWithLifecycle
106+import androidx.lifecycle.viewmodel.compose.viewModel
107+import kotlinx.coroutines.Dispatchers
108+import kotlinx.coroutines.launch
109+import kotlinx.coroutines.withContext
110+import soy.iko.opencode.data.model.Part
111+import soy.iko.opencode.data.model.MessageWithParts
112+import soy.iko.opencode.data.model.ReasoningPart
113+import soy.iko.opencode.data.model.TextPart
114+import soy.iko.opencode.data.model.ToolCompleted
115+import soy.iko.opencode.data.model.ToolError
116+import soy.iko.opencode.data.model.ToolPart
117+import soy.iko.opencode.data.model.ToolRunning
118+import soy.iko.opencode.data.network.EventStreamClient
119+import soy.iko.opencode.data.network.NetworkConfig
120+import soy.iko.opencode.di.AppContainer
121+import soy.iko.opencode.R
122+import soy.iko.opencode.ui.components.CommandPalette
123+import soy.iko.opencode.ui.components.ConnectionBanner
124+import soy.iko.opencode.ui.components.PaletteAction
125+import soy.iko.opencode.ui.components.copyToClipboard
126+import soy.iko.opencode.ui.components.LocalRelativeTimeTick
127+import soy.iko.opencode.ui.components.rememberRelativeTimeTick
128+import soy.iko.opencode.ui.components.showToast
129+import soy.iko.opencode.ui.components.toImageContext
130+import soy.iko.opencode.ui.vmFactory
131+import soy.iko.opencode.util.runCatchingCancellable
132+
133+@OptIn(ExperimentalMaterial3Api::class)
134+@Composable
135+fun ChatScreen(
136+ container: AppContainer,
137+ sessionId: String,
138+ onBack: () -> Unit,
139+ onOpenFile: ((String) -> Unit)? = null,
140+ onOpenSession: ((String) -> Unit)? = null,
141+) {
142+ val vm: ChatViewModel = viewModel(key = sessionId, factory = vmFactory { ChatViewModel(container, sessionId) })
143+ val hasMessages by vm.hasMessages.collectAsStateWithLifecycle()
144+ val running by vm.running.collectAsStateWithLifecycle()
145+ val aborting by vm.aborting.collectAsStateWithLifecycle()
146+ val loading by vm.loading.collectAsStateWithLifecycle()
147+ val loadError by vm.loadError.collectAsStateWithLifecycle()
148+ val refreshing by vm.refreshing.collectAsStateWithLifecycle()
149+ val models by vm.models.collectAsStateWithLifecycle()
150+ val modelsLoading by vm.modelsLoading.collectAsStateWithLifecycle()
151+ val modelsError by vm.modelsError.collectAsStateWithLifecycle()
152+ val selectedModel by vm.selectedModel.collectAsStateWithLifecycle()
153+ val connectionState by vm.connectionState.collectAsStateWithLifecycle()
154+ val pendingPermission by vm.pendingPermission.collectAsStateWithLifecycle()
155+ val agents by vm.agents.collectAsStateWithLifecycle()
156+ val agentsLoading by vm.agentsLoading.collectAsStateWithLifecycle()
157+ val agentsError by vm.agentsError.collectAsStateWithLifecycle()
158+ val selectedAgent by vm.selectedAgent.collectAsStateWithLifecycle()
159+ val commands by vm.commands.collectAsStateWithLifecycle()
160+ val commandsLoading by vm.commandsLoading.collectAsStateWithLifecycle()
161+ val commandsError by vm.commandsError.collectAsStateWithLifecycle()
162+ val sessionTitle by vm.sessionTitle.collectAsStateWithLifecycle()
163+ val sessionDeleted by vm.sessionDeleted.collectAsStateWithLifecycle()
164+ val draft by vm.draft.collectAsStateWithLifecycle()
165+ val attachments by vm.attachments.collectAsStateWithLifecycle()
166+ val reverted by vm.reverted.collectAsStateWithLifecycle()
167+ val shareUrl by vm.shareUrl.collectAsStateWithLifecycle()
168+ val reconnecting by vm.reconnecting.collectAsStateWithLifecycle()
169+ val sendOnEnter by container.settingsStore.sendOnEnter.collectAsStateWithLifecycle(initialValue = true)
170+ val isOnline by container.isOnline.collectAsStateWithLifecycle()
171+ val haptics = LocalHapticFeedback.current
172+ val scope = rememberCoroutineScope()
173+ val shareContext = LocalContext.current
174+ val defaultShareSubject = stringResource(R.string.share_subject)
175+ val sessionLabel = stringResource(R.string.session)
176+ val defaultModelLabel = stringResource(R.string.default_model)
177+ val subtitle = remember(selectedModel?.modelLabel, selectedAgent, defaultModelLabel) {
178+ buildString {
179+ append(selectedModel?.modelLabel ?: defaultModelLabel)
180+ selectedAgent?.let { append(" · $it") }
181+ }
182+ }
183+
184+ // Connection profile (with auth) for rendering inline image attachments.
185+ // Collect as state so a server switch recomposes the image context.
186+ val activeConnection by container.activeConnection.collectAsStateWithLifecycle()
187+ val imageContext = remember(activeConnection?.profile) {
188+ activeConnection?.profile?.toImageContext()
189+ }
190+
191+ // Mark this session as read while the user is viewing it; clear on leave so new
192+ // background activity can badge it again.
193+ DisposableEffect(sessionId) {
194+ container.setCurrentSession(sessionId)
195+ onDispose { if (container.currentSession.value == sessionId) container.setCurrentSession(null) }
196+ }
197+
198+ // Navigate away when the session is deleted via SSE so the user isn't left
199+ // on a zombie screen showing a conversation that no longer exists. Surface a
200+ // toast (not a snackbar) because the screen is about to be popped — a snackbar
201+ // would be torn down before the user could read it, while a toast survives.
202+ // Covers both SSE-driven deletion (deleted on another device) and a user-
203+ // initiated delete from the chat overflow menu (deleteSession in ChatViewModel
204+ // sets the same flag on success).
205+ val deletedContext = LocalContext.current
206+ LaunchedEffect(sessionDeleted) {
207+ if (sessionDeleted) {
208+ showToast(deletedContext, deletedContext.getString(R.string.session_deleted_chat))
209+ onBack()
210+ }
211+ }
212+
213+ val snackbar = remember { SnackbarHostState() }
214+ val retryLabel = stringResource(R.string.retry)
215+ val inputFocusRequester = remember { androidx.compose.ui.focus.FocusRequester() }
216+ var showModelPicker by rememberSaveable { mutableStateOf(false) }
217+ var showAgentPicker by rememberSaveable { mutableStateOf(false) }
218+ var showCommandPicker by rememberSaveable { mutableStateOf(false) }
219+ var showExitConfirm by rememberSaveable { mutableStateOf(false) }
220+ var showStopConfirm by rememberSaveable { mutableStateOf(false) }
221+ var showOverflowMenu by rememberSaveable { mutableStateOf(false) }
222+ var showRenameDialog by rememberSaveable { mutableStateOf(false) }
223+ var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
224+ var showShellDialog by rememberSaveable { mutableStateOf(false) }
225+ var showPalette by rememberSaveable { mutableStateOf(false) }
226+
227+ // Command-palette entries (keyboard-first: Ctrl+K). Labels resolved here so the remembered
228+ // action list doesn't need a Composable context. Actions map to the same handlers the
229+ // overflow menu drives, so the palette is a keyboard shortcut to them, not a parallel path.
230+ val paletteModelLabel = stringResource(R.string.palette_switch_model)
231+ val paletteAgentLabel = stringResource(R.string.palette_choose_agent)
232+ val paletteCommandLabel = stringResource(R.string.palette_run_command)
233+ val paletteSummarizeLabel = stringResource(R.string.palette_summarize)
234+ val paletteRenameLabel = stringResource(R.string.rename_session_chat)
235+ val paletteShellLabel = stringResource(R.string.run_shell_command)
236+ val paletteActions = remember(
237+ paletteModelLabel, paletteAgentLabel, paletteCommandLabel,
238+ paletteSummarizeLabel, paletteRenameLabel, paletteShellLabel, hasMessages, running,
239+ ) {
240+ buildList {
241+ add(PaletteAction("model", paletteModelLabel) { showModelPicker = true })
242+ add(PaletteAction("agent", paletteAgentLabel) { showAgentPicker = true })
243+ add(PaletteAction("command", paletteCommandLabel) { showCommandPicker = true })
244+ if (hasMessages && !running) add(PaletteAction("summarize", paletteSummarizeLabel) { vm.summarize() })
245+ if (!running) add(PaletteAction("shell", paletteShellLabel) { showShellDialog = true })
246+ add(PaletteAction("rename", paletteRenameLabel) { showRenameDialog = true })
247+ }
248+ }
249+
250+ // Keep the screen awake while the agent is working in *this* session. The foreground
251+ // service that holds process priority during a run is managed app-wide off
252+ // container.anyRunActive (see OpencodeApp) so it survives navigating away from the chat
253+ // mid-run — otherwise leaving the screen would drop priority and Doze could choke the
254+ // SSE stream before the completion notification fires.
255+ val currentView = LocalView.current
256+ val appContext = LocalContext.current.applicationContext
257+ DisposableEffect(running) {
258+ currentView.keepScreenOn = running
259+ onDispose { currentView.keepScreenOn = false }
260+ }
261+
262+ LaunchedEffect(Unit) {
263+ vm.errorEvents.collect { event ->
264+ // Only a failed *send* is retryable; attaching Retry to any other error (e.g. a
265+ // message-load failure) would silently re-submit the last prompt on tap.
266+ val result = if (event.retryable) {
267+ snackbar.showSnackbar(message = event.message, actionLabel = retryLabel)
268+ } else {
269+ snackbar.showSnackbar(event.message)
270+ }
271+ if (result == androidx.compose.material3.SnackbarResult.ActionPerformed) vm.retryFailed()
272+ }
273+ }
274+
275+ // --- Attachments, voice, and share-link plumbing ---
276+ val attachTooLargeMsg = stringResource(R.string.attachment_too_large)
277+ val attachFailedMsg = stringResource(R.string.attachment_failed)
278+ val attachLimitMsg = stringResource(R.string.attachment_limit)
279+ val noCameraMsg = stringResource(R.string.no_camera_app)
280+ val noVoiceMsg = stringResource(R.string.no_voice_app)
281+ val voicePrompt = stringResource(R.string.voice_prompt)
282+ val linkCopiedMsg = stringResource(R.string.link_copied)
283+
284+ // Convert each picked Uri to a base64 attachment off the main thread, honoring the
285+ // per-prompt count cap and surfacing per-file errors without aborting the batch.
286+ fun stageUris(uris: List<Uri>) {
287+ if (uris.isEmpty()) return
288+ scope.launch {
289+ for (uri in uris) {
290+ if (vm.attachments.value.size >= NetworkConfig.maxAttachments) {
291+ snackbar.showSnackbar(attachLimitMsg)
292+ break
293+ }
294+ when (val result = uri.toAttachmentResult(appContext)) {
295+ is AttachmentResult.Ok -> vm.addAttachment(result.attachment)
296+ AttachmentResult.TooLarge -> snackbar.showSnackbar(attachTooLargeMsg)
297+ AttachmentResult.Failed -> snackbar.showSnackbar(attachFailedMsg)
298+ }
299+ }
300+ }
301+ }
302+
303+ val photoPicker = rememberLauncherForActivityResult(
304+ ActivityResultContracts.PickMultipleVisualMedia(NetworkConfig.maxAttachments),
305+ ) { uris -> stageUris(uris) }
306+
307+ val filePicker = rememberLauncherForActivityResult(
308+ ActivityResultContracts.GetMultipleContents(),
309+ ) { uris -> stageUris(uris) }
310+
311+ // Uri survives a config change mid-capture so the TakePicture result still resolves.
312+ var pendingCaptureUri by rememberSaveable { mutableStateOf<Uri?>(null) }
313+ val cameraLauncher = rememberLauncherForActivityResult(
314+ ActivityResultContracts.TakePicture(),
315+ ) { success ->
316+ val uri = pendingCaptureUri
317+ pendingCaptureUri = null
318+ if (success && uri != null) stageUris(listOf(uri))
319+ }
320+
321+ val voiceLauncher = rememberLauncherForActivityResult(
322+ ActivityResultContracts.StartActivityForResult(),
323+ ) { result ->
324+ if (result.resultCode == Activity.RESULT_OK) {
325+ val spoken = result.data
326+ ?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
327+ ?.firstOrNull()
328+ ?.takeIf { it.isNotBlank() }
329+ if (spoken != null) {
330+ // Append to the existing draft rather than replacing, so dictation adds to
331+ // whatever the user already typed.
332+ val current = vm.draft.value
333+ val combined = if (current.isBlank()) spoken else "$current $spoken"
334+ vm.updateDraft(combined.take(NetworkConfig.maxDraftLengthChars))
335+ }
336+ }
337+ }
338+
339+ fun launchCamera() {
340+ val uri = newCameraCaptureUri(appContext)
341+ if (uri == null) {
342+ scope.launch { snackbar.showSnackbar(attachFailedMsg) }
343+ return
344+ }
345+ pendingCaptureUri = uri
346+ runCatching { cameraLauncher.launch(uri) }
347+ .onFailure { pendingCaptureUri = null; scope.launch { snackbar.showSnackbar(noCameraMsg) } }
348+ }
349+
350+ fun launchVoice() {
351+ val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
352+ putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
353+ putExtra(RecognizerIntent.EXTRA_PROMPT, voicePrompt)
354+ }
355+ runCatching { voiceLauncher.launch(intent) }
356+ .onFailure { scope.launch { snackbar.showSnackbar(noVoiceMsg) } }
357+ }
358+
359+ // A freshly-created share link is copied to the clipboard so the user can paste it right away.
360+ LaunchedEffect(Unit) {
361+ vm.shareLinkEvents.collect { url ->
362+ copyToClipboard(shareContext, "share", url)
363+ snackbar.showSnackbar(linkCopiedMsg)
364+ }
365+ }
366+
367+ // Navigate to a session freshly branched from a message (branchFrom), once it's created.
368+ val branchedMsg = stringResource(R.string.branch_created)
369+ LaunchedEffect(onOpenSession) {
370+ vm.branchEvents.collect { newId ->
371+ showToast(shareContext, branchedMsg)
372+ onOpenSession?.invoke(newId)
373+ }
374+ }
375+
376+ // Stage images shared into the app (via the system share sheet) as attachments.
377+ // Keyed on pendingSharedMedia (not Unit) so the effect re-fires when new media arrives
378+ // while this ChatScreen is already composed — which happens in two-pane mode, where the
379+ // detail ChatScreen is persistent (keyed on sessionId) and a LaunchedEffect(Unit) would
380+ // only fire on first composition. consumePendingSharedMedia() atomically drains the
381+ // pending list (compareAndSet to empty), so re-firing on the now-empty value is a no-op.
382+ val pendingSharedMedia by container.pendingSharedMedia.collectAsStateWithLifecycle()
383+ LaunchedEffect(pendingSharedMedia) {
384+ val shared = container.consumePendingSharedMedia()
385+ if (shared.isNotEmpty()) stageUris(shared.map { Uri.parse(it) })
386+ }
387+
388+ fun doSend() {
389+ if (vm.send(draft)) {
390+ // TextHandleMove (a light tick) is semantically right for a send commit;
391+ // LongPress is reserved for long-press/swipe gestures.
392+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
393+ }
394+ }
395+
396+ BackHandler(enabled = running && !showModelPicker && !showAgentPicker && !showCommandPicker) { showExitConfirm = true }
397+
398+ Scaffold(
399+ // Hardware-keyboard shortcuts (tablets / DeX / Chromebooks): Ctrl+K opens the command
400+ // palette; Escape closes it, or stops a run. onPreviewKeyEvent on the Scaffold root sees
401+ // events before the focused text field, but only Ctrl+K / Escape are consumed so normal
402+ // typing (and the composer's own Enter-to-send handling) is untouched.
403+ modifier = Modifier.onPreviewKeyEvent { ev ->
404+ if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
405+ when {
406+ ev.isCtrlPressed && ev.key == Key.K -> { showPalette = true; true }
407+ ev.key == Key.Escape && showPalette -> { showPalette = false; true }
408+ ev.key == Key.Escape && running && !showStopConfirm && !showExitConfirm -> {
409+ showStopConfirm = true; true
410+ }
411+ else -> false
412+ }
413+ },
414+ topBar = {
415+ TopAppBar(
416+ title = {
417+ val changeLabel = stringResource(R.string.change_model_agent)
418+ Row(
419+ modifier = Modifier
420+ .clickable(enabled = models.isNotEmpty(), role = Role.Button) { showModelPicker = true }
421+ .semantics(mergeDescendants = true) { contentDescription = changeLabel },
422+ verticalAlignment = Alignment.CenterVertically,
423+ ) {
424+ Column(modifier = Modifier.weight(1f, fill = false)) {
425+ // maxLines=1 + Ellipsis so a long session title can't wrap and
426+ // push the top bar to multiple lines, shifting the whole layout.
427+ Text(
428+ sessionTitle ?: sessionLabel,
429+ maxLines = 1,
430+ overflow = TextOverflow.Ellipsis,
431+ )
432+ Text(
433+ subtitle,
434+ style = MaterialTheme.typography.labelSmall,
435+ color = MaterialTheme.colorScheme.onSurfaceVariant,
436+ maxLines = 1,
437+ overflow = TextOverflow.Ellipsis,
438+ )
439+ }
440+ // Visual affordance that the title opens the model picker;
441+ // without it the tappable title is indistinguishable from plain
442+ // text and the picker is effectively undiscoverable.
443+ if (models.isNotEmpty()) {
444+ Icon(
445+ Icons.Filled.KeyboardArrowDown,
446+ contentDescription = null,
447+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
448+ modifier = Modifier.size(20.dp),
449+ )
450+ }
451+ }
452+ },
453+ navigationIcon = {
454+ IconButton(onClick = {
455+ if (running) showExitConfirm = true else onBack()
456+ }) {
457+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
458+ }
459+ },
460+ actions = {
461+ // At-a-glance run status in the top bar. When the user is scrolled
462+ // up (jump-to-latest FAB visible), the input-bar Stop button and the
463+ // trailing "working…" row are off-screen, so a top-bar spinner is the
464+ // only signal the agent is still working.
465+ if (running) {
466+ val workingLabel = stringResource(R.string.working)
467+ CircularProgressIndicator(
468+ Modifier.size(18.dp).semantics { contentDescription = workingLabel },
469+ strokeWidth = 2.dp,
470+ )
471+ }
472+ // Manual refresh: forces an SSE reconnect, which re-seeds messages
473+ // from REST. A recovery path when the stream silently drops and the
474+ // auto-reconnect re-seed is slow or fails. Shows a brief spinner as
475+ // immediate tap feedback — the SSE reconnect may not visibly change
476+ // the connection state when already Connected, so without it the tap
477+ // appears to do nothing.
478+ val refreshLabel = stringResource(R.string.refresh)
479+ IconButton(
480+ onClick = { vm.refreshMessages() },
481+ enabled = activeConnection != null && !refreshing,
482+ ) {
483+ if (refreshing) {
484+ CircularProgressIndicator(
485+ Modifier.size(18.dp).semantics { contentDescription = refreshLabel },
486+ strokeWidth = 2.dp,
487+ )
488+ } else {
489+ Icon(Icons.Filled.Refresh, contentDescription = refreshLabel)
490+ }
491+ }
492+ // Overflow menu consolidates the less-frequent actions (share,
493+ // commands, agent, model, rename, delete) so the top bar stays
494+ // scannable on narrow phones — previously 5 icons crowded the bar
495+ // alongside the back button and tappable title. Catalog-loading
496+ // spinners stay inline (next to the overflow) so a load state is
497+ // visible without opening the menu.
498+ val moreLabel = stringResource(R.string.more)
499+ val loadingLabel = stringResource(R.string.loading)
500+ val shareLabel = stringResource(R.string.share_conversation)
501+ val commandsLabel = stringResource(R.string.commands)
502+ val agentLabel = stringResource(R.string.choose_agent)
503+ val modelLabel = stringResource(R.string.choose_model)
504+ val renameLabel = stringResource(R.string.rename_session_chat)
505+ val deleteLabel = stringResource(R.string.delete_session_chat)
506+ val createShareLabel = stringResource(R.string.create_share_link)
507+ val copyShareLabel = stringResource(R.string.copy_share_link)
508+ val stopShareLabel = stringResource(R.string.stop_sharing)
509+ val summarizeLabel = stringResource(R.string.summarize_conversation)
510+ val initLabel = stringResource(R.string.generate_agents_md)
511+ val shellLabel = stringResource(R.string.run_shell_command)
512+ Box {
513+ IconButton(onClick = { showOverflowMenu = true }) {
514+ Icon(Icons.Filled.MoreVert, contentDescription = moreLabel)
515+ }
516+ DropdownMenu(
517+ expanded = showOverflowMenu,
518+ onDismissRequest = { showOverflowMenu = false },
519+ ) {
520+ DropdownMenuItem(
521+ text = { Text(shareLabel) },
522+ enabled = hasMessages,
523+ onClick = {
524+ showOverflowMenu = false
525+ scope.launch {
526+ val md = withContext(Dispatchers.Default) {
527+ buildConversationMarkdown(vm.messages.value, sessionTitle)
528+ }
529+ val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
530+ type = "text/markdown"
531+ putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
532+ putExtra(android.content.Intent.EXTRA_TEXT, md)
533+ }
534+ runCatchingCancellable { shareContext.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
535+ .onFailure { showToast(shareContext, shareContext.getString(R.string.no_share_app)) }
536+ }
537+ },
538+ )
539+ DropdownMenuItem(
540+ text = { Text(commandsLabel) },
541+ enabled = !commandsLoading || commands.isNotEmpty(),
542+ onClick = { showOverflowMenu = false; showCommandPicker = true },
543+ )
544+ DropdownMenuItem(
545+ text = { Text(agentLabel) },
546+ enabled = !agentsLoading || agents.isNotEmpty(),
547+ onClick = { showOverflowMenu = false; showAgentPicker = true },
548+ )
549+ DropdownMenuItem(
550+ text = { Text(modelLabel) },
551+ enabled = !modelsLoading || models.isNotEmpty(),
552+ onClick = { showOverflowMenu = false; showModelPicker = true },
553+ )
554+ androidx.compose.material3.HorizontalDivider()
555+ // Session sharing: create a public link, copy an existing one, or revoke it.
556+ if (shareUrl == null) {
557+ DropdownMenuItem(
558+ text = { Text(createShareLabel) },
559+ enabled = hasMessages,
560+ onClick = { showOverflowMenu = false; vm.shareSession() },
561+ )
562+ } else {
563+ DropdownMenuItem(
564+ text = { Text(copyShareLabel) },
565+ onClick = {
566+ showOverflowMenu = false
567+ shareUrl?.let {
568+ copyToClipboard(shareContext, "share", it)
569+ scope.launch { snackbar.showSnackbar(linkCopiedMsg) }
570+ }
571+ },
572+ )
573+ DropdownMenuItem(
574+ text = { Text(stopShareLabel) },
575+ onClick = { showOverflowMenu = false; vm.unshareSession() },
576+ )
577+ }
578+ DropdownMenuItem(
579+ text = { Text(summarizeLabel) },
580+ enabled = hasMessages && !running,
581+ onClick = { showOverflowMenu = false; vm.summarize() },
582+ )
583+ DropdownMenuItem(
584+ text = { Text(initLabel) },
585+ enabled = !running,
586+ onClick = { showOverflowMenu = false; vm.initProject() },
587+ )
588+ DropdownMenuItem(
589+ text = { Text(shellLabel) },
590+ enabled = !running,
591+ onClick = { showOverflowMenu = false; showShellDialog = true },
592+ )
593+ androidx.compose.material3.HorizontalDivider()
594+ DropdownMenuItem(
595+ text = { Text(renameLabel) },
596+ onClick = { showOverflowMenu = false; showRenameDialog = true },
597+ )
598+ DropdownMenuItem(
599+ text = { Text(deleteLabel, color = MaterialTheme.colorScheme.error) },
600+ onClick = { showOverflowMenu = false; showDeleteDialog = true },
601+ )
602+ }
603+ // While a catalog is initially loading (empty list + loading flag),
604+ // show a tiny inline spinner beside the overflow so the user can see
605+ // a load is in progress without opening the menu. Once loaded the
606+ // spinner disappears; the menu items are always enabled (tapping a
607+ // picker with no items shows the "no models/agents/commands" state).
608+ val catalogLoading = (commandsLoading && commands.isEmpty()) ||
609+ (agentsLoading && agents.isEmpty()) ||
610+ (modelsLoading && models.isEmpty())
611+ if (catalogLoading) {
612+ CircularProgressIndicator(
613+ Modifier
614+ .align(Alignment.TopEnd)
615+ .size(10.dp)
616+ .semantics { contentDescription = loadingLabel },
617+ strokeWidth = 1.dp,
618+ )
619+ }
620+ }
621+ },
622+ )
623+ },
624+ snackbarHost = { SnackbarHost(snackbar) },
625+ bottomBar = {
626+ val queuedFollowUp by vm.queuedFollowUp.collectAsStateWithLifecycle()
627+ val todoPlan by vm.todoPlan.collectAsStateWithLifecycle()
628+ val queuedOffline by vm.outbox.collectAsStateWithLifecycle()
629+ val outboxSending by container.outboxSending.collectAsStateWithLifecycle()
630+ Column {
631+ // Pinned above the composer so the agent's plan and progress stay visible
632+ // while scrolled up mid-run; renders nothing when there's no plan.
633+ TodoPlanBar(todoPlan)
634+ AnimatedVisibility(visible = reverted) {
635+ RevertBanner(onUndo = { vm.unrevert() })
636+ }
637+ AnimatedVisibility(visible = queuedOffline.isNotEmpty()) {
638+ OutboxBanner(
639+ count = queuedOffline.size,
640+ sending = outboxSending,
641+ onFlush = { vm.flushQueued() },
642+ onDiscard = { vm.discardAllQueued() },
643+ )
644+ }
645+ ChatInputBar(
646+ value = draft,
647+ onValueChange = vm::updateDraft,
648+ running = running,
649+ aborting = aborting,
650+ enabled = activeConnection != null,
651+ sendOnEnter = sendOnEnter,
652+ onSend = ::doSend,
653+ onAbort = { showStopConfirm = true },
654+ queuedFollowUp = queuedFollowUp,
655+ onQueueFollowUp = vm::queueFollowUp,
656+ onCancelQueue = { vm.queueFollowUp("") },
657+ focusRequester = inputFocusRequester,
658+ attachments = attachments,
659+ onRemoveAttachment = vm::removeAttachment,
660+ onPickPhoto = {
661+ photoPicker.launch(
662+ PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
663+ )
664+ },
665+ onPickFile = { filePicker.launch("*/*") },
666+ onCamera = { launchCamera() },
667+ onVoice = { launchVoice() },
668+ )
669+ }
670+ },
671+ ) { padding ->
672+ // Collect messages inside the content lambda so streaming token updates
673+ // recompose only this subtree, not the top bar / input bar / 20+ other
674+ // state reads in the ChatScreen body.
675+ val messages by vm.messages.collectAsStateWithLifecycle()
676+ val listState = rememberLazyListState()
677+ val contentScope = rememberCoroutineScope()
678+ val timeTick = rememberRelativeTimeTick()
679+ // Group messages by day and interleave date separators so a long conversation
680+ // has visual "Today"/"Yesterday"/date breaks. Computed once per messages
681+ // emission (memoized) so a scroll-induced recomposition doesn't re-scan. Lives
682+ // outside the PullToRefreshBox so the auto-scroll effects below can reference
683+ // the same list the LazyColumn renders (separators shift the indices vs. the
684+ // raw messages list, so scroll targets must be in listItems space).
685+ val todayLabel = stringResource(R.string.today)
686+ val yesterdayLabel = stringResource(R.string.yesterday)
687+ val listItems = remember(messages, todayLabel, yesterdayLabel) {
688+ buildMessageListItems(messages, todayLabel, yesterdayLabel)
689+ }
690+ // listItems is a plain (non-snapshot) local, rebuilt each recomposition. The
691+ // derivedStateOf and snapshotFlow lambdas below are created once (keyless
692+ // remember / LaunchedEffect(Unit)); reading `listItems` directly inside them
693+ // would capture the first composition's list — which is empty, since `messages`
694+ // starts empty — freezing it forever. That pinned isPinnedToBottom at true and
695+ // AutoScrollSignal.size at 0, breaking streaming auto-scroll and the
696+ // jump-to-latest FAB. rememberUpdatedState hands those lambdas a stable State
697+ // whose value tracks the latest list.
698+ val currentListItems by rememberUpdatedState(listItems)
699+
700+ val isPinnedToBottom by remember {
701+ derivedStateOf {
702+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
703+ // When a run is active, the LazyColumn has one trailing "__typing"
704+ // item after the last list item. Account for it so "pinned to bottom"
705+ // includes the working indicator — otherwise the pin check targets
706+ // listItems.lastIndex, the typing row sits just below the viewport,
707+ // and the user never sees the progress indicator even when pinned.
708+ val items = currentListItems
709+ val effectiveLast = if (running) items.size else items.lastIndex
710+ lastVisible >= effectiveLast || items.isEmpty()
711+ }
712+ }
713+
714+ // Jump to the newest message once, when the conversation first loads. Guarded by a
715+ // saveable one-shot flag so it doesn't re-fire on recomposition after a config change
716+ // (rotation) — rememberLazyListState restores the user's scroll offset, and an
717+ // unconditional scrollToItem here would clobber it, snapping them back to the bottom.
718+ var didInitialScroll by rememberSaveable { mutableStateOf(false) }
719+ LaunchedEffect(listItems.isNotEmpty()) {
720+ if (listItems.isNotEmpty() && !didInitialScroll) {
721+ // When a run is active the LazyColumn has a trailing "__typing" row at
722+ // listItems.size; target it (not listItems.lastIndex) so deep-linking into a
723+ // running session lands truly pinned to bottom and streaming auto-scroll engages.
724+ listState.scrollToItem(if (running) listItems.size else listItems.lastIndex)
725+ didInitialScroll = true
726+ }
727+ }
728+
729+ // When a run starts, the LazyColumn gains a trailing "__typing" row, so
730+ // effectiveLast jumps from listItems.lastIndex to listItems.size and the
731+ // isPinnedToBottom check flips to false the instant you send — freezing
732+ // auto-scroll. If the user was pinned to the last item just before the run
733+ // began, bring the typing row into view so the pin (and streaming follow) is
734+ // preserved. Guard on the raw last-visible index (isPinnedToBottom has already
735+ // recomputed to false by now) so we don't scroll when the user had scrolled up.
736+ LaunchedEffect(running) {
737+ if (running && listItems.isNotEmpty()) {
738+ val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1
739+ if (lastVisible >= listItems.lastIndex) {
740+ runCatchingCancellable { listState.animateScrollToItem(listItems.size) }
741+ }
742+ }
743+ }
744+
745+ LaunchedEffect(Unit) {
746+ snapshotFlow {
747+ // Track list size + the streaming length of the last part so we auto-scroll
748+ // as content arrives, plus the pinned flag. A small data class with primitive
749+ // fields avoids the per-frame boxing that Triple<Int,Int,Boolean> pays
750+ // (snapshotFlow re-evaluates this lambda every snapshot), and reading only the
751+ // last part's length is O(1) vs. a sumOf over every part. Covering reasoning
752+ // and tool output — not just TextPart — keeps a pinned view following a long
753+ // reasoning/tool block while it streams (previously it stalled at 0).
754+ val lastLen = streamingContentLength(messages.lastOrNull()?.parts?.lastOrNull())
755+ AutoScrollSignal(currentListItems.size, lastLen, isPinnedToBottom)
756+ }.collect { signal ->
757+ if (signal.size > 0 && signal.pinned) {
758+ // Scroll to the effective last index, including the trailing
759+ // "__typing" row when a run is active so the working indicator
760+ // is brought into view (not just the last message).
761+ val items = currentListItems
762+ val target = if (running) items.size else items.lastIndex
763+ listState.scrollToItem(target)
764+ }
765+ }
766+ }
767+
768+ CompositionLocalProvider(LocalRelativeTimeTick provides timeTick) {
769+ // The bottomBar (ChatInputBar) already has imePadding, and the Scaffold's
770+ // content padding accounts for the bottomBar's raised height, so the message
771+ // list is already above the keyboard. Adding imePadding here would double-apply
772+ // the IME inset and push messages too far up.
773+ Box(modifier = Modifier.fillMaxSize().padding(padding)) {
774+ if (activeConnection == null) {
775+ Column(
776+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
777+ horizontalAlignment = Alignment.CenterHorizontally,
778+ ) {
779+ Text(
780+ stringResource(R.string.not_connected),
781+ color = MaterialTheme.colorScheme.error,
782+ )
783+ Spacer(Modifier.size(12.dp))
784+ Button(onClick = { vm.reconnect() }, enabled = !reconnecting) {
785+ if (reconnecting) {
786+ CircularProgressIndicator(
787+ Modifier.size(20.dp),
788+ color = MaterialTheme.colorScheme.onPrimary,
789+ strokeWidth = 2.dp,
790+ )
791+ } else {
792+ Text(stringResource(R.string.reconnect))
793+ }
794+ }
795+ }
796+ } else {
797+ ConnectionBanner(
798+ state = connectionState,
799+ modifier = Modifier.align(Alignment.TopCenter),
800+ isOnline = isOnline,
801+ // On a hard failure, retry by forcing an SSE reconnect (which
802+ // re-seeds from REST). refreshMessages is the right recovery path
803+ // when the connection is present but the stream died; reconnect()
804+ // is the path when the whole connection is gone (handled by the
805+ // separate "Not connected" state below).
806+ onRetry = { vm.refreshMessages() },
807+ )
808+ if (loading && messages.isEmpty()) {
809+ val loadingLabel = stringResource(R.string.loading)
810+ CircularProgressIndicator(
811+ Modifier
812+ .align(Alignment.Center)
813+ .semantics { contentDescription = loadingLabel },
814+ )
815+ } else if (loadError && messages.isEmpty()) {
816+ // A failed load with nothing to show. Distinct from the empty
817+ // conversation state below — the conversation isn't empty, it
818+ // failed to load, and offering "Start a conversation" here is
819+ // misleading. Offer a Retry instead, mirroring SessionListScreen.
820+ Column(
821+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
822+ horizontalAlignment = Alignment.CenterHorizontally,
823+ ) {
824+ Text(
825+ stringResource(R.string.load_messages_failed),
826+ color = MaterialTheme.colorScheme.error,
827+ )
828+ Spacer(Modifier.size(12.dp))
829+ Button(onClick = { vm.refreshMessages() }) {
830+ Text(stringResource(R.string.retry))
831+ }
832+ }
833+ } else if (messages.isEmpty() && running) {
834+ // A run just started but no parts have arrived yet — the empty
835+ // list with only the trailing "working…" row looks broken, so
836+ // surface a clear starting state until the first part streams in.
837+ val workingText = stringResource(R.string.working)
838+ Column(
839+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
840+ horizontalAlignment = Alignment.CenterHorizontally,
841+ ) {
842+ CircularProgressIndicator(Modifier.size(28.dp), strokeWidth = 3.dp)
843+ Spacer(Modifier.size(12.dp))
844+ Text(workingText, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant)
845+ }
846+ } else if (messages.isEmpty()) {
847+ EmptyConversation(
848+ onSuggestion = {
849+ vm.updateDraft(it)
850+ // Focus the input so the user can edit the suggestion before
851+ // sending, instead of having to manually tap the field.
852+ runCatching { inputFocusRequester.requestFocus() }
853+ },
854+ modifier = Modifier.align(Alignment.Center),
855+ )
856+ } else {
857+ // Pull-to-refresh wraps the message list so the user can force an SSE
858+ // reconnect + re-seed with the same gesture they use on the session and
859+ // file lists, instead of hunting for the top-bar refresh icon. The empty
860+ // / loading / error states above stay outside the PTR box since there's
861+ // no list content to pull against.
862+ PullToRefreshBox(
863+ isRefreshing = refreshing,
864+ onRefresh = { vm.refreshMessages() },
865+ modifier = Modifier.fillMaxSize(),
866+ ) {
867+ val lastMessageId = messages.lastOrNull()?.info?.id
868+ // Text-to-speech for reading assistant replies aloud; shuts down when the
869+ // message list leaves composition.
870+ val tts = rememberTtsController()
871+ val speakingMessageId by tts.speakingId
872+ LazyColumn(
873+ state = listState,
874+ modifier = Modifier.fillMaxSize(),
875+ contentPadding = PaddingValues(16.dp),
876+ verticalArrangement = Arrangement.spacedBy(16.dp),
877+ ) {
878+ items(
879+ items = listItems,
880+ key = { it.key },
881+ contentType = { it.contentType },
882+ ) { item ->
883+ when (item) {
884+ is MessageListItem.Separator -> DateSeparator(item.label)
885+ is MessageListItem.Message -> {
886+ val message = item.message
887+ // Only the last (streaming) message needs isRunning — it drives the
888+ // reasoning-block spinner. Passing the live flag to every bubble
889+ // makes all visible messages recompose whenever a run starts or stops.
890+ val modelLabel = remember(message.info, models) {
891+ (message.info as? soy.iko.opencode.data.model.AssistantMessage)
892+ ?.let { resolveModelLabel(it, models) }
893+ }
894+ MessageBubble(
895+ message,
896+ isRunning = running && message.info.id == lastMessageId,
897+ imageContext = imageContext,
898+ modelLabel = modelLabel,
899+ onOpenFile = onOpenFile,
900+ onRevert = { vm.revertTo(message.info.id) },
901+ onEdit = { text -> vm.editMessage(message.info.id, text) },
902+ onSpeak = { text -> tts.toggle(message.info.id, text) },
903+ isSpeaking = message.info.id == speakingMessageId,
904+ onQuote = { text ->
905+ vm.quoteReply(text)
906+ runCatching { inputFocusRequester.requestFocus() }
907+ },
908+ onBranch = { text -> vm.branchFrom(text) },
909+ )
910+ }
911+ }
912+ }
913+ if (running) {
914+ item(key = "__typing") {
915+ val workingText = stringResource(R.string.working)
916+ Row(
917+ verticalAlignment = Alignment.CenterVertically,
918+ modifier = Modifier.semantics {
919+ contentDescription = workingText
920+ },
921+ ) {
922+ CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
923+ Text(workingText, style = MaterialTheme.typography.labelMedium, modifier = Modifier.padding(start = 6.dp))
924+ }
925+ }
926+ }
927+ }
928+ }
929+ // Jump-to-latest affordance when the user has scrolled away during a stream.
930+ AnimatedVisibility(
931+ visible = !isPinnedToBottom && listItems.isNotEmpty(),
932+ modifier = Modifier.align(Alignment.BottomEnd),
933+ ) {
934+ ExtendedFloatingActionButton(
935+ onClick = {
936+ if (listItems.isNotEmpty()) {
937+ // Scroll to the effective last index, including the
938+ // trailing "__typing" row when a run is active.
939+ val target = if (running) listItems.size else listItems.lastIndex
940+ contentScope.launch { runCatchingCancellable { listState.animateScrollToItem(target) } }
941+ }
942+ },
943+ icon = { Icon(Icons.Filled.KeyboardArrowDown, contentDescription = stringResource(R.string.latest)) },
944+ text = { Text(stringResource(R.string.latest)) },
945+ modifier = Modifier.padding(end = 16.dp, bottom = 16.dp),
946+ )
947+ }
948+ }
949+ }
950+ }
951+ }
952+ }
953+
954+ if (showExitConfirm) {
955+ AlertDialog(
956+ onDismissRequest = { showExitConfirm = false },
957+ title = { Text(stringResource(R.string.stop_and_exit_title)) },
958+ text = { Text(stringResource(R.string.stop_and_exit_text)) },
959+ confirmButton = {
960+ TextButton(onClick = {
961+ showExitConfirm = false
962+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
963+ vm.abort()
964+ onBack()
965+ }) { Text(stringResource(R.string.stop_and_exit)) }
966+ },
967+ dismissButton = {
968+ TextButton(onClick = { showExitConfirm = false }) { Text(stringResource(R.string.stay)) }
969+ },
970+ )
971+ }
972+
973+ if (showStopConfirm) {
974+ AlertDialog(
975+ onDismissRequest = { showStopConfirm = false },
976+ title = { Text(stringResource(R.string.stop_run_title)) },
977+ text = { Text(stringResource(R.string.stop_run_text)) },
978+ confirmButton = {
979+ TextButton(onClick = {
980+ showStopConfirm = false
981+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.TextHandleMove)
982+ vm.abort()
983+ }) { Text(stringResource(R.string.stop), color = MaterialTheme.colorScheme.error) }
984+ },
985+ dismissButton = {
986+ TextButton(onClick = { showStopConfirm = false }) { Text(stringResource(R.string.cancel)) }
987+ },
988+ )
989+ }
990+
991+ if (showRenameDialog) {
992+ RenameSessionChatDialog(
993+ initialTitle = sessionTitle ?: "",
994+ onDismiss = { showRenameDialog = false },
995+ onConfirm = { newName ->
996+ showRenameDialog = false
997+ vm.renameSession(newName)
998+ },
999+ )
1000+ }
1001+
1002+ if (showDeleteDialog) {
1003+ AlertDialog(
1004+ onDismissRequest = { showDeleteDialog = false },
1005+ title = { Text(stringResource(R.string.delete_session_chat_title)) },
1006+ text = { Text(stringResource(R.string.delete_session_chat_text, sessionTitle ?: sessionLabel)) },
1007+ confirmButton = {
1008+ TextButton(onClick = {
1009+ showDeleteDialog = false
1010+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
1011+ vm.deleteSession()
1012+ }) { Text(stringResource(R.string.delete), color = MaterialTheme.colorScheme.error) }
1013+ },
1014+ dismissButton = {
1015+ TextButton(onClick = { showDeleteDialog = false }) { Text(stringResource(R.string.cancel)) }
1016+ },
1017+ )
1018+ }
1019+
1020+ if (showShellDialog) {
1021+ ShellCommandDialog(
1022+ onDismiss = { showShellDialog = false },
1023+ onRun = { cmd ->
1024+ showShellDialog = false
1025+ vm.runShell(cmd)
1026+ },
1027+ )
1028+ }
1029+
1030+ if (showModelPicker) {
1031+ ModelPickerSheet(
1032+ options = models,
1033+ selected = selectedModel,
1034+ loading = modelsLoading,
1035+ error = modelsError,
1036+ onSelect = { vm.selectModel(it) },
1037+ onRetry = { vm.reloadModels() },
1038+ onDismiss = { showModelPicker = false },
1039+ )
1040+ }
1041+
1042+ if (showAgentPicker) {
1043+ AgentPickerSheet(
1044+ agents = agents,
1045+ selected = selectedAgent,
1046+ loading = agentsLoading,
1047+ error = agentsError,
1048+ onSelect = { vm.selectAgent(it?.name) },
1049+ onRetry = { vm.reloadAgents() },
1050+ onDismiss = { showAgentPicker = false },
1051+ )
1052+ }
1053+
1054+ if (showCommandPicker) {
1055+ CommandPickerSheet(
1056+ commands = commands,
1057+ loading = commandsLoading,
1058+ error = commandsError,
1059+ onSelect = { vm.runCommand(it) },
1060+ onRetry = { vm.reloadCommands() },
1061+ onDismiss = { showCommandPicker = false },
1062+ )
1063+ }
1064+
1065+ pendingPermission?.let { permission ->
1066+ PermissionDialog(
1067+ permission = permission,
1068+ onRespond = { response -> vm.respondPermission(permission, response) },
1069+ )
1070+ }
1071+
1072+ if (showPalette) {
1073+ CommandPalette(actions = paletteActions, onDismiss = { showPalette = false })
1074+ }
1075+}
1076+
1077+@Composable
1078+private fun ChatInputBar(
1079+ value: String,
1080+ onValueChange: (String) -> Unit,
1081+ running: Boolean,
1082+ aborting: Boolean,
1083+ enabled: Boolean,
1084+ sendOnEnter: Boolean,
1085+ onSend: () -> Unit,
1086+ onAbort: () -> Unit,
1087+ queuedFollowUp: String?,
1088+ onQueueFollowUp: (String) -> Unit,
1089+ onCancelQueue: () -> Unit,
1090+ focusRequester: androidx.compose.ui.focus.FocusRequester,
1091+ attachments: List<PendingAttachment>,
1092+ onRemoveAttachment: (String) -> Unit,
1093+ onPickPhoto: () -> Unit,
1094+ onPickFile: () -> Unit,
1095+ onCamera: () -> Unit,
1096+ onVoice: () -> Unit,
1097+) {
1098+ // Sendable when there's text OR at least one attachment (an image-only prompt is valid).
1099+ val hasContent = value.isNotBlank() || attachments.isNotEmpty()
1100+ Surface(tonalElevation = 3.dp, modifier = Modifier.imePadding()) {
1101+ Column(modifier = Modifier.fillMaxWidth().windowInsetsPadding(WindowInsets.navigationBars)) {
1102+ // A queued follow-up replaces the Stop button with a "queued" chip so the
1103+ // user sees their message will be sent when the run finishes, and can cancel.
1104+ if (queuedFollowUp != null) {
1105+ Row(
1106+ modifier = Modifier
1107+ .fillMaxWidth()
1108+ .padding(start = 12.dp, top = 6.dp, end = 4.dp),
1109+ verticalAlignment = Alignment.CenterVertically,
1110+ ) {
1111+ Column(modifier = Modifier.weight(1f)) {
1112+ Text(
1113+ stringResource(R.string.queued),
1114+ style = MaterialTheme.typography.labelSmall,
1115+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1116+ )
1117+ Text(
1118+ queuedFollowUp,
1119+ style = MaterialTheme.typography.bodySmall,
1120+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1121+ maxLines = 1,
1122+ overflow = TextOverflow.Ellipsis,
1123+ )
1124+ }
1125+ TextButton(onClick = onCancelQueue, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
1126+ Text(stringResource(R.string.cancel))
1127+ }
1128+ }
1129+ }
1130+ // Staged attachments: horizontally-scrollable thumbnails/chips, each removable.
1131+ AttachmentStrip(attachments, onRemove = onRemoveAttachment)
1132+ Row(
1133+ modifier = Modifier.fillMaxWidth().padding(8.dp),
1134+ verticalAlignment = Alignment.Bottom,
1135+ ) {
1136+ // Attach menu: photo picker, camera, or any file.
1137+ var showAttachMenu by remember { mutableStateOf(false) }
1138+ Box {
1139+ IconButton(onClick = { showAttachMenu = true }, enabled = enabled) {
1140+ Icon(Icons.Filled.Add, contentDescription = stringResource(R.string.add_attachment))
1141+ }
1142+ DropdownMenu(expanded = showAttachMenu, onDismissRequest = { showAttachMenu = false }) {
1143+ DropdownMenuItem(
1144+ leadingIcon = { Icon(Icons.Filled.PhotoLibrary, contentDescription = null) },
1145+ text = { Text(stringResource(R.string.attach_photo)) },
1146+ onClick = { showAttachMenu = false; onPickPhoto() },
1147+ )
1148+ DropdownMenuItem(
1149+ leadingIcon = { Icon(Icons.Filled.PhotoCamera, contentDescription = null) },
1150+ text = { Text(stringResource(R.string.attach_camera)) },
1151+ onClick = { showAttachMenu = false; onCamera() },
1152+ )
1153+ DropdownMenuItem(
1154+ leadingIcon = { Icon(Icons.Filled.Description, contentDescription = null) },
1155+ text = { Text(stringResource(R.string.attach_file)) },
1156+ onClick = { showAttachMenu = false; onPickFile() },
1157+ )
1158+ }
1159+ }
1160+ // Voice dictation: appends recognized speech to the draft.
1161+ IconButton(onClick = onVoice, enabled = enabled) {
1162+ Icon(Icons.Filled.Mic, contentDescription = stringResource(R.string.voice_input))
1163+ }
1164+ OutlinedTextField(
1165+ value = value,
1166+ onValueChange = { v ->
1167+ // Cap input length so a huge paste can't stall the UI. Shares are
1168+ // capped separately in MainActivity; this guards typed/pasted drafts.
1169+ onValueChange(v.take(NetworkConfig.maxDraftLengthChars))
1170+ },
1171+ modifier = Modifier
1172+ .weight(1f)
1173+ .focusRequester(focusRequester)
1174+ .testTag("chat_input")
1175+ .onPreviewKeyEvent { event ->
1176+ if (event.type != KeyEventType.KeyDown || event.key != Key.Enter) return@onPreviewKeyEvent false
1177+ // With "Send on Enter" on, Enter sends (Shift+Enter newlines).
1178+ // With it off, Enter inserts a newline and Ctrl+Enter sends.
1179+ val send = enterShouldSend(
1180+ enabled = enabled,
1181+ hasContent = hasContent,
1182+ sendOnEnter = sendOnEnter,
1183+ shift = event.isShiftPressed,
1184+ ctrl = event.isCtrlPressed,
1185+ )
1186+ if (send) {
1187+ if (running) onQueueFollowUp(value) else onSend()
1188+ true
1189+ } else {
1190+ false
1191+ }
1192+ },
1193+ placeholder = { Text(stringResource(R.string.message_placeholder)) },
1194+ enabled = enabled,
1195+ maxLines = 6,
1196+ // Show a "N / max" countdown once the draft crosses a high fraction of
1197+ // the cap, so the user knows a paste is about to be truncated instead
1198+ // of being silently cut off. Hidden for normal short prompts to avoid
1199+ // clutter under a typical one-line message.
1200+ supportingText = {
1201+ val threshold = (NetworkConfig.maxDraftLengthChars * NetworkConfig.draftCountdownThresholdFraction).toInt()
1202+ if (value.length >= threshold) {
1203+ Text(
1204+ stringResource(
1205+ R.string.draft_chars_remaining,
1206+ NetworkConfig.maxDraftLengthChars - value.length,
1207+ NetworkConfig.maxDraftLengthChars,
1208+ ),
1209+ style = MaterialTheme.typography.labelSmall,
1210+ color = if (value.length >= NetworkConfig.maxDraftLengthChars) MaterialTheme.colorScheme.error
1211+ else MaterialTheme.colorScheme.onSurfaceVariant,
1212+ )
1213+ }
1214+ },
1215+ keyboardOptions = KeyboardOptions(
1216+ // With send-on-Enter off, expose the platform default action so the soft
1217+ // keyboard shows a newline key (this is a multi-line field) instead of a
1218+ // Send action — otherwise the setting has no effect on soft keyboards and
1219+ // there's no way to type a newline. Hardware Enter is handled separately.
1220+ imeAction = if (sendOnEnter) ImeAction.Send else ImeAction.Default,
1221+ capitalization = KeyboardCapitalization.Sentences,
1222+ ),
1223+ keyboardActions = KeyboardActions(onSend = {
1224+ if (enabled && hasContent) {
1225+ if (running) onQueueFollowUp(value) else onSend()
1226+ }
1227+ }),
1228+ )
1229+ ComposerTrailingButton(
1230+ running = running,
1231+ aborting = aborting,
1232+ canSend = enabled && hasContent,
1233+ onSend = onSend,
1234+ onAbort = onAbort,
1235+ )
1236+ }
1237+ }
1238+ }
1239+}
1240+
1241+/** Whether a hardware Enter keypress should send: never when disabled/empty; otherwise
1242+ * Enter sends (unless Shift) when send-on-Enter is on, else only Ctrl+Enter sends. */
1243+private fun enterShouldSend(
1244+ enabled: Boolean,
1245+ hasContent: Boolean,
1246+ sendOnEnter: Boolean,
1247+ shift: Boolean,
1248+ ctrl: Boolean,
1249+): Boolean = when {
1250+ !enabled || !hasContent -> false
1251+ sendOnEnter -> !shift
1252+ else -> ctrl
1253+}
1254+
1255+/** The composer's trailing button: a Stop (with in-flight spinner) while a run is active,
1256+ * otherwise Send. Extracted so [ChatInputBar] stays under the complexity threshold. */
1257+@Composable
1258+private fun ComposerTrailingButton(
1259+ running: Boolean,
1260+ aborting: Boolean,
1261+ canSend: Boolean,
1262+ onSend: () -> Unit,
1263+ onAbort: () -> Unit,
1264+) {
1265+ if (running) {
1266+ // Show a spinner while the abort REST call is in flight so the user sees the stop
1267+ // was sent, and disable to prevent a double-tap.
1268+ IconButton(
1269+ onClick = onAbort,
1270+ enabled = !aborting,
1271+ modifier = Modifier.padding(start = 4.dp).testTag("stop_button"),
1272+ ) {
1273+ if (aborting) {
1274+ val stopLabel = stringResource(R.string.stop)
1275+ CircularProgressIndicator(
1276+ Modifier.size(18.dp).semantics { contentDescription = stopLabel },
1277+ strokeWidth = 2.dp,
1278+ )
1279+ } else {
1280+ Icon(Icons.Filled.Stop, contentDescription = stringResource(R.string.stop))
1281+ }
1282+ }
1283+ } else {
1284+ IconButton(
1285+ onClick = onSend,
1286+ enabled = canSend,
1287+ modifier = Modifier.padding(start = 4.dp).testTag("send_button"),
1288+ ) {
1289+ Icon(Icons.AutoMirrored.Filled.Send, contentDescription = stringResource(R.string.send))
1290+ }
1291+ }
1292+}
1293+
1294+@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
1295+@Composable
1296+private fun EmptyConversation(
1297+ onSuggestion: (String) -> Unit,
1298+ modifier: Modifier = Modifier,
1299+) {
1300+ // A few example prompts so a first-time user has a one-tap path to their first
1301+ // message instead of staring at a blank input. Tapping a chip fills the draft
1302+ // (without sending) so the user can edit it before sending.
1303+ val suggestions = remember {
1304+ listOf(
1305+ R.string.empty_chat_suggest_1,
1306+ R.string.empty_chat_suggest_2,
1307+ R.string.empty_chat_suggest_3,
1308+ )
1309+ }
1310+ Column(
1311+ modifier = modifier.padding(32.dp),
1312+ horizontalAlignment = Alignment.CenterHorizontally,
1313+ ) {
1314+ Icon(
1315+ Icons.Filled.AutoAwesome,
1316+ contentDescription = null,
1317+ modifier = Modifier.size(48.dp),
1318+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
1319+ )
1320+ Spacer(Modifier.size(16.dp))
1321+ Text(
1322+ stringResource(R.string.empty_chat_title),
1323+ style = MaterialTheme.typography.titleMedium,
1324+ )
1325+ Spacer(Modifier.size(8.dp))
1326+ Text(
1327+ stringResource(R.string.empty_chat_hint),
1328+ style = MaterialTheme.typography.bodyMedium,
1329+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1330+ )
1331+ Spacer(Modifier.size(20.dp))
1332+ // Suggestion chips wrap across rows on narrow screens instead of stacking full-width,
1333+ // so a longer localized prompt doesn't push the chips off-screen on a phone.
1334+ FlowRow(
1335+ modifier = Modifier.fillMaxWidth(),
1336+ horizontalArrangement = Arrangement.spacedBy(8.dp),
1337+ verticalArrangement = Arrangement.spacedBy(8.dp),
1338+ ) {
1339+ suggestions.forEach { resId ->
1340+ val text = stringResource(resId)
1341+ androidx.compose.material3.AssistChip(
1342+ onClick = { onSuggestion(text) },
1343+ label = { Text(text, style = MaterialTheme.typography.bodyMedium) },
1344+ )
1345+ }
1346+ }
1347+ }
1348+}
1349+
1350+/** A banner shown while a revert checkpoint is active, offering an Undo (unrevert). */
1351+@Composable
1352+private fun RevertBanner(onUndo: () -> Unit) {
1353+ Surface(
1354+ color = MaterialTheme.colorScheme.tertiaryContainer,
1355+ tonalElevation = 2.dp,
1356+ modifier = Modifier.fillMaxWidth(),
1357+ ) {
1358+ Row(
1359+ modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
1360+ verticalAlignment = Alignment.CenterVertically,
1361+ horizontalArrangement = Arrangement.SpaceBetween,
1362+ ) {
1363+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
1364+ Icon(
1365+ Icons.Filled.Restore,
1366+ contentDescription = null,
1367+ tint = MaterialTheme.colorScheme.onTertiaryContainer,
1368+ modifier = Modifier.size(18.dp),
1369+ )
1370+ Text(
1371+ stringResource(R.string.reverted_banner),
1372+ style = MaterialTheme.typography.bodySmall,
1373+ color = MaterialTheme.colorScheme.onTertiaryContainer,
1374+ modifier = Modifier.padding(start = 8.dp),
1375+ )
1376+ }
1377+ TextButton(onClick = onUndo) { Text(stringResource(R.string.undo)) }
1378+ }
1379+ }
1380+}
1381+
1382+/** A banner shown while messages composed offline are queued in the outbox, with a
1383+ * "Send now" nudge and a discard. Shows a spinner while the queue is flushing. */
1384+@Composable
1385+private fun OutboxBanner(
1386+ count: Int,
1387+ sending: Boolean,
1388+ onFlush: () -> Unit,
1389+ onDiscard: () -> Unit,
1390+) {
1391+ Surface(
1392+ color = MaterialTheme.colorScheme.secondaryContainer,
1393+ tonalElevation = 2.dp,
1394+ modifier = Modifier.fillMaxWidth(),
1395+ ) {
1396+ Row(
1397+ modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 4.dp, top = 4.dp, bottom = 4.dp),
1398+ verticalAlignment = Alignment.CenterVertically,
1399+ horizontalArrangement = Arrangement.SpaceBetween,
1400+ ) {
1401+ Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f)) {
1402+ if (sending) {
1403+ CircularProgressIndicator(
1404+ Modifier.size(16.dp),
1405+ strokeWidth = 2.dp,
1406+ color = MaterialTheme.colorScheme.onSecondaryContainer,
1407+ )
1408+ Spacer(Modifier.size(8.dp))
1409+ }
1410+ Text(
1411+ if (sending) {
1412+ stringResource(R.string.outbox_sending)
1413+ } else {
1414+ val res = if (count == 1) R.string.outbox_pending_one else R.string.outbox_pending_many
1415+ stringResource(res, count)
1416+ },
1417+ style = MaterialTheme.typography.bodySmall,
1418+ color = MaterialTheme.colorScheme.onSecondaryContainer,
1419+ )
1420+ }
1421+ if (!sending) {
1422+ TextButton(onClick = onFlush) { Text(stringResource(R.string.outbox_flush)) }
1423+ TextButton(onClick = onDiscard) {
1424+ Text(stringResource(R.string.outbox_discard_all), color = MaterialTheme.colorScheme.error)
1425+ }
1426+ }
1427+ }
1428+ }
1429+}
1430+
1431+/** Prompt for a one-off shell command to run in the session's worktree. */
1432+@Composable
1433+private fun ShellCommandDialog(onDismiss: () -> Unit, onRun: (String) -> Unit) {
1434+ var command by rememberSaveable { mutableStateOf("") }
1435+ AlertDialog(
1436+ onDismissRequest = onDismiss,
1437+ title = { Text(stringResource(R.string.run_shell_command)) },
1438+ text = {
1439+ OutlinedTextField(
1440+ value = command,
1441+ onValueChange = { command = it },
1442+ modifier = Modifier.fillMaxWidth(),
1443+ placeholder = { Text(stringResource(R.string.shell_command_hint)) },
1444+ label = { Text(stringResource(R.string.shell_command_label)) },
1445+ singleLine = true,
1446+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
1447+ keyboardActions = KeyboardActions(onDone = { if (command.isNotBlank()) onRun(command.trim()) }),
1448+ )
1449+ },
1450+ confirmButton = {
1451+ TextButton(
1452+ onClick = { if (command.isNotBlank()) onRun(command.trim()) },
1453+ enabled = command.isNotBlank(),
1454+ ) { Text(stringResource(R.string.run)) }
1455+ },
1456+ dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
1457+ )
1458+}
1459+
1460+/**
1461+ * Change signal for the auto-scroll-on-stream watcher. Primitive fields avoid the
1462+ * boxing `Triple<Int, Int, Boolean>` would impose on every snapshot evaluation, and
1463+ * equality is structural so `snapshotFlow` emits only when something relevant changes.
1464+ */
1465+private data class AutoScrollSignal(val size: Int, val lastTextLength: Int, val pinned: Boolean)
1466+
1467+/** Length of the streaming content carried by [part], driving auto-scroll as it grows.
1468+ * Covers text, reasoning, and tool output so a pinned view keeps following non-text
1469+ * streaming, not just plain assistant text. O(1). */
1470+private fun streamingContentLength(part: Part?): Int = when (part) {
1471+ is TextPart -> part.text.length
1472+ is ReasoningPart -> part.text.length
1473+ is ToolPart -> when (val st = part.state) {
1474+ is ToolCompleted -> st.output?.length ?: 0
1475+ is ToolError -> st.error?.length ?: 0
1476+ is ToolRunning -> st.title?.length ?: 0
1477+ else -> 0
1478+ }
1479+ else -> 0
1480+}
1481+
1482+/** Rename dialog for the currently-open session. Mirrors the SessionListScreen rename
1483+ * dialog but lives in the chat screen so the user can rename without backing out to
1484+ * the list. Pre-filled with the current title; the cap matches the list dialog. */
1485+@Composable
1486+private fun RenameSessionChatDialog(
1487+ initialTitle: String,
1488+ onDismiss: () -> Unit,
1489+ onConfirm: (String) -> Unit,
1490+) {
1491+ // Seed the field once (no key input) so a later external title change — e.g. opencode
1492+ // auto-generating a session title after the first prompt, arriving via SessionUpdated —
1493+ // doesn't re-initialize the box and discard the user's in-progress edit.
1494+ var title by rememberSaveable { mutableStateOf(initialTitle) }
1495+ AlertDialog(
1496+ onDismissRequest = onDismiss,
1497+ title = { Text(stringResource(R.string.rename_session_chat)) },
1498+ text = {
1499+ OutlinedTextField(
1500+ value = title,
1501+ onValueChange = { v -> title = v.take(NetworkConfig.maxSessionTitleChars) },
1502+ modifier = Modifier.fillMaxWidth(),
1503+ placeholder = { Text(stringResource(R.string.session_title_hint)) },
1504+ label = { Text(stringResource(R.string.rename_session_chat)) },
1505+ singleLine = true,
1506+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
1507+ keyboardActions = KeyboardActions(onDone = { if (title.isNotBlank()) onConfirm(title.trim()) }),
1508+ )
1509+ },
1510+ confirmButton = {
1511+ TextButton(onClick = { if (title.isNotBlank()) onConfirm(title.trim()) }, enabled = title.isNotBlank()) {
1512+ Text(stringResource(R.string.save))
1513+ }
1514+ },
1515+ dismissButton = {
1516+ TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) }
1517+ },
1518+ )
1519+}
1520+
1521+/**
1522+ * A single item in the chat message list — either a [Message] bubble or a day
1523+ * [Separator] inserted before the first message of a new day. The separator shifts
1524+ * the LazyColumn indices vs. the raw messages list, so scroll-target math must use
1525+ * the [List] of these items (not `messages.size`/`lastIndex`).
1526+ */
1527+private sealed interface MessageListItem {
1528+ val key: Any
1529+ val contentType: Any
1530+
1531+ data class Message(val message: MessageWithParts, override val key: Any) : MessageListItem {
1532+ override val contentType: Any get() = message.info::class
1533+ }
1534+
1535+ data class Separator(val label: String, val ordinal: Int) : MessageListItem {
1536+ // Key on the day-occurrence ordinal, not the label: two non-contiguous groups
1537+ // can share a label (e.g. an untimestamped message — empty label — between two
1538+ // same-day messages, or two untimestamped groups), and a label-only key would
1539+ // then collide, which makes LazyColumn throw on duplicate keys. The ordinal is
1540+ // stable for a given message ordering so slots aren't needlessly recreated.
1541+ override val key: Any get() = "sep_${ordinal}_$label"
1542+ // Separators share a contentType so the LazyColumn can recycle their slots.
1543+ override val contentType: Any get() = "separator"
1544+ }
1545+}
1546+
1547+/**
1548+ * Build the interleaved list of [MessageListItem]s for the message list, inserting a
1549+ * [MessageListItem.Separator] before the first message of each new calendar day. Uses
1550+ * the message's `time.created` (falling back to `updated` then `completed`) to bucket.
1551+ * Messages with no timestamp are grouped under an empty-label separator only if they
1552+ * start the list, so a server that omits timestamps doesn't suppress the first divider.
1553+ *
1554+ * [todayLabel]/[yesterdayLabel] are resolved by the caller (a @Composable can't call
1555+ * stringResource from inside this plain function) and used for the "Today"/"Yesterday"
1556+ * labels; older days fall back to a locale-stable medium-date format.
1557+ */
1558+private fun buildMessageListItems(
1559+ messages: List<MessageWithParts>,
1560+ todayLabel: String,
1561+ yesterdayLabel: String,
1562+): List<MessageListItem> {
1563+ if (messages.isEmpty()) return emptyList()
1564+ val today = java.time.LocalDate.now(java.time.ZoneId.systemDefault())
1565+ val dateFmt = mediumDateFormat()
1566+ val result = ArrayList<MessageListItem>(messages.size + 4)
1567+ val seenKeys = HashSet<String>(messages.size * 2)
1568+ var lastDayKey: String? = null
1569+ var sepOrdinal = 0
1570+ var emptyIdOrdinal = 0
1571+ for (message in messages) {
1572+ val ts = message.info.time?.created ?: message.info.time?.updated ?: message.info.time?.completed
1573+ val dayKey = ts?.let { dayKey(it) } ?: ""
1574+ if (dayKey != lastDayKey) {
1575+ result.add(MessageListItem.Separator(dayLabel(dayKey, ts ?: 0L, today, todayLabel, yesterdayLabel, dateFmt), sepOrdinal++))
1576+ lastDayKey = dayKey
1577+ }
1578+ // Most messages have a unique non-empty id. An unrecognized-role message can carry an
1579+ // empty id — and the reducer intentionally keeps several such messages as distinct
1580+ // holders — so give each a stable synthetic key and defensively de-collide any remaining
1581+ // duplicate; otherwise LazyColumn throws on a duplicate key and the whole screen crashes.
1582+ var key = message.info.id.ifEmpty { "msg-empty-${emptyIdOrdinal++}" }
1583+ if (!seenKeys.add(key)) {
1584+ var suffix = 1
1585+ while (!seenKeys.add("$key#$suffix")) suffix++
1586+ key = "$key#$suffix"
1587+ }
1588+ result.add(MessageListItem.Message(message, key))
1589+ }
1590+ return result
1591+}
1592+
1593+/**
1594+ * A stable bucket key for a timestamp's calendar day (epoch-days as a string).
1595+ *
1596+ * [buildMessageListItems] re-runs on every streaming snapshot (the memo is keyed on the
1597+ * whole `messages` list, which must stay so the live message's fresh parts render), so a
1598+ * naive implementation would re-allocate an `Instant`/`ZonedDateTime`/`LocalDate` for
1599+ * every message on every token. Message timestamps are immutable once assigned, so we
1600+ * memoize the parse per epoch-millis: after the first snapshot, each unchanged message is
1601+ * a cheap hash lookup. Bounded LRU (access-order) caps memory; only touched on the main
1602+ * thread during composition, so the coarse lock is uncontended.
1603+ */
1604+private val dayKeyCache = object : LinkedHashMap<Long, String>(128, 0.75f, true) {
1605+ override fun removeEldestEntry(eldest: MutableMap.MutableEntry<Long, String>): Boolean = size > 1024
1606+}
1607+
1608+private fun dayKey(epochMillis: Long): String = synchronized(dayKeyCache) {
1609+ dayKeyCache.getOrPut(epochMillis) {
1610+ java.time.Instant.ofEpochMilli(epochMillis)
1611+ .atZone(java.time.ZoneId.systemDefault()).toLocalDate().toEpochDay().toString()
1612+ }
1613+}
1614+
1615+// The medium-date formatter is an ICU locale lookup; cache one instance instead of
1616+// allocating a fresh one per buildMessageListItems call, rebuilding only if the default
1617+// locale changes at runtime. Main-thread-only, so no synchronization is needed.
1618+private var cachedDateFmt: java.text.DateFormat? = null
1619+private var cachedDateFmtLocale: java.util.Locale? = null
1620+
1621+private fun mediumDateFormat(): java.text.DateFormat {
1622+ val locale = java.util.Locale.getDefault()
1623+ val existing = cachedDateFmt
1624+ if (existing != null && cachedDateFmtLocale == locale) return existing
1625+ return java.text.DateFormat.getDateInstance(java.text.DateFormat.MEDIUM).also {
1626+ cachedDateFmt = it
1627+ cachedDateFmtLocale = locale
1628+ }
1629+}
1630+
1631+/**
1632+ * Resolve a day-key back to a human-readable label. "Today" / "Yesterday" for the
1633+ * recent days, otherwise a locale-stable medium-date format. Returns an empty string
1634+ * when [dayKey] is empty (no timestamp) so no separator is rendered for un-timestamped
1635+ * messages.
1636+ */
1637+private fun dayLabel(
1638+ dayKey: String,
1639+ ts: Long,
1640+ today: java.time.LocalDate,
1641+ todayLabel: String,
1642+ yesterdayLabel: String,
1643+ dateFmt: java.text.DateFormat,
1644+): String {
1645+ if (dayKey.isEmpty()) return ""
1646+ val epochDay = dayKey.toLongOrNull() ?: return ""
1647+ val date = java.time.LocalDate.ofEpochDay(epochDay)
1648+ return when {
1649+ date == today -> todayLabel
1650+ date == today.minusDays(1) -> yesterdayLabel
1651+ else -> dateFmt.format(java.util.Date(ts))
1652+ }
1653+}
1654+
1655+/**
1656+ * A centered day divider in the message list. Renders nothing for an empty label
1657+ * (the no-timestamp case) so un-timestamped messages don't get a stray blank divider.
1658+ */
1659+@Composable
1660+private fun DateSeparator(label: String) {
1661+ if (label.isBlank()) return
1662+ Row(
1663+ modifier = Modifier.fillMaxWidth(),
1664+ horizontalArrangement = Arrangement.Center,
1665+ ) {
1666+ Surface(
1667+ color = MaterialTheme.colorScheme.surfaceVariant,
1668+ shape = MaterialTheme.shapes.small,
1669+ ) {
1670+ Text(
1671+ label,
1672+ style = MaterialTheme.typography.labelSmall,
1673+ color = MaterialTheme.colorScheme.onSurfaceVariant,
1674+ modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
1675+ )
1676+ }
1677+ }
1678+}
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.ktnew file+1326
@@ -0,0 +1,1326 @@
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.Permission
11+import soy.iko.opencode.data.model.PermissionReplied
12+import soy.iko.opencode.data.model.PermissionResponse
13+import soy.iko.opencode.data.model.PermissionUpdated
14+import soy.iko.opencode.data.model.SessionDeleted
15+import soy.iko.opencode.data.model.SessionUpdated
16+import soy.iko.opencode.data.model.TextPart
17+import soy.iko.opencode.data.model.TodoItem
18+import soy.iko.opencode.data.model.currentTodoPlan
19+import soy.iko.opencode.data.model.defaultOption
20+import soy.iko.opencode.data.model.toOptions
21+import soy.iko.opencode.data.network.EventStreamClient
22+import soy.iko.opencode.data.network.NetworkConfig
23+import soy.iko.opencode.data.repo.OutboxMessage
24+import soy.iko.opencode.data.repo.SessionRepository
25+import soy.iko.opencode.di.AppContainer
26+import soy.iko.opencode.R
27+import soy.iko.opencode.util.runCatchingCancellable
28+import soy.iko.opencode.util.safeExceptionSummary
29+import android.util.Log
30+import kotlinx.coroutines.ExperimentalCoroutinesApi
31+import kotlinx.coroutines.channels.BufferOverflow
32+import kotlinx.coroutines.channels.Channel
33+import kotlinx.coroutines.flow.Flow
34+import kotlinx.coroutines.flow.MutableStateFlow
35+import kotlinx.coroutines.flow.SharingStarted
36+import kotlinx.coroutines.flow.StateFlow
37+import kotlinx.coroutines.flow.asStateFlow
38+import kotlinx.coroutines.flow.receiveAsFlow
39+import kotlinx.coroutines.CancellationException
40+import kotlinx.coroutines.flow.collectLatest
41+import kotlinx.coroutines.flow.debounce
42+import kotlinx.coroutines.flow.distinctUntilChanged
43+import kotlinx.coroutines.flow.drop
44+import kotlinx.coroutines.flow.first
45+import kotlinx.coroutines.flow.filterIsInstance
46+import kotlinx.coroutines.flow.flatMapLatest
47+import kotlinx.coroutines.flow.flowOf
48+import kotlinx.coroutines.flow.flowOn
49+import kotlinx.coroutines.flow.map
50+import kotlinx.coroutines.flow.merge
51+import kotlinx.coroutines.flow.onEach
52+import kotlinx.coroutines.flow.retryWhen
53+import kotlinx.coroutines.flow.stateIn
54+import kotlinx.coroutines.flow.update
55+import kotlinx.coroutines.Dispatchers
56+import kotlinx.coroutines.Job
57+import kotlinx.coroutines.delay
58+import kotlinx.coroutines.isActive
59+import kotlinx.coroutines.currentCoroutineContext
60+import kotlinx.coroutines.launch
61+
62+/**
63+ * A user-facing error to surface as a snackbar. [retryable] is true only for a failed
64+ * message *send* (whose draft is held in [ChatViewModel.failedDraft]); the snackbar attaches
65+ * a Retry action solely for those so an unrelated error (e.g. a message-load failure) can't
66+ * inherit a Retry that silently re-submits the last prompt.
67+ */
68+data class ChatError(val message: String, val retryable: Boolean = false)
69+
70+/**
71+ * An attachment staged to send with the next prompt. [previewModel] is a Coil-loadable model
72+ * for the thumbnail (the source content Uri, as a string) for images, or null for non-image
73+ * files (rendered with a generic icon). [part] is the wire form (a base64 data URL) sent to
74+ * the server.
75+ */
76+@androidx.compose.runtime.Immutable
77+data class PendingAttachment(
78+ val id: String,
79+ val name: String,
80+ val mime: String,
81+ val previewModel: Any?,
82+ val part: FilePromptPart,
83+) {
84+ val isImage: Boolean get() = mime.startsWith("image/")
85+}
86+
87+/** Reduce a staged attachment to its persistable form (the self-contained data URL). */
88+private fun PendingAttachment.toPersisted() =
89+ soy.iko.opencode.data.repo.PersistedAttachment(id, name, mime, part.url, part.filename)
90+
91+/** Rebuild a staged attachment from persistence. The base64 data URL doubles as the Coil
92+ * preview model for images (the original content Uri didn't survive the process restart). */
93+private fun soy.iko.opencode.data.repo.PersistedAttachment.toPending() = PendingAttachment(
94+ id = id,
95+ name = name,
96+ mime = mime,
97+ previewModel = if (mime.startsWith("image/")) url else null,
98+ part = FilePromptPart(mime = mime, url = url, filename = filename),
99+)
100+
101+// Large by design: this VM owns the whole chat surface (streaming, drafts, attachments,
102+// catalogs, permissions, revert/edit, sharing, summarize/init/shell, TTS wiring). It's
103+// already grandfathered for TooManyFunctions in the detekt baseline; the class-size rule is
104+// suppressed for the same reason. A future split into sub-controllers is tracked separately.
105+@Suppress("LargeClass")
106+@OptIn(ExperimentalCoroutinesApi::class, kotlinx.coroutines.FlowPreview::class)
107+class ChatViewModel(
108+ private val container: AppContainer,
109+ private val sessionId: String,
110+) : ViewModel() {
111+
112+ private val connection get() = container.activeConnection.value
113+
114+ val connected: Boolean get() = connection != null
115+
116+ private val _loading = MutableStateFlow(true)
117+ val loading: StateFlow<Boolean> = _loading.asStateFlow()
118+
119+ /** Set when the messages flow failed to load (and the list is empty), so the UI
120+ * can render a distinct error state instead of masquerading as an empty
121+ * conversation. Cleared on a successful non-empty emission. */
122+ private val _loadError = MutableStateFlow(false)
123+ val loadError: StateFlow<Boolean> = _loadError.asStateFlow()
124+
125+ /** Tracks whether the messages flow has ever emitted a non-empty list, so
126+ * [messages]' retryWhen can decide whether a re-fetch failure should set
127+ * [loadError] (nothing shown yet → error screen) or just emit a snackbar
128+ * (already showing messages → don't replace the conversation with an error). */
129+ private var hasShownMessages = false
130+
131+ /** True once we've surfaced a snackbar for the current message-load failure streak.
132+ * Reset on any successful emission so a persistent failure doesn't spam a fresh
133+ * snackbar every retry cycle (every few seconds). */
134+ private var loadErrorSnackbarShown = false
135+
136+ /** Separate from [loading]: tracks the manual reconnect() flow so its spinner
137+ * doesn't conflict with the messages flow's loading state. */
138+ private val _reconnecting = MutableStateFlow(false)
139+ val reconnecting: StateFlow<Boolean> = _reconnecting.asStateFlow()
140+
141+ /** When true, the debounced draft collector skips persisting an empty draft so
142+ * [send]'s deliberate non-persistence of the cleared draft isn't undone by the
143+ * debounce timer firing before the send completes. */
144+ private val suppressDraftPersist = java.util.concurrent.atomic.AtomicBoolean(false)
145+
146+ /** The draft value this VM last persisted, so the drafts observer can tell its own
147+ * (debounced, possibly stale) echo apart from a genuine external write. Without this
148+ * a debounced persist of an older value could echo back and overwrite newer text the
149+ * user has since typed. */
150+ @Volatile private var lastPersistedDraft: String? = null
151+
152+ /** Per-catalog reload triggers: incrementing one causes [observeCatalog]'s
153+ * collectLatest to cancel any in-flight fetch and start a fresh one, so a
154+ * manual reload supersedes a stale observeCatalog fetch. */
155+ private val _modelsReload = MutableStateFlow(0)
156+ private val _agentsReload = MutableStateFlow(0)
157+ private val _commandsReload = MutableStateFlow(0)
158+
159+ val messages: StateFlow<List<MessageWithParts>> =
160+ container.activeConnection
161+ .flatMapLatest { conn ->
162+ conn?.repository?.observeMessages(sessionId) ?: flowOf(emptyList())
163+ }
164+ .onEach {
165+ _loading.value = false
166+ // A value flowed through: the stream recovered, so allow the next distinct
167+ // failure to surface a fresh snackbar again.
168+ loadErrorSnackbarShown = false
169+ // Clear the error on ANY successful emission, including an empty list. An empty
170+ // session that hit one transient error would otherwise stay latched on the
171+ // "Failed to load / Retry" screen forever, because the later successful EMPTY
172+ // emission couldn't clear the flag. hasShownMessages stays gated on non-empty
173+ // (an empty session has genuinely shown nothing yet).
174+ _loadError.value = false
175+ if (it.isNotEmpty()) {
176+ hasShownMessages = true
177+ }
178+ }
179+ .retryWhen { cause, attempt ->
180+ _loading.value = false
181+ // Only surface the persistent error state when there's nothing to
182+ // show — if we already have messages, a transient re-fetch failure
183+ // is better surfaced as a snackbar (via errorEvents) than by
184+ // replacing the visible conversation with an error screen.
185+ if (!hasShownMessages) _loadError.value = true
186+ // Surface the snackbar only once per failure streak — retryWhen loops on a
187+ // backoff, so emitting here unconditionally would spam a new snackbar every
188+ // retry on a persistent load failure.
189+ if (!loadErrorSnackbarShown) {
190+ _errorEvents.trySend(ChatError(container.friendlyError(cause)))
191+ loadErrorSnackbarShown = true
192+ }
193+ // Exponential backoff capped at retryMaxDelayMs so a persistently failing
194+ // server is retried with growing delays (retryInitialDelayMs, then doubling)
195+ // instead of hammered at a fixed interval forever. Clamp the shift so a long
196+ // failure streak can't overflow the Long delay.
197+ val backoffMs = (NetworkConfig.retryInitialDelayMs shl attempt.coerceAtMost(16L).toInt())
198+ .coerceAtMost(NetworkConfig.retryMaxDelayMs)
199+ delay(backoffMs)
200+ true
201+ }
202+ .stateIn(
203+ scope = viewModelScope,
204+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
205+ initialValue = emptyList(),
206+ )
207+
208+ /** Whether the conversation has any messages. Derived separately so the top bar
209+ * (share button enabled state) can observe this cheap boolean instead of the
210+ * full messages list, avoiding per-token recomposition of the app bar during
211+ * streaming. */
212+ val hasMessages: StateFlow<Boolean> = messages
213+ .map { it.isNotEmpty() }
214+ .stateIn(
215+ scope = viewModelScope,
216+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
217+ initialValue = false,
218+ )
219+
220+ /** The agent's current task plan (the latest `todowrite`), surfaced so the chat can pin a
221+ * live progress checklist above the composer. distinctUntilChanged so a per-token messages
222+ * emission that doesn't change the plan doesn't recompose the bar. */
223+ val todoPlan: StateFlow<List<TodoItem>> = messages
224+ .map { currentTodoPlan(it) }
225+ // Run currentTodoPlan off the main thread: it's an O(messages×parts) scan (worst case
226+ // when the conversation has no todowrite) and runs on every conflated messages emission.
227+ // distinctUntilChanged is downstream so it dedupes the result but doesn't prevent the
228+ // scan — without flowOn this scans on Main.immediate per streamed token → jank.
229+ .flowOn(Dispatchers.Default)
230+ .distinctUntilChanged()
231+ .stateIn(
232+ scope = viewModelScope,
233+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
234+ initialValue = emptyList(),
235+ )
236+
237+ private val _running = MutableStateFlow(false)
238+ val running: StateFlow<Boolean> = _running.asStateFlow()
239+
240+ /** True once the current run ended via SessionIdle/SessionError, until the next run starts
241+ * (a send) or an SSE reconnect. Gates the run-indicator re-light below: isRunActivity
242+ * returns true for ANY message.part.updated, so without this a trailing/replayed part
243+ * arriving after a legitimate SessionIdle would re-light the "working" indicator with no
244+ * further idle to clear it — a stuck spinner + foreground service. Cleared on reconnect so
245+ * genuine reconnect-recovery re-lighting (its actual purpose) still works. */
246+ @Volatile private var runEndedByIdle = false
247+
248+ /** The in-flight REST title resolution for the current connection. Launched from — and
249+ * cancelled by — the per-connection reset block so it can't observe or restore a previous
250+ * server's stale title. */
251+ private var sessionTitleJob: Job? = null
252+
253+ /** True while an abort REST call is in flight, so the Stop button can show a
254+ * spinner and prevent double-taps from firing a second abort. */
255+ private val _aborting = MutableStateFlow(false)
256+ val aborting: StateFlow<Boolean> = _aborting.asStateFlow()
257+
258+ /** A follow-up the user typed while a run was active. send() queues it here and
259+ * auto-sends once the run completes (SessionIdle), so the user isn't blocked with
260+ * no way to send and no indication why the Send button is gone. */
261+ private val _queuedFollowUp = MutableStateFlow<String?>(null)
262+ val queuedFollowUp: StateFlow<String?> = _queuedFollowUp.asStateFlow()
263+
264+ /** One-shot error events surfaced as snackbars. A Channel (not SharedFlow) so an event
265+ * emitted before the UI subscribes is buffered and still delivered — a SharedFlow with
266+ * replay=0 would drop it (e.g. a VM-init catalog fetch failing before first
267+ * composition). Each event is delivered exactly once and not replayed to a
268+ * re-subscribing collector, so rotation doesn't re-show a stale snackbar. */
269+ private val _errorEvents = Channel<ChatError>(
270+ capacity = NetworkConfig.snackbarEventBufferCapacity,
271+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
272+ )
273+ val errorEvents: Flow<ChatError> = _errorEvents.receiveAsFlow()
274+
275+ private val _models = MutableStateFlow<List<ModelOption>>(emptyList())
276+ val models: StateFlow<List<ModelOption>> = _models.asStateFlow()
277+
278+ private val _modelsLoading = MutableStateFlow(true)
279+ val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
280+
281+ private val _modelsError = MutableStateFlow(false)
282+ val modelsError: StateFlow<Boolean> = _modelsError.asStateFlow()
283+
284+ private val _selectedModel = MutableStateFlow<ModelOption?>(null)
285+ val selectedModel: StateFlow<ModelOption?> = _selectedModel.asStateFlow()
286+
287+ val connectionState: StateFlow<EventStreamClient.ConnectionState> =
288+ container.activeConnection
289+ .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
290+ .stateIn(
291+ scope = viewModelScope,
292+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
293+ initialValue = EventStreamClient.ConnectionState.Disconnected,
294+ )
295+
296+ private val _pendingPermission = MutableStateFlow<Permission?>(null)
297+ val pendingPermission: StateFlow<Permission?> = _pendingPermission.asStateFlow()
298+
299+ // Unanswered permission requests keyed by id, insertion-ordered. The dialog shows the most
300+ // recent; when one is answered/replied — or a response fails — we fall back to the next so a
301+ // request that arrived while another was in flight isn't silently dropped (its tool run would
302+ // otherwise stay paused server-side with no UI affordance). Only touched on the VM's main
303+ // dispatcher (event collector + viewModelScope launches), so no extra synchronization is needed.
304+ private val pendingPermissions = LinkedHashMap<String, Permission>()
305+
306+ private fun enqueuePermission(permission: Permission) {
307+ pendingPermissions[permission.id] = permission
308+ _pendingPermission.value = pendingPermissions.values.lastOrNull()
309+ }
310+
311+ private fun resolvePermission(id: String) {
312+ pendingPermissions.remove(id)
313+ _pendingPermission.value = pendingPermissions.values.lastOrNull()
314+ }
315+
316+ private fun clearPermissions() {
317+ pendingPermissions.clear()
318+ _pendingPermission.value = null
319+ }
320+
321+ private val _agents = MutableStateFlow<List<Agent>>(emptyList())
322+ val agents: StateFlow<List<Agent>> = _agents.asStateFlow()
323+
324+ private val _agentsLoading = MutableStateFlow(true)
325+ val agentsLoading: StateFlow<Boolean> = _agentsLoading.asStateFlow()
326+
327+ private val _agentsError = MutableStateFlow(false)
328+ val agentsError: StateFlow<Boolean> = _agentsError.asStateFlow()
329+
330+ private val _selectedAgent = MutableStateFlow<String?>(null)
331+ val selectedAgent: StateFlow<String?> = _selectedAgent.asStateFlow()
332+
333+ private val _commands = MutableStateFlow<List<Command>>(emptyList())
334+ val commands: StateFlow<List<Command>> = _commands.asStateFlow()
335+
336+ private val _commandsLoading = MutableStateFlow(true)
337+ val commandsLoading: StateFlow<Boolean> = _commandsLoading.asStateFlow()
338+
339+ private val _commandsError = MutableStateFlow(false)
340+ val commandsError: StateFlow<Boolean> = _commandsError.asStateFlow()
341+
342+ private val _sessionTitle = MutableStateFlow<String?>(null)
343+ val sessionTitle: StateFlow<String?> = _sessionTitle.asStateFlow()
344+
345+ /** Set when the current session is deleted via SSE, so the UI can navigate away. */
346+ private val _sessionDeleted = MutableStateFlow(false)
347+ val sessionDeleted: StateFlow<Boolean> = _sessionDeleted.asStateFlow()
348+
349+ /** The text of the last send that failed, surfaced so the UI can offer a retry. */
350+ private val _failedDraft = MutableStateFlow<String?>(null)
351+ val failedDraft: StateFlow<String?> = _failedDraft.asStateFlow()
352+
353+ /** The idempotency key of the last failed online send, stashed alongside [_failedDraft] so
354+ * [retryFailed] re-submits with the SAME key. A retry after a lost response is then
355+ * deduplicated server-side instead of starting a duplicate agent run. */
356+ @Volatile private var failedIdempotencyKey: String? = null
357+
358+ /** Per-session draft, persisted so it survives navigation/process death. */
359+ private val _draft = MutableStateFlow(container.draftStore.get(sessionId))
360+ val draft: StateFlow<String> = _draft.asStateFlow()
361+
362+ /** Messages composed for this session while offline/disconnected, queued in the outbox and
363+ * awaiting an automatic flush on reconnect. Surfaced so the composer can show a "queued"
364+ * chip with a discard/send-now affordance. */
365+ val outbox: StateFlow<List<OutboxMessage>> =
366+ container.outboxStore.messages
367+ .map { list -> list.filter { it.sessionId == sessionId } }
368+ .stateIn(
369+ scope = viewModelScope,
370+ started = SharingStarted.WhileSubscribed(NetworkConfig.stateFlowSubscriptionTimeoutMs),
371+ initialValue = emptyList(),
372+ )
373+
374+ /** Attachments staged for the next prompt (images/files). Persisted per-session (as their
375+ * self-contained base64 data URLs) via [AttachmentDraftStore] so an interrupted compose
376+ * survives process death — the source content Uris wouldn't survive, but the data URLs
377+ * do. Cleared on a successful send. */
378+ private val _attachments = MutableStateFlow<List<PendingAttachment>>(emptyList())
379+ val attachments: StateFlow<List<PendingAttachment>> = _attachments.asStateFlow()
380+
381+ /** True while a revert checkpoint is active for this session (messages after it are hidden
382+ * server-side). Drives the "reverted" banner with its Undo. */
383+ private val _reverted = MutableStateFlow(false)
384+ val reverted: StateFlow<Boolean> = _reverted.asStateFlow()
385+
386+ /** The active public share URL for this session, or null when not shared. */
387+ private val _shareUrl = MutableStateFlow<String?>(null)
388+ val shareUrl: StateFlow<String?> = _shareUrl.asStateFlow()
389+
390+ /** One-shot events carrying a freshly-created share URL so the UI can copy/share it. */
391+ private val _shareLinkEvents = Channel<String>(
392+ capacity = NetworkConfig.snackbarEventBufferCapacity,
393+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
394+ )
395+ val shareLinkEvents: Flow<String> = _shareLinkEvents.receiveAsFlow()
396+
397+ /** One-shot events carrying the id of a session freshly branched from a message, so the
398+ * UI can navigate to it. */
399+ private val _branchEvents = Channel<String>(
400+ capacity = NetworkConfig.snackbarEventBufferCapacity,
401+ onBufferOverflow = BufferOverflow.DROP_OLDEST,
402+ )
403+ val branchEvents: Flow<String> = _branchEvents.receiveAsFlow()
404+
405+ init {
406+ // DraftStore loads SharedPreferences asynchronously; on a cold start the
407+ // synchronous get() above returns "" until the background load completes.
408+ // Observe the ready signal and re-seed the draft so it appears in the UI.
409+ viewModelScope.launch {
410+ container.draftStore.ready.collect { ready ->
411+ if (!ready) return@collect
412+ if (_draft.value.isEmpty()) {
413+ _draft.value = container.draftStore.get(sessionId)
414+ }
415+ // Recover a follow-up queued before process death (its in-memory StateFlow
416+ // is gone). If the input is now free, drop it back there for review/send and
417+ // consume the stored copy; otherwise re-queue it so it auto-sends when the
418+ // current run next idles. Guarded on == null so it never clobbers a
419+ // follow-up the user queued after opening this session.
420+ val savedFollowUp = container.draftStore.getFollowUp(sessionId)
421+ if (savedFollowUp.isNotEmpty() && _queuedFollowUp.value == null) {
422+ if (_draft.value.isEmpty()) {
423+ _draft.value = savedFollowUp
424+ container.draftStore.flushFollowUp(sessionId, "")
425+ } else {
426+ _queuedFollowUp.value = savedFollowUp
427+ }
428+ }
429+ }
430+ }
431+ // Observe the drafts store for external mutations to this session's draft
432+ // (e.g. a share injected via draftStore.set in two-pane mode, or setImmediate
433+ // before navigation in single-pane mode). Without this, a share injected into
434+ // an already-open session never appears in the input field — the init-time
435+ // read and the ready-only re-seed miss it when the draft is non-empty.
436+ // The VM's own debounced persist writes the same value _draft already holds,
437+ // so those updates are no-ops here (storeValue == _draft.value). The
438+ // suppressDraftPersist guard prevents a stale store value from clobbering
439+ // the deliberate draft clear during a send (the store still holds the
440+ // pre-send draft until the send resolves).
441+ viewModelScope.launch {
442+ container.draftStore.drafts.collect { drafts ->
443+ // Ignore emissions until the initial disk load completes: the load itself
444+ // emits the persisted snapshot, and applying it here would clobber text the
445+ // user typed during the async load (the `ready` observer above seeds an
446+ // empty draft). After ready, emissions are external writes.
447+ if (!container.draftStore.ready.value) return@collect
448+ val storeValue = drafts[sessionId].orEmpty()
449+ // Ignore this VM's own (debounced, possibly stale) persistence echo — only a
450+ // genuine external write (e.g. a two-pane share injection) should overwrite
451+ // the live draft.
452+ if (storeValue == lastPersistedDraft) return@collect
453+ if (storeValue != _draft.value && !suppressDraftPersist.get()) {
454+ _draft.value = storeValue
455+ }
456+ }
457+ }
458+ }
459+
460+ init {
461+ // Restore attachments staged before process death. They were persisted as
462+ // self-contained data URLs, so they re-send and (for images) re-preview intact.
463+ // Guarded on empty so it never clobbers attachments the user staged after opening.
464+ viewModelScope.launch {
465+ val restored = runCatchingCancellable { container.attachmentDraftStore.load(sessionId) }
466+ .getOrDefault(emptyList())
467+ if (restored.isNotEmpty() && _attachments.value.isEmpty()) {
468+ _attachments.value = restored.map { it.toPending() }
469+ }
470+ }
471+ }
472+
473+ fun selectModel(option: ModelOption) { _selectedModel.value = option }
474+
475+ fun selectAgent(name: String?) { _selectedAgent.value = name }
476+
477+ fun reloadModels() {
478+ val conn = connection ?: return
479+ viewModelScope.launch {
480+ conn.api.invalidateProvidersCache()
481+ _modelsReload.value++
482+ }
483+ }
484+
485+ fun reloadAgents() {
486+ val conn = connection ?: return
487+ viewModelScope.launch {
488+ conn.api.invalidateAgentsCache()
489+ _agentsReload.value++
490+ }
491+ }
492+
493+ fun reloadCommands() {
494+ val conn = connection ?: return
495+ viewModelScope.launch {
496+ conn.api.invalidateCommandsCache()
497+ _commandsReload.value++
498+ }
499+ }
500+
501+ /**
502+ * Shared helper for init-block catalog observers: re-fetches whenever the active
503+ * connection or the catalog's reload trigger changes, invoking [onNull] to clear
504+ * state on null so stale data from the old server doesn't persist. The
505+ * [reloadTrigger] is merged with [activeConnection] so a manual reload (via
506+ * reloadModels/reloadAgents/reloadCommands) cancels any in-flight observe fetch
507+ * via collectLatest, preventing a stale observe result from overwriting a fresh
508+ * reload result. Failure is non-fatal — the error flag is surfaced to the UI.
509+ */
510+ private fun <T> observeCatalog(
511+ tag: String,
512+ loading: MutableStateFlow<Boolean>,
513+ error: MutableStateFlow<Boolean>,
514+ fetch: suspend (soy.iko.opencode.data.network.OpencodeApiClient) -> T,
515+ onSuccess: (T) -> Unit,
516+ onNull: () -> Unit,
517+ reloadTrigger: StateFlow<Int>,
518+ ) {
519+ viewModelScope.launch {
520+ merge(container.activeConnection, reloadTrigger).collectLatest { _ ->
521+ val conn = container.activeConnection.value
522+ if (conn == null) { onNull(); loading.value = false; error.value = false; return@collectLatest }
523+ loading.value = true
524+ error.value = false
525+ runCatchingCancellable { fetch(conn.api) }
526+ // fetch() does HTTP via withRetry, so the failure can be a
527+ // ClientRequestException whose message embeds the full request URL
528+ // (may contain auth/paths). Log only a scrubbed summary.
529+ .onFailure { Log.w("ChatViewModel", "Failed to load $tag: ${safeExceptionSummary(it)}"); error.value = true }
530+ .getOrNull()?.let(onSuccess)
531+ loading.value = false
532+ }
533+ }
534+ }
535+
536+ fun updateDraft(text: String) {
537+ _draft.value = text
538+ }
539+
540+ init {
541+ // Debounce draft persistence so we don't write to disk on every keystroke.
542+ // Skip persisting empty drafts that were set by send() (suppressed via
543+ // suppressDraftPersist) — send() clears the in-memory draft immediately for
544+ // UI feedback but deliberately doesn't persist the clear until the send
545+ // succeeds, so a failed send can restore the draft.
546+ viewModelScope.launch {
547+ _draft.drop(1).debounce(NetworkConfig.draftDebounceMs).collect { text ->
548+ if (text.isBlank() && suppressDraftPersist.get()) return@collect
549+ lastPersistedDraft = text
550+ runCatchingCancellable { container.draftStore.set(sessionId, text) }
551+ .onFailure { Log.w("ChatViewModel", "Failed to persist draft", it) }
552+ }
553+ }
554+ // Reset per-connection state when the active server changes so stale spinners,
555+ // permission dialogs, errors, and agent selections from the old server don't
556+ // persist into the new one.
557+ viewModelScope.launch {
558+ container.activeConnection.collectLatest { conn ->
559+ if (conn == null) {
560+ // The connection dropped (e.g. disconnect() mid-run). No SSE stream will
561+ // arrive to deliver SessionIdle/SessionError, so reset the run state here —
562+ // otherwise the working spinner sticks on, ChatScreen keeps keepScreenOn +
563+ // the RunForegroundService alive, and the Stop button no-ops (abort() early-
564+ // returns with no connection) until a later reconnect happens to clear it.
565+ _running.value = false
566+ _aborting.value = false
567+ clearPermissions()
568+ sessionTitleJob?.cancel()
569+ return@collectLatest
570+ }
571+ _running.value = false
572+ // Allow the re-light below to recover a still-running run after this (re)connect;
573+ // a run that actually finished during the outage emits no live parts, so this
574+ // won't spuriously re-light.
575+ runEndedByIdle = false
576+ clearPermissions()
577+ _failedDraft.value = null
578+ // NOTE: deliberately not clearing _queuedFollowUp here. It's session-scoped
579+ // user intent that is now persisted; wiping it on every (re)connect — which
580+ // this collector does, including transient SSE drops and the cold-start
581+ // restore path — would drop a legitimately queued/recovered follow-up.
582+ _selectedAgent.value = null
583+ _sessionTitle.value = null
584+ // Resolve the app-bar title via REST for THIS connection. Launched from the same
585+ // block that just nulled _sessionTitle (and cancelled the previous fetch) so it can
586+ // never observe or restore the previous server's stale title — the old separate
587+ // collector could, racing that reset. The assignment is guarded on a still-null
588+ // title so a fresher SSE SessionUpdated (from the event collector below) wins.
589+ sessionTitleJob?.cancel()
590+ sessionTitleJob = viewModelScope.launch {
591+ runCatchingCancellable { conn.repository.listSessions() }
592+ .getOrNull()
593+ ?.firstOrNull { it.id == sessionId }
594+ ?.let { session ->
595+ if (_sessionTitle.value == null) {
596+ _sessionTitle.value = session.displayTitle
597+ _reverted.value = session.isReverted
598+ _shareUrl.value = session.share?.url?.takeIf { it.isNotBlank() }
599+ }
600+ }
601+ }
602+ try {
603+ // Loop the inner collect so a transient non-cancellation exception (a
604+ // malformed SSE event decoding error, an unexpected channel close)
605+ // doesn't terminate the event collector for this connection. Without
606+ // the loop, the catch returns the collectLatest lambda normally, so
607+ // collectLatest considers this connection's block complete and never
608+ // re-runs it — the chat would go permanently silent (no streaming,
609+ // no permission prompts, no title updates) until a server switch.
610+ while (currentCoroutineContext().isActive) {
611+ try {
612+ conn.events.events.collect { event ->
613+ if (SessionRepository.isIdle(event, sessionId)) {
614+ _running.value = false
615+ runEndedByIdle = true
616+ // Auto-send a queued follow-up once the previous run finishes,
617+ // so the user's drafted-while-running message isn't lost.
618+ val queued = _queuedFollowUp.value
619+ if (queued != null) {
620+ setQueuedFollowUp(null)
621+ send(queued, includeAttachments = false)
622+ }
623+ }
624+ if (SessionRepository.isError(event, sessionId)) {
625+ _running.value = false
626+ runEndedByIdle = true
627+ // The run errored, so the queued follow-up won't auto-send.
628+ // Restore it to the input (if free) so the user's typed text
629+ // isn't silently lost; otherwise just clear the queue.
630+ val queued = _queuedFollowUp.value
631+ if (!queued.isNullOrEmpty() && _draft.value.isEmpty()) {
632+ _draft.value = queued
633+ }
634+ setQueuedFollowUp(null)
635+ _errorEvents.trySend(ChatError(container.string(R.string.error_agent_reported)))
636+ }
637+ // Restore the run indicator if a live streaming event arrives while it's
638+ // cleared — e.g. an SSE reconnect mid-run reset _running to false, but the
639+ // agent is still working. Don't revive it once the user has hit Stop.
640+ val relightRunIndicator = SessionRepository.isRunActivity(event, sessionId) &&
641+ !_aborting.value && !_running.value && !runEndedByIdle
642+ if (relightRunIndicator) {
643+ _running.value = true
644+ }
645+ when (event) {
646+ is PermissionUpdated ->
647+ if (event.properties.sessionID == sessionId) enqueuePermission(event.properties)
648+ is PermissionReplied ->
649+ if (event.properties.sessionID == sessionId) event.properties.permissionID?.let { resolvePermission(it) }
650+ is SessionUpdated ->
651+ if (event.properties.info.id == sessionId) {
652+ val info = event.properties.info
653+ _sessionTitle.value = info.displayTitle
654+ _reverted.value = info.isReverted
655+ _shareUrl.value = info.share?.url?.takeIf { it.isNotBlank() }
656+ }
657+ is SessionDeleted ->
658+ if (event.properties.info?.id == sessionId || event.properties.sessionID == sessionId) {
659+ _sessionDeleted.value = true
660+ }
661+ else -> {}
662+ }
663+ }
664+ } catch (e: CancellationException) {
665+ throw e
666+ } catch (e: Exception) {
667+ Log.w("ChatViewModel", "SSE event collector error, restarting", e)
668+ }
669+ }
670+ } catch (e: CancellationException) {
671+ throw e
672+ } catch (e: Exception) {
673+ Log.w("ChatViewModel", "SSE event collector loop error", e)
674+ }
675+ }
676+ }
677+ // Load the model catalog; preselect the server default. Failure is non-fatal —
678+ // sending with no model just uses the server's default agent/model.
679+ observeCatalog(
680+ tag = "model catalog",
681+ loading = _modelsLoading,
682+ error = _modelsError,
683+ fetch = { it.providers() },
684+ onSuccess = { resp ->
685+ val options = resp.toOptions()
686+ _models.value = options
687+ // Only (re)apply the server default when there's no valid current
688+ // selection. On re-fetch/reconnect/Retry the user's chosen model must
689+ // survive — keep it as long as it's still present in the refreshed list.
690+ val current = _selectedModel.value
691+ if (current == null || options.none { it.ref == current.ref }) {
692+ _selectedModel.value = resp.defaultOption(options)
693+ }
694+ },
695+ onNull = { _models.value = emptyList(); _selectedModel.value = null },
696+ reloadTrigger = _modelsReload,
697+ )
698+ // Load the agent catalog (non-fatal).
699+ observeCatalog(
700+ tag = "agent catalog",
701+ loading = _agentsLoading,
702+ error = _agentsError,
703+ fetch = { it.agents() },
704+ onSuccess = { _agents.value = it },
705+ onNull = { _agents.value = emptyList() },
706+ reloadTrigger = _agentsReload,
707+ )
708+ // Load the command catalog (non-fatal).
709+ observeCatalog(
710+ tag = "command catalog",
711+ loading = _commandsLoading,
712+ error = _commandsError,
713+ fetch = { it.commands() },
714+ onSuccess = { _commands.value = it },
715+ onNull = { _commands.value = emptyList() },
716+ reloadTrigger = _commandsReload,
717+ )
718+ // If the SSE stream drops mid-run, the run indicator would spin forever;
719+ // reset it so the UI doesn't look stuck while the banner shows "Reconnecting…".
720+ viewModelScope.launch {
721+ container.activeConnection.collectLatest { conn ->
722+ if (conn == null) return@collectLatest
723+ try {
724+ // Loop so a transient non-cancellation exception doesn't terminate the
725+ // state watcher for this connection. Without the loop, a single thrown
726+ // error returns the collectLatest lambda and the _running reset on
727+ // Disconnected/Failed never fires again for this connection — a dropped
728+ // stream leaves the working spinner (and the RunForegroundService) stuck.
729+ while (currentCoroutineContext().isActive) {
730+ try {
731+ conn.events.state.collect { state ->
732+ if ((state == EventStreamClient.ConnectionState.Disconnected ||
733+ state == EventStreamClient.ConnectionState.Failed) && _running.value) {
734+ _running.value = false
735+ }
736+ }
737+ } catch (e: CancellationException) {
738+ throw e
739+ } catch (e: Exception) {
740+ Log.w("ChatViewModel", "SSE state collector error, restarting", e)
741+ }
742+ }
743+ } catch (e: CancellationException) {
744+ throw e
745+ } catch (e: Exception) {
746+ Log.w("ChatViewModel", "SSE state collector loop error", e)
747+ }
748+ }
749+ }
750+ }
751+
752+ /** Sends [text] together with any staged attachments; returns true on success so the
753+ * caller can clear the draft only then. */
754+ fun send(text: String): Boolean = send(text, includeAttachments = true)
755+
756+ /**
757+ * Core send. [includeAttachments] is false for the internal auto-send paths (a queued
758+ * follow-up, a retry) so staged attachments aren't silently re-sent with unrelated text.
759+ */
760+ private fun send(text: String, includeAttachments: Boolean, idempotencyKey: String? = null): Boolean {
761+ val trimmed = text.trim()
762+ val attachments = if (includeAttachments) _attachments.value else emptyList()
763+ // An image-only prompt (attachments, no text) is valid; a blank prompt with nothing
764+ // attached is not.
765+ if (trimmed.isEmpty() && attachments.isEmpty()) return false
766+ // Offline, or no active connection: queue the prompt to the outbox (flushed on
767+ // reconnect) instead of failing the send. Device-offline is caught even when a stale
768+ // connection object lingers, so a phone that lost signal mid-conversation still
769+ // captures the message rather than bouncing it off a dead socket.
770+ val conn = connection
771+ if (conn == null || !container.isOnline.value) {
772+ return enqueueOffline(trimmed, attachments)
773+ }
774+ if (!_running.compareAndSet(false, true)) return false
775+ // A new run is starting: reopen the re-light gate so this run's streamed parts keep the
776+ // indicator lit (and a later SessionIdle re-closes it).
777+ runEndedByIdle = false
778+ _failedDraft.value = null
779+ // Stable idempotency key for THIS online attempt: a brand-new message gets a fresh key,
780+ // while retryFailed() re-submits with the key it stashed on the original failure — so a
781+ // retry after a lost response is deduplicated server-side (no duplicate agent run).
782+ val key = idempotencyKey ?: java.util.UUID.randomUUID().toString()
783+ // Clear the staged attachments optimistically so the composer empties immediately;
784+ // restore them if the send fails (mirrors the draft handling below).
785+ if (attachments.isNotEmpty()) _attachments.value = emptyList()
786+ // Clear the in-memory draft for the UI immediately, but don't persist the clear
787+ // yet — if the send fails and the process dies before we restore, the draft
788+ // would be lost forever. The persisted draft is cleared only on success.
789+ // suppressDraftPersist prevents the debounced collector from persisting the
790+ // empty draft before the send resolves.
791+ //
792+ // Only clear when the text being sent IS the current draft. send() is also
793+ // invoked for auto-sent queued follow-ups (SessionIdle) and retryFailed(), where
794+ // the user may have typed a NEW draft since — wiping it would lose that text.
795+ if (_draft.value.trim() == trimmed) {
796+ suppressDraftPersist.set(true)
797+ _draft.value = ""
798+ }
799+ viewModelScope.launch {
800+ runCatchingCancellable {
801+ conn.repository.sendPrompt(
802+ sessionId,
803+ trimmed,
804+ attachments = attachments.map { it.part },
805+ model = _selectedModel.value?.ref,
806+ agent = _selectedAgent.value,
807+ idempotencyKey = key,
808+ )
809+ }.onFailure {
810+ suppressDraftPersist.set(false)
811+ _failedDraft.value = trimmed
812+ failedIdempotencyKey = key
813+ // Only restore the draft if the user hasn't typed anything new since.
814+ if (_draft.value.isBlank()) updateDraft(trimmed)
815+ // Restore the staged attachments too, so a failed send doesn't lose them —
816+ // but only if the user hasn't staged new ones in the meantime.
817+ if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
818+ // Re-persist so the restored attachments survive process death after a failure.
819+ persistAttachments()
820+ // Retryable: this is the failed send whose prompt Retry re-submits.
821+ _errorEvents.trySend(ChatError(container.friendlyError(it), retryable = true))
822+ _running.value = false
823+ }.onSuccess {
824+ suppressDraftPersist.set(false)
825+ // Send succeeded — the attachments rode along, so clear their persisted copy
826+ // (or keep any the user staged after the optimistic clear).
827+ persistAttachments()
828+ // Send succeeded — persist the cleared draft, but only if the user hasn't
829+ // typed a new one while the send was in flight. Otherwise clearing the
830+ // store echoes back through the draft observer and wipes the in-progress
831+ // text (data loss). Mirrors the guard on the failure path above.
832+ if (_draft.value.isBlank()) {
833+ lastPersistedDraft = ""
834+ container.draftStore.set(sessionId, "")
835+ }
836+ // Don't reset _running here: the agent continues streaming via SSE.
837+ // _running is cleared on SessionIdle/SessionError (see event collector)
838+ // or when the SSE stream drops (see connection state watcher below).
839+ }
840+ }
841+ return true
842+ }
843+
844+ /**
845+ * Queue [trimmed] (+ [attachments]) to the offline outbox, clearing the composer
846+ * optimistically like a real send. AppContainer flushes the outbox automatically on
847+ * reconnect. The prompt is attributed to the active (or most-recent) server profile so a
848+ * reconnect to a different server doesn't misfire it. On any failure the draft/attachments
849+ * are restored so nothing is lost.
850+ */
851+ private fun enqueueOffline(trimmed: String, attachments: List<PendingAttachment>): Boolean {
852+ _failedDraft.value = null
853+ if (attachments.isNotEmpty()) _attachments.value = emptyList()
854+ val clearDraft = _draft.value.trim() == trimmed
855+ if (clearDraft) {
856+ suppressDraftPersist.set(true)
857+ _draft.value = ""
858+ }
859+ viewModelScope.launch {
860+ val profileId = connection?.profile?.id
861+ ?: runCatchingCancellable { container.profileStore.profiles.first().firstOrNull()?.id }.getOrNull()
862+ if (profileId == null) {
863+ // No server to attribute the message to — restore the composer and report it
864+ // rather than silently dropping the prompt.
865+ suppressDraftPersist.set(false)
866+ if (_draft.value.isBlank()) updateDraft(trimmed)
867+ if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
868+ persistAttachments()
869+ _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
870+ return@launch
871+ }
872+ runCatchingCancellable {
873+ container.outboxStore.enqueue(
874+ OutboxMessage(
875+ id = java.util.UUID.randomUUID().toString(),
876+ profileId = profileId,
877+ sessionId = sessionId,
878+ text = trimmed,
879+ attachments = attachments.map { it.toPersisted() },
880+ model = _selectedModel.value?.ref,
881+ agent = _selectedAgent.value,
882+ createdAt = System.currentTimeMillis(),
883+ ),
884+ )
885+ }.onSuccess {
886+ suppressDraftPersist.set(false)
887+ // The staged attachments now live in the outbox item; clear their draft copy.
888+ persistAttachments()
889+ if (_draft.value.isBlank()) {
890+ lastPersistedDraft = ""
891+ container.draftStore.set(sessionId, "")
892+ }
893+ // Nudge a flush in case we're actually online with a live connection.
894+ container.flushOutbox()
895+ _errorEvents.trySend(ChatError(container.string(R.string.outbox_queued)))
896+ }.onFailure {
897+ suppressDraftPersist.set(false)
898+ if (_draft.value.isBlank()) updateDraft(trimmed)
899+ if (attachments.isNotEmpty() && _attachments.value.isEmpty()) _attachments.value = attachments
900+ persistAttachments()
901+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
902+ }
903+ }
904+ return true
905+ }
906+
907+ /** Discard a single queued (offline) message. */
908+ fun discardQueued(id: String) {
909+ viewModelScope.launch { runCatchingCancellable { container.outboxStore.remove(id) } }
910+ }
911+
912+ /** Discard every queued (offline) message for this session. */
913+ fun discardAllQueued() {
914+ viewModelScope.launch { runCatchingCancellable { container.outboxStore.removeForSession(sessionId) } }
915+ }
916+
917+ /** Attempt to flush the outbox now (e.g. the user tapped "Send now"). */
918+ fun flushQueued() { container.flushOutbox() }
919+
920+ /** Re-send the last draft whose send failed, if any. */
921+ fun retryFailed() {
922+ val draft = _failedDraft.value ?: return
923+ // Reuse the failed send's idempotency key so a retry after a lost response is
924+ // deduplicated server-side rather than starting a duplicate run.
925+ val key = failedIdempotencyKey
926+ // Don't clear _failedDraft until send() accepts the text — if _running is
927+ // already true, send() returns false and the draft would be lost forever.
928+ // Attachments aren't re-sent on retry (they were optimistically restored to the
929+ // composer on the original failure, so they'll ride the next manual send).
930+ if (send(draft, includeAttachments = false, idempotencyKey = key)) {
931+ _failedDraft.value = null
932+ failedIdempotencyKey = null
933+ }
934+ }
935+
936+ /**
937+ * Queue [text] to be sent automatically when the current run finishes, or clear any
938+ * queued follow-up when [text] is blank. Used when the user taps Send while a run
939+ * is already active — the Send button is replaced by Stop during a run, but the
940+ * input field stays enabled, so a follow-up typed mid-run would otherwise have
941+ * nowhere to go.
942+ */
943+ fun queueFollowUp(text: String) {
944+ val trimmed = text.trim()
945+ setQueuedFollowUp(trimmed.takeIf { it.isNotEmpty() })
946+ // The text now lives in the queued chip, so clear the input field just as a
947+ // normal send would. No-op for the cancel case (blank text), which must leave
948+ // whatever the user has since typed untouched.
949+ if (trimmed.isNotEmpty()) _draft.value = ""
950+ }
951+
952+ /** Set or clear the queued follow-up and mirror it to persistence, so a follow-up
953+ * queued mid-run survives process death (recovered on the next open of this session).
954+ * Passing null / blank clears both the in-memory value and the persisted copy. */
955+ private fun setQueuedFollowUp(text: String?) {
956+ _queuedFollowUp.value = text
957+ container.draftStore.flushFollowUp(sessionId, text.orEmpty())
958+ }
959+
960+ /** Transient flag set by [refreshMessages] so the top-bar refresh icon can show
961+ * a brief spinner as immediate tap feedback. The SSE reconnect triggered by
962+ * refreshMessages may not visibly change [connectionState] (it's already
963+ * Connected), so without this the tap appears to do nothing. Clears after a
964+ * short delay or when the connection state next becomes Connected. */
965+ private val _refreshing = MutableStateFlow(false)
966+ val refreshing: StateFlow<Boolean> = _refreshing.asStateFlow()
967+
968+ /** Manually re-fetch messages by forcing the SSE stream to reconnect, which
969+ * triggers the repository's re-seed-from-REST logic. Gives the user a recovery
970+ * path when the SSE stream silently drops and the auto-reconnect re-seed is slow
971+ * or fails — a pull-to-refresh or top-bar refresh forces a fresh fetch under the
972+ * existing generation-guarded merge. Also clears [loadError] optimistically and
973+ * sets [refreshing] for immediate tap feedback. */
974+ fun refreshMessages() {
975+ val conn = connection ?: return
976+ _loadError.value = false
977+ _refreshing.value = true
978+ conn.events.triggerReconnect()
979+ viewModelScope.launch {
980+ delay(NetworkConfig.refreshFeedbackMs)
981+ _refreshing.value = false
982+ }
983+ }
984+
985+ /** Invoke a slash-command by name via the server's /command endpoint. */
986+ fun runCommand(command: Command) {
987+ val conn = connection ?: return
988+ // A run is already in flight — surface feedback instead of silently dropping the
989+ // command (the picker stays openable during a run and dismisses on select).
990+ if (!_running.compareAndSet(false, true)) {
991+ _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
992+ return
993+ }
994+ // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
995+ // reconnect keeps the working indicator lit instead of leaving it stuck off.
996+ runEndedByIdle = false
997+ viewModelScope.launch {
998+ runCatchingCancellable {
999+ conn.repository.runCommand(sessionId, command.name, agent = command.agent)
1000+ }.onFailure {
1001+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
1002+ _running.value = false
1003+ }
1004+ }
1005+ }
1006+
1007+ fun abort() {
1008+ val conn = connection ?: return
1009+ // Drop any queued follow-up so the SessionIdle that follows the abort doesn't
1010+ // auto-send it — the user tapped Stop to halt work, not to trigger the next turn.
1011+ setQueuedFollowUp(null)
1012+ if (!_aborting.compareAndSet(false, true)) return
1013+ viewModelScope.launch {
1014+ try {
1015+ runCatchingCancellable { conn.repository.abort(sessionId) }
1016+ .onSuccess { _running.value = false }
1017+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1018+ } finally {
1019+ _aborting.value = false
1020+ }
1021+ }
1022+ }
1023+
1024+ fun addAttachment(attachment: PendingAttachment) {
1025+ // Enforce the cumulative size cap across the whole staged set (the per-file size and the
1026+ // count caps are checked upstream). The base64 data URLs are held in memory and
1027+ // re-serialized on every add/remove and again when the request body is built, so an
1028+ // unbounded set OOMs. Reject rather than add, mirroring the other attachment limits'
1029+ // snackbar via errorEvents. Size is the raw (pre-base64) byte count, matching the
1030+ // per-file check, and includes attachments already staged (incl. restored ones).
1031+ val staged = _attachments.value.sumOf { base64DataUrlByteSize(it.part.url) }
1032+ if (staged + base64DataUrlByteSize(attachment.part.url) > NetworkConfig.maxTotalAttachmentBytes) {
1033+ _errorEvents.trySend(ChatError(container.string(R.string.attachment_limit)))
1034+ return
1035+ }
1036+ _attachments.update { it + attachment }
1037+ persistAttachments()
1038+ }
1039+
1040+ fun removeAttachment(id: String) {
1041+ _attachments.update { list -> list.filterNot { it.id == id } }
1042+ persistAttachments()
1043+ }
1044+
1045+ fun clearAttachments() {
1046+ _attachments.value = emptyList()
1047+ persistAttachments()
1048+ }
1049+
1050+ /** Persist the current staged attachments so an interrupted compose survives process
1051+ * death (mirrors draft persistence). Fire-and-forget on [viewModelScope]. */
1052+ private fun persistAttachments() {
1053+ val snapshot = _attachments.value
1054+ viewModelScope.launch {
1055+ runCatchingCancellable { container.attachmentDraftStore.save(sessionId, snapshot.map { it.toPersisted() }) }
1056+ .onFailure { Log.w("ChatViewModel", "Failed to persist attachments", it) }
1057+ }
1058+ }
1059+
1060+ /** Revert the conversation to just before [messageId], hiding everything after it. The
1061+ * authoritative state also arrives via SessionUpdated; we flag it here for immediate
1062+ * feedback. */
1063+ fun revertTo(messageId: String) {
1064+ val conn = connection ?: return
1065+ viewModelScope.launch {
1066+ runCatchingCancellable { conn.api.revert(sessionId, messageId) }
1067+ .onSuccess { _reverted.value = it.isReverted }
1068+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1069+ }
1070+ }
1071+
1072+ /** Load a previously-sent user prompt back into the composer to edit and re-send.
1073+ * Reverts the conversation to just before [messageId] (hiding it and everything after)
1074+ * and prefills the draft with [text]. The next Send continues from that checkpoint,
1075+ * effectively replacing the edited message; the reverted banner's Undo restores it.
1076+ * Overwrites the current draft — this is an explicit edit action on that message. */
1077+ fun editMessage(messageId: String, text: String) {
1078+ revertTo(messageId)
1079+ _draft.value = text.take(NetworkConfig.maxDraftLengthChars)
1080+ }
1081+
1082+ /** Prefill the composer with [text] as a Markdown blockquote so the user can respond to a
1083+ * specific message inline. Appends to any existing draft (with a blank line) rather than
1084+ * overwriting, so quoting doesn't discard text already being typed. */
1085+ fun quoteReply(text: String) {
1086+ val quoted = text.trim().lineSequence().joinToString("\n") { "> $it" }
1087+ if (quoted.isBlank()) return
1088+ val current = _draft.value
1089+ val combined = if (current.isBlank()) "$quoted\n\n" else "$current\n\n$quoted\n\n"
1090+ // Cap like the composer's typed/pasted/dictated input so quoting onto a near-full draft
1091+ // can't push it past the limit (negative remaining counter + an over-cap prompt sent).
1092+ _draft.value = combined.take(NetworkConfig.maxDraftLengthChars)
1093+ }
1094+
1095+ /** Fork the conversation by creating a brand-new session seeded with [text] as its first
1096+ * prompt (using the currently-selected model/agent). Emits the new session id via
1097+ * [branchEvents] so the UI can navigate to it. There's no server-side "branch" endpoint,
1098+ * so this starts a fresh session from the chosen prompt rather than copying history. */
1099+ fun branchFrom(text: String) {
1100+ val conn = connection ?: return
1101+ val prompt = text.trim()
1102+ if (prompt.isEmpty()) return
1103+ viewModelScope.launch {
1104+ runCatchingCancellable {
1105+ val session = conn.repository.createSession(title = null)
1106+ conn.repository.sendPrompt(
1107+ session.id,
1108+ prompt,
1109+ model = _selectedModel.value?.ref,
1110+ agent = _selectedAgent.value,
1111+ )
1112+ session.id
1113+ }
1114+ .onSuccess { newId -> _branchEvents.trySend(newId) }
1115+ .onFailure { _errorEvents.trySend(ChatError(container.string(R.string.branch_failed))) }
1116+ }
1117+ }
1118+
1119+ /** Undo the active revert checkpoint, restoring the hidden messages. */
1120+ fun unrevert() {
1121+ val conn = connection ?: return
1122+ viewModelScope.launch {
1123+ runCatchingCancellable { conn.api.unrevert(sessionId) }
1124+ .onSuccess { _reverted.value = it.isReverted }
1125+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1126+ }
1127+ }
1128+
1129+ /** Create (or fetch the existing) public share link. The URL is exposed via [shareUrl]
1130+ * and also emitted once via [shareLinkEvents] so the UI can copy it to the clipboard. */
1131+ fun shareSession() {
1132+ val conn = connection ?: return
1133+ viewModelScope.launch {
1134+ runCatchingCancellable { conn.api.shareSession(sessionId) }
1135+ .onSuccess { session ->
1136+ val url = session.share?.url?.takeIf { it.isNotBlank() }
1137+ _shareUrl.value = url
1138+ if (url != null) _shareLinkEvents.trySend(url)
1139+ }
1140+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1141+ }
1142+ }
1143+
1144+ /** Revoke the session's public share link. */
1145+ fun unshareSession() {
1146+ val conn = connection ?: return
1147+ viewModelScope.launch {
1148+ runCatchingCancellable { conn.api.unshareSession(sessionId) }
1149+ .onSuccess { _shareUrl.value = it.share?.url?.takeIf { it.isNotBlank() } }
1150+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1151+ }
1152+ }
1153+
1154+ /** Compact the conversation via the summarize endpoint; the summary streams back via SSE.
1155+ * Uses the currently-selected model (required by the endpoint). */
1156+ fun summarize() {
1157+ val conn = connection ?: return
1158+ val model = _selectedModel.value?.ref ?: run {
1159+ _errorEvents.trySend(ChatError(container.string(R.string.needs_model)))
1160+ return
1161+ }
1162+ if (!_running.compareAndSet(false, true)) {
1163+ _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1164+ return
1165+ }
1166+ // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1167+ // reconnect keeps the working indicator lit instead of leaving it stuck off.
1168+ runEndedByIdle = false
1169+ viewModelScope.launch {
1170+ runCatchingCancellable { conn.api.summarize(sessionId, model) }
1171+ .onFailure {
1172+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
1173+ _running.value = false
1174+ }
1175+ }
1176+ }
1177+
1178+ /** Analyze the project and (re)generate its AGENTS.md; the run streams back via SSE. */
1179+ fun initProject() {
1180+ val conn = connection ?: return
1181+ if (!_running.compareAndSet(false, true)) {
1182+ _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1183+ return
1184+ }
1185+ // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1186+ // reconnect keeps the working indicator lit instead of leaving it stuck off.
1187+ runEndedByIdle = false
1188+ viewModelScope.launch {
1189+ runCatchingCancellable { conn.api.initSession(sessionId) }
1190+ .onFailure {
1191+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
1192+ _running.value = false
1193+ }
1194+ }
1195+ }
1196+
1197+ /** Run a one-off shell [command] in the session's worktree; output streams back via SSE.
1198+ * The server requires an agent to scope the run — use the selected one, else the primary. */
1199+ fun runShell(command: String) {
1200+ val conn = connection ?: return
1201+ val cmd = command.trim()
1202+ if (cmd.isEmpty()) return
1203+ val agent = _selectedAgent.value
1204+ ?: _agents.value.firstOrNull { it.isPrimary }?.name
1205+ ?: _agents.value.firstOrNull()?.name
1206+ ?: DEFAULT_SHELL_AGENT
1207+ if (!_running.compareAndSet(false, true)) {
1208+ _errorEvents.trySend(ChatError(container.string(R.string.command_busy)))
1209+ return
1210+ }
1211+ // A new run is starting: reopen the re-light gate (as send() does) so a mid-run SSE
1212+ // reconnect keeps the working indicator lit instead of leaving it stuck off.
1213+ runEndedByIdle = false
1214+ viewModelScope.launch {
1215+ runCatchingCancellable { conn.api.shell(sessionId, cmd, agent, _selectedModel.value?.ref) }
1216+ .onFailure {
1217+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
1218+ _running.value = false
1219+ }
1220+ }
1221+ }
1222+
1223+ /** Rename the current session via PATCH /session/:id. On success updates [sessionTitle]
1224+ * so the top bar reflects the new name immediately. A failure surfaces as a snackbar;
1225+ * the caller keeps the dialog open so the user can retry without retyping. */
1226+ fun renameSession(newTitle: String) {
1227+ val conn = connection ?: return
1228+ val title = newTitle.trim()
1229+ if (title.isEmpty() || title == _sessionTitle.value) return
1230+ viewModelScope.launch {
1231+ runCatchingCancellable { conn.api.updateSession(sessionId, title) }
1232+ .onSuccess { _sessionTitle.value = it.displayTitle }
1233+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1234+ }
1235+ }
1236+
1237+ /** Delete the current session via DELETE /session/:id. Surfaces a toast (via the UI's
1238+ * sessionDeleted flow) and navigates away on success; a failure surfaces as a snackbar.
1239+ * Unlike [SessionListViewModel.deleteSession] there's no undo window — the user is
1240+ * already viewing the session, so a confirmation dialog guards the action instead. */
1241+ fun deleteSession() {
1242+ val conn = connection ?: return
1243+ viewModelScope.launch {
1244+ runCatchingCancellable { conn.repository.deleteSession(sessionId) }
1245+ .onSuccess {
1246+ container.draftStore.remove(sessionId)
1247+ container.attachmentDraftStore.remove(sessionId)
1248+ container.messageCacheStore.remove(sessionId)
1249+ container.outboxStore.removeForSession(sessionId)
1250+ _sessionDeleted.value = true
1251+ }
1252+ .onFailure { _errorEvents.trySend(ChatError(container.friendlyError(it))) }
1253+ }
1254+ }
1255+
1256+ /** Reconnect to the most recently used server profile (used when the connection is gone). */
1257+ fun reconnect() {
1258+ if (container.activeConnection.value != null) return
1259+ // Guard against concurrent reconnect calls: the user can tap the button
1260+ // multiple times while _reconnecting is true, which would launch parallel
1261+ // connect() coroutines that race to set the active connection, leaking the
1262+ // intermediate connections.
1263+ if (!_reconnecting.compareAndSet(false, true)) return
1264+ viewModelScope.launch {
1265+ try {
1266+ val recent = runCatchingCancellable { container.profileStore.profiles.first() }
1267+ .getOrDefault(emptyList())
1268+ .firstOrNull()
1269+ if (recent == null) {
1270+ _errorEvents.trySend(ChatError(container.string(R.string.no_servers_to_reconnect)))
1271+ return@launch
1272+ }
1273+ runCatchingCancellable {
1274+ val conn = container.connect(recent)
1275+ conn.api.ping()
1276+ }.onFailure {
1277+ container.disconnect()
1278+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
1279+ }
1280+ } finally {
1281+ _reconnecting.value = false
1282+ }
1283+ }
1284+ }
1285+
1286+ fun respondPermission(permission: Permission, response: PermissionResponse) {
1287+ val conn = connection ?: return
1288+ // Dismiss optimistically (revealing any queued request); permission.replied will confirm.
1289+ // If the call fails we re-surface this request so the user can retry instead of being stuck
1290+ // with a dismissed dialog and a tool run that's still paused server-side.
1291+ resolvePermission(permission.id)
1292+ viewModelScope.launch {
1293+ runCatchingCancellable { conn.api.respondPermission(sessionId, permission.id, response) }
1294+ .onFailure {
1295+ _errorEvents.trySend(ChatError(container.friendlyError(it)))
1296+ enqueuePermission(permission)
1297+ }
1298+ }
1299+ }
1300+
1301+ private companion object {
1302+ /** Fallback agent for the shell endpoint when none is selected and no agent catalog
1303+ * loaded. opencode's default primary agent is "build". */
1304+ const val DEFAULT_SHELL_AGENT = "build"
1305+ }
1306+
1307+ override fun onCleared() {
1308+ super.onCleared()
1309+ // Flush any pending debounced draft so it survives navigation. Uses an
1310+ // asynchronous apply() (not a synchronous commit) so the main thread isn't
1311+ // blocked on disk I/O — Android's SharedPreferences framework guarantees
1312+ // pending apply() writes are flushed before the process exits.
1313+ // Flush the pending draft — including an empty one. Persistence is otherwise debounced,
1314+ // so if the user cleared the input and navigated back within the debounce window, the
1315+ // debounce coroutine was cancelled with viewModelScope and the prefs still hold the
1316+ // previous non-empty draft. flushDraft() removes the key when the text is blank, so
1317+ // flushing commits the clear instead of resurrecting the deleted text.
1318+ //
1319+ // BUT respect the send-in-flight guard: send() optimistically clears _draft to "" WITHOUT
1320+ // persisting the clear so a failed send can restore the text. If the user taps Send then
1321+ // immediately navigates back (cancelling the in-flight send), flushing the blank _draft here
1322+ // would remove the persisted key and erase the pre-send draft with no recovery. Skip the
1323+ // flush while a send is in flight so the pre-send draft stays persisted (recoverable).
1324+ if (!suppressDraftPersist.get()) container.draftStore.flushDraft(sessionId, _draft.value)
1325+ }
1326+}
app/src/main/java/soy/iko/opencode/ui/chat/CommandPickerSheet.ktnew file+204
@@ -0,0 +1,204 @@
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.getValue
28+import androidx.compose.runtime.mutableStateOf
29+import androidx.compose.runtime.remember
30+import androidx.compose.runtime.saveable.rememberSaveable
31+import androidx.compose.runtime.setValue
32+import androidx.compose.ui.Alignment
33+import androidx.compose.ui.Modifier
34+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
35+import androidx.compose.ui.res.stringResource
36+import androidx.compose.ui.semantics.Role
37+import androidx.compose.ui.semantics.contentDescription
38+import androidx.compose.ui.semantics.semantics
39+import androidx.compose.ui.text.input.ImeAction
40+import androidx.compose.ui.text.style.TextOverflow
41+import androidx.compose.ui.unit.dp
42+import androidx.compose.foundation.layout.Box
43+import soy.iko.opencode.data.model.Command
44+import soy.iko.opencode.R
45+
46+/** Bottom sheet that lists slash-commands; selecting one invokes it. */
47+@OptIn(ExperimentalMaterial3Api::class)
48+@Composable
49+fun CommandPickerSheet(
50+ commands: List<Command>,
51+ loading: Boolean,
52+ error: Boolean,
53+ onSelect: (Command) -> Unit,
54+ onRetry: () -> Unit,
55+ onDismiss: () -> Unit,
56+) {
57+ // Persist only the command *name* (a savable String), not the Command itself: a
58+ // kotlinx @Serializable Command is neither Parcelable nor java.io.Serializable, so
59+ // routing it through rememberSaveable crashes on rotation while the confirm dialog
60+ // is open. Re-resolve the full Command from the current list for rendering.
61+ var pendingCommandName by rememberSaveable { mutableStateOf<String?>(null) }
62+ val pendingCommand = pendingCommandName?.let { name -> commands.firstOrNull { it.name == name } }
63+ ModalBottomSheet(onDismissRequest = onDismiss) {
64+ Text(
65+ stringResource(R.string.commands),
66+ style = MaterialTheme.typography.titleMedium,
67+ modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
68+ )
69+ if (loading) {
70+ val loadingLabel = stringResource(R.string.loading)
71+ Box(
72+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
73+ contentAlignment = Alignment.Center,
74+ ) {
75+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
76+ }
77+ } else if (error && commands.isEmpty()) {
78+ Column(
79+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
80+ horizontalAlignment = Alignment.CenterHorizontally,
81+ ) {
82+ Text(
83+ stringResource(R.string.load_failed),
84+ style = MaterialTheme.typography.bodyMedium,
85+ color = MaterialTheme.colorScheme.error,
86+ )
87+ TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
88+ }
89+ } else if (commands.isEmpty()) {
90+ Text(
91+ stringResource(R.string.no_commands),
92+ style = MaterialTheme.typography.bodyMedium,
93+ color = MaterialTheme.colorScheme.onSurfaceVariant,
94+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
95+ )
96+ } else {
97+ var query by rememberSaveable { mutableStateOf("") }
98+ val keyboardController = LocalSoftwareKeyboardController.current
99+ OutlinedTextField(
100+ value = query,
101+ onValueChange = { query = it },
102+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp),
103+ placeholder = { Text(stringResource(R.string.search_commands)) },
104+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
105+ trailingIcon = if (query.isNotEmpty()) {
106+ {
107+ IconButton(onClick = { query = "" }) {
108+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
109+ }
110+ }
111+ } else null,
112+ singleLine = true,
113+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
114+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
115+ )
116+ val filtered = remember(commands, query) {
117+ val q = query.trim()
118+ if (q.isEmpty()) commands
119+ else commands.filter {
120+ it.name.contains(q, ignoreCase = true) ||
121+ it.displayDescription.contains(q, ignoreCase = true)
122+ }
123+ }
124+ if (filtered.isEmpty()) {
125+ Text(
126+ stringResource(R.string.no_commands_match, query.trim()),
127+ style = MaterialTheme.typography.bodyMedium,
128+ color = MaterialTheme.colorScheme.onSurfaceVariant,
129+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
130+ )
131+ } else {
132+ LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
133+ // distinctBy the name so duplicate command names from the server can't
134+ // crash the list with LazyColumn's "Key was already used".
135+ items(filtered.distinctBy { it.name }, key = { it.name }) { cmd ->
136+ Column(
137+ modifier = Modifier
138+ .fillMaxWidth()
139+ .clickable(role = Role.Button) {
140+ if (isDestructiveCommand(cmd.name)) {
141+ pendingCommandName = cmd.name
142+ } else {
143+ onSelect(cmd)
144+ onDismiss()
145+ }
146+ }
147+ .padding(horizontal = 24.dp, vertical = 12.dp),
148+ ) {
149+ androidx.compose.foundation.layout.Row(
150+ verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
151+ ) {
152+ Icon(Icons.AutoMirrored.Filled.PlaylistPlay, contentDescription = null)
153+ Text(
154+ "/${cmd.name}",
155+ style = MaterialTheme.typography.bodyLarge,
156+ modifier = Modifier.padding(start = 8.dp),
157+ )
158+ }
159+ Text(
160+ cmd.displayDescription,
161+ style = MaterialTheme.typography.bodySmall,
162+ color = MaterialTheme.colorScheme.onSurfaceVariant,
163+ maxLines = 2,
164+ overflow = TextOverflow.Ellipsis,
165+ modifier = Modifier.padding(start = 28.dp, top = 2.dp),
166+ )
167+ }
168+ }
169+ }
170+ }
171+ }
172+ }
173+
174+ // Confirmation for commands whose name suggests they may discard work or reset state,
175+ // so a tap doesn't irrevocably run e.g. /clear or /compact without consent.
176+ pendingCommand?.let { cmd ->
177+ AlertDialog(
178+ onDismissRequest = { pendingCommandName = null },
179+ title = { Text(stringResource(R.string.run_command_title, cmd.name)) },
180+ text = { Text(stringResource(R.string.run_command_text, "/${cmd.name}")) },
181+ confirmButton = {
182+ TextButton(onClick = {
183+ pendingCommandName = null
184+ onSelect(cmd)
185+ onDismiss()
186+ }) { Text(stringResource(R.string.run)) }
187+ },
188+ dismissButton = {
189+ TextButton(onClick = { pendingCommandName = null }) { Text(stringResource(R.string.cancel)) }
190+ },
191+ )
192+ }
193+}
194+
195+/**
196+ * Heuristic for commands that may be destructive (discard work / reset state) and so
197+ * warrant a confirmation step before invocation. Matches common opencode slash-command
198+ * names; non-matching commands invoke immediately.
199+ */
200+private fun isDestructiveCommand(name: String): Boolean {
201+ val n = name.lowercase()
202+ return n in setOf("clear", "compact", "delete", "reset", "remove", "wipe", "drop", "purge") ||
203+ n.startsWith("clear") || n.startsWith("compact") || n.startsWith("reset")
204+}
app/src/main/java/soy/iko/opencode/ui/chat/ConversationExport.ktnew file+124
@@ -0,0 +1,124 @@
1+package soy.iko.opencode.ui.chat
2+
3+import soy.iko.opencode.data.model.AssistantMessage
4+import soy.iko.opencode.data.model.MessageWithParts
5+import soy.iko.opencode.data.model.ReasoningPart
6+import soy.iko.opencode.data.model.TextPart
7+import soy.iko.opencode.data.model.ToolCompleted
8+import soy.iko.opencode.data.model.ToolError
9+import soy.iko.opencode.data.model.ToolPart
10+import soy.iko.opencode.data.model.ToolRunning
11+import soy.iko.opencode.data.model.UserMessage
12+
13+/**
14+ * Render a conversation as Markdown for sharing/exporting. Each message becomes a
15+ * role-prefixed section containing its text parts; reasoning is exported as a blockquote
16+ * and tool calls are summarized so the transcript shows what the agent *did*, not just
17+ * what it said. Pure (no Android deps) so it can be unit-tested directly.
18+ */
19+fun buildConversationMarkdown(messages: List<MessageWithParts>, title: String?): String {
20+ val sb = StringBuilder(messages.size * 128)
21+ if (!title.isNullOrBlank()) {
22+ sb.append("# ").append(escapeMarkdown(title.trim())).append("\n\n")
23+ }
24+ for (message in messages) {
25+ val heading = when (message.info) {
26+ is UserMessage -> "## You"
27+ is AssistantMessage -> "## opencode"
28+ else -> null
29+ } ?: continue
30+ // The assistant's reply text is already Markdown (code fences, bold, headings);
31+ // escaping it would render the export as backslash noise. Only escape user-authored
32+ // text, which isn't expected to be Markdown.
33+ val escapeText = message.info is UserMessage
34+ val body = message.parts
35+ .mapNotNull { part ->
36+ when (part) {
37+ is TextPart -> part.text.takeIf { it.isNotBlank() }
38+ ?.let { if (escapeText) escapeMarkdown(it) else it }
39+ is ReasoningPart -> part.text.takeIf { it.isNotBlank() }
40+ ?.let { text -> text.trim().lines().joinToString("\n") { "> _${escapeMarkdown(it)}_" } }
41+ is ToolPart -> formatToolCall(part)
42+ else -> null
43+ }
44+ }
45+ .joinToString("\n\n")
46+ .trim()
47+ if (body.isEmpty()) continue
48+ sb.append(heading).append("\n\n").append(body).append("\n\n")
49+ }
50+ return sb.toString().trimEnd()
51+}
52+
53+/** Render a tool call as a compact, readable blockquote summary: the tool name, its
54+ * human-readable title (if any), and a truncated output/error. Keeps the exported
55+ * transcript useful when the agent's work (edits, command runs) is the substance. */
56+private fun formatToolCall(part: ToolPart): String? {
57+ val title = when (val s = part.state) {
58+ is ToolRunning -> s.title
59+ is ToolCompleted -> s.title
60+ else -> null
61+ }
62+ val detail = when (val s = part.state) {
63+ is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }?.let { truncateOutput(it) }
64+ is ToolError -> s.error?.takeIf { it.isNotBlank() }?.let { "Error: ${truncateOutput(it)}" }
65+ else -> null
66+ }
67+ val status = when (part.state) {
68+ is ToolCompleted -> null
69+ is ToolError -> " — error"
70+ is ToolRunning -> " — running"
71+ else -> null
72+ }
73+ val head = "**${part.tool}**${status ?: ""}"
74+ val titleLine = title?.takeIf { it.isNotBlank() }?.let { escapeMarkdown(it) }
75+ return buildList {
76+ add("> $head")
77+ if (titleLine != null) add("> $titleLine")
78+ // Prefix every detail line with "> " so the fenced code block stays inside the
79+ // blockquote — otherwise lines after the first fall out of the quote.
80+ if (detail != null) {
81+ val quoted = detail.lines().joinToString("\n") { "> $it" }
82+ // Use a fence longer than the longest backtick run in the content, so tool output
83+ // that itself contains a ``` sequence (shell output echoing markdown, etc.) can't
84+ // prematurely close the block and make the rest of the transcript render broken.
85+ val fence = "`".repeat(maxOf(3, longestBacktickRun(detail) + 1))
86+ add(">\n> $fence\n$quoted\n> $fence")
87+ }
88+ }.joinToString("\n").takeIf { it.isNotBlank() }
89+}
90+
91+/** Length of the longest consecutive run of backticks in [text] (0 if none). */
92+private fun longestBacktickRun(text: String): Int {
93+ var max = 0
94+ var current = 0
95+ for (c in text) {
96+ if (c == '`') {
97+ current++
98+ if (current > max) max = current
99+ } else {
100+ current = 0
101+ }
102+ }
103+ return max
104+}
105+
106+private fun truncateOutput(output: String): String {
107+ if (output.length <= TOOL_OUTPUT_LIMIT) return output
108+ return output.take(TOOL_OUTPUT_LIMIT) + "\n… (truncated)"
109+}
110+
111+private const val TOOL_OUTPUT_LIMIT = 2000
112+
113+/** Escape markdown special characters so user/model text doesn't produce malformed markdown. */
114+private fun escapeMarkdown(text: String): String {
115+ if (text.isEmpty()) return text
116+ val sb = StringBuilder(text.length + 16)
117+ for (c in text) {
118+ when (c) {
119+ '\\', '*', '_', '#', '`', '>' -> sb.append('\\')
120+ }
121+ sb.append(c)
122+ }
123+ return sb.toString()
124+}
app/src/main/java/soy/iko/opencode/ui/chat/MessageBubble.ktnew file+421
@@ -0,0 +1,421 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.foundation.background
4+import androidx.compose.foundation.layout.Arrangement
5+import androidx.compose.foundation.layout.Column
6+import androidx.compose.foundation.layout.Row
7+import androidx.compose.foundation.layout.fillMaxWidth
8+import androidx.compose.foundation.layout.padding
9+import androidx.compose.foundation.layout.size
10+import androidx.compose.material.icons.Icons
11+import androidx.compose.material.icons.automirrored.filled.HelpOutline
12+import androidx.compose.material.icons.automirrored.filled.VolumeUp
13+import androidx.compose.material.icons.filled.CallSplit
14+import androidx.compose.material.icons.filled.ContentCopy
15+import androidx.compose.material.icons.filled.Edit
16+import androidx.compose.material.icons.filled.FormatQuote
17+import androidx.compose.material.icons.filled.MoreVert
18+import androidx.compose.material.icons.filled.Restore
19+import androidx.compose.material.icons.filled.StopCircle
20+import androidx.compose.material3.DropdownMenu
21+import androidx.compose.material3.DropdownMenuItem
22+import androidx.compose.material3.Icon
23+import androidx.compose.material3.IconButton
24+import androidx.compose.material3.MaterialTheme
25+import androidx.compose.material3.Text
26+import androidx.compose.runtime.Composable
27+import androidx.compose.runtime.getValue
28+import androidx.compose.runtime.mutableStateOf
29+import androidx.compose.runtime.remember
30+import androidx.compose.runtime.setValue
31+import androidx.compose.ui.Alignment
32+import androidx.compose.ui.Modifier
33+import androidx.compose.ui.draw.clip
34+import androidx.compose.ui.platform.LocalContext
35+import androidx.compose.ui.res.stringResource
36+import androidx.compose.ui.unit.dp
37+import soy.iko.opencode.R
38+import soy.iko.opencode.data.model.AssistantMessage
39+import soy.iko.opencode.data.model.MessageWithParts
40+import soy.iko.opencode.data.model.ModelOption
41+import soy.iko.opencode.data.model.TextPart
42+import soy.iko.opencode.data.model.Tokens
43+import soy.iko.opencode.data.model.UnknownMessage
44+import soy.iko.opencode.data.model.UserMessage
45+import soy.iko.opencode.data.network.NetworkConfig
46+import soy.iko.opencode.ui.components.ImageLoadContext
47+import soy.iko.opencode.ui.components.copyToClipboard
48+import soy.iko.opencode.ui.components.RelativeTimeText
49+
50+/**
51+ * Resolve an assistant message's model id to a friendly label from the loaded catalog,
52+ * falling back to the raw id (so a model not in the catalog still shows something
53+ * meaningful instead of being hidden). Returns null only when the message has no model.
54+ */
55+fun resolveModelLabel(info: AssistantMessage, models: List<ModelOption>): String? {
56+ val id = info.modelID ?: return null
57+ if (models.isEmpty()) return id
58+ val byPair = models.firstOrNull { it.providerID == info.providerID && it.modelID == id }
59+ return byPair?.modelLabel
60+ ?: models.firstOrNull { it.modelID == id }?.modelLabel
61+ ?: id
62+}
63+
64+/** A single message: user prompts right-aligned in a bubble, assistant output full-width. */
65+@Composable
66+fun MessageBubble(
67+ message: MessageWithParts,
68+ modifier: Modifier = Modifier,
69+ isRunning: Boolean = false,
70+ imageContext: ImageLoadContext? = null,
71+ modelLabel: String? = null,
72+ onOpenFile: ((String) -> Unit)? = null,
73+ onRevert: (() -> Unit)? = null,
74+ onEdit: ((String) -> Unit)? = null,
75+ onSpeak: ((String) -> Unit)? = null,
76+ isSpeaking: Boolean = false,
77+ onQuote: ((String) -> Unit)? = null,
78+ onBranch: ((String) -> Unit)? = null,
79+) {
80+ when (message.info) {
81+ is UserMessage -> UserBubble(message, imageContext, modifier, onOpenFile, onRevert, onEdit, onQuote, onBranch)
82+ is UnknownMessage -> UnknownMessageBlock(message, imageContext, modifier, onOpenFile)
83+ else -> AssistantBlock(message, isRunning, imageContext, modifier, modelLabel, onOpenFile, onRevert, onSpeak, isSpeaking, onQuote)
84+ }
85+}
86+
87+/** Overflow menu of secondary per-message actions (quote-reply, branch-a-new-session).
88+ * Shown when at least one action is available and there's text to act on. Keeps the
89+ * inline action row (copy/edit/revert/speak) uncluttered while still surfacing the extras. */
90+@Composable
91+private fun MessageOverflow(
92+ text: String,
93+ onQuote: ((String) -> Unit)?,
94+ onBranch: ((String) -> Unit)?,
95+) {
96+ if (onQuote == null && onBranch == null) return
97+ var expanded by remember { mutableStateOf(false) }
98+ val moreLabel = stringResource(R.string.message_actions)
99+ androidx.compose.foundation.layout.Box {
100+ IconButton(onClick = { expanded = true }) {
101+ Icon(
102+ Icons.Filled.MoreVert,
103+ contentDescription = moreLabel,
104+ modifier = Modifier.size(18.dp),
105+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
106+ )
107+ }
108+ DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
109+ onQuote?.let { quote ->
110+ DropdownMenuItem(
111+ leadingIcon = { Icon(Icons.Filled.FormatQuote, contentDescription = null) },
112+ text = { Text(stringResource(R.string.quote_reply)) },
113+ onClick = { expanded = false; quote(text) },
114+ )
115+ }
116+ onBranch?.let { branch ->
117+ DropdownMenuItem(
118+ leadingIcon = { Icon(Icons.Filled.CallSplit, contentDescription = null) },
119+ text = { Text(stringResource(R.string.branch_session)) },
120+ onClick = { expanded = false; branch(text) },
121+ )
122+ }
123+ }
124+ }
125+}
126+
127+/** A small "revert to before this message" icon button, shown next to Copy when a revert
128+ * handler is supplied. Reverting is undoable (see the chat's revert banner), so it acts
129+ * immediately without a confirmation dialog. */
130+@Composable
131+private fun RevertButton(onRevert: () -> Unit) {
132+ val label = stringResource(R.string.revert_to_here)
133+ IconButton(onClick = onRevert) {
134+ Icon(
135+ Icons.Filled.Restore,
136+ contentDescription = label,
137+ modifier = Modifier.size(18.dp),
138+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
139+ )
140+ }
141+}
142+
143+@Composable
144+private fun UnknownMessageBlock(
145+ message: MessageWithParts,
146+ imageContext: ImageLoadContext?,
147+ modifier: Modifier,
148+ onOpenFile: ((String) -> Unit)? = null,
149+) {
150+ // Forward-compat: a role the client doesn't model. Render a muted note so the user
151+ // sees *something* rather than an unlabeled block, plus any parts (e.g. text) the
152+ // server attached, so content isn't silently dropped.
153+ Column(
154+ modifier = modifier.fillMaxWidth(),
155+ verticalArrangement = Arrangement.spacedBy(8.dp),
156+ ) {
157+ Row(verticalAlignment = Alignment.CenterVertically) {
158+ Icon(
159+ Icons.AutoMirrored.Filled.HelpOutline,
160+ contentDescription = null,
161+ modifier = Modifier.size(14.dp),
162+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
163+ )
164+ Text(
165+ stringResource(R.string.unknown_message),
166+ style = MaterialTheme.typography.labelSmall,
167+ color = MaterialTheme.colorScheme.onSurfaceVariant,
168+ modifier = Modifier.padding(start = 6.dp),
169+ )
170+ }
171+ for (part in message.parts) {
172+ PartView(part, imageContext = imageContext, onOpenFile = onOpenFile)
173+ }
174+ MessageTimestampText(message.info)
175+ }
176+}
177+
178+@Composable
179+private fun UserBubble(
180+ message: MessageWithParts,
181+ imageContext: ImageLoadContext?,
182+ modifier: Modifier,
183+ onOpenFile: ((String) -> Unit)? = null,
184+ onRevert: (() -> Unit)? = null,
185+ onEdit: ((String) -> Unit)? = null,
186+ onQuote: ((String) -> Unit)? = null,
187+ onBranch: ((String) -> Unit)? = null,
188+) {
189+ val context = LocalContext.current
190+ val copyLabel = stringResource(R.string.copy)
191+ val editLabel = stringResource(R.string.edit_message)
192+ // Collect text from all TextParts for copying, so a user can reuse/repost their
193+ // own prompt. Memoized so a scroll-induced recomposition doesn't re-scan the list.
194+ val textToCopy = remember(message.parts) {
195+ message.parts
196+ .filterIsInstance<TextPart>()
197+ .joinToString("\n\n") { it.text }
198+ .takeIf { it.isNotBlank() }
199+ }
200+ Row(modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) {
201+ Column(
202+ modifier = Modifier
203+ .fillMaxWidth(NetworkConfig.userBubbleWidthFraction)
204+ .clip(MaterialTheme.shapes.medium)
205+ .background(MaterialTheme.colorScheme.primaryContainer)
206+ .padding(12.dp),
207+ verticalArrangement = Arrangement.spacedBy(6.dp),
208+ ) {
209+ for (part in message.parts) PartView(part, imageContext = imageContext, onOpenFile = onOpenFile)
210+ Row(
211+ modifier = Modifier.fillMaxWidth(),
212+ horizontalArrangement = Arrangement.SpaceBetween,
213+ verticalAlignment = Alignment.CenterVertically,
214+ ) {
215+ MessageTimestampText(message.info)
216+ Row(verticalAlignment = Alignment.CenterVertically) {
217+ // Edit affordance: reload this prompt into the composer, reverting the
218+ // conversation to before it so a Send replaces it. Only when there's text
219+ // to edit (an image-only prompt has nothing to reload).
220+ if (onEdit != null && textToCopy != null) {
221+ IconButton(onClick = { onEdit(textToCopy) }) {
222+ Icon(
223+ Icons.Filled.Edit,
224+ contentDescription = editLabel,
225+ modifier = Modifier.size(18.dp),
226+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
227+ )
228+ }
229+ }
230+ onRevert?.let { RevertButton(it) }
231+ // Copy affordance for user prompts, mirroring the assistant block.
232+ // Without it, reusing a prior prompt requires discovering the
233+ // long-press on the markdown text (and only TextParts support that).
234+ if (textToCopy != null) {
235+ IconButton(
236+ onClick = { copyToClipboard(context, "message", textToCopy) },
237+ ) {
238+ Icon(
239+ Icons.Filled.ContentCopy,
240+ contentDescription = copyLabel,
241+ modifier = Modifier.size(18.dp),
242+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
243+ )
244+ }
245+ }
246+ // Quote-reply / branch-a-new-session, tucked behind an overflow so the
247+ // inline row stays compact. Only when there's prompt text to act on.
248+ if (textToCopy != null) {
249+ MessageOverflow(text = textToCopy, onQuote = onQuote, onBranch = onBranch)
250+ }
251+ }
252+ }
253+ }
254+ }
255+}
256+
257+@Composable
258+private fun AssistantBlock(
259+ message: MessageWithParts,
260+ isRunning: Boolean,
261+ imageContext: ImageLoadContext?,
262+ modifier: Modifier,
263+ modelLabel: String? = null,
264+ onOpenFile: ((String) -> Unit)? = null,
265+ onRevert: (() -> Unit)? = null,
266+ onSpeak: ((String) -> Unit)? = null,
267+ isSpeaking: Boolean = false,
268+ onQuote: ((String) -> Unit)? = null,
269+) {
270+ Column(
271+ modifier = modifier.fillMaxWidth(),
272+ verticalArrangement = Arrangement.spacedBy(8.dp),
273+ ) {
274+ val info = message.info
275+ if (info is AssistantMessage) {
276+ val label = modelLabel ?: info.modelID
277+ if (label != null) {
278+ Text(
279+ label,
280+ style = MaterialTheme.typography.labelSmall,
281+ color = MaterialTheme.colorScheme.onSurfaceVariant,
282+ )
283+ }
284+ }
285+ message.parts.forEachIndexed { index, part ->
286+ // Only the final part of a running message is actively streaming; earlier parts
287+ // (a finished reasoning block, a completed tool call) are already done, so passing
288+ // isRunning to all of them would keep a completed reasoning block showing the
289+ // "Thinking…" spinner until the whole message finishes.
290+ val partStreaming = isRunning && index == message.parts.lastIndex
291+ PartView(part, isRunning = partStreaming, modifier = Modifier.fillMaxWidth(), imageContext = imageContext, onOpenFile = onOpenFile)
292+ }
293+ if (info is AssistantMessage) {
294+ val cost = info.cost
295+ val tokens = info.tokens
296+ val tokenFormat = stringResource(R.string.tokens_in_out)
297+ val costShort = stringResource(R.string.cost_format_short)
298+ val costLong = stringResource(R.string.cost_format_long)
299+ // Memoize the formatted cost/tokens line so a scroll-induced or unrelated
300+ // state-flip recomposition doesn't re-run NumberFormat + buildList +
301+ // joinToString for every visible assistant bubble.
302+ val costSummary = remember(info.isComplete, tokens, cost, tokenFormat, costShort, costLong) {
303+ if (!info.isComplete || (cost == null && tokens == null)) null
304+ else buildList {
305+ // Skip an all-zero token count (e.g. a completed message that reported no
306+ // usage) so the bubble doesn't show a meaningless "0 in · 0 out".
307+ tokens?.takeIf { it.input > 0 || it.output > 0 }?.let { add(formatTokens(it, tokenFormat)) }
308+ cost?.takeIf { it > 0 }?.let { add(formatCost(it, costShort, costLong)) }
309+ }.takeIf { it.isNotEmpty() }?.joinToString(" • ")
310+ }
311+ Row(
312+ modifier = Modifier.fillMaxWidth(),
313+ horizontalArrangement = Arrangement.SpaceBetween,
314+ verticalAlignment = Alignment.CenterVertically,
315+ ) {
316+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
317+ costSummary?.let {
318+ Text(
319+ it,
320+ style = MaterialTheme.typography.labelSmall,
321+ color = MaterialTheme.colorScheme.onSurfaceVariant,
322+ )
323+ }
324+ MessageTimestampText(message.info)
325+ }
326+ // Collect text from all TextParts for copy/read-aloud. Memoized so a
327+ // scroll-induced recomposition doesn't re-scan the parts list.
328+ val textToCopy = remember(message.parts) {
329+ message.parts
330+ .filterIsInstance<TextPart>()
331+ .joinToString("\n\n") { it.text }
332+ .takeIf { it.isNotBlank() }
333+ }
334+ AssistantActions(
335+ textToCopy = textToCopy,
336+ isSpeaking = isSpeaking,
337+ onSpeak = onSpeak,
338+ onRevert = onRevert,
339+ onQuote = onQuote,
340+ )
341+ }
342+ } else {
343+ MessageTimestampText(message.info)
344+ }
345+ }
346+}
347+
348+/** Trailing action buttons for an assistant message: read-aloud (TTS), revert, copy.
349+ * Extracted from [AssistantBlock] to keep that function under the complexity threshold. */
350+@Composable
351+private fun AssistantActions(
352+ textToCopy: String?,
353+ isSpeaking: Boolean,
354+ onSpeak: ((String) -> Unit)?,
355+ onRevert: (() -> Unit)?,
356+ onQuote: ((String) -> Unit)? = null,
357+) {
358+ val context = LocalContext.current
359+ val copyLabel = stringResource(R.string.copy)
360+ Row(verticalAlignment = Alignment.CenterVertically) {
361+ // Read-aloud toggle: speaks the assistant text via TextToSpeech, showing a Stop
362+ // icon while this message is the one being spoken.
363+ if (onSpeak != null && textToCopy != null) {
364+ val speakLabel = stringResource(if (isSpeaking) R.string.stop_reading else R.string.read_aloud)
365+ IconButton(onClick = { onSpeak(textToCopy) }) {
366+ Icon(
367+ if (isSpeaking) Icons.Filled.StopCircle else Icons.AutoMirrored.Filled.VolumeUp,
368+ contentDescription = speakLabel,
369+ modifier = Modifier.size(18.dp),
370+ tint = if (isSpeaking) MaterialTheme.colorScheme.primary
371+ else MaterialTheme.colorScheme.onSurfaceVariant,
372+ )
373+ }
374+ }
375+ onRevert?.let { RevertButton(it) }
376+ if (textToCopy != null) {
377+ IconButton(onClick = { copyToClipboard(context, "message", textToCopy) }) {
378+ Icon(
379+ Icons.Filled.ContentCopy,
380+ contentDescription = copyLabel,
381+ modifier = Modifier.size(18.dp),
382+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
383+ )
384+ }
385+ }
386+ // Assistant replies can be quoted into the composer (branching is offered only on
387+ // user prompts, where seeding a new session with the prompt makes sense).
388+ if (textToCopy != null && onQuote != null) {
389+ MessageOverflow(text = textToCopy, onQuote = onQuote, onBranch = null)
390+ }
391+ }
392+}
393+
394+@Composable
395+private fun MessageTimestampText(info: soy.iko.opencode.data.model.MessageInfo) {
396+ // Compact relative label by default; long-press reveals the full absolute timestamp
397+ // (RelativeTimeText renders nothing when the time is absent, so no isNotEmpty guard).
398+ RelativeTimeText(info.time?.created)
399+}
400+
401+// NumberFormat.getNumberInstance performs an expensive ICU locale lookup + object
402+// construction on every call. Reuse a thread-local instance so repeated calls (e.g.
403+// when the message list re-seeds after a reconnect and every visible assistant bubble
404+// recomposes at once) don't each pay that cost. Thread-local because NumberFormat is
405+// not thread-safe.
406+private val tokenNumberFormat: ThreadLocal<java.text.NumberFormat> = ThreadLocal.withInitial {
407+ java.text.NumberFormat.getNumberInstance(java.util.Locale.US)
408+}
409+
410+private fun formatTokens(tokens: Tokens, format: String): String {
411+ val nf = tokenNumberFormat.get()!!
412+ return format.format(nf.format(tokens.input), nf.format(tokens.output))
413+}
414+
415+private fun formatCost(cost: Double, shortFormat: String, longFormat: String): String =
416+ // Locale.US so the formatting is stable regardless of device locale (avoids
417+ // non-ASCII digits or comma decimal separators in a dollar amount). The format
418+ // string itself is localized via strings.xml so the currency symbol can be
419+ // adapted by translators.
420+ if (cost < 0.01) String.format(java.util.Locale.US, longFormat, cost)
421+ else String.format(java.util.Locale.US, shortFormat, cost)
app/src/main/java/soy/iko/opencode/ui/chat/ModelPickerSheet.ktnew file+182
@@ -0,0 +1,182 @@
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.text.KeyboardActions
13+import androidx.compose.foundation.text.KeyboardOptions
14+import androidx.compose.material.icons.Icons
15+import androidx.compose.material.icons.filled.Check
16+import androidx.compose.material.icons.filled.Close
17+import androidx.compose.material.icons.filled.Search
18+import androidx.compose.material3.CircularProgressIndicator
19+import androidx.compose.material3.ExperimentalMaterial3Api
20+import androidx.compose.material3.Icon
21+import androidx.compose.material3.IconButton
22+import androidx.compose.material3.MaterialTheme
23+import androidx.compose.material3.ModalBottomSheet
24+import androidx.compose.material3.OutlinedTextField
25+import androidx.compose.material3.Text
26+import androidx.compose.material3.TextButton
27+import androidx.compose.runtime.Composable
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.platform.LocalSoftwareKeyboardController
36+import androidx.compose.ui.res.stringResource
37+import androidx.compose.ui.semantics.Role
38+import androidx.compose.ui.semantics.contentDescription
39+import androidx.compose.ui.semantics.role
40+import androidx.compose.ui.semantics.selected
41+import androidx.compose.ui.semantics.semantics
42+import androidx.compose.ui.text.input.ImeAction
43+import androidx.compose.ui.unit.dp
44+import androidx.compose.foundation.layout.Box
45+import soy.iko.opencode.data.model.ModelOption
46+import soy.iko.opencode.R
47+
48+/** Bottom sheet that lists every provider/model and lets the user pick one. */
49+@OptIn(ExperimentalMaterial3Api::class)
50+@Composable
51+fun ModelPickerSheet(
52+ options: List<ModelOption>,
53+ selected: ModelOption?,
54+ loading: Boolean,
55+ error: Boolean,
56+ onSelect: (ModelOption) -> Unit,
57+ onRetry: () -> Unit,
58+ onDismiss: () -> Unit,
59+) {
60+ ModalBottomSheet(onDismissRequest = onDismiss) {
61+ Text(
62+ stringResource(R.string.model),
63+ style = MaterialTheme.typography.titleMedium,
64+ modifier = Modifier.padding(start = 24.dp, end = 24.dp, bottom = 8.dp),
65+ )
66+ if (loading) {
67+ val loadingLabel = stringResource(R.string.loading)
68+ Box(
69+ modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp),
70+ contentAlignment = Alignment.Center,
71+ ) {
72+ CircularProgressIndicator(Modifier.semantics { contentDescription = loadingLabel })
73+ }
74+ } else if (error && options.isEmpty()) {
75+ Column(
76+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
77+ horizontalAlignment = Alignment.CenterHorizontally,
78+ ) {
79+ Text(
80+ stringResource(R.string.load_failed),
81+ style = MaterialTheme.typography.bodyMedium,
82+ color = MaterialTheme.colorScheme.error,
83+ )
84+ TextButton(onClick = onRetry) { Text(stringResource(R.string.retry)) }
85+ }
86+ } else if (options.isEmpty()) {
87+ Text(
88+ stringResource(R.string.no_models),
89+ style = MaterialTheme.typography.bodyMedium,
90+ color = MaterialTheme.colorScheme.onSurfaceVariant,
91+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
92+ )
93+ } else {
94+ var query by rememberSaveable { mutableStateOf("") }
95+ val keyboardController = LocalSoftwareKeyboardController.current
96+ OutlinedTextField(
97+ value = query,
98+ onValueChange = { query = it },
99+ modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 8.dp),
100+ placeholder = { Text(stringResource(R.string.search_models)) },
101+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
102+ trailingIcon = if (query.isNotEmpty()) {
103+ {
104+ IconButton(onClick = { query = "" }) {
105+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
106+ }
107+ }
108+ } else null,
109+ singleLine = true,
110+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
111+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
112+ )
113+ val filtered = remember(options, query) {
114+ val q = query.trim()
115+ if (q.isEmpty()) options
116+ else options.filter {
117+ it.modelLabel.contains(q, ignoreCase = true) ||
118+ it.providerLabel.contains(q, ignoreCase = true)
119+ }
120+ }
121+ if (filtered.isEmpty()) {
122+ Text(
123+ stringResource(R.string.no_models_match, query.trim()),
124+ style = MaterialTheme.typography.bodyMedium,
125+ color = MaterialTheme.colorScheme.onSurfaceVariant,
126+ modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
127+ )
128+ } else {
129+ // Group by provider so a long catalog is scannable: a provider header
130+ // followed by its models, instead of a flat list mixing providers.
131+ val grouped = remember(filtered) { filtered.groupBy { it.providerID } }
132+ LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 420.dp)) {
133+ grouped.forEach { (providerID, opts) ->
134+ item(key = "header_$providerID") {
135+ Text(
136+ opts.firstOrNull()?.providerLabel ?: providerID,
137+ style = MaterialTheme.typography.labelMedium,
138+ color = MaterialTheme.colorScheme.primary,
139+ modifier = Modifier.padding(start = 24.dp, top = 12.dp, bottom = 4.dp),
140+ )
141+ }
142+ // distinctBy the (provider, model) pair so a duplicate entry from the
143+ // server can't crash the list with LazyColumn's "Key was already used".
144+ items(opts.distinctBy { it.providerID to it.modelID }, key = { it.providerID to it.modelID }) { option ->
145+ val isSelected = option.providerID == selected?.providerID &&
146+ option.modelID == selected?.modelID
147+ Column(
148+ modifier = Modifier
149+ .fillMaxWidth()
150+ .semantics(mergeDescendants = true) {
151+ if (isSelected) this.selected = true
152+ }
153+ .clickable(role = Role.RadioButton) {
154+ onSelect(option)
155+ onDismiss()
156+ }
157+ .padding(horizontal = 24.dp, vertical = 12.dp),
158+ ) {
159+ Row(verticalAlignment = Alignment.CenterVertically) {
160+ Text(
161+ option.modelLabel,
162+ style = MaterialTheme.typography.bodyLarge,
163+ color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
164+ modifier = Modifier.weight(1f),
165+ )
166+ if (isSelected) {
167+ Icon(
168+ Icons.Filled.Check,
169+ contentDescription = null,
170+ tint = MaterialTheme.colorScheme.primary,
171+ modifier = Modifier.size(18.dp),
172+ )
173+ }
174+ }
175+ }
176+ }
177+ }
178+ }
179+ }
180+ }
181+ }
182+}
app/src/main/java/soy/iko/opencode/ui/chat/PartRenderer.ktnew file+499
@@ -0,0 +1,499 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.animation.AnimatedVisibility
4+import androidx.compose.foundation.background
5+import androidx.compose.foundation.clickable
6+import androidx.compose.foundation.combinedClickable
7+import androidx.compose.foundation.layout.Column
8+import androidx.compose.foundation.layout.Row
9+import androidx.compose.foundation.layout.defaultMinSize
10+import androidx.compose.foundation.layout.fillMaxWidth
11+import androidx.compose.foundation.layout.padding
12+import androidx.compose.foundation.layout.size
13+import androidx.compose.foundation.text.selection.SelectionContainer
14+import androidx.compose.material.icons.Icons
15+import androidx.compose.material.icons.automirrored.filled.HelpOutline
16+import androidx.compose.material.icons.filled.Bolt
17+import androidx.compose.material.icons.filled.CheckCircle
18+import androidx.compose.material.icons.filled.ContentCopy
19+import androidx.compose.material.icons.filled.Description
20+import androidx.compose.material.icons.filled.Error
21+import androidx.compose.material.icons.filled.ExpandLess
22+import androidx.compose.material.icons.filled.ExpandMore
23+import androidx.compose.material.icons.filled.Psychology
24+import androidx.compose.material3.CircularProgressIndicator
25+import androidx.compose.material3.Icon
26+import androidx.compose.material3.IconButton
27+import androidx.compose.material3.MaterialTheme
28+import androidx.compose.material3.Text
29+import androidx.compose.material3.TextButton
30+import androidx.compose.runtime.Composable
31+import androidx.compose.runtime.getValue
32+import androidx.compose.runtime.mutableStateOf
33+import androidx.compose.runtime.remember
34+import androidx.compose.runtime.saveable.rememberSaveable
35+import androidx.compose.runtime.setValue
36+import androidx.compose.ui.Alignment
37+import androidx.compose.ui.Modifier
38+import androidx.compose.ui.draw.clip
39+import androidx.compose.ui.platform.LocalContext
40+import androidx.compose.ui.semantics.Role
41+import androidx.compose.ui.semantics.contentDescription
42+import androidx.compose.ui.semantics.semantics
43+import androidx.compose.ui.semantics.stateDescription
44+import androidx.compose.ui.text.font.FontFamily
45+import androidx.compose.ui.res.stringResource
46+import androidx.compose.ui.unit.dp
47+import kotlinx.serialization.json.Json
48+import kotlinx.serialization.json.JsonElement
49+import soy.iko.opencode.data.model.FilePart
50+import soy.iko.opencode.data.model.Part
51+import soy.iko.opencode.data.model.ReasoningPart
52+import soy.iko.opencode.data.model.StepFinishPart
53+import soy.iko.opencode.data.model.StepStartPart
54+import soy.iko.opencode.data.model.TextPart
55+import soy.iko.opencode.data.model.ToolCompleted
56+import soy.iko.opencode.data.model.ToolError
57+import soy.iko.opencode.data.model.ToolPart
58+import soy.iko.opencode.data.model.ToolPending
59+import soy.iko.opencode.data.model.ToolRunning
60+import soy.iko.opencode.data.model.ToolState
61+import soy.iko.opencode.data.model.ToolUnknown
62+import soy.iko.opencode.data.model.TODO_WRITE_TOOL
63+import soy.iko.opencode.data.model.UnknownPart
64+import soy.iko.opencode.data.model.inputElement
65+import soy.iko.opencode.data.model.parseTodos
66+import soy.iko.opencode.data.model.sourcePath
67+import soy.iko.opencode.ui.components.DiffView
68+import soy.iko.opencode.ui.components.ImageLoadContext
69+import soy.iko.opencode.ui.components.MarkdownText
70+import soy.iko.opencode.ui.components.RemoteImage
71+import soy.iko.opencode.ui.components.copyToClipboard
72+import soy.iko.opencode.ui.components.isImage
73+import soy.iko.opencode.ui.components.looksLikeDiff
74+import soy.iko.opencode.ui.components.showToast
75+import soy.iko.opencode.R
76+
77+private const val COLLAPSED_LIMIT = 600
78+
79+// Pretty-printer for tool inputs. Separate from OpencodeJson (which is tuned for
80+// resilient decoding) so this stays human-readable; constructed lazily and memoized.
81+private val prettyJson: Json = Json { prettyPrint = true }
82+
83+/** Extract a human-readable title (e.g. "Reading src/main.kt") from a tool state. */
84+private fun ToolState.titleText(): String? = when (this) {
85+ is ToolRunning -> title
86+ is ToolCompleted -> title
87+ else -> null
88+}
89+
90+/**
91+ * Renders a single message [Part]. The exhaustive `when` over the sealed type gives
92+ * compile-time coverage; the [UnknownPart] arm keeps the UI forward-compatible.
93+ *
94+ * [onOpenFile] is invoked when the user taps a [FilePart] chip that references a
95+ * source path the viewer can open; null leaves the chip as a copy-path affordance
96+ * (used when no file navigation is wired, e.g. in tests).
97+ */
98+@Composable
99+fun PartView(
100+ part: Part,
101+ modifier: Modifier = Modifier,
102+ isRunning: Boolean = false,
103+ imageContext: ImageLoadContext? = null,
104+ onOpenFile: ((path: String) -> Unit)? = null,
105+) {
106+ when (part) {
107+ is TextPart -> if (!part.ignored && part.text.isNotEmpty()) {
108+ MarkdownText(part.text, modifier = modifier, streaming = isRunning)
109+ }
110+ is ReasoningPart -> ReasoningBlock(part.text, streaming = isRunning, keyId = part.id, modifier = modifier)
111+ is ToolPart -> ToolCallView(part, modifier)
112+ is FilePart -> if (part.isImage && imageContext != null && (part.source != null || !part.url.isNullOrBlank())) {
113+ RemoteImage(part, imageContext, modifier)
114+ } else {
115+ FileChip(part, modifier, onOpenFile)
116+ }
117+ is StepStartPart -> {} // boundary marker — nothing to draw
118+ is StepFinishPart -> {} // metrics handled at message level
119+ is UnknownPart -> UnknownPartNote(modifier)
120+ }
121+}
122+
123+@Composable
124+private fun UnknownPartNote(modifier: Modifier) {
125+ // Forward-compat: a part type the client doesn't model. Render a muted note (matching
126+ // UnknownMessageBlock's pattern) so the user can tell content was dropped instead of
127+ // the part vanishing silently — important when a new server release adds a part type.
128+ Row(
129+ modifier = modifier.fillMaxWidth(),
130+ verticalAlignment = Alignment.CenterVertically,
131+ ) {
132+ Icon(
133+ Icons.AutoMirrored.Filled.HelpOutline,
134+ contentDescription = null,
135+ modifier = Modifier.size(14.dp),
136+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
137+ )
138+ Text(
139+ stringResource(R.string.unknown_part),
140+ style = MaterialTheme.typography.labelSmall,
141+ color = MaterialTheme.colorScheme.onSurfaceVariant,
142+ modifier = Modifier.padding(start = 6.dp),
143+ )
144+ }
145+}
146+
147+@Composable
148+private fun ReasoningBlock(text: String, streaming: Boolean, keyId: String, modifier: Modifier) {
149+ if (text.isBlank()) return
150+ val context = LocalContext.current
151+ // Key the expanded state on the part's stable id so a new ReasoningPart doesn't
152+ // inherit the positional saveable key of a prior block. A content-prefix key would
153+ // change on nearly every token while streaming and snap the block back to collapsed;
154+ // fall back to a content prefix only when the part has no id.
155+ val saveableKey = keyId.ifBlank { text.take(64) }
156+ var expanded by rememberSaveable(saveableKey) { mutableStateOf(false) }
157+ val expandedState = stringResource(R.string.state_expanded)
158+ val collapsedState = stringResource(R.string.state_collapsed)
159+ val thinkingLabel = stringResource(R.string.thinking)
160+ val thoughtsLabel = stringResource(R.string.thoughts)
161+ Column(modifier = modifier.fillMaxWidth()) {
162+ Row(
163+ modifier = Modifier
164+ .fillMaxWidth()
165+ .defaultMinSize(minHeight = 48.dp)
166+ .clickable(role = Role.Button) { expanded = !expanded }
167+ .padding(vertical = 4.dp)
168+ .semantics {
169+ stateDescription = if (expanded) expandedState else collapsedState
170+ },
171+ verticalAlignment = Alignment.CenterVertically,
172+ ) {
173+ if (streaming) {
174+ CircularProgressIndicator(
175+ Modifier.size(14.dp).semantics { contentDescription = thinkingLabel },
176+ strokeWidth = 2.dp,
177+ color = MaterialTheme.colorScheme.onSurfaceVariant,
178+ )
179+ } else {
180+ Icon(
181+ Icons.Filled.Psychology,
182+ contentDescription = null,
183+ modifier = Modifier.size(16.dp),
184+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
185+ )
186+ }
187+ Text(
188+ if (streaming) thinkingLabel else thoughtsLabel,
189+ style = MaterialTheme.typography.labelMedium,
190+ color = MaterialTheme.colorScheme.onSurfaceVariant,
191+ modifier = Modifier.padding(start = 6.dp),
192+ )
193+ }
194+ AnimatedVisibility(visible = expanded) {
195+ Column {
196+ // Wrap in SelectionContainer so the user can select a portion of the
197+ // reasoning (e.g. a single step) instead of the all-or-nothing copy
198+ // button below. Matches the markdown text's selectability.
199+ SelectionContainer {
200+ Text(
201+ text,
202+ style = MaterialTheme.typography.bodySmall,
203+ color = MaterialTheme.colorScheme.onSurfaceVariant,
204+ )
205+ }
206+ TextButton(
207+ onClick = { copyToClipboard(context, "reasoning", text) },
208+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
209+ modifier = Modifier.semantics(mergeDescendants = true) {},
210+ ) {
211+ Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
212+ Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
213+ }
214+ }
215+ }
216+ }
217+}
218+
219+@OptIn(kotlinx.serialization.ExperimentalSerializationApi::class)
220+@Composable
221+private fun ToolCallView(part: ToolPart, modifier: Modifier) {
222+ val context = LocalContext.current
223+ Column(
224+ modifier = modifier
225+ .fillMaxWidth()
226+ .clip(MaterialTheme.shapes.small)
227+ .background(MaterialTheme.colorScheme.surfaceVariant)
228+ .padding(10.dp),
229+ ) {
230+ Row(verticalAlignment = Alignment.CenterVertically) {
231+ ToolStatusIcon(part.state)
232+ Text(
233+ part.tool,
234+ style = MaterialTheme.typography.labelLarge,
235+ fontFamily = FontFamily.Monospace,
236+ modifier = Modifier.padding(start = 6.dp),
237+ )
238+ }
239+ // A human-readable summary of what the tool was asked to do (e.g. "Reading
240+ // src/main.kt"), when the server provides one. Sits directly under the tool
241+ // name so the user understands the call before diving into input/output.
242+ part.state.titleText()?.takeIf { it.isNotBlank() }?.let { title ->
243+ Text(
244+ title,
245+ style = MaterialTheme.typography.bodySmall,
246+ color = MaterialTheme.colorScheme.onSurfaceVariant,
247+ modifier = Modifier.padding(start = 22.dp, top = 2.dp),
248+ )
249+ }
250+ // A todowrite call carries the agent's task plan as its input; render it as a
251+ // checklist rather than raw JSON so each step's progress reads at a glance.
252+ val planTodos = remember(part) {
253+ if (part.tool.equals(TODO_WRITE_TOOL, ignoreCase = true)) parseTodos(part.state.inputElement()) else emptyList()
254+ }
255+ if (planTodos.isNotEmpty()) {
256+ TodoPlanChecklist(planTodos, modifier = Modifier.padding(top = 8.dp))
257+ } else {
258+ // The tool's arguments (pretty-printed JSON), collapsible. Rendered before the
259+ // output so the call reads top-to-bottom: name → what → input → result.
260+ part.state.inputElement()?.let { input ->
261+ val inputLabel = stringResource(R.string.tool_input)
262+ val pretty = remember(input) {
263+ runCatching { prettyJson.encodeToString(JsonElement.serializer(), input) }.getOrDefault(input.toString())
264+ }
265+ CollapsibleDetail(
266+ label = inputLabel,
267+ detail = pretty,
268+ isDiff = false,
269+ keySuffix = "input",
270+ // The input block has a label row whose collapse toggle announces its state
271+ // via stateDescription; pass the strings so TalkBack reads "expanded"/"collapsed"
272+ // rather than an empty state (the defaults are "").
273+ expandedState = stringResource(R.string.state_expanded),
274+ collapsedState = stringResource(R.string.state_collapsed),
275+ )
276+ }
277+ }
278+ val detail = when (val s = part.state) {
279+ is ToolCompleted -> s.output?.takeIf { it.isNotBlank() }
280+ is ToolError -> s.error ?: stringResource(R.string.error_generic)
281+ else -> null
282+ }
283+ if (detail != null) {
284+ val collapsed = remember(detail) { detail.take(COLLAPSED_LIMIT) }
285+ var expanded by rememberSaveable(part.id) { mutableStateOf(false) }
286+ val expandedState = stringResource(R.string.state_expanded)
287+ val collapsedState = stringResource(R.string.state_collapsed)
288+ val display = if (expanded || detail.length <= COLLAPSED_LIMIT) detail else collapsed
289+ // looksLikeDiff scans the whole string; memoize so a recomposition that doesn't
290+ // change `display` (e.g. an unrelated state flip) doesn't re-scan on every pass.
291+ val isDiff = remember(display) { looksLikeDiff(display) }
292+ // How many lines are hidden while collapsed. detail.lines() splits the whole
293+ // (potentially multi-KB) output, so memoize it — otherwise it re-splits on
294+ // every recomposition, including each streaming update of a running tool.
295+ // `collapsed` is a prefix of `detail`, so its line count never exceeds it;
296+ // 0 (a single long line cut mid-way) falls back to the generic label.
297+ val moreLines = remember(detail, collapsed) {
298+ if (detail.length > COLLAPSED_LIMIT) detail.lines().size - collapsed.lines().size else 0
299+ }
300+ CollapsibleDetail(
301+ label = null,
302+ detail = display,
303+ isDiff = isDiff,
304+ keySuffix = "output",
305+ expanded = expanded || detail.length <= COLLAPSED_LIMIT,
306+ onToggleExpand = if (detail.length > COLLAPSED_LIMIT) {
307+ { expanded = !expanded }
308+ } else null,
309+ moreLines = moreLines,
310+ expandedState = expandedState,
311+ collapsedState = collapsedState,
312+ onCopy = { copyToClipboard(context, "output", detail) },
313+ )
314+ }
315+ }
316+}
317+
318+/**
319+ * A collapsible monospace detail block shared by tool input and output. [label] is an
320+ * optional heading (e.g. "Input"); when null, no heading row is drawn (used for output
321+ * which follows the tool name directly). Long content is truncated to [COLLAPSED_LIMIT]
322+ * with an expand/collapse affordance when [onToggleExpand] is supplied.
323+ */
324+@Composable
325+private fun CollapsibleDetail(
326+ label: String?,
327+ detail: String,
328+ isDiff: Boolean,
329+ keySuffix: String,
330+ expanded: Boolean = false,
331+ onToggleExpand: (() -> Unit)? = null,
332+ moreLines: Int = 0,
333+ expandedState: String = "",
334+ collapsedState: String = "",
335+ onCopy: (() -> Unit)? = null,
336+) {
337+ Column(modifier = Modifier.fillMaxWidth().padding(top = 6.dp)) {
338+ if (label != null) {
339+ var inputExpanded by rememberSaveable(label + keySuffix) { mutableStateOf(false) }
340+ Row(
341+ modifier = Modifier
342+ .fillMaxWidth()
343+ .defaultMinSize(minHeight = 48.dp)
344+ .clickable(role = Role.Button) { inputExpanded = !inputExpanded }
345+ .semantics { stateDescription = if (inputExpanded) expandedState else collapsedState },
346+ verticalAlignment = Alignment.CenterVertically,
347+ ) {
348+ Icon(
349+ if (inputExpanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
350+ contentDescription = null,
351+ modifier = Modifier.size(16.dp),
352+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
353+ )
354+ Text(
355+ label,
356+ style = MaterialTheme.typography.labelSmall,
357+ color = MaterialTheme.colorScheme.onSurfaceVariant,
358+ modifier = Modifier.padding(start = 4.dp),
359+ )
360+ }
361+ if (!inputExpanded) return@Column
362+ }
363+ if (isDiff) {
364+ DiffView(detail)
365+ } else {
366+ // SelectionContainer so the user can select a portion of the output (e.g.
367+ // a single line of stdout) instead of only copy-all via the button below.
368+ SelectionContainer {
369+ Text(
370+ detail,
371+ style = MaterialTheme.typography.bodySmall,
372+ fontFamily = FontFamily.Monospace,
373+ )
374+ }
375+ }
376+ if (onToggleExpand != null) {
377+ TextButton(
378+ onClick = onToggleExpand,
379+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
380+ modifier = Modifier.semantics { stateDescription = if (expanded) expandedState else collapsedState },
381+ ) {
382+ Icon(
383+ if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
384+ contentDescription = null,
385+ modifier = Modifier.size(16.dp),
386+ )
387+ Text(
388+ when {
389+ expanded -> stringResource(R.string.show_less)
390+ moreLines > 0 -> stringResource(R.string.show_more_lines, moreLines)
391+ else -> stringResource(R.string.show_more)
392+ },
393+ style = MaterialTheme.typography.labelSmall,
394+ modifier = Modifier.padding(start = 4.dp),
395+ )
396+ }
397+ }
398+ if (onCopy != null) {
399+ TextButton(
400+ onClick = onCopy,
401+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 0.dp),
402+ modifier = Modifier.semantics(mergeDescendants = true) {},
403+ ) {
404+ Icon(Icons.Filled.ContentCopy, contentDescription = null, modifier = Modifier.size(14.dp))
405+ Text(stringResource(R.string.copy), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(start = 4.dp))
406+ }
407+ }
408+ }
409+}
410+
411+@Composable
412+private fun ToolStatusIcon(state: ToolState) {
413+ when (state) {
414+ is ToolPending, is ToolRunning -> {
415+ val label = stringResource(R.string.tool_running)
416+ CircularProgressIndicator(
417+ modifier = Modifier.size(16.dp).semantics { contentDescription = label },
418+ strokeWidth = 2.dp,
419+ )
420+ }
421+ is ToolCompleted ->
422+ Icon(
423+ Icons.Filled.CheckCircle,
424+ contentDescription = stringResource(R.string.tool_completed),
425+ modifier = Modifier.size(16.dp),
426+ tint = MaterialTheme.colorScheme.primary,
427+ )
428+ is ToolError ->
429+ Icon(
430+ Icons.Filled.Error,
431+ contentDescription = stringResource(R.string.tool_error),
432+ modifier = Modifier.size(16.dp),
433+ tint = MaterialTheme.colorScheme.error,
434+ )
435+ is ToolUnknown ->
436+ Icon(
437+ Icons.Filled.Bolt,
438+ contentDescription = stringResource(R.string.tool_unknown),
439+ modifier = Modifier.size(16.dp),
440+ )
441+ }
442+}
443+
444+@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
445+@Composable
446+private fun FileChip(part: FilePart, modifier: Modifier, onOpenFile: ((String) -> Unit)?) {
447+ val context = LocalContext.current
448+ val path = part.sourcePath ?: part.url ?: part.filename
449+ val copyLabel = stringResource(R.string.copy_path)
450+ val openLabel = stringResource(R.string.open_file)
451+ // When a source path is available and a navigation callback is wired, tapping the
452+ // chip opens the file in the viewer (the action a user tapping a file reference
453+ // most likely expects). Long-press still copies the path to the clipboard, so the
454+ // copy affordance is preserved without being the default tap action. When no
455+ // navigation is wired (e.g. tests), falls back to copy-path on tap.
456+ val source = part.sourcePath
457+ val opener = onOpenFile
458+ val openPath: String? = if (opener != null && !source.isNullOrBlank()) source else null
459+ val canOpen = openPath != null
460+ val semanticsLabel = if (canOpen) openLabel else copyLabel
461+ Row(
462+ modifier = modifier
463+ .clip(MaterialTheme.shapes.small)
464+ .background(MaterialTheme.colorScheme.surfaceVariant)
465+ .combinedClickable(
466+ role = Role.Button,
467+ onClick = {
468+ val toOpen = openPath
469+ if (toOpen != null) {
470+ opener?.invoke(toOpen)
471+ } else if (!path.isNullOrBlank()) {
472+ copyToClipboard(context, "path", path)
473+ showToast(context, context.getString(R.string.path_copied))
474+ }
475+ },
476+ onLongClick = {
477+ if (!path.isNullOrBlank()) {
478+ copyToClipboard(context, "path", path)
479+ showToast(context, context.getString(R.string.path_copied))
480+ }
481+ },
482+ )
483+ .semantics { contentDescription = semanticsLabel }
484+ .padding(horizontal = 12.dp, vertical = 6.dp),
485+ verticalAlignment = Alignment.CenterVertically,
486+ ) {
487+ Icon(
488+ Icons.Filled.Description,
489+ contentDescription = null,
490+ modifier = Modifier.size(16.dp),
491+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
492+ )
493+ Text(
494+ part.filename ?: part.url ?: stringResource(R.string.file),
495+ style = MaterialTheme.typography.bodyMedium,
496+ modifier = Modifier.padding(start = 6.dp),
497+ )
498+ }
499+}
app/src/main/java/soy/iko/opencode/ui/chat/PermissionDialog.ktnew file+123
@@ -0,0 +1,123 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.foundation.layout.Arrangement
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.padding
8+import androidx.compose.material.icons.Icons
9+import androidx.compose.material.icons.filled.Shield
10+import androidx.compose.material3.AlertDialog
11+import androidx.compose.material3.Button
12+import androidx.compose.material3.Icon
13+import androidx.compose.material3.MaterialTheme
14+import androidx.compose.material3.OutlinedButton
15+import androidx.compose.material3.Text
16+import androidx.compose.material3.TextButton
17+import androidx.compose.runtime.Composable
18+import androidx.compose.runtime.mutableStateOf
19+import androidx.compose.runtime.remember
20+import androidx.compose.runtime.getValue
21+import androidx.compose.runtime.setValue
22+import androidx.compose.ui.Modifier
23+import androidx.compose.ui.platform.LocalHapticFeedback
24+import androidx.compose.ui.res.stringResource
25+import androidx.compose.ui.text.font.FontFamily
26+import androidx.compose.ui.unit.dp
27+import soy.iko.opencode.data.model.Permission
28+import soy.iko.opencode.data.model.PermissionResponse
29+import soy.iko.opencode.R
30+
31+/**
32+ * Modal asking the user to approve a tool the agent wants to run. The agent run is
33+ * paused until one of these responses is sent. Non-dismissable by tap-outside so the
34+ * decision is explicit.
35+ */
36+@Composable
37+fun PermissionDialog(
38+ permission: Permission,
39+ onRespond: (PermissionResponse) -> Unit,
40+) {
41+ val haptics = LocalHapticFeedback.current
42+ // Guard against double-respond: back press + button tap, or rapid double-tap,
43+ // could call onRespond twice. Once a response is sent, subsequent calls are no-ops.
44+ // Keyed on permission.id: the host mounts this dialog against a conflating StateFlow,
45+ // so a null -> permissionB transition can collapse to just permissionB, reusing this
46+ // composable for a different permission. Without the key `responded` would stay true
47+ // and every button (and back-press) would be a no-op — an undismissable modal.
48+ // Plain `remember` (not `rememberSaveable`): a failed respondPermission re-enqueues
49+ // the same permission id (ChatViewModel.respondPermission -> enqueuePermission), and
50+ // rememberSaveable would restore a stale `true` from the Bundle across a config
51+ // change in between, locking the re-queued modal with all buttons no-op. Plain
52+ // remember resets to false on every fresh composition, so a re-queued permission is
53+ // always answerable.
54+ var responded by remember(permission.id) { mutableStateOf(false) }
55+ val respond: (PermissionResponse) -> Unit = { response ->
56+ if (!responded) {
57+ responded = true
58+ onRespond(response)
59+ }
60+ }
61+ AlertDialog(
62+ // Back press routes here (dismissOnBackPress defaults true) and is treated as an
63+ // explicit reject — the safe default. Tap-outside is disabled below so it can't
64+ // dismiss silently. A host-composition BackHandler doesn't work: AlertDialog
65+ // renders in its own window whose back dispatcher never reaches the host.
66+ onDismissRequest = { respond(PermissionResponse.REJECT) },
67+ properties = androidx.compose.ui.window.DialogProperties(dismissOnClickOutside = false),
68+ icon = { Icon(Icons.Filled.Shield, contentDescription = null) },
69+ title = { Text(permission.title ?: stringResource(R.string.permission_title)) },
70+ text = {
71+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
72+ permission.type?.let {
73+ Text(it, style = MaterialTheme.typography.labelLarge, fontFamily = FontFamily.Monospace)
74+ }
75+ permission.patternText?.let {
76+ Text(
77+ it,
78+ style = MaterialTheme.typography.bodyMedium,
79+ fontFamily = FontFamily.Monospace,
80+ color = MaterialTheme.colorScheme.onSurfaceVariant,
81+ )
82+ }
83+ Text(
84+ stringResource(R.string.permission_text),
85+ style = MaterialTheme.typography.bodySmall,
86+ color = MaterialTheme.colorScheme.onSurfaceVariant,
87+ )
88+ }
89+ },
90+ confirmButton = {
91+ Column(
92+ modifier = Modifier.fillMaxWidth(),
93+ verticalArrangement = Arrangement.spacedBy(4.dp),
94+ ) {
95+ Button(
96+ onClick = {
97+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
98+ respond(PermissionResponse.ONCE)
99+ },
100+ modifier = Modifier.fillMaxWidth(),
101+ ) { Text(stringResource(R.string.allow_once)) }
102+ OutlinedButton(
103+ onClick = {
104+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
105+ respond(PermissionResponse.ALWAYS)
106+ },
107+ modifier = Modifier.fillMaxWidth(),
108+ ) { Text(stringResource(R.string.always_allow)) }
109+ // Reject is an OutlinedButton (not a low-prominence TextButton) in the
110+ // same column so the three actions are visually balanced — the prior
111+ // layout buried Reject as a small dismiss-button, nudging users toward
112+ // granting. Reject uses the error color to signal its consequence.
113+ OutlinedButton(
114+ onClick = {
115+ haptics.performHapticFeedback(androidx.compose.ui.hapticfeedback.HapticFeedbackType.LongPress)
116+ respond(PermissionResponse.REJECT)
117+ },
118+ modifier = Modifier.fillMaxWidth(),
119+ ) { Text(stringResource(R.string.reject), color = MaterialTheme.colorScheme.error) }
120+ }
121+ },
122+ )
123+}
app/src/main/java/soy/iko/opencode/ui/chat/TodoPlan.ktnew file+165
@@ -0,0 +1,165 @@
1+package soy.iko.opencode.ui.chat
2+
3+import androidx.compose.animation.AnimatedVisibility
4+import androidx.compose.foundation.clickable
5+import androidx.compose.foundation.layout.Arrangement
6+import androidx.compose.foundation.layout.Column
7+import androidx.compose.foundation.layout.Row
8+import androidx.compose.foundation.layout.Spacer
9+import androidx.compose.foundation.layout.defaultMinSize
10+import androidx.compose.foundation.layout.fillMaxWidth
11+import androidx.compose.foundation.layout.heightIn
12+import androidx.compose.foundation.layout.padding
13+import androidx.compose.foundation.layout.size
14+import androidx.compose.foundation.rememberScrollState
15+import androidx.compose.foundation.verticalScroll
16+import androidx.compose.material.icons.Icons
17+import androidx.compose.material.icons.filled.Autorenew
18+import androidx.compose.material.icons.filled.Cancel
19+import androidx.compose.material.icons.filled.CheckCircle
20+import androidx.compose.material.icons.filled.Checklist
21+import androidx.compose.material.icons.filled.ExpandLess
22+import androidx.compose.material.icons.filled.ExpandMore
23+import androidx.compose.material.icons.filled.RadioButtonUnchecked
24+import androidx.compose.material3.Icon
25+import androidx.compose.material3.MaterialTheme
26+import androidx.compose.material3.Surface
27+import androidx.compose.material3.Text
28+import androidx.compose.runtime.Composable
29+import androidx.compose.runtime.getValue
30+import androidx.compose.runtime.mutableStateOf
31+import androidx.compose.runtime.remember
32+import androidx.compose.runtime.saveable.rememberSaveable
33+import androidx.compose.runtime.setValue
34+import androidx.compose.ui.Alignment
35+import androidx.compose.ui.Modifier
36+import androidx.compose.ui.res.stringResource
37+import androidx.compose.ui.semantics.Role
38+import androidx.compose.ui.semantics.semantics
39+import androidx.compose.ui.semantics.stateDescription
40+import androidx.compose.ui.text.font.FontWeight
41+import androidx.compose.ui.text.style.TextDecoration
42+import androidx.compose.ui.unit.dp
43+import soy.iko.opencode.R
44+import soy.iko.opencode.data.model.TodoItem
45+import soy.iko.opencode.data.model.TodoStatus
46+import soy.iko.opencode.data.model.statusEnum
47+
48+/** Renders a task plan as a checklist: a status icon per row plus its description, with
49+ * completed/cancelled items struck through and the in-progress item emphasized. */
50+@Composable
51+fun TodoPlanChecklist(todos: List<TodoItem>, modifier: Modifier = Modifier) {
52+ Column(modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(4.dp)) {
53+ for (todo in todos) TodoRow(todo)
54+ }
55+}
56+
57+@Composable
58+private fun TodoRow(todo: TodoItem) {
59+ val status = todo.statusEnum()
60+ val struck = status == TodoStatus.COMPLETED || status == TodoStatus.CANCELLED
61+ Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.Top) {
62+ TodoStatusIcon(status)
63+ Text(
64+ todo.content,
65+ style = MaterialTheme.typography.bodySmall,
66+ color = when {
67+ status == TodoStatus.IN_PROGRESS -> MaterialTheme.colorScheme.primary
68+ struck -> MaterialTheme.colorScheme.onSurfaceVariant
69+ else -> MaterialTheme.colorScheme.onSurface
70+ },
71+ fontWeight = if (status == TodoStatus.IN_PROGRESS) FontWeight.Medium else null,
72+ textDecoration = if (struck) TextDecoration.LineThrough else null,
73+ modifier = Modifier.padding(start = 8.dp),
74+ )
75+ }
76+}
77+
78+@Composable
79+private fun TodoStatusIcon(status: TodoStatus) {
80+ val icon = when (status) {
81+ TodoStatus.COMPLETED -> Icons.Filled.CheckCircle
82+ TodoStatus.IN_PROGRESS -> Icons.Filled.Autorenew
83+ TodoStatus.CANCELLED -> Icons.Filled.Cancel
84+ else -> Icons.Filled.RadioButtonUnchecked
85+ }
86+ val tint = when (status) {
87+ TodoStatus.COMPLETED, TodoStatus.IN_PROGRESS -> MaterialTheme.colorScheme.primary
88+ else -> MaterialTheme.colorScheme.onSurfaceVariant
89+ }
90+ val desc = when (status) {
91+ TodoStatus.COMPLETED -> R.string.todo_completed
92+ TodoStatus.IN_PROGRESS -> R.string.todo_in_progress
93+ TodoStatus.CANCELLED -> R.string.todo_cancelled
94+ else -> R.string.todo_pending
95+ }
96+ Icon(
97+ icon,
98+ contentDescription = stringResource(desc),
99+ tint = tint,
100+ modifier = Modifier.size(16.dp),
101+ )
102+}
103+
104+/** A collapsible plan bar pinned above the composer: the header shows completed/total and
105+ * a chevron; expanding reveals the full (scrollable, height-capped) checklist. Renders
106+ * nothing when there's no plan, so the caller can place it unconditionally. */
107+@Composable
108+fun TodoPlanBar(todos: List<TodoItem>, modifier: Modifier = Modifier) {
109+ if (todos.isEmpty()) return
110+ var expanded by rememberSaveable { mutableStateOf(false) }
111+ val completed = remember(todos) { todos.count { it.statusEnum() == TodoStatus.COMPLETED } }
112+ val expandedState = stringResource(R.string.state_expanded)
113+ val collapsedState = stringResource(R.string.state_collapsed)
114+ Surface(
115+ color = MaterialTheme.colorScheme.surfaceVariant,
116+ tonalElevation = 2.dp,
117+ modifier = modifier.fillMaxWidth(),
118+ ) {
119+ Column {
120+ Row(
121+ modifier = Modifier
122+ .fillMaxWidth()
123+ .defaultMinSize(minHeight = 44.dp)
124+ .clickable(role = Role.Button) { expanded = !expanded }
125+ .semantics { stateDescription = if (expanded) expandedState else collapsedState }
126+ .padding(horizontal = 12.dp, vertical = 6.dp),
127+ verticalAlignment = Alignment.CenterVertically,
128+ ) {
129+ Icon(
130+ Icons.Filled.Checklist,
131+ contentDescription = null,
132+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
133+ modifier = Modifier.size(18.dp),
134+ )
135+ Text(
136+ stringResource(R.string.plan),
137+ style = MaterialTheme.typography.labelLarge,
138+ modifier = Modifier.padding(start = 8.dp),
139+ )
140+ Text(
141+ stringResource(R.string.plan_progress, completed, todos.size),
142+ style = MaterialTheme.typography.labelMedium,
143+ color = MaterialTheme.colorScheme.onSurfaceVariant,
144+ modifier = Modifier.padding(start = 8.dp),
145+ )
146+ Spacer(Modifier.weight(1f))
147+ Icon(
148+ if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
149+ contentDescription = null,
150+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
151+ modifier = Modifier.size(20.dp),
152+ )
153+ }
154+ AnimatedVisibility(visible = expanded) {
155+ TodoPlanChecklist(
156+ todos,
157+ modifier = Modifier
158+ .heightIn(max = 240.dp)
159+ .verticalScroll(rememberScrollState())
160+ .padding(start = 12.dp, end = 12.dp, bottom = 10.dp),
161+ )
162+ }
163+ }
164+ }
165+}
app/src/main/java/soy/iko/opencode/ui/chat/TtsController.ktnew file+144
@@ -0,0 +1,144 @@
1+package soy.iko.opencode.ui.chat
2+
3+import android.content.Context
4+import android.os.Handler
5+import android.os.Looper
6+import android.speech.tts.TextToSpeech
7+import android.speech.tts.UtteranceProgressListener
8+import androidx.compose.runtime.Composable
9+import androidx.compose.runtime.RememberObserver
10+import androidx.compose.runtime.State
11+import androidx.compose.runtime.mutableStateOf
12+import androidx.compose.runtime.remember
13+import androidx.compose.ui.platform.LocalContext
14+
15+/**
16+ * Remember a [TtsController] scoped to the current composition; it shuts the engine down
17+ * when the composition leaves (so the process isn't left holding a TextToSpeech instance).
18+ * The controller is a [RememberObserver], so an *abandoned* composition (one never committed)
19+ * also releases the engine — a plain DisposableEffect would leak it in that case.
20+ */
21+@Composable
22+fun rememberTtsController(): TtsController {
23+ val context = LocalContext.current
24+ return remember { TtsController(context.applicationContext) }
25+}
26+
27+/**
28+ * Thin wrapper over Android [TextToSpeech] for reading an assistant message aloud. Exposes
29+ * [speakingId] — the id of the message currently being spoken, or null — so the UI can show
30+ * a play/stop toggle. [toggle] plays a message or stops it if it's already the one speaking.
31+ *
32+ * The engine initializes asynchronously; calls before it's ready are dropped (best-effort —
33+ * TTS is an optional convenience). Utterance-progress callbacks arrive on a binder thread,
34+ * so state is cleared on the main thread via [mainHandler].
35+ */
36+class TtsController(context: Context) : RememberObserver {
37+
38+ private val mainHandler = Handler(Looper.getMainLooper())
39+ // Written from the TextToSpeech init callback (a binder thread) and read from the main
40+ // thread in toggle(); @Volatile guarantees the main thread observes a successful init
41+ // promptly instead of a stale false, which would silently drop the read-aloud request.
42+ @Volatile private var ready = false
43+ private var shutDown = false
44+
45+ private val _speakingId = mutableStateOf<String?>(null)
46+ val speakingId: State<String?> = _speakingId
47+
48+ private val tts: TextToSpeech = TextToSpeech(context) { status ->
49+ ready = status == TextToSpeech.SUCCESS
50+ }.apply {
51+ setOnUtteranceProgressListener(object : UtteranceProgressListener() {
52+ override fun onStart(utteranceId: String?) { /* speakingId already set by toggle() */ }
53+ override fun onDone(utteranceId: String?) = clearIfCurrent(utteranceId)
54+ @Deprecated("Deprecated in Java", ReplaceWith(""))
55+ override fun onError(utteranceId: String?) = clearIfCurrent(utteranceId)
56+ override fun onError(utteranceId: String?, errorCode: Int) = clearIfCurrent(utteranceId)
57+ })
58+ }
59+
60+ /** Clear the speaking state when the utterance that finished is the one we're tracking. */
61+ private fun clearIfCurrent(utteranceId: String?) {
62+ mainHandler.post { if (_speakingId.value == utteranceId) _speakingId.value = null }
63+ }
64+
65+ /** Speak [text] for message [id], or stop if [id] is already the one being spoken. */
66+ fun toggle(id: String, text: String) {
67+ if (_speakingId.value == id) { stop(); return }
68+ if (!ready || text.isBlank()) return
69+ // TextToSpeech.speak() silently returns ERROR (enqueuing nothing, firing no callback)
70+ // when a single input exceeds getMaxSpeechInputLength(), which would leave the Stop
71+ // button stuck with no audio. Chunk long text and enqueue the parts back-to-back.
72+ val chunks = chunkForTts(text)
73+ if (chunks.isEmpty()) return
74+ chunks.forEachIndexed { index, chunk ->
75+ val queueMode = if (index == 0) TextToSpeech.QUEUE_FLUSH else TextToSpeech.QUEUE_ADD
76+ // Only the final chunk carries the tracked [id]; intermediate chunks get a
77+ // distinct id so their per-utterance onDone doesn't clear the Stop state early.
78+ val utteranceId = if (index == chunks.lastIndex) id else "$id#$index"
79+ val result = tts.speak(chunk, queueMode, null, utteranceId)
80+ // If any chunk can't be enqueued, the final chunk's onDone (which would clear
81+ // the Stop state) never fires — leaving the Stop button stuck with partial or
82+ // no audio. Bail without marking this message as speaking so the button doesn't
83+ // stick. A mid-queue failure also breaks the contiguous playback, so stopping
84+ // is the safe default rather than playing a truncated prefix silently.
85+ if (result != TextToSpeech.SUCCESS) {
86+ if (index == 0) _speakingId.value = null
87+ else stop()
88+ return
89+ }
90+ }
91+ _speakingId.value = id
92+ }
93+
94+ /**
95+ * Split [text] into segments no longer than [TextToSpeech.getMaxSpeechInputLength] so
96+ * every part can actually be enqueued. Prefers sentence/paragraph then whitespace
97+ * boundaries, packing greedily, and hard-slices any single piece that still overflows.
98+ */
99+ private fun chunkForTts(text: String): List<String> {
100+ val max = TextToSpeech.getMaxSpeechInputLength().coerceAtLeast(1)
101+ if (text.length <= max) return listOf(text)
102+ val chunks = ArrayList<String>()
103+ val current = StringBuilder()
104+ fun flush() {
105+ if (current.isNotEmpty()) { chunks.add(current.toString()); current.setLength(0) }
106+ }
107+ for (piece in text.split(Regex("(?<=[.!?\\n])\\s+"))) {
108+ var p = piece
109+ // A single piece with no usable boundary can still exceed the limit: hard-slice it.
110+ while (p.length > max) {
111+ flush()
112+ chunks.add(p.substring(0, max))
113+ p = p.substring(max)
114+ }
115+ when {
116+ p.isEmpty() -> Unit
117+ current.isEmpty() -> current.append(p)
118+ current.length + 1 + p.length <= max -> current.append(' ').append(p)
119+ else -> { flush(); current.append(p) }
120+ }
121+ }
122+ flush()
123+ return chunks
124+ }
125+
126+ fun stop() {
127+ tts.stop()
128+ _speakingId.value = null
129+ }
130+
131+ fun shutdown() {
132+ if (shutDown) return
133+ shutDown = true
134+ tts.stop()
135+ tts.shutdown()
136+ _speakingId.value = null
137+ }
138+
139+ // RememberObserver: release the engine whenever this instance leaves the composition,
140+ // including an abandoned (never-committed) composition, which onForgotten doesn't cover.
141+ override fun onRemembered() { /* engine is created eagerly in the constructor */ }
142+ override fun onForgotten() = shutdown()
143+ override fun onAbandoned() = shutdown()
144+}
app/src/main/java/soy/iko/opencode/ui/components/ChatTextPrefs.ktnew file+51
@@ -0,0 +1,51 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.runtime.compositionLocalOf
4+import androidx.compose.material3.Typography
5+
6+/**
7+ * Chat presentation preferences, threaded through the composition so the markdown and
8+ * code renderers can honor them without every call site plumbing them explicitly.
9+ *
10+ * - [LocalChatTextScale]: a multiplier applied to chat/message/code font sizes.
11+ * - [LocalCodeWrap]: whether code blocks soft-wrap long lines (vs. horizontal scroll).
12+ *
13+ * Provided once near the app root from the persisted [soy.iko.opencode.data.repo.SettingsStore]
14+ * values. Defaults keep the design behavior when no provider is present (e.g. previews/tests).
15+ */
16+val LocalChatTextScale = compositionLocalOf { 1f }
17+val LocalCodeWrap = compositionLocalOf { false }
18+
19+/**
20+ * Return a copy of this [Typography] with every text role's font size and line height
21+ * multiplied by [scale]. Used to scale a whole markdown subtree (headings, lists, code,
22+ * body) from a single user preference. Returns the receiver unchanged when [scale] is 1.
23+ */
24+fun Typography.scaledBy(scale: Float): Typography {
25+ if (scale == 1f) return this
26+ fun androidx.compose.ui.text.TextStyle.scaled() = copy(
27+ fontSize = if (fontSize.isSp) fontSize * scale else fontSize,
28+ lineHeight = if (lineHeight.isSp) lineHeight * scale else lineHeight,
29+ )
30+ return copy(
31+ displayLarge = displayLarge.scaled(),
32+ displayMedium = displayMedium.scaled(),
33+ displaySmall = displaySmall.scaled(),
34+ headlineLarge = headlineLarge.scaled(),
35+ headlineMedium = headlineMedium.scaled(),
36+ headlineSmall = headlineSmall.scaled(),
37+ titleLarge = titleLarge.scaled(),
38+ titleMedium = titleMedium.scaled(),
39+ titleSmall = titleSmall.scaled(),
40+ bodyLarge = bodyLarge.scaled(),
41+ bodyMedium = bodyMedium.scaled(),
42+ bodySmall = bodySmall.scaled(),
43+ labelLarge = labelLarge.scaled(),
44+ labelMedium = labelMedium.scaled(),
45+ labelSmall = labelSmall.scaled(),
46+ )
47+}
48+
49+/** True when this [androidx.compose.ui.unit.TextUnit] is expressed in sp (safe to scale). */
50+private val androidx.compose.ui.unit.TextUnit.isSp: Boolean
51+ get() = type == androidx.compose.ui.unit.TextUnitType.Sp
app/src/main/java/soy/iko/opencode/ui/components/CodeHighlighter.ktnew file+286
@@ -0,0 +1,286 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.material3.MaterialTheme
4+import androidx.compose.runtime.Composable
5+import androidx.compose.runtime.remember
6+import androidx.compose.ui.graphics.Color
7+import androidx.compose.ui.text.AnnotatedString
8+import androidx.compose.ui.text.SpanStyle
9+import androidx.compose.ui.text.font.FontFamily
10+import androidx.compose.ui.text.font.FontStyle
11+import androidx.compose.ui.text.font.FontWeight
12+import androidx.compose.ui.text.withStyle
13+
14+/**
15+ * Lightweight, dependency-free syntax highlighting for the file viewer.
16+ *
17+ * A full tokenizer (TreeSitter, Prism, etc.) would add a heavy native or generated
18+ * dependency that conflicts with the project's no-KSP / no-native-deps stance. This
19+ * heuristic highlighter instead colors comments, strings, numbers, and a per-language
20+ * keyword set via simple character/word scanning — enough to make a code file scannable
21+ * in the viewer without pulling in a parser.
22+ *
23+ * The highlighter is per-line (the viewer renders line-by-line), so multi-line block
24+ * comments and triple-quoted strings are not handled; that's an accepted trade-off for
25+ * a viewer (not an editor) where the goal is visual orientation, not correctness.
26+ */
27+
28+/** Language families recognized by [highlightLine]. The set of keywords is shared
29+ * across C-family languages (kotlin, java, js, ts, swift, rust, go, c, cpp) since the
30+ * common keywords overlap heavily; python has its own set; shell has its own. */
31+internal enum class Language {
32+ C_FAMILY, // kotlin, java, js, ts, swift, rust, go, c, cpp, csharp, scala
33+ PYTHON,
34+ SHELL, // sh, bash, zsh
35+ MARKUP, // html, xml, svg
36+ NONE,
37+}
38+
39+private fun languageFor(filename: String): Language {
40+ val ext = filename.substringAfterLast('.', missingDelimiterValue = "").lowercase()
41+ return when (ext) {
42+ "kt", "kts", "java", "js", "mjs", "cjs", "ts", "tsx",
43+ "swift", "rs", "go", "c", "h", "cpp", "cc", "cxx", "hpp", "hh",
44+ "cs", "scala", "groovy", "dart", "gradle",
45+ -> Language.C_FAMILY
46+ "py", "pyw", "rb" -> Language.PYTHON
47+ "sh", "bash", "zsh", "fish" -> Language.SHELL
48+ "html", "htm", "xml", "svg", "vue" -> Language.MARKUP
49+ else -> Language.NONE
50+ }
51+}
52+
53+private val cFamilyKeywords = setOf(
54+ "abstract", "as", "assert", "async", "await", "break", "case", "catch", "class",
55+ "const", "continue", "default", "defer", "do", "else", "enum", "export",
56+ "extends", "external", "final", "finally", "fn", "for", "func", "fun", "go",
57+ "if", "impl", "implements", "import", "in", "init", "inline", "instanceof", "is",
58+ "let", "match", "mod", "module", "mut", "namespace", "new", "object", "of",
59+ "override", "package", "private", "protected", "public", "pub", "raise", "return",
60+ "sealed", "self", "static", "struct", "super", "suspend", "switch", "this", "throw",
61+ "throws", "trait", "try", "type", "typeof", "union", "unsafe", "val", "var", "when",
62+ "where", "while", "with", "yield", "true", "false", "null", "nil",
63+ "and", "or", "not", "lambda", "elif", "pass", "print",
64+)
65+
66+private val pythonKeywords = setOf(
67+ "False", "None", "True", "and", "as", "assert", "async", "await", "break", "class",
68+ "continue", "def", "del", "elif", "else", "except", "finally", "for", "from",
69+ "global", "if", "import", "in", "is", "lambda", "nonlocal", "not", "or", "pass",
70+ "raise", "return", "try", "while", "with", "yield", "self", "cls", "print",
71+)
72+
73+private val shellKeywords = setOf(
74+ "if", "then", "else", "elif", "fi", "for", "do", "done", "while", "case", "esac",
75+ "function", "in", "return", "exit", "echo", "export", "local", "readonly", "unset",
76+ "set", "shift", "source", "true", "false", "cd", "pwd", "ls", "cp", "mv", "rm",
77+)
78+
79+/** Theme-derived colors for the highlighter. Captured in a @Composable so the styles
80+ * re-resolve when the color scheme changes (e.g. dynamic color or a theme switch). */
81+data class HighlightPalette(
82+ val keyword: Color,
83+ val comment: Color,
84+ val string: Color,
85+ val number: Color,
86+ val annotation: Color,
87+ val tag: Color,
88+ val base: Color,
89+)
90+
91+@Composable
92+fun rememberHighlightPalette(): HighlightPalette {
93+ // Use muted theme roles so the highlighting reads as part of the UI, not a foreign
94+ // palette. Keyword = primary, string = secondary, comment = outline (dimmed),
95+ // number = tertiary. Falls back gracefully when dynamic color is on (the roles
96+ // come from the wallpaper-derived scheme).
97+ val scheme = MaterialTheme.colorScheme
98+ return remember(scheme) {
99+ HighlightPalette(
100+ keyword = scheme.primary,
101+ comment = scheme.outline,
102+ string = scheme.secondary,
103+ number = scheme.tertiary,
104+ annotation = scheme.tertiary,
105+ tag = scheme.primary,
106+ base = scheme.onSurface,
107+ )
108+ }
109+}
110+
111+/**
112+ * Produce a highlighted [AnnotatedString] for a single line of source code.
113+ *
114+ * The scanner walks the line character by character, classifying runs of chars as
115+ * comments, strings, numbers, identifiers (checked against the keyword set), or plain
116+ * text. It's O(n) per line and allocation-light (a single AnnotatedString.Builder),
117+ * which matters because the file viewer renders lazily but can scroll through
118+ * thousands of lines — a heavy highlighter would jank on fast scrolls.
119+ */
120+fun highlightLine(line: String, filename: String, palette: HighlightPalette): AnnotatedString =
121+ highlightLine(line, syntaxFor(filename), palette)
122+
123+/**
124+ * A file's language classification, resolved once from its name. Resolving this per file
125+ * (instead of re-parsing the extension inside [highlightLine] for every line) keeps the
126+ * hot per-line highlight path free of the `substringAfterLast`/`lowercase` work.
127+ */
128+class FileSyntax internal constructor(
129+ internal val lang: Language,
130+ internal val keywords: Set<String>,
131+)
132+
133+/** Resolve the [FileSyntax] for a filename. Cheap; call once per file and reuse. */
134+fun syntaxFor(filename: String): FileSyntax {
135+ val lang = languageFor(filename)
136+ return FileSyntax(lang, keywordsFor(lang))
137+}
138+
139+/** Highlight a single line using a pre-resolved [FileSyntax]. See [FileSyntax]. */
140+fun highlightLine(line: String, syntax: FileSyntax, palette: HighlightPalette): AnnotatedString {
141+ val lang = syntax.lang
142+ if (lang == Language.NONE) return AnnotatedString(line)
143+ val keywords = syntax.keywords
144+ val builder = AnnotatedString.Builder(line.length + 8)
145+ val baseStyle = SpanStyle(color = palette.base, fontFamily = FontFamily.Monospace)
146+ val mono = FontFamily.Monospace
147+ var i = 0
148+ val n = line.length
149+ while (i < n) {
150+ val c = line[i]
151+ val emitted = emitToken(builder, line, i, lang, keywords, palette, baseStyle, mono)
152+ if (emitted.consumed) {
153+ i = emitted.nextIndex
154+ } else {
155+ builder.withStyle(baseStyle) { append(c) }
156+ i++
157+ }
158+ }
159+ return builder.toAnnotatedString()
160+}
161+
162+/** Result of attempting to emit one token starting at [start]: the next scan index and
163+ * whether a token was consumed (vs. falling through to the default plain-text emit). */
164+private data class EmitResult(val nextIndex: Int, val consumed: Boolean)
165+
166+private fun emitToken(
167+ builder: AnnotatedString.Builder,
168+ line: String,
169+ start: Int,
170+ lang: Language,
171+ keywords: Set<String>,
172+ palette: HighlightPalette,
173+ baseStyle: SpanStyle,
174+ mono: FontFamily,
175+): EmitResult {
176+ val c = line[start]
177+ if (isLineCommentStart(c, line, start, lang)) {
178+ builder.withStyle(SpanStyle(color = palette.comment, fontStyle = FontStyle.Italic, fontFamily = mono)) {
179+ append(line.substring(start))
180+ }
181+ return EmitResult(line.length, true)
182+ }
183+ if (c == '"' || c == '\'') {
184+ val end = findStringEnd(line, start, c)
185+ builder.withStyle(SpanStyle(color = palette.string, fontFamily = mono)) { append(line.substring(start, end)) }
186+ return EmitResult(end, true)
187+ }
188+ if (isNumberStart(c, line, start)) {
189+ val end = findNumberEnd(line, start)
190+ builder.withStyle(SpanStyle(color = palette.number, fontFamily = mono)) { append(line.substring(start, end)) }
191+ return EmitResult(end, true)
192+ }
193+ if (c.isLetter() || c == '_' || c == '@') {
194+ val end = findIdentifierEnd(line, start)
195+ val word = line.substring(start, end)
196+ when {
197+ c == '@' -> builder.withStyle(SpanStyle(color = palette.annotation, fontFamily = mono)) { append(word) }
198+ word in keywords -> builder.withStyle(SpanStyle(color = palette.keyword, fontWeight = FontWeight.SemiBold, fontFamily = mono)) { append(word) }
199+ else -> builder.withStyle(baseStyle) { append(word) }
200+ }
201+ return EmitResult(end, true)
202+ }
203+ if (lang == Language.MARKUP && isMarkupDelim(c)) {
204+ builder.withStyle(SpanStyle(color = palette.tag, fontFamily = mono)) { append(c) }
205+ return EmitResult(start + 1, true)
206+ }
207+ return EmitResult(start + 1, false)
208+}
209+
210+private fun keywordsFor(lang: Language): Set<String> = when (lang) {
211+ Language.C_FAMILY -> cFamilyKeywords
212+ Language.PYTHON -> pythonKeywords
213+ Language.SHELL -> shellKeywords
214+ Language.MARKUP -> emptySet()
215+ Language.NONE -> emptySet()
216+}
217+
218+private fun isLineCommentStart(c: Char, line: String, i: Int, lang: Language): Boolean =
219+ // `//` is a line comment only in C-family languages. Elsewhere it isn't: e.g. Python's
220+ // `a // b` is floor division, which must not be dimmed as a comment to end-of-line.
221+ (c == '/' && lang == Language.C_FAMILY && i + 1 < line.length && line[i + 1] == '/') ||
222+ // `#` is a line comment only in Python and shell. It is NOT one in C-family
223+ // (`#include`, `#define`, C# `#region`, JS/TS `this.#field`) or markup.
224+ (c == '#' && (lang == Language.PYTHON || lang == Language.SHELL))
225+
226+private fun isMarkupDelim(c: Char): Boolean = c == '<' || c == '>' || c == '/' || c == '='
227+
228+private fun isNumberStart(c: Char, line: String, i: Int): Boolean =
229+ c.isDigit() || (c == '.' && i + 1 < line.length && line[i + 1].isDigit())
230+
231+/** Scan forward to the end of a string literal starting at [start] (the opening quote
232+ * is line[start]). Returns the index just past the closing quote (or line.length if
233+ * the string is unterminated on this line). Honors backslash escapes. */
234+private fun findStringEnd(line: String, start: Int, quote: Char): Int {
235+ var i = start + 1
236+ val n = line.length
237+ while (i < n && line[i] != quote) {
238+ if (line[i] == '\\' && i + 1 < n) i += 2 else i++
239+ }
240+ return if (i < n) i + 1 else n
241+}
242+
243+/** Scan forward to the end of a number literal starting at [start].
244+ *
245+ * A small state machine so we don't over-consume neighbouring source: `5-3`, `1..10`
246+ * and `123abc` must split rather than merge into one token.
247+ * - `+`/`-` are consumed only immediately after an `e`/`E` (an exponent sign).
248+ * - `e`/`E` continues a decimal number at most once, and never after a `0x` prefix.
249+ * - hex digits `a-f`/`A-F` and the `x`/`X` marker are consumed only for a `0x`/`0X` literal.
250+ * - a single `.` continues a decimal number; a second `.` stops the scan (so `1..10` splits).
251+ */
252+private fun findNumberEnd(line: String, start: Int): Int {
253+ val n = line.length
254+ // Detect a hex prefix ("0x"/"0X") right at the number start.
255+ val hex = start + 1 < n && line[start] == '0' && (line[start + 1] == 'x' || line[start + 1] == 'X')
256+ var i = if (hex) start + 2 else start + 1
257+ var seenDot = line[start] == '.'
258+ var seenExp = false
259+ while (i < n) {
260+ val c = line[i]
261+ val prev = line[i - 1]
262+ when {
263+ c.isDigit() || c == '_' -> i++
264+ hex && isHexDigit(c) -> i++
265+ c == '.' && !hex && !seenDot && !seenExp -> { seenDot = true; i++ }
266+ !hex && !seenExp && (c == 'e' || c == 'E') -> { seenExp = true; i++ }
267+ !hex && (c == '+' || c == '-') && (prev == 'e' || prev == 'E') -> i++
268+ else -> break
269+ }
270+ }
271+ return i
272+}
273+
274+private fun isHexDigit(c: Char): Boolean = c in 'a'..'f' || c in 'A'..'F'
275+
276+/** Scan forward to the end of an identifier starting at [start]. */
277+private fun findIdentifierEnd(line: String, start: Int): Int {
278+ var i = start
279+ if (line[i] == '@') i++
280+ val n = line.length
281+ while (i < n && isIdentifierPartChar(line[i])) i++
282+ return i
283+}
284+
285+private fun isIdentifierPartChar(c: Char): Boolean =
286+ c.isLetterOrDigit() || c == '_' || c == '$'
app/src/main/java/soy/iko/opencode/ui/components/CommandPalette.ktnew file+102
@@ -0,0 +1,102 @@
1+package soy.iko.opencode.ui.components
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.material3.MaterialTheme
11+import androidx.compose.material3.OutlinedTextField
12+import androidx.compose.material3.Surface
13+import androidx.compose.material3.Text
14+import androidx.compose.runtime.Composable
15+import androidx.compose.runtime.LaunchedEffect
16+import androidx.compose.runtime.getValue
17+import androidx.compose.runtime.mutableStateOf
18+import androidx.compose.runtime.remember
19+import androidx.compose.runtime.setValue
20+import androidx.compose.ui.Modifier
21+import androidx.compose.ui.focus.focusRequester
22+import androidx.compose.ui.input.key.Key
23+import androidx.compose.ui.input.key.KeyEventType
24+import androidx.compose.ui.input.key.key
25+import androidx.compose.ui.input.key.onPreviewKeyEvent
26+import androidx.compose.ui.input.key.type
27+import androidx.compose.ui.res.stringResource
28+import androidx.compose.ui.unit.dp
29+import androidx.compose.ui.window.Dialog
30+import soy.iko.opencode.R
31+
32+/**
33+ * A keyboard-first command palette: a search field over a list of [actions], filtered as the
34+ * user types. Enter runs the first match, tap runs any entry; the field auto-focuses so a
35+ * hardware-keyboard user can type immediately after opening it. Dismissed via back/scrim, or
36+ * Escape (handled by the opener). Selecting an action dismisses first, then runs it, so a
37+ * follow-on dialog isn't immediately covered by the palette.
38+ */
39+@Composable
40+fun CommandPalette(
41+ actions: List<PaletteAction>,
42+ onDismiss: () -> Unit,
43+) {
44+ var query by remember { mutableStateOf("") }
45+ val filtered = remember(query, actions) {
46+ if (query.isBlank()) actions else actions.filter { it.label.contains(query, ignoreCase = true) }
47+ }
48+ val focus = remember { androidx.compose.ui.focus.FocusRequester() }
49+ fun run(action: PaletteAction) {
50+ onDismiss()
51+ action.onSelect()
52+ }
53+ Dialog(onDismissRequest = onDismiss) {
54+ Surface(
55+ shape = MaterialTheme.shapes.large,
56+ tonalElevation = 6.dp,
57+ color = MaterialTheme.colorScheme.surface,
58+ ) {
59+ Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) {
60+ OutlinedTextField(
61+ value = query,
62+ onValueChange = { query = it },
63+ placeholder = { Text(stringResource(R.string.palette_hint)) },
64+ singleLine = true,
65+ modifier = Modifier
66+ .fillMaxWidth()
67+ .focusRequester(focus)
68+ .onPreviewKeyEvent { ev ->
69+ if (ev.type == KeyEventType.KeyDown && ev.key == Key.Enter) {
70+ filtered.firstOrNull()?.let { run(it) }
71+ true
72+ } else {
73+ false
74+ }
75+ },
76+ )
77+ LaunchedEffect(Unit) { runCatching { focus.requestFocus() } }
78+ if (filtered.isEmpty()) {
79+ Text(
80+ stringResource(R.string.palette_no_results),
81+ style = MaterialTheme.typography.bodyMedium,
82+ color = MaterialTheme.colorScheme.onSurfaceVariant,
83+ modifier = Modifier.padding(16.dp),
84+ )
85+ } else {
86+ LazyColumn(modifier = Modifier.heightIn(max = 360.dp)) {
87+ items(filtered, key = { it.id }) { action ->
88+ Text(
89+ action.label,
90+ style = MaterialTheme.typography.bodyLarge,
91+ modifier = Modifier
92+ .fillMaxWidth()
93+ .clickable { run(action) }
94+ .padding(horizontal = 12.dp, vertical = 14.dp),
95+ )
96+ }
97+ }
98+ }
99+ }
100+ }
101+ }
102+}
app/src/main/java/soy/iko/opencode/ui/components/ConnectionBanner.ktnew file+121
@@ -0,0 +1,121 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.animation.AnimatedVisibility
4+import androidx.compose.animation.fadeIn
5+import androidx.compose.animation.fadeOut
6+import androidx.compose.foundation.layout.Arrangement
7+import androidx.compose.foundation.layout.Row
8+import androidx.compose.foundation.layout.fillMaxWidth
9+import androidx.compose.foundation.layout.padding
10+import androidx.compose.foundation.layout.size
11+import androidx.compose.material3.CircularProgressIndicator
12+import androidx.compose.material3.MaterialTheme
13+import androidx.compose.material3.Surface
14+import androidx.compose.material3.Text
15+import androidx.compose.material3.TextButton
16+import androidx.compose.runtime.Composable
17+import androidx.compose.runtime.remember
18+import androidx.compose.runtime.mutableStateOf
19+import androidx.compose.ui.Alignment
20+import androidx.compose.ui.Modifier
21+import androidx.compose.ui.res.stringResource
22+import androidx.compose.ui.semantics.contentDescription
23+import androidx.compose.ui.semantics.liveRegion
24+import androidx.compose.ui.semantics.LiveRegionMode
25+import androidx.compose.ui.semantics.semantics
26+import androidx.compose.ui.unit.dp
27+import soy.iko.opencode.R
28+import soy.iko.opencode.data.network.EventStreamClient
29+
30+/**
31+ * Banner shown when the SSE event stream is not connected. Shared by the chat and the
32+ * session list so a dropped stream is visible everywhere, not just mid-conversation.
33+ *
34+ * On a hard failure ([ConnectionState.Failed]) an inline "Retry now" button is offered
35+ * when [onRetry] is supplied, so the user can re-connect without hunting for the
36+ * chat screen's reconnect button or navigating to the server list. Transient
37+ * connecting/reconnecting states remain non-interactive (the system is already trying).
38+ */
39+@Composable
40+fun ConnectionBanner(
41+ state: EventStreamClient.ConnectionState,
42+ modifier: Modifier = Modifier,
43+ isOnline: Boolean = true,
44+ onRetry: (() -> Unit)? = null,
45+) {
46+ // When the device itself has no connectivity, surface that distinctly (instead of
47+ // the SSE state) — a dropped Wi-Fi shouldn't read "Reconnecting…" or "check
48+ // credentials", neither of which reflects the real problem.
49+ val text = if (!isOnline) {
50+ stringResource(R.string.offline)
51+ } else {
52+ when (state) {
53+ EventStreamClient.ConnectionState.Connecting -> stringResource(R.string.connecting)
54+ EventStreamClient.ConnectionState.Disconnected -> stringResource(R.string.reconnecting)
55+ EventStreamClient.ConnectionState.Failed -> stringResource(R.string.connection_failed)
56+ EventStreamClient.ConnectionState.Connected -> null
57+ }
58+ }
59+ val isOffline = !isOnline
60+ // AnimatedVisibility so the banner slides/fades in and out instead of appearing
61+ // and disappearing instantly — a state change that's especially jarring when the
62+ // connection flaps, and was called out as a rough edge in the UX audit.
63+ // Retain the last non-null banner text so the exit fade-out has content to render.
64+ // The content lambda recomposes with the now-null `text` during the exit animation;
65+ // early-returning on null there would leave nothing to fade, so the banner would just
66+ // vanish instead of fading out.
67+ val lastText = remember { mutableStateOf(text) }
68+ if (text != null) lastText.value = text
69+ AnimatedVisibility(visible = text != null, enter = fadeIn(), exit = fadeOut()) {
70+ val shown = lastText.value ?: return@AnimatedVisibility
71+ // Distinguish a hard failure (e.g. bad credentials) and an offline device from
72+ // transient connecting/reconnecting states by switching to the error palette,
73+ // so the banner conveys urgency without relying on text alone.
74+ val isFailed = isOffline || state == EventStreamClient.ConnectionState.Failed
75+ val container = if (isFailed) MaterialTheme.colorScheme.errorContainer
76+ else MaterialTheme.colorScheme.tertiaryContainer
77+ val onContainer = if (isFailed) MaterialTheme.colorScheme.onErrorContainer
78+ else MaterialTheme.colorScheme.onTertiaryContainer
79+ Surface(
80+ modifier = modifier
81+ .fillMaxWidth()
82+ .semantics {
83+ // Announce connection state changes to TalkBack users so they're
84+ // aware the stream dropped/reconnecting without visual cues.
85+ liveRegion = LiveRegionMode.Polite
86+ contentDescription = shown
87+ },
88+ color = container,
89+ ) {
90+ Row(
91+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp, horizontal = 8.dp),
92+ horizontalArrangement = Arrangement.Center,
93+ verticalAlignment = Alignment.CenterVertically,
94+ ) {
95+ if (!isFailed) {
96+ CircularProgressIndicator(
97+ Modifier.size(16.dp),
98+ strokeWidth = 2.dp,
99+ )
100+ }
101+ Text(
102+ shown,
103+ style = MaterialTheme.typography.labelMedium,
104+ color = onContainer,
105+ modifier = Modifier
106+ .padding(start = 6.dp)
107+ .weight(1f, fill = false),
108+ )
109+ // Inline retry on the Failed banner so the user can recover without
110+ // leaving the screen. Hidden during transient states (the system is
111+ // already reconnecting), when offline (retry can't help), and when no
112+ // callback is wired (callers that don't have a reconnect path).
113+ if (state == EventStreamClient.ConnectionState.Failed && !isOffline && onRetry != null) {
114+ TextButton(onClick = onRetry, contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 8.dp)) {
115+ Text(stringResource(R.string.retry_now), color = onContainer)
116+ }
117+ }
118+ }
119+ }
120+ }
121+}
app/src/main/java/soy/iko/opencode/ui/components/DiffView.ktnew file+222
@@ -0,0 +1,222 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.foundation.background
4+import androidx.compose.foundation.horizontalScroll
5+import androidx.compose.foundation.layout.Arrangement
6+import androidx.compose.foundation.layout.Column
7+import androidx.compose.foundation.layout.Row
8+import androidx.compose.foundation.layout.Spacer
9+import androidx.compose.foundation.layout.fillMaxWidth
10+import androidx.compose.foundation.layout.height
11+import androidx.compose.foundation.layout.padding
12+import androidx.compose.foundation.layout.width
13+import androidx.compose.foundation.rememberScrollState
14+import androidx.compose.material.icons.Icons
15+import androidx.compose.material.icons.filled.ContentCopy
16+import androidx.compose.material3.Icon
17+import androidx.compose.material3.IconButton
18+import androidx.compose.material3.MaterialTheme
19+import androidx.compose.material3.Text
20+import androidx.compose.material3.TextButton
21+import androidx.compose.runtime.Composable
22+import androidx.compose.runtime.getValue
23+import androidx.compose.runtime.mutableStateOf
24+import androidx.compose.runtime.remember
25+import androidx.compose.runtime.saveable.rememberSaveable
26+import androidx.compose.runtime.setValue
27+import androidx.compose.ui.Alignment
28+import androidx.compose.ui.Modifier
29+import androidx.compose.ui.draw.clip
30+import androidx.compose.ui.graphics.Color
31+import androidx.compose.ui.platform.LocalContext
32+import androidx.compose.ui.text.font.FontFamily
33+import androidx.compose.ui.unit.dp
34+import androidx.compose.ui.res.stringResource
35+import soy.iko.opencode.R
36+
37+/** A single line of a parsed unified diff. */
38+sealed interface DiffLine {
39+ val text: String
40+
41+ data class Hunk(override val text: String) : DiffLine // @@ ... @@
42+ data class FileHeader(override val text: String) : DiffLine // --- / +++ / diff / index
43+ data class Context(override val text: String) : DiffLine
44+ data class Add(override val text: String) : DiffLine
45+ data class Remove(override val text: String) : DiffLine
46+ data class Meta(override val text: String) : DiffLine // git metadata (new file mode, etc.)
47+}
48+
49+/** Git metadata line prefixes that aren't part of the diff content. */
50+private val gitMetaPrefixes = listOf(
51+ "new file mode ", "deleted file mode ", "old mode ", "new mode ",
52+ "similarity index ", "dissimilarity index ", "rename from ", "rename to ",
53+ "copy from ", "copy to ", "Binary files ", "\\ No newline",
54+)
55+
56+/** Does [raw] look like a real unified-diff file header ("--- a/…", "+++ b/…", "--- /dev/null")
57+ * rather than a removed/added content line that merely starts with "--- "/"+++ "? */
58+private fun isDiffFileHeader(raw: String): Boolean {
59+ val rest = when {
60+ raw.startsWith("--- ") -> raw.substring(4)
61+ raw.startsWith("+++ ") -> raw.substring(4)
62+ else -> return false
63+ }
64+ // Header paths are "a/…", "b/…", or the literal "/dev/null" (optionally followed by a
65+ // tab/space-separated timestamp). Restricting the absolute-path case to /dev/null avoids
66+ // misclassifying a removed content line like "-- /usr/local/foo" (on the wire
67+ // "--- /usr/local/foo"), whose rest starts with "/", as a file header.
68+ return rest.startsWith("a/") || rest.startsWith("b/") ||
69+ rest == "/dev/null" || rest.startsWith("/dev/null\t") || rest.startsWith("/dev/null ")
70+}
71+
72+/** Parse a unified diff string into typed [DiffLine]s. */
73+fun parseDiff(diff: String): List<DiffLine> {
74+ val result = mutableListOf<DiffLine>()
75+ for (raw in diff.lineSequence()) {
76+ when {
77+ raw.startsWith("@@") -> result.add(DiffLine.Hunk(raw))
78+ // Unified-diff file headers are "--- <path>" / "+++ <path>" where the path is
79+ // "a/…", "b/…", or an absolute path (e.g. "/dev/null"). Requiring that the marker
80+ // be followed by "a/", "b/", or "/" avoids misclassifying removed/added *content*
81+ // lines like "-- TODO"/"++ x" — which appear on the wire as "--- TODO"/"+++ x" —
82+ // as headers; those fall through to the +/- branches below.
83+ isDiffFileHeader(raw) -> result.add(DiffLine.FileHeader(raw))
84+ raw.startsWith("+") -> result.add(DiffLine.Add(raw.removePrefix("+")))
85+ raw.startsWith("-") -> result.add(DiffLine.Remove(raw.removePrefix("-")))
86+ raw.startsWith(" ") -> result.add(DiffLine.Context(raw.removePrefix(" ")))
87+ raw.startsWith("diff ") || raw.startsWith("index ") -> result.add(DiffLine.FileHeader(raw))
88+ gitMetaPrefixes.any { raw.startsWith(it) } -> result.add(DiffLine.Meta(raw))
89+ raw.isNotBlank() -> result.add(DiffLine.Context(raw))
90+ }
91+ }
92+ return result
93+}
94+
95+/** Heuristic: does this string look like a unified diff? */
96+fun looksLikeDiff(text: String): Boolean {
97+ val lines = text.lineSequence()
98+ var hunkCount = 0
99+ var addCount = 0
100+ var removeCount = 0
101+ for (line in lines) {
102+ when {
103+ line.startsWith("@@") -> hunkCount++
104+ line.startsWith("+") && !line.startsWith("+++") -> addCount++
105+ line.startsWith("-") && !line.startsWith("---") -> removeCount++
106+ }
107+ if (hunkCount >= 1 && (addCount + removeCount) >= 1) return true
108+ }
109+ return false
110+}
111+
112+private const val COLLAPSED_DIFF_LINES = 200
113+
114+/**
115+ * Renders a parsed unified diff. Implemented as a plain (non-lazy) [Column] so it is
116+ * safe to embed inside another vertically scrolling container (e.g. the chat message
117+ * list) — a nested `LazyColumn` would crash with an unbounded-height constraint.
118+ * Callers that want the view to scroll on its own (e.g. the file viewer) should pass a
119+ * [Modifier.verticalScroll] in [modifier].
120+ *
121+ * To avoid composing thousands of [Text] nodes at once (which can ANR/OOM on large
122+ * diffs), only the first [COLLAPSED_DIFF_LINES] lines are rendered unless the user
123+ * expands the view.
124+ */
125+@Composable
126+fun DiffView(diff: String, modifier: Modifier = Modifier) {
127+ val lines = remember(diff) { parseDiff(diff) }
128+ // Derive the four row colors once per color scheme instead of allocating fresh Color
129+ // objects (the .copy() calls) on every recomposition — a diff re-parses/recomposes
130+ // per token while its output streams under the collapse limit.
131+ val scheme = MaterialTheme.colorScheme
132+ val addColor = remember(scheme) { scheme.primary.copy(alpha = 0.15f) }
133+ val removeColor = remember(scheme) { scheme.error.copy(alpha = 0.15f) }
134+ val addText = scheme.primary
135+ val removeText = scheme.error
136+ val context = LocalContext.current
137+ val hScrollState = rememberScrollState()
138+ var expanded by rememberSaveable(diff) { mutableStateOf(false) }
139+
140+ Column(
141+ modifier = modifier
142+ .fillMaxWidth()
143+ .clip(MaterialTheme.shapes.small)
144+ .background(MaterialTheme.colorScheme.surfaceVariant),
145+ ) {
146+ Row(
147+ modifier = Modifier.fillMaxWidth().padding(top = 2.dp, end = 4.dp),
148+ horizontalArrangement = Arrangement.End,
149+ ) {
150+ IconButton(onClick = { copyToClipboard(context, "diff", diff) }) {
151+ Icon(Icons.Filled.ContentCopy, contentDescription = stringResource(R.string.copy), tint = MaterialTheme.colorScheme.onSurfaceVariant)
152+ }
153+ }
154+ val visibleLines = if (lines.size <= COLLAPSED_DIFF_LINES || expanded) lines
155+ else lines.subList(0, COLLAPSED_DIFF_LINES)
156+ // One horizontalScroll on the container instead of one per row: each modifier
157+ // adds a layout node + clip + offset pass, so a 200-line collapsed diff was
158+ // paying for 200 of them. The shared scroll state still synchronizes all rows.
159+ Column(modifier = Modifier.horizontalScroll(hScrollState)) {
160+ visibleLines.forEach { line ->
161+ Row(modifier = Modifier.padding(horizontal = 10.dp)) {
162+ when (line) {
163+ is DiffLine.Hunk -> Text(
164+ line.text,
165+ style = MaterialTheme.typography.bodySmall,
166+ fontFamily = FontFamily.Monospace,
167+ color = MaterialTheme.colorScheme.tertiary,
168+ modifier = Modifier.padding(vertical = 2.dp),
169+ )
170+ is DiffLine.FileHeader -> Text(
171+ line.text,
172+ style = MaterialTheme.typography.bodySmall,
173+ fontFamily = FontFamily.Monospace,
174+ color = MaterialTheme.colorScheme.onSurfaceVariant,
175+ modifier = Modifier.padding(vertical = 1.dp),
176+ )
177+ is DiffLine.Meta -> Text(
178+ line.text,
179+ style = MaterialTheme.typography.bodySmall,
180+ fontFamily = FontFamily.Monospace,
181+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
182+ modifier = Modifier.padding(vertical = 1.dp),
183+ )
184+ is DiffLine.Add -> DiffRow(line.text, "+", addColor, addText)
185+ is DiffLine.Remove -> DiffRow(line.text, "-", removeColor, removeText)
186+ is DiffLine.Context -> DiffRow(line.text, " ", Color.Transparent, MaterialTheme.colorScheme.onSurface)
187+ }
188+ }
189+ }
190+ }
191+ if (lines.size > COLLAPSED_DIFF_LINES && !expanded) {
192+ TextButton(
193+ onClick = { expanded = true },
194+ contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 10.dp),
195+ ) {
196+ Text(stringResource(R.string.show_more_lines, lines.size - COLLAPSED_DIFF_LINES))
197+ }
198+ }
199+ Spacer(Modifier.height(10.dp))
200+ }
201+}
202+
203+@Composable
204+private fun DiffRow(text: String, prefix: String, bg: Color, textColor: androidx.compose.ui.graphics.Color) {
205+ Row(
206+ modifier = Modifier.background(bg),
207+ ) {
208+ Text(
209+ prefix,
210+ style = MaterialTheme.typography.bodySmall,
211+ fontFamily = FontFamily.Monospace,
212+ color = textColor,
213+ modifier = Modifier.width(16.dp),
214+ )
215+ Text(
216+ text,
217+ style = MaterialTheme.typography.bodySmall,
218+ fontFamily = FontFamily.Monospace,
219+ color = textColor,
220+ )
221+ }
222+}
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.ktnew file+296
@@ -0,0 +1,296 @@
1+package soy.iko.opencode.ui.components
2+
3+import android.content.ClipData
4+import android.content.ClipboardManager
5+import android.content.Context
6+import android.widget.Toast
7+import androidx.compose.foundation.background
8+import androidx.compose.foundation.horizontalScroll
9+import androidx.compose.foundation.layout.Arrangement
10+import androidx.compose.foundation.layout.Box
11+import androidx.compose.foundation.layout.Column
12+import androidx.compose.foundation.layout.Row
13+import androidx.compose.foundation.layout.fillMaxWidth
14+import androidx.compose.foundation.layout.padding
15+import androidx.compose.foundation.layout.size
16+import androidx.compose.foundation.rememberScrollState
17+import androidx.compose.foundation.text.selection.SelectionContainer
18+import androidx.compose.material.icons.Icons
19+import androidx.compose.material.icons.filled.ContentCopy
20+import androidx.compose.material.icons.automirrored.filled.WrapText
21+import androidx.compose.material3.Icon
22+import androidx.compose.material3.IconButton
23+import androidx.compose.material3.MaterialTheme
24+import androidx.compose.material3.Surface
25+import androidx.compose.material3.Text
26+import androidx.compose.material3.Typography
27+import androidx.compose.runtime.Composable
28+import androidx.compose.runtime.LaunchedEffect
29+import androidx.compose.runtime.getValue
30+import androidx.compose.runtime.mutableStateOf
31+import androidx.compose.runtime.remember
32+import androidx.compose.runtime.rememberUpdatedState
33+import androidx.compose.runtime.setValue
34+import androidx.compose.runtime.snapshotFlow
35+import androidx.compose.ui.Alignment
36+import androidx.compose.ui.Modifier
37+import androidx.compose.ui.draw.clip
38+import androidx.compose.ui.platform.LocalContext
39+import androidx.compose.ui.text.TextStyle
40+import androidx.compose.ui.text.font.FontFamily
41+import androidx.compose.ui.unit.dp
42+import kotlinx.coroutines.flow.conflate
43+import kotlinx.coroutines.delay
44+import com.mikepenz.markdown.compose.components.MarkdownComponentModel
45+import com.mikepenz.markdown.compose.components.markdownComponents
46+import com.mikepenz.markdown.m3.Markdown
47+import org.intellij.markdown.MarkdownElementTypes
48+import org.intellij.markdown.ast.getTextInNode
49+import soy.iko.opencode.R
50+import soy.iko.opencode.data.network.NetworkConfig
51+
52+/**
53+ * Renders markdown using [multiplatform-markdown-renderer](https://github.com/mikepenz/multiplatform-markdown-renderer)
54+ * (commonmark-java under the hood). Keeps a local API so callers don't import the library directly.
55+ * Code blocks/fences get an inline copy button.
56+ *
57+ * The rendered text is wrapped in a [SelectionContainer] so the user can select and
58+ * copy a portion of the response (e.g. a single code snippet or paragraph) instead of
59+ * the all-or-nothing long-press copy. The per-message copy button in [MessageBubble]
60+ * copies all TextParts; this complements it with partial selection.
61+ *
62+ * During streaming, the full markdown is re-parsed on every token (the library re-parses
63+ * whenever the content string changes). To avoid O(n²) work during long responses, the
64+ * rendered content is throttled — the latest [markdown] is committed to the renderer at
65+ * most once every ~50ms, so a burst of tokens coalesces into a single re-parse.
66+ *
67+ * The throttle uses a single long-lived `LaunchedEffect(Unit)` that observes the markdown
68+ * parameter via `rememberUpdatedState` + `snapshotFlow` + `conflate` + `collect`. A keyed
69+ * effect (`LaunchedEffect(markdown)`) cancels and restarts on every token; if tokens arrive
70+ * faster than the throttle delay, the in-flight `delay` is cancelled before it completes,
71+ * starving the render and showing stale content until the stream pauses. The conflated
72+ * `collect` lets the delay run to completion, then picks up the most recent buffered value,
73+ * so rendering always progresses even under continuous fast streaming.
74+ *
75+ * When [streaming] is false (the common case: every non-active message in the chat list),
76+ * the throttle pipeline is skipped entirely — no `LaunchedEffect`, no `snapshotFlow`, no
77+ * coroutine. This eliminates per-item coroutine churn as messages scroll in and out of view.
78+ */
79+@Composable
80+fun MarkdownText(
81+ markdown: String,
82+ modifier: Modifier = Modifier,
83+ @Suppress("UNUSED_PARAMETER") style: TextStyle = MaterialTheme.typography.bodyLarge,
84+ streaming: Boolean = false,
85+) {
86+ val scale = LocalChatTextScale.current
87+ if (scale == 1f) {
88+ MarkdownBody(markdown, modifier, streaming)
89+ } else {
90+ // Scale the entire markdown subtree (headings, lists, code, body) from the user's
91+ // chat text-size preference by nesting a MaterialTheme with a scaled typography:
92+ // the renderer derives its typography from MaterialTheme, so this reaches every
93+ // element without plumbing a scale through the library. colorScheme/shapes default
94+ // to the current theme's, so only sizes change. Memoized so a recomposition that
95+ // doesn't change the scale doesn't rebuild the 15-role Typography.
96+ val base = MaterialTheme.typography
97+ val scaled = remember(scale, base) { base.scaledBy(scale) }
98+ MaterialTheme(typography = scaled) {
99+ MarkdownBody(markdown, modifier, streaming)
100+ }
101+ }
102+}
103+
104+@Composable
105+private fun MarkdownBody(
106+ markdown: String,
107+ modifier: Modifier = Modifier,
108+ streaming: Boolean = false,
109+) {
110+ val components = remember {
111+ markdownComponents(
112+ codeFence = { CodeWithCopy(it) },
113+ codeBlock = { CodeWithCopy(it) },
114+ )
115+ }
116+ if (streaming) {
117+ // Bridge the markdown parameter into snapshot state so snapshotFlow can observe it.
118+ val markdownState = rememberUpdatedState(markdown)
119+ // Keyed to a content-prefix so switching to a different message resets
120+ // immediately instead of showing the old message for one frame.
121+ val prefix = markdown.take(32)
122+ var renderedContent by remember(prefix) { mutableStateOf(markdown) }
123+ // conflate() coalesces a burst of token updates into a single emission so the
124+ // collector sees only the latest value after the previous delay completes. Plain
125+ // collect (not collectLatest) is critical: collectLatest would cancel the delay
126+ // on every new value, reintroducing the same starvation the keyed effect had.
127+ LaunchedEffect(prefix) {
128+ snapshotFlow { markdownState.value }
129+ .conflate()
130+ .collect { md ->
131+ // Only throttle when the content is growing incrementally (streaming):
132+ // a content switch or initial render proceeds immediately. Checking
133+ // length is O(1) vs. startsWith which is O(n) in the rendered content
134+ // length — during a long streaming response this runs every throttle
135+ // cycle. The keyed LaunchedEffect(markdown.take(32)) already handles
136+ // content switches; within one keyed cycle, growing length means
137+ // appending (streaming), not a rewrite.
138+ if (renderedContent.isNotEmpty() &&
139+ md.length > renderedContent.length
140+ ) {
141+ delay(NetworkConfig.streamingThrottleMs)
142+ }
143+ renderedContent = md
144+ }
145+ }
146+ // SelectionContainer is intentionally omitted during streaming: the content is
147+ // changing every ~50ms, and an active selection would be invalidated (and the
148+ // selection handles would flicker) on each throttle commit. Partial selection
149+ // becomes available once the stream finishes; the per-message Copy button in
150+ // MessageBubble copies all TextParts at any time.
151+ Markdown(
152+ content = renderedContent,
153+ modifier = modifier,
154+ components = components,
155+ )
156+ } else {
157+ SelectionContainer {
158+ Markdown(
159+ content = markdown,
160+ modifier = modifier,
161+ components = components,
162+ )
163+ }
164+ }
165+}
166+
167+/**
168+ * Renders a fenced or indented code block with a header toolbar: the fence's language
169+ * label (when present), a wrap toggle (soft-wrap long lines vs. horizontal scroll), and a
170+ * copy button. Self-contained so it doesn't depend on the library's internal code-block
171+ * composables. The wrap toggle starts from the user's [LocalCodeWrap] preference but can
172+ * be flipped per-block.
173+ */
174+@Composable
175+private fun CodeWithCopy(model: MarkdownComponentModel) {
176+ val context = LocalContext.current
177+ // Key on the content string + the AST node's offset range (stable ints) instead of
178+ // the model itself. MarkdownComponentModel is not @Immutable/@Stable (it holds an
179+ // ASTNode), so remembering on it re-executes extract* on every recomposition.
180+ val node = model.node
181+ val isFenced = node.type == MarkdownElementTypes.CODE_FENCE
182+ val raw = remember(model.content, node.startOffset, node.endOffset) {
183+ node.getTextInNode(model.content).toString()
184+ }
185+ val code = remember(raw, isFenced) { extractCodeText(raw, isFenced) }
186+ val language = remember(raw, isFenced) { extractFenceLanguage(raw, isFenced) }
187+ val codeStyle = model.typography.code.copy(fontFamily = FontFamily.Monospace)
188+ // Default per-block wrap from the global preference; re-seed if the preference changes
189+ // while this block is composed (rememberSaveable would over-persist across blocks).
190+ val defaultWrap = LocalCodeWrap.current
191+ var wrap by remember(defaultWrap) { mutableStateOf(defaultWrap) }
192+ Column(
193+ modifier = Modifier
194+ .fillMaxWidth()
195+ .clip(MaterialTheme.shapes.small)
196+ .background(MaterialTheme.colorScheme.surfaceVariant),
197+ ) {
198+ Row(
199+ modifier = Modifier
200+ .fillMaxWidth()
201+ .padding(start = 12.dp, end = 2.dp),
202+ verticalAlignment = Alignment.CenterVertically,
203+ ) {
204+ Text(
205+ language ?: context.getString(R.string.code),
206+ style = MaterialTheme.typography.labelSmall,
207+ color = MaterialTheme.colorScheme.onSurfaceVariant,
208+ modifier = Modifier.weight(1f),
209+ )
210+ val wrapLabel = context.getString(if (wrap) R.string.code_no_wrap else R.string.code_wrap)
211+ IconButton(onClick = { wrap = !wrap }, modifier = Modifier.size(36.dp)) {
212+ Icon(
213+ Icons.AutoMirrored.Filled.WrapText,
214+ contentDescription = wrapLabel,
215+ modifier = Modifier.size(16.dp),
216+ tint = if (wrap) MaterialTheme.colorScheme.primary
217+ else MaterialTheme.colorScheme.onSurfaceVariant,
218+ )
219+ }
220+ IconButton(
221+ onClick = { copyToClipboard(context, "code", code) },
222+ modifier = Modifier.size(36.dp),
223+ ) {
224+ Icon(
225+ Icons.Filled.ContentCopy,
226+ contentDescription = context.getString(R.string.copy),
227+ modifier = Modifier.size(14.dp),
228+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
229+ )
230+ }
231+ }
232+ // Wrap → let the Text wrap naturally (no horizontal scroll). No-wrap → horizontal
233+ // scroll so long lines keep their formatting. Selectable so a portion can be copied.
234+ SelectionContainer {
235+ Text(
236+ text = code,
237+ style = codeStyle,
238+ modifier = if (wrap) {
239+ Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, bottom = 12.dp)
240+ } else {
241+ Modifier
242+ .horizontalScroll(rememberScrollState())
243+ .fillMaxWidth()
244+ .padding(start = 12.dp, end = 12.dp, bottom = 12.dp)
245+ },
246+ )
247+ }
248+ }
249+}
250+
251+/**
252+ * Extract the fence language tag (e.g. "kotlin" from ```kotlin) so the code block can
253+ * label itself. Returns null for indented blocks or fences with no info string. Extracted
254+ * as a pure function for testability.
255+ */
256+internal fun extractFenceLanguage(raw: String, isFenced: Boolean): String? {
257+ if (!isFenced) return null
258+ val first = raw.lineSequence().firstOrNull()?.trim() ?: return null
259+ // Strip the whole opening fence run (CommonMark allows 3+ backticks or tildes, e.g.
260+ // ````kotlin), not a fixed 3 chars, before reading the info string.
261+ val info = first.trimStart('`', '~').trim()
262+ // A fence info string can carry more than the language (e.g. "```ts title=foo"); the
263+ // first whitespace-delimited token is the language.
264+ return info.substringBefore(' ').takeIf { it.isNotBlank() }
265+}
266+
267+/**
268+ * Strip fence markers (```/~~~ and the language tag) from a raw code node text so
269+ * only the code content is returned. Extracted as a pure function for testability.
270+ *
271+ * @param raw the raw text of the code node (including fence markers for fenced blocks)
272+ * @param isFenced true for CODE_FENCE nodes, false for indented CODE_BLOCK nodes
273+ */
274+internal fun extractCodeText(raw: String, isFenced: Boolean): String {
275+ if (!isFenced) return raw.trimIndent()
276+ val lines = raw.lines()
277+ val body = lines.drop(1).toMutableList()
278+ if (body.isNotEmpty() && (body.last().startsWith("```") || body.last().startsWith("~~~"))) {
279+ body.removeAt(body.lastIndex)
280+ }
281+ return body.joinToString("\n").trimEnd()
282+}
283+
284+/** Copy [text] to the system clipboard and show a confirmation toast. */
285+internal fun copyToClipboard(context: Context, label: String, text: String = label) {
286+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return
287+ clipboard.setPrimaryClip(ClipData.newPlainText(label.take(40), text))
288+ showToast(context, context.getString(R.string.copied))
289+}
290+
291+private var lastToast: Toast? = null
292+
293+internal fun showToast(context: Context, message: String) {
294+ lastToast?.cancel()
295+ lastToast = Toast.makeText(context.applicationContext, message, Toast.LENGTH_SHORT).also { it.show() }
296+}
app/src/main/java/soy/iko/opencode/ui/components/MessageBubble.ktdeleted−479
@@ -1,479 +0,0 @@
1−package soy.iko.opencode.ui.components
2−
3−import android.content.ClipData
4−import android.content.ClipboardManager
5−import android.content.Context
6−import android.content.Intent
7−import android.net.Uri
8−import android.widget.Toast
9−import androidx.compose.foundation.ExperimentalFoundationApi
10−import androidx.compose.foundation.background
11−import androidx.compose.foundation.clickable
12−import androidx.compose.foundation.combinedClickable
13−import androidx.compose.foundation.layout.Arrangement
14−import androidx.compose.foundation.layout.Column
15−import androidx.compose.foundation.layout.Row
16−import androidx.compose.foundation.layout.fillMaxWidth
17−import androidx.compose.foundation.layout.padding
18−import androidx.compose.foundation.layout.size
19−import androidx.compose.foundation.layout.widthIn
20−import androidx.compose.foundation.shape.RoundedCornerShape
21−import androidx.compose.foundation.text.selection.SelectionContainer
22−import androidx.compose.material.icons.Icons
23−import androidx.compose.material.icons.filled.Build
24−import androidx.compose.material.icons.filled.Check
25−import androidx.compose.material.icons.filled.ErrorOutline
26−import androidx.compose.material.icons.filled.ExpandLess
27−import androidx.compose.material.icons.filled.ExpandMore
28−import androidx.compose.material.icons.filled.Schedule
29−import androidx.compose.material.icons.filled.SmartToy
30−import androidx.compose.material3.DropdownMenu
31−import androidx.compose.material3.DropdownMenuItem
32−import androidx.compose.material3.Icon
33−import androidx.compose.material3.MaterialTheme
34−import androidx.compose.material3.Text
35−import androidx.compose.runtime.Composable
36−import androidx.compose.runtime.getValue
37−import androidx.compose.runtime.mutableStateOf
38−import androidx.compose.runtime.remember
39−import androidx.compose.runtime.setValue
40−import androidx.compose.ui.Alignment
41−import androidx.compose.ui.Modifier
42−import androidx.compose.ui.draw.clip
43−import androidx.compose.ui.graphics.Color
44−import androidx.compose.ui.platform.LocalContext
45−import androidx.compose.ui.text.AnnotatedString
46−import androidx.compose.ui.text.LinkAnnotation
47−import androidx.compose.ui.text.SpanStyle
48−import androidx.compose.ui.text.TextLinkStyles
49−import androidx.compose.ui.text.buildAnnotatedString
50−import androidx.compose.ui.text.font.FontFamily
51−import androidx.compose.ui.text.font.FontStyle
52−import androidx.compose.ui.text.font.FontWeight
53−import androidx.compose.ui.text.withLink
54−import androidx.compose.ui.text.withStyle
55−import androidx.compose.ui.text.style.TextDecoration
56−import androidx.compose.ui.text.style.TextOverflow
57−import androidx.compose.ui.unit.dp
58−import androidx.compose.ui.unit.sp
59−import soy.iko.opencode.core.MessageStatus
60−import soy.iko.opencode.core.MessageView
61−import soy.iko.opencode.core.ToolView
62−import soy.iko.opencode.ui.theme.MonoStyle
63−
64−private val timeFormat = java.time.format.DateTimeFormatter.ofPattern("HH:mm")
65−
66−@OptIn(ExperimentalFoundationApi::class)
67−@Composable
68−fun MessageBubble(message: MessageView, modifier: Modifier = Modifier, onRetry: () -> Unit = {}, compactSpacing: Boolean = false) {
69− val isUser = message.isUser
70− val bubbleColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
71− val textColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface
72− val mutedColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.6f) else MaterialTheme.colorScheme.onSurfaceVariant
73− val shape = RoundedCornerShape(
74− topStart = 16.dp,
75− topEnd = 16.dp,
76− bottomStart = if (isUser) 16.dp else 4.dp,
77− bottomEnd = if (isUser) 4.dp else 16.dp,
78− )
79−
80− val context = LocalContext.current
81− var showMenu by remember { mutableStateOf(false) }
82−
83− Row(
84− modifier = modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = if (compactSpacing) 1.dp else 4.dp),
85− horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
86− ) {
87− if (!isUser) {
88− Icon(
89− Icons.Filled.SmartToy,
90− contentDescription = null,
91− tint = MaterialTheme.colorScheme.primary,
92− modifier = Modifier.align(Alignment.Top).padding(end = 6.dp, top = 2.dp).size(28.dp),
93− )
94− }
95− Column(
96− modifier = Modifier
97− .fillMaxWidth(0.85f)
98− .widthIn(max = 320.dp)
99− .clip(shape)
100− .background(bubbleColor)
101− .combinedClickable(
102− onClick = {},
103− onLongClick = {
104− if (message.text.isNotEmpty()) {
105− showMenu = true
106− }
107− },
108− )
109− .padding(horizontal = 14.dp, vertical = 10.dp),
110− ) {
111− DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
112− DropdownMenuItem(
113− text = { Text("Copy") },
114− onClick = {
115− copyToClipboard(context, message.text)
116− Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
117− showMenu = false
118− },
119− )
120− DropdownMenuItem(
121− text = { Text("Share") },
122− onClick = {
123− shareText(context, message.text)
124− showMenu = false
125− },
126− )
127− }
128−
129− message.reasoning?.let { ReasoningBlock(it, context) }
130−
131− // Give a quick overview of how many tools ran before listing them.
132− if (message.tools.size > 1) {
133− Text(
134− text = "${message.tools.size} tool calls",
135− style = MaterialTheme.typography.labelSmall,
136− color = MaterialTheme.colorScheme.onSurfaceVariant,
137− modifier = Modifier.padding(bottom = 4.dp),
138− )
139− }
140−
141− message.tools.forEach { ToolRow(it) }
142−
143− SelectionContainer {
144− if (message.text.isNotEmpty()) {
145− val linkColor = MaterialTheme.colorScheme.primary
146− // Tint the code background off the bubble's own foreground colour so it
147− // contrasts on both bubble types (user bubbles use primaryContainer).
148− val codeBackground = (if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurface).copy(alpha = 0.08f)
149− val annotatedText = remember(message.text, message.streaming, linkColor, codeBackground, context) {
150− buildLinkedText(message.text, message.streaming, linkColor, codeBackground, context)
151− }
152− Text(
153− text = annotatedText,
154− color = textColor,
155− // Give assistant replies (often long, code-heavy) extra line
156− // spacing for readability; user bubbles keep the default.
157− style = if (isUser) {
158− MaterialTheme.typography.bodyLarge
159− } else {
160− MaterialTheme.typography.bodyLarge.copy(lineHeight = 22.sp)
161− },
162− )
163− } else if (message.streaming && message.tools.isEmpty() && message.reasoning == null) {
164− Text("▌", color = textColor, style = MaterialTheme.typography.bodyLarge)
165− }
166− }
167−
168− if (message.time > 0) {
169− Text(
170− text = timeFormat.format(java.time.Instant.ofEpochMilli(message.time).atZone(java.time.ZoneId.systemDefault()).toLocalTime()),
171− style = MaterialTheme.typography.labelSmall,
172− color = mutedColor,
173− modifier = Modifier.padding(top = 4.dp),
174− )
175− }
176−
177− if (isUser && message.status != MessageStatus.Sent) {
178− StatusLine(message.status, onRetry = onRetry)
179− }
180− }
181− }
182−}
183−
184−private fun copyToClipboard(context: Context, text: String) {
185− val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
186− clipboard.setPrimaryClip(ClipData.newPlainText("message", text))
187−}
188−
189−private val urlRegex = Regex("""https?://[^\s]+""")
190−
191−/**
192− * Render [text] as an AnnotatedString. Triple-backtick fences split the text into
193− * prose and code segments: code segments are rendered monospace on a tinted
194− * [codeBackground] (fences dropped), while prose has each http(s) URL turned into a
195− * clickable link. The streaming cursor (" ▌") is appended outside any annotation.
196− */
197−private fun buildLinkedText(
198− text: String,
199− streaming: Boolean,
200− linkColor: Color,
201− codeBackground: Color,
202− context: Context,
203−): AnnotatedString = buildAnnotatedString {
204− // Odd-indexed segments sit between a pair of ``` fences, so they're code. An
205− // unclosed fence leaves the trailing segment styled as code, mirroring markdown.
206− val segments = text.split("```")
207− segments.forEachIndexed { index, segment ->
208− if (index % 2 == 1) {
209− withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = codeBackground)) {
210− append(segment)
211− }
212− } else {
213− appendLinkedText(segment, linkColor, codeBackground, context)
214− }
215− }
216− if (streaming) append(" ▌")
217−}
218−
219−/**
220− * Append [text] to the builder. Double-asterisk pairs are rendered bold; within each
221− * segment, single-backtick pairs are rendered as inline code (monospace, backticks
222− * dropped), and the remaining prose has each http(s) URL turned into a clickable link.
223− * Triple-backtick fences are already stripped upstream in [buildLinkedText], so only
224− * inline spans reach here.
225− */
226−private fun AnnotatedString.Builder.appendLinkedText(
227− text: String,
228− linkColor: Color,
229− codeBackground: Color,
230− context: Context,
231−) {
232− // Bold is the outermost inline layer: split on ** first so a bold span can wrap
233− // inline code and links inside it. Odd-indexed segments sit between a pair of **
234− // markers, so they're bold (markers dropped); even segments are ordinary prose.
235− // Only the non-bold (even) segments get single-* italic handling — splitting a bold
236− // segment on "*" would misfire on the leftover asterisks from the ** markers.
237− val segments = text.split("**")
238− // An even segment count means an odd number of ** markers, so the final ** is unclosed:
239− // its trailing segment isn't bold. Restore the literal ** and render it as ordinary prose.
240− val unclosed = segments.size % 2 == 0
241− segments.forEachIndexed { index, segment ->
242− val isUnclosedTail = unclosed && index == segments.size - 1
243− if (index % 2 == 1 && !isUnclosedTail) {
244− withStyle(SpanStyle(fontWeight = FontWeight.Bold)) {
245− appendInlineCode(segment, linkColor, codeBackground, context)
246− }
247− } else {
248− if (isUnclosedTail) append("**")
249− appendItalicText(segment, linkColor, codeBackground, context)
250− }
251− }
252−}
253−
254−/**
255− * Append [text] to the builder, rendering single-asterisk pairs as italic. Double
256− * asterisks are already consumed as bold upstream in [appendLinkedText], so only lone
257− * `*` markers reach here. Each segment then flows through inline-code and URL handling.
258− */
259−private fun AnnotatedString.Builder.appendItalicText(
260− text: String,
261− linkColor: Color,
262− codeBackground: Color,
263− context: Context,
264−) {
265− // Odd-indexed segments sit between a pair of single asterisks, so they're italic
266− // (markers dropped); even segments are ordinary prose.
267− val segments = text.split("*")
268− // An even segment count means an odd number of * markers, so the final * is unclosed:
269− // its trailing segment isn't italic. Restore the literal * and render it as prose.
270− val unclosed = segments.size % 2 == 0
271− segments.forEachIndexed { index, segment ->
272− val isUnclosedTail = unclosed && index == segments.size - 1
273− if (index % 2 == 1 && !isUnclosedTail) {
274− withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
275− appendInlineCode(segment, linkColor, codeBackground, context)
276− }
277− } else {
278− if (isUnclosedTail) append("*")
279− appendInlineCode(segment, linkColor, codeBackground, context)
280− }
281− }
282−}
283−
284−/**
285− * Append [text] to the builder, rendering single-backtick pairs as inline code
286− * (monospace, backticks dropped) and turning each http(s) URL in the remaining prose
287− * into a clickable link.
288− */
289−private fun AnnotatedString.Builder.appendInlineCode(
290− text: String,
291− linkColor: Color,
292− codeBackground: Color,
293− context: Context,
294−) {
295− // Odd-indexed segments sit between a pair of single backticks, so they're inline
296− // code. Even segments are ordinary prose and get URL linking applied.
297− val segments = text.split("`")
298− // An even segment count means an odd number of backtick markers, so the final backtick
299− // is unclosed: its trailing segment isn't code. Restore the literal ` and treat as prose.
300− val unclosed = segments.size % 2 == 0
301− segments.forEachIndexed { index, segment ->
302− val isUnclosedTail = unclosed && index == segments.size - 1
303− if (index % 2 == 1 && !isUnclosedTail) {
304− withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = codeBackground)) {
305− append(segment)
306− }
307− } else {
308− if (isUnclosedTail) append("`")
309− appendUrls(segment, linkColor, context)
310− }
311− }
312−}
313−
314−/** Append [text] to the builder, turning each http(s) URL into a clickable link. */
315−private fun AnnotatedString.Builder.appendUrls(
316− text: String,
317− linkColor: Color,
318− context: Context,
319−) {
320− val matches = urlRegex.findAll(text).toList()
321− var cursor = 0
322− for (match in matches) {
323− append(text.substring(cursor, match.range.first))
324− val url = match.value
325− withLink(
326− LinkAnnotation.Clickable(
327− tag = url,
328− styles = TextLinkStyles(
329− SpanStyle(color = linkColor, textDecoration = TextDecoration.Underline),
330− ),
331− linkInteractionListener = {
332− runCatching {
333− context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
334− }
335− },
336− ),
337− ) {
338− append(url)
339− }
340− cursor = match.range.last + 1
341− }
342− append(text.substring(cursor))
343−}
344−
345−private fun shareText(context: Context, text: String) {
346− val intent = Intent(Intent.ACTION_SEND).apply {
347− type = "text/plain"
348− putExtra(Intent.EXTRA_TEXT, text)
349− }
350− context.startActivity(Intent.createChooser(intent, null))
351−}
352−
353−@Composable
354−private fun ReasoningBlock(text: String, context: Context) {
355− var expanded by remember { mutableStateOf(false) }
356− Column(modifier = Modifier.padding(bottom = 6.dp)) {
357− Row(
358− verticalAlignment = Alignment.CenterVertically,
359− modifier = Modifier.clickable { expanded = !expanded },
360− ) {
361− Text(
362− text = if (expanded) "Show less" else "Reasoning",
363− style = MaterialTheme.typography.bodySmall,
364− color = MaterialTheme.colorScheme.onSurfaceVariant,
365− )
366− Icon(
367− imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
368− contentDescription = if (expanded) "Collapse reasoning" else "Expand reasoning",
369− tint = MaterialTheme.colorScheme.onSurfaceVariant,
370− modifier = Modifier.padding(start = 2.dp).widthIn(max = 18.dp),
371− )
372− }
373− if (expanded) {
374− Text(
375− text = text,
376− style = MaterialTheme.typography.bodySmall.copy(fontStyle = FontStyle.Italic),
377− color = MaterialTheme.colorScheme.onSurfaceVariant,
378− modifier = Modifier.padding(top = 4.dp),
379− )
380− Text(
381− text = "Copy",
382− style = MaterialTheme.typography.labelSmall,
383− color = MaterialTheme.colorScheme.primary,
384− modifier = Modifier
385− .padding(top = 4.dp)
386− .clickable {
387− copyToClipboard(context, text)
388− Toast.makeText(context, "Copied", Toast.LENGTH_SHORT).show()
389− },
390− )
391− }
392− }
393−}
394−
395−@Composable
396−private fun ToolRow(tool: ToolView) {
397− var expanded by remember { mutableStateOf(false) }
398− val baseColor = MaterialTheme.colorScheme.onSurfaceVariant
399− val statusColor = when (tool.status) {
400− "completed", "success" -> MaterialTheme.colorScheme.primary
401− "error", "failed" -> MaterialTheme.colorScheme.error
402− "pending", "running", "in_progress" -> MaterialTheme.colorScheme.onSurfaceVariant
403− else -> MaterialTheme.colorScheme.onSurfaceVariant
404− }
405− Row(
406− verticalAlignment = Alignment.CenterVertically,
407− modifier = Modifier
408− .fillMaxWidth()
409− .clickable { expanded = !expanded }
410− .padding(bottom = 6.dp),
411− ) {
412− Icon(
413− Icons.Filled.Build,
414− contentDescription = null,
415− tint = MaterialTheme.colorScheme.primary,
416− modifier = Modifier.padding(end = 6.dp).widthIn(max = 16.dp),
417− )
418− Text(
419− text = buildAnnotatedString {
420− withStyle(SpanStyle(color = baseColor)) {
421− append(tool.name)
422− append(" · ")
423− }
424− withStyle(SpanStyle(color = statusColor)) {
425− append(tool.status)
426− }
427− if (expanded) tool.title?.let {
428− withStyle(SpanStyle(color = baseColor)) {
429− append(" — ")
430− append(it)
431− }
432− }
433− },
434− style = MonoStyle,
435− color = baseColor,
436− maxLines = if (expanded) Int.MAX_VALUE else 1,
437− overflow = TextOverflow.Ellipsis,
438− modifier = Modifier.weight(1f),
439− )
440− Icon(
441− imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore,
442− contentDescription = if (expanded) "Collapse tool details" else "Expand tool details",
443− tint = MaterialTheme.colorScheme.onSurfaceVariant,
444− modifier = Modifier.padding(start = 2.dp).widthIn(max = 18.dp),
445− )
446− }
447−}
448−
449−@Composable
450−private fun StatusLine(status: MessageStatus, onRetry: () -> Unit) {
451− val (icon, label, tint) = when (status) {
452− MessageStatus.Pending -> Triple(Icons.Filled.Schedule, "Sending…", MaterialTheme.colorScheme.onPrimaryContainer)
453− MessageStatus.Failed -> Triple(Icons.Filled.ErrorOutline, "Failed to send", MaterialTheme.colorScheme.error)
454− MessageStatus.Sent -> Triple(Icons.Filled.Check, "", Color.Unspecified)
455− }
456− Row(
457− verticalAlignment = Alignment.CenterVertically,
458− horizontalArrangement = Arrangement.End,
459− modifier = Modifier.fillMaxWidth().padding(top = 4.dp),
460− ) {
461− Icon(icon, contentDescription = null, tint = tint, modifier = Modifier.widthIn(max = 14.dp))
462− Text(
463− text = label,
464− style = MaterialTheme.typography.labelSmall,
465− color = tint,
466− modifier = Modifier.padding(start = 4.dp),
467− )
468− if (status == MessageStatus.Failed) {
469− Text(
470− text = "Retry",
471− style = MaterialTheme.typography.labelSmall,
472− color = MaterialTheme.colorScheme.primary,
473− modifier = Modifier
474− .padding(start = 12.dp)
475− .clickable { onRetry() },
476− )
477− }
478− }
479−}
app/src/main/java/soy/iko/opencode/ui/components/PaletteAction.ktnew file+11
@@ -0,0 +1,11 @@
1+package soy.iko.opencode.ui.components
2+
3+import androidx.compose.runtime.Immutable
4+
5+/** One entry in the [CommandPalette]: a label the user can search for and an action to run. */
6+@Immutable
7+data class PaletteAction(
8+ val id: String,
9+ val label: String,
10+ val onSelect: () -> Unit,
11+)
app/src/main/java/soy/iko/opencode/ui/components/RemoteImage.ktnew file+199
@@ -0,0 +1,199 @@
1+package soy.iko.opencode.ui.components
2+
3+import android.util.Base64
4+import androidx.compose.foundation.layout.Box
5+import androidx.compose.foundation.layout.fillMaxWidth
6+import androidx.compose.foundation.layout.heightIn
7+import androidx.compose.material3.MaterialTheme
8+import androidx.compose.runtime.Composable
9+import androidx.compose.runtime.Immutable
10+import androidx.compose.runtime.produceState
11+import androidx.compose.runtime.remember
12+import androidx.compose.ui.Alignment
13+import androidx.compose.ui.Modifier
14+import androidx.compose.ui.draw.clip
15+import androidx.compose.ui.layout.ContentScale
16+import androidx.compose.ui.platform.LocalContext
17+import androidx.compose.ui.res.stringResource
18+import androidx.compose.ui.semantics.contentDescription
19+import androidx.compose.ui.semantics.semantics
20+import androidx.compose.ui.unit.dp
21+import coil.compose.SubcomposeAsyncImage
22+import coil.request.ImageRequest
23+import kotlinx.coroutines.Dispatchers
24+import kotlinx.coroutines.withContext
25+import androidx.compose.material3.CircularProgressIndicator
26+import androidx.compose.material3.Icon
27+import androidx.compose.material.icons.Icons
28+import androidx.compose.material.icons.filled.BrokenImage
29+import soy.iko.opencode.R
30+import soy.iko.opencode.data.model.FilePart
31+import soy.iko.opencode.data.model.ServerProfile
32+import soy.iko.opencode.data.model.sourcePath
33+import soy.iko.opencode.data.network.HttpClientFactory
34+
35+/**
36+ * Carries the bits needed to load an image off the opencode server: the base URL (for
37+ * resolving relative `url`s) and an optional HTTP Basic header for protected servers.
38+ */
39+@Immutable
40+data class ImageLoadContext(
41+ val baseUrl: String,
42+ val basicAuthHeader: String?,
43+)
44+
45+/** Build an [ImageLoadContext] from a resolved connection profile (password included). */
46+fun ServerProfile.toImageContext(): ImageLoadContext {
47+ val auth = if (hasAuth && !password.isNullOrEmpty()) {
48+ val raw = "$username:$password".toByteArray()
49+ "Basic " + Base64.encodeToString(raw, Base64.NO_WRAP)
50+ } else {
51+ null
52+ }
53+ // Resolve image URLs against the HTTPS-upgraded base URL, not the raw stored one, so a
54+ // requireHttps (or certificate-pinned) profile never fetches an image — with the Basic auth
55+ // header attached — over cleartext http even when the stored URL is http://. This mirrors
56+ // how HttpClientFactory computes the effective base URL for the REST/SSE channel.
57+ return ImageLoadContext(baseUrl = effectiveImageBaseUrl(), basicAuthHeader = auth)
58+}
59+
60+/**
61+ * The base URL used to resolve image URLs, after applying the same http->https upgrade the
62+ * REST/SSE client uses (see HttpClientFactory.effectiveBaseUrl, the source of truth — that
63+ * helper and its pin parser are private there and this file may not edit HttpClientFactory, so
64+ * the logic is replicated here and must be kept in sync). A pin is meaningless over cleartext, so
65+ * a configured certificate pin forces HTTPS too, matching the REST channel.
66+ */
67+private fun ServerProfile.effectiveImageBaseUrl(): String {
68+ val normalized = HttpClientFactory.normalizeBaseUrl(baseUrl)
69+ val forceHttps = requireHttps || parseCertPins(certPin).isNotEmpty()
70+ return if (forceHttps && normalized.lowercase().startsWith("http://")) {
71+ "https://" + normalized.substring("http://".length)
72+ } else {
73+ normalized
74+ }
75+}
76+
77+/** Split a certificate-pin field into individual pins; mirrors HttpClientFactory.parsePins. */
78+private fun parseCertPins(raw: String?): List<String> =
79+ raw?.split(Regex("[\\s,]+"))?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
80+
81+/** True if the part references a raster/vector image we can render. */
82+val FilePart.isImage: Boolean
83+ get() = mime?.startsWith("image/") == true
84+
85+private fun decodeDataUri(data: String): ByteArray? {
86+ val comma = data.indexOf(',')
87+ if (comma < 0) return null
88+ val header = data.substring(0, comma)
89+ val payload = data.substring(comma + 1)
90+ // Only ";base64" payloads are Base64; a plain/percent-encoded data URI (e.g.
91+ // "data:image/svg+xml,%3Csvg…") must be percent-decoded to bytes instead of Base64-decoded,
92+ // which would throw and render the broken-image icon.
93+ return runCatching {
94+ if (header.contains(";base64", ignoreCase = true)) {
95+ Base64.decode(payload, Base64.DEFAULT)
96+ } else {
97+ android.net.Uri.decode(payload).toByteArray()
98+ }
99+ }.getOrNull()
100+}
101+
102+/**
103+ * Resolve a [FilePart] to a Coil-loadable model: a decoded [ByteArray] for inline
104+ * data URIs, an absolute URL string otherwise. Returns null when nothing loadable.
105+ */
106+private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
107+ val src = sourcePath
108+ if (!src.isNullOrBlank() && src.startsWith("data:")) return decodeDataUri(src)
109+ val url = url ?: return null
110+ // Attachments are persisted as a data URL in `part.url`, so a data URI can arrive here too.
111+ if (url.startsWith("data:")) return decodeDataUri(url)
112+ // Resolve the URL (absolute, relative, or server-absolute like "/media/x.png") against
113+ // the base, collapsing any ../ segments. Both absolute and relative forms must clear the
114+ // same-origin check below — an absolute foreign URL (e.g. "https://evil.com/x.png") would
115+ // otherwise get the server's Basic auth attached and leak the credentials to that host.
116+ val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
117+ val baseUri = runCatching { java.net.URI(base) }.getOrElse { return null }
118+ val resolved = runCatching {
119+ java.net.URI(base).resolve(url).normalize()
120+ }.getOrElse { return null }
121+ // Guard on same origin (scheme + host + port), not base-path prefix: the real risk is
122+ // sending the request (with its Basic auth) to another host. A server-absolute path
123+ // resolves to a different base *path* but the same origin, so it must still be allowed.
124+ // Compare *effective* ports (substituting the scheme default when unspecified) so an
125+ // absolute image URL that spells out the default port — "https://host:443/x.png" while
126+ // the base is "https://host/" — isn't wrongly rejected as cross-origin (URI.port is -1
127+ // when omitted, so a raw `==` would treat 443 and -1 as different).
128+ val sameOrigin = resolved.host != null &&
129+ resolved.scheme.equals(baseUri.scheme, ignoreCase = true) &&
130+ resolved.host.equals(baseUri.host, ignoreCase = true) &&
131+ effectivePort(resolved) == effectivePort(baseUri)
132+ return if (sameOrigin) resolved.toString() else null
133+}
134+
135+/** The URI's port, or the scheme's default (80/443) when unspecified (`port == -1`). */
136+private fun effectivePort(uri: java.net.URI): Int {
137+ if (uri.port != -1) return uri.port
138+ return when (uri.scheme?.lowercase()) {
139+ "https" -> 443
140+ "http" -> 80
141+ else -> -1
142+ }
143+}
144+
145+/**
146+ * Renders an image attachment from a [FilePart]. Handles inline data URIs (no network)
147+ * and server-relative URLs (fetched with Basic auth when the server requires it).
148+ * Falls back to nothing when the part has no loadable source.
149+ */
150+@Composable
151+fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
152+ val context = LocalContext.current
153+ val model = produceState<Any?>(initialValue = null, part.source, part.url, ctx.baseUrl) {
154+ value = withContext(Dispatchers.Default) { part.resolveModel(ctx) }
155+ }.value ?: return
156+ val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader, model) {
157+ ImageRequest.Builder(context)
158+ .data(model)
159+ .apply {
160+ // resolveModel already verified same-origin, so a String model can only point at
161+ // the user's own server — attaching Basic auth over http (not just https) is safe.
162+ // A data-URI model is a ByteArray, so gate on the model being a URL String.
163+ if (model is String) ctx.basicAuthHeader?.let { addHeader("Authorization", it) }
164+ }
165+ .crossfade(true)
166+ .build()
167+ }
168+ SubcomposeAsyncImage(
169+ model = request,
170+ contentDescription = part.filename ?: stringResource(R.string.image),
171+ contentScale = ContentScale.FillWidth,
172+ modifier = modifier
173+ .heightIn(max = 320.dp)
174+ .clip(MaterialTheme.shapes.small),
175+ loading = {
176+ Box(
177+ modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
178+ contentAlignment = Alignment.Center,
179+ ) {
180+ val loadingLabel = stringResource(R.string.loading)
181+ CircularProgressIndicator(
182+ modifier = Modifier.semantics { contentDescription = loadingLabel },
183+ )
184+ }
185+ },
186+ error = {
187+ Box(
188+ modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
189+ contentAlignment = Alignment.Center,
190+ ) {
191+ Icon(
192+ Icons.Filled.BrokenImage,
193+ contentDescription = stringResource(R.string.image_failed),
194+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
195+ )
196+ }
197+ },
198+ )
199+}
app/src/main/java/soy/iko/opencode/ui/components/TimeFormat.ktnew file+216
@@ -0,0 +1,216 @@
1+package soy.iko.opencode.ui.components
2+
3+import android.content.Context
4+import android.text.format.DateUtils
5+import androidx.compose.material3.ExperimentalMaterial3Api
6+import androidx.compose.material3.MaterialTheme
7+import androidx.compose.material3.PlainTooltip
8+import androidx.compose.material3.Text
9+import androidx.compose.material3.TooltipBox
10+import androidx.compose.material3.TooltipDefaults
11+import androidx.compose.material3.rememberTooltipState
12+import androidx.compose.runtime.Composable
13+import androidx.compose.runtime.compositionLocalOf
14+import androidx.compose.runtime.derivedStateOf
15+import androidx.compose.runtime.getValue
16+import androidx.compose.runtime.mutableLongStateOf
17+import androidx.compose.runtime.remember
18+import androidx.compose.runtime.setValue
19+import androidx.compose.ui.Modifier
20+import androidx.compose.ui.graphics.Color
21+import androidx.compose.ui.platform.LocalContext
22+import androidx.compose.ui.text.TextStyle
23+import androidx.lifecycle.Lifecycle
24+import androidx.lifecycle.compose.LocalLifecycleOwner
25+import androidx.lifecycle.repeatOnLifecycle
26+import kotlinx.coroutines.delay
27+import java.util.concurrent.TimeUnit
28+
29+/**
30+ * Formats an epoch-millis timestamp as a short relative string (e.g. "3m", "2h", "5d").
31+ *
32+ * For coarse units (minutes/hours/days) prefers Android's locale-aware
33+ * [DateUtils.getRelativeTimeSpanString] so the abbreviations match the device locale
34+ * instead of hardcoded English. Falls back to a compact numeric form (e.g. "5m",
35+ * "3h", "2d") when [DateUtils] returns null (e.g. under JVM unit tests where
36+ * `isReturnDefaultValues = true` stubs the framework call to null). For
37+ * weeks/months/years — which [DateUtils] doesn't provide short abbreviations for —
38+ * uses the compact numeric form with a locale-stable unit suffix. Always returns a
39+ * non-empty string for a valid timestamp.
40+ */
41+fun relativeTime(epochMillis: Long?): String {
42+ if (epochMillis == null || epochMillis <= 0) return ""
43+ val now = System.currentTimeMillis()
44+ val diff = now - epochMillis
45+ if (diff < 0) return "now"
46+ val minutes = TimeUnit.MILLISECONDS.toMinutes(diff)
47+ if (minutes < 1) return "now"
48+ if (minutes < 60) {
49+ val locale = DateUtils.getRelativeTimeSpanString(
50+ epochMillis, now, DateUtils.MINUTE_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE,
51+ )?.toString()
52+ return locale?.takeIf { it.isNotBlank() } ?: "${minutes}m"
53+ }
54+ val hours = TimeUnit.MILLISECONDS.toHours(diff)
55+ if (hours < 24) {
56+ val locale = DateUtils.getRelativeTimeSpanString(
57+ epochMillis, now, DateUtils.HOUR_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE,
58+ )?.toString()
59+ return locale?.takeIf { it.isNotBlank() } ?: "${hours}h"
60+ }
61+ val days = TimeUnit.MILLISECONDS.toDays(diff)
62+ if (days < 7) {
63+ val locale = DateUtils.getRelativeTimeSpanString(
64+ epochMillis, now, DateUtils.DAY_IN_MILLIS, DateUtils.FORMAT_ABBREV_RELATIVE,
65+ )?.toString()
66+ return locale?.takeIf { it.isNotBlank() } ?: "${days}d"
67+ }
68+ // DateUtils doesn't offer a short week/month/year abbreviation; use a compact
69+ // numeric form. The suffix is locale-stable (English letter) but these are
70+ // rare timestamps (older than a week) and the numeric value is the primary signal.
71+ val weeks = days / 7
72+ if (weeks < 5) return "${weeks}w"
73+ // Switch to years only past a full 365 days. Gating on `months < 12` (with months =
74+ // days/30) instead misfires for 360–364 days: months == 12 fails the check and falls
75+ // through to years, rendering "1y" for a span that's under a year (~11.9 months).
76+ if (days < 365) return "${days / 30}mo"
77+ val years = days / 365
78+ return "${years}y"
79+}
80+
81+/** Re-evaluation interval for relative-time labels — coarse enough for battery, fine
82+ * enough for the smallest unit we show (minutes). */
83+private const val RELATIVE_TIME_INTERVAL_MS = 30_000L
84+
85+/**
86+ * A shared, screen-level "tick" that advances periodically so every relative-time label
87+ * in the subtree refreshes together. Provide it once near the top of a scrollable screen:
88+ *
89+ * val tick = rememberRelativeTimeTick()
90+ * CompositionLocalProvider(LocalRelativeTimeTick provides tick) { ... }
91+ *
92+ * [rememberRelativeTime] then reads this single source instead of each timestamp spinning
93+ * up its own coroutine + lifecycle observer — a list with many timestamped rows avoids a
94+ * timer churn as items enter and leave the viewport.
95+ *
96+ * Defaults to 0 (no provider); [rememberRelativeTime] falls back to a self-managed timer
97+ * then, so it keeps auto-refreshing even without a provider.
98+ */
99+val LocalRelativeTimeTick = compositionLocalOf<Long> { 0L }
100+
101+/**
102+ * Advances a tick counter while the lifecycle is at least resumed (immediately on resume,
103+ * then every [intervalMs]), and suspends while the screen isn't visible to avoid battery
104+ * drain. Provides one shared time source for an entire subtree via [LocalRelativeTimeTick].
105+ */
106+@Composable
107+fun rememberRelativeTimeTick(intervalMs: Long = RELATIVE_TIME_INTERVAL_MS): Long {
108+ var tick by remember { mutableLongStateOf(0L) }
109+ val lifecycleOwner = LocalLifecycleOwner.current
110+
111+ androidx.compose.runtime.LaunchedEffect(lifecycleOwner) {
112+ lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
113+ // Refresh immediately on resume (so a stale label doesn't wait an interval),
114+ // then periodically so labels stay current.
115+ tick++
116+ while (true) {
117+ delay(intervalMs)
118+ tick++
119+ }
120+ }
121+ }
122+ return tick
123+}
124+
125+/**
126+ * Formats [epochMillis] relative to now, recomputing as time passes. Prefers the shared
127+ * screen-level tick from [LocalRelativeTimeTick] (one timer for the whole subtree); when
128+ * no provider is present it falls back to its own lifecycle-bound timer, so callers don't
129+ * need a provider to get auto-refreshing labels.
130+ */
131+@Composable
132+fun rememberRelativeTime(epochMillis: Long?): String {
133+ val sharedTick = LocalRelativeTimeTick.current
134+ return if (sharedTick != 0L) {
135+ // Reading sharedTick above registers a recomposition dependency on the shared
136+ // ticker, so the label recomputes (cheaply) whenever the screen-level tick advances.
137+ relativeTime(epochMillis)
138+ } else {
139+ rememberRelativeTimeStandalone(epochMillis)
140+ }
141+}
142+
143+/** Self-contained auto-refreshing label used when no [LocalRelativeTimeTick] provider exists. */
144+@Composable
145+private fun rememberRelativeTimeStandalone(epochMillis: Long?): String {
146+ var tick by remember { mutableLongStateOf(0L) }
147+ val lifecycleOwner = LocalLifecycleOwner.current
148+
149+ androidx.compose.runtime.LaunchedEffect(epochMillis, lifecycleOwner) {
150+ if (epochMillis == null) return@LaunchedEffect
151+ lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
152+ // Tick immediately on resume so a stale label refreshes without waiting for
153+ // the first interval, then periodically.
154+ tick++
155+ while (true) {
156+ delay(RELATIVE_TIME_INTERVAL_MS)
157+ tick++
158+ }
159+ }
160+ }
161+
162+ // derivedStateOf reads tick so Compose recomposes when it changes, and recomputes
163+ // the relative-time string from the current wall clock on each tick. Keying remember
164+ // on epochMillis ensures a new derivedStateOf is created when the timestamp changes.
165+ val formatted by remember(epochMillis) {
166+ derivedStateOf {
167+ // Read tick to create a recomposition dependency on the timer.
168+ tick
169+ relativeTime(epochMillis)
170+ }
171+ }
172+ return formatted
173+}
174+
175+/**
176+ * Formats [epochMillis] as a full, locale-aware absolute timestamp (date + time + year),
177+ * e.g. "Jul 2, 2026, 14:33". Complements the compact [relativeTime] label shown in lists
178+ * and bubbles — the relative form is scannable, the absolute form is precise. Returns an
179+ * empty string for a null/invalid timestamp. Uses the device locale and 12/24-hour setting.
180+ */
181+fun absoluteTime(context: Context, epochMillis: Long?): String {
182+ if (epochMillis == null || epochMillis <= 0) return ""
183+ return DateUtils.formatDateTime(
184+ context,
185+ epochMillis,
186+ DateUtils.FORMAT_SHOW_DATE or DateUtils.FORMAT_SHOW_TIME or DateUtils.FORMAT_SHOW_YEAR,
187+ )
188+}
189+
190+/**
191+ * A relative-time label (auto-refreshing, per [rememberRelativeTime]) that reveals the full
192+ * absolute timestamp in a tooltip on long-press (or hover). Lets a compact "3m"/"2h" stay
193+ * the default while the exact date/time is one long-press away — without spending screen
194+ * space on it. Renders nothing when the timestamp is absent so callers can drop it in
195+ * unconditionally.
196+ */
197+@OptIn(ExperimentalMaterial3Api::class)
198+@Composable
199+fun RelativeTimeText(
200+ epochMillis: Long?,
201+ modifier: Modifier = Modifier,
202+ style: TextStyle = MaterialTheme.typography.labelSmall,
203+ color: Color = MaterialTheme.colorScheme.onSurfaceVariant,
204+) {
205+ val relative = rememberRelativeTime(epochMillis)
206+ if (relative.isEmpty()) return
207+ val context = LocalContext.current
208+ val absolute = remember(epochMillis) { absoluteTime(context, epochMillis) }
209+ TooltipBox(
210+ positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(),
211+ tooltip = { PlainTooltip { Text(absolute) } },
212+ state = rememberTooltipState(),
213+ ) {
214+ Text(relative, style = style, color = color, modifier = modifier)
215+ }
216+}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.ktnew file+544
@@ -0,0 +1,544 @@
1+package soy.iko.opencode.ui.file
2+
3+import androidx.compose.foundation.clickable
4+import androidx.compose.foundation.horizontalScroll
5+import androidx.compose.foundation.layout.Arrangement
6+import androidx.compose.foundation.layout.Box
7+import androidx.compose.foundation.layout.Column
8+import androidx.compose.foundation.layout.Row
9+import androidx.compose.foundation.layout.Spacer
10+import androidx.compose.foundation.layout.defaultMinSize
11+import androidx.compose.foundation.layout.fillMaxSize
12+import androidx.compose.foundation.layout.fillMaxWidth
13+import androidx.compose.foundation.layout.padding
14+import androidx.compose.foundation.layout.size
15+import androidx.compose.foundation.lazy.LazyColumn
16+import androidx.compose.foundation.lazy.items
17+import androidx.compose.foundation.lazy.itemsIndexed
18+import androidx.compose.foundation.rememberScrollState
19+import androidx.compose.foundation.text.KeyboardActions
20+import androidx.compose.foundation.text.KeyboardOptions
21+import androidx.compose.material.icons.Icons
22+import androidx.compose.material.icons.automirrored.filled.ArrowBack
23+import androidx.compose.material.icons.filled.ChevronRight
24+import androidx.compose.material.icons.filled.Close
25+import androidx.compose.material.icons.filled.Description
26+import androidx.compose.material.icons.filled.Folder
27+import androidx.compose.material.icons.filled.Home
28+import androidx.compose.material.icons.filled.Search
29+import androidx.compose.material3.CircularProgressIndicator
30+import androidx.compose.material3.ExperimentalMaterial3Api
31+import androidx.compose.material3.FilterChip
32+import androidx.compose.material3.HorizontalDivider
33+import androidx.compose.material3.Icon
34+import androidx.compose.material3.IconButton
35+import androidx.compose.material3.MaterialTheme
36+import androidx.compose.material3.OutlinedTextField
37+import androidx.compose.material3.Scaffold
38+import androidx.compose.material3.SnackbarHost
39+import androidx.compose.material3.SnackbarHostState
40+import androidx.compose.material3.Text
41+import androidx.compose.material3.TopAppBar
42+import androidx.compose.runtime.Composable
43+import androidx.compose.runtime.LaunchedEffect
44+import androidx.compose.runtime.getValue
45+import androidx.compose.runtime.remember
46+import androidx.compose.ui.Alignment
47+import androidx.compose.ui.Modifier
48+import androidx.compose.ui.graphics.Color
49+import androidx.compose.ui.platform.LocalSoftwareKeyboardController
50+import androidx.compose.ui.platform.testTag
51+import androidx.compose.ui.semantics.Role
52+import androidx.compose.ui.semantics.contentDescription
53+import androidx.compose.ui.semantics.role
54+import androidx.compose.ui.semantics.semantics
55+import androidx.compose.ui.text.SpanStyle
56+import androidx.compose.ui.text.buildAnnotatedString
57+import androidx.compose.ui.text.font.FontFamily
58+import androidx.compose.ui.text.font.FontWeight
59+import androidx.compose.ui.text.input.ImeAction
60+import androidx.compose.ui.text.style.TextOverflow
61+import androidx.compose.ui.text.withStyle
62+import androidx.compose.ui.unit.dp
63+import androidx.compose.ui.res.stringResource
64+import androidx.lifecycle.compose.collectAsStateWithLifecycle
65+import androidx.lifecycle.viewmodel.compose.viewModel
66+import soy.iko.opencode.data.model.FileStatusEntry
67+import soy.iko.opencode.data.model.FindMatch
68+import soy.iko.opencode.data.model.SymbolResult
69+import soy.iko.opencode.data.model.symbolKindLabel
70+import soy.iko.opencode.di.AppContainer
71+import soy.iko.opencode.R
72+import soy.iko.opencode.ui.vmFactory
73+
74+@OptIn(ExperimentalMaterial3Api::class)
75+@Composable
76+fun FileBrowserScreen(
77+ container: AppContainer,
78+ onOpenFile: (path: String, line: Int?) -> Unit,
79+ onBack: () -> Unit,
80+) {
81+ val vm: FileBrowserViewModel = viewModel(factory = vmFactory { FileBrowserViewModel(container) })
82+ val state by vm.state.collectAsStateWithLifecycle()
83+ val refreshing by vm.refreshing.collectAsStateWithLifecycle()
84+ val snackbar = remember { SnackbarHostState() }
85+
86+ LaunchedEffect(Unit) {
87+ vm.transientErrors.collect { msg ->
88+ snackbar.showSnackbar(msg)
89+ }
90+ }
91+
92+ Scaffold(
93+ topBar = {
94+ TopAppBar(
95+ title = { Text(stringResource(R.string.files), maxLines = 1, overflow = TextOverflow.Ellipsis) },
96+ navigationIcon = {
97+ IconButton(onClick = onBack) {
98+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back))
99+ }
100+ },
101+ )
102+ },
103+ snackbarHost = { SnackbarHost(snackbar) },
104+ ) { padding ->
105+ Column(modifier = Modifier.fillMaxSize().padding(padding)) {
106+ // Tappable breadcrumb trail so deep paths are navigable.
107+ Breadcrumbs(
108+ path = state.path,
109+ onNavigate = vm::open,
110+ modifier = Modifier.fillMaxWidth(),
111+ )
112+ val keyboardController = LocalSoftwareKeyboardController.current
113+ OutlinedTextField(
114+ value = state.query,
115+ onValueChange = vm::setQuery,
116+ modifier = Modifier.fillMaxWidth().padding(start = 12.dp, end = 12.dp, top = 12.dp).testTag("file_search"),
117+ label = {
118+ Text(
119+ stringResource(
120+ when (state.mode) {
121+ SearchMode.FILES -> R.string.search_files
122+ SearchMode.TEXT -> R.string.search_in_files
123+ SearchMode.SYMBOL -> R.string.search_symbols_label
124+ },
125+ ),
126+ )
127+ },
128+ leadingIcon = { Icon(Icons.Filled.Search, contentDescription = null) },
129+ trailingIcon = if (state.query.isNotEmpty()) {
130+ {
131+ IconButton(onClick = { vm.setQuery("") }) {
132+ Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.clear_search))
133+ }
134+ }
135+ } else null,
136+ singleLine = true,
137+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
138+ keyboardActions = KeyboardActions(onSearch = { keyboardController?.hide() }),
139+ )
140+ // Mode selector: file names / contents / symbols.
141+ Row(
142+ modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(horizontal = 12.dp),
143+ horizontalArrangement = Arrangement.spacedBy(8.dp),
144+ ) {
145+ SearchModeChip(stringResource(R.string.search_mode_files), state.mode == SearchMode.FILES) { vm.setMode(SearchMode.FILES) }
146+ SearchModeChip(stringResource(R.string.search_mode_text), state.mode == SearchMode.TEXT) { vm.setMode(SearchMode.TEXT) }
147+ SearchModeChip(stringResource(R.string.search_mode_symbols), state.mode == SearchMode.SYMBOL) { vm.setMode(SearchMode.SYMBOL) }
148+ }
149+ Spacer(Modifier.size(8.dp))
150+
151+ androidx.compose.material3.pulltorefresh.PullToRefreshBox(
152+ isRefreshing = refreshing,
153+ onRefresh = { vm.refresh() },
154+ modifier = Modifier.fillMaxSize(),
155+ ) {
156+ Box(modifier = Modifier.fillMaxSize()) {
157+ when {
158+ state.loading || state.searching -> {
159+ val loadingLabel = stringResource(R.string.loading)
160+ CircularProgressIndicator(
161+ Modifier.align(Alignment.Center).semantics { contentDescription = loadingLabel },
162+ )
163+ }
164+ state.error != null -> Column(
165+ modifier = Modifier.align(Alignment.Center).padding(24.dp),
166+ horizontalAlignment = Alignment.CenterHorizontally,
167+ ) {
168+ Text(
169+ state.error ?: "",
170+ color = MaterialTheme.colorScheme.error,
171+ )
172+ androidx.compose.foundation.layout.Spacer(Modifier.size(12.dp))
173+ // Retry the operation that actually failed: re-run the search
174+ // when one was active (open() would reset the query and drop the
175+ // user back into the directory listing), otherwise reload the dir.
176+ androidx.compose.material3.TextButton(
177+ onClick = { if (state.isSearching) vm.setQuery(state.query) else vm.open(state.path) },
178+ ) {
179+ Text(stringResource(R.string.retry))
180+ }
181+ }
182+ state.mode == SearchMode.TEXT ->
183+ TextResults(state.textResults) { path, line -> onOpenFile(path, line) }
184+ state.mode == SearchMode.SYMBOL ->
185+ SymbolResults(state.symbolResults) { path, line -> onOpenFile(path, line) }
186+ state.isSearching -> SearchResults(state.results) { onOpenFile(it, null) }
187+ else -> DirectoryListing(
188+ state = state,
189+ onOpenDir = vm::open,
190+ onUp = vm::up,
191+ onOpenFile = { onOpenFile(it, null) },
192+ )
193+ }
194+ }
195+ }
196+ }
197+ }
198+}
199+
200+@Composable
201+private fun Breadcrumbs(path: String, onNavigate: (String) -> Unit, modifier: Modifier = Modifier) {
202+ val segments = if (path.isBlank()) emptyList() else path.trim('/').split('/').filter { it.isNotEmpty() }
203+ Row(
204+ modifier = modifier
205+ .horizontalScroll(rememberScrollState())
206+ .padding(horizontal = 16.dp, vertical = 8.dp),
207+ verticalAlignment = Alignment.CenterVertically,
208+ ) {
209+ IconButton(onClick = { onNavigate("") }) {
210+ Icon(Icons.Filled.Home, contentDescription = stringResource(R.string.root), modifier = Modifier.size(24.dp))
211+ }
212+ var acc = ""
213+ segments.forEachIndexed { index, segment ->
214+ acc = if (acc.isEmpty()) segment else "$acc/$segment"
215+ Icon(
216+ Icons.Filled.ChevronRight,
217+ contentDescription = null,
218+ modifier = Modifier.size(16.dp),
219+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
220+ )
221+ val target = acc
222+ Text(
223+ segment,
224+ style = MaterialTheme.typography.bodyMedium,
225+ color = if (index == segments.lastIndex) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.primary,
226+ modifier = Modifier
227+ .clickable(role = Role.Button) { onNavigate(target) }
228+ .defaultMinSize(minHeight = 48.dp)
229+ .padding(horizontal = 6.dp, vertical = 14.dp),
230+ )
231+ }
232+ }
233+}
234+
235+@Composable
236+private fun SearchResults(results: List<String>, onOpenFile: (String) -> Unit) {
237+ if (results.isEmpty()) {
238+ EmptyFileState(
239+ icon = Icons.Filled.Search,
240+ message = stringResource(R.string.no_matches),
241+ modifier = Modifier.fillMaxWidth().padding(24.dp),
242+ )
243+ return
244+ }
245+ LazyColumn(modifier = Modifier.fillMaxSize()) {
246+ // Key on path + index (not the bare path): a duplicate path from findFiles would
247+ // otherwise crash LazyColumn with "Key already used". Mirrors TextResults/SymbolResults.
248+ itemsIndexed(results, key = { i, p -> "$p:$i" }) { _, path ->
249+ // Split the path into directory (muted) + filename (emphasized) so results
250+ // are scannable instead of a wall of identical-looking full paths.
251+ val dir = path.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
252+ val name = path.substringAfterLast('/')
253+ FileRow(
254+ icon = false,
255+ label = name,
256+ sublabel = dir.takeIf { it.isNotEmpty() },
257+ onClick = { onOpenFile(path) },
258+ modifier = Modifier.animateItem(),
259+ )
260+ HorizontalDivider()
261+ }
262+ }
263+}
264+
265+@OptIn(ExperimentalMaterial3Api::class)
266+@Composable
267+private fun SearchModeChip(label: String, selected: Boolean, onClick: () -> Unit) {
268+ FilterChip(selected = selected, onClick = onClick, label = { Text(label) })
269+}
270+
271+/** Content (ripgrep) search results: file + line number, with the matched line highlighted. */
272+@Composable
273+private fun TextResults(results: List<FindMatch>, onOpen: (String, Int?) -> Unit) {
274+ if (results.isEmpty()) {
275+ EmptyFileState(
276+ icon = Icons.Filled.Search,
277+ message = stringResource(R.string.no_matches),
278+ modifier = Modifier.fillMaxWidth().padding(24.dp),
279+ )
280+ return
281+ }
282+ LazyColumn(modifier = Modifier.fillMaxSize()) {
283+ itemsIndexed(results, key = { i, m -> "${m.filePath}:${m.lineNumber}:$i" }) { _, match ->
284+ val name = match.filePath.substringAfterLast('/')
285+ val dir = match.filePath.substringBeforeLast('/', missingDelimiterValue = "").trimEnd('/')
286+ val highlighted = remember(match) { highlightMatchLine(match) }
287+ Column(
288+ modifier = Modifier
289+ .fillMaxWidth()
290+ .clickable(role = Role.Button) { onOpen(match.filePath, match.lineNumber) }
291+ .padding(horizontal = 16.dp, vertical = 10.dp),
292+ ) {
293+ Row(verticalAlignment = Alignment.CenterVertically) {
294+ Text(
295+ name,
296+ style = MaterialTheme.typography.bodyMedium,
297+ maxLines = 1,
298+ overflow = TextOverflow.Ellipsis,
299+ modifier = Modifier.weight(1f, fill = false),
300+ )
301+ Text(
302+ ":${match.lineNumber}",
303+ style = MaterialTheme.typography.labelSmall,
304+ color = MaterialTheme.colorScheme.onSurfaceVariant,
305+ fontFamily = FontFamily.Monospace,
306+ modifier = Modifier.padding(start = 4.dp),
307+ )
308+ }
309+ if (dir.isNotEmpty()) {
310+ Text(
311+ dir,
312+ style = MaterialTheme.typography.labelSmall,
313+ color = MaterialTheme.colorScheme.onSurfaceVariant,
314+ maxLines = 1,
315+ overflow = TextOverflow.Ellipsis,
316+ )
317+ }
318+ Text(
319+ highlighted,
320+ style = MaterialTheme.typography.bodySmall,
321+ fontFamily = FontFamily.Monospace,
322+ maxLines = 2,
323+ overflow = TextOverflow.Ellipsis,
324+ modifier = Modifier.padding(top = 2.dp),
325+ )
326+ }
327+ HorizontalDivider()
328+ }
329+ }
330+}
331+
332+/** Workspace symbol results: symbol name + kind, and the file:line it's defined at. */
333+@Composable
334+private fun SymbolResults(results: List<SymbolResult>, onOpen: (String, Int?) -> Unit) {
335+ if (results.isEmpty()) {
336+ EmptyFileState(
337+ icon = Icons.Filled.Search,
338+ message = stringResource(R.string.no_matches),
339+ modifier = Modifier.fillMaxWidth().padding(24.dp),
340+ )
341+ return
342+ }
343+ LazyColumn(modifier = Modifier.fillMaxSize()) {
344+ itemsIndexed(results, key = { i, s -> "${s.filePath}:${s.name}:$i" }) { _, symbol ->
345+ val fileName = symbol.filePath.substringAfterLast('/')
346+ Column(
347+ modifier = Modifier
348+ .fillMaxWidth()
349+ .clickable(role = Role.Button) { onOpen(symbol.filePath, symbol.displayLine) }
350+ .padding(horizontal = 16.dp, vertical = 10.dp),
351+ ) {
352+ Row(verticalAlignment = Alignment.CenterVertically) {
353+ Text(
354+ symbol.name,
355+ style = MaterialTheme.typography.bodyMedium,
356+ maxLines = 1,
357+ overflow = TextOverflow.Ellipsis,
358+ modifier = Modifier.weight(1f, fill = false),
359+ )
360+ Text(
361+ symbolKindLabel(symbol.kind),
362+ style = MaterialTheme.typography.labelSmall,
363+ color = MaterialTheme.colorScheme.primary,
364+ modifier = Modifier.padding(start = 8.dp),
365+ )
366+ }
367+ Text(
368+ "$fileName:${symbol.displayLine}",
369+ style = MaterialTheme.typography.labelSmall,
370+ color = MaterialTheme.colorScheme.onSurfaceVariant,
371+ fontFamily = FontFamily.Monospace,
372+ maxLines = 1,
373+ overflow = TextOverflow.Ellipsis,
374+ )
375+ }
376+ HorizontalDivider()
377+ }
378+ }
379+}
380+
381+/** Build the matched line with each ripgrep submatch bolded. Offsets are byte columns
382+ * within the line; clamped to the (trimmed) line length so a multibyte line can't crash. */
383+private fun highlightMatchLine(match: FindMatch): androidx.compose.ui.text.AnnotatedString {
384+ val line = match.lineText
385+ return buildAnnotatedString {
386+ var cursor = 0
387+ for (sub in match.submatches.sortedBy { it.start }) {
388+ val start = sub.start.coerceIn(0, line.length)
389+ val end = sub.end.coerceIn(start, line.length)
390+ if (start > cursor) append(line.substring(cursor, start))
391+ withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(line.substring(start, end)) }
392+ cursor = end
393+ }
394+ if (cursor < line.length) append(line.substring(cursor))
395+ }
396+}
397+
398+@Composable
399+private fun DirectoryListing(
400+ state: FileBrowserState,
401+ onOpenDir: (String) -> Unit,
402+ onUp: () -> Unit,
403+ onOpenFile: (String) -> Unit,
404+) {
405+ if (state.entries.isEmpty()) {
406+ EmptyFileState(
407+ icon = Icons.Filled.Folder,
408+ message = stringResource(R.string.empty_folder),
409+ modifier = Modifier.fillMaxWidth().padding(24.dp),
410+ )
411+ return
412+ }
413+ LazyColumn(modifier = Modifier.fillMaxSize()) {
414+ if (state.path.isNotBlank()) {
415+ item(key = "__up") {
416+ FileRow(icon = true, label = "..", onClick = onUp)
417+ HorizontalDivider()
418+ }
419+ }
420+ items(state.entries, key = { it.path + "_" + it.name }) { node ->
421+ FileRow(
422+ icon = node.isDirectory,
423+ label = node.name,
424+ onClick = { if (node.isDirectory) onOpenDir(node.path) else onOpenFile(node.path) },
425+ status = state.statusMap[node.path],
426+ modifier = Modifier.animateItem(),
427+ )
428+ HorizontalDivider()
429+ }
430+ }
431+}
432+
433+/** Shared empty-state for the file browser: icon + message, matching the icon+text
434+ * pattern used by the session and server lists (the prior versions were bare Text
435+ * lines that read as status messages rather than intentional empty states). */
436+@Composable
437+private fun EmptyFileState(icon: androidx.compose.ui.graphics.vector.ImageVector, message: String, modifier: Modifier = Modifier) {
438+ Column(
439+ modifier = modifier,
440+ horizontalAlignment = Alignment.CenterHorizontally,
441+ ) {
442+ Icon(
443+ icon,
444+ contentDescription = null,
445+ modifier = Modifier.size(48.dp),
446+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
447+ )
448+ Spacer(Modifier.size(12.dp))
449+ Text(
450+ message,
451+ style = MaterialTheme.typography.bodyMedium,
452+ color = MaterialTheme.colorScheme.onSurfaceVariant,
453+ )
454+ }
455+}
456+
457+@Composable
458+private fun FileRow(
459+ icon: Boolean,
460+ label: String,
461+ onClick: () -> Unit,
462+ status: FileStatusEntry? = null,
463+ sublabel: String? = null,
464+ modifier: Modifier = Modifier,
465+) {
466+ val fileDesc = if (label == "..") stringResource(R.string.parent_dir)
467+ else if (icon) stringResource(R.string.folder, label)
468+ else stringResource(R.string.file_label, label)
469+ // Build a combined description so TalkBack announces both the file name and its git
470+ // status. The parent sets an explicit contentDescription (mergeDescendants), which
471+ // would otherwise suppress the StatusBadge's own description.
472+ val statusDesc = status?.let {
473+ when (it.status) {
474+ "added" -> stringResource(R.string.git_added)
475+ "modified" -> stringResource(R.string.git_modified)
476+ "deleted" -> stringResource(R.string.git_deleted)
477+ else -> ""
478+ }
479+ }.orEmpty()
480+ val fullDesc = if (statusDesc.isNotEmpty()) "$fileDesc, $statusDesc" else fileDesc
481+ Row(
482+ modifier = modifier
483+ .fillMaxWidth()
484+ .defaultMinSize(minHeight = 48.dp)
485+ .clickable(role = Role.Button, onClick = onClick)
486+ .semantics(mergeDescendants = true) { contentDescription = fullDesc }
487+ .padding(horizontal = 16.dp, vertical = 14.dp),
488+ verticalAlignment = Alignment.CenterVertically,
489+ ) {
490+ Icon(
491+ if (icon) Icons.Filled.Folder else Icons.Filled.Description,
492+ contentDescription = null,
493+ modifier = Modifier.size(20.dp),
494+ tint = if (icon) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
495+ )
496+ Column(modifier = Modifier.weight(1f).padding(start = 8.dp)) {
497+ Text(
498+ label,
499+ style = MaterialTheme.typography.bodyLarge,
500+ maxLines = 1,
501+ overflow = TextOverflow.Ellipsis,
502+ )
503+ if (sublabel != null) {
504+ Text(
505+ sublabel,
506+ style = MaterialTheme.typography.labelSmall,
507+ color = MaterialTheme.colorScheme.onSurfaceVariant,
508+ maxLines = 1,
509+ overflow = TextOverflow.Ellipsis,
510+ )
511+ }
512+ }
513+ if (status != null) StatusBadge(status)
514+ }
515+}
516+
517+@Composable
518+private fun StatusBadge(status: FileStatusEntry) {
519+ val (letter, color) = when (status.status) {
520+ "added" -> "A" to MaterialTheme.colorScheme.primary
521+ "modified" -> "M" to MaterialTheme.colorScheme.tertiary
522+ "deleted" -> "D" to MaterialTheme.colorScheme.error
523+ else -> "·" to MaterialTheme.colorScheme.onSurfaceVariant
524+ }
525+ Row(
526+ verticalAlignment = Alignment.CenterVertically,
527+ ) {
528+ Text(
529+ letter,
530+ style = MaterialTheme.typography.labelMedium,
531+ fontFamily = FontFamily.Monospace,
532+ color = color,
533+ )
534+ if (status.added > 0 || status.removed > 0) {
535+ Text(
536+ "+${status.added} −${status.removed}",
537+ style = MaterialTheme.typography.labelSmall,
538+ fontFamily = FontFamily.Monospace,
539+ color = MaterialTheme.colorScheme.onSurfaceVariant,
540+ modifier = Modifier.padding(start = 4.dp),
541+ )
542+ }
543+ }
544+}
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.ktnew file+266

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/file/FileViewModel.ktnew file+59

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.ktnew file+565

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/mcp/McpScreen.ktnew file+140

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/mcp/McpServerInfo.ktnew file+45

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/mcp/McpViewModel.ktnew file+60

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/screens/ChatScreen.ktdeleted−590

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/screens/ConnectScreen.ktdeleted−222

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/screens/SessionsScreen.ktdeleted−402

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchScreen.ktnew file+161

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/search/GlobalSearchViewModel.ktnew file+134

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.ktnew file+472

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.ktnew file+374

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/server/ServerListScreen.ktnew file+460

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/server/ServerListViewModel.ktnew file+138

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/session/NewSessionDialog.ktnew file+250

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.ktnew file+946

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.ktnew file+774

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/session/TwoPaneSessionChat.ktnew file+200

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.ktnew file+421

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.ktnew file+684

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

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

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/theme/Type.ktdeleted−15

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/theme/Typography.ktnew file+87

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/usage/UsageAggregation.ktnew file+111

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/usage/UsageScreen.ktnew file+226

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/ui/usage/UsageViewModel.ktnew file+92

This diff is too big to show here.

app/src/main/java/soy/iko/opencode/util/Coroutines.ktnew file+44

This diff is too big to show here.

app/src/main/res/drawable/ic_launcher_foreground.xmlnew file+13

This diff is too big to show here.

app/src/main/res/drawable/widget_background.xmlnew file+6

This diff is too big to show here.

app/src/main/res/layout/widget_session_item.xmlnew file+21

This diff is too big to show here.

app/src/main/res/layout/widget_sessions.xmlnew file+64

This diff is too big to show here.

app/src/main/res/mipmap-anydpi/ic_launcher.xmlnew file+6

This diff is too big to show here.

app/src/main/res/values-es/strings.xmlnew file+473

This diff is too big to show here.

app/src/main/res/values-night/colors.xmldeleted−4

This diff is too big to show here.

app/src/main/res/values-night/themes.xmldeleted−8

This diff is too big to show here.

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

This diff is too big to show here.

app/src/main/res/values/strings.xml+471

This diff is too big to show here.

app/src/main/res/values/themes.xml+10−9

This diff is too big to show here.

app/src/main/res/xml/data_extraction_rules.xmlnew file+15

This diff is too big to show here.

app/src/main/res/xml/file_paths.xmlnew file+6

This diff is too big to show here.

app/src/main/res/xml/network_security_config.xmlnew file+15

This diff is too big to show here.

app/src/main/res/xml/sessions_widget_info.xmlnew file+10

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/M5DecodingTest.ktnew file+144

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/M6Test.ktnew file+102

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/OpencodeJsonTest.ktnew file+122

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/UxTest.ktnew file+91

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/core/ConnectUrlTest.ktdeleted−69

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/model/TodoPlanTest.ktnew file+90

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/network/EventStreamClientTest.ktnew file+37

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/network/NormalizeBaseUrlTest.ktnew file+42

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/network/OpencodeApiClientTest.ktnew file+115

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.ktnew file+254

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/CrashLoggerScrubTest.ktnew file+46

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/ErrorKindTest.ktnew file+60

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/MessageStoreTest.ktnew file+385

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/OutboxStoreTest.ktnew file+44

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/data/repo/SessionRepositoryTest.ktnew file+73

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/di/UnreadTrackingTest.ktnew file+56

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/chat/ChatViewModelTest.ktnew file+601

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/chat/ConversationExportTest.ktnew file+90

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/components/DiffParseTest.ktnew file+55

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/components/ExtractCodeTextTest.ktnew file+56

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/components/ExtractFenceLanguageTest.ktnew file+28

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/file/FileBrowserViewModelTest.ktnew file+219

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/file/FileViewModelTest.ktnew file+70

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/mcp/McpParserTest.ktnew file+52

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/search/GlobalSearchViewModelTest.ktnew file+120

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/server/CertPinValidationTest.ktnew file+39

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.ktnew file+464

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/session/SessionListViewModelTest.ktnew file+634

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.ktnew file+470

This diff is too big to show here.

app/src/test/java/soy/iko/opencode/ui/usage/UsageAggregationTest.ktnew file+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.ymlnew file+68

This diff is too big to show here.

flake.lock+3−3

This diff is too big to show here.

flake.nix+17−27

This diff is too big to show here.

generated/com/novi/bincode/BincodeDeserializer.javadeleted−38

This diff is too big to show here.

generated/com/novi/bincode/BincodeSerializer.javadeleted−33

This diff is too big to show here.

generated/com/novi/serde/ArrayLen.javadeleted−12

This diff is too big to show here.

generated/com/novi/serde/BinaryDeserializer.javadeleted−195

This diff is too big to show here.

generated/com/novi/serde/BinarySerializer.javadeleted−146

This diff is too big to show here.

generated/com/novi/serde/Bytes.javadeleted−54

This diff is too big to show here.

generated/com/novi/serde/DeserializationError.javadeleted−11

This diff is too big to show here.

generated/com/novi/serde/Deserializer.javadeleted−56

This diff is too big to show here.

generated/com/novi/serde/Int128.javadeleted−11

This diff is too big to show here.

generated/com/novi/serde/SerializationError.javadeleted−11

This diff is too big to show here.

generated/com/novi/serde/Serializer.javadeleted−58

This diff is too big to show here.

generated/com/novi/serde/Slice.javadeleted−40

This diff is too big to show here.

generated/com/novi/serde/Tuple2.javadeleted−40

This diff is too big to show here.

generated/com/novi/serde/Tuple3.javadeleted−46

This diff is too big to show here.

generated/com/novi/serde/Tuple4.javadeleted−54

This diff is too big to show here.

generated/com/novi/serde/Tuple5.javadeleted−61

This diff is too big to show here.

generated/com/novi/serde/Tuple6.javadeleted−67

This diff is too big to show here.

generated/com/novi/serde/Unit.javadeleted−20

This diff is too big to show here.

generated/com/novi/serde/Unsigned.javadeleted−11

This diff is too big to show here.

generated/soy/iko/opencode/shared/MessageStatus.javadeleted−150

This diff is too big to show here.

generated/soy/iko/opencode/shared/MessageView.javadeleted−131

This diff is too big to show here.

generated/soy/iko/opencode/shared/Requests.javadeleted−27

This diff is too big to show here.

generated/soy/iko/opencode/shared/Screen.javadeleted−150

This diff is too big to show here.

generated/soy/iko/opencode/shared/SessionView.javadeleted−77

This diff is too big to show here.

generated/soy/iko/opencode/shared/ToolView.javadeleted−86

This diff is too big to show here.

generated/soy/iko/opencode/shared/TraitHelpers.javadeleted−71

This diff is too big to show here.

generated/soy/iko/opencode/shared/ViewModel.javadeleted−185

This diff is too big to show here.

gradle.properties+2−5

This diff is too big to show here.

gradle/libs.versions.toml+52−25

This diff is too big to show here.

renovate.jsonnew file+32

This diff is too big to show here.

settings.gradle.kts−3

This diff is too big to show here.

shared/Cargo.lockdeleted−1358

This diff is too big to show here.

shared/Cargo.tomldeleted−31

This diff is too big to show here.

shared/src/app.rsdeleted−1051

This diff is too big to show here.

shared/src/bin/codegen.rsdeleted−35

This diff is too big to show here.

shared/src/lib.rsdeleted−17

This diff is too big to show here.

shared/src/protocol.rsdeleted−248

This diff is too big to show here.