Skip to content

Commit 82c49da

Browse files
authored
Merge pull request #3020 from wgqqqqq/wgq/mobile-harmony-parity
feat(mobile): align native controller features with HarmonyOS
2 parents fcecd89 + 3f79afc commit 82c49da

80 files changed

Lines changed: 1940 additions & 372 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎src/apps/mobile/AGENTS.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Native mobile applications are product entrypoints under `src/apps/mobile`.
2525
| `ios/` | iOS app, resources, lifecycle, and adapters |
2626
| `harmonyos/` | HarmonyOS app, resources, lifecycle, and adapters |
2727
| `shared/` | Kotlin Multiplatform core: protocol, crypto, transport, persistence, domain, feature stores |
28+
| `miniapps/` | Build-time packaging and isolated document bridge for product-owned offline tools; native storage and WebView lifecycle stay in each app |
2829
| `design-system/` | HarmonyOS-derived mobile tokens, component contracts, deterministic preview scenarios, and the desktop comparison surface |
2930

3031
## Native UI Contract
@@ -90,3 +91,13 @@ UiState or an Intent declared there, and no module above it is visible to them.
9091
from `shared/`, with an emulator or handset attached. Those suites compile the
9192
same `commonTest` sources onto ART; they are not in CI, so run them by hand
9293
when touching either module.
94+
95+
## Offline Mini Apps
96+
97+
Android preBuild and the iOS resource phase run `miniapps/generate.cjs` (Node.js
98+
must be on PATH). Generated native HTML is ignored; update product-owned built-in
99+
sources or the packaging owner, never the generated resources. Harmony retains
100+
its existing native host and invokes the same generator without native wrappers.
101+
Run `node --test src/apps/mobile/miniapps/*.test.cjs src/apps/mobile/harmonyos/miniapps/*.test.cjs`
102+
from the repository root after bundle/bridge changes. Storage keys remain scoped
103+
to an allowlisted app; retain unreadable records and surface the error.

‎src/apps/mobile/README.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@ interfaces.
2121
All three apps can send images with or without text. Camera photos are decoded
2222
on the phone and converted to a supported format before upload. Failed sends
2323
retain the draft and images; acknowledgement removes only the submitted content.
24+
Android supports selecting several photos at once and retains prepared attachments
25+
across Activity recreation and process restarts. Android stores prepared image drafts
26+
in app-private files excluded from backups, scoped by account endpoint, account,
27+
device and session. Failed saves and unreadable records offer retry without erasing
28+
the stored draft. An in-progress photo conversion still needs to be retried if the
29+
process stops before preparation and saving finish.
2430

2531
Model selection belongs to the connected host. A primary model that supports
2632
images receives their pixels directly. For a text-only primary model, select an
@@ -57,6 +63,10 @@ metadata, execution-mode IDs, speech draft merging, and completion observation i
5763
| Capability | Android and iOS behavior | Compatibility and verification boundary |
5864
|---|---|---|
5965
| Execution modes | Minimal / Standard / Ultimate, with the HarmonyOS density glyph; workspace menus also retain Cowork | Enabled only by live `get_workspace_info.capabilities` containing `harness_profiles_v1`; older hosts keep Code / Cowork. Capability absence in old payloads is covered by a round-trip test. |
66+
| Running-turn input | A nonempty remote draft offers Send; an empty composer retains Stop | Hosts advertising `dialog_steer_v1` receive `steer_turn` with the active turn ID. Older hosts retain `send_message` queueing; failed sends retain the draft and attachments. |
67+
| Plans | Legacy CreatePlan, structured plans and `.plan.md` writes show a plan card with file preview and Build | Build requires `plan_build_v1`, a completed plan tool and an idle connected session. Paths remain remote; the mobile app never runs the plan locally. |
68+
| Offline Mini Apps | Gomoku, Regex Playground and Daily Divination open from welcome and sidebar without login | Product-owned sources are bundled by `miniapps/generate.cjs`; native WebView hosts expose only per-app allowlisted storage and clipboard. No network, Node or shell bridge. |
69+
| Code preview | Native lexical colors, line numbers and referenced-line backgrounds | Both apps use the core-feature highlighter with native theme tokens; large files retain its bounded plain-text fallback. |
6070
| Account profile | Public GitHub login and avatar replace the numeric-ID placeholder | The immutable Relay user ID still authorizes devices. Display metadata uses a separate encrypted 24-hour cache; offline refreshes preserve credentials and cached display. |
6171
| Task completion | Notify for a previously observed successful remote turn while backgrounded | Identity includes target, session and turn. Replayed, failed and cancelled turns do not notify. These are local notifications, not server push. iOS observes within its OS background-task allowance; Android observes while the controller process remains alive. Neither promises notification after process termination. |
6272
| Speech input | Continue an existing draft without trimming its whitespace or inserting spaces into Chinese text | iOS owns Speech/AVAudio lifecycle in a platform adapter and cancels on route, target or scene changes. Android uses the system recognition activity. |
@@ -81,3 +91,10 @@ or opens system notification settings for an existing decision.
8191
Camera and microphone access stays contextual to scanning and voice input.
8292
Notification authorization does not extend the platform background-execution
8393
limits described above.
94+
95+
## Connection recovery
96+
97+
Native iOS and Android controllers probe idle session lists while the app is in the
98+
foreground. An open transcript uses its existing session poll for recovery instead
99+
of duplicating the health request. Temporary transport failures keep the displayed
100+
list or transcript; a successful response restores the connected state.

