diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a6564c7..a3a57d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,3 +117,65 @@ jobs: echo echo "Artifacts expire after 14 days. Neither APK uses the MegaProxy release key." } >> "$GITHUB_STEP_SUMMARY" + + android-ui-tests: + name: Android UI tests + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Enable KVM acceleration + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \ + | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Set up JDK 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: "21" + cache: gradle + + - name: Set up Go + uses: actions/setup-go@v7 + with: + go-version-file: native/go.mod + cache-dependency-path: native/go.sum + + - name: Set up Ruby and Fastlane + uses: ruby/setup-ruby@v1 + with: + ruby-version: "3.4.10" + bundler-cache: true + + - name: Install native build dependencies + run: | + sdkmanager="$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" + yes | "$sdkmanager" --licenses >/dev/null || true + "$sdkmanager" "ndk;29.0.14206865" + echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/29.0.14206865" >> "$GITHUB_ENV" + + - name: Run Compose UI tests on an emulator + uses: reactivecircus/android-emulator-runner@v2.38.0 + with: + api-level: 35 + arch: x86_64 + disable-linux-hw-accel: false + disable-animations: true + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + script: bundle exec fastlane android ui_tests + + - name: Publish UI test report + if: always() + uses: actions/upload-artifact@v7 + with: + name: android-ui-test-report + path: | + app/build/reports/androidTests/connected/debug + app/build/outputs/androidTest-results/connected/debug + if-no-files-found: ignore + retention-days: 14 diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 3fefce4..ec5ebb6 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,6 +1,7 @@ { "recommendations": [ "vscjava.vscode-java-pack", + "vscjava.vscode-gradle", "fwcd.kotlin", "golang.go" ] diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..a0ae14e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,9 @@ +{ + "java.configuration.updateBuildConfiguration": "automatic", + "java.import.gradle.enabled": true, + "java.import.gradle.wrapper.enabled": true, + "java.jdt.ls.androidSupport.enabled": true, + "java.gradle.buildServer.enabled": "on", + "kotlin.compiler.jvm.target": "17", + "kotlin.scripts.buildScriptsEnabled": false +} diff --git a/README.md b/README.md index 0ded559..9b9e6d6 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,13 @@ emulator, building, installing, viewing app-specific Logcat output, running test JDWP debugger. Select **Run MegaProxy on Emulator** for `Ctrl+F5` or **Attach MegaProxy (JDWP)** for `F5`. +For Kotlin editing, install the workspace recommendations, including `fwcd.kotlin` and Gradle for +Java. The repository's `kls-classpath` helper supplies the language server with the Android SDK, +Compose dependencies, and generated `R` classes that it cannot discover from Android Gradle Plugin +variants by itself. After installing or updating the extensions, run **Kotlin: Restart the Language +Server** (or **Developer: Reload Window**) once. Run `./kls-classpath` directly to diagnose +classpath resolution. + Run all native and Android checks from VS Code with **Tasks: Run Test Task**, or from a shell: ```shell diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aa92f39..64cab70 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,6 @@ +import java.io.File +import org.gradle.api.attributes.Attribute + plugins { id("com.android.application") id("org.jetbrains.kotlin.android") @@ -113,7 +116,35 @@ dependencies { implementation("androidx.compose.material:material-icons-extended") implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") + implementation("androidx.navigation:navigation-compose:2.9.5") testImplementation("junit:junit:4.13.2") testImplementation("org.json:json:20250107") + androidTestImplementation(platform("androidx.compose:compose-bom:2025.01.01")) + androidTestImplementation("androidx.compose.ui:ui-test-junit4") + androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.test.espresso:espresso-core:3.6.1") + debugImplementation("androidx.compose.ui:ui-test-manifest") runtimeOnly(files("libs/megaproxy.aar")) } + +// fwcd.kotlin does not understand Android Gradle Plugin variants reliably. Its language server +// invokes the repository's kls-classpath script, which uses this task to expose the same classpath +// as the debug Kotlin compiler, including Android's boot classes and generated R/BuildConfig code. +tasks.register("kotlinLanguageServerClasspath") { + dependsOn("processDebugResources") + doLast { + val generatedEntries = listOf( + layout.buildDirectory.file("intermediates/compile_and_runtime_not_namespaced_r_class_jar/debug/processDebugResources/R.jar").get().asFile, + layout.buildDirectory.dir("intermediates/javac/debug/compileDebugJavaWithJavac/classes").get().asFile, + ) + val compileJars = configurations.getByName("debugCompileClasspath").incoming.artifactView { + attributes.attribute(Attribute.of("artifactType", String::class.java), "android-classes-jar") + }.files.files + val entries = ( + compileJars + + android.bootClasspath + + generatedEntries.filter { it.exists() } + ).map { it.absolutePath }.distinct() + println(entries.joinToString(File.pathSeparator)) + } +} diff --git a/app/src/androidTest/java/net/megaproxy487/AppNavigationTest.kt b/app/src/androidTest/java/net/megaproxy487/AppNavigationTest.kt new file mode 100644 index 0000000..576b7d0 --- /dev/null +++ b/app/src/androidTest/java/net/megaproxy487/AppNavigationTest.kt @@ -0,0 +1,91 @@ +package net.megaproxy487 + +import android.content.Context +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AppNavigationTest { + @get:Rule + val compose = createEmptyComposeRule() + + private lateinit var scenario: ActivityScenario + + @Before + fun launchApp() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + context.getSharedPreferences("app_language", Context.MODE_PRIVATE) + .edit().putString("language", "en").commit() + context.getSharedPreferences("battery_optimization_reminder", Context.MODE_PRIVATE) + .edit().putLong("last_request_at", System.currentTimeMillis()).commit() + scenario = ActivityScenario.launch(MainActivity::class.java) + } + + @After + fun closeApp() { + scenario.close() + } + + @Test + fun settingsAndProfilesShareTheMainActivityBackStack() { + compose.onNodeWithTag("screen-main").assertIsDisplayed() + + compose.onNodeWithText("Settings").performClick() + compose.onNodeWithTag("screen-settings").assertIsDisplayed() + + compose.onNodeWithText("Profiles").performClick() + compose.onNodeWithTag("screen-profiles").assertIsDisplayed() + + compose.onNodeWithContentDescription("Back").performClick() + compose.onNodeWithTag("screen-settings").assertIsDisplayed() + + compose.onNodeWithContentDescription("Back").performClick() + compose.onNodeWithTag("screen-main").assertIsDisplayed() + } + + @Test + fun everySettingsDestinationOpensAndReturnsThroughTheNavHost() { + compose.onNodeWithText("Settings").performClick() + + listOf( + "Profiles" to "profiles", + "Always-on VPN" to "always-on", + "Fingerprints" to "fingerprints", + "Split tunneling" to "split-tunnel", + "Failover" to "failover", + "Visibility" to "visibility", + "Diagnostic log" to "diagnostic-log", + ).forEach { (label, route) -> + compose.onNodeWithText(label).performScrollTo().performClick() + compose.onNodeWithTag("screen-$route").assertIsDisplayed() + compose.onNodeWithContentDescription("Back").performClick() + compose.onNodeWithTag("screen-settings").assertIsDisplayed() + } + } + + @Test + fun russianLocaleIsAppliedAcrossNavigation() { + scenario.close() + InstrumentationRegistry.getInstrumentation().targetContext + .getSharedPreferences("app_language", Context.MODE_PRIVATE) + .edit().putString("language", "ru").commit() + scenario = ActivityScenario.launch(MainActivity::class.java) + + compose.onNodeWithText("Настройки").performClick() + compose.onNodeWithText("Профили").assertIsDisplayed() + compose.onNodeWithText("Диагностический журнал").performScrollTo().assertIsDisplayed() + } +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 14fe189..c550e85 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -27,6 +27,7 @@ android:theme="@style/Theme.MegaProxy"> @@ -34,18 +35,6 @@ - - - - - - - - - - - - + ScreenDestination("profile-editor") { ProfileEditorScreen(activity, entry.arguments?.getString("profileId"), back) } + } + composable(AppRoute.ALWAYS_ON) { ScreenDestination(AppRoute.ALWAYS_ON) { AlwaysOnSettingsScreen(activity, back) } } + composable(AppRoute.FINGERPRINTS) { ScreenDestination(AppRoute.FINGERPRINTS) { TlsFingerprintScreen(activity, back) } } + composable(AppRoute.SPLIT_TUNNEL) { ScreenDestination(AppRoute.SPLIT_TUNNEL) { SplitTunnelScreen(activity, back) } } + composable(AppRoute.FAILOVER) { ScreenDestination(AppRoute.FAILOVER) { FailoverSettingsScreen(activity, back) } } + composable(AppRoute.VISIBILITY) { ScreenDestination(AppRoute.VISIBILITY) { VisibilityScreen(activity, back) } } + composable(AppRoute.CONNECTION_TEST) { ScreenDestination(AppRoute.CONNECTION_TEST) { ConnectionTestScreen(activity, autoStart = true, onBack = back) } } + composable(AppRoute.DIAGNOSTIC_LOG) { ScreenDestination(AppRoute.DIAGNOSTIC_LOG) { DiagnosticLogScreen(activity, back) } } + composable(AppRoute.SSH_HOST_KEY) { + hostKeyPrompt?.let { prompt -> + SshHostKeyScreen(activity, prompt) { + net.megaproxy487.vpn.SshHostKeyPromptState.clear() + navController.popBackStack() + } + } + } + } +} + +@Composable +private fun ScreenDestination(route: String, content: @Composable () -> Unit) { + Box(Modifier.fillMaxSize().testTag("screen-$route")) { content() } +} diff --git a/app/src/main/java/net/megaproxy487/ConnectionTestActivity.kt b/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt similarity index 86% rename from app/src/main/java/net/megaproxy487/ConnectionTestActivity.kt rename to app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt index 53c2914..ce3aded 100644 --- a/app/src/main/java/net/megaproxy487/ConnectionTestActivity.kt +++ b/app/src/main/java/net/megaproxy487/ConnectionTestScreen.kt @@ -62,19 +62,9 @@ import net.megaproxy487.vpn.openAndroidVpnSettings import net.megaproxy487.vpn.readAlwaysOnVpnStatus import net.megaproxy487.ui.theme.MegaProxyTheme -class ConnectionTestActivity : LocalizedActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - val autoStart = savedInstanceState == null - if (autoStart) TestDiagnosticLog.reset() - setContent { MegaProxyTheme { ConnectionTestScreen(this, autoStart) } } - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun ConnectionTestScreen(activity: Activity, autoStart: Boolean) { +internal fun ConnectionTestScreen(activity: Activity, autoStart: Boolean, onBack: () -> Unit) { val state by TestDiagnosticLog.state val exitIp by TestDiagnosticLog.exitIp val pendingHostKey by SshHostKeyPromptState.pending @@ -116,15 +106,20 @@ private fun ConnectionTestScreen(activity: Activity, autoStart: Boolean) { } } } - LaunchedEffect(autoStart) { if (autoStart) runTest() } + LaunchedEffect(Unit) { + if (autoStart) { + TestDiagnosticLog.reset() + runTest() + } + } Scaffold( topBar = { TopAppBar( title = { Text(stringResource(R.string.connection_test)) }, navigationIcon = { - IconButton(onClick = { activity.finish() }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) } }, ) @@ -159,14 +154,14 @@ private fun ConnectionTestScreen(activity: Activity, autoStart: Boolean) { if (state == TestState.RUNNING) { CircularProgressIndicator(modifier = Modifier.padding(top = 8.dp)) } - exitIp?.let { Text("Proxy exit IP: $it") } + exitIp?.let { Text(stringResource(R.string.proxy_exit_ip, it)) } } } Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Button(onClick = runTest, enabled = state != TestState.RUNNING) { Text(stringResource(R.string.run_again)) } Button(onClick = { val clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager - clipboard.setPrimaryClip(ClipData.newPlainText("MegaProxy connection test", TestDiagnosticLog.entries.joinToString("\n"))) + clipboard.setPrimaryClip(ClipData.newPlainText(activity.getString(R.string.connection_test_clipboard_label), TestDiagnosticLog.entries.joinToString("\n"))) }, enabled = TestDiagnosticLog.entries.isNotEmpty()) { Text(stringResource(R.string.copy_log)) } } Text( @@ -190,16 +185,16 @@ private fun ConnectionTestScreen(activity: Activity, autoStart: Boolean) { if (showAlwaysOnConflict) { AlertDialog( onDismissRequest = { showAlwaysOnConflict = false }, - title = { Text("Always-on VPN is already in use") }, + title = { Text(stringResource(R.string.always_on_conflict_title)) }, text = { Text(OTHER_ALWAYS_ON_VPN_MESSAGE) }, confirmButton = { TextButton(onClick = { showAlwaysOnConflict = false openAndroidVpnSettings(activity) - }) { Text("Change settings") } + }) { Text(stringResource(R.string.change_settings)) } }, dismissButton = { - TextButton(onClick = { showAlwaysOnConflict = false }) { Text("Cancel") } + TextButton(onClick = { showAlwaysOnConflict = false }) { Text(stringResource(R.string.cancel)) } }, ) } @@ -209,14 +204,14 @@ private fun ConnectionTestScreen(activity: Activity, autoStart: Boolean) { SshHostKeyPromptState.clear() ProxyVpnService.dismissHostKeyPrompt(activity) }, - title = { Text(if (pending.changed) "SSH host key changed" else "Trust SSH host key?") }, + title = { Text(stringResource(if (pending.changed) R.string.ssh_host_key_changed else R.string.trust_ssh_host_key)) }, text = { Text(buildString { if (pending.changed) { - append("Warning: the previously trusted key for the ${pending.hop} host has changed. This may indicate a server reinstall or a man-in-the-middle attack. Verify it through a trusted channel before replacing it.\n\n") + append(activity.getString(R.string.ssh_changed_key_warning, pending.hop)) } else { - append("This is the first connection to the ${pending.hop} SSH host. Verify its fingerprint through a trusted channel.\n\n") + append(activity.getString(R.string.ssh_first_connection_warning, pending.hop)) } - append("Algorithm: ${pending.algorithm}\nFingerprint: ${pending.fingerprint}") + append(activity.getString(R.string.ssh_key_details, pending.algorithm, pending.fingerprint)) }) }, confirmButton = { TextButton(onClick = { @@ -232,13 +227,13 @@ private fun ConnectionTestScreen(activity: Activity, autoStart: Boolean) { } else { TestDiagnosticLog.fail("The SSH host key could not be saved to the tested profile") } - }) { Text(if (pending.changed) "Replace and test" else "Trust and test") } + }) { Text(stringResource(if (pending.changed) R.string.replace_and_test else R.string.trust_and_test)) } }, dismissButton = { TextButton(onClick = { SshHostKeyPromptState.clear() ProxyVpnService.dismissHostKeyPrompt(activity) - }) { Text("Cancel") } + }) { Text(stringResource(R.string.cancel)) } }, ) } diff --git a/app/src/main/java/net/megaproxy487/DiagnosticLogActivity.kt b/app/src/main/java/net/megaproxy487/DiagnosticLogScreen.kt similarity index 89% rename from app/src/main/java/net/megaproxy487/DiagnosticLogActivity.kt rename to app/src/main/java/net/megaproxy487/DiagnosticLogScreen.kt index bba768d..d6bf57a 100644 --- a/app/src/main/java/net/megaproxy487/DiagnosticLogActivity.kt +++ b/app/src/main/java/net/megaproxy487/DiagnosticLogScreen.kt @@ -58,17 +58,9 @@ import net.megaproxy487.data.ConfigStore import net.megaproxy487.vpn.PersistentDiagnosticLog import net.megaproxy487.ui.theme.MegaProxyTheme -class DiagnosticLogActivity : LocalizedActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { MegaProxyTheme { DiagnosticLogScreen(this) } } - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun DiagnosticLogScreen(activity: Activity) { +internal fun DiagnosticLogScreen(activity: Activity, onBack: () -> Unit) { val viewerWindowBytes = 512 * 1024 val store = remember { ConfigStore(activity) } val listState = rememberLazyListState() @@ -94,7 +86,7 @@ private fun DiagnosticLogScreen(activity: Activity) { PersistentDiagnosticLog.copyTo(it) } ?: error("Could not open the export file") } - }.onSuccess { message = "Diagnostic log exported." } + }.onSuccess { message = activity.getString(R.string.diagnostic_log_exported) } .onFailure { message = it.message ?: "Could not export the diagnostic log" } } } @@ -126,8 +118,8 @@ private fun DiagnosticLogScreen(activity: Activity) { TopAppBar( title = { Text(stringResource(R.string.diagnostic_log)) }, navigationIcon = { - IconButton(onClick = { activity.finish() }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) } }, ) @@ -154,8 +146,8 @@ private fun DiagnosticLogScreen(activity: Activity) { } } }, - label = { Text("Rotated log limit (MB)") }, - supportingText = { Text("Total size across both log segments; 1–100 MB.") }, + label = { Text(stringResource(R.string.rotated_log_limit)) }, + supportingText = { Text(stringResource(R.string.rotated_log_limit_description)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -179,7 +171,7 @@ private fun DiagnosticLogScreen(activity: Activity) { state = listState, modifier = Modifier.fillMaxWidth().weight(1f), ) { - if (lines.isEmpty()) item { Text("No diagnostic events yet.") } + if (lines.isEmpty()) item { Text(stringResource(R.string.no_diagnostic_events)) } itemsIndexed(lines, key = { index, _ -> index }) { _, line -> Text(line, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace) } @@ -190,20 +182,20 @@ private fun DiagnosticLogScreen(activity: Activity) { if (showClearConfirmation) { AlertDialog( onDismissRequest = { showClearConfirmation = false }, - title = { Text("Clear diagnostic log?") }, - text = { Text("Both rotated log segments will be deleted.") }, + title = { Text(stringResource(R.string.clear_diagnostic_log_title)) }, + text = { Text(stringResource(R.string.clear_diagnostic_log_message)) }, confirmButton = { TextButton(onClick = { showClearConfirmation = false PersistentDiagnosticLog.clear() - }) { Text("Clear") } }, - dismissButton = { TextButton(onClick = { showClearConfirmation = false }) { Text("Cancel") } }, + }) { Text(stringResource(R.string.clear_action)) } }, + dismissButton = { TextButton(onClick = { showClearConfirmation = false }) { Text(stringResource(R.string.cancel)) } }, ) } message?.let { AlertDialog( onDismissRequest = { message = null }, text = { Text(it) }, - confirmButton = { TextButton(onClick = { message = null }) { Text("OK") } }, + confirmButton = { TextButton(onClick = { message = null }) { Text(stringResource(R.string.ok)) } }, ) } } diff --git a/app/src/main/java/net/megaproxy487/MainActivity.kt b/app/src/main/java/net/megaproxy487/MainActivity.kt index 74d5145..423d738 100644 --- a/app/src/main/java/net/megaproxy487/MainActivity.kt +++ b/app/src/main/java/net/megaproxy487/MainActivity.kt @@ -82,6 +82,7 @@ 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 @@ -98,9 +99,30 @@ 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 { MainScreen(this) } } + setContent { MegaProxyTheme { MegaProxyNavHost(this) } } + } + + 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() { @@ -111,6 +133,16 @@ class MainActivity : LocalizedActivity() { val profileId = if (status.enabled) store.alwaysOnProfileId() else store.connectionProfile().id VpnRuntimeState.updateSystem(status.enabled, status.lockdown, profileId) } + + companion object { + const val ACTION_REVIEW_SSH_HOST_KEY = "net.megaproxy487.REVIEW_SSH_HOST_KEY" + const val EXTRA_PROFILE_ID = "profile_id" + const val EXTRA_HOP = "hop" + const val EXTRA_ALGORITHM = "algorithm" + const val EXTRA_FINGERPRINT = "fingerprint" + const val EXTRA_CHANGED = "changed" + const val EXTRA_TEST_ONLY = "test_only" + } } @Composable @@ -135,7 +167,12 @@ private fun ProfileTypeBadge(type: ProxyType, foreground: Color) { @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun MainScreen(activity: Activity) { +internal fun MainScreen( + activity: Activity, + onOpenSettings: () -> Unit, + onOpenConnectionTest: () -> Unit, + onEditProfile: (String) -> Unit, +) { val connection by VpnRuntimeState.connection val runtimeAlwaysOn by VpnRuntimeState.alwaysOn val runtimeLockdown by VpnRuntimeState.lockdown @@ -274,7 +311,7 @@ private fun MainScreen(activity: Activity) { } Scaffold( - topBar = { TopAppBar(title = { Text("MegaProxy") }) }, + topBar = { TopAppBar(title = { Text(stringResource(R.string.app_name)) }) }, contentWindowInsets = WindowInsets.safeDrawing, ) { padding -> Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.TopCenter) { @@ -365,7 +402,7 @@ private fun MainScreen(activity: Activity) { ), ) { Column(Modifier.fillMaxWidth().padding(14.dp)) { - Text("Profile", style = MaterialTheme.typography.labelMedium) + Text(stringResource(R.string.profile), style = MaterialTheme.typography.labelMedium) Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, @@ -383,10 +420,10 @@ private fun MainScreen(activity: Activity) { } Text(activeProfile.displayName, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) ProfileTypeBadge(activeProfile.config.type, onProfileColor) - Icon(Icons.Filled.ArrowDropDown, contentDescription = "Select profile", tint = onProfileColor) + Icon(Icons.Filled.ArrowDropDown, contentDescription = stringResource(R.string.select_profile), tint = onProfileColor) } if (actualProfile != null && actualProfile.id != activeProfile.id && connection != VpnConnectionState.DISCONNECTED) { - Text("Connected through: ${actualProfile.displayNameWithFlag}", style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.connected_through, actualProfile.displayNameWithFlag), style = MaterialTheme.typography.bodySmall) } DropdownMenu(profileMenuExpanded, { profileMenuExpanded = false }) { profiles.forEach { profile -> @@ -429,9 +466,7 @@ private fun MainScreen(activity: Activity) { ) { Text(activeProfileError, modifier = Modifier.weight(1f), color = MaterialTheme.colorScheme.onErrorContainer) TextButton(onClick = { - activity.startActivity(Intent(activity, ProfileEditorActivity::class.java).apply { - putExtra(ProfileEditorActivity.EXTRA_PROFILE_ID, activeProfile.id) - }) + onEditProfile(activeProfile.id) }) { Text(stringResource(R.string.configure)) } } } @@ -485,11 +520,11 @@ private fun MainScreen(activity: Activity) { } } FilledTonalButton( - onClick = { activity.startActivity(Intent(activity, ConnectionTestActivity::class.java)) }, + onClick = onOpenConnectionTest, modifier = Modifier.fillMaxWidth(), ) { Text(stringResource(R.string.test_connection)) } FilledTonalButton( - onClick = { activity.startActivity(Intent(activity, ProxySettingsActivity::class.java)) }, + onClick = onOpenSettings, modifier = Modifier.fillMaxWidth(), ) { Text(stringResource(R.string.settings)) } FilledTonalButton( @@ -525,8 +560,8 @@ private fun MainScreen(activity: Activity) { if (showCrashReport) { AlertDialog( onDismissRequest = {}, - title = { Text("MegaProxy stopped unexpectedly") }, - text = { Text("A privacy-filtered crash report was saved. You can send it to help diagnose the problem.") }, + title = { Text(stringResource(R.string.unexpected_stop_title)) }, + text = { Text(stringResource(R.string.crash_report_saved)) }, confirmButton = { TextButton(onClick = { scope.launch { @@ -547,13 +582,13 @@ private fun MainScreen(activity: Activity) { error = "Could not open an email client: ${it.message ?: "unknown error"}" } } - }) { Text("Report") } + }) { Text(stringResource(R.string.report)) } }, dismissButton = { TextButton(onClick = { CrashHandler.markReportHandled() showCrashReport = false - }) { Text("Close") } + }) { Text(stringResource(R.string.close)) } }, ) } @@ -561,16 +596,16 @@ private fun MainScreen(activity: Activity) { if (showAlwaysOnConflict) { AlertDialog( onDismissRequest = { showAlwaysOnConflict = false }, - title = { Text("Always-on VPN is already in use") }, + title = { Text(stringResource(R.string.always_on_conflict_title)) }, text = { Text(OTHER_ALWAYS_ON_VPN_MESSAGE) }, confirmButton = { TextButton(onClick = { showAlwaysOnConflict = false openAndroidVpnSettings(activity) - }) { Text("Change settings") } + }) { Text(stringResource(R.string.change_settings)) } }, dismissButton = { - TextButton(onClick = { showAlwaysOnConflict = false }) { Text("Cancel") } + TextButton(onClick = { showAlwaysOnConflict = false }) { Text(stringResource(R.string.cancel)) } }, ) } @@ -582,14 +617,14 @@ private fun MainScreen(activity: Activity) { } AlertDialog( onDismissRequest = ::dismissHostKeyPrompt, - title = { Text(if (pending.changed) "SSH host key changed" else "Trust SSH host key?") }, + title = { Text(stringResource(if (pending.changed) R.string.ssh_host_key_changed else R.string.trust_ssh_host_key)) }, text = { Text(buildString { if (pending.changed) { - append("Warning: the previously trusted key for the ${pending.hop} host has changed. This may indicate a server reinstall or a man-in-the-middle attack. Verify it through a trusted channel before replacing it.\n\n") + append(activity.getString(R.string.ssh_changed_key_warning, pending.hop)) } else { - append("This is the first connection to the ${pending.hop} SSH host. Verify its fingerprint through a trusted channel.\n\n") + append(activity.getString(R.string.ssh_first_connection_warning, pending.hop)) } - append("Algorithm: ${pending.algorithm}\nFingerprint: ${pending.fingerprint}") + append(activity.getString(R.string.ssh_key_details, pending.algorithm, pending.fingerprint)) }) }, confirmButton = { TextButton(onClick = { @@ -597,12 +632,12 @@ private fun MainScreen(activity: Activity) { SshHostKeyPromptState.clear() ProxyVpnService.reconnect(activity) } else { - error = "The SSH host key could not be saved to this profile" + error = activity.getString(R.string.ssh_key_save_failed) } - }) { Text(if (pending.changed) "Replace trusted key" else "Trust and connect") } + }) { Text(stringResource(if (pending.changed) R.string.replace_trusted_key else R.string.trust_and_connect)) } }, dismissButton = { - TextButton(onClick = ::dismissHostKeyPrompt) { Text("Cancel") } + TextButton(onClick = ::dismissHostKeyPrompt) { Text(stringResource(R.string.cancel)) } }, ) } diff --git a/app/src/main/java/net/megaproxy487/ProfileEditorActivity.kt b/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt similarity index 80% rename from app/src/main/java/net/megaproxy487/ProfileEditorActivity.kt rename to app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt index 6ffcd8b..01c0bf7 100644 --- a/app/src/main/java/net/megaproxy487/ProfileEditorActivity.kt +++ b/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt @@ -46,6 +46,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -73,25 +74,15 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class ProfileEditorActivity : LocalizedActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) - setContent { MegaProxyTheme { ProfileEditorScreen(this) } } - } - - companion object { - const val EXTRA_PROFILE_ID = "profile_id" - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun ProfileEditorScreen(activity: Activity) { +internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: () -> Unit) { val store = remember { ConfigStore(activity) } val coroutineScope = rememberCoroutineScope() - val profileId = remember { activity.intent.getStringExtra(ProfileEditorActivity.EXTRA_PROFILE_ID) } + DisposableEffect(activity) { + activity.window.addFlags(WindowManager.LayoutParams.FLAG_SECURE) + onDispose { activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) } + } var profile by remember { mutableStateOf(store.profile(profileId.orEmpty()) ?: store.activeProfile()) } var config by remember { mutableStateOf(profile.config) } var portText by remember { mutableStateOf(config.port.toString()) } @@ -166,8 +157,8 @@ private fun ProfileEditorScreen(activity: Activity) { TopAppBar( title = { Text(profile.displayName) }, navigationIcon = { - IconButton(onClick = { activity.finish() }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) } }, ) @@ -185,7 +176,7 @@ private fun ProfileEditorScreen(activity: Activity) { OutlinedTextField(profile.name, { name -> acceptText(name, 256) { profile = profile.copy(name = name) saveProfile() - } }, label = { Text("Profile name (optional)") }, supportingText = { Text("Defaults to the proxy host") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } }, label = { Text(stringResource(R.string.profile_name_optional)) }, supportingText = { Text(stringResource(R.string.defaults_to_proxy_host)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) } item { ExposedDropdownMenuBox(countryExpanded, { countryExpanded = it }) { @@ -193,12 +184,12 @@ private fun ProfileEditorScreen(activity: Activity) { profile.countryCode.takeIf(String::isNotEmpty)?.let { code -> "${profile.flagEmoji} ${countries.firstOrNull { it.first == code }?.second ?: code}" } ?: "No flag", - {}, readOnly = true, label = { Text("Country flag") }, + {}, readOnly = true, label = { Text(stringResource(R.string.country_flag)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(countryExpanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), ) DropdownMenu(countryExpanded, { countryExpanded = false }) { - DropdownMenuItem(text = { Text("No flag") }, onClick = { + DropdownMenuItem(text = { Text(stringResource(R.string.no_flag)) }, onClick = { profile = profile.copy(countryCode = "") saveProfile() countryExpanded = false @@ -219,11 +210,11 @@ private fun ProfileEditorScreen(activity: Activity) { } item { - Text("Connection", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.connection), style = MaterialTheme.typography.titleMedium) } item { ExposedDropdownMenuBox(typeExpanded, { typeExpanded = it }) { - OutlinedTextField(config.type.title, {}, readOnly = true, label = { Text("Profile type") }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(typeExpanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth()) + OutlinedTextField(config.type.title, {}, readOnly = true, label = { Text(stringResource(R.string.profile_type)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(typeExpanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth()) DropdownMenu(typeExpanded, { typeExpanded = false }) { ProxyType.entries.forEach { type -> DropdownMenuItem(text = { Text(type.title) }, onClick = { updateConfig(config.copy(type = type, port = type.defaultPort)) @@ -235,7 +226,7 @@ private fun ProfileEditorScreen(activity: Activity) { } } item { - OutlinedTextField(config.host, { value -> acceptText(value, 253) { updateConfig(config.copy(host = it)) } }, label = { Text(if (config.type == ProxyType.HTTPS) "HTTPS proxy hostname" else "Destination SSH hostname") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.host, { value -> acceptText(value, 253) { updateConfig(config.copy(host = it)) } }, label = { Text(stringResource(if (config.type == ProxyType.HTTPS) R.string.https_proxy_hostname else R.string.destination_ssh_hostname)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) } item { OutlinedTextField(portText, { value -> @@ -243,13 +234,13 @@ private fun ProfileEditorScreen(activity: Activity) { val port = value.toIntOrNull() if (port == null) error = "Port must be between 1 and 65535" else updateConfig(config.copy(port = port)) - }, label = { Text("Port") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + }, label = { Text(stringResource(R.string.port)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) } item { - OutlinedTextField(config.username, { value -> acceptText(value, 4_096) { updateConfig(config.copy(username = it)) } }, label = { Text(if (config.type == ProxyType.HTTPS) "Basic Auth username" else "SSH username") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.username, { value -> acceptText(value, 4_096) { updateConfig(config.copy(username = it)) } }, label = { Text(stringResource(if (config.type == ProxyType.HTTPS) R.string.basic_auth_username else R.string.ssh_username)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) } item { - OutlinedTextField(config.password, { value -> acceptText(value, 16_384) { updateConfig(config.copy(password = it)) } }, label = { Text(if (config.type == ProxyType.HTTPS) "Password" else "SSH password (optional)") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.password, { value -> acceptText(value, 16_384) { updateConfig(config.copy(password = it)) } }, label = { Text(stringResource(if (config.type == ProxyType.HTTPS) R.string.password else R.string.ssh_password_optional)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) } if (config.type == ProxyType.HTTPS) item { SettingCheckboxRow( checked = config.allowInvalidProxyCertificate, @@ -264,7 +255,7 @@ private fun ProfileEditorScreen(activity: Activity) { if (config.type != ProxyType.HTTPS) { item { - OutlinedTextField(config.privateKey, { value -> acceptText(value, 64 * 1024) { updateConfig(config.copy(privateKey = it)) } }, label = { Text("Private key (optional)") }, supportingText = { Text("PEM or OpenSSH format. Passphrase-protected keys are not supported.") }, minLines = 3, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.privateKey, { value -> acceptText(value, 64 * 1024) { updateConfig(config.copy(privateKey = it)) } }, label = { Text(stringResource(R.string.private_key_optional)) }, supportingText = { Text(stringResource(R.string.private_key_format_hint)) }, minLines = 3, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) } item { OutlinedButton( @@ -286,7 +277,7 @@ private fun ProfileEditorScreen(activity: Activity) { if (checked) unsafeHostKeyHop = "destination" else updateConfig(config.copy(acceptAnyHostKey = false)) } } - if (config.trustedHostKey.isNotBlank()) item { Text("Trusted destination key: ${config.trustedHostKey}", style = MaterialTheme.typography.bodySmall) } + if (config.trustedHostKey.isNotBlank()) item { Text(stringResource(R.string.trusted_destination_key, config.trustedHostKey), style = MaterialTheme.typography.bodySmall) } } if (config.type == ProxyType.SSH_JUMP) { @@ -294,10 +285,10 @@ private fun ProfileEditorScreen(activity: Activity) { HorizontalDivider() } item { - Text("Jump host", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.jump_host), style = MaterialTheme.typography.titleMedium) } item { - OutlinedTextField(config.jumpHost, { value -> acceptText(value, 253) { updateConfig(config.copy(jumpHost = it)) } }, label = { Text("Jump SSH hostname") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.jumpHost, { value -> acceptText(value, 253) { updateConfig(config.copy(jumpHost = it)) } }, label = { Text(stringResource(R.string.jump_ssh_hostname)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) } item { OutlinedTextField(jumpPortText, { value -> @@ -305,7 +296,7 @@ private fun ProfileEditorScreen(activity: Activity) { jumpPortText = value value.toIntOrNull()?.let { updateConfig(config.copy(jumpPort = it)) } } - }, label = { Text("Jump port") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + }, label = { Text(stringResource(R.string.jump_port)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) } item { SettingCheckboxRow(config.sameJumpAuthentication, "Use the same authentication", "Reuse destination username, password and private key.") { @@ -314,13 +305,13 @@ private fun ProfileEditorScreen(activity: Activity) { } if (!config.sameJumpAuthentication) { item { - OutlinedTextField(config.jumpUsername, { value -> acceptText(value, 4_096) { updateConfig(config.copy(jumpUsername = it)) } }, label = { Text("Jump SSH username") }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.jumpUsername, { value -> acceptText(value, 4_096) { updateConfig(config.copy(jumpUsername = it)) } }, label = { Text(stringResource(R.string.jump_ssh_username)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) } item { - OutlinedTextField(config.jumpPassword, { value -> acceptText(value, 16_384) { updateConfig(config.copy(jumpPassword = it)) } }, label = { Text("Jump SSH password (optional)") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.jumpPassword, { value -> acceptText(value, 16_384) { updateConfig(config.copy(jumpPassword = it)) } }, label = { Text(stringResource(R.string.jump_ssh_password_optional)) }, singleLine = true, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) } item { - OutlinedTextField(config.jumpPrivateKey, { value -> acceptText(value, 64 * 1024) { updateConfig(config.copy(jumpPrivateKey = it)) } }, label = { Text("Jump private key (optional)") }, minLines = 3, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.jumpPrivateKey, { value -> acceptText(value, 64 * 1024) { updateConfig(config.copy(jumpPrivateKey = it)) } }, label = { Text(stringResource(R.string.jump_private_key_optional)) }, minLines = 3, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) } item { OutlinedButton( @@ -343,7 +334,7 @@ private fun ProfileEditorScreen(activity: Activity) { if (checked) unsafeHostKeyHop = "jump" else updateConfig(config.copy(jumpAcceptAnyHostKey = false)) } } - if (config.jumpTrustedHostKey.isNotBlank()) item { Text("Trusted jump key: ${config.jumpTrustedHostKey}", style = MaterialTheme.typography.bodySmall) } + if (config.jumpTrustedHostKey.isNotBlank()) item { Text(stringResource(R.string.trusted_jump_key, config.jumpTrustedHostKey), style = MaterialTheme.typography.bodySmall) } } item { @@ -353,33 +344,33 @@ private fun ProfileEditorScreen(activity: Activity) { } item { - Text("DNS over HTTPS", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.dns_over_https), style = MaterialTheme.typography.titleMedium) } item { ExposedDropdownMenuBox(dnsExpanded, { dnsExpanded = it }) { - OutlinedTextField(config.dnsProvider.title, {}, readOnly = true, label = { Text("DNS over HTTPS") }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(dnsExpanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth()) + OutlinedTextField(config.dnsProvider.title, {}, readOnly = true, label = { Text(stringResource(R.string.dns_over_https)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(dnsExpanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth()) DropdownMenu(dnsExpanded, { dnsExpanded = false }) { DnsProvider.entries.forEach { provider -> DropdownMenuItem(text = { Text(provider.title) }, onClick = { updateConfig(config.copy(dnsProvider = provider)); dnsExpanded = false }) } } } } - if (config.dnsProvider == DnsProvider.CUSTOM) item { OutlinedTextField(config.customDohUrl, { value -> acceptText(value, 2_048) { updateConfig(config.copy(customDohUrl = it)) } }, label = { Text("Custom DoH URL") }, singleLine = true, modifier = Modifier.fillMaxWidth()) } + if (config.dnsProvider == DnsProvider.CUSTOM) item { OutlinedTextField(config.customDohUrl, { value -> acceptText(value, 2_048) { updateConfig(config.copy(customDohUrl = it)) } }, label = { Text(stringResource(R.string.custom_doh_url)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) } error?.let { message -> item { Text(message, color = MaterialTheme.colorScheme.error) } } if (showReconnectPrompt) { item { Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer)) { Column(Modifier.fillMaxWidth().padding(12.dp)) { - Text("Reconnect to apply these changes to the active VPN.", style = MaterialTheme.typography.bodyMedium) + Text(stringResource(R.string.reconnect_apply_changes), style = MaterialTheme.typography.bodyMedium) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { TextButton(onClick = { showReconnectPrompt = false connectionChangeDeferred = true - }) { Text("Next connection") } + }) { Text(stringResource(R.string.next_connection)) } TextButton(onClick = { showReconnectPrompt = false connectionChangeDeferred = true ProxyVpnService.reconnect(activity) - }) { Text("Reconnect now") } + }) { Text(stringResource(R.string.reconnect_now)) } } } } @@ -389,14 +380,14 @@ private fun ProfileEditorScreen(activity: Activity) { item { Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer)) { Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { - Text("Always-on is active. Changes apply on the next connection.", modifier = Modifier.weight(1f)) - TextButton(onClick = { showAlwaysOnNotice = false }) { Text("Dismiss") } + Text(stringResource(R.string.always_on_changes_next_connection), modifier = Modifier.weight(1f)) + TextButton(onClick = { showAlwaysOnNotice = false }) { Text(stringResource(R.string.dismiss)) } } } } } item { - Text("Changes are saved automatically.", style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.changes_saved_automatically), style = MaterialTheme.typography.bodySmall) } } } @@ -404,22 +395,22 @@ private fun ProfileEditorScreen(activity: Activity) { if (showInvalidCertificateWarning) { AlertDialog( onDismissRequest = { showInvalidCertificateWarning = false }, - title = { Text("Allow an untrusted proxy certificate?") }, - text = { Text("This disables certificate-chain and hostname verification between your phone and the HTTPS proxy. An attacker could impersonate the proxy and obtain its Basic Auth credentials. Destination HTTPS certificates remain verified.") }, + title = { Text(stringResource(R.string.allow_untrusted_certificate_title)) }, + text = { Text(stringResource(R.string.allow_untrusted_certificate_message)) }, confirmButton = { TextButton(onClick = { updateConfig(config.copy(allowInvalidProxyCertificate = true)) showInvalidCertificateWarning = false - }) { Text("OK") } }, - dismissButton = { TextButton(onClick = { showInvalidCertificateWarning = false }) { Text("Cancel") } }, + }) { Text(stringResource(R.string.ok)) } }, + dismissButton = { TextButton(onClick = { showInvalidCertificateWarning = false }) { Text(stringResource(R.string.cancel)) } }, ) } unsafeHostKeyHop?.let { hop -> AlertDialog( onDismissRequest = { unsafeHostKeyHop = null }, - title = { Text("Accept any SSH host key?") }, - text = { Text("This disables identity verification for the $hop SSH host and permits man-in-the-middle attacks. Credentials and tunneled traffic could be intercepted.") }, - confirmButton = { TextButton(onClick = { updateConfig(if (hop == "jump") config.copy(jumpAcceptAnyHostKey = true) else config.copy(acceptAnyHostKey = true)); unsafeHostKeyHop = null }) { Text("Accept any key") } }, - dismissButton = { TextButton(onClick = { unsafeHostKeyHop = null }) { Text("Cancel") } }, + title = { Text(stringResource(R.string.accept_any_ssh_key_title)) }, + text = { Text(stringResource(R.string.accept_any_ssh_key_message, hop)) }, + confirmButton = { TextButton(onClick = { updateConfig(if (hop == "jump") config.copy(jumpAcceptAnyHostKey = true) else config.copy(acceptAnyHostKey = true)); unsafeHostKeyHop = null }) { Text(stringResource(R.string.accept_any_key)) } }, + dismissButton = { TextButton(onClick = { unsafeHostKeyHop = null }) { Text(stringResource(R.string.cancel)) } }, ) } } diff --git a/app/src/main/java/net/megaproxy487/ProxySettingsActivity.kt b/app/src/main/java/net/megaproxy487/ProfilesScreen.kt similarity index 98% rename from app/src/main/java/net/megaproxy487/ProxySettingsActivity.kt rename to app/src/main/java/net/megaproxy487/ProfilesScreen.kt index 0040240..d64ab4a 100644 --- a/app/src/main/java/net/megaproxy487/ProxySettingsActivity.kt +++ b/app/src/main/java/net/megaproxy487/ProfilesScreen.kt @@ -96,14 +96,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class ProfilesActivity : LocalizedActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { MegaProxyTheme { SettingsScreen(this) } } - } -} - private data class MissingProfilesReview( val profiles: List, val importSummary: String, @@ -202,7 +194,7 @@ private fun applySelectedProfileOptions( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun SettingsScreen(activity: Activity) { +internal fun ProfilesScreen(activity: Activity, onBack: () -> Unit, onEditProfile: (String) -> Unit) { val store = remember { ConfigStore(activity) } val scope = rememberCoroutineScope() val profiles = remember { mutableStateListOf() } @@ -246,9 +238,7 @@ private fun SettingsScreen(activity: Activity) { return true } fun edit(profile: ProxyProfile) { - activity.startActivity(Intent(activity, ProfileEditorActivity::class.java).apply { - putExtra(ProfileEditorActivity.EXTRA_PROFILE_ID, profile.id) - }) + onEditProfile(profile.id) } fun writeExport(uri: Uri?) { if (uri == null) return @@ -418,7 +408,7 @@ private fun SettingsScreen(activity: Activity) { TopAppBar( title = { Text(stringResource(R.string.profiles)) }, navigationIcon = { - IconButton(onClick = { activity.finish() }) { + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) } }, diff --git a/app/src/main/java/net/megaproxy487/SettingsActivities.kt b/app/src/main/java/net/megaproxy487/SettingsScreens.kt similarity index 77% rename from app/src/main/java/net/megaproxy487/SettingsActivities.kt rename to app/src/main/java/net/megaproxy487/SettingsScreens.kt index 7c85ce0..492b78e 100644 --- a/app/src/main/java/net/megaproxy487/SettingsActivities.kt +++ b/app/src/main/java/net/megaproxy487/SettingsScreens.kt @@ -78,41 +78,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class ProxySettingsActivity : LocalizedActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { MegaProxyTheme { SettingsHomeScreen(this) } } - } -} - -class AlwaysOnSettingsActivity : LocalizedActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { MegaProxyTheme { AlwaysOnSettingsScreen(this) } } - } -} - -class TlsFingerprintActivity : LocalizedActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { MegaProxyTheme { TlsFingerprintScreen(this) } } - } -} - -class FailoverSettingsActivity : LocalizedActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { MegaProxyTheme { FailoverSettingsScreen(this) } } - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun SettingsHomeScreen(activity: Activity) { +internal fun SettingsHomeScreen(activity: Activity, onBack: () -> Unit, onNavigate: (String) -> Unit) { val lifecycleOwner = LocalLifecycleOwner.current val powerManager = remember { activity.getSystemService(PowerManager::class.java) } var batteryOptimizationDisabled by remember { @@ -132,15 +100,15 @@ private fun SettingsHomeScreen(activity: Activity) { onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } } - SettingsScaffold(activity, stringResource(R.string.settings)) { + SettingsScaffold(onBack, stringResource(R.string.settings)) { Text(stringResource(R.string.connection), style = MaterialTheme.typography.titleMedium) - SettingsButton(stringResource(R.string.profiles), stringResource(R.string.profiles_description)) { activity.startActivity(Intent(activity, ProfilesActivity::class.java)) } - SettingsButton(stringResource(R.string.always_on_vpn), stringResource(R.string.always_on_description)) { activity.startActivity(Intent(activity, AlwaysOnSettingsActivity::class.java)) } - SettingsButton(stringResource(R.string.fingerprints), stringResource(R.string.fingerprints_description)) { activity.startActivity(Intent(activity, TlsFingerprintActivity::class.java)) } - SettingsButton(stringResource(R.string.split_tunneling), stringResource(R.string.split_tunneling_description)) { activity.startActivity(Intent(activity, SplitTunnelActivity::class.java)) } - SettingsButton(stringResource(R.string.failover), stringResource(R.string.failover_description)) { activity.startActivity(Intent(activity, FailoverSettingsActivity::class.java)) } + SettingsButton(stringResource(R.string.profiles), stringResource(R.string.profiles_description)) { onNavigate(AppRoute.PROFILES) } + SettingsButton(stringResource(R.string.always_on_vpn), stringResource(R.string.always_on_description)) { onNavigate(AppRoute.ALWAYS_ON) } + SettingsButton(stringResource(R.string.fingerprints), stringResource(R.string.fingerprints_description)) { onNavigate(AppRoute.FINGERPRINTS) } + SettingsButton(stringResource(R.string.split_tunneling), stringResource(R.string.split_tunneling_description)) { onNavigate(AppRoute.SPLIT_TUNNEL) } + SettingsButton(stringResource(R.string.failover), stringResource(R.string.failover_description)) { onNavigate(AppRoute.FAILOVER) } Text(stringResource(R.string.diagnostics), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp)) - SettingsButton(stringResource(R.string.visibility), stringResource(R.string.visibility_description)) { activity.startActivity(Intent(activity, VisibilityActivity::class.java)) } + SettingsButton(stringResource(R.string.visibility), stringResource(R.string.visibility_description)) { onNavigate(AppRoute.VISIBILITY) } if (!batteryOptimizationDisabled) { SettingsButton(stringResource(R.string.battery_settings), stringResource(R.string.battery_settings_description)) { activity.startActivity(Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { @@ -148,7 +116,7 @@ private fun SettingsHomeScreen(activity: Activity) { }) } } - SettingsButton(stringResource(R.string.diagnostic_log), stringResource(R.string.diagnostic_log_description)) { activity.startActivity(Intent(activity, DiagnosticLogActivity::class.java)) } + SettingsButton(stringResource(R.string.diagnostic_log), stringResource(R.string.diagnostic_log_description)) { onNavigate(AppRoute.DIAGNOSTIC_LOG) } Text(stringResource(R.string.appearance_and_language), style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(top = 8.dp)) SettingsButton( stringResource(R.string.language), @@ -168,7 +136,7 @@ private fun SettingsHomeScreen(activity: Activity) { ) } activity.startActivity(intent) - }.onFailure { supportError = it.message ?: "Could not open an email client" } + }.onFailure { supportError = it.message ?: activity.getString(R.string.could_not_open_email) } } } SettingsButton(stringResource(R.string.github_issues), stringResource(R.string.github_issues_description)) { @@ -224,7 +192,7 @@ private fun SettingsHomeScreen(activity: Activity) { @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun FailoverSettingsScreen(activity: Activity) { +internal fun FailoverSettingsScreen(activity: Activity, onBack: () -> Unit) { val store = remember { ConfigStore(activity) } val scope = rememberCoroutineScope() var settings by remember { mutableStateOf(store.globalConnectionSettings()) } @@ -236,9 +204,9 @@ private fun FailoverSettingsScreen(activity: Activity) { val snapshot = settings scope.launch(ConfigIoDispatcher) { store.saveGlobalConnectionSettings(snapshot) } } - SettingsScaffold(activity, "Failover") { + SettingsScaffold(onBack, stringResource(R.string.failover)) { ExposedDropdownMenuBox(expanded, { expanded = it }) { - OutlinedTextField(settings.failoverMode.title, {}, readOnly = true, label = { Text("Failover mode") }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth()) + OutlinedTextField(settings.failoverMode.title, {}, readOnly = true, label = { Text(stringResource(R.string.failover_mode)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth()) DropdownMenu(expanded, { expanded = false }) { FailoverMode.entries.forEach { mode -> DropdownMenuItem(text = { Text(mode.title) }, onClick = { expanded = false @@ -246,10 +214,10 @@ private fun FailoverSettingsScreen(activity: Activity) { }) } } } - Text("Failover is only triggered after repeated timeout, reset or silent-drop signals. Authentication, host-key, certificate and configuration errors never trigger it.", style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.failover_trigger_description), style = MaterialTheme.typography.bodySmall) if (settings.failoverMode == FailoverMode.SELECTED) { - Text("Fallback profiles", style = MaterialTheme.typography.titleMedium) - Text("Profiles are tried in the same order as the Profiles screen.", style = MaterialTheme.typography.bodySmall) + 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 -> val checked = profile.id in settings.failoverProfileIds Row( @@ -270,19 +238,19 @@ private fun FailoverSettingsScreen(activity: Activity) { val usableFallbacks = store.sortedProfiles().count { it.id in settings.failoverProfileIds } if (usableFallbacks < 2) { Text( - "Select at least two profiles. With fewer profiles, failover may have nowhere to switch.", + stringResource(R.string.failover_profiles_warning), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall, ) } } if (settings.failoverMode != FailoverMode.DISABLED) { - Text("Warning: switching profiles may change the apparent country and public exit IP.", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.failover_location_warning), color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) if (alwaysOn) Text( if (settings.failoverMode == FailoverMode.ALL) - "Always-on VPN remains active during automatic switching. Every switch is reported in the persistent notification and on the main screen." + stringResource(R.string.always_on_all_failover_status) else - "Always-on VPN remains active during automatic switching. The persistent notification and the main screen show the profile actually in use.", + stringResource(R.string.always_on_selected_failover_status), color = MaterialTheme.colorScheme.tertiary, style = MaterialTheme.typography.bodySmall, ) @@ -290,26 +258,26 @@ private fun FailoverSettingsScreen(activity: Activity) { } if (pendingAll) AlertDialog( onDismissRequest = { pendingAll = false }, - title = { Text("Enable global failover?") }, - text = { Text("MegaProxy will automatically try any configured profile when blocking is suspected. This can change your location and public exit IP. Individual switches will not require confirmation.") }, - confirmButton = { TextButton(onClick = { pendingAll = false; save(mode = FailoverMode.ALL) }) { Text("Enable") } }, - dismissButton = { TextButton(onClick = { pendingAll = false }) { Text("Cancel") } }, + title = { Text(stringResource(R.string.enable_global_failover_title)) }, + text = { Text(stringResource(R.string.enable_global_failover_message)) }, + confirmButton = { TextButton(onClick = { pendingAll = false; save(mode = FailoverMode.ALL) }) { Text(stringResource(R.string.enable)) } }, + dismissButton = { TextButton(onClick = { pendingAll = false }) { Text(stringResource(R.string.cancel)) } }, ) } @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun AlwaysOnSettingsScreen(activity: Activity) { +internal fun AlwaysOnSettingsScreen(activity: Activity, onBack: () -> Unit) { val store = remember { ConfigStore(activity) } val scope = rememberCoroutineScope() var expanded by remember { mutableStateOf(false) } var selected by remember { mutableStateOf(store.alwaysOnProfile()) } val alwaysOnActive = remember { ProxyVpnService.isAlwaysOnMode || readAlwaysOnVpnStatus(activity).enabled } - SettingsScaffold(activity, "Always-on VPN") { + SettingsScaffold(onBack, stringResource(R.string.always_on_vpn)) { ExposedDropdownMenuBox(expanded, { expanded = it }) { OutlinedTextField( selected.displayNameWithFlag, {}, readOnly = true, - label = { Text("Profile for Always-on VPN") }, + label = { Text(stringResource(R.string.always_on_profile)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), ) @@ -326,11 +294,11 @@ private fun AlwaysOnSettingsScreen(activity: Activity) { } } } - Text("This profile is used when Android starts MegaProxy as an Always-on VPN.", style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.always_on_profile_description), style = MaterialTheme.typography.bodySmall) if (alwaysOnActive) { - Text("Always-on is active. Selecting another profile reconnects immediately.", color = MaterialTheme.colorScheme.tertiary, style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.always_on_profile_reconnect), color = MaterialTheme.colorScheme.tertiary, style = MaterialTheme.typography.bodySmall) } - SettingsButton("Open Android Always-on VPN settings") { + SettingsButton(stringResource(R.string.open_always_on_settings)) { activity.startActivity(Intent(Settings.ACTION_VPN_SETTINGS)) } } @@ -338,7 +306,7 @@ private fun AlwaysOnSettingsScreen(activity: Activity) { @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun TlsFingerprintScreen(activity: Activity) { +internal fun TlsFingerprintScreen(activity: Activity, onBack: () -> Unit) { val store = remember { ConfigStore(activity) } val scope = rememberCoroutineScope() var settings by remember { mutableStateOf(store.globalConnectionSettings()) } @@ -371,16 +339,16 @@ private fun TlsFingerprintScreen(activity: Activity) { ) { saveSettings(settings.copy(tlsProfile = profile, customJa3 = customJa3, sshProfile = sshProfile)) error = if (profile == TlsProfile.CUSTOM && Ja3Spec.parse(customJa3) == null) { - "JA3 must contain five fields: version,ciphers,extensions,groups,points" + activity.getString(R.string.invalid_ja3) } else null } - SettingsScaffold(activity, "Fingerprints") { - Text("HTTPS fingerprint", style = MaterialTheme.typography.titleMedium) + SettingsScaffold(onBack, stringResource(R.string.fingerprints)) { + Text(stringResource(R.string.https_fingerprint), style = MaterialTheme.typography.titleMedium) ExposedDropdownMenuBox(expanded, { expanded = it }) { OutlinedTextField( settings.tlsProfile.title, {}, readOnly = true, - label = { Text("HTTPS TLS / JA3 profile") }, + label = { Text(stringResource(R.string.https_tls_ja3_profile)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), ) @@ -390,22 +358,22 @@ private fun TlsFingerprintScreen(activity: Activity) { } } } - Text("Default currently uses the Chrome Android fingerprint and may change in a future MegaProxy update.", style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.default_fingerprint_description), style = MaterialTheme.typography.bodySmall) if (settings.tlsProfile == TlsProfile.CUSTOM) { OutlinedTextField( settings.customJa3, { if (it.length <= 8 * 1024) save(customJa3 = it) }, - label = { Text("JA3: version,ciphers,extensions,groups,points") }, + label = { Text(stringResource(R.string.ja3_format)) }, minLines = 2, modifier = Modifier.fillMaxWidth(), ) } error?.let { Text(it, color = MaterialTheme.colorScheme.error) } - Text("SSH fingerprint", style = MaterialTheme.typography.titleMedium) + Text(stringResource(R.string.ssh_fingerprint), style = MaterialTheme.typography.titleMedium) ExposedDropdownMenuBox(sshExpanded, { sshExpanded = it }) { OutlinedTextField( settings.sshProfile.title, {}, readOnly = true, - label = { Text("SSH client profile") }, + label = { Text(stringResource(R.string.ssh_client_profile)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(sshExpanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), ) @@ -421,7 +389,7 @@ private fun TlsFingerprintScreen(activity: Activity) { ExposedDropdownMenuBox(sshAuthExpanded, { sshAuthExpanded = it }) { OutlinedTextField( settings.sshAuthMode.title, {}, readOnly = true, - label = { Text("SSH authentication") }, + label = { Text(stringResource(R.string.ssh_authentication)) }, trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(sshAuthExpanded) }, modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), ) @@ -432,30 +400,30 @@ private fun TlsFingerprintScreen(activity: Activity) { }) } } } - IntegerSettingField(settings.sshKeepaliveSeconds, 0..3600, "SSH keepalive, seconds (0 = disabled)") { + IntegerSettingField(settings.sshKeepaliveSeconds, 0..3600, stringResource(R.string.ssh_keepalive)) { saveSettings(settings.copy(sshKeepaliveSeconds = it)) } - IntegerSettingField(settings.sshMaxChannels, 1..256, "Maximum parallel SSH channels") { + IntegerSettingField(settings.sshMaxChannels, 1..256, stringResource(R.string.ssh_max_channels)) { saveSettings(settings.copy(sshMaxChannels = it)) } - IntegerSettingField(settings.sshRotationMinutes, 0..1440, "Rotate SSH session after minutes (0 = disabled)") { + IntegerSettingField(settings.sshRotationMinutes, 0..1440, stringResource(R.string.ssh_rotation_minutes)) { saveSettings(settings.copy(sshRotationMinutes = it)) } - IntegerSettingField(settings.sshRotationMb, 0..10240, "Rotate SSH session after MB (0 = disabled)") { + IntegerSettingField(settings.sshRotationMb, 0..10240, stringResource(R.string.ssh_rotation_mb)) { saveSettings(settings.copy(sshRotationMb = it)) } - Text("The SSH profile controls the client banner and preferred KEX, cipher and MAC ordering.", style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.ssh_profile_description), style = MaterialTheme.typography.bodySmall) if (showReconnectPrompt) { Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer)) { Column(Modifier.fillMaxWidth().padding(12.dp)) { - Text("Reconnect to apply these changes to the active VPN.") + Text(stringResource(R.string.reconnect_apply_changes)) Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { - TextButton(onClick = { showReconnectPrompt = false; deferred = true }) { Text("Next connection") } + TextButton(onClick = { showReconnectPrompt = false; deferred = true }) { Text(stringResource(R.string.next_connection)) } TextButton(onClick = { showReconnectPrompt = false deferred = true ProxyVpnService.reconnect(activity) - }) { Text("Reconnect now") } + }) { Text(stringResource(R.string.reconnect_now)) } } } } @@ -463,12 +431,12 @@ private fun TlsFingerprintScreen(activity: Activity) { if (showAlwaysOnNotice) { Card(colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer)) { Row(Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically) { - Text("Always-on is active. Changes apply on the next connection.", modifier = Modifier.weight(1f)) - TextButton(onClick = { showAlwaysOnNotice = false }) { Text("Dismiss") } + Text(stringResource(R.string.always_on_changes_next_connection), modifier = Modifier.weight(1f)) + TextButton(onClick = { showAlwaysOnNotice = false }) { Text(stringResource(R.string.dismiss)) } } } } - Text("Changes are saved automatically and apply to every profile.", style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.global_changes_saved), style = MaterialTheme.typography.bodySmall) } } @@ -486,7 +454,7 @@ private fun IntegerSettingField(initialValue: Int, range: IntRange, label: Strin }, label = { Text(label) }, supportingText = if (text.isNotEmpty() && parsed !in range) { - { Text("Allowed range: ${range.first}–${range.last}") } + { Text(stringResource(R.string.allowed_range, range.first, range.last)) } } else null, isError = text.isNotEmpty() && parsed !in range, singleLine = true, @@ -496,13 +464,13 @@ private fun IntegerSettingField(initialValue: Int, range: IntRange, label: Strin @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun SettingsScaffold(activity: Activity, title: String, content: @Composable ColumnScope.() -> Unit) { +private fun SettingsScaffold(onBack: () -> Unit, title: String, content: @Composable ColumnScope.() -> Unit) { Scaffold( topBar = { TopAppBar( title = { Text(title) }, navigationIcon = { - IconButton(onClick = { activity.finish() }) { + IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) } }, diff --git a/app/src/main/java/net/megaproxy487/SplitTunnelActivity.kt b/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt similarity index 90% rename from app/src/main/java/net/megaproxy487/SplitTunnelActivity.kt rename to app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt index 5c799c7..cb97b62 100644 --- a/app/src/main/java/net/megaproxy487/SplitTunnelActivity.kt +++ b/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt @@ -1,5 +1,6 @@ package net.megaproxy487 +import android.app.Activity import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity @@ -58,19 +59,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class SplitTunnelActivity : LocalizedActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { MegaProxyTheme { SplitTunnelScreen(this) } } - } -} - private data class AppItem(val packageName: String, val label: String) @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun SplitTunnelScreen(activity: SplitTunnelActivity) { +internal fun SplitTunnelScreen(activity: Activity, onBack: () -> Unit) { val store = remember { ConfigStore(activity) } var settings by remember { mutableStateOf(store.globalConnectionSettings()) } var appSearch by remember { mutableStateOf("") } @@ -122,8 +115,8 @@ private fun SplitTunnelScreen(activity: SplitTunnelActivity) { TopAppBar( title = { Text(stringResource(R.string.split_tunneling)) }, navigationIcon = { - IconButton(onClick = { activity.finish() }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) } }, ) @@ -144,13 +137,13 @@ private fun SplitTunnelScreen(activity: SplitTunnelActivity) { showReconnectPrompt = false showAlwaysOnDeferredNotice = false deferChangesUntilNextConnection = true - }) { Text(if (showAlwaysOnDeferredNotice) "Dismiss" else "Next connection") } + }) { Text(stringResource(if (showAlwaysOnDeferredNotice) R.string.dismiss else R.string.next_connection)) } if (showReconnectPrompt) { TextButton(onClick = { showReconnectPrompt = false deferChangesUntilNextConnection = true ProxyVpnService.reconnect(activity) - }) { Text("Reconnect now") } + }) { Text(stringResource(R.string.reconnect_now)) } } } } @@ -182,7 +175,7 @@ private fun SplitTunnelScreen(activity: SplitTunnelActivity) { RadioButton(!settings.routeAllApps, null) Column(Modifier.weight(1f)) { Text(stringResource(R.string.split_tunneling)) - Text("Only selected applications use the proxy.", style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.selected_apps_routing_description), style = MaterialTheme.typography.bodySmall) } } Row( @@ -195,13 +188,13 @@ private fun SplitTunnelScreen(activity: SplitTunnelActivity) { ) { RadioButton(settings.routeAllApps, null) Column(Modifier.weight(1f)) { - Text("Global VPN") - Text("All applications use the proxy. This is the default mode.", style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.global_vpn)) + Text(stringResource(R.string.global_vpn_description), style = MaterialTheme.typography.bodySmall) } } } } - item { Text("UDP and QUIC are blocked; DNS uses the profile's configured DoH endpoint.", style = MaterialTheme.typography.bodySmall) } + item { Text(stringResource(R.string.udp_quic_blocked_description), style = MaterialTheme.typography.bodySmall) } item { Row( Modifier.fillMaxWidth().heightIn(min = 56.dp).toggleable( @@ -213,8 +206,8 @@ private fun SplitTunnelScreen(activity: SplitTunnelActivity) { verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, ) { Column(Modifier.weight(1f)) { - Text("Bypass local networks") - Text("Direct TCP access to private and link-local IP addresses.", style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.bypass_local_networks)) + Text(stringResource(R.string.bypass_local_networks_description), style = MaterialTheme.typography.bodySmall) } Checkbox(settings.bypassLocalNetworks, null) } @@ -230,7 +223,7 @@ private fun SplitTunnelScreen(activity: SplitTunnelActivity) { OutlinedTextField( value = appSearch, onValueChange = { appSearch = it }, - label = { Text("Search applications") }, + label = { Text(stringResource(R.string.search_applications)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) diff --git a/app/src/main/java/net/megaproxy487/SshHostKeyActivity.kt b/app/src/main/java/net/megaproxy487/SshHostKeyActivity.kt deleted file mode 100644 index c53b170..0000000 --- a/app/src/main/java/net/megaproxy487/SshHostKeyActivity.kt +++ /dev/null @@ -1,61 +0,0 @@ -package net.megaproxy487 - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.enableEdgeToEdge -import androidx.activity.compose.setContent -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import net.megaproxy487.ui.theme.MegaProxyTheme -import net.megaproxy487.data.ConfigStore -import net.megaproxy487.vpn.ProxyVpnService - -class SshHostKeyActivity : LocalizedActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - val profileId = intent.getStringExtra(EXTRA_PROFILE_ID).orEmpty() - val hop = intent.getStringExtra(EXTRA_HOP).orEmpty() - val algorithm = intent.getStringExtra(EXTRA_ALGORITHM).orEmpty() - val fingerprint = intent.getStringExtra(EXTRA_FINGERPRINT).orEmpty() - val changed = intent.getBooleanExtra(EXTRA_CHANGED, false) - val testOnly = intent.getBooleanExtra(EXTRA_TEST_ONLY, false) - fun reject() { - // A normal/Always-on connection remains paused with an actionable persistent - // notification. Tests are disposable and may release their temporary service. - if (testOnly) ProxyVpnService.dismissHostKeyPrompt(this) - finish() - } - setContent { - MegaProxyTheme { - AlertDialog( - onDismissRequest = ::reject, - title = { Text(if (changed) "SSH host key changed" else "Trust SSH host key?") }, - text = { Text(buildString { - if (changed) append("Warning: the previously trusted key for the $hop host has changed. This can indicate a server reinstall or a man-in-the-middle attack. Verify the fingerprint through a trusted channel before replacing it.\n\n") - else append("This is the first connection to the $hop SSH host. Verify its fingerprint through a trusted channel.\n\n") - append("Algorithm: $algorithm\nFingerprint: $fingerprint") - }) }, - confirmButton = { TextButton(onClick = { - if (ConfigStore(this).trustSshHostKey(profileId, hop, fingerprint)) { - if (testOnly) ProxyVpnService.test(this) else ProxyVpnService.reconnect(this) - } - finish() - }) { Text(if (changed) "Replace trusted key" else "Trust and connect") } }, - dismissButton = { TextButton(onClick = ::reject) { Text("Cancel") } }, - ) - } - } - } - - companion object { - const val EXTRA_PROFILE_ID = "profile_id" - const val EXTRA_HOP = "hop" - const val EXTRA_ALGORITHM = "algorithm" - const val EXTRA_FINGERPRINT = "fingerprint" - const val EXTRA_CHANGED = "changed" - const val EXTRA_TEST_ONLY = "test_only" - } -} diff --git a/app/src/main/java/net/megaproxy487/SshHostKeyScreen.kt b/app/src/main/java/net/megaproxy487/SshHostKeyScreen.kt new file mode 100644 index 0000000..ae71a83 --- /dev/null +++ b/app/src/main/java/net/megaproxy487/SshHostKeyScreen.kt @@ -0,0 +1,37 @@ +package net.megaproxy487 + +import android.app.Activity +import androidx.compose.runtime.Composable +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.ui.res.stringResource +import net.megaproxy487.data.ConfigStore +import net.megaproxy487.vpn.PendingSshHostKey +import net.megaproxy487.vpn.ProxyVpnService + +@Composable +internal fun SshHostKeyScreen(activity: Activity, prompt: PendingSshHostKey, onDismiss: () -> Unit) { + 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) + onDismiss() + } + AlertDialog( + onDismissRequest = ::reject, + title = { Text(stringResource(if (prompt.changed) R.string.ssh_host_key_changed else R.string.trust_ssh_host_key)) }, + text = { Text(buildString { + append(activity.getString(if (prompt.changed) R.string.ssh_changed_key_warning else R.string.ssh_first_connection_warning, prompt.hop)) + append(activity.getString(R.string.ssh_key_details, prompt.algorithm, prompt.fingerprint)) + }) }, + confirmButton = { TextButton(onClick = { + if (ConfigStore(activity).trustSshHostKey(prompt.profileId, prompt.hop, prompt.fingerprint)) { + if (prompt.testOnly) ProxyVpnService.test(activity) else ProxyVpnService.reconnect(activity) + } + onDismiss() + }) { Text(stringResource(if (prompt.changed) R.string.replace_trusted_key else R.string.trust_and_connect)) } }, + dismissButton = { TextButton(onClick = ::reject) { Text(stringResource(R.string.cancel)) } }, + ) +} diff --git a/app/src/main/java/net/megaproxy487/VisibilityActivity.kt b/app/src/main/java/net/megaproxy487/VisibilityScreen.kt similarity index 70% rename from app/src/main/java/net/megaproxy487/VisibilityActivity.kt rename to app/src/main/java/net/megaproxy487/VisibilityScreen.kt index c6c3c59..0ef3494 100644 --- a/app/src/main/java/net/megaproxy487/VisibilityActivity.kt +++ b/app/src/main/java/net/megaproxy487/VisibilityScreen.kt @@ -47,18 +47,10 @@ import net.megaproxy487.ui.theme.MegaProxyTheme import net.megaproxy487.data.ConfigStore import java.net.NetworkInterface -class VisibilityActivity : LocalizedActivity() { - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContent { MegaProxyTheme { VisibilityScreen(this) } } - } -} - -private enum class VisibilityState(val label: String) { - DETECTED("Detected"), - SELECTED_APPS_ONLY("Selected apps only"), - NOT_DETECTED("Not detected"), +private enum class VisibilityState(val labelResource: Int) { + DETECTED(R.string.detected), + SELECTED_APPS_ONLY(R.string.selected_apps_only), + NOT_DETECTED(R.string.not_detected), } private data class VisibilityCheck( @@ -75,15 +67,15 @@ private data class VisibilityReport( @OptIn(ExperimentalMaterial3Api::class) @Composable -private fun VisibilityScreen(activity: Activity) { +internal fun VisibilityScreen(activity: Activity, onBack: () -> Unit) { var report by remember { mutableStateOf(buildVisibilityReport(activity)) } Scaffold( topBar = { TopAppBar( title = { Text(stringResource(R.string.visibility)) }, navigationIcon = { - IconButton(onClick = activity::finish) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) } }, ) @@ -95,25 +87,25 @@ private fun VisibilityScreen(activity: Activity) { verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - "Local checks show signals that another Android app may observe. No network request, location lookup, or external reputation service is used.", + stringResource(R.string.visibility_intro), style = MaterialTheme.typography.bodyMedium, ) Button(onClick = { report = buildVisibilityReport(activity) }, modifier = Modifier.fillMaxWidth()) { Text(stringResource(R.string.run_checks_again)) } - VisibilitySection("Globally observable signals", report.global) + VisibilitySection(stringResource(R.string.globally_observable_signals), report.global) if (report.splitTunneling) { Text( - "The signals below belong to the VPN network. With split tunneling, Android exposes that network to selected apps; excluded apps use the underlying network instead.", + stringResource(R.string.split_visibility_explanation), style = MaterialTheme.typography.bodySmall, ) } VisibilitySection( - if (report.splitTunneling) "Visible to selected apps" else "VPN network signals", + stringResource(if (report.splitTunneling) R.string.visible_to_selected_apps else R.string.vpn_network_signals), report.selectedApps, ) Text( - "A detected signal is not proof by itself. Android VPN APIs intentionally expose some VPN state, and legitimate security or filtering apps can produce the same signals.", + stringResource(R.string.visibility_disclaimer), style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(bottom = 16.dp), ) @@ -146,7 +138,7 @@ private fun VisibilityBadge(state: VisibilityState) { } val foreground = if (state == VisibilityState.SELECTED_APPS_ONLY) Color.Black else Color.White Surface(color = background, contentColor = foreground, shape = RoundedCornerShape(12.dp)) { - Text(state.label, style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) + Text(stringResource(state.labelResource), style = MaterialTheme.typography.labelSmall, modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)) } } @@ -200,23 +192,23 @@ private fun buildVisibilityReport(context: Context): VisibilityReport { splitTunneling -> VisibilityState.SELECTED_APPS_ONLY else -> VisibilityState.DETECTED }, - if (detected && splitTunneling) "$yes Excluded apps use their underlying network." else if (detected) yes else no, + if (detected && splitTunneling) context.getString(R.string.excluded_apps_underlying_network, yes) else if (detected) yes else no, ) return VisibilityReport( global = listOf( - check("System proxy properties", systemProxyVisible, "A process-wide proxy property is visible.", "No process-wide proxy property is visible."), - check("VPN-like interface name", vpnInterfaces.isNotEmpty(), "An active interface has a tun, tap, wg, ppp, or ipsec-style name.", "No active interface has a common VPN-style name."), - check("Unusual VPN MTU", unusualMtu.isNotEmpty(), "A VPN-style interface uses an MTU other than 1280 or 1500.", "No unusual MTU was found on VPN-style interfaces."), - VisibilityCheck("Local MITM certificate", VisibilityState.NOT_DETECTED, "MegaProxy installs no CA certificate and performs no TLS interception."), + check(context.getString(R.string.system_proxy_properties), systemProxyVisible, context.getString(R.string.system_proxy_yes), context.getString(R.string.system_proxy_no)), + check(context.getString(R.string.vpn_interface_name), vpnInterfaces.isNotEmpty(), context.getString(R.string.vpn_interface_yes), context.getString(R.string.vpn_interface_no)), + check(context.getString(R.string.unusual_vpn_mtu), unusualMtu.isNotEmpty(), context.getString(R.string.unusual_vpn_mtu_yes), context.getString(R.string.unusual_vpn_mtu_no)), + VisibilityCheck(context.getString(R.string.local_mitm_certificate), VisibilityState.NOT_DETECTED, context.getString(R.string.local_mitm_certificate_detail)), ), selectedApps = listOf( - vpnCheck("VPN transport", vpnTransportVisible, "Android exposes a network with TRANSPORT_VPN.", "No VPN transport is currently exposed."), - vpnCheck("VPN transport information", vpnInfoVisible, "Android exposes VpnTransportInfo for the VPN network.", "VpnTransportInfo is not exposed on the currently visible networks."), - vpnCheck("NOT_VPN capability", vpnWithoutNotVpn, "The VPN network does not have NET_CAPABILITY_NOT_VPN.", "No visible network is identified by this capability check as a VPN."), - vpnCheck("VPN link proxy", linkProxyVisible, "A proxy setting is attached to the VPN network.", "No proxy setting is attached to the VPN network."), - vpnCheck("Virtual default route", virtualDefaultRoute, "A visible default route uses a VPN-style interface.", "No visible default route uses a VPN-style interface."), - vpnCheck("Virtual DNS address", virtualDns, "The VPN network exposes a private, link-local, or loopback DNS address.", "The VPN network does not expose a local DNS address."), + vpnCheck(context.getString(R.string.vpn_transport), vpnTransportVisible, context.getString(R.string.vpn_transport_yes), context.getString(R.string.vpn_transport_no)), + vpnCheck(context.getString(R.string.vpn_transport_information), vpnInfoVisible, context.getString(R.string.vpn_info_yes), context.getString(R.string.vpn_info_no)), + vpnCheck(context.getString(R.string.not_vpn_capability), vpnWithoutNotVpn, context.getString(R.string.not_vpn_yes), context.getString(R.string.not_vpn_no)), + vpnCheck(context.getString(R.string.vpn_link_proxy), linkProxyVisible, context.getString(R.string.vpn_link_proxy_yes), context.getString(R.string.vpn_link_proxy_no)), + vpnCheck(context.getString(R.string.virtual_default_route), virtualDefaultRoute, context.getString(R.string.virtual_route_yes), context.getString(R.string.virtual_route_no)), + vpnCheck(context.getString(R.string.virtual_dns_address), virtualDns, context.getString(R.string.virtual_dns_yes), context.getString(R.string.virtual_dns_no)), ), splitTunneling = splitTunneling, ) diff --git a/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt b/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt index 4d6c4c0..0709866 100644 --- a/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt +++ b/app/src/main/java/net/megaproxy487/vpn/ProxyVpnService.kt @@ -18,7 +18,6 @@ import android.os.SystemClock import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import net.megaproxy487.MainActivity -import net.megaproxy487.SshHostKeyActivity import net.megaproxy487.data.ConfigStore import net.megaproxy487.data.ConfigIoDispatcher import net.megaproxy487.model.FailoverMode @@ -745,13 +744,14 @@ 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, SshHostKeyActivity::class.java) - .putExtra(SshHostKeyActivity.EXTRA_PROFILE_ID, profileId) - .putExtra(SshHostKeyActivity.EXTRA_HOP, parts[0]) - .putExtra(SshHostKeyActivity.EXTRA_ALGORITHM, parts[1]) - .putExtra(SshHostKeyActivity.EXTRA_FINGERPRINT, fingerprint) - .putExtra(SshHostKeyActivity.EXTRA_CHANGED, changed) - .putExtra(SshHostKeyActivity.EXTRA_TEST_ONLY, testOnly) + val intent = Intent(this, MainActivity::class.java) + .setAction(MainActivity.ACTION_REVIEW_SSH_HOST_KEY) + .putExtra(MainActivity.EXTRA_PROFILE_ID, profileId) + .putExtra(MainActivity.EXTRA_HOP, parts[0]) + .putExtra(MainActivity.EXTRA_ALGORITHM, parts[1]) + .putExtra(MainActivity.EXTRA_FINGERPRINT, fingerprint) + .putExtra(MainActivity.EXTRA_CHANGED, changed) + .putExtra(MainActivity.EXTRA_TEST_ONLY, testOnly) SshHostKeyPromptState.show( PendingSshHostKey(profileId, parts[0], parts[1], fingerprint, changed, testOnly), ) @@ -763,12 +763,12 @@ class ProxyVpnService : VpnService() { private fun notification(text: String) = NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(net.megaproxy487.R.drawable.ic_vpn_notification) - .setContentTitle("MegaProxy is active") + .setContentTitle(getString(net.megaproxy487.R.string.megaproxy_active)) .setContentText(text) .setOngoing(true) .setCategory(NotificationCompat.CATEGORY_SERVICE) .setContentIntent(hostKeyPrompt ?: PendingIntent.getActivity(this, 0, Intent(this, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE)) - .also { builder -> hostKeyPrompt?.let { builder.addAction(0, "Review SSH key", it) } } + .also { builder -> hostKeyPrompt?.let { builder.addAction(0, getString(net.megaproxy487.R.string.review_ssh_key), it) } } .build() companion object { diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 133cf0d..196b777 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -111,4 +111,111 @@ SSH, мультиплексирование Импортировать ключ назначения из файла Импортировать ключ jump host из файла + Название профиля (необязательно) + По умолчанию используется адрес прокси + Флаг страны + Без флага + Тип профиля + Адрес HTTPS-прокси + Адрес конечного SSH-сервера + Порт + Логин Basic Auth + Логин SSH + Пароль + Пароль SSH (необязательно) + Закрытый ключ (необязательно) + Формат PEM или OpenSSH. Ключи с парольной фразой не поддерживаются. + Доверенный ключ назначения: %1$s + Промежуточный сервер + Адрес промежуточного SSH-сервера + Порт промежуточного сервера + Логин промежуточного SSH-сервера + Пароль промежуточного SSH-сервера (необязательно) + Закрытый ключ промежуточного сервера (необязательно) + Доверенный ключ промежуточного сервера: %1$s + DNS через HTTPS + Собственный URL DoH + Переподключитесь, чтобы применить изменения к активному VPN. + Постоянный VPN активен. Изменения применятся при следующем подключении. + Разрешить недоверенный сертификат прокси? + Проверка цепочки сертификатов и имени HTTPS-прокси будет отключена. Злоумышленник сможет выдать себя за прокси и получить данные Basic Auth. Сертификаты конечных HTTPS-сайтов по-прежнему проверяются. + Принимать любой ключ SSH-сервера? + Проверка подлинности SSH-сервера %1$s будет отключена, что допускает атаку посредника. Учётные данные и туннелируемый трафик могут быть перехвачены. + Принять любой ключ + Ключ SSH-сервера изменился + Доверять ключу SSH-сервера? + Ранее доверенный ключ сервера %1$s изменился. Это может означать переустановку сервера или атаку посредника. Перед заменой сверьте отпечаток по доверенному каналу.\n\n + Это первое подключение к SSH-серверу %1$s. Сверьте отпечаток по доверенному каналу.\n\n + Алгоритм: %1$s\nОтпечаток: %2$s + Заменить доверенный ключ + Доверять и подключиться + Заменить и проверить + Доверять и проверить + Постоянный VPN уже используется + Изменить настройки + Выходной IP прокси: %1$s + Глобальный VPN + Только выбранные приложения используют прокси. + Все приложения используют прокси. Это режим по умолчанию. + UDP и QUIC блокируются; DNS использует настроенный в профиле DoH-сервер. + Обходить локальные сети + Прямой TCP-доступ к частным и локальным IP-адресам. + Поиск приложений + Размер журнала с ротацией (МБ) + Общий размер двух сегментов журнала: 1–100 МБ. + Диагностических событий пока нет. + Очистить диагностический журнал? + Оба сегмента журнала будут удалены. + Диагностический журнал экспортирован. + Режим переключения + Переключение срабатывает только после повторных тайм-аутов, сбросов или отсутствия ответа. Ошибки аутентификации, ключей, сертификатов и конфигурации его не запускают. + Резервные профили + Профили перебираются в том же порядке, что и на экране профилей. + Выберите хотя бы два профиля, иначе переключаться может быть некуда. + Внимание: переключение профиля может изменить видимую страну и публичный IP. + Включить глобальное переключение? + При вероятной блокировке MegaProxy автоматически попробует любой настроенный профиль. Это может изменить местоположение и публичный IP. Отдельные переключения подтверждать не потребуется. + Включить + Профиль постоянного VPN + Этот профиль используется, когда Android запускает MegaProxy как постоянный VPN. + Постоянный VPN активен. Выбор другого профиля сразу переподключит VPN. + Открыть системные настройки постоянного VPN + Отпечаток HTTPS + Профиль HTTPS TLS / JA3 + Сейчас по умолчанию используется отпечаток Chrome для Android; он может измениться в будущей версии MegaProxy. + JA3: версия,шифры,расширения,группы,точки + Отпечаток SSH + Профиль SSH-клиента + Аутентификация SSH + Профиль SSH задаёт приветствие клиента и предпочтительный порядок KEX, шифров и MAC. + Изменения сохраняются автоматически и применяются ко всем профилям. + Допустимый диапазон: %1$d–%2$d + Подключено через: %1$s + MegaProxy неожиданно остановился + Сохранён обезличенный отчёт о сбое. Его можно отправить, чтобы помочь с диагностикой. + Отправить отчёт + Закрыть + Проверка подключения MegaProxy + ОбнаруженоТолько выбранные приложенияНе обнаружено + Локальные проверки показывают признаки, доступные другому Android-приложению. Сетевые запросы, геолокация и внешние сервисы репутации не используются. + Глобально видимые признакиВидно выбранным приложениямПризнаки VPN-сети + Признаки ниже относятся к VPN-сети. При раздельном туннелировании Android показывает её выбранным приложениям, а исключённые используют основную сеть. + Обнаруженный признак сам по себе ничего не доказывает. Android намеренно раскрывает часть состояния VPN; такие же признаки создают обычные приложения безопасности и фильтрации. + %1$s Исключённые приложения используют основную сеть. + Системные параметры проксиВидно глобальное свойство прокси процесса.Глобальное свойство прокси процесса не видно. + Имя VPN-интерфейсаИмя активного интерфейса похоже на tun, tap, wg, ppp или ipsec.Активных интерфейсов с типичным VPN-именем нет. + Необычный MTU VPNVPN-интерфейс использует MTU, отличный от 1280 и 1500.Необычный MTU у VPN-интерфейсов не найден. + Локальный MITM-сертификатMegaProxy не устанавливает CA-сертификаты и не перехватывает TLS. + Транспорт VPNAndroid показывает сеть с TRANSPORT_VPN.Транспорт VPN сейчас не виден. + Информация о транспорте VPNAndroid показывает VpnTransportInfo для VPN-сети.VpnTransportInfo не виден в доступных сетях. + Возможность NOT_VPNУ VPN-сети нет NET_CAPABILITY_NOT_VPN.По этой проверке ни одна видимая сеть не определена как VPN. + Прокси VPN-сетиК VPN-сети привязана настройка прокси.К VPN-сети не привязана настройка прокси. + Виртуальный маршрут по умолчаниюВидимый маршрут по умолчанию использует VPN-интерфейс.Маршрут по умолчанию через VPN-интерфейс не виден. + Виртуальный адрес DNSVPN-сеть показывает частный, link-local или loopback-адрес DNS.VPN-сеть не показывает локальный адрес DNS. + Выбрать профильНе удалось сохранить ключ SSH-сервера в этом профиле + MegaProxy активенПроверить ключ SSH + Постоянный VPN остаётся активным при автоматическом переключении. Каждое переключение отображается в постоянном уведомлении и на главном экране. + Постоянный VPN остаётся активным при автоматическом переключении. В уведомлении и на главном экране отображается фактически используемый профиль. + Интервал keepalive SSH, секунд (0 — отключено)Максимум параллельных каналов SSHСменить SSH-сессию через минут (0 — отключено)Сменить SSH-сессию после МБ (0 — отключено) + Не удалось открыть почтовое приложениеJA3 должен содержать пять полей: версия,шифры,расширения,группы,точки diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9b03c83..e85ddf5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -112,4 +112,111 @@ SSH multiplexed Import destination key from file Import jump key from file + Profile name (optional) + Defaults to the proxy host + Country flag + No flag + Profile type + HTTPS proxy hostname + Destination SSH hostname + Port + Basic Auth username + SSH username + Password + SSH password (optional) + Private key (optional) + PEM or OpenSSH format. Passphrase-protected keys are not supported. + Trusted destination key: %1$s + Jump host + Jump SSH hostname + Jump port + Jump SSH username + Jump SSH password (optional) + Jump private key (optional) + Trusted jump key: %1$s + DNS over HTTPS + Custom DoH URL + Reconnect to apply these changes to the active VPN. + Always-on is active. Changes apply on the next connection. + Allow an untrusted proxy certificate? + This disables certificate-chain and hostname verification between your phone and the HTTPS proxy. An attacker could impersonate the proxy and obtain its Basic Auth credentials. Destination HTTPS certificates remain verified. + Accept any SSH host key? + This disables identity verification for the %1$s SSH host and permits man-in-the-middle attacks. Credentials and tunneled traffic could be intercepted. + Accept any key + SSH host key changed + Trust SSH host key? + Warning: the previously trusted key for the %1$s host has changed. This can indicate a server reinstall or a man-in-the-middle attack. Verify the fingerprint through a trusted channel before replacing it.\n\n + This is the first connection to the %1$s SSH host. Verify its fingerprint through a trusted channel.\n\n + Algorithm: %1$s\nFingerprint: %2$s + Replace trusted key + Trust and connect + Replace and test + Trust and test + Always-on VPN is already in use + Change settings + Proxy exit IP: %1$s + Global VPN + Only selected applications use the proxy. + All applications use the proxy. This is the default mode. + UDP and QUIC are blocked; DNS uses the profile\'s configured DoH endpoint. + Bypass local networks + Direct TCP access to private and link-local IP addresses. + Search applications + Rotated log limit (MB) + Total size across both log segments; 1–100 MB. + No diagnostic events yet. + Clear diagnostic log? + Both rotated log segments will be deleted. + Diagnostic log exported. + Failover mode + Failover is only triggered after repeated timeout, reset or silent-drop signals. Authentication, host-key, certificate and configuration errors never trigger it. + Fallback profiles + Profiles are tried in the same order as the Profiles screen. + Select at least two profiles. With fewer profiles, failover may have nowhere to switch. + Warning: switching profiles may change the apparent country and public exit IP. + Enable global failover? + MegaProxy will automatically try any configured profile when blocking is suspected. This can change your location and public exit IP. Individual switches will not require confirmation. + Enable + Profile for Always-on VPN + This profile is used when Android starts MegaProxy as an Always-on VPN. + Always-on is active. Selecting another profile reconnects immediately. + Open Android Always-on VPN settings + HTTPS fingerprint + HTTPS TLS / JA3 profile + Default currently uses the Chrome Android fingerprint and may change in a future MegaProxy update. + JA3: version,ciphers,extensions,groups,points + SSH fingerprint + SSH client profile + SSH authentication + The SSH profile controls the client banner and preferred KEX, cipher and MAC ordering. + Changes are saved automatically and apply to every profile. + Allowed range: %1$d–%2$d + Connected through: %1$s + MegaProxy stopped unexpectedly + A privacy-filtered crash report was saved. You can send it to help diagnose the problem. + Report + Close + MegaProxy connection test + DetectedSelected apps onlyNot detected + Local checks show signals that another Android app may observe. No network request, location lookup, or external reputation service is used. + Globally observable signalsVisible to selected appsVPN network signals + The signals below belong to the VPN network. With split tunneling, Android exposes that network to selected apps; excluded apps use the underlying network instead. + A detected signal is not proof by itself. Android VPN APIs intentionally expose some VPN state, and legitimate security or filtering apps can produce the same signals. + %1$s Excluded apps use their underlying network. + System proxy propertiesA process-wide proxy property is visible.No process-wide proxy property is visible. + VPN-like interface nameAn active interface has a tun, tap, wg, ppp, or ipsec-style name.No active interface has a common VPN-style name. + Unusual VPN MTUA VPN-style interface uses an MTU other than 1280 or 1500.No unusual MTU was found on VPN-style interfaces. + Local MITM certificateMegaProxy installs no CA certificate and performs no TLS interception. + VPN transportAndroid exposes a network with TRANSPORT_VPN.No VPN transport is currently exposed. + VPN transport informationAndroid exposes VpnTransportInfo for the VPN network.VpnTransportInfo is not exposed on the currently visible networks. + NOT_VPN capabilityThe VPN network does not have NET_CAPABILITY_NOT_VPN.No visible network is identified by this capability check as a VPN. + VPN link proxyA proxy setting is attached to the VPN network.No proxy setting is attached to the VPN network. + Virtual default routeA visible default route uses a VPN-style interface.No visible default route uses a VPN-style interface. + Virtual DNS addressThe VPN network exposes a private, link-local, or loopback DNS address.The VPN network does not expose a local DNS address. + Select profileThe SSH host key could not be saved to this profile + MegaProxy is activeReview SSH key + Always-on VPN remains active during automatic switching. Every switch is reported in the persistent notification and on the main screen. + Always-on VPN remains active during automatic switching. The persistent notification and the main screen show the profile actually in use. + SSH keepalive, seconds (0 = disabled)Maximum parallel SSH channelsRotate SSH session after minutes (0 = disabled)Rotate SSH session after MB (0 = disabled) + Could not open an email clientJA3 must contain five fields: version,ciphers,extensions,groups,points diff --git a/docs/en/fastlane.md b/docs/en/fastlane.md index 8eb1645..9cb9785 100644 --- a/docs/en/fastlane.md +++ b/docs/en/fastlane.md @@ -37,10 +37,14 @@ That command lists the lanes available in the checked-out version of the project | --- | --- | | `bundle exec fastlane android native_tests` | Runs all Go tests with the race detector. | | `bundle exec fastlane android 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 ui_tests` | Builds the native AAR and runs the instrumented Compose UI tests on a connected Android device or emulator. This lane runs in pull-request CI. | | `bundle exec fastlane android test` | Runs `native_tests` and `android_checks`; this is the normal pre-commit command. | | `bundle exec fastlane android debug_artifact` | Builds `app/build/outputs/apk/debug/app-debug.apk`. | | `bundle exec fastlane android release_artifacts` | Builds and verifies the signed release APKs, AAB, native debug symbols, and `SHA256SUMS` in `dist/release`. | +`ui_tests` requires a running Android device or emulator visible to `adb`. The other lanes do not +start an emulator automatically. + The release lane requires the signing configuration described in [Signed release builds](../../README.md#signed-release-builds). It builds artifacts but does not upload them to Google Play or publish a GitHub Release. GitHub Actions invokes the same lane and diff --git a/docs/ru/fastlane.md b/docs/ru/fastlane.md index 14ca945..81741fa 100644 --- a/docs/ru/fastlane.md +++ b/docs/ru/fastlane.md @@ -38,10 +38,14 @@ bundle exec fastlane lanes | --- | --- | | `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 ui_tests` | Собирает native AAR и запускает инструментальные Compose UI-тесты на подключённом Android-устройстве или эмуляторе. Этот lane выполняется в CI каждого pull request. | | `bundle exec fastlane android test` | Выполняет `native_tests` и `android_checks`; основная команда перед коммитом. | | `bundle exec fastlane android debug_artifact` | Собирает `app/build/outputs/apk/debug/app-debug.apk`. | | `bundle exec fastlane android release_artifacts` | Собирает и проверяет подписанные APK, AAB, native debug symbols и `SHA256SUMS` в `dist/release`. | +Для `ui_tests` нужен запущенный Android-девайс или эмулятор, видимый через `adb`. Остальные lane +не запускают эмулятор автоматически. + Для release lane нужна конфигурация подписи из раздела [Signed release builds](../../README.md#signed-release-builds). Lane только собирает артефакты: он не загружает их в Google Play и не публикует GitHub Release. GitHub Actions запускает тот же lane, diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 04e016a..dedaecf 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -64,6 +64,16 @@ platform :android do UI.success("Verified unsigned release APK: app/build/outputs/apk/release/app-release-unsigned.apk") end + desc "Run instrumented Compose UI tests on a connected Android device or emulator" + lane :ui_tests do + native_library + gradle( + task: "connectedDebugAndroidTest", + flags: "--no-daemon", + project_dir: project_root + ) + end + desc "Run every native and Android test and check" lane :test do native_tests diff --git a/kls-classpath b/kls-classpath new file mode 100755 index 0000000..d1a7806 --- /dev/null +++ b/kls-classpath @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [[ -f "$HOME/.zshrc.extra" ]]; then + # VS Code launched from Finder may not inherit JAVA_HOME or Android SDK variables. + source "$HOME/.zshrc.extra" +fi + +if [[ -z "${JAVA_HOME:-}" && -d /opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home ]]; then + export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home +fi + +exec "$project_dir/gradlew" -q :app:kotlinLanguageServerClasspath --no-daemon diff --git a/scripts/start-android-emulator.sh b/scripts/start-android-emulator.sh index 356c197..f10c2c7 100755 --- a/scripts/start-android-emulator.sh +++ b/scripts/start-android-emulator.sh @@ -8,18 +8,33 @@ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" avd_processes() { ps ax -o pid=,command= | awk -v avd="@$avd_name" ' - index($0, avd) && ($0 ~ /\/emulator / || $0 ~ /qemu-system/) { print $1 } + index($0, avd) && ($0 ~ /\/emulator / || $0 ~ /qemu-system/) { print $1; next } + index($0, "-avd " substr(avd, 2)) && ($0 ~ /\/emulator / || $0 ~ /qemu-system/) { print $1 } + ' +} + +headless_avd_processes() { + ps ax -o pid=,command= | awk -v avd="$avd_name" ' + (index($0, "@" avd) || index($0, "-avd " avd)) && + ($0 ~ /\/emulator / || $0 ~ /qemu-system/) && + ($0 ~ /-no-window/ || $0 ~ /qemu-system-[^ ]*-headless/) { print $1 } ' } emulator_serial="$(adb devices | awk 'NR > 1 && $1 ~ /^emulator-/ { print $1; exit }')" emulator_state="" +restart_for_window=false if [[ -n "$emulator_serial" ]]; then emulator_state="$(adb -s "$emulator_serial" get-state 2>/dev/null || true)" fi -if [[ -n "$emulator_serial" && "$emulator_state" != "device" ]]; then +if [[ "$emulator_state" == "device" && -n "$(headless_avd_processes)" ]]; then + echo "A headless Android emulator is running; restarting it with a window" + restart_for_window=true +fi + +if [[ "$restart_for_window" != "true" && -n "$emulator_serial" && "$emulator_state" != "device" ]]; then echo "Android emulator is $emulator_state; attempting ADB reconnect" adb reconnect offline >/dev/null 2>&1 || true for _ in {1..20}; do @@ -29,10 +44,11 @@ if [[ -n "$emulator_serial" && "$emulator_state" != "device" ]]; then done fi -if { [[ -n "$emulator_serial" && "$emulator_state" != "device" ]] || +if { [[ "$restart_for_window" == "true" ]] || + [[ -n "$emulator_serial" && "$emulator_state" != "device" ]] || { [[ -z "$emulator_serial" ]] && [[ -n "$(avd_processes)" ]]; }; }; then if [[ -n "$emulator_serial" ]]; then - echo "Stopping unresponsive emulator: $emulator_serial" + echo "Stopping non-windowed or unresponsive emulator: $emulator_serial" adb -s "$emulator_serial" emu kill >/dev/null 2>&1 || true else echo "Stopping orphaned emulator process for: $avd_name"