Skip to content
Source
Sign inSign up

[Bug]: Android 13/14 Open URL silently succeeds when no app handles the link #35980

Send to my agent
Closedcoygeek(GitHub profile, opens in a new tab) opened this issue 1 comment

Summary

Opening a link on an older Android image can complete successfully in T3 even when Android rejects the launch because no app handles the link. The action should return a failure so the caller can distinguish rejection from a successful launch. This was reproduced through the production action function with an isolated fake adb response based on released Android source; no real device was contacted.

Before submitting

  • Related issues: searched open and closed issues and PRs; related quoting fix listed under Additional context, no duplicate found.
  • Removed credentials, tokens, private paths, and sensitive data.

Area

apps/server

Observed behavior

The supported openUrl action runs adb -s emulator-5554 shell am start -a android.intent.action.VIEW -d <url>. When the runner returns code 0 with stderr Error: Activity not started, unable to resolve Intent, runDeviceAction completes successfully and discards the diagnostic. Released Android 13 and 14 activity-manager source uses precisely this zero-exit failure behavior. The reproduction models that response; this is not a captured real-device log.

The action is reachable through the Device panel's URL form and device.action: apps/web/src/components/device/DeviceToolsPanel.tsx#L153-L159, apps/server/src/device/DeviceService.ts#L1027-L1035. The client clears its action error on a successful result in apps/web/src/components/device/useDeviceControls.ts#L42-L62; the panel itself was not run in this reproduction.

Reproduction

  1. Use a clean checkout at the commit below with its pinned Effect dependency installed, and save the script below outside the checkout as repro-open-url.mjs.
  2. Run node --experimental-vm-modules repro-open-url.mjs /path/to/t3code. An optional second argument selects a directory containing a separately installed pinned effect@4.1.3 dependency; that option was used for the recorded run.
  3. The harness loads the actual DeviceActions.ts and its production error definitions, provides the real pinned Effect runtime, and replaces adb with a shell fixture on a temporary PATH. A quoted URL success and a nonzero launch failure must both pass their controls before it checks the legacy zero-exit rejection.

Deterministic within the fixture; one recorded invocation reproduced the failure. Prerequisites: Node with stripTypeScriptTypes and experimental VM modules, Git, a POSIX /bin/sh, and the checkout's Effect dependency. The script creates its fixtures and HOME, TMPDIR, and T3 state directory under a temporary directory, removes them on exit, and does not edit the checkout. Exit 0 means the action rejects the unresolved launch, exit 1 reproduces the defect, and exit 2 means setup or a control failed.

