Skip to content

Commit c229486

Browse files
authored
mobile: land android follow-ups and ci cache fixes (#54)
1 parent e0a23f7 commit c229486

File tree

11 files changed

+235
-52
lines changed

11 files changed

+235
-52
lines changed

.github/workflows/android-apk-release.yml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,14 @@ jobs:
115115
runs-on: ubuntu-latest
116116
timeout-minutes: 90
117117
environment: release
118+
env:
119+
SCCACHE_BUCKET: rust-cache
120+
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_R2_ENDPOINT }}
121+
SCCACHE_REGION: auto
122+
SCCACHE_S3_USE_SSL: "true"
123+
SCCACHE_S3_KEY_PREFIX: ci/android
124+
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_R2_ACCESS_KEY_ID }}
125+
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_R2_SECRET_ACCESS_KEY }}
118126

119127
steps:
120128
- name: Checkout
@@ -147,14 +155,6 @@ jobs:
147155

148156
- name: Setup sccache
149157
uses: mozilla-actions/sccache-action@v0.0.9
150-
env:
151-
SCCACHE_BUCKET: rust-cache
152-
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_R2_ENDPOINT }}
153-
SCCACHE_REGION: auto
154-
SCCACHE_S3_USE_SSL: "true"
155-
SCCACHE_S3_KEY_PREFIX: ci/android
156-
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_R2_ACCESS_KEY_ID }}
157-
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_R2_SECRET_ACCESS_KEY }}
158158

159159
- name: Download shared prep artifact
160160
uses: actions/download-artifact@v4

.github/workflows/android-play-release.yml

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,14 @@ jobs:
8181
runs-on: ubuntu-latest
8282
timeout-minutes: 90
8383
environment: release
84+
env:
85+
SCCACHE_BUCKET: rust-cache
86+
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_R2_ENDPOINT }}
87+
SCCACHE_REGION: auto
88+
SCCACHE_S3_USE_SSL: "true"
89+
SCCACHE_S3_KEY_PREFIX: ci/android
90+
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_R2_ACCESS_KEY_ID }}
91+
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_R2_SECRET_ACCESS_KEY }}
8492

8593
steps:
8694
- name: Checkout
@@ -136,14 +144,6 @@ jobs:
136144

137145
- name: Setup sccache
138146
uses: mozilla-actions/sccache-action@v0.0.9
139-
env:
140-
SCCACHE_BUCKET: rust-cache
141-
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_R2_ENDPOINT }}
142-
SCCACHE_REGION: auto
143-
SCCACHE_S3_USE_SSL: "true"
144-
SCCACHE_S3_KEY_PREFIX: ci/android
145-
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_R2_ACCESS_KEY_ID }}
146-
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_R2_SECRET_ACCESS_KEY }}
147147

148148
- name: Download shared prep artifact
149149
uses: actions/download-artifact@v4

.github/workflows/ios-testflight.yml

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ jobs:
3333
environment: release
3434
env:
3535
HOMEBREW_NO_AUTO_UPDATE: "1"
36+
SCCACHE_BUCKET: rust-cache
37+
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_R2_ENDPOINT }}
38+
SCCACHE_REGION: auto
39+
SCCACHE_S3_USE_SSL: "true"
40+
SCCACHE_S3_KEY_PREFIX: ci/ios
41+
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_R2_ACCESS_KEY_ID }}
42+
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_R2_SECRET_ACCESS_KEY }}
3643

3744
steps:
3845
- name: Checkout
@@ -84,14 +91,6 @@ jobs:
8491

8592
- name: Setup sccache
8693
uses: mozilla-actions/sccache-action@v0.0.9
87-
env:
88-
SCCACHE_BUCKET: rust-cache
89-
SCCACHE_ENDPOINT: ${{ secrets.SCCACHE_R2_ENDPOINT }}
90-
SCCACHE_REGION: auto
91-
SCCACHE_S3_USE_SSL: "true"
92-
SCCACHE_S3_KEY_PREFIX: ci/ios
93-
AWS_ACCESS_KEY_ID: ${{ secrets.SCCACHE_R2_ACCESS_KEY_ID }}
94-
AWS_SECRET_ACCESS_KEY: ${{ secrets.SCCACHE_R2_SECRET_ACCESS_KEY }}
9594

