feat(android): add optional alarm-style notifications and fix reminder cancellation

- Fix bug where native Android alarms weren't cancelled when reminders were removed
- Add cancelNativeReminderOnUnschedule$ effect to cancel alarms on unschedule/dismiss
- Add useAlarmStyleReminders setting (default: false) for regular vs alarm notifications
- Create separate notification channels for alarm-style and regular reminders
- Add IS_ANDROID_WEB_VIEW_TOKEN injection token for testability
- Pass useAlarmStyle parameter through JS bridge to native Kotlin layer
This commit is contained in:
Johannes Millan 2026-01-15 17:49:56 +01:00
parent 74d983a001
commit 566760bd4a
14 changed files with 242 additions and 25 deletions

View file

@ -21,6 +21,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
const val EXTRA_RELATED_ID = "related_id"
const val EXTRA_TITLE = "title"
const val EXTRA_REMINDER_TYPE = "reminder_type"
const val EXTRA_USE_ALARM_STYLE = "use_alarm_style"
const val SNOOZE_DURATION_MS = 10 * 60 * 1000L // 10 minutes
}
@ -33,6 +34,7 @@ class ReminderActionReceiver : BroadcastReceiver() {
val relatedId = intent.getStringExtra(EXTRA_RELATED_ID) ?: return
val title = intent.getStringExtra(EXTRA_TITLE) ?: "Reminder"
val reminderType = intent.getStringExtra(EXTRA_REMINDER_TYPE) ?: "TASK"
val useAlarmStyle = intent.getBooleanExtra(EXTRA_USE_ALARM_STYLE, false)
Log.d(TAG, "Snooze: notificationId=$notificationId, title=$title")
@ -50,7 +52,8 @@ class ReminderActionReceiver : BroadcastReceiver() {
relatedId,
title,
reminderType,
newTriggerTime
newTriggerTime,
useAlarmStyle
)
Log.d(TAG, "Rescheduled reminder for ${SNOOZE_DURATION_MS / 60000} minutes from now")

View file

@ -19,6 +19,7 @@ class ReminderAlarmReceiver : BroadcastReceiver() {
const val EXTRA_RELATED_ID = "related_id"
const val EXTRA_TITLE = "title"
const val EXTRA_REMINDER_TYPE = "reminder_type"
const val EXTRA_USE_ALARM_STYLE = "use_alarm_style"
}
override fun onReceive(context: Context, intent: Intent) {
@ -29,8 +30,9 @@ class ReminderAlarmReceiver : BroadcastReceiver() {
val relatedId = intent.getStringExtra(EXTRA_RELATED_ID) ?: return
val title = intent.getStringExtra(EXTRA_TITLE) ?: "Reminder"
val reminderType = intent.getStringExtra(EXTRA_REMINDER_TYPE) ?: "TASK"
val useAlarmStyle = intent.getBooleanExtra(EXTRA_USE_ALARM_STYLE, false)
Log.d(TAG, "Alarm triggered: id=$notificationId, title=$title")
Log.d(TAG, "Alarm triggered: id=$notificationId, title=$title, useAlarmStyle=$useAlarmStyle")
ReminderNotificationHelper.showNotification(
context,
@ -38,7 +40,8 @@ class ReminderAlarmReceiver : BroadcastReceiver() {
reminderId,
relatedId,
title,
reminderType
reminderType,
useAlarmStyle
)
}
}

View file

@ -24,31 +24,54 @@ import com.superproductivity.superproductivity.receiver.ReminderAlarmReceiver
*/
object ReminderNotificationHelper {
const val TAG = "ReminderNotifHelper"
const val CHANNEL_ID = "sp_reminders_channel"
const val CHANNEL_ID_ALARM = "sp_reminders_channel"
const val CHANNEL_ID_REGULAR = "sp_reminders_regular_channel"
fun createChannel(context: Context) {
fun createChannels(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = context.getSystemService(NotificationManager::class.java)
// Alarm-style channel (louder, more intrusive)
val alarmSound = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM)
?: RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
val audioAttributes = AudioAttributes.Builder()
val alarmAudioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ALARM)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
val channel = NotificationChannel(
CHANNEL_ID,
val alarmChannel = NotificationChannel(
CHANNEL_ID_ALARM,
"Reminders (Alarm)",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Alarm-style task reminders with louder sound"
setShowBadge(true)
enableVibration(true)
vibrationPattern = longArrayOf(0, 500, 200, 500, 200, 500)
setSound(alarmSound, alarmAudioAttributes)
}
notificationManager.createNotificationChannel(alarmChannel)
// Regular notification channel (standard notification sound)
val notificationSound = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION)
val notificationAudioAttributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
val regularChannel = NotificationChannel(
CHANNEL_ID_REGULAR,
"Reminders",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Task and note reminders"
setShowBadge(true)
enableVibration(true)
vibrationPattern = longArrayOf(0, 500, 200, 500, 200, 500)
setSound(alarmSound, audioAttributes)
setSound(notificationSound, notificationAudioAttributes)
}
val notificationManager = context.getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)
notificationManager.createNotificationChannel(regularChannel)
}
}
@ -59,9 +82,10 @@ object ReminderNotificationHelper {
relatedId: String,
title: String,
reminderType: String,
triggerAtMs: Long
triggerAtMs: Long,
useAlarmStyle: Boolean = false
) {
Log.d(TAG, "Scheduling reminder: id=$notificationId, title=$title")
Log.d(TAG, "Scheduling reminder: id=$notificationId, title=$title, useAlarmStyle=$useAlarmStyle")
val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
@ -72,6 +96,7 @@ object ReminderNotificationHelper {
putExtra(ReminderAlarmReceiver.EXTRA_RELATED_ID, relatedId)
putExtra(ReminderAlarmReceiver.EXTRA_TITLE, title)
putExtra(ReminderAlarmReceiver.EXTRA_REMINDER_TYPE, reminderType)
putExtra(ReminderAlarmReceiver.EXTRA_USE_ALARM_STYLE, useAlarmStyle)
}
val pendingIntent = PendingIntent.getBroadcast(
@ -109,9 +134,12 @@ object ReminderNotificationHelper {
reminderId: String,
relatedId: String,
title: String,
reminderType: String
reminderType: String,
useAlarmStyle: Boolean = false
) {
createChannel(context)
createChannels(context)
val channelId = if (useAlarmStyle) CHANNEL_ID_ALARM else CHANNEL_ID_REGULAR
// Tapping notification opens app
val contentIntent = Intent(context, CapacitorMainActivity::class.java).apply {
@ -130,13 +158,16 @@ object ReminderNotificationHelper {
putExtra(ReminderActionReceiver.EXTRA_RELATED_ID, relatedId)
putExtra(ReminderActionReceiver.EXTRA_TITLE, title)
putExtra(ReminderActionReceiver.EXTRA_REMINDER_TYPE, reminderType)
putExtra(ReminderActionReceiver.EXTRA_USE_ALARM_STYLE, useAlarmStyle)
}
val snoozePendingIntent = PendingIntent.getBroadcast(
context, notificationId * 10 + 1, snoozeIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
val category = if (useAlarmStyle) NotificationCompat.CATEGORY_ALARM else NotificationCompat.CATEGORY_REMINDER
val notification = NotificationCompat.Builder(context, channelId)
.setSmallIcon(R.drawable.ic_stat_sp)
.setContentTitle(title)
.setContentText(if (reminderType == "TASK") "Task reminder" else "Note reminder")
@ -144,7 +175,7 @@ object ReminderNotificationHelper {
.setAutoCancel(true)
.addAction(0, "Snooze 10m", snoozePendingIntent)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.setCategory(NotificationCompat.CATEGORY_ALARM)
.setCategory(category)
.build()
try {

View file

@ -194,7 +194,8 @@ class JavaScriptInterface(
relatedId: String,
title: String,
reminderType: String,
triggerAtMs: Long
triggerAtMs: Long,
useAlarmStyle: Boolean
) {
safeCall("Failed to schedule native reminder") {
ReminderNotificationHelper.scheduleReminder(
@ -204,7 +205,8 @@ class JavaScriptInterface(
relatedId,
title,
reminderType,
triggerAtMs
triggerAtMs,
useAlarmStyle
)
}
}

View file

@ -70,6 +70,7 @@ export interface AndroidInterface {
title: string,
reminderType: string,
triggerAtMs: number,
useAlarmStyle: boolean,
): void;
cancelNativeReminder?(notificationId: number): void;

View file

@ -1,7 +1,7 @@
import { inject, Injectable } from '@angular/core';
import { createEffect } from '@ngrx/effects';
import { switchMap, tap } from 'rxjs/operators';
import { timer } from 'rxjs';
import { combineLatest, timer } from 'rxjs';
import { LocalNotifications } from '@capacitor/local-notifications';
import { SnackService } from '../../../core/snack/snack.service';
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
@ -12,6 +12,7 @@ import { TaskService } from '../../tasks/task.service';
import { TaskAttachmentService } from '../../tasks/task-attachment/task-attachment.service';
import { Store } from '@ngrx/store';
import { selectAllTasksWithReminder } from '../../tasks/store/task.selectors';
import { selectReminderConfig } from '../../config/store/global-config.reducer';
// TODO send message to electron when current task changes here
@ -71,8 +72,13 @@ export class AndroidEffects {
createEffect(
() =>
timer(DELAY_SCHEDULE).pipe(
switchMap(() => this._store.select(selectAllTasksWithReminder)),
tap(async (tasksWithReminders) => {
switchMap(() =>
combineLatest([
this._store.select(selectAllTasksWithReminder),
this._store.select(selectReminderConfig),
]),
),
tap(async ([tasksWithReminders, reminderConfig]) => {
try {
const currentReminderIds = new Set(
(tasksWithReminders || []).map((t) => t.id),
@ -111,6 +117,8 @@ export class AndroidEffects {
}
await this._ensureExactAlarmAccess();
const useAlarmStyle = reminderConfig.useAlarmStyleReminders ?? false;
// Schedule each reminder using native Android AlarmManager
for (const task of tasksWithReminders) {
const id = generateNotificationId(task.id);
@ -124,6 +132,7 @@ export class AndroidEffects {
task.title,
'TASK',
scheduleAt,
useAlarmStyle,
);
}

View file

@ -175,6 +175,7 @@ export const DEFAULT_GLOBAL_CONFIG: GlobalConfigState = {
countdownDuration: minute * 10,
defaultTaskRemindOption: TaskReminderOptionId.AtStart, // The hard-coded default prior to this changeable setting
isFocusWindow: false,
useAlarmStyleReminders: false,
},
schedule: {
isWorkStartEndEnabled: true,

View file

@ -1,6 +1,7 @@
import { ConfigFormSection, ReminderConfig } from '../global-config.model';
import { TASK_REMINDER_OPTIONS } from '../../planner/dialog-schedule-task/task-reminder-options.const';
import { T } from '../../../t.const';
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
export const REMINDER_FORM_CFG: ConfigFormSection<ReminderConfig> = {
title: T.GCF.REMINDER.TITLE,
@ -47,5 +48,17 @@ export const REMINDER_FORM_CFG: ConfigFormSection<ReminderConfig> = {
label: T.GCF.REMINDER.IS_FOCUS_WINDOW,
},
},
...(IS_ANDROID_WEB_VIEW
? [
{
key: 'useAlarmStyleReminders' as const,
type: 'checkbox',
templateOptions: {
label: T.GCF.REMINDER.USE_ALARM_STYLE_REMINDERS,
description: T.GCF.REMINDER.USE_ALARM_STYLE_REMINDERS_DESCRIPTION,
},
},
]
: []),
],
};

View file

@ -180,6 +180,8 @@ export type ReminderConfig = Readonly<{
defaultTaskRemindOption?: TaskReminderOptionId;
disableReminders?: boolean;
isFocusWindow?: boolean;
// Android only: use alarm-style notifications (louder, more intrusive)
useAlarmStyleReminders?: boolean;
}>;
export type TrackingReminderConfigOld = Readonly<{

View file

@ -10,6 +10,7 @@ import { TaskSharedActions } from '../../../root-store/meta/task-shared.actions'
import { DEFAULT_TASK, Task } from '../task.model';
import { TestScheduler } from 'rxjs/testing';
import { T } from '../../../t.const';
import { IS_ANDROID_WEB_VIEW_TOKEN } from '../../../util/is-android-web-view';
describe('TaskReminderEffects', () => {
let actions$: Observable<Action>;
@ -47,6 +48,7 @@ describe('TaskReminderEffects', () => {
{ provide: TaskService, useValue: taskServiceSpy },
{ provide: Store, useValue: storeSpy },
{ provide: LocaleDatePipe, useValue: datePipeSpy },
{ provide: IS_ANDROID_WEB_VIEW_TOKEN, useValue: false },
],
});
@ -298,3 +300,111 @@ describe('TaskReminderEffects', () => {
// NOTE: Tests for removeTaskReminderTrigger1$ were removed because the effect no longer exists.
// The reminder removal is now handled differently in the task-shared scheduling meta-reducer.
});
// NOTE: Full Android integration tests for cancelNativeReminderOnUnschedule$ require
// androidInterface to be initialized (only happens in Android WebView).
// These tests verify the filter behavior with the injection token.
describe('TaskReminderEffects - cancelNativeReminderOnUnschedule$ filter', () => {
let actions$: Observable<Action>;
let effects: TaskReminderEffects;
describe('when IS_ANDROID_WEB_VIEW_TOKEN is true', () => {
beforeEach(() => {
const snackServiceSpy = jasmine.createSpyObj('SnackService', ['open']);
const taskServiceSpy = jasmine.createSpyObj('TaskService', ['getByIdOnce$']);
const storeSpy = jasmine.createSpyObj('Store', ['dispatch']);
const datePipeSpy = jasmine.createSpyObj('LocaleDatePipe', ['transform']);
TestBed.configureTestingModule({
providers: [
TaskReminderEffects,
provideMockActions(() => actions$),
{ provide: SnackService, useValue: snackServiceSpy },
{ provide: TaskService, useValue: taskServiceSpy },
{ provide: Store, useValue: storeSpy },
{ provide: LocaleDatePipe, useValue: datePipeSpy },
{ provide: IS_ANDROID_WEB_VIEW_TOKEN, useValue: true },
],
});
effects = TestBed.inject(TaskReminderEffects);
});
it('should pass through unscheduleTask action when on Android', (done) => {
const action = TaskSharedActions.unscheduleTask({ id: 'task-1' });
actions$ = of(action);
// Verify the effect emits (filter passes)
effects.cancelNativeReminderOnUnschedule$.subscribe({
next: () => {
// Action passed through the filter - this is the expected behavior
expect(true).toBe(true);
done();
},
error: () => {
// The tap may throw because androidInterface is undefined,
// but the filter still passed, which is what we're testing
expect(true).toBe(true);
done();
},
});
});
it('should pass through dismissReminderOnly action when on Android', (done) => {
const action = TaskSharedActions.dismissReminderOnly({ id: 'task-1' });
actions$ = of(action);
effects.cancelNativeReminderOnUnschedule$.subscribe({
next: () => {
expect(true).toBe(true);
done();
},
error: () => {
expect(true).toBe(true);
done();
},
});
});
});
describe('when IS_ANDROID_WEB_VIEW_TOKEN is false', () => {
beforeEach(() => {
const snackServiceSpy = jasmine.createSpyObj('SnackService', ['open']);
const taskServiceSpy = jasmine.createSpyObj('TaskService', ['getByIdOnce$']);
const storeSpy = jasmine.createSpyObj('Store', ['dispatch']);
const datePipeSpy = jasmine.createSpyObj('LocaleDatePipe', ['transform']);
TestBed.configureTestingModule({
providers: [
TaskReminderEffects,
provideMockActions(() => actions$),
{ provide: SnackService, useValue: snackServiceSpy },
{ provide: TaskService, useValue: taskServiceSpy },
{ provide: Store, useValue: storeSpy },
{ provide: LocaleDatePipe, useValue: datePipeSpy },
{ provide: IS_ANDROID_WEB_VIEW_TOKEN, useValue: false },
],
});
effects = TestBed.inject(TaskReminderEffects);
});
it('should filter out actions when not on Android', (done) => {
const action = TaskSharedActions.unscheduleTask({ id: 'task-1' });
actions$ = of(action);
let emitted = false;
effects.cancelNativeReminderOnUnschedule$.subscribe({
next: () => {
emitted = true;
},
complete: () => {
// Filter should block the action, so nothing should emit
expect(emitted).toBe(false);
done();
},
});
});
});
});

View file

@ -9,7 +9,10 @@ import { SnackService } from '../../../core/snack/snack.service';
import { TaskService } from '../task.service';
import { Store } from '@ngrx/store';
import { LocaleDatePipe } from 'src/app/ui/pipes/locale-date.pipe';
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
import {
IS_ANDROID_WEB_VIEW,
IS_ANDROID_WEB_VIEW_TOKEN,
} from '../../../util/is-android-web-view';
import { androidInterface } from '../../android/android-interface';
import { generateNotificationId } from '../../android/android-notification-id.util';
@ -20,6 +23,7 @@ export class TaskReminderEffects {
private _taskService = inject(TaskService);
private _store = inject(Store);
private _datePipe = inject(LocaleDatePipe);
private _isAndroidWebView = inject(IS_ANDROID_WEB_VIEW_TOKEN);
snack$ = createEffect(
() =>
@ -132,6 +136,25 @@ export class TaskReminderEffects {
{ dispatch: false },
);
// Cancel native Android reminders when reminder is removed or dismissed
// Uses injection token with filter for testability (unlike other Android effects)
cancelNativeReminderOnUnschedule$ = createEffect(
() =>
this._localActions$.pipe(
ofType(TaskSharedActions.unscheduleTask, TaskSharedActions.dismissReminderOnly),
filter(() => this._isAndroidWebView),
tap(({ id }) => {
try {
const notificationId = generateNotificationId(id);
androidInterface.cancelNativeReminder?.(notificationId);
} catch (e) {
console.error('Failed to cancel native reminder:', e);
}
}),
),
{ dispatch: false },
);
// Cancel native Android reminders when tasks are deleted
cancelNativeRemindersOnDelete$ =
IS_ANDROID_WEB_VIEW &&

View file

@ -1865,6 +1865,9 @@ const T = {
IS_COUNTDOWN_BANNER_ENABLED: 'GCF.REMINDER.IS_COUNTDOWN_BANNER_ENABLED',
IS_FOCUS_WINDOW: 'GCF.REMINDER.IS_FOCUS_WINDOW',
TITLE: 'GCF.REMINDER.TITLE',
USE_ALARM_STYLE_REMINDERS: 'GCF.REMINDER.USE_ALARM_STYLE_REMINDERS',
USE_ALARM_STYLE_REMINDERS_DESCRIPTION:
'GCF.REMINDER.USE_ALARM_STYLE_REMINDERS_DESCRIPTION',
},
SCHEDULE: {
HELP: 'GCF.SCHEDULE.HELP',

View file

@ -1,2 +1,16 @@
import { InjectionToken } from '@angular/core';
export const IS_ANDROID_WEB_VIEW = !!(window as any).SUPAndroid;
export const IS_F_DROID_APP = !!(window as any).SUPFDroid;
/**
* Injection token for IS_ANDROID_WEB_VIEW to enable testing.
* Use this in effects/services that need to be unit tested.
*/
export const IS_ANDROID_WEB_VIEW_TOKEN = new InjectionToken<boolean>(
'IS_ANDROID_WEB_VIEW',
{
providedIn: 'root',
factory: () => IS_ANDROID_WEB_VIEW,
},
);

View file

@ -1859,7 +1859,9 @@
"DISABLE_REMINDERS": "Disable all reminders",
"IS_COUNTDOWN_BANNER_ENABLED": "Show countdown banner before reminders are due",
"IS_FOCUS_WINDOW": "Focus application window when reminder is triggered",
"TITLE": "Reminders"
"TITLE": "Reminders",
"USE_ALARM_STYLE_REMINDERS": "Use alarm-style notifications (Android)",
"USE_ALARM_STYLE_REMINDERS_DESCRIPTION": "When enabled, reminders use louder alarm sounds and more intrusive notifications. Recommended for critical reminders."
},
"SCHEDULE": {
"HELP": "The schedule feature should provide you with a quick overview over how your planned tasks play out over time. You can find it in the left hand menu under <a href='#/schedule'>'Schedule'</a>. ",