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:
GaëtanandLucas Nogueira authored and GitHub committed 2026-10-16 16:07:08 -03:00
1 parent aedd883cf3
commit f36ba7ba48
7 files changed
+262 -1

No files matched your search

@@ -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(