Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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)
}
}
}

Expand All @@ -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)
}
}
}

Expand Down Expand Up @@ -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() {
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit (Performance) — commit() é síncrono e bloqueia a thread principal (chamado em onDestroy/onTaskRemoved, caminhos sensíveis a ANR). Se a durabilidade imediata não for estritamente necessária (o alarme é local, não lê os prefs de outro processo), apply() evita o I/O bloqueante. Se a escolha foi deliberada para garantir que o contador sobreviva a um kill do processo logo após, vale um comentário explicando — hoje não é óbvio pra quem for mexer depois.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

O apply() enfileira a escrita, que ainda vai ocorrer na thread principal dependendo do ciclo de vida

}

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.")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
4 changes: 4 additions & 0 deletions ios/Classes/service/BackgroundService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}

Expand Down
Loading