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