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
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<String>()
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,11 +177,13 @@ 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")
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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<List<ConnectivityResult>>? _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<ConnectivityResult> 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<bool> 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