diff --git a/android/app/src/main/kotlin/app/cogwheel/conduit/BackgroundStreamingHandler.kt b/android/app/src/main/kotlin/app/cogwheel/conduit/BackgroundStreamingHandler.kt index a69a02b..b0fefb0 100644 --- a/android/app/src/main/kotlin/app/cogwheel/conduit/BackgroundStreamingHandler.kt +++ b/android/app/src/main/kotlin/app/cogwheel/conduit/BackgroundStreamingHandler.kt @@ -1,14 +1,20 @@ package app.cogwheel.conduit -import android.app.* +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service import android.content.Context import android.content.Intent import android.content.SharedPreferences +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo import android.os.Build import android.os.IBinder import android.os.PowerManager import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel @@ -21,22 +27,41 @@ import org.json.JSONObject class BackgroundStreamingService : Service() { private var wakeLock: PowerManager.WakeLock? = null private val activeStreams = mutableSetOf() + private var isForeground = false + private var currentForegroundType: Int = 0 companion object { const val CHANNEL_ID = "conduit_streaming_channel" const val NOTIFICATION_ID = 1001 const val ACTION_START = "START_STREAMING" const val ACTION_STOP = "STOP_STREAMING" + private const val EXTRA_REQUIRES_MICROPHONE = "requiresMicrophone" } override fun onCreate() { super.onCreate() - // Start foreground with minimal notification (required for foreground service) - startForeground(NOTIFICATION_ID, createMinimalNotification()) println("BackgroundStreamingService: Service created") } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val notification = createMinimalNotification() + val desiredType = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + resolveForegroundServiceType(intent) + } else { + 0 + } + + if (!isForeground) { + if (!startForegroundInternal(notification, desiredType)) { + stopSelf() + return START_NOT_STICKY + } + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && + currentForegroundType != desiredType + ) { + updateForegroundType(notification, desiredType) + } + when (intent?.action) { ACTION_START -> { acquireWakeLock() @@ -53,6 +78,55 @@ class BackgroundStreamingService : Service() { return START_STICKY // Restart if killed by system } + private fun startForegroundInternal(notification: Notification, type: Int): Boolean { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, notification, type) + currentForegroundType = type + } else { + @Suppress("DEPRECATION") + startForeground(NOTIFICATION_ID, notification) + } + isForeground = true + true + } catch (e: SecurityException) { + println("BackgroundStreamingService: Failed to enter foreground: ${e.message}") + false + } + } + + private fun updateForegroundType(notification: Notification, type: Int) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return + try { + startForeground(NOTIFICATION_ID, notification, type) + currentForegroundType = type + } catch (e: SecurityException) { + println("BackgroundStreamingService: Unable to update foreground type: ${e.message}") + } + } + + private fun resolveForegroundServiceType(intent: Intent?): Int { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return 0 + + val requiresMicrophone = intent?.getBooleanExtra(EXTRA_REQUIRES_MICROPHONE, false) ?: false + if (requiresMicrophone) { + if (hasRecordAudioPermission()) { + return ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE or + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + } + println("BackgroundStreamingService: Microphone permission missing; falling back to data sync type") + } + + return ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + } + + private fun hasRecordAudioPermission(): Boolean { + return ContextCompat.checkSelfPermission( + this, + android.Manifest.permission.RECORD_AUDIO + ) == PackageManager.PERMISSION_GRANTED + } + private fun createMinimalNotification(): Notification { // Create a minimal, silent notification (required for foreground service) return NotificationCompat.Builder(this, CHANNEL_ID) @@ -103,15 +177,17 @@ class BackgroundStreamingService : Service() { releaseWakeLock() stopForeground(true) stopSelf() + isForeground = false println("BackgroundStreamingService: Service stopped") } - + override fun onDestroy() { releaseWakeLock() + isForeground = false super.onDestroy() println("BackgroundStreamingService: Service destroyed") } - + override fun onBind(intent: Intent?): IBinder? = null } @@ -363,4 +439,4 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal stopBackgroundMonitoring() stopForegroundService() } -} \ No newline at end of file +} diff --git a/lib/core/providers/app_startup_providers.dart b/lib/core/providers/app_startup_providers.dart index b88ebae..2d7dbbe 100644 --- a/lib/core/providers/app_startup_providers.dart +++ b/lib/core/providers/app_startup_providers.dart @@ -58,13 +58,18 @@ void _scheduleConversationWarmup(Ref ref, {bool force = false}) { return; } + final connectivity = ref.read(connectivityServiceProvider); + if (!connectivity.isAppForeground) { + return; + } + final isOnline = ref.read(isOnlineProvider); if (!isOnline) { return; } // If network latency is high, delay warmup further to reduce contention - final latency = ref.read(connectivityServiceProvider).lastLatencyMs; + final latency = connectivity.lastLatencyMs; final extraDelay = latency > 800 ? 400 : latency > 400 @@ -99,6 +104,11 @@ void _scheduleConversationWarmup(Ref ref, {bool force = false}) { await Future.delayed(Duration(milliseconds: extraDelay)); } try { + if (!ref.read(connectivityServiceProvider).isAppForeground) { + statusController.set(_ConversationWarmupStatus.idle); + return; + } + final existing = ref.read(conversationsProvider); if (existing.hasValue) { statusController.set(_ConversationWarmupStatus.complete); diff --git a/lib/core/router/app_router.dart b/lib/core/router/app_router.dart index a7131e5..f59446a 100644 --- a/lib/core/router/app_router.dart +++ b/lib/core/router/app_router.dart @@ -85,6 +85,7 @@ class RouterNotifier extends ChangeNotifier { } final authState = ref.read(authNavigationStateProvider); + final connectivityService = ref.read(connectivityServiceProvider); if (location == Routes.serverConnection) { return authState == AuthNavigationState.authenticated @@ -102,7 +103,9 @@ class RouterNotifier extends ChangeNotifier { final shouldShowConnectionIssue = !reviewerMode && connectivity == ConnectivityStatus.offline && - authState == AuthNavigationState.authenticated; + authState == AuthNavigationState.authenticated && + connectivityService.isAppForeground && + !connectivityService.isOfflineSuppressed; if (shouldShowConnectionIssue) { return location == Routes.connectionIssue ? null : Routes.connectionIssue; diff --git a/lib/core/services/connectivity_service.dart b/lib/core/services/connectivity_service.dart index c93b66d..5eff435 100644 --- a/lib/core/services/connectivity_service.dart +++ b/lib/core/services/connectivity_service.dart @@ -5,6 +5,7 @@ import 'package:connectivity_plus/connectivity_plus.dart'; import 'package:dio/dio.dart'; import 'package:dio/io.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; @@ -24,7 +25,7 @@ enum ConnectivityStatus { online, offline } /// - Assumes online by default (optimistic) /// - Only shows offline when explicitly confirmed /// - Minimal state changes during startup -class ConnectivityService { +class ConnectivityService with WidgetsBindingObserver { ConnectivityService(this._dio, this._ref, [Connectivity? connectivity]) : _connectivity = connectivity ?? Connectivity() { _initialize(); @@ -38,6 +39,8 @@ class ConnectivityService { StreamSubscription>? _connectivitySubscription; Timer? _pollTimer; Timer? _noNetworkGraceTimer; + DateTime? _offlineSuppressedUntil; + bool _isAppForeground = true; // Start optimistically as online to prevent flash ConnectivityStatus _currentStatus = ConnectivityStatus.online; @@ -51,6 +54,8 @@ class ConnectivityService { ConnectivityStatus get currentStatus => _currentStatus; int get lastLatencyMs => _lastLatencyMs; bool get isOnline => _currentStatus == ConnectivityStatus.online; + bool get isAppForeground => _isAppForeground; + bool get isOfflineSuppressed => _isOfflineSuppressed; void _initialize() { // Listen to network interface changes @@ -64,6 +69,9 @@ class ConnectivityService { // Start periodic health checks _scheduleNextCheck(); + + WidgetsBinding.instance.addObserver(this); + _extendOfflineSuppression(const Duration(seconds: 3)); } void _handleNetworkChange(List results) { @@ -105,6 +113,10 @@ class ConnectivityService { void _scheduleNextCheck({Duration? delay}) { _stopPolling(); + if (!_isAppForeground) { + return; + } + // Adaptive polling based on failure count final interval = delay ?? @@ -222,6 +234,12 @@ class ConnectivityService { if (_currentStatus != newStatus && !_statusController.isClosed) { _currentStatus = newStatus; _statusController.add(newStatus); + } else { + _currentStatus = newStatus; + } + + if (newStatus == ConnectivityStatus.online) { + _offlineSuppressedUntil = null; } } @@ -230,6 +248,27 @@ class ConnectivityService { _noNetworkGraceTimer = null; } + bool get _isOfflineSuppressed { + final until = _offlineSuppressedUntil; + if (until == null) { + return false; + } + if (DateTime.now().isBefore(until)) { + return true; + } + _offlineSuppressedUntil = null; + return false; + } + + void _extendOfflineSuppression(Duration duration) { + final base = DateTime.now(); + final proposed = base.add(duration); + if (_offlineSuppressedUntil == null || + proposed.isAfter(_offlineSuppressedUntil!)) { + _offlineSuppressedUntil = proposed; + } + } + /// Manually trigger a connectivity check. Future checkNow() async { await _checkServerHealth(); @@ -241,6 +280,7 @@ class ConnectivityService { _connectivitySubscription?.cancel(); _connectivitySubscription = null; _cancelNoNetworkGrace(); + WidgetsBinding.instance.removeObserver(this); if (!_statusController.isClosed) { _statusController.close(); @@ -287,6 +327,29 @@ class ConnectivityService { return parsed; } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + switch (state) { + case AppLifecycleState.resumed: + _isAppForeground = true; + _extendOfflineSuppression(const Duration(seconds: 4)); + // Give networking stack a short window to settle + _scheduleNextCheck(delay: const Duration(milliseconds: 500)); + break; + case AppLifecycleState.inactive: + case AppLifecycleState.paused: + case AppLifecycleState.hidden: + _isAppForeground = false; + _extendOfflineSuppression(const Duration(seconds: 6)); + _stopPolling(); + break; + case AppLifecycleState.detached: + _isAppForeground = false; + _stopPolling(); + break; + } + } } // Provider for the connectivity service