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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions .github/workflows/android-device.yml
Original file line number Diff line number Diff line change
@@ -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"
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,6 @@ __pycache__/
# Local Bundler configuration and installed gems
/.bundle/
/vendor/bundle/

/test-results/
/fastlane/report.xml
18 changes: 9 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -139,6 +140,9 @@ dependencies {
testImplementation("androidx.compose.ui:ui-test-junit4")
debugImplementation("androidx.compose.ui:ui-test-manifest")
testImplementation("junit:junit:4.13.2")
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"))
}
Expand Down
158 changes: 158 additions & 0 deletions app/src/androidTest/java/net/megaproxy487/DeviceTestBase.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
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.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
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.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

/** 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 lateinit var scenario: ActivityScenario<MainActivity>
protected fun argument(name: String) = requireNotNull(arguments.getString(name)) { "Missing fixture argument: $name" }
protected fun text(id: Int) = context.getString(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.
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() {
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()
}
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() {
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()
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())
scenario = ActivityScenario.launch(MainActivity::class.java)
device.waitForIdle()
}
}
private fun cleanup() {
repeat(3) {
if (device.currentPackageName != context.packageName) {
device.pressBack()
device.waitForIdle()
}
}
try {
ProxyVpnService.stop(context)
stopped()
} finally {
saved()
}
}
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)
}
Loading
Loading