‎src/apps/mobile/android/AGENTS.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,9 @@ JAVA_HOME='/Applications/Android Studio.app/Contents/jbr/Contents/Home' ./gradle
4949
# Verify negotiated execution modes and legacy fallback on an emulator.
5050
JAVA_HOME='/Applications/Android Studio.app/Contents/jbr/Contents/Home' ./gradlew :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.openbitfun.mobile.app.HarnessProfileMenuTest
5151

52+
# Verify running-input, plan gating, offline tools, and attachment retention.
53+
./gradlew :app:connectedDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.openbitfun.mobile.app.MobileParityTest
54+
5255
# Run shared JVM tests after core-feature changes.
5356
cd ../shared
5457
JAVA_HOME='/Applications/Android Studio.app/Contents/jbr/Contents/Home' ./gradlew jvmTest

‎src/apps/mobile/android/app/build.gradle.kts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,10 @@ dependencies {
9595
// release explicitly until the BOM updates its transitive constraint.
9696
androidTestImplementation(libs.androidx.test.espresso)
9797
}
98+
99+
// Project product-owned offline tools into Android assets before packaging.
100+
val generateMiniApps by tasks.registering(Exec::class) {
101+
workingDir(rootProject.file("../miniapps"))
102+
commandLine("node", "generate.cjs", "android")
103+
}
104+
tasks.named("preBuild").configure { dependsOn(generateMiniApps) }

‎src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/AccountRemoteScreenTest.kt‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ class AccountRemoteScreenTest {
6868
}
6969
}
7070

71-
composeRule.onAllNodesWithText("Connect to a desktop").assertCountEquals(0)
71+
composeRule.onAllNodesWithText(testString(R.string.pairing_title)).assertCountEquals(0)
7272
}
7373

7474
@Test
@@ -98,7 +98,7 @@ class AccountRemoteScreenTest {
9898
}
9999
}
100100

101-
composeRule.onNodeWithText("Choose a desktop").assertIsDisplayed()
101+
composeRule.onNodeWithText(testString(R.string.connect_account_devices_title)).assertIsDisplayed()
102102
composeRule.onNodeWithTag(CONNECT_ACCOUNT_DEVICE_REFRESH_TEST_TAG).performClick()
103103
composeRule.onNodeWithTag(CONNECT_ACCOUNT_DEVICE_ROW_TEST_TAG_PREFIX + "desk-1").performClick()
104104
composeRule.onNodeWithTag(CONNECT_ACCOUNT_DEVICE_ROW_TEST_TAG_PREFIX + "desk-2").performClick()

‎src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/ChatMessageBubbleTest.kt‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ class ChatMessageBubbleTest {
5555
}
5656

5757
val first = composeRule.onNodeWithText("Checking the manifest.").getUnclippedBoundsInRoot()
58-
val tool = composeRule.onNodeWithText("Running \"AndroidManifest.xml\"").getUnclippedBoundsInRoot()
58+
val tool = composeRule.onNodeWithText(testString(R.string.tool_line_running, "AndroidManifest.xml")).getUnclippedBoundsInRoot()
5959
val second = composeRule.onNodeWithText("It targets API 35.").getUnclippedBoundsInRoot()
6060

