git.iko.soy/opencode-android
on master help sign in
Fix 12 bugs across network, repo, DI, and UI layers including abort retry idempotency, widget profile routing, SSL failure classification, cleartext credential warnings, and usage key collision
6dcf3ediko
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+20−2
@@ -78,7 +78,10 @@ open class EventStreamClient(
78
78
79 /** Classifies a non-retryable SSE failure so the UI can show the right recovery hint:
79 /** Classifies a non-retryable SSE failure so the UI can show the right recovery hint:
80 * an auth failure (401/403) reads "check credentials", while other 4xx (400/404/405…)
80 * an auth failure (401/403) reads "check credentials", while other 4xx (400/404/405…)
81− * reads "check the server URL and version". Returns null for transient/retryable errors. */
81+ * reads "check the server URL and version". Returns null for transient/retryable errors.
82+ * SSL/TLS and certificate-pinning failures are permanent (a changed/rotated cert won't
83+ * heal on retry) and are classified as Other so the user sees "check the server URL and
84+ * version / cert pin" instead of an infinite "Reconnecting…" spinner. */
82 private enum class SseFailureKind { Auth, Other }
85 private enum class SseFailureKind { Auth, Other }
83
86
84 private fun classifyNonRetryableSseFailure(e: Throwable): SseFailureKind? =
87 private fun classifyNonRetryableSseFailure(e: Throwable): SseFailureKind? =
@@ -88,11 +91,21 @@ open class EventStreamClient(
88 when (t) {
91 when (t) {
89 is io.ktor.client.plugins.sse.SSEClientException -> t.response?.status?.value
92 is io.ktor.client.plugins.sse.SSEClientException -> t.response?.status?.value
90 is io.ktor.client.plugins.ClientRequestException -> t.response.status.value
93 is io.ktor.client.plugins.ClientRequestException -> t.response.status.value
94+ // A TLS handshake / cert-pinning / SSL peer unverified failure happens
95+ // before any HTTP response, so it carries no status code. It is never
96+ // transient (the server's cert must change), so classify it directly
97+ // instead of falling through to the null (retryable) default — otherwise
98+ // the client retries forever with backoff against an unreachable pinned
99+ // server, showing "Reconnecting…" with no hint that the failure is fixed.
100+ is javax.net.ssl.SSLException -> -1
101+ is java.security.cert.CertificateException -> -1
91 else -> null
102 else -> null
92 }
103 }
93 }
104 }
94 // Non-408/429 4xx = permanent; 408 and 429 stay in the transient retry path.
105 // Non-408/429 4xx = permanent; 408 and 429 stay in the transient retry path.
106+ // The -1 sentinel (SSL/cert) is also permanent → Other.
95 .map { code -> when {
107 .map { code -> when {
108+ code == -1 -> SseFailureKind.Other
96 code in 400..499 && code != 408 && code != 429 ->
109 code in 400..499 && code != 408 && code != 429 ->
97 if (code == 401 || code == 403) SseFailureKind.Auth else SseFailureKind.Other
110 if (code == 401 || code == 403) SseFailureKind.Auth else SseFailureKind.Other
98 else -> null
111 else -> null
@@ -326,9 +339,14 @@ open class EventStreamClient(
326 if (reconnectConsumedDuringRead) return true
339 if (reconnectConsumedDuringRead) return true
327 var signaled = false
340 var signaled = false
328 val jitter = ((backoffMs * NetworkConfig.retryJitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
341 val jitter = ((backoffMs * NetworkConfig.retryJitterFactor) * (Random.nextDouble() * 2 - 1)).toLong()
342+ // Floor the wait at half the initial backoff: symmetric jitter can otherwise pull a
343+ // small backoff down to 0ms, producing a sub-second reconnect storm right after a
344+ // triggerReconnect reset on a connect-RST loop. A non-zero floor preserves the
345+ // purpose of backoff (spacing retries) while still allowing jitter to shorten it.
346+ val minWait = (initialBackoffMs / 2).coerceAtLeast(1L)
329 select {
347 select {
330 reconnectSignal.onReceive { signaled = true }
348 reconnectSignal.onReceive { signaled = true }
331− onTimeout((backoffMs + jitter).coerceAtLeast(0)) { /* normal backoff elapsed */ }
349+ onTimeout((backoffMs + jitter).coerceAtLeast(minWait)) { /* normal backoff elapsed */ }
332 }
350 }
333 return signaled
351 return signaled
334 }
352 }
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+11−2
@@ -186,8 +186,13 @@ open class OpencodeApiClient private constructor(
186 }
186 }
187 }
187 }
188
188
189+ /** Abort the active run in a session. NOT retried: a lost response followed by a
190+ * retry would POST abort again, and if a new run started in the gap the retry
191+ * would kill that *new* run. Unlike sendPrompt/runCommand/summarize there's no
192+ * Idempotency-Key the server could use to dedupe, so a single attempt is the
193+ * safe choice — the SSE stream reflects the actual run state regardless. */
189 open suspend fun abort(sessionId: String) {
194 open suspend fun abort(sessionId: String) {
190− withRetry { client!!.post("session/${encode(sessionId)}/abort").body<String>() }
195+ withRetry(maxAttempts = 1) { client!!.post("session/${encode(sessionId)}/abort").body<String>() }
191 }
196 }
192
197
193 /** Invoke a slash-command by name via `POST /session/:id/command`. */
198 /** Invoke a slash-command by name via `POST /session/:id/command`. */
@@ -414,9 +419,13 @@ open class OpencodeApiClient private constructor(
414 } catch (t: ClientRequestException) {
419 } catch (t: ClientRequestException) {
415 // A response that reached the server but whose reply was lost gets retried; the
420 // A response that reached the server but whose reply was lost gets retried; the
416 // retry finds the permission already resolved (404 gone / 409 conflict). Both mean
421 // retry finds the permission already resolved (404 gone / 409 conflict). Both mean
422+ // the answer landed (or the session/permission no longer exists, in which case the
417− // the answer landed, so treat them as success instead of a spurious failure.
423+ // response is moot anyway), so treat them as success instead of a spurious failure
424+ // that would re-queue the dialog against a gone session. Log the swallowed status
425+ // so a 404 caused by a deleted session is debuggable rather than fully silent.
418 val code = t.response.status.value
426 val code = t.response.status.value
419 if (code != 404 && code != 409) throw t
427 if (code != 404 && code != 409) throw t
428+ android.util.Log.d("OpencodeApi", "respondPermission swallowed $code for session=$sessionId permission=$permissionId")
420 ""
429 ""
421 }
430 }
422
431
app/src/main/java/soy/iko/opencode/data/repo/RecentSessionsStore.kt+11−2
@@ -11,9 +11,18 @@ import kotlinx.serialization.encodeToString
11import kotlinx.serialization.json.Json
11import kotlinx.serialization.json.Json
12import java.io.File
12import java.io.File
13
13
14−/** A minimal session summary persisted for the home-screen widget and launcher shortcuts. */
14+/** A minimal session summary persisted for the home-screen widget and launcher shortcuts.
15+ * Carries the originating [profileId] so a widget tap routes back to the server that ran
16+ * the session even after the user has switched to a different connection — without it,
17+ * the session would open on whichever server is currently active and 404 or show empty. */
15@Serializable
18@Serializable
16−data class RecentSession(val id: String, val title: String)
19+data class RecentSession(
20+ val id: String,
21+ val title: String,
22+ // Defaulted so a pre-profileId persisted file (from an older app version) still decodes:
23+ // the field is absent → default "" → the widget falls back to the active connection.
24+ val profileId: String = "",
25+)
17
26
18/**
27/**
19 * Persists a short list of recent sessions to a small JSON file under filesDir so app-external
28 * Persists a short list of recent sessions to a small JSON file under filesDir so app-external
app/src/main/java/soy/iko/opencode/data/repo/SessionRepository.kt+11−4
@@ -410,9 +410,13 @@ internal class MessageStore {
410 // accumulating stale messages forever. Skipped on the initial seed to avoid
410 // accumulating stale messages forever. Skipped on the initial seed to avoid
411 // racing with just-arrived SSE events whose messages may not yet be in REST.
411 // racing with just-arrived SSE events whose messages may not yet be in REST.
412 if (prune) {
412 if (prune) {
413+ // Build the retain-set from the synthetic key (matching the map's keying for
414+ // UnknownMessages with empty ids) so an entry stored under "unknown-c<created>"
415+ // isn't wrongly evicted and re-inserted under "" — which would lose its
416+ // insertion-order position and force a spurious snapshot emission.
413− val incomingIds = initial.mapTo(mutableSetOf()) { it.info.id }
417+ val incomingKeys = initial.mapTo(mutableSetOf()) { syntheticKeyFor(it) }
414 val before = messages.size
418 val before = messages.size
415− messages.keys.retainAll(incomingIds)
419+ messages.keys.retainAll(incomingKeys)
416 if (messages.size != before) changed = true
420 if (messages.size != before) changed = true
417 }
421 }
418
422
@@ -447,8 +451,11 @@ internal class MessageStore {
447 }
451 }
448 // Adopt the authoritative REST info unless the in-memory info was itself
452 // Adopt the authoritative REST info unless the in-memory info was itself
449 // updated live since the pivot (i.e. during this fetch), in which case it's
453 // updated live since the pivot (i.e. during this fetch), in which case it's
450− // newer than the snapshot. Otherwise the REST snapshot wins.
454+ // newer than the snapshot. Otherwise the REST snapshot wins. The pivot set is
455+ // keyed by the same synthetic key used in the map (see handleMessageUpdated),
456+ // so test that here instead of m.info.id — which is "" for an UnknownMessage
457+ // and would never match, leaving the pivot protection inert for that case.
451− val info = if (existing.info is UnknownMessage || m.info.id !in messageInfoUpdatedSincePivot) m.info else existing.info
458+ val info = if (existing.info is UnknownMessage || key !in messageInfoUpdatedSincePivot) m.info else existing.info
452 val merged = MessageWithParts(info = info, parts = ordered)
459 val merged = MessageWithParts(info = info, parts = ordered)
453 if (merged != existing.view()) {
460 if (merged != existing.view()) {
454 // Store under `key` (the synthetic key for an empty-id UnknownMessage),
461 // Store under `key` (the synthetic key for an empty-id UnknownMessage),
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+13−6
@@ -330,15 +330,19 @@ open class AppContainer private constructor(
330 }
330 }
331
331
332 /** Toggle a session's mute state. A muted session suppresses the unread badge and the
332 /** Toggle a session's mute state. A muted session suppresses the unread badge and the
333− * completion notification so a noisy conversation doesn't keep interrupting. Persisted
333+ * completion notification so a noisy conversation doesn't interrupt. Persisted
334 * via SessionPrefsStore so it survives process death. */
334 * via SessionPrefsStore so it survives process death. */
335 fun toggleSessionMute(id: String) {
335 fun toggleSessionMute(id: String) {
336− val muted = id in _mutedSessions.value
336+ // Use atomic update so a concurrent prefs reload (_mutedSessions.value = ids from
337− val next = if (muted) _mutedSessions.value - id else _mutedSessions.value + id
337+ // sessionPrefsStore.muted.collect) can't clobber this toggle mid read-modify-write.
338− _mutedSessions.value = next
338+ var nowMuted = false
339+ _mutedSessions.update { current ->
340+ nowMuted = id !in current
341+ if (nowMuted) current + id else current - id
342+ }
339 appContext?.let {
343 appContext?.let {
340 appScope.launch {
344 appScope.launch {
341− runCatchingCancellable { sessionPrefsStore.setMuted(id, !muted) }
345+ runCatchingCancellable { sessionPrefsStore.setMuted(id, nowMuted) }
342 }
346 }
343 }
347 }
344 }
348 }
@@ -1509,9 +1513,12 @@ open class AppContainer private constructor(
1509 // on activeConnection/isOnline/_outboxFlushTrigger — NOT on outboxStore.messages — so
1513 // on activeConnection/isOnline/_outboxFlushTrigger — NOT on outboxStore.messages — so
1510 // if connect() sets activeConnection before load() finishes reading a (possibly
1514 // if connect() sets activeConnection before load() finishes reading a (possibly
1511 // multi-MB) outbox.json, the connect-triggered flush sees an empty queue and nothing
1515 // multi-MB) outbox.json, the connect-triggered flush sees an empty queue and nothing
1512− // re-triggers it. Nudging the flush here guarantees a loaded queue is sent.
1516+ // re-triggers it. Nudging the flush here guarantees a loaded queue is sent. Log a
1517+ // load failure so a corrupt outbox.json / IO error is debuggable rather than silently
1518+ // dropping every queued offline message until a future restart happens to succeed.
1513 appScope.launch {
1519 appScope.launch {
1514 runCatchingCancellable { outboxStore.load() }
1520 runCatchingCancellable { outboxStore.load() }
1521+ .onFailure { Log.w("AppContainer", "Outbox load failed; queued messages may be lost: ${safeExceptionSummary(it)}") }
1515 flushOutbox()
1522 flushOutbox()
1516 }
1523 }
1517 observeOutbox()
1524 observeOutbox()
app/src/main/java/soy/iko/opencode/platform/AppShortcuts.kt+7
@@ -9,6 +9,7 @@ import androidx.core.graphics.drawable.IconCompat
9import soy.iko.opencode.MainActivity
9import soy.iko.opencode.MainActivity
10import soy.iko.opencode.R
10import soy.iko.opencode.R
11import soy.iko.opencode.data.repo.RecentSession
11import soy.iko.opencode.data.repo.RecentSession
12+import soy.iko.opencode.notification.NotificationActionReceiver
12
13
13/**
14/**
14 * Maintains the app's dynamic launcher shortcuts (long-press the icon): "New session" and
15 * Maintains the app's dynamic launcher shortcuts (long-press the icon): "New session" and
@@ -47,6 +48,12 @@ object AppShortcuts {
47 Intent(ctx, MainActivity::class.java).apply {
48 Intent(ctx, MainActivity::class.java).apply {
48 action = Intent.ACTION_VIEW
49 action = Intent.ACTION_VIEW
49 data = Uri.parse("opencode://session/$safeId")
50 data = Uri.parse("opencode://session/$safeId")
51+ // Embed the profile id so the session opens under the server that
52+ // ran it, not whichever is active when the user taps — mirrors the
53+ // widget and notification action paths.
54+ if (lastSession.profileId.isNotBlank()) {
55+ putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, lastSession.profileId)
56+ }
50 },
57 },
51 )
58 )
52 .build()
59 .build()
app/src/main/java/soy/iko/opencode/platform/SessionsWidgetService.kt+9−5
@@ -8,6 +8,7 @@ import soy.iko.opencode.MainActivity
8import soy.iko.opencode.R
8import soy.iko.opencode.R
9import soy.iko.opencode.data.repo.RecentSession
9import soy.iko.opencode.data.repo.RecentSession
10import soy.iko.opencode.data.repo.RecentSessionsStore
10import soy.iko.opencode.data.repo.RecentSessionsStore
11+import soy.iko.opencode.notification.NotificationActionReceiver
11import java.security.MessageDigest
12import java.security.MessageDigest
12
13
13/** Serves the list rows for [SessionsWidgetProvider] from [RecentSessionsStore]. */
14/** Serves the list rows for [SessionsWidgetProvider] from [RecentSessionsStore]. */
@@ -33,11 +34,14 @@ private class SessionsWidgetFactory(private val context: Context) : RemoteViewsS
33 val row = RemoteViews(context.packageName, R.layout.widget_session_item)
34 val row = RemoteViews(context.packageName, R.layout.widget_session_item)
34 val session = items.getOrNull(position) ?: return row
35 val session = items.getOrNull(position) ?: return row
35 row.setTextViewText(R.id.widget_item_title, session.title.ifBlank { context.getString(R.string.session) })
36 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+ // Fill-in intent merged into the list's template: carries the session id the app opens,
38+ // plus the profile id of the server that ran the session so it opens under THAT server
39+ // even after the user has switched connections — mirrors the notification action path.
40+ val fillIn = Intent().putExtra(MainActivity.EXTRA_SESSION_ID, session.id)
41+ if (session.profileId.isNotBlank()) {
42+ fillIn.putExtra(NotificationActionReceiver.EXTRA_PROFILE_ID, session.profileId)
43+ }
37− row.setOnClickFillInIntent(
44+ row.setOnClickFillInIntent(R.id.widget_item_root, fillIn)
38− R.id.widget_item_root,
39− Intent().putExtra(MainActivity.EXTRA_SESSION_ID, session.id),
40− )
41 return row
45 return row
42 }
46 }
43
47
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+1−1
@@ -805,7 +805,7 @@ private fun BoxScope.FileViewStateContent(
805 // display. Falls back to the binary-file label if decoding fails.
805 // display. Falls back to the binary-file label if decoding fails.
806 state.content != null && state.content.isBinary && run {
806 state.content != null && state.content.isBinary && run {
807 val ext = path.substringAfterLast('.', "").lowercase()
807 val ext = path.substringAfterLast('.', "").lowercase()
808− ext in setOf("png", "jpg", "jpeg", "gif", "webp", "bmp", "svg")
808+ ext in setOf("png", "jpg", "jpeg", "gif", "webp", "bmp", "svg", "ico", "heic")
809 } -> {
809 } -> {
810 ImageFilePreview(
810 ImageFilePreview(
811 content = state.content,
811 content = state.content,
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+12
@@ -210,6 +210,18 @@ private fun ServerEditForm(
210 onImeDone = { if (canSave && !state.saving) vm.connect(onDone) },
210 onImeDone = { if (canSave && !state.saving) vm.connect(onDone) },
211 )
211 )
212 }
212 }
213+ // Warn when credentials will travel over cleartext HTTP: an http:// URL with a
214+ // username sends Basic auth unencrypted on every request. Surface this prominently
215+ // so the user switches to https:// (or enables Require HTTPS) rather than silently
216+ // leaking the password on a hostile LAN.
217+ if (state.baseUrl.lowercase().startsWith("http://") && state.username.isNotBlank()) {
218+ Text(
219+ stringResource(R.string.cleartext_credentials_warning),
220+ color = MaterialTheme.colorScheme.error,
221+ style = MaterialTheme.typography.bodySmall,
222+ modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
223+ )
224+ }
213 // Single primary action: probe-then-save-and-connect. The probe IS the connection
225 // Single primary action: probe-then-save-and-connect. The probe IS the connection
214 // test — a failed probe keeps the user on this screen with the error shown below.
226 // test — a failed probe keeps the user on this screen with the error shown below.
215 Button(
227 Button(
app/src/main/java/soy/iko/opencode/ui/server/ServerSettingsScreen.kt+9
@@ -224,6 +224,15 @@ private fun ServerSettingsForm(
224 onImeDone = { if (canSave && !state.saving) vm.save(onDone) },
224 onImeDone = { if (canSave && !state.saving) vm.save(onDone) },
225 )
225 )
226 }
226 }
227+ // Warn when credentials will travel over cleartext HTTP (mirrors ServerEditScreen).
228+ if (state.baseUrl.lowercase().startsWith("http://") && state.username.isNotBlank()) {
229+ Text(
230+ stringResource(R.string.cleartext_credentials_warning),
231+ color = MaterialTheme.colorScheme.error,
232+ style = MaterialTheme.typography.bodySmall,
233+ modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
234+ )
235+ }
227 Button(
236 Button(
228 onClick = {
237 onClick = {
229 haptics.performHapticFeedback(HapticFeedbackType.LongPress)
238 haptics.performHapticFeedback(HapticFeedbackType.LongPress)
app/src/main/java/soy/iko/opencode/ui/session/SessionListScreen.kt+7−2
@@ -235,7 +235,7 @@ fun SessionListScreen(
235 // session list — the best source of session titles. Cheap, idempotent, and off the main
235 // session list — the best source of session titles. Cheap, idempotent, and off the main
236 // thread (the store write dispatches to IO); the widget refresh no-ops when none is placed.
236 // thread (the store write dispatches to IO); the widget refresh no-ops when none is placed.
237 val platformContext = LocalContext.current.applicationContext
237 val platformContext = LocalContext.current.applicationContext
238− SyncShortcutsAndWidget(platformContext, state)
238+ SyncShortcutsAndWidget(platformContext, state, connectedId)
239
239
240 // Reflect total unread sessions as a launcher-icon badge (silent notification on a
240 // Reflect total unread sessions as a launcher-icon badge (silent notification on a
241 // badge-enabled channel). Skips itself when the session list isn't loading a server yet.
241 // badge-enabled channel). Skips itself when the session list isn't loading a server yet.
@@ -1240,6 +1240,7 @@ private fun DateGroupHeader(label: String) {
1240private fun SyncShortcutsAndWidget(
1240private fun SyncShortcutsAndWidget(
1241 platformContext: android.content.Context,
1241 platformContext: android.content.Context,
1242 state: SessionListState,
1242 state: SessionListState,
1243+ profileId: String?,
1243) {
1244) {
1244 val serverIsEmpty = state.sessions.isEmpty() && state.error == null && !state.loading
1245 val serverIsEmpty = state.sessions.isEmpty() && state.error == null && !state.loading
1245 LaunchedEffect(serverIsEmpty) {
1246 LaunchedEffect(serverIsEmpty) {
@@ -1252,7 +1253,11 @@ private fun SyncShortcutsAndWidget(
1252 // involve binder IPC and file I/O; run them off the main thread so a debounced
1253 // involve binder IPC and file I/O; run them off the main thread so a debounced
1253 // session-update burst (e.g. during multi-session streaming) doesn't jank the list.
1254 // session-update burst (e.g. during multi-session streaming) doesn't jank the list.
1254 withContext(Dispatchers.IO) {
1255 withContext(Dispatchers.IO) {
1256+ // Embed the active profile id so a widget tap routes back to THIS server even
1257+ // after the user switches connections — without it, the session would open on
1258+ // whichever server is active when the user taps, 404ing or showing the wrong chat.
1259+ val pid = profileId.orEmpty()
1255− val recents = sessions.take(RecentSessionsStore.MAX).map { RecentSession(it.id, it.displayTitle) }
1260+ val recents = sessions.take(RecentSessionsStore.MAX).map { RecentSession(it.id, it.displayTitle, pid) }
1256 RecentSessionsStore.write(platformContext, recents)
1261 RecentSessionsStore.write(platformContext, recents)
1257 AppShortcuts.update(platformContext, recents.firstOrNull())
1262 AppShortcuts.update(platformContext, recents.firstOrNull())
1258 SessionsWidgetProvider.refresh(platformContext)
1263 SessionsWidgetProvider.refresh(platformContext)
app/src/main/java/soy/iko/opencode/ui/usage/UsageAggregation.kt+5−4
@@ -99,7 +99,7 @@ fun aggregateUsage(
99 var totalCost = 0.0
99 var totalCost = 0.0
100 var totalTokens = Tokens()
100 var totalTokens = Tokens()
101 var messageCount = 0
101 var messageCount = 0
102− val byModel = LinkedHashMap<String, MutableAgg>()
102+ val byModel = LinkedHashMap<Pair<String, String>, MutableAgg>()
103 val bySession = ArrayList<SessionUsage>()
103 val bySession = ArrayList<SessionUsage>()
104
104
105 for (entry in sessions) {
105 for (entry in sessions) {
@@ -125,9 +125,10 @@ fun aggregateUsage(
125 sessionTokens += tokens
125 sessionTokens += tokens
126 val model = info.modelID?.takeIf { it.isNotBlank() } ?: "unknown"
126 val model = info.modelID?.takeIf { it.isNotBlank() } ?: "unknown"
127 val provider = info.providerID?.takeIf { it.isNotBlank() } ?: "unknown"
127 val provider = info.providerID?.takeIf { it.isNotBlank() } ?: "unknown"
128− // Key by provider+model so the same model id served by different providers
128+ // Key by (provider, model) pair so a model id containing '/' (e.g. "org/model")
129− // stays two rows instead of collapsing into one ambiguous total.
129+ // can't collide with a different provider/model split that would produce the same
130+ // concatenated "$provider/$model" string.
130− val key = "$provider/$model"
131+ val key = provider to model
131 val agg = byModel.getOrPut(key) { MutableAgg(provider, model) }
132 val agg = byModel.getOrPut(key) { MutableAgg(provider, model) }
132 agg.messages++
133 agg.messages++
133 agg.cost += cost
134 agg.cost += cost
app/src/main/res/values-es/strings.xml+1
@@ -572,6 +572,7 @@
572 <string name="server_security">Seguridad</string>
572 <string name="server_security">Seguridad</string>
573 <string name="require_https">Requerir HTTPS</string>
573 <string name="require_https">Requerir HTTPS</string>
574 <string name="require_https_desc">Rechazar conexiones por HTTP sin cifrar</string>
574 <string name="require_https_desc">Rechazar conexiones por HTTP sin cifrar</string>
575+ <string name="cleartext_credentials_warning">Este servidor usa HTTP sin cifrar con usuario/contraseña: tus credenciales se enviarán sin cifrar por la red. Usa HTTPS si el servidor lo admite.</string>
575 <string name="cert_pin">Fijación de certificado</string>
576 <string name="cert_pin">Fijación de certificado</string>
576 <string name="cert_pin_hint">sha256/AAAA… (opcional)</string>
577 <string name="cert_pin_hint">sha256/AAAA… (opcional)</string>
577 <string name="cert_pin_desc">Fija el certificado TLS del servidor (SHA-256)</string>
578 <string name="cert_pin_desc">Fija el certificado TLS del servidor (SHA-256)</string>
app/src/main/res/values/strings.xml+1
@@ -588,6 +588,7 @@
588 <string name="server_security">Security</string>
588 <string name="server_security">Security</string>
589 <string name="require_https">Require HTTPS</string>
589 <string name="require_https">Require HTTPS</string>
590 <string name="require_https_desc">Refuse to connect over cleartext HTTP</string>
590 <string name="require_https_desc">Refuse to connect over cleartext HTTP</string>
591+ <string name="cleartext_credentials_warning">This server uses cleartext HTTP with a username/password — your credentials will be sent unencrypted over the network. Use HTTPS if the server supports it.</string>
591 <string name="cert_pin">Certificate pin</string>
592 <string name="cert_pin">Certificate pin</string>
592 <string name="cert_pin_hint">sha256/AAAA… (optional)</string>
593 <string name="cert_pin_hint">sha256/AAAA… (optional)</string>
593 <string name="cert_pin_desc">Pin the server\'s TLS certificate (SHA-256)</string>
594 <string name="cert_pin_desc">Pin the server\'s TLS certificate (SHA-256)</string>