I’m having issues with my Android app’s notification system and scheduled background tasks.
On some devices (such as the Google Pixel 8a), notifications are not received, the database doesn’t update automatically every few hours, or notifications arrive very late when the phone is locked or idle.
On other devices (for example, Samsung phones), everything works perfectly:
- Scheduled notifications are delivered at the correct time.
- The database updates approximately every 4 hours as intended.
App context
When the user adds an anime, the app schedules a notification for the release time of the next episode.
I also have a WorkManager task that runs every 4 hours to:
- Check if any release times have changed.
- Update the firebase database if needed.
- Reschedule notifications accordingly.
Everything works fine on some devices, but on others (like the Pixel 8a), the system seems to restrict or delay background tasks due to Doze mode or more aggressive battery optimization policies.
Question
Is there a more reliable way to ensure periodic background work and scheduled notifications run correctly even on devices with stricter Doze or battery restrictions?
I’m currently using WorkManager. Should I consider alternatives or additional configurations such as setExactAndAllowWhileIdle(), a ForegroundService, or any other recommended approach?
If possible, could someone provide a basic implementation example showing how to:
Run a reliable background task that executes even under Doze mode, and Schedule a notification that will fire exactly at a given time (even if the device is idle)?
I show how I have it mounted:
@HiltAndroidApp
class NotakuApplication : Application(), Configuration.Provider {
@Inject
lateinit var workerFactory: HiltWorkerFactory
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.setMinimumLoggingLevel(Log.ERROR)
.build()
override fun onCreate() {
super.onCreate()
launchPeriodicSyncWorker()
launchImmediateSyncWorker()
}
private fun launchPeriodicSyncWorker() {
val workRequest = PeriodicWorkRequestBuilder<AnimeSyncAndReminderWorker>(
4, TimeUnit.HOURS
)
.setInitialDelay(1, TimeUnit.MINUTES)
.build()
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
"anime_sync_and_reminder_work",
ExistingPeriodicWorkPolicy.KEEP,
workRequest
)
}
private fun launchImmediateSyncWorker() {
val oneTimeWork = OneTimeWorkRequestBuilder<AnimeSyncAndReminderWorker>().build()
WorkManager.getInstance(this)
.enqueueUniqueWork(
"immediate_anime_sync_and_reminder_work",
ExistingWorkPolicy.REPLACE,
oneTimeWork
)
}
}
@HiltWorker
class ReminderWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted workerParams: WorkerParameters
) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result {
val title = inputData.getString("title") ?: return Result.failure()
val episodeNumber = inputData.getInt("episodeNumber", 0)
val episodeDateMillis = inputData.getLong("episodeDateMillis", System.currentTimeMillis())
showNotification(applicationContext, title, episodeNumber, episodeDateMillis)
return Result.success()
}
companion object {
fun showNotification(context: Context, title: String, episodeNumber: Int, episodeDateMillis: Long) {
val formatter = DateTimeFormatter.ofPattern("HH:mm")
.withLocale(Locale.getDefault())
.withZone(ZoneId.systemDefault())
val formattedTime = formatter.format(Instant.ofEpochMilli(episodeDateMillis))
val message = "¡El episodio $episodeNumber de $title se estrenará a las $formattedTime!"
val channelId = "notaku_channel"
val notificationManager = context.getSystemService(NotificationManager::class.java)
if (notificationManager.getNotificationChannel(channelId) == null) {
val channel = NotificationChannel(
channelId,
"Notaku Notifications",
NotificationManager.IMPORTANCE_DEFAULT
)
notificationManager.createNotificationChannel(channel)
}
val builder = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.notaku_logo_black)
.setContentTitle(title)
.setContentText(message)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setAutoCancel(true)
notificationManager.notify(System.currentTimeMillis().toInt(), builder.build())
}
}
}
@HiltWorker
class AnimeSyncAndReminderWorker @AssistedInject constructor(
@Assisted context: Context,
@Assisted workerParams: WorkerParameters,
private val syncUserTrackedAnimesUseCase: SyncUserTrackedAnimesUseCase,
private val getTrackedAnimesUseCase: GetTrackedAnimesUseCase,
private val getUserPreferencesUseCase: GetUserPreferencesUseCase
) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result {
return try {
syncUserTrackedAnimesUseCase.execute()
scheduleEpisodeReminders(getTrackedAnimesUseCase.execute())
Result.success()
} catch (e: Exception) {
e.printStackTrace()
Result.retry()
}
}
private suspend fun scheduleEpisodeReminders(animes: List<FavoriteAnimeModel>) {
val context = applicationContext
val workManager = WorkManager.getInstance(context)
val reminderMinutes = getUserReminderMinutes()
val now = System.currentTimeMillis()
for (anime in animes) {
val episodeDateMillis = parseDateToMillis(anime.episodeDate) ?: continue
val delayMillis = episodeDateMillis - now - reminderMinutes * 60_000
val workName = "reminder_${anime.title}_${anime.episodeNumber}"
val data = workDataOf(
"title" to anime.title,
"episodeNumber" to anime.episodeNumber,
"episodeDateMillis" to episodeDateMillis
)
val existing = workManager.getWorkInfosForUniqueWork(workName).get()
val alreadyScheduled = existing.any {
it.state == WorkInfo.State.ENQUEUED &&
it.outputData.getLong("episodeDateMillis", 0) == episodeDateMillis
}
if (alreadyScheduled) {
Log.d(TagHandler.NOTAKU_INFO, "🔁 Already scheduled ${anime.title} - skipping")
continue
}
if (delayMillis > 0) {
val workRequest = OneTimeWorkRequestBuilder<ReminderWorker>()
.setInitialDelay(delayMillis, TimeUnit.MILLISECONDS)
.setInputData(data)
.build()
workManager.enqueueUniqueWork(workName, ExistingWorkPolicy.REPLACE, workRequest)
}
}
}
private fun parseDateToMillis(dateString: String): Long? {
return try {
val odt = OffsetDateTime.parse(dateString)
odt.toInstant().toEpochMilli()
} catch (e: Exception) {
Log.e(TagHandler.NOTAKU_ERROR, "Error parsing date: $dateString", e)
null
}
}
private suspend fun getUserReminderMinutes(): Int {
return getUserPreferencesUseCase()
.firstOrNull()
?.notificationTime ?: 30
}
}
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>