Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/apps/mobile/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,18 @@
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="openbitfun" android:host="auth" android:pathPrefix="/callback" />
</intent-filter>
</activity>
</application>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ class MainActivity : ComponentActivity() {
if (!intent.getBooleanExtra(DESIGN_PREVIEW_EXTRA, false)) accountModel().setBackground(false)
}

override fun onNewIntent(intent: android.content.Intent) {
super.onNewIntent(intent)
setIntent(intent)
accountModel().notifyAuthorizationCallback()
}

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

val activity = remember(context) { context.findActivity() }
// No activity means no window to track — a @Preview, or a composable hosted
Expand Down Expand Up @@ -154,9 +161,14 @@ internal fun rememberWindowMetrics(): WindowMetrics {
}
}
}
val foldInfo by foldInfoFlow.collectAsStateWithLifecycle(
val reportedFoldInfo by foldInfoFlow.collectAsStateWithLifecycle(
AndroidFoldInfo(emptyList(), emptyList(), reduceFoldFacts(false, emptyList())),
)
val foldInfo = if (isHarmonyCompatibilityLayer) {
AndroidFoldInfo(emptyList(), emptyList(), reduceFoldFacts(false, emptyList()))
} else {
reportedFoldInfo
}

return WindowMetrics(
widthDp = widthDp,
Expand All @@ -176,6 +188,14 @@ internal fun rememberWindowMetrics(): WindowMetrics {

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

private fun android.content.pm.PackageManager.hasPackage(packageName: String): Boolean =
try {
getApplicationInfo(packageName, 0)
true
} catch (_: android.content.pm.PackageManager.NameNotFoundException) {
false
}

private fun Context.findActivity(): Activity? {
var current: Context? = this
while (current is ContextWrapper) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,10 @@ internal fun AccountLoginPage(
val authorizationUrl = (state as? AccountUiState.Authorizing)?.authorizationUrl?.let { value ->
val uri = android.net.Uri.parse(value)
if (uri.scheme == "https" && uri.host == "auth.openbitfun.com") {
uri.buildUpon().appendQueryParameter("locale", locale).build().toString()
uri.buildUpon()
.appendQueryParameter("locale", locale)
.appendQueryParameter("returnTo", "openbitfun://auth/callback")
.build().toString()
} else value
}
val uriHandler = androidx.compose.ui.platform.LocalUriHandler.current
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,9 @@ internal fun ConversationView(
Box(modifier = Modifier.fillMaxSize().testTag(CONVERSATION_TEST_TAG)) {
if (timeline == null) {
Box(Modifier.fillMaxSize()) {
if (loadingVisible) ConversationLoadingState(Modifier.fillMaxSize())
if (loadingVisible) ConversationLoadingState(
Modifier.fillMaxSize().padding(top = topInset, bottom = bottomInset),
)
}
} else if (visibleRows.isEmpty() && !state.hasMoreMessages) {
ConversationEmptyState(modifier = Modifier.fillMaxSize())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
Expand All @@ -31,6 +32,7 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import org.json.JSONObject
import com.openbitfun.mobile.app.R
import com.openbitfun.mobile.core.feature.session.ToolAction
import com.openbitfun.mobile.core.feature.session.ToolCard
Expand Down Expand Up @@ -212,6 +214,11 @@ internal fun ToolStatusRow(
}
return
}
if (tool.kind == com.openbitfun.mobile.core.feature.session.ToolKind.TODO ||
tool.operation == ToolOperation.UPDATE_TODOS || tool.name.equals("TodoWrite", ignoreCase = true)) {
TodoToolCard(tool = tool, enabled = enabled, modifier = modifier)
return
}
var localExpanded by rememberSaveable(tool.id) { mutableStateOf(false) }
val disclosure = LocalToolDisclosure.current
val expanded = disclosure?.let { it.selected == tool.id } ?: localExpanded
Expand Down Expand Up @@ -358,6 +365,79 @@ internal fun ToolStatusRow(
}
}


private data class TodoItem(val content: String, val status: String)

private fun parseTodoItems(value: String): List<TodoItem> = runCatching {
val root = JSONObject(value)
val todos = root.optJSONArray("todos") ?: return@runCatching emptyList()
buildList {
for (index in 0 until todos.length()) {
val item = todos.optJSONObject(index) ?: continue
val content = item.optString("content").trim()
if (content.isNotEmpty()) add(TodoItem(content, item.optString("status", "pending")))
}
}
}.getOrDefault(emptyList())

@Composable
private fun TodoToolCard(tool: ToolCard, enabled: Boolean, modifier: Modifier) {
var expanded by rememberSaveable(tool.id) { mutableStateOf(tool.phase != ToolPhase.COMPLETED) }
val items = remember(tool.input, tool.output) {
parseTodoItems(tool.input).ifEmpty { parseTodoItems(tool.output) }
}
val done = items.count { it.status.equals("completed", true) }
val title = stringResource(R.string.tool_op_update_todos)
val active = items.firstOrNull { it.status.equals("in_progress", true) }
?: items.firstOrNull { it.status.equals("pending", true) }
val summary = when {
items.isEmpty() -> tool.target.ifBlank { stringResource(R.string.tool_todo_fallback) }
done == items.size -> stringResource(R.string.tool_todo_all_completed)
else -> active?.content ?: items.first().content
}
Column(
modifier = modifier.fillMaxWidth()
.background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(16.dp))
.border(1.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(16.dp))
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth().clickable { expanded = !expanded },
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(9.dp),
) {
ToolStatusIcon(tool, Modifier)
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(title, style = MaterialTheme.typography.bodyMedium, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold)
Text(summary, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, overflow = TextOverflow.Ellipsis)
}
if (items.isNotEmpty()) Text("$done/${items.size}", style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
Chevron(if (expanded) R.drawable.ic_symbol_chevron_down else R.drawable.ic_symbol_chevron_right)
}
if (expanded && items.isNotEmpty()) {
Column(Modifier.fillMaxWidth().padding(start = 29.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Box(Modifier.fillMaxWidth().height(2.dp).background(MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(2.dp))) {
Box(Modifier.fillMaxWidth(done.toFloat() / items.size.toFloat()).height(2.dp).background(MaterialTheme.colorScheme.onSurfaceVariant, RoundedCornerShape(2.dp)))
}
items.forEach { item ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.Top) {
Text(if (item.status.equals("completed", true)) "✓" else if (item.status.equals("cancelled", true)) "×" else if (item.status.equals("in_progress", true)) "•" else "○",
color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(item.content, style = MaterialTheme.typography.bodySmall,
color = if (item.status.equals("in_progress", true)) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant)
}
}
}
}
if (expanded && items.isEmpty() && tool.output.isNotBlank()) {
ToolDetail(stringResource(R.string.tool_output), tool.output, tool.phase == ToolPhase.FAILED)
}
}
}

/**
* The row's one line, ported from `toolLineLabel`.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,13 @@ internal fun CreateSessionRoute(
val modelOptions = ready?.createModelOptions(stringResource(R.string.models_unnamed)).orEmpty()
val baseline = rememberSaveable { mutableStateOf(ready?.selectedSessionId) }
val created = ready?.selectedSessionId
val hasTimeline = ready?.timeline != null
LaunchedEffect(created, hasTimeline) {
if (created != null && created != baseline.value && hasTimeline) onCreated(created)
LaunchedEffect(created) {
// The shared store publishes the new id only after the remote create
// has committed. Do not wait for a non-null timeline here: an empty
// session has no records, and a delayed stream must not strand the
// user on the creation surface. ConversationView owns that loading
// state once the id has been routed.
if (created != null && created != baseline.value) onCreated(created)
}

CreateSessionScreen(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ internal fun AccountRemoteScreen(
requestedSessionId: String? = null,
creatingSession: Boolean = false,
onOpenSession: (String) -> Unit = {},
// CreateSession is already opened by the shared store. Keep its route
// callback separate from the sidebar callback, which also dispatches an
// Open intent for an existing row.
onCreatedSession: (String) -> Unit = onOpenSession,
onCreateSession: () -> Unit = {},
onRemoteHome: () -> Unit = {},
modifier: Modifier,
Expand All @@ -99,6 +103,7 @@ internal fun AccountRemoteScreen(
requestedSessionId = requestedSessionId,
creatingSession = creatingSession,
onOpenSession = onOpenSession,
onCreatedSession = onCreatedSession,
onCreateSession = onCreateSession,
onRemoteHome = onRemoteHome,
connectionDetails = {
Expand Down Expand Up @@ -129,6 +134,7 @@ private fun RemoteConnectedScreen(
requestedSessionId: String?,
creatingSession: Boolean,
onOpenSession: (String) -> Unit,
onCreatedSession: (String) -> Unit,
onCreateSession: () -> Unit,
onRemoteHome: () -> Unit,
connectionDetails: @Composable () -> Unit,
Expand Down Expand Up @@ -207,7 +213,7 @@ private fun RemoteConnectedScreen(
compact = compact,
onDevicePick = onCreateDevicePick,
onBack = onRemoteHome,
onCreated = onOpenSession,
onCreated = onCreatedSession,
onWorkspaceIntent = onWorkspaceIntent,
onIntent = onSessionIntent,
modifier = modifier,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,16 @@ internal fun MobileScreen(onAccountRestored: (Boolean) -> Unit = {}) {
val accountPhase by accountViewModel.connectionPhase.collectAsStateWithLifecycle()
val accountWorkspaceDirectory by accountViewModel.workspaceDirectory.collectAsStateWithLifecycle()
val readyAccount = accountState as? AccountUiState.Ready
LaunchedEffect(accountPhase, shell.remoteCreating) {
// A create failure is terminal for the creation route. The shared
// store keeps the last valid session projection so returning to the
// remote home here restores the same pre-create route as Harmony and
// iOS, while the store's typed create error remains available to the
// surface-level error/toast handling.
if (shell.remoteCreating && accountPhase == ConnectionPhase.FAILED) {
shell.closeRemoteSession()
}
}
LaunchedEffect(accountState) {
if (accountState !is AccountUiState.Idle && accountState !is AccountUiState.Restoring) {
onAccountRestored(readyAccount?.userId?.isNotBlank() == true)
Expand Down Expand Up @@ -188,6 +198,13 @@ internal fun MobileScreen(onAccountRestored: (Boolean) -> Unit = {}) {
RemoteControlSource.ACCOUNT_DEVICE -> accountRemoteState
RemoteControlSource.NONE -> RemoteSessionUiState.Idle
}
val committedRemoteSessions = (activeRemoteState as? RemoteSessionUiState.Ready)?.sessions.orEmpty()
// Shell navigation is only a transient request while the shared store
// catches up. Once a delete succeeds the row disappears, which invalidates
// a stale shell request and lets the store's selection take over.
val routedSessionId = shell.remoteSessionId
?.takeIf { requested -> committedRemoteSessions.any { it.id == requested } }
?: (activeRemoteState as? RemoteSessionUiState.Ready)?.selectedSessionId
val activeWorkspaceDirectory = when (controlSummary.source) {
RemoteControlSource.ACCOUNT_DEVICE -> accountWorkspaceDirectory
RemoteControlSource.NONE -> WorkspaceSessionDirectoryUiState(emptyList())
Expand Down Expand Up @@ -330,7 +347,10 @@ internal fun MobileScreen(onAccountRestored: (Boolean) -> Unit = {}) {
workspaceState = activeWorkspaceState,
workspaceDirectory = activeWorkspaceDirectory,
remoteActive = shell.surface == MobileSurface.REMOTE,
remoteSelectedSessionId = shell.remoteSessionId,
// The session store owns selection. The shell only owns the
// container route, so deleting the active row clears this value
// without leaving a stale requested session behind.
remoteSelectedSessionId = routedSessionId,
query = shell.sidebarQuery,
searchOpen = shell.searchOpen,
onQueryChange = shell::search,
Expand Down Expand Up @@ -361,6 +381,11 @@ internal fun MobileScreen(onAccountRestored: (Boolean) -> Unit = {}) {
dispatchActiveSession(RemoteSessionIntent.Open(sessionId))
},
onCreateRemoteInWorkspace = { workspace, agentType ->
// Workspace quick-create used to dispatch directly while the
// shell still rendered the home surface, so its committed
// session had no creation route callback. Enter the same
// creation route as the global new-session action first.
shell.createRemoteSession()
// With an ID the create carries only the ID; the legacy triple is for pre-ID rows.
dispatchActiveSession(
RemoteSessionIntent.CreateSession(
Expand All @@ -374,7 +399,6 @@ internal fun MobileScreen(onAccountRestored: (Boolean) -> Unit = {}) {
workspaceId = workspace.workspaceId,
),
)
shell.show(MobileSurface.REMOTE)
closeDrawer()
},
onWorkspaceTool = { path, connectionId, terminal ->
Expand Down Expand Up @@ -467,9 +491,13 @@ internal fun MobileScreen(onAccountRestored: (Boolean) -> Unit = {}) {
null
},
compact = !wide,
requestedSessionId = shell.remoteSessionId,
requestedSessionId = routedSessionId,
creatingSession = shell.remoteCreating,
onOpenSession = shell::openRemoteSession,
onCreatedSession = { id ->
shell.openRemoteSession(id)
closeDrawer()
},
onCreateSession = shell::createRemoteSession,
onRemoteHome = shell::closeRemoteSession,
modifier = Modifier,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ internal class AccountViewModel(application: Application) : AndroidViewModel(app
store.dispatch(intent)
}

fun notifyAuthorizationCallback() {
store.notifyAuthorizationCallback()
}

/** The handle General Chat reads the account's synced models through. */

fun dispatchSession(intent: RemoteSessionIntent) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -526,4 +526,6 @@
<string name="workspace_file_conflict">电脑上的文件已被修改,当前草稿仍保留。请先复制草稿,再重新打开文件以读取最新内容。</string>
<string name="chat_load_older_failed">加载更早消息失败,点按重试。</string>
<string name="chat_loading_older_messages">正在加载更早消息…</string>
<string name="tool_todo_fallback">任务列表</string>
<string name="tool_todo_all_completed">全部任务已完成</string>
</resources>
2 changes: 2 additions & 0 deletions src/apps/mobile/android/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -541,4 +541,6 @@
<string name="workspace_file_conflict">File changed on the computer. Your draft is preserved. Copy it before reopening the file to load the latest version.</string>
<string name="chat_load_older_failed">Could not load earlier messages. Tap to retry.</string>
<string name="chat_loading_older_messages">Loading earlier messages…</string>
<string name="tool_todo_fallback">Task list</string>
<string name="tool_todo_all_completed">All tasks completed</string>
</resources>
Loading
Loading