Skip to content

Notification System

AugustoVSoares edited this page Oct 14, 2026 · 1 revision

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 design principle

The whole system rests on two responsibilities that are kept deliberately apart:

  • NotificationManager orchestrates. Permissions, the background worker, session refresh, the storage of when things last ran.
  • Notification decides what and when to show, via its trigger.

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.


1. The Notification base class

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 reference

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).

Two things to get right

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.


2. Shared value types

NotificationChannel

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',
);

NotificationImage

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.

NotificationGroup

Field Default Android iOS
groupKey required groupKey threadIdentifier
isSummary false setAsGroupSummary -

Leave group as null for a standalone notification.

NotificationIdStrategy

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);

3. Triggers

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

⚠️ ScheduledTrigger is 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.

PollTrigger

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.

ScheduledTrigger

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.

⚠️ Caveats before you use ScheduledTrigger

  • Android exact-alarm permission is not declared. SCHEDULE_EXACT_ALARM is absent from AndroidManifest.xml (only POST_NOTIFICATIONS is 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.local defaults to UTC. For one-shot schedules this is harmless since only the instant matters, but it must be revisited before repeating schedules land.

4. How notifications are distributed

Registration is static

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).

The call chain

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.

Platform differences

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.

Per-isolate plugin initialization

This is subtle enough to be worth stating outright. The manager keeps two separate guards:

  • _initialized - has initializeNotifications() 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.

The poll body

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:

  1. Load preferences and the saved session. Bail out entirely if there is no session. Notifications are always user-specific.
  2. Refresh the session before use, since it may have expired while the app was closed. See Authentication Flow.
  3. Initialise the plugin and open the storage.
  4. Loop every registered type, each wrapped in its own try/catch so one broken notification cannot starve the rest.

What _processNotification does, in order

  1. TTL expiry - if a ttl is set and has elapsed, cancel(id) and clear the record.
  2. 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.
  3. Branch on the trigger - PollTrigger throttles then checks then displays; ScheduledTrigger resolves and reschedules.

5. Throttle vs TTL

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.

The storage file

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.

Two subtleties worth knowing

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.


6. Adding a new notification

This example is a step by step of a possible ExamReminderNotification.

Step 1 - Create the file

packages/uni_app/lib/controller/background_workers/notifications/<your_type>.dart, extending Notification.

Step 2 - Implement the required members

uniqueID, prefsToggleKey, defaultEnabled, channel, idStrategy, trigger, and buildContent.

Step 3 - Pick a trigger and an unused id

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.

Step 4 - Register it in notificationMap

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.

Step 5 - Add the settings toggle, if user-facing

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.

Other things to keep in mind

  • Do not re-check the settings toggle inside shouldFire. TuitionNotification._shouldFire calls getTuitionNotificationToggle() even though the manager already gates on isEnabled() immediately before. Harmless, but redundant.
  • Do not rename an androidId after release. The channel is created once on the device; changing the id leaves the old channel orphaned in system settings.
  • Do not use ScheduledTrigger on Android until SCHEDULE_EXACT_ALARM and the boot receiver are in place.

7. Do and Don't

✅ Do

  • Register every new type in notificationMap.
  • Make buildContent dynamic and locale-aware.
  • Pick an unused FixedNotificationId, and keep track of which ids are taken.
  • Swallow network errors inside shouldFire and return false.
  • Use ttl for anything time-sensitive, so stale reminders disappear.
  • Set image via FileNotificationImage if you need it on both platforms.
  • Group related notifications with a shared groupKey.

❌ Don't

  • Add a type and forget the notificationMap entry, it will silently never run.
  • Assume instance fields survive between polls; a new instance is built every time.
  • Treat throttle as 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/catch in shouldFire and let network errors escape.

See also: Architecture Overview · Authentication Flow · State Management · Local Storage (Database) · Theme System

Clone this wiki locally