diff --git a/CHANGELOG.md b/CHANGELOG.md index 96a63cc7..38042403 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,41 @@ 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 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 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 client whose owner + proves nothing for three dials at the top of its redial ladder (an owner + 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 + 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 [gateway-daemon contract](docs/spec/gateway-contract.md) over TCP to a @@ -689,6 +724,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/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/android/src/main/java/com/offlineprotocol/OfflineProtocolModule.kt b/bindings/react-native/android/src/main/java/com/offlineprotocol/OfflineProtocolModule.kt index 004043de..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 @@ -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,11 +2563,21 @@ 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. + // 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 new file mode 100644 index 00000000..d22b029a --- /dev/null +++ b/bindings/react-native/android/src/main/java/com/offlineprotocol/WifiDirectGroupFormation.kt @@ -0,0 +1,273 @@ +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 + * from the application id alone, so every device of an application can join + * its group without having heard who owns 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 + * + * 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. + * + * ## 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: 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. 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 never stayed with + * + * 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" + 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 on service instances (the DNS-SD mapping chapter); never a peer. */ + const val KEY_SERVICE_ID = "sid" + + /** 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 + /** 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( + val device: String, + val address: String, + val app: 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, + /** 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, + ) + + enum class Action { Wait, Create, Join } + + /** + * 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 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 failedJoinsAfterGroupJoined(previous: Int, leftOwner: Boolean): Int = + if (leftOwner) previous + 1 else 0 + + /** + * 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 } + if (peers.isEmpty()) { + return if (local.ownerNearby && local.ownerProbeDue) Action.Join else Action.Wait + } + 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 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() + txt[KEY_VERSION] = "1" + txt[KEY_ADDRESS] = address + txt[KEY_APP] = appTag + 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 + 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 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(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 = + 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..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 @@ -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 @@ -21,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 /** @@ -50,16 +54,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 +109,45 @@ 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 = 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 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 + // 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 } + /** + * 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], 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 + // MARK: - Properties private val wifiP2pManager: WifiP2pManager? = @@ -160,10 +208,73 @@ 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 + // 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 + 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, 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, 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. + private var appGroupOwnerDevice: String? = null + 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. @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) @@ -318,6 +429,7 @@ class WifiDirectManager( updateState(TransportState.RUNNING) // Notify protocol + layerUp = true try { protocol.wifiDirectStatusChanged(true) } catch (e: Exception) { @@ -328,6 +440,7 @@ class WifiDirectManager( transportHandler.post(messagePollingRunnable) adoptExistingGroup() + startGroupFormation() emitDiagnostic("info", "WiFi Direct transport started") } @@ -375,6 +488,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 @@ -398,6 +512,7 @@ class WifiDirectManager( channel = null // Notify protocol + layerUp = false try { protocol.wifiDirectStatusChanged(false) } catch (e: Exception) { @@ -560,12 +675,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() + } } } @@ -576,12 +710,31 @@ class WifiDirectManager( wifiP2pManager?.requestPeers(channel) { peers -> val deviceList = peers?.deviceList ?: return@requestPeers + val owners = deviceList.filter { it.isGroupOwner }.map { it.deviceAddress }.toSet() + // A new owner may be this application's group: probe it at once. + // 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() for (device in deviceList) { currentPeers.add(device.deviceAddress) if (!discoveredPeers.containsKey(device.deviceAddress)) { discoveredPeers[device.deviceAddress] = device + // 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 @@ -605,6 +758,15 @@ class WifiDirectManager( emitDiagnostic("info", "Connection changed", mapOf( "connected" to connected )) + inGroup = connected + if (connected) { + transportHandler.removeCallbacks(joinWindowTimeout) + formationBackoffMs = FORMATION_RETRY_MS + // 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()) { wifiP2pManager?.requestConnectionInfo(channel) { info -> @@ -634,11 +796,19 @@ class WifiDirectManager( // Connect to group owner connectToGroupOwner(groupOwnerAddress!!) } + refreshGroupForFormation() } } } else { isGroupOwner = false groupOwnerAddress = null + ownerClients = 0 + createdNetwork = null + ownsFormationGroup = false + inAppGroup = false + appGroupOwnerDevice = null + 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 // in [handleWifiP2pStateChanged]. @@ -653,6 +823,437 @@ 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 + 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. + nextServiceDiscoveryAtMs = 0L + emitDiagnostic("debug", "Wi-Fi Direct peer record", mapOf("address" to advert.address)) + } + }, + ) + formationRegistered = true + heardSinceLastDiscovery = true + 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") + } + + /** + * 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, + WifiDirectGroupFormation.SERVICE_TYPE, + ), + quietListener("add service request"), + ) + } + + /** The framework drops local services and service requests with P2P. */ + private fun resumeGroupFormationAfterP2pReturned() { + if (!formGroups || !formationSupported) return + addServiceRequest() + advertPublished = false + nextPublishAtMs = 0L + 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) + // 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 + inAppGroup = false + appGroupOwnerDevice = null + unprovedAtCeiling.set(0) + ownerIdleSinceMs = 0L + adverts.clear() + inGroup = false + ownerClients = 0 + advertPublished = false + nextPublishAtMs = 0L + failedJoins = 0 + joinFirstRemaining = 0 + joinIsProbe = false + failedProbes = 0 + quietDiscoveryRounds = 0 + heardSinceLastDiscovery = true + nearbyOwners = emptySet() + ownerLastSeenMs.clear() + } + + /** One step: refresh what is known, and act on it if the backoff allows. */ + @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 } + 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 + // 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) { + ownerIdleSinceMs = now + 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") + 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 { + 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) { + // 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) { + // 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 + // 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, liveOwners.isNotEmpty(), + ownerProbeDue = now - lastJoinAtMs >= + WifiDirectGroupFormation.probePeriodMs(failedProbes), + joinFirst = joinFirstRemaining > 0, + ), + adverts.values, + ) + when (action) { + WifiDirectGroupFormation.Action.Wait -> return + 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 } + // 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. + * + * 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): 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 { + 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 = 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.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) + } + }) + } + + // 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 + 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 note whether the + * 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 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 + if (ownsFormationGroup && createdNetwork == null) createdNetwork = appNetwork + } else { + 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 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. 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() { + if (state != TransportState.RUNNING || !formationRegistered) return + if (!inGroup || isGroupOwner || !inAppGroup) return + 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), + )) + } + }) + nextFormationAtMs = now + FORMATION_TICK_MS + } + + @SuppressLint("MissingPermission") + 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), + ) + val added = object : WifiP2pManager.ActionListener { + override fun onSuccess() { + advertPublished = true + emitDiagnostic("debug", "Wi-Fi Direct record published") + } + 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() { @@ -767,6 +1368,23 @@ 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, + ) + } + // 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)) { + transportHandler.post { leaveDeadApplicationGroup() } + } val plan = GroupOwnerRedial.next( ended = ended, proved = proved, @@ -930,6 +1548,7 @@ class WifiDirectManager( WifiP2pManager.P2P_UNSUPPORTED -> "P2P_UNSUPPORTED" WifiP2pManager.BUSY -> "BUSY" WifiP2pManager.ERROR -> "ERROR" + WifiP2pManager.NO_SERVICE_REQUESTS -> "NO_SERVICE_REQUESTS" else -> "UNKNOWN ($reason)" } } @@ -960,6 +1579,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 + * 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. + */ + 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 new file mode 100644 index 00000000..a8d77953 --- /dev/null +++ b/bindings/react-native/android/src/test/java/com/offlineprotocol/WifiDirectGroupFormationTest.kt @@ -0,0 +1,293 @@ +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.assertFalse +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 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, appTag: String = app) = + Advert(device = "mac-$address", address = address, app = appTag, seenAtMs = 0) + + private fun local( + address: String, + inGroup: Boolean = false, + failedJoins: Int = 0, + ownerNearby: Boolean = false, + ) = Local(address, app, inGroup, failedJoins, ownerNearby) + + private fun decide(local: Local, vararg heard: String) = + WifiDirectGroupFormation.decide(local, heard.map { advert(it) }) + + // --- Who creates ---------------------------------------------------------- + + @Test + fun `alone, with no group owner nearby, a device does nothing`() { + assertEquals(Action.Wait, decide(local("off1b"))) + } + + @Test + 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 `a higher address joins the group the lower one creates`() { + assertEquals(Action.Join, decide(local("off1b"), "off1a")) + } + + @Test + fun `of three that hear each other, exactly one creates`() { + val all = listOf("off1a", "off1b", "off1c") + 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) + } + + // --- Asymmetric hearing ------------------------------------------------------ + + @Test + 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 `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))) + } + + @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 `the group name needs nothing heard from its owner`() { + // Joiner and creator derive one name from the application alone. + assertEquals( + WifiDirectGroupFormation.networkName("com.example.app"), + WifiDirectGroupFormation.networkName("com.example.app"), + ) + assertNotEquals( + WifiDirectGroupFormation.networkName("com.example.app"), + WifiDirectGroupFormation.networkName("com.other.app"), + ) + } + + @Test + 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")))) + } + + // --- In a group ---------------------------------------------------------------- + + @Test + 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 ------------------------------------------------------------- + + @Test + fun `devices of another application are invisible`() { + val other = WifiDirectGroupFormation.appTag("com.other.app") + assertNotEquals(app, 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, decide(local("off1b"), "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) + assertEquals(listOf("txtvers", "addr", "app"), txt.keys.toList()) + assertEquals("1", txt["txtvers"]) + assertEquals("off1q", txt["addr"]) + } + + @Test + 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) + 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 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 + fun `the network name is one Android accepts`() { + val name = WifiDirectGroupFormation.networkName("com.example.app") + assertTrue(name, WifiDirectGroupFormation.isValidNetworkName(name)) + 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") + val owner = WifiDirectGroupFormation.passphrase("com.example.app", name) + assertEquals(owner, WifiDirectGroupFormation.passphrase("com.example.app", name)) + assertTrue(owner.length in 8..63) + } + + @Test + 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), + ) + } + + // --- 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)) + } + + // --- An owner that was left --------------------------------------------------- + + @Test + 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 `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 `a device that heard nobody does not probe an owner it left`() { + 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 + 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)) + } +} diff --git a/bindings/react-native/src/types.ts b/bindings/react-native/src/types.ts index f4a728cf..8e192047 100644 --- a/bindings/react-native/src/types.ts +++ b/bindings/react-native/src/types.ts @@ -414,9 +414,18 @@ 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, 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; - /** Group owner intent (0-15, higher = more likely to be GO) */ + /** + * @deprecated Not used. The device with the lowest address owns a group + * the SDK forms. + */ groupOwnerIntent?: number; } diff --git a/crates/offline-protocol-uniffi/src/lib.rs b/crates/offline-protocol-uniffi/src/lib.rs index 445618c7..64a2b8c9 100644 --- a/crates/offline-protocol-uniffi/src/lib.rs +++ b/crates/offline-protocol-uniffi/src/lib.rs @@ -18060,6 +18060,43 @@ 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" + ); + // 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 375c3e6b..16ac8742 100644 --- a/docs/bridges/kotlin.md +++ b/docs/bridges/kotlin.md @@ -137,9 +137,53 @@ 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 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 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. + +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 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 +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 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 1e3c2055..f8784400 100644 --- a/docs/react-native-integration.md +++ b/docs/react-native-integration.md @@ -115,7 +115,8 @@ 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. -- Config: `wifiDirect: { enabled: true, autoAccept: true, groupOwnerIntent: 10 }`. +- `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 diff --git a/docs/security/threat-model.md b/docs/security/threat-model.md index abb46feb..4a372161 100644 --- a/docs/security/threat-model.md +++ b/docs/security/threat-model.md @@ -898,6 +898,50 @@ 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. 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 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 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 b065e1f8..ba03757c 100644 --- a/docs/spec/stream-framing.md +++ b/docs/spec/stream-framing.md @@ -262,6 +262,18 @@ 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 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. 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 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..ecb0f33a 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 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 reconnects to its group owner while the group lasts.