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
1 change: 1 addition & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ The app uses a layered service architecture with clear separation of concerns:
- `PingService`: TX/RX/Discovery ping orchestration, coordinates with TxTracker/DiscTracker/RxLogger
- `ApiQueueService`: Hive-based persistent upload queue with batch POST and retry logic
- `ApiService`: HTTP client for MeshMapper API endpoints
- `NetworkStateService`: Android constrained and satellite network monitoring; routine uploads use 60-second pacing and auth uses a 30-second timeout on constrained links
- `DeviceModelService`: Loads `assets/device-models.json` for device identification and power reporting

**State Management** (`lib/providers/`):
Expand Down
11 changes: 11 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@

<!-- Internet for API uploads -->
<uses-permission android:name="android.permission.INTERNET" />
<!-- Network state for satellite/constrained-network detection -->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

<!-- Wake lock for auto-ping mode -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
Expand Down Expand Up @@ -73,6 +75,15 @@
android:name="flutterEmbedding"
android:value="2" />

<!-- Opts this app in to Android's satellite/constrained-network routing
(API 36+). Without this, the system never hands the app's default
network traffic to a satellite link even when one is the only
option. See:
https://developer.android.com/develop/connectivity/satellite/constrained-networks -->
<meta-data
android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED"
android:value="net.meshmapper.app" />

<!-- Background service for continuous wardriving -->
<service
android:name="id.flutter.flutter_background_service.BackgroundService"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@ import java.io.File

class MainActivity : FlutterActivity() {
private var usbService: MeshMapperUsbService? = null
private var networkService: MeshMapperNetworkService? = null

override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)

usbService = MeshMapperUsbService(this)
usbService!!.configureFlutterEngine(flutterEngine)

networkService = MeshMapperNetworkService(applicationContext)
networkService!!.configureFlutterEngine(flutterEngine)

// MapLibre tile cache management. Mirrors AppDelegate.swift's iOS
// implementation. Called from Dart's TileCacheService by the Offline
// Maps screen's Tile Cache card.
Expand Down Expand Up @@ -98,6 +102,7 @@ class MainActivity : FlutterActivity() {

override fun onDestroy() {
usbService?.dispose()
networkService?.dispose()
super.onDestroy()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package net.meshmapper.app

import android.content.Context
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.os.Looper
import android.util.Log
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel

/// Reports whether the device's active default network is a bandwidth-constrained
/// link (e.g. satellite), per Android's constrained-networks API introduced in
/// API 36 (Android 16):
/// https://developer.android.com/develop/connectivity/satellite/constrained-networks
///
/// Older OS versions have no notion of NET_CAPABILITY_NOT_BANDWIDTH_CONSTRAINED,
/// so every network on them reports the bit unset. Treating its absence as
/// "constrained" would flag every pre-16 network as constrained. We only run
/// the check on API 36+ and otherwise always report "not constrained".
class MeshMapperNetworkService(private val context: Context) {
private companion object {
const val EVENT_CHANNEL = "meshmapper/network_state"
const val LOG_TAG = "MeshMapperNetwork"
const val MIN_SDK_FOR_CONSTRAINED_CHECK = 36
}

private val connectivityManager =
context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
private val mainHandler = Handler(Looper.getMainLooper())
private var handlerThread: HandlerThread? = null
private var networkCallback: ConnectivityManager.NetworkCallback? = null

@Volatile private var eventSink: EventChannel.EventSink? = null

fun configureFlutterEngine(flutterEngine: FlutterEngine) {
EventChannel(flutterEngine.dartExecutor.binaryMessenger, EVENT_CHANNEL)
.setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
eventSink = events
startMonitoring()
}
override fun onCancel(arguments: Any?) {
stopMonitoring()
eventSink = null
}
},
)
}

fun dispose() {
stopMonitoring()
}

private fun startMonitoring() {
if (Build.VERSION.SDK_INT < MIN_SDK_FOR_CONSTRAINED_CHECK) {
emit(stateMap(constrained = false, satellite = false))
return
}

val thread = HandlerThread("MeshMapperNetworkMonitor").also { it.start() }
handlerThread = thread
val handler = Handler(thread.looper)

val request = NetworkRequest.Builder()
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
.removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_BANDWIDTH_CONSTRAINED)
.build()

