devsnack

Renewal Reminders on Android: A Reminder Is Not an Alarm

The subscription billed on the 31st moves to the 28th in February and never moves back. Anchored date arithmetic, the exact-alarm permission not to ask for, and one WorkManager sweep that survives reboots, Doze and a restored backup.

DevSnack15 Sep 2026 · 16 min

A user adds their gym membership, billed on the 31st, and asks for a reminder three days before. January's arrives on the 28th. February's arrives on the 25th, which is right. March's also arrives on the 25th, three days before the 28th, and the gym charges them on the 31st — six days after a reminder they have now learned to ignore. The review says the reminders are wrong. Crashlytics says nothing at all.

A different user updates their phone, it restarts, and they never get a reminder again. That review is shorter.

A renewal reminder has two jobs, and neither of them is arriving at a particular minute. It has to be about the right day, and it has to arrive at all — on a phone that has been rebooted, dozed, restored from a backup and not opened in five weeks. The obvious implementation gets the first job wrong in the date arithmetic and the second wrong by reaching for an alarm.

How the amount is stored, and why it is not a Double, is the Room money post. This one starts at the date column and ends in the notification shade.

The date is wrong before anything schedules it

java.time clamps when a month is too short for the day you ask for, and it is right to. The 31st of January plus one month is the 28th of February, because there is no 31st to land on. The bug is what happens next: the app stores that result as the new renewal date and adds a month to it the following time. The clamp stops being a one-month adjustment and becomes the date forever.

LocalDate.of(2026, 1, 31).plusMonths(1)   = 2026-02-28
LocalDate.of(2026, 2, 28).plusMonths(1)   = 2026-03-28   // stepped from last time: the 31st is gone
LocalDate.of(2026, 1, 31).plusMonths(2)   = 2026-03-31   // stepped from the anchor

LocalDate.of(2028, 2, 29).plusYears(1)    = 2029-02-28
LocalDate.of(2029, 2, 28).plusYears(3)    = 2032-02-28   // a leap year, and the wrong day
LocalDate.of(2028, 2, 29).plusYears(4)    = 2032-02-29

So do not store the next renewal date as a column you overwrite. Store the date the subscription was first billed — the anchor — and its cycle, and derive the next renewal from the anchor every time you need it. The arithmetic is a count of whole cycles, never a chain of single steps.

domain/Renewal.kt
enum class Cycle(val months: Long) { MONTHLY(1), QUARTERLY(3), YEARLY(12) }

fun nextRenewal(anchor: LocalDate, cycle: Cycle, today: LocalDate): LocalDate {
    if (!anchor.isBefore(today)) return anchor

    // Whole cycles since the anchor, then one plusMonths from the
    // anchor itself. Never from the previous renewal — that is
    // where the 31st turns into the 28th and stays there.
    var n = ChronoUnit.MONTHS.between(anchor, today) / cycle.months
    var next = anchor.plusMonths(n * cycle.months)
    while (next.isBefore(today)) {
        n += 1
        next = anchor.plusMonths(n * cycle.months)
    }
    return next
}

Weekly cycles do not need any of this, because a week always has seven days and plusWeeks never clamps. Keep them in the same function if you support them; just do not route them through months.

Store the anchor as a LocalDate — an epoch day in an INTEGER column through a one-line type converter — and not as a timestamp. A subscription renews on the 14th wherever its owner happens to be standing; converting that to an instant at the moment it was entered is how a user who flies west gets every reminder a day early. If your minSdk is below 26, java.time needs core library desugaring switched on in the Gradle build.

An exact alarm is the wrong tool and the wrong permission

The reflex is AlarmManager: compute 09:00 three days before the renewal and set an exact alarm for it. That reflex has become expensive. From Android 12, an app targeting it needs SCHEDULE_EXACT_ALARM to set an exact alarm, and setting one without it throws a SecurityException. From Android 14 that permission is denied by default on a fresh install for apps targeting Android 13 or higher, and there is no dialog to grant it — the user has to find the Alarms & reminders page in Settings. If they later switch it off, the system stops your app and cancels every exact alarm it had set.

