diff --git a/.changes/notification-chronometer.md b/.changes/notification-chronometer.md new file mode 100644 index 000000000..fb87e9c10 --- /dev/null +++ b/.changes/notification-chronometer.md @@ -0,0 +1,6 @@ +--- +"notification": minor:feat +"notification-js": minor:feat +--- + +Added chronometer support for Android notifications with `when`, `usesChronometer`, and `chronometerCountDown` options. This allows displaying an automatically updating elapsed time counter directly in the notification, useful for time-tracking apps, ongoing calls, or timers. diff --git a/packages/api-e2e/test/specs/notification.spec.ts b/packages/api-e2e/test/specs/notification.spec.ts index c67256c44..85e021e54 100644 --- a/packages/api-e2e/test/specs/notification.spec.ts +++ b/packages/api-e2e/test/specs/notification.spec.ts @@ -2,11 +2,14 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: MIT +import path from 'node:path' +import { spawnSync } from 'node:child_process' import { expect } from '@wdio/globals' import { tauri, tauriError, describePlugin, + eventually, itDesktop, itOn } from '../helpers/index.js' @@ -22,6 +25,71 @@ import { // Scheduling, action types, channels, the pending/active lists and the // listeners are only implemented on mobile. None of them need the permission, // so they are covered there on an app that has not been granted it. +// +// On Android, what the system actually posted (`when` and the chronometer +// flags) is read back from `dumpsys notification` over adb; Appium's +// `autoGrantPermissions` has granted POST_NOTIFICATIONS so they are posted. + +const appId = 'com.tauri.api' + +/** `adb` from the Android SDK (else `PATH`), on `E2E_ANDROID_DEVICE` if set. */ +function adb(args: string[]): string { + const sdk = process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT + const binary = sdk ? path.join(sdk, 'platform-tools', 'adb') : 'adb' + const device = process.env.E2E_ANDROID_DEVICE + ? ['-s', process.env.E2E_ANDROID_DEVICE] + : [] + const result = spawnSync(binary, [...device, ...args], { + encoding: 'utf8', + timeout: 20_000 + }) + if (result.status !== 0) { + throw new Error(`adb ${args.join(' ')} failed: ${result.stderr}`) + } + return result.stdout +} + +interface PostedNotification { + when: number + showChronometer: boolean + chronometerCountDown: boolean +} + +/** + * The notification with `id` the app has posted, as the system's notification + * service records it. Throws while it is not posted (for {@link eventually}). + */ +function postedNotification(id: number): PostedNotification { + const dump = adb(['shell', 'dumpsys', 'notification', '--noredact']) + const record = dump + .split(/(?=^\s*NotificationRecord\()/m) + // the record's key is `||||` + .find((block) => block.includes(`|${appId}|${id}|`)) + if (!record) { + throw new Error(`notification ${id} is not posted`) + } + const when = /\bwhen=(\d+)/.exec(record) + if (!when) { + throw new Error( + `no \`when\` in the record of notification ${id}:\n${record}` + ) + } + const flag = (key: string) => record.includes(`android.${key}=Boolean (true)`) + return { + when: Number(when[1]), + showChronometer: flag('showChronometer'), + chronometerCountDown: flag('chronometerCountDown') + } +} + +/** Cancels pending and removes posted notifications. */ +async function clearNotifications(): Promise { + await tauri(async (api) => { + await api.notification.cancelAll() + await api.notification.removeAllActive() + return null + }) +} describePlugin('notification', () => { itDesktop('permission is granted on desktop', async () => { @@ -106,6 +174,106 @@ describePlugin('notification', () => { }) }) + itOn('android', '`when` and `usesChronometer` are posted', async () => { + const when = Date.now() - 60_000 + try { + await tauri((api, when) => { + api.notification.sendNotification({ + id: 7101, + title: 'e2e chronometer', + when, + usesChronometer: true + }) + return null + }, when) + expect(await eventually(() => postedNotification(7101))).toEqual({ + when, + showChronometer: true, + chronometerCountDown: false + }) + } finally { + await clearNotifications() + } + }) + + itOn('android', '`chronometerCountDown` is posted', async () => { + const when = Date.now() + 10 * 60_000 + try { + await tauri((api, when) => { + api.notification.sendNotification({ + id: 7102, + title: 'e2e countdown', + when, + usesChronometer: true, + chronometerCountDown: true + }) + return null + }, when) + expect(await eventually(() => postedNotification(7102))).toEqual({ + when, + showChronometer: true, + chronometerCountDown: true + }) + } finally { + await clearNotifications() + } + }) + + // Scheduled notifications are built when scheduled and posted by an alarm, + // which used to overwrite `when` with the time it fired. Without the exact + // alarm permission the alarm is inexact, hence the generous timeout. + itOn('android', 'a scheduled notification keeps its `when`', async () => { + const when = Date.now() + 10 * 60_000 + try { + await tauri( + (api, when, at) => { + api.notification.sendNotification({ + id: 7103, + title: 'e2e scheduled countdown', + schedule: api.notification.Schedule.at(new Date(at), false, true), + when, + usesChronometer: true, + chronometerCountDown: true + }) + return null + }, + when, + Date.now() + 3000 + ) + expect( + await eventually(() => postedNotification(7103), { timeout: 90_000 }) + ).toEqual({ when, showChronometer: true, chronometerCountDown: true }) + } finally { + await clearNotifications() + } + }) + + itOn( + 'android', + 'a scheduled notification without `when` shows when it fired', + async () => { + const at = Date.now() + 3000 + try { + await tauri((api, at) => { + api.notification.sendNotification({ + id: 7104, + title: 'e2e scheduled', + schedule: api.notification.Schedule.at(new Date(at), false, true) + }) + return null + }, at) + const posted = await eventually(() => postedNotification(7104), { + timeout: 90_000 + }) + // The emulator's clock follows the host's, give or take a little. + expect(posted.when).toBeGreaterThan(at - 2000) + expect(posted.when).toBeLessThan(Date.now() + 2000) + } finally { + await clearNotifications() + } + } + ) + itOn('ios', 'channels are not implemented', async () => { for (const call of ['create', 'remove', 'list'] as const) { const error = await tauriError( diff --git a/plugins/notification/android/src/main/java/Notification.kt b/plugins/notification/android/src/main/java/Notification.kt index bf10f3dc4..b8dcde5e7 100644 --- a/plugins/notification/android/src/main/java/Notification.kt +++ b/plugins/notification/android/src/main/java/Notification.kt @@ -38,6 +38,9 @@ class Notification { var sourceJson: String? = null var visibility: Int? = null var number: Int? = null + var `when`: Long? = null + var usesChronometer: Boolean = false + var chronometerCountDown: Boolean = false fun getSound(context: Context, defaultSound: Int): String? { var soundPath: String? = null diff --git a/plugins/notification/android/src/main/java/TauriNotificationManager.kt b/plugins/notification/android/src/main/java/TauriNotificationManager.kt index a8912739b..a8de1d7bb 100644 --- a/plugins/notification/android/src/main/java/TauriNotificationManager.kt +++ b/plugins/notification/android/src/main/java/TauriNotificationManager.kt @@ -199,6 +199,16 @@ class TauriNotificationManager( } mBuilder.setVisibility(notification.visibility ?: NotificationCompat.VISIBILITY_PRIVATE) mBuilder.setOnlyAlertOnce(true) + + // Chronometer support + notification.`when`?.let { mBuilder.setWhen(it) } + if (notification.usesChronometer) { + mBuilder.setUsesChronometer(true) + } + if (notification.chronometerCountDown) { + mBuilder.setChronometerCountDown(true) + } + mBuilder.setSmallIcon(notification.getSmallIcon(context, getDefaultSmallIcon(context))) mBuilder.setLargeIcon(notification.getLargeIcon(context)) val iconColor = notification.getIconColor(config?.iconColor ?: "") @@ -320,6 +330,7 @@ class TauriNotificationManager( ) notificationIntent.putExtra(NOTIFICATION_INTENT_KEY, request.id) notificationIntent.putExtra(TimedNotificationPublisher.NOTIFICATION_KEY, notification) + notificationIntent.putExtra(TimedNotificationPublisher.CUSTOM_WHEN_KEY, request.`when` != null) var flags = PendingIntent.FLAG_CANCEL_CURRENT if (SDK_INT >= Build.VERSION_CODES.S) { flags = flags or PendingIntent.FLAG_MUTABLE @@ -481,7 +492,10 @@ class TimedNotificationPublisher : BroadcastReceiver() { } else { getParcelableExtraLegacy(intent, NOTIFICATION_KEY) } - notification?.`when` = System.currentTimeMillis() + // keep the user provided `when` (e.g. a chronometer target), otherwise show the time it fired + if (!intent.getBooleanExtra(CUSTOM_WHEN_KEY, false)) { + notification?.`when` = System.currentTimeMillis() + } val id = intent.getIntExtra(NOTIFICATION_INTENT_KEY, Int.MIN_VALUE) if (id == Int.MIN_VALUE) { Logger.error(Logger.tags("Notification"), "No valid id supplied", null) @@ -535,6 +549,7 @@ class TimedNotificationPublisher : BroadcastReceiver() { companion object { var NOTIFICATION_KEY = "NotificationPublisher.notification" var CRON_KEY = "NotificationPublisher.cron" + var CUSTOM_WHEN_KEY = "NotificationPublisher.customWhen" } } diff --git a/plugins/notification/guest-js/index.ts b/plugins/notification/guest-js/index.ts index 903451002..f87f65055 100644 --- a/plugins/notification/guest-js/index.ts +++ b/plugins/notification/guest-js/index.ts @@ -137,6 +137,29 @@ interface Options { * Sets the number of items this notification represents on Android. */ number?: number + /** + * Set the time that the event occurred. Notifications in the panel are sorted by this time. + * Represented as milliseconds since the epoch (e.g. `Date.now()`). + * + * **Android only.** + */ + when?: number + /** + * Show the {@link when} field as a stopwatch. Instead of presenting `when` as a timestamp, + * the notification will show an automatically updating display of the minutes and seconds + * since `when`. Useful when showing an elapsed time (like an ongoing phone call or timer). + * + * **Android only.** + */ + usesChronometer?: boolean + /** + * Sets the Chronometer to count down instead of counting up. + * This is only relevant if {@link usesChronometer} is `true`. + * If `when` is set to a time in the future, the chronometer will count down to zero then stop. + * + * **Android only.** + */ + chronometerCountDown?: boolean } /** diff --git a/plugins/notification/src/lib.rs b/plugins/notification/src/lib.rs index 70b22f0b3..c5d77a6aa 100644 --- a/plugins/notification/src/lib.rs +++ b/plugins/notification/src/lib.rs @@ -243,6 +243,36 @@ impl NotificationBuilder { self.on_close = Some(OnClose(Box::new(handler))); self } + + /// Set the time that the event occurred (milliseconds since epoch). + /// Notifications in the panel are sorted by this time. + /// When used with `uses_chronometer`, the notification shows elapsed time since this value. + /// + /// **Android only.** + pub fn when(mut self, when: i64) -> Self { + self.data.when = Some(when); + self + } + + /// Show the `when` field as a stopwatch. + /// Instead of presenting `when` as a timestamp, the notification will show an automatically + /// updating display of the minutes and seconds since `when`. + /// + /// **Android only.** + pub fn uses_chronometer(mut self) -> Self { + self.data.uses_chronometer = true; + self + } + + /// Sets the Chronometer to count down instead of counting up. + /// Only relevant if `uses_chronometer` is true. + /// If `when` is set to a time in the future, the chronometer will count down to zero then stop. + /// + /// **Android only.** + pub fn chronometer_count_down(mut self) -> Self { + self.data.chronometer_count_down = true; + self + } } /// Extensions to [`tauri::App`], [`tauri::AppHandle`], [`tauri::WebviewWindow`], [`tauri::Webview`] and [`tauri::Window`] to access the notification APIs. diff --git a/plugins/notification/src/models.rs b/plugins/notification/src/models.rs index c9bc323f5..ddee2ddd5 100644 --- a/plugins/notification/src/models.rs +++ b/plugins/notification/src/models.rs @@ -243,6 +243,19 @@ pub struct NotificationData { pub(crate) auto_cancel: bool, #[serde(default)] pub(crate) silent: bool, + /// Set the time that the event occurred (milliseconds since epoch). + /// Notifications in the panel are sorted by this time. + /// Android only. + pub(crate) when: Option, + /// Show the `when` field as a stopwatch (elapsed time). + /// Android only. + #[serde(default)] + pub(crate) uses_chronometer: bool, + /// Sets the Chronometer to count down instead of counting up. + /// Only relevant if `uses_chronometer` is true. + /// Android only. + #[serde(default)] + pub(crate) chronometer_count_down: bool, } fn default_id() -> i32 { @@ -272,6 +285,9 @@ impl Default for NotificationData { ongoing: false, auto_cancel: false, silent: false, + when: None, + uses_chronometer: false, + chronometer_count_down: false, } } }