diff --git a/ElectronClient/app/app.js b/ElectronClient/app/app.js index 8c547d824..fa8ec65a8 100644 --- a/ElectronClient/app/app.js +++ b/ElectronClient/app/app.js @@ -355,7 +355,15 @@ class Application extends BaseApplication { setInterval(() => { runAutoUpdateCheck() }, 2 * 60 * 60 * 1000); } - reg.scheduleSync(); + setTimeout(() => { + AlarmService.garbageCollect(); + }, 1000 * 60 * 60); + + reg.scheduleSync().then(() => { + // Wait for the first sync before updating the notifications, since synchronisation + // might change the notifications. + AlarmService.updateAllNotifications(); + }); } } diff --git a/ElectronClient/app/bridge.js b/ElectronClient/app/bridge.js index e1294a184..7e92131e4 100644 --- a/ElectronClient/app/bridge.js +++ b/ElectronClient/app/bridge.js @@ -84,7 +84,7 @@ class Bridge { this.autoUpdateLogger_ = new Logger(); this.autoUpdateLogger_.addTarget('file', { path: logFilePath }); this.autoUpdateLogger_.setLevel(Logger.LEVEL_DEBUG); - this.autoUpdateLogger_.info('checkForUpdatesAndNotify: Intializing...'); + this.autoUpdateLogger_.info('checkForUpdatesAndNotify: Initializing...'); this.autoUpdater_ = require("electron-updater").autoUpdater; this.autoUpdater_.logger = this.autoUpdateLogger_; } diff --git a/ReactNativeClient/lib/BaseApplication.js b/ReactNativeClient/lib/BaseApplication.js index 7d20f9e19..e8c34b714 100644 --- a/ReactNativeClient/lib/BaseApplication.js +++ b/ReactNativeClient/lib/BaseApplication.js @@ -348,7 +348,7 @@ class BaseApplication { this.dbLogger_.setLevel(initArgs.logLevel); if (Setting.value('env') === 'dev') { - this.dbLogger_.setLevel(Logger.LEVEL_DEBUG); + this.dbLogger_.setLevel(Logger.LEVEL_WARN); } this.logger_.info('Profile directory: ' + profileDir); diff --git a/ReactNativeClient/lib/base-model.js b/ReactNativeClient/lib/base-model.js index 5f4e1e3ec..1b184c961 100644 --- a/ReactNativeClient/lib/base-model.js +++ b/ReactNativeClient/lib/base-model.js @@ -331,14 +331,14 @@ class BaseModel { } static delete(id, options = null) { - options = this.modOptions(options); if (!id) throw new Error('Cannot delete object without an ID'); + options = this.modOptions(options); return this.db().exec('DELETE FROM ' + this.tableName() + ' WHERE id = ?', [id]); } static batchDelete(ids, options = null) { + if (!ids.length) return; options = this.modOptions(options); - if (!ids.length) throw new Error('Cannot delete object without an ID'); return this.db().exec('DELETE FROM ' + this.tableName() + ' WHERE id IN ("' + ids.join('","') + '")'); } diff --git a/ReactNativeClient/lib/models/Alarm.js b/ReactNativeClient/lib/models/Alarm.js index 558b1ec85..12a74f7b3 100644 --- a/ReactNativeClient/lib/models/Alarm.js +++ b/ReactNativeClient/lib/models/Alarm.js @@ -14,16 +14,14 @@ class Alarm extends BaseModel { return this.modelSelectOne('SELECT * FROM alarms WHERE note_id = ?', [noteId]); } - static async garbageCollect() { - // Delete alarms that have already been triggered - await this.db().exec('DELETE FROM alarms WHERE trigger_time <= ?', [Date.now()]); + static async deleteExpiredAlarms() { + return this.db().exec('DELETE FROM alarms WHERE trigger_time <= ?', [Date.now()]); + } - // Delete alarms that correspond to non-existent notes + static async alarmIdsWithoutNotes() { // https://stackoverflow.com/a/4967229/561309 - await this.db().exec('DELETE FROM alarms WHERE id IN (SELECT alarms.id FROM alarms LEFT JOIN notes ON alarms.note_id = notes.id WHERE notes.id IS NULL)'); - - // TODO: Check for duplicate alarms for a note - // const rows = await this.db().exec('SELECT count(*) as note_count, note_id from alarms group by note_id having note_count >= 2'); + const alarms = await this.db().selectAll('SELECT alarms.id FROM alarms LEFT JOIN notes ON alarms.note_id = notes.id WHERE notes.id IS NULL'); + return alarms.map((a) => { return a.id }); } } diff --git a/ReactNativeClient/lib/models/note.js b/ReactNativeClient/lib/models/note.js index ccaa89677..6df28eed8 100644 --- a/ReactNativeClient/lib/models/note.js +++ b/ReactNativeClient/lib/models/note.js @@ -422,7 +422,7 @@ class Note extends BaseItem { } static dueNotes() { - return this.modelSelectAll('SELECT id, title, body, todo_due FROM notes WHERE is_conflict = 0 AND is_todo = 1 AND todo_completed = 0 AND todo_due > ?', [time.unixMs()]); + return this.modelSelectAll('SELECT id, title, body, is_todo, todo_due, todo_completed, is_conflict FROM notes WHERE is_conflict = 0 AND is_todo = 1 AND todo_completed = 0 AND todo_due > ?', [time.unixMs()]); } static needAlarm(note) { diff --git a/ReactNativeClient/lib/registry.js b/ReactNativeClient/lib/registry.js index 46242d3c1..165385f9a 100644 --- a/ReactNativeClient/lib/registry.js +++ b/ReactNativeClient/lib/registry.js @@ -37,6 +37,11 @@ reg.syncTarget = (syncTargetId = null) => { reg.scheduleSync = async (delay = null) => { if (delay === null) delay = 1000 * 3; + let promiseResolve = null; + const promise = new Promise((resolve, reject) => { + promiseResolve = resolve; + }); + if (reg.scheduleSyncId_) { clearTimeout(reg.scheduleSyncId_); reg.scheduleSyncId_ = null; @@ -57,6 +62,7 @@ reg.scheduleSync = async (delay = null) => { if (!reg.syncTarget(syncTargetId).isAuthenticated()) { reg.logger().info('Synchroniser is missing credentials - manual sync required to authenticate.'); + promiseResolve(); return; } @@ -73,6 +79,7 @@ reg.scheduleSync = async (delay = null) => { if (error.code == 'alreadyStarted') { reg.logger().info(error.message); } else { + promiseResolve(); throw error; } } @@ -82,6 +89,8 @@ reg.scheduleSync = async (delay = null) => { } reg.setupRecurrentSync(); + + promiseResolve(); }; if (delay === 0) { @@ -89,6 +98,8 @@ reg.scheduleSync = async (delay = null) => { } else { reg.scheduleSyncId_ = setTimeout(timeoutCallback, delay); } + + return promise; } reg.setupRecurrentSync = () => { diff --git a/ReactNativeClient/lib/services/AlarmService.js b/ReactNativeClient/lib/services/AlarmService.js index 778aee11a..87d878983 100644 --- a/ReactNativeClient/lib/services/AlarmService.js +++ b/ReactNativeClient/lib/services/AlarmService.js @@ -20,13 +20,52 @@ class AlarmService { return this.logger_; } - static async updateNoteNotification(noteId, isDeleted = false) { - const note = await Note.load(noteId); + static async garbageCollect() { + this.logger().info('Garbage collecting alarms...'); + + // Delete alarms that have already been triggered + await Alarm.deleteExpiredAlarms(); + + // Delete alarms that correspond to non-existent notes + const alarmIds = await Alarm.alarmIdsWithoutNotes(); + for (let i = 0; i < alarmIds.length; i++) { + this.logger().info('Clearing notification for non-existing note. Alarm ' + alarmIds[i]); + await this.driver().clearNotification(alarmIds[i]); + } + await Alarm.batchDelete(alarmIds); + } + + // When passing a note, make sure it has all the required properties + // (better to pass a complete note or else just the ID) + static async updateNoteNotification(noteOrId, isDeleted = false) { + let note = null; + let noteId = null; + + if (typeof noteOrId === 'object') { + note = noteOrId; + noteId = note.id; + } else { + note = await Note.load(noteOrId); + noteId = note ? note.id : null; + } + if (!note && !isDeleted) return; + const driver = this.driver(); + let alarm = await Alarm.byNoteId(note.id); let clearAlarm = false; + const makeNotificationFromAlarm = (alarm) => { + return { + id: alarm.id, + date: new Date(note.todo_due), + title: note.title, + } + } + + console.info('NOTE', note, Note.needAlarm(note)); + if (isDeleted || !Note.needAlarm(note) || (alarm && alarm.trigger_time !== note.todo_due)) @@ -34,11 +73,24 @@ class AlarmService { clearAlarm = !!alarm; } - if (!clearAlarm && alarm) return; // Alarm already exists and set at the right time + if (!clearAlarm && alarm) { // Alarm already exists and set at the right time + + // For persistent notifications (those that stay active after the app has been closed, like on mobile), if we have + // an alarm object we can be sure that the notification has already been set, so there's nothing to do. + // For non-persistent notifications however we need to check that the notification has been set because, for example, + // if the app has just started the notifications need to be set again. so we do this below. + if (!driver.hasPersistentNotifications() && !driver.notificationIsSet(alarm.id)) { + const notification = makeNotificationFromAlarm(alarm); + this.logger().info('Scheduling (non-persistent) notification for note ' + note.id, notification); + driver.scheduleNotification(notification); + } + + return; + } if (clearAlarm) { this.logger().info('Clearing notification for note ' + noteId); - await this.driver().clearNotification(alarm.id); + await driver.clearNotification(alarm.id); await Alarm.delete(alarm.id); } @@ -52,20 +104,28 @@ class AlarmService { // Reload alarm to get its ID alarm = await Alarm.byNoteId(note.id); - const notification = { - id: alarm.id, - date: new Date(note.todo_due), - title: note.title, - }; + const notification = makeNotificationFromAlarm(alarm); if (note.body) notification.body = note.body; this.logger().info('Scheduling notification for note ' + note.id, notification); - await this.driver().scheduleNotification(notification); + await driver.scheduleNotification(notification); + } + + static async updateAllNotifications() { + this.logger().info('Updading all notifications...'); + + await this.garbageCollect(); + + const dueNotes = await Note.dueNotes(); + for (let i = 0; i < dueNotes.length; i++) { + await this.updateNoteNotification(dueNotes[i]); + } } // TODO: inner notifications (when app is active) // TODO: locale-dependent format + // TODO: status to view active notifications } diff --git a/ReactNativeClient/lib/services/AlarmServiceDriver.android.js b/ReactNativeClient/lib/services/AlarmServiceDriver.android.js index 056dd3f7d..3bcc752ac 100644 --- a/ReactNativeClient/lib/services/AlarmServiceDriver.android.js +++ b/ReactNativeClient/lib/services/AlarmServiceDriver.android.js @@ -2,6 +2,14 @@ const PushNotification = require('react-native-push-notification'); class AlarmServiceDriver { + hasPersistentNotifications() { + return true; + } + + notificationIsSet(alarmId) { + throw new Error('Available only for non-persistent alarms'); + } + async clearNotification(id) { PushNotification.cancelLocalNotifications({ id: id }); } diff --git a/ReactNativeClient/lib/services/AlarmServiceDriverNode.js b/ReactNativeClient/lib/services/AlarmServiceDriverNode.js index 9173558e7..d1c93721e 100644 --- a/ReactNativeClient/lib/services/AlarmServiceDriverNode.js +++ b/ReactNativeClient/lib/services/AlarmServiceDriverNode.js @@ -1,21 +1,32 @@ class AlarmServiceDriverNode { + constructor() { + this.notifications_ = {}; + } + + hasPersistentNotifications() { + return false; + } + + notificationIsSet(id) { + return id in this.notifications_; + } + async clearNotification(id) { - console.info('AlarmServiceDriverNode::clearNotification', id); + if (!this.notificationIsSet(id)) return; + clearTimeout(this.notifications_[id].timeoutId); + delete this.notifications_[id]; } async scheduleNotification(notification) { - console.info('AlarmServiceDriverNode::scheduleNotification', notification); + const now = Date.now(); + const interval = notification.date.getTime() - now; + if (interval < 0) return; - // const androidNotification = { - // id: notification.id, - // message: notification.title.substr(0, 100), // No idea what the limits are for title and body but set something reasonable anyway - // date: notification.date, - // }; - - // if ('body' in notification) androidNotification.body = notification.body.substr(0, 512); - - // PushNotification.localNotificationSchedule(androidNotification); + const timeoutId = setTimeout(() => { + console.info('NOTIFICATION: ', notification); + this.clearNotification(notification.id); + }, interval); } }