Skip to content

Commit b2ad0c1

Browse files
authored
Merge pull request #3241 from GCWing/wgq/mobile-session-lifecycle
fix(mobile): align remote session lifecycle across platforms
2 parents a0d4437 + d4e96d4 commit b2ad0c1

33 files changed

Lines changed: 819 additions & 90 deletions

File tree

‎src/apps/mobile/android/app/src/main/AndroidManifest.xml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,18 @@
1919
<activity
2020
android:name=".MainActivity"
2121
android:exported="true"
22+
android:launchMode="singleTask"
2223
android:windowSoftInputMode="adjustResize">
2324
<intent-filter>
2425
<action android:name="android.intent.action.MAIN" />
2526
<category android:name="android.intent.category.LAUNCHER" />
2627
</intent-filter>
28+
<intent-filter>
29+
<action android:name="android.intent.action.VIEW" />
30+
<category android:name="android.intent.category.DEFAULT" />
31+
<category android:name="android.intent.category.BROWSABLE" />
32+
<data android:scheme="openbitfun" android:host="auth" android:pathPrefix="/callback" />
33+
</intent-filter>
2734
</activity>
2835
</application>
2936

‎src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/MainActivity.kt‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,12 @@ class MainActivity : ComponentActivity() {
9191
if (!intent.getBooleanExtra(DESIGN_PREVIEW_EXTRA, false)) accountModel().setBackground(false)
9292
}
9393

