diff --git a/app/schemas/io.timelimit.android.data.RoomDatabase/45.json b/app/schemas/io.timelimit.android.data.RoomDatabase/45.json
new file mode 100644
index 0000000..fb0185c
--- /dev/null
+++ b/app/schemas/io.timelimit.android.data.RoomDatabase/45.json
@@ -0,0 +1,1734 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 45,
+ "identityHash": "cd8c9703a2467f95e13b2e79b1117bde",
+ "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, `manipulation_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": "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
+ },
+ {
+ "fieldPath": "manipulationFlags",
+ "columnName": "manipulation_flags",
+ "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"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "crypt_container_metadata",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`crypt_container_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `device_id` TEXT, `category_id` TEXT, `type` INTEGER NOT NULL, `server_version` TEXT NOT NULL, `current_generation` INTEGER NOT NULL, `current_generation_first_timestamp` INTEGER NOT NULL, `next_counter` INTEGER NOT NULL, `current_generation_key` BLOB, `status` INTEGER NOT NULL, FOREIGN KEY(`device_id`) REFERENCES `device`(`id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "cryptContainerId",
+ "columnName": "crypt_container_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceId",
+ "columnName": "device_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "categoryId",
+ "columnName": "category_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverVersion",
+ "columnName": "server_version",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currentGeneration",
+ "columnName": "current_generation",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currentGenerationFirstTimestamp",
+ "columnName": "current_generation_first_timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nextCounter",
+ "columnName": "next_counter",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "currentGenerationKey",
+ "columnName": "current_generation_key",
+ "affinity": "BLOB",
+ "notNull": false
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "crypt_container_id"
+ ],
+ "autoGenerate": true
+ },
+ "indices": [
+ {
+ "name": "index_crypt_container_metadata_device_id",
+ "unique": false,
+ "columnNames": [
+ "device_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_crypt_container_metadata_device_id` ON `${TABLE_NAME}` (`device_id`)"
+ },
+ {
+ "name": "index_crypt_container_metadata_category_id",
+ "unique": false,
+ "columnNames": [
+ "category_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_crypt_container_metadata_category_id` ON `${TABLE_NAME}` (`category_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "device",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "device_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ },
+ {
+ "table": "category",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "category_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "crypt_container_data",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`crypt_container_id` INTEGER NOT NULL, `encrypted_data` BLOB NOT NULL, PRIMARY KEY(`crypt_container_id`), FOREIGN KEY(`crypt_container_id`) REFERENCES `crypt_container_metadata`(`crypt_container_id`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "cryptContainerId",
+ "columnName": "crypt_container_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "encryptedData",
+ "columnName": "encrypted_data",
+ "affinity": "BLOB",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "crypt_container_id"
+ ],
+ "autoGenerate": false
+ },
+ "indices": [],
+ "foreignKeys": [
+ {
+ "table": "crypt_container_metadata",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "crypt_container_id"
+ ],
+ "referencedColumns": [
+ "crypt_container_id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "crypt_container_pending_key_request",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`crypt_container_id` INTEGER NOT NULL, `request_time_crypt_container_generation` INTEGER NOT NULL, `request_sequence_id` INTEGER NOT NULL, `request_key` BLOB NOT NULL, PRIMARY KEY(`crypt_container_id`), FOREIGN KEY(`crypt_container_id`) REFERENCES `crypt_container_metadata`(`crypt_container_id`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "cryptContainerId",
+ "columnName": "crypt_container_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requestTimeCryptContainerGeneration",
+ "columnName": "request_time_crypt_container_generation",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requestSequenceId",
+ "columnName": "request_sequence_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "requestKey",
+ "columnName": "request_key",
+ "affinity": "BLOB",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "crypt_container_id"
+ ],
+ "autoGenerate": false
+ },
+ "indices": [
+ {
+ "name": "index_crypt_container_pending_key_request_request_sequence_id",
+ "unique": true,
+ "columnNames": [
+ "request_sequence_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_crypt_container_pending_key_request_request_sequence_id` ON `${TABLE_NAME}` (`request_sequence_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "crypt_container_metadata",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "crypt_container_id"
+ ],
+ "referencedColumns": [
+ "crypt_container_id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "crypt_container_key_result",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`request_sequence_id` INTEGER NOT NULL, `device_id` TEXT NOT NULL, `status` INTEGER NOT NULL, PRIMARY KEY(`request_sequence_id`, `device_id`), FOREIGN KEY(`request_sequence_id`) REFERENCES `crypt_container_pending_key_request`(`request_sequence_id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`device_id`) REFERENCES `device`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "requestSequenceId",
+ "columnName": "request_sequence_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "deviceId",
+ "columnName": "device_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "status",
+ "columnName": "status",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "request_sequence_id",
+ "device_id"
+ ],
+ "autoGenerate": false
+ },
+ "indices": [
+ {
+ "name": "index_crypt_container_key_result_request_sequence_id",
+ "unique": false,
+ "columnNames": [
+ "request_sequence_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_crypt_container_key_result_request_sequence_id` ON `${TABLE_NAME}` (`request_sequence_id`)"
+ },
+ {
+ "name": "index_crypt_container_key_result_device_id",
+ "unique": false,
+ "columnNames": [
+ "device_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_crypt_container_key_result_device_id` ON `${TABLE_NAME}` (`device_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "crypt_container_pending_key_request",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "request_sequence_id"
+ ],
+ "referencedColumns": [
+ "request_sequence_id"
+ ]
+ },
+ {
+ "table": "device",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "device_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "device_public_key",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`device_id` TEXT NOT NULL, `public_key` BLOB NOT NULL, `next_sequence_number` INTEGER NOT NULL, PRIMARY KEY(`device_id`), FOREIGN KEY(`device_id`) REFERENCES `device`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "deviceId",
+ "columnName": "device_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "public_key",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nextSequenceNumber",
+ "columnName": "next_sequence_number",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "device_id"
+ ],
+ "autoGenerate": false
+ },
+ "indices": [],
+ "foreignKeys": [
+ {
+ "table": "device",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "device_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "user_u2f_key",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `user_id` TEXT NOT NULL, `added_at` INTEGER NOT NULL, `key_handle` BLOB NOT NULL, `public_key` BLOB NOT NULL, `next_counter` INTEGER NOT NULL, FOREIGN KEY(`user_id`) REFERENCES `user`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "keyId",
+ "columnName": "key_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userId",
+ "columnName": "user_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "addedAt",
+ "columnName": "added_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "keyHandle",
+ "columnName": "key_handle",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "publicKey",
+ "columnName": "public_key",
+ "affinity": "BLOB",
+ "notNull": true
+ },
+ {
+ "fieldPath": "nextCounter",
+ "columnName": "next_counter",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "key_id"
+ ],
+ "autoGenerate": true
+ },
+ "indices": [
+ {
+ "name": "index_user_u2f_key_key_handle_public_key",
+ "unique": true,
+ "columnNames": [
+ "key_handle",
+ "public_key"
+ ],
+ "orders": [],
+ "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_user_u2f_key_key_handle_public_key` ON `${TABLE_NAME}` (`key_handle`, `public_key`)"
+ },
+ {
+ "name": "index_user_u2f_key_user_id",
+ "unique": false,
+ "columnNames": [
+ "user_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_user_u2f_key_user_id` ON `${TABLE_NAME}` (`user_id`)"
+ }
+ ],
+ "foreignKeys": [
+ {
+ "table": "user",
+ "onDelete": "CASCADE",
+ "onUpdate": "CASCADE",
+ "columns": [
+ "user_id"
+ ],
+ "referencedColumns": [
+ "id"
+ ]
+ }
+ ]
+ },
+ {
+ "tableName": "widget_category",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`widget_id` INTEGER NOT NULL, `category_id` TEXT NOT NULL, PRIMARY KEY(`widget_id`, `category_id`), FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )",
+ "fields": [
+ {
+ "fieldPath": "widgetId",
+ "columnName": "widget_id",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "categoryId",
+ "columnName": "category_id",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "columnNames": [
+ "widget_id",
+ "category_id"
+ ],
+ "autoGenerate": false
+ },
+ "indices": [
+ {
+ "name": "index_widget_category_category_id",
+ "unique": false,
+ "columnNames": [
+ "category_id"
+ ],
+ "orders": [],
+ "createSql": "CREATE INDEX IF NOT EXISTS `index_widget_category_category_id` ON `${TABLE_NAME}` (`category_id`)"
+ }
+ ],
+ "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, 'cd8c9703a2467f95e13b2e79b1117bde')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 5f31d7c..cae3804 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -112,6 +112,14 @@
android:taskAffinity=":update"
android:launchMode="singleTop" />
+
+
+
+
+
+
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 e7544c9..c786f51 100644
--- a/app/src/main/java/io/timelimit/android/data/Database.kt
+++ b/app/src/main/java/io/timelimit/android/data/Database.kt
@@ -46,6 +46,7 @@ interface Database {
fun cryptContainerKeyResult(): CryptContainerKeyResultDao
fun deviceKey(): DeviceKeyDao
fun u2f(): U2FDao
+ fun widgetCategory(): WidgetCategoryDao
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 695c618..4419806 100644
--- a/app/src/main/java/io/timelimit/android/data/Migrations.kt
+++ b/app/src/main/java/io/timelimit/android/data/Migrations.kt
@@ -326,6 +326,13 @@ object DatabaseMigrations {
}
}
+ val MIGRATE_TO_V45 = object: Migration(44, 45) {
+ override fun migrate(database: SupportSQLiteDatabase) {
+ database.execSQL("CREATE TABLE IF NOT EXISTS `widget_category` (`widget_id` INTEGER NOT NULL, `category_id` TEXT NOT NULL, PRIMARY KEY(`widget_id`, `category_id`), FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )")
+ database.execSQL("CREATE INDEX IF NOT EXISTS `index_widget_category_category_id` ON `widget_category` (`category_id`)")
+ }
+ }
+
val ALL = arrayOf(
MIGRATE_TO_V2,
MIGRATE_TO_V3,
@@ -369,6 +376,7 @@ object DatabaseMigrations {
MIGRATE_TO_V41,
MIGRATE_TO_V42,
MIGRATE_TP_V43,
- MIGRATE_TO_V44
+ MIGRATE_TO_V44,
+ MIGRATE_TO_V45
)
}
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 99b2acc..0e24449 100644
--- a/app/src/main/java/io/timelimit/android/data/RoomDatabase.kt
+++ b/app/src/main/java/io/timelimit/android/data/RoomDatabase.kt
@@ -58,8 +58,9 @@ import java.util.concurrent.TimeUnit
CryptContainerPendingKeyRequest::class,
CryptContainerKeyResult::class,
DevicePublicKey::class,
- UserU2FKey::class
-], version = 44)
+ UserU2FKey::class,
+ WidgetCategory::class
+], version = 45)
abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database {
companion object {
private val lock = Object()
diff --git a/app/src/main/java/io/timelimit/android/data/dao/WidgetCategoryDao.kt b/app/src/main/java/io/timelimit/android/data/dao/WidgetCategoryDao.kt
new file mode 100644
index 0000000..b54f0e3
--- /dev/null
+++ b/app/src/main/java/io/timelimit/android/data/dao/WidgetCategoryDao.kt
@@ -0,0 +1,47 @@
+/*
+ * 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.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import io.timelimit.android.data.model.WidgetCategory
+
+@Dao
+interface WidgetCategoryDao {
+ @Query("DELETE FROM widget_category WHERE widget_id = :widgetId")
+ fun deleteByWidgetId(widgetId: Int)
+
+ @Query("DELETE FROM widget_category WHERE widget_id IN (:widgetIds)")
+ fun deleteByWidgetIds(widgetIds: IntArray)
+
+ @Query("DELETE FROM widget_category WHERE widget_id = :widgetId AND category_id IN (:categoryIds)")
+ fun deleteByWidgetIdAndCategoryIds(widgetId: Int, categoryIds: List)
+
+ @Query("DELETE FROM widget_category")
+ fun deleteAll()
+
+ @Query("SELECT * FROM widget_category")
+ fun queryLive(): LiveData>
+
+ @Query("SELECT category_id FROM widget_category WHERE widget_id = :widgetId")
+ fun queryByWidgetIdSync(widgetId: Int): List
+
+ @Insert(onConflict = OnConflictStrategy.IGNORE)
+ fun insert(items: List)
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/timelimit/android/data/model/WidgetCategory.kt b/app/src/main/java/io/timelimit/android/data/model/WidgetCategory.kt
new file mode 100644
index 0000000..58bd3f5
--- /dev/null
+++ b/app/src/main/java/io/timelimit/android/data/model/WidgetCategory.kt
@@ -0,0 +1,40 @@
+/*
+ * 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 androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.ForeignKey
+
+@Entity(
+ tableName = "widget_category",
+ primaryKeys = ["widget_id", "category_id"],
+ foreignKeys = [
+ ForeignKey(
+ entity = Category::class,
+ parentColumns = ["id"],
+ childColumns = ["category_id"],
+ onUpdate = ForeignKey.CASCADE,
+ onDelete = ForeignKey.CASCADE
+ )
+ ]
+)
+data class WidgetCategory (
+ @ColumnInfo(name = "widget_id")
+ val widgetId: Int,
+ @ColumnInfo(name = "category_id", index = true)
+ val categoryId: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/io/timelimit/android/integration/platform/android/BackgroundActionService.kt b/app/src/main/java/io/timelimit/android/integration/platform/android/BackgroundActionService.kt
index d458f22..df56620 100644
--- a/app/src/main/java/io/timelimit/android/integration/platform/android/BackgroundActionService.kt
+++ b/app/src/main/java/io/timelimit/android/integration/platform/android/BackgroundActionService.kt
@@ -1,5 +1,5 @@
/*
- * TimeLimit Copyright 2019 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
@@ -44,8 +44,13 @@ class BackgroundActionService: Service() {
fun prepareRevokeTemporarilyAllowed(context: Context) = Intent(context, BackgroundActionService::class.java)
.putExtra(ACTION, ACTION_REVOKE_TEMPORARILY_ALLOWED_APPS)
- fun prepareSwitchToDefaultUser(context: Context) = Intent(context, BackgroundActionService::class.java)
- .putExtra(ACTION, ACTION_SWITCH_TO_DEFAULT_USER)
+ fun getSwitchToDefaultUserIntent(context: Context) = PendingIntent.getService(
+ context,
+ PendingIntentIds.SWITCH_TO_DEFAULT_USER,
+ Intent(context, BackgroundActionService::class.java)
+ .putExtra(ACTION, ACTION_SWITCH_TO_DEFAULT_USER),
+ PendingIntentIds.PENDING_INTENT_FLAGS
+ )
fun prepareDismissNotification(context: Context, type: Int, id: String) = Intent(context, BackgroundActionService::class.java)
.putExtra(ACTION, ACTION_DISMISS_NOTIFICATION)
diff --git a/app/src/main/java/io/timelimit/android/integration/platform/android/BackgroundService.kt b/app/src/main/java/io/timelimit/android/integration/platform/android/BackgroundService.kt
index 80cbd52..63b6d8d 100644
--- a/app/src/main/java/io/timelimit/android/integration/platform/android/BackgroundService.kt
+++ b/app/src/main/java/io/timelimit/android/integration/platform/android/BackgroundService.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
@@ -17,7 +17,6 @@ package io.timelimit.android.integration.platform.android
import android.app.ActivityManager
import android.app.NotificationManager
-import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
@@ -89,12 +88,7 @@ class BackgroundService: Service() {
NotificationCompat.Action.Builder(
R.drawable.ic_account_circle_black_24dp,
context.getString(R.string.manage_device_default_user_switch_btn),
- PendingIntent.getService(
- context,
- PendingIntentIds.SWITCH_TO_DEFAULT_USER,
- BackgroundActionService.prepareSwitchToDefaultUser(context),
- PendingIntentIds.PENDING_INTENT_FLAGS
- )
+ BackgroundActionService.getSwitchToDefaultUserIntent(context)
).build()
)
}
diff --git a/app/src/main/java/io/timelimit/android/logic/AppLogic.kt b/app/src/main/java/io/timelimit/android/logic/AppLogic.kt
index c4f76d4..e98c472 100644
--- a/app/src/main/java/io/timelimit/android/logic/AppLogic.kt
+++ b/app/src/main/java/io/timelimit/android/logic/AppLogic.kt
@@ -30,6 +30,7 @@ import io.timelimit.android.sync.SyncUtil
import io.timelimit.android.sync.network.api.ServerApi
import io.timelimit.android.sync.websocket.NetworkStatusInterface
import io.timelimit.android.sync.websocket.WebsocketClientCreator
+import io.timelimit.android.ui.widget.TimesWidgetProvider
class AppLogic(
val platformIntegration: PlatformIntegration,
@@ -98,6 +99,7 @@ class AppLogic(
init {
WatchdogLogic(this)
+ TimesWidgetProvider.triggerUpdates(context)
}
val suspendAppsLogic = SuspendAppsLogic(this)
diff --git a/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetConfig.kt b/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetConfig.kt
new file mode 100644
index 0000000..02685e4
--- /dev/null
+++ b/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetConfig.kt
@@ -0,0 +1,24 @@
+/*
+ * 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.widget
+
+import io.timelimit.android.data.model.WidgetCategory
+
+data class TimesWidgetConfig (val categories: List) {
+ val widgetCategoriesByWidgetId by lazy {
+ categories.groupBy { it.widgetId }.mapValues { it.value.map { it.categoryId }.toSet() }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetContent.kt b/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetContent.kt
new file mode 100644
index 0000000..3e64f3b
--- /dev/null
+++ b/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetContent.kt
@@ -0,0 +1,32 @@
+/*
+ * 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.widget
+
+sealed class TimesWidgetContent {
+ object UnconfiguredDevice: TimesWidgetContent()
+ data class NoChildUser(val canSwitchToDefaultUser: Boolean): TimesWidgetContent()
+ data class Categories(
+ val categories: List- ,
+ val canSwitchToDefaultUser: Boolean
+ ): TimesWidgetContent() {
+ data class Item(
+ val categoryId: String,
+ val categoryName: String,
+ val level: Int,
+ val remainingTimeToday: Long?
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetContentLoader.kt b/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetContentLoader.kt
new file mode 100644
index 0000000..dd27153
--- /dev/null
+++ b/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetContentLoader.kt
@@ -0,0 +1,135 @@
+/*
+ * 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.widget
+
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MediatorLiveData
+import io.timelimit.android.async.Threads
+import io.timelimit.android.data.extensions.sortedCategories
+import io.timelimit.android.data.model.UserType
+import io.timelimit.android.livedata.ignoreUnchanged
+import io.timelimit.android.logic.AppLogic
+import io.timelimit.android.logic.RealTime
+import io.timelimit.android.logic.blockingreason.CategoryHandlingCache
+
+object TimesWidgetContentLoader {
+ fun with(logic: AppLogic): LiveData {
+ val database = logic.database
+ val realTimeLogic = logic.realTimeLogic
+ val realTime = RealTime.newInstance()
+ val categoryHandlingCache = CategoryHandlingCache()
+ val handler = Threads.mainThreadHandler
+
+ val deviceAndUserRelatedDataLive = database.derivedDataDao().getUserAndDeviceRelatedDataLive()
+ var deviceAndUserRelatedDataLiveLoaded = false
+
+ val batteryStatusLive = logic.platformIntegration.getBatteryStatusLive()
+
+ lateinit var timeModificationListener: () -> Unit
+ lateinit var updateByClockRunnable: Runnable
+ var isActive = false
+
+ val newResult = object: MediatorLiveData() {
+ override fun onActive() {
+ super.onActive()
+
+ isActive = true
+
+ realTimeLogic.registerTimeModificationListener(timeModificationListener)
+
+ // ensure that the next update gets scheduled
+ updateByClockRunnable.run()
+ }
+
+ override fun onInactive() {
+ super.onInactive()
+
+ isActive = true
+
+ realTimeLogic.unregisterTimeModificationListener(timeModificationListener)
+ handler.removeCallbacks(updateByClockRunnable)
+ }
+ }
+
+ fun update() {
+ handler.removeCallbacks(updateByClockRunnable)
+
+ if (!deviceAndUserRelatedDataLiveLoaded) { return }
+
+ val deviceAndUserRelatedData = deviceAndUserRelatedDataLive.value
+ val userRelatedData = deviceAndUserRelatedData?.userRelatedData
+ val canSwitchToDefaultUser = deviceAndUserRelatedData?.deviceRelatedData?.canSwitchToDefaultUser ?: false
+
+ if (deviceAndUserRelatedData == null) {
+ newResult.value = TimesWidgetContent.UnconfiguredDevice
+
+ return
+ } else if (userRelatedData?.user?.type != UserType.Child) {
+ newResult.value = TimesWidgetContent.NoChildUser(
+ canSwitchToDefaultUser = canSwitchToDefaultUser
+ )
+
+ return
+ }
+
+ realTimeLogic.getRealTime(realTime)
+
+ categoryHandlingCache.reportStatus(
+ user = userRelatedData,
+ timeInMillis = realTime.timeInMillis,
+ batteryStatus = logic.platformIntegration.getBatteryStatus(),
+ shouldTrustTimeTemporarily = realTime.shouldTrustTimeTemporarily,
+ assumeCurrentDevice = true,
+ currentNetworkId = null, // not relevant here
+ hasPremiumOrLocalMode = false // not relevant here
+ )
+
+ var maxTime = Long.MAX_VALUE
+
+ val categories = userRelatedData.sortedCategories().map { (level, category) ->
+ val handling = categoryHandlingCache.get(categoryId = category.category.id)
+
+ maxTime = maxTime.coerceAtMost(handling.dependsOnMaxTime)
+
+ TimesWidgetContent.Categories.Item(
+ categoryId = category.category.id,
+ categoryName = category.category.title,
+ level = level,
+ remainingTimeToday = handling.remainingTime?.includingExtraTime
+ )
+ }
+
+ newResult.value = TimesWidgetContent.Categories(
+ categories = categories,
+ canSwitchToDefaultUser = canSwitchToDefaultUser
+ )
+
+ if (isActive && maxTime != Long.MAX_VALUE) {
+ val delay = maxTime - realTime.timeInMillis
+
+ handler.postDelayed(updateByClockRunnable, delay)
+ }
+ }
+
+ timeModificationListener = { update() }
+ updateByClockRunnable = Runnable { update() }
+
+ newResult.addSource(deviceAndUserRelatedDataLive) { deviceAndUserRelatedDataLiveLoaded = true; update() }
+ newResult.addSource(batteryStatusLive) { update() }
+
+ return newResult.ignoreUnchanged()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetItem.kt b/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetItem.kt
index 47ce81b..985c5a4 100644
--- a/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetItem.kt
+++ b/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetItem.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
@@ -15,8 +15,8 @@
*/
package io.timelimit.android.ui.widget
-data class TimesWidgetItem(
- val title: String,
- val level: Int,
- val remainingTimeToday: Long?
-)
\ No newline at end of file
+sealed class TimesWidgetItem {
+ data class TextMessage(val textRessourceId: Int): TimesWidgetItem()
+ data class Category(val category: TimesWidgetContent.Categories.Item): TimesWidgetItem()
+ object DefaultUserButton: TimesWidgetItem()
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetItems.kt b/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetItems.kt
index dc78ac8..4ff43bd 100644
--- a/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetItems.kt
+++ b/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetItems.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
@@ -15,107 +15,31 @@
*/
package io.timelimit.android.ui.widget
-import androidx.lifecycle.LiveData
-import androidx.lifecycle.MediatorLiveData
-import io.timelimit.android.async.Threads
-import io.timelimit.android.data.extensions.sortedCategories
-import io.timelimit.android.livedata.ignoreUnchanged
-import io.timelimit.android.logic.AppLogic
-import io.timelimit.android.logic.RealTime
-import io.timelimit.android.logic.blockingreason.CategoryHandlingCache
+import io.timelimit.android.R
object TimesWidgetItems {
- fun with(logic: AppLogic): LiveData
> {
- val database = logic.database
- val realTimeLogic = logic.realTimeLogic
- val categoryHandlingCache = CategoryHandlingCache()
- val realTime = RealTime.newInstance()
- val handler = Threads.mainThreadHandler
+ fun with(content: TimesWidgetContent, config: TimesWidgetConfig, appWidgetId: Int): List = when (content) {
+ is TimesWidgetContent.UnconfiguredDevice -> listOf(TimesWidgetItem.TextMessage(R.string.widget_msg_unconfigured))
+ is TimesWidgetContent.NoChildUser -> {
+ val base = TimesWidgetItem.TextMessage(R.string.widget_msg_no_child)
- val deviceAndUserRelatedDataLive = database.derivedDataDao().getUserAndDeviceRelatedDataLive()
- var deviceAndUserRelatedDataLiveLoaded = false
-
- val batteryStatusLive = logic.platformIntegration.getBatteryStatusLive()
-
- lateinit var timeModificationListener: () -> Unit
- lateinit var updateByClockRunnable: Runnable
- var isActive = false
-
- val newResult = object: MediatorLiveData>() {
- override fun onActive() {
- super.onActive()
-
- isActive = true
-
- realTimeLogic.registerTimeModificationListener(timeModificationListener)
-
- // ensure that the next update gets scheduled
- updateByClockRunnable.run()
- }
-
- override fun onInactive() {
- super.onInactive()
-
- isActive = true
-
- realTimeLogic.unregisterTimeModificationListener(timeModificationListener)
- handler.removeCallbacks(updateByClockRunnable)
- }
+ if (content.canSwitchToDefaultUser) listOf(base, TimesWidgetItem.DefaultUserButton)
+ else listOf(base)
}
+ is TimesWidgetContent.Categories -> {
+ val categoryFilter = config.widgetCategoriesByWidgetId[appWidgetId] ?: emptySet()
- fun update() {
- handler.removeCallbacks(updateByClockRunnable)
+ val categoryItems = if (content.categories.isEmpty()) listOf(TimesWidgetItem.TextMessage(R.string.widget_msg_no_category))
+ else if (categoryFilter.isEmpty()) content.categories.map { TimesWidgetItem.Category(it) }
+ else {
+ val filteredCategories = content.categories.filter { categoryFilter.contains(it.categoryId) }
- if (!deviceAndUserRelatedDataLiveLoaded) { return }
-
- val deviceAndUserRelatedData = deviceAndUserRelatedDataLive.value
- val userRelatedData = deviceAndUserRelatedData?.userRelatedData
-
- if (userRelatedData == null) {
- newResult.value = emptyList(); return
+ if (filteredCategories.isEmpty()) listOf(TimesWidgetItem.TextMessage(R.string.widget_msg_no_filtered_category))
+ else filteredCategories.map { TimesWidgetItem.Category(it) }
}
- realTimeLogic.getRealTime(realTime)
-
- categoryHandlingCache.reportStatus(
- user = userRelatedData,
- timeInMillis = realTime.timeInMillis,
- batteryStatus = logic.platformIntegration.getBatteryStatus(),
- shouldTrustTimeTemporarily = realTime.shouldTrustTimeTemporarily,
- assumeCurrentDevice = true,
- currentNetworkId = null, // not relevant here
- hasPremiumOrLocalMode = false // not relevant here
- )
-
- var maxTime = Long.MAX_VALUE
-
- val list = userRelatedData.sortedCategories().map { (level, category) ->
- val handling = categoryHandlingCache.get(categoryId = category.category.id)
-
- maxTime = maxTime.coerceAtMost(handling.dependsOnMaxTime)
-
- TimesWidgetItem(
- title = category.category.title,
- level = level,
- remainingTimeToday = handling.remainingTime?.includingExtraTime
- )
- }
-
- newResult.value = list
-
- if (isActive && maxTime != Long.MAX_VALUE) {
- val delay = maxTime - realTime.timeInMillis
-
- handler.postDelayed(updateByClockRunnable, delay)
- }
+ if (content.canSwitchToDefaultUser) categoryItems + TimesWidgetItem.DefaultUserButton
+ else categoryItems
}
-
- timeModificationListener = { update() }
- updateByClockRunnable = Runnable { update() }
-
- newResult.addSource(deviceAndUserRelatedDataLive) { deviceAndUserRelatedDataLiveLoaded = true; update() }
- newResult.addSource(batteryStatusLive) { update() }
-
- return newResult.ignoreUnchanged()
}
}
\ No newline at end of file
diff --git a/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetProvider.kt b/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetProvider.kt
index 53f1811..a1656a6 100644
--- a/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetProvider.kt
+++ b/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetProvider.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
@@ -17,13 +17,45 @@ package io.timelimit.android.ui.widget
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
+import android.content.ComponentName
import android.content.Context
import android.content.Intent
+import android.util.Log
import android.widget.RemoteViews
+import androidx.core.content.getSystemService
+import io.timelimit.android.BuildConfig
import io.timelimit.android.R
+import io.timelimit.android.async.Threads
+import io.timelimit.android.integration.platform.android.BackgroundActionService
import io.timelimit.android.logic.DefaultAppLogic
class TimesWidgetProvider: AppWidgetProvider() {
+ companion object {
+ private const val LOG_TAG = "TimesWidgetProvider"
+
+ private fun handleUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
+ for (appWidgetId in appWidgetIds) {
+ val views = RemoteViews(context.packageName, R.layout.widget_times)
+
+ views.setRemoteAdapter(android.R.id.list, TimesWidgetService.intent(context, appWidgetId))
+ views.setPendingIntentTemplate(android.R.id.list, BackgroundActionService.getSwitchToDefaultUserIntent(context))
+ views.setEmptyView(android.R.id.list, android.R.id.empty)
+
+ appWidgetManager.updateAppWidget(appWidgetId, views)
+ }
+
+ TimesWidgetService.notifyContentChanges(context)
+ }
+
+ fun triggerUpdates(context: Context) {
+ context.getSystemService()?.also { appWidgetManager ->
+ val appWidgetIds = appWidgetManager.getAppWidgetIds(ComponentName(context, TimesWidgetProvider::class.java))
+
+ handleUpdate(context, appWidgetManager, appWidgetIds)
+ }
+ }
+ }
+
override fun onReceive(context: Context, intent: Intent?) {
super.onReceive(context, intent)
@@ -34,13 +66,38 @@ class TimesWidgetProvider: AppWidgetProvider() {
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
super.onUpdate(context, appWidgetManager, appWidgetIds)
- for (appWidgetId in appWidgetIds) {
- val views = RemoteViews(context.packageName, R.layout.widget_times)
+ handleUpdate(context, appWidgetManager, appWidgetIds)
+ }
- views.setRemoteAdapter(android.R.id.list, Intent(context, TimesWidgetService::class.java))
- views.setEmptyView(android.R.id.list, android.R.id.empty)
+ override fun onDeleted(context: Context, appWidgetIds: IntArray) {
+ super.onDeleted(context, appWidgetIds)
- appWidgetManager.updateAppWidget(appWidgetId, views)
+ val database = DefaultAppLogic.with(context).database
+
+ Threads.database.execute {
+ try {
+ database.widgetCategory().deleteByWidgetIds(appWidgetIds)
+ } catch (ex: Exception) {
+ if (BuildConfig.DEBUG) {
+ Log.d(LOG_TAG, "onDisabled", ex)
+ }
+ }
+ }
+ }
+
+ override fun onDisabled(context: Context) {
+ super.onDisabled(context)
+
+ val database = DefaultAppLogic.with(context).database
+
+ Threads.database.execute {
+ try {
+ database.widgetCategory().deleteAll()
+ } catch (ex: Exception) {
+ if (BuildConfig.DEBUG) {
+ Log.d(LOG_TAG, "onDisabled", ex)
+ }
+ }
}
}
}
\ No newline at end of file
diff --git a/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetService.kt b/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetService.kt
index 4b619f3..1d255b4 100644
--- a/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetService.kt
+++ b/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetService.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
@@ -17,101 +17,150 @@ package io.timelimit.android.ui.widget
import android.appwidget.AppWidgetManager
import android.content.ComponentName
+import android.content.Context
import android.content.Intent
+import android.net.Uri
import android.view.View
import android.widget.RemoteViews
import android.widget.RemoteViewsService
+import androidx.core.content.getSystemService
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
+import androidx.lifecycle.map
import io.timelimit.android.R
import io.timelimit.android.async.Threads
+import io.timelimit.android.livedata.mergeLiveDataWaitForValues
import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.ui.manage.child.category.CategoryItemLeftPadding
-import io.timelimit.android.util.TimeTextUtil
class TimesWidgetService: RemoteViewsService() {
- private val appWidgetManager: AppWidgetManager by lazy { AppWidgetManager.getInstance(this) }
+ companion object {
+ private const val EXTRA_APP_WIDGET_ID = "appWidgetId"
- private val categoriesLive: LiveData> by lazy {
- TimesWidgetItems.with(DefaultAppLogic.with(this))
+ fun intent(context: Context, appWidgetId: Int) = Intent(context, TimesWidgetService::class.java)
+ .setData(Uri.parse("widget:$appWidgetId"))
+ .putExtra(EXTRA_APP_WIDGET_ID, appWidgetId)
+
+ fun notifyContentChanges(context: Context) {
+ context.getSystemService()?.also { appWidgetManager ->
+ val widgetIds = appWidgetManager.getAppWidgetIds(ComponentName(context, TimesWidgetProvider::class.java))
+
+ appWidgetManager.notifyAppWidgetViewDataChanged(widgetIds, android.R.id.list)
+ }
+ }
}
- private var categoriesInput: List = emptyList()
- private var categoriesCurrent: List = categoriesInput
+ private val content: LiveData> by lazy {
+ val logic = DefaultAppLogic.with(this)
- private val categoriesObserver = Observer> {
- categoriesInput = it
+ val content = TimesWidgetContentLoader.with(logic)
+ val config = logic.database.widgetCategory().queryLive().map { TimesWidgetConfig(it) }
- val widgetIds = appWidgetManager.getAppWidgetIds(ComponentName(this, TimesWidgetProvider::class.java))
-
- appWidgetManager.notifyAppWidgetViewDataChanged(widgetIds, android.R.id.list)
+ mergeLiveDataWaitForValues(content, config)
}
- private val factory = object : RemoteViewsFactory {
+ private var observerCounter = 0
+ private var contentInput: Pair? = null
+
+ private val contentObserver = Observer> {
+ contentInput = it
+
+ notifyContentChanges(this)
+ }
+
+ private fun createFactory(appWidgetId: Int) = object : RemoteViewsFactory {
+ private var currentItems: List = emptyList()
+
+ init { onDataSetChanged() }
+
override fun onCreate() {
- Threads.mainThreadHandler.post { categoriesLive.observeForever(categoriesObserver) }
+ Threads.mainThreadHandler.post {
+ if (observerCounter < 0) throw IllegalStateException()
+ else if (observerCounter == 0) content.observeForever(contentObserver)
+
+ observerCounter++
+ }
}
override fun onDestroy() {
- Threads.mainThreadHandler.post { categoriesLive.removeObserver(categoriesObserver) }
+ Threads.mainThreadHandler.post {
+ if (observerCounter <= 0) throw IllegalStateException()
+ else if (observerCounter == 1) content.removeObserver(contentObserver)
+
+ observerCounter--
+ }
}
override fun onDataSetChanged() {
- categoriesCurrent = categoriesInput
+ currentItems = contentInput?.let { TimesWidgetItems.with(it.first, it.second, appWidgetId) } ?: emptyList()
}
- override fun getCount(): Int = categoriesCurrent.size
+ override fun getCount(): Int = currentItems.size
override fun getViewAt(position: Int): RemoteViews {
- if (position >= categoriesCurrent.size) {
- return RemoteViews(packageName, R.layout.widget_times_item)
+ if (position >= currentItems.size) {
+ return RemoteViews(packageName, R.layout.widget_times_category_item)
}
- val category = categoriesCurrent[position]
- val result = RemoteViews(packageName, R.layout.widget_times_item)
+ fun createCategoryItem(title: String?, subtitle: String, paddingLeft: Int) = RemoteViews(packageName, R.layout.widget_times_category_item).also { result ->
+ result.setTextViewText(R.id.title, title ?: "")
+ result.setTextViewText(R.id.subtitle, subtitle)
- result.setTextViewText(R.id.title, category.title)
- result.setTextViewText(
- R.id.subtitle,
- if (category.remainingTimeToday == null)
- getString(R.string.manage_child_category_no_time_limits)
- else
- TimeTextUtil.remaining(category.remainingTimeToday.toInt(), this@TimesWidgetService)
- )
+ result.setViewPadding(R.id.widgetInnerContainer, paddingLeft, 0, 0, 0)
+ result.setViewVisibility(R.id.title, if (title != null) View.VISIBLE else View.GONE)
+ result.setViewVisibility(R.id.topPadding, if (position == 0) View.VISIBLE else View.GONE)
+ result.setViewVisibility(R.id.bottomPadding, if (position == count - 1) View.VISIBLE else View.GONE)
+ }
- result.setViewPadding(
- R.id.widgetInnerContainer,
- // not much space here => / 2
- CategoryItemLeftPadding.calculate(category.level, this@TimesWidgetService) / 2,
- 0, 0, 0
- )
+ val item = currentItems[position]
- result.setViewVisibility(R.id.topPadding, if (position == 0) View.VISIBLE else View.GONE)
- result.setViewVisibility(R.id.bottomPadding, if (position == count - 1) View.VISIBLE else View.GONE)
+ return when (item) {
+ is TimesWidgetItem.Category -> item.category.let { category ->
+ createCategoryItem(
+ title = if (category.remainingTimeToday == null)
+ getString(R.string.manage_child_category_no_time_limits_short)
+ else {
+ val remainingTimeToday = category.remainingTimeToday.coerceAtLeast(0) / (1000 * 60)
+ val minutes = remainingTimeToday % 60
+ val hours = remainingTimeToday / 60
- return result
+ if (hours == 0L) "$minutes m"
+ else "$hours h $minutes m"
+ },
+ subtitle = category.categoryName,
+ // not much space here => / 2
+ paddingLeft = CategoryItemLeftPadding.calculate(category.level, this@TimesWidgetService) / 2
+ )
+ }
+ is TimesWidgetItem.TextMessage -> createCategoryItem(null, getString(item.textRessourceId), 0)
+ is TimesWidgetItem.DefaultUserButton -> RemoteViews(packageName, R.layout.widget_times_button).also { result ->
+ result.setTextViewText(R.id.button, getString(R.string.manage_device_default_user_switch_btn))
+ result.setOnClickFillInIntent(R.id.button, Intent())
+ }
+ }
}
- override fun getLoadingView(): RemoteViews? {
- return null
- }
+ override fun getLoadingView(): RemoteViews? = null
- override fun getViewTypeCount(): Int {
- return 1
- }
+ override fun getViewTypeCount(): Int = 2
override fun getItemId(position: Int): Long {
- if (position >= categoriesCurrent.size) {
+ if (position >= currentItems.size) {
return -(position.toLong())
}
- return categoriesCurrent[position].hashCode().toLong()
+ val item = currentItems[position]
+
+ return when (item) {
+ is TimesWidgetItem.Category -> item.category.categoryId.hashCode()
+ else -> item.hashCode()
+ }.toLong()
}
- override fun hasStableIds(): Boolean {
- return true
- }
+ override fun hasStableIds(): Boolean = true
}
- override fun onGetViewFactory(intent: Intent): RemoteViewsFactory = factory
+ override fun onGetViewFactory(intent: Intent): RemoteViewsFactory = createFactory(
+ intent.getIntExtra(EXTRA_APP_WIDGET_ID, 0)
+ )
}
\ No newline at end of file
diff --git a/app/src/main/java/io/timelimit/android/ui/widget/config/UnconfiguredDialogFragment.kt b/app/src/main/java/io/timelimit/android/ui/widget/config/UnconfiguredDialogFragment.kt
new file mode 100644
index 0000000..c698680
--- /dev/null
+++ b/app/src/main/java/io/timelimit/android/ui/widget/config/UnconfiguredDialogFragment.kt
@@ -0,0 +1,51 @@
+/*
+ * 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.widget.config
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.os.Bundle
+import androidx.appcompat.app.AlertDialog
+import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.activityViewModels
+import io.timelimit.android.R
+
+class UnconfiguredDialogFragment: DialogFragment() {
+ companion object {
+ const val DIALOG_TAG = "UnconfiguredDialogFragment"
+ }
+
+ private val model: WidgetConfigModel by activityViewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ model.state.observe(this) {
+ if (!(it is WidgetConfigModel.State.Unconfigured)) dismissAllowingStateLoss()
+ }
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = AlertDialog.Builder(requireContext(), theme)
+ .setMessage(R.string.widget_msg_unconfigured)
+ .setPositiveButton(R.string.generic_ok) { _, _ -> model.userCancel() }
+ .create()
+
+ override fun onCancel(dialog: DialogInterface) {
+ super.onCancel(dialog)
+
+ model.userCancel()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/timelimit/android/ui/widget/config/WidgetConfigActivity.kt b/app/src/main/java/io/timelimit/android/ui/widget/config/WidgetConfigActivity.kt
new file mode 100644
index 0000000..b1c09e3
--- /dev/null
+++ b/app/src/main/java/io/timelimit/android/ui/widget/config/WidgetConfigActivity.kt
@@ -0,0 +1,78 @@
+/*
+ * 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.widget.config
+
+import android.appwidget.AppWidgetManager
+import android.content.Intent
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.viewModels
+import androidx.fragment.app.FragmentActivity
+import io.timelimit.android.R
+import io.timelimit.android.extensions.showSafe
+
+class WidgetConfigActivity: FragmentActivity() {
+ private val model: WidgetConfigModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ if (model.state.value == WidgetConfigModel.State.WaitingForInit) {
+ model.init(
+ intent?.getIntExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, AppWidgetManager.INVALID_APPWIDGET_ID)
+ ?: AppWidgetManager.INVALID_APPWIDGET_ID
+ )
+ }
+
+ model.state.observe(this) { state ->
+ when (state) {
+ is WidgetConfigModel.State.WaitingForInit -> {/* ignore */}
+ is WidgetConfigModel.State.Working -> {/* ignore */}
+ is WidgetConfigModel.State.Unconfigured -> {
+ if (supportFragmentManager.findFragmentByTag(UnconfiguredDialogFragment.DIALOG_TAG) == null) {
+ UnconfiguredDialogFragment().showSafe(supportFragmentManager, UnconfiguredDialogFragment.DIALOG_TAG)
+ }
+ }
+ is WidgetConfigModel.State.ShowModeSelection -> {
+ if (supportFragmentManager.findFragmentByTag(WidgetConfigModeDialogFragment.DIALOG_TAG) == null) {
+ WidgetConfigModeDialogFragment().showSafe(supportFragmentManager, WidgetConfigModeDialogFragment.DIALOG_TAG)
+ }
+ }
+ is WidgetConfigModel.State.ShowCategorySelection -> {
+ if (supportFragmentManager.findFragmentByTag(WidgetConfigFilterDialogFragment.DIALOG_TAG) == null) {
+ WidgetConfigFilterDialogFragment().showSafe(supportFragmentManager, WidgetConfigFilterDialogFragment.DIALOG_TAG)
+ }
+ }
+ is WidgetConfigModel.State.Done -> {
+ setResult(
+ RESULT_OK,
+ Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, state.appWidgetId)
+ )
+
+ finish()
+ }
+ is WidgetConfigModel.State.ErrorCancel -> {
+ Toast.makeText(this, R.string.error_general, Toast.LENGTH_SHORT).show()
+
+ finish()
+ }
+ is WidgetConfigModel.State.UserCancel -> finish()
+ }
+ }
+
+ setResult(RESULT_CANCELED)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/timelimit/android/ui/widget/config/WidgetConfigFilterDialogFragment.kt b/app/src/main/java/io/timelimit/android/ui/widget/config/WidgetConfigFilterDialogFragment.kt
new file mode 100644
index 0000000..5925efa
--- /dev/null
+++ b/app/src/main/java/io/timelimit/android/ui/widget/config/WidgetConfigFilterDialogFragment.kt
@@ -0,0 +1,88 @@
+/*
+ * 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.widget.config
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.os.Bundle
+import android.widget.Toast
+import androidx.appcompat.app.AlertDialog
+import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.activityViewModels
+import io.timelimit.android.R
+
+class WidgetConfigFilterDialogFragment: DialogFragment() {
+ companion object {
+ private const val STATE_CATEGORY_IDS = "categoryIds"
+
+ const val DIALOG_TAG = "WidgetConfigFilterDialogFragment"
+ }
+
+ private val model: WidgetConfigModel by activityViewModels()
+ private val selectedCategoryIds = mutableSetOf()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ model.state.value?.also { state ->
+ if (state is WidgetConfigModel.State.ShowCategorySelection) {
+ selectedCategoryIds.clear()
+ selectedCategoryIds.addAll(state.selectedFilterCategories)
+ }
+ }
+
+ savedInstanceState?.also {
+ selectedCategoryIds.clear()
+ selectedCategoryIds.addAll(it.getStringArray(STATE_CATEGORY_IDS) ?: emptyArray())
+ }
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+
+ outState.putStringArray(STATE_CATEGORY_IDS, selectedCategoryIds.toTypedArray())
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ val state = model.state.value
+
+ if (!(state is WidgetConfigModel.State.ShowCategorySelection)) return super.onCreateDialog(savedInstanceState)
+
+ return AlertDialog.Builder(requireContext(), theme)
+ .setMultiChoiceItems(
+ state.categories.map { it.title }.toTypedArray(),
+ state.categories.map { selectedCategoryIds.contains(it.id) }.toBooleanArray()
+ ) { _, index, checked ->
+ val categoryId = state.categories[index].id
+
+ if (checked) selectedCategoryIds.add(categoryId) else selectedCategoryIds.remove(categoryId)
+ }
+ .setPositiveButton(R.string.wiazrd_next) { _, _ ->
+ if (selectedCategoryIds.isEmpty()) {
+ Toast.makeText(requireContext(), R.string.widget_config_error_filter_empty, Toast.LENGTH_SHORT).show()
+
+ model.userCancel()
+ } else model.selectFilterItems(selectedCategoryIds)
+ }
+ .create()
+ }
+
+ override fun onCancel(dialog: DialogInterface) {
+ super.onCancel(dialog)
+
+ model.userCancel()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/timelimit/android/ui/widget/config/WidgetConfigModeDialogFragment.kt b/app/src/main/java/io/timelimit/android/ui/widget/config/WidgetConfigModeDialogFragment.kt
new file mode 100644
index 0000000..ffe97cd
--- /dev/null
+++ b/app/src/main/java/io/timelimit/android/ui/widget/config/WidgetConfigModeDialogFragment.kt
@@ -0,0 +1,77 @@
+/*
+ * 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.widget.config
+
+import android.app.Dialog
+import android.content.DialogInterface
+import android.os.Bundle
+import androidx.appcompat.app.AlertDialog
+import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.activityViewModels
+import io.timelimit.android.R
+
+class WidgetConfigModeDialogFragment: DialogFragment() {
+ companion object {
+ private const val STATE_SELECTION = "selection"
+
+ const val DIALOG_TAG = "WidgetConfigModeDialogFragment"
+ }
+
+ private val model: WidgetConfigModel by activityViewModels()
+ private var selection = 0
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ model.state.value?.also {
+ if (it is WidgetConfigModel.State.ShowModeSelection) {
+ selection = if (it.selectedFilterCategories.isEmpty()) 0 else 1
+ }
+ }
+
+ savedInstanceState?.also { selection = it.getInt(STATE_SELECTION) }
+
+ model.state.observe(this) {
+ if (!(it is WidgetConfigModel.State.ShowModeSelection)) dismissAllowingStateLoss()
+ }
+ }
+
+ override fun onSaveInstanceState(outState: Bundle) {
+ super.onSaveInstanceState(outState)
+
+ outState.putInt(STATE_SELECTION, selection)
+ }
+
+ override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = AlertDialog.Builder(requireContext(), theme)
+ .setSingleChoiceItems(
+ arrayOf(
+ getString(R.string.widget_config_mode_all),
+ getString(R.string.widget_config_mode_filter)
+ ),
+ selection
+ ) { _, selectedItemIndex -> selection = selectedItemIndex }
+ .setPositiveButton(R.string.wiazrd_next) { _, _ ->
+ if (selection == 0) model.selectModeAll()
+ else model.selectModeFilter()
+ }
+ .create()
+
+ override fun onCancel(dialog: DialogInterface) {
+ super.onCancel(dialog)
+
+ model.userCancel()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/io/timelimit/android/ui/widget/config/WidgetConfigModel.kt b/app/src/main/java/io/timelimit/android/ui/widget/config/WidgetConfigModel.kt
new file mode 100644
index 0000000..98d5858
--- /dev/null
+++ b/app/src/main/java/io/timelimit/android/ui/widget/config/WidgetConfigModel.kt
@@ -0,0 +1,165 @@
+/*
+ * 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.widget.config
+
+import android.app.Application
+import android.util.Log
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.viewModelScope
+import io.timelimit.android.BuildConfig
+import io.timelimit.android.async.Threads
+import io.timelimit.android.coroutines.executeAndWait
+import io.timelimit.android.data.extensions.sortedCategories
+import io.timelimit.android.data.model.Category
+import io.timelimit.android.data.model.UserType
+import io.timelimit.android.data.model.WidgetCategory
+import io.timelimit.android.livedata.castDown
+import io.timelimit.android.logic.DefaultAppLogic
+import kotlinx.coroutines.launch
+
+class WidgetConfigModel(application: Application): AndroidViewModel(application) {
+ companion object {
+ private const val LOG_TAG = "WidgetConfigModel"
+ }
+
+ private val stateInternal = MutableLiveData().apply { value = State.WaitingForInit }
+ private val database = DefaultAppLogic.with(application).database
+
+ val state = stateInternal.castDown()
+
+ fun init(appWidgetId: Int) {
+ if (state.value != State.WaitingForInit) return
+ stateInternal.value = State.Working
+
+ viewModelScope.launch {
+ try {
+ val (deviceAndUserRelatedData, selectedFilterCategories) = Threads.database.executeAndWait {
+ database.runInTransaction {
+ val deviceAndUserRelatedData = database.derivedDataDao().getUserAndDeviceRelatedDataSync()
+ val selectedFilterCategories = database.widgetCategory().queryByWidgetIdSync(appWidgetId).toSet()
+
+ Pair(deviceAndUserRelatedData, selectedFilterCategories)
+ }
+ }
+
+ val isNoChildUser = deviceAndUserRelatedData?.userRelatedData?.user?.type != UserType.Child
+ val currentUserCategories = deviceAndUserRelatedData?.userRelatedData?.sortedCategories()?.map { it.second.category }
+
+ if (currentUserCategories == null || isNoChildUser) {
+ stateInternal.value = State.Unconfigured
+
+ return@launch
+ }
+
+ stateInternal.value = State.ShowModeSelection(appWidgetId, selectedFilterCategories, currentUserCategories)
+ } catch (ex: Exception) {
+ if (BuildConfig.DEBUG) {
+ Log.d(LOG_TAG, "selectModeAll", ex)
+ }
+
+ stateInternal.value = State.ErrorCancel
+ }
+ }
+ }
+
+ fun selectModeAll() {
+ val oldState = state.value
+ if (!(oldState is State.ShowModeSelection)) return
+ stateInternal.value = State.Working
+
+ viewModelScope.launch {
+ try {
+ Threads.database.executeAndWait {
+ database.widgetCategory().deleteByWidgetId(oldState.appWidgetId)
+ }
+
+ stateInternal.value = State.Done(appWidgetId = oldState.appWidgetId)
+ } catch (ex: Exception) {
+ if (BuildConfig.DEBUG) {
+ Log.d(LOG_TAG, "selectModeAll", ex)
+ }
+
+ stateInternal.value = State.ErrorCancel
+ }
+ }
+ }
+
+ fun selectModeFilter() {
+ val oldState = state.value
+ if (!(oldState is State.ShowModeSelection)) return
+ stateInternal.value = State.Working
+
+ stateInternal.value = State.ShowCategorySelection(
+ appWidgetId = oldState.appWidgetId,
+ selectedFilterCategories = oldState.selectedFilterCategories,
+ categories = oldState.categories
+ )
+ }
+
+ fun selectFilterItems(selectedCategoryIds: Set) {
+ val oldState = state.value
+ if (!(oldState is State.ShowCategorySelection)) return
+ stateInternal.value = State.Working
+
+ viewModelScope.launch {
+ try {
+ Threads.database.executeAndWait {
+ val userAndDeviceRelatedData = database.derivedDataDao().getUserAndDeviceRelatedDataSync()
+ val currentCategoryIds = userAndDeviceRelatedData!!.userRelatedData!!.categoryById.keys
+
+ val categoriesToRemove = currentCategoryIds - selectedCategoryIds
+ val categoriesToAdd = selectedCategoryIds.filter { currentCategoryIds.contains(it) }
+
+ if (categoriesToRemove.isNotEmpty()) {
+ database.widgetCategory().deleteByWidgetIdAndCategoryIds(
+ oldState.appWidgetId, categoriesToRemove.toList()
+ )
+ }
+
+ if (categoriesToAdd.isNotEmpty()) {
+ database.widgetCategory().insert(
+ categoriesToAdd.toList().map { WidgetCategory(oldState.appWidgetId, it) }
+ )
+ }
+ }
+
+ stateInternal.value = State.Done(appWidgetId = oldState.appWidgetId)
+ } catch (ex: Exception) {
+ if (BuildConfig.DEBUG) {
+ Log.d(LOG_TAG, "selectModeAll", ex)
+ }
+
+ stateInternal.value = State.ErrorCancel
+ }
+ }
+ }
+
+ fun userCancel() {
+ stateInternal.value = State.UserCancel
+ }
+
+ sealed class State {
+ object WaitingForInit: State()
+ object Working: State()
+ data class ShowModeSelection(val appWidgetId: Int, val selectedFilterCategories: Set, val categories: List): State()
+ data class ShowCategorySelection(val appWidgetId: Int, val selectedFilterCategories: Set, val categories: List): State()
+ data class Done(val appWidgetId: Int): State()
+ object Unconfigured: State()
+ object UserCancel: State()
+ object ErrorCancel: State()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/res/drawable-de/widget_preview.png b/app/src/main/res/drawable-de/widget_preview.png
index b5d2c55..125126b 100644
Binary files a/app/src/main/res/drawable-de/widget_preview.png and b/app/src/main/res/drawable-de/widget_preview.png differ
diff --git a/app/src/main/res/drawable/widget_preview.png b/app/src/main/res/drawable/widget_preview.png
index 0cb60f2..fbba680 100644
Binary files a/app/src/main/res/drawable/widget_preview.png and b/app/src/main/res/drawable/widget_preview.png differ
diff --git a/app/src/main/res/layout/widget_times.xml b/app/src/main/res/layout/widget_times.xml
index 9dc6c37..17e6a4f 100644
--- a/app/src/main/res/layout/widget_times.xml
+++ b/app/src/main/res/layout/widget_times.xml
@@ -1,23 +1,23 @@
-
+ android:background="@color/widgetBackground">
-
+ android:layout_width="wrap_content"
+ android:layout_height="wrap_content"
+ style="?android:attr/progressBarStyleLarge"
+ android:layout_gravity="center" />
\ No newline at end of file
diff --git a/app/src/main/res/layout/widget_times_button.xml b/app/src/main/res/layout/widget_times_button.xml
new file mode 100644
index 0000000..1dcc003
--- /dev/null
+++ b/app/src/main/res/layout/widget_times_button.xml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_times_item.xml b/app/src/main/res/layout/widget_times_category_item.xml
similarity index 87%
rename from app/src/main/res/layout/widget_times_item.xml
rename to app/src/main/res/layout/widget_times_category_item.xml
index baa59e0..a44894e 100644
--- a/app/src/main/res/layout/widget_times_item.xml
+++ b/app/src/main/res/layout/widget_times_category_item.xml
@@ -1,6 +1,6 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index efd8817..0d42ff5 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -769,6 +769,7 @@
erweiterte Einstellungen
Keine Zeitbegrenzung
+ keine Begrenzung
Kategorien
@@ -1608,10 +1609,13 @@
%s bis %s
- Wenn dieses Gerät einem Kind zugeordnet
- wird und dieses Kind Kategorien hat, dann sollten diese Kategorien
- hier angezeigt werden.
-
+ Dieses Widget funktioniert nur auf beschränkten Geräten
+ Momentan hat dieses Gerät keinen Kind-Benutzer
+ Das Kind hat keine Kategorien
+ Das Kind hat keine für das Widget ausgewählten Kategorien
+ alle Kategorien anzeigen
+ sichtbare Kategorien filtern
+ Sie müssen mindestens eine Kategorie auswählen
Weiter
diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml
index d667f6c..69b31d7 100644
--- a/app/src/main/res/values-night/colors.xml
+++ b/app/src/main/res/values-night/colors.xml
@@ -1,6 +1,6 @@
\ No newline at end of file