Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix android open link verify #1999

Merged
merged 9 commits into from
Sep 2, 2024
12 changes: 8 additions & 4 deletions maestro-client/src/main/java/maestro/android/AndroidAppFiles.kt
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,15 @@ object AndroidAppFiles {
}

fun getApkFile(dadb: Dadb, appId: String): File {
val apkPath = dadb.shell("pm list packages -f --user 0 | grep $appId | head -1")
.output.substringAfterLast("package:").substringBefore("=$appId")
apkPath.substringBefore("=$appId")
val apkPath = dadb.shell("pm path $appId").output.removePrefix("package:").trim()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice! Thanks for making it shorter.

val dst = File.createTempFile("tmp", ".apk")
dadb.pull(dst, apkPath)
try {
dadb.pull(dst, apkPath)
} catch (e: IOException) {
val newApkPath = "/sdcard/$appId.apk"
dadb.shell("cp $apkPath $newApkPath")
dadb.pull(dst, newApkPath)
}
Comment on lines +43 to +49
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this change? I'm curious to learn why /sdcard is used as fallback

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • I encountered a permission denied when pulling the APK on some of the API levels
  • This is because /data is not accessible to pull from without the device being rooted in those cases
  • However running shell cp and copying the files over is possible
  • And /sdcard is accessible to pull from

See this SO answer for example

If we add multi-API-level testing on the CI, we should add simple tests for AndroidAppFiles as this is the kind of things that breaks with API changes.

return dst
}

Expand Down
98 changes: 64 additions & 34 deletions maestro-client/src/main/java/maestro/drivers/AndroidDriver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import org.w3c.dom.Element
import org.w3c.dom.Node
import java.io.File
import java.io.IOException
import java.net.URI
import java.util.UUID
import java.util.concurrent.CompletableFuture
import java.util.concurrent.Executors
Expand Down Expand Up @@ -503,55 +504,85 @@ class AndroidDriver(
}

override fun openLink(link: String, appId: String?, autoVerify: Boolean, browser: Boolean) {
if (browser) {
openBrowser(link)
} else {
dadb.shell("am start -a android.intent.action.VIEW -d \"$link\"")
}
if (autoVerify && !browser && appId != null) autoVerifyLinkFromSettings(appId, link)

if (browser) openBrowser(link)
else dadb.shell("am start -a android.intent.action.VIEW -d \"$link\"")

if (autoVerify) {
autoVerifyApp(appId)
if (appId != null && !browser) autoVerifyWithChooser(appId)
autoVerifyChromeOnboarding()
}
}

private fun autoVerifyApp(appId: String?) {
if (appId != null) {
autoVerifyWithAppName(appId)
private fun autoVerifyLinkFromSettings(appId: String, link: String) {
val apiLevel = dadb.shell("getprop ro.build.version.sdk").output.trim().toInt()
if (apiLevel <= 30) return
val domain = runCatching { URI.create(link).toURL().host }.getOrNull() ?: return
val packageOption = "--package $appId"
val allowed = dadb.shell("pm set-app-links-allowed --user 0 $packageOption true")
if (allowed.exitCode > 0) {
LOGGER.debug("set-app-links-allowed failed for appId: $appId reason: ${allowed.errorOutput}")
return
}
val selected = dadb.shell("pm set-app-links-user-selection --user 0 $packageOption true $domain")
if (selected.exitCode > 0) {
LOGGER.debug("set-app-links-user-selection failed for appId: $appId domain: $domain reason: ${selected.errorOutput}")
}
autoVerifyChromeAgreement()
}

private fun autoVerifyWithAppName(appId: String) {
private fun autoVerifyWithChooser(appId: String) {
val appNameResult = runCatching {
val apkFile = AndroidAppFiles.getApkFile(dadb, appId)
val appName = ApkFile(apkFile).apkMeta.name
apkFile.delete()
appName
appName ?: appId // The app chooser shows the appId if no application label attribute is set
}
if (appNameResult.isSuccess) {
val appName = appNameResult.getOrThrow()
waitUntilScreenIsStatic(3000)
val appNameElement = filterByText(appName)
if (appNameElement != null) {
tap(appNameElement.bounds.center())
filterById("android:id/button_once")?.let {
tap(it.bounds.center())
}
} else {
val openWithAppElement = filterByText(".*$appName.*")
if (openWithAppElement != null) {
filterById("android:id/button_once")?.let {
tap(it.bounds.center())
}
}
}
if (appNameResult.isFailure) {
LOGGER.info("Aborting autoVerify. Could not get app name from APK metadata for $appId", appNameResult.exceptionOrNull())
return
}

fun selectChooserOptionOnce(wordElement: UiElement) {
tap(wordElement.bounds.center())
filterById("android:id/button_once")?.let { tap(it.bounds.center()) }
}

waitUntilScreenIsStatic(3000)

val appName = appNameResult.getOrThrow()
filterByText(".*$appName.*")?.let {
selectChooserOptionOnce(it)
return
}

val appNameWord = appName.split(" ").first()
filterByText(".*$appNameWord.*")?.let {
selectChooserOptionOnce(it)
return
}

LOGGER.info("Aborting autoVerify. Could not find app name element for $appName")
}

private fun autoVerifyChromeAgreement() {
filterById("com.android.chrome:id/terms_accept")?.let { tap(it.bounds.center()) }
private fun autoVerifyChromeOnboarding() {
val chrome = "com.android.chrome"
if (!isPackageInstalled(chrome)) return
Thread.sleep(100) // Lets enough time for the transition to Chrome to start
waitUntilScreenIsStatic(3000)
// Welcome to Chrome screen "Accept & continue"
filterById("$chrome:id/send_report_checkbox")?.let { tap(it.bounds.center()) }
filterById("$chrome:id/negative_button")?.let { tap(it.bounds.center()) }
filterById("$chrome:id/terms_accept")?.let { tap(it.bounds.center()) }
waitForAppToSettle(null, null)
// Welcome to Chrome screen "Add account to device"
filterById("$chrome:id/signin_fre_dismiss_button")?.let { tap(it.bounds.center()) }
waitForAppToSettle(null, null)
filterById("com.android.chrome:id/negative_button")?.let { tap(it.bounds.center()) }
// Turn on Sync screen
filterById("$chrome:id/negative_button")?.let { tap(it.bounds.center()) }
waitForAppToSettle(null, null)
// Chrome Notifications screen
filterById("$chrome:id/negative_button")?.let { tap(it.bounds.center()) }
}

private fun filterByText(textRegex: String): UiElement? {
Expand All @@ -572,13 +603,12 @@ class AndroidDriver(
installedPackages.contains("com.android.chrome") -> {
dadb.shell("am start -a android.intent.action.VIEW -d \"$link\" com.android.chrome")
}

installedPackages.contains("org.mozilla.firefox") -> {
dadb.shell("am start -a android.intent.action.VIEW -d \"$link\" org.mozilla.firefox")
}

else -> {
dadb.shell("am start -a android.intent.action.VIEW -d \"$link\"")
autoVerifyWithChooser("org.chromium.webview_shell")
}
}
}
Expand Down
Loading