From 43dabe17ab9c6f80c03e9cf26af5652d942af335 Mon Sep 17 00:00:00 2001 From: Jonas Lochmann Date: Mon, 4 May 2020 02:00:00 +0200 Subject: [PATCH] Add barcode unlocking for the local mode --- app/build.gradle | 6 +- app/proguard-rules.pro | 14 +- .../28.json | 887 ++++++++++++++++++ app/src/main/AndroidManifest.xml | 1 - .../android/barcode/BarcodeConstants.kt | 20 + .../timelimit/android/barcode/BarcodeMask.kt | 47 + .../android/barcode/BarcodeMaskDrawable.kt | 65 ++ .../timelimit/android/barcode/DataMatrix.kt | 25 + .../io/timelimit/android/crypto/Curve25519.kt | 67 ++ .../io/timelimit/android/data/Database.kt | 3 +- .../io/timelimit/android/data/Migrations.kt | 7 + .../io/timelimit/android/data/RoomDatabase.kt | 8 +- .../android/data/backup/DatabaseBackup.kt | 6 +- .../data/backup/DatabaseBackupLowlevel.kt | 13 +- .../timelimit/android/data/dao/ConfigDao.kt | 5 + .../timelimit/android/data/dao/UserKeyDao.kt | 44 + .../android/data/model/ConfigurationItem.kt | 9 +- .../timelimit/android/data/model/UserKey.kt | 119 +++ .../io/timelimit/android/ui/MainActivity.kt | 5 +- .../ui/login/CodeLoginDialogFragment.kt | 39 + .../ui/login/LoginDialogFragmentModel.kt | 85 +- .../android/ui/login/LoginUserAdapter.kt | 31 +- .../android/ui/login/NewLoginFragment.kt | 18 +- .../ui/manage/parent/ManageParentFragment.kt | 9 + .../parent/key/AddUserKeyDialogFragment.kt | 77 ++ .../ui/manage/parent/key/ManageUserKeyView.kt | 70 ++ .../parent/key/ScanKeyDialogFragment.kt | 102 ++ .../ui/manage/parent/key/ScannedKey.kt | 57 ++ .../ui/overview/about/AboutFragment.kt | 32 +- .../android/ui/overview/main/MainFragment.kt | 23 +- .../ui/parentmode/ParentModeCodeFragment.kt | 46 + .../ui/parentmode/ParentModeCodeModel.kt | 87 ++ .../ui/parentmode/ParentModeFragment.kt | 60 ++ .../ui/parentmode/ParentModeHelpFragment.kt | 29 + .../ui/setup/SetupSelectModeFragment.kt | 10 + .../SetupParentmodeDialogFragment.kt | 61 ++ .../parentmode/SetupParentmodeDialogModel.kt | 61 ++ .../drawable/ic_help_outline_black_24dp.xml | 9 + .../res/drawable/ic_vpn_key_black_24dp.xml | 9 + .../res/layout/fragment_manage_parent.xml | 3 + .../res/layout/fragment_setup_select_mode.xml | 28 + .../main/res/layout/manage_user_key_view.xml | 94 ++ .../res/layout/parent_mode_code_fragment.xml | 48 + .../main/res/layout/parent_mode_fragment.xml | 37 + .../res/layout/parent_mode_help_fragment.xml | 28 + .../res/menu/fragment_parent_menu_bottom.xml | 32 + app/src/main/res/navigation/nav_graph.xml | 10 +- app/src/main/res/values-de/strings-login.xml | 3 + .../res/values-de/strings-manage-user-key.xml | 31 + .../res/values-de/strings-parent-mode.xml | 26 + .../main/res/values-de/strings-purchase.xml | 1 + .../res/values-de/strings-scan-key-dialog.xml | 20 + .../values-de/strings-setup-select-mode.xml | 9 +- app/src/main/res/values/strings-about.xml | 4 + app/src/main/res/values/strings-login.xml | 3 + .../res/values/strings-manage-user-key.xml | 30 + .../main/res/values/strings-parent-mode.xml | 26 + app/src/main/res/values/strings-purchase.xml | 1 + .../res/values/strings-scan-key-dialog.xml | 20 + .../res/values/strings-setup-select-mode.xml | 10 +- 60 files changed, 2690 insertions(+), 40 deletions(-) create mode 100644 app/schemas/io.timelimit.android.data.RoomDatabase/28.json create mode 100644 app/src/main/java/io/timelimit/android/barcode/BarcodeConstants.kt create mode 100644 app/src/main/java/io/timelimit/android/barcode/BarcodeMask.kt create mode 100644 app/src/main/java/io/timelimit/android/barcode/BarcodeMaskDrawable.kt create mode 100644 app/src/main/java/io/timelimit/android/barcode/DataMatrix.kt create mode 100644 app/src/main/java/io/timelimit/android/crypto/Curve25519.kt create mode 100644 app/src/main/java/io/timelimit/android/data/dao/UserKeyDao.kt create mode 100644 app/src/main/java/io/timelimit/android/data/model/UserKey.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/login/CodeLoginDialogFragment.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/parent/key/AddUserKeyDialogFragment.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/parent/key/ManageUserKeyView.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/parent/key/ScanKeyDialogFragment.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/parent/key/ScannedKey.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/parentmode/ParentModeCodeFragment.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/parentmode/ParentModeCodeModel.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/parentmode/ParentModeFragment.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/parentmode/ParentModeHelpFragment.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/setup/parentmode/SetupParentmodeDialogFragment.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/setup/parentmode/SetupParentmodeDialogModel.kt create mode 100644 app/src/main/res/drawable/ic_help_outline_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_vpn_key_black_24dp.xml create mode 100644 app/src/main/res/layout/manage_user_key_view.xml create mode 100644 app/src/main/res/layout/parent_mode_code_fragment.xml create mode 100644 app/src/main/res/layout/parent_mode_fragment.xml create mode 100644 app/src/main/res/layout/parent_mode_help_fragment.xml create mode 100644 app/src/main/res/menu/fragment_parent_menu_bottom.xml create mode 100644 app/src/main/res/values-de/strings-manage-user-key.xml create mode 100644 app/src/main/res/values-de/strings-parent-mode.xml create mode 100644 app/src/main/res/values-de/strings-scan-key-dialog.xml create mode 100644 app/src/main/res/values/strings-manage-user-key.xml create mode 100644 app/src/main/res/values/strings-parent-mode.xml create mode 100644 app/src/main/res/values/strings-scan-key-dialog.xml diff --git a/app/build.gradle b/app/build.gradle index 8e0cffb..d859e22 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -197,4 +197,8 @@ dependencies { } implementation 'org.apache.commons:commons-text:1.6' -} + + implementation 'org.whispersystems:curve25519-java:0.5.0' + + implementation 'com.google.zxing:core:3.2.1' +} \ No newline at end of file diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 37d0bd0..fa114c6 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,4 +1,4 @@ -# TimeLimit Copyright 2019 Jonas Lochmann +# TimeLimit Copyright 2019 - 2020 Jonas Lochmann # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -47,4 +47,14 @@ } # readable stack traces --keepattributes SourceFile,LineNumberTable \ No newline at end of file +-keepattributes SourceFile,LineNumberTable + +# Curve25519 support +-keepnames class org.whispersystems.curve25519.NativeCurve25519Provider {} +-keepnames class org.whispersystems.curve25519.JavaCurve25519Provider {} +-keepnames class org.whispersystems.curve25519.J2meCurve25519Provider {} +-keepnames class org.whispersystems.curve25519.OpportunisticCurve25519Provider {} +-keep class org.whispersystems.curve25519.NativeCurve25519Provider {} +-keep class org.whispersystems.curve25519.JavaCurve25519Provider {} +-keep class org.whispersystems.curve25519.J2meCurve25519Provider {} +-keep class org.whispersystems.curve25519.OpportunisticCurve25519Provider {} \ No newline at end of file diff --git a/app/schemas/io.timelimit.android.data.RoomDatabase/28.json b/app/schemas/io.timelimit.android.data.RoomDatabase/28.json new file mode 100644 index 0000000..83e26ea --- /dev/null +++ b/app/schemas/io.timelimit.android.data.RoomDatabase/28.json @@ -0,0 +1,887 @@ +{ + "formatVersion": 1, + "database": { + "version": 28, + "identityHash": "afecf774158d4b4648f40b5aaa068a6b", + "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, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondPasswordSalt", + "columnName": "second_password_salt", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timeZone", + "columnName": "timezone", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "disableLimitsUntil", + "columnName": "disable_limits_until", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mail", + "columnName": "mail", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentDevice", + "columnName": "current_device", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "categoryForNotAssignedApps", + "columnName": "category_for_not_assigned_apps", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relaxPrimaryDevice", + "columnName": "relax_primary_device", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mailNotificationFlags", + "columnName": "mail_notification_flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockedTimes", + "columnName": "blocked_times", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "device", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `model` TEXT NOT NULL, `added_at` INTEGER NOT NULL, `current_user_id` TEXT NOT NULL, `apps_version` TEXT NOT NULL, `network_time` TEXT NOT NULL, `current_protection_level` TEXT NOT NULL, `highest_permission_level` TEXT NOT NULL, `current_usage_stats_permission` TEXT NOT NULL, `highest_usage_stats_permission` TEXT NOT NULL, `current_notification_access_permission` TEXT NOT NULL, `highest_notification_access_permission` TEXT NOT NULL, `current_app_version` INTEGER NOT NULL, `highest_app_version` INTEGER NOT NULL, `tried_disabling_device_admin` INTEGER NOT NULL, `did_reboot` INTEGER NOT NULL, `had_manipulation` INTEGER NOT NULL, `had_manipulation_flags` INTEGER NOT NULL, `did_report_uninstall` INTEGER NOT NULL, `is_user_kept_signed_in` INTEGER NOT NULL, `show_device_connected` INTEGER NOT NULL, `default_user` TEXT NOT NULL, `default_user_timeout` INTEGER NOT NULL, `consider_reboot_manipulation` INTEGER NOT NULL, `current_overlay_permission` TEXT NOT NULL, `highest_overlay_permission` TEXT NOT NULL, `current_accessibility_service_permission` INTEGER NOT NULL, `was_accessibility_service_permission` INTEGER NOT NULL, `enable_activity_level_blocking` INTEGER NOT NULL, `q_or_later` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addedAt", + "columnName": "added_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentUserId", + "columnName": "current_user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "installedAppsVersion", + "columnName": "apps_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "networkTime", + "columnName": "network_time", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentProtectionLevel", + "columnName": "current_protection_level", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "highestProtectionLevel", + "columnName": "highest_permission_level", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentUsageStatsPermission", + "columnName": "current_usage_stats_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "highestUsageStatsPermission", + "columnName": "highest_usage_stats_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentNotificationAccessPermission", + "columnName": "current_notification_access_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "highestNotificationAccessPermission", + "columnName": "highest_notification_access_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "currentAppVersion", + "columnName": "current_app_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "highestAppVersion", + "columnName": "highest_app_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "manipulationTriedDisablingDeviceAdmin", + "columnName": "tried_disabling_device_admin", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "manipulationDidReboot", + "columnName": "did_reboot", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hadManipulation", + "columnName": "had_manipulation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hadManipulationFlags", + "columnName": "had_manipulation_flags", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "didReportUninstall", + "columnName": "did_report_uninstall", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isUserKeptSignedIn", + "columnName": "is_user_kept_signed_in", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "showDeviceConnected", + "columnName": "show_device_connected", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "defaultUser", + "columnName": "default_user", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "defaultUserTimeout", + "columnName": "default_user_timeout", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "considerRebootManipulation", + "columnName": "consider_reboot_manipulation", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentOverlayPermission", + "columnName": "current_overlay_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "highestOverlayPermission", + "columnName": "highest_overlay_permission", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "accessibilityServiceEnabled", + "columnName": "current_accessibility_service_permission", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "wasAccessibilityServiceEnabled", + "columnName": "was_accessibility_service_permission", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "enableActivityLevelBlocking", + "columnName": "enable_activity_level_blocking", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "qOrLater", + "columnName": "q_or_later", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "app", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`device_id` TEXT NOT NULL, `package_name` TEXT NOT NULL, `title` TEXT NOT NULL, `launchable` INTEGER NOT NULL, `recommendation` TEXT NOT NULL, PRIMARY KEY(`device_id`, `package_name`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "device_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isLaunchable", + "columnName": "launchable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recommendation", + "columnName": "recommendation", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "device_id", + "package_name" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_app_device_id", + "unique": false, + "columnNames": [ + "device_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_app_device_id` ON `${TABLE_NAME}` (`device_id`)" + }, + { + "name": "index_app_package_name", + "unique": false, + "columnNames": [ + "package_name" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_app_package_name` ON `${TABLE_NAME}` (`package_name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "category_app", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`category_id` TEXT NOT NULL, `package_name` TEXT NOT NULL, PRIMARY KEY(`category_id`, `package_name`))", + "fields": [ + { + "fieldPath": "categoryId", + "columnName": "category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "category_id", + "package_name" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_category_app_category_id", + "unique": false, + "columnNames": [ + "category_id" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_category_app_category_id` ON `${TABLE_NAME}` (`category_id`)" + }, + { + "name": "index_category_app_package_name", + "unique": false, + "columnNames": [ + "package_name" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_category_app_package_name` ON `${TABLE_NAME}` (`package_name`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "category", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `child_id` TEXT NOT NULL, `title` TEXT NOT NULL, `blocked_times` TEXT NOT NULL, `extra_time` INTEGER NOT NULL, `extra_time_day` INTEGER NOT NULL, `temporarily_blocked` INTEGER NOT NULL, `temporarily_blocked_end_time` INTEGER NOT NULL, `base_version` TEXT NOT NULL, `apps_version` TEXT NOT NULL, `rules_version` TEXT NOT NULL, `usedtimes_version` TEXT NOT NULL, `parent_category_id` TEXT NOT NULL, `block_all_notifications` INTEGER NOT NULL, `time_warnings` INTEGER NOT NULL, `min_battery_charging` INTEGER NOT NULL, `min_battery_mobile` INTEGER NOT NULL, `sort` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "childId", + "columnName": "child_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedMinutesInWeek", + "columnName": "blocked_times", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "extraTimeInMillis", + "columnName": "extra_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "extraTimeDay", + "columnName": "extra_time_day", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "temporarilyBlocked", + "columnName": "temporarily_blocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "temporarilyBlockedEndTime", + "columnName": "temporarily_blocked_end_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "baseVersion", + "columnName": "base_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "assignedAppsVersion", + "columnName": "apps_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timeLimitRulesVersion", + "columnName": "rules_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "usedTimesVersion", + "columnName": "usedtimes_version", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentCategoryId", + "columnName": "parent_category_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockAllNotifications", + "columnName": "block_all_notifications", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timeWarnings", + "columnName": "time_warnings", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minBatteryLevelWhileCharging", + "columnName": "min_battery_charging", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minBatteryLevelMobile", + "columnName": "min_battery_mobile", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sort", + "columnName": "sort", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "used_time", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`day_of_epoch` INTEGER NOT NULL, `used_time` INTEGER NOT NULL, `category_id` TEXT NOT NULL, PRIMARY KEY(`category_id`, `day_of_epoch`))", + "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 + } + ], + "primaryKey": { + "columnNames": [ + "category_id", + "day_of_epoch" + ], + "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, 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 + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "config", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `value` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "temporarily_allowed_app", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`device_id` TEXT NOT NULL, `package_name` TEXT NOT NULL, PRIMARY KEY(`device_id`, `package_name`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "device_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "device_id", + "package_name" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "pending_sync_action", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sequence_number` INTEGER NOT NULL, `action` TEXT NOT NULL, `integrity` TEXT NOT NULL, `scheduled_for_upload` INTEGER NOT NULL, `type` TEXT NOT NULL, `user_id` TEXT NOT NULL, PRIMARY KEY(`sequence_number`))", + "fields": [ + { + "fieldPath": "sequenceNumber", + "columnName": "sequence_number", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encodedAction", + "columnName": "action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "integrity", + "columnName": "integrity", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scheduledForUpload", + "columnName": "scheduled_for_upload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sequence_number" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_pending_sync_action_scheduled_for_upload", + "unique": false, + "columnNames": [ + "scheduled_for_upload" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_sync_action_scheduled_for_upload` ON `${TABLE_NAME}` (`scheduled_for_upload`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "app_activity", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`device_id` TEXT NOT NULL, `app_package_name` TEXT NOT NULL, `activity_class_name` TEXT NOT NULL, `activity_title` TEXT NOT NULL, PRIMARY KEY(`device_id`, `app_package_name`, `activity_class_name`))", + "fields": [ + { + "fieldPath": "deviceId", + "columnName": "device_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "appPackageName", + "columnName": "app_package_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "activityClassName", + "columnName": "activity_class_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "activity_title", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "device_id", + "app_package_name", + "activity_class_name" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "notification", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` INTEGER NOT NULL, `id` TEXT NOT NULL, `first_notify_time` INTEGER NOT NULL, `dismissed` INTEGER NOT NULL, PRIMARY KEY(`type`, `id`))", + "fields": [ + { + "fieldPath": "type", + "columnName": "type", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstNotifyTime", + "columnName": "first_notify_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDismissed", + "columnName": "dismissed", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "type", + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "allowed_contact", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `phone` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phone", + "columnName": "phone", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "user_key", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` TEXT NOT NULL, `key` BLOB NOT NULL, `last_use` INTEGER NOT NULL, PRIMARY KEY(`user_id`), FOREIGN KEY(`user_id`) REFERENCES `user`(`id`) ON UPDATE CASCADE ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "key", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastUse", + "columnName": "last_use", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "user_id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_user_key_key", + "unique": true, + "columnNames": [ + "key" + ], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_user_key_key` ON `${TABLE_NAME}` (`key`)" + } + ], + "foreignKeys": [ + { + "table": "user", + "onDelete": "CASCADE", + "onUpdate": "CASCADE", + "columns": [ + "user_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "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, 'afecf774158d4b4648f40b5aaa068a6b')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2d366b1..a3f3b77 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -64,7 +64,6 @@ android:autoRemoveFromRecents="true" android:excludeFromRecents="true" android:exported="false" - android:noHistory="true" android:resizeableActivity="false" android:supportsPictureInPicture="false" android:taskAffinity=":lock" diff --git a/app/src/main/java/io/timelimit/android/barcode/BarcodeConstants.kt b/app/src/main/java/io/timelimit/android/barcode/BarcodeConstants.kt new file mode 100644 index 0000000..a567f8a --- /dev/null +++ b/app/src/main/java/io/timelimit/android/barcode/BarcodeConstants.kt @@ -0,0 +1,20 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.barcode + +object BarcodeConstants { + const val LOCAL_MODE_MAGIC: Int = 473967493 +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/barcode/BarcodeMask.kt b/app/src/main/java/io/timelimit/android/barcode/BarcodeMask.kt new file mode 100644 index 0000000..0f0ae59 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/barcode/BarcodeMask.kt @@ -0,0 +1,47 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.barcode + +import com.google.zxing.common.BitMatrix +import java.util.* + +data class BarcodeMask(val width: Int, val height: Int, val mask: BitSet) { + companion object { + fun fromBitMatrix(bitMatrix: BitMatrix) = BarcodeMask( + width = bitMatrix.width, + height = bitMatrix.height, + mask = BitSet(bitMatrix.width * bitMatrix.height).apply { + (0 until bitMatrix.width).forEach { x -> + (0 until bitMatrix.height).forEach { y -> + set(y * bitMatrix.width + x, bitMatrix[x, y]) + } + } + } + ) + } + + fun withPadding(size: Int) = BarcodeMask( + width = width + size * 2, + height = height + size * 2, + mask = BitSet((width + size * 2) * (height + size * 2)).apply { + (0 until width).forEach { x -> + (0 until height).forEach { y -> + set((y + size) * (width + size * 2) + (x + size), mask[y * width + x]) + } + } + } + ) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/barcode/BarcodeMaskDrawable.kt b/app/src/main/java/io/timelimit/android/barcode/BarcodeMaskDrawable.kt new file mode 100644 index 0000000..e90cc6f --- /dev/null +++ b/app/src/main/java/io/timelimit/android/barcode/BarcodeMaskDrawable.kt @@ -0,0 +1,65 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.barcode + +import android.graphics.* +import android.graphics.drawable.Drawable + +class BarcodeMaskDrawable(val mask: BarcodeMask): Drawable() { + private val blackPaint = Paint(Color.BLACK) + + override fun draw(target: Canvas) { + target.drawColor(Color.WHITE) + + val bLeft = bounds.left + val bTop = bounds.top + val bWidth = bounds.width() + val bHeight = bounds.height() + + (0 until mask.height).forEach { y -> + (0 until mask.width).forEach { x -> + if (mask.mask[y * mask.width + x]) { + val left = bLeft + x.toFloat() / mask.width * bWidth + val top = bTop + y.toFloat() / mask.height * bHeight + val right = bLeft + (x + 1).toFloat() / mask.width * bWidth + val bottom = bTop + (y + 1).toFloat() / mask.height * bHeight + + target.drawRect( + left, + top, + right, + bottom, + blackPaint + ) + } + } + } + } + + override fun setAlpha(p0: Int) { + // ignored + } + + override fun setColorFilter(p0: ColorFilter?) { + // ignored + } + + override fun getOpacity(): Int = PixelFormat.OPAQUE + + override fun getIntrinsicWidth(): Int = mask.width * 2 + + override fun getIntrinsicHeight(): Int = mask.height * 2 +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/barcode/DataMatrix.kt b/app/src/main/java/io/timelimit/android/barcode/DataMatrix.kt new file mode 100644 index 0000000..d181914 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/barcode/DataMatrix.kt @@ -0,0 +1,25 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.barcode + +import com.google.zxing.BarcodeFormat +import com.google.zxing.datamatrix.DataMatrixWriter + +object DataMatrix { + fun generate(data: String): BarcodeMask { + return BarcodeMask.fromBitMatrix(DataMatrixWriter().encode(data, BarcodeFormat.DATA_MATRIX, 0, 0)).withPadding(3) + } +} \ 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 new file mode 100644 index 0000000..0621f42 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/crypto/Curve25519.kt @@ -0,0 +1,67 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.crypto + +import android.util.Base64 +import io.timelimit.android.crypto.Curve25519.PRIVATE_KEY_SIZE +import io.timelimit.android.crypto.Curve25519.PUBLIC_KEY_SIZE +import org.whispersystems.curve25519.Curve25519 +import org.whispersystems.curve25519.Curve25519KeyPair + +object Curve25519 { + const val PRIVATE_KEY_SIZE = 32 + const val PUBLIC_KEY_SIZE = 32 + const val SIGNATURE_SIZE = 64 + + private val instance: Curve25519 by lazy { org.whispersystems.curve25519.Curve25519.getInstance(org.whispersystems.curve25519.Curve25519.JAVA) } + + fun generateKeyPair() = instance.generateKeyPair().serialize() + + fun getPublicKey(data: ByteArray): ByteArray { + if (data.size != PRIVATE_KEY_SIZE + PUBLIC_KEY_SIZE) { + throw IllegalArgumentException() + } + + return data.copyOfRange(0, PUBLIC_KEY_SIZE) + } + + fun getPublicKeyId(publicKey: ByteArray): String { + return Base64.encodeToString(publicKey.copyOfRange(0, 6), Base64.NO_WRAP) + } + + fun getPrivateKey(data: ByteArray): ByteArray { + if (data.size != PRIVATE_KEY_SIZE + PUBLIC_KEY_SIZE) { + throw IllegalArgumentException() + } + + return data.copyOfRange(PUBLIC_KEY_SIZE, PUBLIC_KEY_SIZE + PRIVATE_KEY_SIZE) + } + + fun sign(privateKey: ByteArray, message: ByteArray): ByteArray = instance.calculateSignature(privateKey, message) + + fun validateSignature(publicKey: ByteArray, message: ByteArray, signature: ByteArray) = instance.verifySignature(publicKey, message, signature) +} + +fun Curve25519KeyPair.serialize(): ByteArray { + val private = this.privateKey + val public = this.publicKey + + if (public.size != PUBLIC_KEY_SIZE || privateKey.size != PRIVATE_KEY_SIZE) { + throw IllegalStateException() + } + + return public + private +} \ 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 a6b0ec1..86dbde0 100644 --- a/app/src/main/java/io/timelimit/android/data/Database.kt +++ b/app/src/main/java/io/timelimit/android/data/Database.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 Jonas Lochmann + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -32,6 +32,7 @@ interface Database { fun appActivity(): AppActivityDao fun notification(): NotificationDao fun allowedContact(): AllowedContactDao + fun userKey(): UserKeyDao fun beginTransaction() fun setTransactionSuccessful() 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 2b82ffa..e21c25f 100644 --- a/app/src/main/java/io/timelimit/android/data/Migrations.kt +++ b/app/src/main/java/io/timelimit/android/data/Migrations.kt @@ -192,4 +192,11 @@ object DatabaseMigrations { database.execSQL("ALTER TABLE `category` ADD COLUMN `extra_time_day` INTEGER NOT NULL DEFAULT -1") } } + + val MIGRATE_TO_V28 = object: Migration(27, 28) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE TABLE IF NOT EXISTS `user_key` (`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 )") + database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_user_key_key` ON `user_key` (`key`)") + } + } } 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 d60779d..9b1ba98 100644 --- a/app/src/main/java/io/timelimit/android/data/RoomDatabase.kt +++ b/app/src/main/java/io/timelimit/android/data/RoomDatabase.kt @@ -34,8 +34,9 @@ import io.timelimit.android.data.model.* PendingSyncAction::class, AppActivity::class, Notification::class, - AllowedContact::class -], version = 27) + AllowedContact::class, + UserKey::class +], version = 28) abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database { companion object { private val lock = Object() @@ -96,7 +97,8 @@ abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database DatabaseMigrations.MIGRATE_TO_V24, DatabaseMigrations.MIGRATE_TO_V25, DatabaseMigrations.MIGRATE_TO_V26, - DatabaseMigrations.MIGRATE_TO_V27 + DatabaseMigrations.MIGRATE_TO_V27, + DatabaseMigrations.MIGRATE_TO_V28 ) .build() } diff --git a/app/src/main/java/io/timelimit/android/data/backup/DatabaseBackup.kt b/app/src/main/java/io/timelimit/android/data/backup/DatabaseBackup.kt index f2f87dc..9f64fa3 100644 --- a/app/src/main/java/io/timelimit/android/data/backup/DatabaseBackup.kt +++ b/app/src/main/java/io/timelimit/android/data/backup/DatabaseBackup.kt @@ -67,7 +67,11 @@ class DatabaseBackup(private val context: Context) { val database = RoomDatabase.with(context) - if (database.config().getOwnDeviceIdSync().orEmpty().isNotEmpty()) { + if ( + database.config().getOwnDeviceIdSync().orEmpty().isNotEmpty() || + database.config().getParentModeKeySync() != null || + database.config().getCustomServerUrlSync().isNotEmpty() + ) { if (BuildConfig.DEBUG) { Log.d(LOG_TAG, "database is not empty -> don't restore backup") } diff --git a/app/src/main/java/io/timelimit/android/data/backup/DatabaseBackupLowlevel.kt b/app/src/main/java/io/timelimit/android/data/backup/DatabaseBackupLowlevel.kt index c511d32..1fe3538 100644 --- a/app/src/main/java/io/timelimit/android/data/backup/DatabaseBackupLowlevel.kt +++ b/app/src/main/java/io/timelimit/android/data/backup/DatabaseBackupLowlevel.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 Jonas Lochmann + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -41,6 +41,7 @@ object DatabaseBackupLowlevel { private const val APP_ACTIVITY = "appActivity" private const val NOTIFICATION = "notification" private const val ALLOWED_CONTACT = "allowedContact" + private const val USER_KEY = "userKey" fun outputAsBackupJson(database: Database, outputStream: OutputStream) { val writer = JsonWriter(OutputStreamWriter(outputStream, Charsets.UTF_8)) @@ -85,6 +86,7 @@ object DatabaseBackupLowlevel { handleCollection(APP_ACTIVITY) { offset, pageSize -> database.appActivity().getAppActivityPageSync(offset, pageSize) } handleCollection(NOTIFICATION) { offset, pageSize -> database.notification().getNotificationPageSync(offset, pageSize) } handleCollection(ALLOWED_CONTACT) { offset, pageSize -> database.allowedContact().getAllowedContactPageSync(offset, pageSize) } + handleCollection(USER_KEY) { offset, pageSize -> database.userKey().getUserKeyPageSync(offset, pageSize) } writer.endObject().flush() } @@ -215,6 +217,15 @@ object DatabaseBackupLowlevel { reader.endArray() } + USER_KEY -> { + reader.beginArray() + + while (reader.hasNext()) { + database.userKey().addUserKeySync(UserKey.parse(reader)) + } + + reader.endArray() + } else -> reader.skipValue() } } 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 f69e340..58b4ff8 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 @@ -16,6 +16,7 @@ package io.timelimit.android.data.dao import android.content.ComponentName +import android.util.Base64 import androidx.lifecycle.LiveData import androidx.lifecycle.Transformations import androidx.room.* @@ -271,4 +272,8 @@ abstract class ConfigDao { fun getHomescreenDelaySync(): Int = getValueOfKeySync(ConfigurationItemType.HomescreenDelay)?.toIntOrNull(radix = 10) ?: 5 fun setHomescreenDelaySync(delay: Int) = updateValueSync(ConfigurationItemType.HomescreenDelay, delay.toString(radix = 10)) + + fun setParentModeKeySync(key: ByteArray) = updateValueSync(ConfigurationItemType.ParentModeKey, Base64.encodeToString(key, 0)) + fun getParentModeKeySync() = getValueOfKeySync(ConfigurationItemType.ParentModeKey)?.let { value -> Base64.decode(value, 0) } + fun getParentModeKeyLive() = getValueOfKeyAsync(ConfigurationItemType.ParentModeKey).map { value -> value?.let { Base64.decode(it, 0) } } } diff --git a/app/src/main/java/io/timelimit/android/data/dao/UserKeyDao.kt b/app/src/main/java/io/timelimit/android/data/dao/UserKeyDao.kt new file mode 100644 index 0000000..62f5f16 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/dao/UserKeyDao.kt @@ -0,0 +1,44 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.data.dao + +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import io.timelimit.android.data.model.UserKey + +@Dao +interface UserKeyDao { + @Query("SELECT * FROM user_key WHERE `key` = :publicKey") + fun findUserKeyByPublicKeySync(publicKey: ByteArray): UserKey? + + @Insert(onConflict = OnConflictStrategy.ABORT) + fun addUserKeySync(key: UserKey) + + @Query("SELECT * FROM user_key LIMIT :pageSize OFFSET :offset") + fun getUserKeyPageSync(offset: Int, pageSize: Int): List + + @Query("SELECT * FROM user_key WHERE user_id = :userId") + fun getUserKeyByUserIdLive(userId: String): LiveData + + @Query("DELETE FROM user_key WHERE user_id = :userId") + fun deleteUserKeySync(userId: String) + + @Query("UPDATE user_key SET last_use = :timestamp WHERE `key` = :key") + fun updateKeyTimestamp(key: ByteArray, timestamp: Long) +} \ 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 fc07d80..f330448 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 @@ -94,7 +94,8 @@ enum class ConfigurationItemType { EnableAlternativeDurationSelection, ExperimentalFlags, DefaultHomescreen, - HomescreenDelay + HomescreenDelay, + ParentModeKey } object ConfigurationItemTypeUtil { @@ -116,6 +117,7 @@ object ConfigurationItemTypeUtil { private const val EXPERIMENTAL_FLAGS = 17 private const val DEFAULT_HOMESCREEN = 18 private const val HOMESCREEN_DELAY = 19 + private const val PARENT_MODE_KEY = 20 val TYPES = listOf( ConfigurationItemType.OwnDeviceId, @@ -135,7 +137,8 @@ object ConfigurationItemTypeUtil { ConfigurationItemType.EnableAlternativeDurationSelection, ConfigurationItemType.ExperimentalFlags, ConfigurationItemType.DefaultHomescreen, - ConfigurationItemType.HomescreenDelay + ConfigurationItemType.HomescreenDelay, + ConfigurationItemType.ParentModeKey ) fun serialize(value: ConfigurationItemType) = when(value) { @@ -157,6 +160,7 @@ object ConfigurationItemTypeUtil { ConfigurationItemType.ExperimentalFlags -> EXPERIMENTAL_FLAGS ConfigurationItemType.DefaultHomescreen -> DEFAULT_HOMESCREEN ConfigurationItemType.HomescreenDelay -> HOMESCREEN_DELAY + ConfigurationItemType.ParentModeKey -> PARENT_MODE_KEY } fun parse(value: Int) = when(value) { @@ -178,6 +182,7 @@ object ConfigurationItemTypeUtil { EXPERIMENTAL_FLAGS -> ConfigurationItemType.ExperimentalFlags DEFAULT_HOMESCREEN -> ConfigurationItemType.DefaultHomescreen HOMESCREEN_DELAY -> ConfigurationItemType.HomescreenDelay + PARENT_MODE_KEY -> ConfigurationItemType.ParentModeKey else -> throw IllegalArgumentException() } } 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 new file mode 100644 index 0000000..ff55064 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/model/UserKey.kt @@ -0,0 +1,119 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.data.model + +import android.util.Base64 +import android.util.JsonReader +import android.util.JsonWriter +import androidx.room.* +import io.timelimit.android.crypto.Curve25519 +import io.timelimit.android.data.IdGenerator +import io.timelimit.android.data.JsonSerializable + +@Entity( + tableName = "user_key", + foreignKeys = [ + ForeignKey( + entity = User::class, + parentColumns = ["id"], + childColumns = ["user_id"], + onUpdate = ForeignKey.CASCADE, + onDelete = ForeignKey.CASCADE + ) + ], + indices = [ + Index( + value = ["key"], + unique = true + ) + ] +) +data class UserKey( + @PrimaryKey + @ColumnInfo(name = "user_id") + val userId: String, + @ColumnInfo(name = "key") + val publicKey: ByteArray, + @ColumnInfo(name = "last_use") + val lastUse: Long +): JsonSerializable { + companion object { + private const val USER_ID = "userId" + private const val PUBLIC_KEY = "publicKey" + private const val LAST_USE = "lastUse" + + fun parse(reader: JsonReader): UserKey { + var userId: String? = null + var publicKey: ByteArray? = null + var lastUse: Long? = null + + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + USER_ID -> userId = reader.nextString() + PUBLIC_KEY -> publicKey = Base64.decode(reader.nextString(), 0) + LAST_USE -> lastUse = reader.nextLong() + else -> reader.skipValue() + } + } + reader.endObject() + + return UserKey( + userId = userId!!, + publicKey = publicKey!!, + lastUse = lastUse!! + ) + } + } + + init { + if (publicKey.size != Curve25519.PUBLIC_KEY_SIZE) { + throw IllegalArgumentException() + } + + IdGenerator.assertIdValid(userId) + } + + override fun serialize(writer: JsonWriter) { + writer.beginObject() + + writer.name(USER_ID).value(userId) + writer.name(PUBLIC_KEY).value(Base64.encodeToString(publicKey, Base64.NO_WRAP)) + writer.name(LAST_USE).value(lastUse) + + writer.endObject() + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as UserKey + + if (userId != other.userId) return false + if (!publicKey.contentEquals(other.publicKey)) return false + if (lastUse != other.lastUse) return false + + return true + } + + override fun hashCode(): Int { + var result = userId.hashCode() + result = 31 * result + publicKey.contentHashCode() + result = 31 * result + lastUse.hashCode() + return result + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/MainActivity.kt b/app/src/main/java/io/timelimit/android/ui/MainActivity.kt index 6ca2af8..14e2cab 100644 --- a/app/src/main/java/io/timelimit/android/ui/MainActivity.kt +++ b/app/src/main/java/io/timelimit/android/ui/MainActivity.kt @@ -44,6 +44,7 @@ import io.timelimit.android.ui.manage.parent.link.LinkParentMailFragment import io.timelimit.android.ui.manage.parent.password.restore.RestoreParentPasswordFragment import io.timelimit.android.ui.migrate_to_connected.MigrateToConnectedModeFragment import io.timelimit.android.ui.overview.main.MainFragment +import io.timelimit.android.ui.parentmode.ParentModeFragment import io.timelimit.android.ui.payment.ActivityPurchaseModel import io.timelimit.android.ui.setup.SetupTermsFragment import io.timelimit.android.ui.setup.parent.SetupParentModeFragment @@ -104,7 +105,7 @@ class MainActivity : AppCompatActivity(), ActivityViewModelHolder { // up button val shouldShowBackButtonForNavigatorFragment = currentNavigatorFragment.map { fragment -> - (!(fragment is MainFragment)) && (!(fragment is SetupTermsFragment)) + (!(fragment is MainFragment)) && (!(fragment is SetupTermsFragment)) && (!(fragment is ParentModeFragment)) } val shouldShowUpButton = shouldShowBackButtonForNavigatorFragment @@ -224,7 +225,7 @@ class MainActivity : AppCompatActivity(), ActivityViewModelHolder { } override fun onBackPressed() { - if (currentNavigatorFragment.value is SetupTermsFragment) { + if (currentNavigatorFragment.value is SetupTermsFragment || currentNavigatorFragment.value is ParentModeFragment) { // hack to prevent the user from going to the launch screen of the App if it is not set up finish() } else { diff --git a/app/src/main/java/io/timelimit/android/ui/login/CodeLoginDialogFragment.kt b/app/src/main/java/io/timelimit/android/ui/login/CodeLoginDialogFragment.kt new file mode 100644 index 0000000..273f365 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/login/CodeLoginDialogFragment.kt @@ -0,0 +1,39 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.login + +import android.widget.Toast +import androidx.fragment.app.FragmentManager +import io.timelimit.android.R +import io.timelimit.android.extensions.showSafe +import io.timelimit.android.ui.manage.parent.key.ScanKeyDialogFragment +import io.timelimit.android.ui.manage.parent.key.ScannedKey + +class CodeLoginDialogFragment: ScanKeyDialogFragment() { + companion object { + private const val DIALOG_TAG = "CodeLoginDialogFragment" + } + + override fun handleResult(key: ScannedKey?) { + if (key == null) { + Toast.makeText(context!!, R.string.manage_user_key_invalid, Toast.LENGTH_SHORT).show() + } else { + (targetFragment as NewLoginFragment).tryCodeLogin(key) + } + } + + fun show(fragmentManager: FragmentManager) = showSafe(fragmentManager, DIALOG_TAG) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/login/LoginDialogFragmentModel.kt b/app/src/main/java/io/timelimit/android/ui/login/LoginDialogFragmentModel.kt index 5445575..d614a09 100644 --- a/app/src/main/java/io/timelimit/android/ui/login/LoginDialogFragmentModel.kt +++ b/app/src/main/java/io/timelimit/android/ui/login/LoginDialogFragmentModel.kt @@ -27,6 +27,7 @@ import io.timelimit.android.coroutines.runAsync import io.timelimit.android.crypto.PasswordHashing import io.timelimit.android.data.model.User import io.timelimit.android.data.model.UserType +import io.timelimit.android.data.transaction import io.timelimit.android.livedata.* import io.timelimit.android.logic.BlockingReasonUtil import io.timelimit.android.logic.DefaultAppLogic @@ -37,6 +38,7 @@ import io.timelimit.android.sync.actions.apply.ApplyActionChildAuthentication import io.timelimit.android.sync.actions.apply.ApplyActionUtil import io.timelimit.android.ui.main.ActivityViewModel import io.timelimit.android.ui.main.AuthenticatedUser +import io.timelimit.android.ui.manage.parent.key.ScannedKey import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.util.* @@ -131,8 +133,13 @@ class LoginDialogFragmentModel(application: Application): AndroidViewModel(appli } } null -> { - users.map { users -> - UserListLoginDialogStatus(users) as LoginDialogStatus + logic.fullVersion.isLocalMode.switchMap { isLocalMode -> + users.map { users -> + UserListLoginDialogStatus( + usersToShow = users, + isLocalMode = isLocalMode + ) as LoginDialogStatus + } } } } @@ -186,6 +193,78 @@ class LoginDialogFragmentModel(application: Application): AndroidViewModel(appli } } + fun tryCodeLogin(code: ScannedKey, model: ActivityViewModel) { + runAsync { + loginLock.withLock { + if (!logic.fullVersion.isLocalMode.waitForNonNullValue()) { + Toast.makeText(getApplication(), R.string.error_general, Toast.LENGTH_SHORT).show() + + return@runAsync + } + + val user: User? = Threads.database.executeAndWait { + logic.database.transaction().use { + val keyEntry = logic.database.userKey().findUserKeyByPublicKeySync(code.publicKey) + + if (keyEntry == null) { + Threads.mainThreadHandler.post { + Toast.makeText(getApplication(), R.string.login_scan_code_err_not_linked, Toast.LENGTH_SHORT).show() + } + + return@executeAndWait null + } + + if (keyEntry.lastUse >= code.timestamp) { + Threads.mainThreadHandler.post { + Toast.makeText(getApplication(), R.string.login_scan_code_err_expired, Toast.LENGTH_SHORT).show() + } + + return@executeAndWait null + } + + logic.database.userKey().updateKeyTimestamp(code.publicKey, code.timestamp) + it.setSuccess() + + logic.database.user().getUserByIdSync(keyEntry.userId) + } + } + + if (user != null && user.type == UserType.Parent) { + val hasBlockedTimes = !user.blockedTimes.dataNotToModify.isEmpty + + val shouldSignIn = if (hasBlockedTimes) { + val hasPremium = logic.fullVersion.shouldProvideFullVersionFunctions.waitForNonNullValue() + + if (hasPremium) { + val isGoodTime = blockingReasonUtil.getTrustedMinuteOfWeekLive(TimeZone.getTimeZone(user.timeZone)).map { minuteOfWeek -> + minuteOfWeek != null && user.blockedTimes.dataNotToModify[minuteOfWeek] == false + }.waitForNonNullValue() + + isGoodTime + } else { + true + } + } else { + true + } + + if (shouldSignIn) { + // this feature is limited to the local mode + model.setAuthenticatedUser(AuthenticatedUser( + userId = user.id, + firstPasswordHash = user.password, + secondPasswordHash = "device" + )) + + isLoginDone.value = true + } else { + Toast.makeText(getApplication(), R.string.login_blocked_time, Toast.LENGTH_SHORT).show() + } + } + } + } + } + fun tryParentLogin( password: String, keepSignedIn: Boolean, @@ -329,7 +408,7 @@ class LoginDialogFragmentModel(application: Application): AndroidViewModel(appli } sealed class LoginDialogStatus -data class UserListLoginDialogStatus(val usersToShow: List): LoginDialogStatus() +data class UserListLoginDialogStatus(val usersToShow: List, val isLocalMode: Boolean): LoginDialogStatus() object ParentUserLoginMissingTrustedTime: LoginDialogStatus() object ParentUserLoginBlockedTime: LoginDialogStatus() data class ParentUserLogin( diff --git a/app/src/main/java/io/timelimit/android/ui/login/LoginUserAdapter.kt b/app/src/main/java/io/timelimit/android/ui/login/LoginUserAdapter.kt index 959b5f7..8b1a9ed 100644 --- a/app/src/main/java/io/timelimit/android/ui/login/LoginUserAdapter.kt +++ b/app/src/main/java/io/timelimit/android/ui/login/LoginUserAdapter.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 Jonas Lochmann + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,19 +17,20 @@ package io.timelimit.android.ui.login import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView +import io.timelimit.android.R import io.timelimit.android.data.model.User import io.timelimit.android.ui.list.TextViewHolder import kotlin.properties.Delegates class LoginUserAdapter : RecyclerView.Adapter() { - var data: List? by Delegates.observable(null as List?) { _, _, _ -> notifyDataSetChanged() } + var data: List? by Delegates.observable(null as List?) { _, _, _ -> notifyDataSetChanged() } var listener: LoginUserAdapterListener? by Delegates.observable(null as LoginUserAdapterListener?) { _, _, _ -> notifyDataSetChanged() } init { setHasStableIds(true) } - fun getItem(position: Int): User { + fun getItem(position: Int): LoginUserAdapterItem { return data!![position] } @@ -44,7 +45,12 @@ class LoginUserAdapter : RecyclerView.Adapter() { } override fun getItemId(position: Int): Long { - return getItem(position).id.hashCode().toLong() + val item = getItem(position) + + return when (item) { + is LoginUserAdapterUser -> item.item.id.hashCode().toLong() + LoginUserAdapterScan -> 1 + } } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): TextViewHolder { @@ -55,10 +61,18 @@ class LoginUserAdapter : RecyclerView.Adapter() { val item = getItem(position) val listener = this.listener - holder.textView.text = item.name + holder.textView.text = when (item) { + is LoginUserAdapterUser -> item.item.name + LoginUserAdapterScan -> holder.textView.context.getString(R.string.login_scan_code) + } if (listener !=null) { - holder.textView.setOnClickListener { listener.onUserClicked(item) } + holder.textView.setOnClickListener { + when (item) { + is LoginUserAdapterUser -> listener.onUserClicked(item.item) + LoginUserAdapterScan -> listener.onScanCodeRequested() + } + } } else { holder.textView.setOnClickListener(null) } @@ -67,4 +81,9 @@ class LoginUserAdapter : RecyclerView.Adapter() { interface LoginUserAdapterListener { fun onUserClicked(user: User) + fun onScanCodeRequested() } + +sealed class LoginUserAdapterItem +data class LoginUserAdapterUser(val item: User): LoginUserAdapterItem() +object LoginUserAdapterScan: LoginUserAdapterItem() \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/login/NewLoginFragment.kt b/app/src/main/java/io/timelimit/android/ui/login/NewLoginFragment.kt index 0fa418f..5672c6e 100644 --- a/app/src/main/java/io/timelimit/android/ui/login/NewLoginFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/login/NewLoginFragment.kt @@ -35,6 +35,7 @@ import io.timelimit.android.data.model.User import io.timelimit.android.databinding.NewLoginFragmentBinding import io.timelimit.android.extensions.setOnEnterListenr import io.timelimit.android.ui.main.getActivityViewModel +import io.timelimit.android.ui.manage.parent.key.ScannedKey import io.timelimit.android.ui.view.KeyboardViewListener class NewLoginFragment: DialogFragment() { @@ -115,6 +116,12 @@ class NewLoginFragment: DialogFragment() { // go to the next step model.startSignIn(user) } + + override fun onScanCodeRequested() { + CodeLoginDialogFragment().apply { + setTargetFragment(this@NewLoginFragment, 0) + }.show(parentFragmentManager) + } } binding.userList.recycler.adapter = adapter @@ -181,7 +188,12 @@ class NewLoginFragment: DialogFragment() { binding.switcher.displayedChild = USER_LIST } - adapter.data = status.usersToShow + val users = status.usersToShow.map { LoginUserAdapterUser(it) } + + adapter.data = if (status.isLocalMode) + users + LoginUserAdapterScan + else + users Threads.mainThreadHandler.post { binding.userList.recycler.requestFocus() } @@ -302,4 +314,8 @@ class NewLoginFragment: DialogFragment() { return binding.root } + + fun tryCodeLogin(code: ScannedKey) { + model.tryCodeLogin(code, getActivityViewModel(activity!!)) + } } diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/ManageParentFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/ManageParentFragment.kt index ca17662..6fce800 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/parent/ManageParentFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/ManageParentFragment.kt @@ -38,6 +38,7 @@ import io.timelimit.android.ui.main.AuthenticationFab import io.timelimit.android.ui.main.FragmentWithCustomTitle import io.timelimit.android.ui.manage.child.advanced.timezone.UserTimezoneView import io.timelimit.android.ui.manage.parent.delete.DeleteParentView +import io.timelimit.android.ui.manage.parent.key.ManageUserKeyView class ManageParentFragment : Fragment(), FragmentWithCustomTitle { private val activity: ActivityViewModelHolder by lazy { getActivity() as ActivityViewModelHolder } @@ -115,6 +116,14 @@ class ManageParentFragment : Fragment(), FragmentWithCustomTitle { userEntry = parentUser ) + ManageUserKeyView.bind( + view = binding.userKey, + lifecycleOwner = viewLifecycleOwner, + userId = params.parentId, + auth = activity.getActivityViewModel(), + fragmentManager = parentFragmentManager + ) + binding.handlers = object: ManageParentFragmentHandlers { override fun onChangePasswordClicked() { navigation.safeNavigate( diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/key/AddUserKeyDialogFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/key/AddUserKeyDialogFragment.kt new file mode 100644 index 0000000..2012e39 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/key/AddUserKeyDialogFragment.kt @@ -0,0 +1,77 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.parent.key + +import android.os.Bundle +import android.widget.Toast +import androidx.fragment.app.FragmentManager +import io.timelimit.android.R +import io.timelimit.android.async.Threads +import io.timelimit.android.data.model.UserKey +import io.timelimit.android.data.transaction +import io.timelimit.android.extensions.showSafe +import io.timelimit.android.logic.DefaultAppLogic + +class AddUserKeyDialogFragment: ScanKeyDialogFragment() { + companion object { + private const val DIALOG_TAG = "AddUserKeyDialogFragment" + private const val USER_ID = "userId" + + fun newInstance(userId: String) = AddUserKeyDialogFragment().apply { + arguments = Bundle().apply { + putString(USER_ID, userId) + } + } + } + + override fun handleResult(key: ScannedKey?) { + if (key == null) { + Toast.makeText(context!!, R.string.manage_user_key_invalid, Toast.LENGTH_SHORT).show() + } else { + val context = context!!.applicationContext + val database = DefaultAppLogic.with(context!!).database + val userId = arguments!!.getString(USER_ID)!! + + Threads.database.execute { + database.transaction().use { + val old = database.userKey().findUserKeyByPublicKeySync(key.publicKey) + + if (old == null) { + database.userKey().addUserKeySync( + UserKey( + userId = userId, + publicKey = key.publicKey, + lastUse = key.timestamp + ) + ) + + Threads.mainThreadHandler.post { + Toast.makeText(context, R.string.manage_user_key_added, Toast.LENGTH_SHORT).show() + } + + it.setSuccess() + } else { + Threads.mainThreadHandler.post { + Toast.makeText(context, R.string.manage_user_key_other_user, Toast.LENGTH_SHORT).show() + } + } + } + } + } + } + + fun show(fragmentManager: FragmentManager) = showSafe(fragmentManager, DIALOG_TAG) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/key/ManageUserKeyView.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/key/ManageUserKeyView.kt new file mode 100644 index 0000000..d5c7ddd --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/key/ManageUserKeyView.kt @@ -0,0 +1,70 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.parent.key + +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.Observer +import io.timelimit.android.R +import io.timelimit.android.async.Threads +import io.timelimit.android.crypto.Curve25519 +import io.timelimit.android.databinding.ManageUserKeyViewBinding +import io.timelimit.android.ui.help.HelpDialogFragment +import io.timelimit.android.ui.main.ActivityViewModel + +object ManageUserKeyView { + fun bind( + view: ManageUserKeyViewBinding, + lifecycleOwner: LifecycleOwner, + userId: String, + auth: ActivityViewModel, + fragmentManager: FragmentManager + ) { + val userKey = auth.logic.database.userKey().getUserKeyByUserIdLive(userId) + + userKey.observe(lifecycleOwner, Observer { + view.isLoaded = true + view.keyId = it?.publicKey?.let { Curve25519.getPublicKeyId(it) } + }) + + auth.logic.fullVersion.isLocalMode.observe(lifecycleOwner, Observer { + view.isLocalMode = it + }) + + view.addKeyButton.setOnClickListener { + if (auth.requestAuthenticationOrReturnTrue()) { + AddUserKeyDialogFragment.newInstance(userId).show(fragmentManager) + } + } + + view.removeKeyButton.setOnClickListener { + if (auth.requestAuthenticationOrReturnTrue()) { + val database = auth.database + + Threads.database.execute { + database.userKey().deleteUserKeySync(userId) + } + } + } + + view.titleView.setOnClickListener { + HelpDialogFragment.newInstance( + title = R.string.manage_user_key_title, + text = R.string.manage_user_key_info + ).show(fragmentManager) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/key/ScanKeyDialogFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/key/ScanKeyDialogFragment.kt new file mode 100644 index 0000000..e4868a7 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/key/ScanKeyDialogFragment.kt @@ -0,0 +1,102 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.parent.key + +import android.app.Activity +import android.app.Dialog +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.widget.Toast +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import io.timelimit.android.R + +abstract class ScanKeyDialogFragment: DialogFragment() { + companion object { + private const val CAN_NOT_SCAN = "canNotScan" + private const val REQ_SCAN = 1 + } + + private var canNotScan = false + + final override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (savedInstanceState == null) { + try { + startActivityForResult( + Intent() + .setPackage("de.markusfisch.android.binaryeye") + .setAction("com.google.zxing.client.android.SCAN"), + REQ_SCAN + ) + } catch (ex: ActivityNotFoundException) { + canNotScan = true + } + } else { + canNotScan = savedInstanceState.getBoolean(CAN_NOT_SCAN) + } + } + + final override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + if (canNotScan) { + return AlertDialog.Builder(context!!, theme) + .setTitle(R.string.scan_key_missing_title) + .setMessage(R.string.scan_key_missing_text) + .setNegativeButton(R.string.generic_cancel, null) + .setPositiveButton(R.string.scan_key_missing_install) { _, _ -> + try { + startActivity( + Intent( + Intent.ACTION_VIEW, + Uri.parse("market://details?id=de.markusfisch.android.binaryeye") + + ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } catch (ex: ActivityNotFoundException) { + Toast.makeText(context!!, R.string.error_general, Toast.LENGTH_SHORT).show() + } + } + .create() + } else { + return super.onCreateDialog(savedInstanceState) + } + } + + final override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + + outState.putBoolean(CAN_NOT_SCAN, canNotScan) + } + + final override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + if (requestCode == REQ_SCAN && (!canNotScan)) { + if (resultCode == Activity.RESULT_OK) { + val key = ScannedKey.tryDecode(data?.getStringExtra("SCAN_RESULT") ?: "") + + handleResult(key) + } + + dismiss() + } + } + + abstract fun handleResult(key: ScannedKey?) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/parent/key/ScannedKey.kt b/app/src/main/java/io/timelimit/android/ui/manage/parent/key/ScannedKey.kt new file mode 100644 index 0000000..5b4dcdf --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/parent/key/ScannedKey.kt @@ -0,0 +1,57 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.parent.key + +import android.util.Base64 +import io.timelimit.android.barcode.BarcodeConstants +import io.timelimit.android.crypto.Curve25519 +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class ScannedKey(val publicKey: ByteArray, val timestamp: Long) { + companion object { + private const val expectedSize = 12 + Curve25519.SIGNATURE_SIZE + Curve25519.PUBLIC_KEY_SIZE + + fun tryDecode(dataString: String): ScannedKey? { + val data = try { + Base64.decode(dataString, 0) + } catch (ex: IllegalArgumentException) { + return null + } + + if (data.size != expectedSize) { + return null + } + + val buffer = ByteBuffer.wrap(data).apply { order(ByteOrder.BIG_ENDIAN) } + + if (buffer.getInt(0) != BarcodeConstants.LOCAL_MODE_MAGIC) { + return null + } + + val timestamp = buffer.getLong(4) + val dataToSign = data.copyOfRange(0, 12) + val signature = data.copyOfRange(12, 12 + Curve25519.SIGNATURE_SIZE) + val publicKey = data.copyOfRange(12 + Curve25519.SIGNATURE_SIZE, 12 + Curve25519.SIGNATURE_SIZE + Curve25519.PUBLIC_KEY_SIZE) + + if (!Curve25519.validateSignature(publicKey, dataToSign, signature)) { + return null + } + + return ScannedKey(publicKey, timestamp) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/overview/about/AboutFragment.kt b/app/src/main/java/io/timelimit/android/ui/overview/about/AboutFragment.kt index 6a6b0d3..ecb3c3c 100644 --- a/app/src/main/java/io/timelimit/android/ui/overview/about/AboutFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/overview/about/AboutFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 Jonas Lochmann + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -30,11 +30,22 @@ import io.timelimit.android.logic.AppLogic import io.timelimit.android.logic.DefaultAppLogic class AboutFragment : Fragment() { + companion object { + private const val EXTRA_SHOWN_OUTSIDE_OF_OVERVIEW = "shownOutsideOfOverview" + + fun newInstance(shownOutsideOfOverview: Boolean = false) = AboutFragment().apply { + arguments = Bundle().apply { + putBoolean(EXTRA_SHOWN_OUTSIDE_OF_OVERVIEW, shownOutsideOfOverview) + } + } + } + private val logic: AppLogic by lazy { DefaultAppLogic.with(context!!) } private val listener: AboutFragmentParentHandlers by lazy { parentFragment as AboutFragmentParentHandlers } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { val binding = FragmentAboutBinding.inflate(inflater, container, false) + val shownOutsideOfOverview = arguments?.getBoolean(EXTRA_SHOWN_OUTSIDE_OF_OVERVIEW, false) ?: false mergeLiveData( logic.database.config().getDeviceAuthTokenAsync(), logic.database.config().getFullVersionUntilAsync() @@ -78,14 +89,19 @@ class AboutFragment : Fragment() { binding.containedSoftwareText.movementMethod = LinkMovementMethod.getInstance() binding.sourceCodeUrl.movementMethod = LinkMovementMethod.getInstance() - ResetShownHints.bind( - binding = binding.resetShownHintsView, - lifecycleOwner = this, - database = logic.database - ) + if (shownOutsideOfOverview) { + binding.resetShownHintsView.root.visibility = View.GONE + binding.errorDiagnoseCard.visibility = View.GONE + } else { + ResetShownHints.bind( + binding = binding.resetShownHintsView, + lifecycleOwner = this, + database = logic.database + ) - binding.errorDiagnoseCard.setOnClickListener { - listener.onShowDiagnoseScreen() + binding.errorDiagnoseCard.setOnClickListener { + listener.onShowDiagnoseScreen() + } } return binding.root diff --git a/app/src/main/java/io/timelimit/android/ui/overview/main/MainFragment.kt b/app/src/main/java/io/timelimit/android/ui/overview/main/MainFragment.kt index 08ff615..c090b99 100644 --- a/app/src/main/java/io/timelimit/android/ui/overview/main/MainFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/overview/main/MainFragment.kt @@ -30,6 +30,8 @@ import androidx.navigation.NavController import androidx.navigation.Navigation 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.runAsync import io.timelimit.android.data.model.UserType import io.timelimit.android.extensions.safeNavigate @@ -94,11 +96,22 @@ class MainFragment : Fragment(), OverviewFragmentParentHandlers, AboutFragmentPa } }.observe(viewLifecycleOwner, Observer { shouldShowSetup -> if (shouldShowSetup == true) { - fab.post { - navigation.safeNavigate( - MainFragmentDirections.actionOverviewFragmentToSetupTermsFragment(), - R.id.overviewFragment - ) + runAsync { + val hasParentKey = Threads.database.executeAndWait { logic.database.config().getParentModeKeySync() != null } + + if (parentFragmentManager.isStateSaved == false) { + if (hasParentKey) { + navigation.safeNavigate( + MainFragmentDirections.actionOverviewFragmentToParentModeFragment(), + R.id.overviewFragment + ) + } else { + navigation.safeNavigate( + MainFragmentDirections.actionOverviewFragmentToSetupTermsFragment(), + R.id.overviewFragment + ) + } + } } } else { if (savedInstanceState == null && !didRedirectToUserScreen) { diff --git a/app/src/main/java/io/timelimit/android/ui/parentmode/ParentModeCodeFragment.kt b/app/src/main/java/io/timelimit/android/ui/parentmode/ParentModeCodeFragment.kt new file mode 100644 index 0000000..8738edd --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/parentmode/ParentModeCodeFragment.kt @@ -0,0 +1,46 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.parentmode + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProvider +import io.timelimit.android.barcode.BarcodeMaskDrawable +import io.timelimit.android.databinding.ParentModeCodeFragmentBinding +import io.timelimit.android.logic.DefaultAppLogic + +class ParentModeCodeFragment: Fragment() { + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val binding = ParentModeCodeFragmentBinding.inflate(inflater, container, false) + val model = ViewModelProvider(this).get(ParentModeCodeModel::class.java) + + model.init(DefaultAppLogic.with(context!!).database) + + model.barcodeContent.observe(viewLifecycleOwner, Observer { + binding.image.setImageDrawable(if (it != null) BarcodeMaskDrawable(it) else null) + }) + + model.keyId.observe(viewLifecycleOwner, Observer { + binding.keyId = it + }) + + return binding.root + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/parentmode/ParentModeCodeModel.kt b/app/src/main/java/io/timelimit/android/ui/parentmode/ParentModeCodeModel.kt new file mode 100644 index 0000000..38d3bcf --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/parentmode/ParentModeCodeModel.kt @@ -0,0 +1,87 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.parentmode + +import android.util.Base64 +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import io.timelimit.android.barcode.BarcodeConstants +import io.timelimit.android.barcode.BarcodeMask +import io.timelimit.android.barcode.DataMatrix +import io.timelimit.android.data.Database +import io.timelimit.android.livedata.liveDataFromFunction +import io.timelimit.android.livedata.liveDataFromValue +import io.timelimit.android.livedata.map +import io.timelimit.android.livedata.switchMap +import io.timelimit.android.crypto.Curve25519 +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class ParentModeCodeModel: ViewModel() { + private var didInit = false + lateinit var barcodeContent: LiveData + lateinit var keyId: LiveData + + fun init(database: Database) { + if (didInit) { + return + } + + didInit = true + + val parentKey = database.config().getParentModeKeyLive() + val timestamp = liveDataFromFunction (5 * 1000L) { System.currentTimeMillis() } + + keyId = parentKey.map { key -> + if (key != null) { + Curve25519.getPublicKeyId(Curve25519.getPublicKey(key)) + } else { + "???" + } + } + + barcodeContent = parentKey.switchMap { parentKey -> + if (parentKey == null) { + liveDataFromValue(null as BarcodeMask?) + } else { + val privateKey = Curve25519.getPrivateKey(parentKey) + val publicKey = Curve25519.getPublicKey(parentKey) + + timestamp.map { timestamp -> + val dataToSign = ByteArray(12) + + ByteBuffer.allocate(12).apply { + order(ByteOrder.BIG_ENDIAN) + + putInt(0, BarcodeConstants.LOCAL_MODE_MAGIC) + putLong(4, timestamp) + + get(dataToSign) + } + + val signature = Curve25519.sign( + privateKey, + dataToSign + ) + + val content = dataToSign + signature + publicKey + + DataMatrix.generate(Base64.encodeToString(content, Base64.NO_WRAP)) as BarcodeMask? + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/parentmode/ParentModeFragment.kt b/app/src/main/java/io/timelimit/android/ui/parentmode/ParentModeFragment.kt new file mode 100644 index 0000000..dcc0fad --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/parentmode/ParentModeFragment.kt @@ -0,0 +1,60 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.parentmode + +import android.os.Bundle +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.FragmentTransaction +import io.timelimit.android.R +import io.timelimit.android.ui.overview.about.AboutFragment +import kotlinx.android.synthetic.main.fragment_main.* + +class ParentModeFragment : Fragment() { + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.parent_mode_fragment, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + if (savedInstanceState == null) { + childFragmentManager.beginTransaction() + .replace(R.id.container, ParentModeCodeFragment()) + .commitNow() + } + + bottom_navigation_view.setOnNavigationItemSelectedListener { menuItem -> + if (childFragmentManager.isStateSaved) { + false + } else { + childFragmentManager.beginTransaction() + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + .replace(R.id.container, when (menuItem.itemId) { + R.id.parent_mode_tab_code -> ParentModeCodeFragment() + R.id.parent_mode_tab_help -> ParentModeHelpFragment() + R.id.parent_mode_tab_about -> AboutFragment.newInstance(shownOutsideOfOverview = true) + else -> throw IllegalStateException() + }) + .commit() + + true + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/parentmode/ParentModeHelpFragment.kt b/app/src/main/java/io/timelimit/android/ui/parentmode/ParentModeHelpFragment.kt new file mode 100644 index 0000000..5c89b7b --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/parentmode/ParentModeHelpFragment.kt @@ -0,0 +1,29 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.parentmode + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import io.timelimit.android.R + +class ParentModeHelpFragment: Fragment() { + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.parent_mode_help_fragment, container, false) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/setup/SetupSelectModeFragment.kt b/app/src/main/java/io/timelimit/android/ui/setup/SetupSelectModeFragment.kt index fa360cb..082a5b5 100644 --- a/app/src/main/java/io/timelimit/android/ui/setup/SetupSelectModeFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/setup/SetupSelectModeFragment.kt @@ -32,6 +32,7 @@ import io.timelimit.android.R import io.timelimit.android.databinding.FragmentSetupSelectModeBinding import io.timelimit.android.extensions.safeNavigate import io.timelimit.android.logic.DefaultAppLogic +import io.timelimit.android.ui.setup.parentmode.SetupParentmodeDialogFragment import io.timelimit.android.ui.setup.privacy.PrivacyInfoDialogFragment import kotlinx.android.synthetic.main.fragment_setup_select_mode.* @@ -39,6 +40,7 @@ class SetupSelectModeFragment : Fragment() { companion object { private const val REQ_SETUP_CONNECTED_PARENT = 1 private const val REQ_SETUP_CONNECTED_CHILD = 2 + private const val REQUEST_SETUP_PARENT_MODE = 3 } private lateinit var navigation: NavController @@ -73,6 +75,12 @@ class SetupSelectModeFragment : Fragment() { }.show(fragmentManager!!) } + btn_parent_key_mode.setOnClickListener { + SetupParentmodeDialogFragment().apply { + setTargetFragment(this@SetupSelectModeFragment, REQUEST_SETUP_PARENT_MODE) + }.show(parentFragmentManager) + } + btn_uninstall.setOnClickListener { DefaultAppLogic.with(context!!).platformIntegration.disableDeviceAdmin() @@ -104,6 +112,8 @@ class SetupSelectModeFragment : Fragment() { SetupSelectModeFragmentDirections.actionSetupSelectModeFragmentToSetupParentModeFragment(), R.id.setupSelectModeFragment ) + } else if (requestCode == REQUEST_SETUP_PARENT_MODE && resultCode == Activity.RESULT_OK) { + navigation.popBackStack(R.id.overviewFragment, false) } } } diff --git a/app/src/main/java/io/timelimit/android/ui/setup/parentmode/SetupParentmodeDialogFragment.kt b/app/src/main/java/io/timelimit/android/ui/setup/parentmode/SetupParentmodeDialogFragment.kt new file mode 100644 index 0000000..fdfb6af --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/setup/parentmode/SetupParentmodeDialogFragment.kt @@ -0,0 +1,61 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.setup.parentmode + +import android.app.Activity +import android.app.Dialog +import android.app.ProgressDialog +import android.os.Bundle +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProvider +import io.timelimit.android.R +import io.timelimit.android.extensions.showSafe +import io.timelimit.android.logic.DefaultAppLogic + +class SetupParentmodeDialogFragment: DialogFragment() { + companion object { + private const val DIALOG_TAG = "SetupParentmodeDialogFragment" + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + ViewModelProvider(this).get(SetupParentmodeDialogModel::class.java) + .init(DefaultAppLogic.with(context!!).database) + .observe(this, Observer { ok -> + dismissAllowingStateLoss() + + if (ok) { + targetFragment?.onActivityResult(targetRequestCode, Activity.RESULT_OK, null) + } + }) + + isCancelable = false + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val result = ProgressDialog(context, theme) + + result.setCanceledOnTouchOutside(false) + result.setMessage(getString(R.string.setup_select_mode_parent_key_progress)) + + return result + } + + fun show(fragmentManager: FragmentManager) = showSafe(fragmentManager, DIALOG_TAG) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/setup/parentmode/SetupParentmodeDialogModel.kt b/app/src/main/java/io/timelimit/android/ui/setup/parentmode/SetupParentmodeDialogModel.kt new file mode 100644 index 0000000..bb605c7 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/setup/parentmode/SetupParentmodeDialogModel.kt @@ -0,0 +1,61 @@ +/* + * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package io.timelimit.android.ui.setup.parentmode + +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import io.timelimit.android.async.Threads +import io.timelimit.android.coroutines.executeAndWait +import io.timelimit.android.coroutines.runAsync +import io.timelimit.android.data.Database +import io.timelimit.android.data.transaction +import io.timelimit.android.crypto.Curve25519 + +class SetupParentmodeDialogModel: ViewModel() { + private var didInit = false + private val hadSuccess = MutableLiveData() + + fun init(database: Database): LiveData { + if (!didInit) { + didInit = true + + runAsync { + val keys = Threads.crypto.executeAndWait { Curve25519.generateKeyPair() } + val ok = Threads.database.executeAndWait { + database.transaction().use { + if (database.config().getOwnDeviceIdSync() != null) { + false + } else if (database.config().getParentModeKeySync() != null) { + true + } else { + database.config().setParentModeKeySync(keys) + + it.setSuccess() + + true + } + } + } + + hadSuccess.value = ok + } + } + + return hadSuccess + } +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_help_outline_black_24dp.xml b/app/src/main/res/drawable/ic_help_outline_black_24dp.xml new file mode 100644 index 0000000..e7cf8ea --- /dev/null +++ b/app/src/main/res/drawable/ic_help_outline_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_vpn_key_black_24dp.xml b/app/src/main/res/drawable/ic_vpn_key_black_24dp.xml new file mode 100644 index 0000000..2eddd16 --- /dev/null +++ b/app/src/main/res/drawable/ic_vpn_key_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/fragment_manage_parent.xml b/app/src/main/res/layout/fragment_manage_parent.xml index 5edcc8b..4441a0a 100644 --- a/app/src/main/res/layout/fragment_manage_parent.xml +++ b/app/src/main/res/layout/fragment_manage_parent.xml @@ -157,6 +157,9 @@ + + diff --git a/app/src/main/res/layout/fragment_setup_select_mode.xml b/app/src/main/res/layout/fragment_setup_select_mode.xml index baa3dee..b4738a5 100644 --- a/app/src/main/res/layout/fragment_setup_select_mode.xml +++ b/app/src/main/res/layout/fragment_setup_select_mode.xml @@ -127,6 +127,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +