Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,13 @@ actual class AndroidDeviceCommandExecutor actual constructor(
return AdbCommandUtil.isAppRunning(appId)
}

actual fun grantAppOpsPermission(appId: String, permission: String) {
AdbCommandUtil.grantAppOpsPermission(
targetAppPackageName = appId,
permission = permission,
)
}

actual fun writeFileToDownloads(fileName: String, content: ByteArray) {
val context = InstrumentationRegistry.getInstrumentation().context
FileReadWriteUtil.writeToDownloadsFile(
Expand Down Expand Up @@ -188,4 +195,5 @@ actual class AndroidDeviceCommandExecutor actual constructor(
tempFile.delete()
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ import xyz.block.trailblaze.devices.TrailblazeDeviceId
* Expected class for executing device commands on Android devices.
* Implementations may use ADB (from host JVM) or direct Android APIs (from on-device Android).
*/
/**
* AppOps operation name for the MANAGE_EXTERNAL_STORAGE permission (API 30+).
* Equivalent to [android.app.AppOpsManager.OPSTR_MANAGE_EXTERNAL_STORAGE], which is not
* available on JVM.
*/
const val APPOPS_MANAGE_EXTERNAL_STORAGE = "MANAGE_EXTERNAL_STORAGE"

expect class AndroidDeviceCommandExecutor(
deviceId: TrailblazeDeviceId,
) {
Expand Down Expand Up @@ -36,6 +43,28 @@ expect class AndroidDeviceCommandExecutor(
*/
fun isAppRunning(appId: String): Boolean

/**
* Grants an AppOps permission to the specified app via `appops set <appId> <permission> allow`.
*
* Requires ADB shell access or an instrumentation test context (which runs as the `shell` user
* via [UiDevice.executeShellCommand]). Does **not** require root. Will fail with a permission
* denial if called from a regular (non-privileged) app process.
*
* This only works for AppOps operations, **not** standard runtime permissions (which require
* `pm grant`). Common supported operations include:
* - `MANAGE_EXTERNAL_STORAGE` — broad file access (API 30+)
* - `REQUEST_INSTALL_PACKAGES` — install unknown apps
* - `SYSTEM_ALERT_WINDOW` — draw overlays
* - `WRITE_SETTINGS` — modify system settings
* - `ACCESS_NOTIFICATIONS` — read notifications
* - `PICTURE_IN_PICTURE` — picture-in-picture mode
*
* @param appId the application package name
* @param permission the AppOps operation name (e.g. [APPOPS_MANAGE_EXTERNAL_STORAGE])
* @see <a href="https://developer.android.com/reference/android/app/AppOpsManager">AppOpsManager</a>
*/
fun grantAppOpsPermission(appId: String, permission: String)

/**
* Writes a file to the device's public Downloads directory.
* On Android, this uses MediaStore/ContentResolver for Q+ compatibility.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ object HostAndroidDeviceConnectUtils {
sendProgressMessage("Installing pre-compiled test APK...")

val installSuccess = PrecompiledApkInstaller.extractAndInstallPrecompiledApk(
resourcePath = PrecompiledApkInstaller.PRECOMPILED_APK_RESOURCE_PATH,
trailblazeDeviceId = trailblazeDeviceId,
sendProgressMessage = sendProgressMessage,
)
Expand Down Expand Up @@ -172,15 +171,24 @@ object HostAndroidDeviceConnectUtils {
)

if (alreadyRunning) {
sendProgressMessage("On-device server already running — reusing existing connection.")
Console.log("On-device server already running for ${trailblazeOnDeviceInstrumentationTarget.testAppId}, skipping force-stop/reinstall.")
return DeviceConnectionStatus.WithTargetDevice.TrailblazeInstrumentationRunning(
trailblazeDeviceId = trailblazeDeviceId,
)
// Even if running, verify the installed APK matches the bundled version.
// This handles brew upgrades and local source rebuilds transparently.
if (PrecompiledApkInstaller.isInstalledApkUpToDate(trailblazeDeviceId)) {
sendProgressMessage("On-device server already running — reusing existing connection.")
Console.log("On-device server already running for ${trailblazeOnDeviceInstrumentationTarget.testAppId}, skipping force-stop/reinstall.")
return DeviceConnectionStatus.WithTargetDevice.TrailblazeInstrumentationRunning(
trailblazeDeviceId = trailblazeDeviceId,
)
} else {
sendProgressMessage("On-device APK is outdated — reinstalling...")
Console.log("APK SHA mismatch for ${trailblazeOnDeviceInstrumentationTarget.testAppId}, forcing reinstall.")
}
}

// Server not running (or force restart requested) — clean slate setup
sendProgressMessage("On-device server not running — starting fresh...")
// Server not running, force restart requested, or APK outdated — clean slate setup
if (!alreadyRunning) {
sendProgressMessage("On-device server not running — starting fresh...")
}
forceStopAllAndroidInstrumentationProcesses(
trailblazeOnDeviceInstrumentationTargetTestApps = setOf(trailblazeOnDeviceInstrumentationTarget),
deviceId = trailblazeDeviceId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import kotlinx.coroutines.withContext
import xyz.block.trailblaze.devices.TrailblazeDeviceId
import xyz.block.trailblaze.model.TrailblazeOnDeviceInstrumentationTarget
import java.nio.file.Files
import java.security.MessageDigest

object PrecompiledApkInstaller {

Expand All @@ -14,18 +15,51 @@ object PrecompiledApkInstaller {
*/
const val PRECOMPILED_APK_RESOURCE_PATH = "/apks/trailblaze-ondevice-runner.apk"

/** Device-side path where the APK SHA marker is stored after installation. */
private const val DEVICE_SHA_MARKER_PATH = "/data/local/tmp/trailblaze-runner-sha.txt"

/** Cached SHA256 of the bundled APK resource, computed once per process. */
private val bundledApkSha: String? by lazy { computeBundledApkSha() }

/**
* Checks whether the on-device APK matches the bundled APK by comparing SHA256 hashes.
* Returns true if the installed version is up-to-date, false if a reinstall is needed.
*/
fun isInstalledApkUpToDate(trailblazeDeviceId: TrailblazeDeviceId): Boolean {
// If we can't compute the bundled SHA (e.g., resource missing in dev packaging),
// assume up-to-date to avoid tearing down a working on-device server.
val expectedSha = bundledApkSha ?: return true
return try {
val deviceSha = AndroidHostAdbUtils.execAdbShellCommand(
deviceId = trailblazeDeviceId,
args = listOf("cat", DEVICE_SHA_MARKER_PATH),
).trim()
val match = deviceSha == expectedSha
if (!match) {
Console.log(
"APK version mismatch: device=$deviceSha, bundled=$expectedSha — will reinstall."
)
}
match
} catch (e: Exception) {
Console.log("Could not read APK SHA marker from device: ${e.message}")
false
}
}

/**
* Extracts a pre-compiled APK from resources and installs it on the device.
* The APK is bundled during the desktop app build process, eliminating the need
* for runtime Gradle compilation and significantly improving user experience.
*
* @param resourcePath The path to the APK resource
* After successful installation a SHA256 marker is written to the device so that
* subsequent connections can skip reinstallation when the APK hasn't changed.
*
* @param deviceId The device ID to install the APK on
* @param sendProgressMessage Callback to send progress messages
* @return true if installation was successful, false otherwise
*/
suspend fun extractAndInstallPrecompiledApk(
resourcePath: String,
trailblazeDeviceId: TrailblazeDeviceId,
sendProgressMessage: (String) -> Unit,
): Boolean {
Expand All @@ -34,7 +68,7 @@ object PrecompiledApkInstaller {

val tempApkFile = withContext(Dispatchers.IO) {
// Get the APK from resources
val apkInputStream = PrecompiledApkInstaller::class.java.getResourceAsStream(resourcePath)
val apkInputStream = PrecompiledApkInstaller::class.java.getResourceAsStream(PRECOMPILED_APK_RESOURCE_PATH)
?: return@withContext null

// Create a temporary file to store the APK
Expand All @@ -50,7 +84,7 @@ object PrecompiledApkInstaller {

tempFile
} ?: return run {
sendProgressMessage("Error: Could not find APK at resource path: $resourcePath")
sendProgressMessage("Error: Could not find APK at resource path: $PRECOMPILED_APK_RESOURCE_PATH")
false
}

Expand All @@ -64,6 +98,8 @@ object PrecompiledApkInstaller {

if (installResult) {
sendProgressMessage("Test APK installed successfully")
// Write the SHA marker so future connections can skip reinstallation
writeShaMarkerToDevice(trailblazeDeviceId)
} else {
sendProgressMessage("Failed to install test APK")
}
Expand All @@ -82,4 +118,32 @@ object PrecompiledApkInstaller {
fun hasPrecompiledApk(target: TrailblazeOnDeviceInstrumentationTarget): Boolean {
return PrecompiledApkInstaller::class.java.getResource(PRECOMPILED_APK_RESOURCE_PATH) != null
}

private fun computeBundledApkSha(): String? = try {
PrecompiledApkInstaller::class.java.getResourceAsStream(PRECOMPILED_APK_RESOURCE_PATH)
?.use { input ->
val digest = MessageDigest.getInstance("SHA-256")
val buffer = ByteArray(8192)
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
digest.update(buffer, 0, bytesRead)
}
digest.digest().joinToString("") { "%02x".format(it) }
}
} catch (e: Exception) {
Console.log("Failed to compute bundled APK SHA: ${e.message}")
null
}

private fun writeShaMarkerToDevice(trailblazeDeviceId: TrailblazeDeviceId) {
val sha = bundledApkSha ?: return
try {
AndroidHostAdbUtils.execAdbShellCommand(
deviceId = trailblazeDeviceId,
args = listOf("sh", "-c", "echo '$sha' > $DEVICE_SHA_MARKER_PATH"),
)
} catch (e: Exception) {
Console.log("Failed to write APK SHA marker to device: ${e.message}")
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ actual class AndroidDeviceCommandExecutor actual constructor(
)
}

actual fun grantAppOpsPermission(appId: String, permission: String) {
shellCommand("appops", "set", appId, permission, "allow")
}

/**
* Writes a file to Downloads via the MediaStore content provider.
*
Expand Down
13 changes: 9 additions & 4 deletions trailblaze-desktop/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,18 @@ val shrinkUberJar by tasks.registering(JavaExec::class) {
outputs.file(outputJar)

val javaHome = System.getProperty("java.home")
// Resolved in doFirst, reused in doLast so both operate on the same JAR.
var resolvedInputJar: File? = null

doFirst {
outputJar.get().asFile.parentFile.mkdirs()
// Find the newest uber JAR (old builds may leave stale JARs in this directory).
val jarsDir = layout.buildDirectory.dir("compose/jars").get().asFile
val actualJar = jarsDir.listFiles()?.firstOrNull { it.extension == "jar" }
val actualJar = jarsDir.listFiles()
?.filter { it.extension == "jar" }
?.maxByOrNull { it.lastModified() }
?: error("No uber JAR found in ${jarsDir.absolutePath}")
resolvedInputJar = actualJar

val jmodsArgs = File("$javaHome/jmods").listFiles { f -> f.extension == "jmod" }
?.sorted()
Expand All @@ -175,9 +182,7 @@ val shrinkUberJar by tasks.registering(JavaExec::class) {
}

doLast {
val jarsDir = layout.buildDirectory.dir("compose/jars").get().asFile
val originalJar = jarsDir.listFiles()?.firstOrNull { it.extension == "jar" }
?: return@doLast
val originalJar = resolvedInputJar ?: return@doLast
restoreArchiveEntries(originalJar, outputJar.get().asFile)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,13 +207,24 @@ class PlaywrightBrowserInstaller {
return false
}

return cachePath.listFiles()?.any { file ->
val dirs = cachePath.listFiles() ?: return false
// Check for INSTALLATION_COMPLETE marker — Playwright writes this after a successful download.
// Checking only directory names is insufficient (partial downloads leave empty directories).
val hasChromium = dirs.any { file ->
file.isDirectory &&
(file.name.startsWith("chromium-") || file.name.startsWith("chromium_headless_shell-"))
} ?: false
file.name.startsWith("chromium-") &&
File(file, INSTALLATION_COMPLETE_MARKER).exists()
}
val hasHeadlessShell = dirs.any { file ->
file.isDirectory &&
file.name.startsWith("chromium_headless_shell-") &&
File(file, INSTALLATION_COMPLETE_MARKER).exists()
}
return hasChromium && hasHeadlessShell
}

companion object {
private val PROGRESS_REGEX = Regex("""(\d{1,3})\s*%""")
private const val INSTALLATION_COMPLETE_MARKER = "INSTALLATION_COMPLETE"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -901,8 +901,22 @@ class TrailblazeMcpBridgeImpl(
}

override suspend fun getAvailableDevices(): Set<TrailblazeConnectedDeviceSummary> {
val availableDevices = trailblazeDeviceManager.loadDevicesSuspend(applyDriverFilter = true).toSet()
return availableDevices
// Load all devices (unfiltered) — the UI-level targetDeviceFilter may not work correctly
// in headless/MCP mode (e.g., testingEnvironment=null skips Playwright, but MCP always
// needs to offer web). Instead, deduplicate here using the configured driver type per platform.
val allDevices = trailblazeDeviceManager.loadDevicesSuspend(applyDriverFilter = false)
return allDevices
.groupBy { it.instanceId to it.platform }
.map { (_, variants) ->
val platform = variants.first().platform
val configuredType = getConfiguredDriverType(platform)
if (configuredType != null) {
variants.find { it.trailblazeDriverType == configuredType } ?: variants.first()
} else {
variants.first()
}
}
.toSet()
}

override suspend fun getInstalledAppIds(): Set<String> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,9 +176,12 @@ object PlaywrightDriverManager {
*
* Playwright separates the "driver" (Node.js runtime, ~7 MB) from the "browser" (Chromium
* binary, ~150 MB). [ensureDriverAvailable] handles the driver; this method handles the
* browser. It checks for any `chromium-*` or `chromium_headless_shell-*` directory in the
* ms-playwright cache (or in [PLAYWRIGHT_BROWSERS_PATH] if set), and runs
* `playwright install chromium` if none is found.
* browser. It checks that both `chromium-*` and `chromium_headless_shell-*` directories exist
* in the ms-playwright cache (or in [PLAYWRIGHT_BROWSERS_PATH] if set) with a completed
* installation marker, and runs `playwright install chromium` if either is missing.
*
* Both variants are required because headless mode (the default) uses the headless shell,
* while headed mode uses the full Chromium browser.
*
* Must be called after [ensureDriverAvailable] so that `playwright.cli.dir` is already set.
* Blocks the calling thread until the download completes (one-time, ~150 MB).
Expand All @@ -201,6 +204,9 @@ object PlaywrightDriverManager {
/** Prefix for the headless-shell Chromium directory in the Playwright cache. */
private const val CHROMIUM_HEADLESS_SHELL_DIR_PREFIX = "chromium_headless_shell-"

/** Marker file Playwright writes after a successful browser download. */
private const val INSTALLATION_COMPLETE_MARKER = "INSTALLATION_COMPLETE"

/**
* Returns the Playwright browser cache directory for the current platform.
*
Expand Down Expand Up @@ -228,10 +234,23 @@ object PlaywrightDriverManager {
private fun isChromiumInstalled(): Boolean {
val cacheDir = getPlaywrightBrowsersCacheDir()
if (!cacheDir.exists() || !cacheDir.isDirectory) return false
return cacheDir.listFiles()?.any { file ->
// Playwright writes an INSTALLATION_COMPLETE marker file after downloading a browser.
// Checking only the directory name is insufficient — the directory can exist without
// the actual binary (e.g., partial download, interrupted install, or cleanup).
// We need *both* chromium and chromium_headless_shell because headless mode (the default)
// uses the headless shell variant, while headed mode needs the full browser.
val dirs = cacheDir.listFiles() ?: return false
val hasChromium = dirs.any { file ->
file.isDirectory &&
(file.name.startsWith(CHROMIUM_DIR_PREFIX) || file.name.startsWith(CHROMIUM_HEADLESS_SHELL_DIR_PREFIX))
} ?: false
file.name.startsWith(CHROMIUM_DIR_PREFIX) &&
File(file, INSTALLATION_COMPLETE_MARKER).exists()
}
val hasHeadlessShell = dirs.any { file ->
file.isDirectory &&
file.name.startsWith(CHROMIUM_HEADLESS_SHELL_DIR_PREFIX) &&
File(file, INSTALLATION_COMPLETE_MARKER).exists()
}
return hasChromium && hasHeadlessShell
}

private val progressRegex = Regex("""(\d{1,3})\s*%""")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,9 @@ class PlaywrightElectronBrowserManager(

init {
try {
// Ensure the Playwright driver is available (downloads on first use if driver-bundle
// is not on the classpath, e.g., when running from the uber JAR).
PlaywrightDriverManager.ensureDriverAvailable()
runBlocking(playwrightDispatcher) {
playwright = Playwright.create()
browser = playwright.chromium().connectOverCDP(cdpUrl)
Expand Down
Loading
Loading