From a9b1c824f8ab8c1602fe273656b3e33fc38d8dbd Mon Sep 17 00:00:00 2001 From: Jonas Lochmann Date: Mon, 22 Jul 2019 00:00:00 +0000 Subject: [PATCH] Import changes from TimeLimit --- CONTRIBUTING.md | 5 +- app/build.gradle | 13 +- .../5.json | 587 ++++++++++++++++++ app/src/main/AndroidManifest.xml | 17 + .../io/timelimit/android/data/Database.kt | 2 + .../android/data/DatabaseMigrations.kt | 24 + .../io/timelimit/android/data/RoomDatabase.kt | 9 +- .../data/backup/DatabaseBackupLowlevel.kt | 26 + .../android/data/dao/AllowedContactDao.kt | 37 ++ .../android/data/dao/AppActivityDao.kt | 48 ++ .../timelimit/android/data/dao/CategoryDao.kt | 3 + .../timelimit/android/data/dao/ConfigDao.kt | 15 + .../timelimit/android/data/dao/DeviceDao.kt | 3 + .../android/data/model/AllowedContact.kt | 69 ++ .../android/data/model/AppActivity.kt | 83 +++ .../timelimit/android/data/model/Category.kt | 31 +- .../android/data/model/CategoryApp.kt | 11 + .../android/data/model/ConfigurationItem.kt | 21 +- .../io/timelimit/android/data/model/Device.kt | 74 ++- .../timelimit/android/extensions/EditText.kt | 18 + .../platform/PlatformIntegration.kt | 25 +- .../platform/android/AccessibilityService.kt | 51 ++ .../platform/android/AdminReceiver.kt | 13 +- .../platform/android/AndroidIntegration.kt | 114 +++- .../integration/platform/android/Apps.kt | 21 + .../platform/android/BackgroundService.kt | 54 +- .../platform/android/Notification.kt | 29 +- .../platform/android/NotificationListener.kt | 61 +- .../platform/android/OverlayUtil.kt | 81 +++ .../CompatForegroundAppHelper.kt | 13 +- .../foregroundapp/ForegroundAppHelper.kt | 3 +- .../LollipopForegroundAppHelper.kt | 13 +- .../platform/dummy/DummyIntegration.kt | 34 +- .../android/livedata/BooleanConnections.kt | 12 +- .../android/livedata/MultiKeyLiveDataCache.kt | 35 ++ .../io/timelimit/android/logic/AppLogic.kt | 4 + .../timelimit/android/logic/AppSetupLogic.kt | 19 +- .../android/logic/BackgroundTaskLogic.kt | 281 ++++++--- .../timelimit/android/logic/BlockingReason.kt | 240 +++++-- .../android/logic/DefaultUserLogic.kt | 181 ++++++ .../android/logic/SyncInstalledAppsLogic.kt | 112 ++-- .../logic/UsedTimeItemBatchUpdateHelper.kt | 5 +- .../timelimit/android/sync/actions/Actions.kt | 67 +- .../android/sync/actions/apply/ApplyAction.kt | 34 +- .../sync/actions/dispatch/AppLogicAction.kt | 87 +++ .../sync/actions/dispatch/ParentAction.kt | 71 ++- .../io/timelimit/android/ui/MainActivity.kt | 8 +- .../android/ui/contacts/ContactsAdapter.kt | 115 ++++ .../android/ui/contacts/ContactsFragment.kt | 226 +++++++ .../android/ui/contacts/ContactsItem.kt | 23 + .../android/ui/contacts/ContactsModel.kt | 66 ++ .../diagnose/DiagnoseForegroundAppFragment.kt | 113 ++++ .../ui/diagnose/DiagnoseMainFragment.kt | 7 + .../timelimit/android/ui/lock/LockActivity.kt | 30 +- .../timelimit/android/ui/lock/LockFragment.kt | 91 ++- .../android/ui/main/ActivityViewModel.kt | 4 +- .../ui/main/ActivityViewModelHolder.kt | 1 + .../manage/category/ManageCategoryFragment.kt | 58 +- .../ui/manage/category/PagerAdapter.kt | 38 -- .../ui/manage/category/apps/AppAdapter.kt | 4 +- .../manage/category/apps/CategoryAppsModel.kt | 6 +- .../manage/category/apps/add/AddAppAdapter.kt | 9 +- .../apps/add/AddCategoryAppsFragment.kt | 23 + .../AddAppActivitiesDialogFragment.kt | 148 +++++ .../apps/addactivity/AddAppActivityAdapter.kt | 78 +++ .../settings/CategoryNotificationFilter.kt | 57 ++ .../settings/CategorySettingsFragment.kt | 34 + .../settings/CategoryTimeWarningView.kt | 60 ++ .../edit/EditTimeLimitRuleDialogFragment.kt | 39 +- .../ui/manage/child/ManageChildFragment.kt | 50 +- .../android/ui/manage/child/PagerAdapter.kt | 33 - .../category/ManageChildCategoriesFragment.kt | 5 +- ...ivityLaunchPermissionRequiredAndMissing.kt | 42 ++ .../device/manage/ManageDeviceFragment.kt | 190 ++---- .../device/manage/ManageDeviceManipulation.kt | 6 + .../UsageStatsAccessRequiredAndMissing.kt | 7 +- .../device/manage/advanced/ManageDevice.kt | 35 ++ .../advanced/ManageDeviceAdvancedFragment.kt | 103 +++ .../ManageDeviceTroubleshooting.kt | 2 +- .../UpdateDeviceTitleDialogFragment.kt | 2 +- .../defaultuser/ManageDeviceDefaultUser.kt | 102 +++ .../SetDeviceDefaultUserDialogFragment.kt | 131 ++++ ...tDeviceDefaultUserTimeoutDialogFragment.kt | 125 ++++ .../ManageDeviceActivityLevelBlocking.kt | 40 ++ .../feature/ManageDeviceFeaturesFragment.kt | 122 ++++ .../ManageDeviceRebootManipulationView.kt | 2 +- .../InformAboutDeviceOwnerDialogFragment.kt | 2 +- .../ManageDevicePermissionsFragment.kt | 222 +++++++ .../manage/user/ManageDeviceUserFragment.kt | 182 ++++++ .../UnlockAfterManipulationActivity.kt | 2 + .../android/ui/mustread/MustReadFragment.kt | 71 +++ .../android/ui/mustread/MustReadModel.kt | 36 ++ .../android/ui/overview/main/MainFragment.kt | 72 +-- .../ui/overview/overview/OverviewFragment.kt | 5 +- .../overview/overview/OverviewFragmentItem.kt | 4 +- .../setup/SetupDevicePermissionsFragment.kt | 23 +- .../android/ui/user/create/AddUserModel.kt | 2 +- .../android/ui/view/SelectTimeSpanView.kt | 61 +- .../timelimit/android/util/AndroidVersion.kt | 22 + .../android/util/PhoneNumberUtils.java | 115 ++++ .../io/timelimit/android/util/TimeTextUtil.kt | 4 + .../main/play/de-DE/listing/fulldescription | 26 +- app/src/main/play/de-DE/whatsnew | 5 +- .../main/play/en-US/listing/fulldescription | 24 +- app/src/main/play/en-US/listing/icon/icon.png | Bin 28554 -> 17097 bytes app/src/main/play/en-US/whatsnew | 5 +- .../res/drawable/ic_launcher_background.xml | 74 +++ .../res/drawable/ic_launcher_foreground.xml | 13 + .../ic_perm_contact_calendar_black_24dp.xml | 9 + .../drawable/ic_unfold_more_black_24dp.xml | 9 + app/src/main/res/layout/blocking_overlay.xml | 64 ++ .../layout/category_notification_filter.xml | 48 ++ .../layout/category_time_warnings_view.xml | 36 ++ app/src/main/res/layout/contacts_fragment.xml | 24 + app/src/main/res/layout/contacts_intro.xml | 46 ++ app/src/main/res/layout/contacts_item.xml | 57 ++ .../diagnose_foreground_app_fragment.xml | 70 +++ app/src/main/res/layout/fragment_about.xml | 29 +- .../fragment_add_category_activities.xml | 103 +++ .../fragment_add_category_activities_item.xml | 100 +++ .../fragment_add_category_apps_item.xml | 1 + .../res/layout/fragment_category_settings.xml | 6 + .../res/layout/fragment_diagnose_main.xml | 6 + app/src/main/res/layout/fragment_main.xml | 4 +- .../res/layout/fragment_manage_category.xml | 4 +- .../main/res/layout/fragment_manage_child.xml | 4 +- .../res/layout/fragment_manage_device.xml | 250 ++------ .../fragment_setup_device_permissions.xml | 117 ++++ app/src/main/res/layout/lock_fragment.xml | 25 +- .../manage_device_activity_level_blocking.xml | 48 ++ .../manage_device_advanced_fragment.xml | 61 ++ .../res/layout/manage_device_default_user.xml | 131 ++++ .../manage_device_features_fragment.xml | 62 ++ .../manage_device_manipulation_view.xml | 21 + .../manage_device_permissions_fragment.xml | 404 ++++++++++++ .../layout/manage_device_user_fragment.xml | 81 +++ .../main/res/layout/manage_device_view.xml | 43 ++ ...issing.xml => missing_permission_view.xml} | 10 +- .../main/res/layout/view_select_time_span.xml | 189 ++++-- .../main/res/menu/fragment_main_bottom.xml | 5 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 5 + app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 3210 -> 3755 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 0 -> 3755 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 1774 -> 2206 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 0 -> 2206 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 3574 -> 5190 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 0 -> 5190 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 6575 -> 8747 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 0 -> 8747 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 7822 -> 12908 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 0 -> 12908 bytes app/src/main/res/navigation/nav_graph.xml | 76 +++ app/src/main/res/values-de/strings-about.xml | 4 +- .../values-de/strings-background-logic.xml | 6 + .../res/values-de/strings-category-apps.xml | 13 + .../strings-category-notification-filter.xml | 26 + .../strings-category-time-warnings.xml | 21 + .../main/res/values-de/strings-contacts.xml | 32 + .../main/res/values-de/strings-diagnose.xml | 4 + app/src/main/res/values-de/strings-lock.xml | 14 +- ...-manage-device-activity-level-blocking.xml | 23 + .../strings-manage-device-default-user.xml | 39 ++ .../strings-manage-device-manipulation.xml | 2 + .../res/values-de/strings-manage-device.xml | 28 + .../main/res/values-de/strings-must-read.xml | 32 + .../strings-notification-channels.xml | 3 + .../strings-select-time-span-view.xml | 20 + .../strings-setup-device-permissions.xml | 2 + ...-stats-permission-required-and-missing.xml | 4 +- .../main/res/values-de/strings-util-time.xml | 6 + .../res/values/ic_launcher_background.xml | 4 + app/src/main/res/values/strings-about.xml | 3 + .../res/values/strings-background-logic.xml | 6 + .../main/res/values/strings-category-apps.xml | 13 + .../strings-category-notification-filter.xml | 26 + .../values/strings-category-time-warnings.xml | 21 + app/src/main/res/values/strings-contacts.xml | 32 + app/src/main/res/values/strings-diagnose.xml | 4 + app/src/main/res/values/strings-lock.xml | 12 +- ...-manage-device-activity-level-blocking.xml | 23 + .../strings-manage-device-default-user.xml | 36 ++ .../strings-manage-device-manipulation.xml | 2 + .../main/res/values/strings-manage-device.xml | 27 +- app/src/main/res/values/strings-must-read.xml | 32 + .../values/strings-notification-channels.xml | 3 + .../values/strings-select-time-span-view.xml | 20 + .../strings-setup-device-permissions.xml | 2 + ...-stats-permission-required-and-missing.xml | 3 +- app/src/main/res/values/strings-util-time.xml | 5 + app/src/main/res/xml/accesibility.xml | 22 + app/src/main/web_hi_res_512.png | Bin 28554 -> 0 bytes build.gradle | 4 +- gradle/wrapper/gradle-wrapper.properties | 6 +- 194 files changed, 7804 insertions(+), 895 deletions(-) create mode 100644 app/schemas/io.timelimit.android.data.RoomDatabase/5.json create mode 100644 app/src/main/java/io/timelimit/android/data/dao/AllowedContactDao.kt create mode 100644 app/src/main/java/io/timelimit/android/data/dao/AppActivityDao.kt create mode 100644 app/src/main/java/io/timelimit/android/data/model/AllowedContact.kt create mode 100644 app/src/main/java/io/timelimit/android/data/model/AppActivity.kt create mode 100644 app/src/main/java/io/timelimit/android/integration/platform/android/AccessibilityService.kt create mode 100644 app/src/main/java/io/timelimit/android/integration/platform/android/OverlayUtil.kt create mode 100644 app/src/main/java/io/timelimit/android/logic/DefaultUserLogic.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/contacts/ContactsAdapter.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/contacts/ContactsFragment.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/contacts/ContactsItem.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/contacts/ContactsModel.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseForegroundAppFragment.kt delete mode 100644 app/src/main/java/io/timelimit/android/ui/manage/category/PagerAdapter.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/category/apps/addactivity/AddAppActivitiesDialogFragment.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/category/apps/addactivity/AddAppActivityAdapter.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/category/settings/CategoryNotificationFilter.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/category/settings/CategoryTimeWarningView.kt delete mode 100644 app/src/main/java/io/timelimit/android/ui/manage/child/PagerAdapter.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/device/manage/ActivityLaunchPermissionRequiredAndMissing.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/device/manage/advanced/ManageDevice.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/device/manage/advanced/ManageDeviceAdvancedFragment.kt rename app/src/main/java/io/timelimit/android/ui/manage/device/manage/{ => advanced}/ManageDeviceTroubleshooting.kt (96%) rename app/src/main/java/io/timelimit/android/ui/manage/device/manage/{ => advanced}/UpdateDeviceTitleDialogFragment.kt (98%) create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/device/manage/defaultuser/ManageDeviceDefaultUser.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/device/manage/defaultuser/SetDeviceDefaultUserDialogFragment.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/device/manage/defaultuser/SetDeviceDefaultUserTimeoutDialogFragment.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/device/manage/feature/ManageDeviceActivityLevelBlocking.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/device/manage/feature/ManageDeviceFeaturesFragment.kt rename app/src/main/java/io/timelimit/android/ui/manage/device/manage/{ => feature}/ManageDeviceRebootManipulationView.kt (97%) rename app/src/main/java/io/timelimit/android/ui/manage/device/manage/{ => permission}/InformAboutDeviceOwnerDialogFragment.kt (97%) create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/device/manage/permission/ManageDevicePermissionsFragment.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/manage/device/manage/user/ManageDeviceUserFragment.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/mustread/MustReadFragment.kt create mode 100644 app/src/main/java/io/timelimit/android/ui/mustread/MustReadModel.kt create mode 100644 app/src/main/java/io/timelimit/android/util/AndroidVersion.kt create mode 100644 app/src/main/java/io/timelimit/android/util/PhoneNumberUtils.java create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 app/src/main/res/drawable/ic_perm_contact_calendar_black_24dp.xml create mode 100644 app/src/main/res/drawable/ic_unfold_more_black_24dp.xml create mode 100644 app/src/main/res/layout/blocking_overlay.xml create mode 100644 app/src/main/res/layout/category_notification_filter.xml create mode 100644 app/src/main/res/layout/category_time_warnings_view.xml create mode 100644 app/src/main/res/layout/contacts_fragment.xml create mode 100644 app/src/main/res/layout/contacts_intro.xml create mode 100644 app/src/main/res/layout/contacts_item.xml create mode 100644 app/src/main/res/layout/diagnose_foreground_app_fragment.xml create mode 100644 app/src/main/res/layout/fragment_add_category_activities.xml create mode 100644 app/src/main/res/layout/fragment_add_category_activities_item.xml create mode 100644 app/src/main/res/layout/manage_device_activity_level_blocking.xml create mode 100644 app/src/main/res/layout/manage_device_advanced_fragment.xml create mode 100644 app/src/main/res/layout/manage_device_default_user.xml create mode 100644 app/src/main/res/layout/manage_device_features_fragment.xml create mode 100644 app/src/main/res/layout/manage_device_permissions_fragment.xml create mode 100644 app/src/main/res/layout/manage_device_user_fragment.xml create mode 100644 app/src/main/res/layout/manage_device_view.xml rename app/src/main/res/layout/{usage_stats_permission_required_and_missing.xml => missing_permission_view.xml} (87%) create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/values-de/strings-category-notification-filter.xml create mode 100644 app/src/main/res/values-de/strings-category-time-warnings.xml create mode 100644 app/src/main/res/values-de/strings-contacts.xml create mode 100644 app/src/main/res/values-de/strings-manage-device-activity-level-blocking.xml create mode 100644 app/src/main/res/values-de/strings-manage-device-default-user.xml create mode 100644 app/src/main/res/values-de/strings-must-read.xml create mode 100644 app/src/main/res/values-de/strings-select-time-span-view.xml create mode 100644 app/src/main/res/values/ic_launcher_background.xml create mode 100644 app/src/main/res/values/strings-category-notification-filter.xml create mode 100644 app/src/main/res/values/strings-category-time-warnings.xml create mode 100644 app/src/main/res/values/strings-contacts.xml create mode 100644 app/src/main/res/values/strings-manage-device-activity-level-blocking.xml create mode 100644 app/src/main/res/values/strings-manage-device-default-user.xml create mode 100644 app/src/main/res/values/strings-must-read.xml create mode 100644 app/src/main/res/values/strings-select-time-span-view.xml create mode 100644 app/src/main/res/xml/accesibility.xml delete mode 100644 app/src/main/web_hi_res_512.png diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 80e6719..91e4c5c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,9 +1,8 @@ ## bug reports and feature requests - open a ticket here at GitLab -- alternativly, send a message to support@timelimit.io +- alternatively, send a message to support@timelimit.io ## merge requests -This App and the proprietary TimeLimit App are developed by the same developer who prefers to keep them similar to make the maintance easier. -Due to that, merge requests are not wanted to avoid licensing issues when adding something from a merge request to the proprietary version. \ No newline at end of file +Are possible but only after talking with the developer before developing anything. \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle index e5a0f2f..6b07324 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -25,18 +25,21 @@ androidExtensions { } android { - compileSdkVersion 28 + compileSdkVersion 29 defaultConfig { applicationId "io.timelimit.android.open" minSdkVersion 19 - targetSdkVersion 28 - versionCode 5 - versionName "0.2.3" + targetSdkVersion 29 + versionCode 50 + versionName "1.5.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" kapt { arguments { arg("room.schemaLocation", "$projectDir/schemas".toString()) } + javacOptions { + option("-Xmaxerrs", 500) + } } } @@ -63,7 +66,7 @@ android { } dependencies { - def nav_version = "1.0.0-beta02" + def nav_version = "1.0.0" def room_version = "2.0.0" def paging_version = "2.1.0" diff --git a/app/schemas/io.timelimit.android.data.RoomDatabase/5.json b/app/schemas/io.timelimit.android.data.RoomDatabase/5.json new file mode 100644 index 0000000..e5befb0 --- /dev/null +++ b/app/schemas/io.timelimit.android.data.RoomDatabase/5.json @@ -0,0 +1,587 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "56a9f03550c893f49f3487dad7c271b4", + "entities": [ + { + "tableName": "user", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `password` TEXT NOT NULL, `type` TEXT NOT NULL, `timezone` TEXT NOT NULL, `disable_limits_until` INTEGER NOT NULL, `category_for_not_assigned_apps` 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": "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": "categoryForNotAssignedApps", + "columnName": "category_for_not_assigned_apps", + "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, `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, `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": "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": "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}` (`package_name` TEXT NOT NULL, `title` TEXT NOT NULL, `launchable` INTEGER NOT NULL, `recommendation` TEXT NOT NULL, PRIMARY KEY(`package_name`))", + "fields": [ + { + "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": [ + "package_name" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_app_package_name", + "unique": false, + "columnNames": [ + "package_name" + ], + "createSql": "CREATE INDEX `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 `index_category_app_category_id` ON `${TABLE_NAME}` (`category_id`)" + }, + { + "name": "index_category_app_package_name", + "unique": false, + "columnNames": [ + "package_name" + ], + "createSql": "CREATE INDEX `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, `temporarily_blocked` INTEGER NOT NULL, `parent_category_id` TEXT NOT NULL, `block_all_notifications` INTEGER NOT NULL, `time_warnings` 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": "temporarilyBlocked", + "columnName": "temporarily_blocked", + "affinity": "INTEGER", + "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 + } + ], + "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}` (`package_name` TEXT NOT NULL, PRIMARY KEY(`package_name`))", + "fields": [ + { + "fieldPath": "packageName", + "columnName": "package_name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "package_name" + ], + "autoGenerate": false + }, + "indices": [], + "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": "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": [] + } + ], + "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, \"56a9f03550c893f49f3487dad7c271b4\")" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 64e2a40..f4a214a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -20,6 +20,7 @@ + + + @@ -111,6 +115,19 @@ + + + + + + + + + + + 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 4d5b843..a4e4339 100644 --- a/app/src/main/java/io/timelimit/android/data/Database.kt +++ b/app/src/main/java/io/timelimit/android/data/Database.kt @@ -28,6 +28,8 @@ interface Database { fun usedTimes(): UsedTimeDao fun user(): UserDao fun temporarilyAllowedApp(): TemporarilyAllowedAppDao + fun appActivity(): AppActivityDao + fun allowedContact(): AllowedContactDao fun beginTransaction() fun setTransactionSuccessful() diff --git a/app/src/main/java/io/timelimit/android/data/DatabaseMigrations.kt b/app/src/main/java/io/timelimit/android/data/DatabaseMigrations.kt index 76724b0..6945ff5 100644 --- a/app/src/main/java/io/timelimit/android/data/DatabaseMigrations.kt +++ b/app/src/main/java/io/timelimit/android/data/DatabaseMigrations.kt @@ -22,4 +22,28 @@ object DatabaseMigrations { database.execSQL("ALTER TABLE `device` ADD COLUMN `consider_reboot_manipulation` INTEGER NOT NULL DEFAULT 0") } } + + val MIGRATE_TO_V5 = object: Migration(4, 5) { + override fun migrate(database: SupportSQLiteDatabase) { + // device table + database.execSQL("ALTER TABLE `device` ADD COLUMN `current_overlay_permission` TEXT NOT NULL DEFAULT \"not granted\"") + database.execSQL("ALTER TABLE `device` ADD COLUMN `highest_overlay_permission` TEXT NOT NULL DEFAULT \"not granted\"") + database.execSQL("ALTER TABLE `device` ADD COLUMN `current_accessibility_service_permission` INTEGER NOT NULL DEFAULT 0") + database.execSQL("ALTER TABLE `device` ADD COLUMN `was_accessibility_service_permission` INTEGER NOT NULL DEFAULT 0") + database.execSQL("ALTER TABLE `device` ADD COLUMN `enable_activity_level_blocking` INTEGER NOT NULL DEFAULT 0") + database.execSQL("ALTER TABLE `device` ADD COLUMN `q_or_later` INTEGER NOT NULL DEFAULT 0") + database.execSQL("ALTER TABLE `device` ADD COLUMN `default_user` TEXT NOT NULL DEFAULT \"\"") + database.execSQL("ALTER TABLE `device` ADD COLUMN `default_user_timeout` INTEGER NOT NULL DEFAULT 0") + + // category table + database.execSQL("ALTER TABLE `category` ADD COLUMN `block_all_notifications` INTEGER NOT NULL DEFAULT 0") + database.execSQL("ALTER TABLE `category` ADD COLUMN `time_warnings` INTEGER NOT NULL DEFAULT 0") + + // app_activity table + database.execSQL("CREATE TABLE IF NOT EXISTS `app_activity` (`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`))") + + // allowed_contact table + database.execSQL("CREATE TABLE IF NOT EXISTS `allowed_contact` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `phone` TEXT NOT NULL)") + } + } } \ No newline at end of file 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 9e97bb6..cc8cc2d 100644 --- a/app/src/main/java/io/timelimit/android/data/RoomDatabase.kt +++ b/app/src/main/java/io/timelimit/android/data/RoomDatabase.kt @@ -30,8 +30,10 @@ import io.timelimit.android.data.model.* UsedTimeItem::class, TimeLimitRule::class, ConfigurationItem::class, - TemporarilyAllowedApp::class -], version = 4) + TemporarilyAllowedApp::class, + AppActivity::class, + AllowedContact::class +], version = 5) abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database { companion object { private val lock = Object() @@ -69,7 +71,8 @@ abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database .addMigrations( DatabaseMigrations.MIGRATE_TO_V2, DatabaseMigrations.MIGRATE_TO_V3, - DatabaseMigrations.MIGRATE_TO_V4 + DatabaseMigrations.MIGRATE_TO_V4, + DatabaseMigrations.MIGRATE_TO_V5 ) .build() } diff --git a/app/src/main/java/io/timelimit/android/data/backup/DatabaseBackupLowlevel.kt b/app/src/main/java/io/timelimit/android/data/backup/DatabaseBackupLowlevel.kt index 7586b70..a387ae9 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 @@ -37,6 +37,8 @@ object DatabaseBackupLowlevel { private const val TIME_LIMIT_RULE = "timelimitRule" private const val USED_TIME_ITEM = "usedTime" private const val USER = "user" + private const val APP_ACTIVITY = "appActivity" + private const val ALLOWED_CONTACT = "allowedContact" fun outputAsBackupJson(database: Database, outputStream: OutputStream) { val writer = JsonWriter(OutputStreamWriter(outputStream, Charsets.UTF_8)) @@ -77,6 +79,9 @@ object DatabaseBackupLowlevel { handleCollection(TIME_LIMIT_RULE) { offset, pageSize -> database.timeLimitRules().getRulePageSync(offset, pageSize) } handleCollection(USED_TIME_ITEM) { offset, pageSize -> database.usedTimes().getUsedTimePageSync(offset, pageSize) } handleCollection(USER) { offset, pageSize -> database.user().getUserPageSync(offset, pageSize) } + handleCollection(APP_ACTIVITY) { offset, pageSize -> database.appActivity().getAppActivityPageSync(offset, pageSize) } + handleCollection(ALLOWED_CONTACT) { offset, pageSize -> database.allowedContact().getAllowedContactPageSync(offset, pageSize) } + writer.endObject().flush() } @@ -168,6 +173,27 @@ object DatabaseBackupLowlevel { reader.endArray() } + APP_ACTIVITY -> { + reader.beginArray() + + while (reader.hasNext()) { + database.appActivity().addAppActivitySync(AppActivity.parse(reader)) + } + + reader.endArray() + } + ALLOWED_CONTACT -> { + reader.beginArray() + + while (reader.hasNext()) { + database.allowedContact().addContactSync( + // this will use an unused id + AllowedContact.parse(reader).copy(id = 0) + ) + } + + reader.endArray() + } else -> reader.skipValue() } } diff --git a/app/src/main/java/io/timelimit/android/data/dao/AllowedContactDao.kt b/app/src/main/java/io/timelimit/android/data/dao/AllowedContactDao.kt new file mode 100644 index 0000000..a86813f --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/dao/AllowedContactDao.kt @@ -0,0 +1,37 @@ +/* + * TimeLimit Copyright 2019 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.data.dao + +import androidx.lifecycle.LiveData +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query +import io.timelimit.android.data.model.AllowedContact + +@Dao +interface AllowedContactDao { + @Query("SELECT * FROM allowed_contact LIMIT :pageSize OFFSET :offset") + fun getAllowedContactPageSync(offset: Int, pageSize: Int): List + + @Query("SELECT * FROM allowed_contact") + fun getAllowedContactsLive(): LiveData> + + @Insert + fun addContactSync(item: AllowedContact) + + @Query("DELETE FROM allowed_contact WHERE id = :id") + fun removeContactSync(id: Int) +} diff --git a/app/src/main/java/io/timelimit/android/data/dao/AppActivityDao.kt b/app/src/main/java/io/timelimit/android/data/dao/AppActivityDao.kt new file mode 100644 index 0000000..18e528d --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/dao/AppActivityDao.kt @@ -0,0 +1,48 @@ +/* + * TimeLimit Copyright 2019 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.AppActivity + +@Dao +interface AppActivityDao { + @Query("SELECT * FROM app_activity LIMIT :pageSize OFFSET :offset") + fun getAppActivityPageSync(offset: Int, pageSize: Int): List + + @Query("SELECT * FROM app_activity WHERE device_id IN (:deviceIds)") + fun getAppActivitiesByDeviceIds(deviceIds: List): LiveData> + + @Query("SELECT * FROM app_activity WHERE app_package_name = :packageName") + fun getAppActivitiesByPackageName(packageName: String): LiveData> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun addAppActivitySync(item: AppActivity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun addAppActivitiesSync(items: List) + + @Query("DELETE FROM app_activity WHERE device_id = :deviceId AND app_package_name = :packageName AND activity_class_name IN (:activities)") + fun deleteAppActivitiesSync(deviceId: String, packageName: String, activities: List) + + @Query("DELETE FROM app_activity WHERE device_id IN (:deviceIds)") + fun deleteAppActivitiesByDeviceIds(deviceIds: List) +} diff --git a/app/src/main/java/io/timelimit/android/data/dao/CategoryDao.kt b/app/src/main/java/io/timelimit/android/data/dao/CategoryDao.kt index 86b804b..81b37bf 100644 --- a/app/src/main/java/io/timelimit/android/data/dao/CategoryDao.kt +++ b/app/src/main/java/io/timelimit/android/data/dao/CategoryDao.kt @@ -68,6 +68,9 @@ abstract class CategoryDao { @Query("UPDATE category SET parent_category_id = :parentCategoryId WHERE id = :categoryId") abstract fun updateParentCategory(categoryId: String, parentCategoryId: String) + + @Update + abstract fun updateCategorySync(category: Category) } data class CategoryShortInfo( 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 9c58caf..421f7e5 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 @@ -112,4 +112,19 @@ abstract class ConfigDao { fun wasDeviceLockedSync() = getValueOfKeySync(ConfigurationItemType.WasDeviceLocked) == "true" fun setWasDeviceLockedSync(value: Boolean) = updateValueSync(ConfigurationItemType.WasDeviceLocked, if (value) "true" else "false") + + fun getForegroundAppQueryIntervalAsync(): LiveData = getValueOfKeyAsync(ConfigurationItemType.ForegroundAppQueryRange).map { (it ?: "0").toLong() } + fun setForegroundAppQueryIntervalSync(interval: Long) { + if (interval < 0) { + throw IllegalArgumentException() + } + + updateValueSync(ConfigurationItemType.ForegroundAppQueryRange, interval.toString()) + } + + fun getEnableAlternativeDurationSelectionAsync() = getValueOfKeyAsync(ConfigurationItemType.EnableAlternativeDurationSelection).map { it == "1" } + fun setEnableAlternativeDurationSelectionSync(enable: Boolean) = updateValueSync(ConfigurationItemType.EnableAlternativeDurationSelection, if (enable) "1" else "0") + + fun setLastScreenOnTime(time: Long) = updateValueSync(ConfigurationItemType.LastScreenOnTime, time.toString()) + fun getLastScreenOnTime() = getValueOfKeySync(ConfigurationItemType.LastScreenOnTime)?.toLong() ?: 0L } diff --git a/app/src/main/java/io/timelimit/android/data/dao/DeviceDao.kt b/app/src/main/java/io/timelimit/android/data/dao/DeviceDao.kt index 7d80a70..7e02d90 100644 --- a/app/src/main/java/io/timelimit/android/data/dao/DeviceDao.kt +++ b/app/src/main/java/io/timelimit/android/data/dao/DeviceDao.kt @@ -47,6 +47,9 @@ abstract class DeviceDao { @Query("UPDATE device SET current_user_id = :userId WHERE id = :deviceId") abstract fun updateDeviceUser(deviceId: String, userId: String) + @Query("UPDATE device SET default_user = :defaultUserId WHERE id = :deviceId") + abstract fun updateDeviceDefaultUser(deviceId: String, defaultUserId: String) + @Update abstract fun updateDeviceEntry(device: Device) diff --git a/app/src/main/java/io/timelimit/android/data/model/AllowedContact.kt b/app/src/main/java/io/timelimit/android/data/model/AllowedContact.kt new file mode 100644 index 0000000..433f451 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/model/AllowedContact.kt @@ -0,0 +1,69 @@ +/* +* TimeLimit Copyright 2019 Jonas Lochmann +* +* This program is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published by +* the Free Software Foundation version 3 of the License. +* +* This program is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with this program. If not, see . +*/ +package io.timelimit.android.data.model + +import android.util.JsonReader +import android.util.JsonWriter +import androidx.room.Entity +import androidx.room.PrimaryKey +import io.timelimit.android.data.JsonSerializable + +@Entity(tableName = "allowed_contact") +data class AllowedContact( + @PrimaryKey(autoGenerate = true) + val id: Int, + val title: String, + val phone: String +): JsonSerializable { + companion object { + private const val ID = "id" + private const val TITLE = "title" + private const val PHONE = "phone" + + fun parse(reader: JsonReader): AllowedContact { + var id: Int? = null + var title: String? = null + var phone: String? = null + + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + ID -> id = reader.nextInt() + TITLE -> title = reader.nextString() + PHONE -> phone = reader.nextString() + else -> reader.skipValue() + } + } + reader.endObject() + + return AllowedContact( + id = id!!, + title = title!!, + phone = phone!! + ) + } + } + + override fun serialize(writer: JsonWriter) { + writer.beginObject() + + writer.name(ID).value(id) + writer.name(TITLE).value(title) + writer.name(PHONE).value(phone) + + writer.endObject() + } +} diff --git a/app/src/main/java/io/timelimit/android/data/model/AppActivity.kt b/app/src/main/java/io/timelimit/android/data/model/AppActivity.kt new file mode 100644 index 0000000..c7b7fe7 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/model/AppActivity.kt @@ -0,0 +1,83 @@ +/* + * TimeLimit Copyright 2019 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.data.model + +import android.util.JsonReader +import android.util.JsonWriter +import androidx.room.ColumnInfo +import androidx.room.Entity +import io.timelimit.android.data.IdGenerator +import io.timelimit.android.data.JsonSerializable + +@Entity(primaryKeys = ["device_id", "app_package_name", "activity_class_name"], tableName = "app_activity") +data class AppActivity( + @ColumnInfo(name = "device_id") + val deviceId: String, + @ColumnInfo(name = "app_package_name") + val appPackageName: String, + @ColumnInfo(name = "activity_class_name") + val activityClassName: String, + @ColumnInfo(name = "activity_title") + val title: String +): JsonSerializable { + companion object { + private const val DEVICE_ID = "deviceId" + private const val APP_PACKAGE_NAME = "app_package_name" + private const val ACTIVITY_CLASS_NAME = "activity_class_name" + private const val TITLE = "title" + + fun parse(reader: JsonReader): AppActivity { + var deviceId: String? = null + var appPackageName: String? = null + var activityClassName: String? = null + var title: String? = null + + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + DEVICE_ID -> deviceId = reader.nextString() + APP_PACKAGE_NAME -> appPackageName = reader.nextString() + ACTIVITY_CLASS_NAME -> activityClassName = reader.nextString() + TITLE -> title = reader.nextString() + else -> reader.skipValue() + } + } + reader.endObject() + + return AppActivity( + deviceId = deviceId!!, + appPackageName = appPackageName!!, + activityClassName = activityClassName!!, + title = title!! + ) + } + } + + init { + IdGenerator.assertIdValid(deviceId) + } + + override fun serialize(writer: JsonWriter) { + writer.beginObject() + + writer.name(DEVICE_ID).value(deviceId) + writer.name(APP_PACKAGE_NAME).value(appPackageName) + writer.name(ACTIVITY_CLASS_NAME).value(activityClassName) + writer.name(TITLE).value(title) + + writer.endObject() + } +} diff --git a/app/src/main/java/io/timelimit/android/data/model/Category.kt b/app/src/main/java/io/timelimit/android/data/model/Category.kt index 95a7083..e65d5c2 100644 --- a/app/src/main/java/io/timelimit/android/data/model/Category.kt +++ b/app/src/main/java/io/timelimit/android/data/model/Category.kt @@ -44,7 +44,11 @@ data class Category( @ColumnInfo(name = "temporarily_blocked") val temporarilyBlocked: Boolean, @ColumnInfo(name = "parent_category_id") - val parentCategoryId: String + val parentCategoryId: String, + @ColumnInfo(name = "block_all_notifications") + val blockAllNotifications: Boolean, + @ColumnInfo(name = "time_warnings") + val timeWarnings: Int ): JsonSerializable { companion object { const val MINUTES_PER_DAY = 60 * 24 @@ -57,6 +61,8 @@ data class Category( private const val EXTRA_TIME_IN_MILLIS = "extraTimeInMillis" private const val TEMPORARILY_BLOCKED = "temporarilyBlocked" private const val PARENT_CATEGORY_ID = "parentCategoryId" + private const val BlOCK_ALL_NOTIFICATIONS = "blockAllNotifications" + private const val TIME_WARNINGS = "timeWarnings" fun parse(reader: JsonReader): Category { var id: String? = null @@ -67,6 +73,8 @@ data class Category( var temporarilyBlocked: Boolean? = null // this field was added later so it has got a default value var parentCategoryId = "" + var blockAllNotifications = false + var timeWarnings = 0 reader.beginObject() @@ -79,6 +87,8 @@ data class Category( EXTRA_TIME_IN_MILLIS -> extraTimeInMillis = reader.nextLong() TEMPORARILY_BLOCKED -> temporarilyBlocked = reader.nextBoolean() PARENT_CATEGORY_ID -> parentCategoryId = reader.nextString() + BlOCK_ALL_NOTIFICATIONS -> blockAllNotifications = reader.nextBoolean() + TIME_WARNINGS -> timeWarnings = reader.nextInt() else -> reader.skipValue() } } @@ -92,7 +102,9 @@ data class Category( blockedMinutesInWeek = blockedMinutesInWeek!!, extraTimeInMillis = extraTimeInMillis!!, temporarilyBlocked = temporarilyBlocked!!, - parentCategoryId = parentCategoryId + parentCategoryId = parentCategoryId, + blockAllNotifications = blockAllNotifications, + timeWarnings = timeWarnings ) } } @@ -120,7 +132,22 @@ data class Category( writer.name(EXTRA_TIME_IN_MILLIS).value(extraTimeInMillis) writer.name(TEMPORARILY_BLOCKED).value(temporarilyBlocked) writer.name(PARENT_CATEGORY_ID).value(parentCategoryId) + writer.name(BlOCK_ALL_NOTIFICATIONS).value(blockAllNotifications) + writer.name(TIME_WARNINGS).value(timeWarnings) writer.endObject() } } + +object CategoryTimeWarnings { + val durationToBitIndex = mapOf( + 1000L * 60 to 0, // 1 minute + 1000L * 60 * 3 to 1, // 3 minutes + 1000L * 60 * 5 to 2, // 5 minutes + 1000L * 60 * 10 to 3, // 10 minutes + 1000L * 60 * 15 to 4 // 15 minutes + ) + + val durations = durationToBitIndex.keys +} + diff --git a/app/src/main/java/io/timelimit/android/data/model/CategoryApp.kt b/app/src/main/java/io/timelimit/android/data/model/CategoryApp.kt index 9893bb3..4fec809 100644 --- a/app/src/main/java/io/timelimit/android/data/model/CategoryApp.kt +++ b/app/src/main/java/io/timelimit/android/data/model/CategoryApp.kt @@ -56,6 +56,17 @@ data class CategoryApp( } } + @delegate:Transient + val packageNameWithoutActivityName: String by lazy { + if (specifiesActivity) + packageName.substring(0, packageName.indexOf(":")) + else + packageName + } + + @Transient + val specifiesActivity = packageName.contains(":") + init { IdGenerator.assertIdValid(categoryId) 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 e6a5d28..3743a54 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 @@ -77,30 +77,45 @@ data class ConfigurationItem( enum class ConfigurationItemType { OwnDeviceId, ShownHints, - WasDeviceLocked + WasDeviceLocked, + ForegroundAppQueryRange, + EnableAlternativeDurationSelection, + LastScreenOnTime } object ConfigurationItemTypeUtil { private const val OWN_DEVICE_ID = 1 private const val SHOWN_HINTS = 2 private const val WAS_DEVICE_LOCKED = 3 + private const val FOREGROUND_APP_QUERY_RANGE = 4 + private const val ENABLE_ALTERNATIVE_DURATION_SELECTION = 5 + private const val LAST_SCREEN_ON_TIME = 6 val TYPES = listOf( ConfigurationItemType.OwnDeviceId, ConfigurationItemType.ShownHints, - ConfigurationItemType.WasDeviceLocked + ConfigurationItemType.WasDeviceLocked, + ConfigurationItemType.ForegroundAppQueryRange, + ConfigurationItemType.EnableAlternativeDurationSelection, + ConfigurationItemType.LastScreenOnTime ) fun serialize(value: ConfigurationItemType) = when(value) { ConfigurationItemType.OwnDeviceId -> OWN_DEVICE_ID ConfigurationItemType.ShownHints -> SHOWN_HINTS ConfigurationItemType.WasDeviceLocked -> WAS_DEVICE_LOCKED + ConfigurationItemType.ForegroundAppQueryRange -> FOREGROUND_APP_QUERY_RANGE + ConfigurationItemType.EnableAlternativeDurationSelection -> ENABLE_ALTERNATIVE_DURATION_SELECTION + ConfigurationItemType.LastScreenOnTime -> LAST_SCREEN_ON_TIME } fun parse(value: Int) = when(value) { OWN_DEVICE_ID -> ConfigurationItemType.OwnDeviceId SHOWN_HINTS -> ConfigurationItemType.ShownHints WAS_DEVICE_LOCKED -> ConfigurationItemType.WasDeviceLocked + FOREGROUND_APP_QUERY_RANGE -> ConfigurationItemType.ForegroundAppQueryRange + ENABLE_ALTERNATIVE_DURATION_SELECTION -> ConfigurationItemType.EnableAlternativeDurationSelection + LAST_SCREEN_ON_TIME -> ConfigurationItemType.LastScreenOnTime else -> throw IllegalArgumentException() } } @@ -118,4 +133,6 @@ object HintsToShow { const val DEVICE_SCREEN_INTRODUCTION = 2L const val CATEGORIES_INTRODUCTION = 4L const val TIME_LIMIT_RULE_INTRODUCTION = 8L + const val CONTACTS_INTRO = 16L + const val TIMELIMIT_RULE_MUSTREAD = 32L } diff --git a/app/src/main/java/io/timelimit/android/data/model/Device.kt b/app/src/main/java/io/timelimit/android/data/model/Device.kt index c8482b3..be1427e 100644 --- a/app/src/main/java/io/timelimit/android/data/model/Device.kt +++ b/app/src/main/java/io/timelimit/android/data/model/Device.kt @@ -65,8 +65,24 @@ data class Device( val manipulationDidReboot: Boolean, @ColumnInfo(name = "had_manipulation") val hadManipulation: Boolean, + @ColumnInfo(name = "default_user") + val defaultUser: String, + @ColumnInfo(name = "default_user_timeout") + val defaultUserTimeout: Int, @ColumnInfo(name = "consider_reboot_manipulation") - val considerRebootManipulation: Boolean + val considerRebootManipulation: Boolean, + @ColumnInfo(name = "current_overlay_permission") + val currentOverlayPermission: RuntimePermissionStatus, + @ColumnInfo(name = "highest_overlay_permission") + val highestOverlayPermission: RuntimePermissionStatus, + @ColumnInfo(name = "current_accessibility_service_permission") + val accessibilityServiceEnabled: Boolean, + @ColumnInfo(name = "was_accessibility_service_permission") + val wasAccessibilityServiceEnabled: Boolean, + @ColumnInfo(name = "enable_activity_level_blocking") + val enableActivityLevelBlocking: Boolean, + @ColumnInfo(name = "q_or_later") + val qOrLater: Boolean ): JsonSerializable { companion object { private const val ID = "id" @@ -85,7 +101,15 @@ data class Device( private const val TRIED_DISABLING_DEVICE_ADMIN = "tdda" private const val MANIPULATION_DID_REBOOT = "mdr" private const val HAD_MANIPULATION = "hm" + private const val DEFAULT_USER = "du" + private const val DEFAULT_USER_TIMEOUT = "dut" private const val CONSIDER_REBOOT_A_MANIPULATION = "cram" + private const val CURRENT_OVERLAY_PERMISSION = "cop" + private const val HIGHEST_OVERLAY_PERMISSION = "hop" + private const val ACCESSIBILITY_SERVICE_ENABLED = "ase" + private const val WAS_ACCESSIBILITY_SERVICE_ENABLED = "wase" + private const val ENABLE_ACTIVITY_LEVEL_BLOCKING = "ealb" + private const val Q_OR_LATER = "qol" fun parse(reader: JsonReader): Device { var id: String? = null @@ -104,7 +128,15 @@ data class Device( var manipulationTriedDisablingDeviceAdmin: Boolean? = null var manipulationDidReboot: Boolean = false var hadManipulation: Boolean? = null + var defaultUser = "" + var defaultUserTimeout = 0 var considerRebootManipulation = false + var currentOverlayPermission = RuntimePermissionStatus.NotGranted + var highestOverlayPermission = RuntimePermissionStatus.NotGranted + var accessibilityServiceEnabled = false + var wasAccessibilityServiceEnabled = false + var enableActivityLevelBlocking = false + var qOrLater = false reader.beginObject() @@ -126,7 +158,15 @@ data class Device( TRIED_DISABLING_DEVICE_ADMIN -> manipulationTriedDisablingDeviceAdmin = reader.nextBoolean() MANIPULATION_DID_REBOOT -> manipulationDidReboot = reader.nextBoolean() HAD_MANIPULATION -> hadManipulation = reader.nextBoolean() + DEFAULT_USER -> defaultUser = reader.nextString() + DEFAULT_USER_TIMEOUT -> defaultUserTimeout = reader.nextInt() CONSIDER_REBOOT_A_MANIPULATION -> considerRebootManipulation = reader.nextBoolean() + CURRENT_OVERLAY_PERMISSION -> currentOverlayPermission = RuntimePermissionStatusUtil.parse(reader.nextString()) + HIGHEST_OVERLAY_PERMISSION -> highestOverlayPermission = RuntimePermissionStatusUtil.parse(reader.nextString()) + ACCESSIBILITY_SERVICE_ENABLED -> accessibilityServiceEnabled = reader.nextBoolean() + WAS_ACCESSIBILITY_SERVICE_ENABLED -> wasAccessibilityServiceEnabled = reader.nextBoolean() + ENABLE_ACTIVITY_LEVEL_BLOCKING -> enableActivityLevelBlocking = reader.nextBoolean() + Q_OR_LATER -> qOrLater = reader.nextBoolean() else -> reader.skipValue() } } @@ -150,7 +190,15 @@ data class Device( manipulationTriedDisablingDeviceAdmin = manipulationTriedDisablingDeviceAdmin!!, manipulationDidReboot = manipulationDidReboot, hadManipulation = hadManipulation!!, - considerRebootManipulation = considerRebootManipulation + defaultUser = defaultUser, + defaultUserTimeout = defaultUserTimeout, + considerRebootManipulation = considerRebootManipulation, + currentOverlayPermission = currentOverlayPermission, + highestOverlayPermission = highestOverlayPermission, + accessibilityServiceEnabled = accessibilityServiceEnabled, + wasAccessibilityServiceEnabled = wasAccessibilityServiceEnabled, + enableActivityLevelBlocking = enableActivityLevelBlocking, + qOrLater = qOrLater ) } } @@ -198,7 +246,15 @@ data class Device( writer.name(TRIED_DISABLING_DEVICE_ADMIN).value(manipulationTriedDisablingDeviceAdmin) writer.name(MANIPULATION_DID_REBOOT).value(manipulationDidReboot) writer.name(HAD_MANIPULATION).value(hadManipulation) + writer.name(DEFAULT_USER).value(defaultUser) + writer.name(DEFAULT_USER_TIMEOUT).value(defaultUserTimeout) writer.name(CONSIDER_REBOOT_A_MANIPULATION).value(considerRebootManipulation) + writer.name(CURRENT_OVERLAY_PERMISSION).value(RuntimePermissionStatusUtil.serialize(currentOverlayPermission)) + writer.name(HIGHEST_OVERLAY_PERMISSION).value(RuntimePermissionStatusUtil.serialize(highestOverlayPermission)) + writer.name(ACCESSIBILITY_SERVICE_ENABLED).value(accessibilityServiceEnabled) + writer.name(WAS_ACCESSIBILITY_SERVICE_ENABLED).value(wasAccessibilityServiceEnabled) + writer.name(ENABLE_ACTIVITY_LEVEL_BLOCKING).value(enableActivityLevelBlocking) + writer.name(Q_OR_LATER).value(qOrLater) writer.endObject() } @@ -211,6 +267,10 @@ data class Device( val manipulationOfNotificationAccess = currentNotificationAccessPermission != highestNotificationAccessPermission @Transient val manipulationOfAppVersion = currentAppVersion != highestAppVersion + @Transient + val manipulationOfOverlayPermission = currentOverlayPermission != highestOverlayPermission + @Transient + val manipulationOfAccessibilityService = accessibilityServiceEnabled != wasAccessibilityServiceEnabled @Transient val hasActiveManipulationWarning = manipulationOfProtectionLevel || @@ -218,8 +278,16 @@ data class Device( manipulationOfNotificationAccess || manipulationOfAppVersion || manipulationTriedDisablingDeviceAdmin || - manipulationDidReboot + manipulationDidReboot || + manipulationOfOverlayPermission || + manipulationOfAccessibilityService @Transient val hasAnyManipulation = hasActiveManipulationWarning || hadManipulation + + @Transient + val missingPermissionAtQOrLater = qOrLater && + (!accessibilityServiceEnabled) && + (currentOverlayPermission != RuntimePermissionStatus.Granted) && + (currentProtectionLevel != ProtectionLevel.DeviceOwner) } diff --git a/app/src/main/java/io/timelimit/android/extensions/EditText.kt b/app/src/main/java/io/timelimit/android/extensions/EditText.kt index c2542fd..df81dca 100644 --- a/app/src/main/java/io/timelimit/android/extensions/EditText.kt +++ b/app/src/main/java/io/timelimit/android/extensions/EditText.kt @@ -15,6 +15,8 @@ */ package io.timelimit.android.extensions +import android.text.Editable +import android.text.TextWatcher import android.view.KeyEvent import android.view.inputmethod.EditorInfo import android.widget.EditText @@ -40,3 +42,19 @@ fun EditText.setOnEnterListenr(listener: () -> Unit) { } } } + +fun EditText.addOnTextChangedListener(listener: () -> Unit) { + this.addTextChangedListener(object: TextWatcher { + override fun afterTextChanged(s: Editable?) { + // ignore + } + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) { + // ignore + } + + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) { + listener() + } + }) +} diff --git a/app/src/main/java/io/timelimit/android/integration/platform/PlatformIntegration.kt b/app/src/main/java/io/timelimit/android/integration/platform/PlatformIntegration.kt index 7f33cff..2e03891 100644 --- a/app/src/main/java/io/timelimit/android/integration/platform/PlatformIntegration.kt +++ b/app/src/main/java/io/timelimit/android/integration/platform/PlatformIntegration.kt @@ -19,29 +19,37 @@ import android.graphics.drawable.Drawable import android.os.Parcelable import androidx.room.TypeConverter import io.timelimit.android.data.model.App +import io.timelimit.android.data.model.AppActivity import kotlinx.android.parcel.Parcelize abstract class PlatformIntegration( val maximumProtectionLevel: ProtectionLevel ) { abstract fun getLocalApps(): Collection + abstract fun getLocalAppActivities(deviceId: String): Collection abstract fun getLocalAppTitle(packageName: String): String? abstract fun getAppIcon(packageName: String): Drawable? + abstract fun getLauncherAppPackageName(): String? abstract fun getCurrentProtectionLevel(): ProtectionLevel abstract fun getForegroundAppPermissionStatus(): RuntimePermissionStatus abstract fun getDrawOverOtherAppsPermissionStatus(): RuntimePermissionStatus abstract fun getNotificationAccessPermissionStatus(): NewPermissionStatus + abstract fun getOverlayPermissionStatus(): RuntimePermissionStatus + abstract fun isAccessibilityServiceEnabled(): Boolean abstract fun disableDeviceAdmin() abstract fun trySetLockScreenPassword(password: String): Boolean // this must have a fallback if the permission is not granted abstract fun showOverlayMessage(text: String) - abstract fun showAppLockScreen(currentPackageName: String) + abstract fun showAppLockScreen(currentPackageName: String, currentActivityName: String?) + abstract fun muteAudioIfPossible(packageName: String) + abstract fun setShowBlockingOverlay(show: Boolean) // this should throw an SecurityException if the permission is missing - abstract suspend fun getForegroundAppPackageName(): String? + abstract suspend fun getForegroundApp(result: ForegroundAppSpec, queryInterval: Long) abstract fun setAppStatusMessage(message: AppStatusMessage?) abstract fun isScreenOn(): Boolean abstract fun setShowNotificationToRevokeTemporarilyAllowedApps(show: Boolean) + abstract fun showTimeWarningNotification(title: String, text: String) // returns package names for which it was set abstract fun setSuspendedApps(packageNames: List, suspend: Boolean): List abstract fun stopSuspendingForAllApps() @@ -54,6 +62,12 @@ abstract class PlatformIntegration( var installedAppsChangeListener: Runnable? = null } +data class ForegroundAppSpec(var packageName: String?, var activityName: String?) { + companion object { + fun newInstance() = ForegroundAppSpec(packageName = null, activityName = null) + } +} + enum class ProtectionLevel { None, SimpleDeviceAdmin, PasswordDeviceAdmin, DeviceOwner } @@ -170,4 +184,9 @@ class NewPermissionStatusConverter { } @Parcelize -data class AppStatusMessage(val title: String, val text: String): Parcelable +data class AppStatusMessage( + val title: String, + val text: String, + val subtext: String? = null, + val showSwitchToDefaultUserOption: Boolean = false +): Parcelable diff --git a/app/src/main/java/io/timelimit/android/integration/platform/android/AccessibilityService.kt b/app/src/main/java/io/timelimit/android/integration/platform/android/AccessibilityService.kt new file mode 100644 index 0000000..99274ef --- /dev/null +++ b/app/src/main/java/io/timelimit/android/integration/platform/android/AccessibilityService.kt @@ -0,0 +1,51 @@ +/* + * TimeLimit Copyright 2019 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.integration.platform.android + +import android.accessibilityservice.AccessibilityService +import android.view.accessibility.AccessibilityEvent +import io.timelimit.android.logic.DefaultAppLogic + +class AccessibilityService: AccessibilityService() { + companion object { + var instance: io.timelimit.android.integration.platform.android.AccessibilityService? = null + } + + override fun onServiceConnected() { + super.onServiceConnected() + + instance = this + DefaultAppLogic.with(this) // init + } + + override fun onDestroy() { + super.onDestroy() + + instance = null + } + + override fun onAccessibilityEvent(event: AccessibilityEvent?) { + // ignore + } + + override fun onInterrupt() { + // ignore + } + + fun showHomescreen() { + performGlobalAction(GLOBAL_ACTION_HOME) + } +} diff --git a/app/src/main/java/io/timelimit/android/integration/platform/android/AdminReceiver.kt b/app/src/main/java/io/timelimit/android/integration/platform/android/AdminReceiver.kt index e7c8d6b..7364a2f 100644 --- a/app/src/main/java/io/timelimit/android/integration/platform/android/AdminReceiver.kt +++ b/app/src/main/java/io/timelimit/android/integration/platform/android/AdminReceiver.kt @@ -41,14 +41,11 @@ class AdminReceiver: DeviceAdminReceiver() { override fun onDisableRequested(context: Context, intent: Intent?): CharSequence { runAsync { - val logic = DefaultAppLogic.with(context) - - if (logic.database.config().getOwnDeviceId().waitForNullableValue() != null) { - ApplyActionUtil.applyAppLogicAction( - TriedDisablingDeviceAdminAction, - logic - ) - } + ApplyActionUtil.applyAppLogicAction( + action = TriedDisablingDeviceAdminAction, + appLogic = DefaultAppLogic.with(context), + ignoreIfDeviceIsNotConfigured = true + ) } return context.getString(R.string.admin_disable_warning) diff --git a/app/src/main/java/io/timelimit/android/integration/platform/android/AndroidIntegration.kt b/app/src/main/java/io/timelimit/android/integration/platform/android/AndroidIntegration.kt index 1d0b897..c632568 100644 --- a/app/src/main/java/io/timelimit/android/integration/platform/android/AndroidIntegration.kt +++ b/app/src/main/java/io/timelimit/android/integration/platform/android/AndroidIntegration.kt @@ -17,6 +17,7 @@ package io.timelimit.android.integration.platform.android import android.annotation.TargetApi import android.app.ActivityManager +import android.app.Application import android.app.NotificationManager import android.app.PendingIntent import android.app.admin.DevicePolicyManager @@ -27,20 +28,27 @@ import android.content.Intent import android.content.pm.ApplicationInfo import android.content.pm.PackageManager import android.graphics.drawable.Drawable +import android.media.session.MediaSessionManager import android.os.Build import android.os.PowerManager import android.os.UserManager import android.provider.Settings import android.util.Log +import android.view.KeyEvent import android.widget.Toast import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import io.timelimit.android.BuildConfig import io.timelimit.android.R +import io.timelimit.android.coroutines.runAsyncExpectForever import io.timelimit.android.data.model.App +import io.timelimit.android.data.model.AppActivity import io.timelimit.android.integration.platform.* import io.timelimit.android.integration.platform.android.foregroundapp.ForegroundAppHelper import io.timelimit.android.ui.lock.LockActivity +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.consumeEach +import kotlinx.coroutines.delay class AndroidIntegration(context: Context): PlatformIntegration(maximumProtectionLevel) { @@ -65,6 +73,7 @@ class AndroidIntegration(context: Context): PlatformIntegration(maximumProtectio private val activityManager = this.context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager private val notificationManager = this.context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val deviceAdmin = ComponentName(context.applicationContext, AdminReceiver::class.java) + private val overlay = OverlayUtil(context as Application) init { AppsChangeListener.registerBroadcastReceiver(this.context, object : BroadcastReceiver() { @@ -78,10 +87,20 @@ class AndroidIntegration(context: Context): PlatformIntegration(maximumProtectio return AndroidIntegrationApps.getLocalApps(context) } + override fun getLocalAppActivities(deviceId: String): Collection { + return AndroidIntegrationApps.getLocalAppActivities(deviceId, context) + } + override fun getLocalAppTitle(packageName: String): String? { return AndroidIntegrationApps.getAppTitle(packageName, context) } + override fun getLauncherAppPackageName(): String? { + return Intent(Intent.ACTION_MAIN) + .addCategory(Intent.CATEGORY_HOME) + .resolveActivity(context.packageManager)?.packageName + } + override fun getAppIcon(packageName: String): Drawable? { return AndroidIntegrationApps.getAppIcon(packageName, context) } @@ -90,8 +109,8 @@ class AndroidIntegration(context: Context): PlatformIntegration(maximumProtectio return AdminStatus.getAdminStatus(context, policyManager) } - override suspend fun getForegroundAppPackageName(): String? { - return foregroundAppHelper.getForegroundAppPackage() + override suspend fun getForegroundApp(result: ForegroundAppSpec, queryInterval: Long) { + foregroundAppHelper.getForegroundApp(result, queryInterval) } override fun getForegroundAppPermissionStatus(): RuntimePermissionStatus { @@ -128,6 +147,30 @@ class AndroidIntegration(context: Context): PlatformIntegration(maximumProtectio } } + override fun getOverlayPermissionStatus(): RuntimePermissionStatus = overlay.getOverlayPermissionStatus() + + override fun isAccessibilityServiceEnabled(): Boolean { + val service = context.packageName + "/" + AccessibilityService::class.java.canonicalName + + val accessibilityEnabled = try { + Settings.Secure.getInt(context.contentResolver, Settings.Secure.ACCESSIBILITY_ENABLED) + } catch (ex: Settings.SettingNotFoundException) { + 0 + } + + if (accessibilityEnabled == 1) { + val enabledServicesString = Settings.Secure.getString(context.contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES) + + if (!enabledServicesString.isNullOrEmpty()) { + if (enabledServicesString.split(":").contains(service)) { + return true + } + } + } + + return false + } + override fun trySetLockScreenPassword(password: String): Boolean { if (BuildConfig.DEBUG) { Log.d(LOG_TAG, "set password") @@ -153,17 +196,55 @@ class AndroidIntegration(context: Context): PlatformIntegration(maximumProtectio } private var lastAppStatusMessage: AppStatusMessage? = null + private var appStatusMessageChannel = Channel(capacity = Channel.CONFLATED) override fun setAppStatusMessage(message: AppStatusMessage?) { if (lastAppStatusMessage != message) { lastAppStatusMessage = message - - BackgroundService.setStatusMessage(message, context) + appStatusMessageChannel.offer(message) } } - override fun showAppLockScreen(currentPackageName: String) { - LockActivity.start(context, currentPackageName) + init { + runAsyncExpectForever { + appStatusMessageChannel.consumeEach { message -> + BackgroundService.setStatusMessage(message, context) + + delay(200) + } + } + } + + override fun showAppLockScreen(currentPackageName: String, currentActivityName: String?) { + LockActivity.start(context, currentPackageName, currentActivityName) + } + + override fun muteAudioIfPossible(packageName: String) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + if (getNotificationAccessPermissionStatus() == NewPermissionStatus.Granted) { + val manager = context.getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager + val sessions = manager.getActiveSessions(ComponentName(context, NotificationListener::class.java)) + val sessionsOfTheApp = sessions.filter { it.packageName == packageName } + sessionsOfTheApp.forEach { session -> + session.dispatchMediaButtonEvent(KeyEvent( + KeyEvent.ACTION_DOWN, + KeyEvent.KEYCODE_MEDIA_STOP + )) + session.dispatchMediaButtonEvent(KeyEvent( + KeyEvent.ACTION_UP, + KeyEvent.KEYCODE_MEDIA_STOP + )) + } + } + } + } + + override fun setShowBlockingOverlay(show: Boolean) { + if (show) { + overlay.show() + } else { + overlay.hide() + } } override fun isScreenOn(): Boolean { @@ -176,7 +257,7 @@ class AndroidIntegration(context: Context): PlatformIntegration(maximumProtectio override fun setShowNotificationToRevokeTemporarilyAllowedApps(show: Boolean) { if (show) { - NotificationChannels.createAppStatusChannel(notificationManager, context) + NotificationChannels.createNotificationChannels(notificationManager, context) val actionIntent = PendingIntent.getService( context, @@ -206,6 +287,25 @@ class AndroidIntegration(context: Context): PlatformIntegration(maximumProtectio } } + override fun showTimeWarningNotification(title: String, text: String) { + NotificationChannels.createNotificationChannels(notificationManager, context) + + notificationManager.notify( + NotificationIds.TIME_WARNING, + NotificationCompat.Builder(context, NotificationChannels.TIME_WARNING) + .setSmallIcon(R.drawable.ic_stat_timelapse) + .setContentTitle(title) + .setContentText(text) + .setWhen(System.currentTimeMillis()) + .setShowWhen(true) + .setLocalOnly(true) + .setAutoCancel(false) + .setOngoing(false) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .build() + ) + } + override fun disableDeviceAdmin() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { if (policyManager.isDeviceOwnerApp(context.packageName)) { diff --git a/app/src/main/java/io/timelimit/android/integration/platform/android/Apps.kt b/app/src/main/java/io/timelimit/android/integration/platform/android/Apps.kt index f3f43bc..918cda3 100644 --- a/app/src/main/java/io/timelimit/android/integration/platform/android/Apps.kt +++ b/app/src/main/java/io/timelimit/android/integration/platform/android/Apps.kt @@ -25,6 +25,7 @@ import android.provider.ContactsContract import android.provider.Settings import android.provider.Telephony import io.timelimit.android.data.model.App +import io.timelimit.android.data.model.AppActivity import io.timelimit.android.data.model.AppRecommendation object AndroidIntegrationApps { @@ -90,6 +91,26 @@ object AndroidIntegrationApps { return result.values } + fun getLocalAppActivities(deviceId: String, context: Context): Collection { + return context.packageManager.getInstalledApplications(0).asSequence().map { applicationInfo -> + ( + try { + context.packageManager.getPackageInfo(applicationInfo.packageName, PackageManager.GET_ACTIVITIES)?.activities + } catch (ex: PackageManager.NameNotFoundException) { + null + } + ?: emptyArray() + ).map { + AppActivity( + deviceId = deviceId, + appPackageName = applicationInfo.packageName, + activityClassName = it.name, + title = it.loadLabel(context.packageManager).toString() + ) + } + }.flatten().toSet() + } + private fun add(map: MutableMap, resolveInfoList: List, recommendation: AppRecommendation, context: Context) { val packageManager = context.packageManager diff --git a/app/src/main/java/io/timelimit/android/integration/platform/android/BackgroundService.kt b/app/src/main/java/io/timelimit/android/integration/platform/android/BackgroundService.kt index 567c457..fae0ae6 100644 --- a/app/src/main/java/io/timelimit/android/integration/platform/android/BackgroundService.kt +++ b/app/src/main/java/io/timelimit/android/integration/platform/android/BackgroundService.kt @@ -21,12 +21,15 @@ import android.app.Service import android.content.Context import android.content.Intent import android.os.IBinder +import android.widget.Toast import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import io.timelimit.android.R import io.timelimit.android.coroutines.runAsync import io.timelimit.android.integration.platform.AppStatusMessage import io.timelimit.android.logic.DefaultAppLogic +import io.timelimit.android.sync.actions.SignOutAtDeviceAction +import io.timelimit.android.sync.actions.apply.ApplyActionUtil import io.timelimit.android.ui.MainActivity class BackgroundService: Service() { @@ -34,6 +37,7 @@ class BackgroundService: Service() { private const val ACTION = "action" private const val ACTION_SET_NOTIFICATION = "set_notification" private const val ACTION_REVOKE_TEMPORARILY_ALLOWED_APPS = "revoke_temporarily_allowed_apps" + private const val ACTION_SWITCH_TO_DEFAULT_USER = "switch_to_default_user" private const val EXTRA_NOTIFICATION = "notification" fun setStatusMessage(status: AppStatusMessage?, context: Context) { @@ -53,6 +57,16 @@ class BackgroundService: Service() { fun prepareRevokeTemporarilyAllowed(context: Context) = Intent(context, BackgroundService::class.java) .putExtra(ACTION, ACTION_REVOKE_TEMPORARILY_ALLOWED_APPS) + + fun prepareSwitchToDefaultUser(context: Context) = Intent(context, BackgroundService::class.java) + .putExtra(ACTION, ACTION_SWITCH_TO_DEFAULT_USER) + + fun getOpenAppIntent(context: Context) = PendingIntent.getActivity( + context, + PendingIntentIds.OPEN_MAIN_APP, + Intent(context, MainActivity::class.java), + PendingIntent.FLAG_UPDATE_CURRENT + ) } private val notificationManager: NotificationManager by lazy { @@ -68,7 +82,7 @@ class BackgroundService: Service() { DefaultAppLogic.with(this) // create the channel - NotificationChannels.createAppStatusChannel(notificationManager, this) + NotificationChannels.createNotificationChannels(notificationManager, this) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { @@ -78,18 +92,12 @@ class BackgroundService: Service() { if (action == ACTION_SET_NOTIFICATION) { val appStatusMessage = intent.getParcelableExtra(EXTRA_NOTIFICATION) - val openAppIntent = PendingIntent.getActivity( - this, - PendingIntentIds.OPEN_MAIN_APP, - Intent(this, MainActivity::class.java), - PendingIntent.FLAG_UPDATE_CURRENT - ) - val notification = NotificationCompat.Builder(this, NotificationChannels.APP_STATUS) .setSmallIcon(R.drawable.ic_stat_timelapse) .setContentTitle(appStatusMessage.title) .setContentText(appStatusMessage.text) - .setContentIntent(openAppIntent) + .setSubText(appStatusMessage.subtext) + .setContentIntent(getOpenAppIntent(this@BackgroundService)) .setWhen(0) .setShowWhen(false) .setSound(null) @@ -98,6 +106,24 @@ class BackgroundService: Service() { .setAutoCancel(false) .setOngoing(true) .setPriority(NotificationCompat.PRIORITY_LOW) + .let { builder -> + if (appStatusMessage.showSwitchToDefaultUserOption) { + builder.addAction( + NotificationCompat.Action.Builder( + R.drawable.ic_account_circle_black_24dp, + getString(R.string.manage_device_default_user_switch_btn), + PendingIntent.getService( + this@BackgroundService, + PendingIntentIds.SWITCH_TO_DEFAULT_USER, + prepareSwitchToDefaultUser(this@BackgroundService), + PendingIntent.FLAG_UPDATE_CURRENT + ) + ).build() + ) + } + + builder + } .build() if (didPostNotification) { @@ -110,6 +136,16 @@ class BackgroundService: Service() { runAsync { DefaultAppLogic.with(this@BackgroundService).backgroundTaskLogic.resetTemporarilyAllowedApps() } + } else if (action == ACTION_SWITCH_TO_DEFAULT_USER) { + runAsync { + val logic = DefaultAppLogic.with(this@BackgroundService) + + ApplyActionUtil.applyAppLogicAction( + appLogic = logic, + action = SignOutAtDeviceAction, + ignoreIfDeviceIsNotConfigured = true + ) + } } } diff --git a/app/src/main/java/io/timelimit/android/integration/platform/android/Notification.kt b/app/src/main/java/io/timelimit/android/integration/platform/android/Notification.kt index c2b8495..11831f9 100644 --- a/app/src/main/java/io/timelimit/android/integration/platform/android/Notification.kt +++ b/app/src/main/java/io/timelimit/android/integration/platform/android/Notification.kt @@ -25,14 +25,15 @@ object NotificationIds { const val APP_STATUS = 1 const val NOTIFICATION_BLOCKED = 2 const val REVOKE_TEMPORARILY_ALLOWED_APPS = 3 - const val APP_RESET = 4 + const val TIME_WARNING = 4 } object NotificationChannels { const val APP_STATUS = "app status" const val BLOCKED_NOTIFICATIONS_NOTIFICATION = "notification blocked notification" + const val TIME_WARNING = "time warning" - fun createAppStatusChannel(notificationManager: NotificationManager, context: Context) { + private fun createAppStatusChannel(notificationManager: NotificationManager, context: Context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { notificationManager.createNotificationChannel( NotificationChannel( @@ -50,7 +51,7 @@ object NotificationChannels { } } - fun createBlockedNotificationChannel(notificationManager: NotificationManager, context: Context) { + private fun createBlockedNotificationChannel(notificationManager: NotificationManager, context: Context) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { notificationManager.createNotificationChannel( NotificationChannel( @@ -63,9 +64,31 @@ object NotificationChannels { ) } } + + private fun createTimeWarningsNotificationChannel(notificationManager: NotificationManager, context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + notificationManager.createNotificationChannel( + NotificationChannel( + NotificationChannels.TIME_WARNING, + context.getString(R.string.notification_channel_time_warning_title), + NotificationManager.IMPORTANCE_HIGH + ).apply { + description = context.getString(R.string.notification_channel_time_warning_text) + } + ) + } + } + + fun createNotificationChannels(notificationManager: NotificationManager, context: Context) { + createAppStatusChannel(notificationManager, context) + createBlockedNotificationChannel(notificationManager, context) + createTimeWarningsNotificationChannel(notificationManager, context) + } } object PendingIntentIds { const val OPEN_MAIN_APP = 1 const val REVOKE_TEMPORARILY_ALLOWED = 2 + const val SWITCH_TO_DEFAULT_USER = 3 + val DYNAMIC_NOTIFICATION_RANGE = 100..10000 } diff --git a/app/src/main/java/io/timelimit/android/integration/platform/android/NotificationListener.kt b/app/src/main/java/io/timelimit/android/integration/platform/android/NotificationListener.kt index 34ae079..73ebe2b 100644 --- a/app/src/main/java/io/timelimit/android/integration/platform/android/NotificationListener.kt +++ b/app/src/main/java/io/timelimit/android/integration/platform/android/NotificationListener.kt @@ -35,17 +35,19 @@ import io.timelimit.android.logic.* class NotificationListener: NotificationListenerService() { companion object { private const val LOG_TAG = "NotificationListenerLog" + private val SUPPORTS_HIDING_ONGOING_NOTIFICATIONS = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O } private val appLogic: AppLogic by lazy { DefaultAppLogic.with(this) } private val blockingReasonUtil: BlockingReasonUtil by lazy { BlockingReasonUtil(appLogic) } private val notificationManager: NotificationManager by lazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager } private val queryAppTitleCache: QueryAppTitleCache by lazy { QueryAppTitleCache(appLogic.platformIntegration) } + private val lastOngoingNotificationHidden = mutableSetOf() override fun onCreate() { super.onCreate() - NotificationChannels.createBlockedNotificationChannel(notificationManager, this) + NotificationChannels.createNotificationChannels(notificationManager, this) } override fun onNotificationPosted(sbn: StatusBarNotification) { @@ -58,9 +60,25 @@ class NotificationListener: NotificationListenerService() { runAsync { val reason = shouldRemoveNotification(sbn) - if (reason != BlockingReason.None) { + if (reason == BlockingReason.None) { + if (sbn.isOngoing) { + lastOngoingNotificationHidden.remove(sbn.packageName) + } + } else { + appLogic.platformIntegration.muteAudioIfPossible(sbn.packageName) + val success = try { - cancelNotification(sbn.key) + if (sbn.isOngoing && SUPPORTS_HIDING_ONGOING_NOTIFICATIONS) { + // only snooze for 5 seconds to show it again soon + snoozeNotification(sbn.key, 5000) + + if (!lastOngoingNotificationHidden.add(sbn.packageName)) { + // skip showing again a notification that it was blocked + return@runAsync + } + } else { + cancelNotification(sbn.key) + } true } catch (ex: SecurityException) { @@ -91,6 +109,7 @@ class NotificationListener: NotificationListenerService() { BlockingReason.TimeOver -> getString(R.string.lock_reason_short_time_over) BlockingReason.TimeOverExtraTimeCanBeUsedLater -> getString(R.string.lock_reason_short_time_over) BlockingReason.BlockedAtThisTime -> getString(R.string.lock_reason_short_blocked_time_area) + BlockingReason.NotificationsAreBlocked -> getString(R.string.lock_reason_short_notification_blocking) BlockingReason.None -> throw IllegalStateException() } ) @@ -109,25 +128,41 @@ class NotificationListener: NotificationListenerService() { } private suspend fun shouldRemoveNotification(sbn: StatusBarNotification): BlockingReason { - if (sbn.packageName == packageName || sbn.isOngoing) { + if (sbn.packageName == packageName) { return BlockingReason.None } - val blockingReason = blockingReasonUtil.getBlockingReason(sbn.packageName).waitForNonNullValue() - - if (blockingReason == BlockingReason.None) { + if (sbn.isOngoing && (!SUPPORTS_HIDING_ONGOING_NOTIFICATIONS)) { return BlockingReason.None } - if (isSystemApp(sbn.packageName) && blockingReason == BlockingReason.NotPartOfAnCategory) { - return BlockingReason.None + val blockingReason = blockingReasonUtil.getBlockingReason( + packageName = sbn.packageName, + activityName = null + ).waitForNonNullValue() + + if (blockingReason.areNotificationsBlocked) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "blocking notification of ${sbn.packageName} because notifications are blocked") + } + + return BlockingReason.NotificationsAreBlocked } - if (BuildConfig.DEBUG) { - Log.d(LOG_TAG, "blocking notification of ${sbn.packageName} because $blockingReason") - } + return when (blockingReason) { + is NoBlockingReason -> BlockingReason.None + is BlockedReasonDetails -> { + if (isSystemApp(sbn.packageName) && blockingReason.reason == BlockingReason.NotPartOfAnCategory) { + return BlockingReason.None + } - return blockingReason + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "blocking notification of ${sbn.packageName} because ${blockingReason.reason}") + } + + return blockingReason.reason + } + } } private fun isSystemApp(packageName: String): Boolean { diff --git a/app/src/main/java/io/timelimit/android/integration/platform/android/OverlayUtil.kt b/app/src/main/java/io/timelimit/android/integration/platform/android/OverlayUtil.kt new file mode 100644 index 0000000..1c3b719 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/integration/platform/android/OverlayUtil.kt @@ -0,0 +1,81 @@ +/* + * TimeLimit Copyright 2019 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.integration.platform.android + +import android.app.Application +import android.content.Context +import android.view.WindowManager +import android.graphics.PixelFormat +import android.os.Build +import android.provider.Settings +import android.view.LayoutInflater +import io.timelimit.android.async.Threads +import io.timelimit.android.databinding.BlockingOverlayBinding +import io.timelimit.android.integration.platform.RuntimePermissionStatus + +class OverlayUtil(private var application: Application) { + private val windowManager = application.getSystemService(Context.WINDOW_SERVICE) as WindowManager + private var currentView: BlockingOverlayBinding? = null + + fun show() { + if (currentView != null) { + return + } + + if (getOverlayPermissionStatus() == RuntimePermissionStatus.NotGranted) { + return + } + + val view = BlockingOverlayBinding.inflate(LayoutInflater.from(application)) + + val params = WindowManager.LayoutParams( + WindowManager.LayoutParams.MATCH_PARENT, + WindowManager.LayoutParams.MATCH_PARENT, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY + else + WindowManager.LayoutParams.TYPE_SYSTEM_ALERT, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE, + PixelFormat.TRANSLUCENT + ) + + windowManager.addView(view.root, params) + currentView = view + + Threads.mainThreadHandler.postDelayed({ + view.showWarningMessage = true + }, 2000) + } + + fun hide() { + if (currentView == null) { + return + } + + windowManager.removeView(currentView!!.root) + currentView = null + } + + fun isOverlayShown() = currentView?.root?.isShown ?: false + + fun getOverlayPermissionStatus() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) + if (Settings.canDrawOverlays(application)) + RuntimePermissionStatus.Granted + else + RuntimePermissionStatus.NotGranted + else + RuntimePermissionStatus.NotRequired +} diff --git a/app/src/main/java/io/timelimit/android/integration/platform/android/foregroundapp/CompatForegroundAppHelper.kt b/app/src/main/java/io/timelimit/android/integration/platform/android/foregroundapp/CompatForegroundAppHelper.kt index 0f63a9a..8264f78 100644 --- a/app/src/main/java/io/timelimit/android/integration/platform/android/foregroundapp/CompatForegroundAppHelper.kt +++ b/app/src/main/java/io/timelimit/android/integration/platform/android/foregroundapp/CompatForegroundAppHelper.kt @@ -17,16 +17,21 @@ package io.timelimit.android.integration.platform.android.foregroundapp import android.app.ActivityManager import android.content.Context +import io.timelimit.android.integration.platform.ForegroundAppSpec import io.timelimit.android.integration.platform.RuntimePermissionStatus class CompatForegroundAppHelper(context: Context) : ForegroundAppHelper() { private val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager - override suspend fun getForegroundAppPackage(): String? { - return try { - activityManager.getRunningTasks(1)[0].topActivity.packageName + override suspend fun getForegroundApp(result: ForegroundAppSpec, queryInterval: Long) { + try { + val activity = activityManager.getRunningTasks(1)[0].topActivity + + result.packageName = activity.packageName + result.activityName = activity.className } catch (ex: NullPointerException) { - null + result.activityName = null + result.packageName = null } } diff --git a/app/src/main/java/io/timelimit/android/integration/platform/android/foregroundapp/ForegroundAppHelper.kt b/app/src/main/java/io/timelimit/android/integration/platform/android/foregroundapp/ForegroundAppHelper.kt index b65fa3f..269066b 100644 --- a/app/src/main/java/io/timelimit/android/integration/platform/android/foregroundapp/ForegroundAppHelper.kt +++ b/app/src/main/java/io/timelimit/android/integration/platform/android/foregroundapp/ForegroundAppHelper.kt @@ -17,10 +17,11 @@ package io.timelimit.android.integration.platform.android.foregroundapp import android.content.Context import android.os.Build +import io.timelimit.android.integration.platform.ForegroundAppSpec import io.timelimit.android.integration.platform.RuntimePermissionStatus abstract class ForegroundAppHelper { - abstract suspend fun getForegroundAppPackage(): String? + abstract suspend fun getForegroundApp(result: ForegroundAppSpec, queryInterval: Long) abstract fun getPermissionStatus(): RuntimePermissionStatus companion object { diff --git a/app/src/main/java/io/timelimit/android/integration/platform/android/foregroundapp/LollipopForegroundAppHelper.kt b/app/src/main/java/io/timelimit/android/integration/platform/android/foregroundapp/LollipopForegroundAppHelper.kt index 431408d..30d2a5f 100644 --- a/app/src/main/java/io/timelimit/android/integration/platform/android/foregroundapp/LollipopForegroundAppHelper.kt +++ b/app/src/main/java/io/timelimit/android/integration/platform/android/foregroundapp/LollipopForegroundAppHelper.kt @@ -22,6 +22,7 @@ import android.app.usage.UsageStatsManager import android.content.Context import android.os.Build import io.timelimit.android.coroutines.executeAndWait +import io.timelimit.android.integration.platform.ForegroundAppSpec import io.timelimit.android.integration.platform.RuntimePermissionStatus import java.util.concurrent.Executor import java.util.concurrent.Executors @@ -37,11 +38,12 @@ class LollipopForegroundAppHelper(private val context: Context) : ForegroundAppH private var lastQueryTime: Long = 0 private var lastPackage: String? = null + private var lastPackageActivity: String? = null private var lastPackageTime: Long = 0 private val event = UsageEvents.Event() @Throws(SecurityException::class) - override suspend fun getForegroundAppPackage(): String? { + override suspend fun getForegroundApp(result: ForegroundAppSpec, queryInterval: Long) { if (getPermissionStatus() == RuntimePermissionStatus.NotGranted) { throw SecurityException() } @@ -49,10 +51,11 @@ class LollipopForegroundAppHelper(private val context: Context) : ForegroundAppH return foregroundAppThread.executeAndWait { val now = System.currentTimeMillis() - if (lastQueryTime > now) { + if (lastQueryTime > now || queryInterval >= 1000 * 60 * 60 * 24 /* 1 day */) { // if the time went backwards, forget everything lastQueryTime = 0 lastPackage = null + lastPackageActivity = null lastPackageTime = 0 } @@ -66,7 +69,7 @@ class LollipopForegroundAppHelper(private val context: Context) : ForegroundAppH // which seems to provide all data // update: with 1 second, some App switching events were missed // it seems to always work with 1.5 seconds - lastQueryTime - 1500 + lastQueryTime - Math.max(queryInterval, 1500) } usageStatsManager.queryEvents(queryStartTime, now)?.let { usageEvents -> @@ -77,6 +80,7 @@ class LollipopForegroundAppHelper(private val context: Context) : ForegroundAppH if (event.timeStamp > lastPackageTime) { lastPackageTime = event.timeStamp lastPackage = event.packageName + lastPackageActivity = event.className } } } @@ -84,7 +88,8 @@ class LollipopForegroundAppHelper(private val context: Context) : ForegroundAppH lastQueryTime = now - lastPackage + result.packageName = lastPackage + result.activityName = lastPackageActivity } } diff --git a/app/src/main/java/io/timelimit/android/integration/platform/dummy/DummyIntegration.kt b/app/src/main/java/io/timelimit/android/integration/platform/dummy/DummyIntegration.kt index 0a5ee7b..7937738 100644 --- a/app/src/main/java/io/timelimit/android/integration/platform/dummy/DummyIntegration.kt +++ b/app/src/main/java/io/timelimit/android/integration/platform/dummy/DummyIntegration.kt @@ -17,6 +17,7 @@ package io.timelimit.android.integration.platform.dummy import android.graphics.drawable.Drawable import io.timelimit.android.data.model.App +import io.timelimit.android.data.model.AppActivity import io.timelimit.android.integration.platform.* class DummyIntegration( @@ -37,6 +38,10 @@ class DummyIntegration( return localApps } + override fun getLocalAppActivities(deviceId: String): Collection { + return emptySet() + } + override fun getLocalAppTitle(packageName: String): String? { return localApps.find { it.packageName == packageName }?.title } @@ -45,10 +50,20 @@ class DummyIntegration( return null } + override fun getLauncherAppPackageName(): String? = null + override fun getCurrentProtectionLevel(): ProtectionLevel { return protectionLevel } + override fun getOverlayPermissionStatus(): RuntimePermissionStatus { + return RuntimePermissionStatus.NotRequired + } + + override fun isAccessibilityServiceEnabled(): Boolean { + return false + } + override fun getForegroundAppPermissionStatus(): RuntimePermissionStatus { return foregroundAppPermission } @@ -68,10 +83,18 @@ class DummyIntegration( // do nothing } - override fun showAppLockScreen(currentPackageName: String) { + override fun showAppLockScreen(currentPackageName: String, currentActivityName: String?) { launchLockScreenForPackage = currentPackageName } + override fun muteAudioIfPossible(packageName: String) { + // ignore + } + + override fun setShowBlockingOverlay(show: Boolean) { + // ignore + } + fun getAndResetShowAppLockScreen(): String? { try { return launchLockScreenForPackage @@ -80,12 +103,13 @@ class DummyIntegration( } } - override suspend fun getForegroundAppPackageName(): String? { + override suspend fun getForegroundApp(result: ForegroundAppSpec, queryInterval: Long) { if (foregroundAppPermission == RuntimePermissionStatus.NotGranted) { throw SecurityException() } - return foregroundApp + result.packageName = foregroundApp + result.activityName = null } override fun setAppStatusMessage(message: AppStatusMessage?) { @@ -108,6 +132,10 @@ class DummyIntegration( showRevokeTemporarilyAllowedNotification = show } + override fun showTimeWarningNotification(title: String, text: String) { + // nothing to do + } + override fun disableDeviceAdmin() { // nothing to do } diff --git a/app/src/main/java/io/timelimit/android/livedata/BooleanConnections.kt b/app/src/main/java/io/timelimit/android/livedata/BooleanConnections.kt index 14fd1b6..e1ef8ab 100644 --- a/app/src/main/java/io/timelimit/android/livedata/BooleanConnections.kt +++ b/app/src/main/java/io/timelimit/android/livedata/BooleanConnections.kt @@ -18,14 +18,18 @@ package io.timelimit.android.livedata import androidx.lifecycle.LiveData fun LiveData.or(other: LiveData): LiveData { - return mergeLiveData(this, other).map { - (it.first != null && it.first == true) || ( it.second != null && it.second == true) + return this.switchMap { value1 -> + other.map { value2 -> + value1 || value2 + } } } fun LiveData.and(other: LiveData): LiveData { - return mergeLiveData(this, other).map { - (it.first != null && it.first == true) && ( it.second != null && it.second == true) + return this.switchMap { value1 -> + other.map { value2 -> + value1 && value2 + } } } diff --git a/app/src/main/java/io/timelimit/android/livedata/MultiKeyLiveDataCache.kt b/app/src/main/java/io/timelimit/android/livedata/MultiKeyLiveDataCache.kt index bba127c..eeca684 100644 --- a/app/src/main/java/io/timelimit/android/livedata/MultiKeyLiveDataCache.kt +++ b/app/src/main/java/io/timelimit/android/livedata/MultiKeyLiveDataCache.kt @@ -59,6 +59,41 @@ class SingleItemLiveDataCache(private val liveData: LiveData): LiveDataCac } } +class SingleItemLiveDataCacheWithRequery(private val liveDataCreator: () -> LiveData): LiveDataCache() { + private val dummyObserver = Observer { + // do nothing + } + + private var wasUsed = false + private var instance: LiveData? = null + + fun read(): LiveData { + if (instance == null) { + instance = liveDataCreator() + instance!!.observeForever(dummyObserver) + } + + wasUsed = true + + return instance!! + } + + override fun removeAllItems() { + if (instance != null) { + instance!!.removeObserver(dummyObserver) + instance = null + } + } + + override fun reportLoopDone() { + if (instance != null && !wasUsed) { + removeAllItems() + } + + wasUsed = false + } +} + abstract class MultiKeyLiveDataCache: LiveDataCache() { class ItemWrapper(val value: LiveData, var used: Boolean) diff --git a/app/src/main/java/io/timelimit/android/logic/AppLogic.kt b/app/src/main/java/io/timelimit/android/logic/AppLogic.kt index 22a2fcb..6ca598b 100644 --- a/app/src/main/java/io/timelimit/android/logic/AppLogic.kt +++ b/app/src/main/java/io/timelimit/android/logic/AppLogic.kt @@ -65,6 +65,10 @@ class AppLogic( } }.ignoreUnchanged() + private val foregroundAppQueryInterval = database.config().getForegroundAppQueryIntervalAsync().apply { observeForever { } } + fun getForegroundAppQueryInterval() = foregroundAppQueryInterval.value ?: 0L + + val defaultUserLogic = DefaultUserLogic(this) val backgroundTaskLogic = BackgroundTaskLogic(this) val appSetupLogic = AppSetupLogic(this) private val syncAppsLogic = SyncInstalledAppsLogic(this) diff --git a/app/src/main/java/io/timelimit/android/logic/AppSetupLogic.kt b/app/src/main/java/io/timelimit/android/logic/AppSetupLogic.kt index e37a54d..37a26a9 100644 --- a/app/src/main/java/io/timelimit/android/logic/AppSetupLogic.kt +++ b/app/src/main/java/io/timelimit/android/logic/AppSetupLogic.kt @@ -29,6 +29,7 @@ import io.timelimit.android.integration.platform.NewPermissionStatus import io.timelimit.android.integration.platform.ProtectionLevel import io.timelimit.android.integration.platform.RuntimePermissionStatus import io.timelimit.android.ui.user.create.DefaultCategories +import io.timelimit.android.util.AndroidVersion import java.util.* class AppSetupLogic(private val appLogic: AppLogic) { @@ -84,7 +85,15 @@ class AppSetupLogic(private val appLogic: AppLogic) { manipulationTriedDisablingDeviceAdmin = false, manipulationDidReboot = false, hadManipulation = false, - considerRebootManipulation = false + defaultUser = "", + defaultUserTimeout = 0, + considerRebootManipulation = false, + currentOverlayPermission = RuntimePermissionStatus.NotGranted, + highestOverlayPermission = RuntimePermissionStatus.NotGranted, + accessibilityServiceEnabled = false, + wasAccessibilityServiceEnabled = false, + enableActivityLevelBlocking = false, + qOrLater = AndroidVersion.qOrLater ) appLogic.database.device().addDeviceSync(device) @@ -139,7 +148,9 @@ class AppSetupLogic(private val appLogic: AppLogic) { blockedMinutesInWeek = ImmutableBitmask((BitSet())), extraTimeInMillis = 0, temporarilyBlocked = false, - parentCategoryId = "" + parentCategoryId = "", + blockAllNotifications = false, + timeWarnings = 0 )) appLogic.database.category().addCategory(Category( @@ -149,7 +160,9 @@ class AppSetupLogic(private val appLogic: AppLogic) { blockedMinutesInWeek = defaultCategories.allowedGamesBlockedTimes, extraTimeInMillis = 0, temporarilyBlocked = false, - parentCategoryId = "" + parentCategoryId = "", + blockAllNotifications = false, + timeWarnings = 0 )) // add default allowed apps diff --git a/app/src/main/java/io/timelimit/android/logic/BackgroundTaskLogic.kt b/app/src/main/java/io/timelimit/android/logic/BackgroundTaskLogic.kt index 77ac13a..e201dc7 100644 --- a/app/src/main/java/io/timelimit/android/logic/BackgroundTaskLogic.kt +++ b/app/src/main/java/io/timelimit/android/logic/BackgroundTaskLogic.kt @@ -30,17 +30,23 @@ import io.timelimit.android.data.model.* import io.timelimit.android.date.DateInTimezone import io.timelimit.android.date.getMinuteOfWeek import io.timelimit.android.integration.platform.AppStatusMessage +import io.timelimit.android.integration.platform.ForegroundAppSpec import io.timelimit.android.integration.platform.ProtectionLevel +import io.timelimit.android.integration.platform.android.AccessibilityService import io.timelimit.android.integration.platform.android.AndroidIntegrationApps import io.timelimit.android.livedata.* import io.timelimit.android.sync.actions.UpdateDeviceStatusAction import io.timelimit.android.sync.actions.apply.ApplyActionUtil +import io.timelimit.android.util.AndroidVersion import io.timelimit.android.util.TimeTextUtil +import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.util.* class BackgroundTaskLogic(val appLogic: AppLogic) { + var pauseBackgroundLoop = false + companion object { private const val CHECK_PERMISSION_INTERVAL = 10 * 1000L // all 10 seconds private const val BACKGROUND_SERVICE_INTERVAL = 100L // all 100 ms @@ -107,12 +113,15 @@ class BackgroundTaskLogic(val appLogic: AppLogic) { } } + private val shouldDoAutomaticSignOut = SingleItemLiveDataCacheWithRequery { -> appLogic.defaultUserLogic.hasAutomaticSignOut()} + private val liveDataCaches = LiveDataCaches(arrayOf( deviceUserEntryLive, childCategories, appCategories, timeLimitRules, - usedTimesOfCategoryAndWeekByFirstDayOfWeek + usedTimesOfCategoryAndWeekByFirstDayOfWeek, + shouldDoAutomaticSignOut )) private var usedTimeUpdateHelper: UsedTimeItemBatchUpdateHelper? = null @@ -125,6 +134,28 @@ class BackgroundTaskLogic(val appLogic: AppLogic) { private val appTitleCache = QueryAppTitleCache(appLogic.platformIntegration) + private suspend fun openLockscreen(blockedAppPackageName: String, blockedAppActivityName: String?) { + appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( + title = appTitleCache.query(blockedAppPackageName), + text = appLogic.context.getString(R.string.background_logic_opening_lockscreen) + )) + + appLogic.platformIntegration.setShowBlockingOverlay(true) + + if (appLogic.platformIntegration.isAccessibilityServiceEnabled()) { + if (blockedAppPackageName != appLogic.platformIntegration.getLauncherAppPackageName()) { + AccessibilityService.instance?.showHomescreen() + delay(100) + AccessibilityService.instance?.showHomescreen() + delay(100) + } + } + + appLogic.platformIntegration.showAppLockScreen(blockedAppPackageName, blockedAppActivityName) + } + + private val foregroundAppSpec = ForegroundAppSpec.newInstance() + private suspend fun backgroundServiceLoop() { while (true) { // app must be enabled @@ -132,6 +163,7 @@ class BackgroundTaskLogic(val appLogic: AppLogic) { usedTimeUpdateHelper?.commit(appLogic) liveDataCaches.removeAllItems() appLogic.platformIntegration.setAppStatusMessage(null) + appLogic.platformIntegration.setShowBlockingOverlay(false) appLogic.enable.waitUntilValueMatches { it == true } continue @@ -142,9 +174,31 @@ class BackgroundTaskLogic(val appLogic: AppLogic) { if (deviceUserEntry == null || deviceUserEntry.type != UserType.Child) { usedTimeUpdateHelper?.commit(appLogic) - liveDataCaches.removeAllItems() - appLogic.platformIntegration.setAppStatusMessage(null) - deviceUserEntryLive.read().waitUntilValueMatches { it != null && it.type == UserType.Child } + val shouldDoAutomaticSignOut = shouldDoAutomaticSignOut.read() + + if (shouldDoAutomaticSignOut.waitForNonNullValue()) { + appLogic.defaultUserLogic.reportScreenOn(appLogic.platformIntegration.isScreenOn()) + + appLogic.platformIntegration.setAppStatusMessage( + AppStatusMessage( + title = appLogic.context.getString(R.string.background_logic_timeout_title), + text = appLogic.context.getString(R.string.background_logic_timeout_text), + showSwitchToDefaultUserOption = true + ) + ) + appLogic.platformIntegration.setShowBlockingOverlay(false) + + liveDataCaches.reportLoopDone() + appLogic.timeApi.sleep(BACKGROUND_SERVICE_INTERVAL) + } else { + liveDataCaches.removeAllItems() + appLogic.platformIntegration.setAppStatusMessage(null) + appLogic.platformIntegration.setShowBlockingOverlay(false) + + val isChildSignedIn = deviceUserEntryLive.read().map { it != null && it.type == UserType.Child } + + isChildSignedIn.or(shouldDoAutomaticSignOut).waitUntilValueMatches { it == true } + } continue } @@ -159,11 +213,17 @@ class BackgroundTaskLogic(val appLogic: AppLogic) { val minuteOfWeek = getMinuteOfWeek(nowTimestamp, nowTimezone) // eventually remove old used time data - if (dayChangeTracker.reportDayChange(nowDate.dayOfEpoch) == DayChangeTracker.DayChange.NowSinceLongerTime) { - UsedTimeDeleter.deleteOldUsedTimeItems( + run { + val dayChange = dayChangeTracker.reportDayChange(nowDate.dayOfEpoch) + + fun deleteOldUsedTimes() = UsedTimeDeleter.deleteOldUsedTimeItems( database = appLogic.database, date = nowDate ) + + if (dayChange == DayChangeTracker.DayChange.NowSinceLongerTime) { + deleteOldUsedTimes() + } } // get the categories @@ -173,33 +233,63 @@ class BackgroundTaskLogic(val appLogic: AppLogic) { // get the current status val isScreenOn = appLogic.platformIntegration.isScreenOn() + appLogic.defaultUserLogic.reportScreenOn(isScreenOn) + if (!isScreenOn) { if (temporarilyAllowedApps.isNotEmpty()) { resetTemporarilyAllowedApps() } } - val foregroundAppPackageName = appLogic.platformIntegration.getForegroundAppPackageName() + appLogic.platformIntegration.getForegroundApp(foregroundAppSpec, appLogic.getForegroundAppQueryInterval()) + val foregroundAppPackageName = foregroundAppSpec.packageName + val foregroundAppActivityName = foregroundAppSpec.activityName + val activityLevelBlocking = appLogic.deviceEntry.value?.enableActivityLevelBlocking ?: false + + fun showStatusMessageWithCurrentAppTitle(text: String, titlePrefix: String? = "") { + appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( + titlePrefix + appTitleCache.query(foregroundAppPackageName ?: "invalid"), + text, + if (activityLevelBlocking) foregroundAppActivityName?.removePrefix(foregroundAppPackageName ?: "invalid") else null + )) + } + // the following is not executed if the permission is missing - if (foregroundAppPackageName == BuildConfig.APPLICATION_ID) { - // this app itself runs now -> no need for an status message - usedTimeUpdateHelper?.commit(appLogic) - appLogic.platformIntegration.setAppStatusMessage(null) - } else if (foregroundAppPackageName != null && AndroidIntegrationApps.ignoredApps.contains(foregroundAppPackageName)) { + if (pauseBackgroundLoop) { usedTimeUpdateHelper?.commit(appLogic) appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( - appTitleCache.query(foregroundAppPackageName), - appLogic.context.getString(R.string.background_logic_whitelisted) + title = appLogic.context.getString(R.string.background_logic_paused_title), + text = appLogic.context.getString(R.string.background_logic_paused_text) )) + appLogic.platformIntegration.setShowBlockingOverlay(false) + } else if ( + (foregroundAppPackageName == BuildConfig.APPLICATION_ID) || + (foregroundAppPackageName != null && AndroidIntegrationApps.ignoredApps.contains(foregroundAppPackageName))) { + usedTimeUpdateHelper?.commit(appLogic) + showStatusMessageWithCurrentAppTitle( + text = appLogic.context.getString(R.string.background_logic_whitelisted) + ) + appLogic.platformIntegration.setShowBlockingOverlay(false) } else if (foregroundAppPackageName != null && temporarilyAllowedApps.contains(foregroundAppPackageName)) { usedTimeUpdateHelper?.commit(appLogic) - appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( - appTitleCache.query(foregroundAppPackageName), - appLogic.context.getString(R.string.background_logic_temporarily_allowed) - )) + showStatusMessageWithCurrentAppTitle(appLogic.context.getString(R.string.background_logic_temporarily_allowed)) + appLogic.platformIntegration.setShowBlockingOverlay(false) } else if (foregroundAppPackageName != null) { - val appCategory = appCategories.get(Pair(foregroundAppPackageName, categories.map { it.id })).waitForNullableValue() + val categoryIds = categories.map { it.id } + + val appCategory = run { + val appLevelCategoryLive = appCategories.get(foregroundAppPackageName to categoryIds) + + if (activityLevelBlocking) { + val appActivityCategoryLive = appCategories.get("$foregroundAppPackageName:$foregroundAppActivityName" to categoryIds) + + appActivityCategoryLive.waitForNullableValue() ?: appLevelCategoryLive.waitForNullableValue() + } else { + appLevelCategoryLive.waitForNullableValue() + } + } + val category = categories.find { it.id == appCategory?.categoryId } ?: categories.find { it.id == deviceUserEntry.categoryForNotAssignedApps } val parentCategory = categories.find { it.id == category?.parentCategoryId } @@ -207,39 +297,30 @@ class BackgroundTaskLogic(val appLogic: AppLogic) { if (category == null) { usedTimeUpdateHelper?.commit(appLogic) - appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( - title = appTitleCache.query(foregroundAppPackageName), - text = appLogic.context.getString(R.string.background_logic_opening_lockscreen) - )) - appLogic.platformIntegration.setSuspendedApps(listOf(foregroundAppPackageName), true) - appLogic.platformIntegration.showAppLockScreen(foregroundAppPackageName) + if (AndroidIntegrationApps.ignoredApps.contains(foregroundAppPackageName) == false) { + // don't suspend system apps which are whitelisted in any version + appLogic.platformIntegration.setSuspendedApps(listOf(foregroundAppPackageName), true) + } + + openLockscreen(foregroundAppPackageName, foregroundAppActivityName) } else if (category.temporarilyBlocked or (parentCategory?.temporarilyBlocked == true)) { usedTimeUpdateHelper?.commit(appLogic) - appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( - title = appTitleCache.query(foregroundAppPackageName), - text = appLogic.context.getString(R.string.background_logic_opening_lockscreen) - )) - appLogic.platformIntegration.showAppLockScreen(foregroundAppPackageName) + openLockscreen(foregroundAppPackageName, foregroundAppActivityName) } else { // disable time limits temporarily feature if (nowTimestamp < deviceUserEntry.disableLimitsUntil) { - appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( - title = appTitleCache.query(foregroundAppPackageName), - text = appLogic.context.getString(R.string.background_logic_limits_disabled) - )) + showStatusMessageWithCurrentAppTitle(appLogic.context.getString(R.string.background_logic_limits_disabled)) + appLogic.platformIntegration.setShowBlockingOverlay(false) } else if ( // check blocked time areas + // directly blocked (category.blockedMinutesInWeek.read(minuteOfWeek)) or (parentCategory?.blockedMinutesInWeek?.read(minuteOfWeek) == true) ) { usedTimeUpdateHelper?.commit(appLogic) - appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( - title = appTitleCache.query(foregroundAppPackageName), - text = appLogic.context.getString(R.string.background_logic_opening_lockscreen) - )) - appLogic.platformIntegration.showAppLockScreen(foregroundAppPackageName) + openLockscreen(foregroundAppPackageName, foregroundAppActivityName) } else { // check time limits val rules = timeLimitRules.get(category.id).waitForNonNullValue() @@ -251,10 +332,11 @@ class BackgroundTaskLogic(val appLogic: AppLogic) { // unlimited usedTimeUpdateHelper?.commit(appLogic) - appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( - category.title + " - " + appTitleCache.query(foregroundAppPackageName), - appLogic.context.getString(R.string.background_logic_no_timelimit) - )) + showStatusMessageWithCurrentAppTitle( + text = appLogic.context.getString(R.string.background_logic_no_timelimit), + titlePrefix = category.title + " - " + ) + appLogic.platformIntegration.setShowBlockingOverlay(false) } else { val usedTimes = usedTimesOfCategoryAndWeekByFirstDayOfWeek.get(Pair(category.id, nowDate.dayOfEpoch - nowDate.dayOfWeek)).waitForNonNullValue() val parentUsedTimes = parentCategory?.let { @@ -317,42 +399,57 @@ class BackgroundTaskLogic(val appLogic: AppLogic) { usedTimeUpdateHelper?.commit(appLogic) - appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( - category.title + " - " + appTitleCache.query(foregroundAppPackageName), - appLogic.context.getString(R.string.background_logic_no_timelimit) - )) + showStatusMessageWithCurrentAppTitle( + text = appLogic.context.getString(R.string.background_logic_no_timelimit), + titlePrefix = category.title + " - " + ) + appLogic.platformIntegration.setShowBlockingOverlay(false) } else { // time limited if (remaining.includingExtraTime > 0) { + var subtractExtraTime: Boolean + if (remaining.default == 0L) { // using extra time - - appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( - category.title + " - " + appTitleCache.query(foregroundAppPackageName), - appLogic.context.getString(R.string.background_logic_using_extra_time, TimeTextUtil.remaining(remaining.includingExtraTime.toInt(), appLogic.context)) - )) - - if (isScreenOn) { - newUsedTimeItemBatchUpdateHelper.addUsedTime( - Math.min(previousMainLogicExecutionTime, MAX_USED_TIME_PER_ROUND), // never save more than a second of used time - true, - appLogic - ) - } + showStatusMessageWithCurrentAppTitle( + text = appLogic.context.getString(R.string.background_logic_using_extra_time, TimeTextUtil.remaining(remaining.includingExtraTime.toInt(), appLogic.context)), + titlePrefix = category.title + " - " + ) + subtractExtraTime = true } else { // using normal contingent + showStatusMessageWithCurrentAppTitle( + text = TimeTextUtil.remaining(remaining.default.toInt(), appLogic.context), + titlePrefix = category.title + " - " + ) + subtractExtraTime = false + } - appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( - category.title + " - " + appTitleCache.query(foregroundAppPackageName), - TimeTextUtil.remaining(remaining.default.toInt(), appLogic.context) - )) + appLogic.platformIntegration.setShowBlockingOverlay(false) + if (isScreenOn) { + // never save more than a second of used time + val timeToSubtract = Math.min(previousMainLogicExecutionTime, MAX_USED_TIME_PER_ROUND) - if (isScreenOn) { - newUsedTimeItemBatchUpdateHelper.addUsedTime( - Math.min(previousMainLogicExecutionTime, MAX_USED_TIME_PER_ROUND), // never save more than a second of used time - false, - appLogic - ) + newUsedTimeItemBatchUpdateHelper.addUsedTime( + timeToSubtract, + subtractExtraTime, + appLogic + ) + + val oldRemainingTime = remaining.includingExtraTime + val newRemainingTime = oldRemainingTime - timeToSubtract + + if (oldRemainingTime / (1000 * 60) != newRemainingTime / (1000 * 60)) { + // eventually show remaining time warning + val roundedNewTime = ((newRemainingTime / (1000 * 60)) + 1) * (1000 * 60) + val flagIndex = CategoryTimeWarnings.durationToBitIndex[roundedNewTime] + + if (flagIndex != null && category.timeWarnings and (1 shl flagIndex) != 0) { + appLogic.platformIntegration.showTimeWarningNotification( + title = appLogic.context.getString(R.string.time_warning_not_title, category.title), + text = TimeTextUtil.remaining(roundedNewTime.toInt(), appLogic.context) + ) + } } } } else { @@ -360,11 +457,7 @@ class BackgroundTaskLogic(val appLogic: AppLogic) { newUsedTimeItemBatchUpdateHelper.commit(appLogic) - appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( - title = appTitleCache.query(foregroundAppPackageName), - text = appLogic.context.getString(R.string.background_logic_opening_lockscreen) - )) - appLogic.platformIntegration.showAppLockScreen(foregroundAppPackageName) + openLockscreen(foregroundAppPackageName, foregroundAppActivityName) } } } @@ -375,6 +468,7 @@ class BackgroundTaskLogic(val appLogic: AppLogic) { appLogic.context.getString(R.string.background_logic_idle_title), appLogic.context.getString(R.string.background_logic_idle_text) )) + appLogic.platformIntegration.setShowBlockingOverlay(false) } } catch (ex: SecurityException) { // this is handled by an other main loop (with a delay) @@ -383,6 +477,7 @@ class BackgroundTaskLogic(val appLogic: AppLogic) { appLogic.context.getString(R.string.background_logic_error), appLogic.context.getString(R.string.background_logic_error_permission) )) + appLogic.platformIntegration.setShowBlockingOverlay(false) } catch (ex: Exception) { if (BuildConfig.DEBUG) { Log.w(LOG_TAG, "exception during running main loop", ex) @@ -392,6 +487,7 @@ class BackgroundTaskLogic(val appLogic: AppLogic) { appLogic.context.getString(R.string.background_logic_error), appLogic.context.getString(R.string.background_logic_error_internal) )) + appLogic.platformIntegration.setShowBlockingOverlay(false) } liveDataCaches.reportLoopDone() @@ -413,10 +509,11 @@ class BackgroundTaskLogic(val appLogic: AppLogic) { if (deviceEntry != null) { if (deviceEntry.currentAppVersion != currentAppVersion) { ApplyActionUtil.applyAppLogicAction( - UpdateDeviceStatusAction.empty.copy( + action = UpdateDeviceStatusAction.empty.copy( newAppVersion = currentAppVersion ), - appLogic + appLogic = appLogic, + ignoreIfDeviceIsNotConfigured = true ) } } @@ -446,10 +543,11 @@ class BackgroundTaskLogic(val appLogic: AppLogic) { if (deviceEntry?.considerRebootManipulation == true) { ApplyActionUtil.applyAppLogicAction( - UpdateDeviceStatusAction.empty.copy( + action = UpdateDeviceStatusAction.empty.copy( didReboot = true ), - appLogic + appLogic = appLogic, + ignoreIfDeviceIsNotConfigured = true ) } } @@ -463,6 +561,9 @@ class BackgroundTaskLogic(val appLogic: AppLogic) { val protectionLevel = appLogic.platformIntegration.getCurrentProtectionLevel() val usageStatsPermission = appLogic.platformIntegration.getForegroundAppPermissionStatus() val notificationAccess = appLogic.platformIntegration.getNotificationAccessPermissionStatus() + val overlayPermission = appLogic.platformIntegration.getOverlayPermissionStatus() + val accessibilityService = appLogic.platformIntegration.isAccessibilityServiceEnabled() + val qOrLater = AndroidVersion.qOrLater var changes = UpdateDeviceStatusAction.empty @@ -488,8 +589,28 @@ class BackgroundTaskLogic(val appLogic: AppLogic) { ) } + if (overlayPermission != deviceEntry.currentOverlayPermission) { + changes = changes.copy( + newOverlayPermission = overlayPermission + ) + } + + if (accessibilityService != deviceEntry.accessibilityServiceEnabled) { + changes = changes.copy( + newAccessibilityServiceEnabled = accessibilityService + ) + } + + if (qOrLater && !deviceEntry.qOrLater) { + changes = changes.copy(isQOrLaterNow = true) + } + if (changes != UpdateDeviceStatusAction.empty) { - ApplyActionUtil.applyAppLogicAction(changes, appLogic) + ApplyActionUtil.applyAppLogicAction( + action = changes, + appLogic = appLogic, + ignoreIfDeviceIsNotConfigured = true + ) } } } diff --git a/app/src/main/java/io/timelimit/android/logic/BlockingReason.kt b/app/src/main/java/io/timelimit/android/logic/BlockingReason.kt index 9e61ef8..d969cc9 100644 --- a/app/src/main/java/io/timelimit/android/logic/BlockingReason.kt +++ b/app/src/main/java/io/timelimit/android/logic/BlockingReason.kt @@ -1,5 +1,5 @@ /* - * Open TimeLimit Copyright 2019 Jonas Lochmann + * TimeLimit Copyright 2019 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 @@ -20,10 +20,7 @@ import android.util.SparseLongArray import androidx.lifecycle.LiveData import androidx.lifecycle.Transformations import io.timelimit.android.BuildConfig -import io.timelimit.android.data.model.Category -import io.timelimit.android.data.model.TimeLimitRule -import io.timelimit.android.data.model.User -import io.timelimit.android.data.model.UserType +import io.timelimit.android.data.model.* import io.timelimit.android.date.DateInTimezone import io.timelimit.android.date.getMinuteOfWeek import io.timelimit.android.integration.platform.android.AndroidIntegrationApps @@ -37,69 +34,106 @@ enum class BlockingReason { TemporarilyBlocked, BlockedAtThisTime, TimeOver, - TimeOverExtraTimeCanBeUsedLater + TimeOverExtraTimeCanBeUsedLater, + NotificationsAreBlocked } +enum class BlockingLevel { + App, + Activity +} + +sealed class BlockingReasonDetail { + abstract val areNotificationsBlocked: Boolean +} +data class NoBlockingReason( + override val areNotificationsBlocked: Boolean +): BlockingReasonDetail() { + companion object { + private val instanceWithoutNotificationsBlocked = NoBlockingReason(areNotificationsBlocked = false) + private val instanceWithNotificationsBlocked = NoBlockingReason(areNotificationsBlocked = true) + + fun getInstance(areNotificationsBlocked: Boolean) = if (areNotificationsBlocked) + instanceWithNotificationsBlocked + else + instanceWithoutNotificationsBlocked + } +} +data class BlockedReasonDetails( + val reason: BlockingReason, + val level: BlockingLevel, + val categoryId: String?, + override val areNotificationsBlocked: Boolean +): BlockingReasonDetail() + class BlockingReasonUtil(private val appLogic: AppLogic) { companion object { private const val LOG_TAG = "BlockingReason" } - fun getBlockingReason(packageName: String): LiveData { + private val enableActivityLevelFiltering = appLogic.deviceEntry.map { it?.enableActivityLevelBlocking ?: false } + + fun getBlockingReason(packageName: String, activityName: String?): LiveData { // check precondition that the app is running return appLogic.enable.switchMap { enabled -> if (enabled == null || enabled == false) { - liveDataFromValue(BlockingReason.None) + liveDataFromValue(NoBlockingReason.getInstance(areNotificationsBlocked = false) as BlockingReasonDetail) } else { appLogic.deviceUserEntry.switchMap { user -> if (user == null || user.type != UserType.Child) { - liveDataFromValue(BlockingReason.None) + liveDataFromValue(NoBlockingReason.getInstance(areNotificationsBlocked = false) as BlockingReasonDetail) } else { - getBlockingReasonStep2(packageName, user, TimeZone.getTimeZone(user.timeZone)) + getBlockingReasonStep2(packageName, activityName, user, TimeZone.getTimeZone(user.timeZone)) } } } } } - private fun getBlockingReasonStep2(packageName: String, child: User, timeZone: TimeZone): LiveData { + private fun getBlockingReasonStep2(packageName: String, activityName: String?, child: User, timeZone: TimeZone): LiveData { if (BuildConfig.DEBUG) { Log.d(LOG_TAG, "step 2") } // check internal whitelist if (packageName == BuildConfig.APPLICATION_ID) { - return liveDataFromValue(BlockingReason.None) + return liveDataFromValue(NoBlockingReason.getInstance(areNotificationsBlocked = false)) } else if (AndroidIntegrationApps.ignoredApps.contains(packageName)) { - return liveDataFromValue(BlockingReason.None) + return liveDataFromValue(NoBlockingReason.getInstance(areNotificationsBlocked = false)) } else { - return getBlockingReasonStep3(packageName, child, timeZone) + return getBlockingReasonStep3(packageName, activityName, child, timeZone) } } - private fun getBlockingReasonStep3(packageName: String, child: User, timeZone: TimeZone): LiveData { + private fun getBlockingReasonStep3(packageName: String, activityName: String?, child: User, timeZone: TimeZone): LiveData { if (BuildConfig.DEBUG) { Log.d(LOG_TAG, "step 3") } // check temporarily allowed Apps - return appLogic.database.temporarilyAllowedApp().getTemporarilyAllowedApps().switchMap { + return appLogic.deviceId.switchMap { + if (it != null) { + appLogic.database.temporarilyAllowedApp().getTemporarilyAllowedApps() + } else { + liveDataFromValue(Collections.emptyList()) + } + }.switchMap { temporarilyAllowedApps -> if (temporarilyAllowedApps.contains(packageName)) { - liveDataFromValue(BlockingReason.None) + liveDataFromValue(NoBlockingReason.getInstance(areNotificationsBlocked = false) as BlockingReasonDetail) } else { - getBlockingReasonStep4(packageName, child, timeZone) + getBlockingReasonStep4(packageName, activityName, child, timeZone) } } } - private fun getBlockingReasonStep4(packageName: String, child: User, timeZone: TimeZone): LiveData { + private fun getBlockingReasonStep4(packageName: String, activityName: String?, child: User, timeZone: TimeZone): LiveData { if (BuildConfig.DEBUG) { Log.d(LOG_TAG, "step 4") } @@ -107,13 +141,27 @@ class BlockingReasonUtil(private val appLogic: AppLogic) { return appLogic.database.category().getCategoriesByChildId(child.id).switchMap { childCategories -> - Transformations.map(appLogic.database.categoryApp().getCategoryApp(childCategories.map { it.id }, packageName)) { + val categoryAppLevel = appLogic.database.categoryApp().getCategoryApp(childCategories.map { it.id }, packageName) + val categoryAppActivityLevel = enableActivityLevelFiltering.switchMap { + if (it) + appLogic.database.categoryApp().getCategoryApp(childCategories.map { it.id }, "$packageName:$activityName") + else + liveDataFromValue(null as CategoryApp?) + } + + val categoryApp = categoryAppLevel.switchMap { appLevel -> + categoryAppActivityLevel.map { activityLevel -> + activityLevel?.let { it to BlockingLevel.Activity } ?: appLevel?.let { it to BlockingLevel.App } + } + } + + Transformations.map(categoryApp) { categoryApp -> if (categoryApp == null) { null } else { - childCategories.find { it.id == categoryApp.categoryId } + childCategories.find { it.id == categoryApp.first.categoryId }?.let { it to categoryApp.second } } } }.switchMap { @@ -127,22 +175,52 @@ class BlockingReasonUtil(private val appLogic: AppLogic) { defaultCategory.switchMap { categoryEntry2 -> if (categoryEntry2 == null) { - liveDataFromValue(BlockingReason.NotPartOfAnCategory) + liveDataFromValue( + BlockedReasonDetails( + areNotificationsBlocked = false, + level = BlockingLevel.App, + reason = BlockingReason.NotPartOfAnCategory, + categoryId = null + ) as BlockingReasonDetail + ) } else { - getBlockingReasonStep4Point5(categoryEntry2, child, timeZone, false) + getBlockingReasonStep4Point5(categoryEntry2, child, timeZone, false, BlockingLevel.App) } } } else { - getBlockingReasonStep4Point5(categoryEntry, child, timeZone, false) + getBlockingReasonStep4Point5(categoryEntry.first, child, timeZone, false, categoryEntry.second) } } } - private fun getBlockingReasonStep4Point5(category: Category, child: User, timeZone: TimeZone, isParentCategory: Boolean): LiveData { + private fun getBlockingReasonStep4Point5(category: Category, child: User, timeZone: TimeZone, isParentCategory: Boolean, blockingLevel: BlockingLevel): LiveData { if (BuildConfig.DEBUG) { Log.d(LOG_TAG, "step 4.5") } + val blockNotifications = category.blockAllNotifications + + val nextLevel = getBlockingReasonStep4Point7(category, child, timeZone, isParentCategory, blockingLevel) + + return nextLevel.map { blockingReason -> + if (blockingReason == BlockingReason.None) { + NoBlockingReason.getInstance(areNotificationsBlocked = blockNotifications) + } else { + BlockedReasonDetails( + areNotificationsBlocked = blockNotifications, + level = blockingLevel, + reason = blockingReason, + categoryId = category.id + ) + } + } + } + + private fun getBlockingReasonStep4Point7(category: Category, child: User, timeZone: TimeZone, isParentCategory: Boolean, blockingLevel: BlockingLevel): LiveData { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "step 4.7") + } + if (category.temporarilyBlocked) { return liveDataFromValue(BlockingReason.TemporarilyBlocked) } @@ -152,8 +230,10 @@ class BlockingReasonUtil(private val appLogic: AppLogic) { if (child.disableLimitsUntil == 0L) { areLimitsDisabled = liveDataFromValue(false) } else { - areLimitsDisabled = timeInMillis.map { timeInMillis -> - child.disableLimitsUntil > timeInMillis + areLimitsDisabled = getTemporarilyTrustedTimeInMillis().map { + trustedTimeInMillis -> + + trustedTimeInMillis != null && child.disableLimitsUntil > trustedTimeInMillis } } @@ -171,7 +251,7 @@ class BlockingReasonUtil(private val appLogic: AppLogic) { if (parentCategory == null) { liveDataFromValue(BlockingReason.None) } else { - getBlockingReasonStep4Point5(parentCategory, child, timeZone, true) + getBlockingReasonStep4Point7(parentCategory, child, timeZone, true, blockingLevel) } } } else { @@ -185,7 +265,7 @@ class BlockingReasonUtil(private val appLogic: AppLogic) { Log.d(LOG_TAG, "step 5") } - return Transformations.switchMap(getMinuteOfWeekLive(appLogic.timeApi, timeZone)) { + return Transformations.switchMap(getTrustedMinuteOfWeekLive(appLogic.timeApi, timeZone)) { trustedMinuteOfWeek -> if (category.blockedMinutesInWeek.dataNotToModify.isEmpty) { @@ -203,7 +283,7 @@ class BlockingReasonUtil(private val appLogic: AppLogic) { Log.d(LOG_TAG, "step 6") } - return getDateLive(appLogic.timeApi, timeZone).switchMap { + return getTrustedDateLive(appLogic.timeApi, timeZone).switchMap { nowTrustedDate -> appLogic.database.timeLimitRules().getTimeLimitRulesByCategory(category.id).switchMap { @@ -212,12 +292,20 @@ class BlockingReasonUtil(private val appLogic: AppLogic) { if (rules.isEmpty()) { liveDataFromValue(BlockingReason.None) } else { - getBlockingReasonStep7(category, nowTrustedDate, rules) + getBlockingReasonStep6(category, nowTrustedDate, rules) } } } } + private fun getBlockingReasonStep6(category: Category, nowTrustedDate: DateInTimezone, rules: List): LiveData { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "step 6 - 2") + } + + return getBlockingReasonStep7(category, nowTrustedDate, rules) + } + private fun getBlockingReasonStep7(category: Category, nowTrustedDate: DateInTimezone, rules: List): LiveData { if (BuildConfig.DEBUG) { Log.d(LOG_TAG, "step 7") @@ -246,15 +334,89 @@ class BlockingReasonUtil(private val appLogic: AppLogic) { } } - private val timeInMillis: LiveData = liveDataFromFunction { - appLogic.timeApi.getCurrentTimeInMillis() + private fun getTemporarilyTrustedTimeInMillis(): LiveData { + return liveDataFromFunction { + appLogic.timeApi.getCurrentTimeInMillis() + } } - private fun getMinuteOfWeekLive(api: TimeApi, timeZone: TimeZone): LiveData = liveDataFromFunction { - getMinuteOfWeek(api.getCurrentTimeInMillis(), timeZone) - }.ignoreUnchanged() + private fun getTrustedMinuteOfWeekLive(api: TimeApi, timeZone: TimeZone): LiveData { + return object: LiveData() { + fun update() { + val timeInMillis = appLogic.timeApi.getCurrentTimeInMillis() - private fun getDateLive(api: TimeApi, timeZone: TimeZone): LiveData = liveDataFromFunction { - DateInTimezone.newInstance(api.getCurrentTimeInMillis(), timeZone) - }.ignoreUnchanged() + value = getMinuteOfWeek(timeInMillis, timeZone) + } + + init { + update() + } + + val scheduledUpdateRunnable = Runnable { + update() + scheduleUpdate() + } + + fun scheduleUpdate() { + api.runDelayed(scheduledUpdateRunnable, 1000L /* every second */) + } + + fun cancelScheduledUpdate() { + api.cancelScheduledAction(scheduledUpdateRunnable) + } + + override fun onActive() { + super.onActive() + + update() + scheduleUpdate() + } + + override fun onInactive() { + super.onInactive() + + cancelScheduledUpdate() + } + }.ignoreUnchanged() + } + + private fun getTrustedDateLive(api: TimeApi, timeZone: TimeZone): LiveData { + return object: LiveData() { + fun update() { + val timeInMillis = appLogic.timeApi.getCurrentTimeInMillis() + + value = DateInTimezone.newInstance(timeInMillis, timeZone) + } + + init { + update() + } + + val scheduledUpdateRunnable = Runnable { + update() + scheduleUpdate() + } + + fun scheduleUpdate() { + api.runDelayed(scheduledUpdateRunnable, 1000L /* every second */) + } + + fun cancelScheduledUpdate() { + api.cancelScheduledAction(scheduledUpdateRunnable) + } + + override fun onActive() { + super.onActive() + + update() + scheduleUpdate() + } + + override fun onInactive() { + super.onInactive() + + cancelScheduledUpdate() + } + }.ignoreUnchanged() + } } diff --git a/app/src/main/java/io/timelimit/android/logic/DefaultUserLogic.kt b/app/src/main/java/io/timelimit/android/logic/DefaultUserLogic.kt new file mode 100644 index 0000000..2791a26 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/logic/DefaultUserLogic.kt @@ -0,0 +1,181 @@ +/* + * TimeLimit Copyright 2019 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.logic + +import android.util.Log +import io.timelimit.android.BuildConfig +import io.timelimit.android.async.Threads +import io.timelimit.android.coroutines.executeAndWait +import io.timelimit.android.coroutines.runAsync +import io.timelimit.android.data.model.User +import io.timelimit.android.livedata.* +import io.timelimit.android.sync.actions.SignOutAtDeviceAction +import io.timelimit.android.sync.actions.apply.ApplyActionUtil +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class DefaultUserLogic(private val appLogic: AppLogic) { + companion object { + private const val LOG_TAG = "DefaultUserLogic" + } + + private fun defaultUserEntry() = appLogic.deviceEntry.map { device -> + device?.defaultUser + }.ignoreUnchanged().switchMap { + if (it != null) + appLogic.database.user().getUserByIdLive(it) + else + liveDataFromValue(null as User?) + } + private fun hasDefaultUser() = defaultUserEntry().map { it != null }.ignoreUnchanged() + private fun defaultUserTimeout() = appLogic.deviceEntry.map { it?.defaultUserTimeout ?: 0 }.ignoreUnchanged() + private fun hasDefaultUserTimeout() = defaultUserTimeout().map { it != 0 }.ignoreUnchanged() + fun hasAutomaticSignOut() = hasDefaultUser().and(hasDefaultUserTimeout()) + + private val logoutLock = Mutex() + + private var lastScreenOnStatus = false + private var lastScreenDisableTime = 0L + private var lastScreenOnSaveTime = 0L + private var restoredLastScreenOnTime: Long? = null + private var didRestoreLastDisabledTime = false + + fun reportScreenOn(isScreenOn: Boolean) { + if (isScreenOn) { + val now = appLogic.timeApi.getCurrentTimeInMillis() + + if (lastScreenOnSaveTime + 1000 * 30 < now) { + lastScreenOnSaveTime = now + + Threads.database.submit { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "save last screen on time") + } + + if (restoredLastScreenOnTime == null) { + restoredLastScreenOnTime = appLogic.database.config().getLastScreenOnTime() + } + + appLogic.database.config().setLastScreenOnTime(now) + } + } + } + + if (isScreenOn != lastScreenOnStatus) { + lastScreenOnStatus = isScreenOn + + if (isScreenOn) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "screen was enabled") + } + + runAsync { + logoutLock.withLock { + if (lastScreenDisableTime == 0L) { + if (!didRestoreLastDisabledTime) { + didRestoreLastDisabledTime = true + + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "screen disabling time is not known - try to restore time") + } + + val nowTime = appLogic.timeApi.getCurrentTimeInMillis() + val nowUptime = appLogic.timeApi.getCurrentUptimeInMillis() + val savedLastScreenOnTime = restoredLastScreenOnTime ?: kotlin.run { + Threads.database.executeAndWait { + restoredLastScreenOnTime = appLogic.database.config().getLastScreenOnTime() + } + + restoredLastScreenOnTime!! + } + + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "now: $nowTime; uptime: $nowUptime; last screen on time: $savedLastScreenOnTime") + } + + if (savedLastScreenOnTime == 0L) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "no saved value - can not restore") + } + } else if (savedLastScreenOnTime > nowTime) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "saved last screen on time is in the future - can not restore") + } + } else { + val diffToNow = nowTime - savedLastScreenOnTime + val theoreticallyUptimeValue = nowUptime - diffToNow + + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "restored last screen on time: diff to now: ${diffToNow / 1000} s; theoretically uptime: ${theoreticallyUptimeValue / 1000} s") + } + + lastScreenDisableTime = theoreticallyUptimeValue + } + } + } + + if (lastScreenDisableTime != 0L) { + val now = appLogic.timeApi.getCurrentUptimeInMillis() + val diff = now - lastScreenDisableTime + + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "screen was disabled for ${diff / 1000} seconds") + } + + val defaultUser = defaultUserEntry().waitForNullableValue() + + if (defaultUser != null) { + if (appLogic.deviceEntry.waitForNullableValue()?.currentUserId == defaultUser.id) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "default user already signed in") + } + } else { + val timeout = defaultUserTimeout().waitForNonNullValue() + + if (diff >= timeout && timeout != 0) { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "much time - log out") + } + + ApplyActionUtil.applyAppLogicAction( + appLogic = appLogic, + action = SignOutAtDeviceAction, + ignoreIfDeviceIsNotConfigured = true + ) + } else { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "no reason to log out") + } + } + } + } else { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "has no default user") + } + } + } + } + } + } else { + if (BuildConfig.DEBUG) { + Log.d(LOG_TAG, "screen was disabled") + } + + lastScreenDisableTime = appLogic.timeApi.getCurrentUptimeInMillis() + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/logic/SyncInstalledAppsLogic.kt b/app/src/main/java/io/timelimit/android/logic/SyncInstalledAppsLogic.kt index ba3744b..1c303bd 100644 --- a/app/src/main/java/io/timelimit/android/logic/SyncInstalledAppsLogic.kt +++ b/app/src/main/java/io/timelimit/android/logic/SyncInstalledAppsLogic.kt @@ -17,11 +17,10 @@ package io.timelimit.android.logic import androidx.lifecycle.MutableLiveData import io.timelimit.android.coroutines.runAsyncExpectForever +import io.timelimit.android.data.model.AppActivity import io.timelimit.android.data.model.UserType import io.timelimit.android.livedata.* -import io.timelimit.android.sync.actions.AddInstalledAppsAction -import io.timelimit.android.sync.actions.InstalledApp -import io.timelimit.android.sync.actions.RemoveInstalledAppsAction +import io.timelimit.android.sync.actions.* import io.timelimit.android.sync.actions.apply.ApplyActionUtil import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -36,12 +35,13 @@ class SyncInstalledAppsLogic(val appLogic: AppLogic) { init { appLogic.platformIntegration.installedAppsChangeListener = Runnable { requestSync() } - appLogic.deviceEntryIfEnabled.map { it?.id + it?.currentUserId }.ignoreUnchanged().observeForever { requestSync() } - + appLogic.deviceEntry.map { it?.id + it?.enableActivityLevelBlocking }.ignoreUnchanged().observeForever { requestSync() } runAsyncExpectForever { syncLoop() } } private suspend fun syncLoop() { + requestSync.postValue(true) + while (true) { requestSync.waitUntilValueMatches { it == true } requestSync.value = false @@ -55,46 +55,84 @@ class SyncInstalledAppsLogic(val appLogic: AppLogic) { private suspend fun doSyncNow() { doSyncLock.withLock { - val userEntry = appLogic.deviceUserEntry.waitForNullableValue() + val deviceEntry = appLogic.deviceEntry.waitForNullableValue() ?: return@withLock + val deviceId = deviceEntry.id - if (userEntry == null || userEntry.type != UserType.Child) { - return@withLock + run { + val currentlyInstalled = appLogic.platformIntegration.getLocalApps().associateBy { app -> app.packageName } + val currentlySaved = appLogic.database.app().getApps().waitForNonNullValue().associateBy { app -> app.packageName } + + // skip all items for removal which are still saved locally + val itemsToRemove = HashMap(currentlySaved) + currentlyInstalled.forEach { (packageName, _) -> itemsToRemove.remove(packageName) } + + // only add items which are not the same locally + val itemsToAdd = currentlyInstalled.filter { (packageName, app) -> currentlySaved[packageName] != app } + + // save the changes + if (itemsToRemove.isNotEmpty()) { + ApplyActionUtil.applyAppLogicAction( + action = RemoveInstalledAppsAction(packageNames = itemsToRemove.keys.toList()), + appLogic = appLogic, + ignoreIfDeviceIsNotConfigured = true + ) + } + + if (itemsToAdd.isNotEmpty()) { + ApplyActionUtil.applyAppLogicAction( + action = AddInstalledAppsAction( + apps = itemsToAdd.map { (_, app) -> + + InstalledApp( + packageName = app.packageName, + title = app.title, + recommendation = app.recommendation, + isLaunchable = app.isLaunchable + ) + } + ), + appLogic = appLogic, + ignoreIfDeviceIsNotConfigured = true + ) + } } - val currentlyInstalled = appLogic.platformIntegration.getLocalApps().associateBy { app -> app.packageName } - val currentlySaved = appLogic.database.app().getApps().waitForNonNullValue().associateBy { app -> app.packageName } + run { + fun buildKey(activity: AppActivity) = "${activity.appPackageName}:${activity.activityClassName}" - // skip all items for removal which are still saved locally - val itemsToRemove = HashMap(currentlySaved) - currentlyInstalled.forEach { (packageName, _) -> itemsToRemove.remove(packageName) } + val currentlyInstalled = ( + if (deviceEntry.enableActivityLevelBlocking) + appLogic.platformIntegration.getLocalAppActivities(deviceId = deviceId) + else + emptyList() + ).associateBy { buildKey(it) } - // only add items which are not the same locally - val itemsToAdd = currentlyInstalled.filter { (packageName, app) -> currentlySaved[packageName] != app } + val currentlySaved = appLogic.database.appActivity().getAppActivitiesByDeviceIds(deviceIds = listOf(deviceId)).waitForNonNullValue().associateBy { buildKey(it) } - // save the changes - if (itemsToRemove.isNotEmpty()) { - ApplyActionUtil.applyAppLogicAction( - RemoveInstalledAppsAction(packageNames = itemsToRemove.keys.toList()), - appLogic - ) - } + // skip all items for removal which are still saved locally + val itemsToRemove = HashMap(currentlySaved) + currentlyInstalled.forEach { (packageName, _) -> itemsToRemove.remove(packageName) } - if (itemsToAdd.isNotEmpty()) { - ApplyActionUtil.applyAppLogicAction( - AddInstalledAppsAction( - apps = itemsToAdd.map { - (_, app) -> + // only add items which are not the same locally + val itemsToAdd = currentlyInstalled.filter { (packageName, app) -> currentlySaved[packageName] != app } - InstalledApp( - packageName = app.packageName, - title = app.title, - recommendation = app.recommendation, - isLaunchable = app.isLaunchable - ) - } - ), - appLogic - ) + // save the changes + if (itemsToRemove.isNotEmpty() or itemsToAdd.isNotEmpty()) { + ApplyActionUtil.applyAppLogicAction( + action = UpdateAppActivitiesAction( + removedActivities = itemsToRemove.map { it.value.appPackageName to it.value.activityClassName }, + updatedOrAddedActivities = itemsToAdd.map { item -> + AppActivityItem( + packageName = item.value.appPackageName, + className = item.value.activityClassName, + title = item.value.title + ) + } + ), + appLogic = appLogic, + ignoreIfDeviceIsNotConfigured = true + ) + } } } } diff --git a/app/src/main/java/io/timelimit/android/logic/UsedTimeItemBatchUpdateHelper.kt b/app/src/main/java/io/timelimit/android/logic/UsedTimeItemBatchUpdateHelper.kt index 93a32e8..6e52f10 100644 --- a/app/src/main/java/io/timelimit/android/logic/UsedTimeItemBatchUpdateHelper.kt +++ b/app/src/main/java/io/timelimit/android/logic/UsedTimeItemBatchUpdateHelper.kt @@ -104,13 +104,14 @@ class UsedTimeItemBatchUpdateHelper( // do nothing } else { ApplyActionUtil.applyAppLogicAction( - AddUsedTimeAction( + action = AddUsedTimeAction( categoryId = childCategoryId, timeToAdd = timeToAdd, dayOfEpoch = date.dayOfEpoch, extraTimeToSubtract = extraTimeToSubtract ), - logic + appLogic = logic, + ignoreIfDeviceIsNotConfigured = true ) timeToAdd = 0 diff --git a/app/src/main/java/io/timelimit/android/sync/actions/Actions.kt b/app/src/main/java/io/timelimit/android/sync/actions/Actions.kt index a192849..96f1304 100644 --- a/app/src/main/java/io/timelimit/android/sync/actions/Actions.kt +++ b/app/src/main/java/io/timelimit/android/sync/actions/Actions.kt @@ -74,6 +74,24 @@ data class RemoveInstalledAppsAction(val packageNames: List): AppLogicAc } } +data class AppActivityItem ( + val packageName: String, + val className: String, + val title: String +) +data class UpdateAppActivitiesAction( + // package name to activity class names + val removedActivities: List>, + val updatedOrAddedActivities: List +): AppLogicAction() { + init { + if (removedActivities.isEmpty() && updatedOrAddedActivities.isEmpty()) { + throw IllegalArgumentException("empty action") + } + } +} +object SignOutAtDeviceAction: AppLogicAction() + data class AddCategoryAppsAction(val categoryId: String, val packageNames: List): ParentAction() { init { IdGenerator.assertIdValid(categoryId) @@ -126,6 +144,11 @@ data class UpdateCategoryTemporarilyBlockedAction(val categoryId: String, val bl IdGenerator.assertIdValid(categoryId) } } +data class UpdateCategoryTimeWarningsAction(val categoryId: String, val enable: Boolean, val flags: Int): ParentAction() { + init { + IdGenerator.assertIdValid(categoryId) + } +} data class SetCategoryForUnassignedApps(val childId: String, val categoryId: String): ParentAction() { // category id can be empty @@ -155,16 +178,22 @@ data class UpdateDeviceStatusAction( val newProtectionLevel: ProtectionLevel?, val newUsageStatsPermissionStatus: RuntimePermissionStatus?, val newNotificationAccessPermission: NewPermissionStatus?, + val newOverlayPermission: RuntimePermissionStatus?, + val newAccessibilityServiceEnabled: Boolean?, val newAppVersion: Int?, - val didReboot: Boolean + val didReboot: Boolean, + val isQOrLaterNow: Boolean ): AppLogicAction() { companion object { val empty = UpdateDeviceStatusAction( newProtectionLevel = null, newUsageStatsPermissionStatus = null, newNotificationAccessPermission = null, + newOverlayPermission = null, + newAccessibilityServiceEnabled = null, newAppVersion = null, - didReboot = false + didReboot = false, + isQOrLaterNow = false ) } @@ -182,6 +211,8 @@ data class IgnoreManipulationAction( val ignoreAppDowngrade: Boolean, val ignoreNotificationAccessManipulation: Boolean, val ignoreUsageStatsAccessManipulation: Boolean, + val ignoreOverlayPermissionManipulation: Boolean, + val ignoreAccessibilityServiceManipulation: Boolean, val ignoreReboot: Boolean, val ignoreHadManipulation: Boolean ): ParentAction() { @@ -211,18 +242,50 @@ data class SetDeviceUserAction(val deviceId: String, val userId: String): Parent } } +data class SetDeviceDefaultUserAction(val deviceId: String, val defaultUserId: String): ParentAction() { + init { + IdGenerator.assertIdValid(deviceId) + + if (defaultUserId.isNotEmpty()) { + IdGenerator.assertIdValid(defaultUserId) + } + } +} + +data class SetDeviceDefaultUserTimeoutAction(val deviceId: String, val timeout: Int): ParentAction() { + init { + IdGenerator.assertIdValid(deviceId) + + if (timeout < 0) { + throw IllegalArgumentException("can not set a negative default user timeout") + } + } +} + data class SetConsiderRebootManipulationAction(val deviceId: String, val considerRebootManipulation: Boolean): ParentAction() { init { IdGenerator.assertIdValid(deviceId) } } +data class UpdateEnableActivityLevelBlocking(val deviceId: String, val enable: Boolean): ParentAction() { + init { + IdGenerator.assertIdValid(deviceId) + } +} + data class UpdateCategoryBlockedTimesAction(val categoryId: String, val blockedTimes: ImmutableBitmask): ParentAction() { init { IdGenerator.assertIdValid(categoryId) } } +data class UpdateCategoryBlockAllNotificationsAction(val categoryId: String, val blocked: Boolean): ParentAction() { + init { + IdGenerator.assertIdValid(categoryId) + } +} + data class CreateTimeLimitRuleAction(val rule: TimeLimitRule): ParentAction() data class UpdateTimeLimitRuleAction(val ruleId: String, val dayMask: Byte, val maximumTimeInMillis: Int, val applyToExtraTimeUsage: Boolean): ParentAction() { diff --git a/app/src/main/java/io/timelimit/android/sync/actions/apply/ApplyAction.kt b/app/src/main/java/io/timelimit/android/sync/actions/apply/ApplyAction.kt index 2ef0ac6..5dc2fc0 100644 --- a/app/src/main/java/io/timelimit/android/sync/actions/apply/ApplyAction.kt +++ b/app/src/main/java/io/timelimit/android/sync/actions/apply/ApplyAction.kt @@ -30,14 +30,40 @@ import io.timelimit.android.sync.actions.dispatch.LocalDatabaseAppLogicActionDis import io.timelimit.android.sync.actions.dispatch.LocalDatabaseParentActionDispatcher object ApplyActionUtil { - suspend fun applyAppLogicAction(action: AppLogicAction, appLogic: AppLogic) { - applyAppLogicAction(action, appLogic.database, appLogic.manipulationLogic) + suspend fun applyAppLogicAction( + action: AppLogicAction, + appLogic: AppLogic, + ignoreIfDeviceIsNotConfigured: Boolean + ) { + applyAppLogicAction(action, appLogic.database, appLogic.manipulationLogic, ignoreIfDeviceIsNotConfigured) } - private suspend fun applyAppLogicAction(action: AppLogicAction, database: Database, manipulationLogic: ManipulationLogic) { + private suspend fun applyAppLogicAction( + action: AppLogicAction, + database: Database, + manipulationLogic: ManipulationLogic, + ignoreIfDeviceIsNotConfigured: Boolean + ) { + // uncomment this if you need to know what's dispatching an action + /* + if (BuildConfig.DEBUG) { + try { + throw Exception() + } catch (ex: Exception) { + Log.d(LOG_TAG, "handling action: $action", ex) + } + } + */ + Threads.database.executeAndWait { database.transaction().use { - LocalDatabaseAppLogicActionDispatcher.dispatchAppLogicActionSync(action, database.config().getOwnDeviceIdSync()!!, database, manipulationLogic) + val ownDeviceId = database.config().getOwnDeviceIdSync() + + if (ownDeviceId == null && ignoreIfDeviceIsNotConfigured) { + return@executeAndWait + } + + LocalDatabaseAppLogicActionDispatcher.dispatchAppLogicActionSync(action, ownDeviceId!!, database, manipulationLogic) database.setTransactionSuccessful() } diff --git a/app/src/main/java/io/timelimit/android/sync/actions/dispatch/AppLogicAction.kt b/app/src/main/java/io/timelimit/android/sync/actions/dispatch/AppLogicAction.kt index 8d73490..b8407a6 100644 --- a/app/src/main/java/io/timelimit/android/sync/actions/dispatch/AppLogicAction.kt +++ b/app/src/main/java/io/timelimit/android/sync/actions/dispatch/AppLogicAction.kt @@ -17,6 +17,7 @@ package io.timelimit.android.sync.actions.dispatch import io.timelimit.android.data.Database import io.timelimit.android.data.model.App +import io.timelimit.android.data.model.AppActivity import io.timelimit.android.data.model.UsedTimeItem import io.timelimit.android.integration.platform.NewPermissionStatusUtil import io.timelimit.android.integration.platform.ProtectionLevelUtil @@ -148,6 +149,42 @@ object LocalDatabaseAppLogicActionDispatcher { } } + if (action.newOverlayPermission != null) { + if (device.currentOverlayPermission != action.newOverlayPermission) { + device = device.copy( + currentOverlayPermission = action.newOverlayPermission + ) + + if (RuntimePermissionStatusUtil.toInt(action.newOverlayPermission) > RuntimePermissionStatusUtil.toInt(device.highestOverlayPermission)) { + device = device.copy( + highestOverlayPermission = action.newOverlayPermission + ) + } + + if (device.currentOverlayPermission != device.highestOverlayPermission) { + device = device.copy(hadManipulation = true) + } + } + } + + if (action.newAccessibilityServiceEnabled != null) { + if (device.accessibilityServiceEnabled != action.newAccessibilityServiceEnabled) { + device = device.copy( + accessibilityServiceEnabled = action.newAccessibilityServiceEnabled + ) + + if (action.newAccessibilityServiceEnabled) { + device = device.copy( + wasAccessibilityServiceEnabled = true + ) + } + + if (device.accessibilityServiceEnabled != device.wasAccessibilityServiceEnabled) { + device = device.copy(hadManipulation = true) + } + } + } + if (action.newAppVersion != null) { if (device.currentAppVersion != action.newAppVersion) { device = device.copy( @@ -167,6 +204,10 @@ object LocalDatabaseAppLogicActionDispatcher { ) } + if (action.isQOrLaterNow && !device.qOrLater) { + device = device.copy(qOrLater = true) + } + database.device().updateDeviceEntry(device) if (device.hasActiveManipulationWarning) { @@ -186,6 +227,52 @@ object LocalDatabaseAppLogicActionDispatcher { manipulationLogic.lockDeviceSync() + null + } + is SignOutAtDeviceAction -> { + val deviceEntry = database.device().getDeviceByIdSync(database.config().getOwnDeviceIdSync()!!)!! + + if (deviceEntry.defaultUser.isEmpty()) { + throw IllegalStateException("can not sign out without configured default user") + } + + LocalDatabaseParentActionDispatcher.dispatchParentActionSync( + SetDeviceUserAction( + deviceId = deviceEntry.id, + userId = deviceEntry.defaultUser + ), + database + ) + + null + } + is UpdateAppActivitiesAction -> { + if (action.updatedOrAddedActivities.isNotEmpty()) { + database.appActivity().addAppActivitiesSync( + action.updatedOrAddedActivities.map { item -> + AppActivity( + deviceId = deviceId, + appPackageName = item.packageName, + activityClassName = item.className, + title = item.title + ) + } + ) + } + + if (action.removedActivities.isNotEmpty()) { + action.removedActivities.groupBy { it.first }.entries.forEach { item -> + val packageName = item.component1() + val activities = item.component2().map { it.second } + + database.appActivity().deleteAppActivitiesSync( + deviceId = deviceId, + packageName = packageName, + activities = activities + ) + } + } + null } }.let { } diff --git a/app/src/main/java/io/timelimit/android/sync/actions/dispatch/ParentAction.kt b/app/src/main/java/io/timelimit/android/sync/actions/dispatch/ParentAction.kt index 6103bb9..3aed502 100644 --- a/app/src/main/java/io/timelimit/android/sync/actions/dispatch/ParentAction.kt +++ b/app/src/main/java/io/timelimit/android/sync/actions/dispatch/ParentAction.kt @@ -74,7 +74,9 @@ object LocalDatabaseParentActionDispatcher { blockedMinutesInWeek = ImmutableBitmask(BitSet()), extraTimeInMillis = 0, temporarilyBlocked = false, - parentCategoryId = "" + parentCategoryId = "", + blockAllNotifications = false, + timeWarnings = 0 )) } is DeleteCategoryAction -> { @@ -271,6 +273,14 @@ object LocalDatabaseParentActionDispatcher { deviceEntry = deviceEntry.copy(highestUsageStatsPermission = deviceEntry.currentUsageStatsPermission) } + if (action.ignoreOverlayPermissionManipulation) { + deviceEntry = deviceEntry.copy(highestOverlayPermission = deviceEntry.currentOverlayPermission) + } + + if (action.ignoreAccessibilityServiceManipulation) { + deviceEntry = deviceEntry.copy(wasAccessibilityServiceEnabled = deviceEntry.accessibilityServiceEnabled) + } + if (action.ignoreReboot) { deviceEntry = deviceEntry.copy(manipulationDidReboot = false) } @@ -328,6 +338,26 @@ object LocalDatabaseParentActionDispatcher { timezone = action.timezone ) } + is SetDeviceDefaultUserAction -> { + if (action.defaultUserId.isNotEmpty()) { + DatabaseValidation.assertUserExists(database, action.defaultUserId) + } + + DatabaseValidation.assertDeviceExists(database, action.deviceId) + + database.device().updateDeviceDefaultUser( + deviceId = action.deviceId, + defaultUserId = action.defaultUserId + ) + } + is SetDeviceDefaultUserTimeoutAction -> { + val deviceEntry = database.device().getDeviceByIdSync(action.deviceId) + ?: throw IllegalArgumentException("device not found") + + database.device().updateDeviceEntry(deviceEntry.copy( + defaultUserTimeout = action.timeout + )) + } is SetConsiderRebootManipulationAction -> { val deviceEntry = database.device().getDeviceByIdSync(action.deviceId) ?: throw IllegalArgumentException("device not found") @@ -338,6 +368,45 @@ object LocalDatabaseParentActionDispatcher { ) ) } + is UpdateCategoryBlockAllNotificationsAction -> { + val categoryEntry = database.category().getCategoryByIdSync(action.categoryId) + ?: throw IllegalArgumentException("can not update notification blocking for non exsistent category") + + database.category().updateCategorySync( + categoryEntry.copy( + blockAllNotifications = action.blocked + ) + ) + } + is UpdateEnableActivityLevelBlocking -> { + val deviceEntry = database.device().getDeviceByIdSync(action.deviceId) + ?: throw IllegalArgumentException("device not found") + + database.device().updateDeviceEntry( + deviceEntry.copy( + enableActivityLevelBlocking = action.enable + ) + ) + } + is UpdateCategoryTimeWarningsAction -> { + val categoryEntry = database.category().getCategoryByIdSync(action.categoryId) + ?: throw IllegalArgumentException("category not found") + + val modified = if (action.enable) + categoryEntry.copy( + timeWarnings = categoryEntry.timeWarnings or action.flags + ) + else + categoryEntry.copy( + timeWarnings = categoryEntry.timeWarnings and (action.flags.inv()) + ) + + if (modified != categoryEntry) { + database.category().updateCategorySync(modified) + } + + null + } }.let { } database.setTransactionSuccessful() 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 fb7af1f..15f5f1b 100644 --- a/app/src/main/java/io/timelimit/android/ui/MainActivity.kt +++ b/app/src/main/java/io/timelimit/android/ui/MainActivity.kt @@ -48,6 +48,8 @@ class MainActivity : AppCompatActivity(), ActivityViewModelHolder { private val currentNavigatorFragment = MutableLiveData() + override var ignoreStop: Boolean = false + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) @@ -104,7 +106,7 @@ class MainActivity : AppCompatActivity(), ActivityViewModelHolder { override fun onStop() { super.onStop() - if (!isChangingConfigurations) { + if ((!isChangingConfigurations) && (!ignoreStop)) { getActivityViewModel().logOut() } } @@ -112,6 +114,10 @@ class MainActivity : AppCompatActivity(), ActivityViewModelHolder { override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) + if ((intent?.flags ?: 0) and Intent.FLAG_ACTIVITY_REORDER_TO_FRONT == Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) { + return + } + getNavController().popBackStack(R.id.overviewFragment, true) getNavController().handleDeepLink( getNavController().createDeepLink() diff --git a/app/src/main/java/io/timelimit/android/ui/contacts/ContactsAdapter.kt b/app/src/main/java/io/timelimit/android/ui/contacts/ContactsAdapter.kt new file mode 100644 index 0000000..eab9ea9 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/contacts/ContactsAdapter.kt @@ -0,0 +1,115 @@ +/* + * TimeLimit Copyright 2019 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.contacts + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import io.timelimit.android.R +import io.timelimit.android.databinding.AddItemViewBinding +import io.timelimit.android.databinding.ContactsItemBinding +import kotlin.properties.Delegates + +class ContactsAdapter: RecyclerView.Adapter() { + companion object { + private const val TYPE_INTRO = 1 + private const val TYPE_ITEM = 2 + private const val TYPE_ADD = 3 + } + + var items: List? by Delegates.observable(null as List?) { _, _, _ -> notifyDataSetChanged() } + var handlers: ContactsHandlers? = null + + init { + setHasStableIds(true) + } + + override fun getItemCount(): Int = items?.size ?: 0 + + override fun getItemId(position: Int): Long { + val item = items!![position] + + return when (item) { + is IntroContactsItem -> Long.MAX_VALUE + is AddContactsItem -> Long.MAX_VALUE - 1 + is ContactContactsItem -> item.item.id.toLong() + } + } + + override fun getItemViewType(position: Int): Int = when (items!![position]) { + is IntroContactsItem -> TYPE_INTRO + is ContactContactsItem -> TYPE_ITEM + is AddContactsItem -> TYPE_ADD + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ContactsViewHolder = when (viewType) { + TYPE_INTRO -> ContactsStaticHolder( + LayoutInflater.from(parent.context) + .inflate(R.layout.contacts_intro, parent, false) + ) + TYPE_ITEM -> ContactsItemHolder( + ContactsItemBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ) + ) + TYPE_ADD -> ContactsStaticHolder( + AddItemViewBinding.inflate( + LayoutInflater.from(parent.context), parent, false + ).let { + it.label = parent.context.getString(R.string.contacts_add) + + it.root.setOnClickListener { + handlers?.onAddContactClicked() + } + + it.root + } + ) + else -> throw IllegalStateException() + } + + override fun onBindViewHolder(holder: ContactsViewHolder, position: Int) { + when (holder) { + is ContactsStaticHolder -> {/* nothing to do */} + is ContactsItemHolder -> { + val item = items!![position] + + item as ContactContactsItem + + holder.view.title = item.item.title + holder.view.phone = item.item.phone + + holder.view.card.setOnClickListener { handlers?.onContactClicked(item) } + holder.view.card.setOnLongClickListener { handlers?.onContactLongClicked(item) ?: false } + + holder.view.executePendingBindings() + + null + } + }.let {/* require handling all cases */} + } +} + +sealed class ContactsViewHolder(root: View): RecyclerView.ViewHolder(root) +class ContactsStaticHolder(root: View): ContactsViewHolder(root) +class ContactsItemHolder(val view: ContactsItemBinding): ContactsViewHolder(view.root) + +interface ContactsHandlers { + fun onAddContactClicked() + fun onContactLongClicked(item: ContactContactsItem): Boolean + fun onContactClicked(item: ContactContactsItem) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/contacts/ContactsFragment.kt b/app/src/main/java/io/timelimit/android/ui/contacts/ContactsFragment.kt new file mode 100644 index 0000000..07df57e --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/contacts/ContactsFragment.kt @@ -0,0 +1,226 @@ +/* + * TimeLimit Copyright 2019 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.contacts + + +import android.Manifest +import android.app.Activity +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Bundle +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import io.timelimit.android.R +import android.provider.ContactsContract +import android.util.Log +import androidx.core.content.ContextCompat +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import androidx.recyclerview.widget.ItemTouchHelper +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.snackbar.Snackbar +import io.timelimit.android.BuildConfig +import io.timelimit.android.coroutines.runAsync +import io.timelimit.android.data.model.AllowedContact +import io.timelimit.android.databinding.ContactsFragmentBinding +import io.timelimit.android.logic.DefaultAppLogic +import io.timelimit.android.ui.MainActivity +import io.timelimit.android.ui.main.ActivityViewModel +import io.timelimit.android.ui.main.ActivityViewModelHolder +import io.timelimit.android.util.PhoneNumberUtils +import kotlinx.coroutines.delay + + +class ContactsFragment : Fragment() { + companion object { + private const val LOG_TAG = "ContactsFragment" + private const val REQ_SELECT_CONTACT = 1 + private const val REQ_CALL_PERMISSION = 2 + } + + private val model: ContactsModel by lazy { + ViewModelProviders.of(this).get(ContactsModel::class.java) + } + + private val activityModelHolder: ActivityViewModelHolder by lazy { activity as ActivityViewModelHolder } + private val auth: ActivityViewModel by lazy { activityModelHolder.getActivityViewModel() } + private var numberToCallWithPermission: String? = null + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val binding = ContactsFragmentBinding.inflate(inflater, container, false) + val adapter = ContactsAdapter() + + model.listItems.observe(this, Observer { adapter.items = it }) + + binding.recycler.layoutManager = LinearLayoutManager(context) + binding.recycler.adapter = adapter + + adapter.handlers = object: ContactsHandlers { + override fun onAddContactClicked() { + if (auth.requestAuthenticationOrReturnTrue()) { + activityModelHolder.ignoreStop = true + + showContactSelection() + } + } + + override fun onContactLongClicked(item: ContactContactsItem): Boolean { + removeItem(item.item) + + return true + } + + override fun onContactClicked(item: ContactContactsItem) { + startCall(item.item.phone) + } + } + + ItemTouchHelper(object: ItemTouchHelper.Callback() { + override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { + val item = adapter.items!![viewHolder.adapterPosition] + + if (item is ContactContactsItem && auth.isParentAuthenticated()) { + return makeMovementFlags(0, ItemTouchHelper.START or ItemTouchHelper.END) + } else if (item is IntroContactsItem) { + return makeMovementFlags(0, ItemTouchHelper.START or ItemTouchHelper.END) + } + + return 0 + } + + override fun onMove(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder): Boolean { + // ignore + + return false + } + + override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) { + val item = adapter.items!![viewHolder.adapterPosition] + + if (item is ContactContactsItem) { + removeItem(item.item) + } else if (item is IntroContactsItem) { + model.hideIntro() + } + } + }).attachToRecyclerView(binding.recycler) + + return binding.root + } + + private fun showContactSelection() { + startActivityForResult( + Intent(Intent.ACTION_PICK, ContactsContract.Contacts.CONTENT_URI) + .setType(ContactsContract.CommonDataKinds.Phone.CONTENT_TYPE), + REQ_SELECT_CONTACT + ) + } + + private fun removeItem(item: AllowedContact) { + if (auth.isParentAuthenticated()) { + model.removeContact(item.id) + + Snackbar.make(view!!, getString(R.string.contacts_snackbar_removed, item.title), Snackbar.LENGTH_SHORT) + .setAction(R.string.generic_undo) { + model.addContact(item) + } + .show() + } else { + Snackbar.make(view!!, R.string.contacts_snackbar_remove_auth, Snackbar.LENGTH_SHORT).show() + } + } + + private fun startCall(number: String) { + if (ContextCompat.checkSelfPermission(context!!, Manifest.permission.CALL_PHONE) == PackageManager.PERMISSION_GRANTED) { + val logic = DefaultAppLogic.with(context!!) + + try { + val intent = Intent(Intent.ACTION_CALL, Uri.parse("tel:" + PhoneNumberUtils.normalizeNumber(number))) + + logic.backgroundTaskLogic.pauseBackgroundLoop = true + + startActivity(intent) + + runAsync { + delay(500) + + startActivity( + Intent(context!!, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT) + ) + + delay(200) + + logic.backgroundTaskLogic.pauseBackgroundLoop = false + + delay(500) + + Snackbar.make(view!!, R.string.contacts_snackbar_call_started, Snackbar.LENGTH_LONG).show() + } + } catch (ex: Exception) { + if (BuildConfig.DEBUG) { + Log.w(LOG_TAG, "could not start call", ex) + } + + logic.backgroundTaskLogic.pauseBackgroundLoop = false + + Snackbar.make(view!!, R.string.contacts_snackbar_call_failed, Snackbar.LENGTH_SHORT).show() + } + } else { + numberToCallWithPermission = number + requestPermissions(arrayOf(Manifest.permission.CALL_PHONE), REQ_CALL_PERMISSION) + } + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + super.onActivityResult(requestCode, resultCode, data) + + if (requestCode == REQ_SELECT_CONTACT) { + activityModelHolder.ignoreStop = false + + if (resultCode == Activity.RESULT_OK) { + data?.data?.let { contactData -> + val cursor = context!!.contentResolver.query(contactData, null, null, null, null) + + cursor?.use { + if (cursor.moveToFirst()) { + val title = cursor.getString(cursor.getColumnIndex(ContactsContract.Contacts.DISPLAY_NAME)) + val phoneNumber = cursor.getString(cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)) + + model.addContact(title = title, phoneNumber = phoneNumber) + + Snackbar.make(view!!, R.string.contacts_snackbar_added, Snackbar.LENGTH_LONG).show() + } + } + } + } + } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + + if (requestCode == REQ_CALL_PERMISSION) { + if (grantResults.size == 1 && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + numberToCallWithPermission?.let { number -> startCall(number) } + } + } + } +} diff --git a/app/src/main/java/io/timelimit/android/ui/contacts/ContactsItem.kt b/app/src/main/java/io/timelimit/android/ui/contacts/ContactsItem.kt new file mode 100644 index 0000000..16dcec9 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/contacts/ContactsItem.kt @@ -0,0 +1,23 @@ +/* + * TimeLimit Copyright 2019 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.contacts + +import io.timelimit.android.data.model.AllowedContact + +sealed class ContactsItem +object IntroContactsItem: ContactsItem() +object AddContactsItem: ContactsItem() +data class ContactContactsItem(val item: AllowedContact): ContactsItem() \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/contacts/ContactsModel.kt b/app/src/main/java/io/timelimit/android/ui/contacts/ContactsModel.kt new file mode 100644 index 0000000..97b0218 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/contacts/ContactsModel.kt @@ -0,0 +1,66 @@ +/* + * TimeLimit Copyright 2019 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.contacts + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import io.timelimit.android.async.Threads +import io.timelimit.android.data.model.AllowedContact +import io.timelimit.android.data.model.HintsToShow +import io.timelimit.android.livedata.map +import io.timelimit.android.livedata.switchMap +import io.timelimit.android.logic.DefaultAppLogic + +class ContactsModel(application: Application): AndroidViewModel(application) { + private val appLogic = DefaultAppLogic.with(application) + private val allowedContacts = appLogic.database.allowedContact().getAllowedContactsLive() + private val didHideIntro = appLogic.database.config().wereHintsShown(HintsToShow.CONTACTS_INTRO) + + private val convertedContactItems = allowedContacts.map { items -> items.map { ContactContactsItem(it) } } + private val baseListItems = convertedContactItems.map { list -> list + listOf(AddContactsItem) } + + val listItems = didHideIntro.switchMap { hideIntro -> + baseListItems.map { baseItems -> + if (hideIntro) { + baseItems + } else { + listOf(IntroContactsItem) + baseItems + } + } + } + + fun addContact(title: String, phoneNumber: String) { + Threads.database.submit { + appLogic.database.allowedContact().addContactSync(AllowedContact( + id = 0, + phone = phoneNumber, + title = title + )) + } + } + + fun addContact(item: AllowedContact) { + Threads.database.submit { appLogic.database.allowedContact().addContactSync(item) } + } + + fun removeContact(id: Int) { + Threads.database.submit { appLogic.database.allowedContact().removeContactSync(id) } + } + + fun hideIntro() { + Threads.database.submit { appLogic.database.config().setHintsShownSync(HintsToShow.CONTACTS_INTRO) } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseForegroundAppFragment.kt b/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseForegroundAppFragment.kt new file mode 100644 index 0000000..6b130eb --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseForegroundAppFragment.kt @@ -0,0 +1,113 @@ +/* + * TimeLimit Copyright 2019 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.diagnose + +import android.os.Bundle +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.RadioButton +import androidx.lifecycle.Observer +import io.timelimit.android.R +import io.timelimit.android.async.Threads +import io.timelimit.android.databinding.DiagnoseForegroundAppFragmentBinding +import io.timelimit.android.livedata.liveDataFromValue +import io.timelimit.android.livedata.map +import io.timelimit.android.logic.DefaultAppLogic +import io.timelimit.android.ui.main.ActivityViewModelHolder +import io.timelimit.android.ui.main.AuthenticationFab +import io.timelimit.android.ui.main.getActivityViewModel +import io.timelimit.android.util.TimeTextUtil + +class DiagnoseForegroundAppFragment : Fragment() { + companion object { + private val buttonIntervals = listOf( + 0, + 5 * 1000, + 30 * 1000, + 60 * 1000, + 15 * 60 * 1000, + 60 * 60 * 1000, + 24 * 60 * 60 * 1000, + 7 * 24 * 60 * 60 * 1000 + ) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val activity: ActivityViewModelHolder = activity as ActivityViewModelHolder + val binding = DiagnoseForegroundAppFragmentBinding.inflate(inflater, container, false) + val auth = activity.getActivityViewModel() + val logic = DefaultAppLogic.with(context!!) + val currentValue = logic.database.config().getForegroundAppQueryIntervalAsync() + val currentId = currentValue.map { + val res = buttonIntervals.indexOf(it.toInt()) + + if (res == -1) + 0 + else + res + } + + AuthenticationFab.manageAuthenticationFab( + fab = binding.fab, + shouldHighlight = auth.shouldHighlightAuthenticationButton, + authenticatedUser = auth.authenticatedUser, + doesSupportAuth = liveDataFromValue(true), + fragment = this + ) + + binding.fab.setOnClickListener { activity.showAuthenticationScreen() } + + val allButtons = buttonIntervals.mapIndexed { index, interval -> + RadioButton(context!!).apply { + id = index + + if (interval == 0) { + setText(R.string.diagnose_fga_query_range_min) + } else if (interval < 60 * 1000) { + text = TimeTextUtil.seconds(interval / 1000, context!!) + } else { + text = TimeTextUtil.time(interval, context!!) + } + } + } + + allButtons.forEach { binding.radioGroup.addView(it) } + + currentId.observe(this, Observer { + binding.radioGroup.check(it) + }) + + binding.radioGroup.setOnCheckedChangeListener { _, checkedId -> + val oldId = currentId.value + + if (oldId != null && checkedId != oldId) { + if (auth.requestAuthenticationOrReturnTrue()) { + val newValue = buttonIntervals[checkedId] + + Threads.database.execute { + logic.database.config().setForegroundAppQueryIntervalSync(newValue.toLong()) + } + } else { + binding.radioGroup.check(oldId) + } + } + } + + return binding.root + } +} diff --git a/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseMainFragment.kt b/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseMainFragment.kt index e54bbe9..9fd2731 100644 --- a/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseMainFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/diagnose/DiagnoseMainFragment.kt @@ -37,6 +37,13 @@ class DiagnoseMainFragment : Fragment() { ) } + binding.diagnoseFgaButton.setOnClickListener { + navigation.safeNavigate( + DiagnoseMainFragmentDirections.actionDiagnoseMainFragmentToDiagnoseForegroundAppFragment(), + R.id.diagnoseMainFragment + ) + } + return binding.root } } diff --git a/app/src/main/java/io/timelimit/android/ui/lock/LockActivity.kt b/app/src/main/java/io/timelimit/android/ui/lock/LockActivity.kt index 7a1ebab..4be3c6b 100644 --- a/app/src/main/java/io/timelimit/android/ui/lock/LockActivity.kt +++ b/app/src/main/java/io/timelimit/android/ui/lock/LockActivity.kt @@ -32,12 +32,18 @@ import io.timelimit.android.ui.main.ActivityViewModelHolder class LockActivity : AppCompatActivity(), ActivityViewModelHolder { companion object { private const val EXTRA_PACKAGE_NAME = "packageName" + private const val EXTRA_ACTIVITY_NAME = "activityName" private const val LOGIN_DIALOG_TAG = "loginDialog" - fun start(context: Context, packageName: String) { + fun start(context: Context, packageName: String, activityName: String?) { context.startActivity( Intent(context, LockActivity::class.java) .putExtra(EXTRA_PACKAGE_NAME, packageName) + .apply { + if (activityName != null) { + putExtra(EXTRA_ACTIVITY_NAME, activityName) + } + } .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) @@ -45,18 +51,29 @@ class LockActivity : AppCompatActivity(), ActivityViewModelHolder { } } + override var ignoreStop: Boolean = false + val blockedPackageName: String by lazy { intent.getStringExtra(EXTRA_PACKAGE_NAME) } + private val blockedActivityName: String? by lazy { + if (intent.hasExtra(EXTRA_ACTIVITY_NAME)) + intent.getStringExtra(EXTRA_ACTIVITY_NAME) + else + null + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.lock_activity) if (savedInstanceState == null) { supportFragmentManager.beginTransaction() - .replace(R.id.container, LockFragment.newInstance(blockedPackageName)) + .replace(R.id.container, LockFragment.newInstance(blockedPackageName, blockedActivityName)) .commitNow() + + stopMediaPlayback() } } @@ -83,12 +100,12 @@ class LockActivity : AppCompatActivity(), ActivityViewModelHolder { override fun onStop() { super.onStop() - if (!isChangingConfigurations) { + if ((!isChangingConfigurations) && (!ignoreStop)) { getActivityViewModel().logOut() } } - fun lockTaskModeWorkaround() { + private fun lockTaskModeWorkaround() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { val platformIntegration = DefaultAppLogic.with(this).platformIntegration val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager @@ -105,6 +122,11 @@ class LockActivity : AppCompatActivity(), ActivityViewModelHolder { } } + private fun stopMediaPlayback() { + val platformIntegration = DefaultAppLogic.with(this).platformIntegration + platformIntegration.muteAudioIfPossible(blockedPackageName) + } + override fun onBackPressed() { // do nothing because going back would open the blocked app again // super.onBackPressed() diff --git a/app/src/main/java/io/timelimit/android/ui/lock/LockFragment.kt b/app/src/main/java/io/timelimit/android/ui/lock/LockFragment.kt index 1d6b55b..bca7a1d 100644 --- a/app/src/main/java/io/timelimit/android/ui/lock/LockFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/lock/LockFragment.kt @@ -16,6 +16,7 @@ package io.timelimit.android.ui.lock import android.content.Intent +import android.database.sqlite.SQLiteConstraintException import android.os.Bundle import android.view.LayoutInflater import android.view.View @@ -36,10 +37,7 @@ import io.timelimit.android.data.model.User import io.timelimit.android.data.model.UserType import io.timelimit.android.databinding.LockFragmentBinding import io.timelimit.android.livedata.* -import io.timelimit.android.logic.AppLogic -import io.timelimit.android.logic.BlockingReason -import io.timelimit.android.logic.BlockingReasonUtil -import io.timelimit.android.logic.DefaultAppLogic +import io.timelimit.android.logic.* import io.timelimit.android.sync.actions.AddCategoryAppsAction import io.timelimit.android.sync.actions.IncrementCategoryExtraTimeAction import io.timelimit.android.sync.actions.UpdateCategoryTemporarilyBlockedAction @@ -50,27 +48,39 @@ import io.timelimit.android.ui.main.getActivityViewModel import io.timelimit.android.ui.manage.child.ManageChildFragmentArgs import io.timelimit.android.ui.manage.child.advanced.managedisabletimelimits.ManageDisableTimelimitsViewHelper import io.timelimit.android.ui.manage.child.category.create.CreateCategoryDialogFragment +import io.timelimit.android.ui.view.SelectTimeSpanViewListener class LockFragment : Fragment() { companion object { private const val EXTRA_PACKAGE_NAME = "packageName" + private const val EXTRA_ACTIVITY = "activitiy" - fun newInstance(packageName: String): LockFragment { + fun newInstance(packageName: String, activity: String?): LockFragment { val result = LockFragment() val arguments = Bundle() arguments.putString(EXTRA_PACKAGE_NAME, packageName) + if (activity != null) { + arguments.putString(EXTRA_ACTIVITY, activity) + } + result.arguments = arguments return result } } - private val packageName: String by lazy { arguments!!.getString(EXTRA_PACKAGE_NAME) } + private val packageName: String by lazy { arguments!!.getString(EXTRA_PACKAGE_NAME)!! } + private val activityName: String? by lazy { + if (arguments!!.containsKey(EXTRA_ACTIVITY)) + arguments!!.getString(EXTRA_ACTIVITY) + else + null + } private val auth: ActivityViewModel by lazy { getActivityViewModel(activity!!) } private val logic: AppLogic by lazy { DefaultAppLogic.with(context!!) } private val title: String? by lazy { logic.platformIntegration.getLocalAppTitle(packageName) } - private val blockingReason: LiveData by lazy { BlockingReasonUtil(logic).getBlockingReason(packageName) } + private val blockingReason: LiveData by lazy { BlockingReasonUtil(logic).getBlockingReason(packageName, activityName) } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { val binding = LockFragmentBinding.inflate(layoutInflater, container, false) @@ -83,8 +93,14 @@ class LockFragment : Fragment() { doesSupportAuth = liveDataFromValue(true) ) + val enableActivityLevelBlocking = logic.deviceEntry.map { it?.enableActivityLevelBlocking ?: false } + binding.packageName = packageName + enableActivityLevelBlocking.observe(this, Observer { + binding.activityName = if (it) activityName?.removePrefix(packageName) else null + }) + if (title != null) { binding.appTitle = title } else { @@ -94,11 +110,16 @@ class LockFragment : Fragment() { binding.appIcon.setImageDrawable(logic.platformIntegration.getAppIcon(packageName)) blockingReason.observe(this, Observer { - if (it == BlockingReason.None) { - activity!!.finish() - } else { - binding.reason = it - } + when (it) { + is NoBlockingReason -> activity!!.finish() + is BlockedReasonDetails -> { + binding.reason = it.reason + binding.blockedKindLabel = when (it.level) { + BlockingLevel.Activity -> "Activity" + BlockingLevel.App -> "App" + } + } + }.let { /* require handling all cases */ } }) val categories = logic.deviceUserEntry.switchMap { @@ -124,13 +145,14 @@ class LockFragment : Fragment() { } else { val (_, categoryItems) = status - Transformations.map(logic.database.categoryApp().getCategoryApp( - categoryItems.map { it.id }, - packageName - )) { - appEntry -> - - categoryItems.find { it.id == appEntry?.categoryId } + blockingReason.map { reason -> + if (reason is BlockedReasonDetails) { + reason.categoryId + } else { + null + } + }.map { categoryId -> + categoryItems.find { it.id == categoryId } } } } @@ -196,6 +218,8 @@ class LockFragment : Fragment() { if (extraTimeToAdd > 0) { binding.extraTimeBtnOk.isEnabled = false + binding.extraTimeSelection.clearNumberPickerFocus() + val categoryId = appCategory.waitForNullableValue()?.id if (categoryId != null) { @@ -215,6 +239,22 @@ class LockFragment : Fragment() { } } + logic.database.config().getEnableAlternativeDurationSelectionAsync().observe(this, Observer { + binding.extraTimeSelection.enablePickerMode(it) + }) + + binding.extraTimeSelection.listener = object: SelectTimeSpanViewListener { + override fun onTimeSpanChanged(newTimeInMillis: Long) { + // ignore + } + + override fun setEnablePickerMode(enable: Boolean) { + Threads.database.execute { + logic.database.config().setEnableAlternativeDurationSelectionSync(enable) + } + } + } + // bind disable time limits logic.deviceUserEntry.observe(this, Observer { child -> @@ -267,9 +307,16 @@ class LockFragment : Fragment() { logic.platformIntegration.setSuspendedApps(listOf(packageName), false) Threads.database.executeAndWait(Runnable { - database.temporarilyAllowedApp().addTemporarilyAllowedAppSync(TemporarilyAllowedApp( - packageName = packageName - )) + try { + database.temporarilyAllowedApp().addTemporarilyAllowedAppSync(TemporarilyAllowedApp( + packageName = packageName + )) + } catch (ex: SQLiteConstraintException) { + // ignore this + // + // this happens when touching that option more than once very fast + // or if the device is under load + } }) } } diff --git a/app/src/main/java/io/timelimit/android/ui/main/ActivityViewModel.kt b/app/src/main/java/io/timelimit/android/ui/main/ActivityViewModel.kt index 423f464..f834d5b 100644 --- a/app/src/main/java/io/timelimit/android/ui/main/ActivityViewModel.kt +++ b/app/src/main/java/io/timelimit/android/ui/main/ActivityViewModel.kt @@ -37,7 +37,7 @@ class ActivityViewModel(application: Application): AndroidViewModel(application) private const val LOG_TAG = "ActivityViewModel" } - private val logic = DefaultAppLogic.with(application) + val logic = DefaultAppLogic.with(application) private val database = logic.database val shouldHighlightAuthenticationButton = MutableLiveData().apply { value = false } @@ -115,6 +115,8 @@ class ActivityViewModel(application: Application): AndroidViewModel(application) authenticatedUserMetadata.value = user } + fun getAuthenticatedUser() = authenticatedUserMetadata.value + fun logOut() { authenticatedUserMetadata.value = null } diff --git a/app/src/main/java/io/timelimit/android/ui/main/ActivityViewModelHolder.kt b/app/src/main/java/io/timelimit/android/ui/main/ActivityViewModelHolder.kt index b4e00c8..6032b90 100644 --- a/app/src/main/java/io/timelimit/android/ui/main/ActivityViewModelHolder.kt +++ b/app/src/main/java/io/timelimit/android/ui/main/ActivityViewModelHolder.kt @@ -20,6 +20,7 @@ import android.app.Activity interface ActivityViewModelHolder { fun getActivityViewModel(): ActivityViewModel fun showAuthenticationScreen() + var ignoreStop: Boolean } fun getActivityViewModel(activity: Activity): ActivityViewModel { diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/ManageCategoryFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/ManageCategoryFragment.kt index 3879af3..ace8e42 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/category/ManageCategoryFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/ManageCategoryFragment.kt @@ -20,6 +20,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentTransaction import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import androidx.navigation.Navigation @@ -35,6 +36,11 @@ import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.ui.main.ActivityViewModelHolder import io.timelimit.android.ui.main.AuthenticationFab import io.timelimit.android.ui.main.FragmentWithCustomTitle +import io.timelimit.android.ui.manage.category.apps.CategoryAppsFragment +import io.timelimit.android.ui.manage.category.blocked_times.BlockedTimeAreasFragment +import io.timelimit.android.ui.manage.category.settings.CategorySettingsFragment +import io.timelimit.android.ui.manage.category.timelimit_rules.CategoryTimeLimitRulesFragment +import io.timelimit.android.ui.manage.category.usagehistory.UsageHistoryFragment import kotlinx.android.synthetic.main.fragment_manage_category.* class ManageCategoryFragment : Fragment(), FragmentWithCustomTitle { @@ -47,7 +53,6 @@ class ManageCategoryFragment : Fragment(), FragmentWithCustomTitle { private val user: LiveData by lazy { logic.database.user().getUserByIdLive(params.childId) } - private val adapter: PagerAdapter by lazy { PagerAdapter(childFragmentManager, params) } private val activity: ActivityViewModelHolder by lazy { getActivity() as ActivityViewModelHolder } private var wereViewsCreated = false @@ -70,43 +75,28 @@ class ManageCategoryFragment : Fragment(), FragmentWithCustomTitle { val navigation = Navigation.findNavController(view) - pager.adapter = adapter - - bottom_navigation_view.setOnNavigationItemSelectedListener { - menuItem -> - - pager.currentItem = when(menuItem.itemId) { - R.id.manage_category_tab_apps -> 0 - R.id.manage_category_tab_time_limit_rules -> 1 - R.id.manage_category_tab_blocked_time_areas -> 2 - R.id.manage_category_tab_usage_log -> 3 - R.id.manage_category_tab_settings -> 4 - else -> 0 - } + bottom_navigation_view.setOnNavigationItemReselectedListener { /* ignore */ } + bottom_navigation_view.setOnNavigationItemSelectedListener { menuItem -> + childFragmentManager.beginTransaction() + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + .replace(R.id.container, when(menuItem.itemId) { + R.id.manage_category_tab_apps -> CategoryAppsFragment.newInstance(params) + R.id.manage_category_tab_time_limit_rules -> CategoryTimeLimitRulesFragment.newInstance(params) + R.id.manage_category_tab_blocked_time_areas -> BlockedTimeAreasFragment.newInstance(params) + R.id.manage_category_tab_usage_log -> UsageHistoryFragment.newInstance(params) + R.id.manage_category_tab_settings -> CategorySettingsFragment.newInstance(params) + else -> throw IllegalStateException() + }) + .commit() true } - pager.addOnPageChangeListener(object: ViewPager.OnPageChangeListener { - override fun onPageScrollStateChanged(state: Int) { - // ignore - } - - override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) { - // ignore - } - - override fun onPageSelected(position: Int) { - bottom_navigation_view.selectedItemId = when(position) { - 0 -> R.id.manage_category_tab_apps - 1 -> R.id.manage_category_tab_time_limit_rules - 2 -> R.id.manage_category_tab_blocked_time_areas - 3 -> R.id.manage_category_tab_usage_log - 4 -> R.id.manage_category_tab_settings - else -> throw IllegalStateException() - } - } - }) + if (childFragmentManager.findFragmentById(R.id.container) == null) { + childFragmentManager.beginTransaction() + .replace(R.id.container, CategoryAppsFragment.newInstance(params)) + .commit() + } if (!wereViewsCreated) { wereViewsCreated = true diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/PagerAdapter.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/PagerAdapter.kt deleted file mode 100644 index 6c9e0d1..0000000 --- a/app/src/main/java/io/timelimit/android/ui/manage/category/PagerAdapter.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* - * Open TimeLimit Copyright 2019 Jonas Lochmann - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation version 3 of the License. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ -package io.timelimit.android.ui.manage.category - -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.FragmentStatePagerAdapter -import io.timelimit.android.ui.manage.category.apps.CategoryAppsFragment -import io.timelimit.android.ui.manage.category.blocked_times.BlockedTimeAreasFragment -import io.timelimit.android.ui.manage.category.settings.CategorySettingsFragment -import io.timelimit.android.ui.manage.category.timelimit_rules.CategoryTimeLimitRulesFragment -import io.timelimit.android.ui.manage.category.usagehistory.UsageHistoryFragment - -class PagerAdapter(fragmentManager: FragmentManager, private val params: ManageCategoryFragmentArgs): FragmentStatePagerAdapter(fragmentManager) { - override fun getCount() = 5 - - override fun getItem(position: Int): Fragment = when (position) { - 0 -> CategoryAppsFragment.newInstance(params) - 1 -> CategoryTimeLimitRulesFragment.newInstance(params) - 2 -> BlockedTimeAreasFragment.newInstance(params) - 3 -> UsageHistoryFragment.newInstance(params) - 4 -> CategorySettingsFragment.newInstance(params) - else -> throw IllegalStateException() - } -} diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/apps/AppAdapter.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/AppAdapter.kt index 5ecbe10..d88e4fc 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/category/apps/AppAdapter.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/AppAdapter.kt @@ -102,7 +102,7 @@ class AppAdapter: RecyclerView.Adapter() { binding.icon.setImageDrawable( DefaultAppLogic.with(binding.root.context) - .platformIntegration.getAppIcon(item.packageName) + .platformIntegration.getAppIcon(item.packageNameWithoutActivityName) ) } } @@ -111,7 +111,7 @@ class AppAdapter: RecyclerView.Adapter() { open class ViewHolder(view: View): RecyclerView.ViewHolder(view) class AppViewHolder(val binding: FragmentCategoryAppsItemBinding): ViewHolder(binding.root) -data class AppEntry(val title: String, val packageName: String) +data class AppEntry(val title: String, val packageName: String, val packageNameWithoutActivityName: String) interface Handlers { fun onAppClicked(app: AppEntry) diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/apps/CategoryAppsModel.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/CategoryAppsModel.kt index 1cda6e0..92c9f5f 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/category/apps/CategoryAppsModel.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/CategoryAppsModel.kt @@ -42,7 +42,7 @@ class CategoryAppsModel(application: Application): AndroidViewModel(application) private val appsOfCategoryWithNames = installedApps.switchMap { allApps -> appsOfThisCategory.map { apps -> apps.map { categoryApp -> - categoryApp to allApps.find { app -> app.packageName == categoryApp.packageName } + categoryApp to allApps.find { app -> app.packageName == categoryApp.packageNameWithoutActivityName } } } } @@ -50,9 +50,9 @@ class CategoryAppsModel(application: Application): AndroidViewModel(application) val appEntries = appsOfCategoryWithNames.map { apps -> apps.map { (app, appEntry) -> if (appEntry != null) { - AppEntry(appEntry.title, app.packageName) + AppEntry(appEntry.title, app.packageName, app.packageNameWithoutActivityName) } else { - AppEntry("app not found", app.packageName) + AppEntry("app not found", app.packageName, app.packageNameWithoutActivityName) } }.sortedBy { it.title.toLowerCase(Locale.US) } } diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/apps/add/AddAppAdapter.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/add/AddAppAdapter.kt index decb481..91f42a6 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/category/apps/add/AddAppAdapter.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/add/AddAppAdapter.kt @@ -26,6 +26,7 @@ import kotlin.properties.Delegates class AddAppAdapter: RecyclerView.Adapter() { var data: List? by Delegates.observable(null as List?) { _, _, _ -> notifyDataSetChanged() } + var listener: AddAppAdapterListener? = null var categoryTitleByPackageName: Map by Delegates.observable(emptyMap()) { _, _, _ -> notifyDataSetChanged() } val selectedApps = mutableSetOf() @@ -35,6 +36,8 @@ class AddAppAdapter: RecyclerView.Adapter() { notifyDataSetChanged() } + + override fun onAppLongClicked(app: App) = listener?.onAppLongClicked(app) ?: false } init { @@ -86,6 +89,10 @@ class AddAppAdapter: RecyclerView.Adapter() { class ViewHolder(val binding: FragmentAddCategoryAppsItemBinding): RecyclerView.ViewHolder(binding.root) -interface ItemHandlers { +interface ItemHandlers: AddAppAdapterListener { fun onAppClicked(app: App) } + +interface AddAppAdapterListener { + fun onAppLongClicked(app: App): Boolean +} diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/apps/add/AddCategoryAppsFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/add/AddCategoryAppsFragment.kt index 4ff1e6e..9932b55 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/category/apps/add/AddCategoryAppsFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/add/AddCategoryAppsFragment.kt @@ -19,6 +19,7 @@ package io.timelimit.android.ui.manage.category.apps.add import android.app.Dialog import android.os.Bundle import android.view.LayoutInflater +import android.widget.Toast import androidx.appcompat.app.AlertDialog import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentManager @@ -27,6 +28,7 @@ import androidx.lifecycle.Observer import androidx.recyclerview.widget.LinearLayoutManager import io.timelimit.android.R import io.timelimit.android.data.Database +import io.timelimit.android.data.model.App import io.timelimit.android.data.model.UserType import io.timelimit.android.databinding.FragmentAddCategoryAppsBinding import io.timelimit.android.extensions.showSafe @@ -39,6 +41,7 @@ import io.timelimit.android.sync.actions.AddCategoryAppsAction import io.timelimit.android.ui.main.ActivityViewModel import io.timelimit.android.ui.main.getActivityViewModel import io.timelimit.android.ui.manage.category.ManageCategoryFragmentArgs +import io.timelimit.android.ui.manage.category.apps.addactivity.AddAppActivitiesDialogFragment import io.timelimit.android.ui.view.AppFilterView class AddCategoryAppsFragment : DialogFragment() { @@ -168,6 +171,26 @@ class AddCategoryAppsFragment : DialogFragment() { adapter.notifyDataSetChanged() } + adapter.listener = object: AddAppAdapterListener { + override fun onAppLongClicked(app: App): Boolean { + return if (adapter.selectedApps.isEmpty()) { + AddAppActivitiesDialogFragment.newInstance( + childId = params.childId, + categoryId = params.categoryId, + packageName = app.packageName + ).show(fragmentManager!!) + + dismissAllowingStateLoss() + + true + } else { + Toast.makeText(context, R.string.category_apps_add_dialog_cannot_add_activities_already_sth_selected, Toast.LENGTH_LONG).show() + + false + } + } + } + return AlertDialog.Builder(context!!, R.style.AppTheme) .setView(binding.root) .create() diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/apps/addactivity/AddAppActivitiesDialogFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/addactivity/AddAppActivitiesDialogFragment.kt new file mode 100644 index 0000000..9f03734 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/addactivity/AddAppActivitiesDialogFragment.kt @@ -0,0 +1,148 @@ +/* + * TimeLimit Copyright 2019 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.category.apps.addactivity + +import android.app.Dialog +import android.os.Bundle +import android.view.LayoutInflater +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import androidx.recyclerview.widget.LinearLayoutManager +import io.timelimit.android.R +import io.timelimit.android.data.model.UserType +import io.timelimit.android.databinding.FragmentAddCategoryActivitiesBinding +import io.timelimit.android.extensions.addOnTextChangedListener +import io.timelimit.android.extensions.showSafe +import io.timelimit.android.livedata.map +import io.timelimit.android.livedata.switchMap +import io.timelimit.android.logic.DefaultAppLogic +import io.timelimit.android.sync.actions.AddCategoryAppsAction +import io.timelimit.android.ui.main.getActivityViewModel + +class AddAppActivitiesDialogFragment: DialogFragment() { + companion object { + private const val DIALOG_TAG = "AddAppActivitiesDialogFragment" + private const val CHILD_ID = "childId" + private const val CATEGORY_ID = "categoryId" + private const val PACKAGE_NAME = "packageName" + private const val SELECTED_ACTIVITIES = "selectedActivities" + + fun newInstance(childId: String, categoryId: String, packageName: String) = AddAppActivitiesDialogFragment().apply { + arguments = Bundle().apply { + putString(CHILD_ID, childId) + putString(CATEGORY_ID, categoryId) + putString(PACKAGE_NAME, packageName) + } + } + } + + val adapter = AddAppActivityAdapter() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (savedInstanceState != null) { + adapter.selectedActiviities.clear() + savedInstanceState.getStringArray(SELECTED_ACTIVITIES)!!.forEach { adapter.selectedActiviities.add(it) } + } + } + + override fun onSaveInstanceState(outState: Bundle) { + super.onSaveInstanceState(outState) + + outState.putStringArray(SELECTED_ACTIVITIES, adapter.selectedActiviities.toTypedArray()) + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val appPackageName = arguments!!.getString(PACKAGE_NAME)!! + val categoryId = arguments!!.getString(CATEGORY_ID)!! + val auth = getActivityViewModel(activity!!) + val binding = FragmentAddCategoryActivitiesBinding.inflate(LayoutInflater.from(context!!)) + val searchTerm = MutableLiveData().apply { value = binding.search.text.toString() } + binding.search.addOnTextChangedListener { searchTerm.value = binding.search.text.toString() } + + auth.authenticatedUser.observe(this, Observer { + if (it?.second?.type != UserType.Parent) { + dismissAllowingStateLoss() + } + }) + + val logic = DefaultAppLogic.with(context!!) + val allActivities = logic.database.appActivity().getAppActivitiesByPackageName(appPackageName).map { activities -> + activities.distinctBy { it.activityClassName } + } + val filteredActivities = allActivities.switchMap { activities -> + searchTerm.map { term -> + if (term.isEmpty()) { + activities + } else { + activities.filter { it.activityClassName.contains(term, ignoreCase = true) or it.title.contains(term, ignoreCase = true) } + } + } + } + + binding.recycler.layoutManager = LinearLayoutManager(context!!) + binding.recycler.adapter = adapter + + filteredActivities.observe(this, Observer { list -> + val selectedActivities = adapter.selectedActiviities + val visibleActivities = list.map { it.activityClassName } + val hiddenSelectedActivities = selectedActivities.toMutableSet().apply { removeAll(visibleActivities) }.size + + adapter.data = list + binding.hiddenEntries = if (hiddenSelectedActivities == 0) + null + else + resources.getQuantityString(R.plurals.category_apps_add_dialog_hidden_entries, hiddenSelectedActivities, hiddenSelectedActivities) + }) + + val emptyViewText = allActivities.switchMap { all -> + filteredActivities.map { filtered -> + if (filtered.isNotEmpty()) + null + else if (all.isNotEmpty()) + getString(R.string.category_apps_add_activity_empty_filtered) + else /* (all.isEmpty()) */ + getString(R.string.category_apps_add_activity_empty_unfiltered) + } + } + + emptyViewText.observe(this, Observer { + binding.emptyViewText = it + }) + + binding.cancelButton.setOnClickListener { dismissAllowingStateLoss() } + binding.addActivitiesButton.setOnClickListener { + if (adapter.selectedActiviities.isNotEmpty()) { + auth.tryDispatchParentAction(AddCategoryAppsAction( + categoryId = categoryId, + packageNames = adapter.selectedActiviities.toList().map { "$appPackageName:$it" } + )) + } + + dismissAllowingStateLoss() + } + + return AlertDialog.Builder(context!!, R.style.AppTheme) + .setView(binding.root) + .create() + } + + fun show(fragmentManager: FragmentManager) = showSafe(fragmentManager, DIALOG_TAG) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/apps/addactivity/AddAppActivityAdapter.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/addactivity/AddAppActivityAdapter.kt new file mode 100644 index 0000000..994d650 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/addactivity/AddAppActivityAdapter.kt @@ -0,0 +1,78 @@ +/* + * TimeLimit Copyright 2019 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.category.apps.addactivity + +import android.view.LayoutInflater +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import io.timelimit.android.data.model.App +import io.timelimit.android.data.model.AppActivity +import io.timelimit.android.databinding.FragmentAddCategoryActivitiesItemBinding +import io.timelimit.android.databinding.FragmentAddCategoryAppsItemBinding +import io.timelimit.android.extensions.toggle +import io.timelimit.android.logic.DefaultAppLogic +import kotlin.properties.Delegates + +class AddAppActivityAdapter: RecyclerView.Adapter() { + var data: List? by Delegates.observable(null as List?) { _, _, _ -> notifyDataSetChanged() } + val selectedActiviities = mutableSetOf() + + private val itemHandlers = object: ItemHandlers { + override fun onActivityClicked(activity: AppActivity) { + selectedActiviities.toggle(activity.activityClassName) + + notifyDataSetChanged() + } + } + + init { + setHasStableIds(true) + } + + private fun getItem(position: Int): AppActivity { + return data!![position] + } + + override fun getItemId(position: Int): Long { + return getItem(position).activityClassName.hashCode().toLong() + } + + override fun getItemCount(): Int = this.data?.size ?: 0 + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder( + FragmentAddCategoryActivitiesItemBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ).apply { handlers = itemHandlers } + ) + + override fun onBindViewHolder(holder: ViewHolder, position: Int) { + val item = getItem(position) + + holder.apply { + binding.item = item + binding.checked = selectedActiviities.contains(item.activityClassName) + binding.executePendingBindings() + } + } +} + +class ViewHolder(val binding: FragmentAddCategoryActivitiesItemBinding): RecyclerView.ViewHolder(binding.root) + +interface ItemHandlers { + fun onActivityClicked(activity: AppActivity) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/settings/CategoryNotificationFilter.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/settings/CategoryNotificationFilter.kt new file mode 100644 index 0000000..4db9705 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/settings/CategoryNotificationFilter.kt @@ -0,0 +1,57 @@ +/* + * Open TimeLimit Copyright 2019 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.category.settings + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import io.timelimit.android.data.model.Category +import io.timelimit.android.databinding.CategoryNotificationFilterBinding +import io.timelimit.android.sync.actions.UpdateCategoryBlockAllNotificationsAction +import io.timelimit.android.ui.main.ActivityViewModel + +object CategoryNotificationFilter { + fun bind( + view: CategoryNotificationFilterBinding, + auth: ActivityViewModel, + categoryLive: LiveData, + lifecycleOwner: LifecycleOwner + ) { + categoryLive.observe(lifecycleOwner, Observer { category -> + val shouldBeChecked = category?.blockAllNotifications ?: false + + view.checkbox.setOnCheckedChangeListener { _, _ -> } + view.checkbox.isChecked = shouldBeChecked + view.checkbox.setOnCheckedChangeListener { _, isChecked -> + if (isChecked != shouldBeChecked) { + if ( + category != null && + auth.tryDispatchParentAction( + UpdateCategoryBlockAllNotificationsAction( + categoryId = category.id, + blocked = isChecked + ) + ) + ) { + // ok + } else { + view.checkbox.isChecked = shouldBeChecked + } + } + } + }) + } +} diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/settings/CategorySettingsFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/settings/CategorySettingsFragment.kt index 7e71265..7634f07 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/category/settings/CategorySettingsFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/settings/CategorySettingsFragment.kt @@ -23,6 +23,7 @@ import androidx.fragment.app.Fragment import androidx.lifecycle.Observer import com.google.android.material.snackbar.Snackbar import io.timelimit.android.R +import io.timelimit.android.async.Threads import io.timelimit.android.databinding.FragmentCategorySettingsBinding import io.timelimit.android.logic.AppLogic import io.timelimit.android.logic.DefaultAppLogic @@ -30,6 +31,7 @@ import io.timelimit.android.sync.actions.SetCategoryExtraTimeAction import io.timelimit.android.ui.main.ActivityViewModel import io.timelimit.android.ui.main.getActivityViewModel import io.timelimit.android.ui.manage.category.ManageCategoryFragmentArgs +import io.timelimit.android.ui.view.SelectTimeSpanViewListener class CategorySettingsFragment : Fragment() { companion object { @@ -68,6 +70,20 @@ class CategorySettingsFragment : Fragment() { auth = auth ) + CategoryNotificationFilter.bind( + view = binding.notificationFilter, + lifecycleOwner = this, + auth = auth, + categoryLive = categoryEntry + ) + + CategoryTimeWarningView.bind( + view = binding.timeWarnings, + auth = auth, + categoryLive = categoryEntry, + lifecycleOwner = this + ) + binding.btnDeleteCategory.setOnClickListener { deleteCategory() } binding.editCategoryTitleGo.setOnClickListener { renameCategory() } @@ -82,6 +98,8 @@ class CategorySettingsFragment : Fragment() { }) binding.extraTimeBtnOk.setOnClickListener { + binding.extraTimeSelection.clearNumberPickerFocus() + val newExtraTime = binding.extraTimeSelection.timeInMillis if ( @@ -96,6 +114,22 @@ class CategorySettingsFragment : Fragment() { } } + appLogic.database.config().getEnableAlternativeDurationSelectionAsync().observe(this, Observer { + binding.extraTimeSelection.enablePickerMode(it) + }) + + binding.extraTimeSelection.listener = object: SelectTimeSpanViewListener { + override fun onTimeSpanChanged(newTimeInMillis: Long) { + // ignore + } + + override fun setEnablePickerMode(enable: Boolean) { + Threads.database.execute { + appLogic.database.config().setEnableAlternativeDurationSelectionSync(enable) + } + } + } + return binding.root } diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/settings/CategoryTimeWarningView.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/settings/CategoryTimeWarningView.kt new file mode 100644 index 0000000..243af7f --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/settings/CategoryTimeWarningView.kt @@ -0,0 +1,60 @@ +package io.timelimit.android.ui.manage.category.settings + +import android.widget.CheckBox +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import io.timelimit.android.data.model.Category +import io.timelimit.android.data.model.CategoryTimeWarnings +import io.timelimit.android.databinding.CategoryTimeWarningsViewBinding +import io.timelimit.android.sync.actions.UpdateCategoryTimeWarningsAction +import io.timelimit.android.ui.main.ActivityViewModel +import io.timelimit.android.util.TimeTextUtil + +object CategoryTimeWarningView { + fun bind( + view: CategoryTimeWarningsViewBinding, + lifecycleOwner: LifecycleOwner, + categoryLive: LiveData, + auth: ActivityViewModel + ) { + view.linearLayout.removeAllViews() + + val durationToCheckbox = mutableMapOf() + + CategoryTimeWarnings.durations.sorted().forEach { duration -> + CheckBox(view.root.context).let { checkbox -> + checkbox.text = TimeTextUtil.time(duration.toInt(), view.root.context) + + view.linearLayout.addView(checkbox) + durationToCheckbox[duration] = checkbox + } + } + + categoryLive.observe(lifecycleOwner, Observer { category -> + durationToCheckbox.entries.forEach { (duration, checkbox) -> + checkbox.setOnCheckedChangeListener { _, _ -> } + + val flag = (1 shl CategoryTimeWarnings.durationToBitIndex[duration]!!) + val enable = (category?.timeWarnings ?: 0) and flag != 0 + checkbox.isChecked = enable + + checkbox.setOnCheckedChangeListener { _, isChecked -> + if (isChecked != enable && category != null) { + if (auth.tryDispatchParentAction( + UpdateCategoryTimeWarningsAction( + categoryId = category.id, + enable = isChecked, + flags = flag + ) + )) { + // it worked + } else { + checkbox.isChecked = enable + } + } + } + } + }) + } +} diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/timelimit_rules/edit/EditTimeLimitRuleDialogFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/timelimit_rules/edit/EditTimeLimitRuleDialogFragment.kt index 347a67b..741940d 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/category/timelimit_rules/edit/EditTimeLimitRuleDialogFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/timelimit_rules/edit/EditTimeLimitRuleDialogFragment.kt @@ -26,17 +26,22 @@ import androidx.lifecycle.Observer import com.google.android.material.R import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import io.timelimit.android.async.Threads +import io.timelimit.android.coroutines.runAsync import io.timelimit.android.data.IdGenerator +import io.timelimit.android.data.model.HintsToShow import io.timelimit.android.data.model.TimeLimitRule import io.timelimit.android.data.model.UserType import io.timelimit.android.databinding.FragmentEditTimeLimitRuleDialogBinding import io.timelimit.android.extensions.showSafe +import io.timelimit.android.livedata.waitForNonNullValue import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.sync.actions.CreateTimeLimitRuleAction import io.timelimit.android.sync.actions.DeleteTimeLimitRuleAction import io.timelimit.android.sync.actions.UpdateTimeLimitRuleAction import io.timelimit.android.ui.main.ActivityViewModel import io.timelimit.android.ui.main.getActivityViewModel +import io.timelimit.android.ui.mustread.MustReadFragment import io.timelimit.android.ui.view.SelectDayViewHandlers import io.timelimit.android.ui.view.SelectTimeSpanViewListener import java.nio.ByteBuffer @@ -84,6 +89,23 @@ class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + if (savedInstanceState == null) { + val database = DefaultAppLogic.with(context!!).database + + runAsync { + val wasShown = database.config().wereHintsShown(HintsToShow.TIMELIMIT_RULE_MUSTREAD).waitForNonNullValue() + + if (!wasShown) { + MustReadFragment.newInstance(io.timelimit.android.R.string.must_read_timelimit_rules).show(fragmentManager!!) + + Threads.database.execute { + database.config().setHintsShownSync(HintsToShow.TIMELIMIT_RULE_MUSTREAD) + } + } + } + } + + existingRule = savedInstanceState?.getParcelable(PARAM_EXISTING_RULE) ?: arguments?.getParcelable(PARAM_EXISTING_RULE) } @@ -92,6 +114,7 @@ class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment() { val view = FragmentEditTimeLimitRuleDialogBinding.inflate(layoutInflater, container, false) val listener = targetFragment as EditTimeLimitRuleDialogFragmentListener var newRule: TimeLimitRule + val database = DefaultAppLogic.with(context!!).database auth.authenticatedUser.observe(this, Observer { if (it == null || it.second.type != UserType.Parent) { @@ -135,7 +158,7 @@ class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment() { view.timeSpan.timeInMillis = newRule.maximumTimeInMillis.toLong() val affectedDays = Math.max(0, (0..6).map { (newRule.dayMask.toInt() shr it) and 1 }.sum()) - view.timeSpan.maxDays = affectedDays - 1 + view.timeSpan.maxDays = Math.max(0, affectedDays - 1) // max prevents crash view.affectsMultipleDays = affectedDays >= 2 } @@ -160,6 +183,8 @@ class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment() { } override fun onSaveRule() { + view.timeSpan.clearNumberPickerFocus() + if (existingRule != null) { if (existingRule != newRule) { if (!auth.tryDispatchParentAction( @@ -213,10 +238,20 @@ class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment() { bindRule() } } + + override fun setEnablePickerMode(enable: Boolean) { + Threads.database.execute { + database.config().setEnableAlternativeDurationSelectionSync(enable) + } + } } + database.config().getEnableAlternativeDurationSelectionAsync().observe(this, Observer { + view.timeSpan.enablePickerMode(it) + }) + if (existingRule != null) { - DefaultAppLogic.with(context!!).database.timeLimitRules() + database.timeLimitRules() .getTimeLimitRuleByIdLive(existingRule!!.id).observe(this, Observer { if (it == null) { // rule was deleted diff --git a/app/src/main/java/io/timelimit/android/ui/manage/child/ManageChildFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/child/ManageChildFragment.kt index 742642b..10a0c0f 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/child/ManageChildFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/child/ManageChildFragment.kt @@ -20,6 +20,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentTransaction import androidx.lifecycle.LiveData import androidx.lifecycle.Observer import androidx.navigation.Navigation @@ -34,11 +35,13 @@ import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.ui.main.ActivityViewModelHolder import io.timelimit.android.ui.main.AuthenticationFab import io.timelimit.android.ui.main.FragmentWithCustomTitle +import io.timelimit.android.ui.manage.child.advanced.ManageChildAdvancedFragment +import io.timelimit.android.ui.manage.child.apps.ChildAppsFragment +import io.timelimit.android.ui.manage.child.category.ManageChildCategoriesFragment import kotlinx.android.synthetic.main.fragment_manage_child.* class ManageChildFragment : Fragment(), FragmentWithCustomTitle { private val params: ManageChildFragmentArgs by lazy { ManageChildFragmentArgs.fromBundle(arguments!!) } - private val adapter: PagerAdapter by lazy { PagerAdapter(childFragmentManager, params) } private val logic: AppLogic by lazy { DefaultAppLogic.with(context!!) } private val child: LiveData by lazy { logic.database.user().getUserByIdLive(params.childId) } private val activity: ActivityViewModelHolder by lazy { getActivity() as ActivityViewModelHolder } @@ -74,39 +77,26 @@ class ManageChildFragment : Fragment(), FragmentWithCustomTitle { }) } - pager.adapter = adapter - - bottom_navigation_view.setOnNavigationItemSelectedListener { - menuItem -> - - pager.currentItem = when (menuItem.itemId) { - R.id.manage_child_tab_categories -> 0 - R.id.manage_child_tab_apps -> 1 - R.id.manage_child_tab_manage -> 2 - else -> 0 - } + bottom_navigation_view.setOnNavigationItemReselectedListener { /* ignore */ } + bottom_navigation_view.setOnNavigationItemSelectedListener { menuItem -> + childFragmentManager.beginTransaction() + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + .replace(R.id.container, when (menuItem.itemId) { + R.id.manage_child_tab_categories -> ManageChildCategoriesFragment.newInstance(params) + R.id.manage_child_tab_apps -> ChildAppsFragment.newInstance(params) + R.id.manage_child_tab_manage -> ManageChildAdvancedFragment.newInstance(params) + else -> throw IllegalStateException() + }) + .commit() true } - pager.addOnPageChangeListener(object: ViewPager.OnPageChangeListener { - override fun onPageScrollStateChanged(state: Int) { - // ignore - } - - override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) { - // ignore - } - - override fun onPageSelected(position: Int) { - bottom_navigation_view.selectedItemId = when(position) { - 0 -> R.id.manage_child_tab_categories - 1 -> R.id.manage_child_tab_apps - 2 -> R.id.manage_child_tab_manage - else -> throw IllegalStateException() - } - } - }) + if (childFragmentManager.findFragmentById(R.id.container) == null) { + childFragmentManager.beginTransaction() + .replace(R.id.container, ManageChildCategoriesFragment.newInstance(params)) + .commit() + } } override fun getCustomTitle() = child.map { it?.name } diff --git a/app/src/main/java/io/timelimit/android/ui/manage/child/PagerAdapter.kt b/app/src/main/java/io/timelimit/android/ui/manage/child/PagerAdapter.kt deleted file mode 100644 index 71de9f2..0000000 --- a/app/src/main/java/io/timelimit/android/ui/manage/child/PagerAdapter.kt +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Open TimeLimit Copyright 2019 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.child - -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.FragmentStatePagerAdapter -import io.timelimit.android.ui.manage.child.advanced.ManageChildAdvancedFragment -import io.timelimit.android.ui.manage.child.apps.ChildAppsFragment -import io.timelimit.android.ui.manage.child.category.ManageChildCategoriesFragment - -class PagerAdapter(fragmentManager: FragmentManager, private val params: ManageChildFragmentArgs): FragmentStatePagerAdapter(fragmentManager) { - override fun getCount() = 3 - - override fun getItem(position: Int) = when(position) { - 0 -> ManageChildCategoriesFragment.newInstance(params) - 1 -> ChildAppsFragment.newInstance(params) - 2 -> ManageChildAdvancedFragment.newInstance(params) - else -> throw IllegalStateException() - } -} diff --git a/app/src/main/java/io/timelimit/android/ui/manage/child/category/ManageChildCategoriesFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/child/category/ManageChildCategoriesFragment.kt index 0bb7473..9e0b543 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/child/category/ManageChildCategoriesFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/child/category/ManageChildCategoriesFragment.kt @@ -91,7 +91,10 @@ class ManageChildCategoriesFragment : Fragment() { ItemTouchHelper(object: ItemTouchHelper.Callback() { override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { - if (adapter.categories!![viewHolder.adapterPosition] == CategoriesIntroductionHeader) { + val index = viewHolder.adapterPosition + val item = if (index == RecyclerView.NO_POSITION) null else adapter.categories!![index] + + if (item == CategoriesIntroductionHeader) { return makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE, ItemTouchHelper.END) or makeFlag(ItemTouchHelper.ACTION_STATE_IDLE, ItemTouchHelper.END) } else { diff --git a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/ActivityLaunchPermissionRequiredAndMissing.kt b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/ActivityLaunchPermissionRequiredAndMissing.kt new file mode 100644 index 0000000..7bfb5f1 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/ActivityLaunchPermissionRequiredAndMissing.kt @@ -0,0 +1,42 @@ +/* +* TimeLimit Copyright 2019 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.device.manage + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import io.timelimit.android.R +import io.timelimit.android.data.model.Device +import io.timelimit.android.data.model.User +import io.timelimit.android.data.model.UserType +import io.timelimit.android.databinding.MissingPermissionViewBinding +import io.timelimit.android.integration.platform.RuntimePermissionStatus +import io.timelimit.android.livedata.mergeLiveData + +object ActivityLaunchPermissionRequiredAndMissing { + fun bind( + view: MissingPermissionViewBinding, + user: LiveData, + device: LiveData, + lifecycleOwner: LifecycleOwner + ) { + view.title = view.root.context.getString(R.string.activity_launch_permission_required_and_missing_title) + + mergeLiveData(user, device).observe(lifecycleOwner, Observer { (user, device) -> + view.showMessage = user?.type == UserType.Child && device?.missingPermissionAtQOrLater ?: false + }) + } +} diff --git a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/ManageDeviceFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/ManageDeviceFragment.kt index 44bc076..608858f 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/ManageDeviceFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/ManageDeviceFragment.kt @@ -35,6 +35,7 @@ import androidx.navigation.Navigation import io.timelimit.android.R import io.timelimit.android.data.model.Device import io.timelimit.android.databinding.FragmentManageDeviceBinding +import io.timelimit.android.extensions.safeNavigate import io.timelimit.android.integration.platform.ProtectionLevel import io.timelimit.android.integration.platform.android.AdminReceiver import io.timelimit.android.livedata.liveDataFromValue @@ -48,6 +49,8 @@ import io.timelimit.android.ui.main.ActivityViewModel import io.timelimit.android.ui.main.ActivityViewModelHolder import io.timelimit.android.ui.main.AuthenticationFab import io.timelimit.android.ui.main.FragmentWithCustomTitle +import io.timelimit.android.ui.manage.device.manage.feature.ManageDeviceFeaturesFragment +import io.timelimit.android.ui.manage.device.manage.permission.ManageDevicePermissionsFragment class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle { private val activity: ActivityViewModelHolder by lazy { getActivity() as ActivityViewModelHolder } @@ -70,10 +73,6 @@ class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle { activityViewModel = auth ) - val userSpinnerAdapter = ArrayAdapter(context!!, android.R.layout.simple_spinner_item).apply { - setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - } - // auth AuthenticationFab.manageAuthenticationFab( fab = binding.fab, @@ -83,89 +82,41 @@ class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle { doesSupportAuth = liveDataFromValue(true) ) - // label, id - val userListItems = ArrayList>() - - fun bindUserListItems() { - userSpinnerAdapter.clear() - userSpinnerAdapter.addAll(userListItems.map { it.first }) - userSpinnerAdapter.notifyDataSetChanged() - } - - fun bindUserListSelection() { - val selectedUserId = deviceEntry.value?.currentUserId - - val selectedIndex = userListItems.indexOfFirst { it.second == selectedUserId } - - if (selectedIndex != -1) { - binding.userSpinner.setSelection(selectedIndex) - } else { - val fallbackSelectedIndex = userListItems.indexOfFirst { it.second == "" } - - if (fallbackSelectedIndex != -1) { - binding.userSpinner.setSelection(fallbackSelectedIndex) - } - } - } - binding.handlers = object: ManageDeviceFragmentHandlers { - override fun openUsageStatsSettings() { - if (binding.isThisDevice == true) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - startActivity( - Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - ) - } - } + override fun showUserScreen() { + navigation.safeNavigate( + ManageDeviceFragmentDirections.actionManageDeviceFragmentToManageDeviceUserFragment( + deviceId = args.deviceId + ), + R.id.manageDeviceFragment + ) } - override fun openNotificationAccessSettings() { - if (binding.isThisDevice == true) { - try { - startActivity( - Intent("android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS") - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - ) - } catch (ex: Exception) { - Toast.makeText( - context, - R.string.error_general, - Toast.LENGTH_SHORT - ).show() - } - } + override fun showPermissionsScreen() { + navigation.safeNavigate( + ManageDeviceFragmentDirections.actionManageDeviceFragmentToManageDevicePermissionsFragment( + deviceId = args.deviceId + ), + R.id.manageDeviceFragment + ) } - override fun manageDeviceAdmin() { - if (binding.isThisDevice == true) { - val protectionLevel = logic.platformIntegration.getCurrentProtectionLevel() - - if (protectionLevel == ProtectionLevel.None) { - if (InformAboutDeviceOwnerDialogFragment.shouldShow) { - startActivity( - Intent(DevicePolicyManager.ACTION_ADD_DEVICE_ADMIN) - .putExtra( - DevicePolicyManager.EXTRA_DEVICE_ADMIN, - ComponentName(context!!, AdminReceiver::class.java) - ) - ) - } else { - InformAboutDeviceOwnerDialogFragment().show(fragmentManager!!) - } - } else { - startActivity( - Intent(Settings.ACTION_SECURITY_SETTINGS) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - ) - } - } + override fun showFeaturesScreen() { + navigation.safeNavigate( + ManageDeviceFragmentDirections.actionManageDeviceFragmentToManageDeviceFeaturesFragment( + deviceId = args.deviceId + ), + R.id.manageDeviceFragment + ) } - override fun editDeviceTitle() { - if (auth.requestAuthenticationOrReturnTrue()) { - UpdateDeviceTitleDialogFragment.newInstance(args.deviceId).show(fragmentManager!!) - } + override fun showManageScreen() { + navigation.safeNavigate( + ManageDeviceFragmentDirections.actionManageDeviceFragmentToManageDeviceAdvancedFragment( + deviceId = args.deviceId + ), + R.id.manageDeviceFragment + ) } override fun showAuthenticationScreen() { @@ -173,32 +124,6 @@ class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle { } } - binding.userSpinner.adapter = userSpinnerAdapter - binding.userSpinner.onItemSelectedListener = object: AdapterView.OnItemSelectedListener { - override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { - val item = userListItems[position] - val userId = item.second - val device = deviceEntry.value - - if (device != null) { - if (device.currentUserId != userId) { - if (!auth.tryDispatchParentAction( - SetDeviceUserAction( - deviceId = args.deviceId, - userId = userId - ) - )) { - bindUserListSelection() - } - } - } - } - - override fun onNothingSelected(parent: AdapterView<*>?) { - // nothing to do - } - } - deviceEntry.observe(this, Observer { device -> @@ -207,7 +132,6 @@ class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle { } else { val now = logic.timeApi.getCurrentTimeInMillis() - binding.deviceTitle = device.name binding.modelString = device.model binding.addedAtString = getString(R.string.manage_device_added_at, DateUtils.getRelativeTimeSpanString( device.addedAt, @@ -215,25 +139,9 @@ class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle { DateUtils.HOUR_IN_MILLIS )) - binding.usageStatsAccess = device.currentUsageStatsPermission - binding.notificationAccessPermission = device.currentNotificationAccessPermission - binding.protectionLevel = device.currentProtectionLevel binding.didAppDowngrade = device.currentAppVersion < device.highestAppVersion - } - }) - - mergeLiveData(deviceEntry, userEntries).observe(this, Observer { - val (device, users) = it!! - - if (device != null && users != null) { - userListItems.clear() - userListItems.addAll( - users.map { user -> Pair(user.name, user.id) } - ) - userListItems.add(Pair(getString(R.string.manage_device_current_user_none), "")) - - bindUserListItems() - bindUserListSelection() + binding.permissionCardText = ManageDevicePermissionsFragment.getPreviewText(device, context!!) + binding.featureCardText = ManageDeviceFeaturesFragment.getPreviewText(device, context!!) } }) @@ -264,35 +172,27 @@ class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle { user = userEntry ) - ManageDeviceTroubleshooting.bind( - view = binding.troubleshootingView, - userEntry = userEntry, - lifecycleOwner = this + ActivityLaunchPermissionRequiredAndMissing.bind( + view = binding.activityLaunchPermissionMissing, + lifecycleOwner = this, + device = deviceEntry, + user = userEntry ) - ManageDeviceRebootManipulationView.bind( - view = binding.deviceRebootManipulation, - lifecycleOwner = this, - deviceEntry = deviceEntry, - auth = auth - ) + userEntry.observe(this, Observer { + binding.userCardText = it?.name ?: getString(R.string.manage_device_current_user_none) + }) return binding.root } - override fun onResume() { - super.onResume() - - logic.backgroundTaskLogic.syncDeviceStatusAsync() - } - override fun getCustomTitle() = deviceEntry.map { it?.name } } interface ManageDeviceFragmentHandlers { - fun openUsageStatsSettings() - fun openNotificationAccessSettings() - fun manageDeviceAdmin() - fun editDeviceTitle() + fun showUserScreen() + fun showPermissionsScreen() + fun showFeaturesScreen() + fun showManageScreen() fun showAuthenticationScreen() } diff --git a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/ManageDeviceManipulation.kt b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/ManageDeviceManipulation.kt index 0d37600..17ff806 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/ManageDeviceManipulation.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/ManageDeviceManipulation.kt @@ -41,6 +41,8 @@ object ManageDeviceManipulation { binding.hasManipulatedDeviceAdmin = device?.manipulationOfProtectionLevel ?: false binding.hasManipulatedUsageStatsAccess = device?.manipulationOfUsageStats ?: false binding.hasManipulatedNotificationAccess = device?.manipulationOfNotificationAccess ?: false + binding.hasManipulatedOverlayPermission = device?.manipulationOfOverlayPermission ?: false + binding.hasManipulatedAccessibilityService = device?.manipulationOfAccessibilityService ?: false binding.hasManipulationReboot = device?.manipulationDidReboot ?: false binding.hasHadManipulation = (device?.hadManipulation ?: false) and (! (device?.hasActiveManipulationWarning ?: false)) binding.hasAnyManipulation = device?.hasAnyManipulation ?: false @@ -62,6 +64,8 @@ object ManageDeviceManipulation { binding.deviceAdminDisabledCheckbox, binding.usageAccessCheckbox, binding.notificationAccessCheckbox, + binding.overlayPermissionCheckbox, + binding.accessibilityServiceCheckbox, binding.rebootCheckbox, binding.hadManipulationCheckbox ) @@ -80,6 +84,8 @@ object ManageDeviceManipulation { ignoreNotificationAccessManipulation = binding.notificationAccessCheckbox.isChecked && binding.hasManipulatedNotificationAccess == true, ignoreDeviceAdminManipulationAttempt = binding.deviceAdminDisableAttemptCheckbox.isChecked && binding.hasTriedManipulatingDeviceAdmin == true, ignoreDeviceAdminManipulation = binding.deviceAdminDisabledCheckbox.isChecked && binding.hasManipulatedDeviceAdmin == true, + ignoreOverlayPermissionManipulation = binding.overlayPermissionCheckbox.isChecked && binding.hasManipulatedOverlayPermission == true, + ignoreAccessibilityServiceManipulation = binding.accessibilityServiceCheckbox.isChecked && binding.hasManipulatedAccessibilityService == true, ignoreAppDowngrade = binding.appVersionCheckbox.isChecked && binding.hasManipulatedAppVersion == true, ignoreReboot = binding.rebootCheckbox.isChecked && binding.hasManipulationReboot == true, ignoreHadManipulation = binding.hadManipulationCheckbox.isChecked || ( diff --git a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/UsageStatsAccessRequiredAndMissing.kt b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/UsageStatsAccessRequiredAndMissing.kt index 0a307d1..7ebf565 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/UsageStatsAccessRequiredAndMissing.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/UsageStatsAccessRequiredAndMissing.kt @@ -18,20 +18,23 @@ package io.timelimit.android.ui.manage.device.manage import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData import androidx.lifecycle.Observer +import io.timelimit.android.R import io.timelimit.android.data.model.Device import io.timelimit.android.data.model.User import io.timelimit.android.data.model.UserType -import io.timelimit.android.databinding.UsageStatsPermissionRequiredAndMissingBinding +import io.timelimit.android.databinding.MissingPermissionViewBinding import io.timelimit.android.integration.platform.RuntimePermissionStatus import io.timelimit.android.livedata.mergeLiveData object UsageStatsAccessRequiredAndMissing { fun bind( - view: UsageStatsPermissionRequiredAndMissingBinding, + view: MissingPermissionViewBinding, user: LiveData, device: LiveData, lifecycleOwner: LifecycleOwner ) { + view.title = view.root.context.getString(R.string.usage_stats_permission_required_and_missing_title) + mergeLiveData(user, device).observe(lifecycleOwner, Observer { (user, device) -> view.showMessage = user?.type == UserType.Child && device?.currentUsageStatsPermission == RuntimePermissionStatus.NotGranted }) diff --git a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/advanced/ManageDevice.kt b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/advanced/ManageDevice.kt new file mode 100644 index 0000000..ab600f0 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/advanced/ManageDevice.kt @@ -0,0 +1,35 @@ +/* + * TimeLimit Copyright 2019 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.device.manage.advanced + +import androidx.fragment.app.FragmentManager +import io.timelimit.android.databinding.ManageDeviceViewBinding +import io.timelimit.android.ui.main.ActivityViewModel + +object ManageDevice { + fun bind( + view: ManageDeviceViewBinding, + activityViewModel: ActivityViewModel, + fragmentManager: FragmentManager, + deviceId: String + ) { + view.renameBtn.setOnClickListener { + if (activityViewModel.requestAuthenticationOrReturnTrue()) { + UpdateDeviceTitleDialogFragment.newInstance(deviceId).show(fragmentManager) + } + } + } +} diff --git a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/advanced/ManageDeviceAdvancedFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/advanced/ManageDeviceAdvancedFragment.kt new file mode 100644 index 0000000..b450c5b --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/advanced/ManageDeviceAdvancedFragment.kt @@ -0,0 +1,103 @@ +/* + * TimeLimit Copyright 2019 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.device.manage.advanced + + +import android.os.Bundle +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.navigation.Navigation +import io.timelimit.android.R +import io.timelimit.android.data.model.Device +import io.timelimit.android.data.model.User +import io.timelimit.android.databinding.ManageDeviceAdvancedFragmentBinding +import io.timelimit.android.livedata.ignoreUnchanged +import io.timelimit.android.livedata.liveDataFromValue +import io.timelimit.android.livedata.map +import io.timelimit.android.livedata.switchMap +import io.timelimit.android.logic.AppLogic +import io.timelimit.android.logic.DefaultAppLogic +import io.timelimit.android.ui.main.ActivityViewModel +import io.timelimit.android.ui.main.ActivityViewModelHolder +import io.timelimit.android.ui.main.AuthenticationFab +import io.timelimit.android.ui.main.FragmentWithCustomTitle + +class ManageDeviceAdvancedFragment : Fragment(), FragmentWithCustomTitle { + private val activity: ActivityViewModelHolder by lazy { getActivity() as ActivityViewModelHolder } + private val logic: AppLogic by lazy { DefaultAppLogic.with(context!!) } + private val auth: ActivityViewModel by lazy { activity.getActivityViewModel() } + private val args: ManageDeviceAdvancedFragmentArgs by lazy { ManageDeviceAdvancedFragmentArgs.fromBundle(arguments!!) } + private val deviceEntry: LiveData by lazy { + logic.database.device().getDeviceById(args.deviceId) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val binding = ManageDeviceAdvancedFragmentBinding.inflate(inflater, container, false) + val navigation = Navigation.findNavController(container!!) + + val userEntry = deviceEntry.switchMap { device -> + device?.currentUserId?.let { userId -> + logic.database.user().getUserByIdLive(userId) + } ?: liveDataFromValue(null as User?) + } + + AuthenticationFab.manageAuthenticationFab( + fab = binding.fab, + shouldHighlight = auth.shouldHighlightAuthenticationButton, + authenticatedUser = auth.authenticatedUser, + fragment = this, + doesSupportAuth = liveDataFromValue(true) + ) + + ManageDevice.bind( + view = binding.manageDevice, + activityViewModel = auth, + fragmentManager = fragmentManager!!, + deviceId = args.deviceId + ) + + ManageDeviceTroubleshooting.bind( + view = binding.troubleshootingView, + userEntry = userEntry, + lifecycleOwner = this + ) + + binding.handlers = object: ManageDeviceAdvancedFragmentHandlers { + override fun showAuthenticationScreen() { + activity.showAuthenticationScreen() + } + } + + deviceEntry.observe(this, Observer { device -> + if (device == null) { + navigation.popBackStack(R.id.overviewFragment, false) + } + }) + + + return binding.root + } + + override fun getCustomTitle() = deviceEntry.map { it?.name } +} + +interface ManageDeviceAdvancedFragmentHandlers { + fun showAuthenticationScreen() +} diff --git a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/ManageDeviceTroubleshooting.kt b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/advanced/ManageDeviceTroubleshooting.kt similarity index 96% rename from app/src/main/java/io/timelimit/android/ui/manage/device/manage/ManageDeviceTroubleshooting.kt rename to app/src/main/java/io/timelimit/android/ui/manage/device/manage/advanced/ManageDeviceTroubleshooting.kt index 8f4fda3..4ef4b34 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/ManageDeviceTroubleshooting.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/advanced/ManageDeviceTroubleshooting.kt @@ -13,7 +13,7 @@ * 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.device.manage +package io.timelimit.android.ui.manage.device.manage.advanced import android.text.method.LinkMovementMethod import androidx.lifecycle.LifecycleOwner diff --git a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/UpdateDeviceTitleDialogFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/advanced/UpdateDeviceTitleDialogFragment.kt similarity index 98% rename from app/src/main/java/io/timelimit/android/ui/manage/device/manage/UpdateDeviceTitleDialogFragment.kt rename to app/src/main/java/io/timelimit/android/ui/manage/device/manage/advanced/UpdateDeviceTitleDialogFragment.kt index 18c71ba..0aa6f9e 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/UpdateDeviceTitleDialogFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/advanced/UpdateDeviceTitleDialogFragment.kt @@ -13,7 +13,7 @@ * 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.device.manage +package io.timelimit.android.ui.manage.device.manage.advanced import android.os.Bundle import android.view.View diff --git a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/defaultuser/ManageDeviceDefaultUser.kt b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/defaultuser/ManageDeviceDefaultUser.kt new file mode 100644 index 0000000..fc08c9e --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/defaultuser/ManageDeviceDefaultUser.kt @@ -0,0 +1,102 @@ +/* + * TimeLimit Copyright 2019 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.device.manage.defaultuser + +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import io.timelimit.android.R +import io.timelimit.android.coroutines.runAsync +import io.timelimit.android.data.model.Device +import io.timelimit.android.data.model.User +import io.timelimit.android.databinding.ManageDeviceDefaultUserBinding +import io.timelimit.android.livedata.map +import io.timelimit.android.livedata.switchMap +import io.timelimit.android.sync.actions.SignOutAtDeviceAction +import io.timelimit.android.sync.actions.apply.ApplyActionUtil +import io.timelimit.android.ui.main.ActivityViewModel +import io.timelimit.android.util.TimeTextUtil + +object ManageDeviceDefaultUser { + fun bind( + view: ManageDeviceDefaultUserBinding, + users: LiveData>, + lifecycleOwner: LifecycleOwner, + device: LiveData, + isThisDevice: LiveData, + auth: ActivityViewModel, + fragmentManager: FragmentManager + ) { + val context = view.root.context + + device.switchMap { deviceEntry -> + users.map { users -> + deviceEntry to users.find { it.id == deviceEntry?.defaultUser } + } + }.observe(lifecycleOwner, Observer { (deviceEntry, defaultUser) -> + view.hasDefaultUser = defaultUser != null + view.isAlreadyUsingDefaultUser = defaultUser != null && deviceEntry?.currentUserId == defaultUser.id + view.defaultUserTitle = defaultUser?.name + }) + + isThisDevice.observe(lifecycleOwner, Observer { + view.isCurrentDevice = it + }) + + device.observe(lifecycleOwner, Observer { deviceEntry -> + view.setDefaultUserButton.setOnClickListener { + if (deviceEntry != null && auth.requestAuthenticationOrReturnTrue()) { + SetDeviceDefaultUserDialogFragment.newInstance( + deviceId = deviceEntry.id + ).show(fragmentManager) + } + } + + view.configureAutoLogoutButton.setOnClickListener { + if (deviceEntry != null && auth.requestAuthenticationOrReturnTrue()) { + SetDeviceDefaultUserTimeoutDialogFragment + .newInstance(deviceId = deviceEntry.id) + .show(fragmentManager) + } + } + + val defaultUserTimeout = deviceEntry?.defaultUserTimeout ?: 0 + + view.isAutomaticallySwitchingToDefaultUserEnabled = defaultUserTimeout != 0 + view.defaultUserSwitchText = if (defaultUserTimeout == 0) + context.getString(R.string.manage_device_default_user_timeout_off) + else + context.getString( + R.string.manage_device_default_user_timeout_on, + if (defaultUserTimeout < 1000 * 60) + TimeTextUtil.seconds(defaultUserTimeout / 1000, context) + else + TimeTextUtil.time(defaultUserTimeout, context) + ) + }) + + view.switchToDefaultUserButton.setOnClickListener { + runAsync { + ApplyActionUtil.applyAppLogicAction( + action = SignOutAtDeviceAction, + appLogic = auth.logic, + ignoreIfDeviceIsNotConfigured = true + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/defaultuser/SetDeviceDefaultUserDialogFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/defaultuser/SetDeviceDefaultUserDialogFragment.kt new file mode 100644 index 0000000..a551bba --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/defaultuser/SetDeviceDefaultUserDialogFragment.kt @@ -0,0 +1,131 @@ +/* + * TimeLimit Copyright 2019 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.device.manage.defaultuser + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.CheckedTextView +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Observer +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import io.timelimit.android.R +import io.timelimit.android.data.Database +import io.timelimit.android.data.model.UserType +import io.timelimit.android.databinding.BottomSheetSelectionListBinding +import io.timelimit.android.extensions.showSafe +import io.timelimit.android.livedata.ignoreUnchanged +import io.timelimit.android.livedata.map +import io.timelimit.android.livedata.switchMap +import io.timelimit.android.logic.AppLogic +import io.timelimit.android.logic.DefaultAppLogic +import io.timelimit.android.sync.actions.SetDeviceDefaultUserAction +import io.timelimit.android.ui.main.ActivityViewModel +import io.timelimit.android.ui.main.ActivityViewModelHolder + +class SetDeviceDefaultUserDialogFragment: BottomSheetDialogFragment() { + companion object { + private const val EXTRA_DEVICE_ID = "deviceId" + private const val DIALOG_TAG = "sddudf" + + fun newInstance(deviceId: String) = SetDeviceDefaultUserDialogFragment().apply { + arguments = Bundle().apply { + putString(EXTRA_DEVICE_ID, deviceId) + } + } + } + + val deviceId: String by lazy { arguments!!.getString(EXTRA_DEVICE_ID) } + val logic: AppLogic by lazy { DefaultAppLogic.with(context!!) } + val database: Database by lazy { logic.database } + val auth: ActivityViewModel by lazy { (activity as ActivityViewModelHolder).getActivityViewModel() } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + auth.authenticatedUser.observe(this, Observer { + if (it?.second?.type != UserType.Parent) { + dismissAllowingStateLoss() + } + }) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val binding = BottomSheetSelectionListBinding.inflate(inflater, container, false) + + binding.title = getString(R.string.manage_device_default_user_title) + + val list = binding.list + val users = database.user().getAllUsersLive() + val deviceEntry = database.device().getDeviceById(deviceId) + val currentDefaultUserId = deviceEntry.map { it?.defaultUser }.ignoreUnchanged() + + currentDefaultUserId.switchMap { v1 -> + users.map { v2 -> v1 to v2 } + }.observe(this, Observer { (defaultUserId, userList) -> + list.removeAllViews() + + fun buildRow(): CheckedTextView = LayoutInflater.from(context!!).inflate( + android.R.layout.simple_list_item_single_choice, + list, + false + ) as CheckedTextView + + val hasDefaultUser = userList.find { it.id == defaultUserId } != null + + userList.forEach { user -> + buildRow().let { row -> + row.text = user.name + row.isChecked = defaultUserId == user.id + row.setOnClickListener { + auth.tryDispatchParentAction( + SetDeviceDefaultUserAction( + deviceId = deviceId, + defaultUserId = user.id + ) + ) + + dismiss() + } + + list.addView(row) + } + } + + buildRow().let { row -> + row.setText(R.string.manage_device_default_user_selection_none) + row.isChecked = !hasDefaultUser + row.setOnClickListener { + auth.tryDispatchParentAction( + SetDeviceDefaultUserAction( + deviceId = deviceId, + defaultUserId = "" + ) + ) + + dismiss() + } + + list.addView(row) + } + }) + + return binding.root + } + + fun show(fragmentManager: FragmentManager) = showSafe(fragmentManager, DIALOG_TAG) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/defaultuser/SetDeviceDefaultUserTimeoutDialogFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/defaultuser/SetDeviceDefaultUserTimeoutDialogFragment.kt new file mode 100644 index 0000000..d6c42e7 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/defaultuser/SetDeviceDefaultUserTimeoutDialogFragment.kt @@ -0,0 +1,125 @@ +/* + * TimeLimit Copyright 2019 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.device.manage.defaultuser + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.CheckedTextView +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import io.timelimit.android.R +import io.timelimit.android.data.model.Device +import io.timelimit.android.data.model.UserType +import io.timelimit.android.databinding.BottomSheetSelectionListBinding +import io.timelimit.android.extensions.showSafe +import io.timelimit.android.logic.DefaultAppLogic +import io.timelimit.android.sync.actions.SetDeviceDefaultUserTimeoutAction +import io.timelimit.android.ui.main.ActivityViewModel +import io.timelimit.android.ui.main.getActivityViewModel +import io.timelimit.android.util.TimeTextUtil + +class SetDeviceDefaultUserTimeoutDialogFragment: BottomSheetDialogFragment() { + companion object { + private const val EXTRA_DEVICE_ID = "deviceId" + private const val DIALOG_TAG = "sddutdf" + private val OPTIONS = listOf( + 0, + 1000 * 5, + 1000 * 60, + 1000 * 60 * 5, + 1000 * 60 * 15, + 1000 * 60 * 30, + 1000 * 60 * 60 + ) + + fun newInstance(deviceId: String) = SetDeviceDefaultUserTimeoutDialogFragment().apply { + arguments = Bundle().apply { + putString(EXTRA_DEVICE_ID, deviceId) + } + } + } + + val deviceId: String by lazy { arguments!!.getString(EXTRA_DEVICE_ID) } + val deviceEntry: LiveData by lazy { + DefaultAppLogic.with(context!!).database.device().getDeviceById(deviceId) + } + val auth: ActivityViewModel by lazy { getActivityViewModel(activity!!) } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + auth.authenticatedUser.observe(this, Observer { + if (it?.second?.type != UserType.Parent) { + dismissAllowingStateLoss() + } + }) + + deviceEntry.observe(this, Observer { + if (it == null) { + dismissAllowingStateLoss() + } + }) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val binding = BottomSheetSelectionListBinding.inflate(inflater, container, false) + binding.title = getString(R.string.manage_device_default_user_timeout_dialog_title) + val list = binding.list + + deviceEntry.observe(this, Observer { device -> + val timeout = device?.defaultUserTimeout ?: 0 + + fun buildRow(): CheckedTextView = LayoutInflater.from(context!!).inflate( + android.R.layout.simple_list_item_single_choice, + list, + false + ) as CheckedTextView + + list.removeAllViews() + + OPTIONS.forEach { option -> + buildRow().let { row -> + row.text = if (option == 0) + getString(R.string.manage_device_default_user_timeout_dialog_disable) + else if (option < 1000 * 60) + TimeTextUtil.seconds(option / 1000, context!!) + else + TimeTextUtil.time(option, context!!) + + row.isChecked = option == timeout + row.setOnClickListener { + auth.tryDispatchParentAction(SetDeviceDefaultUserTimeoutAction( + deviceId = deviceId, + timeout = option + )) + + dismiss() + } + + list.addView(row) + } + } + }) + + return binding.root + } + + fun show(fragmentManager: FragmentManager) = showSafe(fragmentManager, DIALOG_TAG) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/feature/ManageDeviceActivityLevelBlocking.kt b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/feature/ManageDeviceActivityLevelBlocking.kt new file mode 100644 index 0000000..bf741e2 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/feature/ManageDeviceActivityLevelBlocking.kt @@ -0,0 +1,40 @@ +package io.timelimit.android.ui.manage.device.manage.feature + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import io.timelimit.android.data.model.Device +import io.timelimit.android.databinding.ManageDeviceActivityLevelBlockingBinding +import io.timelimit.android.sync.actions.UpdateEnableActivityLevelBlocking +import io.timelimit.android.ui.main.ActivityViewModel + +object ManageDeviceActivityLevelBlocking { + fun bind( + view: ManageDeviceActivityLevelBlockingBinding, + auth: ActivityViewModel, + deviceEntry: LiveData, + lifecycleOwner: LifecycleOwner + ) { + deviceEntry.observe(lifecycleOwner, Observer { device -> + val enable = device?.enableActivityLevelBlocking ?: false + + view.checkbox.setOnCheckedChangeListener { _, _ -> } + view.checkbox.isChecked = enable + view.checkbox.setOnCheckedChangeListener { _, isChecked -> + if (isChecked != enable) { + if ( + device == null || + (!auth.tryDispatchParentAction( + UpdateEnableActivityLevelBlocking( + deviceId = device.id, + enable = isChecked + ) + )) + ) { + view.checkbox.isChecked = enable + } + } + } + }) + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/feature/ManageDeviceFeaturesFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/feature/ManageDeviceFeaturesFragment.kt new file mode 100644 index 0000000..52fefbb --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/feature/ManageDeviceFeaturesFragment.kt @@ -0,0 +1,122 @@ +/* + * TimeLimit Copyright 2019 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.device.manage.feature + + +import android.content.Context +import android.os.Bundle +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.navigation.Navigation +import io.timelimit.android.R +import io.timelimit.android.data.model.Device +import io.timelimit.android.databinding.ManageDeviceFeaturesFragmentBinding +import io.timelimit.android.livedata.liveDataFromValue +import io.timelimit.android.livedata.map +import io.timelimit.android.logic.AppLogic +import io.timelimit.android.logic.DefaultAppLogic +import io.timelimit.android.ui.main.ActivityViewModel +import io.timelimit.android.ui.main.ActivityViewModelHolder +import io.timelimit.android.ui.main.AuthenticationFab +import io.timelimit.android.ui.main.FragmentWithCustomTitle + +class ManageDeviceFeaturesFragment : Fragment(), FragmentWithCustomTitle { + companion object { + fun getPreviewText(device: Device, context: Context): String { + val featureLabels = mutableListOf() + + if (device.considerRebootManipulation) { + featureLabels.add(context.getString(R.string.manage_device_reboot_manipulation_title)) + } + + if (device.enableActivityLevelBlocking) { + featureLabels.add(context.getString(R.string.manage_device_activity_level_blocking_title)) + } + + return if (featureLabels.isEmpty()) { + context.getString(R.string.manage_device_feature_summary_none) + } else { + featureLabels.joinToString(separator = ", ") + } + } + } + + private val activity: ActivityViewModelHolder by lazy { getActivity() as ActivityViewModelHolder } + private val logic: AppLogic by lazy { DefaultAppLogic.with(context!!) } + private val auth: ActivityViewModel by lazy { activity.getActivityViewModel() } + private val args: ManageDeviceFeaturesFragmentArgs by lazy { ManageDeviceFeaturesFragmentArgs.fromBundle(arguments!!) } + private val deviceEntry: LiveData by lazy { + logic.database.device().getDeviceById(args.deviceId) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val navigation = Navigation.findNavController(container!!) + val binding = ManageDeviceFeaturesFragmentBinding.inflate(inflater, container, false) + + // auth + AuthenticationFab.manageAuthenticationFab( + fab = binding.fab, + shouldHighlight = auth.shouldHighlightAuthenticationButton, + authenticatedUser = auth.authenticatedUser, + fragment = this, + doesSupportAuth = liveDataFromValue(true) + ) + + // handlers + binding.handlers = object: ManageDeviceFeaturesFragmentHandlers { + override fun showAuthenticationScreen() { + activity.showAuthenticationScreen() + } + } + + // going back + deviceEntry.observe(this, Observer { + device -> + + if (device == null) { + navigation.popBackStack(R.id.overviewFragment, false) + } + }) + + // handle reboot as manipulation + ManageDeviceRebootManipulationView.bind( + view = binding.deviceRebootManipulation, + lifecycleOwner = this, + deviceEntry = deviceEntry, + auth = auth + ) + + // activity level blocking + ManageDeviceActivityLevelBlocking.bind( + view = binding.activityLevelBlocking, + auth = auth, + deviceEntry = deviceEntry, + lifecycleOwner = this + ) + + return binding.root + } + + override fun getCustomTitle() = deviceEntry.map { it?.name } +} + +interface ManageDeviceFeaturesFragmentHandlers { + fun showAuthenticationScreen() +} diff --git a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/ManageDeviceRebootManipulationView.kt b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/feature/ManageDeviceRebootManipulationView.kt similarity index 97% rename from app/src/main/java/io/timelimit/android/ui/manage/device/manage/ManageDeviceRebootManipulationView.kt rename to app/src/main/java/io/timelimit/android/ui/manage/device/manage/feature/ManageDeviceRebootManipulationView.kt index 93b8e90..21725a5 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/ManageDeviceRebootManipulationView.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/feature/ManageDeviceRebootManipulationView.kt @@ -13,7 +13,7 @@ * 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.device.manage +package io.timelimit.android.ui.manage.device.manage.feature import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LiveData diff --git a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/InformAboutDeviceOwnerDialogFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/permission/InformAboutDeviceOwnerDialogFragment.kt similarity index 97% rename from app/src/main/java/io/timelimit/android/ui/manage/device/manage/InformAboutDeviceOwnerDialogFragment.kt rename to app/src/main/java/io/timelimit/android/ui/manage/device/manage/permission/InformAboutDeviceOwnerDialogFragment.kt index f5d910f..a707c43 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/InformAboutDeviceOwnerDialogFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/permission/InformAboutDeviceOwnerDialogFragment.kt @@ -13,7 +13,7 @@ * 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.device.manage +package io.timelimit.android.ui.manage.device.manage.permission import android.app.Dialog import android.app.admin.DevicePolicyManager diff --git a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/permission/ManageDevicePermissionsFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/permission/ManageDevicePermissionsFragment.kt new file mode 100644 index 0000000..5e51b92 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/permission/ManageDevicePermissionsFragment.kt @@ -0,0 +1,222 @@ +/* + * TimeLimit Copyright 2019 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.device.manage.permission + +import android.app.admin.DevicePolicyManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.Settings +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.navigation.Navigation +import io.timelimit.android.R +import io.timelimit.android.data.model.Device +import io.timelimit.android.databinding.ManageDevicePermissionsFragmentBinding +import io.timelimit.android.integration.platform.NewPermissionStatus +import io.timelimit.android.integration.platform.ProtectionLevel +import io.timelimit.android.integration.platform.RuntimePermissionStatus +import io.timelimit.android.integration.platform.android.AdminReceiver +import io.timelimit.android.livedata.ignoreUnchanged +import io.timelimit.android.livedata.liveDataFromValue +import io.timelimit.android.livedata.map +import io.timelimit.android.logic.AppLogic +import io.timelimit.android.logic.DefaultAppLogic +import io.timelimit.android.ui.main.ActivityViewModel +import io.timelimit.android.ui.main.ActivityViewModelHolder +import io.timelimit.android.ui.main.AuthenticationFab +import io.timelimit.android.ui.main.FragmentWithCustomTitle + +class ManageDevicePermissionsFragment : Fragment(), FragmentWithCustomTitle { + companion object { + fun getPreviewText(device: Device, context: Context): String { + val permissionLabels = mutableListOf() + + if (device.currentUsageStatsPermission == RuntimePermissionStatus.Granted) { + permissionLabels.add(context.getString(R.string.manage_device_permissions_usagestats_title_short)) + } + + if (device.currentNotificationAccessPermission == NewPermissionStatus.Granted) { + permissionLabels.add(context.getString(R.string.manage_device_permission_notification_access_title)) + } + + if (device.currentProtectionLevel != ProtectionLevel.None) { + permissionLabels.add(context.getString(R.string.manage_device_permission_device_admin_title)) + } + + if (device.currentOverlayPermission == RuntimePermissionStatus.Granted) { + permissionLabels.add(context.getString(R.string.manage_device_permissions_overlay_title)) + } + + if (device.accessibilityServiceEnabled) { + permissionLabels.add(context.getString(R.string.manage_device_permission_accessibility_title)) + } + + return if (permissionLabels.isEmpty()) { + context.getString(R.string.manage_device_permissions_summary_none) + } else { + permissionLabels.joinToString(", ") + } + } + } + + private val activity: ActivityViewModelHolder by lazy { getActivity() as ActivityViewModelHolder } + private val logic: AppLogic by lazy { DefaultAppLogic.with(context!!) } + private val auth: ActivityViewModel by lazy { activity.getActivityViewModel() } + private val args: ManageDevicePermissionsFragmentArgs by lazy { ManageDevicePermissionsFragmentArgs.fromBundle(arguments!!) } + private val deviceEntry: LiveData by lazy { + logic.database.device().getDeviceById(args.deviceId) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val navigation = Navigation.findNavController(container!!) + val binding = ManageDevicePermissionsFragmentBinding.inflate(inflater, container, false) + + // auth + AuthenticationFab.manageAuthenticationFab( + fab = binding.fab, + shouldHighlight = auth.shouldHighlightAuthenticationButton, + authenticatedUser = auth.authenticatedUser, + fragment = this, + doesSupportAuth = liveDataFromValue(true) + ) + + // handlers + binding.handlers = object: ManageDevicePermissionsFragmentHandlers { + override fun openUsageStatsSettings() { + if (binding.isThisDevice == true) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + startActivity( + Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } + } + } + + override fun openNotificationAccessSettings() { + if (binding.isThisDevice == true) { + try { + startActivity( + Intent("android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS") + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } catch (ex: Exception) { + Toast.makeText( + context, + R.string.error_general, + Toast.LENGTH_SHORT + ).show() + } + } + } + + override fun openDrawOverOtherAppsScreen() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + startActivity( + Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + context!!.packageName)) + ) + } + } + + override fun openAccessibilitySettings() { + startActivity( + Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } + + override fun manageDeviceAdmin() { + if (binding.isThisDevice == true) { + val protectionLevel = logic.platformIntegration.getCurrentProtectionLevel() + + if (protectionLevel == ProtectionLevel.None) { + if (InformAboutDeviceOwnerDialogFragment.shouldShow) { + InformAboutDeviceOwnerDialogFragment().show(fragmentManager!!) + } else { + startActivity( + Intent(DevicePolicyManager.ACTION_ADD_DEVICE_ADMIN) + .putExtra( + DevicePolicyManager.EXTRA_DEVICE_ADMIN, + ComponentName(context!!, AdminReceiver::class.java) + ) + ) + } + } else { + startActivity( + Intent(Settings.ACTION_SECURITY_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } + } + } + + override fun showAuthenticationScreen() { + activity.showAuthenticationScreen() + } + } + + // is this device + val isThisDevice = logic.deviceId.map { ownDeviceId -> ownDeviceId == args.deviceId }.ignoreUnchanged() + + isThisDevice.observe(this, Observer { + binding.isThisDevice = it + }) + + // permissions + deviceEntry.observe(this, Observer { + device -> + + if (device == null) { + navigation.popBackStack(R.id.overviewFragment, false) + } else { + binding.usageStatsAccess = device.currentUsageStatsPermission + binding.notificationAccessPermission = device.currentNotificationAccessPermission + binding.protectionLevel = device.currentProtectionLevel + binding.overlayPermission = device.currentOverlayPermission + binding.accessibilityServiceEnabled = device.accessibilityServiceEnabled + } + }) + + + return binding.root + } + + override fun onResume() { + super.onResume() + + logic.backgroundTaskLogic.syncDeviceStatusAsync() + } + + override fun getCustomTitle() = deviceEntry.map { it?.name } +} + +interface ManageDevicePermissionsFragmentHandlers { + fun openUsageStatsSettings() + fun openNotificationAccessSettings() + fun openDrawOverOtherAppsScreen() + fun openAccessibilitySettings() + fun manageDeviceAdmin() + fun showAuthenticationScreen() +} diff --git a/app/src/main/java/io/timelimit/android/ui/manage/device/manage/user/ManageDeviceUserFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/user/ManageDeviceUserFragment.kt new file mode 100644 index 0000000..fa7596a --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/device/manage/user/ManageDeviceUserFragment.kt @@ -0,0 +1,182 @@ +/* + * TimeLimit Copyright 2019 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.device.manage.user + + +import android.os.Bundle +import androidx.fragment.app.Fragment +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.RadioButton +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import androidx.navigation.Navigation +import io.timelimit.android.R +import io.timelimit.android.data.model.Device +import io.timelimit.android.databinding.ManageDeviceUserFragmentBinding +import io.timelimit.android.livedata.ignoreUnchanged +import io.timelimit.android.livedata.liveDataFromValue +import io.timelimit.android.livedata.map +import io.timelimit.android.livedata.mergeLiveData +import io.timelimit.android.logic.AppLogic +import io.timelimit.android.logic.DefaultAppLogic +import io.timelimit.android.sync.actions.SetDeviceUserAction +import io.timelimit.android.ui.main.ActivityViewModel +import io.timelimit.android.ui.main.ActivityViewModelHolder +import io.timelimit.android.ui.main.AuthenticationFab +import io.timelimit.android.ui.main.FragmentWithCustomTitle +import io.timelimit.android.ui.manage.device.manage.defaultuser.ManageDeviceDefaultUser + +class ManageDeviceUserFragment : Fragment(), FragmentWithCustomTitle { + private val activity: ActivityViewModelHolder by lazy { getActivity() as ActivityViewModelHolder } + private val logic: AppLogic by lazy { DefaultAppLogic.with(context!!) } + private val auth: ActivityViewModel by lazy { activity.getActivityViewModel() } + private val args: ManageDeviceUserFragmentArgs by lazy { ManageDeviceUserFragmentArgs.fromBundle(arguments!!) } + private val deviceEntry: LiveData by lazy { + logic.database.device().getDeviceById(args.deviceId) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + val navigation = Navigation.findNavController(container!!) + val binding = ManageDeviceUserFragmentBinding.inflate(inflater, container, false) + val userEntries = logic.database.user().getAllUsersLive() + var isBindingUserListSelection = false + + // auth + AuthenticationFab.manageAuthenticationFab( + fab = binding.fab, + shouldHighlight = auth.shouldHighlightAuthenticationButton, + authenticatedUser = auth.authenticatedUser, + fragment = this, + doesSupportAuth = liveDataFromValue(true) + ) + + // label, id + val userListItems = ArrayList>() + + fun bindUserListItems() { + userListItems.forEachIndexed { index, listItem -> + val oldRadio = binding.userList.getChildAt(index) as RadioButton? + val radio = oldRadio ?: RadioButton(context!!) + + radio.text = listItem.first + + if (oldRadio == null) { + radio.layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ) + radio.id = index + + binding.userList.addView(radio) + } + } + + while (binding.userList.childCount > userListItems.size) { + binding.userList.removeViewAt(userListItems.size) + } + } + + fun bindUserListSelection() { + isBindingUserListSelection = true + + val selectedUserId = deviceEntry.value?.currentUserId + val selectedIndex = userListItems.indexOfFirst { it.second == selectedUserId } + + if (selectedIndex != -1) { + binding.userList.check(selectedIndex) + } else { + val fallbackSelectedIndex = userListItems.indexOfFirst { it.second == "" } + + if (fallbackSelectedIndex != -1) { + binding.userList.check(fallbackSelectedIndex) + } + } + + isBindingUserListSelection = false + } + + binding.handlers = object: ManageDeviceUserFragmentHandlers { + override fun showAuthenticationScreen() { + activity.showAuthenticationScreen() + } + } + + binding.userList.setOnCheckedChangeListener { _, checkedId -> + if (isBindingUserListSelection) { + return@setOnCheckedChangeListener + } + + val userId = userListItems[checkedId].second + val device = deviceEntry.value + + if (device != null && device.currentUserId != userId) { + if (!auth.tryDispatchParentAction( + SetDeviceUserAction( + deviceId = args.deviceId, + userId = userId + ) + )) { + bindUserListSelection() + } + } + } + + deviceEntry.observe(this, Observer { + device -> + + if (device == null) { + navigation.popBackStack(R.id.overviewFragment, false) + } + }) + + val isThisDevice = logic.deviceId.map { ownDeviceId -> ownDeviceId == args.deviceId }.ignoreUnchanged() + + mergeLiveData(deviceEntry, userEntries).observe(this, Observer { + val (device, users) = it!! + + if (device != null && users != null) { + userListItems.clear() + userListItems.addAll( + users.map { user -> Pair(user.name, user.id) } + ) + userListItems.add(Pair(getString(R.string.manage_device_current_user_none), "")) + + bindUserListItems() + bindUserListSelection() + } + }) + + ManageDeviceDefaultUser.bind( + view = binding.defaultUser, + device = deviceEntry, + users = userEntries, + lifecycleOwner = this, + isThisDevice = isThisDevice, + auth = auth, + fragmentManager = fragmentManager!! + ) + + return binding.root + } + + override fun getCustomTitle() = deviceEntry.map { it?.name } +} + +interface ManageDeviceUserFragmentHandlers { + fun showAuthenticationScreen() +} diff --git a/app/src/main/java/io/timelimit/android/ui/manipulation/UnlockAfterManipulationActivity.kt b/app/src/main/java/io/timelimit/android/ui/manipulation/UnlockAfterManipulationActivity.kt index 2c8a5f7..b193e8c 100644 --- a/app/src/main/java/io/timelimit/android/ui/manipulation/UnlockAfterManipulationActivity.kt +++ b/app/src/main/java/io/timelimit/android/ui/manipulation/UnlockAfterManipulationActivity.kt @@ -36,6 +36,8 @@ class UnlockAfterManipulationActivity : AppCompatActivity(), ActivityViewModelHo ViewModelProviders.of(this).get(ActivityViewModel::class.java) } + override var ignoreStop: Boolean = false + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_unlock_after_manipulation) diff --git a/app/src/main/java/io/timelimit/android/ui/mustread/MustReadFragment.kt b/app/src/main/java/io/timelimit/android/ui/mustread/MustReadFragment.kt new file mode 100644 index 0000000..bc157f0 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/mustread/MustReadFragment.kt @@ -0,0 +1,71 @@ +/* + * TimeLimit Copyright 2019 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.mustread + +import android.app.Dialog +import android.os.Bundle +import androidx.appcompat.app.AlertDialog +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.FragmentManager +import androidx.lifecycle.Observer +import androidx.lifecycle.ViewModelProviders +import io.timelimit.android.R +import io.timelimit.android.extensions.showSafe + +class MustReadFragment: DialogFragment() { + companion object { + private const val DIALOG_TAG = "MustReadDialog" + private const val MESSAGE = "message" + + fun newInstance(message: Int) = MustReadFragment().apply { + arguments = Bundle().apply { + putInt(MESSAGE, message) + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + isCancelable = false + } + + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { + val model = ViewModelProviders.of(this).get(MustReadModel::class.java) + + val alert = AlertDialog.Builder(context!!, theme) + .setMessage(arguments!!.getInt(MESSAGE)) + .setPositiveButton(R.string.generic_ok) { _, _ -> dismiss() } + .create() + + alert.setOnShowListener { + val okButton = alert.getButton(AlertDialog.BUTTON_POSITIVE) + val okString = getString(R.string.generic_ok) + + model.timer.observe(this, Observer { + okButton.isEnabled = it == 0 + okButton.text = if (it == 0) + okString + else + "$okString ($it)" + }) + } + + return alert + } + + 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/mustread/MustReadModel.kt b/app/src/main/java/io/timelimit/android/ui/mustread/MustReadModel.kt new file mode 100644 index 0000000..27e9bf8 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/mustread/MustReadModel.kt @@ -0,0 +1,36 @@ +/* + * TimeLimit Copyright 2019 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.mustread + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import io.timelimit.android.coroutines.runAsync +import io.timelimit.android.livedata.castDown +import kotlinx.coroutines.delay + +class MustReadModel: ViewModel() { + private val timerInternal = MutableLiveData() + val timer = timerInternal.castDown() + + init { + runAsync { + for (i in 10 downTo 0) { + timerInternal.value = i + delay(1000) + } + } + } +} 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 e9d8b7f..2549499 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 @@ -20,6 +20,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentTransaction import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import androidx.navigation.NavController @@ -35,10 +36,14 @@ import io.timelimit.android.livedata.switchMap import io.timelimit.android.livedata.waitForNullableValue import io.timelimit.android.logic.AppLogic import io.timelimit.android.logic.DefaultAppLogic +import io.timelimit.android.ui.contacts.ContactsFragment import io.timelimit.android.ui.main.ActivityViewModelHolder import io.timelimit.android.ui.main.AuthenticationFab +import io.timelimit.android.ui.overview.about.AboutFragment import io.timelimit.android.ui.overview.about.AboutFragmentParentHandlers +import io.timelimit.android.ui.overview.overview.OverviewFragment import io.timelimit.android.ui.overview.overview.OverviewFragmentParentHandlers +import io.timelimit.android.ui.overview.uninstall.UninstallFragment import kotlinx.android.synthetic.main.fragment_main.* class MainFragment : Fragment(), OverviewFragmentParentHandlers, AboutFragmentParentHandlers { @@ -79,7 +84,7 @@ class MainFragment : Fragment(), OverviewFragmentParentHandlers, AboutFragmentPa } }.observe(this, Observer { shouldShowSetup -> if (shouldShowSetup == true) { - pager.post { + fab.post { navigation.safeNavigate( MainFragmentDirections.actionOverviewFragmentToSetupTermsFragment(), R.id.overviewFragment @@ -103,52 +108,41 @@ class MainFragment : Fragment(), OverviewFragmentParentHandlers, AboutFragmentPa }) } - pager.adapter = adapter - - bottom_navigation_view.setOnNavigationItemSelectedListener { - menuItem -> - - pager.currentItem = when(menuItem.itemId) { - R.id.main_tab_overview -> 0 - R.id.main_tab_uninstall -> 1 - R.id.main_tab_about -> 2 - else -> 0 - } - - true - } - - fun updateShowFab(selectedPage: Int) { - showAuthButtonLive.value = when (selectedPage) { - 0 -> true - 1 -> true - 2 -> false + fun updateShowFab(selectedItemId: Int) { + showAuthButtonLive.value = when (selectedItemId) { + R.id.main_tab_overview -> true + R.id.main_tab_contacts -> true + R.id.main_tab_uninstall -> true + R.id.main_tab_about -> false else -> throw IllegalStateException() } } - updateShowFab(pager.currentItem) + bottom_navigation_view.setOnNavigationItemReselectedListener { /* ignore */ } + bottom_navigation_view.setOnNavigationItemSelectedListener { menuItem -> + childFragmentManager.beginTransaction() + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE) + .replace(R.id.container, when(menuItem.itemId) { + R.id.main_tab_overview -> OverviewFragment() + R.id.main_tab_contacts -> ContactsFragment() + R.id.main_tab_uninstall -> UninstallFragment() + R.id.main_tab_about -> AboutFragment() + else -> throw IllegalStateException() + }) + .commit() - pager.addOnPageChangeListener(object: ViewPager.OnPageChangeListener { - override fun onPageScrollStateChanged(state: Int) { - // ignore - } + updateShowFab(menuItem.itemId) - override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) { - // ignore - } + true + } - override fun onPageSelected(position: Int) { - updateShowFab(position) + if (childFragmentManager.findFragmentById(R.id.container) == null) { + childFragmentManager.beginTransaction() + .replace(R.id.container, OverviewFragment()) + .commit() + } - bottom_navigation_view.selectedItemId = when(pager.currentItem) { - 0 -> R.id.main_tab_overview - 1 -> R.id.main_tab_uninstall - 2 -> R.id.main_tab_about - else -> throw IllegalStateException() - } - } - }) + updateShowFab(bottom_navigation_view.selectedItemId) } override fun openAddUserScreen() { diff --git a/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragment.kt b/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragment.kt index a1bc7c4..e8494dc 100644 --- a/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragment.kt @@ -80,7 +80,10 @@ class OverviewFragment : CoroutineFragment() { ItemTouchHelper( object: ItemTouchHelper.Callback() { override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { - if (adapter.data!![viewHolder.adapterPosition] == OverviewFragmentHeaderIntro) { + val index = viewHolder.adapterPosition + val item = if (index == RecyclerView.NO_POSITION) null else adapter.data!![index] + + if (item == OverviewFragmentHeaderIntro) { return makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE, ItemTouchHelper.END) or makeFlag(ItemTouchHelper.ACTION_STATE_IDLE, ItemTouchHelper.END) } else { diff --git a/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragmentItem.kt b/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragmentItem.kt index 83617a4..62d2e0d 100644 --- a/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragmentItem.kt +++ b/app/src/main/java/io/timelimit/android/ui/overview/overview/OverviewFragmentItem.kt @@ -24,7 +24,9 @@ sealed class OverviewFragmentItem object OverviewFragmentHeaderUsers: OverviewFragmentItem() object OverviewFragmentHeaderDevices: OverviewFragmentItem() data class OverviewFragmentItemDevice(val device: Device, val deviceUser: User?, val isCurrentDevice: Boolean): OverviewFragmentItem() { - val isMissingRequiredPermission = deviceUser?.type == UserType.Child && device.currentUsageStatsPermission == RuntimePermissionStatus.NotGranted + val isMissingRequiredPermission = deviceUser?.type == UserType.Child && ( + device.currentUsageStatsPermission == RuntimePermissionStatus.NotGranted || device.missingPermissionAtQOrLater + ) } data class OverviewFragmentItemUser(val user: User, val temporarilyBlocked: Boolean, val limitsTemporarilyDisabled: Boolean): OverviewFragmentItem() object OverviewFragmentActionAddUser: OverviewFragmentItem() diff --git a/app/src/main/java/io/timelimit/android/ui/setup/SetupDevicePermissionsFragment.kt b/app/src/main/java/io/timelimit/android/ui/setup/SetupDevicePermissionsFragment.kt index e8bbb00..b7441d5 100644 --- a/app/src/main/java/io/timelimit/android/ui/setup/SetupDevicePermissionsFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/setup/SetupDevicePermissionsFragment.kt @@ -18,6 +18,7 @@ package io.timelimit.android.ui.setup import android.app.admin.DevicePolicyManager import android.content.ComponentName import android.content.Intent +import android.net.Uri import android.os.Build import android.os.Bundle import android.provider.Settings @@ -34,7 +35,7 @@ import io.timelimit.android.integration.platform.ProtectionLevel import io.timelimit.android.integration.platform.android.AdminReceiver import io.timelimit.android.logic.AppLogic import io.timelimit.android.logic.DefaultAppLogic -import io.timelimit.android.ui.manage.device.manage.InformAboutDeviceOwnerDialogFragment +import io.timelimit.android.ui.manage.device.manage.permission.InformAboutDeviceOwnerDialogFragment class SetupDevicePermissionsFragment : Fragment() { private val logic: AppLogic by lazy { DefaultAppLogic.with(context!!) } @@ -93,6 +94,22 @@ class SetupDevicePermissionsFragment : Fragment() { } } + override fun openDrawOverOtherAppsScreen() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + startActivity( + Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + context!!.packageName)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } + } + + override fun openAccessibilitySettings() { + startActivity( + Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } + override fun gotoNextStep() { navigation.safeNavigate( SetupDevicePermissionsFragmentDirections @@ -113,6 +130,8 @@ class SetupDevicePermissionsFragment : Fragment() { binding.notificationAccessPermission = platform.getNotificationAccessPermissionStatus() binding.protectionLevel = platform.getCurrentProtectionLevel() binding.usageStatsAccess = platform.getForegroundAppPermissionStatus() + binding.overlayPermission = platform.getOverlayPermissionStatus() + binding.accessibilityServiceEnabled = platform.isAccessibilityServiceEnabled() } override fun onResume() { @@ -126,5 +145,7 @@ interface SetupDevicePermissionsHandlers { fun manageDeviceAdmin() fun openUsageStatsSettings() fun openNotificationAccessSettings() + fun openDrawOverOtherAppsScreen() + fun openAccessibilitySettings() fun gotoNextStep() } diff --git a/app/src/main/java/io/timelimit/android/ui/user/create/AddUserModel.kt b/app/src/main/java/io/timelimit/android/ui/user/create/AddUserModel.kt index 97c0425..aca3fbc 100644 --- a/app/src/main/java/io/timelimit/android/ui/user/create/AddUserModel.kt +++ b/app/src/main/java/io/timelimit/android/ui/user/create/AddUserModel.kt @@ -95,7 +95,7 @@ class AddUserModel(application: Application): AndroidViewModel(application) { ) )) - defaultCategories.generateGamesTimeLimitRules(allowedAppsCategory).forEach { rule -> + defaultCategories.generateGamesTimeLimitRules(allowedGamesCategory).forEach { rule -> actions.add(CreateTimeLimitRuleAction(rule)) } diff --git a/app/src/main/java/io/timelimit/android/ui/view/SelectTimeSpanView.kt b/app/src/main/java/io/timelimit/android/ui/view/SelectTimeSpanView.kt index 01f8221..f36c009 100644 --- a/app/src/main/java/io/timelimit/android/ui/view/SelectTimeSpanView.kt +++ b/app/src/main/java/io/timelimit/android/ui/view/SelectTimeSpanView.kt @@ -18,6 +18,7 @@ package io.timelimit.android.ui.view import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater +import android.view.View import android.widget.FrameLayout import android.widget.SeekBar import io.timelimit.android.R @@ -34,14 +35,16 @@ class SelectTimeSpanView(context: Context, attributeSet: AttributeSet): FrameLay var listener: SelectTimeSpanViewListener? = null - var timeInMillis: Long by Delegates.observable(0L) { - _, _, _ -> - bindTime() - listener?.onTimeSpanChanged(timeInMillis) + var timeInMillis: Long by Delegates.observable(0L) { _, _, _ -> + bindTime() + listener?.onTimeSpanChanged(timeInMillis) } - var maxDays: Int by Delegates.observable(0) { - _, _, _ -> binding.maxDays = maxDays + var maxDays: Int by Delegates.observable(0) { _, _, _ -> + binding.maxDays = maxDays + + binding.dayPicker.maxValue = maxDays + binding.dayPickerContainer.visibility = if (maxDays > 0) View.VISIBLE else View.GONE } init { @@ -69,6 +72,10 @@ class SelectTimeSpanView(context: Context, attributeSet: AttributeSet): FrameLay binding.daysText = TimeTextUtil.days(totalDays, context!!) binding.minutesText = TimeTextUtil.minutes(minutes, context!!) binding.hoursText = TimeTextUtil.hours(hours, context!!) + + binding.minutePicker.value = binding.minutes ?: 0 + binding.hourPicker.value = binding.hours ?: 0 + binding.dayPicker.value = binding.days ?: 0 } private fun readStatusFromBinding() { @@ -79,7 +86,43 @@ class SelectTimeSpanView(context: Context, attributeSet: AttributeSet): FrameLay timeInMillis = (((days * 24) + hours) * 60 + minutes) * 1000 * 60 } + fun clearNumberPickerFocus() { + binding.minutePicker.clearFocus() + binding.hourPicker.clearFocus() + binding.dayPicker.clearFocus() + } + + fun enablePickerMode(enable: Boolean) { + binding.seekbarContainer.visibility = if (enable) View.GONE else View.VISIBLE + binding.pickerContainer.visibility = if (enable) View.VISIBLE else View.GONE + } + init { + binding.minutePicker.minValue = 0 + binding.minutePicker.maxValue = 59 + + binding.hourPicker.minValue = 0 + binding.hourPicker.maxValue = 23 + + binding.dayPicker.minValue = 0 + binding.dayPicker.maxValue = 1 + binding.dayPickerContainer.visibility = View.GONE + + binding.minutePicker.setOnValueChangedListener { _, _, newValue -> + binding.minutes = newValue + readStatusFromBinding() + } + + binding.hourPicker.setOnValueChangedListener { _, _, newValue -> + binding.hours = newValue + readStatusFromBinding() + } + + binding.dayPicker.setOnValueChangedListener { _, _, newValue -> + binding.days = newValue + readStatusFromBinding() + } + binding.daysSeek.setOnSeekBarChangeListener(object: SeekBar.OnSeekBarChangeListener { override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { binding.days = progress @@ -124,9 +167,15 @@ class SelectTimeSpanView(context: Context, attributeSet: AttributeSet): FrameLay // ignore } }) + + binding.pickerContainer.visibility = GONE + + binding.switchToPickerButton.setOnClickListener { listener?.setEnablePickerMode(true) } + binding.switchToSeekbarButton.setOnClickListener { listener?.setEnablePickerMode(false) } } } interface SelectTimeSpanViewListener { fun onTimeSpanChanged(newTimeInMillis: Long) + fun setEnablePickerMode(enable: Boolean) } diff --git a/app/src/main/java/io/timelimit/android/util/AndroidVersion.kt b/app/src/main/java/io/timelimit/android/util/AndroidVersion.kt new file mode 100644 index 0000000..8abd842 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/util/AndroidVersion.kt @@ -0,0 +1,22 @@ +/* + * TimeLimit Copyright 2019 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.util + +import android.os.Build + +object AndroidVersion { + val qOrLater = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q +} diff --git a/app/src/main/java/io/timelimit/android/util/PhoneNumberUtils.java b/app/src/main/java/io/timelimit/android/util/PhoneNumberUtils.java new file mode 100644 index 0000000..3a524cd --- /dev/null +++ b/app/src/main/java/io/timelimit/android/util/PhoneNumberUtils.java @@ -0,0 +1,115 @@ +// this is a reduced version of https://raw.githubusercontent.com/aosp-mirror/platform_frameworks_base/master/telephony/java/android/telephony/PhoneNumberUtils.java + +/* + * Copyright (C) 2006 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.timelimit.android.util; + +import android.text.TextUtils; +import android.util.SparseIntArray; + +public class PhoneNumberUtils { + private PhoneNumberUtils() {} + + + /** + * Normalize a phone number by removing the characters other than digits. If + * the given number has keypad letters, the letters will be converted to + * digits first. + * + * @param phoneNumber the number to be normalized. + * @return the normalized number. + */ + public static String normalizeNumber(String phoneNumber) { + if (TextUtils.isEmpty(phoneNumber)) { + return ""; + } + + StringBuilder sb = new StringBuilder(); + int len = phoneNumber.length(); + for (int i = 0; i < len; i++) { + char c = phoneNumber.charAt(i); + // Character.digit() supports ASCII and Unicode digits (fullwidth, Arabic-Indic, etc.) + int digit = Character.digit(c, 10); + if (digit != -1) { + sb.append(digit); + } else if (sb.length() == 0 && c == '+') { + sb.append(c); + } else if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) { + return normalizeNumber(PhoneNumberUtils.convertKeypadLettersToDigits(phoneNumber)); + } + } + return sb.toString(); + } + + /** + * Translates any alphabetic letters (i.e. [A-Za-z]) in the + * specified phone number into the equivalent numeric digits, + * according to the phone keypad letter mapping described in + * ITU E.161 and ISO/IEC 9995-8. + * + * @return the input string, with alpha letters converted to numeric + * digits using the phone keypad letter mapping. For example, + * an input of "1-800-GOOG-411" will return "1-800-4664-411". + */ + public static String convertKeypadLettersToDigits(String input) { + if (input == null) { + return input; + } + int len = input.length(); + if (len == 0) { + return input; + } + + char[] out = input.toCharArray(); + + for (int i = 0; i < len; i++) { + char c = out[i]; + // If this char isn't in KEYPAD_MAP at all, just leave it alone. + out[i] = (char) KEYPAD_MAP.get(c, c); + } + + return new String(out); + } + + /** + * The phone keypad letter mapping (see ITU E.161 or ISO/IEC 9995-8.) + */ + private static final SparseIntArray KEYPAD_MAP = new SparseIntArray(); + static { + KEYPAD_MAP.put('a', '2'); KEYPAD_MAP.put('b', '2'); KEYPAD_MAP.put('c', '2'); + KEYPAD_MAP.put('A', '2'); KEYPAD_MAP.put('B', '2'); KEYPAD_MAP.put('C', '2'); + + KEYPAD_MAP.put('d', '3'); KEYPAD_MAP.put('e', '3'); KEYPAD_MAP.put('f', '3'); + KEYPAD_MAP.put('D', '3'); KEYPAD_MAP.put('E', '3'); KEYPAD_MAP.put('F', '3'); + + KEYPAD_MAP.put('g', '4'); KEYPAD_MAP.put('h', '4'); KEYPAD_MAP.put('i', '4'); + KEYPAD_MAP.put('G', '4'); KEYPAD_MAP.put('H', '4'); KEYPAD_MAP.put('I', '4'); + + KEYPAD_MAP.put('j', '5'); KEYPAD_MAP.put('k', '5'); KEYPAD_MAP.put('l', '5'); + KEYPAD_MAP.put('J', '5'); KEYPAD_MAP.put('K', '5'); KEYPAD_MAP.put('L', '5'); + + KEYPAD_MAP.put('m', '6'); KEYPAD_MAP.put('n', '6'); KEYPAD_MAP.put('o', '6'); + KEYPAD_MAP.put('M', '6'); KEYPAD_MAP.put('N', '6'); KEYPAD_MAP.put('O', '6'); + + KEYPAD_MAP.put('p', '7'); KEYPAD_MAP.put('q', '7'); KEYPAD_MAP.put('r', '7'); KEYPAD_MAP.put('s', '7'); + KEYPAD_MAP.put('P', '7'); KEYPAD_MAP.put('Q', '7'); KEYPAD_MAP.put('R', '7'); KEYPAD_MAP.put('S', '7'); + + KEYPAD_MAP.put('t', '8'); KEYPAD_MAP.put('u', '8'); KEYPAD_MAP.put('v', '8'); + KEYPAD_MAP.put('T', '8'); KEYPAD_MAP.put('U', '8'); KEYPAD_MAP.put('V', '8'); + + KEYPAD_MAP.put('w', '9'); KEYPAD_MAP.put('x', '9'); KEYPAD_MAP.put('y', '9'); KEYPAD_MAP.put('z', '9'); + KEYPAD_MAP.put('W', '9'); KEYPAD_MAP.put('X', '9'); KEYPAD_MAP.put('Y', '9'); KEYPAD_MAP.put('Z', '9'); + }} diff --git a/app/src/main/java/io/timelimit/android/util/TimeTextUtil.kt b/app/src/main/java/io/timelimit/android/util/TimeTextUtil.kt index 25d75d5..6cb8f57 100644 --- a/app/src/main/java/io/timelimit/android/util/TimeTextUtil.kt +++ b/app/src/main/java/io/timelimit/android/util/TimeTextUtil.kt @@ -29,6 +29,10 @@ object TimeTextUtil { return context.resources.getQuantityString(R.plurals.util_time_minutes, minutes, minutes) } + fun seconds(seconds: Int, context: Context): String { + return context.resources.getQuantityString(R.plurals.util_time_seconds, seconds, seconds) + } + fun days(days: Int, context: Context): String { return context.resources.getQuantityString(R.plurals.util_time_days, days, days) } diff --git a/app/src/main/play/de-DE/listing/fulldescription b/app/src/main/play/de-DE/listing/fulldescription index 4e74d2d..cc6d3b3 100644 --- a/app/src/main/play/de-DE/listing/fulldescription +++ b/app/src/main/play/de-DE/listing/fulldescription @@ -1,11 +1,27 @@ -Mit Open TimeLimit kann die Nutzungsdauer flexibel beschränkt werden. Es gibt frei wählbare Sperrzeiten, um z.B. zu verhindern, dass zu spät noch gespielt wird. Dabei können je nach App andere Regeln festgelegt werden. +Flexibel -Es kann eine Extrazeit festgelegt werden, z.B. als Belohnung. Diese kann frei eingeteilt werden und wird automatisch verbraucht, sobald die reguläre Zeit vorbei ist. Es ist zusätzlich möglich, die Nutzung der Extrazeit einzuschränken. +Apps werden in Kategorien zusammengefasst, wobei eine Kategorie auch nur eine App enthalten kann. -Die Zeitbegrenzungen können vorübergehend deaktiviert werden. +Für jede Kategorie kann dann gewählt werden, an welchen Uhrzeiten diese Apps erlaubt sein sollen. Dadurch kann verhindert werden, dass zu spät noch gespielt wird. -Je nach Android-Version verwendet TimeLimit die Berechtigung zum Nutzungsdatenzugriff oder die "GET_TASKS"-Berechtigung. Diese werden ausschließlich zur Erkennung der momentan genutzten App verwendet. Auf der Basis der momentan genutzten App erfolgt eine Sperrung, eine Freigabe oder eine Berechnung der verbleibenden Nutzungsdauer. +Zusätzlich können Zeitbegrenzungsregeln eingestellt werden. Damit kann die Gesamtnutzungsdauer an einem Tag oder über mehrere Tage hinweg (z.B. das Wochenende) begrenzt werden. Das lässt sich sogar kombinieren, sodass z.B. am Wochenende höchstens 3 Stunden gespielt werden darf, aber an jedem Wochenendtag jeweils höchstens 2 Stunden. + +Außerdem gibt es die Möglichkeit, eine Extrazeit zu erteilen. Das ermöglicht eine einmalige längere Nutzung als sonst. Das kann z.B. als Belohnung verwendet werden. Es gibt auch die Möglichkeit, die Zeitbegrenzungen vorübergehend (z.B. für eine Stunde oder den Rest eines Tages) abzuschalten. + +Mehrbenutzerfähig + +Es gibt den Fall, dass ein Gerät nur von einem Benutzer verwendet wird. Insbesondere bei Tablets ist das aber oft nicht der Fall. Daher könne in TimeLimit mehrere Benutzerprofile erstellt werden, für die es jeweils andere Einstellungen und Zeitkonten gibt. Dabei gibt es zwei Arten von Benutzern: Kinder und Elternteile. Wenn ein Elternteil als Benutzer eingestellt wurde, gibt es keine Einschränkungen. Eltern können jeden beliebigen Benutzer als aktuellen Benutzer einstellen. + +Hinweise + +Falls die App "nicht funktioniert" kann das an Energiesparfunktionen liegen. Wie Sie diese abschalten können, wird unter https://dontkillmyapp.com/ beschrieben. Wenden Sie sich an den Support, wenn das nicht hilft. + +Je nach Android-Version verwendet TimeLimit die Berechtigung zum Nutzungsdatenzugriff oder die "GET_TASKS"-Berechtigung. Diese werden ausschließlich zur Erkennung der momentan genutzten App verwendet. Auf der Basis der momentan genutzten App erfolgt eine Sperrung, eine Freigabe oder eine Berechnung und Anpassung der verbleibenden Nutzungsdauer. Die Geräte-Administrator-Berechtigung wird genutzt, um ein Deinstallieren von TimeLimit zu erkennen. -TimeLimit verwendet den Benachrichtigungszugriff, um auch die Benachrichtigungen von gesperrten Apps zu sperren. Es erfolgt keine Speicherung von Benachrichtigungen oder deren Inhalten. +TimeLimit verwendet den Benachrichtigungszugriff, um auch die Benachrichtigungen von gesperrten Apps zu sperren und um Medienplayer vollständig zu beenden. Es erfolgt keine Speicherung von Benachrichtigungen oder deren Inhalten. + +TimeLimit verwendet die Bedienhilfe-Berechtigung, um den Home-Button vor dem Aufruf des Sperrbildschirms zu drücken, um das Sperren in einigen Fällen zu verbessern. Außerdem ist das eine Möglichkeit, um es TimeLimit unter neueren Android-Versionen zu ermöglichen, den Sperrbildschirm zu öffnen. + +TimeLimit verwendet die Berechtigung "Über anderen Apps anzeigen", um bei neueren Android-Versionen den Sperrbildschirm aufrufen zu können und um gesperrte Apps zu überlagern, bis der Sperrbildschirm gestartet wurde. diff --git a/app/src/main/play/de-DE/whatsnew b/app/src/main/play/de-DE/whatsnew index 49bc4ab..d05f9a1 100644 --- a/app/src/main/play/de-DE/whatsnew +++ b/app/src/main/play/de-DE/whatsnew @@ -1,2 +1,3 @@ -- Abschaltung vom Passwortschutz möglich -- keine Passwortabfrage, wenn nur ein Elternteil ohne Passwort existiert +- Zeitwarnungen +- Kontakt-Whitelist +- Sperren auf Activity-Ebene diff --git a/app/src/main/play/en-US/listing/fulldescription b/app/src/main/play/en-US/listing/fulldescription index 99b9933..7bd1ac1 100644 --- a/app/src/main/play/en-US/listing/fulldescription +++ b/app/src/main/play/en-US/listing/fulldescription @@ -1,11 +1,27 @@ -With Open TimeLimit, you can limit the usage duration flexibly. You can chose different settings for different Apps. +Flexible -You can add extra time, e.g. as reward. This can be set freely and is spent automatically when the regular limit was reached. Additionally, it's possible to limit the usage of extra time. +Apps are grouped to categories (a category can contain one or multiple App). -You can disable the time limits temporarily. +You can chose per category at which times it should be allowed. This allows preventing playing games too late. + +Additionally, you can configure time limit rules. These rules limit the total usage duration at one day or over multiple days (e.g. a weekend). It is possible to combine both, e.g. 2 hours per week end day, but in total only 3 hours. + +Moreover, there is the possibility to set an extra time. This allows using something longer than regulary once. This can be used as bonus. There is additionally the option to disable all time limits temporarily (e.g. for the whole day or an hour). + +Multi user support + +There is the scenario that one device is used by exactly one user. However, with tablets, there are often multiple possible users. Due to that, it is possible to create multiple user profiles in TimeLimit. Each user has got different settings and time counters. There are two kinds of users: parents and children. If a parent was chosen as user, then there are no restrictions. Parents can chose any other user as current user. + +Notes + +If it "does not work": This can be caused by power saving features. You can find at https://dontkillmyapp.com/ how you can disable these features. Get in touch with the support if that does not help. Depending on the Android version, TimeLimit uses the permission for the usage stats access or the GET_TASKS permission. These are only used to detect the currently used App. Based on the currently used App, the App is blocked, allowed, or the remaining time is calculated. The device admin permission is used to detect an uninstallation of TimeLimit. -TimeLimit uses the notification access to block notifications of blocked apps. Notifications and their contents are not saved. +TimeLimit uses the notification access to block notifications of blocked apps and to terminate media players completely. Notifications and their contents are not saved. + +TimeLimit uses an accessibility service to press the home button before showing the lock screen. This fixes blocking in some cases. Moreover, this allows opening the lockscreen at newer Android versions. + +TimeLimit uses the permission "draw over other Apps" to allow opening the lockscreen at newer android versions and to overlay blocked Apps until the lockscreen is launched. diff --git a/app/src/main/play/en-US/listing/icon/icon.png b/app/src/main/play/en-US/listing/icon/icon.png index 7b48bbe3e4371a98358dcd08327ed7c3afc6caaa..17ab6c94b9a3fdc9a71c65ffe480aa374518f27a 100644 GIT binary patch literal 17097 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 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..57cfcc0 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_perm_contact_calendar_black_24dp.xml b/app/src/main/res/drawable/ic_perm_contact_calendar_black_24dp.xml new file mode 100644 index 0000000..0d6b3bb --- /dev/null +++ b/app/src/main/res/drawable/ic_perm_contact_calendar_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/ic_unfold_more_black_24dp.xml b/app/src/main/res/drawable/ic_unfold_more_black_24dp.xml new file mode 100644 index 0000000..e9ba754 --- /dev/null +++ b/app/src/main/res/drawable/ic_unfold_more_black_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/blocking_overlay.xml b/app/src/main/res/layout/blocking_overlay.xml new file mode 100644 index 0000000..0bb1108 --- /dev/null +++ b/app/src/main/res/layout/blocking_overlay.xml @@ -0,0 +1,64 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/category_notification_filter.xml b/app/src/main/res/layout/category_notification_filter.xml new file mode 100644 index 0000000..c0671ba --- /dev/null +++ b/app/src/main/res/layout/category_notification_filter.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/category_time_warnings_view.xml b/app/src/main/res/layout/category_time_warnings_view.xml new file mode 100644 index 0000000..eb9bc97 --- /dev/null +++ b/app/src/main/res/layout/category_time_warnings_view.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/contacts_fragment.xml b/app/src/main/res/layout/contacts_fragment.xml new file mode 100644 index 0000000..1954583 --- /dev/null +++ b/app/src/main/res/layout/contacts_fragment.xml @@ -0,0 +1,24 @@ + + + + + + diff --git a/app/src/main/res/layout/contacts_intro.xml b/app/src/main/res/layout/contacts_intro.xml new file mode 100644 index 0000000..c34a92a --- /dev/null +++ b/app/src/main/res/layout/contacts_intro.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/contacts_item.xml b/app/src/main/res/layout/contacts_item.xml new file mode 100644 index 0000000..be8d1af --- /dev/null +++ b/app/src/main/res/layout/contacts_item.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/diagnose_foreground_app_fragment.xml b/app/src/main/res/layout/diagnose_foreground_app_fragment.xml new file mode 100644 index 0000000..c8957f2 --- /dev/null +++ b/app/src/main/res/layout/diagnose_foreground_app_fragment.xml @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_about.xml b/app/src/main/res/layout/fragment_about.xml index 1aa3c5e..1f78f45 100644 --- a/app/src/main/res/layout/fragment_about.xml +++ b/app/src/main/res/layout/fragment_about.xml @@ -47,8 +47,8 @@ android:padding="8dp" android:layout_gravity="center_vertical" android:src="@mipmap/ic_launcher" - android:layout_width="wrap_content" - android:layout_height="wrap_content" /> + android:layout_width="56dp" + android:layout_height="56dp" /> + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_add_category_activities.xml b/app/src/main/res/layout/fragment_add_category_activities.xml new file mode 100644 index 0000000..3508dde --- /dev/null +++ b/app/src/main/res/layout/fragment_add_category_activities.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +