git.iko.soy/opencode-android
on master help sign in
Skip redundant snapshots on reconnect, batch password reads, and deduplicate URL normalization
commit45321b6653afe3eb324d50fa47f451a639262375 authoriko <mail@iko.soy> dateWed 1 Jul 2026, 02:59 (3 months ago) parentbe390fb
4 files changed+88−18
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+5−3 app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+14−8 app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+21−7 app/src/test/java/soy/iko/opencode/data/repo/MessageStoreTest.kt+48
app/src/main/java/soy/iko/opencode/data/network/HttpClientFactory.kt+5−3
@@ -52,8 +52,10 @@ object HttpClientFactory {
5252 requestTimeoutMillis = NetworkConfig.restRequestTimeoutMs
5353 }
5454
55+ val normalizedUrl = normalizeBaseUrl(profile.baseUrl)
56+ val isHttps = normalizedUrl.startsWith("https://")
57+
5558 if (profile.hasAuth) {
56− val isHttps = normalizeBaseUrl(profile.baseUrl).startsWith("https://")
5759 if (isHttps) {
5860 install(Auth) {
5961 basic {
@@ -80,12 +82,12 @@ object HttpClientFactory {
8082 }
8183
8284 defaultRequest {
83− url.takeFrom(URLBuilder().takeFrom(normalizeBaseUrl(profile.baseUrl)))
85+ url.takeFrom(URLBuilder().takeFrom(normalizedUrl))
8486 // For HTTP profiles with auth, attach the Basic auth header proactively on
8587 // every request (the Auth plugin is skipped for non-HTTPS). The user chose
8688 // cleartext explicitly, so we honor that — but without the reactive challenge
8789 // logic that could re-send credentials silently.
88− if (profile.hasAuth && !normalizeBaseUrl(profile.baseUrl).startsWith("https://")) {
90+ if (profile.hasAuth && !isHttps) {
8991 val credentials = java.util.Base64.getEncoder().encodeToString(
9092 "${profile.username.orEmpty()}:${profile.password.orEmpty()}".toByteArray(),
9193 )
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+14−8
@@ -108,16 +108,21 @@ open class ProfileStore private constructor(
108108 // Migrate any orphaned plaintext passwords to secure prefs on first load.
109109 migrateFallbackPasswords()
110110 val json = prefs[profilesKey] ?: return@map emptyList()
111− runCatching {
111+ val stored = runCatching {
112112 OpencodeJson.decodeFromString(ListSerializer(StoredProfile.serializer()), json)
113113 }.onFailure { Log.w("ProfileStore", "Failed to decode stored profiles, ignoring", it) }
114114 .getOrDefault(emptyList())
115115 .sortedByDescending { it.lastUsed }
116− .map { it.toProfile() }
116+ // Batch all password lookups into a single IO dispatch instead of one
117+ // withContext round-trip per profile (N dispatches → 1). DataStore already
118+ // runs this map block on IO, but each passwordFor() call still pays the
119+ // suspend + redispatch overhead.
120+ val pwPrefs = prefsForPasswords()
121+ stored.map { it.toProfile(pwPrefs) }
117122 } ?: kotlinx.coroutines.flow.flowOf(emptyList())
118123
119− private suspend fun StoredProfile.toProfile(): ServerProfile {
120− val pw = username?.let { passwordFor(id) }
124+ private fun StoredProfile.toProfile(pwPrefs: SharedPreferences): ServerProfile {
125+ val pw = username?.let { pwPrefs.getString(passwordKey(id), null) }
121126 return ServerProfile(
122127 id = id,
123128 label = label,
@@ -129,13 +134,14 @@ open class ProfileStore private constructor(
129134 }
130135
131136 open suspend fun resolve(profile: ServerProfile): ServerProfile {
132− val pw = if (profile.hasAuth) passwordFor(profile.id) else null
137+ val pw = if (profile.hasAuth) {
138+ withContext(Dispatchers.IO) { prefsForPasswords().getString(passwordKey(profile.id), null) }
139+ } else {
140+ null
141+ }
133142 return profile.copy(password = pw)
134143 }
135144
136− private suspend fun passwordFor(id: String): String? =
137− withContext(Dispatchers.IO) { prefsForPasswords().getString(passwordKey(id), null) }
138−
139145 open suspend fun save(profile: ServerProfile) {
140146 // Write the password first so that if the DataStore write fails, we're left
141147 // with an orphaned password (harmless, cleaned up on next save/delete) rather
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+21−7
@@ -103,6 +103,7 @@ open class SessionRepository(
103103 val fresh = runCatchingCancellable { api.listMessages(sessionId) }
104104 .onFailure { Log.w("SessionRepository", "Re-seed message load failed for $sessionId; relying on SSE: ${safeExceptionSummary(it)}") }
105105 .getOrDefault(emptyList())
106+ var seedChanged = false
106107 lock.withLock {
107108 if (generation == seedGeneration) {
108109 // Merge without pruning: SSE events arriving during the
@@ -111,10 +112,10 @@ open class SessionRepository(
111112 // Stale messages from deletions during the disconnect
112113 // gap are eventually removed via MessageRemoved events
113114 // or on the next app restart.
114− store.seed(fresh, prune = false)
115+ seedChanged = store.seed(fresh, prune = false)
115116 }
116117 }
117− publish()
118+ if (seedChanged) publish()
118119 }
119120 hasConnectedBefore = true
120121 }
@@ -159,7 +160,8 @@ internal class MessageStore {
159160
160161 fun snapshot(): List<MessageWithParts> = messages.values.toList()
161162
162− fun seed(initial: List<MessageWithParts>, prune: Boolean = false) {
163+ fun seed(initial: List<MessageWithParts>, prune: Boolean = false): Boolean {
164+ var changed = false
163165 // On re-seed (after SSE reconnect), remove messages that are no longer in the
164166 // server snapshot (e.g. deleted during the disconnection gap). This keeps the
165167 // in-memory state in sync with the authoritative REST snapshot rather than
@@ -167,13 +169,16 @@ internal class MessageStore {
167169 // racing with just-arrived SSE events whose messages may not yet be in REST.
168170 if (prune) {
169171 val incomingIds = initial.mapTo(mutableSetOf()) { it.info.id }
172+ val before = messages.size
170173 messages.keys.retainAll(incomingIds)
174+ if (messages.size != before) changed = true
171175 }
172176
173177 for (m in initial) {
174178 val existing = messages[m.info.id]
175179 if (existing == null) {
176180 messages[m.info.id] = m
181+ changed = true
177182 } else {
178183 // A part streamed in between subscribe and this initial load may already
179184 // have populated this message (see observeMessages). Merge instead of
@@ -187,10 +192,15 @@ internal class MessageStore {
187192 if (p.id !in snapshotIds) ordered.add(p)
188193 }
189194 val info = if (existing.info is UnknownMessage) m.info else existing.info
190− messages[m.info.id] = MessageWithParts(info = info, parts = ordered)
195+ val merged = MessageWithParts(info = info, parts = ordered)
196+ if (merged != existing) {
197+ messages[m.info.id] = merged
198+ changed = true
199+ }
191200 }
192201 }
193− evictOldMessages()
202+ if (evictOldMessages()) changed = true
203+ return changed
194204 }
195205
196206 /** Returns true if the state changed (and a new snapshot should be published). */
@@ -280,11 +290,15 @@ internal class MessageStore {
280290 return true
281291 }
282292
283− /** Evict the oldest messages when the store exceeds [maxMessages], keeping memory bounded. */
284− private fun evictOldMessages() {
293+ /** Evict the oldest messages when the store exceeds [maxMessages], keeping memory bounded.
294+ * Returns true if any messages were evicted. */
295+ private fun evictOldMessages(): Boolean {
296+ var evicted = false
285297 while (messages.size > maxMessages) {
286298 val oldestKey = messages.keys.iterator().next()
287299 messages.remove(oldestKey)
300+ evicted = true
288301 }
302+ return evicted
289303 }
290304}
app/src/test/java/soy/iko/opencode/data/repo/MessageStoreTest.kt+48
@@ -249,6 +249,54 @@ class MessageStoreTest {
249249 assertEquals(listOf("m1"), store.snapshot().map { it.info.id })
250250 }
251251
252+ // --- seed return value (skip unnecessary snapshot on reconnect) ---
253+
254+ @Test
255+ fun seedReturnsTrueWhenAddingNewMessages() {
256+ val store = MessageStore()
257+ val changed = store.seed(listOf(MessageWithParts(UserMessage("u1", session))))
258+ assertTrue(changed)
259+ }
260+
261+ @Test
262+ fun seedReturnsFalseWhenReSeedingIdenticalMessages() {
263+ val store = MessageStore()
264+ val messages = listOf(MessageWithParts(UserMessage("u1", session)))
265+ store.seed(messages)
266+ val changed = store.seed(messages)
267+ assertFalse(changed)
268+ }
269+
270+ @Test
271+ fun seedReturnsTrueWhenMessagePartsDiffer() {
272+ val store = MessageStore()
273+ store.seed(listOf(MessageWithParts(UserMessage("u1", session))))
274+ val changed = store.seed(
275+ listOf(MessageWithParts(UserMessage("u1", session), listOf(TextPart(id = "t1")))),
276+ )
277+ assertTrue(changed)
278+ }
279+
280+ @Test
281+ fun seedPruneReturnsTrueWhenMessagesRemoved() {
282+ val store = MessageStore()
283+ store.seed(listOf(
284+ MessageWithParts(UserMessage("u1", session)),
285+ MessageWithParts(AssistantMessage("a1", session)),
286+ ))
287+ val changed = store.seed(listOf(MessageWithParts(UserMessage("u1", session))), prune = true)
288+ assertTrue(changed)
289+ }
290+
291+ @Test
292+ fun seedPruneReturnsFalseWhenNoMessagesRemoved() {
293+ val store = MessageStore()
294+ val messages = listOf(MessageWithParts(UserMessage("u1", session)))
295+ store.seed(messages)
296+ val changed = store.seed(messages, prune = true)
297+ assertFalse(changed)
298+ }
299+
252300 // --- seed prune (re-seed after SSE reconnect) ---
253301
254302 @Test