1package soy.iko.opencode.di
3import android.content.Context
4import android.net.ConnectivityManager
5import android.net.Network
6import android.net.NetworkRequest
8import soy.iko.opencode.data.model.BusEvent
9import soy.iko.opencode.data.model.MessagePartUpdated
10import soy.iko.opencode.data.model.MessageUpdated
11import soy.iko.opencode.data.model.ServerProfile
12import soy.iko.opencode.data.model.SessionIdle
13import soy.iko.opencode.data.repo.DraftStore
14import soy.iko.opencode.data.repo.ErrorKind
15import soy.iko.opencode.data.repo.ProfileStore
16import soy.iko.opencode.data.repo.SettingsStore
17import soy.iko.opencode.data.repo.classifyError
18import soy.iko.opencode.data.repo.responseStatusCode
19import soy.iko.opencode.notification.NotificationChannels
20import soy.iko.opencode.notification.SessionNotifications
21import soy.iko.opencode.R
22import kotlinx.coroutines.CoroutineScope
23import kotlinx.coroutines.Dispatchers
24import kotlinx.coroutines.ExperimentalCoroutinesApi
25import kotlinx.coroutines.SupervisorJob
26import kotlinx.coroutines.cancel
27import kotlinx.coroutines.flow.MutableStateFlow
28import kotlinx.coroutines.flow.StateFlow
29import kotlinx.coroutines.flow.asStateFlow
30import kotlinx.coroutines.flow.emptyFlow
31import kotlinx.coroutines.flow.first
32import kotlinx.coroutines.flow.flatMapLatest
33import kotlinx.coroutines.flow.update
34import kotlinx.coroutines.launch
35import kotlinx.coroutines.sync.Mutex
36import kotlinx.coroutines.sync.withLock
37import java.util.concurrent.atomic.AtomicBoolean
40 * Hand-written service locator held by [soy.iko.opencode.OpencodeApp]. Owns the
41 * process-wide singletons and the currently active [OpencodeConnection].
43class AppContainer(context: Context) {
45 private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
46 val profileStore = ProfileStore(context)
47 val settingsStore = SettingsStore(context)
48 val draftStore = DraftStore(context, appScope)
50 private val appContext = context.applicationContext
52 private val _activeConnection = MutableStateFlow<OpencodeConnection?>(null)
53 val activeConnection: StateFlow<OpencodeConnection?> = _activeConnection.asStateFlow()
55 /** True while a background auto-reconnect to the most-recent server is in flight. */
56 private val _reconnecting = MutableStateFlow(false)
57 val reconnecting: StateFlow<Boolean> = _reconnecting.asStateFlow()
60 * Set once a startup auto-reconnect succeeds. The server list collects this and
61 * (once) navigates straight to the session list so a returning user skips it.
62 * [consumeAutoConnect] guards against re-firing on screen re-entry.
64 private val _autoConnectDone = MutableStateFlow(false)
65 val autoConnectDone: StateFlow<Boolean> = _autoConnectDone.asStateFlow()
66 private val autoConnectConsumed = AtomicBoolean(false)
67 fun consumeAutoConnect(): Boolean = autoConnectConsumed.compareAndSet(false, true)
69 /** Text shared into the app (ACTION_SEND), prefilled into a session draft. */
70 private val _pendingShare = MutableStateFlow<String?>(null)
71 val pendingShare: StateFlow<String?> = _pendingShare.asStateFlow()
72 fun setPendingShare(text: String) { _pendingShare.value = text }
73 fun consumePendingShare(): String? {
74 val current = _pendingShare.value ?: return null
75 return if (_pendingShare.compareAndSet(current, null)) current else null
79 * A session id to open from an external trigger (a notification tap or a deep link).
80 * The nav host consumes it once a connection is active.
82 private val _pendingOpenSession = MutableStateFlow<String?>(null)
83 val pendingOpenSession: StateFlow<String?> = _pendingOpenSession.asStateFlow()
84 fun requestOpenSession(id: String) { _pendingOpenSession.value = id }
85 fun consumePendingOpenSession(): String? {
86 val current = _pendingOpenSession.value ?: return null
87 return if (_pendingOpenSession.compareAndSet(current, null)) current else null
91 * The session the user is currently viewing (or null when not in a chat). Drives the
92 * unread tracker: messages arriving for any *other* session mark it unread, and
93 * opening a session clears its unread state.
95 private val _currentSession = MutableStateFlow<String?>(null)
96 val currentSession: StateFlow<String?> = _currentSession.asStateFlow()
98 /** Session ids that received activity while not being viewed. */
99 private val _unread = MutableStateFlow<Set<String>>(emptySet())
100 val unread: StateFlow<Set<String>> = _unread.asStateFlow()
102 fun setCurrentSession(id: String?) {
103 _currentSession.value = id
105 _unread.update { it - id }
106 SessionNotifications.cancel(appContext, id)
110 /** Resolve a localized string — view models reach resources through the container. */
111 fun string(id: Int, vararg formatArgs: Any): String =
112 if (formatArgs.isEmpty()) appContext.getString(id) else appContext.getString(id, *formatArgs)
115 * Convert a throwable into a user-facing message. Classifies the error by concrete
116 * type (network, timeout, HTTP status) rather than string-matching class names, so
117 * the message reflects what actually went wrong without leaking internal URLs/state.
119 fun friendlyError(t: Throwable): String {
120 val baseUrl = activeConnection.value?.profile?.baseUrl.orEmpty()
121 return when (classifyError(t)) {
122 ErrorKind.NOT_REACHABLE, ErrorKind.NETWORK ->
123 string(R.string.error_not_reachable, baseUrl)
124 ErrorKind.TIMEOUT -> string(R.string.error_timeout)
125 ErrorKind.SERVER -> string(R.string.error_server)
126 // Show only the HTTP status (e.g. 401, 404) — never the request URL,
127 // which a ClientRequestException carries in its message and which we
128 // promised not to leak (it can include auth or internal paths).
129 ErrorKind.CLIENT -> responseStatusCode(t)?.let { string(R.string.error_client_status, it) }
130 ?: string(R.string.error_generic)
131 ErrorKind.UNKNOWN -> string(R.string.error_generic)
136 NotificationChannels.create(appContext)
137 observeMessageActivity()
138 appScope.launch { autoConnect() }
141 /** Release resources held for the process lifetime (network callback, app scope). */
143 _activeConnection.value?.close()
144 _activeConnection.value = null
145 val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
146 networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
151 * Watch the SSE bus for new message activity and badge any session that isn't
152 * currently open. This powers the unread dot on the session list so the user can
153 * tell which conversations got a reply while they were elsewhere.
155 * Uses [flatMapLatest] so a server switch (which replaces the active connection)
156 * re-subscribes to the new event stream and drops the old one.
158 @OptIn(ExperimentalCoroutinesApi::class)
159 private fun observeMessageActivity() {
162 .flatMapLatest { conn -> conn?.events?.events ?: emptyFlow() }
164 // SessionIdle is not "message activity" (don't badge as unread)
165 // but signals a run finished — fire a completion notification if the
166 // session isn't currently being viewed and was actively streaming.
167 if (event is SessionIdle) {
168 val idleSid = event.properties.sessionID ?: return@collect
169 if (idleSid != _currentSession.value && activeRuns.remove(idleSid)) {
170 notifySessionCompleted(idleSid)
174 val sid = sessionOf(event) ?: return@collect
175 if (sid != _currentSession.value) {
176 _unread.update { it + sid }
178 // Track sessions actively streaming so we know which idle events
179 // represent a finished run worth notifying about.
180 if (event is MessagePartUpdated || event is MessageUpdated) {
181 if (activeRuns.size >= activeRunsLimit) activeRuns.clear()
188 /** Session ids currently streaming an assistant run (best-effort, in-process). */
189 private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
191 /** Upper bound on [activeRuns] to prevent unbounded growth if SessionIdle never arrives. */
192 private val activeRunsLimit = 200
194 /** Guards connect/disconnect so concurrent callers can't leak an old connection. */
195 private val connectionMutex = Mutex()
197 /** Only persist the `lastUsed` timestamp if it's older than this, to avoid
198 * a DataStore + encrypted-prefs write on every rapid reconnect. */
199 private companion object {
200 const val LAST_USED_SAVE_THRESHOLD_MS = 60_000L
203 /** Resolve the title for [sessionId] (best-effort) and post a completion notification. */
204 private suspend fun notifySessionCompleted(sessionId: String) {
205 val conn = activeConnection.value ?: return
206 val title = runCatching {
207 conn.repository.listSessions().firstOrNull { it.id == sessionId }?.displayTitle
208 }.getOrNull() ?: sessionId
209 SessionNotifications.postCompleted(appContext, sessionId, title)
212 /** Extract the session id an event pertains to, for message-activity events. */
213 private fun sessionOf(event: BusEvent): String? = sessionOfEvent(event)
216 * On cold start, transparently reconnect to the most recently used server so a
217 * returning user lands in their session list instead of the empty server screen.
219 private suspend fun autoConnect() {
220 val recent = runCatching { profileStore.profiles.first() }
221 .getOrDefault(emptyList())
222 .firstOrNull() ?: return
223 _reconnecting.value = true
224 val ok = runCatching {
225 val conn = connect(recent)
228 _reconnecting.value = false
229 if (ok) _autoConnectDone.value = true else disconnect()
233 * When the device regains connectivity, nudge the active SSE stream to reconnect
234 * immediately instead of waiting out the exponential backoff.
236 private val networkCallback: ConnectivityManager.NetworkCallback? = registerNetworkMonitor()
238 private fun registerNetworkMonitor(): ConnectivityManager.NetworkCallback? {
239 val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
241 val callback = object : ConnectivityManager.NetworkCallback() {
242 override fun onAvailable(network: Network) {
243 activeConnection.value?.events?.triggerReconnect()
246 runCatching { cm.registerNetworkCallback(NetworkRequest.Builder().build(), callback) }
247 .onFailure { Log.w("AppContainer", "Failed to register network callback", it) }
251 /** Open (or re-open) a connection to [profile], replacing any current one.
252 * Only persists the updated `lastUsed` timestamp if it's stale (older than a
253 * minute), so rapid reconnect storms don't each trigger a DataStore write. */
254 suspend fun connect(profile: ServerProfile): OpencodeConnection =
255 connectionMutex.withLock {
256 _activeConnection.value?.close()
257 _activeConnection.value = null
259 val now = System.currentTimeMillis()
260 val resolved = profileStore.resolve(profile)
261 val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
262 val finalProfile = if (needsSave) resolved.copy(lastUsed = now) else resolved
263 if (needsSave) profileStore.save(finalProfile)
264 OpencodeConnection(finalProfile).also { _activeConnection.value = it }
267 suspend fun disconnect() {
268 // Acquire the connection mutex so disconnect is serialized with connect().
269 // Previously this was fire-and-forget (launch on appScope), which raced with
270 // a subsequent connect(): connect() could acquire the mutex, create a new
271 // connection, and then the pending disconnect() would close it.
272 connectionMutex.withLock {
273 _activeConnection.value?.close()
274 _activeConnection.value = null
280 * Extract the session id a message-activity event pertains to, or null for events that
281 * don't describe message activity. This drives the unread badge: any session that
282 * receives activity while it isn't the currently-viewed one gets badged. Extracted as
283 * a top-level `internal` function so the rule is unit-testable without an Android
284 * [Context] (the surrounding [AppContainer] needs one).
286internal fun sessionOfEvent(event: BusEvent): String? = when (event) {
287 is MessageUpdated -> event.properties.info.sessionID
288 is MessagePartUpdated -> event.properties.part.sessionID ?: event.properties.sessionID
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list