diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9753195..925fc42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,7 @@ jobs: PR_NUMBER: ${{ github.event.pull_request.number }} GH_TOKEN: ${{ github.token }} run: | - args=(--base "$BASE_SHA" --head "$HEAD_SHA" --github-output "$GITHUB_OUTPUT" --summary "$GITHUB_STEP_SUMMARY") + args=(--base "$BASE_SHA" --head "$HEAD_SHA" --run-attempt "$GITHUB_RUN_ATTEMPT" --github-output "$GITHUB_OUTPUT" --summary "$GITHUB_STEP_SUMMARY") if [[ "$EVENT_NAME" == push ]]; then args+=(--push); else args+=(--history); fi python3 scripts/ci_changes.py "${args[@]}" diff --git a/AGENTS.md b/AGENTS.md index 985a43f..42ab1ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,6 +28,10 @@ branch names, credentials, signing material, or other secrets. 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. +- 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, + so previously skipped suites run too. Failed-only reruns reuse scope unless that job also reruns. - 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 @@ -35,6 +39,9 @@ branch names, credentials, signing material, or other secrets. - 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. +- Compose interaction tests may run in `app/src/test` using Robolectric with a pinned SDK and + plain test Application. Inject platform operations; do not load Go JNI or real Keystore in + those tests. They run through the existing Fastlane Android checks without an emulator. - 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. @@ -77,6 +84,9 @@ branch names, credentials, signing material, or other secrets. - `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. +- JNI calls use the generated gomobile types as a compile-time dependency. Native `Start` borrows + the JVM TUN descriptor only for the call, duplicates it internally, and receives the Android MTU + explicitly. Keep callback exceptions inside the JVM boundary and reject stale-session callbacks. - 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. diff --git a/PRIVACY.md b/PRIVACY.md index 44b90d0..4c1e80a 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,37 +1,67 @@ # Privacy Policy -**Last updated: September 2, 2026** +**Last updated: September 8, 2026** -MegaProxy does not collect, transmit, store, or share personal information or user data. +MegaProxy is an Android VPN client for proxy servers you choose. It does not provide a proxy +service, require an account, or automatically send usage data or crash reports to the developer. +It contains no advertising, analytics SDKs or tracking services. -The application does not use analytics, telemetry, advertising services, or user tracking. +## Data used on your device -## Diagnostic logs +MegaProxy stores your connection profiles, credentials, settings and trusted SSH host keys to +connect to your servers. Passwords and imported private keys are encrypted with a key held by Android Keystore. Android backup and device transfer are disabled for app data. -The application may create diagnostic logs locally on the user's device for troubleshooting purposes. +For per-app routing, MegaProxy reads the applications visible to it on your device and stores +which applications you select. This information is used locally to configure routing, not +uploaded as an application inventory. Profile exports can include these routing selections. -These logs are not automatically transmitted to the developer or any third party. +Connection statistics and diagnostic/crash logs are processed locally to display connection +status and help troubleshoot failures. Logs contain operational events and error details; +filtering is designed to remove credentials and sensitive addresses. Review reports before sharing. -The user may choose to export or share a diagnostic log using the sharing functionality provided by the device. Sharing a log is entirely voluntary and is initiated explicitly by the user. +## Data sent to network services -The application is designed not to include personal information in diagnostic logs. +MegaProxy forwards selected application traffic to your configured proxy servers and sends the +authentication information needed to connect. SSH authentication does not transmit private keys. Proxy +operators can see connection metadata and destinations, and unencrypted application content. +Choose operators you trust. -## Data sharing +DNS providers receive the names being resolved. Before connecting, MegaProxy may resolve your +proxy's hostname directly through Cloudflare, Yandex, Google or Quad9; these resolvers can see +your source IP address and the proxy hostname. DNS queries through the tunnel use the configured +provider and permitted fallbacks. -MegaProxy does not automatically send user data to the developer or to third parties. +When you run a connection test, MegaProxy contacts a test website and external IP/country lookup +services through the proxy. They receive the exit IP and test requests to check connectivity and +identify the proxy's apparent country, not your GPS location. The current services are listed in +[Network privacy details](README.md#privacy-and-security). These providers handle requests under +their own policies; MegaProxy does not control their retention practices. -## Data retention and deletion +## Sharing and contacting support -The developer does not maintain a database or other server-side storage containing user data collected by the application. +Exporting or sharing a profile can disclose its settings and, if explicitly included, passwords +or private keys to the destination you choose. Copying diagnostics places them on the clipboard. -Locally stored application data and diagnostic logs can be removed by clearing the application's data or uninstalling the application. +If you choose to share a feedback or crash report, MegaProxy passes it to the application you +select. The prepared report contains device model, Android/app versions, a summary of connection settings and +a diagnostic log attachment. You control whether to send it. The receiving application handles +the shared copy under its own policies. -## Third-party services +If you email support, the developer receives your sender address, message and any attachments +you send for handling your request. Uninstalling MegaProxy does not delete that correspondence. -MegaProxy does not use third-party analytics, advertising, telemetry, or tracking services. +## Retention and deletion -## Contact +Profiles and settings remain locally until you change/delete them or clear app data. Diagnostic +logs rotate within a configurable size limit; you can clear them in the diagnostic-log screen. +Clearing app data or uninstalling removes local app files, including cached report attachments. +Exported files and copies shared with other applications must be deleted separately. + +Support emails, including the sender address, message and attachments, are retained until the +reported problem is fixed, then deleted. You can contact the developer about your correspondence +at the address below. -If you have questions about this Privacy Policy, you can contact: +## Contact -megaproxy-feedback@hotmail.com +For privacy questions or requests about information you sent to support, contact the MegaProxy +developer at megaproxy-feedback@hotmail.com. diff --git a/README.md b/README.md index 3c64ef9..6983334 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # MegaProxy -[![CI](https://github.com/andre487/AndroidMegaProxy/actions/workflows/ci.yml/badge.svg)](https://github.com/andre487/AndroidMegaProxy/actions/workflows/ci.yml) +[![CI](https://github.com/andre487/AndroidMegaProxy/actions/workflows/ci.yml/badge.svg?branch=main&event=push)](https://github.com/andre487/AndroidMegaProxy/actions/workflows/ci.yml?query=branch%3Amain+event%3Apush) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![Android 8+](https://img.shields.io/badge/Android-8.0%2B-3DDC84?logo=android&logoColor=white)](https://developer.android.com/about/versions/oreo) @@ -22,8 +22,8 @@ statistics and diagnostic logs stay on the device unless you explicitly choose t - **Private by design.** No account, ads, analytics, tracking identifiers, or background telemetry. - **Your infrastructure.** Connect to your HTTPS or SSH servers, directly or through a jump server. -- **End-to-end application encryption.** HTTPS proxying uses CONNECT without intercepting or - decrypting application traffic. +- **Preserves application TLS.** HTTPS proxying uses CONNECT without intercepting or + decrypting application TLS; plain application protocols still need their own encryption. - **Flexible routing.** Route the whole device or only selected applications through the VPN. - **Resilient connections.** Profile failover, encrypted DNS fallback, SSH keepalives, and connection health reporting help recover from network and server failures. @@ -51,6 +51,7 @@ statistics and diagnostic logs stay on the device unless you explicitly choose t - Android Always-on VPN integration and a persistent foreground-service notification. - Automatic reconnect when the active profile or pending connection settings change. - Approximate upload speed, download speed, proxy latency, and recent connection-error rate. +- Session traffic totals with selectable IEC/SI units, connection start time and elapsed duration. ### DNS and transport @@ -63,7 +64,7 @@ statistics and diagnostic logs stay on the device unless you explicitly choose t ### Diagnostics -- A staged connection test for proxy setup, `example.com`, and the observed exit IP. +- A staged connection test for proxy setup, `example.com`, and the observed exit IP and country. - Local, size-limited, rotating diagnostic and crash logs designed to omit credentials and traffic content. - On-device connection visibility checks and actionable connection warnings. @@ -75,8 +76,10 @@ statistics and diagnostic logs stay on the device unless you explicitly choose t MegaProxy does not operate a proxy service and does not send configuration or usage data to the project author. Network traffic is sent only where required by the selected profile, destination, -and DNS configuration. The explicit connection test additionally contacts `example.com` and -`ifconfig.me`. +and DNS configuration. Proxy-hostname bootstrap may contact Cloudflare, Yandex, Google or Quad9 +DoH resolvers directly before the tunnel exists. The explicit connection test contacts `example.com` +and uses fallback providers for exit IP (`ifconfig.me`, `api.ipify.org`, `icanhazip.com`) and country +(`ifconfig.co`, `ipapi.co`, `api.country.is`) through the proxy. See [PRIVACY.md](PRIVACY.md). - HTTPS proxy certificates are checked against the Android trust store, including hostname and validity. Normal CA certificate renewal does not require certificate pinning. @@ -104,8 +107,8 @@ vendors may impose additional background-execution restrictions. MegaProxy requires Android 8.0 (API 26) or newer. Download the latest signed build from [GitHub Releases](https://github.com/andre487/AndroidMegaProxy/releases/latest), expand the **Assets** section, and download the file ending in `universal.apk`. It is the recommended build: -it supports every architecture listed below and is also the artifact independently rebuilt and -verified for F-Droid distribution. +it supports every architecture listed below and is the artifact intended for reproducible +F-Droid verification. **[Download the recommended universal APK](https://github.com/andre487/AndroidMegaProxy/releases/latest/download/mega-proxy-universal.apk)** @@ -180,7 +183,7 @@ After installation: 1. Create or import a connection profile. 2. Choose global routing or select applications for split tunneling. 3. Review DNS and fingerprint settings if the defaults are not appropriate for your server. -4. Tap **Test** to validate the connection, then tap **Connect**. +4. Open the main-screen menu and choose **Test**, then tap **Connect**. 5. Optionally enable Always-on VPN in Android settings. Server configurations and setup instructions are maintained separately in @@ -233,16 +236,15 @@ exports support single HTTPS proxies only and omit chain profiles. The command-line build does not require Android Studio. It requires: - JDK 21 -- Go 1.26 or newer -- `gomobile` -- Android SDK Platform 35 +- Go 1.26.3 or newer (see `native/go.mod`) +- Network access to download the pinned `gomobile`/`gobind` tools during native builds - Android SDK Platform 36 and Build Tools 36.0.0 - Android NDK 29.0.14206865 Example environment on macOS: ```shell -export JAVA_HOME="/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home" +export JAVA_HOME="$(/usr/libexec/java_home -v 21)" export ANDROID_HOME="$HOME/Library/Android/sdk" export ANDROID_NDK_HOME="$ANDROID_HOME/ndk/29.0.14206865" export PATH="$JAVA_HOME/bin:$HOME/go/bin:$ANDROID_HOME/cmdline-tools/latest/bin:$ANDROID_HOME/emulator:$ANDROID_HOME/platform-tools:$PATH" @@ -269,22 +271,26 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk adb shell am start -n net.megaproxy487/.MainActivity ``` -The project pins Gradle 8.9 through the checked-in wrapper. Use `./gradlew` rather than a globally +The project pins Gradle 8.11.1 through the checked-in wrapper. Use `./gradlew` rather than a globally installed Gradle version. See [native/README.md](native/README.md) for Go data-plane details. ## Development workflow -English is the project language for source code, comments, documentation, commit messages, UI -copy, logs, and tooling. +English is the project language for source code, comments, commit messages, logs, and tooling. +User-visible UI strings are provided in English and Russian. Developer guides are maintained in +both languages; numbers and dates follow the system locale independently of the app language. ### Emulator -Create the API 35 Google APIs ARM64 emulator: +For optional local device testing on an ARM64 host, create the API 35 Google APIs ARM64 emulator +(the script installs the emulator and system image if missing): ```shell ./scripts/create-android-emulator.sh ``` +Required CI and Robolectric Compose tests do not need an emulator. + 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 name. @@ -318,6 +324,9 @@ logging. ### Signed release builds +Release scripts require `gomobile` on `PATH`. The `debug_artifact` and `android_checks` lanes +install the pinned native tools; run either once when preparing a fresh build environment. + Build optimized and signed APKs for `arm64-v8a`, `armeabi-v7a`, `x86_64`, and `x86`, plus the universal APK used for reproducible F-Droid verification: @@ -335,18 +344,15 @@ provided as `mega-proxy-native-debug-symbols.zip` for upload in Play Console. Pushing a version tag runs the same Fastlane release lane in GitHub Actions, builds and verifies every APK and the App Bundle, and attaches the artifacts to a GitHub Release. The tag must match -`versionName` exactly: - -```shell -git tag v0.0.4 -git push origin v0.0.4 -``` +`v` followed by the current `versionName` in `app/build.gradle.kts`. Create that tag with +`git tag` and push the specific tag with `git push origin`; do not reuse a historical release tag. ## Contributing Bug reports and focused pull requests are welcome. Please avoid including proxy credentials, private keys, destination history, or other personal data in issues and logs. Run both the Go and -Android unit-test suites before opening a pull request. +Android checks with `bundle exec fastlane android test` before opening a pull request. For Python +changes, also run `bundle exec fastlane android python_checks`; the `test` lane does not include Python. ## License @@ -354,7 +360,8 @@ MegaProxy is released under the [MIT License](LICENSE). ### CI tools -CI selects checks from changes since each suite’s last successful ancestor check, with a full PR diff fallback. Use `python3 scripts/github_actions.py` to choose an open -PR and rerun all CI jobs or only failed jobs through GitHub CLI. Supports `--dry-run` and `--yes`/`-y`. +Every push to main runs all Android/Compose UI, Go and Python checks; the CI badge tracks these runs. +PR CI selects checks from changes since each suite’s last successful ancestor check, with a full PR diff fallback. Use `python3 scripts/github_actions.py` to choose an open +PR and rerun all CI jobs (including skipped checks) or only failed jobs through GitHub CLI. Supports `--dry-run` and `--yes`/`-y`. See the [English](docs/en/fastlane.md) or [Russian](docs/ru/fastlane.md) reference for scope rules, Python formatting/tests and launcher setup. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 805c27e..1d31236 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -83,6 +83,24 @@ android { } } + testOptions { + unitTests.isIncludeAndroidResources = true + unitTests.all { + it.systemProperty("robolectric.dependency.repo.url", "https://repo.maven.apache.org/maven2") + it.jvmArgs( + "--add-opens=java.base/java.lang=ALL-UNNAMED", + "--add-opens=java.base/java.util=ALL-UNNAMED", + "--add-opens=java.base/java.io=ALL-UNNAMED", + "--add-opens=java.base/java.net=ALL-UNNAMED", + "--add-opens=java.base/java.security=ALL-UNNAMED", + "--add-opens=java.base/java.text=ALL-UNNAMED", + "--add-opens=java.base/jdk.internal.access=ALL-UNNAMED", + "--add-opens=java.desktop/java.awt.font=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED", + ) + } + } + buildFeatures { compose = true buildConfig = true @@ -116,9 +134,12 @@ dependencies { 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("org.robolectric:robolectric:4.16") + testImplementation("androidx.compose.ui:ui-test-junit4") + debugImplementation("androidx.compose.ui:ui-test-manifest") testImplementation("junit:junit:4.13.2") testImplementation("org.json:json:20250107") - runtimeOnly(files("libs/megaproxy.aar")) + implementation(files("libs/megaproxy.aar")) } // fwcd.kotlin does not understand Android Gradle Plugin variants reliably. Its language server diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c550e85..8177ef0 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -35,6 +35,11 @@ + NativeConnectionStats? = ConnectionStatsReader::snapshot, +) { val navController = rememberNavController() val hostKeyPrompt by net.megaproxy487.vpn.SshHostKeyPromptState.pending val back = { navController.popBackStack(); Unit } @@ -80,6 +85,8 @@ internal fun MegaProxyNavHost(activity: Activity) { LaunchedEffect(hostKeyPrompt) { if (hostKeyPrompt != null && navController.currentDestination?.route != AppRoute.SSH_HOST_KEY) { navController.navigate(AppRoute.SSH_HOST_KEY) + } else if (hostKeyPrompt == null && navController.currentDestination?.route == AppRoute.SSH_HOST_KEY) { + navController.popBackStack() } } @@ -87,6 +94,7 @@ internal fun MegaProxyNavHost(activity: Activity) { composable(AppRoute.MAIN) { ScreenDestination(AppRoute.MAIN) { MainScreen( activity = activity, + readConnectionStats = readConnectionStats, onOpenSettings = { navController.navigate(AppRoute.SETTINGS) }, onOpenConnectionTest = { navController.navigate(AppRoute.CONNECTION_TEST) }, onEditProfile = { navController.navigate(AppRoute.profileEditor(it)) }, @@ -115,7 +123,7 @@ internal fun MegaProxyNavHost(activity: Activity) { hostKeyPrompt?.let { prompt -> SshHostKeyScreen(activity, prompt) { net.megaproxy487.vpn.SshHostKeyPromptState.clear() - navController.popBackStack() + if (navController.currentDestination?.route == AppRoute.SSH_HOST_KEY) navController.popBackStack() } } } diff --git a/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt b/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt index 71069a5..a3143de 100644 --- a/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt +++ b/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt @@ -72,15 +72,12 @@ internal fun ConnectionTestScreen(activity: Activity, autoStart: Boolean, onBack val state by TestDiagnosticLog.state val exitIp by TestDiagnosticLog.exitIp val countryCode by TestDiagnosticLog.countryCode - val pendingHostKey by SshHostKeyPromptState.pending - var vpnPermissionRequestedAt by remember { mutableStateOf(0L) } var showAlwaysOnConflict by remember { mutableStateOf(false) } val permission = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { if (VpnService.prepare(activity) == null) ProxyVpnService.test(activity) else { val status = readAlwaysOnVpnStatus(activity) - val dismissedImmediately = System.currentTimeMillis() - vpnPermissionRequestedAt < 1_000 - if (status.hasOtherProvider || dismissedImmediately) { + if (status.hasOtherProvider) { TestDiagnosticLog.fail(activity.uiText(R.string.other_always_on_vpn)) showAlwaysOnConflict = true } else { @@ -88,10 +85,13 @@ internal fun ConnectionTestScreen(activity: Activity, autoStart: Boolean, onBack } } } - val runTest = { + val runTest = runTest@{ + if (TestDiagnosticLog.state.value == TestState.RUNNING) return@runTest val configStore = ConfigStore(activity) - val error = configStore.globalConnectionSettings().applyTo(configStore.activeProfile().config) - .connectionValidationError()?.let { activity.uiText(it) } + // A live VPN is tested with its actual runtime configuration in the service. + val error = if (ProxyVpnService.isRunning) null else + configStore.globalConnectionSettings().applyTo(configStore.activeProfile().config) + .connectionValidationError()?.let { activity.uiText(it) } if (error != null) { TestDiagnosticLog.fail(error) } else { @@ -105,7 +105,6 @@ internal fun ConnectionTestScreen(activity: Activity, autoStart: Boolean, onBack if (intent == null) { ProxyVpnService.test(activity) } else { - vpnPermissionRequestedAt = System.currentTimeMillis() permission.launch(intent) } } @@ -115,8 +114,7 @@ internal fun ConnectionTestScreen(activity: Activity, autoStart: Boolean, onBack LaunchedEffect(autoStart) { if (autoStart && !autoStartConsumed) { autoStartConsumed = true - TestDiagnosticLog.reset() - runTest() + if (shouldAutoStartConnectionTest(state)) runTest() } } @@ -209,43 +207,4 @@ internal fun ConnectionTestScreen(activity: Activity, autoStart: Boolean, onBack }, ) } - pendingHostKey?.takeIf { it.testOnly }?.let { pending -> - AlertDialog( - onDismissRequest = { - SshHostKeyPromptState.clear() - ProxyVpnService.dismissHostKeyPrompt(activity) - }, - title = { DialogTitle(stringResource(if (pending.changed) R.string.ssh_host_key_changed else R.string.trust_ssh_host_key)) }, - text = { ScrollableDialogText(buildString { - if (pending.changed) { - append(activity.uiText(R.string.ssh_changed_key_warning, activity.sshHopLabel(pending.hop))) - } else { - append(activity.uiText(R.string.ssh_first_connection_warning, activity.sshHopLabel(pending.hop))) - } - append(activity.uiText(R.string.ssh_key_details, pending.algorithm, pending.fingerprint)) - }) }, - confirmButton = { - TextButton(shape = RoundedCornerShape(12.dp), onClick = { - val saved = ConfigStore(activity).trustSshHostKey( - pending.profileId, pending.hop, pending.fingerprint, - ) - SshHostKeyPromptState.clear() - val persisted = ConfigStore(activity).profile(pending.profileId)?.config?.let { config -> - if (pending.hop == "jump") config.jumpTrustedHostKey else config.trustedHostKey - } - if (saved && persisted == pending.fingerprint) { - ProxyVpnService.test(activity) - } else { - TestDiagnosticLog.fail(activity.uiText(R.string.test_key_save_failed)) - } - }) { Text(stringResource(if (pending.changed) R.string.replace_and_test else R.string.trust_and_test)) } - }, - dismissButton = { - TextButton(shape = RoundedCornerShape(12.dp), onClick = { - SshHostKeyPromptState.clear() - ProxyVpnService.dismissHostKeyPrompt(activity) - }) { Text(stringResource(R.string.cancel)) } - }, - ) - } } diff --git a/app/src/main/java/net/megaproxy487/ConnectionUiContracts.kt b/app/src/main/java/net/megaproxy487/ConnectionUiContracts.kt new file mode 100644 index 0000000..69dc0fe --- /dev/null +++ b/app/src/main/java/net/megaproxy487/ConnectionUiContracts.kt @@ -0,0 +1,17 @@ +package net.megaproxy487 + +import net.megaproxy487.data.ConfigWriteStatus +import net.megaproxy487.vpn.TestState +import net.megaproxy487.vpn.VpnConnectionState + +/** Persistence only gates starting a connection; stopping must remain available. */ +internal fun connectionActionEnabled( + connection: VpnConnectionState, + alwaysOn: Boolean, + writes: ConfigWriteStatus, + validProfile: Boolean, +): Boolean = !alwaysOn && (connection != VpnConnectionState.DISCONNECTED || + (writes.pending == 0 && !writes.failed && validProfile)) + +/** Returning to an in-flight diagnostic attaches to it instead of resetting its log. */ +internal fun shouldAutoStartConnectionTest(state: TestState): Boolean = state != TestState.RUNNING diff --git a/app/src/main/java/net/megaproxy487/DiagnosticLogScreen.kt b/app/src/main/java/net/megaproxy487/DiagnosticLogScreen.kt index 6595487..66f67bd 100644 --- a/app/src/main/java/net/megaproxy487/DiagnosticLogScreen.kt +++ b/app/src/main/java/net/megaproxy487/DiagnosticLogScreen.kt @@ -75,6 +75,7 @@ internal fun DiagnosticLogScreen(activity: Activity, onBack: () -> Unit) { val lifecycleOwner = LocalLifecycleOwner.current val scope = rememberCoroutineScope() var lines by remember { mutableStateOf(emptyList()) } + var readFailed by remember { mutableStateOf(false) } var autoScroll by remember { mutableStateOf(true) } var limitText by rememberSaveable { mutableStateOf(store.diagnosticLogLimitMb().toString()) } var showClearConfirmation by remember { mutableStateOf(false) } @@ -102,9 +103,19 @@ internal fun DiagnosticLogScreen(activity: Activity, onBack: () -> Unit) { LaunchedEffect(lifecycleOwner) { lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { + var seenRevision = -1L while (true) { - val content = withContext(Dispatchers.IO) { PersistentDiagnosticLog.readTail(viewerWindowBytes) } - lines = content.lineSequence().filter(String::isNotEmpty).toList() + val revision = PersistentDiagnosticLog.revision + if (revision != seenRevision) { + val snapshot = withContext(Dispatchers.IO) { + readDiagnosticSnapshot { PersistentDiagnosticLog.readTail(viewerWindowBytes) } + } + readFailed = snapshot == null + if (snapshot != null) { + lines = snapshot + seenRevision = revision + } + } delay(1_000) } } @@ -166,6 +177,11 @@ internal fun DiagnosticLogScreen(activity: Activity, onBack: () -> Unit) { style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) + if (readFailed) Text( + stringResource(R.string.log_read_failed), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) LazyColumn( state = listState, modifier = Modifier.fillMaxWidth().heightIn(min = 160.dp, max = logHeight), diff --git a/app/src/main/java/net/megaproxy487/DiagnosticSnapshot.kt b/app/src/main/java/net/megaproxy487/DiagnosticSnapshot.kt new file mode 100644 index 0000000..ea2647f --- /dev/null +++ b/app/src/main/java/net/megaproxy487/DiagnosticSnapshot.kt @@ -0,0 +1,12 @@ +package net.megaproxy487 + +import java.io.IOException + +/** A temporary storage failure keeps the last snapshot visible and allows the next poll to retry. */ +internal fun readDiagnosticSnapshot(read: () -> String): List? = try { + read().lineSequence().filter(String::isNotEmpty).toList() +} catch (_: IOException) { + null +} catch (_: SecurityException) { + null +} diff --git a/app/src/main/java/net/megaproxy487/MainActivity.kt b/app/src/main/java/net/megaproxy487/MainActivity.kt index 607feb9..aa22b72 100644 --- a/app/src/main/java/net/megaproxy487/MainActivity.kt +++ b/app/src/main/java/net/megaproxy487/MainActivity.kt @@ -100,7 +100,6 @@ import net.megaproxy487.data.ConfigStore import net.megaproxy487.data.ConfigIoDispatcher import net.megaproxy487.vpn.ProxyVpnService import net.megaproxy487.vpn.SshHostKeyPromptState -import net.megaproxy487.vpn.PendingSshHostKey import net.megaproxy487.vpn.VpnConnectionState import net.megaproxy487.vpn.VpnRuntimeState import net.megaproxy487.vpn.VpnTransportProtocol @@ -116,7 +115,6 @@ import net.megaproxy487.ui.theme.MegaProxyTheme class MainActivity : LocalizedActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - restoreHostKeyPrompt(intent) enableEdgeToEdge() BatteryOptimizationReminder.maybeRequest(this) setContent { MegaProxyTheme { MegaProxyNavHost(this) } } @@ -125,21 +123,6 @@ class MainActivity : LocalizedActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) - restoreHostKeyPrompt(intent) - } - - private fun restoreHostKeyPrompt(intent: Intent?) { - if (intent?.action != ACTION_REVIEW_SSH_HOST_KEY) return - SshHostKeyPromptState.show( - PendingSshHostKey( - profileId = intent.getStringExtra(EXTRA_PROFILE_ID).orEmpty(), - hop = intent.getStringExtra(EXTRA_HOP).orEmpty(), - algorithm = intent.getStringExtra(EXTRA_ALGORITHM).orEmpty(), - fingerprint = intent.getStringExtra(EXTRA_FINGERPRINT).orEmpty(), - changed = intent.getBooleanExtra(EXTRA_CHANGED, false), - testOnly = intent.getBooleanExtra(EXTRA_TEST_ONLY, false), - ), - ) } override fun onResume() { @@ -147,7 +130,7 @@ class MainActivity : LocalizedActivity() { ProxyVpnService.refreshStatus(this) val status = readAlwaysOnVpnStatus(this) val store = ConfigStore(this) - val profileId = if (status.enabled) store.alwaysOnProfileId() else store.connectionProfile().id + val profileId = if (status.enabled && !ProxyVpnService.isRunning && !store.isFailoverActive()) store.alwaysOnProfileId() else store.connectionProfileId() VpnRuntimeState.updateSystem(status.enabled, status.lockdown, profileId) } @@ -244,6 +227,7 @@ internal fun MainScreen( onOpenSettings: () -> Unit, onOpenConnectionTest: () -> Unit, onEditProfile: (String) -> Unit, + readConnectionStats: () -> NativeConnectionStats? = ConnectionStatsReader::snapshot, ) { val connection by VpnRuntimeState.connection val runtimeAlwaysOn by VpnRuntimeState.alwaysOn @@ -251,7 +235,6 @@ internal fun MainScreen( val runtimeProfileId by VpnRuntimeState.connectionProfileId val networkWarning by VpnRuntimeState.networkWarning val transportProtocol by VpnRuntimeState.transportProtocol - val pendingHostKey by SshHostKeyPromptState.pending val store = remember { ConfigStore(activity) } val writeStatus by ConfigWrites.status.collectAsState() var error by remember { mutableStateOf(null) } @@ -259,10 +242,9 @@ internal fun MainScreen( var profileMenuExpanded by remember { mutableStateOf(false) } var profiles by remember { mutableStateOf(store.sortedProfiles()) } var activeProfileId by remember { mutableStateOf(store.activeProfileId()) } - var connectionProfileId by remember { mutableStateOf(store.connectionProfile().id) } + var connectionProfileId by remember { mutableStateOf(store.connectionProfileId()) } var connectionStats by remember { mutableStateOf(null) } var systemVpnStatus by remember { mutableStateOf(readAlwaysOnVpnStatus(activity)) } - var vpnPermissionRequestedAt by remember { mutableStateOf(0L) } var showCrashReport by remember { mutableStateOf(CrashHandler.hasPendingReport()) } var showAlwaysOnConflict by remember { mutableStateOf(false) } var pendingReconnect by remember { mutableStateOf(store.hasPendingReconnect()) } @@ -281,7 +263,7 @@ internal fun MainScreen( status = status, profiles = store.sortedProfiles(), activeProfileId = store.activeProfileId(), - connectionProfileId = if (status.enabled) store.alwaysOnProfileId() else store.connectionProfile().id, + connectionProfileId = if (status.enabled && !ProxyVpnService.isRunning && !store.isFailoverActive()) store.alwaysOnProfileId() else store.connectionProfileId(), pendingReconnect = store.hasPendingReconnect(), globalSettings = store.globalConnectionSettings(), ) @@ -315,24 +297,27 @@ internal fun MainScreen( } lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { var previous: NativeConnectionStats? = null + var previousAt = 0L var smoothedDownload = 0.0 var smoothedUpload = 0.0 while (true) { - // JNI reflection and JSON decoding are small but not frame work. Some + // JNI calls and JSON decoding are small but not frame work. Some // vendor devices expose their cost as visible input latency, so sample // away from the main dispatcher. val snapshot = withContext(Dispatchers.Default) { - ConnectionStatsReader.snapshot() + readConnectionStats() } + val sampledAt = android.os.SystemClock.elapsedRealtime() if (snapshot != null) { previous?.let { old -> - val download = (snapshot.downloadBytes - old.downloadBytes).coerceAtLeast(0).toDouble() - val upload = (snapshot.uploadBytes - old.uploadBytes).coerceAtLeast(0).toDouble() + val download = sampledTrafficRate(snapshot.downloadBytes, old.downloadBytes, sampledAt - previousAt) + val upload = sampledTrafficRate(snapshot.uploadBytes, old.uploadBytes, sampledAt - previousAt) val alpha = 0.35 smoothedDownload = if (smoothedDownload == 0.0) download else alpha * download + (1 - alpha) * smoothedDownload smoothedUpload = if (smoothedUpload == 0.0) upload else alpha * upload + (1 - alpha) * smoothedUpload } previous = snapshot + previousAt = sampledAt connectionStats = DisplayedConnectionStats(snapshot, smoothedDownload, smoothedUpload) } delay(1_000) @@ -344,8 +329,7 @@ internal fun MainScreen( if (!isAlwaysOnVpnActive(activity)) ProxyVpnService.start(activity) else error = null } else { val status = readAlwaysOnVpnStatus(activity) - val dismissedImmediately = System.currentTimeMillis() - vpnPermissionRequestedAt < 1_000 - if (status.hasOtherProvider || dismissedImmediately) { + if (status.hasOtherProvider) { error = null showAlwaysOnConflict = true } else { @@ -363,7 +347,6 @@ internal fun MainScreen( if (intent == null) { ProxyVpnService.start(activity) } else { - vpnPermissionRequestedAt = System.currentTimeMillis() vpnPermission.launch(intent) } } @@ -403,6 +386,7 @@ internal fun MainScreen( DropdownMenu(actionsMenuExpanded, { actionsMenuExpanded = false }) { DropdownMenuItem( text = { Text(stringResource(R.string.test_connection)) }, + enabled = connection != VpnConnectionState.CONNECTING, onClick = { actionsMenuExpanded = false; onOpenConnectionTest() }, ) DropdownMenuItem( @@ -603,8 +587,7 @@ internal fun MainScreen( connect() } }, - enabled = !alwaysOn && writeStatus.pending == 0 && !writeStatus.failed && - (connection != VpnConnectionState.DISCONNECTED || activeProfileError == null), + enabled = connectionActionEnabled(connection, alwaysOn, writeStatus, activeProfileError == null), modifier = Modifier.fillMaxWidth(), ) { Text( @@ -716,37 +699,7 @@ internal fun MainScreen( ) } - pendingHostKey?.takeIf { !it.testOnly }?.let { pending -> - fun dismissHostKeyPrompt() { - SshHostKeyPromptState.clear() - ProxyVpnService.dismissHostKeyPrompt(activity) - } - AlertDialog( - onDismissRequest = ::dismissHostKeyPrompt, - title = { DialogTitle(stringResource(if (pending.changed) R.string.ssh_host_key_changed else R.string.trust_ssh_host_key)) }, - text = { ScrollableDialogText(buildString { - if (pending.changed) { - append(activity.uiText(R.string.ssh_changed_key_warning, activity.sshHopLabel(pending.hop))) - } else { - append(activity.uiText(R.string.ssh_first_connection_warning, activity.sshHopLabel(pending.hop))) - } - append(activity.uiText(R.string.ssh_key_details, pending.algorithm, pending.fingerprint)) - }) }, - confirmButton = { - TextButton(shape = RoundedCornerShape(12.dp), onClick = { - if (store.trustSshHostKey(pending.profileId, pending.hop, pending.fingerprint)) { - SshHostKeyPromptState.clear() - ProxyVpnService.reconnect(activity) - } else { - error = activity.uiText(R.string.ssh_key_save_failed) - } - }) { Text(stringResource(if (pending.changed) R.string.replace_trusted_key else R.string.trust_and_connect)) } - }, - dismissButton = { - TextButton(shape = RoundedCornerShape(12.dp), onClick = ::dismissHostKeyPrompt) { Text(stringResource(R.string.cancel)) } - }, - ) - } + } private fun isAlwaysOnVpnActive(activity: Activity): Boolean = diff --git a/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt b/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt index 3fb7920..104e4cb 100644 --- a/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt +++ b/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt @@ -87,6 +87,7 @@ import kotlinx.coroutines.withContext private class ProfileEditorState(initialProfile: net.megaproxy487.model.ProxyProfile) : ViewModel() { var profile by mutableStateOf(initialProfile) + var persisted by mutableStateOf(false) var config by mutableStateOf(profile.config) var portText by mutableStateOf(config.port.toString()) var jumpPortText by mutableStateOf(config.jumpPort.toString()) @@ -113,10 +114,10 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: } val draftId = rememberSaveable { java.util.UUID.randomUUID().toString() } val isNew = profileId == "new" - val editorState = viewModel { ProfileEditorState( - if (isNew) store.profile(draftId) ?: store.newProfileDraft(draftId) - else store.profile(profileId.orEmpty()) ?: store.activeProfile(), - ) } + val editorState = viewModel { + val existing = if (isNew) store.profile(draftId) else store.profile(profileId.orEmpty()) ?: store.activeProfile() + ProfileEditorState(existing ?: store.newProfileDraft(draftId)).also { it.persisted = existing != null } + } val coroutineScope = editorState.viewModelScope var profile by editorState::profile var config by editorState::config @@ -139,14 +140,22 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: }.sortedBy { it.second.lowercase(systemFormattingLocale()) } } - val globalSettings = store.globalConnectionSettings() - val editedConnectionProfileId = store.connectionProfile().id + val writeStatus by ConfigWrites.status.collectAsState() + var globalSettings by remember(store) { mutableStateOf(store.globalConnectionSettings()) } + androidx.compose.runtime.LaunchedEffect(writeStatus) { + if (writeStatus.pending == 0) { + globalSettings = withContext(net.megaproxy487.data.ConfigIoDispatcher) { store.globalConnectionSettings() } + } + } + val editedConnectionProfileId = net.megaproxy487.vpn.VpnRuntimeState.connectionProfileId.value + .ifEmpty { store.connectionProfileId() } val alwaysOnActive = ProxyVpnService.isAlwaysOnMode || readAlwaysOnVpnStatus(activity).enabled fun saveProfile(affectsConnection: Boolean = false) { val snapshot = profile ConfigWrites.submit("profile:${snapshot.id}") { store.saveProfile(snapshot, createIfMissing = isNew) - if (affectsConnection && ProxyVpnService.isRunning && snapshot.id == store.connectionProfile().id) store.markPendingReconnect() + editorState.persisted = true + if (affectsConnection && ProxyVpnService.isRunning && snapshot.id == store.connectionProfileId()) store.markPendingReconnect() } } fun acceptText(value: String, maxLength: Int, update: (String) -> Unit) { @@ -198,7 +207,6 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: fun fieldError(vararg ids: Int): String? = validationRes?.takeIf { it in ids }?.let { activity.uiText(it) } val validPorts = validIntegerInput(portText, 1..65535) != null && (!config.type.hasJump || validIntegerInput(jumpPortText, 1..65535) != null) - val writeStatus by ConfigWrites.status.collectAsState() val canReconnect = validationRes == null && validPorts && writeStatus.pending == 0 && !writeStatus.failed Scaffold( @@ -461,7 +469,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: if (config.dnsProvider == DnsProvider.CUSTOM) item { OutlinedTextField(config.customDohUrl, { value -> acceptText(value, 2_048) { updateConfig(config.copy(customDohUrl = it)) } }, label = { FieldLabel(stringResource(R.string.custom_doh_url)) }, isError = fieldError(R.string.validation_doh_url) != null, supportingText = fieldError(R.string.validation_doh_url)?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) } item { - Text(stringResource(if (isNew && store.profile(profile.id) == null) R.string.draft_profile_hint else R.string.changes_saved_automatically), style = MaterialTheme.typography.bodySmall) + Text(stringResource(if (isNew && !editorState.persisted) R.string.draft_profile_hint else R.string.changes_saved_automatically), style = MaterialTheme.typography.bodySmall) } } } diff --git a/app/src/main/java/net/megaproxy487/ProfileTransferContracts.kt b/app/src/main/java/net/megaproxy487/ProfileTransferContracts.kt index d816822..d8be6fc 100644 --- a/app/src/main/java/net/megaproxy487/ProfileTransferContracts.kt +++ b/app/src/main/java/net/megaproxy487/ProfileTransferContracts.kt @@ -18,7 +18,7 @@ internal fun parseProfileImport(text: String, mimeType: String?, fileName: Strin val json = mimeType == "application/json" || fileName.substringAfterLast('.', "").equals("json", true) || text.trimStart().startsWith('{') if (json) { - val root = org.json.JSONObject(text) + val root = boundedJsonObject(text) return if (ConfigTransfer.isSupportedSchema(root.optString("schema"))) { ParsedProfileImport.Configuration(ConfigTransfer.importJson(text)) } else ParsedProfileImport.ProxyList(FoxyProxyParser.parse(text).getOrThrow(), R.string.imported_foxyproxy) diff --git a/app/src/main/java/net/megaproxy487/SettingsScreens.kt b/app/src/main/java/net/megaproxy487/SettingsScreens.kt index 43be032..ff8215d 100644 --- a/app/src/main/java/net/megaproxy487/SettingsScreens.kt +++ b/app/src/main/java/net/megaproxy487/SettingsScreens.kt @@ -172,6 +172,19 @@ internal fun SettingsHomeScreen(activity: Activity, onBack: () -> Unit, onNaviga }.onFailure { supportError = activity.uiText(R.string.no_browser) } } supportError?.let { Text(it, color = MaterialTheme.colorScheme.error) } + TextButton( + onClick = { + try { + activity.startActivity(Intent(Intent.ACTION_VIEW, + Uri.parse("https://github.com/andre487/AndroidMegaProxy/blob/main/PRIVACY.md"))) + } catch (_: Exception) { + supportError = activity.uiText(R.string.no_browser) + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.privacy_policy)) + } } if (showLanguageDialog) { @@ -253,6 +266,7 @@ internal fun SettingsHomeScreen(activity: Activity, onBack: () -> Unit, onNaviga @Composable internal fun FailoverSettingsScreen(activity: Activity, onBack: () -> Unit) { val store = remember { ConfigStore(activity) } + val profiles = remember(store) { store.sortedProfiles() } val scope = rememberCoroutineScope() var settings by remember { mutableStateOf(store.globalConnectionSettings()) } var expanded by remember { mutableStateOf(false) } @@ -277,7 +291,7 @@ internal fun FailoverSettingsScreen(activity: Activity, onBack: () -> Unit) { if (settings.failoverMode == FailoverMode.SELECTED) { Text(stringResource(R.string.fallback_profiles), style = MaterialTheme.typography.titleMedium) Text(stringResource(R.string.fallback_profiles_order), style = MaterialTheme.typography.bodySmall) - store.sortedProfiles().forEach { profile -> + profiles.forEach { profile -> val checked = profile.id in settings.failoverProfileIds Row( Modifier.fillMaxWidth().heightIn(min = 56.dp).toggleable( @@ -294,7 +308,7 @@ internal fun FailoverSettingsScreen(activity: Activity, onBack: () -> Unit) { Text(profile.localizedNameWithFlag(activity), modifier = Modifier.weight(1f)) } } - val usableFallbacks = store.sortedProfiles().count { it.id in settings.failoverProfileIds } + val usableFallbacks = profiles.count { it.id in settings.failoverProfileIds } if (usableFallbacks < 2) { Text( stringResource(R.string.failover_profiles_warning), @@ -328,6 +342,7 @@ internal fun FailoverSettingsScreen(activity: Activity, onBack: () -> Unit) { @Composable internal fun AlwaysOnSettingsScreen(activity: Activity, onBack: () -> Unit) { val store = remember { ConfigStore(activity) } + val profiles = remember(store) { store.sortedProfiles() } val scope = rememberCoroutineScope() var expanded by remember { mutableStateOf(false) } var selected by remember { mutableStateOf(store.alwaysOnProfile()) } @@ -341,7 +356,7 @@ internal fun AlwaysOnSettingsScreen(activity: Activity, onBack: () -> Unit) { modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), ) DropdownMenu(expanded, { expanded = false }) { - store.sortedProfiles().forEach { profile -> + profiles.forEach { profile -> DropdownMenuItem(text = { Text(profile.localizedNameWithFlag(activity)) }, onClick = { selected = profile ConfigWrites.submit("always-on") { store.setAlwaysOnProfile(profile.id) } diff --git a/app/src/main/java/net/megaproxy487/SshHostKeyReviewActivity.kt b/app/src/main/java/net/megaproxy487/SshHostKeyReviewActivity.kt new file mode 100644 index 0000000..ad09ace --- /dev/null +++ b/app/src/main/java/net/megaproxy487/SshHostKeyReviewActivity.kt @@ -0,0 +1,28 @@ +package net.megaproxy487 + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import net.megaproxy487.vpn.PendingSshHostKey +import net.megaproxy487.vpn.SshHostKeyPromptState + +/** Only the app's immutable notification PendingIntent may restore a key challenge. */ +class SshHostKeyReviewActivity : Activity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (intent?.action == MainActivity.ACTION_REVIEW_SSH_HOST_KEY) { + SshHostKeyPromptState.show(PendingSshHostKey( + profileId = intent.getStringExtra(MainActivity.EXTRA_PROFILE_ID).orEmpty(), + hop = intent.getStringExtra(MainActivity.EXTRA_HOP).orEmpty(), + algorithm = intent.getStringExtra(MainActivity.EXTRA_ALGORITHM).orEmpty(), + fingerprint = intent.getStringExtra(MainActivity.EXTRA_FINGERPRINT).orEmpty(), + changed = intent.getBooleanExtra(MainActivity.EXTRA_CHANGED, false), + testOnly = intent.getBooleanExtra(MainActivity.EXTRA_TEST_ONLY, false), + )) + } + startActivity(Intent(this, MainActivity::class.java).addFlags( + Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP, + )) + finish() + } +} diff --git a/app/src/main/java/net/megaproxy487/SshHostKeyScreen.kt b/app/src/main/java/net/megaproxy487/SshHostKeyScreen.kt index 0006b1f..1866822 100644 --- a/app/src/main/java/net/megaproxy487/SshHostKeyScreen.kt +++ b/app/src/main/java/net/megaproxy487/SshHostKeyScreen.kt @@ -1,41 +1,106 @@ package net.megaproxy487 -import androidx.compose.ui.unit.dp - -import androidx.compose.foundation.shape.RoundedCornerShape - import android.app.Activity -import androidx.compose.runtime.Composable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import net.megaproxy487.uiStringResource as stringResource +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.unit.dp +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.compose.viewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.megaproxy487.data.ConfigIoDispatcher import net.megaproxy487.data.ConfigStore import net.megaproxy487.vpn.PendingSshHostKey import net.megaproxy487.vpn.ProxyVpnService +import net.megaproxy487.uiStringResource as stringResource + +private class HostKeyReviewState : ViewModel() { + var saving by mutableStateOf(false) + var failed by mutableStateOf(false) + var completed by mutableStateOf(false) +} @Composable internal fun SshHostKeyScreen(activity: Activity, prompt: PendingSshHostKey, onDismiss: () -> Unit) { + val app = activity.applicationContext + SshHostKeyReview( + prompt = prompt, + saveAndResume = { + val saved = withContext(ConfigIoDispatcher) { + ConfigStore(app).trustSshHostKey(prompt.profileId, prompt.hop, prompt.fingerprint) + } + if (saved) { + if (prompt.testOnly) ProxyVpnService.test(app) else ProxyVpnService.reconnect(app) + } + saved + }, + onReject = { if (prompt.testOnly) ProxyVpnService.dismissHostKeyPrompt(app) }, + onDismiss = onDismiss, + ) +} + +/** The actual dialog and save state machine; platform side effects belong to the route above. */ +@Composable +internal fun SshHostKeyReview( + prompt: PendingSshHostKey, + saveAndResume: suspend () -> Boolean, + onReject: () -> Unit, + onDismiss: () -> Unit, +) { + val context = androidx.compose.ui.platform.LocalContext.current + val state = viewModel(key = "${prompt.profileId}:${prompt.hop}:${prompt.fingerprint}") { + HostKeyReviewState() + } + LaunchedEffect(state.completed) { if (state.completed) onDismiss() } fun reject() { - // A normal/Always-on connection remains paused with an actionable persistent - // notification. Tests are disposable and may release their temporary service. - if (prompt.testOnly) ProxyVpnService.dismissHostKeyPrompt(activity) + if (state.saving) return + // Regular connections remain paused; temporary diagnostics may release their service. + onReject() onDismiss() } + androidx.activity.compose.BackHandler { reject() } AlertDialog( onDismissRequest = ::reject, title = { DialogTitle(stringResource(if (prompt.changed) R.string.ssh_host_key_changed else R.string.trust_ssh_host_key)) }, - text = { ScrollableDialogText(buildString { - append(activity.uiText(if (prompt.changed) R.string.ssh_changed_key_warning else R.string.ssh_first_connection_warning, activity.sshHopLabel(prompt.hop))) - append(activity.uiText(R.string.ssh_key_details, prompt.algorithm, prompt.fingerprint)) - }) }, - confirmButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = { - if (ConfigStore(activity).trustSshHostKey(prompt.profileId, prompt.hop, prompt.fingerprint)) { - if (prompt.testOnly) ProxyVpnService.test(activity) else ProxyVpnService.reconnect(activity) + text = { + Column { + ScrollableDialogText(buildString { + append(context.uiText(if (prompt.changed) R.string.ssh_changed_key_warning else R.string.ssh_first_connection_warning, context.sshHopLabel(prompt.hop))) + append(context.uiText(R.string.ssh_key_details, prompt.algorithm, prompt.fingerprint)) + }) + if (state.saving) Text(stringResource(R.string.saving_changes)) + if (state.failed) Text(stringResource(R.string.ssh_key_save_failed), color = MaterialTheme.colorScheme.error) + } + }, + confirmButton = { TextButton(shape = RoundedCornerShape(12.dp), enabled = !state.saving, onClick = { + state.saving = true + state.failed = false + state.viewModelScope.launch { + try { + if (saveAndResume()) state.completed = true else state.failed = true + } catch (cancelled: CancellationException) { + throw cancelled + } catch (_: Exception) { + state.failed = true + } finally { state.saving = false } } - onDismiss() - }) { Text(stringResource(if (prompt.changed) R.string.replace_trusted_key else R.string.trust_and_connect)) } }, - dismissButton = { TextButton(shape = RoundedCornerShape(12.dp), onClick = ::reject) { Text(stringResource(R.string.cancel)) } }, + }) { Text(stringResource(when { + prompt.testOnly && prompt.changed -> R.string.replace_and_test + prompt.testOnly -> R.string.trust_and_test + prompt.changed -> R.string.replace_trusted_key + else -> R.string.trust_and_connect + })) } }, + dismissButton = { TextButton(shape = RoundedCornerShape(12.dp), enabled = !state.saving, onClick = ::reject) { Text(stringResource(R.string.cancel)) } }, ) } diff --git a/app/src/main/java/net/megaproxy487/TrafficFormatting.kt b/app/src/main/java/net/megaproxy487/TrafficFormatting.kt index 7c0d409..14c8432 100644 --- a/app/src/main/java/net/megaproxy487/TrafficFormatting.kt +++ b/app/src/main/java/net/megaproxy487/TrafficFormatting.kt @@ -36,3 +36,8 @@ internal fun formatTrafficRate( unitSystem: TrafficUnitSystem = TrafficUnitSystem.IEC, locale: Locale = Locale.getDefault(), ): String = "${formatTrafficBytes(bytesPerSecond.coerceAtLeast(0.0).toLong(), unitSystem, locale)}/s" + +/** Scheduler delays and slow JNI sampling must not inflate bytes per second. */ +internal fun sampledTrafficRate(currentBytes: Long, previousBytes: Long, elapsedMillis: Long): Double = + if (elapsedMillis <= 0 || currentBytes < previousBytes) 0.0 + else (currentBytes - previousBytes).toDouble() * 1000.0 / elapsedMillis diff --git a/app/src/main/java/net/megaproxy487/data/BoundedJson.kt b/app/src/main/java/net/megaproxy487/data/BoundedJson.kt new file mode 100644 index 0000000..6ad3a96 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/data/BoundedJson.kt @@ -0,0 +1,39 @@ +package net.megaproxy487.data + +import net.megaproxy487.R +import net.megaproxy487.UiException +import net.megaproxy487.requireUi +import org.json.JSONObject + +/** Bound allocations and recursive parser depth before org.json builds its object tree. */ +internal fun boundedJsonObject(text: String): JSONObject { + requireUi(text.length <= MAX_CONFIG_FILE_BYTES) { UiException(R.string.error_config_large) } + var depth = 0 + var tokens = 0 + var quoted = false + var escaped = false + for (character in text) { + if (quoted) { + if (escaped) escaped = false + else if (character == '\\') escaped = true + else if (character == '"') quoted = false + } else { + when (character) { + '"' -> quoted = true + '[', '{' -> { + depth++ + tokens++ + } + ']', '}' -> depth-- + ',', ':' -> tokens++ + // Android's lenient parser also accepts comments and single quotes. + // Reject those extensions so they cannot bypass this JSON guard. + '\'', '/', '#', ';', '=' -> throw UiException(R.string.error_invalid_input) + } + requireUi(depth in 0..32 && tokens <= 250_000) { + UiException(R.string.error_config_complex) + } + } + } + return JSONObject(text) +} diff --git a/app/src/main/java/net/megaproxy487/data/ConfigStore.kt b/app/src/main/java/net/megaproxy487/data/ConfigStore.kt index 978224c..7515758 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigStore.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigStore.kt @@ -150,7 +150,7 @@ class ConfigStore(context: Context) { if (stored != null) { migrateGlobalIpv6ToProfiles(stored) val decoded = decodeGlobalConnectionSettings(stored) - if (!runCatching { JSONObject(stored).has("sshProfile") }.getOrDefault(false)) { + if (!operationResult { JSONObject(stored).has("sshProfile") }.getOrDefault(false)) { val upgraded = decoded.copy(sshProfile = activeProfile().config.sshProfile) saveGlobalConnectionSettings(upgraded) return upgraded @@ -174,7 +174,7 @@ class ConfigStore(context: Context) { @Synchronized private fun migrateGlobalIpv6ToProfiles(storedSettings: String) { if (prefs.getBoolean(IPV6_PROFILE_MIGRATED, false)) return - val enabled = runCatching { JSONObject(storedSettings).optBoolean("allowIpv6", false) }.getOrDefault(false) + val enabled = operationResult { JSONObject(storedSettings).optBoolean("allowIpv6", false) }.getOrDefault(false) writeProfiles(profiles().map { it.copy(config = it.config.copy(allowIpv6 = enabled)) }) prefs.edit().putBoolean(IPV6_PROFILE_MIGRATED, true).apply() } @@ -285,9 +285,7 @@ class ConfigStore(context: Context) { resolved } val missing = existing.filter { it.id !in importedById } - writeProfiles(existing.map { importedById[it.id]?.let { mergedProfile -> - merged.first { profile -> profile.id == mergedProfile.id } - } ?: it } + added) + writeProfiles(mergeResolvedProfiles(existing, merged, added)) val editor = prefs.edit() .putInt(DIAGNOSTIC_LOG_LIMIT_MB, configuration.diagnosticLogLimitMb) configuration.activeProfileId?.takeIf(importedById::containsKey)?.let { editor.putString(ACTIVE_PROFILE_ID, it) } @@ -436,7 +434,7 @@ class ConfigStore(context: Context) { ) private inline fun > enumValue(value: String?, default: T): T = - runCatching { enumValueOf(value ?: default.name) }.getOrDefault(default) + operationResult { enumValueOf(value ?: default.name) }.getOrDefault(default) private fun nextColorIndex(profiles: List): Int { val counts = IntArray(ProfileColors.argb.size) @@ -490,7 +488,7 @@ class ConfigStore(context: Context) { put("bypassLocalNetworks", config.bypassLocalNetworks) } - private fun decodeProfiles(value: String?): List = runCatching { + private fun decodeProfiles(value: String?): List = operationResult { val array = JSONArray(value ?: return emptyList()) List(array.length()) { index -> val item = array.getJSONObject(index) @@ -552,7 +550,7 @@ class ConfigStore(context: Context) { put("bypassLocalNetworks", settings.bypassLocalNetworks) }.toString() - private fun decodeGlobalConnectionSettings(value: String): GlobalConnectionSettings = runCatching { + private fun decodeGlobalConnectionSettings(value: String): GlobalConnectionSettings = operationResult { val item = JSONObject(value) val parsedTls = enumValue(item.optString("fingerprint"), TlsProfile.DEFAULT) GlobalConnectionSettings( @@ -576,16 +574,20 @@ class ConfigStore(context: Context) { ) }.getOrDefault(GlobalConnectionSettings()) + private var cachedKey: SecretKey? = null + + @Synchronized private fun key(): SecretKey { + cachedKey?.let { return it } val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) } - (store.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } + (store.getKey(KEY_ALIAS, null) as? SecretKey)?.let { cachedKey = it; return it } return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run { init(KeyGenParameterSpec.Builder(KEY_ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT) .setBlockModes(KeyProperties.BLOCK_MODE_GCM) .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) .build()) generateKey() - } + }.also { cachedKey = it } } private fun encrypt(plain: String): String { @@ -594,7 +596,7 @@ class ConfigStore(context: Context) { return Base64.encodeToString(cipher.iv + cipher.doFinal(plain.toByteArray(Charsets.UTF_8)), Base64.NO_WRAP) } - private fun decrypt(packed: String?): String = runCatching { + private fun decrypt(packed: String?): String = operationResult { if (packed == null) return "" val bytes = Base64.decode(packed, Base64.NO_WRAP) require(bytes.size > IV_SIZE) diff --git a/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt b/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt index 22322f3..97b35ec 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigTransfer.kt @@ -99,7 +99,7 @@ object ConfigTransfer { }.toString(2) fun importJson(text: String): PortableConfiguration { - val root = JSONObject(text) + val root = boundedJsonObject(text) requireUi(isSupportedSchema(root.optString("schema"))) { UiException(R.string.error_config_invalid) } val version = root.optInt("version", 0) requireUi(version in 1..SCHEMA_VERSION) { UiException(R.string.error_config_version, version) } @@ -109,7 +109,7 @@ object ConfigTransfer { array.optJSONObject(index)?.optString("id")?.let { it.isNotBlank() && it.length <= 256 } == true }) { UiException(R.string.error_config_stable_ids) } val decoded = (0 until array.length()).map { index -> - runCatching { decodeProfile(array.getJSONObject(index), index) } + operationResult { decodeProfile(array.getJSONObject(index), index) } } val decodedProfiles = decoded.mapNotNull(Result::getOrNull) requireUi(decodedProfiles.isNotEmpty()) { UiException(R.string.error_config_no_usable) } @@ -252,7 +252,7 @@ object ConfigTransfer { config = ProxyConfig( type = type, host = host, - port = proxy.optInt("port", 443).takeIf { it in 1..65535 } ?: 443, + port = proxy.optInt("port", type.defaultPort).takeIf { it in 1..65535 } ?: type.defaultPort, username = proxy.limitedString("username", 4_096), password = proxy.limitedString("password", 16_384), privateKey = proxy.limitedString("privateKey", 64 * 1024), diff --git a/app/src/main/java/net/megaproxy487/data/ConfigWrites.kt b/app/src/main/java/net/megaproxy487/data/ConfigWrites.kt index 2273946..3d1c3f7 100644 --- a/app/src/main/java/net/megaproxy487/data/ConfigWrites.kt +++ b/app/src/main/java/net/megaproxy487/data/ConfigWrites.kt @@ -23,12 +23,17 @@ class ConfigWriteQueue(dispatcher: CoroutineDispatcher) { val generation = generations[key] ?: 0L scope.launch { val current = synchronized(this@ConfigWriteQueue) { generation == (generations[key] ?: 0L) } - val result = if (current) runCatching(write) else null - synchronized(this@ConfigWriteQueue) { - if (generation == (generations[key] ?: 0L) && result != null) { - if (result.isSuccess) failures.remove(key) else failures[key] = write + try { + val result = if (current) operationResult(write) else null + synchronized(this@ConfigWriteQueue) { + if (generation == (generations[key] ?: 0L) && result != null) { + if (result.isSuccess) failures.remove(key) else failures[key] = write + } + } + } finally { + synchronized(this@ConfigWriteQueue) { + mutableStatus.value = ConfigWriteStatus(mutableStatus.value.pending - 1, failures.isNotEmpty()) } - mutableStatus.value = ConfigWriteStatus(mutableStatus.value.pending - 1, failures.isNotEmpty()) } } } diff --git a/app/src/main/java/net/megaproxy487/data/FoxyProxyParser.kt b/app/src/main/java/net/megaproxy487/data/FoxyProxyParser.kt index dcfb16c..099d27f 100644 --- a/app/src/main/java/net/megaproxy487/data/FoxyProxyParser.kt +++ b/app/src/main/java/net/megaproxy487/data/FoxyProxyParser.kt @@ -8,8 +8,8 @@ import net.megaproxy487.model.ProxyConfig import org.json.JSONObject object FoxyProxyParser { - fun parse(text: String): Result = runCatching { - val root = JSONObject(text) + fun parse(text: String): Result = operationResult { + val root = boundedJsonObject(text) requireUi(!ConfigTransfer.isSupportedSchema(root.optString("schema"))) { UiException(R.string.error_foxy_wrong) } diff --git a/app/src/main/java/net/megaproxy487/data/OperationResult.kt b/app/src/main/java/net/megaproxy487/data/OperationResult.kt new file mode 100644 index 0000000..019bc07 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/data/OperationResult.kt @@ -0,0 +1,12 @@ +package net.megaproxy487.data + +import java.util.concurrent.CancellationException + +/** Recoverable failures may be retried; VM errors must never become empty persisted data. */ +internal inline fun operationResult(block: () -> T): Result = try { + Result.success(block()) +} catch (cancelled: CancellationException) { + throw cancelled +} catch (error: Exception) { + Result.failure(error) +} diff --git a/app/src/main/java/net/megaproxy487/data/ProfileMerge.kt b/app/src/main/java/net/megaproxy487/data/ProfileMerge.kt new file mode 100644 index 0000000..7d83756 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/data/ProfileMerge.kt @@ -0,0 +1,13 @@ +package net.megaproxy487.data + +import net.megaproxy487.model.ProxyProfile + +/** Preserve local ordering without scanning the replacement list for every profile. */ +internal fun mergeResolvedProfiles( + existing: List, + resolved: List, + added: List, +): List { + val byId = resolved.associateBy(ProxyProfile::id) + return existing.map { byId[it.id] ?: it } + added +} diff --git a/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt b/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt index a3b419d..eeebc01 100644 --- a/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt +++ b/app/src/main/java/net/megaproxy487/model/ProxyConfig.kt @@ -1,5 +1,6 @@ package net.megaproxy487.model +import java.net.URI import androidx.annotation.StringRes import net.megaproxy487.R @@ -55,7 +56,7 @@ data class ProxyConfig( type == ProxyType.HTTPS_JUMP && !sameJumpAuthentication && jumpPassword.isBlank() -> R.string.validation_jump_basic_password type.isHttps && profile == TlsProfile.CUSTOM && Ja3Spec.parse(customJa3) == null -> R.string.validation_ja3 - dnsProvider == DnsProvider.CUSTOM && !customDohUrl.matches(Regex("https://[^/\\s]+/.+")) -> + dnsProvider == DnsProvider.CUSTOM && !validDohUrl(customDohUrl.trim()) -> R.string.validation_doh_url else -> null } @@ -198,11 +199,28 @@ data class Ja3Spec( ) { companion object { fun parse(value: String): Ja3Spec? = runCatching { + require(value.length <= 8192) val fields = value.trim().split(',') require(fields.size == 5) - fun numbers(field: String): List = if (field.isBlank()) emptyList() else - field.split('-').map { it.toInt().also { number -> require(number in 0..65535) } } - Ja3Spec(fields[0].toInt(), numbers(fields[1]), numbers(fields[2]), numbers(fields[3]), numbers(fields[4])) + fun numbers(field: String): List = if (field.isEmpty()) emptyList() else + field.split('-').also { require(it.size <= 256) }.map { + require(it.matches(Regex("[0-9]+"))) + it.toInt().also { number -> require(number in 0..65535) } + } + val version = numbers(fields[0]).single() + require(version == 771 || version == 772) + val ciphers = numbers(fields[1]) + require(ciphers.isNotEmpty()) + val points = numbers(fields[4]) + require(points.all { it <= 255 }) + Ja3Spec(version, ciphers, numbers(fields[2]), numbers(fields[3]), points) }.getOrNull() } } + +internal fun validDohUrl(value: String): Boolean = runCatching { + val uri = URI(value) + uri.scheme == "https" && !uri.host.isNullOrBlank() && !uri.rawPath.isNullOrEmpty() && + uri.rawUserInfo == null && uri.rawFragment == null && + (uri.port == -1 || uri.port in 1..65535) +}.getOrDefault(false) diff --git a/app/src/main/java/net/megaproxy487/vpn/BridgeCallbacks.kt b/app/src/main/java/net/megaproxy487/vpn/BridgeCallbacks.kt new file mode 100644 index 0000000..fa9f07d --- /dev/null +++ b/app/src/main/java/net/megaproxy487/vpn/BridgeCallbacks.kt @@ -0,0 +1,36 @@ +package net.megaproxy487.vpn + +import mobile.Protector +import mobile.Reporter + +/** These interfaces have no Go error result. Never leave a recoverable JNI exception pending. */ +internal class BridgeProtector( + private val enabled: () -> Boolean, + private val protect: (Int) -> Boolean, + private val onFailure: (Exception) -> Unit, +) : Protector { + override fun protect(fd: Long): Boolean { + if (!enabled() || fd !in 0..Int.MAX_VALUE.toLong()) return false + return try { + protect(fd.toInt()) + } catch (error: Exception) { + onFailure(error) + false // Fail closed: Go must not dial an unprotected upstream socket. + } + } +} + +internal class BridgeReporter( + private val enabled: () -> Boolean, + private val deliver: (String) -> Unit, + private val onFailure: (Exception) -> Unit, +) : Reporter { + override fun report(message: String) { + if (!enabled()) return + try { + deliver(message) + } catch (error: Exception) { + onFailure(error) + } + } +} diff --git a/app/src/main/java/net/megaproxy487/vpn/ConnectionTestTarget.kt b/app/src/main/java/net/megaproxy487/vpn/ConnectionTestTarget.kt new file mode 100644 index 0000000..63836f2 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/vpn/ConnectionTestTarget.kt @@ -0,0 +1,25 @@ +package net.megaproxy487.vpn + +import net.megaproxy487.model.ProxyConfig +import net.megaproxy487.model.ProxyProfile + +/** Configuration and trust destination must come from the same connection snapshot. */ +internal data class ConnectionTestTarget(val profileId: String, val config: ProxyConfig) + +internal fun connectionTestTarget( + runtime: ConnectionTestTarget?, + selected: () -> ConnectionTestTarget, +): ConnectionTestTarget = runtime ?: selected() + +/** A fresh diagnostic session must observe a just-approved key without applying draft settings. */ +internal fun ConnectionTestTarget.withStoredTrust(profile: ProxyProfile?): ConnectionTestTarget { + if (profile?.id != profileId || profile.config.type != config.type) return this + val stored = profile.config + val sameJump = stored.jumpHost == config.jumpHost && stored.jumpPort == config.jumpPort + val sameDestination = stored.host == config.host && stored.port == config.port && + (!config.type.hasJump || sameJump) + return copy(config = config.copy( + trustedHostKey = if (sameDestination) stored.trustedHostKey else config.trustedHostKey, + jumpTrustedHostKey = if (sameJump) stored.jumpTrustedHostKey else config.jumpTrustedHostKey, + )) +} diff --git a/app/src/main/java/net/megaproxy487/vpn/DiagnosticLog.kt b/app/src/main/java/net/megaproxy487/vpn/DiagnosticLog.kt index 0dcf2db..2f4e215 100644 --- a/app/src/main/java/net/megaproxy487/vpn/DiagnosticLog.kt +++ b/app/src/main/java/net/megaproxy487/vpn/DiagnosticLog.kt @@ -33,8 +33,9 @@ object TestDiagnosticLog { val exitIp: androidx.compose.runtime.State = mutableExitIp val countryCode: androidx.compose.runtime.State = mutableCountryCode - private fun onMain(action: () -> Unit) { + private fun onMain(droppable: Boolean = false, action: () -> Unit) { if (Looper.myLooper() == Looper.getMainLooper()) action() + else if (!droppable) mainHandler.post { action() } else if (pendingUiUpdates.incrementAndGet() <= MAX_PENDING_UI_UPDATES) { mainHandler.post { try { action() } finally { pendingUiUpdates.decrementAndGet() } @@ -59,7 +60,7 @@ object TestDiagnosticLog { fun add(message: String) { val safeMessage = PrivacyLogSanitizer.sanitize(message) PersistentDiagnosticLog.write("scope=connection_test $safeMessage") - onMain { + onMain(droppable = true) { entries.add("${LocalTime.now().format(timeFormat)} $safeMessage") while (entries.size > MAX_ENTRIES) entries.removeAt(0) } diff --git a/app/src/main/java/net/megaproxy487/vpn/PersistentDiagnosticLog.kt b/app/src/main/java/net/megaproxy487/vpn/PersistentDiagnosticLog.kt index cf5bd6f..1019680 100644 --- a/app/src/main/java/net/megaproxy487/vpn/PersistentDiagnosticLog.kt +++ b/app/src/main/java/net/megaproxy487/vpn/PersistentDiagnosticLog.kt @@ -2,11 +2,13 @@ package net.megaproxy487.vpn import android.content.Context import android.os.Build +import java.io.IOException import java.io.File import java.io.OutputStream import java.io.RandomAccessFile import java.time.Instant import java.util.UUID +import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.ArrayBlockingQueue import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.TimeUnit @@ -26,6 +28,8 @@ object PersistentDiagnosticLog { ThreadPoolExecutor.DiscardOldestPolicy(), ) private val lock = Any() + private val revisionCounter = AtomicLong() + val revision: Long get() = revisionCounter.get() private val sessionId = UUID.randomUUID().toString().take(8) @Volatile private var directory: File? = null @Volatile private var limitMb = DEFAULT_LIMIT_MB @@ -46,17 +50,18 @@ object PersistentDiagnosticLog { fun write(rawMessage: String) { val safeMessage = PrivacyLogSanitizer.sanitize(rawMessage) val line = "${Instant.now()} session=$sessionId $safeMessage\n" - executor.execute { + enqueue { synchronized(lock) { val logDirectory = directory ?: return@synchronized rotateIfNeeded(logDirectory, line.toByteArray().size.toLong()) File(logDirectory, CURRENT_FILE).appendText(line, Charsets.UTF_8) + revisionCounter.incrementAndGet() } } } /** Crash-path write: deliberately synchronous so it survives immediate process termination. */ - fun writeCrash(thread: Thread, throwable: Throwable) = synchronized(lock) { + fun writeCrash(thread: Thread, throwable: Throwable): Unit = synchronized(lock) { val logDirectory = directory ?: return val entry = buildString { append("${Instant.now()} session=$sessionId event=uncaught_exception api=${Build.VERSION.SDK_INT}") @@ -78,6 +83,7 @@ object PersistentDiagnosticLog { } rotateIfNeeded(logDirectory, entry.toByteArray(Charsets.UTF_8).size.toLong()) File(logDirectory, CURRENT_FILE).appendText(entry, Charsets.UTF_8) + revisionCounter.incrementAndGet() } fun readTail(maxBytes: Int): String = synchronized(lock) { @@ -115,11 +121,12 @@ object PersistentDiagnosticLog { } fun clear() { - executor.execute { + enqueue { synchronized(lock) { val logDirectory = directory ?: return@synchronized File(logDirectory, CURRENT_FILE).delete() File(logDirectory, PREVIOUS_FILE).delete() + revisionCounter.incrementAndGet() } write("event=log_cleared") } @@ -136,16 +143,21 @@ object PersistentDiagnosticLog { } private fun enforceLimitAsync() { - executor.execute { + enqueue { synchronized(lock) { val logDirectory = directory ?: return@synchronized val segmentLimit = segmentLimitBytes() trimToTail(File(logDirectory, PREVIOUS_FILE), segmentLimit) trimToTail(File(logDirectory, CURRENT_FILE), segmentLimit) + revisionCounter.incrementAndGet() } } } + private fun enqueue(action: () -> Unit) { + executor.execute { runDiagnosticIo(action) } + } + private fun segmentLimitBytes(): Long = limitMb.toLong() * 1024 * 1024 / 2 private fun trimToTail(file: File, maxBytes: Long) { @@ -188,6 +200,8 @@ object PrivacyLogSanitizer { private val privatePath = Regex("(?i)/(?:data|storage|sdcard|mnt)/[^\\s]+") private val packageName = Regex("(? Unit): Boolean = try { + action() + true +} catch (_: IOException) { + false +} catch (_: SecurityException) { + false +} diff --git a/app/src/main/java/net/megaproxy487/vpn/ProxyCore.kt b/app/src/main/java/net/megaproxy487/vpn/ProxyCore.kt index 667338c..b0eef5e 100644 --- a/app/src/main/java/net/megaproxy487/vpn/ProxyCore.kt +++ b/app/src/main/java/net/megaproxy487/vpn/ProxyCore.kt @@ -5,11 +5,15 @@ import android.os.ParcelFileDescriptor import net.megaproxy487.model.ProxyConfig import org.json.JSONObject import org.json.JSONArray -import java.lang.reflect.Proxy +import mobile.Mobile +import mobile.Protector +import mobile.Reporter +import net.megaproxy487.data.operationResult +import java.util.concurrent.atomic.AtomicBoolean interface ProxyCore { fun resolveProxy(host: String, status: (String) -> Unit): String? - fun start(tunFd: Int, config: ProxyConfig, status: (String) -> Unit): Boolean + fun start(tunFd: Int, mtu: Int, config: ProxyConfig, status: (String) -> Unit): Boolean fun test(config: ProxyConfig, status: (String) -> Unit): ConnectionTestResult? fun stop() } @@ -35,8 +39,8 @@ data class NativeConnectionStats( ) object ConnectionStatsReader { - fun snapshot(): NativeConnectionStats? = runCatching { - val raw = Class.forName("mobile.Mobile").getMethod("getStats").invoke(null) as String + fun snapshot(): NativeConnectionStats? = operationResult { + val raw = Mobile.getStats() val json = JSONObject(raw) NativeConnectionStats( downloadBytes = json.getLong("downloadBytes"), @@ -54,8 +58,18 @@ object ConnectionStatsReader { class NativeProxyCore( private val vpnService: VpnService, private val diagnostics: (String) -> Unit = DiagnosticLog::add, + private val isCurrent: () -> Boolean = { true }, ) : ProxyCore { - private fun protectSocket(fd: Long): Boolean = vpnService.protect(fd.toInt()) + private val callbacksEnabled = AtomicBoolean(true) + private fun callbackFailure(error: Throwable) { + android.util.Log.w("MegaProxy", "Native callback failed: ${error.javaClass.simpleName}") + } + private fun protector(): Protector = BridgeProtector( + { callbacksEnabled.get() && isCurrent() }, vpnService::protect, ::callbackFailure, + ) + private fun reporter(deliver: (String) -> Unit): Reporter = BridgeReporter( + { callbacksEnabled.get() && isCurrent() }, deliver, ::callbackFailure, + ) private fun configJson(config: ProxyConfig) = JSONObject() .put("type", config.type.name) @@ -92,68 +106,44 @@ class NativeProxyCore( .put("bypassLocalNetworks", config.bypassLocalNetworks) .toString() - private fun callback(type: Class<*>, methodName: String, callback: (Array?) -> Any?) = - Proxy.newProxyInstance(type.classLoader, arrayOf(type)) { _, method, args -> - if (method.name == methodName) callback(args) else error("Unknown native callback ${method.name}") - } - - override fun resolveProxy(host: String, status: (String) -> Unit): String? = runCatching { - val mobile = Class.forName("mobile.Mobile") - val protectorType = Class.forName("mobile.Protector") - val reporterType = Class.forName("mobile.Reporter") - val protector = callback(protectorType, "protect") { protectSocket(it!![0] as Long) } - val reporter = callback(reporterType, "report") { diagnostics(it!![0] as String); null } - mobile.getMethod("resolveProxy", String::class.java, protectorType, reporterType) - .invoke(null, host, protector, reporter) as String + override fun resolveProxy(host: String, status: (String) -> Unit): String? = operationResult { + Mobile.resolveProxy(host, protector(), reporter(diagnostics)) }.onFailure { val message = it.cause?.message ?: it.message ?: "Unknown native error" diagnostics("event=bootstrap_dns result=failed detail=$message") status("Proxy DNS failed: $message") }.getOrNull() - override fun start(tunFd: Int, config: ProxyConfig, status: (String) -> Unit): Boolean { - var detachedFd: Int? = null - return runCatching { - val mobile = Class.forName("mobile.Mobile") - val protectorType = Class.forName("mobile.Protector") - val reporterType = Class.forName("mobile.Reporter") - val protector = callback(protectorType, "protect") { protectSocket(it!![0] as Long) } - val reporter = callback(reporterType, "report") { - val message = it!![0] as String + override fun start(tunFd: Int, mtu: Int, config: ProxyConfig, status: (String) -> Unit): Boolean { + var nativeStarted = false + return operationResult { + val reporter = reporter { message -> diagnostics(message) VpnRuntimeState.observeDiagnostic(message) if ("SSH_HOST_KEY_" in message || "dpi_hint=possible" in message) status(message) - null } - detachedFd = ParcelFileDescriptor.fromFd(tunFd).detachFd() - val startMethod = mobile.getMethod( - "start", Long::class.javaPrimitiveType, String::class.java, protectorType, reporterType, - ) - val goFd = detachedFd!! - detachedFd = null // Start's contract takes ownership, including error paths. - startMethod.invoke(null, goFd.toLong(), configJson(config), protector, reporter) + val json = configJson(config) + // Java keeps its duplicate alive for the call; Go duplicates it on entry. + // Even a linkage failure before Go is entered cannot leak a detached FD. + ParcelFileDescriptor.fromFd(tunFd).use { borrowed -> + Mobile.start(borrowed.fd.toLong(), mtu.toLong(), json, protector(), reporter) + nativeStarted = true + } status("TCP is protected by ${config.type.title}") true }.getOrElse { - detachedFd?.let { fd -> runCatching { ParcelFileDescriptor.adoptFd(fd).close() } } - status(if (it is ClassNotFoundException) "Add app/libs/megaproxy.aar" else "Native core error: ${it.cause?.message ?: it.message}") + if (nativeStarted) stop() + status("Native core error: ${it.message}") false } } - override fun test(config: ProxyConfig, status: (String) -> Unit): ConnectionTestResult? = runCatching { - val mobile = Class.forName("mobile.Mobile") - val protectorType = Class.forName("mobile.Protector") - val reporterType = Class.forName("mobile.Reporter") - val protector = callback(protectorType, "protect") { protectSocket(it!![0] as Long) } - val reporter = callback(reporterType, "report") { - val message = it!![0] as String + override fun test(config: ProxyConfig, status: (String) -> Unit): ConnectionTestResult? = operationResult { + val reporter = reporter { message -> diagnostics(message) if ("SSH_HOST_KEY_" in message) status(message) - null } - val raw = mobile.getMethod("testConnection", String::class.java, protectorType, reporterType) - .invoke(null, configJson(config), protector, reporter) as String + val raw = Mobile.testConnection(configJson(config), protector(), reporter) parseConnectionTestResult(raw) }.onSuccess { status("Test passed: exit IP ${it.exitIp}") @@ -164,6 +154,7 @@ class NativeProxyCore( }.getOrNull() override fun stop() { - runCatching { Class.forName("mobile.Mobile").getMethod("stop").invoke(null) } + callbacksEnabled.set(false) + operationResult { Mobile.stop() }.onFailure(::callbackFailure) } } diff --git a/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt b/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt index bc7e646..1ca18ff 100644 --- a/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt +++ b/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt @@ -37,12 +37,13 @@ import kotlinx.coroutines.launch class ProxyVpnService : VpnService() { @Volatile private var tunnel: ParcelFileDescriptor? = null @Volatile private var core: ProxyCore? = null - @Volatile private var activeConfig: net.megaproxy487.model.ProxyConfig? = null + @Volatile private var activeSession: ConnectionTestTarget? = null @Volatile private var tunnelTestOnly = false @Volatile private var hostKeyPrompt: PendingIntent? = null @Volatile private var failoverNotice: String? = null @Volatile private var connectionBlockedForAction = false @Volatile private var reconnectAfterStart = false + @Volatile private var serviceDestroyed = false @Volatile private var healthWarningActive = false @Volatile private var consecutiveStartFailures = 0 @Volatile private var nextStartAttemptAt = 0L @@ -159,6 +160,8 @@ class ProxyVpnService : VpnService() { return START_NOT_STICKY } if (intent?.action == ACTION_STOP) { + reconnectAfterStart = false + store.setConnectionDesired(false) stopTunnel() stopSelf() return START_NOT_STICKY @@ -238,6 +241,8 @@ class ProxyVpnService : VpnService() { if (reconnectAfterStart) { reconnectAfterStart = false monitorHandler.post { + // A queued reconnect must not undo a later Stop or revive a destroyed service. + if (serviceDestroyed || !ConfigStore(this).isConnectionDesired()) return@post startService(Intent(this, ProxyVpnService::class.java).setAction(ACTION_RECONNECT) .putExtra(EXTRA_RECONNECT_REASON, "profile_changed_during_connect")) } @@ -245,14 +250,18 @@ class ProxyVpnService : VpnService() { } private fun testConnection() { - val storedConfig = ConfigStore(this).globalConnectionSettings().applyTo(ConfigStore(this).activeProfile().config) - storedConfig.connectionValidationError()?.let { uiText(it) }?.let { + val target = connectionTestTarget(synchronized(tunnelStateLock) { activeSession }) { + val store = ConfigStore(this) + val profile = store.activeProfile() + ConnectionTestTarget(profile.id, store.globalConnectionSettings().applyTo(profile.config)) + } + target.config.connectionValidationError()?.let { uiText(it) }?.let { TestDiagnosticLog.fail("Connection test cannot start: $it") if (tunnel == null) { stopForeground(STOP_FOREGROUND_REMOVE); stopSelf() } return } val temporaryVpn = tunnel == null - if (temporaryVpn && !startTunnel(testOnly = true, suppliedConfig = storedConfig, generation = startGeneration.get())) { + if (temporaryVpn && !startTunnel(testOnly = true, suppliedTarget = target, generation = startGeneration.get())) { TestDiagnosticLog.fail("Connection test failed: temporary VPN could not be started") if (hostKeyPrompt == null) { stopForeground(STOP_FOREGROUND_REMOVE) @@ -260,12 +269,13 @@ class ProxyVpnService : VpnService() { } return } - val config = synchronized(tunnelStateLock) { activeConfig } ?: run { + val session = synchronized(tunnelStateLock) { activeSession } ?: run { TestDiagnosticLog.fail("Connection test failed: active VPN configuration is unavailable") return } - val result = NativeProxyCore(this, TestDiagnosticLog::add).test(config) { message -> - configureHostKeyPrompt(message, ConfigStore(this).activeProfileId(), true) + val testConfig = session.withStoredTrust(ConfigStore(this).profile(session.profileId)).config + val result = NativeProxyCore(this, TestDiagnosticLog::add).test(testConfig) { message -> + configureHostKeyPrompt(message, session.profileId, true) getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(uiText(if (hostKeyPrompt != null) R.string.ssh_key_approval else if (isRunning) R.string.status_connected else R.string.status_connecting_progress))) } if (result != null) TestDiagnosticLog.succeed(result.exitIp, result.countryCode) else TestDiagnosticLog.fail() @@ -277,14 +287,14 @@ class ProxyVpnService : VpnService() { private fun startTunnel( testOnly: Boolean, - suppliedConfig: net.megaproxy487.model.ProxyConfig? = null, + suppliedTarget: ConnectionTestTarget? = null, generation: Long = startGeneration.get(), ): Boolean { val configStore = ConfigStore(this) val pendingReconnectToken = if (testOnly) null else configStore.pendingReconnectToken() val storedProfile = configStore.connectionProfile() - val storedConfig = suppliedConfig ?: configStore.globalConnectionSettings().applyTo(storedProfile.config) - val promptProfileId = if (testOnly) configStore.activeProfileId() else storedProfile.id + val storedConfig = suppliedTarget?.config ?: configStore.globalConnectionSettings().applyTo(storedProfile.config) + val promptProfileId = suppliedTarget?.profileId ?: storedProfile.id val diagnostics = if (testOnly) TestDiagnosticLog::add else DiagnosticLog::add var failureDetail = "" diagnostics( @@ -343,79 +353,95 @@ class ProxyVpnService : VpnService() { handleStartFailure(testOnly, "VPN interface could not be established") return false } - diagnostics("TUN established with IPv4, IPv6 and intercepted DNS") - val proxyCore = NativeProxyCore(this, diagnostics) - val addressCache = BootstrapAddressCache(this) - fun resolveHost(host: String, target: String): String? { - if (!testOnly) { - addressCache.get(host)?.let { - diagnostics("event=bootstrap_dns result=cache_hit target=$target age_limit_days=7") - return it + val proxyCore = NativeProxyCore(this, diagnostics) { + !serviceDestroyed && isStartCurrent(generation) + } + var nativeStarted = false + var tunnelCommitted = false + try { + diagnostics("TUN established with IPv4, IPv6 and intercepted DNS") + val addressCache = BootstrapAddressCache(this) + fun resolveHost(host: String, target: String): String? { + if (!testOnly) { + addressCache.get(host)?.let { + diagnostics("event=bootstrap_dns result=cache_hit target=$target age_limit_days=7") + return it + } } + return proxyCore.resolveProxy(host) { message -> + failureDetail = message + getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(uiText(if (hostKeyPrompt != null) R.string.ssh_key_approval else if (isRunning) R.string.status_connected else R.string.status_connecting_progress))) + }?.also { addressCache.put(host, it) } } - return proxyCore.resolveProxy(host) { message -> - failureDetail = message - getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(uiText(if (hostKeyPrompt != null) R.string.ssh_key_approval else if (isRunning) R.string.status_connected else R.string.status_connecting_progress))) - }?.also { addressCache.put(host, it) } - } - val proxyIp = if (storedConfig.type == net.megaproxy487.model.ProxyType.HTTPS_JUMP) "" else resolveHost(storedConfig.host, "proxy") ?: run { - establishedTunnel.close() - if (!isStartCurrent(generation)) return false - handleStartFailure(testOnly, "Proxy bootstrap DNS failed", failureDetail, promptProfileId) - return false - } - val jumpIp = if (storedConfig.type.hasJump) { - resolveHost(storedConfig.jumpHost, "jump") ?: run { - establishedTunnel.close() + val proxyIp = if (storedConfig.type == net.megaproxy487.model.ProxyType.HTTPS_JUMP) "" else resolveHost(storedConfig.host, "proxy") ?: run { if (!isStartCurrent(generation)) return false - handleStartFailure(testOnly, "Jump host bootstrap DNS failed", failureDetail, promptProfileId) + handleStartFailure(testOnly, "Proxy bootstrap DNS failed", failureDetail, promptProfileId) return false } - } else "" - if (!isStartCurrent(generation)) { - establishedTunnel.close() - return false - } - val config = storedConfig.copy(resolvedProxyIp = proxyIp, resolvedJumpIp = jumpIp) - val started = proxyCore.start(establishedTunnel.fd, config) { message -> - failureDetail = message - configureHostKeyPrompt(message, promptProfileId, testOnly) - if (!testOnly && "dpi_hint=possible" in message) monitorHandler.post { handleRuntimeDiagnostic(promptProfileId, message) } - getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(uiText(if (hostKeyPrompt != null) R.string.ssh_key_approval else if (isRunning) R.string.status_connected else R.string.status_connecting_progress))) + val jumpIp = if (storedConfig.type.hasJump) { + resolveHost(storedConfig.jumpHost, "jump") ?: run { + if (!isStartCurrent(generation)) return false + handleStartFailure(testOnly, "Jump host bootstrap DNS failed", failureDetail, promptProfileId) + return false + } + } else "" + if (!isStartCurrent(generation)) { + return false } - if (!started) { - establishedTunnel.close() - if (isStartCurrent(generation)) { - isRunning = false - handleStartFailure(testOnly, "Native proxy core failed to start", failureDetail, promptProfileId) + val config = storedConfig.copy(resolvedProxyIp = proxyIp, resolvedJumpIp = jumpIp) + val started = proxyCore.start(establishedTunnel.fd, VPN_MTU, config) { message -> + failureDetail = message + configureHostKeyPrompt(message, promptProfileId, testOnly) + if (!testOnly && "dpi_hint=possible" in message) monitorHandler.post { handleRuntimeDiagnostic(promptProfileId, message) } + getSystemService(NotificationManager::class.java).notify(NOTIFICATION_ID, notification(uiText(if (hostKeyPrompt != null) R.string.ssh_key_approval else if (isRunning) R.string.status_connected else R.string.status_connecting_progress))) + } + nativeStarted = started + if (!started) { + if (isStartCurrent(generation)) { + isRunning = false + handleStartFailure(testOnly, "Native proxy core failed to start", failureDetail, promptProfileId) + } + return false } - return false - } - val committed = synchronized(tunnelStateLock) { - if (!isStartCurrent(generation) || tunnel != null) false else { - tunnel = establishedTunnel - core = proxyCore - tunnelTestOnly = testOnly - activeConfig = config - isRunning = true - true + val committed = synchronized(tunnelStateLock) { + if (!isStartCurrent(generation) || tunnel != null) false else { + tunnelCommitted = true + tunnel = establishedTunnel + core = proxyCore + tunnelTestOnly = testOnly + activeSession = ConnectionTestTarget(promptProfileId, config) + isRunning = true + true + } + } + if (!committed) { + return false + } + underlyingNetwork?.let { setUnderlyingNetworks(arrayOf(it)) } + probableFailureCounts.remove(promptProfileId) + probableFailureTimes.remove(promptProfileId) + if (failoverNotice == null) VpnRuntimeState.updateNetworkWarning(null) + else VpnRuntimeState.updateNetworkWarning(failoverNotice) + VpnRuntimeState.updateSystem(isAlwaysOnMode, isLockdownMode, promptProfileId) + if (!testOnly) configStore.clearPendingReconnect(pendingReconnectToken) + resetRetryState() + VpnRuntimeState.update(VpnConnectionState.CONNECTED) + return true + } finally { + // Until publication under tunnelStateLock, this attempt owns both resources. + // Exceptions in DNS/cache/notification work must not leak them across retries. + if (!tunnelCommitted) { + try { + if (nativeStarted) proxyCore.stop() + } finally { + try { + establishedTunnel.close() + } catch (error: java.io.IOException) { + diagnostics("event=tun_close result=failed error=${error.javaClass.simpleName}") + } + } } } - if (!committed) { - proxyCore.stop() - establishedTunnel.close() - return false - } - underlyingNetwork?.let { setUnderlyingNetworks(arrayOf(it)) } - probableFailureCounts.remove(promptProfileId) - probableFailureTimes.remove(promptProfileId) - if (failoverNotice == null) VpnRuntimeState.updateNetworkWarning(null) - else VpnRuntimeState.updateNetworkWarning(failoverNotice) - VpnRuntimeState.updateSystem(isAlwaysOnMode, isLockdownMode, promptProfileId) - if (!testOnly) configStore.clearPendingReconnect(pendingReconnectToken) - resetRetryState() - VpnRuntimeState.update(VpnConnectionState.CONNECTED) - return true } private fun isStartCurrent(generation: Long): Boolean = startGeneration.get() == generation @@ -471,12 +497,13 @@ class ProxyVpnService : VpnService() { } val delay = retryDelayMs(consecutiveStartFailures) nextStartAttemptAt = SystemClock.elapsedRealtime() + delay - retryStatus = this@ProxyVpnService.uiText(R.string.vpn_retry_delay, failureStage, delay / 1_000, consecutiveStartFailures + 1) + val retryMessage = this@ProxyVpnService.uiText(R.string.vpn_retry_delay, failureStage, delay / 1_000, consecutiveStartFailures + 1) + retryStatus = retryMessage DiagnosticLog.add("event=vpn_retry result=scheduled attempt=${consecutiveStartFailures + 1} delay_ms=$delay stage=${failureStageToken(detail)}") VpnRuntimeState.update(VpnConnectionState.CONNECTING) getSystemService(NotificationManager::class.java).notify( NOTIFICATION_ID, - notification(retryStatus!!), + notification(retryMessage), ) } } @@ -695,7 +722,7 @@ class ProxyVpnService : VpnService() { tunnel = null core = null tunnelTestOnly = false - activeConfig = null + activeSession = null isRunning = false healthWarningActive = false lastHealthBytes = 0L @@ -708,8 +735,15 @@ class ProxyVpnService : VpnService() { } VpnRuntimeState.update(VpnConnectionState.DISCONNECTED) resetRetryState() - stopped.second?.stop() - stopped.first?.close() + try { + stopped.second?.stop() + } finally { + try { + stopped.first?.close() + } catch (error: java.io.IOException) { + DiagnosticLog.add("event=tun_close result=failed error=${error.javaClass.simpleName}") + } + } if (removeForeground) stopForeground(STOP_FOREGROUND_REMOVE) } @@ -721,6 +755,8 @@ class ProxyVpnService : VpnService() { stopSelf() } override fun onDestroy() { + serviceDestroyed = true + reconnectAfterStart = false monitorHandler.removeCallbacks(monitor) monitorHandler.removeCallbacks(reconnectForNetworkChange) if (networkCallbackRegistered) { @@ -749,7 +785,7 @@ class ProxyVpnService : VpnService() { val changed = marker.startsWith("SSH_HOST_KEY_CHANGED") val fingerprint = if (changed) parts.getOrNull(3) else parts.getOrNull(2) if (fingerprint == null || !fingerprint.startsWith("SHA256:")) return - val intent = Intent(this, MainActivity::class.java) + val intent = Intent(this, net.megaproxy487.SshHostKeyReviewActivity::class.java) .setAction(MainActivity.ACTION_REVIEW_SSH_HOST_KEY) .putExtra(MainActivity.EXTRA_PROFILE_ID, profileId) .putExtra(MainActivity.EXTRA_HOP, parts[0]) @@ -803,9 +839,23 @@ class ProxyVpnService : VpnService() { @Volatile var isLockdownMode: Boolean = false private set - fun start(context: Context) { + private fun launchCommand(context: Context, action: () -> Unit) { val app = context.applicationContext commandScope.launch { + try { + action() + } catch (cancelled: kotlinx.coroutines.CancellationException) { + throw cancelled + } catch (error: Exception) { + DiagnosticLog.add("event=vpn_command result=failed error=${error.javaClass.simpleName}") + VpnRuntimeState.updateNetworkWarning(app.uiText(R.string.vpn_command_failed)) + } + } + } + + fun start(context: Context) { + val app = context.applicationContext + launchCommand(app) { ConfigStore(app).setConnectionDesired(true) ConfigStore(app).let { it.setConnectionProfile(it.activeProfileId()) } ContextCompat.startForegroundService(app, Intent(app, ProxyVpnService::class.java).setAction(ACTION_START_MANUAL)) @@ -813,14 +863,14 @@ class ProxyVpnService : VpnService() { } fun stop(context: Context) { val app = context.applicationContext - commandScope.launch { + launchCommand(app) { ConfigStore(app).setConnectionDesired(false) app.startService(Intent(app, ProxyVpnService::class.java).setAction(ACTION_STOP)) } } fun reconnect(context: Context) { val app = context.applicationContext - commandScope.launch { + launchCommand(app) { ConfigStore(app).setConnectionDesired(true) ContextCompat.startForegroundService(app, Intent(app, ProxyVpnService::class.java).setAction(ACTION_RECONNECT)) } @@ -836,9 +886,9 @@ class ProxyVpnService : VpnService() { } fun switchProfile(context: Context, profileId: String, useAsAlwaysOn: Boolean) { val app = context.applicationContext - commandScope.launch { + launchCommand(app) { val store = ConfigStore(app) - if (store.profile(profileId) == null) return@launch + if (store.profile(profileId) == null) return@launchCommand if (useAsAlwaysOn) store.setAlwaysOnProfile(profileId) else store.setActiveProfile(profileId) store.setConnectionProfile(profileId) store.setConnectionDesired(true) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index ecbae71..a6839ef 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -1,5 +1,6 @@ + Политика конфиденциальности Назад Настройки Подключение @@ -461,4 +462,7 @@ Обработка конфигурации… Профиль сохранится после первого изменения. Чтобы отменить создание, вернитесь назад. Другие действия + Не удалось прочитать диагностический лог. Повторяем попытку… + Конфигурация содержит слишком глубокую вложенность или слишком много элементов JSON. + Не удалось выполнить команду VPN. Проверьте состояние соединения и повторите попытку. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 22851a0..da594bf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,5 +1,6 @@ + Privacy policy MegaProxy Settings Connection @@ -456,4 +457,7 @@ Processing configuration… This profile will be saved after your first change. Go back to cancel. More actions + Could not read the diagnostic log. Retrying… + The configuration is too deeply nested or contains too many JSON elements. + Could not complete the VPN command. Check the connection state and try again. diff --git a/app/src/test/java/net/megaproxy487/ConnectionUiContractsTest.kt b/app/src/test/java/net/megaproxy487/ConnectionUiContractsTest.kt new file mode 100644 index 0000000..333edfa --- /dev/null +++ b/app/src/test/java/net/megaproxy487/ConnectionUiContractsTest.kt @@ -0,0 +1,27 @@ +package net.megaproxy487 + +import net.megaproxy487.data.ConfigWriteStatus +import net.megaproxy487.vpn.TestState +import net.megaproxy487.vpn.VpnConnectionState +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ConnectionUiContractsTest { + @Test fun failedOrPendingSaveCannotTrapUserInVpn() { + for (connection in listOf(VpnConnectionState.CONNECTED, VpnConnectionState.CONNECTING)) { + assertTrue(connectionActionEnabled(connection, false, ConfigWriteStatus(1, true), false)) + assertFalse(connectionActionEnabled(connection, true, ConfigWriteStatus(), true)) + } + assertFalse(connectionActionEnabled(VpnConnectionState.DISCONNECTED, false, ConfigWriteStatus(1), true)) + assertFalse(connectionActionEnabled(VpnConnectionState.DISCONNECTED, false, ConfigWriteStatus(failed = true), true)) + assertTrue(connectionActionEnabled(VpnConnectionState.DISCONNECTED, false, ConfigWriteStatus(), true)) + } + + @Test fun reopeningRunningDiagnosticDoesNotLaunchAnotherOne() { + assertFalse(shouldAutoStartConnectionTest(TestState.RUNNING)) + assertTrue(shouldAutoStartConnectionTest(TestState.IDLE)) + assertTrue(shouldAutoStartConnectionTest(TestState.FAILED)) + assertTrue(shouldAutoStartConnectionTest(TestState.SUCCEEDED)) + } +} diff --git a/app/src/test/java/net/megaproxy487/DiagnosticSnapshotTest.kt b/app/src/test/java/net/megaproxy487/DiagnosticSnapshotTest.kt new file mode 100644 index 0000000..7ceadd6 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/DiagnosticSnapshotTest.kt @@ -0,0 +1,23 @@ +package net.megaproxy487 + +import java.io.IOException +import java.util.concurrent.CancellationException +import org.junit.Assert.* +import org.junit.Test + +class DiagnosticSnapshotTest { + @Test fun storageFailureAllowsNextReadToRecover() { + assertNull(readDiagnosticSnapshot { throw IOException("storage unavailable") }) + assertNull(readDiagnosticSnapshot { throw SecurityException("access denied") }) + assertEquals(listOf("first", "second"), readDiagnosticSnapshot { "first\n\nsecond\n" }) + } + + @Test fun cancellationAndFatalErrorsAreNotMasked() { + assertThrows(CancellationException::class.java) { + readDiagnosticSnapshot { throw CancellationException() } + } + assertThrows(OutOfMemoryError::class.java) { + readDiagnosticSnapshot { throw OutOfMemoryError("synthetic") } + } + } +} diff --git a/app/src/test/java/net/megaproxy487/DiagnosticsUiTest.kt b/app/src/test/java/net/megaproxy487/DiagnosticsUiTest.kt new file mode 100644 index 0000000..e422dc9 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/DiagnosticsUiTest.kt @@ -0,0 +1,44 @@ +package net.megaproxy487 + +import android.content.ClipboardManager +import android.content.Context +import androidx.compose.ui.test.* +import net.megaproxy487.vpn.TestDiagnosticLog +import org.junit.Assert.* +import org.junit.Test + +class DiagnosticsUiTest : MainUiTestBase() { + @Test fun runningDiagnosticCannotBeStartedAgainAndKeepsItsLog() { + TestDiagnosticLog.begin() + TestDiagnosticLog.add("event=test_marker") + content { ConnectionTestScreen(activity, autoStart = true, onBack = {}) } + node(R.string.run_again).assertIsNotEnabled() + node(R.string.copy_log).assertIsEnabled().performClick() + val clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + assertTrue(clipboard.primaryClip!!.getItemAt(0).text.contains("event=test_marker")) + } + + @Test fun completedDiagnosticShowsExitIpAndAllowsAnotherRun() { + TestDiagnosticLog.succeed("203.0.113.7", "DE") + content { ConnectionTestScreen(activity, autoStart = false, onBack = {}) } + node(R.string.test_passed).assertIsDisplayed() + compose.onNodeWithText(activity.uiText(R.string.proxy_exit_ip, "203.0.113.7")).assertIsDisplayed() + node(R.string.run_again).assertIsEnabled() + } + + @Test fun failureShowsStatusAndEmptyLogCannotBeCopied() { + TestDiagnosticLog.fail() + content { ConnectionTestScreen(activity, autoStart = false, onBack = {}) } + node(R.string.test_failed).assertIsDisplayed() + node(R.string.run_again).assertIsEnabled() + node(R.string.copy_log).assertIsNotEnabled() + } + + @Test fun clearingDiagnosticLogRequiresConfirmation() { + content { DiagnosticLogScreen(activity, {}) } + node(R.string.clear_action).performScrollTo().performClick() + node(R.string.clear_diagnostic_log_title).assertIsDisplayed() + node(R.string.cancel).performClick() + node(R.string.clear_diagnostic_log_title).assertDoesNotExist() + } +} diff --git a/app/src/test/java/net/megaproxy487/MainScreenUiTest.kt b/app/src/test/java/net/megaproxy487/MainScreenUiTest.kt new file mode 100644 index 0000000..e552dc1 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/MainScreenUiTest.kt @@ -0,0 +1,117 @@ +package net.megaproxy487 + +import android.app.Application +import android.content.Intent +import android.provider.Settings +import androidx.compose.ui.test.* +import net.megaproxy487.vpn.* +import org.junit.Assert.* +import org.junit.Test +import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowVpnService + +class MainScreenUiTest : MainUiTestBase() { + private fun screen() = content { MainScreen(activity, {}, {}, {}, readConnectionStats = { null }) } + private fun command(action: String) { + drainConfigIo() + val application = shadowOf(activity.application as Application) + val received = application.nextStartedService + assertEquals("net.megaproxy487.$action", received?.action) + assertEquals(ProxyVpnService::class.java.name, received?.component?.className) + assertNull("Unexpected extra service command", application.nextStartedService) + } + + private fun noCommand() { + drainConfigIo() + assertNull(shadowOf(activity.application as Application).nextStartedService) + } + + @Test fun connectDispatchesStartAndPersistsDesiredState() { + screen() + node(R.string.connect).performScrollTo().assertIsEnabled().performClick() + command("START_MANUAL") + assertTrue(store.isConnectionDesired()) + } + + @Test fun grantingVpnPermissionStartsRequestedConnection() { + val consent = Intent("test.VPN_CONSENT") + ShadowVpnService.setPrepareResult(consent) + screen() + node(R.string.connect).performScrollTo().performClick() + noCommand() + compose.runOnIdle { + ShadowVpnService.setPrepareResult(null) + shadowOf(activity).receiveResult(consent, android.app.Activity.RESULT_OK, null) + } + command("START_MANUAL") + assertTrue(store.isConnectionDesired()) + } + + @Test fun selectingProfileWhileDisconnectedPersistsWithoutStartingVpn() { + val other = store.cloneProfile(store.activeProfileId())!!.copy(name = "Secondary") + store.saveProfile(other) + screen() + compose.onNodeWithText("Primary").performClick() + compose.onNodeWithText("Secondary").performClick() + saved() + assertEquals(other.id, store.activeProfileId()) + compose.onNodeWithText("Secondary").assertIsDisplayed() + noCommand() + } + + @Test fun invalidProfileRequiresConfigurationBeforeConnect() { + store.saveProfile(store.activeProfile().let { it.copy(config = it.config.copy(host = "")) }) + var edited: String? = null + content { MainScreen(activity, {}, {}, { edited = it }, readConnectionStats = { null }) } + node(R.string.connect).performScrollTo().assertIsNotEnabled() + node(R.string.configure).performScrollTo().performClick() + compose.runOnIdle { assertEquals(store.activeProfileId(), edited) } + } + + @Test fun cancellingConnectRemainsPossibleAndTestMenuIsDisabled() { + VpnRuntimeState.update(VpnConnectionState.CONNECTING) + screen() + icon(R.string.main_actions).performClick() + node(R.string.test_connection).assertIsNotEnabled() + compose.runOnIdle { activity.onBackPressedDispatcher.onBackPressed() } + node(R.string.disconnect).performScrollTo().assertIsEnabled().performClick() + command("STOP") + assertFalse(store.isConnectionDesired()) + } + + @Test fun connectedSessionOffersReconnectAndDisconnectWithoutLoadingJni() { + VpnRuntimeState.update(VpnConnectionState.CONNECTED) + screen() + node(R.string.reconnect).performScrollTo().performClick() + command("RECONNECT") + node(R.string.disconnect).performScrollTo().performClick() + command("STOP") + assertFalse(store.isConnectionDesired()) + } + + @Test fun alwaysOnDisablesManualConnectionControl() { + Settings.Secure.putString(activity.contentResolver, "always_on_vpn_app", activity.packageName) + screen() + node(R.string.connect).performScrollTo().assertIsNotEnabled() + } + + @Test fun anotherAlwaysOnProviderShowsConflictInsteadOfStartingVpn() { + Settings.Secure.putString(activity.contentResolver, "always_on_vpn_app", "other.vpn") + screen() + node(R.string.connect).performScrollTo().performClick() + node(R.string.always_on_conflict_title).assertIsDisplayed() + noCommand() + } + + @Test fun deniedPermissionShowsDenialRatherThanAlwaysOnConflict() { + val consent = Intent("test.VPN_CONSENT") + ShadowVpnService.setPrepareResult(consent) + screen() + node(R.string.connect).performScrollTo().performClick() + compose.runOnIdle { shadowOf(activity).receiveResult(consent, android.app.Activity.RESULT_CANCELED, null) } + node(R.string.vpn_permission_denied).assertIsDisplayed() + node(R.string.always_on_conflict_title).assertDoesNotExist() + noCommand() + assertFalse(store.isConnectionDesired()) + } +} diff --git a/app/src/test/java/net/megaproxy487/MainUiTestBase.kt b/app/src/test/java/net/megaproxy487/MainUiTestBase.kt new file mode 100644 index 0000000..5231670 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/MainUiTestBase.kt @@ -0,0 +1,82 @@ +package net.megaproxy487 + +import android.Manifest +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.test.* +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import net.megaproxy487.data.ConfigIoDispatcher +import net.megaproxy487.data.ConfigStore +import net.megaproxy487.data.ConfigWrites +import net.megaproxy487.model.GlobalConnectionSettings +import net.megaproxy487.vpn.* +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import org.robolectric.shadows.ShadowVpnService +import java.security.Security +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.coroutines.EmptyCoroutineContext +import org.junit.Assert.assertFalse + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class, qualifiers = "en-w411dp-h891dp") +@LooperMode(LooperMode.Mode.PAUSED) +abstract class MainUiTestBase { + @get:Rule val compose = createAndroidComposeRule() + protected val activity get() = compose.activity + protected val store get() = ConfigStore(activity) + protected fun text(id: Int) = activity.uiText(id) + protected fun node(id: Int) = compose.onNodeWithText(text(id)) + protected fun icon(id: Int) = compose.onNodeWithContentDescription(text(id)) + protected fun content(block: @Composable () -> Unit) = compose.setContent { MaterialTheme { block() } } + protected fun drainConfigIo() { + compose.waitForIdle() + val completed = AtomicBoolean() + ConfigIoDispatcher.dispatch(EmptyCoroutineContext, Runnable { completed.set(true) }) + compose.waitUntil(10_000) { completed.get() } + compose.waitForIdle() + } + protected fun saved() { + drainConfigIo() + compose.waitUntil(10_000) { ConfigWrites.status.value.pending == 0 } + assertFalse("Configuration write failed", ConfigWrites.status.value.failed) + } + protected fun waitForText(value: String) { + compose.waitUntil(10_000) { compose.onAllNodesWithText(value).fetchSemanticsNodes().isNotEmpty() } + } + protected fun seed(name: String = "Primary") = store.activeProfile().let { + it.copy(name = name, config = it.config.copy(host = "proxy.example", username = "user", password = "test-password")) + }.also { store.saveProfile(it) } + + @Before fun preparePlatform() { + Security.removeProvider("AndroidKeyStore") + UiTestKeyStore.keys.clear() + Security.addProvider(UiTestKeyStoreProvider()) + shadowOf(activity.application as Application).grantPermissions(Manifest.permission.POST_NOTIFICATIONS) + VpnRuntimeState.update(VpnConnectionState.DISCONNECTED) + VpnRuntimeState.updateSystem(false, false, "") + VpnRuntimeState.updateNetworkWarning(null) + SshHostKeyPromptState.clear() + TestDiagnosticLog.reset() + store.saveGlobalConnectionSettings(GlobalConnectionSettings(routeAllApps = true)) + ShadowVpnService.setPrepareResult(null) + seed() + } + + @After fun finishPlatform() { + try { + saved() + } finally { + Security.removeProvider("AndroidKeyStore") + UiTestKeyStore.keys.clear() + } + } +} diff --git a/app/src/test/java/net/megaproxy487/ManifestSecurityTest.kt b/app/src/test/java/net/megaproxy487/ManifestSecurityTest.kt new file mode 100644 index 0000000..6341ee2 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/ManifestSecurityTest.kt @@ -0,0 +1,22 @@ +package net.megaproxy487 + +import java.io.File +import javax.xml.parsers.DocumentBuilderFactory +import org.junit.Assert.assertEquals +import org.junit.Test + +class ManifestSecurityTest { + @Test fun onlyLauncherActivityAcceptsExternalIntents() { + val factory = DocumentBuilderFactory.newInstance().apply { isNamespaceAware = true } + val document = factory.newDocumentBuilder().parse(File("src/main/AndroidManifest.xml")) + val activities = document.getElementsByTagName("activity") + val android = "http://schemas.android.com/apk/res/android" + val exported = (0 until activities.length).map { activities.item(it) as org.w3c.dom.Element } + .filter { it.getAttributeNS(android, "exported") == "true" } + .map { it.getAttributeNS(android, "name") } + assertEquals(listOf(".MainActivity"), exported) + val review = (0 until activities.length).map { activities.item(it) as org.w3c.dom.Element } + .single { it.getAttributeNS(android, "name") == ".SshHostKeyReviewActivity" } + assertEquals("false", review.getAttributeNS(android, "exported")) + } +} diff --git a/app/src/test/java/net/megaproxy487/NavigationUiTest.kt b/app/src/test/java/net/megaproxy487/NavigationUiTest.kt new file mode 100644 index 0000000..dc1ce76 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/NavigationUiTest.kt @@ -0,0 +1,54 @@ +package net.megaproxy487 + +import androidx.compose.ui.test.* +import net.megaproxy487.vpn.* +import org.junit.Test + +class NavigationUiTest : MainUiTestBase() { + @Test fun connectionTestMenuOpensDiagnosticAndBackReturnsHome() { + TestDiagnosticLog.begin() + content { MegaProxyNavHost(activity, readConnectionStats = { null }) } + icon(R.string.main_actions).performClick() + node(R.string.test_connection).performClick() + compose.onNodeWithTag("screen-connection-test").assertIsDisplayed() + node(R.string.run_again).assertIsNotEnabled() + icon(R.string.back).performClick() + compose.onNodeWithTag("screen-main").assertIsDisplayed() + } + + @Test fun settingsDestinationsOpenAndBackReturnsToSettings() { + content { MegaProxyNavHost(activity, readConnectionStats = { null }) } + icon(R.string.main_actions).performClick() + node(R.string.settings).performClick() + compose.onNodeWithTag("screen-settings").assertIsDisplayed() + for (destination in settingsDestinations) { + node(destination.titleRes).performScrollTo().performClick() + compose.onNodeWithTag("screen-${destination.route}").assertIsDisplayed() + icon(R.string.back).performClick() + compose.onNodeWithTag("screen-settings").assertIsDisplayed() + } + icon(R.string.back).performClick() + compose.onNodeWithTag("screen-main").assertIsDisplayed() + } + + @Test fun addProfileAndBackReturnToListWithoutCreatingEmptyDraft() { + content { MegaProxyNavHost(activity, readConnectionStats = { null }) } + icon(R.string.main_actions).performClick() + node(R.string.settings).performClick() + node(R.string.profiles).performClick() + node(R.string.add_profile).performClick() + compose.onNodeWithTag("screen-profile-editor").assertIsDisplayed() + icon(R.string.back).performClick() + compose.onNodeWithTag("screen-profiles").assertIsDisplayed() + compose.runOnIdle { org.junit.Assert.assertEquals(1, store.profiles().size) } + } + + @Test fun sshPromptAndExternalClearNeverLeaveEmptyDestination() { + content { MegaProxyNavHost(activity, readConnectionStats = { null }) } + compose.runOnIdle { SshHostKeyPromptState.show(PendingSshHostKey(store.activeProfileId(), "destination", "ssh-ed25519", "SHA256:test", false, false)) } + node(R.string.trust_and_connect).assertIsDisplayed() + compose.runOnIdle { SshHostKeyPromptState.clear() } + compose.onNodeWithTag("screen-main").assertIsDisplayed() + node(R.string.trust_and_connect).assertDoesNotExist() + } +} diff --git a/app/src/test/java/net/megaproxy487/ProfileEditorUiTest.kt b/app/src/test/java/net/megaproxy487/ProfileEditorUiTest.kt new file mode 100644 index 0000000..470d16a --- /dev/null +++ b/app/src/test/java/net/megaproxy487/ProfileEditorUiTest.kt @@ -0,0 +1,72 @@ +package net.megaproxy487 + +import androidx.compose.ui.test.* +import net.megaproxy487.model.ProxyType +import org.junit.Assert.* +import org.junit.Test + +class ProfileEditorUiTest : MainUiTestBase() { + private fun field(id: Int): SemanticsNodeInteraction { + compose.onNode(hasScrollToIndexAction()).performScrollToNode(hasText(text(id))) + return node(id) + } + @Test fun editsPersistButInvalidPortDoesNotReplaceSavedValue() { + val id = store.activeProfileId() + content { ProfileEditorScreen(activity, id, {}) } + field(R.string.profile_name_optional).performTextReplacement("Renamed") + field(R.string.port).performTextReplacement("70000") + saved() + assertEquals("Renamed", store.profile(id)!!.name) + assertEquals(443, store.profile(id)!!.config.port) + field(R.string.port).performTextReplacement("8443") + saved() + assertEquals(8443, store.profile(id)!!.config.port) + } + + @Test fun switchingProtocolShowsJumpFieldsAndUsesProtocolPort() { + val id = store.activeProfileId() + content { ProfileEditorScreen(activity, id, {}) } + field(R.string.profile_type).performClick() + node(ProxyType.SSH_JUMP.titleRes).performClick() + saved() + assertEquals(ProxyType.SSH_JUMP, store.profile(id)!!.config.type) + assertEquals(22, store.profile(id)!!.config.port) + field(R.string.jump_host).assertExists() + } + + @Test fun certificateBypassRequiresExplicitConfirmation() { + val id = store.activeProfileId() + content { ProfileEditorScreen(activity, id, {}) } + field(R.string.allow_proxy_certificate).performClick() + node(R.string.allow_untrusted_certificate_title).assertIsDisplayed() + node(R.string.cancel).performClick() + saved() + assertFalse(store.profile(id)!!.config.allowInvalidProxyCertificate) + field(R.string.allow_proxy_certificate).performClick() + node(R.string.ok).performClick() + saved() + assertTrue(store.profile(id)!!.config.allowInvalidProxyCertificate) + } + + @Test fun httpsJumpSettingsAreSavedSeparatelyFromDestination() { + val id = store.activeProfileId() + content { ProfileEditorScreen(activity, id, {}) } + field(R.string.profile_type).performClick() + node(ProxyType.HTTPS_JUMP.titleRes).performClick() + field(R.string.https_jump_hostname).performTextReplacement("jump.example") + saved() + assertEquals(ProxyType.HTTPS_JUMP, store.profile(id)!!.config.type) + assertEquals("proxy.example", store.profile(id)!!.config.host) + assertEquals("jump.example", store.profile(id)!!.config.jumpHost) + } + + @Test fun typingInNewDraftCreatesExactlyOneProfile() { + content { ProfileEditorScreen(activity, "new", {}) } + field(R.string.profile_name_optional).performTextReplacement("New profile") + saved() + field(R.string.https_proxy_hostname).performTextReplacement("new.example") + saved() + assertEquals(2, store.profiles().size) + assertEquals("new.example", store.profiles().single { it.name == "New profile" }.config.host) + } +} diff --git a/app/src/test/java/net/megaproxy487/ProfileTransferUiTest.kt b/app/src/test/java/net/megaproxy487/ProfileTransferUiTest.kt new file mode 100644 index 0000000..dc94ecc --- /dev/null +++ b/app/src/test/java/net/megaproxy487/ProfileTransferUiTest.kt @@ -0,0 +1,92 @@ +package net.megaproxy487 + +import android.app.Activity +import android.content.Intent +import android.net.Uri +import androidx.compose.ui.test.* +import org.junit.Assert.* +import org.junit.Test +import org.robolectric.Shadows.shadowOf +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream + +class ProfileTransferUiTest : MainUiTestBase() { + private fun screen() { + content { ProfilesScreen(activity, {}, {}) } + waitForText("Primary") + compose.waitUntil(10_000) { !icon(R.string.import_action).fetchSemanticsNode().config.contains(androidx.compose.ui.semantics.SemanticsProperties.Disabled) } + } + private fun documentResult(uri: Uri) { + var request: Intent? = null + compose.waitUntil(10_000) { request = shadowOf(activity).nextStartedActivityForResult?.intent; request != null } + compose.runOnIdle { shadowOf(activity).receiveResult(request!!, Activity.RESULT_OK, Intent().setData(uri)) } + } + + @Test fun cloneCreatesIndependentProfile() { + screen() + node(R.string.clone).performClick() + compose.waitUntil(10_000) { store.profiles().size == 2 } + val profiles = store.profiles() + assertNotEquals(profiles[0].id, profiles[1].id) + assertEquals(profiles[0].config, profiles[1].config) + } + + @Test fun deleteCanBeCancelledAndCannotRemoveLastProfile() { + screen() + node(R.string.delete).performClick() + node(R.string.cancel).performClick() + assertEquals(1, store.profiles().size) + node(R.string.delete).performClick() + compose.onNode(hasText(text(R.string.delete)) and hasAnyAncestor(isDialog())).assertIsNotEnabled() + assertEquals(1, store.profiles().size) + } + + @Test fun deletingActiveProfileSelectsRemainingProfile() { + val original = store.activeProfileId() + val remaining = store.cloneProfile(original)!! + screen() + compose.onAllNodesWithText(text(R.string.delete))[0].performClick() + compose.onNode(hasText(text(R.string.delete)) and hasAnyAncestor(isDialog())) + .assertIsEnabled().performClick() + compose.waitUntil(10_000) { store.profiles().size == 1 } + assertNull(store.profile(original)) + assertEquals(remaining.id, store.activeProfileId()) + waitForText(remaining.name) + } + + @Test fun importReadsPickedFileAndAddsProfile() { + val uri = Uri.parse("content://ui-test/proxies.txt") + shadowOf(activity.contentResolver).registerInputStream(uri, ByteArrayInputStream("https://test:secret@import.example:443?title=Imported".toByteArray())) + screen() + icon(R.string.import_action).performClick() + documentResult(uri) + compose.waitUntil(10_000) { store.profiles().size == 2 } + assertEquals("import.example", store.profiles().single { it.config.host == "import.example" }.config.host) + } + + @Test fun malformedImportShowsErrorWithoutChangingProfiles() { + val uri = Uri.parse("content://ui-test/broken.json") + shadowOf(activity.contentResolver).registerInputStream(uri, ByteArrayInputStream("{broken".toByteArray())) + screen() + icon(R.string.import_action).performClick() + documentResult(uri) + waitForText(text(R.string.configuration_transfer)) + node(R.string.ok).performClick() + assertEquals(1, store.profiles().size) + } + + @Test fun defaultJsonExportOmitsSecrets() { + val uri = Uri.parse("content://ui-test/export.json") + val output = ByteArrayOutputStream() + shadowOf(activity.contentResolver).registerOutputStream(uri, output) + screen() + icon(R.string.export_action).performClick() + node(R.string.passwords_omitted_message).assertIsDisplayed() + node(R.string.export_action).performClick() + documentResult(uri) + waitForText(text(R.string.configuration_exported)) + val exported = output.toString("UTF-8") + assertTrue(exported.contains("proxy.example")) + assertFalse(exported.contains("test-password")) + } +} diff --git a/app/src/test/java/net/megaproxy487/SettingsUiTest.kt b/app/src/test/java/net/megaproxy487/SettingsUiTest.kt new file mode 100644 index 0000000..0902f3f --- /dev/null +++ b/app/src/test/java/net/megaproxy487/SettingsUiTest.kt @@ -0,0 +1,49 @@ +package net.megaproxy487 + +import androidx.compose.ui.test.* +import net.megaproxy487.model.FailoverMode +import net.megaproxy487.model.TlsProfile +import org.junit.Assert.* +import org.junit.Test + +class SettingsUiTest : MainUiTestBase() { + @Test fun trafficUnitsSelectionIsPersisted() { + content { SettingsHomeScreen(activity, {}, {}) } + node(R.string.traffic_units).performScrollTo().performClick() + node(R.string.traffic_units_si).performClick() + assertEquals(TrafficUnitSystem.SI, TrafficUnitPreferences.current(activity)) + node(R.string.traffic_units).performScrollTo().performClick() + node(R.string.traffic_units_iec).performClick() + assertEquals(TrafficUnitSystem.IEC, TrafficUnitPreferences.current(activity)) + } + + @Test fun globalFailoverIsSavedOnlyAfterConfirmation() { + content { FailoverSettingsScreen(activity, {}) } + node(R.string.failover_mode).performClick() + node(FailoverMode.ALL.titleRes).performClick() + node(R.string.cancel).performClick() + saved() + assertEquals(FailoverMode.DISABLED, store.globalConnectionSettings().failoverMode) + node(R.string.failover_mode).performClick() + node(FailoverMode.ALL.titleRes).performClick() + node(R.string.enable).performClick() + saved() + assertEquals(FailoverMode.ALL, store.globalConnectionSettings().failoverMode) + } + + @Test fun fingerprintChoicePersists() { + content { TlsFingerprintScreen(activity, {}) } + node(R.string.https_tls_ja3_profile).performScrollTo().performClick() + node(TlsProfile.FIREFOX_ANDROID.titleRes).performClick() + saved() + assertEquals(TlsProfile.FIREFOX_ANDROID, store.globalConnectionSettings().tlsProfile) + } + + @Test fun selectedAppsModePersistsWithoutSelectingAllApplications() { + content { SplitTunnelScreen(activity, {}) } + node(R.string.selected_apps_routing_description).performClick() + saved() + assertFalse(store.globalConnectionSettings().routeAllApps) + assertTrue(store.globalConnectionSettings().selectedPackages.isEmpty()) + } +} diff --git a/app/src/test/java/net/megaproxy487/SshHostKeyUiTest.kt b/app/src/test/java/net/megaproxy487/SshHostKeyUiTest.kt new file mode 100644 index 0000000..441f232 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/SshHostKeyUiTest.kt @@ -0,0 +1,105 @@ +package net.megaproxy487 + +import android.app.Application +import androidx.activity.ComponentActivity +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsEnabled +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import kotlinx.coroutines.CompletableDeferred +import net.megaproxy487.vpn.PendingSshHostKey +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode + +/** Real Compose UI on the JVM; no VPN, JNI, Keystore or external services are started. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class, qualifiers = "en") +@LooperMode(LooperMode.Mode.PAUSED) +class SshHostKeyUiTest { + @get:Rule val compose = createAndroidComposeRule() + private val prompt = PendingSshHostKey("profile", "destination", "ssh-ed25519", "SHA256:test", false, false) + private fun text(id: Int) = compose.activity.uiText(id) + + @Test fun successfulConfirmationSavesOnceAndDismisses() { + var saves = 0 + var dismissals = 0 + compose.setContent { + MaterialTheme { + SshHostKeyReview(prompt, { saves++; true }, { error("unexpected rejection") }, { dismissals++ }) + } + } + compose.onNodeWithText(text(R.string.trust_and_connect)).performClick() + compose.runOnIdle { + assertEquals(1, saves) + assertEquals(1, dismissals) + } + } + + @Test fun savingDisablesActionsAndBackUntilOperationFinishes() { + val result = CompletableDeferred() + var saves = 0 + var dismissals = 0 + compose.setContent { + MaterialTheme { + SshHostKeyReview(prompt, { saves++; result.await() }, { error("unexpected rejection") }, { dismissals++ }) + } + } + compose.onNodeWithText(text(R.string.trust_and_connect)).performClick() + compose.onNodeWithText(text(R.string.trust_and_connect)).assertIsNotEnabled() + compose.onNodeWithText(text(R.string.cancel)).assertIsNotEnabled() + compose.onNodeWithText(text(R.string.saving_changes)).assertIsDisplayed() + compose.runOnIdle { compose.activity.onBackPressedDispatcher.onBackPressed() } + compose.runOnIdle { + assertEquals(1, saves) + assertEquals(0, dismissals) + result.complete(true) + } + compose.runOnIdle { assertEquals(1, dismissals) } + } + + @Test fun failureShowsErrorAndAllowsRetry() { + var saves = 0 + var dismissals = 0 + compose.setContent { + MaterialTheme { + SshHostKeyReview(prompt, { + if (++saves == 1) throw java.io.IOException("storage unavailable") + true + }, {}, { dismissals++ }) + } + } + compose.onNodeWithText(text(R.string.trust_and_connect)).performClick() + compose.onNodeWithText(text(R.string.ssh_key_save_failed)).assertIsDisplayed() + compose.onNodeWithText(text(R.string.trust_and_connect)).assertIsEnabled().performClick() + compose.onNodeWithText(text(R.string.ssh_key_save_failed)).assertDoesNotExist() + compose.runOnIdle { + assertEquals(2, saves) + assertEquals(1, dismissals) + } + } + + @Test fun cancelledTestDoesNotSaveAndUsesTestSpecificAction() { + var rejected = 0 + var dismissed = 0 + compose.setContent { + MaterialTheme { + SshHostKeyReview(prompt.copy(testOnly = true, changed = true), + { error("cancel must not save") }, { rejected++ }, { dismissed++ }) + } + } + compose.onNodeWithText(text(R.string.replace_and_test)).assertIsDisplayed() + compose.onNodeWithText(text(R.string.cancel)).performClick() + compose.runOnIdle { + assertEquals(1, rejected) + assertEquals(1, dismissed) + } + } +} diff --git a/app/src/test/java/net/megaproxy487/TrafficFormattingTest.kt b/app/src/test/java/net/megaproxy487/TrafficFormattingTest.kt index cc7b44f..a6b900e 100644 --- a/app/src/test/java/net/megaproxy487/TrafficFormattingTest.kt +++ b/app/src/test/java/net/megaproxy487/TrafficFormattingTest.kt @@ -5,6 +5,12 @@ import org.junit.Assert.assertEquals import org.junit.Test class TrafficFormattingTest { + @org.junit.Test fun slowSamplingAndCounterResetDoNotInflateRates() { + org.junit.Assert.assertEquals(1024.0, sampledTrafficRate(4096, 1024, 3000), 0.001) + org.junit.Assert.assertEquals(0.0, sampledTrafficRate(0, 4096, 1000), 0.001) + org.junit.Assert.assertEquals(0.0, sampledTrafficRate(4096, 1024, 0), 0.001) + } + @Test fun formatsByteTotalsUsingIecUnitsByDefault() { assertEquals("0 B", formatTrafficBytes(0, locale = Locale.US)) diff --git a/app/src/test/java/net/megaproxy487/UiTestKeyStore.kt b/app/src/test/java/net/megaproxy487/UiTestKeyStore.kt new file mode 100644 index 0000000..960a3b2 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/UiTestKeyStore.kt @@ -0,0 +1,56 @@ +package net.megaproxy487 + +import android.security.keystore.KeyGenParameterSpec +import java.io.InputStream +import java.io.OutputStream +import java.security.Key +import java.security.KeyStoreSpi +import java.security.Provider +import java.security.SecureRandom +import java.security.cert.Certificate +import java.security.spec.AlgorithmParameterSpec +import java.util.Collections +import java.util.Date +import javax.crypto.KeyGenerator +import javax.crypto.KeyGeneratorSpi +import javax.crypto.SecretKey + +/** Test-only JCA provider. Production ConfigStore still performs its normal encryption/serialization. */ +class UiTestKeyStoreProvider : Provider("AndroidKeyStore", 1.0, "In-memory UI test keys") { + init { + put("KeyStore.AndroidKeyStore", UiTestKeyStore::class.java.name) + put("KeyGenerator.AES", UiTestKeyGenerator::class.java.name) + } +} + +class UiTestKeyStore : KeyStoreSpi() { + companion object { val keys = java.util.concurrent.ConcurrentHashMap() } + override fun engineGetKey(alias: String, password: CharArray?) = keys[alias] + override fun engineGetCertificateChain(alias: String): Array? = null + override fun engineGetCertificate(alias: String): Certificate? = null + override fun engineGetCreationDate(alias: String) = Date(0) + override fun engineSetKeyEntry(alias: String, key: Key, password: CharArray?, chain: Array?) { keys[alias] = key } + override fun engineSetKeyEntry(alias: String, key: ByteArray, chain: Array?) { error("unsupported") } + override fun engineSetCertificateEntry(alias: String, cert: Certificate) { error("unsupported") } + override fun engineDeleteEntry(alias: String) { keys.remove(alias) } + override fun engineAliases() = Collections.enumeration(keys.keys) + override fun engineContainsAlias(alias: String) = keys.containsKey(alias) + override fun engineSize() = keys.size + override fun engineIsKeyEntry(alias: String) = keys.containsKey(alias) + override fun engineIsCertificateEntry(alias: String) = false + override fun engineGetCertificateAlias(cert: Certificate): String? = null + override fun engineStore(stream: OutputStream?, password: CharArray?) = Unit + override fun engineLoad(stream: InputStream?, password: CharArray?) = Unit +} + +class UiTestKeyGenerator : KeyGeneratorSpi() { + private lateinit var alias: String + override fun engineInit(random: SecureRandom?) = Unit + override fun engineInit(keysize: Int, random: SecureRandom?) = Unit + override fun engineInit(params: AlgorithmParameterSpec, random: SecureRandom?) { + alias = (params as KeyGenParameterSpec).keystoreAlias + } + override fun engineGenerateKey(): SecretKey = KeyGenerator.getInstance("AES").apply { init(256) }.generateKey().also { + UiTestKeyStore.keys[alias] = it + } +} diff --git a/app/src/test/java/net/megaproxy487/data/BoundedJsonTest.kt b/app/src/test/java/net/megaproxy487/data/BoundedJsonTest.kt new file mode 100644 index 0000000..f1c1eb3 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/data/BoundedJsonTest.kt @@ -0,0 +1,39 @@ +package net.megaproxy487.data + +import net.megaproxy487.R +import net.megaproxy487.UiException +import org.junit.Assert.* +import org.junit.Test + +class BoundedJsonTest { + @Test fun deeplyNestedImportIsRejectedBeforeRecursiveParsing() { + val text = "{\"data\":" + "[".repeat(20_000) + "0" + "]".repeat(20_000) + "}" + val error = assertThrows(UiException::class.java) { boundedJsonObject(text) } + assertEquals(R.string.error_config_complex, error.textId) + assertTrue(FoxyProxyParser.parse(text).exceptionOrNull() is UiException) + assertThrows(UiException::class.java) { ConfigTransfer.importJson(text) } + } + + @Test fun wideJsonIsRejectedBeforeAllocatingItsTree() { + val text = "{\"ignored\":[" + "{},".repeat(150_000) + "{}]}" + assertThrows(UiException::class.java) { boundedJsonObject(text) } + } + + @Test fun directParserCallsAlsoHaveSizeLimit() { + val text = " ".repeat(MAX_CONFIG_FILE_BYTES + 1) + assertEquals(R.string.error_config_large, + assertThrows(UiException::class.java) { boundedJsonObject(text) }.textId) + } + + @Test fun delimitersAndEscapedQuotesInsideStringsDoNotCount() { + val value = "[{}],:#/'\"\\".repeat(200) + val text = org.json.JSONObject().put("value", value).toString() + assertEquals(value, boundedJsonObject(text).getString("value")) + } + + @Test fun lenientSyntaxCannotHideNestingFromGuard() { + for (text in listOf("{\"key\":[0;0;0]}", "{\"key\"=0}", "{'key':[]}", "{/* comment */\"key\":[]}", "{# comment\n\"key\":[]}")) { + assertThrows(UiException::class.java) { boundedJsonObject(text) } + } + } +} diff --git a/app/src/test/java/net/megaproxy487/data/ConfigStoreIntegrationTest.kt b/app/src/test/java/net/megaproxy487/data/ConfigStoreIntegrationTest.kt new file mode 100644 index 0000000..7dd991f --- /dev/null +++ b/app/src/test/java/net/megaproxy487/data/ConfigStoreIntegrationTest.kt @@ -0,0 +1,139 @@ +package net.megaproxy487.data + +import android.app.Application +import android.content.Context +import net.megaproxy487.UiTestKeyStore +import net.megaproxy487.UiTestKeyStoreProvider +import net.megaproxy487.model.FailoverMode +import net.megaproxy487.model.GlobalConnectionSettings +import net.megaproxy487.model.ProxyProfile +import net.megaproxy487.model.ProxyType +import org.json.JSONArray +import org.json.JSONObject +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.security.Security + +/** Real persistence/crypto with synthetic keys; no Compose, JNI or device Keystore. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class) +class ConfigStoreIntegrationTest { + private val context get() = RuntimeEnvironment.getApplication() + private val store get() = ConfigStore(context) + private val prefs get() = context.getSharedPreferences("proxy_config", Context.MODE_PRIVATE) + + @Before fun installKeys() { + Security.removeProvider("AndroidKeyStore") + UiTestKeyStore.keys.clear() + Security.addProvider(UiTestKeyStoreProvider()) + } + + @After fun removeKeys() { + Security.removeProvider("AndroidKeyStore") + UiTestKeyStore.keys.clear() + } + + private fun secretProfile(): ProxyProfile = store.activeProfile().let { + it.copy(config = it.config.copy( + type = ProxyType.SSH_JUMP, port = 22, jumpPort = 22, + jumpHost = "jump.example", sameJumpAuthentication = false, + host = "exit.example", password = "destination-secret", + privateKey = "destination-private-key", jumpPassword = "jump-secret", + jumpPrivateKey = "jump-private-key", + )) + }.also(store::saveProfile) + + private fun importProfile(profile: JSONObject) = store.importConfiguration( + ConfigTransfer.importJson(JSONObject() + .put("schema", ConfigTransfer.SCHEMA_ID) + .put("version", ConfigTransfer.SCHEMA_VERSION) + .put("profiles", JSONArray().put(profile)).toString()), + ) + + @Test fun allCredentialFieldsSurviveReopeningAndAreEncryptedAtRest() { + val original = secretProfile() + assertEquals(original, store.profile(original.id)) + val raw = prefs.getString("profiles_v2", null)!! + for (secret in listOf(original.config.password, original.config.privateKey, + original.config.jumpPassword, original.config.jumpPrivateKey)) { + assertFalse("Plaintext credential in preferences", raw.contains(secret)) + } + store.saveProfile(original) + assertNotEquals("AES-GCM must use fresh IVs", raw, prefs.getString("profiles_v2", null)) + assertEquals(original, store.profile(original.id)) + } + + @Test fun importWithoutSecretsPreservesAllExistingCredentials() { + val original = secretProfile() + val updated = original.copy(name = "Updated") + val result = importProfile(ConfigTransfer.encodeProfile(updated, false, false)) + assertEquals(listOf(original.id), result.updated.map { it.id }) + assertEquals(updated, store.profile(original.id)) + } + + @Test fun explicitlyEmptyImportedSecretsClearAllExistingCredentials() { + val original = secretProfile() + val empty = original.copy(config = original.config.copy( + password = "", privateKey = "", jumpPassword = "", jumpPrivateKey = "", + )) + importProfile(ConfigTransfer.encodeProfile(empty, true, true)) + assertEquals(empty, store.profile(original.id)) + } + + @Test fun deletedProfileRepairsAllReferencesAndFailoverState() { + val original = secretProfile() + val remaining = store.cloneProfile(original.id)!! + store.setActiveProfile(original.id) + store.setAlwaysOnProfile(original.id) + store.setConnectionProfile(original.id) + store.saveGlobalConnectionSettings(GlobalConnectionSettings( + failoverMode = FailoverMode.SELECTED, failoverProfileIds = listOf(original.id, remaining.id), + )) + store.setFailoverState(true, "old failure") + assertTrue(store.deleteProfile(original.id)) + assertEquals(remaining.id, store.activeProfileId()) + assertEquals(remaining.id, store.alwaysOnProfileId()) + assertEquals(remaining.id, store.connectionProfileId()) + assertEquals(listOf(remaining.id), store.globalConnectionSettings().failoverProfileIds) + assertFalse(store.isFailoverActive()) + assertNull(store.failoverNotice()) + assertFalse(store.deleteProfile(remaining.id)) + } + + @Test fun staleReconnectCompletionCannotClearNewRequest() { + store.markPendingReconnect() + val old = store.pendingReconnectToken() + store.markPendingReconnect() + val current = store.pendingReconnectToken() + assertNotNull(current) + assertNotEquals(old, current) + store.clearPendingReconnect(old) + store.clearPendingReconnect(null) + assertEquals(current, store.pendingReconnectToken()) + store.clearPendingReconnect(current) + assertFalse(store.hasPendingReconnect()) + } + + @Test fun trustingJumpKeyDoesNotChangeDestinationOrAnotherProfile() { + val original = secretProfile().let { it.copy(config = it.config.copy( + trustedHostKey = "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAA", + jumpAcceptAnyHostKey = true, + )) }.also(store::saveProfile) + val other = store.cloneProfile(original.id)!! + val pin = "SHA256:BBBBBBBBBBBBBBBBBBBBBBBBBBBB" + assertTrue(store.trustSshHostKey(original.id, "jump", pin)) + assertEquals(original.copy(config = original.config.copy( + jumpTrustedHostKey = pin, jumpAcceptAnyHostKey = false, + )), store.profile(original.id)) + assertEquals(other, store.profile(other.id)) + assertFalse(store.trustSshHostKey(original.id, "jump", "invalid")) + assertFalse(store.trustSshHostKey("deleted", "jump", pin)) + assertEquals(pin, store.profile(original.id)!!.config.jumpTrustedHostKey) + } +} diff --git a/app/src/test/java/net/megaproxy487/data/ConfigTransferTest.kt b/app/src/test/java/net/megaproxy487/data/ConfigTransferTest.kt index 6c1a63b..2d1c4d8 100644 --- a/app/src/test/java/net/megaproxy487/data/ConfigTransferTest.kt +++ b/app/src/test/java/net/megaproxy487/data/ConfigTransferTest.kt @@ -48,6 +48,14 @@ class ConfigTransferTest { assertFalse(config.jumpAllowInvalidProxyCertificate) } + @Test + fun `SSH imports use the SSH default port`() { + val raw = """{"schema":"net.megaproxy487.config","version":8,"profiles":[ + {"id":"ssh","proxy":{"type":"SSH","host":"ssh.example"}} + ]}""" + assertEquals(22, ConfigTransfer.importJson(raw).profiles.single().config.port) + } + @Test fun `proxy list omits passwords by default`() { val profile = ProxyProfile( diff --git a/app/src/test/java/net/megaproxy487/data/ConfigWritesTest.kt b/app/src/test/java/net/megaproxy487/data/ConfigWritesTest.kt index 94dc274..4d86501 100644 --- a/app/src/test/java/net/megaproxy487/data/ConfigWritesTest.kt +++ b/app/src/test/java/net/megaproxy487/data/ConfigWritesTest.kt @@ -12,6 +12,51 @@ class ConfigWritesTest { fun drain() { while (tasks.isNotEmpty()) tasks.removeFirst().run() } } + @Test fun retryWhileWritesArePendingDoesNotDuplicateWork() { + val dispatcher = ManualDispatcher() + val queue = ConfigWriteQueue(dispatcher) + var attempts = 0 + queue.submit("profile") { attempts++; error("disk unavailable") } + dispatcher.drain() + queue.retry() + queue.retry() + assertEquals(1, queue.status.value.pending) + dispatcher.drain() + assertEquals(2, attempts) + assertEquals(ConfigWriteStatus(failed = true), queue.status.value) + } + + @Test fun deletingBeforeInitialWritePreventsDraftResurrection() { + val dispatcher = ManualDispatcher() + val queue = ConfigWriteQueue(dispatcher) + var writes = 0 + queue.submit("profile:deleted") { writes++ } + queue.discard("profile:deleted") + dispatcher.drain() + assertEquals(0, writes) + assertEquals(ConfigWriteStatus(), queue.status.value) + queue.submit("profile:deleted") { writes++ } + dispatcher.drain() + assertEquals(1, writes) + } + + @Test fun successfulWriteForOtherProfileDoesNotHideFailure() { + val dispatcher = ManualDispatcher() + val queue = ConfigWriteQueue(dispatcher) + var fail = true + var writes = 0 + queue.submit("first") { if (fail) error("disk unavailable") else writes++ } + queue.submit("second") { writes++ } + dispatcher.drain() + assertEquals(1, writes) + assertEquals(ConfigWriteStatus(failed = true), queue.status.value) + fail = false + queue.retry() + dispatcher.drain() + assertEquals(2, writes) + assertEquals(ConfigWriteStatus(), queue.status.value) + } + @Test fun queuedWritesSurviveCallerCancellationAndKeepOrder() { val dispatcher = ManualDispatcher() val queue = ConfigWriteQueue(dispatcher) diff --git a/app/src/test/java/net/megaproxy487/data/OperationResultTest.kt b/app/src/test/java/net/megaproxy487/data/OperationResultTest.kt new file mode 100644 index 0000000..3e830ae --- /dev/null +++ b/app/src/test/java/net/megaproxy487/data/OperationResultTest.kt @@ -0,0 +1,23 @@ +package net.megaproxy487.data + +import java.io.IOException +import java.util.concurrent.CancellationException +import org.junit.Assert.* +import org.junit.Test + +class OperationResultTest { + @Test fun expectedFailureRemainsAvailableForRetry() { + val failure = IOException("disk full") + assertSame(failure, operationResult { throw failure }.exceptionOrNull()) + assertEquals("saved", operationResult { "saved" }.getOrThrow()) + } + + @Test fun vmFailureAndCancellationCannotBecomeDefaultConfiguration() { + for (failure in listOf(OutOfMemoryError("synthetic"), StackOverflowError(), CancellationException())) { + val thrown = assertThrows(failure.javaClass) { + operationResult { throw failure }.getOrDefault("empty configuration") + } + assertSame(failure, thrown) + } + } +} diff --git a/app/src/test/java/net/megaproxy487/data/ProfileMergeTest.kt b/app/src/test/java/net/megaproxy487/data/ProfileMergeTest.kt new file mode 100644 index 0000000..4d49e89 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/data/ProfileMergeTest.kt @@ -0,0 +1,22 @@ +package net.megaproxy487.data + +import net.megaproxy487.model.ProxyProfile +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Test + +class ProfileMergeTest { + @Test fun partialImportPreservesLocalOrderAndUnchangedProfiles() { + val existing = List(1000) { ProxyProfile(id = "$it", name = "local-$it", colorIndex = 0) } + val updates = existing.filterIndexed { index, _ -> index % 2 == 0 } + .reversed().map { it.copy(name = "imported-${it.id}") } + val added = listOf(ProxyProfile(id = "new", colorIndex = 1)) + val merged = mergeResolvedProfiles(existing, updates + added, added) + assertEquals(existing.map { it.id } + "new", merged.map { it.id }) + existing.indices.forEach { index -> + if (index % 2 == 0) assertEquals("imported-$index", merged[index].name) + else assertSame(existing[index], merged[index]) + } + assertSame(added.single(), merged.last()) + } +} diff --git a/app/src/test/java/net/megaproxy487/model/Ja3SpecTest.kt b/app/src/test/java/net/megaproxy487/model/Ja3SpecTest.kt index 9663da8..e29cf2e 100644 --- a/app/src/test/java/net/megaproxy487/model/Ja3SpecTest.kt +++ b/app/src/test/java/net/megaproxy487/model/Ja3SpecTest.kt @@ -11,6 +11,15 @@ class Ja3SpecTest { assertEquals(listOf(4865, 4866), spec.cipherSuites) } + @Test fun rejectsValuesRejectedByNativeParser() { + listOf( + "-1,4865,0,29,0", "770,4865,0,29,0", "773,4865,0,29,0", + "771,,0,29,0", "771,4865,0,29,256", "771,+4865,0,29,0", + "771,4865, ,29,0", "771," + List(257) { "4865" }.joinToString("-") + ",0,29,0", + "771,4865,0,29,0" + " ".repeat(8192), + ).forEach { assertNull(it, Ja3Spec.parse(it)) } + } + @Test fun rejectsMalformedJa3() { assertNull(Ja3Spec.parse("771,4865,0")) assertNull(Ja3Spec.parse("771,70000,0,29,0")) diff --git a/app/src/test/java/net/megaproxy487/model/ProxyConfigTest.kt b/app/src/test/java/net/megaproxy487/model/ProxyConfigTest.kt index cc2f5e2..0fc0c6c 100644 --- a/app/src/test/java/net/megaproxy487/model/ProxyConfigTest.kt +++ b/app/src/test/java/net/megaproxy487/model/ProxyConfigTest.kt @@ -46,6 +46,18 @@ class ProxyConfigTest { assertEquals(setOf(ProxyType.SSH_JUMP, ProxyType.HTTPS_JUMP), ProxyType.entries.filter { it.hasJump }.toSet()) } + @Test + fun customDnsUrlValidationMatchesNativeRequirements() { + for (url in listOf("https://dns.example/", "https://dns.example:8443/query?mode=1")) { + assertNull(validConnection.copy(dnsProvider = DnsProvider.CUSTOM, customDohUrl = url).validationError()) + } + for (url in listOf("http://dns.example/query", "https://user:secret@dns.example/query", + "https://dns.example/query#fragment", "https://dns.example:65536/query", "https://dns.example")) { + assertEquals(url, R.string.validation_doh_url, + validConnection.copy(dnsProvider = DnsProvider.CUSTOM, customDohUrl = url).validationError()) + } + } + @Test fun splitTunnelingAllowsNoApplications() { assertNull(validConnection.copy(routeAllApps = false).validationError()) diff --git a/app/src/test/java/net/megaproxy487/vpn/ConnectionTestTargetTest.kt b/app/src/test/java/net/megaproxy487/vpn/ConnectionTestTargetTest.kt new file mode 100644 index 0000000..4511e5d --- /dev/null +++ b/app/src/test/java/net/megaproxy487/vpn/ConnectionTestTargetTest.kt @@ -0,0 +1,40 @@ +package net.megaproxy487.vpn + +import net.megaproxy487.model.ProxyConfig +import net.megaproxy487.model.ProxyProfile +import net.megaproxy487.model.ProxyType +import org.junit.Assert.* +import org.junit.Test + +class ConnectionTestTargetTest { + @Test fun failoverTestKeepsRuntimeConfigAndTrustDestinationTogether() { + val runtime = ConnectionTestTarget("fallback", ProxyConfig(host = "fallback.example")) + val target = connectionTestTarget(runtime) { error("selected draft must not be read") } + assertSame(runtime, target) + assertEquals("fallback", target.profileId) + assertEquals("fallback.example", target.config.host) + } + + @Test fun disconnectedTestCapturesSelectedProfileOnce() { + var reads = 0 + val selected = ConnectionTestTarget("selected", ProxyConfig(host = "selected.example")) + assertSame(selected, connectionTestTarget(null) { reads++; selected }) + assertEquals(1, reads) + } + + @Test fun approvingRuntimeHostKeyDoesNotKeepRetestingOldPinOrApplyDraftPassword() { + val config = ProxyConfig(type = ProxyType.SSH, host = "ssh.example", port = 22, + password = "runtime", trustedHostKey = "old") + val target = ConnectionTestTarget("runtime", config) + val stored = ProxyProfile(id = "runtime", colorIndex = 0, config = config.copy(password = "draft", trustedHostKey = "approved")) + assertEquals(config.copy(trustedHostKey = "approved"), target.withStoredTrust(stored).config) + assertEquals(config, target.withStoredTrust(stored.copy(id = "selected")).config) + } + + @Test fun editedHostCannotSupplyTrustForRuntimeEndpoint() { + val config = ProxyConfig(type = ProxyType.SSH, host = "old.example", trustedHostKey = "old") + val target = ConnectionTestTarget("runtime", config) + val stored = ProxyProfile(id = "runtime", colorIndex = 0, config = config.copy(host = "new.example", trustedHostKey = "new")) + assertEquals(config, target.withStoredTrust(stored).config) + } +} diff --git a/app/src/test/java/net/megaproxy487/vpn/DiagnosticIoTest.kt b/app/src/test/java/net/megaproxy487/vpn/DiagnosticIoTest.kt new file mode 100644 index 0000000..40f2270 --- /dev/null +++ b/app/src/test/java/net/megaproxy487/vpn/DiagnosticIoTest.kt @@ -0,0 +1,21 @@ +package net.megaproxy487.vpn + +import java.io.IOException +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class DiagnosticIoTest { + @Test fun storageFailuresDoNotEscapeAndLaterWritesCanSucceed() { + assertFalse(runDiagnosticIo { throw IOException("disk full") }) + assertFalse(runDiagnosticIo { throw SecurityException("access denied") }) + var written = false + assertTrue(runDiagnosticIo { written = true }) + assertTrue(written) + } + + @Test(expected = IllegalStateException::class) + fun programmingErrorsAreNotHidden() { + runDiagnosticIo { error("unexpected state") } + } +} diff --git a/app/src/test/java/net/megaproxy487/vpn/NativeCallbackTest.kt b/app/src/test/java/net/megaproxy487/vpn/NativeCallbackTest.kt new file mode 100644 index 0000000..10c4a2b --- /dev/null +++ b/app/src/test/java/net/megaproxy487/vpn/NativeCallbackTest.kt @@ -0,0 +1,56 @@ +package net.megaproxy487.vpn + +import org.junit.Assert.* +import org.junit.Test + +class NativeCallbackTest { + @Test fun protectorFailsClosedAndReportsExceptions() { + val failure = SecurityException("denied") + val failures = mutableListOf() + val protector = BridgeProtector({ true }, { throw failure }, failures::add) + assertFalse(protector.protect(42L)) + assertEquals(listOf(failure), failures) + } + + @Test fun descriptorRangeIsCheckedBeforeNarrowingLongToInt() { + val calls = mutableListOf() + val protector = BridgeProtector({ true }, { calls += it; true }, { throw it }) + assertFalse(protector.protect(-1L)) + assertFalse(protector.protect(1L shl 32)) + assertTrue(protector.protect(0L)) + assertTrue(protector.protect(Int.MAX_VALUE.toLong())) + assertEquals(listOf(0, Int.MAX_VALUE), calls) + } + + @Test fun stoppedCallbacksCannotProtectOrPublish() { + var enabled = true + var calls = 0 + val protector = BridgeProtector({ enabled }, { calls++; true }, { throw it }) + val reporter = BridgeReporter({ enabled }, { calls++ }, { throw it }) + reporter.report("active") + assertTrue(protector.protect(1)) + enabled = false + reporter.report("late") + assertFalse(protector.protect(1)) + assertEquals(2, calls) + } + + @Test fun reportingFailureDoesNotEscapeAndNextEventStillWorks() { + var attempts = 0 + val failures = mutableListOf() + val reporter = BridgeReporter({ true }, { + if (attempts++ == 0) throw IllegalStateException("notification unavailable") + }, failures::add) + reporter.report("first") + reporter.report("second") + assertEquals(2, attempts) + assertEquals(1, failures.size) + assertEquals(reporter, reporter) + assertEquals(System.identityHashCode(reporter), reporter.hashCode()) + } + + @Test fun fatalErrorsAreNotDisguisedAsSuccessfulCallbacks() { + val reporter = BridgeReporter({ true }, { throw OutOfMemoryError("synthetic") }, { throw it }) + assertThrows(OutOfMemoryError::class.java) { reporter.report("event") } + } +} diff --git a/docs/en/fastlane.md b/docs/en/fastlane.md index 7c3067a..fae3313 100644 --- a/docs/en/fastlane.md +++ b/docs/en/fastlane.md @@ -16,6 +16,8 @@ including JDK 21, Go, the Android SDK, and the Android NDK. Then install Ruby 3. `.ruby-version`. A Ruby version manager is recommended; do not depend on the old system Ruby included with macOS. +Native and release build scripts discover JDK 21 from `JAVA_HOME`, macOS `java_home`, or `java` on `PATH`. An explicitly configured incompatible JDK fails early; no Homebrew installation path is assumed. + Install a current Bundler and the repository-pinned Fastlane dependency from the project root: ```shell @@ -35,6 +37,10 @@ That command lists the lanes available in the checked-out version of the project | Command | Result | | --- | --- | +| `bundle exec fastlane android python_format` | Formats Python scripts with pinned Black and isort. | +| `bundle exec fastlane android python_tests` | Runs Python unit tests. | +| `bundle exec fastlane android python_checks` | Checks Python formatting/import order and runs unit tests. | +| `bundle exec fastlane android native_fuzz` | Fuzzes native parsers for 20 seconds with two workers. | | `bundle exec fastlane android native_tests` | Runs all Go tests 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 test` | Runs `native_tests` and `android_checks`; this is the normal pre-commit command. | @@ -81,10 +87,11 @@ suite. Failed, cancelled and skipped jobs do not count as successful coverage. C belong to the same PR and repository, use the same recorded PR base and precede the current run. Rebased-away commits are ignored. The history search examines the latest 30 completed CI runs on the branch through gh; missing history, API errors and old runs without a recorded base fall back -to the full PR diff. Pushes to main compare push endpoints. Each suite's baseline and decision are +to the full PR diff. Every push to main runs all suites without diff/history filtering; the README badge explicitly tracks +`badge.svg?branch=main&event=push`. Each suite's baseline and decision are shown in the Actions summary. Reruns exclude their own run ID from baseline selection. -Python-only changes run Python checks; documentation-only changes skip test jobs. Native production +On initial PR runs, Python-only changes run Python checks; documentation-only changes skip test jobs. Native production changes enable Go and Android, while Go test-only changes enable Go. Shared CI/Fastlane inputs and unknown paths enable all suites. Failed diff calculation fails `Change scope` instead of silently skipping tests. Skipped Android builds do not publish APK artifacts. @@ -112,7 +119,7 @@ python3 scripts/github_actions.py --dry-run python3 scripts/github_actions.py --yes ``` -Choose an open PR and either rerun all CI jobs or only failed jobs. Requires GitHub CLI (`gh`) +Choose an open PR and either rerun all CI jobs **including skipped checks**, or only failed jobs. Requires GitHub CLI (`gh`) and its existing authentication (`gh auth login`, `GH_TOKEN` or `GITHUB_TOKEN`). The launcher uses native gh commands, no custom HTTP client or token storage. `--repo OWNER/REPO` overrides the repo. `--yes` / `-y` skips final confirmation but retains menu selection and the stale-head check; @@ -120,5 +127,48 @@ native gh commands, no custom HTTP client or token storage. `--repo OWNER/REPO` The script targets an existing completed CI run for the exact current PR commit. Running/queued jobs and missing runs are rejected; CI normally starts on pushes. Failed-only mode requires a failed run; cancelled runs can be rerun with all jobs. Launch failures/timeouts are never retried automatically. -Rerunning preserves that run's original commit and diff baseline; push a new commit to reassess scope -against an updated PR base. No device or release workflows are offered. +A full rerun also reruns Change scope. On attempt 2 or later it enables Android (including +Compose UI tests), native and Python suites without diff/history filtering. The same applies to +GitHub's Re-run all jobs button. Failed-only reruns keep the existing scope unless Change scope +itself failed and is rerun, in which case all suites are enabled. +[GitHub reruns preserve the original commit](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/re-run-workflows-and-jobs). +Runs created before this workflow change retain the old filtering; push a new commit first. +No device or release workflows are offered. + +### Native parser fuzzing + +Run a bounded local fuzz campaign for native config, JA3 and DNS parsers (20 seconds, two workers). Seed inputs also run as part of `native_tests`. This campaign is not a device test. + +```shell +bundle exec fastlane android native_fuzz +``` + +## Compose UI tests without an emulator + +`bundle exec fastlane android android_checks` (and `android test`) runs the Robolectric +Compose tests in `app/src/test` together with the existing JVM tests. They also run in the +normal Android PR check; no device, ADB or KVM is required. + +The interaction suite covers the main user flows: + +- Main screen: connect/reconnect/disconnect, permission approval and denial, invalid profiles, + Always-on conflicts, disabled actions while connecting, and profile selection. +- Profiles: draft creation, editing and port validation, SSH/HTTPS Jump fields, certificate + bypass confirmation, cloning/deletion, file import errors, and export without passwords. +- Settings: traffic units, TLS fingerprint, failover confirmation, and selected-app routing. +- Navigation: settings destinations and Back, profile creation, diagnostics, and SSH prompts. +- Diagnostics: running/success/failure states, exit IP, log copying and clear confirmation. +- SSH trust: successful save, failed save and retry, disabled actions/Back during a pending + save, and test cancellation (`SshHostKeyUiTest`). + +`MainUiTestBase` uses the real screens and ConfigStore with an in-memory test Keystore +provider. Robolectric records service commands and supplies permission/document-picker +results; the connection statistics reader is injected. Tests do not start VPN forwarding, +load Go JNI or access the device Keystore. A plain test Application, Android API 35 and +English resources are pinned; Robolectric downloads its Android runtime from Maven Central +on the first run. + +Add behavior tests using the same runner and Compose rule. Keep platform operations at the +screen boundary and supply deterministic fakes; avoid sleeps and real network calls. +These are interaction tests, not screenshot comparisons or device lifecycle certification. +See [Robolectric setup](https://robolectric.org/getting-started/). diff --git a/docs/en/index.md b/docs/en/index.md index b9d5b81..0650f5f 100644 --- a/docs/en/index.md +++ b/docs/en/index.md @@ -5,6 +5,7 @@ Server configurations and setup instructions have moved to the dedicated Client connections: +- [Installation](../../README.md#installation) - [HTTPS with Jump](../../README.md#https-with-jump) Developer documentation: diff --git a/docs/google-play/foreground-service-declaration.md b/docs/google-play/foreground-service-declaration.md index fac2205..a24f053 100644 --- a/docs/google-play/foreground-service-declaration.md +++ b/docs/google-play/foreground-service-declaration.md @@ -15,9 +15,10 @@ Manifest subtype: ## Play Console description MegaProxy provides a user-configured VPN tunnel, which is the app's core -functionality. The foreground service starts only after the user taps Connect, -when Android starts the configured Always-on VPN, or during a user-initiated -connection test. +functionality. The foreground service starts when the user taps Connect, when Android starts the +configured Always-on VPN, or during a user-initiated connection test. It can also restore a +previously requested connection after an app update and reconnect that session after network +or configuration changes. The service continuously processes network traffic through Android's `VpnService` tunnel. This work must start immediately and remain active for the @@ -27,8 +28,8 @@ interrupt the network connection that the user explicitly enabled. While the tunnel is active, MegaProxy displays a persistent, low-priority notification titled "MegaProxy is active". The Android VPN indicator and the main screen also show that the VPN is connected. The user can stop a manually -started session by tapping Disconnect. An Always-on VPN session is controlled -through MegaProxy or Android's VPN settings. MegaProxy does not keep the +started session by tapping Disconnect. An Always-on VPN session is controlled through Android's +VPN settings; manual connection controls in MegaProxy are disabled while Always-on is active. MegaProxy does not keep the foreground service running after the VPN session has stopped. ## Video diff --git a/docs/reviews/privacy-policy.md b/docs/reviews/privacy-policy.md new file mode 100644 index 0000000..f0b7eea --- /dev/null +++ b/docs/reviews/privacy-policy.md @@ -0,0 +1,51 @@ +# Privacy policy review + +Reviewed September 8, 2026 against the application source and Google's published requirements. +This review addresses the policy text; it does not certify store declarations or jurisdiction-specific +legal compliance. + +## Necessary disclosures + +The policy should explain the data used, its purpose, recipients, protection and deletion. +[Google Play's User Data policy](https://support.google.com/googleplay/android-developer/answer/10144311?rd=1) +requires these disclosures and a privacy contact, including for apps without analytics. It treats +installed-app information and authentication data as sensitive. Local processing therefore still +needs an accurate explanation even when the developer receives no automatic uploads. + +Keep the distinctions between local storage, traffic forwarded to user-chosen servers, external +DNS/diagnostic services and voluntary support correspondence. Avoid a blanket “no data collected” +claim, because the developer receives emails that users choose to send. + +## Changes to the text + +- Remove the cipher name and application-TLS implementation details. Retain the user-relevant + statement that stored passwords/private keys are encrypted with an Android Keystore-held key. +- Link to README for the diagnostic endpoint inventory rather than duplicating every hostname. + Retain recipient categories, purposes, and the difference between the source IP exposed during + direct bootstrap DNS and the proxy exit IP exposed during diagnostics. +- Add installed-app visibility and routing selections, including their presence in profile exports + (`SplitTunnelScreen`, `ConfigStore`, `ConfigTransfer`). This is local functionality, not an uploaded + application inventory. +- Describe the voluntary report payload: model, OS/app versions, connection-setting summary and + filtered logs (`FeedbackEmail`). Choosing a receiving application already gives it the shared + copy; sending an email is a separate user action. Avoid implying that cancellation removes copies + or drafts held by that application. +- Explain local log rotation/manual clearing and deletion of cached reports, and distinguish + exported/shared copies from app data (`PersistentDiagnosticLog`, `FeedbackEmail`). +- Describe support emails as data received by the developer, including the sender address and + attachments. The developer confirmed that they are retained until the reported issue is fixed, + then deleted. This is an operational practice, not an app-enforced expiration timer. + +## Remaining distribution work + +The app now includes a privacy-policy link at the bottom of Settings, with English and Russian +labels and an error message if the browser cannot be opened. It points to the public PRIVACY.md +on main. Google's User Data policy also requires a URL in Play Console; verify that the published +URL remains public and readable. The repository policy alone does +not establish that the Play Console field or Data safety answers are correct; those settings were +not inspected in this review. + +The [VpnService guidance](https://support.google.com/googleplay/android-developer/answer/12564964?hl=en) +also addresses in-app disclosure/consent for personal or sensitive data handled through VpnService. +A privacy policy does not substitute for that disclosure. Review the actual onboarding/consent +flow against the final distribution declarations separately; no consent UI was added here. diff --git a/docs/reviews/test-quality.md b/docs/reviews/test-quality.md new file mode 100644 index 0000000..e91486c --- /dev/null +++ b/docs/reviews/test-quality.md @@ -0,0 +1,62 @@ +# Test quality and critical-scenario review + +Review snapshot: September 8, 2026. Counts and verification below describe that review. + +This is a scenario/source audit, not a measured line-coverage or mutation-coverage report. +Counts of tests do not establish coverage of the Android VPN lifecycle. + +## Findings addressed + +- `MainUiTestBase.saved()` previously waited only for `pending == 0`, which also describes + failed writes. It now drains the ordered configuration executor and asserts no failed + write remains. Keystore cleanup runs in `finally`, including when an assertion fails. +- `MainScreenUiTest.command()` previously consumed arbitrary service commands until the + expected action appeared. A wrong or duplicate start could pass. It now checks the next + command, target component, and absence of extra commands after draining the executor. + Negative command assertions use the same barrier. Stop and denied consent also verify + the persisted desired-connection state. +- ConfigStore lacked direct integration coverage of several persistence contracts. Six new + tests exercise reopening encrypted credentials, fresh GCM IVs, omitted versus explicitly + empty secrets on import, deletion/reference/failover cleanup, stale reconnect tokens, + and SSH jump trust isolation. They use the real serializer and crypto with synthetic keys. +- ConfigWriteQueue now additionally tests repeated Retry while a retry is pending, deletion + before the initial queued write, and failure isolation between different profiles. + +## Coverage assessment + +| Area | Existing evidence | Important limit | +| --- | --- | --- | +| Native HTTPS/SSH/Jump | `native/mobile/*_test.go`: local TLS/SSH servers, payload round trips, auth/hop isolation, shared-session failures, cancelled opens, deadlines, closed dialers | HTTPS Jump failure cases currently use HTTP/1.1; the successful tunnel matrix covers both HTTP versions. No actual Android network handover. | +| Native startup and DNS | Borrowed/duplicated FD ownership, overlapping Stop/Start guard, DoH queue/deadline limits, bootstrap literals, parser fuzz seeds | Bridge tests use synthetic descriptors; no working Android TUN/JNI lifecycle. Fuzzing checks crashes, not all semantic outcomes. | +| JVM/native boundary | `NativeCallbackTest`, `ConnectionTestTargetTest`: descriptor range, fail-closed protection, stale callbacks, runtime profile/trust identity | Injected callbacks do not exercise generated JNI at runtime. | +| Persistence/import | Parser and merge tests, ConfigStore integration tests, ordered-write failure/retry tests | In-memory Keystore and Robolectric preferences do not model device key loss, disk-full commits, process death, or all legacy migrations. | +| UI | Real Compose screens, state assertions, navigation, permission/document results and persisted changes | Service intents are recorded, not executed. Only API 35 and English interaction fixtures; no screenshot/overlap assertions. | +| CI/launcher | `scripts/tests`: real temporary Git diffs, per-suite history selection, safe `gh` arguments, PR recheck, timeout without duplicate launch | Mocked GitHub responses do not certify live Actions permissions or branch-protection configuration. | + +## Remaining priorities + +1. **High: service lifecycle and recovery.** `ProxyVpnService` startup completion, Stop during + startup, queued reconnect after Stop/onDestroy, network-change debounce and failover candidate + selection have no direct behavioral tests. Extract the orchestration behind injectable core, + scheduler and network inputs; assert stale starts cannot publish CONNECTED or revive a stopped + VPN, and only valid/untried permitted profiles are selected. Keep real TUN/notification/OS + delivery smoke checks on controlled devices outside required hosted CI. +2. **High: interrupted persistence and transfer.** Test commit failure after preferences have + changed in memory, retry after returning to a screen, legacy migration fixtures, and loss of + Keystore access. Define the expected recovery behavior before locking it into assertions; + ordinary decryption errors currently yield empty secrets. Recreate the activity during import, + export and SSH save; verify no duplicated operation and no credentials in saved state. +3. **Medium: broader UI configurations and negative paths.** Add Russian/narrow-window/large-font + interactions and API-minimum coverage for platform-dependent screens. Test export cancellation, + provider write failures and lost export payloads, not just successful export and malformed + import. Navigation tests show that destinations open; they do not prove every control works. +4. **Medium: native failure matrix.** Extend HTTPS Jump rejection/cancellation checks to HTTP/2 + hops and assert the intended failure stage; accepting any non-null error can hide an unrelated + setup failure. Add successful trust-store verification alongside self-signed rejection/bypass. + +## Verification + +Use the supported Fastlane commands in the [English](../en/fastlane.md) and +[Russian](../ru/fastlane.md) references. The review adds nine JVM tests; the suite contains +149 JVM tests, including 36 Compose interactions. Native race tests and Android JVM/lint/build +checks were run locally. No emulator, real Keystore, real VPN traffic or device profiling was used. diff --git a/docs/ru/fastlane.md b/docs/ru/fastlane.md index f18f7ca..f7fe382 100644 --- a/docs/ru/fastlane.md +++ b/docs/ru/fastlane.md @@ -16,6 +16,8 @@ MegaProxy использует [Fastlane](https://fastlane.tools/) как осн Затем установите Ruby 3.4.10 — эта версия зафиксирована в `.ruby-version`. Рекомендуется менеджер версий Ruby; системный Ruby из macOS использовать не следует. +Скрипты нативной и релизной сборки находят JDK 21 через `JAVA_HOME`, macOS `java_home` или `java` в `PATH`. Явно заданный несовместимый JDK приводит к понятной ошибке до сборки; путь установки Homebrew не предполагается. + Установите актуальный Bundler и зафиксированные проектом зависимости из корня репозитория: ```shell @@ -36,6 +38,10 @@ bundle exec fastlane lanes | Команда | Результат | | --- | --- | +| `bundle exec fastlane android python_format` | Форматирует Python зафиксированными Black и isort. | +| `bundle exec fastlane android python_tests` | Запускает Python unit-тесты. | +| `bundle exec fastlane android python_checks` | Проверяет форматирование, порядок импортов и Python-тесты. | +| `bundle exec fastlane android native_fuzz` | Запускает fuzz-тесты нативных парсеров на 20 секунд с двумя worker-процессами. | | `bundle exec fastlane android native_tests` | Запускает все Go-тесты с race detector. | | `bundle exec fastlane android android_checks` | Собирает native AAR, запускает Android unit-тесты и lint, собирает debug APK, затем собирает и проверяет unsigned release APK. Команда отклоняет переменные release-подписи. | | `bundle exec fastlane android test` | Выполняет `native_tests` и `android_checks`; основная команда перед коммитом. | @@ -83,11 +89,12 @@ bundle exec fastlane android test Кандидат должен относиться к тому же PR и репозиторию, иметь ту же сохранённую базу PR и быть старше текущего прогона. Коммиты из отброшенной после rebase истории не используются. Через gh проверяются последние 30 завершённых CI-прогонов ветки. Если истории нет, API недоступен или старый прогон не -сохранял базу, используется полный diff PR. Пуши в main сравниваются по началу и концу пуша. +сохранял базу, используется полный diff PR. Каждый пуш в main запускает все наборы без фильтрации по diff и истории. +Бейдж README явно привязан к `badge.svg?branch=main&event=push`. База сравнения и решение для каждого набора видны в summary Actions. При повторе собственный run ID не используется как предыдущая проверка. -Изменения только Python запускают Python-проверки; изменения только документации пропускают +В первичном прогоне PR изменения только Python запускают Python-проверки; изменения только документации пропускают тестовые задания. Production-код Go включает Go и Android, изменения только Go-тестов — Go. Общие файлы CI/Fastlane и неизвестные пути включают все проверки. Ошибка вычисления diff приводит к ошибке `Change scope`, а не к тихому пропуску тестов. При пропуске Android-сборки APK не публикуются. @@ -115,7 +122,7 @@ python3 scripts/github_actions.py --dry-run python3 scripts/github_actions.py --yes ``` -Выберите открытый PR и повтор всего CI либо только упавших заданий. Нужен GitHub CLI (`gh`) +Выберите открытый PR и повтор всего CI **включая пропущенные проверки** либо только упавших заданий. Нужен GitHub CLI (`gh`) с авторизацией (`gh auth login`, `GH_TOKEN` или `GITHUB_TOKEN`). Скрипт вызывает штатные команды gh, без своего HTTP-клиента и хранения токенов. `--repo OWNER/REPO` переопределяет репозиторий. `--yes` / `-y` пропускает последнее подтверждение, сохраняя меню и проверку актуальности коммита; @@ -124,5 +131,47 @@ python3 scripts/github_actions.py --yes Активные задания и отсутствие прогона приводят к отказу; обычно CI начинается после пуша. Повтор только упавших заданий требует failed-прогона; cancelled можно повторить целиком. Ошибки/таймауты запуска не приводят к автоматической повторной отправке. -Повтор сохраняет исходный коммит и базу diff того прогона; для пересчёта относительно обновлённой -базы PR нужен новый пуш. В меню нет запуска устройств или release-workflow. +Полный повтор заново запускает Change scope. Начиная со второй попытки он включает Android +(в том числе Compose UI-тесты), native и Python без фильтрации по diff и истории. Так же работает +кнопка Re-run all jobs в GitHub. Повтор только упавших сохраняет прежний состав проверок, кроме +случая, когда сам Change scope упал и запускается повторно: тогда включаются все проверки. +[GitHub сохраняет исходный коммит при повторе](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/re-run-workflows-and-jobs). +Старые прогоны, созданные до этого изменения workflow, сохраняют прежнюю фильтрацию; сначала нужен +новый пуш. В меню нет запуска устройств или release-workflow. + +### Native parser fuzzing + +Ограниченный локальный fuzz-прогон парсеров конфигурации, JA3 и DNS: 20 секунд, два worker-процесса. Начальный корпус также проверяется в `native_tests`. Устройство не требуется. + +```shell +bundle exec fastlane android native_fuzz +``` + +## Compose UI-тесты без эмулятора + +`bundle exec fastlane android android_checks` (и `android test`) запускает Compose-тесты +Robolectric из `app/src/test` вместе с существующими JVM-тестами. Они входят в обычную +Android-проверку PR; устройство, ADB и KVM не нужны. + +Тесты взаимодействия покрывают основные пользовательские сценарии: + +- Главный экран: подключение/переподключение/остановку, выдачу и отказ VPN-разрешения, + невалидный профиль, конфликты Always-on, блокировку действий при подключении и выбор профиля. +- Профили: создание черновика, редактирование и проверку порта, поля SSH/HTTPS Jump, + подтверждение обхода сертификата, клонирование/удаление, ошибки импорта и экспорт без паролей. +- Настройки: единицы трафика, TLS fingerprint, подтверждение failover и режим выбранных приложений. +- Навигацию: разделы настроек и возврат, создание профиля, диагностику и SSH-подтверждение. +- Диагностику: выполнение/успех/ошибку, выходной IP, копирование лога и подтверждение очистки. +- Доверие SSH: успешное сохранение, ошибку и повтор, блокировку действий/Back во время + сохранения и отмену диагностики (`SshHostKeyUiTest`). + +`MainUiTestBase` использует реальные экраны и ConfigStore с тестовым Keystore в памяти. +Robolectric фиксирует команды сервиса и подставляет результаты разрешений и выбора файлов; +чтение статистики соединения подменяется. Тесты не запускают VPN-трафик, Go JNI и Keystore +устройства. Зафиксированы обычный тестовый Application, Android API 35 и английские ресурсы; +при первом запуске Robolectric скачивает Android runtime из Maven Central. + +Новые проверки поведения добавляйте с теми же runner и Compose rule. Операции платформы +оставляйте на границе экрана и подставляйте управляемые реализации; избегайте sleep и сети. +Это проверки взаимодействия, а не сравнение скриншотов или полная проверка на устройстве. +См. [настройку Robolectric](https://robolectric.org/getting-started/). diff --git a/docs/ru/index.md b/docs/ru/index.md index 892d0e8..e3d755c 100644 --- a/docs/ru/index.md +++ b/docs/ru/index.md @@ -3,6 +3,11 @@ Серверные конфигурации и инструкции по настройке перенесены в отдельный репозиторий [MegaProxyServer](https://github.com/andre487/MegaProxyServer). +Документация клиента: + +- [Установка](installation.md) +- [HTTPS через Jump](#https-через-jump) + Документация для разработчиков: - [Работа с Fastlane](fastlane.md) diff --git a/docs/ru/installation.md b/docs/ru/installation.md index 4958153..519eae4 100644 --- a/docs/ru/installation.md +++ b/docs/ru/installation.md @@ -11,7 +11,7 @@ MegaProxy поддерживает Android 8.0 (API 26) и новее. Офиц Откройте последний релиз, разверните список **Assets** и скачайте файл с окончанием `universal.apk`. Это основной рекомендуемый вариант: он поддерживает все перечисленные ниже -архитектуры и используется для независимой проверки воспроизводимой сборки в F-Droid. +архитектуры и предназначен для независимой проверки воспроизводимой сборки в F-Droid. | APK | Для каких устройств | | --- | --- | @@ -86,4 +86,4 @@ Android установит обновление поверх текущей ве и ключа подписи. Все официальные релизные APK MegaProxy подписываются одним ключом проекта. После установки или обновления рекомендуется открыть приложение, проверить выбранный профиль и -нажать **Тест** перед первым подключением. +в меню главного экрана выбрать **Проверить** перед первым подключением. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index ed6ae82..ba4ff8b 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -14,6 +14,13 @@ platform :android do end end + desc "Fuzz native config, JA3 and DNS parsers for 20 seconds with two workers" + lane :native_fuzz do + Dir.chdir(File.join(project_root, "native")) do + sh("go", "test", "./mobile", "-run=^$", "-fuzz=FuzzNativeParsers", "-fuzztime=20s", "-parallel=2") + end + end + desc "Build the native AAR used by Android builds" private_lane :native_library do sh(File.join(project_root, "scripts", "build-fdroid-native.sh")) diff --git a/fastlane/README.md b/fastlane/README.md index 5c1e129..fee6a39 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -1,6 +1,6 @@ # MegaProxy Fastlane lanes -Fastlane is the supported entry point for tests and build artifacts. Install Ruby 3.4, then run +Fastlane is the supported entry point for tests and build artifacts. Install Ruby 3.4.10 (see `.ruby-version`), then run `bundle install` from the repository root. | Command | Purpose | @@ -8,6 +8,7 @@ Fastlane is the supported entry point for tests and build artifacts. Install Rub | `bundle exec fastlane android python_format` | Format Python with Black and isort | | `bundle exec fastlane android python_tests` | Run Python unit tests | | `bundle exec fastlane android python_checks` | Check Python style and run unit tests | +| `bundle exec fastlane android native_fuzz` | Fuzz native parsers for 20 seconds with two workers | | `bundle exec fastlane android native_tests` | Run Go tests with 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 test` | Run all native and Android checks | @@ -17,3 +18,5 @@ Fastlane is the supported entry point for tests and build artifacts. Install Rub The release lane deliberately delegates signing and artifact verification to the repository's existing release scripts. It requires the signing environment documented in the root README. Publishing to an app store is not performed by any lane. + +Full setup and CI scope rules: [English](../docs/en/fastlane.md) / [Русский](../docs/ru/fastlane.md). diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 69c7ae0..f083e0a 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -1,12 +1,13 @@

