diff --git a/app/schemas/io.timelimit.android.data.RoomDatabase/40.json b/app/schemas/io.timelimit.android.data.RoomDatabase/40.json new file mode 100644 index 0000000..e5edeb0 --- /dev/null +++ b/app/schemas/io.timelimit.android.data.RoomDatabase/40.json @@ -0,0 +1,1264 @@ +{ + "formatVersion": 1, + "database": { + "version": 40, + "identityHash": "4b90ef1fc32072c93f68fecee1bd7864", + "entities": [ + { + "tableName": "user", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `password` TEXT NOT NULL, `second_password_salt` TEXT NOT NULL, `type` TEXT NOT NULL, `timezone` TEXT NOT NULL, `disable_limits_until` INTEGER NOT NULL, `mail` TEXT NOT NULL, `current_device` TEXT NOT NULL, `category_for_not_assigned_apps` TEXT NOT NULL, `relax_primary_device` INTEGER NOT NULL, `mail_notification_flags` INTEGER NOT NULL, `blocked_times` TEXT NOT NULL, `flags` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondPasswordSalt", + "columnName": "second_password_salt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timeZone", + "columnName": "timezone", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disableLimitsUntil", + "columnName": "disable_limits_until", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mail", + "columnName": "mail", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentDevice", + "columnName": "current_device", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryForNotAssignedApps", + "columnName": "category_for_not_assigned_apps", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relaxPrimaryDevice", + "columnName": "relax_primary_device", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mailNotificationFlags", + "columnName": "mail_notification_flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "obsoleteBlockedTimes", + "columnName": "blocked_times", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "device", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `model` TEXT NOT NULL, `added_at` INTEGER NOT NULL, `current_user_id` TEXT NOT NULL, `apps_version` TEXT NOT NULL, `network_time` TEXT NOT NULL, `current_protection_level` TEXT NOT NULL, `highest_permission_level` TEXT NOT NULL, `current_usage_stats_permission` TEXT NOT NULL, `highest_usage_stats_permission` TEXT NOT NULL, `current_notification_access_permission` TEXT NOT NULL, `highest_notification_access_permission` TEXT NOT NULL, `current_app_version` INTEGER NOT NULL, `highest_app_version` INTEGER NOT NULL, `tried_disabling_device_admin` INTEGER NOT NULL, `did_reboot` INTEGER NOT NULL, `had_manipulation` INTEGER NOT NULL, `had_manipulation_flags` INTEGER NOT NULL, `did_report_uninstall` INTEGER NOT NULL, `is_user_kept_signed_in` INTEGER NOT NULL, `show_device_connected` INTEGER NOT NULL, `default_user` TEXT NOT NULL, `default_user_timeout` INTEGER NOT NULL, `consider_reboot_manipulation` INTEGER NOT NULL, `current_overlay_permission` TEXT NOT NULL, `highest_overlay_permission` TEXT NOT NULL, `current_accessibility_service_permission` INTEGER NOT NULL, `was_accessibility_service_permission` INTEGER NOT NULL, `enable_activity_level_blocking` INTEGER NOT NULL, `q_or_later` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "added_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentUserId", + "columnName": "current_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installedAppsVersion", + "columnName": "apps_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "networkTime", + "columnName": "network_time", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentProtectionLevel", + "columnName": "current_protection_level", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "highestProtectionLevel", + "columnName": "highest_permission_level", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentUsageStatsPermission", + "columnName": "current_usage_stats_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "highestUsageStatsPermission", + "columnName": "highest_usage_stats_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentNotificationAccessPermission", + "columnName": "current_notification_access_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "highestNotificationAccessPermission", + "columnName": "highest_notification_access_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentAppVersion", + "columnName": "current_app_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "highestAppVersion", + "columnName": "highest_app_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "manipulationTriedDisablingDeviceAdmin", + "columnName": "tried_disabling_device_admin", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "manipulationDidReboot", + "columnName": "did_reboot", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hadManipulation", + "columnName": "had_manipulation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hadManipulationFlags", + "columnName": "had_manipulation_flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "didReportUninstall", + "columnName": "did_report_uninstall", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUserKeptSignedIn", + "columnName": "is_user_kept_signed_in", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showDeviceConnected", + "columnName": "show_device_connected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultUser", + "columnName": "default_user", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultUserTimeout", + "columnName": "default_user_timeout", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "considerRebootManipulation", + "columnName": "consider_reboot_manipulation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentOverlayPermission", + "columnName": "current_overlay_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "highestOverlayPermission", + "columnName": "highest_overlay_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessibilityServiceEnabled", + "columnName": "current_accessibility_service_permission", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wasAccessibilityServiceEnabled", + "columnName": "was_accessibility_service_permission", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enableActivityLevelBlocking", + "columnName": "enable_activity_level_blocking", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "qOrLater", + "columnName": "q_or_later", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "app", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`device_id` TEXT NOT NULL, `package_name` TEXT NOT NULL, `title` TEXT NOT NULL, `launchable` INTEGER NOT NULL, `recommendation` TEXT NOT NULL, PRIMARY KEY(`device_id`, `package_name`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "device_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isLaunchable", + "columnName": "launchable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recommendation", + "columnName": "recommendation", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "device_id", + "package_name" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_app_device_id", + "unique": false, + "columnNames": [ + "device_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_app_device_id` ON `${TABLE_NAME}` (`device_id`)" + }, + { + "name": "index_app_package_name", + "unique": false, + "columnNames": [ + "package_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_app_package_name` ON `${TABLE_NAME}` (`package_name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "category_app", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`category_id` TEXT NOT NULL, `package_name` TEXT NOT NULL, PRIMARY KEY(`category_id`, `package_name`))", + "fields": [ + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appSpecifierString", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "category_id", + "package_name" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_category_app_category_id", + "unique": false, + "columnNames": [ + "category_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_category_app_category_id` ON `${TABLE_NAME}` (`category_id`)" + }, + { + "name": "index_category_app_package_name", + "unique": false, + "columnNames": [ + "package_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_category_app_package_name` ON `${TABLE_NAME}` (`package_name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "category", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `child_id` TEXT NOT NULL, `title` TEXT NOT NULL, `blocked_times` TEXT NOT NULL, `extra_time` INTEGER NOT NULL, `extra_time_day` INTEGER NOT NULL, `temporarily_blocked` INTEGER NOT NULL, `temporarily_blocked_end_time` INTEGER NOT NULL, `base_version` TEXT NOT NULL, `apps_version` TEXT NOT NULL, `rules_version` TEXT NOT NULL, `usedtimes_version` TEXT NOT NULL, `tasks_version` TEXT NOT NULL DEFAULT '', `parent_category_id` TEXT NOT NULL, `block_all_notifications` INTEGER NOT NULL, `time_warnings` INTEGER NOT NULL, `min_battery_charging` INTEGER NOT NULL, `min_battery_mobile` INTEGER NOT NULL, `sort` INTEGER NOT NULL, `disable_limits_until` INTEGER NOT NULL, `flags` INTEGER NOT NULL DEFAULT 0, `block_notification_delay` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "childId", + "columnName": "child_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedMinutesInWeek", + "columnName": "blocked_times", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extraTimeInMillis", + "columnName": "extra_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "extraTimeDay", + "columnName": "extra_time_day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "temporarilyBlocked", + "columnName": "temporarily_blocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "temporarilyBlockedEndTime", + "columnName": "temporarily_blocked_end_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "baseVersion", + "columnName": "base_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "assignedAppsVersion", + "columnName": "apps_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timeLimitRulesVersion", + "columnName": "rules_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usedTimesVersion", + "columnName": "usedtimes_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tasksVersion", + "columnName": "tasks_version", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "parentCategoryId", + "columnName": "parent_category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockAllNotifications", + "columnName": "block_all_notifications", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeWarnings", + "columnName": "time_warnings", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minBatteryLevelWhileCharging", + "columnName": "min_battery_charging", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minBatteryLevelMobile", + "columnName": "min_battery_mobile", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disableLimitsUntil", + "columnName": "disable_limits_until", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "blockNotificationDelay", + "columnName": "block_notification_delay", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "used_time", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day_of_epoch` INTEGER NOT NULL, `used_time` INTEGER NOT NULL, `category_id` TEXT NOT NULL, `start_time_of_day` INTEGER NOT NULL, `end_time_of_day` INTEGER NOT NULL, PRIMARY KEY(`category_id`, `day_of_epoch`, `start_time_of_day`, `end_time_of_day`))", + "fields": [ + { + "fieldPath": "dayOfEpoch", + "columnName": "day_of_epoch", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedMillis", + "columnName": "used_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startTimeOfDay", + "columnName": "start_time_of_day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endTimeOfDay", + "columnName": "end_time_of_day", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "category_id", + "day_of_epoch", + "start_time_of_day", + "end_time_of_day" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "time_limit_rule", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `category_id` TEXT NOT NULL, `apply_to_extra_time_usage` INTEGER NOT NULL, `day_mask` INTEGER NOT NULL, `max_time` INTEGER NOT NULL, `start_minute_of_day` INTEGER NOT NULL, `end_minute_of_day` INTEGER NOT NULL, `session_duration_milliseconds` INTEGER NOT NULL, `session_pause_milliseconds` INTEGER NOT NULL, `per_day` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "applyToExtraTimeUsage", + "columnName": "apply_to_extra_time_usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dayMask", + "columnName": "day_mask", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maximumTimeInMillis", + "columnName": "max_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startMinuteOfDay", + "columnName": "start_minute_of_day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endMinuteOfDay", + "columnName": "end_minute_of_day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionDurationMilliseconds", + "columnName": "session_duration_milliseconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionPauseMilliseconds", + "columnName": "session_pause_milliseconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "perDay", + "columnName": "per_day", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "config", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "temporarily_allowed_app", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`device_id` TEXT NOT NULL, `package_name` TEXT NOT NULL, PRIMARY KEY(`device_id`, `package_name`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "device_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "device_id", + "package_name" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "pending_sync_action", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sequence_number` INTEGER NOT NULL, `action` TEXT NOT NULL, `integrity` TEXT NOT NULL, `scheduled_for_upload` INTEGER NOT NULL, `type` TEXT NOT NULL, `user_id` TEXT NOT NULL, PRIMARY KEY(`sequence_number`))", + "fields": [ + { + "fieldPath": "sequenceNumber", + "columnName": "sequence_number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encodedAction", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "integrity", + "columnName": "integrity", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scheduledForUpload", + "columnName": "scheduled_for_upload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sequence_number" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_pending_sync_action_scheduled_for_upload", + "unique": false, + "columnNames": [ + "scheduled_for_upload" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_sync_action_scheduled_for_upload` ON `${TABLE_NAME}` (`scheduled_for_upload`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "app_activity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`device_id` TEXT NOT NULL, `app_package_name` TEXT NOT NULL, `activity_class_name` TEXT NOT NULL, `activity_title` TEXT NOT NULL, PRIMARY KEY(`device_id`, `app_package_name`, `activity_class_name`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "device_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appPackageName", + "columnName": "app_package_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activityClassName", + "columnName": "activity_class_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "activity_title", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "device_id", + "app_package_name", + "activity_class_name" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "notification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` INTEGER NOT NULL, `id` TEXT NOT NULL, `first_notify_time` INTEGER NOT NULL, `dismissed` INTEGER NOT NULL, PRIMARY KEY(`type`, `id`))", + "fields": [ + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstNotifyTime", + "columnName": "first_notify_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDismissed", + "columnName": "dismissed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "type", + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "allowed_contact", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `phone` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phone", + "columnName": "phone", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "user_key", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `key` BLOB NOT NULL, `last_use` INTEGER NOT NULL, PRIMARY KEY(`user_id`), FOREIGN KEY(`user_id`) REFERENCES `user`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "key", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastUse", + "columnName": "last_use", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_user_key_key", + "unique": true, + "columnNames": [ + "key" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_user_key_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [ + { + "table": "user", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "session_duration", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`category_id` TEXT NOT NULL, `max_session_duration` INTEGER NOT NULL, `session_pause_duration` INTEGER NOT NULL, `start_minute_of_day` INTEGER NOT NULL, `end_minute_of_day` INTEGER NOT NULL, `last_usage` INTEGER NOT NULL, `last_session_duration` INTEGER NOT NULL, PRIMARY KEY(`category_id`, `max_session_duration`, `session_pause_duration`, `start_minute_of_day`, `end_minute_of_day`), FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxSessionDuration", + "columnName": "max_session_duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionPauseDuration", + "columnName": "session_pause_duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startMinuteOfDay", + "columnName": "start_minute_of_day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endMinuteOfDay", + "columnName": "end_minute_of_day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUsage", + "columnName": "last_usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSessionDuration", + "columnName": "last_session_duration", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "category_id", + "max_session_duration", + "session_pause_duration", + "start_minute_of_day", + "end_minute_of_day" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "session_duration_index_category_id", + "unique": false, + "columnNames": [ + "category_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `session_duration_index_category_id` ON `${TABLE_NAME}` (`category_id`)" + } + ], + "foreignKeys": [ + { + "table": "category", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "category_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "user_limit_login_category", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `category_id` TEXT NOT NULL, `pre_block_duration` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`user_id`), FOREIGN KEY(`user_id`) REFERENCES `user`(`id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "preBlockDuration", + "columnName": "pre_block_duration", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "columnNames": [ + "user_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "user_limit_login_category_index_category_id", + "unique": false, + "columnNames": [ + "category_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `user_limit_login_category_index_category_id` ON `${TABLE_NAME}` (`category_id`)" + } + ], + "foreignKeys": [ + { + "table": "user", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "category", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "category_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "category_network_id", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`category_id` TEXT NOT NULL, `network_item_id` TEXT NOT NULL, `hashed_network_id` TEXT NOT NULL, PRIMARY KEY(`category_id`, `network_item_id`), FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "networkItemId", + "columnName": "network_item_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hashedNetworkId", + "columnName": "hashed_network_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "category_id", + "network_item_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "category", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "category_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "child_task", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`task_id` TEXT NOT NULL, `category_id` TEXT NOT NULL, `task_title` TEXT NOT NULL, `extra_time_duration` INTEGER NOT NULL, `pending_request` INTEGER NOT NULL, `last_grant_timestamp` INTEGER NOT NULL, PRIMARY KEY(`task_id`), FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "taskId", + "columnName": "task_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "taskTitle", + "columnName": "task_title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extraTimeDuration", + "columnName": "extra_time_duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pendingRequest", + "columnName": "pending_request", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastGrantTimestamp", + "columnName": "last_grant_timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "task_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "category", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "category_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "category_time_warning", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`category_id` TEXT NOT NULL, `minutes` INTEGER NOT NULL, PRIMARY KEY(`category_id`, `minutes`), FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minutes", + "columnName": "minutes", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "category_id", + "minutes" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "category", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "category_id" + ], + "referencedColumns": [ + "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, '4b90ef1fc32072c93f68fecee1bd7864')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/Database.kt b/app/src/main/java/io/timelimit/android/data/Database.kt index ac46dfe..abccd7d 100644 --- a/app/src/main/java/io/timelimit/android/data/Database.kt +++ b/app/src/main/java/io/timelimit/android/data/Database.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -40,6 +40,7 @@ interface Database { fun userLimitLoginCategoryDao(): UserLimitLoginCategoryDao fun categoryNetworkId(): CategoryNetworkIdDao fun childTasks(): ChildTaskDao + fun timeWarning(): CategoryTimeWarningDao fun runInTransaction(block: () -> T): T fun runInUnobservedTransaction(block: () -> T): T diff --git a/app/src/main/java/io/timelimit/android/data/Migrations.kt b/app/src/main/java/io/timelimit/android/data/Migrations.kt index 04a0221..55f7009 100644 --- a/app/src/main/java/io/timelimit/android/data/Migrations.kt +++ b/app/src/main/java/io/timelimit/android/data/Migrations.kt @@ -280,4 +280,10 @@ object DatabaseMigrations { // nothing to do, there was just a new config item type added } } + + val MIGRATE_TO_V40 = object: Migration(39, 40) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE TABLE IF NOT EXISTS `category_time_warning` (`category_id` TEXT NOT NULL, `minutes` INTEGER NOT NULL, PRIMARY KEY(`category_id`, `minutes`), FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )") + } + } } diff --git a/app/src/main/java/io/timelimit/android/data/RoomDatabase.kt b/app/src/main/java/io/timelimit/android/data/RoomDatabase.kt index 4d8c55f..9986b43 100644 --- a/app/src/main/java/io/timelimit/android/data/RoomDatabase.kt +++ b/app/src/main/java/io/timelimit/android/data/RoomDatabase.kt @@ -50,8 +50,9 @@ import java.util.concurrent.TimeUnit SessionDuration::class, UserLimitLoginCategory::class, CategoryNetworkId::class, - ChildTask::class -], version = 39) + ChildTask::class, + CategoryTimeWarning::class +], version = 40) abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database { companion object { private val lock = Object() @@ -124,7 +125,8 @@ abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database DatabaseMigrations.MIGRATE_TO_V36, DatabaseMigrations.MIGRATE_TO_V37, DatabaseMigrations.MIGRATE_TO_V38, - DatabaseMigrations.MIGRATE_TO_V39 + DatabaseMigrations.MIGRATE_TO_V39, + DatabaseMigrations.MIGRATE_TO_V40 ) .setQueryExecutor(Threads.database) .addCallback(object: Callback() { diff --git a/app/src/main/java/io/timelimit/android/data/backup/DatabaseBackupLowlevel.kt b/app/src/main/java/io/timelimit/android/data/backup/DatabaseBackupLowlevel.kt index b0127a9..7ee82d9 100644 --- a/app/src/main/java/io/timelimit/android/data/backup/DatabaseBackupLowlevel.kt +++ b/app/src/main/java/io/timelimit/android/data/backup/DatabaseBackupLowlevel.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -45,6 +45,7 @@ object DatabaseBackupLowlevel { private const val USER_LIMIT_LOGIN_CATEGORY = "userLimitLoginCategory" private const val CATEGORY_NETWORK_ID = "categoryNetworkId" private const val CHILD_TASK = "childTask" + private const val CATEGORY_TIME_WARNINGS = "timeWarnings" fun outputAsBackupJson(database: Database, outputStream: OutputStream) { val writer = JsonWriter(OutputStreamWriter(outputStream, Charsets.UTF_8)) @@ -73,6 +74,14 @@ object DatabaseBackupLowlevel { writer.endArray() } + fun handleCollection(name: String, items: List) { + writer.name(name).beginArray() + + items.forEach { it.serialize(writer) } + + writer.endArray() + } + handleCollection(APP) {offset, pageSize -> database.app().getAppPageSync(offset, pageSize) } handleCollection(CATEGORY) {offset: Int, pageSize: Int -> database.category().getCategoryPageSync(offset, pageSize) } handleCollection(CATEGORY_APP) { offset, pageSize -> database.categoryApp().getCategoryAppPageSync(offset, pageSize) } @@ -94,6 +103,7 @@ object DatabaseBackupLowlevel { handleCollection(USER_LIMIT_LOGIN_CATEGORY) { offset, pageSize -> database.userLimitLoginCategoryDao().getAllowedContactPageSync(offset, pageSize) } handleCollection(CATEGORY_NETWORK_ID) { offset, pageSize -> database.categoryNetworkId().getPageSync(offset, pageSize) } handleCollection(CHILD_TASK) { offset, pageSize -> database.childTasks().getPageSync(offset, pageSize) } + handleCollection(CATEGORY_TIME_WARNINGS, database.timeWarning().getAllItemsSync()) writer.endObject().flush() } @@ -104,6 +114,7 @@ object DatabaseBackupLowlevel { var userLoginLimitCategories = emptyList() var categoryNetworkId = emptyList() var childTasks = emptyList() + var timeWarnings = emptyList() database.runInTransaction { database.deleteAllData() @@ -283,6 +294,19 @@ object DatabaseBackupLowlevel { reader.endArray() } + CATEGORY_TIME_WARNINGS -> { + reader.beginArray() + + mutableListOf().let { list -> + while (reader.hasNext()) { + list.add(CategoryTimeWarning.parse(reader)) + } + + timeWarnings = list + } + + reader.endArray() + } else -> reader.skipValue() } } @@ -291,6 +315,7 @@ object DatabaseBackupLowlevel { if (userLoginLimitCategories.isNotEmpty()) { database.userLimitLoginCategoryDao().addItemsSync(userLoginLimitCategories) } if (categoryNetworkId.isNotEmpty()) { database.categoryNetworkId().insertItemsSync(categoryNetworkId) } if (childTasks.isNotEmpty()) { database.childTasks().insertItemsSync(childTasks) } + if (timeWarnings.isNotEmpty()) { database.timeWarning().insertItemsSync(timeWarnings) } } } } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/dao/CategoryTimeWarningDao.kt b/app/src/main/java/io/timelimit/android/data/dao/CategoryTimeWarningDao.kt new file mode 100644 index 0000000..fbf6aec --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/dao/CategoryTimeWarningDao.kt @@ -0,0 +1,44 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.data.dao + +import androidx.lifecycle.LiveData +import androidx.room.* +import io.timelimit.android.data.model.CategoryTimeWarning + +@Dao +interface CategoryTimeWarningDao { + @Query("SELECT * FROM category_time_warning") + fun getAllItemsSync(): List + + @Query("SELECT * FROM category_time_warning WHERE category_id = :categoryId") + fun getItemsByCategoryIdSync(categoryId: String): List + + @Query("SELECT * FROM category_time_warning WHERE category_id = :categoryId") + fun getItemsByCategoryIdLive(categoryId: String): LiveData> + + @Insert + fun insertItemsSync(items: List) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insertItemIgnoreConflictSync(item: CategoryTimeWarning) + + @Query("DELETE FROM category_time_warning WHERE category_id = :categoryId AND minutes = :minutes") + fun deleteItem(categoryId: String, minutes: Int) + + @Query("DELETE FROM category_time_warning WHERE category_id = :categoryId") + fun deleteByCategoryIdSync(categoryId: String) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/invalidation/Tables.kt b/app/src/main/java/io/timelimit/android/data/invalidation/Tables.kt index d6fc2d9..cc707b7 100644 --- a/app/src/main/java/io/timelimit/android/data/invalidation/Tables.kt +++ b/app/src/main/java/io/timelimit/android/data/invalidation/Tables.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -33,7 +33,8 @@ enum class Table { User, UserKey, UserLimitLoginCategory, - CategoryNetworkId + CategoryNetworkId, + CategoryTimeWarning } object TableNames { @@ -54,6 +55,7 @@ object TableNames { const val USER_KEY = "user_key" const val USER_LIMIT_LOGIN_CATEGORY = "user_limit_login_category" const val CATEGORY_NETWORK_ID = "category_network_id" + const val CATEGORY_TIME_WARNING = "category_time_warning" } object TableUtil { @@ -75,6 +77,7 @@ object TableUtil { Table.UserKey -> TableNames.USER_KEY Table.UserLimitLoginCategory -> TableNames.USER_LIMIT_LOGIN_CATEGORY Table.CategoryNetworkId -> TableNames.CATEGORY_NETWORK_ID + Table.CategoryTimeWarning -> TableNames.CATEGORY_TIME_WARNING } fun toEnum(value: String): Table = when (value) { @@ -95,6 +98,7 @@ object TableUtil { TableNames.USER_KEY -> Table.UserKey TableNames.USER_LIMIT_LOGIN_CATEGORY -> Table.UserLimitLoginCategory TableNames.CATEGORY_NETWORK_ID -> Table.CategoryNetworkId + TableNames.CATEGORY_TIME_WARNING -> Table.CategoryTimeWarning else -> throw IllegalArgumentException() } } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/model/Category.kt b/app/src/main/java/io/timelimit/android/data/model/Category.kt index 323e716..0a4e6cd 100644 --- a/app/src/main/java/io/timelimit/android/data/model/Category.kt +++ b/app/src/main/java/io/timelimit/android/data/model/Category.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2021 Jonas Lochmann + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -263,15 +263,13 @@ data class Category( } object CategoryTimeWarnings { - val durationToBitIndex = mapOf( - 1000L * 60 to 0, // 1 minute - 1000L * 60 * 3 to 1, // 3 minutes - 1000L * 60 * 5 to 2, // 5 minutes - 1000L * 60 * 10 to 3, // 10 minutes - 1000L * 60 * 15 to 4 // 15 minutes + val durationInMinutesToBitIndex = mapOf( + 1 to 0, + 3 to 1, + 5 to 2, + 10 to 3, + 15 to 4 ) - - val durations = durationToBitIndex.keys } object CategoryFlags { diff --git a/app/src/main/java/io/timelimit/android/data/model/CategoryTimeWarning.kt b/app/src/main/java/io/timelimit/android/data/model/CategoryTimeWarning.kt new file mode 100644 index 0000000..24eabd3 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/model/CategoryTimeWarning.kt @@ -0,0 +1,85 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.data.model + +import android.util.JsonReader +import android.util.JsonWriter +import androidx.room.* +import io.timelimit.android.data.IdGenerator +import io.timelimit.android.data.JsonSerializable + +@Entity( + tableName = "category_time_warning", + primaryKeys = ["category_id", "minutes"], + foreignKeys = [ + ForeignKey( + entity = Category::class, + parentColumns = ["id"], + childColumns = ["category_id"], + onUpdate = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE + ) + ] +) +data class CategoryTimeWarning ( + @ColumnInfo(name = "category_id") + val categoryId: String, + val minutes: Int +): JsonSerializable { + companion object { + private const val CATEGORY_ID = "categoryId" + private const val MINUTES = "minutes" + const val MIN = 1 + const val MAX = 60 * 24 * 7 - 2 + + fun parse(reader: JsonReader): CategoryTimeWarning { + var categoryId: String? = null + var minutes: Int? = null + + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + CATEGORY_ID -> categoryId = reader.nextString() + MINUTES -> minutes = reader.nextInt() + else -> reader.skipValue() + } + } + reader.endObject() + + return CategoryTimeWarning( + categoryId = categoryId!!, + minutes = minutes!! + ) + } + } + + init { + IdGenerator.assertIdValid(categoryId) + + if (minutes < MIN || minutes > MAX) { + throw IllegalArgumentException() + } + } + + override fun serialize(writer: JsonWriter) { + writer.beginObject() + + writer.name(CATEGORY_ID).value(categoryId) + writer.name(MINUTES).value(minutes) + + writer.endObject() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/model/derived/CategoryRelatedData.kt b/app/src/main/java/io/timelimit/android/data/model/derived/CategoryRelatedData.kt index b39c9f8..9e64aa6 100644 --- a/app/src/main/java/io/timelimit/android/data/model/derived/CategoryRelatedData.kt +++ b/app/src/main/java/io/timelimit/android/data/model/derived/CategoryRelatedData.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -25,7 +25,8 @@ data class CategoryRelatedData( val usedTimes: List, val durations: List, val networks: List, - val limitLoginCategories: List + val limitLoginCategories: List, + val additionalTimeWarnings: List ) { companion object { fun load(category: Category, database: Database): CategoryRelatedData = database.runInUnobservedTransaction { @@ -34,6 +35,7 @@ data class CategoryRelatedData( val durations = database.sessionDuration().getSessionDurationItemsByCategoryIdSync(category.id) val networks = database.categoryNetworkId().getByCategoryIdSync(category.id) val limitLoginCategories = database.userLimitLoginCategoryDao().getByCategoryIdSync(category.id) + val additionalTimeWarnings = database.timeWarning().getItemsByCategoryIdSync(category.id) CategoryRelatedData( category = category, @@ -41,11 +43,24 @@ data class CategoryRelatedData( usedTimes = usedTimes, durations = durations, networks = networks, - limitLoginCategories = limitLoginCategories + limitLoginCategories = limitLoginCategories, + additionalTimeWarnings = additionalTimeWarnings ) } } + val allTimeWarningMinutes: Set by lazy { + mutableSetOf().also { result -> + CategoryTimeWarnings.durationInMinutesToBitIndex.entries.forEach { (durationInMinutes, bitIndex) -> + if (category.timeWarnings and (1 shl bitIndex) != 0) { + result.add(durationInMinutes) + } + } + + additionalTimeWarnings.forEach { result.add(it.minutes) } + } + } + fun update( category: Category, updateRules: Boolean, @@ -53,6 +68,7 @@ data class CategoryRelatedData( updateDurations: Boolean, updateNetworks: Boolean, updateLimitLoginCategories: Boolean, + updateTimeWarnings: Boolean, database: Database ): CategoryRelatedData = database.runInUnobservedTransaction { if (category.id != this.category.id) { @@ -64,10 +80,12 @@ data class CategoryRelatedData( val durations = if (updateDurations) database.sessionDuration().getSessionDurationItemsByCategoryIdSync(category.id) else durations val networks = if (updateNetworks) database.categoryNetworkId().getByCategoryIdSync(category.id) else networks val limitLoginCategories = if (updateLimitLoginCategories) database.userLimitLoginCategoryDao().getByCategoryIdSync(category.id) else limitLoginCategories + val additionalTimeWarnings = if (updateTimeWarnings) database.timeWarning().getItemsByCategoryIdSync(category.id) else additionalTimeWarnings if ( category == this.category && rules == this.rules && usedTimes == this.usedTimes && - durations == this.durations && networks == this.networks && limitLoginCategories == this.limitLoginCategories + durations == this.durations && networks == this.networks && + limitLoginCategories == this.limitLoginCategories && additionalTimeWarnings == this.additionalTimeWarnings ) { this } else { @@ -77,7 +95,8 @@ data class CategoryRelatedData( usedTimes = usedTimes, durations = durations, networks = networks, - limitLoginCategories = limitLoginCategories + limitLoginCategories = limitLoginCategories, + additionalTimeWarnings = additionalTimeWarnings ) } } diff --git a/app/src/main/java/io/timelimit/android/data/model/derived/UserRelatedData.kt b/app/src/main/java/io/timelimit/android/data/model/derived/UserRelatedData.kt index 9ba06a0..f702a2e 100644 --- a/app/src/main/java/io/timelimit/android/data/model/derived/UserRelatedData.kt +++ b/app/src/main/java/io/timelimit/android/data/model/derived/UserRelatedData.kt @@ -39,7 +39,7 @@ data class UserRelatedData( private val relatedTables = arrayOf( Table.User, Table.Category, Table.TimeLimitRule, Table.UsedTimeItem, Table.SessionDuration, Table.CategoryApp, - Table.CategoryNetworkId + Table.CategoryNetworkId, Table.UserLimitLoginCategory, Table.CategoryTimeWarning ) fun load(user: User, database: Database): UserRelatedData = database.runInUnobservedTransaction { @@ -103,11 +103,12 @@ data class UserRelatedData( private var categoryAppsInvalidated = false private var categoryNetworksInvalidated = false private var limitLoginCategoriesInvalidated = false + private var timeWarningsInvalidated = false private val invalidated get() = userInvalidated || categoriesInvalidated || rulesInvalidated || usedTimesInvalidated || sessionDurationsInvalidated || categoryAppsInvalidated || categoryNetworksInvalidated || - limitLoginCategoriesInvalidated + limitLoginCategoriesInvalidated || timeWarningsInvalidated override fun onInvalidated(tables: Set) { tables.forEach { @@ -120,6 +121,7 @@ data class UserRelatedData( Table.CategoryApp -> categoryAppsInvalidated = true Table.CategoryNetworkId -> categoryNetworksInvalidated = true Table.UserLimitLoginCategory -> limitLoginCategoriesInvalidated = true + Table.CategoryTimeWarning -> timeWarningsInvalidated = true else -> {/* do nothing */} } } @@ -144,13 +146,17 @@ data class UserRelatedData( updateRules = rulesInvalidated, updateTimes = usedTimesInvalidated, updateNetworks = categoryNetworksInvalidated, - updateLimitLoginCategories = limitLoginCategoriesInvalidated + updateLimitLoginCategories = limitLoginCategoriesInvalidated, + updateTimeWarnings = timeWarningsInvalidated ) ?: CategoryRelatedData.load( category = category, database = database ) } - } else if (sessionDurationsInvalidated || rulesInvalidated || usedTimesInvalidated || categoryNetworksInvalidated || limitLoginCategoriesInvalidated) { + } else if ( + sessionDurationsInvalidated || rulesInvalidated || usedTimesInvalidated || + categoryNetworksInvalidated || limitLoginCategoriesInvalidated || timeWarningsInvalidated + ) { categories.map { it.update( category = it.category, @@ -159,7 +165,8 @@ data class UserRelatedData( updateRules = rulesInvalidated, updateTimes = usedTimesInvalidated, updateNetworks = categoryNetworksInvalidated, - updateLimitLoginCategories = limitLoginCategoriesInvalidated + updateLimitLoginCategories = limitLoginCategoriesInvalidated, + updateTimeWarnings = timeWarningsInvalidated ) } } else { diff --git a/app/src/main/java/io/timelimit/android/logic/BackgroundTaskLogic.kt b/app/src/main/java/io/timelimit/android/logic/BackgroundTaskLogic.kt index a324278..f7dd6a0 100644 --- a/app/src/main/java/io/timelimit/android/logic/BackgroundTaskLogic.kt +++ b/app/src/main/java/io/timelimit/android/logic/BackgroundTaskLogic.kt @@ -25,7 +25,6 @@ import io.timelimit.android.coroutines.executeAndWait import io.timelimit.android.coroutines.runAsync import io.timelimit.android.coroutines.runAsyncExpectForever import io.timelimit.android.data.backup.DatabaseBackup -import io.timelimit.android.data.model.CategoryTimeWarnings import io.timelimit.android.data.model.ExperimentalFlags import io.timelimit.android.data.model.UserType import io.timelimit.android.data.model.derived.UserRelatedData @@ -46,7 +45,6 @@ import io.timelimit.android.logic.blockingreason.needsNetworkId import io.timelimit.android.sync.actions.ForceSyncAction import io.timelimit.android.sync.actions.UpdateDeviceStatusAction import io.timelimit.android.sync.actions.apply.ApplyActionUtil -import io.timelimit.android.ui.IsAppInForeground import io.timelimit.android.ui.lock.LockActivity import io.timelimit.android.util.AndroidVersion import io.timelimit.android.util.TimeTextUtil @@ -393,33 +391,50 @@ class BackgroundTaskLogic(val appLogic: AppLogic) { val oldSessionDuration = handling.remainingSessionDuration?.let { it - timeToSubtractForCategory } // trigger time warnings - fun showTimeWarningNotification(title: Int, roundedNewTime: Long) { - appLogic.platformIntegration.showTimeWarningNotification( - title = appLogic.context.getString(title, category.title), - text = TimeTextUtil.remaining(roundedNewTime.toInt(), appLogic.context) - ) + fun handleTimeWarnings( + notificationTitleStringResource: Int, + roundedNewTimeInMilliseconds: Long + ) { + val roundedNewTimeInMinutes = roundedNewTimeInMilliseconds / (1000 * 60) + + if ( + // CategoryTimeWarning.MAX is still small enough for an integer + roundedNewTimeInMilliseconds >= 0 && + roundedNewTimeInMilliseconds < Int.MAX_VALUE && + roundedNewTimeInMinutes >= 0 && + roundedNewTimeInMinutes < Int.MAX_VALUE && + handling.createdWithCategoryRelatedData.allTimeWarningMinutes.contains( + roundedNewTimeInMinutes.toInt() + ) + ) { + appLogic.platformIntegration.showTimeWarningNotification( + title = appLogic.context.getString( + notificationTitleStringResource, + category.title + ), + text = TimeTextUtil.remaining( + roundedNewTimeInMilliseconds.toInt(), + appLogic.context + ) + ) + } } if (oldRemainingTime / (1000 * 60) != newRemainingTime / (1000 * 60)) { - // eventually show remaining time warning - val roundedNewTime = ((newRemainingTime / (1000 * 60)) + 1) * (1000 * 60) - val flagIndex = CategoryTimeWarnings.durationToBitIndex[roundedNewTime] - - if (flagIndex != null && category.timeWarnings and (1 shl flagIndex) != 0) { - showTimeWarningNotification(title = R.string.time_warning_not_title, roundedNewTime = roundedNewTime) - } + handleTimeWarnings( + notificationTitleStringResource = R.string.time_warning_not_title, + roundedNewTimeInMilliseconds = ((newRemainingTime / (1000 * 60)) + 1) * 1000 * 60 + ) } if (oldSessionDuration != null) { val newSessionDuration = oldSessionDuration - timeToSubtract - // eventually show session duration warning - if (oldSessionDuration / (1000 * 60) != newSessionDuration / (1000 * 60)) { - val roundedNewTime = ((newSessionDuration / (1000 * 60)) + 1) * (1000 * 60) - val flagIndex = CategoryTimeWarnings.durationToBitIndex[roundedNewTime] - if (flagIndex != null && category.timeWarnings and (1 shl flagIndex) != 0) { - showTimeWarningNotification(title = R.string.time_warning_not_title_session, roundedNewTime = roundedNewTime) - } + if (oldSessionDuration / (1000 * 60) != newSessionDuration / (1000 * 60)) { + handleTimeWarnings( + notificationTitleStringResource = R.string.time_warning_not_title_session, + roundedNewTimeInMilliseconds = ((newSessionDuration / (1000 * 60)) + 1) * (1000 * 60) + ) } } @@ -434,12 +449,11 @@ class BackgroundTaskLogic(val appLogic: AppLogic) { if (nextBlockedMinute != null) { val minutesUntilNextBlockedMinute = nextBlockedMinute - nowMinuteOfWeek - val msUntilNextBlocking = minutesUntilNextBlockedMinute.toLong() * 1000 * 60 - val flagIndex = CategoryTimeWarnings.durationToBitIndex[msUntilNextBlocking] - if (flagIndex != null && category.timeWarnings and (1 shl flagIndex) != 0) { - showTimeWarningNotification(title = R.string.time_warning_not_title_blocked_time_area, roundedNewTime = msUntilNextBlocking) - } + handleTimeWarnings( + notificationTitleStringResource = R.string.time_warning_not_title_blocked_time_area, + roundedNewTimeInMilliseconds = minutesUntilNextBlockedMinute.toLong() * 1000 * 60 + ) } } diff --git a/app/src/main/java/io/timelimit/android/sync/ApplyServerDataStatus.kt b/app/src/main/java/io/timelimit/android/sync/ApplyServerDataStatus.kt index 247585f..70873fe 100644 --- a/app/src/main/java/io/timelimit/android/sync/ApplyServerDataStatus.kt +++ b/app/src/main/java/io/timelimit/android/sync/ApplyServerDataStatus.kt @@ -387,6 +387,20 @@ object ApplyServerDataStatus { } ) } + + // apply time warnings + database.timeWarning().deleteByCategoryIdSync(newCategory.categoryId) + + if (newCategory.additionalTimeWarnings.isNotEmpty()) { + database.timeWarning().insertItemsSync( + newCategory.additionalTimeWarnings.map { minutes -> + CategoryTimeWarning( + categoryId = newCategory.categoryId, + minutes = minutes + ) + } + ) + } } } } diff --git a/app/src/main/java/io/timelimit/android/sync/actions/Actions.kt b/app/src/main/java/io/timelimit/android/sync/actions/Actions.kt index 6902293..fe873a4 100644 --- a/app/src/main/java/io/timelimit/android/sync/actions/Actions.kt +++ b/app/src/main/java/io/timelimit/android/sync/actions/Actions.kt @@ -726,16 +726,28 @@ data class UpdateCategoryTemporarilyBlockedAction(val categoryId: String, val bl writer.endObject() } } -data class UpdateCategoryTimeWarningsAction(val categoryId: String, val enable: Boolean, val flags: Int): ParentAction() { +data class UpdateCategoryTimeWarningsAction( + val categoryId: String, + val enable: Boolean, + val flags: Int, + val minutes: Int? +): ParentAction() { companion object { const val TYPE_VALUE = "UPDATE_CATEGORY_TIME_WARNINGS" private const val CATEGORY_ID = "categoryId" private const val ENABLE = "enable" private const val FLAGS = "flags" + private const val MINUTES = "minutes" } init { IdGenerator.assertIdValid(categoryId) + + if (minutes != null) { + if (minutes < CategoryTimeWarning.MIN || minutes > CategoryTimeWarning.MAX) { + throw IllegalArgumentException() + } + } } override fun serialize(writer: JsonWriter) { @@ -746,6 +758,10 @@ data class UpdateCategoryTimeWarningsAction(val categoryId: String, val enable: writer.name(ENABLE).value(enable) writer.name(FLAGS).value(flags) + if (minutes != null) { + writer.name(MINUTES).value(minutes) + } + writer.endObject() } } diff --git a/app/src/main/java/io/timelimit/android/sync/actions/dispatch/ParentAction.kt b/app/src/main/java/io/timelimit/android/sync/actions/dispatch/ParentAction.kt index f7cd8ec..2a11948 100644 --- a/app/src/main/java/io/timelimit/android/sync/actions/dispatch/ParentAction.kt +++ b/app/src/main/java/io/timelimit/android/sync/actions/dispatch/ParentAction.kt @@ -686,6 +686,19 @@ object LocalDatabaseParentActionDispatcher { database.category().updateCategorySync(modified) } + if (action.minutes != null) { + if (action.enable) database.timeWarning().insertItemIgnoreConflictSync( + CategoryTimeWarning( + categoryId = action.categoryId, + minutes = action.minutes + ) + ) + else database.timeWarning().deleteItem( + categoryId = action.categoryId, + minutes = action.minutes + ) + } + null } is UpdateCategoryBatteryLimit -> { diff --git a/app/src/main/java/io/timelimit/android/sync/network/ServerDataStatus.kt b/app/src/main/java/io/timelimit/android/sync/network/ServerDataStatus.kt index 1433ac3..fa2295e 100644 --- a/app/src/main/java/io/timelimit/android/sync/network/ServerDataStatus.kt +++ b/app/src/main/java/io/timelimit/android/sync/network/ServerDataStatus.kt @@ -459,7 +459,8 @@ data class ServerUpdatedCategoryBaseData( val networks: List, val disableLimitsUntil: Long, val flags: Long, - val blockNotificationDelay: Long + val blockNotificationDelay: Long, + val additionalTimeWarnings: Set ) { companion object { private const val CATEGORY_ID = "categoryId" @@ -481,6 +482,7 @@ data class ServerUpdatedCategoryBaseData( private const val DISABLE_LIMITS_UNTIL = "dlu" private const val FLAGS = "flags" private const val BLOCK_NOTIFICATION_DELAY = "blockNotificationDelay" + private const val ADDITIONAL_TIME_WARNINGS = "atw" fun parse(reader: JsonReader): ServerUpdatedCategoryBaseData { var categoryId: String? = null @@ -503,6 +505,7 @@ data class ServerUpdatedCategoryBaseData( var disableLimitsUntil = 0L var flags = 0L var blockNotificationDelay = 0L + var additionalTimeWarnings = emptySet() reader.beginObject() while (reader.hasNext()) { @@ -526,31 +529,41 @@ data class ServerUpdatedCategoryBaseData( DISABLE_LIMITS_UNTIL -> disableLimitsUntil = reader.nextLong() FLAGS -> flags = reader.nextLong() BLOCK_NOTIFICATION_DELAY -> blockNotificationDelay = reader.nextLong() + ADDITIONAL_TIME_WARNINGS -> additionalTimeWarnings = mutableSetOf().also { result -> + reader.beginArray() + + while (reader.hasNext()) { + result.add(reader.nextInt()) + } + + reader.endArray() + } else -> reader.skipValue() } } reader.endObject() return ServerUpdatedCategoryBaseData( - categoryId = categoryId!!, - childId = childId!!, - title = title!!, - blockedMinutesInWeek = blockedMinutesInWeek!!, - extraTimeInMillis = extraTimeInMillis!!, - extraTimeDay = extraTimeDay, - temporarilyBlocked = temporarilyBlocked!!, - temporarilyBlockedEndTime = temporarilyBlockedEndTime, - baseDataVersion = baseDataVersion!!, - parentCategoryId = parentCategoryId!!, - blockAllNotifications = blockAllNotifications, - timeWarnings = timeWarnings, - minBatteryLevelCharging = minBatteryLevelCharging, - minBatteryLevelMobile = minBatteryLevelMobile, - sort = sort, - networks = networks, - disableLimitsUntil = disableLimitsUntil, - flags = flags, - blockNotificationDelay = blockNotificationDelay + categoryId = categoryId!!, + childId = childId!!, + title = title!!, + blockedMinutesInWeek = blockedMinutesInWeek!!, + extraTimeInMillis = extraTimeInMillis!!, + extraTimeDay = extraTimeDay, + temporarilyBlocked = temporarilyBlocked!!, + temporarilyBlockedEndTime = temporarilyBlockedEndTime, + baseDataVersion = baseDataVersion!!, + parentCategoryId = parentCategoryId!!, + blockAllNotifications = blockAllNotifications, + timeWarnings = timeWarnings, + minBatteryLevelCharging = minBatteryLevelCharging, + minBatteryLevelMobile = minBatteryLevelMobile, + sort = sort, + networks = networks, + disableLimitsUntil = disableLimitsUntil, + flags = flags, + blockNotificationDelay = blockNotificationDelay, + additionalTimeWarnings = additionalTimeWarnings ) } diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/settings/CategorySettingsFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/settings/CategorySettingsFragment.kt index a5aaa90..f7249fa 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/category/settings/CategorySettingsFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/settings/CategorySettingsFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2021 Jonas Lochmann + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -23,6 +23,7 @@ import android.view.ViewGroup import android.widget.Toast import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import com.google.android.material.snackbar.Snackbar import io.timelimit.android.R @@ -38,6 +39,8 @@ import io.timelimit.android.ui.main.ActivityViewModel import io.timelimit.android.ui.main.getActivityViewModel import io.timelimit.android.ui.manage.category.settings.addusedtime.AddUsedTimeDialogFragment import io.timelimit.android.ui.manage.category.settings.networks.ManageCategoryNetworksView +import io.timelimit.android.ui.manage.category.settings.timewarning.CategoryTimeWarningStatus +import io.timelimit.android.ui.manage.category.settings.timewarning.CategoryTimeWarningView import io.timelimit.android.ui.payment.RequiresPurchaseDialogFragment import io.timelimit.android.ui.util.bind @@ -46,6 +49,7 @@ class CategorySettingsFragment : Fragment() { private const val PERMISSION_REQUEST_CODE = 1 private const val CHILD_ID = "childId" private const val CATEGORY_ID = "categoryId" + private const val TIME_WARNING_STATUS = "timeWarningStatus" fun newInstance(childId: String, categoryId: String) = CategorySettingsFragment().apply { arguments = Bundle().apply { @@ -61,10 +65,35 @@ class CategorySettingsFragment : Fragment() { private val categoryId: String get() = requireArguments().getString(CATEGORY_ID)!! private val notificationFilterModel: ManageNotificationFilterModel by viewModels() + private val timeWarningStatus = MutableLiveData() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + timeWarningStatus.value = savedInstanceState?.getParcelable(TIME_WARNING_STATUS) ?: CategoryTimeWarningStatus.default + } + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { val binding = FragmentCategorySettingsBinding.inflate(inflater, container, false) val categoryEntry = appLogic.database.category().getCategoryByChildIdAndId(childId, categoryId) + val timeWarnings = appLogic.database.timeWarning().getItemsByCategoryIdLive(categoryId) + + categoryEntry.observe(viewLifecycleOwner) { + if (it != null) { + timeWarningStatus.value?.let { old -> + timeWarningStatus.value = old.update(it) + } + } + } + + timeWarnings.observe(viewLifecycleOwner) { + if (it != null) { + timeWarningStatus.value?.let { old -> + timeWarningStatus.value = old.update(it) + } + } + } val childDate = appLogic.database.user().getChildUserByIdLive(childId).mapToTimezone().switchMap { timezone -> liveDataFromFunction (1000 * 10L) { DateInTimezone.newInstance(appLogic.timeApi.getCurrentTimeInMillis(), timezone) } @@ -122,11 +151,13 @@ class CategorySettingsFragment : Fragment() { ) CategoryTimeWarningView.bind( - view = binding.timeWarnings, - auth = auth, - categoryLive = categoryEntry, - lifecycleOwner = this, - fragmentManager = parentFragmentManager + view = binding.timeWarnings, + auth = auth, + statusLive = timeWarningStatus, + lifecycleOwner = this, + fragmentManager = parentFragmentManager, + categoryId = categoryId, + serverApiLevelInfo = appLogic.serverApiLevelLogic.infoLive ) ManageCategoryNetworksView.bind( @@ -254,4 +285,10 @@ class CategorySettingsFragment : Fragment() { } } } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + + timeWarningStatus.value?.let { outState.putParcelable(TIME_WARNING_STATUS, it) } + } } diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/settings/CategoryTimeWarningView.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/settings/CategoryTimeWarningView.kt deleted file mode 100644 index d0c1edc..0000000 --- a/app/src/main/java/io/timelimit/android/ui/manage/category/settings/CategoryTimeWarningView.kt +++ /dev/null @@ -1,86 +0,0 @@ -/* - * TimeLimit Copyright 2019 - 2020 Jonas Lochmann - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package io.timelimit.android.ui.manage.category.settings - -import android.widget.CheckBox -import androidx.fragment.app.FragmentManager -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LiveData -import androidx.lifecycle.Observer -import io.timelimit.android.R -import io.timelimit.android.data.model.Category -import io.timelimit.android.data.model.CategoryTimeWarnings -import io.timelimit.android.databinding.CategoryTimeWarningsViewBinding -import io.timelimit.android.sync.actions.UpdateCategoryTimeWarningsAction -import io.timelimit.android.ui.help.HelpDialogFragment -import io.timelimit.android.ui.main.ActivityViewModel -import io.timelimit.android.util.TimeTextUtil - -object CategoryTimeWarningView { - fun bind( - view: CategoryTimeWarningsViewBinding, - lifecycleOwner: LifecycleOwner, - categoryLive: LiveData, - auth: ActivityViewModel, - fragmentManager: FragmentManager - ) { - view.titleView.setOnClickListener { - HelpDialogFragment.newInstance( - title = R.string.time_warning_title, - text = R.string.time_warning_desc - ).show(fragmentManager) - } - - view.linearLayout.removeAllViews() - - val durationToCheckbox = mutableMapOf() - - CategoryTimeWarnings.durations.sorted().forEach { duration -> - CheckBox(view.root.context).let { checkbox -> - checkbox.text = TimeTextUtil.time(duration.toInt(), view.root.context) - - view.linearLayout.addView(checkbox) - durationToCheckbox[duration] = checkbox - } - } - - categoryLive.observe(lifecycleOwner, Observer { category -> - durationToCheckbox.entries.forEach { (duration, checkbox) -> - checkbox.setOnCheckedChangeListener { _, _ -> } - - val flag = (1 shl CategoryTimeWarnings.durationToBitIndex[duration]!!) - val enable = (category?.timeWarnings ?: 0) and flag != 0 - checkbox.isChecked = enable - - checkbox.setOnCheckedChangeListener { _, isChecked -> - if (isChecked != enable && category != null) { - if (auth.tryDispatchParentAction( - UpdateCategoryTimeWarningsAction( - categoryId = category.id, - enable = isChecked, - flags = flag - ) - )) { - // it worked - } else { - checkbox.isChecked = enable - } - } - } - } - }) - } -} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/settings/timewarning/AddTimeWarningDialogFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/settings/timewarning/AddTimeWarningDialogFragment.kt new file mode 100644 index 0000000..88aeccb --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/settings/timewarning/AddTimeWarningDialogFragment.kt @@ -0,0 +1,81 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.category.settings.timewarning + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.FragmentManager +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import io.timelimit.android.data.model.CategoryTimeWarning +import io.timelimit.android.data.model.CategoryTimeWarnings +import io.timelimit.android.databinding.AddTimeWarningDialogBinding +import io.timelimit.android.extensions.showSafe +import io.timelimit.android.sync.actions.UpdateCategoryTimeWarningsAction +import io.timelimit.android.ui.main.ActivityViewModelHolder + +class AddTimeWarningDialogFragment: BottomSheetDialogFragment() { + companion object { + private const val DIALOG_TAG = "AddTimeWarningDialogFragment" + private const val CATEGORY_ID = "categoryId" + + fun newInstance(categoryId: String) = AddTimeWarningDialogFragment().apply { + arguments = Bundle().apply { + putString(CATEGORY_ID, categoryId) + } + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + val binding = AddTimeWarningDialogBinding.inflate(inflater, container, false) + val categoryId = requireArguments().getString(CATEGORY_ID)!! + val auth = (requireActivity() as ActivityViewModelHolder).getActivityViewModel() + + auth.authenticatedUser.observe(viewLifecycleOwner) { if (it == null) dismissAllowingStateLoss() } + + binding.numberPicker.minValue = CategoryTimeWarning.MIN + binding.numberPicker.maxValue = CategoryTimeWarning.MAX + + binding.confirmButton.setOnClickListener { + val minutes = binding.numberPicker.value + val flagIndex = CategoryTimeWarnings.durationInMinutesToBitIndex[minutes] + val action = if (flagIndex != null) { + UpdateCategoryTimeWarningsAction( + categoryId = categoryId, + enable = true, + flags = 1 shl flagIndex, + minutes = null + ) + } else { + UpdateCategoryTimeWarningsAction( + categoryId = categoryId, + enable = true, + flags = 0, + minutes = minutes + ) + } + + if (auth.tryDispatchParentAction(action)) { + dismissAllowingStateLoss() + } + } + + return binding.root + } + + fun show(fragmentManager: FragmentManager) = showSafe(fragmentManager, DIALOG_TAG) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/settings/timewarning/CategoryTimeWarningStatus.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/settings/timewarning/CategoryTimeWarningStatus.kt new file mode 100644 index 0000000..6a65eee --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/settings/timewarning/CategoryTimeWarningStatus.kt @@ -0,0 +1,118 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.category.settings.timewarning + +import android.os.Parcelable +import io.timelimit.android.data.model.Category +import io.timelimit.android.data.model.CategoryTimeWarning +import io.timelimit.android.data.model.CategoryTimeWarnings +import io.timelimit.android.sync.actions.UpdateCategoryTimeWarningsAction +import kotlinx.parcelize.IgnoredOnParcel +import kotlinx.parcelize.Parcelize +import java.util.* + +@Parcelize +data class CategoryTimeWarningStatus( + private val categoryFlags: Int?, + private val timeWarnings: Set?, + private val additionalTimeWarningSlots: Set +): Parcelable { + companion object { + val default = CategoryTimeWarningStatus( + categoryFlags = null, + timeWarnings = null, + additionalTimeWarningSlots = emptySet() + ) + } + + fun update(category: Category): CategoryTimeWarningStatus { + if (this.categoryFlags == category.timeWarnings) return this + + return this.copy(categoryFlags = category.timeWarnings) + } + + fun update(warnings: List): CategoryTimeWarningStatus { + val timeWarnings = warnings.map { it.minutes }.toSet() + + if (this.timeWarnings == timeWarnings) return this + + return this.copy( + timeWarnings = timeWarnings, + additionalTimeWarningSlots = additionalTimeWarningSlots + timeWarnings + ) + } + + fun buildAction(categoryId: String, minutes: Int, enable: Boolean): UpdateCategoryTimeWarningsAction { + val flagIndex = CategoryTimeWarnings.durationInMinutesToBitIndex[minutes] + + return if (enable) { + if (flagIndex != null) UpdateCategoryTimeWarningsAction( + categoryId = categoryId, + enable = true, + flags = 1 shl flagIndex, + minutes = null + ) else UpdateCategoryTimeWarningsAction( + categoryId = categoryId, + enable = true, + flags = 0, + minutes = minutes + ) + } else { + UpdateCategoryTimeWarningsAction( + categoryId = categoryId, + enable = false, + flags = if (flagIndex != null) 1 shl flagIndex else 0, + minutes = if (timeWarnings != null && timeWarnings.contains(minutes)) minutes else null + ) + } + } + + @IgnoredOnParcel + val display = TreeMap().also { result -> + val complete = categoryFlags != null && timeWarnings != null + + additionalTimeWarningSlots.forEach { minute -> + result[minute] = if (complete) CategoryTimeWarningOptionStatus.Unchecked + else CategoryTimeWarningOptionStatus.Undefined + } + + timeWarnings?.forEach { minute -> result[minute] = CategoryTimeWarningOptionStatus.Checked } + + CategoryTimeWarnings.durationInMinutesToBitIndex.forEach { (minute, bitIndex) -> + result[minute] = if (complete && categoryFlags != null) { + if (categoryFlags and (1 shl bitIndex) != 0) CategoryTimeWarningOptionStatus.Checked + else CategoryTimeWarningOptionStatus.Unchecked + } else CategoryTimeWarningOptionStatus.Undefined + } + }.let { output -> + mutableListOf().also { result -> + output.entries.forEach { (minute, status) -> + result.add(CategoryTimeWarningOption(minute, status)) + } + } + }.toList() + + data class CategoryTimeWarningOption( + val minutes: Int, + val status: CategoryTimeWarningOptionStatus + ) + + enum class CategoryTimeWarningOptionStatus { + Checked, + Unchecked, + Undefined + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/settings/timewarning/CategoryTimeWarningView.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/settings/timewarning/CategoryTimeWarningView.kt new file mode 100644 index 0000000..5f6e0ea --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/settings/timewarning/CategoryTimeWarningView.kt @@ -0,0 +1,105 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.category.settings.timewarning + +import android.view.View +import android.widget.CheckBox +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import io.timelimit.android.R +import io.timelimit.android.databinding.CategoryTimeWarningsViewBinding +import io.timelimit.android.logic.ServerApiLevelInfo +import io.timelimit.android.ui.help.HelpDialogFragment +import io.timelimit.android.ui.main.ActivityViewModel +import io.timelimit.android.util.TimeTextUtil + +object CategoryTimeWarningView { + fun bind( + view: CategoryTimeWarningsViewBinding, + lifecycleOwner: LifecycleOwner, + statusLive: LiveData, + auth: ActivityViewModel, + fragmentManager: FragmentManager, + categoryId: String, + serverApiLevelInfo: LiveData + ) { + view.titleView.setOnClickListener { + HelpDialogFragment.newInstance( + title = R.string.time_warning_title, + text = R.string.time_warning_desc + ).show(fragmentManager) + } + + view.addTimeWarningButton.setOnClickListener { + if (auth.requestAuthenticationOrReturnTrue()) { + AddTimeWarningDialogFragment.newInstance(categoryId).show(fragmentManager) + } + } + + serverApiLevelInfo.observe(lifecycleOwner) { info -> + view.addTimeWarningButton.visibility = if (info.hasLevelOrIsOffline(3)) View.VISIBLE else View.GONE + } + + view.linearLayout.removeAllViews() + + val views = mutableListOf() + + statusLive.observe(lifecycleOwner) { status -> + if (views.size != status.display.size) { + views.clear() + view.linearLayout.removeAllViews() + + for (index in 1..status.display.size) { + CheckBox(view.root.context).also { checkbox -> + views.add(checkbox) + view.linearLayout.addView(checkbox) + } + } + } + + status.display.forEachIndexed { index, item -> + val checkbox = views[index] + + val enabled = item.status != CategoryTimeWarningStatus.CategoryTimeWarningOptionStatus.Undefined + val checked = item.status == CategoryTimeWarningStatus.CategoryTimeWarningOptionStatus.Checked + + checkbox.text = TimeTextUtil.time(item.minutes * 1000 * 60, view.root.context) + + checkbox.setOnCheckedChangeListener(null) + + checkbox.isEnabled = enabled + checkbox.isChecked = checked + + if (item.status != CategoryTimeWarningStatus.CategoryTimeWarningOptionStatus.Undefined) { + checkbox.setOnCheckedChangeListener { _, isChecked -> + if (isChecked != checked) { + if (auth.tryDispatchParentAction(status.buildAction( + categoryId = categoryId, + minutes = item.minutes, + enable = isChecked + ))) { + // it worked + } else { + checkbox.isChecked = checked + } + } + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/child/advanced/duplicate/DuplicateChildActions.kt b/app/src/main/java/io/timelimit/android/ui/manage/child/advanced/duplicate/DuplicateChildActions.kt index 0014c8d..098aa0b 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/child/advanced/duplicate/DuplicateChildActions.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/child/advanced/duplicate/DuplicateChildActions.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2021 Jonas Lochmann + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -89,13 +89,23 @@ object DuplicateChildActions { oldCategory.category.timeWarnings.let { timeWarnings -> if (timeWarnings != 0) { result.add(UpdateCategoryTimeWarningsAction( - categoryId = newCategoryId, - enable = true, - flags = timeWarnings + categoryId = newCategoryId, + enable = true, + flags = timeWarnings, + minutes = null )) } } + oldCategory.additionalTimeWarnings.forEach { timeWarning -> + result.add(UpdateCategoryTimeWarningsAction( + categoryId = newCategoryId, + enable = true, + flags = 0, + minutes = timeWarning.minutes + )) + } + if (oldCategory.category.minBatteryLevelWhileCharging != 0 || oldCategory.category.minBatteryLevelMobile != 0) { result.add(UpdateCategoryBatteryLimit( categoryId = newCategoryId, diff --git a/app/src/main/res/layout/add_time_warning_dialog.xml b/app/src/main/res/layout/add_time_warning_dialog.xml new file mode 100644 index 0000000..44df232 --- /dev/null +++ b/app/src/main/res/layout/add_time_warning_dialog.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + +