diff --git a/app/build.gradle b/app/build.gradle index 5f9e77a..53accd0 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -19,6 +19,7 @@ apply plugin: 'kotlin-android' apply plugin: 'kotlin-parcelize' apply plugin: "androidx.navigation.safeargs.kotlin" apply plugin: 'kotlin-kapt' +apply plugin: 'com.squareup.wire' android { compileSdkVersion 33 @@ -151,6 +152,10 @@ android { } } +wire { + kotlin {} +} + dependencies { def nav_version = "2.5.0" def room_version = "2.4.2" @@ -211,4 +216,6 @@ dependencies { implementation 'org.whispersystems:curve25519-java:0.5.0' implementation 'com.google.zxing:core:3.3.3' + + api "com.squareup.wire:wire-runtime:4.4.0" } diff --git a/app/schemas/io.timelimit.android.data.RoomDatabase/43.json b/app/schemas/io.timelimit.android.data.RoomDatabase/43.json new file mode 100644 index 0000000..e0fd272 --- /dev/null +++ b/app/schemas/io.timelimit.android.data.RoomDatabase/43.json @@ -0,0 +1,1603 @@ +{ + "formatVersion": 1, + "database": { + "version": 43, + "identityHash": "c23560ce207ae407fed28309b0913e23", + "entities": [ + { + "tableName": "user", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `password` TEXT NOT NULL, `second_password_salt` TEXT NOT NULL, `type` TEXT NOT NULL, `timezone` TEXT NOT NULL, `disable_limits_until` INTEGER NOT NULL, `mail` TEXT NOT NULL, `current_device` TEXT NOT NULL, `category_for_not_assigned_apps` TEXT NOT NULL, `relax_primary_device` INTEGER NOT NULL, `mail_notification_flags` INTEGER NOT NULL, `blocked_times` TEXT NOT NULL, `flags` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondPasswordSalt", + "columnName": "second_password_salt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timeZone", + "columnName": "timezone", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disableLimitsUntil", + "columnName": "disable_limits_until", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mail", + "columnName": "mail", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentDevice", + "columnName": "current_device", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryForNotAssignedApps", + "columnName": "category_for_not_assigned_apps", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relaxPrimaryDevice", + "columnName": "relax_primary_device", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mailNotificationFlags", + "columnName": "mail_notification_flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "obsoleteBlockedTimes", + "columnName": "blocked_times", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "device", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `model` TEXT NOT NULL, `added_at` INTEGER NOT NULL, `current_user_id` TEXT NOT NULL, `apps_version` TEXT NOT NULL, `network_time` TEXT NOT NULL, `current_protection_level` TEXT NOT NULL, `highest_permission_level` TEXT NOT NULL, `current_usage_stats_permission` TEXT NOT NULL, `highest_usage_stats_permission` TEXT NOT NULL, `current_notification_access_permission` TEXT NOT NULL, `highest_notification_access_permission` TEXT NOT NULL, `current_app_version` INTEGER NOT NULL, `highest_app_version` INTEGER NOT NULL, `tried_disabling_device_admin` INTEGER NOT NULL, `did_reboot` INTEGER NOT NULL, `had_manipulation` INTEGER NOT NULL, `had_manipulation_flags` INTEGER NOT NULL, `did_report_uninstall` INTEGER NOT NULL, `is_user_kept_signed_in` INTEGER NOT NULL, `show_device_connected` INTEGER NOT NULL, `default_user` TEXT NOT NULL, `default_user_timeout` INTEGER NOT NULL, `consider_reboot_manipulation` INTEGER NOT NULL, `current_overlay_permission` TEXT NOT NULL, `highest_overlay_permission` TEXT NOT NULL, `current_accessibility_service_permission` INTEGER NOT NULL, `was_accessibility_service_permission` INTEGER NOT NULL, `enable_activity_level_blocking` INTEGER NOT NULL, `q_or_later` INTEGER NOT NULL, `manipulation_flags` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "added_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentUserId", + "columnName": "current_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installedAppsVersion", + "columnName": "apps_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "networkTime", + "columnName": "network_time", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentProtectionLevel", + "columnName": "current_protection_level", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "highestProtectionLevel", + "columnName": "highest_permission_level", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentUsageStatsPermission", + "columnName": "current_usage_stats_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "highestUsageStatsPermission", + "columnName": "highest_usage_stats_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentNotificationAccessPermission", + "columnName": "current_notification_access_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "highestNotificationAccessPermission", + "columnName": "highest_notification_access_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentAppVersion", + "columnName": "current_app_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "highestAppVersion", + "columnName": "highest_app_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "manipulationTriedDisablingDeviceAdmin", + "columnName": "tried_disabling_device_admin", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "manipulationDidReboot", + "columnName": "did_reboot", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hadManipulation", + "columnName": "had_manipulation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hadManipulationFlags", + "columnName": "had_manipulation_flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "didReportUninstall", + "columnName": "did_report_uninstall", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUserKeptSignedIn", + "columnName": "is_user_kept_signed_in", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showDeviceConnected", + "columnName": "show_device_connected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultUser", + "columnName": "default_user", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultUserTimeout", + "columnName": "default_user_timeout", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "considerRebootManipulation", + "columnName": "consider_reboot_manipulation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentOverlayPermission", + "columnName": "current_overlay_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "highestOverlayPermission", + "columnName": "highest_overlay_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessibilityServiceEnabled", + "columnName": "current_accessibility_service_permission", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wasAccessibilityServiceEnabled", + "columnName": "was_accessibility_service_permission", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enableActivityLevelBlocking", + "columnName": "enable_activity_level_blocking", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "qOrLater", + "columnName": "q_or_later", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "manipulationFlags", + "columnName": "manipulation_flags", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "app", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`device_id` TEXT NOT NULL, `package_name` TEXT NOT NULL, `title` TEXT NOT NULL, `launchable` INTEGER NOT NULL, `recommendation` TEXT NOT NULL, PRIMARY KEY(`device_id`, `package_name`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "device_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isLaunchable", + "columnName": "launchable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recommendation", + "columnName": "recommendation", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "device_id", + "package_name" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_app_device_id", + "unique": false, + "columnNames": [ + "device_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_app_device_id` ON `${TABLE_NAME}` (`device_id`)" + }, + { + "name": "index_app_package_name", + "unique": false, + "columnNames": [ + "package_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_app_package_name` ON `${TABLE_NAME}` (`package_name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "category_app", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`category_id` TEXT NOT NULL, `package_name` TEXT NOT NULL, PRIMARY KEY(`category_id`, `package_name`))", + "fields": [ + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appSpecifierString", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "category_id", + "package_name" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_category_app_category_id", + "unique": false, + "columnNames": [ + "category_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_category_app_category_id` ON `${TABLE_NAME}` (`category_id`)" + }, + { + "name": "index_category_app_package_name", + "unique": false, + "columnNames": [ + "package_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_category_app_package_name` ON `${TABLE_NAME}` (`package_name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "category", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `child_id` TEXT NOT NULL, `title` TEXT NOT NULL, `blocked_times` TEXT NOT NULL, `extra_time` INTEGER NOT NULL, `extra_time_day` INTEGER NOT NULL, `temporarily_blocked` INTEGER NOT NULL, `temporarily_blocked_end_time` INTEGER NOT NULL, `base_version` TEXT NOT NULL, `apps_version` TEXT NOT NULL, `rules_version` TEXT NOT NULL, `usedtimes_version` TEXT NOT NULL, `tasks_version` TEXT NOT NULL DEFAULT '', `parent_category_id` TEXT NOT NULL, `block_all_notifications` INTEGER NOT NULL, `time_warnings` INTEGER NOT NULL, `min_battery_charging` INTEGER NOT NULL, `min_battery_mobile` INTEGER NOT NULL, `sort` INTEGER NOT NULL, `disable_limits_until` INTEGER NOT NULL, `flags` INTEGER NOT NULL DEFAULT 0, `block_notification_delay` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "childId", + "columnName": "child_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedMinutesInWeek", + "columnName": "blocked_times", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extraTimeInMillis", + "columnName": "extra_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "extraTimeDay", + "columnName": "extra_time_day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "temporarilyBlocked", + "columnName": "temporarily_blocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "temporarilyBlockedEndTime", + "columnName": "temporarily_blocked_end_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "baseVersion", + "columnName": "base_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "assignedAppsVersion", + "columnName": "apps_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timeLimitRulesVersion", + "columnName": "rules_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usedTimesVersion", + "columnName": "usedtimes_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tasksVersion", + "columnName": "tasks_version", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "parentCategoryId", + "columnName": "parent_category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockAllNotifications", + "columnName": "block_all_notifications", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeWarnings", + "columnName": "time_warnings", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minBatteryLevelWhileCharging", + "columnName": "min_battery_charging", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minBatteryLevelMobile", + "columnName": "min_battery_mobile", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disableLimitsUntil", + "columnName": "disable_limits_until", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "flags", + "columnName": "flags", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "blockNotificationDelay", + "columnName": "block_notification_delay", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "used_time", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day_of_epoch` INTEGER NOT NULL, `used_time` INTEGER NOT NULL, `category_id` TEXT NOT NULL, `start_time_of_day` INTEGER NOT NULL, `end_time_of_day` INTEGER NOT NULL, PRIMARY KEY(`category_id`, `day_of_epoch`, `start_time_of_day`, `end_time_of_day`))", + "fields": [ + { + "fieldPath": "dayOfEpoch", + "columnName": "day_of_epoch", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "usedMillis", + "columnName": "used_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "startTimeOfDay", + "columnName": "start_time_of_day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endTimeOfDay", + "columnName": "end_time_of_day", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "category_id", + "day_of_epoch", + "start_time_of_day", + "end_time_of_day" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "time_limit_rule", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `category_id` TEXT NOT NULL, `apply_to_extra_time_usage` INTEGER NOT NULL, `day_mask` INTEGER NOT NULL, `max_time` INTEGER NOT NULL, `start_minute_of_day` INTEGER NOT NULL, `end_minute_of_day` INTEGER NOT NULL, `session_duration_milliseconds` INTEGER NOT NULL, `session_pause_milliseconds` INTEGER NOT NULL, `per_day` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "applyToExtraTimeUsage", + "columnName": "apply_to_extra_time_usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "dayMask", + "columnName": "day_mask", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maximumTimeInMillis", + "columnName": "max_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startMinuteOfDay", + "columnName": "start_minute_of_day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endMinuteOfDay", + "columnName": "end_minute_of_day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionDurationMilliseconds", + "columnName": "session_duration_milliseconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionPauseMilliseconds", + "columnName": "session_pause_milliseconds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "perDay", + "columnName": "per_day", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "config", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "temporarily_allowed_app", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`device_id` TEXT NOT NULL, `package_name` TEXT NOT NULL, PRIMARY KEY(`device_id`, `package_name`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "device_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "device_id", + "package_name" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "pending_sync_action", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sequence_number` INTEGER NOT NULL, `action` TEXT NOT NULL, `integrity` TEXT NOT NULL, `scheduled_for_upload` INTEGER NOT NULL, `type` TEXT NOT NULL, `user_id` TEXT NOT NULL, PRIMARY KEY(`sequence_number`))", + "fields": [ + { + "fieldPath": "sequenceNumber", + "columnName": "sequence_number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encodedAction", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "integrity", + "columnName": "integrity", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scheduledForUpload", + "columnName": "scheduled_for_upload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sequence_number" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_pending_sync_action_scheduled_for_upload", + "unique": false, + "columnNames": [ + "scheduled_for_upload" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_sync_action_scheduled_for_upload` ON `${TABLE_NAME}` (`scheduled_for_upload`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "app_activity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`device_id` TEXT NOT NULL, `app_package_name` TEXT NOT NULL, `activity_class_name` TEXT NOT NULL, `activity_title` TEXT NOT NULL, PRIMARY KEY(`device_id`, `app_package_name`, `activity_class_name`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "device_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appPackageName", + "columnName": "app_package_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activityClassName", + "columnName": "activity_class_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "activity_title", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "device_id", + "app_package_name", + "activity_class_name" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "notification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` INTEGER NOT NULL, `id` TEXT NOT NULL, `first_notify_time` INTEGER NOT NULL, `dismissed` INTEGER NOT NULL, PRIMARY KEY(`type`, `id`))", + "fields": [ + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstNotifyTime", + "columnName": "first_notify_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDismissed", + "columnName": "dismissed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "type", + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "allowed_contact", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `phone` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phone", + "columnName": "phone", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "user_key", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `key` BLOB NOT NULL, `last_use` INTEGER NOT NULL, PRIMARY KEY(`user_id`), FOREIGN KEY(`user_id`) REFERENCES `user`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "key", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastUse", + "columnName": "last_use", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_user_key_key", + "unique": true, + "columnNames": [ + "key" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_user_key_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [ + { + "table": "user", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "session_duration", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`category_id` TEXT NOT NULL, `max_session_duration` INTEGER NOT NULL, `session_pause_duration` INTEGER NOT NULL, `start_minute_of_day` INTEGER NOT NULL, `end_minute_of_day` INTEGER NOT NULL, `last_usage` INTEGER NOT NULL, `last_session_duration` INTEGER NOT NULL, PRIMARY KEY(`category_id`, `max_session_duration`, `session_pause_duration`, `start_minute_of_day`, `end_minute_of_day`), FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxSessionDuration", + "columnName": "max_session_duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionPauseDuration", + "columnName": "session_pause_duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startMinuteOfDay", + "columnName": "start_minute_of_day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "endMinuteOfDay", + "columnName": "end_minute_of_day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUsage", + "columnName": "last_usage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSessionDuration", + "columnName": "last_session_duration", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "category_id", + "max_session_duration", + "session_pause_duration", + "start_minute_of_day", + "end_minute_of_day" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "session_duration_index_category_id", + "unique": false, + "columnNames": [ + "category_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `session_duration_index_category_id` ON `${TABLE_NAME}` (`category_id`)" + } + ], + "foreignKeys": [ + { + "table": "category", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "category_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "user_limit_login_category", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `category_id` TEXT NOT NULL, `pre_block_duration` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`user_id`), FOREIGN KEY(`user_id`) REFERENCES `user`(`id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "preBlockDuration", + "columnName": "pre_block_duration", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "columnNames": [ + "user_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "user_limit_login_category_index_category_id", + "unique": false, + "columnNames": [ + "category_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `user_limit_login_category_index_category_id` ON `${TABLE_NAME}` (`category_id`)" + } + ], + "foreignKeys": [ + { + "table": "user", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "category", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "category_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "category_network_id", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`category_id` TEXT NOT NULL, `network_item_id` TEXT NOT NULL, `hashed_network_id` TEXT NOT NULL, PRIMARY KEY(`category_id`, `network_item_id`), FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "networkItemId", + "columnName": "network_item_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hashedNetworkId", + "columnName": "hashed_network_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "category_id", + "network_item_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "category", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "category_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "child_task", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`task_id` TEXT NOT NULL, `category_id` TEXT NOT NULL, `task_title` TEXT NOT NULL, `extra_time_duration` INTEGER NOT NULL, `pending_request` INTEGER NOT NULL, `last_grant_timestamp` INTEGER NOT NULL, PRIMARY KEY(`task_id`), FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "taskId", + "columnName": "task_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "taskTitle", + "columnName": "task_title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extraTimeDuration", + "columnName": "extra_time_duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pendingRequest", + "columnName": "pending_request", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastGrantTimestamp", + "columnName": "last_grant_timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "task_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "category", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "category_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "category_time_warning", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`category_id` TEXT NOT NULL, `minutes` INTEGER NOT NULL, PRIMARY KEY(`category_id`, `minutes`), FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "minutes", + "columnName": "minutes", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "category_id", + "minutes" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "category", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "category_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "crypt_container_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`crypt_container_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `device_id` TEXT, `category_id` TEXT, `type` INTEGER NOT NULL, `server_version` TEXT NOT NULL, `current_generation` INTEGER NOT NULL, `current_generation_first_timestamp` INTEGER NOT NULL, `next_counter` INTEGER NOT NULL, `current_generation_key` BLOB, `status` INTEGER NOT NULL, FOREIGN KEY(`device_id`) REFERENCES `device`(`id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "cryptContainerId", + "columnName": "crypt_container_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "device_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "serverVersion", + "columnName": "server_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentGeneration", + "columnName": "current_generation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentGenerationFirstTimestamp", + "columnName": "current_generation_first_timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nextCounter", + "columnName": "next_counter", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentGenerationKey", + "columnName": "current_generation_key", + "affinity": "BLOB", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "crypt_container_id" + ], + "autoGenerate": true + }, + "indices": [ + { + "name": "index_crypt_container_metadata_device_id", + "unique": false, + "columnNames": [ + "device_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_crypt_container_metadata_device_id` ON `${TABLE_NAME}` (`device_id`)" + }, + { + "name": "index_crypt_container_metadata_category_id", + "unique": false, + "columnNames": [ + "category_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_crypt_container_metadata_category_id` ON `${TABLE_NAME}` (`category_id`)" + } + ], + "foreignKeys": [ + { + "table": "device", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "device_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "category", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "category_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "crypt_container_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`crypt_container_id` INTEGER NOT NULL, `encrypted_data` BLOB NOT NULL, PRIMARY KEY(`crypt_container_id`), FOREIGN KEY(`crypt_container_id`) REFERENCES `crypt_container_metadata`(`crypt_container_id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "cryptContainerId", + "columnName": "crypt_container_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptedData", + "columnName": "encrypted_data", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "crypt_container_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "crypt_container_metadata", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "crypt_container_id" + ], + "referencedColumns": [ + "crypt_container_id" + ] + } + ] + }, + { + "tableName": "crypt_container_pending_key_request", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`crypt_container_id` INTEGER NOT NULL, `request_time_crypt_container_generation` INTEGER NOT NULL, `request_sequence_id` INTEGER NOT NULL, `request_key` BLOB NOT NULL, PRIMARY KEY(`crypt_container_id`), FOREIGN KEY(`crypt_container_id`) REFERENCES `crypt_container_metadata`(`crypt_container_id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "cryptContainerId", + "columnName": "crypt_container_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requestTimeCryptContainerGeneration", + "columnName": "request_time_crypt_container_generation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requestSequenceId", + "columnName": "request_sequence_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requestKey", + "columnName": "request_key", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "crypt_container_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_crypt_container_pending_key_request_request_sequence_id", + "unique": true, + "columnNames": [ + "request_sequence_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_crypt_container_pending_key_request_request_sequence_id` ON `${TABLE_NAME}` (`request_sequence_id`)" + } + ], + "foreignKeys": [ + { + "table": "crypt_container_metadata", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "crypt_container_id" + ], + "referencedColumns": [ + "crypt_container_id" + ] + } + ] + }, + { + "tableName": "crypt_container_key_result", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`request_sequence_id` INTEGER NOT NULL, `device_id` TEXT NOT NULL, `status` INTEGER NOT NULL, PRIMARY KEY(`request_sequence_id`, `device_id`), FOREIGN KEY(`request_sequence_id`) REFERENCES `crypt_container_pending_key_request`(`request_sequence_id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`device_id`) REFERENCES `device`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "requestSequenceId", + "columnName": "request_sequence_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "device_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "request_sequence_id", + "device_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_crypt_container_key_result_request_sequence_id", + "unique": false, + "columnNames": [ + "request_sequence_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_crypt_container_key_result_request_sequence_id` ON `${TABLE_NAME}` (`request_sequence_id`)" + }, + { + "name": "index_crypt_container_key_result_device_id", + "unique": false, + "columnNames": [ + "device_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_crypt_container_key_result_device_id` ON `${TABLE_NAME}` (`device_id`)" + } + ], + "foreignKeys": [ + { + "table": "crypt_container_pending_key_request", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "request_sequence_id" + ], + "referencedColumns": [ + "request_sequence_id" + ] + }, + { + "table": "device", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "device_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "device_public_key", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`device_id` TEXT NOT NULL, `public_key` BLOB NOT NULL, `next_sequence_number` INTEGER NOT NULL, PRIMARY KEY(`device_id`), FOREIGN KEY(`device_id`) REFERENCES `device`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "device_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "public_key", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "nextSequenceNumber", + "columnName": "next_sequence_number", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "device_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [ + { + "table": "device", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "device_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "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, 'c23560ce207ae407fed28309b0913e23')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/crypto/CryptContainer.kt b/app/src/main/java/io/timelimit/android/crypto/CryptContainer.kt new file mode 100644 index 0000000..7c35188 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/crypto/CryptContainer.kt @@ -0,0 +1,138 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.crypto + +import java.nio.ByteBuffer +import java.security.SecureRandom +import javax.crypto.AEADBadTagException +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +object CryptContainer { + const val KEY_SIZE = 16 + private const val AUTH_TAG_BITS = 128 + private const val AUTH_TAG_BYTES = AUTH_TAG_BITS / 8 + + data class EncryptParameters(val generation: Long, val counter: Long, val key: ByteArray) { + companion object { + fun generate() = EncryptParameters( + key = generateKey(), + generation = 0, + counter = 0 + ) + } + } + + data class Header ( + val generation: Long, + val counter: Long, + val iv: Int + ) { + companion object { + const val SIZE = 8 + 8 + 4 + + fun read(input: ByteArray): Header { + validate(input) + + val buffer = ByteBuffer.wrap(input) + + val generation = buffer.getLong(0) + val counter = buffer.getLong(8) + val iv = buffer.getInt(16) + + return Header( + generation = generation, + counter = counter, + iv = iv + ) + } + } + + fun write(output: ByteBuffer) { + output.putLong(0, generation) + output.putLong(8, counter) + output.putInt(16, iv) + } + } + + fun validate(input: ByteArray) { + if (input.size < Header.SIZE + AUTH_TAG_BYTES) throw CryptException.InvalidContainer() + } + + fun generateKey() = ByteArray(KEY_SIZE).also { SecureRandom().nextBytes(it) } + + private fun buildIV(counter: Long, iv: Int): ByteArray { + val result = ByteArray(12) + + ByteBuffer.wrap(result).also { + it.putInt(0, iv) + it.putLong(4, counter) + } + + return result + } + + private fun buildSecretKey(key: ByteArray): SecretKeySpec { + if (key.size != KEY_SIZE) throw CryptException.InvalidKey() + + return SecretKeySpec(key, "AES") + } + + private fun buildAAD(generation: Long): ByteArray = ByteArray(8).also { result -> + ByteBuffer.wrap(result).putLong(0, generation) + } + + fun decrypt(key: ByteArray, input: ByteArray): ByteArray { + val header = Header.read(input) + + val cipher = Cipher.getInstance("AES/GCM/NoPadding").also { + it.init(Cipher.DECRYPT_MODE, buildSecretKey(key), GCMParameterSpec(AUTH_TAG_BITS, buildIV(header.counter, header.iv))) + it.updateAAD(buildAAD(header.generation)) + } + + try { + return cipher.doFinal(input, Header.SIZE, input.size - Header.SIZE) + } catch (ex: AEADBadTagException) { + throw CryptException.WrongKey() + } + } + + fun encrypt(input: ByteArray, params: EncryptParameters): ByteArray { + val iv = SecureRandom().nextInt() + + val cipher = Cipher.getInstance("AES/GCM/NoPadding").also { + it.init(Cipher.ENCRYPT_MODE, buildSecretKey(params.key), GCMParameterSpec(AUTH_TAG_BITS, buildIV(params.counter, iv))) + it.updateAAD(buildAAD(params.generation)) + } + + val result = ByteArray(Header.SIZE + input.size + AUTH_TAG_BYTES) + val buffer = ByteBuffer.wrap(result) + + Header( + iv = iv, + counter = params.counter, + generation = params.generation + ).write(buffer) + + if (cipher.doFinal(input, 0, input.size, result, Header.SIZE) != input.size + AUTH_TAG_BYTES) { + throw IllegalStateException() + } + + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/crypto/Curve25519.kt b/app/src/main/java/io/timelimit/android/crypto/Curve25519.kt index 0621f42..dd9e0e3 100644 --- a/app/src/main/java/io/timelimit/android/crypto/Curve25519.kt +++ b/app/src/main/java/io/timelimit/android/crypto/Curve25519.kt @@ -53,6 +53,9 @@ object Curve25519 { fun sign(privateKey: ByteArray, message: ByteArray): ByteArray = instance.calculateSignature(privateKey, message) fun validateSignature(publicKey: ByteArray, message: ByteArray, signature: ByteArray) = instance.verifySignature(publicKey, message, signature) + + // 32 bytes + fun sharedSecret(publicKey: ByteArray, privateKey: ByteArray) = instance.calculateAgreement(publicKey, privateKey) } fun Curve25519KeyPair.serialize(): ByteArray { diff --git a/app/src/main/java/io/timelimit/android/crypto/Exception.kt b/app/src/main/java/io/timelimit/android/crypto/Exception.kt new file mode 100644 index 0000000..0243e1d --- /dev/null +++ b/app/src/main/java/io/timelimit/android/crypto/Exception.kt @@ -0,0 +1,23 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.crypto + +sealed class CryptException: RuntimeException() { + class InvalidContainer: CryptException() + class InvalidKey: CryptException() + class WrongKey: CryptException() +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/Database.kt b/app/src/main/java/io/timelimit/android/data/Database.kt index abccd7d..b0f2c81 100644 --- a/app/src/main/java/io/timelimit/android/data/Database.kt +++ b/app/src/main/java/io/timelimit/android/data/Database.kt @@ -41,6 +41,10 @@ interface Database { fun categoryNetworkId(): CategoryNetworkIdDao fun childTasks(): ChildTaskDao fun timeWarning(): CategoryTimeWarningDao + fun cryptContainer(): CryptContainerDao + fun cryptContainerKeyRequest(): CryptContainerKeyRequestDao + fun cryptContainerKeyResult(): CryptContainerKeyResultDao + fun deviceKey(): DeviceKeyDao 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 19dec21..0e320fe 100644 --- a/app/src/main/java/io/timelimit/android/data/Migrations.kt +++ b/app/src/main/java/io/timelimit/android/data/Migrations.kt @@ -299,6 +299,25 @@ object DatabaseMigrations { } } + private val MIGRATE_TP_V43 = object: Migration(42, 43) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE TABLE IF NOT EXISTS `crypt_container_metadata` (`crypt_container_id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `device_id` TEXT, `category_id` TEXT, `type` INTEGER NOT NULL, `server_version` TEXT NOT NULL, `current_generation` INTEGER NOT NULL, `current_generation_first_timestamp` INTEGER NOT NULL, `next_counter` INTEGER NOT NULL, `current_generation_key` BLOB, `status` INTEGER NOT NULL, FOREIGN KEY(`device_id`) REFERENCES `device`(`id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`category_id`) REFERENCES `category`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_crypt_container_metadata_device_id` ON `crypt_container_metadata` (`device_id`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_crypt_container_metadata_category_id` ON `crypt_container_metadata` (`category_id`)") + + database.execSQL("CREATE TABLE IF NOT EXISTS `crypt_container_data` (`crypt_container_id` INTEGER NOT NULL, `encrypted_data` BLOB NOT NULL, PRIMARY KEY(`crypt_container_id`), FOREIGN KEY(`crypt_container_id`) REFERENCES `crypt_container_metadata`(`crypt_container_id`) ON UPDATE CASCADE ON DELETE CASCADE )") + + database.execSQL("CREATE TABLE IF NOT EXISTS `crypt_container_pending_key_request` (`crypt_container_id` INTEGER NOT NULL, `request_time_crypt_container_generation` INTEGER NOT NULL, `request_sequence_id` INTEGER NOT NULL, `request_key` BLOB NOT NULL, PRIMARY KEY(`crypt_container_id`), FOREIGN KEY(`crypt_container_id`) REFERENCES `crypt_container_metadata`(`crypt_container_id`) ON UPDATE CASCADE ON DELETE CASCADE )") + database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_crypt_container_pending_key_request_request_sequence_id` ON `crypt_container_pending_key_request` (`request_sequence_id`)") + + database.execSQL("CREATE TABLE IF NOT EXISTS `crypt_container_key_result` (`request_sequence_id` INTEGER NOT NULL, `device_id` TEXT NOT NULL, `status` INTEGER NOT NULL, PRIMARY KEY(`request_sequence_id`, `device_id`), FOREIGN KEY(`request_sequence_id`) REFERENCES `crypt_container_pending_key_request`(`request_sequence_id`) ON UPDATE CASCADE ON DELETE CASCADE , FOREIGN KEY(`device_id`) REFERENCES `device`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_crypt_container_key_result_request_sequence_id` ON `crypt_container_key_result` (`request_sequence_id`)") + database.execSQL("CREATE INDEX IF NOT EXISTS `index_crypt_container_key_result_device_id` ON `crypt_container_key_result` (`device_id`)") + + database.execSQL("CREATE TABLE IF NOT EXISTS `device_public_key` (`device_id` TEXT NOT NULL, `public_key` BLOB NOT NULL, `next_sequence_number` INTEGER NOT NULL, PRIMARY KEY(`device_id`), FOREIGN KEY(`device_id`) REFERENCES `device`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )") + } + } + val ALL = arrayOf( MIGRATE_TO_V2, MIGRATE_TO_V3, @@ -340,6 +359,7 @@ object DatabaseMigrations { MIGRATE_TO_V39, MIGRATE_TO_V40, MIGRATE_TO_V41, - MIGRATE_TO_V42 + MIGRATE_TO_V42, + MIGRATE_TP_V43 ) } 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 17f9ae4..3a5988c 100644 --- a/app/src/main/java/io/timelimit/android/data/RoomDatabase.kt +++ b/app/src/main/java/io/timelimit/android/data/RoomDatabase.kt @@ -52,8 +52,13 @@ import java.util.concurrent.TimeUnit UserLimitLoginCategory::class, CategoryNetworkId::class, ChildTask::class, - CategoryTimeWarning::class -], version = 42) + CategoryTimeWarning::class, + CryptContainerMetadata::class, + CryptContainerData::class, + CryptContainerPendingKeyRequest::class, + CryptContainerKeyResult::class, + DevicePublicKey::class +], version = 43) abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database { companion object { private val lock = Object() diff --git a/app/src/main/java/io/timelimit/android/data/dao/CategoryDao.kt b/app/src/main/java/io/timelimit/android/data/dao/CategoryDao.kt index fd9cc1b..10251ca 100644 --- a/app/src/main/java/io/timelimit/android/data/dao/CategoryDao.kt +++ b/app/src/main/java/io/timelimit/android/data/dao/CategoryDao.kt @@ -66,7 +66,7 @@ abstract class CategoryDao { abstract fun updateCategoryTemporarilyBlocked(categoryId: String, blocked: Boolean, endTime: Long) @Query("SELECT id, base_version, apps_version, rules_version, usedtimes_version, tasks_version FROM category") - abstract fun getCategoriesWithVersionNumbers(): LiveData> + abstract fun getCategoriesWithVersionNumbersSybc(): List @Query("UPDATE category SET apps_version = :assignedAppsVersion WHERE id = :categoryId") abstract fun updateCategoryAssignedAppsVersion(categoryId: String, assignedAppsVersion: String) diff --git a/app/src/main/java/io/timelimit/android/data/dao/ConfigDao.kt b/app/src/main/java/io/timelimit/android/data/dao/ConfigDao.kt index 11f467a..cb5f730 100644 --- a/app/src/main/java/io/timelimit/android/data/dao/ConfigDao.kt +++ b/app/src/main/java/io/timelimit/android/data/dao/ConfigDao.kt @@ -25,6 +25,8 @@ import io.timelimit.android.data.model.ConfigurationItem import io.timelimit.android.data.model.ConfigurationItemType import io.timelimit.android.data.model.ConfigurationItemTypeConverter import io.timelimit.android.data.model.ConfigurationItemTypeUtil +import io.timelimit.android.extensions.base64 +import io.timelimit.android.extensions.parseBase64 import io.timelimit.android.extensions.toJsonReader import io.timelimit.android.livedata.ignoreUnchanged import io.timelimit.android.livedata.map @@ -86,28 +88,16 @@ abstract class ConfigDao { updateValueSync(ConfigurationItemType.OwnDeviceId, deviceId) } - fun getDeviceListVersion(): LiveData { - return getValueOfKeyAsync(ConfigurationItemType.DeviceListVersion).map { - if (it == null) { - "" - } else { - it - } - } + fun getDeviceListVersionSync(): String { + return getValueOfKeySync(ConfigurationItemType.DeviceListVersion) ?: "" } fun setDeviceListVersionSync(deviceListVersion: String) { updateValueSync(ConfigurationItemType.DeviceListVersion, deviceListVersion) } - fun getUserListVersion(): LiveData { - return getValueOfKeyAsync(ConfigurationItemType.UserListVersion).map { - if (it == null) { - "" - } else { - it - } - } + fun getUserListVersionSync(): String { + return getValueOfKeySync(ConfigurationItemType.UserListVersion) ?: "" } fun setUserListVersionSync(userListVersion: String) { @@ -354,4 +344,23 @@ abstract class ConfigDao { (getConsentFlagsSync() and (flags.inv())).toString(16) ) } + + fun getSigningKeySync() = getValueOfKeySync(ConfigurationItemType.SigningKey)?.parseBase64() + fun getSigningKeyAsync() = getValueOfKeyAsync(ConfigurationItemType.SigningKey).map { v -> v?.let { Base64.decode(it, 0) } } + fun setSigningKeySync(value: ByteArray) = updateValueSync(ConfigurationItemType.SigningKey, value.base64()) + + private fun getNextSigningSequenceNumberSync(): Long = getValueOfKeySync(ConfigurationItemType.SignSequenceNumber)?.toLong() ?: 0L + private fun setNextSigningSequenceNumberSync(value: Long) = updateValueSync(ConfigurationItemType.SignSequenceNumber, value.toString()) + fun getNextSigningSequenceNumberAndIncrementIt(): Long { + val current = getNextSigningSequenceNumberSync() + setNextSigningSequenceNumberSync(current + 1) + + return current + } + + fun getLastServerKeyRequestSequenceSync(): Long? = getValueOfKeySync(ConfigurationItemType.LastServerKeyRequestSequence)?.toLong() + fun setLastServerKeyRequestSequenceSync(value: Long) = updateValueSync(ConfigurationItemType.LastServerKeyRequestSequence, value.toString()) + + fun getLastServerKeyResponseSequenceSync(): Long? = getValueOfKeySync(ConfigurationItemType.LastKeyResponseSequence)?.toLong() + fun setLastServerKeyResponseSequenceSync(value: Long) = updateValueSync(ConfigurationItemType.LastKeyResponseSequence, value.toString()) } diff --git a/app/src/main/java/io/timelimit/android/data/dao/CryptContainerDao.kt b/app/src/main/java/io/timelimit/android/data/dao/CryptContainerDao.kt new file mode 100644 index 0000000..1cb8250 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/dao/CryptContainerDao.kt @@ -0,0 +1,78 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.data.dao + +import androidx.room.* +import io.timelimit.android.data.model.CryptContainerData +import io.timelimit.android.data.model.CryptContainerMetadata +import io.timelimit.android.data.model.CryptContainerMetadataProcessingStatusConverter + +@Dao +@TypeConverters(CryptContainerMetadataProcessingStatusConverter::class) +interface CryptContainerDao { + @Query("SELECT * FROM crypt_container_metadata WHERE crypt_container_id = :containerId") + fun getCryptoMetadataSyncByContainerId(containerId: Long): CryptContainerMetadata? + + @Query("SELECT * FROM crypt_container_metadata JOIN crypt_container_data USING (crypt_container_id) WHERE category_id IS NULL AND device_id = :deviceId AND type = :type") + fun getCryptoFullDataSyncByDeviceId(deviceId: String, type: Int): MetadataAndContent? + + @Query("SELECT * FROM crypt_container_metadata WHERE category_id IS NULL AND device_id IS NULL AND type = :type") + fun getCryptoMetadataSyncByType(type: Int): CryptContainerMetadata? + + @Query("SELECT * FROM crypt_container_metadata WHERE category_id IS NULL AND device_id = :deviceId AND type = :type") + fun getCryptoMetadataSyncByDeviceId(deviceId: String, type: Int): CryptContainerMetadata? + + @Query("SELECT * FROM crypt_container_metadata WHERE category_id = :categoryId AND device_id IS NULL AND type = :type") + fun getCryptoMetadataSyncByCategoryId(categoryId: String, type: Int): CryptContainerMetadata? + + @Query("DELETE FROM crypt_container_metadata WHERE category_id IS NULL AND device_id = :deviceId AND type in (:types)") + fun removeDeviceCryptoMetadata(deviceId: String, types: List) + + @Query("SELECT * FROM crypt_container_metadata WHERE status = :processingStatus") + fun getMetadataByProcessingStatus(processingStatus: CryptContainerMetadata.ProcessingStatus): List + + @Insert + fun insertMetadata(container: CryptContainerMetadata): Long + + @Update + fun updateMetadata(container: List) + + @Update + fun updateMetadata(container: CryptContainerMetadata) + + @Insert + fun insertData(data: CryptContainerData) + + @Update + fun updateData(data: List) + + @Update + fun updateData(data: CryptContainerData) + + @Query("SELECT * FROM crypt_container_data WHERE crypt_container_id = :containerId") + fun getData(containerId: Long): CryptContainerData? + + @Query("UPDATE crypt_container_metadata SET server_version = ''") + fun deleteAllServerVersionNumbers() + + @Entity + data class MetadataAndContent( + @Embedded + val metadata: CryptContainerMetadata, + @ColumnInfo(name = "encrypted_data") + val encryptedData: ByteArray + ) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/dao/CryptContainerKeyRequestDao.kt b/app/src/main/java/io/timelimit/android/data/dao/CryptContainerKeyRequestDao.kt new file mode 100644 index 0000000..9eb72fb --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/dao/CryptContainerKeyRequestDao.kt @@ -0,0 +1,40 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.data.dao + +import androidx.room.Dao +import androidx.room.Delete +import androidx.room.Insert +import androidx.room.Query +import io.timelimit.android.data.model.CryptContainerPendingKeyRequest + +@Dao +interface CryptContainerKeyRequestDao { + @Query("SELECT * FROM crypt_container_pending_key_request WHERE crypt_container_id = :cryptContainerId") + fun byCryptContainerId(cryptContainerId: Long): CryptContainerPendingKeyRequest? + + @Query("SELECT * FROM crypt_container_pending_key_request WHERE request_sequence_id = :requestId") + fun byRequestId(requestId: Long): CryptContainerPendingKeyRequest? + + @Insert + fun insert(item: CryptContainerPendingKeyRequest) + + @Delete + fun delete(item: CryptContainerPendingKeyRequest) + + @Query("DELETE FROM crypt_container_pending_key_request") + fun deleteAll() +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/dao/CryptContainerKeyResultDao.kt b/app/src/main/java/io/timelimit/android/data/dao/CryptContainerKeyResultDao.kt new file mode 100644 index 0000000..7d3eaaf --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/dao/CryptContainerKeyResultDao.kt @@ -0,0 +1,30 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.data.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import io.timelimit.android.data.model.CryptContainerKeyResult + +@Dao +interface CryptContainerKeyResultDao { + @Query("SELECT COUNT(*) FROM crypt_container_key_result WHERE request_sequence_id = :requestSequenceNumber AND device_id = :deviceId") + fun countResultItems(requestSequenceNumber: Long, deviceId: String): Long + + @Insert + fun insert(item: CryptContainerKeyResult) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/dao/DeviceDao.kt b/app/src/main/java/io/timelimit/android/data/dao/DeviceDao.kt index 1c25973..1dda4a9 100644 --- a/app/src/main/java/io/timelimit/android/data/dao/DeviceDao.kt +++ b/app/src/main/java/io/timelimit/android/data/dao/DeviceDao.kt @@ -55,7 +55,7 @@ abstract class DeviceDao { abstract fun updateDeviceDefaultUser(deviceId: String, defaultUserId: String) @Query("SELECT id, apps_version FROM device") - abstract fun getInstalledAppsVersions(): LiveData> + abstract fun getInstalledAppsVersionsSync(): List @Query("DELETE FROM device WHERE id IN (:deviceIds)") abstract fun removeDevicesById(deviceIds: List) @@ -92,6 +92,14 @@ abstract class DeviceDao { @Query("SELECT COUNT(*) FROM device JOIN user ON (device.current_user_id = user.id) WHERE user.type = \"child\"") abstract fun countDevicesWithChildUser(): LiveData + + fun getDeviceDetailDataSync() = getDeviceDetailDataSyncInternal( + CryptContainerMetadata.TYPE_APP_LIST_BASE, + CryptContainerMetadata.TYPE_APP_LIST_DIFF + ) + + @Query("SELECT d.id AS device_id, c1.server_version AS app_base_version, c2.server_version AS app_diff_version FROM device d LEFT JOIN crypt_container_metadata c1 ON (c1.device_id = d.id AND c1.type = :baseType) LEFT JOIN crypt_container_metadata c2 ON (c2.device_id = d.id AND c2.type = :diffType)") + protected abstract fun getDeviceDetailDataSyncInternal(baseType: Int, diffType: Int): List } data class DeviceWithAppVersion( @@ -100,3 +108,12 @@ data class DeviceWithAppVersion( @ColumnInfo(name = "apps_version") val installedAppsVersions: String ) + +data class DeviceDetailDataBase( + @ColumnInfo(name = "device_id") + val deviceId: String, + @ColumnInfo(name = "app_base_version") + val appBaseVersion: String?, + @ColumnInfo(name = "app_diff_version") + val appDiffVersion: String? +) \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/dao/DeviceKeyDao.kt b/app/src/main/java/io/timelimit/android/data/dao/DeviceKeyDao.kt new file mode 100644 index 0000000..83bf1fb --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/dao/DeviceKeyDao.kt @@ -0,0 +1,38 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.data.dao + +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import androidx.room.Update +import io.timelimit.android.data.model.DevicePublicKey + +@Dao +interface DeviceKeyDao { + @Insert + fun insert(key: DevicePublicKey) + + @Update + fun update(key: DevicePublicKey) + + @Query("SELECT * FROM device_public_key WHERE device_id = :deviceId") + fun getSync(deviceId: String): DevicePublicKey? + + @Query("SELECT * FROM device_public_key WHERE device_id = :deviceId") + fun getLive(deviceId: String): LiveData +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/model/ConfigurationItem.kt b/app/src/main/java/io/timelimit/android/data/model/ConfigurationItem.kt index adfa220..6bf9c47 100644 --- a/app/src/main/java/io/timelimit/android/data/model/ConfigurationItem.kt +++ b/app/src/main/java/io/timelimit/android/data/model/ConfigurationItem.kt @@ -74,8 +74,6 @@ data class ConfigurationItem( } } -// TODO: validate config item values - enum class ConfigurationItemType { OwnDeviceId, UserListVersion, @@ -102,6 +100,10 @@ enum class ConfigurationItemType { ServerApiLevel, AnnoyManualUnblockCounter, ConsentFlags, + SigningKey, + SignSequenceNumber, + LastServerKeyRequestSequence, + LastKeyResponseSequence } object ConfigurationItemTypeUtil { @@ -130,6 +132,10 @@ object ConfigurationItemTypeUtil { private const val SERVER_API_LEVEL = 24 private const val ANNOY_MANUAL_UNBLOCK_COUNTER = 25 private const val CONSENT_FLAGS = 26 + private const val SIGNING_KEY = 27 + private const val SIGN_SEQUENCE_NUMBER = 28 + private const val LAST_SERVER_KEY_REQUEST_SEQUENCE = 29 + private const val LAST_SERVER_KEY_RESPONSE_SEQUENCE = 30 val TYPES = listOf( ConfigurationItemType.OwnDeviceId, @@ -156,7 +162,11 @@ object ConfigurationItemTypeUtil { ConfigurationItemType.CustomOrganizationName, ConfigurationItemType.ServerApiLevel, ConfigurationItemType.AnnoyManualUnblockCounter, - ConfigurationItemType.ConsentFlags + ConfigurationItemType.ConsentFlags, + ConfigurationItemType.SigningKey, + ConfigurationItemType.SignSequenceNumber, + ConfigurationItemType.LastServerKeyRequestSequence, + ConfigurationItemType.LastKeyResponseSequence ) fun serialize(value: ConfigurationItemType) = when(value) { @@ -185,6 +195,10 @@ object ConfigurationItemTypeUtil { ConfigurationItemType.ServerApiLevel -> SERVER_API_LEVEL ConfigurationItemType.AnnoyManualUnblockCounter -> ANNOY_MANUAL_UNBLOCK_COUNTER ConfigurationItemType.ConsentFlags -> CONSENT_FLAGS + ConfigurationItemType.SigningKey -> SIGNING_KEY + ConfigurationItemType.SignSequenceNumber -> SIGN_SEQUENCE_NUMBER + ConfigurationItemType.LastServerKeyRequestSequence -> LAST_SERVER_KEY_REQUEST_SEQUENCE + ConfigurationItemType.LastKeyResponseSequence -> LAST_SERVER_KEY_RESPONSE_SEQUENCE } fun parse(value: Int) = when(value) { @@ -213,6 +227,10 @@ object ConfigurationItemTypeUtil { SERVER_API_LEVEL -> ConfigurationItemType.ServerApiLevel ANNOY_MANUAL_UNBLOCK_COUNTER -> ConfigurationItemType.AnnoyManualUnblockCounter CONSENT_FLAGS -> ConfigurationItemType.ConsentFlags + SIGNING_KEY -> ConfigurationItemType.SigningKey + SIGN_SEQUENCE_NUMBER -> ConfigurationItemType.SignSequenceNumber + LAST_SERVER_KEY_REQUEST_SEQUENCE -> ConfigurationItemType.LastServerKeyRequestSequence + LAST_SERVER_KEY_RESPONSE_SEQUENCE -> ConfigurationItemType.LastKeyResponseSequence else -> throw IllegalArgumentException() } } @@ -256,6 +274,7 @@ object ExperimentalFlags { // const val INSTANCE_ID_FG_APP_DETECTION = 65536L // private const val OBSOLETE_DISABLE_FG_APP_DETECTION_FALLBACK = 131072L const val STRICT_OVERLAY_CHECKING = 0x40000L + const val DISABLE_LEGACY_APP_SENDING = 0x80000L } object ConsentFlags { diff --git a/app/src/main/java/io/timelimit/android/data/model/CryptContainerData.kt b/app/src/main/java/io/timelimit/android/data/model/CryptContainerData.kt new file mode 100644 index 0000000..dcdcdda --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/model/CryptContainerData.kt @@ -0,0 +1,41 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.data.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey + +@Entity( + tableName = "crypt_container_data", + foreignKeys = [ + ForeignKey( + entity = CryptContainerMetadata::class, + childColumns = ["crypt_container_id"], + parentColumns = ["crypt_container_id"], + onDelete = ForeignKey.CASCADE, + onUpdate = ForeignKey.CASCADE + ) + ] +) +data class CryptContainerData ( + @PrimaryKey + @ColumnInfo(name = "crypt_container_id") + val cryptContainerId: Long, + @ColumnInfo(name = "encrypted_data") + val encryptedData: ByteArray +) \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/model/CryptContainerKeyResult.kt b/app/src/main/java/io/timelimit/android/data/model/CryptContainerKeyResult.kt new file mode 100644 index 0000000..bcdf945 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/model/CryptContainerKeyResult.kt @@ -0,0 +1,64 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.data.model + +import androidx.room.* + +@Entity( + tableName = "crypt_container_key_result", + primaryKeys = ["request_sequence_id", "device_id"], + foreignKeys = [ + ForeignKey( + entity = CryptContainerPendingKeyRequest::class, + childColumns = ["request_sequence_id"], + parentColumns = ["request_sequence_id"], + onUpdate = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = Device::class, + childColumns = ["device_id"], + parentColumns = ["id"], + onUpdate = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE + ) + ] +) +@TypeConverters(CryptContainerKeyResultStatusConverter::class) +data class CryptContainerKeyResult ( + @ColumnInfo(name = "request_sequence_id", index = true) + val requestSequenceId: Long, + @ColumnInfo(name = "device_id", index = true) + val deviceId: String, + val status: Status +) { + enum class Status { + InvalidKey + } +} + +class CryptContainerKeyResultStatusConverter { + @TypeConverter + fun toStatus(input: Int): CryptContainerKeyResult.Status = when (input) { + 0 -> CryptContainerKeyResult.Status.InvalidKey + else -> CryptContainerKeyResult.Status.InvalidKey + } + + @TypeConverter + fun toInt(input: CryptContainerKeyResult.Status): Int = when (input) { + CryptContainerKeyResult.Status.InvalidKey -> 0 + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/model/CryptContainerMetadata.kt b/app/src/main/java/io/timelimit/android/data/model/CryptContainerMetadata.kt new file mode 100644 index 0000000..0dee037 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/model/CryptContainerMetadata.kt @@ -0,0 +1,163 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.data.model + +import androidx.room.* +import io.timelimit.android.crypto.CryptContainer + +@Entity( + tableName = "crypt_container_metadata", + foreignKeys = [ + ForeignKey( + entity = Device::class, + childColumns = ["device_id"], + parentColumns = ["id"], + onDelete = ForeignKey.CASCADE, + onUpdate = ForeignKey.CASCADE + ), + ForeignKey( + entity = Category::class, + childColumns = ["category_id"], + parentColumns = ["id"], + onDelete = ForeignKey.CASCADE, + onUpdate = ForeignKey.CASCADE + ) + ] +) +@TypeConverters(CryptContainerMetadataProcessingStatusConverter::class) +data class CryptContainerMetadata ( + @PrimaryKey(autoGenerate = true) + @ColumnInfo(name = "crypt_container_id") + val cryptContainerId: Long, + @ColumnInfo(name = "device_id", index = true) + val deviceId: String?, + @ColumnInfo(name = "category_id", index = true) + val categoryId: String?, + val type: Int, + @ColumnInfo(name = "server_version") + val serverVersion: String, + @ColumnInfo(name = "current_generation") + val currentGeneration: Long, + @ColumnInfo(name = "current_generation_first_timestamp") + val currentGenerationFirstTimestamp: Long, + @ColumnInfo(name = "next_counter") + val nextCounter: Long, + @ColumnInfo(name = "current_generation_key") + val currentGenerationKey: ByteArray?, + val status: ProcessingStatus +) { + companion object { + private const val GENERATION_DURATION_LIMIT = 1000 * 60 * 60 * 24 * 7 // 7 days + private const val GENERATION_COUNTER_LIMIT = 16L + + const val TYPE_APP_LIST_BASE = 1 + const val TYPE_APP_LIST_DIFF = 2 + + fun buildFor(deviceId: String?, categoryId: String?, type: Int, params: CryptContainer.EncryptParameters) = CryptContainerMetadata( + cryptContainerId = 0, + deviceId = deviceId, + categoryId = categoryId, + type = type, + serverVersion = "", + currentGeneration = params.generation, + currentGenerationFirstTimestamp = System.currentTimeMillis(), + nextCounter = params.counter + 1, + currentGenerationKey = params.key, + status = ProcessingStatus.Finished + ) + + fun isTypeValid(type: Int) = type == TYPE_APP_LIST_BASE || type == TYPE_APP_LIST_DIFF + } + + enum class ProcessingStatus { + MissingKey, + DowngradeDetected, + Unprocessed, + CryptoDamage, + ContentDamage, + Finished + } + + fun needsNewGeneration(): Boolean { + val now = System.currentTimeMillis() + + val timeWentBackwards = now < currentGenerationFirstTimestamp + val timeLimitReached = now >= currentGenerationFirstTimestamp + GENERATION_DURATION_LIMIT + val counterLimitReached = nextCounter >= GENERATION_COUNTER_LIMIT + + return timeWentBackwards or timeLimitReached or counterLimitReached + } + + fun prepareEncryption(forceNewGeneration: Boolean): PrepareEncryptionResult { + return if (needsNewGeneration() || forceNewGeneration || currentGenerationKey == null) { + val newKey = CryptContainer.generateKey() + val generation = currentGeneration + 1 + + PrepareEncryptionResult( + params = CryptContainer.EncryptParameters( + key = newKey, + generation = generation, + counter = 0 + ), + newMetadata = copy( + currentGeneration = generation, + nextCounter = 1, + currentGenerationFirstTimestamp = System.currentTimeMillis(), + currentGenerationKey = newKey + ) + ) + } else { + PrepareEncryptionResult( + params = CryptContainer.EncryptParameters( + key = currentGenerationKey, + generation = currentGeneration, + counter = nextCounter + ), + newMetadata = copy( + nextCounter = nextCounter + 1 + ) + ) + } + } + + data class PrepareEncryptionResult ( + val params: CryptContainer.EncryptParameters, + val newMetadata: CryptContainerMetadata + ) +} + +class CryptContainerMetadataProcessingStatusConverter { + @TypeConverter + fun toStatus(input: Int): CryptContainerMetadata.ProcessingStatus = when (input) { + 0 -> CryptContainerMetadata.ProcessingStatus.MissingKey + 1 -> CryptContainerMetadata.ProcessingStatus.DowngradeDetected + 2 -> CryptContainerMetadata.ProcessingStatus.Unprocessed + 3 -> CryptContainerMetadata.ProcessingStatus.CryptoDamage + 4 -> CryptContainerMetadata.ProcessingStatus.ContentDamage + 5 -> CryptContainerMetadata.ProcessingStatus.Finished + else -> CryptContainerMetadata.ProcessingStatus.CryptoDamage + } + + @TypeConverter + fun toInt(input: CryptContainerMetadata.ProcessingStatus): Int = when (input) { + CryptContainerMetadata.ProcessingStatus.MissingKey -> 0 + CryptContainerMetadata.ProcessingStatus.DowngradeDetected -> 1 + CryptContainerMetadata.ProcessingStatus.Unprocessed -> 2 + CryptContainerMetadata.ProcessingStatus.CryptoDamage -> 3 + CryptContainerMetadata.ProcessingStatus.ContentDamage -> 4 + CryptContainerMetadata.ProcessingStatus.Finished -> 5 + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/model/CryptContainerPendingKeyRequest.kt b/app/src/main/java/io/timelimit/android/data/model/CryptContainerPendingKeyRequest.kt new file mode 100644 index 0000000..71ee9cf --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/model/CryptContainerPendingKeyRequest.kt @@ -0,0 +1,48 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.data.model + +import androidx.room.* + +@Entity( + tableName = "crypt_container_pending_key_request", + foreignKeys = [ + ForeignKey( + entity = CryptContainerMetadata::class, + childColumns = ["crypt_container_id"], + parentColumns = ["crypt_container_id"], + onDelete = ForeignKey.CASCADE, + onUpdate = ForeignKey.CASCADE + ) + ], + indices = [ + Index( + unique = true, + value = ["request_sequence_id"] + ) + ] +) +data class CryptContainerPendingKeyRequest ( + @PrimaryKey + @ColumnInfo(name = "crypt_container_id") + val cryptContainerId: Long, + @ColumnInfo(name = "request_time_crypt_container_generation") + val requestTimeCryptContainerGeneration: Long, + @ColumnInfo(name = "request_sequence_id") + val requestSequenceId: Long, + @ColumnInfo(name = "request_key") + val requestKey: ByteArray +) \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/model/DevicePublicKey.kt b/app/src/main/java/io/timelimit/android/data/model/DevicePublicKey.kt new file mode 100644 index 0000000..a7f3a70 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/model/DevicePublicKey.kt @@ -0,0 +1,43 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.data.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey + +@Entity( + tableName = "device_public_key", + foreignKeys = [ + ForeignKey( + entity = Device::class, + parentColumns = ["id"], + childColumns = ["device_id"], + onUpdate = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE + ) + ] +) +data class DevicePublicKey ( + @PrimaryKey + @ColumnInfo(name = "device_id") + val deviceId: String, + @ColumnInfo(name = "public_key") + val publicKey: ByteArray, + @ColumnInfo(name = "next_sequence_number") + val nextSequenceNumber: Long +) \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/model/UserKey.kt b/app/src/main/java/io/timelimit/android/data/model/UserKey.kt index ff55064..fff3cff 100644 --- a/app/src/main/java/io/timelimit/android/data/model/UserKey.kt +++ b/app/src/main/java/io/timelimit/android/data/model/UserKey.kt @@ -22,6 +22,7 @@ import androidx.room.* import io.timelimit.android.crypto.Curve25519 import io.timelimit.android.data.IdGenerator import io.timelimit.android.data.JsonSerializable +import io.timelimit.android.extensions.parseBase64 @Entity( tableName = "user_key", @@ -64,7 +65,7 @@ data class UserKey( while (reader.hasNext()) { when (reader.nextName()) { USER_ID -> userId = reader.nextString() - PUBLIC_KEY -> publicKey = Base64.decode(reader.nextString(), 0) + PUBLIC_KEY -> publicKey = reader.nextString().parseBase64() LAST_USE -> lastUse = reader.nextLong() else -> reader.skipValue() } diff --git a/app/src/main/java/io/timelimit/android/extensions/String.kt b/app/src/main/java/io/timelimit/android/extensions/String.kt new file mode 100644 index 0000000..b2c3a71 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/extensions/String.kt @@ -0,0 +1,21 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.extensions + +import android.util.Base64 + +fun String.parseBase64() = Base64.decode(this, Base64.DEFAULT) +fun ByteArray.base64() = Base64.encodeToString(this, Base64.NO_PADDING or Base64.NO_WRAP) \ No newline at end of file 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 4fcf58f..d020135 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 @@ -91,6 +91,8 @@ abstract class PlatformIntegration( abstract fun getExitLog(length: Int): List + abstract fun showNewDeviceNotification(title: String) + var installedAppsChangeListener: Runnable? = null var systemClockChangeListener: Runnable? = null } 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 2fb4970..8e40872 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 @@ -62,6 +62,7 @@ import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import java.util.* import kotlin.system.exitProcess @@ -821,4 +822,25 @@ class AndroidIntegration(context: Context): PlatformIntegration(maximumProtectio .map { ExitLogItem.fromApplicationExitInfo(it) } } else emptyList() } + + override fun showNewDeviceNotification(title: String) { + NotificationChannels.createNotificationChannels(notificationManager, context) + + notificationManager.notify( + UUID.randomUUID().toString(), + NotificationIds.NEW_DEVICE, + NotificationCompat.Builder(context, NotificationChannels.NEW_DEVICE) + .setSmallIcon(R.drawable.ic_stat_timelapse) + .setContentTitle(context.getString(R.string.notification_new_device_title)) + .setContentText(title) + .setContentIntent(BackgroundActionService.getOpenAppIntent(context)) + .setWhen(System.currentTimeMillis()) + .setShowWhen(true) + .setLocalOnly(true) + .setAutoCancel(false) + .setOngoing(false) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + ) + } } diff --git a/app/src/main/java/io/timelimit/android/integration/platform/android/Notification.kt b/app/src/main/java/io/timelimit/android/integration/platform/android/Notification.kt index 70f32b2..4132178 100644 --- a/app/src/main/java/io/timelimit/android/integration/platform/android/Notification.kt +++ b/app/src/main/java/io/timelimit/android/integration/platform/android/Notification.kt @@ -33,6 +33,7 @@ object NotificationIds { const val LOCAL_UPDATE_NOTIFICATION = 7 const val WORKER_REPORT_UNINSTALL = 8 const val WORKER_SYNC_BACKGROUND = 9 + const val NEW_DEVICE = 10 } object NotificationChannels { @@ -45,6 +46,7 @@ object NotificationChannels { const val BACKGROUND_SYNC_NOTIFICATION = "background sync" const val TEMP_ALLOWED_APP = "temporarily allowed App" const val APP_RESET = "app reset" + const val NEW_DEVICE = "new device" private fun createAppStatusChannel(notificationManager: NotificationManager, context: Context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { @@ -193,6 +195,25 @@ object NotificationChannels { } } + private fun createNewDeviceChannel(notificationManager: NotificationManager, context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + notificationManager.createNotificationChannel( + NotificationChannel( + NEW_DEVICE, + context.getString(R.string.notification_channel_new_device_title), + NotificationManager.IMPORTANCE_LOW + ).apply { + description = context.getString(R.string.notification_channel_new_device_description) + enableLights(false) + setSound(null, null) + enableVibration(false) + setShowBadge(true) + lockscreenVisibility = NotificationCompat.VISIBILITY_SECRET + } + ) + } + } + fun createNotificationChannels(notificationManager: NotificationManager, context: Context) { createAppStatusChannel(notificationManager, context) createBlockedNotificationChannel(notificationManager, context) @@ -203,6 +224,7 @@ object NotificationChannels { createBackgroundSyncChannel(notificationManager, context) createTempAllowedAppChannel(notificationManager, context) createAppResetChannel(notificationManager, context) + createNewDeviceChannel(notificationManager, context) } } 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 0221871..b70c34c 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 @@ -191,4 +191,6 @@ class DummyIntegration( ): Boolean = false override fun getExitLog(length: Int): List = emptyList() + + override fun showNewDeviceNotification(title: String) = Unit } diff --git a/app/src/main/java/io/timelimit/android/livedata/MergeLiveData.kt b/app/src/main/java/io/timelimit/android/livedata/MergeLiveData.kt index 39473ec..66a753e 100644 --- a/app/src/main/java/io/timelimit/android/livedata/MergeLiveData.kt +++ b/app/src/main/java/io/timelimit/android/livedata/MergeLiveData.kt @@ -167,6 +167,8 @@ fun mergeLiveDataWaitForValues(d1: LiveData, d2: LiveData): Liv return result } +data class SevenTuple(val first: A, val second: B, val third: C, val forth: D, val fifth: E, val sixth: F, val seventh: G) + fun mergeLiveDataWaitForValues(d1: LiveData, d2: LiveData, d3: LiveData): LiveData> { val result = MediatorLiveData>() var state = Triple, Option, Option>(Option.None(), Option.None(), Option.None()) @@ -281,5 +283,113 @@ fun mergeLiveDataWaitForValues(d1: LiveData, d2: LiveDa update() } + return result +} + +fun mergeLiveDataWaitForValues(d1: LiveData, d2: LiveData, d3: LiveData, d4: LiveData, d5: LiveData, d6: LiveData): LiveData> { + val result = MediatorLiveData>() + var state = SixTuple, Option, Option, Option, Option, Option>(Option.None(), Option.None(), Option.None(), Option.None(), Option.None(), Option.None()) + + fun update() { + val (a, b, c, d, e, f) = state + + if (a is Option.Some && b is Option.Some && c is Option.Some && d is Option.Some && e is Option.Some && f is Option.Some) { + result.value = SixTuple(a.value, b.value, c.value, d.value, e.value, f.value) + } + } + + result.addSource(d1) { + state = state.copy(first = Option.Some(it)) + + update() + } + + result.addSource(d2) { + state = state.copy(second = Option.Some(it)) + + update() + } + + result.addSource(d3) { + state = state.copy(third = Option.Some(it)) + + update() + } + + result.addSource(d4) { + state = state.copy(forth = Option.Some(it)) + + update() + } + + result.addSource(d5) { + state = state.copy(fifth = Option.Some(it)) + + update() + } + + result.addSource(d6) { + state = state.copy(sixth = Option.Some(it)) + + update() + } + + return result +} + +fun mergeLiveDataWaitForValues(d1: LiveData, d2: LiveData, d3: LiveData, d4: LiveData, d5: LiveData, d6: LiveData, d7: LiveData): LiveData> { + val result = MediatorLiveData>() + var state = SevenTuple, Option, Option, Option, Option, Option, Option>(Option.None(), Option.None(), Option.None(), Option.None(), Option.None(), Option.None(), Option.None()) + + fun update() { + val (a, b, c, d, e, f, g) = state + + if (a is Option.Some && b is Option.Some && c is Option.Some && d is Option.Some && e is Option.Some && f is Option.Some && g is Option.Some) { + result.value = SevenTuple(a.value, b.value, c.value, d.value, e.value, f.value, g.value) + } + } + + result.addSource(d1) { + state = state.copy(first = Option.Some(it)) + + update() + } + + result.addSource(d2) { + state = state.copy(second = Option.Some(it)) + + update() + } + + result.addSource(d3) { + state = state.copy(third = Option.Some(it)) + + update() + } + + result.addSource(d4) { + state = state.copy(forth = Option.Some(it)) + + update() + } + + result.addSource(d5) { + state = state.copy(fifth = Option.Some(it)) + + update() + } + + result.addSource(d6) { + state = state.copy(sixth = Option.Some(it)) + + update() + } + + result.addSource(d7) { + state = state.copy(seventh = Option.Some(it)) + + update() + } + return result } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/logic/AppLogic.kt b/app/src/main/java/io/timelimit/android/logic/AppLogic.kt index 9ab487e..c4f76d4 100644 --- a/app/src/main/java/io/timelimit/android/logic/AppLogic.kt +++ b/app/src/main/java/io/timelimit/android/logic/AppLogic.kt @@ -21,14 +21,13 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Transformations import io.timelimit.android.data.Database import io.timelimit.android.data.model.Device -import io.timelimit.android.data.model.ExperimentalFlags import io.timelimit.android.data.model.User import io.timelimit.android.integration.platform.PlatformIntegration import io.timelimit.android.integration.time.TimeApi import io.timelimit.android.livedata.* +import io.timelimit.android.logic.applist.SyncInstalledAppsLogic import io.timelimit.android.sync.SyncUtil import io.timelimit.android.sync.network.api.ServerApi -import io.timelimit.android.sync.websocket.NetworkStatus import io.timelimit.android.sync.websocket.NetworkStatusInterface import io.timelimit.android.sync.websocket.WebsocketClientCreator diff --git a/app/src/main/java/io/timelimit/android/logic/ServerApiLevelLogic.kt b/app/src/main/java/io/timelimit/android/logic/ServerApiLevelLogic.kt index e009bbe..63a2491 100644 --- a/app/src/main/java/io/timelimit/android/logic/ServerApiLevelLogic.kt +++ b/app/src/main/java/io/timelimit/android/logic/ServerApiLevelLogic.kt @@ -15,16 +15,26 @@ */ package io.timelimit.android.logic +import io.timelimit.android.data.Database import io.timelimit.android.livedata.liveDataFromNonNullValue import io.timelimit.android.livedata.map import io.timelimit.android.livedata.switchMap class ServerApiLevelLogic(logic: AppLogic) { - val infoLive = logic.database.config().getDeviceAuthTokenAsync().switchMap { authToken -> + companion object { + fun getSync(database: Database): ServerApiLevelInfo = if (database.config().getDeviceAuthTokenSync().isEmpty()) + ServerApiLevelInfo.Offline + else + ServerApiLevelInfo.Online(serverLevel = database.config().getServerApiLevelSync()) + } + + private val database = logic.database + + val infoLive = database.config().getDeviceAuthTokenAsync().switchMap { authToken -> if (authToken.isEmpty()) liveDataFromNonNullValue(ServerApiLevelInfo.Offline) else - logic.database.config().getServerApiLevelLive().map { apiLevel -> + database.config().getServerApiLevelLive().map { apiLevel -> ServerApiLevelInfo.Online(serverLevel = apiLevel) } } diff --git a/app/src/main/java/io/timelimit/android/logic/SyncInstalledAppsLogic.kt b/app/src/main/java/io/timelimit/android/logic/SyncInstalledAppsLogic.kt deleted file mode 100644 index 9eee578..0000000 --- a/app/src/main/java/io/timelimit/android/logic/SyncInstalledAppsLogic.kt +++ /dev/null @@ -1,261 +0,0 @@ -/* - * TimeLimit Copyright 2019 - 2022 Jonas Lochmann - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package io.timelimit.android.logic - -import android.util.Log -import android.widget.Toast -import androidx.lifecycle.MutableLiveData -import io.timelimit.android.BuildConfig -import io.timelimit.android.R -import io.timelimit.android.async.Threads -import io.timelimit.android.coroutines.executeAndWait -import io.timelimit.android.coroutines.runAsyncExpectForever -import io.timelimit.android.data.model.App -import io.timelimit.android.data.model.AppActivity -import io.timelimit.android.data.model.ConsentFlags -import io.timelimit.android.data.model.UserType -import io.timelimit.android.integration.platform.ProtectionLevel -import io.timelimit.android.livedata.* -import io.timelimit.android.sync.actions.* -import io.timelimit.android.sync.actions.apply.ApplyActionUtil -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - -class SyncInstalledAppsLogic(val appLogic: AppLogic) { - companion object { - private const val LOG_TAG = "SyncInstalledAppsLogic" - } - - private val doSyncLock = Mutex() - private var requestSync = MutableLiveData().apply { value = false } - - private fun requestSync() { - requestSync.value = true - } - - private val deviceStateLive = mergeLiveDataWaitForValues( - appLogic.deviceEntryIfEnabled, - appLogic.database.config().isConsentFlagSetAsync(ConsentFlags.APP_LIST_SYNC), - appLogic.database.config().getDeviceAuthTokenAsync().map { it.isEmpty() }, - appLogic.deviceUserEntry, - appLogic.deviceEntryIfEnabled.switchMap { deviceEntry -> - val defaultUser = deviceEntry?.defaultUser - - if (defaultUser.isNullOrEmpty()) liveDataFromNullableValue(null) - else appLogic.database.user().getUserByIdLive(defaultUser) - } - ).map { (deviceEntry, hasSyncConsent, isLocalMode, deviceUser, deviceDefaultUser) -> - deviceEntry?.let { device -> - DeviceState( - id = device.id, - isCurrentUserChild = deviceUser?.type == UserType.Child, - isDefaultUserChild = deviceDefaultUser?.type == UserType.Child, - enableActivityLevelBlocking = device.enableActivityLevelBlocking, - isDeviceOwner = device.currentProtectionLevel == ProtectionLevel.DeviceOwner, - hasSyncConsent = hasSyncConsent, - isLocalMode = isLocalMode - ) - } - }.ignoreUnchanged() - - val shouldAskForConsent = deviceStateLive.map { it?.shouldAskForConsent ?: false }.ignoreUnchanged() - - private fun getDeviceStateSync(): DeviceState? { - val userAndDeviceData = appLogic.database.derivedDataDao().getUserAndDeviceRelatedDataSync() ?: return null - val deviceRelatedData = userAndDeviceData.deviceRelatedData - val device = deviceRelatedData.deviceEntry - val defaultUser = if (device.defaultUser.isNotEmpty()) appLogic.database.user().getUserByIdSync(device.defaultUser) else null - - return DeviceState( - id = device.id, - isCurrentUserChild = userAndDeviceData.userRelatedData?.user?.type == UserType.Child, - isDefaultUserChild = defaultUser?.type == UserType.Child, - enableActivityLevelBlocking = device.enableActivityLevelBlocking, - isDeviceOwner = device.currentProtectionLevel == ProtectionLevel.DeviceOwner, - hasSyncConsent = deviceRelatedData.consentFlags and ConsentFlags.APP_LIST_SYNC == ConsentFlags.APP_LIST_SYNC, - isLocalMode = deviceRelatedData.isLocalMode - ) - } - - init { - appLogic.platformIntegration.installedAppsChangeListener = Runnable { requestSync() } - deviceStateLive.observeForever { requestSync() } - - runAsyncExpectForever { syncLoop() } - } - - private suspend fun syncLoop() { - // wait a moment before the first sync - appLogic.timeApi.sleep(15 * 1000) - - while (true) { - requestSync.waitUntilValueMatches { it == true } - requestSync.value = false - - try { - doSyncNow() - - // maximal 1 time per 5 seconds - appLogic.timeApi.sleep(5 * 1000) - } catch (ex: Exception) { - if (BuildConfig.DEBUG) { - Log.w(LOG_TAG, "could not sync installed app list", ex) - } - - Toast.makeText(appLogic.context, R.string.background_logic_toast_sync_apps, Toast.LENGTH_SHORT).show() - - appLogic.timeApi.sleep(45 * 1000) - requestSync.value = true - } - } - } - - private suspend fun doSyncNow() { - doSyncLock.withLock { - val deviceState = Threads.database.executeAndWait { getDeviceStateSync() } ?: return - - if (deviceState.isLocalMode) { - // local mode -> sync always - } else { - // connected mode -> don't sync always - if (!deviceState.hasSyncConsent) return@withLock - if (!deviceState.hasAnyChildUser) return@withLock - } - - val deviceId = deviceState.id - - val currentlyInstalledApps = getCurrentApps(deviceId) - - run { - val currentlySaved = appLogic.database.app().getAppsByDeviceIdAsync(deviceId = deviceId).waitForNonNullValue().associateBy { app -> app.packageName } - - // skip all items for removal which are still saved locally - val itemsToRemove = HashMap(currentlySaved) - currentlyInstalledApps.forEach { (packageName, _) -> itemsToRemove.remove(packageName) } - - // only add items which are not the same locally - val itemsToAdd = currentlyInstalledApps.filter { (packageName, app) -> currentlySaved[packageName] != app } - - // save the changes - if (itemsToRemove.isNotEmpty()) { - ApplyActionUtil.applyAppLogicAction( - action = RemoveInstalledAppsAction(packageNames = itemsToRemove.keys.toList()), - appLogic = appLogic, - ignoreIfDeviceIsNotConfigured = true - ) - } - - if (itemsToAdd.isNotEmpty()) { - ApplyActionUtil.applyAppLogicAction( - action = AddInstalledAppsAction( - apps = itemsToAdd.map { - (_, app) -> - - InstalledApp( - packageName = app.packageName, - title = app.title, - recommendation = app.recommendation, - isLaunchable = app.isLaunchable - ) - } - ), - appLogic = appLogic, - ignoreIfDeviceIsNotConfigured = true - ) - } - } - - run { - fun buildKey(activity: AppActivity) = "${activity.appPackageName}:${activity.activityClassName}" - - val currentlyInstalled = if (deviceState.enableActivityLevelBlocking) - Threads.backgroundOSInteraction.executeAndWait { - val realActivities = appLogic.platformIntegration.getLocalAppActivities(deviceId = deviceId) - val dummyActivities = currentlyInstalledApps.keys.map { packageName -> - AppActivity( - deviceId = deviceId, - appPackageName = packageName, - activityClassName = DummyApps.ACTIVITY_BACKGROUND_AUDIO, - title = appLogic.context.getString(R.string.dummy_app_activity_audio) - ) - } - - val allActivities = realActivities + dummyActivities - - allActivities.associateBy { buildKey(it) } - } - else - emptyMap() - - val currentlySaved = appLogic.database.appActivity().getAppActivitiesByDeviceIds(deviceIds = listOf(deviceId)).waitForNonNullValue().associateBy { buildKey(it) } - - // skip all items for removal which are still saved locally - val itemsToRemove = HashMap(currentlySaved) - currentlyInstalled.forEach { (packageName, _) -> itemsToRemove.remove(packageName) } - - // only add items which are not the same locally - val itemsToAdd = currentlyInstalled.filter { (packageName, app) -> currentlySaved[packageName] != app } - - // save the changes - if (itemsToRemove.isNotEmpty() or itemsToAdd.isNotEmpty()) { - ApplyActionUtil.applyAppLogicAction( - action = UpdateAppActivitiesAction( - removedActivities = itemsToRemove.map { it.value.appPackageName to it.value.activityClassName }, - updatedOrAddedActivities = itemsToAdd.map { item -> - AppActivityItem( - packageName = item.value.appPackageName, - className = item.value.activityClassName, - title = item.value.title - ) - } - ), - appLogic = appLogic, - ignoreIfDeviceIsNotConfigured = true - ) - } - } - } - } - - private suspend fun getCurrentApps(deviceId: String): Map { - val currentlyInstalled = Threads.backgroundOSInteraction.executeAndWait { - appLogic.platformIntegration.getLocalApps(deviceId = deviceId).associateBy { app -> app.packageName } - } - - val featureDummyApps = appLogic.platformIntegration.getFeatures().map { - DummyApps.forFeature( - id = it.id, - title = it.title, - deviceId = deviceId - ) - }.associateBy { it.packageName } - - return currentlyInstalled + featureDummyApps - } - - internal data class DeviceState( - val id: String, - val isCurrentUserChild: Boolean, - val isDefaultUserChild: Boolean, - val enableActivityLevelBlocking: Boolean, - val isDeviceOwner: Boolean, - val hasSyncConsent: Boolean, - val isLocalMode: Boolean - ) { - val hasAnyChildUser = isCurrentUserChild || isDefaultUserChild - val shouldAskForConsent = hasAnyChildUser && !isLocalMode && !hasSyncConsent - } -} diff --git a/app/src/main/java/io/timelimit/android/logic/applist/AppsDifferenceUtil.kt b/app/src/main/java/io/timelimit/android/logic/applist/AppsDifferenceUtil.kt new file mode 100644 index 0000000..a155070 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/logic/applist/AppsDifferenceUtil.kt @@ -0,0 +1,74 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.logic.applist + +import io.timelimit.android.proto.toAppActivityItem +import io.timelimit.android.proto.toInstalledApp +import io.timelimit.android.sync.actions.AddInstalledAppsAction +import io.timelimit.android.sync.actions.AppLogicAction +import io.timelimit.android.sync.actions.RemoveInstalledAppsAction +import io.timelimit.android.sync.actions.UpdateAppActivitiesAction +import io.timelimit.proto.applist.InstalledAppsDifferenceProto +import io.timelimit.proto.applist.InstalledAppsProto +import io.timelimit.proto.applist.RemovedAppActivityProto + +object AppsDifferenceUtil { + fun calculateAppsDifference(old: InstalledAppsProto, current: InstalledAppsProto): InstalledAppsDifferenceProto { + val oldAppsByPackageName = old.apps.associateBy { it.package_name } + val packageNamesToRemove = (oldAppsByPackageName.keys - current.apps.map { it.package_name }.toSet()).toList() + val appsToAdd = current.apps.filter { app -> oldAppsByPackageName[app.package_name] != app } + + val oldActivitiesIndexed = old.activities.associateBy { Pair(it.package_name, it.class_name) } + val currentActivitiesIndexed = current.activities.associateBy { Pair(it.package_name, it.class_name) } + val activitiesToRemove = (oldActivitiesIndexed.keys - currentActivitiesIndexed.keys) + .map { activity -> RemovedAppActivityProto(package_name = activity.first, class_name = activity.second) } + val activitiesToAdd = currentActivitiesIndexed.filter { (key, activity) -> oldActivitiesIndexed[key] != activity } + .values.toList() + + return InstalledAppsDifferenceProto( + added = InstalledAppsProto( + apps = appsToAdd, + activities = activitiesToAdd + ), + removed_packages = packageNamesToRemove, + removed_activities = activitiesToRemove + ) + } + + fun calculateAppsDifferenceActions(difference: InstalledAppsDifferenceProto, deviceId: String): List { + val result = mutableListOf() + + if (difference.removed_packages.isNotEmpty()) { + result.add(RemoveInstalledAppsAction(packageNames = difference.removed_packages)) + } + + if (difference.added != null && difference.added.apps.isNotEmpty()) { + result.add(AddInstalledAppsAction(apps = difference.added.apps.map { it.toInstalledApp() })) + } + + val addedActivities = difference.added?.activities ?: emptyList() + val removedActivities = difference.removed_activities + + if (addedActivities.isNotEmpty() || removedActivities.isNotEmpty()) { + result.add(UpdateAppActivitiesAction( + removedActivities = removedActivities.map { it.package_name to it.class_name }, + updatedOrAddedActivities = addedActivities.map { it.toAppActivityItem() } + )) + } + + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/logic/applist/CryptoAppListSync.kt b/app/src/main/java/io/timelimit/android/logic/applist/CryptoAppListSync.kt new file mode 100644 index 0000000..4b04bdb --- /dev/null +++ b/app/src/main/java/io/timelimit/android/logic/applist/CryptoAppListSync.kt @@ -0,0 +1,193 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.logic.applist + +import io.timelimit.android.async.Threads +import io.timelimit.android.coroutines.executeAndWait +import io.timelimit.android.crypto.CryptContainer +import io.timelimit.android.data.Database +import io.timelimit.android.data.model.CryptContainerData +import io.timelimit.android.data.model.CryptContainerMetadata +import io.timelimit.android.proto.build +import io.timelimit.android.proto.encodeDeflated +import io.timelimit.android.sync.SyncUtil +import io.timelimit.android.sync.actions.AppLogicAction +import io.timelimit.android.sync.actions.UpdateInstalledAppsAction +import io.timelimit.android.sync.actions.apply.ApplyActionUtil +import io.timelimit.proto.applist.InstalledAppsDifferenceProto +import io.timelimit.proto.applist.InstalledAppsProto +import io.timelimit.proto.applist.SavedAppsDifferenceProto + +object CryptoAppListSync { + private const val SIZE_LIMIT_COMPRESSED = 1024 * 256 + + class TooLargeException: RuntimeException() + + suspend fun sync( + deviceState: DeviceState, + database: Database, + installed: InstalledAppsProto, + syncUtil: SyncUtil, + disableLegacySync: Boolean + ) { + fun dispatch(action: AppLogicAction) { + if (deviceState.isConnectedMode) { + ApplyActionUtil.addAppLogicActionToDatabaseSync(action, database) + } + } + + val savedCrypt = Threads.database.executeAndWait { + InstalledAppsUtil.getEncryptedInstalledAppsFromDatabaseSync(database, deviceState.id) + } + + if (savedCrypt == null) { + val baseKey = CryptContainer.EncryptParameters.generate() + val diffKey = CryptContainer.EncryptParameters.generate() + + val baseEncrypted = CryptContainer.encrypt(installed.encodeDeflated(), baseKey) + val diffEncrypted = CryptContainer.encrypt(SavedAppsDifferenceProto.build(baseEncrypted, InstalledAppsDifferenceProto()).encodeDeflated(), diffKey) + + if (baseEncrypted.size > SIZE_LIMIT_COMPRESSED) throw TooLargeException() + + Threads.database.executeAndWait { + database.cryptContainer().removeDeviceCryptoMetadata( + deviceId = deviceState.id, + types = listOf( + CryptContainerMetadata.TYPE_APP_LIST_BASE, + CryptContainerMetadata.TYPE_APP_LIST_DIFF + ) + ) + + val baseId = database.cryptContainer().insertMetadata( + CryptContainerMetadata.buildFor( + deviceId = deviceState.id, + categoryId = null, + type = CryptContainerMetadata.TYPE_APP_LIST_BASE, + params = baseKey + ) + ) + + val diffId = database.cryptContainer().insertMetadata( + CryptContainerMetadata.buildFor( + deviceId = deviceState.id, + categoryId = null, + type = CryptContainerMetadata.TYPE_APP_LIST_DIFF, + params = diffKey + ) + ) + + database.cryptContainer().insertData( + CryptContainerData( + cryptContainerId = baseId, + encryptedData = baseEncrypted + ) + ) + + database.cryptContainer().insertData( + CryptContainerData( + cryptContainerId = diffId, + encryptedData = diffEncrypted + ) + ) + + dispatch(UpdateInstalledAppsAction( + base = baseEncrypted, + diff = diffEncrypted, + wipe = disableLegacySync + )) + } + + syncUtil.requestImportantSync() + } else { + val diffCrypto = AppsDifferenceUtil.calculateAppsDifference(savedCrypt.base, installed) + + if (diffCrypto != savedCrypt.diff) { + val baseSize = savedCrypt.base.adapter.encodedSize(savedCrypt.base) + val diffSize = diffCrypto.adapter.encodedSize(diffCrypto) + val needsNewBySize = diffSize >= baseSize / 10 + val baseNeedsNewGeneration = savedCrypt.baseMeta.needsNewGeneration() + val diffNeedsNewGeneration = savedCrypt.diffMeta.needsNewGeneration() or baseNeedsNewGeneration + + val diffCryptParams = savedCrypt.diffMeta.prepareEncryption(diffNeedsNewGeneration) + + if (needsNewBySize or baseNeedsNewGeneration) { + val baseCryptParams = savedCrypt.baseMeta.prepareEncryption(baseNeedsNewGeneration) + + val baseEncrypted = CryptContainer.encrypt(installed.encodeDeflated(), baseCryptParams.params) + + val diffEncrypted = CryptContainer.encrypt( + SavedAppsDifferenceProto.build(baseEncrypted, InstalledAppsDifferenceProto()).encodeDeflated(), + diffCryptParams.params + ) + + if (baseEncrypted.size > SIZE_LIMIT_COMPRESSED) throw TooLargeException() + + Threads.database.executeAndWait { + database.cryptContainer().updateMetadata(listOf( + baseCryptParams.newMetadata, + diffCryptParams.newMetadata + )) + + database.cryptContainer().updateData(listOf( + CryptContainerData( + cryptContainerId = savedCrypt.baseMeta.cryptContainerId, + encryptedData = baseEncrypted + ), + CryptContainerData( + cryptContainerId = savedCrypt.diffMeta.cryptContainerId, + encryptedData = diffEncrypted + ) + )) + + dispatch(UpdateInstalledAppsAction( + base = baseEncrypted, + diff = diffEncrypted, + wipe = disableLegacySync + )) + } + + syncUtil.requestImportantSync() + } else { + val diffEncrypted = CryptContainer.encrypt( + SavedAppsDifferenceProto.build(savedCrypt.baseHeader, diffCrypto).encodeDeflated(), + diffCryptParams.params + ) + + if (diffEncrypted.size > SIZE_LIMIT_COMPRESSED) throw TooLargeException() + + Threads.database.executeAndWait { + database.cryptContainer().updateMetadata(diffCryptParams.newMetadata) + + database.cryptContainer().updateData( + CryptContainerData( + cryptContainerId = savedCrypt.diffMeta.cryptContainerId, + encryptedData = diffEncrypted + ) + ) + + dispatch(UpdateInstalledAppsAction( + base = null, + diff = diffEncrypted, + wipe = disableLegacySync + )) + } + + syncUtil.requestImportantSync() + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/logic/applist/DeviceStatus.kt b/app/src/main/java/io/timelimit/android/logic/applist/DeviceStatus.kt new file mode 100644 index 0000000..8a6709d --- /dev/null +++ b/app/src/main/java/io/timelimit/android/logic/applist/DeviceStatus.kt @@ -0,0 +1,95 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.logic.applist + +import androidx.lifecycle.LiveData +import io.timelimit.android.data.Database +import io.timelimit.android.data.model.ConsentFlags +import io.timelimit.android.data.model.ExperimentalFlags +import io.timelimit.android.data.model.UserType +import io.timelimit.android.integration.platform.ProtectionLevel +import io.timelimit.android.livedata.* +import io.timelimit.android.logic.AppLogic +import io.timelimit.android.logic.ServerApiLevelInfo +import io.timelimit.android.logic.ServerApiLevelLogic + +data class DeviceState( + val id: String, + val isCurrentUserChild: Boolean, + val isDefaultUserChild: Boolean, + val enableActivityLevelBlocking: Boolean, + val isDeviceOwner: Boolean, + val hasSyncConsent: Boolean, + val isLocalMode: Boolean, + val disableLegacySync: Boolean, + val serverApiLevel: ServerApiLevelInfo +) { + companion object { + fun getSync(database: Database): DeviceState? { + val userAndDeviceData = database.derivedDataDao().getUserAndDeviceRelatedDataSync() ?: return null + val deviceRelatedData = userAndDeviceData.deviceRelatedData + val device = deviceRelatedData.deviceEntry + val defaultUser = if (device.defaultUser.isNotEmpty()) database.user().getUserByIdSync(device.defaultUser) else null + val disableLegacySync = deviceRelatedData.isExperimentalFlagSetSync(ExperimentalFlags.DISABLE_LEGACY_APP_SENDING) + val serverApiLevel = ServerApiLevelLogic.getSync(database) + + return DeviceState( + id = device.id, + isCurrentUserChild = userAndDeviceData.userRelatedData?.user?.type == UserType.Child, + isDefaultUserChild = defaultUser?.type == UserType.Child, + enableActivityLevelBlocking = device.enableActivityLevelBlocking, + isDeviceOwner = device.currentProtectionLevel == ProtectionLevel.DeviceOwner, + hasSyncConsent = deviceRelatedData.consentFlags and ConsentFlags.APP_LIST_SYNC == ConsentFlags.APP_LIST_SYNC, + isLocalMode = deviceRelatedData.isLocalMode, + disableLegacySync = disableLegacySync, + serverApiLevel = serverApiLevel + ) + } + + fun getLive(appLogic: AppLogic): LiveData = mergeLiveDataWaitForValues( + appLogic.deviceEntryIfEnabled, + appLogic.database.config().isConsentFlagSetAsync(ConsentFlags.APP_LIST_SYNC), + appLogic.database.config().getDeviceAuthTokenAsync().map { it.isEmpty() }, + appLogic.deviceUserEntry, + appLogic.deviceEntryIfEnabled.switchMap { deviceEntry -> + val defaultUser = deviceEntry?.defaultUser + + if (defaultUser.isNullOrEmpty()) liveDataFromNullableValue(null) + else appLogic.database.user().getUserByIdLive(defaultUser) + }, + appLogic.database.config().isExperimentalFlagsSetAsync(ExperimentalFlags.DISABLE_LEGACY_APP_SENDING), + appLogic.serverApiLevelLogic.infoLive + ).map { (deviceEntry, hasSyncConsent, isLocalMode, deviceUser, deviceDefaultUser, disableLegacySync, serverApiLevel) -> + deviceEntry?.let { device -> + DeviceState( + id = device.id, + isCurrentUserChild = deviceUser?.type == UserType.Child, + isDefaultUserChild = deviceDefaultUser?.type == UserType.Child, + enableActivityLevelBlocking = device.enableActivityLevelBlocking, + isDeviceOwner = device.currentProtectionLevel == ProtectionLevel.DeviceOwner, + hasSyncConsent = hasSyncConsent, + isLocalMode = isLocalMode, + disableLegacySync = disableLegacySync, + serverApiLevel = serverApiLevel + ) + } + }.ignoreUnchanged() + } + + val hasAnyChildUser = isCurrentUserChild || isDefaultUserChild + val shouldAskForConsent = hasAnyChildUser && !isLocalMode && !hasSyncConsent + val isConnectedMode = !isLocalMode +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/logic/applist/InstalledAppsUtil.kt b/app/src/main/java/io/timelimit/android/logic/applist/InstalledAppsUtil.kt new file mode 100644 index 0000000..6f36b63 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/logic/applist/InstalledAppsUtil.kt @@ -0,0 +1,171 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.logic.applist + +import android.util.Log +import io.timelimit.android.BuildConfig +import io.timelimit.android.R +import io.timelimit.android.async.Threads +import io.timelimit.android.coroutines.executeAndWait +import io.timelimit.android.crypto.CryptContainer +import io.timelimit.android.crypto.CryptException +import io.timelimit.android.data.Database +import io.timelimit.android.data.model.AppActivity +import io.timelimit.android.data.model.CryptContainerMetadata +import io.timelimit.android.livedata.waitForNonNullValue +import io.timelimit.android.logic.AppLogic +import io.timelimit.android.logic.DummyApps +import io.timelimit.android.proto.decodeInflated +import io.timelimit.android.proto.toProto +import io.timelimit.proto.applist.InstalledAppsDifferenceProto +import io.timelimit.proto.applist.InstalledAppsProto +import io.timelimit.proto.applist.SavedAppsDifferenceProto +import java.io.IOException + +object InstalledAppsUtil { + private const val LOG_TAG = "InstalledAppsUtil" + + suspend fun getInstalledAppsFromPlainDatabaseAsync(database: Database, deviceId: String): InstalledAppsProto { + return InstalledAppsProto( + apps = database.app().getAppsByDeviceIdAsync(deviceId = deviceId).waitForNonNullValue().map { it.toProto() }, + activities = database.appActivity().getAppActivitiesByDeviceIds(deviceIds = listOf(deviceId)).waitForNonNullValue().map { it.toProto() } + ) + } + + fun getEncryptedInstalledAppsFromDatabaseSync(database: Database, deviceId: String): DecryptedInstalledApps? { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "getEncryptedInstalledAppsFromDatabaseSync()") + } + + val baseValue = database.cryptContainer().getCryptoFullDataSyncByDeviceId( + deviceId = deviceId, + type = CryptContainerMetadata.TYPE_APP_LIST_BASE + ) + + val diffValue = database.cryptContainer().getCryptoFullDataSyncByDeviceId( + deviceId = deviceId, + type = CryptContainerMetadata.TYPE_APP_LIST_DIFF + ) + + if ( + baseValue == null || + baseValue.metadata.currentGenerationKey == null || + baseValue.metadata.status != CryptContainerMetadata.ProcessingStatus.Finished || + diffValue == null || + diffValue.metadata.currentGenerationKey == null || + diffValue.metadata.status != CryptContainerMetadata.ProcessingStatus.Finished + ) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "incomplete data") + } + + return null + } + + val (baseHeader, baseDecrypted, diffDecrypted) = try { + val baseHeader = CryptContainer.Header.read(baseValue.encryptedData) + + val baseDecrypted = CryptContainer.decrypt( + baseValue.metadata.currentGenerationKey, + baseValue.encryptedData + ) + + val diffDecrypted = CryptContainer.decrypt( + diffValue.metadata.currentGenerationKey, + diffValue.encryptedData + ) + + Triple(baseHeader, baseDecrypted, diffDecrypted) + } catch (ex: CryptException) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "could not decrypt previous data", ex) + } + + return null + } + + val (base, diff) = try { + val base = InstalledAppsProto.ADAPTER.decodeInflated(baseDecrypted) + + val diff = SavedAppsDifferenceProto.ADAPTER.decodeInflated(diffDecrypted).apps + ?: InstalledAppsDifferenceProto() + + Pair(base, diff) + } catch (ex: IOException) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "could not decode data", ex) + } + + return null + } + + return DecryptedInstalledApps( + base = base, + baseMeta = baseValue.metadata, + baseHeader = baseHeader, + diff = diff, + diffMeta = diffValue.metadata + ) + } + + data class DecryptedInstalledApps( + val base: InstalledAppsProto, + val baseHeader: CryptContainer.Header, + val baseMeta: CryptContainerMetadata, + val diff: InstalledAppsDifferenceProto, + val diffMeta: CryptContainerMetadata + ) + + suspend fun getInstalledAppsFromOs(appLogic: AppLogic, deviceState: DeviceState): InstalledAppsProto { + val apps = kotlin.run { + val currentlyInstalled = Threads.backgroundOSInteraction.executeAndWait { + appLogic.platformIntegration.getLocalApps(deviceId = deviceState.id) + } + + val featureDummyApps = appLogic.platformIntegration.getFeatures().map { + DummyApps.forFeature( + id = it.id, + title = it.title, + deviceId = deviceState.id + ) + } + + (currentlyInstalled + featureDummyApps).map { it.toProto() } + } + + val activities = if (deviceState.enableActivityLevelBlocking) + Threads.backgroundOSInteraction.executeAndWait { + val realActivities = appLogic.platformIntegration.getLocalAppActivities(deviceId = deviceState.id) + val dummyActivities = apps.map { app -> + AppActivity( + deviceId = deviceState.id, + appPackageName = app.package_name, + activityClassName = DummyApps.ACTIVITY_BACKGROUND_AUDIO, + title = appLogic.context.getString(R.string.dummy_app_activity_audio) + ) + } + + (realActivities + dummyActivities).map { it.toProto() } + } + else + emptyList() + + return InstalledAppsProto( + apps = apps, + activities = activities + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/logic/applist/SyncInstalledAppsLogic.kt b/app/src/main/java/io/timelimit/android/logic/applist/SyncInstalledAppsLogic.kt new file mode 100644 index 0000000..062d0ce --- /dev/null +++ b/app/src/main/java/io/timelimit/android/logic/applist/SyncInstalledAppsLogic.kt @@ -0,0 +1,132 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.logic.applist + +import android.util.Log +import android.widget.Toast +import androidx.lifecycle.MutableLiveData +import io.timelimit.android.BuildConfig +import io.timelimit.android.R +import io.timelimit.android.async.Threads +import io.timelimit.android.coroutines.executeAndWait +import io.timelimit.android.coroutines.runAsyncExpectForever +import io.timelimit.android.livedata.* +import io.timelimit.android.logic.AppLogic +import io.timelimit.android.sync.actions.apply.ApplyActionUtil +import io.timelimit.android.sync.actions.dispatch.LocalDatabaseAppLogicActionDispatcher +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class SyncInstalledAppsLogic(val appLogic: AppLogic) { + companion object { + private const val LOG_TAG = "SyncInstalledAppsLogic" + } + + private val doSyncLock = Mutex() + private var requestSync = MutableLiveData().apply { value = false } + + private fun requestSync() { + requestSync.value = true + } + + private val deviceStateLive = DeviceState.getLive(appLogic) + + val shouldAskForConsent = deviceStateLive.map { it?.shouldAskForConsent ?: false }.ignoreUnchanged() + + init { + appLogic.platformIntegration.installedAppsChangeListener = Runnable { requestSync() } + deviceStateLive.observeForever { requestSync() } + + runAsyncExpectForever { syncLoop() } + } + + private suspend fun syncLoop() { + // wait a moment before the first sync + appLogic.timeApi.sleep(15 * 1000) + + while (true) { + requestSync.waitUntilValueMatches { it == true } + requestSync.value = false + + try { + doSyncNow() + + // maximal 1 time per 5 seconds + appLogic.timeApi.sleep(5 * 1000) + } catch (ex: Exception) { + if (BuildConfig.DEBUG) { + Log.w(LOG_TAG, "could not sync installed app list", ex) + } + + Toast.makeText(appLogic.context, R.string.background_logic_toast_sync_apps, Toast.LENGTH_SHORT).show() + + appLogic.timeApi.sleep(45 * 1000) + requestSync.value = true + } + } + } + + private suspend fun doSyncNow() { + doSyncLock.withLock { + val deviceState = Threads.database.executeAndWait { DeviceState.getSync(appLogic.database) } ?: return + + if (deviceState.isLocalMode) { + // local mode -> sync always + } else { + // connected mode -> don't sync always + if (!deviceState.hasSyncConsent) return@withLock + if (!deviceState.hasAnyChildUser) return@withLock + } + + val installed = InstalledAppsUtil.getInstalledAppsFromOs(appLogic, deviceState) + + val savedPlain = InstalledAppsUtil.getInstalledAppsFromPlainDatabaseAsync(appLogic.database, deviceState.id) + + val diffPlain = AppsDifferenceUtil.calculateAppsDifference(savedPlain, installed) + val diffPlainActions = AppsDifferenceUtil.calculateAppsDifferenceActions(diffPlain, deviceState.id) + + if (deviceState.disableLegacySync) { + if (diffPlainActions.isNotEmpty()) { + Threads.database.executeAndWait { + diffPlainActions.forEach { + LocalDatabaseAppLogicActionDispatcher.dispatchAppLogicActionSync( + it, appLogic.database.config().getOwnDeviceIdSync()!!, appLogic.database + ) + } + } + } + } else { + diffPlainActions.forEach { action -> + ApplyActionUtil.applyAppLogicAction( + action = action, + appLogic = appLogic, + ignoreIfDeviceIsNotConfigured = true + ) + } + } + + if (deviceState.isConnectedMode && deviceState.serverApiLevel.hasLevelOrIsOffline(4)) { + CryptoAppListSync.sync( + deviceState = deviceState, + database = appLogic.database, + installed = installed, + syncUtil = appLogic.syncUtil, + disableLegacySync = deviceState.disableLegacySync + ) + } + } + } +} diff --git a/app/src/main/java/io/timelimit/android/logic/crypto/CryptDataHandler.kt b/app/src/main/java/io/timelimit/android/logic/crypto/CryptDataHandler.kt new file mode 100644 index 0000000..28b3de7 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/logic/crypto/CryptDataHandler.kt @@ -0,0 +1,157 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.logic.crypto + +import io.timelimit.android.crypto.CryptContainer +import io.timelimit.android.crypto.CryptException +import io.timelimit.android.crypto.Curve25519 +import io.timelimit.android.data.Database +import io.timelimit.android.data.model.* +import io.timelimit.android.sync.actions.SendKeyRequestAction +import io.timelimit.android.sync.actions.apply.ApplyActionUtil +import io.timelimit.android.sync.network.ServerCryptContainer + +object CryptDataHandler { + fun process(database: Database, data: ServerCryptContainer, type: Int, deviceId: String?, categoryId: String?): Result { + val oldItem = if (deviceId != null) + database.cryptContainer().getCryptoMetadataSyncByDeviceId(deviceId, type) + else if (categoryId != null) + database.cryptContainer().getCryptoMetadataSyncByCategoryId(categoryId, type) + else + database.cryptContainer().getCryptoMetadataSyncByType(type) + + val isUnmodified = if (oldItem != null) { + val oldSavedData = database.cryptContainer().getData(oldItem.cryptContainerId) + + oldSavedData != null && oldSavedData.encryptedData.contentEquals(data.data) + } else false + + val currentItem = if (oldItem == null) { + val baseItem = CryptContainerMetadata( + cryptContainerId = 0, + deviceId = deviceId, + categoryId = categoryId, + type = type, + serverVersion = data.version, + currentGeneration = 0, + currentGenerationFirstTimestamp = System.currentTimeMillis(), + currentGenerationKey = null, + nextCounter = 0, + status = CryptContainerMetadata.ProcessingStatus.MissingKey + ) + + val cryptContainerId = database.cryptContainer().insertMetadata(baseItem) + + baseItem.copy(cryptContainerId = cryptContainerId) + } else oldItem.copy(serverVersion = data.version) + + if (deviceId == database.config().getOwnDeviceIdSync()) { + database.cryptContainer().updateMetadata(currentItem) + + return Result(didCreateKeyRequests = false) + } + + if (!isUnmodified) { + CryptContainerData( + cryptContainerId = currentItem.cryptContainerId, + encryptedData = data.data + ).also { item -> + if (oldItem == null) database.cryptContainer().insertData(item) + else database.cryptContainer().updateData(item) + } + } + + val header = try { + CryptContainer.Header.read(data.data) + } catch (ex: CryptException.InvalidContainer) { + null + } + + val updatedMetadata = if (isUnmodified) { + currentItem + } else if (header == null) { + currentItem.copy(status = CryptContainerMetadata.ProcessingStatus.CryptoDamage) + } else if (header.generation < currentItem.currentGeneration) { + currentItem.copy(status = CryptContainerMetadata.ProcessingStatus.DowngradeDetected) + } else if (header.generation > currentItem.currentGeneration) { + currentItem.copy(status = CryptContainerMetadata.ProcessingStatus.MissingKey) + } else if (header.counter < currentItem.nextCounter) { + currentItem.copy(status = CryptContainerMetadata.ProcessingStatus.DowngradeDetected) + } else if (currentItem.currentGenerationKey == null) { + currentItem.copy(status = CryptContainerMetadata.ProcessingStatus.MissingKey) + } else { + try { + CryptContainer.decrypt(currentItem.currentGenerationKey, data.data) + + currentItem.copy( + status = CryptContainerMetadata.ProcessingStatus.Unprocessed, + nextCounter = header.counter + 1 + ) + } catch (ex: CryptException.WrongKey) { + currentItem.copy(status = CryptContainerMetadata.ProcessingStatus.CryptoDamage) + } + } + + database.cryptContainer().updateMetadata(updatedMetadata) + + if (updatedMetadata.status == CryptContainerMetadata.ProcessingStatus.MissingKey && header != null) { + if (database.cryptContainerKeyRequest().byCryptContainerId(currentItem.cryptContainerId) == null) { + val signingKey = DeviceSigningKey.getPublicAndPrivateKeySync(database)!!.let { Curve25519.getPrivateKey(it) } + val requestKeyPair = Curve25519.generateKeyPair() + val sequenceNumber = database.config().getNextSigningSequenceNumberAndIncrementIt() + + ApplyActionUtil.addAppLogicActionToDatabaseSync( + SendKeyRequestAction( + deviceSequenceNumber = sequenceNumber, + deviceId = deviceId, + categoryId = categoryId, + type = type, + tempKey = Curve25519.getPublicKey(requestKeyPair), + signature = Curve25519.sign( + signingKey, + KeyRequestSignedData( + deviceSequenceNumber = sequenceNumber, + deviceId = deviceId, + categoryId = categoryId, + type = type, + tempKey = Curve25519.getPublicKey( + requestKeyPair + ) + ).serialize() + ) + ), database + ) + + database.cryptContainerKeyRequest().insert( + CryptContainerPendingKeyRequest( + cryptContainerId = currentItem.cryptContainerId, + requestTimeCryptContainerGeneration = header.generation, + requestSequenceId = sequenceNumber, + requestKey = requestKeyPair + ) + ) + + return Result(didCreateKeyRequests = true) + } + } + + return Result(didCreateKeyRequests = false) + } + + data class Result (val didCreateKeyRequests: Boolean) { + fun or(other: Result) = Result(this.didCreateKeyRequests or other.didCreateKeyRequests) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/logic/crypto/CryptoSyncLogic.kt b/app/src/main/java/io/timelimit/android/logic/crypto/CryptoSyncLogic.kt new file mode 100644 index 0000000..db28abd --- /dev/null +++ b/app/src/main/java/io/timelimit/android/logic/crypto/CryptoSyncLogic.kt @@ -0,0 +1,56 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.logic.crypto + +import io.timelimit.android.async.Threads +import io.timelimit.android.coroutines.executeAndWait +import io.timelimit.android.data.Database +import io.timelimit.android.logic.crypto.decrypt.DecryptProcessor +import io.timelimit.android.sync.network.ServerKeyRequest +import io.timelimit.android.sync.network.ServerKeyResponse + +object CryptoSyncLogic { + suspend fun postPullHook( + database: Database, + keyRequests: List, + keyResponses: List + ): Result { + return Threads.database.executeAndWait { + val privateKeyAndPublicKey = DeviceSigningKey.getPublicAndPrivateKeySync(database, uploadIfMissingAtServer = true) + ?: return@executeAndWait Result(didSendReplies = false) + + val keyRequestResult = KeyRequestProcessor.process( + keyRequests = keyRequests, + database = database, + privateKeyAndPublicKey = privateKeyAndPublicKey + ) + + val keyResponseResult = KeyResponseProcessor.process( + keyResponses = keyResponses, + database = database, + privateKeyAndPublicKey = privateKeyAndPublicKey + ) + + DecryptProcessor.handleEncryptedApps(database) + + Result( + didSendReplies = keyRequestResult.didSendReplies or keyResponseResult.gotNewKeys + ) + } + } + + data class Result(val didSendReplies: Boolean) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/logic/crypto/DeviceSigningKey.kt b/app/src/main/java/io/timelimit/android/logic/crypto/DeviceSigningKey.kt new file mode 100644 index 0000000..58a72cb --- /dev/null +++ b/app/src/main/java/io/timelimit/android/logic/crypto/DeviceSigningKey.kt @@ -0,0 +1,50 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.logic.crypto + +import io.timelimit.android.crypto.Curve25519 +import io.timelimit.android.data.Database +import io.timelimit.android.sync.actions.UploadDevicePublicKeyAction +import io.timelimit.android.sync.actions.apply.ApplyActionUtil + +object DeviceSigningKey { + fun getPublicAndPrivateKeySync(database: Database, uploadIfMissingAtServer: Boolean = false): ByteArray? = database.runInTransaction { + if (database.config().getServerApiLevelSync() < 4) return@runInTransaction null + + val oldData = database.config().getSigningKeySync() + + if (oldData != null) { + if (database.deviceKey().getSync(database.config().getOwnDeviceIdSync()!!) == null && uploadIfMissingAtServer) { + ApplyActionUtil.addAppLogicActionToDatabaseSync( + UploadDevicePublicKeyAction(publicKey = Curve25519.getPublicKey(oldData)), + database + ) + } + + return@runInTransaction oldData + } + + val newData = Curve25519.generateKeyPair() + + database.config().setSigningKeySync(newData) + ApplyActionUtil.addAppLogicActionToDatabaseSync( + UploadDevicePublicKeyAction(publicKey = Curve25519.getPublicKey(newData)), + database + ) + + return@runInTransaction newData + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/logic/crypto/KeyRequestProcessor.kt b/app/src/main/java/io/timelimit/android/logic/crypto/KeyRequestProcessor.kt new file mode 100644 index 0000000..327238f --- /dev/null +++ b/app/src/main/java/io/timelimit/android/logic/crypto/KeyRequestProcessor.kt @@ -0,0 +1,184 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.logic.crypto + +import android.util.Log +import io.timelimit.android.BuildConfig +import io.timelimit.android.crypto.CryptContainer +import io.timelimit.android.crypto.Curve25519 +import io.timelimit.android.data.Database +import io.timelimit.android.data.model.CryptContainerMetadata +import io.timelimit.android.sync.actions.ReplyToKeyRequestAction +import io.timelimit.android.sync.actions.apply.ApplyActionUtil +import io.timelimit.android.sync.network.ServerKeyRequest +import javax.crypto.Cipher +import javax.crypto.spec.SecretKeySpec + +object KeyRequestProcessor { + private const val LOG_TAG = "KeyRequestProcessor" + + fun process( + keyRequests: List, + database: Database, + privateKeyAndPublicKey: ByteArray + ): Result { + var didSendReplies = false + + if (keyRequests.isNotEmpty()) { + val lastServerSequence = database.config().getLastServerKeyRequestSequenceSync() + var newServerSequence: Long? = null + + for (request in keyRequests) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "got key request: $request") + } + + if (lastServerSequence != null && request.serverRequestSequenceNumber <= lastServerSequence) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "ignore because server sequence number repeated") + } + + continue + } + + if (newServerSequence == null || newServerSequence < request.serverRequestSequenceNumber) { + newServerSequence = request.serverRequestSequenceNumber + } + + val deviceKey = database.deviceKey().getSync(request.senderDeviceId) + + if (deviceKey == null) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "ignore because no device key is known") + } + + continue + } + + if (request.senderSequenceNumber < deviceKey.nextSequenceNumber) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "ignore because client sequence number repeated") + } + + continue + } + + if (deviceKey.publicKey.size != Curve25519.PUBLIC_KEY_SIZE || request.signature.size != Curve25519.SIGNATURE_SIZE) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "ignore because cryptographic data has the wrong size") + } + + continue + } + + val requestSignedData = KeyRequestSignedData( + deviceSequenceNumber = request.senderSequenceNumber, + deviceId = request.deviceId, + categoryId = request.categoryId, + type = request.type, + tempKey = request.tempKey + ) + + if ( + !Curve25519.validateSignature( + deviceKey.publicKey, + requestSignedData.serialize(), + request.signature + ) + ) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "ignore because the signature is invalid") + } + + continue + } + + database.deviceKey().update(deviceKey.copy(nextSequenceNumber = request.senderSequenceNumber + 1)) + + val metadata = if (request.deviceId != null) { + database.cryptContainer().getCryptoMetadataSyncByDeviceId(request.deviceId, request.type) + } else if (request.categoryId != null) { + database.cryptContainer().getCryptoMetadataSyncByCategoryId(request.categoryId, request.type) + } else { + database.cryptContainer().getCryptoMetadataSyncByType(request.type) + } + + if ( + metadata?.currentGenerationKey == null || + metadata.status == CryptContainerMetadata.ProcessingStatus.MissingKey + ) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "ignore because the key is unknown") + } + + continue + } + + if (metadata.currentGenerationKey.size != CryptContainer.KEY_SIZE) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "ignore because the key has the wrong size") + } + + continue + } + + val selfTempKey = Curve25519.generateKeyPair() + + val sharedSecret = Curve25519.sharedSecret( + publicKey = request.tempKey, + privateKey = Curve25519.getPrivateKey(selfTempKey) + ) + + val encryptedKey = Cipher.getInstance("AES/ECB/NoPadding").let { + // this encrypt just a single block and the integrity is checked using async crypto + // actually, xor could be enough for this case + it.init(Cipher.ENCRYPT_MODE, SecretKeySpec(sharedSecret.copyOfRange(0, CryptContainer.KEY_SIZE), "AES")) + it.doFinal(metadata.currentGenerationKey) + } + + ApplyActionUtil.addAppLogicActionToDatabaseSync( + action = ReplyToKeyRequestAction( + requestServerSequenceNumber = request.serverRequestSequenceNumber, + tempKey = Curve25519.getPublicKey(selfTempKey), + encryptedKey = encryptedKey, + signature = Curve25519.sign( + privateKey = Curve25519.getPrivateKey(privateKeyAndPublicKey), + message = KeyResponseSignedData( + request = requestSignedData, + senderDevicePublicKey = deviceKey.publicKey, + tempKey = Curve25519.getPublicKey(selfTempKey), + encryptedKey = encryptedKey + ).serialize() + ) + ), + database = database + ) + + didSendReplies = true + } + + newServerSequence?.let { + database.config().setLastServerKeyRequestSequenceSync(it) + } + } + + return Result( + didSendReplies = didSendReplies + ) + } + + data class Result(val didSendReplies: Boolean) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/logic/crypto/KeyRequestSignedData.kt b/app/src/main/java/io/timelimit/android/logic/crypto/KeyRequestSignedData.kt new file mode 100644 index 0000000..7ba2cdc --- /dev/null +++ b/app/src/main/java/io/timelimit/android/logic/crypto/KeyRequestSignedData.kt @@ -0,0 +1,83 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.logic.crypto + +import okio.Buffer + +data class KeyRequestSignedData ( + val deviceSequenceNumber: Long, + val deviceId: String?, + val categoryId: String?, + val type: Int, + val tempKey: ByteArray +) { + init { + if (tempKey.size != 32) { + throw IllegalArgumentException() + } + } + + fun serialize(): ByteArray = Buffer().also { buffer -> + fun writeBool(value: Boolean) = buffer.writeByte(when (value) { + false -> 0 + true -> 1 + }) + + fun writeString(value: String) = value.toByteArray(Charsets.UTF_8).also { + buffer.writeInt(it.size) + buffer.write(it) + } + + fun writeOptionalString(value: String?) { + if (value == null) { + writeBool(false) + } else { + writeBool(true) + writeString(value) + } + } + + writeString("KeyRequestSignedData") + buffer.writeLong(deviceSequenceNumber) + writeBool(deviceId != null) + writeOptionalString(deviceId) + writeOptionalString(categoryId) + buffer.writeInt(type) + buffer.write(tempKey) // fixed size => no length prefix required + }.readByteArray() +} + +data class KeyResponseSignedData ( + val request: KeyRequestSignedData, + val senderDevicePublicKey: ByteArray, + val tempKey: ByteArray, + val encryptedKey: ByteArray +) { + fun serialize(): ByteArray = Buffer().also { buffer -> + fun writeByteArray(array: ByteArray) { + buffer.writeInt(array.size) + buffer.write(array) + } + + fun writeString(value: String) = writeByteArray(value.toByteArray(Charsets.UTF_8)) + + writeString("KeyResponseSignedData") + writeByteArray(senderDevicePublicKey) + writeByteArray(tempKey) + writeByteArray(encryptedKey) + buffer.write(request.serialize()) + }.readByteArray() +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/logic/crypto/KeyResponseProcessor.kt b/app/src/main/java/io/timelimit/android/logic/crypto/KeyResponseProcessor.kt new file mode 100644 index 0000000..29b6851 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/logic/crypto/KeyResponseProcessor.kt @@ -0,0 +1,216 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.logic.crypto + +import android.util.Log +import io.timelimit.android.BuildConfig +import io.timelimit.android.crypto.CryptContainer +import io.timelimit.android.crypto.CryptException +import io.timelimit.android.crypto.Curve25519 +import io.timelimit.android.data.Database +import io.timelimit.android.data.model.CryptContainerKeyResult +import io.timelimit.android.data.model.CryptContainerMetadata +import io.timelimit.android.sync.actions.FinishKeyRequestAction +import io.timelimit.android.sync.actions.apply.ApplyActionUtil +import io.timelimit.android.sync.network.ServerKeyResponse +import javax.crypto.Cipher +import javax.crypto.spec.SecretKeySpec + +object KeyResponseProcessor { + private const val LOG_TAG = "KeyResponseProcessor" + + fun process( + keyResponses: List, + database: Database, + privateKeyAndPublicKey: ByteArray + ): Result { + var gotNewKeys = false + + if (keyResponses.isNotEmpty()) { + val lastServerSequence = database.config().getLastServerKeyResponseSequenceSync() + var newServerSequence: Long? = null + + for (response in keyResponses) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "got key response: $response") + } + + if (lastServerSequence != null && response.serverResponseSequenceNumber <= lastServerSequence) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "ignore because server sequence number repeated") + } + + continue + } + + if (newServerSequence == null || newServerSequence < response.serverResponseSequenceNumber) { + newServerSequence = response.serverResponseSequenceNumber + } + + val deviceKey = database.deviceKey().getSync(response.senderDeviceId) + + if (deviceKey == null) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "ignore because no device key is known") + } + + continue + } + + if ( + deviceKey.publicKey.size != Curve25519.PUBLIC_KEY_SIZE || + response.tempKey.size != Curve25519.PUBLIC_KEY_SIZE || + response.signature.size != Curve25519.SIGNATURE_SIZE || + response.encryptedKey.size != CryptContainer.KEY_SIZE + ) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "ignore because cryptographic data has the wrong size") + } + + continue + } + + val requestMeta = database.cryptContainerKeyRequest().byRequestId(response.requestSequenceId) + + if (requestMeta == null) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "ignore because no request was found") + } + + continue + } + + val cryptContainerMeta = database.cryptContainer().getCryptoMetadataSyncByContainerId(requestMeta.cryptContainerId)!! + + if ( + database.cryptContainerKeyResult().countResultItems( + requestSequenceNumber = requestMeta.requestSequenceId, + deviceId = response.senderDeviceId + ) > 0 + ) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "ignore because this device sent already an invalid reply") + } + + continue + } + + val requestSignedData = KeyRequestSignedData( + deviceSequenceNumber = requestMeta.requestSequenceId, + deviceId = cryptContainerMeta.deviceId, + categoryId = cryptContainerMeta.categoryId, + type = cryptContainerMeta.type, + tempKey = Curve25519.getPublicKey(requestMeta.requestKey) + ) + + val responseSignedData = KeyResponseSignedData( + request = requestSignedData, + senderDevicePublicKey = Curve25519.getPublicKey(privateKeyAndPublicKey), + encryptedKey = response.encryptedKey, + tempKey = response.tempKey + ) + + if ( + !Curve25519.validateSignature( + deviceKey.publicKey, + responseSignedData.serialize(), + response.signature + ) + ) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "ignore because the signature is invalid") + } + + continue + } + + val sharedSecret = Curve25519.sharedSecret( + publicKey = response.tempKey, + privateKey = Curve25519.getPrivateKey(requestMeta.requestKey) + ) + + val decryptedKey = Cipher.getInstance("AES/ECB/NoPadding").let { + // this encrypt just a single block and the integrity is checked using async crypto + // actually, xor could be enough for this case + it.init(Cipher.DECRYPT_MODE, SecretKeySpec(sharedSecret.copyOfRange(0, CryptContainer.KEY_SIZE), "AES")) + it.doFinal(response.encryptedKey) + } + + val encryptedData = database.cryptContainer().getData(cryptContainerMeta.cryptContainerId)!! + val encryptedDataHeader = try { + CryptContainer.Header.read(encryptedData.encryptedData) + } catch (ex: CryptException.InvalidContainer) { + null + } + + val isKeyValid = try { + CryptContainer.decrypt(decryptedKey, encryptedData.encryptedData) + + true + } catch (ex: CryptException.WrongKey) { + false + } + + if (isKeyValid && encryptedDataHeader != null) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "got valid key") + } + + gotNewKeys = true + + ApplyActionUtil.addAppLogicActionToDatabaseSync( + action = FinishKeyRequestAction(deviceSequenceNumber = requestMeta.requestSequenceId), + database = database + ) + + database.cryptContainerKeyRequest().delete(requestMeta) + + database.cryptContainer().updateMetadata( + cryptContainerMeta.copy( + currentGeneration = encryptedDataHeader.generation, + currentGenerationFirstTimestamp = System.currentTimeMillis(), + nextCounter = encryptedDataHeader.counter + 1, + currentGenerationKey = decryptedKey, + status = CryptContainerMetadata.ProcessingStatus.Unprocessed + ) + ) + } else { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "got invalid key") + } + + database.cryptContainerKeyResult().insert( + CryptContainerKeyResult( + requestSequenceId = requestMeta.requestSequenceId, + deviceId = response.senderDeviceId, + status = CryptContainerKeyResult.Status.InvalidKey + ) + ) + } + } + + newServerSequence?.let { + database.config().setLastServerKeyResponseSequenceSync(it) + } + } + + return Result(gotNewKeys = gotNewKeys) + } + + data class Result( + val gotNewKeys: Boolean + ) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/logic/crypto/decrypt/DecryptProcessor.kt b/app/src/main/java/io/timelimit/android/logic/crypto/decrypt/DecryptProcessor.kt new file mode 100644 index 0000000..710bd63 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/logic/crypto/decrypt/DecryptProcessor.kt @@ -0,0 +1,184 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.logic.crypto.decrypt + +import android.util.Log +import io.timelimit.android.BuildConfig +import io.timelimit.android.crypto.CryptContainer +import io.timelimit.android.crypto.CryptException +import io.timelimit.android.data.Database +import io.timelimit.android.data.dao.CryptContainerDao +import io.timelimit.android.data.model.App +import io.timelimit.android.data.model.AppActivity +import io.timelimit.android.data.model.CryptContainerMetadata +import io.timelimit.android.proto.decodeInflated +import io.timelimit.android.proto.toDb +import io.timelimit.proto.applist.InstalledAppsProto +import io.timelimit.proto.applist.SavedAppsDifferenceProto +import java.io.IOException + +object DecryptProcessor { + private const val LOG_TAG = "DecryptProcessor" + + fun handleEncryptedApps(database: Database) { + val unprocessed = database.cryptContainer().getMetadataByProcessingStatus(CryptContainerMetadata.ProcessingStatus.Unprocessed) + val finishedDeviceIds = mutableSetOf() + + for (metadata in unprocessed) { + if ( + metadata.type == CryptContainerMetadata.TYPE_APP_LIST_BASE || + metadata.type == CryptContainerMetadata.TYPE_APP_LIST_DIFF + ) { + if ( + metadata.deviceId == null || + finishedDeviceIds.contains(metadata.deviceId) || + metadata.deviceId == database.config().getOwnDeviceIdSync() + ) continue + + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "found data for ${metadata.deviceId}") + } + + finishedDeviceIds.add(metadata.deviceId) + + val baseData = database.cryptContainer().getCryptoFullDataSyncByDeviceId(metadata.deviceId, CryptContainerMetadata.TYPE_APP_LIST_BASE) ?: continue + val diffData = database.cryptContainer().getCryptoFullDataSyncByDeviceId(metadata.deviceId, CryptContainerMetadata.TYPE_APP_LIST_DIFF) ?: continue + + if (!(isReadyForProcessing(baseData) && isReadyForProcessing(diffData))) continue + + val (baseDecrypted, baseHeader) = try { + CryptContainer.decrypt( + baseData.metadata.currentGenerationKey ?: continue, + baseData.encryptedData + ) to CryptContainer.Header.read(baseData.encryptedData) + } catch (ex: CryptException) { + database.cryptContainer().updateMetadata(baseData.metadata.copy(status = CryptContainerMetadata.ProcessingStatus.CryptoDamage)) + + continue + } + + val diffDecrypted = try { + CryptContainer.decrypt( + diffData.metadata.currentGenerationKey ?: continue, + diffData.encryptedData + ) + } catch (ex: CryptException) { + database.cryptContainer().updateMetadata(diffData.metadata.copy(status = CryptContainerMetadata.ProcessingStatus.CryptoDamage)) + + continue + } + + val baseContent = try { + InstalledAppsProto.ADAPTER.decodeInflated(baseDecrypted) + } catch (ex: IOException) { + database.cryptContainer().updateMetadata(baseData.metadata.copy(status = CryptContainerMetadata.ProcessingStatus.ContentDamage)) + + continue + } + + val diffContent = try { + SavedAppsDifferenceProto.ADAPTER.decodeInflated(diffDecrypted) + } catch (ex: IOException) { + database.cryptContainer().updateMetadata(diffData.metadata.copy(status = CryptContainerMetadata.ProcessingStatus.ContentDamage)) + + continue + } + + if ( + diffContent.base_generation != baseHeader.generation || + diffContent.base_counter != baseHeader.counter + ) { + database.cryptContainer().updateMetadata(diffData.metadata.copy(status = CryptContainerMetadata.ProcessingStatus.ContentDamage)) + + continue + } + + database.app().deleteAllAppsByDeviceId(metadata.deviceId) + database.appActivity().deleteAppActivitiesByDeviceIds(listOf(metadata.deviceId)) + + database.app().addAppsSync( + baseContent.apps.map { + App( + deviceId = metadata.deviceId, + packageName = it.package_name, + title = it.title, + isLaunchable = it.is_launchable, + recommendation = it.recommendation.toDb() + ) + } + ) + + database.appActivity().addAppActivitiesSync( + baseContent.activities.map { + AppActivity( + deviceId = metadata.deviceId, + appPackageName = it.package_name, + activityClassName = it.class_name, + title = it.title + ) + } + ) + + database.app().removeAppsByDeviceIdAndPackageNamesSync( + metadata.deviceId, + diffContent.apps?.removed_packages ?: emptyList() + ) + + diffContent.apps?.removed_activities?.forEach { + database.appActivity().deleteAppActivitiesSync( + deviceId = metadata.deviceId, + packageName = it.package_name, + activities = listOf(it.class_name) + ) + } + + database.app().addAppsSync( + diffContent.apps?.added?.apps?.map { + App( + deviceId = metadata.deviceId, + packageName = it.package_name, + title = it.title, + isLaunchable = it.is_launchable, + recommendation = it.recommendation.toDb() + ) + } ?: emptyList() + ) + + database.appActivity().addAppActivitiesSync( + diffContent.apps?.added?.activities?.map { + AppActivity( + deviceId = metadata.deviceId, + appPackageName = it.package_name, + activityClassName = it.class_name, + title = it.title + ) + } ?: emptyList() + ) + + database.cryptContainer().updateMetadata(listOf( + baseData.metadata.copy(status = CryptContainerMetadata.ProcessingStatus.Finished), + diffData.metadata.copy(status = CryptContainerMetadata.ProcessingStatus.Finished) + )) + } + } + } + + private fun isReadyForProcessing(value: CryptContainerDao.MetadataAndContent) = when (value.metadata.status) { + CryptContainerMetadata.ProcessingStatus.Unprocessed -> true + CryptContainerMetadata.ProcessingStatus.Finished -> true + else -> false + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/proto/AppList.kt b/app/src/main/java/io/timelimit/android/proto/AppList.kt new file mode 100644 index 0000000..dcd50b2 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/proto/AppList.kt @@ -0,0 +1,75 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.proto + +import io.timelimit.android.crypto.CryptContainer +import io.timelimit.android.data.model.App +import io.timelimit.android.data.model.AppActivity +import io.timelimit.android.data.model.AppRecommendation +import io.timelimit.android.sync.actions.AppActivityItem +import io.timelimit.android.sync.actions.InstalledApp +import io.timelimit.proto.applist.InstalledAppActivityProto +import io.timelimit.proto.applist.InstalledAppProto +import io.timelimit.proto.applist.InstalledAppsDifferenceProto +import io.timelimit.proto.applist.SavedAppsDifferenceProto + +fun InstalledAppProto.Recommendation.toDb(): AppRecommendation = when (this) { + InstalledAppProto.Recommendation.NONE -> AppRecommendation.None + InstalledAppProto.Recommendation.WHITELIST -> AppRecommendation.Whitelist + InstalledAppProto.Recommendation.BLACKLIST -> AppRecommendation.Blacklist +} + +fun AppRecommendation.toProto(): InstalledAppProto.Recommendation = when (this) { + AppRecommendation.None -> InstalledAppProto.Recommendation.NONE + AppRecommendation.Whitelist -> InstalledAppProto.Recommendation.WHITELIST + AppRecommendation.Blacklist -> InstalledAppProto.Recommendation.BLACKLIST +} + +fun InstalledAppProto.toInstalledApp(): InstalledApp = InstalledApp( + packageName = this.package_name, + title = this.title, + recommendation = this.recommendation.toDb(), + isLaunchable = this.is_launchable +) + +fun App.toProto(): InstalledAppProto = InstalledAppProto( + package_name = this.packageName, + title = this.title, + is_launchable = this.isLaunchable, + recommendation = this.recommendation.toProto() +) + +fun InstalledAppActivityProto.toAppActivityItem(): AppActivityItem = AppActivityItem( + packageName = this.package_name, + className = this.class_name, + title = this.title +) + +fun AppActivity.toProto(): InstalledAppActivityProto = InstalledAppActivityProto( + package_name = this.appPackageName, + class_name = this.activityClassName, + title = this.title +) + +fun SavedAppsDifferenceProto.Companion.build(header: CryptContainer.Header, diff: InstalledAppsDifferenceProto): SavedAppsDifferenceProto = + SavedAppsDifferenceProto( + base_generation = header.generation, + base_counter = header.counter, + apps = diff + ) + +fun SavedAppsDifferenceProto.Companion.build(encryptedBaseData: ByteArray, diff: InstalledAppsDifferenceProto): SavedAppsDifferenceProto = + build(CryptContainer.Header.read(encryptedBaseData), diff) \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/proto/Compression.kt b/app/src/main/java/io/timelimit/android/proto/Compression.kt new file mode 100644 index 0000000..60fe48b --- /dev/null +++ b/app/src/main/java/io/timelimit/android/proto/Compression.kt @@ -0,0 +1,32 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.proto + +import com.squareup.wire.Message +import com.squareup.wire.ProtoAdapter +import okio.Buffer +import okio.use +import java.io.ByteArrayInputStream +import java.util.zip.DeflaterOutputStream +import java.util.zip.InflaterInputStream + +fun , B : Message.Builder> Message.encodeDeflated(): ByteArray = Buffer().also { buffer -> + DeflaterOutputStream(buffer.outputStream()).use { this.encode(it) } +}.readByteArray() + +fun ProtoAdapter.decodeInflated(input: ByteArray): T = InflaterInputStream(ByteArrayInputStream(input)).use { + this.decode(it) +} \ No newline at end of file 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 ae54fdf..bd03a9f 100644 --- a/app/src/main/java/io/timelimit/android/sync/ApplyServerDataStatus.kt +++ b/app/src/main/java/io/timelimit/android/sync/ApplyServerDataStatus.kt @@ -21,21 +21,21 @@ import io.timelimit.android.coroutines.runAsync import io.timelimit.android.data.Database import io.timelimit.android.data.model.* import io.timelimit.android.integration.platform.PlatformIntegration -import io.timelimit.android.sync.actions.DatabaseValidation -import io.timelimit.android.sync.actions.DeleteCategoryAction -import io.timelimit.android.sync.actions.RemoveUserAction +import io.timelimit.android.logic.crypto.CryptDataHandler +import io.timelimit.android.sync.actions.* import io.timelimit.android.sync.actions.dispatch.LocalDatabaseParentActionDispatcher +import io.timelimit.android.sync.network.ServerCryptContainer import io.timelimit.android.sync.network.ServerDataStatus object ApplyServerDataStatus { - suspend fun applyServerDataStatusCoroutine(status: ServerDataStatus, database: Database, platformIntegration: PlatformIntegration) { - Threads.database.executeAndWait { + suspend fun applyServerDataStatusCoroutine(status: ServerDataStatus, database: Database, platformIntegration: PlatformIntegration): Result { + return Threads.database.executeAndWait { applyServerDataStatusSync(status, database, platformIntegration) } } - fun applyServerDataStatusSync(status: ServerDataStatus, database: Database, platformIntegration: PlatformIntegration) { - database.runInTransaction { + fun applyServerDataStatusSync(status: ServerDataStatus, database: Database, platformIntegration: PlatformIntegration): Result { + return database.runInTransaction { // this would override some local data which was not sent yet // so it's better to cancel in this case (or, more complicated, // apply the delta which was not sent locally) @@ -53,6 +53,8 @@ object ApplyServerDataStatus { database.config().setServerApiLevelSync(status.apiLevel) } + var didCreateNewActions = false + run { val newUserList = status.newUserList @@ -123,9 +125,10 @@ object ApplyServerDataStatus { } } - run { + val newDeviceTitles = run { // apply new device list val newDeviceList = status.newDeviceList + val newDeviceTitles = mutableListOf() if (newDeviceList != null) { val oldDeviceList = database.device().getAllDevicesSync() @@ -191,6 +194,8 @@ object ApplyServerDataStatus { qOrLater = newDevice.qOrLater, manipulationFlags = newDevice.manipulationFlags )) + + newDeviceTitles.add(newDevice.name) } else { // eventually update old entry @@ -239,44 +244,85 @@ object ApplyServerDataStatus { } } } + + if (newDevice.publicKey != null) { + val entry = database.deviceKey().getSync(newDevice.deviceId) + + if (entry == null) { + database.deviceKey().insert(DevicePublicKey( + deviceId = newDevice.deviceId, + publicKey = newDevice.publicKey, + nextSequenceNumber = 0 + )) + } + } } } database.config().setDeviceListVersionSync(newDeviceList.version) } + + newDeviceTitles.toList() } run { - status.newInstalledApps.forEach { - item -> + status.updatedExtendedDeviceData.forEach { item -> + fun handle(data: ServerCryptContainer, type: Int) { + val result = CryptDataHandler.process( + database = database, + data = data, + type = type, + categoryId = null, + deviceId = item.deviceId + ) + if (result.didCreateKeyRequests) { + didCreateNewActions = true + } + } + + item.appsBase?.let { handle(it, CryptContainerMetadata.TYPE_APP_LIST_BASE) } + item.appsDiff?.let { handle(it, CryptContainerMetadata.TYPE_APP_LIST_DIFF) } + } + } + + run { + val disableLegacySync = database.config().isExperimentalFlagsSetSync(ExperimentalFlags.DISABLE_LEGACY_APP_SENDING) + + for (item in status.newInstalledApps) { DatabaseValidation.assertDeviceExists(database, item.deviceId) - run { - // apply apps - database.app().deleteAllAppsByDeviceId(item.deviceId) - database.app().addAppsSync(item.apps.map { - App( + if ( + database.cryptContainer().getCryptoMetadataSyncByDeviceId(item.deviceId, CryptContainerMetadata.TYPE_APP_LIST_BASE) == null || + (item.deviceId == database.config().getOwnDeviceIdSync() && !disableLegacySync) + ) { + run { + // apply apps + database.app().deleteAllAppsByDeviceId(item.deviceId) + database.app().addAppsSync(item.apps.map { + App( deviceId = item.deviceId, packageName = it.packageName, title = it.title, isLaunchable = it.isLaunchable, recommendation = it.recommendation - ) - }) - } + ) + }) + } - run { - // apply activities - database.appActivity().deleteAppActivitiesByDeviceIds(listOf(item.deviceId)) - database.appActivity().addAppActivitiesSync(item.activities.map { - AppActivity( + run { + // apply activities + database.appActivity() + .deleteAppActivitiesByDeviceIds(listOf(item.deviceId)) + database.appActivity().addAppActivitiesSync(item.activities.map { + AppActivity( deviceId = item.deviceId, appPackageName = it.packageName, activityClassName = it.className, title = it.title - ) - }) + ) + }) + } } run { @@ -565,8 +611,24 @@ object ApplyServerDataStatus { } } } + + Result( + newDeviceTitles = newDeviceTitles, + didCreateNewActions = didCreateNewActions + ) + } + } + + fun postNotifications(result: Result, platformIntegration: PlatformIntegration) { + result.newDeviceTitles.forEach { deviceTitle -> + platformIntegration.showNewDeviceNotification(deviceTitle) } } class PendingSyncActionException: RuntimeException() + + data class Result ( + val newDeviceTitles: List, + val didCreateNewActions: Boolean + ) } diff --git a/app/src/main/java/io/timelimit/android/sync/SyncUtil.kt b/app/src/main/java/io/timelimit/android/sync/SyncUtil.kt index 5ca4833..89c5c10 100644 --- a/app/src/main/java/io/timelimit/android/sync/SyncUtil.kt +++ b/app/src/main/java/io/timelimit/android/sync/SyncUtil.kt @@ -27,6 +27,7 @@ import io.timelimit.android.coroutines.runAsync import io.timelimit.android.livedata.* import io.timelimit.android.logic.AppLogic import io.timelimit.android.logic.ServerLogic +import io.timelimit.android.logic.crypto.CryptoSyncLogic import io.timelimit.android.sync.actions.apply.UploadActionsUtil import io.timelimit.android.sync.network.ClientDataStatus import io.timelimit.android.sync.network.api.UnauthorizedHttpError @@ -246,9 +247,19 @@ class SyncUtil (private val logic: AppLogic) { } private suspend fun pullStatus(server: ServerLogic.ServerConfig) { - val currentStatus = ClientDataStatus.getClientDataStatusAsync(logic.database) + val currentStatus = Threads.database.executeAndWait { ClientDataStatus.getClientDataStatusSync(logic.database) } val serverResponse = server.api.pullChanges(server.deviceAuthToken, currentStatus) - ApplyServerDataStatus.applyServerDataStatusCoroutine(serverResponse, logic.database, logic.platformIntegration) + val applyResult = ApplyServerDataStatus.applyServerDataStatusCoroutine(serverResponse, logic.database, logic.platformIntegration) + ApplyServerDataStatus.postNotifications(applyResult, logic.platformIntegration) + val cryptoResult = CryptoSyncLogic.postPullHook(logic.database, serverResponse.pendingKeyRequests, serverResponse.keyResponses) + + if (applyResult.didCreateNewActions or cryptoResult.didSendReplies) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "request next sync by crypto handling") + } + + requestImportantSync() + } } private suspend fun wipeCacheIfUpdated() { 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 9fd493b..163524f 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 @@ -23,6 +23,7 @@ import io.timelimit.android.data.customtypes.ImmutableBitmask import io.timelimit.android.data.customtypes.ImmutableBitmaskJson import io.timelimit.android.data.model.* import io.timelimit.android.extensions.MinuteOfDay +import io.timelimit.android.extensions.base64 import io.timelimit.android.integration.platform.* import io.timelimit.android.sync.network.ParentPassword import io.timelimit.android.sync.validation.ListValidation @@ -468,6 +469,144 @@ data class UpdateAppActivitiesAction( writer.endObject() } } +data class UpdateInstalledAppsAction ( + val base: ByteArray?, + val diff: ByteArray?, + val wipe: Boolean +): AppLogicAction() { + companion object { + private const val TYPE_VALUE = "UPDATE_INSTALLED_APPS" + private const val BASE = "b" + private const val DIFF = "d" + private const val WIPE = "w" + } + + override fun serialize(writer: JsonWriter) { + writer.beginObject() + + writer.name(TYPE).value(TYPE_VALUE) + + base?.let { writer.name(BASE).value(it.base64()) } + diff?.let { writer.name(DIFF).value(it.base64()) } + + writer.name(WIPE).value(wipe) + + writer.endObject() + } +} +data class UploadDevicePublicKeyAction (val publicKey: ByteArray): AppLogicAction() { + companion object { + private const val TYPE_VALUE = "UPLOAD_DEVICE_PUBLIC_KEY" + private const val KEY = "key" + } + + init { + if (publicKey.size != 32) throw IllegalArgumentException() + } + + override fun serialize(writer: JsonWriter) { + writer.beginObject() + writer.name(TYPE).value(TYPE_VALUE) + writer.name(KEY).value(publicKey.base64()) + writer.endObject() + } +} +data class SendKeyRequestAction( + val deviceSequenceNumber: Long, + val deviceId: String?, + val categoryId: String?, + val type: Int, + val tempKey: ByteArray, + val signature: ByteArray +): AppLogicAction() { + companion object { + private const val TYPE_VALUE = "SEND_KEY_REQUEST" + private const val SEQ_NUM = "dsn" + private const val DEVICE_ID = "deviceId" + private const val CATEGORY_ID = "categoryId" + private const val DATA_TYPE = "dataType" + private const val TEMP_KEY = "tempKey" + private const val SIGNATURE = "signature" + } + + init { + if (tempKey.size != 32 || signature.size != 64) { + throw IllegalArgumentException() + } + + if (deviceId != null && categoryId != null) { + throw IllegalArgumentException() + } + + deviceId?.also { IdGenerator.assertIdValid(it) } + categoryId?.also { IdGenerator.assertIdValid(it) } + + if (!CryptContainerMetadata.isTypeValid(type)) { + throw IllegalArgumentException() + } + } + + override fun serialize(writer: JsonWriter) { + writer.beginObject() + + writer.name(TYPE).value(TYPE_VALUE) + writer.name(SEQ_NUM).value(deviceSequenceNumber) + deviceId?.let { writer.name(DEVICE_ID).value(it) } + categoryId?.let { writer.name(CATEGORY_ID).value(it) } + writer.name(DATA_TYPE).value(type) + writer.name(TEMP_KEY).value(tempKey.base64()) + writer.name(SIGNATURE).value(signature.base64()) + + writer.endObject() + } +} +data class FinishKeyRequestAction(val deviceSequenceNumber: Long): AppLogicAction() { + companion object { + private const val TYPE_VALUE = "FINISH_KEY_REQUEST" + private const val SEQ_NUM = "dsn" + } + + override fun serialize(writer: JsonWriter) { + writer.beginObject() + + writer.name(TYPE).value(TYPE_VALUE) + writer.name(SEQ_NUM).value(deviceSequenceNumber) + + writer.endObject() + } +} +data class ReplyToKeyRequestAction( + val requestServerSequenceNumber: Long, + val tempKey: ByteArray, + val encryptedKey: ByteArray, + val signature: ByteArray +): AppLogicAction() { + companion object { + private const val TYPE_VALUE = "REPLY_TO_KEY_REQUEST" + private const val REQUEST_SEQUENCE_NUMBER = "rsn" + private const val TEMP_KEY = "tempKey" + private const val ENCRYPTED_KEY = "encryptedKey" + private const val SIGNATURE = "signature" + } + + init { + if (tempKey.size != 32 || encryptedKey.size != 16 || signature.size != 64) { + throw IllegalArgumentException() + } + } + + override fun serialize(writer: JsonWriter) { + writer.beginObject() + + writer.name(TYPE).value(TYPE_VALUE) + writer.name(REQUEST_SEQUENCE_NUMBER).value(requestServerSequenceNumber) + writer.name(TEMP_KEY).value(tempKey.base64()) + writer.name(ENCRYPTED_KEY).value(encryptedKey.base64()) + writer.name(SIGNATURE).value(signature.base64()) + + writer.endObject() + } +} object SignOutAtDeviceAction: AppLogicAction() { const val TYPE_VALUE = "SIGN_OUT_AT_DEVICE" diff --git a/app/src/main/java/io/timelimit/android/sync/actions/apply/ApplyAction.kt b/app/src/main/java/io/timelimit/android/sync/actions/apply/ApplyAction.kt index fd8e508..9de1c8f 100644 --- a/app/src/main/java/io/timelimit/android/sync/actions/apply/ApplyAction.kt +++ b/app/src/main/java/io/timelimit/android/sync/actions/apply/ApplyAction.kt @@ -38,6 +38,19 @@ import java.io.StringWriter object ApplyActionUtil { private const val LOG_TAG = "ApplyActionUtil" + fun addAppLogicActionToDatabaseSync(action: AppLogicAction, database: Database) = database.runInUnobservedTransaction { + database.pendingSyncAction().addSyncActionSync( + PendingSyncAction( + sequenceNumber = database.config().getNextSyncActionSequenceActionAndIncrementIt(), + scheduledForUpload = false, + type = PendingSyncActionType.AppLogic, + userId = "", + integrity = "", + encodedAction = SerializationUtil.serializeAction(action) + ) + ) + } + suspend fun applyAppLogicAction( action: AppLogicAction, appLogic: AppLogic, @@ -163,20 +176,7 @@ object ApplyActionUtil { } } - val serializedAction = StringWriter().apply { - JsonWriter(this).apply { - action.serialize(this) - } - }.toString() - - database.pendingSyncAction().addSyncActionSync(PendingSyncAction( - sequenceNumber = database.config().getNextSyncActionSequenceActionAndIncrementIt(), - encodedAction = serializedAction, - integrity = "", - scheduledForUpload = false, - type = PendingSyncActionType.AppLogic, - userId = "" - )) + addAppLogicActionToDatabaseSync(action, database) if (action is AddUsedTimeActionVersion2) { syncUtil.requestVeryUnimportantSync() diff --git a/app/src/main/java/io/timelimit/android/sync/actions/apply/UploadActionsUtil.kt b/app/src/main/java/io/timelimit/android/sync/actions/apply/UploadActionsUtil.kt index 966e2c4..22e9e70 100644 --- a/app/src/main/java/io/timelimit/android/sync/actions/apply/UploadActionsUtil.kt +++ b/app/src/main/java/io/timelimit/android/sync/actions/apply/UploadActionsUtil.kt @@ -26,12 +26,15 @@ class UploadActionsUtil(private val database: Database, private val syncConflict companion object { private const val BATCH_SIZE = 25 - fun deleteAllVersionNumbersSync(database: Database) { + fun deleteAllVersionNumbersSync(database: Database, wipeCryptoRequests: Boolean = false) { database.runInTransaction { database.config().setUserListVersionSync("") database.config().setDeviceListVersionSync("") database.device().deleteAllInstalledAppsVersions() database.category().deleteAllCategoriesVersionNumbers() + database.cryptContainer().deleteAllServerVersionNumbers() + + if (wipeCryptoRequests) { database.cryptContainerKeyRequest().deleteAll() } } } } diff --git a/app/src/main/java/io/timelimit/android/sync/actions/dispatch/AppLogicAction.kt b/app/src/main/java/io/timelimit/android/sync/actions/dispatch/AppLogicAction.kt index e3231b0..e0bcf10 100644 --- a/app/src/main/java/io/timelimit/android/sync/actions/dispatch/AppLogicAction.kt +++ b/app/src/main/java/io/timelimit/android/sync/actions/dispatch/AppLogicAction.kt @@ -388,6 +388,11 @@ object LocalDatabaseAppLogicActionDispatcher { database.childTasks().updateItemSync(task.copy(pendingRequest = true)) } + is UpdateInstalledAppsAction -> {/* nothing to do, this is only for the server */} + is UploadDevicePublicKeyAction -> {/* nothing to do, this is only for the server */} + is SendKeyRequestAction -> {/* nothing to do, this is only for the server */} + is FinishKeyRequestAction -> {/* nothing to do, this is only for the server */} + is ReplyToKeyRequestAction -> {/* nothing to do, this is only for the server */} }.let { } } } diff --git a/app/src/main/java/io/timelimit/android/sync/network/ClientDataStatus.kt b/app/src/main/java/io/timelimit/android/sync/network/ClientDataStatus.kt index 01b3aca..62a8b65 100644 --- a/app/src/main/java/io/timelimit/android/sync/network/ClientDataStatus.kt +++ b/app/src/main/java/io/timelimit/android/sync/network/ClientDataStatus.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 Jonas Lochmann + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,16 +17,15 @@ package io.timelimit.android.sync.network import android.util.JsonWriter import io.timelimit.android.data.Database -import io.timelimit.android.livedata.map -import io.timelimit.android.livedata.waitForNonNullValue -import java.util.* -import kotlin.collections.HashMap data class ClientDataStatus( val deviceListVersion: String, val installedAppsVersionsByDeviceId: Map, + val deviceDetailData: Map, val categories: Map, - val userListVersion: String + val userListVersion: String, + val lastKeyRequestServerSequence: Long?, + val lastKeyResponseServerSequence: Long? ) { companion object { private const val DEVICES = "devices" @@ -34,44 +33,59 @@ data class ClientDataStatus( private const val CATEGORIES = "categories" private const val USERS = "users" private const val CLIENT_LEVEL = "clientLevel" - private const val CLIENT_LEVEL_VALUE = 3 + private const val DEVICES_DETAIL = "devicesDetail" + private const val LAST_KEY_REQUEST_SEQUENCE = "kri" + private const val LAST_KEY_RESPONSE_SEQUENCE = "kr" + private const val CLIENT_LEVEL_VALUE = 4 val empty = ClientDataStatus( - deviceListVersion = "", - installedAppsVersionsByDeviceId = Collections.emptyMap(), - categories = Collections.emptyMap(), - userListVersion = "" + deviceListVersion = "", + installedAppsVersionsByDeviceId = emptyMap(), + deviceDetailData = emptyMap(), + categories = emptyMap(), + userListVersion = "", + lastKeyRequestServerSequence = null, + lastKeyResponseServerSequence = null ) - suspend fun getClientDataStatusAsync(database: Database): ClientDataStatus { - return ClientDataStatus( - deviceListVersion = database.config().getDeviceListVersion().waitForNonNullValue(), - installedAppsVersionsByDeviceId = database.device().getInstalledAppsVersions().map { - val devicesWithAppVersions = it - val result = HashMap() + fun getClientDataStatusSync(database: Database): ClientDataStatus { + return database.runInUnobservedTransaction { + ClientDataStatus( + deviceListVersion = database.config().getDeviceListVersionSync(), + installedAppsVersionsByDeviceId = database.device() + .getInstalledAppsVersionsSync() + .associateBy { it.deviceId } + .mapValues { it.value.installedAppsVersions }, + deviceDetailData = if (database.config().getServerApiLevelSync() >= 4) + database.device().getDeviceDetailDataSync() + .associateBy { it.deviceId } + .mapValues { + val item = it.value - devicesWithAppVersions.forEach { result[it.deviceId] = it.installedAppsVersions } + DeviceDataStatus( + appsBaseVersion = item.appBaseVersion, + appsDiffVersion = item.appDiffVersion + ) + } + else emptyMap(), + categories = database.category().getCategoriesWithVersionNumbersSybc() + .associateBy { it.categoryId } + .mapValues { + val item = it.value - Collections.unmodifiableMap(result) - }.waitForNonNullValue(), - categories = database.category().getCategoriesWithVersionNumbers().map { - val categoriesWithVersions = it - val result = HashMap() - - categoriesWithVersions.forEach { - result[it.categoryId] = CategoryDataStatus( - baseVersion = it.baseVersion, - assignedAppsVersion = it.assignedAppsVersion, - timeLimitRulesVersion = it.timeLimitRulesVersion, - usedTimeItemsVersion = it.usedTimeItemsVersion, - taskListVersion = it.taskListVersion + CategoryDataStatus( + baseVersion = item.baseVersion, + assignedAppsVersion = item.assignedAppsVersion, + timeLimitRulesVersion = item.timeLimitRulesVersion, + usedTimeItemsVersion = item.usedTimeItemsVersion, + taskListVersion = item.taskListVersion ) - } - - Collections.unmodifiableMap(result) - }.waitForNonNullValue(), - userListVersion = database.config().getUserListVersion().waitForNonNullValue() - ) + }, + userListVersion = database.config().getUserListVersionSync(), + lastKeyRequestServerSequence = database.config().getLastServerKeyRequestSequenceSync(), + lastKeyResponseServerSequence = database.config().getLastServerKeyResponseSequenceSync() + ) + } } } @@ -89,6 +103,16 @@ data class ClientDataStatus( } writer.endObject() + if (deviceDetailData.isNotEmpty()) { + writer.name(DEVICES_DETAIL) + writer.beginObject() + deviceDetailData.entries.forEach { + writer.name(it.key) + it.value.serialize(writer) + } + writer.endObject() + } + writer.name(CATEGORIES) writer.beginObject() categories.entries.forEach { @@ -97,6 +121,9 @@ data class ClientDataStatus( } writer.endObject() + lastKeyRequestServerSequence?.let { writer.name(LAST_KEY_REQUEST_SEQUENCE).value(it) } + lastKeyResponseServerSequence?.let { writer.name(LAST_KEY_RESPONSE_SEQUENCE).value(it) } + writer.endObject() } } @@ -126,6 +153,25 @@ data class CategoryDataStatus( if (taskListVersion.isNotEmpty()) writer.name(TASK_LIST_VERSION).value(taskListVersion) + writer.endObject() + } +} + +data class DeviceDataStatus ( + val appsBaseVersion: String?, + val appsDiffVersion: String? +) { + companion object { + private const val APPS_BASE_VERSION = "appsB" + private const val APPS_DIFF_VERSION = "appsD" + } + + fun serialize(writer: JsonWriter) { + writer.beginObject() + + appsBaseVersion?.let { writer.name(APPS_BASE_VERSION).value(it) } + appsDiffVersion?.let { writer.name(APPS_DIFF_VERSION).value(it) } + writer.endObject() } } \ No newline at end of file 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 5969633..87a0b68 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 @@ -23,6 +23,7 @@ import io.timelimit.android.data.customtypes.ImmutableBitmask import io.timelimit.android.data.customtypes.ImmutableBitmaskJson import io.timelimit.android.data.model.* import io.timelimit.android.extensions.MinuteOfDay +import io.timelimit.android.extensions.parseBase64 import io.timelimit.android.extensions.parseList import io.timelimit.android.integration.platform.* import io.timelimit.android.sync.actions.AppActivityItem @@ -34,6 +35,7 @@ import kotlin.collections.ArrayList data class ServerDataStatus( val newDeviceList: ServerDeviceList?, + val updatedExtendedDeviceData: List, val newInstalledApps: List, val removedCategories: List, val newCategoryBaseData: List, @@ -42,12 +44,15 @@ data class ServerDataStatus( val newCategoryTimeLimitRules: List, val newCategoryTasks: List, val newUserList: ServerUserList?, + val pendingKeyRequests: List, + val keyResponses: List, val fullVersionUntil: Long, val message: String?, val apiLevel: Int ) { companion object { private const val NEW_DEVICE_LIST = "devices" + private const val UPDATED_EXTENDED_DEVICE_DATA = "devices2" private const val NEW_INSTALLED_APPS = "apps" private const val REMOVED_CATEGORIES = "rmCategories" private const val NEW_CATEGORIES_BASE_DATA = "categoryBase" @@ -56,12 +61,15 @@ data class ServerDataStatus( private const val NEW_CATEGORY_TIME_LIMIT_RULES = "rules" private const val NEW_CATEGORY_TASKS = "tasks" private const val NEW_USER_LIST = "users" + private const val PENDING_KEY_REQUESTS = "krq" + private const val KEY_RESPONSES = "kr" private const val FULL_VERSION_UNTIL = "fullVersion" private const val MESSAGE = "message" private const val API_LEVEL = "apiLevel" fun parse(reader: JsonReader): ServerDataStatus { var newDeviceList: ServerDeviceList? = null + var updatedExtendedDeviceData: List = emptyList() var newInstalledApps: List = Collections.emptyList() var removedCategories: List = Collections.emptyList() var newCategoryBaseData: List = Collections.emptyList() @@ -70,6 +78,8 @@ data class ServerDataStatus( var newCategoryTimeLimitRules: List = Collections.emptyList() var newCategoryTasks: List = emptyList() var newUserList: ServerUserList? = null + var pendingKeyRequests = emptyList() + var keyResponses = emptyList() var fullVersionUntil: Long? = null var message: String? = null var apiLevel = 0 @@ -78,6 +88,7 @@ data class ServerDataStatus( while (reader.hasNext()) { when(reader.nextName()) { NEW_DEVICE_LIST -> newDeviceList = ServerDeviceList.parse(reader) + UPDATED_EXTENDED_DEVICE_DATA -> updatedExtendedDeviceData = parseJsonArray(reader, { ServerExtendedDeviceData.parse(reader) }) NEW_INSTALLED_APPS -> newInstalledApps = ServerInstalledAppsData.parseList(reader) REMOVED_CATEGORIES -> removedCategories = parseJsonStringArray(reader) NEW_CATEGORIES_BASE_DATA -> newCategoryBaseData = ServerUpdatedCategoryBaseData.parseList(reader) @@ -86,6 +97,8 @@ data class ServerDataStatus( NEW_CATEGORY_TIME_LIMIT_RULES -> newCategoryTimeLimitRules = ServerUpdatedTimeLimitRules.parseList(reader) NEW_CATEGORY_TASKS -> newCategoryTasks = ServerUpdatedCategoryTasks.parseList(reader) NEW_USER_LIST -> newUserList = ServerUserList.parse(reader) + PENDING_KEY_REQUESTS -> pendingKeyRequests = ServerKeyRequest.parseList(reader) + KEY_RESPONSES -> keyResponses = ServerKeyResponse.parseList(reader) FULL_VERSION_UNTIL -> fullVersionUntil = reader.nextLong() MESSAGE -> message = reader.nextString() API_LEVEL -> apiLevel = reader.nextInt() @@ -95,18 +108,21 @@ data class ServerDataStatus( reader.endObject() return ServerDataStatus( - newDeviceList = newDeviceList, - newInstalledApps = newInstalledApps, - removedCategories = removedCategories, - newCategoryBaseData = newCategoryBaseData, - newCategoryAssignedApps = newCategoryAssignedApps, - newCategoryUsedTimes = newCategoryUsedTimes, - newCategoryTimeLimitRules = newCategoryTimeLimitRules, - newCategoryTasks = newCategoryTasks, - newUserList = newUserList, - fullVersionUntil = fullVersionUntil!!, - message = message, - apiLevel = apiLevel + newDeviceList = newDeviceList, + updatedExtendedDeviceData = updatedExtendedDeviceData, + newInstalledApps = newInstalledApps, + removedCategories = removedCategories, + newCategoryBaseData = newCategoryBaseData, + newCategoryAssignedApps = newCategoryAssignedApps, + newCategoryUsedTimes = newCategoryUsedTimes, + newCategoryTimeLimitRules = newCategoryTimeLimitRules, + newCategoryTasks = newCategoryTasks, + newUserList = newUserList, + pendingKeyRequests = pendingKeyRequests, + keyResponses = keyResponses, + fullVersionUntil = fullVersionUntil!!, + message = message, + apiLevel = apiLevel ) } } @@ -299,7 +315,8 @@ data class ServerDeviceData( val wasAccessibilityServiceEnabled: Boolean, val enableActivityLevelBlocking: Boolean, val qOrLater: Boolean, - val manipulationFlags: Long + val manipulationFlags: Long, + val publicKey: ByteArray? ) { companion object { private const val DEVICE_ID = "deviceId" @@ -333,6 +350,7 @@ data class ServerDeviceData( private const val ENABLE_ACTIVITY_LEVEL_BLOCKING = "activityLevelBlocking" private const val Q_OR_LATER = "qOrLater" private const val MANIPULATION_FLAGS = "mFlags" + private const val PUBLIC_KEY = "pk" fun parse(reader: JsonReader): ServerDeviceData { var deviceId: String? = null @@ -366,6 +384,7 @@ data class ServerDeviceData( var enableActivityLevelBlocking = false var qOrLater = false var manipulationFlags = 0L + var publicKey: ByteArray? = null reader.beginObject() while (reader.hasNext()) { @@ -401,6 +420,7 @@ data class ServerDeviceData( ENABLE_ACTIVITY_LEVEL_BLOCKING -> enableActivityLevelBlocking = reader.nextBoolean() Q_OR_LATER -> qOrLater = reader.nextBoolean() MANIPULATION_FLAGS -> manipulationFlags = reader.nextLong() + PUBLIC_KEY -> publicKey = reader.nextString().parseBase64() else -> reader.skipValue() } } @@ -437,7 +457,8 @@ data class ServerDeviceData( wasAccessibilityServiceEnabled = wasAccessibilityServiceEnabled!!, enableActivityLevelBlocking = enableActivityLevelBlocking, qOrLater = qOrLater, - manipulationFlags = manipulationFlags + manipulationFlags = manipulationFlags, + publicKey = publicKey ) } @@ -1065,3 +1086,182 @@ data class ServerInstalledAppsData( fun parseList(reader: JsonReader) = parseJsonArray(reader) { parse(reader) } } } + +data class ServerExtendedDeviceData( + val deviceId: String, + val appsBase: ServerCryptContainer?, + val appsDiff: ServerCryptContainer? +) { + companion object { + private const val DEVICE_ID = "deviceId" + private const val APPS_BASE = "appsBase" + private const val APPS_DIFF = "appsDiff" + + fun parse(reader: JsonReader): ServerExtendedDeviceData { + var deviceId: String? = null + var appsBase: ServerCryptContainer? = null + var appsDiff: ServerCryptContainer? = null + + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + DEVICE_ID -> deviceId = reader.nextString() + APPS_BASE -> appsBase = ServerCryptContainer.parse(reader) + APPS_DIFF -> appsDiff = ServerCryptContainer.parse(reader) + else -> reader.skipValue() + } + } + reader.endObject() + + return ServerExtendedDeviceData( + deviceId = deviceId!!, + appsBase = appsBase, + appsDiff = appsDiff + ) + } + } +} + +data class ServerCryptContainer( + val version: String, + val data: ByteArray +) { + companion object { + private const val VERSION = "version" + private const val DATA = "data" + + fun parse(reader: JsonReader): ServerCryptContainer { + var version: String? = null + var data: ByteArray? = null + + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + VERSION -> version = reader.nextString() + DATA -> data = reader.nextString().parseBase64() + else -> reader.skipValue() + } + } + reader.endObject() + + return ServerCryptContainer( + version = version!!, + data = data!! + ) + } + } +} + +data class ServerKeyRequest( + val serverRequestSequenceNumber: Long, + val senderDeviceId: String, + val senderSequenceNumber: Long, + val tempKey: ByteArray, + val deviceId: String?, + val categoryId: String?, + val type: Int, + val signature: ByteArray +) { + companion object { + private const val SERVER_REQUEST_SEQUENCE_NUMBER = "srvSeq" + private const val SENDER_DEVICE_ID = "senId" + private const val SENDER_SEQUENCE_NUMBER = "senSeq" + private const val DEVICE_ID = "deviceId" + private const val CATEGORY_ID = "categoryId" + private const val TYPE = "type" + private const val TEMP_KEY = "tempKey" + private const val SIGNATURE = "signature" + + fun parse(reader: JsonReader): ServerKeyRequest { + var serverRequestSequenceNumber: Long? = null + var senderDeviceId: String? = null + var senderSequenceNumber: Long? = null + var tempKey: ByteArray? = null + var deviceId: String? = null + var categoryId: String? = null + var type: Int? = null + var signature: ByteArray? = null + + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + SERVER_REQUEST_SEQUENCE_NUMBER -> serverRequestSequenceNumber = reader.nextLong() + SENDER_DEVICE_ID -> senderDeviceId = reader.nextString() + SENDER_SEQUENCE_NUMBER -> senderSequenceNumber = reader.nextLong() + TEMP_KEY -> tempKey = reader.nextString().parseBase64() + DEVICE_ID -> deviceId = reader.nextString() + CATEGORY_ID -> categoryId = reader.nextString() + TYPE -> type = reader.nextInt() + SIGNATURE -> signature = reader.nextString().parseBase64() + else -> reader.skipValue() + } + } + reader.endObject() + + return ServerKeyRequest( + serverRequestSequenceNumber = serverRequestSequenceNumber!!, + senderDeviceId = senderDeviceId!!, + senderSequenceNumber = senderSequenceNumber!!, + tempKey = tempKey!!, + deviceId = deviceId, + categoryId = categoryId, + type = type!!, + signature = signature!! + ) + } + + fun parseList(reader: JsonReader): List = parseJsonArray(reader) { parse(reader) } + } +} + +data class ServerKeyResponse( + val serverResponseSequenceNumber: Long, + val senderDeviceId: String, + val requestSequenceId: Long, + val tempKey: ByteArray, + val encryptedKey: ByteArray, + val signature: ByteArray +) { + companion object { + private const val SERVER_RESPONSE_SEQUENCE = "srvSeq" + private const val SENDER_DEVICE_ID = "sender" + private const val REQUEST_SEQUENCE_ID = "rqSeq" + private const val TEMP_KEY = "tempKey" + private const val ENCRYPTED_KEY = "cryptKey" + private const val SIGNATURE = "signature" + + fun parse(reader: JsonReader): ServerKeyResponse { + var serverResponseSequenceNumber: Long? = null + var senderDeviceId: String? = null + var requestSequenceId: Long? = null + var tempKey: ByteArray? = null + var encryptedKey: ByteArray? = null + var signature: ByteArray? = null + + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + SERVER_RESPONSE_SEQUENCE -> serverResponseSequenceNumber = reader.nextLong() + SENDER_DEVICE_ID -> senderDeviceId = reader.nextString() + REQUEST_SEQUENCE_ID -> requestSequenceId = reader.nextLong() + TEMP_KEY -> tempKey = reader.nextString().parseBase64() + ENCRYPTED_KEY -> encryptedKey = reader.nextString().parseBase64() + SIGNATURE -> signature = reader.nextString().parseBase64() + else -> reader.skipValue() + } + } + reader.endObject() + + return ServerKeyResponse( + serverResponseSequenceNumber = serverResponseSequenceNumber!!, + senderDeviceId = senderDeviceId!!, + requestSequenceId = requestSequenceId!!, + tempKey = tempKey!!, + encryptedKey = encryptedKey!!, + signature = signature!! + ) + } + + fun parseList(reader: JsonReader) = parseJsonArray(reader) { parse(reader) } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseExperimentalFlagFragment.kt b/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseExperimentalFlagFragment.kt index 5a4c4e8..e5d084e 100644 --- a/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseExperimentalFlagFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseExperimentalFlagFragment.kt @@ -195,6 +195,12 @@ data class DiagnoseExperimentalFlagItem( enableFlags = ExperimentalFlags.STRICT_OVERLAY_CHECKING, disableFlags = ExperimentalFlags.STRICT_OVERLAY_CHECKING, enable = { true } + ), + DiagnoseExperimentalFlagItem( + label = R.string.diagnose_exf_dls, + enableFlags = ExperimentalFlags.DISABLE_LEGACY_APP_SENDING, + disableFlags = ExperimentalFlags.DISABLE_LEGACY_APP_SENDING, + enable = { true } ) ) } diff --git a/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseSyncFragment.kt b/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseSyncFragment.kt index 11d6419..10d0fbb 100644 --- a/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseSyncFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseSyncFragment.kt @@ -55,7 +55,7 @@ class DiagnoseSyncFragment : Fragment(), FragmentWithCustomTitle { binding.clearCacheBtn.setOnClickListener { Threads.database.execute { - UploadActionsUtil.deleteAllVersionNumbersSync(logic.database) + UploadActionsUtil.deleteAllVersionNumbersSync(logic.database, wipeCryptoRequests = true) } Toast.makeText(requireContext(), R.string.diagnose_sync_btn_clear_cache_toast, Toast.LENGTH_SHORT).show() diff --git a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/ManageDeviceFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/ManageDeviceFragment.kt index 668e7c9..6bb0b1f 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/ManageDeviceFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/ManageDeviceFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2021 Jonas Lochmann + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -24,15 +24,15 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProviders +import androidx.lifecycle.switchMap import androidx.navigation.Navigation import io.timelimit.android.R +import io.timelimit.android.crypto.Curve25519 +import io.timelimit.android.crypto.HexString import io.timelimit.android.data.model.Device import io.timelimit.android.databinding.FragmentManageDeviceBinding import io.timelimit.android.extensions.safeNavigate -import io.timelimit.android.livedata.ignoreUnchanged -import io.timelimit.android.livedata.liveDataFromNonNullValue -import io.timelimit.android.livedata.map -import io.timelimit.android.livedata.switchMap +import io.timelimit.android.livedata.* import io.timelimit.android.logic.AppLogic import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.logic.RealTime @@ -150,6 +150,26 @@ class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle { binding.isThisDevice = it }) + val signingKey = isThisDevice.switchMap { isLocalDevice -> + if (isLocalDevice) { + logic.fullVersion.isLocalMode.switchMap { isLocalMode -> + if (isLocalMode) liveDataFromNullableValue(null) + else + logic.database.config().getSigningKeyAsync().map { + if (it != null) Curve25519.getPublicKey(it) + else null + } + } + } else { + logic.database.deviceKey().getLive(args.deviceId).map { + it?.publicKey + } + } + }.map { + if (it == null) null + else HexString.toHex(it) + } + ManageDeviceIntroduction.bind( view = binding.introduction, database = logic.database, @@ -182,6 +202,8 @@ class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle { binding.userCardText = it?.name ?: getString(R.string.manage_device_current_user_none) }) + signingKey.observe(viewLifecycleOwner) { binding.devicePublicKey = it } + return binding.root } diff --git a/app/src/main/proto/io/timelimit/proto/applist.proto b/app/src/main/proto/io/timelimit/proto/applist.proto new file mode 100644 index 0000000..12417a1 --- /dev/null +++ b/app/src/main/proto/io/timelimit/proto/applist.proto @@ -0,0 +1,59 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +syntax = "proto3"; + +package io.timelimit.proto.applist; + +message InstalledAppProto { + string package_name = 1; + string title = 2; + bool is_launchable = 3; + Recommendation recommendation = 4; + + enum Recommendation { + NONE = 0; + WHITELIST = 1; + BLACKLIST = 2; + } +} + +message InstalledAppActivityProto { + string package_name = 1; + string class_name = 2; + string title = 3; +} + +message RemovedAppActivityProto { + string package_name = 1; + string class_name = 2; +} + +message InstalledAppsProto { + repeated InstalledAppProto apps = 1; + repeated InstalledAppActivityProto activities = 2; +} + +message InstalledAppsDifferenceProto { + InstalledAppsProto added = 1; + repeated string removed_packages = 2; + repeated RemovedAppActivityProto removed_activities = 3; +} + +message SavedAppsDifferenceProto { + InstalledAppsDifferenceProto apps = 1; + int64 base_generation = 2; + int64 base_counter = 3; +} \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_manage_device.xml b/app/src/main/res/layout/fragment_manage_device.xml index 078f916..55350cf 100644 --- a/app/src/main/res/layout/fragment_manage_device.xml +++ b/app/src/main/res/layout/fragment_manage_device.xml @@ -50,8 +50,13 @@ name="handlers" type="io.timelimit.android.ui.manage.device.manage.ManageDeviceFragmentHandlers" /> + + + + + Overlay und Home-Button nicht zum Sperren verwenden Toasts zur Synchronisation anzeigen strengere Prüfung der Überlagerungs-Berechtigung aktivieren + deaktiviere Senden der App-Liste im alten Format Hintergrundaufgabenschleifenfehler @@ -894,6 +895,7 @@ Hinzugefügt: %s Das ist das Gerät, das Sie gerade vor sich haben + Signaturschlüssel: %s Benutzer des Geräts Keine Angabe - keine Begrenzungen @@ -1144,6 +1146,9 @@ Reset-Benachrichtigung Zeigt eine Benachrichtigung an, wenn TimeLimit an diesem Gerät zurückgesetzt wurde + neue Geräte + Zeigt eine Benachrichtigung an, wenn TimeLimit mit Vernetzung verwendet wird und ein neues Gerät verknüpft wurde + TimeLimit hat eine Benachrichtigung blockiert TimeLimit konnte eine Benachrichtigung nicht blockieren @@ -1157,6 +1162,8 @@ TimeLimit ist aktiv eine Synchronisation läuft + Gerät hinzugefügt + Sie verwenden TimeLimit auf einer älteren Android-Version. Das kann funktionieren, aber es wird nicht empfohlen. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c6e2350..3903acd 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -105,6 +105,8 @@ (GNU General Public License v3.0) \nZXing (Apache License 2.0) + \nWire + (Apache License 2.0) Error diagnose @@ -581,6 +583,7 @@ Do not use a overlay or the home button for blocking Show sync related toasts Enable strict overlay permission check + Disable sending the App List with the legacy encoding Background task loop exception @@ -937,6 +940,7 @@ Added: %s This is the device which is in front of you + Signing Key: %s User of the device No selection - no limits @@ -1186,6 +1190,9 @@ Reset Notification Shows a notification if TimeLimit was reset at this device + New Device + Shows a notification if the connected mode is used and a new device was linked + TimeLimit has blocked a notification TimeLimit could not block a notification @@ -1199,6 +1206,8 @@ TimeLimit is running a background synchronisation is in progress + Device added + You are using TimeLimit at a obsolete Android version. Although this can work, it is not recommend. diff --git a/build.gradle b/build.gradle index a817190..e57a60b 100644 --- a/build.gradle +++ b/build.gradle @@ -26,6 +26,7 @@ buildscript { classpath 'com.android.tools.build:gradle:7.2.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "androidx.navigation:navigation-safe-args-gradle-plugin:2.4.2" + classpath 'com.squareup.wire:wire-gradle-plugin:4.4.0' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files