MegaProxy is an open-source Android VPN client for reliable and secure connections through proxy servers you control or trust.

-

It contains no advertising, analytics SDKs, tracking identifiers, or remote telemetry. Connection statistics, profiles, and diagnostic logs remain on the device unless you explicitly choose to share them.

+

It contains no advertising, analytics SDKs, tracking identifiers, or remote telemetry. Connection statistics and diagnostic logs remain on the device unless you explicitly choose to share them. Profiles are stored locally; connection credentials are used to authenticate to the configured servers.

Features include:

  • HTTPS proxies over TLS using CONNECT without intercepting application traffic
  • HTTP/2 CONNECT multiplexing with automatic HTTP/1.1 fallback
  • +
  • Two HTTPS proxies in sequence with HTTPS with Jump
  • SSH transport and SSH through a jump host
  • Password and unencrypted private-key SSH authentication
  • Global VPN and per-application split tunneling
  • @@ -17,6 +18,6 @@
  • English and Russian interfaces
-

MegaProxy does not provide a proxy service. You need an HTTPS, SSH, or SSH-with-jump server that you operate or trust.

+

MegaProxy does not provide a proxy service. You need HTTPS or SSH servers that you operate or trust; both transports support a jump server.

Only TCP application traffic is forwarded. General UDP and QUIC forwarding are not supported.