6161
assertTrue(first.top < tool.top)
@@ -175,8 +175,8 @@ class ChatMessageBubbleTest {
175175
}
176176

177177
// The agent's reply started and stopped; it was never "not delivered".
178-
composeRule.onNodeWithText("Reply interrupted.").assertIsDisplayed()
179-
composeRule.onNodeWithText("Retry").assertIsDisplayed()
178+
composeRule.onNodeWithText(testString(R.string.chat_reply_interrupted)).assertIsDisplayed()
179+
composeRule.onNodeWithText(testString(R.string.account_devices_retry)).assertIsDisplayed()
180180
}
181181

182182
@Test

‎src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/CreateSessionScreenTest.kt‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ class CreateSessionScreenTest {
6969
)
7070
}
7171

72-
composeRule.onNodeWithText("Chat").assertIsDisplayed()
72+
composeRule.onNodeWithText(testString(R.string.navigation_general_chat)).assertIsDisplayed()
7373
composeRule.onNodeWithTag(COMPOSER_INPUT_TEST_TAG).performTextInput("review the parser")
7474
composeRule.onNodeWithTag(COMPOSER_SEND_TEST_TAG).performClick()
7575

@@ -102,7 +102,7 @@ class CreateSessionScreenTest {
102102
)
103103
}
104104

105-
composeRule.onNodeWithText("No desktop is connected.").assertIsDisplayed()
105+
composeRule.onNodeWithText(testString(R.string.create_no_device)).assertIsDisplayed()
106106
// The draft survives — it is worth keeping until a desktop comes back —
107107
// but the send is the same dimmed control a dropout produces.
108108
composeRule.onNodeWithTag(COMPOSER_INPUT_TEST_TAG).performTextInput("review the parser")
@@ -162,10 +162,10 @@ class CreateSessionScreenTest {
162162

163163
composeRule.onNodeWithTag(CREATE_SESSION_WORKSPACE_TEST_TAG).performClick()
164164

165-
composeRule.onNodeWithText("Choose a workspace").assertIsDisplayed()
165+
composeRule.onNodeWithText(testString(R.string.create_workspace_picker)).assertIsDisplayed()
166166
// A desktop with no recent projects is not a broken picker, and saying
167167
// where to fix it is the only useful thing the sheet can offer.
168-
composeRule.onNodeWithText("No recent workspaces. Open one on the desktop first.")
168+
composeRule.onNodeWithText(testString(R.string.create_no_workspaces))
169169
.assertIsDisplayed()
170170
}
171171

‎src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/FileReferenceCardsTest.kt‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ class FileReferenceCardsTest {
118118
)
119119
}
120120

121-
composeRule.onNodeWithContentDescription("Download").assertIsDisplayed().performClick()
121+
composeRule.onNodeWithContentDescription(testString(R.string.file_download)).assertIsDisplayed().performClick()
122122
composeRule.onNodeWithTag(FILE_DOWNLOAD_ACTION_TEST_TAG)
123123
.assertWidthIsEqualTo(44.dp)
124124
.assertHeightIsEqualTo(44.dp)

‎src/apps/mobile/android/app/src/androidTest/kotlin/com/openbitfun/mobile/app/MobileDesignPreviewVisualTest.kt‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import androidx.compose.ui.graphics.toArgb
1414
import androidx.compose.ui.graphics.toPixelMap
1515
import androidx.compose.ui.platform.testTag
1616
import androidx.compose.ui.test.ExperimentalTestApi
17+
import androidx.compose.ui.test.assertIsEnabled
1718
import androidx.compose.ui.test.assertIsDisplayed
1819
import androidx.compose.ui.test.captureToImage
1920
import androidx.compose.ui.test.junit4.v2.createComposeRule
@@ -121,8 +122,8 @@ class MobileDesignPreviewVisualTest {
121122
composeRule.onNodeWithText(scenario.composerDraft).assertIsDisplayed()
122123
}
123124
if (scenario == MobilePreviewScenarios.StreamingDark) {
124-
composeRule.onNodeWithContentDescription(targetContext.getString(R.string.message_stop))
125-
.assertIsDisplayed()
125+
composeRule.onNodeWithContentDescription(targetContext.getString(R.string.message_send))
126+
.assertIsDisplayed().assertIsEnabled()
126127
}
127128
}
128129

0 commit comments

Comments
 (0)