reminders/AlarmScheduler.kt
val alarms = context.getSystemService(AlarmManager::class.java)

// Android 12+: SecurityException without SCHEDULE_EXACT_ALARM.
// Android 14+: that permission starts out denied on a new install,
// and the only way to grant it is a Settings page the user has to
// be sent to and has no reason to understand.
if (alarms.canScheduleExactAlarms()) {
    alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent)
}

The other exact-alarm permission, USE_EXACT_ALARM, is granted without asking — which is why Google Play only permits it in apps whose core function is an alarm clock or a calendar. A subscription tracker is neither, and an app that ships it anyway is asking a reviewer to agree that it is.

More to the point, the permission buys nothing the feature needs. No user can tell whether a reminder three days out arrived at 09:00 or at 11:40. And alarms do not survive a reboot: every one of them is cleared when the phone restarts, so an alarm-based reminder also needs a BOOT_COMPLETED receiver to put them all back, plus a way to rebuild the full set after an app update or a restore. setAndAllowWhileIdle drops the permission requirement by dropping exactness, but it is cleared by a reboot just the same. At that point you are writing a persistent job scheduler on top of AlarmManager, and Jetpack already ships one.

One sweep, not one job per subscription

The second reflex, once WorkManager is on the table, is one delayed job per subscription. That moves the problem rather than removing it. Every edit, deletion, pause and change of billing cycle now has to cancel a job and enqueue another, and each of those is a place to forget — the forgotten one is a reminder for a subscription the user deleted last week. It also fails the restore case outright: WorkManager keeps its queue in the app's no-backup storage, so a phone restored from a backup gets every subscription back and none of the jobs.

Turn it around. Schedule one periodic worker, once, that reads the table and decides what is due. The database is the source of truth and the schedule holds nothing but a heartbeat, so editing a subscription needs no scheduling code at all — the next sweep simply sees the new row.

App.kt
class App : Application() {
    override fun onCreate() {
        super.onCreate()

        val sweep = PeriodicWorkRequestBuilder<RenewalReminderWorker>(6, TimeUnit.HOURS)
            .build()

        // KEEP, so launching the app leaves an existing sweep and its
        // timing alone instead of cancelling it and starting over.
        WorkManager.getInstance(this).enqueueUniquePeriodicWork(
            "renewal-reminders",
            ExistingPeriodicWorkPolicy.KEEP,
            sweep,
        )
    }
}

Enqueuing in Application.onCreate is what makes the restored phone work: the first launch after a restore puts the sweep back, and the sweep finds the restored rows. WorkManager itself handles reboots, so nothing else needs a boot receiver.

There are no constraints on the request because the sweep needs no network — every fact it uses is on the device. Six hours is not a timetable, either. WorkManager treats the interval as the minimum spacing between runs, never shorter than fifteen minutes, and the system is free to run each one later than that. The rest of the design assumes it will.

Query a window, not a day

Because runs are late, the check cannot be "renews exactly three days from today". A sweep deferred from Tuesday night to Wednesday morning skips that day entirely and the reminder never fires. Ask a question that stays true across a missed run instead — renews within the next three days, and has not been reminded about yet.

