Import changes from TimeLimit

This commit is contained in:
Jonas Lochmann 2019-07-22 00:00:00 +00:00
parent 799a890989
commit a9b1c824f8
No known key found for this signature in database
GPG key ID: 8B8C9AEE10FA5B36
194 changed files with 7804 additions and 895 deletions

View file

@ -1,9 +1,8 @@
## bug reports and feature requests ## bug reports and feature requests
- open a ticket here at GitLab - open a ticket here at GitLab
- alternativly, send a message to support@timelimit.io - alternatively, send a message to support@timelimit.io
## merge requests ## 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. Are possible but only after talking with the developer before developing anything.
Due to that, merge requests are not wanted to avoid licensing issues when adding something from a merge request to the proprietary version.

View file

@ -25,18 +25,21 @@ androidExtensions {
} }
android { android {
compileSdkVersion 28 compileSdkVersion 29
defaultConfig { defaultConfig {
applicationId "io.timelimit.android.open" applicationId "io.timelimit.android.open"
minSdkVersion 19 minSdkVersion 19
targetSdkVersion 28 targetSdkVersion 29
versionCode 5 versionCode 50
versionName "0.2.3" versionName "1.5.1"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
kapt { kapt {
arguments { arguments {
arg("room.schemaLocation", "$projectDir/schemas".toString()) arg("room.schemaLocation", "$projectDir/schemas".toString())
} }
javacOptions {
option("-Xmaxerrs", 500)
}
} }
} }
@ -63,7 +66,7 @@ android {
} }
dependencies { dependencies {
def nav_version = "1.0.0-beta02" def nav_version = "1.0.0"
def room_version = "2.0.0" def room_version = "2.0.0"
def paging_version = "2.1.0" def paging_version = "2.1.0"

View file

@ -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\")"
]
}
}

View file

@ -20,6 +20,7 @@
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.VIBRATE" /> <uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- suppress DeprecatedClassUsageInspection --> <!-- suppress DeprecatedClassUsageInspection -->
<uses-permission <uses-permission
android:name="android.permission.GET_TASKS" android:name="android.permission.GET_TASKS"
@ -29,11 +30,14 @@
tools:ignore="ProtectedPermissions" /> tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.WAKE_LOCK" /> <uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-feature android:name="android.hardware.telephony" android:required="false" />
<application <application
android:name=".Application" android:name=".Application"
android:allowBackup="false" android:allowBackup="false"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name" android:label="@string/app_name"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/AppTheme"> android:theme="@style/AppTheme">
@ -111,6 +115,19 @@
</intent-filter> </intent-filter>
</service> </service>
<service android:name=".integration.platform.android.AccessibilityService"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data android:name="android.accessibilityservice"
android:resource="@xml/accesibility" />
</service>
</application> </application>
</manifest> </manifest>

View file

@ -28,6 +28,8 @@ interface Database {
fun usedTimes(): UsedTimeDao fun usedTimes(): UsedTimeDao
fun user(): UserDao fun user(): UserDao
fun temporarilyAllowedApp(): TemporarilyAllowedAppDao fun temporarilyAllowedApp(): TemporarilyAllowedAppDao
fun appActivity(): AppActivityDao
fun allowedContact(): AllowedContactDao
fun beginTransaction() fun beginTransaction()
fun setTransactionSuccessful() fun setTransactionSuccessful()

View file

@ -22,4 +22,28 @@ object DatabaseMigrations {
database.execSQL("ALTER TABLE `device` ADD COLUMN `consider_reboot_manipulation` INTEGER NOT NULL DEFAULT 0") 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)")
}
}
} }

View file

@ -30,8 +30,10 @@ import io.timelimit.android.data.model.*
UsedTimeItem::class, UsedTimeItem::class,
TimeLimitRule::class, TimeLimitRule::class,
ConfigurationItem::class, ConfigurationItem::class,
TemporarilyAllowedApp::class TemporarilyAllowedApp::class,
], version = 4) AppActivity::class,
AllowedContact::class
], version = 5)
abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database { abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database {
companion object { companion object {
private val lock = Object() private val lock = Object()
@ -69,7 +71,8 @@ abstract class RoomDatabase: RoomDatabase(), io.timelimit.android.data.Database
.addMigrations( .addMigrations(
DatabaseMigrations.MIGRATE_TO_V2, DatabaseMigrations.MIGRATE_TO_V2,
DatabaseMigrations.MIGRATE_TO_V3, DatabaseMigrations.MIGRATE_TO_V3,
DatabaseMigrations.MIGRATE_TO_V4 DatabaseMigrations.MIGRATE_TO_V4,
DatabaseMigrations.MIGRATE_TO_V5
) )
.build() .build()
} }

View file

@ -37,6 +37,8 @@ object DatabaseBackupLowlevel {
private const val TIME_LIMIT_RULE = "timelimitRule" private const val TIME_LIMIT_RULE = "timelimitRule"
private const val USED_TIME_ITEM = "usedTime" private const val USED_TIME_ITEM = "usedTime"
private const val USER = "user" private const val USER = "user"
private const val APP_ACTIVITY = "appActivity"
private const val ALLOWED_CONTACT = "allowedContact"
fun outputAsBackupJson(database: Database, outputStream: OutputStream) { fun outputAsBackupJson(database: Database, outputStream: OutputStream) {
val writer = JsonWriter(OutputStreamWriter(outputStream, Charsets.UTF_8)) 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(TIME_LIMIT_RULE) { offset, pageSize -> database.timeLimitRules().getRulePageSync(offset, pageSize) }
handleCollection(USED_TIME_ITEM) { offset, pageSize -> database.usedTimes().getUsedTimePageSync(offset, pageSize) } handleCollection(USED_TIME_ITEM) { offset, pageSize -> database.usedTimes().getUsedTimePageSync(offset, pageSize) }
handleCollection(USER) { offset, pageSize -> database.user().getUserPageSync(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() writer.endObject().flush()
} }
@ -168,6 +173,27 @@ object DatabaseBackupLowlevel {
reader.endArray() 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() else -> reader.skipValue()
} }
} }

View file

@ -0,0 +1,37 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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<AllowedContact>
@Query("SELECT * FROM allowed_contact")
fun getAllowedContactsLive(): LiveData<List<AllowedContact>>
@Insert
fun addContactSync(item: AllowedContact)
@Query("DELETE FROM allowed_contact WHERE id = :id")
fun removeContactSync(id: Int)
}

View file

@ -0,0 +1,48 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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<AppActivity>
@Query("SELECT * FROM app_activity WHERE device_id IN (:deviceIds)")
fun getAppActivitiesByDeviceIds(deviceIds: List<String>): LiveData<List<AppActivity>>
@Query("SELECT * FROM app_activity WHERE app_package_name = :packageName")
fun getAppActivitiesByPackageName(packageName: String): LiveData<List<AppActivity>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun addAppActivitySync(item: AppActivity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun addAppActivitiesSync(items: List<AppActivity>)
@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<String>)
@Query("DELETE FROM app_activity WHERE device_id IN (:deviceIds)")
fun deleteAppActivitiesByDeviceIds(deviceIds: List<String>)
}

View file

@ -68,6 +68,9 @@ abstract class CategoryDao {
@Query("UPDATE category SET parent_category_id = :parentCategoryId WHERE id = :categoryId") @Query("UPDATE category SET parent_category_id = :parentCategoryId WHERE id = :categoryId")
abstract fun updateParentCategory(categoryId: String, parentCategoryId: String) abstract fun updateParentCategory(categoryId: String, parentCategoryId: String)
@Update
abstract fun updateCategorySync(category: Category)
} }
data class CategoryShortInfo( data class CategoryShortInfo(

View file

@ -112,4 +112,19 @@ abstract class ConfigDao {
fun wasDeviceLockedSync() = getValueOfKeySync(ConfigurationItemType.WasDeviceLocked) == "true" fun wasDeviceLockedSync() = getValueOfKeySync(ConfigurationItemType.WasDeviceLocked) == "true"
fun setWasDeviceLockedSync(value: Boolean) = updateValueSync(ConfigurationItemType.WasDeviceLocked, if (value) "true" else "false") fun setWasDeviceLockedSync(value: Boolean) = updateValueSync(ConfigurationItemType.WasDeviceLocked, if (value) "true" else "false")
fun getForegroundAppQueryIntervalAsync(): LiveData<Long> = 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
} }

View file

@ -47,6 +47,9 @@ abstract class DeviceDao {
@Query("UPDATE device SET current_user_id = :userId WHERE id = :deviceId") @Query("UPDATE device SET current_user_id = :userId WHERE id = :deviceId")
abstract fun updateDeviceUser(deviceId: String, userId: String) 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 @Update
abstract fun updateDeviceEntry(device: Device) abstract fun updateDeviceEntry(device: Device)

View file

@ -0,0 +1,69 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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()
}
}

View file

@ -0,0 +1,83 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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()
}
}

View file

