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 7b48bbe..17ab6c9 100644
Binary files a/app/src/main/play/en-US/listing/icon/icon.png and b/app/src/main/play/en-US/listing/icon/icon.png differ
diff --git a/app/src/main/play/en-US/whatsnew b/app/src/main/play/en-US/whatsnew
index 76b00f1..66b0d61 100644
--- a/app/src/main/play/en-US/whatsnew
+++ b/app/src/main/play/en-US/whatsnew
@@ -1,2 +1,3 @@
-- allow disabling the password protection
-- no password prompt if there is only one parent user without password
+- time warnings
+- contact whitelist
+- blocking at activity level
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..2408e30
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_add_category_activities_item.xml b/app/src/main/res/layout/fragment_add_category_activities_item.xml
new file mode 100644
index 0000000..4b533c8
--- /dev/null
+++ b/app/src/main/res/layout/fragment_add_category_activities_item.xml
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_add_category_apps_item.xml b/app/src/main/res/layout/fragment_add_category_apps_item.xml
index d7e73f6..4b4acb2 100644
--- a/app/src/main/res/layout/fragment_add_category_apps_item.xml
+++ b/app/src/main/res/layout/fragment_add_category_apps_item.xml
@@ -40,6 +40,7 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml
index ef65859..8fcfb74 100644
--- a/app/src/main/res/layout/fragment_main.xml
+++ b/app/src/main/res/layout/fragment_main.xml
@@ -26,8 +26,8 @@
android:layout_weight="1"
android:layout_height="0dp">
-
diff --git a/app/src/main/res/layout/fragment_manage_category.xml b/app/src/main/res/layout/fragment_manage_category.xml
index 93bd608..11bb8ec 100644
--- a/app/src/main/res/layout/fragment_manage_category.xml
+++ b/app/src/main/res/layout/fragment_manage_category.xml
@@ -27,8 +27,8 @@
android:layout_weight="1"
android:layout_height="0dp">
-
diff --git a/app/src/main/res/layout/fragment_manage_child.xml b/app/src/main/res/layout/fragment_manage_child.xml
index fabde3f..edd333f 100644
--- a/app/src/main/res/layout/fragment_manage_child.xml
+++ b/app/src/main/res/layout/fragment_manage_child.xml
@@ -27,8 +27,8 @@
android:layout_weight="1"
android:layout_height="0dp">
-
diff --git a/app/src/main/res/layout/fragment_manage_device.xml b/app/src/main/res/layout/fragment_manage_device.xml
index f8262d5..9b77da3 100644
--- a/app/src/main/res/layout/fragment_manage_device.xml
+++ b/app/src/main/res/layout/fragment_manage_device.xml
@@ -1,17 +1,16 @@
-
-
@@ -35,30 +30,28 @@
name="isThisDevice"
type="Boolean" />
-
-
-
+ name="userCardText"
+ type="String" />
+
+ name="permissionCardText"
+ type="String" />
+
+
-
-
-
+
+ layout="@layout/missing_permission_view" />
+
+
-
-
-
-
-
-
-
-
-
-
-
-
@@ -198,49 +157,13 @@
-
-
-
-
-
-
-
-
-
-
@@ -248,9 +171,8 @@
@@ -262,51 +184,13 @@
-
-
-
-
-
-
-
-
-
-
@@ -314,9 +198,8 @@
@@ -328,62 +211,45 @@
-
-
-
-
-
-
-
-
-
-
-
+
+
-
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/fragment_setup_device_permissions.xml b/app/src/main/res/layout/fragment_setup_device_permissions.xml
index 2d01996..d43e99d 100644
--- a/app/src/main/res/layout/fragment_setup_device_permissions.xml
+++ b/app/src/main/res/layout/fragment_setup_device_permissions.xml
@@ -31,6 +31,14 @@
name="protectionLevel"
type="io.timelimit.android.integration.platform.ProtectionLevel" />
+
+
+
+
@@ -230,6 +238,115 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -44,6 +48,10 @@
name="areMultipleCategoriesBlocked"
type="Boolean" />
+
+
@@ -113,6 +121,14 @@
android:layout_width="match_parent"
android:layout_height="wrap_content" />
+
+
diff --git a/app/src/main/res/layout/manage_device_activity_level_blocking.xml b/app/src/main/res/layout/manage_device_activity_level_blocking.xml
new file mode 100644
index 0000000..1011792
--- /dev/null
+++ b/app/src/main/res/layout/manage_device_activity_level_blocking.xml
@@ -0,0 +1,48 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/manage_device_advanced_fragment.xml b/app/src/main/res/layout/manage_device_advanced_fragment.xml
new file mode 100644
index 0000000..b521690
--- /dev/null
+++ b/app/src/main/res/layout/manage_device_advanced_fragment.xml
@@ -0,0 +1,61 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/manage_device_default_user.xml b/app/src/main/res/layout/manage_device_default_user.xml
new file mode 100644
index 0000000..57bff15
--- /dev/null
+++ b/app/src/main/res/layout/manage_device_default_user.xml
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/manage_device_features_fragment.xml b/app/src/main/res/layout/manage_device_features_fragment.xml
new file mode 100644
index 0000000..0caf763
--- /dev/null
+++ b/app/src/main/res/layout/manage_device_features_fragment.xml
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/manage_device_manipulation_view.xml b/app/src/main/res/layout/manage_device_manipulation_view.xml
index a1830c9..97b1a89 100644
--- a/app/src/main/res/layout/manage_device_manipulation_view.xml
+++ b/app/src/main/res/layout/manage_device_manipulation_view.xml
@@ -34,6 +34,14 @@
name="hasManipulatedNotificationAccess"
type="Boolean" />
+
+
+
+
@@ -108,6 +116,19 @@
android:layout_width="match_parent"
android:layout_height="wrap_content" />
+
+
+
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 .
+-->
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/manage_device_user_fragment.xml b/app/src/main/res/layout/manage_device_user_fragment.xml
new file mode 100644
index 0000000..bc11a7d
--- /dev/null
+++ b/app/src/main/res/layout/manage_device_user_fragment.xml
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/manage_device_view.xml b/app/src/main/res/layout/manage_device_view.xml
new file mode 100644
index 0000000..230ec7a
--- /dev/null
+++ b/app/src/main/res/layout/manage_device_view.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/usage_stats_permission_required_and_missing.xml b/app/src/main/res/layout/missing_permission_view.xml
similarity index 87%
rename from app/src/main/res/layout/usage_stats_permission_required_and_missing.xml
rename to app/src/main/res/layout/missing_permission_view.xml
index 839e1ea..afd9619 100644
--- a/app/src/main/res/layout/usage_stats_permission_required_and_missing.xml
+++ b/app/src/main/res/layout/missing_permission_view.xml
@@ -15,12 +15,17 @@
along with this program. If not, see .
-->
+ xmlns:app="http://schemas.android.com/apk/res-auto"
+ xmlns:tools="http://schemas.android.com/tools">
+
+
@@ -41,7 +46,8 @@
android:drawablePadding="8dp"
android:drawableStart="@drawable/ic_warning_white_24dp"
android:textAppearance="?android:textAppearanceLarge"
- android:text="@string/usage_stats_permission_required_and_missing_title"
+ tools:text="@string/usage_stats_permission_required_and_missing_title"
+ android:text="@{title}"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
diff --git a/app/src/main/res/layout/view_select_time_span.xml b/app/src/main/res/layout/view_select_time_span.xml
index 6fb4cf2..1c9f725 100644
--- a/app/src/main/res/layout/view_select_time_span.xml
+++ b/app/src/main/res/layout/view_select_time_span.xml
@@ -1,18 +1,17 @@
@@ -54,46 +53,148 @@
android:layout_width="match_parent"
android:layout_height="wrap_content">
-
+ android:layout_height="wrap_content">
-
+
-
+
-
+
-
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+ android:layout_height="wrap_content">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/menu/fragment_main_bottom.xml b/app/src/main/res/menu/fragment_main_bottom.xml
index 2b5a3f6..4732542 100644
--- a/app/src/main/res/menu/fragment_main_bottom.xml
+++ b/app/src/main/res/menu/fragment_main_bottom.xml
@@ -20,6 +20,11 @@
android:title="@string/main_tab_overview"
android:id="@+id/main_tab_overview" />
+
+
-
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
new file mode 100644
index 0000000..7353dbd
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png
index f96d9ab..da67c45 100644
Binary files a/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
new file mode 100644
index 0000000..da67c45
Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png
index bd50c7f..e225c2e 100644
Binary files a/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
new file mode 100644
index 0000000..e225c2e
Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png
index 3e03dba..a0424cd 100644
Binary files a/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..a0424cd
Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
index 6dfb180..101b186 100644
Binary files a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..101b186
Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
index 3f3891e..d048fdf 100644
Binary files a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
new file mode 100644
index 0000000..d048fdf
Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ
diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml
index ba8654e..8755219 100644
--- a/app/src/main/res/navigation/nav_graph.xml
+++ b/app/src/main/res/navigation/nav_graph.xml
@@ -139,6 +139,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/values-de/strings-about.xml b/app/src/main/res/values-de/strings-about.xml
index db1efc5..4f7e589 100644
--- a/app/src/main/res/values-de/strings-about.xml
+++ b/app/src/main/res/values-de/strings-about.xml
@@ -17,7 +17,7 @@
Version %s
- Source code
+ Quelltext
https://gitlab.com/timelimit.io/opentimelimit-android
@@ -26,4 +26,6 @@
Fehlerdiagnose
Tippen Sie hier, um Details anzuzeigen, die bei einer Fehlerdiagnose helfen können
+
+ Fingerabdruck des GPG-Schlüssels des Entwicklers
diff --git a/app/src/main/res/values-de/strings-background-logic.xml b/app/src/main/res/values-de/strings-background-logic.xml
index c2d8a81..342914e 100644
--- a/app/src/main/res/values-de/strings-background-logic.xml
+++ b/app/src/main/res/values-de/strings-background-logic.xml
@@ -29,4 +29,10 @@
Apps wurden vorübergehend erlaubt
Zum Rückgängig machen hier tippen oder Bildschirm ausschalten
+
+ automatische Abmeldung aktiviert
+ TimeLimit läuft noch, um das zu ermöglichen
+
+ Vorübergehend deaktiviert
+ Keine Einschränkungen
diff --git a/app/src/main/res/values-de/strings-category-apps.xml b/app/src/main/res/values-de/strings-category-apps.xml
index d956522..025c151 100644
--- a/app/src/main/res/values-de/strings-category-apps.xml
+++ b/app/src/main/res/values-de/strings-category-apps.xml
@@ -26,6 +26,19 @@
- %d ausgeblendeten Eintrag ausgewählt
- %s ausgeblendete Einträge ausgewählt
+
+ Sie können nur Activities hinzufügen, wenn Sie noch keine Apps zum Hinufügen ausgewählt haben
+
+ Activities suchen
+ Activities hinzufügen
+
+ Zu diesem Suchbegriff wurden keine Activities gefunden
+
+
+ Es wurden keine Activities gefunden.
+ Stellen Sie sicher, dass das Sperren auf Activity-Ebene aktiviert wurde.
+
+
%s wurde entfernt
diff --git a/app/src/main/res/values-de/strings-category-notification-filter.xml b/app/src/main/res/values-de/strings-category-notification-filter.xml
new file mode 100644
index 0000000..edfff17
--- /dev/null
+++ b/app/src/main/res/values-de/strings-category-notification-filter.xml
@@ -0,0 +1,26 @@
+
+
+
+ Benachrichtigungsfilter
+
+ Wenn der Benachrichtigungszugriff in den Geräte-Einträgen aktiviert wurde,
+ dann werden Benachrichtigungen bei Sperrzeiten und wenn die Zeit abgelaufen ist blockiert.
+ Hiermit können die Benachrichtigungen von Apps einer Kategorie immer blockiert werden.
+ Diese Funktion setzt voraus, dass der Benachrichtigungszugriff aktiviert wurde. Ansonsten funktioniert
+ diese Funktion nicht.
+
+ Alle Benachrichtigungen blockieren
+
\ No newline at end of file
diff --git a/app/src/main/res/values-de/strings-category-time-warnings.xml b/app/src/main/res/values-de/strings-category-time-warnings.xml
new file mode 100644
index 0000000..2e5841a
--- /dev/null
+++ b/app/src/main/res/values-de/strings-category-time-warnings.xml
@@ -0,0 +1,21 @@
+
+
+
+ Zeitwarnung
+ Wenn weniger Zeit als unten ausgewählt übrig ist, dann wird eine Benachrichtigung angezeigt
+
+ Zeitwarnung für %s
+
\ No newline at end of file
diff --git a/app/src/main/res/values-de/strings-contacts.xml b/app/src/main/res/values-de/strings-contacts.xml
new file mode 100644
index 0000000..edb9639
--- /dev/null
+++ b/app/src/main/res/values-de/strings-contacts.xml
@@ -0,0 +1,32 @@
+
+
+
+ Kontakte
+ Kontakt wurde hinzugefügt
+ Erlaubte Kontakte
+ Das ist eine Liste von Telefonnummern.
+ Eltern können Einträge von der Systemkontaktliste hinzufügen.
+ Jeder Nutzer diese Gerätes kann über diese Ansicht einen Anruf zu diesen Nummern starten,
+ auch wenn die Telefon-App gesperrt ist.
+ Die Telefonnummern werden nur auf diesem Gerät gespeichert.
+
+ Se können diesen Hinweis entfernen, indem Sie ihn zur Seite wischen
+ Kontakt hinzufügen
+ Sie müssen sich anmelden um Kontakte zu entfernen
+ %s wurde entfernt
+ Anruf fehlgeschlagen
+ Anruf gestartet; Kann per Benachrichtigungsbereich beendet werden
+
\ No newline at end of file
diff --git a/app/src/main/res/values-de/strings-diagnose.xml b/app/src/main/res/values-de/strings-diagnose.xml
index 73c6afe..206a25b 100644
--- a/app/src/main/res/values-de/strings-diagnose.xml
+++ b/app/src/main/res/values-de/strings-diagnose.xml
@@ -24,4 +24,8 @@
Wochentag (0 = Montag, ...): %d
Epochaltag: %d
Minute der Woche: %d
+
+ Erkennung der aktiven App
+ Abfragezeitraum
+ Minimal (Standard)
diff --git a/app/src/main/res/values-de/strings-lock.xml b/app/src/main/res/values-de/strings-lock.xml
index 82e0046..6e4dfb7 100644
--- a/app/src/main/res/values-de/strings-lock.xml
+++ b/app/src/main/res/values-de/strings-lock.xml
@@ -43,16 +43,16 @@
Die App wurde zu keiner Kategorie zugeordnet. Das bedeutet, dass es keine Einschränkungs-Einstellungen gibt. Und da ist es die einfachste Lösung, die App zu sperren.
- Diese App ist in einer Kategorie, die vorübergehend gesperrt wurde.
+ Diese %s ist in einer Kategorie, die vorübergehend gesperrt wurde.
- Diese App ist in einer Kategorie, für die festgelegt wurde, dass Sie zu dieser Uhrzeit gesperrt ist.
+ Diese %s ist in einer Kategorie, für die festgelegt wurde, dass Sie zu dieser Uhrzeit gesperrt ist.
- Diese App ist in einer Kategorie, deren maximale Nutzungszeit erreicht wurde.
+ Diese %s ist in einer Kategorie, deren maximale Nutzungszeit erreicht wurde.
- Diese App ist in einer Kategorie, deren maximale Nutzungszeit erreicht wurde.
+ Diese %s ist in einer Kategorie, deren maximale Nutzungszeit erreicht wurde.
Es gab noch Extra-Zeit, aber die maximale Nutzung der Extra-Zeit wurde erreicht.
@@ -60,4 +60,10 @@
vorübergehend gesperrt
Zeit verbraucht
Sperrzeit
+ alle Benachrichtigungen werden blockiert
+
+
+ Öffnen des Sperrbildschirms fehlgeschlagen.
+ Versuche es, den Home- oder Anwendungslisten-Button zu verwenden, um diesen Bildschirm zu verlassen.
+
diff --git a/app/src/main/res/values-de/strings-manage-device-activity-level-blocking.xml b/app/src/main/res/values-de/strings-manage-device-activity-level-blocking.xml
new file mode 100644
index 0000000..234afa4
--- /dev/null
+++ b/app/src/main/res/values-de/strings-manage-device-activity-level-blocking.xml
@@ -0,0 +1,23 @@
+
+
+
+ Sperren auf Activity-Ebene
+
+ Das ermöglicht es (abhänging von den Apps), einige Funktionen in Apps zu sperren.
+ Wenn Sie nicht verstehen, was Activities unter Android sind, dann aktivieren Sie diese Funktion nicht.
+
+ Sperren auf Activity-Ebene aktivieren
+
\ No newline at end of file
diff --git a/app/src/main/res/values-de/strings-manage-device-default-user.xml b/app/src/main/res/values-de/strings-manage-device-default-user.xml
new file mode 100644
index 0000000..cebdac0
--- /dev/null
+++ b/app/src/main/res/values-de/strings-manage-device-default-user.xml
@@ -0,0 +1,39 @@
+
+
+
+ Standardbenutzer
+ Der Standardbenutzer ist ein Benutzer,
+ zu dem jeder an dem Gerät ohne Eingabe eines Passworts wechseln kann (um sich abzumelden).
+ Das ist sinnvoll bei Geräten, die von verschiedenen Benutzern genutzt werden können.
+
+ Der Standardbenutzer ist %s
+ Zum Standardbenutzer wechseln
+ Sie können nur am Gerät selbst zum Standardbenutzer wechseln
+ Sie können nicht zum Standardbenutzer wechseln,
+ solange es keinen Standardbenutzer gibt
+ Sie können
+ nicht zum Standardbenutzer wechseln, wenn der Standardbenutzer bereits der aktuelle Benutzer ist
+
+ Standardbenutzer wählen
+ Standardbenutzer ändern
+ kein Standardbenutzer
+ Dieses Gerät wechselt nicht automatisch zum Standardbenutzer
+ Dieses Gerät wechselt automatisch zum Standardbenutzer, wenn es %s nicht benutzt wurde
+ automatischen Wechsel zum Standardbenutzer aktivieren
+ automatischen Wechsel zum Standardbenutzer konfigurieren
+ automatisch zum Standardbenutzer wechseln
+ nie
+
\ No newline at end of file
diff --git a/app/src/main/res/values-de/strings-manage-device-manipulation.xml b/app/src/main/res/values-de/strings-manage-device-manipulation.xml
index 55227a1..77c1ee9 100644
--- a/app/src/main/res/values-de/strings-manage-device-manipulation.xml
+++ b/app/src/main/res/values-de/strings-manage-device-manipulation.xml
@@ -23,6 +23,8 @@
Geräteadministrator-Deaktivierung wurde versucht
Nutzungsdatenzugriff
Benachrichtigungszugriff
+ Berechtigung zum Anzeigen über anderen Apps
+ Bedienhilfe-Berechtigung
ältere App-Version installiert
Gerät wurde neu gestartet
Es gab eine Manipulation, die wieder beendet wurde
diff --git a/app/src/main/res/values-de/strings-manage-device.xml b/app/src/main/res/values-de/strings-manage-device.xml
index aa523b8..dbfab53 100644
--- a/app/src/main/res/values-de/strings-manage-device.xml
+++ b/app/src/main/res/values-de/strings-manage-device.xml
@@ -15,13 +15,24 @@
along with this program. If not, see .
-->
+ Benutzer
+ Berechtigungen
+ Funktionen
+ Verwaltung
+ sonstige Einstellungen für dieses Gerät
+
Hinzugefügt: %s
Das ist das Gerät, das Sie gerade vor sich haben
Benutzer des Geräts
Keine Angabe - keine Begrenzungen
+ Keine Funktionen aktiviert
+
+ Keine Berechtigungen gewährt
+
Berechtigung zum Nutzungsdatenzugriff
+ Nutzungsdatenzugriff
Der Nutzungsdatenzugriff ermöglicht es, die Nutzung des Gerätes zu erfassen.
Dies wird genutzt, um zu ermitteln, welche App genutzt wird, damit die Zeit gezählt werden kann oder die App gesperrt werden kann.
@@ -31,6 +42,23 @@
Der Benachrichtigungszugriff wird von TimeLimit genutzt, um nicht nur Apps, sondern auch
deren Benachrichtigungen zu sperren.
+ Zusätzlich wird dadurch die Wiedergaben von Medienplayern beendet, wenn die entsprechenden
+ Medienplayer gesperrt werden.
+
+
+ Über anderen Apps anzeigen
+
+ Das Anzeigen über anderen Apps kann das Sperren in einigen Fällen verbessern.
+ Außerdem ist diese Berechtigung eine Möglichkeit, um TimeLimit bei neueren Android-Versionen zu erlauben,
+ den Sperrbildschirm zu öffnen.
+
+
+ Bedienhilfe
+
+ Damit \"drückt\" TimeLimit den Home-Button, bevor der Sperrbildschirm aufgerufen wird.
+ Das kann das Sperren in einigen Fällen verbessern.
+ Außerdem ist diese Berechtigung eine Möglichkeit, um TimeLimit bei neueren Android-Versionen zu erlauben,
+ den Sperrbildschirm zu öffnen.
Geräte-Administrator
diff --git a/app/src/main/res/values-de/strings-must-read.xml b/app/src/main/res/values-de/strings-must-read.xml
new file mode 100644
index 0000000..39fbf4b
--- /dev/null
+++ b/app/src/main/res/values-de/strings-must-read.xml
@@ -0,0 +1,32 @@
+
+
+
+
+ Da es immer wieder Beschwerden gab:
+ \n1. In den Standardeinstellungen kann TimeLimit ganz leicht,
+ auch von einem Kind, entfernt werden. Wenn das ein Problem ist wenden Sie sich an den Support.
+ \n2. Wenn \"es nicht funktioniert\" liegt es oft an Energiesparfunktionen die deaktiviert werden müssen.
+ Diese sind oft automatisch aktiviert, ohne das es Ihnen als Nutzer bewusst ist.
+
+
+ Da es häufiger falsch bedient wird:
+ Eine Regel begrenzt die Gesamtnutzungsdauer der Kategorie in einer Woche an den gewählten Tagen.
+ Das bedeutet, dass wenn eine Regel für Montag bis Freitag mit 3 Stunden eingestellt wird,
+ die Kategorie in jeder Woche von Montag bis Freitag insgesamt nur 3 Stunden benutzt werden kann.
+ Wenn je eine Regel für die Tage von Montag bis Freitag mit jeweils 3 Stunden gewählt wird,
+ kann die Kategorie an jedem dieser Tage für 3 Stunden genutzt werden.
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values-de/strings-notification-channels.xml b/app/src/main/res/values-de/strings-notification-channels.xml
index c075e2c..d9f63ef 100644
--- a/app/src/main/res/values-de/strings-notification-channels.xml
+++ b/app/src/main/res/values-de/strings-notification-channels.xml
@@ -20,4 +20,7 @@
blockierte Benachrichtigungen
Zeigt an, wann eine Benachrichtigung einer anderen App von TimeLimit blockiert wurde
+
+ Zeitwarnungen
+ Benachrichtigungen für Kinder, wenn nur noch wenig Zeit übrig ist
diff --git a/app/src/main/res/values-de/strings-select-time-span-view.xml b/app/src/main/res/values-de/strings-select-time-span-view.xml
new file mode 100644
index 0000000..fed0437
--- /dev/null
+++ b/app/src/main/res/values-de/strings-select-time-span-view.xml
@@ -0,0 +1,20 @@
+
+
+
+ Minuten
+ Stunden
+ Tage
+
\ No newline at end of file
diff --git a/app/src/main/res/values-de/strings-setup-device-permissions.xml b/app/src/main/res/values-de/strings-setup-device-permissions.xml
index 059be78..da11e98 100644
--- a/app/src/main/res/values-de/strings-setup-device-permissions.xml
+++ b/app/src/main/res/values-de/strings-setup-device-permissions.xml
@@ -18,6 +18,8 @@
TimeLimit benötigt einige Berechtigungen, um funktionieren zu können.
Wirklich erforderlich ist nur die 1. Berechtigung.
+ Ab Android Q wird zum Öffnen des Sperrbildschirms die Berechtigung
+ \"Über anderen Apps anzeigen\" oder \"Bedienhilfe\" benötigt.
Die weiteren Berechtigungen ermöglichen Zusatzfunktionen.
diff --git a/app/src/main/res/values-de/strings-usage-stats-permission-required-and-missing.xml b/app/src/main/res/values-de/strings-usage-stats-permission-required-and-missing.xml
index d475c2d..0fc8218 100644
--- a/app/src/main/res/values-de/strings-usage-stats-permission-required-and-missing.xml
+++ b/app/src/main/res/values-de/strings-usage-stats-permission-required-and-missing.xml
@@ -18,6 +18,8 @@
Nutzungsdatenzugriff wird benötigt
TimeLimit benötigt den Nutzungsdatenzugriff, um korrekt zu funktionieren.
- Sie finden die Möglichkeit, um den Nutzungsdatenzugriff zu aktivieren, weiter unten.
+ Wählen Sie die Option \"Berechtigungen\", um den Nutzungsdatenzugriff zu aktivieren.
+
+ Über anderen Apps anzeigen oder Bedienhilfe wird benötigt
diff --git a/app/src/main/res/values-de/strings-util-time.xml b/app/src/main/res/values-de/strings-util-time.xml
index 14aa94e..9ec65d1 100644
--- a/app/src/main/res/values-de/strings-util-time.xml
+++ b/app/src/main/res/values-de/strings-util-time.xml
@@ -15,6 +15,12 @@
along with this program. If not, see .
-->
+
+ - %d Sekunde
+ - %d Sekunden
+
+
+
- %d Minute
- %d Minuten
diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000..0e106bb
--- /dev/null
+++ b/app/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #673AB7
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings-about.xml b/app/src/main/res/values/strings-about.xml
index 4709cdd..674e73a 100644
--- a/app/src/main/res/values/strings-about.xml
+++ b/app/src/main/res/values/strings-about.xml
@@ -52,4 +52,7 @@
Error diagnose
Tap here to access details which help diagnosing errors
+
+ Developer GPG-Key fingerprint
+ 2E5C672DE893055D04F5B7BC36B449FB5364BDC4
diff --git a/app/src/main/res/values/strings-background-logic.xml b/app/src/main/res/values/strings-background-logic.xml
index f7756c5..a148588 100644
--- a/app/src/main/res/values/strings-background-logic.xml
+++ b/app/src/main/res/values/strings-background-logic.xml
@@ -29,4 +29,10 @@
Apps are temporarily allowed
Tap here or turn screen off to undo
+
+ auto logout enabled
+ TimeLimit still runs to do that
+
+ Temporarily disabled
+ No limitations apply
diff --git a/app/src/main/res/values/strings-category-apps.xml b/app/src/main/res/values/strings-category-apps.xml
index 73fb5dc..46b575d 100644
--- a/app/src/main/res/values/strings-category-apps.xml
+++ b/app/src/main/res/values/strings-category-apps.xml
@@ -27,6 +27,19 @@
- %s hidden entries selected
+
+ You can only add Activities if you did not select any Apps for assigning.
+
+ Search Activities
+ Add Activities
+
+ There are no Activities which match this search term
+
+
+ There are no known activities.
+ Please check if blocking at activity level was enabled.
+
+
%s was removed
Already assigned to %s
diff --git a/app/src/main/res/values/strings-category-notification-filter.xml b/app/src/main/res/values/strings-category-notification-filter.xml
new file mode 100644
index 0000000..91450b8
--- /dev/null
+++ b/app/src/main/res/values/strings-category-notification-filter.xml
@@ -0,0 +1,26 @@
+
+
+
+ Notification filter
+
+ When enabling the notification access in the device entries,
+ then notifications are blocked at blocked time areas and when the time is over.
+ Here you can enable blocking all notifications from Apps of a category.
+ This feature requires that the notification access was enabled. Otherwise,
+ it does not work.
+
+ Block all notifications
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings-category-time-warnings.xml b/app/src/main/res/values/strings-category-time-warnings.xml
new file mode 100644
index 0000000..2636b68
--- /dev/null
+++ b/app/src/main/res/values/strings-category-time-warnings.xml
@@ -0,0 +1,21 @@
+
+
+
+ Time warnings
+ If there is less time remaining than checked below, then a notification will be shown
+
+ Time warning for %s
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings-contacts.xml b/app/src/main/res/values/strings-contacts.xml
new file mode 100644
index 0000000..6a29194
--- /dev/null
+++ b/app/src/main/res/values/strings-contacts.xml
@@ -0,0 +1,32 @@
+
+
+
+ Contacts
+ Contact was added
+ Allowed contacts
+ This is a list of phone numbers.
+ Parents can add items from the contact list of the device.
+ Every user of this device can start a call to one of these numbers using this screen.
+ This works if the phone app is blocked.
+ The phone numbers are only saved at this device and are never transmitted.
+
+ Swipe to the side to remove this message
+ Add contact
+ You must sign in to remove contacts
+ %s was removed
+ Could not start call
+ Call started; You can hang up using the notification area
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings-diagnose.xml b/app/src/main/res/values/strings-diagnose.xml
index 6ab5d33..05929f7 100644
--- a/app/src/main/res/values/strings-diagnose.xml
+++ b/app/src/main/res/values/strings-diagnose.xml
@@ -24,4 +24,8 @@
Day of week (0 = monday, ...): %d
Day of epoch: %d
Minute of week: %d
+
+ Foreground-App-Detection
+ Requested time range
+ Minimum (Default)
diff --git a/app/src/main/res/values/strings-lock.xml b/app/src/main/res/values/strings-lock.xml
index 5318bf6..bf40b68 100644
--- a/app/src/main/res/values/strings-lock.xml
+++ b/app/src/main/res/values/strings-lock.xml
@@ -47,13 +47,13 @@
And the simplest solution in this case is to block the App.
- This App is part of a category which was temporarily blocked.
+ This %s is part of a category which was temporarily blocked.
- This App is part of a category which is blocked at this time.
+ This %s is part of a category which is blocked at this time.
- This App is part of a category whose time limit was reached.
+ This %s is part of a category whose time limit was reached.
This App is part of a category whose time limit was reached.
@@ -64,4 +64,10 @@
temporarily blocked
time over
blocked time area
+ all notifications are blocked
+
+
+ Failed to open the lock screen.
+ You can try using the home or overview button to leave this screen.
+
diff --git a/app/src/main/res/values/strings-manage-device-activity-level-blocking.xml b/app/src/main/res/values/strings-manage-device-activity-level-blocking.xml
new file mode 100644
index 0000000..a618294
--- /dev/null
+++ b/app/src/main/res/values/strings-manage-device-activity-level-blocking.xml
@@ -0,0 +1,23 @@
+
+
+
+ Activity level blocking
+
+ This allows (depending on the App) blocking some features within an App.
+ If you don\'t understand what Android-Activities are, don\'t enable this.
+
+ Enable activity level blocking
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings-manage-device-default-user.xml b/app/src/main/res/values/strings-manage-device-default-user.xml
new file mode 100644
index 0000000..a5ff3f8
--- /dev/null
+++ b/app/src/main/res/values/strings-manage-device-default-user.xml
@@ -0,0 +1,36 @@
+
+
+
+ Default user
+ The default user is a user
+ to which any user at the device can switch without a password (to sign out).
+ This is useful at devices which can be used by multiple different users.
+
+ The current default user is %s
+ Switch to the default user
+ You can only switch to the default user at the device itself
+ You can only switch to the default user if a default user was configured
+ You can not switch to the default user if the default user is already the current user
+ Set default user
+ Change default user
+ No default user
+ The device will not switch automatically to the default user
+ The device will switch automatically to the default user when it was not used for %s
+ enable switching automatically to default user
+ configure switching automatically to default user
+ Automatically switch to the default user
+ never
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings-manage-device-manipulation.xml b/app/src/main/res/values/strings-manage-device-manipulation.xml
index 3b6c421..8f81125 100644
--- a/app/src/main/res/values/strings-manage-device-manipulation.xml
+++ b/app/src/main/res/values/strings-manage-device-manipulation.xml
@@ -23,6 +23,8 @@
disabling devcie admin was attempted
usage stats access
notification access
+ draw over other Apps permission
+ Accessibility service permission
older App version installed
device was rebooted
there was a manipulation which was stopped again
diff --git a/app/src/main/res/values/strings-manage-device.xml b/app/src/main/res/values/strings-manage-device.xml
index fc9a6a6..eb0a768 100644
--- a/app/src/main/res/values/strings-manage-device.xml
+++ b/app/src/main/res/values/strings-manage-device.xml
@@ -15,13 +15,24 @@
along with this program. If not, see .
-->
+ User
+ Permissions
+ Features
+ Manage
+ other settings for this device
+
Added: %s
This is the device which is in front of you
User of the device
No selection - no limits
+ No features enabled
+
+ No permissions granted
+
Usage stats access
+ Usage stats
TimeLimit uses the permission for the usage stats access.
This is only used to detect the currently used App.
@@ -30,7 +41,21 @@
Notification access
TimeLimit uses the notification access to block notifications of blocked apps.
- Notifications and their contents are not saved.
+ Notifications and their contents are not saved. Additionally, this blocks
+ the playback of media players if the media player is blocked.
+
+
+ Draw over other Apps
+
+ Enabling drawing on top of other Apps can improve blocking in some cases.
+ Moreover, this is one way to allow TimeLimit at newer Android versions to open the lockscreen.
+
+
+ Accessibility service
+
+ This is used to \"press\" the home button before showing the lock screen.
+ This fixes blocking in some cases.
+ Moreover, this is one way to allow TimeLimit at newer Android versions to open the lockscreen.
Device admin
diff --git a/app/src/main/res/values/strings-must-read.xml b/app/src/main/res/values/strings-must-read.xml
new file mode 100644
index 0000000..5247567
--- /dev/null
+++ b/app/src/main/res/values/strings-must-read.xml
@@ -0,0 +1,32 @@
+
+
+
+
+ Due to regular bad ratings:
+ \n1st: In the default settings, TimeLimit can be removed easily, even by children.
+ If this is a problem for you, then get in touch with the support.
+ \n2nd: If \"it does not work\" then this is most likely caused by power saving features.
+ This features are often enabled even if you did not enable it actively.
+
+
+ Because it happens often that it is configured wrongly:
+ A rule limits the total usage duration of a category at the selected days of the week.
+ If there is a rule from monday to friday with 3 hours,
+ then there is a limit of total 3 hours per week from monday to friday.
+ If there is one rule per day with 3 hours,
+ then there is a limit of 3 hours per day.
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings-notification-channels.xml b/app/src/main/res/values/strings-notification-channels.xml
index 3f10d73..13713d2 100644
--- a/app/src/main/res/values/strings-notification-channels.xml
+++ b/app/src/main/res/values/strings-notification-channels.xml
@@ -20,4 +20,7 @@
blocked notifications
Shows when a notification was blocked by TimeLimit
+
+ Time warning
+ Notification for childs if there is not much time remaining
diff --git a/app/src/main/res/values/strings-select-time-span-view.xml b/app/src/main/res/values/strings-select-time-span-view.xml
new file mode 100644
index 0000000..66e1cf9
--- /dev/null
+++ b/app/src/main/res/values/strings-select-time-span-view.xml
@@ -0,0 +1,20 @@
+
+
+
+ Minutes
+ Hours
+ Days
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings-setup-device-permissions.xml b/app/src/main/res/values/strings-setup-device-permissions.xml
index 15e89e9..d9d4064 100644
--- a/app/src/main/res/values/strings-setup-device-permissions.xml
+++ b/app/src/main/res/values/strings-setup-device-permissions.xml
@@ -18,6 +18,8 @@
TimeLimit needs some permissions to work.
Only the first permission is absolutely necessary.
+ Starting Android Q, the permission \"Draw over other Apps\" or \"Accessibility service\"
+ is required to open the lockscreen.
The other permissions are required for some extra features.
diff --git a/app/src/main/res/values/strings-usage-stats-permission-required-and-missing.xml b/app/src/main/res/values/strings-usage-stats-permission-required-and-missing.xml
index 899c37d..315a88b 100644
--- a/app/src/main/res/values/strings-usage-stats-permission-required-and-missing.xml
+++ b/app/src/main/res/values/strings-usage-stats-permission-required-and-missing.xml
@@ -16,5 +16,6 @@
-->
Usage stats access is required
- TimeLimit needs this permission to work correctly. Scroll down to find the option to enable it.
+ TimeLimit needs this permission to work correctly. Select \"permissions\" below to enable it.
+ Draw over other Apps or Accessibility service is required
diff --git a/app/src/main/res/values/strings-util-time.xml b/app/src/main/res/values/strings-util-time.xml
index 8c268d9..e4b0a32 100644
--- a/app/src/main/res/values/strings-util-time.xml
+++ b/app/src/main/res/values/strings-util-time.xml
@@ -15,6 +15,11 @@
along with this program. If not, see .
-->
+
+ - %d second
+ - %d seconds
+
+
- %d minute
- %d minutes
diff --git a/app/src/main/res/xml/accesibility.xml b/app/src/main/res/xml/accesibility.xml
new file mode 100644
index 0000000..6eb27d4
--- /dev/null
+++ b/app/src/main/res/xml/accesibility.xml
@@ -0,0 +1,22 @@
+
+
+
diff --git a/app/src/main/web_hi_res_512.png b/app/src/main/web_hi_res_512.png
deleted file mode 100644
index 7b48bbe..0000000
Binary files a/app/src/main/web_hi_res_512.png and /dev/null differ
diff --git a/build.gradle b/build.gradle
index 35ef2d0..8baaa8d 100644
--- a/build.gradle
+++ b/build.gradle
@@ -7,9 +7,9 @@ buildscript {
jcenter()
}
dependencies {
- classpath 'com.android.tools.build:gradle:3.3.2'
+ classpath 'com.android.tools.build:gradle:3.4.1'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
- classpath "android.arch.navigation:navigation-safe-args-gradle-plugin:1.0.0-alpha11"
+ classpath "android.arch.navigation:navigation-safe-args-gradle-plugin:1.0.0-rc02"
// NOTE: Do not place your application dependencies here; they belong
// in the individual module build.gradle files
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 6995d54..175d881 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,7 +1,7 @@
-#Thu Jun 21 09:47:23 CEST 2018
+#Mon Apr 22 15:16:13 CEST 2019
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.1-all.zip
-distributionSha256Sum=36bf7ff499223d5139f005822130ccca784c91591b514677fd376eed966c907e
+distributionUrl=https\://services.gradle.org/distributions/gradle-5.1.1-all.zip
+distributionSha256Sum=53b71812f18cdb2777e9f1b2a0f2038683907c90bdc406bc64d8b400e1fb2c3b