An ongoing timer notification for Android, driven from a Tauri app. The notification carries the system chronometer and pause / resume / stop buttons, and it keeps counting with no JavaScript running and the app in the background.
Written for freel, a freelance time tracker, and kept as a separate crate because nothing in it is specific to that app. Android only — on desktop every call is a no-op, so the host app does not need
cfgbranches.
A timer in a web view stops being a timer the moment Android reclaims the process. Three separate things had to be true for it to survive:
- The elapsed time is Android's to count. The plugin hands the notification a
baseMsinstant, andNotificationCompat.setUsesChronometercounts up from it. Nothing has to tick on the JS side — a backgrounded web view can be frozen and the number on screen stays right. - The process has to stay alive. The notification is held by a foreground service. Without it the process is reclaimed, and the notification's buttons go dead along with the broadcast receiver and the plugin instance behind them.
START_STICKYbrings the service back if the system reclaims it anyway. - The buttons have to reach the app. Taps land in a
BroadcastReceiver, which forwards them to the plugin, which relays them to the web layer as an event. The web layer can then act on a button pressed while the app was nowhere on screen.
use tauri_plugin_timer::TimerExt;
app.timer().show(ShowArgs {
title: "Landing page".into(),
base_ms: started_at_epoch_millis,
paused: false,
paused_label: "Paused".into(),
running_label: "Running".into(),
pause_action_label: "Pause".into(),
resume_action_label: "Resume".into(),
stop_action_label: "Stop".into(),
channel_name: "Task timer".into(),
})?;
app.timer().cancel()?;All labels are passed in rather than baked into the plugin: the host app owns its wording and its translations.
From the web layer:
import { addPluginListener, invoke } from '@tauri-apps/api/core';
await invoke('plugin:timer|show', {
args: { title, baseMs, paused, pausedLabel, runningLabel, /* … */ },
});
// Button taps arrive as an event, including ones pressed while the app
// was nowhere on screen.
const listener = await addPluginListener<{ action: string }>(
'timer',
'timer-action',
({ action }) => {
// 'pause' | 'resume' | 'stop'
},
);
await invoke('plugin:timer|cancel');
listener.unregister();baseMs is the instant the chronometer counts from, so banked time from earlier pauses is folded into it by the caller — the notification then shows total session time rather than time since the last resume.
The default permission set allows all four commands:
permissions = [
"allow-show",
"allow-cancel",
"allow-registerListener",
"allow-removeListener",
]Add timer:default to the app's capabilities, or pick the individual permissions.
Merged into the host app automatically:
| Permission | Why |
|---|---|
POST_NOTIFICATIONS |
Android 13+ requires it before anything is shown |
FOREGROUND_SERVICE |
The service that holds the notification |
FOREGROUND_SERVICE_SPECIAL_USE |
Declared subtype: an ongoing work timer that must keep counting in the background |
The service and the receiver are both exported="false" — nothing outside the app can start them.
[dependencies]
tauri-plugin-timer = "0.1"tauri::Builder::default()
.plugin(tauri_plugin_timer::init())src/lib.rs plugin entry, ShowArgs, desktop no-op
android/src/main/
java/com/freel/timer/
TimerPlugin.kt @TauriPlugin commands + action receiver
TimerService.kt foreground service holding the notification
AndroidManifest.xml permissions, service, receiver
res/drawable/ic_timer_small.xml status bar icon
permissions/ whitelist for Tauri's capability system
MIT