fix(sample-app): make it standalone, no repo_root assumptions (#8)

* fix(cli): fall back to node_modules for @uatu/spec resolution

Drop the hard failure when the uatu source tree is not reachable from
the spec file. Users integrating uatu in their own app have @uatu/spec
installed via npm; esbuild now resolves it from node_modules.

* build(gradle): drop :sample-app include from root settings

The sample now has its own Gradle project in examples/sample-app/android.

* build(sample-app): vendor gradle wrapper

Users running the sample build the APK via ./gradlew from inside the
sample-app's own android/ directory, no repo-root wrapper required.

* build(sample-app): make gradle project standalone

Drop the project(':sdk-android') dependency in favor of the Maven
Central coordinate io.github.priyanshujain:sdk-android. The sample now
owns its settings.gradle.kts and gradle.properties, so it builds
without any pieces of the uatu source tree.

* chore(sample-app): declare @uatu/spec npm dependency

Mirrors what a downstream user would put in their own package.json.
Uses file: for pre-release development; becomes a normal semver pin
once @uatu/spec ships to npm.

* docs(sample-app): rewrite justfile and add README

Justfile drops repo_root; all recipes run against the local gradle
wrapper and uatu from PATH. README is scoped to what a user needs to
run the sample against their own device.

* docs(manual): update sample install steps to standalone layout

./gradlew :sample-app:installDebug no longer exists; the sample owns
its own wrapper under android/.

* feat(sdk): log when Uatu.start succeeds

Silent SDK start makes the "SDK didn't connect" failure mode
impossible to debug. One INFO line at start time is enough.

* fix(sidecar): launch via am start -W instead of monkey

monkey -p <pkg> -c LAUNCHER 1 is unreliable on API 36+: it reports no
error but silently fails to start the activity, so the SDK never runs
and the CLI times out on the SDK-accept handshake.

Resolve the launcher activity via `cmd package resolve-activity
--brief` and launch it with `am start -W -n`. -W makes the call block
until the activity is up, which also makes the subsequent SDK
accept timing deterministic.

* feat(cli): auto-resolve Android device; boot AVD if none connected

--avd becomes optional. Resolution order:
 - use any already-connected adb device;
 - else if --avd names an existing AVD, boot it and wait for boot;
 - else if --avd is missing or names no AVD, error with a clear message.

Falls back to $ANDROID_HOME/emulator/emulator when the binary is not on
PATH, so a standard Android SDK install works without extra shell setup.

* docs(sample-app): AVD is optional; document both paths

just test runs against any connected device. If none, pass AVD=<name>
to have uatu boot the emulator for you.

* feat(cli): auto-discover Android SDK; auto-pick the lone AVD

adb and emulator are looked up via PATH, then $ANDROID_HOME,
$ANDROID_SDK_ROOT, ~/Library/Android/sdk, ~/Android/Sdk, and the
Homebrew cask path. The discovered platform-tools directory is
prepended to the sidecar's PATH so its adb subprocess calls work too.

When --avd isn't passed and no device is connected, the CLI picks the
sole local AVD and boots it. Multiple AVDs → error listing them.

* build(make): add `make install` that go-installs uatu onto PATH

Puts `uatu` into $GOBIN (or $GOPATH/bin) so the sample and any local
dev flow can call it without PATH= prefixes.

* docs(sample-app): zero-config just test; dotenv-load for persistence

Drop the expectation that users prefix commands with PATH=, ANDROID_HOME=,
or AVD=. `just test` now works as-is; optional knobs can be pinned in a
.env file alongside the justfile.

* fix(sample-app): auto-detect ANDROID_HOME for Gradle tasks

The Go CLI finds the SDK itself, but AGP still needs ANDROID_HOME to
resolve `sdk.dir`. The justfile now resolves it from env or canonical
install paths before invoking ./gradlew, so `just install` works out
of the box on a standard Android SDK setup.

* gitignore runs directory for sample app
This commit is contained in:
pj authored and GitHub committed 2026-04-18 15:31:47 +07:00
1 parent bf733f4f93
commit 6b1c17328c
26 files changed
+1098 -65

No files matched your search

@@ -36,13 +36,47 @@ class StubDriverBackend(private val platform: String) : DriverBackend {
if (clearState) {
runAdb(listOf("shell", "pm", "clear", bundleId))
}
if (launcherActivity.isNotEmpty()) {
val component = if (launcherActivity.contains('/')) launcherActivity else "$bundleId/$launcherActivity"
runAdb(listOf("shell", "am", "start", "-n", component))
} else {
// `monkey` uses PackageManager.getLaunchIntentForPackage, which
// picks the canonical default launcher.
runAdb(listOf("shell", "monkey", "-p", bundleId, "-c", "android.intent.category.LAUNCHER", "1"))
val component = when {
launcherActivity.isEmpty() -> "$bundleId/${resolveLauncherActivity(bundleId)}"
launcherActivity.contains('/') -> launcherActivity
else -> "$bundleId/$launcherActivity"
}
runAdb(listOf("shell", "am", "start", "-W", "-n", component))
}
private fun resolveLauncherActivity(bundleId: String): String {
val output = captureAdb(
listOf(
"shell", "cmd", "package", "resolve-activity", "--brief",
"-a", "android.intent.action.MAIN",
"-c", "android.intent.category.LAUNCHER",
bundleId,
),
)
return parseResolvedActivity(bundleId, output)
?: throw IllegalStateException("could not resolve launcher activity for $bundleId: $output")
}
private fun captureAdb(arguments: List<String>): String {
val process = ProcessBuilder(listOf("adb") + arguments).redirectErrorStream(true).start()
val output = process.inputStream.bufferedReader().readText()
process.waitFor()
return output
}
companion object {
// parseResolvedActivity extracts the activity name from the output of
// `cmd package resolve-activity --brief`. The brief output is two
// lines: metadata, then `<pkg>/<activity>`.
internal fun parseResolvedActivity(bundleId: String, output: String): String? {
val prefix = "$bundleId/"
for (line in output.lines()) {
val trimmed = line.trim()
if (trimmed.startsWith(prefix)) {
return trimmed.removePrefix(prefix)
}
}
return null
}
}
@@ -35,7 +35,13 @@ class DriverServiceTest {
val backend = StubDriverBackend("android")
val client = newClient(backend)
client.launch(LaunchRequest.newBuilder().setBundleId("com.example").setClearState(true).build())
client.launch(
LaunchRequest.newBuilder()
.setBundleId("com.example")
.setLauncherActivity(".MainActivity")
.setClearState(true)
.build(),
)
assertEquals("com.example", backend.lastBundleId)
assertEquals(1, backend.launchCount)
@@ -0,0 +1,39 @@
package dev.uatu.sidecar
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class ResolveActivityTest {
@Test fun extractsActivityFromBriefOutput() {
val output = """
priority=0 preferredOrder=0 match=0x108000 specificIndex=-1 isDefault=false
dev.uatu.sample/.MainActivity
""".trimIndent()
val activity = StubDriverBackend.parseResolvedActivity("dev.uatu.sample", output)
assertEquals(".MainActivity", activity)
}
@Test fun extractsFullyQualifiedActivity() {
val output = "com.example.app/com.example.app.ui.LaunchActivity"
val activity = StubDriverBackend.parseResolvedActivity("com.example.app", output)
assertEquals("com.example.app.ui.LaunchActivity", activity)
}
@Test fun returnsNullWhenPackageNotFound() {
val output = "No activity found"
val activity = StubDriverBackend.parseResolvedActivity("dev.uatu.sample", output)
assertNull(activity)
}
@Test fun doesNotMatchDifferentPackagePrefix() {
val output = "other.pkg/.MainActivity"
val activity = StubDriverBackend.parseResolvedActivity("dev.uatu.sample", output)
assertNull(activity)
}
}