@ -44,7 +44,11 @@ data class Category(
@ColumnInfo(name = "temporarily_blocked") @ColumnInfo(name = "temporarily_blocked")
val temporarilyBlocked: Boolean, val temporarilyBlocked: Boolean,
@ColumnInfo(name = "parent_category_id") @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 { ): JsonSerializable {
companion object { companion object {
const val MINUTES_PER_DAY = 60 * 24 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 EXTRA_TIME_IN_MILLIS = "extraTimeInMillis"
private const val TEMPORARILY_BLOCKED = "temporarilyBlocked" private const val TEMPORARILY_BLOCKED = "temporarilyBlocked"
private const val PARENT_CATEGORY_ID = "parentCategoryId" 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 { fun parse(reader: JsonReader): Category {
var id: String? = null var id: String? = null
@ -67,6 +73,8 @@ data class Category(
var temporarilyBlocked: Boolean? = null var temporarilyBlocked: Boolean? = null
// this field was added later so it has got a default value // this field was added later so it has got a default value
var parentCategoryId = "" var parentCategoryId = ""
var blockAllNotifications = false
var timeWarnings = 0
reader.beginObject() reader.beginObject()
@ -79,6 +87,8 @@ data class Category(
EXTRA_TIME_IN_MILLIS -> extraTimeInMillis = reader.nextLong() EXTRA_TIME_IN_MILLIS -> extraTimeInMillis = reader.nextLong()
TEMPORARILY_BLOCKED -> temporarilyBlocked = reader.nextBoolean() TEMPORARILY_BLOCKED -> temporarilyBlocked = reader.nextBoolean()
PARENT_CATEGORY_ID -> parentCategoryId = reader.nextString() PARENT_CATEGORY_ID -> parentCategoryId = reader.nextString()
BlOCK_ALL_NOTIFICATIONS -> blockAllNotifications = reader.nextBoolean()
TIME_WARNINGS -> timeWarnings = reader.nextInt()
else -> reader.skipValue() else -> reader.skipValue()
} }
} }
@ -92,7 +102,9 @@ data class Category(
blockedMinutesInWeek = blockedMinutesInWeek!!, blockedMinutesInWeek = blockedMinutesInWeek!!,
extraTimeInMillis = extraTimeInMillis!!, extraTimeInMillis = extraTimeInMillis!!,
temporarilyBlocked = temporarilyBlocked!!, 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(EXTRA_TIME_IN_MILLIS).value(extraTimeInMillis)
writer.name(TEMPORARILY_BLOCKED).value(temporarilyBlocked) writer.name(TEMPORARILY_BLOCKED).value(temporarilyBlocked)
writer.name(PARENT_CATEGORY_ID).value(parentCategoryId) writer.name(PARENT_CATEGORY_ID).value(parentCategoryId)
writer.name(BlOCK_ALL_NOTIFICATIONS).value(blockAllNotifications)
writer.name(TIME_WARNINGS).value(timeWarnings)
writer.endObject() 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
}

View file

@ -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 { init {
IdGenerator.assertIdValid(categoryId) IdGenerator.assertIdValid(categoryId)

View file

@ -77,30 +77,45 @@ data class ConfigurationItem(
enum class ConfigurationItemType { enum class ConfigurationItemType {
OwnDeviceId, OwnDeviceId,
ShownHints, ShownHints,
WasDeviceLocked WasDeviceLocked,
ForegroundAppQueryRange,
EnableAlternativeDurationSelection,
LastScreenOnTime
} }
object ConfigurationItemTypeUtil { object ConfigurationItemTypeUtil {
private const val OWN_DEVICE_ID = 1 private const val OWN_DEVICE_ID = 1
private const val SHOWN_HINTS = 2 private const val SHOWN_HINTS = 2
private const val WAS_DEVICE_LOCKED = 3 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( val TYPES = listOf(
ConfigurationItemType.OwnDeviceId, ConfigurationItemType.OwnDeviceId,
ConfigurationItemType.ShownHints, ConfigurationItemType.ShownHints,
ConfigurationItemType.WasDeviceLocked ConfigurationItemType.WasDeviceLocked,
ConfigurationItemType.ForegroundAppQueryRange,
ConfigurationItemType.EnableAlternativeDurationSelection,
ConfigurationItemType.LastScreenOnTime
) )
fun serialize(value: ConfigurationItemType) = when(value) { fun serialize(value: ConfigurationItemType) = when(value) {
ConfigurationItemType.OwnDeviceId -> OWN_DEVICE_ID ConfigurationItemType.OwnDeviceId -> OWN_DEVICE_ID
ConfigurationItemType.ShownHints -> SHOWN_HINTS ConfigurationItemType.ShownHints -> SHOWN_HINTS
ConfigurationItemType.WasDeviceLocked -> WAS_DEVICE_LOCKED 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) { fun parse(value: Int) = when(value) {
OWN_DEVICE_ID -> ConfigurationItemType.OwnDeviceId OWN_DEVICE_ID -> ConfigurationItemType.OwnDeviceId
SHOWN_HINTS -> ConfigurationItemType.ShownHints SHOWN_HINTS -> ConfigurationItemType.ShownHints
WAS_DEVICE_LOCKED -> ConfigurationItemType.WasDeviceLocked 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() else -> throw IllegalArgumentException()
} }
} }
@ -118,4 +133,6 @@ object HintsToShow {
const val DEVICE_SCREEN_INTRODUCTION = 2L const val DEVICE_SCREEN_INTRODUCTION = 2L
const val CATEGORIES_INTRODUCTION = 4L const val CATEGORIES_INTRODUCTION = 4L
const val TIME_LIMIT_RULE_INTRODUCTION = 8L const val TIME_LIMIT_RULE_INTRODUCTION = 8L
const val CONTACTS_INTRO = 16L
const val TIMELIMIT_RULE_MUSTREAD = 32L
} }

View file

@ -65,8 +65,24 @@ data class Device(
val manipulationDidReboot: Boolean, val manipulationDidReboot: Boolean,
@ColumnInfo(name = "had_manipulation") @ColumnInfo(name = "had_manipulation")
val hadManipulation: Boolean, val hadManipulation: Boolean,
@ColumnInfo(name = "default_user")
val defaultUser: String,
@ColumnInfo(name = "default_user_timeout")
val defaultUserTimeout: Int,
@ColumnInfo(name = "consider_reboot_manipulation") @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 { ): JsonSerializable {
companion object { companion object {
private const val ID = "id" private const val ID = "id"
@ -85,7 +101,15 @@ data class Device(
private const val TRIED_DISABLING_DEVICE_ADMIN = "tdda" private const val TRIED_DISABLING_DEVICE_ADMIN = "tdda"
private const val MANIPULATION_DID_REBOOT = "mdr" private const val MANIPULATION_DID_REBOOT = "mdr"
private const val HAD_MANIPULATION = "hm" 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 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 { fun parse(reader: JsonReader): Device {
var id: String? = null var id: String? = null
@ -104,7 +128,15 @@ data class Device(
var manipulationTriedDisablingDeviceAdmin: Boolean? = null var manipulationTriedDisablingDeviceAdmin: Boolean? = null
var manipulationDidReboot: Boolean = false var manipulationDidReboot: Boolean = false
var hadManipulation: Boolean? = null var hadManipulation: Boolean? = null
var defaultUser = ""
var defaultUserTimeout = 0
var considerRebootManipulation = false 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() reader.beginObject()
@ -126,7 +158,15 @@ data class Device(
TRIED_DISABLING_DEVICE_ADMIN -> manipulationTriedDisablingDeviceAdmin = reader.nextBoolean() TRIED_DISABLING_DEVICE_ADMIN -> manipulationTriedDisablingDeviceAdmin = reader.nextBoolean()
MANIPULATION_DID_REBOOT -> manipulationDidReboot = reader.nextBoolean() MANIPULATION_DID_REBOOT -> manipulationDidReboot = reader.nextBoolean()
HAD_MANIPULATION -> hadManipulation = 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() 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() else -> reader.skipValue()
} }
} }
@ -150,7 +190,15 @@ data class Device(
manipulationTriedDisablingDeviceAdmin = manipulationTriedDisablingDeviceAdmin!!, manipulationTriedDisablingDeviceAdmin = manipulationTriedDisablingDeviceAdmin!!,
manipulationDidReboot = manipulationDidReboot, manipulationDidReboot = manipulationDidReboot,
hadManipulation = hadManipulation!!, 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(TRIED_DISABLING_DEVICE_ADMIN).value(manipulationTriedDisablingDeviceAdmin)
writer.name(MANIPULATION_DID_REBOOT).value(manipulationDidReboot) writer.name(MANIPULATION_DID_REBOOT).value(manipulationDidReboot)
writer.name(HAD_MANIPULATION).value(hadManipulation) 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(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() writer.endObject()
} }
@ -211,6 +267,10 @@ data class Device(
val manipulationOfNotificationAccess = currentNotificationAccessPermission != highestNotificationAccessPermission val manipulationOfNotificationAccess = currentNotificationAccessPermission != highestNotificationAccessPermission
@Transient @Transient
val manipulationOfAppVersion = currentAppVersion != highestAppVersion val manipulationOfAppVersion = currentAppVersion != highestAppVersion
@Transient
val manipulationOfOverlayPermission = currentOverlayPermission != highestOverlayPermission
@Transient
val manipulationOfAccessibilityService = accessibilityServiceEnabled != wasAccessibilityServiceEnabled
@Transient @Transient
val hasActiveManipulationWarning = manipulationOfProtectionLevel || val hasActiveManipulationWarning = manipulationOfProtectionLevel ||
@ -218,8 +278,16 @@ data class Device(
manipulationOfNotificationAccess || manipulationOfNotificationAccess ||
manipulationOfAppVersion || manipulationOfAppVersion ||
manipulationTriedDisablingDeviceAdmin || manipulationTriedDisablingDeviceAdmin ||
manipulationDidReboot manipulationDidReboot ||
manipulationOfOverlayPermission ||
manipulationOfAccessibilityService
@Transient @Transient
val hasAnyManipulation = hasActiveManipulationWarning || hadManipulation val hasAnyManipulation = hasActiveManipulationWarning || hadManipulation
@Transient
val missingPermissionAtQOrLater = qOrLater &&
(!accessibilityServiceEnabled) &&
(currentOverlayPermission != RuntimePermissionStatus.Granted) &&
(currentProtectionLevel != ProtectionLevel.DeviceOwner)
} }

View file

@ -15,6 +15,8 @@
*/ */
package io.timelimit.android.extensions package io.timelimit.android.extensions
import android.text.Editable
import android.text.TextWatcher
import android.view.KeyEvent import android.view.KeyEvent
import android.view.inputmethod.EditorInfo import android.view.inputmethod.EditorInfo
import android.widget.EditText 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()
}
})
}

View file

@ -19,29 +19,37 @@ import android.graphics.drawable.Drawable
import android.os.Parcelable import android.os.Parcelable
import androidx.room.TypeConverter import androidx.room.TypeConverter
import io.timelimit.android.data.model.App import io.timelimit.android.data.model.App
import io.timelimit.android.data.model.AppActivity
import kotlinx.android.parcel.Parcelize import kotlinx.android.parcel.Parcelize
abstract class PlatformIntegration( abstract class PlatformIntegration(
val maximumProtectionLevel: ProtectionLevel val maximumProtectionLevel: ProtectionLevel
) { ) {
abstract fun getLocalApps(): Collection<App> abstract fun getLocalApps(): Collection<App>
abstract fun getLocalAppActivities(deviceId: String): Collection<AppActivity>
abstract fun getLocalAppTitle(packageName: String): String? abstract fun getLocalAppTitle(packageName: String): String?
abstract fun getAppIcon(packageName: String): Drawable? abstract fun getAppIcon(packageName: String): Drawable?
abstract fun getLauncherAppPackageName(): String?
abstract fun getCurrentProtectionLevel(): ProtectionLevel abstract fun getCurrentProtectionLevel(): ProtectionLevel
abstract fun getForegroundAppPermissionStatus(): RuntimePermissionStatus abstract fun getForegroundAppPermissionStatus(): RuntimePermissionStatus
abstract fun getDrawOverOtherAppsPermissionStatus(): RuntimePermissionStatus abstract fun getDrawOverOtherAppsPermissionStatus(): RuntimePermissionStatus
abstract fun getNotificationAccessPermissionStatus(): NewPermissionStatus abstract fun getNotificationAccessPermissionStatus(): NewPermissionStatus
abstract fun getOverlayPermissionStatus(): RuntimePermissionStatus
abstract fun isAccessibilityServiceEnabled(): Boolean
abstract fun disableDeviceAdmin() abstract fun disableDeviceAdmin()
abstract fun trySetLockScreenPassword(password: String): Boolean abstract fun trySetLockScreenPassword(password: String): Boolean
// this must have a fallback if the permission is not granted // this must have a fallback if the permission is not granted
abstract fun showOverlayMessage(text: String) 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 // 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 setAppStatusMessage(message: AppStatusMessage?)
abstract fun isScreenOn(): Boolean abstract fun isScreenOn(): Boolean
abstract fun setShowNotificationToRevokeTemporarilyAllowedApps(show: Boolean) abstract fun setShowNotificationToRevokeTemporarilyAllowedApps(show: Boolean)
abstract fun showTimeWarningNotification(title: String, text: String)
// returns package names for which it was set // returns package names for which it was set
abstract fun setSuspendedApps(packageNames: List<String>, suspend: Boolean): List<String> abstract fun setSuspendedApps(packageNames: List<String>, suspend: Boolean): List<String>
abstract fun stopSuspendingForAllApps() abstract fun stopSuspendingForAllApps()
@ -54,6 +62,12 @@ abstract class PlatformIntegration(
var installedAppsChangeListener: Runnable? = null 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 { enum class ProtectionLevel {
None, SimpleDeviceAdmin, PasswordDeviceAdmin, DeviceOwner None, SimpleDeviceAdmin, PasswordDeviceAdmin, DeviceOwner
} }
@ -170,4 +184,9 @@ class NewPermissionStatusConverter {
} }
@Parcelize @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

View file

@ -0,0 +1,51 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View file

@ -41,14 +41,11 @@ class AdminReceiver: DeviceAdminReceiver() {
override fun onDisableRequested(context: Context, intent: Intent?): CharSequence { override fun onDisableRequested(context: Context, intent: Intent?): CharSequence {
runAsync { runAsync {
val logic = DefaultAppLogic.with(context) ApplyActionUtil.applyAppLogicAction(
action = TriedDisablingDeviceAdminAction,
if (logic.database.config().getOwnDeviceId().waitForNullableValue() != null) { appLogic = DefaultAppLogic.with(context),
ApplyActionUtil.applyAppLogicAction( ignoreIfDeviceIsNotConfigured = true
TriedDisablingDeviceAdminAction, )
logic
)
}
} }
return context.getString(R.string.admin_disable_warning) return context.getString(R.string.admin_disable_warning)

View file

@ -17,6 +17,7 @@ package io.timelimit.android.integration.platform.android
import android.annotation.TargetApi import android.annotation.TargetApi
import android.app.ActivityManager import android.app.ActivityManager
import android.app.Application
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.app.admin.DevicePolicyManager import android.app.admin.DevicePolicyManager
@ -27,20 +28,27 @@ import android.content.Intent
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.media.session.MediaSessionManager
import android.os.Build import android.os.Build
import android.os.PowerManager import android.os.PowerManager
import android.os.UserManager import android.os.UserManager
import android.provider.Settings import android.provider.Settings
import android.util.Log import android.util.Log
import android.view.KeyEvent
import android.widget.Toast import android.widget.Toast
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import io.timelimit.android.BuildConfig import io.timelimit.android.BuildConfig
import io.timelimit.android.R import io.timelimit.android.R
import io.timelimit.android.coroutines.runAsyncExpectForever
import io.timelimit.android.data.model.App 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.*
import io.timelimit.android.integration.platform.android.foregroundapp.ForegroundAppHelper import io.timelimit.android.integration.platform.android.foregroundapp.ForegroundAppHelper
import io.timelimit.android.ui.lock.LockActivity 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) { 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 activityManager = this.context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
private val notificationManager = this.context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val notificationManager = this.context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val deviceAdmin = ComponentName(context.applicationContext, AdminReceiver::class.java) private val deviceAdmin = ComponentName(context.applicationContext, AdminReceiver::class.java)
private val overlay = OverlayUtil(context as Application)
init { init {
AppsChangeListener.registerBroadcastReceiver(this.context, object : BroadcastReceiver() { AppsChangeListener.registerBroadcastReceiver(this.context, object : BroadcastReceiver() {
@ -78,10 +87,20 @@ class AndroidIntegration(context: Context): PlatformIntegration(maximumProtectio
return AndroidIntegrationApps.getLocalApps(context) return AndroidIntegrationApps.getLocalApps(context)
} }
override fun getLocalAppActivities(deviceId: String): Collection<AppActivity> {
return AndroidIntegrationApps.getLocalAppActivities(deviceId, context)
}
override fun getLocalAppTitle(packageName: String): String? { override fun getLocalAppTitle(packageName: String): String? {
return AndroidIntegrationApps.getAppTitle(packageName, context) 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? { override fun getAppIcon(packageName: String): Drawable? {
return AndroidIntegrationApps.getAppIcon(packageName, context) return AndroidIntegrationApps.getAppIcon(packageName, context)
} }
@ -90,8 +109,8 @@ class AndroidIntegration(context: Context): PlatformIntegration(maximumProtectio
return AdminStatus.getAdminStatus(context, policyManager) return AdminStatus.getAdminStatus(context, policyManager)
} }
override suspend fun getForegroundAppPackageName(): String? { override suspend fun getForegroundApp(result: ForegroundAppSpec, queryInterval: Long) {
return foregroundAppHelper.getForegroundAppPackage() foregroundAppHelper.getForegroundApp(result, queryInterval)
} }
override fun getForegroundAppPermissionStatus(): RuntimePermissionStatus { 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 { override fun trySetLockScreenPassword(password: String): Boolean {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "set password") Log.d(LOG_TAG, "set password")
@ -153,17 +196,55 @@ class AndroidIntegration(context: Context): PlatformIntegration(maximumProtectio
} }
private var lastAppStatusMessage: AppStatusMessage? = null private var lastAppStatusMessage: AppStatusMessage? = null
private var appStatusMessageChannel = Channel<AppStatusMessage?>(capacity = Channel.CONFLATED)
override fun setAppStatusMessage(message: AppStatusMessage?) { override fun setAppStatusMessage(message: AppStatusMessage?) {
if (lastAppStatusMessage != message) { if (lastAppStatusMessage != message) {
lastAppStatusMessage = message lastAppStatusMessage = message
appStatusMessageChannel.offer(message)
BackgroundService.setStatusMessage(message, context)
} }
} }
override fun showAppLockScreen(currentPackageName: String) { init {
LockActivity.start(context, currentPackageName) 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 { override fun isScreenOn(): Boolean {
@ -176,7 +257,7 @@ class AndroidIntegration(context: Context): PlatformIntegration(maximumProtectio
override fun setShowNotificationToRevokeTemporarilyAllowedApps(show: Boolean) { override fun setShowNotificationToRevokeTemporarilyAllowedApps(show: Boolean) {
if (show) { if (show) {
NotificationChannels.createAppStatusChannel(notificationManager, context) NotificationChannels.createNotificationChannels(notificationManager, context)
val actionIntent = PendingIntent.getService( val actionIntent = PendingIntent.getService(
context, 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() { override fun disableDeviceAdmin() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
if (policyManager.isDeviceOwnerApp(context.packageName)) { if (policyManager.isDeviceOwnerApp(context.packageName)) {

View file

@ -25,6 +25,7 @@ import android.provider.ContactsContract
import android.provider.Settings import android.provider.Settings
import android.provider.Telephony import android.provider.Telephony
import io.timelimit.android.data.model.App import io.timelimit.android.data.model.App
import io.timelimit.android.data.model.AppActivity
import io.timelimit.android.data.model.AppRecommendation import io.timelimit.android.data.model.AppRecommendation
object AndroidIntegrationApps { object AndroidIntegrationApps {
@ -90,6 +91,26 @@ object AndroidIntegrationApps {
return result.values return result.values
} }
fun getLocalAppActivities(deviceId: String, context: Context): Collection<AppActivity> {
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<String, App>, resolveInfoList: List<ResolveInfo>, recommendation: AppRecommendation, context: Context) { private fun add(map: MutableMap<String, App>, resolveInfoList: List<ResolveInfo>, recommendation: AppRecommendation, context: Context) {
val packageManager = context.packageManager val packageManager = context.packageManager

View file

@ -21,12 +21,15 @@ import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.IBinder import android.os.IBinder
import android.widget.Toast
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import io.timelimit.android.R import io.timelimit.android.R
import io.timelimit.android.coroutines.runAsync import io.timelimit.android.coroutines.runAsync
import io.timelimit.android.integration.platform.AppStatusMessage import io.timelimit.android.integration.platform.AppStatusMessage
import io.timelimit.android.logic.DefaultAppLogic 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 import io.timelimit.android.ui.MainActivity
class BackgroundService: Service() { class BackgroundService: Service() {
@ -34,6 +37,7 @@ class BackgroundService: Service() {
private const val ACTION = "action" private const val ACTION = "action"
private const val ACTION_SET_NOTIFICATION = "set_notification" private const val ACTION_SET_NOTIFICATION = "set_notification"
private const val ACTION_REVOKE_TEMPORARILY_ALLOWED_APPS = "revoke_temporarily_allowed_apps" 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" private const val EXTRA_NOTIFICATION = "notification"
fun setStatusMessage(status: AppStatusMessage?, context: Context) { fun setStatusMessage(status: AppStatusMessage?, context: Context) {
@ -53,6 +57,16 @@ class BackgroundService: Service() {
fun prepareRevokeTemporarilyAllowed(context: Context) = Intent(context, BackgroundService::class.java) fun prepareRevokeTemporarilyAllowed(context: Context) = Intent(context, BackgroundService::class.java)
.putExtra(ACTION, ACTION_REVOKE_TEMPORARILY_ALLOWED_APPS) .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 { private val notificationManager: NotificationManager by lazy {
@ -68,7 +82,7 @@ class BackgroundService: Service() {
DefaultAppLogic.with(this) DefaultAppLogic.with(this)
// create the channel // create the channel
NotificationChannels.createAppStatusChannel(notificationManager, this) NotificationChannels.createNotificationChannels(notificationManager, this)
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@ -78,18 +92,12 @@ class BackgroundService: Service() {
if (action == ACTION_SET_NOTIFICATION) { if (action == ACTION_SET_NOTIFICATION) {
val appStatusMessage = intent.getParcelableExtra<AppStatusMessage>(EXTRA_NOTIFICATION) val appStatusMessage = intent.getParcelableExtra<AppStatusMessage>(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) val notification = NotificationCompat.Builder(this, NotificationChannels.APP_STATUS)
.setSmallIcon(R.drawable.ic_stat_timelapse) .setSmallIcon(R.drawable.ic_stat_timelapse)
.setContentTitle(appStatusMessage.title) .setContentTitle(appStatusMessage.title)
.setContentText(appStatusMessage.text) .setContentText(appStatusMessage.text)
.setContentIntent(openAppIntent) .setSubText(appStatusMessage.subtext)
.setContentIntent(getOpenAppIntent(this@BackgroundService))
.setWhen(0) .setWhen(0)
.setShowWhen(false) .setShowWhen(false)
.setSound(null) .setSound(null)
@ -98,6 +106,24 @@ class BackgroundService: Service() {
.setAutoCancel(false) .setAutoCancel(false)
.setOngoing(true) .setOngoing(true)
.setPriority(NotificationCompat.PRIORITY_LOW) .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() .build()
if (didPostNotification) { if (didPostNotification) {
@ -110,6 +136,16 @@ class BackgroundService: Service() {
runAsync { runAsync {
DefaultAppLogic.with(this@BackgroundService).backgroundTaskLogic.resetTemporarilyAllowedApps() 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
)
}
} }
} }

View file

@ -25,14 +25,15 @@ object NotificationIds {
const val APP_STATUS = 1 const val APP_STATUS = 1
const val NOTIFICATION_BLOCKED = 2 const val NOTIFICATION_BLOCKED = 2
const val REVOKE_TEMPORARILY_ALLOWED_APPS = 3 const val REVOKE_TEMPORARILY_ALLOWED_APPS = 3
const val APP_RESET = 4 const val TIME_WARNING = 4
} }
object NotificationChannels { object NotificationChannels {
const val APP_STATUS = "app status" const val APP_STATUS = "app status"
const val BLOCKED_NOTIFICATIONS_NOTIFICATION = "notification blocked notification" 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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notificationManager.createNotificationChannel( notificationManager.createNotificationChannel(
NotificationChannel( 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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notificationManager.createNotificationChannel( notificationManager.createNotificationChannel(
NotificationChannel( 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 { object PendingIntentIds {
const val OPEN_MAIN_APP = 1 const val OPEN_MAIN_APP = 1
const val REVOKE_TEMPORARILY_ALLOWED = 2 const val REVOKE_TEMPORARILY_ALLOWED = 2
const val SWITCH_TO_DEFAULT_USER = 3
val DYNAMIC_NOTIFICATION_RANGE = 100..10000
} }

View file

@ -35,17 +35,19 @@ import io.timelimit.android.logic.*
class NotificationListener: NotificationListenerService() { class NotificationListener: NotificationListenerService() {
companion object { companion object {
private const val LOG_TAG = "NotificationListenerLog" 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 appLogic: AppLogic by lazy { DefaultAppLogic.with(this) }
private val blockingReasonUtil: BlockingReasonUtil by lazy { BlockingReasonUtil(appLogic) } private val blockingReasonUtil: BlockingReasonUtil by lazy { BlockingReasonUtil(appLogic) }
private val notificationManager: NotificationManager by lazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager } private val notificationManager: NotificationManager by lazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager }
private val queryAppTitleCache: QueryAppTitleCache by lazy { QueryAppTitleCache(appLogic.platformIntegration) } private val queryAppTitleCache: QueryAppTitleCache by lazy { QueryAppTitleCache(appLogic.platformIntegration) }
private val lastOngoingNotificationHidden = mutableSetOf<String>()
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
NotificationChannels.createBlockedNotificationChannel(notificationManager, this) NotificationChannels.createNotificationChannels(notificationManager, this)
} }
override fun onNotificationPosted(sbn: StatusBarNotification) { override fun onNotificationPosted(sbn: StatusBarNotification) {
@ -58,9 +60,25 @@ class NotificationListener: NotificationListenerService() {
runAsync { runAsync {
val reason = shouldRemoveNotification(sbn) 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 { 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 true
} catch (ex: SecurityException) { } catch (ex: SecurityException) {
@ -91,6 +109,7 @@ class NotificationListener: NotificationListenerService() {
BlockingReason.TimeOver -> getString(R.string.lock_reason_short_time_over) BlockingReason.TimeOver -> getString(R.string.lock_reason_short_time_over)
BlockingReason.TimeOverExtraTimeCanBeUsedLater -> 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.BlockedAtThisTime -> getString(R.string.lock_reason_short_blocked_time_area)
BlockingReason.NotificationsAreBlocked -> getString(R.string.lock_reason_short_notification_blocking)
BlockingReason.None -> throw IllegalStateException() BlockingReason.None -> throw IllegalStateException()
} }
) )
@ -109,25 +128,41 @@ class NotificationListener: NotificationListenerService() {
} }
private suspend fun shouldRemoveNotification(sbn: StatusBarNotification): BlockingReason { private suspend fun shouldRemoveNotification(sbn: StatusBarNotification): BlockingReason {
if (sbn.packageName == packageName || sbn.isOngoing) { if (sbn.packageName == packageName) {
return BlockingReason.None return BlockingReason.None
} }
val blockingReason = blockingReasonUtil.getBlockingReason(sbn.packageName).waitForNonNullValue() if (sbn.isOngoing && (!SUPPORTS_HIDING_ONGOING_NOTIFICATIONS)) {
if (blockingReason == BlockingReason.None) {
return BlockingReason.None return BlockingReason.None
} }
if (isSystemApp(sbn.packageName) && blockingReason == BlockingReason.NotPartOfAnCategory) { val blockingReason = blockingReasonUtil.getBlockingReason(
return BlockingReason.None 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) { return when (blockingReason) {
Log.d(LOG_TAG, "blocking notification of ${sbn.packageName} because $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 { private fun isSystemApp(packageName: String): Boolean {

View file

@ -0,0 +1,81 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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
}

View file

@ -17,16 +17,21 @@ package io.timelimit.android.integration.platform.android.foregroundapp
import android.app.ActivityManager import android.app.ActivityManager
import android.content.Context import android.content.Context
import io.timelimit.android.integration.platform.ForegroundAppSpec
import io.timelimit.android.integration.platform.RuntimePermissionStatus import io.timelimit.android.integration.platform.RuntimePermissionStatus
class CompatForegroundAppHelper(context: Context) : ForegroundAppHelper() { class CompatForegroundAppHelper(context: Context) : ForegroundAppHelper() {
private val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager private val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
override suspend fun getForegroundAppPackage(): String? { override suspend fun getForegroundApp(result: ForegroundAppSpec, queryInterval: Long) {
return try { try {
activityManager.getRunningTasks(1)[0].topActivity.packageName val activity = activityManager.getRunningTasks(1)[0].topActivity
result.packageName = activity.packageName
result.activityName = activity.className
} catch (ex: NullPointerException) { } catch (ex: NullPointerException) {
null result.activityName = null
result.packageName = null
} }
} }

View file

@ -17,10 +17,11 @@ package io.timelimit.android.integration.platform.android.foregroundapp
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import io.timelimit.android.integration.platform.ForegroundAppSpec
import io.timelimit.android.integration.platform.RuntimePermissionStatus import io.timelimit.android.integration.platform.RuntimePermissionStatus
abstract class ForegroundAppHelper { abstract class ForegroundAppHelper {
abstract suspend fun getForegroundAppPackage(): String? abstract suspend fun getForegroundApp(result: ForegroundAppSpec, queryInterval: Long)
abstract fun getPermissionStatus(): RuntimePermissionStatus abstract fun getPermissionStatus(): RuntimePermissionStatus
companion object { companion object {

View file

@ -22,6 +22,7 @@ import android.app.usage.UsageStatsManager
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import io.timelimit.android.coroutines.executeAndWait import io.timelimit.android.coroutines.executeAndWait
import io.timelimit.android.integration.platform.ForegroundAppSpec
import io.timelimit.android.integration.platform.RuntimePermissionStatus import io.timelimit.android.integration.platform.RuntimePermissionStatus
import java.util.concurrent.Executor import java.util.concurrent.Executor
import java.util.concurrent.Executors import java.util.concurrent.Executors
@ -37,11 +38,12 @@ class LollipopForegroundAppHelper(private val context: Context) : ForegroundAppH
private var lastQueryTime: Long = 0 private var lastQueryTime: Long = 0
private var lastPackage: String? = null private var lastPackage: String? = null
private var lastPackageActivity: String? = null
private var lastPackageTime: Long = 0 private var lastPackageTime: Long = 0
private val event = UsageEvents.Event() private val event = UsageEvents.Event()
@Throws(SecurityException::class) @Throws(SecurityException::class)
override suspend fun getForegroundAppPackage(): String? { override suspend fun getForegroundApp(result: ForegroundAppSpec, queryInterval: Long) {
if (getPermissionStatus() == RuntimePermissionStatus.NotGranted) { if (getPermissionStatus() == RuntimePermissionStatus.NotGranted) {
throw SecurityException() throw SecurityException()
} }
@ -49,10 +51,11 @@ class LollipopForegroundAppHelper(private val context: Context) : ForegroundAppH
return foregroundAppThread.executeAndWait { return foregroundAppThread.executeAndWait {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (lastQueryTime > now) { if (lastQueryTime > now || queryInterval >= 1000 * 60 * 60 * 24 /* 1 day */) {
// if the time went backwards, forget everything // if the time went backwards, forget everything
lastQueryTime = 0 lastQueryTime = 0
lastPackage = null lastPackage = null
lastPackageActivity = null
lastPackageTime = 0 lastPackageTime = 0
} }
@ -66,7 +69,7 @@ class LollipopForegroundAppHelper(private val context: Context) : ForegroundAppH
// which seems to provide all data // which seems to provide all data
// update: with 1 second, some App switching events were missed // update: with 1 second, some App switching events were missed
// it seems to always work with 1.5 seconds // it seems to always work with 1.5 seconds
lastQueryTime - 1500 lastQueryTime - Math.max(queryInterval, 1500)
} }
usageStatsManager.queryEvents(queryStartTime, now)?.let { usageEvents -> usageStatsManager.queryEvents(queryStartTime, now)?.let { usageEvents ->
@ -77,6 +80,7 @@ class LollipopForegroundAppHelper(private val context: Context) : ForegroundAppH
if (event.timeStamp > lastPackageTime) { if (event.timeStamp > lastPackageTime) {
lastPackageTime = event.timeStamp lastPackageTime = event.timeStamp
lastPackage = event.packageName lastPackage = event.packageName
lastPackageActivity = event.className
} }
} }
} }
@ -84,7 +88,8 @@ class LollipopForegroundAppHelper(private val context: Context) : ForegroundAppH
lastQueryTime = now lastQueryTime = now
lastPackage result.packageName = lastPackage
result.activityName = lastPackageActivity
} }
} }

View file

@ -17,6 +17,7 @@ package io.timelimit.android.integration.platform.dummy
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import io.timelimit.android.data.model.App 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.*
class DummyIntegration( class DummyIntegration(
@ -37,6 +38,10 @@ class DummyIntegration(
return localApps return localApps
} }
override fun getLocalAppActivities(deviceId: String): Collection<AppActivity> {
return emptySet()
}
override fun getLocalAppTitle(packageName: String): String? { override fun getLocalAppTitle(packageName: String): String? {
return localApps.find { it.packageName == packageName }?.title return localApps.find { it.packageName == packageName }?.title
} }
@ -45,10 +50,20 @@ class DummyIntegration(
return null return null
} }
override fun getLauncherAppPackageName(): String? = null
override fun getCurrentProtectionLevel(): ProtectionLevel { override fun getCurrentProtectionLevel(): ProtectionLevel {
return protectionLevel return protectionLevel
} }
override fun getOverlayPermissionStatus(): RuntimePermissionStatus {
return RuntimePermissionStatus.NotRequired
}
override fun isAccessibilityServiceEnabled(): Boolean {
return false
}
override fun getForegroundAppPermissionStatus(): RuntimePermissionStatus { override fun getForegroundAppPermissionStatus(): RuntimePermissionStatus {
return foregroundAppPermission return foregroundAppPermission
} }
@ -68,10 +83,18 @@ class DummyIntegration(
// do nothing // do nothing
} }
override fun showAppLockScreen(currentPackageName: String) { override fun showAppLockScreen(currentPackageName: String, currentActivityName: String?) {
launchLockScreenForPackage = currentPackageName launchLockScreenForPackage = currentPackageName
} }
override fun muteAudioIfPossible(packageName: String) {
// ignore
}
override fun setShowBlockingOverlay(show: Boolean) {
// ignore
}
fun getAndResetShowAppLockScreen(): String? { fun getAndResetShowAppLockScreen(): String? {
try { try {
return launchLockScreenForPackage 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) { if (foregroundAppPermission == RuntimePermissionStatus.NotGranted) {
throw SecurityException() throw SecurityException()
} }
return foregroundApp result.packageName = foregroundApp
result.activityName = null
} }
override fun setAppStatusMessage(message: AppStatusMessage?) { override fun setAppStatusMessage(message: AppStatusMessage?) {
@ -108,6 +132,10 @@ class DummyIntegration(
showRevokeTemporarilyAllowedNotification = show showRevokeTemporarilyAllowedNotification = show
} }
override fun showTimeWarningNotification(title: String, text: String) {
// nothing to do
}
override fun disableDeviceAdmin() { override fun disableDeviceAdmin() {
// nothing to do // nothing to do
} }

View file

@ -18,14 +18,18 @@ package io.timelimit.android.livedata
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
fun LiveData<Boolean>.or(other: LiveData<Boolean>): LiveData<Boolean> { fun LiveData<Boolean>.or(other: LiveData<Boolean>): LiveData<Boolean> {
return mergeLiveData(this, other).map { return this.switchMap { value1 ->
(it.first != null && it.first == true) || ( it.second != null && it.second == true) other.map { value2 ->
value1 || value2
}
} }
} }
fun LiveData<Boolean>.and(other: LiveData<Boolean>): LiveData<Boolean> { fun LiveData<Boolean>.and(other: LiveData<Boolean>): LiveData<Boolean> {
return mergeLiveData(this, other).map { return this.switchMap { value1 ->
(it.first != null && it.first == true) && ( it.second != null && it.second == true) other.map { value2 ->
value1 && value2
}
} }
} }

View file

@ -59,6 +59,41 @@ class SingleItemLiveDataCache<T>(private val liveData: LiveData<T>): LiveDataCac
} }
} }
class SingleItemLiveDataCacheWithRequery<T>(private val liveDataCreator: () -> LiveData<T>): LiveDataCache() {
private val dummyObserver = Observer<T> {
// do nothing
}
private var wasUsed = false
private var instance: LiveData<T>? = null
fun read(): LiveData<T> {
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<R, K>: LiveDataCache() { abstract class MultiKeyLiveDataCache<R, K>: LiveDataCache() {
class ItemWrapper<R>(val value: LiveData<R>, var used: Boolean) class ItemWrapper<R>(val value: LiveData<R>, var used: Boolean)

View file

@ -65,6 +65,10 @@ class AppLogic(
} }
}.ignoreUnchanged() }.ignoreUnchanged()
private val foregroundAppQueryInterval = database.config().getForegroundAppQueryIntervalAsync().apply { observeForever { } }
fun getForegroundAppQueryInterval() = foregroundAppQueryInterval.value ?: 0L
val defaultUserLogic = DefaultUserLogic(this)
val backgroundTaskLogic = BackgroundTaskLogic(this) val backgroundTaskLogic = BackgroundTaskLogic(this)
val appSetupLogic = AppSetupLogic(this) val appSetupLogic = AppSetupLogic(this)
private val syncAppsLogic = SyncInstalledAppsLogic(this) private val syncAppsLogic = SyncInstalledAppsLogic(this)

View file

@ -29,6 +29,7 @@ import io.timelimit.android.integration.platform.NewPermissionStatus
import io.timelimit.android.integration.platform.ProtectionLevel import io.timelimit.android.integration.platform.ProtectionLevel
import io.timelimit.android.integration.platform.RuntimePermissionStatus import io.timelimit.android.integration.platform.RuntimePermissionStatus
import io.timelimit.android.ui.user.create.DefaultCategories import io.timelimit.android.ui.user.create.DefaultCategories
import io.timelimit.android.util.AndroidVersion
import java.util.* import java.util.*
class AppSetupLogic(private val appLogic: AppLogic) { class AppSetupLogic(private val appLogic: AppLogic) {
@ -84,7 +85,15 @@ class AppSetupLogic(private val appLogic: AppLogic) {
manipulationTriedDisablingDeviceAdmin = false, manipulationTriedDisablingDeviceAdmin = false,
manipulationDidReboot = false, manipulationDidReboot = false,
hadManipulation = 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) appLogic.database.device().addDeviceSync(device)
@ -139,7 +148,9 @@ class AppSetupLogic(private val appLogic: AppLogic) {
blockedMinutesInWeek = ImmutableBitmask((BitSet())), blockedMinutesInWeek = ImmutableBitmask((BitSet())),
extraTimeInMillis = 0, extraTimeInMillis = 0,
temporarilyBlocked = false, temporarilyBlocked = false,
parentCategoryId = "" parentCategoryId = "",
blockAllNotifications = false,
timeWarnings = 0
)) ))
appLogic.database.category().addCategory(Category( appLogic.database.category().addCategory(Category(
@ -149,7 +160,9 @@ class AppSetupLogic(private val appLogic: AppLogic) {
blockedMinutesInWeek = defaultCategories.allowedGamesBlockedTimes, blockedMinutesInWeek = defaultCategories.allowedGamesBlockedTimes,
extraTimeInMillis = 0, extraTimeInMillis = 0,
temporarilyBlocked = false, temporarilyBlocked = false,
parentCategoryId = "" parentCategoryId = "",
blockAllNotifications = false,
timeWarnings = 0
)) ))
// add default allowed apps // add default allowed apps

View file

@ -30,17 +30,23 @@ import io.timelimit.android.data.model.*
import io.timelimit.android.date.DateInTimezone import io.timelimit.android.date.DateInTimezone
import io.timelimit.android.date.getMinuteOfWeek import io.timelimit.android.date.getMinuteOfWeek
import io.timelimit.android.integration.platform.AppStatusMessage 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.ProtectionLevel
import io.timelimit.android.integration.platform.android.AccessibilityService
import io.timelimit.android.integration.platform.android.AndroidIntegrationApps import io.timelimit.android.integration.platform.android.AndroidIntegrationApps
import io.timelimit.android.livedata.* import io.timelimit.android.livedata.*
import io.timelimit.android.sync.actions.UpdateDeviceStatusAction import io.timelimit.android.sync.actions.UpdateDeviceStatusAction
import io.timelimit.android.sync.actions.apply.ApplyActionUtil import io.timelimit.android.sync.actions.apply.ApplyActionUtil
import io.timelimit.android.util.AndroidVersion
import io.timelimit.android.util.TimeTextUtil import io.timelimit.android.util.TimeTextUtil
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import java.util.* import java.util.*
class BackgroundTaskLogic(val appLogic: AppLogic) { class BackgroundTaskLogic(val appLogic: AppLogic) {
var pauseBackgroundLoop = false
companion object { companion object {
private const val CHECK_PERMISSION_INTERVAL = 10 * 1000L // all 10 seconds private const val CHECK_PERMISSION_INTERVAL = 10 * 1000L // all 10 seconds
private const val BACKGROUND_SERVICE_INTERVAL = 100L // all 100 ms 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( private val liveDataCaches = LiveDataCaches(arrayOf(
deviceUserEntryLive, deviceUserEntryLive,
childCategories, childCategories,
appCategories, appCategories,
timeLimitRules, timeLimitRules,
usedTimesOfCategoryAndWeekByFirstDayOfWeek usedTimesOfCategoryAndWeekByFirstDayOfWeek,
shouldDoAutomaticSignOut
)) ))
private var usedTimeUpdateHelper: UsedTimeItemBatchUpdateHelper? = null private var usedTimeUpdateHelper: UsedTimeItemBatchUpdateHelper? = null
@ -125,6 +134,28 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
private val appTitleCache = QueryAppTitleCache(appLogic.platformIntegration) 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() { private suspend fun backgroundServiceLoop() {
while (true) { while (true) {
// app must be enabled // app must be enabled
@ -132,6 +163,7 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
usedTimeUpdateHelper?.commit(appLogic) usedTimeUpdateHelper?.commit(appLogic)
liveDataCaches.removeAllItems() liveDataCaches.removeAllItems()
appLogic.platformIntegration.setAppStatusMessage(null) appLogic.platformIntegration.setAppStatusMessage(null)
appLogic.platformIntegration.setShowBlockingOverlay(false)
appLogic.enable.waitUntilValueMatches { it == true } appLogic.enable.waitUntilValueMatches { it == true }
continue continue
@ -142,9 +174,31 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
if (deviceUserEntry == null || deviceUserEntry.type != UserType.Child) { if (deviceUserEntry == null || deviceUserEntry.type != UserType.Child) {
usedTimeUpdateHelper?.commit(appLogic) usedTimeUpdateHelper?.commit(appLogic)
liveDataCaches.removeAllItems() val shouldDoAutomaticSignOut = shouldDoAutomaticSignOut.read()
appLogic.platformIntegration.setAppStatusMessage(null)
deviceUserEntryLive.read().waitUntilValueMatches { it != null && it.type == UserType.Child } 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 continue
} }
@ -159,11 +213,17 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
val minuteOfWeek = getMinuteOfWeek(nowTimestamp, nowTimezone) val minuteOfWeek = getMinuteOfWeek(nowTimestamp, nowTimezone)
// eventually remove old used time data // eventually remove old used time data
if (dayChangeTracker.reportDayChange(nowDate.dayOfEpoch) == DayChangeTracker.DayChange.NowSinceLongerTime) { run {
UsedTimeDeleter.deleteOldUsedTimeItems( val dayChange = dayChangeTracker.reportDayChange(nowDate.dayOfEpoch)
fun deleteOldUsedTimes() = UsedTimeDeleter.deleteOldUsedTimeItems(
database = appLogic.database, database = appLogic.database,
date = nowDate date = nowDate
) )
if (dayChange == DayChangeTracker.DayChange.NowSinceLongerTime) {
deleteOldUsedTimes()
}
} }
// get the categories // get the categories
@ -173,33 +233,63 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
// get the current status // get the current status
val isScreenOn = appLogic.platformIntegration.isScreenOn() val isScreenOn = appLogic.platformIntegration.isScreenOn()
appLogic.defaultUserLogic.reportScreenOn(isScreenOn)
if (!isScreenOn) { if (!isScreenOn) {
if (temporarilyAllowedApps.isNotEmpty()) { if (temporarilyAllowedApps.isNotEmpty()) {
resetTemporarilyAllowedApps() 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 // the following is not executed if the permission is missing
if (foregroundAppPackageName == BuildConfig.APPLICATION_ID) { if (pauseBackgroundLoop) {
// 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)) {
usedTimeUpdateHelper?.commit(appLogic) usedTimeUpdateHelper?.commit(appLogic)
appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage(
appTitleCache.query(foregroundAppPackageName), title = appLogic.context.getString(R.string.background_logic_paused_title),
appLogic.context.getString(R.string.background_logic_whitelisted) 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)) { } else if (foregroundAppPackageName != null && temporarilyAllowedApps.contains(foregroundAppPackageName)) {
usedTimeUpdateHelper?.commit(appLogic) usedTimeUpdateHelper?.commit(appLogic)
appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( showStatusMessageWithCurrentAppTitle(appLogic.context.getString(R.string.background_logic_temporarily_allowed))
appTitleCache.query(foregroundAppPackageName), appLogic.platformIntegration.setShowBlockingOverlay(false)
appLogic.context.getString(R.string.background_logic_temporarily_allowed)
))
} else if (foregroundAppPackageName != null) { } 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 } val category = categories.find { it.id == appCategory?.categoryId }
?: categories.find { it.id == deviceUserEntry.categoryForNotAssignedApps } ?: categories.find { it.id == deviceUserEntry.categoryForNotAssignedApps }
val parentCategory = categories.find { it.id == category?.parentCategoryId } val parentCategory = categories.find { it.id == category?.parentCategoryId }
@ -207,39 +297,30 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
if (category == null) { if (category == null) {
usedTimeUpdateHelper?.commit(appLogic) usedTimeUpdateHelper?.commit(appLogic)
appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( if (AndroidIntegrationApps.ignoredApps.contains(foregroundAppPackageName) == false) {
title = appTitleCache.query(foregroundAppPackageName), // don't suspend system apps which are whitelisted in any version
text = appLogic.context.getString(R.string.background_logic_opening_lockscreen) appLogic.platformIntegration.setSuspendedApps(listOf(foregroundAppPackageName), true)
)) }
appLogic.platformIntegration.setSuspendedApps(listOf(foregroundAppPackageName), true)
appLogic.platformIntegration.showAppLockScreen(foregroundAppPackageName) openLockscreen(foregroundAppPackageName, foregroundAppActivityName)
} else if (category.temporarilyBlocked or (parentCategory?.temporarilyBlocked == true)) { } else if (category.temporarilyBlocked or (parentCategory?.temporarilyBlocked == true)) {
usedTimeUpdateHelper?.commit(appLogic) usedTimeUpdateHelper?.commit(appLogic)
appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( openLockscreen(foregroundAppPackageName, foregroundAppActivityName)
title = appTitleCache.query(foregroundAppPackageName),
text = appLogic.context.getString(R.string.background_logic_opening_lockscreen)
))
appLogic.platformIntegration.showAppLockScreen(foregroundAppPackageName)
} else { } else {
// disable time limits temporarily feature // disable time limits temporarily feature
if (nowTimestamp < deviceUserEntry.disableLimitsUntil) { if (nowTimestamp < deviceUserEntry.disableLimitsUntil) {
appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( showStatusMessageWithCurrentAppTitle(appLogic.context.getString(R.string.background_logic_limits_disabled))
title = appTitleCache.query(foregroundAppPackageName), appLogic.platformIntegration.setShowBlockingOverlay(false)
text = appLogic.context.getString(R.string.background_logic_limits_disabled)
))
} else if ( } else if (
// check blocked time areas // check blocked time areas
// directly blocked
(category.blockedMinutesInWeek.read(minuteOfWeek)) or (category.blockedMinutesInWeek.read(minuteOfWeek)) or
(parentCategory?.blockedMinutesInWeek?.read(minuteOfWeek) == true) (parentCategory?.blockedMinutesInWeek?.read(minuteOfWeek) == true)
) { ) {
usedTimeUpdateHelper?.commit(appLogic) usedTimeUpdateHelper?.commit(appLogic)
appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( openLockscreen(foregroundAppPackageName, foregroundAppActivityName)
title = appTitleCache.query(foregroundAppPackageName),
text = appLogic.context.getString(R.string.background_logic_opening_lockscreen)
))
appLogic.platformIntegration.showAppLockScreen(foregroundAppPackageName)
} else { } else {
// check time limits // check time limits
val rules = timeLimitRules.get(category.id).waitForNonNullValue() val rules = timeLimitRules.get(category.id).waitForNonNullValue()
@ -251,10 +332,11 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
// unlimited // unlimited
usedTimeUpdateHelper?.commit(appLogic) usedTimeUpdateHelper?.commit(appLogic)
appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( showStatusMessageWithCurrentAppTitle(
category.title + " - " + appTitleCache.query(foregroundAppPackageName), text = appLogic.context.getString(R.string.background_logic_no_timelimit),
appLogic.context.getString(R.string.background_logic_no_timelimit) titlePrefix = category.title + " - "
)) )
appLogic.platformIntegration.setShowBlockingOverlay(false)
} else { } else {
val usedTimes = usedTimesOfCategoryAndWeekByFirstDayOfWeek.get(Pair(category.id, nowDate.dayOfEpoch - nowDate.dayOfWeek)).waitForNonNullValue() val usedTimes = usedTimesOfCategoryAndWeekByFirstDayOfWeek.get(Pair(category.id, nowDate.dayOfEpoch - nowDate.dayOfWeek)).waitForNonNullValue()
val parentUsedTimes = parentCategory?.let { val parentUsedTimes = parentCategory?.let {
@ -317,42 +399,57 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
usedTimeUpdateHelper?.commit(appLogic) usedTimeUpdateHelper?.commit(appLogic)
appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( showStatusMessageWithCurrentAppTitle(
category.title + " - " + appTitleCache.query(foregroundAppPackageName), text = appLogic.context.getString(R.string.background_logic_no_timelimit),
appLogic.context.getString(R.string.background_logic_no_timelimit) titlePrefix = category.title + " - "
)) )
appLogic.platformIntegration.setShowBlockingOverlay(false)
} else { } else {
// time limited // time limited
if (remaining.includingExtraTime > 0) { if (remaining.includingExtraTime > 0) {
var subtractExtraTime: Boolean
if (remaining.default == 0L) { if (remaining.default == 0L) {
// using extra time // using extra time
showStatusMessageWithCurrentAppTitle(
appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( text = appLogic.context.getString(R.string.background_logic_using_extra_time, TimeTextUtil.remaining(remaining.includingExtraTime.toInt(), appLogic.context)),
category.title + " - " + appTitleCache.query(foregroundAppPackageName), titlePrefix = category.title + " - "
appLogic.context.getString(R.string.background_logic_using_extra_time, TimeTextUtil.remaining(remaining.includingExtraTime.toInt(), appLogic.context)) )
)) subtractExtraTime = true
if (isScreenOn) {
newUsedTimeItemBatchUpdateHelper.addUsedTime(
Math.min(previousMainLogicExecutionTime, MAX_USED_TIME_PER_ROUND), // never save more than a second of used time
true,
appLogic
)
}
} else { } else {
// using normal contingent // using normal contingent
showStatusMessageWithCurrentAppTitle(
text = TimeTextUtil.remaining(remaining.default.toInt(), appLogic.context),
titlePrefix = category.title + " - "
)
subtractExtraTime = false
}
appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( appLogic.platformIntegration.setShowBlockingOverlay(false)
category.title + " - " + appTitleCache.query(foregroundAppPackageName), if (isScreenOn) {
TimeTextUtil.remaining(remaining.default.toInt(), appLogic.context) // never save more than a second of used time
)) val timeToSubtract = Math.min(previousMainLogicExecutionTime, MAX_USED_TIME_PER_ROUND)
if (isScreenOn) { newUsedTimeItemBatchUpdateHelper.addUsedTime(
newUsedTimeItemBatchUpdateHelper.addUsedTime( timeToSubtract,
Math.min(previousMainLogicExecutionTime, MAX_USED_TIME_PER_ROUND), // never save more than a second of used time subtractExtraTime,
false, appLogic
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 { } else {
@ -360,11 +457,7 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
newUsedTimeItemBatchUpdateHelper.commit(appLogic) newUsedTimeItemBatchUpdateHelper.commit(appLogic)
appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage( openLockscreen(foregroundAppPackageName, foregroundAppActivityName)
title = appTitleCache.query(foregroundAppPackageName),
text = appLogic.context.getString(R.string.background_logic_opening_lockscreen)
))
appLogic.platformIntegration.showAppLockScreen(foregroundAppPackageName)
} }
} }
} }
@ -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_title),
appLogic.context.getString(R.string.background_logic_idle_text) appLogic.context.getString(R.string.background_logic_idle_text)
)) ))
appLogic.platformIntegration.setShowBlockingOverlay(false)
} }
} catch (ex: SecurityException) { } catch (ex: SecurityException) {
// this is handled by an other main loop (with a delay) // 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),
appLogic.context.getString(R.string.background_logic_error_permission) appLogic.context.getString(R.string.background_logic_error_permission)
)) ))
appLogic.platformIntegration.setShowBlockingOverlay(false)
} catch (ex: Exception) { } catch (ex: Exception) {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Log.w(LOG_TAG, "exception during running main loop", ex) 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),
appLogic.context.getString(R.string.background_logic_error_internal) appLogic.context.getString(R.string.background_logic_error_internal)
)) ))
appLogic.platformIntegration.setShowBlockingOverlay(false)
} }
liveDataCaches.reportLoopDone() liveDataCaches.reportLoopDone()
@ -413,10 +509,11 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
if (deviceEntry != null) { if (deviceEntry != null) {
if (deviceEntry.currentAppVersion != currentAppVersion) { if (deviceEntry.currentAppVersion != currentAppVersion) {
ApplyActionUtil.applyAppLogicAction( ApplyActionUtil.applyAppLogicAction(
UpdateDeviceStatusAction.empty.copy( action = UpdateDeviceStatusAction.empty.copy(
newAppVersion = currentAppVersion newAppVersion = currentAppVersion
), ),
appLogic appLogic = appLogic,
ignoreIfDeviceIsNotConfigured = true
) )
} }
} }
@ -446,10 +543,11 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
if (deviceEntry?.considerRebootManipulation == true) { if (deviceEntry?.considerRebootManipulation == true) {
ApplyActionUtil.applyAppLogicAction( ApplyActionUtil.applyAppLogicAction(
UpdateDeviceStatusAction.empty.copy( action = UpdateDeviceStatusAction.empty.copy(
didReboot = true didReboot = true
), ),
appLogic appLogic = appLogic,
ignoreIfDeviceIsNotConfigured = true
) )
} }
} }
@ -463,6 +561,9 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
val protectionLevel = appLogic.platformIntegration.getCurrentProtectionLevel() val protectionLevel = appLogic.platformIntegration.getCurrentProtectionLevel()
val usageStatsPermission = appLogic.platformIntegration.getForegroundAppPermissionStatus() val usageStatsPermission = appLogic.platformIntegration.getForegroundAppPermissionStatus()
val notificationAccess = appLogic.platformIntegration.getNotificationAccessPermissionStatus() val notificationAccess = appLogic.platformIntegration.getNotificationAccessPermissionStatus()
val overlayPermission = appLogic.platformIntegration.getOverlayPermissionStatus()
val accessibilityService = appLogic.platformIntegration.isAccessibilityServiceEnabled()
val qOrLater = AndroidVersion.qOrLater
var changes = UpdateDeviceStatusAction.empty 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) { if (changes != UpdateDeviceStatusAction.empty) {
ApplyActionUtil.applyAppLogicAction(changes, appLogic) ApplyActionUtil.applyAppLogicAction(
action = changes,
appLogic = appLogic,
ignoreIfDeviceIsNotConfigured = true
)
} }
} }
} }

View file

@ -1,5 +1,5 @@
/* /*
* Open TimeLimit Copyright <C> 2019 Jonas Lochmann * TimeLimit Copyright <C> 2019 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * 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 * 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.LiveData
import androidx.lifecycle.Transformations import androidx.lifecycle.Transformations
import io.timelimit.android.BuildConfig import io.timelimit.android.BuildConfig
import io.timelimit.android.data.model.Category import io.timelimit.android.data.model.*
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.date.DateInTimezone import io.timelimit.android.date.DateInTimezone
import io.timelimit.android.date.getMinuteOfWeek import io.timelimit.android.date.getMinuteOfWeek
import io.timelimit.android.integration.platform.android.AndroidIntegrationApps import io.timelimit.android.integration.platform.android.AndroidIntegrationApps
@ -37,69 +34,106 @@ enum class BlockingReason {
TemporarilyBlocked, TemporarilyBlocked,
BlockedAtThisTime, BlockedAtThisTime,
TimeOver, 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) { class BlockingReasonUtil(private val appLogic: AppLogic) {
companion object { companion object {
private const val LOG_TAG = "BlockingReason" private const val LOG_TAG = "BlockingReason"
} }
fun getBlockingReason(packageName: String): LiveData<BlockingReason> { private val enableActivityLevelFiltering = appLogic.deviceEntry.map { it?.enableActivityLevelBlocking ?: false }
fun getBlockingReason(packageName: String, activityName: String?): LiveData<BlockingReasonDetail> {
// check precondition that the app is running // check precondition that the app is running
return appLogic.enable.switchMap { return appLogic.enable.switchMap {
enabled -> enabled ->
if (enabled == null || enabled == false) { if (enabled == null || enabled == false) {
liveDataFromValue(BlockingReason.None) liveDataFromValue(NoBlockingReason.getInstance(areNotificationsBlocked = false) as BlockingReasonDetail)
} else { } else {
appLogic.deviceUserEntry.switchMap { appLogic.deviceUserEntry.switchMap {
user -> user ->
if (user == null || user.type != UserType.Child) { if (user == null || user.type != UserType.Child) {
liveDataFromValue(BlockingReason.None) liveDataFromValue(NoBlockingReason.getInstance(areNotificationsBlocked = false) as BlockingReasonDetail)
} else { } 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<BlockingReason> { private fun getBlockingReasonStep2(packageName: String, activityName: String?, child: User, timeZone: TimeZone): LiveData<BlockingReasonDetail> {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "step 2") Log.d(LOG_TAG, "step 2")
} }
// check internal whitelist // check internal whitelist
if (packageName == BuildConfig.APPLICATION_ID) { if (packageName == BuildConfig.APPLICATION_ID) {
return liveDataFromValue(BlockingReason.None) return liveDataFromValue(NoBlockingReason.getInstance(areNotificationsBlocked = false))
} else if (AndroidIntegrationApps.ignoredApps.contains(packageName)) { } else if (AndroidIntegrationApps.ignoredApps.contains(packageName)) {
return liveDataFromValue(BlockingReason.None) return liveDataFromValue(NoBlockingReason.getInstance(areNotificationsBlocked = false))
} else { } else {
return getBlockingReasonStep3(packageName, child, timeZone) return getBlockingReasonStep3(packageName, activityName, child, timeZone)
} }
} }
private fun getBlockingReasonStep3(packageName: String, child: User, timeZone: TimeZone): LiveData<BlockingReason> { private fun getBlockingReasonStep3(packageName: String, activityName: String?, child: User, timeZone: TimeZone): LiveData<BlockingReasonDetail> {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "step 3") Log.d(LOG_TAG, "step 3")
} }
// check temporarily allowed Apps // 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 -> temporarilyAllowedApps ->
if (temporarilyAllowedApps.contains(packageName)) { if (temporarilyAllowedApps.contains(packageName)) {
liveDataFromValue(BlockingReason.None) liveDataFromValue(NoBlockingReason.getInstance(areNotificationsBlocked = false) as BlockingReasonDetail)
} else { } else {
getBlockingReasonStep4(packageName, child, timeZone) getBlockingReasonStep4(packageName, activityName, child, timeZone)
} }
} }
} }
private fun getBlockingReasonStep4(packageName: String, child: User, timeZone: TimeZone): LiveData<BlockingReason> { private fun getBlockingReasonStep4(packageName: String, activityName: String?, child: User, timeZone: TimeZone): LiveData<BlockingReasonDetail> {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "step 4") Log.d(LOG_TAG, "step 4")
} }
@ -107,13 +141,27 @@ class BlockingReasonUtil(private val appLogic: AppLogic) {
return appLogic.database.category().getCategoriesByChildId(child.id).switchMap { return appLogic.database.category().getCategoriesByChildId(child.id).switchMap {
childCategories -> 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 -> categoryApp ->
if (categoryApp == null) { if (categoryApp == null) {
null null
} else { } else {
childCategories.find { it.id == categoryApp.categoryId } childCategories.find { it.id == categoryApp.first.categoryId }?.let { it to categoryApp.second }
} }
} }
}.switchMap { }.switchMap {
@ -127,22 +175,52 @@ class BlockingReasonUtil(private val appLogic: AppLogic) {
defaultCategory.switchMap { categoryEntry2 -> defaultCategory.switchMap { categoryEntry2 ->
if (categoryEntry2 == null) { if (categoryEntry2 == null) {
liveDataFromValue(BlockingReason.NotPartOfAnCategory) liveDataFromValue(
BlockedReasonDetails(
areNotificationsBlocked = false,
level = BlockingLevel.App,
reason = BlockingReason.NotPartOfAnCategory,
categoryId = null
) as BlockingReasonDetail
)
} else { } else {
getBlockingReasonStep4Point5(categoryEntry2, child, timeZone, false) getBlockingReasonStep4Point5(categoryEntry2, child, timeZone, false, BlockingLevel.App)
} }
} }
} else { } 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<BlockingReason> { private fun getBlockingReasonStep4Point5(category: Category, child: User, timeZone: TimeZone, isParentCategory: Boolean, blockingLevel: BlockingLevel): LiveData<BlockingReasonDetail> {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "step 4.5") 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<BlockingReason> {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "step 4.7")
}
if (category.temporarilyBlocked) { if (category.temporarilyBlocked) {
return liveDataFromValue(BlockingReason.TemporarilyBlocked) return liveDataFromValue(BlockingReason.TemporarilyBlocked)
} }
@ -152,8 +230,10 @@ class BlockingReasonUtil(private val appLogic: AppLogic) {
if (child.disableLimitsUntil == 0L) { if (child.disableLimitsUntil == 0L) {
areLimitsDisabled = liveDataFromValue(false) areLimitsDisabled = liveDataFromValue(false)
} else { } else {
areLimitsDisabled = timeInMillis.map { timeInMillis -> areLimitsDisabled = getTemporarilyTrustedTimeInMillis().map {
child.disableLimitsUntil > timeInMillis trustedTimeInMillis ->
trustedTimeInMillis != null && child.disableLimitsUntil > trustedTimeInMillis
} }
} }
@ -171,7 +251,7 @@ class BlockingReasonUtil(private val appLogic: AppLogic) {
if (parentCategory == null) { if (parentCategory == null) {
liveDataFromValue(BlockingReason.None) liveDataFromValue(BlockingReason.None)
} else { } else {
getBlockingReasonStep4Point5(parentCategory, child, timeZone, true) getBlockingReasonStep4Point7(parentCategory, child, timeZone, true, blockingLevel)
} }
} }
} else { } else {
@ -185,7 +265,7 @@ class BlockingReasonUtil(private val appLogic: AppLogic) {
Log.d(LOG_TAG, "step 5") Log.d(LOG_TAG, "step 5")
} }
return Transformations.switchMap(getMinuteOfWeekLive(appLogic.timeApi, timeZone)) { return Transformations.switchMap(getTrustedMinuteOfWeekLive(appLogic.timeApi, timeZone)) {
trustedMinuteOfWeek -> trustedMinuteOfWeek ->
if (category.blockedMinutesInWeek.dataNotToModify.isEmpty) { if (category.blockedMinutesInWeek.dataNotToModify.isEmpty) {
@ -203,7 +283,7 @@ class BlockingReasonUtil(private val appLogic: AppLogic) {
Log.d(LOG_TAG, "step 6") Log.d(LOG_TAG, "step 6")
} }
return getDateLive(appLogic.timeApi, timeZone).switchMap { return getTrustedDateLive(appLogic.timeApi, timeZone).switchMap {
nowTrustedDate -> nowTrustedDate ->
appLogic.database.timeLimitRules().getTimeLimitRulesByCategory(category.id).switchMap { appLogic.database.timeLimitRules().getTimeLimitRulesByCategory(category.id).switchMap {
@ -212,12 +292,20 @@ class BlockingReasonUtil(private val appLogic: AppLogic) {
if (rules.isEmpty()) { if (rules.isEmpty()) {
liveDataFromValue(BlockingReason.None) liveDataFromValue(BlockingReason.None)
} else { } else {
getBlockingReasonStep7(category, nowTrustedDate, rules) getBlockingReasonStep6(category, nowTrustedDate, rules)
} }
} }
} }
} }
private fun getBlockingReasonStep6(category: Category, nowTrustedDate: DateInTimezone, rules: List<TimeLimitRule>): LiveData<BlockingReason> {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "step 6 - 2")
}
return getBlockingReasonStep7(category, nowTrustedDate, rules)
}
private fun getBlockingReasonStep7(category: Category, nowTrustedDate: DateInTimezone, rules: List<TimeLimitRule>): LiveData<BlockingReason> { private fun getBlockingReasonStep7(category: Category, nowTrustedDate: DateInTimezone, rules: List<TimeLimitRule>): LiveData<BlockingReason> {
if (BuildConfig.DEBUG) { if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "step 7") Log.d(LOG_TAG, "step 7")
@ -246,15 +334,89 @@ class BlockingReasonUtil(private val appLogic: AppLogic) {
} }
} }
private val timeInMillis: LiveData<Long> = liveDataFromFunction { private fun getTemporarilyTrustedTimeInMillis(): LiveData<Long?> {
appLogic.timeApi.getCurrentTimeInMillis() return liveDataFromFunction {
appLogic.timeApi.getCurrentTimeInMillis()
}
} }
private fun getMinuteOfWeekLive(api: TimeApi, timeZone: TimeZone): LiveData<Int> = liveDataFromFunction { private fun getTrustedMinuteOfWeekLive(api: TimeApi, timeZone: TimeZone): LiveData<Int> {
getMinuteOfWeek(api.getCurrentTimeInMillis(), timeZone) return object: LiveData<Int>() {
}.ignoreUnchanged() fun update() {
val timeInMillis = appLogic.timeApi.getCurrentTimeInMillis()
private fun getDateLive(api: TimeApi, timeZone: TimeZone): LiveData<DateInTimezone> = liveDataFromFunction { value = getMinuteOfWeek(timeInMillis, timeZone)
DateInTimezone.newInstance(api.getCurrentTimeInMillis(), timeZone) }
}.ignoreUnchanged()
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<DateInTimezone> {
return object: LiveData<DateInTimezone>() {
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()
}
} }

View file

@ -0,0 +1,181 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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()
}
}
}
}

View file

@ -17,11 +17,10 @@ package io.timelimit.android.logic
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import io.timelimit.android.coroutines.runAsyncExpectForever import io.timelimit.android.coroutines.runAsyncExpectForever
import io.timelimit.android.data.model.AppActivity
import io.timelimit.android.data.model.UserType import io.timelimit.android.data.model.UserType
import io.timelimit.android.livedata.* import io.timelimit.android.livedata.*
import io.timelimit.android.sync.actions.AddInstalledAppsAction import io.timelimit.android.sync.actions.*
import io.timelimit.android.sync.actions.InstalledApp
import io.timelimit.android.sync.actions.RemoveInstalledAppsAction
import io.timelimit.android.sync.actions.apply.ApplyActionUtil import io.timelimit.android.sync.actions.apply.ApplyActionUtil
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
@ -36,12 +35,13 @@ class SyncInstalledAppsLogic(val appLogic: AppLogic) {
init { init {
appLogic.platformIntegration.installedAppsChangeListener = Runnable { requestSync() } 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() } runAsyncExpectForever { syncLoop() }
} }
private suspend fun syncLoop() { private suspend fun syncLoop() {
requestSync.postValue(true)
while (true) { while (true) {
requestSync.waitUntilValueMatches { it == true } requestSync.waitUntilValueMatches { it == true }
requestSync.value = false requestSync.value = false
@ -55,46 +55,84 @@ class SyncInstalledAppsLogic(val appLogic: AppLogic) {
private suspend fun doSyncNow() { private suspend fun doSyncNow() {
doSyncLock.withLock { 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) { run {
return@withLock 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 } run {
val currentlySaved = appLogic.database.app().getApps().waitForNonNullValue().associateBy { app -> app.packageName } fun buildKey(activity: AppActivity) = "${activity.appPackageName}:${activity.activityClassName}"
// skip all items for removal which are still saved locally val currentlyInstalled = (
val itemsToRemove = HashMap(currentlySaved) if (deviceEntry.enableActivityLevelBlocking)
currentlyInstalled.forEach { (packageName, _) -> itemsToRemove.remove(packageName) } appLogic.platformIntegration.getLocalAppActivities(deviceId = deviceId)
else
emptyList()
).associateBy { buildKey(it) }
// only add items which are not the same locally val currentlySaved = appLogic.database.appActivity().getAppActivitiesByDeviceIds(deviceIds = listOf(deviceId)).waitForNonNullValue().associateBy { buildKey(it) }
val itemsToAdd = currentlyInstalled.filter { (packageName, app) -> currentlySaved[packageName] != app }
// save the changes // skip all items for removal which are still saved locally
if (itemsToRemove.isNotEmpty()) { val itemsToRemove = HashMap(currentlySaved)
ApplyActionUtil.applyAppLogicAction( currentlyInstalled.forEach { (packageName, _) -> itemsToRemove.remove(packageName) }
RemoveInstalledAppsAction(packageNames = itemsToRemove.keys.toList()),
appLogic
)
}
if (itemsToAdd.isNotEmpty()) { // only add items which are not the same locally
ApplyActionUtil.applyAppLogicAction( val itemsToAdd = currentlyInstalled.filter { (packageName, app) -> currentlySaved[packageName] != app }
AddInstalledAppsAction(
apps = itemsToAdd.map {
(_, app) ->
InstalledApp( // save the changes
packageName = app.packageName, if (itemsToRemove.isNotEmpty() or itemsToAdd.isNotEmpty()) {
title = app.title, ApplyActionUtil.applyAppLogicAction(
recommendation = app.recommendation, action = UpdateAppActivitiesAction(
isLaunchable = app.isLaunchable removedActivities = itemsToRemove.map { it.value.appPackageName to it.value.activityClassName },
) updatedOrAddedActivities = itemsToAdd.map { item ->
} AppActivityItem(
), packageName = item.value.appPackageName,
appLogic className = item.value.activityClassName,
) title = item.value.title
)
}
),
appLogic = appLogic,
ignoreIfDeviceIsNotConfigured = true
)
}
} }
} }
} }

View file

@ -104,13 +104,14 @@ class UsedTimeItemBatchUpdateHelper(
// do nothing // do nothing
} else { } else {
ApplyActionUtil.applyAppLogicAction( ApplyActionUtil.applyAppLogicAction(
AddUsedTimeAction( action = AddUsedTimeAction(
categoryId = childCategoryId, categoryId = childCategoryId,
timeToAdd = timeToAdd, timeToAdd = timeToAdd,
dayOfEpoch = date.dayOfEpoch, dayOfEpoch = date.dayOfEpoch,
extraTimeToSubtract = extraTimeToSubtract extraTimeToSubtract = extraTimeToSubtract
), ),
logic appLogic = logic,
ignoreIfDeviceIsNotConfigured = true
) )
timeToAdd = 0 timeToAdd = 0

View file

@ -74,6 +74,24 @@ data class RemoveInstalledAppsAction(val packageNames: List<String>): 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<Pair<String, String>>,
val updatedOrAddedActivities: List<AppActivityItem>
): AppLogicAction() {
init {
if (removedActivities.isEmpty() && updatedOrAddedActivities.isEmpty()) {
throw IllegalArgumentException("empty action")
}
}
}
object SignOutAtDeviceAction: AppLogicAction()
data class AddCategoryAppsAction(val categoryId: String, val packageNames: List<String>): ParentAction() { data class AddCategoryAppsAction(val categoryId: String, val packageNames: List<String>): ParentAction() {
init { init {
IdGenerator.assertIdValid(categoryId) IdGenerator.assertIdValid(categoryId)
@ -126,6 +144,11 @@ data class UpdateCategoryTemporarilyBlockedAction(val categoryId: String, val bl
IdGenerator.assertIdValid(categoryId) 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() { data class SetCategoryForUnassignedApps(val childId: String, val categoryId: String): ParentAction() {
// category id can be empty // category id can be empty
@ -155,16 +178,22 @@ data class UpdateDeviceStatusAction(
val newProtectionLevel: ProtectionLevel?, val newProtectionLevel: ProtectionLevel?,
val newUsageStatsPermissionStatus: RuntimePermissionStatus?, val newUsageStatsPermissionStatus: RuntimePermissionStatus?,
val newNotificationAccessPermission: NewPermissionStatus?, val newNotificationAccessPermission: NewPermissionStatus?,
val newOverlayPermission: RuntimePermissionStatus?,
val newAccessibilityServiceEnabled: Boolean?,
val newAppVersion: Int?, val newAppVersion: Int?,
val didReboot: Boolean val didReboot: Boolean,
val isQOrLaterNow: Boolean
): AppLogicAction() { ): AppLogicAction() {
companion object { companion object {
val empty = UpdateDeviceStatusAction( val empty = UpdateDeviceStatusAction(
newProtectionLevel = null, newProtectionLevel = null,
newUsageStatsPermissionStatus = null, newUsageStatsPermissionStatus = null,
newNotificationAccessPermission = null, newNotificationAccessPermission = null,
newOverlayPermission = null,
newAccessibilityServiceEnabled = null,
newAppVersion = null, newAppVersion = null,
didReboot = false didReboot = false,
isQOrLaterNow = false
) )
} }
@ -182,6 +211,8 @@ data class IgnoreManipulationAction(
val ignoreAppDowngrade: Boolean, val ignoreAppDowngrade: Boolean,
val ignoreNotificationAccessManipulation: Boolean, val ignoreNotificationAccessManipulation: Boolean,
val ignoreUsageStatsAccessManipulation: Boolean, val ignoreUsageStatsAccessManipulation: Boolean,
val ignoreOverlayPermissionManipulation: Boolean,
val ignoreAccessibilityServiceManipulation: Boolean,
val ignoreReboot: Boolean, val ignoreReboot: Boolean,
val ignoreHadManipulation: Boolean val ignoreHadManipulation: Boolean
): ParentAction() { ): 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() { data class SetConsiderRebootManipulationAction(val deviceId: String, val considerRebootManipulation: Boolean): ParentAction() {
init { init {
IdGenerator.assertIdValid(deviceId) 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() { data class UpdateCategoryBlockedTimesAction(val categoryId: String, val blockedTimes: ImmutableBitmask): ParentAction() {
init { init {
IdGenerator.assertIdValid(categoryId) 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 CreateTimeLimitRuleAction(val rule: TimeLimitRule): ParentAction()
data class UpdateTimeLimitRuleAction(val ruleId: String, val dayMask: Byte, val maximumTimeInMillis: Int, val applyToExtraTimeUsage: Boolean): ParentAction() { data class UpdateTimeLimitRuleAction(val ruleId: String, val dayMask: Byte, val maximumTimeInMillis: Int, val applyToExtraTimeUsage: Boolean): ParentAction() {

View file

@ -30,14 +30,40 @@ import io.timelimit.android.sync.actions.dispatch.LocalDatabaseAppLogicActionDis
import io.timelimit.android.sync.actions.dispatch.LocalDatabaseParentActionDispatcher import io.timelimit.android.sync.actions.dispatch.LocalDatabaseParentActionDispatcher
object ApplyActionUtil { object ApplyActionUtil {
suspend fun applyAppLogicAction(action: AppLogicAction, appLogic: AppLogic) { suspend fun applyAppLogicAction(
applyAppLogicAction(action, appLogic.database, appLogic.manipulationLogic) 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 { Threads.database.executeAndWait {
database.transaction().use { 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() database.setTransactionSuccessful()
} }

View file

@ -17,6 +17,7 @@ package io.timelimit.android.sync.actions.dispatch
import io.timelimit.android.data.Database import io.timelimit.android.data.Database
import io.timelimit.android.data.model.App 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.data.model.UsedTimeItem
import io.timelimit.android.integration.platform.NewPermissionStatusUtil import io.timelimit.android.integration.platform.NewPermissionStatusUtil
import io.timelimit.android.integration.platform.ProtectionLevelUtil 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 (action.newAppVersion != null) {
if (device.currentAppVersion != action.newAppVersion) { if (device.currentAppVersion != action.newAppVersion) {
device = device.copy( device = device.copy(
@ -167,6 +204,10 @@ object LocalDatabaseAppLogicActionDispatcher {
) )
} }
if (action.isQOrLaterNow && !device.qOrLater) {
device = device.copy(qOrLater = true)
}
database.device().updateDeviceEntry(device) database.device().updateDeviceEntry(device)
if (device.hasActiveManipulationWarning) { if (device.hasActiveManipulationWarning) {
@ -186,6 +227,52 @@ object LocalDatabaseAppLogicActionDispatcher {
manipulationLogic.lockDeviceSync() 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 null
} }
}.let { } }.let { }

View file

@ -74,7 +74,9 @@ object LocalDatabaseParentActionDispatcher {
blockedMinutesInWeek = ImmutableBitmask(BitSet()), blockedMinutesInWeek = ImmutableBitmask(BitSet()),
extraTimeInMillis = 0, extraTimeInMillis = 0,
temporarilyBlocked = false, temporarilyBlocked = false,
parentCategoryId = "" parentCategoryId = "",
blockAllNotifications = false,
timeWarnings = 0
)) ))
} }
is DeleteCategoryAction -> { is DeleteCategoryAction -> {
@ -271,6 +273,14 @@ object LocalDatabaseParentActionDispatcher {
deviceEntry = deviceEntry.copy(highestUsageStatsPermission = deviceEntry.currentUsageStatsPermission) 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) { if (action.ignoreReboot) {
deviceEntry = deviceEntry.copy(manipulationDidReboot = false) deviceEntry = deviceEntry.copy(manipulationDidReboot = false)
} }
@ -328,6 +338,26 @@ object LocalDatabaseParentActionDispatcher {
timezone = action.timezone 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 -> { is SetConsiderRebootManipulationAction -> {
val deviceEntry = database.device().getDeviceByIdSync(action.deviceId) val deviceEntry = database.device().getDeviceByIdSync(action.deviceId)
?: throw IllegalArgumentException("device not found") ?: 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 { } }.let { }
database.setTransactionSuccessful() database.setTransactionSuccessful()

View file

@ -48,6 +48,8 @@ class MainActivity : AppCompatActivity(), ActivityViewModelHolder {
private val currentNavigatorFragment = MutableLiveData<Fragment>() private val currentNavigatorFragment = MutableLiveData<Fragment>()
override var ignoreStop: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
@ -104,7 +106,7 @@ class MainActivity : AppCompatActivity(), ActivityViewModelHolder {
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
if (!isChangingConfigurations) { if ((!isChangingConfigurations) && (!ignoreStop)) {
getActivityViewModel().logOut() getActivityViewModel().logOut()
} }
} }
@ -112,6 +114,10 @@ class MainActivity : AppCompatActivity(), ActivityViewModelHolder {
override fun onNewIntent(intent: Intent?) { override fun onNewIntent(intent: Intent?) {
super.onNewIntent(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().popBackStack(R.id.overviewFragment, true)
getNavController().handleDeepLink( getNavController().handleDeepLink(
getNavController().createDeepLink() getNavController().createDeepLink()

View file

@ -0,0 +1,115 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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<ContactsViewHolder>() {
companion object {
private const val TYPE_INTRO = 1
private const val TYPE_ITEM = 2
private const val TYPE_ADD = 3
}
var items: List<ContactsItem>? by Delegates.observable(null as List<ContactsItem>?) { _, _, _ -> 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)
}

View file

@ -0,0 +1,226 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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<out String>, 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) }
}
}
}
}

View file

@ -0,0 +1,23 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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()

View file

@ -0,0 +1,66 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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) }
}
}

View file

@ -0,0 +1,113 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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
}
}

View file

@ -37,6 +37,13 @@ class DiagnoseMainFragment : Fragment() {
) )
} }
binding.diagnoseFgaButton.setOnClickListener {
navigation.safeNavigate(
DiagnoseMainFragmentDirections.actionDiagnoseMainFragmentToDiagnoseForegroundAppFragment(),
R.id.diagnoseMainFragment
)
}
return binding.root return binding.root
} }
} }

View file

@ -32,12 +32,18 @@ import io.timelimit.android.ui.main.ActivityViewModelHolder
class LockActivity : AppCompatActivity(), ActivityViewModelHolder { class LockActivity : AppCompatActivity(), ActivityViewModelHolder {
companion object { companion object {
private const val EXTRA_PACKAGE_NAME = "packageName" private const val EXTRA_PACKAGE_NAME = "packageName"
private const val EXTRA_ACTIVITY_NAME = "activityName"
private const val LOGIN_DIALOG_TAG = "loginDialog" private const val LOGIN_DIALOG_TAG = "loginDialog"
fun start(context: Context, packageName: String) { fun start(context: Context, packageName: String, activityName: String?) {
context.startActivity( context.startActivity(
Intent(context, LockActivity::class.java) Intent(context, LockActivity::class.java)
.putExtra(EXTRA_PACKAGE_NAME, packageName) .putExtra(EXTRA_PACKAGE_NAME, packageName)
.apply {
if (activityName != null) {
putExtra(EXTRA_ACTIVITY_NAME, activityName)
}
}
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) .addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION)
@ -45,18 +51,29 @@ class LockActivity : AppCompatActivity(), ActivityViewModelHolder {
} }
} }
override var ignoreStop: Boolean = false
val blockedPackageName: String by lazy { val blockedPackageName: String by lazy {
intent.getStringExtra(EXTRA_PACKAGE_NAME) 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.lock_activity) setContentView(R.layout.lock_activity)
if (savedInstanceState == null) { if (savedInstanceState == null) {
supportFragmentManager.beginTransaction() supportFragmentManager.beginTransaction()
.replace(R.id.container, LockFragment.newInstance(blockedPackageName)) .replace(R.id.container, LockFragment.newInstance(blockedPackageName, blockedActivityName))
.commitNow() .commitNow()
stopMediaPlayback()
} }
} }
@ -83,12 +100,12 @@ class LockActivity : AppCompatActivity(), ActivityViewModelHolder {
override fun onStop() { override fun onStop() {
super.onStop() super.onStop()
if (!isChangingConfigurations) { if ((!isChangingConfigurations) && (!ignoreStop)) {
getActivityViewModel().logOut() getActivityViewModel().logOut()
} }
} }
fun lockTaskModeWorkaround() { private fun lockTaskModeWorkaround() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
val platformIntegration = DefaultAppLogic.with(this).platformIntegration val platformIntegration = DefaultAppLogic.with(this).platformIntegration
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager 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() { override fun onBackPressed() {
// do nothing because going back would open the blocked app again // do nothing because going back would open the blocked app again
// super.onBackPressed() // super.onBackPressed()

View file

@ -16,6 +16,7 @@
package io.timelimit.android.ui.lock package io.timelimit.android.ui.lock
import android.content.Intent import android.content.Intent
import android.database.sqlite.SQLiteConstraintException
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View 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.data.model.UserType
import io.timelimit.android.databinding.LockFragmentBinding import io.timelimit.android.databinding.LockFragmentBinding
import io.timelimit.android.livedata.* import io.timelimit.android.livedata.*
import io.timelimit.android.logic.AppLogic import io.timelimit.android.logic.*
import io.timelimit.android.logic.BlockingReason
import io.timelimit.android.logic.BlockingReasonUtil
import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.sync.actions.AddCategoryAppsAction import io.timelimit.android.sync.actions.AddCategoryAppsAction
import io.timelimit.android.sync.actions.IncrementCategoryExtraTimeAction import io.timelimit.android.sync.actions.IncrementCategoryExtraTimeAction
import io.timelimit.android.sync.actions.UpdateCategoryTemporarilyBlockedAction 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.ManageChildFragmentArgs
import io.timelimit.android.ui.manage.child.advanced.managedisabletimelimits.ManageDisableTimelimitsViewHelper 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.manage.child.category.create.CreateCategoryDialogFragment
import io.timelimit.android.ui.view.SelectTimeSpanViewListener
class LockFragment : Fragment() { class LockFragment : Fragment() {
companion object { companion object {
private const val EXTRA_PACKAGE_NAME = "packageName" 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 result = LockFragment()
val arguments = Bundle() val arguments = Bundle()
arguments.putString(EXTRA_PACKAGE_NAME, packageName) arguments.putString(EXTRA_PACKAGE_NAME, packageName)
if (activity != null) {
arguments.putString(EXTRA_ACTIVITY, activity)
}
result.arguments = arguments result.arguments = arguments
return result 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 auth: ActivityViewModel by lazy { getActivityViewModel(activity!!) }
private val logic: AppLogic by lazy { DefaultAppLogic.with(context!!) } private val logic: AppLogic by lazy { DefaultAppLogic.with(context!!) }
private val title: String? by lazy { logic.platformIntegration.getLocalAppTitle(packageName) } private val title: String? by lazy { logic.platformIntegration.getLocalAppTitle(packageName) }
private val blockingReason: LiveData<BlockingReason> by lazy { BlockingReasonUtil(logic).getBlockingReason(packageName) } private val blockingReason: LiveData<BlockingReasonDetail> by lazy { BlockingReasonUtil(logic).getBlockingReason(packageName, activityName) }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
val binding = LockFragmentBinding.inflate(layoutInflater, container, false) val binding = LockFragmentBinding.inflate(layoutInflater, container, false)
@ -83,8 +93,14 @@ class LockFragment : Fragment() {
doesSupportAuth = liveDataFromValue(true) doesSupportAuth = liveDataFromValue(true)
) )
val enableActivityLevelBlocking = logic.deviceEntry.map { it?.enableActivityLevelBlocking ?: false }
binding.packageName = packageName binding.packageName = packageName
enableActivityLevelBlocking.observe(this, Observer {
binding.activityName = if (it) activityName?.removePrefix(packageName) else null
})
if (title != null) { if (title != null) {
binding.appTitle = title binding.appTitle = title
} else { } else {
@ -94,11 +110,16 @@ class LockFragment : Fragment() {
binding.appIcon.setImageDrawable(logic.platformIntegration.getAppIcon(packageName)) binding.appIcon.setImageDrawable(logic.platformIntegration.getAppIcon(packageName))
blockingReason.observe(this, Observer { blockingReason.observe(this, Observer {
if (it == BlockingReason.None) { when (it) {
activity!!.finish() is NoBlockingReason -> activity!!.finish()
} else { is BlockedReasonDetails -> {
binding.reason = it 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 { val categories = logic.deviceUserEntry.switchMap {
@ -124,13 +145,14 @@ class LockFragment : Fragment() {
} else { } else {
val (_, categoryItems) = status val (_, categoryItems) = status
Transformations.map(logic.database.categoryApp().getCategoryApp( blockingReason.map { reason ->
categoryItems.map { it.id }, if (reason is BlockedReasonDetails) {
packageName reason.categoryId
)) { } else {
appEntry -> null
}
categoryItems.find { it.id == appEntry?.categoryId } }.map { categoryId ->
categoryItems.find { it.id == categoryId }
} }
} }
} }
@ -196,6 +218,8 @@ class LockFragment : Fragment() {
if (extraTimeToAdd > 0) { if (extraTimeToAdd > 0) {
binding.extraTimeBtnOk.isEnabled = false binding.extraTimeBtnOk.isEnabled = false
binding.extraTimeSelection.clearNumberPickerFocus()
val categoryId = appCategory.waitForNullableValue()?.id val categoryId = appCategory.waitForNullableValue()?.id
if (categoryId != null) { 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 // bind disable time limits
logic.deviceUserEntry.observe(this, Observer { logic.deviceUserEntry.observe(this, Observer {
child -> child ->
@ -267,9 +307,16 @@ class LockFragment : Fragment() {
logic.platformIntegration.setSuspendedApps(listOf(packageName), false) logic.platformIntegration.setSuspendedApps(listOf(packageName), false)
Threads.database.executeAndWait(Runnable { Threads.database.executeAndWait(Runnable {
database.temporarilyAllowedApp().addTemporarilyAllowedAppSync(TemporarilyAllowedApp( try {
packageName = packageName 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
}
}) })
} }
} }

View file

@ -37,7 +37,7 @@ class ActivityViewModel(application: Application): AndroidViewModel(application)
private const val LOG_TAG = "ActivityViewModel" private const val LOG_TAG = "ActivityViewModel"
} }
private val logic = DefaultAppLogic.with(application) val logic = DefaultAppLogic.with(application)
private val database = logic.database private val database = logic.database
val shouldHighlightAuthenticationButton = MutableLiveData<Boolean>().apply { value = false } val shouldHighlightAuthenticationButton = MutableLiveData<Boolean>().apply { value = false }
@ -115,6 +115,8 @@ class ActivityViewModel(application: Application): AndroidViewModel(application)
authenticatedUserMetadata.value = user authenticatedUserMetadata.value = user
} }
fun getAuthenticatedUser() = authenticatedUserMetadata.value
fun logOut() { fun logOut() {
authenticatedUserMetadata.value = null authenticatedUserMetadata.value = null
} }

View file

@ -20,6 +20,7 @@ import android.app.Activity
interface ActivityViewModelHolder { interface ActivityViewModelHolder {
fun getActivityViewModel(): ActivityViewModel fun getActivityViewModel(): ActivityViewModel
fun showAuthenticationScreen() fun showAuthenticationScreen()
var ignoreStop: Boolean
} }
fun getActivityViewModel(activity: Activity): ActivityViewModel { fun getActivityViewModel(activity: Activity): ActivityViewModel {

View file

@ -20,6 +20,7 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentTransaction
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.navigation.Navigation 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.ActivityViewModelHolder
import io.timelimit.android.ui.main.AuthenticationFab import io.timelimit.android.ui.main.AuthenticationFab
import io.timelimit.android.ui.main.FragmentWithCustomTitle 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.* import kotlinx.android.synthetic.main.fragment_manage_category.*
class ManageCategoryFragment : Fragment(), FragmentWithCustomTitle { class ManageCategoryFragment : Fragment(), FragmentWithCustomTitle {
@ -47,7 +53,6 @@ class ManageCategoryFragment : Fragment(), FragmentWithCustomTitle {
private val user: LiveData<User?> by lazy { private val user: LiveData<User?> by lazy {
logic.database.user().getUserByIdLive(params.childId) 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 val activity: ActivityViewModelHolder by lazy { getActivity() as ActivityViewModelHolder }
private var wereViewsCreated = false private var wereViewsCreated = false
@ -70,43 +75,28 @@ class ManageCategoryFragment : Fragment(), FragmentWithCustomTitle {
val navigation = Navigation.findNavController(view) val navigation = Navigation.findNavController(view)
pager.adapter = adapter bottom_navigation_view.setOnNavigationItemReselectedListener { /* ignore */ }
bottom_navigation_view.setOnNavigationItemSelectedListener { menuItem ->
bottom_navigation_view.setOnNavigationItemSelectedListener { childFragmentManager.beginTransaction()
menuItem -> .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
.replace(R.id.container, when(menuItem.itemId) {
pager.currentItem = when(menuItem.itemId) { R.id.manage_category_tab_apps -> CategoryAppsFragment.newInstance(params)
R.id.manage_category_tab_apps -> 0 R.id.manage_category_tab_time_limit_rules -> CategoryTimeLimitRulesFragment.newInstance(params)
R.id.manage_category_tab_time_limit_rules -> 1 R.id.manage_category_tab_blocked_time_areas -> BlockedTimeAreasFragment.newInstance(params)
R.id.manage_category_tab_blocked_time_areas -> 2 R.id.manage_category_tab_usage_log -> UsageHistoryFragment.newInstance(params)
R.id.manage_category_tab_usage_log -> 3 R.id.manage_category_tab_settings -> CategorySettingsFragment.newInstance(params)
R.id.manage_category_tab_settings -> 4 else -> throw IllegalStateException()
else -> 0 })
} .commit()
true true
} }
pager.addOnPageChangeListener(object: ViewPager.OnPageChangeListener { if (childFragmentManager.findFragmentById(R.id.container) == null) {
override fun onPageScrollStateChanged(state: Int) { childFragmentManager.beginTransaction()
// ignore .replace(R.id.container, CategoryAppsFragment.newInstance(params))
} .commit()
}
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 (!wereViewsCreated) { if (!wereViewsCreated) {
wereViewsCreated = true wereViewsCreated = true

View file

@ -1,38 +0,0 @@
/*
* Open TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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()
}
}

View file

@ -102,7 +102,7 @@ class AppAdapter: RecyclerView.Adapter<ViewHolder>() {
binding.icon.setImageDrawable( binding.icon.setImageDrawable(
DefaultAppLogic.with(binding.root.context) DefaultAppLogic.with(binding.root.context)
.platformIntegration.getAppIcon(item.packageName) .platformIntegration.getAppIcon(item.packageNameWithoutActivityName)
) )
} }
} }
@ -111,7 +111,7 @@ class AppAdapter: RecyclerView.Adapter<ViewHolder>() {
open class ViewHolder(view: View): RecyclerView.ViewHolder(view) open class ViewHolder(view: View): RecyclerView.ViewHolder(view)
class AppViewHolder(val binding: FragmentCategoryAppsItemBinding): ViewHolder(binding.root) 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 { interface Handlers {
fun onAppClicked(app: AppEntry) fun onAppClicked(app: AppEntry)

View file

@ -42,7 +42,7 @@ class CategoryAppsModel(application: Application): AndroidViewModel(application)
private val appsOfCategoryWithNames = installedApps.switchMap { allApps -> private val appsOfCategoryWithNames = installedApps.switchMap { allApps ->
appsOfThisCategory.map { apps -> appsOfThisCategory.map { apps ->
apps.map { categoryApp -> 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 -> val appEntries = appsOfCategoryWithNames.map { apps ->
apps.map { (app, appEntry) -> apps.map { (app, appEntry) ->
if (appEntry != null) { if (appEntry != null) {
AppEntry(appEntry.title, app.packageName) AppEntry(appEntry.title, app.packageName, app.packageNameWithoutActivityName)
} else { } else {
AppEntry("app not found", app.packageName) AppEntry("app not found", app.packageName, app.packageNameWithoutActivityName)
} }
}.sortedBy { it.title.toLowerCase(Locale.US) } }.sortedBy { it.title.toLowerCase(Locale.US) }
} }

View file

@ -26,6 +26,7 @@ import kotlin.properties.Delegates
class AddAppAdapter: RecyclerView.Adapter<ViewHolder>() { class AddAppAdapter: RecyclerView.Adapter<ViewHolder>() {
var data: List<App>? by Delegates.observable(null as List<App>?) { _, _, _ -> notifyDataSetChanged() } var data: List<App>? by Delegates.observable(null as List<App>?) { _, _, _ -> notifyDataSetChanged() }
var listener: AddAppAdapterListener? = null
var categoryTitleByPackageName: Map<String, String> by Delegates.observable(emptyMap()) { _, _, _ -> notifyDataSetChanged() } var categoryTitleByPackageName: Map<String, String> by Delegates.observable(emptyMap()) { _, _, _ -> notifyDataSetChanged() }
val selectedApps = mutableSetOf<String>() val selectedApps = mutableSetOf<String>()
@ -35,6 +36,8 @@ class AddAppAdapter: RecyclerView.Adapter<ViewHolder>() {
notifyDataSetChanged() notifyDataSetChanged()
} }
override fun onAppLongClicked(app: App) = listener?.onAppLongClicked(app) ?: false
} }
init { init {
@ -86,6 +89,10 @@ class AddAppAdapter: RecyclerView.Adapter<ViewHolder>() {
class ViewHolder(val binding: FragmentAddCategoryAppsItemBinding): RecyclerView.ViewHolder(binding.root) class ViewHolder(val binding: FragmentAddCategoryAppsItemBinding): RecyclerView.ViewHolder(binding.root)
interface ItemHandlers { interface ItemHandlers: AddAppAdapterListener {
fun onAppClicked(app: App) fun onAppClicked(app: App)
} }
interface AddAppAdapterListener {
fun onAppLongClicked(app: App): Boolean
}

View file

@ -19,6 +19,7 @@ package io.timelimit.android.ui.manage.category.apps.add
import android.app.Dialog import android.app.Dialog
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.widget.Toast
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
@ -27,6 +28,7 @@ import androidx.lifecycle.Observer
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import io.timelimit.android.R import io.timelimit.android.R
import io.timelimit.android.data.Database import io.timelimit.android.data.Database
import io.timelimit.android.data.model.App
import io.timelimit.android.data.model.UserType import io.timelimit.android.data.model.UserType
import io.timelimit.android.databinding.FragmentAddCategoryAppsBinding import io.timelimit.android.databinding.FragmentAddCategoryAppsBinding
import io.timelimit.android.extensions.showSafe 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.ActivityViewModel
import io.timelimit.android.ui.main.getActivityViewModel import io.timelimit.android.ui.main.getActivityViewModel
import io.timelimit.android.ui.manage.category.ManageCategoryFragmentArgs 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 import io.timelimit.android.ui.view.AppFilterView
class AddCategoryAppsFragment : DialogFragment() { class AddCategoryAppsFragment : DialogFragment() {
@ -168,6 +171,26 @@ class AddCategoryAppsFragment : DialogFragment() {
adapter.notifyDataSetChanged() 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) return AlertDialog.Builder(context!!, R.style.AppTheme)
.setView(binding.root) .setView(binding.root)
.create() .create()

View file

@ -0,0 +1,148 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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<String>().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)
}

View file

@ -0,0 +1,78 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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<ViewHolder>() {
var data: List<AppActivity>? by Delegates.observable(null as List<AppActivity>?) { _, _, _ -> notifyDataSetChanged() }
val selectedActiviities = mutableSetOf<String>()
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)
}

View file

@ -0,0 +1,57 @@
/*
* Open TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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<Category?>,
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
}
}
}
})
}
}

View file

@ -23,6 +23,7 @@ import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import com.google.android.material.snackbar.Snackbar import com.google.android.material.snackbar.Snackbar
import io.timelimit.android.R import io.timelimit.android.R
import io.timelimit.android.async.Threads
import io.timelimit.android.databinding.FragmentCategorySettingsBinding import io.timelimit.android.databinding.FragmentCategorySettingsBinding
import io.timelimit.android.logic.AppLogic import io.timelimit.android.logic.AppLogic
import io.timelimit.android.logic.DefaultAppLogic 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.ActivityViewModel
import io.timelimit.android.ui.main.getActivityViewModel import io.timelimit.android.ui.main.getActivityViewModel
import io.timelimit.android.ui.manage.category.ManageCategoryFragmentArgs import io.timelimit.android.ui.manage.category.ManageCategoryFragmentArgs
import io.timelimit.android.ui.view.SelectTimeSpanViewListener
class CategorySettingsFragment : Fragment() { class CategorySettingsFragment : Fragment() {
companion object { companion object {
@ -68,6 +70,20 @@ class CategorySettingsFragment : Fragment() {
auth = auth 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.btnDeleteCategory.setOnClickListener { deleteCategory() }
binding.editCategoryTitleGo.setOnClickListener { renameCategory() } binding.editCategoryTitleGo.setOnClickListener { renameCategory() }
@ -82,6 +98,8 @@ class CategorySettingsFragment : Fragment() {
}) })
binding.extraTimeBtnOk.setOnClickListener { binding.extraTimeBtnOk.setOnClickListener {
binding.extraTimeSelection.clearNumberPickerFocus()
val newExtraTime = binding.extraTimeSelection.timeInMillis val newExtraTime = binding.extraTimeSelection.timeInMillis
if ( 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 return binding.root
} }

View file

@ -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<Category?>,
auth: ActivityViewModel
) {
view.linearLayout.removeAllViews()
val durationToCheckbox = mutableMapOf<Long, CheckBox>()
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
}
}
}
}
})
}
}

View file

@ -26,17 +26,22 @@ import androidx.lifecycle.Observer
import com.google.android.material.R import com.google.android.material.R
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialogFragment 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.IdGenerator
import io.timelimit.android.data.model.HintsToShow
import io.timelimit.android.data.model.TimeLimitRule import io.timelimit.android.data.model.TimeLimitRule
import io.timelimit.android.data.model.UserType import io.timelimit.android.data.model.UserType
import io.timelimit.android.databinding.FragmentEditTimeLimitRuleDialogBinding import io.timelimit.android.databinding.FragmentEditTimeLimitRuleDialogBinding
import io.timelimit.android.extensions.showSafe import io.timelimit.android.extensions.showSafe
import io.timelimit.android.livedata.waitForNonNullValue
import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.sync.actions.CreateTimeLimitRuleAction import io.timelimit.android.sync.actions.CreateTimeLimitRuleAction
import io.timelimit.android.sync.actions.DeleteTimeLimitRuleAction import io.timelimit.android.sync.actions.DeleteTimeLimitRuleAction
import io.timelimit.android.sync.actions.UpdateTimeLimitRuleAction import io.timelimit.android.sync.actions.UpdateTimeLimitRuleAction
import io.timelimit.android.ui.main.ActivityViewModel import io.timelimit.android.ui.main.ActivityViewModel
import io.timelimit.android.ui.main.getActivityViewModel 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.SelectDayViewHandlers
import io.timelimit.android.ui.view.SelectTimeSpanViewListener import io.timelimit.android.ui.view.SelectTimeSpanViewListener
import java.nio.ByteBuffer import java.nio.ByteBuffer
@ -84,6 +89,23 @@ class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) 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) existingRule = savedInstanceState?.getParcelable(PARAM_EXISTING_RULE)
?: arguments?.getParcelable<TimeLimitRule?>(PARAM_EXISTING_RULE) ?: arguments?.getParcelable<TimeLimitRule?>(PARAM_EXISTING_RULE)
} }
@ -92,6 +114,7 @@ class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment() {
val view = FragmentEditTimeLimitRuleDialogBinding.inflate(layoutInflater, container, false) val view = FragmentEditTimeLimitRuleDialogBinding.inflate(layoutInflater, container, false)
val listener = targetFragment as EditTimeLimitRuleDialogFragmentListener val listener = targetFragment as EditTimeLimitRuleDialogFragmentListener
var newRule: TimeLimitRule var newRule: TimeLimitRule
val database = DefaultAppLogic.with(context!!).database
auth.authenticatedUser.observe(this, Observer { auth.authenticatedUser.observe(this, Observer {
if (it == null || it.second.type != UserType.Parent) { if (it == null || it.second.type != UserType.Parent) {
@ -135,7 +158,7 @@ class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment() {
view.timeSpan.timeInMillis = newRule.maximumTimeInMillis.toLong() view.timeSpan.timeInMillis = newRule.maximumTimeInMillis.toLong()
val affectedDays = Math.max(0, (0..6).map { (newRule.dayMask.toInt() shr it) and 1 }.sum()) 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 view.affectsMultipleDays = affectedDays >= 2
} }
@ -160,6 +183,8 @@ class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment() {
} }
override fun onSaveRule() { override fun onSaveRule() {
view.timeSpan.clearNumberPickerFocus()
if (existingRule != null) { if (existingRule != null) {
if (existingRule != newRule) { if (existingRule != newRule) {
if (!auth.tryDispatchParentAction( if (!auth.tryDispatchParentAction(
@ -213,10 +238,20 @@ class EditTimeLimitRuleDialogFragment : BottomSheetDialogFragment() {
bindRule() 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) { if (existingRule != null) {
DefaultAppLogic.with(context!!).database.timeLimitRules() database.timeLimitRules()
.getTimeLimitRuleByIdLive(existingRule!!.id).observe(this, Observer { .getTimeLimitRuleByIdLive(existingRule!!.id).observe(this, Observer {
if (it == null) { if (it == null) {
// rule was deleted // rule was deleted

View file

@ -20,6 +20,7 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentTransaction
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.navigation.Navigation 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.ActivityViewModelHolder
import io.timelimit.android.ui.main.AuthenticationFab import io.timelimit.android.ui.main.AuthenticationFab
import io.timelimit.android.ui.main.FragmentWithCustomTitle 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.* import kotlinx.android.synthetic.main.fragment_manage_child.*
class ManageChildFragment : Fragment(), FragmentWithCustomTitle { class ManageChildFragment : Fragment(), FragmentWithCustomTitle {
private val params: ManageChildFragmentArgs by lazy { ManageChildFragmentArgs.fromBundle(arguments!!) } 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 logic: AppLogic by lazy { DefaultAppLogic.with(context!!) }
private val child: LiveData<User?> by lazy { logic.database.user().getUserByIdLive(params.childId) } private val child: LiveData<User?> by lazy { logic.database.user().getUserByIdLive(params.childId) }
private val activity: ActivityViewModelHolder by lazy { getActivity() as ActivityViewModelHolder } private val activity: ActivityViewModelHolder by lazy { getActivity() as ActivityViewModelHolder }
@ -74,39 +77,26 @@ class ManageChildFragment : Fragment(), FragmentWithCustomTitle {
}) })
} }
pager.adapter = adapter bottom_navigation_view.setOnNavigationItemReselectedListener { /* ignore */ }
bottom_navigation_view.setOnNavigationItemSelectedListener { menuItem ->
bottom_navigation_view.setOnNavigationItemSelectedListener { childFragmentManager.beginTransaction()
menuItem -> .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_FADE)
.replace(R.id.container, when (menuItem.itemId) {
pager.currentItem = when (menuItem.itemId) { R.id.manage_child_tab_categories -> ManageChildCategoriesFragment.newInstance(params)
R.id.manage_child_tab_categories -> 0 R.id.manage_child_tab_apps -> ChildAppsFragment.newInstance(params)
R.id.manage_child_tab_apps -> 1 R.id.manage_child_tab_manage -> ManageChildAdvancedFragment.newInstance(params)
R.id.manage_child_tab_manage -> 2 else -> throw IllegalStateException()
else -> 0 })
} .commit()
true true
} }
pager.addOnPageChangeListener(object: ViewPager.OnPageChangeListener { if (childFragmentManager.findFragmentById(R.id.container) == null) {
override fun onPageScrollStateChanged(state: Int) { childFragmentManager.beginTransaction()
// ignore .replace(R.id.container, ManageChildCategoriesFragment.newInstance(params))
} .commit()
}
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()
}
}
})
} }
override fun getCustomTitle() = child.map { it?.name } override fun getCustomTitle() = child.map { it?.name }

View file

@ -1,33 +0,0 @@
/*
* Open TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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()
}
}

View file

@ -91,7 +91,10 @@ class ManageChildCategoriesFragment : Fragment() {
ItemTouchHelper(object: ItemTouchHelper.Callback() { ItemTouchHelper(object: ItemTouchHelper.Callback() {
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { 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 return makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE, ItemTouchHelper.END) or
makeFlag(ItemTouchHelper.ACTION_STATE_IDLE, ItemTouchHelper.END) makeFlag(ItemTouchHelper.ACTION_STATE_IDLE, ItemTouchHelper.END)
} else { } else {

View file

@ -0,0 +1,42 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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<User?>,
device: LiveData<Device?>,
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
})
}
}

View file

@ -35,6 +35,7 @@ import androidx.navigation.Navigation
import io.timelimit.android.R import io.timelimit.android.R
import io.timelimit.android.data.model.Device import io.timelimit.android.data.model.Device
import io.timelimit.android.databinding.FragmentManageDeviceBinding 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.ProtectionLevel
import io.timelimit.android.integration.platform.android.AdminReceiver import io.timelimit.android.integration.platform.android.AdminReceiver
import io.timelimit.android.livedata.liveDataFromValue 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.ActivityViewModelHolder
import io.timelimit.android.ui.main.AuthenticationFab import io.timelimit.android.ui.main.AuthenticationFab
import io.timelimit.android.ui.main.FragmentWithCustomTitle 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 { class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle {
private val activity: ActivityViewModelHolder by lazy { getActivity() as ActivityViewModelHolder } private val activity: ActivityViewModelHolder by lazy { getActivity() as ActivityViewModelHolder }
@ -70,10 +73,6 @@ class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle {
activityViewModel = auth activityViewModel = auth
) )
val userSpinnerAdapter = ArrayAdapter<String>(context!!, android.R.layout.simple_spinner_item).apply {
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
}
// auth // auth
AuthenticationFab.manageAuthenticationFab( AuthenticationFab.manageAuthenticationFab(
fab = binding.fab, fab = binding.fab,
@ -83,89 +82,41 @@ class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle {
doesSupportAuth = liveDataFromValue(true) doesSupportAuth = liveDataFromValue(true)
) )
// label, id
val userListItems = ArrayList<Pair<String, String>>()
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 { binding.handlers = object: ManageDeviceFragmentHandlers {
override fun openUsageStatsSettings() { override fun showUserScreen() {
if (binding.isThisDevice == true) { navigation.safeNavigate(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { ManageDeviceFragmentDirections.actionManageDeviceFragmentToManageDeviceUserFragment(
startActivity( deviceId = args.deviceId
Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS) ),
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) R.id.manageDeviceFragment
) )
}
}
} }
override fun openNotificationAccessSettings() { override fun showPermissionsScreen() {
if (binding.isThisDevice == true) { navigation.safeNavigate(
try { ManageDeviceFragmentDirections.actionManageDeviceFragmentToManageDevicePermissionsFragment(
startActivity( deviceId = args.deviceId
Intent("android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS") ),
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) R.id.manageDeviceFragment
) )
} catch (ex: Exception) {
Toast.makeText(
context,
R.string.error_general,
Toast.LENGTH_SHORT
).show()
}
}
} }
override fun manageDeviceAdmin() { override fun showFeaturesScreen() {
if (binding.isThisDevice == true) { navigation.safeNavigate(
val protectionLevel = logic.platformIntegration.getCurrentProtectionLevel() ManageDeviceFragmentDirections.actionManageDeviceFragmentToManageDeviceFeaturesFragment(
deviceId = args.deviceId
if (protectionLevel == ProtectionLevel.None) { ),
if (InformAboutDeviceOwnerDialogFragment.shouldShow) { R.id.manageDeviceFragment
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 editDeviceTitle() { override fun showManageScreen() {
if (auth.requestAuthenticationOrReturnTrue()) { navigation.safeNavigate(
UpdateDeviceTitleDialogFragment.newInstance(args.deviceId).show(fragmentManager!!) ManageDeviceFragmentDirections.actionManageDeviceFragmentToManageDeviceAdvancedFragment(
} deviceId = args.deviceId
),
R.id.manageDeviceFragment
)
} }
override fun showAuthenticationScreen() { 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 { deviceEntry.observe(this, Observer {
device -> device ->
@ -207,7 +132,6 @@ class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle {
} else { } else {
val now = logic.timeApi.getCurrentTimeInMillis() val now = logic.timeApi.getCurrentTimeInMillis()
binding.deviceTitle = device.name
binding.modelString = device.model binding.modelString = device.model
binding.addedAtString = getString(R.string.manage_device_added_at, DateUtils.getRelativeTimeSpanString( binding.addedAtString = getString(R.string.manage_device_added_at, DateUtils.getRelativeTimeSpanString(
device.addedAt, device.addedAt,
@ -215,25 +139,9 @@ class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle {
DateUtils.HOUR_IN_MILLIS DateUtils.HOUR_IN_MILLIS
)) ))
binding.usageStatsAccess = device.currentUsageStatsPermission
binding.notificationAccessPermission = device.currentNotificationAccessPermission
binding.protectionLevel = device.currentProtectionLevel
binding.didAppDowngrade = device.currentAppVersion < device.highestAppVersion binding.didAppDowngrade = device.currentAppVersion < device.highestAppVersion
} binding.permissionCardText = ManageDevicePermissionsFragment.getPreviewText(device, context!!)
}) binding.featureCardText = ManageDeviceFeaturesFragment.getPreviewText(device, context!!)
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()
} }
}) })
@ -264,35 +172,27 @@ class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle {
user = userEntry user = userEntry
) )
ManageDeviceTroubleshooting.bind( ActivityLaunchPermissionRequiredAndMissing.bind(
view = binding.troubleshootingView, view = binding.activityLaunchPermissionMissing,
userEntry = userEntry, lifecycleOwner = this,
lifecycleOwner = this device = deviceEntry,
user = userEntry
) )
ManageDeviceRebootManipulationView.bind( userEntry.observe(this, Observer {
view = binding.deviceRebootManipulation, binding.userCardText = it?.name ?: getString(R.string.manage_device_current_user_none)
lifecycleOwner = this, })
deviceEntry = deviceEntry,
auth = auth
)
return binding.root return binding.root
} }
override fun onResume() {
super.onResume()
logic.backgroundTaskLogic.syncDeviceStatusAsync()
}
override fun getCustomTitle() = deviceEntry.map { it?.name } override fun getCustomTitle() = deviceEntry.map { it?.name }
} }
interface ManageDeviceFragmentHandlers { interface ManageDeviceFragmentHandlers {
fun openUsageStatsSettings() fun showUserScreen()
fun openNotificationAccessSettings() fun showPermissionsScreen()
fun manageDeviceAdmin() fun showFeaturesScreen()
fun editDeviceTitle() fun showManageScreen()
fun showAuthenticationScreen() fun showAuthenticationScreen()
} }

View file

@ -41,6 +41,8 @@ object ManageDeviceManipulation {
binding.hasManipulatedDeviceAdmin = device?.manipulationOfProtectionLevel ?: false binding.hasManipulatedDeviceAdmin = device?.manipulationOfProtectionLevel ?: false
binding.hasManipulatedUsageStatsAccess = device?.manipulationOfUsageStats ?: false binding.hasManipulatedUsageStatsAccess = device?.manipulationOfUsageStats ?: false
binding.hasManipulatedNotificationAccess = device?.manipulationOfNotificationAccess ?: false binding.hasManipulatedNotificationAccess = device?.manipulationOfNotificationAccess ?: false
binding.hasManipulatedOverlayPermission = device?.manipulationOfOverlayPermission ?: false
binding.hasManipulatedAccessibilityService = device?.manipulationOfAccessibilityService ?: false
binding.hasManipulationReboot = device?.manipulationDidReboot ?: false binding.hasManipulationReboot = device?.manipulationDidReboot ?: false
binding.hasHadManipulation = (device?.hadManipulation ?: false) and (! (device?.hasActiveManipulationWarning ?: false)) binding.hasHadManipulation = (device?.hadManipulation ?: false) and (! (device?.hasActiveManipulationWarning ?: false))
binding.hasAnyManipulation = device?.hasAnyManipulation ?: false binding.hasAnyManipulation = device?.hasAnyManipulation ?: false
@ -62,6 +64,8 @@ object ManageDeviceManipulation {
binding.deviceAdminDisabledCheckbox, binding.deviceAdminDisabledCheckbox,
binding.usageAccessCheckbox, binding.usageAccessCheckbox,
binding.notificationAccessCheckbox, binding.notificationAccessCheckbox,
binding.overlayPermissionCheckbox,
binding.accessibilityServiceCheckbox,
binding.rebootCheckbox, binding.rebootCheckbox,
binding.hadManipulationCheckbox binding.hadManipulationCheckbox
) )
@ -80,6 +84,8 @@ object ManageDeviceManipulation {
ignoreNotificationAccessManipulation = binding.notificationAccessCheckbox.isChecked && binding.hasManipulatedNotificationAccess == true, ignoreNotificationAccessManipulation = binding.notificationAccessCheckbox.isChecked && binding.hasManipulatedNotificationAccess == true,
ignoreDeviceAdminManipulationAttempt = binding.deviceAdminDisableAttemptCheckbox.isChecked && binding.hasTriedManipulatingDeviceAdmin == true, ignoreDeviceAdminManipulationAttempt = binding.deviceAdminDisableAttemptCheckbox.isChecked && binding.hasTriedManipulatingDeviceAdmin == true,
ignoreDeviceAdminManipulation = binding.deviceAdminDisabledCheckbox.isChecked && binding.hasManipulatedDeviceAdmin == 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, ignoreAppDowngrade = binding.appVersionCheckbox.isChecked && binding.hasManipulatedAppVersion == true,
ignoreReboot = binding.rebootCheckbox.isChecked && binding.hasManipulationReboot == true, ignoreReboot = binding.rebootCheckbox.isChecked && binding.hasManipulationReboot == true,
ignoreHadManipulation = binding.hadManipulationCheckbox.isChecked || ( ignoreHadManipulation = binding.hadManipulationCheckbox.isChecked || (

View file

@ -18,20 +18,23 @@ package io.timelimit.android.ui.manage.device.manage
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import io.timelimit.android.R
import io.timelimit.android.data.model.Device import io.timelimit.android.data.model.Device
import io.timelimit.android.data.model.User import io.timelimit.android.data.model.User
import io.timelimit.android.data.model.UserType 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.integration.platform.RuntimePermissionStatus
import io.timelimit.android.livedata.mergeLiveData import io.timelimit.android.livedata.mergeLiveData
object UsageStatsAccessRequiredAndMissing { object UsageStatsAccessRequiredAndMissing {
fun bind( fun bind(
view: UsageStatsPermissionRequiredAndMissingBinding, view: MissingPermissionViewBinding,
user: LiveData<User?>, user: LiveData<User?>,
device: LiveData<Device?>, device: LiveData<Device?>,
lifecycleOwner: LifecycleOwner 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) -> mergeLiveData(user, device).observe(lifecycleOwner, Observer { (user, device) ->
view.showMessage = user?.type == UserType.Child && device?.currentUsageStatsPermission == RuntimePermissionStatus.NotGranted view.showMessage = user?.type == UserType.Child && device?.currentUsageStatsPermission == RuntimePermissionStatus.NotGranted
}) })

View file

@ -0,0 +1,35 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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)
}
}
}
}

View file

@ -0,0 +1,103 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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<Device?> 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()
}

View file

@ -13,7 +13,7 @@
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package io.timelimit.android.ui.manage.device.manage package io.timelimit.android.ui.manage.device.manage.advanced
import android.text.method.LinkMovementMethod import android.text.method.LinkMovementMethod
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner

View file

@ -13,7 +13,7 @@
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package io.timelimit.android.ui.manage.device.manage package io.timelimit.android.ui.manage.device.manage.advanced
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View

View file

@ -0,0 +1,102 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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<List<User>>,
lifecycleOwner: LifecycleOwner,
device: LiveData<Device?>,
isThisDevice: LiveData<Boolean>,
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
)
}
}
}
}

View file

@ -0,0 +1,131 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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)
}

View file

@ -0,0 +1,125 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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<Device?> 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)
}

View file

@ -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<Device?>,
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
}
}
}
})
}
}

View file

@ -0,0 +1,122 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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<String>()
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<Device?> 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()
}

View file

@ -13,7 +13,7 @@
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package io.timelimit.android.ui.manage.device.manage package io.timelimit.android.ui.manage.device.manage.feature
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData

View file

@ -13,7 +13,7 @@
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package io.timelimit.android.ui.manage.device.manage package io.timelimit.android.ui.manage.device.manage.permission
import android.app.Dialog import android.app.Dialog
import android.app.admin.DevicePolicyManager import android.app.admin.DevicePolicyManager

View file

@ -0,0 +1,222 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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<String>()
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<Device?> 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()
}

View file

@ -0,0 +1,182 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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<Device?> 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<Pair<String, String>>()
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()
}

View file

@ -36,6 +36,8 @@ class UnlockAfterManipulationActivity : AppCompatActivity(), ActivityViewModelHo
ViewModelProviders.of(this).get(ActivityViewModel::class.java) ViewModelProviders.of(this).get(ActivityViewModel::class.java)
} }
override var ignoreStop: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_unlock_after_manipulation) setContentView(R.layout.activity_unlock_after_manipulation)

View file

@ -0,0 +1,71 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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)
}

View file

@ -0,0 +1,36 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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<Int>()
val timer = timerInternal.castDown()
init {
runAsync {
for (i in 10 downTo 0) {
timerInternal.value = i
delay(1000)
}
}
}
}

View file

@ -20,6 +20,7 @@ import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentTransaction
import androidx.lifecycle.MutableLiveData import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.navigation.NavController import androidx.navigation.NavController
@ -35,10 +36,14 @@ import io.timelimit.android.livedata.switchMap
import io.timelimit.android.livedata.waitForNullableValue import io.timelimit.android.livedata.waitForNullableValue
import io.timelimit.android.logic.AppLogic import io.timelimit.android.logic.AppLogic
import io.timelimit.android.logic.DefaultAppLogic 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.ActivityViewModelHolder
import io.timelimit.android.ui.main.AuthenticationFab 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.about.AboutFragmentParentHandlers
import io.timelimit.android.ui.overview.overview.OverviewFragment
import io.timelimit.android.ui.overview.overview.OverviewFragmentParentHandlers import io.timelimit.android.ui.overview.overview.OverviewFragmentParentHandlers
import io.timelimit.android.ui.overview.uninstall.UninstallFragment
import kotlinx.android.synthetic.main.fragment_main.* import kotlinx.android.synthetic.main.fragment_main.*
class MainFragment : Fragment(), OverviewFragmentParentHandlers, AboutFragmentParentHandlers { class MainFragment : Fragment(), OverviewFragmentParentHandlers, AboutFragmentParentHandlers {
@ -79,7 +84,7 @@ class MainFragment : Fragment(), OverviewFragmentParentHandlers, AboutFragmentPa
} }
}.observe(this, Observer { shouldShowSetup -> }.observe(this, Observer { shouldShowSetup ->
if (shouldShowSetup == true) { if (shouldShowSetup == true) {
pager.post { fab.post {
navigation.safeNavigate( navigation.safeNavigate(
MainFragmentDirections.actionOverviewFragmentToSetupTermsFragment(), MainFragmentDirections.actionOverviewFragmentToSetupTermsFragment(),
R.id.overviewFragment R.id.overviewFragment
@ -103,52 +108,41 @@ class MainFragment : Fragment(), OverviewFragmentParentHandlers, AboutFragmentPa
}) })
} }
pager.adapter = adapter fun updateShowFab(selectedItemId: Int) {
showAuthButtonLive.value = when (selectedItemId) {
bottom_navigation_view.setOnNavigationItemSelectedListener { R.id.main_tab_overview -> true
menuItem -> R.id.main_tab_contacts -> true
R.id.main_tab_uninstall -> true
pager.currentItem = when(menuItem.itemId) { R.id.main_tab_about -> false
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
else -> throw IllegalStateException() 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 { updateShowFab(menuItem.itemId)
override fun onPageScrollStateChanged(state: Int) {
// ignore
}
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) { true
// ignore }
}
override fun onPageSelected(position: Int) { if (childFragmentManager.findFragmentById(R.id.container) == null) {
updateShowFab(position) childFragmentManager.beginTransaction()
.replace(R.id.container, OverviewFragment())
.commit()
}
bottom_navigation_view.selectedItemId = when(pager.currentItem) { updateShowFab(bottom_navigation_view.selectedItemId)
0 -> R.id.main_tab_overview
1 -> R.id.main_tab_uninstall
2 -> R.id.main_tab_about
else -> throw IllegalStateException()
}
}
})
} }
override fun openAddUserScreen() { override fun openAddUserScreen() {

View file

@ -80,7 +80,10 @@ class OverviewFragment : CoroutineFragment() {
ItemTouchHelper( ItemTouchHelper(
object: ItemTouchHelper.Callback() { object: ItemTouchHelper.Callback() {
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int { 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 return makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE, ItemTouchHelper.END) or
makeFlag(ItemTouchHelper.ACTION_STATE_IDLE, ItemTouchHelper.END) makeFlag(ItemTouchHelper.ACTION_STATE_IDLE, ItemTouchHelper.END)
} else { } else {

View file

@ -24,7 +24,9 @@ sealed class OverviewFragmentItem
object OverviewFragmentHeaderUsers: OverviewFragmentItem() object OverviewFragmentHeaderUsers: OverviewFragmentItem()
object OverviewFragmentHeaderDevices: OverviewFragmentItem() object OverviewFragmentHeaderDevices: OverviewFragmentItem()
data class OverviewFragmentItemDevice(val device: Device, val deviceUser: User?, val isCurrentDevice: Boolean): 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() data class OverviewFragmentItemUser(val user: User, val temporarilyBlocked: Boolean, val limitsTemporarilyDisabled: Boolean): OverviewFragmentItem()
object OverviewFragmentActionAddUser: OverviewFragmentItem() object OverviewFragmentActionAddUser: OverviewFragmentItem()

View file

@ -18,6 +18,7 @@ package io.timelimit.android.ui.setup
import android.app.admin.DevicePolicyManager import android.app.admin.DevicePolicyManager
import android.content.ComponentName import android.content.ComponentName
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.Settings 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.integration.platform.android.AdminReceiver
import io.timelimit.android.logic.AppLogic import io.timelimit.android.logic.AppLogic
import io.timelimit.android.logic.DefaultAppLogic 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() { class SetupDevicePermissionsFragment : Fragment() {
private val logic: AppLogic by lazy { DefaultAppLogic.with(context!!) } 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() { override fun gotoNextStep() {
navigation.safeNavigate( navigation.safeNavigate(
SetupDevicePermissionsFragmentDirections SetupDevicePermissionsFragmentDirections
@ -113,6 +130,8 @@ class SetupDevicePermissionsFragment : Fragment() {
binding.notificationAccessPermission = platform.getNotificationAccessPermissionStatus() binding.notificationAccessPermission = platform.getNotificationAccessPermissionStatus()
binding.protectionLevel = platform.getCurrentProtectionLevel() binding.protectionLevel = platform.getCurrentProtectionLevel()
binding.usageStatsAccess = platform.getForegroundAppPermissionStatus() binding.usageStatsAccess = platform.getForegroundAppPermissionStatus()
binding.overlayPermission = platform.getOverlayPermissionStatus()
binding.accessibilityServiceEnabled = platform.isAccessibilityServiceEnabled()
} }
override fun onResume() { override fun onResume() {
@ -126,5 +145,7 @@ interface SetupDevicePermissionsHandlers {
fun manageDeviceAdmin() fun manageDeviceAdmin()
fun openUsageStatsSettings() fun openUsageStatsSettings()
fun openNotificationAccessSettings() fun openNotificationAccessSettings()
fun openDrawOverOtherAppsScreen()
fun openAccessibilitySettings()
fun gotoNextStep() fun gotoNextStep()
} }

View file

@ -95,7 +95,7 @@ class AddUserModel(application: Application): AndroidViewModel(application) {
) )
)) ))
defaultCategories.generateGamesTimeLimitRules(allowedAppsCategory).forEach { rule -> defaultCategories.generateGamesTimeLimitRules(allowedGamesCategory).forEach { rule ->
actions.add(CreateTimeLimitRuleAction(rule)) actions.add(CreateTimeLimitRuleAction(rule))
} }

View file

@ -18,6 +18,7 @@ package io.timelimit.android.ui.view
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.SeekBar import android.widget.SeekBar
import io.timelimit.android.R import io.timelimit.android.R
@ -34,14 +35,16 @@ class SelectTimeSpanView(context: Context, attributeSet: AttributeSet): FrameLay
var listener: SelectTimeSpanViewListener? = null var listener: SelectTimeSpanViewListener? = null
var timeInMillis: Long by Delegates.observable(0L) { var timeInMillis: Long by Delegates.observable(0L) { _, _, _ ->
_, _, _ -> bindTime()
bindTime() listener?.onTimeSpanChanged(timeInMillis)
listener?.onTimeSpanChanged(timeInMillis)
} }
var maxDays: Int by Delegates.observable(0) { var maxDays: Int by Delegates.observable(0) { _, _, _ ->
_, _, _ -> binding.maxDays = maxDays binding.maxDays = maxDays
binding.dayPicker.maxValue = maxDays
binding.dayPickerContainer.visibility = if (maxDays > 0) View.VISIBLE else View.GONE
} }
init { init {
@ -69,6 +72,10 @@ class SelectTimeSpanView(context: Context, attributeSet: AttributeSet): FrameLay
binding.daysText = TimeTextUtil.days(totalDays, context!!) binding.daysText = TimeTextUtil.days(totalDays, context!!)
binding.minutesText = TimeTextUtil.minutes(minutes, context!!) binding.minutesText = TimeTextUtil.minutes(minutes, context!!)
binding.hoursText = TimeTextUtil.hours(hours, 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() { private fun readStatusFromBinding() {
@ -79,7 +86,43 @@ class SelectTimeSpanView(context: Context, attributeSet: AttributeSet): FrameLay
timeInMillis = (((days * 24) + hours) * 60 + minutes) * 1000 * 60 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 { 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 { binding.daysSeek.setOnSeekBarChangeListener(object: SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) { override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
binding.days = progress binding.days = progress
@ -124,9 +167,15 @@ class SelectTimeSpanView(context: Context, attributeSet: AttributeSet): FrameLay
// ignore // ignore
} }
}) })
binding.pickerContainer.visibility = GONE
binding.switchToPickerButton.setOnClickListener { listener?.setEnablePickerMode(true) }
binding.switchToSeekbarButton.setOnClickListener { listener?.setEnablePickerMode(false) }
} }
} }
interface SelectTimeSpanViewListener { interface SelectTimeSpanViewListener {
fun onTimeSpanChanged(newTimeInMillis: Long) fun onTimeSpanChanged(newTimeInMillis: Long)
fun setEnablePickerMode(enable: Boolean)
} }

View file

@ -0,0 +1,22 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.util
import android.os.Build
object AndroidVersion {
val qOrLater = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
}

View file

@ -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');
}}

Some files were not shown because too many files have changed in this diff Show more