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
123 changes: 0 additions & 123 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,126 +117,3 @@ jobs:
echo
echo "Artifacts expire after 14 days. Neither APK uses the MegaProxy release key."
} >> "$GITHUB_STEP_SUMMARY"

android-ui-tests:
name: Android UI tests
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Check out repository
uses: actions/checkout@v6

- name: Enable KVM device access
run: |
if [[ ! -e /dev/kvm ]]; then
echo "::error::KVM is unavailable on this runner"
exit 1
fi

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

if [[ ! -r /dev/kvm || ! -w /dev/kvm ]]; then
echo "::error::The runner cannot access /dev/kvm"
ls -l /dev/kvm
exit 1
fi
echo "KVM is available; the emulator will use hardware acceleration"

- name: Set up JDK 21
uses: actions/setup-java@v5
with:
distribution: temurin
java-version: "21"
cache: gradle

- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: native/go.mod
cache-dependency-path: native/go.sum

- name: Set up Ruby and Fastlane
uses: ruby/setup-ruby@v1
with:
ruby-version: "3.4.10"
bundler-cache: true

- name: Install native build dependencies
run: |
sdkmanager="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager"
yes | "$sdkmanager" --licenses >/dev/null || true
"$sdkmanager" "ndk;29.0.14206865"
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV"

- name: Run Compose UI tests on an emulator
uses: reactivecircus/android-emulator-runner@v2.38.0
with:
api-level: 30
target: aosp_atd
arch: x86_64
disable-linux-hw-accel: false
disable-animations: true
emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none
script: bundle exec fastlane android ui_tests

