diff --git a/apps/threshold/src-tauri/src/alarm/mod.rs b/apps/threshold/src-tauri/src/alarm/mod.rs index d62f1ad7..6aa2edec 100644 --- a/apps/threshold/src-tauri/src/alarm/mod.rs +++ b/apps/threshold/src-tauri/src/alarm/mod.rs @@ -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( &self, app: &AppHandle, 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?; diff --git a/apps/threshold/src-tauri/src/commands.rs b/apps/threshold/src-tauri/src/commands.rs index 4342f2f7..667b5cdf 100644 --- a/apps/threshold/src-tauri/src/commands.rs +++ b/apps/threshold/src-tauri/src/commands.rs @@ -121,15 +121,15 @@ pub async fn dismiss_alarm( /// - `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( app: AppHandle, 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()) } diff --git a/apps/threshold/src-tauri/src/lib.rs b/apps/threshold/src-tauri/src/lib.rs index 2128805d..bd40264f 100644 --- a/apps/threshold/src-tauri/src/lib.rs +++ b/apps/threshold/src-tauri/src/lib.rs @@ -394,7 +394,10 @@ pub fn run() { } if let Some(coord) = handle.try_state::() { - 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), } diff --git a/apps/threshold/src/screens/Ringing.test.tsx b/apps/threshold/src/screens/Ringing.test.tsx index 4b9014f8..ad9e055c 100644 --- a/apps/threshold/src/screens/Ringing.test.tsx +++ b/apps/threshold/src/screens/Ringing.test.tsx @@ -22,7 +22,7 @@ vi.mock('../services/AlarmManagerService', () => ({ isInitialized: vi.fn(() => true), loadAlarms: vi.fn(), stopRinging: vi.fn(), - snoozeAlarm: vi.fn(), + snoozeRinging: vi.fn(), }, })); @@ -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(); @@ -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(); diff --git a/apps/threshold/src/screens/Ringing.tsx b/apps/threshold/src/screens/Ringing.tsx index 4cc91755..d856acc6 100644 --- a/apps/threshold/src/screens/Ringing.tsx +++ b/apps/threshold/src/screens/Ringing.tsx @@ -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(); }; diff --git a/apps/threshold/src/services/AlarmManagerService.test.ts b/apps/threshold/src/services/AlarmManagerService.test.ts index e1397467..56a78807 100644 --- a/apps/threshold/src/services/AlarmManagerService.test.ts +++ b/apps/threshold/src/services/AlarmManagerService.test.ts @@ -320,15 +320,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) | null = null; @@ -386,6 +405,7 @@ describe('AlarmManagerService', () => { it('snoozes upcoming actions without stopping active ringing', async () => { const service = new AlarmManagerService(); let actionCallback: ((notification: any) => Promise) | null = null; + const nextTrigger = Date.now() + 10 * 60_000; (PlatformUtils.isMobile as any).mockReturnValue(true); (PlatformUtils.getPlatform as any).mockReturnValue('android'); @@ -393,11 +413,13 @@ describe('AlarmManagerService', () => { 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: { @@ -406,9 +428,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 () => { @@ -515,4 +541,102 @@ 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'); + }); + + it('falls back to stopping ringing when the alarm_trigger snooze action has no alarm ID', async () => { + const service = new AlarmManagerService(); + let actionCallback: ((notification: any) => Promise) | null = null; + + (PlatformUtils.isMobile as any).mockReturnValue(true); + (onAction as any).mockImplementation(async (cb: (notification: any) => Promise) => { + actionCallback = cb; + return undefined; + }); + + await service.init(); + expect(actionCallback).not.toBeNull(); + + (invoke as any).mockClear(); + // The JS-driven 'alarm_trigger' notification carries no alarm ID (unlike the + // native AlarmRingingService channel bridge, tested above). + await actionCallback!({ + actionId: 'snooze', + notification: { + actionTypeId: 'alarm_trigger', + }, + }); + + expect(AlarmService.snooze).not.toHaveBeenCalled(); + expect(invoke).toHaveBeenCalledWith('plugin:alarm-manager|stop_ringing'); + }); }); diff --git a/apps/threshold/src/services/AlarmManagerService.ts b/apps/threshold/src/services/AlarmManagerService.ts index 46fc67cf..6d6f6f8a 100644 --- a/apps/threshold/src/services/AlarmManagerService.ts +++ b/apps/threshold/src/services/AlarmManagerService.ts @@ -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'); @@ -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; @@ -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( '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(); @@ -169,10 +182,18 @@ 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) => { + console.log('[AlarmManager] Action: Snooze ringing', alarmId); + if (alarmId == null) { + // The JS-driven ringing notification action has no alarm ID to + // recalculate against yet — fall back to stopping ringing only, + // same as before this alarm ID plumbing existed. + await this.stopRinging(); + return; + } + 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); @@ -180,7 +201,7 @@ export class AlarmManagerService { }, 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); }, }); @@ -347,17 +368,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( diff --git a/apps/threshold/src/services/AlarmNotificationService.ts b/apps/threshold/src/services/AlarmNotificationService.ts index fcf486ed..2cc30ad9 100644 --- a/apps/threshold/src/services/AlarmNotificationService.ts +++ b/apps/threshold/src/services/AlarmNotificationService.ts @@ -19,7 +19,10 @@ const EVENT_NOTIFICATIONS_TOAST = 'notifications:toast'; type NotificationActionHandlers = { onDismissRinging: () => Promise; - onSnoozeRinging: () => Promise; + // The JS-driven 'alarm_trigger' notification action (unlike the native + // AlarmRingingService channel bridge) doesn't carry an alarm ID yet, so this + // must tolerate being called with none. + onSnoozeRinging: (alarmId?: number) => Promise; onDismissUpcoming: (alarmId: number) => Promise; onSnoozeUpcoming: (alarmId: number, snoozeMinutes: number) => Promise; }; @@ -250,6 +253,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 { diff --git a/apps/threshold/src/services/AlarmService.test.ts b/apps/threshold/src/services/AlarmService.test.ts index 567e3d7b..24e1cc2c 100644 --- a/apps/threshold/src/services/AlarmService.test.ts +++ b/apps/threshold/src/services/AlarmService.test.ts @@ -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 }); }); }); diff --git a/apps/threshold/src/services/AlarmService.ts b/apps/threshold/src/services/AlarmService.ts index 36574860..191d42f1 100644 --- a/apps/threshold/src/services/AlarmService.ts +++ b/apps/threshold/src/services/AlarmService.ts @@ -77,8 +77,8 @@ export class AlarmService { /** * Snooze ringing alarm */ - static async snooze(id: number, minutes: number): Promise { - await invoke('snooze_alarm', { id, minutes }); + static async snooze(id: number, snoozedUntil: number): Promise { + await invoke('snooze_alarm', { id, snoozedUntil }); } /** diff --git a/plugins/alarm-manager/android/src/main/java/com/plugin/alarmmanager/AlarmManagerPlugin.kt b/plugins/alarm-manager/android/src/main/java/com/plugin/alarmmanager/AlarmManagerPlugin.kt index e2323173..ea04476c 100644 --- a/plugins/alarm-manager/android/src/main/java/com/plugin/alarmmanager/AlarmManagerPlugin.kt +++ b/plugins/alarm-manager/android/src/main/java/com/plugin/alarmmanager/AlarmManagerPlugin.kt @@ -63,9 +63,15 @@ class AlarmEventHandlerArgs { lateinit var handler: Channel } +@InvokeArg +class SnoozeEventHandlerArgs { + lateinit var handler: Channel +} + @TauriPlugin class AlarmManagerPlugin(private val activity: android.app.Activity) : Plugin(activity) { private var alarmEventChannel: Channel? = null + private var snoozeEventChannel: Channel? = null @Volatile private var alarmPipelineReady: Boolean = false @@ -73,6 +79,7 @@ class AlarmManagerPlugin(private val activity: android.app.Activity) : Plugin(ac private const val TAG = "AlarmManagerPlugin" private const val CALLBACK_PREFS = "AlarmManagerCallbacks" private const val KEY_PENDING_ALARM_EVENTS = "pending_alarm_events" + private const val KEY_PENDING_SNOOZE_EVENTS = "pending_snooze_events" @Volatile var instance: AlarmManagerPlugin? = null @@ -92,6 +99,20 @@ class AlarmManagerPlugin(private val activity: android.app.Activity) : Plugin(ac Log.i(TAG, "Queued native alarm fired event (plugin/channel not ready): id=$alarmId") } + @Synchronized + fun notifySnoozeRequested(context: Context, alarmId: Int) { + if (alarmId <= 0) return + + val plugin = instance + if (plugin != null && plugin.dispatchSnoozeRequestedEvent(alarmId)) { + Log.d(TAG, "Dispatched snooze requested immediately: id=$alarmId") + return + } + + queueSnoozeEvent(context, alarmId) + Log.i(TAG, "Queued snooze requested event (plugin/channel not ready): id=$alarmId") + } + @Synchronized private fun queueAlarmEvent(context: Context, alarmId: Int, actualFiredAt: Long) { val prefs = context.getSharedPreferences(CALLBACK_PREFS, Context.MODE_PRIVATE) @@ -102,6 +123,16 @@ class AlarmManagerPlugin(private val activity: android.app.Activity) : Plugin(ac }) prefs.edit().putString(KEY_PENDING_ALARM_EVENTS, queue.toString()).apply() } + + @Synchronized + private fun queueSnoozeEvent(context: Context, alarmId: Int) { + val prefs = context.getSharedPreferences(CALLBACK_PREFS, Context.MODE_PRIVATE) + val queue = JSONArray(prefs.getString(KEY_PENDING_SNOOZE_EVENTS, "[]")) + queue.put(JSONObject().apply { + put("id", alarmId) + }) + prefs.edit().putString(KEY_PENDING_SNOOZE_EVENTS, queue.toString()).apply() + } } override fun load(webview: WebView) { @@ -109,6 +140,7 @@ class AlarmManagerPlugin(private val activity: android.app.Activity) : Plugin(ac instance = this Log.d(TAG, "Plugin loaded.") drainPendingAlarmEvents() + drainPendingSnoozeEvents() } @Command @@ -166,11 +198,20 @@ class AlarmManagerPlugin(private val activity: android.app.Activity) : Plugin(ac invoke.resolve() } + @Command + fun setSnoozeEventHandler(invoke: Invoke) { + val args = invoke.parseArgs(SnoozeEventHandlerArgs::class.java) + snoozeEventChannel = args.handler + Log.d(TAG, "Snooze event handler channel registered") + invoke.resolve() + } + @Command fun markAlarmPipelineReady(invoke: Invoke) { alarmPipelineReady = true Log.d(TAG, "Alarm pipeline marked ready") drainPendingAlarmEvents() + drainPendingSnoozeEvents() invoke.resolve() } @@ -258,6 +299,21 @@ class AlarmManagerPlugin(private val activity: android.app.Activity) : Plugin(ac invoke.resolve(ret) } + private fun dispatchSnoozeRequestedEvent(alarmId: Int): Boolean { + if (!alarmPipelineReady) return false + val channel = snoozeEventChannel ?: return false + return try { + val event = JSObject().apply { + put("id", alarmId) + } + channel.send(event) + true + } catch (e: Exception) { + Log.w(TAG, "Failed to dispatch snooze requested event", e) + false + } + } + private fun dispatchAlarmFiredEvent(alarmId: Int, actualFiredAt: Long): Boolean { if (!alarmPipelineReady) return false val channel = alarmEventChannel ?: return false @@ -305,4 +361,34 @@ class AlarmManagerPlugin(private val activity: android.app.Activity) : Plugin(ac prefs.edit().putString(KEY_PENDING_ALARM_EVENTS, remaining.toString()).apply() Log.i(TAG, "Replayed ${queue.length() - remaining.length()} queued native alarm fired event(s)") } + + @Synchronized + private fun drainPendingSnoozeEvents() { + if (!alarmPipelineReady) return + val channel = snoozeEventChannel ?: return + val prefs = activity.getSharedPreferences(CALLBACK_PREFS, Context.MODE_PRIVATE) + val rawQueue = prefs.getString(KEY_PENDING_SNOOZE_EVENTS, "[]") ?: "[]" + val queue = JSONArray(rawQueue) + if (queue.length() == 0) return + + val remaining = JSONArray() + for (i in 0 until queue.length()) { + val item = queue.optJSONObject(i) ?: continue + val id = item.optInt("id", -1) + if (id <= 0) continue + + try { + val event = JSObject().apply { + put("id", id) + } + channel.send(event) + } catch (e: Exception) { + Log.w(TAG, "Failed to replay queued snooze requested event id=$id", e) + remaining.put(item) + } + } + + prefs.edit().putString(KEY_PENDING_SNOOZE_EVENTS, remaining.toString()).apply() + Log.i(TAG, "Replayed ${queue.length() - remaining.length()} queued snooze requested event(s)") + } } diff --git a/plugins/alarm-manager/android/src/main/java/com/plugin/alarmmanager/AlarmReceiver.kt b/plugins/alarm-manager/android/src/main/java/com/plugin/alarmmanager/AlarmReceiver.kt index a272d4be..a5e0b155 100644 --- a/plugins/alarm-manager/android/src/main/java/com/plugin/alarmmanager/AlarmReceiver.kt +++ b/plugins/alarm-manager/android/src/main/java/com/plugin/alarmmanager/AlarmReceiver.kt @@ -18,6 +18,16 @@ class AlarmReceiver : BroadcastReceiver() { val alarmId = intent.getIntExtra("ALARM_ID", -1) val soundUri = intent.getStringExtra("ALARM_SOUND_URI") Log.d("AlarmReceiver", "Alarm ID: $alarmId, Sound URI: $soundUri") + + // Guard: skip alarms that were cancelled or deleted before this broadcast was processed. + // cancelAlarm() removes the prefs entry atomically with the AlarmManager cancellation, so + // a missing entry means the alarm is definitively gone even if the broadcast was in-flight. + if (!AlarmUtils.isAlarmLive(context, alarmId)) { + Log.w("AlarmReceiver", "Alarm $alarmId no longer live — skipping fire (deleted/cancelled)") + Log.d("AlarmReceiver", "========== ALARM RECEIVER END (skipped) ==========") + return + } + AlarmManagerPlugin.notifyAlarmFired(context, alarmId) // Start the foreground service for sound/notification @@ -36,7 +46,7 @@ class AlarmReceiver : BroadcastReceiver() { context.startService(serviceIntent) Log.d("AlarmReceiver", "Started service (API < 26)") } - + Log.d("AlarmReceiver", "Service started. Notification will launch app via full-screen intent.") Log.d("AlarmReceiver", "========== ALARM RECEIVER END ==========") } diff --git a/plugins/alarm-manager/android/src/main/java/com/plugin/alarmmanager/AlarmRingingService.kt b/plugins/alarm-manager/android/src/main/java/com/plugin/alarmmanager/AlarmRingingService.kt index 687595ab..7e77c734 100644 --- a/plugins/alarm-manager/android/src/main/java/com/plugin/alarmmanager/AlarmRingingService.kt +++ b/plugins/alarm-manager/android/src/main/java/com/plugin/alarmmanager/AlarmRingingService.kt @@ -1,3 +1,8 @@ +// Foreground service that plays audio and shows the ringing alarm notification +// +// (c) Copyright 2026 Liminal HQ, Scott Morris +// SPDX-License-Identifier: Apache-2.0 OR MIT + package com.plugin.alarmmanager import android.app.NotificationChannel @@ -29,6 +34,7 @@ class AlarmRingingService : Service() { companion object { const val CHANNEL_ID = "alarm_ringing_service" const val ACTION_DISMISS = "com.threshold.ACTION_DISMISS" + const val ACTION_SNOOZE = "com.threshold.ACTION_SNOOZE" const val NOTIFICATION_ID = 999 private const val TAG = "AlarmRingingService" } @@ -58,6 +64,14 @@ class AlarmRingingService : Service() { return START_NOT_STICKY } + if (intent.action == ACTION_SNOOZE) { + val alarmId = intent.getIntExtra("ALARM_ID", -1) + Log.d(TAG, "Snooze action received for alarm $alarmId") + AlarmManagerPlugin.notifySnoozeRequested(applicationContext, alarmId) + stopSelf() + return START_NOT_STICKY + } + val soundUriStr = intent.getStringExtra("ALARM_SOUND_URI") currentAlarmId = intent.getIntExtra("ALARM_ID", -1) @@ -98,15 +112,26 @@ class AlarmRingingService : Service() { notificationManager.createNotificationChannel(channel) } - // Dismiss Action + // Dismiss Action — carries alarm ID so the handler knows which alarm to dismiss val dismissIntent = Intent(this, AlarmRingingService::class.java).apply { action = ACTION_DISMISS + putExtra("ALARM_ID", currentAlarmId) } val dismissPendingIntent = PendingIntent.getService( this, 0, dismissIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) + // Snooze Action — routes through the plugin bridge so the TS layer computes the new trigger + val snoozeIntent = Intent(this, AlarmRingingService::class.java).apply { + action = ACTION_SNOOZE + putExtra("ALARM_ID", currentAlarmId) + } + val snoozePendingIntent = PendingIntent.getService( + this, 1, snoozeIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + // Launch App Intent with Deep Link val deepLinkUri = "threshold://ringing/$currentAlarmId" val launchIntent = Intent(Intent.ACTION_VIEW, Uri.parse(deepLinkUri)).apply { @@ -131,6 +156,7 @@ class AlarmRingingService : Service() { .setOngoing(true) .setContentIntent(contentPendingIntent) .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Dismiss", dismissPendingIntent) + .addAction(android.R.drawable.ic_popup_reminder, "Snooze", snoozePendingIntent) .build() startForeground(NOTIFICATION_ID, notification) @@ -155,7 +181,7 @@ class AlarmRingingService : Service() { // Fallback to notification sound if alarm sound is not available uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION) } - + if (uri == null) { Log.e(TAG, "No sound URI available to play") return diff --git a/plugins/alarm-manager/android/src/main/java/com/plugin/alarmmanager/AlarmUtils.kt b/plugins/alarm-manager/android/src/main/java/com/plugin/alarmmanager/AlarmUtils.kt index bfe16f14..90aaf530 100644 --- a/plugins/alarm-manager/android/src/main/java/com/plugin/alarmmanager/AlarmUtils.kt +++ b/plugins/alarm-manager/android/src/main/java/com/plugin/alarmmanager/AlarmUtils.kt @@ -1,3 +1,8 @@ +// Native alarm scheduling, cancellation, and SharedPreferences-backed live-alarm state +// +// (c) Copyright 2026 Liminal HQ, Scott Morris +// SPDX-License-Identifier: Apache-2.0 OR MIT + package com.plugin.alarmmanager import android.app.AlarmManager @@ -91,6 +96,11 @@ object AlarmUtils { } } + fun isAlarmLive(context: Context, id: Int): Boolean { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + return prefs.contains("alarm_$id") + } + fun loadAllFromPrefs(context: Context): List> { val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val all = prefs.all diff --git a/plugins/alarm-manager/src/mobile.rs b/plugins/alarm-manager/src/mobile.rs index 371b135c..ae6c692a 100644 --- a/plugins/alarm-manager/src/mobile.rs +++ b/plugins/alarm-manager/src/mobile.rs @@ -20,6 +20,12 @@ struct AlarmEventHandler { handler: Channel, } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SnoozeEventHandler { + handler: Channel, +} + // Initialize the plugin pub fn init( app: &tauri::AppHandle, @@ -56,6 +62,32 @@ pub fn init( }, )?; + // Register a channel for snooze-from-notification events. The ringing service posts + // ACTION_SNOOZE which Kotlin forwards here; we re-emit as a Tauri event so the TS layer + // can compute the snoozed_until timestamp and invoke snooze_alarm. + let snooze_app_handle = app.clone(); + handle.run_mobile_plugin::<()>( + "setSnoozeEventHandler", + SnoozeEventHandler { + handler: Channel::new(move |event| { + let payload = match event { + InvokeResponseBody::Json(payload) => { + serde_json::from_str::(&payload).ok() + } + _ => None, + }; + + if let Some(payload) = payload { + log::info!("alarm-manager: snooze requested id={}", payload.id); + let _ = snooze_app_handle.emit("alarm-manager:snooze-requested", &payload); + } else { + log::warn!("alarm-manager: failed to parse snooze requested payload"); + } + Ok(()) + }), + }, + )?; + handle }; #[cfg(not(target_os = "android"))] diff --git a/plugins/alarm-manager/src/models.rs b/plugins/alarm-manager/src/models.rs index cbe76d0f..f7cfcf3f 100644 --- a/plugins/alarm-manager/src/models.rs +++ b/plugins/alarm-manager/src/models.rs @@ -57,3 +57,9 @@ pub struct NativeAlarmFiredPayload { pub id: i32, pub actual_fired_at: i64, } + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct NativeSnoozeRequestedPayload { + pub id: i32, +}