15 KiB
Cross-App Launch and Task Lifecycle
This page documents the behavior that matters when one app opens another app: startActivity, startActivityForResult, openApp, Task lifetime, and BackDispatcher fallthrough. For the lower-level Intent API and matching rules, start with intent-system.md.
Design Boundaries
MobileGym follows the AOSP model where it is useful for benchmark transfer, but the simulator is not a process-accurate Android runtime.
| Dimension | Android / AOSP | MobileGym |
|---|---|---|
| App component model | One app can declare many Activities, each with launch mode and task affinity. | One app entry component, with one or more OS Activity instances wrapping React Router routes. |
| In-app navigation | Activities, fragments, Navigation Component. | MemoryRouter; route history is the app-local back stack. |
| Process model | Apps run in separate processes. | One browser JavaScript process hosts all apps. |
| Lifecycle | onCreate / onStart / onResume / onPause / onStop / onDestroy. |
App lifecycle events plus mounted/backgrounded React trees. |
OEM-specific behavior such as split-screen, freeform windows, background task eviction, splash timing, and vendor-specific notification stack synthesis is out of scope. When behavior differs across devices, align to AOSP first.
Receiver Manifest
AppIntentFilter lives in os/types/manifest.ts and is declared in each receiving app's manifest.ts.
| Field | Required | Meaning |
|---|---|---|
action |
Yes | Intent action, e.g. ACTION_VIEW, ACTION_SEND, ACTION_PAY. |
type |
No | MIME type filter. Supports exact match and wildcards such as image/*. |
scheme |
No | URL scheme filter such as sms, weixin, alipays. |
route |
Yes | App route where the receiver lands when the filter matches. |
launchMode |
No | 'standard' or 'singleTask'; default is 'standard'. |
params |
No | Documentation metadata for expected extras / data. Not enforced at runtime. |
description |
No | Label text used by the chooser when multiple apps match. |
launchMode is modeled per intent filter rather than per Activity. In MobileGym, a filter acts like the receiving Activity boundary in Android.
Caller APIs
startActivity(intent, options?)
Use this when app A wants app B to perform an action or show a piece of data.
window.__OS__.startActivity(
{
action: 'ACTION_VIEW',
scheme: 'sms',
data: { address: '12306', body: '999' },
},
{ newTask: true },
);
{ newTask: true } maps to the useful part of FLAG_ACTIVITY_NEW_TASK: the receiver enters or reuses its own Task. Without it, a standard receiver is pushed onto the caller's current Task.
Unsupported caller flags include CLEAR_TOP, SINGLE_TOP, NO_HISTORY, MULTIPLE_TASK, and FORWARD_RESULT. Model those behaviors explicitly with route state or app logic instead.
startActivityForResult(intent | appId, callback)
Use this when the caller needs a result from the callee.
window.__OS__.startActivityForResult(
{ action: 'ACTION_PAY', scheme: 'alipays', data: { amount: 99 } },
(result) => {
if (result.resultCode === 'OK') {
// read result.data
}
},
);
The receiver calls:
window.__OS__.setResult({ resultCode: 'OK', data: { transactionId } });
The OS stores the result, finishes the result Activity, and invokes the caller callback. If the user backs out before a result is set, the callback receives resultCode: 'CANCELED'.
openApp(appId, route?)
Use this for explicit app launches, launcher actions, and notification/deep-link routing.
window.__OS__.openApp('wechat', '/chat/wxid_123');
openApp skips intent matching. When a route is provided, it always pushes the route into the app's MemoryRouter history:
| App state | Behavior |
|---|---|
| App Task does not exist | Create the app Task at /, mark it externally routed, then push route, producing ['/', route]. |
| App Task already exists | Keep the user's current in-app route and push route on top. |
This intentionally differs from Android TaskStackBuilder warm-task behavior, which can rebuild the Task. MobileGym keeps the user's current page so notification taps are less destructive.
Do not use openApp for "borrow this other app briefly and return to me" flows such as Settings opening FileManager. Use startActivity so Back can pop the borrowed Activity or return to the caller Task.
Route Priority
When both the caller and receiver specify a route, caller intent wins:
const baseRoute = intent.route ?? targetFilter?.route ?? '/';
This is a MobileGym abstraction. Android has no route field; the caller would pass extras, and the receiving Activity would interpret them in onCreate / onNewIntent. MobileGym allows intent.route to express that target subpage directly and avoid a two-step "filter route, then app reroute" race.
Example:
window.__OS__.startActivity({
action: 'ACTION_VIEW',
type: 'inode/directory',
route: '/category/images',
});
The FileManager filter can default to /, but this caller lands on /category/images.
Launch Modes
standard
standard is the default.
| Caller option | Behavior |
|---|---|
No { newTask: true } |
Push a new Activity instance onto the caller's current Task. |
{ newTask: true } and target Task absent |
Create target Task, set launchedByTaskId to the caller Task, and navigate to the base route. |
{ newTask: true } and target Task exists |
Push a new Activity for the target app onto the target Task. |
Use standard for one-off actions such as payment confirmation, document/category viewing, compose screens, and result pickers.
singleTask
singleTask is receiver-controlled. It promotes the launch into the receiver's own Task even when the caller omitted { newTask: true }.
| Target Task state | Behavior |
|---|---|
| Task absent | Create the target Task with root MemoryRouter history ['/'], then push baseRoute — final history ['/', baseRoute]. / stays at the bottom of the back stack so the user backs through it before exiting the Task. |
| Task exists | Pop Activities above root, deliver the intent to the root Activity, activate the Task, then popToRoot('/') + navigate(baseRoute, { replace: false }) — same final history ['/', baseRoute]. |
Use singleTask for receiver entry points that should behave as a singleton, such as a share target that should leave the user in the receiving app after the flow.
Replace vs. push: the OS decides
The receiving app's navigator does not choose between replace and push — the OS passes the right mode through onNavigate(path, { replace }):
- Cold start to
baseRoute(newly created Task):replace: trueon the first navigateToActivity call, so history is just[baseRoute](or['/', baseRoute]forsingleTask). - Warm
singleTaskre-entry:replace: falseafterpopToRoot, producing['/', baseRoute]. openAppinto an existing Task:replace: false— push the new route on top of whatever the user was looking at.startActivity({ newTask: true }): a new Activity is pushed onto the Task; the new Activity gets its own initial history.
App-side onNavigate handlers should forward replace to the React Router navigate(path, { replace }) call without overriding it.
Task Lifetime
Task.launchedByTaskId is a one-shot pointer back to the caller Task. It is set when a new Task is created from another active Task, and consumed the first time Back returns to that caller.
The one-shot rule matters:
- App A opens app B in B's own Task.
- User backs out at B's root.
- OS reactivates A and clears B's
launchedByTaskId. - Later, user opens B from Recents.
- Back from B now goes home, not back to the old A Task.
Tasks are never destroyed by Back. Even when only one Activity remains and there's no launchedByTaskId, the OS calls goHome() rather than closeTask(). This matches Android: root Back leaves the Task in Recents. Destruction is explicit — Recents swipe-away or __OS__.closeApp(appId). Earlier versions of the simulator did destroy Tasks via wasExternallyRouted; that branch has been removed.
wasExternallyRouted is still set when openApp creates a Task, but its only remaining role is subtler: when a LAUNCH_APP reactivates the Task from the Launcher with no caller (i.e. user tapped the app icon), the reducer clears launchedByTaskId only if !wasExternallyRouted. For tasks created via openApp(id, route), the original caller relationship is preserved across Launcher reactivations.
Before returning to a caller Task, the OS resets the current app's route to /. That prevents a transient bridge route such as SMS compose or payment confirmation from being the screen shown when the user later reopens the Task from Recents.
finishActivity
finishActivity() (and the back-stack equivalents os.returnToLauncherTask / os.goHomeFallback) closes the current Activity, not the Task:
- Stack > 1: pop the top Activity. If it had
launchedByTaskId, activate that caller Task and consume the pointer. - Stack == 1, has
launchedByTaskId: activate the caller, consume the pointer. The closed Task stays in Recents. - Stack == 1, no
launchedByTaskId: callgoHome(). The Task stays in Recents.
No branch calls closeTask. Apps that need a Task gone must call __OS__.closeApp(appId) explicitly.
Active-task gate for back
Back handlers registered by useAppNavigationHandler apply an active-task gate in two places:
// inside AppNavigator.back closure
const state = TaskManager.getState();
if (taskId && state.activeTaskId !== taskId) return false;
// inside the BackDispatcher registration
BackDispatcher.register(`app.back.${appId}`, () => {
if (!isForegroundRef.current) return false;
const state = TaskManager.getState();
if (taskId && state.activeTaskId !== taskId) return false;
// …
}, 100);
The gate runs in the handler body — not as a "don't register" filter — so the same App rendered in a foreign Task (e.g. Alipay pushed onto a 12306 Task for payment) silently defers and lets the foreground Activity handle Back via the priority-50 os.activityBack chain. Skipping registration would leave the foreground unable to recover the handler.
Intent payload delivery is poll-based
When a singleTask warm re-entry delivers a new intent to an already-mounted Activity, the OS writes the payload via TaskManager.setActivityIntent(activityId, payload). Apps read it back with os.getIntentPayload(activityId). There is no onNewIntent-style event callback — pages that need to react to a redelivered intent must call getIntentPayload in an effect (or on render) and diff against the previous payload themselves.
BackDispatcher Chain
During cross-app flows, Back falls through this effective order:
| Priority | Handler | Cross-app role |
|---|---|---|
| 1000 | permission.dialog |
Dismiss permission UI before app routing. |
| 900 | os.intentChooser |
Dismiss chooser before changing Tasks. |
| 800 | shade.dismiss |
Close notification shade. |
| 700 | keyboard.dismiss |
Hide keyboard. |
| 600 | os.mediaPicker |
Close media picker. |
| 100 | app.back.<appId> / os.appBack |
Let the visible app route back first. |
| 50 | os.activityBack |
Let the top Activity's own navigator back out. |
| 25 | os.finishTopActivity |
If the current Task stack has more than one Activity, pop the top Activity. |
| 12 | os.returnToLauncherTask |
If only one Activity remains and launchedByTaskId exists, reactivate caller and consume the pointer. |
| 0 | os.goHomeFallback |
Return home while keeping the Task in Recents. |
Foreign-task isolation prevents background instances of the same app from consuming Back. When app B is mounted both in its own Task and borrowed on app A's Task, useAppNavigationHandler skips app-level registration in the foreign Task (task.rootAppId !== appId) and relies on the Activity-level navigator for the foreground borrowed instance.
Scenario Traces
Caller opens SMS in a new Task
- Caller invokes
startActivity(intent, { newTask: true }). IntentResolvermatches astandardSMS filter.- The SMS Task is created or activated with
launchedByTaskIdpointing to the caller Task. - If the SMS route cannot handle Back itself,
os.returnToLauncherTaskreactivates the caller and consumes the pointer. - SMS remains in Recents. Reopening SMS later and pressing Back goes home.
Share target with singleTask
- Gallery sends
ACTION_SEND image/*. - WeChat's matching filter declares
launchMode: 'singleTask'. - If WeChat is cold, a WeChat Task is created and navigates from
/to the share route. - If WeChat already exists, Activities above root are popped, the intent payload is delivered to root, and the share route is pushed from
/. - After send, app code can replace the share route with the final chat route so Back returns to WeChat home.
Same-Task viewer push
- Settings invokes
startActivity(intent)without{ newTask: true }. - FileManager's
standardfilter matches. - A FileManager Activity is pushed onto the Settings Task.
- Back at FileManager route root falls through to
os.finishTopActivity, popping FileManager and revealing Settings. - No standalone FileManager Task is left in Recents.
Notification route
- User taps a notification.
- Notification handler calls
openApp(appId, route). - If cold, the app Task is created at
/androuteis pushed. - If warm,
routeis pushed above the current page. - Back pops to the previous in-app route; root Back follows normal caller-return or home fallback rules.
Known Differences from AOSP
| AOSP concept | MobileGym behavior | Decision |
|---|---|---|
Per-Activity launchMode |
Per-filter launchMode. |
Architectural simplification. |
singleTop |
Not modeled. | Use route state or explicit openApp. |
singleInstance |
Not modeled. | Out of scope for benchmark flows. |
FLAG_ACTIVITY_CLEAR_TOP |
Not modeled as a caller flag. | Use singleTask or explicit route reset. |
FLAG_ACTIVITY_NO_HISTORY |
Not modeled. | Back fallback resets transient routes where needed. |
FLAG_ACTIVITY_MULTIPLE_TASK |
Not modeled. | One Task per app root unless same-task push is used. |
FLAG_ACTIVITY_FORWARD_RESULT |
Not modeled. | Result callbacks are direct. |
Activity.onNewIntent callback |
No callback — payload is written via setActivityIntent and apps poll it via getIntentPayload(activityId) in their own effect / render. |
Close enough for current flows; pages that need to react to same-route redelivery must observe explicitly. |
Cross-app taskAffinity |
Not modeled. | App affinity is effectively manifest.id — one Task per app root. |
TaskStackBuilder full parent chain |
Cold openApp(route) synthesizes ['/', route] (just app home + target); warm openApp(route) preserves current history and pushes. |
Intentional UX-friendly divergence — no full reconstruction of an inferred parent chain. |