@@ -4,6 +4,7 @@ import android.content.Context
44import android.net.ConnectivityManager
55import android.net.Network
66import android.net.NetworkRequest
7+import android.util.Log
78import soy.iko.opencode.data.model.BusEvent
89import soy.iko.opencode.data.model.MessagePartUpdated
910import soy.iko.opencode.data.model.MessageUpdated
@@ -22,6 +23,7 @@ import kotlinx.coroutines.CoroutineScope
2223import kotlinx.coroutines.Dispatchers
2324import kotlinx.coroutines.ExperimentalCoroutinesApi
2425import kotlinx.coroutines.SupervisorJob
26+import kotlinx.coroutines.cancel
2527import kotlinx.coroutines.flow.MutableStateFlow
2628import kotlinx.coroutines.flow.StateFlow
2729import kotlinx.coroutines.flow.asStateFlow
@@ -30,6 +32,8 @@ import kotlinx.coroutines.flow.first
3032import kotlinx.coroutines.flow.flatMapLatest
3133import kotlinx.coroutines.flow.update
3234import kotlinx.coroutines.launch
35+import kotlinx.coroutines.sync.Mutex
36+import kotlinx.coroutines.sync.withLock
3337import java.util.concurrent.atomic.AtomicBoolean
3438
3539/**
@@ -38,7 +42,7 @@ import java.util.concurrent.atomic.AtomicBoolean
3842 */
3943class AppContainer(context: Context) {
4044
41β val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
45+ val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
4246 val profileStore = ProfileStore(context)
4347 val settingsStore = SettingsStore(context)
4448 val draftStore = DraftStore(context)
@@ -124,11 +128,17 @@ class AppContainer(context: Context) {
124128
125129 init {
126130 NotificationChannels.create(appContext)
127β registerNetworkMonitor()
128131 observeMessageActivity()
129132 appScope.launch { autoConnect() }
130133 }
131134
135+ /** Release resources held for the process lifetime (network callback, app scope). */
136+ fun shutdown() {
137+ val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
138+ networkCallback?.let { runCatching { cm?.unregisterNetworkCallback(it) } }
139+ appScope.cancel()
140+ }
141+
132142 /**
133143 * Watch the SSE bus for new message activity and badge any session that isn't
134144 * currently open. This powers the unread dot on the session list so the user can
@@ -162,6 +172,15 @@ class AppContainer(context: Context) {
162172 /** Session ids currently streaming an assistant run (best-effort, in-process). */
163173 private val activeRuns: MutableSet<String> = java.util.Collections.synchronizedSet(mutableSetOf())
164174
175+ /** Guards connect/disconnect so concurrent callers can't leak an old connection. */
176+ private val connectionMutex = Mutex()
177+
178+ /** Only persist the `lastUsed` timestamp if it's older than this, to avoid
179+ * a DataStore + encrypted-prefs write on every rapid reconnect. */
180+ private companion object {
181+ const val LAST_USED_SAVE_THRESHOLD_MS = 60_000L
182+ }
183+
165184 /** Resolve the title for [sessionId] (best-effort) and post a completion notification. */
166185 private suspend fun notifySessionCompleted(sessionId: String) {
167186 val conn = activeConnection.value ?: return
@@ -195,29 +214,42 @@ class AppContainer(context: Context) {
195214 * When the device regains connectivity, nudge the active SSE stream to reconnect
196215 * immediately instead of waiting out the exponential backoff.
197216 */
198β private fun registerNetworkMonitor() {
217+ private val networkCallback: ConnectivityManager.NetworkCallback? = registerNetworkMonitor()
218+
219+ private fun registerNetworkMonitor(): ConnectivityManager.NetworkCallback? {
199220 val cm = appContext.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
200β ?: return
201β runCatching {
202β cm.registerNetworkCallback(NetworkRequest.Builder().build(), object : ConnectivityManager.NetworkCallback() {
203β override fun onAvailable(network: Network) {
204β activeConnection.value?.events?.triggerReconnect()
205β }
206β })
221+ ?: return null
222+ val callback = object : ConnectivityManager.NetworkCallback() {
223+ override fun onAvailable(network: Network) {
224+ activeConnection.value?.events?.triggerReconnect()
225+ }
207226 }
227+ runCatching { cm.registerNetworkCallback(NetworkRequest.Builder().build(), callback) }
228+ .onFailure { Log.w("AppContainer", "Failed to register network callback", it) }
229+ return callback
208230 }
209231
210β /** Open (or re-open) a connection to [profile], replacing any current one. */
211β suspend fun connect(profile: ServerProfile): OpencodeConnection {
212β disconnect()
213β val resolved = profileStore.resolve(profile).copy(lastUsed = System.currentTimeMillis())
214β profileStore.save(resolved)
215β return OpencodeConnection(resolved).also { _activeConnection.value = it }
216β }
232+ /** Open (or re-open) a connection to [profile], replacing any current one.
233+ * Only persists the updated `lastUsed` timestamp if it's stale (older than a
234+ * minute), so rapid reconnect storms don't each trigger a DataStore write. */
235+ suspend fun connect(profile: ServerProfile): OpencodeConnection =
236+ connectionMutex.withLock {
237+ _activeConnection.value?.close()
238+ _activeConnection.value = null
239+ val now = System.currentTimeMillis()
240+ val resolved = profileStore.resolve(profile)
241+ val needsSave = (now - resolved.lastUsed) > LAST_USED_SAVE_THRESHOLD_MS
242+ val finalProfile = if (needsSave) resolved.copy(lastUsed = now) else resolved
243+ if (needsSave) profileStore.save(finalProfile)
244+ OpencodeConnection(finalProfile).also { _activeConnection.value = it }
245+ }
217246
218247 fun disconnect() {
219β _activeConnection.value?.close()
248+ // Non-suspending best-effort disconnect; if a connect is in progress under the
249+ // mutex, the connection it sets will be closed on the next disconnect/connect.
250+ val conn = _activeConnection.value ?: return
220251 _activeConnection.value = null
252+ conn.close()
221253 }
222254}
223255