RizTech Academy logo
RizTech Academy
Work That Outlives the ScreenLesson 4 of 430 min

Notifications, channels and permission

The Alerts tab has promised since module 6 that Mandi Bhav would "tell you when a price crosses a level you choose, such as onion at Lasalgaon above ₹2,500 a quintal". The daily refresh from lesson 2 now brings the prices while the phone is in Priya's pocket; this lesson makes it tell her. That means storing the alerts, deciding which ones have crossed, asking permission at the right moment, posting a notification Android will actually show, and making a tap on it open the right screen. Four separate things went wrong on the emulator along the way, and each one looked fine in the code.

The alerts, and when one has crossed

An alert is a commodity, a market, a direction and a level. model/PriceAlert.kt:

enum class AlertDirection { Above, Below }

data class PriceAlert(
    val id: Long,
    val commodity: String,
    val market: String,
    val direction: AlertDirection,
    val level: Long,
    val isCrossed: Boolean = false,
)

fun PriceAlert.isMetBy(modalPrice: Long): Boolean = when (direction) {
    AlertDirection.Above -> modalPrice > level
    AlertDirection.Below -> modalPrice < level
}

Above for a farmer waiting to sell, below for a trader waiting to buy, compared with the modal price. Exactly at the level is not past it.

isCrossed is the field that matters. Without it, onion at ₹2,580 against a ₹2,500 alert would notify Priya on Monday, again on Tuesday at ₹2,610, and every evening until the price fell: a notification she learns to swipe away, and then to block. So an alert fires when the price goes past its level and was not past it last time, and is ready to fire again only after the price has come back. domain/AlertCheck.kt:

fun checkAlerts(alerts: List<PriceAlert>, prices: List<CommodityPrice>): AlertCheck {
    val todays = prices.associateBy { it.market to it.commodity }
    val newlyCrossed = mutableListOf<AlertCrossing>()
    val changes = mutableMapOf<Long, Boolean>()
    for (alert in alerts) {
        val price = todays[alert.market to alert.commodity] ?: continue
        val crossedNow = alert.isMetBy(price.modalPrice)
        if (crossedNow && !alert.isCrossed) newlyCrossed += AlertCrossing(alert, price)
        if (crossedNow != alert.isCrossed) changes[alert.id] = crossedNow
    }
    return AlertCheck(newlyCrossed, changes)
}

A pure function, so AlertCheckTest covers each case in a line: a new crossing, the same crossing next day, coming back, exactly at the level, below, and a market that did not report today (continue: a missing price is not a price that came back). An alert saved when the price is already past its level starts crossed; Priya can see that price on the screen in front of her.

The alerts are the user's own data, unlike prices a server can send again, so they go in Room: an AlertEntity in a new price_alerts table, schema version 5. A new table is something Room can migrate by itself, AutoMigration(from = 4, to = 5), and MigrationTest builds a version 4 database with a price in it, migrates, and checks both that the price survived and that the new table takes an alert. Installed over the module 9 build on the emulator, the app opened with its saved prices intact.

RefreshPricesUseCase from lesson 2 runs the check after every refresh, from the worker or the Refresh button:

suspend fun checkAlerts() = checking.withLock {
    val check = checkAlerts(alerts.allAlerts(), prices.todaysPrices().first())
    notifier.notify(check.newlyCrossed)
    alerts.setCrossed(check.crossedChanges)
}

The notifications go out before the new states are saved. If the process dies between the two, the next check announces the crossing again, which is better than one that is recorded and never announced. The Mutex makes two refreshes that finish together check one after the other, so the second finds nothing new.

On the emulator, serving a new day each time and running the worker: 11 November, onion ₹2,600 against a ₹2,650 alert, nothing; 12 November, ₹2,720, a notification; 13 November, ₹2,730, nothing new, and the shade still showed "Onion at Lasalgaon APMC: ₹2,720".

A channel

Since Android 8, every notification belongs to a channel, and the user controls each channel in Settings: its sound, whether it pops up, whether it appears at all. notifications/PriceAlertNotifications.kt:

const val PRICE_ALERTS_CHANNEL_ID = "price_alerts"

fun createNotificationChannels(context: Context) {
    NotificationManagerCompat.from(context).createNotificationChannel(
        NotificationChannelCompat.Builder(PRICE_ALERTS_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
            .setName("Price alerts")
            .setDescription("When a price crosses a level you set on the Alerts tab")
            .build(),
    )
}

called in MandiBhavApplication.onCreate. Creating a channel that exists is safe: Android updates its name and description and leaves alone whatever the user chose. That is also the catch. Once created, a channel's importance belongs to the user; changing IMPORTANCE_DEFAULT in a later release changes nothing on phones that already have the channel. Choose it carefully, and give each kind of notification its own channel, so a user can silence one without losing the others.

Importance What the user gets For
IMPORTANCE_HIGH Sound, and a pop-up over whatever is on screen Messages, calls, anything urgent
IMPORTANCE_DEFAULT Sound, in the shade Mandi Bhav's alerts: wanted, not urgent
IMPORTANCE_LOW In the shade, silent Background status
IMPORTANCE_MIN In the shade, collapsed, no status bar icon Things nobody needs to see
IMPORTANCE_NONE Nothing; the channel is blocked Set by the user, not by you

Asking at the moment it makes sense

From Android 13 (API 33), posting notifications needs the POST_NOTIFICATIONS runtime permission, and a new install starts without it. In the manifest:

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

When to ask is the decision. At launch, Priya has no idea why a price app wants to interrupt her, and a "no" then applies to everything. Ask when the reason is on the screen: the moment she saves her first alert. AlertsRoute:

val permissionLauncher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
    askingPermission = false
    notificationsOn = areAlertNotificationsOn(context)
}

onSave = {
    val first = uiState.alerts.isEmpty()
    if (viewModel.save()) {
        focusManager.clearFocus()
        if (first && !notificationsOn && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            askingPermission = true
            permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
        }
    }
}

On the emulator, a fresh install: the app opened with no dialog; the Alerts form filled in (Onion, Lasalgaon APMC, Above, 2600, with "Today: ₹2,580 a quintal." under the field); "Save alert"; and only then "Allow Mandi Bhav to send you notifications?".

"Don't allow" is a real answer, and the app has to live with it. The alert is still saved, and the screen still shows when a price is past its level. What changes is a card above the form, shown only once there is an alert to miss:

Notifications are off
Your alerts are saved, and this screen shows when a price is past your level, but Mandi Bhav cannot tell you when the app is closed.
[Turn on in Settings]

The button opens Settings.ACTION_APP_NOTIFICATION_SETTINGS for the app. Whether notifications are on is the system's to say, and the user can change it in Settings at any time, so the screen asks again whenever it comes back to the front, with LifecycleResumeEffect. On the emulator: Settings opened on Mandi Bhav's notification page; with notifications allowed there and Back pressed, the card was gone. areAlertNotificationsOn also checks the channel, since a user can leave the app allowed and block only "Price alerts".

And with notifications refused, the worker still ran: 9 prices saved, onion at ₹2,620 past ₹2,600, and dumpsys notification listed nothing from the app. SystemAlertNotifier checks the permission before posting, because Android 13 and later drop a notification posted without it, silently. Module 11 covers the rest of the permission model: explaining before asking, and what happens after a user refuses more than once.

Posting it

private fun AlertCrossing.toNotification(): Notification {
    val open = Intent(context, MainActivity::class.java)
        .putExtra(EXTRA_MARKET, price.market)
        .putExtra(EXTRA_COMMODITY, price.commodity)
    return NotificationCompat.Builder(context, PRICE_ALERTS_CHANNEL_ID)
        .setSmallIcon(R.drawable.ic_alerts)
        .setContentTitle(notificationTitle())   // Onion at Lasalgaon APMC: ₹2,720
        .setContentText(notificationText())     // Above your ₹2,650 a quintal. Modal price on 12 November.
        .setContentIntent(tapOpening(open, requestCode = alert.id.toInt()))
        .setAutoCancel(true)
        .setCategory(NotificationCompat.CATEGORY_RECOMMENDATION)
        .setGroup(GROUP_KEY)
        .build()
}

posted with the alert's id as the notification's id, so a second crossing of the same alert replaces its notification instead of adding another. tapOpening wraps the intent in a PendingIntent with FLAG_IMMUTABLE, which Android 12 and later require you to choose, and FLAG_UPDATE_CURRENT. The small icon is the Alerts tab's vector, a single colour on transparent, which is what the status bar draws.

Every line of that function, and the three after it, is there because of something the emulator showed.

Trap one: two notifications, and the bundle. The first version posted each notification on its own. With two alerts crossing in one refresh, onion and tomato, Android bundled them under "Mandi Bhav" by itself. A tap on that bundle sent the app's ordinary launch intent:

START u0 {act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] … cmp=com.riztechacademy.mandibhav/.MainActivity} with LAUNCH_SINGLE_TOP …

It opened whatever screen the app was last on, and both notifications vanished from the shade. So Mandi Bhav makes its own group: setGroup on each, plus a summary with setGroupSummary(true), an InboxStyle listing the crossings, and a tap that opens the Alerts tab. Measured again: the collapsed bundle opened Alerts; expanded, each notification opened its own price.

Trap two: one request code. Android treats two PendingIntents as the same if they differ only in their extras, and with FLAG_UPDATE_CURRENT the newer extras replace the older. A scratch build gave every notification request code 0, the same as the summary. Tapping "Onion at Lasalgaon APMC: ₹2,730":

I/M10Nav: onCreate 135609603 target=Alerts

The onion notification opened the Alerts tab, because the last PendingIntent made with code 0 was the summary's. The fix is the comment in the code: one request code per alert, and 0 is left for the summary because alert ids start at 1.

Trap three: the app is already open. MainActivity had the default launch mode. A scratch build, with Mandi Bhav open in the background, tapping the onion notification:

START u0 {… cmp=com.riztechacademy.mandibhav.m10/com.riztechacademy.mandibhav.MainActivity (has extras)} with LAUNCH_MULTIPLE … result code=2

Code 2 is "task brought to front". The app came forward exactly as it was, on the Prices list; the extras never reached it, and the notification was gone. With the app on screen, the result was code 3, "delivered to top", and still no change. The fix is two parts. android:launchMode="singleTop" on the activity, so an intent for the activity already on top is delivered to it, and a listener for it in MainActivity:

if (savedInstanceState == null) notificationTarget = intent.notificationTarget()
addOnNewIntentListener { newIntent -> newIntent.notificationTarget()?.let { notificationTarget = it } }

savedInstanceState == null matters: after rotation or process death the activity gets the same intent again, and without the check it would push the same screen a second time. With both, the tap in the background reused the same activity record and opened the price.

Trap four: a notification outlives its price. The first version put the price's id in the intent, lasalgaon-onion-2026-11-12. Module 9 made that id carry the date and replaced it when the next day's price arrived. A notification posted at seven tonight is often tapped tomorrow, after tomorrow's refresh. On the emulator, a notification from 12 November tapped after the 13 November refresh:

This price is no longer available.

module 6's not-found screen, from Priya's own alert. So the intent carries the market and commodity, and a new navigation key opens whatever today's price is:

@Serializable
data class LatestPrice(val market: String, val commodity: String) : NavKey

shown by LatestPriceRoute, whose ViewModel maps todaysPrices() to the matching price and reuses the detail screen. A notification posted for 15 November, tapped after the 16 November refresh, opened "Lasalgaon APMC, 16 Nov 2026, ₹2,690 per quintal"; Back went to today's prices, and Back again out of the app, because openFromNotification puts the screen on the Prices tab over its first screen.

Check your work

Why isCrossed is stored. So a price that stays past the level is announced once, not every evening; it fires again only after the price comes back.

When the permission is asked for, and why. When the first alert is saved, the moment the reason is on screen; never at launch.

What the app does when the user refuses. Saves the alert, shows when a price is past its level, posts nothing, and shows a card with a link to its notification settings, checked again on every resume.

What the system's own bundle did. A tap opened the app's launcher screen and cleared both notifications; Mandi Bhav's own group and summary fixed it.

What one shared request code did. The onion notification opened the Alerts tab: all three PendingIntents were one, with the summary's extras.

Why singleTop. Without it, a tap with the app open brought it forward unchanged and the price never reached it.

Why the intent carries market and commodity. The price id carries the date, and tomorrow's refresh replaces it; the market and commodity still name today's price.

Practice 4. The second notification for the same alert replaces the first, because its notification id is the alert's id; the shade shows one onion line with the newer price.

Practice

  1. Add the alerts: model, entity, DAO, schema 5 with its migration test, checkAlerts with its tests, the repository, and the new Alerts screen. Run the 154 tests.
  2. Clear the app's data, open it, and confirm no permission dialog appears until you save your first alert. Choose "Don't allow" and read the card. Then allow it from Settings and come back.
  3. Serve three days from your computer with the price below, above and still above an alert's level, running the debug receiver after each. Count the notifications. Then bring the price back below and above again.
  4. Make one alert cross, bring it back, and cross it again, without tapping the first notification. Predict what the shade shows, then check.
  5. Break it on purpose, one at a time, in a scratch copy under a different applicationIdSuffix: give every notification request code 0; remove singleTop; put price.id back in the intent. For each, tap a notification with the app closed and with it open, and write down what opened.
  6. Harder: Kavita sets twelve alerts, and a volatile day crosses eight of them at once. Decide what the shade should show, and change SystemAlertNotifier so eight crossings make one notification that lists them, while one crossing still opens its own price. Write the test first, against FakeAlertNotifier.

Official documentation

Next: module 11, Permissions and Device Features.


That is module 10. You can now:

  • name what stops background work on Android (process death, background execution limits, Doze, App Standby Buckets and force-stop), test each one from adb, and choose the API that fits a job before writing it;
  • schedule deferrable work with WorkManager: a Hilt-injected CoroutineWorker, unique periodic work with constraints and backoff, a configuration that gives WorkManager the right factory, and tests with work-testing;
  • show that work survives the app being swiped away mid-download, and explain what a force-stop does to it;
  • decide honestly whether a foreground service is justified, and if it is, give it a type, its permission, its Play declaration and an onTimeout;
  • create notification channels, ask for POST_NOTIFICATIONS at the moment it makes sense, live with "no", and make a tap open the right screen whether the app is closed, in the background or open.

Mandi Bhav now refreshes every evening while the phone is in a pocket, through the same single-flight refresh as the button, and survives being swiped away in the middle. Priya can set "onion at Lasalgaon above ₹2,500", and the evening it crosses, her phone tells her once, and a tap shows her today's price. Module 11, Permissions and Device Features, takes the permission model further than one dialog: explaining before asking, a user who refuses twice, and the camera, photos and location, which is how Mandi Bhav will find the market nearest to the farmer.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.

About the internship