Hebrew version · English chapter index
Previous chapter · Next chapter
On a touch screen, swipe right for the next chapter and left for the previous chapter.
We continue from the app at the end of chapter 3. We will add a LocalNotif button: pressing it requests permission if it has not already been granted, then immediately displays a notification on the same phone after approval. Later, we will explore two more ways to create similar notifications on other people’s phones, both from our app and from a completely external source.
Back to Roadmap for Chapters 5–7: From Local Alert to Firebase
What you will learn
- How to declare
POST_NOTIFICATIONSand request it at runtime on Android 13 and above. - What a
NotificationChannelis and why every notification belongs to a channel. - How to build a
Notificationand submit it to Android usingnotify.
We will not use scheduling or background work. There is no AlarmManager, JobScheduler or WorkManager here: the click is the event that displays the notification immediately.
1. Declare the notification permission
In the Android window, open:
app > manifests > AndroidManifest.xml
Add the permission inside manifest, before application:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
+ <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
+
<application
android:allowBackup="true"
The Manifest declaration only allows the app to request permission. On Android 13 and later, the user must still approve it in a system dialog at runtime, and we must decide when to request permission. Once permission has been granted, the app knows not to ask again.
2. Add the button to the screen
Reminder: If you have not yet practiced using the graphical editor for ConstraintLayout, work through this lesson. If you cannot do it now, set aside time later to practice building these screens.
In the Android window, open:
app > res > layout > activity_main.xml
Inside ConstraintLayout, after gameBoard and before the closing tag, add the button. At the same time, update the board’s constraint so that it starts below the new button:
<com.example.collectcircles.GameBoardView
android:id="@+id/gameBoard"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginTop="16dp"
android:layout_marginBottom="20dp"
android:minHeight="320dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
- app:layout_constraintTop_toBottomOf="@id/startButton"
+ app:layout_constraintTop_toBottomOf="@id/localNotifButton"
tools:background="@color/board_background" />
+ <com.google.android.material.button.MaterialButton
+ android:id="@+id/localNotifButton"
+ android:layout_width="0dp"
+ android:layout_height="wrap_content"
+ android:layout_marginTop="8dp"
+ android:text="@string/local_notification"
+ app:cornerRadius="14dp"
+ app:layout_constraintEnd_toEndOf="parent"
+ app:layout_constraintStart_toStartOf="parent"
+ app:layout_constraintTop_toBottomOf="@id/startButton" />
+
</androidx.constraintlayout.widget.ConstraintLayout>
Now open:
app > res > values > strings.xml
Add the button text after start, and the notification text before </resources>:
<string name="start">Start</string>
+ <string name="local_notification">Permission request + Local Notif</string>
<string name="restart">Restart</string>
Further down in the same file:
<string name="completion_message">You collected every circle in %1$.3f seconds.</string>
<string name="ok">OK</string>
+ <string name="notification_channel_name">Circle invitations</string>
+ <string name="notification_channel_description">Notifications about Collect Circles invitations</string>
+ <string name="notification_title">Collect Circles</string>
+ <string name="notification_text">Come collect circles with me!</string>
+ <string name="notification_permission_denied">Notification permission is required</string>
</resources>
3. Create the Notifications helper class
In the Android window, go to:
app > kotlin+java > com.example.collectcircles
Right click on the com.example.collectcircles package, select New > Java Class, type Notifications and select Class. The new class will appear in the same package named Notifications.
Replace the contents of the file with the following code:
package com.example.collectcircles;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.content.Context;
public final class Notifications {
private static final String CHANNEL_ID = "circle_invitations";
private static final int NOTIFICATION_ID = 1;
private Notifications() {
// This is a helper class; there is no need to create instances of it.
}
public static void createChannel(Context context) {
NotificationManager manager = context.getSystemService(NotificationManager.class);
// Every notification must belong to a channel. Creating an existing channel does not duplicate it,
// so it is safe to call this method before each display. HIGH importance lets the system
// display the notification prominently, subject to the user’s settings on the device.
NotificationChannel channel = new NotificationChannel(
CHANNEL_ID,
context.getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_HIGH
);
channel.setDescription(
context.getString(R.string.notification_channel_description)
);
manager.createNotificationChannel(channel);
}
public static void show(Context context) {
createChannel(context);
NotificationManager manager = context.getSystemService(NotificationManager.class);
// The builder only describes the notification. Calling notify submits it
// to Android, which then takes responsibility for displaying it to the user.
Notification notification = new Notification.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentTitle(context.getString(R.string.notification_title))
.setContentText(context.getString(R.string.notification_text))
.build();
manager.notify(NOTIFICATION_ID, notification);
}
}
CHANNEL_ID is the fixed internal identifier. The user sees the channel’s name and description in settings. Creating an existing channel is safe: Android does not create another copy. IMPORTANCE_HIGH allows prominent display, but users can still change the channel settings.
Reusing NOTIFICATION_ID updates the same notification instead of filling the notification drawer with copies.
Before continuing: After syncing Gradle, the project should run at this stage.
4. Ask for permission and connect the button
In the Android window, open:
app > kotlin+java > com.example.collectcircles > MainActivity
A. Add imports
At the top of the file, add the following imports in the appropriate places. Android Studio can add them automatically with Alt+Enter:
package com.example.collectcircles;
+import android.Manifest;
import android.content.SharedPreferences;
+import android.content.pm.PackageManager;
+import android.os.Build;
import android.os.Bundle;
import android.os.SystemClock;
+import android.widget.Toast;
import androidx.activity.EdgeToEdge;
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
import androidx.appcompat.app.AppCompatActivity;
B. Add a field
Add a launcher beside the binding field to receive the user’s response:
private ActivityMainBinding binding;
+ private ActivityResultLauncher<String> notificationPermissionLauncher;
private SharedPreferences preferences;
C. Register the permission request
Inside onCreate, first find the entire insets block. The block ends with the line }); — the closing brace ends the lambda, the closing parenthesis ends the listener call, and the semicolon ends the statement. Add the permission registration only after the line });, and before getSharedPreferences:
binding = ActivityMainBinding.inflate(getLayoutInflater());
setContentView(binding.getRoot());
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
return insets;
}); // This is where the insets listener ends
+ // Continue inside onCreate, but outside the insets listener.
+ // Starting with Android 13, explicit permission is needed to display notifications.
+ notificationPermissionLauncher = registerForActivityResult(
+ new ActivityResultContracts.RequestPermission(),
+ permissionGranted -> { // The callback is declared here.
+ // This is an anonymous function: an unnamed piece of code we want to run.
+ if (permissionGranted) {
+ Notifications.show(this);
+ } else {
+ Toast.makeText(
+ this,
+ R.string.notification_permission_denied,
+ Toast.LENGTH_SHORT
+ ).show();
+ }
+ }
+ );
+
// Continue inside onCreate, but outside the insets listener.
preferences = getSharedPreferences(PREFERENCES_NAME, MODE_PRIVATE);
bestTimeMillis = preferences.getLong(BEST_TIME_KEY, NO_BEST_TIME);
showBestTime();
binding.startButton.setOnClickListener(view -> startGame());
binding.gameBoard.setOnGameFinishedListener(this::finishGame);
} // This is where onCreate ends
If v.setPadding(...) is missing immediately after the systemBars calculation, check before adding another copy. Look further down in the same onCreate: if it appears only after getSharedPreferences or the button listeners, code from an earlier chapter was accidentally pasted inside the insets callback. Move v.setPadding(...) and return insets directly after the systemBars calculation, close the listener with });, then keep the remaining initialization code after it. If the project has no setOnApplyWindowInsetsListener call, you do not need to add one just for notifications. Register the permission request directly in onCreate, before getSharedPreferences.
If notificationPermissionLauncher = registerForActivityResult(...) is between the opening { of setOnApplyWindowInsetsListener and its closing });, it is in the wrong place. The insets callback may not run until the Activity is already RESUMED, causing a crash such as LifecycleOwners must call register before they are STARTED. Only the insets calculation, padding update, and return insets belong inside that listener.
Distinguish the two actions triggered by the same button. LocalNotif starts the permission request process and, once permission has been granted, requests notification display. These are separate actions:
notificationPermissionLauncher.launch(...)opens a system dialog asking for permission to display notifications. Only app code running while the user is using the app can open this dialog.Notifications.show(...)creates a notification and submits it to Android. It can be displayed only if permission has already been granted.
The same permission also applies to notifications that arrive later from FCM. If on Android 13 and above the application never asked the user for POST_NOTIFICATIONS, or the user rejected it, the company’s server can still send an FCM message and the device may even receive it — but Android will not display it as a notification to the user. The server and curl cannot remotely open the permission dialog or authorize it on the user’s behalf.
D. Add the method
Immediately after onCreate and before startGame, add:
}
+ private void requestPermissionAndShowNotification() {
+ // Android 12 and earlier do not have a runtime notification permission.
+ // On Android 13, request permission if it has not already been granted.
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU
+ && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
+ != PackageManager.PERMISSION_GRANTED) {
+ notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS);
+ return; // If we just requested permission, do not continue to Notifications.show.
+ }
+
+ Notifications.show(this); // Display the notification only if this is not the first click.
+ // On the first click, the callback shown earlier in the lesson displays the notification.
+ }
+
private void startGame() {
E. Connect the click listener
After the listener of startButton add the new listener:
binding.startButton.setOnClickListener(view -> startGame());
+ binding.localNotifButton.setOnClickListener(
+ view -> requestPermissionAndShowNotification()
+ );
binding.gameBoard.setOnGameFinishedListener(this::finishGame);
Requesting permission is asynchronous: the code opening the permission dialog finishes before the user chooses to allow or deny it. Calling Notifications.show(this) immediately after requesting permission in the button listener would therefore happen before the response arrives. After approval, display the notification from the notificationPermissionLauncher callback. If permission has already been granted, display it immediately.
On Android 13 and above the flow on the first click is:
LocalNotif ⟶ permission request ⟶ user confirmation ⟶ Notifications.show ⟶ Android notification
On subsequent clicks, permission has already been granted, so display is immediate. Android 12 and earlier do not have this permission dialog.
Where do you request permission in an application that does not have a LocalNotif button?
A real app does not need a button that displays a local notification. It still needs a point in the app’s flow where it explains why notifications are useful and launches notificationPermissionLauncher. The right place depends on the product:
- In the introduction screen, after a short explanation such as “We will notify you when a new message arrives” and an Enable notifications button;
- When the user first activates a feature that depends on notifications, for example reminders, deliveries or new messages;
- On the Settings screen of the application, when the user activates the receive notifications switch;
- After logging into the account, if only a logged in user can receive the company’s periodic notifications.
Avoid opening the system dialog immediately on first launch without context. First show a short explanation in the app, then launch the system request after the user makes a clear choice. This helps users understand what they will receive and why they might want to allow it.
The flow in the application that receives periodic notifications from company servers will be:
Opening the app for the first time
→ Explanation of the benefits of the notifications
→ The user chooses "Enable notifications"
→ POST_NOTIFICATIONS request and user confirmation
→ Registering the device in FCM and saving the subscription
→ In the future the server sends messages even when the screen is not open
→ Android displays them using the permission already granted
Permission is a decision of the device user, while registration to FCM is a technical connection to the messaging service. Both are needed: an FCM subscriber without permission can receive information without displaying a notification; permission without FCM registration allows you to display local notifications, but does not yet provide an address or topic through which messages can be sent. In chapter 6 we will add the FCM side.
5. Test this stage
- Do Build > Make Project and run the application.
- Click
LocalNotif. - On Android 13 and later, allow the notification permission request.
- Make sure that a notification named Collect Circles appears with the text Come collect circles with me!.
- Press the button again and check that the notification updates immediately.
If you denied permission, the app displays a Toast and cannot display a notification. Grant permission in the app’s settings on the device, then try again.
The phone has already requested notification permission and can display a local notification. In chapter 6 we will subscribe it to two FCM topics; the notifications that come from the server are also subject to the same permission.
Continue to guide 6: FCM notifications for other phones
Gemini tutor
Open Gem: CollectCircles Notifications Lab to get hints, diagnostic questions and explanations appropriate to the stage you are on.