diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9ea6c43..4a41fc2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -148,7 +148,9 @@ kotlin { } dependencies { - implementation(platform("androidx.compose:compose-bom:2025.01.01")) + val composeBom = platform("androidx.compose:compose-bom:2025.01.01") + implementation(composeBom) + androidTestImplementation(composeBom) implementation("androidx.activity:activity-compose:1.10.0") implementation("androidx.core:core-ktx:1.15.0") implementation("androidx.compose.material3:material3") @@ -162,6 +164,7 @@ dependencies { testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test:runner:1.6.2") androidTestImplementation("androidx.test.ext:junit:1.2.1") + androidTestImplementation("androidx.compose.ui:ui-test-junit4") androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0") testImplementation("org.json:json:20250107") implementation("com.networknt:json-schema-validator:1.5.9") diff --git a/app/src/androidTest/java/net/megaproxy487/DocumentsDeviceTest.kt b/app/src/androidTest/java/net/megaproxy487/DocumentsDeviceTest.kt index 9ee1c7c..33e64ba 100644 --- a/app/src/androidTest/java/net/megaproxy487/DocumentsDeviceTest.kt +++ b/app/src/androidTest/java/net/megaproxy487/DocumentsDeviceTest.kt @@ -35,7 +35,9 @@ class DocumentsDeviceTest : DeviceTestBase() { return result } private fun downloads() { - systemNode(By.desc("Show roots")).click() + val roots = systemNode(By.desc("Show roots")) + if (device.hasObject(By.res(java.util.regex.Pattern.compile(".*:id/breadcrumb_text")).text("Downloads"))) return + roots.click() systemNode(By.res("android:id/title").text("Downloads")) // Drawer animation changes coordinates after discovery. Invoke the row's action instead. var node = requireNotNull(instrumentation.uiAutomation.rootInActiveWindow diff --git a/app/src/androidTest/java/net/megaproxy487/KeyboardInsetsDeviceTest.kt b/app/src/androidTest/java/net/megaproxy487/KeyboardInsetsDeviceTest.kt new file mode 100644 index 0000000..b4094bf --- /dev/null +++ b/app/src/androidTest/java/net/megaproxy487/KeyboardInsetsDeviceTest.kt @@ -0,0 +1,205 @@ +package net.megaproxy487 + +import android.content.Context +import android.content.Intent +import android.content.pm.ActivityInfo +import android.content.res.Configuration +import android.os.SystemClock +import androidx.activity.compose.setContent +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.test.hasScrollToIndexAction +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.assertIsFocused +import androidx.compose.ui.test.hasContentDescription +import androidx.compose.ui.test.performTextReplacement +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.createEmptyComposeRule +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.unit.Density +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.test.core.app.ActivityScenario +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.UiDevice +import java.io.File +import net.megaproxy487.data.ConfigStore +import net.megaproxy487.model.DnsProvider +import net.megaproxy487.model.ProxyType +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** Local real-IME checks. Refuse to touch the installed production app or its profiles. */ +@RunWith(AndroidJUnit4::class) +class KeyboardInsetsDeviceTest { + @get:Rule val compose = createEmptyComposeRule() + private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val context = instrumentation.targetContext + private val arguments = InstrumentationRegistry.getArguments() + private val device = UiDevice.getInstance(instrumentation) + private lateinit var scenario: ActivityScenario + private val store get() = ConfigStore(context) + + @Before fun prepareIsolatedApp() { + check(context.packageName == "net.megaproxy487.keyboardreview") { "Build the isolated keyboardreview APK first" } + AppLanguageManager.set(context, if (arguments.getString("language") == "ru") AppLanguage.RUSSIAN else AppLanguage.ENGLISH) + context.getSharedPreferences("battery_optimization_reminder", Context.MODE_PRIVATE) + .edit().putLong("last_request_at", System.currentTimeMillis()).commit() + val profile = store.activeProfile() + store.saveProfile(profile.copy(name = "Keyboard fixture", config = profile.config.copy( + type = ProxyType.SSH, host = "proxy.example", port = 22, + username = "fixture", password = "fixture-password", privateKey = "fixture\n".repeat(12), + dnsProvider = DnsProvider.CUSTOM, customDohUrl = "https://dns.example/dns-query", + ))) + store.saveGlobalConnectionSettings(store.globalConnectionSettings().copy(routeAllApps = false)) + scenario = ActivityScenario.launch(Intent(context, MainActivity::class.java)) + val landscape = arguments.getString("orientation") == "landscape" + scenario.onActivity { + it.window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + it.requestedOrientation = if (landscape) ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE else ActivityInfo.SCREEN_ORIENTATION_PORTRAIT + } + val deadline = SystemClock.elapsedRealtime() + 10_000 + var oriented = false + while (!oriented && SystemClock.elapsedRealtime() < deadline) { + scenario.onActivity { + oriented = (it.resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE) == landscape + } + SystemClock.sleep(50) + } + assertTrue("Activity did not rotate", oriented) + // Configuration can be updated on the old Activity before its recreation finishes. + scenario.recreate() + scenario.onActivity { it.window.addFlags(android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } + instrumentation.waitForIdleSync() + device.waitForIdle() + } + + @After fun closeIsolatedApp() { + if (::scenario.isInitialized) { + device.dumpWindowHierarchy(File(context.getExternalFilesDir(null), "last.xml")) + scenario.close() + } + } + + @Test fun splitSearchAboveKeyboard() = checkField("split", R.string.search_applications, "Chrome") + @Test fun editorDnsAboveKeyboard() = checkField("editor", R.string.custom_doh_url, "https://dns.example/dns-query") + @Test fun invalidPortAboveKeyboard() = checkField("editor", R.string.port, "70000") + @Test fun subscriptionIntervalAboveKeyboard() = checkField("subscription", R.string.subscription_interval, "120") + @Test fun privateKeyAboveKeyboard() = checkField("editor", R.string.private_key_optional, null) + + private fun checkField(screen: String, labelId: Int, input: String?) { + var label = "" + val draft = SubscriptionDraft().apply { + loaded = true; authenticated = true; url = "https://feed.example/config" + } + scenario.onActivity { activity -> + label = activity.uiText(labelId) + activity.setContent { KeyboardAwareLayout { + val scale = arguments.getString("fontScale")?.toFloat() ?: 1f + val configuration = Configuration(LocalConfiguration.current).apply { fontScale = scale } + CompositionLocalProvider( + LocalDensity provides Density(LocalDensity.current.density, scale), + LocalConfiguration provides configuration, + ) { + MaterialTheme { + when (screen) { + "split" -> SplitTunnelScreen(activity, {}) + "editor" -> ProfileEditorScreen(activity, store.activeProfileId(), {}) + "subscription" -> ConfigSubscriptionScreen(activity, {}, draft) + } + } + } + } } + } + val anchor = activityText(when (screen) { + "split" -> R.string.routing_mode + "editor" -> R.string.profile_name_optional + else -> R.string.subscription_url + }) + compose.waitUntil(10_000) { + compose.onAllNodes(hasText(anchor)).fetchSemanticsNodes().isNotEmpty() + } + val field = hasText(label) and hasSetTextAction() + if (screen != "subscription") { + compose.onNode(hasScrollToIndexAction()).performScrollToNode(field) + } + compose.onNode(field).performScrollTo().performClick() + compose.waitForIdle() + scenario.onActivity { activity -> + val view = activity.window.decorView.findFocus() + assertNotNull("Activity focus missing", view) + activity.getSystemService(android.view.inputmethod.InputMethodManager::class.java) + .showSoftInput(view, android.view.inputmethod.InputMethodManager.SHOW_IMPLICIT) + } + val deadline = SystemClock.elapsedRealtime() + 10_000 + var imeBottom = 0 + while (imeBottom == 0 && SystemClock.elapsedRealtime() < deadline) { + scenario.onActivity { activity -> + imeBottom = ViewCompat.getRootWindowInsets(activity.window.decorView) + ?.getInsets(WindowInsetsCompat.Type.ime())?.bottom ?: 0 + } + SystemClock.sleep(50) + } + assertTrue("Keyboard did not open", imeBottom > 0) + // Real IME animation and Compose relocation must settle before geometry is checked. + SystemClock.sleep(1_000) + device.waitForIdle() + compose.onNode(field).assertIsFocused() + if (input != null) { + compose.onNode(field).performTextReplacement(input) + compose.waitForIdle() + SystemClock.sleep(500) + } + compose.onNode(field).assertIsFocused() + var keyboardTop = 0 + var density = 1f + scenario.onActivity { activity -> + val view = activity.window.decorView + val location = IntArray(2) + view.getLocationOnScreen(location) + val insets = ViewCompat.getRootWindowInsets(view)!! + keyboardTop = location[1] + view.height - insets.getInsets(WindowInsetsCompat.Type.ime()).bottom + density = activity.resources.displayMetrics.density + } + val bounds = compose.onNode(field).fetchSemanticsNode().boundsInWindow + val evidence = "${screen}-${context.resources.getResourceEntryName(labelId)}" + File(context.getExternalFilesDir(null), "$evidence.txt").writeText("field=$bounds keyboardTop=$keyboardTop density=$density") + device.dumpWindowHierarchy(File(context.getExternalFilesDir(null), "$evidence.xml")) + assertTrue("Field under IME: $bounds, keyboard top $keyboardTop", bounds.bottom <= keyboardTop + 1) + assertTrue("Field collapsed: $bounds", bounds.height >= 48 * density) + // Use the platform IME API on API 26 too; selection mode can consume Back. + scenario.onActivity { activity -> + activity.getSystemService(android.view.inputmethod.InputMethodManager::class.java) + .hideSoftInputFromWindow(activity.window.decorView.windowToken, 0) + } + val hideDeadline = SystemClock.elapsedRealtime() + 5_000 + var hidden = false + while (!hidden && SystemClock.elapsedRealtime() < hideDeadline) { + scenario.onActivity { activity -> + hidden = ViewCompat.getRootWindowInsets(activity.window.decorView) + ?.getInsets(WindowInsetsCompat.Type.ime())?.bottom == 0 + } + SystemClock.sleep(50) + } + assertTrue("Keyboard did not close", hidden) + val back = activityText(R.string.back) + compose.waitUntil(5_000) { + compose.onAllNodes(hasContentDescription(back)).fetchSemanticsNodes().isNotEmpty() + } + } + + private fun activityText(id: Int): String { + var result = "" + scenario.onActivity { result = it.uiText(id) } + return result + } +} diff --git a/app/src/main/java/net/megaproxy487/ConfigSubscriptionScreen.kt b/app/src/main/java/net/megaproxy487/ConfigSubscriptionScreen.kt index a996f7a..ed066b9 100644 --- a/app/src/main/java/net/megaproxy487/ConfigSubscriptionScreen.kt +++ b/app/src/main/java/net/megaproxy487/ConfigSubscriptionScreen.kt @@ -97,10 +97,10 @@ internal fun ConfigSubscriptionScreen(activity: Activity, onBack: () -> Unit, dr } if (!readError && draft.loaded) { OutlinedTextField(draft.url, { draft.url = it }, label = { FieldLabel(stringResource(R.string.subscription_url)) }, - modifier = Modifier.fillMaxWidth(), singleLine = true, enabled = !busy) + modifier = Modifier.fillMaxWidth().revealOnKeyboard(), singleLine = true, enabled = !busy) OutlinedTextField(draft.backups, { draft.backups = it }, label = { FieldLabel(stringResource(R.string.subscription_backups)) }, supportingText = { Text(stringResource(R.string.subscription_backups_hint)) }, - modifier = Modifier.fillMaxWidth(), enabled = !busy) + modifier = Modifier.fillMaxWidth().revealOnKeyboard(), enabled = !busy) Row( Modifier.fillMaxWidth().toggleable( value = draft.authenticated, @@ -115,7 +115,7 @@ internal fun ConfigSubscriptionScreen(activity: Activity, onBack: () -> Unit, dr } if (draft.authenticated) { OutlinedTextField(draft.username, { draft.username = it }, label = { FieldLabel(stringResource(R.string.basic_auth_username)) }, - modifier = Modifier.fillMaxWidth(), singleLine = true, enabled = !busy) + modifier = Modifier.fillMaxWidth().revealOnKeyboard(), singleLine = true, enabled = !busy) PasswordField(draft.password, { draft.password = it }, stringResource(R.string.password), modifier = Modifier.fillMaxWidth()) } IntegerInputField(draft.interval, { draft.interval = it }, 1..10080, diff --git a/app/src/main/java/net/megaproxy487/DiagnosticLogScreen.kt b/app/src/main/java/net/megaproxy487/DiagnosticLogScreen.kt index 66f67bd..dc4b26c 100644 --- a/app/src/main/java/net/megaproxy487/DiagnosticLogScreen.kt +++ b/app/src/main/java/net/megaproxy487/DiagnosticLogScreen.kt @@ -27,20 +27,15 @@ import androidx.compose.foundation.layout.union import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.saveable.rememberSaveable import net.megaproxy487.data.ConfigWrites @@ -134,14 +129,7 @@ internal fun DiagnosticLogScreen(activity: Activity, onBack: () -> Unit) { Scaffold( topBar = { - TopAppBar( - title = { ScreenTitle(stringResource(R.string.diagnostic_log)) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) - } - }, - ) + EditingTopAppBar(stringResource(R.string.diagnostic_log), onBack) }, contentWindowInsets = WindowInsets.systemBars.union(WindowInsets.ime), ) { padding -> diff --git a/app/src/main/java/net/megaproxy487/EditingUi.kt b/app/src/main/java/net/megaproxy487/EditingUi.kt index e6eba9e..a99d9bb 100644 --- a/app/src/main/java/net/megaproxy487/EditingUi.kt +++ b/app/src/main/java/net/megaproxy487/EditingUi.kt @@ -1,5 +1,9 @@ package net.megaproxy487 +import androidx.compose.foundation.layout.ime +import androidx.compose.foundation.relocation.bringIntoViewRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -39,7 +43,7 @@ internal fun PasswordField(value: String, onValueChange: (String) -> Unit, label // Never persist either the secret or its visible state in an Android saved-state bundle. var visible by remember { mutableStateOf(false) } OutlinedTextField(value, onValueChange, label = { FieldLabel(label) }, singleLine = true, - modifier = modifier, isError = error != null, + modifier = modifier.revealOnKeyboard(), isError = error != null, supportingText = error?.let { { Text(it) } }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), visualTransformation = if (visible) VisualTransformation.None else PasswordVisualTransformation(), @@ -56,7 +60,23 @@ internal fun IntegerInputField(text: String, onTextChange: (String) -> Unit, ran OutlinedTextField(text, { value -> onTextChange(value) validIntegerInput(value, range)?.let(onValidValue) - }, label = { FieldLabel(label) }, singleLine = true, modifier = modifier, + }, label = { FieldLabel(label) }, singleLine = true, modifier = modifier.revealOnKeyboard(), keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), isError = !valid, supportingText = if (!valid) { { Text(uiStringResource(R.string.integer_not_saved, range.first, range.last)) } } else null) } + +/** Re-reveal the whole field after the IME changes the scroll viewport. */ +@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class) +@Composable +internal fun Modifier.revealOnKeyboard(): Modifier { + val requester = remember { androidx.compose.foundation.relocation.BringIntoViewRequester() } + var focused by remember { mutableStateOf(false) } + var height by remember { mutableStateOf(0) } + val imeBottom = androidx.compose.foundation.layout.WindowInsets.ime.getBottom(androidx.compose.ui.platform.LocalDensity.current) + LaunchedEffect(focused, imeBottom, height) { + if (focused) requester.bringIntoView() + } + return this.bringIntoViewRequester(requester) + .onFocusChanged { focused = it.hasFocus } + .onSizeChanged { height = it.height } +} diff --git a/app/src/main/java/net/megaproxy487/MainActivity.kt b/app/src/main/java/net/megaproxy487/MainActivity.kt index 9abbd5a..2432b0b 100644 --- a/app/src/main/java/net/megaproxy487/MainActivity.kt +++ b/app/src/main/java/net/megaproxy487/MainActivity.kt @@ -130,10 +130,14 @@ class MainActivity : LocalizedActivity() { enableEdgeToEdge() BatteryOptimizationReminder.maybeRequest(this) if (intent?.action == OPEN_UPDATES_ACTION) updateRequest++ - setContent { MegaProxyTheme { - MegaProxyNavHost(this, updateRequest = updateRequest) - UpdateAvailableDialog(this, updateRequest) { updateRequest++ } - } } + setContent { + KeyboardAwareLayout { + MegaProxyTheme { + MegaProxyNavHost(this, updateRequest = updateRequest) + UpdateAvailableDialog(this, updateRequest) { updateRequest++ } + } + } + } } override fun onNewIntent(intent: Intent) { diff --git a/app/src/main/java/net/megaproxy487/MtuSettingsScreen.kt b/app/src/main/java/net/megaproxy487/MtuSettingsScreen.kt index 5c6cfd6..b92a468 100644 --- a/app/src/main/java/net/megaproxy487/MtuSettingsScreen.kt +++ b/app/src/main/java/net/megaproxy487/MtuSettingsScreen.kt @@ -89,7 +89,7 @@ internal fun MtuSettingsScreen(activity: Activity, onBack: () -> Unit) { singleLine = true, isError = value == null, supportingText = { Text(stringResource(R.string.mtu_invalid)) }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().revealOnKeyboard(), ) Button(onClick = { value?.let(::save) }, enabled = value != null && value != settings.mtu) { Text(stringResource(R.string.apply_new_settings)) diff --git a/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt b/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt index 34e3dd2..107ccfc 100644 --- a/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt +++ b/app/src/main/java/net/megaproxy487/ProfileEditorScreen.kt @@ -24,8 +24,6 @@ import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.union import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.selection.toggleable -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -37,8 +35,6 @@ import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -46,7 +42,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface 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 @@ -226,43 +221,41 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: (!config.type.hasJump || validIntegerInput(jumpPortText, 1..65535) != null) val canReconnect = validationRes == null && validPorts && writeStatus.pending == 0 && !writeStatus.failed + val compactKeyboard = LocalCompactKeyboard.current + val editorStatus: @Composable () -> Unit = { + SaveStatusBanner() + if (importingKey) androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth()) + error?.takeIf { it != validationRes?.let { id -> activity.uiText(id) } }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + if (!validPorts || validationRes != null) { + Text(stringResource(R.string.correct_fields_before_connecting), style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error) + } + if (showReconnectPrompt || showAlwaysOnNotice) { + Text(stringResource(if (showAlwaysOnNotice) R.string.always_on_changes_next_connection else R.string.reconnect_apply_changes), + style = MaterialTheme.typography.bodySmall) + WrappingActions { + TextButton(shape = RoundedCornerShape(12.dp), onClick = { showReconnectPrompt = false; showAlwaysOnNotice = false; connectionChangeDeferred = true }) { + Text(stringResource(R.string.next_connection)) + } + if (!showAlwaysOnNotice) TextButton(shape = RoundedCornerShape(12.dp), enabled = canReconnect, onClick = { + showReconnectPrompt = false; connectionChangeDeferred = true + ProxyVpnService.reconnect(activity) + }) { Text(stringResource(R.string.reconnect_now)) } + } + } + } + Scaffold( topBar = { - TopAppBar( - title = { ScreenTitle(profile.localizedName(activity)) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) - } - }, - ) + EditingTopAppBar(profile.localizedName(activity), onBack, compactKeyboard) }, bottomBar = { Surface(tonalElevation = 3.dp) { Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) { Column(Modifier.widthIn(max = 840.dp).fillMaxWidth().navigationBarsPadding().imePadding().padding(horizontal = 12.dp, vertical = 4.dp)) { - SaveStatusBanner() - if (importingKey) androidx.compose.material3.LinearProgressIndicator(Modifier.fillMaxWidth()) - error?.takeIf { it != validationRes?.let { id -> activity.uiText(id) } }?.let { - Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) - } - if (!validPorts || validationRes != null) { - Text(stringResource(R.string.correct_fields_before_connecting), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error) - } - if (showReconnectPrompt || showAlwaysOnNotice) { - Text(stringResource(if (showAlwaysOnNotice) R.string.always_on_changes_next_connection else R.string.reconnect_apply_changes), - style = MaterialTheme.typography.bodySmall) - WrappingActions { - TextButton(shape = RoundedCornerShape(12.dp), onClick = { showReconnectPrompt = false; showAlwaysOnNotice = false; connectionChangeDeferred = true }) { - Text(stringResource(R.string.next_connection)) - } - if (!showAlwaysOnNotice) TextButton(shape = RoundedCornerShape(12.dp), enabled = canReconnect, onClick = { - showReconnectPrompt = false; connectionChangeDeferred = true - ProxyVpnService.reconnect(activity) - }) { Text(stringResource(R.string.reconnect_now)) } - } - } + if (!compactKeyboard) editorStatus() } } } @@ -275,11 +268,12 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(10.dp), ) { + item(key = "editor-status") { if (compactKeyboard) Column { editorStatus() } } item { OutlinedTextField(profile.name, { name -> acceptText(name, 256) { profile = profile.copy(name = name) saveProfile() - } }, label = { FieldLabel(stringResource(R.string.profile_name_optional)) }, supportingText = { Text(stringResource(R.string.defaults_to_proxy_host)) }, singleLine = true, modifier = Modifier.fillMaxWidth()) + } }, label = { FieldLabel(stringResource(R.string.profile_name_optional)) }, supportingText = { Text(stringResource(R.string.defaults_to_proxy_host)) }, singleLine = true, modifier = Modifier.fillMaxWidth().revealOnKeyboard()) } item { ExposedDropdownMenuBox(countryExpanded, { countryExpanded = it }) { @@ -335,14 +329,14 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: } } item { - OutlinedTextField(config.host, { value -> acceptText(value, 253) { updateConfig(config.copy(host = it)) } }, label = { FieldLabel(stringResource(if (config.type == ProxyType.HTTPS_JUMP) R.string.destination_https_proxy_hostname else if (config.type == ProxyType.SOCKS5) R.string.socks5_proxy_hostname else if (config.type == ProxyType.MASQUE) R.string.masque_proxy_hostname else if (config.type.isHttpProxy) R.string.https_proxy_hostname else R.string.destination_ssh_hostname)) }, isError = fieldError(R.string.validation_proxy_host, R.string.validation_ssh_host, R.string.validation_host_format) != null, supportingText = fieldError(R.string.validation_proxy_host, R.string.validation_ssh_host, R.string.validation_host_format)?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.host, { value -> acceptText(value, 253) { updateConfig(config.copy(host = it)) } }, label = { FieldLabel(stringResource(if (config.type == ProxyType.HTTPS_JUMP) R.string.destination_https_proxy_hostname else if (config.type == ProxyType.SOCKS5) R.string.socks5_proxy_hostname else if (config.type == ProxyType.MASQUE) R.string.masque_proxy_hostname else if (config.type.isHttpProxy) R.string.https_proxy_hostname else R.string.destination_ssh_hostname)) }, isError = fieldError(R.string.validation_proxy_host, R.string.validation_ssh_host, R.string.validation_host_format) != null, supportingText = fieldError(R.string.validation_proxy_host, R.string.validation_ssh_host, R.string.validation_host_format)?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth().revealOnKeyboard()) } item { IntegerInputField(portText, { portText = it }, 1..65535, stringResource(R.string.port), onValidValue = { updateConfig(config.copy(port = it)) }, modifier = Modifier.fillMaxWidth()) } item { - OutlinedTextField(config.username, { value -> acceptText(value, 4_096) { updateConfig(config.copy(username = it)) } }, label = { FieldLabel(stringResource(if (config.type == ProxyType.SOCKS5) R.string.socks5_username_optional else if (config.type.isHttpProxy) R.string.basic_auth_username else R.string.ssh_username)) }, isError = fieldError(R.string.validation_basic_username, R.string.validation_ssh_username, R.string.validation_socks5_auth) != null, supportingText = fieldError(R.string.validation_basic_username, R.string.validation_ssh_username, R.string.validation_socks5_auth)?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.username, { value -> acceptText(value, 4_096) { updateConfig(config.copy(username = it)) } }, label = { FieldLabel(stringResource(if (config.type == ProxyType.SOCKS5) R.string.socks5_username_optional else if (config.type.isHttpProxy) R.string.basic_auth_username else R.string.ssh_username)) }, isError = fieldError(R.string.validation_basic_username, R.string.validation_ssh_username, R.string.validation_socks5_auth) != null, supportingText = fieldError(R.string.validation_basic_username, R.string.validation_ssh_username, R.string.validation_socks5_auth)?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth().revealOnKeyboard()) } item { PasswordField(config.password, { value -> acceptText(value, 16_384) { updateConfig(config.copy(password = it, unreadableSecrets = config.unreadableSecrets - "password")) } }, label = stringResource(if (config.type == ProxyType.SOCKS5) R.string.socks5_password_optional else if (config.type.isHttpProxy) R.string.password else R.string.ssh_password_optional), @@ -364,7 +358,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: label = { FieldLabel(stringResource(R.string.masque_custom_ja3)) }, isError = fieldError(R.string.validation_ja3, R.string.validation_quic_ja3) != null, supportingText = fieldError(R.string.validation_ja3, R.string.validation_quic_ja3)?.let { { Text(it) } }, - minLines = 2, modifier = Modifier.fillMaxWidth(), + minLines = 2, modifier = Modifier.fillMaxWidth().revealOnKeyboard(), ) } } @@ -381,7 +375,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: if (config.type.isSsh) { item { - OutlinedTextField(config.privateKey, { value -> acceptText(value, 64 * 1024) { updateConfig(config.copy(privateKey = it, unreadableSecrets = config.unreadableSecrets - "privateKey")) } }, label = { FieldLabel(stringResource(R.string.private_key_optional)) }, supportingText = { Text(stringResource(R.string.private_key_format_hint)) }, minLines = 3, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.privateKey, { value -> acceptText(value, 64 * 1024) { updateConfig(config.copy(privateKey = it, unreadableSecrets = config.unreadableSecrets - "privateKey")) } }, label = { FieldLabel(stringResource(R.string.private_key_optional)) }, supportingText = { Text(stringResource(R.string.private_key_format_hint)) }, minLines = 3, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth().revealOnKeyboard()) } item { OutlinedButton(shape = RoundedCornerShape(12.dp), @@ -411,7 +405,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: item { HorizontalDivider() } item { Text(stringResource(R.string.https_jump_proxy), style = MaterialTheme.typography.titleMedium) } item { - OutlinedTextField(config.jumpHost, { value -> acceptText(value, 253) { updateConfig(config.copy(jumpHost = it)) } }, label = { FieldLabel(stringResource(R.string.https_jump_hostname)) }, isError = fieldError(R.string.validation_jump_host, R.string.validation_jump_host_format) != null, supportingText = fieldError(R.string.validation_jump_host, R.string.validation_jump_host_format)?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.jumpHost, { value -> acceptText(value, 253) { updateConfig(config.copy(jumpHost = it)) } }, label = { FieldLabel(stringResource(R.string.https_jump_hostname)) }, isError = fieldError(R.string.validation_jump_host, R.string.validation_jump_host_format) != null, supportingText = fieldError(R.string.validation_jump_host, R.string.validation_jump_host_format)?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth().revealOnKeyboard()) } item { IntegerInputField(jumpPortText, { jumpPortText = it }, 1..65535, stringResource(R.string.jump_port), @@ -424,7 +418,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: } if (!config.sameJumpAuthentication) { item { - OutlinedTextField(config.jumpUsername, { value -> acceptText(value, 4_096) { updateConfig(config.copy(jumpUsername = it)) } }, label = { FieldLabel(stringResource(R.string.https_jump_username)) }, isError = fieldError(R.string.validation_jump_ssh_username, R.string.validation_jump_basic_username) != null, supportingText = fieldError(R.string.validation_jump_ssh_username, R.string.validation_jump_basic_username)?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.jumpUsername, { value -> acceptText(value, 4_096) { updateConfig(config.copy(jumpUsername = it)) } }, label = { FieldLabel(stringResource(R.string.https_jump_username)) }, isError = fieldError(R.string.validation_jump_ssh_username, R.string.validation_jump_basic_username) != null, supportingText = fieldError(R.string.validation_jump_ssh_username, R.string.validation_jump_basic_username)?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth().revealOnKeyboard()) } item { PasswordField(config.jumpPassword, { value -> acceptText(value, 16_384) { updateConfig(config.copy(jumpPassword = it, unreadableSecrets = config.unreadableSecrets - "jumpPassword")) } }, label = stringResource(R.string.https_jump_password), @@ -447,7 +441,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: Text(stringResource(R.string.jump_host), style = MaterialTheme.typography.titleMedium) } item { - OutlinedTextField(config.jumpHost, { value -> acceptText(value, 253) { updateConfig(config.copy(jumpHost = it)) } }, label = { FieldLabel(stringResource(R.string.jump_ssh_hostname)) }, isError = fieldError(R.string.validation_jump_host, R.string.validation_jump_host_format) != null, supportingText = fieldError(R.string.validation_jump_host, R.string.validation_jump_host_format)?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.jumpHost, { value -> acceptText(value, 253) { updateConfig(config.copy(jumpHost = it)) } }, label = { FieldLabel(stringResource(R.string.jump_ssh_hostname)) }, isError = fieldError(R.string.validation_jump_host, R.string.validation_jump_host_format) != null, supportingText = fieldError(R.string.validation_jump_host, R.string.validation_jump_host_format)?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth().revealOnKeyboard()) } item { IntegerInputField(jumpPortText, { jumpPortText = it }, 1..65535, stringResource(R.string.jump_port), @@ -460,14 +454,14 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: } if (!config.sameJumpAuthentication) { item { - OutlinedTextField(config.jumpUsername, { value -> acceptText(value, 4_096) { updateConfig(config.copy(jumpUsername = it)) } }, label = { FieldLabel(stringResource(R.string.jump_ssh_username)) }, isError = fieldError(R.string.validation_jump_ssh_username, R.string.validation_jump_basic_username) != null, supportingText = fieldError(R.string.validation_jump_ssh_username, R.string.validation_jump_basic_username)?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.jumpUsername, { value -> acceptText(value, 4_096) { updateConfig(config.copy(jumpUsername = it)) } }, label = { FieldLabel(stringResource(R.string.jump_ssh_username)) }, isError = fieldError(R.string.validation_jump_ssh_username, R.string.validation_jump_basic_username) != null, supportingText = fieldError(R.string.validation_jump_ssh_username, R.string.validation_jump_basic_username)?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth().revealOnKeyboard()) } item { PasswordField(config.jumpPassword, { value -> acceptText(value, 16_384) { updateConfig(config.copy(jumpPassword = it, unreadableSecrets = config.unreadableSecrets - "jumpPassword")) } }, label = stringResource(R.string.jump_ssh_password_optional), error = fieldError(R.string.validation_jump_basic_password), modifier = Modifier.fillMaxWidth()) } item { - OutlinedTextField(config.jumpPrivateKey, { value -> acceptText(value, 64 * 1024) { updateConfig(config.copy(jumpPrivateKey = it, unreadableSecrets = config.unreadableSecrets - "jumpPrivateKey")) } }, label = { FieldLabel(stringResource(R.string.jump_private_key_optional)) }, minLines = 3, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth()) + OutlinedTextField(config.jumpPrivateKey, { value -> acceptText(value, 64 * 1024) { updateConfig(config.copy(jumpPrivateKey = it, unreadableSecrets = config.unreadableSecrets - "jumpPrivateKey")) } }, label = { FieldLabel(stringResource(R.string.jump_private_key_optional)) }, minLines = 3, visualTransformation = PasswordVisualTransformation(), modifier = Modifier.fillMaxWidth().revealOnKeyboard()) } item { OutlinedButton(shape = RoundedCornerShape(12.dp), @@ -551,7 +545,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: val dnsError = fieldError(R.string.validation_doh_https_required, R.string.validation_doh_url) OutlinedTextField(config.customDohUrl, { value -> acceptText(value, 2_048) { updateConfig(config.copy(customDohUrl = it)) } }, label = { FieldLabel(stringResource(R.string.custom_doh_url)) }, isError = dnsError != null, - supportingText = dnsError?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) + supportingText = dnsError?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth().revealOnKeyboard()) } } else { item { @@ -561,7 +555,7 @@ internal fun ProfileEditorScreen(activity: Activity, profileId: String?, onBack: OutlinedTextField(config.customDohUrl.trim().substringAfter("://", ""), { value -> acceptText(value, 256) { updateConfig(config.copy(customDohUrl = "${config.dnsTransport.scheme}://$it")) } }, label = { FieldLabel(stringResource(R.string.dns_server_ip)) }, isError = dnsError != null, - supportingText = dnsError?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth()) + supportingText = dnsError?.let { { Text(it) } }, singleLine = true, modifier = Modifier.fillMaxWidth().revealOnKeyboard()) } } diff --git a/app/src/main/java/net/megaproxy487/SettingsScreens.kt b/app/src/main/java/net/megaproxy487/SettingsScreens.kt index 726d7e6..f6b9d53 100644 --- a/app/src/main/java/net/megaproxy487/SettingsScreens.kt +++ b/app/src/main/java/net/megaproxy487/SettingsScreens.kt @@ -32,7 +32,6 @@ import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowForward import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card @@ -45,13 +44,11 @@ import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.MenuAnchorType import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.SideEffect import androidx.compose.runtime.saveable.rememberSaveable @@ -451,7 +448,7 @@ internal fun TlsFingerprintScreen(activity: Activity, onBack: () -> Unit) { { if (it.length <= 8 * 1024) save(customJa3 = it) }, label = { FieldLabel(stringResource(R.string.ja3_format)) }, minLines = 2, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().revealOnKeyboard(), ) } error?.let { Text(it, color = MaterialTheme.colorScheme.error) } @@ -539,14 +536,7 @@ private fun IntegerSettingField(initialValue: Int, range: IntRange, label: Strin internal fun SettingsScaffold(onBack: () -> Unit, title: String, content: @Composable ColumnScope.() -> Unit) { Scaffold( topBar = { - TopAppBar( - title = { ScreenTitle(title) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) - } - }, - ) + EditingTopAppBar(title, onBack) }, contentWindowInsets = WindowInsets.systemBars.union(WindowInsets.ime), ) { padding -> diff --git a/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt b/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt index 999291f..188adef 100644 --- a/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt +++ b/app/src/main/java/net/megaproxy487/SplitTunnelScreen.kt @@ -11,6 +11,8 @@ import androidx.activity.ComponentActivity import androidx.activity.enableEdgeToEdge import androidx.activity.compose.setContent import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets @@ -26,8 +28,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.selection.toggleable -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.ButtonDefaults @@ -35,15 +35,12 @@ import androidx.compose.material3.Checkbox import androidx.compose.material3.Switch import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.RadioButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.LaunchedEffect @@ -122,58 +119,55 @@ internal fun SplitTunnelScreen(activity: Activity, onBack: () -> Unit) { } } - Scaffold( - topBar = { - TopAppBar( - title = { ScreenTitle(stringResource(R.string.split_tunneling)) }, - navigationIcon = { - IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back)) - } - }, - ) - }, - bottomBar = { - Column { - SaveStatusBanner() - if (showReconnectPrompt || showAlwaysOnDeferredNotice) { - Card( - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - contentColor = MaterialTheme.colorScheme.onTertiaryContainer, - ), - modifier = Modifier.fillMaxWidth().padding(12.dp), - ) { - Column(Modifier.fillMaxWidth().padding(12.dp)) { - Text( - if (showAlwaysOnDeferredNotice) activity.uiText(R.string.routing_deferred) - else activity.uiText(R.string.routing_reconnect), + val compactKeyboard = LocalCompactKeyboard.current + val routingStatus: @Composable () -> Unit = { + SaveStatusBanner() + if (showReconnectPrompt || showAlwaysOnDeferredNotice) { + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer, + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + ), + modifier = Modifier.fillMaxWidth().padding(12.dp), + ) { + Column(Modifier.fillMaxWidth().padding(12.dp)) { + Text( + if (showAlwaysOnDeferredNotice) activity.uiText(R.string.routing_deferred) + else activity.uiText(R.string.routing_reconnect), + ) + WrappingActions() { + val actionColors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onTertiaryContainer, + disabledContentColor = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.6f), ) - WrappingActions() { - val actionColors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.onTertiaryContainer, - disabledContentColor = MaterialTheme.colorScheme.onTertiaryContainer.copy(alpha = 0.6f), - ) - TextButton(colors = actionColors, shape = RoundedCornerShape(12.dp), onClick = { + TextButton(colors = actionColors, shape = RoundedCornerShape(12.dp), onClick = { + showReconnectPrompt = false + showAlwaysOnDeferredNotice = false + deferChangesUntilNextConnection = true + }) { Text(stringResource(if (showAlwaysOnDeferredNotice) R.string.dismiss else R.string.next_connection)) } + if (showReconnectPrompt) { + TextButton(colors = actionColors, shape = RoundedCornerShape(12.dp), enabled = writeStatus.pending == 0 && !writeStatus.failed, onClick = { showReconnectPrompt = false - showAlwaysOnDeferredNotice = false deferChangesUntilNextConnection = true - }) { Text(stringResource(if (showAlwaysOnDeferredNotice) R.string.dismiss else R.string.next_connection)) } - if (showReconnectPrompt) { - TextButton(colors = actionColors, shape = RoundedCornerShape(12.dp), enabled = writeStatus.pending == 0 && !writeStatus.failed, onClick = { - showReconnectPrompt = false - deferChangesUntilNextConnection = true - ProxyVpnService.reconnect(activity) - }) { Text(stringResource(R.string.reconnect_now)) } - } + ProxyVpnService.reconnect(activity) + }) { Text(stringResource(R.string.reconnect_now)) } } } } } + } + } + + Scaffold( + topBar = { + EditingTopAppBar(stringResource(R.string.split_tunneling), onBack, compactKeyboard) + }, + bottomBar = { + Column(Modifier.navigationBarsPadding().imePadding()) { + if (!compactKeyboard) routingStatus() } }, - // Use one inset source. Applying safeDrawing in Scaffold and imePadding to - // its child can double-apply or misreport the IME area on some OEM builds. + // A supplied bottom bar owns bottom insets, even when it has no visible notices. contentWindowInsets = WindowInsets.systemBars.union(WindowInsets.ime), ) { padding -> ScreenContent(Modifier.padding(padding)) { @@ -182,6 +176,7 @@ internal fun SplitTunnelScreen(activity: Activity, onBack: () -> Unit) { contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp), ) { + item(key = "routing-status") { if (compactKeyboard) routingStatus() } item { Text(stringResource(R.string.routing_mode), style = MaterialTheme.typography.titleMedium) } item { Column(Modifier.selectableGroup()) { @@ -266,13 +261,13 @@ internal fun SplitTunnelScreen(activity: Activity, onBack: () -> Unit) { CircularProgressIndicator() } } - item { + item(key = "app-search") { OutlinedTextField( value = appSearch, onValueChange = { appSearch = it }, label = { FieldLabel(stringResource(R.string.search_applications)) }, singleLine = true, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().revealOnKeyboard(), ) } items(visibleApps, key = { it.packageName }) { app -> diff --git a/app/src/main/java/net/megaproxy487/UiLayout.kt b/app/src/main/java/net/megaproxy487/UiLayout.kt index e599f8a..22f6409 100644 --- a/app/src/main/java/net/megaproxy487/UiLayout.kt +++ b/app/src/main/java/net/megaproxy487/UiLayout.kt @@ -1,25 +1,79 @@ package net.megaproxy487 -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat + +internal val LocalCompactKeyboard = compositionLocalOf { false } + +/** Install once above navigation; outgoing screens must not remove the next screen's listener. */ +@Composable +internal fun KeyboardAwareLayout(content: @Composable () -> Unit) { + val root = LocalView.current.rootView + val density = LocalDensity.current.density + var compact by remember(root) { mutableStateOf(false) } + DisposableEffect(root, density) { + ViewCompat.setOnApplyWindowInsetsListener(root) { _, insets -> + compact = insets.getInsets(WindowInsetsCompat.Type.ime()).bottom > 0 && + root.height / density < 480 + insets + } + ViewCompat.requestApplyInsets(root) + onDispose { ViewCompat.setOnApplyWindowInsetsListener(root, null) } + } + CompositionLocalProvider(LocalCompactKeyboard provides compact, content = content) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun EditingTopAppBar(title: String, onBack: () -> Unit, compactKeyboard: Boolean = LocalCompactKeyboard.current) { + if (!compactKeyboard) { + TopAppBar( + title = { ScreenTitle(title) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = uiStringResource(R.string.back), + ) + } + }, + ) + } +} /** Keep forms and result cards readable in large windows. */ @Composable diff --git a/docs/en/release-testing.md b/docs/en/release-testing.md index 58a878c..616726d 100644 --- a/docs/en/release-testing.md +++ b/docs/en/release-testing.md @@ -50,6 +50,8 @@ restart, subscription persistence and system DocumentsUI. The runner invokes cas instrumentation processes; Keystore seed/verify must remain separate. These tests do not establish physical network handover, OEM power policy, every Always-on/lockdown mode or real IPv6 uplink support. +The disposable emulator disables public captive-portal probes, reconnects Wi-Fi on API 35 (the API 26 `svc wifi` client exits with 137) and waits for it to become the validated default network. Before starting VPN scenarios it must receive the local SSH fixture banner through `10.0.2.2`; failure saves network diagnostics and stops the suite without retrying tests. + ## Release acceptance: smoke first, expand by risk Identify the exact candidate commit, version/ABI, APK SHA-256 and signer. Use full successful CI for @@ -101,3 +103,27 @@ budget, not permission to skip a failed or blocked critical check. failures and material limitations in the relevant PR/release; always restore the device. CI is unchanged: retain all existing suites, scope rules and independent API checks. + +### Local keyboard checks on emulators and phones + +Use `KeyboardInsetsDeviceTest` for real-IME geometry checks. It refuses to run against +`net.megaproxy487`: build its isolated `net.megaproxy487.keyboardreview` app first. +The isolated app uses synthetic profiles and never starts a VPN. Keep these visual +configuration checks local; they are not part of the VPN integration CI scenarios. + +Unlock phones before starting. The test Activity keeps the screen on without changing the system timeout. + +```sh +bundle exec fastlane run gradle tasks:"assembleDebug assembleDebugAndroidTest" flags:"--no-daemon -I scripts/keyboard-review.gradle" +adb -s SERIAL install app/build/outputs/apk/debug/app-debug.apk +adb -s SERIAL install app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk +adb -s SERIAL shell am instrument -w -r -e class net.megaproxy487.KeyboardInsetsDeviceTest -e language en -e orientation landscape -e fontScale 2.0 net.megaproxy487.keyboardreview.test/androidx.test.runner.AndroidJUnitRunner +``` + +Check API 26 and 35 emulators and connected phones with their usual keyboards: +portrait, landscape, and landscape with 200% font; include EN/RU. The checks cover +app search, DoH, an invalid port with a validation notice, subscription interval, +and a multiline private key. They require a real keyboard to open, enough visible +field height above it, and navigation returning when the keyboard closes. Inspect typing, cursor visibility +and scrolling as well. In short windows the app bar disappears while typing; notices +move into the scrollable form. Orientation and font arguments affect only the test Activity. Afterward uninstall only `net.megaproxy487.keyboardreview.test` and `net.megaproxy487.keyboardreview`. diff --git a/docs/ru/release-testing.md b/docs/ru/release-testing.md index 00b0f4e..629060a 100644 --- a/docs/ru/release-testing.md +++ b/docs/ru/release-testing.md @@ -51,6 +51,8 @@ stop/restart и остановку при запуске SSH, VPN/notification p instrumentation-процессом; Keystore seed/verify должны оставаться раздельными. Это не доказывает работу physical network handover, OEM power policy, всех Always-on/lockdown режимов и реального IPv6 uplink. +Одноразовый эмулятор отключает публичные проверки captive portal, переподключает Wi-Fi на API 35 (клиент `svc wifi` API 26 завершается с кодом 137) и ждёт, пока она станет подтверждённой сетью по умолчанию. До VPN-сценариев он должен получить баннер локального SSH-стенда через `10.0.2.2`; при отказе сохраняется диагностика сети, а набор останавливается без повторения тестов. + ## Приёмка релиза: smoke, затем риски изменения Зафиксируйте точный commit, версию/ABI, SHA-256 и подпись APK. Нужен полный успешный CI кандидата; @@ -102,3 +104,29 @@ instrumentation-процессом; Keystore seed/verify должны остав укажите проверенную область, ошибки и существенные ограничения; устройство обязательно восстановите. CI не меняется: сохраняются все наборы, scope rules и независимые API-проверки. + +### Локальная проверка клавиатуры на эмуляторах и телефонах + +`KeyboardInsetsDeviceTest` проверяет геометрию полей с настоящей клавиатурой. +Он отказывается работать с `net.megaproxy487`: сначала соберите отдельное приложение +`net.megaproxy487.keyboardreview`. В нём используются тестовые профили, VPN не запускается. +Эти проверки конфигураций UI остаются локальными и не входят в интеграционные VPN-сценарии CI. + +Перед запуском разблокируйте телефоны. Тестовая Activity удерживает экран включённым, не меняя системный тайм-аут. + +```sh +bundle exec fastlane run gradle tasks:"assembleDebug assembleDebugAndroidTest" flags:"--no-daemon -I scripts/keyboard-review.gradle" +adb -s SERIAL install app/build/outputs/apk/debug/app-debug.apk +adb -s SERIAL install app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk +adb -s SERIAL shell am instrument -w -r -e class net.megaproxy487.KeyboardInsetsDeviceTest -e language ru -e orientation landscape -e fontScale 2.0 net.megaproxy487.keyboardreview.test/androidx.test.runner.AndroidJUnitRunner +``` + +Проверьте эмуляторы API 26/35 и подключённые телефоны с их обычными клавиатурами: +портретную и альбомную ориентации, затем альбомную со шрифтом 200%; включите EN/RU. +Проверки охватывают поиск приложений, DoH, ошибочный порт с сообщением проверки, +интервал подписки и многострочный приватный ключ. Они требуют открытия настоящей +клавиатуры, достаточной высоты поля над ней и восстановления навигации после закрытия клавиатуры. +Также осмотрите ввод, видимость курсора и прокрутку. В коротком окне панель заголовка +скрывается на время ввода, а сообщения переходят в прокручиваемую форму. Аргументы ориентации и шрифта +влияют только на тестовую Activity. После проверки удалите только `net.megaproxy487.keyboardreview.test` +и `net.megaproxy487.keyboardreview`. diff --git a/scripts/android_device_tests.py b/scripts/android_device_tests.py index 24f933b..0f58fe6 100644 --- a/scripts/android_device_tests.py +++ b/scripts/android_device_tests.py @@ -363,7 +363,23 @@ def handle(self): def prepare_emulator(shell, api): - # Cellular/Wi-Fi validation can switch the default network mid-request. + # Fixtures are private: public captive-portal probes must not decide whether + # Android supplies their underlying network. Reconnect to apply the setting. + previous_default = None + shell("settings", "put", "global", "captive_portal_mode", "0") + # The API 26 svc Wi-Fi client is killed (137), including on x86_64 CI. + # Keep its existing connection; readiness below is required on both APIs. + if api >= 35: + previous_default = re.search( + r"Active default network: (\d+)", shell("dumpsys", "connectivity") + ) + shell("svc", "wifi", "disable") + deadline = time.monotonic() + 30 + while "Wi-Fi is disabled" not in shell("dumpsys", "wifi"): + if time.monotonic() >= deadline: + raise RuntimeError("Disposable emulator Wi-Fi did not disconnect") + time.sleep(0.1) + shell("svc", "wifi", "enable") # These scenarios test proxy failover, not Android network handover. shell("svc", "data", "disable") # Unused image apps can obscure tests with crash/ANR dialogs. MegaProxy's @@ -375,6 +391,28 @@ def prepare_emulator(shell, api): shell("am", "force-stop", package) shell("pm", "disable-user", "--user", "0", package) + deadline = time.monotonic() + 30 + while True: + state = shell("dumpsys", "connectivity") + default = re.search(r"Active default network: (\d+)", state) + if ( + default + and (not previous_default or default[1] != previous_default[1]) + and any( + line.lstrip().startswith("NetworkAgentInfo{") + and f"network{{{default[1]}}}" in line + and "Transports: WIFI" in line + and re.search(r"Capabilities:[^\]]*\bVALIDATED\b", line) + for line in state.splitlines() + ) + ): + return + if time.monotonic() >= deadline: + raise RuntimeError( + "Disposable emulator has no validated default Wi-Fi network" + ) + time.sleep(0.1) + def main(): parser = argparse.ArgumentParser(description=__doc__) @@ -397,9 +435,14 @@ def shell(*words): ) if shell("getprop", "ro.kernel.qemu") != "1": parser.error("Device is not an emulator") - prepare_emulator(shell, args.api) results = ROOT / "test-results" / f"android-api{args.api}" results.mkdir(parents=True, exist_ok=True) + try: + prepare_emulator(shell, args.api) + except (RuntimeError, subprocess.SubprocessError): + for service in ("connectivity", "wifi"): + (results / f"preflight-{service}.txt").write_text(shell("dumpsys", service)) + raise suite = ET.Element("testsuite", name=f"Android API {args.api}") failures = 0 command( @@ -416,6 +459,22 @@ def shell(*words): raise RuntimeError("Failed to reset disposable app data") with fixture() as parameters, stalled_ssh(shell) as stalled: parameters.update(stalled) + # Verify the emulator-to-host leg too, before any VPN or test starts. + try: + banner = shell( + "sh", + "-c", + f"toybox nc -w 3 -q 1 10.0.2.2 {int(parameters['sshPort'])} = 33: tests.append(("VpnDeviceTest#deniedNotificationsStillAllowVpn", None)) diff --git a/scripts/keyboard-review.gradle b/scripts/keyboard-review.gradle new file mode 100644 index 0000000..c0cafaa --- /dev/null +++ b/scripts/keyboard-review.gradle @@ -0,0 +1,7 @@ +gradle.beforeProject { project -> + project.plugins.withId('com.android.application') { + project.extensions.getByName('androidComponents').finalizeDsl { dsl -> + dsl.defaultConfig.applicationId = 'net.megaproxy487.keyboardreview' + } + } +} diff --git a/scripts/tests/test_android_device_tests.py b/scripts/tests/test_android_device_tests.py index b21a272..8cde69f 100644 --- a/scripts/tests/test_android_device_tests.py +++ b/scripts/tests/test_android_device_tests.py @@ -5,7 +5,7 @@ import unittest import xml.etree.ElementTree as ET from pathlib import Path -from unittest.mock import Mock, call +from unittest.mock import Mock, call, patch sys.path.insert(0, str(Path(__file__).parents[1])) import android_device_tests as m @@ -60,18 +60,56 @@ def test_disposable_emulator_removes_observed_background_dialog_sources(self): (35, "com.google.android.apps.nexuslauncher"), ]: with self.subTest(api=api): - shell = Mock() + ready = "Active default network: 100\nNetworkAgentInfo{ network{100} nc{[ Transports: WIFI Capabilities: INTERNET&NOT_VPN&VALIDATED]}}" + connections = iter( + ["Active default network: 99", ready.replace("100", "99"), ready] + if api >= 35 + else [ready] + ) + + def response(*args): + if args == ("dumpsys", "connectivity"): + return next(connections) + if args == ("dumpsys", "wifi"): + return "Wi-Fi is disabled" + return "" + + shell = Mock(side_effect=response) m.prepare_emulator(shell, api) - self.assertEqual( - [ - call("svc", "data", "disable"), - call("am", "force-stop", package), - call("pm", "disable-user", "--user", "0", package), - ], - shell.call_args_list, + self.assertIsNone( + next(connections, None), + "The stale pre-reconnect network must not satisfy readiness", ) + shell.assert_any_call("svc", "data", "disable") + shell.assert_any_call( + "settings", "put", "global", "captive_portal_mode", "0" + ) + shell.assert_any_call("am", "force-stop", package) + shell.assert_any_call("pm", "disable-user", "--user", "0", package) + if api == 26: + self.assertNotIn( + call("svc", "wifi", "disable"), shell.call_args_list + ) + self.assertNotIn( + call("svc", "wifi", "enable"), shell.call_args_list + ) + else: + shell.assert_any_call("svc", "wifi", "disable") + shell.assert_any_call("svc", "wifi", "enable") self.assertNotIn(m.PACKAGE, str(shell.call_args_list)) + def test_emulator_rejects_an_unvalidated_default_network(self): + for state in ( + "Active default network: 100\nNetworkAgentInfo{ network{101} nc{[ Transports: WIFI Capabilities: INTERNET&VALIDATED]}}", + "Active default network: 100\nNetworkAgentInfo{ network{100} Score(IS_VALIDATED) nc{[ Transports: WIFI Capabilities: INTERNET&NOT_VPN]}}", + ): + with ( + self.subTest(state=state), + patch.object(m.time, "monotonic", side_effect=[0, 31]), + ): + with self.assertRaisesRegex(RuntimeError, "no validated default Wi-Fi"): + m.prepare_emulator(Mock(return_value=state), 26) + def test_logcat_failure_preserves_evidence_and_failed_report(self): suite = ET.Element("testsuite") case = ET.SubElement(suite, "testcase", time="1.0")