diff --git a/.github/workflows/integration-test.yml b/.github/workflows/integration-test.yml new file mode 100644 index 0000000000..e3abafa0a5 --- /dev/null +++ b/.github/workflows/integration-test.yml @@ -0,0 +1,32 @@ +name: Integration Test + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +on: + workflow_dispatch: + pull_request: + types: [labeled, synchronize, reopened] + +jobs: + integration-test: + if: | + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-integration-test')) + runs-on: ubuntu-latest + timeout-minutes: 90 + + steps: + - name: Check out code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + ls -l /dev/kvm + + - name: Run integration test + run: make android-integration-test diff --git a/.gitignore b/.gitignore index bc7058fc03..c11b6b673b 100644 --- a/.gitignore +++ b/.gitignore @@ -31,6 +31,9 @@ tailscale.jks # Persistent $HOME/.android for `make docker-*` (keeps debug.keystore so # the debug signer is stable across container runs). .android-docker +.android-integration-docker +.cache-docker +.gradle-docker # Java profiling output *.hprof diff --git a/Makefile b/Makefile index e7e6012d64..095ead611f 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,13 @@ # # The convention here is tailscale-android-build-amd64- DOCKER_IMAGE := tailscale-android-build-amd64-041425-1 + +# The integration test image contains the Android emulator, system image, SDK, +# build-tools, NDK, adb, and helper tools needed to run the emulator-backed Go +# integration tests. Bump this tag when docker/Dockerfile.android-integration +# or the required tool versions change, using: +# tailscale-android-integration-amd64-YYYYMMDD-N +ANDROID_INTEGRATION_DOCKER_IMAGE := tailscale-android-integration-amd64-20260526-3 export TS_USE_TOOLCHAIN=1 # If set, additional comma-separated build tags passed to the libtailscale Go @@ -365,6 +372,14 @@ docker-build-image: ## Builds the docker image for the android build environment docker build -f docker/DockerFile.amd64-build -t $(DOCKER_IMAGE) .; \ fi +.PHONY: docker-build-android-integration-image +docker-build-android-integration-image: ## Build the Docker image used to run Android integration tests + @echo "Checking if docker image $(ANDROID_INTEGRATION_DOCKER_IMAGE) already exists..." + @if ! docker images $(ANDROID_INTEGRATION_DOCKER_IMAGE) -q | grep -q . ; then \ + echo "Image does not exist. Building..."; \ + docker build -f docker/Dockerfile.android-integration -t $(ANDROID_INTEGRATION_DOCKER_IMAGE) .; \ + fi + # DOCKER_ANDROID_DIR is bind-mounted as /root/.android inside the container # so the Gradle-generated debug keystore (and anything else under ~/.android) # persists across docker runs. Without this, every docker-based debug build @@ -373,12 +388,19 @@ docker-build-image: ## Builds the docker image for the android build environment # JVM's user.home resolves to /root for the container's root user, regardless # of the Dockerfile's HOME=/build env. DOCKER_ANDROID_DIR := $(CURDIR)/.android-docker +DOCKER_ANDROID_INTEGRATION_DIR := $(CURDIR)/.android-integration-docker +DOCKER_GRADLE_DIR := $(CURDIR)/.gradle-docker +DOCKER_GO_CACHE_DIR := $(CURDIR)/.cache-docker .PHONY: docker-android-dir docker-android-dir: - @mkdir -p $(DOCKER_ANDROID_DIR) + @mkdir -p $(DOCKER_ANDROID_DIR) $(DOCKER_GRADLE_DIR) $(DOCKER_GO_CACHE_DIR) + +.PHONY: docker-android-integration-dir +docker-android-integration-dir: + @mkdir -p $(DOCKER_ANDROID_INTEGRATION_DIR) $(DOCKER_GO_CACHE_DIR) -DOCKER_RUN_VOLS := -v $(CURDIR):/build/tailscale-android -v $(DOCKER_ANDROID_DIR):/root/.android +DOCKER_RUN_VOLS := -v $(CURDIR):/build/tailscale-android -v $(DOCKER_ANDROID_DIR):/root/.android -v $(DOCKER_GRADLE_DIR):/build/.gradle -v $(DOCKER_GO_CACHE_DIR):/build/.cache --env GOPATH=/build/.cache/go --env GOMODCACHE=/build/.cache/go/pkg/mod .PHONY: docker-run-build docker-run-build: clean jarsign-env docker-build-image docker-android-dir ## Runs the docker image for the android build environment and builds release @@ -388,6 +410,21 @@ docker-run-build: clean jarsign-env docker-build-image docker-android-dir ## Run docker-tailscale-debug: docker-build-image docker-android-dir ## Build tailscale-debug.apk inside the docker env (stable signer across runs) @docker run --rm $(DOCKER_RUN_VOLS) $(DOCKER_IMAGE) make tailscale-debug +.PHONY: android-integration-test +android-integration-test: docker-tailscale-debug android-integration-test-run ## Build APK and run adb-backed Android integration tests in Docker + +.PHONY: android-integration-test-run +android-integration-test-run: docker-build-android-integration-image docker-android-integration-dir ## Run adb-backed Android integration tests in Docker using existing APK + @docker run --rm --device /dev/kvm \ + -v $(CURDIR):/workspace \ + -v $(DOCKER_ANDROID_INTEGRATION_DIR):/root/.android \ + -v $(DOCKER_GO_CACHE_DIR):/root/.cache \ + --env GOPATH=/root/.cache/go \ + --env GOMODCACHE=/root/.cache/go/pkg/mod \ + -w /workspace \ + $(ANDROID_INTEGRATION_DOCKER_IMAGE) \ + /usr/local/bin/run-android-integration-test /workspace/$(DEBUG_APK) + .PHONY: docker-remove-build-image docker-remove-build-image: ## Removes the current docker build image docker rmi --force $(DOCKER_IMAGE) diff --git a/android/build.gradle b/android/build.gradle index 8f64b3de8e..25ded427ec 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -114,6 +114,7 @@ dependencies { implementation "androidx.browser:browser:1.8.0" implementation "androidx.security:security-crypto:1.1.0-alpha06" implementation "androidx.work:work-runtime:2.9.1" + implementation "androidx.work:work-runtime-ktx:2.9.1" // Kotlin dependencies. implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3" diff --git a/android/src/main/java/com/tailscale/ipn/IPNReceiver.java b/android/src/main/java/com/tailscale/ipn/IPNReceiver.java index 87ab33c023..4d60c5c440 100644 --- a/android/src/main/java/com/tailscale/ipn/IPNReceiver.java +++ b/android/src/main/java/com/tailscale/ipn/IPNReceiver.java @@ -22,6 +22,7 @@ public class IPNReceiver extends BroadcastReceiver { public static final String INTENT_CONNECT_VPN = "com.tailscale.ipn.CONNECT_VPN"; public static final String INTENT_DISCONNECT_VPN = "com.tailscale.ipn.DISCONNECT_VPN"; + public static final String INTENT_INTEGRATION_LOGIN = "com.tailscale.ipn.integration.LOGIN"; private static final String INTENT_USE_EXIT_NODE = "com.tailscale.ipn.USE_EXIT_NODE"; // Unique work names prevent connect/disconnect flapping from enqueuing a long backlog. @@ -72,6 +73,27 @@ public void onReceive(Context context, Intent intent) { .build(); workManager.enqueueUniqueWork(WORK_USE_EXIT_NODE, ExistingWorkPolicy.REPLACE, req); + } else if (BuildConfig.DEBUG && Objects.equals(action, INTENT_INTEGRATION_LOGIN)) { + Data input = + new Data.Builder() + .putString( + IntegrationLoginWorker.EXTRA_CONTROL_URL, + intent.getStringExtra( + IntegrationLoginWorker.EXTRA_CONTROL_URL)) + .putString( + IntegrationLoginWorker.EXTRA_AUTH_KEY, + intent.getStringExtra(IntegrationLoginWorker.EXTRA_AUTH_KEY)) + .build(); + + OneTimeWorkRequest req = + new OneTimeWorkRequest.Builder(IntegrationLoginWorker.class) + .setInputData(input) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .addTag(IntegrationLoginWorker.WORK_NAME) + .build(); + + workManager.enqueueUniqueWork( + IntegrationLoginWorker.WORK_NAME, ExistingWorkPolicy.REPLACE, req); } } } diff --git a/android/src/main/java/com/tailscale/ipn/IntegrationLoginWorker.kt b/android/src/main/java/com/tailscale/ipn/IntegrationLoginWorker.kt new file mode 100644 index 0000000000..86341bc9fa --- /dev/null +++ b/android/src/main/java/com/tailscale/ipn/IntegrationLoginWorker.kt @@ -0,0 +1,76 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package com.tailscale.ipn + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.tailscale.ipn.ui.localapi.Client +import com.tailscale.ipn.ui.model.Ipn +import com.tailscale.ipn.util.TSLog +import kotlinx.coroutines.CompletableDeferred + +/** + * Test-only worker used by emulator integration tests to log a debug APK into a local testcontrol + * server with an auth key. + * + * IPNReceiver only enqueues this worker when BuildConfig.DEBUG is true. Release builds do not + * expose the broadcast entry point, and minified final APKs strip this unreachable debug-only path. + */ +class IntegrationLoginWorker(context: Context, params: WorkerParameters) : + CoroutineWorker(context, params) { + override suspend fun doWork(): Result { + if (!BuildConfig.DEBUG) { + return Result.failure() + } + + val controlURL = inputData.getString(EXTRA_CONTROL_URL) + val authKey = inputData.getString(EXTRA_AUTH_KEY) + if (controlURL.isNullOrBlank() || authKey.isNullOrBlank()) { + TSLog.e(TAG, "missing $EXTRA_CONTROL_URL or $EXTRA_AUTH_KEY") + return Result.failure() + } + + return try { + login(controlURL, authKey) + Result.success() + } catch (e: Exception) { + TSLog.e(TAG, "integration login failed: $e") + Result.failure() + } + } + + private suspend fun login(controlURL: String, authKey: String) { + val app = App.get() + app.startForegroundForLogin() + + val client = Client(app.applicationScope) + val maskedPrefs = + Ipn.MaskedPrefs().apply { + ControlURL = controlURL + LoggedOut = false + } + + val prefs = await { client.editPrefs(maskedPrefs, it) }.getOrThrow() + prefs.WantRunning = true + + val opts = Ipn.Options(UpdatePrefs = prefs, AuthKey = authKey) + await { client.start(opts, it) }.getOrThrow() + await { client.startLoginInteractive(it) }.getOrThrow() + app.startVPN() + } + + private suspend fun await(call: ((kotlin.Result) -> Unit) -> Unit): kotlin.Result { + val result = CompletableDeferred>() + call { result.complete(it) } + return result.await() + } + + companion object { + const val TAG = "IntegrationLoginWorker" + const val WORK_NAME = "ipn-integration-login" + const val EXTRA_CONTROL_URL = "control_url" + const val EXTRA_AUTH_KEY = "auth_key" + } +} diff --git a/docker/Dockerfile.android-integration b/docker/Dockerfile.android-integration new file mode 100644 index 0000000000..476e288a86 --- /dev/null +++ b/docker/Dockerfile.android-integration @@ -0,0 +1,60 @@ +# Copyright (c) Tailscale Inc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause + +FROM --platform=linux/amd64 eclipse-temurin:21 + +ENV ANDROID_HOME=/opt/android-sdk +ENV ANDROID_SDK_ROOT=/opt/android-sdk +ENV ANDROID_CMDLINE_TOOLS_VERSION=9477386 +ENV PATH=$PATH:/opt/android-sdk/cmdline-tools/latest/bin:/opt/android-sdk/platform-tools:/opt/android-sdk/emulator + +RUN apt-get update && \ + apt-get -y install --no-install-recommends \ + ca-certificates \ + curl \ + gcc \ + git \ + libc6-dev \ + libdbus-1-3 \ + libfontconfig1 \ + libgl1 \ + libglib2.0-0 \ + libnss3 \ + libpulse0 \ + libstdc++6 \ + libvulkan1 \ + libx11-6 \ + libxcb-cursor0 \ + libxcb1 \ + libxext6 \ + libxi6 \ + libxrender1 \ + libxtst6 \ + make \ + tar \ + unzip \ + zip && \ + apt-get -y clean && \ + rm -rf /var/lib/apt/lists/* + +RUN mkdir -p /opt/android-sdk/cmdline-tools /tmp/android-sdk && \ + curl -fsSL -o /tmp/android-sdk/commandlinetools.zip \ + "https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip" && \ + unzip -q /tmp/android-sdk/commandlinetools.zip -d /tmp/android-sdk && \ + mv /tmp/android-sdk/cmdline-tools /opt/android-sdk/cmdline-tools/latest && \ + rm -rf /tmp/android-sdk + +RUN yes | sdkmanager --licenses >/dev/null && \ + sdkmanager \ + "build-tools;34.0.0" \ + "emulator" \ + "ndk;27.2.12479018" \ + "platform-tools" \ + "platforms;android-34" \ + "system-images;android-33;google_apis;x86_64" + +COPY scripts/run-android-integration-test-docker.sh /usr/local/bin/run-android-integration-test +RUN chmod 0755 /usr/local/bin/run-android-integration-test + +WORKDIR /workspace +CMD ["/usr/local/bin/run-android-integration-test"] diff --git a/go.mod b/go.mod index d840124f58..3a6bb67393 100644 --- a/go.mod +++ b/go.mod @@ -26,16 +26,19 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.13 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect github.com/aws/smithy-go v1.24.0 // indirect + github.com/axiomhq/hyperloglog v0.0.0-20240319100328-84253e514e02 // indirect github.com/coder/websocket v1.8.12 // indirect github.com/coreos/go-iptables v0.7.1-0.20240112124308-65c67c9f46e6 // indirect github.com/creachadair/msync v0.7.1 // indirect github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect + github.com/dgryski/go-metro v0.0.0-20180109044635-280f6062b5bc // indirect github.com/digitalocean/go-smbios v0.0.0-20180907143718-390a4f403a8e // indirect github.com/djherbis/times v1.6.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/gaissmai/bart v0.26.1 // indirect github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect github.com/go-ole/go-ole v1.3.0 // indirect + github.com/go4org/hashtriemap v0.0.0-20251130024219-545ba229f689 // indirect github.com/godbus/dbus/v5 v5.1.1-0.20230522191255-76236955d466 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/btree v1.1.3 // indirect @@ -76,6 +79,7 @@ require ( golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.53.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/term v0.42.0 // indirect diff --git a/go.sum b/go.sum index 921ad7b244..0773a75247 100644 --- a/go.sum +++ b/go.sum @@ -57,6 +57,7 @@ github.com/creachadair/taskgroup v0.13.2/go.mod h1:i3V1Zx7H8RjwljUEeUWYT30Lmb9po github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa h1:h8TfIT1xc8FWbwwpmHn1J5i43Y0uZP97GqasGCzSRJk= @@ -137,6 +138,8 @@ github.com/miekg/dns v1.1.58 h1:ca2Hdkz+cDg/7eNF6V56jjzuZ4aCAE+DbVkILdQWG/4= github.com/miekg/dns v1.1.58/go.mod h1:Ypv+3b/KadlvW9vJfXOTf300O4UqaHFzFCuHz+rPkBY= github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0= @@ -148,6 +151,10 @@ github.com/pkg/sftp v1.13.6/go.mod h1:tz1ryNURKu77RL+GuCzmoJYxQczL3wLNNpPWagdg4Q github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE= +github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/safchain/ethtool v0.3.0 h1:gimQJpsI6sc1yIqP/y8GYgiXn/NjgvpM0RNoWLVVmP0= @@ -210,6 +217,8 @@ golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= @@ -233,6 +242,8 @@ golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeu golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard/windows v0.5.3 h1:On6j2Rpn3OEMXqBq00QEDC7bWSZrPIHKIus8eIuExIE= golang.zx2c4.com/wireguard/windows v0.5.3/go.mod h1:9TEe8TJmtwyQebdFwAkEWOPr3prrtqm+REGFifP60hI= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff --git a/integration/androidvmtest/android_test.go b/integration/androidvmtest/android_test.go new file mode 100644 index 0000000000..76d61021e6 --- /dev/null +++ b/integration/androidvmtest/android_test.go @@ -0,0 +1,605 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package androidvmtest + +import ( + "context" + "crypto/tls" + _ "embed" + "flag" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "tailscale.com/derp/derpserver" + "tailscale.com/ipn/store/mem" + "tailscale.com/net/stun/stuntest" + "tailscale.com/tailcfg" + "tailscale.com/tsnet" + "tailscale.com/tstest/integration/testcontrol" + "tailscale.com/types/key" + "tailscale.com/types/logger" + "tailscale.com/types/nettype" +) + +//go:embed testdata/NetprobeReceiver.java +var netprobeReceiverJava []byte + +var ( + adbPath = flag.String("android.adb", "adb", "path to adb") + apkPath = flag.String("android.apk", os.Getenv("TAILSCALE_ANDROID_APK"), "path to Tailscale Android APK") + adbSerial = flag.String("android.serial", os.Getenv("ANDROID_SERIAL"), "adb device serial") + androidGOARCH = flag.String("android.goarch", envDefault("TAILSCALE_ANDROID_GOARCH", runtime.GOARCH), "GOARCH for adb-pushed Android test binaries") + emulatorHost = flag.String("android.emulator-host", "10.0.2.2", "host address reachable from the Android emulator") + derpHost = flag.String("android.derp-host", os.Getenv("TAILSCALE_ANDROID_DERP_HOST"), "DERP/STUN host reachable from both Android and the in-test tsnet peer") + waitTimeout = flag.Duration("android.wait", 2*time.Minute, "time to wait for Android to register with testcontrol") +) + +const ( + androidPackage = "com.tailscale.ipn" + probePackage = "com.tailscale.ipn.integrationprobe" + probeReceiver = probePackage + "/.NetprobeReceiver" + loginAction = "com.tailscale.ipn.integration.LOGIN" + authKey = "tskey-integration-android" + debugKeystorePass = "android" +) + +func TestAndroidAuthKeyLogin(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Android integration test currently only runs on Linux") + } + if *apkPath == "" { + t.Skip("set --android.apk or TAILSCALE_ANDROID_APK to run") + } + if _, err := exec.LookPath(*adbPath); err != nil { + t.Skipf("adb not found: %v", err) + } + + advertisedDERPHost := *derpHost + if advertisedDERPHost == "" { + advertisedDERPHost = nonLoopbackIPv4(t) + } + t.Logf("test DERP/STUN host: %s", advertisedDERPHost) + derpMap := runDERPAndSTUN(t, advertisedDERPHost) + control := &testcontrol.Server{ + Logf: logger.WithPrefix(t.Logf, "testcontrol: "), + DERPMap: derpMap, + RequireAuthKey: authKey, + AllOnline: true, + } + controlServer := httptest.NewServer(control) + t.Cleanup(controlServer.Close) + + controlURL := rewriteHost(t, controlServer.URL, *emulatorHost) + control.ExplicitBaseURL = controlURL + t.Logf("test control URL for Android: %s", controlURL) + + const wantBody = "hello-from-tsnet-android-integration" + peer := &tsnet.Server{ + Dir: t.TempDir(), + ControlURL: controlServer.URL, + AuthKey: authKey, + Hostname: "tsnetpeer", + Ephemeral: true, + Logf: logger.WithPrefix(t.Logf, "tsnet: "), + Store: new(mem.Store), + } + t.Cleanup(func() { peer.Close() }) + + upCtx, cancelUp := context.WithTimeout(context.Background(), 30*time.Second) + defer cancelUp() + status, err := peer.Up(upCtx) + if err != nil { + t.Fatalf("tsnet peer Up: %v", err) + } + if len(status.TailscaleIPs) == 0 { + t.Fatalf("tsnet peer has no TailscaleIPs") + } + peerIP := status.TailscaleIPs[0] + t.Logf("tsnet peer up at %v", peerIP) + + peerLn, err := peer.Listen("tcp", ":80") + if err != nil { + t.Fatalf("tsnet peer Listen :80: %v", err) + } + t.Cleanup(func() { peerLn.Close() }) + go http.Serve(peerLn, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, wantBody) + })) + + adb := adbRunner{path: *adbPath, serial: *adbSerial} + adb.run(t, "wait-for-device") + adb.run(t, "install", "-r", "-t", *apkPath) + probePath := buildAndroidProbe(t) + probeAPKPath := buildProbeAPK(t, probePath) + adb.run(t, "install", "-r", "-t", probeAPKPath) + t.Cleanup(func() { adb.runAllowError(t, "uninstall", probePackage) }) + t.Cleanup(func() { adb.runAllowError(t, "shell", "am", "force-stop", androidPackage) }) + adb.run(t, "shell", "appops", "set", androidPackage, "ACTIVATE_VPN", "allow") + adb.run(t, "shell", "am", "force-stop", androidPackage) + adb.run(t, "shell", "pm", "clear", androidPackage) + + adb.run(t, + "shell", "am", "broadcast", + "-a", loginAction, + "-n", androidPackage+"/.IPNReceiver", + "--include-stopped-packages", + "--es", "control_url", controlURL, + "--es", "auth_key", authKey, + ) + + waitForNodeCount(t, control, 2) + + probeResultPath := "files/result" + adb.run(t, "shell", "run-as", probePackage, "mkdir", "-p", "files") + + peerURL := fmt.Sprintf("http://%s/", peerIP) + t.Logf("running Android netprobe against tsnet peer URL %s", peerURL) + adb.runAllowError(t, "shell", "run-as", probePackage, "rm", "-f", probeResultPath) + adb.run(t, + "shell", "am", "broadcast", + "-n", probeReceiver, + "--es", "url", peerURL, + ) + got := waitForProbeResult(t, adb, probeResultPath) + if !strings.Contains(got, "exit=0\n") { + t.Fatalf("Android probe failed: %q", got) + } + if !strings.Contains(got, "status=200\n") { + t.Fatalf("Android probe status is not OK: %q", got) + } + if !strings.Contains(got, "body="+wantBody+"\n") { + t.Fatalf("Android probe body does not contain %q: %q", wantBody, got) + } +} + +func buildAndroidProbe(t *testing.T) string { + t.Helper() + out := t.TempDir() + "/tailscale-android-netprobe" + root := repoRoot(t) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, root+"/tool/go", "build", "-buildvcs=false", "-o", out, "./integration/androidvmtest/testnetprobe") + cmd.Dir = root + cmd.Env = append(os.Environ(), + "GOOS=android", + "GOARCH="+*androidGOARCH, + "CGO_ENABLED=1", + "CC="+androidClang(t, *androidGOARCH), + ) + buildOut, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("building Android netprobe: %v\n%s", err, buildOut) + } + return out +} + +func buildProbeAPK(t *testing.T, probePath string) string { + t.Helper() + root := repoRoot(t) + dir := t.TempDir() + manifest := filepath.Join(dir, "AndroidManifest.xml") + if err := os.WriteFile(manifest, []byte(` + + + + + +`), 0644); err != nil { + t.Fatal(err) + } + unsignedAPK := filepath.Join(dir, "probe-unsigned.apk") + alignedAPK := filepath.Join(dir, "probe-aligned.apk") + signedAPK := filepath.Join(dir, "probe.apk") + keystore := filepath.Join(dir, "debug.keystore") + javaSrcDir := filepath.Join(dir, "java", "com", "tailscale", "ipn", "integrationprobe") + classesDir := filepath.Join(dir, "classes") + dexDir := filepath.Join(dir, "dex") + libDir := filepath.Join(dir, "apkroot", "lib", androidABI(t, *androidGOARCH)) + if err := os.MkdirAll(javaSrcDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(classesDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(dexDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(libDir, 0755); err != nil { + t.Fatal(err) + } + if err := copyFile(filepath.Join(libDir, "libnetprobe.so"), probePath, 0755); err != nil { + t.Fatal(err) + } + javaSrc := filepath.Join(javaSrcDir, "NetprobeReceiver.java") + if err := os.WriteFile(javaSrc, netprobeReceiverJava, 0644); err != nil { + t.Fatal(err) + } + + runCmd(t, root, "keytool", + "-genkeypair", + "-keystore", keystore, + "-storepass", debugKeystorePass, + "-keypass", debugKeystorePass, + "-storetype", "PKCS12", + "-alias", "androiddebugkey", + "-keyalg", "RSA", + "-keysize", "2048", + "-validity", "10000", + "-dname", "CN=Android Debug,O=Android,C=US", + ) + runCmd(t, root, androidBuildTool(t, "aapt2"), + "link", + "--manifest", manifest, + "-I", androidJar(t), + "--rename-manifest-package", probePackage, + "--min-sdk-version", "24", + "--target-sdk-version", "34", + "-o", unsignedAPK, + ) + runCmd(t, root, "javac", "-source", "8", "-target", "8", "-bootclasspath", androidJar(t), "-d", classesDir, javaSrc) + d8Args := append([]string{"--min-api", "24", "--lib", androidJar(t), "--output", dexDir}, classFiles(t, classesDir)...) + runCmd(t, root, androidBuildTool(t, "d8"), d8Args...) + runCmd(t, root, "zip", "-j", unsignedAPK, filepath.Join(dexDir, "classes.dex")) + runCmd(t, filepath.Join(dir, "apkroot"), "zip", "-r", unsignedAPK, "lib") + runCmd(t, root, androidBuildTool(t, "zipalign"), "-f", "4", unsignedAPK, alignedAPK) + runCmd(t, root, androidBuildTool(t, "apksigner"), + "sign", + "--ks", keystore, + "--ks-pass", "pass:"+debugKeystorePass, + "--key-pass", "pass:"+debugKeystorePass, + "--out", signedAPK, + alignedAPK, + ) + return signedAPK +} + +func runCmd(t *testing.T, dir, name string, args ...string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, name, args...) + cmd.Dir = dir + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s %v failed: %v\n%s", name, args, err, out) + } +} + +func waitForProbeResult(t *testing.T, adb adbRunner, resultPath string) string { + t.Helper() + deadline := time.Now().Add(*waitTimeout) + for time.Now().Before(deadline) { + got, err := adb.runOutputAllowError("shell", "run-as", probePackage, "cat", resultPath) + if err == nil && got != "" { + t.Logf("Android probe result:\n%s", got) + return got + } + time.Sleep(500 * time.Millisecond) + } + t.Fatalf("timed out after %v waiting for Android probe result", *waitTimeout) + return "" +} + +func classFiles(t *testing.T, root string) []string { + t.Helper() + var files []string + if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && strings.HasSuffix(path, ".class") { + files = append(files, path) + } + return nil + }); err != nil { + t.Fatal(err) + } + if len(files) == 0 { + t.Fatalf("no .class files found under %s", root) + } + return files +} + +func copyFile(dst, src string, perm os.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, perm) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + return err + } + return out.Close() +} + +func androidABI(t *testing.T, goarch string) string { + t.Helper() + switch goarch { + case "amd64": + return "x86_64" + case "arm64": + return "arm64-v8a" + case "386": + return "x86" + case "arm": + return "armeabi-v7a" + default: + t.Fatalf("unsupported Android GOARCH %q", goarch) + return "" + } +} + +func repoRoot(t *testing.T) string { + t.Helper() + dir, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for { + if _, err := os.Stat(dir + "/tool/go"); err == nil { + if _, err := os.Stat(dir + "/go.mod"); err == nil { + return dir + } + } + parent := dir[:strings.LastIndex(dir, "/")] + if parent == "" || parent == dir { + t.Fatal("could not find repo root containing tool/go and go.mod") + } + dir = parent + } +} + +func androidClang(t *testing.T, goarch string) string { + t.Helper() + if cc := os.Getenv("ANDROID_CC"); cc != "" { + return cc + } + var triple string + switch goarch { + case "amd64": + triple = "x86_64-linux-android" + case "arm64": + triple = "aarch64-linux-android" + case "386": + triple = "i686-linux-android" + case "arm": + triple = "armv7a-linux-androideabi" + default: + t.Fatalf("unsupported Android GOARCH %q", goarch) + } + androidHome := androidHome(t) + ndkRoot := os.Getenv("ANDROID_NDK_HOME") + if ndkRoot == "" { + entries, err := os.ReadDir(androidHome + "/ndk") + if err != nil { + t.Fatalf("finding Android NDK under %s/ndk: %v", androidHome, err) + } + for i := len(entries) - 1; i >= 0; i-- { + if entries[i].IsDir() { + ndkRoot = androidHome + "/ndk/" + entries[i].Name() + break + } + } + } + if ndkRoot == "" { + t.Fatalf("no Android NDK found under %s/ndk", androidHome) + } + cc := ndkRoot + "/toolchains/llvm/prebuilt/linux-x86_64/bin/" + triple + "24-clang" + if _, err := os.Stat(cc); err != nil { + t.Fatalf("Android clang not found at %s: %v", cc, err) + } + return cc +} + +func androidJar(t *testing.T) string { + t.Helper() + return androidHome(t) + "/platforms/android-34/android.jar" +} + +func androidBuildTool(t *testing.T, name string) string { + t.Helper() + path := androidHome(t) + "/build-tools/34.0.0/" + name + if _, err := os.Stat(path); err != nil { + t.Fatalf("Android build tool %s not found at %s: %v", name, path, err) + } + return path +} + +func androidHome(t *testing.T) string { + t.Helper() + androidHome := os.Getenv("ANDROID_HOME") + if androidHome == "" { + androidHome = os.Getenv("ANDROID_SDK_ROOT") + } + if androidHome == "" { + t.Fatal("ANDROID_HOME or ANDROID_SDK_ROOT must be set") + } + return androidHome +} + +func waitForNodeCount(t *testing.T, control *testcontrol.Server, want int) { + t.Helper() + deadline := time.Now().Add(*waitTimeout) + for time.Now().Before(deadline) { + if nodes := control.AllNodes(); len(nodes) == want { + var names []string + for _, node := range nodes { + names = append(names, node.ComputedName) + } + t.Logf("registered nodes: %v", names) + return + } + time.Sleep(500 * time.Millisecond) + } + t.Fatalf("timed out after %v waiting for %d nodes; testcontrol has %d nodes", *waitTimeout, want, control.NumNodes()) +} + +type adbRunner struct { + path string + serial string +} + +func (a adbRunner) args(args ...string) []string { + if a.serial == "" { + return args + } + return append([]string{"-s", a.serial}, args...) +} + +func (a adbRunner) run(t *testing.T, args ...string) { + t.Helper() + out := a.runOutput(t, args...) + t.Logf("adb %v:\n%s", args, out) +} + +func (a adbRunner) runOutput(t *testing.T, args ...string) string { + t.Helper() + out, err := a.runOutputAllowError(args...) + if err != nil { + t.Fatalf("adb %v failed: %v\n%s", args, err, out) + } + return out +} + +func (a adbRunner) runOutputAllowError(args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + out, err := exec.CommandContext(ctx, a.path, a.args(args...)...).CombinedOutput() + return string(out), err +} + +func (a adbRunner) runAllowError(t *testing.T, args ...string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + out, err := exec.CommandContext(ctx, a.path, a.args(args...)...).CombinedOutput() + if err != nil { + t.Logf("adb %v failed during cleanup: %v\n%s", args, err, out) + } +} + +func rewriteHost(t *testing.T, rawURL, host string) string { + t.Helper() + u, err := url.Parse(rawURL) + if err != nil { + t.Fatal(err) + } + _, port, err := net.SplitHostPort(u.Host) + if err != nil { + t.Fatal(err) + } + u.Host = net.JoinHostPort(host, port) + return u.String() +} + +func nonLoopbackIPv4(t *testing.T) string { + t.Helper() + ifaces, err := net.Interfaces() + if err != nil { + t.Fatal(err) + } + for _, iface := range ifaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + addrs, err := iface.Addrs() + if err != nil { + t.Fatal(err) + } + for _, addr := range addrs { + var ip net.IP + switch v := addr.(type) { + case *net.IPNet: + ip = v.IP + case *net.IPAddr: + ip = v.IP + } + if ip4 := ip.To4(); ip4 != nil { + return ip4.String() + } + } + } + t.Fatal("no non-loopback IPv4 address found; set -android.derp-host") + return "" +} + +func runDERPAndSTUN(t testing.TB, advertisedHost string) *tailcfg.DERPMap { + t.Helper() + + d := derpserver.New(key.NewNode(), logger.WithPrefix(t.Logf, "derp: ")) + ln, err := net.Listen("tcp", ":0") + if err != nil { + t.Fatal(err) + } + + handler := derpserver.AddWebSocketSupport(d, derpserver.Handler(d)) + httpsrv := httptest.NewUnstartedServer(handler) + if err := httpsrv.Listener.Close(); err != nil { + t.Fatal(err) + } + httpsrv.Listener = ln + httpsrv.Config.TLSNextProto = make(map[string]func(*http.Server, *tls.Conn, http.Handler)) + httpsrv.StartTLS() + + stunAddr, stunCleanup := stuntest.ServeWithPacketListener(t, nettype.Std{}) + + t.Cleanup(func() { + httpsrv.CloseClientConnections() + httpsrv.Close() + d.Close() + stunCleanup() + }) + + return &tailcfg.DERPMap{ + Regions: map[int]*tailcfg.DERPRegion{ + 1: { + RegionID: 1, + RegionCode: "test", + Nodes: []*tailcfg.DERPNode{ + { + Name: "t1", + RegionID: 1, + HostName: advertisedHost, + IPv4: advertisedHost, + IPv6: "none", + STUNPort: stunAddr.Port, + DERPPort: httpsrv.Listener.Addr().(*net.TCPAddr).Port, + InsecureForTests: true, + STUNTestIP: advertisedHost, + }, + }, + }, + }, + } +} + +func TestRewriteHost(t *testing.T) { + got := rewriteHost(t, "http://127.0.0.1:12345", "10.0.2.2") + if want := "http://10.0.2.2:12345"; got != want { + t.Fatalf("rewriteHost = %q; want %q", got, want) + } +} + +func envDefault(name, def string) string { + if v := os.Getenv(name); v != "" { + return v + } + return def +} diff --git a/integration/androidvmtest/testdata/NetprobeReceiver.java b/integration/androidvmtest/testdata/NetprobeReceiver.java new file mode 100644 index 0000000000..e9b71b93bd --- /dev/null +++ b/integration/androidvmtest/testdata/NetprobeReceiver.java @@ -0,0 +1,74 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +// NetprobeReceiver is embedded by integration/androidvmtest/android_test.go and compiled into a +// temporary helper APK during the emulator integration test. +// +// The receiver runs that APK's packaged Go netprobe binary from a real Android app process, then +// writes the probe output to the app's private files directory for the Go test to read with adb. +package com.tailscale.ipn.integrationprobe; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; + +public final class NetprobeReceiver extends BroadcastReceiver { + @Override + public void onReceive(final Context context, final Intent intent) { + final PendingResult pending = goAsync(); + String path = context.getApplicationInfo().nativeLibraryDir + "/libnetprobe.so"; + String url = intent.getStringExtra("url"); + new Thread(new ProbeRunnable(context.getApplicationContext(), pending, path, url)).start(); + } + + private static final class ProbeRunnable implements Runnable { + private final Context context; + private final PendingResult pending; + private final String path; + private final String url; + + ProbeRunnable(Context context, PendingResult pending, String path, String url) { + this.context = context; + this.pending = pending; + this.path = path; + this.url = url; + } + + @Override + public void run() { + String result; + try { + Process proc = new ProcessBuilder(path, url).redirectErrorStream(true).start(); + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + InputStream in = proc.getInputStream(); + byte[] tmp = new byte[8192]; + int n; + while ((n = in.read(tmp)) != -1) { + buf.write(tmp, 0, n); + } + if (!proc.waitFor(45, TimeUnit.SECONDS)) { + proc.destroyForcibly(); + result = "timeout\n" + buf.toString("UTF-8"); + } else { + result = "exit=" + proc.exitValue() + "\n" + buf.toString("UTF-8"); + } + } catch (Exception e) { + result = "error=" + e + "\n"; + } + try { + File out = new File(context.getFilesDir(), "result"); + FileOutputStream fos = new FileOutputStream(out); + fos.write(result.getBytes(StandardCharsets.UTF_8)); + fos.close(); + } catch (Exception ignored) { + } + pending.finish(); + } + } +} diff --git a/integration/androidvmtest/testnetprobe/main.go b/integration/androidvmtest/testnetprobe/main.go new file mode 100644 index 0000000000..149987197a --- /dev/null +++ b/integration/androidvmtest/testnetprobe/main.go @@ -0,0 +1,32 @@ +// Copyright (c) Tailscale Inc & AUTHORS +// SPDX-License-Identifier: BSD-3-Clause + +package main + +import ( + "fmt" + "io" + "net/http" + "os" + "time" +) + +func main() { + if len(os.Args) != 2 { + fmt.Fprintf(os.Stderr, "usage: %s URL\n", os.Args[0]) + os.Exit(2) + } + client := &http.Client{Timeout: 30 * time.Second} + res, err := client.Get(os.Args[1]) + if err != nil { + fmt.Fprintf(os.Stderr, "GET %s: %v\n", os.Args[1], err) + os.Exit(1) + } + defer res.Body.Close() + body, err := io.ReadAll(res.Body) + if err != nil { + fmt.Fprintf(os.Stderr, "reading response body: %v\n", err) + os.Exit(1) + } + fmt.Printf("status=%d\nbody=%s\n", res.StatusCode, body) +} diff --git a/scripts/run-android-integration-test-docker.sh b/scripts/run-android-integration-test-docker.sh new file mode 100644 index 0000000000..9aae1ef3aa --- /dev/null +++ b/scripts/run-android-integration-test-docker.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Copyright (c) Tailscale Inc & AUTHORS +# SPDX-License-Identifier: BSD-3-Clause + +set -euo pipefail + +AVD="${AVD:-tailscale-integration}" +AVD_IMAGE="${AVD_IMAGE:-system-images;android-33;google_apis;x86_64}" +KEYCODE_MENU=82 +APK="${1:-/workspace/tailscale-debug.apk}" +shift || true + +export GOPATH="${GOPATH:-${HOME}/.cache/go}" +export GOMODCACHE="${GOMODCACHE:-${GOPATH}/pkg/mod}" + +if [[ ! -f "${APK}" ]]; then + echo "APK not found: ${APK}" >&2 + exit 1 +fi + +if [[ ! -e /dev/kvm ]]; then + echo "/dev/kvm is not available. Run this container with --device /dev/kvm." >&2 + exit 1 +fi + +mkdir -p "${HOME}/.android" +touch "${HOME}/.android/repositories.cfg" + +if ! avdmanager list avd | grep -q "Name: ${AVD}$"; then + echo "Creating AVD ${AVD} (${AVD_IMAGE})" + echo "no" | avdmanager create avd -n "${AVD}" -k "${AVD_IMAGE}" --device pixel +fi + +emulator -avd "${AVD}" \ + -no-window \ + -no-audio \ + -no-snapshot \ + -no-boot-anim \ + -gpu swiftshader_indirect \ + -netdelay none \ + -netspeed full & +emulator_pid=$! + +cleanup() { + adb emu kill >/dev/null 2>&1 || true + wait "${emulator_pid}" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +adb wait-for-device + +deadline=$((SECONDS + 180)) +while [[ "$(adb shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')" != "1" ]]; do + if (( SECONDS > deadline )); then + echo "Timed out waiting for emulator boot" >&2 + adb devices -l >&2 || true + exit 1 + fi + sleep 2 +done + +adb shell input keyevent "${KEYCODE_MENU}" >/dev/null 2>&1 || true + +./tool/go test ./integration/androidvmtest \ + -run TestAndroidAuthKeyLogin \ + -android.apk="${APK}" \ + "$@"