Focus Flow 6 — notifications
Put the phone face down. A notification fires the moment a phase ends — which needs a channel created before the first post, a permission declared in the manifest, and on Android 13 and newer a runtime request the user is allowed to refuse without breaking anything.
A timer you have to watch is not a timer
Focus Flow currently requires you to stare at it. The ring is lovely and it is useless if the whole point of the app is that you are looking at something else.
So: the phone goes face down, you work, and twenty-five minutes later it tells you. That is one line of code in finishPhase and about eighty lines of getting Android to agree.
Three separate things have to be true before a notification appears, and missing any one of them gives you silence with no error message, which is a miserable way to spend an evening.
- A channel exists. Since Android 8, every notification belongs to one, and it must be created before the first post.
- The permission is declared in the manifest.
- The permission has been granted — which, from Android 13, means the user was asked at runtime and said yes.
This lesson does all three, and then does the fourth thing that most tutorials skip: it makes the app work perfectly well when the user says no.
Think about the switchboard in a hotel.
Every call to a room goes through a numbered line: one for reception, one for wake-up calls, one for housekeeping. A guest who does not want to be disturbed by housekeeping can have that line silenced without cutting off the wake-up call they actually asked for.
A is that numbered line. Android insists every notification travels on one so that the guest — not the hotel — decides which ones ring, which ones buzz, and which ones just wait quietly in the shade.
Focus Flow has exactly one line, and it is called Session alerts.
The channel
1const val CHANNEL_ID = "session_done"
2private const val NOTE_ID = 1001Two ids, and they do completely different jobs.
CHANNEL_ID names the category. It is written once when the channel is created and quoted again on every notification, and if the two ever disagree, Android silently throws the notification away. Sharing one constant removes that entire class of bug.
NOTE_ID names this particular notification. Posting again with the same id replaces the one already showing rather than adding a second. That is exactly what you want here: you never need two "focus round done" alerts stacked up.
The object gains two more functions later in this lesson; here is its first one.
minSdk for Focus Flow is 26, which is Android 8. You will often see this call wrapped in if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O), and on a project with a lower minSdk that guard is essential. Here it would always be true, so it is not written. Checking a version you already require is noise, and noise hides the checks that matter.
It has to run before the first post, so MainActivity does it on the way up:
1 enableEdgeToEdge()
2 // The channel has to exist before the first
3 // notification is ever posted.
4 Notifier.ensureChannel(this)The permission, declared
1 <!-- The only permission this app ever asks for. -->
2 <uses-permission
3 android:name="android.permission.POST_NOTIFICATIONS" />One line, above <application>. This is the entire list of Focus Flow uses — no network, no storage, no location — and that is worth being a little proud of.
Declaring it does two things. On Android 12 and older, it is all you need: the permission is granted at install time and there is nothing to ask. On Android 13 and newer, declaring it only buys you the right to ask.
The permission, granted
Android 13 (API 33, code name ) flipped notifications from opt-out to opt-in. Apps had spent a decade abusing the privilege, so now the user has to say yes.
The whole thing is one line in TimerScreen, first thing in the composable:
AskNotificationPermission()About those star imports
1// Two star imports, for one reason only: the names
2// rememberLauncherForActivityResult and
3// ActivityResultContracts make import lines that are
4// longer than the 60 character budget.
5import androidx.activity.compose.*
6import androidx.activity.result.contract.*Named in full, the first one is androidx.activity.compose.rememberLauncherForActivityResult — 59 characters plus import makes 66. Over budget.
A is normally worth avoiding, because it hides where a name came from and can quietly pull in something you did not mean. This file breaks that rule twice and says out loud, in a comment, exactly why. That is the right way to break a rule: not silently.
Never crash on a "no"
1 // On Android 13 and newer, posting without the
2 // POST_NOTIFICATIONS permission throws a
3 // SecurityException. Check before you post.
4 private fun canPost(context: Context): Boolean {
5 val needsAsking = Build.VERSION.SDK_INT >=
6 Build.VERSION_CODES.TIRAMISU
7 if (!needsAsking) return true
8
9 val state = ContextCompat.checkSelfPermission(
10 context,
11 Manifest.permission.POST_NOTIFICATIONS,
12 )
13 return state == PackageManager.PERMISSION_GRANTED
14 }You asked once, when the Timer screen first appeared. That answer is not permanent. The user can open Settings tomorrow and take the permission away, and your app is not told.
So the check happens again, immediately before every post. If the answer is no, sessionFinished returns and nothing at all happens. The timer keeps counting, the row is still written to the database, the ring still empties. The only thing missing is the alert the user explicitly said they did not want.
A focus timer that crashes because somebody declined a notification is a broken focus timer.
Building the notification
The status bar icon
1<?xml version="1.0" encoding="utf-8"?>
2<!-- Status bar icons must be a plain white shape on
3 a transparent background; Android tints them. -->
4<vector
5 xmlns:android="http://schemas.android.com/apk/res/android"
6 android:width="24dp"
7 android:height="24dp"
8 android:viewportWidth="24"
9 android:viewportHeight="24">
10
11 <path
12 android:pathData=
13 "M12,3 A9,9 0 1 0 12,21 A9,9 0 1 0 12,3"
14 android:strokeColor="#FFFFFF"
15 android:strokeWidth="2" />
16
17 <path
18 android:pathData="M12,7 L12,12.5 L16,15"
19 android:strokeColor="#FFFFFF"
20 android:strokeWidth="2"
21 android:strokeLineCap="round" />
22</vector>A , not a picture. Two paths: a circle drawn as two half-circle arcs, and a pair of clock hands.
The rule for status bar icons is strict and catches everybody once: white shape, transparent background, nothing else. Android takes the silhouette and tints it to match the system theme, so a full-colour icon does not come out full colour — it comes out as a solid white blob in the shape of its bounding box.
Firing it
1class TimerViewModel(
2 private val app: Application,
3) : AndroidViewModel(app) {One word, private val, turns the constructor parameter into a property the whole class can reach. AndroidViewModel already stores the Application, but getting it back out means calling getApplication() and casting, and keeping your own named reference is clearer.
Then, at the end of finishPhase:
1 Notifier.sessionFinished(
2 context = app,
3 finishedFocus = s.phase == Phase.FOCUS,
4 nextMinutes = (total / MINUTE_MS).toInt(),
5 )s.phase is the phase that just ended, because finishPhase was handed the old state. total is the length of the one about to start. So a focus round ending says "a 5 minute break has started", and a break ending says "back to it: 25 minutes of focus".
The notification in the shade, after a 25 minute focus round ends. The small white clock in the status bar is ic_stat_focus, tinted by the system.
- Open Pocket Studio → Projects → Focus Flow →
app/src/main/AndroidManifest.xml. Add the<uses-permission>element directly above<application>. - Long-press
res/drawable→ New → Drawable resource file, name itic_stat_focus, and type the vector. - Long-press the
com.nativeworks.focusflowpackage → New → Package, name itnotify. Inside it, add a Kotlin file calledNotifier. - Long-press
ui/timer→ New → Kotlin File, name itNotificationPermission. - Open
MainActivity.ktand addNotifier.ensureChannel(this)right afterenableEdgeToEdge(). - Open
ui/timer/TimerViewModel.kt. Changeapp: Applicationtoprivate val app: Applicationand add theNotifier.sessionFinished(...)call at the end offinishPhase. - Open
ui/timer/TimerScreen.ktand addAskNotificationPermission()as the first line ofTimerScreen. - Tap Build, then Run ▶. On Android 13 or newer the permission dialog appears immediately. Tap Allow.
- Shorten the test: temporarily set the focus length to its minimum of 5 minutes in the Settings tab, tap Start, and lock the phone.
- When the phase ends, the notification appears. Tap it — the app opens and the notification disappears, which is
setContentIntentandsetAutoCanceldoing their jobs. - Now the honest test. Open the phone's Settings → Apps → Focus Flow → Notifications and turn them off. Run another phase. No alert, no crash, and the totals line still counts up.
- While you are in there, look at the channel list. Session alerts is your channel, with your description underneath it.
Focus Flow — end of chapter 6
A complete project. Unzip it, open it in Pocket Studio, and press Run.
PendingIntent without saying whether whoever receives it is allowed to change what it does. Android 12 stopped guessing and started refusing.PendingIntent.FLAG_IMMUTABLE as the last argument, as the code above does. Immutable is the right choice here — the intent only ever needs to reopen your own activity, so nobody has any business editing it.Notifier.ensureChannel(this) in MainActivity.onCreate, and make sure the id used to create the channel is the same one the builder is given. One shared CHANNEL_ID constant removes the whole class of bug.setSmallIcon was never called, or was given a resource that does not exist — that is what id=0x0 means. Every notification must have a small icon..setSmallIcon(R.drawable.ic_stat_focus) and make sure the drawable really exists. Keep it a plain white shape on a transparent background: Android tints the silhouette, so a full-colour icon comes out as a white blob.<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> in the manifest, and check ContextCompat.checkSelfPermission(...) before posting, as canPost does. Declaring it without ever asking means the dialog never appears and the check always fails.androidx.activity.compose.rememberLauncherForActivityResult, which is 66 characters on an import line — over this course's 60 character budget.import androidx.activity.compose.* and import androidx.activity.result.contract.*. Star imports are normally worth avoiding, which is why the file carries a comment saying exactly why it makes an exception.- Three things must all be true or you get silence with no error: the channel exists, the permission is declared, and the permission is granted.
- A is a category the user controls. Its , name and description are fixed the moment it is created, so choose them properly the first time.
createNotificationChannelon an id that already exists does nothing, which is whyensureChannelruns on every launch and never has to be remembered.CHANNEL_IDnames the category;NOTE_IDnames the notification. Posting twice with the sameNOTE_IDreplaces rather than stacks.- From Android 13, is a . Ask once with an inside a , and check again with before every single post — the user can revoke it at any time.
- Refusing must cost the user nothing.
canPostreturns false,sessionFinishedreturns, and the timer carries on exactly as before. - A needs , and a status bar icon must be a white shape on a transparent background.
- Next: the last chapter. All those saved sessions become three totals and a seven-day bar chart, drawn by hand on a Canvas — and Focus Flow is finished.