- name: Verify UI tests actually ran
run: |
shopt -s nullglob
reports=(app/build/outputs/androidTest-results/connected/debug/TEST-*.xml)
if (( ${#reports[@]} == 0 )); then
echo "No Android UI test XML reports were produced" >&2
exit 1
fi

tests=0
failures=0
errors=0
skipped=0
for report in "${reports[@]}"; do
suite="$(sed -nE 's/.*tests="([0-9]+)" failures="([0-9]+)" errors="([0-9]+)" skipped="([0-9]+)".*/\1 \2 \3 \4/p' "$report" | head -n 1)"
if [[ -z "$suite" ]]; then
echo "Cannot read test counters from $report" >&2
exit 1
fi
read -r suite_tests suite_failures suite_errors suite_skipped <<< "$suite"
(( tests += suite_tests )) || true
(( failures += suite_failures )) || true
(( errors += suite_errors )) || true
(( skipped += suite_skipped )) || true
done

if (( tests < 4 )); then
echo "Expected at least 4 Android UI tests, found $tests" >&2
exit 1
fi
if (( failures != 0 || errors != 0 || skipped != 0 )); then
echo "Android UI test results: tests=$tests failures=$failures errors=$errors skipped=$skipped" >&2
exit 1
fi
expected_tests=(
everySettingsDestinationOpensAndReturnsThroughTheNavHost
russianLocaleIsAppliedAcrossNavigation
settingsAndProfilesShareTheMainActivityBackStack
trafficUnitsDefaultToIecAndPersistSiSelection
)
for expected_test in "${expected_tests[@]}"; do
if ! grep -Fq "name=\"$expected_test\"" "${reports[@]}"; then
echo "Expected Android UI test did not run: $expected_test" >&2
exit 1
fi
done
echo "Verified Android UI test results: tests=$tests failures=0 errors=0 skipped=0"

- name: Publish UI test report
if: ${{ always() && hashFiles('app/build/outputs/androidTest-results/connected/debug/TEST-*.xml') != '' }}
uses: actions/upload-artifact@v7
with:
name: android-ui-test-report
path: |
app/build/reports/androidTests/connected/debug
app/build/outputs/androidTest-results/connected/debug
if-no-files-found: error
retention-days: 14
30 changes: 16 additions & 14 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,19 @@ branch names, credentials, signing material, or other secrets.

## CI and artifacts

- Pull requests must run native tests, Android unit/lint/build checks, and instrumented Compose UI
tests.
- 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.
- PR builds may publish debug and unsigned APK artifacts. They must never have access to release
signing material and must never produce or publish a signed release APK.
- Surface downloadable APK artifacts in the GitHub Actions job summary in addition to uploading
them through `actions/upload-artifact`.
- UI tests use the lightweight API 30 `aosp_atd` x86_64 image. KVM acceleration is required: fail
quickly with a clear diagnostic when `/dev/kvm` is unavailable instead of falling back to the
unstable software emulator. An infrastructure retry should use a fresh job/runner rather than
repeatedly invoking instrumentation in the same crashed emulator.
- Treat `Starting 0 tests`, missing XML reports, or fewer than the expected tests as a CI failure;
a green Gradle process alone is not proof that instrumentation tests ran.
- Prefer extracting UI-facing decisions into small production contracts and testing those with
deterministic JVM unit tests. Resource parity, navigation destination wiring, preference
serialization/defaults, formatting, and state transitions should not require a device.
- 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.

## Releases and distribution

Expand All @@ -52,21 +53,22 @@ branch names, credentials, signing material, or other secrets.
- In Russian UI, translate “samples” as “сэмплы”, not “попытки”. Relative latency timestamps belong
on a separate line in smaller text.
- Connection diagnostics include exit IP and country, with fallback providers so one unavailable
external service does not make the whole check fail. UI tests should assert that country is
actually presented, not merely fetched internally.
external service does not make the whole check fail. Keep presentation inputs and formatting
covered by JVM tests.

## Architecture landmarks

- `ProxyVpnService` extends Android's standard `android.net.VpnService`. It owns VPN lifecycle,
creates the TUN interface, coordinates profiles/reconnects/status, and hands the TUN file
descriptor to the native networking layer. Native code performs the actual proxy forwarding.
- Navigation uses a single activity/back stack. Navigation, localization, settings persistence,
and cross-screen behavior should be covered by Compose instrumentation tests.
- Navigation uses a single activity/back stack. Keep route and settings-destination definitions in
shared production contracts whose completeness and uniqueness can be checked by JVM tests.

## Collaboration workflow

- Put each new change on a branch based on the current `main` and normally deliver it as one focused
GitHub pull request. After a PR is merged, start subsequent work from the updated `main` instead of
continuing on the merged branch.
- Before changing CI after a failure, inspect the full job log and distinguish application test
failures from runner/emulator infrastructure failures.
- Keep required CI deterministic. If a check depends on unreliable hosted-runner capabilities,
replace it with JVM coverage where practical or move it to purpose-built infrastructure rather
than normalizing repeated reruns.
6 changes: 0 additions & 6 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ android {
versionCode = versionCodeBase * 1000 + versionVariantCode
versionName = "0.0.12"
buildConfigField("String", "GIT_COMMIT_HASH", "\"$gitCommitHash\"")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
if (versionVariant != "universal") {
// Keep every ABI-specific APK genuinely single-ABI. Without this,
// transitive native libraries are packaged for every architecture
Expand Down Expand Up @@ -119,11 +118,6 @@ dependencies {
implementation("androidx.navigation:navigation-compose:2.9.5")
testImplementation("junit:junit:4.13.2")
testImplementation("org.json:json:20250107")
androidTestImplementation(platform("androidx.compose:compose-bom:2025.01.01"))
androidTestImplementation("androidx.compose.ui:ui-test-junit4")
androidTestImplementation("androidx.test.ext:junit:1.2.1")
androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1")
debugImplementation("androidx.compose.ui:ui-test-manifest")
runtimeOnly(files("libs/megaproxy.aar"))
}

Expand Down
111 changes: 0 additions & 111 deletions app/src/androidTest/java/net/megaproxy487/AppNavigationTest.kt

This file was deleted.

39 changes: 39 additions & 0 deletions app/src/main/java/net/megaproxy487/AppNavigation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,48 @@ internal object AppRoute {
const val DIAGNOSTIC_LOG = "diagnostic-log"
const val SSH_HOST_KEY = "ssh-host-key"

val registeredStaticRoutes = setOf(
MAIN,
SETTINGS,
PROFILES,
ALWAYS_ON,
FINGERPRINTS,
SPLIT_TUNNEL,
FAILOVER,
VISIBILITY,
CONNECTION_TEST,
DIAGNOSTIC_LOG,
SSH_HOST_KEY,
)

fun profileEditor(profileId: String) = "profile/${Uri.encode(profileId)}"
}

internal data class SettingsDestination(
val titleRes: Int,
val descriptionRes: Int,
val route: String,
)

internal val connectionSettingsDestinations = listOf(
SettingsDestination(R.string.profiles, R.string.profiles_description, AppRoute.PROFILES),
SettingsDestination(R.string.always_on_vpn, R.string.always_on_description, AppRoute.ALWAYS_ON),
SettingsDestination(R.string.fingerprints, R.string.fingerprints_description, AppRoute.FINGERPRINTS),
SettingsDestination(R.string.split_tunneling, R.string.split_tunneling_description, AppRoute.SPLIT_TUNNEL),
SettingsDestination(R.string.failover, R.string.failover_description, AppRoute.FAILOVER),
)

internal val visibilitySettingsDestination =
SettingsDestination(R.string.visibility, R.string.visibility_description, AppRoute.VISIBILITY)

internal val diagnosticLogSettingsDestination =
SettingsDestination(R.string.diagnostic_log, R.string.diagnostic_log_description, AppRoute.DIAGNOSTIC_LOG)

internal val settingsDestinations = connectionSettingsDestinations + listOf(
visibilitySettingsDestination,
diagnosticLogSettingsDestination,
)

@Composable
internal fun MegaProxyNavHost(activity: Activity) {
val navController = rememberNavController()
Expand Down
Loading