Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
205 changes: 205 additions & 0 deletions app/src/androidTest/java/net/megaproxy487/KeyboardInsetsDeviceTest.kt
Original file line number Diff line number Diff line change
@@ -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<MainActivity>
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
14 changes: 1 addition & 13 deletions app/src/main/java/net/megaproxy487/DiagnosticLogScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 ->
Expand Down
24 changes: 22 additions & 2 deletions app/src/main/java/net/megaproxy487/EditingUi.kt
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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(),
Expand All @@ -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 }
}
12 changes: 8 additions & 4 deletions app/src/main/java/net/megaproxy487/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion app/src/main/java/net/megaproxy487/MtuSettingsScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Loading
Loading