Repository navigation
Notification System
The app pushes notifications to the user for things that need attention outside the app.
Most of the logic lives in a single file:
packages/uni_app/lib/controller/background_workers/notifications.dart
Each notification type then lives in its own file next to it, under
packages/uni_app/lib/controller/background_workers/notifications/.
The whole system rests on two responsibilities that are kept deliberately apart:
-
NotificationManagerorchestrates. Permissions, the background worker, session refresh, the storage of when things last ran. -
Notificationdecides what and when to show, via itstrigger.
The creator of a notification picks the trigger type. The manager just branches on it. This means adding a new notification never requires touching the manager.
Every notification type extends the abstract Notification class. The contract is intentionally declarative, most members being simple getters that describe what the notification is.
abstract class Notification {
String get uniqueID;
String get prefsToggleKey;
bool get defaultEnabled;
NotificationChannel get channel;
NotificationIdStrategy get idStrategy;
NotificationTrigger get trigger;
NotificationImage? get image => null;
NotificationGroup? get group => null;
Duration? get ttl => null;
Future<(String, String)> buildContent(Session session);
int get id => switch (idStrategy) {
FixedNotificationId(:final id) => id,
};
bool isEnabled(SharedPreferences prefs) =>
prefs.getBool(prefsToggleKey) ?? defaultEnabled;
NotificationDetails get details =>
channel.toDetails(image: image, group: group);
Future<void> display(
(String, String) content,
FlutterLocalNotificationsPlugin localNotificationsPlugin,
);
}| Member | Type | Purpose |
|---|---|---|
uniqueID |
String |
Stable key. Storage key, log messages, registry identity. Ex: 'tuition-notification'. |
prefsToggleKey |
String |
SharedPreferences key of the user-facing toggle. See section 6. |
defaultEnabled |
bool |
Value used when the key was never written. Tuition: true. |
channel |
NotificationChannel |
Android channel + iOS presentation settings, in one declaration. |
idStrategy |
NotificationIdStrategy |
How the platform notification id is chosen. |
trigger |
NotificationTrigger |
The decision of when to fire. See section 3. |
image |
NotificationImage? |
Optional attachment. Defaults to null. |
group |
NotificationGroup? |
Optional grouping. Defaults to null (no grouping). |
ttl |
Duration? |
Optional time-to-live after firing. Defaults to null. See section 5. |
buildContent() |
Future<(String, String)> |
Produces the (title, body). |
buildContent must be dynamic and locale-aware. A hardcoded string is wrong since the content depends on live session data (a countdown, a course name, a room). The current tuition strings are still hardcoded Portuguese, which is a known gap, not a pattern to copy.
// content derived from live data: correct ✅
@override
Future<(String, String)> buildContent(Session session) async {
final duration = _dueDate.difference(DateTime.now());
return (
'O prazo limite para as propinas está a acabar',
duration.toFormattedString(
'Falta {} para a data limite',
'Faltam {} para a data limite',
),
);
}
// static content, no locale awareness: wrong ❌
@override
Future<(String, String)> buildContent(Session session) async =>
const ('Tuition', 'Your tuition is due');Do not rely on instance state across polls. The manager constructs a new instance on every poll via the factory in notificationMap:
for (final value in notificationMap.values) {
final notification = value(); // fresh instance every single poll
...
}State set during shouldFire and read in buildContent is safe, because both are called within the same _processNotification invocation. Anything that needs to survive between polls has to go through NotificationTimeoutStorage.
One declaration covers both platforms, so you never write the Android and iOS variants separately.
| Field | Default | Effect |
|---|---|---|
androidId |
required | Android channel id. Changing it after release orphans existing channels. |
androidName |
required | Channel name shown in Android system settings. |
androidImportance |
Importance.high |
Controls heads-up display and sound. |
presentAlert |
true |
iOS: show the banner. |
presentBadge |
true |
iOS: update the app icon badge. |
presentSound |
true |
iOS: play a sound. |
interruptionLevel |
null |
iOS: timeSensitive, critical, etc. |
@override
NotificationChannel get channel => const NotificationChannel(
androidId: 'propinas-notificacao',
androidName: 'propinas-notificacao',
);A sealed class with three variants. This is a resource, a file path, or raw bytes. Passing a widget here will not compile.
| Variant | Android | iOS |
|---|---|---|
DrawableNotificationImage(name) |
DrawableResourceAndroidBitmap |
not supported |
FileNotificationImage(path) |
FilePathAndroidBitmap |
DarwinNotificationAttachment |
BytesNotificationImage(bytes) |
ByteArrayAndroidBitmap |
not supported |
If you need both platforms to show an image, use FileNotificationImage and write the file to disk first.
| Field | Default | Android | iOS |
|---|---|---|---|
groupKey |
required | groupKey |
threadIdentifier |
isSummary |
false |
setAsGroupSummary |
- |
Leave group as null for a standalone notification.
A sealed class, currently with one variant: FixedNotificationId(id).
This exists because the previous implementation hardcoded id: 2 for everything, which collides as soon as there is more than one notification type. A fixed id means re-showing replaces the previous notification in place rather than stacking a second one. Pick an id nothing else uses.
Currently used Ids:
- tuition is
2.
@override
NotificationIdStrategy get idStrategy => const FixedNotificationId(2);NotificationTrigger is a sealed class with two variants. This is the most important decision you make when adding a notification.
PollTrigger |
ScheduledTrigger |
|
|---|---|---|
| Question it answers | "Should this fire right now?" | "When exactly does this fire?" |
| Callback | shouldFire(session, now) |
nextFireTime(session) |
| Needs network + session | Yes | No |
| Rate limiting |
throttle, via storage |
None — fires once |
| Enforced by | Workmanager / Timer.periodic
|
OS AlarmManager / UNNotificationRequest
|
| Typical use | Condition-based alerts | Exact-time reminders |
⚠️ ScheduledTriggeris implemented but not yet used in production. No notification type in the app currently uses it, and it is not yet ready on Android. See the caveats below before relying on it.
Checked on every background poll. throttle limits how often the check runs, not how often the notification is displayed.
class PollTrigger extends NotificationTrigger {
const PollTrigger({required this.shouldFire, required this.throttle});
final Future<bool> Function(Session session, DateTime now) shouldFire;
/// Minimum time between two checks.
final Duration throttle;
}@override
NotificationTrigger get trigger =>
PollTrigger(throttle: const Duration(hours: 12), shouldFire: _shouldFire);
Future<bool> _shouldFire(Session session, DateTime now) async {
final feesFetcher = CurrentAccountFetcher();
DateTime? dueDate;
try {
dueDate = parseFeesNextLimit(await feesFetcher.getUserFeesResponse(session));
} catch (_) {
return false;
}
if (dueDate == null) return false;
_dueDate = dueDate;
return now.difference(_dueDate).inDays >= -3; // inside the 3-day window
}Swallow network errors inside shouldFire and return false. The manager already isolates failures per notification, but an early return keeps the throttle bookkeeping clean.
Fires at an exact DateTime, with no polling or condition check. The manager resolves the next fire time and hands it to the OS.
class ScheduledTrigger extends NotificationTrigger {
const ScheduledTrigger({
required this.nextFireTime,
this.allowWhileIdle = true,
});
/// Next time the notification should fire; `null` cancels the pending one.
final Future<DateTime?> Function(Session session) nextFireTime;
final bool allowWhileIdle;
}Returning null from nextFireTime cancels any pending notification for that id. allowWhileIdle maps to androidScheduleMode.exactAllowWhileIdle versus exact.
Why this exists at all: Workmanager periodic tasks are inexact so the platform may defer them by up to roughly 15 minutes, which for exact-time notifications is too long to wait. Exact-time reminders have to go through the OS scheduler.
-
Android exact-alarm permission is not declared.
SCHEDULE_EXACT_ALARMis absent fromAndroidManifest.xml(onlyPOST_NOTIFICATIONSis present). Add it before shipping a type that uses this trigger. - No boot receiver. Scheduled notifications do not survive a device reboot. A reschedule-on-boot handler still needs to be written.
- Reschedule points are incomplete. Login and the app-start paths need to call into the scheduling path for the pending notification to be re-registered.
-
Timezone setup is partial.
tz.localdefaults to UTC. For one-shot schedules this is harmless since only the instant matters, but it must be revisited before repeating schedules land.
Nothing is registered at runtime. The background worker spins up in a context where your code has no chance to run first, so every type must be declared up front:
// notifications.dart — top of file
Map<Type, Notification Function()> notificationMap = {
TuitionNotification: TuitionNotification.new,
};// background_callback.dart — the Workmanager entry point
const taskMap = {
'pt.up.fe.ni.uni.notificationworker': (
NotificationManager.updateAndTriggerNotifications,
true,
),
};A notification type missing from notificationMap will never fire, and nothing will report an error. This is the single most common mistake (see section 6).
Workmanager / Timer
└─► workerStartCallback() background_callback.dart
└─► taskMap[taskName] 'pt.up.fe.ni.uni.notificationworker'
└─► NotificationManager.updateAndTriggerNotifications()
updateAndTriggerNotifications is static and takes no arguments. That is a hard constraint, it is the Workmanager entry point signature and cannot change.
| Android | iOS / Web | |
|---|---|---|
| Mechanism | Workmanager().registerPeriodicTask |
Timer.periodic |
| Period | 1 hour | 1 hour |
| Isolate | Background isolate, runs with the app terminated | Main isolate |
| Constraint | Requires connected network | None |
On Android the worker runs in a fresh isolate when the app has been terminated. On iOS and web we cannot register plugins in a background isolate with the current Flutter version, so the task runs on the main isolate: once immediately after the worker is built, then every hour. See session_provider.dart, which delays initializeNotifications() by 20 seconds after login so the app has settled first.
This is subtle enough to be worth stating outright. The manager keeps two separate guards:
-
_initialized- hasinitializeNotifications()run? Guards the whole setup, once per app lifetime. -
_pluginInitialized- is the plugin itself ready? Guards_ensurePluginInitialized(), once per isolate.
They cannot be the same flag. updateAndTriggerNotifications may run in a Workmanager isolate where initializeNotifications() was never called, so the in-loop plugin init is the only initialisation that happens there.
static Future<void> updateAndTriggerNotifications() async {
PreferencesController.prefs = await SharedPreferences.getInstance();
final savedSession = await PreferencesController.getSavedSession();
if (savedSession == null) return;
final session = await savedSession.createRefreshRequest().perform();
await _ensurePluginInitialized();
final notificationStorage = await NotificationTimeoutStorage.create();
for (final value in notificationMap.values) {
final notification = value();
try {
await _processNotification(notification, session, notificationStorage);
} catch (err, st) {
Logger().e('Error while checking notification ${notification.uniqueID}',
error: err, stackTrace: st);
}
}
}Note the ordering and the isolation:
- Load preferences and the saved session. Bail out entirely if there is no session. Notifications are always user-specific.
- Refresh the session before use, since it may have expired while the app was closed. See Authentication Flow.
- Initialise the plugin and open the storage.
- Loop every registered type, each wrapped in its own try/catch so one broken notification cannot starve the rest.
-
TTL expiry - if a
ttlis set and has elapsed,cancel(id)and clear the record. -
Settings toggle - if the user disabled this type, return. For a
ScheduledTrigger, also cancel any pending notification, so disabling actually stops a future one from firing. -
Branch on the trigger -
PollTriggerthrottles then checks then displays;ScheduledTriggerresolves and reschedules.
Two different timers that sound similar and mean different things.
throttle |
ttl |
|
|---|---|---|
| Declared on |
PollTrigger only |
the base Notification
|
| Measures | Time between checks | Time a notification stays visible after firing |
| Enforced by | NotificationTimeoutStorage |
cancel(id) on a later poll |
| Default | required | null |
| Tuition value | 12 hours |
null (persists until dismissed) |
A throttle of 12 hours means "do not even check more often than every 12 hours". How often the user actually sees something depends entirely on what shouldFire returns.
Both live in the same file, at <applicationDocumentsDirectory>/notificationTimeout.json, managed by NotificationTimeoutStorage. It uses two different keys:
| Key | Written when | Read for |
|---|---|---|
<uniqueID> |
every PollTrigger check |
throttle |
<uniqueID>_firedAt |
only when ttl != null
|
TTL expiry |
It has to be on disk rather than in memory: Android Workmanager cold-starts in a fresh isolate, so in-memory state resets on every run.
ScheduledTrigger ignores the file entirely while the OS persists its scheduled requests natively.
A missing throttle key means "run now". getLastTimeNotificationExecuted returns the Unix epoch for an unknown id, so a brand new notification fires on its very first check.
The throttle timestamp is recorded even when the check fails. The write sits in a finally block, so an exception or a false from shouldFire still advances the clock. This is intended, since it stops a broken fetcher from hammering the API on every poll.
TTL expiry is only as timely as the poll. It is checked at the top of the next _processNotification call, so a cancelled notification can linger for up to one worker period. On Android that is up to an hour.
This example is a step by step of a possible ExamReminderNotification.
packages/uni_app/lib/controller/background_workers/notifications/<your_type>.dart, extending Notification.
uniqueID, prefsToggleKey, defaultEnabled, channel, idStrategy, trigger, and buildContent.
Choose PollTrigger for a condition ("is the deadline within 3 days?") and ScheduledTrigger for an exact time ("the exam starts at 10:00"). Use a FixedNotificationId that no other type uses.
Map<Type, Notification Function()> notificationMap = {
TuitionNotification: TuitionNotification.new,
ExamReminderNotification: ExamReminderNotification.new, // don't forget
};This step is mandatory. Skipping it produces a notification that compiles, analyses clean, and never fires.
Add a row to NotificationsDialog (lib/view/profile/widgets/notifications_dialog.dart) with a switch that writes prefsToggleKey via PreferencesController. Look at TuitionNotificationSwitch for the pattern. The manager reads the key through isEnabled() and skips the notification entirely when it is off.
-
Do not re-check the settings toggle inside
shouldFire.TuitionNotification._shouldFirecallsgetTuitionNotificationToggle()even though the manager already gates onisEnabled()immediately before. Harmless, but redundant. -
Do not rename an
androidIdafter release. The channel is created once on the device; changing the id leaves the old channel orphaned in system settings. -
Do not use
ScheduledTriggeron Android untilSCHEDULE_EXACT_ALARMand the boot receiver are in place.
- Register every new type in
notificationMap. - Make
buildContentdynamic and locale-aware. - Pick an unused
FixedNotificationId, and keep track of which ids are taken. - Swallow network errors inside
shouldFireand returnfalse. - Use
ttlfor anything time-sensitive, so stale reminders disappear. - Set
imageviaFileNotificationImageif you need it on both platforms. - Group related notifications with a shared
groupKey.
- Add a type and forget the
notificationMapentry, it will silently never run. - Assume instance fields survive between polls; a new instance is built every time.
- Treat
throttleas a display frequency. It limits checks. - Spell the tuition prefs key correctly.
- Write static or non-localized content in
buildContent. - Put a Flutter widget into
NotificationImage. - Rename an existing
androidId. - Skip the
try/catchinshouldFireand let network errors escape.
See also: Architecture Overview · Authentication Flow · State Management · Local Storage (Database) · Theme System
uni by NIAEFEUP
Need help? Open an Issue | Read Contributing Guidelines
Licensed under the GPL-3.0 License.