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
62 changes: 62 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,65 @@ 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 acceleration
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm

- 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: 35
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: Publish UI test report
if: always()
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: ignore
retention-days: 14
1 change: 1 addition & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"recommendations": [
"vscjava.vscode-java-pack",
"vscjava.vscode-gradle",
"fwcd.kotlin",
"golang.go"
]
Expand Down
9 changes: 9 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"java.configuration.updateBuildConfiguration": "automatic",
"java.import.gradle.enabled": true,
"java.import.gradle.wrapper.enabled": true,
"java.jdt.ls.androidSupport.enabled": true,
"java.gradle.buildServer.enabled": "on",
"kotlin.compiler.jvm.target": "17",
"kotlin.scripts.buildScriptsEnabled": false
}
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,13 @@ emulator, building, installing, viewing app-specific Logcat output, running test
JDWP debugger. Select **Run MegaProxy on Emulator** for `Ctrl+F5` or **Attach MegaProxy (JDWP)** for
`F5`.

For Kotlin editing, install the workspace recommendations, including `fwcd.kotlin` and Gradle for
Java. The repository's `kls-classpath` helper supplies the language server with the Android SDK,
Compose dependencies, and generated `R` classes that it cannot discover from Android Gradle Plugin
variants by itself. After installing or updating the extensions, run **Kotlin: Restart the Language
Server** (or **Developer: Reload Window**) once. Run `./kls-classpath` directly to diagnose
classpath resolution.

Run all native and Android checks from VS Code with **Tasks: Run Test Task**, or from a shell:

