feat: enhance background streaming service with foreground handling

- Improved BackgroundStreamingService to manage foreground service notifications more effectively, ensuring compliance with Android requirements.
- Implemented dynamic foreground service type resolution based on microphone permission, enhancing service behavior based on app state.
- Added checks for app foreground status in connectivity management, improving responsiveness to network changes.
- Refactored notification handling to streamline service lifecycle management and improve code maintainability.
This commit is contained in:
cogwheel0
2025-10-09 15:47:27 +05:30
parent 162a5e0781
commit c073d71363
4 changed files with 161 additions and 9 deletions

View File

@@ -1,14 +1,20 @@
package app.cogwheel.conduit 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.Context
import android.content.Intent import android.content.Intent
import android.content.SharedPreferences import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager import android.os.PowerManager
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel import io.flutter.plugin.common.MethodChannel
@@ -21,22 +27,41 @@ import org.json.JSONObject
class BackgroundStreamingService : Service() { class BackgroundStreamingService : Service() {
private var wakeLock: PowerManager.WakeLock? = null private var wakeLock: PowerManager.WakeLock? = null
private val activeStreams = mutableSetOf<String>() private val activeStreams = mutableSetOf<String>()
private var isForeground = false
private var currentForegroundType: Int = 0
companion object { companion object {
const val CHANNEL_ID = "conduit_streaming_channel" const val CHANNEL_ID = "conduit_streaming_channel"
const val NOTIFICATION_ID = 1001 const val NOTIFICATION_ID = 1001
const val ACTION_START = "START_STREAMING" const val ACTION_START = "START_STREAMING"
const val ACTION_STOP = "STOP_STREAMING" const val ACTION_STOP = "STOP_STREAMING"
private const val EXTRA_REQUIRES_MICROPHONE = "requiresMicrophone"
} }
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
// Start foreground with minimal notification (required for foreground service)
startForeground(NOTIFICATION_ID, createMinimalNotification())
println("BackgroundStreamingService: Service created") println("BackgroundStreamingService: Service created")
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { 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) { when (intent?.action) {
ACTION_START -> { ACTION_START -> {
acquireWakeLock() acquireWakeLock()
@@ -53,6 +78,55 @@ class BackgroundStreamingService : Service() {
return START_STICKY // Restart if killed by system 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 { private fun createMinimalNotification(): Notification {
// Create a minimal, silent notification (required for foreground service) // Create a minimal, silent notification (required for foreground service)
return NotificationCompat.Builder(this, CHANNEL_ID) return NotificationCompat.Builder(this, CHANNEL_ID)
@@ -103,15 +177,17 @@ class BackgroundStreamingService : Service() {
releaseWakeLock() releaseWakeLock()
stopForeground(true) stopForeground(true)
stopSelf() stopSelf()
isForeground = false
println("BackgroundStreamingService: Service stopped") println("BackgroundStreamingService: Service stopped")
} }
override fun onDestroy() { override fun onDestroy() {
releaseWakeLock() releaseWakeLock()
isForeground = false
super.onDestroy() super.onDestroy()
println("BackgroundStreamingService: Service destroyed") println("BackgroundStreamingService: Service destroyed")
} }
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
} }
@@ -363,4 +439,4 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal
stopBackgroundMonitoring() stopBackgroundMonitoring()
stopForegroundService() stopForegroundService()
} }
} }

View File