reminders/RenewalReminderWorker.kt
class RenewalReminderWorker(
    context: Context,
    params: WorkerParameters,
) : CoroutineWorker(context, params) {

    override suspend fun doWork(): Result {
        val db = AppDatabase.get(applicationContext)
        val notifier = RenewalNotifier(applicationContext)
        val now = ZonedDateTime.now(ZoneId.systemDefault())
        val today = now.toLocalDate()

        for (sub in db.subscriptions().active()) {
            val renewal = nextRenewal(sub.anchor, sub.cycle, today)
            val daysLeft = ChronoUnit.DAYS.between(today, renewal)

            // A window. If the run meant for "three days before" was
            // deferred, two days before still gets a reminder.
            if (daysLeft > sub.leadDays) continue

            // Quiet hours are a filter, not a schedule. Skipping leaves
            // the reminder unclaimed for a later run — unless renewal is
            // today or tomorrow, when late beats never.
            if (now.hour !in 8..20 && daysLeft > 1) continue

            val entry = ReminderLogEntity(sub.id, renewal.toEpochDay(), System.currentTimeMillis())
            if (db.reminderLog().claim(entry) != -1L) {
                notifier.post(sub, renewal, daysLeft)
            }
        }
        return Result.success()
    }
}

Reading every active subscription is deliberate. It is a list a person maintains by hand — dozens of rows, not thousands — and a query that loads all of them and runs the date arithmetic in Kotlin keeps nextRenewal in one place instead of re-implementing month clamping in SQL.

The time zone is read when the sweep runs, not when the subscription was saved, so a user who travels gets reminders in the local day they are actually living in. That falls out of the design for free; with one alarm per subscription it is a bug you have to go looking for.

Claim the reminder before posting it

A window means the same renewal is eligible on several runs, so something has to remember which ones have already been sent. That is a table whose primary key is the question being answered.

data/local/ReminderLog.kt
@Entity(
    tableName = "reminder_log",
    primaryKeys = ["subscription_id", "renewal_date"],
    foreignKeys = [ForeignKey(
        entity = SubscriptionEntity::class,
        parentColumns = ["id"],
        childColumns = ["subscription_id"],
        onDelete = ForeignKey.CASCADE,
    )],
)
data class ReminderLogEntity(
    @ColumnInfo(name = "subscription_id") val subscriptionId: String,
    // Epoch day of the renewal this reminder was about. Next month
    // is a different row, so it gets its own reminder.
    @ColumnInfo(name = "renewal_date") val renewalDate: Long,
    @ColumnInfo(name = "posted_at") val postedAt: Long,
)

@Dao
interface ReminderLogDao {
    // Returns the new row id, or -1 when the key already exists:
    // the insert and the "have we sent this?" check are one statement.
    @Insert(onConflict = OnConflictStrategy.IGNORE)
    suspend fun claim(entry: ReminderLogEntity): Long
}

Keying on the renewal date rather than on a month number does the right thing when the user edits an anchor: the new date is a different key, so the corrected renewal gets a reminder of its own. The cascade removes a deleted subscription's history with it.

Claiming before posting is a trade, and worth making on purpose. If the process dies in the single line between the insert and the notify call, that one reminder is lost. The other order has the mirror-image window — posted, then killed before the insert — and the next run sends it again. Both windows are one line wide, so pick the failure you would rather explain; a duplicate is the one users screenshot.

The notification needs a permission, a channel and a reason

From Android 13, posting notifications is a runtime permission, POST_NOTIFICATIONS, for apps targeting 13 or higher. Without it the notify call posts nothing and throws nothing, which makes it the quietest failure in this post. Checking it has a trap of its own: checkSelfPermission reports that permission as denied on Android 12 and below, where it does not exist, so a naive check switches reminders off for everyone on an older phone. areNotificationsEnabled() gives the answer you actually want on every version.

reminders/RenewalNotifier.kt
class RenewalNotifier(private val context: Context) {

    private val manager = NotificationManagerCompat.from(context)

    fun ensureChannel() {
        // Required from Android 8; the Compat call is a no-op below it.
        // A notification sent to a channel that does not exist is dropped.
        val channel = NotificationChannelCompat
            .Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
            .setName(context.getString(R.string.channel_renewals))
            .build()
        manager.createNotificationChannel(channel)
    }