Reproduction script: repro-open-url.mjs
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import vm from 'node:vm';
import { stripTypeScriptTypes, createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
import { spawnSync } from 'node:child_process';

const checkout = path.resolve(process.argv[2] ?? '.');
const dependencyRoot = path.resolve(process.argv[3] ?? path.join(checkout, 'apps/server'));
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 't3-adb-repro-'));
let status = 2;
try {
  const require = createRequire(path.join(dependencyRoot, 'package.json'));
  const Effect = await import(pathToFileURL(require.resolve('effect/Effect')).href);
  const Schema = await import(pathToFileURL(require.resolve('effect/Schema')).href);
  const Result = await import(pathToFileURL(require.resolve('effect/Result')).href);
  console.log(`Node ${process.version}; effect ${require('effect/package.json').version}`);
  console.log(spawnSync('git', ['--version'], { encoding: 'utf8' }).stdout.trim());
  console.log(`T3 ${spawnSync('git', ['-C', checkout, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).stdout.trim()}`);
  console.log(`Shell ${spawnSync('/bin/sh', ['-c', 'printf "%s" "${BASH_VERSION:-POSIX sh}"'], { encoding: 'utf8' }).stdout}`);
  console.log('adb fixture: Android 14 activity-manager response contract; no real adb invoked');
  const source = fs.readFileSync(path.join(checkout, 'apps/server/src/device/DeviceActions.ts'), 'utf8');
  const contracts = fs.readFileSync(path.join(checkout, 'packages/contracts/src/device.ts'), 'utf8');
  const errorStart = contracts.indexOf('export class DeviceOperationError');
  const errorEnd = contracts.indexOf('export const DeviceError', errorStart);
  if (errorStart < 0 || errorEnd < 0) throw new Error('Cannot locate production error definitions');
  const errorSource = 'const DevicePlatform = Schema.Literals(["ios", "android"]);\n' + contracts.slice(errorStart, errorEnd);
  function synthetic(namespace) {
    return new vm.SyntheticModule(Object.keys(namespace), function () {
      for (const [name, value] of Object.entries(namespace)) this.setExport(name, value);
    });
  }
  const schemaModule = synthetic(Schema);
  await schemaModule.link(() => { throw new Error('Unexpected schema dependency'); });
  await schemaModule.evaluate();
  const errors = new vm.SourceTextModule(stripTypeScriptTypes('import * as Schema from "effect/Schema";\n' + errorSource));
  await errors.link(() => schemaModule);
  await errors.evaluate();
  const modules = new Map([
    ['effect/Effect', synthetic(Effect)], ['effect/Schema', schemaModule],
    ['effect/Result', synthetic(Result)], ['@t3tools/contracts', errors],
  ]);
  const actions = new vm.SourceTextModule(stripTypeScriptTypes(source));
  await actions.link((specifier) => {
    const module = modules.get(specifier);
    if (!module) throw new Error(`Unexpected import ${specifier}`);
    return module;
  });
  await actions.evaluate();
  const bin = path.join(scratch, 'bin');
  fs.mkdirSync(bin);
  const fakeAdb = `#!/bin/sh
if [ "$1" != '-s' ] || [ "$2" != 'emulator-5554' ] || [ "$3" != 'shell' ]; then
  echo 'fixture: wrong target' >&2; exit 91
fi
shift 3
am() {
  if [ "$1" != 'start' ] || [ "$2" != '-a' ] || [ "$3" != 'android.intent.action.VIEW' ] || [ "$4" != '-d' ] || [ "$#" != 5 ]; then
    echo 'fixture: wrong intent arguments' >&2; return 92
  fi
  printf 'Starting: Intent { act=android.intent.action.VIEW dat=%s }\\n' "$5"
  case "$5" in
    'https://example.com/a b?x=1&y=2') return 0 ;;
    't3-unhandled-fixture://nonzero') echo 'Error: Activity not started, unable to resolve Intent' >&2; return 1 ;;
    't3-unhandled-fixture://zero') echo 'Error: Activity not started, unable to resolve Intent' >&2; return 0 ;;
    *) echo 'fixture: unexpected URL' >&2; return 93 ;;
  esac
}
export -f am 2>/dev/null || :
# POSIX shell evaluation models adb's verbatim argument join.
eval "$*"
`;
  // The function and eval run in this one isolated shell; no device transport exists.
  fs.writeFileSync(path.join(bin, 'adb'), fakeAdb.replace('export -f am 2>/dev/null || :\n', ''), { mode: 0o700 });
  const env = { ...process.env, HOME: scratch, TMPDIR: scratch, T3CODE_HOME: path.join(scratch, 't3'), PATH: bin + path.delimiter + process.env.PATH };
  const outputs = [];
  const ready = {
    run: (command, args) => Effect.sync(() => {
      if (command !== 'adb') throw new Error('Unexpected host executable');
      const result = spawnSync(command, args, { env, encoding: 'utf8' });
      if (result.error || result.status === null || result.status >= 90) throw result.error ?? new Error(result.stderr);
      outputs.push({ code: result.status, stdout: result.stdout.trim(), stderr: result.stderr.trim() });
      return { code: result.status, stdout: result.stdout, stderr: result.stderr };
    }),
  };
  async function open(url) {
    return Effect.runPromise(Effect.result(actions.namespace.runDeviceAction(ready, 'android', { type: 'openUrl', deviceId: 'emulator-5554', url })));
  }
  const good = await open('https://example.com/a b?x=1&y=2');
  const failedExit = await open('t3-unhandled-fixture://nonzero');
  if (!Result.isSuccess(good) || !Result.isFailure(failedExit)) throw new Error('Passing/failing controls did not behave as expected');
  console.log('CONTROL: complete quoted URL succeeds; nonzero device-shell failure is surfaced');
  const rejected = await open('t3-unhandled-fixture://zero');
  console.log(`UNHANDLED: ${JSON.stringify(outputs.at(-1))}`);
  console.log(`T3 action result: ${Result.isSuccess(rejected) ? 'success' : 'failure'}`);
  status = Result.isSuccess(rejected) ? 1 : 0;
} catch (error) {
  console.log(`Setup/control error: ${error.message}`);
} finally {
  fs.rmSync(scratch, { recursive: true, force: true });
}
console.log(status === 0 ? 'RESULT: expected behavior' : status === 1 ? 'RESULT: defect reproduced' : 'RESULT: inconclusive');
process.exitCode = status;

Expected behavior

An unresolved VIEW intent should fail the action and retain the Android rejection diagnostic, even when the guest command exits zero. A resolvable link should still complete successfully, and warnings for a delivered-to-existing-activity result should not be treated as launch failures.

