diff --git a/apps/expo-go/.gitignore b/apps/expo-go/.gitignore index d565bde5e266a1..d2af1bdb76297c 100644 --- a/apps/expo-go/.gitignore +++ b/apps/expo-go/.gitignore @@ -6,8 +6,6 @@ /android/**/local.properties /android/captures /android/expoview/libs/ReactAndroid-temp -/android/versioned-react-native/packages -/android/versioned-react-native/ReactAndroid ReactAndroid-temp.aar /android/prebuiltHermes /android/versioned-abis/**/*.aar diff --git a/apps/expo-go/android/app/build.gradle b/apps/expo-go/android/app/build.gradle index 5604b1ec58df17..15763a8202aa37 100644 --- a/apps/expo-go/android/app/build.gradle +++ b/apps/expo-go/android/app/build.gradle @@ -230,36 +230,9 @@ dependencies { implementation 'androidx.core:core-splashscreen:1.0.1' // Our dependencies from ExpoView - // DON'T ADD ANYTHING HERE THAT ISN'T IN EXPOVIEW. ONLY COPY THINGS FROM EXPOVIEW TO HERE. compileOnly 'org.glassfish:javax.annotation:3.1.1' - implementation 'com.jakewharton:butterknife:10.2.1' - implementation 'de.greenrobot:eventbus:2.4.0' - - implementation 'com.squareup.picasso:picasso:2.5.2' - implementation 'com.google.android.gms:play-services-analytics:16.0.1' - implementation 'com.google.android.gms:play-services-maps:18.0.0' - implementation 'com.google.android.gms:play-services-auth:15.0.1' - implementation 'com.google.android.gms:play-services-location:15.0.1' - debugImplementation 'com.squareup.leakcanary:leakcanary-android-no-op:1.4-beta1' - // debugImplementation 'com.squareup.leakcanary:leakcanary-android:1.4-beta1' - releaseImplementation 'com.squareup.leakcanary:leakcanary-android-no-op:1.4-beta1' - implementation 'com.facebook.device.yearclass:yearclass:2.1.0' - implementation 'commons-io:commons-io:1.4' - implementation 'com.airbnb.android:lottie:6.5.2' - implementation 'me.leolin:ShortcutBadger:1.1.4@aar' - implementation 'com.vanniktech:android-image-cropper:4.6.0' - implementation 'commons-codec:commons-codec:1.10' - implementation 'com.google.zxing:core:3.3.3' - implementation 'net.openid:appauth:0.4.1' - implementation "androidx.exifinterface:exifinterface:1.3.3" - implementation 'com.squareup.okio:okio:1.9.0' - implementation 'com.facebook.soloader:soloader:0.12.1' implementation 'com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava' - // expo-file-system - implementation 'com.squareup.okhttp3:okhttp:3.10.0' - implementation 'com.squareup.okhttp3:okhttp-urlconnection:3.10.0' - // Testing androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0' androidTestImplementation 'androidx.test:runner:1.4.0' diff --git a/apps/expo-go/android/expoview/build.gradle b/apps/expo-go/android/expoview/build.gradle index 5e37d7b27f1428..4c3feb369581fa 100644 --- a/apps/expo-go/android/expoview/build.gradle +++ b/apps/expo-go/android/expoview/build.gradle @@ -24,7 +24,6 @@ plugins { id 'expo-module-gradle-plugin' } apply plugin: 'com.google.devtools.ksp' -apply from: new File(rootDir, "versioning_linking.gradle") apply plugin: 'com.apollographql.apollo' expoModule { @@ -34,12 +33,6 @@ expoModule { def reactProperties = new Properties() file("${project(':packages:react-native:ReactAndroid').projectDir}/gradle.properties").withInputStream { reactProperties.load(it) } -// WHEN_VERSIONING_REMOVE_FROM_HERE -//maven repository info -group = 'host.exp.exponent' -version = '45.0.0' -// WHEN_VERSIONING_REMOVE_TO_HERE - repositories { mavenCentral() maven { url "https://jitpack.io" } @@ -90,11 +83,6 @@ android { versionCode 1 versionName "1.0" - /* WHEN_VERSIONING_UNCOMMENT_FROM_HERE - missingDimensionStrategy('versioning', 'versioned') - WHEN_VERSIONING_UNCOMMENT_TO_HERE */ - - // WHEN_VERSIONING_REMOVE_FROM_HERE manifestPlaceholders = [ 'appAuthRedirectScheme': 'host.exp.exponent' ] @@ -102,7 +90,6 @@ android { buildConfigField("boolean", "IS_INTERNAL_BUILD", "false") buildConfigField("int", "EXOPACKAGE_FLAGS", "0") buildConfigField("boolean", "IS_NEW_ARCHITECTURE_ENABLED", "true") - // WHEN_VERSIONING_REMOVE_TO_HERE } buildTypes { @@ -124,7 +111,6 @@ apollo { } } -// WHEN_VERSIONING_REMOVE_FROM_HERE import org.apache.tools.ant.taskdefs.condition.Os class GenerateDynamicMacrosPlugin implements Plugin { @@ -152,8 +138,6 @@ class GenerateDynamicMacrosPlugin implements Plugin { apply plugin: GenerateDynamicMacrosPlugin -// WHEN_VERSIONING_REMOVE_TO_HERE - dependencies { api fileTree(dir: 'libs', include: ['*.jar']) implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion" @@ -165,12 +149,10 @@ dependencies { implementation 'io.coil-kt.coil3:coil-network-okhttp:3.2.0' implementation 'androidx.compose.material3:material3:1.4.0-alpha10' implementation 'androidx.core:core-splashscreen:1.0.1' - implementation 'io.coil-kt:coil-compose:2.6.0' implementation "androidx.navigation:navigation-compose:2.8.5" implementation "org.jetbrains.kotlinx:kotlinx-serialization-core:1.9.0" implementation "androidx.compose.ui:ui:1.7.6" implementation "androidx.compose.runtime:runtime-saveable:1.7.6" - implementation "androidx.compose.material3:material3:1.3.1" implementation "androidx.compose.animation:animation:1.7.6" implementation "androidx.compose.foundation:foundation:1.7.6" implementation "androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7" @@ -178,10 +160,6 @@ dependencies { testImplementation 'junit:junit:4.13.2' - // WHEN_VERSIONING_REPLACE_WITH_DEPENDENCIES - - // WHEN_VERSIONING_REMOVE_FROM_HERE - api 'org.webkit:android-jsc:r250231' // needs to be before react-native api project(':packages:react-native:ReactAndroid') @@ -193,10 +171,6 @@ dependencies { // Expo modules api project(':expo') - // Versioned react native - // THIS COMMENT IS USED BY android-build-aar.sh DO NOT MODIFY - // WHEN_VERSIONING_REMOVE_TO_HERE - // React native dependencies api(libs.androidx.appcompat.resources) api(libs.androidx.appcompat) @@ -225,7 +199,6 @@ dependencies { api 'de.greenrobot:eventbus:2.4.0' api 'com.google.firebase:firebase-crashlytics:19.3.0' api 'com.google.firebase:firebase-crashlytics-ndk:19.3.0' - api "androidx.room:room-runtime:2.1.0" implementation 'com.google.android.material:material:1.3.0' implementation 'com.google.code.gson:gson:2.13.2' @@ -233,20 +206,9 @@ dependencies { api(expoLibs.fresco.animated.webp) api(expoLibs.fresco.webpsupport) - api 'com.squareup.picasso:picasso:2.5.2' - api 'com.google.android.gms:play-services-analytics:17.0.0' - api 'com.google.android.gms:play-services-maps:18.0.1' - api 'com.google.android.gms:play-services-auth:17.0.0' - api 'com.google.android.gms:play-services-location:20.0.0' api 'com.google.android.gms:play-services-fitness:17.0.0' - api 'com.google.android.gms:play-services-wallet:17.0.0' //may need 10.+ - debugApi 'com.squareup.leakcanary:leakcanary-android-no-op:1.4-beta1' - // debugApi 'com.squareup.leakcanary:leakcanary-android:1.4-beta1' - releaseApi 'com.squareup.leakcanary:leakcanary-android-no-op:1.4-beta1' api 'commons-io:commons-io:2.6' api 'me.leolin:ShortcutBadger:1.1.4@aar' - implementation "com.vanniktech:android-image-cropper:4.6.0" - api 'commons-codec:commons-codec:1.10' api 'net.openid:appauth:0.7.1' api 'com.airbnb.android:lottie:6.5.2' compileOnly 'io.branch.sdk.android:library:4.1.0' @@ -255,22 +217,9 @@ dependencies { api "androidx.browser:browser:1.0.0" implementation "androidx.tracing:tracing-ktx:1.1.0" - api 'com.google.firebase:firebase-core:21.1.0' api 'com.google.firebase:firebase-messaging:22.0.0' - api 'com.google.maps.android:android-maps-utils:3.4.0' - // Remember to update DetachAppTemplate build.gradle if you add any excludes or transitive = false here! - - // expo-file-system - api 'com.squareup.okhttp3:okhttp:3.10.0' - api 'com.squareup.okhttp3:okhttp-urlconnection:3.10.0' - // expo-application - api 'com.android.installreferrer:installreferrer:1.0' - - // expo-blur - implementation 'com.github.Dimezis:BlurView:version-2.0.3' - - // expo-camera + // Home screen QR scanner (QRScannerActivity, HomeAppViewModel) def camerax_version = "1.4.0-alpha02" implementation "androidx.camera:camera-core:${camerax_version}" implementation "androidx.camera:camera-camera2:${camerax_version}" @@ -282,7 +231,7 @@ dependencies { implementation "com.google.mlkit:barcode-scanning:17.3.0" implementation 'androidx.camera:camera-mlkit-vision:1.4.0-alpha02' - // expo-store-review + // In-app review prompt (HomeAppViewModel) implementation "com.google.android.play:review:2.0.1" implementation "com.google.android.play:review-ktx:2.0.0" @@ -293,17 +242,6 @@ dependencies { implementation 'com.cronutils:cron-utils:4.1.3' - // @react-native-community/viewpager - api 'com.github.troZee:ViewPager2:v1.0.6' - - // react-native-webview - implementation "androidx.webkit:webkit:1.4.0" - - // expo-updates - implementation 'org.bouncycastle:bcutil-jdk15to18:1.70' - - // react-native-maps - implementation "androidx.work:work-runtime:2.7.1" } project.afterEvaluate { @@ -320,5 +258,3 @@ project.afterEvaluate { // For the workaround, we just pretend we don't have local aar dependencies. tasks.findByName("bundleReleaseLocalLintAar")?.localAarDeps?.setFrom([]) } - -useVendoredModulesForExpoView('unversioned') diff --git a/apps/expo-go/android/expoview/src/main/AndroidManifest.xml b/apps/expo-go/android/expoview/src/main/AndroidManifest.xml index 2b4233e70ffbe9..7f3f3c939384d9 100644 --- a/apps/expo-go/android/expoview/src/main/AndroidManifest.xml +++ b/apps/expo-go/android/expoview/src/main/AndroidManifest.xml @@ -2,14 +2,12 @@ xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> - - @@ -70,7 +68,6 @@ - - - - include ":expoview-$abiVariant" - project(":expoview-$abiVariant").projectDir = new File(rootDir, "versioned-abis/expoview-$abiVariant") -}) diff --git a/apps/expo-go/android/versioned-react-native/build.gradle b/apps/expo-go/android/versioned-react-native/build.gradle deleted file mode 100755 index 81e689a7ec7612..00000000000000 --- a/apps/expo-go/android/versioned-react-native/build.gradle +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright 2015-present Facebook. All Rights Reserved. - -buildscript { - ext { - kotlinVersion = '1.8.10' - } - repositories { - google() - mavenCentral() - mavenLocal() - } - dependencies { - classpath "com.android.tools.build:gradle" - classpath 'de.undercouch:gradle-download-task:5.0.1' - classpath "com.facebook.react:react-native-gradle-plugin" - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" - - // NOTE: Do not place your application dependencies here; they belong - // in the individual module build.gradle files - } -} - -allprojects { - repositories { - google() - mavenCentral() - mavenLocal() - } -} diff --git a/apps/expo-go/android/versioned-react-native/gradle.properties b/apps/expo-go/android/versioned-react-native/gradle.properties deleted file mode 100644 index 28f6afde78d6f2..00000000000000 --- a/apps/expo-go/android/versioned-react-native/gradle.properties +++ /dev/null @@ -1,15 +0,0 @@ -org.gradle.parallel=true -org.gradle.daemon=true -org.gradle.jvmargs=-Xmx9216m -XX:MaxMetaspaceSize=512m -org.gradle.configureondemand=true - -# Use this property to specify which architecture you want to build. -# You can also override it from the CLI using -# ./gradlew -PreactNativeArchitectures=x86_64 -reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64 - -android.useAndroidX=true -android.enableJetifier=true - -# Remove this workaround when upgrading to react-native@0.72.1 -kotlin.jvm.target.validation.mode=warning diff --git a/apps/expo-go/android/versioned-react-native/gradle/wrapper/gradle-wrapper.jar b/apps/expo-go/android/versioned-react-native/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 249e5832f090a2..00000000000000 Binary files a/apps/expo-go/android/versioned-react-native/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/apps/expo-go/android/versioned-react-native/gradle/wrapper/gradle-wrapper.properties b/apps/expo-go/android/versioned-react-native/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 608bd94305f244..00000000000000 --- a/apps/expo-go/android/versioned-react-native/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,5 +0,0 @@ -distributionBase=GRADLE_USER_HOME -distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.1-all.zip -zipStoreBase=GRADLE_USER_HOME -zipStorePath=wrapper/dists diff --git a/apps/expo-go/android/versioned-react-native/gradlew b/apps/expo-go/android/versioned-react-native/gradlew deleted file mode 100755 index dd7ff23d97d0bb..00000000000000 --- a/apps/expo-go/android/versioned-react-native/gradlew +++ /dev/null @@ -1,240 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015-2021 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», -# «${var#prefix}», «${var%suffix}», and «$( cmd )»; -# * compound commands having a testable exit status, especially «case»; -# * various built-in commands including «command», «set», and «ulimit». -# -# Important for patching: -# -# (2) This script targets any POSIX shell, so it avoids extensions provided -# by Bash, Ksh, etc; in particular arrays are avoided. -# -# The "traditional" practice of packing multiple parameters into a -# space-separated string is a well documented source of bugs and security -# problems, so this is (mostly) avoided, by progressively accumulating -# options in "$@", and eventually passing that to Java. -# -# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, -# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; -# see the in-line comments for details. -# -# There are tweaks for specific operating systems such as AIX, CygWin, -# Darwin, MinGW, and NonStop. -# -# (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt -# within the Gradle project. -# -# You can find Gradle at https://github.com/gradle/gradle/. -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" -APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH="\\\"\\\"" - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't mess with options #( - /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # args, so each arg winds up back in the position where it started, but - # possibly modified. - # - # NB: a `for` loop captures its iteration list before it begins, so - # changing the positional parameters here affects neither the number of - # iterations, nor the values presented in `arg`. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ - "$@" - -# Stop when "xargs" is not available. -if ! command -v xargs >/dev/null 2>&1 -then - die "xargs is not available" -fi - -# Use "xargs" to parse quoted args. -# -# With -n1 it outputs one arg per line, with the quotes and backslashes removed. -# -# In Bash we could simply go: -# -# readarray ARGS < <( xargs -n1 <<<"$var" ) && -# set -- "${ARGS[@]}" "$@" -# -# but POSIX shell has neither arrays nor command substitution, so instead we -# post-process each arg (as a line of input to sed) to backslash-escape any -# character that might be a shell metacharacter, then use eval to reverse -# that process (while maintaining the separation between arguments), and wrap -# the whole thing up as a single "set" statement. -# -# This will of course break if any of these variables contains a newline or -# an unmatched quote. -# - -eval "set -- $( - printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | - xargs -n1 | - sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | - tr '\n' ' ' - )" '"$@"' - -exec "$JAVACMD" "$@" diff --git a/apps/expo-go/android/versioned-react-native/settings.gradle b/apps/expo-go/android/versioned-react-native/settings.gradle deleted file mode 100755 index 16c4ef969a2f04..00000000000000 --- a/apps/expo-go/android/versioned-react-native/settings.gradle +++ /dev/null @@ -1,13 +0,0 @@ -includeBuild('../../react-native-lab/react-native/packages/react-native-gradle-plugin/') -include ':packages:react-native:ReactAndroid' -project(':packages:react-native:ReactAndroid').projectDir = new File(rootDir, 'packages/react-native/ReactAndroid') -include ':packages:react-native:ReactAndroid:hermes-engine' -project(':packages:react-native:ReactAndroid:hermes-engine').projectDir = new File(rootDir, 'packages/react-native/ReactAndroid/hermes-engine') - -dependencyResolutionManagement { - versionCatalogs { - libs { - from(files("./packages/react-native/gradle/libs.versions.toml")) - } - } -} diff --git a/apps/expo-go/android/versioning_linking.gradle b/apps/expo-go/android/versioning_linking.gradle deleted file mode 100644 index a68bee424d2fce..00000000000000 --- a/apps/expo-go/android/versioning_linking.gradle +++ /dev/null @@ -1,34 +0,0 @@ -import java.nio.file.Paths - - -def normalizeProjectName(String projectRoot) { - return projectRoot.replace('/', '_') -} - -/** - * Link vendored modules into settings.gradle - */ -ext.useVendoredModulesForSettingsGradle = { String sdkVersion -> - def vendoredRoot = Paths.get(rootDir.toString(), 'vendored', sdkVersion).toString() - fileTree(dir: vendoredRoot, include: '**/build.gradle').each { gradleFile -> - def projectRoot = rootDir.toPath().relativize(gradleFile.toPath()).getParent().getParent().toString() - def projectName = normalizeProjectName(projectRoot) - - include(":${projectName}") - project(":${projectName}").projectDir = gradleFile.getParentFile() - } -} - - -/** - * Link vendored modules into settings.gradle - */ -ext.useVendoredModulesForExpoView = { String sdkVersion -> - def vendoredRoot = Paths.get(rootDir.toString(), 'vendored', sdkVersion).toString() - fileTree(dir: vendoredRoot, include: '**/build.gradle').each { gradleFile -> - def projectRoot = rootDir.toPath().relativize(gradleFile.toPath()).getParent().getParent().toString() - def projectName = normalizeProjectName(projectRoot) - - dependencies.add('implementation', project(":${projectName}")) - } -} diff --git a/apps/native-component-list/oxlint.config.mjs b/apps/native-component-list/oxlint.config.mjs index 4a0fea02e3a3db..6356dbd1f33548 100644 --- a/apps/native-component-list/oxlint.config.mjs +++ b/apps/native-component-list/oxlint.config.mjs @@ -1,2 +1,11 @@ -// @generated by expo-module-scripts -export { default } from 'expo-module-scripts/oxlint.config.base'; +import base from 'expo-module-scripts/oxlint.config.base'; +import { defineConfig } from 'oxlint'; + +export default defineConfig({ + extends: [base], + ignorePatterns: base.ignorePatterns, + rules: { + // Demo screens intentionally render impure values (`Date.now()`, `Math.random()`). + 'react/purity': 'off', + }, +}); diff --git a/apps/native-component-list/src/screens/AsyncStorageScreen.tsx b/apps/native-component-list/src/screens/AsyncStorageScreen.tsx index af4b2b5e7e2443..6e72c84ff536fc 100644 --- a/apps/native-component-list/src/screens/AsyncStorageScreen.tsx +++ b/apps/native-component-list/src/screens/AsyncStorageScreen.tsx @@ -11,10 +11,6 @@ const key = 'random_value'; function PersistExample() { const [storedNumber, setStoredNumber] = React.useState('0'); - React.useEffect(() => { - getItem(); - }, []); - const getItem = React.useCallback(async () => { const value = await AsyncStorage.getItem(key); if (value) { @@ -24,6 +20,10 @@ function PersistExample() { } }, [setStoredNumber]); + React.useEffect(() => { + getItem(); + }, []); + const increment = React.useCallback(async () => { const newNumber = +storedNumber > 0 ? +storedNumber + 10 : 10; diff --git a/apps/native-component-list/src/screens/Image/ImageResizableScreen.tsx b/apps/native-component-list/src/screens/Image/ImageResizableScreen.tsx index de2bdd8407fb74..b95e5ee01c6a02 100644 --- a/apps/native-component-list/src/screens/Image/ImageResizableScreen.tsx +++ b/apps/native-component-list/src/screens/Image/ImageResizableScreen.tsx @@ -27,6 +27,8 @@ const PADDING = 20; const HANDLE_SIZE = 25; const HANDLE_SLOP = 10; const WINDOW_DIMENSIONS = Dimensions.get('window'); + +const AnimatedTextInput = Animated.createAnimatedComponent(TextInput); const MAX_WIDTH = WINDOW_DIMENSIONS.width - 2 * PADDING; const MAX_HEIGHT = WINDOW_DIMENSIONS.height - 330; @@ -68,8 +70,6 @@ const ResizableView: React.FC = ({ children }) => { // Here we use any because the text prop is not available in the type } as any; }); - const AnimatedTextInput = Animated.createAnimatedComponent(TextInput); - return ( { if (sectionsMenuExpanded === false) { @@ -408,6 +409,27 @@ export default function DropdownMenuScreen() { +
+ + setRoundedMenuExpanded(false)} + cornerRadius={16}> + + + + + setRoundedMenuExpanded(false)}> + + Item 1 + + + + + +
); } diff --git a/apps/router-e2e/__e2e__/native-navigation/app/header-items.tsx b/apps/router-e2e/__e2e__/native-navigation/app/header-items.tsx index f8d9fc3b281e04..e13b81876c6d05 100644 --- a/apps/router-e2e/__e2e__/native-navigation/app/header-items.tsx +++ b/apps/router-e2e/__e2e__/native-navigation/app/header-items.tsx @@ -15,6 +15,28 @@ import { import { searchIcon, sendIcon, deleteIcon } from './icons'; +function CustomHeaderElement() { + return ( + Alert.alert('Custom Element', 'Custom header element pressed!')} + style={styles.customHeaderElement}> + + + ); +} + export default function HeaderItemsScreen() { const params = useLocalSearchParams(); @@ -97,28 +119,6 @@ export default function HeaderItemsScreen() { Alert.alert('Color Selected', `You selected ${color}`); }; - function CustomHeaderElement() { - return ( - Alert.alert('Custom Element', 'Custom header element pressed!')} - style={styles.customHeaderElement}> - - - ); - } - return ( <> diff --git a/apps/router-e2e/__e2e__/native-navigation/components/faces.tsx b/apps/router-e2e/__e2e__/native-navigation/components/faces.tsx index 75aa22dc34680c..ce60f158c6f992 100644 --- a/apps/router-e2e/__e2e__/native-navigation/components/faces.tsx +++ b/apps/router-e2e/__e2e__/native-navigation/components/faces.tsx @@ -49,7 +49,7 @@ const getColors = () => export function useFaceColors() { const scheme = useColorScheme(); - const colors = useMemo(getColors, [scheme]); + const colors = useMemo(() => getColors(), [scheme]); return colors; } diff --git a/apps/router-e2e/__e2e__/native-tabs/components/faces.tsx b/apps/router-e2e/__e2e__/native-tabs/components/faces.tsx index 3db97b6a5d5962..a4787829e8fc92 100644 --- a/apps/router-e2e/__e2e__/native-tabs/components/faces.tsx +++ b/apps/router-e2e/__e2e__/native-tabs/components/faces.tsx @@ -49,7 +49,7 @@ const getColors = () => export function useFaceColors() { const scheme = useColorScheme(); - const colors = useMemo(getColors, [scheme]); + const colors = useMemo(() => getColors(), [scheme]); return colors; } diff --git a/apps/router-e2e/oxlint.config.mjs b/apps/router-e2e/oxlint.config.mjs index 4a0fea02e3a3db..450112e3aeb872 100644 --- a/apps/router-e2e/oxlint.config.mjs +++ b/apps/router-e2e/oxlint.config.mjs @@ -1,2 +1,11 @@ -// @generated by expo-module-scripts -export { default } from 'expo-module-scripts/oxlint.config.base'; +import base from 'expo-module-scripts/oxlint.config.base'; +import { defineConfig } from 'oxlint'; + +export default defineConfig({ + extends: [base], + ignorePatterns: base.ignorePatterns, + rules: { + // Test fixtures intentionally render impure values (`Date.now()`, `Math.random()`). + 'react/purity': 'off', + }, +}); diff --git a/docs/constants/navigation.js b/docs/constants/navigation.js index 9cb77f33f3b6f7..5028bcce49eee5 100644 --- a/docs/constants/navigation.js +++ b/docs/constants/navigation.js @@ -754,6 +754,7 @@ const preview = [ makePage('preview/eas-simulator/get-started.mdx'), makePage('preview/eas-simulator/run-and-control.mdx'), makePage('preview/eas-simulator/cli-reference.mdx'), + makePage('preview/eas-simulator/rest-api.mdx'), makePage('preview/eas-simulator/troubleshooting.mdx'), ], { expanded: true } diff --git a/docs/pages/preview/eas-simulator/cli-reference.mdx b/docs/pages/preview/eas-simulator/cli-reference.mdx index 23bd896798be37..a5ec56d5397441 100644 --- a/docs/pages/preview/eas-simulator/cli-reference.mdx +++ b/docs/pages/preview/eas-simulator/cli-reference.mdx @@ -15,6 +15,8 @@ The `--help` flag displays the available options for `simulator:start`. The start command is also available as `eas simulator`, `eas sim`, and `eas sim:start`. The other commands have matching `eas sim:*` aliases, such as `eas sim:list` and `eas sim:stop`. +To manage sessions without EAS CLI, use the [REST API](/preview/eas-simulator/rest-api/). + ## Commands | Command | Purpose | diff --git a/docs/pages/preview/eas-simulator/get-started.mdx b/docs/pages/preview/eas-simulator/get-started.mdx index 7c7735af427354..88a5c4bcbe9f25 100644 --- a/docs/pages/preview/eas-simulator/get-started.mdx +++ b/docs/pages/preview/eas-simulator/get-started.mdx @@ -36,6 +36,8 @@ If you use an AI agent, install [Expo Skills](/skills/) and ask it to use the EA ## Start a session manually +The steps below use EAS CLI. To manage sessions directly from your own HTTP client, see the [REST API reference](/preview/eas-simulator/rest-api/). + ### Authenticate and verify the project diff --git a/docs/pages/preview/eas-simulator/introduction.mdx b/docs/pages/preview/eas-simulator/introduction.mdx index 6b232e9fe560c7..7655eaaae569d0 100644 --- a/docs/pages/preview/eas-simulator/introduction.mdx +++ b/docs/pages/preview/eas-simulator/introduction.mdx @@ -1,7 +1,7 @@ --- title: Introduction to EAS Simulator sidebar_title: Introduction -description: Run and control remote iOS Simulators and Android Emulators on EAS infrastructure from the CLI, an AI agent, or a browser. +description: Run and control remote iOS Simulators and Android Emulators on EAS infrastructure from the CLI, REST API, an AI agent, or a browser. --- import { Cloud01Icon } from '@expo/styleguide-icons/outline/Cloud01Icon'; @@ -49,12 +49,12 @@ A common setup is a [Cursor cloud agent](https://cursor.com/cloud) or another ba An EAS Simulator workflow has four stages: -1. **Start a session** with EAS CLI. Give the session a descriptive name, choose a remote device and session type, and optionally set automatic duration or idle limits. +1. **Start a session** with EAS CLI or the [REST API](/preview/eas-simulator/rest-api/). Give the session a descriptive name, choose a remote device and session type, and optionally set automatic duration or idle limits. 2. **Install your app.** Pass an EAS Build, application archive URL, or Expo Go to the start command, or install a local build through the controller after the session starts. A session without an application source starts with a blank device. 3. **Drive the device.** Use [agent-device](/agents/agent-device/), [Argent](/agents/argent/), or [Appium](https://appium.io/docs/en/) for programmatic control. Supported iOS sessions also include a web preview. 4. **Stop the session.** Stop it explicitly when you finish. An unattended non-interactive session continues consuming usage until it stops or reaches a configured limit. -EAS CLI manages the session lifecycle and connection configuration. The controller provides device actions such as opening an app, pressing a button, entering text, inspecting the accessibility tree, and capturing a screenshot. +EAS CLI manages the session lifecycle and connection configuration. Use the REST API to manage sessions from your own HTTP client. The controller provides device actions such as opening an app, pressing a button, entering text, inspecting the accessibility tree, and capturing a screenshot. ## Available surfaces @@ -95,6 +95,13 @@ Use [EAS Build](/build/introduction/) to create installable builds, and use loca Icon={Cloud01Icon} /> + + **important** **EAS Simulator is a limited-access preview.** It is not included with paid or free plans and is currently available only to select partners. [Join the waitlist](https://expo.dev/services/simulators) if you are interested in trying it. + +The EAS Simulator REST API lets you manage remote device sessions from CI, an agent, or another HTTP client without running EAS CLI. All endpoints live under `https://api.expo.dev`. Requests and responses are JSON, and successful responses wrap the result in `data`. + +Create a session, poll it until connection details are available, and stop it when you finish. The REST API manages the session lifecycle. Use the selected controller for device actions such as installing an app, pressing a button, or taking a screenshot. + +## Authentication + +All endpoints require an [Expo access token](/accounts/programmatic-access/), sent as a bearer token in the `Authorization` header. The account that owns the project must have EAS Simulator access. + +For production integrations, create a [robot user](/accounts/programmatic-access/#robot-users-and-access-tokens) on the account that owns the project and assign it the [Developer role](/accounts/account-types/#manage-access), the minimum role that can create and stop sessions. A [personal access token](/accounts/programmatic-access/#personal-access-tokens) also works for scripts. + +```text +Authorization: Bearer +Content-Type: application/json +``` + +The examples below read the token from the `EXPO_TOKEN` environment variable. Keep this token in your CI secret store or server environment, not in client code. + +> **warning** Session responses can include controller credentials. Treat connection details as secrets: do not commit them, include them in logs, or expose them in a public response. Successful responses carry `Cache-Control: private, no-store` and `Pragma: no-cache`. Do not cache session responses. + +## Create a session + +```text +POST /v2/device-run-sessions +``` + +Creates a session and queues the remote device. The request returns before the device and controller are ready. + +### Request body + +**Choose at most one application source:** `buildId`, `applicationArchiveUrl`, or `expoGo: true`. Providing more than one returns `400`. You can omit all three to start with a blank device. + +UUID means universally unique identifier. + +| Field | Type | Required | Description | +| ----------------------- | ------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `appId` | string (UUID) | Yes | The EAS project ID. Find it on the project page or in [app config](/workflow/configuration/) under `extra.eas.projectId`. | +| `platform` | string | Yes | `android` or `ios`. | +| `name` | string | No | A descriptive session name, up to 255 characters. Must not be blank. | +| `type` | string | No | `agent-device`, `appium`, `argent`, or `serve-sim`. Defaults to `agent-device`. | +| `packageVersion` | string | No | Version of the package for the selected session type. Omit to use the service default. | +| `deviceIdentifier` | string | No | iOS Simulator name or unique device identifier (UDID), or Android virtual device hardware profile. The runner chooses a device when omitted. | +| `maxDurationMinutes` | integer | No | Automatic session stop time in minutes. Must be between 0 and 115 inclusive, and within the account's duration limit. The 115-minute limit excludes five minutes reserved for cleanup. Omit to use the account default. | +| `maxIdleTimeMinutes` | integer | No | Stop after this many minutes without session activity. Must be positive and less than 115. It must be smaller than `maxDurationMinutes` when both are set. Omit to disable the idle timeout. Not supported for `serve-sim`. | +| `buildId` | string (UUID) | No | An EAS Build to download, install, and launch. Use an Android **.apk** or an iOS Simulator build. | +| `applicationArchiveUrl` | string | No | An HTTP or HTTPS URL for an installable application archive, up to 2,048 characters. | +| `expoGo` | boolean | No | Set to `true` to install and launch Expo Go. | +| `sdkVersion` | string | No | Expo SDK version used to select Expo Go, such as `"57"`. Only valid with `expoGo: true`. Omit to use the current Expo Go release for the platform. | +| `launchArgs` | string[] | No | Arguments passed to the installed app when it launches. Requires an application source. | +| `openUrl` | string | No | A URL to open after launching the app, such as an Expo or development-client URL. Requires an application source. | + +The application source options install and launch the app for `agent-device`, `argent`, and `serve-sim` sessions. For `appium`, install and launch the app through your Appium client. + +The REST API uses `serve-sim` for the session type that EAS CLI calls `web-preview-only`. It is only available on iOS and does not support an idle timeout. Other session types provide a controller and include a web preview on supported iOS sessions. + +### Example + +Replace `appId` and `buildId` with your EAS project ID and a compatible build ID: + + + +### Response + +Returns `200 OK` with the new session's metadata: + +```json +{ + "data": { + "id": "019d9d17-013a-7e05-89aa-4aa83ff68c32", + "appId": "a415eac6-231a-4b38-b481-3255a59f13b8", + "url": "https://expo.dev/accounts/acme/projects/example/simulator-sessions/019d9d17-013a-7e05-89aa-4aa83ff68c32", + "jobRunId": "019d9d17-1a3f-7c10-bdee-5f2811a9d6ad", + "name": "Checkout flow screenshots", + "type": "agent-device", + "packageVersion": null, + "buildId": "f9609423-5072-4ea2-a0a5-c345eedf2c2a", + "applicationArchiveUrl": null, + "platform": "ios", + "status": "new", + "remoteConfig": null, + "maxDurationMinutes": 30, + "maxIdleTimeMinutes": 5, + "startedAt": null, + "finishedAt": null, + "createdAt": "2026-08-28T10:00:00.000Z", + "updatedAt": "2026-08-28T10:00:00.000Z" + } +} +``` + +Save `data.id` and set it as `SESSION_ID` for the remaining examples. `url` links to the session page on expo.dev. It is not the live browser preview URL. + +`buildId` contains the supplied build ID. `applicationArchiveUrl` contains the supplied archive URL or the resolved Expo Go archive URL. It is `null` when using `buildId`. Optional values such as `name`, `packageVersion`, and `maxIdleTimeMinutes` are `null` when omitted. + +The response's `maxDurationMinutes` matches the requested session duration. A request for 30 minutes returns `30`. + +`remoteConfig` is `null` while the session is starting. Poll the [get endpoint](#get-a-session) for status and connection details together. + +## Get a session + +```text +GET /v2/device-run-sessions/:deviceRunSessionId +``` + +Returns `200 OK` with the same session object as the create endpoint, including its current status, timestamps, and `remoteConfig`. Replace `:deviceRunSessionId` with the session ID. No separate connection request is needed. + +### Example + + + +### Response + +An agent-device session that is ready returns connection details alongside its metadata: + +```json +{ + "data": { + "id": "019d9d17-013a-7e05-89aa-4aa83ff68c32", + "appId": "a415eac6-231a-4b38-b481-3255a59f13b8", + "url": "https://expo.dev/accounts/acme/projects/example/simulator-sessions/019d9d17-013a-7e05-89aa-4aa83ff68c32", + "jobRunId": "019d9d17-1a3f-7c10-bdee-5f2811a9d6ad", + "name": "Checkout flow screenshots", + "type": "agent-device", + "packageVersion": null, + "buildId": "f9609423-5072-4ea2-a0a5-c345eedf2c2a", + "applicationArchiveUrl": null, + "platform": "ios", + "status": "in-progress", + "remoteConfig": { + "agentDeviceRemoteSessionUrl": "https://controller.example.com", + "agentDeviceRemoteSessionToken": "", + "webPreviewUrl": "https://preview.example.com" + }, + "maxDurationMinutes": 30, + "maxIdleTimeMinutes": 5, + "startedAt": "2026-08-28T10:01:00.000Z", + "finishedAt": null, + "createdAt": "2026-08-28T10:00:00.000Z", + "updatedAt": "2026-08-28T10:01:00.000Z" + } +} +``` + +### Session status + +The REST API returns lowercase status values: + +| Value | Meaning | +| ------------- | --------------------------------------------------------------------------------------- | +| `new` | The session is queued or preparing the device and controller. `remoteConfig` is `null`. | +| `in-progress` | The session is running. Connect when `remoteConfig` is not `null`. | +| `stopped` | The session has stopped. Terminal. `remoteConfig` is `null`. | +| `errored` | The session ended with an error. Terminal. `remoteConfig` is `null`. | + +Poll this endpoint every five seconds until `status` is `in-progress` and `remoteConfig` is not `null`, then connect using that configuration. Set a timeout in your integration. If the status becomes `stopped` or `errored`, stop polling and inspect the session page at `url`. + +### Connection details + +The fields in `remoteConfig` depend on the session type. Connection details are only returned while the session is `in-progress`. After it stops or errors, the session response returns `remoteConfig: null` even if the session previously had connection details. + +| Session type | Connection fields | +| -------------- | -------------------------------------------------------------------------------------------- | +| `agent-device` | `agentDeviceRemoteSessionUrl` and `agentDeviceRemoteSessionToken`. Optional `webPreviewUrl`. | +| `argent` | `toolsUrl`, optional `toolsAuthToken`, and optional `webPreviewUrl`. | +| `appium` | `appiumUrl`, a `capabilities` object, and optional `webPreviewUrl`. | +| `serve-sim` | `previewUrl`. This session type has no controller. | + +For agent-device, use the URL and token as `AGENT_DEVICE_DAEMON_BASE_URL` and `AGENT_DEVICE_DAEMON_AUTH_TOKEN`. For Argent, use `toolsUrl` and `toolsAuthToken` as `ARGENT_TOOLS_URL` and `ARGENT_AUTH_TOKEN`. For Appium, configure your client with `appiumUrl` and `capabilities`. See [Run and control an app](/preview/eas-simulator/run-and-control/) for controller usage. + +Open `webPreviewUrl` (or `previewUrl` for `serve-sim`) in a desktop browser to view supported iOS sessions. Do not open it on the remote device. Android sessions do not currently provide a browser preview. + +## Stop a session + +```text +POST /v2/device-run-sessions/:deviceRunSessionId/stop +``` + +Stops a session. This endpoint does not require a request body. + +### Example + + + +### Response + +Returns `200 OK` with the session object. An active session transitions to `stopped`, includes `finishedAt`, and returns `remoteConfig: null`. The operation is idempotent: if the session is already `stopped` or `errored`, it returns that session without changing its status or finish time. Connection details remain `null`. + +Always stop the session when your work finishes, including on errors or polling timeouts. Returning from an HTTP request does not stop the remote device. Use a cleanup handler in your integration and set `maxDurationMinutes` to limit unattended usage. + +## Errors + +| Status | Reason | +| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 400 | The request fails validation. Examples include an invalid UUID, incompatible application sources, unsupported session options, or a duration outside the account's limit. Session creation also fails if EAS Simulator is not enabled for the account. | +| 401 | The bearer token is invalid. A request with no `Authorization` header returns `400` with `UNAUTHORIZED_ERROR` instead. | +| 403 | The token does not have permission to read the project or perform the requested action. | +| 404 | No project or session exists with the given ID. | + +Use the returned error message to identify which option or permission needs attention. For session startup failures, inspect the session page linked by `url`. + +## Next step + + diff --git a/docs/pages/push-notifications/sending-notifications.mdx b/docs/pages/push-notifications/sending-notifications.mdx index ab183d7d498490..59ba80ac2a35d8 100644 --- a/docs/pages/push-notifications/sending-notifications.mdx +++ b/docs/pages/push-notifications/sending-notifications.mdx @@ -292,6 +292,8 @@ Each message must be a JSON object with the given fields (only the `to` field is **Note on `priority`**: On Android, normal-priority messages won't open network connections on sleeping devices and their delivery may be delayed to conserve the battery. High-priority messages are more likely to be delivered immediately and may wake sleeping devices to open network connections, consuming energy. On iOS, normal-priority messages are sent at a time that takes into account power considerations for the device and may be grouped and delivered in bursts. They are throttled and may not be delivered by Apple. High-priority messages are usually sent immediately. Normal priority corresponds to APNs priority level 5 and high priority to 10. +**Note on `sound` and `badge`**: On iOS, the system plays the sound only if the user allows sounds, and sets the badge only if the user allows badges. [`getPermissionsAsync()`](/versions/latest/sdk/notifications/#getpermissionsasync) reports these two settings as `ios.allowsSound` and `ios.allowsBadge`. The top-level `status` stays `granted` when either one is off. Check the two `ios` fields when a notification arrives without a sound or without a badge. The user chooses both settings in the first permission prompt and can change them later in the Settings app. + **Note on `channelId`**: If left null, a "Default" channel is used, and Expo creates the channel on the device if it does not yet exist. However, use caution, as the "Default" channel is user-facing and you may not be able to fully delete it. ### Push ticket format diff --git a/docs/pages/versions/unversioned/sdk/ui/jetpack-compose/dropdownmenu.mdx b/docs/pages/versions/unversioned/sdk/ui/jetpack-compose/dropdownmenu.mdx index 489c1af6d10b5e..ec2d547e35680b 100644 --- a/docs/pages/versions/unversioned/sdk/ui/jetpack-compose/dropdownmenu.mdx +++ b/docs/pages/versions/unversioned/sdk/ui/jetpack-compose/dropdownmenu.mdx @@ -211,6 +211,52 @@ export default function LongPressDropdownMenuExample() { +### Rounded corners + +Use `cornerRadius` to override the default Material3 menu shape with a custom corner radius (in dp). + + + +```tsx +import { + Host, + DropdownMenu, + DropdownMenuItem, + OutlinedButton, + Text, +} from '@expo/ui/jetpack-compose'; +import { useState } from 'react'; + +export default function RoundedDropdownMenuExample() { + const [isExpanded, setIsExpanded] = useState(false); + return ( + + setIsExpanded(false)} + cornerRadius={16}> + + setIsExpanded(true)}> + Show menu + + + + setIsExpanded(false)}> + + Item 1 + + + + + + ); +} +``` + + + ## API ```tsx diff --git a/docs/pages/versions/v57.0.0/sdk/ui/jetpack-compose/dropdownmenu.mdx b/docs/pages/versions/v57.0.0/sdk/ui/jetpack-compose/dropdownmenu.mdx index ef808a39ca91cb..5cc6cae0d2545b 100644 --- a/docs/pages/versions/v57.0.0/sdk/ui/jetpack-compose/dropdownmenu.mdx +++ b/docs/pages/versions/v57.0.0/sdk/ui/jetpack-compose/dropdownmenu.mdx @@ -211,6 +211,52 @@ export default function LongPressDropdownMenuExample() { +### Rounded corners + +Use `cornerRadius` to override the default Material3 menu shape with a custom corner radius (in dp). + + + +```tsx +import { + Host, + DropdownMenu, + DropdownMenuItem, + OutlinedButton, + Text, +} from '@expo/ui/jetpack-compose'; +import { useState } from 'react'; + +export default function RoundedDropdownMenuExample() { + const [isExpanded, setIsExpanded] = useState(false); + return ( + + setIsExpanded(false)} + cornerRadius={16}> + + setIsExpanded(true)}> + Show menu + + + + setIsExpanded(false)}> + + Item 1 + + + + + + ); +} +``` + + + ## API ```tsx diff --git a/guides/releasing/Release Workflow.md b/guides/releasing/Release Workflow.md index b44a3b99808bc9..99c8b72a68ec20 100644 --- a/guides/releasing/Release Workflow.md +++ b/guides/releasing/Release Workflow.md @@ -227,7 +227,7 @@ Web is comparatively well-tested in CI, so a few manual smoke tests suffice for **How:** - Run `et eas ios-simulator-build` to trigger building Expo Go for iOS simulators on EAS. -- Run `GITHUB_TOKEN=${GITHUB_TOKEN} et eas ios-simulator-upload` to download the build artifact and upload it to [the expo-go-releases GitHub repo](https://github.com/expo/expo-go-releases/releases). The `GITHUB_TOKEN` environment variable should have contents read/write access to the repo. +- Run `GITHUB_TOKEN=${GITHUB_TOKEN} et eas ios-simulator-upload` to download the simulator build artifact and the device IPA (built by `ios-client-build-and-submit`) and upload both to [the expo-go-releases GitHub repo](https://github.com/expo/expo-go-releases/releases). The `GITHUB_TOKEN` environment variable should have contents read/write access to the repo. - Run `et eas android-apk-build` to trigger building Expo Go for Android APK on EAS. - Run `GITHUB_TOKEN=${GITHUB_TOKEN} et eas android-apk-upload` to download the build artifact and upload it to [the expo-go-releases GitHub repo](https://github.com/expo/expo-go-releases/releases). The `GITHUB_TOKEN` environment variable should have contents read/write access to the repo. - Once the job is finished, test if this simulator build work as expected. You can install and launch it using expotools command `et client-install -p {ios,android}`. diff --git a/packages/@expo/cli/CHANGELOG.md b/packages/@expo/cli/CHANGELOG.md index d55856378243cb..7fbc529cea6f09 100644 --- a/packages/@expo/cli/CHANGELOG.md +++ b/packages/@expo/cli/CHANGELOG.md @@ -38,6 +38,7 @@ - Support npm@12's dictionary-based `npm pack --json` format ([#48761](https://github.com/expo/expo/pull/48761) by [@kitten](https://github.com/kitten)) - Fix wirelessly connected iOS 16 and older devices being omitted from `expo run:ios --device` selection. ([#48127](https://github.com/expo/expo/pull/48127) by [@davellanedam](https://github.com/davellanedam)) - Fix resolution of ESLint failing in `expo lint` after prerequisites check installs it ([#46223](https://github.com/expo/expo/pull/46223) by [@claritystorm](https://github.com/claritystorm)) +- Make Android tooling (avd and adb) handling safer, more reliable, and cancellable ([#49258](https://github.com/expo/expo/pull/49258) by [@kitten](https://github.com/kitten)) - Serve one virtual Metro asset registry to every consumer on React Native 0.87 — Metro's generated asset modules, `react-native/asset-registry` and legacy `@react-native/assets-registry` imports, and React Native core's internal registry imports — so Metro-registered assets and React Native's `` share one registry instance. ([#49444](https://github.com/expo/expo/pull/49444) by [@gabrieldonadel](https://github.com/gabrieldonadel)) - Depend on `@react-native/js-polyfills` directly for the web polyfills instead of the `react-native/rn-get-polyfills` subpath removed in React Native 0.87. ([#47729](https://github.com/expo/expo/pull/47729) by [@gabrieldonadel](https://github.com/gabrieldonadel)) diff --git a/packages/@expo/cli/src/start/platforms/android/ADBServer.ts b/packages/@expo/cli/src/start/platforms/android/ADBServer.ts index c8ab75f2893c5b..83e6f130a087f6 100644 --- a/packages/@expo/cli/src/start/platforms/android/ADBServer.ts +++ b/packages/@expo/cli/src/start/platforms/android/ADBServer.ts @@ -1,23 +1,22 @@ -import spawnAsync from '@expo/spawn-async'; -import { execFileSync } from 'child_process'; - import { Log } from '../../../log'; import { env } from '../../../utils/env'; import { AbortCommandError, CommandError } from '../../../utils/errors'; -import { installExitHooks } from '../../../utils/exit'; import { event } from '../events'; import { assertSdkRoot } from './AndroidSdk'; +import { + AdbProcessWaitError, + runAdbDeviceMutationAsync, + runAdbHostQueryAsync, + runBoundedAdbDeviceQueryAsync, + runBoundedAdbDeviceMutationAsync, + runBoundedAdbHostQueryAsync, +} from './adbProcess'; const BEGINNING_OF_ADB_ERROR_MESSAGE = 'error: '; - -// This is a tricky class since it controls a system state (side-effects). -// A more ideal solution would be to implement ADB in JS. -// The main reason this is a class is to control the flow of testing. +const PROPERTY_QUERY_WAIT_LIMIT_MS = 10_000; +const DEVICE_QUERY_WAIT_LIMIT_MS = 10_000; export class ADBServer { - isRunning: boolean = false; - removeExitHook: () => void = () => {}; - /** Returns the command line reference to ADB. */ getAdbExecutablePath(): string { try { @@ -33,69 +32,78 @@ export class ADBServer { return 'adb'; } - /** Start the ADB server. */ - async startAsync(): Promise { - if (this.isRunning) { - return false; - } - // clean up - this.removeExitHook = installExitHooks(() => { - if (this.isRunning) { - this.stopAsync(); - } - }); + /** Execute an ADB command with given args. */ + async runDeviceQueryAsync( + args: string[], + operation: string, + signal?: AbortSignal, + waitLimitMs: number = DEVICE_QUERY_WAIT_LIMIT_MS + ): Promise { const adb = this.getAdbExecutablePath(); - const result = await this.resolveAdbPromise(spawnAsync(adb, ['start-server'])); - const lines = result.stderr.trim().split(/\r?\n/); - const isStarted = lines.includes('* daemon started successfully'); - this.isRunning = isStarted; - return isStarted; - } - /** Kill the ADB server. */ - async stopAsync(): Promise { - if (!this.isRunning) { - return false; - } - this.removeExitHook(); - try { - await this.runAsync(['kill-server']); - return true; - } catch (error: any) { - Log.error('Failed to stop ADB server: ' + error.message); - return false; - } finally { - this.isRunning = false; - } + // NOTE(@kitten): We removed start-server/stop-server calls, + // because each command already negotiates the server automatically + + event('adb_server_run', { command: [adb, ...args].join(' ') }); + const result = await this.resolveAdbPromise( + runBoundedAdbDeviceQueryAsync(adb, args, operation, waitLimitMs, signal) + ); + assertValidAdbUserOutput(result); + return result.stdout; } - /** Execute an ADB command with given args. */ - async runAsync(args: string[]): Promise { - // TODO: Add a global package that installs adb to the path. + async runDeviceMutationAsync( + args: string[], + operation: string, + signal?: AbortSignal, + waitLimitMs?: number + ): Promise { const adb = this.getAdbExecutablePath(); - - await this.startAsync(); - event('adb_server_run', { command: [adb, ...args].join(' ') }); - const result = await this.resolveAdbPromise(spawnAsync(adb, args)); - return result.output.join('\n'); + const result = await this.resolveAdbPromise( + waitLimitMs == null + ? runAdbDeviceMutationAsync(adb, args, operation, signal) + : runBoundedAdbDeviceMutationAsync(adb, args, operation, waitLimitMs, signal) + ); + assertValidAdbUserOutput(result); + return result.stdout; + } + + async runHostQueryAsync( + args: string[], + operation: string, + signal?: AbortSignal, + waitLimitMs?: number + ): Promise { + const adb = this.getAdbExecutablePath(); + const result = await this.resolveAdbPromise( + waitLimitMs == null + ? runAdbHostQueryAsync(adb, args, operation, signal) + : runBoundedAdbHostQueryAsync(adb, args, operation, waitLimitMs, signal) + ); + return result.stdout; } /** Get ADB file output. Useful for reading device state/settings. */ - async getFileOutputAsync(args: string[]): Promise { + async getFileOutputAsync( + args: string[], + { signal, waitLimitMs }: { signal?: AbortSignal; waitLimitMs?: number } = {} + ): Promise { // TODO: Add a global package that installs adb to the path. const adb = this.getAdbExecutablePath(); - await this.startAsync(); - - const results = await this.resolveAdbPromise( - execFileSync(adb, args, { - encoding: 'latin1', - stdio: 'pipe', - }) + const result = await this.resolveAdbPromise( + runBoundedAdbDeviceQueryAsync( + adb, + args, + 'device property/boot query', + waitLimitMs ?? PROPERTY_QUERY_WAIT_LIMIT_MS, + signal + ) ); - event('adb_file_output', { output: results }); - return results; + assertValidAdbUserOutput(result); + event('adb_file_output', { output: result.stdout }); + return result.stdout; } /** Formats error info. */ @@ -103,12 +111,16 @@ export class ADBServer { try { return await promise; } catch (error: any) { + if (error instanceof AdbProcessWaitError) { + throw error; + } // User pressed ctrl+c to cancel the process... if (error.signal === 'SIGINT') { throw new AbortCommandError(); } - if (error.status === 255 && error.stdout.includes('Bad user number')) { - const userNumber = error.stdout.match(/Bad user number: (.+)/)?.[1] ?? env.EXPO_ADB_USER; + const processOutput = [error.stdout, error.stderr].filter(Boolean).join('\n'); + if (error.status === 255 && processOutput.includes('Bad user number')) { + const userNumber = processOutput.match(/Bad user number: (.+)/)?.[1] ?? env.EXPO_ADB_USER; throw new CommandError( 'EXPO_ADB_USER', `Invalid ADB user number "${userNumber}" set with environment variable EXPO_ADB_USER. Run "adb shell pm list users" to see valid user numbers.` @@ -125,3 +137,14 @@ export class ADBServer { } } } + +function assertValidAdbUserOutput(result: { stdout: string; stderr: string }): void { + if (!env.EXPO_ADB_USER) return; + const output = [result.stdout, result.stderr].filter(Boolean).join('\n'); + if (/Bad user number|(?:user .* does not exist|no user with id)/i.test(output)) { + throw new CommandError( + 'EXPO_ADB_USER', + `Invalid ADB user number "${env.EXPO_ADB_USER}" set with environment variable EXPO_ADB_USER. Run "adb shell pm list users" to see valid user numbers.` + ); + } +} diff --git a/packages/@expo/cli/src/start/platforms/android/AndroidDeviceManager.ts b/packages/@expo/cli/src/start/platforms/android/AndroidDeviceManager.ts index b0fbb13f8a2395..8e3ad1f99f64fd 100644 --- a/packages/@expo/cli/src/start/platforms/android/AndroidDeviceManager.ts +++ b/packages/@expo/cli/src/start/platforms/android/AndroidDeviceManager.ts @@ -3,12 +3,20 @@ import chalk from 'chalk'; import * as Log from '../../../log'; import { AbortCommandError, CommandError } from '../../../utils/errors'; +import { installExitHooks } from '../../../utils/exit'; import { validateUrl } from '../../../utils/url'; import { DeviceManager } from '../DeviceManager'; import { ExpoGoInstaller } from '../ExpoGoInstaller'; import type { BaseResolveDeviceProps } from '../PlatformManager'; import { activateWindowAsync } from './activateWindow'; import * as AndroidDebugBridge from './adb'; +import { isAdbDeviceStateUsable } from './adbDeviceList'; +import { + createAdbOperationError, + formatAdbDeviceError, + isAdbDeviceDisconnectedError, +} from './adbDiagnostics'; +import { AdbProcessError } from './adbProcess'; import { startDeviceAsync } from './emulator'; import { getDevicesAsync } from './getDevices'; import { promptForDeviceAsync } from './promptAndroidDevice'; @@ -16,12 +24,22 @@ import { promptForDeviceAsync } from './promptAndroidDevice'; const EXPO_GO_APPLICATION_IDENTIFIER = 'host.exp.exponent'; export class AndroidDeviceManager extends DeviceManager { - static async resolveFromNameAsync(name: string): Promise { + static async resolveFromNameAsync(query: string): Promise { const devices = await getDevicesAsync(); - const device = devices.find((device) => device.name === name); + const device = + devices.find((device) => device.pid === query) ?? + devices.find((device) => device.name === query); if (!device) { - throw new CommandError('Could not find device with name: ' + name); + const message = [ + `No connected Android device or emulator matched "${query}" by serial or name.`, + 'Available devices:', + ...devices.map( + (device) => ` ${device.name} (${device.pid ?? 'not attached'}, ${device.type})` + ), + 'Pass a device serial from `adb devices` or a name from the list above to --device.', + ].join('\n'); + throw new CommandError('BAD_ARGS', message); } return AndroidDeviceManager.resolveAsync({ device, shouldPrompt: false }); } @@ -53,28 +71,113 @@ export class AndroidDeviceManager extends DeviceManager { - const info = await AndroidDebugBridge.getPackageInfoAsync(this.device, { - appId: applicationId, - }); + const info = await this.runDeviceOperationAsync((signal) => + AndroidDebugBridge.getPackageInfoAsync( + this.device, + { + appId: applicationId, + }, + signal + ) + ); const regex = /versionName=([0-9.]+)/; return regex.exec(info)?.[1] ?? null; } protected async attemptToStartAsync(): Promise { - // TODO: Add a light-weight method for checking since a device could disconnect. - if (!(await AndroidDebugBridge.isDeviceBootedAsync(this.device))) { - this.device = await startDeviceAsync(this.device); + // Only detached AVD inventory entries may enter the emulator launch path + if (this.device.isLaunchable) { + const attachedDevice = await AndroidDebugBridge.isDeviceBootedAsync(this.device); + if (attachedDevice) { + this.assertDeviceStateIsUsable(attachedDevice); + this.device = attachedDevice; + } else { + this.device = await startDeviceAsync(this.device); + } + } else { + this.assertDeviceStateIsUsable(this.device); + const attachedDevice = await AndroidDebugBridge.isDeviceBootedAsync(this.device); + if (!attachedDevice) { + throw this.createDeviceStateError( + new Error(`Device not found after discovery: ${this.device.pid ?? this.device.name}.`) + ); + } + this.assertDeviceStateIsUsable(attachedDevice); + if ( + this.device.transportId && + attachedDevice.transportId && + this.device.transportId !== attachedDevice.transportId + ) { + throw this.createDeviceStateError( + new Error( + `Device ${this.device.pid ?? this.device.name} was replaced after discovery (transport ${this.device.transportId} became ${attachedDevice.transportId})` + ), + attachedDevice + ); + } + this.device = attachedDevice; } if (this.device.isAuthorized === false) { AndroidDebugBridge.logUnauthorized(this.device); - return null; + throw this.createDeviceStateError( + new Error(`Device ${this.device.pid ?? this.device.name} is unauthorized.`) + ); } return this.device; } + private assertDeviceStateIsUsable(device: AndroidDebugBridge.Device): void { + // Exclude unauthorized states, which are checked separately + if (device.state === 'unauthorized') { + return; + } + + if (device.state && !isAdbDeviceStateUsable(device.state)) { + throw this.createDeviceStateError( + new Error(`Device ${device.pid ?? device.name} is in state ${device.state}.`), + device + ); + } + } + + private createDeviceStateError( + error: Error, + device: AndroidDebugBridge.Device = this.device + ): CommandError { + return new CommandError('ADB_DEVICE_STATE', formatAdbDeviceError(error, device)); + } + + private async mapDeviceOperationError(error: unknown): Promise { + if (error instanceof CommandError) { + throw error; + } + if (isAdbDeviceDisconnectedError(error)) { + throw new CommandError('ADB_DEVICE_DISCONNECTED', formatAdbDeviceError(error, this.device)); + } + if (error instanceof AdbProcessError) { + throw createAdbOperationError('ADB_DEVICE_OPERATION', error, this.device); + } + throw error; + } + + private async runDeviceOperationAsync( + operation: (signal: AbortSignal) => Promise + ): Promise { + // NOTE(@kitten): Do not retry device commands; side effects may already have started + const controller = new AbortController(); + const removeExitHook = installExitHooks(() => controller.abort(new AbortCommandError())); + try { + return await operation(controller.signal); + } catch (error) { + return await this.mapDeviceOperationError(error); + } finally { + removeExitHook(); + } + } + async startAsync(): Promise { const device = await this.attemptToStartAsync(); assert(device, `Failed to boot emulator.`); @@ -82,9 +185,9 @@ export class AndroidDeviceManager extends DeviceManager + AndroidDebugBridge.installAsync(this.device, { filePath: binaryPath }, signal) + ); } async uninstallAppAsync(appId: string) { @@ -95,9 +198,9 @@ export class AndroidDeviceManager extends DeviceManager + AndroidDebugBridge.uninstallAsync(this.device, { appId }, signal) + ); } catch (e) { Log.error( `Could not uninstall app "${appId}" from your device, please uninstall it manually and try again.` @@ -111,11 +214,17 @@ export class AndroidDeviceManager extends DeviceManager { try { - return await AndroidDebugBridge.launchActivityAsync(this.device, { - launchActivity, - url, - }); + return await this.runDeviceOperationAsync((signal) => + AndroidDebugBridge.launchActivityAsync(this.device, { launchActivity, url }, signal) + ); } catch (error: any) { + if ( + error instanceof CommandError && + error.code.startsWith('ADB_') && + error.code !== 'ADB_DEVICE_OPERATION' + ) { + throw error; + } let errorMessage = `Couldn't open Android app with activity "${launchActivity}" on device "${this.name}".`; if (error instanceof CommandError && error.code === 'APP_NOT_INSTALLED') { errorMessage += `\nThe app might not be installed, try installing it with: ${chalk.bold( @@ -129,7 +238,9 @@ export class AndroidDeviceManager extends DeviceManager + AndroidDebugBridge.isPackageInstalledAsync(this.device, applicationId, signal) + ); } async openUrlAsync(url: string) { @@ -142,15 +253,20 @@ export class AndroidDeviceManager extends DeviceManager + AndroidDebugBridge.launchActivityAsync( + { pid: this.device.pid }, + { + launchActivity: `${EXPO_GO_APPLICATION_IDENTIFIER}/.experience.HomeActivity`, + }, + signal + ) ); } - await AndroidDebugBridge.openUrlAsync({ pid: this.device.pid }, { url }); + await this.runDeviceOperationAsync((signal) => + AndroidDebugBridge.openUrlAsync({ pid: this.device.pid }, { url }, signal) + ); } async activateWindowAsync() { diff --git a/packages/@expo/cli/src/start/platforms/android/__tests__/ADBServer-test.ts b/packages/@expo/cli/src/start/platforms/android/__tests__/ADBServer-test.ts index e25ffaae1a85c0..2a6d7853f2b529 100644 --- a/packages/@expo/cli/src/start/platforms/android/__tests__/ADBServer-test.ts +++ b/packages/@expo/cli/src/start/platforms/android/__tests__/ADBServer-test.ts @@ -1,17 +1,15 @@ import spawnAsync from '@expo/spawn-async'; -import { execFileSync } from 'child_process'; import { vol } from 'memfs'; +import path from 'node:path'; import * as Log from '../../../../log'; import { AbortCommandError } from '../../../../utils/errors'; -import { installExitHooks } from '../../../../utils/exit'; import { ADBServer } from '../ADBServer'; +import { AdbProcessWaitError } from '../adbProcess'; jest.mock('fs', () => jest.requireActual('memfs').fs); jest.mock('../../../../log'); -jest.mock('../../../../utils/exit', () => ({ - installExitHooks: jest.fn(), -})); +jest.unmock('child_process'); const env = process.env; @@ -78,89 +76,194 @@ describe('resolveAdbPromise', () => { /^Invalid ADB user number "FUNKY" set with environment variable EXPO_ADB_USER. Run "adb shell pm list users" to see valid user numbers.$/ ); }); -}); - -describe('startAsync', () => { - it(`starts the ADB server`, async () => { - jest.mocked(spawnAsync).mockResolvedValueOnce({ - stderr: '* daemon started successfully', - } as any); + it('preserves structured wait errors even when the terminated client produced output', async () => { const server = new ADBServer(); - await expect(server.startAsync()).resolves.toBe(true); - expect(server.isRunning).toBe(true); - expect(installExitHooks).toHaveBeenCalledTimes(1); - expect(spawnAsync).toHaveBeenCalledTimes(1); - }); - it(`does not start if the server is already running`, async () => { - const server = new ADBServer(); - server.isRunning = true; - await expect(server.startAsync()).resolves.toBe(false); - expect(server.isRunning).toBe(true); - expect(installExitHooks).toHaveBeenCalledTimes(0); - expect(spawnAsync).toHaveBeenCalledTimes(0); + const error = Object.assign( + new AdbProcessWaitError( + 'Expo stopped waiting for discovery.', + 'device discovery', + 'host-request' + ), + { + stdout: 'partial output', + stderr: 'diagnostic output', + signal: 'SIGTERM', + } + ); + + await expect(server.resolveAdbPromise(Promise.reject(error))).rejects.toBe(error); }); }); -describe('runAsync', () => { + +describe('runDeviceQueryAsync', () => { it(`runs an ADB command`, async () => { jest.mocked(spawnAsync).mockResolvedValueOnce({ - output: ['did thing'], - stderr: 'did thing', + stdout: 'did thing', + stderr: '', + status: 0, + signal: null, } as any); const server = new ADBServer(); - server.startAsync = jest.fn(); server.resolveAdbPromise = jest.fn(server.resolveAdbPromise); server.getAdbExecutablePath = jest.fn(() => 'adb'); - await expect(server.runAsync(['foo', 'bar'])).resolves.toBe('did thing'); + await expect(server.runDeviceQueryAsync(['foo', 'bar'], 'test query')).resolves.toBe( + 'did thing' + ); expect(server.getAdbExecutablePath).toHaveBeenCalledTimes(1); - expect(server.startAsync).toHaveBeenCalledTimes(1); expect(server.resolveAdbPromise).toHaveBeenCalledTimes(1); expect(spawnAsync).toHaveBeenCalledTimes(1); expect(spawnAsync).toHaveBeenCalledWith('adb', ['foo', 'bar']); }); + + it('keeps diagnostic stderr out of parsed query output', async () => { + jest.mocked(spawnAsync).mockResolvedValueOnce({ + stdout: 'machine-readable output', + stderr: 'diagnostic output', + status: 0, + signal: null, + } as any); + const server = new ADBServer(); + server.getAdbExecutablePath = jest.fn(() => 'adb'); + + await expect(server.runDeviceQueryAsync(['devices', '-l'], 'device query')).resolves.toBe( + 'machine-readable output' + ); + }); + + it('rejects a nonexistent ADB user reported with a successful exit', async () => { + jest.mocked(spawnAsync).mockResolvedValueOnce({ + stdout: '', + stderr: 'Error: user 99 does not exist', + status: 0, + signal: null, + } as any); + const server = new ADBServer(); + server.getAdbExecutablePath = jest.fn(() => 'adb'); + + await expect( + server.runDeviceQueryAsync(['shell', 'pm', 'list', 'packages'], 'query') + ).rejects.toMatchObject({ + code: 'EXPO_ADB_USER', + }); + }); }); describe('getFileOutputAsync', () => { - it(`returns file output from ADB`, async () => { - jest.mocked(execFileSync).mockReturnValueOnce('foobar'); + it(`returns UTF-8 file output from ADB`, async () => { + const output = '日本語 🚀'; + jest.mocked(spawnAsync).mockResolvedValueOnce({ + stdout: output, + stderr: '', + status: 0, + signal: null, + } as any); const server = new ADBServer(); - server.startAsync = jest.fn(); server.resolveAdbPromise = jest.fn(server.resolveAdbPromise); server.getAdbExecutablePath = jest.fn(() => 'adb'); - await expect(server.getFileOutputAsync(['foo', 'bar'])).resolves.toBe('foobar'); + await expect(server.getFileOutputAsync(['foo', 'bar'])).resolves.toBe(output); expect(server.getAdbExecutablePath).toHaveBeenCalledTimes(1); - expect(server.startAsync).toHaveBeenCalledTimes(1); expect(server.resolveAdbPromise).toHaveBeenCalledTimes(1); - expect(execFileSync).toHaveBeenCalledTimes(1); - expect(execFileSync).toHaveBeenCalledWith('adb', ['foo', 'bar'], { - encoding: 'latin1', - stdio: 'pipe', - }); + expect(spawnAsync).toHaveBeenCalledTimes(1); + expect(spawnAsync).toHaveBeenCalledWith('adb', ['foo', 'bar']); }); -}); -describe('stopAsync', () => { - it(`stops the ADB server when running`, async () => { - jest.mocked(spawnAsync).mockResolvedValueOnce({ output: [''] } as any); + + it('maps ADB errors from property queries', async () => { + jest.mocked(spawnAsync).mockRejectedValueOnce({ + status: 255, + stdout: 'Error: java.lang.IllegalArgumentException: Bad user number: FUNKY\n', + stderr: '', + }); const server = new ADBServer(); - server.isRunning = true; - await expect(server.stopAsync()).resolves.toBe(true); - expect(server.isRunning).toBe(false); - expect(spawnAsync).toHaveBeenCalledTimes(1); + server.getAdbExecutablePath = jest.fn(() => 'adb'); + + await expect(server.getFileOutputAsync(['shell', 'getprop'])).rejects.toThrow( + 'Invalid ADB user number "FUNKY"' + ); }); - it(`stops the ADB server when not running`, async () => { - jest.mocked(spawnAsync).mockResolvedValueOnce({ output: [''] } as any); + + it('maps bad ADB users reported on stderr', async () => { const server = new ADBServer(); - server.isRunning = false; - await expect(server.stopAsync()).resolves.toBe(false); - expect(spawnAsync).toHaveBeenCalledTimes(0); + await expect( + server.resolveAdbPromise( + Promise.reject({ + status: 255, + stderr: 'java.lang.IllegalArgumentException: Bad user number: FUNKY', + }) + ) + ).rejects.toMatchObject({ code: 'EXPO_ADB_USER' }); }); - it(`considers the ADB server stopped if the process fails`, async () => { + it('formats status failures without stdout', async () => { const server = new ADBServer(); - server.isRunning = true; - server.runAsync = jest.fn(() => { - throw new Error('foobar'); + const error = { status: 255, stderr: 'device rejected command' }; + + await expect(server.resolveAdbPromise(Promise.reject(error))).rejects.toMatchObject({ + message: 'device rejected command', }); - await expect(server.stopAsync()).resolves.toBe(false); - expect(server.isRunning).toBe(false); - expect(Log.error).toHaveBeenCalled(); + }); + + it('does not block scheduled JavaScript while a property query is pending', async () => { + let resolveSpawn!: (result: any) => void; + jest.mocked(spawnAsync).mockReturnValueOnce( + new Promise((resolve) => { + resolveSpawn = resolve; + }) as any + ); + const server = new ADBServer(); + server.getAdbExecutablePath = jest.fn(() => 'adb'); + const scheduledTask = jest.fn(); + const query = server.getFileOutputAsync(['shell', 'getprop']); + + await new Promise((resolve) => + setTimeout(() => { + scheduledTask(); + resolve(); + }, 0) + ); + expect(scheduledTask).toHaveBeenCalledTimes(1); + + resolveSpawn({ stdout: 'value', stderr: '', status: 0, signal: null }); + await expect(query).resolves.toBe('value'); + }); +}); + +describe('bounded commands', () => { + jest.setTimeout(10_000); + + class FixtureADBServer extends ADBServer { + override getAdbExecutablePath(): string { + return process.execPath; + } + + override runDeviceQueryAsync(args: string[], operation: string, signal?: AbortSignal) { + const fixture = path.join(__dirname, 'fixtures', 'adb-long-command.js'); + return super.runDeviceQueryAsync([fixture, ...args], operation, signal); + } + } + + beforeEach(() => { + jest.mocked(spawnAsync).mockImplementation(jest.requireActual('@expo/spawn-async')); + }); + + it('allows a finite command to finish within the default deadline', async () => { + await expect(new FixtureADBServer().runDeviceQueryAsync(['finite'], 'finite')).resolves.toBe( + 'completed' + ); + }); + + it('keeps a stream active until its caller cancels', async () => { + const controller = new AbortController(); + const stream = new FixtureADBServer().runDeviceQueryAsync( + ['stream'], + 'stream', + controller.signal + ); + const reason = new Error('stop stream'); + const cancellation = setTimeout(() => controller.abort(reason), 100); + + try { + await expect(stream).rejects.toBe(reason); + } finally { + clearTimeout(cancellation); + } }); }); diff --git a/packages/@expo/cli/src/start/platforms/android/__tests__/AndroidDeviceManager-test.ts b/packages/@expo/cli/src/start/platforms/android/__tests__/AndroidDeviceManager-test.ts index 30aaf3bf519131..5c62b2198bdd42 100644 --- a/packages/@expo/cli/src/start/platforms/android/__tests__/AndroidDeviceManager-test.ts +++ b/packages/@expo/cli/src/start/platforms/android/__tests__/AndroidDeviceManager-test.ts @@ -1,7 +1,16 @@ import { CommandError } from '../../../../utils/errors'; import { AndroidDeviceManager } from '../AndroidDeviceManager'; import type { Device } from '../adb'; -import { getPackageInfoAsync, launchActivityAsync, openUrlAsync } from '../adb'; +import { + getPackageInfoAsync, + installAsync, + isDeviceBootedAsync, + launchActivityAsync, + logUnauthorized, + openUrlAsync, +} from '../adb'; +import { startDeviceAsync } from '../emulator'; +import { getDevicesAsync } from '../getDevices'; import { shellDumpsysPackage } from './fixtures/adb-output'; jest.mock('../adbReverse', () => ({ @@ -12,7 +21,13 @@ jest.mock('../adb', () => ({ launchActivityAsync: jest.fn(), openAppIdAsync: jest.fn(), openUrlAsync: jest.fn(), + installAsync: jest.fn(), + isPackageInstalledAsync: jest.fn(), + isDeviceBootedAsync: jest.fn(), + logUnauthorized: jest.fn(), })); +jest.mock('../emulator', () => ({ startDeviceAsync: jest.fn() })); +jest.mock('../getDevices', () => ({ getDevicesAsync: jest.fn() })); const asDevice = (device: Partial): Device => device as Device; @@ -20,6 +35,285 @@ function createDevice() { return new AndroidDeviceManager(asDevice({ name: 'Pixel 5', pid: '123' })); } +describe('device resolution', () => { + it('launches only an explicit AVD inventory record', async () => { + const avd = asDevice({ + name: 'Pixel_API_35', + type: 'emulator', + isLaunchable: true, + isBooted: false, + isAuthorized: true, + }); + const attached = asDevice({ + ...avd, + pid: 'emulator-5554', + state: 'device', + transportId: '4', + isLaunchable: false, + isBooted: true, + }); + jest.mocked(startDeviceAsync).mockResolvedValueOnce(attached); + + await expect(AndroidDeviceManager.resolveAsync({ device: avd })).resolves.toMatchObject({ + device: attached, + }); + expect(startDeviceAsync).toHaveBeenCalledWith(avd); + expect(isDeviceBootedAsync).toHaveBeenCalledWith(avd); + }); + + it('does not relaunch an AVD that became attached after inventory', async () => { + const avd = asDevice({ + name: 'Pixel_API_35', + type: 'emulator', + isLaunchable: true, + isBooted: false, + isAuthorized: true, + }); + const attached = asDevice({ + ...avd, + pid: 'emulator-5554', + state: 'device', + isLaunchable: false, + isBooted: true, + }); + jest.mocked(isDeviceBootedAsync).mockResolvedValueOnce(attached); + + await expect(AndroidDeviceManager.resolveAsync({ device: avd })).resolves.toMatchObject({ + device: attached, + }); + expect(startDeviceAsync).not.toHaveBeenCalled(); + }); + + it('reports a booting AVD that became attached after inventory as unready', async () => { + const avd = asDevice({ + name: 'Pixel_API_35', + type: 'emulator', + isLaunchable: true, + isBooted: false, + isAuthorized: true, + }); + const booting = asDevice({ + ...avd, + pid: 'emulator-5554', + state: 'offline', + isLaunchable: false, + isAuthorized: false, + }); + jest.mocked(isDeviceBootedAsync).mockResolvedValueOnce(booting); + + await expect(AndroidDeviceManager.resolveAsync({ device: avd })).rejects.toThrow( + /emulator-5554 is in state offline.*Wait until ADB reports the emulator as ready/s + ); + expect(startDeviceAsync).not.toHaveBeenCalled(); + expect(logUnauthorized).not.toHaveBeenCalled(); + }); + + it('keeps the authorization flow for an unauthorized AVD that became attached', async () => { + const avd = asDevice({ + name: 'Pixel_API_35', + type: 'emulator', + isLaunchable: true, + isBooted: false, + isAuthorized: true, + }); + const attached = asDevice({ + ...avd, + pid: 'emulator-5554', + state: 'unauthorized', + isLaunchable: false, + isAuthorized: false, + }); + jest.mocked(isDeviceBootedAsync).mockResolvedValueOnce(attached); + + await expect(AndroidDeviceManager.resolveAsync({ device: avd })).rejects.toThrow( + /emulator-5554 is unauthorized.*Authorize this computer/s + ); + expect(logUnauthorized).toHaveBeenCalledWith(attached); + expect(startDeviceAsync).not.toHaveBeenCalled(); + }); + + it.each(['offline', 'future-state'])( + 'rejects an attached %s transport without entering the AVD launch path', + async (state) => { + const physical = asDevice({ + name: 'Device USB-1', + pid: 'USB-1', + type: 'device', + state, + isLaunchable: false, + isAuthorized: true, + }); + + await expect(AndroidDeviceManager.resolveAsync({ device: physical })).rejects.toThrow(state); + expect(startDeviceAsync).not.toHaveBeenCalled(); + expect(isDeviceBootedAsync).not.toHaveBeenCalled(); + } + ); + + it('preserves the dedicated authorization flow for an attached unauthorized device', async () => { + const physical = asDevice({ + name: 'Device USB-1', + pid: 'USB-1', + type: 'device', + state: 'unauthorized', + isLaunchable: false, + isAuthorized: false, + }); + jest.mocked(isDeviceBootedAsync).mockResolvedValueOnce(physical); + + await expect(AndroidDeviceManager.resolveAsync({ device: physical })).rejects.toThrow( + /Device USB-1 is unauthorized.*Authorize this computer/s + ); + expect(logUnauthorized).toHaveBeenCalledWith(physical); + expect(startDeviceAsync).not.toHaveBeenCalled(); + }); + + it('reports disappearance after discovery without launching or replaying', async () => { + const physical = asDevice({ + name: 'Pixel USB', + pid: 'USB-1', + type: 'device', + state: 'device', + transportId: '4', + isLaunchable: false, + isAuthorized: true, + }); + jest.mocked(isDeviceBootedAsync).mockResolvedValueOnce(null); + + await expect(AndroidDeviceManager.resolveAsync({ device: physical })).rejects.toThrow( + /Device not found after discovery/ + ); + expect(startDeviceAsync).not.toHaveBeenCalled(); + }); + + it('uses a freshly rediscovered physical transport without launching an emulator', async () => { + const physical = asDevice({ + name: 'Pixel USB', + pid: 'USB-1', + type: 'device', + state: 'device', + transportId: '4', + isLaunchable: false, + isAuthorized: true, + }); + jest.mocked(isDeviceBootedAsync).mockResolvedValueOnce(physical); + + await expect(AndroidDeviceManager.resolveAsync({ device: physical })).resolves.toMatchObject({ + device: physical, + }); + expect(startDeviceAsync).not.toHaveBeenCalled(); + }); + + it('reports transport replacement after discovery', async () => { + const physical = asDevice({ + name: 'Pixel USB', + pid: 'USB-1', + type: 'device', + state: 'device', + transportId: '4', + isLaunchable: false, + isAuthorized: true, + }); + jest.mocked(isDeviceBootedAsync).mockResolvedValueOnce({ ...physical, transportId: '5' }); + + await expect(AndroidDeviceManager.resolveAsync({ device: physical })).rejects.toThrow( + /transport 4 became 5/ + ); + expect(startDeviceAsync).not.toHaveBeenCalled(); + }); + + it('maps a post-selection device-not-found error without replaying install', async () => { + const manager = createDevice(); + jest.mocked(installAsync).mockRejectedValueOnce(new Error('error: device not found')); + + await expect(manager.installAppAsync('/tmp/app.apk')).rejects.toThrow( + /The device disconnected. Reconnect it and try again./ + ); + expect(installAsync).toHaveBeenCalledTimes(1); + }); +}); + +describe('resolveFromNameAsync', () => { + it('resolves a physical device by serial', async () => { + const device = asDevice({ + name: 'moto_g55_5G', + pid: 'ZY22KPLGQ9', + type: 'device', + state: 'device', + isAuthorized: true, + isBooted: true, + }); + jest.mocked(getDevicesAsync).mockResolvedValueOnce([device]); + jest.mocked(isDeviceBootedAsync).mockResolvedValueOnce(device); + + const manager = await AndroidDeviceManager.resolveFromNameAsync('ZY22KPLGQ9'); + expect(manager.device.pid).toBe('ZY22KPLGQ9'); + }); + + it('resolves a physical device by name', async () => { + const device = asDevice({ + name: 'moto_g55_5G', + pid: 'ZY22KPLGQ9', + type: 'device', + state: 'device', + isAuthorized: true, + isBooted: true, + }); + jest.mocked(getDevicesAsync).mockResolvedValueOnce([device]); + jest.mocked(isDeviceBootedAsync).mockResolvedValueOnce(device); + + const manager = await AndroidDeviceManager.resolveFromNameAsync('moto_g55_5G'); + expect(manager.device.pid).toBe('ZY22KPLGQ9'); + }); + + it('prefers a serial match over a name match', async () => { + const deviceNamedX = asDevice({ + name: 'X', + pid: 'other-serial', + type: 'device', + state: 'device', + isAuthorized: true, + isBooted: true, + }); + const deviceWithSerialX = asDevice({ + name: 'other-name', + pid: 'X', + type: 'device', + state: 'device', + isAuthorized: true, + isBooted: true, + }); + jest.mocked(getDevicesAsync).mockResolvedValueOnce([deviceNamedX, deviceWithSerialX]); + jest.mocked(isDeviceBootedAsync).mockResolvedValueOnce(deviceWithSerialX); + + const manager = await AndroidDeviceManager.resolveFromNameAsync('X'); + expect(manager.device.pid).toBe('X'); + expect(manager.device.name).toBe('other-name'); + }); + + it('rejects with an actionable error when no device matches', async () => { + const device = asDevice({ + name: 'Pixel 5', + pid: '123', + type: 'device', + state: 'device', + isAuthorized: true, + isBooted: true, + }); + jest.mocked(getDevicesAsync).mockResolvedValue([device]); + + await expect(AndroidDeviceManager.resolveFromNameAsync('nonsense')).rejects.toThrow( + /nonsense/s + ); + await expect(AndroidDeviceManager.resolveFromNameAsync('nonsense')).rejects.toThrow( + /Pixel 5.*123/s + ); + await expect(AndroidDeviceManager.resolveFromNameAsync('nonsense')).rejects.toThrow( + /--device/s + ); + }); +}); + describe('getAppVersionAsync', () => { it(`gets the version from an installed app`, async () => { const device = createDevice(); @@ -65,7 +359,8 @@ describe('launchActivityAsync', () => { expect.objectContaining({ launchActivity: 'dev.expo.test/.MainActivity', url: 'exp+expo-test://expo-development-client/?url=http%3A%2F%2F192.168.86.186%3A8081', - }) + }), + expect.any(AbortSignal) ); }); }); @@ -76,15 +371,24 @@ describe('openUrlAsync', () => { await device.openUrlAsync('exp://foobar'); expect(launchActivityAsync).toHaveBeenCalledWith( { pid: '123' }, - { launchActivity: 'host.exp.exponent/.experience.HomeActivity' } + { launchActivity: 'host.exp.exponent/.experience.HomeActivity' }, + expect.any(AbortSignal) + ); + expect(openUrlAsync).toHaveBeenCalledWith( + { pid: '123' }, + { url: 'exp://foobar' }, + expect.any(AbortSignal) ); - expect(openUrlAsync).toHaveBeenCalledWith({ pid: '123' }, { url: 'exp://foobar' }); }); it('opens a URL on a device', async () => { const device = createDevice(); await device.openUrlAsync('http://foobar'); expect(launchActivityAsync).not.toHaveBeenCalled(); - expect(openUrlAsync).toHaveBeenCalledWith({ pid: '123' }, { url: 'http://foobar' }); + expect(openUrlAsync).toHaveBeenCalledWith( + { pid: '123' }, + { url: 'http://foobar' }, + expect.any(AbortSignal) + ); }); it('launches nonstandard URL', async () => { const device = createDevice(); diff --git a/packages/@expo/cli/src/start/platforms/android/__tests__/adb-test.ts b/packages/@expo/cli/src/start/platforms/android/__tests__/adb-test.ts index a9522f8943b887..7d2702d09237f2 100644 --- a/packages/@expo/cli/src/start/platforms/android/__tests__/adb-test.ts +++ b/packages/@expo/cli/src/start/platforms/android/__tests__/adb-test.ts @@ -1,50 +1,101 @@ +import spawnAsync from '@expo/spawn-async'; +import fs from 'node:fs'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; + import { CommandError } from '../../../../utils/errors'; +import { ora } from '../../../../utils/ora'; +import type { ADBServer } from '../ADBServer'; import type { Device } from '../adb'; import { getAdbNameForDeviceIdAsync, getAttachedDevicesAsync, getDeviceABIsAsync, getPropertyDataForDeviceAsync, - getServer, + getServer as getServerBase, isBootAnimationCompleteAsync, isDeviceBootedAsync, isPackageInstalledAsync, launchActivityAsync, sanitizeAdbDeviceName, + waitForAttachedDevicesAsync, openUrlAsync, } from '../adb'; +import * as AdbEndpoint from '../adbEndpoint'; +import { AdbProcessWaitError } from '../adbProcess'; jest.mock('../ADBServer', () => ({ - ADBServer: jest.fn(() => ({ - runAsync: jest.fn(async () => ''), - getFileOutputAsync: jest.fn(async () => ''), - })), + ADBServer: jest.fn(() => { + return { + runDeviceQueryAsync: jest.fn(async () => ''), + runDeviceMutationAsync: jest.fn(async () => ''), + runHostQueryAsync: jest.fn(async () => ''), + getFileOutputAsync: jest.fn(async () => ''), + }; + }), +})); +jest.mock('../../../../utils/ora', () => ({ + ora: jest.fn(() => ({ start: jest.fn(), stop: jest.fn() })), })); +jest.unmock('child_process'); +jest.unmock('fs'); +jest.unmock('node:fs'); +jest.unmock('os'); +jest.unmock('node:os'); + +const originalEndpointEnvironment = { + ADB_SERVER_SOCKET: process.env.ADB_SERVER_SOCKET, + ANDROID_ADB_SERVER_ADDRESS: process.env.ANDROID_ADB_SERVER_ADDRESS, + ANDROID_ADB_SERVER_PORT: process.env.ANDROID_ADB_SERVER_PORT, +}; + +beforeEach(() => { + delete process.env.ADB_SERVER_SOCKET; + delete process.env.ANDROID_ADB_SERVER_ADDRESS; + delete process.env.ANDROID_ADB_SERVER_PORT; +}); + +afterAll(() => { + for (const [key, value] of Object.entries(originalEndpointEnvironment)) { + if (value == null) delete process.env[key]; + else process.env[key] = value; + } +}); const asDevice = (device: Partial): Device => device as Device; +const getServer = () => jest.mocked(getServerBase()); const device = asDevice({ name: 'Pixel 5', pid: '123' }); +const deviceListResult = (stdout: string) => stdout; + describe(openUrlAsync, () => { it(`quotes the url`, async () => { await openUrlAsync(device, { url: 'acme://foo?bar=1&baz=2' }); - expect(getServer().runAsync).toHaveBeenCalledWith([ - '-s', - '123', - 'shell', - "'am'", - "'start'", - "'-a'", - "'android.intent.action.VIEW'", - "'-d'", - "'acme://foo?bar=1&baz=2'", - ]); + expect(getServer().runDeviceMutationAsync).toHaveBeenCalledWith( + [ + '-s', + '123', + 'shell', + "'am'", + "'start'", + "'-a'", + "'android.intent.action.VIEW'", + "'-d'", + "'acme://foo?bar=1&baz=2'", + ], + 'URL launch', + undefined + ); }); it(`neutralizes shell-injection attempts in the url`, async () => { await openUrlAsync(device, { url: 'acme://x; reboot' }); - expect(getServer().runAsync).toHaveBeenCalledWith( - expect.arrayContaining(["'acme://x; reboot'"]) + expect(getServer().runDeviceMutationAsync).toHaveBeenCalledWith( + expect.arrayContaining(["'acme://x; reboot'"]), + 'URL launch', + undefined ); }); }); @@ -52,7 +103,7 @@ describe(openUrlAsync, () => { describe(launchActivityAsync, () => { it(`asserts that the launch activity does not exist`, async () => { jest - .mocked(getServer().runAsync) + .mocked(getServer().runDeviceMutationAsync) .mockResolvedValueOnce('Error: Activity class dev.bacon.app/.MainActivity does not exist.'); await expect( launchActivityAsync(device, { @@ -61,57 +112,82 @@ describe(launchActivityAsync, () => { ).rejects.toThrow(CommandError); }); it(`launches activity`, async () => { - jest.mocked(getServer().runAsync).mockResolvedValueOnce('...'); + jest.mocked(getServer().runDeviceMutationAsync).mockResolvedValueOnce('...'); await launchActivityAsync(device, { launchActivity: 'dev.bacon.app/.MainActivity', }); - expect(getServer().runAsync).toHaveBeenCalledWith([ - '-s', - '123', - 'shell', - "'am'", - "'start'", - "'-f'", - "'0x20000000'", - "'-n'", - "'dev.bacon.app/.MainActivity'", - ]); + expect(getServer().runDeviceMutationAsync).toHaveBeenCalledWith( + [ + '-s', + '123', + 'shell', + "'am'", + "'start'", + "'-f'", + "'0x20000000'", + "'-n'", + "'dev.bacon.app/.MainActivity'", + ], + 'activity launch', + undefined + ); }); it(`launches activity with url`, async () => { - jest.mocked(getServer().runAsync).mockResolvedValueOnce('...'); + jest.mocked(getServer().runDeviceMutationAsync).mockResolvedValueOnce('...'); await launchActivityAsync(device, { launchActivity: 'dev.expo.custom.appid/dev.bacon.app.MainActivity', url: 'exp+expo-test://expo-development-client/?url=http%3A%2F%2F192.168.86.186%3A8081', }); - expect(getServer().runAsync).toHaveBeenCalledWith([ - '-s', - '123', - 'shell', - "'am'", - "'start'", - "'-f'", - "'0x20000000'", - "'-n'", - "'dev.expo.custom.appid/dev.bacon.app.MainActivity'", - "'-d'", - "'exp+expo-test://expo-development-client/?url=http%3A%2F%2F192.168.86.186%3A8081'", - ]); + expect(getServer().runDeviceMutationAsync).toHaveBeenCalledWith( + [ + '-s', + '123', + 'shell', + "'am'", + "'start'", + "'-f'", + "'0x20000000'", + "'-n'", + "'dev.expo.custom.appid/dev.bacon.app.MainActivity'", + "'-d'", + "'exp+expo-test://expo-development-client/?url=http%3A%2F%2F192.168.86.186%3A8081'", + ], + 'activity launch', + undefined + ); }); it(`neutralizes shell-injection attempts in the launch activity`, async () => { - jest.mocked(getServer().runAsync).mockResolvedValueOnce('...'); + jest.mocked(getServer().runDeviceMutationAsync).mockResolvedValueOnce('...'); await launchActivityAsync(device, { launchActivity: 'dev.bacon.app/.MainActivity; reboot', }); - expect(getServer().runAsync).toHaveBeenCalledWith( - expect.arrayContaining(["'dev.bacon.app/.MainActivity; reboot'"]) + expect(getServer().runDeviceMutationAsync).toHaveBeenCalledWith( + expect.arrayContaining(["'dev.bacon.app/.MainActivity; reboot'"]), + 'activity launch', + undefined ); }); + it('classifies a rejected missing activity as not installed', async () => { + jest.mocked(getServer().runDeviceMutationAsync).mockRejectedValueOnce( + Object.assign(new Error('ADB command failed'), { + status: 1, + stderr: + 'Error type 3\nError: Activity class {com.does.not.exist/com.does.not.exist.MainActivity} does not exist.', + }) + ); + + await expect( + launchActivityAsync(device, { + launchActivity: 'com.does.not.exist/.MainActivity', + }) + ).rejects.toMatchObject({ code: 'APP_NOT_INSTALLED' }); + }); }); describe(isPackageInstalledAsync, () => { it(`returns true when a package is installed`, async () => { jest - .mocked(getServer().runAsync) + .mocked(getServer().runDeviceQueryAsync) .mockResolvedValueOnce( [ 'package:com.google.android.networkstack.tethering', @@ -120,34 +196,42 @@ describe(isPackageInstalledAsync, () => { ].join('\n') ); expect(await isPackageInstalledAsync(device, 'com.google.android.youtube')).toBe(true); - expect(getServer().runAsync).toHaveBeenCalledWith([ - '-s', - '123', - 'shell', - "'pm'", - "'list'", - "'packages'", - "'--user'", - "'0'", - "'com.google.android.youtube'", - ]); + expect(getServer().runDeviceQueryAsync).toHaveBeenCalledWith( + [ + '-s', + '123', + 'shell', + "'pm'", + "'list'", + "'packages'", + "'--user'", + "'0'", + "'com.google.android.youtube'", + ], + 'package query', + undefined + ); }); it(`returns false when a package is not isntalled`, async () => { - jest.mocked(getServer().runAsync).mockResolvedValueOnce(''); + jest.mocked(getServer().runDeviceQueryAsync).mockResolvedValueOnce(''); expect(await isPackageInstalledAsync(device, 'com.google.android.youtube')).toBe(false); }); it(`neutralizes shell-injection attempts in the package name`, async () => { - jest.mocked(getServer().runAsync).mockResolvedValueOnce(''); + jest.mocked(getServer().runDeviceQueryAsync).mockResolvedValueOnce(''); await isPackageInstalledAsync(device, 'com.google.android.youtube; reboot'); - expect(getServer().runAsync).toHaveBeenCalledWith( - expect.arrayContaining(["'com.google.android.youtube; reboot'"]) + expect(getServer().runDeviceQueryAsync).toHaveBeenCalledWith( + expect.arrayContaining(["'com.google.android.youtube; reboot'"]), + 'package query', + undefined ); }); }); describe(getAdbNameForDeviceIdAsync, () => { it(`returns a device name`, async () => { - jest.mocked(getServer().runAsync).mockResolvedValueOnce(['Pixel_4_XL_API_30', 'OK'].join('\n')); + jest + .mocked(getServer().runDeviceQueryAsync) + .mockResolvedValueOnce(['Pixel_4_XL_API_30', 'OK'].join('\n')); expect(await getAdbNameForDeviceIdAsync(asDevice({ pid: 'emulator-5554' }))).toBe( 'Pixel_4_XL_API_30' @@ -155,7 +239,7 @@ describe(getAdbNameForDeviceIdAsync, () => { }); it(`asserts when a device is not found`, async () => { jest - .mocked(getServer().runAsync) + .mocked(getServer().runDeviceQueryAsync) .mockResolvedValueOnce('error: could not connect to TCP port 55534: Connection refused'); await expect(getAdbNameForDeviceIdAsync(asDevice({ pid: 'emulator-5554' }))).rejects.toThrow( @@ -167,39 +251,141 @@ describe(getAdbNameForDeviceIdAsync, () => { describe(isDeviceBootedAsync, () => { it(`returns a device when booted`, async () => { jest - .mocked(getServer().runAsync) - .mockResolvedValueOnce( - [ - 'List of devices attached', - 'emulator-5554 device product:sdk_gphone_x86_arm model:sdk_gphone_x86_arm device:generic_x86_arm transport_id:1', - '', - ].join('\n') - ) + .mocked(getServer().runHostQueryAsync) .mockResolvedValueOnce( - // Return the emulator name - ['Pixel_4_XL_API_30', 'OK'].join('\n') + deviceListResult( + [ + 'List of devices attached', + 'emulator-5554 device product:sdk_gphone_x86_arm model:sdk_gphone_x86_arm device:generic_x86_arm transport_id:1', + '', + ].join('\n') + ) ); + jest + .mocked(getServer().runDeviceQueryAsync) + .mockResolvedValueOnce(['Pixel_4_XL_API_30', 'OK'].join('\n')); - expect(await isDeviceBootedAsync(asDevice({ name: 'Pixel_4_XL_API_30' }))).toStrictEqual({ + expect(await isDeviceBootedAsync(asDevice({ name: 'Pixel_4_XL_API_30' }))).toMatchObject({ isAuthorized: true, isBooted: true, + isLaunchable: false, name: 'Pixel_4_XL_API_30', pid: 'emulator-5554', + state: 'device', + transportId: '1', type: 'emulator', }); }); + it('revalidates a selected device by serial when device names are duplicated', async () => { + jest + .mocked(getServer().runHostQueryAsync) + .mockResolvedValueOnce( + deviceListResult( + [ + 'List of devices attached', + 'serial-1 device product:walleye model:Pixel_2 device:walleye transport_id:1', + 'serial-2 device product:walleye model:Pixel_2 device:walleye transport_id:2', + '', + ].join('\n') + ) + ); + + await expect( + isDeviceBootedAsync(asDevice({ pid: 'serial-2', name: 'Pixel_2' })) + ).resolves.toMatchObject({ + pid: 'serial-2', + name: 'Pixel_2', + transportId: '2', + }); + }); + it(`returns null when the device is not booted`, async () => { - jest.mocked(getServer().runAsync).mockResolvedValueOnce(''); + jest.mocked(getServer().runHostQueryAsync).mockResolvedValueOnce(deviceListResult('')); expect(await isDeviceBootedAsync(device)).toBe(null); }); }); describe(getAttachedDevicesAsync, () => { - it(`gets devices`, async () => { + it('retries transient empty discovery results beyond three rapid attempts', async () => { + jest + .mocked(getServer().runHostQueryAsync) + .mockResolvedValueOnce(deviceListResult('')) + .mockResolvedValueOnce(deviceListResult('')) + .mockResolvedValueOnce(deviceListResult('')) + .mockResolvedValueOnce(deviceListResult('')) + .mockResolvedValueOnce(deviceListResult('USB-1 device usb:1 model:Pixel transport_id:4')); + + await expect(waitForAttachedDevicesAsync()).resolves.toEqual([ + expect.objectContaining({ pid: 'USB-1', name: 'Pixel' }), + ]); + expect(getServer().runHostQueryAsync).toHaveBeenCalledTimes(5); + }); + + it('reports the daemon failure that drove retries when discovery runs out of time', async () => { jest - .mocked(getServer().runAsync) + .mocked(getServer().runHostQueryAsync) + .mockRejectedValueOnce(new Error('cannot connect to daemon at tcp:5037: Connection refused')) + .mockResolvedValueOnce(deviceListResult('emulator-5554 device transport_id:1')); + jest.mocked(getServer().runDeviceQueryAsync).mockImplementationOnce( + (_args, _operation, signal) => + new Promise((_resolve, reject) => { + signal!.addEventListener('abort', () => reject(signal!.reason), { once: true }); + }) + ); + + await expect( + getAttachedDevicesAsync({ waitLimitMs: 400, probeWaitLimitMs: 5 }) + ).rejects.toThrow(/cannot connect to daemon at tcp:5037: Connection refused/); + expect(getServer().runHostQueryAsync).toHaveBeenCalledTimes(2); + }); + + it('does not report an earlier daemon failure once a later attempt lists no devices', async () => { + jest + .mocked(getServer().runHostQueryAsync) + .mockRejectedValueOnce(new Error('cannot connect to daemon at tcp:5037: Connection refused')) + .mockResolvedValue(deviceListResult('')); + + await expect(waitForAttachedDevicesAsync()).resolves.toEqual([]); + }); + + it('uses an AVD name for a booting offline emulator', async () => { + jest + .mocked(getServer().runHostQueryAsync) + .mockResolvedValueOnce(deviceListResult('emulator-5554 offline transport_id:1')); + jest.mocked(getServer().runDeviceQueryAsync).mockResolvedValueOnce('Pixel_8a_big\nOK'); + + await expect(getAttachedDevicesAsync()).resolves.toEqual([ + expect.objectContaining({ + pid: 'emulator-5554', + name: 'Pixel_8a_big', + state: 'offline', + isBooted: false, + }), + ]); + }); + + it('keeps healthy devices when an emulator console name query fails', async () => { + jest + .mocked(getServer().runHostQueryAsync) .mockResolvedValueOnce( + deviceListResult( + 'USB-1 device usb:1 model:Pixel transport_id:4\nemulator-5554 device transport_id:1' + ) + ); + jest + .mocked(getServer().runDeviceQueryAsync) + .mockRejectedValueOnce(new Error('could not connect to TCP port 5554: Connection refused')); + + await expect(getAttachedDevicesAsync()).resolves.toEqual([ + expect.objectContaining({ pid: 'USB-1', name: 'Pixel' }), + expect.objectContaining({ pid: 'emulator-5554', name: 'Device emulator-5554' }), + ]); + }); + + it(`gets devices`, async () => { + jest.mocked(getServer().runHostQueryAsync).mockResolvedValueOnce( + deviceListResult( [ 'List of devices attached', // unauthorized @@ -208,64 +394,87 @@ describe(getAttachedDevicesAsync, () => { 'FA8251A00720 device usb:338690048X product:walleye model:Pixel_2 device:walleye transport_id:4', // Emulator 'emulator-5554 device product:sdk_gphone_x86_arm model:sdk_gphone_x86_arm device:generic_x86_arm transport_id:1', + // Physical device with "emulator" in its metadata + 'FA8251A00721 device usb:338690048X model:emulator_phone transport_id:6', '', ].join('\n') ) - .mockResolvedValueOnce( - // Return the emulator name - ['Pixel_4_XL_API_30', 'OK'].join('\n') - ); + ); + jest + .mocked(getServer().runDeviceQueryAsync) + .mockResolvedValueOnce(['Pixel_4_XL_API_30', 'OK'].join('\n')); const devices = await getAttachedDevicesAsync(); + expect(ora).not.toHaveBeenCalled(); + expect(devices).toEqual([ { isAuthorized: false, - isBooted: true, + isBooted: false, + isLaunchable: false, name: 'Device FA8251A00719', pid: 'FA8251A00719', + state: 'unauthorized', + transportId: '5', type: 'device', connectionType: 'USB', }, { isAuthorized: true, isBooted: true, + isLaunchable: false, name: 'Pixel_2', pid: 'FA8251A00720', + state: 'device', + transportId: '4', type: 'device', connectionType: 'USB', }, { isAuthorized: true, isBooted: true, + isLaunchable: false, name: 'Pixel_4_XL_API_30', pid: 'emulator-5554', + state: 'device', + transportId: '1', type: 'emulator', }, + { + connectionType: 'USB', + isAuthorized: true, + isBooted: true, + isLaunchable: false, + name: 'emulator_phone', + pid: 'FA8251A00721', + state: 'device', + transportId: '6', + type: 'device', + }, ]); }); it(`gets network connected devices`, async () => { - jest - .mocked(getServer().runAsync) - .mockResolvedValueOnce( + jest.mocked(getServer().runHostQueryAsync).mockResolvedValueOnce( + deviceListResult( [ 'List of devices attached', - // unauthorized + // offline 'adb-00000XXX000XXX-YzYyyy._adb-tls-connect._tcp. offline transport_id:1', // authorized & online 'adb-00000XXX000XXX-YzXxxx._adb-tls-connect._tcp. device product:cheetah model:Pixel_7_Pro device:cheetah transport_id:2', - // authorized & offline + // offline with retained model metadata 'adb-00000XXX000XXX-YzZzzz._adb-tls-connect._tcp. offline product:cheetah model:Pixel_7_Pro device:cheetah transport_id:2', // Emulator 'emulator-5554 device product:sdk_gphone_x86_arm model:sdk_gphone_x86_arm device:generic_x86_arm transport_id:1', '', ].join('\n') ) - .mockResolvedValueOnce( - // Return the emulator name - ['Pixel_4_XL_API_30', 'OK'].join('\n') - ); + ); + jest + .mocked(getServer().runDeviceQueryAsync) + .mockResolvedValueOnce(['Pixel_4_XL_API_30', 'OK'].join('\n')); const devices = await getAttachedDevicesAsync(); @@ -273,70 +482,147 @@ describe(getAttachedDevicesAsync, () => { { isAuthorized: false, isBooted: false, + isLaunchable: false, name: 'Device adb-00000XXX000XXX-YzYyyy._adb-tls-connect._tcp.', pid: 'adb-00000XXX000XXX-YzYyyy._adb-tls-connect._tcp.', + state: 'offline', + transportId: '1', type: 'device', connectionType: 'Network', }, { isAuthorized: true, isBooted: true, + isLaunchable: false, name: 'Pixel_7_Pro', pid: 'adb-00000XXX000XXX-YzXxxx._adb-tls-connect._tcp.', + state: 'device', + transportId: '2', type: 'device', connectionType: 'Network', }, { - isAuthorized: true, + isAuthorized: false, isBooted: false, - name: 'Pixel_7_Pro', + isLaunchable: false, + name: 'Device adb-00000XXX000XXX-YzZzzz._adb-tls-connect._tcp.', pid: 'adb-00000XXX000XXX-YzZzzz._adb-tls-connect._tcp.', + state: 'offline', + transportId: '2', type: 'device', connectionType: 'Network', }, { isAuthorized: true, isBooted: true, + isLaunchable: false, name: 'Pixel_4_XL_API_30', pid: 'emulator-5554', + state: 'device', + transportId: '1', type: 'emulator', }, ]); }); - it(`gets devices when ADB_TRACE is set`, async () => { + it('reports a silent remote endpoint without replacing its owner', async () => { + process.env.ANDROID_ADB_SERVER_ADDRESS = '192.0.2.10'; jest - .mocked(getServer().runAsync) - .mockResolvedValueOnce( - [ - 'List of devices attached', - 'adb D 03-06 15:25:53 63677 4018815 adb_client.cpp:393] adb_query: host:devices-l', - 'adb D 03-06 15:25:53 63677 4018815 adb_client.cpp:351] adb_connect: service: host:devices-l', - 'adb D 03-06 15:25:53 63677 4018815 adb_client.cpp:160] _adb_connect: host:devices-l', - 'adb D 03-06 15:25:53 63677 4018815 adb_client.cpp:194] _adb_connect: return fd 3', - 'adb D 03-06 15:25:53 63677 4018815 adb_client.cpp:369] adb_connect: return fd 3', - // Emulator - 'emulator-5554 offline transport_id:1', - '', - ].join('\n') - ) - .mockResolvedValueOnce( - // Return the emulator name - ['Pixel_4_XL_API_30', 'OK'].join('\n') + .mocked(getServer().runHostQueryAsync) + .mockRejectedValue( + new AdbProcessWaitError('discovery timed out', 'device discovery', 'host-request') ); + jest + .spyOn(AdbEndpoint, 'probeAdbHostVersionAsync') + .mockResolvedValue({ kind: 'connected-no-reply' }); - const devices = await getAttachedDevicesAsync(); + await expect(getAttachedDevicesAsync({ probeWaitLimitMs: 5 })).rejects.toThrow( + /ADB server at tcp:192\.0\.2\.10:5037.*is not responding/ + ); + expect(AdbEndpoint.probeAdbHostVersionAsync).toHaveBeenCalledWith( + expect.objectContaining({ host: '192.0.2.10', scope: 'remote' }), + expect.any(AbortSignal) + ); + }); - expect(devices).toEqual([ - { - isAuthorized: true, - isBooted: true, - name: 'Pixel_4_XL_API_30', - pid: 'emulator-5554', - type: 'emulator', - }, + it('reports invalid protocol at the custom selected socket', async () => { + process.env.ADB_SERVER_SOCKET = 'tcp:localhost:5041'; + jest + .mocked(getServer().runHostQueryAsync) + .mockRejectedValue(new Error('cannot connect: invalid protocol')); + jest.spyOn(AdbEndpoint, 'probeAdbHostVersionAsync').mockResolvedValue({ + kind: 'invalid-protocol', + }); + + await expect(getAttachedDevicesAsync({ probeWaitLimitMs: 5 })).rejects.toThrow( + /endpoint at tcp:localhost:5041.*is not an ADB server/s + ); + }); + + it('keeps an attached emulator when resolving its name times out', async () => { + jest + .mocked(getServer().runHostQueryAsync) + .mockResolvedValueOnce('List of devices attached\nemulator-5554 device transport_id:1'); + jest + .mocked(getServer().runDeviceQueryAsync) + .mockRejectedValueOnce( + new AdbProcessWaitError('name lookup timed out', 'emulator name query', 'device-service') + ); + await expect(getAttachedDevicesAsync({ probeWaitLimitMs: 5 })).resolves.toEqual([ + expect.objectContaining({ pid: 'emulator-5554', name: 'Device emulator-5554' }), ]); }); + + it('does not probe after explicit caller cancellation', async () => { + const cancellation = new Error('cancelled'); + const controller = new AbortController(); + controller.abort(cancellation); + jest.mocked(getServer().runHostQueryAsync).mockRejectedValueOnce(cancellation); + const probe = jest.spyOn(AdbEndpoint, 'probeAdbHostVersionAsync'); + + await expect(getAttachedDevicesAsync({ signal: controller.signal })).rejects.toBe(cancellation); + expect(probe).not.toHaveBeenCalled(); + }); + + it('does not probe after a caller timeout', async () => { + const controller = new AbortController(); + const reason = new DOMException('caller timed out', 'TimeoutError'); + jest.mocked(getServer().runHostQueryAsync).mockImplementationOnce( + (_args, _operation, signal) => + new Promise((_, reject) => { + signal!.addEventListener('abort', () => reject(signal!.reason), { once: true }); + }) + ); + const probe = jest.spyOn(AdbEndpoint, 'probeAdbHostVersionAsync'); + + const result = getAttachedDevicesAsync({ signal: controller.signal }); + controller.abort(reason); + + await expect(result).rejects.toBe(reason); + expect(probe).not.toHaveBeenCalled(); + }); + + it('preserves caller cancellation while the diagnostic probe is running', async () => { + const controller = new AbortController(); + const reason = new Error('cancel diagnostics'); + jest + .mocked(getServer().runHostQueryAsync) + .mockRejectedValueOnce( + new AdbProcessWaitError('discovery timed out', 'device discovery', 'host-request') + ); + jest.spyOn(AdbEndpoint, 'probeAdbHostVersionAsync').mockImplementationOnce( + (_endpoint, signal) => + new Promise((_, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { once: true }); + }) + ); + + const result = getAttachedDevicesAsync({ signal: controller.signal }); + await Promise.resolve(); + controller.abort(reason); + + await expect(result).rejects.toBe(reason); + }); }); describe(isBootAnimationCompleteAsync, () => { @@ -353,16 +639,30 @@ describe(isBootAnimationCompleteAsync, () => { .mockResolvedValueOnce(['[init.svc.bootanim]: [running]'].join('\n')); await expect(isBootAnimationCompleteAsync()).resolves.toBe(false); }); - it(`returns false if the properties cannot be read`, async () => { + it(`preserves errors when boot properties cannot be read`, async () => { jest.mocked(getServer().getFileOutputAsync).mockImplementationOnce(() => { throw new Error('File not found'); }); - await expect(isBootAnimationCompleteAsync()).resolves.toBe(false); + await expect(isBootAnimationCompleteAsync()).rejects.toThrow('File not found'); }); }); describe(getPropertyDataForDeviceAsync, () => { + it('does not extend an expired property wait with a host probe', async () => { + const operation = 'device property/boot query'; + jest + .mocked(getServer().getFileOutputAsync) + .mockRejectedValueOnce( + new AdbProcessWaitError('property wait expired', operation, 'device-service') + ); + const probe = jest.spyOn(AdbEndpoint, 'probeAdbHostVersionAsync'); + + const result = getPropertyDataForDeviceAsync(asDevice({ pid: '123' })); + await expect(result).rejects.toThrow('property wait expired'); + expect(probe).not.toHaveBeenCalled(); + }); + it(`returns parsed property data`, async () => { jest.mocked(getServer().getFileOutputAsync).mockResolvedValueOnce( [ @@ -408,3 +708,118 @@ describe(sanitizeAdbDeviceName, () => { expect(sanitizeAdbDeviceName(`Pixel_6_API_31\rOK`)).toBe('Pixel_6_API_31'); }); }); + +describe('bounded discovery integration', () => { + jest.setTimeout(10_000); + + it('keeps startup diagnostics on stderr out of the device parser', async () => { + await expect( + getAttachedDevicesAsync({ + server: fixtureServer('adb-cold-start.js'), + waitLimitMs: 1_000, + }) + ).resolves.toEqual([ + expect.objectContaining({ + pid: 'USB-1', + name: 'Pixel', + transportId: '4', + }), + ]); + }); + + it('retries a cold start that fails before its daemon accepts connections', async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'expo-adb-cold-start-')); + const attemptFile = path.join(directory, 'attempts'); + + try { + await expect( + getAttachedDevicesAsync({ + server: fixtureServer('adb-cold-start-failure.js', [attemptFile, '1']), + waitLimitMs: 5_000, + }) + ).resolves.toEqual([ + expect.objectContaining({ pid: 'USB-1', name: 'Pixel', transportId: '4' }), + ]); + expect(fs.readFileSync(attemptFile, 'utf8')).toBe('2'); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it('reports a cold start that keeps failing for the whole retry window', async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'expo-adb-cold-start-')); + const attemptFile = path.join(directory, 'attempts'); + + try { + await expect( + getAttachedDevicesAsync({ + server: fixtureServer('adb-cold-start-failure.js', [attemptFile, '1000']), + waitLimitMs: 5_000, + probeWaitLimitMs: 5, + }) + ).rejects.toThrow(/cannot connect to daemon at tcp:5037: Connection refused/); + expect(Number(fs.readFileSync(attemptFile, 'utf8'))).toBeGreaterThan(1); + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } + }); + + it('cleans up silent discovery before probing the selected host', async () => { + const sockets = new Set(); + const hostServer = net.createServer((socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + socket.end('OKAY00040029'); + }); + await new Promise((resolve, reject) => { + hostServer.once('error', reject); + hostServer.listen(0, '127.0.0.1', resolve); + }); + const address = hostServer.address(); + if (!address || typeof address === 'string') throw new Error('Expected TCP server address.'); + process.env.ADB_SERVER_SOCKET = `tcp:127.0.0.1:${address.port}`; + + try { + await expect( + getAttachedDevicesAsync({ + server: fixtureServer('adb-hang.js'), + waitLimitMs: 100, + probeWaitLimitMs: 500, + }) + ).rejects.toThrow(/Expo stopped waiting for the ADB device discovery operation to finish/); + } finally { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve, reject) => + hostServer.close((error) => (error ? reject(error) : resolve())) + ); + } + }); +}); + +function fixtureServer(name: string, fixtureArgs: string[] = []): ADBServer { + const { ADBServer: ActualADBServer } = + jest.requireActual('../ADBServer'); + const fixtureArgv = [fixturePath(name), ...fixtureArgs]; + + class FixtureADBServer extends ActualADBServer { + getAdbExecutablePath(): string { + return process.execPath; + } + + runHostQueryAsync( + args: string[], + operation: string, + signal?: AbortSignal, + waitLimitMs?: number + ): Promise { + jest.mocked(spawnAsync).mockImplementationOnce(jest.requireActual('@expo/spawn-async')); + return super.runHostQueryAsync([...fixtureArgv, ...args], operation, signal, waitLimitMs); + } + } + + return new FixtureADBServer(); +} + +function fixturePath(name: string): string { + return path.join(__dirname, 'fixtures', name); +} diff --git a/packages/@expo/cli/src/start/platforms/android/__tests__/adbDeviceList-test.ts b/packages/@expo/cli/src/start/platforms/android/__tests__/adbDeviceList-test.ts new file mode 100644 index 00000000000000..4e28c6a1864b84 --- /dev/null +++ b/packages/@expo/cli/src/start/platforms/android/__tests__/adbDeviceList-test.ts @@ -0,0 +1,100 @@ +import { isAdbDeviceStateUsable, parseAdbDeviceList } from '../adbDeviceList'; + +describe(parseAdbDeviceList, () => { + it.each([ + ['tabs', 'emulator-5554\tdevice product:sdk model:Pixel transport_id:1'], + ['runs of whitespace', 'emulator-5554 offline transport_id:1'], + ['CRLF', 'emulator-5554\tdevice transport_id:1\r\n'], + ])('parses records separated with %s', (_, record) => { + expect(parseAdbDeviceList(`List of devices attached\r\n${record}`)).toEqual([ + expect.objectContaining({ + serial: 'emulator-5554', + transportId: '1', + }), + ]); + }); + + it('parses USB and network device metadata', () => { + expect( + parseAdbDeviceList( + [ + 'List of devices attached', + 'FA8251A00720 device usb:338690048X product:walleye model:Pixel_2 transport_id:4', + '192.0.2.1:5555 device product:cheetah model:Pixel_7 transport_id:7', + ].join('\n') + ) + ).toEqual([ + { + serial: 'FA8251A00720', + state: 'device', + metadata: ['usb:338690048X', 'product:walleye', 'model:Pixel_2', 'transport_id:4'], + transportId: '4', + }, + { + serial: '192.0.2.1:5555', + state: 'device', + metadata: ['product:cheetah', 'model:Pixel_7', 'transport_id:7'], + transportId: '7', + }, + ]); + }); + + it.each(['offline', 'unauthorized', 'authorizing', 'connecting'])( + 'retains the transitional state %s', + (state) => { + expect(parseAdbDeviceList(`List of devices attached\nserial-1 ${state}`)).toEqual([ + { serial: 'serial-1', state, metadata: [] }, + ]); + } + ); + + it('retains multi-word no permissions diagnostics', () => { + expect( + parseAdbDeviceList( + 'List of devices attached\nserial-1 no permissions (user is in the plugdev group); see [http://developer.android.com/tools/device.html]' + ) + ).toEqual([ + { + serial: 'serial-1', + state: 'no permissions', + metadata: [ + '(user', + 'is', + 'in', + 'the', + 'plugdev', + 'group);', + 'see', + '[http://developer.android.com/tools/device.html]', + ], + }, + ]); + }); + + it('ignores ADB trace lines written alongside the device list', () => { + expect( + parseAdbDeviceList( + [ + 'List of devices attached', + 'adb D adb_client.cpp:393] adb_query: host:devices-l', + 'emulator-5554 device transport_id:1', + ].join('\n') + ) + ).toEqual([ + { + serial: 'emulator-5554', + state: 'device', + metadata: ['transport_id:1'], + transportId: '1', + }, + ]); + }); +}); + +describe('ADB device state predicates', () => { + it('recognizes only the states Expo acts on without losing unknown values', () => { + expect(isAdbDeviceStateUsable('device')).toBe(true); + expect(isAdbDeviceStateUsable('authorizing')).toBe(false); + expect(isAdbDeviceStateUsable('future-adb-state')).toBe(false); + }); +}); diff --git a/packages/@expo/cli/src/start/platforms/android/__tests__/adbDiagnostics-test.ts b/packages/@expo/cli/src/start/platforms/android/__tests__/adbDiagnostics-test.ts new file mode 100644 index 00000000000000..7a0d29e8b2cf1e --- /dev/null +++ b/packages/@expo/cli/src/start/platforms/android/__tests__/adbDiagnostics-test.ts @@ -0,0 +1,91 @@ +import { formatAdbDeviceError, formatAdbDiscoveryError } from '../adbDiagnostics'; +import type { AdbEndpoint } from '../adbEndpoint'; +import { AdbProcessError } from '../adbProcess'; + +const localEndpoint: AdbEndpoint = { + type: 'tcp', + host: '127.0.0.1', + port: 5037, + scope: 'local', + source: 'default', +}; + +describe('ADB diagnostics', () => { + it('reports spawn failures with concise advice', () => { + const message = formatAdbDiscoveryError( + new AdbProcessError('spawn adb ENOENT', 'device discovery', 'host-request', undefined, true), + localEndpoint, + { kind: 'connection-refused' } + ); + + expect(message).toContain('spawn adb ENOENT'); + expect(message).toContain('Android SDK Platform-Tools'); + expect(message).not.toContain('tcp:127.0.0.1:5037'); + }); + + it('does not infer a cause from a successful post-failure host probe', () => { + const message = formatAdbDiscoveryError( + new AdbProcessError('device discovery failed', 'device discovery', 'host-request'), + localEndpoint, + { kind: 'version' } + ); + + expect(message).toBe('device discovery failed'); + }); + + it('identifies remote, unsupported, device-state, and discovery/use-race contexts', () => { + const message = formatAdbDeviceError(new Error('error: device not found'), { + pid: 'serial-1', + state: 'offline', + }); + expect(message).toContain('Reconnect device serial-1 and try again.'); + expect(message).not.toContain('disappeared after discovery'); + expect(message).not.toMatch(/kill-server|USB|driver/i); + + expect( + formatAdbDiscoveryError( + new Error('unsupported socket'), + { + type: 'unsupported', + specification: 'localabstract:adb', + source: 'ADB_SERVER_SOCKET', + }, + { kind: 'unsupported' } + ) + ).toContain('Check ADB_SERVER_SOCKET and try again'); + }); + + it('uses boot advice instead of USB reconnect advice for offline emulators', () => { + const message = formatAdbDeviceError(new Error('emulator is offline'), { + pid: 'emulator-5554', + state: 'offline', + type: 'emulator', + }); + + expect(message).toContain('Wait until ADB reports the emulator as ready'); + expect(message).not.toContain('Reconnect device emulator-5554'); + }); + + it.each([ + ['unauthorized', 'https://expo.fyi/authorize-android-device'], + ['no permissions', 'configure the appropriate udev rules'], + ['authorizing', 'Wait until ADB reports the device as ready'], + ['future-state', 'Wait until ADB reports the device as ready'], + ])('preserves %s state in actionable diagnostics', (state, expected) => { + expect(formatAdbDeviceError(new Error('transport unavailable'), { state })).toContain(expected); + }); + + it('reports unknown remote completion for a cancelled side effect', () => { + const cancellation = Object.assign(new Error('cancelled'), { remoteCompletionUnknown: true }); + const message = formatAdbDeviceError(cancellation, {}); + + expect(message).toContain('may have completed on the device'); + expect(message).toContain('Check before trying again'); + }); + + it('reports a device that is still authorizing as disconnected without a known state', () => { + const message = formatAdbDeviceError(new Error('error: device still authorizing'), {}); + + expect(message).toContain('The device disconnected. Reconnect it and try again.'); + }); +}); diff --git a/packages/@expo/cli/src/start/platforms/android/__tests__/adbEndpoint-test.ts b/packages/@expo/cli/src/start/platforms/android/__tests__/adbEndpoint-test.ts new file mode 100644 index 00000000000000..3bf0dc34bc183a --- /dev/null +++ b/packages/@expo/cli/src/start/platforms/android/__tests__/adbEndpoint-test.ts @@ -0,0 +1,272 @@ +import fs from 'node:fs'; +import net from 'node:net'; +import os from 'node:os'; +import path from 'node:path'; + +import { event } from '../../events'; +import { + ADB_HOST_PROBE_WAIT_LIMIT_MS, + formatAdbEndpoint, + parseAdbHostVersionResponse, + probeAdbHostVersionAsync, + resolveAdbEndpoint, +} from '../adbEndpoint'; + +jest.unmock('fs'); +jest.unmock('node:fs'); +jest.unmock('os'); +jest.unmock('node:os'); +jest.mock('../../events', () => ({ event: jest.fn() })); + +it('allows for the cold-start device scan before a host probe expires', () => { + expect(ADB_HOST_PROBE_WAIT_LIMIT_MS).toBe(4_000); +}); + +describe(resolveAdbEndpoint, () => { + it('uses ADB_SERVER_SOCKET before Android address and port variables', () => { + expect( + resolveAdbEndpoint({ + ADB_SERVER_SOCKET: 'tcp:localhost:5041', + ANDROID_ADB_SERVER_ADDRESS: '192.0.2.10', + ANDROID_ADB_SERVER_PORT: '5042', + }) + ).toEqual({ + type: 'tcp', + host: 'localhost', + port: 5041, + scope: 'local', + source: 'ADB_SERVER_SOCKET', + }); + }); + + it.each([ + [{}, { type: 'tcp', host: '127.0.0.1', port: 5037, scope: 'local', source: 'default' }], + [ + { ANDROID_ADB_SERVER_PORT: '5041' }, + { + type: 'tcp', + host: '127.0.0.1', + port: 5041, + scope: 'local', + source: 'ANDROID_ADB_SERVER_PORT', + }, + ], + [ + { ANDROID_ADB_SERVER_ADDRESS: '192.0.2.10' }, + { + type: 'tcp', + host: '192.0.2.10', + port: 5037, + scope: 'remote', + source: 'ANDROID_ADB_SERVER_ADDRESS', + }, + ], + [ + { ANDROID_ADB_SERVER_ADDRESS: '::1', ANDROID_ADB_SERVER_PORT: '5041' }, + { + type: 'tcp', + host: '::1', + port: 5041, + scope: 'local', + source: 'ANDROID_ADB_SERVER_ADDRESS', + }, + ], + ])('resolves Android address/port combination %#', (environment, endpoint) => { + expect(resolveAdbEndpoint(environment)).toEqual(endpoint); + }); + + it.each([ + ['tcp:5041', { type: 'tcp', host: '127.0.0.1', port: 5041, scope: 'local' }], + ['tcp:localhost:5041', { type: 'tcp', host: 'localhost', port: 5041, scope: 'local' }], + ['tcp:[::1]:5041', { type: 'tcp', host: '::1', port: 5041, scope: 'local' }], + ['tcp:192.0.2.10:5041', { type: 'tcp', host: '192.0.2.10', port: 5041, scope: 'remote' }], + ])('parses the socket specification %s', (specification, endpoint) => { + expect(resolveAdbEndpoint({ ADB_SERVER_SOCKET: specification })).toEqual({ + ...endpoint, + source: 'ADB_SERVER_SOCKET', + }); + }); + + it('parses local filesystem sockets and preserves unsupported specifications', () => { + expect(resolveAdbEndpoint({ ADB_SERVER_SOCKET: 'localfilesystem:/tmp/adb.sock' })).toEqual({ + type: 'local-filesystem', + path: '/tmp/adb.sock', + source: 'ADB_SERVER_SOCKET', + }); + expect(resolveAdbEndpoint({ ADB_SERVER_SOCKET: 'localabstract:adb' })).toEqual({ + type: 'unsupported', + specification: 'localabstract:adb', + source: 'ADB_SERVER_SOCKET', + }); + }); +}); + +describe(formatAdbEndpoint, () => { + it('brackets IPv6 addresses', () => { + expect( + formatAdbEndpoint({ + type: 'tcp', + host: '::1', + port: 5037, + scope: 'local', + source: 'default', + }) + ).toBe('tcp:[::1]:5037 (local, selected by default)'); + }); +}); + +describe('ADB smart-socket framing', () => { + it.each([ + [Buffer.from('OKAY00040029'), { kind: 'version' }], + [Buffer.from('FAIL0004nope'), { kind: 'adb-failure', message: 'nope' }], + [Buffer.from('NOPE'), { kind: 'invalid-protocol' }], + [Buffer.from('OKAY00'), { kind: 'incomplete' }], + [Buffer.from('OKAY000400'), { kind: 'incomplete' }], + ])('parses response fixture %#', (response, result) => { + expect(parseAdbHostVersionResponse(response as Buffer)).toEqual(result); + }); +}); + +describe(probeAdbHostVersionAsync, () => { + it('reads a complete host:version response across partial TCP packets', async () => { + const server = net.createServer((socket) => { + socket.once('data', () => { + socket.write('OKAY'); + setTimeout(() => socket.end('00040029'), 5); + }); + }); + const endpoint = await listenTcpAsync(server); + try { + await expect(probeAdbHostVersionAsync(endpoint, AbortSignal.timeout(500))).resolves.toEqual({ + kind: 'version', + }); + expect(event).toHaveBeenCalledWith('adb_host_probe', { + endpoint: expect.stringContaining(`tcp:${endpoint.host}:${endpoint.port}`), + result: 'version', + }); + } finally { + await closeServerAsync(server); + } + }); + + it('distinguishes an accepted connection with no complete reply', async () => { + const server = net.createServer(() => {}); + const endpoint = await listenTcpAsync(server); + try { + await expect(probeAdbHostVersionAsync(endpoint, AbortSignal.timeout(50))).resolves.toEqual({ + kind: 'connected-no-reply', + }); + } finally { + await closeServerAsync(server); + } + }); + + it('reports a listener that returns invalid ADB status bytes', async () => { + const server = net.createServer((socket) => socket.end('NOPE')); + const endpoint = await listenTcpAsync(server); + try { + await expect(probeAdbHostVersionAsync(endpoint, AbortSignal.timeout(500))).resolves.toEqual({ + kind: 'invalid-protocol', + }); + } finally { + await closeServerAsync(server); + } + }); + + it('distinguishes refusal and retries a newly appearing local listener after startup grace', async () => { + const reservation = net.createServer(); + const endpoint = await listenTcpAsync(reservation); + await closeServerAsync(reservation); + + await expect(probeAdbHostVersionAsync(endpoint, AbortSignal.timeout(500))).resolves.toEqual({ + kind: 'connection-refused', + }); + + const server = net.createServer((socket) => socket.end('OKAY00040029')); + trackServerConnections(server); + const startServer = setTimeout(() => server.listen(endpoint.port, endpoint.host), 20); + try { + await expect(probeAdbHostVersionAsync(endpoint, AbortSignal.timeout(500))).resolves.toEqual({ + kind: 'version', + }); + } finally { + clearTimeout(startServer); + await closeServerAsync(server); + } + }); + + it('propagates explicit caller cancellation while reading', async () => { + const server = net.createServer(() => {}); + const endpoint = await listenTcpAsync(server); + const controller = new AbortController(); + const reason = new Error('caller cancelled'); + const cancellation = setTimeout(() => controller.abort(reason), 20); + try { + await expect(probeAdbHostVersionAsync(endpoint, controller.signal)).rejects.toBe(reason); + } finally { + clearTimeout(cancellation); + await closeServerAsync(server); + } + }); + + const filesystemTest = process.platform === 'win32' ? it.skip : it; + filesystemTest('probes a selected local-filesystem socket', async () => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'expo-adb-endpoint-')); + const socketPath = path.join(directory, 'adb.sock'); + const server = net.createServer((socket) => socket.end('OKAY00040029')); + trackServerConnections(server); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, resolve); + }); + try { + await expect( + probeAdbHostVersionAsync( + { type: 'local-filesystem', path: socketPath, source: 'ADB_SERVER_SOCKET' }, + AbortSignal.timeout(500) + ) + ).resolves.toEqual({ kind: 'version' }); + } finally { + await closeServerAsync(server); + fs.rmSync(directory, { recursive: true, force: true }); + } + }); +}); + +async function listenTcpAsync(server: net.Server) { + trackServerConnections(server); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected a TCP fixture server address.'); + } + return { + type: 'tcp' as const, + host: address.address, + port: address.port, + scope: 'local' as const, + source: 'default' as const, + }; +} + +async function closeServerAsync(server: net.Server): Promise { + if (!server.listening) return; + for (const socket of serverConnections.get(server) ?? []) socket.destroy(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())) + ); +} + +const serverConnections = new WeakMap>(); + +function trackServerConnections(server: net.Server): void { + const sockets = new Set(); + serverConnections.set(server, sockets); + server.on('connection', (socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + }); +} diff --git a/packages/@expo/cli/src/start/platforms/android/__tests__/adbProcess-test.ts b/packages/@expo/cli/src/start/platforms/android/__tests__/adbProcess-test.ts new file mode 100644 index 00000000000000..c86d5797901034 --- /dev/null +++ b/packages/@expo/cli/src/start/platforms/android/__tests__/adbProcess-test.ts @@ -0,0 +1,206 @@ +import spawnAsync from '@expo/spawn-async'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { event } from '../../events'; +import { + AdbProcessWaitError, + runAdbDeviceMutationAsync, + runAdbDeviceQueryAsync, + runAdbHostQueryAsync, + runBoundedAdbHostQueryAsync, +} from '../adbProcess'; + +jest.mock('../../events', () => ({ event: jest.fn() })); +jest.unmock('child_process'); +jest.unmock('fs'); +jest.unmock('node:fs'); +jest.unmock('os'); +jest.unmock('node:os'); + +describe(runAdbDeviceQueryAsync, () => { + function createPendingSpawn({ ignoreTerm = false, ignoreKill = false } = {}) { + let reject!: (error: unknown) => void; + const promise = new Promise((_, rejectPromise) => { + reject = rejectPromise; + }) as any; + promise.child = { + kill: jest.fn((signal) => { + if ((signal === 'SIGKILL' && !ignoreKill) || (signal !== 'SIGKILL' && !ignoreTerm)) { + reject( + Object.assign(new Error(`killed with ${signal}`), { + signal, + status: null, + }) + ); + } + return true; + }), + }; + return promise; + } + + it('returns separate process output and exit data', async () => { + jest.mocked(spawnAsync).mockResolvedValueOnce({ + stdout: 'stdout', + stderr: 'stderr', + status: 0, + signal: null, + } as any); + + await expect(runAdbDeviceQueryAsync('adb', ['devices'], 'test command')).resolves.toMatchObject( + { + stdout: 'stdout', + stderr: 'stderr', + } + ); + expect(event).toHaveBeenCalledWith('adb_operation_start', { + operation: 'test command', + phase: 'device-service', + waitLimitMs: undefined, + }); + }); + + it('does not spawn a command when the caller signal is already aborted', async () => { + const controller = new AbortController(); + const reason = new Error('already cancelled'); + controller.abort(reason); + + await expect( + runAdbDeviceMutationAsync('adb', ['install'], 'app install', controller.signal) + ).rejects.toBe(reason); + expect(spawnAsync).not.toHaveBeenCalled(); + expect(event).not.toHaveBeenCalled(); + }); + + it('maps wait-policy expiry after observing graceful cleanup', async () => { + const pending = createPendingSpawn(); + jest.mocked(spawnAsync).mockReturnValueOnce(pending); + + const result = runBoundedAdbHostQueryAsync('adb', ['devices'], 'device discovery', 1); + await expect(result).rejects.toBeInstanceOf(AdbProcessWaitError); + expect(event).toHaveBeenCalledWith('adb_operation_cleanup', { + operation: 'device discovery', + phase: 'host-request', + reason: 'wait-limit', + status: 'terminated', + }); + }); + + it('includes subprocess cleanup in the bounded wait policy', async () => { + const pending = createPendingSpawn({ ignoreTerm: true }); + jest.mocked(spawnAsync).mockReturnValueOnce(pending); + const startedAt = Date.now(); + + await expect( + runBoundedAdbHostQueryAsync('adb', ['devices'], 'device discovery', 100) + ).rejects.toBeInstanceOf(AdbProcessWaitError); + expect(Date.now() - startedAt).toBeLessThan(200); + expect(pending.child.kill).toHaveBeenCalledWith('SIGKILL'); + }); + + it('preserves a caller timeout when a longer wait policy is configured', async () => { + const pending = createPendingSpawn(); + jest.mocked(spawnAsync).mockReturnValueOnce(pending); + const controller = new AbortController(); + const reason = new DOMException('caller timed out', 'TimeoutError'); + + const result = runBoundedAdbHostQueryAsync( + 'adb', + ['devices'], + 'device discovery', + 10_000, + controller.signal + ); + controller.abort(reason); + + await expect(result).rejects.toBe(reason); + expect(event).toHaveBeenCalledWith('adb_operation_cleanup', { + operation: 'device discovery', + phase: 'host-request', + reason: 'cancelled', + status: 'terminated', + }); + }); + + it('reports when cleanup cannot observe child exit', async () => { + const pending = createPendingSpawn({ ignoreTerm: true, ignoreKill: true }); + jest.mocked(spawnAsync).mockReturnValueOnce(pending); + const controller = new AbortController(); + const result = runAdbHostQueryAsync('adb', ['devices'], 'device discovery', controller.signal); + + const reason = new Error('stop discovery'); + controller.abort(reason); + await expect(result).rejects.toBe(reason); + }); + + it('marks cancelled side-effecting operations as having unknown remote completion', async () => { + const pending = createPendingSpawn(); + jest.mocked(spawnAsync).mockReturnValueOnce(pending); + const controller = new AbortController(); + const result = runAdbDeviceMutationAsync('adb', ['install'], 'app install', controller.signal); + + const reason = new Error('stop install'); + controller.abort(reason); + await expect(result).rejects.toBe(reason); + expect(reason).toMatchObject({ remoteCompletionUnknown: true }); + }); + + it('distinguishes spawn and nonzero-exit failures', async () => { + jest.mocked(spawnAsync).mockRejectedValueOnce( + Object.assign(new Error('ENOENT'), { + status: null, + signal: null, + }) + ); + await expect(runAdbDeviceQueryAsync('adb', [], 'test command')).rejects.toMatchObject({ + spawnFailed: true, + }); + + jest.mocked(spawnAsync).mockRejectedValueOnce( + Object.assign(new Error('exit 1'), { + stdout: '', + stderr: 'failed', + status: 1, + signal: null, + }) + ); + await expect(runAdbDeviceQueryAsync('adb', [], 'test command')).rejects.toMatchObject({ + spawnFailed: undefined, + }); + }); +}); + +describe('subprocess cleanup integration', () => { + jest.setTimeout(10_000); + + it('drains both pipes, escalates where supported, and reaps after cancellation', async () => { + const signal = AbortSignal.timeout(300); + const error = await runFixture(signal); + + expect(error).toBe(signal.reason); + }); +}); + +async function runFixture(signal: AbortSignal): Promise { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'expo-adb-process-')); + const pidFile = path.join(directory, 'pid'); + const fixture = path.join(__dirname, 'fixtures', 'adb-process-child.js'); + try { + jest.mocked(spawnAsync).mockImplementationOnce(jest.requireActual('@expo/spawn-async')); + let error: unknown; + try { + await runAdbHostQueryAsync(process.execPath, [fixture, pidFile], 'fixture child', signal); + } catch (caught) { + error = caught; + } + + const pid = Number(fs.readFileSync(pidFile, 'utf8')); + expect(Number.isInteger(pid)).toBe(true); + expect(() => process.kill(pid, 0)).toThrow(); + return error; + } finally { + fs.rmSync(directory, { recursive: true, force: true }); + } +} diff --git a/packages/@expo/cli/src/start/platforms/android/__tests__/adbReverse-test.ts b/packages/@expo/cli/src/start/platforms/android/__tests__/adbReverse-test.ts index 720dc6bea3dc0d..50f77112cf8d8e 100644 --- a/packages/@expo/cli/src/start/platforms/android/__tests__/adbReverse-test.ts +++ b/packages/@expo/cli/src/start/platforms/android/__tests__/adbReverse-test.ts @@ -1,4 +1,5 @@ import * as Log from '../../../../log'; +import { installExitHooks } from '../../../../utils/exit'; import { getAttachedDevicesAsync, getServer } from '../adb'; import { startAdbReverseAsync, stopAdbReverseAsync } from '../adbReverse'; @@ -6,7 +7,7 @@ jest.mock('../../../../log'); jest.mock('../adb', () => { const actual = jest.requireActual('../adb'); const server = { - runAsync: jest.fn(async () => ''), + runDeviceMutationAsync: jest.fn(async () => ''), }; return { ...actual, @@ -19,6 +20,31 @@ jest.mock('../../../../utils/exit', () => ({ })); describe(startAdbReverseAsync, () => { + it('awaits and bounds best-effort cleanup in the exit hook', async () => { + jest.useFakeTimers(); + let exitHook: (() => void | Promise) | undefined; + jest.mocked(installExitHooks).mockImplementationOnce((hook) => { + exitHook = () => hook('SIGINT'); + return jest.fn(); + }); + jest + .mocked(getAttachedDevicesAsync) + .mockResolvedValueOnce([]) + .mockImplementationOnce( + ({ signal } = {}) => + new Promise((_, reject) => { + signal!.addEventListener('abort', () => reject(signal!.reason), { once: true }); + }) + ); + + await expect(startAdbReverseAsync([3000])).resolves.toBe(true); + const cleanup = exitHook!(); + expect(cleanup).toBeInstanceOf(Promise); + await jest.advanceTimersByTimeAsync(2_000); + await expect(cleanup).resolves.toBeUndefined(); + jest.useRealTimers(); + }); + it(`reverses devices`, async () => { jest.mocked(getAttachedDevicesAsync).mockResolvedValueOnce([ { @@ -38,14 +64,14 @@ describe(startAdbReverseAsync, () => { ]); await expect(startAdbReverseAsync([3000])).resolves.toBe(true); - expect(getServer().runAsync).toHaveBeenCalledTimes(2); - expect(getServer().runAsync).toHaveBeenNthCalledWith(1, [ - '-s', - 'FA8251A00720', - 'reverse', - 'tcp:3000', - 'tcp:3000', - ]); + expect(getServer().runDeviceMutationAsync).toHaveBeenCalledTimes(2); + expect(getServer().runDeviceMutationAsync).toHaveBeenNthCalledWith( + 1, + ['-s', 'FA8251A00720', 'reverse', 'tcp:3000', 'tcp:3000'], + 'reverse port', + expect.any(AbortSignal), + 2_000 + ); }); it(`reverses multiple ports`, async () => { jest.mocked(getAttachedDevicesAsync).mockResolvedValueOnce([ @@ -59,14 +85,14 @@ describe(startAdbReverseAsync, () => { ]); await expect(startAdbReverseAsync([3000, 3001])).resolves.toBe(true); - expect(getServer().runAsync).toHaveBeenCalledTimes(2); - expect(getServer().runAsync).toHaveBeenNthCalledWith(1, [ - '-s', - 'emulator-5554', - 'reverse', - 'tcp:3000', - 'tcp:3000', - ]); + expect(getServer().runDeviceMutationAsync).toHaveBeenCalledTimes(2); + expect(getServer().runDeviceMutationAsync).toHaveBeenNthCalledWith( + 1, + ['-s', 'emulator-5554', 'reverse', 'tcp:3000', 'tcp:3000'], + 'reverse port', + expect.any(AbortSignal), + 2_000 + ); }); it(`returns false when reversing a device that is unauthorized`, async () => { @@ -80,7 +106,7 @@ describe(startAdbReverseAsync, () => { }, ]); await expect(startAdbReverseAsync([3000])).resolves.toBe(false); - expect(getServer().runAsync).toHaveBeenCalledTimes(0); + expect(getServer().runDeviceMutationAsync).toHaveBeenCalledTimes(0); expect(Log.warn).toHaveBeenCalledTimes(1); }); @@ -94,11 +120,30 @@ describe(startAdbReverseAsync, () => { type: 'device', }, ]); - jest.mocked(getServer().runAsync).mockRejectedValueOnce(new Error('test')); + jest.mocked(getServer().runDeviceMutationAsync).mockRejectedValueOnce(new Error('test')); await expect(startAdbReverseAsync([3000])).resolves.toBe(false); - expect(getServer().runAsync).toHaveBeenCalledTimes(1); + expect(getServer().runDeviceMutationAsync).toHaveBeenCalledTimes(1); expect(Log.warn).toHaveBeenCalledTimes(1); }); + + it('preserves caller cancellation when reversing a port', async () => { + const reason = new Error('cancel reverse'); + const controller = new AbortController(); + controller.abort(reason); + jest.mocked(getAttachedDevicesAsync).mockResolvedValueOnce([ + { + isAuthorized: true, + isBooted: true, + name: 'Pixel_2', + pid: 'FA8251A00720', + type: 'device', + }, + ]); + jest.mocked(getServer().runDeviceMutationAsync).mockRejectedValueOnce(reason); + + await expect(startAdbReverseAsync([3000], controller.signal)).rejects.toBe(reason); + expect(Log.warn).not.toHaveBeenCalled(); + }); }); describe(stopAdbReverseAsync, () => { @@ -121,13 +166,31 @@ describe(stopAdbReverseAsync, () => { }, ]); await stopAdbReverseAsync([3000]); - expect(getServer().runAsync).toHaveBeenCalledTimes(2); - expect(getServer().runAsync).toHaveBeenNthCalledWith(1, [ - '-s', - 'FA8251A00720', - 'reverse', - '--remove', - 'tcp:3000', + expect(getServer().runDeviceMutationAsync).toHaveBeenCalledTimes(2); + expect(getServer().runDeviceMutationAsync).toHaveBeenNthCalledWith( + 1, + ['-s', 'FA8251A00720', 'reverse', '--remove', 'tcp:3000'], + 'remove reverse port', + expect.any(AbortSignal), + 2_000 + ); + }); + + it('preserves caller cancellation when removing a reversed port', async () => { + const reason = new Error('cancel reverse cleanup'); + const controller = new AbortController(); + controller.abort(reason); + jest.mocked(getAttachedDevicesAsync).mockResolvedValueOnce([ + { + isAuthorized: true, + isBooted: true, + name: 'Pixel_2', + pid: 'FA8251A00720', + type: 'device', + }, ]); + jest.mocked(getServer().runDeviceMutationAsync).mockRejectedValueOnce(reason); + + await expect(stopAdbReverseAsync([3000], controller.signal)).rejects.toBe(reason); }); }); diff --git a/packages/@expo/cli/src/start/platforms/android/__tests__/emulator-test.ts b/packages/@expo/cli/src/start/platforms/android/__tests__/emulator-test.ts index 979bb6d683d103..1c8d2195b59154 100644 --- a/packages/@expo/cli/src/start/platforms/android/__tests__/emulator-test.ts +++ b/packages/@expo/cli/src/start/platforms/android/__tests__/emulator-test.ts @@ -2,6 +2,7 @@ import spawnAsync from '@expo/spawn-async'; import { spawn } from 'child_process'; import * as ADB from '../adb'; +import { AdbProcessWaitError } from '../adbProcess'; import { listAvdsAsync, startDeviceAsync } from '../emulator'; jest.mock('../../../../log'); @@ -20,16 +21,31 @@ describe(listAvdsAsync, () => { } as any); await expect(listAvdsAsync()).resolves.toStrictEqual([ - { isAuthorized: true, isBooted: false, name: 'avd1', type: 'emulator' }, - { isAuthorized: true, isBooted: false, name: 'avd2', type: 'emulator' }, + { + isAuthorized: true, + isBooted: false, + isLaunchable: true, + name: 'avd1', + type: 'emulator', + }, + { + isAuthorized: true, + isBooted: false, + isLaunchable: true, + name: 'avd2', + type: 'emulator', + }, ]); }); - it(`returns an empty list when emulator fails`, async () => { - jest.mocked(spawnAsync).mockRejectedValueOnce({ - stderr: 'err', - } as any); + it(`preserves emulator tool failures`, async () => { + jest + .mocked(spawnAsync) + .mockRejectedValueOnce(Object.assign(new Error('emulator failed'), { stderr: 'err' })); - await expect(listAvdsAsync()).resolves.toStrictEqual([]); + await expect(listAvdsAsync()).rejects.toMatchObject({ + message: 'emulator failed', + stderr: 'err', + }); }); }); @@ -41,6 +57,7 @@ describe(startDeviceAsync, () => { jest.mocked(spawn).mockReturnValueOnce({ unref: jest.fn(), on: jest.fn(), + off: jest.fn(), }); await expect(startDeviceAsync({ name: 'foo' }, { timeout: 5 })).rejects.toThrow( @@ -63,6 +80,7 @@ describe(startDeviceAsync, () => { jest.mocked(spawn).mockReturnValueOnce({ unref: jest.fn(), on: jest.fn(), + off: jest.fn(), }); await expect( @@ -71,4 +89,122 @@ describe(startDeviceAsync, () => { name: 'foo', }); }); + + it('retries a transient boot property failure', async () => { + jest.mocked(ADB.getAttachedDevicesAsync).mockResolvedValue([ + // @ts-expect-error + { name: 'foo', pid: 'emulator-5554' }, + ]); + jest + .mocked(ADB.isBootAnimationCompleteAsync) + .mockRejectedValueOnce( + new AdbProcessWaitError( + 'property query timed out', + 'device property/boot query', + 'device-service' + ) + ) + .mockResolvedValueOnce(true); + // @ts-expect-error + jest.mocked(spawn).mockReturnValueOnce({ unref: jest.fn(), on: jest.fn(), off: jest.fn() }); + + await expect( + startDeviceAsync({ name: 'foo' }, { timeout: 500, interval: 1 }) + ).resolves.toMatchObject({ name: 'foo' }); + expect(ADB.isBootAnimationCompleteAsync).toHaveBeenCalledTimes(2); + }); + + it('preserves a permanent boot property failure', async () => { + const failure = new Error('getprop is unavailable'); + jest.mocked(ADB.getAttachedDevicesAsync).mockResolvedValue([ + // @ts-expect-error + { name: 'foo', pid: 'emulator-5554' }, + ]); + jest.mocked(ADB.isBootAnimationCompleteAsync).mockRejectedValueOnce(failure); + // @ts-expect-error + jest.mocked(spawn).mockReturnValueOnce({ unref: jest.fn(), on: jest.fn(), off: jest.fn() }); + + await expect(startDeviceAsync({ name: 'foo' })).rejects.toBe(failure); + expect(ADB.isBootAnimationCompleteAsync).toHaveBeenCalledTimes(1); + }); + + it('keeps boot checks single-flight while an earlier attempt is pending', async () => { + let resolveFirstCheck!: (devices: ADB.Device[]) => void; + jest + .mocked(ADB.getAttachedDevicesAsync) + .mockImplementationOnce( + () => new Promise((resolve) => (resolveFirstCheck = resolve)) + ) + .mockResolvedValue([]); + // @ts-expect-error + jest.mocked(spawn).mockReturnValueOnce({ + unref: jest.fn(), + on: jest.fn(), + off: jest.fn(), + }); + + const result = startDeviceAsync({ name: 'foo' }, { timeout: 50, interval: 5 }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(ADB.getAttachedDevicesAsync).toHaveBeenCalledTimes(1); + expect(ADB.getAttachedDevicesAsync).toHaveBeenCalledWith({ + signal: expect.any(AbortSignal), + shouldShowWaitingMessage: false, + }); + + resolveFirstCheck([]); + await expect(result).rejects.toThrow(/It took too long/); + }); + + it('stops immediately on an underlying discovery failure', async () => { + const failure = new Error('ADB discovery failed'); + jest.mocked(ADB.getAttachedDevicesAsync).mockRejectedValueOnce(failure); + // @ts-expect-error + jest.mocked(spawn).mockReturnValueOnce({ unref: jest.fn(), on: jest.fn(), off: jest.fn() }); + + await expect(startDeviceAsync({ name: 'foo' })).rejects.toBe(failure); + }); + + it('threads explicit caller cancellation through a pending discovery check', async () => { + jest + .mocked(ADB.getAttachedDevicesAsync) + .mockImplementationOnce( + ({ signal } = {}) => + new Promise((_, reject) => + signal!.addEventListener('abort', () => reject(signal!.reason), { once: true }) + ) + ); + // @ts-expect-error + jest.mocked(spawn).mockReturnValueOnce({ unref: jest.fn(), on: jest.fn(), off: jest.fn() }); + const controller = new AbortController(); + const reason = new Error('caller cancelled'); + const result = startDeviceAsync({ name: 'foo' }, { signal: controller.signal }); + + controller.abort(reason); + await expect(result).rejects.toBe(reason); + }); + + it('reports emulator process exit instead of a boot timeout and disposes listeners', async () => { + const handlers = new Map void>(); + const child = { + unref: jest.fn(), + on: jest.fn((event: string, handler: () => void) => handlers.set(event, handler)), + off: jest.fn(), + }; + jest + .mocked(ADB.getAttachedDevicesAsync) + .mockImplementationOnce( + ({ signal } = {}) => + new Promise((_, reject) => + signal!.addEventListener('abort', () => reject(signal!.reason), { once: true }) + ) + ); + // @ts-expect-error + jest.mocked(spawn).mockReturnValueOnce(child); + const result = startDeviceAsync({ name: 'foo' }); + + handlers.get('exit')!(); + await expect(result).rejects.toThrow('quit before it finished opening'); + expect(child.off).toHaveBeenCalledWith('error', handlers.get('error')); + expect(child.off).toHaveBeenCalledWith('exit', handlers.get('exit')); + }); }); diff --git a/packages/@expo/cli/src/start/platforms/android/__tests__/fixtures/adb-cold-start-failure.js b/packages/@expo/cli/src/start/platforms/android/__tests__/fixtures/adb-cold-start-failure.js new file mode 100644 index 00000000000000..f1a8d6b164368b --- /dev/null +++ b/packages/@expo/cli/src/start/platforms/android/__tests__/fixtures/adb-cold-start-failure.js @@ -0,0 +1,23 @@ +const fs = require('node:fs'); + +const attemptFile = process.argv[2]; +const failingAttempts = Number(process.argv[3]); + +let attempt = 0; +try { + attempt = Number(fs.readFileSync(attemptFile, 'utf8')) || 0; +} catch { + attempt = 0; +} +fs.writeFileSync(attemptFile, String(++attempt)); + +if (attempt <= failingAttempts) { + process.stderr.write( + '* daemon not running; starting now at tcp:5037\n' + + 'adb: failed to check server version: cannot connect to daemon at tcp:5037: Connection refused\n' + ); + process.exitCode = 1; +} else { + process.stdout.write('List of devices attached\nUSB-1 device usb:1 model:Pixel transport_id:4\n'); + process.stderr.write('* daemon started successfully\n'); +} diff --git a/packages/@expo/cli/src/start/platforms/android/__tests__/fixtures/adb-cold-start.js b/packages/@expo/cli/src/start/platforms/android/__tests__/fixtures/adb-cold-start.js new file mode 100644 index 00000000000000..dbfa5646a10918 --- /dev/null +++ b/packages/@expo/cli/src/start/platforms/android/__tests__/fixtures/adb-cold-start.js @@ -0,0 +1,6 @@ +process.stdout.write('List of devices attached\nUSB-1 device usb:1 model:Pixel transport_id:4\n'); +process.stderr.write( + '* daemon not running; starting now at tcp:5037\n' + + '* daemon started successfully\n' + + 'adb D adb_client.cpp:393] adb_query: host:devices-l\n' +); diff --git a/packages/@expo/cli/src/start/platforms/android/__tests__/fixtures/adb-hang.js b/packages/@expo/cli/src/start/platforms/android/__tests__/fixtures/adb-hang.js new file mode 100644 index 00000000000000..9e041c5e1253ff --- /dev/null +++ b/packages/@expo/cli/src/start/platforms/android/__tests__/fixtures/adb-hang.js @@ -0,0 +1,2 @@ +process.on('SIGTERM', () => {}); +setInterval(() => {}, 1_000); diff --git a/packages/@expo/cli/src/start/platforms/android/__tests__/fixtures/adb-long-command.js b/packages/@expo/cli/src/start/platforms/android/__tests__/fixtures/adb-long-command.js new file mode 100644 index 00000000000000..c381006158d3b7 --- /dev/null +++ b/packages/@expo/cli/src/start/platforms/android/__tests__/fixtures/adb-long-command.js @@ -0,0 +1,10 @@ +const mode = process.argv[2]; + +if (mode === 'finite') { + setTimeout(() => process.stdout.write('completed'), 150); +} else if (mode === 'stream') { + setInterval(() => process.stdout.write('tick\n'), 10); +} else { + process.stderr.write(`unknown fixture mode: ${mode}`); + process.exitCode = 1; +} diff --git a/packages/@expo/cli/src/start/platforms/android/__tests__/fixtures/adb-process-child.js b/packages/@expo/cli/src/start/platforms/android/__tests__/fixtures/adb-process-child.js new file mode 100644 index 00000000000000..a5a59933d4add4 --- /dev/null +++ b/packages/@expo/cli/src/start/platforms/android/__tests__/fixtures/adb-process-child.js @@ -0,0 +1,17 @@ +const fs = require('node:fs'); + +const pidFile = process.argv[2]; +fs.writeFileSync(pidFile, String(process.pid)); + +process.on('SIGTERM', () => { + // Exercise the runner's escalation path on platforms with catchable SIGTERM. +}); + +const output = Buffer.alloc(64 * 1024, 'o'); +const errorOutput = Buffer.alloc(64 * 1024, 'e'); +for (let index = 0; index < 32; index++) { + process.stdout.write(output); + process.stderr.write(errorOutput); +} + +setInterval(() => {}, 1_000); diff --git a/packages/@expo/cli/src/start/platforms/android/__tests__/getDevices-test.ts b/packages/@expo/cli/src/start/platforms/android/__tests__/getDevices-test.ts index 762e095c08b9d7..47c5ce05ea9f8c 100644 --- a/packages/@expo/cli/src/start/platforms/android/__tests__/getDevices-test.ts +++ b/packages/@expo/cli/src/start/platforms/android/__tests__/getDevices-test.ts @@ -1,19 +1,69 @@ -import { CommandError } from '../../../../utils/errors'; -import { getAttachedDevicesAsync } from '../adb'; +import { waitForAttachedDevicesAsync } from '../adb'; import { listAvdsAsync } from '../emulator'; -import { getDevicesAsync } from '../getDevices'; +import { getDevicesAsync, mergeDevices } from '../getDevices'; jest.mock('../adb', () => ({ - getAttachedDevicesAsync: jest.fn(), + waitForAttachedDevicesAsync: jest.fn(), })); jest.mock('../emulator', () => ({ listAvdsAsync: jest.fn(), })); it(`asserts no devices are available`, async () => { - jest.mocked(getAttachedDevicesAsync).mockResolvedValueOnce([]); + jest.mocked(waitForAttachedDevicesAsync).mockResolvedValueOnce([]); jest.mocked(listAvdsAsync).mockResolvedValueOnce([]); - await expect(getDevicesAsync()).rejects.toThrow(CommandError); - expect(getAttachedDevicesAsync).toHaveBeenCalled(); + await expect(getDevicesAsync()).rejects.toMatchObject({ + code: 'ANDROID_NO_DEVICES', + }); + expect(waitForAttachedDevicesAsync).toHaveBeenCalled(); expect(listAvdsAsync).toHaveBeenCalled(); }); + +it('adds device setup guidance to AVD inventory tool failures', async () => { + jest.mocked(waitForAttachedDevicesAsync).mockResolvedValueOnce([]); + jest.mocked(listAvdsAsync).mockRejectedValueOnce(new Error('emulator -list-avds failed')); + + await expect(getDevicesAsync()).rejects.toThrow( + /No Android connected device found[\s\S]*emulator -list-avds failed[\s\S]*Connect a device or create an emulator/ + ); +}); + +it('preserves attached devices when AVD inventory is unavailable', async () => { + const device = { + name: 'Pixel USB', + pid: 'serial-1', + type: 'device' as const, + isBooted: true, + isAuthorized: true, + }; + jest.mocked(waitForAttachedDevicesAsync).mockResolvedValueOnce([device]); + jest.mocked(listAvdsAsync).mockRejectedValueOnce(new Error('emulator -list-avds failed')); + + await expect(getDevicesAsync()).resolves.toEqual([device]); +}); + +describe(mergeDevices, () => { + const avd = (name: string) => ({ + name, + type: 'emulator' as const, + isBooted: false, + isAuthorized: true, + isLaunchable: true, + }); + + it('preserves attached-device ordering and appends absent AVDs', () => { + const physical = { + name: 'Pixel USB', + pid: 'serial-1', + type: 'device' as const, + isBooted: true, + isAuthorized: true, + connectionType: 'USB' as const, + }; + const attachedAvd = { ...avd('Pixel_API_35'), pid: 'emulator-5554', isBooted: true }; + + expect( + mergeDevices([physical, attachedAvd], [avd('Pixel_API_35'), avd('Tablet_API_35')]) + ).toEqual([physical, attachedAvd, avd('Tablet_API_35')]); + }); +}); diff --git a/packages/@expo/cli/src/start/platforms/android/adb.ts b/packages/@expo/cli/src/start/platforms/android/adb.ts index aacc4895d6771d..c3b18f555e3e39 100644 --- a/packages/@expo/cli/src/start/platforms/android/adb.ts +++ b/packages/@expo/cli/src/start/platforms/android/adb.ts @@ -1,12 +1,25 @@ import chalk from 'chalk'; -import os from 'os'; import * as Log from '../../../log'; import { env } from '../../../utils/env'; import { CommandError } from '../../../utils/errors'; +import { isInteractive } from '../../../utils/interactive'; import { learnMore } from '../../../utils/link'; +import { ora } from '../../../utils/ora'; import { event } from '../events'; import { ADBServer } from './ADBServer'; +import { isAdbDeviceStateUsable, parseAdbDeviceList } from './adbDeviceList'; +import { + createAdbOperationError, + formatAdbDiscoveryError, + shouldProbeAdbHost, +} from './adbDiagnostics'; +import { + ADB_HOST_PROBE_WAIT_LIMIT_MS, + AdbHostProbeResult, + probeAdbHostVersionAsync, + resolveAdbEndpoint, +} from './adbEndpoint'; export enum DeviceABI { // The arch specific android target platforms are soft-deprecated. @@ -38,6 +51,12 @@ export type Device = { isAuthorized: boolean; /** The connection type to ADB, only available when `type: device` */ connectionType?: 'USB' | 'Network'; + /** Raw state reported by ADB. Absent for an AVD that is not attached. */ + state?: string; + /** Stable transport identity reported by `adb devices -l`, when available. */ + transportId?: string; + /** Whether Expo may launch this inventory record with `emulator @name`. */ + isLaunchable?: boolean; }; type DeviceContext = Pick; @@ -53,6 +72,9 @@ const PROP_CPU_ABI_LIST_NAME = 'ro.product.cpu.abilist'; // Can sometimes be null // http://developer.android.com/ndk/guides/abis.html const PROP_BOOT_ANIMATION_STATE = 'init.svc.bootanim'; +const DEVICE_DISCOVERY_WAIT_LIMIT_MS = 10_000; +const DEVICE_DISCOVERY_RETRY_DELAY_MS = 200; +const DEVICE_DISCOVERY_RETRY_WINDOW_MS = 2_000; let _server: ADBServer | null; @@ -74,10 +96,13 @@ export function logUnauthorized(device: Device) { /** Returns true if the provided package name is installed on the provided Android device. */ export async function isPackageInstalledAsync( device: DeviceContext, - androidPackage: string + androidPackage: string, + signal?: AbortSignal ): Promise { - const packages = await getServer().runAsync( - adbShellArgs(device.pid, 'pm', 'list', 'packages', '--user', env.EXPO_ADB_USER, androidPackage) + const packages = await getServer().runDeviceQueryAsync( + adbShellArgs(device.pid, 'pm', 'list', 'packages', '--user', env.EXPO_ADB_USER, androidPackage), + 'package query', + signal ); const lines = packages.split(/\r?\n/); @@ -103,7 +128,8 @@ export async function launchActivityAsync( }: { launchActivity: string; url?: string; - } + }, + signal?: AbortSignal ) { const command: string[] = [ 'am', @@ -120,7 +146,7 @@ export async function launchActivityAsync( command.push('-d', url); } - return openAsync(adbShellArgs(device.pid, ...command)); + return openAsync(adbShellArgs(device.pid, ...command), 'activity launch', signal); } /** @@ -133,48 +159,92 @@ export async function openUrlAsync( url, }: { url: string; - } + }, + signal?: AbortSignal ) { return openAsync( - adbShellArgs(device.pid, 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', url) + adbShellArgs(device.pid, 'am', 'start', '-a', 'android.intent.action.VIEW', '-d', url), + 'URL launch', + signal ); } /** Runs a generic command watches for common errors in order to throw with an expected code. */ -async function openAsync(args: string[]): Promise { - const results = await getServer().runAsync(args); - if ( - results.includes(CANT_START_ACTIVITY_ERROR) || - results.match(/Error: Activity class .* does not exist\./g) - ) { - throw new CommandError('APP_NOT_INSTALLED', results.substring(results.indexOf('Error: '))); +async function openAsync(args: string[], operation: string, signal?: AbortSignal): Promise { + let results: string; + try { + results = await getServer().runDeviceMutationAsync(args, operation, signal); + } catch (error) { + const output = + error && typeof error === 'object' + ? [ + 'stdout' in error ? error.stdout : undefined, + 'stderr' in error ? error.stderr : undefined, + error instanceof Error ? error.message : undefined, + ] + .filter((value): value is string => typeof value === 'string') + .join('\n') + : String(error); + if (isMissingActivityOutput(output)) { + throw new CommandError('APP_NOT_INSTALLED', extractActivityError(output)); + } + throw error; + } + if (isMissingActivityOutput(results)) { + throw new CommandError('APP_NOT_INSTALLED', extractActivityError(results)); } return results; } +function isMissingActivityOutput(output: string): boolean { + return ( + output.includes(CANT_START_ACTIVITY_ERROR) || + /Error: Activity class .* does not exist\./.test(output) + ); +} + +function extractActivityError(output: string): string { + const errorIndex = output.indexOf('Error: '); + return errorIndex >= 0 ? output.substring(errorIndex) : output; +} + /** Uninstall an app given its Android package name. */ export async function uninstallAsync( device: DeviceContext, - { appId }: { appId: string } + { appId }: { appId: string }, + signal?: AbortSignal ): Promise { - return await getServer().runAsync( - adbArgs(device.pid, 'uninstall', '--user', env.EXPO_ADB_USER, appId) + return await getServer().runDeviceMutationAsync( + adbArgs(device.pid, 'uninstall', '--user', env.EXPO_ADB_USER, appId), + 'app uninstall', + signal ); } /** Get package info from an app based on its Android package name. */ export async function getPackageInfoAsync( device: DeviceContext, - { appId }: { appId: string } + { appId }: { appId: string }, + signal?: AbortSignal ): Promise { - return await getServer().runAsync(adbShellArgs(device.pid, 'dumpsys', 'package', appId)); + return await getServer().runDeviceQueryAsync( + adbShellArgs(device.pid, 'dumpsys', 'package', appId), + 'package info query', + signal + ); } /** Install an app on a connected device. */ -export async function installAsync(device: DeviceContext, { filePath }: { filePath: string }) { +export async function installAsync( + device: DeviceContext, + { filePath }: { filePath: string }, + signal?: AbortSignal +) { // TODO: Handle the `INSTALL_FAILED_INSUFFICIENT_STORAGE` error. - return await getServer().runAsync( - adbArgs(device.pid, 'install', '-r', '-d', '--user', env.EXPO_ADB_USER, filePath) + return await getServer().runDeviceMutationAsync( + adbArgs(device.pid, 'install', '-r', '-d', '--user', env.EXPO_ADB_USER, filePath), + 'app install', + signal ); } @@ -201,88 +271,189 @@ function shellQuote(value: string): string { } // TODO: This is very expensive for some operations. -export async function getAttachedDevicesAsync(): Promise { - const output = await getServer().runAsync(['devices', '-l']); +type GetAttachedDevicesOptions = { + server?: ADBServer; + signal?: AbortSignal; + waitLimitMs?: number; + probeWaitLimitMs?: number; + shouldShowWaitingMessage?: boolean; +}; - const splitItems = output - .trim() - .replace(/\n$/, '') - .split(os.EOL) - // Filter ADB trace logs from the output, e.g. - // adb D 03-06 15:25:53 63677 4018815 adb_client.cpp:393] adb_query: host:devices-l - // 03-04 12:29:44.557 16415 16415 D adb : commandline.cpp:1646 Using server socket: tcp:172.27.192.1:5037 - // 03-04 12:29:44.557 16415 16415 D adb : adb_client.cpp:160 _adb_connect: host:version - .filter((line) => !line.match(/\.cpp:[0-9]+/)); - - // First line is `"List of devices attached"`, remove it - // @ts-ignore: todo - const attachedDevices: { - props: string[]; - type: Device['type']; - isAuthorized: Device['isAuthorized']; - isBooted: Device['isBooted']; - connectionType?: Device['connectionType']; - }[] = splitItems - .slice(1, splitItems.length) - .map((line) => { - // unauthorized: ['FA8251A00719', 'unauthorized', 'usb:338690048X', 'transport_id:5'] - // authorized: ['FA8251A00719', 'device', 'usb:336592896X', 'product:walleye', 'model:Pixel_2', 'device:walleye', 'transport_id:4'] - // emulator: ['emulator-5554', 'offline', 'transport_id:1'] - const props = line.split(' ').filter(Boolean); - const type = line.includes('emulator') ? 'emulator' : 'device'; - - let connectionType; - if (type === 'device' && line.includes('usb:')) { - connectionType = 'USB'; - } else if (type === 'device' && line.includes('_adb-tls-connect.')) { - connectionType = 'Network'; - } +export async function getAttachedDevicesAsync( + options: GetAttachedDevicesOptions = {} +): Promise { + return getAttachedDevicesWithOptionsAsync(options, false); +} - const isBooted = type === 'emulator' || props[1] !== 'offline'; - const isAuthorized = - connectionType === 'Network' - ? line.includes('model:') // Network connected devices show `model:` when authorized - : props[1] !== 'unauthorized'; +export async function waitForAttachedDevicesAsync(): Promise { + return getAttachedDevicesWithOptionsAsync({ shouldShowWaitingMessage: true }, true); +} - return { props, type, isAuthorized, isBooted, connectionType }; - }) - .filter(({ props: [pid] }) => !!pid); - - const devicePromises = attachedDevices.map>(async (props) => { - const { - type, - props: [pid, ...deviceInfo], - isAuthorized, - isBooted, - } = props; - - let name: string | null = null; - - if (type === 'device') { - if (isAuthorized) { - // Possibly formatted like `model:Pixel_2` - // Transform to `Pixel_2` - const modelItem = deviceInfo.find((info) => info.includes('model:')); - if (modelItem) { - name = modelItem.replace('model:', ''); +async function getAttachedDevicesWithOptionsAsync( + { + server = getServer(), + signal, + waitLimitMs, + probeWaitLimitMs = ADB_HOST_PROBE_WAIT_LIMIT_MS, + shouldShowWaitingMessage = false, + }: GetAttachedDevicesOptions, + shouldRetryEmptyResult: boolean +): Promise { + const discoveryWaitLimitMs = waitLimitMs ?? DEVICE_DISCOVERY_WAIT_LIMIT_MS; + const discoveryDeadline = Date.now() + discoveryWaitLimitMs; + const retryDeadline = Math.min(discoveryDeadline, Date.now() + DEVICE_DISCOVERY_RETRY_WINDOW_MS); + const waitSignal = AbortSignal.timeout(discoveryWaitLimitMs); + const operationSignal = signal ? AbortSignal.any([signal, waitSignal]) : waitSignal; + const endpoint = resolveAdbEndpoint(); + const spinner = + shouldShowWaitingMessage && isInteractive() + ? ora('Waiting for ADB device discovery').start() + : null; + + let retryError: unknown; + try { + while (true) { + try { + const output = await server.runHostQueryAsync( + ['devices', '-l'], + 'device discovery', + signal, + Math.max(1, discoveryDeadline - Date.now()) + ); + const devices = await parseAttachedDevicesAsync(output, operationSignal); + if (devices.length || !shouldRetryEmptyResult) { + return devices; + } + retryError = undefined; + } catch (error) { + if (operationSignal.aborted && error === operationSignal.reason) throw error; + if (!shouldRetryAdbDiscovery(error)) { + throw error; } + retryError = error; } - // unauthorized devices don't have a name available to read - if (!name) { - // Device FA8251A00719 - name = `Device ${pid}`; + + const retryTimeRemaining = retryDeadline - Date.now(); + if (retryTimeRemaining <= 0) { + if (retryError) throw retryError; + return []; } - } else { - // Given an emulator pid, get the emulator name which can be used to start the emulator later. - name = (await getAdbNameForDeviceIdAsync({ pid })) ?? ''; + await waitForRetryAsync( + Math.min(DEVICE_DISCOVERY_RETRY_DELAY_MS, retryTimeRemaining), + operationSignal + ); + } + } catch (caughtError) { + // Caller cancellation is not a discovery failure, so skip the diagnostic probe + if (signal?.aborted && caughtError === signal.reason) { + throw caughtError; } - return props.connectionType - ? { pid, name, type, isAuthorized, isBooted, connectionType: props.connectionType } - : { pid, name, type, isAuthorized, isBooted }; - }); + const error = + retryError != null && operationSignal.aborted && caughtError === operationSignal.reason + ? retryError + : caughtError; + + let hostProbe: AdbHostProbeResult | undefined; + if (shouldProbeAdbHost(error) && !operationSignal.aborted) { + try { + const probeSignal = AbortSignal.any([ + operationSignal, + AbortSignal.timeout(probeWaitLimitMs), + ]); + hostProbe = await probeAdbHostVersionAsync(endpoint, probeSignal); + } catch (probeError) { + if (signal?.aborted && probeError === signal.reason) { + throw probeError; + } else if (!operationSignal.aborted) { + hostProbe = { + kind: 'connection-failure', + }; + } + } + } + throw new CommandError('ADB_DISCOVERY', formatAdbDiscoveryError(error, endpoint, hostProbe)); + } finally { + spinner?.stop(); + } +} + +async function parseAttachedDevicesAsync(output: string, signal: AbortSignal): Promise { + return Promise.all( + parseAdbDeviceList(output).map(async (record): Promise => { + // unauthorized: ['FA8251A00719', 'unauthorized', 'usb:338690048X', 'transport_id:5'] + // authorized: ['FA8251A00719', 'device', 'usb:336592896X', 'product:walleye', 'model:Pixel_2', 'device:walleye', 'transport_id:4'] + // emulator: ['emulator-5554', 'offline', 'transport_id:1'] + const type: Device['type'] = /^emulator-\d+$/.test(record.serial) ? 'emulator' : 'device'; + + const connectionType: Device['connectionType'] = + type !== 'device' + ? undefined + : record.metadata.some((field) => field.startsWith('usb:')) + ? 'USB' + : record.serial.includes('_adb-tls-connect.') + ? 'Network' + : undefined; + + const { serial: pid, state, transportId, metadata: deviceInfo } = record; + const isUsable = isAdbDeviceStateUsable(state); + + let name: string; + if (type === 'device') { + // Unauthorized devices do not report model metadata. + const model = isUsable + ? deviceInfo.find((field) => field.startsWith('model:'))?.slice('model:'.length) + : undefined; + name = model || `Device ${pid}`; + } else { + // Resolve names in every emulator state so booting transports merge with AVD inventory. + // A broken emulator console must not discard otherwise healthy attached devices. + try { + name = (await getAdbNameForDeviceIdAsync({ pid }, signal)) || `Device ${pid}`; + } catch (error) { + if (signal.aborted && error === signal.reason) throw error; + name = `Device ${pid}`; + } + } + + return { + pid, + name, + type, + isAuthorized: isUsable, + isBooted: isUsable, + isLaunchable: false, + state, + transportId, + connectionType, + }; + }) + ); +} - return Promise.all(devicePromises); +function shouldRetryAdbDiscovery(error: unknown): boolean { + return ( + !(error instanceof Error && error.name === 'AbortError') && + error instanceof Error && + /(?:cannot connect|connection refused|server didn't ACK|daemon (?:not running|still not running)|smartsocket)/i.test( + error.message + ) + ); +} + +function waitForRetryAsync(delayMs: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(signal.reason); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + signal.removeEventListener('abort', handleAbort); + resolve(); + }, delayMs); + const handleAbort = () => { + clearTimeout(timeout); + reject(signal.reason); + }; + signal.addEventListener('abort', handleAbort, { once: true }); + }); } /** @@ -290,8 +461,15 @@ export async function getAttachedDevicesAsync(): Promise { * * @param device.pid a value like `emulator-5554` from `abd devices` */ -export async function getAdbNameForDeviceIdAsync(device: DeviceContext): Promise { - const results = await getServer().runAsync(adbArgs(device.pid, 'emu', 'avd', 'name')); +export async function getAdbNameForDeviceIdAsync( + device: DeviceContext, + signal?: AbortSignal +): Promise { + const results = await getServer().runDeviceQueryAsync( + adbArgs(device.pid, 'emu', 'avd', 'name'), + 'emulator name query', + signal + ); if (results.match(/could not connect to TCP port .*: Connection refused/)) { // Can also occur when the emulator does not exist. @@ -302,15 +480,14 @@ export async function getAdbNameForDeviceIdAsync(device: DeviceContext): Promise } export async function isDeviceBootedAsync({ + pid, name, -}: { name?: string } = {}): Promise { +}: Partial> = {}): Promise { const devices = await getAttachedDevicesAsync(); - if (!name) { - return devices[0] ?? null; - } - - return devices.find((device) => device.name === name) ?? null; + if (pid) return devices.find((device) => device.pid === pid) ?? null; + if (name) return devices.find((device) => device.name === name) ?? null; + return devices[0] ?? null; } /** @@ -319,13 +496,12 @@ export async function isDeviceBootedAsync({ * * @param pid */ -export async function isBootAnimationCompleteAsync(pid?: string): Promise { - try { - const props = await getPropertyDataForDeviceAsync({ pid }, PROP_BOOT_ANIMATION_STATE); - return !!props[PROP_BOOT_ANIMATION_STATE]?.match(/stopped/); - } catch { - return false; - } +export async function isBootAnimationCompleteAsync( + pid?: string, + signal?: AbortSignal +): Promise { + const props = await getPropertyDataForDeviceAsync({ pid }, PROP_BOOT_ANIMATION_STATE, signal); + return !!props[PROP_BOOT_ANIMATION_STATE]?.match(/stopped/); } /** Get a list of ABIs for the provided device. */ @@ -348,20 +524,26 @@ export async function getDeviceABIsAsync( export async function getPropertyDataForDeviceAsync( device: DeviceContext, - prop?: string + prop?: string, + signal?: AbortSignal ): Promise { const propCommand = prop ? adbShellArgs(device.pid, 'getprop', prop) : adbShellArgs(device.pid, 'getprop'); try { - // Prevent reading as UTF8. - const results = await getServer().getFileOutputAsync(propCommand); + const results = await getServer().getFileOutputAsync(propCommand, { + signal, + }); // Like: // [wifi.direct.interface]: [p2p-dev-wlan0] // [wifi.interface]: [wlan0] if (prop) { - event('adb_property_data', { devicePid: device.pid, prop, data: results }); + event('adb_property_data', { + devicePid: device.pid, + prop, + data: results, + }); return { [prop]: results, }; @@ -372,8 +554,7 @@ export async function getPropertyDataForDeviceAsync( return props; } catch (error: any) { - // TODO: Ensure error has message and not stderr - throw new CommandError(`Failed to get properties for device (${device.pid}): ${error.message}`); + throw createAdbOperationError('ADB_PROPERTY', error, device); } } diff --git a/packages/@expo/cli/src/start/platforms/android/adbDeviceList.ts b/packages/@expo/cli/src/start/platforms/android/adbDeviceList.ts new file mode 100644 index 00000000000000..d8a532578cc107 --- /dev/null +++ b/packages/@expo/cli/src/start/platforms/android/adbDeviceList.ts @@ -0,0 +1,43 @@ +export interface AdbDeviceRecord { + serial: string; + state: string; + metadata: string[]; + transportId: string | undefined; +} + +export function isAdbDeviceStateUsable(state: string): boolean { + return state === 'device'; +} + +const DEVICE_LIST_HEADER = 'List of devices attached'; + +export function parseAdbDeviceList(output: string): AdbDeviceRecord[] { + return output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && line !== DEVICE_LIST_HEADER && !/\.cpp:[0-9]+/.test(line)) + .map(parseDeviceLine) + .filter((record): record is AdbDeviceRecord => record != null); +} + +function parseDeviceLine(line: string): AdbDeviceRecord | null { + const match = line.match(/^(\S+)\s+(.+)$/); + if (!match) { + return null; + } + + const [, serial, remainder] = match; + const fields = remainder!.split(/\s+/); + const state = fields[0] === 'no' && fields[1] === 'permissions' ? 'no permissions' : fields[0]!; + const metadata = fields.slice(state === 'no permissions' ? 2 : 1); + const transportId = metadata + .find((field) => field.startsWith('transport_id:')) + ?.slice('transport_id:'.length); + + return { + serial: serial!, + state, + metadata, + transportId, + }; +} diff --git a/packages/@expo/cli/src/start/platforms/android/adbDiagnostics.ts b/packages/@expo/cli/src/start/platforms/android/adbDiagnostics.ts new file mode 100644 index 00000000000000..64b207272f3a1e --- /dev/null +++ b/packages/@expo/cli/src/start/platforms/android/adbDiagnostics.ts @@ -0,0 +1,125 @@ +import { CommandError } from '../../../utils/errors'; +import { formatAdbEndpoint } from './adbEndpoint'; +import type { AdbEndpoint, AdbHostProbeResult } from './adbEndpoint'; +import { AdbProcessError, AdbProcessWaitError } from './adbProcess'; + +export type AdbDeviceDiagnostic = { pid?: string; state?: string; type?: 'emulator' | 'device' }; + +function formatAdbError( + error: unknown, + endpoint?: AdbEndpoint, + hostProbe?: AdbHostProbeResult, + device?: AdbDeviceDiagnostic +): string { + const details: string[] = [error instanceof Error ? error.message : String(error)]; + + // NOTE(@kitten): Side-effect marker from `hasSideEffects` in ./adbProcess.ts + if ( + typeof error === 'object' && + error != null && + 'remoteCompletionUnknown' in error && + error.remoteCompletionUnknown === true + ) { + details.push('The operation may have completed on the device. Check before trying again.'); + } + + if (hostProbe && !(error instanceof AdbProcessError && error.spawnFailed)) { + const hostProbeAdvice = formatHostProbeAdvice(endpoint, hostProbe); + if (hostProbeAdvice) { + details.push(hostProbeAdvice); + } + } + + details.push(...getAdvice(error, device)); + return details.join('\n'); +} + +export function formatAdbDeviceError(error: unknown, device: AdbDeviceDiagnostic): string { + return formatAdbError(error, undefined, undefined, device); +} + +export function formatAdbDiscoveryError( + error: unknown, + endpoint: AdbEndpoint, + hostProbe?: AdbHostProbeResult +): string { + return formatAdbError(error, endpoint, hostProbe); +} + +export function shouldProbeAdbHost(error: unknown): boolean { + return ( + error instanceof AdbProcessWaitError || + (error instanceof Error && + /(?:cannot connect|connection refused|server didn't ACK|daemon (?:not running|still not running)|smartsocket)/i.test( + error.message + )) + ); +} + +export function createAdbOperationError( + code: string, + error: unknown, + device?: AdbDeviceDiagnostic +): CommandError { + const commandError = new CommandError(code, formatAdbError(error, undefined, undefined, device)); + commandError.cause = error; + return commandError; +} + +function formatHostProbeAdvice( + endpoint: AdbEndpoint | undefined, + result: AdbHostProbeResult +): string | null { + const formattedEndpoint = endpoint ? formatAdbEndpoint(endpoint) : 'the configured endpoint'; + switch (result.kind) { + case 'version': + // A successful probe after an operation failed cannot explain the earlier failure. + return null; + case 'connected-no-reply': + return `The ADB server at ${formattedEndpoint} is not responding. Check or restart that server, then try again.`; + case 'connection-refused': + case 'connection-failure': + return `Could not connect to the ADB server at ${formattedEndpoint}. Check the server configuration and try again.`; + case 'adb-failure': + return `The ADB server rejected Expo's health check: ${result.message}. Check the server configuration and try again.`; + case 'invalid-protocol': + return `The configured endpoint at ${formattedEndpoint} is not an ADB server. Check ADB_SERVER_SOCKET and try again.`; + case 'unsupported': + return 'Expo could not check the configured ADB server. Check ADB_SERVER_SOCKET and try again.'; + } +} + +function getAdvice(error: unknown, device?: AdbDeviceDiagnostic): string[] { + const advice: string[] = []; + if (error instanceof AdbProcessError && error.spawnFailed) { + advice.push('Check that Android SDK Platform-Tools is installed and ADB is available.'); + } + if (device?.state === 'offline' && device.type === 'emulator') { + advice.push('Wait until ADB reports the emulator as ready, then try again.'); + } else if (device?.state === 'offline') { + advice.push(`Reconnect ${device.pid ? `device ${device.pid}` : 'the device'} and try again.`); + } else if (device?.state === 'unauthorized') { + advice.push( + 'Authorize this computer on the device, then try again: https://expo.fyi/authorize-android-device' + ); + } else if (device?.state === 'no permissions') { + advice.push( + 'Set up permission to access the device, then reconnect it. On Linux, configure the appropriate udev rules; on Windows, install the appropriate USB driver: https://developer.android.com/studio/run/device.html' + ); + } else if (device?.state && device.state !== 'device') { + advice.push('Wait until ADB reports the device as ready, then try again.'); + } + if ((!device?.state || device.state === 'device') && isAdbDeviceDisconnectedError(error)) { + advice.push('The device disconnected. Reconnect it and try again.'); + } + return advice; +} + +export function isAdbDeviceDisconnectedError(error: unknown): boolean { + return ( + error instanceof Error && + /device (?:not found|offline|still authorizing)|no devices\/emulators found|transport (?:is closed|error)/i.test( + error.message + ) + ); +} diff --git a/packages/@expo/cli/src/start/platforms/android/adbEndpoint.ts b/packages/@expo/cli/src/start/platforms/android/adbEndpoint.ts new file mode 100644 index 00000000000000..c1bf0daabdc13e --- /dev/null +++ b/packages/@expo/cli/src/start/platforms/android/adbEndpoint.ts @@ -0,0 +1,282 @@ +import net from 'node:net'; +import { setTimeout as delayAsync } from 'node:timers/promises'; + +import { event } from '../events'; +import { isAdbTimeoutReason } from './adbProcess'; + +export const ADB_HOST_PROBE_WAIT_LIMIT_MS = 4_000; + +type AdbEndpointSource = + | 'ADB_SERVER_SOCKET' + | 'ANDROID_ADB_SERVER_ADDRESS' + | 'ANDROID_ADB_SERVER_PORT' + | 'default'; + +export type AdbEndpoint = + | { + type: 'tcp'; + host: string; + port: number; + scope: 'local' | 'remote'; + source: AdbEndpointSource; + } + | { + type: 'local-filesystem'; + path: string; + source: 'ADB_SERVER_SOCKET'; + } + | { + type: 'unsupported'; + specification: string; + source: 'ADB_SERVER_SOCKET'; + }; + +export type AdbHostProbeResult = + | { kind: 'connection-refused' } + | { kind: 'connection-failure' } + | { kind: 'connected-no-reply' } + | { kind: 'adb-failure'; message: string } + | { kind: 'invalid-protocol' } + | { kind: 'version' } + | { kind: 'unsupported' }; + +type AdbHostVersionFrame = + | Extract + | { kind: 'incomplete' }; + +const DEFAULT_ADB_HOST = '127.0.0.1'; +const DEFAULT_ADB_PORT = 5037; +const ADB_STARTUP_GRACE_MS = 250; + +export function formatAdbEndpoint(endpoint: AdbEndpoint): string { + switch (endpoint.type) { + case 'tcp': { + const host = net.isIP(endpoint.host) === 6 ? `[${endpoint.host}]` : endpoint.host; + return `tcp:${host}:${endpoint.port} (${endpoint.scope}, selected by ${endpoint.source})`; + } + case 'local-filesystem': + return `localfilesystem:${endpoint.path} (selected by ${endpoint.source})`; + case 'unsupported': + return `${endpoint.specification} (selected by ${endpoint.source}, direct probe unsupported)`; + } +} + +export function resolveAdbEndpoint( + environment: Readonly> = process.env +): AdbEndpoint { + const socket = environment.ADB_SERVER_SOCKET; + if (socket) { + return parseServerSocket(socket); + } + + const address = environment.ANDROID_ADB_SERVER_ADDRESS; + const port = parsePort(environment.ANDROID_ADB_SERVER_PORT) ?? DEFAULT_ADB_PORT; + const host = address || DEFAULT_ADB_HOST; + const source: AdbEndpointSource = address + ? 'ANDROID_ADB_SERVER_ADDRESS' + : environment.ANDROID_ADB_SERVER_PORT + ? 'ANDROID_ADB_SERVER_PORT' + : 'default'; + return tcpEndpoint(host, port, source); +} + +function encodeAdbHostRequest(service: string): Buffer { + const payload = Buffer.from(service, 'utf8'); + return Buffer.concat([Buffer.from(payload.length.toString(16).padStart(4, '0')), payload]); +} + +export function parseAdbHostVersionResponse(response: Buffer): AdbHostVersionFrame { + if (response.length < 4) { + return { kind: 'incomplete' }; + } + + const status = response.subarray(0, 4).toString('ascii'); + if (status !== 'OKAY' && status !== 'FAIL') { + return { + kind: 'invalid-protocol', + }; + } else if (response.length < 8) { + return { kind: 'incomplete' }; + } + + const lengthText = response.subarray(4, 8).toString('ascii'); + if (!/^[0-9a-fA-F]{4}$/.test(lengthText)) { + return { + kind: 'invalid-protocol', + }; + } + + const length = Number.parseInt(lengthText, 16); + if (response.length < 8 + length) { + return { kind: 'incomplete' }; + } + + const payload = response.subarray(8, 8 + length).toString('utf8'); + if (status === 'FAIL') { + return { kind: 'adb-failure', message: payload }; + } else if (!/^[0-9a-fA-F]+$/.test(payload)) { + return { + kind: 'invalid-protocol', + }; + } else { + return { kind: 'version' }; + } +} + +export async function probeAdbHostVersionAsync( + endpoint: AdbEndpoint, + signal: AbortSignal +): Promise { + function recordHostProbeResult(endpoint: AdbEndpoint, result: AdbHostProbeResult): void { + event('adb_host_probe', { + endpoint: formatAdbEndpoint(endpoint), + result: result.kind, + }); + } + + event('adb_operation_start', { + operation: 'host version probe', + phase: 'host-request', + waitLimitMs: ADB_HOST_PROBE_WAIT_LIMIT_MS, + }); + + if (endpoint.type === 'unsupported') { + const result = { kind: 'unsupported' } as const; + recordHostProbeResult(endpoint, result); + return result; + } + + let result = await probeEndpointOnceAsync(endpoint, signal); + // A new local server may briefly refuse connections while starting. + if ( + result.kind === 'connection-refused' && + (endpoint.type === 'local-filesystem' || endpoint.scope === 'local') && + !signal.aborted + ) { + await delayAsync(ADB_STARTUP_GRACE_MS, undefined, { signal }); + result = await probeEndpointOnceAsync(endpoint, signal); + } + + recordHostProbeResult(endpoint, result); + return result; +} + +async function probeEndpointOnceAsync( + endpoint: Exclude, + signal: AbortSignal +): Promise { + signal.throwIfAborted(); + const socket = + endpoint.type === 'tcp' + ? net.createConnection({ host: endpoint.host, port: endpoint.port }) + : net.createConnection(endpoint.path); + let connectionState: 'connecting' | 'connected' = 'connecting'; + let response = Buffer.alloc(0); + let handleConnect = () => {}; + let handleData = (_chunk: Buffer) => {}; + let handleError = (_error: NodeJS.ErrnoException) => {}; + let handleClose = () => {}; + let handleAbort = () => {}; + + try { + return await new Promise((resolve, reject) => { + handleConnect = () => { + connectionState = 'connected'; + socket.write(encodeAdbHostRequest('host:version')); + }; + handleData = (chunk: Buffer) => { + response = Buffer.concat([response, chunk]); + const frame = parseAdbHostVersionResponse(response); + if (frame.kind !== 'incomplete') { + resolve(frame); + } + }; + handleError = (error: NodeJS.ErrnoException) => { + resolve( + error.code === 'ECONNREFUSED' + ? { kind: 'connection-refused' } + : { kind: 'connection-failure' } + ); + }; + handleClose = () => { + resolve( + connectionState === 'connected' + ? { kind: 'connected-no-reply' } + : { kind: 'connection-failure' } + ); + }; + handleAbort = () => { + if (isAdbTimeoutReason(signal.reason) && connectionState === 'connected') { + resolve({ kind: 'connected-no-reply' }); + } else { + reject(signal.reason); + } + }; + + socket.once('connect', handleConnect); + socket.on('data', handleData); + socket.once('error', handleError); + socket.once('close', handleClose); + signal.addEventListener('abort', handleAbort, { once: true }); + }); + } finally { + signal.removeEventListener('abort', handleAbort); + socket.removeListener('connect', handleConnect); + socket.removeListener('data', handleData); + socket.removeListener('error', handleError); + socket.removeListener('close', handleClose); + socket.destroy(); + } +} + +function parseServerSocket(specification: string): AdbEndpoint { + if (specification.startsWith('localfilesystem:')) { + const path = specification.slice('localfilesystem:'.length); + return path + ? { type: 'local-filesystem', path, source: 'ADB_SERVER_SOCKET' } + : { type: 'unsupported', specification, source: 'ADB_SERVER_SOCKET' }; + } + if (specification.startsWith('tcp:')) { + const address = specification.slice('tcp:'.length); + const lastColon = address.lastIndexOf(':'); + if (lastColon < 0) { + const port = parsePort(address); + if (port != null) { + return tcpEndpoint(DEFAULT_ADB_HOST, port, 'ADB_SERVER_SOCKET'); + } + } else { + const host = stripIpv6Brackets(address.slice(0, lastColon)); + const port = parsePort(address.slice(lastColon + 1)); + if (host && port != null) { + return tcpEndpoint(host, port, 'ADB_SERVER_SOCKET'); + } + } + } + return { type: 'unsupported', specification, source: 'ADB_SERVER_SOCKET' }; +} + +function tcpEndpoint(host: string, port: number, source: AdbEndpointSource): AdbEndpoint { + return { + type: 'tcp', + host, + port, + scope: isLocalhost(host) ? 'local' : 'remote', + source, + }; +} + +function isLocalhost(host: string): boolean { + return ['localhost', '127.0.0.1', '::1'].includes(stripIpv6Brackets(host).toLowerCase()); +} + +function stripIpv6Brackets(host: string): string { + return host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host; +} + +function parsePort(value: string | undefined): number | null { + if (!value || !/^\d+$/.test(value)) { + return null; + } + const port = Number(value); + return port > 0 && port <= 65535 ? port : null; +} diff --git a/packages/@expo/cli/src/start/platforms/android/adbProcess.ts b/packages/@expo/cli/src/start/platforms/android/adbProcess.ts new file mode 100644 index 00000000000000..68caf7f54ac663 --- /dev/null +++ b/packages/@expo/cli/src/start/platforms/android/adbProcess.ts @@ -0,0 +1,282 @@ +import spawnAsync from '@expo/spawn-async'; + +import { event } from '../events'; + +type AdbProcessPhase = 'host-request' | 'device-service'; +type AdbClientTermination = 'terminated' | 'killed' | 'exit-unobserved'; + +export class AdbProcessError extends Error { + constructor( + message: string, + readonly operation: string, + readonly phase: AdbProcessPhase, + remoteCompletionUnknown?: true, + spawnFailed?: true + ) { + super(message); + this.remoteCompletionUnknown = remoteCompletionUnknown; + this.spawnFailed = spawnFailed; + } + + remoteCompletionUnknown?: boolean; + spawnFailed?: boolean; + stdout?: string; + stderr?: string; + status?: number; + signal?: NodeJS.Signals | null; +} + +export class AdbProcessWaitError extends AdbProcessError {} + +const ADB_SUBPROCESS_CLEANUP_WAIT_LIMIT_MS = 2_000; + +export const runAdbHostQueryAsync = ( + command: string, + args: string[], + operation: string, + signal?: AbortSignal +): Promise => + runAdbProcessAsync( + command, + args, + { operation, phase: 'host-request', hasSideEffects: false }, + signal + ); + +export const runBoundedAdbHostQueryAsync = ( + command: string, + args: string[], + operation: string, + waitLimitMs: number, + signal?: AbortSignal +): Promise => + runAdbProcessAsync( + command, + args, + { operation, phase: 'host-request', hasSideEffects: false, waitLimitMs }, + signal + ); + +export const runAdbDeviceQueryAsync = ( + command: string, + args: string[], + operation: string, + signal?: AbortSignal +): Promise => + runAdbProcessAsync( + command, + args, + { operation, phase: 'device-service', hasSideEffects: false }, + signal + ); + +export const runBoundedAdbDeviceQueryAsync = ( + command: string, + args: string[], + operation: string, + waitLimitMs: number, + signal?: AbortSignal +): Promise => + runAdbProcessAsync( + command, + args, + { operation, phase: 'device-service', hasSideEffects: false, waitLimitMs }, + signal + ); + +export const runAdbDeviceMutationAsync = ( + command: string, + args: string[], + operation: string, + signal?: AbortSignal +): Promise => + runAdbProcessAsync( + command, + args, + { operation, phase: 'device-service', hasSideEffects: true }, + signal + ); + +export const runBoundedAdbDeviceMutationAsync = ( + command: string, + args: string[], + operation: string, + waitLimitMs: number, + signal?: AbortSignal +): Promise => + runAdbProcessAsync( + command, + args, + { operation, phase: 'device-service', hasSideEffects: true, waitLimitMs }, + signal + ); + +interface AdbProcessParams { + operation: string; + phase: AdbProcessPhase; + hasSideEffects: boolean; + waitLimitMs?: number; +} + +async function runAdbProcessAsync( + command: string, + args: string[], + params: AdbProcessParams, + signal?: AbortSignal +): Promise { + signal?.throwIfAborted(); + event('adb_operation_start', { + operation: params.operation, + phase: params.phase, + waitLimitMs: params.waitLimitMs, + }); + let spawnPromise: spawnAsync.SpawnPromise; + try { + // NOTE(@kitten): Passing signal to spawnAsync would bypass cleanup accounting. + spawnPromise = spawnAsync(command, args); + } catch (error) { + throw createProcessError(error, params.operation, params.phase); + } + + let operationSignal = signal; + let waitLimitSignal: AbortSignal | undefined; + let cleanupWaitLimitMs = ADB_SUBPROCESS_CLEANUP_WAIT_LIMIT_MS; + if (params.waitLimitMs != null) { + cleanupWaitLimitMs = Math.min( + ADB_SUBPROCESS_CLEANUP_WAIT_LIMIT_MS, + Math.max(1, Math.floor(params.waitLimitMs / 4)) + ); + // The public wait limit covers both command execution and reaping its child. + waitLimitSignal = AbortSignal.timeout(Math.max(1, params.waitLimitMs - cleanupWaitLimitMs)); + operationSignal = operationSignal + ? AbortSignal.any([operationSignal, waitLimitSignal]) + : waitLimitSignal; + } + + try { + return operationSignal + ? await raceWithSignal(spawnPromise, operationSignal) + : await spawnPromise; + } catch (error) { + if (operationSignal?.aborted && error === operationSignal.reason) { + // NOTE(@kitten): Killing this client cannot retract a request already sent to the server + const clientTermination = await terminateAndObserveAsync(spawnPromise, cleanupWaitLimitMs); + const waitExpired = waitLimitSignal?.aborted && error === waitLimitSignal.reason; + event('adb_operation_cleanup', { + operation: params.operation, + phase: params.phase, + reason: waitExpired ? 'wait-limit' : 'cancelled', + status: clientTermination, + }); + + if (waitExpired) { + throw new AdbProcessWaitError( + `Expo stopped waiting for the ADB ${params.operation} operation to finish.`, + params.operation, + params.phase, + params.hasSideEffects ? true : undefined + ); + } + + if (params.hasSideEffects && error instanceof Error) { + (error as AdbProcessError).remoteCompletionUnknown = true; + } + + throw error; + } + throw createProcessError(error, params.operation, params.phase); + } +} + +async function terminateAndObserveAsync( + spawnPromise: spawnAsync.SpawnPromise, + cleanupWaitLimitMs: number +): Promise { + const observed = spawnPromise.then( + () => undefined, + () => undefined + ); + // Cleanup needs a fresh timeout because the operation signal has already aborted + const cleanupSignal = AbortSignal.timeout(cleanupWaitLimitMs); + + // Windows kill() is forced termination, not a graceful SIGTERM attempt. + if (process.platform === 'win32') { + spawnPromise.child.kill(); + return observeProcessExitAsync(observed, cleanupSignal, 'killed'); + } + + const gracefulSignal = AbortSignal.any([ + cleanupSignal, + AbortSignal.timeout(Math.floor(Math.min(500, Math.max(1, cleanupWaitLimitMs / 4)))), + ]); + + spawnPromise.child.kill('SIGTERM'); + const gracefulResult = await observeProcessExitAsync(observed, gracefulSignal, 'terminated'); + if (gracefulResult === 'terminated') { + return gracefulResult; + } else { + spawnPromise.child.kill('SIGKILL'); + return observeProcessExitAsync(observed, cleanupSignal, 'killed'); + } +} + +async function observeProcessExitAsync( + processResultPromise: Promise, + signal: AbortSignal, + observedCleanup: 'terminated' | 'killed' +): Promise { + try { + await raceWithSignal(processResultPromise, signal); + return observedCleanup; + } catch { + return 'exit-unobserved'; + } +} + +function raceWithSignal(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) { + return Promise.reject(signal.reason); + } + return new Promise((resolve, reject) => { + const handleAbort = () => reject(signal.reason); + signal.addEventListener('abort', handleAbort, { once: true }); + promise.then( + (value) => { + signal.removeEventListener('abort', handleAbort); + resolve(value); + }, + (error) => { + signal.removeEventListener('abort', handleAbort); + reject(error); + } + ); + }); +} + +function createProcessError( + error: any, + operation: string, + phase: AdbProcessPhase +): AdbProcessError { + const result = new AdbProcessError( + error?.message ?? 'Failed to run ADB.', + operation, + phase, + undefined, + error?.status == null && error?.signal == null ? true : undefined + ); + result.stdout = error?.stdout; + result.stderr = error?.stderr; + result.status = error?.status; + result.signal = error?.signal; + return result; +} + +export function isAdbTimeoutReason(reason: unknown): boolean { + return ( + typeof reason === 'object' && + reason != null && + 'name' in reason && + reason.name === 'TimeoutError' + ); +} diff --git a/packages/@expo/cli/src/start/platforms/android/adbReverse.ts b/packages/@expo/cli/src/start/platforms/android/adbReverse.ts index 5c64c2de0d9edc..c509b3be7577e6 100644 --- a/packages/@expo/cli/src/start/platforms/android/adbReverse.ts +++ b/packages/@expo/cli/src/start/platforms/android/adbReverse.ts @@ -6,6 +6,8 @@ import type { Device } from './adb'; import { adbArgs, getAttachedDevicesAsync, getServer, logUnauthorized } from './adb'; let removeExitHook: (() => void) | null = null; +const ADB_REVERSE_WAIT_LIMIT_MS = 2_000; +const ADB_REVERSE_CLEANUP_WAIT_LIMIT_MS = 2_000; export function hasAdbReverseAsync(): boolean { try { @@ -16,16 +18,24 @@ export function hasAdbReverseAsync(): boolean { } } -export async function startAdbReverseAsync(ports: number[]): Promise { +export async function startAdbReverseAsync( + ports: number[], + signal?: AbortSignal +): Promise { + const exitController = new AbortController(); + const operationSignal = signal + ? AbortSignal.any([signal, exitController.signal]) + : exitController.signal; // Install cleanup automatically... removeExitHook = installExitHooks(() => { - stopAdbReverseAsync(ports); + exitController.abort(); + return stopAdbReverseAsync(ports).catch(() => undefined); }); - const devices = await getAttachedDevicesAsync(); + const devices = await getAttachedDevicesAsync({ signal: operationSignal }); for (const device of devices) { for (const port of ports) { - if (!(await adbReverseAsync(device, port))) { + if (!(await adbReverseAsync(device, port, operationSignal))) { event('adb_reverse_port_failed', { port, deviceName: device.name }); return false; } @@ -34,43 +44,63 @@ export async function startAdbReverseAsync(ports: number[]): Promise { return true; } -export async function stopAdbReverseAsync(ports: number[]): Promise { +export async function stopAdbReverseAsync(ports: number[], signal?: AbortSignal): Promise { removeExitHook?.(); - const devices = await getAttachedDevicesAsync(); + const cleanupSignal = AbortSignal.timeout(ADB_REVERSE_CLEANUP_WAIT_LIMIT_MS); + const operationSignal = signal ? AbortSignal.any([signal, cleanupSignal]) : cleanupSignal; + const devices = await getAttachedDevicesAsync({ signal: operationSignal }); for (const device of devices) { for (const port of ports) { - await adbReverseRemoveAsync(device, port); + await adbReverseRemoveAsync(device, port, operationSignal); } } } -async function adbReverseAsync(device: Device, port: number): Promise { +async function adbReverseAsync( + device: Device, + port: number, + signal?: AbortSignal +): Promise { if (!device.isAuthorized) { logUnauthorized(device); return false; } try { - await getServer().runAsync(adbArgs(device.pid, 'reverse', `tcp:${port}`, `tcp:${port}`)); + await getServer().runDeviceMutationAsync( + adbArgs(device.pid, 'reverse', `tcp:${port}`, `tcp:${port}`), + 'reverse port', + signal, + ADB_REVERSE_WAIT_LIMIT_MS + ); return true; } catch (error: any) { + if (signal?.aborted && error === signal.reason) throw error; Log.warn(`[ADB] Couldn't reverse port ${port}: ${error.message}`); return false; } } -async function adbReverseRemoveAsync(device: Device, port: number): Promise { +async function adbReverseRemoveAsync( + device: Device, + port: number, + signal?: AbortSignal +): Promise { if (!device.isAuthorized) { return false; } try { - await getServer().runAsync(adbArgs(device.pid, 'reverse', '--remove', `tcp:${port}`)); + await getServer().runDeviceMutationAsync( + adbArgs(device.pid, 'reverse', '--remove', `tcp:${port}`), + 'remove reverse port', + signal, + ADB_REVERSE_CLEANUP_WAIT_LIMIT_MS + ); return true; } catch (error: any) { - // Don't send this to warn because we call this preemptively sometimes - event('adb_reverse_unforward_failed', { port, error: event.error(error as Error) }); + if (signal?.aborted && error === signal.reason) throw error; return false; } } diff --git a/packages/@expo/cli/src/start/platforms/android/emulator.ts b/packages/@expo/cli/src/start/platforms/android/emulator.ts index 2b7fd4fb46be93..7e3eb91322257e 100644 --- a/packages/@expo/cli/src/start/platforms/android/emulator.ts +++ b/packages/@expo/cli/src/start/platforms/android/emulator.ts @@ -1,13 +1,15 @@ import spawnAsync from '@expo/spawn-async'; import chalk from 'chalk'; import { spawn } from 'child_process'; -import os from 'os'; +import { setTimeout as delayAsync } from 'node:timers/promises'; import * as Log from '../../../log'; -import { AbortCommandError } from '../../../utils/errors'; +import { AbortCommandError, CommandError } from '../../../utils/errors'; import { installExitHooks } from '../../../utils/exit'; import type { Device } from './adb'; import { getAttachedDevicesAsync, isBootAnimationCompleteAsync } from './adb'; +import { isAdbDeviceDisconnectedError } from './adbDiagnostics'; +import { AdbProcessWaitError } from './adbProcess'; export const EMULATOR_MAX_WAIT_TIMEOUT = 60 * 1000 * 3; @@ -22,28 +24,24 @@ export function whichEmulator(): string { /** Returns a list of emulator names. */ export async function listAvdsAsync(): Promise { - try { - const { stdout } = await spawnAsync(whichEmulator(), ['-list-avds']); - return ( - stdout - .split(os.EOL) - .filter(Boolean) - /** - * AVD IDs cannot contain spaces. This removes extra info lines from the output. e.g. - * "INFO | Storing crashdata in: /tmp/android-brent/emu-crash-34.1.18.db - */ - .filter((name) => !name.trim().includes(' ')) - .map((name) => ({ - name, - type: 'emulator', - // unsure from this - isBooted: false, - isAuthorized: true, - })) - ); - } catch { - return []; - } + const { stdout } = await spawnAsync(whichEmulator(), ['-list-avds']); + return ( + stdout + .split(/\r?\n/) + .filter(Boolean) + /** + * AVD IDs cannot contain spaces. This removes extra info lines from the output. e.g. + * "INFO | Storing crashdata in: /tmp/android-brent/emu-crash-34.1.18.db + */ + .filter((name) => !name.trim().includes(' ')) + .map((name) => ({ + name, + type: 'emulator', + isBooted: false, + isAuthorized: true, + isLaunchable: true, + })) + ); } /** Start an Android device and wait until it is booted. */ @@ -52,12 +50,15 @@ export async function startDeviceAsync( { timeout = EMULATOR_MAX_WAIT_TIMEOUT, interval = 1000, + signal, }: { /** Time in milliseconds to wait before asserting a timeout error. */ timeout?: number; interval?: number; + signal?: AbortSignal; } = {} ): Promise { + signal?.throwIfAborted(); Log.log(`\u203A Opening emulator ${chalk.bold(device.name)}`); // Start a process to open an emulator @@ -76,56 +77,65 @@ export async function startDeviceAsync( emulatorProcess.unref(); - return new Promise((resolve, reject) => { - const waitTimer = setInterval(async () => { - try { - const bootedDevices = await getAttachedDevicesAsync(); - const connected = bootedDevices.find(({ name }) => name === device.name); - if (connected) { - const isBooted = await isBootAnimationCompleteAsync(connected.pid); - if (isBooted) { - stopWaiting(); - resolve(connected); - } - } - } catch (error) { - stopWaiting(); - reject(error); - } - }, interval); + const controller = new AbortController(); + const timeoutSignal = AbortSignal.timeout(timeout); + const operationSignal = signal + ? AbortSignal.any([signal, controller.signal, timeoutSignal]) + : AbortSignal.any([controller.signal, timeoutSignal]); + const manualCommand = `${whichEmulator()} @${device.name}`; + const handleEmulatorError = (error: Error) => controller.abort(error); + const handleEmulatorExit = () => + controller.abort( + new Error( + `The emulator (${device.name}) quit before it finished opening. You can try starting the emulator manually from the terminal with: ${manualCommand}` + ) + ); + const removeExitHook = installExitHooks((exitSignal) => { + emulatorProcess.kill(exitSignal); + controller.abort(new AbortCommandError()); + }); + emulatorProcess.on('error', handleEmulatorError); + emulatorProcess.on('exit', handleEmulatorExit); - // Reject command after timeout - const maxTimer = setTimeout(() => { - const manualCommand = `${whichEmulator()} @${device.name}`; - stopWaitingAndReject( + try { + // Wait for each check before delaying so boot polls never overlap. + while (true) { + const connected = await checkEmulatorBootAsync(device.name, operationSignal); + if (connected) return connected; + await delayAsync(interval, undefined, { signal: operationSignal }); + } + } catch (error) { + if (timeoutSignal.aborted && operationSignal.reason === timeoutSignal.reason) { + throw new Error( `It took too long to start the Android emulator: ${device.name}. You can try starting the emulator manually from the terminal with: ${manualCommand}` ); - }, timeout); - - const stopWaiting = () => { - clearTimeout(maxTimer); - clearInterval(waitTimer); - removeExitHook(); - }; - - const stopWaitingAndReject = (message: string) => { - stopWaiting(); - reject(new Error(message)); - }; - - const removeExitHook = installExitHooks((signal) => { - stopWaiting(); - emulatorProcess.kill(signal); - reject(new AbortCommandError()); - }); + } + throw operationSignal.aborted ? operationSignal.reason : error; + } finally { + removeExitHook(); + emulatorProcess.off('error', handleEmulatorError); + emulatorProcess.off('exit', handleEmulatorExit); + } +} - emulatorProcess.on('error', ({ message }) => stopWaitingAndReject(message)); +async function checkEmulatorBootAsync(name: string, signal?: AbortSignal): Promise { + const bootedDevices = await getAttachedDevicesAsync({ signal, shouldShowWaitingMessage: false }); + const connected = bootedDevices.find((device) => device.name === name); + if (connected) { + try { + if (await isBootAnimationCompleteAsync(connected.pid, signal)) { + return connected; + } + } catch (error) { + if (signal?.aborted) throw signal.reason; + if (!isTransientBootPropertyError(error)) throw error; + } + } + return null; +} - emulatorProcess.on('exit', () => { - const manualCommand = `${whichEmulator()} @${device.name}`; - stopWaitingAndReject( - `The emulator (${device.name}) quit before it finished opening. You can try starting the emulator manually from the terminal with: ${manualCommand}` - ); - }); - }); +function isTransientBootPropertyError(error: unknown): boolean { + const cause = + error instanceof CommandError && error.code === 'ADB_PROPERTY' ? error.cause : error; + return cause instanceof AdbProcessWaitError || isAdbDeviceDisconnectedError(cause); } diff --git a/packages/@expo/cli/src/start/platforms/android/getDevices.ts b/packages/@expo/cli/src/start/platforms/android/getDevices.ts index 4c39fef1b6d551..43cf84eff011e2 100644 --- a/packages/@expo/cli/src/start/platforms/android/getDevices.ts +++ b/packages/@expo/cli/src/start/platforms/android/getDevices.ts @@ -1,39 +1,49 @@ import { CommandError } from '../../../utils/errors'; import type { Device } from './adb'; -import { getAttachedDevicesAsync } from './adb'; +import { waitForAttachedDevicesAsync } from './adb'; import { listAvdsAsync } from './emulator'; /** Get a list of all devices including offline emulators. Asserts if no devices are available. */ export async function getDevicesAsync(): Promise { - const bootedDevices = await getAttachedDevicesAsync(); + const bootedDevices = await waitForAttachedDevicesAsync(); - const data = await listAvdsAsync(); - const connectedNames = bootedDevices.map(({ name }) => name); - - const offlineEmulators = data - .filter(({ name }) => !connectedNames.includes(name)) - .map(({ name, type }) => { - return { - name, - type, - isBooted: false, - // TODO: Are emulators always authorized? - isAuthorized: true, - }; - }); + // NOTE(@kitten): We don't assume AVD must succeed or be present, and still allow + // devices to be discovered and move on + let data: Device[]; + try { + data = await listAvdsAsync(); + } catch (error) { + if (!bootedDevices.length) { + throw new CommandError( + 'ANDROID_AVD_DISCOVERY', + formatNoDevicesMessage(error instanceof Error ? error.message : String(error)) + ); + } + data = []; + } - const allDevices = bootedDevices.concat(offlineEmulators); + const allDevices = mergeDevices(bootedDevices, data); if (!allDevices.length) { - throw new CommandError( - [ - `No Android connected device found, and no emulators could be started automatically.`, - `Connect a device or create an emulator (https://docs.expo.dev/workflow/android-studio-emulator).`, - `Then follow the instructions here to enable USB debugging:`, - `https://developer.android.com/studio/run/device.html#developer-device-options. If you are using Genymotion go to Settings -> ADB, select "Use custom Android SDK tools", and point it at your Android SDK directory.`, - ].join('\n') - ); + throw new CommandError('ANDROID_NO_DEVICES', formatNoDevicesMessage()); } return allDevices; } + +function formatNoDevicesMessage(cause?: string): string { + return [ + `No Android connected device found, and no emulators could be started automatically.`, + cause ? `Could not list Android emulators: ${cause}` : null, + `Connect a device or create an emulator (https://docs.expo.dev/workflow/android-studio-emulator).`, + `Then follow the instructions here to enable USB debugging:`, + `https://developer.android.com/studio/run/device.html#developer-device-options. If you are using Genymotion go to Settings -> ADB, select "Use custom Android SDK tools", and point it at your Android SDK directory.`, + ] + .filter((line): line is string => line != null) + .join('\n'); +} + +export function mergeDevices(attachedDevices: Device[], avds: Device[]): Device[] { + const connectedNames = new Set(attachedDevices.map(({ name }) => name)); + return attachedDevices.concat(avds.filter(({ name }) => !connectedNames.has(name))); +} diff --git a/packages/@expo/cli/src/start/platforms/events.ts b/packages/@expo/cli/src/start/platforms/events.ts index b76e4c288cb319..be30ece9a6d990 100644 --- a/packages/@expo/cli/src/start/platforms/events.ts +++ b/packages/@expo/cli/src/start/platforms/events.ts @@ -7,15 +7,34 @@ declare module '2g' { 'platform:activate_window_lsof': { args: string }; 'platform:activate_window_pid': { pid: string }; // android/adb.ts - 'platform:adb_property_data': { devicePid: string | undefined; prop: string; data: string }; + 'platform:adb_property_data': { + devicePid: string | undefined; + prop: string; + data: string; + }; 'platform:adb_parsed_properties': { props: Record }; // android/adbReverse.ts 'platform:adb_reverse_sdk_missing': { error: SerializedError }; 'platform:adb_reverse_port_failed': { port: number; deviceName: string }; - 'platform:adb_reverse_unforward_failed': { port: number; error: SerializedError }; + 'platform:adb_reverse_unforward_failed': { + port: number; + error: SerializedError; + }; // android/ADBServer.ts 'platform:adb_server_run': { command: string }; 'platform:adb_file_output': { output: string }; + 'platform:adb_operation_start': { + operation: string; + phase: 'host-request' | 'device-service'; + waitLimitMs: number | undefined; + }; + 'platform:adb_operation_cleanup': { + operation: string; + phase: 'host-request' | 'device-service'; + reason: 'wait-limit' | 'cancelled'; + status: 'terminated' | 'killed' | 'exit-unobserved'; + }; + 'platform:adb_host_probe': { endpoint: string; result: string }; // android/gradle.ts 'platform:gradle_spawn': { command: string }; // ExpoGoInstaller.ts @@ -51,14 +70,21 @@ declare module '2g' { }; 'platform:simctl_url_scheme_parse_error': { error: SerializedError }; 'platform:simctl_allowed_links': { plistData: Record }; - 'platform:simctl_allow_deep_link': { key: string; appId: string | undefined }; + 'platform:simctl_allow_deep_link': { + key: string; + appId: string | undefined; + }; // ios/xcrun.ts 'platform:xcrun_run': { command: string }; // PlatformManager.ts 'platform:open_launch_url': { appId: string; redirectUrl: string }; 'platform:open_custom': { props: string }; 'platform:open_custom_url': { url: string | null; props: string }; - 'platform:open_async': { runtime: string; platform: string; shouldPrompt: boolean | undefined }; + 'platform:open_async': { + runtime: string; + platform: string; + shouldPrompt: boolean | undefined; + }; // android/AndroidPlatformManager.ts 'platform:android_open_custom_launch_activity': { launchActivity: string }; // android/AndroidAppIdResolver.ts diff --git a/packages/@expo/cli/src/start/server/DevServerManager.ts b/packages/@expo/cli/src/start/server/DevServerManager.ts index 1828d3ddbfd932..df79c63f107e35 100644 --- a/packages/@expo/cli/src/start/server/DevServerManager.ts +++ b/packages/@expo/cli/src/start/server/DevServerManager.ts @@ -9,7 +9,6 @@ import { env } from '../../utils/env'; import type { ProjectPrerequisite } from '../doctor/Prerequisite'; import { TypeScriptProjectPrerequisite } from '../doctor/typescript/TypeScriptProjectPrerequisite'; import { printItem } from '../interface/commandsTable'; -import * as AndroidDebugBridge from '../platforms/android/adb'; import { resolveSchemeAsync } from '../resolveOptions'; import type { BundlerDevServer, BundlerStartOptions } from './BundlerDevServer'; import DevToolsPluginManager from './DevToolsPluginManager'; @@ -223,12 +222,10 @@ export class DevServerManager { await this.devServers.find((server) => server.name === 'metro')?.watchEnvironmentVariables(); } - /** Stop all servers including ADB. */ + /** Stop all development servers. */ async stopAsync(): Promise { await Promise.allSettled([ this.notifier?.stopObserving(), - // Stop ADB - AndroidDebugBridge.getServer().stopAsync(), // Stop all dev servers ...this.devServers.map((server) => server.stopAsync().catch((error) => { diff --git a/packages/@expo/log-box/src/logbox-rn-polyfill.tsx b/packages/@expo/log-box/src/logbox-rn-polyfill.tsx index 1361d2a7c95998..5205d4a6168ed4 100644 --- a/packages/@expo/log-box/src/logbox-rn-polyfill.tsx +++ b/packages/@expo/log-box/src/logbox-rn-polyfill.tsx @@ -93,6 +93,9 @@ function LogBoxRNPolyfill(props: { ); return ( + // `LogBoxWrapper` is memoized with empty dependencies, so its identity is stable + // across renders. + // oxlint-disable-next-line react/static-components { return downloadFirst ? null : resolveSource(source); - }, [JSON.stringify(source), downloadFirst]); + }, [serializedSource, downloadFirst]); const player = useReleasingSharedObject( () => @@ -160,7 +161,7 @@ export function useAudioPlayer( return () => { isCancelled = true; }; - }, [player, JSON.stringify(source), downloadFirst]); + }, [player, serializedSource, downloadFirst]); return player; } @@ -336,8 +337,9 @@ export { useAudioRecorderState } from './utils/useAudioRecorderState'; */ export function useAudioPlaylist(options: AudioPlaylistOptions = {}): AudioPlaylist { const { sources = [], updateInterval = 500, loop = 'none' } = options; + const serializedSources = JSON.stringify(sources); - const resolvedSources = useMemo(() => resolveSources(sources), [JSON.stringify(sources)]); + const resolvedSources = useMemo(() => resolveSources(sources), [serializedSources]); const playlist = useReleasingSharedObject( () => new AudioModule.AudioPlaylist(resolvedSources, updateInterval, loop), diff --git a/packages/expo-audio/src/ExpoAudio.web.ts b/packages/expo-audio/src/ExpoAudio.web.ts index 197689821fb1c6..6202f0ff958241 100644 --- a/packages/expo-audio/src/ExpoAudio.web.ts +++ b/packages/expo-audio/src/ExpoAudio.web.ts @@ -53,13 +53,14 @@ export function useAudioPlayer( options: AudioPlayerOptions = {} ): AudioModule.AudioPlayerWeb { const { downloadFirst = false } = options; + const serializedSource = JSON.stringify(source); // If downloadFirst is true, we don't need to resolve the source, because it will be resolved in the useEffect below. // If downloadFirst is false, we resolve the source here. // we call .replace() in the useEffect below to replace the source with the downloaded one. const initialSource = useMemo(() => { return downloadFirst ? null : resolveSource(source); - }, [JSON.stringify(source), downloadFirst]); + }, [serializedSource, downloadFirst]); const player = useReleasingSharedObject( () => new AudioModule.AudioPlayerWeb(initialSource, options), @@ -86,7 +87,7 @@ export function useAudioPlayer( return () => { isCancelled = true; }; - }, [player, JSON.stringify(source), downloadFirst]); + }, [player, serializedSource, downloadFirst]); return player; } @@ -115,9 +116,10 @@ export function useAudioRecorder( statusListener?: (status: RecordingStatus) => void ): AudioModule.AudioRecorderWeb { const platformOptions = createRecordingOptions(options); + const serializedPlatformOptions = JSON.stringify(platformOptions); const recorder = useMemo(() => { return new AudioModule.AudioRecorderWeb(platformOptions); - }, [JSON.stringify(platformOptions)]); + }, [serializedPlatformOptions]); useEffect(() => { const subscription = recorder.addListener(RECORDING_STATUS_UPDATE, (status) => { @@ -152,12 +154,14 @@ export async function getRecordingPermissionsAsync(): Promise resolveSources(sources), [JSON.stringify(sources)]); + const resolvedSources = useMemo(() => resolveSources(sources), [serializedSources]); + const serializedResolvedSources = JSON.stringify(resolvedSources); const playlist = useMemo( () => new AudioModule.AudioPlaylistWeb(resolvedSources, updateInterval, loop, crossOrigin), - [JSON.stringify(resolvedSources), updateInterval, loop, crossOrigin] + [serializedResolvedSources, updateInterval, loop, crossOrigin] ); useEffect(() => { diff --git a/packages/expo-blur/README.md b/packages/expo-blur/README.md index 8960e67806cc4a..c3cfbd1ac9a5c4 100644 --- a/packages/expo-blur/README.md +++ b/packages/expo-blur/README.md @@ -31,7 +31,7 @@ npx expo install expo-blur ### Configure for Android > [!note] -> This package only supports iOS. On Android, a plain `View` with a translucent background will be rendered. +> [The blurring feature is stable on Android from SDK 55](https://docs.expo.dev/versions/latest/sdk/blur-view/#android-support) ### Configure for iOS diff --git a/packages/expo-module-scripts/CHANGELOG.md b/packages/expo-module-scripts/CHANGELOG.md index 1a8cc828829615..7ca45fad5d09cd 100644 --- a/packages/expo-module-scripts/CHANGELOG.md +++ b/packages/expo-module-scripts/CHANGELOG.md @@ -15,6 +15,7 @@ - Added `CLAUDE.md` and `CONTRIBUTING.md` to `.npmignore` template. ([#47451](https://github.com/expo/expo/pull/47451) by [@kudo](https://github.com/kudo)) - Updated `oxlint-config-universe` to 0.2.0, which requires `oxlint` 1.79.0. ([#49241](https://github.com/expo/expo/pull/49241) by [@tsapeta](https://github.com/tsapeta)) +- Enabled the `react/use-memo`, `react/void-use-memo`, `react/purity`, `react/preserve-manual-memoization`, and `react/static-components` rules in the shared oxlint config. ([#49242](https://github.com/expo/expo/pull/49242) by [@tsapeta](https://github.com/tsapeta)) ## 56.0.3 - 2026-05-29 diff --git a/packages/expo-module-scripts/oxlint.config.base.js b/packages/expo-module-scripts/oxlint.config.base.js index 4bb3e202133f65..1d0ebd3710c7a3 100644 --- a/packages/expo-module-scripts/oxlint.config.base.js +++ b/packages/expo-module-scripts/oxlint.config.base.js @@ -112,13 +112,8 @@ export default defineConfig({ // get triaged. 'react/globals': 'off', 'react/immutability': 'off', - 'react/preserve-manual-memoization': 'off', - 'react/purity': 'off', 'react/refs': 'off', 'react/set-state-in-effect': 'off', - 'react/static-components': 'off', - 'react/use-memo': 'off', - 'react/void-use-memo': 'off', // ----------------- // --- Stylistic --- diff --git a/packages/expo-modules-core/src/hooks/useReleasingSharedObjectWithLifecycle.ts b/packages/expo-modules-core/src/hooks/useReleasingSharedObjectWithLifecycle.ts index 4224433e910555..ed8f1a47017df6 100644 --- a/packages/expo-modules-core/src/hooks/useReleasingSharedObjectWithLifecycle.ts +++ b/packages/expo-modules-core/src/hooks/useReleasingSharedObjectWithLifecycle.ts @@ -117,6 +117,8 @@ export function useReleasingSharedObjectWithLifecycle { isFastRefresh.current = true; }, []); diff --git a/packages/expo-modules-jsi/CHANGELOG.md b/packages/expo-modules-jsi/CHANGELOG.md index 3e811f793935ac..f21f65bbb948bf 100644 --- a/packages/expo-modules-jsi/CHANGELOG.md +++ b/packages/expo-modules-jsi/CHANGELOG.md @@ -26,6 +26,7 @@ - [iOS] `CppError::tryCatch` now takes a C++ callable instead of an Objective-C block. Every caller already passes a pure C++ body, so the block bridged no Swift closure and only added a non-inlinable indirect call and an Objective-C runtime dependency on the JS call/eval error-handling path. ([#48333](https://github.com/expo/expo/pull/48333) by [@tsapeta](https://github.com/tsapeta)) - [iOS] `JavaScriptActor.assumeIsolated` no longer heap-allocates a closure box per call by keeping its `operation` non-escaping, making synchronous host calls ~1.6× faster. ([#47837](https://github.com/expo/expo/pull/47837) by [@tsapeta](https://github.com/tsapeta)) +- [iOS] `JavaScriptValue.undefined` and `JavaScriptValue.null` now return shared immortal instances instead of allocating a new value on each access, removing one allocation from every void-returning host call. ([#49545](https://github.com/expo/expo/pull/49545) by [@tsapeta](https://github.com/tsapeta)) ## 57.0.4 — 2026-07-22 diff --git a/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptValue.swift b/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptValue.swift index 5a79ab7490565a..c568ed8f1706ae 100644 --- a/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptValue.swift +++ b/packages/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/Values/JavaScriptValue.swift @@ -602,18 +602,25 @@ public final class JavaScriptValue: JavaScriptType, Equatable, Escapable { // MARK: - Runtime-free initializers + // Shared immortal instances for the runtime-free `undefined`/`null` kinds. The class is fully + // immutable and these carry no runtime and a trivial `jsi::Value`, so one instance can be safely + // handed out from any isolation context. Every void-returning `@JS` function returns `.undefined`, + // so a computed getter here would allocate on every host call. + private static let sharedUndefined = JavaScriptValue(nil, facebook.jsi.Value.undefined()) + private static let sharedNull = JavaScriptValue(nil, facebook.jsi.Value.null()) + /// This is a lightweight way to create an undefined value that can be used in contexts /// where a runtime is not available or needed. The resulting value can be passed to /// JavaScript functions or used in comparisons. public static var undefined: JavaScriptValue { - JavaScriptValue(nil, facebook.jsi.Value.undefined()) + return sharedUndefined } /// This is a lightweight way to create a null value that can be used in contexts /// where a runtime is not available or needed. The resulting value represents /// JavaScript's `null`, which is distinct from `undefined`. public static var null: JavaScriptValue { - JavaScriptValue(nil, facebook.jsi.Value.null()) + return sharedNull } /// This is a lightweight way to create a boolean true value that can be used in contexts diff --git a/packages/expo-router/CHANGELOG.md b/packages/expo-router/CHANGELOG.md index 8e262650b8fd95..89a90ad9a34931 100644 --- a/packages/expo-router/CHANGELOG.md +++ b/packages/expo-router/CHANGELOG.md @@ -46,6 +46,7 @@ ### 🎉 New features +- [Android] Add `cornerRadius` support to dropdown menu - Add `unstable_useIsNavigating` for observing queued or pending navigation. ([#49448](https://github.com/expo/expo/pull/49448) by [@Ubax](https://github.com/Ubax)) - Add unstable `NavigationAwareActivity` component. ([#49164](https://github.com/expo/expo/pull/49164) by [@Ubax](https://github.com/Ubax)) - Add screen error boundaries ([#49174](https://github.com/expo/expo/pull/49174) by [@Ubax](https://github.com/Ubax)) diff --git a/packages/expo-router/oxlint.config.mjs b/packages/expo-router/oxlint.config.mjs index 596ea296f6c176..14f2ea42bb768c 100644 --- a/packages/expo-router/oxlint.config.mjs +++ b/packages/expo-router/oxlint.config.mjs @@ -5,6 +5,13 @@ export default defineConfig({ extends: [base], ignorePatterns: base.ignorePatterns, overrides: [ + { + // Vendored React Navigation code; keep the diff against upstream minimal. + files: ['src/react-navigation/**'], + rules: { + 'react/preserve-manual-memoization': 'off', + }, + }, { files: ['src/loaders/LoaderClient.ts'], rules: { diff --git a/packages/expo-router/src/layouts/stack-utils/toolbar/StackToolbarMenu/native.android.tsx b/packages/expo-router/src/layouts/stack-utils/toolbar/StackToolbarMenu/native.android.tsx index 6e035d95387fd4..35afb8aed8e47e 100644 --- a/packages/expo-router/src/layouts/stack-utils/toolbar/StackToolbarMenu/native.android.tsx +++ b/packages/expo-router/src/layouts/stack-utils/toolbar/StackToolbarMenu/native.android.tsx @@ -89,7 +89,8 @@ export const NativeToolbarMenu: React.FC = (props) => { setExpanded(false)} - color={backgroundColor}> + color={backgroundColor} + cornerRadius={props.cornerRadius}> { @@ -147,7 +148,8 @@ export const NativeToolbarMenu: React.FC = (props) => { setExpanded(false)} - color={backgroundColor}> + color={backgroundColor} + cornerRadius={props.cornerRadius}> {iconButton} diff --git a/packages/expo-router/src/layouts/stack-utils/toolbar/StackToolbarMenu/types.ts b/packages/expo-router/src/layouts/stack-utils/toolbar/StackToolbarMenu/types.ts index ab648bba2783bb..eb764fed09b7dd 100644 --- a/packages/expo-router/src/layouts/stack-utils/toolbar/StackToolbarMenu/types.ts +++ b/packages/expo-router/src/layouts/stack-utils/toolbar/StackToolbarMenu/types.ts @@ -165,6 +165,13 @@ export interface StackToolbarMenuProps { * @platform ios */ title?: string; + /** + * Corner radius in dp for the dropdown menu container. Defaults to the Material3 + * `MenuDefaults.shape` corner radius. + * + * @platform android + */ + cornerRadius?: number; /** * @default 'plain' * @@ -195,6 +202,8 @@ export interface NativeToolbarMenuProps { hidesSharedBackground?: boolean; icon?: SFSymbol; xcassetName?: string; + /** @platform android */ + cornerRadius?: number; // TODO(@ubax): Add useImage support in a follow-up PR. /** * Image to display for the menu item. diff --git a/packages/expo-router/src/link/preview/HrefPreview.tsx b/packages/expo-router/src/link/preview/HrefPreview.tsx index 816f19f665d325..01d6f71d869eaa 100644 --- a/packages/expo-router/src/link/preview/HrefPreview.tsx +++ b/packages/expo-router/src/link/preview/HrefPreview.tsx @@ -113,6 +113,9 @@ function PreviewForRootHrefState({ {}, unset: () => {} }}> {/* Using NavigationContext to override useNavigation */} + {/* `getQualifiedRouteComponent` returns a cached component per route node, + so the identity is stable across renders. */} + {/* oxlint-disable-next-line react/static-components */} diff --git a/packages/expo-router/src/split-view/split-view.tsx b/packages/expo-router/src/split-view/split-view.tsx index 323d8d8ccd015c..f0a272bf82754d 100644 --- a/packages/expo-router/src/split-view/split-view.tsx +++ b/packages/expo-router/src/split-view/split-view.tsx @@ -7,6 +7,12 @@ import { SplitViewColumn, SplitViewInspector } from './elements'; const IsWithinSplitViewContext = createContext(false); +const WrappedSlot = () => ( + + + +); + /** * For full list of supported props, see [`SplitHostProps`](http://github.com/software-mansion/react-native-screens/blob/main/src/components/gamma/split/SplitHost.types.ts#L117) */ @@ -31,12 +37,6 @@ function SplitViewNavigator({ children, ...splitViewHostProps }: SplitViewProps) return ; } - const WrappedSlot = () => ( - - - - ); - const allChildrenArray = React.Children.toArray(children); const columnChildren = allChildrenArray.filter( (child) => isValidElement(child) && child.type === SplitViewColumn diff --git a/packages/expo-router/src/ui/Slot.tsx b/packages/expo-router/src/ui/Slot.tsx index b499749152c5db..d709a4a7d7a65c 100644 --- a/packages/expo-router/src/ui/Slot.tsx +++ b/packages/expo-router/src/ui/Slot.tsx @@ -23,7 +23,7 @@ import { StyleSheet, type ViewProps } from 'react-native'; */ function ShimSlotForReactNative(Component: typeof RUISlot): typeof RUISlot { return forwardRef(function RNSlotHOC({ style, ...props }, ref) { - style = useMemo(() => StyleSheet.flatten(style), [style]); + const flattenedStyle = useMemo(() => StyleSheet.flatten(style), [style]); if (process.env.NODE_ENV !== 'production') { if (React.isValidElement(props.children)) { if ( @@ -38,7 +38,7 @@ function ShimSlotForReactNative(Component: typeof RUISlot): typeof RUISlot { } } } - return ; + return ; }); } diff --git a/packages/expo-router/src/useScreens.tsx b/packages/expo-router/src/useScreens.tsx index 862a1bb3dbd690..d4d323a0c31898 100644 --- a/packages/expo-router/src/useScreens.tsx +++ b/packages/expo-router/src/useScreens.tsx @@ -409,6 +409,9 @@ export function getQualifiedRouteComponent(value: RouteNode) { void; color?: ColorValue; + cornerRadius?: number; style?: StyleProp; modifiers?: ModifierConfig[]; children?: ReactNode; diff --git a/packages/expo/src/dom/dom-hooks.ts b/packages/expo/src/dom/dom-hooks.ts index 87ea5323504804..2d5f820eaa3754 100644 --- a/packages/expo/src/dom/dom-hooks.ts +++ b/packages/expo/src/dom/dom-hooks.ts @@ -19,6 +19,8 @@ export function useDOMImperativeHandle( ) { const isTargetWeb = !hasWebViewBridge() && typeof window.$$EXPO_INITIAL_PROPS === 'undefined'; + // This generic hook forwards the caller-provided dependency list, which cannot be an array literal. + // oxlint-disable-next-line react/use-memo const stubHandlerFactory = useCallback(() => ({}) as T, deps ?? []); // This standard useImperativeHandle hook is serving for web diff --git a/template-files/android/AndroidManifest.xml b/template-files/android/AndroidManifest.xml index 59291f663c3851..521037366a5ff4 100644 --- a/template-files/android/AndroidManifest.xml +++ b/template-files/android/AndroidManifest.xml @@ -201,29 +201,6 @@ android:name=".ExponentIntentService" android:exported="false" /> - - - - - - - - - - - - - - -
diff --git a/tools/src/commands/EasDispatch.ts b/tools/src/commands/EasDispatch.ts index 704c8aa11387a2..f400c392fc3e3a 100644 --- a/tools/src/commands/EasDispatch.ts +++ b/tools/src/commands/EasDispatch.ts @@ -50,7 +50,7 @@ const CUSTOM_ACTIONS: Record = { action: iosSimulatorBuildAsync, }, 'ios-simulator-upload': { - name: '[internal] Upload a new iOS Client simulator to expo/expo-go-releases repo and updates www endpoint', + name: '[internal] Upload a new iOS Client simulator build and device IPA to expo/expo-go-releases repo and updates www endpoint', actionId: 'ios-simulator-upload', action: iosSimulatorUploadAsync, }, @@ -137,6 +137,10 @@ function getIosSimulatorUrl(appVersion: string): string { return `https://github.com/${REPO_OWNER}/${RELEASES_REPO_NAME}/releases/download/${getAppName(appVersion)}/${getAppName(appVersion)}.tar.gz`; } +function getIosIpaUrl(appVersion: string): string { + return `https://github.com/${REPO_OWNER}/${RELEASES_REPO_NAME}/releases/download/${getAppName(appVersion)}/${getAppName(appVersion)}.ipa`; +} + async function confirmPromptIfOverridingRemoteFileAsync( url: string, appVersion: string @@ -582,6 +586,33 @@ async function iosSimulatorUploadAsync() { spinner.fail('Upload failed!'); throw error; } + + await confirmPromptIfOverridingRemoteFileAsync(getIosIpaUrl(appVersion), appVersion); + const ipaPath = await downloadBuildArtifactAsync( + projectDir, + 'ios', + appVersion, + sdkVersion, + RELEASE_BUILD_PROFILE, + 'ipa' + ); + + const ipaSpinner = ora(`Uploading to GitHub: ${path.basename(ipaPath)}...`).start(); + try { + const ipaRes = await uploadReleaseAssetAsync( + repoOwner, + repoName, + release.data.id, + path.basename(ipaPath), + await fs.readFile(ipaPath) + ); + ipaSpinner.succeed(`Upload completed successfully! ${ipaRes.data.browser_download_url}`); + } catch (error) { + ipaSpinner.fail('IPA upload failed!'); + throw error; + } finally { + await fs.unlink(ipaPath); + } } catch (error: any) { logger.error(`Error creating release: ${error.message}`); throw error; @@ -608,7 +639,9 @@ async function downloadBuildArtifactAsync( projectDir: string, platform: 'ios' | 'android', appVersion: string, - sdkVersion: string + sdkVersion: string, + buildProfile: string = PUBLISH_CLIENT_BUILD_PROFILE, + extension: string = platform === 'android' ? 'apk' : 'tar.gz' ) { const buildInfo = await spawnAsync( 'eas', @@ -623,7 +656,7 @@ async function downloadBuildArtifactAsync( '--status', 'finished', '--profile', - PUBLISH_CLIENT_BUILD_PROFILE, + buildProfile, '--sdk-version', sdkVersion, ], @@ -632,7 +665,7 @@ async function downloadBuildArtifactAsync( stdio: 'pipe', env: { ...process.env, - EAS_BUILD_PROFILE: PUBLISH_CLIENT_BUILD_PROFILE, + EAS_BUILD_PROFILE: buildProfile, }, } ); @@ -690,10 +723,8 @@ async function downloadBuildArtifactAsync( if (!input.startsWith('http')) { return 'URL must start with http:// or https://'; } - if (platform === 'android' && !input.endsWith('.apk')) { - return 'URL must end with .apk'; - } else if (platform === 'ios' && !input.endsWith('.tar.gz')) { - return 'URL must end with .tar.gz'; + if (!input.endsWith(`.${extension}`)) { + return `URL must end with .${extension}`; } return true; }, @@ -707,10 +738,7 @@ async function downloadBuildArtifactAsync( buildUrl = selectedBuild.artifacts.buildUrl; } - const archivePath = path.join( - projectDir, - `${getAppName(appVersion)}.${platform === 'android' ? 'apk' : 'tar.gz'}` - ); + const archivePath = path.join(projectDir, `${getAppName(appVersion)}.${extension}`); logger.info(`Downloading build from: ${buildUrl}`); await spawnAsync('curl', ['-L', '-o', archivePath, buildUrl], {