Pocket Studio Academy
HomePart 55.17

Focus Flow 6 — notifications

Full course15 min read·4 questions

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.

  1. A channel exists. Since Android 8, every notification belongs to one, and it must be created before the first post.
  2. The permission is declared in the manifest.
  3. 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 of it like this

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

notify/Notifier.ktkotlin
1const val CHANNEL_ID = "session_done"
2private const val NOTE_ID = 1001

Two 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:

MainActivity.ktkotlin
1        enableEdgeToEdge()
2        // The channel has to exist before the first
3        // notification is ever posted.
4        Notifier.ensureChannel(this)

The permission, declared

app/src/main/AndroidManifest.xmlxml
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:

ui/timer/TimerScreen.ktkotlin
    AskNotificationPermission()

About those star imports

ui/timer/NotificationPermission.ktkotlin
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"

notify/Notifier.ktkotlin
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

res/drawable/ic_stat_focus.xmlxml
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

ui/timer/TimerViewModel.ktkotlin
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:

ui/timer/TimerViewModel.ktkotlin
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".

9:41  ▲ ▮
Sun, 15 AugSilent notifications below
Focus Flow · now
Focus round done
Nice work. A 5 minute break has started.
Another app
Something else entirely
Clear all

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.

Try it in Pocket Studio
  1. Open Pocket StudioProjectsFocus Flowapp/src/main/AndroidManifest.xml. Add the <uses-permission> element directly above <application>.
  2. Long-press res/drawableNewDrawable resource file, name it ic_stat_focus, and type the vector.
  3. Long-press the com.nativeworks.focusflow package → NewPackage, name it notify. Inside it, add a Kotlin file called Notifier.
  4. Long-press ui/timerNewKotlin File, name it NotificationPermission.
  5. Open MainActivity.kt and add Notifier.ensureChannel(this) right after enableEdgeToEdge().
  6. Open ui/timer/TimerViewModel.kt. Change app: Application to private val app: Application and add the Notifier.sessionFinished(...) call at the end of finishPhase.
  7. Open ui/timer/TimerScreen.kt and add AskNotificationPermission() as the first line of TimerScreen.
  8. Tap Build, then Run ▶. On Android 13 or newer the permission dialog appears immediately. Tap Allow.
  9. Shorten the test: temporarily set the focus length to its minimum of 5 minutes in the Settings tab, tap Start, and lock the phone.
  10. When the phase ends, the notification appears. Tap it — the app opens and the notification disappears, which is setContentIntent and setAutoCancel doing their jobs.
  11. Now the honest test. Open the phone's SettingsAppsFocus FlowNotifications and turn them off. Run another phase. No alert, no crash, and the totals line still counts up.
  12. 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.

Download ZIP
Error Doctor5 common errors
java.lang.IllegalArgumentException: com.nativeworks.focusflow: Targeting S+ (version 31 and above) requires that one of FLAG_IMMUTABLE or FLAG_MUTABLE be specified when creating a PendingIntent. Strongly consider using FLAG_IMMUTABLE, only use FLAG_MUTABLE if some functionality depends on the PendingIntent being mutable.
MeansYou built a PendingIntent without saying whether whoever receives it is allowed to change what it does. Android 12 stopped guessing and started refusing.
FixPass 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.
E/NotificationService: No Channel found for pkg=com.nativeworks.focusflow, channelId=session_done, id=1001, tag=null, opPkg=com.nativeworks.focusflow, callingUid=10234, userId=0, ...
MeansThe notification named a channel that does not exist, so Android threw it away. Nothing crashes. Nothing appears either — this line in Logcat is the only clue you get.
FixCall 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.
android.app.RemoteServiceException$BadNotificationException: Bad notification posted from package com.nativeworks.focusflow: Couldn't create icon: StatusBarIcon(icon=Icon(typ=RESOURCE pkg=... id=0x0) ... )
MeanssetSmallIcon 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.
FixAdd .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.
java.lang.SecurityException: Permission Denial: ... from pid=8123, uid=10234 requires android.permission.POST_NOTIFICATIONS
MeansOn Android 13 or newer, you posted without holding the permission.
FixTwo halves, and you need both. Declare <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.
e: file:///.../NotificationPermission.kt:24:20 Unresolved reference 'rememberLauncherForActivityResult'.
MeansThe import is missing. Named in full it is androidx.activity.compose.rememberLauncherForActivityResult, which is 66 characters on an import line — over this course's 60 character budget.
FixThis one file uses 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.
Recap
  • 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.
  • createNotificationChannel on an id that already exists does nothing, which is why ensureChannel runs on every launch and never has to be remembered.
  • CHANNEL_ID names the category; NOTE_ID names the notification. Posting twice with the same NOTE_ID replaces 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. canPost returns false, sessionFinished returns, 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.