diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/PreferencesKey.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/PreferencesKey.kt index 7e1b8e54..555315e5 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/PreferencesKey.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/PreferencesKey.kt @@ -58,4 +58,9 @@ object PreferencesKey { // task data const val CALLBACK_HANDLE = "callbackHandle" + + // chaves relacionadas ao orçamento de alarm clock para iniciar o serviço novamente + const val RESTART_BUDGET_PREFS = prefix + "RESTART_BUDGET" + const val RESTART_ATTEMPT_COUNT = "restartAttemptCount" + const val RESTART_WINDOW_START = "restartWindowStart" } diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundService.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundService.kt index 3e3f010c..ab9f906d 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundService.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundService.kt @@ -135,6 +135,10 @@ class ForegroundService : Service() { var action = foregroundServiceStatus.action val isSetStopWithTaskFlag = ForegroundServiceUtils.isSetStopWithTaskFlag(this) + if (action == ForegroundServiceAction.API_START) { + RestartBudget.reset(this) + } + if (action == ForegroundServiceAction.API_STOP) { stopForegroundService(true) return START_NOT_STICKY @@ -178,6 +182,8 @@ class ForegroundService : Service() { } } catch (e: Exception) { Log.e(TAG, e.message, e) + + // startForeground() falhou, e tentar reanexar repete a mesma exceção. stopForegroundService(false) } @@ -212,8 +218,13 @@ class ForegroundService : Service() { val allowAutoRestart = foregroundTaskOptions.allowAutoRestart if (allowAutoRestart && !isCorrectlyStopped && !ForegroundServiceUtils.isSetStopWithTaskFlag(this)) { - Log.e(TAG, "The service will be restarted after 5 seconds because it wasn't properly stopped.") - RestartReceiver.setRestartAlarm(this, 5000) + val delayMillis = RestartBudget.consume(this) + if (delayMillis == null) { + Log.e(TAG, "The service wasn't properly stopped, but the auto-restart budget is exhausted.") + } else { + Log.e(TAG, "The service will be restarted after ${delayMillis}ms because it wasn't properly stopped.") + RestartReceiver.setRestartAlarm(this, delayMillis) + } } } @@ -222,7 +233,13 @@ class ForegroundService : Service() { if (ForegroundServiceUtils.isSetStopWithTaskFlag(this)) { stopSelf() } else { - RestartReceiver.setRestartAlarm(this, 1000) + // A primeira tentativa é 1s para manter responsivo quando o usuario só limpou a task. + val delayMillis = RestartBudget.consume(this, firstAttemptDelayMillis = 1000) + if (delayMillis == null) { + Log.e(TAG, "Task removed, but the auto-restart budget is exhausted.") + } else { + RestartReceiver.setRestartAlarm(this, delayMillis) + } } } @@ -291,6 +308,9 @@ class ForegroundService : Service() { acquireLockMode() _isRunningServiceState.update { true } + + // Serviço está em foreground e é seguro limpar o reinício. + RestartBudget.reset(this) } private fun attachForegroundTask() { @@ -311,16 +331,23 @@ class ForegroundService : Service() { private fun stopForegroundService(shouldReattach: Boolean) { if (shouldReattach) { - attachForegroundTask() + try { + attachForegroundTask() + } catch (e: Exception) { + Log.e(TAG, "attachForegroundTask() failed before stop: ${e.message}", e) + } } - RestartReceiver.cancelRestartAlarm(this) - - releaseLockMode() - stopForeground(true) - stopSelf() - - _isRunningServiceState.update { false } + try { + RestartReceiver.cancelRestartAlarm(this) + releaseLockMode() + stopForeground(true) + } catch (e: Exception) { + Log.e(TAG, "stopForegroundService() teardown failed: ${e.message}", e) + } finally { + stopSelf() + _isRunningServiceState.update { false } + } } @RequiresApi(Build.VERSION_CODES.O) diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/RestartBudget.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/RestartBudget.kt new file mode 100644 index 00000000..2ffef424 --- /dev/null +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/RestartBudget.kt @@ -0,0 +1,87 @@ +package com.pravera.flutter_foreground_task.service + +import android.content.Context +import android.util.Log +import com.pravera.flutter_foreground_task.PreferencesKey as PrefsKey + +/** + * Orçamento de reinícios automáticos do foreground service. + * + * Utilizado para controlar o ciclo "onDestroy -> setRestartAlarm(5s) -> RestartReceiver -> + * startForegroundService -> startForeground() -> stopForegroundService -> + * onDestroy" que se repete indefinidamente a cada 5 segundos para tentar iniciar o + * serviço novamente. + * + * Aqui cada tentativa consome uma fatia de um orçamento com backoff crescente e + * teto. O contador deve zerar quando o servico volta ao foreground com sucesso, + * quando o app pede um start explicito, ou quando a janela de observação expira. + */ +object RestartBudget { + private val TAG = RestartBudget::class.java.simpleName + + /** Atraso de cada tentativa, em ms. */ + private val BACKOFF_DELAYS = intArrayOf( + 5_000, // 5 s + 30_000, // 30 s + 120_000, // 2 min + 600_000, // 10 min + 1_800_000 // 30 min + ) + + /** Passada esta janela sem nenhuma tentativa, o orçamento volta ao cheio. */ + private const val WINDOW_MILLIS = 60 * 60 * 1000L // 1h + + /** + * Consome uma tentativa e devolve o atraso a usar no alarme, ou null quando o + * orçamento acabou -- nesse caso o chamador NÃO deve agendar alarme algum. + * + * @param firstAttemptDelayMillis atraso da primeira tentativa da janela. Existe + * porque o onTaskRemoved quer reagir mais rapido (1s) que o onDestroy (5s). + */ + fun consume(context: Context, firstAttemptDelayMillis: Int = BACKOFF_DELAYS[0]): Int? { + val prefs = context.getSharedPreferences(PrefsKey.RESTART_BUDGET_PREFS, Context.MODE_PRIVATE) + val now = System.currentTimeMillis() + + val windowStart = prefs.getLong(PrefsKey.RESTART_WINDOW_START, 0L) + var attempts = prefs.getInt(PrefsKey.RESTART_ATTEMPT_COUNT, 0) + + // Janela expirada ou primeira tentativa: recomeça o orçamento. + val isNewWindow = windowStart == 0L || (now - windowStart) > WINDOW_MILLIS + if (isNewWindow) { + attempts = 0 + } + + if (attempts >= BACKOFF_DELAYS.size) { + Log.e(TAG, "Auto-restart budget exhausted (${BACKOFF_DELAYS.size} attempts). " + + "No further restart alarm will be scheduled until the service starts " + + "successfully or ${WINDOW_MILLIS / 60000} minutes elapse.") + return null + } + + val delay = if (attempts == 0) firstAttemptDelayMillis else BACKOFF_DELAYS[attempts] + + with(prefs.edit()) { + putInt(PrefsKey.RESTART_ATTEMPT_COUNT, attempts + 1) + putLong(PrefsKey.RESTART_WINDOW_START, if (isNewWindow) now else windowStart) + commit() + } + + Log.w(TAG, "Auto-restart attempt ${attempts + 1}/${BACKOFF_DELAYS.size} scheduled in ${delay}ms.") + return delay + } + + /** Zera o orçamento. Chamar quando o serviço voltou a rodar em foreground. */ + fun reset(context: Context) { + val prefs = context.getSharedPreferences(PrefsKey.RESTART_BUDGET_PREFS, Context.MODE_PRIVATE) + if (prefs.getInt(PrefsKey.RESTART_ATTEMPT_COUNT, 0) == 0) { + return + } + + with(prefs.edit()) { + remove(PrefsKey.RESTART_ATTEMPT_COUNT) + remove(PrefsKey.RESTART_WINDOW_START) + commit() + } + Log.i(TAG, "Auto-restart budget reset.") + } +} diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/RestartReceiver.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/RestartReceiver.kt index dbf12498..76941530 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/RestartReceiver.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/RestartReceiver.kt @@ -39,8 +39,7 @@ class RestartReceiver : BroadcastReceiver() { val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && PluginUtils.canScheduleExactAlarms(context)) { - val info = AlarmManager.AlarmClockInfo(triggerTime, operation) - alarmManager.setAlarmClock(info, operation) + alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerTime, operation) } else { alarmManager.set(AlarmManager.RTC_WAKEUP, triggerTime, operation) } diff --git a/ios/Classes/service/BackgroundService.swift b/ios/Classes/service/BackgroundService.swift index 8894a30e..878cf0f7 100644 --- a/ios/Classes/service/BackgroundService.swift +++ b/ios/Classes/service/BackgroundService.swift @@ -129,6 +129,7 @@ class BackgroundService: NSObject { // If it is not a notification requested by this plugin, the processing below is ignored. if notification.request.identifier != NOTIFICATION_ID { return } + // O som customizado deve tocar mesmo com playSound == false. if notificationOptions.playSound || notificationContent.notificationSound != nil { completionHandler([.alert, .sound]) } else { @@ -157,6 +158,7 @@ class BackgroundService: NSObject { } private func requestNotification() { + // Não publicar notificação com corpo vazio, evitando mostrar card em branco no iOS. if !notificationOptions.showNotification || notificationContent.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return } @@ -179,6 +181,8 @@ class BackgroundService: NSObject { content.sound = .default } } else { + // Zerar o som para impedir que a notificação use o último som do cache mesmo quando + // foi solicitado não emitir som. content.sound = nil }