    fun post(sub: Subscription, renewal: LocalDate, daysLeft: Long) {
        // Covers the Android 13 permission and the per-app switch on
        // older versions, which checkSelfPermission does not.
        if (!manager.areNotificationsEnabled()) return

        val notification = NotificationCompat.Builder(context, CHANNEL_ID)
            .setSmallIcon(R.drawable.ic_notification)
            .setContentTitle(context.getString(R.string.renews_soon, sub.name))
            .setContentText(renewalText(sub, renewal, daysLeft))
            .setContentIntent(openSubscription(context, sub.id))
            .setAutoCancel(true)
            .build()

        manager.notify(sub.id.hashCode(), notification)
    }

    companion object {
        const val CHANNEL_ID = "renewals"
    }
}

Call ensureChannel from Application.onCreate alongside the sweep. Then ask for the permission at the moment it makes sense — when the user switches a reminder on for their first subscription — not on first launch, before the app has shown them anything worth being reminded about. A request denied twice stops showing its dialog at all, so an early denial is close to permanent.

And make the failure visible where the user will see it. The dashboard knows reminders are switched on and can ask whether notifications are enabled; when they are not, a one-line banner with a button to the settings page is worth more than any amount of scheduling code.

A subscription tracker is exactly the app Android defers

Android sorts apps into standby buckets by how recently and how often they are used, and background work from an app in a lower bucket is spaced further apart. A subscription tracker is the textbook case. It is opened intensely on the afternoon someone adds their subscriptions, then not again for weeks — which is the whole point of the product, and precisely the usage pattern that earns the least background time.

That is the real reason for a lead time measured in days, and for the window query. A reminder for the morning of the renewal is not just rude about timing, it is structurally too late: the charge can land in the small hours, and a sweep from a rarely used app may not run again until the afternoon. Three days before gives a deferred sweep several chances to find it. There is a pleasant side effect as well — a user tapping a notification counts as using the app, which moves it back up the buckets just as the next reminders come due.

One case no code fixes. A user who force-stops the app, or a manufacturer's battery manager that does the equivalent when the app is swiped from recents, stops all of its scheduled work until the app is opened again. The honest response is in the product, not the scheduler: show the date of the last successful sweep somewhere on the settings screen, so a user whose phone is doing this can find out why.

Test it on a phone that is asleep

The date bug is a pure function and deserves a plain JVM unit test, run for every day of a few years rather than for the one date you thought of. Start with the case that opened this post.

test/domain/RenewalTest.kt
@Test
fun thirtyFirstSurvivesFebruary() {
    val anchor = LocalDate.of(2026, 1, 31)

    assertEquals(LocalDate.of(2026, 2, 28), nextRenewal(anchor, Cycle.MONTHLY, LocalDate.of(2026, 2, 1)))
    assertEquals(LocalDate.of(2026, 3, 31), nextRenewal(anchor, Cycle.MONTHLY, LocalDate.of(2026, 3, 1)))
    assertEquals(LocalDate.of(2026, 4, 30), nextRenewal(anchor, Cycle.MONTHLY, LocalDate.of(2026, 4, 1)))
}

The delivery half only means anything on a device in the states a real phone spends its life in, and you can put one there in a minute rather than waiting five weeks.

# Where weeks of not opening the app would put it
adb shell am set-standby-bucket com.example.subs rare

# Doze, without leaving the phone on a desk for an hour.
# Turn the screen off first.
adb shell dumpsys battery unplug
adb shell dumpsys deviceidle force-idle

# Put things back afterwards
adb shell dumpsys deviceidle unforce
adb shell dumpsys battery reset

# And the one that clears every alarm you were relying on
adb reboot

Add a subscription that renews in two days, put the phone through all of that, and do not open the app afterwards. If the reminder still turns up — a few hours late, about the right day — the feature works. If the only way you have ever seen a reminder arrive is with the app open on your desk, you have tested the scheduler that ran in your head, not the one on the phone.

TrackMySubs — $11

A Compose subscription manager with renewal reminders, multi-currency amounts and a Room database behind the dashboard, full source included.

View on CodeCanyon