From 7c8c00b5393c48509650ed88a2fd39ecbb453b3e Mon Sep 17 00:00:00 2001 From: Jonas Lochmann Date: Mon, 29 Jun 2020 02:00:00 +0200 Subject: [PATCH] Add user limit login category feature --- .../31.json | 1070 +++++++++++++++++ .../io/timelimit/android/data/Database.kt | 1 + .../io/timelimit/android/data/Migrations.kt | 7 + .../io/timelimit/android/data/RoomDatabase.kt | 8 +- .../data/backup/DatabaseBackupLowlevel.kt | 19 + .../android/data/dao/DerivedDataDao.kt | 127 +- .../data/dao/UserLimitLoginCategoryDao.kt | 58 + .../android/data/extensions/Category.kt | 13 + .../android/data/invalidation/Tables.kt | 6 +- .../data/model/UserLimitLoginCategory.kt | 108 ++ .../derived/CompleteUserLoginRelatedData.kt | 23 + .../model/derived/UserLoginRelatedData.kt | 78 ++ .../logic/blockingreason/AppBaseHandling.kt | 15 +- .../blockingreason/CategoryItselfHandling.kt | 17 + .../android/sync/ApplyServerDataStatus.kt | 38 +- .../timelimit/android/sync/actions/Actions.kt | 26 + .../timelimit/android/sync/actions/Parser.kt | 1 + .../sync/actions/dispatch/ParentAction.kt | 32 +- .../android/sync/network/ServerDataStatus.kt | 104 +- .../android/ui/login/AllowUserLoginStatus.kt | 208 ++++ .../ui/login/LoginDialogFragmentModel.kt | 207 ++-- .../android/ui/login/NewLoginFragment.kt | 29 +- .../ui/manage/parent/ManageParentFragment.kt | 10 + .../manage/parent/delete/DeleteParentModel.kt | 18 +- .../manage/parent/delete/DeleteParentView.kt | 3 +- ...ginRestrictedToUserItselfDialogFragment.kt | 39 + ...tLimitLoginSelectCategoryDialogFragment.kt | 138 +++ .../parent/limitlogin/ParentLimitLoginView.kt | 70 ++ .../res/layout/fragment_manage_parent.xml | 3 + .../main/res/layout/new_login_fragment.xml | 5 +- ...ew_login_fragment_parent_login_blocked.xml | 37 + .../res/layout/parent_limit_login_view.xml | 74 ++ app/src/main/res/values-de/strings-login.xml | 1 + .../strings-manage-parent-blocked-times.xml | 10 +- .../res/values-de/strings-manage-parent.xml | 5 +- .../values-de/strings-parent-limit-login.xml | 34 + app/src/main/res/values/strings-login.xml | 1 + .../strings-manage-parent-blocked-times.xml | 10 +- .../main/res/values/strings-manage-parent.xml | 5 +- .../res/values/strings-parent-limit-login.xml | 31 + 40 files changed, 2502 insertions(+), 187 deletions(-) create mode 100644 app/schemas/io.timelimit.android.data.RoomDatabase/31.json create mode 100644 app/src/main/java/io/timelimit/android/data/dao/UserLimitLoginCategoryDao.kt create mode 100644 app/src/main/java/io/timelimit/android/data/model/UserLimitLoginCategory.kt create mode 100644 app/src/main/java/io/timelimit/android/data/model/derived/CompleteUserLoginRelatedData.kt create mode 100644 app/src/main/java/io/timelimit/android/data/model/derived/UserLoginRelatedData.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/login/AllowUserLoginStatus.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/parent/limitlogin/LimitLoginRestrictedToUserItselfDialogFragment.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/parent/limitlogin/ParentLimitLoginSelectCategoryDialogFragment.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/parent/limitlogin/ParentLimitLoginView.kt create mode 100644 app/src/main/res/layout/new_login_fragment_parent_login_blocked.xml create mode 100644 app/src/main/res/layout/parent_limit_login_view.xml create mode 100644 app/src/main/res/values-de/strings-parent-limit-login.xml create mode 100644 app/src/main/res/values/strings-parent-limit-login.xml diff --git a/app/schemas/io.timelimit.android.data.RoomDatabase/31.json b/app/schemas/io.timelimit.android.data.RoomDatabase/31.json new file mode 100644 index 0000000..c302755 --- /dev/null +++ b/app/schemas/io.timelimit.android.data.RoomDatabase/31.json @@ -0,0 +1,1070 @@ +{ + "formatVersion": 1, + "database": { + "version": 31, + "identityHash": "3e3b6bb155e867c011b722041a4cb9e8", + "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": "blockedTimes", + "columnName": "blocked_times", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "device", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `model` TEXT NOT NULL, `added_at` INTEGER NOT NULL, `current_user_id` TEXT NOT NULL, `apps_version` TEXT NOT NULL, `network_time` TEXT NOT NULL, `current_protection_level` TEXT NOT NULL, `highest_permission_level` TEXT NOT NULL, `current_usage_stats_permission` TEXT NOT NULL, `highest_usage_stats_permission` TEXT NOT NULL, `current_notification_access_permission` TEXT NOT NULL, `highest_notification_access_permission` TEXT NOT NULL, `current_app_version` INTEGER NOT NULL, `highest_app_version` INTEGER NOT NULL, `tried_disabling_device_admin` INTEGER NOT NULL, `did_reboot` INTEGER NOT NULL, `had_manipulation` INTEGER NOT NULL, `had_manipulation_flags` INTEGER NOT NULL, `did_report_uninstall` INTEGER NOT NULL, `is_user_kept_signed_in` INTEGER NOT NULL, `show_device_connected` INTEGER NOT NULL, `default_user` TEXT NOT NULL, `default_user_timeout` INTEGER NOT NULL, `consider_reboot_manipulation` INTEGER NOT NULL, `current_overlay_permission` TEXT NOT NULL, `highest_overlay_permission` TEXT NOT NULL, `current_accessibility_service_permission` INTEGER NOT NULL, `was_accessibility_service_permission` INTEGER NOT NULL, `enable_activity_level_blocking` INTEGER NOT NULL, `q_or_later` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "added_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentUserId", + "columnName": "current_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installedAppsVersion", + "columnName": "apps_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "networkTime", + "columnName": "network_time", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentProtectionLevel", + "columnName": "current_protection_level", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "highestProtectionLevel", + "columnName": "highest_permission_level", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentUsageStatsPermission", + "columnName": "current_usage_stats_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "highestUsageStatsPermission", + "columnName": "highest_usage_stats_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentNotificationAccessPermission", + "columnName": "current_notification_access_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "highestNotificationAccessPermission", + "columnName": "highest_notification_access_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentAppVersion", + "columnName": "current_app_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "highestAppVersion", + "columnName": "highest_app_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "manipulationTriedDisablingDeviceAdmin", + "columnName": "tried_disabling_device_admin", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "manipulationDidReboot", + "columnName": "did_reboot", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hadManipulation", + "columnName": "had_manipulation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hadManipulationFlags", + "columnName": "had_manipulation_flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "didReportUninstall", + "columnName": "did_report_uninstall", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUserKeptSignedIn", + "columnName": "is_user_kept_signed_in", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showDeviceConnected", + "columnName": "show_device_connected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultUser", + "columnName": "default_user", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultUserTimeout", + "columnName": "default_user_timeout", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "considerRebootManipulation", + "columnName": "consider_reboot_manipulation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentOverlayPermission", + "columnName": "current_overlay_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "highestOverlayPermission", + "columnName": "highest_overlay_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessibilityServiceEnabled", + "columnName": "current_accessibility_service_permission", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wasAccessibilityServiceEnabled", + "columnName": "was_accessibility_service_permission", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enableActivityLevelBlocking", + "columnName": "enable_activity_level_blocking", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "qOrLater", + "columnName": "q_or_later", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "app", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`device_id` TEXT NOT NULL, `package_name` TEXT NOT NULL, `title` TEXT NOT NULL, `launchable` INTEGER NOT NULL, `recommendation` TEXT NOT NULL, PRIMARY KEY(`device_id`, `package_name`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "device_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isLaunchable", + "columnName": "launchable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recommendation", + "columnName": "recommendation", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "device_id", + "package_name" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_app_device_id", + "unique": false, + "columnNames": [ + "device_id" + ], + "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" + ], + "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": "packageName", + "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" + ], + "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" + ], + "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, `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, 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": "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 + } + ], + "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, 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 + } + ], + "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" + ], + "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" + ], + "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" + ], + "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, 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 + } + ], + "primaryKey": { + "columnNames": [ + "user_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "user_limit_login_category_index_category_id", + "unique": false, + "columnNames": [ + "category_id" + ], + "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" + ] + } + ] + } + ], + "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, '3e3b6bb155e867c011b722041a4cb9e8')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/Database.kt b/app/src/main/java/io/timelimit/android/data/Database.kt index 0622df7..2f9feac 100644 --- a/app/src/main/java/io/timelimit/android/data/Database.kt +++ b/app/src/main/java/io/timelimit/android/data/Database.kt @@ -37,6 +37,7 @@ interface Database { fun userKey(): UserKeyDao fun sessionDuration(): SessionDurationDao fun derivedDataDao(): DerivedDataDao + fun userLimitLoginCategoryDao(): UserLimitLoginCategoryDao 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 4d31514..f17fda2 100644 --- a/app/src/main/java/io/timelimit/android/data/Migrations.kt +++ b/app/src/main/java/io/timelimit/android/data/Migrations.kt @@ -224,4 +224,11 @@ object DatabaseMigrations { database.execSQL("ALTER TABLE `user` ADD COLUMN `flags` INTEGER NOT NULL DEFAULT 0") } } + + val MIGRATE_TO_V31 = object: Migration(30, 31) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE TABLE IF NOT EXISTS `user_limit_login_category` (`user_id` TEXT NOT NULL, `category_id` TEXT NOT NULL, 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 )") + database.execSQL("CREATE INDEX IF NOT EXISTS `user_limit_login_category_index_category_id` ON `user_limit_login_category` (`category_id`)") + } + } } 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 58e6ba3..974eb2b 100644 --- a/app/src/main/java/io/timelimit/android/data/RoomDatabase.kt +++ b/app/src/main/java/io/timelimit/android/data/RoomDatabase.kt @@ -44,8 +44,9 @@ import java.util.concurrent.CountDownLatch Notification::class, AllowedContact::class, UserKey::class, - SessionDuration::class -], version = 30) + SessionDuration::class, + UserLimitLoginCategory::class +], version = 31) abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database { companion object { private val lock = Object() @@ -109,7 +110,8 @@ abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database DatabaseMigrations.MIGRATE_TO_V27, DatabaseMigrations.MIGRATE_TO_V28, DatabaseMigrations.MIGRATE_TO_V29, - DatabaseMigrations.MIGRATE_TO_V30 + DatabaseMigrations.MIGRATE_TO_V30, + DatabaseMigrations.MIGRATE_TO_V31 ) .build() } diff --git a/app/src/main/java/io/timelimit/android/data/backup/DatabaseBackupLowlevel.kt b/app/src/main/java/io/timelimit/android/data/backup/DatabaseBackupLowlevel.kt index 676ec46..7687d12 100644 --- a/app/src/main/java/io/timelimit/android/data/backup/DatabaseBackupLowlevel.kt +++ b/app/src/main/java/io/timelimit/android/data/backup/DatabaseBackupLowlevel.kt @@ -42,6 +42,7 @@ object DatabaseBackupLowlevel { private const val ALLOWED_CONTACT = "allowedContact" private const val USER_KEY = "userKey" private const val SESSION_DURATION = "sessionDuration" + private const val USER_LIMIT_LOGIN_CATEGORY = "userLimitLoginCategory" fun outputAsBackupJson(database: Database, outputStream: OutputStream) { val writer = JsonWriter(OutputStreamWriter(outputStream, Charsets.UTF_8)) @@ -88,6 +89,7 @@ object DatabaseBackupLowlevel { handleCollection(ALLOWED_CONTACT) { offset, pageSize -> database.allowedContact().getAllowedContactPageSync(offset, pageSize) } handleCollection(USER_KEY) { offset, pageSize -> database.userKey().getUserKeyPageSync(offset, pageSize) } handleCollection(SESSION_DURATION) { offset, pageSize -> database.sessionDuration().getSessionDurationPageSync(offset, pageSize) } + handleCollection(USER_LIMIT_LOGIN_CATEGORY) { offset, pageSize -> database.userLimitLoginCategoryDao().getAllowedContactPageSync(offset, pageSize) } writer.endObject().flush() } @@ -95,6 +97,8 @@ object DatabaseBackupLowlevel { fun restoreFromBackupJson(database: Database, inputStream: InputStream) { val reader = JsonReader(InputStreamReader(inputStream, Charsets.UTF_8)) + var userLoginLimitCategories = emptyList() + database.runInTransaction { database.deleteAllData() @@ -234,10 +238,25 @@ object DatabaseBackupLowlevel { reader.endArray() } + USER_LIMIT_LOGIN_CATEGORY -> { + reader.beginArray() + + mutableListOf().let { list -> + while (reader.hasNext()) { + list.add(UserLimitLoginCategory.parse(reader)) + } + + userLoginLimitCategories = list + } + + reader.endArray() + } else -> reader.skipValue() } } reader.endObject() + + database.userLimitLoginCategoryDao().addItemsSync(userLoginLimitCategories) } } } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/dao/DerivedDataDao.kt b/app/src/main/java/io/timelimit/android/data/dao/DerivedDataDao.kt index e22453b..763a01a 100644 --- a/app/src/main/java/io/timelimit/android/data/dao/DerivedDataDao.kt +++ b/app/src/main/java/io/timelimit/android/data/dao/DerivedDataDao.kt @@ -20,9 +20,7 @@ import androidx.lifecycle.LiveData import io.timelimit.android.data.Database import io.timelimit.android.data.cache.multi.* import io.timelimit.android.data.cache.single.* -import io.timelimit.android.data.model.derived.DeviceAndUserRelatedData -import io.timelimit.android.data.model.derived.DeviceRelatedData -import io.timelimit.android.data.model.derived.UserRelatedData +import io.timelimit.android.data.model.derived.* class DerivedDataDao (private val database: Database) { private val userRelatedDataCache = object : DataCacheHelperInterface { @@ -32,16 +30,8 @@ class DerivedDataDao (private val database: Database) { return UserRelatedData.load(user, database) } - override fun updateItemSync(key: String, item: UserRelatedData?): UserRelatedData? { - return if (item != null) { - item.update(database) - } else { - openItemSync(key) - } - } - + override fun updateItemSync(key: String, item: UserRelatedData?): UserRelatedData? = if (item != null) item.update(database) else openItemSync(key) override fun wrapOpenOrUpdate(block: () -> R): R = database.runInUnobservedTransaction { block() } - override fun disposeItemFast(key: String, item: UserRelatedData?) = Unit override fun prepareForUser(item: UserRelatedData?): UserRelatedData? = item override fun close() = Unit @@ -49,27 +39,30 @@ class DerivedDataDao (private val database: Database) { private val deviceRelatedDataCache = object: SingleItemDataCacheHelperInterface { override fun openItemSync(): DeviceRelatedData? = DeviceRelatedData.load(database) - - override fun updateItemSync(item: DeviceRelatedData?): DeviceRelatedData? = if (item != null) { - item.update(database) - } else { - openItemSync() - } - + override fun updateItemSync(item: DeviceRelatedData?): DeviceRelatedData? = if (item != null) item.update(database) else openItemSync() override fun wrapOpenOrUpdate(block: () -> R): R = database.runInUnobservedTransaction { block() } - override fun prepareForUser(item: DeviceRelatedData?): DeviceRelatedData? = item override fun disposeItemFast(item: DeviceRelatedData?): Unit = Unit }.createCache() + private val userLoginRelatedDataCache = object: DataCacheHelperInterface { + override fun openItemSync(key: String): UserLoginRelatedData? = UserLoginRelatedData.load(key, database) + override fun updateItemSync(key: String, item: UserLoginRelatedData?): UserLoginRelatedData? = if (item != null) item.update(database) else openItemSync(key) + override fun wrapOpenOrUpdate(block: () -> R): R = database.runInUnobservedTransaction { block() } + override fun disposeItemFast(key: String, item: UserLoginRelatedData?) = Unit + override fun prepareForUser(item: UserLoginRelatedData?): UserLoginRelatedData? = item + override fun close() = Unit + }.createCache() + private val usableUserRelatedData = userRelatedDataCache.userInterface.delayClosingItems(15 * 1000 /* 15 seconds */) private val usableDeviceRelatedData = deviceRelatedDataCache.userInterface.delayClosingItem(60 * 1000 /* 1 minute */) + private val usableUserLoginRelatedDataCache = userLoginRelatedDataCache.userInterface.delayClosingItems(15 * 1000 /* 15 seconds */) private val deviceAndUserRelatedDataCache = object: SingleItemDataCacheHelperInterface { override fun openItemSync(): DeviceAndUserRelatedData? { val deviceRelatedData = usableDeviceRelatedData.openSync(null) ?: return null val userRelatedData = if (deviceRelatedData.deviceEntry.currentUserId.isNotEmpty()) - usableUserRelatedData.openSync(deviceRelatedData.deviceEntry.currentUserId, null) + usableUserRelatedData.openSync(deviceRelatedData.deviceEntry.currentUserId, null) else null @@ -80,27 +73,12 @@ class DerivedDataDao (private val database: Database) { } override fun updateItemSync(item: DeviceAndUserRelatedData?): DeviceAndUserRelatedData? { - val deviceRelatedData = usableDeviceRelatedData.openSync(null) ?: run { - // close old listener instances + try { + val newItem = openItemSync() + + return if (newItem != item) newItem else item + } finally { disposeItemFast(item) - - return null - } - val userRelatedData = if (deviceRelatedData.deviceEntry.currentUserId.isNotEmpty()) - usableUserRelatedData.openSync(deviceRelatedData.deviceEntry.currentUserId, null) - else - null - - // close old listener instances - disposeItemFast(item) - - return if (deviceRelatedData == item?.deviceRelatedData && userRelatedData == item.userRelatedData) { - item - } else { - DeviceAndUserRelatedData( - deviceRelatedData = deviceRelatedData, - userRelatedData = userRelatedData - ) } } @@ -109,21 +87,72 @@ class DerivedDataDao (private val database: Database) { override fun prepareForUser(item: DeviceAndUserRelatedData?): DeviceAndUserRelatedData? = item override fun disposeItemFast(item: DeviceAndUserRelatedData?) { - if (item != null) { - usableDeviceRelatedData.close(null) - item.userRelatedData?.user?.let { usableUserRelatedData.close(it.id, null) } + usableDeviceRelatedData.close(null) + item?.deviceRelatedData?.deviceEntry?.currentUserId?.let { + if (it.isNotEmpty()) { + usableUserRelatedData.close(it, null) + } } } }.createCache() + private val completeUserLoginRelatedData = object: DataCacheHelperInterface { + override fun openItemSync(key: String): CompleteUserLoginRelatedData? = database.runInUnobservedTransaction { + val userLoginRelatedData = usableUserLoginRelatedDataCache.openSync(key, null) + val deviceRelatedData = usableDeviceRelatedData.openSync(null) + + val limitLoginCategoryUserRelatedData = if (userLoginRelatedData?.limitLoginCategory == null) + null + else { + usableUserRelatedData.openSync(userLoginRelatedData.limitLoginCategory.childId, null) + } + + if (userLoginRelatedData == null || deviceRelatedData == null) { + null + } else { + CompleteUserLoginRelatedData( + loginRelatedData = userLoginRelatedData, + deviceRelatedData = deviceRelatedData, + limitLoginCategoryUserRelatedData = limitLoginCategoryUserRelatedData + ) + } + } + + override fun updateItemSync(key: String, item: CompleteUserLoginRelatedData?): CompleteUserLoginRelatedData? { + try { + val newItem = openItemSync(key) + + return if (newItem != item) newItem else item + } finally { + disposeItemFast(key, item) + } + } + + override fun disposeItemFast(key: String, item: CompleteUserLoginRelatedData?) { + usableUserLoginRelatedDataCache.close(key, null) + usableDeviceRelatedData.close(null) + item?.loginRelatedData?.limitLoginCategory?.let { category -> + usableUserRelatedData.close(category.childId, null) + } + } + + override fun wrapOpenOrUpdate(block: () -> R): R = database.runInUnobservedTransaction { block() } + override fun prepareForUser(item: CompleteUserLoginRelatedData?): CompleteUserLoginRelatedData? = item + override fun close() = Unit + }.createCache() + private val usableDeviceAndUserRelatedDataCache = deviceAndUserRelatedDataCache.userInterface.delayClosingItem(5000) + private val usableCompleteUserLoginRelatedData = completeUserLoginRelatedData.userInterface.delayClosingItems(5000) + private val deviceAndUserRelatedDataLive = usableDeviceAndUserRelatedDataCache.openLiveAtDatabaseThread() init { database.registerTransactionCommitListener { userRelatedDataCache.ownerInterface.updateSync() deviceRelatedDataCache.ownerInterface.updateSync() + userLoginRelatedDataCache.ownerInterface.updateSync() deviceAndUserRelatedDataCache.ownerInterface.updateSync() + completeUserLoginRelatedData.ownerInterface.updateSync() } } @@ -135,7 +164,17 @@ class DerivedDataDao (private val database: Database) { return result } + fun getUserLoginRelatedDataSync(userId: String): CompleteUserLoginRelatedData? { + val result = usableCompleteUserLoginRelatedData.openSync(userId, null) + + usableCompleteUserLoginRelatedData.close(userId, null) + + return result + } + fun getUserAndDeviceRelatedDataLive(): LiveData = deviceAndUserRelatedDataLive fun getUserRelatedDataLive(userId: String): LiveData = usableUserRelatedData.openLiveAtDatabaseThread(userId) + + fun getUserLoginRelatedDataLive(userId: String) = usableCompleteUserLoginRelatedData.openLiveAtDatabaseThread(userId) } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/dao/UserLimitLoginCategoryDao.kt b/app/src/main/java/io/timelimit/android/data/dao/UserLimitLoginCategoryDao.kt new file mode 100644 index 0000000..9240844 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/dao/UserLimitLoginCategoryDao.kt @@ -0,0 +1,58 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.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.UserLimitLoginCategory +import io.timelimit.android.data.model.UserLimitLoginCategoryWithChildId + +@Dao +interface UserLimitLoginCategoryDao { + @Query("SELECT * FROM user_limit_login_category LIMIT :pageSize OFFSET :offset") + fun getAllowedContactPageSync(offset: Int, pageSize: Int): List + + @Insert + fun addItemsSync(item: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertOrReplaceItemSync(item: UserLimitLoginCategory) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insertOrIgnoreItemSync(item: UserLimitLoginCategory) + + @Query("SELECT child_user.id AS child_id, child_user.name AS child_title, category.id AS category_id, category.title AS category_title, 1 AS selected FROM user_limit_login_category JOIN category ON (user_limit_login_category.category_id = category.id) JOIN user child_user ON (category.child_id = child_user.id) WHERE user_limit_login_category.user_id = :parentUserId") + fun getByParentUserIdLive(parentUserId: String): LiveData + + @Query("SELECT child_user.id AS child_id, child_user.name AS child_title, category.id AS category_id, category.title AS category_title, 1 AS selected FROM user_limit_login_category JOIN category ON (user_limit_login_category.category_id = category.id) JOIN user child_user ON (category.child_id = child_user.id) WHERE user_limit_login_category.user_id = :parentUserId") + fun getByParentUserIdSync(parentUserId: String): UserLimitLoginCategoryWithChildId? + + @Query("SELECT child_user.id AS child_id, child_user.name AS child_title, category.id AS category_id, category.title AS category_title, CASE WHEN category.id IN (SELECT user_limit_login_category.category_id FROM user_limit_login_category WHERE user_limit_login_category.user_id = :parentUserId) THEN 1 ELSE 0 END AS selected FROM user child_user JOIN category category ON (category.child_id = child_user.id)") + fun getLimitLoginCategoryOptions(parentUserId: String): LiveData> + + @Query("SELECT COUNT(*) FROM user WHERE user.id != :userId AND user.type = 'parent' AND user.id NOT IN (SELECT user_id FROM user_limit_login_category)") + fun countOtherUsersWithoutLimitLoginCategoryLive(userId: String): LiveData + + @Query("SELECT COUNT(*) FROM user WHERE user.id != :userId AND user.type = 'parent' AND user.id NOT IN (SELECT user_id FROM user_limit_login_category)") + fun countOtherUsersWithoutLimitLoginCategorySync(userId: String): Long + + @Query("DELETE FROM user_limit_login_category WHERE user_id = :userId") + fun removeItemSync(userId: String) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/extensions/Category.kt b/app/src/main/java/io/timelimit/android/data/extensions/Category.kt index 8481f49..d86797b 100644 --- a/app/src/main/java/io/timelimit/android/data/extensions/Category.kt +++ b/app/src/main/java/io/timelimit/android/data/extensions/Category.kt @@ -66,6 +66,19 @@ fun UserRelatedData.getChildCategories(categoryId: String): Set { return result } +fun UserRelatedData.getCategoryWithParentCategories(startCategoryId: String): Set { + val startCategory = categoryById[startCategoryId]!! + val categoryIds = mutableSetOf(startCategoryId) + + var currentCategory: CategoryRelatedData? = categoryById[startCategory.category.parentCategoryId] + + while (currentCategory != null && categoryIds.add(currentCategory.category.id)) { + currentCategory = categoryById[currentCategory.category.parentCategoryId] + } + + return categoryIds +} + fun List.getChildCategories(categoryId: String): Set { if (this.find { it.id == categoryId } != null) { return emptySet() diff --git a/app/src/main/java/io/timelimit/android/data/invalidation/Tables.kt b/app/src/main/java/io/timelimit/android/data/invalidation/Tables.kt index dc7fd89..1aba83a 100644 --- a/app/src/main/java/io/timelimit/android/data/invalidation/Tables.kt +++ b/app/src/main/java/io/timelimit/android/data/invalidation/Tables.kt @@ -31,7 +31,8 @@ enum class Table { TimeLimitRule, UsedTimeItem, User, - UserKey + UserKey, + UserLimitLoginCategory } object TableNames { @@ -50,6 +51,7 @@ object TableNames { const val USED_TIME_ITEM = "used_time" const val USER = "user" const val USER_KEY = "user_key" + const val USER_LIMIT_LOGIN_CATEGORY = "user_limit_login_category" } object TableUtil { @@ -69,6 +71,7 @@ object TableUtil { Table.UsedTimeItem -> TableNames.USED_TIME_ITEM Table.User -> TableNames.USER Table.UserKey -> TableNames.USER_KEY + Table.UserLimitLoginCategory -> TableNames.USER_LIMIT_LOGIN_CATEGORY } fun toEnum(value: String): Table = when (value) { @@ -87,6 +90,7 @@ object TableUtil { TableNames.USED_TIME_ITEM -> Table.UsedTimeItem TableNames.USER -> Table.User TableNames.USER_KEY -> Table.UserKey + TableNames.USER_LIMIT_LOGIN_CATEGORY -> Table.UserLimitLoginCategory else -> throw IllegalArgumentException() } } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/model/UserLimitLoginCategory.kt b/app/src/main/java/io/timelimit/android/data/model/UserLimitLoginCategory.kt new file mode 100644 index 0000000..c0a243f --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/model/UserLimitLoginCategory.kt @@ -0,0 +1,108 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.data.model + +import android.util.JsonReader +import android.util.JsonWriter +import androidx.room.* +import io.timelimit.android.data.IdGenerator +import io.timelimit.android.data.JsonSerializable + +@Entity( + tableName = "user_limit_login_category", + indices = [ + Index( + name = "user_limit_login_category_index_category_id", + value = ["category_id"] + ) + ], + foreignKeys = [ + ForeignKey( + entity = User::class, + childColumns = ["user_id"], + parentColumns = ["id"], + onDelete = ForeignKey.CASCADE, + onUpdate = ForeignKey.CASCADE + ), + ForeignKey( + entity = Category::class, + childColumns = ["category_id"], + parentColumns = ["id"], + onDelete = ForeignKey.CASCADE, + onUpdate = ForeignKey.CASCADE + ) + ] +) +data class UserLimitLoginCategory( + @PrimaryKey + @ColumnInfo(name = "user_id") + val userId: String, + @ColumnInfo(name = "category_id") + val categoryId: String +): JsonSerializable { + companion object { + private const val USER_ID = "userId" + private const val CATEGORY_ID = "categoryId" + + fun parse(reader: JsonReader): UserLimitLoginCategory { + var userId: String? = null + var categoryId: String? = null + + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + USER_ID -> userId = reader.nextString() + CATEGORY_ID -> categoryId = reader.nextString() + else -> reader.skipValue() + } + } + reader.endObject() + + return UserLimitLoginCategory( + userId = userId!!, + categoryId = categoryId!! + ) + } + } + + init { + IdGenerator.assertIdValid(userId) + IdGenerator.assertIdValid(categoryId) + } + + override fun serialize(writer: JsonWriter) { + writer.beginObject() + + writer.name(USER_ID).value(userId) + writer.name(CATEGORY_ID).value(categoryId) + + writer.endObject() + } +} + +data class UserLimitLoginCategoryWithChildId( + @ColumnInfo(name = "child_id") + val childId: String, + @ColumnInfo(name = "child_title") + val childTitle: String, + @ColumnInfo(name = "category_id") + val categoryId: String, + @ColumnInfo(name = "category_title") + val categoryTitle: String, + @ColumnInfo(name = "selected") + val selected: Boolean +) \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/model/derived/CompleteUserLoginRelatedData.kt b/app/src/main/java/io/timelimit/android/data/model/derived/CompleteUserLoginRelatedData.kt new file mode 100644 index 0000000..8199949 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/model/derived/CompleteUserLoginRelatedData.kt @@ -0,0 +1,23 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.data.model.derived + +data class CompleteUserLoginRelatedData( + val loginRelatedData: UserLoginRelatedData, + val deviceRelatedData: DeviceRelatedData, + val limitLoginCategoryUserRelatedData: UserRelatedData? +) \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/model/derived/UserLoginRelatedData.kt b/app/src/main/java/io/timelimit/android/data/model/derived/UserLoginRelatedData.kt new file mode 100644 index 0000000..c52e051 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/model/derived/UserLoginRelatedData.kt @@ -0,0 +1,78 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.data.model.derived + +import io.timelimit.android.data.Database +import io.timelimit.android.data.invalidation.Observer +import io.timelimit.android.data.invalidation.Table +import io.timelimit.android.data.model.User +import io.timelimit.android.data.model.UserLimitLoginCategoryWithChildId +import java.lang.ref.WeakReference + +data class UserLoginRelatedData( + val user: User, + val limitLoginCategory: UserLimitLoginCategoryWithChildId? +): Observer { + companion object { + private val relatedTables = arrayOf(Table.User, Table.UserLimitLoginCategory, Table.Category) + + fun load(userId: String, database: Database): UserLoginRelatedData? = database.runInUnobservedTransaction { + val user = database.user().getUserByIdSync(userId) ?: return@runInUnobservedTransaction null + val limitLoginCategory = database.userLimitLoginCategoryDao().getByParentUserIdSync(userId) + + UserLoginRelatedData( + user = user, + limitLoginCategory = limitLoginCategory + ).also { + database.registerWeakObserver(relatedTables, WeakReference(it)) + } + } + } + + private var userInvalidated = false + private var limitLoginCategoryInvalidated = false + + override fun onInvalidated(tables: Set) { + tables.forEach { table -> + when (table) { + Table.User -> userInvalidated = true + Table.UserLimitLoginCategory -> limitLoginCategoryInvalidated = true + Table.Category -> limitLoginCategoryInvalidated = true + else -> {/* ignore */} + } + } + } + + fun update(database: Database): UserLoginRelatedData? { + if (!userInvalidated && !limitLoginCategoryInvalidated) { + return this + } + + return database.runInUnobservedTransaction { + val userId = user.id + val user = if (userInvalidated) database.user().getUserByIdSync(userId) ?: return@runInUnobservedTransaction null else user + val limitLoginCategory = if (limitLoginCategoryInvalidated) database.userLimitLoginCategoryDao().getByParentUserIdSync(userId) else limitLoginCategory + + UserLoginRelatedData( + user = user, + limitLoginCategory = limitLoginCategory + ).also { + database.registerWeakObserver(relatedTables, WeakReference(it)) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/logic/blockingreason/AppBaseHandling.kt b/app/src/main/java/io/timelimit/android/logic/blockingreason/AppBaseHandling.kt index 4d92a25..957b09b 100644 --- a/app/src/main/java/io/timelimit/android/logic/blockingreason/AppBaseHandling.kt +++ b/app/src/main/java/io/timelimit/android/logic/blockingreason/AppBaseHandling.kt @@ -17,6 +17,7 @@ package io.timelimit.android.logic.blockingreason import io.timelimit.android.BuildConfig +import io.timelimit.android.data.extensions.getCategoryWithParentCategories import io.timelimit.android.data.model.derived.CategoryRelatedData import io.timelimit.android.data.model.derived.DeviceRelatedData import io.timelimit.android.data.model.derived.UserRelatedData @@ -85,20 +86,8 @@ sealed class AppBaseHandling { if (startCategory == null) { return BlockDueToNoCategory } else { - val categoryIds = mutableSetOf(startCategory.category.id) - - run { - // get parent category ids - - var currentCategory: CategoryRelatedData? = userRelatedData.categoryById[startCategory.category.parentCategoryId] - - while (currentCategory != null && categoryIds.add(currentCategory.category.id)) { - currentCategory = userRelatedData.categoryById[currentCategory.category.parentCategoryId] - } - } - return UseCategories( - categoryIds = categoryIds, + categoryIds = userRelatedData.getCategoryWithParentCategories(startCategoryId = startCategory.category.id), shouldCount = !pauseCounting, level = when (appCategory?.specifiesActivity) { null -> BlockingLevel.Activity // occurs when using a default category diff --git a/app/src/main/java/io/timelimit/android/logic/blockingreason/CategoryItselfHandling.kt b/app/src/main/java/io/timelimit/android/logic/blockingreason/CategoryItselfHandling.kt index 7fe2eff..f73880a 100644 --- a/app/src/main/java/io/timelimit/android/logic/blockingreason/CategoryItselfHandling.kt +++ b/app/src/main/java/io/timelimit/android/logic/blockingreason/CategoryItselfHandling.kt @@ -241,6 +241,23 @@ data class CategoryItselfHandling ( // blockAllNotifications is only relevant if premium or local mode // val shouldBlockNotifications = !okAll || blockAllNotifications val shouldBlockAtSystemLevel = !okBasic + val systemLevelBlockingReason: BlockingReason = if (!okByBattery) + BlockingReason.BatteryLimit + else if (!okByTempBlocking) + BlockingReason.TemporarilyBlocked + else if (!okByBlockedTimeAreas) + BlockingReason.BlockedAtThisTime + else if (!okByTimeLimitRules) + if (remainingTime?.hasRemainingTime == true) + BlockingReason.TimeOverExtraTimeCanBeUsedLater + else + BlockingReason.TimeOver + else if (!okBySessionDurationLimits) + BlockingReason.SessionDurationLimit + else if (missingNetworkTime) + BlockingReason.MissingNetworkTime + else + BlockingReason.None fun isValid( categoryRelatedData: CategoryRelatedData, diff --git a/app/src/main/java/io/timelimit/android/sync/ApplyServerDataStatus.kt b/app/src/main/java/io/timelimit/android/sync/ApplyServerDataStatus.kt index fb9c1a6..ae29119 100644 --- a/app/src/main/java/io/timelimit/android/sync/ApplyServerDataStatus.kt +++ b/app/src/main/java/io/timelimit/android/sync/ApplyServerDataStatus.kt @@ -52,8 +52,24 @@ object ApplyServerDataStatus { run { // update/ create entries (first because there must be always one parent user) - newUserList.data.forEach { - newData -> + newUserList.data.forEach { newEntry -> + val newData = User( + id = newEntry.id, + name = newEntry.name, + password = newEntry.password, + secondPasswordSalt = newEntry.secondPasswordSalt, + type = newEntry.type, + timeZone = newEntry.timeZone, + disableLimitsUntil = newEntry.disableLimitsUntil, + mail = newEntry.mail, + currentDevice = newEntry.currentDevice, + categoryForNotAssignedApps = newEntry.categoryForNotAssignedApps, + relaxPrimaryDevice = newEntry.relaxPrimaryDevice, + mailNotificationFlags = newEntry.mailNotificationFlags, + blockedTimes = newEntry.blockedTimes, + flags = newEntry.flags + ) + val oldEntry = oldUserList.find { it.id == newData.id } if (oldEntry == null) { @@ -460,6 +476,24 @@ object ApplyServerDataStatus { ) } } + + status.newUserList?.data?.forEach { user -> + if (user.limitLoginCategory == null) { + database.userLimitLoginCategoryDao().removeItemSync(user.id) + } else { + val oldItem = database.userLimitLoginCategoryDao().getByParentUserIdSync(user.id) + + if (oldItem == null || oldItem.categoryId != user.limitLoginCategory) { + database.userLimitLoginCategoryDao().removeItemSync(user.id) + database.userLimitLoginCategoryDao().insertOrIgnoreItemSync( + UserLimitLoginCategory( + userId = user.id, + categoryId = user.limitLoginCategory + ) + ) + } + } + } } } } diff --git a/app/src/main/java/io/timelimit/android/sync/actions/Actions.kt b/app/src/main/java/io/timelimit/android/sync/actions/Actions.kt index 16b3da6..772aed7 100644 --- a/app/src/main/java/io/timelimit/android/sync/actions/Actions.kt +++ b/app/src/main/java/io/timelimit/android/sync/actions/Actions.kt @@ -1854,6 +1854,32 @@ data class UpdateUserFlagsAction(val userId: String, val modifiedBits: Long, val } } +data class UpdateUserLimitLoginCategory(val userId: String, val categoryId: String?): ParentAction() { + companion object { + private const val TYPE_VALUE = "UPDATE_USER_LIMIT_LOGIN_CATEGORY" + private const val USER_ID = "userId" + private const val CATEGORY_ID = "categoryId" + } + + init { + IdGenerator.assertIdValid(userId) + categoryId?.let { IdGenerator.assertIdValid(categoryId) } + } + + override fun serialize(writer: JsonWriter) { + writer.beginObject() + + writer.name(TYPE).value(TYPE_VALUE) + writer.name(USER_ID).value(userId) + + if (categoryId != null) { + writer.name(CATEGORY_ID).value(categoryId) + } + + writer.endObject() + } +} + // child actions object ChildSignInAction: ChildAction() { private const val TYPE_VALUE = "CHILD_SIGN_IN" diff --git a/app/src/main/java/io/timelimit/android/sync/actions/Parser.kt b/app/src/main/java/io/timelimit/android/sync/actions/Parser.kt index d4717d8..7dd6f8f 100644 --- a/app/src/main/java/io/timelimit/android/sync/actions/Parser.kt +++ b/app/src/main/java/io/timelimit/android/sync/actions/Parser.kt @@ -69,6 +69,7 @@ object ActionParser { // UpdateCategoryBatteryLimit // UpdateCategorySorting // UpdateUserFlagsAction + // UpdateUserLimitLoginCategory else -> throw IllegalStateException() } } diff --git a/app/src/main/java/io/timelimit/android/sync/actions/dispatch/ParentAction.kt b/app/src/main/java/io/timelimit/android/sync/actions/dispatch/ParentAction.kt index 886d310..d8e1c19 100644 --- a/app/src/main/java/io/timelimit/android/sync/actions/dispatch/ParentAction.kt +++ b/app/src/main/java/io/timelimit/android/sync/actions/dispatch/ParentAction.kt @@ -18,10 +18,7 @@ package io.timelimit.android.sync.actions.dispatch import io.timelimit.android.data.Database import io.timelimit.android.data.customtypes.ImmutableBitmask import io.timelimit.android.data.extensions.getChildCategories -import io.timelimit.android.data.model.Category -import io.timelimit.android.data.model.CategoryApp -import io.timelimit.android.data.model.User -import io.timelimit.android.data.model.UserType +import io.timelimit.android.data.model.* import io.timelimit.android.sync.actions.* import java.util.* @@ -259,6 +256,10 @@ object LocalDatabaseParentActionDispatcher { if (currentParents.size <= 1) { throw IllegalStateException("would delete last parent") } + + if (database.userLimitLoginCategoryDao().countOtherUsersWithoutLimitLoginCategorySync(action.userId) == 0L) { + throw IllegalStateException("would delete last user without login limit") + } } if (userToDelete.type == UserType.Child) { @@ -595,6 +596,29 @@ object LocalDatabaseParentActionDispatcher { database.user().updateUserSync(updatedUser) } + is UpdateUserLimitLoginCategory -> { + val user = database.user().getUserByIdSync(action.userId)!! + + if (user.type != UserType.Parent) { + throw IllegalArgumentException() + } + + if (action.categoryId == null) { + database.userLimitLoginCategoryDao().removeItemSync(action.userId) + } else { + if (database.userLimitLoginCategoryDao().countOtherUsersWithoutLimitLoginCategorySync(action.userId) == 0L) { + throw IllegalStateException("there must be one user withou such limits") + } + + database.category().getCategoryByIdSync(action.categoryId)!! + + database.userLimitLoginCategoryDao().insertOrReplaceItemSync( + UserLimitLoginCategory( + userId = action.userId, + categoryId = action.categoryId + ) + ) } + } }.let { } } } diff --git a/app/src/main/java/io/timelimit/android/sync/network/ServerDataStatus.kt b/app/src/main/java/io/timelimit/android/sync/network/ServerDataStatus.kt index 45b971a..8dd9ebf 100644 --- a/app/src/main/java/io/timelimit/android/sync/network/ServerDataStatus.kt +++ b/app/src/main/java/io/timelimit/android/sync/network/ServerDataStatus.kt @@ -16,6 +16,7 @@ package io.timelimit.android.sync.network import android.util.JsonReader +import android.util.JsonToken import io.timelimit.android.data.customtypes.ImmutableBitmask import io.timelimit.android.data.customtypes.ImmutableBitmaskJson import io.timelimit.android.data.model.* @@ -130,7 +131,7 @@ data class ServerDeviceList( data class ServerUserList( val version: String, - val data: List + val data: List ) { companion object { private const val VERSION = "version" @@ -138,13 +139,13 @@ data class ServerUserList( fun parse(reader: JsonReader): ServerUserList { var version: String? = null - var data: List? = null + var data: List? = null reader.beginObject() while (reader.hasNext()) { when (reader.nextName()) { VERSION -> version = reader.nextString() - DATA -> data = User.parseList(reader) + DATA -> data = ServerUserData.parseList(reader) else -> reader.skipValue() } } @@ -158,6 +159,103 @@ data class ServerUserList( } } +data class ServerUserData( + val id: String, + val name: String, + val password: String, + val secondPasswordSalt: String, + val type: UserType, + val timeZone: String, + val disableLimitsUntil: Long, + val mail: String, + val currentDevice: String, + val categoryForNotAssignedApps: String, + val relaxPrimaryDevice: Boolean, + val mailNotificationFlags: Int, + val blockedTimes: ImmutableBitmask, + val flags: Long, + val limitLoginCategory: String? +) { + companion object { + private const val ID = "id" + private const val NAME = "name" + private const val PASSWORD = "password" + private const val SECOND_PASSWORD_SALT = "secondPasswordSalt" + private const val TYPE = "type" + private const val TIMEZONE = "timeZone" + private const val DISABLE_LIMITS_UNTIL = "disableLimitsUntil" + private const val MAIL = "mail" + private const val CURRENT_DEVICE = "currentDevice" + private const val CATEGORY_FOR_NOT_ASSIGNED_APPS = "categoryForNotAssignedApps" + private const val RELAX_PRIMARY_DEVICE = "relaxPrimaryDevice" + private const val MAIL_NOTIFICATION_FLAGS = "mailNotificationFlags" + private const val BLOCKED_TIMES = "blockedTimes" + private const val FLAGS = "flags" + private const val USER_LIMIT_LOGIN_CATEGORY = "llc" + + fun parse(reader: JsonReader): ServerUserData { + var id: String? = null + var name: String? = null + var password: String? = null + var secondPasswordSalt: String? = null + var type: UserType? = null + var timeZone: String? = null + var disableLimitsUntil: Long? = null + var mail: String? = null + var currentDevice: String? = null + var categoryForNotAssignedApps = "" + var relaxPrimaryDevice = false + var mailNotificationFlags = 0 + var blockedTimes = ImmutableBitmask(BitSet()) + var flags = 0L + var limitLoginCategory: String? = null + + reader.beginObject() + while (reader.hasNext()) { + when(reader.nextName()) { + ID -> id = reader.nextString() + NAME -> name = reader.nextString() + PASSWORD -> password = reader.nextString() + SECOND_PASSWORD_SALT -> secondPasswordSalt = reader.nextString() + TYPE -> type = UserTypeJson.parse(reader.nextString()) + TIMEZONE -> timeZone = reader.nextString() + DISABLE_LIMITS_UNTIL -> disableLimitsUntil = reader.nextLong() + MAIL -> mail = reader.nextString() + CURRENT_DEVICE -> currentDevice = reader.nextString() + CATEGORY_FOR_NOT_ASSIGNED_APPS -> categoryForNotAssignedApps = reader.nextString() + RELAX_PRIMARY_DEVICE -> relaxPrimaryDevice = reader.nextBoolean() + MAIL_NOTIFICATION_FLAGS -> mailNotificationFlags = reader.nextInt() + BLOCKED_TIMES -> blockedTimes = ImmutableBitmaskJson.parse(reader.nextString(), Category.BLOCKED_MINUTES_IN_WEEK_LENGTH) + FLAGS -> flags = reader.nextLong() + USER_LIMIT_LOGIN_CATEGORY -> if (reader.peek() == JsonToken.NULL) reader.nextNull() else limitLoginCategory = reader.nextString() + else -> reader.skipValue() + } + } + reader.endObject() + + return ServerUserData( + id = id!!, + name = name!!, + password = password!!, + secondPasswordSalt = secondPasswordSalt!!, + type = type!!, + timeZone = timeZone!!, + disableLimitsUntil = disableLimitsUntil!!, + mail = mail!!, + currentDevice = currentDevice!!, + categoryForNotAssignedApps = categoryForNotAssignedApps, + relaxPrimaryDevice = relaxPrimaryDevice, + mailNotificationFlags = mailNotificationFlags, + blockedTimes = blockedTimes, + flags = flags, + limitLoginCategory = limitLoginCategory + ) + } + + fun parseList(reader: JsonReader) = parseJsonArray(reader) { parse(reader) } + } +} + data class ServerDeviceData( val deviceId: String, val name: String, diff --git a/app/src/main/java/io/timelimit/android/ui/login/AllowUserLoginStatus.kt b/app/src/main/java/io/timelimit/android/ui/login/AllowUserLoginStatus.kt new file mode 100644 index 0000000..9d6011b --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/login/AllowUserLoginStatus.kt @@ -0,0 +1,208 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.ui.login + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MediatorLiveData +import io.timelimit.android.async.Threads +import io.timelimit.android.data.extensions.getCategoryWithParentCategories +import io.timelimit.android.data.model.derived.CompleteUserLoginRelatedData +import io.timelimit.android.date.getMinuteOfWeek +import io.timelimit.android.integration.platform.BatteryStatus +import io.timelimit.android.logic.AppLogic +import io.timelimit.android.logic.BlockingReason +import io.timelimit.android.logic.RealTime +import io.timelimit.android.logic.blockingreason.CategoryHandlingCache +import java.util.* +import java.util.concurrent.CountDownLatch + +sealed class AllowUserLoginStatus { + data class Allow(val maxTime: Long): AllowUserLoginStatus() + data class ForbidByCurrentTime(val missingNetworkTime: Boolean, val maxTime: Long): AllowUserLoginStatus() + data class ForbidByCategory(val categoryTitle: String, val blockingReason: BlockingReason, val maxTime: Long): AllowUserLoginStatus() + object ForbidUserNotFound: AllowUserLoginStatus() +} + +object AllowUserLoginStatusUtil { + private fun calculate(data: CompleteUserLoginRelatedData, time: RealTime, cache: CategoryHandlingCache, batteryStatus: BatteryStatus): AllowUserLoginStatus = synchronized(cache) { + val hasPremium = data.deviceRelatedData.isConnectedAndHasPremium || data.deviceRelatedData.isLocalMode + + if (!hasPremium) { + return AllowUserLoginStatus.Allow(maxTime = Long.MAX_VALUE) + } + + if (!data.loginRelatedData.user.blockedTimes.dataNotToModify.isEmpty) { + if (!time.shouldTrustTimePermanently) { + return AllowUserLoginStatus.ForbidByCurrentTime(missingNetworkTime = true, maxTime = Long.MAX_VALUE) + } else { + val minuteOfWeek = getMinuteOfWeek(time.timeInMillis, TimeZone.getTimeZone(data.loginRelatedData.user.timeZone)) + + if (data.loginRelatedData.user.blockedTimes.dataNotToModify[minuteOfWeek]) { + val nextAllowedSlot = data.loginRelatedData.user.blockedTimes.dataNotToModify.nextClearBit(minuteOfWeek) + val minutesToWait: Long = (nextAllowedSlot - minuteOfWeek).toLong() + // not very nice but it works + val msToWait = if (minutesToWait <= 1) 5000 else (minutesToWait - 1) * 1000 * 60 + + return AllowUserLoginStatus.ForbidByCurrentTime(missingNetworkTime = false, maxTime = time.timeInMillis + msToWait) + } + } + } + + return if (data.limitLoginCategoryUserRelatedData != null && data.loginRelatedData.limitLoginCategory != null) { + cache.reportStatus( + user = data.limitLoginCategoryUserRelatedData, + assumeCurrentDevice = true, + timeInMillis = time.timeInMillis, + batteryStatus = batteryStatus, + shouldTrustTimeTemporarily = time.shouldTrustTimeTemporarily + ) + + val categoryIds = data.limitLoginCategoryUserRelatedData.getCategoryWithParentCategories(data.loginRelatedData.limitLoginCategory.categoryId) + val handlings = categoryIds.map { cache.get(it) } + + val blockingHandling = handlings.find { it.shouldBlockAtSystemLevel } + + if (blockingHandling != null) { + AllowUserLoginStatus.ForbidByCategory( + categoryTitle = blockingHandling.createdWithCategoryRelatedData.category.title, + blockingReason = blockingHandling.systemLevelBlockingReason, + maxTime = blockingHandling.dependsOnMaxTime.coerceAtMost( + if (data.loginRelatedData.user.blockedTimes.dataNotToModify.isEmpty) + Long.MAX_VALUE + else + time.timeInMillis + 1000 * 5 + ) + ) + } else { + val maxTimeByCategories = handlings.minBy { it.dependsOnMaxTime }?.dependsOnMaxTime ?: Long.MAX_VALUE + + AllowUserLoginStatus.Allow( + maxTime = maxTimeByCategories.coerceAtMost( + if (data.loginRelatedData.user.blockedTimes.dataNotToModify.isEmpty) + Long.MAX_VALUE + else + time.timeInMillis + 1000 * 5 + ) + ) + } + } else { + AllowUserLoginStatus.Allow( + maxTime = if (data.loginRelatedData.user.blockedTimes.dataNotToModify.isEmpty) + Long.MAX_VALUE + else + time.timeInMillis + 1000 * 5 + ) + } + } + + fun calculateSync(logic: AppLogic, userId: String): AllowUserLoginStatus { + val userRelatedData = logic.database.derivedDataDao().getUserLoginRelatedDataSync(userId) ?: return AllowUserLoginStatus.ForbidUserNotFound + val realTime = RealTime.newInstance() + val batteryStatus = logic.platformIntegration.getBatteryStatus() + val latch = CountDownLatch(1) + + Threads.mainThreadHandler.post { + logic.realTimeLogic.getRealTime(realTime) + latch.countDown() + } + + latch.await() + + return calculate( + data = userRelatedData, + batteryStatus = batteryStatus, + time = realTime, + cache = CategoryHandlingCache() + ) + } + + fun calculateLive(logic: AppLogic, userId: String): LiveData = object : MediatorLiveData() { + val cache = CategoryHandlingCache() + val time = RealTime.newInstance() + var batteryStatus: BatteryStatus? = null + var hasUserLoginRelatedData = false + var userLoginRelatedData: CompleteUserLoginRelatedData? = null + + init { + addSource(logic.platformIntegration.getBatteryStatusLive(), androidx.lifecycle.Observer { + batteryStatus = it; update() + }) + + addSource(logic.database.derivedDataDao().getUserLoginRelatedDataLive(userId), androidx.lifecycle.Observer { + userLoginRelatedData = it; hasUserLoginRelatedData = true; update() + }) + } + + val updateLambda: () -> Unit = { update() } + val updateRunnable = Runnable { update() } + + fun update() { + val batteryStatus = batteryStatus + val userLoginRelatedData = userLoginRelatedData + + if (batteryStatus == null || !hasUserLoginRelatedData) return + + if (userLoginRelatedData == null) { + if (value !== AllowUserLoginStatus.ForbidUserNotFound) { + value = AllowUserLoginStatus.ForbidUserNotFound + } + + return + } + + logic.realTimeLogic.getRealTime(time) + + val result = calculate( + data = userLoginRelatedData, + batteryStatus = batteryStatus, + cache = cache, + time = time + ) + + if (result != value) { + value = result + } + + val scheduledTime: Long = when (result) { + AllowUserLoginStatus.ForbidUserNotFound -> Long.MAX_VALUE + is AllowUserLoginStatus.ForbidByCurrentTime -> result.maxTime + is AllowUserLoginStatus.Allow -> result.maxTime + is AllowUserLoginStatus.ForbidByCategory -> result.maxTime + } + + if (scheduledTime != Long.MAX_VALUE) { + logic.timeApi.cancelScheduledAction(updateRunnable) + logic.timeApi.runDelayedByUptime(updateRunnable, scheduledTime - time.timeInMillis) + } + } + + override fun onActive() { + super.onActive() + + logic.realTimeLogic.registerTimeModificationListener(updateLambda) + + update() + } + + override fun onInactive() { + super.onInactive() + + logic.realTimeLogic.unregisterTimeModificationListener(updateLambda) + logic.timeApi.cancelScheduledAction(updateRunnable) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/login/LoginDialogFragmentModel.kt b/app/src/main/java/io/timelimit/android/ui/login/LoginDialogFragmentModel.kt index 20d4c28..5122331 100644 --- a/app/src/main/java/io/timelimit/android/ui/login/LoginDialogFragmentModel.kt +++ b/app/src/main/java/io/timelimit/android/ui/login/LoginDialogFragmentModel.kt @@ -27,7 +27,9 @@ import io.timelimit.android.coroutines.runAsync import io.timelimit.android.crypto.PasswordHashing import io.timelimit.android.data.model.User import io.timelimit.android.data.model.UserType +import io.timelimit.android.data.model.derived.CompleteUserLoginRelatedData import io.timelimit.android.livedata.* +import io.timelimit.android.logic.BlockingReason import io.timelimit.android.logic.BlockingReasonUtil import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.sync.actions.ChildSignInAction @@ -40,21 +42,18 @@ import io.timelimit.android.ui.main.AuthenticatedUser import io.timelimit.android.ui.manage.parent.key.ScannedKey import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import java.util.* class LoginDialogFragmentModel(application: Application): AndroidViewModel(application) { val selectedUserId = MutableLiveData().apply { value = null } private val logic = DefaultAppLogic.with(application) private val blockingReasonUtil = BlockingReasonUtil(logic) private val users = logic.database.user().getAllUsersLive() - private val isConnectedMode = logic.fullVersion.isLocalMode.invert() - private val selectedUser = users.switchMap { users -> - selectedUserId.map { userId -> - users.find { it.id == userId } - } + private val selectedUser = selectedUserId.switchMap { selectedUserId -> + if (selectedUserId != null) + logic.database.derivedDataDao().getUserLoginRelatedDataLive(selectedUserId) + else + liveDataFromValue(null as CompleteUserLoginRelatedData?) } - private val trustedTime = selectedUser.switchMap { blockingReasonUtil.getTrustedMinuteOfWeekLive(TimeZone.getTimeZone(it?.timeZone ?: "GMT")) } - private val currentDeviceUser = logic.deviceUserId private val isCheckingPassword = MutableLiveData().apply { value = false } private val wasPasswordWrong = MutableLiveData().apply { value = false } private val isLoginDone = MutableLiveData().apply { value = false } @@ -64,71 +63,70 @@ class LoginDialogFragmentModel(application: Application): AndroidViewModel(appli if (isLoginDone) { liveDataFromValue(LoginDialogDone as LoginDialogStatus) } else { - selectedUser.switchMap { selectedUser -> + selectedUser.switchMap { selectedUserInfo -> + val selectedUser = selectedUserInfo?.loginRelatedData?.user + when (selectedUser?.type) { UserType.Parent -> { - val isAlreadyCurrentUser = currentDeviceUser.map { it == selectedUser.id }.ignoreUnchanged() - val loginScreen = isConnectedMode.switchMap { isConnectedMode -> - isAlreadyCurrentUser.switchMap { isAlreadyCurrentUser -> - isCheckingPassword.switchMap { isCheckingPassword -> - wasPasswordWrong.map { wasPasswordWrong -> - ParentUserLogin( - isConnectedMode = isConnectedMode, - isAlreadyCurrentDeviceUser = isAlreadyCurrentUser, - isCheckingPassword = isCheckingPassword, - wasPasswordWrong = wasPasswordWrong - ) as LoginDialogStatus - } - } + val isAlreadyCurrentUser = selectedUserInfo.deviceRelatedData.deviceEntry.currentUserId == selectedUser.id + val isConnectedMode = !selectedUserInfo.deviceRelatedData.isLocalMode + val loginScreen = isCheckingPassword.switchMap { isCheckingPassword -> + wasPasswordWrong.map { wasPasswordWrong -> + ParentUserLogin( + isConnectedMode = isConnectedMode, + isAlreadyCurrentDeviceUser = isAlreadyCurrentUser, + isCheckingPassword = isCheckingPassword, + wasPasswordWrong = wasPasswordWrong + ) as LoginDialogStatus } } - if (selectedUser.blockedTimes.dataNotToModify.isEmpty) { - loginScreen - } else { - logic.fullVersion.shouldProvideFullVersionFunctions.switchMap { hasPremium -> - if (hasPremium) { - trustedTime.switchMap { time -> - if (time == null) { - liveDataFromValue(ParentUserLoginMissingTrustedTime as LoginDialogStatus) - } else if (selectedUser.blockedTimes.dataNotToModify[time]) { - liveDataFromValue(ParentUserLoginBlockedTime as LoginDialogStatus) - } else { - loginScreen - } - } - } else { - loginScreen - } + AllowUserLoginStatusUtil.calculateLive(logic, selectedUser.id).switchMap { status -> + if (status is AllowUserLoginStatus.Allow) { + loginScreen + } else if ( + status is AllowUserLoginStatus.ForbidByCurrentTime || + (status is AllowUserLoginStatus.ForbidByCategory && status.blockingReason == BlockingReason.MissingNetworkTime) + ) { + liveDataFromValue(ParentUserLoginMissingTrustedTime as LoginDialogStatus) + } else if (status is AllowUserLoginStatus.ForbidByCurrentTime) { + liveDataFromValue(ParentUserLoginBlockedTime as LoginDialogStatus) + } else if (status is AllowUserLoginStatus.ForbidByCategory) { + liveDataFromValue( + ParentUserLoginBlockedByCategory( + categoryTitle = status.categoryTitle, + reason = status.blockingReason + ) as LoginDialogStatus + ) + } else { + loginScreen } } } UserType.Child -> { - logic.fullVersion.shouldProvideFullVersionFunctions.switchMap { fullversion -> - if (fullversion) { - if (selectedUser.password.isEmpty()) { - liveDataFromValue(CanNotSignInChildHasNoPassword(childName = selectedUser.name) as LoginDialogStatus) - } else { - val isAlreadyCurrentUser = currentDeviceUser.map { it == selectedUser.id }.ignoreUnchanged() + val hasPremium = selectedUserInfo.deviceRelatedData.isLocalMode || selectedUserInfo.deviceRelatedData.isConnectedAndHasPremium - isAlreadyCurrentUser.switchMap { isSignedIn -> - if (isSignedIn) { - liveDataFromValue(ChildAlreadyDeviceUser as LoginDialogStatus) - } else { - isCheckingPassword.switchMap { isCheckingPassword -> - wasPasswordWrong.map { wasPasswordWrong -> - ChildUserLogin( - isCheckingPassword = isCheckingPassword, - wasPasswordWrong = wasPasswordWrong - ) as LoginDialogStatus - } - } + if (hasPremium) { + if (selectedUser.password.isEmpty()) { + liveDataFromValue(CanNotSignInChildHasNoPassword(childName = selectedUser.name) as LoginDialogStatus) + } else { + val isAlreadyCurrentUser = selectedUserInfo.deviceRelatedData.deviceEntry.currentUserId == selectedUser.id + + if (isAlreadyCurrentUser) { + liveDataFromValue(ChildAlreadyDeviceUser as LoginDialogStatus) + } else { + isCheckingPassword.switchMap { isCheckingPassword -> + wasPasswordWrong.map { wasPasswordWrong -> + ChildUserLogin( + isCheckingPassword = isCheckingPassword, + wasPasswordWrong = wasPasswordWrong + ) as LoginDialogStatus } } } - } else { - liveDataFromValue(ChildLoginRequiresPremiumStatus as LoginDialogStatus) } + } else { + liveDataFromValue(ChildLoginRequiresPremiumStatus as LoginDialogStatus) } } null -> { @@ -155,24 +153,14 @@ class LoginDialogFragmentModel(application: Application): AndroidViewModel(appli loginLock.withLock { logic.database.user().getParentUsersLive().waitForNonNullValue().singleOrNull()?.let { user -> val emptyPasswordValid = Threads.crypto.executeAndWait { PasswordHashing.validateSync("", user.password) } + val hasBlockedTimes = !user.blockedTimes.dataNotToModify.isEmpty val shouldSignIn = if (emptyPasswordValid) { - val hasBlockedTimes = !user.blockedTimes.dataNotToModify.isEmpty - - if (hasBlockedTimes) { - val hasPremium = logic.fullVersion.shouldProvideFullVersionFunctions.waitForNonNullValue() - - if (hasPremium) { - val isGoodTime = blockingReasonUtil.getTrustedMinuteOfWeekLive(TimeZone.getTimeZone(user.timeZone)).map { minuteOfWeek -> - minuteOfWeek != null && user.blockedTimes.dataNotToModify[minuteOfWeek] == false - }.waitForNonNullValue() - - isGoodTime - } else { - true - } - } else { - true + Threads.database.executeAndWait { + AllowUserLoginStatusUtil.calculateSync( + logic = logic, + userId = user.id + ) is AllowUserLoginStatus.Allow } } else { false @@ -185,6 +173,10 @@ class LoginDialogFragmentModel(application: Application): AndroidViewModel(appli secondPasswordHash = Threads.crypto.executeAndWait { PasswordHashing.hashSyncWithSalt("", user.secondPasswordSalt) } )) + if (hasBlockedTimes) { + Toast.makeText(getApplication(), R.string.manage_parent_blocked_times_toast, Toast.LENGTH_LONG).show() + } + isLoginDone.value = true } } @@ -230,20 +222,11 @@ class LoginDialogFragmentModel(application: Application): AndroidViewModel(appli if (user != null && user.type == UserType.Parent) { val hasBlockedTimes = !user.blockedTimes.dataNotToModify.isEmpty - val shouldSignIn = if (hasBlockedTimes) { - val hasPremium = logic.fullVersion.shouldProvideFullVersionFunctions.waitForNonNullValue() - - if (hasPremium) { - val isGoodTime = blockingReasonUtil.getTrustedMinuteOfWeekLive(TimeZone.getTimeZone(user.timeZone)).map { minuteOfWeek -> - minuteOfWeek != null && user.blockedTimes.dataNotToModify[minuteOfWeek] == false - }.waitForNonNullValue() - - isGoodTime - } else { - true - } - } else { - true + val shouldSignIn = Threads.database.executeAndWait { + AllowUserLoginStatusUtil.calculateSync( + logic = logic, + userId = user.id + ) is AllowUserLoginStatus.Allow } if (shouldSignIn) { @@ -254,6 +237,10 @@ class LoginDialogFragmentModel(application: Application): AndroidViewModel(appli secondPasswordHash = "device" )) + if (hasBlockedTimes) { + Toast.makeText(getApplication(), R.string.manage_parent_blocked_times_toast, Toast.LENGTH_LONG).show() + } + isLoginDone.value = true } else { Toast.makeText(getApplication(), R.string.login_blocked_time, Toast.LENGTH_SHORT).show() @@ -274,15 +261,17 @@ class LoginDialogFragmentModel(application: Application): AndroidViewModel(appli try { isCheckingPassword.value = true - val userEntry = selectedUser.waitForNullableValue() - val ownDeviceId = logic.deviceId.waitForNullableValue() + val userEntryInfo = selectedUser.waitForNullableValue() + val userEntry = userEntryInfo?.loginRelatedData?.user - if (userEntry?.type != UserType.Parent || ownDeviceId == null) { + if (userEntry?.type != UserType.Parent) { selectedUserId.value = null return@runAsync } + val ownDeviceId = userEntryInfo.deviceRelatedData.deviceEntry.id + val passwordValid = Threads.crypto.executeAndWait { PasswordHashing.validateSync(password, userEntry.password) } if (!passwordValid) { @@ -299,12 +288,28 @@ class LoginDialogFragmentModel(application: Application): AndroidViewModel(appli secondPasswordHash = secondPasswordHash ) + val hasBlockedTimes = !userEntry.blockedTimes.dataNotToModify.isEmpty + val shouldSignIn = Threads.database.executeAndWait { + AllowUserLoginStatusUtil.calculateSync( + logic = logic, + userId = userEntry.id + ) is AllowUserLoginStatus.Allow + } + + if (!shouldSignIn) { + Toast.makeText(getApplication(), R.string.login_blocked_time, Toast.LENGTH_SHORT).show() + + return@runAsync + } + model.setAuthenticatedUser(authenticatedUser) - if (setAsDeviceUser) { - val deviceEntry = logic.deviceEntry.waitForNonNullValue()!! + if (hasBlockedTimes) { + Toast.makeText(getApplication(), R.string.manage_parent_blocked_times_toast, Toast.LENGTH_LONG).show() + } - if (deviceEntry.currentUserId != userEntry.id) { + if (setAsDeviceUser) { + if (userEntryInfo.deviceRelatedData.deviceEntry.currentUserId != userEntry.id) { ActivityViewModel.dispatchWithoutCheckOrCatching( SetDeviceUserAction( deviceId = ownDeviceId, @@ -319,7 +324,7 @@ class LoginDialogFragmentModel(application: Application): AndroidViewModel(appli if (keepSignedIn) { if ( setAsDeviceUser || - (currentDeviceUser.waitForNullableValue() == userEntry.id) + userEntryInfo.deviceRelatedData.deviceEntry.currentUserId == userEntry.id ) { ActivityViewModel.dispatchWithoutCheckOrCatching( SetKeepSignedInAction( @@ -341,18 +346,17 @@ class LoginDialogFragmentModel(application: Application): AndroidViewModel(appli } fun tryChildLogin( - password: String, - model: ActivityViewModel + password: String ) { runAsync { loginLock.withLock { try { isCheckingPassword.value = true - val userEntry = selectedUser.waitForNullableValue() - val ownDeviceId = logic.deviceId.waitForNullableValue() + val userEntryInfo = selectedUser.waitForNullableValue() + val userEntry = userEntryInfo?.loginRelatedData?.user - if (userEntry?.type != UserType.Child || ownDeviceId == null) { + if (userEntry?.type != UserType.Child) { selectedUserId.value = null return@runAsync @@ -409,6 +413,7 @@ sealed class LoginDialogStatus data class UserListLoginDialogStatus(val usersToShow: List, val isLocalMode: Boolean): LoginDialogStatus() object ParentUserLoginMissingTrustedTime: LoginDialogStatus() object ParentUserLoginBlockedTime: LoginDialogStatus() +data class ParentUserLoginBlockedByCategory(val categoryTitle: String, val reason: BlockingReason): LoginDialogStatus() data class ParentUserLogin( val isConnectedMode: Boolean, val isAlreadyCurrentDeviceUser: Boolean, diff --git a/app/src/main/java/io/timelimit/android/ui/login/NewLoginFragment.kt b/app/src/main/java/io/timelimit/android/ui/login/NewLoginFragment.kt index 5672c6e..6c4062c 100644 --- a/app/src/main/java/io/timelimit/android/ui/login/NewLoginFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/login/NewLoginFragment.kt @@ -34,6 +34,7 @@ import io.timelimit.android.async.Threads import io.timelimit.android.data.model.User import io.timelimit.android.databinding.NewLoginFragmentBinding import io.timelimit.android.extensions.setOnEnterListenr +import io.timelimit.android.logic.BlockingReason import io.timelimit.android.ui.main.getActivityViewModel import io.timelimit.android.ui.manage.parent.key.ScannedKey import io.timelimit.android.ui.view.KeyboardViewListener @@ -51,6 +52,7 @@ class NewLoginFragment: DialogFragment() { private const val CHILD_LOGIN_REQUIRES_PREMIUM = 5 private const val BLOCKED_LOGIN_TIME = 6 private const val UNVERIFIED_TIME = 7 + private const val PARENT_LOGIN_BLOCKED = 8 } private val model: LoginDialogFragmentModel by lazy { @@ -170,8 +172,7 @@ class NewLoginFragment: DialogFragment() { binding.childPassword.apply { password.setOnEnterListenr { model.tryChildLogin( - password = password.text.toString(), - model = getActivityViewModel(activity!!) + password = password.text.toString() ) } } @@ -307,6 +308,30 @@ class NewLoginFragment: DialogFragment() { binding.switcher.displayedChild = CHILD_LOGIN_REQUIRES_PREMIUM } + null + } + is ParentUserLoginBlockedByCategory -> { + if (binding.switcher.displayedChild != PARENT_LOGIN_BLOCKED) { + binding.switcher.setInAnimation(context!!, R.anim.wizard_open_step_in) + binding.switcher.setOutAnimation(context!!, R.anim.wizard_open_step_out) + binding.switcher.displayedChild = PARENT_LOGIN_BLOCKED + } + + binding.parentLoginBlocked.categoryTitle = status.categoryTitle + binding.parentLoginBlocked.reason = when (status.reason) { + BlockingReason.TemporarilyBlocked -> getString(R.string.lock_reason_short_temporarily_blocked) + BlockingReason.TimeOver -> getString(R.string.lock_reason_short_time_over) + BlockingReason.TimeOverExtraTimeCanBeUsedLater -> getString(R.string.lock_reason_short_time_over) + BlockingReason.BlockedAtThisTime -> getString(R.string.lock_reason_short_blocked_time_area) + BlockingReason.MissingNetworkTime -> getString(R.string.lock_reason_short_missing_network_time) + BlockingReason.RequiresCurrentDevice -> getString(R.string.lock_reason_short_requires_current_device) + BlockingReason.NotificationsAreBlocked -> getString(R.string.lock_reason_short_notification_blocking) + BlockingReason.BatteryLimit -> getString(R.string.lock_reason_short_battery_limit) + BlockingReason.SessionDurationLimit -> getString(R.string.lock_reason_short_session_duration) + BlockingReason.NotPartOfAnCategory -> "???" + BlockingReason.None -> "???" + } + null } }.let { /* require handling all cases */ } diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/ManageParentFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/ManageParentFragment.kt index 6fce800..67bac52 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/parent/ManageParentFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/ManageParentFragment.kt @@ -28,6 +28,7 @@ import androidx.navigation.Navigation import io.timelimit.android.R import io.timelimit.android.data.model.User import io.timelimit.android.databinding.FragmentManageParentBinding +import io.timelimit.android.databinding.ParentLimitLoginViewBinding import io.timelimit.android.extensions.safeNavigate import io.timelimit.android.livedata.liveDataFromValue import io.timelimit.android.livedata.map @@ -39,6 +40,7 @@ import io.timelimit.android.ui.main.FragmentWithCustomTitle import io.timelimit.android.ui.manage.child.advanced.timezone.UserTimezoneView import io.timelimit.android.ui.manage.parent.delete.DeleteParentView import io.timelimit.android.ui.manage.parent.key.ManageUserKeyView +import io.timelimit.android.ui.manage.parent.limitlogin.ParentLimitLoginView class ManageParentFragment : Fragment(), FragmentWithCustomTitle { private val activity: ActivityViewModelHolder by lazy { getActivity() as ActivityViewModelHolder } @@ -124,6 +126,14 @@ class ManageParentFragment : Fragment(), FragmentWithCustomTitle { fragmentManager = parentFragmentManager ) + ParentLimitLoginView.bind( + view = binding.parentLimitLogin, + lifecycleOwner = viewLifecycleOwner, + userId = params.parentId, + auth = activity.getActivityViewModel(), + fragmentManager = parentFragmentManager + ) + binding.handlers = object: ManageParentFragmentHandlers { override fun onChangePasswordClicked() { navigation.safeNavigate( diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/delete/DeleteParentModel.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/delete/DeleteParentModel.kt index abd10a9..18ff7e2 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/parent/delete/DeleteParentModel.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/delete/DeleteParentModel.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 Jonas Lochmann + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -48,14 +48,19 @@ class DeleteParentModel(application: Application): AndroidViewModel(application) } != null } } + private val isLastWithoutLoginLimit = parentUserIdLive.switchMap { userId -> + database.userLimitLoginCategoryDao().countOtherUsersWithoutLimitLoginCategoryLive(userId).map { it == 0L } + } private val statusIgnoringLinkingLive = parentUserIdLive.switchMap { parentUserId -> - authenticatedUserLive.map { authenticatedUser -> - if (authenticatedUser?.second?.type != UserType.Parent) { - Status.NotAuthenticated - } else { - if (authenticatedUser.second.id == parentUserId) { + authenticatedUserLive.switchMap { authenticatedUser -> + isLastWithoutLoginLimit.map { lastWithoutLoginLimit -> + if (authenticatedUser?.second?.type != UserType.Parent) { + Status.NotAuthenticated + } else if (authenticatedUser.second.id == parentUserId) { Status.WrongAccount + } else if (lastWithoutLoginLimit) { + Status.LastWihtoutLoginLimit } else { Status.Ready } @@ -142,5 +147,6 @@ enum class Status { LastLinked, NotAuthenticated, WrongAccount, + LastWihtoutLoginLimit, Ready } diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/delete/DeleteParentView.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/delete/DeleteParentView.kt index 404216e..5cfbb83 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/parent/delete/DeleteParentView.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/delete/DeleteParentView.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 Jonas Lochmann + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -41,6 +41,7 @@ object DeleteParentView { Status.NotAuthenticated -> context.getString(R.string.manage_parent_remove_user_status_not_authenticated, userName) Status.WrongAccount -> context.getString(R.string.manage_parent_remove_user_status_wrong_account, userName) Status.Ready -> context.getString(R.string.manage_parent_remove_user_status_ready, userName) + Status.LastWihtoutLoginLimit -> context.getString(R.string.manage_parent_remove_user_status_last_without_login_limit) null -> "" } }) diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/limitlogin/LimitLoginRestrictedToUserItselfDialogFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/limitlogin/LimitLoginRestrictedToUserItselfDialogFragment.kt new file mode 100644 index 0000000..47f1231 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/limitlogin/LimitLoginRestrictedToUserItselfDialogFragment.kt @@ -0,0 +1,39 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.parent.limitlogin + +import android.app.Dialog +import android.os.Bundle +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentManager +import io.timelimit.android.R +import io.timelimit.android.extensions.showSafe + +class LimitLoginRestrictedToUserItselfDialogFragment: DialogFragment() { + companion object { + private const val DIALOG_TAG = "LimitLoginRestrictedToUserItselfDialogFragment" + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + return AlertDialog.Builder(context!!, theme) + .setMessage(R.string.parent_limit_login_error_user_itself) + .setPositiveButton(R.string.generic_ok, null) + .create() + } + + fun show(fragmentManager: FragmentManager) = showSafe(fragmentManager, DIALOG_TAG) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/limitlogin/ParentLimitLoginSelectCategoryDialogFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/limitlogin/ParentLimitLoginSelectCategoryDialogFragment.kt new file mode 100644 index 0000000..950cab9 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/limitlogin/ParentLimitLoginSelectCategoryDialogFragment.kt @@ -0,0 +1,138 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.ui.manage.parent.limitlogin + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.CheckedTextView +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Observer +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import io.timelimit.android.R +import io.timelimit.android.data.model.UserType +import io.timelimit.android.databinding.BottomSheetSelectionListBinding +import io.timelimit.android.extensions.showSafe +import io.timelimit.android.livedata.map +import io.timelimit.android.livedata.switchMap +import io.timelimit.android.sync.actions.UpdateUserLimitLoginCategory +import io.timelimit.android.ui.main.ActivityViewModelHolder +import io.timelimit.android.ui.payment.RequiresPurchaseDialogFragment + +class ParentLimitLoginSelectCategoryDialogFragment: BottomSheetDialogFragment() { + companion object { + private const val DIALOG_TAG = "ParentLimitLoginSelectCategoryDialogFragment" + private const val USER_ID = "userId" + + fun newInstance(userId: String) = ParentLimitLoginSelectCategoryDialogFragment().apply { + arguments = Bundle().apply { + putString(USER_ID, userId) + } + } + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val userId = arguments!!.getString(USER_ID)!! + val auth = (activity as ActivityViewModelHolder).getActivityViewModel() + val logic = auth.logic + val options = logic.database.userLimitLoginCategoryDao().getLimitLoginCategoryOptions(userId) + val hasPremium = logic.fullVersion.shouldProvideFullVersionFunctions + + val binding = BottomSheetSelectionListBinding.inflate(inflater, container, false) + + binding.title = getString(R.string.parent_limit_login_title) + + val list = binding.list + + hasPremium.switchMap { a -> + options.switchMap { b -> + auth.authenticatedUser.map { c -> + Triple(a, b, c) + } + } + }.observe(viewLifecycleOwner, Observer { (hasPremium, categoryList, user) -> + if (user?.second?.type != UserType.Parent) { + dismissAllowingStateLoss(); return@Observer + } + + val isUserItself = user.second.id == userId + + val hasSelection = categoryList.find { it.selected } != null + + list.removeAllViews() + + fun buildRow(): CheckedTextView = LayoutInflater.from(context!!).inflate( + android.R.layout.simple_list_item_single_choice, + list, + false + ) as CheckedTextView + + categoryList.forEach { category -> + val row = buildRow() + + row.text = getString(R.string.parent_limit_login_dialog_item, category.childTitle, category.categoryTitle) + row.isChecked = category.selected + row.setOnClickListener { + if (!hasPremium) { + RequiresPurchaseDialogFragment().show(parentFragmentManager) + } else if (!row.isChecked) { + if (isUserItself) { + auth.tryDispatchParentAction( + UpdateUserLimitLoginCategory( + userId = userId, + categoryId = category.categoryId + ) + ) + + dismiss() + } else { + LimitLoginRestrictedToUserItselfDialogFragment().show(parentFragmentManager) + } + } else { + dismiss() + } + } + + list.addView(row) + } + + buildRow().let { row -> + row.setText(R.string.parent_limit_login_dialog_no_selection) + row.isChecked = !hasSelection + row.setOnClickListener { + if (!row.isChecked) { + auth.tryDispatchParentAction( + UpdateUserLimitLoginCategory( + userId = userId, + categoryId = null + ) + ) + } + + dismiss() + } + + list.addView(row) + } + }) + + return binding.root + } + + fun show(fragmentManager: FragmentManager) = showSafe(fragmentManager, DIALOG_TAG) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/limitlogin/ParentLimitLoginView.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/limitlogin/ParentLimitLoginView.kt new file mode 100644 index 0000000..653d18b --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/limitlogin/ParentLimitLoginView.kt @@ -0,0 +1,70 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.ui.manage.parent.limitlogin + +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.Observer +import io.timelimit.android.R +import io.timelimit.android.databinding.ParentLimitLoginViewBinding +import io.timelimit.android.livedata.map +import io.timelimit.android.livedata.switchMap +import io.timelimit.android.ui.help.HelpDialogFragment +import io.timelimit.android.ui.main.ActivityViewModel + +object ParentLimitLoginView { + fun bind( + view: ParentLimitLoginViewBinding, + lifecycleOwner: LifecycleOwner, + userId: String, + auth: ActivityViewModel, + fragmentManager: FragmentManager + ) { + val database = auth.logic.database + val context = view.root.context + + view.titleView.setOnClickListener { + HelpDialogFragment.newInstance( + title = R.string.parent_limit_login_title, + text = R.string.parent_limit_login_help + ).show(fragmentManager) + } + + view.changeButton.setOnClickListener { + if (auth.requestAuthenticationOrReturnTrue()) { + ParentLimitLoginSelectCategoryDialogFragment.newInstance(userId).show(fragmentManager) + } + } + + database.userLimitLoginCategoryDao().countOtherUsersWithoutLimitLoginCategoryLive(userId).switchMap { otherUsers -> + database.userLimitLoginCategoryDao().getByParentUserIdLive(userId).map { config -> + otherUsers to config + } + }.observe(lifecycleOwner, Observer { (otherUsers, config) -> + if (otherUsers == 0L) { + view.canConfigure = false + view.status = context.getString(R.string.parent_limit_login_status_needs_other_user) + } else { + view.canConfigure = true + view.status = if (config == null) + context.getString(R.string.parent_limit_login_status_disabled) + else + context.getString(R.string.parent_limit_login_status_enabled, config.categoryTitle, config.childTitle) + } + }) + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_manage_parent.xml b/app/src/main/res/layout/fragment_manage_parent.xml index 4441a0a..6f93bf0 100644 --- a/app/src/main/res/layout/fragment_manage_parent.xml +++ b/app/src/main/res/layout/fragment_manage_parent.xml @@ -163,6 +163,9 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/parent_limit_login_view.xml b/app/src/main/res/layout/parent_limit_login_view.xml new file mode 100644 index 0000000..1208a83 --- /dev/null +++ b/app/src/main/res/layout/parent_limit_login_view.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + +