Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import android.bluetooth.le.ScanSettings
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.content.pm.ApplicationInfo
import android.content.pm.ServiceInfo
import android.content.pm.PackageManager
Expand Down Expand Up @@ -271,6 +272,7 @@ class MeshBleService : Service() {
private var notificationTickRunnable: Runnable? = null
private var lastNotificationText: String? = null
private var lastNotificationPresent: Boolean? = null
private var lastStartForegroundMs: Long = 0L

// Only run as a foreground service (persistent notification) once connected.
private var isForegroundActive: Boolean = false
Expand Down Expand Up @@ -387,13 +389,17 @@ class MeshBleService : Service() {
private var telemetryWakeLock: PowerManager.WakeLock? = null

private lateinit var fusedLocationClient: FusedLocationProviderClient
private lateinit var prefs: SharedPreferences
private lateinit var flutterPrefs: SharedPreferences

override fun onCreate() {
super.onCreate()
logI("onCreate", mapOf("sdk" to Build.VERSION.SDK_INT))
bluetoothAdapter = (getSystemService(BLUETOOTH_SERVICE) as android.bluetooth.BluetoothManager).adapter
scanner = bluetoothAdapter?.bluetoothLeScanner
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
prefs = getSharedPreferences(prefsName, MODE_PRIVATE)
flutterPrefs = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)

logI(
"adapter/scanner init",
Expand All @@ -407,7 +413,6 @@ class MeshBleService : Service() {
ensureNotificationChannel()

// Attempt restore
val prefs = getSharedPreferences(prefsName, MODE_PRIVATE)
autoReconnect = prefs.getBoolean(keyAutoReconnect, false)
targetAddress = prefs.getString(keyLastAddress, null)
logI(
Expand Down Expand Up @@ -458,7 +463,6 @@ class MeshBleService : Service() {

// Also disable auto-reconnect persistence to avoid resurrecting the
// foreground notification after an explicit stop.
val prefs = getSharedPreferences(prefsName, MODE_PRIVATE)
prefs.edit().putBoolean(keyAutoReconnect, false).apply()
autoReconnect = false
reconnectAttempt = 0
Expand All @@ -479,7 +483,6 @@ class MeshBleService : Service() {
logI("actionUserStop")

// Disable reconnect now and across restarts
val prefs = getSharedPreferences(prefsName, MODE_PRIVATE)
prefs.edit().putBoolean(keyAutoReconnect, false).remove(keyLastAddress).apply()
autoReconnect = false
targetAddress = null
Expand Down Expand Up @@ -520,7 +523,6 @@ class MeshBleService : Service() {
actionDisconnect -> {
logI("actionDisconnect")
// Treat explicit disconnect as manual: disable auto-reconnect
val prefs = getSharedPreferences(prefsName, MODE_PRIVATE)
prefs.edit().putBoolean(keyAutoReconnect, false).apply()
autoReconnect = false
reconnectAttempt = 0
Expand Down Expand Up @@ -825,7 +827,6 @@ class MeshBleService : Service() {
MeshBleState.deviceName = null

if (!fromRestore) {
val prefs = getSharedPreferences(prefsName, MODE_PRIVATE)
prefs.edit()
.putString(keyLastAddress, address)
.putBoolean(keyAutoReconnect, true)
Expand Down Expand Up @@ -1442,29 +1443,28 @@ class MeshBleService : Service() {
}

private fun trackingNotificationLine(): String? {
val prefs = getSharedPreferences("FlutterSharedPreferences", MODE_PRIVATE)
val telemetryEnabled = prefs.getBoolean("flutter.telemetry_enabled", false)
val telemetryEnabled = flutterPrefs.getBoolean("flutter.telemetry_enabled", false)
if (!telemetryEnabled) return null

val companionKey = prefs.getString("flutter.current_companion_public_key", null)
val companionKey = flutterPrefs.getString("flutter.current_companion_public_key", null)
val perCompanionName = if (!companionKey.isNullOrBlank()) {
prefs.getString("flutter.telemetry_channel_name_$companionKey", null)
flutterPrefs.getString("flutter.telemetry_channel_name_$companionKey", null)
} else {
null
}
val channelName = perCompanionName ?: prefs.getString("flutter.telemetry_channel_name", null)
val channelName = perCompanionName ?: flutterPrefs.getString("flutter.telemetry_channel_name", null)

val normalizedName = channelName?.trim()?.takeIf { it.isNotEmpty() }
if (normalizedName != null) {
return "Tracking enabled on channel:$normalizedName"
}

val perCompanionHash = if (!companionKey.isNullOrBlank()) {
prefs.getString("flutter.telemetry_channel_hash_$companionKey", null)
flutterPrefs.getString("flutter.telemetry_channel_hash_$companionKey", null)
} else {
null
}
val hash = perCompanionHash ?: prefs.getString("flutter.telemetry_channel_hash", null)
val hash = perCompanionHash ?: flutterPrefs.getString("flutter.telemetry_channel_hash", null)
val normalizedHash = hash?.trim()?.takeIf { it.isNotEmpty() } ?: return null

return "Tracking enabled on channel:$normalizedHash"
Expand Down Expand Up @@ -1505,6 +1505,7 @@ class MeshBleService : Service() {
ServiceInfo.FOREGROUND_SERVICE_TYPE_CONNECTED_DEVICE
},
)
lastStartForegroundMs = android.os.SystemClock.elapsedRealtime()
logI(
"startForeground(refreshed)",
mapOf(
Expand All @@ -1530,6 +1531,9 @@ class MeshBleService : Service() {

private fun isForegroundNotificationPresent(): Boolean {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) return true
// Skip the Binder IPC if we asserted the notification recently. After
// this window expires we do one real check, catching any user dismissal.
if (android.os.SystemClock.elapsedRealtime() - lastStartForegroundMs < 10_000L) return true
return try {
val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
manager.activeNotifications.any { it.id == notificationId }
Expand Down
4 changes: 4 additions & 0 deletions android/gradle.properties
Original file line number Diff line number Diff line change
@@ -1,2 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false
3 changes: 3 additions & 0 deletions lib/repositories/message_repository.dart
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,9 @@ class MessageRepository {
/// - App responds with SYNC_NEXT_MESSAGE to retrieve each message
/// - Continues until RESP_NO_MORE_MESSAGES received
/// - Direct messages (code 16) are also pushed directly
void beginNotificationSync() => _notificationService.beginSync();
Future<void> endNotificationSync() => _notificationService.endSync();

void startPushListener() {
if (_isListeningForPushes) {
debugPrint('[MessageSync] Already listening for push notifications');
Expand Down
4 changes: 2 additions & 2 deletions lib/screens/channel_chat_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
List<ContactData> _allContacts = [];
List<ContactData> _mentionSuggestions = [];
StreamSubscription<List<ContactData>>? _contactsSub;
Stream<List<MessageData>>? _messageStream;

@override
void initState() {
Expand Down Expand Up @@ -139,8 +140,7 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
// Messages list
Expanded(
child: StreamBuilder<List<MessageData>>(
stream: _messageRepository
.watchMessagesByChannel(widget.channel.hash),
stream: _messageStream ??= _messageRepository.watchMessagesByChannel(widget.channel.hash),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
Expand Down
29 changes: 18 additions & 11 deletions lib/screens/main_navigation_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,16 @@ class _MainNavigationScreenState extends State<MainNavigationScreen>

bool _identityDialogShowing = false;

Stream<_UnreadCounts>? _unreadCountsStream;

static const MethodChannel _appLifecycleChannel =
MethodChannel('com.meshcore.team/app_lifecycle');

final List<Widget> _screens = [
const ConnectionScreen(),
const ContactsScreen(),
const ChannelsScreen(),
const MapScreen(),
const RepaintBoundary(child: ConnectionScreen()),
const RepaintBoundary(child: ContactsScreen()),
const RepaintBoundary(child: ChannelsScreen()),
const RepaintBoundary(child: MapScreen()),
];

@override
Expand Down Expand Up @@ -71,13 +73,14 @@ class _MainNavigationScreenState extends State<MainNavigationScreen>

@override
Widget build(BuildContext context) {
final connectionVM = context.watch<ConnectionViewModel>();
final channelRepository = context.watch<ChannelRepository>();
final contactRepository = context.watch<ContactRepository>();
final isConnected = connectionVM.isConnected;
final navLocked = connectionVM.identityConfirmationRequired;
final isConnected = context.select<ConnectionViewModel, bool>(
(vm) => vm.isConnected);
final navLocked = context.select<ConnectionViewModel, bool>(
(vm) => vm.identityConfirmationRequired);
final syncPhase = context.select<ConnectionViewModel, SyncPhase>(
(vm) => vm.syncStatus.phase);
final shouldShowIdentityDialog =
navLocked && connectionVM.syncStatus.phase == SyncPhase.complete;
navLocked && syncPhase == SyncPhase.complete;

if (navLocked && _currentIndex != 0) {
WidgetsBinding.instance.addPostFrameCallback((_) {
Expand All @@ -92,6 +95,7 @@ class _MainNavigationScreenState extends State<MainNavigationScreen>
_identityDialogShowing = true;
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!mounted) return;
final connectionVM = context.read<ConnectionViewModel>();
await _showIdentityDialog(context, connectionVM);
if (!mounted) return;
_identityDialogShowing = false;
Expand All @@ -116,7 +120,10 @@ class _MainNavigationScreenState extends State<MainNavigationScreen>
children: _screens,
),
bottomNavigationBar: StreamBuilder<_UnreadCounts>(
stream: _getUnreadCounts(channelRepository, contactRepository),
stream: _unreadCountsStream ??= _getUnreadCounts(
context.read<ChannelRepository>(),
context.read<ContactRepository>(),
),
builder: (context, snapshot) {
final counts = snapshot.data ?? const _UnreadCounts(0, 0);
final contactsUnread = counts.contacts;
Expand Down
66 changes: 66 additions & 0 deletions lib/services/message_notification_service.dart
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ class MessageNotificationService {

int _notificationIdCounter = 2000;

static const int _syncBatchThreshold = 5;
bool _isSyncing = false;
final List<({MessageData message, String channelName, bool isDirect})>
_syncQueue = [];

MessageNotificationService({
required FlutterLocalNotificationsPlugin notifications,
required SettingsService settings,
Expand Down Expand Up @@ -94,6 +99,31 @@ class MessageNotificationService {
debugPrint('✅ Message notification service initialized');
}

void beginSync() {
_isSyncing = true;
_syncQueue.clear();
}

Future<void> endSync() async {
_isSyncing = false;
final queued = List.of(_syncQueue);
_syncQueue.clear();

if (queued.isEmpty) return;

if (queued.length < _syncBatchThreshold) {
for (final item in queued) {
await _showNotificationInternal(
message: item.message,
channelName: item.channelName,
isDirect: item.isDirect,
);
}
} else {
await _showSyncSummaryNotification(queued.length);
}
}

/// Show notification for a new message
Future<void> showMessageNotification({
required MessageData message,
Expand Down Expand Up @@ -124,6 +154,19 @@ class MessageNotificationService {
return;
}

// Accumulate during sync; flush logic is handled by endSync().
if (_isSyncing) {
_syncQueue.add((message: message, channelName: channelName, isDirect: isDirect));
return;
}

// Suppress replayed history arriving outside the sync window (e.g. room server).
final ageMs = DateTime.now().millisecondsSinceEpoch - message.timestamp;
if (ageMs > const Duration(minutes: 2).inMilliseconds) {
debugPrint('📬 Suppressing stale message notification (age ${ageMs}ms)');
return;
}

// Show the notification
await _showNotificationInternal(
message: message,
Expand Down Expand Up @@ -207,6 +250,29 @@ class MessageNotificationService {
);
}

Future<void> _showSyncSummaryNotification(int count) async {
const notificationId = 1999;
const androidDetails = AndroidNotificationDetails(
channelIdMessages,
'Channel Messages',
importance: Importance.high,
priority: Priority.high,
playSound: true,
enableVibration: true,
);
const iosDetails = DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
);
await _notifications.show(
notificationId,
'New messages',
'You have $count new messages',
const NotificationDetails(android: androidDetails, iOS: iosDetails),
);
}

/// Show notification for a new waypoint
Future<void> showWaypointNotification({
required String waypointName,
Expand Down
6 changes: 5 additions & 1 deletion lib/viewmodels/connection_viewmodel.dart
Original file line number Diff line number Diff line change
Expand Up @@ -842,15 +842,17 @@ class ConnectionViewModel extends ChangeNotifier {

// Start listening for PUSH_MSG_WAITING (device will push when messages available)
_messageRepository.startPushListener();
_messageRepository.beginNotificationSync();

// Actively pull any queued messages now (some firmware wont emit a PUSH immediately).
// Actively pull any queued messages now (some firmware won't emit a PUSH immediately).
final pulled = await _messageRepository.syncMessagesNow();
debugPrint(
'[ConnectionVM] ✅ Phase 3 complete: pulled $pulled messages (listener active)');

// All phases complete
_updateSyncStatus(
const SyncStatus(phase: SyncPhase.complete, isComplete: true));
await _messageRepository.endNotificationSync();
debugPrint('[ConnectionVM] ✅ All sync phases complete');
}

Expand Down Expand Up @@ -896,12 +898,14 @@ class ConnectionViewModel extends ChangeNotifier {
_updateSyncStatus(const SyncStatus(phase: SyncPhase.syncingMessages));

_messageRepository.startPushListener();
_messageRepository.beginNotificationSync();
final pulled = await _messageRepository.syncMessagesNow();
debugPrint(
'[ConnectionVM] ✅ Reconnect sync complete: pulled $pulled messages (channels skipped)');

_updateSyncStatus(
const SyncStatus(phase: SyncPhase.complete, isComplete: true));
await _messageRepository.endNotificationSync();
}

Future<void> _finalizeAfterSync() async {
Expand Down