git.iko.soy/opencode-android
on master help sign in
Fix completion-notification dead code, non-idempotent sendPrompt retry, CrashLogger thread-safety and path traversal, ProfileStore keystore crash, share-intent crashes, stale catalogs on server switch, deprecated getPackageInfo API, accessibility roles, and add backoff jitter
0bc1edaiko
app/src/main/java/soy/iko/opencode/data/network/EventStreamClient.kt+6−3
@@ -101,9 +101,11 @@ class EventStreamClient(
101 // Stream closed cleanly: exit to reconnect without a warning log.
101 // Stream closed cleanly: exit to reconnect without a warning log.
102 val sse = result.getOrNull() ?: break
102 val sse = result.getOrNull() ?: break
103 val data = sse.data ?: continue
103 val data = sse.data ?: continue
104− val event = runCatching {
104+ val event = try {
105 OpencodeJson.decodeFromString(BusEvent.serializer(), data)
105 OpencodeJson.decodeFromString(BusEvent.serializer(), data)
106+ } catch (e: kotlinx.serialization.SerializationException) {
106− }.getOrNull() ?: continue
107+ continue
108+ }
107 // Any successfully decoded event means the stream is live;
109 // Any successfully decoded event means the stream is live;
108 // don't wait for a ServerConnected event to show "Connected".
110 // don't wait for a ServerConnected event to show "Connected".
109 if (_state.value != ConnectionState.Connected) {
111 if (_state.value != ConnectionState.Connected) {
@@ -124,9 +126,10 @@ class EventStreamClient(
124 if (!isActive) break
126 if (!isActive) break
125 // Wait for the backoff, but allow a reconnect signal to cut it short.
127 // Wait for the backoff, but allow a reconnect signal to cut it short.
126 var signaled = false
128 var signaled = false
129+ val jitter = (backoffMs * 0.2 * Math.random()).toLong()
127 select {
130 select {
128 reconnectSignal.onReceive { signaled = true }
131 reconnectSignal.onReceive { signaled = true }
129− onTimeout(backoffMs) { /* normal backoff elapsed */ }
132+ onTimeout(backoffMs + jitter) { /* normal backoff elapsed */ }
130 }
133 }
131 backoffMs = if (signaled) NetworkConfig.sseInitialBackoffMs else (backoffMs * 2).coerceAtMost(NetworkConfig.sseMaxBackoffMs)
134 backoffMs = if (signaled) NetworkConfig.sseInitialBackoffMs else (backoffMs * 2).coerceAtMost(NetworkConfig.sseMaxBackoffMs)
132 }
135 }
app/src/main/java/soy/iko/opencode/data/network/NetworkConfig.kt+2
@@ -26,6 +26,8 @@ object NetworkConfig {
26 const val retryMaxAttempts = 3
26 const val retryMaxAttempts = 3
27 /** Initial backoff delay; doubles on each successive failure (exponential backoff). */
27 /** Initial backoff delay; doubles on each successive failure (exponential backoff). */
28 const val retryInitialDelayMs = 500L
28 const val retryInitialDelayMs = 500L
29+ /** Jitter factor for retry backoff (0.2 = ±20% random jitter added to each delay). */
30+ const val retryJitterFactor = 0.2
29
31
30 // --- SSE stream (EventStreamClient) ---
32 // --- SSE stream (EventStreamClient) ---
31
33
app/src/main/java/soy/iko/opencode/data/network/OpencodeApiClient.kt+8−4
@@ -69,7 +69,7 @@ class OpencodeApiClient(private val client: HttpClient) {
69 text: String,
69 text: String,
70 model: ModelRef? = null,
70 model: ModelRef? = null,
71 agent: String? = null,
71 agent: String? = null,
72− ): MessageWithParts = withRetry {
72+ ): MessageWithParts =
73 client.post("session/$sessionId/message") {
73 client.post("session/$sessionId/message") {
74 contentType(ContentType.Application.Json)
74 contentType(ContentType.Application.Json)
75 setBody(
75 setBody(
@@ -80,7 +80,6 @@ class OpencodeApiClient(private val client: HttpClient) {
80 ),
80 ),
81 )
81 )
82 }.body()
82 }.body()
83− }
84
83
85 suspend fun abort(sessionId: String) {
84 suspend fun abort(sessionId: String) {
86 client.post("session/$sessionId/abort")
85 client.post("session/$sessionId/abort")
@@ -145,7 +144,7 @@ private suspend fun <T> withRetry(
145 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
144 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
146 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
145 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
147 block: suspend () -> T,
146 block: suspend () -> T,
148−): T = withRetryInternal(maxAttempts, initialDelayMs, block)
147+): T = withRetryInternal(maxAttempts, initialDelayMs, jitterFactor = NetworkConfig.retryJitterFactor, block)
149
148
150/**
149/**
151 * Retry a suspending block up to [maxAttempts] times with exponential backoff.
150 * Retry a suspending block up to [maxAttempts] times with exponential backoff.
@@ -158,6 +157,7 @@ private suspend fun <T> withRetry(
158internal suspend fun <T> withRetryInternal(
157internal suspend fun <T> withRetryInternal(
159 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
158 maxAttempts: Int = NetworkConfig.retryMaxAttempts,
160 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
159 initialDelayMs: Long = NetworkConfig.retryInitialDelayMs,
160+ jitterFactor: Double = NetworkConfig.retryJitterFactor,
161 block: suspend () -> T,
161 block: suspend () -> T,
162): T {
162): T {
163 var lastError: Throwable? = null
163 var lastError: Throwable? = null
@@ -169,7 +169,11 @@ internal suspend fun <T> withRetryInternal(
169 } catch (t: Throwable) {
169 } catch (t: Throwable) {
170 lastError = t
170 lastError = t
171 if (t is ClientRequestException) throw t
171 if (t is ClientRequestException) throw t
172+ if (attempt < maxAttempts) {
172− if (attempt < maxAttempts) delay(initialDelayMs * (1 shl (attempt - 1)))
173+ val baseDelay = initialDelayMs * (1 shl (attempt - 1))
174+ val jitter = (baseDelay * jitterFactor * Math.random()).toLong()
175+ delay(baseDelay + jitter)
176+ }
173 }
177 }
174 }
178 }
175 throw lastError ?: IllegalStateException("withRetry failed without error")
179 throw lastError ?: IllegalStateException("withRetry failed without error")
app/src/main/java/soy/iko/opencode/data/repo/CrashLogger.kt+17−4
@@ -1,6 +1,7 @@
1package soy.iko.opencode.data.repo
1package soy.iko.opencode.data.repo
2
2
3import android.content.Context
3import android.content.Context
4+import android.content.pm.PackageManager
4import android.os.Build
5import android.os.Build
5import android.util.Log
6import android.util.Log
6import androidx.core.content.pm.PackageInfoCompat
7import androidx.core.content.pm.PackageInfoCompat
@@ -61,13 +62,16 @@ class CrashLogger private constructor(private val appContext: Context) {
61 }
62 }
62
63
63 fun readReport(fileName: String): String? {
64 fun readReport(fileName: String): String? {
64− val file = File(crashDir, fileName)
65+ val file = File(crashDir, fileName).canonicalFile
65− if (!file.isFile) return null
66+ if (!file.path.startsWith(crashDir.canonicalPath + File.separator) || !file.isFile) return null
66 return runCatching { file.readText() }.getOrNull()
67 return runCatching { file.readText() }.getOrNull()
67 }
68 }
68
69
69 fun deleteReport(fileName: String) {
70 fun deleteReport(fileName: String) {
70− File(crashDir, fileName).delete()
71+ val file = File(crashDir, fileName).canonicalFile
72+ if (file.path.startsWith(crashDir.canonicalPath + File.separator)) {
73+ file.delete()
74+ }
71 refresh()
75 refresh()
72 }
76 }
73
77
@@ -99,11 +103,20 @@ class CrashLogger private constructor(private val appContext: Context) {
99 }
103 }
100
104
101 private fun appVersion(): String = runCatching {
105 private fun appVersion(): String = runCatching {
106+ val pi = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
107+ appContext.packageManager.getPackageInfo(
108+ appContext.packageName,
109+ PackageManager.PackageInfoFlags.of(0),
110+ )
111+ } else {
112+ @Suppress("DEPRECATION")
102− val pi = appContext.packageManager.getPackageInfo(appContext.packageName, 0)
113+ appContext.packageManager.getPackageInfo(appContext.packageName, 0)
114+ }
103 "${pi.versionName} (${PackageInfoCompat.getLongVersionCode(pi)})"
115 "${pi.versionName} (${PackageInfoCompat.getLongVersionCode(pi)})"
104 }.getOrDefault("unknown")
116 }.getOrDefault("unknown")
105
117
106 companion object {
118 companion object {
119+ @Volatile
107 private var instance: CrashLogger? = null
120 private var instance: CrashLogger? = null
108
121
109 fun get(context: Context): CrashLogger =
122 fun get(context: Context): CrashLogger =
app/src/main/java/soy/iko/opencode/data/repo/ProfileStore.kt+15−10
@@ -28,16 +28,21 @@ class ProfileStore(context: Context) {
28 private val profilesKey = stringPreferencesKey("profiles_json")
28 private val profilesKey = stringPreferencesKey("profiles_json")
29
29
30 private val securePrefs: SharedPreferences by lazy {
30 private val securePrefs: SharedPreferences by lazy {
31+ runCatching {
31− val masterKey = MasterKey.Builder(appContext)
32+ val masterKey = MasterKey.Builder(appContext)
32− .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
33+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
33− .build()
34+ .build()
34− EncryptedSharedPreferences.create(
35+ EncryptedSharedPreferences.create(
35− appContext,
36+ appContext,
36− "server_secrets",
37+ "server_secrets",
37− masterKey,
38+ masterKey,
38− EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
39+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
39− EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
40+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
40− )
41+ )
42+ }.getOrElse {
43+ Log.e("ProfileStore", "Encrypted prefs unavailable, falling back to plaintext", it)
44+ appContext.getSharedPreferences("server_secrets_fallback", Context.MODE_PRIVATE)
45+ }
41 }
46 }
42
47
43 /** Stored shape on DataStore (everything except the secret password). */
48 /** Stored shape on DataStore (everything except the secret password). */
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+10−3
@@ -153,6 +153,16 @@ class AppContainer(context: Context) {
153 activeConnection
153 activeConnection
154 .flatMapLatest { conn -> conn?.events?.events ?: emptyFlow() }
154 .flatMapLatest { conn -> conn?.events?.events ?: emptyFlow() }
155 .collect { event ->
155 .collect { event ->
156+ // SessionIdle is not "message activity" (don't badge as unread)
157+ // but signals a run finished — fire a completion notification if the
158+ // session isn't currently being viewed and was actively streaming.
159+ if (event is SessionIdle) {
160+ val idleSid = event.properties.sessionID ?: return@collect
161+ if (idleSid != _currentSession.value && activeRuns.remove(idleSid)) {
162+ notifySessionCompleted(idleSid)
163+ }
164+ return@collect
165+ }
156 val sid = sessionOf(event) ?: return@collect
166 val sid = sessionOf(event) ?: return@collect
157 if (sid != _currentSession.value) {
167 if (sid != _currentSession.value) {
158 _unread.update { it + sid }
168 _unread.update { it + sid }
@@ -162,9 +172,6 @@ class AppContainer(context: Context) {
162 if (event is MessagePartUpdated || event is MessageUpdated) {
172 if (event is MessagePartUpdated || event is MessageUpdated) {
163 activeRuns.add(sid)
173 activeRuns.add(sid)
164 }
174 }
165− if (event is SessionIdle && sid != _currentSession.value && activeRuns.remove(sid)) {
166− notifySessionCompleted(sid)
167− }
168 }
175 }
169 }
176 }
170 }
177 }
app/src/main/java/soy/iko/opencode/ui/chat/ChatScreen.kt+1−1
@@ -235,7 +235,7 @@ fun ChatScreen(
235 putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
235 putExtra(android.content.Intent.EXTRA_SUBJECT, sessionTitle ?: defaultShareSubject)
236 putExtra(android.content.Intent.EXTRA_TEXT, md)
236 putExtra(android.content.Intent.EXTRA_TEXT, md)
237 }
237 }
238− shareLauncher.startActivity(android.content.Intent.createChooser(send, shareLabel))
238+ runCatching { shareLauncher.startActivity(android.content.Intent.createChooser(send, shareLabel)) }
239 },
239 },
240 enabled = messages.isNotEmpty(),
240 enabled = messages.isNotEmpty(),
241 ) {
241 ) {
app/src/main/java/soy/iko/opencode/ui/chat/ChatViewModel.kt+4−4
@@ -72,7 +72,7 @@ class ChatViewModel(
72
72
73 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
73 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
74 container.activeConnection
74 container.activeConnection
75− .flatMapLatest { it?.events?.state ?: MutableStateFlow(EventStreamClient.ConnectionState.Disconnected) }
75+ .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
76 .stateIn(
76 .stateIn(
77 scope = viewModelScope,
77 scope = viewModelScope,
78 started = SharingStarted.WhileSubscribed(5_000),
78 started = SharingStarted.WhileSubscribed(5_000),
@@ -144,7 +144,7 @@ class ChatViewModel(
144 // sending with no model just uses the server's default agent/model.
144 // sending with no model just uses the server's default agent/model.
145 viewModelScope.launch {
145 viewModelScope.launch {
146 container.activeConnection.collectLatest { conn ->
146 container.activeConnection.collectLatest { conn ->
147− if (conn == null) return@collectLatest
147+ if (conn == null) { _models.value = emptyList(); _selectedModel.value = null; return@collectLatest }
148 runCatching { conn.api.providers() }
148 runCatching { conn.api.providers() }
149 .onFailure { Log.w("ChatViewModel", "Failed to load model catalog", it) }
149 .onFailure { Log.w("ChatViewModel", "Failed to load model catalog", it) }
150 .getOrNull()?.let { resp ->
150 .getOrNull()?.let { resp ->
@@ -157,7 +157,7 @@ class ChatViewModel(
157 // Load the agent catalog (non-fatal).
157 // Load the agent catalog (non-fatal).
158 viewModelScope.launch {
158 viewModelScope.launch {
159 container.activeConnection.collectLatest { conn ->
159 container.activeConnection.collectLatest { conn ->
160− if (conn == null) return@collectLatest
160+ if (conn == null) { _agents.value = emptyList(); return@collectLatest }
161 runCatching { conn.api.agents() }
161 runCatching { conn.api.agents() }
162 .onFailure { Log.w("ChatViewModel", "Failed to load agent catalog", it) }
162 .onFailure { Log.w("ChatViewModel", "Failed to load agent catalog", it) }
163 .getOrNull()?.let { _agents.value = it }
163 .getOrNull()?.let { _agents.value = it }
@@ -166,7 +166,7 @@ class ChatViewModel(
166 // Load the command catalog (non-fatal).
166 // Load the command catalog (non-fatal).
167 viewModelScope.launch {
167 viewModelScope.launch {
168 container.activeConnection.collectLatest { conn ->
168 container.activeConnection.collectLatest { conn ->
169− if (conn == null) return@collectLatest
169+ if (conn == null) { _commands.value = emptyList(); return@collectLatest }
170 runCatching { conn.api.commands() }
170 runCatching { conn.api.commands() }
171 .onFailure { Log.w("ChatViewModel", "Failed to load command catalog", it) }
171 .onFailure { Log.w("ChatViewModel", "Failed to load command catalog", it) }
172 .getOrNull()?.let { _commands.value = it }
172 .getOrNull()?.let { _commands.value = it }
app/src/main/java/soy/iko/opencode/ui/components/MarkdownText.kt+1−1
@@ -59,7 +59,7 @@ fun MarkdownText(
59 content = markdown,
59 content = markdown,
60 modifier = modifier.semantics(mergeDescendants = true) {
60 modifier = modifier.semantics(mergeDescendants = true) {
61 contentDescription = markdown.take(500)
61 contentDescription = markdown.take(500)
62− role = Role.Image
62+ role = Role.Button
63 }.combinedClickable(
63 }.combinedClickable(
64 onClick = {},
64 onClick = {},
65 onLongClick = { copyToClipboard(context, markdown) },
65 onLongClick = { copyToClipboard(context, markdown) },
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserScreen.kt+1−1
@@ -217,7 +217,7 @@ private fun FileRow(
217 Row(
217 Row(
218 modifier = Modifier
218 modifier = Modifier
219 .fillMaxWidth()
219 .fillMaxWidth()
220− .clickable(onClick = onClick)
220+ .clickable(role = Role.Button, onClick = onClick)
221 .semantics(mergeDescendants = true) { contentDescription = desc }
221 .semantics(mergeDescendants = true) { contentDescription = desc }
222 .padding(horizontal = 16.dp, vertical = 14.dp),
222 .padding(horizontal = 16.dp, vertical = 14.dp),
223 verticalAlignment = Alignment.CenterVertically,
223 verticalAlignment = Alignment.CenterVertically,
app/src/main/java/soy/iko/opencode/ui/file/FileBrowserViewModel.kt+3−1
@@ -40,6 +40,7 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
40 fun clearTransientError() { _transientError.value = null }
40 fun clearTransientError() { _transientError.value = null }
41
41
42 private var searchJob: Job? = null
42 private var searchJob: Job? = null
43+ private var openJob: Job? = null
43
44
44 init { open(""); loadStatus() }
45 init { open(""); loadStatus() }
45
46
@@ -65,7 +66,8 @@ class FileBrowserViewModel(private val container: AppContainer) : ViewModel() {
65 return
66 return
66 }
67 }
67 _state.value = _state.value.copy(path = path, loading = true, error = null)
68 _state.value = _state.value.copy(path = path, loading = true, error = null)
69+ openJob?.cancel()
68− viewModelScope.launch {
70+ openJob = viewModelScope.launch {
69 runCatching { client.listDirectory(path) }
71 runCatching { client.listDirectory(path) }
70 .onSuccess { entries ->
72 .onSuccess { entries ->
71 _state.value = _state.value.copy(
73 _state.value = _state.value.copy(
app/src/main/java/soy/iko/opencode/ui/file/FileViewScreen.kt+1−1
@@ -75,7 +75,7 @@ fun FileViewScreen(
75 putExtra(Intent.EXTRA_SUBJECT, filename)
75 putExtra(Intent.EXTRA_SUBJECT, filename)
76 putExtra(Intent.EXTRA_TEXT, content)
76 putExtra(Intent.EXTRA_TEXT, content)
77 }
77 }
78− context.startActivity(Intent.createChooser(send, shareLabel))
78+ runCatching { context.startActivity(Intent.createChooser(send, shareLabel)) }
79 }) {
79 }) {
80 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
80 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
81 }
81 }
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+6−2
@@ -59,14 +59,18 @@ class ServerEditViewModel(
59 val s = _state.value
59 val s = _state.value
60 if (!s.canSave) return
60 if (!s.canSave) return
61 viewModelScope.launch {
61 viewModelScope.launch {
62+ val existingLastUsed = if (s.id != null) {
63+ container.profileStore.profiles.first()
64+ .firstOrNull { it.id == s.id }?.lastUsed ?: 0L
65+ } else 0L
62 container.profileStore.save(
66 container.profileStore.save(
63 ServerProfile(
67 ServerProfile(
64 id = s.id ?: UUID.randomUUID().toString(),
68 id = s.id ?: UUID.randomUUID().toString(),
65 label = s.label.trim(),
69 label = s.label.trim(),
66 baseUrl = s.baseUrl.trim(),
70 baseUrl = s.baseUrl.trim(),
67 username = s.username.trim().takeIf { it.isNotBlank() },
71 username = s.username.trim().takeIf { it.isNotBlank() },
68− password = s.password.takeIf { it.isNotEmpty() },
72+ password = s.password.trim().takeIf { it.isNotEmpty() },
69− lastUsed = 0,
73+ lastUsed = existingLastUsed,
70 ),
74 ),
71 )
75 )
72 onDone()
76 onDone()
app/src/main/java/soy/iko/opencode/ui/session/SessionListViewModel.kt+2−1
@@ -19,6 +19,7 @@ import kotlinx.coroutines.flow.StateFlow
19import kotlinx.coroutines.flow.asStateFlow
19import kotlinx.coroutines.flow.asStateFlow
20import kotlinx.coroutines.flow.collectLatest
20import kotlinx.coroutines.flow.collectLatest
21import kotlinx.coroutines.flow.flatMapLatest
21import kotlinx.coroutines.flow.flatMapLatest
22+import kotlinx.coroutines.flow.flowOf
22import kotlinx.coroutines.flow.stateIn
23import kotlinx.coroutines.flow.stateIn
23import kotlinx.coroutines.flow.update
24import kotlinx.coroutines.flow.update
24import kotlinx.coroutines.joinAll
25import kotlinx.coroutines.joinAll
@@ -51,7 +52,7 @@ class SessionListViewModel(private val container: AppContainer) : ViewModel() {
51 @OptIn(ExperimentalCoroutinesApi::class)
52 @OptIn(ExperimentalCoroutinesApi::class)
52 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
53 val connectionState: StateFlow<EventStreamClient.ConnectionState> =
53 container.activeConnection
54 container.activeConnection
54− .flatMapLatest { it?.events?.state ?: MutableStateFlow(EventStreamClient.ConnectionState.Disconnected) }
55+ .flatMapLatest { it?.events?.state ?: flowOf(EventStreamClient.ConnectionState.Disconnected) }
55 .stateIn(
56 .stateIn(
56 scope = viewModelScope,
57 scope = viewModelScope,
57 started = SharingStarted.WhileSubscribed(5_000),
58 started = SharingStarted.WhileSubscribed(5_000),
app/src/main/java/soy/iko/opencode/ui/settings/DiagnosticsScreen.kt+2−2
@@ -136,7 +136,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
136 putExtra(Intent.EXTRA_SUBJECT, shareLabel)
136 putExtra(Intent.EXTRA_SUBJECT, shareLabel)
137 putExtra(Intent.EXTRA_TEXT, content)
137 putExtra(Intent.EXTRA_TEXT, content)
138 }
138 }
139− context.startActivity(Intent.createChooser(send, shareLabel))
139+ runCatching { context.startActivity(Intent.createChooser(send, shareLabel)) }
140 }
140 }
141 }) {
141 }) {
142 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
142 Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.share))
@@ -170,7 +170,7 @@ fun DiagnosticsScreen(onBack: () -> Unit) {
170 putExtra(Intent.EXTRA_SUBJECT, shareLabel2)
170 putExtra(Intent.EXTRA_SUBJECT, shareLabel2)
171 putExtra(Intent.EXTRA_TEXT, content.orEmpty())
171 putExtra(Intent.EXTRA_TEXT, content.orEmpty())
172 }
172 }
173− context.startActivity(Intent.createChooser(send, shareLabel2))
173+ runCatching { context.startActivity(Intent.createChooser(send, shareLabel2)) }
174 }) { Text(stringResource(R.string.share)) }
174 }) { Text(stringResource(R.string.share)) }
175 Spacer(Modifier.size(8.dp))
175 Spacer(Modifier.size(8.dp))
176 TextButton(onClick = {
176 TextButton(onClick = {
app/src/main/java/soy/iko/opencode/ui/settings/SettingsScreen.kt+13−2
@@ -1,5 +1,7 @@
1package soy.iko.opencode.ui.settings
1package soy.iko.opencode.ui.settings
2
2
3+import android.content.pm.PackageManager
4+import android.os.Build
3import androidx.compose.foundation.clickable
5import androidx.compose.foundation.clickable
4import androidx.compose.foundation.layout.Arrangement
6import androidx.compose.foundation.layout.Arrangement
5import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.Column
@@ -55,8 +57,17 @@ fun SettingsScreen(container: AppContainer, onBack: () -> Unit, onManageServers:
55 val context = LocalContext.current
57 val context = LocalContext.current
56 val unknownVersion = stringResource(R.string.unknown_version)
58 val unknownVersion = stringResource(R.string.unknown_version)
57 val versionName = remember {
59 val versionName = remember {
60+ runCatching {
61+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
62+ context.packageManager.getPackageInfo(
63+ context.packageName,
64+ PackageManager.PackageInfoFlags.of(0),
65+ ).versionName
66+ } else {
67+ @Suppress("DEPRECATION")
58− runCatching { context.packageManager.getPackageInfo(context.packageName, 0).versionName }
68+ context.packageManager.getPackageInfo(context.packageName, 0).versionName
69+ }
59− .getOrNull() ?: unknownVersion
70+ }.getOrNull() ?: unknownVersion
60 }
71 }
61
72
62 Scaffold(
73 Scaffold(
app/src/test/java/soy/iko/opencode/data/network/WithRetryTest.kt+2−2
@@ -130,7 +130,7 @@ class WithRetryTest {
130 var calls = 0
130 var calls = 0
131 val start = testScheduler.currentTime
131 val start = testScheduler.currentTime
132 runCatching {
132 runCatching {
133− withRetryInternal(maxAttempts = 3, initialDelayMs = 100L) {
133+ withRetryInternal(maxAttempts = 3, initialDelayMs = 100L, jitterFactor = 0.0) {
134 calls++
134 calls++
135 throw IOException("always")
135 throw IOException("always")
136 }
136 }
@@ -147,7 +147,7 @@ class WithRetryTest {
147 var calls = 0
147 var calls = 0
148 var lastTime = testScheduler.currentTime
148 var lastTime = testScheduler.currentTime
149 runCatching {
149 runCatching {
150− withRetryInternal(maxAttempts = 4, initialDelayMs = 50L) {
150+ withRetryInternal(maxAttempts = 4, initialDelayMs = 50L, jitterFactor = 0.0) {
151 calls++
151 calls++
152 val now = testScheduler.currentTime
152 val now = testScheduler.currentTime
153 if (calls > 1) delays.add(now - lastTime)
153 if (calls > 1) delays.add(now - lastTime)