diff --git a/fastlane/metadata/android/ru-RU/full_description.txt b/fastlane/metadata/android/ru-RU/full_description.txt index f7c939d..e489177 100644 --- a/fastlane/metadata/android/ru-RU/full_description.txt +++ b/fastlane/metadata/android/ru-RU/full_description.txt @@ -1,12 +1,13 @@

MegaProxy — открытый VPN-клиент для Android, предназначенный для надёжных и безопасных подключений через прокси-серверы, которыми вы управляете или которым доверяете.

-

В приложении нет рекламы, аналитических SDK, идентификаторов отслеживания и удалённой телеметрии. Статистика соединения, профили и диагностические журналы остаются на устройстве, пока вы сами не решите ими поделиться.

+

В приложении нет рекламы, аналитических SDK, идентификаторов отслеживания и удалённой телеметрии. Статистика соединения и диагностические журналы остаются на устройстве, пока вы сами не решите ими поделиться. Профили хранятся локально; данные авторизации используются для подключения к настроенным серверам.

Возможности:

  • HTTPS-прокси поверх TLS с CONNECT без перехвата трафика приложений
  • Мультиплексирование HTTP/2 CONNECT с автоматическим переходом на HTTP/1.1
  • +
  • Цепочка из двух HTTPS-прокси в режиме HTTPS через Jump
  • Подключение через SSH и SSH с jump host
  • Авторизация SSH по паролю и незашифрованному приватному ключу
  • Глобальный VPN и раздельное туннелирование для выбранных приложений
  • @@ -17,6 +18,6 @@
  • Интерфейс на русском и английском языках
