From 2d9c1638dcabb978c98e07587e02190b308caa1a Mon Sep 17 00:00:00 2001 From: Alex Baker Date: Sat, 22 Jan 2022 10:03:35 -0600 Subject: [PATCH] Add support for relative and repeating alarms --- .../com.todoroo.astrid.dao.Database/81.json | 1376 +++++++++++++++++ .../astrid/alarms/AlarmJobServiceTest.kt | 210 ++- .../astrid/reminders/ReminderServiceTest.kt | 238 +-- .../tasks/ui/editviewmodel/ReminderTests.kt | 39 +- .../java/org/tasks/makers/TaskMaker.kt | 2 +- .../com/todoroo/astrid/alarms/AlarmService.kt | 80 +- .../java/com/todoroo/astrid/dao/Database.kt | 3 +- .../java/com/todoroo/astrid/dao/TaskDao.kt | 6 +- .../main/java/com/todoroo/astrid/data/Task.kt | 46 +- .../astrid/reminders/ReminderService.kt | 94 +- .../astrid/repeats/RepeatTaskHelper.kt | 7 +- .../com/todoroo/astrid/service/TaskCreator.kt | 40 +- .../com/todoroo/astrid/service/Upgrader.kt | 21 +- .../todoroo/astrid/ui/ReminderControlSet.kt | 88 +- .../org/tasks/backup/TasksJsonImporter.kt | 33 +- app/src/main/java/org/tasks/data/Alarm.kt | 67 +- app/src/main/java/org/tasks/data/AlarmDao.kt | 23 +- app/src/main/java/org/tasks/db/Migrations.kt | 27 + .../main/java/org/tasks/jobs/AlarmEntry.java | 8 +- .../java/org/tasks/jobs/NotificationQueue.kt | 10 +- .../java/org/tasks/jobs/ReminderEntry.java | 3 +- .../java/org/tasks/reminders/AlarmToString.kt | 76 + .../main/java/org/tasks/time/DateTimeUtils.kt | 4 + .../java/org/tasks/ui/TaskEditViewModel.kt | 69 +- app/src/main/res/values/strings.xml | 4 + 25 files changed, 2085 insertions(+), 489 deletions(-) create mode 100644 app/schemas/com.todoroo.astrid.dao.Database/81.json create mode 100644 app/src/main/java/org/tasks/reminders/AlarmToString.kt diff --git a/app/schemas/com.todoroo.astrid.dao.Database/81.json b/app/schemas/com.todoroo.astrid.dao.Database/81.json new file mode 100644 index 000000000..523596852 --- /dev/null +++ b/app/schemas/com.todoroo.astrid.dao.Database/81.json @@ -0,0 +1,1376 @@ +{ + "formatVersion": 1, + "database": { + "version": 81, + "identityHash": "950d2a0b758fc58028bde3e388b9f32b", + "entities": [ + { + "tableName": "notification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task` INTEGER NOT NULL, `timestamp` INTEGER NOT NULL, `type` INTEGER NOT NULL, `location` INTEGER)", + "fields": [ + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "taskId", + "columnName": "task", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "uid" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_notification_task", + "unique": true, + "columnNames": [ + "task" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_notification_task` ON `${TABLE_NAME}` (`task`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "tagdata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `remoteId` TEXT, `name` TEXT, `color` INTEGER, `tagOrdering` TEXT, `td_icon` INTEGER, `td_order` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remoteId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tagOrdering", + "columnName": "tagOrdering", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "td_icon", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "td_order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "userActivity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `remoteId` TEXT, `message` TEXT, `picture` TEXT, `target_id` TEXT, `created_at` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remoteId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "picture", + "columnName": "picture", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "targetId", + "columnName": "target_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "created", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "task_attachments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `remoteId` TEXT, `task_id` TEXT, `name` TEXT, `path` TEXT, `content_type` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remoteId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "taskId", + "columnName": "task_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uri", + "columnName": "path", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "contentType", + "columnName": "content_type", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "task_list_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT, `remoteId` TEXT, `tag_uuid` TEXT, `filter` TEXT, `task_ids` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remoteId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagUuid", + "columnName": "tag_uuid", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "filter", + "columnName": "filter", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "taskIds", + "columnName": "task_ids", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT, `importance` INTEGER NOT NULL, `dueDate` INTEGER NOT NULL, `hideUntil` INTEGER NOT NULL, `created` INTEGER NOT NULL, `modified` INTEGER NOT NULL, `completed` INTEGER NOT NULL, `deleted` INTEGER NOT NULL, `notes` TEXT, `estimatedSeconds` INTEGER NOT NULL, `elapsedSeconds` INTEGER NOT NULL, `timerStart` INTEGER NOT NULL, `notificationFlags` INTEGER NOT NULL, `notifications` INTEGER NOT NULL, `lastNotified` INTEGER NOT NULL, `snoozeTime` INTEGER NOT NULL, `recurrence` TEXT, `repeatUntil` INTEGER NOT NULL, `calendarUri` TEXT, `remoteId` TEXT, `collapsed` INTEGER NOT NULL, `parent` INTEGER NOT NULL, `parent_uuid` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "priority", + "columnName": "importance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dueDate", + "columnName": "dueDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hideUntil", + "columnName": "hideUntil", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationDate", + "columnName": "created", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modificationDate", + "columnName": "modified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completionDate", + "columnName": "completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deletionDate", + "columnName": "deleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "estimatedSeconds", + "columnName": "estimatedSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "elapsedSeconds", + "columnName": "elapsedSeconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timerStart", + "columnName": "timerStart", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ringFlags", + "columnName": "notificationFlags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reminderPeriod", + "columnName": "notifications", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reminderLast", + "columnName": "lastNotified", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reminderSnooze", + "columnName": "snoozeTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recurrence", + "columnName": "recurrence", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "repeatUntil", + "columnName": "repeatUntil", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "calendarURI", + "columnName": "calendarUri", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "remoteId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isCollapsed", + "columnName": "collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parentUuid", + "columnName": "parent_uuid", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "t_rid", + "unique": true, + "columnNames": [ + "remoteId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `t_rid` ON `${TABLE_NAME}` (`remoteId`)" + }, + { + "name": "active_and_visible", + "unique": false, + "columnNames": [ + "completed", + "deleted", + "hideUntil" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `active_and_visible` ON `${TABLE_NAME}` (`completed`, `deleted`, `hideUntil`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "alarms", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task` INTEGER NOT NULL, `time` INTEGER NOT NULL, `type` INTEGER NOT NULL DEFAULT 0, `repeat` INTEGER NOT NULL DEFAULT 0, `interval` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "task", + "columnName": "task", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "time", + "columnName": "time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "repeat", + "columnName": "repeat", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "interval", + "columnName": "interval", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "places", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`place_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `uid` TEXT, `name` TEXT, `address` TEXT, `phone` TEXT, `url` TEXT, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `place_color` INTEGER NOT NULL, `place_icon` INTEGER NOT NULL, `place_order` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "place_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uid", + "columnName": "uid", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone", + "columnName": "phone", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "place_color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "icon", + "columnName": "place_icon", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "place_order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "place_id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "place_uid", + "unique": true, + "columnNames": [ + "uid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `place_uid` ON `${TABLE_NAME}` (`uid`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "geofences", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`geofence_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task` INTEGER NOT NULL, `place` TEXT, `radius` INTEGER NOT NULL, `arrival` INTEGER NOT NULL, `departure` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "geofence_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "task", + "columnName": "task", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "place", + "columnName": "place", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "radius", + "columnName": "radius", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isArrival", + "columnName": "arrival", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeparture", + "columnName": "departure", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "geofence_id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "geo_task", + "unique": false, + "columnNames": [ + "task" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `geo_task` ON `${TABLE_NAME}` (`task`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "tags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `task` INTEGER NOT NULL, `name` TEXT, `tag_uid` TEXT, `task_uid` TEXT)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "task", + "columnName": "task", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tagUid", + "columnName": "tag_uid", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "taskUid", + "columnName": "task_uid", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "tag_task", + "unique": false, + "columnNames": [ + "task" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `tag_task` ON `${TABLE_NAME}` (`task`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "google_tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gt_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `gt_task` INTEGER NOT NULL, `gt_remote_id` TEXT, `gt_list_id` TEXT, `gt_parent` INTEGER NOT NULL, `gt_remote_parent` TEXT, `gt_moved` INTEGER NOT NULL, `gt_order` INTEGER NOT NULL, `gt_remote_order` INTEGER NOT NULL, `gt_last_sync` INTEGER NOT NULL, `gt_deleted` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "gt_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "task", + "columnName": "gt_task", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteId", + "columnName": "gt_remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "listId", + "columnName": "gt_list_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "parent", + "columnName": "gt_parent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteParent", + "columnName": "gt_remote_parent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isMoved", + "columnName": "gt_moved", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "gt_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "remoteOrder", + "columnName": "gt_remote_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSync", + "columnName": "gt_last_sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "gt_deleted", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "gt_id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "gt_task", + "unique": false, + "columnNames": [ + "gt_task" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `gt_task` ON `${TABLE_NAME}` (`gt_task`)" + }, + { + "name": "gt_list_parent", + "unique": false, + "columnNames": [ + "gt_list_id", + "gt_parent" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `gt_list_parent` ON `${TABLE_NAME}` (`gt_list_id`, `gt_parent`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "filters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT, `sql` TEXT, `values` TEXT, `criterion` TEXT, `f_color` INTEGER, `f_icon` INTEGER, `f_order` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "sql", + "columnName": "sql", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "values", + "columnName": "values", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "criterion", + "columnName": "criterion", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "f_color", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "f_icon", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "f_order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "google_task_lists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gtl_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `gtl_account` TEXT, `gtl_remote_id` TEXT, `gtl_title` TEXT, `gtl_remote_order` INTEGER NOT NULL, `gtl_last_sync` INTEGER NOT NULL, `gtl_color` INTEGER, `gtl_icon` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "gtl_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "account", + "columnName": "gtl_account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "gtl_remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "gtl_title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "gtl_remote_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSync", + "columnName": "gtl_last_sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "gtl_color", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "gtl_icon", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "gtl_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "caldav_lists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cdl_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `cdl_account` TEXT, `cdl_uuid` TEXT, `cdl_name` TEXT, `cdl_color` INTEGER NOT NULL, `cdl_ctag` TEXT, `cdl_url` TEXT, `cdl_icon` INTEGER, `cdl_order` INTEGER NOT NULL, `cdl_access` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "cdl_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "account", + "columnName": "cdl_account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "uuid", + "columnName": "cdl_uuid", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "cdl_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "color", + "columnName": "cdl_color", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ctag", + "columnName": "cdl_ctag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "cdl_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "icon", + "columnName": "cdl_icon", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "cdl_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "access", + "columnName": "cdl_access", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "cdl_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "caldav_tasks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cd_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `cd_task` INTEGER NOT NULL, `cd_calendar` TEXT, `cd_object` TEXT, `cd_remote_id` TEXT, `cd_etag` TEXT, `cd_last_sync` INTEGER NOT NULL, `cd_deleted` INTEGER NOT NULL, `cd_vtodo` TEXT, `cd_remote_parent` TEXT, `cd_order` INTEGER)", + "fields": [ + { + "fieldPath": "id", + "columnName": "cd_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "task", + "columnName": "cd_task", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "calendar", + "columnName": "cd_calendar", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "object", + "columnName": "cd_object", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteId", + "columnName": "cd_remote_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "cd_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastSync", + "columnName": "cd_last_sync", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deleted", + "columnName": "cd_deleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "vtodo", + "columnName": "cd_vtodo", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "remoteParent", + "columnName": "cd_remote_parent", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "order", + "columnName": "cd_order", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "cd_id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "cd_task", + "unique": false, + "columnNames": [ + "cd_task" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `cd_task` ON `${TABLE_NAME}` (`cd_task`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "caldav_accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`cda_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `cda_uuid` TEXT, `cda_name` TEXT, `cda_url` TEXT, `cda_username` TEXT, `cda_password` TEXT, `cda_error` TEXT, `cda_repeat` INTEGER NOT NULL, `cda_encryption_key` TEXT, `cda_account_type` INTEGER NOT NULL, `cda_collapsed` INTEGER NOT NULL, `cda_server_type` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "cda_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "uuid", + "columnName": "cda_uuid", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "cda_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "cda_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "username", + "columnName": "cda_username", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "password", + "columnName": "cda_password", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "error", + "columnName": "cda_error", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isSuppressRepeatingTasks", + "columnName": "cda_repeat", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptionKey", + "columnName": "cda_encryption_key", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "accountType", + "columnName": "cda_account_type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCollapsed", + "columnName": "cda_collapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverType", + "columnName": "cda_server_type", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "cda_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "google_task_accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`gta_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `gta_account` TEXT, `gta_error` TEXT, `gta_etag` TEXT, `gta_collapsed` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "gta_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "account", + "columnName": "gta_account", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "error", + "columnName": "gta_error", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "etag", + "columnName": "gta_etag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "isCollapsed", + "columnName": "gta_collapsed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "gta_id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "principals", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `account` INTEGER NOT NULL, `href` TEXT NOT NULL, `email` TEXT, `display_name` TEXT, FOREIGN KEY(`account`) REFERENCES `caldav_accounts`(`cda_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "account", + "columnName": "account", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "href", + "columnName": "href", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_principals_account_href", + "unique": true, + "columnNames": [ + "account", + "href" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_principals_account_href` ON `${TABLE_NAME}` (`account`, `href`)" + } + ], + "foreignKeys": [ + { + "table": "caldav_accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "account" + ], + "referencedColumns": [ + "cda_id" + ] + } + ] + }, + { + "tableName": "principal_access", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `principal` INTEGER NOT NULL, `list` INTEGER NOT NULL, `invite` INTEGER NOT NULL, `access` INTEGER NOT NULL, FOREIGN KEY(`principal`) REFERENCES `principals`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`list`) REFERENCES `caldav_lists`(`cdl_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "principal", + "columnName": "principal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "list", + "columnName": "list", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "invite", + "columnName": "invite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "access", + "columnName": "access", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_principal_access_list_principal", + "unique": true, + "columnNames": [ + "list", + "principal" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_principal_access_list_principal` ON `${TABLE_NAME}` (`list`, `principal`)" + }, + { + "name": "index_principal_access_principal", + "unique": false, + "columnNames": [ + "principal" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_principal_access_principal` ON `${TABLE_NAME}` (`principal`)" + } + ], + "foreignKeys": [ + { + "table": "principals", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "principal" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "caldav_lists", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "list" + ], + "referencedColumns": [ + "cdl_id" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '950d2a0b758fc58028bde3e388b9f32b')" + ] + } +} \ No newline at end of file diff --git a/app/src/androidTest/java/com/todoroo/astrid/alarms/AlarmJobServiceTest.kt b/app/src/androidTest/java/com/todoroo/astrid/alarms/AlarmJobServiceTest.kt index 2ec1f3f86..474ae8044 100644 --- a/app/src/androidTest/java/com/todoroo/astrid/alarms/AlarmJobServiceTest.kt +++ b/app/src/androidTest/java/com/todoroo/astrid/alarms/AlarmJobServiceTest.kt @@ -1,22 +1,37 @@ package com.todoroo.astrid.alarms import com.natpryce.makeiteasy.MakeItEasy.with +import com.todoroo.andlib.utility.DateUtilities.now import com.todoroo.astrid.dao.TaskDao +import com.todoroo.astrid.data.Task +import com.todoroo.astrid.data.Task.Companion.HIDE_UNTIL_DUE +import com.todoroo.astrid.data.Task.Companion.HIDE_UNTIL_DUE_TIME +import com.todoroo.astrid.data.Task.Companion.URGENCY_SPECIFIC_DAY_TIME import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.UninstallModules import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Test import org.tasks.data.Alarm +import org.tasks.data.Alarm.Companion.whenDue +import org.tasks.data.Alarm.Companion.whenOverdue +import org.tasks.data.Alarm.Companion.whenStarted import org.tasks.data.AlarmDao +import org.tasks.date.DateTimeUtils.newDateTime +import org.tasks.date.DateTimeUtils.toDateTime import org.tasks.injection.InjectingTestCase import org.tasks.injection.ProductionModule import org.tasks.jobs.AlarmEntry import org.tasks.jobs.NotificationQueue +import org.tasks.makers.TaskMaker.COMPLETION_TIME +import org.tasks.makers.TaskMaker.DELETION_TIME +import org.tasks.makers.TaskMaker.DUE_DATE +import org.tasks.makers.TaskMaker.DUE_TIME +import org.tasks.makers.TaskMaker.HIDE_TYPE import org.tasks.makers.TaskMaker.REMINDER_LAST import org.tasks.makers.TaskMaker.newTask import org.tasks.time.DateTime +import java.util.concurrent.TimeUnit import javax.inject.Inject @UninstallModules(ProductionModule::class) @@ -29,24 +44,195 @@ class AlarmJobServiceTest : InjectingTestCase() { @Test fun scheduleAlarm() = runBlocking { - val task = newTask() - taskDao.createNew(task) - val alarmTime = DateTime(2017, 9, 24, 19, 57) - val alarm = Alarm(task.id, alarmTime.millis) - alarm.id = alarmDao.insert(alarm) - alarmService.scheduleAllAlarms() + val task = taskDao.createNew(newTask()) + val alarm = insertAlarm(Alarm(task, DateTime(2017, 9, 24, 19, 57).millis)) - assertEquals(listOf(AlarmEntry(alarm)), jobs.getJobs()) + verify(AlarmEntry(alarm, task, DateTime(2017, 9, 24, 19, 57).millis)) } @Test fun ignoreStaleAlarm() = runBlocking { val alarmTime = DateTime(2017, 9, 24, 19, 57) - val task = newTask(with(REMINDER_LAST, alarmTime.endOfMinute())) - taskDao.createNew(task) - alarmDao.insert(Alarm(task.id, alarmTime.millis)) + val task = taskDao.createNew(newTask(with(REMINDER_LAST, alarmTime.endOfMinute()))) + alarmDao.insert(Alarm(task, alarmTime.millis)) + + verify() + } + + @Test + fun scheduleReminderAtDefaultDue() = runBlocking { + val now = newDateTime() + val task = taskDao.insert(newTask(with(DUE_DATE, now))) + val alarm = alarmDao.insert(whenDue(task)) + + verify(AlarmEntry(alarm, task, now.startOfDay().withHourOfDay(18).millis)) + } + + @Test + fun scheduleReminderAtDefaultDueTime() = runBlocking { + val now = newDateTime() + val task = taskDao.insert(newTask(with(DUE_TIME, now))) + val alarm = alarmDao.insert(whenDue(task)) + + verify(AlarmEntry(alarm, task, now.startOfMinute().millis + 1000)) + } + + @Test + fun scheduleReminderAtDefaultStart() = runBlocking { + val now = newDateTime() + val task = taskDao.insert(newTask(with(DUE_DATE, now), with(HIDE_TYPE, HIDE_UNTIL_DUE))) + val alarm = alarmDao.insert(whenStarted(task)) + + verify(AlarmEntry(alarm, task, now.startOfDay().withHourOfDay(18).millis)) + } + + @Test + fun scheduleReminerAtDefaultStartTime() = runBlocking { + val now = newDateTime() + val task = taskDao.insert(newTask(with(DUE_TIME, now), with(HIDE_TYPE, HIDE_UNTIL_DUE_TIME))) + val alarm = alarmDao.insert(whenStarted(task)) + + verify(AlarmEntry(alarm, task, now.startOfMinute().millis + 1000)) + } + + @Test + fun dontScheduleReminderForCompletedTask() = runBlocking { + val task = taskDao.insert( + newTask( + with(DUE_DATE, newDateTime()), + with(COMPLETION_TIME, newDateTime()) + ) + ) + alarmDao.insert(whenDue(task)) + + verify() + } + + @Test + fun dontScheduleReminderForDeletedTask() = runBlocking { + val task = taskDao.insert( + newTask( + with(DUE_DATE, newDateTime()), + with(DELETION_TIME, newDateTime()) + ) + ) + alarmDao.insert(whenDue(task)) + + verify() + } + + @Test + fun scheduleRelativeAfterDue() = runBlocking { + val now = newDateTime() + val task = taskDao.insert(newTask(with(DUE_DATE, now))) + val alarm = alarmDao.insert(Alarm(task, TimeUnit.DAYS.toMillis(1), Alarm.TYPE_REL_END)) + + verify(AlarmEntry(alarm, task, now.plusDays(1).startOfDay().withHourOfDay(18).millis)) + } + + @Test + fun scheduleRelativeAfterDueTime() = runBlocking { + val now = newDateTime() + val task = taskDao.insert(newTask(with(DUE_TIME, now))) + val alarm = alarmDao.insert(Alarm(task, TimeUnit.DAYS.toMillis(1), Alarm.TYPE_REL_END)) + + verify(AlarmEntry(alarm, task, now.plusDays(1).startOfMinute().millis + 1000)) + } + + @Test + fun scheduleRelativeAfterStart() = runBlocking { + val now = newDateTime() + val task = taskDao.insert(newTask(with(DUE_DATE, now), with(HIDE_TYPE, HIDE_UNTIL_DUE))) + val alarm = alarmDao.insert(Alarm(task, TimeUnit.DAYS.toMillis(1), Alarm.TYPE_REL_START)) + + verify(AlarmEntry(alarm, task, now.plusDays(1).startOfDay().withHourOfDay(18).millis)) + } + + @Test + fun scheduleRelativeAfterStartTime() = runBlocking { + val now = newDateTime() + val task = taskDao.insert(newTask(with(DUE_TIME, now), with(HIDE_TYPE, HIDE_UNTIL_DUE_TIME))) + val alarm = alarmDao.insert(Alarm(task, TimeUnit.DAYS.toMillis(1), Alarm.TYPE_REL_START)) + + verify(AlarmEntry(alarm, task, now.plusDays(1).startOfMinute().millis + 1000)) + } + + @Test + fun scheduleFirstRepeatReminder() = runBlocking { + val now = newDateTime() + val task = taskDao.insert( + newTask(with(DUE_TIME, now), with(REMINDER_LAST, now.plusMinutes(4))) + ) + val alarm = alarmDao.insert(Alarm(task, 0, Alarm.TYPE_REL_END, 1, TimeUnit.MINUTES.toMillis(5))) + + verify(AlarmEntry(alarm, task, now.plusMinutes(5).startOfMinute().millis + 1000)) + } + + @Test + fun scheduleSecondRepeatReminder() = runBlocking { + val now = newDateTime() + val task = taskDao.insert( + newTask(with(DUE_TIME, now), with(REMINDER_LAST, now.plusMinutes(6))) + ) + val alarm = alarmDao.insert(Alarm(task, 0, Alarm.TYPE_REL_END, 2, TimeUnit.MINUTES.toMillis(5))) + + verify(AlarmEntry(alarm, task, now.plusMinutes(10).startOfMinute().millis + 1000)) + } + + @Test + fun terminateRepeatReminder() = runBlocking { + val now = Task.createDueDate(URGENCY_SPECIFIC_DAY_TIME, now()).toDateTime() + val task = taskDao.insert( + newTask(with(DUE_TIME, now), with(REMINDER_LAST, now.plusMinutes(10))) + ) + alarmDao.insert(Alarm(task, 0, Alarm.TYPE_REL_END, 2, TimeUnit.MINUTES.toMillis(5))) + + verify() + } + + @Test + fun dontScheduleRelativeEndWithNoEnd() = runBlocking { + val task = taskDao.insert(newTask()) + alarmDao.insert(whenDue(task)) + + verify() + } + + @Test + fun dontScheduleRelativeStartWithNoStart() = runBlocking { + val now = newDateTime() + val task = taskDao.insert(newTask(with(DUE_DATE, now))) + alarmDao.insert(whenStarted(task)) + + verify() + } + + @Test + fun reminderOverdueEveryDay() = runBlocking { + val dueDate = Task.createDueDate(URGENCY_SPECIFIC_DAY_TIME, DateTime(2022, 1, 30, 13, 30).millis).toDateTime() + val task = taskDao.insert(newTask(with(DUE_TIME, dueDate), with(REMINDER_LAST, dueDate.plusDays(6)))) + val alarm = alarmDao.insert(whenOverdue(task)) + + verify(AlarmEntry(alarm, task, dueDate.plusDays(7).millis)) + } + + @Test + fun endDailyOverdueReminder() = runBlocking { + val dueDate = Task.createDueDate(URGENCY_SPECIFIC_DAY_TIME, DateTime(2022, 1, 30, 13, 30).millis).toDateTime() + val task = taskDao.insert(newTask(with(DUE_TIME, dueDate), with(REMINDER_LAST, dueDate.plusDays(7)))) + alarmDao.insert(whenOverdue(task)) + + verify() + } + + private suspend fun insertAlarm(alarm: Alarm): Long { + alarm.id = alarmDao.insert(alarm) + return alarm.id + } + + private suspend fun verify(vararg alarms: AlarmEntry) { alarmService.scheduleAllAlarms() - assertTrue(jobs.getJobs().isEmpty()) + assertEquals(alarms.toList(), jobs.getJobs()) } } \ No newline at end of file diff --git a/app/src/androidTest/java/com/todoroo/astrid/reminders/ReminderServiceTest.kt b/app/src/androidTest/java/com/todoroo/astrid/reminders/ReminderServiceTest.kt index 335ed5e45..cc88f0696 100644 --- a/app/src/androidTest/java/com/todoroo/astrid/reminders/ReminderServiceTest.kt +++ b/app/src/androidTest/java/com/todoroo/astrid/reminders/ReminderServiceTest.kt @@ -3,7 +3,6 @@ package com.todoroo.astrid.reminders import com.natpryce.makeiteasy.MakeItEasy.with import com.todoroo.andlib.utility.DateUtilities import com.todoroo.astrid.data.Task -import com.todoroo.astrid.data.Task.Companion.HIDE_UNTIL_DUE import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.UninstallModules import org.junit.Assert.assertEquals @@ -11,19 +10,14 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.tasks.Freeze.Companion.freezeClock -import org.tasks.R import org.tasks.data.TaskDao import org.tasks.date.DateTimeUtils.newDateTime import org.tasks.injection.InjectingTestCase import org.tasks.injection.ProductionModule import org.tasks.jobs.NotificationQueue import org.tasks.jobs.ReminderEntry -import org.tasks.makers.TaskMaker.COMPLETION_TIME import org.tasks.makers.TaskMaker.CREATION_TIME -import org.tasks.makers.TaskMaker.DELETION_TIME -import org.tasks.makers.TaskMaker.DUE_DATE import org.tasks.makers.TaskMaker.DUE_TIME -import org.tasks.makers.TaskMaker.HIDE_TYPE import org.tasks.makers.TaskMaker.ID import org.tasks.makers.TaskMaker.RANDOM_REMINDER_PERIOD import org.tasks.makers.TaskMaker.REMINDERS @@ -33,7 +27,6 @@ import org.tasks.makers.TaskMaker.newTask import org.tasks.preferences.Preferences import org.tasks.reminders.Random import org.tasks.time.DateTime -import java.util.concurrent.TimeUnit import javax.inject.Inject @UninstallModules(ProductionModule::class) @@ -51,152 +44,7 @@ class ReminderServiceTest : InjectingTestCase() { super.setUp() random = RandomStub() preferences.clear() - service = ReminderService(preferences, jobs, random, taskDao) - } - - @Test - fun dontScheduleStartDateReminderWhenFlagNotSet() { - service.scheduleAlarm( - newTask( - with(ID, 1L), - with(HIDE_TYPE, Task.HIDE_UNTIL_DUE), - with(DUE_TIME, newDateTime()) - ) - ) - - assertTrue(jobs.isEmpty()) - } - - @Test - fun dontScheduleDueDateReminderWhenFlagNotSet() { - service.scheduleAlarm(newTask(with(ID, 1L), with(DUE_TIME, newDateTime()))) - - assertTrue(jobs.isEmpty()) - } - - @Test - fun dontScheduleDueDateReminderWhenTimeNotSet() { - service.scheduleAlarm(newTask(with(ID, 1L), with(REMINDERS, Task.NOTIFY_AT_DEADLINE))) - - assertTrue(jobs.isEmpty()) - } - - @Test - fun schedulePastStartDate() { - freezeClock { - val dueDate = newDateTime().minusDays(1) - val task = newTask( - with(ID, 1L), - with(DUE_TIME, dueDate), - with(HIDE_TYPE, HIDE_UNTIL_DUE), - with(REMINDERS, Task.NOTIFY_AT_START) - ) - - service.scheduleAlarm(task) - - verify( - ReminderEntry( - 1, - dueDate.startOfDay().withHourOfDay(18).millis, - ReminderService.TYPE_START - ) - ) - } - } - - @Test - fun scheduleFutureStartDate() { - val dueDate = newDateTime().plusDays(1) - val task = newTask( - with(ID, 1L), - with(DUE_TIME, dueDate), - with(HIDE_TYPE, HIDE_UNTIL_DUE), - with(REMINDERS, Task.NOTIFY_AT_START) - ) - - service.scheduleAlarm(task) - - verify( - ReminderEntry( - 1, - dueDate.startOfDay().withHourOfDay(18).millis, - ReminderService.TYPE_START - ) - ) - } - - @Test - fun schedulePastDueDate() { - val task = newTask( - with(ID, 1L), - with(DUE_TIME, newDateTime().minusDays(1)), - with(REMINDERS, Task.NOTIFY_AT_DEADLINE)) - - service.scheduleAlarm(task) - - verify(ReminderEntry(1, task.dueDate, ReminderService.TYPE_DUE)) - } - - @Test - fun scheduleFutureDueDate() { - val task = newTask( - with(ID, 1L), - with(DUE_TIME, newDateTime().plusDays(1)), - with(REMINDERS, Task.NOTIFY_AT_DEADLINE)) - - service.scheduleAlarm(task) - - verify(ReminderEntry(1, task.dueDate, ReminderService.TYPE_DUE)) - } - - @Test - fun scheduleReminderAtDefaultDueTime() { - val now = newDateTime() - val task = newTask(with(ID, 1L), with(DUE_DATE, now), with(REMINDERS, Task.NOTIFY_AT_DEADLINE)) - - service.scheduleAlarm(task) - - verify(ReminderEntry(1, now.startOfDay().withHourOfDay(18).millis, ReminderService.TYPE_DUE)) - } - - @Test - fun dontScheduleReminderForCompletedTask() { - val task = newTask( - with(ID, 1L), - with(DUE_TIME, newDateTime().plusDays(1)), - with(COMPLETION_TIME, newDateTime()), - with(REMINDERS, Task.NOTIFY_AT_DEADLINE)) - - service.scheduleAlarm(task) - - assertTrue(jobs.isEmpty()) - } - - @Test - fun dontScheduleReminderForDeletedTask() { - val task = newTask( - with(ID, 1L), - with(DUE_TIME, newDateTime().plusDays(1)), - with(DELETION_TIME, newDateTime()), - with(REMINDERS, Task.NOTIFY_AT_DEADLINE)) - - service.scheduleAlarm(task) - - assertTrue(jobs.isEmpty()) - } - - @Test - fun dontScheduleDueDateReminderWhenAlreadyReminded() { - val now = newDateTime() - val task = newTask( - with(ID, 1L), - with(DUE_TIME, now), - with(REMINDER_LAST, now.plusSeconds(1)), - with(REMINDERS, Task.NOTIFY_AT_DEADLINE)) - - service.scheduleAlarm(task) - - assertTrue(jobs.isEmpty()) + service = ReminderService(jobs, random, taskDao) } @Test @@ -205,12 +53,12 @@ class ReminderServiceTest : InjectingTestCase() { with(ID, 1L), with(DUE_TIME, newDateTime()), with(SNOOZE_TIME, newDateTime().minusMinutes(5)), - with(REMINDER_LAST, newDateTime().minusMinutes(4)), - with(REMINDERS, Task.NOTIFY_AT_DEADLINE)) + with(REMINDER_LAST, newDateTime().minusMinutes(4)) + ) service.scheduleAlarm(task) - verify(ReminderEntry(1, task.dueDate, ReminderService.TYPE_DUE)) + assertTrue(jobs.isEmpty()) } @Test @@ -282,84 +130,6 @@ class ReminderServiceTest : InjectingTestCase() { } } - @Test - fun scheduleOverdueNoLastReminder() { - val task = newTask( - with(ID, 1L), - with(DUE_TIME, DateTime(2017, 9, 22, 15, 30)), - with(REMINDER_LAST, null as DateTime?), - with(REMINDERS, Task.NOTIFY_AFTER_DEADLINE)) - - service.scheduleAlarm(task) - - verify(ReminderEntry(1L, DateTime(2017, 9, 23, 15, 30, 1, 0).millis, ReminderService.TYPE_OVERDUE)) - } - - @Test - fun scheduleOverduePastLastReminder() { - val task = newTask( - with(ID, 1L), - with(DUE_TIME, DateTime(2017, 9, 22, 15, 30)), - with(REMINDER_LAST, DateTime(2017, 9, 24, 12, 0)), - with(REMINDERS, Task.NOTIFY_AFTER_DEADLINE)) - - service.scheduleAlarm(task) - - verify(ReminderEntry(1L, DateTime(2017, 9, 24, 15, 30, 1, 0).millis, ReminderService.TYPE_OVERDUE)) - } - - @Test - fun scheduleOverdueBeforeLastReminder() { - val task = newTask( - with(ID, 1L), - with(DUE_TIME, DateTime(2017, 9, 22, 12, 30)), - with(REMINDER_LAST, DateTime(2017, 9, 24, 15, 0)), - with(REMINDERS, Task.NOTIFY_AFTER_DEADLINE)) - - service.scheduleAlarm(task) - - verify(ReminderEntry(1L, DateTime(2017, 9, 25, 12, 30, 1, 0).millis, ReminderService.TYPE_OVERDUE)) - } - - @Test - fun scheduleOverdueWithNoDueTime() { - preferences.setInt(R.string.p_rmd_time, TimeUnit.HOURS.toMillis(15).toInt()) - val task = newTask( - with(ID, 1L), - with(DUE_DATE, DateTime(2017, 9, 22)), - with(REMINDER_LAST, DateTime(2017, 9, 23, 12, 17, 59, 999)), - with(REMINDERS, Task.NOTIFY_AFTER_DEADLINE)) - - service.scheduleAlarm(task) - - verify(ReminderEntry(1L, DateTime(2017, 9, 23, 15, 0, 0, 0).millis, ReminderService.TYPE_OVERDUE)) - } - - @Test - fun scheduleSubsequentOverdueReminder() { - val task = newTask( - with(ID, 1L), - with(DUE_TIME, DateTime(2017, 9, 22, 15, 30)), - with(REMINDER_LAST, DateTime(2017, 9, 23, 15, 30, 59, 999)), - with(REMINDERS, Task.NOTIFY_AFTER_DEADLINE)) - - service.scheduleAlarm(task) - - verify(ReminderEntry(1L, DateTime(2017, 9, 24, 15, 30, 1, 0).millis, ReminderService.TYPE_OVERDUE)) - } - - @Test - fun scheduleOverdueAfterLastReminder() { - val task = newTask( - with(ID, 1L), - with(DUE_TIME, DateTime(2017, 9, 22, 15, 30)), - with(REMINDER_LAST, DateTime(2017, 9, 23, 12, 17, 59, 999)), - with(REMINDERS, Task.NOTIFY_AFTER_DEADLINE)) - - service.scheduleAlarm(task) - - verify(ReminderEntry(1L, DateTime(2017, 9, 23, 15, 30, 1, 0).millis, ReminderService.TYPE_OVERDUE)) - } @Test fun snoozeOverridesAll() { diff --git a/app/src/androidTest/java/org/tasks/ui/editviewmodel/ReminderTests.kt b/app/src/androidTest/java/org/tasks/ui/editviewmodel/ReminderTests.kt index 7ec88d8bb..ba2a7550c 100644 --- a/app/src/androidTest/java/org/tasks/ui/editviewmodel/ReminderTests.kt +++ b/app/src/androidTest/java/org/tasks/ui/editviewmodel/ReminderTests.kt @@ -1,39 +1,66 @@ package org.tasks.ui.editviewmodel +import com.todoroo.astrid.data.Task import dagger.hilt.android.testing.HiltAndroidTest import dagger.hilt.android.testing.UninstallModules import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue +import org.junit.Assert.* import org.junit.Test +import org.tasks.data.Alarm +import org.tasks.data.Alarm.Companion.whenOverdue import org.tasks.injection.ProductionModule import org.tasks.makers.TaskMaker.newTask +import org.tasks.time.DateTimeUtils.currentTimeMillis @UninstallModules(ProductionModule::class) @HiltAndroidTest class ReminderTests : BaseTaskEditViewModelTest() { + @Test + fun whenStartReminder() = runBlocking { + val task = newTask() + task.defaultReminders(Task.NOTIFY_AT_START) + setup(task) + + viewModel.hideUntil = Task.createDueDate(Task.URGENCY_SPECIFIC_DAY_TIME, currentTimeMillis()) + + save() + + assertEquals( + listOf(Alarm(1, 0, Alarm.TYPE_REL_START).apply { id = 1 }), + alarmDao.getAlarms(task.id) + ) + } + @Test fun whenDueReminder() = runBlocking { val task = newTask() + task.defaultReminders(Task.NOTIFY_AT_DEADLINE) setup(task) - viewModel.whenDue = true + viewModel.dueDate = Task.createDueDate(Task.URGENCY_SPECIFIC_DAY_TIME, currentTimeMillis()) save() - assertTrue(taskDao.fetch(task.id)!!.isNotifyAtDeadline) + assertEquals( + listOf(Alarm(1, 0, Alarm.TYPE_REL_END).apply { id = 1 }), + alarmDao.getAlarms(task.id) + ) } @Test fun whenOverDueReminder() = runBlocking { val task = newTask() + task.defaultReminders(Task.NOTIFY_AFTER_DEADLINE) setup(task) - viewModel.whenOverdue = true + viewModel.dueDate = Task.createDueDate(Task.URGENCY_SPECIFIC_DAY_TIME, currentTimeMillis()) save() - assertTrue(taskDao.fetch(task.id)!!.isNotifyAfterDeadline) + assertEquals( + listOf(whenOverdue(1).apply { id = 1 }), + alarmDao.getAlarms(task.id) + ) } @Test diff --git a/app/src/commonTest/java/org/tasks/makers/TaskMaker.kt b/app/src/commonTest/java/org/tasks/makers/TaskMaker.kt index 8d2b57003..aa538cc59 100644 --- a/app/src/commonTest/java/org/tasks/makers/TaskMaker.kt +++ b/app/src/commonTest/java/org/tasks/makers/TaskMaker.kt @@ -73,7 +73,7 @@ object TaskMaker { } val reminderFlags = lookup.valueOf(REMINDERS, -1) if (reminderFlags >= 0) { - task.reminderFlags = reminderFlags + task.ringFlags = reminderFlags } val reminderLast = lookup.valueOf(REMINDER_LAST, null as DateTime?) if (reminderLast != null) { diff --git a/app/src/main/java/com/todoroo/astrid/alarms/AlarmService.kt b/app/src/main/java/com/todoroo/astrid/alarms/AlarmService.kt index 40a235c9c..9a7115045 100644 --- a/app/src/main/java/com/todoroo/astrid/alarms/AlarmService.kt +++ b/app/src/main/java/com/todoroo/astrid/alarms/AlarmService.kt @@ -5,10 +5,17 @@ */ package com.todoroo.astrid.alarms +import com.todoroo.astrid.data.Task import org.tasks.data.Alarm +import org.tasks.data.Alarm.Companion.TYPE_DATE_TIME +import org.tasks.data.Alarm.Companion.TYPE_REL_END +import org.tasks.data.Alarm.Companion.TYPE_REL_START import org.tasks.data.AlarmDao +import org.tasks.data.TaskDao import org.tasks.jobs.AlarmEntry import org.tasks.jobs.NotificationQueue +import org.tasks.preferences.Preferences +import org.tasks.time.DateTimeUtils.withMillisOfDay import javax.inject.Inject import javax.inject.Singleton @@ -20,7 +27,10 @@ import javax.inject.Singleton @Singleton class AlarmService @Inject constructor( private val alarmDao: AlarmDao, - private val jobs: NotificationQueue) { + private val jobs: NotificationQueue, + private val taskDao: TaskDao, + private val preferences: Preferences, +) { suspend fun getAlarms(taskId: Long): List = alarmDao.getAlarms(taskId) @@ -30,9 +40,15 @@ class AlarmService @Inject constructor( * @return true if data was changed */ suspend fun synchronizeAlarms(taskId: Long, alarms: MutableSet): Boolean { + val task = taskDao.fetch(taskId) ?: return false var changed = false for (existing in alarmDao.getAlarms(taskId)) { - if (!alarms.removeIf { it.time == existing.time }) { + if (!alarms.removeIf { + it.type == existing.type && + it.time == existing.time && + it.repeat == existing.repeat && + it.interval == existing.interval + }) { jobs.cancelAlarm(existing.id) alarmDao.delete(existing) changed = true @@ -44,7 +60,7 @@ class AlarmService @Inject constructor( changed = true } if (changed) { - scheduleAlarms(taskId) + scheduleAlarms(task) } return changed } @@ -53,7 +69,13 @@ class AlarmService @Inject constructor( alarmDao.getActiveAlarms(taskId) suspend fun scheduleAllAlarms() { - alarmDao.getActiveAlarms().forEach(::scheduleAlarm) + alarmDao + .getActiveAlarms() + .groupBy { it.task } + .forEach { (taskId, alarms) -> + val task = taskDao.fetch(taskId) ?: return@forEach + alarms.forEach { scheduleAlarm(task, it) } + } } suspend fun cancelAlarms(taskId: Long) { @@ -63,25 +85,55 @@ class AlarmService @Inject constructor( } /** Schedules alarms for a single task */ - private suspend fun scheduleAlarms(taskId: Long) { - getActiveAlarmsForTask(taskId).forEach(::scheduleAlarm) + suspend fun scheduleAlarms(task: Task) { + getActiveAlarmsForTask(task.id).forEach { scheduleAlarm(task, it) } } /** Schedules alarms for a single task */ - private fun scheduleAlarm(alarm: Alarm?) { + private fun scheduleAlarm(task: Task, alarm: Alarm?) { if (alarm == null) { return } - val alarmEntry = AlarmEntry(alarm) - val time = alarmEntry.time - if (time == 0L || time == NO_ALARM) { - jobs.cancelAlarm(alarmEntry.id) - } else { - jobs.add(alarmEntry) + val trigger = when (alarm.type) { + TYPE_DATE_TIME -> + alarm.time + TYPE_REL_START -> + when { + task.hasStartTime() -> + task.hideUntil + alarm.time + task.hasStartDate() -> + task.hideUntil.withMillisOfDay(preferences.defaultDueTime) + alarm.time + else -> + NO_ALARM + } + TYPE_REL_END -> + when { + task.hasDueTime() -> + task.dueDate + alarm.time + task.hasDueDate() -> + task.dueDate.withMillisOfDay(preferences.defaultDueTime) + alarm.time + else -> + NO_ALARM + } + else -> NO_ALARM + } + jobs.cancelAlarm(alarm.id) + when { + trigger <= NO_ALARM -> + {} + trigger > task.reminderLast -> + jobs.add(AlarmEntry(alarm.id, alarm.task, trigger)) + alarm.repeat > 0 -> { + val past = (task.reminderLast - trigger) / alarm.interval + val next = trigger + (past + 1) * alarm.interval + if (past < alarm.repeat && next > task.reminderLast) { + jobs.add(AlarmEntry(alarm.id, alarm.task, next)) + } + } } } companion object { - private const val NO_ALARM = Long.MAX_VALUE + private const val NO_ALARM = 0L } } diff --git a/app/src/main/java/com/todoroo/astrid/dao/Database.kt b/app/src/main/java/com/todoroo/astrid/dao/Database.kt index d64f046dc..6c9f7bcbe 100644 --- a/app/src/main/java/com/todoroo/astrid/dao/Database.kt +++ b/app/src/main/java/com/todoroo/astrid/dao/Database.kt @@ -61,7 +61,8 @@ import org.tasks.notifications.NotificationDao Principal::class, PrincipalAccess::class ], - version = 80) + version = 81 +) abstract class Database : RoomDatabase() { abstract fun notificationDao(): NotificationDao abstract val tagDataDao: TagDataDao diff --git a/app/src/main/java/com/todoroo/astrid/dao/TaskDao.kt b/app/src/main/java/com/todoroo/astrid/dao/TaskDao.kt index 3bf81a78b..95b64b3de 100644 --- a/app/src/main/java/com/todoroo/astrid/dao/TaskDao.kt +++ b/app/src/main/java/com/todoroo/astrid/dao/TaskDao.kt @@ -6,6 +6,7 @@ package com.todoroo.astrid.dao import com.todoroo.andlib.utility.DateUtilities.now +import com.todoroo.astrid.alarms.AlarmService import com.todoroo.astrid.api.Filter import com.todoroo.astrid.data.Task import com.todoroo.astrid.reminders.ReminderService @@ -34,7 +35,9 @@ class TaskDao @Inject constructor( private val notificationManager: NotificationManager, private val geofenceApi: GeofenceApi, private val timerPlugin: TimerPlugin, - private val syncAdapters: SyncAdapters) { + private val syncAdapters: SyncAdapters, + private val alarmService: AlarmService, +) { suspend fun fetch(id: Long): Task? = taskDao.fetch(id) @@ -135,6 +138,7 @@ class TaskDao @Inject constructor( geofenceApi.update(task.id) } reminderService.scheduleAlarm(task) + alarmService.scheduleAlarms(task) refreshScheduler.scheduleRefresh(task) if (!task.isSuppressRefresh()) { localBroadcastManager.broadcastRefresh() diff --git a/app/src/main/java/com/todoroo/astrid/data/Task.kt b/app/src/main/java/com/todoroo/astrid/data/Task.kt index e6586cb83..5505e58b9 100644 --- a/app/src/main/java/com/todoroo/astrid/data/Task.kt +++ b/app/src/main/java/com/todoroo/astrid/data/Task.kt @@ -5,7 +5,12 @@ import android.os.Parcel import android.os.Parcelable import androidx.annotation.IntDef import androidx.core.os.ParcelCompat -import androidx.room.* +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.Ignore +import androidx.room.Index +import androidx.room.PrimaryKey +import com.google.gson.annotations.SerializedName import com.todoroo.andlib.data.Table import com.todoroo.andlib.sql.Field import com.todoroo.andlib.utility.DateUtilities @@ -17,7 +22,6 @@ import org.tasks.date.DateTimeUtils.toDateTime import org.tasks.time.DateTime import org.tasks.time.DateTimeUtils.startOfDay import timber.log.Timber -import java.util.* @Entity( tableName = Task.TABLE_NAME, @@ -77,7 +81,8 @@ class Task : Parcelable { /** Flags for when to send reminders */ @ColumnInfo(name = "notificationFlags") - var reminderFlags = 0 + @SerializedName("ringFlags", alternate = ["reminderFlags"]) + var ringFlags = 0 /** Reminder period, in milliseconds. 0 means disabled */ @ColumnInfo(name = "notifications") @@ -136,7 +141,7 @@ class Task : Parcelable { modificationDate = parcel.readLong() notes = parcel.readString() recurrence = parcel.readString() - reminderFlags = parcel.readInt() + ringFlags = parcel.readInt() reminderLast = parcel.readLong() reminderPeriod = parcel.readLong() reminderSnooze = parcel.readLong() @@ -243,22 +248,26 @@ class Task : Parcelable { } val isNotifyModeNonstop: Boolean - get() = isReminderFlagSet(NOTIFY_MODE_NONSTOP) + get() = isRingSet(NOTIFY_MODE_NONSTOP) val isNotifyModeFive: Boolean - get() = isReminderFlagSet(NOTIFY_MODE_FIVE) + get() = isRingSet(NOTIFY_MODE_FIVE) val isNotifyAfterDeadline: Boolean - get() = isReminderFlagSet(NOTIFY_AFTER_DEADLINE) + get() = isReminderSet(NOTIFY_AFTER_DEADLINE) val isNotifyAtStart: Boolean - get() = isReminderFlagSet(NOTIFY_AT_START) + get() = isReminderSet(NOTIFY_AT_START) val isNotifyAtDeadline: Boolean - get() = isReminderFlagSet(NOTIFY_AT_DEADLINE) + get() = isReminderSet(NOTIFY_AT_DEADLINE) - private fun isReminderFlagSet(flag: Int): Boolean { - return reminderFlags and flag > 0 + private fun isReminderSet(flag: Int): Boolean { + return ((transitoryData?.get(TRANS_REMINDERS) as? Int) ?: 0) and flag > 0 + } + + private fun isRingSet(flag: Int): Boolean { + return ringFlags and flag > 0 } val isNew: Boolean @@ -282,7 +291,7 @@ class Task : Parcelable { dest.writeLong(modificationDate) dest.writeString(notes) dest.writeString(recurrence) - dest.writeInt(reminderFlags) + dest.writeInt(ringFlags) dest.writeLong(reminderLast) dest.writeLong(reminderPeriod) dest.writeLong(reminderSnooze) @@ -313,7 +322,7 @@ class Task : Parcelable { && notes == task.notes && estimatedSeconds == task.estimatedSeconds && elapsedSeconds == task.elapsedSeconds - && reminderFlags == task.reminderFlags + && ringFlags == task.ringFlags && reminderPeriod == task.reminderPeriod && recurrence == task.recurrence && repeatUntil == task.repeatUntil @@ -372,6 +381,10 @@ class Task : Parcelable { fun isSuppressRefresh() = checkTransitory(TRANS_SUPPRESS_REFRESH) + fun defaultReminders(flags: Int) { + putTransitory(TRANS_REMINDERS, flags) + } + @Synchronized fun putTransitory(key: String, value: Any) { if (transitoryData == null) { @@ -425,7 +438,7 @@ class Task : Parcelable { if (estimatedSeconds != other.estimatedSeconds) return false if (elapsedSeconds != other.elapsedSeconds) return false if (timerStart != other.timerStart) return false - if (reminderFlags != other.reminderFlags) return false + if (ringFlags != other.ringFlags) return false if (reminderPeriod != other.reminderPeriod) return false if (reminderLast != other.reminderLast) return false if (reminderSnooze != other.reminderSnooze) return false @@ -454,7 +467,7 @@ class Task : Parcelable { result = 31 * result + estimatedSeconds result = 31 * result + elapsedSeconds result = 31 * result + timerStart.hashCode() - result = 31 * result + reminderFlags + result = 31 * result + ringFlags result = 31 * result + reminderPeriod.hashCode() result = 31 * result + reminderLast.hashCode() result = 31 * result + reminderSnooze.hashCode() @@ -469,7 +482,7 @@ class Task : Parcelable { } override fun toString(): String { - return "Task(id=$id, title=$title, priority=$priority, dueDate=$dueDate, hideUntil=$hideUntil, creationDate=$creationDate, modificationDate=$modificationDate, completionDate=$completionDate, deletionDate=$deletionDate, notes=$notes, estimatedSeconds=$estimatedSeconds, elapsedSeconds=$elapsedSeconds, timerStart=$timerStart, reminderFlags=$reminderFlags, reminderPeriod=$reminderPeriod, reminderLast=$reminderLast, reminderSnooze=$reminderSnooze, recurrence=$recurrence, repeatUntil=$repeatUntil, calendarURI=$calendarURI, remoteId='$remoteId', isCollapsed=$isCollapsed, parent=$parent, transitoryData=$transitoryData)" + return "Task(id=$id, title=$title, priority=$priority, dueDate=$dueDate, hideUntil=$hideUntil, creationDate=$creationDate, modificationDate=$modificationDate, completionDate=$completionDate, deletionDate=$deletionDate, notes=$notes, estimatedSeconds=$estimatedSeconds, elapsedSeconds=$elapsedSeconds, timerStart=$timerStart, ringFlags=$ringFlags, reminderPeriod=$reminderPeriod, reminderLast=$reminderLast, reminderSnooze=$reminderSnooze, recurrence=$recurrence, repeatUntil=$repeatUntil, calendarURI=$calendarURI, remoteId='$remoteId', isCollapsed=$isCollapsed, parent=$parent, transitoryData=$transitoryData)" } @Retention(AnnotationRetention.SOURCE) @@ -558,6 +571,7 @@ class Task : Parcelable { const val URGENCY_IN_TWO_WEEKS = 5 private const val TRANS_SUPPRESS_REFRESH = "suppress-refresh" + const val TRANS_REMINDERS = "reminders" private val INVALID_COUNT = ";?COUNT=-1".toRegex() diff --git a/app/src/main/java/com/todoroo/astrid/reminders/ReminderService.kt b/app/src/main/java/com/todoroo/astrid/reminders/ReminderService.kt index 822fc4752..ab4a91e54 100644 --- a/app/src/main/java/com/todoroo/astrid/reminders/ReminderService.kt +++ b/app/src/main/java/com/todoroo/astrid/reminders/ReminderService.kt @@ -10,25 +10,22 @@ import com.todoroo.astrid.data.Task import org.tasks.data.TaskDao import org.tasks.jobs.NotificationQueue import org.tasks.jobs.ReminderEntry -import org.tasks.preferences.Preferences import org.tasks.reminders.Random -import org.tasks.time.DateTime import javax.inject.Inject import javax.inject.Singleton @Singleton class ReminderService internal constructor( - private val preferences: Preferences, private val jobs: NotificationQueue, private val random: Random, - private val taskDao: TaskDao) { + private val taskDao: TaskDao, +) { @Inject internal constructor( - preferences: Preferences, notificationQueue: NotificationQueue, taskDao: TaskDao - ) : this(preferences, notificationQueue, Random(), taskDao) + ) : this(notificationQueue, Random(), taskDao) suspend fun scheduleAlarm(id: Long) = scheduleAllAlarms(listOf(id)) @@ -66,31 +63,12 @@ class ReminderService internal constructor( // random reminders val whenRandom = calculateNextRandomReminder(task) - val whenStartDate = calculateStartDateReminder(task) - - // notifications at due date - val whenDueDate = calculateNextDueDateReminder(task) - - // notifications after due date - val whenOverdue = calculateNextOverdueReminder(task) - // snooze trumps all - if (whenSnooze != NO_ALARM) { - return ReminderEntry(taskId, whenSnooze, TYPE_SNOOZE) - } else if ( - whenRandom < whenDueDate && - whenRandom < whenOverdue && - whenRandom < whenStartDate - ) { - return ReminderEntry(taskId, whenRandom, TYPE_RANDOM) - } else if (whenStartDate < whenDueDate) { - return ReminderEntry(taskId, whenStartDate, TYPE_START) - } else if (whenDueDate < whenOverdue) { - return ReminderEntry(taskId, whenDueDate, TYPE_DUE) - } else if (whenOverdue != NO_ALARM) { - return ReminderEntry(taskId, whenOverdue, TYPE_OVERDUE) + return when { + whenSnooze != NO_ALARM -> ReminderEntry(taskId, whenSnooze, TYPE_SNOOZE) + whenRandom != NO_ALARM -> ReminderEntry(taskId, whenRandom, TYPE_RANDOM) + else -> null } - return null } private fun calculateNextSnoozeReminder(task: Task): Long { @@ -99,64 +77,6 @@ class ReminderService internal constructor( } else NO_ALARM } - private fun calculateNextOverdueReminder(task: Task): Long { - // Uses getNowValue() instead of DateUtilities.now() - if (task.hasDueDate() && task.isNotifyAfterDeadline) { - var overdueDate = DateTime(task.dueDate).plusDays(1) - if (!task.hasDueTime()) { - overdueDate = overdueDate.withMillisOfDay(preferences.defaultDueTime) - } - val lastReminder = DateTime(task.reminderLast) - if (overdueDate.isAfter(lastReminder)) { - return overdueDate.millis - } - overdueDate = lastReminder.withMillisOfDay(overdueDate.millisOfDay) - return if (overdueDate.isAfter(lastReminder)) overdueDate.millis else overdueDate.plusDays(1).millis - } - return NO_ALARM - } - - private fun calculateStartDateReminder(task: Task): Long { - if (task.hasStartDate() && task.isNotifyAtStart) { - val startDate = task.hideUntil - val startDateAlarm = if (task.hasStartTime()) { - startDate - } else { - DateTime(startDate).withMillisOfDay(preferences.defaultDueTime).millis - } - if (task.reminderLast < startDateAlarm) { - return startDateAlarm - } - } - return NO_ALARM - } - - /** - * Calculate the next alarm time for due date reminders. - * - * - * This alarm always returns the due date, and is triggered if the last reminder time occurred - * before the due date. This means it is possible to return due dates in the past. - * - * - * If the date was indicated to not have a due time, we read from preferences and assign a - * time. - */ - private fun calculateNextDueDateReminder(task: Task): Long { - if (task.hasDueDate() && task.isNotifyAtDeadline) { - val dueDate = task.dueDate - val dueDateAlarm = if (task.hasDueTime()) { - dueDate - } else { - DateTime(dueDate).withMillisOfDay(preferences.defaultDueTime).millis - } - if (task.reminderLast < dueDateAlarm) { - return dueDateAlarm - } - } - return NO_ALARM - } - /** * Calculate the next alarm time for random reminders. * diff --git a/app/src/main/java/com/todoroo/astrid/repeats/RepeatTaskHelper.kt b/app/src/main/java/com/todoroo/astrid/repeats/RepeatTaskHelper.kt index b6b30b52b..60a0ddbc3 100644 --- a/app/src/main/java/com/todoroo/astrid/repeats/RepeatTaskHelper.kt +++ b/app/src/main/java/com/todoroo/astrid/repeats/RepeatTaskHelper.kt @@ -16,6 +16,7 @@ import net.fortuna.ical4j.model.Date import net.fortuna.ical4j.model.Recur import net.fortuna.ical4j.model.WeekDay import org.tasks.LocalBroadcastManager +import org.tasks.data.Alarm import org.tasks.date.DateTimeUtils.newDateTime import org.tasks.repeats.RecurrenceUtils.newRecur import org.tasks.time.DateTime @@ -116,7 +117,11 @@ class RepeatTaskHelper @Inject constructor( } alarmService.getAlarms(taskId) .takeIf { it.isNotEmpty() } - ?.onEach { it.time += newDueDate - oldDueDate } + ?.onEach { + if (it.type == Alarm.TYPE_DATE_TIME) { + it.time += newDueDate - oldDueDate + } + } ?.let { alarmService.synchronizeAlarms(taskId, it.toMutableSet()) } } diff --git a/app/src/main/java/com/todoroo/astrid/service/TaskCreator.kt b/app/src/main/java/com/todoroo/astrid/service/TaskCreator.kt index 6f347493b..fe0881a24 100644 --- a/app/src/main/java/com/todoroo/astrid/service/TaskCreator.kt +++ b/app/src/main/java/com/todoroo/astrid/service/TaskCreator.kt @@ -17,12 +17,26 @@ import com.todoroo.astrid.helper.UUIDHelper import com.todoroo.astrid.utility.TitleParser.parse import org.tasks.R import org.tasks.Strings.isNullOrEmpty -import org.tasks.data.* +import org.tasks.data.Alarm +import org.tasks.data.Alarm.Companion.whenDue +import org.tasks.data.Alarm.Companion.whenOverdue +import org.tasks.data.Alarm.Companion.whenStarted +import org.tasks.data.AlarmDao +import org.tasks.data.CaldavDao +import org.tasks.data.CaldavTask +import org.tasks.data.Geofence +import org.tasks.data.GoogleTask +import org.tasks.data.GoogleTaskDao +import org.tasks.data.LocationDao +import org.tasks.data.Place +import org.tasks.data.Tag +import org.tasks.data.TagDao +import org.tasks.data.TagData +import org.tasks.data.TagDataDao import org.tasks.preferences.DefaultFilterProvider import org.tasks.preferences.Preferences import org.tasks.time.DateTimeUtils.startOfDay import timber.log.Timber -import java.util.* import javax.inject.Inject class TaskCreator @Inject constructor( @@ -34,7 +48,9 @@ class TaskCreator @Inject constructor( private val googleTaskDao: GoogleTaskDao, private val defaultFilterProvider: DefaultFilterProvider, private val caldavDao: CaldavDao, - private val locationDao: LocationDao) { + private val locationDao: LocationDao, + private val alarmDao: AlarmDao, +) { suspend fun basicQuickAddTask(title: String): Task { val task = createWithValues(title.trim { it <= ' ' }) @@ -71,6 +87,7 @@ class TaskCreator @Inject constructor( } } taskDao.save(task, null) + alarmDao.insert(task.getDefaultAlarms()) return task } @@ -161,10 +178,25 @@ class TaskCreator @Inject constructor( private fun setDefaultReminders(preferences: Preferences, task: Task) { task.reminderPeriod = (DateUtilities.ONE_HOUR * preferences.getIntegerFromString(R.string.p_rmd_default_random_hours, 0)) - task.reminderFlags = preferences.defaultReminders or preferences.defaultRingMode + task.defaultReminders(preferences.defaultReminders) + task.ringFlags = preferences.defaultRingMode } private fun Any?.substitute(): String? = (this as? String)?.let { PermaSql.replacePlaceholdersForNewTask(it) } + + fun Task.getDefaultAlarms(): List = ArrayList().apply { + if (hasStartDate() && isNotifyAtStart) { + add(whenStarted(id)) + } + if (hasDueDate()) { + if (isNotifyAtDeadline) { + add(whenDue(id)) + } + if (isNotifyAfterDeadline) { + add(whenOverdue(id)) + } + } + } } } diff --git a/app/src/main/java/com/todoroo/astrid/service/Upgrader.kt b/app/src/main/java/com/todoroo/astrid/service/Upgrader.kt index d6378a0cb..933256a4c 100644 --- a/app/src/main/java/com/todoroo/astrid/service/Upgrader.kt +++ b/app/src/main/java/com/todoroo/astrid/service/Upgrader.kt @@ -16,13 +16,27 @@ import org.tasks.caldav.iCalendar import org.tasks.caldav.iCalendar.Companion.fromVtodo import org.tasks.caldav.iCalendar.Companion.order import org.tasks.caldav.iCalendar.Companion.parent -import org.tasks.data.* +import org.tasks.data.CaldavDao +import org.tasks.data.CaldavTask +import org.tasks.data.CaldavTaskContainer +import org.tasks.data.FilterDao +import org.tasks.data.GoogleTaskAccount +import org.tasks.data.GoogleTaskDao +import org.tasks.data.GoogleTaskListDao +import org.tasks.data.Location +import org.tasks.data.LocationDao +import org.tasks.data.Tag +import org.tasks.data.TagDao +import org.tasks.data.TagData +import org.tasks.data.TagDataDao +import org.tasks.data.TaskAttachmentDao +import org.tasks.data.UpgraderDao +import org.tasks.data.UserActivityDao import org.tasks.preferences.DefaultFilterProvider import org.tasks.preferences.Preferences import org.tasks.widget.AppWidgetManager import org.tasks.widget.WidgetPreferences import java.io.File -import java.util.* import javax.inject.Inject class Upgrader @Inject constructor( @@ -322,7 +336,7 @@ class Upgrader @Inject constructor( private const val V5_3_0 = 491 private const val V6_0_beta_1 = 522 private const val V6_0_beta_2 = 523 - private const val V6_4 = 546 + const val V6_4 = 546 private const val V6_7 = 585 private const val V6_8_1 = 607 private const val V6_9 = 608 @@ -337,6 +351,7 @@ class Upgrader @Inject constructor( const val V9_7_3 = 90704 const val V10_0_2 = 100012 const val V11_13 = 111300 + const val V12_3 = 120300 @JvmStatic fun getAndroidColor(context: Context, index: Int): Int { diff --git a/app/src/main/java/com/todoroo/astrid/ui/ReminderControlSet.kt b/app/src/main/java/com/todoroo/astrid/ui/ReminderControlSet.kt index 71f8c12eb..67665e63c 100644 --- a/app/src/main/java/com/todoroo/astrid/ui/ReminderControlSet.kt +++ b/app/src/main/java/com/todoroo/astrid/ui/ReminderControlSet.kt @@ -15,18 +15,23 @@ import android.view.ViewGroup import android.widget.LinearLayout import android.widget.TextView import androidx.annotation.StringRes -import com.todoroo.andlib.utility.DateUtilities import dagger.hilt.android.AndroidEntryPoint import org.tasks.R import org.tasks.activities.DateAndTimePickerActivity import org.tasks.data.Alarm +import org.tasks.data.Alarm.Companion.TYPE_DATE_TIME +import org.tasks.data.Alarm.Companion.TYPE_RANDOM +import org.tasks.data.Alarm.Companion.TYPE_REL_END +import org.tasks.data.Alarm.Companion.TYPE_REL_START +import org.tasks.data.Alarm.Companion.whenDue +import org.tasks.data.Alarm.Companion.whenOverdue +import org.tasks.data.Alarm.Companion.whenStarted import org.tasks.databinding.ControlSetRemindersBinding import org.tasks.date.DateTimeUtils import org.tasks.dialogs.DialogBuilder import org.tasks.dialogs.MyTimePickerDialog -import org.tasks.locale.Locale +import org.tasks.reminders.AlarmToString import org.tasks.ui.TaskEditControlFragment -import java.util.* import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -38,9 +43,9 @@ import javax.inject.Inject @AndroidEntryPoint class ReminderControlSet : TaskEditControlFragment() { @Inject lateinit var activity: Activity - @Inject lateinit var locale: Locale @Inject lateinit var dialogBuilder: DialogBuilder - + @Inject lateinit var alarmToString: AlarmToString + private lateinit var alertContainer: LinearLayout private lateinit var mode: TextView @@ -53,15 +58,6 @@ class ReminderControlSet : TaskEditControlFragment() { viewModel.ringFiveTimes!! -> setRingMode(1) else -> setRingMode(0) } - if (viewModel.whenStart!!) { - addStart() - } - if (viewModel.whenDue!!) { - addDue() - } - if (viewModel.whenOverdue!!) { - addOverdue() - } if (viewModel.reminderPeriod!! > 0) { addRandomReminder(viewModel.reminderPeriod!!) } @@ -101,11 +97,16 @@ class ReminderControlSet : TaskEditControlFragment() { private fun addAlarm(selected: String) { when (selected) { - getString(R.string.when_started) -> addStart() - getString(R.string.when_due) -> addDue() - getString(R.string.when_overdue) -> addOverdue() - getString(R.string.randomly) -> addRandomReminder(TimeUnit.DAYS.toMillis(14)) - getString(R.string.pick_a_date_and_time) -> addNewAlarm() + getString(R.string.when_started) -> + addAlarmRow(whenStarted(viewModel.task?.id ?: 0)) + getString(R.string.when_due) -> + addAlarmRow(whenDue(viewModel.task?.id ?: 0)) + getString(R.string.when_overdue) -> + addAlarmRow(whenOverdue(viewModel.task?.id ?: 0)) + getString(R.string.randomly) -> + addRandomReminder(TimeUnit.DAYS.toMillis(14)) + getString(R.string.pick_a_date_and_time) -> + addNewAlarm() } } @@ -142,7 +143,7 @@ class ReminderControlSet : TaskEditControlFragment() { if (requestCode == REQUEST_NEW_ALARM) { if (resultCode == Activity.RESULT_OK) { val timestamp = data!!.getLongExtra(MyTimePickerDialog.EXTRA_TIMESTAMP, 0L) - if (viewModel.selectedAlarms?.any { timestamp == it.time } == false) { + if (viewModel.selectedAlarms?.any { it.type == TYPE_DATE_TIME && timestamp == it.time } == false) { val alarm = Alarm(viewModel.task?.id ?: 0, timestamp) viewModel.selectedAlarms?.add(alarm) addAlarmRow(alarm) @@ -154,8 +155,13 @@ class ReminderControlSet : TaskEditControlFragment() { } private fun addAlarmRow(alarm: Alarm) { - addAlarmRow(DateUtilities.getLongDateStringWithTime(alarm.time, locale.locale)) { - viewModel.selectedAlarms?.removeIf { it.time == alarm.time } + addAlarmRow(alarm) { + viewModel.selectedAlarms?.removeIf { + it.type == alarm.type && + it.time == alarm.time && + it.repeat == alarm.repeat && + it.interval == alarm.interval + } } } @@ -166,16 +172,17 @@ class ReminderControlSet : TaskEditControlFragment() { startActivityForResult(intent, REQUEST_NEW_ALARM) } - private fun addAlarmRow(text: String, onRemove: View.OnClickListener): View { + private fun addAlarmRow(alarm: Alarm, onRemove: View.OnClickListener): View { val alertItem = requireActivity().layoutInflater.inflate(R.layout.alarm_edit_row, null) alertContainer.addView(alertItem) - addAlarmRow(alertItem, text, onRemove) + addAlarmRow(alertItem, alarm, onRemove) return alertItem } - private fun addAlarmRow(alertItem: View, text: String, onRemove: View.OnClickListener?) { + private fun addAlarmRow(alertItem: View, alarm: Alarm, onRemove: View.OnClickListener?) { val display = alertItem.findViewById(R.id.alarm_string) - display.text = text + viewModel.selectedAlarms?.add(alarm) + display.text = alarmToString.toString(alarm) alertItem .findViewById(R.id.clear) .setOnClickListener { v: View? -> @@ -187,13 +194,13 @@ class ReminderControlSet : TaskEditControlFragment() { private val options: List get() { val options: MutableList = ArrayList() - if (viewModel.whenStart != true) { + if (viewModel.selectedAlarms?.find { it.type == TYPE_REL_START && it.time == 0L } == null) { options.add(getString(R.string.when_started)) } - if (viewModel.whenDue != true) { + if (viewModel.selectedAlarms?.find { it.type == TYPE_REL_END && it.time == 0L } == null) { options.add(getString(R.string.when_due)) } - if (viewModel.whenOverdue != true) { + if (viewModel.selectedAlarms?.find { it.type == TYPE_REL_END && it.time == TimeUnit.HOURS.toMillis(24) } == null) { options.add(getString(R.string.when_overdue)) } if (randomControlSet == null) { @@ -203,29 +210,8 @@ class ReminderControlSet : TaskEditControlFragment() { return options } - private fun addStart() { - viewModel.whenStart = true - addAlarmRow(getString(R.string.when_started)) { - viewModel.whenStart = false - } - } - - private fun addDue() { - viewModel.whenDue = true - addAlarmRow(getString(R.string.when_due)) { - viewModel.whenDue = false - } - } - - private fun addOverdue() { - viewModel.whenOverdue = true - addAlarmRow(getString(R.string.when_overdue)) { - viewModel.whenOverdue = false - } - } - private fun addRandomReminder(reminderPeriod: Long) { - val alarmRow = addAlarmRow(getString(R.string.randomly_once) + " ") { + val alarmRow = addAlarmRow(Alarm(viewModel.task?.id ?: 0, 0, TYPE_RANDOM)) { viewModel.reminderPeriod = 0 randomControlSet = null } diff --git a/app/src/main/java/org/tasks/backup/TasksJsonImporter.kt b/app/src/main/java/org/tasks/backup/TasksJsonImporter.kt index 60cd01c59..b2418c7de 100644 --- a/app/src/main/java/org/tasks/backup/TasksJsonImporter.kt +++ b/app/src/main/java/org/tasks/backup/TasksJsonImporter.kt @@ -7,13 +7,30 @@ import android.os.Handler import com.google.gson.Gson import com.google.gson.JsonObject import com.todoroo.astrid.dao.TaskDao +import com.todoroo.astrid.data.Task +import com.todoroo.astrid.service.TaskCreator.Companion.getDefaultAlarms import com.todoroo.astrid.service.TaskMover import com.todoroo.astrid.service.Upgrader +import com.todoroo.astrid.service.Upgrader.Companion.V12_3 +import com.todoroo.astrid.service.Upgrader.Companion.V6_4 import com.todoroo.astrid.service.Upgrader.Companion.getAndroidColor import org.tasks.LocalBroadcastManager import org.tasks.R -import org.tasks.data.* +import org.tasks.data.AlarmDao +import org.tasks.data.CaldavDao +import org.tasks.data.FilterDao +import org.tasks.data.Geofence +import org.tasks.data.GoogleTaskDao +import org.tasks.data.GoogleTaskListDao +import org.tasks.data.LocationDao import org.tasks.data.Place.Companion.newPlace +import org.tasks.data.Tag +import org.tasks.data.TagDao +import org.tasks.data.TagData +import org.tasks.data.TagDataDao +import org.tasks.data.TaskAttachmentDao +import org.tasks.data.TaskListMetadataDao +import org.tasks.data.UserActivityDao import org.tasks.preferences.Preferences import timber.log.Timber import java.io.FileNotFoundException @@ -136,9 +153,19 @@ class TasksJsonImporter @Inject constructor( alarm.task = taskId alarmDao.insert(alarm) } + if (version < V12_3) { + task.defaultReminders(task.ringFlags) + alarmDao.insert(task.getDefaultAlarms()) + task.ringFlags = when { + task.isNotifyModeFive -> Task.NOTIFY_MODE_FIVE + task.isNotifyModeNonstop -> Task.NOTIFY_MODE_NONSTOP + else -> 0 + } + taskDao.save(task) + } for (comment in backup.comments) { comment.targetId = taskUuid - if (version < 546) { + if (version < V6_4) { comment.convertPictureUri() } userActivityDao.createNew(comment) @@ -177,7 +204,7 @@ class TasksJsonImporter @Inject constructor( } backup.attachments?.forEach { attachment -> attachment.taskId = taskUuid - if (version < 546) { + if (version < V6_4) { attachment.convertPathUri() } taskAttachmentDao.insert(attachment) diff --git a/app/src/main/java/org/tasks/data/Alarm.kt b/app/src/main/java/org/tasks/data/Alarm.kt index 97bf6d8c3..b64734217 100644 --- a/app/src/main/java/org/tasks/data/Alarm.kt +++ b/app/src/main/java/org/tasks/data/Alarm.kt @@ -6,6 +6,8 @@ import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.Ignore import androidx.room.PrimaryKey +import org.tasks.time.DateTimeUtils.printTimestamp +import java.util.concurrent.TimeUnit @Entity(tableName = "alarms") class Alarm : Parcelable { @@ -21,6 +23,15 @@ class Alarm : Parcelable { @ColumnInfo(name = "time") var time: Long = 0 + @ColumnInfo(name = "type", defaultValue = "0") + var type: Int = 0 + + @ColumnInfo(name = "repeat", defaultValue = "0") + var repeat: Int = 0 + + @ColumnInfo(name = "interval", defaultValue = "0") + var interval: Long = 0 + constructor() @Ignore @@ -28,25 +39,47 @@ class Alarm : Parcelable { id = parcel.readLong() task = parcel.readLong() time = parcel.readLong() + type = parcel.readInt() + repeat = parcel.readInt() + interval = parcel.readLong() } @Ignore - constructor(task: Long, time: Long) { + constructor(task: Long, time: Long, type: Int = 0, repeat: Int = 0, interval: Long = 0) { this.task = task this.time = time + this.type = type + this.repeat = repeat + this.interval = interval + } + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeLong(id) + parcel.writeLong(task) + parcel.writeLong(time) + parcel.writeInt(type) + parcel.writeInt(repeat) + parcel.writeLong(interval) } + override fun describeContents() = 0 + override fun toString(): String { - return "Alarm(id=$id, task=$task, time=$time)" + return "Alarm(id=$id, task=$task, time=${printTimestamp(time)}, type=$type, repeat=$repeat, interval=$interval)" } override fun equals(other: Any?): Boolean { if (this === other) return true - if (other !is Alarm) return false + if (javaClass != other?.javaClass) return false + + other as Alarm if (id != other.id) return false if (task != other.task) return false if (time != other.time) return false + if (type != other.type) return false + if (repeat != other.repeat) return false + if (interval != other.interval) return false return true } @@ -55,20 +88,30 @@ class Alarm : Parcelable { var result = id.hashCode() result = 31 * result + task.hashCode() result = 31 * result + time.hashCode() + result = 31 * result + type + result = 31 * result + repeat + result = 31 * result + interval.hashCode() return result } - override fun writeToParcel(parcel: Parcel, flags: Int) { - parcel.writeLong(id) - parcel.writeLong(task) - parcel.writeLong(time) - } + companion object { + const val TYPE_DATE_TIME = 0 + const val TYPE_REL_START = 1 + const val TYPE_REL_END = 2 + const val TYPE_RANDOM = 3 - override fun describeContents() = 0 + fun whenStarted(task: Long) = Alarm(task, 0, TYPE_REL_START) + + fun whenDue(task: Long) = Alarm(task, 0, TYPE_REL_END) + + fun whenOverdue(task: Long) = + Alarm(task, TimeUnit.DAYS.toMillis(1), TYPE_REL_END, 6, TimeUnit.DAYS.toMillis(1)) - companion object CREATOR : Parcelable.Creator { - override fun createFromParcel(parcel: Parcel) = Alarm(parcel) + @JvmField + val CREATOR = object : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel) = Alarm(parcel) - override fun newArray(size: Int): Array = arrayOfNulls(size) + override fun newArray(size: Int): Array = arrayOfNulls(size) + } } } \ No newline at end of file diff --git a/app/src/main/java/org/tasks/data/AlarmDao.kt b/app/src/main/java/org/tasks/data/AlarmDao.kt index 7efe410ac..775db3d73 100644 --- a/app/src/main/java/org/tasks/data/AlarmDao.kt +++ b/app/src/main/java/org/tasks/data/AlarmDao.kt @@ -8,17 +8,26 @@ import com.todoroo.astrid.data.Task @Dao interface AlarmDao { - @Query("SELECT alarms.* FROM alarms INNER JOIN tasks ON tasks._id = alarms.task " - + "WHERE tasks.completed = 0 AND tasks.deleted = 0 AND tasks.lastNotified < alarms.time " - + "ORDER BY time ASC") + @Query(""" +SELECT alarms.* +FROM alarms + INNER JOIN tasks ON tasks._id = alarms.task +WHERE tasks.completed = 0 + AND tasks.deleted = 0 +""") suspend fun getActiveAlarms(): List - @Query("SELECT alarms.* FROM alarms INNER JOIN tasks ON tasks._id = alarms.task " - + "WHERE tasks._id = :taskId AND tasks.completed = 0 AND tasks.deleted = 0 AND tasks.lastNotified < alarms.time " - + "ORDER BY time ASC") + @Query(""" +SELECT alarms.* +FROM alarms + INNER JOIN tasks ON tasks._id = alarms.task +WHERE tasks._id = :taskId + AND tasks.completed = 0 + AND tasks.deleted = 0 +""") suspend fun getActiveAlarms(taskId: Long): List - @Query("SELECT * FROM alarms WHERE task = :taskId ORDER BY time ASC") + @Query("SELECT * FROM alarms WHERE task = :taskId") suspend fun getAlarms(taskId: Long): List @Delete diff --git a/app/src/main/java/org/tasks/db/Migrations.kt b/app/src/main/java/org/tasks/db/Migrations.kt index 4406d650e..898d9238a 100644 --- a/app/src/main/java/org/tasks/db/Migrations.kt +++ b/app/src/main/java/org/tasks/db/Migrations.kt @@ -4,8 +4,14 @@ import android.database.sqlite.SQLiteException import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.todoroo.astrid.api.FilterListItem.NO_ORDER +import com.todoroo.astrid.data.Task.Companion.NOTIFY_AFTER_DEADLINE +import com.todoroo.astrid.data.Task.Companion.NOTIFY_AT_DEADLINE +import com.todoroo.astrid.data.Task.Companion.NOTIFY_AT_START +import org.tasks.data.Alarm.Companion.TYPE_REL_END +import org.tasks.data.Alarm.Companion.TYPE_REL_START import org.tasks.data.CaldavAccount.Companion.SERVER_UNKNOWN import timber.log.Timber +import java.util.concurrent.TimeUnit.HOURS object Migrations { private val MIGRATION_35_36: Migration = object : Migration(35, 36) { @@ -404,6 +410,26 @@ object Migrations { } } + private val MIGRATION_80_81 = object : Migration(80, 81) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("ALTER TABLE `alarms` ADD COLUMN `type` INTEGER NOT NULL DEFAULT 0") + database.execSQL("ALTER TABLE `alarms` ADD COLUMN `repeat` INTEGER NOT NULL DEFAULT 0") + database.execSQL("ALTER TABLE `alarms` ADD COLUMN `interval` INTEGER NOT NULL DEFAULT 0") + database.execSQL( + "INSERT INTO `alarms` (`task`, `time`, `type`) SELECT `_id`, 0, $TYPE_REL_START FROM `tasks` WHERE `hideUntil` > 0 AND `notificationFlags` | $NOTIFY_AT_START" + ) + database.execSQL( + "INSERT INTO `alarms` (`task`, `time`, `type`) SELECT `_id`, 0, $TYPE_REL_END FROM `tasks` WHERE `dueDate` > 0 AND `notificationFlags` | $NOTIFY_AT_DEADLINE" + ) + database.execSQL( + "INSERT INTO `alarms` (`task`, `time`, `type`, `repeat`, `interval`) SELECT `_id`, ${HOURS.toMillis(24)}, $TYPE_REL_END, 6, ${HOURS.toMillis(24)} FROM `tasks` WHERE `dueDate` > 0 AND `notificationFlags` | $NOTIFY_AFTER_DEADLINE" + ) + database.execSQL( + "UPDATE `tasks` SET `notificationFlags` = `notificationFlags` & ~$NOTIFY_AT_START & ~$NOTIFY_AT_DEADLINE & ~$NOTIFY_AFTER_DEADLINE" + ) + } + } + val MIGRATIONS = arrayOf( MIGRATION_35_36, MIGRATION_36_37, @@ -441,6 +467,7 @@ object Migrations { MIGRATION_77_78, MIGRATION_78_79, MIGRATION_79_80, + MIGRATION_80_81, ) private fun noop(from: Int, to: Int): Migration = object : Migration(from, to) { diff --git a/app/src/main/java/org/tasks/jobs/AlarmEntry.java b/app/src/main/java/org/tasks/jobs/AlarmEntry.java index a84e365a5..8a9e94649 100644 --- a/app/src/main/java/org/tasks/jobs/AlarmEntry.java +++ b/app/src/main/java/org/tasks/jobs/AlarmEntry.java @@ -1,8 +1,10 @@ package org.tasks.jobs; import static org.tasks.time.DateTimeUtils.currentTimeMillis; +import static org.tasks.time.DateTimeUtils.printTimestamp; import com.todoroo.astrid.reminders.ReminderService; + import org.tasks.notifications.Notification; public class AlarmEntry implements NotificationQueueEntry { @@ -11,10 +13,6 @@ public class AlarmEntry implements NotificationQueueEntry { private final long taskId; private final long time; - public AlarmEntry(org.tasks.data.Alarm alarm) { - this(alarm.getId(), alarm.getTask(), alarm.getTime()); - } - public AlarmEntry(long alarmId, long taskId, Long time) { this.alarmId = alarmId; this.taskId = taskId; @@ -70,6 +68,6 @@ public class AlarmEntry implements NotificationQueueEntry { @Override public String toString() { - return "AlarmEntry{" + "alarmId=" + alarmId + ", taskId=" + taskId + ", time=" + time + '}'; + return "AlarmEntry{" + "alarmId=" + alarmId + ", taskId=" + taskId + ", time=" + printTimestamp(time) + '}'; } } diff --git a/app/src/main/java/org/tasks/jobs/NotificationQueue.kt b/app/src/main/java/org/tasks/jobs/NotificationQueue.kt index 5b62f19d3..d85ce0f0e 100644 --- a/app/src/main/java/org/tasks/jobs/NotificationQueue.kt +++ b/app/src/main/java/org/tasks/jobs/NotificationQueue.kt @@ -11,8 +11,14 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class NotificationQueue @Inject constructor(private val preferences: Preferences, private val workManager: WorkManager) { - private val jobs = TreeMultimap.create(Ordering.natural(), Comparator { l: NotificationQueueEntry, r: NotificationQueueEntry -> Ints.compare(l.hashCode(), r.hashCode()) }) +class NotificationQueue @Inject constructor( + private val preferences: Preferences, + private val workManager: WorkManager +) { + private val jobs = + TreeMultimap.create(Ordering.natural()) { l, r -> + Ints.compare(l.hashCode(), r.hashCode()) + } @Synchronized fun add(entry: T) = add(listOf(entry)) diff --git a/app/src/main/java/org/tasks/jobs/ReminderEntry.java b/app/src/main/java/org/tasks/jobs/ReminderEntry.java index f5a39b610..4d7370dea 100644 --- a/app/src/main/java/org/tasks/jobs/ReminderEntry.java +++ b/app/src/main/java/org/tasks/jobs/ReminderEntry.java @@ -1,6 +1,7 @@ package org.tasks.jobs; import static org.tasks.time.DateTimeUtils.currentTimeMillis; +import static org.tasks.time.DateTimeUtils.printTimestamp; import org.tasks.notifications.Notification; @@ -65,6 +66,6 @@ public class ReminderEntry implements NotificationQueueEntry { @Override public String toString() { - return "ReminderEntry{" + "taskId=" + taskId + ", time=" + time + ", type=" + type + '}'; + return "ReminderEntry{" + "taskId=" + taskId + ", time=" + printTimestamp(time) + ", type=" + type + '}'; } } diff --git a/app/src/main/java/org/tasks/reminders/AlarmToString.kt b/app/src/main/java/org/tasks/reminders/AlarmToString.kt new file mode 100644 index 000000000..e777fb297 --- /dev/null +++ b/app/src/main/java/org/tasks/reminders/AlarmToString.kt @@ -0,0 +1,76 @@ +package org.tasks.reminders + +import android.content.Context +import com.todoroo.andlib.utility.DateUtilities +import dagger.hilt.android.qualifiers.ApplicationContext +import org.tasks.R +import org.tasks.data.Alarm +import org.tasks.locale.Locale +import java.util.concurrent.TimeUnit +import javax.inject.Inject +import kotlin.math.absoluteValue + +class AlarmToString @Inject constructor( + @ApplicationContext context: Context, + var locale: Locale, +) { + private val resources = context.resources + + fun toString(alarm: Alarm): String { + val reminder = when (alarm.type) { + Alarm.TYPE_REL_START -> + if (alarm.time == 0L) { + resources.getString(R.string.when_started) + } else { + val res = if (alarm.time < 0) { + R.string.alarm_before_start + } else { + R.string.alarm_after_start + } + resources.getString(res, getDurationString(alarm.time)) + } + Alarm.TYPE_REL_END -> + if (alarm.time == 0L) { + resources.getString(R.string.when_due) + } else { + val res = if (alarm.time < 0) { + R.string.alarm_before_due + } else { + R.string.alarm_after_due + } + resources.getString(res, getDurationString(alarm.time)) + } + Alarm.TYPE_RANDOM -> + resources.getString(R.string.randomly_once) + " " + else -> + DateUtilities.getLongDateStringWithTime(alarm.time, locale.locale) + } + return if (alarm.repeat > 0) { + val frequencyPlural = getDurationString(alarm.interval) + val count = alarm.repeat + val countString = resources.getQuantityString(R.plurals.repeat_times, count) + reminder + "\n" + resources.getString(R.string.repeats_plural_number_of_times, frequencyPlural, count, countString) + } else { + reminder + } + } + + private fun getDurationString(duration: Long): String { + val seconds = duration.absoluteValue + val day = TimeUnit.MILLISECONDS.toDays(seconds) + val hours = TimeUnit.MILLISECONDS.toHours(seconds) - day * 24 + val minute = + TimeUnit.MILLISECONDS.toMinutes(seconds) - TimeUnit.MILLISECONDS.toHours(seconds) * 60 + val result = ArrayList() + if (day > 0) { + result.add(resources.getQuantityString(R.plurals.repeat_n_days, day.toInt(), day.toInt())) + } + if (hours > 0) { + result.add(resources.getQuantityString(R.plurals.repeat_n_hours, hours.toInt(), hours.toInt())) + } + if (minute > 0) { + result.add(resources.getQuantityString(R.plurals.repeat_n_minutes, minute.toInt(), minute.toInt())) + } + return result.joinToString(" ") + } +} \ No newline at end of file diff --git a/app/src/main/java/org/tasks/time/DateTimeUtils.kt b/app/src/main/java/org/tasks/time/DateTimeUtils.kt index 023da168c..b09003112 100644 --- a/app/src/main/java/org/tasks/time/DateTimeUtils.kt +++ b/app/src/main/java/org/tasks/time/DateTimeUtils.kt @@ -24,6 +24,7 @@ object DateTimeUtils { MILLIS_PROVIDER = SYSTEM_MILLIS_PROVIDER } + @JvmStatic fun printTimestamp(timestamp: Long): String = if (BuildConfig.DEBUG) Date(timestamp).toString() else timestamp.toString() @@ -42,4 +43,7 @@ object DateTimeUtils { fun Long.millisOfDay(): Int = if (this > 0) toDateTime().millisOfDay else 0 fun Long.toDate(): net.fortuna.ical4j.model.Date? = this.toDateTime().toDate() + + fun Long.withMillisOfDay(millisOfDay: Int): Long = + if (this > 0) toDateTime().withMillisOfDay(millisOfDay).millis else 0 } \ No newline at end of file diff --git a/app/src/main/java/org/tasks/ui/TaskEditViewModel.kt b/app/src/main/java/org/tasks/ui/TaskEditViewModel.kt index d878cc0a6..e2f93809d 100644 --- a/app/src/main/java/org/tasks/ui/TaskEditViewModel.kt +++ b/app/src/main/java/org/tasks/ui/TaskEditViewModel.kt @@ -35,6 +35,11 @@ import org.tasks.R import org.tasks.Strings import org.tasks.calendars.CalendarEventProvider import org.tasks.data.Alarm +import org.tasks.data.Alarm.Companion.TYPE_REL_END +import org.tasks.data.Alarm.Companion.TYPE_REL_START +import org.tasks.data.Alarm.Companion.whenDue +import org.tasks.data.Alarm.Companion.whenOverdue +import org.tasks.data.Alarm.Companion.whenStarted import org.tasks.data.CaldavDao import org.tasks.data.CaldavTask import org.tasks.data.GoogleTask @@ -74,7 +79,8 @@ class TaskEditViewModel @Inject constructor( private val googleTaskDao: GoogleTaskDao, private val caldavDao: CaldavDao, private val taskCompleter: TaskCompleter, - private val alarmService: AlarmService) : ViewModel() { + private val alarmService: AlarmService +) : ViewModel() { val cleared = MutableLiveData>() @@ -90,7 +96,22 @@ class TaskEditViewModel @Inject constructor( originalList = list originalLocation = location originalTags = tags.toImmutableList() - originalAlarms = alarms.toList().toImmutableSet() + originalAlarms = + if (isNew) { + ArrayList().apply { + if (task.isNotifyAtStart) { + add(whenStarted(0)) + } + if (task.isNotifyAtDeadline) { + add(whenDue(0)) + } + if (task.isNotifyAfterDeadline) { + add(whenOverdue(0)) + } + } + } else { + alarms + }.toImmutableSet() if (isNew && permissionChecker.canAccessCalendars()) { originalCalendar = preferences.defaultCalendar } @@ -250,17 +271,8 @@ class TaskEditViewModel @Inject constructor( var selectedAlarms: HashSet? = null - var whenStart: Boolean? = null - get() = field ?: (task?.reminderFlags?.and(Task.NOTIFY_AT_START) ?: 0 > 0) - - var whenDue: Boolean? = null - get() = field ?: (task?.reminderFlags?.and(Task.NOTIFY_AT_DEADLINE) ?: 0 > 0) - - var whenOverdue: Boolean? = null - get() = field ?: (task?.reminderFlags?.and(Task.NOTIFY_AFTER_DEADLINE) ?: 0 > 0) - var ringNonstop: Boolean? = null - get() = field ?: (task?.reminderFlags?.and(Task.NOTIFY_MODE_NONSTOP) ?: 0 > 0) + get() = field ?: (task?.ringFlags?.and(Task.NOTIFY_MODE_NONSTOP) ?: 0 > 0) set(value) { field = value if (value == true) { @@ -269,7 +281,7 @@ class TaskEditViewModel @Inject constructor( } var ringFiveTimes:Boolean? = null - get() = field ?: (task?.reminderFlags?.and(Task.NOTIFY_MODE_FIVE) ?: 0 > 0) + get() = field ?: (task?.ringFlags?.and(Task.NOTIFY_MODE_FIVE) ?: 0 > 0) set(value) { field = value if (value == true) { @@ -304,7 +316,7 @@ class TaskEditViewModel @Inject constructor( originalTags?.toHashSet() != selectedTags?.toHashSet() || newSubtasks.isNotEmpty() || it.reminderPeriod != reminderPeriod || - it.reminderFlags != getReminderFlags() || + it.ringFlags != getRingFlags() || originalAlarms != selectedAlarms } ?: false @@ -329,7 +341,7 @@ class TaskEditViewModel @Inject constructor( it.repeatUntil = repeatUntil!! it.elapsedSeconds = elapsedSeconds!! it.estimatedSeconds = estimatedSeconds!! - it.reminderFlags = getReminderFlags() + it.ringFlags = getRingFlags() it.reminderPeriod = reminderPeriod!! applyCalendarChanges() @@ -398,13 +410,23 @@ class TaskEditViewModel @Inject constructor( } } - if (selectedAlarms != originalAlarms) { - alarmService.synchronizeAlarms(it.id, selectedAlarms!!) - it.modificationDate = now() + if (!it.hasStartDate()) { + selectedAlarms?.removeIf { a -> a.type == TYPE_REL_START } + } + if (!it.hasDueDate()) { + selectedAlarms?.removeIf { a -> a.type == TYPE_REL_END } } taskDao.save(it, null) + if ( + selectedAlarms != originalAlarms || + (isNew && selectedAlarms?.isNotEmpty() == true) + ) { + alarmService.synchronizeAlarms(it.id, selectedAlarms!!) + it.modificationDate = now() + } + if (it.isCompleted != completed!!) { taskCompleter.setComplete(it, completed!!) } @@ -431,17 +453,8 @@ class TaskEditViewModel @Inject constructor( } } - private fun getReminderFlags(): Int { + private fun getRingFlags(): Int { var value = 0 - if (whenStart == true) { - value = value or Task.NOTIFY_AT_START - } - if (whenDue == true) { - value = value or Task.NOTIFY_AT_DEADLINE - } - if (whenOverdue == true) { - value = value or Task.NOTIFY_AFTER_DEADLINE - } value = value and (Task.NOTIFY_MODE_FIVE or Task.NOTIFY_MODE_NONSTOP).inv() if (ringNonstop == true) { value = value or Task.NOTIFY_MODE_NONSTOP diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 53ecc771f..ec1732e41 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -716,4 +716,8 @@ File %1$s contained %2$s.\n\n Move completed tasks to bottom Sort by completion date %d tasks completed + %s before start + %s after start + %s before due + %s after due