94+
override fun onNewIntent(intent: android.content.Intent) {
95+
super.onNewIntent(intent)
96+
setIntent(intent)
97+
accountModel().notifyAuthorizationCallback()
98+
}
99+
94100
override fun onStop() {
95101
showStartupBrand = false
96102
showColdStart = false

‎src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/platform/WindowMetrics.kt‎

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,13 @@ internal fun rememberWindowMetrics(): WindowMetrics {
103103
val hasHingeSensor = remember(context) {
104104
context.packageManager.hasSystemFeature(FEATURE_SENSOR_HINGE_ANGLE)
105105
}
106+
//卓易通在鸿蒙上会向 Android 兼容层暴露一个“垂直折痕”,但它不是
107+
//Android WindowManager 的真实折叠区域。把它当成铰链会把普通宽屏窗口
108+
//错误拆成左右两套页面。通过鸿蒙系统文件管理器包识别兼容层;普通
109+
//Android 平板和真实 Android 折叠屏不会安装这个包,因此保持原行为。
110+
val isHarmonyCompatibilityLayer = remember(context) {
111+
context.packageManager.hasPackage("com.huawei.hmos.filemanager")
112+
}
106113

107114
val activity = remember(context) { context.findActivity() }
108115
// No activity means no window to track — a @Preview, or a composable hosted
@@ -154,9 +161,14 @@ internal fun rememberWindowMetrics(): WindowMetrics {
154161
}
155162
}
156163
}
157-
val foldInfo by foldInfoFlow.collectAsStateWithLifecycle(
164+
val reportedFoldInfo by foldInfoFlow.collectAsStateWithLifecycle(
158165
AndroidFoldInfo(emptyList(), emptyList(), reduceFoldFacts(false, emptyList())),
159166
)
167+
val foldInfo = if (isHarmonyCompatibilityLayer) {
168+
AndroidFoldInfo(emptyList(), emptyList(), reduceFoldFacts(false, emptyList()))
169+
} else {
170+
reportedFoldInfo
171+
}
160172

161173
return WindowMetrics(
162174
widthDp = widthDp,
@@ -176,6 +188,14 @@ internal fun rememberWindowMetrics(): WindowMetrics {
176188

177189
private const val FEATURE_SENSOR_HINGE_ANGLE = "android.hardware.sensor.hinge_angle"
178190

191+
private fun android.content.pm.PackageManager.hasPackage(packageName: String): Boolean =
192+
try {
193+
getApplicationInfo(packageName, 0)
194+
true
195+
} catch (_: android.content.pm.PackageManager.NameNotFoundException) {
196+
false
197+
}
198+
179199
private fun Context.findActivity(): Activity? {
180200
var current: Context? = this
181201
while (current is ContextWrapper) {

‎src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/account/AccountScreen.kt‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,10 @@ internal fun AccountLoginPage(
109109
val authorizationUrl = (state as? AccountUiState.Authorizing)?.authorizationUrl?.let { value ->
110110
val uri = android.net.Uri.parse(value)
111111
if (uri.scheme == "https" && uri.host == "auth.openbitfun.com") {
112-
uri.buildUpon().appendQueryParameter("locale", locale).build().toString()
112+
uri.buildUpon()
113+
.appendQueryParameter("locale", locale)
114+
.appendQueryParameter("returnTo", "openbitfun://auth/callback")
115+
.build().toString()
113116
} else value
114117
}
115118
val uriHandler = androidx.compose.ui.platform.LocalUriHandler.current

‎src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/ConversationView.kt‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,9 @@ internal fun ConversationView(
271271
Box(modifier = Modifier.fillMaxSize().testTag(CONVERSATION_TEST_TAG)) {
272272
if (timeline == null) {
273273
Box(Modifier.fillMaxSize()) {
274-
if (loadingVisible) ConversationLoadingState(Modifier.fillMaxSize())
274+
if (loadingVisible) ConversationLoadingState(
275+
Modifier.fillMaxSize().padding(top = topInset, bottom = bottomInset),
276+
)
275277
}
276278
} else if (visibleRows.isEmpty() && !state.hasMoreMessages) {
277279
ConversationEmptyState(modifier = Modifier.fillMaxSize())

‎src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/chat/tool/ToolStatusList.kt‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Box
99
import androidx.compose.foundation.layout.Column
1010
import androidx.compose.foundation.layout.Row
1111
import androidx.compose.foundation.layout.fillMaxWidth
12+
import androidx.compose.foundation.layout.height
1213
import androidx.compose.foundation.layout.heightIn
1314
import androidx.compose.foundation.layout.padding
1415
import androidx.compose.foundation.layout.size
@@ -31,6 +32,7 @@ import androidx.compose.ui.res.painterResource
3132
import androidx.compose.ui.res.stringResource
3233
import androidx.compose.ui.text.style.TextOverflow
3334
import androidx.compose.ui.unit.dp
35+
import org.json.JSONObject
3436
import com.openbitfun.mobile.app.R
3537
import com.openbitfun.mobile.core.feature.session.ToolAction
3638
import com.openbitfun.mobile.core.feature.session.ToolCard
@@ -212,6 +214,11 @@ internal fun ToolStatusRow(
212214
}
213215
return
214216
}
217+
if (tool.kind == com.openbitfun.mobile.core.feature.session.ToolKind.TODO ||
218+
tool.operation == ToolOperation.UPDATE_TODOS || tool.name.equals("TodoWrite", ignoreCase = true)) {
219+
TodoToolCard(tool = tool, enabled = enabled, modifier = modifier)
220+
return
221+
}
215222
var localExpanded by rememberSaveable(tool.id) { mutableStateOf(false) }
216223
val disclosure = LocalToolDisclosure.current
217224
val expanded = disclosure?.let { it.selected == tool.id } ?: localExpanded
@@ -358,6 +365,79 @@ internal fun ToolStatusRow(
358365
}
359366
}
360367

368+
369+
private data class TodoItem(val content: String, val status: String)
370+
371+
private fun parseTodoItems(value: String): List<TodoItem> = runCatching {
372+
val root = JSONObject(value)
373+
val todos = root.optJSONArray("todos") ?: return@runCatching emptyList()
374+
buildList {
375+
for (index in 0 until todos.length()) {
376+
val item = todos.optJSONObject(index) ?: continue
377+
val content = item.optString("content").trim()
378+
if (content.isNotEmpty()) add(TodoItem(content, item.optString("status", "pending")))
379+
}
380+
}
381+
}.getOrDefault(emptyList())
382+
383+
@Composable
384+
private fun TodoToolCard(tool: ToolCard, enabled: Boolean, modifier: Modifier) {
385+
var expanded by rememberSaveable(tool.id) { mutableStateOf(tool.phase != ToolPhase.COMPLETED) }
386+
val items = remember(tool.input, tool.output) {
387+
parseTodoItems(tool.input).ifEmpty { parseTodoItems(tool.output) }
388+
}
389+
val done = items.count { it.status.equals("completed", true) }
390+
val title = stringResource(R.string.tool_op_update_todos)
391+
val active = items.firstOrNull { it.status.equals("in_progress", true) }
392+
?: items.firstOrNull { it.status.equals("pending", true) }
393+
val summary = when {
394+
items.isEmpty() -> tool.target.ifBlank { stringResource(R.string.tool_todo_fallback) }
395+
done == items.size -> stringResource(R.string.tool_todo_all_completed)
396+
else -> active?.content ?: items.first().content
397+
}
398+
Column(
399+
modifier = modifier.fillMaxWidth()
400+
.background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(16.dp))
401+
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(16.dp))
402+
.padding(horizontal = 12.dp, vertical = 10.dp),
403+
verticalArrangement = Arrangement.spacedBy(8.dp),
404+
) {
405+
Row(
406+
modifier = Modifier.fillMaxWidth().clickable { expanded = !expanded },
407+
verticalAlignment = Alignment.CenterVertically,
408+
horizontalArrangement = Arrangement.spacedBy(9.dp),
409+
) {
410+
ToolStatusIcon(tool, Modifier)
411+
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
412+
Text(title, style = MaterialTheme.typography.bodyMedium, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold)
413+
Text(summary, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
414+
maxLines = 1, overflow = TextOverflow.Ellipsis)
415+
}
416+
if (items.isNotEmpty()) Text("$done/${items.size}", style = MaterialTheme.typography.labelSmall,
417+
color = MaterialTheme.colorScheme.onSurfaceVariant)
418+
Chevron(if (expanded) R.drawable.ic_symbol_chevron_down else R.drawable.ic_symbol_chevron_right)
419+
}
420+
if (expanded && items.isNotEmpty()) {
421+
Column(Modifier.fillMaxWidth().padding(start = 29.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
422+
Box(Modifier.fillMaxWidth().height(2.dp).background(MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(2.dp))) {
423+
Box(Modifier.fillMaxWidth(done.toFloat() / items.size.toFloat()).height(2.dp).background(MaterialTheme.colorScheme.onSurfaceVariant, RoundedCornerShape(2.dp)))
424+
}
425+
items.forEach { item ->
426+
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.Top) {
427+
Text(if (item.status.equals("completed", true)) "✓" else if (item.status.equals("cancelled", true)) "×" else if (item.status.equals("in_progress", true)) "•" else "○",
428+
color = MaterialTheme.colorScheme.onSurfaceVariant)
429+
Text(item.content, style = MaterialTheme.typography.bodySmall,
430+
color = if (item.status.equals("in_progress", true)) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant)
431+
}
432+
}
433+
}
434+
}
435+
if (expanded && items.isEmpty() && tool.output.isNotBlank()) {
436+
ToolDetail(stringResource(R.string.tool_output), tool.output, tool.phase == ToolPhase.FAILED)
437+
}
438+
}
439+
}
440+
361441
/**
362442
* The row's one line, ported from `toolLineLabel`.
363443
*

‎src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/CreateSessionScreen.kt‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,13 @@ internal fun CreateSessionRoute(
9999
val modelOptions = ready?.createModelOptions(stringResource(R.string.models_unnamed)).orEmpty()
100100
val baseline = rememberSaveable { mutableStateOf(ready?.selectedSessionId) }
101101
val created = ready?.selectedSessionId
102-
val hasTimeline = ready?.timeline != null
103-
LaunchedEffect(created, hasTimeline) {
104-
if (created != null && created != baseline.value && hasTimeline) onCreated(created)
102+
LaunchedEffect(created) {
103+
// The shared store publishes the new id only after the remote create
104+
// has committed. Do not wait for a non-null timeline here: an empty
105+
// session has no records, and a delayed stream must not strand the
106+
// user on the creation surface. ConversationView owns that loading
107+
// state once the id has been routed.
108+
if (created != null && created != baseline.value) onCreated(created)
105109
}
106110

107111
CreateSessionScreen(

‎src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/remote/PairingScreen.kt‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,10 @@ internal fun AccountRemoteScreen(
7575
requestedSessionId: String? = null,
7676
creatingSession: Boolean = false,
7777
onOpenSession: (String) -> Unit = {},
78+
// CreateSession is already opened by the shared store. Keep its route
79+
// callback separate from the sidebar callback, which also dispatches an
80+
// Open intent for an existing row.
81+
onCreatedSession: (String) -> Unit = onOpenSession,
7882
onCreateSession: () -> Unit = {},
7983
onRemoteHome: () -> Unit = {},
8084
modifier: Modifier,
@@ -99,6 +103,7 @@ internal fun AccountRemoteScreen(
99103
requestedSessionId = requestedSessionId,
100104
creatingSession = creatingSession,
101105
onOpenSession = onOpenSession,
106+
onCreatedSession = onCreatedSession,
102107
onCreateSession = onCreateSession,
103108
onRemoteHome = onRemoteHome,
104109
connectionDetails = {
@@ -129,6 +134,7 @@ private fun RemoteConnectedScreen(
129134
requestedSessionId: String?,
130135
creatingSession: Boolean,
131136
onOpenSession: (String) -> Unit,
137+
onCreatedSession: (String) -> Unit,
132138
onCreateSession: () -> Unit,
133139
onRemoteHome: () -> Unit,
134140
connectionDetails: @Composable () -> Unit,
@@ -207,7 +213,7 @@ private fun RemoteConnectedScreen(
207213
compact = compact,
208214
onDevicePick = onCreateDevicePick,
209215
onBack = onRemoteHome,
210-
onCreated = onOpenSession,
216+
onCreated = onCreatedSession,
211217
onWorkspaceIntent = onWorkspaceIntent,
212218
onIntent = onSessionIntent,
213219
modifier = modifier,

‎src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/ui/shell/MobileScreen.kt‎

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,16 @@ internal fun MobileScreen(onAccountRestored: (Boolean) -> Unit = {}) {
137137
val accountPhase by accountViewModel.connectionPhase.collectAsStateWithLifecycle()
138138
val accountWorkspaceDirectory by accountViewModel.workspaceDirectory.collectAsStateWithLifecycle()
139139
val readyAccount = accountState as? AccountUiState.Ready
140+
LaunchedEffect(accountPhase, shell.remoteCreating) {
141+
// A create failure is terminal for the creation route. The shared
142+
// store keeps the last valid session projection so returning to the
143+
// remote home here restores the same pre-create route as Harmony and
144+
// iOS, while the store's typed create error remains available to the
145+
// surface-level error/toast handling.
146+
if (shell.remoteCreating && accountPhase == ConnectionPhase.FAILED) {
147+
shell.closeRemoteSession()
148+
}
149+
}
140150
LaunchedEffect(accountState) {
141151
if (accountState !is AccountUiState.Idle && accountState !is AccountUiState.Restoring) {
142152
onAccountRestored(readyAccount?.userId?.isNotBlank() == true)
@@ -188,6 +198,13 @@ internal fun MobileScreen(onAccountRestored: (Boolean) -> Unit = {}) {
188198
RemoteControlSource.ACCOUNT_DEVICE -> accountRemoteState
189199
RemoteControlSource.NONE -> RemoteSessionUiState.Idle
190200
}
201+
val committedRemoteSessions = (activeRemoteState as? RemoteSessionUiState.Ready)?.sessions.orEmpty()
202+
// Shell navigation is only a transient request while the shared store
203+
// catches up. Once a delete succeeds the row disappears, which invalidates
204+
// a stale shell request and lets the store's selection take over.
205+
val routedSessionId = shell.remoteSessionId
206+
?.takeIf { requested -> committedRemoteSessions.any { it.id == requested } }
207+
?: (activeRemoteState as? RemoteSessionUiState.Ready)?.selectedSessionId
191208
val activeWorkspaceDirectory = when (controlSummary.source) {
192209
RemoteControlSource.ACCOUNT_DEVICE -> accountWorkspaceDirectory
193210
RemoteControlSource.NONE -> WorkspaceSessionDirectoryUiState(emptyList())
@@ -330,7 +347,10 @@ internal fun MobileScreen(onAccountRestored: (Boolean) -> Unit = {}) {
330347
workspaceState = activeWorkspaceState,
331348
workspaceDirectory = activeWorkspaceDirectory,
332349
remoteActive = shell.surface == MobileSurface.REMOTE,
333-
remoteSelectedSessionId = shell.remoteSessionId,
350+
// The session store owns selection. The shell only owns the
351+
// container route, so deleting the active row clears this value
352+
// without leaving a stale requested session behind.
353+
remoteSelectedSessionId = routedSessionId,
334354
query = shell.sidebarQuery,
335355
searchOpen = shell.searchOpen,
336356
onQueryChange = shell::search,
@@ -361,6 +381,11 @@ internal fun MobileScreen(onAccountRestored: (Boolean) -> Unit = {}) {
361381
dispatchActiveSession(RemoteSessionIntent.Open(sessionId))
362382
},
363383
onCreateRemoteInWorkspace = { workspace, agentType ->
384+
// Workspace quick-create used to dispatch directly while the
385+
// shell still rendered the home surface, so its committed
386+
// session had no creation route callback. Enter the same
387+
// creation route as the global new-session action first.
388+
shell.createRemoteSession()
364389
// With an ID the create carries only the ID; the legacy triple is for pre-ID rows.
365390
dispatchActiveSession(
366391
RemoteSessionIntent.CreateSession(
@@ -374,7 +399,6 @@ internal fun MobileScreen(onAccountRestored: (Boolean) -> Unit = {}) {
374399
workspaceId = workspace.workspaceId,
375400
),
376401
)
377-
shell.show(MobileSurface.REMOTE)
378402
closeDrawer()
379403
},
380404
onWorkspaceTool = { path, connectionId, terminal ->
@@ -467,9 +491,13 @@ internal fun MobileScreen(onAccountRestored: (Boolean) -> Unit = {}) {
467491
null
468492
},
469493
compact = !wide,
470-
requestedSessionId = shell.remoteSessionId,
494+
requestedSessionId = routedSessionId,
471495
creatingSession = shell.remoteCreating,
472496
onOpenSession = shell::openRemoteSession,
497+
onCreatedSession = { id ->
498+
shell.openRemoteSession(id)
499+
closeDrawer()
500+
},
473501
onCreateSession = shell::createRemoteSession,
474502
onRemoteHome = shell::closeRemoteSession,
475503
modifier = Modifier,

‎src/apps/mobile/android/app/src/main/kotlin/com/openbitfun/mobile/app/viewmodel/AccountViewModel.kt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ internal class AccountViewModel(application: Application) : AndroidViewModel(app
7777
store.dispatch(intent)
7878
}
7979

80+
fun notifyAuthorizationCallback() {
81+
store.notifyAuthorizationCallback()
82+
}
83+
8084
/** The handle General Chat reads the account's synced models through. */
8185

8286
fun dispatchSession(intent: RemoteSessionIntent) {

0 commit comments

Comments
 (0)