Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions apps/threshold/src-tauri/src/alarm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -237,17 +237,23 @@ impl AlarmCoordinator {
///
/// - `app`: app handle for event emission.
/// - `id`: alarm identifier.
/// - `minutes`: snooze duration in minutes.
/// - `snoozed_until`: absolute epoch-millisecond timestamp for the new trigger.
/// The TS layer is responsible for computing the anchor (now + N for ringing,
/// original_trigger + N for upcoming) and enforcing a minimum-in-future floor.
pub async fn snooze_alarm<R: Runtime>(
&self,
app: &AppHandle<R>,
id: i32,
minutes: i64,
snoozed_until: i64,
) -> Result<()> {
let alarm = self.db.get_by_id(id).await?;
let now = chrono::Utc::now().timestamp_millis();
if snoozed_until <= now {
return Err(Error::Validation(
"snoozed_until must be in the future".into(),
));
}
let alarm = self.db.get_by_id(id).await?;
let original_trigger = alarm.next_trigger.unwrap_or(now);
let snoozed_until = now + minutes * 60 * 1000;

let revision = self.db.next_revision().await?;
let updated = self.db.update_next_trigger(id, Some(snoozed_until), revision).await?;
Expand Down
6 changes: 3 additions & 3 deletions apps/threshold/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -121,15 +121,15 @@ pub async fn dismiss_alarm<R: Runtime>(
/// - `app`: app handle for command context.
/// - `coordinator`: alarm coordinator state.
/// - `id`: alarm identifier.
/// - `minutes`: snooze duration in minutes.
/// - `snoozed_until`: absolute epoch-millisecond timestamp for the new trigger.
pub async fn snooze_alarm<R: Runtime>(
app: AppHandle<R>,
coordinator: State<'_, AlarmCoordinator>,
id: i32,
minutes: i64,
snoozed_until: i64,
) -> Result<(), String> {
coordinator
.snooze_alarm(&app, id, minutes)
.snooze_alarm(&app, id, snoozed_until)
.await
.map_err(|e| e.to_string())
}
Expand Down
5 changes: 4 additions & 1 deletion apps/threshold/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,10 @@ pub fn run() {
}

if let Some(coord) = handle.try_state::<AlarmCoordinator>() {
match coord.snooze_alarm(&handle, cmd.alarm_id, cmd.snooze_length_minutes).await {
// Watch snooze is always now-anchored (ringing alarm)
let snoozed_until = chrono::Utc::now().timestamp_millis()
+ cmd.snooze_length_minutes * 60 * 1000;
match coord.snooze_alarm(&handle, cmd.alarm_id, snoozed_until).await {
Ok(_) => log::info!("watch: snoozed alarm {} for {} min", cmd.alarm_id, cmd.snooze_length_minutes),
Err(e) => log::error!("watch: failed to snooze alarm {}: {e}", cmd.alarm_id),
}
Expand Down
6 changes: 3 additions & 3 deletions apps/threshold/src/screens/Ringing.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ vi.mock('../services/AlarmManagerService', () => ({
isInitialized: vi.fn(() => true),
loadAlarms: vi.fn(),
stopRinging: vi.fn(),
snoozeAlarm: vi.fn(),
snoozeRinging: vi.fn(),
},
}));

Expand Down Expand Up @@ -237,7 +237,7 @@ describe('Ringing Screen Logic', () => {

// Assert
await waitFor(() => {
expect(alarmManagerService.snoozeAlarm).toHaveBeenCalledWith(1, 10);
expect(alarmManagerService.snoozeRinging).toHaveBeenCalledWith(1, 10);
expect(mockWindow.close).toHaveBeenCalled();
});
expect(appManagementService.minimizeApp).not.toHaveBeenCalled();
Expand All @@ -256,7 +256,7 @@ describe('Ringing Screen Logic', () => {

// Assert
await waitFor(() => {
expect(alarmManagerService.snoozeAlarm).toHaveBeenCalledWith(1, 10);
expect(alarmManagerService.snoozeRinging).toHaveBeenCalledWith(1, 10);
expect(appManagementService.minimizeApp).toHaveBeenCalled();
});
expect(mockWindow.close).not.toHaveBeenCalled();
Expand Down
2 changes: 1 addition & 1 deletion apps/threshold/src/screens/Ringing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,7 @@ const Ringing: React.FC = () => {

const handleSnooze = async () => {
console.log('Snoozing Alarm', alarmId, 'for', snoozeLength, 'minutes');
await alarmManagerService.snoozeAlarm(alarmId, snoozeLength);
await alarmManagerService.snoozeRinging(alarmId, snoozeLength);
await closeRingingWindow();
};

Expand Down
105 changes: 101 additions & 4 deletions apps/threshold/src/services/AlarmManagerService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,15 +311,34 @@ describe('AlarmManagerService', () => {
expect(scheduleCalls).toHaveLength(2);
});

it('snoozes alarms and stops the current ring by default', async () => {
it('snoozes ringing alarm with now-anchored timestamp and stops ringing', async () => {
const service = new AlarmManagerService();
const before = Date.now();

await service.snoozeAlarm(42, 10);
await service.snoozeRinging(42, 10);

expect(AlarmService.snooze).toHaveBeenCalledWith(42, 10);
const after = Date.now();
const [calledId, calledTimestamp] = (AlarmService.snooze as any).mock.calls[0];
expect(calledId).toBe(42);
expect(calledTimestamp).toBeGreaterThanOrEqual(before + 10 * 60_000);
expect(calledTimestamp).toBeLessThanOrEqual(after + 10 * 60_000);
expect(invoke).toHaveBeenCalledWith('plugin:alarm-manager|stop_ringing');
});

it('snoozes upcoming alarm with trigger-anchored timestamp without stopping ringing', async () => {
const service = new AlarmManagerService();
const nextTrigger = Date.now() + 5 * 60_000;
(AlarmService.get as any).mockResolvedValue({ id: 42, nextTrigger });

await service.snoozeUpcoming(42, 10);

const [calledId, calledTimestamp] = (AlarmService.snooze as any).mock.calls[0];
expect(calledId).toBe(42);
// Anchored to nextTrigger + 10 min, with a floor of now + 60s
expect(calledTimestamp).toBe(nextTrigger + 10 * 60_000);
expect(invoke).not.toHaveBeenCalledWith('plugin:alarm-manager|stop_ringing');
});

it('dismisses upcoming actions by mapping notification ID to alarm ID', async () => {
const service = new AlarmManagerService();
let actionCallback: ((notification: any) => Promise<void>) | null = null;
Expand Down Expand Up @@ -377,18 +396,21 @@ describe('AlarmManagerService', () => {
it('snoozes upcoming actions without stopping active ringing', async () => {
const service = new AlarmManagerService();
let actionCallback: ((notification: any) => Promise<void>) | null = null;
const nextTrigger = Date.now() + 10 * 60_000;

(PlatformUtils.isMobile as any).mockReturnValue(true);
(PlatformUtils.getPlatform as any).mockReturnValue('android');
(onAction as any).mockImplementation(async (cb: (notification: any) => Promise<void>) => {
actionCallback = cb;
return undefined;
});
(AlarmService.get as any).mockResolvedValue({ id: 11, nextTrigger });

await service.init();
expect(actionCallback).not.toBeNull();

(invoke as any).mockClear();
const before = Date.now();
await actionCallback!({
actionId: 'snooze_alarm',
notification: {
Expand All @@ -397,9 +419,13 @@ describe('AlarmManagerService', () => {
},
});

expect(AlarmService.snooze).toHaveBeenCalledWith(11, 10);
// Upcoming snooze anchors to nextTrigger + snoozeLength
const [calledId, calledTimestamp] = (AlarmService.snooze as any).mock.calls[0];
expect(calledId).toBe(11);
expect(calledTimestamp).toBe(nextTrigger + 10 * 60_000);
expect(invoke).not.toHaveBeenCalledWith('plugin:alarm-manager|stop_ringing');
expect(showToast).toHaveBeenCalled();
void before;
});

it('clears upcoming notification when alarm starts ringing', async () => {
Expand Down Expand Up @@ -506,4 +532,75 @@ describe('AlarmManagerService', () => {
}),
);
});

it('cancels native alarm and upcoming notification when alarm:cancelled fires', async () => {
const service = new AlarmManagerService();
(PlatformUtils.isMobile as any).mockReturnValue(true);

// Pre-populate the signature map as if the alarm had been scheduled
(service as any).scheduledSignatures.set(5, '12345|');

await service.init();

const cancelledHandlers = eventListeners.get('alarm:cancelled') ?? [];
expect(cancelledHandlers.length).toBe(1);

for (const handler of cancelledHandlers) {
await handler({ payload: { id: 5, reason: 'DELETED' } });
}

expect(invoke).toHaveBeenCalledWith('plugin:alarm-manager|cancel', { payload: { id: 5 } });
expect(cancel).toHaveBeenCalledWith([1_000_005]);
expect(removeActive).toHaveBeenCalledWith([{ id: 1_000_005 }]);
expect((service as any).scheduledSignatures.has(5)).toBe(false);
});

it('deleteAlarm only calls AlarmService.delete and relies on alarm:cancelled listener', async () => {
const service = new AlarmManagerService();
await service.deleteAlarm(99);

expect(AlarmService.delete).toHaveBeenCalledWith(99);
// cancelNativeAlarm is NOT called directly — handled by alarm:cancelled event
expect(invoke).not.toHaveBeenCalledWith('plugin:alarm-manager|cancel', expect.anything());
});

it('snoozeUpcoming floors snoozedUntil to now+60s when nextTrigger+N is in the past', async () => {
const service = new AlarmManagerService();
// Simulate: alarm originally at T-5m, snooze 3 minutes → T-2m = past
const nextTrigger = Date.now() - 5 * 60_000;
(AlarmService.get as any).mockResolvedValue({ id: 7, nextTrigger });

const before = Date.now();
await service.snoozeUpcoming(7, 3);
const after = Date.now();

const [, calledTimestamp] = (AlarmService.snooze as any).mock.calls[0];
// Floor: must be at least now + 60s
expect(calledTimestamp).toBeGreaterThanOrEqual(before + 60_000);
expect(calledTimestamp).toBeLessThanOrEqual(after + 60_000 + 100);
});

it('snooze-from-ringing-notification calls snoozeRinging with the alarm ID', async () => {
const service = new AlarmManagerService();
(PlatformUtils.isMobile as any).mockReturnValue(true);

localStorageState.set('threshold_snooze_length', '10');
await service.init();

// Simulate the alarm-manager:snooze-requested event from the Android bridge
const snoozeRequestHandlers = eventListeners.get('alarm-manager:snooze-requested') ?? [];
expect(snoozeRequestHandlers.length).toBe(1);

const before = Date.now();
for (const handler of snoozeRequestHandlers) {
await handler({ payload: { id: 15 } });
}
const after = Date.now();

const [calledId, calledTimestamp] = (AlarmService.snooze as any).mock.calls[0];
expect(calledId).toBe(15);
expect(calledTimestamp).toBeGreaterThanOrEqual(before + 10 * 60_000);
expect(calledTimestamp).toBeLessThanOrEqual(after + 10 * 60_000);
expect(invoke).toHaveBeenCalledWith('plugin:alarm-manager|stop_ringing');
});
});
57 changes: 40 additions & 17 deletions apps/threshold/src/services/AlarmManagerService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export class AlarmManagerService {
});
console.log('[AlarmManager] Event listener 1/4 registered.');

console.log('[AlarmManager] Setting up event listener 2/4: alarms:batch:updated...');
console.log('[AlarmManager] Setting up event listener 2/5: alarms:batch:updated...');
// Listen for batch events and refresh native schedule
await listen('alarms:batch:updated', async () => {
console.log('[AlarmManager] Received alarms:batch:updated event');
Expand All @@ -123,9 +123,22 @@ export class AlarmManagerService {
reason: 'alarm-batch-updated',
});
});
console.log('[AlarmManager] Event listener 2/4 registered.');
console.log('[AlarmManager] Event listener 2/5 registered.');

console.log('[AlarmManager] Setting up event listener 3/5: alarm:cancelled...');
// Eagerly cancel native alarm and upcoming notification when Rust emits alarm:cancelled.
// This fires before alarms:batch:updated and closes the race window where a cancelled
// alarm could still fire if the BroadcastReceiver was already dispatched.
await listen<{ id: number; reason: string }>('alarm:cancelled', async (event) => {
const { id } = event.payload;
console.log(`[AlarmManager] Received alarm:cancelled for id=${id}, reason=${event.payload.reason}`);
await this.cancelNativeAlarm(id);
await alarmNotificationService.cancelUpcomingNotification(id);
this.scheduledSignatures.delete(id);
});
console.log('[AlarmManager] Event listener 3/5 registered.');

console.log('[AlarmManager] Setting up event listener 3/4: settings-changed...');
console.log('[AlarmManager] Setting up event listener 4/5: settings-changed...');
await listen<{ key?: string; value?: unknown }>('settings-changed', async (event) => {
if (event.payload?.key !== 'is24h') return;
if (!PlatformUtils.isMobile()) return;
Expand All @@ -135,16 +148,16 @@ export class AlarmManagerService {
reason: 'settings-24h-changed',
});
});
console.log('[AlarmManager] Event listener 3/4 registered.');
console.log('[AlarmManager] Event listener 4/5 registered.');

console.log('[AlarmManager] Setting up event listener 4/4: notifications:upcoming:resync...');
console.log('[AlarmManager] Setting up event listener 5/5: notifications:upcoming:resync...');
await listen<NotificationUpcomingResyncEvent>(
'notifications:upcoming:resync',
async (event) => {
await this.resyncUpcomingNotifications(event.payload);
},
);
console.log('[AlarmManager] Event listener 4/4 registered.');
console.log('[AlarmManager] Event listener 5/5 registered.');

console.log('[AlarmManager] Checking for native imports...');
await this.checkImports();
Expand Down Expand Up @@ -173,18 +186,19 @@ export class AlarmManagerService {
console.log('[AlarmManager] Action: Dismiss');
await this.stopRinging();
},
onSnoozeRinging: async () => {
console.log('[AlarmManager] Action: Snooze');
// Keep existing behaviour until ringing notifications include alarm IDs.
await this.stopRinging();
onSnoozeRinging: async (alarmId: number) => {
console.log('[AlarmManager] Action: Snooze ringing', alarmId);
const snoozeLength = SettingsService.getSnoozeLength();
await this.snoozeRinging(alarmId, snoozeLength);
await this.emitUpcomingSnoozeToast(alarmId, snoozeLength);
},
onDismissUpcoming: async (alarmId) => {
console.log('[AlarmManager] Action: Dismiss upcoming alarm', alarmId);
await this.dismissNextOccurrence(alarmId);
},
onSnoozeUpcoming: async (alarmId, snoozeLength) => {
console.log('[AlarmManager] Action: Snooze upcoming alarm', alarmId);
await this.snoozeAlarm(alarmId, snoozeLength, false);
await this.snoozeUpcoming(alarmId, snoozeLength);
await this.emitUpcomingSnoozeToast(alarmId, snoozeLength);
},
});
Expand Down Expand Up @@ -376,17 +390,26 @@ export class AlarmManagerService {
}

async deleteAlarm(id: number) {
// Cancellation is handled by the alarm:cancelled listener registered in init().
await AlarmService.delete(id);
}

async snoozeRinging(id: number, minutes: number) {
console.log(`[AlarmManager] Snoozing ringing alarm ${id} for ${minutes} minutes`);
const snoozedUntil = Date.now() + minutes * 60_000;
await alarmNotificationService.cancelUpcomingNotification(id);
await AlarmService.snooze(id, snoozedUntil);
await this.stopRinging();
}

async snoozeAlarm(id: number, minutes: number, stopCurrentRinging: boolean = true) {
console.log(`[AlarmManager] Snoozing alarm ${id} for ${minutes} minutes`);
async snoozeUpcoming(id: number, minutes: number) {
console.log(`[AlarmManager] Snoozing upcoming alarm ${id} for ${minutes} minutes`);
const alarm = await AlarmService.get(id);
const anchor = alarm?.nextTrigger ?? Date.now();
// Floor ensures the new trigger is always in the future even if the alarm was slow to dismiss.
const snoozedUntil = Math.max(Date.now() + 60_000, anchor + minutes * 60_000);
await alarmNotificationService.cancelUpcomingNotification(id);
await AlarmService.snooze(id, minutes);
if (stopCurrentRinging) {
await this.stopRinging();
}
await AlarmService.snooze(id, snoozedUntil);
}

private async scheduleNativeAlarm(
Expand Down
10 changes: 9 additions & 1 deletion apps/threshold/src/services/AlarmNotificationService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const EVENT_NOTIFICATIONS_TOAST = 'notifications:toast';

type NotificationActionHandlers = {
onDismissRinging: () => Promise<void>;
onSnoozeRinging: () => Promise<void>;
onSnoozeRinging: (alarmId: number) => Promise<void>;
onDismissUpcoming: (alarmId: number) => Promise<void>;
onSnoozeUpcoming: (alarmId: number, snoozeMinutes: number) => Promise<void>;
};
Expand Down Expand Up @@ -250,6 +250,14 @@ export class AlarmNotificationService {

await handler(parsed.actionId, { id: parsed.notificationId });
});

// Listen for snooze-from-ringing-notification events bridged from AlarmRingingService.
// The Android service emits ACTION_SNOOZE which Kotlin routes through the plugin channel.
await listen<{ id: number }>('alarm-manager:snooze-requested', async (event) => {
const { id } = event.payload;
console.log(`[AlarmNotifications] Snooze requested from ringing notification for alarm ${id}`);
await handlers.onSnoozeRinging(id);
});
}

async cancelUpcomingNotification(alarmId: number): Promise<void> {
Expand Down
7 changes: 4 additions & 3 deletions apps/threshold/src/services/AlarmService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,13 @@ describe('AlarmService', () => {
});

describe('snooze', () => {
it('should invoke snooze_alarm with minutes', async () => {
it('should invoke snooze_alarm with snoozedUntil timestamp', async () => {
(invoke as any).mockResolvedValue(undefined);
const snoozedUntil = Date.now() + 10 * 60_000;

await AlarmService.snooze(1, 10);
await AlarmService.snooze(1, snoozedUntil);

expect(invoke).toHaveBeenCalledWith('snooze_alarm', { id: 1, minutes: 10 });
expect(invoke).toHaveBeenCalledWith('snooze_alarm', { id: 1, snoozedUntil });
});
});

Expand Down
Loading
Loading