Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions bindings/react-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2550,19 +2550,34 @@ 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")
}

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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>): 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<String>, lastSeenMs: Map<String, Long>, nowMs: Long): Set<String> =
owners.filterTo(HashSet()) { owner ->
val seen = lastSeenMs[owner]
seen == null || nowMs - seen >= OWNER_MEMORY_MS
}

fun decide(local: Local, adverts: Collection<Advert>): 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<String, String> {
val txt = LinkedHashMap<String, String>()
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<String, String>, 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) }
}
Loading
Loading