From 81cf7506d1f39d6d8b1cb1996604a3977617f889 Mon Sep 17 00:00:00 2001 From: nd4y Date: Tue, 22 Sep 2026 17:10:57 +0300 Subject: [PATCH 1/2] =?UTF-8?q?=D0=9F=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E=D1=87?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BA=20=D1=81=D0=BE=D1=85=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D1=91=D0=BD=D0=BD=D0=BE=D0=B9=20=D1=81=D0=B5=D1=82?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=20id,=20=D0=BD=D0=B5=20=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B3=D0=B0=D1=8F=20=D0=BD=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B9?= =?UTF-8?q?=D0=BA=D0=B8=20Android?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd wifi connect-network добавляет сеть заново и переписывает сохранённую запись значениями по умолчанию: MAC-рандомизация, «лимитная сеть», автоподключение, «отправлять имя устройства» сбрасывались при каждом нажатии. Теперь к уже сохранённой сети приложение подключается через IWifiManager.connect(netId) — свой helper (WifiHelper) запускается привилегированным шеллом через app_process, как системный svc. Запись сети не меняется; из профиля применяется только autoJoin (allowAutojoin). Сеть, которой ещё нет, добавляется как раньше. Тумблер «Пересоздавать сохранённую сеть» (overwriteSaved) возвращает старое поведение. Версия 1.20. --- README.md | 42 ++++- README.ru.md | 41 ++++- app/build.gradle.kts | 4 +- .../java/icu/nd4y/netswitcher/data/Model.kt | 22 ++- .../icu/nd4y/netswitcher/data/YamlConfig.kt | 4 + .../nd4y/netswitcher/engine/SavedNetworks.kt | 62 +++++++ .../nd4y/netswitcher/engine/SwitchEngine.kt | 65 +++++-- .../icu/nd4y/netswitcher/engine/WifiHelper.kt | 173 ++++++++++++++++++ .../nd4y/netswitcher/ui/EditProfileDialog.kt | 14 ++ .../nd4y/netswitcher/data/YamlConfigTest.kt | 31 ++++ .../netswitcher/ui/EditProfileDialogTest.kt | 1 + examples/example-config.yaml | 4 + 12 files changed, 425 insertions(+), 38 deletions(-) create mode 100644 app/src/main/java/icu/nd4y/netswitcher/engine/SavedNetworks.kt create mode 100644 app/src/main/java/icu/nd4y/netswitcher/engine/WifiHelper.kt diff --git a/README.md b/README.md index 7f683dd..c4c72ca 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,8 @@ privileges, and NetSwitcher runs exactly the commands you'd type over `adb shell | Action | Command | |---|---| | Turn Wi-Fi on/off | `cmd wifi set-wifi-enabled enabled\|disabled` | -| Connect to a network | `cmd wifi connect-network wpa2 [-h] [-b ] [-d]` | +| Connect to a saved network | `IWifiManager.connect(netId)` — the app's own helper via `app_process`, the saved entry is left untouched (see "Saved networks") | +| Add a network and connect | `cmd wifi connect-network wpa2 [-h] [-b ] [-d]` — only when the network isn't saved yet | | Turn the radio on only | `cmd wifi set-wifi-enabled enabled` — the network is picked by auto-connect | | Airplane mode | `cmd connectivity airplane-mode enable\|disable` → `settings put global airplane_mode_on` + broadcast | | Mobile data | `svc data enable\|disable` | @@ -98,9 +99,39 @@ plus a rewritable tag. ## Wi-Fi passwords -`cmd wifi connect-network` can't select an already-saved network — it adds the network -anew every time, so for WPA2/WPA3 the profile needs the password on file. Passwords -live in the app's private storage (DataStore) and are never sent anywhere. +The profile's password is needed for the first connection — while the network isn't +among Android's saved networks yet — and in the "Re-add the saved network" mode: in both +cases the network is added with `cmd wifi connect-network`, which doesn't work without a +password. An already-saved network is joined by its id (see below), and the profile's +password isn't required for that. Passwords live in the app's private storage (DataStore) +and are never sent anywhere. + +## Saved networks and Android's settings + +`cmd wifi connect-network` doesn't "pick" a network, it adds it anew — and in doing so +rewrites the entry Android has saved with fresh defaults: MAC randomization, "metered", +auto-connect, "send device name" — everything set in Android's Wi-Fi settings got reset +on every button press. Proxy and static IP only survived because the merge in +`WifiConfigManager` happens to leave them alone. + +So an already-saved network is joined differently: the app's own helper finds it among +the saved networks (by SSID and security type) and calls `IWifiManager.connect(netId)` — +exactly what a tap on the network in Android's settings does. The saved entry doesn't +change: password, proxy, IP, MAC, metered override and hidden flag stay as they are in +Android. The one thing applied from the profile is "Auto-connect" (via `allowAutojoin`), +because the profile has an explicit switch for it. The verbose log shows what Android +has stored for the network. + +If the network isn't saved, it's added from the profile with `connect-network` — that +needs the password. The "Re-add the saved network" switch (`overwriteSaved: true` in +YAML) brings back the old behaviour: the network is re-created from the profile on every +connect, and its Android settings are replaced. If the helper fails to connect (say, the +password stored in Android has changed), NetSwitcher reports that and overwrites +nothing — that's what the switch is for. + +The helper is a class inside the app's own APK, launched by the privileged shell through +`app_process` (the same way the system's `svc` tool works). It works with both Shizuku +and root; `cmd wifi` has no connect-by-id. ## Buttons @@ -223,7 +254,8 @@ with the "Tapping again disconnects" flag on the profile. Every Wi-Fi network also has an "Auto-connect" flag (`autoJoin` in YAML). Turn it off and connecting through NetSwitcher saves the network in Android with auto-join disabled -(`connect-network … -d`): the phone no longer hops back onto it by itself when it comes +(`-d` when the network is added, `allowAutojoin` for an already-saved one): the phone no +longer hops back onto it by itself when it comes into range or when the radio turns on — only when you press the button. That's for guest and IoT networks you visit on purpose rather than live on; the stock `Guest` and `IoT` profiles ship with it off. The flag is written into the system's saved network at diff --git a/README.ru.md b/README.ru.md index ebaeb2b..aafd62f 100644 --- a/README.ru.md +++ b/README.ru.md @@ -47,7 +47,8 @@ | Действие | Команда | |---|---| | Включить/выключить Wi-Fi | `cmd wifi set-wifi-enabled enabled\|disabled` | -| Подключиться к сети | `cmd wifi connect-network wpa2 [-h] [-b ] [-d]` | +| Подключиться к сохранённой сети | `IWifiManager.connect(netId)` — свой helper через `app_process`, запись сети не пересоздаётся (см. «Сохранённые сети») | +| Добавить сеть и подключиться | `cmd wifi connect-network wpa2 [-h] [-b ] [-d]` — только если сети ещё нет среди сохранённых | | Только включить радио | `cmd wifi set-wifi-enabled enabled` — сеть выбирает автоподключение | | Авиарежим | `cmd connectivity airplane-mode enable\|disable` → `settings put global airplane_mode_on` + broadcast | | Мобильные данные | `svc data enable\|disable` | @@ -95,9 +96,38 @@ QR строится встроенно (ZXing core), картинка отдаё ## Пароли Wi-Fi -`cmd wifi connect-network` не умеет выбирать уже сохранённую сеть — он добавляет сеть -заново, поэтому для WPA2/WPA3 в профиле нужно указать пароль. Пароли лежат в приватном -хранилище приложения (DataStore), наружу не отправляются. +Пароль в профиле нужен для первого подключения — пока сети ещё нет среди сохранённых +в Android — и в режиме «Пересоздавать сохранённую сеть»: в обоих случаях сеть +добавляется командой `cmd wifi connect-network`, а она без пароля не работает. К уже +сохранённой сети NetSwitcher подключается по её id (см. ниже), и пароль в профиле не +требуется. Пароли лежат в приватном хранилище приложения (DataStore), наружу не +отправляются. + +## Сохранённые сети и настройки Android + +`cmd wifi connect-network` не «выбирает» сеть, а добавляет её заново, и при этом +переписывает сохранённую в Android запись значениями по умолчанию: рандомизация MAC, +«лимитная сеть», автоподключение, «отправлять имя устройства» — всё, что задано в +настройках Wi-Fi Android, сбрасывалось при каждом нажатии кнопки. Прокси и статический IP +выживали только потому, что merge в `WifiConfigManager` их не трогает. + +Поэтому к уже сохранённой сети NetSwitcher подключается иначе: свой helper находит её +среди сохранённых (по SSID и типу защиты) и вызывает `IWifiManager.connect(netId)` — +ровно то, что делает тап по сети в настройках Android. Запись сети при этом не меняется: +пароль, прокси, IP, MAC, лимит трафика и скрытость остаются как в Android. Единственное, +что применяется из профиля, — «Автоподключение к сети» (через `allowAutojoin`): для него в +профиле есть явный тумблер. В подробном логе видно, что Android хранит для сети. + +Если сети среди сохранённых нет, она добавляется из профиля командой `connect-network` — +для этого нужен пароль. Тумблер «Пересоздавать сохранённую сеть» (`overwriteSaved: true` в +YAML) возвращает старое поведение: сеть каждый раз пересоздаётся из профиля, а её +настройки в Android заменяются. Если helper не смог подключиться (например, в Android у +сети уже другой пароль), NetSwitcher сообщает об этом и ничего не перезаписывает — это и +есть случай для тумблера. + +Helper — класс из APK самого приложения, который привилегированный шелл запускает через +`app_process` (так же устроен системный `svc`). Работает и с Shizuku, и с root; в +`cmd wifi` подключения по id нет. ## Кнопки @@ -214,7 +244,8 @@ Wi-Fi-профили конкретных сетей тоже работают У каждой Wi-Fi-сети есть и флажок «Автоподключение к сети» (в YAML — `autoJoin`). Если его выключить, при подключении через NetSwitcher сеть сохраняется в Android с -отключённым автоподключением (`connect-network … -d`): телефон больше не будет сам +отключённым автоподключением (`-d` при добавлении сети, `allowAutojoin` — для уже +сохранённой): телефон больше не будет сам возвращаться в неё при появлении в зоне действия или после включения радио — только по кнопке. Это для гостевых и IoT-сетей, в которых бывают по делу, а не живут; у профилей `Guest` и `IoT` из коробки он выключен. Флаг записывается в системную запись сети в diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aff08ea..f155fc3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "icu.nd4y.netswitcher" minSdk = 31 targetSdk = 36 - versionCode = 20 - versionName = "1.19" + versionCode = 21 + versionName = "1.20" } // Release signing comes from the environment so the key never lives in the repo. diff --git a/app/src/main/java/icu/nd4y/netswitcher/data/Model.kt b/app/src/main/java/icu/nd4y/netswitcher/data/Model.kt index c8f558b..4694c2a 100644 --- a/app/src/main/java/icu/nd4y/netswitcher/data/Model.kt +++ b/app/src/main/java/icu/nd4y/netswitcher/data/Model.kt @@ -47,12 +47,15 @@ enum class ProfileKind { get() = isToggle || this == WIFI } -/** Security token accepted by `cmd wifi connect-network`. */ -enum class WifiSecurity(val token: String, val needsPassword: Boolean) { - WPA2("wpa2", true), - WPA3("wpa3", true), - OPEN("open", false), - OWE("owe", false), +/** + * Security token accepted by `cmd wifi connect-network`, plus the matching + * `WifiConfiguration.SECURITY_TYPE_*` constant the helper uses to find the saved entry. + */ +enum class WifiSecurity(val token: String, val needsPassword: Boolean, val frameworkType: Int) { + WPA2("wpa2", true, 2), + WPA3("wpa3", true, 4), + OPEN("open", false, 0), + OWE("owe", false, 6), } enum class MobileDataAction { KEEP, ENABLE, DISABLE } @@ -86,6 +89,13 @@ data class Profile( * only lands on it when the button is pressed — the guest / IoT case. */ val autoJoin: Boolean = true, + /** + * WIFI: re-add the network from this profile on every connect (the pre-1.20 + * behaviour). Off — the default — joins the entry Android already has by its id, + * leaving proxy, static IP, MAC randomization, metered override and password as + * set in Android's Wi-Fi settings; only [autoJoin] is applied from the profile. + */ + val overwriteSaved: Boolean = false, // --- Cellular --- /** Subscription id of the SIM to make the default data SIM; -1 = leave as is. */ val subscriptionId: Int = -1, diff --git a/app/src/main/java/icu/nd4y/netswitcher/data/YamlConfig.kt b/app/src/main/java/icu/nd4y/netswitcher/data/YamlConfig.kt index 399ad9d..28e3106 100644 --- a/app/src/main/java/icu/nd4y/netswitcher/data/YamlConfig.kt +++ b/app/src/main/java/icu/nd4y/netswitcher/data/YamlConfig.kt @@ -38,6 +38,9 @@ object YamlConfig { } appendLine(" tapAgainDisconnects: ${profile.tapAgainDisconnects}") appendLine(" autoJoin: ${profile.autoJoin} # false = только по кнопке") + if (profile.overwriteSaved) { + appendLine(" overwriteSaved: true # пересоздавать сеть из профиля, затирая настройки Android") + } } ProfileKind.CELLULAR, ProfileKind.CELLULAR_TOGGLE -> { @@ -100,6 +103,7 @@ object YamlConfig { disableWifi = map.bool("disableWifi") ?: true, tapAgainDisconnects = map.bool("tapAgainDisconnects") ?: true, autoJoin = map.bool("autoJoin") ?: true, + overwriteSaved = map.bool("overwriteSaved") ?: false, ) } if (profiles.isEmpty()) error("В файле нет ни одного профиля") diff --git a/app/src/main/java/icu/nd4y/netswitcher/engine/SavedNetworks.kt b/app/src/main/java/icu/nd4y/netswitcher/engine/SavedNetworks.kt new file mode 100644 index 0000000..5273fe7 --- /dev/null +++ b/app/src/main/java/icu/nd4y/netswitcher/engine/SavedNetworks.kt @@ -0,0 +1,62 @@ +package icu.nd4y.netswitcher.engine + +import android.content.Context +import icu.nd4y.netswitcher.data.Profile + +/** Outcome of asking [WifiHelper] to join a network Android already has saved. */ +sealed class SavedConnect { + /** The connect request went out; the saved entry was left untouched. */ + data class Connected(val netId: Int) : SavedConnect() + + /** Nothing saved under that SSID / security — the caller has to add it. */ + object NotSaved : SavedConnect() + + /** The helper could not run or the service refused — no fallback, see [reason]. */ + data class Failed(val reason: String) : SavedConnect() +} + +/** + * Runs [WifiHelper] through the privileged shell. The helper lives in this very APK; + * `app_process` loads it from there, which is why the command carries the APK path. + */ +object SavedNetworks { + + suspend fun connect( + context: Context, + shell: PrivilegedShell, + profile: Profile, + log: MutableList, + ): SavedConnect { + val apk = context.applicationContext.applicationInfo.sourceDir + val command = buildString { + append("CLASSPATH=").append(shQuote(apk)) + append(" /system/bin/app_process /system/bin ") + append(WifiHelper::class.java.name) + append(" connect ").append(shQuote(profile.ssid)) + append(' ').append(profile.security.frameworkType) + append(' ').append(if (profile.autoJoin) 1 else 0) + } + val result = shell.exec(command) + + val lines = result.stdout.lineSequence().map { it.trim() }.filter { it.isNotEmpty() }.toList() + val verdict = lines.firstOrNull().orEmpty() + log += "$ helper connect ${profile.ssid} -> ${result.exitCode}" + + if (verdict.isEmpty()) "" else " | $verdict" + lines.drop(1).forEach { log += it } + if (result.stderr.isNotBlank()) log += result.stderr.trim().take(300) + + return when { + result.exitCode == 0 && verdict.startsWith("ok") -> { + val netId = verdict.substringAfter("id=", "").trim().toIntOrNull() ?: -1 + log += "Подключение к сохранённой сети #$netId — её настройки в Android не тронуты" + SavedConnect.Connected(netId) + } + + result.exitCode == 2 || verdict == "notfound" -> SavedConnect.NotSaved + + else -> SavedConnect.Failed( + result.stderr.trim().ifBlank { result.stdout.trim() }.ifBlank { "код ${result.exitCode}" }.take(200) + ) + } + } +} diff --git a/app/src/main/java/icu/nd4y/netswitcher/engine/SwitchEngine.kt b/app/src/main/java/icu/nd4y/netswitcher/engine/SwitchEngine.kt index 75e69b4..976a4c2 100644 --- a/app/src/main/java/icu/nd4y/netswitcher/engine/SwitchEngine.kt +++ b/app/src/main/java/icu/nd4y/netswitcher/engine/SwitchEngine.kt @@ -207,28 +207,55 @@ class SwitchEngine(context: Context) { } } - if (profile.security.needsPassword && profile.password.isBlank()) { - return ActionResult( - false, - "Для «${profile.ssid}» нужен пароль — задайте его в профиле", - log, - ) - } - exec(shell, "cmd wifi set-wifi-enabled enabled", log) delay(700) - val command = buildConnectCommand(profile, redactPassword = false) - // The verbose log is shown on screen — the real command carries the Wi-Fi - // password, so the logged copy masks it. - val result = exec(shell, command, log, logAs = buildConnectCommand(profile, redactPassword = true)) + // A network Android already knows is joined by id, so whatever the user set for + // it in Android's Wi-Fi settings (proxy, static IP, MAC, metered…) stays put. + // `connect-network` re-adds the entry and resets those, so it is reserved for + // networks not saved yet — and for profiles that ask for the old behaviour. + val joinedSaved = if (profile.overwriteSaved) { + log += "Профиль пересоздаёт сеть: настройки «${profile.ssid}» из Android будут заменены" + false + } else { + when (val saved = SavedNetworks.connect(appContext, shell, profile, log)) { + is SavedConnect.Connected -> true + is SavedConnect.NotSaved -> { + log += "«${profile.ssid}» ещё не сохранена в Android — добавляю из профиля" + false + } - applyMobileData(shell, profile.mobileData, log) + is SavedConnect.Failed -> return ActionResult( + false, + "Не удалось подключиться к сохранённой сети «${profile.ssid}»: " + + "${saved.reason}. Настройки сети в Android не тронуты. Чтобы " + + "пересоздать её из профиля, включите «Пересоздавать сохранённую сеть».", + log, + ) + } + } - if (!result.ok || result.output.contains("Error", ignoreCase = true)) { - return ActionResult(false, "Не удалось подключиться: ${result.output.take(180)}", log) + if (!joinedSaved) { + if (profile.security.needsPassword && profile.password.isBlank()) { + return ActionResult( + false, + "Для «${profile.ssid}» нужен пароль: сети ещё нет среди сохранённых " + + "в Android, а добавить её можно только с паролем", + log, + ) + } + val command = buildConnectCommand(profile, redactPassword = false) + // The verbose log is shown on screen — the real command carries the Wi-Fi + // password, so the logged copy masks it. + val result = exec(shell, command, log, logAs = buildConnectCommand(profile, redactPassword = true)) + if (!result.ok || result.output.contains("Error", ignoreCase = true)) { + applyMobileData(shell, profile.mobileData, log) + return ActionResult(false, "Не удалось подключиться: ${result.output.take(180)}", log) + } } + applyMobileData(shell, profile.mobileData, log) + val ssid = awaitSsid(shell, profile.ssid, log) return if (ssid != null) { ActionResult(true, "Подключено: $ssid", log) @@ -384,11 +411,9 @@ class SwitchEngine(context: Context) { } if (profile.hiddenSsid) append(" -h") if (profile.bssid.isNotBlank()) append(" -b ${profile.bssid}") - // `-d` lands in the saved network as allowAutojoin=false, and the framework - // copies that flag over on every re-add — so the button both connects now - // and keeps the phone from rejoining this SSID on its own later. Without - // `-d` a fresh WifiConfiguration carries allowAutojoin=true, which is how - // flipping the switch back on re-enables auto-join in the system entry. + // `-d` lands in the saved network as allowAutojoin=false. This command only + // runs when the network is added (or a profile insists on re-adding it); + // for an already-saved network the helper sets the flag via allowAutojoin. if (!profile.autoJoin) append(" -d") } diff --git a/app/src/main/java/icu/nd4y/netswitcher/engine/WifiHelper.kt b/app/src/main/java/icu/nd4y/netswitcher/engine/WifiHelper.kt new file mode 100644 index 0000000..98d885d --- /dev/null +++ b/app/src/main/java/icu/nd4y/netswitcher/engine/WifiHelper.kt @@ -0,0 +1,173 @@ +package icu.nd4y.netswitcher.engine + +import android.net.wifi.WifiConfiguration +import android.os.Bundle +import android.os.IBinder +import kotlin.system.exitProcess + +/** + * Privileged helper: the one thing `cmd wifi` cannot do is join an already-saved + * network *without* rewriting its configuration — `connect-network` always re-adds + * the network and the framework then copies the fresh defaults (MAC randomization, + * metered override, auto-join, DHCP hostname…) over whatever the user set in Android's + * Wi-Fi settings. `IWifiManager.connect(null, netId)` — what a tap in Settings does — + * leaves the saved entry alone, so that is what this class calls. + * + * It runs as the shell (or root) user inside `app_process`, launched by the privileged + * shell with the app's own APK on the classpath — the same way the system's `svc` tool + * works. That gives it the shell's NETWORK_SETTINGS privilege and a runtime without + * hidden-API enforcement, so `IWifiManager` is reached by plain reflection; the AIDL + * signatures drift between releases, which is why parameters are filled by type. + * + * Usage (see [SavedNetworks]): + * CLASSPATH= app_process /system/bin icu.nd4y.netswitcher.engine.WifiHelper \ + * connect + * + * Exit codes: 0 — connect requested (stdout: `ok id=` + a summary line), + * 2 — no such saved network, 1 — error (stderr has the reason). + */ +object WifiHelper { + + private const val SHELL_PACKAGE = "com.android.shell" + + private val INT = Int::class.javaPrimitiveType!! + private val BOOL = Boolean::class.javaPrimitiveType!! + + @JvmStatic + fun main(args: Array) { + val code = runCatching { run(args) }.getOrElse { error -> + System.err.println("error: ${error.cause ?: error}") + 1 + } + System.out.flush() + System.err.flush() + exitProcess(code) + } + + private fun run(args: Array): Int { + if (args.getOrNull(0) != "connect") return usage() + val ssid = args.getOrNull(1) ?: return usage() + val securityType = args.getOrNull(2)?.toIntOrNull() ?: return usage() + val autoJoin = when (args.getOrNull(3)) { + "1", "true" -> true + "0", "false" -> false + else -> null + } + + val service = wifiService() + val saved = findSaved(service, ssid, securityType) + if (saved == null) { + println("notfound") + return 2 + } + + // Null listener is what WifiManager.connect(netId, null) sends too. + invoke( + service, "connect", + mapOf, Any?>(WifiConfiguration::class.java to null, INT to saved.networkId), + ) + if (autoJoin != null) { + invoke(service, "allowAutojoin", mapOf, Any?>(INT to saved.networkId, BOOL to autoJoin)) + } + println("ok id=${saved.networkId}") + describe(saved)?.let { println(it) } + return 0 + } + + private fun usage(): Int { + System.err.println("usage: connect ") + return 1 + } + + private fun wifiService(): Any { + val binder = Class.forName("android.os.ServiceManager") + .getMethod("getService", String::class.java) + .invoke(null, "wifi") as? IBinder + ?: error("сервис wifi не найден") + return Class.forName("android.net.wifi.IWifiManager\$Stub") + .getMethod("asInterface", IBinder::class.java) + .invoke(null, binder) + ?: error("IWifiManager.asInterface вернул null") + } + + /** + * Calls [name] on the service proxy, supplying [values] by parameter type and + * filling the rest with neutral defaults: the first String is the package name, + * a Bundle is empty, listeners are null. Android 12 → 16 all fit this shape. + */ + private fun invoke(service: Any, name: String, values: Map, Any?>): Any? { + val method = service.javaClass.methods + .filter { it.name == name } + .maxByOrNull { it.parameterTypes.size } + ?: error("IWifiManager.$name не найден") + var strings = 0 + val args = method.parameterTypes.map { type -> + when { + values.containsKey(type) -> values[type] + type == String::class.java -> if (strings++ == 0) SHELL_PACKAGE else null + type == Bundle::class.java -> Bundle() + type == INT -> 0 + type == BOOL -> false + else -> null + } + } + return method.invoke(service, *args.toTypedArray()) + } + + private fun findSaved(service: Any, ssid: String, securityType: Int): WifiConfiguration? { + val slice = invoke(service, "getConfiguredNetworks", mapOf, Any?>(BOOL to false)) + ?: return null + val list = slice.javaClass.getMethod("getList").invoke(slice) as? List<*> ?: return null + return list.filterIsInstance() + .filter { plainSsid(it.SSID) == ssid } + .firstOrNull { hasSecurity(it, securityType) } + } + + /** The framework stores UTF-8 SSIDs in quotes; non-UTF-8 ones as bare hex. */ + private fun plainSsid(raw: String?): String? = + raw?.let { if (it.length >= 2 && it.startsWith('"') && it.endsWith('"')) it.substring(1, it.length - 1) else it } + + /** + * `isSecurityType` sees the whole security-params list, so a WPA2 network that the + * framework auto-upgraded to WPA3 matches either profile flavour. The legacy + * key-management bitset is the fallback for builds without it. + */ + @Suppress("DEPRECATION") + private fun hasSecurity(config: WifiConfiguration, securityType: Int): Boolean = runCatching { + config.javaClass.getMethod("isSecurityType", INT).invoke(config, securityType) as Boolean + }.getOrElse { + val bit = when (securityType) { + WifiConfiguration.SECURITY_TYPE_OPEN -> WifiConfiguration.KeyMgmt.NONE + WifiConfiguration.SECURITY_TYPE_PSK -> WifiConfiguration.KeyMgmt.WPA_PSK + WifiConfiguration.SECURITY_TYPE_SAE -> WifiConfiguration.KeyMgmt.SAE + WifiConfiguration.SECURITY_TYPE_OWE -> WifiConfiguration.KeyMgmt.OWE + else -> -1 + } + bit >= 0 && config.allowedKeyManagement.get(bit) + } + + /** What Android has stored for the network — goes to the on-screen log, best effort. */ + private fun describe(config: WifiConfiguration): String? = runCatching { + val cls = config.javaClass + fun field(name: String): Any? = runCatching { cls.getField(name).get(config) }.getOrNull() + fun call(name: String): Any? = runCatching { cls.getMethod(name).invoke(config) }.getOrNull() + + val mac = when (field("macRandomizationSetting")) { + WifiConfiguration.RANDOMIZATION_NONE -> "устройства" + WifiConfiguration.RANDOMIZATION_PERSISTENT -> "случайный" + WifiConfiguration.RANDOMIZATION_NON_PERSISTENT -> "случайный, непостоянный" + WifiConfiguration.RANDOMIZATION_AUTO -> "авто" + else -> "?" + } + val metered = when (field("meteredOverride")) { + 0 -> "авто" + 1 -> "лимитная" + 2 -> "безлимитная" + else -> "?" + } + val ip = call("getIpAssignment")?.toString()?.lowercase() ?: "?" + val proxy = call("getProxySettings")?.toString()?.lowercase() ?: "?" + "В Android: MAC $mac · трафик $metered · IP $ip · прокси $proxy · " + + "автоподключение ${field("allowAutojoin")} · скрытая ${config.hiddenSSID}" + }.getOrNull() +} diff --git a/app/src/main/java/icu/nd4y/netswitcher/ui/EditProfileDialog.kt b/app/src/main/java/icu/nd4y/netswitcher/ui/EditProfileDialog.kt index 52a221e..658f94e 100644 --- a/app/src/main/java/icu/nd4y/netswitcher/ui/EditProfileDialog.kt +++ b/app/src/main/java/icu/nd4y/netswitcher/ui/EditProfileDialog.kt @@ -257,6 +257,20 @@ private fun WifiFields(draft: Profile, onChange: (Profile) -> Unit) { "запись сети при подключении через NetSwitcher.", style = MaterialTheme.typography.bodySmall, ) + Spacer(Modifier.height(6.dp)) + ToggleRow( + label = "Пересоздавать сохранённую сеть", + checked = draft.overwriteSaved, + onChange = { onChange(draft.copy(overwriteSaved = it)) }, + ) + Text( + text = "Выкл. — к сети, уже сохранённой в Android, NetSwitcher подключается по её " + + "id и ничего в ней не меняет: прокси, статический IP, MAC, лимит трафика и " + + "пароль остаются как в настройках Android, из профиля применяется только " + + "автоподключение. Вкл. — сеть каждый раз добавляется заново из профиля, " + + "и эти настройки затираются.", + style = MaterialTheme.typography.bodySmall, + ) } } diff --git a/app/src/test/java/icu/nd4y/netswitcher/data/YamlConfigTest.kt b/app/src/test/java/icu/nd4y/netswitcher/data/YamlConfigTest.kt index 1b4e730..f1c9834 100644 --- a/app/src/test/java/icu/nd4y/netswitcher/data/YamlConfigTest.kt +++ b/app/src/test/java/icu/nd4y/netswitcher/data/YamlConfigTest.kt @@ -242,6 +242,37 @@ class YamlConfigTest { assertEquals(config.profiles, again.profiles) } + /** + * `overwriteSaved` is the escape hatch back to re-adding the network on every + * connect. It must default to off (Android's own Wi-Fi settings stay untouched) + * and an explicit `true` must survive the round trip. + */ + @Test + fun `overwriteSaved defaults to off and an explicit on survives a round trip`() { + val yaml = """ + profiles: + - id: "home" + name: "Home" + kind: WIFI + ssid: "Home" + - id: "lab" + name: "Lab" + kind: WIFI + ssid: "Lab" + overwriteSaved: true + """.trimIndent() + + val config = YamlConfig.decode(yaml).getOrThrow() + assertFalse(config.profile("home")!!.overwriteSaved) + assertTrue(config.profile("lab")!!.overwriteSaved) + + val text = YamlConfig.encode(config) + // Emitted only where it is on — the default case should not clutter the file. + assertEquals(1, Regex("overwriteSaved: true").findAll(text).count()) + val again = YamlConfig.decode(text).getOrThrow() + assertEquals(config.profiles, again.profiles) + } + @Test fun `garbage input fails instead of wiping the configuration`() { assertTrue(YamlConfig.decode("не yaml вовсе: [[[").isFailure) diff --git a/app/src/test/java/icu/nd4y/netswitcher/ui/EditProfileDialogTest.kt b/app/src/test/java/icu/nd4y/netswitcher/ui/EditProfileDialogTest.kt index c9b90d2..14ed16a 100644 --- a/app/src/test/java/icu/nd4y/netswitcher/ui/EditProfileDialogTest.kt +++ b/app/src/test/java/icu/nd4y/netswitcher/ui/EditProfileDialogTest.kt @@ -37,6 +37,7 @@ class EditProfileDialogTest { compose.onNodeWithText("SSID").assertExists() compose.onNodeWithText("Пароль").assertExists() compose.onNodeWithText("Автоподключение к сети").assertExists() + compose.onNodeWithText("Пересоздавать сохранённую сеть").assertExists() compose.onNodeWithText("Сохранить").assertExists() } diff --git a/examples/example-config.yaml b/examples/example-config.yaml index c9b6aa1..8e65301 100644 --- a/examples/example-config.yaml +++ b/examples/example-config.yaml @@ -18,6 +18,10 @@ # autoJoin: false у Wi-Fi-профиля — при подключении через NetSwitcher сеть сохраняется # в Android без автоподключения: телефон не вернётся в неё сам, только по кнопке. # Для гостевых и IoT-сетей. Отсутствующий ключ = true. +# overwriteSaved: true у Wi-Fi-профиля — пересоздавать сеть из профиля при каждом +# подключении, затирая её настройки в Android (прокси, IP, MAC, лимит трафика). +# По умолчанию false: к уже сохранённой сети NetSwitcher подключается по id и +# ничего в ней не меняет, пароль в профиле тогда не обязателен. version: 1 backend: AUTO # AUTO | SHIZUKU | ROOT | NONE startNotification: SHADE # SHADE | HEADS_UP From 4cb8ba137abe202a63394d3af05cff3fe23ffec7 Mon Sep 17 00:00:00 2001 From: nd4y Date: Wed, 23 Sep 2026 08:00:24 +0300 Subject: [PATCH 2/2] =?UTF-8?q?=D0=90=D0=B2=D1=82=D0=BE=D0=BF=D0=BE=D0=B4?= =?UTF-8?q?=D0=BA=D0=BB=D1=8E=D1=87=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=B2=20?= =?UTF-8?q?=D1=82=D1=80=D0=B8=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B8=20=D1=81=D0=BA=D1=80=D1=8B=D1=82=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=BF=D0=B0=D1=80=D0=BE=D0=BB=D1=8C=20=D0=B2=20=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=BE=D1=80=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Булев autoJoin со значением «вкл.» по умолчанию заставлял helper при каждом подключении делать allowAutojoin(true) — то есть молча затирал выключенное в Android автоподключение, ровно то, от чего это изменение защищает. Теперь три значения: SYSTEM (как в Android, по умолчанию), ON, OFF. Старые true/false читаются как ON/OFF и в YAML, и в сохранённом JSON — иначе смена типа сбросила бы конфиг 1.19 к значениям по умолчанию. Пароль Wi-Fi в редакторе профиля скрыт точками, кнопка «Показать». --- README.md | 31 ++++---- README.ru.md | 29 ++++---- .../java/icu/nd4y/netswitcher/data/Model.kt | 70 ++++++++++++++++--- .../icu/nd4y/netswitcher/data/YamlConfig.kt | 15 +++- .../nd4y/netswitcher/engine/SavedNetworks.kt | 16 ++++- .../nd4y/netswitcher/engine/SwitchEngine.kt | 12 ++-- .../icu/nd4y/netswitcher/engine/WifiHelper.kt | 4 +- .../nd4y/netswitcher/ui/EditProfileDialog.kt | 44 +++++++++--- .../nd4y/netswitcher/data/ProfileJsonTest.kt | 44 ++++++++++++ .../nd4y/netswitcher/data/YamlConfigTest.kt | 25 +++++-- .../netswitcher/ui/EditProfileDialogTest.kt | 5 +- examples/example-config.yaml | 15 ++-- 12 files changed, 238 insertions(+), 72 deletions(-) create mode 100644 app/src/test/java/icu/nd4y/netswitcher/data/ProfileJsonTest.kt diff --git a/README.md b/README.md index c4c72ca..c8b4345 100644 --- a/README.md +++ b/README.md @@ -118,9 +118,9 @@ So an already-saved network is joined differently: the app's own helper finds it the saved networks (by SSID and security type) and calls `IWifiManager.connect(netId)` — exactly what a tap on the network in Android's settings does. The saved entry doesn't change: password, proxy, IP, MAC, metered override and hidden flag stay as they are in -Android. The one thing applied from the profile is "Auto-connect" (via `allowAutojoin`), -because the profile has an explicit switch for it. The verbose log shows what Android -has stored for the network. +Android. The one thing that may be applied from the profile is "Auto-connect" (via +`allowAutojoin`), and only when the profile says "On" or "Off"; "As in Android" touches +nothing. The verbose log shows what Android has stored for the network. If the network isn't saved, it's added from the profile with `connect-network` — that needs the password. The "Re-add the saved network" switch (`overwriteSaved: true` in @@ -130,8 +130,8 @@ password stored in Android has changed), NetSwitcher reports that and overwrites nothing — that's what the switch is for. The helper is a class inside the app's own APK, launched by the privileged shell through -`app_process` (the same way the system's `svc` tool works). It works with both Shizuku -and root; `cmd wifi` has no connect-by-id. +`app_process` (the same way the system's `svc` tool works). It is built for both +backends, Shizuku and root; `cmd wifi` has no connect-by-id. ## Buttons @@ -252,16 +252,17 @@ already connected to disconnects from it. There's no dedicated "disconnect" shel command, so this is done by turning the radio off — this behavior can be turned off with the "Tapping again disconnects" flag on the profile. -Every Wi-Fi network also has an "Auto-connect" flag (`autoJoin` in YAML). Turn it off -and connecting through NetSwitcher saves the network in Android with auto-join disabled -(`-d` when the network is added, `allowAutojoin` for an already-saved one): the phone no -longer hops back onto it by itself when it comes -into range or when the radio turns on — only when you press the button. That's for -guest and IoT networks you visit on purpose rather than live on; the stock `Guest` and -`IoT` profiles ship with it off. The flag is written into the system's saved network at -the moment the app connects, so a network already saved in Android with auto-join on -gets it turned off after the first connection via the button. The reverse holds too: -connecting through a profile with auto-connect on turns it back on in the system entry. +Every Wi-Fi network also has an "Auto-connect" setting (`autoJoin` in YAML) with three +values. "As in Android" (`SYSTEM`, the default) — NetSwitcher leaves the flag alone. +"Off" (`OFF`) — connecting through NetSwitcher saves the network in Android with +auto-join disabled (`-d` when the network is added, `allowAutojoin` for an already-saved +one): the phone no longer hops back onto it by itself when it comes into range or when +the radio turns on — only when you press the button. That's for guest and IoT networks +you visit on purpose rather than live on; the stock `Guest` and `IoT` profiles ship with +`OFF`. "On" (`ON`) does the opposite and re-enables auto-join in the system entry on +every connect. The value is applied at the moment the app connects, so for a network +already saved in Android it changes after the first button press. Version 1.19 +configuration files with `autoJoin: true` / `false` are read as `ON` / `OFF`. ## Profiles out of the box diff --git a/README.ru.md b/README.ru.md index aafd62f..13921ce 100644 --- a/README.ru.md +++ b/README.ru.md @@ -115,8 +115,9 @@ QR строится встроенно (ZXing core), картинка отдаё среди сохранённых (по SSID и типу защиты) и вызывает `IWifiManager.connect(netId)` — ровно то, что делает тап по сети в настройках Android. Запись сети при этом не меняется: пароль, прокси, IP, MAC, лимит трафика и скрытость остаются как в Android. Единственное, -что применяется из профиля, — «Автоподключение к сети» (через `allowAutojoin`): для него в -профиле есть явный тумблер. В подробном логе видно, что Android хранит для сети. +что может примениться из профиля, — «Автоподключение» (через `allowAutojoin`), и только +если в профиле выбрано «Включено» или «Выключено»; «Как в Android» ничего не трогает. В +подробном логе видно, что Android хранит для сети. Если сети среди сохранённых нет, она добавляется из профиля командой `connect-network` — для этого нужен пароль. Тумблер «Пересоздавать сохранённую сеть» (`overwriteSaved: true` в @@ -126,8 +127,8 @@ YAML) возвращает старое поведение: сеть кажды есть случай для тумблера. Helper — класс из APK самого приложения, который привилегированный шелл запускает через -`app_process` (так же устроен системный `svc`). Работает и с Shizuku, и с root; в -`cmd wifi` подключения по id нет. +`app_process` (так же устроен системный `svc`). Рассчитан на оба бэкенда — Shizuku и +root; в `cmd wifi` подключения по id нет. ## Кнопки @@ -242,17 +243,17 @@ Wi-Fi-профили конкретных сетей тоже работают существует, поэтому это делается выключением радио — поведение отключается флажком «Повторное нажатие отключает от сети» в профиле. -У каждой Wi-Fi-сети есть и флажок «Автоподключение к сети» (в YAML — `autoJoin`). Если -его выключить, при подключении через NetSwitcher сеть сохраняется в Android с +У каждой Wi-Fi-сети есть и настройка «Автоподключение» (в YAML — `autoJoin`) с тремя +значениями. «Как в Android» (`SYSTEM`, по умолчанию) — NetSwitcher этот флаг не трогает. +«Выключено» (`OFF`) — при подключении через NetSwitcher сеть сохраняется в Android с отключённым автоподключением (`-d` при добавлении сети, `allowAutojoin` — для уже -сохранённой): телефон больше не будет сам -возвращаться в неё при появлении в зоне действия или после включения радио — только по -кнопке. Это для гостевых и IoT-сетей, в которых бывают по делу, а не живут; у профилей -`Guest` и `IoT` из коробки он выключен. Флаг записывается в системную запись сети в -момент подключения через приложение, поэтому у сети, которая уже сохранена в Android с -автоподключением, оно выключится после первого подключения кнопкой. Обратное тоже -верно: подключение через профиль с включённым автоподключением включает его в системной -записи заново. +сохранённой): телефон больше не будет сам возвращаться в неё при появлении в зоне +действия или после включения радио — только по кнопке. Это для гостевых и IoT-сетей, в +которых бывают по делу, а не живут; у профилей `Guest` и `IoT` из коробки стоит `OFF`. +«Включено» (`ON`) — наоборот, включает автоподключение в системной записи при каждом +подключении. Значение применяется в момент подключения через приложение, поэтому у сети, +которая уже сохранена в Android, оно поменяется после первого нажатия кнопки. Файлы +конфигурации версии 1.19 с `autoJoin: true` / `false` читаются как `ON` / `OFF`. ## Профили из коробки diff --git a/app/src/main/java/icu/nd4y/netswitcher/data/Model.kt b/app/src/main/java/icu/nd4y/netswitcher/data/Model.kt index 4694c2a..cd76589 100644 --- a/app/src/main/java/icu/nd4y/netswitcher/data/Model.kt +++ b/app/src/main/java/icu/nd4y/netswitcher/data/Model.kt @@ -1,7 +1,15 @@ package icu.nd4y.netswitcher.data import icu.nd4y.netswitcher.R +import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.booleanOrNull /** What a button actually does when pressed. */ enum class ProfileKind { @@ -58,6 +66,52 @@ enum class WifiSecurity(val token: String, val needsPassword: Boolean, val frame OWE("owe", false, 6), } +/** + * What a Wi-Fi profile does to the network's auto-join flag in Android. + * + * [SYSTEM] leaves whatever Android has — the default, so a button press never + * silently flips a setting made in Android's Wi-Fi settings. [OFF] is the guest / + * IoT case: the phone only lands on the network when the button is pressed. [ON] + * re-enables auto-join on every connect. + */ +@Serializable(with = AutoJoinSerializer::class) +enum class AutoJoin { + SYSTEM, ON, OFF; + + companion object { + /** Accepts the enum names and the 1.19 booleans (`true` = ON, `false` = OFF). */ + fun fromString(raw: String): AutoJoin { + val value = raw.trim() + entries.firstOrNull { it.name.equals(value, ignoreCase = true) }?.let { return it } + return when (value.lowercase()) { + "true", "yes", "on", "1" -> ON + "false", "no", "off", "0" -> OFF + else -> SYSTEM + } + } + } +} + +/** + * Configs persisted by 1.19 carry `"autoJoin": true|false`; a decode failure there + * would reset the whole config to defaults, so the JSON booleans stay readable. + */ +object AutoJoinSerializer : KSerializer { + override val descriptor = PrimitiveSerialDescriptor("AutoJoin", PrimitiveKind.STRING) + + override fun serialize(encoder: Encoder, value: AutoJoin) = encoder.encodeString(value.name) + + override fun deserialize(decoder: Decoder): AutoJoin { + val json = decoder as? JsonDecoder ?: return AutoJoin.fromString(decoder.decodeString()) + val element = json.decodeJsonElement() as? JsonPrimitive ?: return AutoJoin.SYSTEM + return when { + element.isString -> AutoJoin.fromString(element.content) + else -> element.booleanOrNull?.let { if (it) AutoJoin.ON else AutoJoin.OFF } + ?: AutoJoin.SYSTEM + } + } +} + enum class MobileDataAction { KEEP, ENABLE, DISABLE } /** Which privileged backend to use for the shell commands. */ @@ -84,11 +138,11 @@ data class Profile( val hiddenSsid: Boolean = false, val bssid: String = "", /** - * WIFI: whether Android may rejoin this network on its own. Off means the saved - * network is written with auto-join disabled (`connect-network -d`), so the phone - * only lands on it when the button is pressed — the guest / IoT case. + * WIFI: what to do with Android's auto-join flag for this network — see [AutoJoin]. + * Applied when the network is added (`connect-network -d`) and, for a saved one, + * via `allowAutojoin`; [AutoJoin.SYSTEM] touches nothing. */ - val autoJoin: Boolean = true, + val autoJoin: AutoJoin = AutoJoin.SYSTEM, /** * WIFI: re-add the network from this profile on every connect (the pre-1.20 * behaviour). Off — the default — joins the entry Android already has by its id, @@ -194,7 +248,7 @@ data class Config( const val TILE_COUNT = 8 fun default(): Config { - fun wifi(id: String, name: String, ssid: String, autoJoin: Boolean = true) = + fun wifi(id: String, name: String, ssid: String, autoJoin: AutoJoin = AutoJoin.SYSTEM) = Profile(id = id, name = name, kind = ProfileKind.WIFI, ssid = ssid, autoJoin = autoJoin) // Placeholder SSIDs — the point is to show the shape of a profile, the // user replaces them (or imports a YAML config) with their own networks. @@ -207,9 +261,9 @@ data class Config( Profile(id = "air_sw", name = "Авиарежим", kind = ProfileKind.AIRPLANE_TOGGLE), wifi("home", "Home", "Home"), wifi("home5", "Home 5G", "Home-5G"), - wifi("guest", "Guest", "Guest", autoJoin = false), - wifi("guest5", "Guest 5G", "Guest-5G", autoJoin = false), - wifi("iot", "IoT", "IoT", autoJoin = false), + wifi("guest", "Guest", "Guest", autoJoin = AutoJoin.OFF), + wifi("guest5", "Guest 5G", "Guest-5G", autoJoin = AutoJoin.OFF), + wifi("iot", "IoT", "IoT", autoJoin = AutoJoin.OFF), Profile( id = "lte", // "only"-suffixed so the one-shot is distinguishable from the LTE diff --git a/app/src/main/java/icu/nd4y/netswitcher/data/YamlConfig.kt b/app/src/main/java/icu/nd4y/netswitcher/data/YamlConfig.kt index 28e3106..8697d7f 100644 --- a/app/src/main/java/icu/nd4y/netswitcher/data/YamlConfig.kt +++ b/app/src/main/java/icu/nd4y/netswitcher/data/YamlConfig.kt @@ -37,7 +37,8 @@ object YamlConfig { appendLine(" bssid: ${quote(profile.bssid)}") } appendLine(" tapAgainDisconnects: ${profile.tapAgainDisconnects}") - appendLine(" autoJoin: ${profile.autoJoin} # false = только по кнопке") + // Quoted: bare ON/OFF are booleans in YAML 1.1 (accepted too, but ugly). + appendLine(" autoJoin: \"${profile.autoJoin.name}\" # SYSTEM = как в Android | ON | OFF") if (profile.overwriteSaved) { appendLine(" overwriteSaved: true # пересоздавать сеть из профиля, затирая настройки Android") } @@ -102,7 +103,7 @@ object YamlConfig { mobileData = map.enum("mobileData", MobileDataAction.entries, MobileDataAction.KEEP), disableWifi = map.bool("disableWifi") ?: true, tapAgainDisconnects = map.bool("tapAgainDisconnects") ?: true, - autoJoin = map.bool("autoJoin") ?: true, + autoJoin = map.autoJoin(), overwriteSaved = map.bool("overwriteSaved") ?: false, ) } @@ -169,6 +170,16 @@ object YamlConfig { else -> null } + /** + * SYSTEM | ON | OFF. A bare `ON` / `OFF` arrives as a YAML 1.1 boolean, and files + * from 1.19 carry `true` / `false` — both map onto ON / OFF. + */ + private fun Map<*, *>.autoJoin(): AutoJoin = when (val raw = this["autoJoin"]) { + null -> AutoJoin.SYSTEM + is Boolean -> if (raw) AutoJoin.ON else AutoJoin.OFF + else -> AutoJoin.fromString(raw.toString()) + } + private fun > Map<*, *>.enum(key: String, values: List, fallback: T): T { val raw = string(key) ?: return fallback return values.firstOrNull { it.name.equals(raw.trim(), ignoreCase = true) } ?: fallback diff --git a/app/src/main/java/icu/nd4y/netswitcher/engine/SavedNetworks.kt b/app/src/main/java/icu/nd4y/netswitcher/engine/SavedNetworks.kt index 5273fe7..db233fe 100644 --- a/app/src/main/java/icu/nd4y/netswitcher/engine/SavedNetworks.kt +++ b/app/src/main/java/icu/nd4y/netswitcher/engine/SavedNetworks.kt @@ -1,6 +1,7 @@ package icu.nd4y.netswitcher.engine import android.content.Context +import icu.nd4y.netswitcher.data.AutoJoin import icu.nd4y.netswitcher.data.Profile /** Outcome of asking [WifiHelper] to join a network Android already has saved. */ @@ -34,7 +35,13 @@ object SavedNetworks { append(WifiHelper::class.java.name) append(" connect ").append(shQuote(profile.ssid)) append(' ').append(profile.security.frameworkType) - append(' ').append(if (profile.autoJoin) 1 else 0) + append(' ').append( + when (profile.autoJoin) { + AutoJoin.SYSTEM -> "keep" + AutoJoin.ON -> "1" + AutoJoin.OFF -> "0" + } + ) } val result = shell.exec(command) @@ -48,7 +55,12 @@ object SavedNetworks { return when { result.exitCode == 0 && verdict.startsWith("ok") -> { val netId = verdict.substringAfter("id=", "").trim().toIntOrNull() ?: -1 - log += "Подключение к сохранённой сети #$netId — её настройки в Android не тронуты" + log += "Подключение к сохранённой сети #$netId — её настройки в Android не тронуты" + + when (profile.autoJoin) { + AutoJoin.SYSTEM -> "" + AutoJoin.ON -> ", автоподключение включено по профилю" + AutoJoin.OFF -> ", автоподключение выключено по профилю" + } SavedConnect.Connected(netId) } diff --git a/app/src/main/java/icu/nd4y/netswitcher/engine/SwitchEngine.kt b/app/src/main/java/icu/nd4y/netswitcher/engine/SwitchEngine.kt index 976a4c2..654bd8f 100644 --- a/app/src/main/java/icu/nd4y/netswitcher/engine/SwitchEngine.kt +++ b/app/src/main/java/icu/nd4y/netswitcher/engine/SwitchEngine.kt @@ -6,6 +6,7 @@ import android.net.wifi.WifiManager import android.net.wifi.WifiNetworkSuggestion import android.provider.Settings import icu.nd4y.netswitcher.data.ActionResult +import icu.nd4y.netswitcher.data.AutoJoin import icu.nd4y.netswitcher.data.Backend import icu.nd4y.netswitcher.data.MobileDataAction import icu.nd4y.netswitcher.data.Profile @@ -411,10 +412,11 @@ class SwitchEngine(context: Context) { } if (profile.hiddenSsid) append(" -h") if (profile.bssid.isNotBlank()) append(" -b ${profile.bssid}") - // `-d` lands in the saved network as allowAutojoin=false. This command only - // runs when the network is added (or a profile insists on re-adding it); - // for an already-saved network the helper sets the flag via allowAutojoin. - if (!profile.autoJoin) append(" -d") + // `-d` lands in the saved network as allowAutojoin=false; a fresh entry has + // it on, so ON and SYSTEM need nothing here. This command only runs when the + // network is added (or a profile insists on re-adding it); for an + // already-saved network the helper applies the choice via allowAutojoin. + if (profile.autoJoin == AutoJoin.OFF) append(" -d") } /** [logAs] is what lands in the on-screen log — pass a redacted copy for secrets. */ @@ -452,7 +454,7 @@ class SwitchEngine(context: Context) { } } if (profile.hiddenSsid) builder.setIsHiddenSsid(true) - if (!profile.autoJoin) builder.setIsInitialAutojoinEnabled(false) + if (profile.autoJoin == AutoJoin.OFF) builder.setIsInitialAutojoinEnabled(false) builder.setIsAppInteractionRequired(false) val manager = appContext.getSystemService(WifiManager::class.java) manager?.removeNetworkSuggestions(emptyList()) diff --git a/app/src/main/java/icu/nd4y/netswitcher/engine/WifiHelper.kt b/app/src/main/java/icu/nd4y/netswitcher/engine/WifiHelper.kt index 98d885d..cfa522e 100644 --- a/app/src/main/java/icu/nd4y/netswitcher/engine/WifiHelper.kt +++ b/app/src/main/java/icu/nd4y/netswitcher/engine/WifiHelper.kt @@ -21,7 +21,7 @@ import kotlin.system.exitProcess * * Usage (see [SavedNetworks]): * CLASSPATH= app_process /system/bin icu.nd4y.netswitcher.engine.WifiHelper \ - * connect + * connect * * Exit codes: 0 — connect requested (stdout: `ok id=` + a summary line), * 2 — no such saved network, 1 — error (stderr has the reason). @@ -75,7 +75,7 @@ object WifiHelper { } private fun usage(): Int { - System.err.println("usage: connect ") + System.err.println("usage: connect ") return 1 } diff --git a/app/src/main/java/icu/nd4y/netswitcher/ui/EditProfileDialog.kt b/app/src/main/java/icu/nd4y/netswitcher/ui/EditProfileDialog.kt index 658f94e..217865a 100644 --- a/app/src/main/java/icu/nd4y/netswitcher/ui/EditProfileDialog.kt +++ b/app/src/main/java/icu/nd4y/netswitcher/ui/EditProfileDialog.kt @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -31,9 +32,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties +import icu.nd4y.netswitcher.data.AutoJoin import icu.nd4y.netswitcher.data.MobileDataAction import icu.nd4y.netswitcher.data.Profile import icu.nd4y.netswitcher.data.ProfileKind @@ -219,11 +224,21 @@ private fun WifiFields(draft: Profile, onChange: (Profile) -> Unit) { ) if (draft.security.needsPassword) { Spacer(Modifier.height(10.dp)) + // Masked by default — the editor opens over the shoulder of whoever is around. + var showPassword by remember { mutableStateOf(false) } OutlinedTextField( value = draft.password, onValueChange = { onChange(draft.copy(password = it)) }, label = { Text("Пароль") }, singleLine = true, + visualTransformation = + if (showPassword) VisualTransformation.None else PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + trailingIcon = { + TextButton(onClick = { showPassword = !showPassword }) { + Text(if (showPassword) "Скрыть" else "Показать") + } + }, modifier = Modifier.fillMaxWidth(), ) Text( @@ -246,15 +261,18 @@ private fun WifiFields(draft: Profile, onChange: (Profile) -> Unit) { checked = draft.hiddenSsid, onChange = { onChange(draft.copy(hiddenSsid = it)) }, ) - ToggleRow( - label = "Автоподключение к сети", - checked = draft.autoJoin, - onChange = { onChange(draft.copy(autoJoin = it)) }, + Spacer(Modifier.height(6.dp)) + Text("Автоподключение", style = MaterialTheme.typography.labelLarge) + Spacer(Modifier.height(6.dp)) + SelectorRow( + options = AutoJoin.entries.map { it to autoJoinLabel(it) }, + selected = draft.autoJoin, + onSelect = { onChange(draft.copy(autoJoin = it)) }, ) Text( - text = "Выключите для гостевых и IoT-сетей: телефон не будет сам возвращаться " + - "в эту сеть, подключение — только кнопкой. Флаг попадает в системную " + - "запись сети при подключении через NetSwitcher.", + text = "«Как в Android» ничего не меняет. «Выключено» — для гостевых и IoT-сетей: " + + "телефон не будет сам возвращаться в сеть, подключение — только кнопкой. " + + "Значение попадает в системную запись сети при подключении через NetSwitcher.", style = MaterialTheme.typography.bodySmall, ) Spacer(Modifier.height(6.dp)) @@ -266,9 +284,9 @@ private fun WifiFields(draft: Profile, onChange: (Profile) -> Unit) { Text( text = "Выкл. — к сети, уже сохранённой в Android, NetSwitcher подключается по её " + "id и ничего в ней не меняет: прокси, статический IP, MAC, лимит трафика и " + - "пароль остаются как в настройках Android, из профиля применяется только " + - "автоподключение. Вкл. — сеть каждый раз добавляется заново из профиля, " + - "и эти настройки затираются.", + "пароль остаются как в настройках Android; из профиля применяется только " + + "автоподключение, если оно не «Как в Android». Вкл. — сеть каждый раз " + + "добавляется заново из профиля, и эти настройки затираются.", style = MaterialTheme.typography.bodySmall, ) } @@ -403,6 +421,12 @@ private fun kindLabel(kind: ProfileKind): String = when (kind) { ProfileKind.AIRPLANE_TOGGLE -> "⇄ Авиарежим" } +private fun autoJoinLabel(value: AutoJoin): String = when (value) { + AutoJoin.SYSTEM -> "Как в Android" + AutoJoin.ON -> "Включено" + AutoJoin.OFF -> "Выключено" +} + private fun mobileDataLabel(action: MobileDataAction): String = when (action) { MobileDataAction.KEEP -> "Не трогать" MobileDataAction.ENABLE -> "Включить" diff --git a/app/src/test/java/icu/nd4y/netswitcher/data/ProfileJsonTest.kt b/app/src/test/java/icu/nd4y/netswitcher/data/ProfileJsonTest.kt new file mode 100644 index 0000000..0ac5043 --- /dev/null +++ b/app/src/test/java/icu/nd4y/netswitcher/data/ProfileJsonTest.kt @@ -0,0 +1,44 @@ +package icu.nd4y.netswitcher.data + +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The config persisted in DataStore is JSON produced by [Profile]'s serializer, and a + * failed decode silently resets it to defaults — so every schema change must keep the + * previous release's output readable. Same Json settings as ConfigRepository. + */ +class ProfileJsonTest { + + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + @Test + fun `autoJoin booleans persisted by 1_19 decode as ON and OFF`() { + val off = """{"id":"g","name":"Guest","kind":"WIFI","ssid":"Guest","autoJoin":false}""" + assertEquals(AutoJoin.OFF, json.decodeFromString(Profile.serializer(), off).autoJoin) + + val on = off.replace("false", "true") + assertEquals(AutoJoin.ON, json.decodeFromString(Profile.serializer(), on).autoJoin) + } + + @Test + fun `autoJoin is stored by name, round trips and defaults to SYSTEM`() { + val profile = Profile( + id = "h", name = "Home", kind = ProfileKind.WIFI, ssid = "Home", + autoJoin = AutoJoin.OFF, overwriteSaved = true, + ) + val text = json.encodeToString(Profile.serializer(), profile) + assertTrue(text, text.contains("\"autoJoin\":\"OFF\"")) + assertEquals(profile, json.decodeFromString(Profile.serializer(), text)) + + val bare = """{"id":"h","name":"Home","kind":"WIFI"}""" + val decoded = json.decodeFromString(Profile.serializer(), bare) + assertEquals(AutoJoin.SYSTEM, decoded.autoJoin) + assertEquals(false, decoded.overwriteSaved) + } +} diff --git a/app/src/test/java/icu/nd4y/netswitcher/data/YamlConfigTest.kt b/app/src/test/java/icu/nd4y/netswitcher/data/YamlConfigTest.kt index f1c9834..e00028c 100644 --- a/app/src/test/java/icu/nd4y/netswitcher/data/YamlConfigTest.kt +++ b/app/src/test/java/icu/nd4y/netswitcher/data/YamlConfigTest.kt @@ -215,12 +215,13 @@ class YamlConfigTest { } /** - * `autoJoin` is what keeps the phone off guest / IoT networks. A file written - * before the key existed must keep auto-join on, and an explicit `false` has to - * survive export → import — otherwise a round trip would silently re-enable it. + * `autoJoin` is what keeps the phone off guest / IoT networks. A file without the + * key must not touch Android's flag (SYSTEM); the 1.19 booleans and bare YAML + * `ON` / `OFF` (booleans in YAML 1.1) map onto ON / OFF; and every value has to + * survive export → import. */ @Test - fun `autoJoin defaults to on and an explicit off survives a round trip`() { + fun `autoJoin defaults to SYSTEM, accepts legacy booleans and survives a round trip`() { val yaml = """ profiles: - id: "home" @@ -232,11 +233,23 @@ class YamlConfigTest { kind: WIFI ssid: "Guest" autoJoin: false + - id: "iot" + name: "IoT" + kind: WIFI + ssid: "IoT" + autoJoin: OFF + - id: "work" + name: "Work" + kind: WIFI + ssid: "Work" + autoJoin: "ON" """.trimIndent() val config = YamlConfig.decode(yaml).getOrThrow() - assertTrue(config.profile("home")!!.autoJoin) - assertFalse(config.profile("guest")!!.autoJoin) + assertEquals(AutoJoin.SYSTEM, config.profile("home")!!.autoJoin) + assertEquals(AutoJoin.OFF, config.profile("guest")!!.autoJoin) + assertEquals(AutoJoin.OFF, config.profile("iot")!!.autoJoin) + assertEquals(AutoJoin.ON, config.profile("work")!!.autoJoin) val again = YamlConfig.decode(YamlConfig.encode(config)).getOrThrow() assertEquals(config.profiles, again.profiles) diff --git a/app/src/test/java/icu/nd4y/netswitcher/ui/EditProfileDialogTest.kt b/app/src/test/java/icu/nd4y/netswitcher/ui/EditProfileDialogTest.kt index 14ed16a..13ce43e 100644 --- a/app/src/test/java/icu/nd4y/netswitcher/ui/EditProfileDialogTest.kt +++ b/app/src/test/java/icu/nd4y/netswitcher/ui/EditProfileDialogTest.kt @@ -36,7 +36,10 @@ class EditProfileDialogTest { } compose.onNodeWithText("SSID").assertExists() compose.onNodeWithText("Пароль").assertExists() - compose.onNodeWithText("Автоподключение к сети").assertExists() + // The password is masked until asked for. + compose.onNodeWithText("Показать").assertExists() + compose.onNodeWithText("Автоподключение").assertExists() + compose.onNodeWithText("Как в Android").assertExists() compose.onNodeWithText("Пересоздавать сохранённую сеть").assertExists() compose.onNodeWithText("Сохранить").assertExists() } diff --git a/examples/example-config.yaml b/examples/example-config.yaml index 8e65301..69632f5 100644 --- a/examples/example-config.yaml +++ b/examples/example-config.yaml @@ -15,9 +15,10 @@ # ETHERNET_TOGGLE — переключатель проводного интерфейса # AIRPLANE_TOGGLE — переключатель авиарежима # -# autoJoin: false у Wi-Fi-профиля — при подключении через NetSwitcher сеть сохраняется -# в Android без автоподключения: телефон не вернётся в неё сам, только по кнопке. -# Для гостевых и IoT-сетей. Отсутствующий ключ = true. +# autoJoin у Wi-Fi-профиля: "SYSTEM" (по умолчанию) — не трогать настройку Android; +# "OFF" — сохранить сеть без автоподключения: телефон не вернётся в неё сам, только +# по кнопке (для гостевых и IoT-сетей); "ON" — включать автоподключение. Пишите в +# кавычках: голые ON/OFF в YAML 1.1 — булевы (они тоже принимаются, как и true/false). # overwriteSaved: true у Wi-Fi-профиля — пересоздавать сеть из профиля при каждом # подключении, затирая её настройки в Android (прокси, IP, MAC, лимит трафика). # По умолчанию false: к уже сохранённой сети NetSwitcher подключается по id и @@ -50,7 +51,7 @@ profiles: security: WPA2 password: "" tapAgainDisconnects: true - autoJoin: true + autoJoin: "SYSTEM" mobileData: KEEP - id: "home5" name: "Home 5G" @@ -59,7 +60,7 @@ profiles: security: WPA2 password: "" tapAgainDisconnects: true - autoJoin: true + autoJoin: "SYSTEM" mobileData: KEEP - id: "guest" name: "Guest" @@ -68,7 +69,7 @@ profiles: security: WPA2 password: "" tapAgainDisconnects: true - autoJoin: false # не возвращаться в сеть самостоятельно + autoJoin: "OFF" # не возвращаться в сеть самостоятельно mobileData: KEEP - id: "iot" name: "IoT" @@ -77,7 +78,7 @@ profiles: security: WPA2 password: "" tapAgainDisconnects: true - autoJoin: false # не возвращаться в сеть самостоятельно + autoJoin: "OFF" # не возвращаться в сеть самостоятельно mobileData: KEEP - id: "lte" name: "LTE only"