The official activity-manager reference defines am start as starting an activity using the supplied intent. In released Android 14 ActivityManagerShellCommand.java#L729-L819, START_INTENT_NOT_RESOLVED prints the rejection, leaves launched false, and breaks before the method returns 0. Android 13 uses the same failure branch. These references establish that host-process success alone does not establish an activity launch on those guests. Android 15 and 16 source returns 1 for that branch, so this report does not claim the same failure on those releases.

Impact and scope

Impact: P2 - bounded incorrect success reporting for URL-opening failures on guest images with the legacy activity-manager behavior. It hides why a deep link did not open and prevents callers from distinguishing a rejected launch from a successful one.

Bug-form impact: Minor bug or occasional failure. The observed failure is the production action function's result with a source-derived response. No app, permission, or device state was changed. Workaround status: unverified; inspecting a direct adb command's diagnostic is a possible diagnostic bypass, but it was not run against a device. NOT_ENOUGH_INFO: actual frequency, vendor modifications, and full panel behavior on a live older guest; an isolated Android 13/14 device test would establish those details.

Version

T3 Code commit 59e64eef881632a23f89c4138bcf37f23d92bf93; pinned effect@4.1.3. Guest response contract checked against AOSP android-13.0.0_r1 and android-14.0.0_r1; compared with android-15.0.0_r1 and android-16.0.0_r1.

Runtime and platform

Observed harness: Node v26.12.1, macOS 27.2, Darwin 27.2.0, arm64, Git 2.56.0, /bin/sh reporting 3.2.57(1)-release. adb and Android Emulator were not executed; the only adb executable was the temporary fixture. This reproduction uses no helper package, device, emulator, account, or server.

Interface

API: device.action, Android openUrl; tested at the exported runDeviceAction function with a substituted host runner.

Earliest failure stage

Integration or transport

Relevant output or evidence

The exact meaningful stdout from the reproduction (the Node experimental-feature warnings are omitted):

Node v26.12.1; effect 4.1.3
git version 2.56.0
T3 59e64eef881632a23f89c4138bcf37f23d92bf93
Shell 3.2.57(1)-release
adb fixture: Android 14 activity-manager response contract; no real adb invoked
CONTROL: complete quoted URL succeeds; nonzero device-shell failure is surfaced
UNHANDLED: {"code":0,"stdout":"Starting: Intent { act=android.intent.action.VIEW dat=t3-unhandled-fixture://zero }","stderr":"Error: Activity not started, unable to resolve Intent"}
T3 action result: success
RESULT: defect reproduced

The action's success decision only checks result.code === 0 in apps/server/src/device/DeviceActions.ts#L75-L84. Its adb wrapper applies that decision in apps/server/src/device/DeviceActions.ts#L341-L354, and openUrl ignores the returned stdout in apps/server/src/device/DeviceActions.ts#L443-L448.

Expected outcome or recovery

Restoration check: rerun the script and obtain RESULT: expected behavior with exit 0 while both controls continue to pass. A later disposable older-guest check should show the rejected link reaching the caller as a failure with the guest diagnostic. That hardware/runtime check has not been performed.

Additional context

  • https://github.com/pingdotgg/t3code/pull/25205 — preserves complete shell-sensitive URL arguments; this report concerns the result after the correctly quoted command reaches the guest, with no quoting change required by the reproduction.
  1. tanstack-source added labelsbug

  2. juliusmarmingecommented

    I tried to reproduce this on the current build and could not.

    Commit e018563669 (origin/main). No live Android 13/14 run was possible: the device pool only keeps API 36 images ready and gives this VM no adb access to its devices, which T3's Android device host needs. To confirm the guest behaviour, I fetched AOSP android-14.0.0_r1 ActivityManagerShellCommand.java: in START_INTENT_NOT_RESOLVED it prints 'Error: Activity not started, unable to resolve Intent ...' through getErrPrintWriter() (stderr), then breaks and returns 0. android-15.0.0_r1 returns 1 in the same branch. I then added a test to apps/server/src/device/DeviceActions.test.ts that runs runDeviceAction(android, openUrl) with a host runner returning code 0, a 'Starting: Intent' stdout and that stderr. vp test run src/device/DeviceActions.test.ts from apps/server: the new test fails because the result is { _tag: 'Success', value: undefined } instead of a DeviceOperationError carrying the stderr; the other 17 tests pass.

    Closing as not planned. If it still happens for you, reply with the exact steps and the version you are on and it will be looked at again.

  3. juliusmarminge closed this

Sign in to join this conversation.