Improve wireless ADB text input

This commit is contained in:
xXJsonDeruloXx committed 2026-09-25 23:17:28 -04:00
1 parent 63f7554f43
commit 90d7eede2e
1 file changed
+263 -133
@@ -1,39 +1,52 @@
package com.droiddeck.launcher.ui
import android.text.Editable
import android.text.InputType
import android.text.TextWatcher
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
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.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.FocusManager
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.droiddeck.launcher.core.PhantomProcessLimit
import kotlin.math.min
import kotlin.math.roundToInt
@Composable
fun WirelessAdbFixPage(
@@ -45,41 +58,48 @@ fun WirelessAdbFixPage(
onApply: (String, Int, Boolean, (String?) -> Unit) -> Unit,
) {
var step by rememberSaveable { mutableStateOf(0) }
var host by rememberSaveable { mutableStateOf("") }
var pairingPort by rememberSaveable { mutableStateOf("") }
var pairingAddress by rememberSaveable { mutableStateOf("") }
var connectionAddress by rememberSaveable { mutableStateOf("") }
var pairingCode by rememberSaveable { mutableStateOf("") }
var connectPort by rememberSaveable { mutableStateOf("") }
var busy by remember { mutableStateOf(false) }
var editing by remember { mutableStateOf(false) }
var message by remember { mutableStateOf<String?>(null) }
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val pairingEndpoint = parseAdbAddress(pairingAddress)
val pairNow: () -> Unit = {
val pairingHost = host
busy = true
message = null
onPair(pairingHost, pairingPort.toInt(), pairingCode) { error ->
pairingCode = ""
if (error != null) {
busy = false
message = error
} else {
step = 1
busy = true
message = "Paired. Finding the Wireless debugging port…"
onFindConnectPort(pairingHost) { discoveredPort ->
if (discoveredPort == null) {
busy = false
message = "Enter the current Wireless debugging port from Settings."
} else {
connectPort = discoveredPort.toString()
message = "Device found. Applying the setting…"
onApply(pairingHost, discoveredPort, desiredEnabled) { applyError ->
parseAdbAddress(pairingAddress)?.let { endpoint ->
keyboardController?.hide()
focusManager.clearFocus()
busy = true
editing = false
message = null
onPair(endpoint.host, endpoint.port, pairingCode) { error ->
pairingCode = ""
if (error != null) {
busy = false
message = error
} else {
step = 1
busy = true
message = "Paired. Finding the Wireless debugging port…"
onFindConnectPort(endpoint.host) { discoveredPort ->
if (discoveredPort == null) {
connectionAddress = formatAddressHost(endpoint.host) + ":"
busy = false
if (applyError == null) {
step = 2
message = null
} else {
message = "Could not apply automatically. Check the port below and retry. $applyError"
message = "Enter the current Wireless debugging address from Settings."
} else {
connectionAddress = formatAdbAddress(endpoint.host, discoveredPort)
message = "Device found. Applying the setting…"
onApply(endpoint.host, discoveredPort, desiredEnabled) { applyError ->
busy = false
if (applyError == null) {
step = 2
message = null
} else {
message = "Could not apply automatically. Check the address below and retry. $applyError"
}
}
}
}
@@ -87,16 +107,21 @@ fun WirelessAdbFixPage(
}
}
}
val applyPort: () -> Unit = {
busy = true
message = null
onApply(host, connectPort.toInt(), desiredEnabled) { error ->
busy = false
if (error == null) {
step = 2
message = null
} else {
message = error
val applyAddress: () -> Unit = {
parseAdbAddress(connectionAddress)?.let { endpoint ->
keyboardController?.hide()
focusManager.clearFocus()
busy = true
editing = false
message = null
onApply(endpoint.host, endpoint.port, desiredEnabled) { error ->
busy = false
if (error == null) {
step = 2
message = null
} else {
message = error
}
}
}
}
@@ -108,20 +133,22 @@ fun WirelessAdbFixPage(
val form: @Composable () -> Unit = {
WirelessStepForm(
step = step,
host = host,
onHostChange = { host = it.trim() },
pairingPort = pairingPort,
onPairingPortChange = { pairingPort = it.filter(Char::isDigit).take(5) },
pairingAddress = pairingAddress,
onPairingAddressChange = { pairingAddress = it },
pairingCode = pairingCode,
onPairingCodeChange = { pairingCode = it.filter(Char::isDigit).take(6) },
connectPort = connectPort,
onConnectPortChange = { connectPort = it.filter(Char::isDigit).take(5) },
connectionAddress = connectionAddress,
onConnectionAddressChange = { connectionAddress = it },
busy = busy,
message = message,
compact = compactSplit,
editing = editing,
onEditingChanged = { editing = it },
focusManager = focusManager,
desiredEnabled = desiredEnabled,
canPair = pairingEndpoint != null && pairingCode.length == 6,
onPair = pairNow,
onApply = applyPort,
onApply = applyAddress,
)
}
SettingsPage(
@@ -134,17 +161,18 @@ fun WirelessAdbFixPage(
eyebrow = "Setup",
lede = when {
compactSplit && step == 0 -> "If the option is missing, pair over Wireless debugging."
compactSplit && step == 1 -> "Enter the Wireless debugging connection port."
compactSplit && step == 1 -> "Enter the Wireless debugging connection address."
compactSplit -> "The child-process limit was updated."
step == 0 -> "Use step 2 if Developer options has no child-process setting. In Android Settings, open Developer options → Wireless debugging → Pair device with pairing code."
step == 1 -> "The pairing port and the Wireless debugging connection port are different."
step == 1 -> "The pairing and Wireless debugging connection addresses are different."
else -> "The child-process limit was updated over Wireless debugging."
},
onBack = { if (!busy) onBack() },
action = if (compactSplit && step < 2) {
{ SecondaryButton("Developer options", enabled = !busy, onClick = onOpenDeveloperOptions) }
{ SecondaryButton("Developer options", compact = true, enabled = !busy, onClick = onOpenDeveloperOptions) }
} else null,
scrollContent = compactSplit,
scrollContent = !compactSplit,
compactLayout = compactSplit,
) {
BoxWithConstraints(Modifier.fillMaxWidth()) {
val wide = maxWidth >= 620.dp
@@ -153,7 +181,7 @@ fun WirelessAdbFixPage(
modifier = Modifier.widthIn(max = 680.dp).fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
SettingsGroup("Child-process limit") {
SettingsGroup("Child-process limit", compact = compactSplit) {
Text(
"Android confirmed the limit is ${if (desiredEnabled) "enabled" else "disabled"}.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -168,7 +196,7 @@ fun WirelessAdbFixPage(
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
form()
CompactComputerFallback(desiredEnabled)
if (!editing) CompactComputerFallback(desiredEnabled)
}
} else {
val routes: @Composable () -> Unit = {
@@ -201,98 +229,91 @@ fun WirelessAdbFixPage(
@Composable
private fun WirelessStepForm(
step: Int,
host: String,
onHostChange: (String) -> Unit,
pairingPort: String,
onPairingPortChange: (String) -> Unit,
pairingAddress: String,
onPairingAddressChange: (String) -> Unit,
pairingCode: String,
onPairingCodeChange: (String) -> Unit,
connectPort: String,
onConnectPortChange: (String) -> Unit,
connectionAddress: String,
onConnectionAddressChange: (String) -> Unit,
busy: Boolean,
message: String?,
compact: Boolean,
editing: Boolean,
onEditingChanged: (Boolean) -> Unit,
focusManager: FocusManager,
desiredEnabled: Boolean,
canPair: Boolean,
onPair: () -> Unit,
onApply: () -> Unit,
) {
if (step == 0) {
SettingsGroup("2 · Pair Wireless debugging") {
SettingsGroup("2 · Pair Wireless debugging", compact = compact) {
Column(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = if (compact) 8.dp else 12.dp, vertical = if (compact) 6.dp else 12.dp),
verticalArrangement = Arrangement.spacedBy(if (compact) 5.dp else 6.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
AdbTextField(
value = host,
onValueChange = onHostChange,
label = "Device IP",
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Next,
onNext = { focusManager.moveFocus(FocusDirection.Next) },
modifier = Modifier.weight(1f),
)
AdbTextField(
value = pairingPort,
onValueChange = onPairingPortChange,
label = "Pairing port",
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Next,
onNext = { focusManager.moveFocus(FocusDirection.Next) },
modifier = Modifier.weight(1f),
)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
AdbTextField(
value = pairingAddress,
onValueChange = onPairingAddressChange,
label = "Pairing address",
placeholder = "192.168.1.42:37123",
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Next,
compact = compact,
onFocusChange = onEditingChanged,
onNext = { focusManager.moveFocus(FocusDirection.Next) },
)
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
AdbTextField(
value = pairingCode,
onValueChange = onPairingCodeChange,
label = "Pairing code",
label = "Pairing code (PIN)",
placeholder = "6 digits",
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
onDone = focusManager::clearFocus,
compact = compact,
onFocusChange = onEditingChanged,
onDone = {
focusManager.clearFocus()
},
modifier = Modifier.weight(1f),
)
PrimaryButton(
"Pair",
enabled = !busy && host.isNotBlank() && validPort(pairingPort) && pairingCode.length == 6,
onClick = onPair,
)
PrimaryButton("Pair", compact = compact, enabled = !busy && canPair, onClick = onPair)
}
StatusMessage(busy, message)
}
}
} else {
SettingsGroup("2 · Apply over Wireless debugging") {
SettingsGroup("2 · Apply over Wireless debugging", compact = compact) {
Column(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().padding(horizontal = if (compact) 8.dp else 12.dp, vertical = if (compact) 6.dp else 12.dp),
verticalArrangement = Arrangement.spacedBy(if (compact) 5.dp else 6.dp),
) {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
AdbTextField(
value = host,
onValueChange = onHostChange,
label = "Device IP",
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Next,
onNext = { focusManager.moveFocus(FocusDirection.Next) },
modifier = Modifier.weight(1f),
)
AdbTextField(
value = connectPort,
onValueChange = onConnectPortChange,
label = "Connection port",
keyboardType = KeyboardType.Number,
imeAction = ImeAction.Done,
onDone = focusManager::clearFocus,
modifier = Modifier.weight(1f),
)
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
AdbTextField(
value = connectionAddress,
onValueChange = onConnectionAddressChange,
label = "Wireless debugging address",
placeholder = "192.168.1.42:45678",
keyboardType = KeyboardType.Ascii,
imeAction = ImeAction.Done,
compact = compact,
onFocusChange = onEditingChanged,
onDone = { focusManager.clearFocus() },
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier.fillMaxWidth(),
) {
StatusMessage(busy, message, Modifier.weight(1f))
PrimaryButton(
if (desiredEnabled) "Enable child-process limit" else "Disable child-process limit",
enabled = !busy && validPort(connectPort),
if (desiredEnabled) "Enable limit" else "Disable limit",
compact = compact,
enabled = !busy && parseAdbAddress(connectionAddress) != null,
onClick = onApply,
)
}
@@ -310,22 +331,99 @@ private fun AdbTextField(
imeAction: ImeAction,
modifier: Modifier = Modifier,
placeholder: String? = null,
compact: Boolean = false,
onFocusChange: (Boolean) -> Unit = {},
onNext: (() -> Unit)? = null,
onDone: (() -> Unit)? = null,
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
placeholder = placeholder?.let { { Text(it) } },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = keyboardType, imeAction = imeAction),
keyboardActions = KeyboardActions(
onNext = { onNext?.invoke() },
onDone = { onDone?.invoke() },
),
modifier = modifier.fillMaxWidth().height(56.dp),
)
val context = LocalContext.current
val palette = LocalPalette.current
val onValueChangeLatest = rememberUpdatedState(onValueChange)
val onNextLatest = rememberUpdatedState(onNext)
val onDoneLatest = rememberUpdatedState(onDone)
val onFocusChangeLatest = rememberUpdatedState(onFocusChange)
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(8.dp)
val inputHeight = if (compact) 44.dp else 52.dp
val colors = MaterialTheme.colorScheme
Column(modifier = modifier.fillMaxWidth()) {
Text(
label,
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Medium,
color = colors.onSurfaceVariant,
maxLines = 1,
)
Box(
modifier = Modifier
.fillMaxWidth()
.height(inputHeight)
.background(colors.surfaceVariant, shape)
.border(1.dp, if (focused) palette.signal else palette.line2, shape)
.padding(horizontal = 10.dp),
contentAlignment = Alignment.CenterStart,
) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { viewContext ->
EditText(viewContext).apply {
isSingleLine = true
setHorizontallyScrolling(true)
background = null
setPadding((2 * context.resources.displayMetrics.density).roundToInt(), 0, 0, 0)
setTextSize(android.util.TypedValue.COMPLEX_UNIT_SP, if (compact) 13f else 14f)
setTextColor(colors.onBackground.toArgb())
setHintTextColor(colors.onSurfaceVariant.toArgb())
contentDescription = label
inputType = if (keyboardType == KeyboardType.Number) {
InputType.TYPE_CLASS_NUMBER
} else {
InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI or InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS
}
imeOptions = imeActionFlag(imeAction) or EditorInfo.IME_FLAG_NO_FULLSCREEN or EditorInfo.IME_FLAG_NO_EXTRACT_UI
hint = placeholder
setOnEditorActionListener { _, actionId, event ->
val isEnter = event?.keyCode == android.view.KeyEvent.KEYCODE_ENTER
when {
imeAction == ImeAction.Next && (actionId == EditorInfo.IME_ACTION_NEXT || isEnter) -> {
onNextLatest.value?.invoke()
true
}
imeAction == ImeAction.Done && (actionId == EditorInfo.IME_ACTION_DONE || isEnter) -> {
onDoneLatest.value?.invoke()
true
}
else -> false
}
}
setOnFocusChangeListener { _, hasFocus ->
focused = hasFocus
onFocusChangeLatest.value(hasFocus)
}
addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
override fun afterTextChanged(s: Editable?) {
onValueChangeLatest.value(s?.toString().orEmpty())
}
})
}
},
update = { editText ->
editText.setTextColor(colors.onBackground.toArgb())
editText.setHintTextColor(colors.onSurfaceVariant.toArgb())
editText.hint = placeholder
editText.contentDescription = label
if (editText.text.toString() != value) {
val cursor = if (editText.hasFocus()) editText.selectionStart.coerceAtLeast(0) else value.length
editText.setText(value)
editText.setSelection(min(cursor, value.length))
}
},
)
}
}
}
@Composable
@@ -355,7 +453,7 @@ private fun FallbackOrder(
@Composable
private fun CompactComputerFallback(desiredEnabled: Boolean) {
SettingsGroup("3 · Computer ADB last resort") {
SettingsGroup("3 · Computer ADB last resort", compact = true) {
Text(
PhantomProcessLimit.adbCommand(desiredEnabled),
style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace),
@@ -377,4 +475,36 @@ private fun StatusMessage(busy: Boolean, message: String?, modifier: Modifier =
}
}
private fun validPort(value: String): Boolean = value.toIntOrNull()?.let { it in 1..65535 } == true
private data class AdbEndpoint(val host: String, val port: Int)
private fun parseAdbAddress(value: String): AdbEndpoint? {
val address = value.trim()
val host: String
val portText: String
if (address.startsWith("[")) {
val closingBracket = address.indexOf(']')
if (closingBracket <= 1 || address.getOrNull(closingBracket + 1) != ':') return null
host = address.substring(1, closingBracket)
if (':' !in host) return null
portText = address.substring(closingBracket + 2)
} else {
val colon = address.lastIndexOf(':')
if (colon <= 0 || address.indexOf(':') != colon) return null
host = address.substring(0, colon)
portText = address.substring(colon + 1)
}
if (host.isBlank() || host.any(Char::isWhitespace)) return null
val port = portText.toIntOrNull()?.takeIf { it in 1..65535 } ?: return null
return AdbEndpoint(host, port)
}
private fun formatAddressHost(host: String): String = if (':' in host) "[$host]" else host
private fun formatAdbAddress(host: String, port: Int): String = "${formatAddressHost(host)}:$port"
private fun imeActionFlag(action: ImeAction): Int = when (action) {
ImeAction.Next -> EditorInfo.IME_ACTION_NEXT
ImeAction.Done -> EditorInfo.IME_ACTION_DONE
ImeAction.Go -> EditorInfo.IME_ACTION_GO
ImeAction.Search -> EditorInfo.IME_ACTION_SEARCH
else -> EditorInfo.IME_ACTION_UNSPECIFIED
}