```shell
Expand Down
31 changes: 31 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import java.io.File
import org.gradle.api.attributes.Attribute

plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
Expand Down Expand Up @@ -113,7 +116,35 @@ dependencies {
implementation("androidx.compose.material:material-icons-extended")
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7")
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"))
}

// fwcd.kotlin does not understand Android Gradle Plugin variants reliably. Its language server
// invokes the repository's kls-classpath script, which uses this task to expose the same classpath
// as the debug Kotlin compiler, including Android's boot classes and generated R/BuildConfig code.
tasks.register("kotlinLanguageServerClasspath") {
dependsOn("processDebugResources")
doLast {
val generatedEntries = listOf(
layout.buildDirectory.file("intermediates/compile_and_runtime_not_namespaced_r_class_jar/debug/processDebugResources/R.jar").get().asFile,
layout.buildDirectory.dir("intermediates/javac/debug/compileDebugJavaWithJavac/classes").get().asFile,
)
val compileJars = configurations.getByName("debugCompileClasspath").incoming.artifactView {
attributes.attribute(Attribute.of("artifactType", String::class.java), "android-classes-jar")
}.files.files
val entries = (
compileJars +
android.bootClasspath +
generatedEntries.filter { it.exists() }
).map { it.absolutePath }.distinct()
println(entries.joinToString(File.pathSeparator))
}
}
91 changes: 91 additions & 0 deletions app/src/androidTest/java/net/megaproxy487/AppNavigationTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package net.megaproxy487

import android.content.Context
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createEmptyComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.After
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class AppNavigationTest {
@get:Rule
val compose = createEmptyComposeRule()

private lateinit var scenario: ActivityScenario<MainActivity>

@Before
fun launchApp() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
context.getSharedPreferences("app_language", Context.MODE_PRIVATE)
.edit().putString("language", "en").commit()
context.getSharedPreferences("battery_optimization_reminder", Context.MODE_PRIVATE)
.edit().putLong("last_request_at", System.currentTimeMillis()).commit()
scenario = ActivityScenario.launch(MainActivity::class.java)
}

@After
fun closeApp() {
scenario.close()
}

@Test
fun settingsAndProfilesShareTheMainActivityBackStack() {
compose.onNodeWithTag("screen-main").assertIsDisplayed()

compose.onNodeWithText("Settings").performClick()
compose.onNodeWithTag("screen-settings").assertIsDisplayed()

compose.onNodeWithText("Profiles").performClick()
compose.onNodeWithTag("screen-profiles").assertIsDisplayed()

compose.onNodeWithContentDescription("Back").performClick()
compose.onNodeWithTag("screen-settings").assertIsDisplayed()

compose.onNodeWithContentDescription("Back").performClick()
compose.onNodeWithTag("screen-main").assertIsDisplayed()
}

@Test
fun everySettingsDestinationOpensAndReturnsThroughTheNavHost() {
compose.onNodeWithText("Settings").performClick()

listOf(
"Profiles" to "profiles",
"Always-on VPN" to "always-on",
"Fingerprints" to "fingerprints",
"Split tunneling" to "split-tunnel",
"Failover" to "failover",
"Visibility" to "visibility",
"Diagnostic log" to "diagnostic-log",
).forEach { (label, route) ->
compose.onNodeWithText(label).performScrollTo().performClick()
compose.onNodeWithTag("screen-$route").assertIsDisplayed()
compose.onNodeWithContentDescription("Back").performClick()
compose.onNodeWithTag("screen-settings").assertIsDisplayed()
}
}

@Test
fun russianLocaleIsAppliedAcrossNavigation() {
scenario.close()
InstrumentationRegistry.getInstrumentation().targetContext
.getSharedPreferences("app_language", Context.MODE_PRIVATE)
.edit().putString("language", "ru").commit()
scenario = ActivityScenario.launch(MainActivity::class.java)

compose.onNodeWithText("Настройки").performClick()
compose.onNodeWithText("Профили").assertIsDisplayed()
compose.onNodeWithText("Диагностический журнал").performScrollTo().assertIsDisplayed()
}
}
13 changes: 1 addition & 12 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,25 +27,14 @@
android:theme="@style/Theme.MegaProxy">
<activity
android:name=".MainActivity"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity android:name=".ProxySettingsActivity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".ProfilesActivity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".AlwaysOnSettingsActivity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".TlsFingerprintActivity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".FailoverSettingsActivity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".VisibilityActivity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".ProfileEditorActivity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".SplitTunnelActivity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".ConnectionTestActivity" android:exported="false" android:windowSoftInputMode="adjustResize" />
<activity android:name=".SshHostKeyActivity" android:exported="false" />
<activity android:name=".DiagnosticLogActivity" android:exported="false" android:windowSoftInputMode="adjustResize" />

<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
Expand Down
89 changes: 89 additions & 0 deletions app/src/main/java/net/megaproxy487/AppNavigation.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package net.megaproxy487

import android.app.Activity
import android.net.Uri
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument

internal object AppRoute {
const val MAIN = "main"
const val SETTINGS = "settings"
const val PROFILES = "profiles"
const val PROFILE_EDITOR = "profile/{profileId}"
const val ALWAYS_ON = "always-on"
const val FINGERPRINTS = "fingerprints"
const val SPLIT_TUNNEL = "split-tunnel"
const val FAILOVER = "failover"
const val VISIBILITY = "visibility"
const val CONNECTION_TEST = "connection-test"
const val DIAGNOSTIC_LOG = "diagnostic-log"
const val SSH_HOST_KEY = "ssh-host-key"

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

@Composable
internal fun MegaProxyNavHost(activity: Activity) {
val navController = rememberNavController()
val hostKeyPrompt by net.megaproxy487.vpn.SshHostKeyPromptState.pending
val back = { navController.popBackStack(); Unit }

LaunchedEffect(hostKeyPrompt) {
if (hostKeyPrompt != null && navController.currentDestination?.route != AppRoute.SSH_HOST_KEY) {
navController.navigate(AppRoute.SSH_HOST_KEY)
}
}

NavHost(navController = navController, startDestination = AppRoute.MAIN) {
composable(AppRoute.MAIN) {
ScreenDestination(AppRoute.MAIN) { MainScreen(
activity = activity,
onOpenSettings = { navController.navigate(AppRoute.SETTINGS) },
onOpenConnectionTest = { navController.navigate(AppRoute.CONNECTION_TEST) },
onEditProfile = { navController.navigate(AppRoute.profileEditor(it)) },
) }
}
composable(AppRoute.SETTINGS) {
ScreenDestination(AppRoute.SETTINGS) { SettingsHomeScreen(activity, back) { navController.navigate(it) } }
}
composable(AppRoute.PROFILES) {
ScreenDestination(AppRoute.PROFILES) { ProfilesScreen(activity, back) { navController.navigate(AppRoute.profileEditor(it)) } }
}
composable(
route = AppRoute.PROFILE_EDITOR,
arguments = listOf(navArgument("profileId") { type = NavType.StringType }),
) { entry ->
ScreenDestination("profile-editor") { ProfileEditorScreen(activity, entry.arguments?.getString("profileId"), back) }
}
composable(AppRoute.ALWAYS_ON) { ScreenDestination(AppRoute.ALWAYS_ON) { AlwaysOnSettingsScreen(activity, back) } }
composable(AppRoute.FINGERPRINTS) { ScreenDestination(AppRoute.FINGERPRINTS) { TlsFingerprintScreen(activity, back) } }
composable(AppRoute.SPLIT_TUNNEL) { ScreenDestination(AppRoute.SPLIT_TUNNEL) { SplitTunnelScreen(activity, back) } }
composable(AppRoute.FAILOVER) { ScreenDestination(AppRoute.FAILOVER) { FailoverSettingsScreen(activity, back) } }
composable(AppRoute.VISIBILITY) { ScreenDestination(AppRoute.VISIBILITY) { VisibilityScreen(activity, back) } }
composable(AppRoute.CONNECTION_TEST) { ScreenDestination(AppRoute.CONNECTION_TEST) { ConnectionTestScreen(activity, autoStart = true, onBack = back) } }
composable(AppRoute.DIAGNOSTIC_LOG) { ScreenDestination(AppRoute.DIAGNOSTIC_LOG) { DiagnosticLogScreen(activity, back) } }
composable(AppRoute.SSH_HOST_KEY) {
hostKeyPrompt?.let { prompt ->
SshHostKeyScreen(activity, prompt) {
net.megaproxy487.vpn.SshHostKeyPromptState.clear()
navController.popBackStack()
}
}
}
}
}

@Composable
private fun ScreenDestination(route: String, content: @Composable () -> Unit) {
Box(Modifier.fillMaxSize().testTag("screen-$route")) { content() }
}
Loading