mirror of
https://github.com/tauri-apps/plugins-workspace.git
synced 2026-10-18 23:07:27 +02:00
feat(notification): add Android chronometer support (#3282)
* feat(notification): add Android chronometer support * e2e test --------- Co-authored-by: Lucas Nogueira <lucas@tauri.app>
This commit is contained in:
1 parent
aedd883cf3
commit
f36ba7ba48
7 files changed
+262
-1
No files matched your search
@@ -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.
|
||||
@@ -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 `<user>|<package>|<id>|<tag>|<uid>`
|
||||
.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<void> {
|
||||
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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -243,6 +243,36 @@ impl<R: Runtime> NotificationBuilder<R> {
|
||||
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.
|
||||
|
||||
@@ -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<i64>,
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user