diff --git a/src/app/features/android/android-notification-id.util.spec.ts b/src/app/features/android/android-notification-id.util.spec.ts new file mode 100644 index 000000000..2718c234f --- /dev/null +++ b/src/app/features/android/android-notification-id.util.spec.ts @@ -0,0 +1,79 @@ +import { generateNotificationId } from './android-notification-id.util'; + +describe('generateNotificationId', () => { + it('should generate the same ID for the same input', () => { + const reminderId = 'test-reminder-id-123'; + const id1 = generateNotificationId(reminderId); + const id2 = generateNotificationId(reminderId); + + expect(id1).toBe(id2); + }); + + it('should generate different IDs for different inputs', () => { + const id1 = generateNotificationId('reminder-abc-123'); + const id2 = generateNotificationId('reminder-xyz-456'); + + expect(id1).not.toBe(id2); + }); + + it('should always return a positive integer', () => { + const testIds = [ + 'short', + 'a-very-long-reminder-id-with-many-characters-123456789', + 'special-chars-!@#$%', + 'nanoid-V1StGXR8_Z5jdHi6B-myT', + ]; + + testIds.forEach((reminderId) => { + const id = generateNotificationId(reminderId); + expect(id).toBeGreaterThan(0); + expect(Number.isInteger(id)).toBe(true); + }); + }); + + it('should return ID within safe Android range', () => { + const testIds = [ + 'test-1', + 'test-2', + 'very-long-id-that-might-cause-overflow-123456789', + ]; + + testIds.forEach((reminderId) => { + const id = generateNotificationId(reminderId); + expect(id).toBeLessThan(2147483647); // Max 32-bit signed integer + expect(id).toBeGreaterThan(0); + }); + }); + + it('should throw error for invalid input', () => { + expect(() => generateNotificationId('')).toThrow(); + expect(() => generateNotificationId(null as any)).toThrow(); + expect(() => generateNotificationId(undefined as any)).toThrow(); + expect(() => generateNotificationId(123 as any)).toThrow(); + }); + + it('should handle typical nanoid format', () => { + // Typical nanoid format used in the app + const nanoidExamples = [ + 'V1StGXR8_Z5jdHi6B-myT', + 'xQY6fK9kL3mN5pR2sT7vW', + 'aB1cD2eF3gH4iJ5kL6mN7', + ]; + + nanoidExamples.forEach((reminderId) => { + const id = generateNotificationId(reminderId); + expect(id).toBeGreaterThan(0); + expect(Number.isInteger(id)).toBe(true); + }); + }); + + it('should be deterministic across multiple calls', () => { + const reminderId = 'consistent-test-id'; + const ids = Array.from({ length: 100 }, () => generateNotificationId(reminderId)); + const firstId = ids[0]; + + ids.forEach((id) => { + expect(id).toBe(firstId); + }); + }); +}); diff --git a/src/app/features/android/android-notification-id.util.ts b/src/app/features/android/android-notification-id.util.ts new file mode 100644 index 000000000..922aaaf07 --- /dev/null +++ b/src/app/features/android/android-notification-id.util.ts @@ -0,0 +1,24 @@ +/** + * Generates a deterministic numeric notification ID from a string reminder ID. + * This ensures the same reminder always gets the same notification ID, + * preventing duplicate notifications on Android. + * + * @param reminderId - The reminder's relatedId (task/note ID) + * @returns A positive integer suitable for Android notification ID + */ +export const generateNotificationId = (reminderId: string): number => { + if (!reminderId || typeof reminderId !== 'string') { + throw new Error('Invalid reminderId: must be a non-empty string'); + } + + // Simple hash function to convert string to positive integer + let hash = 0; + for (let i = 0; i < reminderId.length; i++) { + const char = reminderId.charCodeAt(i); + hash = (hash << 5) - hash + char; + hash = hash & hash; // Convert to 32-bit integer + } + + // Ensure positive integer and within safe range for Android + return Math.abs(hash) % 2147483647; +}; diff --git a/src/app/features/android/store/android.effects.ts b/src/app/features/android/store/android.effects.ts index 406cb55a9..7a519c9cc 100644 --- a/src/app/features/android/store/android.effects.ts +++ b/src/app/features/android/store/android.effects.ts @@ -9,6 +9,7 @@ import { SnackService } from '../../../core/snack/snack.service'; import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view'; import { LocalNotificationSchema } from '@capacitor/local-notifications/dist/esm/definitions'; import { DroidLog } from '../../../core/log'; +import { generateNotificationId } from '../android-notification-id.util'; // TODO send message to electron when current task changes here @@ -93,8 +94,8 @@ export class AndroidEffects { // Re-schedule the full set so the native alarm manager is always in sync. await LocalNotifications.schedule({ notifications: reminders.map((reminder) => { - // since the ids are temporary we can use just Math.random() - const id = Math.round(Math.random() * 10000000); + // Use deterministic ID based on reminder's relatedId to prevent duplicate notifications + const id = generateNotificationId(reminder.relatedId); const now = Date.now(); const scheduleAt = reminder.remindAt <= now ? now + 1000 : reminder.remindAt; // push overdue reminders into the immediate future