From 20ca6bcc9190283e3c39d1b29e30ea1bb0ecea6c Mon Sep 17 00:00:00 2001 From: Jonas Lochmann Date: Mon, 24 Aug 2020 02:00:00 +0200 Subject: [PATCH] Add option to limit categories depending on the current network --- .../32.json | 1115 +++++++++++++++++ app/src/main/AndroidManifest.xml | 3 + .../io/timelimit/android/crypto/Sha512.kt | 4 +- .../io/timelimit/android/data/Database.kt | 1 + .../io/timelimit/android/data/Migrations.kt | 6 + .../io/timelimit/android/data/RoomDatabase.kt | 8 +- .../data/backup/DatabaseBackupLowlevel.kt | 19 +- .../android/data/dao/CategoryNetworkIdDao.kt | 49 + .../android/data/invalidation/Tables.kt | 6 +- .../android/data/model/CategoryNetworkId.kt | 98 ++ .../data/model/derived/CategoryRelatedData.kt | 19 +- .../data/model/derived/UserRelatedData.kt | 15 +- .../platform/PlatformIntegration.kt | 12 +- .../platform/android/AndroidIntegration.kt | 3 + .../platform/android/ConnectedNetworkUtil.kt | 41 + .../platform/android/NotificationListener.kt | 13 +- .../platform/dummy/DummyIntegration.kt | 2 + .../android/logic/BackgroundTaskLogic.kt | 27 +- .../timelimit/android/logic/BlockingReason.kt | 3 +- .../android/logic/SuspendAppsLogic.kt | 4 +- .../logic/blockingreason/AppBaseHandling.kt | 16 +- .../blockingreason/CategoryHandlingCache.kt | 16 +- .../blockingreason/CategoryItselfHandling.kt | 43 +- .../android/sync/ApplyServerDataStatus.kt | 15 + .../timelimit/android/sync/actions/Actions.kt | 44 + .../timelimit/android/sync/actions/Parser.kt | 2 + .../sync/actions/dispatch/ParentAction.kt | 24 + .../android/sync/network/ServerDataStatus.kt | 46 +- .../ui/diagnose/DiagnoseConnectionFragment.kt | 14 +- .../timelimit/android/ui/lock/LockFragment.kt | 63 +- .../android/ui/login/AllowUserLoginStatus.kt | 4 +- .../android/ui/login/NewLoginFragment.kt | 1 + .../settings/CategorySettingsFragment.kt | 23 + .../networks/ManageCategoryNetworksView.kt | 155 +++ .../networks/RequestWifiPermission.kt | 53 + .../android/ui/widget/TimesWidgetItems.kt | 4 +- .../res/drawable/ic_baseline_wifi_lock_24.xml | 10 + .../res/layout/fragment_category_settings.xml | 3 + .../layout/fragment_diagnose_connection.xml | 89 +- app/src/main/res/layout/lock_fragment.xml | 40 +- .../layout/manage_category_networks_view.xml | 135 ++ app/src/main/res/values-de/string-generic.xml | 1 + .../main/res/values-de/strings-diagnose.xml | 1 + app/src/main/res/values-de/strings-lock.xml | 8 + .../strings-manage-category-networks.xml | 47 + app/src/main/res/values/string-generic.xml | 1 + app/src/main/res/values/strings-diagnose.xml | 1 + app/src/main/res/values/strings-lock.xml | 7 + .../strings-manage-category-networks.xml | 43 + 49 files changed, 2265 insertions(+), 92 deletions(-) create mode 100644 app/schemas/io.timelimit.android.data.RoomDatabase/32.json create mode 100644 app/src/main/java/io/timelimit/android/data/dao/CategoryNetworkIdDao.kt create mode 100644 app/src/main/java/io/timelimit/android/data/model/CategoryNetworkId.kt create mode 100644 app/src/main/java/io/timelimit/android/integration/platform/android/ConnectedNetworkUtil.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/category/settings/networks/ManageCategoryNetworksView.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/category/settings/networks/RequestWifiPermission.kt create mode 100644 app/src/main/res/drawable/ic_baseline_wifi_lock_24.xml create mode 100644 app/src/main/res/layout/manage_category_networks_view.xml create mode 100644 app/src/main/res/values-de/strings-manage-category-networks.xml create mode 100644 app/src/main/res/values/strings-manage-category-networks.xml diff --git a/app/schemas/io.timelimit.android.data.RoomDatabase/32.json b/app/schemas/io.timelimit.android.data.RoomDatabase/32.json new file mode 100644 index 0000000..7119915 --- /dev/null +++ b/app/schemas/io.timelimit.android.data.RoomDatabase/32.json @@ -0,0 +1,1115 @@ +{ + "formatVersion": 1, + "database": { + "version": 32, + "identityHash": "9935dfc30c9a5e09b8d71f388d526eb1", + "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" + ] + } + ] + }, + { + "tableName": "category_network_id", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`category_id` TEXT NOT NULL, `network_item_id` TEXT NOT NULL, `hashed_network_id` TEXT NOT NULL, PRIMARY KEY(`category_id`, `network_item_id`), FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "networkItemId", + "columnName": "network_item_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hashedNetworkId", + "columnName": "hashed_network_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "category_id", + "network_item_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "category", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "category_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "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, '9935dfc30c9a5e09b8d71f388d526eb1')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index e7bc317..6962ca5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -33,6 +33,9 @@ + + + diff --git a/app/src/main/java/io/timelimit/android/crypto/Sha512.kt b/app/src/main/java/io/timelimit/android/crypto/Sha512.kt index d44087d..1caa8d4 100644 --- a/app/src/main/java/io/timelimit/android/crypto/Sha512.kt +++ b/app/src/main/java/io/timelimit/android/crypto/Sha512.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 @@ -24,5 +24,5 @@ object Sha512 { return HexString.toHex(hashSync(data.toByteArray(charset("UTF-8")))) } - fun hashSync(data: ByteArray) = messageDigest.digest(data) + fun hashSync(data: ByteArray): ByteArray = messageDigest.digest(data) } 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 2f9feac..e698665 100644 --- a/app/src/main/java/io/timelimit/android/data/Database.kt +++ b/app/src/main/java/io/timelimit/android/data/Database.kt @@ -38,6 +38,7 @@ interface Database { fun sessionDuration(): SessionDurationDao fun derivedDataDao(): DerivedDataDao fun userLimitLoginCategoryDao(): UserLimitLoginCategoryDao + fun categoryNetworkId(): CategoryNetworkIdDao 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 f17fda2..a0c2b5d 100644 --- a/app/src/main/java/io/timelimit/android/data/Migrations.kt +++ b/app/src/main/java/io/timelimit/android/data/Migrations.kt @@ -231,4 +231,10 @@ object DatabaseMigrations { database.execSQL("CREATE INDEX IF NOT EXISTS `user_limit_login_category_index_category_id` ON `user_limit_login_category` (`category_id`)") } } + + val MIGRATE_TO_V32 = object: Migration(31, 32) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE TABLE IF NOT EXISTS `category_network_id` (`category_id` TEXT NOT NULL, `network_item_id` TEXT NOT NULL, `hashed_network_id` TEXT NOT NULL, PRIMARY KEY(`category_id`, `network_item_id`), FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )") + } + } } 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 7250969..c43f32a 100644 --- a/app/src/main/java/io/timelimit/android/data/RoomDatabase.kt +++ b/app/src/main/java/io/timelimit/android/data/RoomDatabase.kt @@ -47,8 +47,9 @@ import java.util.concurrent.TimeUnit AllowedContact::class, UserKey::class, SessionDuration::class, - UserLimitLoginCategory::class -], version = 31) + UserLimitLoginCategory::class, + CategoryNetworkId::class +], version = 32) abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database { companion object { private val lock = Object() @@ -113,7 +114,8 @@ abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database DatabaseMigrations.MIGRATE_TO_V28, DatabaseMigrations.MIGRATE_TO_V29, DatabaseMigrations.MIGRATE_TO_V30, - DatabaseMigrations.MIGRATE_TO_V31 + DatabaseMigrations.MIGRATE_TO_V31, + DatabaseMigrations.MIGRATE_TO_V32 ) .setQueryExecutor(Threads.database) .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 7687d12..ac65ad2 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 @@ -43,6 +43,7 @@ object DatabaseBackupLowlevel { private const val USER_KEY = "userKey" private const val SESSION_DURATION = "sessionDuration" private const val USER_LIMIT_LOGIN_CATEGORY = "userLimitLoginCategory" + private const val CATEGORY_NETWORK_ID = "categoryNetworkId" fun outputAsBackupJson(database: Database, outputStream: OutputStream) { val writer = JsonWriter(OutputStreamWriter(outputStream, Charsets.UTF_8)) @@ -90,6 +91,7 @@ object DatabaseBackupLowlevel { 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) } + handleCollection(CATEGORY_NETWORK_ID) { offset, pageSize -> database.categoryNetworkId().getPageSync(offset, pageSize) } writer.endObject().flush() } @@ -98,6 +100,7 @@ object DatabaseBackupLowlevel { val reader = JsonReader(InputStreamReader(inputStream, Charsets.UTF_8)) var userLoginLimitCategories = emptyList() + var categoryNetworkId = emptyList() database.runInTransaction { database.deleteAllData() @@ -251,12 +254,26 @@ object DatabaseBackupLowlevel { reader.endArray() } + CATEGORY_NETWORK_ID -> { + reader.beginArray() + + mutableListOf().let { list -> + while (reader.hasNext()) { + list.add(CategoryNetworkId.parse(reader)) + } + + categoryNetworkId = list + } + + reader.endArray() + } else -> reader.skipValue() } } reader.endObject() - database.userLimitLoginCategoryDao().addItemsSync(userLoginLimitCategories) + if (userLoginLimitCategories.isNotEmpty()) { database.userLimitLoginCategoryDao().addItemsSync(userLoginLimitCategories) } + if (categoryNetworkId.isNotEmpty()) { database.categoryNetworkId().insertItemsSync(categoryNetworkId) } } } } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/dao/CategoryNetworkIdDao.kt b/app/src/main/java/io/timelimit/android/data/dao/CategoryNetworkIdDao.kt new file mode 100644 index 0000000..56ef5e5 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/dao/CategoryNetworkIdDao.kt @@ -0,0 +1,49 @@ +/* + * 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.Query +import io.timelimit.android.data.model.CategoryNetworkId + +@Dao +interface CategoryNetworkIdDao { + @Query("SELECT * FROM category_network_id LIMIT :pageSize OFFSET :offset") + fun getPageSync(offset: Int, pageSize: Int): List + + @Query("SELECT * FROM category_network_id WHERE category_id = :categoryId") + fun getByCategoryIdLive(categoryId: String): LiveData> + + @Query("SELECT * FROM category_network_id WHERE category_id = :categoryId") + fun getByCategoryIdSync(categoryId: String): List + + @Query("SELECT * FROM category_network_id WHERE category_id = :categoryId AND network_item_id = :itemId") + fun getByCategoryIdAndItemIdSync(categoryId: String, itemId: String): CategoryNetworkId? + + @Query("SELECT COUNT(*) FROM category_network_id WHERE category_id = :categoryId") + fun countByCategoryIdSync(categoryId: String): Long + + @Insert + fun insertItemsSync(items: List) + + @Insert + fun insertItemSync(item: CategoryNetworkId) + + @Query("DELETE FROM category_network_id WHERE category_id = :categoryId") + fun deleteByCategoryId(categoryId: String) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/invalidation/Tables.kt b/app/src/main/java/io/timelimit/android/data/invalidation/Tables.kt index 1aba83a..d6fc2d9 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 @@ -32,7 +32,8 @@ enum class Table { UsedTimeItem, User, UserKey, - UserLimitLoginCategory + UserLimitLoginCategory, + CategoryNetworkId } object TableNames { @@ -52,6 +53,7 @@ object TableNames { const val USER = "user" const val USER_KEY = "user_key" const val USER_LIMIT_LOGIN_CATEGORY = "user_limit_login_category" + const val CATEGORY_NETWORK_ID = "category_network_id" } object TableUtil { @@ -72,6 +74,7 @@ object TableUtil { Table.User -> TableNames.USER Table.UserKey -> TableNames.USER_KEY Table.UserLimitLoginCategory -> TableNames.USER_LIMIT_LOGIN_CATEGORY + Table.CategoryNetworkId -> TableNames.CATEGORY_NETWORK_ID } fun toEnum(value: String): Table = when (value) { @@ -91,6 +94,7 @@ object TableUtil { TableNames.USER -> Table.User TableNames.USER_KEY -> Table.UserKey TableNames.USER_LIMIT_LOGIN_CATEGORY -> Table.UserLimitLoginCategory + TableNames.CATEGORY_NETWORK_ID -> Table.CategoryNetworkId else -> throw IllegalArgumentException() } } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/model/CategoryNetworkId.kt b/app/src/main/java/io/timelimit/android/data/model/CategoryNetworkId.kt new file mode 100644 index 0000000..3254173 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/model/CategoryNetworkId.kt @@ -0,0 +1,98 @@ +/* + * 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.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import io.timelimit.android.crypto.HexString +import io.timelimit.android.crypto.Sha512 +import io.timelimit.android.data.IdGenerator +import io.timelimit.android.data.JsonSerializable + +@Entity( + tableName = "category_network_id", + primaryKeys = ["category_id", "network_item_id"], + foreignKeys = [ + ForeignKey( + entity = Category::class, + parentColumns = ["id"], + childColumns = ["category_id"], + onUpdate = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE + ) + ] +) +data class CategoryNetworkId( + @ColumnInfo(name = "category_id") + val categoryId: String, + @ColumnInfo(name = "network_item_id") + val networkItemId: String, + @ColumnInfo(name = "hashed_network_id") + val hashedNetworkId: String +): JsonSerializable { + companion object { + private const val CATEGORY_ID = "categoryId" + private const val NETWORK_ITEM_ID = "networkItemId" + private const val HASHED_NETWORK_ID = "hashedNetworkId" + const val ANONYMIZED_NETWORK_ID_LENGTH = 8 + const val MAX_ITEMS = 8 + + fun anonymizeNetworkId(itemId: String, networkId: String) = Sha512.hashSync(itemId + networkId).substring(0, ANONYMIZED_NETWORK_ID_LENGTH) + + fun parse(reader: JsonReader): CategoryNetworkId { + var categoryId: String? = null + var networkItemId: String? = null + var hashedNetworkId: String? = null + + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + CATEGORY_ID -> categoryId = reader.nextString() + NETWORK_ITEM_ID -> networkItemId = reader.nextString() + HASHED_NETWORK_ID -> hashedNetworkId = reader.nextString() + else -> reader.skipValue() + } + } + reader.endObject() + + return CategoryNetworkId( + categoryId = categoryId!!, + networkItemId = networkItemId!!, + hashedNetworkId = hashedNetworkId!! + ) + } + } + + init { + IdGenerator.assertIdValid(categoryId) + IdGenerator.assertIdValid(networkItemId) + if (hashedNetworkId.length != ANONYMIZED_NETWORK_ID_LENGTH) throw IllegalArgumentException() + HexString.assertIsHexString(hashedNetworkId) + } + + override fun serialize(writer: JsonWriter) { + writer.beginObject() + + writer.name(CATEGORY_ID).value(categoryId) + writer.name(NETWORK_ITEM_ID).value(networkItemId) + writer.name(HASHED_NETWORK_ID).value(hashedNetworkId) + + writer.endObject() + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/model/derived/CategoryRelatedData.kt b/app/src/main/java/io/timelimit/android/data/model/derived/CategoryRelatedData.kt index bb6eeb6..b8ea9df 100644 --- a/app/src/main/java/io/timelimit/android/data/model/derived/CategoryRelatedData.kt +++ b/app/src/main/java/io/timelimit/android/data/model/derived/CategoryRelatedData.kt @@ -17,28 +17,28 @@ package io.timelimit.android.data.model.derived import io.timelimit.android.data.Database -import io.timelimit.android.data.model.Category -import io.timelimit.android.data.model.SessionDuration -import io.timelimit.android.data.model.TimeLimitRule -import io.timelimit.android.data.model.UsedTimeItem +import io.timelimit.android.data.model.* data class CategoryRelatedData( val category: Category, val rules: List, val usedTimes: List, - val durations: List + val durations: List, + val networks: List ) { companion object { fun load(category: Category, database: Database): CategoryRelatedData = database.runInUnobservedTransaction { val rules = database.timeLimitRules().getTimeLimitRulesByCategorySync(category.id) val usedTimes = database.usedTimes().getUsedTimeItemsByCategoryId(category.id) val durations = database.sessionDuration().getSessionDurationItemsByCategoryIdSync(category.id) + val networks = database.categoryNetworkId().getByCategoryIdSync(category.id) CategoryRelatedData( category = category, rules = rules, usedTimes = usedTimes, - durations = durations + durations = durations, + networks = networks ) } } @@ -48,6 +48,7 @@ data class CategoryRelatedData( updateRules: Boolean, updateTimes: Boolean, updateDurations: Boolean, + updateNetworks: Boolean, database: Database ): CategoryRelatedData = database.runInUnobservedTransaction { if (category.id != this.category.id) { @@ -57,15 +58,17 @@ data class CategoryRelatedData( val rules = if (updateRules) database.timeLimitRules().getTimeLimitRulesByCategorySync(category.id) else rules val usedTimes = if (updateTimes) database.usedTimes().getUsedTimeItemsByCategoryId(category.id) else usedTimes val durations = if (updateDurations) database.sessionDuration().getSessionDurationItemsByCategoryIdSync(category.id) else durations + val networks = if (updateNetworks) database.categoryNetworkId().getByCategoryIdSync(category.id) else networks - if (category == this.category && rules == this.rules && usedTimes == this.usedTimes && durations == this.durations) { + if (category == this.category && rules == this.rules && usedTimes == this.usedTimes && durations == this.durations && networks == this.networks) { this } else { CategoryRelatedData( category = category, rules = rules, usedTimes = usedTimes, - durations = durations + durations = durations, + networks = networks ) } } diff --git a/app/src/main/java/io/timelimit/android/data/model/derived/UserRelatedData.kt b/app/src/main/java/io/timelimit/android/data/model/derived/UserRelatedData.kt index ce47508..f4c6c93 100644 --- a/app/src/main/java/io/timelimit/android/data/model/derived/UserRelatedData.kt +++ b/app/src/main/java/io/timelimit/android/data/model/derived/UserRelatedData.kt @@ -38,7 +38,8 @@ data class UserRelatedData( private val relatedTables = arrayOf( Table.User, Table.Category, Table.TimeLimitRule, - Table.UsedTimeItem, Table.SessionDuration, Table.CategoryApp + Table.UsedTimeItem, Table.SessionDuration, Table.CategoryApp, + Table.CategoryNetworkId ) fun load(user: User, database: Database): UserRelatedData = database.runInUnobservedTransaction { @@ -82,9 +83,10 @@ data class UserRelatedData( private var usedTimesInvalidated = false private var sessionDurationsInvalidated = false private var categoryAppsInvalidated = false + private var categoryNetworksInvalidated = false private val invalidated - get() = userInvalidated || categoriesInvalidated || rulesInvalidated || usedTimesInvalidated || sessionDurationsInvalidated || categoryAppsInvalidated + get() = userInvalidated || categoriesInvalidated || rulesInvalidated || usedTimesInvalidated || sessionDurationsInvalidated || categoryAppsInvalidated || categoryNetworksInvalidated override fun onInvalidated(tables: Set) { tables.forEach { @@ -95,6 +97,7 @@ data class UserRelatedData( Table.UsedTimeItem -> usedTimesInvalidated = true Table.SessionDuration -> sessionDurationsInvalidated = true Table.CategoryApp -> categoryAppsInvalidated = true + Table.CategoryNetworkId -> categoryNetworksInvalidated = true else -> {/* do nothing */} } } @@ -117,20 +120,22 @@ data class UserRelatedData( database = database, updateDurations = sessionDurationsInvalidated, updateRules = rulesInvalidated, - updateTimes = usedTimesInvalidated + updateTimes = usedTimesInvalidated, + updateNetworks = categoryNetworksInvalidated ) ?: CategoryRelatedData.load( category = category, database = database ) } - } else if (sessionDurationsInvalidated || rulesInvalidated || usedTimesInvalidated) { + } else if (sessionDurationsInvalidated || rulesInvalidated || usedTimesInvalidated || categoryNetworksInvalidated) { categories.map { it.update( category = it.category, database = database, updateDurations = sessionDurationsInvalidated, updateRules = rulesInvalidated, - updateTimes = usedTimesInvalidated + updateTimes = usedTimesInvalidated, + updateNetworks = categoryNetworksInvalidated ) } } else { diff --git a/app/src/main/java/io/timelimit/android/integration/platform/PlatformIntegration.kt b/app/src/main/java/io/timelimit/android/integration/platform/PlatformIntegration.kt index f12ffb3..32abd84 100644 --- a/app/src/main/java/io/timelimit/android/integration/platform/PlatformIntegration.kt +++ b/app/src/main/java/io/timelimit/android/integration/platform/PlatformIntegration.kt @@ -74,6 +74,8 @@ abstract class PlatformIntegration( abstract fun restartApp() + abstract fun getCurrentNetworkId(): NetworkId + var installedAppsChangeListener: Runnable? = null var systemClockChangeListener: Runnable? = null } @@ -216,4 +218,12 @@ data class BatteryStatus( throw IllegalArgumentException() } } -} \ No newline at end of file +} + +sealed class NetworkId { + object NoNetworkConnected : NetworkId() + object MissingPermission: NetworkId() + data class Network(val id: String): NetworkId() +} + +fun NetworkId.getNetworkIdOrNull(): String? = if (this is NetworkId.Network) this.id else null \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/integration/platform/android/AndroidIntegration.kt b/app/src/main/java/io/timelimit/android/integration/platform/android/AndroidIntegration.kt index 9cbfe83..be9965e 100644 --- a/app/src/main/java/io/timelimit/android/integration/platform/android/AndroidIntegration.kt +++ b/app/src/main/java/io/timelimit/android/integration/platform/android/AndroidIntegration.kt @@ -83,6 +83,7 @@ class AndroidIntegration(context: Context): PlatformIntegration(maximumProtectio private val deviceAdmin = ComponentName(context.applicationContext, AdminReceiver::class.java) private val overlay = OverlayUtil(context as Application) private val battery = BatteryStatusUtil(context) + private val connectedNetwork = ConnectedNetworkUtil(context) init { AppsChangeListener.registerBroadcastReceiver(this.context, object : BroadcastReceiver() { @@ -520,4 +521,6 @@ class AndroidIntegration(context: Context): PlatformIntegration(maximumProtectio } } } + + override fun getCurrentNetworkId(): NetworkId = connectedNetwork.getNetworkId() } diff --git a/app/src/main/java/io/timelimit/android/integration/platform/android/ConnectedNetworkUtil.kt b/app/src/main/java/io/timelimit/android/integration/platform/android/ConnectedNetworkUtil.kt new file mode 100644 index 0000000..95a96b7 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/integration/platform/android/ConnectedNetworkUtil.kt @@ -0,0 +1,41 @@ +/* + * 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.integration.platform.android + +import android.content.Context +import android.net.wifi.WifiInfo +import android.net.wifi.WifiManager +import io.timelimit.android.crypto.Sha512 +import io.timelimit.android.integration.platform.NetworkId + +class ConnectedNetworkUtil (context: Context) { + private val workContext = context.applicationContext + private val wifiManager = workContext.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager + + fun getNetworkId(): NetworkId { + val info: WifiInfo? = wifiManager.connectionInfo + + info ?: return NetworkId.NoNetworkConnected + + val ssid: String? = info.ssid + val bssid: String? = info.bssid + + if (ssid == null || bssid == null) return NetworkId.NoNetworkConnected + if (ssid == WifiManager.UNKNOWN_SSID) return NetworkId.MissingPermission + + return NetworkId.Network(Sha512.hashSync(ssid + bssid).substring(0, 16)) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/integration/platform/android/NotificationListener.kt b/app/src/main/java/io/timelimit/android/integration/platform/android/NotificationListener.kt index f5e6c92..8a0df12 100644 --- a/app/src/main/java/io/timelimit/android/integration/platform/android/NotificationListener.kt +++ b/app/src/main/java/io/timelimit/android/integration/platform/android/NotificationListener.kt @@ -31,6 +31,8 @@ import io.timelimit.android.async.Threads import io.timelimit.android.coroutines.executeAndWait import io.timelimit.android.coroutines.runAsync import io.timelimit.android.data.model.UserType +import io.timelimit.android.integration.platform.getNetworkIdOrNull +import io.timelimit.android.livedata.waitForNonNullValue import io.timelimit.android.logic.* import io.timelimit.android.logic.blockingreason.AppBaseHandling import io.timelimit.android.logic.blockingreason.CategoryItselfHandling @@ -117,6 +119,7 @@ class NotificationListener: NotificationListenerService() { 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.MissingRequiredNetwork -> getString(R.string.lock_reason_short_missing_required_network) BlockingReason.None -> throw IllegalStateException() } ) @@ -165,12 +168,16 @@ class NotificationListener: NotificationListenerService() { val time = RealTime.newInstance() val battery = appLogic.platformIntegration.getBatteryStatus() val allowNotificationFilter = deviceAndUserRelatedData.deviceRelatedData.isConnectedAndHasPremium || deviceAndUserRelatedData.deviceRelatedData.isLocalMode + val networkId = if (appHandling.needsNetworkId) appLogic.platformIntegration.getCurrentNetworkId().getNetworkIdOrNull() else null + val hasPremiumOrLocalMode = appLogic.fullVersion.shouldProvideFullVersionFunctions.waitForNonNullValue() appLogic.realTimeLogic.getRealTime(time) val categoryHandlings = appHandling.categoryIds.map { categoryId -> + val categoryRelatedData = deviceAndUserRelatedData.userRelatedData.categoryById[categoryId]!! + CategoryItselfHandling.calculate( - categoryRelatedData = deviceAndUserRelatedData.userRelatedData.categoryById[categoryId]!!, + categoryRelatedData = categoryRelatedData, user = deviceAndUserRelatedData.userRelatedData, assumeCurrentDevice = CurrentDeviceLogic.handleDeviceAsCurrentDevice( device = deviceAndUserRelatedData.deviceRelatedData, @@ -178,7 +185,9 @@ class NotificationListener: NotificationListenerService() { ), batteryStatus = battery, shouldTrustTimeTemporarily = time.shouldTrustTimeTemporarily, - timeInMillis = time.timeInMillis + timeInMillis = time.timeInMillis, + currentNetworkId = networkId, + hasPremiumOrLocalMode = hasPremiumOrLocalMode ) } diff --git a/app/src/main/java/io/timelimit/android/integration/platform/dummy/DummyIntegration.kt b/app/src/main/java/io/timelimit/android/integration/platform/dummy/DummyIntegration.kt index e6fecd8..741dd68 100644 --- a/app/src/main/java/io/timelimit/android/integration/platform/dummy/DummyIntegration.kt +++ b/app/src/main/java/io/timelimit/android/integration/platform/dummy/DummyIntegration.kt @@ -175,4 +175,6 @@ class DummyIntegration( override fun setForceNetworkTime(enable: Boolean) = Unit override fun restartApp() = Unit + + override fun getCurrentNetworkId(): NetworkId = NetworkId.NoNetworkConnected } diff --git a/app/src/main/java/io/timelimit/android/logic/BackgroundTaskLogic.kt b/app/src/main/java/io/timelimit/android/logic/BackgroundTaskLogic.kt index fbc8fc1..bb36846 100644 --- a/app/src/main/java/io/timelimit/android/logic/BackgroundTaskLogic.kt +++ b/app/src/main/java/io/timelimit/android/logic/BackgroundTaskLogic.kt @@ -31,9 +31,11 @@ import io.timelimit.android.date.DateInTimezone import io.timelimit.android.integration.platform.AppStatusMessage import io.timelimit.android.integration.platform.ProtectionLevel import io.timelimit.android.integration.platform.android.AccessibilityService +import io.timelimit.android.integration.platform.getNetworkIdOrNull import io.timelimit.android.livedata.* import io.timelimit.android.logic.blockingreason.AppBaseHandling import io.timelimit.android.logic.blockingreason.CategoryHandlingCache +import io.timelimit.android.logic.blockingreason.needsNetworkId import io.timelimit.android.sync.actions.UpdateDeviceStatusAction import io.timelimit.android.sync.actions.apply.ApplyActionUtil import io.timelimit.android.ui.IsAppInForeground @@ -266,16 +268,6 @@ class BackgroundTaskLogic(val appLogic: AppLogic) { } } - fun reportStatusToCategoryHandlingCache(userRelatedData: UserRelatedData) { - categoryHandlingCache.reportStatus( - user = userRelatedData, - timeInMillis = nowTimestamp, - shouldTrustTimeTemporarily = realTime.shouldTrustTimeTemporarily, - assumeCurrentDevice = CurrentDeviceLogic.handleDeviceAsCurrentDevice(deviceRelatedData, userRelatedData), - batteryStatus = batteryStatus - ) - }; reportStatusToCategoryHandlingCache(userRelatedData) - val foregroundApps = appLogic.platformIntegration.getForegroundApps( appLogic.getForegroundAppQueryInterval(), appLogic.getEnableMultiAppDetection() @@ -304,6 +296,21 @@ class BackgroundTaskLogic(val appLogic: AppLogic) { pauseCounting = false ) + val needsNetworkId = foregroundAppWithBaseHandlings.find { it.second.needsNetworkId() } != null || backgroundAppBaseHandling.needsNetworkId() + val networkId: String? = if (needsNetworkId) appLogic.platformIntegration.getCurrentNetworkId().getNetworkIdOrNull() else null + + fun reportStatusToCategoryHandlingCache(userRelatedData: UserRelatedData) { + categoryHandlingCache.reportStatus( + user = userRelatedData, + timeInMillis = nowTimestamp, + shouldTrustTimeTemporarily = realTime.shouldTrustTimeTemporarily, + assumeCurrentDevice = CurrentDeviceLogic.handleDeviceAsCurrentDevice(deviceRelatedData, userRelatedData), + batteryStatus = batteryStatus, + currentNetworkId = networkId, + hasPremiumOrLocalMode = deviceRelatedData.isLocalMode || deviceRelatedData.isConnectedAndHasPremium + ) + }; reportStatusToCategoryHandlingCache(userRelatedData) + // check if should be blocked val blockedForegroundApp = foregroundAppWithBaseHandlings.find { (_, foregroundAppBaseHandling) -> foregroundAppBaseHandling is AppBaseHandling.BlockDueToNoCategory || diff --git a/app/src/main/java/io/timelimit/android/logic/BlockingReason.kt b/app/src/main/java/io/timelimit/android/logic/BlockingReason.kt index 81c65aa..89d40e2 100644 --- a/app/src/main/java/io/timelimit/android/logic/BlockingReason.kt +++ b/app/src/main/java/io/timelimit/android/logic/BlockingReason.kt @@ -32,7 +32,8 @@ enum class BlockingReason { RequiresCurrentDevice, NotificationsAreBlocked, BatteryLimit, - SessionDurationLimit + SessionDurationLimit, + MissingRequiredNetwork } enum class BlockingLevel { diff --git a/app/src/main/java/io/timelimit/android/logic/SuspendAppsLogic.kt b/app/src/main/java/io/timelimit/android/logic/SuspendAppsLogic.kt index a3b9dbd..bdb1eb0 100644 --- a/app/src/main/java/io/timelimit/android/logic/SuspendAppsLogic.kt +++ b/app/src/main/java/io/timelimit/android/logic/SuspendAppsLogic.kt @@ -112,7 +112,9 @@ class SuspendAppsLogic(private val appLogic: AppLogic): Observer { assumeCurrentDevice = CurrentDeviceLogic.handleDeviceAsCurrentDevice( device = userAndDeviceRelatedData.deviceRelatedData, user = userRelatedData - ) + ), + currentNetworkId = null, // not relevant/ not suspending Apps if there is no matching network + hasPremiumOrLocalMode = userAndDeviceRelatedData.deviceRelatedData.isLocalMode || userAndDeviceRelatedData.deviceRelatedData.isConnectedAndHasPremium ) val defaultCategory = userRelatedData.user.categoryForNotAssignedApps 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 7e51044..23f37db 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 @@ -32,7 +32,8 @@ sealed class AppBaseHandling { data class UseCategories( val categoryIds: Set, val shouldCount: Boolean, - val level: BlockingLevel + val level: BlockingLevel, + val needsNetworkId: Boolean ): AppBaseHandling() { init { if (categoryIds.isEmpty()) { @@ -85,14 +86,19 @@ sealed class AppBaseHandling { if (startCategory == null) { return BlockDueToNoCategory } else { + val categoryIds = userRelatedData.getCategoryWithParentCategories(startCategoryId = startCategory.category.id) + return UseCategories( - categoryIds = userRelatedData.getCategoryWithParentCategories(startCategoryId = startCategory.category.id), + categoryIds = categoryIds, shouldCount = !pauseCounting, level = when (appCategory?.specifiesActivity) { null -> BlockingLevel.Activity // occurs when using a default category true -> BlockingLevel.Activity false -> BlockingLevel.App - } + }, + needsNetworkId = categoryIds.find { categoryId -> + userRelatedData.categoryById[categoryId]!!.networks.isNotEmpty() + } != null ) } } else { @@ -112,4 +118,6 @@ sealed class AppBaseHandling { return result } } -} \ No newline at end of file +} + +fun AppBaseHandling.needsNetworkId(): Boolean = if (this is AppBaseHandling.UseCategories) this.needsNetworkId else false \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/logic/blockingreason/CategoryHandlingCache.kt b/app/src/main/java/io/timelimit/android/logic/blockingreason/CategoryHandlingCache.kt index 404db67..c547d78 100644 --- a/app/src/main/java/io/timelimit/android/logic/blockingreason/CategoryHandlingCache.kt +++ b/app/src/main/java/io/timelimit/android/logic/blockingreason/CategoryHandlingCache.kt @@ -27,19 +27,25 @@ class CategoryHandlingCache { private var shouldTrustTimeTemporarily: Boolean = false private var timeInMillis: Long = 0 private var assumeCurrentDevice: Boolean = false + private var currentNetworkId: String? = null + private var hasPremiumOrLocalMode: Boolean = false fun reportStatus( user: UserRelatedData, batteryStatus: BatteryStatus, shouldTrustTimeTemporarily: Boolean, timeInMillis: Long, - assumeCurrentDevice: Boolean + assumeCurrentDevice: Boolean, + currentNetworkId: String?, + hasPremiumOrLocalMode: Boolean ) { this.user = user this.batteryStatus = batteryStatus this.shouldTrustTimeTemporarily = shouldTrustTimeTemporarily this.timeInMillis = timeInMillis this.assumeCurrentDevice = assumeCurrentDevice + this.currentNetworkId = currentNetworkId + this.hasPremiumOrLocalMode = hasPremiumOrLocalMode val iterator = cachedItems.iterator() @@ -54,7 +60,9 @@ class CategoryHandlingCache { batteryStatus = batteryStatus, assumeCurrentDevice = assumeCurrentDevice, shouldTrustTimeTemporarily = shouldTrustTimeTemporarily, - timeInMillis = timeInMillis + timeInMillis = timeInMillis, + currentNetworkId = currentNetworkId, + hasPremiumOrLocalMode = hasPremiumOrLocalMode ) ) { iterator.remove() @@ -76,6 +84,8 @@ class CategoryHandlingCache { batteryStatus = batteryStatus, assumeCurrentDevice = assumeCurrentDevice, shouldTrustTimeTemporarily = shouldTrustTimeTemporarily, - timeInMillis = timeInMillis + timeInMillis = timeInMillis, + currentNetworkId = currentNetworkId, + hasPremiumOrLocalMode = hasPremiumOrLocalMode ) } \ No newline at end of file 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 c976709..fbba930 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 @@ -15,6 +15,7 @@ */ package io.timelimit.android.logic.blockingreason +import io.timelimit.android.data.model.CategoryNetworkId import io.timelimit.android.data.model.derived.CategoryRelatedData import io.timelimit.android.data.model.derived.UserRelatedData import io.timelimit.android.date.DateInTimezone @@ -37,6 +38,7 @@ data class CategoryItselfHandling ( val areLimitsTemporarilyDisabled: Boolean, val okByBattery: Boolean, val okByTempBlocking: Boolean, + val okByNetworkId: Boolean, val okByBlockedTimeAreas: Boolean, val okByTimeLimitRules: Boolean, val okBySessionDurationLimits: Boolean, @@ -50,11 +52,14 @@ data class CategoryItselfHandling ( val dependsOnBatteryCharging: Boolean, val dependsOnMinBatteryLevel: Int, val dependsOnMaxBatteryLevel: Int, + val dependsOnNetworkId: Boolean, val createdWithCategoryRelatedData: CategoryRelatedData, val createdWithUserRelatedData: UserRelatedData, val createdWithBatteryStatus: BatteryStatus, val createdWithTemporarilyTrustTime: Boolean, - val createdWithAssumeCurrentDevice: Boolean + val createdWithAssumeCurrentDevice: Boolean, + val createdWithNetworkId: String?, + val createdWithHasPremiumOrLocalMode: Boolean ) { companion object { fun calculate( @@ -63,7 +68,9 @@ data class CategoryItselfHandling ( batteryStatus: BatteryStatus, shouldTrustTimeTemporarily: Boolean, timeInMillis: Long, - assumeCurrentDevice: Boolean + assumeCurrentDevice: Boolean, + currentNetworkId: String?, + hasPremiumOrLocalMode: Boolean ): CategoryItselfHandling { val dependsOnMinTime = timeInMillis val dateInTimezone = DateInTimezone.newInstance(timeInMillis, user.timeZone) @@ -88,6 +95,14 @@ data class CategoryItselfHandling ( val dependsOnMaxTimeByTemporarilyDisabledLimits = if (areLimitsTemporarilyDisabled) user.user.disableLimitsUntil else Long.MAX_VALUE // ignore it for this case: val requiresTrustedTimeForTempLimitsDisabled = user.user.disableLimitsUntil != 0L + val dependsOnNetworkId = categoryRelatedData.networks.isNotEmpty() + val okByNetworkId = if (categoryRelatedData.networks.isEmpty() || areLimitsTemporarilyDisabled || !hasPremiumOrLocalMode) + true + else if (currentNetworkId == null) + false + else + categoryRelatedData.networks.find { CategoryNetworkId.anonymizeNetworkId(itemId = it.networkItemId, networkId = currentNetworkId) == it.hashedNetworkId } != null + val missingNetworkTimeForBlockedTimeAreas = !categoryRelatedData.category.blockedMinutesInWeek.dataNotToModify.isEmpty val okByBlockedTimeAreas = areLimitsTemporarilyDisabled || !categoryRelatedData.category.blockedMinutesInWeek.read(minuteInWeek) val dependsOnMaxMinuteOfWeekByBlockedTimeAreas = categoryRelatedData.category.blockedMinutesInWeek.let { blockedTimeAreas -> @@ -195,7 +210,7 @@ data class CategoryItselfHandling ( else emptySet() - val blockAllNotifications = categoryRelatedData.category.blockAllNotifications + val blockAllNotifications = categoryRelatedData.category.blockAllNotifications &&hasPremiumOrLocalMode return CategoryItselfHandling( shouldCountTime = shouldCountTime, @@ -205,6 +220,7 @@ data class CategoryItselfHandling ( areLimitsTemporarilyDisabled = areLimitsTemporarilyDisabled, okByBattery = okByBattery, okByTempBlocking = okByTempBlocking, + okByNetworkId = okByNetworkId, okByBlockedTimeAreas = okByBlockedTimeAreas, okByTimeLimitRules = okByTimeLimitRules, okBySessionDurationLimits = okBySessionDurationLimits, @@ -219,22 +235,27 @@ data class CategoryItselfHandling ( dependsOnBatteryCharging = dependsOnBatteryCharging, dependsOnMinBatteryLevel = dependsOnMinBatteryLevel, dependsOnMaxBatteryLevel = dependsOnMaxBatteryLevel, + dependsOnNetworkId = dependsOnNetworkId, createdWithCategoryRelatedData = categoryRelatedData, createdWithBatteryStatus = batteryStatus, createdWithTemporarilyTrustTime = shouldTrustTimeTemporarily, createdWithAssumeCurrentDevice = assumeCurrentDevice, - createdWithUserRelatedData = user + createdWithUserRelatedData = user, + createdWithNetworkId = currentNetworkId, + createdWithHasPremiumOrLocalMode = hasPremiumOrLocalMode ) } } val okBasic = okByBattery && okByTempBlocking && okByBlockedTimeAreas && okByTimeLimitRules && okBySessionDurationLimits && !missingNetworkTime - val okAll = okBasic && okByCurrentDevice + val okAll = okBasic && okByCurrentDevice && okByNetworkId val shouldBlockActivities = !okAll val activityBlockingReason: BlockingReason = if (!okByBattery) BlockingReason.BatteryLimit else if (!okByTempBlocking) BlockingReason.TemporarilyBlocked + else if (!okByNetworkId) + BlockingReason.MissingRequiredNetwork else if (!okByBlockedTimeAreas) BlockingReason.BlockedAtThisTime else if (!okByTimeLimitRules) @@ -278,7 +299,9 @@ data class CategoryItselfHandling ( batteryStatus: BatteryStatus, shouldTrustTimeTemporarily: Boolean, timeInMillis: Long, - assumeCurrentDevice: Boolean + assumeCurrentDevice: Boolean, + currentNetworkId: String?, + hasPremiumOrLocalMode: Boolean ): Boolean { if ( categoryRelatedData != createdWithCategoryRelatedData || user != createdWithUserRelatedData || @@ -299,6 +322,14 @@ data class CategoryItselfHandling ( return false } + if (dependsOnNetworkId && currentNetworkId != createdWithNetworkId) { + return false + } + + if (hasPremiumOrLocalMode != createdWithHasPremiumOrLocalMode) { + return false + } + return true } } 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 a2cc419..3ea5d6e 100644 --- a/app/src/main/java/io/timelimit/android/sync/ApplyServerDataStatus.kt +++ b/app/src/main/java/io/timelimit/android/sync/ApplyServerDataStatus.kt @@ -354,6 +354,21 @@ object ApplyServerDataStatus { database.category().updateCategorySync(updatedCategory) } } + + // apply networks + database.categoryNetworkId().deleteByCategoryId(newCategory.categoryId) + + if (newCategory.networks.isNotEmpty()) { + database.categoryNetworkId().insertItemsSync( + newCategory.networks.map { network -> + CategoryNetworkId( + categoryId = newCategory.categoryId, + networkItemId = network.itemId, + hashedNetworkId = network.hashedNetworkId + ) + } + ) + } } } } 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 772aed7..af27006 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 @@ -965,6 +965,50 @@ data class UpdateCategorySortingAction(val categoryIds: List): ParentAct writer.endObject() } } + +data class AddCategoryNetworkId(val categoryId: String, val itemId: String, val hashedNetworkId: String): ParentAction() { + companion object { + private const val TYPE_VALUE = "ADD_CATEGORY_NETWORK_ID" + private const val CATEGORY_ID = "categoryId" + private const val ITEM_ID = "itemId" + private const val HASHED_NETWORK_ID = "hashedNetworkId" + } + + init { + IdGenerator.assertIdValid(categoryId) + IdGenerator.assertIdValid(itemId) + HexString.assertIsHexString(hashedNetworkId) + if (hashedNetworkId.length != CategoryNetworkId.ANONYMIZED_NETWORK_ID_LENGTH) throw IllegalArgumentException() + } + + override fun serialize(writer: JsonWriter) { + writer.beginObject() + + writer.name(TYPE).value(TYPE_VALUE) + writer.name(CATEGORY_ID).value(categoryId) + writer.name(ITEM_ID).value(itemId) + writer.name(HASHED_NETWORK_ID).value(hashedNetworkId) + + writer.endObject() + } +} + +data class ResetCategoryNetworkIds(val categoryId: String): ParentAction() { + companion object { + private const val TYPE_VALUE = "RESET_CATEGORY_NETWORK_IDS" + private const val CATEGORY_ID = "categoryId" + } + + override fun serialize(writer: JsonWriter) { + writer.beginObject() + + writer.name(TYPE).value(TYPE_VALUE) + writer.name(CATEGORY_ID).value(categoryId) + + writer.endObject() + } +} + // DeviceDao data class UpdateDeviceStatusAction( 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 7dd6f8f..cba0771 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 @@ -70,6 +70,8 @@ object ActionParser { // UpdateCategorySorting // UpdateUserFlagsAction // UpdateUserLimitLoginCategory + // AddCategoryNetworkId + // ResetCategoryNetworkIds 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 d42ea54..5c42d3d 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 @@ -729,6 +729,30 @@ object LocalDatabaseParentActionDispatcher { ) ) } } + is AddCategoryNetworkId -> { + DatabaseValidation.assertCategoryExists(database, action.categoryId) + + val count = database.categoryNetworkId().countByCategoryIdSync(action.categoryId) + + if (count + 1 > CategoryNetworkId.MAX_ITEMS) { + throw IllegalArgumentException() + } + + val oldItem = database.categoryNetworkId().getByCategoryIdAndItemIdSync(categoryId = action.categoryId, itemId = action.itemId) + + if (oldItem != null) { + throw IllegalArgumentException("id already used") + } + + database.categoryNetworkId().insertItemSync(CategoryNetworkId( + categoryId = action.categoryId, + networkItemId = action.itemId, + hashedNetworkId = action.hashedNetworkId + )) + } + is ResetCategoryNetworkIds -> { + database.categoryNetworkId().deleteByCategoryId(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 8dd9ebf..27a7acd 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 @@ -17,6 +17,8 @@ package io.timelimit.android.sync.network import android.util.JsonReader import android.util.JsonToken +import io.timelimit.android.crypto.HexString +import io.timelimit.android.data.IdGenerator import io.timelimit.android.data.customtypes.ImmutableBitmask import io.timelimit.android.data.customtypes.ImmutableBitmaskJson import io.timelimit.android.data.model.* @@ -443,7 +445,8 @@ data class ServerUpdatedCategoryBaseData( val timeWarnings: Int, val minBatteryLevelCharging: Int, val minBatteryLevelMobile: Int, - val sort: Int + val sort: Int, + val networks: List ) { companion object { private const val CATEGORY_ID = "categoryId" @@ -461,6 +464,7 @@ data class ServerUpdatedCategoryBaseData( private const val MIN_BATTERY_LEVEL_MOBILE = "mblMobile" private const val MIN_BATTERY_LEVEL_CHARGING = "mblCharging" private const val SORT = "sort" + private const val NETWORKS = "networks" fun parse(reader: JsonReader): ServerUpdatedCategoryBaseData { var categoryId: String? = null @@ -479,6 +483,7 @@ data class ServerUpdatedCategoryBaseData( var minBatteryLevelCharging = 0 var minBatteryLevelMobile = 0 var sort = 0 + var networks: List = emptyList() reader.beginObject() while (reader.hasNext()) { @@ -498,6 +503,7 @@ data class ServerUpdatedCategoryBaseData( MIN_BATTERY_LEVEL_CHARGING -> minBatteryLevelCharging = reader.nextInt() MIN_BATTERY_LEVEL_MOBILE -> minBatteryLevelMobile = reader.nextInt() SORT -> sort = reader.nextInt() + NETWORKS -> networks = ServerCategoryNetworkId.parseList(reader) else -> reader.skipValue() } } @@ -518,7 +524,8 @@ data class ServerUpdatedCategoryBaseData( timeWarnings = timeWarnings, minBatteryLevelCharging = minBatteryLevelCharging, minBatteryLevelMobile = minBatteryLevelMobile, - sort = sort + sort = sort, + networks = networks ) } @@ -526,6 +533,41 @@ data class ServerUpdatedCategoryBaseData( } } +data class ServerCategoryNetworkId(val itemId: String, val hashedNetworkId: String) { + companion object { + private const val ITEM_ID = "itemId" + private const val HASHED_NETWORK_ID = "hashedNetworkId" + + fun parse(reader: JsonReader): ServerCategoryNetworkId { + var itemId: String? = null + var hashedNetworkId: String? = null + + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + ITEM_ID -> itemId = reader.nextString() + HASHED_NETWORK_ID -> hashedNetworkId = reader.nextString() + else -> reader.skipValue() + } + } + reader.endObject() + + return ServerCategoryNetworkId( + itemId = itemId!!, + hashedNetworkId = hashedNetworkId!! + ) + } + + fun parseList(reader: JsonReader) = parseJsonArray(reader) { parse(reader) } + } + + init { + IdGenerator.assertIdValid(itemId) + HexString.assertIsHexString(hashedNetworkId) + if (hashedNetworkId.length != CategoryNetworkId.ANONYMIZED_NETWORK_ID_LENGTH) throw IllegalArgumentException() + } +} + data class ServerUpdatedCategoryAssignedApps( val categoryId: String, val assignedApps: List, diff --git a/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseConnectionFragment.kt b/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseConnectionFragment.kt index 0acfdc2..e4067f8 100644 --- a/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseConnectionFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseConnectionFragment.kt @@ -25,6 +25,8 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import io.timelimit.android.R import io.timelimit.android.databinding.FragmentDiagnoseConnectionBinding +import io.timelimit.android.integration.platform.NetworkId +import io.timelimit.android.livedata.liveDataFromFunction import io.timelimit.android.livedata.liveDataFromValue import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.sync.websocket.NetworkStatus @@ -35,14 +37,14 @@ class DiagnoseConnectionFragment : Fragment(), FragmentWithCustomTitle { val binding = FragmentDiagnoseConnectionBinding.inflate(inflater, container, false) val logic = DefaultAppLogic.with(context!!) - logic.networkStatus.observe(this, Observer { + logic.networkStatus.observe(viewLifecycleOwner, Observer { binding.generalStatus = getString(when (it!!) { NetworkStatus.Online -> R.string.diagnose_connection_yes NetworkStatus.Offline -> R.string.diagnose_connection_no }) }) - logic.isConnected.observe(this, Observer { + logic.isConnected.observe(viewLifecycleOwner, Observer { binding.ownServerStatus = getString(if (it == true) R.string.diagnose_connection_yes else @@ -50,6 +52,14 @@ class DiagnoseConnectionFragment : Fragment(), FragmentWithCustomTitle { ) }) + liveDataFromFunction { logic.platformIntegration.getCurrentNetworkId() }.observe(viewLifecycleOwner, Observer { + binding.networkId = when (it) { + NetworkId.MissingPermission -> "missing permission" + NetworkId.NoNetworkConnected -> "no network connected" + is NetworkId.Network -> it.id + } + }) + return binding.root } diff --git a/app/src/main/java/io/timelimit/android/ui/lock/LockFragment.kt b/app/src/main/java/io/timelimit/android/ui/lock/LockFragment.kt index 4659e84..2cbae74 100644 --- a/app/src/main/java/io/timelimit/android/ui/lock/LockFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/lock/LockFragment.kt @@ -15,15 +15,19 @@ */ package io.timelimit.android.ui.lock +import android.Manifest import android.content.Intent +import android.content.pm.PackageManager import android.database.sqlite.SQLiteConstraintException import android.os.Bundle import android.text.format.DateUtils import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast import androidx.fragment.app.Fragment import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import io.timelimit.android.R import io.timelimit.android.async.Threads @@ -36,10 +40,13 @@ import io.timelimit.android.databinding.LockFragmentBinding import io.timelimit.android.databinding.LockFragmentCategoryButtonBinding import io.timelimit.android.date.DateInTimezone import io.timelimit.android.integration.platform.BatteryStatus +import io.timelimit.android.integration.platform.NetworkId +import io.timelimit.android.integration.platform.getNetworkIdOrNull import io.timelimit.android.livedata.* import io.timelimit.android.logic.* import io.timelimit.android.logic.blockingreason.AppBaseHandling import io.timelimit.android.logic.blockingreason.CategoryHandlingCache +import io.timelimit.android.logic.blockingreason.needsNetworkId import io.timelimit.android.sync.actions.AddCategoryAppsAction import io.timelimit.android.sync.actions.IncrementCategoryExtraTimeAction import io.timelimit.android.sync.actions.UpdateCategoryTemporarilyBlockedAction @@ -50,6 +57,7 @@ import io.timelimit.android.ui.help.HelpDialogFragment import io.timelimit.android.ui.main.ActivityViewModel import io.timelimit.android.ui.main.AuthenticationFab import io.timelimit.android.ui.main.getActivityViewModel +import io.timelimit.android.ui.manage.category.settings.networks.RequestWifiPermission import io.timelimit.android.ui.manage.child.ManageChildFragmentArgs import io.timelimit.android.ui.manage.child.advanced.managedisabletimelimits.ManageDisableTimelimitsViewHelper import io.timelimit.android.ui.manage.child.category.create.CreateCategoryDialogFragment @@ -63,6 +71,7 @@ class LockFragment : Fragment() { private const val EXTRA_PACKAGE_NAME = "pkg" private const val EXTRA_ACTIVITY = "activitiy" private const val STATUS_DID_OPEN_SET_CURRENT_DEVICE_SCREEN = "didOpenSetCurrentDeviceScreen" + private const val LOCATION_REQUEST_CODE = 1 fun newInstance(packageName: String, activity: String?): LockFragment { val result = LockFragment() @@ -96,6 +105,12 @@ class LockFragment : Fragment() { private val batteryStatus: LiveData by lazy { logic.platformIntegration.getBatteryStatusLive() } + private val needsNetworkIdLive = MutableLiveData().apply { value = false } + private val realNetworkIdLive: LiveData by lazy { liveDataFromFunction { logic.platformIntegration.getCurrentNetworkId() } } + private val networkIdLive: LiveData by lazy { needsNetworkIdLive.switchMap { needsNetworkId -> + if (needsNetworkId) realNetworkIdLive as LiveData else liveDataFromValue(null as NetworkId?) + } } + private val hasPremiumOrLocalMode: LiveData by lazy { logic.fullVersion.shouldProvideFullVersionFunctions } private lateinit var binding: LockFragmentBinding private val handlingCache = CategoryHandlingCache() private val realTime = RealTime.newInstance() @@ -115,6 +130,8 @@ class LockFragment : Fragment() { private fun update() { val deviceAndUserRelatedData = deviceAndUserRelatedData.value ?: return val batteryStatus = batteryStatus.value ?: return + val hasPremiumOrLocalMode = hasPremiumOrLocalMode.value ?: return + val networkId = networkIdLive.value logic.realTimeLogic.getRealTime(realTime) @@ -125,14 +142,6 @@ class LockFragment : Fragment() { return } - handlingCache.reportStatus( - user = deviceAndUserRelatedData.userRelatedData, - assumeCurrentDevice = CurrentDeviceLogic.handleDeviceAsCurrentDevice(deviceAndUserRelatedData.deviceRelatedData, deviceAndUserRelatedData.userRelatedData), - batteryStatus = batteryStatus, - timeInMillis = realTime.timeInMillis, - shouldTrustTimeTemporarily = realTime.shouldTrustTimeTemporarily - ) - val appBaseHandling = AppBaseHandling.calculate( foregroundAppPackageName = packageName, foregroundAppActivityName = activityName, @@ -142,6 +151,24 @@ class LockFragment : Fragment() { pauseCounting = false ) + val needsNetworkId = appBaseHandling.needsNetworkId() + + if (needsNetworkId != needsNetworkIdLive.value) { + needsNetworkIdLive.value = needsNetworkId + } + + if (needsNetworkId && networkId == null) return + + handlingCache.reportStatus( + user = deviceAndUserRelatedData.userRelatedData, + assumeCurrentDevice = CurrentDeviceLogic.handleDeviceAsCurrentDevice(deviceAndUserRelatedData.deviceRelatedData, deviceAndUserRelatedData.userRelatedData), + batteryStatus = batteryStatus, + timeInMillis = realTime.timeInMillis, + shouldTrustTimeTemporarily = realTime.shouldTrustTimeTemporarily, + currentNetworkId = networkId?.getNetworkIdOrNull(), + hasPremiumOrLocalMode = hasPremiumOrLocalMode + ) + binding.activityName = if (deviceAndUserRelatedData.deviceRelatedData.deviceEntry.enableActivityLevelBlocking) activityName?.removePrefix(packageName) else @@ -278,6 +305,10 @@ class LockFragment : Fragment() { } override fun setThisDeviceAsCurrentDevice() = this@LockFragment.setThisDeviceAsCurrentDevice() + + override fun requestLocationPermission() { + RequestWifiPermission.doRequest(this@LockFragment, LOCATION_REQUEST_CODE) + } } } @@ -373,6 +404,12 @@ class LockFragment : Fragment() { } } + private fun initGrantPermissionView() { + networkIdLive.observe(viewLifecycleOwner, Observer { + binding.missingNetworkIdPermission = it is NetworkId.MissingPermission + }) + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -411,6 +448,8 @@ class LockFragment : Fragment() { deviceAndUserRelatedData.observe(viewLifecycleOwner, Observer { update() }) batteryStatus.observe(viewLifecycleOwner, Observer { update() }) + networkIdLive.observe(viewLifecycleOwner, Observer { update() }) + hasPremiumOrLocalMode.observe(viewLifecycleOwner, Observer { update() }) binding.packageName = packageName @@ -418,6 +457,7 @@ class LockFragment : Fragment() { binding.appIcon.setImageDrawable(logic.platformIntegration.getAppIcon(packageName)) initExtraTimeView() + initGrantPermissionView() return binding.root } @@ -441,6 +481,12 @@ class LockFragment : Fragment() { unscheduleUpdate() } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + if (grantResults.find { it != PackageManager.PERMISSION_GRANTED } != null) { + Toast.makeText(context!!, R.string.generic_runtime_permission_rejected, Toast.LENGTH_LONG).show() + } + } } interface Handlers { @@ -452,4 +498,5 @@ interface Handlers { fun disableTemporarilyLockForAllCategories() fun showAuthenticationScreen() fun setThisDeviceAsCurrentDevice() + fun requestLocationPermission() } 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 index b6662eb..15e80e6 100644 --- a/app/src/main/java/io/timelimit/android/ui/login/AllowUserLoginStatus.kt +++ b/app/src/main/java/io/timelimit/android/ui/login/AllowUserLoginStatus.kt @@ -73,7 +73,9 @@ object AllowUserLoginStatusUtil { assumeCurrentDevice = true, timeInMillis = time.timeInMillis, batteryStatus = batteryStatus, - shouldTrustTimeTemporarily = time.shouldTrustTimeTemporarily + shouldTrustTimeTemporarily = time.shouldTrustTimeTemporarily, + currentNetworkId = null, // only checks shouldBlockAtSystemLevel which ignores the network id + hasPremiumOrLocalMode = data.deviceRelatedData.isLocalMode || data.deviceRelatedData.isConnectedAndHasPremium ) val categoryIds = data.limitLoginCategoryUserRelatedData.getCategoryWithParentCategories(data.loginRelatedData.limitLoginCategory.categoryId) 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 f2afb4c..62ad1d9 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 @@ -329,6 +329,7 @@ class NewLoginFragment: DialogFragment() { 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.MissingRequiredNetwork -> getString(R.string.lock_reason_short_missing_required_network) BlockingReason.NotPartOfAnCategory -> "???" BlockingReason.None -> "???" } diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/settings/CategorySettingsFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/settings/CategorySettingsFragment.kt index a53f21b..308e448 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/category/settings/CategorySettingsFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/settings/CategorySettingsFragment.kt @@ -15,10 +15,12 @@ */ package io.timelimit.android.ui.manage.category.settings +import android.content.pm.PackageManager import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast import androidx.fragment.app.Fragment import androidx.lifecycle.Observer import com.google.android.material.snackbar.Snackbar @@ -36,11 +38,14 @@ import io.timelimit.android.ui.main.ActivityViewModel import io.timelimit.android.ui.main.getActivityViewModel import io.timelimit.android.ui.manage.category.ManageCategoryFragmentArgs import io.timelimit.android.ui.manage.category.settings.addusedtime.AddUsedTimeDialogFragment +import io.timelimit.android.ui.manage.category.settings.networks.ManageCategoryNetworksView import io.timelimit.android.ui.payment.RequiresPurchaseDialogFragment import io.timelimit.android.ui.view.SelectTimeSpanViewListener class CategorySettingsFragment : Fragment() { companion object { + private const val PERMISSION_REQUEST_CODE = 1 + fun newInstance(params: ManageCategoryFragmentArgs): CategorySettingsFragment { val result = CategorySettingsFragment() result.arguments = params.toBundle() @@ -117,6 +122,16 @@ class CategorySettingsFragment : Fragment() { fragmentManager = parentFragmentManager ) + ManageCategoryNetworksView.bind( + view = binding.networks, + auth = auth, + lifecycleOwner = viewLifecycleOwner, + fragmentManager = parentFragmentManager, + fragment = this, + permissionRequestCode = PERMISSION_REQUEST_CODE, + categoryId = params.categoryId + ) + binding.btnDeleteCategory.setOnClickListener { deleteCategory() } binding.editCategoryTitleGo.setOnClickListener { renameCategory() } binding.addUsedTimeBtn.setOnClickListener { addUsedTime() } @@ -231,4 +246,12 @@ class CategorySettingsFragment : Fragment() { ).show(parentFragmentManager) } } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + if (requestCode == PERMISSION_REQUEST_CODE) { + if (grantResults.find { it != PackageManager.PERMISSION_GRANTED } != null) { + Toast.makeText(context!!, R.string.generic_runtime_permission_rejected, Toast.LENGTH_LONG).show() + } + } + } } diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/settings/networks/ManageCategoryNetworksView.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/settings/networks/ManageCategoryNetworksView.kt new file mode 100644 index 0000000..eab8f71 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/settings/networks/ManageCategoryNetworksView.kt @@ -0,0 +1,155 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.category.settings.networks + +import android.Manifest +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.Observer +import com.google.android.material.snackbar.Snackbar +import io.timelimit.android.R +import io.timelimit.android.data.IdGenerator +import io.timelimit.android.data.model.CategoryNetworkId +import io.timelimit.android.databinding.ManageCategoryNetworksViewBinding +import io.timelimit.android.integration.platform.NetworkId +import io.timelimit.android.livedata.liveDataFromFunction +import io.timelimit.android.livedata.map +import io.timelimit.android.livedata.switchMap +import io.timelimit.android.sync.actions.AddCategoryNetworkId +import io.timelimit.android.sync.actions.ResetCategoryNetworkIds +import io.timelimit.android.ui.help.HelpDialogFragment +import io.timelimit.android.ui.main.ActivityViewModel +import io.timelimit.android.ui.payment.RequiresPurchaseDialogFragment + +object ManageCategoryNetworksView { + fun bind( + view: ManageCategoryNetworksViewBinding, + auth: ActivityViewModel, + lifecycleOwner: LifecycleOwner, + fragmentManager: FragmentManager, + categoryId: String, + fragment: Fragment, + permissionRequestCode: Int + ) { + fun networkId(): NetworkId = auth.logic.platformIntegration.getCurrentNetworkId() + + val context = view.root.context + val networkIdLive = liveDataFromFunction { networkId() } + val networksLive = auth.database.categoryNetworkId().getByCategoryIdLive(categoryId) + val isFullVersionLive = auth.logic.fullVersion.shouldProvideFullVersionFunctions + + view.titleView.setOnClickListener { + HelpDialogFragment.newInstance( + title = R.string.category_networks_title, + text = R.string.category_networks_help + ).show(fragmentManager) + } + + networksLive.switchMap { networks -> + networkIdLive.map { networkId -> + networks to networkId + } + }.observe(lifecycleOwner, Observer { (networks, networkId) -> + view.showRemoveNetworksButton = networks.isNotEmpty() + + view.addedNetworksText = if (networks.isEmpty()) + context.getString(R.string.category_networks_empty) + else + context.getString( + R.string.category_networks_not_empty, + context.resources.getQuantityString(R.plurals.category_networks_counter, networks.size, networks.size) + ) + + view.status = when (networkId) { + NetworkId.MissingPermission -> NetworkStatus.MissingPermission + NetworkId.NoNetworkConnected -> NetworkStatus.NoneConnected + is NetworkId.Network -> { + val hasItem = networks.find {item -> + CategoryNetworkId.anonymizeNetworkId(networkId = networkId.id, itemId = item.networkItemId) == item.hashedNetworkId + } != null + + if (hasItem) + NetworkStatus.ConnectedAndAdded + else if (networks.size + 1 > CategoryNetworkId.MAX_ITEMS) + NetworkStatus.ConnectedNotAddedButFull + else + NetworkStatus.ConnectedButNotAdded + } + } + }) + + view.removeBtn.setOnClickListener { + val oldList = networksLive.value ?: return@setOnClickListener + + if ( + auth.tryDispatchParentAction( + ResetCategoryNetworkIds(categoryId = categoryId) + ) + ) { + Snackbar.make(view.root, R.string.category_networks_toast_all_removed, Snackbar.LENGTH_LONG) + .setAction(R.string.generic_undo) { + val isEmpty = networksLive.value?.isEmpty() ?: false + + if (isEmpty) { + auth.tryDispatchParentActions( + oldList.map { item -> + AddCategoryNetworkId( + categoryId = item.categoryId, + itemId = item.networkItemId, + hashedNetworkId = item.hashedNetworkId + ) + } + ) + } + }.show() + } + } + + view.grantPermissionButton.setOnClickListener { + RequestWifiPermission.doRequest(fragment, permissionRequestCode) + } + + isFullVersionLive.observe(lifecycleOwner, Observer { isFullVersion -> + view.addNetworkButton.setOnClickListener { + if (isFullVersion) { + val itemId = IdGenerator.generateId() + val networkId = networkId() + + if (!(networkId is NetworkId.Network)) return@setOnClickListener + + auth.tryDispatchParentAction( + AddCategoryNetworkId( + categoryId = categoryId, + itemId = itemId, + hashedNetworkId = CategoryNetworkId.anonymizeNetworkId(itemId = itemId, networkId = networkId.id) + ) + ) + } else { + RequiresPurchaseDialogFragment().show(fragmentManager) + } + } + }) + } + + enum class NetworkStatus { + MissingPermission, + NoneConnected, + ConnectedButNotAdded, + ConnectedNotAddedButFull, + ConnectedAndAdded + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/settings/networks/RequestWifiPermission.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/settings/networks/RequestWifiPermission.kt new file mode 100644 index 0000000..44be1f7 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/settings/networks/RequestWifiPermission.kt @@ -0,0 +1,53 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.category.settings.networks + +import android.Manifest +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.location.LocationManager +import android.os.Build +import android.provider.Settings +import android.widget.Toast +import androidx.core.content.ContextCompat +import androidx.fragment.app.Fragment +import io.timelimit.android.R + +object RequestWifiPermission { + private fun isLocationEnabled(context: Context): Boolean = if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) + Settings.Secure.getInt(context.contentResolver, Settings.Secure.LOCATION_MODE) == Settings.Secure.LOCATION_MODE_OFF + else { + val locationManager = context.getSystemService(Context.LOCATION_SERVICE) as LocationManager + + locationManager.isLocationEnabled + } + + fun doRequest(fragment: Fragment, permissionRequestCode: Int) { + if (ContextCompat.checkSelfPermission(fragment.requireContext(), Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + fragment.requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), permissionRequestCode) + } else if (!isLocationEnabled(fragment.requireContext())) { + Toast.makeText(fragment.requireContext(), R.string.category_networks_toast_enable_location_service, Toast.LENGTH_SHORT).show() + + fragment.startActivity( + Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } else { + Toast.makeText(fragment.requireContext(), R.string.error_general, Toast.LENGTH_SHORT).show() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetItems.kt b/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetItems.kt index 8c80097..dc78ac8 100644 --- a/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetItems.kt +++ b/app/src/main/java/io/timelimit/android/ui/widget/TimesWidgetItems.kt @@ -82,7 +82,9 @@ object TimesWidgetItems { timeInMillis = realTime.timeInMillis, batteryStatus = logic.platformIntegration.getBatteryStatus(), shouldTrustTimeTemporarily = realTime.shouldTrustTimeTemporarily, - assumeCurrentDevice = true + assumeCurrentDevice = true, + currentNetworkId = null, // not relevant here + hasPremiumOrLocalMode = false // not relevant here ) var maxTime = Long.MAX_VALUE diff --git a/app/src/main/res/drawable/ic_baseline_wifi_lock_24.xml b/app/src/main/res/drawable/ic_baseline_wifi_lock_24.xml new file mode 100644 index 0000000..fce0db2 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_wifi_lock_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/layout/fragment_category_settings.xml b/app/src/main/res/layout/fragment_category_settings.xml index 52a9d7a..15ba4fb 100644 --- a/app/src/main/res/layout/fragment_category_settings.xml +++ b/app/src/main/res/layout/fragment_category_settings.xml @@ -95,6 +95,9 @@ + + 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 the Free Software Foundation version 3 of the License. @@ -25,45 +25,82 @@ + + - - + + - - + android:layout_height="wrap_content"> - + + + + + + + + + + + android:layout_height="wrap_content" + android:orientation="vertical" + android:padding="8dp"> - + - - + + + + + + diff --git a/app/src/main/res/layout/lock_fragment.xml b/app/src/main/res/layout/lock_fragment.xml index 77caced..d5879ef 100644 --- a/app/src/main/res/layout/lock_fragment.xml +++ b/app/src/main/res/layout/lock_fragment.xml @@ -51,6 +51,10 @@ name="blockedKindLabel" type="String" /> + + @@ -281,11 +285,11 @@ tools:ignore="UnusedAttribute" android:drawablePadding="16dp" android:drawableTint="?colorOnSurface" - android:drawableStart="@drawable/ic_pause_circle_outline_black_24dp" - android:visibility="@{reason == BlockingReason.SessionDurationLimit ? View.VISIBLE : View.GONE}" + android:drawableStart="@drawable/ic_baseline_wifi_lock_24" + android:visibility="@{reason == BlockingReason.MissingRequiredNetwork ? View.VISIBLE : View.GONE}" android:textAppearance="?android:textAppearanceMedium" - android:text="@{@string/lock_reason_session_duration(blockedKindLabel)}" - tools:text="@string/lock_reason_session_duration" + android:text="@{@string/lock_reason_missing_required_network(blockedKindLabel)}" + tools:text="@string/lock_reason_missing_required_network" android:layout_width="match_parent" android:layout_height="wrap_content" /> @@ -566,6 +570,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +