项目文件夹

文件
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

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: true on the first navigateToActivity call, so history is just [baseRoute] (or ['/', baseRoute] for singleTask).
  • Warm singleTask re-entry: replace: false after popToRoot, producing ['/', baseRoute].
  • openApp into 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:

  1. App A opens app B in B's own Task.
  2. User backs out at B's root.
  3. OS reactivates A and clears B's launchedByTaskId.
  4. Later, user opens B from Recents.
  5. 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: call goHome(). 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

  1. Caller invokes startActivity(intent, { newTask: true }).
  2. IntentResolver matches a standard SMS filter.
  3. The SMS Task is created or activated with launchedByTaskId pointing to the caller Task.
  4. If the SMS route cannot handle Back itself, os.returnToLauncherTask reactivates the caller and consumes the pointer.
  5. SMS remains in Recents. Reopening SMS later and pressing Back goes home.

Share target with singleTask

  1. Gallery sends ACTION_SEND image/*.
  2. WeChat's matching filter declares launchMode: 'singleTask'.
  3. If WeChat is cold, a WeChat Task is created and navigates from / to the share route.
  4. If WeChat already exists, Activities above root are popped, the intent payload is delivered to root, and the share route is pushed from /.
  5. After send, app code can replace the share route with the final chat route so Back returns to WeChat home.

Same-Task viewer push

  1. Settings invokes startActivity(intent) without { newTask: true }.
  2. FileManager's standard filter matches.
  3. A FileManager Activity is pushed onto the Settings Task.
  4. Back at FileManager route root falls through to os.finishTopActivity, popping FileManager and revealing Settings.
  5. No standalone FileManager Task is left in Recents.

Notification route

  1. User taps a notification.
  2. Notification handler calls openApp(appId, route).
  3. If cold, the app Task is created at / and route is pushed.
  4. If warm, route is pushed above the current page.
  5. 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.