-
Notifications
You must be signed in to change notification settings - Fork 3
Expand file tree
/
Copy pathOfflineProtocolModule.kt
More file actions
5218 lines (4825 loc) · 231 KB
/
Copy pathOfflineProtocolModule.kt
File metadata and controls
5218 lines (4825 loc) · 231 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package com.offlineprotocol
import android.app.Activity
import android.app.Application
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.util.Log
import com.facebook.react.bridge.*
import com.facebook.react.modules.core.DeviceEventManagerModule
import org.json.JSONArray
import org.json.JSONObject
import kotlin.math.max
import kotlin.math.min
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledExecutorService
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import com.offlineprotocol.ble.BleTransportFacade
// Import generated UniFFI bindings
import uniffi.offline_protocol.*
/**
* UniFFI-based React Native module
*/
class OfflineProtocolModule(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext), LifecycleEventListener {
/**
* Gates the foreground relay reconnect on how long the app actually stayed
* backgrounded, fed [android.os.SystemClock.elapsedRealtime] (monotonic AND
* sleep-inclusive). Paired with iOS's ForegroundReconnectPolicy so both
* platforms heal on foreground automatically and identically. Main-thread
* confined: React Native delivers the host lifecycle callbacks there.
*/
private val foregroundReconnectPolicy = ForegroundReconnectPolicy()
/**
* The protocol handle.
*
* Written on the native-modules thread (`create`, [destroy], [invalidate])
* and read on the main thread by the process lifecycle watcher through
* [notifyAppStateQuietly]. Volatile for the reason [sawActivityStart] is:
* without it the main thread can go on reading the null it saw before
* `create`, skip the background notification, and leave open the
* telemetry session that notification would have closed.
*/
@Volatile
private var protocol: OfflineProtocol? = null
private var meshServices: MeshServices? = null
private var dataStore: DataStore? = null
private var bleTransport: BleTransportFacade? = null
private var internetManager: InternetManager? = null
private var wifiDirectManager: WifiDirectManager? = null
private var reticulumManager: ReticulumManager? = null
private var nostrManager: NostrManager? = null
private var processScheduler: ScheduledExecutorService? = null
/**
* Our registration in [MeshForegroundService]'s process-global stop slot,
* kept so teardown can clear it by identity rather than unconditionally.
* See [releaseForegroundStopCallback].
*
* Volatile because `start()` writes it from the JS thread while
* [invalidate] reads it from React Native's teardown, with no lock between
* them: a stale read there would skip the clear and leave this module — and
* its ReactContext — pinned by the companion field.
*/
@Volatile
private var foregroundStopCallback: (() -> Unit)? = null
/**
* How many JS subscriptions React Native believes this module has, as
* reported by [addListener] / [removeListeners].
*
* Atomic because it is written from the thread React Native delivers those
* calls on and read from wherever an event happens to originate — including
* the `"mesh-user-stop"` thread [handleUserRequestedMeshStop] spawns, which
* shares no lock and no happens-before edge with the writer. A plain `Int`
* there may legally read a stale `0` while JS is fully subscribed, and
* [sendEvent] would drop the event on it. That is not a theoretical race:
* the event it drops is the one report of a teardown the app did not
* initiate. Same reasoning as [foregroundStopCallback] above.
*
* A correct read is necessary but not sufficient — see [stickyEvents] for
* the events that must survive a genuinely shut gate.
*/
private val listenerCount = AtomicInteger(0)
/**
* Redelivers one-shot events that could not be handed to JS, on the next
* subscribe or foreground. See [sendStickyEvent] for which events qualify
* and why the others must not, and [StickyEventDispatcher] for the three
* orderings that make redelivery correct.
*/
private val stickyEvents = StickyEventDispatcher(
buffer = StickyEventBuffer(),
canEmit = { canEmitToJs() },
emit = { eventJson -> sendEvent(EVENT_NAME, eventParams(eventJson)) },
schedule = { runnable ->
try {
reactApplicationContext.runOnJSQueueThread(runnable)
} catch (e: Exception) {
// runOnJSQueueThread asserts the queue threads were
// initialized; a context torn down under us throws
// (AssertionException, a RuntimeException) rather than
// returning false. The events stay held for the next trigger.
android.util.Log.w(NAME, "Could not schedule sticky event flush", e)
false
}
},
)
/**
* Redelivers *inbound message* events that could not be handed to JS —
* `message_received`, `file_received`, `message_decryption_failed` — on
* the next subscribe or foreground. See [BUFFERED_INBOUND_EVENT_TYPES] for
* why these three qualify and [holdInboundEventIfBuffered] for the gate.
*
* A second [StickyEventDispatcher] rather than more keys in [stickyEvents]
* because the two hold different things. A one-shot event collapses per
* type and the buffer is sized for two keys; an inbound event is one
* message the core has already ACKed and will never restate, so every one
* must survive on its own key (`type:message_id`) and the cap has to be a
* real capacity — 256, the oldest dropped past it — rather than a backstop.
*/
private val inboundEvents = StickyEventDispatcher(
buffer = StickyEventBuffer(maxEntries = INBOUND_EVENT_BUFFER_CAPACITY),
canEmit = { canEmitToJs() },
emit = { eventJson -> sendEvent(EVENT_NAME, eventParams(eventJson)) },
schedule = { runnable ->
try {
reactApplicationContext.runOnJSQueueThread(runnable)
} catch (e: Exception) {
android.util.Log.w(NAME, "Could not schedule inbound event flush", e)
false
}
},
)
/**
* The started activities, which is what tells the process apart from an
* activity, and the watcher that maintains them. Held by identity rather
* than counted; see [installProcessLifecycleWatcher] for why, and for
* why the boundary follows these rather than `onHostPause`.
*
* Written only from the main thread, where every
* `ActivityLifecycleCallbacks` method is delivered, but **read from the
* native-modules thread**: [enableTelemetry] seeds the pipe's starting
* state from these, and [invalidate] tears the watcher down. Neither is
* on main, so the set is synchronized and the flag is `@Volatile`;
* without that a seed can read a stale set under the Java memory model
* and start a session on the wrong side of the boundary. Declared here
* because the `init` below assigns them, and a property initializer
* running after that `init` would overwrite the watcher with null and
* silently unhook the session boundary.
*/
private val startedActivities: MutableSet<Activity> = java.util.Collections.synchronizedSet(
java.util.Collections.newSetFromMap(java.util.IdentityHashMap<Activity, Boolean>())
)
/**
* Whether the watcher has ever seen an activity start.
*
* Until it has, an empty [startedActivities] means "nothing observed
* yet", not "nothing running": this module is constructed around the host
* activity's `onCreate`, so the first `onStart` can land before the
* watcher is registered. [seedAppState] uses this to tell the two apart.
*/
@Volatile
private var sawActivityStart = false
private var processLifecycleWatcher: Application.ActivityLifecycleCallbacks? = null
init {
// Drive the foreground relay-heal from the host activity's lifecycle so
// Android matches iOS: both platforms reconnect automatically on
// foreground after a background stay long enough to have killed the
// socket. See onHostPause/onHostResume and ForegroundReconnectPolicy.
//
// Must stay *below* every field onHostResume touches — today
// foregroundReconnectPolicy and stickyEvents. `addLifecycleEventListener`
// replays onHostResume when the host is already RESUMED, and while it
// does so through `runOnUiQueueThread` (which always posts, so it can
// never run inline in this constructor), depending on that to keep a
// `val` from being read before its initializer is a hazard with no
// upside. Declaration order costs nothing and makes it structural.
reactContext.addLifecycleEventListener(this)
// Telemetry session boundaries follow the process, not the activity.
installProcessLifecycleWatcher()
}
private var currentConfig: ProtocolConfig? = null
companion object {
const val NAME = "OfflineProtocolModule"
const val EVENT_NAME = "OfflineProtocol_Event"
/**
* The two *one-shot* event tags, which double as their
* [StickyEventBuffer] keys — see [sendStickyEvent] for why only these
* two qualify.
*
* Named rather than spelled out at each site because the tag and the
* key have to agree across sites that are nowhere near each other:
* [EVENT_INTERNET_SESSION_SUPERSEDED] is emitted in
* [emitInternetSupersededEvent] and discarded in [enableTransport],
* over a thousand lines apart. A typo in either would compile, pass
* every check in CI, and silently drop half the mechanism — this module
* cannot be unit-tested (`react-android` is `compileOnly` in the test
* harness), and `StickyEventDispatcherTest` necessarily uses its own
* literals, so nothing else is watching. A constant makes the
* key-equals-tag invariant the compiler's problem.
*/
private const val EVENT_MESH_STOPPED_BY_USER = "mesh_stopped_by_user"
/**
* @see EVENT_MESH_STOPPED_BY_USER
*
* Aliased from [SupersededLatchPolicy.EVENT_TYPE] rather than spelled
* again, so the tag has exactly one definition across both platforms —
* and, unlike a literal here, one a unit test can pin (this module has
* no test harness; the policy does, on both platforms).
*/
private const val EVENT_INTERNET_SESSION_SUPERSEDED = SupersededLatchPolicy.EVENT_TYPE
/** How long `destroy` waits for an in-flight process tick to finish. */
private const val PROCESS_SHUTDOWN_TIMEOUT_MS = 2_000L
/**
* The inbound event tags [inboundEvents] holds when JS cannot take
* them. Each reports one message the core has already ACKed,
* dedup-marked and dropped its queued copy of — the sender will not
* resend and nothing will restate it — so a drop here is a lost
* message, and the drop is ordinary: the React instance is down while
* the app is backgrounded, or a push injection lands before JS has
* subscribed. Must match `BUFFERED_INBOUND_EVENT_TYPES` in
* `src/constants.ts` and `InboundEventBuffer.bufferedEventTypes` on
* iOS; pinned by `react_native_buffered_inbound_event_set_matches_native`.
*/
private val BUFFERED_INBOUND_EVENT_TYPES: Set<String> = setOf(
"message_received",
"file_received",
"message_decryption_failed",
)
/** Inbound events held at most; the oldest is dropped past it. */
private const val INBOUND_EVENT_BUFFER_CAPACITY = 256
}
private object Constants {
const val MIN_BATTERY_LEVEL = 0
const val MAX_BATTERY_LEVEL = 100
const val MIN_HISTORY_WINDOW = 1L
const val MAX_HISTORY_WINDOW = 100L
const val BLE_RESTART_DELAY_MS = 1000L
// Matches iOS 100ms tick. Handles retries, ACK timeouts, welcome
// processing, and DORS. Latency-sensitive work is also event-driven.
const val PROCESS_INTERVAL_MS = 100L
const val MAX_RECEIVE_DRAIN_PER_TICK = 100
const val LOG_INTERVAL_MS = 5000L
const val LOG_INTERVAL_THRESHOLD_MS = 100L
const val DEFAULT_RSSI_THRESHOLD: Short = -85
const val DEFAULT_CONGESTION_QUEUE = 50L
const val DEFAULT_STABILITY_WINDOW = 8L
const val DEFAULT_QUEUE_RECOVERY_RATIO = 0.5f
const val HTTPS_PORT = 443
const val HTTP_PORT = 80
const val MILLISECONDS_PER_SECOND = 1000L
}
override fun getName(): String = NAME
/**
* Deliberately NOT `@Synchronized`, and deliberately inline rather than
* routed through [stopTransportsAndProtocol]: React Native may call this
* while holding its own teardown lock, so taking the module monitor here —
* and holding it across calls that reach back into RN internals — is a
* lock-order inversion. The residual is a double-stop of a transport racing
* `stop()`, which every transport tolerates. Please don't "fix" that by
* adding the annotation.
*
* The steps still run through [TeardownSequence] — a plain helper that
* takes no locks, so it does not reintroduce that inversion — because a
* throwing transport must not skip the ones after it, the keep-alive
* service, or the callback clear that unpins this module. Failures only
* reach logcat: `invalidate` has no error channel, and reporting through
* [emitDiagnostic] would push events into a ReactContext that is already
* going down.
*/
override fun invalidate() {
super.invalidate()
val teardown = TeardownSequence()
teardown.step("lifecycle listener") {
reactApplicationContext.removeLifecycleEventListener(this)
}
teardown.step("process lifecycle watcher") { removeProcessLifecycleWatcher() }
teardown.step("process scheduler") { stopProcessScheduler() }
// The null-outs sit outside their steps so a transport that throws on
// stop is still released.
teardown.step("BLE manager") { bleTransport?.stop() }
bleTransport = null
teardown.step("Internet manager") { internetManager?.stop() }
internetManager = null
teardown.step("WiFi Direct manager") { wifiDirectManager?.stop() }
wifiDirectManager = null
teardown.step("Reticulum manager") { reticulumManager?.stop() }
reticulumManager = null
teardown.step("Nostr manager") { nostrManager?.stop() }
nostrManager = null
// Same reason as in [destroy]: the handle owns the telemetry pipe's
// uploader thread, and invalidate is the last moment this module can
// release it deterministically.
val protocolHandle = protocol
protocol = null
teardown.step("protocol handle") { protocolHandle?.destroy() }
teardown.step("mesh foreground service") { stopForegroundService() }
releaseForegroundStopCallback()
for (failure in teardown.failures) {
android.util.Log.w(NAME, "Teardown step failed during invalidate: ${failure.step}", failure.cause)
}
}
/**
* Drops this module's registration from [MeshForegroundService] — a
* companion field that captures the module, so leaving it set outlives the
* module and its ReactContext for the process lifetime.
*
* Clears by identity: the slot is process-global while modules are
* per-ReactContext, so during a reload this module may be tearing down
* *after* its replacement registered, and an unconditional null would
* disarm the live host's Stop action.
*
* A backstop, not the main path, and idempotent so it stays cheap as one:
* mesh teardown surrenders the registration through [stopForegroundService]
* instead, because the slot is the service's sticky-restart liveness signal
* and must fall with the mesh rather than with the module. This covers a
* teardown that never reached that step.
*/
private fun releaseForegroundStopCallback() {
val ours = foregroundStopCallback ?: return
foregroundStopCallback = null
MeshForegroundService.clearStopRequestCallback(ours)
}
// MARK: - Host lifecycle (foreground relay heal)
/**
* App went to background. Record the moment so [onHostResume] can measure
* the stay. A background long enough to have killed the relay TCP (Doze, OS
* freeze, network handoff) leaves the cached ready flags stale-true against
* a dead socket, which only a full reconnect heals.
*/
override fun onHostPause() {
foregroundReconnectPolicy.didEnterBackground(nowMs = android.os.SystemClock.elapsedRealtime())
// No telemetry session boundary here. `onHostPause` is
// `Activity.onPause`, which fires for a permission dialog or a share
// sheet as readily as for the app going away — good enough to arm the
// relay heal above, which only measures a stay, and too coarse to end
// a session. See [installProcessLifecycleWatcher].
}
/**
* App returned to foreground. Proactively heal a socket the OS likely killed
* while backgrounded: `isReady()` cannot tell a healthy socket from a zombie
* (both report connected+authenticated), so gate on background duration
* instead (see ForegroundReconnectPolicy). forceReconnect() no-ops unless
* the transport is running/starting and resets the reconnect backoff, so it
* is safe to call unconditionally within the gate. Mirrors iOS's
* applicationWillEnterForeground — apps no longer need to call
* forceInternetReconnect() on foreground themselves.
*/
override fun onHostResume() {
if (foregroundReconnectPolicy.shouldReconnectOnForeground(nowMs = android.os.SystemClock.elapsedRealtime())) {
internetManager?.forceReconnect()
}
// No telemetry session boundary here either; its pair lives in
// installProcessLifecycleWatcher.
// The other flush trigger besides [addListener]. An app whose listeners
// never went away still needs one: a sticky event held because the React
// instance was briefly down would otherwise wait for a resubscribe that
// is never coming.
flushStickyEvents()
}
override fun onHostDestroy() {
// No-op: teardown is handled by invalidate(); nothing lifecycle-specific
// to release here.
}
// MARK: - Process lifecycle (telemetry session boundaries)
/**
* Draws the telemetry session boundary on the *process* leaving the
* foreground, not on an activity being paused.
*
* [onHostPause] is `Activity.onPause`, which fires for anything that comes
* in front of the host: a runtime permission dialog — including the
* Bluetooth one this SDK itself triggers on first launch — the system
* share sheet, any translucent activity. A boundary there emits a session
* summary and rotates the session id every time a user grants a
* permission, so the same user journey would be counted as several
* sessions on Android and one on iOS, which uses `didEnterBackground` and
* deliberately ignores the transient `Inactive` state. Every activity
* being stopped is the signal that actually means "not visible", and is
* what `androidx.lifecycle`'s `ProcessLifecycleOwner` is built on;
* counting it here keeps that dependency off every consumer of this
* package.
*
* A configuration change (a rotation) stops and restarts the activity
* without the app going anywhere, so `isChangingConfigurations` gates the
* background edge. The matching `ACTIVE` needs no such gate: the pipe
* debounces its own edges, so an `ACTIVE` with no `BACKGROUND` before it
* is not an edge and does nothing.
*
* The started activities are held as an identity set rather than counted.
* React Native constructs its native modules after the host activity's
* `onStart`, so a counter starts at zero while an activity is already
* visible, and the first stop it sees is unmatched. Clamping that at zero
* hides it only while there is one activity: open a second in-process
* activity (a sign-in hub, an image picker, a payment sheet) and the
* sequence is start B, stop A, which takes the count to zero and fires a
* background edge with the app fully on screen — then start A on the way
* back, rotating the session. One journey becomes two sessions on
* Android and one on iOS, which is the asymmetry this whole function
* exists to avoid. A set is right whether or not the first activity was
* ever tracked: an untracked A stopping while B is tracked leaves the set
* non-empty, and the edge waits for B.
*/
private fun installProcessLifecycleWatcher() {
val application = reactApplicationContext.applicationContext as? Application
if (application == null) {
Log.w(
NAME,
"No Application context: telemetry sessions will not follow the process lifecycle"
)
return
}
val watcher = object : Application.ActivityLifecycleCallbacks {
override fun onActivityStarted(activity: Activity) {
sawActivityStart = true
startedActivities.add(activity)
notifyAppStateQuietly(AppState.ACTIVE)
}
override fun onActivityStopped(activity: Activity) {
startedActivities.remove(activity)
if (startedActivities.isEmpty() && !activity.isChangingConfigurations) {
// The pipe emits its summary and wakes the uploader
// thread; nothing here blocks.
notifyAppStateQuietly(AppState.BACKGROUND)
}
}
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {}
override fun onActivityResumed(activity: Activity) {}
override fun onActivityPaused(activity: Activity) {}
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {}
override fun onActivityDestroyed(activity: Activity) {
// An activity destroyed without a matching stop would sit in
// the set forever and hold the background edge off.
startedActivities.remove(activity)
}
}
application.registerActivityLifecycleCallbacks(watcher)
processLifecycleWatcher = watcher
}
private fun removeProcessLifecycleWatcher() {
val watcher = processLifecycleWatcher ?: return
processLifecycleWatcher = null
// Unregister first, then clear. The other order lets a callback
// already in flight on the main thread mutate the set this thread is
// clearing, and leaves an entry behind in the set it just emptied.
(reactApplicationContext.applicationContext as? Application)
?.unregisterActivityLifecycleCallbacks(watcher)
startedActivities.clear()
}
/**
* Reports a lifecycle transition to the pipe, absorbing the throw a
* concurrently destroyed handle raises.
*
* This runs on the main thread, from `ActivityLifecycleCallbacks`, while
* [destroy] and [invalidate] run on the native-modules thread. UniFFI
* throws `IllegalStateException` once a handle's call counter has closed,
* and an exception escaping an `ActivityLifecycleCallbacks` method takes
* the process down. Logging out while the app is being backgrounded is
* exactly that race. Losing the boundary is the correct outcome there:
* the pipe it would have reported to is already stopped, and its final
* flush ran on the teardown path.
*
* Every exception, not just that one. The reason for catching is the
* consequence of not catching, and taking the process down is the same
* consequence whatever the type: UniFFI surfaces a panic in the core as
* `InternalException`, which is not an `IllegalStateException`, and a
* telemetry lifecycle hint is never worth a crash.
*/
private fun notifyAppStateQuietly(state: AppState) {
try {
protocol?.notifyAppState(state)
} catch (e: Exception) {
Log.d(NAME, "Telemetry lifecycle transition skipped: the protocol handle is gone", e)
}
}
/**
* The application state to seed a new telemetry pipe with.
*
* This has to be measured the way the watcher measures its edges, not the
* way React Native reports its own lifecycle. `lifecycleState` is
* `RESUMED` only between `onResume` and `onPause`, so it reads
* `BEFORE_RESUME` while a permission dialog or share sheet sits over a
* fully visible app. A pipe seeded `BACKGROUND` there has its boundary
* armed with nothing to disarm it, because dismissing a dialog fires
* `onActivityResumed` and no `onActivityStarted`: the next real
* background is then read as a non-edge and reports nothing at all. The
* demo app calls `enableTelemetry` immediately after `start()`, which is
* exactly when a first-run permission prompt is on screen.
*
* Three sources, in falling order of authority:
*
* 1. The watcher's own set, once it has seen a start. This is the same
* signal the edges use, so a seed from it can never disagree with them.
* 2. A live current activity. React Native constructs its native modules
* around the host activity's `onCreate`, so the watcher can miss that
* activity's `onStart` and never see one; `currentActivity` survives a
* pause and is cleared only on host destroy, so a non-null value still
* means the process has a foreground activity.
* 3. Otherwise `BACKGROUND`: no activity has ever resumed, which is what
* a headless mesh wake or a service-only launch looks like.
*
* The order also puts the cheaper error first. Seeding `ACTIVE` while
* actually backgrounded costs one missed rotation and no data. Seeding
* `BACKGROUND` while actually foregrounded arms the boundary and loses
* the next real session summary, which is the bug this replaced.
*/
private fun seedAppState(): AppState = when {
sawActivityStart -> if (startedActivities.isEmpty()) AppState.BACKGROUND else AppState.ACTIVE
reactApplicationContext.currentActivity != null -> AppState.ACTIVE
else -> AppState.BACKGROUND
}
@ReactMethod
fun addListener(eventName: String) {
listenerCount.incrementAndGet()
// A subscription is the moment a held one-shot event becomes
// deliverable. The flush defers itself onto the JS queue rather than
// emitting here — see [flushStickyEvents] for why that is required
// rather than tidy.
flushStickyEvents()
}
@ReactMethod
fun removeListeners(count: Double) {
listenerCount.updateAndGet { (it - count.toInt()).coerceAtLeast(0) }
}
/**
* Accepts both the current vocabulary and the pre-0.22 `low`/`medium`/`high`
* spelling of the same three values, so an app that has not migrated its
* config keeps working.
*/
private fun normalizeRelayPriority(priority: String?): RelayPriority? {
if (priority.isNullOrBlank()) {
return null
}
return when (priority.lowercase()) {
"never", "low" -> RelayPriority.NEVER
"auto", "medium" -> RelayPriority.AUTO
"always", "high" -> RelayPriority.ALWAYS
else -> null
}
}
private fun relayPriorityName(priority: RelayPriority?): String = when (priority) {
RelayPriority.NEVER -> "never"
RelayPriority.ALWAYS -> "always"
else -> "auto"
}
private fun applyInitialRuntimeConfig(proto: OfflineProtocol, json: JSONObject) {
json.optJSONObject("dors")?.let { dorsJson ->
try {
val baseConfig = proto.getDorsConfig()
val updatedConfig = baseConfig.copy(
preferOnline = dorsJson.optBooleanCompat("preferOnline", "prefer_online")
?: baseConfig.preferOnline,
switchHysteresis = dorsJson.optDoubleCompat("switchHysteresis", "switch_hysteresis")
?.toFloat()
?.coerceAtLeast(0f)
?: baseConfig.switchHysteresis,
switchCooldownSecs = dorsJson.optLongCompat("switchCooldownSecs", "switch_cooldown_secs")
?.coerceAtLeast(0)
?.toULong()
?: baseConfig.switchCooldownSecs,
bleToWifiRetryThreshold = dorsJson.optIntCompat("bleToWifiRetryThreshold", "ble_to_wifi_retry_threshold")
?.coerceAtLeast(0)
?.toUInt()
?: baseConfig.bleToWifiRetryThreshold,
minSuccessRateBeforeEscalation = dorsJson.optDoubleCompat("minSuccessRateBeforeEscalation", "min_success_rate_before_escalation")
?.toFloat()
?.coerceIn(0f, 1f)
?: baseConfig.minSuccessRateBeforeEscalation,
minBleSamplesBeforeSuccessRateEscalation = dorsJson.optLongCompat("minBleSamplesBeforeSuccessRateEscalation", "min_ble_samples_before_success_rate_escalation")
?.coerceAtLeast(0)
?.toULong()
?: baseConfig.minBleSamplesBeforeSuccessRateEscalation,
rssiSwitchThreshold = dorsJson.optIntCompat("rssiSwitchThreshold", "rssi_switch_threshold")
?.coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt())
?.toShort()
?: baseConfig.rssiSwitchThreshold,
congestionQueueThreshold = dorsJson.optLongCompat("congestionQueueThreshold", "congestion_queue_threshold")
?.coerceAtLeast(0)
?.toULong()
?: baseConfig.congestionQueueThreshold,
stabilityWindowSecs = dorsJson.optLongCompat("stabilityWindowSecs", "stability_window_secs")
?.coerceAtLeast(0)
?.toULong()
?: baseConfig.stabilityWindowSecs,
poorSignalDurationSecs = dorsJson.optLongCompat("poorSignalDurationSecs", "poor_signal_duration_secs")
?.coerceAtLeast(0)
?.toULong()
?: baseConfig.poorSignalDurationSecs,
ttlEscalationThreshold = dorsJson.optIntCompat("ttlEscalationThreshold", "ttl_escalation_threshold")
?.coerceIn(0, UByte.MAX_VALUE.toInt())
?.toUByte()
?: baseConfig.ttlEscalationThreshold,
congestionDurationSecs = dorsJson.optLongCompat("congestionDurationSecs", "congestion_duration_secs")
?.coerceAtLeast(0)
?.toULong()
?: baseConfig.congestionDurationSecs,
ttlEscalationHoldSecs = dorsJson.optLongCompat("ttlEscalationHoldSecs", "ttl_escalation_hold_secs")
?.coerceAtLeast(1)
?.toULong()
?: baseConfig.ttlEscalationHoldSecs,
historyWindowSize = dorsJson.optLongCompat("historyWindowSize", "history_window_size")
?.let { max(Constants.MIN_HISTORY_WINDOW, min(Constants.MAX_HISTORY_WINDOW, it)) }
?.toULong()
?: baseConfig.historyWindowSize,
queueRecoveryRatio = dorsJson.optDoubleCompat("queueRecoveryRatio", "queue_recovery_ratio")
?.toFloat()
?.coerceIn(0f, 1f)
?: baseConfig.queueRecoveryRatio,
lowBatteryThreshold = dorsJson.optIntCompat("lowBatteryThreshold", "low_battery_threshold")
?.coerceIn(Constants.MIN_BATTERY_LEVEL, Constants.MAX_BATTERY_LEVEL)
?.toUByte()
?: baseConfig.lowBatteryThreshold,
relayMinBatteryLevel = dorsJson.optIntCompat("relayMinBatteryLevel", "relay_min_battery_level")
?.coerceIn(Constants.MIN_BATTERY_LEVEL, Constants.MAX_BATTERY_LEVEL)
?.toUByte()
?: baseConfig.relayMinBatteryLevel,
relayOptimalConnectionCount = dorsJson.optIntCompat("relayOptimalConnectionCount", "relay_optimal_connection_count")
?.coerceIn(0, UByte.MAX_VALUE.toInt())
?.toUByte()
?: baseConfig.relayOptimalConnectionCount
)
proto.updateDorsConfig(updatedConfig)
emitDiagnostic("info", "Applied initial DORS config")
} catch (e: Exception) {
emitDiagnostic("warning", "Failed to apply initial DORS config", mapOf(
"message" to (e.message ?: "unknown")
))
}
}
// The whole relay section, not just the priority: allowRelay and
// minBatteryForRelay used to be parsed by nothing, leaving
// `config.relay` on mobile permanently at its defaults.
json.optJSONObject("relay")?.let { relayJson ->
try {
val current = proto.getRelayConfig()
val priorityRaw = relayJson.safeOptString("relayPriority", relayJson.safeOptString("relay_priority"))
val priority = normalizeRelayPriority(priorityRaw) ?: current.relayPriority
val updated = RelayConfig(
minBatteryForRelay = relayJson.optIntCompat("minBatteryForRelay", "min_battery_for_relay")
?.coerceIn(Constants.MIN_BATTERY_LEVEL, Constants.MAX_BATTERY_LEVEL)?.toUByte()
?: current.minBatteryForRelay,
allowRelay = relayJson.optBooleanCompat("allowRelay", "allow_relay") ?: current.allowRelay,
relayPriority = priority
)
proto.updateRelayConfig(updated)
emitDiagnostic("info", "Applied initial relay config", mapOf(
"priority" to relayPriorityName(updated.relayPriority)
))
} catch (e: Exception) {
emitDiagnostic("warning", "Failed to apply initial relay config", mapOf(
"message" to (e.message ?: "unknown")
))
}
}
}
/**
* Rejects [promise] with the stable typed code when the error maps
* (see ProtocolErrorBridge.kt), otherwise with the method's legacy
* fallback code and "$fallbackMessage: <cause>".
*/
private fun rejectWithProtocolError(
promise: Promise,
error: Throwable,
fallbackCode: String,
fallbackMessage: String
) {
val mapped = mapProtocolBridgeError(error)
if (mapped != null) {
promise.reject(mapped.code, mapped.message, error)
} else {
promise.reject(fallbackCode, "$fallbackMessage: ${error.message}", error)
}
}
@ReactMethod
fun create(configJson: String, promise: Promise) {
try {
val parsed = ProtocolConfigParser.parse(configJson)
val config = parsed.coreConfig
val proto = OfflineProtocol(config)
currentConfig = config
emitDiagnostic("info", "Protocol core created", mapOf(
"appId" to config.appId,
"userId" to config.profile,
"bleEnabled" to config.bleEnabled,
"wifiDirectEnabled" to config.wifiDirectEnabled,
"internetEnabled" to config.internetEnabled,
"reticulumEnabled" to config.reticulumEnabled
))
// Set up event callback
proto.setEventCallback(object : EventCallback {
override fun onEvent(eventJson: String) {
// The gate is checked first, before the payload is built
// or the JSON is parsed: with JS reachable the common path
// costs what it always did, and only a shut gate pays for
// the type/id read that decides whether to hold.
if (!canEmitToJs() && holdInboundEventIfBuffered(eventJson)) {
return
}
sendEvent(EVENT_NAME, eventParams(eventJson))
}
})
applyInitialRuntimeConfig(proto, parsed.rawJson)
protocol = proto
meshServices = MeshServices(proto)
// Constructed unconditionally, like MeshServices: the store is
// inert until the config enables it, and every method answers
// DataDisabled until then. Constructing lazily would only move
// that check somewhere less obvious.
dataStore = DataStore(proto)
// Initialize BLE manager if BLE is enabled
if (config.bleEnabled) {
bleTransport = BleTransportFacade(
reactApplicationContext,
proto,
config.profile,
) { level, message, context ->
emitDiagnostic(level, message, context)
}.also { manager ->
manager.listener = object : TransportManagerListener {
override fun onTransportStateChanged(manager: TransportManager, state: TransportState) {
emitDiagnostic("info", "BLE transport state changed", mapOf(
"state" to state.name.lowercase()
))
}
override fun onTransportError(manager: TransportManager, error: Throwable) {
emitDiagnostic("error", "BLE transport error", mapOf(
"message" to (error.message ?: "unknown"),
"exception" to error.javaClass.simpleName
))
}
override fun onTransportDiagnostic(
manager: TransportManager,
level: String,
message: String,
context: Map<String, Any?>
) {
emitDiagnostic(level, message, context)
}
}
}
android.util.Log.i(NAME, "BLE Manager initialized for user: ${config.profile}")
emitDiagnostic("info", "BLE manager initialized", mapOf(
"userId" to config.profile
))
} else {
emitDiagnostic("warning", "BLE disabled in configuration", mapOf(
"userId" to config.profile
))
}
// Initialize Internet manager if internet is enabled
if (config.internetEnabled) {
internetManager = InternetManager(reactApplicationContext, proto, config.profile) { level, message, context ->
emitDiagnostic(level, message, context)
}.also { manager ->
manager.serverMessageEmitter = { rawJson -> emitServerMessageEvent(rawJson) }
manager.connectionStatusEmitter = { connected, authenticated ->
emitInternetStatusEvent(connected, authenticated)
}
manager.supersededEmitter = { reason -> emitInternetSupersededEvent(reason) }
manager.listener = object : TransportManagerListener {
override fun onTransportStateChanged(manager: TransportManager, state: TransportState) {
emitDiagnostic("info", "Internet transport state changed", mapOf(
"transport" to manager.transportId,
"state" to state.name.lowercase()
))
}
override fun onTransportError(manager: TransportManager, error: Throwable) {
emitDiagnostic("error", "Internet transport error", mapOf(
"transport" to manager.transportId,
"message" to (error.message ?: "unknown"),
"exception" to error.javaClass.simpleName
))
}
override fun onTransportDiagnostic(
manager: TransportManager,
level: String,
message: String,
context: Map<String, Any?>
) {
val enrichedContext = context.toMutableMap()
enrichedContext["transport"] = manager.transportId
emitDiagnostic(level, message, enrichedContext)
}
}
}
android.util.Log.i(NAME, "Internet Manager initialized for user: ${config.profile}")
emitDiagnostic("info", "Internet manager initialized", mapOf(
"userId" to config.profile
))
} else {
emitDiagnostic("info", "Internet disabled in configuration", mapOf(
"userId" to config.profile
))
}
// Initialize Reticulum manager if reticulum is enabled
if (config.reticulumEnabled) {
reticulumManager = createReticulumManager(proto, config.profile)
android.util.Log.i(NAME, "Reticulum Manager initialized for user: ${config.profile}")
emitDiagnostic("info", "Reticulum manager initialized", mapOf(
"userId" to config.profile
))
} else {
emitDiagnostic("info", "Reticulum disabled in configuration", mapOf(
"userId" to config.profile
))
}
// Initialize Nostr manager if nostr is enabled
if (config.nostrEnabled) {
nostrManager = createNostrManager(proto)
android.util.Log.i(NAME, "Nostr Manager initialized for user: ${config.profile}")
emitDiagnostic("info", "Nostr manager initialized", mapOf(
"userId" to config.profile
))
} else {
emitDiagnostic("info", "Nostr disabled in configuration", mapOf(
"userId" to config.profile
))
}
// Wire Rust transport callbacks for event-driven sending.
// These replace the 100ms polling loops; the Rust core calls back into
// Kotlin when outgoing data is enqueued, and the manager drains the queue
// immediately. Requires regenerated UniFFI Android bindings that include
// BleTransportCallback / WifiDirectTransportCallback interfaces.
wireTransportCallbacks(proto)
// Start process scheduler
startProcessScheduler()
emitDiagnostic("info", "Protocol process scheduler started")
promise.resolve(null)
} catch (e: Exception) {
emitDiagnostic("error", "Failed to create protocol", mapOf(
"message" to (e.message ?: "unknown"),
"exception" to e.javaClass.simpleName
))
promise.reject("ERROR_CREATE", "Failed to create protocol: ${e.message}", e)
}
}
/**
* Whether an emit has any chance of reaching JS: something is subscribed,
* and there is a live React instance to carry it.
*
* Shared by [sendEvent] and the paths that want to know the answer *before*
* building a `WritableMap` they may not use. Never a delivery guarantee —
* see [sendEvent] for why nothing at this layer can offer one.
*/
private fun canEmitToJs(): Boolean =
listenerCount.get() > 0 && reactApplicationContext.hasActiveReactInstance()
/** The one-field payload every event on [EVENT_NAME] travels in. */
private fun eventParams(eventJson: String): WritableMap =
Arguments.createMap().apply { putString("eventJson", eventJson) }
/**
* Hands an event to JavaScript, reporting whether it got that far.
*
* Returns false when the gate is shut — no JS subscription, or no live
* React instance — and when the emit itself throws. Most callers ignore the
* result: their events are periodic, re-derivable, or followed by another
* carrying the same state, so a drop costs nothing. [sendStickyEvent] is
* for the ones where it costs everything.
*
* The [ReactContext.hasActiveReactInstance] check is a *precondition*, not
* belt-and-braces around the catch, because on the New Architecture there
* is nothing to catch: `getJSModule` returns a proxy that forwards to
* `ReactHost.callFunctionOnModule`, which reports failure by rejecting a
* Task rather than throwing. "Did not throw" is therefore not evidence of
* delivery on bridgeless, and this guard is the only signal available.
*
* Even a true return is not a delivery receipt — nothing here observes JS
* receiving the event. It means the event was handed over with the instance
* alive and a subscription registered, which is as much as this layer can
* know. [flushStickyEvents] is built around that limit.
*
* It is not even a receipt for *this* event name. React Native's
* [addListener] reports the name but [removeListeners] reports only a count,
* so the subscription tally cannot be kept per name and [listenerCount] is
* shared across [EVENT_NAME] and [TELEMETRY_EVENT_NAME]. A subscription to
* one therefore opens the gate for the other. In-tree that cannot bite —
* the SDK's constructor subscribes to both together — but it is a real
* limit on what a `true` means, and it is the sticky path that leans on
* the answer.
*/
private fun sendEvent(eventName: String, params: Any?): Boolean {
if (!canEmitToJs()) {
return false
}
return try {
reactApplicationContext
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
.emit(eventName, params)
true
} catch (e: Exception) {
android.util.Log.w(NAME, "Event emit failed for $eventName", e)
false
}
}
/**
* Emits a *one-shot* event, holding it for redelivery if JS could not take
* it. For everything else, [sendEvent] and its silent drop are correct.
*
* The bar for enrolling an event here is narrow: nothing else must ever
* restate it. `mesh_stopped_by_user` is the terminal event of the mesh
* lifecycle — after it, every transport, the scheduler and the core are
* down, so there is no later event to carry the same news, and no periodic
* signal to re-derive it from. `internet_session_superseded` is the same
* shape for the relay: InternetManager latches the transport stopped and
* refuses every reconnect path until an explicit start(), so a dropped emit
* means the app never learns it is connected elsewhere.
*
* Sticking a *periodic* event would be actively wrong — a held
* `internet_status_changed` replayed minutes later reports a link state
* that has since changed, which is worse than the drop it replaced. That is
* why the sticky set is a decision at the call site rather than a filter
* over event JSON: a filter would have to parse every event that crosses
* this bridge, including the core callback's, to answer a question only
* two call sites ever ask.
*
* [key] identifies the event for last-wins collapsing, so a repeated stop
* cannot accumulate copies. Callers pass the event's `type` tag.
*
* The orderings that make redelivery correct — generation read before the
* emit, a successful hold re-running the flush, and the hop onto the JS
* queue — live in [StickyEventDispatcher], where they are unit-tested.
*/
private fun sendStickyEvent(key: String, eventJson: String) {
stickyEvents.send(key, eventJson)
}
/**
* Redelivers held one-shot and inbound events, if JS looks able to take
* them now.
*/
private fun flushStickyEvents() {
stickyEvents.flush()
inboundEvents.flush()
}
/**
* Holds an inbound message event for redelivery when JS could not take
* it, keyed `type:message_id` so every message survives on its own.