Files
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

194 lines
6.7 KiB
TypeScript

/**
* AlarmManagerService — OS-level user-facing alarm registry, modeled after
* Android's `AlarmManager.setAlarmClock()` / `getNextAlarmClock()` pair.
*
* Real Android flow
* -----------------
* 1. Any app that owns "user-facing alarms" (Clock, Calendar, Sleep, etc.)
* calls `alarmManager.setAlarmClock(AlarmClockInfo, pendingIntent)`.
* 2. The system tracks every registered alarm and merges them into a single
* chronological queue.
* 3. Consumers (lockscreen "next alarm" text, status bar alarm icon, MAML
* widget) call `alarmManager.getNextAlarmClock()` to read the soonest one
* — they never import or know about the source app.
*
* mobile-gym mirror
* -----------------
* - {@link AlarmManagerService.setAlarmClock} — publisher upserts a single
* alarm by stable id.
* - {@link AlarmManagerService.cancelAlarmClock} — publisher removes one.
* - {@link AlarmManagerService.setAlarmClocksFor} — bulk replace all alarms
* owned by one source (idiomatic when an app's full alarm list changes).
* - {@link AlarmManagerService.getNextAlarmClock} — direct mirror of Android.
* - {@link AlarmManagerService.getRegisteredAlarmClocks} — extension beyond
* real Android. Needed because the MAML widget renders ALL alarms as
* ambient array vars (`clock_message[]`/`clock_hour[]`/...). Real Android
* apps would expose enumeration via their own ContentProvider; we simplify
* by surfacing it at the registry. Sorted by trigger time ascending.
*
* State is volatile (no persistence). Publishers re-publish on app boot.
*/
import BroadcastBus from './BroadcastBus';
import { createVolatileOsStore } from './createOsStore';
export const ACTION_NEXT_ALARM_CLOCK_CHANGED = 'android.app.action.NEXT_ALARM_CLOCK_CHANGED';
export type AlarmRepeatMode =
| 'once' // fires a single time then is removed by the publisher
| 'daily' // every day at hour:minute
| 'weekday' // Mon-Fri
| 'workday' // alias for weekday in some locales
| 'holiday' // Sat-Sun
| 'custom'; // publisher-specific; daysOfWeekMask carries the encoding
export interface AlarmClockInfo {
/**
* Stable identifier for this alarm within the OWNER namespace. Combined
* with {@link ownerPackage} to make a globally unique key. Re-publishing
* an id replaces the previous entry.
*/
id: string;
/** Publisher app's package name (e.g. `com.android.deskclock`). */
ownerPackage: string;
/** Wall-clock display hour (0-23). */
hour: number;
/** Wall-clock display minute (0-59). */
minute: number;
/** User-visible label, may be empty. */
label: string;
/**
* Repeat semantics. The publisher is responsible for canceling a `'once'`
* alarm after it fires (mirrors how Android `setAlarmClock` works — it
* doesn't auto-repeat).
*/
repeat: AlarmRepeatMode;
/**
* 7-bit bitmask `Sun=1, Mon=2, Tue=4, ..., Sat=64`. For built-in repeat
* modes (daily=127, weekday=31, holiday=96) consumers can derive this
* from {@link repeat} alone; populated only when {@link repeat} is
* `'custom'`.
*/
daysOfWeekMask?: number;
/** Absolute trigger time in ms since epoch (next firing). */
triggerAtMs: number;
}
interface AlarmManagerState {
/** Keyed by `${ownerPackage}:${id}`. */
alarms: Record<string, AlarmClockInfo>;
}
const store = createVolatileOsStore<AlarmManagerState>(
'alarm_manager',
{ alarms: {} },
{ registerToServiceRegistry: true },
);
function compositeKey(ownerPackage: string, id: string): string {
return `${ownerPackage}:${id}`;
}
function emit(): void {
// Android's matching system broadcast — sent whenever the next alarm clock
// changes (added, removed, or rescheduled).
BroadcastBus.sendBroadcast({ action: ACTION_NEXT_ALARM_CLOCK_CHANGED });
}
export const AlarmManagerService = {
setAlarmClock(info: AlarmClockInfo): void {
const key = compositeKey(info.ownerPackage, info.id);
const prev = store.getState().alarms[key];
if (prev && alarmsEqual(prev, info)) return;
store.setState((state) => ({
alarms: { ...state.alarms, [key]: { ...info } },
}));
emit();
},
cancelAlarmClock(ownerPackage: string, id: string): void {
const key = compositeKey(ownerPackage, id);
if (!store.getState().alarms[key]) return;
store.setState((state) => {
const next = { ...state.alarms };
delete next[key];
return { alarms: next };
});
emit();
},
/**
* Bulk replace every alarm owned by {@link ownerPackage}. Convenient when
* the publisher has the full latest list and doesn't track per-id diffs.
* Cancels any previously-registered alarms from this owner that aren't in
* the new list.
*/
setAlarmClocksFor(ownerPackage: string, infos: AlarmClockInfo[]): void {
const wanted = new Set(infos.map((info) => compositeKey(info.ownerPackage, info.id)));
const current = store.getState().alarms;
let changed = false;
for (const key of Object.keys(current)) {
if (current[key].ownerPackage !== ownerPackage) continue;
if (wanted.has(key)) continue;
changed = true;
}
for (const info of infos) {
const key = compositeKey(info.ownerPackage, info.id);
const prev = current[key];
if (!prev || !alarmsEqual(prev, info)) {
changed = true;
break;
}
}
if (!changed) return;
store.setState((state) => {
const next: Record<string, AlarmClockInfo> = {};
for (const [key, value] of Object.entries(state.alarms)) {
if (value.ownerPackage !== ownerPackage) next[key] = value;
}
for (const info of infos) {
next[compositeKey(info.ownerPackage, info.id)] = { ...info };
}
return { alarms: next };
});
emit();
},
/**
* The single soonest-firing registered alarm, or `null` if none. Direct
* mirror of `android.app.AlarmManager#getNextAlarmClock()`.
*/
getNextAlarmClock(): AlarmClockInfo | null {
const all = Object.values(store.getState().alarms);
if (all.length === 0) return null;
return all.reduce((best, cur) => (cur.triggerAtMs < best.triggerAtMs ? cur : best));
},
/**
* All currently-registered alarms, sorted by trigger time ascending.
* Extension beyond stock Android — see file header for why.
*/
getRegisteredAlarmClocks(): AlarmClockInfo[] {
return Object.values(store.getState().alarms).sort(
(a, b) => a.triggerAtMs - b.triggerAtMs,
);
},
};
function alarmsEqual(a: AlarmClockInfo, b: AlarmClockInfo): boolean {
return (
a.id === b.id &&
a.ownerPackage === b.ownerPackage &&
a.hour === b.hour &&
a.minute === b.minute &&
a.label === b.label &&
a.repeat === b.repeat &&
a.daysOfWeekMask === b.daysOfWeekMask &&
a.triggerAtMs === b.triggerAtMs
);
}
export default AlarmManagerService;