9695
- name: Decode App Store Connect API key
9796
env:

apps/android/app/src/main/java/com/litter/android/state/AppModel.kt

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import kotlinx.coroutines.SupervisorJob
99
import kotlinx.coroutines.flow.MutableStateFlow
1010
import kotlinx.coroutines.flow.StateFlow
1111
import kotlinx.coroutines.flow.asStateFlow
12+
import kotlinx.coroutines.delay
1213
import kotlinx.coroutines.launch
1314
import kotlinx.coroutines.sync.Mutex
1415
import kotlinx.coroutines.sync.withLock
@@ -27,6 +28,7 @@ import uniffi.codex_mobile_client.ServerBridge
2728
import uniffi.codex_mobile_client.SshBridge
2829
import uniffi.codex_mobile_client.ThreadKey
2930
import uniffi.codex_mobile_client.ThreadListParams
31+
import uniffi.codex_mobile_client.ThreadReadParams
3032

3133
/**
3234
* Central app state singleton. Thin wrapper over Rust [AppStore] — all business
@@ -338,6 +340,81 @@ class AppModel private constructor(context: android.content.Context) {
338340
}
339341
}
340342

343+
suspend fun ensureThreadLoaded(
344+
key: ThreadKey,
345+
maxAttempts: Int = 5,
346+
): ThreadKey? {
347+
if (_snapshot.value?.threads?.any { it.key == key } == true) {
348+
return key
349+
}
350+
351+
var currentKey = key
352+
repeat(maxAttempts) { attempt ->
353+
var readSucceeded = false
354+
try {
355+
val response = rpc.threadRead(
356+
currentKey.serverId,
357+
ThreadReadParams(
358+
threadId = currentKey.threadId,
359+
includeTurns = true,
360+
),
361+
)
362+
currentKey = ThreadKey(
363+
serverId = currentKey.serverId,
364+
threadId = response.thread.id,
365+
)
366+
store.setActiveThread(currentKey)
367+
readSucceeded = true
368+
} catch (e: Exception) {
369+
_lastError.value = e.message
370+
}
371+
372+
refreshSnapshot()
373+
if (_snapshot.value?.threads?.any { it.key == currentKey } == true) {
374+
return currentKey
375+
}
376+
377+
if (!readSucceeded) {
378+
try {
379+
rpc.threadList(
380+
currentKey.serverId,
381+
ThreadListParams(
382+
cursor = null,
383+
limit = null,
384+
sortKey = null,
385+
modelProviders = null,
386+
sourceKinds = null,
387+
archived = null,
388+
cwd = null,
389+
searchTerm = null,
390+
),
391+
)
392+
} catch (e: Exception) {
393+
_lastError.value = e.message
394+
}
395+
396+
refreshSnapshot()
397+
if (_snapshot.value?.threads?.any { it.key == currentKey } == true) {
398+
return currentKey
399+
}
400+
}
401+
402+
if (attempt + 1 < maxAttempts) {
403+
delay(250)
404+
}
405+
}
406+
407+
val activeKey = _snapshot.value?.activeThread
408+
if (activeKey != null &&
409+
activeKey.serverId == currentKey.serverId &&
410+
_snapshot.value?.threads?.any { it.key == activeKey } == true
411+
) {
412+
return activeKey
413+
}
414+
415+
return null
416+
}
417+
341418
// --- Internal event handling ----------------------------------------------
342419

343420
private suspend fun handleUpdate(update: AppStoreUpdateRecord) {

apps/android/app/src/main/java/com/litter/android/state/VoiceTranscriptionManager.kt

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import java.net.URL
1919
import java.nio.ByteBuffer
2020
import java.nio.ByteOrder
2121
import kotlin.math.sqrt
22+
import uniffi.codex_mobile_client.AuthMode
2223

2324
/**
2425
* Records microphone input and transcribes via ChatGPT/OpenAI API.
@@ -91,7 +92,7 @@ class VoiceTranscriptionManager {
9192
}.also { it.start() }
9293
}
9394

94-
suspend fun stopAndTranscribe(authToken: String, useOpenAI: Boolean = false): String? {
95+
suspend fun stopAndTranscribe(authMethod: AuthMode?, authToken: String?): String? {
9596
_isRecording.value = false
9697
audioRecord?.stop()
9798
audioRecord?.release()
@@ -119,6 +120,12 @@ class VoiceTranscriptionManager {
119120
return null
120121
}
121122

123+
val token = authToken?.trim().orEmpty()
124+
if (token.isEmpty()) {
125+
_error.value = "Not logged in."
126+
return null
127+
}
128+
122129
// Resample to 24kHz
123130
val targetRate = 24000
124131
val resampled = resample(allSamples, deviceSampleRate, targetRate)
@@ -128,10 +135,10 @@ class VoiceTranscriptionManager {
128135
_isTranscribing.value = true
129136
return try {
130137
withContext(Dispatchers.IO) {
131-
if (useOpenAI) {
132-
transcribeOpenAI(wav, authToken)
138+
if (authMethod == AuthMode.API_KEY) {
139+
transcribeOpenAI(wav, token)
133140
} else {
134-
transcribeChatGPT(wav, authToken)
141+
transcribeChatGPT(wav, token)
135142
}
136143
}
137144
} catch (e: Exception) {
@@ -259,7 +266,7 @@ class VoiceTranscriptionManager {
259266

260267
// Parse transcript from JSON response
261268
return try {
262-
org.json.JSONObject(response).optString("text", null)
269+
org.json.JSONObject(response).optString("text").takeIf { it.isNotBlank() }
263270
} catch (_: Exception) {
264271
response.takeIf { it.isNotBlank() }
265272
}

apps/android/app/src/main/java/com/litter/android/ui/LitterApp.kt

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -108,10 +108,13 @@ fun LitterApp(appModel: AppModel) {
108108
serverId,
109109
appModel.launchState.threadStartParams(cwd),
110110
)
111-
val key = ThreadKey(serverId = serverId, threadId = response.thread.id)
112-
appModel.store.setActiveThread(key)
111+
val startedKey = ThreadKey(serverId = serverId, threadId = response.thread.id)
112+
appModel.store.setActiveThread(startedKey)
113113
appModel.refreshSnapshot()
114-
navigateToConversation(key)
114+
val resolvedKey = appModel.ensureThreadLoaded(startedKey)
115+
?: appModel.snapshot.value?.threads?.firstOrNull { it.key == startedKey }?.key
116+
?: startedKey
117+
navigateToConversation(resolvedKey)
115118
}
116119

117120
fun openDirectoryPicker(preferredServerId: String? = null) {

apps/android/app/src/main/java/com/litter/android/ui/conversation/ComposerBar.kt

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import com.litter.android.state.VoiceTranscriptionManager
6767
import kotlinx.coroutines.Job
6868
import kotlinx.coroutines.delay
6969
import uniffi.codex_mobile_client.FuzzyFileSearchParams
70+
import uniffi.codex_mobile_client.GetAuthStatusParams
7071
import uniffi.codex_mobile_client.PendingUserInputAnswer
7172
import uniffi.codex_mobile_client.PendingUserInputRequest
7273
import uniffi.codex_mobile_client.ReasoningEffort
@@ -205,15 +206,20 @@ fun ComposerBar(
205206
"fork" -> scope.launch {
206207
try {
207208
val cwd = appModel.snapshot.value?.threads?.find { it.key == threadKey }?.info?.cwd
208-
appModel.store.forkThreadFromMessage(
209-
threadKey,
210-
0u,
209+
val response = appModel.rpc.threadFork(
210+
threadKey.serverId,
211211
appModel.launchState.threadForkParams(
212212
sourceThreadId = threadKey.threadId,
213213
cwdOverride = cwd,
214214
modelOverride = appModel.launchState.snapshot.value.selectedModel.trim().ifEmpty { null },
215215
),
216216
)
217+
val newKey = ThreadKey(
218+
serverId = threadKey.serverId,
219+
threadId = response.thread.id,
220+
)
221+
appModel.store.setActiveThread(newKey)
222+
appModel.refreshSnapshot()
217223
} catch (e: Exception) {
218224
onSlashError?.invoke(e.message ?: "Failed to fork conversation")
219225
}
@@ -376,17 +382,19 @@ fun ComposerBar(
376382
onClick = {
377383
if (isRecording) {
378384
scope.launch {
379-
// Get auth token from server account
380-
val snap = appModel.snapshot.value
381-
val server = snap?.servers?.firstOrNull { it.serverId == threadKey.serverId }
382-
// Extract auth token from server account
383-
val account = snap?.servers?.firstOrNull { it.serverId == threadKey.serverId }?.account
384-
val token = when (account) {
385-
is uniffi.codex_mobile_client.Account.Chatgpt -> "" // ChatGPT uses cookies, not bearer
386-
is uniffi.codex_mobile_client.Account.ApiKey -> "" // No direct token access
387-
else -> ""
388-
}
389-
val transcript = transcriptionManager.stopAndTranscribe(token)
385+
val auth = runCatching {
386+
appModel.rpc.getAuthStatus(
387+
threadKey.serverId,
388+
GetAuthStatusParams(
389+
includeToken = true,
390+
refreshToken = false,
391+
),
392+
)
393+
}.getOrNull()
394+
val transcript = transcriptionManager.stopAndTranscribe(
395+
authMethod = auth?.authMethod,
396+
authToken = auth?.authToken,
397+
)
390398
transcript?.let { text = if (text.isBlank()) it else "$text $it" }
391399
}
392400
} else {

apps/android/app/src/main/java/com/litter/android/ui/conversation/ConversationInfoScreen.kt

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -169,17 +169,19 @@ fun ConversationInfoScreen(
169169
val t = thread ?: return@launch
170170
val tk = threadKey ?: return@launch
171171
try {
172-
val newKey = appModel.store.forkThreadFromMessage(
173-
tk,
174-
0u,
172+
val response = appModel.rpc.threadFork(
173+
tk.serverId,
175174
appModel.launchState.threadForkParams(
176-
tk.threadId,
175+
sourceThreadId = tk.threadId,
177176
cwdOverride = t.info.cwd,
178177
),
179178
)
179+
val newKey = ThreadKey(
180+
serverId = tk.serverId,
181+
threadId = response.thread.id,
182+
)
180183
appModel.store.setActiveThread(newKey)
181184
appModel.refreshSnapshot()
182-
onBack()
183185
} catch (_: Exception) {}
184186
}
185187
},

apps/android/app/src/main/java/com/litter/android/ui/home/HomeDashboardScreen.kt

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package com.litter.android.ui.home
22

3+
import androidx.compose.foundation.ExperimentalFoundationApi
34
import androidx.compose.foundation.Image
45
import androidx.compose.foundation.background
56
import androidx.compose.foundation.clickable
7+
import androidx.compose.foundation.combinedClickable
68
import androidx.compose.foundation.layout.Arrangement
79
import androidx.compose.foundation.layout.Box
810
import androidx.compose.foundation.layout.Column
@@ -379,6 +381,7 @@ fun HomeDashboardScreen(
379381
}
380382
}
381383

384+
@OptIn(ExperimentalFoundationApi::class)
382385
@Composable
383386
private fun SessionCard(
384387
session: AppSessionSummary,
@@ -392,7 +395,10 @@ private fun SessionCard(
392395
modifier = Modifier
393396
.fillMaxWidth()
394397
.background(LitterTheme.surface, RoundedCornerShape(10.dp))
395-
.clickable(onClick = onClick)
398+
.combinedClickable(
399+
onClick = onClick,
400+
onLongClick = { showMenu = true },
401+
)
396402
.padding(12.dp),
397403
verticalAlignment = Alignment.CenterVertically,
398404
) {
@@ -445,6 +451,18 @@ private fun SessionCard(
445451
)
446452
}
447453
}
454+
455+
Spacer(Modifier.width(4.dp))
456+
IconButton(
457+
onClick = { showMenu = true },
458+
modifier = Modifier.size(28.dp),
459+
) {
460+
Icon(
461+
Icons.Default.MoreVert,
462+
contentDescription = "Session actions",
463+
tint = LitterTheme.textSecondary,
464+
)
465+
}
448466
}
449467

450468
// Context menu

0 commit comments

Comments
 (0)