From 03cda31733f0d3f7e56d5b1960b84574226e6a9b Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 6 Oct 2026 19:33:50 +0300 Subject: [PATCH 1/6] Test real Android integration on API 26 and API 35 emulators --- .github/workflows/android-device.yml | 113 +++++++ .github/workflows/ci.yml | 18 + .gitignore | 3 + AGENTS.md | 18 +- README.md | 4 +- app/build.gradle.kts | 6 + .../java/net/megaproxy487/DeviceTestBase.kt | 120 +++++++ .../net/megaproxy487/DocumentsDeviceTest.kt | 79 +++++ .../megaproxy487/KeystoreProcessDeviceTest.kt | 49 +++ .../java/net/megaproxy487/VpnDeviceTest.kt | 108 ++++++ .../net/megaproxy487/vpn/ProxyVpnService.kt | 3 + docs/en/fastlane.md | 44 +++ docs/ru/fastlane.md | 45 +++ fastlane/Fastfile | 13 + fastlane/README.md | 3 + scripts/android_device_tests.py | 318 ++++++++++++++++++ scripts/ci_changes.py | 6 +- scripts/ci_history.py | 2 + scripts/tests/test_android_device_tests.py | 24 ++ scripts/tests/test_ci_changes.py | 30 +- scripts/tests/test_ci_history.py | 14 +- 21 files changed, 1003 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/android-device.yml create mode 100644 app/src/androidTest/java/net/megaproxy487/DeviceTestBase.kt create mode 100644 app/src/androidTest/java/net/megaproxy487/DocumentsDeviceTest.kt create mode 100644 app/src/androidTest/java/net/megaproxy487/KeystoreProcessDeviceTest.kt create mode 100644 app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt create mode 100644 scripts/android_device_tests.py create mode 100644 scripts/tests/test_android_device_tests.py diff --git a/.github/workflows/android-device.yml b/.github/workflows/android-device.yml new file mode 100644 index 0000000..b52ea4f --- /dev/null +++ b/.github/workflows/android-device.yml @@ -0,0 +1,113 @@ +name: Android device tests + +on: + workflow_call: + inputs: + api: + required: true + type: number + +permissions: + contents: read + +jobs: + device: + name: Device tests + runs-on: ubuntu-24.04 + timeout-minutes: 45 + env: + ANDROID_SERIAL: emulator-5554 + MEGAPROXY_ANDROID_ABI: x86_64 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + cache: gradle + - uses: actions/setup-go@v7 + with: + go-version-file: native/go.mod + cache-dependency-path: native/go.sum + - uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4.10" + bundler-cache: true + - uses: android-actions/setup-android@v4 + with: + packages: platform-tools + log-accepted-android-sdk-licenses: false + - name: Enable hardware acceleration + run: | + test -e /dev/kvm + 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 + - name: Install SDK and fingerprint snapshot dependencies + id: sdk + env: + DEVICE_API: ${{ inputs.api }} + run: | + sdkmanager 'emulator' 'platform-tools' 'platforms;android-36' 'build-tools;36.0.0' 'ndk;29.0.14206865' "system-images;android-$DEVICE_API;google_apis;x86_64" + echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV" + "$ANDROID_HOME/emulator/emulator" -accel-check + snapshot_hash=$(cat "$ANDROID_HOME/emulator/source.properties" "$ANDROID_HOME/system-images/android-$DEVICE_API/google_apis/x86_64/source.properties" | sha256sum | cut -d ' ' -f 1) + echo "snapshot-hash=$snapshot_hash" >> "$GITHUB_OUTPUT" + - name: Restore clean emulator snapshot + id: avd + uses: actions/cache/restore@v4 + with: + path: | + ~/.android/avd/megaproxy-device.ini + ~/.android/avd/megaproxy-device.avd/ + key: android-device-v1-${{ runner.os }}-api${{ inputs.api }}-google-apis-x86_64-${{ steps.sdk.outputs.snapshot-hash }}-${{ hashFiles('.github/workflows/android-device.yml') }} + - name: Generate clean snapshot on cache miss + if: steps.avd.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2.38.0 + with: + api-level: ${{ inputs.api }} + target: google_apis + arch: x86_64 + avd-name: megaproxy-device + profile: pixel_5 + force-avd-creation: false + emulator-options: -accel on -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none + disable-animations: false + script: adb shell input keyevent 82 + - name: Save clean snapshot before installing MegaProxy + if: steps.avd.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ~/.android/avd/megaproxy-device.ini + ~/.android/avd/megaproxy-device.avd/ + key: ${{ steps.avd.outputs.cache-primary-key }} + - name: Build debug and instrumentation APKs + run: bundle exec fastlane android device_test_build + - name: Run real Android tests + uses: reactivecircus/android-emulator-runner@v2.38.0 + with: + api-level: ${{ inputs.api }} + target: google_apis + arch: x86_64 + avd-name: megaproxy-device + profile: pixel_5 + force-avd-creation: false + emulator-options: -accel on -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none + disable-animations: true + script: bundle exec fastlane android device_tests api:${{ inputs.api }} + - name: Upload Android evidence + if: always() + id: evidence + uses: actions/upload-artifact@v7 + with: + name: android-api${{ inputs.api }}-results + path: test-results/android-api${{ inputs.api }}/ + if-no-files-found: warn + retention-days: 7 + - name: Link evidence in job summary + if: always() && steps.evidence.outputs.artifact-url != '' + env: + ARTIFACT_URL: ${{ steps.evidence.outputs.artifact-url }} + DEVICE_API: ${{ inputs.api }} + run: echo "[Android API $DEVICE_API reports, Logcat and failure screenshots]($ARTIFACT_URL)" >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 002ec77..d8a51be 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,8 @@ jobs: android: ${{ steps.scope.outputs.android }} native: ${{ steps.scope.outputs.native }} python: ${{ steps.scope.outputs.python }} + emulator26: ${{ steps.scope.outputs.emulator26 }} + emulator35: ${{ steps.scope.outputs.emulator35 }} steps: - uses: actions/checkout@v6 with: @@ -188,3 +190,19 @@ jobs: echo echo "Artifacts expire after 14 days. Neither APK uses the MegaProxy release key." } >> "$GITHUB_STEP_SUMMARY" + + emulator26: + name: Android emulator API 26 + needs: changes + if: needs.changes.outputs.emulator26 == 'true' + uses: ./.github/workflows/android-device.yml + with: + api: 26 + + emulator35: + name: Android emulator API 35 + needs: changes + if: needs.changes.outputs.emulator35 == 'true' + uses: ./.github/workflows/android-device.yml + with: + api: 35 diff --git a/.gitignore b/.gitignore index 0d00147..bf117ba 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ __pycache__/ # Local Bundler configuration and installed gems /.bundle/ /vendor/bundle/ + +/test-results/ +/fastlane/report.xml diff --git a/AGENTS.md b/AGENTS.md index 0d59550..3d87fb7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,13 +21,13 @@ branch names, credentials, signing material, or other secrets. ## CI and artifacts -- Pull requests must run native tests and Android JVM unit/lint/build checks. Do not require an - Android emulator in GitHub Actions: hosted-runner KVM availability proved too unreliable for a - trustworthy required check. +- Pull requests must run native tests, Android JVM unit/lint/build checks and independent + Android emulator API 26/API 35 integration scenarios. Use Ubuntu 24.04 with explicit KVM + permissions, acceleration checks and clean snapshot caches, following BrowserMegaProxy. - Compare each suite against its last successful ancestor check in the same PR and base. Failed/skipped/cancelled jobs do not advance coverage. Fall back to the full PR diff when - history is unavailable; unknown paths and shared build/CI inputs enable all suites. Require `Change scope` and `Python tests and style` - alongside native/Android checks when this workflow is adopted. + history is unavailable; unknown paths and shared build/CI inputs enable all suites. Require `Change scope`, `Python tests and style` and both emulator scenarios + alongside native/Android JVM checks when this workflow is adopted. - Every push to main runs all suites without diff/history filtering. The README CI badge is pinned to main/push; selective checks apply to initial PR runs. - A full CI rerun disables change filtering when Change scope executes on run attempt > 1, @@ -46,9 +46,9 @@ branch names, credentials, signing material, or other secrets. service commands or their absence, drain ordered configuration writes and check both completion and failure state; `pending == 0` alone does not prove success. Recorded service intents do not establish VPN lifecycle/JNI coverage. See `docs/reviews/test-quality.md` for coverage gaps. -- Keep device-only tests out of required GitHub CI unless the project later adopts a dependable - device farm or controlled self-hosted runner. Do not reintroduce a software-emulated Android - fallback. +- Keep device tests focused on real TUN/JNI traffic, service lifecycle, system permissions, + Keystore process restarts and document providers. Keep deterministic logic in JVM/Go tests. + Never fall back to unaccelerated software emulation or retry failed tests to obtain green CI. - The native CI job also runs `native_integration`: real GOST/OpenSSH containers and a private HTTP origin, using disposable credentials and the production dialers. Keep it separate from ordinary local Go tests and Android TUN/JNI coverage; never use a public or personal proxy. @@ -87,7 +87,7 @@ branch names, credentials, signing material, or other secrets. - Keep screens usable on narrow windows and with enlarged system fonts. Let actions and status rows wrap or stack; constrain app-bar titles and field labels, and make long dialog content - scrollable. Verify visual changes locally without adding emulator requirements to GitHub CI. + scrollable. Keep full visual configuration checks local; emulator CI covers the agreed integration scenarios. - Configuration writes must outlive individual screens and expose pending/failure state. Keep transfer operations across configuration changes; never put credentials or export payloads into diff --git a/README.md b/README.md index e457d12..c9f4114 100644 --- a/README.md +++ b/README.md @@ -230,7 +230,9 @@ For optional local device testing on an ARM64 host, create the API 35 Google API ./scripts/create-android-emulator.sh ``` -Required CI and Robolectric Compose tests do not need an emulator. +Robolectric Compose tests do not need an emulator. CI also runs independent hardware-accelerated +API 26 and API 35 integration scenarios for real VPN/JNI, Keystore and system document providers. +See the [Fastlane reference](docs/en/fastlane.md#android-emulator-integration-tests). The script installs missing components, configures host keyboard and mouse input, and can be run more than once. It creates `MegaProxy_API_35` by default; set `MEGAPROXY_AVD_NAME` to override the diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 631c426..048cb3d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,6 +42,7 @@ android { applicationId = "net.megaproxy487" minSdk = 26 targetSdk = 36 + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" // Each APK has a unique, monotonically ordered code. Keeping the // universal code below the ABI variants lets app stores prefer the // smaller compatible APK when both are available. @@ -139,6 +140,11 @@ dependencies { testImplementation("androidx.compose.ui:ui-test-junit4") debugImplementation("androidx.compose.ui:ui-test-manifest") testImplementation("junit:junit:4.13.2") + androidTestImplementation(platform("androidx.compose:compose-bom:2025.01.01")) + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + androidTestImplementation("androidx.test:runner:1.6.2") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0") testImplementation("org.json:json:20250107") implementation(files("libs/megaproxy.aar")) } diff --git a/app/src/androidTest/java/net/megaproxy487/DeviceTestBase.kt b/app/src/androidTest/java/net/megaproxy487/DeviceTestBase.kt new file mode 100644 index 0000000..1d51fb6 --- /dev/null +++ b/app/src/androidTest/java/net/megaproxy487/DeviceTestBase.kt @@ -0,0 +1,120 @@ +package net.megaproxy487 + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.VpnService +import android.os.SystemClock +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.Until +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import net.megaproxy487.data.ConfigIoDispatcher +import net.megaproxy487.data.ConfigStore +import net.megaproxy487.data.ConfigWrites +import net.megaproxy487.model.GlobalConnectionSettings +import net.megaproxy487.model.ProxyConfig +import net.megaproxy487.vpn.ProxyVpnService +import net.megaproxy487.vpn.VpnConnectionState +import net.megaproxy487.vpn.VpnRuntimeState +import org.junit.Assert.* +import org.junit.After +import org.junit.Rule +import org.junit.rules.ExternalResource +import org.junit.rules.RuleChain +import java.net.InetSocketAddress +import java.net.Socket + +/** Real Application, Android Keystore, service and generated JNI; no platform shadows. */ +abstract class DeviceTestBase { + protected val instrumentation = InstrumentationRegistry.getInstrumentation() + protected val context = instrumentation.targetContext + protected val arguments = InstrumentationRegistry.getArguments() + protected val device = UiDevice.getInstance(instrumentation) + protected val store get() = ConfigStore(context) + protected val compose = createAndroidComposeRule() + protected fun argument(name: String) = requireNotNull(arguments.getString(name)) { "Missing fixture argument: $name" } + protected fun text(id: Int) = context.getString(id) + protected fun node(id: Int) = compose.onNodeWithText(text(id)) + protected fun io(block: () -> Unit) = runBlocking { withContext(ConfigIoDispatcher) { block() } } + protected fun saved() { + io {} // Barrier behind every ordered write, including service commands. + assertEquals(0, ConfigWrites.status.value.pending) + assertFalse("Configuration write failed", ConfigWrites.status.value.failed) + } + protected fun await(message: String, timeout: Long = 15_000, predicate: () -> Boolean) { + val deadline = SystemClock.elapsedRealtime() + timeout + while (!predicate()) { + check(SystemClock.elapsedRealtime() < deadline) { message } + SystemClock.sleep(50) + } + } + protected fun vpnPresent(): Boolean { + val manager = context.getSystemService(ConnectivityManager::class.java) + return manager.allNetworks.any { manager.getNetworkCapabilities(it)?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true } + } + protected fun stopped() { + saved() + await("VPN did not stop") { !ProxyVpnService.isRunning && !vpnPresent() && VpnRuntimeState.connection.value == VpnConnectionState.DISCONNECTED } + assertFalse(store.isConnectionDesired()) + } + protected fun systemButton(resource: String) { + val button = device.wait(Until.findObject(By.res(resource)), 10_000) + assertNotNull("Missing system button $resource", button) + button.click() + device.waitForIdle() + } + protected fun connect() { + node(R.string.connect).performScrollTo().performClick() + if (VpnService.prepare(context) != null) systemButton("android:id/button1") + await("Real VPN did not connect") { ProxyVpnService.isRunning && vpnPresent() && VpnRuntimeState.connection.value == VpnConnectionState.CONNECTED } + saved() + } + protected fun roundTrip() { + val payload = "MegaProxy device ${System.nanoTime()}" + Socket().use { socket -> + socket.soTimeout = 15_000 + socket.connect(InetSocketAddress(argument("originHost"), 8080), 15_000) + val request = "POST /echo HTTP/1.1\r\nHost: fixture\r\nConnection: close\r\nContent-Length: ${payload.length}\r\n\r\n$payload" + socket.getOutputStream().write(request.toByteArray(Charsets.US_ASCII)) + val response = socket.getInputStream().bufferedReader(Charsets.US_ASCII).readText() + assertTrue("Origin did not return HTTP 200", response.startsWith("HTTP/1.0 200")) + assertTrue(response.contains("X-MegaProxy-Origin: integration")) + assertEquals(payload, response.substringAfter("\r\n\r\n")) + } + } + protected fun directOriginUnavailable() { + assertFalse("Origin is reachable without the VPN", runCatching { + Socket().use { it.connect(InetSocketAddress(argument("originHost"), 8080), 1_000) } + }.isSuccess) + } + + private val platform = object : ExternalResource() { + override fun before() { + if (arguments.getString("phase") != "verify") io { + assertTrue(context.getSharedPreferences("proxy_config", Context.MODE_PRIVATE).edit().clear().commit()) + val profile = store.activeProfile() + store.saveProfile(profile.copy(name = "Device fixture", config = ProxyConfig( + host = "10.0.2.2", port = argument("proxyPort").toInt(), username = "exit", + password = argument("proxyPassword"), allowInvalidProxyCertificate = true, + ))) + store.saveGlobalConnectionSettings(GlobalConnectionSettings(bypassLocalNetworks = false)) + } + assertTrue(context.getSharedPreferences("battery_optimization_reminder", Context.MODE_PRIVATE) + .edit().putLong("last_request_at", System.currentTimeMillis()).commit()) + } + } + @After fun cleanup() { + try { + ProxyVpnService.stop(context) + stopped() + } finally { + saved() + } + } + @get:Rule val rules: RuleChain = RuleChain.outerRule(platform).around(compose) +} diff --git a/app/src/androidTest/java/net/megaproxy487/DocumentsDeviceTest.kt b/app/src/androidTest/java/net/megaproxy487/DocumentsDeviceTest.kt new file mode 100644 index 0000000..b36984b --- /dev/null +++ b/app/src/androidTest/java/net/megaproxy487/DocumentsDeviceTest.kt @@ -0,0 +1,79 @@ +package net.megaproxy487 + +import androidx.activity.compose.setContent +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.* +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import net.megaproxy487.data.ConfigTransfer +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DocumentsDeviceTest : DeviceTestBase() { + private fun screen() { + compose.activityRule.scenario.onActivity { activity -> + activity.setContent { MaterialTheme { ProfilesScreen(activity, {}, {}) } } + } + compose.waitUntil(10_000) { compose.onAllNodesWithText("Device fixture").fetchSemanticsNodes().isNotEmpty() } + } + private fun systemNode(selector: androidx.test.uiautomator.BySelector): androidx.test.uiautomator.UiObject2 { + val result = device.wait(Until.findObject(selector), 10_000) + assertNotNull("Document picker control missing: $selector", result) + return result + } + private fun downloads() { + systemNode(By.desc("Show roots")).click() + systemNode(By.text("Downloads")).click() + } + private fun importFile() { + compose.onNodeWithContentDescription(text(R.string.import_action)).performClick() + downloads() + } + @Test fun cancelledDocumentSelectionPreservesConfiguration() { + screen() + val before = ConfigTransfer.exportJson(store, true, true) + compose.onNodeWithContentDescription(text(R.string.import_action)).performClick() + systemNode(By.desc("Show roots")) + device.pressBack() + compose.waitForIdle() + saved() + assertEquals(before, ConfigTransfer.exportJson(store, true, true)) + compose.onNodeWithContentDescription(text(R.string.export_action)).performClick() + node(R.string.export_action).performClick() + systemNode(By.desc("Show roots")) + device.pressBack() + compose.waitForIdle() + saved() + assertEquals(before, ConfigTransfer.exportJson(store, true, true)) + } + @Test fun systemProviderExportAndImportRoundTrip() { + val filename = "megaproxy-device-${System.nanoTime()}.json" + screen() + try { + compose.onNodeWithContentDescription(text(R.string.export_action)).performClick() + node(R.string.passwords_omitted_message).assertExists() + node(R.string.export_action).performClick() + downloads() + systemNode(By.clazz("android.widget.EditText")).text = filename + systemNode(By.text("SAVE")).click() + compose.waitUntil(10_000) { compose.onAllNodesWithText(text(R.string.configuration_exported)).fetchSemanticsNodes().isNotEmpty() } + node(R.string.ok).performClick() + val exported = device.executeShellCommand("cat /sdcard/Download/$filename") + val configuration = ConfigTransfer.importJson(exported) + assertEquals("Device fixture", configuration.profiles.single().name) + assertFalse(exported.contains(argument("proxyPassword"))) + io { store.saveProfile(store.activeProfile().copy(name = "Changed locally")) } + importFile() + systemNode(By.text(filename)).click() + await("Import did not restore the exported profile") { store.activeProfile().name == "Device fixture" } + saved() + assertEquals(configuration.profiles.single().id, store.activeProfileId()) + assertTrue("Omitted password must preserve the local secret", store.activeProfile().config.password == argument("proxyPassword")) + } finally { + device.executeShellCommand("rm -f /sdcard/Download/$filename") + } + } +} diff --git a/app/src/androidTest/java/net/megaproxy487/KeystoreProcessDeviceTest.kt b/app/src/androidTest/java/net/megaproxy487/KeystoreProcessDeviceTest.kt new file mode 100644 index 0000000..6964f40 --- /dev/null +++ b/app/src/androidTest/java/net/megaproxy487/KeystoreProcessDeviceTest.kt @@ -0,0 +1,49 @@ +package net.megaproxy487 + +import android.os.Process +import android.util.Base64 +import androidx.test.ext.junit.runners.AndroidJUnit4 +import net.megaproxy487.model.ProxyType +import net.megaproxy487.model.SshAuthMode +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import java.io.File +import java.security.KeyStore + +@RunWith(AndroidJUnit4::class) +class KeystoreProcessDeviceTest : DeviceTestBase() { + @Test fun credentialsSurviveProcessRestart() { + val privateKey = Base64.decode(argument("sshKey"), Base64.NO_WRAP).toString(Charsets.UTF_8) + val marker = File(context.filesDir, "device-test-process") + if (argument("phase") == "seed") { + io { + store.saveProfile(store.activeProfile().let { it.copy(config = it.config.copy( + type = ProxyType.SSH, port = argument("sshPort").toInt(), username = "proxy", + password = argument("sshPassword"), privateKey = privateKey, + trustedHostKey = argument("sshFingerprint"), allowInvalidProxyCertificate = false, + )) }) + store.saveGlobalConnectionSettings(store.globalConnectionSettings().copy(sshAuthMode = SshAuthMode.KEY_ONLY)) + } + saved() + val preferences = File(context.applicationInfo.dataDir, "shared_prefs/proxy_config.xml").readText() + assertFalse("Plaintext password on disk", preferences.contains(argument("sshPassword"))) + assertFalse("Plaintext key on disk", preferences.contains("BEGIN OPENSSH PRIVATE KEY")) + assertTrue(KeyStore.getInstance("AndroidKeyStore").apply { load(null) } + .containsAlias("megaproxy.proxy.credentials.v1")) + marker.writeText(Process.myPid().toString()) + } else { + assertEquals("verify", argument("phase")) + assertNotEquals("Activity recreation is insufficient: require a fresh process", marker.readText(), Process.myPid().toString()) + val config = store.activeProfile().config + // Avoid printing disposable private key material in assertion failures. + assertTrue("Password did not survive process restart", config.password == argument("sshPassword")) + assertTrue("SSH key did not survive process restart", config.privateKey == privateKey) + assertEquals(argument("sshFingerprint"), config.trustedHostKey) + directOriginUnavailable() + connect() + roundTrip() // KEY_ONLY proves the recovered key works with real OpenSSH. + marker.delete() + } + } +} diff --git a/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt b/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt new file mode 100644 index 0000000..53af8be --- /dev/null +++ b/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt @@ -0,0 +1,108 @@ +package net.megaproxy487 + +import android.Manifest +import android.content.pm.PackageManager +import android.net.VpnService +import android.os.Build +import android.os.SystemClock +import androidx.compose.ui.test.* +import androidx.test.ext.junit.runners.AndroidJUnit4 +import net.megaproxy487.model.ProxyType +import net.megaproxy487.vpn.ProxyVpnService +import net.megaproxy487.vpn.VpnConnectionState +import net.megaproxy487.vpn.VpnRuntimeState +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class VpnDeviceTest : DeviceTestBase() { + @Test fun trafficStopAndRestart() { + directOriginUnavailable() + connect() + roundTrip() + node(R.string.disconnect).performScrollTo().performClick() + stopped() + directOriginUnavailable() + connect() + roundTrip() + } + + @Test fun deniedVpnConsentDoesNotStartTunnel() { + assertNotNull("Runner must revoke VPN consent before this test", VpnService.prepare(context)) + node(R.string.connect).performScrollTo().performClick() + systemButton("android:id/button2") + node(R.string.vpn_permission_denied).assertExists() + stopped() + assertNotNull(VpnService.prepare(context)) + directOriginUnavailable() + } + + @Test fun notificationActionStopsRealService() { + connect() + roundTrip() + device.openNotification() + val action = device.wait(androidx.test.uiautomator.Until.findObject( + androidx.test.uiautomator.By.text(text(R.string.disconnect))), 10_000) + assertNotNull("VPN notification must expose Disconnect", action) + action.click() + stopped() + device.pressBack() + directOriginUnavailable() + } + + @Test fun stopDuringSshHandshakeCannotReviveVpn() { + io { store.saveProfile(store.activeProfile().let { + it.copy(config = it.config.copy(type = ProxyType.SSH, port = argument("stallPort").toInt(), username = "proxy", acceptAnyHostKey = true)) + }) } + // Real JNI Start waits for this fixture's SSH banner; the host records acceptance. + if (VpnService.prepare(context) != null) { + compose.activityRule.scenario.onActivity { it.startActivity(VpnService.prepare(it)) } + systemButton("android:id/button1") + } + ProxyVpnService.start(context) + await("JNI startup did not reach SSH fixture") { + SocketControl.accepted(context, argument("controlPort").toInt()) + } + assertEquals(VpnConnectionState.CONNECTING, VpnRuntimeState.connection.value) + ProxyVpnService.stop(context) + stopped() + SocketControl.release(context, argument("controlPort").toInt()) + val deadline = SystemClock.elapsedRealtime() + 35_000 + while (SystemClock.elapsedRealtime() < deadline) { + assertFalse("A stale native start revived the VPN", ProxyVpnService.isRunning) + assertEquals(VpnConnectionState.DISCONNECTED, VpnRuntimeState.connection.value) + assertFalse(store.isConnectionDesired()) + SystemClock.sleep(100) + } + directOriginUnavailable() + } + + @Test fun deniedNotificationsStillAllowVpn() { + if (Build.VERSION.SDK_INT < 33) return // Permission does not exist on API 26. + assertEquals(PackageManager.PERMISSION_DENIED, context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)) + node(R.string.connect).performScrollTo().performClick() + systemButton("com.android.permissioncontroller:id/permission_deny_button") + if (VpnService.prepare(context) != null) systemButton("android:id/button1") + await("VPN failed after notification denial") { ProxyVpnService.isRunning && vpnPresent() } + assertEquals(PackageManager.PERMISSION_DENIED, context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)) + roundTrip() + } +} + +private object SocketControl { + // Fixture coordination must remain reachable while the TUN is awaiting native startup. + private fun request(context: android.content.Context, port: Int, path: String): String { + val manager = context.getSystemService(android.net.ConnectivityManager::class.java) + val network = manager.allNetworks.first { + manager.getNetworkCapabilities(it)?.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN) == false + } + return network.socketFactory.createSocket("10.0.2.2", port).use { + it.soTimeout = 2_000 + it.getOutputStream().write("GET /$path HTTP/1.0\r\n\r\n".toByteArray()) + it.getInputStream().bufferedReader().readText().substringAfter("\r\n\r\n") + } + } + fun accepted(context: android.content.Context, port: Int) = request(context, port, "accepted") == "yes" + fun release(context: android.content.Context, port: Int) { assertEquals("yes", request(context, port, "release")) } +} diff --git a/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt b/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt index a2c98d9..82f05f2 100644 --- a/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt +++ b/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt @@ -771,6 +771,9 @@ class ProxyVpnService : VpnService() { .setOngoing(true) .setCategory(NotificationCompat.CATEGORY_SERVICE) .setContentIntent(hostKeyPrompt ?: PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE)) + .addAction(0, uiText(R.string.disconnect), PendingIntent.getService( + this, 1, Intent(this, ProxyVpnService::class.java).setAction(ACTION_STOP), PendingIntent.FLAG_IMMUTABLE, + )) .also { builder -> hostKeyPrompt?.let { builder.addAction(0, uiText(R.string.review_ssh_key), it) } } .build() diff --git a/docs/en/fastlane.md b/docs/en/fastlane.md index 62710f4..a737041 100644 --- a/docs/en/fastlane.md +++ b/docs/en/fastlane.md @@ -44,6 +44,9 @@ That command lists the lanes available in the checked-out version of the project | `bundle exec fastlane android native_tests` | Runs all Go tests with the race detector. | | `bundle exec fastlane android native_integration` | Tests production dialers against real GOST/OpenSSH servers in Docker with the race detector. | | `bundle exec fastlane android android_checks` | Builds the native AAR, runs Android unit tests and lint, builds a debug APK, then builds and verifies an unsigned release APK. It rejects any release-signing environment variables. | +| `bundle exec fastlane android device_test_build` | Build the native AAR, debug APK and instrumentation APK. | +| `bundle exec fastlane android device_tests api:26` | Run integration scenarios on a running disposable API 26 emulator. | +| `bundle exec fastlane android device_tests api:35` | Run integration scenarios on a running disposable API 35 emulator. | | `bundle exec fastlane android test` | Runs `native_tests` and `android_checks`; this is the normal pre-commit command. | | `bundle exec fastlane android debug_artifact` | Builds `app/build/outputs/apk/debug/app-debug.apk`. | | `bundle exec fastlane android release_prepare version:0.1.2` | Generates EN/RU notes, increments the version and creates a release PR (requires API/token setup). | @@ -220,3 +223,44 @@ See [Robolectric setup](https://robolectric.org/getting-started/). [Release workflow setup and recovery](release-automation.md). Release branches `release/vX.Y.Z` always run all CI suites; skipped jobs cannot authorize release finalization. + +## Android emulator integration tests + +CI runs API 26 and API 35 as independent jobs with independent successful-history baselines. +Both use Ubuntu 24.04, explicit KVM permissions, mandatory hardware acceleration and clean AVD +snapshots keyed by API, emulator/system-image versions and workflow content. No software fallback +or automatic test retry is used. Main pushes and full reruns execute both scenarios. + +Locally, start a **disposable** Google APIs emulator with English system UI and set +`ANDROID_SERIAL=emulator-5554` (use its actual serial), then run: + +```shell +bundle exec fastlane android device_test_build +bundle exec fastlane android device_tests api:35 +``` + +Use `api:26` for a separate API 26 emulator. Build the native AAR for the emulator ABI; CI sets +`MEGAPROXY_ANDROID_ABI=x86_64`, while the default local build includes all ABIs. Docker must be +running. On Linux the fixture requires passwordless `sudo iptables` to reject direct host/emulator +connections to the unpublished origin; the disposable rule is removed on exit. Docker VM origins +are inaccessible directly on macOS. Every network scenario verifies that direct access fails. +The runner clears **MegaProxy app data on the selected emulator**, uses disposable passwords and +SSH keys, and force-stops the app between scenarios. Do not point it at a development emulator +with data you want to keep. It rejects physical devices and API mismatches. + +Coverage: real HTTPS proxy traffic through TUN/JNI, stop/restart, stop while real JNI awaits an +SSH handshake, denied VPN consent, notification Disconnect, notification permission denial on +API 35, encrypted password/key persistence across a fresh app process and real KEY_ONLY OpenSSH +traffic, and system DocumentsUI export/import/cancellation. Exports omit passwords by default. +System picker tests exercise the actual Downloads provider and returned content URI. + +The process test has separate seed/verify instrumentation invocations with an explicit force-stop +between them; activity recreation alone cannot pass it. All configuration checks drain the +ordered executor and assert both completion and absence of write failure. Network tests use an +unpublished HTTP echo origin and assert the response marker and exact payload, not only CONNECTED. + +JUnit XML, instrumentation output and Logcat are saved under `test-results/android-api26/` or +`test-results/android-api35/`; failures also capture a screenshot and UI hierarchy. CI uploads +separate seven-day artifacts and links them in each job summary. Pure logic, most Compose +interactions and the full transport failure matrix remain in JVM/Robolectric/Go tests. +These emulators do not certify OEM behavior, physical network handover or all Always-on modes. diff --git a/docs/ru/fastlane.md b/docs/ru/fastlane.md index b064804..a487869 100644 --- a/docs/ru/fastlane.md +++ b/docs/ru/fastlane.md @@ -45,6 +45,9 @@ bundle exec fastlane lanes | `bundle exec fastlane android native_tests` | Запускает все Go-тесты с race detector. | | `bundle exec fastlane android native_integration` | Проверяет сетевой код с настоящими GOST/OpenSSH в Docker и race detector. | | `bundle exec fastlane android android_checks` | Собирает native AAR, запускает Android unit-тесты и lint, собирает debug APK, затем собирает и проверяет unsigned release APK. Команда отклоняет переменные release-подписи. | +| `bundle exec fastlane android device_test_build` | Собирает native AAR, debug APK и instrumentation APK. | +| `bundle exec fastlane android device_tests api:26` | Проверяет интеграционные сценарии на запущенном одноразовом эмуляторе API 26. | +| `bundle exec fastlane android device_tests api:35` | Проверяет интеграционные сценарии на запущенном одноразовом эмуляторе API 35. | | `bundle exec fastlane android test` | Выполняет `native_tests` и `android_checks`; основная команда перед коммитом. | | `bundle exec fastlane android debug_artifact` | Собирает `app/build/outputs/apk/debug/app-debug.apk`. | | `bundle exec fastlane android release_prepare version:0.1.2` | Генерирует EN/RU changelog, повышает версию и создаёт release PR (нужна настройка API/токенов). | @@ -223,3 +226,45 @@ Robolectric фиксирует команды сервиса и подставл [Настройка workflow выпуска и восстановление после ошибок](release-automation.md). Release-ветки `release/vX.Y.Z` всегда запускают все наборы CI; skipped job не разрешает завершение выпуска. + +## Интеграционные тесты на Android-эмуляторах + +CI запускает API 26 и API 35 отдельными заданиями с независимой историей успешных проверок. +Оба используют Ubuntu 24.04, явные разрешения KVM, обязательное аппаратное ускорение и чистые +снимки AVD. Ключ кеша включает API, версии эмулятора/system image и содержимое workflow. +Программного fallback и автоматических повторов тестов нет. Push в main и полный rerun +выполняют оба сценария. + +Локально запустите **одноразовый** Google APIs эмулятор с английским системным интерфейсом, +задайте `ANDROID_SERIAL=emulator-5554` (фактический serial своего эмулятора) и выполните: + +```shell +bundle exec fastlane android device_test_build +bundle exec fastlane android device_tests api:35 +``` + +Для отдельного эмулятора API 26 используйте `api:26`. Native AAR должен включать ABI эмулятора; +CI задаёт `MEGAPROXY_ANDROID_ABI=x86_64`, локальная сборка по умолчанию включает все ABI. +Нужен работающий Docker. На Linux стенду необходим `sudo iptables` без пароля для запрета прямого +доступа хоста/эмулятора к origin без опубликованного порта; временное правило удаляется при выходе. +На macOS origin внутри Docker VM недоступен напрямую. Сетевые сценарии проверяют этот запрет. +Runner **очищает данные MegaProxy на выбранном эмуляторе**, использует одноразовые пароли/SSH-ключи +и останавливает процесс между сценариями. Не выбирайте рабочий эмулятор с нужными данными. +Физические устройства и несовпадающий API отклоняются. + +Покрытие: настоящий HTTPS-трафик через TUN/JNI, остановка/повторный запуск, остановка во время +SSH handshake внутри настоящего JNI, отказ в VPN-согласии, отключение из уведомления, отказ в +разрешении уведомлений на API 35, сохранение зашифрованных пароля/ключа между процессами с +настоящим KEY_ONLY-подключением к OpenSSH, экспорт/импорт/отмена через системный DocumentsUI. +Экспорт по умолчанию не включает пароль. Проверяется настоящий Downloads provider и content URI. + +Проверка процесса состоит из отдельных instrumentation-запусков seed/verify с force-stop между +ними; одного пересоздания Activity недостаточно. Перед проверкой конфигурации дожидаемся ordered +executor и проверяем завершение и отсутствие ошибки записи. Сетевые тесты обращаются к приватному +HTTP echo-origin, проверяют маркер ответа и точное совпадение payload, а не только CONNECTED. + +JUnit XML, instrumentation output и Logcat сохраняются в `test-results/android-api26/` либо +`test-results/android-api35/`; при ошибке добавляются скриншот и UI hierarchy. CI публикует +отдельные артефакты на семь дней со ссылками в job summary. Чистая логика, большинство Compose +взаимодействий и полная матрица сетевых ошибок остаются в JVM/Robolectric/Go-тестах. +Эти эмуляторы не подтверждают поведение OEM, физическое переключение сетей и все Always-on режимы. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index e530cf4..ee1d436 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -78,6 +78,19 @@ platform :android do UI.success("Verified unsigned release APK: app/build/outputs/apk/release/app-release-unsigned.apk") end + desc "Build debug and instrumentation APKs for a disposable emulator" + lane :device_test_build do + native_library + gradle(tasks: %w[assembleDebug assembleDebugAndroidTest], flags: "--no-daemon", project_dir: project_root) + end + + desc "Run real VPN, Keystore and document-provider tests on a disposable API 26 or 35 emulator" + lane :device_tests do |options| + api = options[:api].to_s + UI.user_error!("Use api:26 or api:35") unless %w[26 35].include?(api) + sh(ENV.fetch("PYTHON", "python3"), File.join(project_root, "scripts/android_device_tests.py"), "--api", api) + end + desc "Run every native and Android test and check" lane :test do native_tests diff --git a/fastlane/README.md b/fastlane/README.md index b1676f5..1835d7f 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -12,6 +12,9 @@ Fastlane is the supported entry point for tests and build artifacts. Install Rub | `bundle exec fastlane android native_tests` | Run Go tests with the race detector | | `bundle exec fastlane android native_integration` | Test real GOST/OpenSSH servers with Docker and the race detector | | `bundle exec fastlane android android_checks` | Run Android tests and lint, build a debug APK and release APK, and prove that the release APK is unsigned | +| `bundle exec fastlane android device_test_build` | Build native AAR, debug APK and instrumentation APK | +| `bundle exec fastlane android device_tests api:26` | Run integration scenarios on a disposable API 26 emulator | +| `bundle exec fastlane android device_tests api:35` | Run integration scenarios on a disposable API 35 emulator | | `bundle exec fastlane android test` | Run all native and Android checks | | `bundle exec fastlane android debug_artifact` | Produce `app/build/outputs/apk/debug/app-debug.apk` | | `bundle exec fastlane android release_prepare version:0.1.2` | Generate EN/RU changelogs, bump the version and create a release PR | diff --git a/scripts/android_device_tests.py b/scripts/android_device_tests.py new file mode 100644 index 0000000..8af7f1e --- /dev/null +++ b/scripts/android_device_tests.py @@ -0,0 +1,318 @@ +#!/usr/bin/env python3 +"""Run real Android VPN/Keystore/SAF tests on one disposable API 26 or 35 emulator.""" + +import argparse +import base64 +import contextlib +import http.server +import os +import secrets +import shlex +import socket +import socketserver +import subprocess +import sys +import threading +import time +import uuid +import xml.etree.ElementTree as ET +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PACKAGE = "net.megaproxy487" +RUNNER = PACKAGE + ".test/androidx.test.runner.AndroidJUnitRunner" +GOST = "gogost/gost:3.3.0@sha256:f7a958c451928fbe1b99046d25bd0c9bf42019d4c60a82200f50dc8e617b7580" +TESTS = ( + "VpnDeviceTest#deniedVpnConsentDoesNotStartTunnel", + "VpnDeviceTest#trafficStopAndRestart", + "VpnDeviceTest#notificationActionStopsRealService", + "VpnDeviceTest#stopDuringSshHandshakeCannotReviveVpn", + "DocumentsDeviceTest#cancelledDocumentSelectionPreservesConfiguration", + "DocumentsDeviceTest#systemProviderExportAndImportRoundTrip", +) + + +def command(*args, timeout=300): + return subprocess.check_output( + args, text=True, stderr=subprocess.STDOUT, timeout=timeout + ).strip() + + +def require_success(output): + # adb exits zero even when instrumentation crashes or an assertion fails. + if ( + "OK (1 test)" not in output + or "FAILURES!!!" in output + or "INSTRUMENTATION_FAILED" in output + ): + raise RuntimeError("Instrumentation did not report exactly one successful test") + + +@contextlib.contextmanager +def fixture(): + name = "megaproxy-device-" + uuid.uuid4().hex[:12] + password = secrets.token_hex(16) + containers = [] + firewall = None + image = name + ":fixture" + network_created = False + image_built = False + try: + command("docker", "build", "-t", image, str(ROOT / "native/integration")) + image_built = True + command("docker", "network", "create", name) + network_created = True + + def start(alias, port, server_image, *args): + container = name + "-" + alias + containers.append(container) + options = ["docker", "run", "-d", "--name", container, "--network", name] + if port: + options += ["-p", "127.0.0.1::" + port] + if alias == "ssh": + options += ["-e", "TEST_PASSWORD=" + password] + command(*options, server_image, *args) + return container + + origin = start("origin", "", image, "python3", "/fixture/origin.py") + origin_ip = command( + "docker", + "inspect", + "--format", + "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}", + origin, + ) + # Emulator user-mode networking originates in the host OUTPUT chain. Container + # traffic uses FORWARD, so only the proxies can reach the unpublished origin. + if sys.platform == "linux": + firewall = [ + "OUTPUT", + "-d", + origin_ip, + "-p", + "tcp", + "--dport", + "8080", + "-m", + "comment", + "--comment", + name, + "-j", + "REJECT", + ] + command("sudo", "-n", "iptables", "-I", *firewall) + https = start("https", "8443", GOST, "-L", f"http+tls://exit:{password}@:8443") + ssh = start("ssh", "2222", image) + + def port(container, number): + address = command("docker", "port", container, number + "/tcp") + host, value = address.rsplit(":", 1) + if host != "127.0.0.1": + raise RuntimeError("Fixture proxy must be published on loopback only") + deadline = time.monotonic() + 30 + while True: + try: + with socket.create_connection((host, int(value)), timeout=1): + return value + except OSError: + if time.monotonic() >= deadline: + raise RuntimeError("Fixture proxy did not start") from None + time.sleep(0.1) + + https_port, ssh_port = port(https, "8443"), port(ssh, "2222") + key = ( + command("docker", "exec", ssh, "cat", "/home/proxy/.ssh/id_ed25519") + "\n" + ) + fingerprint = command( + "docker", + "exec", + ssh, + "ssh-keygen", + "-lf", + "/etc/ssh/ssh_host_ed25519_key.pub", + ).split()[1] + yield { + "originHost": origin_ip, + "proxyPort": https_port, + "proxyPassword": password, + "sshPort": ssh_port, + "sshPassword": password, + "sshKey": base64.b64encode(key.encode()).decode(), + "sshFingerprint": fingerprint, + } + finally: + # Cleanup is attempted independently so one failed removal cannot leak siblings. + for container in reversed(containers): + subprocess.run( + ["docker", "rm", "-f", container], capture_output=True, timeout=30 + ) + if firewall: + subprocess.run( + ["sudo", "-n", "iptables", "-D", *firewall], + capture_output=True, + timeout=30, + ) + if network_created: + subprocess.run( + ["docker", "network", "rm", name], capture_output=True, timeout=30 + ) + if image_built: + subprocess.run( + ["docker", "image", "rm", image], capture_output=True, timeout=30 + ) + + +@contextlib.contextmanager +def stalled_ssh(): + accepted, release = threading.Event(), threading.Event() + + class Stall(socketserver.BaseRequestHandler): + def handle(self): + accepted.set() + release.wait(60) + + class Control(http.server.BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/release": + release.set() + body = b"yes" if accepted.is_set() else b"no" + self.send_response(200) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def log_message(self, *_args): + pass + + with ( + socketserver.ThreadingTCPServer(("127.0.0.1", 0), Stall) as server, + http.server.ThreadingHTTPServer(("127.0.0.1", 0), Control) as control, + ): + server.daemon_threads = True + workers = [ + threading.Thread(target=s.serve_forever, daemon=True) + for s in (server, control) + ] + for worker in workers: + worker.start() + try: + yield { + "stallPort": str(server.server_address[1]), + "controlPort": str(control.server_address[1]), + } + finally: + release.set() + server.shutdown() + control.shutdown() + for worker in workers: + worker.join() + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--api", type=int, choices=(26, 35), required=True) + args = parser.parse_args() + adb_path = Path(os.environ["ANDROID_HOME"]) / "platform-tools/adb" + serial = os.environ.get("ANDROID_SERIAL") + if not serial or not serial.startswith("emulator-"): + parser.error("ANDROID_SERIAL must explicitly select a disposable emulator") + adb = [str(adb_path), "-s", serial] + + def shell(*words): + return command(*adb, "shell", shlex.join(words), timeout=180) + + if int(shell("getprop", "ro.build.version.sdk")) != args.api: + parser.error("Connected emulator API does not match --api") + if shell("getprop", "ro.kernel.qemu") != "1": + parser.error("Device is not an emulator") + results = ROOT / "test-results" / f"android-api{args.api}" + results.mkdir(parents=True, exist_ok=True) + suite = ET.Element("testsuite", name=f"Android API {args.api}") + failures = 0 + command( + *adb, "install", "-r", str(ROOT / "app/build/outputs/apk/debug/app-debug.apk") + ) + command( + *adb, + "install", + "-r", + str(ROOT / "app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk"), + ) + with fixture() as parameters, stalled_ssh() as stalled: + parameters.update(stalled) + tests = [(name, None) for name in TESTS] + if args.api >= 33: + tests.append(("VpnDeviceTest#deniedNotificationsStillAllowVpn", None)) + tests += [ + ("KeystoreProcessDeviceTest#credentialsSurviveProcessRestart", "seed"), + ("KeystoreProcessDeviceTest#credentialsSurviveProcessRestart", "verify"), + ] + for test, phase in tests: + label = test.replace("#", ".") + ("." + phase if phase else "") + case = ET.SubElement(suite, "testcase", name=label) + started = time.monotonic() + try: + shell("am", "force-stop", PACKAGE) + if phase != "verify": + if shell("pm", "clear", PACKAGE) != "Success": + raise RuntimeError("Failed to reset disposable app data") + shell("appops", "set", PACKAGE, "ACTIVATE_VPN", "default") + if args.api >= 33 and "deniedNotifications" not in test: + shell( + "pm", "grant", PACKAGE, "android.permission.POST_NOTIFICATIONS" + ) + shell("logcat", "-c") + options = [ + "am", + "instrument", + "-w", + "-r", + "-e", + "class", + PACKAGE + "." + test, + ] + for key, value in { + **parameters, + **({"phase": phase} if phase else {}), + }.items(): + options += ["-e", key, value] + output = shell(*options, RUNNER) + (results / (label + ".txt")).write_text(output) + require_success(output) + print(f"PASS API {args.api}: {label}", flush=True) + except (RuntimeError, subprocess.SubprocessError) as error: + failures += 1 + # Tool failures can include command arguments; never publish the key/password. + message = ( + str(error) + if isinstance(error, RuntimeError) + else type(error).__name__ + ) + ET.SubElement(case, "failure", message=message) + print(f"FAIL API {args.api}: {label}: {message}", flush=True) + screenshot = subprocess.run( + [*adb, "exec-out", "screencap", "-p"], + capture_output=True, + timeout=15, + ) + (results / (label + ".png")).write_bytes(screenshot.stdout) + shell("uiautomator", "dump", "/sdcard/megaproxy-device-window.xml") + (results / (label + ".xml")).write_text( + shell("cat", "/sdcard/megaproxy-device-window.xml") + ) + finally: + case.set("time", f"{time.monotonic() - started:.3f}") + (results / (label + "-logcat.txt")).write_text( + shell("logcat", "-d", "-v", "threadtime") + ) + shell("am", "force-stop", PACKAGE) + suite.set("tests", str(len(suite))) + suite.set("failures", str(failures)) + ET.ElementTree(suite).write( + results / "junit.xml", encoding="utf-8", xml_declaration=True + ) + return bool(failures) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci_changes.py b/scripts/ci_changes.py index a53d491..f90ff30 100644 --- a/scripts/ci_changes.py +++ b/scripts/ci_changes.py @@ -11,7 +11,7 @@ from ci_history import successful_baselines -SUITES = ("android", "native", "python") +SUITES = ("android", "native", "python", "emulator26", "emulator35") def classify(paths): @@ -38,6 +38,8 @@ def classify(paths): selected.add("native") if not path.endswith("_test.go"): selected.add("android") + elif path == "scripts/android_device_tests.py": + selected.update(("python", "emulator26", "emulator35")) elif path.endswith(".py") or path in ( "pyproject.toml", "requirements-dev.txt", @@ -53,6 +55,8 @@ def classify(paths): selected.add("android") else: selected.update(SUITES) + if "android" in selected: + selected.update(("emulator26", "emulator35")) return {suite: suite in selected for suite in SUITES} diff --git a/scripts/ci_history.py b/scripts/ci_history.py index c2539ce..ac95cdf 100644 --- a/scripts/ci_history.py +++ b/scripts/ci_history.py @@ -8,6 +8,8 @@ "android": "Android tests and checks", "native": "Native Go tests", "python": "Python tests and style", + "emulator26": "Android emulator API 26 / Device tests", + "emulator35": "Android emulator API 35 / Device tests", } BASE_STEP = "Comparison base: " diff --git a/scripts/tests/test_android_device_tests.py b/scripts/tests/test_android_device_tests.py new file mode 100644 index 0000000..b3b8dcc --- /dev/null +++ b/scripts/tests/test_android_device_tests.py @@ -0,0 +1,24 @@ +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parents[1])) +import android_device_tests as m + + +class InstrumentationResultTest(unittest.TestCase): + def test_adb_success_is_not_enough(self): + for output in ( + "INSTRUMENTATION_CODE: 0", + "INSTRUMENTATION_FAILED: process crashed", + "FAILURES!!!\nTests run: 1, Failures: 1", + "OK (0 tests)", + "OK (1 test)\nFAILURES!!!", + ): + with self.subTest(output=output), self.assertRaises(RuntimeError): + m.require_success(output) + m.require_success("OK (1 test)\nINSTRUMENTATION_CODE: -1") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/tests/test_ci_changes.py b/scripts/tests/test_ci_changes.py index 5cc306e..f5265b3 100644 --- a/scripts/tests/test_ci_changes.py +++ b/scripts/tests/test_ci_changes.py @@ -67,7 +67,8 @@ def test_main_push_runs_every_suite_even_without_code_changes(self): self.assertEqual(0, m.main()) self.assertTrue(json.loads(stdout.getvalue())["forced"]) self.assertEqual( - "android=true\nnative=true\npython=true\n", output.read_text() + "android=true\nnative=true\npython=true\nemulator26=true\nemulator35=true\n", + output.read_text(), ) self.assertIn("push CI (change filtering disabled)", summary.read_text()) @@ -102,7 +103,8 @@ def test_full_rerun_enables_skipped_suites_without_diff_or_history(self): result = json.loads(stdout.getvalue()) self.assertTrue(result["forced"]) self.assertEqual( - "android=true\nnative=true\npython=true\n", output.read_text() + "android=true\nnative=true\npython=true\nemulator26=true\nemulator35=true\n", + output.read_text(), ) self.assertIn("change filtering disabled", summary.read_text()) @@ -131,7 +133,13 @@ def test_initial_attempt_still_filters_docs_only_changes(self): def test_python_and_markdown_do_not_run_android(self): self.assertEqual( - {"android": False, "native": False, "python": True}, + { + "android": False, + "native": False, + "python": True, + "emulator26": False, + "emulator35": False, + }, m.classify(["scripts/github_actions.py", "docs/ru/fastlane.md"]), ) @@ -149,11 +157,23 @@ def test_runtime_markdown_is_android_content(self): def test_native_production_changes_rebuild_android(self): self.assertEqual( - {"android": True, "native": True, "python": False}, + { + "android": True, + "native": True, + "python": False, + "emulator26": True, + "emulator35": True, + }, m.classify(["native/mobile/dialer.go"]), ) self.assertEqual( - {"android": False, "native": True, "python": False}, + { + "android": False, + "native": True, + "python": False, + "emulator26": False, + "emulator35": False, + }, m.classify(["native/mobile/dialer_test.go"]), ) diff --git a/scripts/tests/test_ci_history.py b/scripts/tests/test_ci_history.py index 43f70d3..48289c4 100644 --- a/scripts/tests/test_ci_history.py +++ b/scripts/tests/test_ci_history.py @@ -37,7 +37,9 @@ def job_data(self, android, native, python): } ] + [ {"name": name, "conclusion": result} - for name, result in zip(m.CHECKS.values(), (android, native, python)) + for name, result in zip( + m.CHECKS.values(), (android, native, python, android, android) + ) ] def read(self, endpoint): @@ -93,6 +95,16 @@ def test_current_future_other_pr_branch_and_fork_are_excluded(self): self.runs = [run] self.assertEqual({}, self.select(), kind) + def test_emulator_failure_does_not_advance_other_api_or_jvm_baseline(self): + self.jobs[30] = self.job_data("success", "success", "success") + for job in self.jobs[30]: + if job["name"] == m.CHECKS["emulator26"]: + job["conclusion"] = "failure" + result = self.select() + self.assertEqual(20, result["emulator26"]["run_id"]) + self.assertEqual(30, result["emulator35"]["run_id"]) + self.assertEqual(30, result["android"]["run_id"]) + def test_gh_failure_does_not_expose_token_or_response(self): with patch.object( m.subprocess, "run", return_value=Mock(returncode=1, stderr="secret") From 2887f8e7c1cb30b5aad92d10a946bc62b4c893a0 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 6 Oct 2026 20:07:28 +0300 Subject: [PATCH 2/6] Stabilize device test lifecycle and system UI interactions --- app/build.gradle.kts | 2 - .../java/net/megaproxy487/DeviceTestBase.kt | 54 ++++++++++++++++--- .../net/megaproxy487/DocumentsDeviceTest.kt | 44 +++++++++------ .../megaproxy487/KeystoreProcessDeviceTest.kt | 8 ++- .../java/net/megaproxy487/VpnDeviceTest.kt | 11 ++-- .../net/megaproxy487/vpn/ProxyVpnService.kt | 4 +- docs/en/fastlane.md | 2 +- docs/ru/fastlane.md | 2 +- scripts/android_device_tests.py | 54 +++++++++++++------ 9 files changed, 127 insertions(+), 54 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 048cb3d..ea60c30 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -140,8 +140,6 @@ dependencies { testImplementation("androidx.compose.ui:ui-test-junit4") debugImplementation("androidx.compose.ui:ui-test-manifest") testImplementation("junit:junit:4.13.2") - androidTestImplementation(platform("androidx.compose:compose-bom:2025.01.01")) - androidTestImplementation("androidx.compose.ui:ui-test-junit4") androidTestImplementation("androidx.test:runner:1.6.2") androidTestImplementation("androidx.test.ext:junit:1.2.1") androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0") diff --git a/app/src/androidTest/java/net/megaproxy487/DeviceTestBase.kt b/app/src/androidTest/java/net/megaproxy487/DeviceTestBase.kt index 1d51fb6..6a9d6f7 100644 --- a/app/src/androidTest/java/net/megaproxy487/DeviceTestBase.kt +++ b/app/src/androidTest/java/net/megaproxy487/DeviceTestBase.kt @@ -5,8 +5,9 @@ import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.net.VpnService import android.os.SystemClock -import androidx.compose.ui.test.* -import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.core.app.ActivityScenario +import androidx.test.uiautomator.UiScrollable +import androidx.test.uiautomator.UiSelector import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice @@ -22,10 +23,12 @@ import net.megaproxy487.vpn.ProxyVpnService import net.megaproxy487.vpn.VpnConnectionState import net.megaproxy487.vpn.VpnRuntimeState import org.junit.Assert.* -import org.junit.After import org.junit.Rule import org.junit.rules.ExternalResource import org.junit.rules.RuleChain +import org.junit.rules.TestWatcher +import org.junit.runner.Description +import java.io.File import java.net.InetSocketAddress import java.net.Socket @@ -36,10 +39,23 @@ abstract class DeviceTestBase { protected val arguments = InstrumentationRegistry.getArguments() protected val device = UiDevice.getInstance(instrumentation) protected val store get() = ConfigStore(context) - protected val compose = createAndroidComposeRule() + protected lateinit var scenario: ActivityScenario protected fun argument(name: String) = requireNotNull(arguments.getString(name)) { "Missing fixture argument: $name" } protected fun text(id: Int) = context.getString(id) - protected fun node(id: Int) = compose.onNodeWithText(text(id)) + protected fun appNode(selector: androidx.test.uiautomator.BySelector): androidx.test.uiautomator.UiObject2 { + device.waitForIdle() + val result = device.wait(Until.findObject(selector), 10_000) + assertNotNull("Missing app control $selector", result) + return result + } + protected fun click(id: Int) { + if (!device.hasObject(By.text(text(id)))) { + device.findObject(UiSelector().scrollable(true)).let { + if (it.exists()) UiScrollable(UiSelector().scrollable(true)).scrollIntoView(UiSelector().text(text(id))) + } + } + appNode(By.text(text(id))).click() + } protected fun io(block: () -> Unit) = runBlocking { withContext(ConfigIoDispatcher) { block() } } protected fun saved() { io {} // Barrier behind every ordered write, including service commands. @@ -69,7 +85,7 @@ abstract class DeviceTestBase { device.waitForIdle() } protected fun connect() { - node(R.string.connect).performScrollTo().performClick() + click(R.string.connect) if (VpnService.prepare(context) != null) systemButton("android:id/button1") await("Real VPN did not connect") { ProxyVpnService.isRunning && vpnPresent() && VpnRuntimeState.connection.value == VpnConnectionState.CONNECTED } saved() @@ -95,6 +111,7 @@ abstract class DeviceTestBase { private val platform = object : ExternalResource() { override fun before() { + listOf("png", "xml").forEach { File(context.getExternalFilesDir(null), "device-failure.$it").delete() } if (arguments.getString("phase") != "verify") io { assertTrue(context.getSharedPreferences("proxy_config", Context.MODE_PRIVATE).edit().clear().commit()) val profile = store.activeProfile() @@ -106,9 +123,17 @@ abstract class DeviceTestBase { } assertTrue(context.getSharedPreferences("battery_optimization_reminder", Context.MODE_PRIVATE) .edit().putLong("last_request_at", System.currentTimeMillis()).commit()) + scenario = ActivityScenario.launch(MainActivity::class.java) + device.waitForIdle() } } - @After fun cleanup() { + private fun cleanup() { + repeat(3) { + if (device.currentPackageName != context.packageName) { + device.pressBack() + device.waitForIdle() + } + } try { ProxyVpnService.stop(context) stopped() @@ -116,5 +141,18 @@ abstract class DeviceTestBase { saved() } } - @get:Rule val rules: RuleChain = RuleChain.outerRule(platform).around(compose) + private val evidence = object : TestWatcher() { + override fun failed(error: Throwable, description: Description) { + val directory = context.getExternalFilesDir(null)!! + device.takeScreenshot(File(directory, "device-failure.png")) + device.dumpWindowHierarchy(File(directory, "device-failure.xml")) + } + } + private val activity = object : ExternalResource() { + override fun after() { scenario.close() } + } + private val finish = object : ExternalResource() { + override fun after() { cleanup() } + } + @get:Rule val rules: RuleChain = RuleChain.outerRule(platform).around(activity).around(finish).around(evidence) } diff --git a/app/src/androidTest/java/net/megaproxy487/DocumentsDeviceTest.kt b/app/src/androidTest/java/net/megaproxy487/DocumentsDeviceTest.kt index b36984b..80b7d2c 100644 --- a/app/src/androidTest/java/net/megaproxy487/DocumentsDeviceTest.kt +++ b/app/src/androidTest/java/net/megaproxy487/DocumentsDeviceTest.kt @@ -2,10 +2,10 @@ package net.megaproxy487 import androidx.activity.compose.setContent import androidx.compose.material3.MaterialTheme -import androidx.compose.ui.test.* import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.uiautomator.By import androidx.test.uiautomator.Until +import androidx.test.uiautomator.UiSelector import net.megaproxy487.data.ConfigTransfer import org.junit.Assert.* import org.junit.Test @@ -14,38 +14,46 @@ import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class DocumentsDeviceTest : DeviceTestBase() { private fun screen() { - compose.activityRule.scenario.onActivity { activity -> + scenario.onActivity { activity -> activity.setContent { MaterialTheme { ProfilesScreen(activity, {}, {}) } } } - compose.waitUntil(10_000) { compose.onAllNodesWithText("Device fixture").fetchSemanticsNodes().isNotEmpty() } + appNode(By.text("Device fixture")) + ready() + } + private fun ready() { + appNode(By.desc(text(R.string.export_action)).enabled(true)) + saved() } private fun systemNode(selector: androidx.test.uiautomator.BySelector): androidx.test.uiautomator.UiObject2 { + device.waitForIdle() val result = device.wait(Until.findObject(selector), 10_000) assertNotNull("Document picker control missing: $selector", result) return result } private fun downloads() { systemNode(By.desc("Show roots")).click() - systemNode(By.text("Downloads")).click() + val downloads = device.findObject(UiSelector().resourceId("android:id/title").text("Downloads")) + assertTrue("Downloads root missing", downloads.waitForExists(10_000)) + downloads.click() } private fun importFile() { - compose.onNodeWithContentDescription(text(R.string.import_action)).performClick() + appNode(By.desc(text(R.string.import_action)).enabled(true)).click() downloads() } @Test fun cancelledDocumentSelectionPreservesConfiguration() { screen() val before = ConfigTransfer.exportJson(store, true, true) - compose.onNodeWithContentDescription(text(R.string.import_action)).performClick() + appNode(By.desc(text(R.string.import_action)).enabled(true)).click() systemNode(By.desc("Show roots")) device.pressBack() - compose.waitForIdle() - saved() + device.waitForIdle() + ready() assertEquals(before, ConfigTransfer.exportJson(store, true, true)) - compose.onNodeWithContentDescription(text(R.string.export_action)).performClick() - node(R.string.export_action).performClick() + appNode(By.desc(text(R.string.export_action)).enabled(true)).click() + systemNode(By.text(text(R.string.export_action))).click() systemNode(By.desc("Show roots")) device.pressBack() - compose.waitForIdle() + device.waitForIdle() saved() assertEquals(before, ConfigTransfer.exportJson(store, true, true)) } @@ -53,14 +61,14 @@ class DocumentsDeviceTest : DeviceTestBase() { val filename = "megaproxy-device-${System.nanoTime()}.json" screen() try { - compose.onNodeWithContentDescription(text(R.string.export_action)).performClick() - node(R.string.passwords_omitted_message).assertExists() - node(R.string.export_action).performClick() + appNode(By.desc(text(R.string.export_action)).enabled(true)).click() + appNode(By.text(text(R.string.passwords_omitted_message))) + systemNode(By.text(text(R.string.export_action))).click() downloads() systemNode(By.clazz("android.widget.EditText")).text = filename - systemNode(By.text("SAVE")).click() - compose.waitUntil(10_000) { compose.onAllNodesWithText(text(R.string.configuration_exported)).fetchSemanticsNodes().isNotEmpty() } - node(R.string.ok).performClick() + systemNode(By.res("android:id/button1")).click() + appNode(By.text(text(R.string.configuration_exported))) + systemNode(By.text(text(R.string.ok))).click() val exported = device.executeShellCommand("cat /sdcard/Download/$filename") val configuration = ConfigTransfer.importJson(exported) assertEquals("Device fixture", configuration.profiles.single().name) @@ -68,6 +76,8 @@ class DocumentsDeviceTest : DeviceTestBase() { io { store.saveProfile(store.activeProfile().copy(name = "Changed locally")) } importFile() systemNode(By.text(filename)).click() + appNode(By.text(text(R.string.import_anyway))) + systemNode(By.text(text(R.string.import_anyway))).click() await("Import did not restore the exported profile") { store.activeProfile().name == "Device fixture" } saved() assertEquals(configuration.profiles.single().id, store.activeProfileId()) diff --git a/app/src/androidTest/java/net/megaproxy487/KeystoreProcessDeviceTest.kt b/app/src/androidTest/java/net/megaproxy487/KeystoreProcessDeviceTest.kt index 6964f40..0c645a1 100644 --- a/app/src/androidTest/java/net/megaproxy487/KeystoreProcessDeviceTest.kt +++ b/app/src/androidTest/java/net/megaproxy487/KeystoreProcessDeviceTest.kt @@ -41,8 +41,14 @@ class KeystoreProcessDeviceTest : DeviceTestBase() { assertTrue("SSH key did not survive process restart", config.privateKey == privateKey) assertEquals(argument("sshFingerprint"), config.trustedHostKey) directOriginUnavailable() + io { store.saveGlobalConnectionSettings(store.globalConnectionSettings().copy(sshAuthMode = SshAuthMode.PASSWORD_ONLY)) } connect() - roundTrip() // KEY_ONLY proves the recovered key works with real OpenSSH. + roundTrip() // No key fallback: the recovered password must authenticate. + click(R.string.disconnect) + stopped() + io { store.saveGlobalConnectionSettings(store.globalConnectionSettings().copy(sshAuthMode = SshAuthMode.KEY_ONLY)) } + connect() + roundTrip() // No password fallback: the recovered key must authenticate. marker.delete() } } diff --git a/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt b/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt index 53af8be..e8271fd 100644 --- a/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt +++ b/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt @@ -5,7 +5,6 @@ import android.content.pm.PackageManager import android.net.VpnService import android.os.Build import android.os.SystemClock -import androidx.compose.ui.test.* import androidx.test.ext.junit.runners.AndroidJUnit4 import net.megaproxy487.model.ProxyType import net.megaproxy487.vpn.ProxyVpnService @@ -21,7 +20,7 @@ class VpnDeviceTest : DeviceTestBase() { directOriginUnavailable() connect() roundTrip() - node(R.string.disconnect).performScrollTo().performClick() + click(R.string.disconnect) stopped() directOriginUnavailable() connect() @@ -30,9 +29,9 @@ class VpnDeviceTest : DeviceTestBase() { @Test fun deniedVpnConsentDoesNotStartTunnel() { assertNotNull("Runner must revoke VPN consent before this test", VpnService.prepare(context)) - node(R.string.connect).performScrollTo().performClick() + click(R.string.connect) systemButton("android:id/button2") - node(R.string.vpn_permission_denied).assertExists() + appNode(androidx.test.uiautomator.By.text(text(R.string.vpn_permission_denied))) stopped() assertNotNull(VpnService.prepare(context)) directOriginUnavailable() @@ -57,7 +56,7 @@ class VpnDeviceTest : DeviceTestBase() { }) } // Real JNI Start waits for this fixture's SSH banner; the host records acceptance. if (VpnService.prepare(context) != null) { - compose.activityRule.scenario.onActivity { it.startActivity(VpnService.prepare(it)) } + scenario.onActivity { it.startActivity(VpnService.prepare(it)) } systemButton("android:id/button1") } ProxyVpnService.start(context) @@ -81,7 +80,7 @@ class VpnDeviceTest : DeviceTestBase() { @Test fun deniedNotificationsStillAllowVpn() { if (Build.VERSION.SDK_INT < 33) return // Permission does not exist on API 26. assertEquals(PackageManager.PERMISSION_DENIED, context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)) - node(R.string.connect).performScrollTo().performClick() + click(R.string.connect) systemButton("com.android.permissioncontroller:id/permission_deny_button") if (VpnService.prepare(context) != null) systemButton("android:id/button1") await("VPN failed after notification denial") { ProxyVpnService.isRunning && vpnPresent() } diff --git a/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt b/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt index 82f05f2..00c0150 100644 --- a/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt +++ b/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt @@ -771,9 +771,9 @@ class ProxyVpnService : VpnService() { .setOngoing(true) .setCategory(NotificationCompat.CATEGORY_SERVICE) .setContentIntent(hostKeyPrompt ?: PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE)) - .addAction(0, uiText(R.string.disconnect), PendingIntent.getService( + .also { builder -> if (!isAlwaysOnMode) builder.addAction(0, uiText(R.string.disconnect), PendingIntent.getService( this, 1, Intent(this, ProxyVpnService::class.java).setAction(ACTION_STOP), PendingIntent.FLAG_IMMUTABLE, - )) + )) } .also { builder -> hostKeyPrompt?.let { builder.addAction(0, uiText(R.string.review_ssh_key), it) } } .build() diff --git a/docs/en/fastlane.md b/docs/en/fastlane.md index a737041..6eae15c 100644 --- a/docs/en/fastlane.md +++ b/docs/en/fastlane.md @@ -250,7 +250,7 @@ with data you want to keep. It rejects physical devices and API mismatches. Coverage: real HTTPS proxy traffic through TUN/JNI, stop/restart, stop while real JNI awaits an SSH handshake, denied VPN consent, notification Disconnect, notification permission denial on -API 35, encrypted password/key persistence across a fresh app process and real KEY_ONLY OpenSSH +API 35, encrypted password/key persistence across a fresh app process and real PASSWORD_ONLY/KEY_ONLY OpenSSH traffic, and system DocumentsUI export/import/cancellation. Exports omit passwords by default. System picker tests exercise the actual Downloads provider and returned content URI. diff --git a/docs/ru/fastlane.md b/docs/ru/fastlane.md index a487869..b172459 100644 --- a/docs/ru/fastlane.md +++ b/docs/ru/fastlane.md @@ -255,7 +255,7 @@ Runner **очищает данные MegaProxy на выбранном эмул Покрытие: настоящий HTTPS-трафик через TUN/JNI, остановка/повторный запуск, остановка во время SSH handshake внутри настоящего JNI, отказ в VPN-согласии, отключение из уведомления, отказ в разрешении уведомлений на API 35, сохранение зашифрованных пароля/ключа между процессами с -настоящим KEY_ONLY-подключением к OpenSSH, экспорт/импорт/отмена через системный DocumentsUI. +настоящими PASSWORD_ONLY/KEY_ONLY-подключениями к OpenSSH, экспорт/импорт/отмена через системный DocumentsUI. Экспорт по умолчанию не включает пароль. Проверяется настоящий Downloads provider и content URI. Проверка процесса состоит из отдельных instrumentation-запусков seed/verify с force-stop между diff --git a/scripts/android_device_tests.py b/scripts/android_device_tests.py index 8af7f1e..1a663f3 100644 --- a/scripts/android_device_tests.py +++ b/scripts/android_device_tests.py @@ -6,6 +6,7 @@ import contextlib import http.server import os +import re import secrets import shlex import socket @@ -223,6 +224,10 @@ def shell(*words): if int(shell("getprop", "ro.build.version.sdk")) != args.api: parser.error("Connected emulator API does not match --api") + if shell("getprop", "sys.boot_completed") != "1": + parser.error( + "Wait for the emulator to finish booting before running device_tests" + ) if shell("getprop", "ro.kernel.qemu") != "1": parser.error("Device is not an emulator") results = ROOT / "test-results" / f"android-api{args.api}" @@ -238,6 +243,9 @@ def shell(*words): "-r", str(ROOT / "app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk"), ) + shell("am", "force-stop", PACKAGE) + if shell("pm", "clear", PACKAGE) != "Success": + raise RuntimeError("Failed to reset disposable app data") with fixture() as parameters, stalled_ssh() as stalled: parameters.update(stalled) tests = [(name, None) for name in TESTS] @@ -253,14 +261,26 @@ def shell(*words): started = time.monotonic() try: shell("am", "force-stop", PACKAGE) - if phase != "verify": - if shell("pm", "clear", PACKAGE) != "Success": - raise RuntimeError("Failed to reset disposable app data") - shell("appops", "set", PACKAGE, "ACTIVATE_VPN", "default") - if args.api >= 33 and "deniedNotifications" not in test: + # Test setup resets ConfigStore itself; clearing app data here can + # asynchronously remove an old task and kill the next test process. + if args.api >= 33: + operation = "revoke" if "deniedNotifications" in test else "grant" shell( - "pm", "grant", PACKAGE, "android.permission.POST_NOTIFICATIONS" + "pm", + operation, + PACKAGE, + "android.permission.POST_NOTIFICATIONS", ) + # Force-stop/task removal can finish asynchronously and kill a newly + # started instrumentation process. Wait for actual Activity removal. + deadline = time.monotonic() + 15 + while re.search( + r"ActivityRecord\{[^\n]* net\.megaproxy487(?:\.test)?/", + shell("dumpsys", "activity", "activities"), + ): + if time.monotonic() >= deadline: + raise RuntimeError("Previous Android activities did not finish") + time.sleep(0.1) shell("logcat", "-c") options = [ "am", @@ -283,6 +303,8 @@ def shell(*words): except (RuntimeError, subprocess.SubprocessError) as error: failures += 1 # Tool failures can include command arguments; never publish the key/password. + if isinstance(error, subprocess.CalledProcessError): + (results / (label + ".txt")).write_text(error.output or "") message = ( str(error) if isinstance(error, RuntimeError) @@ -290,16 +312,16 @@ def shell(*words): ) ET.SubElement(case, "failure", message=message) print(f"FAIL API {args.api}: {label}: {message}", flush=True) - screenshot = subprocess.run( - [*adb, "exec-out", "screencap", "-p"], - capture_output=True, - timeout=15, - ) - (results / (label + ".png")).write_bytes(screenshot.stdout) - shell("uiautomator", "dump", "/sdcard/megaproxy-device-window.xml") - (results / (label + ".xml")).write_text( - shell("cat", "/sdcard/megaproxy-device-window.xml") - ) + for extension in ("png", "xml"): + destination = results / (label + "." + extension) + evidence = f"/sdcard/Android/data/{PACKAGE}/files/device-failure.{extension}" + pulled = subprocess.run( + [*adb, "pull", evidence, str(destination)], + capture_output=True, + timeout=15, + ) + if pulled.returncode: + print(f"Failure {extension} evidence unavailable", flush=True) finally: case.set("time", f"{time.monotonic() - started:.3f}") (results / (label + "-logcat.txt")).write_text( From 244bef572be91e4a874cbb5ef27dcea629930269 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 6 Oct 2026 20:18:04 +0300 Subject: [PATCH 3/6] Avoid launcher interference and preserve logs without buffer clearing --- .../androidTest/java/net/megaproxy487/VpnDeviceTest.kt | 2 +- scripts/android_device_tests.py | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt b/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt index e8271fd..e7327ad 100644 --- a/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt +++ b/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt @@ -42,7 +42,7 @@ class VpnDeviceTest : DeviceTestBase() { roundTrip() device.openNotification() val action = device.wait(androidx.test.uiautomator.Until.findObject( - androidx.test.uiautomator.By.text(text(R.string.disconnect))), 10_000) + androidx.test.uiautomator.By.desc(text(R.string.disconnect))), 10_000) assertNotNull("VPN notification must expose Disconnect", action) action.click() stopped() diff --git a/scripts/android_device_tests.py b/scripts/android_device_tests.py index 1a663f3..bc5a402 100644 --- a/scripts/android_device_tests.py +++ b/scripts/android_device_tests.py @@ -230,6 +230,11 @@ def shell(*words): ) if shell("getprop", "ro.kernel.qemu") != "1": parser.error("Device is not an emulator") + if args.api == 35: + # Pixel Launcher can leave an ANR dialog over every test after snapshot restore. + # This disposable emulator launches activities directly and needs no launcher. + shell("am", "force-stop", "com.google.android.apps.nexuslauncher") + shell("pm", "disable-user", "--user", "0", "com.google.android.apps.nexuslauncher") results = ROOT / "test-results" / f"android-api{args.api}" results.mkdir(parents=True, exist_ok=True) suite = ET.Element("testsuite", name=f"Android API {args.api}") @@ -259,6 +264,7 @@ def shell(*words): label = test.replace("#", ".") + ("." + phase if phase else "") case = ET.SubElement(suite, "testcase", name=label) started = time.monotonic() + log_start = shell("date", "+%m-%d %H:%M:%S.000") try: shell("am", "force-stop", PACKAGE) # Test setup resets ConfigStore itself; clearing app data here can @@ -281,7 +287,6 @@ def shell(*words): if time.monotonic() >= deadline: raise RuntimeError("Previous Android activities did not finish") time.sleep(0.1) - shell("logcat", "-c") options = [ "am", "instrument", @@ -325,7 +330,7 @@ def shell(*words): finally: case.set("time", f"{time.monotonic() - started:.3f}") (results / (label + "-logcat.txt")).write_text( - shell("logcat", "-d", "-v", "threadtime") + shell("logcat", "-d", "-v", "threadtime", "-T", log_start) ) shell("am", "force-stop", PACKAGE) suite.set("tests", str(len(suite))) From ab0c2fe64fa3fcbc388eb9b196244731577248af Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 6 Oct 2026 20:18:44 +0300 Subject: [PATCH 4/6] Apply pinned Python formatting --- scripts/android_device_tests.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/android_device_tests.py b/scripts/android_device_tests.py index bc5a402..0ba36e9 100644 --- a/scripts/android_device_tests.py +++ b/scripts/android_device_tests.py @@ -234,7 +234,9 @@ def shell(*words): # Pixel Launcher can leave an ANR dialog over every test after snapshot restore. # This disposable emulator launches activities directly and needs no launcher. shell("am", "force-stop", "com.google.android.apps.nexuslauncher") - shell("pm", "disable-user", "--user", "0", "com.google.android.apps.nexuslauncher") + shell( + "pm", "disable-user", "--user", "0", "com.google.android.apps.nexuslauncher" + ) results = ROOT / "test-results" / f"android-api{args.api}" results.mkdir(parents=True, exist_ok=True) suite = ET.Element("testsuite", name=f"Android API {args.api}") From 3aa8916b2e67464a4b62c7c0986fbfa97488c531 Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 6 Oct 2026 20:31:07 +0300 Subject: [PATCH 5/6] Cancel pending native VPN startup and release its TUN on Stop --- .../java/net/megaproxy487/VpnDeviceTest.kt | 21 +------- .../net/megaproxy487/vpn/ProxyVpnService.kt | 19 ++++++- native/mobile/bridge_test.go | 20 +++++++ native/mobile/mobile.go | 14 ++++- scripts/android_device_tests.py | 53 +++++++------------ 5 files changed, 72 insertions(+), 55 deletions(-) diff --git a/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt b/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt index e7327ad..c47fc06 100644 --- a/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt +++ b/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt @@ -61,12 +61,12 @@ class VpnDeviceTest : DeviceTestBase() { } ProxyVpnService.start(context) await("JNI startup did not reach SSH fixture") { - SocketControl.accepted(context, argument("controlPort").toInt()) + device.executeShellCommand("test -f ${argument("sshAccepted")} && echo yes").trim() == "yes" } assertEquals(VpnConnectionState.CONNECTING, VpnRuntimeState.connection.value) ProxyVpnService.stop(context) stopped() - SocketControl.release(context, argument("controlPort").toInt()) + device.executeShellCommand("touch ${argument("sshRelease")}") val deadline = SystemClock.elapsedRealtime() + 35_000 while (SystemClock.elapsedRealtime() < deadline) { assertFalse("A stale native start revived the VPN", ProxyVpnService.isRunning) @@ -88,20 +88,3 @@ class VpnDeviceTest : DeviceTestBase() { roundTrip() } } - -private object SocketControl { - // Fixture coordination must remain reachable while the TUN is awaiting native startup. - private fun request(context: android.content.Context, port: Int, path: String): String { - val manager = context.getSystemService(android.net.ConnectivityManager::class.java) - val network = manager.allNetworks.first { - manager.getNetworkCapabilities(it)?.hasTransport(android.net.NetworkCapabilities.TRANSPORT_VPN) == false - } - return network.socketFactory.createSocket("10.0.2.2", port).use { - it.soTimeout = 2_000 - it.getOutputStream().write("GET /$path HTTP/1.0\r\n\r\n".toByteArray()) - it.getInputStream().bufferedReader().readText().substringAfter("\r\n\r\n") - } - } - fun accepted(context: android.content.Context, port: Int) = request(context, port, "accepted") == "yes" - fun release(context: android.content.Context, port: Int) { assertEquals("yes", request(context, port, "release")) } -} diff --git a/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt b/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt index 00c0150..4d34e3e 100644 --- a/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt +++ b/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt @@ -48,6 +48,7 @@ class ProxyVpnService : VpnService() { @Volatile private var nextStartAttemptAt = 0L @Volatile private var retryStatus: String? = null private val tunnelStateLock = Any() + private var pendingStart: Pair? = null private val startGeneration = AtomicLong(0) private val probableFailureCounts = ConcurrentHashMap() private val probableFailureTimes = ConcurrentHashMap() @@ -352,6 +353,13 @@ class ProxyVpnService : VpnService() { val proxyCore = NativeProxyCore(this, diagnostics) { !serviceDestroyed && isStartCurrent(generation) } + synchronized(tunnelStateLock) { + if (!isStartCurrent(generation)) { + establishedTunnel.close() + return false + } + pendingStart = establishedTunnel to proxyCore + } var nativeStarted = false var tunnelCommitted = false try { @@ -402,6 +410,7 @@ class ProxyVpnService : VpnService() { val committed = synchronized(tunnelStateLock) { if (!isStartCurrent(generation) || tunnel != null) false else { tunnelCommitted = true + pendingStart = null tunnel = establishedTunnel core = proxyCore tunnelTestOnly = testOnly @@ -424,7 +433,10 @@ class ProxyVpnService : VpnService() { VpnRuntimeState.update(VpnConnectionState.CONNECTED) return true } finally { - // Until publication under tunnelStateLock, this attempt owns both resources. + synchronized(tunnelStateLock) { + if (pendingStart?.first === establishedTunnel) pendingStart = null + } + // Stop may cancel this pending attempt; failure still closes its resources. // Exceptions in DNS/cache/notification work must not leak them across retries. if (!tunnelCommitted) { try { @@ -682,7 +694,10 @@ class ProxyVpnService : VpnService() { private fun stopTunnel(removeForeground: Boolean = true) { startGeneration.incrementAndGet() + var pending: Pair? = null val stopped = synchronized(tunnelStateLock) { + pending = pendingStart + pendingStart = null val result = Triple(tunnel, core, tunnelTestOnly) tunnel = null core = null @@ -698,9 +713,11 @@ class ProxyVpnService : VpnService() { VpnRuntimeState.update(VpnConnectionState.DISCONNECTED) resetRetryState() try { + pending?.second?.stop() stopped.second?.stop() } finally { try { + pending?.first?.close() stopped.first?.close() } catch (error: java.io.IOException) { DiagnosticLog.add("event=tun_close result=failed error=${error.javaClass.simpleName}") diff --git a/native/mobile/bridge_test.go b/native/mobile/bridge_test.go index 4459e1e..1b79623 100644 --- a/native/mobile/bridge_test.go +++ b/native/mobile/bridge_test.go @@ -1,6 +1,7 @@ package mobile import ( + "context" "os" "strings" "testing" @@ -11,6 +12,25 @@ import ( "golang.org/x/sys/unix" ) +func TestBridgeStopCancelsPendingStart(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + state.Lock() + state.starting = true + state.startCancel = cancel + state.Unlock() + defer func() { + state.Lock() + state.starting = false + state.startCancel = nil + state.Unlock() + }() + Stop() + if ctx.Err() != context.Canceled { + t.Fatal("Stop left the pending native start running") + } +} + func TestBridgeOwnsOnlyDuplicatedDescriptor(t *testing.T) { original, err := os.CreateTemp(t.TempDir(), "tun") if err != nil { diff --git a/native/mobile/mobile.go b/native/mobile/mobile.go index 370b431..0881f68 100644 --- a/native/mobile/mobile.go +++ b/native/mobile/mobile.go @@ -26,6 +26,7 @@ var state struct { starting bool running bool stopping bool + startCancel context.CancelFunc device device.Device stack *stack.Stack proxyCloser io.Closer @@ -60,6 +61,9 @@ func Start(tunFD int, mtu int, rawConfig string, protector Protector, reporter R } state.generation++ generation := state.generation + ctx, cancelStart := context.WithCancel(context.Background()) + defer cancelStart() + state.startCancel = cancelStart state.starting = true state.Unlock() committed := false @@ -71,6 +75,7 @@ func Start(tunFD int, mtu int, rawConfig string, protector Protector, reporter R // The global tunnel must not retain a failed dialer and its Java callbacks. tunnel.T().SetProxy(&reject.Reject{}) state.starting = false + state.startCancel = nil state.Unlock() }() stats := resetStats() @@ -87,8 +92,8 @@ func Start(tunFD int, mtu int, rawConfig string, protector Protector, reporter R proxyCloser = httpsProxy } else { sshProxy := &sshDialer{config: c, protector: protector, reporter: reporter, stats: stats} - ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) - _, sessionErr := sshProxy.session(ctx) + sessionContext, cancel := context.WithTimeout(ctx, 30*time.Second) + _, sessionErr := sshProxy.session(sessionContext) cancel() if sessionErr != nil { dev.Close() @@ -121,6 +126,7 @@ func Start(tunFD int, mtu int, rawConfig string, protector Protector, reporter R state.stats = stats go stats.run() state.starting, state.running = false, true + state.startCancel = nil committed = true state.Unlock() report(reporter, "event=native_stack result=started type=%s fingerprint=%s ssh_profile=%s ipv6=%t bypass_local=%t", c.Type, c.Profile, c.SSHProfile, c.AllowIPv6, c.BypassLocalNetworks) @@ -142,6 +148,7 @@ func Stop() { return } state.stopping = true + cancelStart := state.startCancel // Drop the global strong reference to the dialer, config and JVM service callbacks. // Late queued packets must fail closed rather than use a replacement connection. tunnel.T().SetProxy(&reject.Reject{}) @@ -153,6 +160,9 @@ func Stop() { state.stats = nil } state.Unlock() + if cancelStart != nil { + cancelStart() + } defer func() { state.Lock() state.stopping = false diff --git a/scripts/android_device_tests.py b/scripts/android_device_tests.py index 0ba36e9..98f0805 100644 --- a/scripts/android_device_tests.py +++ b/scripts/android_device_tests.py @@ -4,7 +4,6 @@ import argparse import base64 import contextlib -import http.server import os import re import secrets @@ -164,49 +163,37 @@ def port(container, number): @contextlib.contextmanager -def stalled_ssh(): - accepted, release = threading.Event(), threading.Event() +def stalled_ssh(shell): + release = threading.Event() + prefix = "/data/local/tmp/megaproxy-device-" + uuid.uuid4().hex + accepted_path, release_path = prefix + "-accepted", prefix + "-release" class Stall(socketserver.BaseRequestHandler): def handle(self): - accepted.set() - release.wait(60) - - class Control(http.server.BaseHTTPRequestHandler): - def do_GET(self): - if self.path == "/release": - release.set() - body = b"yes" if accepted.is_set() else b"no" - self.send_response(200) - self.send_header("Content-Length", str(len(body))) - self.end_headers() - self.wfile.write(body) - - def log_message(self, *_args): - pass + shell("touch", accepted_path) + deadline = time.monotonic() + 60 + while not release.wait(0.1) and time.monotonic() < deadline: + if ( + shell("sh", "-c", f"if test -f {release_path}; then echo yes; fi") + == "yes" + ): + break - with ( - socketserver.ThreadingTCPServer(("127.0.0.1", 0), Stall) as server, - http.server.ThreadingHTTPServer(("127.0.0.1", 0), Control) as control, - ): + with socketserver.ThreadingTCPServer(("127.0.0.1", 0), Stall) as server: server.daemon_threads = True - workers = [ - threading.Thread(target=s.serve_forever, daemon=True) - for s in (server, control) - ] - for worker in workers: - worker.start() + worker = threading.Thread(target=server.serve_forever, daemon=True) + worker.start() try: yield { "stallPort": str(server.server_address[1]), - "controlPort": str(control.server_address[1]), + "sshAccepted": accepted_path, + "sshRelease": release_path, } finally: release.set() server.shutdown() - control.shutdown() - for worker in workers: - worker.join() + worker.join() + shell("rm", "-f", accepted_path, release_path) def main(): @@ -253,7 +240,7 @@ def shell(*words): shell("am", "force-stop", PACKAGE) if shell("pm", "clear", PACKAGE) != "Success": raise RuntimeError("Failed to reset disposable app data") - with fixture() as parameters, stalled_ssh() as stalled: + with fixture() as parameters, stalled_ssh(shell) as stalled: parameters.update(stalled) tests = [(name, None) for name in TESTS] if args.api >= 33: From ceed5d3f0e88c3d4d31510aa50af771800290fbe Mon Sep 17 00:00:00 2001 From: Andrey Prokopyuk Date: Tue, 6 Oct 2026 20:38:05 +0300 Subject: [PATCH 6/6] Read SSH fixture readiness without shell operators in UiAutomation --- app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt b/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt index c47fc06..991a059 100644 --- a/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt +++ b/app/src/androidTest/java/net/megaproxy487/VpnDeviceTest.kt @@ -61,7 +61,7 @@ class VpnDeviceTest : DeviceTestBase() { } ProxyVpnService.start(context) await("JNI startup did not reach SSH fixture") { - device.executeShellCommand("test -f ${argument("sshAccepted")} && echo yes").trim() == "yes" + device.executeShellCommand("ls ${argument("sshAccepted")}").trim() == argument("sshAccepted") } assertEquals(VpnConnectionState.CONNECTING, VpnRuntimeState.connection.value) ProxyVpnService.stop(context)