@@ -58,13 +58,18 @@ void _scheduleConversationWarmup(Ref ref, {bool force = false}) {
return; return;
} }
final connectivity = ref.read(connectivityServiceProvider);
if (!connectivity.isAppForeground) {
return;
}
final isOnline = ref.read(isOnlineProvider); final isOnline = ref.read(isOnlineProvider);
if (!isOnline) { if (!isOnline) {
return; return;
} }
// If network latency is high, delay warmup further to reduce contention // 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 final extraDelay = latency > 800
? 400 ? 400
: latency > 400 : latency > 400
@@ -99,6 +104,11 @@ void _scheduleConversationWarmup(Ref ref, {bool force = false}) {
await Future.delayed(Duration(milliseconds: extraDelay)); await Future.delayed(Duration(milliseconds: extraDelay));
} }
try { try {
if (!ref.read(connectivityServiceProvider).isAppForeground) {
statusController.set(_ConversationWarmupStatus.idle);
return;
}
final existing = ref.read(conversationsProvider); final existing = ref.read(conversationsProvider);
if (existing.hasValue) { if (existing.hasValue) {
statusController.set(_ConversationWarmupStatus.complete); statusController.set(_ConversationWarmupStatus.complete);

View File

@@ -85,6 +85,7 @@ class RouterNotifier extends ChangeNotifier {
} }
final authState = ref.read(authNavigationStateProvider); final authState = ref.read(authNavigationStateProvider);
final connectivityService = ref.read(connectivityServiceProvider);
if (location == Routes.serverConnection) { if (location == Routes.serverConnection) {
return authState == AuthNavigationState.authenticated return authState == AuthNavigationState.authenticated
@@ -102,7 +103,9 @@ class RouterNotifier extends ChangeNotifier {
final shouldShowConnectionIssue = final shouldShowConnectionIssue =
!reviewerMode && !reviewerMode &&
connectivity == ConnectivityStatus.offline && connectivity == ConnectivityStatus.offline &&
authState == AuthNavigationState.authenticated; authState == AuthNavigationState.authenticated &&
connectivityService.isAppForeground &&
!connectivityService.isOfflineSuppressed;
if (shouldShowConnectionIssue) { if (shouldShowConnectionIssue) {
return location == Routes.connectionIssue ? null : Routes.connectionIssue; return location == Routes.connectionIssue ? null : Routes.connectionIssue;

View File

@@ -5,6 +5,7 @@ import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:dio/io.dart'; import 'package:dio/io.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart';
@@ -24,7 +25,7 @@ enum ConnectivityStatus { online, offline }
/// - Assumes online by default (optimistic) /// - Assumes online by default (optimistic)
/// - Only shows offline when explicitly confirmed /// - Only shows offline when explicitly confirmed
/// - Minimal state changes during startup /// - Minimal state changes during startup
class ConnectivityService { class ConnectivityService with WidgetsBindingObserver {
ConnectivityService(this._dio, this._ref, [Connectivity? connectivity]) ConnectivityService(this._dio, this._ref, [Connectivity? connectivity])
: _connectivity = connectivity ?? Connectivity() { : _connectivity = connectivity ?? Connectivity() {
_initialize(); _initialize();
@@ -38,6 +39,8 @@ class ConnectivityService {
StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription; StreamSubscription<List<ConnectivityResult>>? _connectivitySubscription;
Timer? _pollTimer; Timer? _pollTimer;
Timer? _noNetworkGraceTimer; Timer? _noNetworkGraceTimer;
DateTime? _offlineSuppressedUntil;
bool _isAppForeground = true;
// Start optimistically as online to prevent flash // Start optimistically as online to prevent flash
ConnectivityStatus _currentStatus = ConnectivityStatus.online; ConnectivityStatus _currentStatus = ConnectivityStatus.online;
@@ -51,6 +54,8 @@ class ConnectivityService {
ConnectivityStatus get currentStatus => _currentStatus; ConnectivityStatus get currentStatus => _currentStatus;
int get lastLatencyMs => _lastLatencyMs; int get lastLatencyMs => _lastLatencyMs;
bool get isOnline => _currentStatus == ConnectivityStatus.online; bool get isOnline => _currentStatus == ConnectivityStatus.online;
bool get isAppForeground => _isAppForeground;
bool get isOfflineSuppressed => _isOfflineSuppressed;
void _initialize() { void _initialize() {
// Listen to network interface changes // Listen to network interface changes
@@ -64,6 +69,9 @@ class ConnectivityService {
// Start periodic health checks // Start periodic health checks
_scheduleNextCheck(); _scheduleNextCheck();
WidgetsBinding.instance.addObserver(this);
_extendOfflineSuppression(const Duration(seconds: 3));
} }
void _handleNetworkChange(List<ConnectivityResult> results) { void _handleNetworkChange(List<ConnectivityResult> results) {
@@ -105,6 +113,10 @@ class ConnectivityService {
void _scheduleNextCheck({Duration? delay}) { void _scheduleNextCheck({Duration? delay}) {
_stopPolling(); _stopPolling();
if (!_isAppForeground) {
return;
}
// Adaptive polling based on failure count // Adaptive polling based on failure count
final interval = final interval =
delay ?? delay ??
@@ -222,6 +234,12 @@ class ConnectivityService {
if (_currentStatus != newStatus && !_statusController.isClosed) { if (_currentStatus != newStatus && !_statusController.isClosed) {
_currentStatus = newStatus; _currentStatus = newStatus;
_statusController.add(newStatus); _statusController.add(newStatus);
} else {
_currentStatus = newStatus;
}
if (newStatus == ConnectivityStatus.online) {
_offlineSuppressedUntil = null;
} }
} }
@@ -230,6 +248,27 @@ class ConnectivityService {
_noNetworkGraceTimer = null; _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. /// Manually trigger a connectivity check.
Future<bool> checkNow() async { Future<bool> checkNow() async {
await _checkServerHealth(); await _checkServerHealth();
@@ -241,6 +280,7 @@ class ConnectivityService {
_connectivitySubscription?.cancel(); _connectivitySubscription?.cancel();
_connectivitySubscription = null; _connectivitySubscription = null;
_cancelNoNetworkGrace(); _cancelNoNetworkGrace();
WidgetsBinding.instance.removeObserver(this);
if (!_statusController.isClosed) { if (!_statusController.isClosed) {
_statusController.close(); _statusController.close();
@@ -287,6 +327,29 @@ class ConnectivityService {
return parsed; 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 // Provider for the connectivity service