val callback = object : ConnectivityManager.NetworkCallback() {
override fun onCapabilitiesChanged(
network: Network,
capabilities: NetworkCapabilities,
) {
emitFrom(capabilities)
}

override fun onLost(network: Network) {
emit(stateMap(constrained = false, satellite = false))
}
}
networkCallback = callback
try {
connectivityManager.registerBestMatchingNetworkCallback(request, callback, handler)
} catch (error: RuntimeException) {
networkCallback = null
handlerThread?.quitSafely()
handlerThread = null
Log.w(LOG_TAG, "[NETWORK] Failed to monitor constrained networks", error)
emit(stateMap(constrained = false, satellite = false))
}
}

private fun emitFrom(capabilities: NetworkCapabilities) {
val satellite = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_SATELLITE)
val constrained =
satellite ||
!capabilities.hasCapability(
NetworkCapabilities.NET_CAPABILITY_NOT_BANDWIDTH_CONSTRAINED,
)
emit(stateMap(constrained, satellite))
}

private fun stateMap(constrained: Boolean, satellite: Boolean): Map<String, Any> =
mapOf("constrained" to constrained, "satellite" to satellite)

/// EventSink.success() must run on the main thread; NetworkCallback
/// methods fire on the HandlerThread registered for them.
private fun emit(state: Map<String, Any>) {
mainHandler.post { eventSink?.success(state) }
}

private fun stopMonitoring() {
networkCallback?.let {
try {
connectivityManager.unregisterNetworkCallback(it)
} catch (_: IllegalArgumentException) {
}
}
networkCallback = null
handlerThread?.quitSafely()
handlerThread = null
}
}
92 changes: 57 additions & 35 deletions lib/services/api_queue_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,37 @@ import '../models/api_queue_item.dart';
import '../utils/debug_logger_io.dart';
import 'api_service.dart';
import 'custom_api_service.dart';
import 'network_state_service.dart';

/// API queue service with batch upload and retry logic
/// Ported from apiQueue and batchUpload() in wardrive.js
///
/// Features:
/// - Queue pings locally with Hive persistence
/// - Batch upload every 50 entries OR 30 seconds
/// - Upload batches contain up to 50 entries and use network-aware timers
/// - RX buffering: group by repeater ID (max 4 per batch)
/// - Retry with exponential backoff for failed uploads
/// - Offline mode: accumulates pings without uploading
class ApiQueueService {
static const String _boxName = 'api_queue';
static const int _batchSize = 50;
static const Duration _batchTimeout = Duration(seconds: 15);
// Wider cadence while on a constrained (e.g. satellite) link: fewer, larger
// batches beat frequent small ones when every round trip carries high
// per-request latency.
static const Duration _batchTimeoutConstrained = Duration(seconds: 60);
static const Duration _pingFlushTimeout = Duration(seconds: 5);
static const Duration _pingFlushTimeoutConstrained = Duration(seconds: 60);
static const int _maxRetries = 5;
static const int _maxRxPerRepeater = 4;

final ApiService _apiService;
final NetworkStateSource _networkState;
Box<ApiQueueItem>? _box;
Timer? _batchTimer;
Timer? _pingFlushTimer;
StreamSubscription<NetworkState>? _networkStateSubscription;
late bool _lastIsConstrained;
bool _isUploading = false;
bool _isRecovering = false;

Expand Down Expand Up @@ -112,7 +122,15 @@ class ApiQueueService {
return true;
}

ApiQueueService({required ApiService apiService}) : _apiService = apiService;
ApiQueueService({
required ApiService apiService,
NetworkStateSource? networkState,
}) : _apiService = apiService,
_networkState = networkState ?? NetworkStateService.instance {
_lastIsConstrained = _networkState.current.isConstrained;
_networkStateSubscription =
_networkState.stream.listen(_handleNetworkState);
}

/// Initialize the queue (must be called before use)
Future<void> init() async {
Expand Down Expand Up @@ -148,6 +166,7 @@ class ApiQueueService {
// Start batch timer
debugLog('[API QUEUE] Starting batch timer...');
_startBatchTimer();

debugLog('[API QUEUE] init() complete');
}

Expand Down Expand Up @@ -328,12 +347,7 @@ class ApiQueueService {
'[API QUEUE] TX enqueued: $heardRepeats (queue size: $queueSize)');
}
onQueueUpdated?.call(queueSize);
_pingFlushTimer?.cancel();
_pingFlushTimer = Timer(const Duration(seconds: 5), () {
debugLog('[API QUEUE] Ping flush timer fired');
_flushRxBuffer();
_uploadBatch();
});
_schedulePingFlush();
}

/// Enqueue an RX observation
Expand Down Expand Up @@ -435,12 +449,7 @@ class ApiQueueService {
'[API QUEUE] DISC enqueued: $repeaterId ($nodeType) at $latitude, $longitude (queue size: $queueSize)');
}
onQueueUpdated?.call(queueSize);
_pingFlushTimer?.cancel();
_pingFlushTimer = Timer(const Duration(seconds: 5), () {
debugLog('[API QUEUE] Ping flush timer fired');
_flushRxBuffer();
_uploadBatch();
});
_schedulePingFlush();
}

/// Enqueue a TRACE ping result (targeted zero-hop trace)
Expand Down Expand Up @@ -491,12 +500,7 @@ class ApiQueueService {
'[API QUEUE] TRACE enqueued: $repeaterId at $latitude, $longitude (queue size: $queueSize)');
}
onQueueUpdated?.call(queueSize);
_pingFlushTimer?.cancel();
_pingFlushTimer = Timer(const Duration(seconds: 5), () {
debugLog('[API QUEUE] Ping flush timer fired');
_flushRxBuffer();
_uploadBatch();
});
_schedulePingFlush();
}

/// Enqueue a failed DISC discovery (no nodes responded)
Expand Down Expand Up @@ -539,19 +543,14 @@ class ApiQueueService {
'[API QUEUE] DISC drop enqueued at $latitude, $longitude (queue size: $queueSize)');
}
onQueueUpdated?.call(queueSize);
_pingFlushTimer?.cancel();
_pingFlushTimer = Timer(const Duration(seconds: 5), () {
debugLog('[API QUEUE] Ping flush timer fired');
_flushRxBuffer();
_uploadBatch();
});
_schedulePingFlush();
}

/// Report a square where smart pinging held a ping. [held] is `tx` or
/// `disc`. The server verifies the square against its own coverage and
/// credits it once per session; a dropped one is silent. Modelled on
/// enqueueDiscDrop: offline rows honour the airborne pause, a closed box
/// falls back to memory, and the 5 second flush timer sends it on.
/// falls back to memory, and the network-aware flush timer sends it on.
Future<void> enqueueDefer({
required double latitude,
required double longitude,
Expand Down Expand Up @@ -584,12 +583,7 @@ class ApiQueueService {
'[API QUEUE] DEFER ($held) enqueued at $latitude, $longitude (queue size: $queueSize)');
}
onQueueUpdated?.call(queueSize);
_pingFlushTimer?.cancel();
_pingFlushTimer = Timer(const Duration(seconds: 5), () {
debugLog('[API QUEUE] Ping flush timer fired');
_flushRxBuffer();
_uploadBatch();
});
_schedulePingFlush();
}

// Guard to prevent concurrent RX buffer flushes
Expand Down Expand Up @@ -637,10 +631,37 @@ class ApiQueueService {
}
}

void _handleNetworkState(NetworkState state) {
if (state.isConstrained == _lastIsConstrained) return;

_lastIsConstrained = state.isConstrained;
debugLog('[API QUEUE] Network pacing changed: '
'${state.isConstrained ? 'constrained' : 'ordinary'}');

if (_batchTimer != null) _startBatchTimer();
if (_pingFlushTimer?.isActive ?? false) _schedulePingFlush();
}

void _schedulePingFlush() {
final timeout =
_lastIsConstrained ? _pingFlushTimeoutConstrained : _pingFlushTimeout;
_pingFlushTimer?.cancel();
_pingFlushTimer = Timer(timeout, () {
debugLog('[API QUEUE] Ping flush timer fired '
'(${timeout.inSeconds}s delay'
'${_lastIsConstrained ? ', constrained network' : ''})');
_flushRxBuffer();
_uploadBatch();
});
}

void _startBatchTimer() {
final constrained = _lastIsConstrained;
final timeout = constrained ? _batchTimeoutConstrained : _batchTimeout;
_batchTimer?.cancel();
_batchTimer = Timer.periodic(_batchTimeout, (_) {
debugLog('[API QUEUE] Batch timer fired (15s interval)');
_batchTimer = Timer.periodic(timeout, (_) {
debugLog('[API QUEUE] Batch timer fired (${timeout.inSeconds}s interval'
'${constrained ? ', constrained network' : ''})');
_flushRxBuffer();
_uploadBatch();
});
Expand Down Expand Up @@ -1007,6 +1028,7 @@ class ApiQueueService {
void dispose() {
_batchTimer?.cancel();
_pingFlushTimer?.cancel();
_networkStateSubscription?.cancel();
_box?.close();
}
}
Loading