From 25e8ad8f8546f0bd85c202efbe43f96e1a84ef80 Mon Sep 17 00:00:00 2001 From: JCPistori <91566210+JCPistori@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:10:53 -0300 Subject: [PATCH 1/4] docs: documenta workarounds de OEM e divergencias do upstream Marca com [GAUDIUM/OEM] e [GAUDIUM] os pontos do fork que existem por causa de dispositivos especificos ou que divergem de proposito do upstream, para que um merge futuro nao os remova por engano: - startForeground() nos caminhos de update e stop (Motorola e outros OEMs) - createNotificationChannel() antes do startForeground() - shouldReattach=false no catch do onStartCommand - guarda de lateinit em foregroundTaskOptions no onDestroy - som em thread separada (ANR em playCustomSound) - iOS: som customizado com playSound=false, reset de content.sound, notificacao com corpo vazio Somente comentarios, sem mudanca de comportamento. Co-Authored-By: Claude Opus 5 (1M context) --- .../service/ForegroundService.kt | 27 ++++++++++++++++++- ios/Classes/service/BackgroundService.swift | 7 +++++ 2 files changed, 33 insertions(+), 1 deletion(-) 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..56cd4bb7 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 @@ -97,7 +97,9 @@ class ForegroundService : Service() { private var isTimeout: Boolean = false - // Executor para tocar sons em thread separada + // [GAUDIUM/OEM] Executor para tocar sons em thread separada. + // NAO tocar o som na thread do onStartCommand: RingtoneManager.getRingtone() faz I/O + // e gerava ANR em ForegroundService.playCustomSound. Ver commit 842b2d6. private val soundExecutor = Executors.newSingleThreadExecutor() // A broadcast receiver that handles intents that occur in the foreground service. @@ -135,6 +137,10 @@ class ForegroundService : Service() { var action = foregroundServiceStatus.action val isSetStopWithTaskFlag = ForegroundServiceUtils.isSetStopWithTaskFlag(this) + // [GAUDIUM/OEM] shouldReattach = true e OBRIGATORIO aqui. + // Motorola e outros OEMs exigem que startForeground() seja chamado tambem no caminho + // de parada; sem isso o processo crasha / lanca excecoes proprias do fabricante. + // Ver commits 8a1b743 e e11b051. Nao simplificar para stopForegroundService(false). if (action == ForegroundServiceAction.API_STOP) { stopForegroundService(true) return START_NOT_STICKY @@ -178,6 +184,9 @@ class ForegroundService : Service() { } } catch (e: Exception) { Log.e(TAG, e.message, e) + // [GAUDIUM/OEM] shouldReattach = false de proposito: se chegamos aqui o + // startForeground() ja falhou, e tentar reanexar so repete a mesma excecao. + // Ver commit 9982fdb. stopForegroundService(false) } @@ -205,6 +214,10 @@ class ForegroundService : Service() { isCorrectlyStopped = foregroundServiceStatus.isCorrectlyStopped() } + // [GAUDIUM] Guarda contra crash de lateinit quando o servico morre antes do + // primeiro loadDataFromPreferences() (ex.: excecao no onStartCommand inicial). + // Ver commit 0b74aa9. O upstream resolve o mesmo problema com um if envolvendo + // o bloco de auto-restart -- em um merge, manter esta versao. if (!::foregroundTaskOptions.isInitialized) { Log.e(TAG, "foregroundTaskOptions was not properly started.") return @@ -293,6 +306,13 @@ class ForegroundService : Service() { _isRunningServiceState.update { true } } + // [GAUDIUM/OEM] Reanexa o servico ao foreground sem reiniciar a task. + // Existe por dois motivos de dispositivo: + // 1. Motorola (e outros OEMs) exigem startForeground() tambem nos caminhos de + // update e stop, senao crasham com excecoes proprias do fabricante. + // 2. createNotificationChannel() precisa rodar ANTES do startForeground(), senao + // alguns aparelhos derrubam o servico por canal inexistente (commit e11b051). + // Nao remover nem inlinar em um merge com o upstream. private fun attachForegroundTask() { RestartReceiver.cancelRestartAlarm(this) @@ -309,6 +329,9 @@ class ForegroundService : Service() { } } + // [GAUDIUM/OEM] O parametro shouldReattach nao existe no upstream (la a assinatura e + // stopForegroundService() sem argumentos). Ao mergear, qualquer chamada nova vinda do + // upstream precisa virar stopForegroundService(true) para preservar o reattach de OEM. private fun stopForegroundService(shouldReattach: Boolean) { if (shouldReattach) { attachForegroundTask() @@ -437,6 +460,8 @@ class ForegroundService : Service() { val serviceId = notificationOptions.serviceId val notification = createNotification() + // [GAUDIUM/OEM] startForeground() tambem no caminho de update, mesma razao do stop. + // Ver commit 8a1b743. attachForegroundTask() val nm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { diff --git a/ios/Classes/service/BackgroundService.swift b/ios/Classes/service/BackgroundService.swift index 8894a30e..fcea5d19 100644 --- a/ios/Classes/service/BackgroundService.swift +++ b/ios/Classes/service/BackgroundService.swift @@ -129,6 +129,8 @@ 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 } + // [GAUDIUM] Divergencia do upstream: o som customizado deve tocar mesmo com + // playSound == false. Ver commit 94446e4. if notificationOptions.playSound || notificationContent.notificationSound != nil { completionHandler([.alert, .sound]) } else { @@ -157,6 +159,8 @@ class BackgroundService: NSObject { } private func requestNotification() { + // [GAUDIUM] Divergencia do upstream: nao publicar notificacao com corpo vazio -- + // no iOS ela aparecia como um card em branco. Ver commit 35827d8. if !notificationOptions.showNotification || notificationContent.text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return } @@ -171,6 +175,9 @@ class BackgroundService: NSObject { content.body = self.notificationContent.text content.categoryIdentifier = NOTIFICATION_CATEGORY_ID + // [GAUDIUM] Divergencia do upstream: o else precisa zerar content.sound. + // Deixar o valor anterior fazia a notificacao repetir o ultimo som do cache + // mesmo quando pedido para nao emitir som. Ver commit c3cd397. if let soundName = self.notificationContent.notificationSound, !soundName.isEmpty { let soundFileURL = Bundle.main.url(forResource: soundName, withExtension: "caf") if let soundFileURL = soundFileURL { From 60eaa5086c116410d89bb11355ac87c8cb30b638 Mon Sep 17 00:00:00 2001 From: JCPistori <91566210+JCPistori@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:12:29 -0300 Subject: [PATCH 2/4] fix: limita o loop de auto-restart do foreground service O ciclo "onDestroy -> setRestartAlarm(5s) -> RestartReceiver -> startForegroundService -> startForeground() lanca -> stopForegroundService -> onDestroy" nao tinha teto: enquanto a causa raiz persistisse (cota de FGS esgotada, start de FGS nao permitido em background) o alarme era reagendado a cada 5 segundos indefinidamente, drenando bateria e acordando o aparelho. - Adiciona RestartBudget: backoff de 5s/30s/2min/10min/30min, teto de 5 tentativas e janela de 1h que devolve o orcamento ao cheio. - Zera o orcamento quando o startForeground() realmente passa, e tambem em um start explicito vindo do Dart. - onTaskRemoved passa a compartilhar o mesmo orcamento (mantendo 1s na primeira tentativa), senao viraria a nova fonte do loop. - Troca setAlarmClock por setExactAndAllowWhileIdle: os dois concedem a janela de allowlist que permite iniciar FGS em background no Android 12+, mas o setAlarmClock e tratado como despertador do usuario e chega a exibir o icone de alarme na status bar. Co-Authored-By: Claude Opus 5 (1M context) --- .../flutter_foreground_task/PreferencesKey.kt | 7 ++ .../service/ForegroundService.kt | 30 ++++++- .../service/RestartBudget.kt | 88 +++++++++++++++++++ .../service/RestartReceiver.kt | 9 +- 4 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 android/src/main/kotlin/com/pravera/flutter_foreground_task/service/RestartBudget.kt 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..d79f70ba 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,11 @@ object PreferencesKey { // task data const val CALLBACK_HANDLE = "callbackHandle" + + // [GAUDIUM] orcamento de auto-restart + // Prefs proprio de proposito: o de task options e limpo no stop() e levaria o + // contador junto. + 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 56cd4bb7..ba9f3cc6 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 @@ -137,6 +137,11 @@ class ForegroundService : Service() { var action = foregroundServiceStatus.action val isSetStopWithTaskFlag = ForegroundServiceUtils.isSetStopWithTaskFlag(this) + // [GAUDIUM] Start explicito vindo do Dart sempre comeca com o orcamento cheio. + if (action == ForegroundServiceAction.API_START) { + RestartBudget.reset(this) + } + // [GAUDIUM/OEM] shouldReattach = true e OBRIGATORIO aqui. // Motorola e outros OEMs exigem que startForeground() seja chamado tambem no caminho // de parada; sem isso o processo crasha / lanca excecoes proprias do fabricante. @@ -225,8 +230,15 @@ 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) + // [GAUDIUM] O agendamento passa pelo orcamento: consume() devolve null quando + // o teto de tentativas foi atingido e ai nenhum alarme e agendado. + 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) + } } } @@ -235,7 +247,15 @@ class ForegroundService : Service() { if (ForegroundServiceUtils.isSetStopWithTaskFlag(this)) { stopSelf() } else { - RestartReceiver.setRestartAlarm(this, 1000) + // [GAUDIUM] Compartilha o mesmo orcamento do onDestroy -- senao este caminho + // vira a nova fonte do loop de alarmes. A primeira tentativa continua em 1s + // para nao perder responsividade quando o usuario so 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) + } } } @@ -304,6 +324,10 @@ class ForegroundService : Service() { acquireLockMode() _isRunningServiceState.update { true } + + // [GAUDIUM] So aqui o servico esta comprovadamente em foreground: o + // startForeground() acima passou. E o unico ponto seguro para zerar o orcamento. + RestartBudget.reset(this) } // [GAUDIUM/OEM] Reanexa o servico ao foreground sem reiniciar a task. 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..317f01c1 --- /dev/null +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/RestartBudget.kt @@ -0,0 +1,88 @@ +package com.pravera.flutter_foreground_task.service + +import android.content.Context +import android.util.Log +import com.pravera.flutter_foreground_task.PreferencesKey as PrefsKey + +/** + * [GAUDIUM] Orcamento de reinicios automaticos do foreground service. + * + * Sem isso o ciclo "onDestroy -> setRestartAlarm(5s) -> RestartReceiver -> + * startForegroundService -> startForeground() lanca -> stopForegroundService -> + * onDestroy" se repete indefinidamente a cada 5 segundos enquanto a causa raiz + * persistir (cota de FGS esgotada, start de FGS nao permitido em background, etc), + * drenando bateria e acordando o device sem limite. + * + * Aqui cada tentativa consome uma fatia de um orcamento com backoff crescente e + * teto. O contador zera quando o servico volta ao foreground com sucesso, quando + * o app pede um start explicito, ou quando a janela de observacao expira. + */ +object RestartBudget { + private val TAG = RestartBudget::class.java.simpleName + + /** Atraso de cada tentativa, em ms. O tamanho do array e o teto de tentativas. */ + private val BACKOFF_DELAYS = intArrayOf( + 5_000, // 5s + 30_000, // 30s + 120_000, // 2min + 600_000, // 10min + 1_800_000 // 30min + ) + + /** Passada esta janela sem nenhuma tentativa, o orcamento 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 + * orcamento acabou -- nesse caso o chamador NAO 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): recomeca o orcamento. + 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 orcamento. Chamar quando o servico 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..aa8959f0 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 @@ -37,10 +37,15 @@ class RestartReceiver : BroadcastReceiver() { context, RequestCode.SET_RESTART_SERVICE_ALARM, intent, flags) val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager + // [GAUDIUM] setExactAndAllowWhileIdle no lugar de setAlarmClock. + // Os dois concedem a janela temporaria de allowlist que permite iniciar um + // foreground service a partir do background no Android 12+, que e o motivo + // real de usar alarme exato aqui. Mas setAlarmClock e tratado pelo sistema + // como despertador do usuario: acorda o aparelho de forma agressiva e chega + // a exibir o icone de alarme na status bar, o que confundia usuarios. 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) } From ec16126326c13a5927d0d72ea9b396ae1bdeaa23 Mon Sep 17 00:00:00 2001 From: JCPistori <91566210+JCPistori@users.noreply.github.com> Date: Tue, 22 Sep 2026 11:13:11 -0300 Subject: [PATCH 3/4] fix: garante o teardown do service quando o reattach de OEM falha O caminho API_STOP do onStartCommand fica fora do try/catch, entao uma excecao em attachForegroundTask() (startForeground() recusado pelo sistema) abortava o stopForegroundService antes do stopSelf(): o servico continuava vivo e o _isRunningServiceState seguia true, fazendo o isRunningService() do Dart mentir e o start seguinte falhar com ServiceAlreadyStartedException. - Envolve o reattach em try/catch, dentro de stopForegroundService, cobrindo de uma vez os caminhos de onStartCommand, onDestroy e onTimeout. - Move stopSelf() e a atualizacao do state flow para um finally. - Remove Log.d ruidoso com TAG truncada no update do ForegroundServiceManager. Co-Authored-By: Claude Opus 5 (1M context) --- .../service/ForegroundService.kt | 31 ++++++++++++++----- .../service/ForegroundServiceManager.kt | 2 -- 2 files changed, 23 insertions(+), 10 deletions(-) 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 ba9f3cc6..fc47f7bc 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 @@ -358,16 +358,31 @@ class ForegroundService : Service() { // upstream precisa virar stopForegroundService(true) para preservar o reattach de OEM. private fun stopForegroundService(shouldReattach: Boolean) { if (shouldReattach) { - attachForegroundTask() + // [GAUDIUM] O reattach e best-effort. Se o startForeground() la dentro lancar, + // a parada NAO pode ser abortada: antes desta guarda a excecao escapava pelo + // caminho API_STOP do onStartCommand, que fica fora do try/catch, e o servico + // seguia vivo com _isRunningServiceState == true. Isso fazia o + // isRunningService() do Dart mentir e o start seguinte falhar com + // ServiceAlreadyStartedException. + 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 { + // [GAUDIUM] Inegociavel: o servico precisa parar e o state flow precisa + // refletir isso, de o que der acima. + stopSelf() + _isRunningServiceState.update { false } + } } @RequiresApi(Build.VERSION_CODES.O) diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundServiceManager.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundServiceManager.kt index 18c03f80..18ad0df1 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundServiceManager.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundServiceManager.kt @@ -2,7 +2,6 @@ package com.pravera.flutter_foreground_task.service import android.content.Context import android.content.Intent -import android.util.Log import androidx.core.content.ContextCompat import com.pravera.flutter_foreground_task.errors.ServiceAlreadyStartedException import com.pravera.flutter_foreground_task.errors.ServiceNotStartedException @@ -51,7 +50,6 @@ class ForegroundServiceManager { /** Update the foreground service. */ fun update(context: Context, arguments: Any?) { - Log.d("ForegroundServiceManag", "update called") if (!isRunningService()) { throw ServiceNotStartedException() } From 6d0dc0cee4b75c03c3db77448075a2baa663b12b Mon Sep 17 00:00:00 2001 From: JCPistori <91566210+JCPistori@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:40:50 -0300 Subject: [PATCH 4/4] =?UTF-8?q?doc:=20simplifica=C3=A7=C3=A3o=20de=20comen?= =?UTF-8?q?t=C3=A1rios?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../flutter_foreground_task/PreferencesKey.kt | 4 +- .../service/ForegroundService.kt | 47 ++----------------- .../service/ForegroundServiceManager.kt | 2 + .../service/RestartBudget.kt | 37 +++++++-------- .../service/RestartReceiver.kt | 6 --- ios/Classes/service/BackgroundService.swift | 11 ++--- 6 files changed, 30 insertions(+), 77 deletions(-) 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 d79f70ba..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 @@ -59,9 +59,7 @@ object PreferencesKey { // task data const val CALLBACK_HANDLE = "callbackHandle" - // [GAUDIUM] orcamento de auto-restart - // Prefs proprio de proposito: o de task options e limpo no stop() e levaria o - // contador junto. + // 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 fc47f7bc..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 @@ -97,9 +97,7 @@ class ForegroundService : Service() { private var isTimeout: Boolean = false - // [GAUDIUM/OEM] Executor para tocar sons em thread separada. - // NAO tocar o som na thread do onStartCommand: RingtoneManager.getRingtone() faz I/O - // e gerava ANR em ForegroundService.playCustomSound. Ver commit 842b2d6. + // Executor para tocar sons em thread separada private val soundExecutor = Executors.newSingleThreadExecutor() // A broadcast receiver that handles intents that occur in the foreground service. @@ -137,15 +135,10 @@ class ForegroundService : Service() { var action = foregroundServiceStatus.action val isSetStopWithTaskFlag = ForegroundServiceUtils.isSetStopWithTaskFlag(this) - // [GAUDIUM] Start explicito vindo do Dart sempre comeca com o orcamento cheio. if (action == ForegroundServiceAction.API_START) { RestartBudget.reset(this) } - // [GAUDIUM/OEM] shouldReattach = true e OBRIGATORIO aqui. - // Motorola e outros OEMs exigem que startForeground() seja chamado tambem no caminho - // de parada; sem isso o processo crasha / lanca excecoes proprias do fabricante. - // Ver commits 8a1b743 e e11b051. Nao simplificar para stopForegroundService(false). if (action == ForegroundServiceAction.API_STOP) { stopForegroundService(true) return START_NOT_STICKY @@ -189,9 +182,8 @@ class ForegroundService : Service() { } } catch (e: Exception) { Log.e(TAG, e.message, e) - // [GAUDIUM/OEM] shouldReattach = false de proposito: se chegamos aqui o - // startForeground() ja falhou, e tentar reanexar so repete a mesma excecao. - // Ver commit 9982fdb. + + // startForeground() falhou, e tentar reanexar repete a mesma exceção. stopForegroundService(false) } @@ -219,10 +211,6 @@ class ForegroundService : Service() { isCorrectlyStopped = foregroundServiceStatus.isCorrectlyStopped() } - // [GAUDIUM] Guarda contra crash de lateinit quando o servico morre antes do - // primeiro loadDataFromPreferences() (ex.: excecao no onStartCommand inicial). - // Ver commit 0b74aa9. O upstream resolve o mesmo problema com um if envolvendo - // o bloco de auto-restart -- em um merge, manter esta versao. if (!::foregroundTaskOptions.isInitialized) { Log.e(TAG, "foregroundTaskOptions was not properly started.") return @@ -230,8 +218,6 @@ class ForegroundService : Service() { val allowAutoRestart = foregroundTaskOptions.allowAutoRestart if (allowAutoRestart && !isCorrectlyStopped && !ForegroundServiceUtils.isSetStopWithTaskFlag(this)) { - // [GAUDIUM] O agendamento passa pelo orcamento: consume() devolve null quando - // o teto de tentativas foi atingido e ai nenhum alarme e agendado. val delayMillis = RestartBudget.consume(this) if (delayMillis == null) { Log.e(TAG, "The service wasn't properly stopped, but the auto-restart budget is exhausted.") @@ -247,9 +233,7 @@ class ForegroundService : Service() { if (ForegroundServiceUtils.isSetStopWithTaskFlag(this)) { stopSelf() } else { - // [GAUDIUM] Compartilha o mesmo orcamento do onDestroy -- senao este caminho - // vira a nova fonte do loop de alarmes. A primeira tentativa continua em 1s - // para nao perder responsividade quando o usuario so limpou a task. + // 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.") @@ -325,18 +309,10 @@ class ForegroundService : Service() { _isRunningServiceState.update { true } - // [GAUDIUM] So aqui o servico esta comprovadamente em foreground: o - // startForeground() acima passou. E o unico ponto seguro para zerar o orcamento. + // Serviço está em foreground e é seguro limpar o reinício. RestartBudget.reset(this) } - // [GAUDIUM/OEM] Reanexa o servico ao foreground sem reiniciar a task. - // Existe por dois motivos de dispositivo: - // 1. Motorola (e outros OEMs) exigem startForeground() tambem nos caminhos de - // update e stop, senao crasham com excecoes proprias do fabricante. - // 2. createNotificationChannel() precisa rodar ANTES do startForeground(), senao - // alguns aparelhos derrubam o servico por canal inexistente (commit e11b051). - // Nao remover nem inlinar em um merge com o upstream. private fun attachForegroundTask() { RestartReceiver.cancelRestartAlarm(this) @@ -353,17 +329,8 @@ class ForegroundService : Service() { } } - // [GAUDIUM/OEM] O parametro shouldReattach nao existe no upstream (la a assinatura e - // stopForegroundService() sem argumentos). Ao mergear, qualquer chamada nova vinda do - // upstream precisa virar stopForegroundService(true) para preservar o reattach de OEM. private fun stopForegroundService(shouldReattach: Boolean) { if (shouldReattach) { - // [GAUDIUM] O reattach e best-effort. Se o startForeground() la dentro lancar, - // a parada NAO pode ser abortada: antes desta guarda a excecao escapava pelo - // caminho API_STOP do onStartCommand, que fica fora do try/catch, e o servico - // seguia vivo com _isRunningServiceState == true. Isso fazia o - // isRunningService() do Dart mentir e o start seguinte falhar com - // ServiceAlreadyStartedException. try { attachForegroundTask() } catch (e: Exception) { @@ -378,8 +345,6 @@ class ForegroundService : Service() { } catch (e: Exception) { Log.e(TAG, "stopForegroundService() teardown failed: ${e.message}", e) } finally { - // [GAUDIUM] Inegociavel: o servico precisa parar e o state flow precisa - // refletir isso, de o que der acima. stopSelf() _isRunningServiceState.update { false } } @@ -499,8 +464,6 @@ class ForegroundService : Service() { val serviceId = notificationOptions.serviceId val notification = createNotification() - // [GAUDIUM/OEM] startForeground() tambem no caminho de update, mesma razao do stop. - // Ver commit 8a1b743. attachForegroundTask() val nm = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { diff --git a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundServiceManager.kt b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundServiceManager.kt index 18ad0df1..18c03f80 100644 --- a/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundServiceManager.kt +++ b/android/src/main/kotlin/com/pravera/flutter_foreground_task/service/ForegroundServiceManager.kt @@ -2,6 +2,7 @@ package com.pravera.flutter_foreground_task.service import android.content.Context import android.content.Intent +import android.util.Log import androidx.core.content.ContextCompat import com.pravera.flutter_foreground_task.errors.ServiceAlreadyStartedException import com.pravera.flutter_foreground_task.errors.ServiceNotStartedException @@ -50,6 +51,7 @@ class ForegroundServiceManager { /** Update the foreground service. */ fun update(context: Context, arguments: Any?) { + Log.d("ForegroundServiceManag", "update called") if (!isRunningService()) { throw ServiceNotStartedException() } 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 index 317f01c1..2ffef424 100644 --- 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 @@ -5,36 +5,35 @@ import android.util.Log import com.pravera.flutter_foreground_task.PreferencesKey as PrefsKey /** - * [GAUDIUM] Orcamento de reinicios automaticos do foreground service. + * Orçamento de reinícios automáticos do foreground service. * - * Sem isso o ciclo "onDestroy -> setRestartAlarm(5s) -> RestartReceiver -> - * startForegroundService -> startForeground() lanca -> stopForegroundService -> - * onDestroy" se repete indefinidamente a cada 5 segundos enquanto a causa raiz - * persistir (cota de FGS esgotada, start de FGS nao permitido em background, etc), - * drenando bateria e acordando o device sem limite. + * 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 orcamento com backoff crescente e - * teto. O contador zera quando o servico volta ao foreground com sucesso, quando - * o app pede um start explicito, ou quando a janela de observacao expira. + * 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. O tamanho do array e o teto de tentativas. */ + /** Atraso de cada tentativa, em ms. */ private val BACKOFF_DELAYS = intArrayOf( - 5_000, // 5s - 30_000, // 30s - 120_000, // 2min - 600_000, // 10min - 1_800_000 // 30min + 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 orcamento volta ao cheio. */ + /** 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 - * orcamento acabou -- nesse caso o chamador NAO deve agendar alarme algum. + * 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). @@ -46,7 +45,7 @@ object RestartBudget { val windowStart = prefs.getLong(PrefsKey.RESTART_WINDOW_START, 0L) var attempts = prefs.getInt(PrefsKey.RESTART_ATTEMPT_COUNT, 0) - // Janela expirada (ou primeira tentativa): recomeca o orcamento. + // Janela expirada ou primeira tentativa: recomeça o orçamento. val isNewWindow = windowStart == 0L || (now - windowStart) > WINDOW_MILLIS if (isNewWindow) { attempts = 0 @@ -71,7 +70,7 @@ object RestartBudget { return delay } - /** Zera o orcamento. Chamar quando o servico voltou a rodar em foreground. */ + /** 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) { 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 aa8959f0..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 @@ -37,12 +37,6 @@ class RestartReceiver : BroadcastReceiver() { context, RequestCode.SET_RESTART_SERVICE_ALARM, intent, flags) val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager - // [GAUDIUM] setExactAndAllowWhileIdle no lugar de setAlarmClock. - // Os dois concedem a janela temporaria de allowlist que permite iniciar um - // foreground service a partir do background no Android 12+, que e o motivo - // real de usar alarme exato aqui. Mas setAlarmClock e tratado pelo sistema - // como despertador do usuario: acorda o aparelho de forma agressiva e chega - // a exibir o icone de alarme na status bar, o que confundia usuarios. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && PluginUtils.canScheduleExactAlarms(context)) { alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerTime, operation) diff --git a/ios/Classes/service/BackgroundService.swift b/ios/Classes/service/BackgroundService.swift index fcea5d19..878cf0f7 100644 --- a/ios/Classes/service/BackgroundService.swift +++ b/ios/Classes/service/BackgroundService.swift @@ -129,8 +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 } - // [GAUDIUM] Divergencia do upstream: o som customizado deve tocar mesmo com - // playSound == false. Ver commit 94446e4. + // O som customizado deve tocar mesmo com playSound == false. if notificationOptions.playSound || notificationContent.notificationSound != nil { completionHandler([.alert, .sound]) } else { @@ -159,8 +158,7 @@ class BackgroundService: NSObject { } private func requestNotification() { - // [GAUDIUM] Divergencia do upstream: nao publicar notificacao com corpo vazio -- - // no iOS ela aparecia como um card em branco. Ver commit 35827d8. + // 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 } @@ -175,9 +173,6 @@ class BackgroundService: NSObject { content.body = self.notificationContent.text content.categoryIdentifier = NOTIFICATION_CATEGORY_ID - // [GAUDIUM] Divergencia do upstream: o else precisa zerar content.sound. - // Deixar o valor anterior fazia a notificacao repetir o ultimo som do cache - // mesmo quando pedido para nao emitir som. Ver commit c3cd397. if let soundName = self.notificationContent.notificationSound, !soundName.isEmpty { let soundFileURL = Bundle.main.url(forResource: soundName, withExtension: "caf") if let soundFileURL = soundFileURL { @@ -186,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 }