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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -845,7 +845,7 @@ jobs:
ble/BleDiscoveryBootstrapPolicy.swift \
PeripheralRestorationAgeOutPolicy.swift \
BleMessageNotificationPolicy.swift \
BleAppTag.swift BleServiceInstanceSelection.swift \
BleAppTag.swift BleDensityPolicy.swift BleServiceInstanceSelection.swift \
Generated/offline_protocol.swift ForcedPresenceCheckQueue.swift \
AddressDeclarationPolicy.swift \
GatewayAttachPolicy.swift GatewayVerdictTracker.swift \
Expand Down
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -790,6 +790,34 @@ archived by series under [docs/changelog/](docs/changelog/); see the
slice is held to 14.0, the oldest system that simulator has. The SDK's own
Rust code is compiled for the pod's target as well, where it was compiled
for the Rust target's floor of iOS 10.
- **Two phones in a room full of other Bluetooth devices find each other.**
The dense-mesh filters on iOS and Android counted every advert in range,
televisions, earbuds and watches included, as mesh density, and in a
"dense" mesh they passed over up to 80% of the peers, chosen by a hash of
the peer's address alone. On two Android phones a metre apart in a house,
the estimate read 32, and each passed the other over on every advert for
the fifteen minutes they were watched. Density now counts the distinct
mesh candidates seen recently, so a few phones never read
as dense, and a peer passed over in a crowded mesh is reconsidered after a
minute. Both platforms compute the pass-over the same way. A toggle
while the app is paused drops the dead links but leaves bringing
Bluetooth back to the resume.
- **Android: Bluetooth switched off and on, or a Bluetooth stack crash, no
longer strands the mesh.** The transport polled the adapter once a minute,
so it noticed Bluetooth going off up to a minute late, came back on its
recovery ladder (12 to 29 s after Bluetooth returned on two phones), and
never noticed a stack crash at all: on an Android 13 phone the stack
restarted in under a second and took this app's GATT server, advertiser
and pending connect with it, and the other phone could not reach it until
the app restarted. It now listens for the adapter's state broadcast, which
reports a crash like a toggle. On the way down it reports each peer lost
and drops the dead links, which Android delivers no disconnect for and
which otherwise counted against the connection cap, kept peers mapped to
old addresses, and held GATT client registrations (one phone held five
stale ones after a session of toggles). On the way up it rebuilds the
scan, GATT server and advertising at once. A peer probed while its radio
was coming back is no longer cached as "not a mesh device" for five
minutes either.

### Changed

Expand Down
1 change: 1 addition & 0 deletions bindings/react-native/MeshSdk.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Pod::Spec.new do |s|
"ios/OutboundFragmentQueue.swift",
"ios/AddressDeclarationPolicy.swift",
"ios/BleAppTag.swift",
"ios/BleDensityPolicy.swift",
"ios/BleMessageNotificationPolicy.swift",
"ios/BleServiceInstanceSelection.swift",
"ios/PeerIdentityBinding.swift",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.offlineprotocol

/**
* How crowded the mesh around this device is, and which mesh peers a scan
* passes over while it is crowded. Mirrors ios/BleDensityPolicy.swift.
*
* The count is of distinct mesh candidates, the devices the discovery gate
* admits, not of scan callbacks from every Bluetooth device in range. Counted
* that way, a room with a television, earbuds and a watch read as a dense
* mesh, and two phones a metre apart passed each other over for minutes.
*
* The pass-over is keyed on the address and a rotating slot: steady within a
* slot, so a crowded scan does not churn, and reconsidered in the next. Keyed
* on the address alone, the same peers were passed over for as long as their
* address lasted.
*/
internal object BleDensityPolicy {
/** How long one pass-over decision holds for an address. */
const val SKIP_SLOT_MS = 60_000L

/** The share of candidates passed over at the high threshold and above. */
const val MAX_SKIP_SHARE = 0.8

/**
* Records [address] as seen at [now] and returns how many distinct
* candidates [seen] holds from the last [windowMs], dropping older ones.
*/
fun recordAndCount(seen: MutableMap<String, Long>, address: String, now: Long, windowMs: Long): Int {
seen[address] = now
seen.entries.removeIf { now - it.value > windowMs }
return seen.size
}

/** The share of candidates to pass over with [meshPeerCount] mesh peers in range. */
fun skipShare(meshPeerCount: Int, lowThreshold: Int, highThreshold: Int): Double {
if (meshPeerCount <= lowThreshold) return 0.0
val density = (meshPeerCount - lowThreshold).toDouble()
val range = (highThreshold - lowThreshold).toDouble()
return minOf(MAX_SKIP_SHARE, density / range * MAX_SKIP_SHARE)
}

/** Whether to pass over the candidate at [address] in the slot holding [now]. */
fun shouldSkip(
address: String,
meshPeerCount: Int,
now: Long,
lowThreshold: Int,
highThreshold: Int,
): Boolean {
val share = skipShare(meshPeerCount, lowThreshold, highThreshold)
if (share == 0.0) return false
return bucket(address, now / SKIP_SLOT_MS) < share
}

/**
* Where [address] falls in [0, 1) for [slot], the same on iOS: the
* address's String.hashCode, folded with the slot and mixed by the
* murmur3 finalizer. Unmixed, consecutive slots of one address landed in
* neighbouring buckets, so a peer passed over in one slot mostly stayed
* passed over.
*/
internal fun bucket(address: String, slot: Long): Double {
var h = address.hashCode() xor (slot.toInt() * GOLDEN_GAMMA)
h = h xor (h ushr 16)
h *= MIX_1
h = h xor (h ushr 13)
h *= MIX_2
h = h xor (h ushr 16)
return (Integer.toUnsignedLong(h) % 1000L) / 1000.0
}

private const val GOLDEN_GAMMA = 0x9E3779B9.toInt()
private const val MIX_1 = 0x85EBCA6B.toInt()
private const val MIX_2 = 0xC2B2AE35.toInt()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.offlineprotocol.ble

import android.bluetooth.BluetoothAdapter

/**
* What one `BluetoothAdapter.ACTION_STATE_CHANGED` broadcast means for a
* running transport. A Bluetooth stack crash reaches apps as the same
* ON -> TURNING_OFF transition a user's toggle does, so both take the
* radio-lost path.
*/
internal enum class AdapterStateTransition {
/** Every link and registration this app held is gone or going. */
RADIO_LOST,

/** The adapter is on again: rebuild now, not on the recovery ladder's next rung. */
RADIO_BACK,

/** An intermediate state (turning on, LE-only); nothing to do yet. */
NONE;

companion object {
fun of(adapterState: Int): AdapterStateTransition = when (adapterState) {
BluetoothAdapter.STATE_TURNING_OFF, BluetoothAdapter.STATE_OFF -> RADIO_LOST
BluetoothAdapter.STATE_ON -> RADIO_BACK
else -> NONE
}
}
}
Loading
Loading