-
-
Notifications
You must be signed in to change notification settings - Fork 117
Expand file tree
/
Copy pathReticulumService.kt
More file actions
392 lines (343 loc) · 17.1 KB
/
Copy pathReticulumService.kt
File metadata and controls
392 lines (343 loc) · 17.1 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
package com.lxmf.messenger.service
import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.util.Log
import androidx.core.content.ContextCompat
import com.lxmf.messenger.service.binder.ReticulumServiceBinder
import com.lxmf.messenger.service.di.ServiceModule
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
/**
* Background service that hosts the Python Reticulum instance.
* Runs as a foreground service to ensure reliability and proper threading for socket I/O.
*
* This solves the Chaquopy threading limitation where background threads for socket I/O
* don't work reliably in the main app process.
*
* Architecture:
* - This class is a thin lifecycle shell (~150 lines vs original 1,762)
* - All business logic is delegated to specialized managers via ServiceModule
* - AIDL implementation is in ReticulumServiceBinder
* - State is managed in ServiceState
*/
class ReticulumService : Service() {
companion object {
private const val TAG = "ReticulumService"
// Actions for service control
const val ACTION_START = "com.lxmf.messenger.service.START"
const val ACTION_STOP = "com.lxmf.messenger.service.STOP"
const val ACTION_RESTART_BLE = "com.lxmf.messenger.RESTART_BLE"
const val ACTION_SET_ALLOW_VOICE_CALLS = "com.lxmf.messenger.SET_ALLOW_VOICE_CALLS"
const val EXTRA_ALLOW_VOICE_CALLS = "allow_voice_calls"
}
// Coroutine scope for background tasks
// Uses Dispatchers.Default for CPU-bound work (JSON parsing, orchestration)
// SupervisorJob ensures child coroutine failures don't cancel the entire service
private val serviceScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
// Managers container (initialized in onCreate)
private lateinit var managers: ServiceModule.ServiceManagers
// AIDL binder (initialized in onCreate)
private lateinit var binder: ReticulumServiceBinder
override fun onCreate() {
super.onCreate()
Log.d(TAG, "Service created")
// Initialize all managers via dependency injection
// Provide callbacks for health monitoring and network changes
managers =
ServiceModule.createManagers(
context = this,
scope = serviceScope,
onStaleHeartbeat = {
Log.e(TAG, "Python heartbeat stale - triggering service restart")
triggerServiceRestart()
},
onNetworkChanged = {
// Trigger AutoInterface hot-add + LXMF announce when network changes.
// CRITICAL: Run in coroutine scope to avoid blocking the ConnectivityManager
// callback thread. Blocking that thread can cause Android's watchdog to kill
// the service, leading to "Service not bound" errors.
Log.d(TAG, "Network changed - restarting AutoInterface and triggering LXMF announce")
// Guard: binder property must be initialized AND Reticulum must be ready
// This prevents announces during service initialization, which can cause
// DataStore race conditions and service crashes
if (::binder.isInitialized && binder.isInitialized()) {
serviceScope.launch {
try {
withTimeout(10_000L) {
// Hot-add any new network interfaces to AutoInterface FIRST,
// so the subsequent announce goes out on the new interface.
// This fixes the bug where starting without WiFi and later
// connecting never discovers AutoInterface peers.
binder.restartAutoInterface()
binder.announceLxmfDestination()
}
// Signal main app's AutoAnnounceManager to reset its timer
// This uses DataStore for cross-process communication
val now = System.currentTimeMillis()
managers.settingsAccessor.saveNetworkChangeAnnounceTime(now)
managers.settingsAccessor.saveLastAutoAnnounceTime(now)
} catch (_: TimeoutCancellationException) {
Log.w(TAG, "LXMF announce timed out on network change")
} catch (e: Exception) {
Log.w(TAG, "Failed to announce on network change", e)
}
}
} else {
Log.d(TAG, "Skipping announce - Reticulum not yet initialized")
}
},
)
// Create notification channel
managers.notificationManager.createNotificationChannel()
// CRITICAL: Start foreground immediately in onCreate to prevent being killed
// before onStartCommand or onBind are called. This is the earliest safe point.
managers.notificationManager.startForeground(this)
Log.d(TAG, "Foreground service started in onCreate")
// CRITICAL: Acquire wake lock early to prevent CPU sleep during initialization
// Previously this was only acquired after Python init, leaving a vulnerable window
managers.lockManager.acquireAll()
Log.d(TAG, "Wake locks acquired in onCreate")
// Clean up stale announces (>30 days old) on each service lifecycle
managers.persistenceManager.cleanupStaleAnnounces()
// Create binder with callbacks
binder =
ServiceModule.createBinder(
context = this,
managers = managers,
scope = serviceScope,
onInitialized = {
Log.d(TAG, "Reticulum initialization complete")
},
onShutdown = {
Log.d(TAG, "Reticulum shutdown complete")
},
onForceExit = {
Log.i(TAG, "Exiting process now...")
System.exit(0)
},
)
}
override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
Log.d(TAG, "Service started with action: ${intent?.action}")
// If the user explicitly shut down the service, don't allow START_STICKY or
// scheduleServiceRestart() to bring it back. Check flag and stop immediately.
val isUserShutdown =
getSharedPreferences("columba_prefs", MODE_PRIVATE)
.getBoolean("is_user_shutdown", false)
if (isUserShutdown && intent?.action != ACTION_STOP && intent?.action != ACTION_START) {
Log.i(TAG, "User shutdown flag set - stopping service instead of restarting")
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
return START_NOT_STICKY
}
// CRITICAL: Always return START_STICKY to ensure Android restarts the service if killed.
// Previously returned START_NOT_STICKY when managers weren't initialized, which meant
// the service wouldn't restart after being killed during the initialization race window.
if (!::managers.isInitialized || !::binder.isInitialized) {
Log.w(TAG, "onStartCommand called before onCreate completed - will retry after init")
// Service will be properly initialized when onCreate completes
// The foreground notification is already started in onCreate
return START_STICKY
}
when (intent?.action) {
ACTION_START -> {
// Clear user shutdown flag — this is an intentional start (app launch or restart)
getSharedPreferences("columba_prefs", MODE_PRIVATE)
.edit()
.putBoolean("is_user_shutdown", false)
.apply()
// Reinforce foreground service with notification (may already be started in onCreate)
managers.notificationManager.startForeground(this)
}
ACTION_STOP -> {
// Shutdown and stop service
Log.d(TAG, "Received ACTION_STOP - forcing process exit")
// Remove notification FIRST — binder.shutdown()'s async Python cleanup
// can crash the process, so ensure the notification is gone before that.
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
if (::managers.isInitialized) managers.state.isPythonShutdownStarted.set(true)
// Stop BLE immediately on Main thread before process exit.
// The normal Python shutdown path (ReticulumWrapper.shutdown() → BLEInterface.detach()
// → AndroidBLEDriver.stop() → KotlinBLEBridge.stop()) won't complete because
// System.exit(0) kills the process before async cleanup finishes.
if (::managers.isInitialized) {
try {
managers.bleCoordinator.stopImmediate()
} catch (e: Exception) {
Log.w(TAG, "Error during BLE immediate shutdown", e)
}
}
try {
binder.shutdown()
} catch (e: Exception) {
Log.w(TAG, "Error during shutdown cleanup (process exiting anyway)", e)
}
// Force process exit to ensure service truly stops even with active bindings
// This is safe because we're in a separate :reticulum process
System.exit(0)
}
ACTION_RESTART_BLE -> {
// Restart BLE interface after permissions granted
handleRestartBle(intent)
}
ACTION_SET_ALLOW_VOICE_CALLS -> {
// UI process flipped the master "Allow voice calls" toggle.
// SharedPreferences.OnSharedPreferenceChangeListener does NOT
// fire across processes, so the UI explicitly signals the
// service via this Intent. The default `true` keeps existing
// behaviour if the extra is missing (e.g., stale broadcast).
val allowed = intent.getBooleanExtra(EXTRA_ALLOW_VOICE_CALLS, true)
Log.i(TAG, "Received ACTION_SET_ALLOW_VOICE_CALLS → $allowed")
if (::binder.isInitialized) {
binder.setAllowVoiceCalls(allowed)
} else {
Log.w(TAG, "Service not yet initialized — Allow voice calls state will be applied at setupCallManager")
}
}
}
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder {
Log.d(TAG, "Service bound")
// Guard against calls before onCreate completes
if (!::managers.isInitialized || !::binder.isInitialized) {
Log.e(TAG, "onBind called before onCreate completed")
throw IllegalStateException("Service not initialized")
}
// Start as foreground when first client binds
managers.notificationManager.startForeground(this)
// Mark service as bound (broadcaster will notify readiness callback)
managers.broadcaster.setServiceBound(true)
return binder
}
override fun onRebind(intent: Intent?) {
Log.d(TAG, "Service rebound")
if (::managers.isInitialized) {
managers.notificationManager.startForeground(this)
managers.broadcaster.setServiceBound(true)
}
}
override fun onUnbind(intent: Intent?): Boolean {
Log.d(TAG, "Service unbound")
// CRITICAL: Reinforce foreground status when all clients disconnect.
// This ensures the service stays protected even when no clients are bound.
// The service should continue running in foreground to receive messages.
if (::managers.isInitialized) {
managers.notificationManager.startForeground(this)
Log.d(TAG, "Reinforced foreground status after unbind")
}
// Return true to allow rebinding without destroying the service
return true
}
override fun onDestroy() {
super.onDestroy()
Log.d(TAG, "Service destroyed")
// Set kill switch to prevent SIGSEGV from late Python calls
if (::managers.isInitialized) {
managers.state.isPythonShutdownStarted.set(true)
}
// Clean up all resources (if initialized)
if (::managers.isInitialized) {
managers.notificationManager.resetSyncNotification()
managers.networkChangeManager.stop()
managers.healthCheckManager.stop()
managers.eventHandler.stopAll()
managers.lockManager.releaseAll()
managers.broadcaster.kill()
// Safety net: stop BLE if not already stopped by ACTION_STOP
managers.bleCoordinator.stopImmediate()
}
serviceScope.cancel()
// Only schedule restart if the user didn't explicitly shut down the service
val isUserShutdown =
getSharedPreferences("columba_prefs", MODE_PRIVATE)
.getBoolean("is_user_shutdown", false)
if (isUserShutdown) {
Log.d(TAG, "User shutdown flag set - skipping service restart")
} else {
// Schedule explicit service restart (Sideband-inspired auto-restart)
// This ensures service comes back up even if Android delays START_STICKY restart
scheduleServiceRestart()
}
}
/**
* Trigger service restart via shutdown and restart.
* Called when Python heartbeat becomes stale (process may be hung).
*/
private fun triggerServiceRestart() {
Log.w(TAG, "Triggering service restart due to stale heartbeat")
// Set kill switch before shutdown to prevent SIGSEGV during teardown
if (::managers.isInitialized) managers.state.isPythonShutdownStarted.set(true)
// Stop BLE immediately before process exit
if (::managers.isInitialized) {
try {
managers.bleCoordinator.stopImmediate()
} catch (e: Exception) {
Log.w(TAG, "Error during BLE immediate shutdown in restart", e)
}
}
// Clean up current state
if (::binder.isInitialized) {
binder.shutdown()
}
// Force process restart to get a clean Python environment
scheduleServiceRestart()
System.exit(1) // Non-zero exit to indicate abnormal termination
}
/**
* Schedule explicit service restart.
* Inspired by Sideband's auto-restart pattern in PythonService.java.
*/
private fun scheduleServiceRestart() {
try {
val restartIntent =
Intent(applicationContext, ReticulumService::class.java).apply {
action = ACTION_START
}
// Start foreground service - Android will handle queueing if process is dying
ContextCompat.startForegroundService(applicationContext, restartIntent)
Log.d(TAG, "Service restart scheduled")
} catch (e: Exception) {
Log.e(TAG, "Failed to schedule service restart", e)
// START_STICKY should still trigger restart eventually
}
}
/**
* Handle BLE restart intent with optional test mode configuration.
*/
private fun handleRestartBle(intent: Intent) {
Log.d(TAG, "Received ACTION_RESTART_BLE - restarting BLE interface")
Log.d(TAG, "Intent extras: ${intent.extras?.keySet()?.joinToString()}")
serviceScope.launch {
try {
val bridge = managers.bleCoordinator.getBridge()
// Test mode: Initialize bridge with UUIDs from intent extras
// This solves the cross-process singleton issue in instrumented tests
val testServiceUuid = intent.getStringExtra("test_service_uuid")
Log.d(TAG, "testServiceUuid = $testServiceUuid")
if (testServiceUuid != null) {
val testRxCharUuid = intent.getStringExtra("test_rx_char_uuid")!!
val testTxCharUuid = intent.getStringExtra("test_tx_char_uuid")!!
val testIdentityCharUuid = intent.getStringExtra("test_identity_char_uuid")!!
Log.d(TAG, "Test mode - initializing BLE with UUIDs from intent extras")
bridge.start(testServiceUuid, testRxCharUuid, testTxCharUuid, testIdentityCharUuid)
}
bridge.restart()
Log.d(TAG, "BLE restart complete")
} catch (e: Exception) {
Log.e(TAG, "Error restarting BLE interface", e)
}
}
}
}