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
59 changes: 46 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <ssid> wpa2 <pass> [-h] [-b <bssid>] [-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 <ssid> wpa2 <pass> [-h] [-b <bssid>] [-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` |
Expand Down Expand Up @@ -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 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
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 is built for both
backends, Shizuku and root; `cmd wifi` has no connect-by-id.

## Buttons

Expand Down Expand Up @@ -221,15 +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
(`connect-network … -d`): 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

Expand Down
60 changes: 46 additions & 14 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@
| Действие | Команда |
|---|---|
| Включить/выключить Wi-Fi | `cmd wifi set-wifi-enabled enabled\|disabled` |
| Подключиться к сети | `cmd wifi connect-network <ssid> wpa2 <pass> [-h] [-b <bssid>] [-d]` |
| Подключиться к сохранённой сети | `IWifiManager.connect(netId)` — свой helper через `app_process`, запись сети не пересоздаётся (см. «Сохранённые сети») |
| Добавить сеть и подключиться | `cmd wifi connect-network <ssid> wpa2 <pass> [-h] [-b <bssid>] [-d]` — только если сети ещё нет среди сохранённых |
| Только включить радио | `cmd wifi set-wifi-enabled enabled` — сеть выбирает автоподключение |
| Авиарежим | `cmd connectivity airplane-mode enable\|disable` → `settings put global airplane_mode_on` + broadcast |
| Мобильные данные | `svc data enable\|disable` |
Expand Down Expand Up @@ -95,9 +96,39 @@ 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» ничего не трогает. В
подробном логе видно, что Android хранит для сети.

Если сети среди сохранённых нет, она добавляется из профиля командой `connect-network` —
для этого нужен пароль. Тумблер «Пересоздавать сохранённую сеть» (`overwriteSaved: true` в
YAML) возвращает старое поведение: сеть каждый раз пересоздаётся из профиля, а её
настройки в Android заменяются. Если helper не смог подключиться (например, в Android у
сети уже другой пароль), NetSwitcher сообщает об этом и ничего не перезаписывает — это и
есть случай для тумблера.

Helper — класс из APK самого приложения, который привилегированный шелл запускает через
`app_process` (так же устроен системный `svc`). Рассчитан на оба бэкенда — Shizuku и
root; в `cmd wifi` подключения по id нет.

## Кнопки

Expand Down Expand Up @@ -212,16 +243,17 @@ Wi-Fi-профили конкретных сетей тоже работают
существует, поэтому это делается выключением радио — поведение отключается флажком
«Повторное нажатие отключает от сети» в профиле.

У каждой Wi-Fi-сети есть и флажок «Автоподключение к сети» (в YAML — `autoJoin`). Если
его выключить, при подключении через NetSwitcher сеть сохраняется в Android с
отключённым автоподключением (`connect-network … -d`): телефон больше не будет сам
возвращаться в неё при появлении в зоне действия или после включения радио — только по
кнопке. Это для гостевых и IoT-сетей, в которых бывают по делу, а не живут; у профилей
`Guest` и `IoT` из коробки он выключен. Флаг записывается в системную запись сети в
момент подключения через приложение, поэтому у сети, которая уже сохранена в Android с
автоподключением, оно выключится после первого подключения кнопкой. Обратное тоже
верно: подключение через профиль с включённым автоподключением включает его в системной
записи заново.
У каждой Wi-Fi-сети есть и настройка «Автоподключение» (в YAML — `autoJoin`) с тремя
значениями. «Как в Android» (`SYSTEM`, по умолчанию) — NetSwitcher этот флаг не трогает.
«Выключено» (`OFF`) — при подключении через NetSwitcher сеть сохраняется в Android с
отключённым автоподключением (`-d` при добавлении сети, `allowAutojoin` — для уже
сохранённой): телефон больше не будет сам возвращаться в неё при появлении в зоне
действия или после включения радио — только по кнопке. Это для гостевых и IoT-сетей, в
которых бывают по делу, а не живут; у профилей `Guest` и `IoT` из коробки стоит `OFF`.
«Включено» (`ON`) — наоборот, включает автоподключение в системной записи при каждом
подключении. Значение применяется в момент подключения через приложение, поэтому у сети,
которая уже сохранена в Android, оно поменяется после первого нажатия кнопки. Файлы
конфигурации версии 1.19 с `autoJoin: true` / `false` читаются как `ON` / `OFF`.

## Профили из коробки

Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
92 changes: 78 additions & 14 deletions app/src/main/java/icu/nd4y/netswitcher/data/Model.kt
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -47,12 +55,61 @@ 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),
}

/**
* 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<AutoJoin> {
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 }
Expand Down Expand Up @@ -81,11 +138,18 @@ 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: 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,
* 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 autoJoin: Boolean = true,
val overwriteSaved: Boolean = false,
// --- Cellular ---
/** Subscription id of the SIM to make the default data SIM; -1 = leave as is. */
val subscriptionId: Int = -1,
Expand Down Expand Up @@ -184,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.
Expand All @@ -197,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
Expand Down
Loading
Loading