-

MegaProxy не предоставляет прокси-сервис. Для работы нужен HTTPS-, SSH- или SSH-with-jump-сервер, которым вы управляете или которому доверяете.

+

MegaProxy не предоставляет прокси-сервис. Для работы нужны HTTPS- или SSH-серверы, которыми вы управляете или которым доверяете; оба транспорта поддерживают промежуточный сервер.

Перенаправляется только TCP-трафик приложений. Произвольный UDP- и QUIC-трафик не поддерживается.

diff --git a/native/README.md b/native/README.md index 9767634..872d0e6 100644 --- a/native/README.md +++ b/native/README.md @@ -8,15 +8,24 @@ verification, authentication and HTTP/2 sessions. Only the jump is dialed direct the destination proxy hostname. Application traffic counters exclude the intermediate tunnel. SSH with Jump creates a nested SSH client through the jump session. SSH transports support TCP; DNS is carried over DoH, while arbitrary UDP (including QUIC) is intentionally blocked. -`Start` always takes ownership of the passed duplicate TUN descriptor, including error paths. +`Start` borrows the JVM-owned TUN descriptor for the call and duplicates it with CLOEXEC on +entry. Go closes only its own duplicate; Java retains responsibility for the descriptor it passed. +The bridge passes the Android TUN MTU explicitly (currently 1400). Generated gomobile types are +compile-time JVM dependencies, so signature changes must compile on both sides. -Build prerequisites: Go 1.26+, Android SDK/NDK and `gomobile`. +Use the supported Fastlane commands from the repository root: ```shell -go install golang.org/x/mobile/cmd/gomobile@latest -gomobile init -gomobile bind -target=android -androidapi 26 -o ../app/libs/megaproxy.aar ./mobile +bundle exec fastlane android native_tests +bundle exec fastlane android native_fuzz +bundle exec fastlane android debug_artifact ``` -Run `go test ./...` before producing the AAR. The dependency versions are pinned in `go.mod`; -commit the generated `go.sum` after the first successful dependency download. +`native_tests` includes the Go race detector; `native_fuzz` runs a bounded 20-second parser campaign. +Android build lanes prepare `app/libs/megaproxy.aar` through `scripts/build-fdroid-native.sh`, which +installs the pinned gomobile/gobind version and uses the reproducible binding flags. Do not replace +that build path with `gomobile@latest`. Go dependencies are pinned in `go.mod` and `go.sum`. + +Requirements and environment setup are in the root [README](../README.md#building-from-source) +and the [Fastlane reference](../docs/en/fastlane.md). Native tests use local servers and synthetic +file descriptors; they do not certify real Android TUN/JNI lifecycle behavior. diff --git a/native/mobile/bootstrap.go b/native/mobile/bootstrap.go index f3d8ec1..5e3263b 100644 --- a/native/mobile/bootstrap.go +++ b/native/mobile/bootstrap.go @@ -10,6 +10,7 @@ import ( "io" "net" "net/http" + "strings" "syscall" "time" ) @@ -33,7 +34,14 @@ var bootstrapResolvers = []bootstrapResolver{ // ResolveProxy bootstraps the proxy address through protected encrypted DNS. func ResolveProxy(host string, protector Protector, reporter Reporter) (string, error) { - query, id, err := buildAQuery(host) + host = strings.TrimSpace(host) + if ip := net.ParseIP(host); ip != nil { + return ip.String(), nil + } + if protector == nil { + return "", errors.New("Android socket protector is required") + } + query, id, err := buildAQuery(strings.TrimSuffix(host, ".")) if err != nil { return "", err } @@ -102,6 +110,9 @@ func resolveProxyWithResolver(query []byte, id uint16, resolver bootstrapResolve } func buildAQuery(host string) ([]byte, uint16, error) { + if len(host) == 0 || len(host) > 253 { + return nil, 0, errors.New("invalid proxy hostname length") + } var idBytes [2]byte if _, err := rand.Read(idBytes[:]); err != nil { return nil, 0, err @@ -163,7 +174,7 @@ func parseAResponse(message []byte, id uint16) (string, error) { func skipDNSName(message []byte, offset int) (int, error) { for { - if offset >= len(message) { + if offset < 0 || offset >= len(message) { return 0, io.ErrUnexpectedEOF } length := int(message[offset]) @@ -172,7 +183,7 @@ func skipDNSName(message []byte, offset int) (int, error) { return offset, nil } if length&0xc0 == 0xc0 { - if offset >= len(message) { + if offset < 0 || offset >= len(message) { return 0, io.ErrUnexpectedEOF } return offset + 1, nil diff --git a/native/mobile/bootstrap_test.go b/native/mobile/bootstrap_test.go index 3fb44ad..78b5deb 100644 --- a/native/mobile/bootstrap_test.go +++ b/native/mobile/bootstrap_test.go @@ -33,3 +33,12 @@ func TestBootstrapIncludesYandexRedundancy(t *testing.T) { t.Fatalf("Yandex resolver count = %d, want 2", count) } } + +func TestResolveProxyLiteralDoesNotRequireDNS(t *testing.T) { + for _, host := range []string{"203.0.113.7", " 203.0.113.7 "} { + ip, err := ResolveProxy(host, nil, nil) + if err != nil || ip != "203.0.113.7" { + t.Fatalf("literal: %q %v", ip, err) + } + } +} diff --git a/native/mobile/bridge_test.go b/native/mobile/bridge_test.go new file mode 100644 index 0000000..4459e1e --- /dev/null +++ b/native/mobile/bridge_test.go @@ -0,0 +1,98 @@ +package mobile + +import ( + "os" + "strings" + "testing" + "time" + + "github.com/xjasonlyu/tun2socks/v2/proxy/reject" + "github.com/xjasonlyu/tun2socks/v2/tunnel" + "golang.org/x/sys/unix" +) + +func TestBridgeOwnsOnlyDuplicatedDescriptor(t *testing.T) { + original, err := os.CreateTemp(t.TempDir(), "tun") + if err != nil { + t.Fatal(err) + } + defer original.Close() + fd, err := duplicateTunFD(int(original.Fd())) + if err != nil { + t.Fatal(err) + } + if fd == int(original.Fd()) { + t.Fatal("descriptor was not duplicated") + } + flags, err := unix.FcntlInt(uintptr(fd), unix.F_GETFD, 0) + if err != nil { + t.Fatal(err) + } + if flags&unix.FD_CLOEXEC == 0 { + t.Error("duplicate must not survive exec") + } + if err := unix.Close(fd); err != nil { + t.Fatal(err) + } + if _, err := original.WriteString("still owned by caller"); err != nil { + t.Fatal(err) + } + for i := 0; i < 20; i++ { + if err := Start(int(original.Fd()), 1400, "invalid json", nil, nil); err == nil { + t.Fatal("invalid config accepted") + } + if _, err := original.Stat(); err != nil { + t.Fatalf("Start closed borrowed descriptor: %v", err) + } + } + if _, err := duplicateTunFD(-1); err == nil { + t.Fatal("negative descriptor accepted") + } +} + +type blockingBridgeCloser struct{ entered, release chan struct{} } + +func (c *blockingBridgeCloser) Close() error { close(c.entered); <-c.release; return nil } + +func TestBridgeStartCannotOverlapStopCleanup(t *testing.T) { + closer := &blockingBridgeCloser{make(chan struct{}), make(chan struct{})} + state.Lock() + state.running = true + state.proxyCloser = closer + tunnel.T().SetProxy(&httpsConnectDialer{}) + state.Unlock() + done := make(chan struct{}) + go func() { Stop(); close(done) }() + defer func() { close(closer.release); <-done }() + select { + case <-closer.entered: + case <-time.After(time.Second): + t.Fatal("Stop did not close upstream") + } + original, err := os.CreateTemp(t.TempDir(), "tun") + if err != nil { + t.Fatal(err) + } + defer original.Close() + raw := `{"host":"proxy.example","dialHost":"192.0.2.1","port":443,"username":"u","password":"p","profile":"CHROME_ANDROID","dohUrl":"https://dns.google/dns-query"}` + if _, err := parseConfig(raw); err != nil { + t.Fatal(err) + } + err = Start(int(original.Fd()), 1400, raw, &jumpTestProtector{}, nil) + if err == nil || !strings.Contains(err.Error(), "already running") { + t.Fatalf("Start during Stop: %v", err) + } + if _, ok := tunnel.T().Proxy().(*reject.Reject); !ok { + t.Fatal("Stop retained the global dialer and its JVM callbacks") + } + Stop() // A concurrent second Stop must not clear the first Stop's guard. + state.Lock() + stopping := state.stopping + state.Unlock() + if !stopping { + t.Fatal("second Stop cleared cleanup guard") + } + if _, err := original.Stat(); err != nil { + t.Fatalf("rejected Start closed borrowed FD: %v", err) + } +} diff --git a/native/mobile/config.go b/native/mobile/config.go index c333fc2..c2d8a41 100644 --- a/native/mobile/config.go +++ b/native/mobile/config.go @@ -48,9 +48,20 @@ type config struct { func parseConfig(raw string) (config, error) { var c config + if len(raw) > 1024*1024 { + return c, errors.New("native config exceeds 1 MiB") + } if err := json.Unmarshal([]byte(raw), &c); err != nil { return c, fmt.Errorf("decode config: %w", err) } + if len(c.DoHFallbackURLs) > 16 { + return c, errors.New("too many fallback DoH providers") + } + if c.SSHKeepaliveSeconds < 0 || c.SSHKeepaliveSeconds > 3600 || + c.SSHRotationMinutes < 0 || c.SSHRotationMinutes > 1440 || + c.SSHRotationMB < 0 || c.SSHRotationMB > 10240 { + return c, errors.New("invalid SSH keepalive or rotation limit") + } c.Host = strings.TrimSpace(c.Host) c.DialHost = strings.TrimSpace(c.DialHost) if c.Type == "" { diff --git a/native/mobile/diagnostics.go b/native/mobile/diagnostics.go index 06fe3f7..5dc5cd1 100644 --- a/native/mobile/diagnostics.go +++ b/native/mobile/diagnostics.go @@ -25,7 +25,8 @@ func errorClass(err error) string { if errors.Is(err, io.EOF) { return "eof" } - if netErr, ok := err.(net.Error); ok && netErr.Timeout() { + var netErr net.Error + if errors.As(err, &netErr) && netErr.Timeout() { return "timeout" } message := strings.ToLower(err.Error()) diff --git a/native/mobile/dialer.go b/native/mobile/dialer.go index 79e96b9..9612b55 100644 --- a/native/mobile/dialer.go +++ b/native/mobile/dialer.go @@ -37,11 +37,13 @@ type httpsConnectDialer struct { connections chan struct{} h2Session *http2ConnectSession h2Disabled bool + closed bool } func (d *httpsConnectDialer) Close() error { d.cacheMu.Lock() defer d.cacheMu.Unlock() + d.closed = true if d.jump != nil { _ = d.jump.Close() } @@ -77,6 +79,12 @@ func (d *httpsConnectDialer) DialContext(ctx context.Context, metadata *M.Metada } func (d *httpsConnectDialer) connectTarget(ctx context.Context, target string) (net.Conn, error) { + d.cacheMu.Lock() + closed := d.closed + d.cacheMu.Unlock() + if closed { + return nil, net.ErrClosed + } connectionID := nextDiagnosticConnectionID() totalStarted := time.Now() if !d.config.AllowIPv6 { @@ -111,6 +119,10 @@ func (d *httpsConnectDialer) connectTarget(ctx context.Context, target string) ( report(d.reporter, "event=http2_session result=unsupported action=fallback_http1") return d.connectTarget(ctx, target) } + // Rejection and cancellation belong to one stream, not the shared session. + if ctx.Err() != nil || session.canTakeRequest() { + return nil, err + } d.invalidateHTTP2Session(session) report(d.reporter, "event=http2_session result=stale action=reconnect reason=%s", errorClass(err)) } @@ -180,6 +192,9 @@ func (d *httpsConnectDialer) connectTarget(ctx context.Context, target string) ( } closeOnError = false // The HTTP/2 session now owns the outer TLS connection. session = d.installHTTP2Session(session) + if session == nil { + return nil, net.ErrClosed + } connection, connectErr := d.openHTTP2Tunnel(ctx, session, target, connectionID, totalStarted, false) if shouldFallbackToHTTP1(connectErr) { d.disableHTTP2(session) @@ -241,6 +256,10 @@ func (d *httpsConnectDialer) dialProxy(ctx context.Context) (net.Conn, error) { return d.protectedDialer().DialContext(ctx, "tcp", d.config.address()) } d.cacheMu.Lock() + if d.closed { + d.cacheMu.Unlock() + return nil, net.ErrClosed + } if d.jump == nil { c := d.config c.Type = "HTTPS" @@ -311,6 +330,10 @@ func (d *httpsConnectDialer) currentHTTP2Session() *http2ConnectSession { func (d *httpsConnectDialer) installHTTP2Session(candidate *http2ConnectSession) *http2ConnectSession { d.cacheMu.Lock() defer d.cacheMu.Unlock() + if d.closed { + _ = candidate.close() + return nil + } if d.h2Session != nil && d.h2Session.canTakeRequest() { _ = candidate.close() return d.h2Session @@ -432,6 +455,10 @@ func (d *httpsConnectDialer) DialUDP(metadata *M.Metadata) (net.PacketConn, erro return nil, errUDPBlocked } d.cacheMu.Lock() + if d.closed { + d.cacheMu.Unlock() + return nil, net.ErrClosed + } if d.dohClient == nil { d.dohClient = newDoHHTTPClient(d.connectTarget) } diff --git a/native/mobile/doh.go b/native/mobile/doh.go index b642b9c..1105de3 100644 --- a/native/mobile/doh.go +++ b/native/mobile/doh.go @@ -50,8 +50,8 @@ type dohPacketConn struct { inFlight chan struct{} closed chan struct{} closeOnce sync.Once - deadlineMu sync.Mutex - readDeadline time.Time + readDeadline packetDeadline + writeDeadline packetDeadline client *http.Client context context.Context cancel context.CancelFunc @@ -95,9 +95,15 @@ func newDoHPacketConnWithClient(c config, reporter Reporter, connect func(contex } func (c *dohPacketConn) WriteTo(payload []byte, addr net.Addr) (int, error) { + if len(payload) < 12 || len(payload) > 65535 { + return 0, errors.New("invalid DNS packet size") + } + deadline := c.writeDeadline.wait() select { case <-c.closed: return 0, net.ErrClosed + case <-deadline: + return 0, c.deadlineError() default: } if !c.config.AllowIPv6 { @@ -111,14 +117,17 @@ func (c *dohPacketConn) WriteTo(payload []byte, addr net.Addr) (int, error) { return len(payload), nil } } - query := append([]byte(nil), payload...) select { case c.inFlight <- struct{}{}: + case <-deadline: + return 0, c.deadlineError() case <-c.closed: return 0, net.ErrClosed case <-c.context.Done(): return 0, c.context.Err() } + // Allocate only after admission, so waiting writers cannot retain extra packet copies. + query := append([]byte(nil), payload...) go func() { defer func() { <-c.inFlight }() var lastErr error @@ -215,22 +224,20 @@ func (c *dohPacketConn) deliver(reply dnsReply) { select { case c.replies <- reply: case <-c.closed: + default: + // UDP may drop packets. A stalled reader must not consume every shared query slot. + report(c.reporter, "event=doh result=dropped reason=reply_queue_full") } } func (c *dohPacketConn) ReadFrom(buffer []byte) (int, net.Addr, error) { - c.deadlineMu.Lock() - deadline := c.readDeadline - c.deadlineMu.Unlock() - var timer <-chan time.Time - if !deadline.IsZero() { - duration := time.Until(deadline) - if duration <= 0 { - return 0, nil, timeoutError{} - } - t := time.NewTimer(duration) - defer t.Stop() - timer = t.C + deadline := c.readDeadline.wait() + select { + case <-deadline: + return 0, nil, c.deadlineError() + case <-c.closed: + return 0, nil, net.ErrClosed + default: } select { case reply := <-c.replies: @@ -241,29 +248,57 @@ func (c *dohPacketConn) ReadFrom(buffer []byte) (int, net.Addr, error) { return 0, reply.addr, io.ErrShortBuffer } return copy(buffer, reply.payload), reply.addr, nil - case <-timer: - return 0, nil, timeoutError{} + case <-deadline: + return 0, nil, c.deadlineError() case <-c.closed: return 0, nil, net.ErrClosed } } +// Close also wakes deadline waiters; report closure instead of a random timeout. +func (c *dohPacketConn) deadlineError() error { + select { + case <-c.closed: + return net.ErrClosed + default: + return timeoutError{} + } +} + func (c *dohPacketConn) Close() error { c.closeOnce.Do(func() { close(c.closed) c.cancel() + c.readDeadline.stop() + c.writeDeadline.stop() }) return nil } func (c *dohPacketConn) LocalAddr() net.Addr { return dnsAddr("megaproxy-doh") } func (c *dohPacketConn) SetDeadline(t time.Time) error { - c.deadlineMu.Lock() - c.readDeadline = t - c.deadlineMu.Unlock() + if err := c.SetReadDeadline(t); err != nil { + return err + } + return c.SetWriteDeadline(t) +} +func (c *dohPacketConn) SetReadDeadline(t time.Time) error { + select { + case <-c.closed: + return net.ErrClosed + default: + } + c.readDeadline.set(t) + return nil +} +func (c *dohPacketConn) SetWriteDeadline(t time.Time) error { + select { + case <-c.closed: + return net.ErrClosed + default: + } + c.writeDeadline.set(t) return nil } -func (c *dohPacketConn) SetReadDeadline(t time.Time) error { return c.SetDeadline(t) } -func (c *dohPacketConn) SetWriteDeadline(time.Time) error { return nil } type dnsAddr string @@ -272,6 +307,6 @@ func (a dnsAddr) String() string { return string(a) } type timeoutError struct{} -func (timeoutError) Error() string { return "DNS read deadline exceeded" } +func (timeoutError) Error() string { return "DNS operation deadline exceeded" } func (timeoutError) Timeout() bool { return true } func (timeoutError) Temporary() bool { return true } diff --git a/native/mobile/http2_connect.go b/native/mobile/http2_connect.go index 2cff3c5..8d2da83 100644 --- a/native/mobile/http2_connect.go +++ b/native/mobile/http2_connect.go @@ -27,8 +27,9 @@ type http2ConnectSession struct { func newHTTP2ConnectSession(raw net.Conn) (*http2ConnectSession, error) { transport := &http2.Transport{ - ReadIdleTimeout: 45 * time.Second, - PingTimeout: 10 * time.Second, + ReadIdleTimeout: 45 * time.Second, + MaxHeaderListSize: 64 * 1024, + PingTimeout: 10 * time.Second, } client, err := transport.NewClientConn(raw) if err != nil { @@ -72,7 +73,7 @@ func (s *http2ConnectSession) openTunnel(ctx context.Context, target, authorizat response *http.Response err error } - resultChannel := make(chan result, 1) + resultChannel := make(chan result) go func() { response, err := s.client.RoundTrip(request) select { @@ -112,16 +113,19 @@ func (s *http2ConnectSession) openTunnel(ctx context.Context, target, authorizat // http2StreamConn exposes one HTTP/2 CONNECT stream as a net.Conn. A deadline // closes only this stream, never the shared outer TLS connection. type http2StreamConn struct { - raw net.Conn - reader io.ReadCloser - writer *io.PipeWriter - cancel context.CancelFunc - closeOnce sync.Once - deadlineMu sync.Mutex - readTimer *time.Timer - writeTimer *time.Timer - readExpired bool - writeExpired bool + raw net.Conn + reader io.ReadCloser + writer *io.PipeWriter + cancel context.CancelFunc + closeOnce sync.Once + deadlineMu sync.Mutex + readTimer *time.Timer + writeTimer *time.Timer + readGeneration uint64 + writeGeneration uint64 + closed bool + readExpired bool + writeExpired bool } func newHTTP2StreamConn(raw net.Conn, reader io.ReadCloser, writer *io.PipeWriter, cancel context.CancelFunc) *http2StreamConn { @@ -154,6 +158,7 @@ func (c *http2StreamConn) Close() error { var closeErr error c.closeOnce.Do(func() { c.deadlineMu.Lock() + c.closed = true if c.readTimer != nil { c.readTimer.Stop() } @@ -190,6 +195,11 @@ func (c *http2StreamConn) SetWriteDeadline(deadline time.Time) error { func (c *http2StreamConn) setReadDeadline(deadline time.Time) { c.deadlineMu.Lock() defer c.deadlineMu.Unlock() + if c.closed { + return + } + c.readGeneration++ + generation := c.readGeneration c.readExpired = false if c.readTimer != nil { c.readTimer.Stop() @@ -197,10 +207,7 @@ func (c *http2StreamConn) setReadDeadline(deadline time.Time) { } if !deadline.IsZero() { c.readTimer = time.AfterFunc(time.Until(deadline), func() { - c.deadlineMu.Lock() - c.readExpired = true - c.deadlineMu.Unlock() - _ = c.Close() + c.expireDeadline(true, generation) }) } } @@ -208,6 +215,11 @@ func (c *http2StreamConn) setReadDeadline(deadline time.Time) { func (c *http2StreamConn) setWriteDeadline(deadline time.Time) { c.deadlineMu.Lock() defer c.deadlineMu.Unlock() + if c.closed { + return + } + c.writeGeneration++ + generation := c.writeGeneration c.writeExpired = false if c.writeTimer != nil { c.writeTimer.Stop() @@ -215,10 +227,28 @@ func (c *http2StreamConn) setWriteDeadline(deadline time.Time) { } if !deadline.IsZero() { c.writeTimer = time.AfterFunc(time.Until(deadline), func() { - c.deadlineMu.Lock() - c.writeExpired = true - c.deadlineMu.Unlock() - _ = c.Close() + c.expireDeadline(false, generation) }) } } + +func (c *http2StreamConn) expireDeadline(read bool, generation uint64) { + c.deadlineMu.Lock() + current := c.writeGeneration + if read { + current = c.readGeneration + } + if c.closed || generation != current { + c.deadlineMu.Unlock() + return + } + if read { + c.readExpired = true + } else { + c.writeExpired = true + } + // Commit expiration under the same lock as SetDeadline, before closing I/O. + c.closed = true + c.deadlineMu.Unlock() + _ = c.Close() +} diff --git a/native/mobile/http2_connect_test.go b/native/mobile/http2_connect_test.go index fde231c..5610916 100644 --- a/native/mobile/http2_connect_test.go +++ b/native/mobile/http2_connect_test.go @@ -3,11 +3,13 @@ package mobile import ( "context" "crypto/tls" + "errors" "fmt" "io" "net" "net/http" "net/http/httptest" + "strings" "sync" "testing" "time" @@ -123,3 +125,62 @@ func isTimeout(err error) bool { value, ok := err.(interface{ Timeout() bool }) return ok && value.Timeout() } + +type rejectedHTTP2Client struct{ closed bool } + +func (c *rejectedHTTP2Client) CanTakeNewRequest() bool { return !c.closed } +func (c *rejectedHTTP2Client) Close() error { c.closed = true; return nil } +func (c *rejectedHTTP2Client) RoundTrip(r *http.Request) (*http.Response, error) { + _ = r.Body.Close() + return &http.Response{StatusCode: http.StatusBadGateway, Body: io.NopCloser(strings.NewReader(""))}, nil +} +func TestHTTP2RejectedTargetPreservesSession(t *testing.T) { + client := &rejectedHTTP2Client{} + session := &http2ConnectSession{client: client} + d := &httpsConnectDialer{h2Session: session} + _, err := d.connectTarget(context.Background(), "example.com:443") + var rejected *http2ConnectStatusError + if !errors.As(err, &rejected) || rejected.status != http.StatusBadGateway { + t.Fatalf("error = %v", err) + } + if client.closed || d.currentHTTP2Session() != session { + t.Fatal("target failure closed shared session") + } +} + +func TestSupersededHTTP2DeadlineCannotCloseStream(t *testing.T) { + left, right := net.Pipe() + defer left.Close() + defer right.Close() + reader, writer := io.Pipe() + stream := newHTTP2StreamConn(left, reader, writer, func() {}) + defer stream.Close() + _ = stream.SetReadDeadline(time.Now().Add(time.Hour)) + old := stream.readGeneration + _ = stream.SetReadDeadline(time.Time{}) + stream.expireDeadline(true, old) // Timer callback already queued before Stop. + if stream.closed || stream.readExpired { + t.Fatal("old read timer expired a cleared deadline") + } + _ = stream.SetWriteDeadline(time.Now().Add(time.Hour)) + old = stream.writeGeneration + _ = stream.SetWriteDeadline(time.Time{}) + stream.expireDeadline(false, old) + if stream.closed || stream.writeExpired { + t.Fatal("old write timer expired a cleared deadline") + } + _ = stream.Close() + _ = stream.SetReadDeadline(time.Now().Add(time.Hour)) + if stream.readTimer != nil && !stream.readTimer.Stop() { + t.Fatal("closed stream installed an active timer") + } +} + +func TestClosedDialerRejectsLateHTTP2Session(t *testing.T) { + d := &httpsConnectDialer{} + _ = d.Close() + client := &rejectedHTTP2Client{} + if session := d.installHTTP2Session(&http2ConnectSession{client: client}); session != nil || !client.closed { + t.Fatal("late handshake resurrected a closed dialer") + } +} diff --git a/native/mobile/mobile.go b/native/mobile/mobile.go index 959cf82..74a3339 100644 --- a/native/mobile/mobile.go +++ b/native/mobile/mobile.go @@ -10,9 +10,12 @@ import ( "syscall" "time" + "golang.org/x/sys/unix" + "github.com/xjasonlyu/tun2socks/v2/core" "github.com/xjasonlyu/tun2socks/v2/core/device" "github.com/xjasonlyu/tun2socks/v2/core/device/fdbased" + "github.com/xjasonlyu/tun2socks/v2/proxy/reject" "github.com/xjasonlyu/tun2socks/v2/tunnel" "gvisor.dev/gvisor/pkg/tcpip/stack" ) @@ -22,13 +25,19 @@ var state struct { generation uint64 starting bool running bool + stopping bool device device.Device stack *stack.Stack proxyCloser io.Closer } -// Start takes ownership of a duplicate of tunFD held by Android's ParcelFileDescriptor. -func Start(tunFD int, rawConfig string, protector Protector, reporter Reporter) error { +// Start borrows tunFD for this call. Android keeps its ParcelFileDescriptor open; +// Go owns only the duplicate made here, including every failure path. +func Start(tunFD int, mtu int, rawConfig string, protector Protector, reporter Reporter) error { + tunFD, err := duplicateTunFD(tunFD) + if err != nil { + return err + } c, err := parseConfig(rawConfig) if err != nil { if tunFD >= 0 { @@ -36,14 +45,14 @@ func Start(tunFD int, rawConfig string, protector Protector, reporter Reporter) } return err } - if tunFD < 0 || protector == nil { + if protector == nil || mtu < 1280 || mtu > 65535 { if tunFD >= 0 { _ = syscall.Close(tunFD) } return errors.New("invalid Android VPN bridge") } state.Lock() - if state.running || state.starting { + if state.running || state.starting || state.stopping { state.Unlock() _ = syscall.Close(tunFD) return errors.New("proxy core is already running") @@ -58,11 +67,13 @@ func Start(tunFD int, rawConfig string, protector Protector, reporter Reporter) return } state.Lock() + // The global tunnel must not retain a failed dialer and its Java callbacks. + tunnel.T().SetProxy(&reject.Reject{}) state.starting = false state.Unlock() }() resetStats() - dev, err := fdbased.Open(strconv.Itoa(tunFD), 1500, 0) + dev, err := fdbased.Open(strconv.Itoa(tunFD), uint32(mtu), 0) if err != nil { _ = syscall.Close(tunFD) return err @@ -89,11 +100,15 @@ func Start(tunFD int, rawConfig string, protector Protector, reporter Reporter) netstack, err := core.CreateStack(&core.Config{LinkEndpoint: dev, TransportHandler: t}) if err != nil { dev.Close() + if proxyCloser != nil { + _ = proxyCloser.Close() + } return err } state.Lock() if state.generation != generation || !state.starting { state.Unlock() + dev.Close() netstack.Close() netstack.Wait() if proxyCloser != nil { @@ -109,13 +124,37 @@ func Start(tunFD int, rawConfig string, protector Protector, reporter Reporter) return nil } +func duplicateTunFD(fd int) (int, error) { + if fd < 0 { + return -1, errors.New("invalid Android TUN descriptor") + } + return unix.FcntlInt(uintptr(fd), unix.F_DUPFD_CLOEXEC, 0) +} + func Stop() { state.Lock() state.generation++ + if state.stopping { + state.Unlock() + return + } + state.stopping = true + // 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{}) state.running = false dev, netstack, proxyCloser := state.device, state.stack, state.proxyCloser state.device, state.stack, state.proxyCloser = nil, nil, nil state.Unlock() + defer func() { + state.Lock() + state.stopping = false + state.Unlock() + }() + // Wake blocked upstream operations before waiting for the stack to finish. + if proxyCloser != nil { + _ = proxyCloser.Close() + } if dev != nil { dev.Close() } @@ -123,7 +162,4 @@ func Stop() { netstack.Close() netstack.Wait() } - if proxyCloser != nil { - _ = proxyCloser.Close() - } } diff --git a/native/mobile/packet_deadline.go b/native/mobile/packet_deadline.go new file mode 100644 index 0000000..597068c --- /dev/null +++ b/native/mobile/packet_deadline.go @@ -0,0 +1,82 @@ +package mobile + +import ( + "sync" + "time" +) + +// A deadline channel is shared by already-blocked and future packet operations. +// Resetting a timer cannot allow its stale callback to expire a newer deadline. +type packetDeadline struct { + mu sync.Mutex + timer *time.Timer + generation uint64 + expired bool + closed bool + signal chan struct{} +} + +func (d *packetDeadline) wait() <-chan struct{} { + d.mu.Lock() + defer d.mu.Unlock() + if d.signal == nil { + d.signal = make(chan struct{}) + } + return d.signal +} + +func (d *packetDeadline) set(t time.Time) { + d.mu.Lock() + defer d.mu.Unlock() + if d.closed { + return + } + d.generation++ + generation := d.generation + if d.timer != nil { + d.timer.Stop() + d.timer = nil + } + if d.signal == nil || d.expired { + d.signal = make(chan struct{}) + } + d.expired = false + if t.IsZero() { + return + } + if !t.After(time.Now()) { + d.expired = true + close(d.signal) + return + } + d.timer = time.AfterFunc(time.Until(t), func() { + d.mu.Lock() + defer d.mu.Unlock() + if d.generation != generation || d.expired { + return + } + d.expired = true + close(d.signal) + }) +} + +func (d *packetDeadline) stop() { + d.mu.Lock() + defer d.mu.Unlock() + if d.closed { + return + } + d.closed = true + d.generation++ + if d.timer != nil { + d.timer.Stop() + d.timer = nil + } + if d.signal == nil { + d.signal = make(chan struct{}) + } + if !d.expired { + close(d.signal) + d.expired = true + } +} diff --git a/native/mobile/robustness_test.go b/native/mobile/robustness_test.go new file mode 100644 index 0000000..a6138e6 --- /dev/null +++ b/native/mobile/robustness_test.go @@ -0,0 +1,178 @@ +package mobile + +import ( + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "net" + "strings" + "testing" + "time" +) + +const validNativeConfig = `{"host":"proxy.example","dialHost":"192.0.2.1","port":443,"username":"u","password":"p","profile":"CHROME_ANDROID","dohUrl":"https://dns.google/dns-query"}` + +func TestNativeConfigLimits(t *testing.T) { + for _, field := range []string{"sshKeepaliveSeconds", "sshRotationMinutes", "sshRotationMb"} { + for _, value := range []int64{-1, 9223372036854775807} { + var fields map[string]any + if err := json.Unmarshal([]byte(validNativeConfig), &fields); err != nil { + t.Fatal(err) + } + fields[field] = value + raw, err := json.Marshal(fields) + if err != nil { + t.Fatal(err) + } + if _, err := parseConfig(string(raw)); err == nil { + t.Fatalf("accepted %s=%d", field, value) + } + } + } + raw := strings.TrimSuffix(validNativeConfig, "}") + `,"unused":"` + strings.Repeat("x", 1024*1024) + `"}` + if _, err := parseConfig(raw); err == nil { + t.Fatal("accepted oversized JSON") + } + if _, err := TestConnection(validNativeConfig, nil, nil); err == nil || !strings.Contains(err.Error(), "protector") { + t.Fatalf("missing protector: %v", err) + } + if _, _, err := buildAQuery(strings.Repeat("a.", 200)); err == nil { + t.Fatal("accepted oversized DNS name") + } +} + +func TestDoHReplyQueueCannotBlockWriters(t *testing.T) { + c := newDoHPacketConnWithClient(config{}, nil, nil, "https://dns.example/query", nil, nil) + defer c.Close() + query, _, err := buildAQuery("example.com") + if err != nil { + t.Fatal(err) + } + binary.BigEndian.PutUint16(query[len(query)-4:], 28) + done := make(chan error, 1) + go func() { + for i := 0; i < 100; i++ { + if _, err := c.WriteTo(query, dnsAddr("dns")); err != nil { + done <- err + return + } + } + done <- nil + }() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("full reply queue blocked DNS writers") + } + if len(c.replies) != cap(c.replies) { + t.Fatal("reply queue not bounded at capacity") + } + if _, err := c.WriteTo(make([]byte, 65536), dnsAddr("dns")); err == nil { + t.Fatal("accepted oversized DNS packet") + } +} + +func TestDoHDeadlinesWakePendingOperations(t *testing.T) { + c := newDoHPacketConnWithClient(config{AllowIPv6: true}, nil, nil, "https://dns.example/query", nil, make(chan struct{}, 1)) + defer c.Close() + read := make(chan error, 1) + go func() { _, _, err := c.ReadFrom(make([]byte, 512)); read <- err }() + if err := c.SetReadDeadline(time.Now().Add(20 * time.Millisecond)); err != nil { + t.Fatal(err) + } + select { + case err := <-read: + if !isTimeout(err) { + t.Fatalf("read: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("pending read ignored updated deadline") + } + if err := c.SetReadDeadline(time.Time{}); err != nil { + t.Fatal(err) + } + c.deliver(dnsReply{payload: []byte{1}, addr: dnsAddr("dns")}) + if n, _, err := c.ReadFrom(make([]byte, 512)); err != nil || n != 1 { + t.Fatalf("cleared deadline: %d %v", n, err) + } + c.inFlight <- struct{}{} // Hold every provider slot, without making a network request. + query, _, err := buildAQuery("example.com") + if err != nil { + t.Fatal(err) + } + write := make(chan error, 1) + go func() { _, err := c.WriteTo(query, dnsAddr("dns")); write <- err }() + if err := c.SetWriteDeadline(time.Now().Add(20 * time.Millisecond)); err != nil { + t.Fatal(err) + } + select { + case err := <-write: + if !isTimeout(err) { + t.Fatalf("write: %v", err) + } + case <-time.After(2 * time.Second): + t.Fatal("pending write ignored deadline") + } +} + +func TestClosedPacketDeadlineCannotRetainNewTimers(t *testing.T) { + var d packetDeadline + d.stop() + d.set(time.Now().Add(time.Hour)) + if d.timer != nil { + t.Fatal("closed deadline installed a timer") + } + select { + case <-d.wait(): + default: + t.Fatal("closed deadline did not signal") + } +} + +func FuzzNativeParsers(f *testing.F) { + f.Add([]byte(validNativeConfig)) + f.Add([]byte("771,4865,0,29,0")) + f.Add([]byte{0, 1, 128, 0, 0, 1, 0, 0, 0, 0, 0, 0}) + f.Fuzz(func(t *testing.T, data []byte) { + if len(data) > 1024*1024 { + t.Skip() + } + _, _ = parseConfig(string(data)) + _, _ = parseJA3(string(data)) + _, _, _ = buildAQuery(string(data)) + _, _ = parseAResponse(data, 1) + _, _, _ = emptyAAAAResponse(data) + _, _ = skipDNSName(data, -1) + _, _ = skipDNSName(data, 0) + }) +} + +// Keep this fixture compile-checked against the production packet interface. +var _ net.PacketConn = (*dohPacketConn)(nil) + +func TestWrappedTimeoutClassification(t *testing.T) { + if got := errorClass(fmt.Errorf("dial proxy: %w", timeoutError{})); got != "timeout" { + t.Fatalf("wrapped timeout classified as %s", got) + } +} + +func TestClosedDoHDoesNotRandomlyReportDeadlineExceeded(t *testing.T) { + c := newDoHPacketConnWithClient(config{}, nil, nil, "https://dns.example/query", nil, nil) + _ = c.Close() + query, _, err := buildAQuery("example.com") + if err != nil { + t.Fatal(err) + } + for i := 0; i < 100; i++ { + if _, _, err := c.ReadFrom(make([]byte, 512)); !errors.Is(err, net.ErrClosed) { + t.Fatalf("read after close: %v", err) + } + if _, err := c.WriteTo(query, dnsAddr("dns")); !errors.Is(err, net.ErrClosed) { + t.Fatalf("write after close: %v", err) + } + } +} diff --git a/native/mobile/ssh_dialer.go b/native/mobile/ssh_dialer.go index c37451b..748e8fc 100644 --- a/native/mobile/ssh_dialer.go +++ b/native/mobile/ssh_dialer.go @@ -25,6 +25,7 @@ type sshDialer struct { reporter Reporter mu sync.Mutex client *ssh.Client + closed bool jumpClient *ssh.Client channels chan struct{} sessionCreated time.Time @@ -39,6 +40,12 @@ func (d *sshDialer) DialContext(ctx context.Context, metadata *M.Metadata) (net. } func (d *sshDialer) connectTarget(ctx context.Context, target string) (net.Conn, error) { + d.mu.Lock() + closed := d.closed + d.mu.Unlock() + if closed { + return nil, net.ErrClosed + } if !d.config.AllowIPv6 { host, _, _ := net.SplitHostPort(target) if ip := net.ParseIP(host); ip != nil && ip.To4() == nil { @@ -76,10 +83,19 @@ func (d *sshDialer) connectTarget(ctx context.Context, target string) (net.Conn, } started := time.Now() dialContext, cancelDial := context.WithTimeout(ctx, 20*time.Second) - conn, err := client.DialContext(dialContext, "tcp", target) + conn, err, abandoned := dialSSHChannel(dialContext, client, target, func() { <-channels }, func() { + report(d.reporter, "event=ssh_session result=stalled reason=abandoned_channel_open") + d.invalidateClient(client) + }, 30*time.Second) + if abandoned { + release = false + } // The blocked worker keeps its slot until it actually ends. cancelDial() if err != nil { - d.invalidate() + var rejected *ssh.OpenChannelError + if !errors.As(err, &rejected) && !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) { + d.invalidateClient(client) + } report(d.reporter, "event=connection mode=ssh stage=direct_tcpip result=failed reason=%s", errorClass(err)) recordConnectionOutcome(false) return nil, fmt.Errorf("SSH direct-tcpip: %w", err) @@ -94,6 +110,9 @@ func (d *sshDialer) connectTarget(ctx context.Context, target string) (net.Conn, func (d *sshDialer) session(ctx context.Context) (*ssh.Client, error) { d.mu.Lock() defer d.mu.Unlock() + if d.closed { + return nil, net.ErrClosed + } if d.client != nil { return d.client, nil } @@ -376,9 +395,15 @@ func (d *sshDialer) sharedDoHResources() (*http.Client, chan struct{}) { return d.dohClient, d.dohInFlight } -func (d *sshDialer) invalidate() { +func (d *sshDialer) invalidate() { d.invalidateClient(nil) } + +func (d *sshDialer) invalidateClient(expected *ssh.Client) { d.mu.Lock() defer d.mu.Unlock() + // An error from an old channel must not tear down a replacement session. + if expected != nil && d.client != expected { + return + } if d.client != nil { d.client.Close() } @@ -393,6 +418,9 @@ func (d *sshDialer) invalidate() { } func (d *sshDialer) Close() error { + d.mu.Lock() + d.closed = true + d.mu.Unlock() d.invalidate() d.mu.Lock() if d.dohClient != nil { @@ -433,6 +461,8 @@ func (d *sshDialer) startKeepalive() { select { case <-ticker.C: if _, _, err := client.SendRequest("keepalive@openssh.com", true, nil); err != nil { + report(d.reporter, "event=ssh_keepalive result=failed reason=%s", errorClass(err)) + d.invalidateClient(client) return } case <-stop: @@ -460,3 +490,51 @@ func (c *sshTrackedConn) Write(p []byte) (int, error) { return n, err } func (c *sshTrackedConn) Close() error { err := c.Conn.Close(); c.once.Do(c.release); return err } + +// x/crypto's DialContext returns on cancellation while its internal Dial may still +// wait for CHANNEL_OPEN confirmation. Keep admission charged to that worker. +func dialSSHChannel(ctx context.Context, client *ssh.Client, target string, releaseAbandoned, abortStalled func(), grace time.Duration) (net.Conn, error, bool) { + if err := ctx.Err(); err != nil { + return nil, err, false + } + type result struct { + conn net.Conn + err error + } + results := make(chan result) + workerDone := make(chan struct{}) + go func() { + defer close(workerDone) + conn, err := client.Dial("tcp", target) + select { + case results <- result{conn, err}: + case <-ctx.Done(): + if conn != nil { + _ = conn.Close() + } + releaseAbandoned() + } + }() + select { + case outcome := <-results: + return outcome.conn, outcome.err, false + case <-ctx.Done(): + // Cancellation must not release admission early, but a peer that never answers + // CHANNEL_OPEN must not consume the entire pool forever either. + go func() { + timer := time.NewTimer(grace) + defer timer.Stop() + select { + case <-workerDone: + case <-timer.C: + select { + case <-workerDone: + return + default: + abortStalled() + } + } + }() + return nil, ctx.Err(), true + } +} diff --git a/native/mobile/ssh_dialer_test.go b/native/mobile/ssh_dialer_test.go index 9280d54..2afaa31 100644 --- a/native/mobile/ssh_dialer_test.go +++ b/native/mobile/ssh_dialer_test.go @@ -1,10 +1,14 @@ package mobile import ( + "context" "crypto/ed25519" "crypto/rand" + "io" "strings" + "sync" "testing" + "time" "golang.org/x/crypto/ssh" ) @@ -65,3 +69,137 @@ func TestSSHAuthenticationModes(t *testing.T) { t.Fatalf("password-only got %d methods", len(methods)) } } + +type rejectedSSHConn struct { + ssh.Conn + closed bool + failure error +} + +func (c *rejectedSSHConn) OpenChannel(string, []byte) (ssh.Channel, <-chan *ssh.Request, error) { + return nil, nil, c.failure +} +func (c *rejectedSSHConn) Close() error { c.closed = true; return nil } + +func TestSSHChannelFailureIsolation(t *testing.T) { + for _, failure := range []error{&ssh.OpenChannelError{Reason: ssh.ConnectionFailed}, context.Canceled, context.DeadlineExceeded, io.EOF} { + t.Run(failure.Error(), func(t *testing.T) { + conn := &rejectedSSHConn{failure: failure} + client := &ssh.Client{Conn: conn} + d := &sshDialer{client: client, config: config{SSHMaxChannels: 2}} + _, err := d.connectTarget(context.Background(), "example.com:443") + if err == nil { + t.Fatal("expected failure") + } + wantClosed := failure == io.EOF + if conn.closed != wantClosed { + t.Fatalf("session closed=%t want %t", conn.closed, wantClosed) + } + if len(d.channels) != 0 { + t.Fatal("channel slot leaked") + } + }) + } +} + +func TestSSHOldFailureDoesNotCloseReplacement(t *testing.T) { + current := &rejectedSSHConn{} + d := &sshDialer{client: &ssh.Client{Conn: current}} + d.invalidateClient(&ssh.Client{}) + if current.closed || d.client == nil { + t.Fatal("old session invalidated its replacement") + } +} + +type blockedSSHConn struct { + ssh.Conn + entered chan struct{} + unblock chan struct{} + once sync.Once +} + +func (c *blockedSSHConn) OpenChannel(string, []byte) (ssh.Channel, <-chan *ssh.Request, error) { + close(c.entered) + <-c.unblock + return nil, nil, io.EOF +} +func (c *blockedSSHConn) Close() error { c.once.Do(func() { close(c.unblock) }); return nil } + +func TestCancelledSSHOpenKeepsAdmissionUntilWorkerEnds(t *testing.T) { + raw := &blockedSSHConn{entered: make(chan struct{}), unblock: make(chan struct{})} + defer raw.Close() + client := &ssh.Client{Conn: raw} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + released := make(chan struct{}) + done := make(chan bool, 1) + go func() { + _, _, abandoned := dialSSHChannel(ctx, client, "example.com:443", func() { close(released) }, func() { t.Error("healthy worker aborted") }, time.Hour) + done <- abandoned + }() + <-raw.entered + cancel() + select { + case abandoned := <-done: + if !abandoned { + t.Fatal("worker not tracked") + } + case <-time.After(time.Second): + t.Fatal("cancel blocked") + } + select { + case <-released: + t.Fatal("released slot with worker still blocked") + default: + } + _ = raw.Close() + select { + case <-released: + case <-time.After(time.Second): + t.Fatal("worker did not release slot after transport closed") + } +} + +func TestClosedSSHDialerCannotReconnect(t *testing.T) { + d := &sshDialer{config: config{BypassLocalNetworks: true}} + _ = d.Close() + if _, err := d.session(context.Background()); err == nil { + t.Fatal("closed SSH dialer attempted a session") + } + if _, err := d.connectTarget(context.Background(), "127.0.0.1:443"); err == nil { + t.Fatal("closed SSH dialer used direct bypass") + } +} + +func TestAbandonedSSHOpenEventuallyReleasesPool(t *testing.T) { + raw := &blockedSSHConn{entered: make(chan struct{}), unblock: make(chan struct{})} + defer raw.Close() + client := &ssh.Client{Conn: raw} + d := &sshDialer{client: client} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + released := make(chan struct{}) + done := make(chan bool, 1) + go func() { + _, _, abandoned := dialSSHChannel(ctx, client, "example.com:443", func() { close(released) }, func() { d.invalidateClient(client) }, 20*time.Millisecond) + done <- abandoned + }() + <-raw.entered + cancel() + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("caller did not cancel") + } + select { + case <-released: + case <-time.After(time.Second): + t.Fatal("unresponsive peer permanently occupied admission") + } + d.mu.Lock() + current := d.client + d.mu.Unlock() + if current != nil { + t.Fatal("stalled session not cleared for next connection") + } +} diff --git a/native/mobile/test_connection.go b/native/mobile/test_connection.go index 909d3d5..18c62b8 100644 --- a/native/mobile/test_connection.go +++ b/native/mobile/test_connection.go @@ -5,6 +5,7 @@ import ( "context" stdtls "crypto/tls" "encoding/json" + "errors" "fmt" "io" "net" @@ -38,6 +39,9 @@ type connectionTestResult struct { // TestConnection verifies the configured proxy path without starting a TUN device. func TestConnection(rawConfig string, protector Protector, reporter Reporter) (string, error) { + if protector == nil { + return "", errors.New("Android socket protector is required") + } c, err := parseConfig(rawConfig) if err != nil { return "", err @@ -143,6 +147,9 @@ func testHTTPSGet(ctx context.Context, connect func(context.Context, string) (ne return "", err } defer tunnel.Close() + // Close the raw tunnel directly: TLS Close can wait to send close_notify. + stopCancellation := context.AfterFunc(ctx, func() { _ = tunnel.Close() }) + defer stopCancellation() connection := stdtls.Client(tunnel, &stdtls.Config{ServerName: host, MinVersion: stdtls.VersionTLS12}) if err := connection.HandshakeContext(ctx); err != nil { @@ -151,6 +158,13 @@ func testHTTPSGet(ctx context.Context, connect func(context.Context, string) (ne tlsState := connection.ConnectionState() report(reporter, "event=connection_test stage=destination_tls result=success certificate=verified version=0x%04x cipher=0x%04x alpn=%s h2_negotiated=%t session_resumed=%t", tlsState.Version, tlsState.CipherSuite, normalizedALPN(tlsState.NegotiatedProtocol), tlsState.NegotiatedProtocol == "h2", tlsState.DidResume) + return testHTTPExchange(ctx, connection, host, path, readBody) +} + +func testHTTPExchange(ctx context.Context, connection net.Conn, host, path string, readBody bool) (string, error) { + // Request.Write/ReadResponse use raw I/O and do not observe Request.Context. + stopCancellation := context.AfterFunc(ctx, func() { _ = connection.Close() }) + defer stopCancellation() request, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://"+host+path, nil) if err != nil { return "", err @@ -160,7 +174,7 @@ func testHTTPSGet(ctx context.Context, connect func(context.Context, string) (ne if err := request.Write(connection); err != nil { return "", fmt.Errorf("write HTTPS request: %w", err) } - response, err := http.ReadResponse(bufio.NewReader(connection), request) + response, err := http.ReadResponse(bufio.NewReader(&limitedHeaderReader{reader: connection, remaining: 64 * 1024}), request) if err != nil { return "", fmt.Errorf("read HTTPS response: %w", err) } diff --git a/native/mobile/test_connection_test.go b/native/mobile/test_connection_test.go index 92e0d75..796fdd3 100644 --- a/native/mobile/test_connection_test.go +++ b/native/mobile/test_connection_test.go @@ -1,9 +1,13 @@ package mobile import ( + "bufio" "context" "errors" + "net" + "net/http" "testing" + "time" ) func TestParseIPAddress(t *testing.T) { @@ -75,3 +79,25 @@ func TestLookupEndpointValueFailsAfterEveryProvider(t *testing.T) { t.Fatalf("lookupEndpointValue returned value=%q err=%v attempts=%d", value, err, attempts) } } + +func TestHTTPExchangeCancellationInterruptsResponse(t *testing.T) { + client, server := net.Pipe() + defer client.Close() + defer server.Close() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { _, err := testHTTPExchange(ctx, client, "example.com", "/", true); done <- err }() + if _, err := http.ReadRequest(bufio.NewReader(server)); err != nil { + t.Fatal(err) + } + cancel() // Peer has accepted the request but never sends response headers. + select { + case err := <-done: + if err == nil { + t.Fatal("cancellation succeeded without an error") + } + case <-time.After(2 * time.Second): + t.Fatal("HTTP read ignored cancellation") + } +} diff --git a/scripts/build-fdroid-native.sh b/scripts/build-fdroid-native.sh index 50a6eb8..31b572c 100755 --- a/scripts/build-fdroid-native.sh +++ b/scripts/build-fdroid-native.sh @@ -4,6 +4,8 @@ set -euo pipefail script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" project_dir="$(cd -- "$script_dir/.." && pwd)" +source "$project_dir/scripts/java-toolchain.sh" + gomobile_version="v0.0.0-20260821190718-4776eadac327" : "${ANDROID_HOME:?ANDROID_HOME must point to the Android SDK}" diff --git a/scripts/build-release-apks.sh b/scripts/build-release-apks.sh index ea1a307..44e22c0 100755 --- a/scripts/build-release-apks.sh +++ b/scripts/build-release-apks.sh @@ -8,7 +8,7 @@ if [[ -f "$HOME/.zshrc.extra" ]]; then source "$HOME/.zshrc.extra" fi -: "${JAVA_HOME:=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home}" +source "$project_dir/scripts/java-toolchain.sh" : "${ANDROID_HOME:=$HOME/Library/Android/sdk}" : "${ANDROID_NDK_HOME:=$ANDROID_HOME/ndk/29.0.14206865}" : "${MEGAPROXY_KEYSTORE_PATH:=$HOME/AndroidApkKey}" diff --git a/scripts/build-release-bundle.sh b/scripts/build-release-bundle.sh index 570f67e..cc9aacd 100755 --- a/scripts/build-release-bundle.sh +++ b/scripts/build-release-bundle.sh @@ -7,7 +7,7 @@ if [[ -f "$HOME/.zshrc.extra" ]]; then source "$HOME/.zshrc.extra" fi -: "${JAVA_HOME:=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home}" +source "$project_dir/scripts/java-toolchain.sh" : "${ANDROID_HOME:=$HOME/Library/Android/sdk}" : "${ANDROID_NDK_HOME:=$ANDROID_HOME/ndk/29.0.14206865}" : "${MEGAPROXY_KEYSTORE_PATH:=$HOME/AndroidApkKey}" diff --git a/scripts/ci_changes.py b/scripts/ci_changes.py index a67c11a..1aa4310 100644 --- a/scripts/ci_changes.py +++ b/scripts/ci_changes.py @@ -81,7 +81,12 @@ def changed_files(base, head, pull_request=True): ] -def select_suites(base, head, pull_request=True, baselines=None): +def select_suites(base, head, pull_request=True, baselines=None, force_all=False): + if force_all: + return {suite: True for suite in SUITES}, { + suite: {"base": base, "run_id": None, "changed_files": None} + for suite in SUITES + } baselines = baselines or {} result = {} details = {} @@ -105,19 +110,28 @@ def main(): parser.add_argument( "--push", action="store_true", - help="Compare push endpoints instead of the PR merge base", + help="Run all suites for push CI (the workflow only accepts pushes to main)", ) parser.add_argument( "--history", action="store_true", help="Reuse successful ancestor checks for this PR", ) + parser.add_argument( + "--run-attempt", + type=int, + default=1, + help="GitHub run attempt; rerunning change scope forces all suites", + ) parser.add_argument("--github-output", type=Path) parser.add_argument("--summary", type=Path) args = parser.parse_args() + if args.run_attempt < 1: + parser.error("--run-attempt must be positive") + force_all = args.push or args.run_attempt > 1 try: baselines = {} - if args.history and not args.push: + if args.history and not args.push and not force_all: try: baselines = successful_baselines( os.environ["GITHUB_REPOSITORY"], @@ -138,8 +152,10 @@ def main(): print( "Check history unavailable; using the full PR diff", file=sys.stderr ) - result, details = select_suites(args.base, args.head, not args.push, baselines) - print(json.dumps({**result, "comparisons": details})) + result, details = select_suites( + args.base, args.head, not args.push, baselines, force_all + ) + print(json.dumps({**result, "forced": force_all, "comparisons": details})) if args.github_output: with args.github_output.open("a") as output: for suite, enabled in result.items(): @@ -150,12 +166,20 @@ def main(): for suite, enabled in result.items(): detail = details[suite] origin = ( - f"successful run {detail['run_id']}" - if detail["run_id"] + ( + "push CI (change filtering disabled)" + if args.push + else "full CI rerun (change filtering disabled)" + ) + if force_all else ( - "full PR diff (no reusable success)" - if not args.push - else "push endpoints" + f"successful run {detail['run_id']}" + if detail["run_id"] + else ( + "full PR diff (no reusable success)" + if not args.push + else "push endpoints" + ) ) ) summary.write( diff --git a/scripts/github_actions.py b/scripts/github_actions.py index cc93c6c..4df0a46 100755 --- a/scripts/github_actions.py +++ b/scripts/github_actions.py @@ -11,7 +11,7 @@ REPOSITORY = "andre487/AndroidMegaProxy" MODES = [ - ("ci", "Re-run all CI jobs"), + ("ci", "Re-run all CI jobs, including skipped checks"), ("failed", "Re-run failed CI jobs only"), ] PR_FIELDS = "number,title,state,headRefName,headRefOid,isCrossRepository" diff --git a/scripts/java-toolchain.sh b/scripts/java-toolchain.sh new file mode 100644 index 0000000..acc7d66 --- /dev/null +++ b/scripts/java-toolchain.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# Source this file to select JDK 21 without installation-specific paths. +megaproxy_java_toolchain() { + local candidate="${JAVA_HOME:-}" properties version + if [[ -z "$candidate" && -x /usr/libexec/java_home ]]; then + candidate="$(/usr/libexec/java_home -v 21 2>/dev/null || true)" + fi + if [[ -n "$candidate" ]]; then + properties="$("$candidate/bin/java" -XshowSettings:properties -version 2>&1)" || { + echo "JAVA_HOME must point to a working JDK 21." >&2 + return 1 + } + else + properties="$(java -XshowSettings:properties -version 2>&1)" || { + echo "JDK 21 is required; configure JAVA_HOME or add it to PATH." >&2 + return 1 + } + candidate="$(sed -n 's/^[[:space:]]*java.home = //p' <<< "$properties")" + fi + version="$(sed -n 's/^[[:space:]]*java.specification.version = //p' <<< "$properties")" + if [[ "$version" != 21 || ! -x "$candidate/bin/javac" ]]; then + echo "JDK 21 is required; configure JAVA_HOME or add it to PATH." >&2 + return 1 + fi + export JAVA_HOME="$candidate" + export PATH="$JAVA_HOME/bin:$PATH" +} +megaproxy_java_toolchain diff --git a/scripts/tests/test_ci_changes.py b/scripts/tests/test_ci_changes.py index 7bc607e..842ea11 100644 --- a/scripts/tests/test_ci_changes.py +++ b/scripts/tests/test_ci_changes.py @@ -19,6 +19,99 @@ class ChangeScopeTest(unittest.TestCase): + def test_main_push_runs_every_suite_even_without_code_changes(self): + with tempfile.TemporaryDirectory() as root: + output = Path(root) / "output" + summary = Path(root) / "summary" + with ( + patch.object( + sys, + "argv", + [ + "ci_changes.py", + "--base", + "a" * 40, + "--head", + "a" * 40, + "--push", + "--run-attempt", + "1", + "--history", + "--github-output", + str(output), + "--summary", + str(summary), + ], + ), + patch.object(m, "successful_baselines", side_effect=AssertionError), + patch.object(m, "changed_files", side_effect=AssertionError), + patch.object(sys, "stdout", io.StringIO()) as stdout, + ): + self.assertEqual(0, m.main()) + self.assertTrue(json.loads(stdout.getvalue())["forced"]) + self.assertEqual( + "android=true\nnative=true\npython=true\n", output.read_text() + ) + self.assertIn("push CI (change filtering disabled)", summary.read_text()) + + def test_full_rerun_enables_skipped_suites_without_diff_or_history(self): + with tempfile.TemporaryDirectory() as root: + output = Path(root) / "output" + summary = Path(root) / "summary" + with ( + patch.object( + sys, + "argv", + [ + "ci_changes.py", + "--base", + "a" * 40, + "--head", + "b" * 40, + "--history", + "--run-attempt", + "2", + "--github-output", + str(output), + "--summary", + str(summary), + ], + ), + patch.object(m, "successful_baselines", side_effect=AssertionError), + patch.object(m, "changed_files", side_effect=AssertionError), + patch.object(sys, "stdout", io.StringIO()) as stdout, + ): + self.assertEqual(0, m.main()) + result = json.loads(stdout.getvalue()) + self.assertTrue(result["forced"]) + self.assertEqual( + "android=true\nnative=true\npython=true\n", output.read_text() + ) + self.assertIn("change filtering disabled", summary.read_text()) + + def test_initial_attempt_still_filters_docs_only_changes(self): + with ( + patch.object( + sys, + "argv", + [ + "ci_changes.py", + "--base", + "a" * 40, + "--head", + "b" * 40, + "--run-attempt", + "1", + ], + ), + patch.object(m, "changed_files", return_value=["README.md"]), + patch.object(sys, "stdout", io.StringIO()) as stdout, + ): + self.assertEqual(0, m.main()) + result = json.loads(stdout.getvalue()) + self.assertFalse(result["forced"]) + self.assertFalse(any(result[suite] for suite in m.SUITES)) + def test_python_and_markdown_do_not_run_android(self): self.assertEqual( {"android": False, "native": False, "python": True}, diff --git a/scripts/tests/test_github_actions.py b/scripts/tests/test_github_actions.py index 5098d2d..c83b360 100644 --- a/scripts/tests/test_github_actions.py +++ b/scripts/tests/test_github_actions.py @@ -54,6 +54,12 @@ def test_failed_mode_rejects_successful_run(self): with self.assertRaisesRegex(RuntimeError, "no failed conclusion"): m.plan_run(self.client, self.pr, "failed") + def test_full_rerun_accepts_successful_run_with_skipped_checks(self): + self.discovery.return_value[0]["conclusion"] = "success" + self.assertEqual( + ["run", "rerun", "20"], m.plan_run(self.client, self.pr, "ci")[0] + ) + def test_dry_run_never_launches_or_requests_confirmation(self): with ( patch.object(self.client, "run", side_effect=AssertionError), diff --git a/scripts/tests/test_java_toolchain.py b/scripts/tests/test_java_toolchain.py new file mode 100644 index 0000000..e4183c2 --- /dev/null +++ b/scripts/tests/test_java_toolchain.py @@ -0,0 +1,49 @@ +"""Exercise JDK validation without building or accessing signing material.""" + +import os +import subprocess +import tempfile +import unittest +from pathlib import Path + +SCRIPT = Path(__file__).resolve().parents[1] / "java-toolchain.sh" + + +class JavaToolchainTests(unittest.TestCase): + def run_toolchain(self, version, compiler=True): + with tempfile.TemporaryDirectory(prefix="megaproxy jdk ") as directory: + jdk = Path(directory) + (jdk / "bin").mkdir() + java = jdk / "bin/java" + java.write_text( + "#!/bin/sh\n" f"echo ' java.specification.version = {version}' >&2\n" + ) + java.chmod(0o755) + if compiler: + javac = jdk / "bin/javac" + javac.write_text("#!/bin/sh\nexit 0\n") + javac.chmod(0o755) + env = dict(os.environ, JAVA_HOME=str(jdk)) + return subprocess.run( + [ + "bash", + "-c", + 'set -e; source "$1"; test -x "$JAVA_HOME/bin/javac"', + "bash", + str(SCRIPT), + ], + env=env, + capture_output=True, + text=True, + timeout=10, + ) + + def test_accepts_jdk21_in_path_with_spaces(self): + result = self.run_toolchain(21) + self.assertEqual(0, result.returncode, result.stderr) + + def test_rejects_wrong_version(self): + self.assertNotEqual(0, self.run_toolchain(17).returncode) + + def test_rejects_runtime_without_compiler(self): + self.assertNotEqual(0, self.run_toolchain(21, compiler=False).returncode)