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:
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user