From 9f05423957f16b5f781488069b1edddbd03bb9db Mon Sep 17 00:00:00 2001 From: kivtxs <131401183+kivtxs@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:05:47 -0400 Subject: [PATCH 1/9] feat(bindings): Android forms its Wi-Fi Direct group itself, without a system dialog Closes the residual #465 left open ("the Android manager does not form a group itself"). With wifiDirect.autoAccept on Android 10+: - Devices of one app find each other over Wi-Fi P2P DNS-SD: the stream chapter's _offlineprotocol._tcp record (txtvers, addr) plus `app` (a tag of the app id) and `net` (the group name, while owning one). - The lowest address creates a group under a name derived from its address; the others join it with a passphrase derived from the app id. Joining by credentials shows no dialog on either phone, unlike connect() by device address, whose invitation a phone in a pocket never answers. - The query names the instance, because a query by type returns only the PTR record and never reaches the TXT listener. A group owner answers no service discovery query, so joiners target the name the lowest peer will create, and an owner whose group stays empty for 45 s dissolves it so it can be found again. The rules are a pure object (WifiDirectGroupFormation) with 20 JVM tests. Off by default; groupOwnerIntent stays unused. Measured on an Android 13 and an Android 15 phone: launch to proved stream in 26-55 s over five clean starts, chat 3/3 and 4/4 each way at ~100 ms. --- CHANGELOG.md | 15 + .../offlineprotocol/OfflineProtocolModule.kt | 11 +- .../WifiDirectGroupFormation.kt | 161 +++++++++ .../com/offlineprotocol/WifiDirectManager.kt | 315 +++++++++++++++++- .../WifiDirectGroupFormationTest.kt | 218 ++++++++++++ bindings/react-native/src/types.ts | 10 +- docs/bridges/kotlin.md | 13 +- docs/react-native-integration.md | 1 + docs/spec/stream-framing.md | 8 + docs/transport-architecture.md | 9 +- 10 files changed, 748 insertions(+), 13 deletions(-) create mode 100644 bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectGroupFormation.kt create mode 100644 bindings/react-native/android/src/test/java/com/offlineprotocol/WifiDirectGroupFormationTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 96a63cc7..58a62c87 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,21 @@ archived by series under [docs/changelog/](docs/changelog/); see the ### Added +- **Android forms its Wi-Fi Direct group itself.** With + `wifiDirect: { enabled: true, autoAccept: true }` on Android 10 and later, + devices of the same app find each other over Wi-Fi P2P service discovery + (`_offlineprotocol._tcp`, the stream chapter's record plus `app` and `net` + entries), the lowest address creates a group under a name derived from its + address, and the others join it with a passphrase derived from the app id. + No system dialog appears on either phone, which a `WifiP2pManager.connect` + invitation would show. Two phones (Android 13 and 15) went from launch to a + proved stream in 26 to 55 seconds over five clean starts. An owner whose + group stays empty for 45 seconds dissolves it, because a group owner answers + no service discovery query and would otherwise be invisible to the peer it + was made for. Off by default; without it a group is formed in the system's + Wi-Fi Direct settings, as before, and that group is never dissolved. + `groupOwnerIntent` is not used. Groups of three or more devices are untested. + - **Python has a gateway-daemon client.** `GatewayManager`, as `ProtocolManager.gateway` when `reticulum_enabled=True`, speaks the [gateway-daemon contract](docs/spec/gateway-contract.md) over TCP to a diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/OfflineProtocolModule.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/OfflineProtocolModule.kt index 004043de..e0e27edd 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/OfflineProtocolModule.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/OfflineProtocolModule.kt @@ -2550,7 +2550,12 @@ class OfflineProtocolModule(reactContext: ReactApplicationContext) : // Configure and start WiFi Direct transport via WifiDirectManager if (wifiDirectManager == null) { // Create manager if not already created - wifiDirectManager = WifiDirectManager(reactApplicationContext, proto, currentConfig?.profile ?: "unknown") { level, message, context -> + wifiDirectManager = WifiDirectManager( + reactApplicationContext, + proto, + currentConfig?.profile ?: "unknown", + appId = currentConfig?.appId ?: "", + ) { level, message, context -> emitDiagnostic(level, message, context) } emitDiagnostic("info", "WiFi Direct manager created on demand") @@ -2558,6 +2563,10 @@ class OfflineProtocolModule(reactContext: ReactApplicationContext) : val manager = wifiDirectManager ?: throw IllegalStateException("Failed to create WiFi Direct manager") + // `autoAccept` is the documented switch for forming groups + // without the system settings; see WifiDirectGroupFormation. + manager.formGroups = config?.takeIf { it.hasKey("autoAccept") && !it.isNull("autoAccept") } + ?.getBoolean("autoAccept") ?: false // Stop the manager first if it's running (to ensure clean restart) if (manager.state == TransportState.RUNNING) { diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectGroupFormation.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectGroupFormation.kt new file mode 100644 index 00000000..4a5e6e06 --- /dev/null +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectGroupFormation.kt @@ -0,0 +1,161 @@ +package com.offlineprotocol + +import java.security.MessageDigest + +/** + * How Android devices running the same app put themselves into one Wi-Fi + * Direct group without anyone opening the system settings. Pure, so + * [WifiDirectGroupFormationTest] pins it without a radio. + * + * ## The group is formed by credentials, never by invitation + * + * `WifiP2pManager.connect` with a peer's device address starts a negotiation + * that shows the other phone a system "Invitation to connect" dialog, which a + * phone in a pocket never answers. Since Android 10 a group can instead be + * created with a chosen network name and passphrase, and joined by anyone who + * presents the same two, with no prompt on either side. Both are derived here: + * the name from the owner's address, the passphrase from the application id + * and the name, so a device of another application never computes it and a + * device of this one never needs it sent. + * + * The passphrase keeps other applications' devices out of the group; it is not + * what protects the traffic. Every stream still proves its peer with the + * identity preamble before it carries anything, and every message is end to + * end encrypted above it, so a device that joined the group with a leaked + * passphrase reaches a listener that refuses it at the first frame. + * + * ## Who owns the group + * + * Each device advertises a DNS-SD record over Wi-Fi P2P service discovery: + * the `_offlineprotocol._tcp` record the stream chapter + * (`docs/spec/stream-framing.md`) fixes, `txtvers=1` and `addr=`, plus + * [KEY_APP] to keep applications apart and, + * while it owns a group, [KEY_NETWORK] with the group's name. The rules, + * applied by every device to the same records, converge on one owner: + * + * 1. A device in no group joins the owner with the lowest address it can see. + * 2. With no owner in sight, the device with the lowest address among the + * peers it can see creates a group, and every other device joins the group + * that one will create, by the name derived from its address. The joiner + * does not wait for the owner's updated record: on two phones a group + * owner answered no service discovery query at all (it listens on its + * operating channel, not the social ones the queries go out on), so a + * record saying "I own DIRECT-op-…" never arrived. A join that runs ahead + * of the group fails and is retried on the manager's backoff. + * 3. An owner with no clients that sees an owner with a lower address leaves + * its own group and joins that one, so two groups that formed at once + * merge, when the other owner's record is heard at all. + * 4. A device that is a client, or an owner with clients, does nothing. + * + * A record proves nothing about its advertiser (the stream chapter's rule), and + * none is needed here: the worst a forged record can do is name a group that + * will not accept this device's passphrase, or keep this device waiting for an + * owner that never appears, and the manager's retry backoff bounds both. + */ +internal object WifiDirectGroupFormation { + const val SERVICE_TYPE = "_offlineprotocol._tcp" + const val INSTANCE_NAME = "offlineprotocol" + + const val KEY_VERSION = "txtvers" + const val KEY_ADDRESS = "addr" + /** A tag of the application id; devices of other applications are ignored. */ + const val KEY_APP = "app" + /** Present only while the advertiser owns a group: that group's network name. */ + const val KEY_NETWORK = "net" + /** Present on service instances (the DNS-SD mapping chapter); never a peer. */ + const val KEY_SERVICE_ID = "sid" + + /** A peer's record as last seen. [ownerNetwork] is null unless it owns a group. */ + data class Advert( + val device: String, + val address: String, + val app: String, + val ownerNetwork: String?, + val seenAtMs: Long, + ) + + /** What this device knows about itself when it decides. */ + data class Local( + val address: String, + val app: String, + val inGroup: Boolean, + val isOwner: Boolean, + val clients: Int, + ) + + sealed interface Action { + /** Nothing to do now. */ + object Wait : Action + /** Create a group under [networkName] with this device as its owner. */ + object Create : Action + /** Join [owner]'s group, named [network]. */ + data class Join(val network: String, val owner: Advert) : Action + /** Leave this device's empty group and join [owner]'s, named [network]. */ + data class Move(val network: String, val owner: Advert) : Action + } + + fun decide(local: Local, adverts: Collection): Action { + val peers = adverts.filter { it.app == local.app && it.address != local.address } + val owners = peers.filter { it.ownerNetwork != null } + if (local.inGroup) { + if (!local.isOwner || local.clients > 0) return Action.Wait + val lower = owners.filter { it.address < local.address }.minByOrNull { it.address } + ?: return Action.Wait + return Action.Move(lower.ownerNetwork!!, lower) + } + owners.minByOrNull { it.address }?.let { return Action.Join(it.ownerNetwork!!, it) } + val lowest = peers.minByOrNull { it.address } ?: return Action.Wait + return if (local.address < lowest.address) { + Action.Create + } else { + Action.Join(networkName(lowest.address), lowest) + } + } + + /** The record this device advertises, in the order the chapter requires. */ + fun txtRecord(address: String, appTag: String, ownerNetwork: String?): LinkedHashMap { + val txt = LinkedHashMap() + txt[KEY_VERSION] = "1" + txt[KEY_ADDRESS] = address + txt[KEY_APP] = appTag + if (ownerNetwork != null) txt[KEY_NETWORK] = ownerNetwork + return txt + } + + /** + * Reads a peer's record, or null for one that is not a peer record of this + * protocol: another version, no address, or a service instance. + */ + fun parse(device: String, txt: Map, nowMs: Long): Advert? { + if (txt[KEY_VERSION] != "1") return null + if (txt.containsKey(KEY_SERVICE_ID)) return null + val address = txt[KEY_ADDRESS]?.takeIf { it.isNotEmpty() } ?: return null + val app = txt[KEY_APP] ?: return null + val network = txt[KEY_NETWORK]?.takeIf { isValidNetworkName(it) } + return Advert(device, address, app, network, nowMs) + } + + /** Eight hex digits of the application id: enough to keep applications apart. */ + fun appTag(appId: String): String = hex(sha256("offline-protocol/wifi-direct/app|$appId"), 4) + + /** + * The group's network name, from its owner's address. Android requires + * `DIRECT-` and two characters at the front; the result is 18 bytes, under + * the 32 a network name may hold. + */ + fun networkName(ownerAddress: String): String = + "DIRECT-op-" + hex(sha256("offline-protocol/wifi-direct/net|$ownerAddress"), 4) + + /** The group's passphrase: 32 hex digits, inside WPA2's 8 to 63. */ + fun passphrase(appId: String, network: String): String = + hex(sha256("offline-protocol/wifi-direct/psk|$appId|$network"), 16) + + fun isValidNetworkName(name: String): Boolean = + name.length <= 32 && Regex("^DIRECT-[a-zA-Z0-9]{2}.*").matches(name) + + private fun sha256(text: String): ByteArray = + MessageDigest.getInstance("SHA-256").digest(text.toByteArray(Charsets.UTF_8)) + + private fun hex(bytes: ByteArray, count: Int): String = + bytes.take(count).joinToString("") { "%02x".format(it.toInt() and 0xff) } +} diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt index 3e2fc502..405bce22 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt @@ -11,7 +11,10 @@ import android.net.wifi.WifiManager import android.net.wifi.p2p.* import android.os.Build import android.os.Handler +import android.os.SystemClock import android.util.Log +import android.net.wifi.p2p.nsd.WifiP2pDnsSdServiceInfo +import android.net.wifi.p2p.nsd.WifiP2pDnsSdServiceRequest import androidx.core.content.ContextCompat import uniffi.offline_protocol.OfflineProtocol import uniffi.offline_protocol.verifyIdentityAssertion @@ -50,16 +53,23 @@ import java.util.concurrent.atomic.AtomicLong * outbound socket a client opens to its owner, and reconnecting that socket * while the group lasts. * - * What this manager does not do: form a group. Nothing here calls - * `WifiP2pManager.connect`, so a group comes from the system's Wi-Fi Direct - * settings or from another app, and this manager joins the socket layer when - * `WIFI_P2P_CONNECTION_CHANGED_ACTION` says one exists, or at start when one - * already does (see [adoptExistingGroup]). + * Forming the group is opt-in. With [formGroups] (`wifiDirect.autoAccept`) + * on Android 10 and later, the manager finds devices of the same application + * over Wi-Fi P2P service discovery and forms or joins one group by derived + * credentials, with no system dialog on either phone; the rules are in + * [WifiDirectGroupFormation]. Without it the manager never calls + * `WifiP2pManager.connect` or `createGroup`: a group comes from the system's + * Wi-Fi Direct settings or from another app. Either way the socket layer joins + * when `WIFI_P2P_CONNECTION_CHANGED_ACTION` says a group exists, or at start + * when one already does (see [adoptExistingGroup]). */ class WifiDirectManager( private val context: Context, private val protocol: OfflineProtocol, private val deviceId: String, + // The application id, from which the group passphrase is derived; see + // [WifiDirectGroupFormation]. + private val appId: String = "", private val diagnosticEmitter: ((String, String, Map) -> Unit)? = null ) : TransportManager { @@ -98,8 +108,33 @@ class WifiDirectManager( // no peer, reset by one that did. private const val RECONNECT_INITIAL_DELAY_MS = 1_000L private const val RECONNECT_MAX_DELAY_MS = 60_000L + // Group formation (opt-in, [formGroups]). A step runs on this period; + // after it acts it waits at least the retry delay, doubled on each + // failure up to the ceiling, so a forged or stale record cannot keep + // the radio busy. A record not heard again within the TTL is dropped. + private const val FORMATION_TICK_MS = 10_000L + private const val FORMATION_RETRY_MS = 20_000L + private const val FORMATION_MAX_BACKOFF_MS = 120_000L + private const val ADVERT_TTL_MS = 180_000L + // How often service discovery is asked again while there is anything + // to find. Each call cancels and re-queues the supplicant's query and + // restarts its search, so it is not asked on every step. + private const val SERVICE_DISCOVERY_PERIOD_MS = 15_000L + // How long a group this manager formed may stay without clients. An + // owner answers no service discovery query, so an empty group is + // invisible to the peer it was made for (after either side restarts, + // say); dissolving it makes the owner discoverable again, and the next + // round forms the group with whoever is there. + private const val OWNER_IDLE_MS = 45_000L } + /** + * Whether this manager forms and joins groups itself (Android 10 and + * later), from `wifiDirect.autoAccept`. Off by default: then a group comes + * from the system settings, as before. Set before [start]. + */ + @Volatile var formGroups: Boolean = false + // MARK: - Properties private val wifiP2pManager: WifiP2pManager? = @@ -160,6 +195,24 @@ class WifiDirectManager( // does not open a second one to the same owner. private val outboundOpen = AtomicBoolean(false) private val discoveredPeers = ConcurrentHashMap() + + // Group formation state. Confined to the transport thread: the framework + // delivers every listener on the channel's looper, which is this one. + private val adverts = HashMap() + private var inGroup = false + private var ownerClients = 0 + private var createdNetwork: String? = null + private var nextFormationAtMs = 0L + private var formationBackoffMs = FORMATION_RETRY_MS + private var cachedLocalAddress: String? = null + private var advertPublished = false + private var nextServiceDiscoveryAtMs = 0L + // True while this device owns a group under the name it would derive, + // which is the only kind of group formation ever dissolves. + private var ownsFormationGroup = false + private var ownerIdleSinceMs = 0L + private var advertNetwork: String? = null + private val appTag: String by lazy { WifiDirectGroupFormation.appTag(appId) } // State tracking. Volatile: written on the transport thread, read by the // socket thread that decides whether a client reconnects. @@ -328,6 +381,7 @@ class WifiDirectManager( transportHandler.post(messagePollingRunnable) adoptExistingGroup() + startGroupFormation() emitDiagnostic("info", "WiFi Direct transport started") } @@ -375,6 +429,7 @@ class WifiDirectManager( // Close all connections closeAllConnections() + stopGroupFormation() // Forget the group, so a reconnect already posted finds nothing to // dial and a later start() waits for its own CONNECTION_CHANGED. isGroupOwner = false @@ -605,6 +660,8 @@ class WifiDirectManager( emitDiagnostic("info", "Connection changed", mapOf( "connected" to connected )) + inGroup = connected + if (connected) formationBackoffMs = FORMATION_RETRY_MS if (connected && hasRequiredPermissions()) { wifiP2pManager?.requestConnectionInfo(channel) { info -> @@ -634,11 +691,17 @@ class WifiDirectManager( // Connect to group owner connectToGroupOwner(groupOwnerAddress!!) } + refreshGroupForFormation() } } } else { isGroupOwner = false groupOwnerAddress = null + ownerClients = 0 + createdNetwork = null + ownsFormationGroup = false + ownerIdleSinceMs = 0L + if (formGroups) publishAdvert(null) // Posted: this is the broadcast receiver's onReceive, and ending // an announced stream is an FFI call. Same reasoning as the post // in [handleWifiP2pStateChanged]. @@ -653,6 +716,248 @@ class WifiDirectManager( )) } + // MARK: - Group formation (opt-in; see WifiDirectGroupFormation) + + private val formationSupported: Boolean + get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q + + private val formationTick = object : Runnable { + override fun run() { + if (state != TransportState.RUNNING || !formGroups) return + if (!isPaused) formationStep() + transportHandler.postDelayed(this, FORMATION_TICK_MS) + } + } + + @SuppressLint("MissingPermission") + private fun startGroupFormation() { + if (!formGroups) return + if (!formationSupported) { + emitDiagnostic("info", "Wi-Fi Direct group formation needs Android 10; join a group from the system settings") + return + } + val p2p = wifiP2pManager ?: return + p2p.setDnsSdResponseListeners( + channel, + { _, _, _ -> }, + { domain, txt, device -> + if (!domain.contains(WifiDirectGroupFormation.SERVICE_TYPE)) return@setDnsSdResponseListeners + val advert = WifiDirectGroupFormation.parse( + device.deviceAddress, txt, SystemClock.elapsedRealtime() + ) ?: return@setDnsSdResponseListeners + val previous = adverts.put(device.deviceAddress, advert) + if (previous == null || previous.ownerNetwork != advert.ownerNetwork) { + emitDiagnostic("debug", "Wi-Fi Direct peer record", mapOf( + "address" to advert.address, + "owner" to (advert.ownerNetwork != null), + )) + } + }, + ) + // A TXT query for the one instance name every device of this protocol + // publishes. A query by service type alone asks only for the PTR + // record, whose answer reaches the service listener above but never + // the TXT listener, and the TXT record is where the address and the + // group name are. + p2p.addServiceRequest( + channel, + WifiP2pDnsSdServiceRequest.newInstance( + WifiDirectGroupFormation.INSTANCE_NAME, + WifiDirectGroupFormation.SERVICE_TYPE, + ), + quietListener("add service request"), + ) + publishAdvert(null) + transportHandler.removeCallbacks(formationTick) + transportHandler.post(formationTick) + emitDiagnostic("info", "Wi-Fi Direct group formation started") + } + + @SuppressLint("MissingPermission") + private fun stopGroupFormation() { + transportHandler.removeCallbacks(formationTick) + if (!formGroups || !formationSupported) return + val p2p = wifiP2pManager ?: return + p2p.clearServiceRequests(channel, quietListener("clear service requests")) + p2p.clearLocalServices(channel, quietListener("clear local services")) + // A group this manager made goes with it. One the system formed, or one + // adopted at start, is not this run's to remove. + if (createdNetwork != null) p2p.removeGroup(channel, quietListener("remove group")) + createdNetwork = null + adverts.clear() + inGroup = false + ownerClients = 0 + advertPublished = false + advertNetwork = null + } + + /** One step: refresh what is known, and act on it if the backoff allows. */ + @SuppressLint("MissingPermission") + private fun formationStep() { + val p2p = wifiP2pManager ?: return + val now = SystemClock.elapsedRealtime() + adverts.values.removeAll { now - it.seenAtMs > ADVERT_TTL_MS } + val address = localAddressOrNull() ?: return + + if (!advertPublished) publishAdvert(advertNetwork) + + if (inGroup && isGroupOwner && ownsFormationGroup && ownerClients == 0) { + if (ownerIdleSinceMs == 0L) { + ownerIdleSinceMs = now + } else if (now - ownerIdleSinceMs > OWNER_IDLE_MS) { + ownerIdleSinceMs = 0L + emitDiagnostic("info", "Dissolving an empty Wi-Fi Direct group so peers can find this device") + p2p.removeGroup(channel, quietListener("remove empty group")) + return + } + } else { + ownerIdleSinceMs = 0L + } + + // Service discovery is one-shot on Android, so it is asked again while + // there is anything to find. Only it: it runs its own peer search, and + // a discoverPeers issued beside it is refused by the supplicant as a + // second pending scan. + val settled = inGroup && (!isGroupOwner || ownerClients > 0) + if (!settled && now >= nextServiceDiscoveryAtMs) { + nextServiceDiscoveryAtMs = now + SERVICE_DISCOVERY_PERIOD_MS + p2p.discoverServices(channel, quietListener("discover services")) + } + if (now < nextFormationAtMs) return + val action = WifiDirectGroupFormation.decide( + WifiDirectGroupFormation.Local(address, appTag, inGroup, isGroupOwner, ownerClients), + adverts.values, + ) + when (action) { + WifiDirectGroupFormation.Action.Wait -> return + WifiDirectGroupFormation.Action.Create -> createGroup(WifiDirectGroupFormation.networkName(address)) + is WifiDirectGroupFormation.Action.Join -> joinGroup(action.network) + is WifiDirectGroupFormation.Action.Move -> { + emitDiagnostic("info", "Leaving an empty Wi-Fi Direct group for a lower owner's") + p2p.removeGroup(channel, object : WifiP2pManager.ActionListener { + override fun onSuccess() { + createdNetwork = null + joinGroup(action.network) + } + override fun onFailure(reason: Int) = formationFailed("remove group", reason) + }) + } + } + nextFormationAtMs = now + formationBackoffMs + } + + @SuppressLint("MissingPermission") + private fun createGroup(network: String) { + // Here and not only in the caller, so lint's NewApi check sees it. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return + val config = WifiP2pConfig.Builder() + .setNetworkName(network) + .setPassphrase(WifiDirectGroupFormation.passphrase(appId, network)) + .enablePersistentMode(false) + .build() + val ch = channel ?: return + emitDiagnostic("info", "Creating a Wi-Fi Direct group", mapOf("network" to network)) + wifiP2pManager?.createGroup(ch, config, object : WifiP2pManager.ActionListener { + override fun onSuccess() { + createdNetwork = network + } + override fun onFailure(reason: Int) = formationFailed("create group", reason) + }) + } + + @SuppressLint("MissingPermission") + private fun joinGroup(network: String) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return + val config = WifiP2pConfig.Builder() + .setNetworkName(network) + .setPassphrase(WifiDirectGroupFormation.passphrase(appId, network)) + .enablePersistentMode(false) + .build() + val ch = channel ?: return + emitDiagnostic("info", "Joining a Wi-Fi Direct group", mapOf("network" to network)) + wifiP2pManager?.connect(ch, config, object : WifiP2pManager.ActionListener { + override fun onSuccess() {} + override fun onFailure(reason: Int) = formationFailed("join group", reason) + }) + } + + private fun formationFailed(what: String, reason: Int) { + formationBackoffMs = minOf(formationBackoffMs * 2, FORMATION_MAX_BACKOFF_MS) + nextFormationAtMs = SystemClock.elapsedRealtime() + formationBackoffMs + emitDiagnostic("warning", "Wi-Fi Direct group formation: $what failed", mapOf( + "reason" to reasonToString(reason), + "retryInMs" to formationBackoffMs, + )) + } + + /** + * After any group change: count this owner's clients and say in the record + * whether this device owns a group others can join. Only a group whose name + * this device would derive is offered; a group formed from the system + * settings has a passphrase no joiner can compute. + */ + @SuppressLint("MissingPermission") + private fun refreshGroupForFormation() { + if (!formGroups || !formationSupported) return + if (!isGroupOwner) { + ownerClients = 0 + ownsFormationGroup = false + publishAdvert(null) + return + } + wifiP2pManager?.requestGroupInfo(channel) { group -> + ownerClients = group?.clientList?.size ?: 0 + val address = localAddressOrNull() + val ours = address?.let { WifiDirectGroupFormation.networkName(it) } + ownsFormationGroup = group?.networkName != null && group.networkName == ours + publishAdvert(group?.networkName?.takeIf { it == ours }) + } + } + + @SuppressLint("MissingPermission") + private fun publishAdvert(ownerNetwork: String?) { + if (!formGroups || !formationSupported) return + val p2p = wifiP2pManager ?: return + val address = localAddressOrNull() ?: return + val info = WifiP2pDnsSdServiceInfo.newInstance( + WifiDirectGroupFormation.INSTANCE_NAME, + WifiDirectGroupFormation.SERVICE_TYPE, + WifiDirectGroupFormation.txtRecord(address, appTag, ownerNetwork), + ) + advertNetwork = ownerNetwork + val added = object : WifiP2pManager.ActionListener { + override fun onSuccess() { + advertPublished = true + emitDiagnostic("debug", "Wi-Fi Direct record published", mapOf("owner" to (ownerNetwork != null))) + } + override fun onFailure(reason: Int) { + advertPublished = false + emitDiagnostic("debug", "Wi-Fi Direct add local service failed", mapOf("reason" to reasonToString(reason))) + } + } + // Replaced whole: a record is a set of TXT entries, not a field to edit. + p2p.clearLocalServices(channel, object : WifiP2pManager.ActionListener { + override fun onSuccess() = p2p.addLocalService(channel, info, added) + override fun onFailure(reason: Int) = p2p.addLocalService(channel, info, added) + }) + } + + private fun localAddressOrNull(): String? { + cachedLocalAddress?.let { return it } + return try { + protocol.localAddress()?.also { cachedLocalAddress = it } + } catch (_: Exception) { + null + } + } + + private fun quietListener(what: String) = object : WifiP2pManager.ActionListener { + override fun onSuccess() {} + override fun onFailure(reason: Int) { + emitDiagnostic("debug", "Wi-Fi Direct $what failed", mapOf("reason" to reasonToString(reason))) + } + } + // MARK: - Socket Operations private fun startServerSocket() { diff --git a/bindings/react-native/android/src/test/java/com/offlineprotocol/WifiDirectGroupFormationTest.kt b/bindings/react-native/android/src/test/java/com/offlineprotocol/WifiDirectGroupFormationTest.kt new file mode 100644 index 00000000..f97dc48e --- /dev/null +++ b/bindings/react-native/android/src/test/java/com/offlineprotocol/WifiDirectGroupFormationTest.kt @@ -0,0 +1,218 @@ +package com.offlineprotocol + +import com.offlineprotocol.WifiDirectGroupFormation.Action +import com.offlineprotocol.WifiDirectGroupFormation.Advert +import com.offlineprotocol.WifiDirectGroupFormation.Local +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * [WifiDirectGroupFormation]: which device of an application owns the Wi-Fi + * Direct group, and the credentials it is formed and joined with. The manager's + * radio calls need a device (docs/bridges C9); the decision every device makes + * from the same records is pinned here, because two devices that disagree about + * it form two groups that never meet. + */ +class WifiDirectGroupFormationTest { + + private val app = WifiDirectGroupFormation.appTag("com.example.app") + + private fun advert(address: String, owner: String? = null, appTag: String = app) = + Advert(device = "mac-$address", address = address, app = appTag, ownerNetwork = owner, seenAtMs = 0) + + private fun local( + address: String, + inGroup: Boolean = false, + isOwner: Boolean = false, + clients: Int = 0, + ) = Local(address = address, app = app, inGroup = inGroup, isOwner = isOwner, clients = clients) + + // --- Who creates ------------------------------------------------------- + + @Test + fun `alone, a device creates nothing`() { + assertEquals(Action.Wait, WifiDirectGroupFormation.decide(local("off1b"), emptyList())) + } + + @Test + fun `of two devices with no group, the lower creates and the higher joins the group it will create`() { + assertEquals(Action.Create, WifiDirectGroupFormation.decide(local("off1a"), listOf(advert("off1b")))) + val lower = advert("off1a") + assertEquals( + Action.Join(WifiDirectGroupFormation.networkName("off1a"), lower), + WifiDirectGroupFormation.decide(local("off1b"), listOf(lower)), + ) + } + + @Test + fun `the name the joiner expects is the name the owner creates`() { + // The owner creates under networkName(its own address); the joiner, + // which never hears the owner's record once it owns the group, joins + // networkName(the address it heard before). They must be one string. + val owner = "off1qxjee2yfec0twantxrm78pt9nzfhxjrr8q0p63fd" + val joined = WifiDirectGroupFormation.decide(local("off1z"), listOf(advert(owner))) + assertEquals(WifiDirectGroupFormation.networkName(owner), (joined as Action.Join).network) + } + + @Test + fun `the lowest of three creates, and the other two join it`() { + val all = listOf("off1a", "off1b", "off1c") + val actions = all.associateWith { me -> + WifiDirectGroupFormation.decide(local(me), all.filter { it != me }.map { advert(it) }) + } + assertEquals(Action.Create, actions["off1a"]) + val expected = WifiDirectGroupFormation.networkName("off1a") + assertEquals(expected, (actions["off1b"] as Action.Join).network) + assertEquals(expected, (actions["off1c"] as Action.Join).network) + } + + // --- Who joins --------------------------------------------------------- + + @Test + fun `a device in no group joins an owner it sees, whatever the addresses`() { + val owner = advert("off1z", owner = "DIRECT-op-11111111") + assertEquals( + Action.Join("DIRECT-op-11111111", owner), + WifiDirectGroupFormation.decide(local("off1a"), listOf(owner)), + ) + } + + @Test + fun `of two owners in sight, a newcomer joins the lower`() { + val low = advert("off1b", owner = "DIRECT-op-b0000000") + val high = advert("off1c", owner = "DIRECT-op-c0000000") + assertEquals( + Action.Join("DIRECT-op-b0000000", low), + WifiDirectGroupFormation.decide(local("off1z"), listOf(high, low)), + ) + } + + // --- Merging and staying ----------------------------------------------- + + @Test + fun `an empty owner that sees a lower owner moves to it`() { + val lower = advert("off1a", owner = "DIRECT-op-a0000000") + assertEquals( + Action.Move("DIRECT-op-a0000000", lower), + WifiDirectGroupFormation.decide(local("off1b", inGroup = true, isOwner = true), listOf(lower)), + ) + } + + @Test + fun `two empty owners that formed at once end in one group`() { + val a = advert("off1a", owner = "DIRECT-op-a0000000") + val b = advert("off1b", owner = "DIRECT-op-b0000000") + val aAction = WifiDirectGroupFormation.decide(local("off1a", inGroup = true, isOwner = true), listOf(b)) + val bAction = WifiDirectGroupFormation.decide(local("off1b", inGroup = true, isOwner = true), listOf(a)) + assertEquals(Action.Wait, aAction) + assertEquals(Action.Move("DIRECT-op-a0000000", a), bAction) + } + + @Test + fun `an owner with clients never moves`() { + val lower = advert("off1a", owner = "DIRECT-op-a0000000") + assertEquals( + Action.Wait, + WifiDirectGroupFormation.decide(local("off1b", inGroup = true, isOwner = true, clients = 1), listOf(lower)), + ) + } + + @Test + fun `a client never acts, including in a group someone else formed`() { + val lower = advert("off1a", owner = "DIRECT-op-a0000000") + assertEquals( + Action.Wait, + WifiDirectGroupFormation.decide(local("off1b", inGroup = true, isOwner = false), listOf(lower)), + ) + } + + // --- What is ignored --------------------------------------------------- + + @Test + fun `devices of another application are invisible`() { + val other = WifiDirectGroupFormation.appTag("com.other.app") + assertNotEquals(app, other) + val adverts = listOf(advert("off1a", owner = "DIRECT-op-a0000000", appTag = other), advert("off1b", appTag = other)) + assertEquals(Action.Wait, WifiDirectGroupFormation.decide(local("off1z"), adverts)) + } + + @Test + fun `a record naming this device's own address is not a peer`() { + assertEquals(Action.Wait, WifiDirectGroupFormation.decide(local("off1b"), listOf(advert("off1b")))) + } + + // --- The record ---------------------------------------------------------- + + @Test + fun `the record starts with txtvers and carries the address, as the stream chapter requires`() { + val txt = WifiDirectGroupFormation.txtRecord("off1q", app, null) + assertEquals(listOf("txtvers", "addr", "app"), txt.keys.toList()) + assertEquals("1", txt["txtvers"]) + assertEquals("off1q", txt["addr"]) + assertTrue("net" in WifiDirectGroupFormation.txtRecord("off1q", app, "DIRECT-op-12345678")) + } + + @Test + fun `a record round-trips, and an owner's network name comes back`() { + val txt = WifiDirectGroupFormation.txtRecord("off1q", app, "DIRECT-op-12345678") + assertEquals( + Advert("mac", "off1q", app, "DIRECT-op-12345678", 7), + WifiDirectGroupFormation.parse("mac", txt, 7), + ) + } + + @Test + fun `records that are not peer records are refused`() { + val good = WifiDirectGroupFormation.txtRecord("off1q", app, null) + assertNull(WifiDirectGroupFormation.parse("mac", good + ("txtvers" to "2"), 0)) + assertNull(WifiDirectGroupFormation.parse("mac", good - "addr", 0)) + assertNull(WifiDirectGroupFormation.parse("mac", good + ("addr" to ""), 0)) + assertNull(WifiDirectGroupFormation.parse("mac", good - "app", 0)) + // A service instance (the DNS-SD mapping chapter) is never a peer. + assertNull(WifiDirectGroupFormation.parse("mac", good + ("sid" to "printer"), 0)) + } + + @Test + fun `a network name Android would refuse is read as no group`() { + val txt = WifiDirectGroupFormation.txtRecord("off1q", app, null) + ("net" to "not-a-direct-name") + assertNull(WifiDirectGroupFormation.parse("mac", txt, 0)?.ownerNetwork) + } + + // --- The credentials ----------------------------------------------------- + + @Test + fun `the network name is one Android accepts`() { + val name = WifiDirectGroupFormation.networkName("off1qxjee2yfec0twantxrm78pt9nzfhxjrr8q0p63fd") + assertTrue(name, WifiDirectGroupFormation.isValidNetworkName(name)) + assertTrue(name.toByteArray().size <= 32) + } + + @Test + fun `the passphrase is WPA2-sized and the same on both ends`() { + val name = WifiDirectGroupFormation.networkName("off1a") + val owner = WifiDirectGroupFormation.passphrase("com.example.app", name) + val joiner = WifiDirectGroupFormation.passphrase("com.example.app", name) + assertEquals(owner, joiner) + assertTrue(owner.length in 8..63) + } + + @Test + fun `another application derives another passphrase for the same group`() { + val name = WifiDirectGroupFormation.networkName("off1a") + assertNotEquals( + WifiDirectGroupFormation.passphrase("com.example.app", name), + WifiDirectGroupFormation.passphrase("com.other.app", name), + ) + } + + @Test + fun `different owners get different network names`() { + assertNotEquals( + WifiDirectGroupFormation.networkName("off1a"), + WifiDirectGroupFormation.networkName("off1b"), + ) + } +} diff --git a/bindings/react-native/src/types.ts b/bindings/react-native/src/types.ts index f4a728cf..0c78221e 100644 --- a/bindings/react-native/src/types.ts +++ b/bindings/react-native/src/types.ts @@ -414,9 +414,15 @@ export interface WifiDirectTransportConfig { enabled: boolean; /** Device name to advertise */ deviceName?: string; - /** Enable autonomous group owner negotiation */ + /** + * Android 10 and later: form the Wi-Fi Direct group without the system + * settings. Devices of the same app find each other over Wi-Fi P2P service + * discovery and join one group by derived credentials, with no dialog on + * either phone, usually within a minute. Off by default: then a group + * formed in the system's Wi-Fi Direct settings is used. + */ autoAccept?: boolean; - /** Group owner intent (0-15, higher = more likely to be GO) */ + /** Not used. The device with the lowest address owns a group the SDK forms. */ groupOwnerIntent?: number; } diff --git a/docs/bridges/kotlin.md b/docs/bridges/kotlin.md index 375c3e6b..890a84bd 100644 --- a/docs/bridges/kotlin.md +++ b/docs/bridges/kotlin.md @@ -137,9 +137,16 @@ so the two-dialler tie that the lower-address rule settles cannot occur. A client whose stream ends while the group is up reconnects on a doubling delay, always to the owner the group has at that moment: on a group switch the new owner's first dial can lose to the old stream still closing, and the -redial is then the only one left. The manager does not form a group; it joins one the system formed, -including one that formed before it started: since Android 10 the connection broadcast is not sticky, -so the manager asks for the group once it is running. +redial is then the only one left. The manager joins a group the system formed, including one that +formed before it started: since Android 10 the connection broadcast is not sticky, so the manager asks +for the group once it is running. With `formGroups` (`wifiDirect.autoAccept`, Android 10 and later) +it also forms one: `WifiDirectGroupFormation` holds the rules (who creates, who joins, the derived +network name and passphrase) and `WifiDirectGroupFormationTest` pins them. Two behaviours of the +radio shaped them and are worth knowing before changing them. A DNS-SD query by service type alone +returns only the PTR record, so the TXT record that carries the address arrives only for a query that +names the instance. And a group owner answers no service discovery query, so a joiner targets the +name the lowest peer will create rather than waiting to hear the owner's record, and an owner whose +group stays empty dissolves it so it can be found again. `PeerStreamSocketsTest` and `PeerStreamFramingTest` pin it, the latter replaying the chapter's vectors. The group handling itself is not covered in diff --git a/docs/react-native-integration.md b/docs/react-native-integration.md index 1e3c2055..9c0774f7 100644 --- a/docs/react-native-integration.md +++ b/docs/react-native-integration.md @@ -115,6 +115,7 @@ transports: { - Permissions: the SDK declares `ACCESS_WIFI_STATE`, `CHANGE_WIFI_STATE` and `NEARBY_WIFI_DEVICES` (with `neverForLocation`). Request `NEARBY_WIFI_DEVICES` at runtime on Android 13+, and `ACCESS_FINE_LOCATION` on Android 12 and lower, where peer discovery needs it. - The SDK's manifest asserts `neverForLocation` on `NEARBY_WIFI_DEVICES`, and the build merges that flag into your app. An app that derives location from Wi-Fi replaces the declaration (`tools:node="replace"`) and then also needs `ACCESS_FINE_LOCATION` on Android 13+, which the SDK does not check for it. - `wifiDirect: { enabled: true }` starts the transport in `start()`. Grant `NEARBY_WIFI_DEVICES` (or, on 12 and lower, fine location) **before** `start()`: an enable refused for a missing grant is logged and not retried, so the transport stays off for the session. If you ask for the grant later, call `enableTransport('wifiDirect')` once it is given. +- `autoAccept: true` lets the SDK form the Wi-Fi Direct group itself on Android 10 and later: devices of the same app find each other and join one group within about a minute, with no system dialog. Without it, pair the phones once in the system's Wi-Fi Direct settings. `groupOwnerIntent` is not used. - Config: `wifiDirect: { enabled: true, autoAccept: true, groupOwnerIntent: 10 }`. ### 4.3 Internet diff --git a/docs/spec/stream-framing.md b/docs/spec/stream-framing.md index b065e1f8..c321822d 100644 --- a/docs/spec/stream-framing.md +++ b/docs/spec/stream-framing.md @@ -262,6 +262,14 @@ and carries no meaning; an implementation SHOULD NOT put the address there, since the TXT entry already carries it and one copy is one place to get it wrong. +On Android, Wi-Fi Direct carries the same record over Wi-Fi P2P service +discovery when an application lets the SDK form the group +(`wifiDirect.autoAccept`). The record adds two entries for that use: `app`, a +tag of the application id that keeps applications' devices apart, and `net`, +present only while the advertiser owns a group, naming it. The `addr` entry +keeps its meaning here: the group the record leads to is only a link, and the +stream opened over it proves its peer like any other. + A record that carries a `sid` entry is not a peer hint. It is a service instance under the [DNS-SD mapping](dns-sd-mapping.md), published under the subtype `_svc._sub` of this type and listed under the base type as well by diff --git a/docs/transport-architecture.md b/docs/transport-architecture.md index 5aaef631..3a5ff552 100644 --- a/docs/transport-architecture.md +++ b/docs/transport-architecture.md @@ -171,8 +171,13 @@ transport. **Platform Notes**: - Android: `WifiP2pManager` for the group, a plain socket for the stream, and `PeerStreamSockets` for everything the chapter asks of that socket. - The manager does not form a group itself: one formed from the system's - Wi-Fi Direct settings (or by another app) is joined when + With `wifiDirect.autoAccept` on Android 10 and later, the manager forms the + group itself: devices of one application find each other over Wi-Fi P2P + service discovery (`_offlineprotocol._tcp`), the lowest address creates a + group under a name derived from its address, and the others join it with a + passphrase derived from the application id, so neither phone shows a system + dialog. Without it, a group formed from the system's Wi-Fi Direct settings + (or by another app) is used. Either way the group is joined when `WIFI_P2P_CONNECTION_CHANGED_ACTION` reports it, or at start when it already exists (the broadcast is not sticky since Android 10), and a client reconnects to its group owner while the group lasts. From a60d1e0c002f6b7c381cbf389b07473b6c12396a Mon Sep 17 00:00:00 2001 From: kivtxs <131401183+kivtxs@users.noreply.github.com> Date: Mon, 5 Oct 2026 18:31:33 -0400 Subject: [PATCH 2/9] fix(bindings): Android Wi-Fi Direct comes back when Wi-Fi does Turning Wi-Fi P2P off reported the slot down; turning it on reported nothing, restarted no discovery and, with group formation on, left the framework's dropped service request and record unregistered, so every later discoverServices failed with NO_SERVICE_REQUESTS. An app started with Wi-Fi off never got Wi-Fi Direct. The manager tracks whether it last reported the layer up, reports it up again when P2P returns (a repeated broadcast is a no-op), restarts peer discovery, re-registers formation's service request and record, and re-registers the request on NO_SERVICE_REQUESTS. Formation pauses while the layer is down instead of failing BUSY. On the phones: app started with Wi-Fi off, then Wi-Fi on: group formed and streams proved in 73 s; Wi-Fi off and on mid-session on one phone: re-formed in 19 s. --- CHANGELOG.md | 11 ++- .../com/offlineprotocol/WifiDirectManager.kt | 76 ++++++++++++++++--- 2 files changed, 75 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58a62c87..530fcf37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,8 @@ archived by series under [docs/changelog/](docs/changelog/); see the address, and the others join it with a passphrase derived from the app id. No system dialog appears on either phone, which a `WifiP2pManager.connect` invitation would show. Two phones (Android 13 and 15) went from launch to a - proved stream in 26 to 55 seconds over five clean starts. An owner whose + proved stream in 26 to 96 seconds over six clean starts, and re-formed the + group 19 seconds after one of them turned Wi-Fi off and on. An owner whose group stays empty for 45 seconds dissolves it, because a group owner answers no service discovery query and would otherwise be invisible to the peer it was made for. Off by default; without it a group is formed in the system's @@ -704,6 +705,14 @@ archived by series under [docs/changelog/](docs/changelog/); see the link is usable and, to `None`, when the last one is not, once per edge: a layer going down with several links switches once, and links proved while the layer is down switch when it comes up. +- **Android Wi-Fi Direct comes back when Wi-Fi does.** Turning Wi-Fi P2P off + reported the slot down to the core; turning it back on reported nothing, + restarted no discovery, and (with group formation on) left the framework's + dropped service request and record unregistered, so every later discovery + failed with `NO_SERVICE_REQUESTS`. An app started with Wi-Fi off never got + Wi-Fi Direct at all. The manager now reports the slot up again when P2P + returns, restarts discovery and re-registers what formation needs, and + re-registers the request on that error too. - **Android rejoins a Wi-Fi Direct group after the app restarts.** The group belongs to the system and outlives the process, but since Android 10 the connection broadcast is not sticky, so a manager that started inside an diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt index 405bce22..d14a6513 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt @@ -217,6 +217,10 @@ class WifiDirectManager( // State tracking. Volatile: written on the transport thread, read by the // socket thread that decides whether a client reconnects. @Volatile private var isGroupOwner = false + // Whether the core was last told the stream layer is up. Wi-Fi P2P going + // off reports it down; coming back on has to report it up again, or the + // core keeps the slot down while streams prove peers over it. + @Volatile private var layerUp = false @Volatile private var groupOwnerAddress: String? = null // The client's next reconnect delay; see [RECONNECT_INITIAL_DELAY_MS]. private val reconnectDelayMs = AtomicLong(RECONNECT_INITIAL_DELAY_MS) @@ -371,6 +375,7 @@ class WifiDirectManager( updateState(TransportState.RUNNING) // Notify protocol + layerUp = true try { protocol.wifiDirectStatusChanged(true) } catch (e: Exception) { @@ -453,6 +458,7 @@ class WifiDirectManager( channel = null // Notify protocol + layerUp = false try { protocol.wifiDirectStatusChanged(false) } catch (e: Exception) { @@ -615,12 +621,31 @@ class WifiDirectManager( // across the flip would keep delivering, and every body // re-adds a neighbour the flip just cleared. closeAllConnections() + layerUp = false try { protocol.wifiDirectStatusChanged(false) } catch (e: Exception) { Log.e(TAG, "Error notifying protocol", e) } } + } else if (enabled && state == TransportState.RUNNING) { + // Wi-Fi P2P came back (Wi-Fi turned on, or the app started with it + // off). Nothing else undoes the branch above: the core kept the + // slot down, discovery had stopped, and the framework had dropped + // every local service and service request with the P2P state. + // Posted for the same reason as above; the flag makes a broadcast + // that repeats the current state a no-op. + transportHandler.post { + if (state != TransportState.RUNNING || layerUp) return@post + layerUp = true + try { + protocol.wifiDirectStatusChanged(true) + } catch (e: Exception) { + Log.e(TAG, "Error notifying protocol", e) + } + startPeerDiscovery() + resumeGroupFormationAfterP2pReturned() + } } } @@ -754,12 +779,22 @@ class WifiDirectManager( } }, ) - // A TXT query for the one instance name every device of this protocol - // publishes. A query by service type alone asks only for the PTR - // record, whose answer reaches the service listener above but never - // the TXT listener, and the TXT record is where the address and the - // group name are. - p2p.addServiceRequest( + addServiceRequest() + publishAdvert(null) + transportHandler.removeCallbacks(formationTick) + transportHandler.post(formationTick) + emitDiagnostic("info", "Wi-Fi Direct group formation started") + } + + /** + * A TXT query for the one instance name every device of this protocol + * publishes. A query by service type alone asks only for the PTR record, + * whose answer reaches the service listener but never the TXT listener, and + * the TXT record is where the address and the group name are. + */ + @SuppressLint("MissingPermission") + private fun addServiceRequest() { + wifiP2pManager?.addServiceRequest( channel, WifiP2pDnsSdServiceRequest.newInstance( WifiDirectGroupFormation.INSTANCE_NAME, @@ -767,10 +802,15 @@ class WifiDirectManager( ), quietListener("add service request"), ) - publishAdvert(null) - transportHandler.removeCallbacks(formationTick) - transportHandler.post(formationTick) - emitDiagnostic("info", "Wi-Fi Direct group formation started") + } + + /** The framework drops local services and service requests with P2P. */ + private fun resumeGroupFormationAfterP2pReturned() { + if (!formGroups || !formationSupported) return + addServiceRequest() + advertPublished = false + nextServiceDiscoveryAtMs = 0L + emitDiagnostic("info", "Wi-Fi Direct group formation resumed") } @SuppressLint("MissingPermission") @@ -795,6 +835,9 @@ class WifiDirectManager( @SuppressLint("MissingPermission") private fun formationStep() { val p2p = wifiP2pManager ?: return + // Nothing to form over while Wi-Fi P2P is off: every call would fail + // BUSY, and a join attempted then is refused with no group to find. + if (!layerUp) return val now = SystemClock.elapsedRealtime() adverts.values.removeAll { now - it.seenAtMs > ADVERT_TTL_MS } val address = localAddressOrNull() ?: return @@ -821,7 +864,17 @@ class WifiDirectManager( val settled = inGroup && (!isGroupOwner || ownerClients > 0) if (!settled && now >= nextServiceDiscoveryAtMs) { nextServiceDiscoveryAtMs = now + SERVICE_DISCOVERY_PERIOD_MS - p2p.discoverServices(channel, quietListener("discover services")) + p2p.discoverServices(channel, object : WifiP2pManager.ActionListener { + override fun onSuccess() {} + override fun onFailure(reason: Int) { + // The request was lost (P2P went off and on between our + // broadcasts, say): register it again for the next round. + if (reason == WifiP2pManager.NO_SERVICE_REQUESTS) addServiceRequest() + emitDiagnostic("debug", "Wi-Fi Direct discover services failed", mapOf( + "reason" to reasonToString(reason) + )) + } + }) } if (now < nextFormationAtMs) return val action = WifiDirectGroupFormation.decide( @@ -1235,6 +1288,7 @@ class WifiDirectManager( WifiP2pManager.P2P_UNSUPPORTED -> "P2P_UNSUPPORTED" WifiP2pManager.BUSY -> "BUSY" WifiP2pManager.ERROR -> "ERROR" + WifiP2pManager.NO_SERVICE_REQUESTS -> "NO_SERVICE_REQUESTS" else -> "UNKNOWN ($reason)" } } From c3e71d5c0535452014cfa315a3d4c89d527e2bfd Mon Sep 17 00:00:00 2001 From: kivtxs <131401183+kivtxs@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:09:28 -0400 Subject: [PATCH 3/9] fix(bindings): form the Android Wi-Fi Direct group by app-wide credentials The first formation design named the group after its owner, so a joiner had to hear the owner's DNS-SD record before it could join. On two phones that failed: a device that owns or is joining a group answers no service discovery query, and the two often heard each other minutes apart or never. Of nine clean starts, two never formed and two took about two minutes. - The group's name and passphrase derive from the app id alone, so a joiner needs nothing from the owner. The `net` record entry is gone. - The lowest address heard creates; others join, and take over creating after three joins found no group (the lower peer may never hear them). - A device that heard no record joins only as a paced probe (once a minute) when a group owner is nearby: Wi-Fi Direct televisions are owners too, and joining on every step kept both phones deaf. - A join attempt is cancelled after 15 s (it must scan, and the supplicant retries a rejected association) and attempts are 30 s apart, so a joiner stays discoverable half the time. - An owner whose group stays empty for a randomised 30-60 s dissolves it and joins twice before it may create again, so groups formed at once merge. Eight clean starts on an Android 13 and an Android 15 phone next to two Wi-Fi Direct televisions: all formed, 18-225 s, median about a minute, no system dialog. 18 JVM tests cover the rules, including the asymmetric cases. --- CHANGELOG.md | 27 +-- .../WifiDirectGroupFormation.kt | 136 +++++++------ .../com/offlineprotocol/WifiDirectManager.kt | 120 ++++++----- .../WifiDirectGroupFormationTest.kt | 192 ++++++++---------- docs/bridges/kotlin.md | 19 +- docs/spec/stream-framing.md | 5 +- docs/transport-architecture.md | 6 +- 7 files changed, 258 insertions(+), 247 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 530fcf37..e984593e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,18 +18,21 @@ archived by series under [docs/changelog/](docs/changelog/); see the - **Android forms its Wi-Fi Direct group itself.** With `wifiDirect: { enabled: true, autoAccept: true }` on Android 10 and later, devices of the same app find each other over Wi-Fi P2P service discovery - (`_offlineprotocol._tcp`, the stream chapter's record plus `app` and `net` - entries), the lowest address creates a group under a name derived from its - address, and the others join it with a passphrase derived from the app id. - No system dialog appears on either phone, which a `WifiP2pManager.connect` - invitation would show. Two phones (Android 13 and 15) went from launch to a - proved stream in 26 to 96 seconds over six clean starts, and re-formed the - group 19 seconds after one of them turned Wi-Fi off and on. An owner whose - group stays empty for 45 seconds dissolves it, because a group owner answers - no service discovery query and would otherwise be invisible to the peer it - was made for. Off by default; without it a group is formed in the system's - Wi-Fi Direct settings, as before, and that group is never dissolved. - `groupOwnerIntent` is not used. Groups of three or more devices are untested. + (`_offlineprotocol._tcp`, the stream chapter's record plus an `app` entry), + and join one group whose name and passphrase are derived from the app id: + the lowest address creates it and the others join. No system dialog appears + on either phone, which a `WifiP2pManager.connect` invitation would show. + Discovery between phones is asymmetric (a device that owns or is joining a + group answers no discovery query), so nothing depends on hearing the owner: + a device that heard a lower peer creates the group itself after three joins + found none, a device that heard nothing still joins when it sees a group + owner, and an owner whose group stays empty for 30 to 60 seconds dissolves + it so two groups that formed at once merge. Two phones (Android 13 and 15) + next to two Wi-Fi Direct televisions formed the group in all eight clean + starts, in 18 to 225 seconds (median about a minute). Off by default; without it a + group is formed in the system's Wi-Fi Direct settings, as before, and that + group is never dissolved. `groupOwnerIntent` is not used. Groups of three or + more devices are untested. - **Python has a gateway-daemon client.** `GatewayManager`, as `ProtocolManager.gateway` when `reticulum_enabled=True`, speaks the diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectGroupFormation.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectGroupFormation.kt index 4a5e6e06..5c5bbdea 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectGroupFormation.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectGroupFormation.kt @@ -13,44 +13,50 @@ import java.security.MessageDigest * that shows the other phone a system "Invitation to connect" dialog, which a * phone in a pocket never answers. Since Android 10 a group can instead be * created with a chosen network name and passphrase, and joined by anyone who - * presents the same two, with no prompt on either side. Both are derived here: - * the name from the owner's address, the passphrase from the application id - * and the name, so a device of another application never computes it and a - * device of this one never needs it sent. + * presents the same two, with no prompt on either side. Both are derived here + * from the application id alone, so every device of an application can join + * its group without having heard who owns it, and a device of another + * application never computes them. * * The passphrase keeps other applications' devices out of the group; it is not * what protects the traffic. Every stream still proves its peer with the * identity preamble before it carries anything, and every message is end to - * end encrypted above it, so a device that joined the group with a leaked - * passphrase reaches a listener that refuses it at the first frame. + * end encrypted above it. * - * ## Who owns the group + * ## Why the name is the application's and not the owner's * - * Each device advertises a DNS-SD record over Wi-Fi P2P service discovery: - * the `_offlineprotocol._tcp` record the stream chapter - * (`docs/spec/stream-framing.md`) fixes, `txtvers=1` and `addr=`, plus - * [KEY_APP] to keep applications apart and, - * while it owns a group, [KEY_NETWORK] with the group's name. The rules, - * applied by every device to the same records, converge on one owner: + * Discovery is asymmetric on real phones. A device that is a group owner, or + * is in the middle of joining one, answers no Wi-Fi P2P service discovery + * query, and two phones in range often hear each other's record minutes + * apart. An earlier version named the group after its owner's address, so a + * joiner could not join until it had heard the owner; on two phones that + * stalled formation for minutes, and once for good. A name every device can + * derive needs nothing heard from the owner. * - * 1. A device in no group joins the owner with the lowest address it can see. - * 2. With no owner in sight, the device with the lowest address among the - * peers it can see creates a group, and every other device joins the group - * that one will create, by the name derived from its address. The joiner - * does not wait for the owner's updated record: on two phones a group - * owner answered no service discovery query at all (it listens on its - * operating channel, not the social ones the queries go out on), so a - * record saying "I own DIRECT-op-…" never arrived. A join that runs ahead - * of the group fails and is retried on the manager's backoff. - * 3. An owner with no clients that sees an owner with a lower address leaves - * its own group and joins that one, so two groups that formed at once - * merge, when the other owner's record is heard at all. - * 4. A device that is a client, or an owner with clients, does nothing. + * ## Who creates the group + * + * Each device advertises the stream chapter's `_offlineprotocol._tcp` record + * (`docs/spec/stream-framing.md`, `txtvers=1` and `addr=`) plus [KEY_APP], and + * a device in no group decides from what it has heard: + * + * 1. Nothing heard and no group owner nearby: wait. + * 2. The lowest address among the peers it has heard: create the group, unless + * a group owner is nearby, in which case join first (it may be ours). + * 3. Otherwise join, and take over creating after [TAKEOVER_FAILED_JOINS] + * joins found no group: the lower peer may never have heard this one. + * 4. Only a group owner nearby, no record heard: join, but only as a probe at + * most once a minute ([Local.ownerProbeDue]). A Wi-Fi Direct television is a + * group owner too, and a device that joins on every step answers no + * discovery query for most of its time: on two phones next to two + * televisions, both did that and neither heard the other for minutes. + * + * A device in a group does nothing here; the manager dissolves a group it + * owns that stays empty, and the device then joins before it may create + * again ([Local.joinFirst]), so two groups that formed at once merge. * * A record proves nothing about its advertiser (the stream chapter's rule), and - * none is needed here: the worst a forged record can do is name a group that - * will not accept this device's passphrase, or keep this device waiting for an - * owner that never appears, and the manager's retry backoff bounds both. + * none is needed: the worst a forged record does is make this device create or + * join a group, which a forger cannot enter without the passphrase. */ internal object WifiDirectGroupFormation { const val SERVICE_TYPE = "_offlineprotocol._tcp" @@ -60,17 +66,17 @@ internal object WifiDirectGroupFormation { const val KEY_ADDRESS = "addr" /** A tag of the application id; devices of other applications are ignored. */ const val KEY_APP = "app" - /** Present only while the advertiser owns a group: that group's network name. */ - const val KEY_NETWORK = "net" /** Present on service instances (the DNS-SD mapping chapter); never a peer. */ const val KEY_SERVICE_ID = "sid" - /** A peer's record as last seen. [ownerNetwork] is null unless it owns a group. */ + /** How many joins that found no group before a non-lowest device creates. */ + const val TAKEOVER_FAILED_JOINS = 3 + + /** A peer's record as last seen. */ data class Advert( val device: String, val address: String, val app: String, - val ownerNetwork: String?, val seenAtMs: Long, ) @@ -79,46 +85,45 @@ internal object WifiDirectGroupFormation { val address: String, val app: String, val inGroup: Boolean, - val isOwner: Boolean, - val clients: Int, + /** Consecutive join attempts that ended without a group. */ + val failedJoins: Int = 0, + /** Any Wi-Fi Direct group owner in the peer list, of any application. */ + val ownerNearby: Boolean = false, + /** Whether a probe join toward an owner nobody vouched for is due. */ + val ownerProbeDue: Boolean = true, + /** + * Join before creating, whatever else holds. Set after this device + * dissolved an empty group: the other device may own one under the + * same name, and creating again at once is how two empty groups + * formed and dissolved in step on two phones. + */ + val joinFirst: Boolean = false, ) - sealed interface Action { - /** Nothing to do now. */ - object Wait : Action - /** Create a group under [networkName] with this device as its owner. */ - object Create : Action - /** Join [owner]'s group, named [network]. */ - data class Join(val network: String, val owner: Advert) : Action - /** Leave this device's empty group and join [owner]'s, named [network]. */ - data class Move(val network: String, val owner: Advert) : Action - } + enum class Action { Wait, Create, Join } fun decide(local: Local, adverts: Collection): Action { + if (local.inGroup) return Action.Wait val peers = adverts.filter { it.app == local.app && it.address != local.address } - val owners = peers.filter { it.ownerNetwork != null } - if (local.inGroup) { - if (!local.isOwner || local.clients > 0) return Action.Wait - val lower = owners.filter { it.address < local.address }.minByOrNull { it.address } - ?: return Action.Wait - return Action.Move(lower.ownerNetwork!!, lower) + if (peers.isEmpty()) { + return if (local.ownerNearby && local.ownerProbeDue) Action.Join else Action.Wait } - owners.minByOrNull { it.address }?.let { return Action.Join(it.ownerNetwork!!, it) } - val lowest = peers.minByOrNull { it.address } ?: return Action.Wait - return if (local.address < lowest.address) { - Action.Create - } else { - Action.Join(networkName(lowest.address), lowest) + val lowest = peers.minOf { it.address } + return when { + local.joinFirst -> Action.Join + local.address < lowest -> + if (local.ownerNearby && local.failedJoins == 0) Action.Join else Action.Create + local.failedJoins >= TAKEOVER_FAILED_JOINS -> Action.Create + else -> Action.Join } } /** The record this device advertises, in the order the chapter requires. */ - fun txtRecord(address: String, appTag: String, ownerNetwork: String?): LinkedHashMap { + fun txtRecord(address: String, appTag: String): LinkedHashMap { val txt = LinkedHashMap() txt[KEY_VERSION] = "1" txt[KEY_ADDRESS] = address txt[KEY_APP] = appTag - if (ownerNetwork != null) txt[KEY_NETWORK] = ownerNetwork return txt } @@ -131,20 +136,19 @@ internal object WifiDirectGroupFormation { if (txt.containsKey(KEY_SERVICE_ID)) return null val address = txt[KEY_ADDRESS]?.takeIf { it.isNotEmpty() } ?: return null val app = txt[KEY_APP] ?: return null - val network = txt[KEY_NETWORK]?.takeIf { isValidNetworkName(it) } - return Advert(device, address, app, network, nowMs) + return Advert(device, address, app, nowMs) } /** Eight hex digits of the application id: enough to keep applications apart. */ fun appTag(appId: String): String = hex(sha256("offline-protocol/wifi-direct/app|$appId"), 4) /** - * The group's network name, from its owner's address. Android requires - * `DIRECT-` and two characters at the front; the result is 18 bytes, under - * the 32 a network name may hold. + * The application's group name. Android requires `DIRECT-` and two + * characters at the front; the result is 18 bytes, under the 32 a network + * name may hold. */ - fun networkName(ownerAddress: String): String = - "DIRECT-op-" + hex(sha256("offline-protocol/wifi-direct/net|$ownerAddress"), 4) + fun networkName(appId: String): String = + "DIRECT-op-" + hex(sha256("offline-protocol/wifi-direct/net|$appId"), 4) /** The group's passphrase: 32 hex digits, inside WPA2's 8 to 63. */ fun passphrase(appId: String, network: String): String = diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt index d14a6513..2447312b 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt @@ -113,19 +113,32 @@ class WifiDirectManager( // failure up to the ceiling, so a forged or stale record cannot keep // the radio busy. A record not heard again within the TTL is dropped. private const val FORMATION_TICK_MS = 10_000L - private const val FORMATION_RETRY_MS = 20_000L + private const val FORMATION_RETRY_MS = 30_000L private const val FORMATION_MAX_BACKOFF_MS = 120_000L private const val ADVERT_TTL_MS = 180_000L // How often service discovery is asked again while there is anything // to find. Each call cancels and re-queues the supplicant's query and // restarts its search, so it is not asked on every step. private const val SERVICE_DISCOVERY_PERIOD_MS = 15_000L - // How long a group this manager formed may stay without clients. An - // owner answers no service discovery query, so an empty group is - // invisible to the peer it was made for (after either side restarts, - // say); dissolving it makes the owner discoverable again, and the next - // round forms the group with whoever is there. - private const val OWNER_IDLE_MS = 45_000L + // How long a group this manager formed may stay without clients, + // randomised up to twice this per group so that two owners that + // formed at once do not dissolve in step. Dissolved, the owner joins + // first, so two groups merge; and an owner answers no service + // discovery query, so an empty group also hides it from new peers. + private const val OWNER_IDLE_MS = 30_000L + // How long one join attempt may run before it is cancelled. A device + // that is joining answers no service discovery query, so a join left + // running hides the joiner from the very peer that has to hear it + // before it creates the group: on two phones that kept a pair apart + // for minutes. Cancelled, the joiner is discoverable again until the + // next attempt. + // Fifteen and not less: a join that has no fresh scan result scans for + // about six seconds before it associates, and the supplicant retries a + // rejected association; an eight-second window cut that retry off. + private const val JOIN_WINDOW_MS = 15_000L + // How often a device that has heard no record probes a nearby group + // owner by joining; see WifiDirectGroupFormation rule 4. + private const val OWNER_PROBE_PERIOD_MS = 60_000L } /** @@ -211,8 +224,16 @@ class WifiDirectManager( // which is the only kind of group formation ever dissolves. private var ownsFormationGroup = false private var ownerIdleSinceMs = 0L - private var advertNetwork: String? = null + private var failedJoins = 0 + // Any Wi-Fi Direct group owner in the peer list. An owner answers no + // service discovery query, so this is how a device that heard no record + // still learns there is a group it might join. + private var ownerNearby = false + private var ownerIdleLimitMs = OWNER_IDLE_MS + private var lastJoinAtMs = 0L + private var joinFirstRemaining = 0 private val appTag: String by lazy { WifiDirectGroupFormation.appTag(appId) } + private val appNetwork: String by lazy { WifiDirectGroupFormation.networkName(appId) } // State tracking. Volatile: written on the transport thread, read by the // socket thread that decides whether a client reconnects. @@ -656,6 +677,8 @@ class WifiDirectManager( wifiP2pManager?.requestPeers(channel) { peers -> val deviceList = peers?.deviceList ?: return@requestPeers + ownerNearby = deviceList.any { it.isGroupOwner } + // Update discovered peers val currentPeers = mutableSetOf() for (device in deviceList) { @@ -686,7 +709,11 @@ class WifiDirectManager( "connected" to connected )) inGroup = connected - if (connected) formationBackoffMs = FORMATION_RETRY_MS + if (connected) { + formationBackoffMs = FORMATION_RETRY_MS + failedJoins = 0 + joinFirstRemaining = 0 + } if (connected && hasRequiredPermissions()) { wifiP2pManager?.requestConnectionInfo(channel) { info -> @@ -726,7 +753,6 @@ class WifiDirectManager( createdNetwork = null ownsFormationGroup = false ownerIdleSinceMs = 0L - if (formGroups) publishAdvert(null) // Posted: this is the broadcast receiver's onReceive, and ending // an announced stream is an FFI call. Same reasoning as the post // in [handleWifiP2pStateChanged]. @@ -770,17 +796,13 @@ class WifiDirectManager( val advert = WifiDirectGroupFormation.parse( device.deviceAddress, txt, SystemClock.elapsedRealtime() ) ?: return@setDnsSdResponseListeners - val previous = adverts.put(device.deviceAddress, advert) - if (previous == null || previous.ownerNetwork != advert.ownerNetwork) { - emitDiagnostic("debug", "Wi-Fi Direct peer record", mapOf( - "address" to advert.address, - "owner" to (advert.ownerNetwork != null), - )) + if (adverts.put(device.deviceAddress, advert) == null) { + emitDiagnostic("debug", "Wi-Fi Direct peer record", mapOf("address" to advert.address)) } }, ) addServiceRequest() - publishAdvert(null) + publishAdvert() transportHandler.removeCallbacks(formationTick) transportHandler.post(formationTick) emitDiagnostic("info", "Wi-Fi Direct group formation started") @@ -828,7 +850,8 @@ class WifiDirectManager( inGroup = false ownerClients = 0 advertPublished = false - advertNetwork = null + failedJoins = 0 + ownerNearby = false } /** One step: refresh what is known, and act on it if the backoff allows. */ @@ -842,15 +865,20 @@ class WifiDirectManager( adverts.values.removeAll { now - it.seenAtMs > ADVERT_TTL_MS } val address = localAddressOrNull() ?: return - if (!advertPublished) publishAdvert(advertNetwork) + if (!advertPublished) publishAdvert() if (inGroup && isGroupOwner && ownsFormationGroup && ownerClients == 0) { if (ownerIdleSinceMs == 0L) { ownerIdleSinceMs = now - } else if (now - ownerIdleSinceMs > OWNER_IDLE_MS) { + ownerIdleLimitMs = OWNER_IDLE_MS + (Math.random() * OWNER_IDLE_MS).toLong() + } else if (now - ownerIdleSinceMs > ownerIdleLimitMs) { ownerIdleSinceMs = 0L - emitDiagnostic("info", "Dissolving an empty Wi-Fi Direct group so peers can find this device") + emitDiagnostic("info", "Dissolving an empty Wi-Fi Direct group") p2p.removeGroup(channel, quietListener("remove empty group")) + // Join before creating again: another owner's group may be up. + failedJoins = 0 + joinFirstRemaining = 2 + nextFormationAtMs = now + FORMATION_TICK_MS return } } else { @@ -878,22 +906,19 @@ class WifiDirectManager( } if (now < nextFormationAtMs) return val action = WifiDirectGroupFormation.decide( - WifiDirectGroupFormation.Local(address, appTag, inGroup, isGroupOwner, ownerClients), + WifiDirectGroupFormation.Local( + address, appTag, inGroup, failedJoins, ownerNearby, + ownerProbeDue = now - lastJoinAtMs >= OWNER_PROBE_PERIOD_MS, + joinFirst = joinFirstRemaining > 0, + ), adverts.values, ) when (action) { WifiDirectGroupFormation.Action.Wait -> return - WifiDirectGroupFormation.Action.Create -> createGroup(WifiDirectGroupFormation.networkName(address)) - is WifiDirectGroupFormation.Action.Join -> joinGroup(action.network) - is WifiDirectGroupFormation.Action.Move -> { - emitDiagnostic("info", "Leaving an empty Wi-Fi Direct group for a lower owner's") - p2p.removeGroup(channel, object : WifiP2pManager.ActionListener { - override fun onSuccess() { - createdNetwork = null - joinGroup(action.network) - } - override fun onFailure(reason: Int) = formationFailed("remove group", reason) - }) + WifiDirectGroupFormation.Action.Create -> createGroup(appNetwork) + WifiDirectGroupFormation.Action.Join -> { + lastJoinAtMs = now + joinGroup(appNetwork) } } nextFormationAtMs = now + formationBackoffMs @@ -929,7 +954,15 @@ class WifiDirectManager( val ch = channel ?: return emitDiagnostic("info", "Joining a Wi-Fi Direct group", mapOf("network" to network)) wifiP2pManager?.connect(ch, config, object : WifiP2pManager.ActionListener { - override fun onSuccess() {} + override fun onSuccess() { + transportHandler.postDelayed({ + if (state == TransportState.RUNNING && !inGroup) { + failedJoins++ + if (joinFirstRemaining > 0) joinFirstRemaining-- + wifiP2pManager?.cancelConnect(channel, quietListener("cancel join")) + } + }, JOIN_WINDOW_MS) + } override fun onFailure(reason: Int) = formationFailed("join group", reason) }) } @@ -944,10 +977,8 @@ class WifiDirectManager( } /** - * After any group change: count this owner's clients and say in the record - * whether this device owns a group others can join. Only a group whose name - * this device would derive is offered; a group formed from the system - * settings has a passphrase no joiner can compute. + * After any group change: count this owner's clients, and note whether the + * group is the application's, the only kind formation ever dissolves. */ @SuppressLint("MissingPermission") private fun refreshGroupForFormation() { @@ -955,33 +986,28 @@ class WifiDirectManager( if (!isGroupOwner) { ownerClients = 0 ownsFormationGroup = false - publishAdvert(null) return } wifiP2pManager?.requestGroupInfo(channel) { group -> ownerClients = group?.clientList?.size ?: 0 - val address = localAddressOrNull() - val ours = address?.let { WifiDirectGroupFormation.networkName(it) } - ownsFormationGroup = group?.networkName != null && group.networkName == ours - publishAdvert(group?.networkName?.takeIf { it == ours }) + ownsFormationGroup = group?.networkName == appNetwork } } @SuppressLint("MissingPermission") - private fun publishAdvert(ownerNetwork: String?) { + private fun publishAdvert() { if (!formGroups || !formationSupported) return val p2p = wifiP2pManager ?: return val address = localAddressOrNull() ?: return val info = WifiP2pDnsSdServiceInfo.newInstance( WifiDirectGroupFormation.INSTANCE_NAME, WifiDirectGroupFormation.SERVICE_TYPE, - WifiDirectGroupFormation.txtRecord(address, appTag, ownerNetwork), + WifiDirectGroupFormation.txtRecord(address, appTag), ) - advertNetwork = ownerNetwork val added = object : WifiP2pManager.ActionListener { override fun onSuccess() { advertPublished = true - emitDiagnostic("debug", "Wi-Fi Direct record published", mapOf("owner" to (ownerNetwork != null))) + emitDiagnostic("debug", "Wi-Fi Direct record published") } override fun onFailure(reason: Int) { advertPublished = false diff --git a/bindings/react-native/android/src/test/java/com/offlineprotocol/WifiDirectGroupFormationTest.kt b/bindings/react-native/android/src/test/java/com/offlineprotocol/WifiDirectGroupFormationTest.kt index f97dc48e..6b55b817 100644 --- a/bindings/react-native/android/src/test/java/com/offlineprotocol/WifiDirectGroupFormationTest.kt +++ b/bindings/react-native/android/src/test/java/com/offlineprotocol/WifiDirectGroupFormationTest.kt @@ -10,163 +10,152 @@ import org.junit.Assert.assertTrue import org.junit.Test /** - * [WifiDirectGroupFormation]: which device of an application owns the Wi-Fi - * Direct group, and the credentials it is formed and joined with. The manager's - * radio calls need a device (docs/bridges C9); the decision every device makes - * from the same records is pinned here, because two devices that disagree about - * it form two groups that never meet. + * [WifiDirectGroupFormation]: which device of an application creates its Wi-Fi + * Direct group, which join it, and the credentials. The manager's radio calls + * need a device (docs/bridges C9); the decisions are pinned here, including the + * asymmetric cases two phones produced: one hearing the other minutes before, + * or never. */ class WifiDirectGroupFormationTest { private val app = WifiDirectGroupFormation.appTag("com.example.app") - private fun advert(address: String, owner: String? = null, appTag: String = app) = - Advert(device = "mac-$address", address = address, app = appTag, ownerNetwork = owner, seenAtMs = 0) + private fun advert(address: String, appTag: String = app) = + Advert(device = "mac-$address", address = address, app = appTag, seenAtMs = 0) private fun local( address: String, inGroup: Boolean = false, - isOwner: Boolean = false, - clients: Int = 0, - ) = Local(address = address, app = app, inGroup = inGroup, isOwner = isOwner, clients = clients) + failedJoins: Int = 0, + ownerNearby: Boolean = false, + ) = Local(address, app, inGroup, failedJoins, ownerNearby) - // --- Who creates ------------------------------------------------------- + private fun decide(local: Local, vararg heard: String) = + WifiDirectGroupFormation.decide(local, heard.map { advert(it) }) + + // --- Who creates ---------------------------------------------------------- @Test - fun `alone, a device creates nothing`() { - assertEquals(Action.Wait, WifiDirectGroupFormation.decide(local("off1b"), emptyList())) + fun `alone, with no group owner nearby, a device does nothing`() { + assertEquals(Action.Wait, decide(local("off1b"))) } @Test - fun `of two devices with no group, the lower creates and the higher joins the group it will create`() { - assertEquals(Action.Create, WifiDirectGroupFormation.decide(local("off1a"), listOf(advert("off1b")))) - val lower = advert("off1a") - assertEquals( - Action.Join(WifiDirectGroupFormation.networkName("off1a"), lower), - WifiDirectGroupFormation.decide(local("off1b"), listOf(lower)), - ) + fun `the lowest address heard creates at once`() { + assertEquals(Action.Create, decide(local("off1a"), "off1b", "off1c")) + } + + @Test + fun `the lowest joins first when an owner is nearby, then creates if that found nothing`() { + assertEquals(Action.Join, decide(local("off1a", ownerNearby = true), "off1b")) + assertEquals(Action.Create, decide(local("off1a", ownerNearby = true, failedJoins = 1), "off1b")) } @Test - fun `the name the joiner expects is the name the owner creates`() { - // The owner creates under networkName(its own address); the joiner, - // which never hears the owner's record once it owns the group, joins - // networkName(the address it heard before). They must be one string. - val owner = "off1qxjee2yfec0twantxrm78pt9nzfhxjrr8q0p63fd" - val joined = WifiDirectGroupFormation.decide(local("off1z"), listOf(advert(owner))) - assertEquals(WifiDirectGroupFormation.networkName(owner), (joined as Action.Join).network) + fun `a higher address joins the group the lower one creates`() { + assertEquals(Action.Join, decide(local("off1b"), "off1a")) } @Test - fun `the lowest of three creates, and the other two join it`() { + fun `of three that hear each other, exactly one creates`() { val all = listOf("off1a", "off1b", "off1c") - val actions = all.associateWith { me -> - WifiDirectGroupFormation.decide(local(me), all.filter { it != me }.map { advert(it) }) - } - assertEquals(Action.Create, actions["off1a"]) - val expected = WifiDirectGroupFormation.networkName("off1a") - assertEquals(expected, (actions["off1b"] as Action.Join).network) - assertEquals(expected, (actions["off1c"] as Action.Join).network) + val actions = all.associateWith { me -> decide(local(me), *all.filter { it != me }.toTypedArray()) } + assertEquals(mapOf("off1a" to Action.Create, "off1b" to Action.Join, "off1c" to Action.Join), actions) } - // --- Who joins --------------------------------------------------------- + // --- Asymmetric hearing ------------------------------------------------------ @Test - fun `a device in no group joins an owner it sees, whatever the addresses`() { - val owner = advert("off1z", owner = "DIRECT-op-11111111") - assertEquals( - Action.Join("DIRECT-op-11111111", owner), - WifiDirectGroupFormation.decide(local("off1a"), listOf(owner)), - ) + fun `a device that heard a lower peer takes over after its joins find no group`() { + // The phones' failure: B heard A, A never heard B, so A never created. + val heardLower = local("off1b", failedJoins = WifiDirectGroupFormation.TAKEOVER_FAILED_JOINS - 1) + assertEquals(Action.Join, decide(heardLower, "off1a")) + val takeover = local("off1b", failedJoins = WifiDirectGroupFormation.TAKEOVER_FAILED_JOINS) + assertEquals(Action.Create, decide(takeover, "off1a")) } @Test - fun `of two owners in sight, a newcomer joins the lower`() { - val low = advert("off1b", owner = "DIRECT-op-b0000000") - val high = advert("off1c", owner = "DIRECT-op-c0000000") - assertEquals( - Action.Join("DIRECT-op-b0000000", low), - WifiDirectGroupFormation.decide(local("off1z"), listOf(high, low)), - ) + fun `a device that heard nothing still joins a group owner it can see`() { + // A group owner answers no service discovery query, so the record of + // the device that created the group may never arrive. + assertEquals(Action.Join, decide(local("off1a", ownerNearby = true))) + // It never creates on that alone: an owner nearby may be a television. + assertEquals(Action.Join, decide(local("off1a", ownerNearby = true, failedJoins = 9))) } - // --- Merging and staying ----------------------------------------------- + @Test + fun `a join on an owner alone is a paced probe, not every step`() { + // Televisions are group owners too; joining on every step kept two + // phones deaf to each other's records for minutes. + val notDue = Local("off1a", app, inGroup = false, ownerNearby = true, ownerProbeDue = false) + assertEquals(Action.Wait, WifiDirectGroupFormation.decide(notDue, emptyList())) + // A record heard is not paced: that peer is real. + assertEquals(Action.Join, WifiDirectGroupFormation.decide(notDue.copy(address = "off1b"), listOf(advert("off1a")))) + } @Test - fun `an empty owner that sees a lower owner moves to it`() { - val lower = advert("off1a", owner = "DIRECT-op-a0000000") + fun `the group name needs nothing heard from its owner`() { + // Joiner and creator derive one name from the application alone. assertEquals( - Action.Move("DIRECT-op-a0000000", lower), - WifiDirectGroupFormation.decide(local("off1b", inGroup = true, isOwner = true), listOf(lower)), + WifiDirectGroupFormation.networkName("com.example.app"), + WifiDirectGroupFormation.networkName("com.example.app"), + ) + assertNotEquals( + WifiDirectGroupFormation.networkName("com.example.app"), + WifiDirectGroupFormation.networkName("com.other.app"), ) } @Test - fun `two empty owners that formed at once end in one group`() { - val a = advert("off1a", owner = "DIRECT-op-a0000000") - val b = advert("off1b", owner = "DIRECT-op-b0000000") - val aAction = WifiDirectGroupFormation.decide(local("off1a", inGroup = true, isOwner = true), listOf(b)) - val bAction = WifiDirectGroupFormation.decide(local("off1b", inGroup = true, isOwner = true), listOf(a)) - assertEquals(Action.Wait, aAction) - assertEquals(Action.Move("DIRECT-op-a0000000", a), bAction) + fun `after dissolving its group a device joins before it may create again`() { + // Two phones created at once, dissolved in step, and created again. + val lowestAfterDissolve = Local("off1a", app, inGroup = false, joinFirst = true) + assertEquals(Action.Join, WifiDirectGroupFormation.decide(lowestAfterDissolve, listOf(advert("off1b")))) + assertEquals(Action.Create, WifiDirectGroupFormation.decide(lowestAfterDissolve.copy(joinFirst = false), listOf(advert("off1b")))) } - @Test - fun `an owner with clients never moves`() { - val lower = advert("off1a", owner = "DIRECT-op-a0000000") - assertEquals( - Action.Wait, - WifiDirectGroupFormation.decide(local("off1b", inGroup = true, isOwner = true, clients = 1), listOf(lower)), - ) - } + // --- In a group ---------------------------------------------------------------- @Test - fun `a client never acts, including in a group someone else formed`() { - val lower = advert("off1a", owner = "DIRECT-op-a0000000") - assertEquals( - Action.Wait, - WifiDirectGroupFormation.decide(local("off1b", inGroup = true, isOwner = false), listOf(lower)), - ) + fun `a device in a group does nothing here`() { + assertEquals(Action.Wait, decide(local("off1a", inGroup = true), "off1b")) + assertEquals(Action.Wait, decide(local("off1z", inGroup = true, failedJoins = 9, ownerNearby = true), "off1a")) } - // --- What is ignored --------------------------------------------------- + // --- What is ignored ------------------------------------------------------------- @Test fun `devices of another application are invisible`() { val other = WifiDirectGroupFormation.appTag("com.other.app") assertNotEquals(app, other) - val adverts = listOf(advert("off1a", owner = "DIRECT-op-a0000000", appTag = other), advert("off1b", appTag = other)) + val adverts = listOf(advert("off1a", appTag = other)) assertEquals(Action.Wait, WifiDirectGroupFormation.decide(local("off1z"), adverts)) } @Test fun `a record naming this device's own address is not a peer`() { - assertEquals(Action.Wait, WifiDirectGroupFormation.decide(local("off1b"), listOf(advert("off1b")))) + assertEquals(Action.Wait, decide(local("off1b"), "off1b")) } - // --- The record ---------------------------------------------------------- + // --- The record -------------------------------------------------------------------- @Test fun `the record starts with txtvers and carries the address, as the stream chapter requires`() { - val txt = WifiDirectGroupFormation.txtRecord("off1q", app, null) + val txt = WifiDirectGroupFormation.txtRecord("off1q", app) assertEquals(listOf("txtvers", "addr", "app"), txt.keys.toList()) assertEquals("1", txt["txtvers"]) assertEquals("off1q", txt["addr"]) - assertTrue("net" in WifiDirectGroupFormation.txtRecord("off1q", app, "DIRECT-op-12345678")) } @Test - fun `a record round-trips, and an owner's network name comes back`() { - val txt = WifiDirectGroupFormation.txtRecord("off1q", app, "DIRECT-op-12345678") - assertEquals( - Advert("mac", "off1q", app, "DIRECT-op-12345678", 7), - WifiDirectGroupFormation.parse("mac", txt, 7), - ) + fun `a record round-trips`() { + val txt = WifiDirectGroupFormation.txtRecord("off1q", app) + assertEquals(Advert("mac", "off1q", app, 7), WifiDirectGroupFormation.parse("mac", txt, 7)) } @Test fun `records that are not peer records are refused`() { - val good = WifiDirectGroupFormation.txtRecord("off1q", app, null) + val good = WifiDirectGroupFormation.txtRecord("off1q", app) assertNull(WifiDirectGroupFormation.parse("mac", good + ("txtvers" to "2"), 0)) assertNull(WifiDirectGroupFormation.parse("mac", good - "addr", 0)) assertNull(WifiDirectGroupFormation.parse("mac", good + ("addr" to ""), 0)) @@ -175,44 +164,29 @@ class WifiDirectGroupFormationTest { assertNull(WifiDirectGroupFormation.parse("mac", good + ("sid" to "printer"), 0)) } - @Test - fun `a network name Android would refuse is read as no group`() { - val txt = WifiDirectGroupFormation.txtRecord("off1q", app, null) + ("net" to "not-a-direct-name") - assertNull(WifiDirectGroupFormation.parse("mac", txt, 0)?.ownerNetwork) - } - - // --- The credentials ----------------------------------------------------- + // --- The credentials ------------------------------------------------------------- @Test fun `the network name is one Android accepts`() { - val name = WifiDirectGroupFormation.networkName("off1qxjee2yfec0twantxrm78pt9nzfhxjrr8q0p63fd") + val name = WifiDirectGroupFormation.networkName("com.example.app") assertTrue(name, WifiDirectGroupFormation.isValidNetworkName(name)) assertTrue(name.toByteArray().size <= 32) } @Test fun `the passphrase is WPA2-sized and the same on both ends`() { - val name = WifiDirectGroupFormation.networkName("off1a") + val name = WifiDirectGroupFormation.networkName("com.example.app") val owner = WifiDirectGroupFormation.passphrase("com.example.app", name) - val joiner = WifiDirectGroupFormation.passphrase("com.example.app", name) - assertEquals(owner, joiner) + assertEquals(owner, WifiDirectGroupFormation.passphrase("com.example.app", name)) assertTrue(owner.length in 8..63) } @Test - fun `another application derives another passphrase for the same group`() { - val name = WifiDirectGroupFormation.networkName("off1a") + fun `another application derives another passphrase`() { + val name = WifiDirectGroupFormation.networkName("com.example.app") assertNotEquals( WifiDirectGroupFormation.passphrase("com.example.app", name), WifiDirectGroupFormation.passphrase("com.other.app", name), ) } - - @Test - fun `different owners get different network names`() { - assertNotEquals( - WifiDirectGroupFormation.networkName("off1a"), - WifiDirectGroupFormation.networkName("off1b"), - ) - } } diff --git a/docs/bridges/kotlin.md b/docs/bridges/kotlin.md index 890a84bd..a2b85d9a 100644 --- a/docs/bridges/kotlin.md +++ b/docs/bridges/kotlin.md @@ -140,13 +140,18 @@ the new owner's first dial can lose to the old stream still closing, and the redial is then the only one left. The manager joins a group the system formed, including one that formed before it started: since Android 10 the connection broadcast is not sticky, so the manager asks for the group once it is running. With `formGroups` (`wifiDirect.autoAccept`, Android 10 and later) -it also forms one: `WifiDirectGroupFormation` holds the rules (who creates, who joins, the derived -network name and passphrase) and `WifiDirectGroupFormationTest` pins them. Two behaviours of the -radio shaped them and are worth knowing before changing them. A DNS-SD query by service type alone -returns only the PTR record, so the TXT record that carries the address arrives only for a query that -names the instance. And a group owner answers no service discovery query, so a joiner targets the -name the lowest peer will create rather than waiting to hear the owner's record, and an owner whose -group stays empty dissolves it so it can be found again. +it also forms one: `WifiDirectGroupFormation` holds the rules (who creates, who joins, the network +name and passphrase, both derived from the application id) and `WifiDirectGroupFormationTest` pins +them. Three behaviours of the radio shaped them and are worth knowing before changing them. A DNS-SD +query by service type alone returns only the PTR record, so the TXT record that carries the address +arrives only for a query that names the instance. A device that owns a group, or is in the middle of +joining one, answers no service discovery query, so two phones often hear each other minutes apart or +not at all; that is why the group is named after the application and not its owner (a joiner needs +nothing from the owner), why a join attempt is cancelled after eight seconds (the joiner is +discoverable again), why a device that hears a lower peer creates the group itself after three joins +found none, and why a device that heard no record still joins when it sees a group owner. And two +groups can form at once, so an owner whose group stays empty for a randomised 30 to 60 seconds +dissolves it and joins before creating again. `PeerStreamSocketsTest` and `PeerStreamFramingTest` pin it, the latter replaying the chapter's vectors. The group handling itself is not covered in diff --git a/docs/spec/stream-framing.md b/docs/spec/stream-framing.md index c321822d..53446a82 100644 --- a/docs/spec/stream-framing.md +++ b/docs/spec/stream-framing.md @@ -264,9 +264,8 @@ wrong. On Android, Wi-Fi Direct carries the same record over Wi-Fi P2P service discovery when an application lets the SDK form the group -(`wifiDirect.autoAccept`). The record adds two entries for that use: `app`, a -tag of the application id that keeps applications' devices apart, and `net`, -present only while the advertiser owns a group, naming it. The `addr` entry +(`wifiDirect.autoAccept`). The record adds one entry for that use: `app`, a +tag of the application id that keeps applications' devices apart. The `addr` entry keeps its meaning here: the group the record leads to is only a link, and the stream opened over it proves its peer like any other. diff --git a/docs/transport-architecture.md b/docs/transport-architecture.md index 3a5ff552..ecb0f33a 100644 --- a/docs/transport-architecture.md +++ b/docs/transport-architecture.md @@ -174,9 +174,9 @@ transport. With `wifiDirect.autoAccept` on Android 10 and later, the manager forms the group itself: devices of one application find each other over Wi-Fi P2P service discovery (`_offlineprotocol._tcp`), the lowest address creates a - group under a name derived from its address, and the others join it with a - passphrase derived from the application id, so neither phone shows a system - dialog. Without it, a group formed from the system's Wi-Fi Direct settings + group whose name and passphrase are derived from the application id, and the + others join it, so neither phone shows a system dialog and a joiner needs + nothing from the owner. Without it, a group formed from the system's Wi-Fi Direct settings (or by another app) is used. Either way the group is joined when `WIFI_P2P_CONNECTION_CHANGED_ACTION` reports it, or at start when it already exists (the broadcast is not sticky since Android 10), and a client From 2fd94bd4ce920848bd509b6fd651f352d8efb47e Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Tue, 6 Oct 2026 11:10:19 +0530 Subject: [PATCH 4/9] fix(bindings): keep Wi-Fi Direct formation on across re-enables, undo it on stop Calling enableTransport('wifiDirect') with no config, which the integration guide tells apps to do once a permission is granted, turned group formation *off*. A config that didn't name autoAccept was read as false. Then it got worse. stop() decided whether to clean up by checking the flag that had just been cleared, so it skipped removing the group this run had created. The device kept owning an empty app-named group, and an owner answers no service discovery, so its peers could no longer find it. Confusion ensues. So a config that doesn't name the key now keeps the current setting, and it is applied after the stop. stop() undoes what the run actually registered and created, no matter what the flag says by then. While at it: - The join timeout is a named runnable that stop() removes. A timer left over from the previous run could otherwise cancel the next run's join and count a failure it never saw. - A join the framework refuses outright (BUSY, say) now counts toward the takeover. Before, a device whose joins kept failing never got to create the group itself. - A device alone stops working the radio as hard. Service discovery backs off from 15 to 60 s while nothing is heard, and the owner probe from one to four minutes. A Wi-Fi Direct printer or TV is a group owner no probe will ever join, and probing one every minute forever is not great. Both reset when a record, a new owner or a new device shows up. - The passphrase comment no longer claims a forger can't get in. Anyone who knows the app id can compute the passphrase. --- .../offlineprotocol/OfflineProtocolModule.kt | 10 +- .../WifiDirectGroupFormation.kt | 65 +++++++- .../com/offlineprotocol/WifiDirectManager.kt | 149 +++++++++++++----- .../WifiDirectGroupFormationTest.kt | 31 ++++ 4 files changed, 209 insertions(+), 46 deletions(-) diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/OfflineProtocolModule.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/OfflineProtocolModule.kt index e0e27edd..93d4da5c 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/OfflineProtocolModule.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/OfflineProtocolModule.kt @@ -2565,13 +2565,19 @@ class OfflineProtocolModule(reactContext: ReactApplicationContext) : ?: throw IllegalStateException("Failed to create WiFi Direct manager") // `autoAccept` is the documented switch for forming groups // without the system settings; see WifiDirectGroupFormation. - manager.formGroups = config?.takeIf { it.hasKey("autoAccept") && !it.isNull("autoAccept") } - ?.getBoolean("autoAccept") ?: false + // Read before the stop, applied after it: stop() undoes the + // run that is ending, and a config that does not name the + // key (the guide's enableTransport('wifiDirect') after a + // permission grant) keeps the current setting. + val autoAccept = config?.takeIf { it.hasKey("autoAccept") && !it.isNull("autoAccept") } + ?.getBoolean("autoAccept") // Stop the manager first if it's running (to ensure clean restart) if (manager.state == TransportState.RUNNING) { manager.stop() } + manager.formGroups = + WifiDirectGroupFormation.formGroupsAfterEnable(manager.formGroups, autoAccept) manager.start() emitDiagnostic("info", "WiFi Direct transport enabled") diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectGroupFormation.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectGroupFormation.kt index 5c5bbdea..904539a2 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectGroupFormation.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectGroupFormation.kt @@ -15,13 +15,15 @@ import java.security.MessageDigest * created with a chosen network name and passphrase, and joined by anyone who * presents the same two, with no prompt on either side. Both are derived here * from the application id alone, so every device of an application can join - * its group without having heard who owns it, and a device of another - * application never computes them. + * its group without having heard who owns it. * - * The passphrase keeps other applications' devices out of the group; it is not - * what protects the traffic. Every stream still proves its peer with the - * identity preamble before it carries anything, and every message is end to - * end encrypted above it. + * The passphrase is not a secret. The application id ships inside every copy + * of the application, so anyone who knows it computes the passphrase, and can + * join the group or run one under its name (threat model R22). What the + * passphrase does is keep honest devices of other applications out of each + * other's groups. It is not what protects the traffic: every stream still + * proves its peer with the identity preamble before it carries anything, and + * every message is end to end encrypted above it. * * ## Why the name is the application's and not the owner's * @@ -55,8 +57,20 @@ import java.security.MessageDigest * again ([Local.joinFirst]), so two groups that formed at once merge. * * A record proves nothing about its advertiser (the stream chapter's rule), and - * none is needed: the worst a forged record does is make this device create or - * join a group, which a forger cannot enter without the passphrase. + * none is needed: the worst a forged record does is steer whether this device + * creates or joins, and the worst a group run by a forger does is carry no + * traffic, since every stream over it still proves its peer (threat model R22). + * + * ## Quiet surroundings cost little + * + * A device alone keeps looking, but less often the longer it hears nothing: + * service discovery backs off from [DISCOVERY_PERIOD_MS] to + * [DISCOVERY_MAX_PERIOD_MS] ([discoveryPeriodMs]), and a probe toward an owner + * nobody vouched for backs off from [PROBE_PERIOD_MS] to [PROBE_MAX_PERIOD_MS] + * ([probePeriodMs]). A Wi-Fi Direct printer or television is an owner whose + * group a probe never joins; without the backoff a device next to one probed + * it every minute for as long as the application was open. Both start over + * when something new appears: a record, a new owner, or a new device. */ internal object WifiDirectGroupFormation { const val SERVICE_TYPE = "_offlineprotocol._tcp" @@ -72,6 +86,15 @@ internal object WifiDirectGroupFormation { /** How many joins that found no group before a non-lowest device creates. */ const val TAKEOVER_FAILED_JOINS = 3 + /** Service discovery period while records keep arriving. */ + const val DISCOVERY_PERIOD_MS = 15_000L + /** Service discovery period after a long run of rounds that heard nothing. */ + const val DISCOVERY_MAX_PERIOD_MS = 60_000L + /** Probe period toward an owner nobody vouched for, before any probe failed. */ + const val PROBE_PERIOD_MS = 60_000L + /** Probe period after a long run of probes that found no group. */ + const val PROBE_MAX_PERIOD_MS = 240_000L + /** A peer's record as last seen. */ data class Advert( val device: String, @@ -118,6 +141,32 @@ internal object WifiDirectGroupFormation { } } + /** The discovery period after [quietRounds] rounds in a row that heard no record. */ + fun discoveryPeriodMs(quietRounds: Int): Long = + doubled(DISCOVERY_PERIOD_MS, quietRounds, DISCOVERY_MAX_PERIOD_MS) + + /** The probe period after [failedProbes] probes in a row that found no group. */ + fun probePeriodMs(failedProbes: Int): Long = + doubled(PROBE_PERIOD_MS, failedProbes, PROBE_MAX_PERIOD_MS) + + /** + * Whether formation is on after an enable. A configuration that does not + * name `autoAccept` keeps the current setting: the integration guide's + * `enableTransport('wifiDirect')` after a permission grant passes none, and + * reading that as "off" turned formation off for the rest of the session. + */ + fun formGroupsAfterEnable(current: Boolean, configured: Boolean?): Boolean = + configured ?: current + + private fun doubled(base: Long, times: Int, ceiling: Long): Long { + var period = base + repeat(times.coerceAtLeast(0)) { + if (period >= ceiling) return ceiling + period *= 2 + } + return minOf(period, ceiling) + } + /** The record this device advertises, in the order the chapter requires. */ fun txtRecord(address: String, appTag: String): LinkedHashMap { val txt = LinkedHashMap() diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt index 2447312b..97b9c1a5 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt @@ -116,10 +116,10 @@ class WifiDirectManager( private const val FORMATION_RETRY_MS = 30_000L private const val FORMATION_MAX_BACKOFF_MS = 120_000L private const val ADVERT_TTL_MS = 180_000L - // How often service discovery is asked again while there is anything - // to find. Each call cancels and re-queues the supplicant's query and - // restarts its search, so it is not asked on every step. - private const val SERVICE_DISCOVERY_PERIOD_MS = 15_000L + // How often service discovery is asked again is + // WifiDirectGroupFormation.discoveryPeriodMs: each call cancels and + // re-queues the supplicant's query and restarts its search, so it is + // not asked on every step, and less often the longer it hears nothing. // How long a group this manager formed may stay without clients, // randomised up to twice this per group so that two owners that // formed at once do not dissolve in step. Dissolved, the owner joins @@ -136,15 +136,14 @@ class WifiDirectManager( // about six seconds before it associates, and the supplicant retries a // rejected association; an eight-second window cut that retry off. private const val JOIN_WINDOW_MS = 15_000L - // How often a device that has heard no record probes a nearby group - // owner by joining; see WifiDirectGroupFormation rule 4. - private const val OWNER_PROBE_PERIOD_MS = 60_000L } /** * Whether this manager forms and joins groups itself (Android 10 and * later), from `wifiDirect.autoAccept`. Off by default: then a group comes - * from the system settings, as before. Set before [start]. + * from the system settings, as before. Set before [start], and only while + * stopped: [stop] undoes what the run did whatever this reads by then, + * but [start] reads it once. */ @Volatile var formGroups: Boolean = false @@ -232,6 +231,20 @@ class WifiDirectManager( private var ownerIdleLimitMs = OWNER_IDLE_MS private var lastJoinAtMs = 0L private var joinFirstRemaining = 0 + // True while this run has a service request and a local service + // registered, so stop() clears exactly what start registered, whatever + // [formGroups] says by then. + private var formationRegistered = false + // Discovery backoff: rounds in a row that heard no record, and whether a + // record arrived since the last round was asked. + private var quietDiscoveryRounds = 0 + private var heardSinceLastDiscovery = true + // Probe backoff: probes in a row toward an owner nobody vouched for that + // found no group, whether the join in flight is one, and the owners seen + // last, so a new owner starts the backoff over. + private var failedProbes = 0 + private var joinIsProbe = false + private var nearbyOwners: Set = emptySet() private val appTag: String by lazy { WifiDirectGroupFormation.appTag(appId) } private val appNetwork: String by lazy { WifiDirectGroupFormation.networkName(appId) } @@ -677,7 +690,11 @@ class WifiDirectManager( wifiP2pManager?.requestPeers(channel) { peers -> val deviceList = peers?.deviceList ?: return@requestPeers - ownerNearby = deviceList.any { it.isGroupOwner } + val owners = deviceList.filter { it.isGroupOwner }.map { it.deviceAddress }.toSet() + ownerNearby = owners.isNotEmpty() + // A new owner may be this application's group: probe it at once. + if ((owners - nearbyOwners).isNotEmpty()) failedProbes = 0 + nearbyOwners = owners // Update discovered peers val currentPeers = mutableSetOf() @@ -685,6 +702,8 @@ class WifiDirectManager( currentPeers.add(device.deviceAddress) if (!discoveredPeers.containsKey(device.deviceAddress)) { discoveredPeers[device.deviceAddress] = device + // Someone new is in range: ask for records at full rate. + quietDiscoveryRounds = 0 emitDiagnostic("info", "Discovered peer", mapOf( "name" to device.deviceName, "address" to device.deviceAddress @@ -710,9 +729,11 @@ class WifiDirectManager( )) inGroup = connected if (connected) { + transportHandler.removeCallbacks(joinWindowTimeout) formationBackoffMs = FORMATION_RETRY_MS failedJoins = 0 joinFirstRemaining = 0 + failedProbes = 0 } if (connected && hasRequiredPermissions()) { @@ -796,11 +817,16 @@ class WifiDirectManager( val advert = WifiDirectGroupFormation.parse( device.deviceAddress, txt, SystemClock.elapsedRealtime() ) ?: return@setDnsSdResponseListeners + heardSinceLastDiscovery = true + quietDiscoveryRounds = 0 if (adverts.put(device.deviceAddress, advert) == null) { emitDiagnostic("debug", "Wi-Fi Direct peer record", mapOf("address" to advert.address)) } }, ) + formationRegistered = true + heardSinceLastDiscovery = true + quietDiscoveryRounds = 0 addServiceRequest() publishAdvert() transportHandler.removeCallbacks(formationTick) @@ -832,26 +858,48 @@ class WifiDirectManager( addServiceRequest() advertPublished = false nextServiceDiscoveryAtMs = 0L + quietDiscoveryRounds = 0 + heardSinceLastDiscovery = true emitDiagnostic("info", "Wi-Fi Direct group formation resumed") } + /** + * Undoes what this run did, decided by what it did and never by + * [formGroups]: the flag can change between [start] and here, and a check + * on it once left a created group up (and the device hidden behind it) + * after formation was switched off. + */ @SuppressLint("MissingPermission") private fun stopGroupFormation() { transportHandler.removeCallbacks(formationTick) - if (!formGroups || !formationSupported) return - val p2p = wifiP2pManager ?: return - p2p.clearServiceRequests(channel, quietListener("clear service requests")) - p2p.clearLocalServices(channel, quietListener("clear local services")) - // A group this manager made goes with it. One the system formed, or one - // adopted at start, is not this run's to remove. - if (createdNetwork != null) p2p.removeGroup(channel, quietListener("remove group")) + // A join timeout from this run would otherwise land on the next run's + // channel, count a failure it did not see, and cancel its join. + transportHandler.removeCallbacks(joinWindowTimeout) + wifiP2pManager?.let { p2p -> + if (formationRegistered) { + p2p.clearServiceRequests(channel, quietListener("clear service requests")) + p2p.clearLocalServices(channel, quietListener("clear local services")) + } + // A group this manager made goes with it. One the system formed, or + // one adopted at start, is not this run's to remove. + if (createdNetwork != null) p2p.removeGroup(channel, quietListener("remove group")) + } + formationRegistered = false createdNetwork = null + ownsFormationGroup = false + ownerIdleSinceMs = 0L adverts.clear() inGroup = false ownerClients = 0 advertPublished = false failedJoins = 0 + joinFirstRemaining = 0 + joinIsProbe = false + failedProbes = 0 + quietDiscoveryRounds = 0 + heardSinceLastDiscovery = true ownerNearby = false + nearbyOwners = emptySet() } /** One step: refresh what is known, and act on it if the backoff allows. */ @@ -891,7 +939,11 @@ class WifiDirectManager( // second pending scan. val settled = inGroup && (!isGroupOwner || ownerClients > 0) if (!settled && now >= nextServiceDiscoveryAtMs) { - nextServiceDiscoveryAtMs = now + SERVICE_DISCOVERY_PERIOD_MS + // The round that just ended heard nothing: wait longer next time. + if (!heardSinceLastDiscovery) quietDiscoveryRounds++ + heardSinceLastDiscovery = false + nextServiceDiscoveryAtMs = + now + WifiDirectGroupFormation.discoveryPeriodMs(quietDiscoveryRounds) p2p.discoverServices(channel, object : WifiP2pManager.ActionListener { override fun onSuccess() {} override fun onFailure(reason: Int) { @@ -908,7 +960,8 @@ class WifiDirectManager( val action = WifiDirectGroupFormation.decide( WifiDirectGroupFormation.Local( address, appTag, inGroup, failedJoins, ownerNearby, - ownerProbeDue = now - lastJoinAtMs >= OWNER_PROBE_PERIOD_MS, + ownerProbeDue = now - lastJoinAtMs >= + WifiDirectGroupFormation.probePeriodMs(failedProbes), joinFirst = joinFirstRemaining > 0, ), adverts.values, @@ -918,21 +971,31 @@ class WifiDirectManager( WifiDirectGroupFormation.Action.Create -> createGroup(appNetwork) WifiDirectGroupFormation.Action.Join -> { lastJoinAtMs = now + // A join with no record of this application heard is a probe + // toward an owner nobody vouched for (rule 4). + joinIsProbe = adverts.values.none { it.app == appTag && it.address != address } joinGroup(appNetwork) } } nextFormationAtMs = now + formationBackoffMs } - @SuppressLint("MissingPermission") - private fun createGroup(network: String) { - // Here and not only in the caller, so lint's NewApi check sees it. - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return - val config = WifiP2pConfig.Builder() + /** The application's group, for creating it and for joining it alike. */ + private fun formationConfig(network: String): WifiP2pConfig? { + // In-function, like the callers', so lint's NewApi check sees it. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return null + return WifiP2pConfig.Builder() .setNetworkName(network) .setPassphrase(WifiDirectGroupFormation.passphrase(appId, network)) .enablePersistentMode(false) .build() + } + + @SuppressLint("MissingPermission") + private fun createGroup(network: String) { + // Here and not only in the caller, so lint's NewApi check sees it. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return + val config = formationConfig(network) ?: return val ch = channel ?: return emitDiagnostic("info", "Creating a Wi-Fi Direct group", mapOf("network" to network)) wifiP2pManager?.createGroup(ch, config, object : WifiP2pManager.ActionListener { @@ -946,27 +1009,41 @@ class WifiDirectManager( @SuppressLint("MissingPermission") private fun joinGroup(network: String) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return - val config = WifiP2pConfig.Builder() - .setNetworkName(network) - .setPassphrase(WifiDirectGroupFormation.passphrase(appId, network)) - .enablePersistentMode(false) - .build() + val config = formationConfig(network) ?: return val ch = channel ?: return emitDiagnostic("info", "Joining a Wi-Fi Direct group", mapOf("network" to network)) wifiP2pManager?.connect(ch, config, object : WifiP2pManager.ActionListener { override fun onSuccess() { - transportHandler.postDelayed({ - if (state == TransportState.RUNNING && !inGroup) { - failedJoins++ - if (joinFirstRemaining > 0) joinFirstRemaining-- - wifiP2pManager?.cancelConnect(channel, quietListener("cancel join")) - } - }, JOIN_WINDOW_MS) + transportHandler.removeCallbacks(joinWindowTimeout) + transportHandler.postDelayed(joinWindowTimeout, JOIN_WINDOW_MS) + } + override fun onFailure(reason: Int) { + // A join the framework refused outright found no group either. + // Counted here as well as on the timeout, or a device whose + // joins keep failing BUSY never reaches the takeover. + joinFoundNoGroup() + formationFailed("join group", reason) } - override fun onFailure(reason: Int) = formationFailed("join group", reason) }) } + // A named field, not a lambda per join, so stopGroupFormation can remove + // it. See JOIN_WINDOW_MS. + private val joinWindowTimeout = Runnable { + if (state == TransportState.RUNNING && !inGroup) { + joinFoundNoGroup() + wifiP2pManager?.cancelConnect(channel, quietListener("cancel join")) + } + } + + /** A join ended without a group, by timeout or by refusal. */ + private fun joinFoundNoGroup() { + failedJoins++ + if (joinFirstRemaining > 0) joinFirstRemaining-- + if (joinIsProbe) failedProbes++ + joinIsProbe = false + } + private fun formationFailed(what: String, reason: Int) { formationBackoffMs = minOf(formationBackoffMs * 2, FORMATION_MAX_BACKOFF_MS) nextFormationAtMs = SystemClock.elapsedRealtime() + formationBackoffMs diff --git a/bindings/react-native/android/src/test/java/com/offlineprotocol/WifiDirectGroupFormationTest.kt b/bindings/react-native/android/src/test/java/com/offlineprotocol/WifiDirectGroupFormationTest.kt index 6b55b817..e90f39ae 100644 --- a/bindings/react-native/android/src/test/java/com/offlineprotocol/WifiDirectGroupFormationTest.kt +++ b/bindings/react-native/android/src/test/java/com/offlineprotocol/WifiDirectGroupFormationTest.kt @@ -189,4 +189,35 @@ class WifiDirectGroupFormationTest { WifiDirectGroupFormation.passphrase("com.other.app", name), ) } + + // --- Quiet surroundings ------------------------------------------------------ + + @Test + fun `discovery backs off while nothing is heard, to a ceiling`() { + assertEquals(15_000L, WifiDirectGroupFormation.discoveryPeriodMs(0)) + assertEquals(30_000L, WifiDirectGroupFormation.discoveryPeriodMs(1)) + assertEquals(60_000L, WifiDirectGroupFormation.discoveryPeriodMs(2)) + assertEquals(60_000L, WifiDirectGroupFormation.discoveryPeriodMs(50)) + } + + @Test + fun `probes toward an owner nobody vouched for back off, to a ceiling`() { + // A printer or television owns a group no probe ever joins. + assertEquals(60_000L, WifiDirectGroupFormation.probePeriodMs(0)) + assertEquals(120_000L, WifiDirectGroupFormation.probePeriodMs(1)) + assertEquals(240_000L, WifiDirectGroupFormation.probePeriodMs(2)) + assertEquals(240_000L, WifiDirectGroupFormation.probePeriodMs(1_000)) + assertEquals(60_000L, WifiDirectGroupFormation.probePeriodMs(-1)) + } + + // --- The switch ---------------------------------------------------------------- + + @Test + fun `an enable that does not name autoAccept keeps the current setting`() { + // enableTransport('wifiDirect') after a permission grant passes no config. + assertTrue(WifiDirectGroupFormation.formGroupsAfterEnable(current = true, configured = null)) + assertEquals(false, WifiDirectGroupFormation.formGroupsAfterEnable(current = false, configured = null)) + assertEquals(false, WifiDirectGroupFormation.formGroupsAfterEnable(current = true, configured = false)) + assertTrue(WifiDirectGroupFormation.formGroupsAfterEnable(current = false, configured = true)) + } } From f1127395cc5aa7aab8b78506f47c00dc9843bfdf Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Tue, 6 Oct 2026 11:10:19 +0530 Subject: [PATCH 5/9] docs: correct the Wi-Fi Direct formation docs and record its residual risk The bridge doc and the CHANGELOG still described the first design: an eight-second join window (the code uses fifteen) and an unthrottled join toward any group owner (the code probes once a minute). These are the docs you're told to read before changing the rules, so they had better be right. The integration guide's example set autoAccept: true back when the option did nothing. Apps that copied it start forming groups on upgrade, and the CHANGELOG now says so instead of claiming nothing changes. groupOwnerIntent is marked deprecated everywhere it is still documented as working. The group passphrase is derived from the app id, which ships inside every copy of the app, so it is not a secret. Add R22 to the threat model: who can join or squat on the group, what forged records can steer, and why the preamble and MLS are the real protection. The spec paragraph points to it. --- CHANGELOG.md | 23 +++++++++++++------- bindings/react-native/README.md | 4 ++-- bindings/react-native/src/types.ts | 5 ++++- docs/bridges/kotlin.md | 21 +++++++++++++----- docs/dors-configuration.md | 3 +-- docs/react-native-integration.md | 2 +- docs/security/threat-model.md | 34 ++++++++++++++++++++++++++++++ docs/spec/stream-framing.md | 7 +++++- 8 files changed, 79 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e984593e..2378f674 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,14 +25,21 @@ archived by series under [docs/changelog/](docs/changelog/); see the Discovery between phones is asymmetric (a device that owns or is joining a group answers no discovery query), so nothing depends on hearing the owner: a device that heard a lower peer creates the group itself after three joins - found none, a device that heard nothing still joins when it sees a group - owner, and an owner whose group stays empty for 30 to 60 seconds dissolves - it so two groups that formed at once merge. Two phones (Android 13 and 15) - next to two Wi-Fi Direct televisions formed the group in all eight clean - starts, in 18 to 225 seconds (median about a minute). Off by default; without it a - group is formed in the system's Wi-Fi Direct settings, as before, and that - group is never dissolved. `groupOwnerIntent` is not used. Groups of three or - more devices are untested. + found none, a device that heard nothing probes a group owner it sees at most + once a minute, and an owner whose group stays empty for 30 to 60 seconds + dissolves it so two groups that formed at once merge. A device alone backs + off: discovery from 15 to 60 seconds, the owner probe from one to four + minutes. Two phones (Android 13 and 15) next to two Wi-Fi Direct televisions + formed the group in all eight clean starts, in 18 to 225 seconds (median + about a minute). Off by default; without it a group is formed in the + system's Wi-Fi Direct settings, as before, and that group is never + dissolved. **An app that already sets `autoAccept: true`**, as the + integration guide's example did while the option did nothing, starts forming + groups on upgrade; set it to `false` to keep the old behaviour. The group's + passphrase is derived from the app id and is not a secret: it keeps apps + apart, and the identity preamble and end-to-end encryption still protect the + traffic (threat model R22). `groupOwnerIntent` is not used and is + deprecated. Groups of three or more devices are untested. - **Python has a gateway-daemon client.** `GatewayManager`, as `ProtocolManager.gateway` when `reticulum_enabled=True`, speaks the diff --git a/bindings/react-native/README.md b/bindings/react-native/README.md index 71faaada..05d3d0c9 100644 --- a/bindings/react-native/README.md +++ b/bindings/react-native/README.md @@ -521,8 +521,8 @@ interface TransportsConfig { wifiDirect?: { enabled: boolean; // default: false (Android only) deviceName?: string; - autoAccept?: boolean; - groupOwnerIntent?: number; // 0-15 + autoAccept?: boolean; // Android 10+: form the group without the system settings + groupOwnerIntent?: number; // deprecated, not used }; reticulum?: { enabled: boolean; // default: false (requires external daemon) diff --git a/bindings/react-native/src/types.ts b/bindings/react-native/src/types.ts index 0c78221e..da92d675 100644 --- a/bindings/react-native/src/types.ts +++ b/bindings/react-native/src/types.ts @@ -422,7 +422,10 @@ export interface WifiDirectTransportConfig { * formed in the system's Wi-Fi Direct settings is used. */ autoAccept?: boolean; - /** Not used. The device with the lowest address owns a group the SDK forms. */ + /** + * @deprecated Not used. The device with the lowest address owns a group + * the SDK forms. + */ groupOwnerIntent?: number; } diff --git a/docs/bridges/kotlin.md b/docs/bridges/kotlin.md index a2b85d9a..449f54aa 100644 --- a/docs/bridges/kotlin.md +++ b/docs/bridges/kotlin.md @@ -147,11 +147,22 @@ query by service type alone returns only the PTR record, so the TXT record that arrives only for a query that names the instance. A device that owns a group, or is in the middle of joining one, answers no service discovery query, so two phones often hear each other minutes apart or not at all; that is why the group is named after the application and not its owner (a joiner needs -nothing from the owner), why a join attempt is cancelled after eight seconds (the joiner is -discoverable again), why a device that hears a lower peer creates the group itself after three joins -found none, and why a device that heard no record still joins when it sees a group owner. And two -groups can form at once, so an owner whose group stays empty for a randomised 30 to 60 seconds -dissolves it and joins before creating again. +nothing from the owner), why a join attempt is cancelled after fifteen seconds (the joiner is +discoverable again; eight seconds cut off the supplicant's retry of a rejected association), why a +device that hears a lower peer creates the group itself after three joins found none (a join the +framework refuses outright counts), and why a device that heard no record still probes a group +owner it sees, at most once a minute. And two groups can form at once, so an owner whose group +stays empty for a randomised 30 to 60 seconds dissolves it and joins before creating again. + +A device alone keeps looking, but less often: service discovery backs off from 15 to 60 seconds +while it hears no record, and the owner probe from one to four minutes while probes find no group +(a Wi-Fi Direct printer or television is an owner no probe joins). Both start over when a record, +a new owner or a new device appears. A device that is a client of any group, the application's or +another (Wi-Fi Direct printing, a screen cast), forms nothing while it lasts. + +`formGroups` is read once by `start()`; `stop()` undoes what the run did (the service request, the +record, a group it created) whatever the flag says by then. `enableTransport('wifiDirect')` with no +configuration keeps the current setting rather than turning formation off. `PeerStreamSocketsTest` and `PeerStreamFramingTest` pin it, the latter replaying the chapter's vectors. The group handling itself is not covered in diff --git a/docs/dors-configuration.md b/docs/dors-configuration.md index b42b9218..be27e7fc 100644 --- a/docs/dors-configuration.md +++ b/docs/dors-configuration.md @@ -25,8 +25,7 @@ const config = { wifiDirect: { enabled: true, // Android only deviceName: 'MyDevice', - autoAccept: false, - groupOwnerIntent: 7, // 0-15, higher = more likely to be group owner + autoAccept: false, // Android 10+: true forms the group without the system settings }, reticulum: { enabled: false, // Requires external Reticulum daemon diff --git a/docs/react-native-integration.md b/docs/react-native-integration.md index 9c0774f7..db711bef 100644 --- a/docs/react-native-integration.md +++ b/docs/react-native-integration.md @@ -116,7 +116,7 @@ transports: { - The SDK's manifest asserts `neverForLocation` on `NEARBY_WIFI_DEVICES`, and the build merges that flag into your app. An app that derives location from Wi-Fi replaces the declaration (`tools:node="replace"`) and then also needs `ACCESS_FINE_LOCATION` on Android 13+, which the SDK does not check for it. - `wifiDirect: { enabled: true }` starts the transport in `start()`. Grant `NEARBY_WIFI_DEVICES` (or, on 12 and lower, fine location) **before** `start()`: an enable refused for a missing grant is logged and not retried, so the transport stays off for the session. If you ask for the grant later, call `enableTransport('wifiDirect')` once it is given. - `autoAccept: true` lets the SDK form the Wi-Fi Direct group itself on Android 10 and later: devices of the same app find each other and join one group within about a minute, with no system dialog. Without it, pair the phones once in the system's Wi-Fi Direct settings. `groupOwnerIntent` is not used. -- Config: `wifiDirect: { enabled: true, autoAccept: true, groupOwnerIntent: 10 }`. +- Config: `wifiDirect: { enabled: true, autoAccept: true }`. `enableTransport('wifiDirect')` with no configuration keeps the `autoAccept` setting the transport already has. ### 4.3 Internet diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index abb46feb..dd767e7b 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -898,6 +898,40 @@ data-layer outcomes apply unchanged, so it buys the attacker nothing a direct send does not. Refusals are silent and counted, so the quotas are not an oracle. +### R22. An Android Wi-Fi Direct group admits anyone who knows the application id + +With `wifiDirect.autoAccept`, Android devices of one application form one +Wi-Fi Direct group whose network name and passphrase are derived from the +application id alone, so that a device can join without having heard the +owner (`WifiDirectGroupFormation`). The application id ships inside every +copy of the application, so it is not a secret, and neither is the +passphrase. A device in radio range (A1) that knows the id can join the +group, or run a group under its name so that nearby devices join it instead +and it carries their link. It can also publish a forged discovery record +with a low `addr` to steer which device creates the group. The record also +carries `addr` and an `app` tag over the air to every device in range: the +same exposure as a Bluetooth LE advertisement, and like the BLE app tag +([R15](#r15-the-bluetooth-le-app-tag-is-unsigned-and-readable)) the `app` +tag names the application only to an observer who already knows its id. + +**Why it stands:** a secret passphrase would need a secret every device of +the application shares and no copy of it reveals, which an application +distributed to the public does not have. A passphrase exchanged per pair +would need the two devices to hear each other first, which is the +dependency the derived name exists to remove: on real phones discovery is +one-sided, and the first design that named the group after its owner failed +to form on two clean starts of nine. + +**What bounds it:** the group is only a link. Every stream over it proves +its peer with the identity preamble before it carries anything, and every +message is end to end encrypted above that, so a device in the group +learns which addresses are present and can carry, delay or drop their +frames, which any relay on any carrier can already do. What it costs is +availability on this carrier: a squatting owner can hold an application's +devices in a group that delivers nothing, until its idle owners dissolve +their own groups or Bluetooth carries the traffic instead. Formation is +opt-in and Android-only. + ## Network egress Until 0.26 the Rust crates opened no socket: every byte that left a device diff --git a/docs/spec/stream-framing.md b/docs/spec/stream-framing.md index 53446a82..ba03757c 100644 --- a/docs/spec/stream-framing.md +++ b/docs/spec/stream-framing.md @@ -267,7 +267,12 @@ discovery when an application lets the SDK form the group (`wifiDirect.autoAccept`). The record adds one entry for that use: `app`, a tag of the application id that keeps applications' devices apart. The `addr` entry keeps its meaning here: the group the record leads to is only a link, and the -stream opened over it proves its peer like any other. +stream opened over it proves its peer like any other. The group's name and +passphrase are derived from the application id, which is not a secret, so the +passphrase keeps honest applications apart and admits anyone who knows the id +([threat model R22](../security/threat-model.md#r22-an-android-wi-fi-direct-group-admits-anyone-who-knows-the-application-id)). +The record is broadcast over the air to any device in radio range, the same +exposure as a Bluetooth LE advertisement. A record that carries a `sid` entry is not a peer hint. It is a service instance under the [DNS-SD mapping](dns-sd-mapping.md), published under the From a4e16dcc157a14b8b690c5975ca1c56bc0ab7ad2 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Tue, 6 Oct 2026 12:05:02 +0530 Subject: [PATCH 6/9] fix(bindings): leave a Wi-Fi Direct group whose owner is gone A Wi-Fi P2P group belongs to the system, not to the process that created it. So when the owner's app gets killed in the background, the group stays up with nobody listening on the stream port, and an owner answers no service discovery query, so nobody can tell. Every other device of the app then sees "a group owner nearby", probe-joins it by the derived credentials, and succeeds. Now it is in a group, so formation never runs again, and it redials a dead port once a minute for the rest of the session. Wi-Fi Direct is gone until somebody cycles Wi-Fi. The threat model claimed idle owners bound a squatter. A dead owner is never idle. It just isn't there. Two rules close it. A client that is in an app-named group leaves after three dials in a row at the top of its redial ladder prove nothing, and joins before creating again. Dials below the ceiling don't count, so an owner that is just restarting its app doesn't get abandoned. A group paired by hand in system settings is never left. And an app-named group this device owns is removed by stop() even if it was adopted at start rather than created in this run. No other app derives that name, and leaving it up is how a device ends up as a hidden, serverless owner in the first place. The leave decision lives in GroupOwnerRedial, a pure function, so it is pinned without a radio. While at it: a new peer or a new record now makes the next service discovery due immediately. Previously it only shortened the period *after* a wait that could already be 60 s out. Also pace the local record publish retry, which was the one formation call that would retry every 10 s against a framework that keeps refusing it. --- CHANGELOG.md | 6 +- .../com/offlineprotocol/WifiDirectManager.kt | 122 ++++++++++++++++-- .../offlineprotocol/GroupOwnerRedialTest.kt | 40 ++++++ .../WifiDirectGroupFormationTest.kt | 16 +++ docs/bridges/kotlin.md | 12 +- docs/security/threat-model.md | 9 +- 6 files changed, 189 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2378f674..eeaa7135 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,11 @@ archived by series under [docs/changelog/](docs/changelog/); see the a device that heard a lower peer creates the group itself after three joins found none, a device that heard nothing probes a group owner it sees at most once a minute, and an owner whose group stays empty for 30 to 60 seconds - dissolves it so two groups that formed at once merge. A device alone backs + dissolves it so two groups that formed at once merge. A client whose owner + proves nothing for three dials at the top of its redial ladder (an owner + whose app died: the group outlives the process) leaves the group and forms + again, and `stop()` removes an app-named group this device owns even when it + was adopted at start. A device alone backs off: discovery from 15 to 60 seconds, the owner probe from one to four minutes. Two phones (Android 13 and 15) next to two Wi-Fi Direct televisions formed the group in all eight clean starts, in 18 to 225 seconds (median diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt index 97b9c1a5..015ca889 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt @@ -24,6 +24,7 @@ import java.net.ServerSocket import java.net.Socket import java.util.concurrent.* import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicLong /** @@ -222,6 +223,14 @@ class WifiDirectManager( // True while this device owns a group under the name it would derive, // which is the only kind of group formation ever dissolves. private var ownsFormationGroup = false + // True while this device is in a group under the application's name, as + // owner or client. Volatile: the socket thread reads it when a client's + // dials keep failing, to decide whether the group is ours to leave. + @Volatile private var inAppGroup = false + // A client's dials in a row toward an owner that proved nothing while the + // redial ladder sat at its ceiling; see [GroupOwnerRedial.shouldLeave]. + private val unprovedAtCeiling = AtomicInteger(0) + private var nextPublishAtMs = 0L private var ownerIdleSinceMs = 0L private var failedJoins = 0 // Any Wi-Fi Direct group owner in the peer list. An owner answers no @@ -702,8 +711,10 @@ class WifiDirectManager( currentPeers.add(device.deviceAddress) if (!discoveredPeers.containsKey(device.deviceAddress)) { discoveredPeers[device.deviceAddress] = device - // Someone new is in range: ask for records at full rate. + // Someone new is in range: ask for records now, and at + // full rate after. quietDiscoveryRounds = 0 + nextServiceDiscoveryAtMs = 0L emitDiagnostic("info", "Discovered peer", mapOf( "name" to device.deviceName, "address" to device.deviceAddress @@ -773,6 +784,8 @@ class WifiDirectManager( ownerClients = 0 createdNetwork = null ownsFormationGroup = false + inAppGroup = false + unprovedAtCeiling.set(0) ownerIdleSinceMs = 0L // Posted: this is the broadcast receiver's onReceive, and ending // an announced stream is an FFI call. Same reasoning as the post @@ -820,6 +833,9 @@ class WifiDirectManager( heardSinceLastDiscovery = true quietDiscoveryRounds = 0 if (adverts.put(device.deviceAddress, advert) == null) { + // A new peer: the next round is due now, not at the end + // of a period backed off while nothing was heard. + nextServiceDiscoveryAtMs = 0L emitDiagnostic("debug", "Wi-Fi Direct peer record", mapOf("address" to advert.address)) } }, @@ -857,6 +873,7 @@ class WifiDirectManager( if (!formGroups || !formationSupported) return addServiceRequest() advertPublished = false + nextPublishAtMs = 0L nextServiceDiscoveryAtMs = 0L quietDiscoveryRounds = 0 heardSinceLastDiscovery = true @@ -887,11 +904,14 @@ class WifiDirectManager( formationRegistered = false createdNetwork = null ownsFormationGroup = false + inAppGroup = false + unprovedAtCeiling.set(0) ownerIdleSinceMs = 0L adverts.clear() inGroup = false ownerClients = 0 advertPublished = false + nextPublishAtMs = 0L failedJoins = 0 joinFirstRemaining = 0 joinIsProbe = false @@ -913,7 +933,12 @@ class WifiDirectManager( adverts.values.removeAll { now - it.seenAtMs > ADVERT_TTL_MS } val address = localAddressOrNull() ?: return - if (!advertPublished) publishAdvert() + // Paced like a formation retry: a framework that keeps refusing the + // record is not asked again on every step. + if (!advertPublished && now >= nextPublishAtMs) { + nextPublishAtMs = now + FORMATION_RETRY_MS + publishAdvert() + } if (inGroup && isGroupOwner && ownsFormationGroup && ownerClients == 0) { if (ownerIdleSinceMs == 0L) { @@ -1055,22 +1080,50 @@ class WifiDirectManager( /** * After any group change: count this owner's clients, and note whether the - * group is the application's, the only kind formation ever dissolves. + * group is the application's, the only kind formation ever dissolves or + * leaves. + * + * An application's group this device owns is this application's to + * remove on [stop], even one adopted at start rather than created by this + * run: no other application derives the name, and the group outlives the + * process. Left up after the process that served it died, it is an owner + * with no listener that answers no service discovery query, and every + * device of the application that probes it joins a group that carries + * nothing. */ @SuppressLint("MissingPermission") private fun refreshGroupForFormation() { if (!formGroups || !formationSupported) return - if (!isGroupOwner) { - ownerClients = 0 - ownsFormationGroup = false - return - } wifiP2pManager?.requestGroupInfo(channel) { group -> - ownerClients = group?.clientList?.size ?: 0 - ownsFormationGroup = group?.networkName == appNetwork + inAppGroup = group?.networkName == appNetwork + if (isGroupOwner) { + ownerClients = group?.clientList?.size ?: 0 + ownsFormationGroup = inAppGroup + if (ownsFormationGroup && createdNetwork == null) createdNetwork = appNetwork + } else { + ownerClients = 0 + ownsFormationGroup = false + } } } + /** + * Leaves an application's group whose owner keeps proving nothing, so + * formation decides again. Runs on the transport thread; re-reads + * everything, because the group can change between the socket thread's + * decision and here. + */ + @SuppressLint("MissingPermission") + private fun leaveDeadApplicationGroup() { + if (state != TransportState.RUNNING || !formationRegistered) return + if (!inGroup || isGroupOwner || !inAppGroup) return + emitDiagnostic("info", "Leaving a Wi-Fi Direct group whose owner proves nothing") + wifiP2pManager?.removeGroup(channel, quietListener("leave dead group")) + failedJoins = 0 + joinFirstRemaining = 2 + nextFormationAtMs = SystemClock.elapsedRealtime() + FORMATION_TICK_MS + } + @SuppressLint("MissingPermission") private fun publishAdvert() { if (!formGroups || !formationSupported) return @@ -1228,6 +1281,20 @@ class WifiDirectManager( * would leave the client with no stream for the whole of the new group. */ private fun scheduleReconnect(ended: String, proved: Boolean) { + val unproved = unprovedAtCeiling.updateAndGet { + GroupOwnerRedial.unprovedAtCeilingAfter( + count = it, + proved = proved, + sameOwner = groupOwnerAddress == ended, + currentDelayMs = reconnectDelayMs.get(), + maxDelayMs = RECONNECT_MAX_DELAY_MS, + ) + } + if (!isGroupOwner && GroupOwnerRedial.shouldLeave(unproved, applicationGroup = inAppGroup)) { + unprovedAtCeiling.set(0) + transportHandler.post { leaveDeadApplicationGroup() } + return + } val plan = GroupOwnerRedial.next( ended = ended, proved = proved, @@ -1422,6 +1489,41 @@ class WifiDirectManager( internal object GroupOwnerRedial { data class Plan(val owner: String, val delayMs: Long, val nextDelayMs: Long) + /** + * Dials in a row at the ladder's ceiling, each proving nothing, after + * which a client leaves a group under the application's name. The group + * outlives the process that owned it, so an owner whose application died + * holds its clients in a group that carries nothing, and a client in a + * group never runs formation again. Three at the ceiling is about three + * minutes past the ladder's climb. + */ + const val LEAVE_AFTER_UNPROVED_AT_CEILING = 3 + + /** + * The count of unproved dials at the ceiling after one more dial ended. + * A proved stream or a different owner starts it over; a dial below the + * ceiling leaves it alone, so an owner restarting its application is not + * mistaken for a dead one. + */ + fun unprovedAtCeilingAfter( + count: Int, + proved: Boolean, + sameOwner: Boolean, + currentDelayMs: Long, + maxDelayMs: Long, + ): Int = when { + proved || !sameOwner -> 0 + currentDelayMs >= maxDelayMs -> count + 1 + else -> count + } + + /** + * Whether a client leaves its group. Only a group under the application's + * name: one paired in the system settings is the user's, not ours. + */ + fun shouldLeave(unprovedAtCeiling: Int, applicationGroup: Boolean): Boolean = + applicationGroup && unprovedAtCeiling >= LEAVE_AFTER_UNPROVED_AT_CEILING + fun next( ended: String, proved: Boolean, diff --git a/bindings/react-native/android/src/test/java/com/offlineprotocol/GroupOwnerRedialTest.kt b/bindings/react-native/android/src/test/java/com/offlineprotocol/GroupOwnerRedialTest.kt index d9567641..64d0169c 100644 --- a/bindings/react-native/android/src/test/java/com/offlineprotocol/GroupOwnerRedialTest.kt +++ b/bindings/react-native/android/src/test/java/com/offlineprotocol/GroupOwnerRedialTest.kt @@ -1,7 +1,9 @@ package com.offlineprotocol import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test /** @@ -61,4 +63,42 @@ class GroupOwnerRedialTest { assertNull(next(isGroupOwner = true)) assertNull(next(running = false)) } + + // --- Leaving a group whose owner is gone ------------------------------------- + + private fun after( + count: Int, + proved: Boolean = false, + sameOwner: Boolean = true, + currentDelayMs: Long = 60_000, + ) = GroupOwnerRedial.unprovedAtCeilingAfter(count, proved, sameOwner, currentDelayMs, maxDelayMs = 60_000) + + @Test + fun `only dials at the ceiling count toward leaving`() { + // Below the ceiling an owner may be restarting its application. + assertEquals(0, after(0, currentDelayMs = 32_000)) + assertEquals(1, after(0)) + assertEquals(2, after(1)) + } + + @Test + fun `a proved stream or another owner starts the count over`() { + assertEquals(0, after(2, proved = true)) + assertEquals(0, after(2, sameOwner = false)) + } + + @Test + fun `a client leaves an application's group whose owner keeps proving nothing`() { + // The group outlives the owner's process: a dead owner held its + // clients in a group that carried nothing, and a client in a group + // never formed again. + val n = GroupOwnerRedial.LEAVE_AFTER_UNPROVED_AT_CEILING + assertFalse(GroupOwnerRedial.shouldLeave(n - 1, applicationGroup = true)) + assertTrue(GroupOwnerRedial.shouldLeave(n, applicationGroup = true)) + } + + @Test + fun `a group paired in the system settings is never left`() { + assertFalse(GroupOwnerRedial.shouldLeave(1_000, applicationGroup = false)) + } } diff --git a/bindings/react-native/android/src/test/java/com/offlineprotocol/WifiDirectGroupFormationTest.kt b/bindings/react-native/android/src/test/java/com/offlineprotocol/WifiDirectGroupFormationTest.kt index e90f39ae..5f8c8a17 100644 --- a/bindings/react-native/android/src/test/java/com/offlineprotocol/WifiDirectGroupFormationTest.kt +++ b/bindings/react-native/android/src/test/java/com/offlineprotocol/WifiDirectGroupFormationTest.kt @@ -4,6 +4,7 @@ import com.offlineprotocol.WifiDirectGroupFormation.Action import com.offlineprotocol.WifiDirectGroupFormation.Advert import com.offlineprotocol.WifiDirectGroupFormation.Local import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue @@ -164,6 +165,14 @@ class WifiDirectGroupFormationTest { assertNull(WifiDirectGroupFormation.parse("mac", good + ("sid" to "printer"), 0)) } + @Test + fun `a record with an entry this version does not know still parses`() { + // A later revision may add entries under txtvers=1; ignoring them is + // what lets it. + val txt = WifiDirectGroupFormation.txtRecord("off1q", app) + ("future" to "x") + assertEquals(Advert("mac", "off1q", app, 0), WifiDirectGroupFormation.parse("mac", txt, 0)) + } + // --- The credentials ------------------------------------------------------------- @Test @@ -173,6 +182,13 @@ class WifiDirectGroupFormationTest { assertTrue(name.toByteArray().size <= 32) } + @Test + fun `names Android refuses are refused`() { + assertFalse(WifiDirectGroupFormation.isValidNetworkName("op-1234")) + assertFalse(WifiDirectGroupFormation.isValidNetworkName("DIRECT-")) + assertFalse(WifiDirectGroupFormation.isValidNetworkName("DIRECT-op-" + "a".repeat(23))) + } + @Test fun `the passphrase is WPA2-sized and the same on both ends`() { val name = WifiDirectGroupFormation.networkName("com.example.app") diff --git a/docs/bridges/kotlin.md b/docs/bridges/kotlin.md index 449f54aa..8c388a90 100644 --- a/docs/bridges/kotlin.md +++ b/docs/bridges/kotlin.md @@ -160,8 +160,16 @@ while it hears no record, and the owner probe from one to four minutes while pro a new owner or a new device appears. A device that is a client of any group, the application's or another (Wi-Fi Direct printing, a screen cast), forms nothing while it lasts. -`formGroups` is read once by `start()`; `stop()` undoes what the run did (the service request, the -record, a group it created) whatever the flag says by then. `enableTransport('wifiDirect')` with no +The group outlives the process. An owner whose application died leaves a group with no listener that +answers no discovery query, and every device that probes it joins a group that carries nothing, then +never runs formation again because it is in a group. Two rules close that: a group under the +application's name that this device owns is removed by `stop()` even when it was adopted at start +rather than created by the run, and a client of such a group leaves it after three dials at the top of +the redial ladder prove nothing (`GroupOwnerRedial.shouldLeave`) and joins before creating again. A +group paired in the system settings is never removed or left. + +`formGroups` is set while stopped; `stop()` undoes what the run did (the service request, the +record, an application group it created or adopted) whatever the flag says by then. `enableTransport('wifiDirect')` with no configuration keeps the current setting rather than turning formation off. `PeerStreamSocketsTest` and `PeerStreamFramingTest` pin it, the latter diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index dd767e7b..abafd916 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -928,9 +928,12 @@ message is end to end encrypted above that, so a device in the group learns which addresses are present and can carry, delay or drop their frames, which any relay on any carrier can already do. What it costs is availability on this carrier: a squatting owner can hold an application's -devices in a group that delivers nothing, until its idle owners dissolve -their own groups or Bluetooth carries the traffic instead. Formation is -opt-in and Android-only. +devices in a group that delivers nothing. A client leaves a group under the +application's name after three dials at the top of its redial ladder prove +no peer (about three minutes), and forms again, so the squatter costs each +device a few minutes per capture rather than the session; Bluetooth carries +the traffic meanwhile. The same rule covers an owner whose application died, +since the group outlives the process. Formation is opt-in and Android-only. ## Network egress From 6914571ce5728816e7a0268006f93e3c1d4aac78 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Tue, 6 Oct 2026 12:05:02 +0530 Subject: [PATCH 7/9] docs: stop promising Wi-Fi Direct groups form within a minute The guide and the autoAccept doc comment said the group forms "within about a minute". The measurements say 18 to 225 s, with half the runs over 56 s. The median is about a minute. That is not the same claim. Say one to two minutes and give the measured range, as the CHANGELOG already does. --- bindings/react-native/src/types.ts | 2 +- docs/react-native-integration.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bindings/react-native/src/types.ts b/bindings/react-native/src/types.ts index da92d675..8e192047 100644 --- a/bindings/react-native/src/types.ts +++ b/bindings/react-native/src/types.ts @@ -418,7 +418,7 @@ export interface WifiDirectTransportConfig { * Android 10 and later: form the Wi-Fi Direct group without the system * settings. Devices of the same app find each other over Wi-Fi P2P service * discovery and join one group by derived credentials, with no dialog on - * either phone, usually within a minute. Off by default: then a group + * either phone, typically in one to two minutes. Off by default: then a group * formed in the system's Wi-Fi Direct settings is used. */ autoAccept?: boolean; diff --git a/docs/react-native-integration.md b/docs/react-native-integration.md index db711bef..f8784400 100644 --- a/docs/react-native-integration.md +++ b/docs/react-native-integration.md @@ -115,7 +115,7 @@ transports: { - Permissions: the SDK declares `ACCESS_WIFI_STATE`, `CHANGE_WIFI_STATE` and `NEARBY_WIFI_DEVICES` (with `neverForLocation`). Request `NEARBY_WIFI_DEVICES` at runtime on Android 13+, and `ACCESS_FINE_LOCATION` on Android 12 and lower, where peer discovery needs it. - The SDK's manifest asserts `neverForLocation` on `NEARBY_WIFI_DEVICES`, and the build merges that flag into your app. An app that derives location from Wi-Fi replaces the declaration (`tools:node="replace"`) and then also needs `ACCESS_FINE_LOCATION` on Android 13+, which the SDK does not check for it. - `wifiDirect: { enabled: true }` starts the transport in `start()`. Grant `NEARBY_WIFI_DEVICES` (or, on 12 and lower, fine location) **before** `start()`: an enable refused for a missing grant is logged and not retried, so the transport stays off for the session. If you ask for the grant later, call `enableTransport('wifiDirect')` once it is given. -- `autoAccept: true` lets the SDK form the Wi-Fi Direct group itself on Android 10 and later: devices of the same app find each other and join one group within about a minute, with no system dialog. Without it, pair the phones once in the system's Wi-Fi Direct settings. `groupOwnerIntent` is not used. +- `autoAccept: true` lets the SDK form the Wi-Fi Direct group itself on Android 10 and later: devices of the same app find each other and join one group, with no system dialog. Expect one to two minutes: two phones took 18 to 225 seconds (median about a minute), and Bluetooth, when on, carries traffic meanwhile. Without it, pair the phones once in the system's Wi-Fi Direct settings. `groupOwnerIntent` is not used. - Config: `wifiDirect: { enabled: true, autoAccept: true }`. `enableTransport('wifiDirect')` with no configuration keeps the `autoAccept` setting the transport already has. ### 4.3 Internet From 8244ad95b93ffda9e784bf97e5b33b46eda0fc16 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Tue, 6 Oct 2026 12:21:20 +0530 Subject: [PATCH 8/9] fix(bindings): don't rejoin the dead Wi-Fi Direct owner we just left The previous commit taught a client to leave an app-named group whose owner proves nothing. It left, all right. Ten seconds later it joined the exact same dead group again. The reason is that every group of an app has the *same* name. A join by name and passphrase lands on whichever owner of that name the supplicant finds, and the only one in sight is the owner we just walked away from. The leave also set join-first, and the dead owner still counted as "an owner nearby", so both the join-first rule and the owner probe aimed straight back at it. Captured for three minutes, leave, captured again. For the whole session. The CHANGELOG and R22 said "forms again". It never did. So remember the owner we left, by device address, for five minutes: longer than an owner whose app comes back takes to dissolve its empty group. It no longer counts as an owner nearby, and while it is remembered every join names its target with setDeviceAddress. The config still carries the network name and passphrase, so it stays a join by credentials and shows no invitation. With no other owner in sight the join is counted as one that found nothing, so the takeover makes the device create its own group. The leave no longer sets join-first. Second hole in the same place: asking to leave also reset the count and *returned* before posting the redial. If removeGroup failed (BUSY during a group transition, say) the client sat in the dead group with no stream, no redial, no leave retry and no formation. Nothing would ever fire again. Keep redialling; a failed leave is asked again on the next unproved dial, and a Rust guard now pins that shape. While at it: - Recount an owner's clients on PEERS_CHANGED too. A client leaving may not change the owner's connection, and an owner that still thinks it has a client never dissolves. - Don't publish the record twice at start. The first step ran before the first publish answered and started a second clear-and-add chain alongside it. - An owner counts as new, and resets the probe backoff, only after five minutes out of the peer list. A television at the edge of range flickering in and out re-armed the one-minute probe every time. Not device-tested: that a targeted join by credentials shows no dialog, and that the peer list's device address is the one setDeviceAddress wants. If either is wrong the join fails and counts toward the takeover, which is the safe way to be wrong. --- CHANGELOG.md | 12 +- .../WifiDirectGroupFormation.kt | 54 +++++++- .../com/offlineprotocol/WifiDirectManager.kt | 125 ++++++++++++++---- .../WifiDirectGroupFormationTest.kt | 52 ++++++++ crates/offline-protocol-uniffi/src/lib.rs | 13 ++ docs/bridges/kotlin.md | 10 +- docs/security/threat-model.md | 11 +- 7 files changed, 239 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eeaa7135..e70e7983 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,11 +29,13 @@ archived by series under [docs/changelog/](docs/changelog/); see the once a minute, and an owner whose group stays empty for 30 to 60 seconds dissolves it so two groups that formed at once merge. A client whose owner proves nothing for three dials at the top of its redial ladder (an owner - whose app died: the group outlives the process) leaves the group and forms - again, and `stop()` removes an app-named group this device owns even when it - was adopted at start. A device alone backs - off: discovery from 15 to 60 seconds, the owner probe from one to four - minutes. Two phones (Android 13 and 15) next to two Wi-Fi Direct televisions + whose app died: the group outlives the process) leaves the group and avoids + that owner for five minutes: every group of an app has one name, so a join + by name alone would land on the owner just left, and while one is avoided + joins name their owner. With no other owner in sight the device forms the + group with its peers instead. `stop()` removes an app-named group this + device owns even when it was adopted at start. A device alone backs off: + discovery from 15 to 60 seconds, the owner probe from one to four minutes. Two phones (Android 13 and 15) next to two Wi-Fi Direct televisions formed the group in all eight clean starts, in 18 to 225 seconds (median about a minute). Off by default; without it a group is formed in the system's Wi-Fi Direct settings, as before, and that group is never diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectGroupFormation.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectGroupFormation.kt index 904539a2..e21c54b4 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectGroupFormation.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectGroupFormation.kt @@ -70,7 +70,24 @@ import java.security.MessageDigest * ([probePeriodMs]). A Wi-Fi Direct printer or television is an owner whose * group a probe never joins; without the backoff a device next to one probed * it every minute for as long as the application was open. Both start over - * when something new appears: a record, a new owner, or a new device. + * when something new appears: a record, a new owner, or a new device. An + * owner counts as new only after [OWNER_MEMORY_MS] out of the peer list, so + * a television at the edge of range does not re-arm the fast probe each time + * it flickers back in ([ownersNewlySeen]). + * + * ## An owner that was left is not joined again + * + * Every group of an application has the same name, so a join by name alone + * lands on whichever owner of that name the supplicant finds. When a client + * leaves a group whose owner proved nothing (its application died; the group + * outlives the process), the owner it left is the one it would find: the + * device rejoined it ten seconds later and was held again, for the session. + * So the owner left is remembered for [DEAD_OWNER_TTL_MS], dropped from the + * owners nearby, and while it is remembered every join names its target + * ([joinTarget]); with no other owner in sight the join counts as one that + * found no group, so the takeover still makes this device create. The memory + * outlasts the 30 to 60 s an owner whose application comes back takes to + * dissolve its empty group. */ internal object WifiDirectGroupFormation { const val SERVICE_TYPE = "_offlineprotocol._tcp" @@ -94,6 +111,10 @@ internal object WifiDirectGroupFormation { const val PROBE_PERIOD_MS = 60_000L /** Probe period after a long run of probes that found no group. */ const val PROBE_MAX_PERIOD_MS = 240_000L + /** How long an owner left for proving nothing is avoided. */ + const val DEAD_OWNER_TTL_MS = 300_000L + /** How long an owner must be out of the peer list to count as new again. */ + const val OWNER_MEMORY_MS = 300_000L /** A peer's record as last seen. */ data class Advert( @@ -125,6 +146,37 @@ internal object WifiDirectGroupFormation { enum class Action { Wait, Create, Join } + /** Which owner a join goes to. */ + sealed class JoinTarget { + /** Whichever owner of the application's name the supplicant finds. */ + object AnyOwner : JoinTarget() + /** This owner only, by its device address. */ + data class Owner(val device: String) : JoinTarget() + /** None: every owner nearby is one this device left. */ + object Nobody : JoinTarget() + } + + /** + * The target of a join, given the owners in the peer list and the owners + * this device left whose memory is still live. An untargeted join is + * only safe while nothing is avoided: it would find the owner just left. + */ + fun joinTarget(nearbyOwners: Set, avoided: Set): JoinTarget { + if (avoided.isEmpty()) return JoinTarget.AnyOwner + val candidate = (nearbyOwners - avoided).minOrNull() ?: return JoinTarget.Nobody + return JoinTarget.Owner(candidate) + } + + /** + * The owners in [owners] not seen within [OWNER_MEMORY_MS] before + * [nowMs], by [lastSeenMs]. A probe backoff starts over only for these. + */ + fun ownersNewlySeen(owners: Set, lastSeenMs: Map, nowMs: Long): Set = + owners.filterTo(HashSet()) { owner -> + val seen = lastSeenMs[owner] + seen == null || nowMs - seen >= OWNER_MEMORY_MS + } + fun decide(local: Local, adverts: Collection): Action { if (local.inGroup) return Action.Wait val peers = adverts.filter { it.app == local.app && it.address != local.address } diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt index 015ca889..9370b15c 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt @@ -7,6 +7,7 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager +import android.net.MacAddress import android.net.wifi.WifiManager import android.net.wifi.p2p.* import android.os.Build @@ -233,10 +234,6 @@ class WifiDirectManager( private var nextPublishAtMs = 0L private var ownerIdleSinceMs = 0L private var failedJoins = 0 - // Any Wi-Fi Direct group owner in the peer list. An owner answers no - // service discovery query, so this is how a device that heard no record - // still learns there is a group it might join. - private var ownerNearby = false private var ownerIdleLimitMs = OWNER_IDLE_MS private var lastJoinAtMs = 0L private var joinFirstRemaining = 0 @@ -249,11 +246,24 @@ class WifiDirectManager( private var quietDiscoveryRounds = 0 private var heardSinceLastDiscovery = true // Probe backoff: probes in a row toward an owner nobody vouched for that - // found no group, whether the join in flight is one, and the owners seen - // last, so a new owner starts the backoff over. + // found no group, and whether the join in flight is one. When each owner + // was last in the peer list, so only an owner new or long gone starts the + // backoff over (WifiDirectGroupFormation.ownersNewlySeen). private var failedProbes = 0 private var joinIsProbe = false + private val ownerLastSeenMs = HashMap() + // Every Wi-Fi Direct group owner in the peer list, of any application. + // An owner answers no service discovery query, so this is how a device + // that heard no record still learns there is a group it might join. private var nearbyOwners: Set = emptySet() + // Owners this device left because they proved nothing, by device address, + // with when. Avoided until WifiDirectGroupFormation.DEAD_OWNER_TTL_MS: a + // join by the application's name would otherwise land on the owner just + // left. Kept across stop(): it is a fact about the radio, not this run. + private val deadOwners = HashMap() + // The device address of the owner of the application's group this device + // is a client of, so a leave knows whom to avoid. + private var appGroupOwnerDevice: String? = null private val appTag: String by lazy { WifiDirectGroupFormation.appTag(appId) } private val appNetwork: String by lazy { WifiDirectGroupFormation.networkName(appId) } @@ -700,10 +710,19 @@ class WifiDirectManager( val deviceList = peers?.deviceList ?: return@requestPeers val owners = deviceList.filter { it.isGroupOwner }.map { it.deviceAddress }.toSet() - ownerNearby = owners.isNotEmpty() // A new owner may be this application's group: probe it at once. - if ((owners - nearbyOwners).isNotEmpty()) failedProbes = 0 + // Not one that only flickered out of the list and back. + val now = SystemClock.elapsedRealtime() + if (WifiDirectGroupFormation.ownersNewlySeen(owners - deadOwners.keys, ownerLastSeenMs, now).isNotEmpty()) { + failedProbes = 0 + } + owners.forEach { ownerLastSeenMs[it] = now } + ownerLastSeenMs.values.removeAll { now - it >= WifiDirectGroupFormation.OWNER_MEMORY_MS } nearbyOwners = owners + // A client leaving changes the peer list, and may not change the + // connection: recount, or an owner whose last client left stays + // "settled" and never dissolves its empty group. + if (inGroup && isGroupOwner) refreshGroupForFormation() // Update discovered peers val currentPeers = mutableSetOf() @@ -785,6 +804,7 @@ class WifiDirectManager( createdNetwork = null ownsFormationGroup = false inAppGroup = false + appGroupOwnerDevice = null unprovedAtCeiling.set(0) ownerIdleSinceMs = 0L // Posted: this is the broadcast receiver's onReceive, and ending @@ -845,6 +865,10 @@ class WifiDirectManager( quietDiscoveryRounds = 0 addServiceRequest() publishAdvert() + // The first step runs at once and the publish above has not answered + // yet: without this it publishes again, and the two clear-and-add + // chains interleave. + nextPublishAtMs = SystemClock.elapsedRealtime() + FORMATION_RETRY_MS transportHandler.removeCallbacks(formationTick) transportHandler.post(formationTick) emitDiagnostic("info", "Wi-Fi Direct group formation started") @@ -905,6 +929,7 @@ class WifiDirectManager( createdNetwork = null ownsFormationGroup = false inAppGroup = false + appGroupOwnerDevice = null unprovedAtCeiling.set(0) ownerIdleSinceMs = 0L adverts.clear() @@ -918,8 +943,8 @@ class WifiDirectManager( failedProbes = 0 quietDiscoveryRounds = 0 heardSinceLastDiscovery = true - ownerNearby = false nearbyOwners = emptySet() + ownerLastSeenMs.clear() } /** One step: refresh what is known, and act on it if the backoff allows. */ @@ -931,6 +956,7 @@ class WifiDirectManager( if (!layerUp) return val now = SystemClock.elapsedRealtime() adverts.values.removeAll { now - it.seenAtMs > ADVERT_TTL_MS } + deadOwners.values.removeAll { now - it >= WifiDirectGroupFormation.DEAD_OWNER_TTL_MS } val address = localAddressOrNull() ?: return // Paced like a formation retry: a framework that keeps refusing the @@ -982,9 +1008,12 @@ class WifiDirectManager( }) } if (now < nextFormationAtMs) return + // An owner this device left is not "an owner nearby": probing it is + // how the device was captured again. + val liveOwners = nearbyOwners - deadOwners.keys val action = WifiDirectGroupFormation.decide( WifiDirectGroupFormation.Local( - address, appTag, inGroup, failedJoins, ownerNearby, + address, appTag, inGroup, failedJoins, liveOwners.isNotEmpty(), ownerProbeDue = now - lastJoinAtMs >= WifiDirectGroupFormation.probePeriodMs(failedProbes), joinFirst = joinFirstRemaining > 0, @@ -999,21 +1028,40 @@ class WifiDirectManager( // A join with no record of this application heard is a probe // toward an owner nobody vouched for (rule 4). joinIsProbe = adverts.values.none { it.app == appTag && it.address != address } - joinGroup(appNetwork) + when (val target = WifiDirectGroupFormation.joinTarget(nearbyOwners, deadOwners.keys)) { + WifiDirectGroupFormation.JoinTarget.AnyOwner -> joinGroup(appNetwork, null) + is WifiDirectGroupFormation.JoinTarget.Owner -> joinGroup(appNetwork, target.device) + WifiDirectGroupFormation.JoinTarget.Nobody -> { + // Only owners this device left are in sight: this join + // would find no group but theirs. Counted as one that + // found none, so the takeover still comes. + joinFoundNoGroup() + emitDiagnostic("debug", "Wi-Fi Direct join skipped: only owners left for proving nothing are nearby") + } + } } } nextFormationAtMs = now + formationBackoffMs } - /** The application's group, for creating it and for joining it alike. */ - private fun formationConfig(network: String): WifiP2pConfig? { + /** + * The application's group, for creating it and for joining it alike. A + * [target] names the one owner a join may land on; the network name and + * passphrase keep the config on the join-by-credentials path, so naming a + * device shows no invitation (that is a config with no network name). + */ + private fun formationConfig(network: String, target: String? = null): WifiP2pConfig? { // In-function, like the callers', so lint's NewApi check sees it. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return null - return WifiP2pConfig.Builder() + val builder = WifiP2pConfig.Builder() .setNetworkName(network) .setPassphrase(WifiDirectGroupFormation.passphrase(appId, network)) .enablePersistentMode(false) - .build() + if (target != null) { + val mac = try { MacAddress.fromString(target) } catch (_: IllegalArgumentException) { return null } + builder.setDeviceAddress(mac) + } + return builder.build() } @SuppressLint("MissingPermission") @@ -1032,11 +1080,14 @@ class WifiDirectManager( } @SuppressLint("MissingPermission") - private fun joinGroup(network: String) { + private fun joinGroup(network: String, target: String?) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return - val config = formationConfig(network) ?: return + val config = formationConfig(network, target) ?: return val ch = channel ?: return - emitDiagnostic("info", "Joining a Wi-Fi Direct group", mapOf("network" to network)) + emitDiagnostic("info", "Joining a Wi-Fi Direct group", mapOf( + "network" to network, + "owner" to (target ?: "any"), + )) wifiP2pManager?.connect(ch, config, object : WifiP2pManager.ActionListener { override fun onSuccess() { transportHandler.removeCallbacks(joinWindowTimeout) @@ -1103,25 +1154,42 @@ class WifiDirectManager( } else { ownerClients = 0 ownsFormationGroup = false + appGroupOwnerDevice = if (inAppGroup) group?.owner?.deviceAddress else null } } } /** * Leaves an application's group whose owner keeps proving nothing, so - * formation decides again. Runs on the transport thread; re-reads - * everything, because the group can change between the socket thread's - * decision and here. + * formation decides again, and remembers the owner so no join lands on it + * again (see [WifiDirectGroupFormation.joinTarget]). Runs on the transport + * thread; re-reads everything, because the group can change between the + * socket thread's decision and here. + * + * Not join-first: the only owner of the application's name in sight is + * usually the one just left. If the removal fails the client is still in + * the group, the redial goes on, and its next unproved dial asks again. */ @SuppressLint("MissingPermission") private fun leaveDeadApplicationGroup() { if (state != TransportState.RUNNING || !formationRegistered) return if (!inGroup || isGroupOwner || !inAppGroup) return - emitDiagnostic("info", "Leaving a Wi-Fi Direct group whose owner proves nothing") - wifiP2pManager?.removeGroup(channel, quietListener("leave dead group")) + val now = SystemClock.elapsedRealtime() + val owner = appGroupOwnerDevice + if (owner != null) deadOwners[owner] = now + emitDiagnostic("info", "Leaving a Wi-Fi Direct group whose owner proves nothing", mapOf( + "owner" to (owner ?: "unknown"), + )) + wifiP2pManager?.removeGroup(channel, object : WifiP2pManager.ActionListener { + override fun onSuccess() {} + override fun onFailure(reason: Int) { + emitDiagnostic("warning", "Wi-Fi Direct leave of a dead group failed; retried on the next dial", mapOf( + "reason" to reasonToString(reason), + )) + } + }) failedJoins = 0 - joinFirstRemaining = 2 - nextFormationAtMs = SystemClock.elapsedRealtime() + FORMATION_TICK_MS + nextFormationAtMs = now + FORMATION_TICK_MS } @SuppressLint("MissingPermission") @@ -1290,10 +1358,13 @@ class WifiDirectManager( maxDelayMs = RECONNECT_MAX_DELAY_MS, ) } + // The redial below goes on after a leave is asked for: the leave can + // fail (BUSY during a group transition), and a client in a group with + // no redial posted has nothing left that would ever fire. A leave that + // succeeds ends the group, resets the count, and the redial finds no + // owner to dial. if (!isGroupOwner && GroupOwnerRedial.shouldLeave(unproved, applicationGroup = inAppGroup)) { - unprovedAtCeiling.set(0) transportHandler.post { leaveDeadApplicationGroup() } - return } val plan = GroupOwnerRedial.next( ended = ended, diff --git a/bindings/react-native/android/src/test/java/com/offlineprotocol/WifiDirectGroupFormationTest.kt b/bindings/react-native/android/src/test/java/com/offlineprotocol/WifiDirectGroupFormationTest.kt index 5f8c8a17..265ccbda 100644 --- a/bindings/react-native/android/src/test/java/com/offlineprotocol/WifiDirectGroupFormationTest.kt +++ b/bindings/react-native/android/src/test/java/com/offlineprotocol/WifiDirectGroupFormationTest.kt @@ -226,6 +226,58 @@ class WifiDirectGroupFormationTest { assertEquals(60_000L, WifiDirectGroupFormation.probePeriodMs(-1)) } + // --- An owner that was left --------------------------------------------------- + + @Test + fun `a join goes to any owner while none is avoided`() { + assertEquals( + WifiDirectGroupFormation.JoinTarget.AnyOwner, + WifiDirectGroupFormation.joinTarget(setOf("aa:01"), avoided = emptySet()), + ) + } + + @Test + fun `a device that left a dead owner never joins it again`() { + // Every group of an application has one name, so a join by name alone + // landed on the owner just left, ten seconds after leaving it. + assertEquals( + WifiDirectGroupFormation.JoinTarget.Nobody, + WifiDirectGroupFormation.joinTarget(setOf("aa:01"), avoided = setOf("aa:01")), + ) + assertEquals( + WifiDirectGroupFormation.JoinTarget.Owner("aa:02"), + WifiDirectGroupFormation.joinTarget(setOf("aa:01", "aa:02"), avoided = setOf("aa:01")), + ) + } + + @Test + fun `with only a dead owner in sight, the device that heard a peer still takes over`() { + // The manager counts a Nobody join as one that found no group, and + // decide() sees no owner nearby, so the takeover arrives. + val heard = local("off1b", failedJoins = WifiDirectGroupFormation.TAKEOVER_FAILED_JOINS) + assertEquals(Action.Create, decide(heard, "off1a")) + // And a device that heard nobody does not probe it. + assertEquals(Action.Wait, decide(local("off1b", ownerNearby = false))) + } + + @Test + fun `an owner counts as new only after it was out of sight a while`() { + // A television at the edge of range flickers in and out of the peer + // list; each return re-armed the fast probe. + val m = WifiDirectGroupFormation.OWNER_MEMORY_MS + val seen = mapOf("tv" to 1_000L) + assertEquals(emptySet(), WifiDirectGroupFormation.ownersNewlySeen(setOf("tv"), seen, 1_000L + m - 1)) + assertEquals(setOf("tv"), WifiDirectGroupFormation.ownersNewlySeen(setOf("tv"), seen, 1_000L + m)) + assertEquals(setOf("new"), WifiDirectGroupFormation.ownersNewlySeen(setOf("tv", "new"), seen, 2_000L)) + } + + @Test + fun `a dead owner is avoided for longer than a revived owner takes to dissolve`() { + // An owner whose application comes back dissolves its empty group in + // 30 to 60 s; avoiding it no longer than that would rejoin it. + assertTrue(WifiDirectGroupFormation.DEAD_OWNER_TTL_MS > 2 * 30_000L) + } + // --- The switch ---------------------------------------------------------------- @Test diff --git a/crates/offline-protocol-uniffi/src/lib.rs b/crates/offline-protocol-uniffi/src/lib.rs index 445618c7..1a2d0da7 100644 --- a/crates/offline-protocol-uniffi/src/lib.rs +++ b/crates/offline-protocol-uniffi/src/lib.rs @@ -18060,6 +18060,19 @@ mod tests { {needed}" ); } + // Asking to leave a dead owner's group does not end the redial. The + // leave's removeGroup can fail (BUSY in a group transition), and a + // client still in the group with no redial posted had nothing left + // that would ever fire: no stream, no leave retry, no formation. + assert!( + kotlin.contains( + "if (!isGroupOwner && GroupOwnerRedial.shouldLeave(unproved, \ + applicationGroup = inAppGroup)) { \ + transportHandler.post { leaveDeadApplicationGroup() } } \ + val plan = GroupOwnerRedial.next(" + ), + "WifiDirectManager.kt must keep redialling after it asks to leave a dead owner's group" + ); for call in ["host.peerStreamConnected(", "host.peerStreamReceived("] { assert_eq!( swift.matches(call).count(), diff --git a/docs/bridges/kotlin.md b/docs/bridges/kotlin.md index 8c388a90..8873965b 100644 --- a/docs/bridges/kotlin.md +++ b/docs/bridges/kotlin.md @@ -165,7 +165,15 @@ answers no discovery query, and every device that probes it joins a group that c never runs formation again because it is in a group. Two rules close that: a group under the application's name that this device owns is removed by `stop()` even when it was adopted at start rather than created by the run, and a client of such a group leaves it after three dials at the top of -the redial ladder prove nothing (`GroupOwnerRedial.shouldLeave`) and joins before creating again. A +the redial ladder prove nothing (`GroupOwnerRedial.shouldLeave`). Leaving is not enough on its own: +every group of the application has one name, so a join by name lands on whichever owner of it the +supplicant finds, and the one in sight is the owner just left. The first version rejoined it ten +seconds later, every time. So the owner left is avoided for five minutes (longer than an owner whose +application comes back takes to dissolve its empty group), it no longer counts as an owner nearby, +and while it is avoided a join names its owner by device address (`WifiDirectGroupFormation.joinTarget`; +the network name and passphrase keep it a join by credentials, with no invitation). With no other +owner in sight the join counts as one that found no group, so the takeover makes the device create. +The redial goes on after a leave is asked for, so a leave the framework refuses is asked again. A group paired in the system settings is never removed or left. `formGroups` is set while stopped; `stop()` undoes what the run did (the service request, the diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index abafd916..c021c397 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -930,10 +930,13 @@ frames, which any relay on any carrier can already do. What it costs is availability on this carrier: a squatting owner can hold an application's devices in a group that delivers nothing. A client leaves a group under the application's name after three dials at the top of its redial ladder prove -no peer (about three minutes), and forms again, so the squatter costs each -device a few minutes per capture rather than the session; Bluetooth carries -the traffic meanwhile. The same rule covers an owner whose application died, -since the group outlives the process. Formation is opt-in and Android-only. +no peer (about three minutes), and avoids that owner's device address for +five minutes, joining only a named other owner meanwhile, so the squatter +costs each device a few minutes per capture rather than the session; +Bluetooth carries the traffic meanwhile. The bound is per device address: a +squatter that changes its address captures the device again, a few minutes +per change. The same rule covers an owner whose application died, since the +group outlives the process. Formation is opt-in and Android-only. ## Network egress From 91f3b510e7a1a4fae63cced881da986c4f32ad52 Mon Sep 17 00:00:00 2001 From: bahdotsh Date: Tue, 6 Oct 2026 12:49:53 +0530 Subject: [PATCH 9/9] fix(bindings): leave a dead Wi-Fi Direct owner on arrival, don't name joins The previous commit tried to keep a device from rejoining the dead owner it just left by naming the owner of every join with WifiP2pConfig.Builder.setDeviceAddress. That cannot work. It turns out that on a join by credentials the supplicant takes that address as go_bssid, pins the temporary network to it, and skips every BSS whose BSSID differs. A group owner's BSSID is its P2P *interface* address. The peer list and the group only ever report *device* addresses, and no application API exposes an owner's interface address before you are in its group. So for five minutes after any leave, every join this device made matched nothing and timed out, merges with healthy owners included, and then the dead owner captured it again. Confusion ensues. Drop the targeting. Joins go out by name again, and the supplicant picks the owner. A join that lands in the group of an owner we left leaves at once and counts as a join that found no group, so a device that heard a peer still reaches the takeover and creates the group itself. The join counters are now settled once the owner is known, not on CONNECTION_CHANGED: resetting there zeroed the count before a capture could be counted, and the takeover never fired. A record heard again from a remembered owner forgives it. This makes recovery next to a dead owner bounded, not guaranteed: which owner a join lands on is still the system's choice. The docs now say that, instead of describing a join that cannot associate. While at it, "the group outlives the process" becomes "can": the framework removes the group when the last app holding Wi-Fi P2P on that phone goes away. --- CHANGELOG.md | 19 +-- .../WifiDirectGroupFormation.kt | 65 +++++----- .../com/offlineprotocol/WifiDirectManager.kt | 113 ++++++++++-------- .../WifiDirectGroupFormationTest.kt | 46 +++---- crates/offline-protocol-uniffi/src/lib.rs | 24 ++++ docs/bridges/kotlin.md | 21 ++-- docs/security/threat-model.md | 18 +-- 7 files changed, 185 insertions(+), 121 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e70e7983..38042403 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,14 +29,17 @@ archived by series under [docs/changelog/](docs/changelog/); see the once a minute, and an owner whose group stays empty for 30 to 60 seconds dissolves it so two groups that formed at once merge. A client whose owner proves nothing for three dials at the top of its redial ladder (an owner - whose app died: the group outlives the process) leaves the group and avoids - that owner for five minutes: every group of an app has one name, so a join - by name alone would land on the owner just left, and while one is avoided - joins name their owner. With no other owner in sight the device forms the - group with its peers instead. `stop()` removes an app-named group this - device owns even when it was adopted at start. A device alone backs off: - discovery from 15 to 60 seconds, the owner probe from one to four minutes. Two phones (Android 13 and 15) next to two Wi-Fi Direct televisions - formed the group in all eight clean starts, in 18 to 225 seconds (median + whose app died: the group can outlive the process) leaves the group and + remembers that owner for five minutes. Every group of an app has one name, + so a join can land on the owner just left, and Android offers an app no way + to steer it elsewhere; a join that lands there leaves at once and counts as + one that found no group, so a device that heard a peer creates the group + itself after three. Which owner a join lands on beside a dead one stays the + system's choice, so the merge there is bounded, not guaranteed. `stop()` + removes an app-named group this device owns even when it was adopted at + start. A device alone backs off: discovery from 15 to 60 seconds, the owner + probe from one to four minutes. Two phones (Android 13 and 15) next to two + Wi-Fi Direct televisions formed the group in all eight clean starts, in 18 to 225 seconds (median about a minute). Off by default; without it a group is formed in the system's Wi-Fi Direct settings, as before, and that group is never dissolved. **An app that already sets `autoAccept: true`**, as the diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectGroupFormation.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectGroupFormation.kt index e21c54b4..d22b029a 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectGroupFormation.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectGroupFormation.kt @@ -75,19 +75,31 @@ import java.security.MessageDigest * a television at the edge of range does not re-arm the fast probe each time * it flickers back in ([ownersNewlySeen]). * - * ## An owner that was left is not joined again + * ## An owner that was left is never stayed with * - * Every group of an application has the same name, so a join by name alone - * lands on whichever owner of that name the supplicant finds. When a client - * leaves a group whose owner proved nothing (its application died; the group - * outlives the process), the owner it left is the one it would find: the - * device rejoined it ten seconds later and was held again, for the session. - * So the owner left is remembered for [DEAD_OWNER_TTL_MS], dropped from the - * owners nearby, and while it is remembered every join names its target - * ([joinTarget]); with no other owner in sight the join counts as one that - * found no group, so the takeover still makes this device create. The memory - * outlasts the 30 to 60 s an owner whose application comes back takes to - * dissolve its empty group. + * Every group of an application has the same name, so a join by name lands on + * whichever owner of that name the supplicant picks (by signal), and the one + * in sight after a client leaves a group whose owner proved nothing (its + * application died; the group can outlive the process) is usually the owner + * just left: the device rejoined it ten seconds later and was held again. + * + * A join cannot be steered away from it. `WifiP2pConfig.Builder.setDeviceAddress` + * on a join by credentials becomes the supplicant's `go_bssid`, which must + * equal the owner's BSSID, its P2P *interface* address; the peer list and the + * group report the *device* address, and no application API exposes an + * owner's interface address before joining it. A join named that way matches + * no network and fails, every time. + * + * So the owner left is remembered for [DEAD_OWNER_TTL_MS] by device address + * and handled after the fact: it does not count as an owner nearby (no probe + * goes toward it), a join that lands in its group leaves at once and counts + * as a join that found no group ([joinedLeftOwner], + * [failedJoinsAfterGroupJoined]), so a device that heard a peer still takes + * over and creates after [TAKEOVER_FAILED_JOINS], and its record heard again + * forgives it. Which owner a join lands on beside a dead one stays the + * supplicant's choice: the merge there is bounded by the takeover, not + * guaranteed. The memory outlasts the 30 to 60 s an owner whose application + * comes back takes to dissolve its empty group. */ internal object WifiDirectGroupFormation { const val SERVICE_TYPE = "_offlineprotocol._tcp" @@ -146,26 +158,21 @@ internal object WifiDirectGroupFormation { enum class Action { Wait, Create, Join } - /** Which owner a join goes to. */ - sealed class JoinTarget { - /** Whichever owner of the application's name the supplicant finds. */ - object AnyOwner : JoinTarget() - /** This owner only, by its device address. */ - data class Owner(val device: String) : JoinTarget() - /** None: every owner nearby is one this device left. */ - object Nobody : JoinTarget() - } + /** + * Whether the group this client just joined is the application's and + * owned by a device it left for proving nothing, whose memory is live. + */ + fun joinedLeftOwner(applicationGroup: Boolean, owner: String?, avoided: Set): Boolean = + applicationGroup && owner != null && owner in avoided /** - * The target of a join, given the owners in the peer list and the owners - * this device left whose memory is still live. An untargeted join is - * only safe while nothing is avoided: it would find the owner just left. + * The failed-join count after joining a group. A group owned by a device + * this one left is a join that found no group: it counts toward the + * takeover rather than starting it over, or every capture by the dead + * owner zeroes the count and the device never creates. */ - fun joinTarget(nearbyOwners: Set, avoided: Set): JoinTarget { - if (avoided.isEmpty()) return JoinTarget.AnyOwner - val candidate = (nearbyOwners - avoided).minOrNull() ?: return JoinTarget.Nobody - return JoinTarget.Owner(candidate) - } + fun failedJoinsAfterGroupJoined(previous: Int, leftOwner: Boolean): Int = + if (leftOwner) previous + 1 else 0 /** * The owners in [owners] not seen within [OWNER_MEMORY_MS] before diff --git a/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt index 9370b15c..523e12a3 100644 --- a/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectManager.kt @@ -7,7 +7,6 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.pm.PackageManager -import android.net.MacAddress import android.net.wifi.WifiManager import android.net.wifi.p2p.* import android.os.Build @@ -257,9 +256,11 @@ class WifiDirectManager( // that heard no record still learns there is a group it might join. private var nearbyOwners: Set = emptySet() // Owners this device left because they proved nothing, by device address, - // with when. Avoided until WifiDirectGroupFormation.DEAD_OWNER_TTL_MS: a - // join by the application's name would otherwise land on the owner just - // left. Kept across stop(): it is a fact about the radio, not this run. + // with when, until WifiDirectGroupFormation.DEAD_OWNER_TTL_MS. Not an + // owner nearby while remembered, and a join that lands in one's group + // leaves at once (refreshGroupForFormation): a join cannot be steered + // away from it. Kept across stop(): it is a fact about the radio, not + // this run. private val deadOwners = HashMap() // The device address of the owner of the application's group this device // is a client of, so a leave knows whom to avoid. @@ -761,9 +762,10 @@ class WifiDirectManager( if (connected) { transportHandler.removeCallbacks(joinWindowTimeout) formationBackoffMs = FORMATION_RETRY_MS - failedJoins = 0 - joinFirstRemaining = 0 - failedProbes = 0 + // The join counters are settled in refreshGroupForFormation, once + // the group's owner is known: a group owned by a device this one + // left is a join that found no group, and resetting here would + // zero the count every time the dead owner captured the device. } if (connected && hasRequiredPermissions()) { @@ -852,6 +854,12 @@ class WifiDirectManager( ) ?: return@setDnsSdResponseListeners heardSinceLastDiscovery = true quietDiscoveryRounds = 0 + // A device left for proving nothing whose record is heard + // again has its application back: its stale group is being + // dissolved, or carries a listener again. + if (deadOwners.remove(device.deviceAddress) != null) { + emitDiagnostic("debug", "Wi-Fi Direct owner left earlier is back", mapOf("address" to advert.address)) + } if (adverts.put(device.deviceAddress, advert) == null) { // A new peer: the next round is due now, not at the end // of a period backed off while nothing was heard. @@ -1028,40 +1036,31 @@ class WifiDirectManager( // A join with no record of this application heard is a probe // toward an owner nobody vouched for (rule 4). joinIsProbe = adverts.values.none { it.app == appTag && it.address != address } - when (val target = WifiDirectGroupFormation.joinTarget(nearbyOwners, deadOwners.keys)) { - WifiDirectGroupFormation.JoinTarget.AnyOwner -> joinGroup(appNetwork, null) - is WifiDirectGroupFormation.JoinTarget.Owner -> joinGroup(appNetwork, target.device) - WifiDirectGroupFormation.JoinTarget.Nobody -> { - // Only owners this device left are in sight: this join - // would find no group but theirs. Counted as one that - // found none, so the takeover still comes. - joinFoundNoGroup() - emitDiagnostic("debug", "Wi-Fi Direct join skipped: only owners left for proving nothing are nearby") - } - } + // By name only. Which owner it lands on is the supplicant's + // choice; one this device left is handled on arrival, in + // refreshGroupForFormation (see WifiDirectGroupFormation). + joinGroup(appNetwork) } } nextFormationAtMs = now + formationBackoffMs } /** - * The application's group, for creating it and for joining it alike. A - * [target] names the one owner a join may land on; the network name and - * passphrase keep the config on the join-by-credentials path, so naming a - * device shows no invitation (that is a config with no network name). + * The application's group, for creating it and for joining it alike. + * + * Never with `setDeviceAddress`: on a join by credentials the supplicant + * takes it as the owner's BSSID (its P2P interface address), while every + * address an application sees is the owner's device address, so a join + * named that way matches no network and fails. */ - private fun formationConfig(network: String, target: String? = null): WifiP2pConfig? { + private fun formationConfig(network: String): WifiP2pConfig? { // In-function, like the callers', so lint's NewApi check sees it. if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return null - val builder = WifiP2pConfig.Builder() + return WifiP2pConfig.Builder() .setNetworkName(network) .setPassphrase(WifiDirectGroupFormation.passphrase(appId, network)) .enablePersistentMode(false) - if (target != null) { - val mac = try { MacAddress.fromString(target) } catch (_: IllegalArgumentException) { return null } - builder.setDeviceAddress(mac) - } - return builder.build() + .build() } @SuppressLint("MissingPermission") @@ -1080,14 +1079,11 @@ class WifiDirectManager( } @SuppressLint("MissingPermission") - private fun joinGroup(network: String, target: String?) { + private fun joinGroup(network: String) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return - val config = formationConfig(network, target) ?: return + val config = formationConfig(network) ?: return val ch = channel ?: return - emitDiagnostic("info", "Joining a Wi-Fi Direct group", mapOf( - "network" to network, - "owner" to (target ?: "any"), - )) + emitDiagnostic("info", "Joining a Wi-Fi Direct group", mapOf("network" to network)) wifiP2pManager?.connect(ch, config, object : WifiP2pManager.ActionListener { override fun onSuccess() { transportHandler.removeCallbacks(joinWindowTimeout) @@ -1136,17 +1132,25 @@ class WifiDirectManager( * * An application's group this device owns is this application's to * remove on [stop], even one adopted at start rather than created by this - * run: no other application derives the name, and the group outlives the - * process. Left up after the process that served it died, it is an owner - * with no listener that answers no service discovery query, and every - * device of the application that probes it joins a group that carries - * nothing. + * run: no other application derives the name, and the group can outlive + * the process (the framework removes it only when no application on the + * phone still holds Wi-Fi P2P). Left up after the process that served it + * died, it is an owner with no listener that answers no service discovery + * query, and every device of the application that probes it joins a group + * that carries nothing. + * + * Also where a join's counters are settled, because only here is the + * owner known. A client that landed in the group of an owner it left for + * proving nothing leaves at once, and the join counts as one that found + * no group, so the takeover still arrives; any other group starts the + * counters over. */ @SuppressLint("MissingPermission") private fun refreshGroupForFormation() { if (!formGroups || !formationSupported) return wifiP2pManager?.requestGroupInfo(channel) { group -> inAppGroup = group?.networkName == appNetwork + var leftOwner = false if (isGroupOwner) { ownerClients = group?.clientList?.size ?: 0 ownsFormationGroup = inAppGroup @@ -1155,20 +1159,36 @@ class WifiDirectManager( ownerClients = 0 ownsFormationGroup = false appGroupOwnerDevice = if (inAppGroup) group?.owner?.deviceAddress else null + leftOwner = WifiDirectGroupFormation.joinedLeftOwner(inAppGroup, appGroupOwnerDevice, deadOwners.keys) + } + failedJoins = WifiDirectGroupFormation.failedJoinsAfterGroupJoined(failedJoins, leftOwner) + if (leftOwner) { + if (joinFirstRemaining > 0) joinFirstRemaining-- + joinIsProbe = false + emitDiagnostic("info", "Wi-Fi Direct join landed on an owner left for proving nothing", mapOf( + "owner" to appGroupOwnerDevice, + "failedJoins" to failedJoins, + )) + leaveDeadApplicationGroup() + } else { + joinFirstRemaining = 0 + failedProbes = 0 } } } /** - * Leaves an application's group whose owner keeps proving nothing, so - * formation decides again, and remembers the owner so no join lands on it - * again (see [WifiDirectGroupFormation.joinTarget]). Runs on the transport + * Leaves an application's group whose owner proves nothing, so formation + * decides again, and remembers the owner (see [WifiDirectGroupFormation], + * "An owner that was left is never stayed with"). Runs on the transport * thread; re-reads everything, because the group can change between the * socket thread's decision and here. * * Not join-first: the only owner of the application's name in sight is - * usually the one just left. If the removal fails the client is still in - * the group, the redial goes on, and its next unproved dial asks again. + * usually the one just left. The failed-join count is left alone: a + * capture by a remembered owner has just counted toward the takeover. + * If the removal fails the client is still in the group, the redial goes + * on, and its next unproved dial at the ceiling asks again. */ @SuppressLint("MissingPermission") private fun leaveDeadApplicationGroup() { @@ -1188,7 +1208,6 @@ class WifiDirectManager( )) } }) - failedJoins = 0 nextFormationAtMs = now + FORMATION_TICK_MS } @@ -1563,7 +1582,7 @@ internal object GroupOwnerRedial { /** * Dials in a row at the ladder's ceiling, each proving nothing, after * which a client leaves a group under the application's name. The group - * outlives the process that owned it, so an owner whose application died + * can outlive the process that owned it, so an owner whose application died * holds its clients in a group that carries nothing, and a client in a * group never runs formation again. Three at the ceiling is about three * minutes past the ladder's climb. diff --git a/bindings/react-native/android/src/test/java/com/offlineprotocol/WifiDirectGroupFormationTest.kt b/bindings/react-native/android/src/test/java/com/offlineprotocol/WifiDirectGroupFormationTest.kt index 265ccbda..a8d77953 100644 --- a/bindings/react-native/android/src/test/java/com/offlineprotocol/WifiDirectGroupFormationTest.kt +++ b/bindings/react-native/android/src/test/java/com/offlineprotocol/WifiDirectGroupFormationTest.kt @@ -229,34 +229,36 @@ class WifiDirectGroupFormationTest { // --- An owner that was left --------------------------------------------------- @Test - fun `a join goes to any owner while none is avoided`() { - assertEquals( - WifiDirectGroupFormation.JoinTarget.AnyOwner, - WifiDirectGroupFormation.joinTarget(setOf("aa:01"), avoided = emptySet()), - ) + fun `a join that lands on an owner this device left is recognised`() { + // Every group of an application has one name, so a join by name landed + // on the owner just left, ten seconds after leaving it. + assertTrue(WifiDirectGroupFormation.joinedLeftOwner(true, "aa:01", avoided = setOf("aa:01"))) + assertFalse(WifiDirectGroupFormation.joinedLeftOwner(true, "aa:02", avoided = setOf("aa:01"))) + assertFalse(WifiDirectGroupFormation.joinedLeftOwner(true, null, avoided = setOf("aa:01"))) + // A group paired in the system settings is never left. + assertFalse(WifiDirectGroupFormation.joinedLeftOwner(false, "aa:01", avoided = setOf("aa:01"))) } @Test - fun `a device that left a dead owner never joins it again`() { - // Every group of an application has one name, so a join by name alone - // landed on the owner just left, ten seconds after leaving it. - assertEquals( - WifiDirectGroupFormation.JoinTarget.Nobody, - WifiDirectGroupFormation.joinTarget(setOf("aa:01"), avoided = setOf("aa:01")), - ) - assertEquals( - WifiDirectGroupFormation.JoinTarget.Owner("aa:02"), - WifiDirectGroupFormation.joinTarget(setOf("aa:01", "aa:02"), avoided = setOf("aa:01")), - ) + fun `captures by a dead owner add up to the takeover`() { + // A join cannot be steered away from the dead owner (the supplicant + // pins a named join to an interface address no application sees), so + // each capture is left at once and counted. Resetting the count on + // joining, as any other group does, never reached the takeover. + var failed = 0 + repeat(WifiDirectGroupFormation.TAKEOVER_FAILED_JOINS) { + failed = WifiDirectGroupFormation.failedJoinsAfterGroupJoined(failed, leftOwner = true) + } + assertEquals(WifiDirectGroupFormation.TAKEOVER_FAILED_JOINS, failed) + // decide() does not count the dead owner as nearby, so the device that + // heard a peer creates. + assertEquals(Action.Create, decide(local("off1b", failedJoins = failed), "off1a")) + // Any other group starts the count over. + assertEquals(0, WifiDirectGroupFormation.failedJoinsAfterGroupJoined(failed, leftOwner = false)) } @Test - fun `with only a dead owner in sight, the device that heard a peer still takes over`() { - // The manager counts a Nobody join as one that found no group, and - // decide() sees no owner nearby, so the takeover arrives. - val heard = local("off1b", failedJoins = WifiDirectGroupFormation.TAKEOVER_FAILED_JOINS) - assertEquals(Action.Create, decide(heard, "off1a")) - // And a device that heard nobody does not probe it. + fun `a device that heard nobody does not probe an owner it left`() { assertEquals(Action.Wait, decide(local("off1b", ownerNearby = false))) } diff --git a/crates/offline-protocol-uniffi/src/lib.rs b/crates/offline-protocol-uniffi/src/lib.rs index 1a2d0da7..64a2b8c9 100644 --- a/crates/offline-protocol-uniffi/src/lib.rs +++ b/crates/offline-protocol-uniffi/src/lib.rs @@ -18073,6 +18073,30 @@ mod tests { ), "WifiDirectManager.kt must keep redialling after it asks to leave a dead owner's group" ); + // A join by credentials is never named. The supplicant takes the + // address as the owner's BSSID (its P2P interface address), and every + // address an application sees is a device address, so a named join + // matched no network: for five minutes after leaving a dead owner, + // every join failed, merges included. + assert!( + !kotlin.contains("setDeviceAddress("), + "WifiDirectManager.kt must not name the owner of a join by credentials" + ); + // A connection does not start the join counters over; the group-info + // callback does, once the owner is known. A reset on connection ran + // before a capture by a remembered dead owner was counted, so the + // count never reached the takeover. + assert!( + kotlin.contains( + "inGroup = connected if (connected) { \ + transportHandler.removeCallbacks(joinWindowTimeout) \ + formationBackoffMs = FORMATION_RETRY_MS }" + ) && kotlin.contains( + "failedJoins = WifiDirectGroupFormation.failedJoinsAfterGroupJoined(failedJoins, leftOwner)" + ), + "WifiDirectManager.kt must settle the join counters in refreshGroupForFormation, \ + not on CONNECTION_CHANGED" + ); for call in ["host.peerStreamConnected(", "host.peerStreamReceived("] { assert_eq!( swift.matches(call).count(), diff --git a/docs/bridges/kotlin.md b/docs/bridges/kotlin.md index 8873965b..16ac8742 100644 --- a/docs/bridges/kotlin.md +++ b/docs/bridges/kotlin.md @@ -160,20 +160,25 @@ while it hears no record, and the owner probe from one to four minutes while pro a new owner or a new device appears. A device that is a client of any group, the application's or another (Wi-Fi Direct printing, a screen cast), forms nothing while it lasts. -The group outlives the process. An owner whose application died leaves a group with no listener that +The group can outlive the process: the framework removes it only when no application on the phone +still holds Wi-Fi P2P. An owner whose application died leaves a group with no listener that answers no discovery query, and every device that probes it joins a group that carries nothing, then never runs formation again because it is in a group. Two rules close that: a group under the application's name that this device owns is removed by `stop()` even when it was adopted at start rather than created by the run, and a client of such a group leaves it after three dials at the top of the redial ladder prove nothing (`GroupOwnerRedial.shouldLeave`). Leaving is not enough on its own: every group of the application has one name, so a join by name lands on whichever owner of it the -supplicant finds, and the one in sight is the owner just left. The first version rejoined it ten -seconds later, every time. So the owner left is avoided for five minutes (longer than an owner whose -application comes back takes to dissolve its empty group), it no longer counts as an owner nearby, -and while it is avoided a join names its owner by device address (`WifiDirectGroupFormation.joinTarget`; -the network name and passphrase keep it a join by credentials, with no invitation). With no other -owner in sight the join counts as one that found no group, so the takeover makes the device create. -The redial goes on after a leave is asked for, so a leave the framework refuses is asked again. A +supplicant picks (by signal), and the one in sight is the owner just left. The first version rejoined +it ten seconds later, every time. A join cannot be steered away from it: `setDeviceAddress` on a join +by credentials becomes the supplicant's `go_bssid`, which must equal the owner's P2P interface address, +and an application only ever sees device addresses, so a join named that way matches no network. So +the owner left is remembered for five minutes by device address (longer than an owner whose application +comes back takes to dissolve its empty group): it no longer counts as an owner nearby, a join that +lands in its group leaves at once and counts as one that found no group +(`WifiDirectGroupFormation.joinedLeftOwner`, `failedJoinsAfterGroupJoined`; the counters are settled +once the owner is known, never on `CONNECTION_CHANGED`, or each capture would zero them), so a device +that heard a peer creates after three, and its record heard again forgives it. Beside a dead owner the +merge is bounded by that takeover, not guaranteed. The redial goes on after a leave is asked for, so a leave the framework refuses is asked again. A group paired in the system settings is never removed or left. `formGroups` is set while stopped; `stop()` undoes what the run did (the service request, the diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index c021c397..4a372161 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -930,13 +930,17 @@ frames, which any relay on any carrier can already do. What it costs is availability on this carrier: a squatting owner can hold an application's devices in a group that delivers nothing. A client leaves a group under the application's name after three dials at the top of its redial ladder prove -no peer (about three minutes), and avoids that owner's device address for -five minutes, joining only a named other owner meanwhile, so the squatter -costs each device a few minutes per capture rather than the session; -Bluetooth carries the traffic meanwhile. The bound is per device address: a -squatter that changes its address captures the device again, a few minutes -per change. The same rule covers an owner whose application died, since the -group outlives the process. Formation is opt-in and Android-only. +no peer (about three minutes), and remembers that owner's device address +for five minutes, leaving at once each time a join lands on it again (a +join cannot be steered away from an owner: Android pins a named join to an +interface address no application sees). After three such captures a device +that heard a peer creates the group itself, so the squatter costs each +device a few minutes per capture rather than the session; Bluetooth carries +the traffic meanwhile. The bound is per device address: a squatter that +changes its address captures the device again, a few minutes per change, +and a squatter whose signal is stronger than the honest owner's keeps +winning the join until the takeover. The same rule covers an owner whose +application died, since the group can outlive the process. Formation is opt-in and Android-only. ## Network egress