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
- open a ticket here at GitLab
- alternativly, send a message to support@timelimit.io
- alternatively, send a message to support@timelimit.io
## merge requests
This App and the proprietary TimeLimit App are developed by the same developer who prefers to keep them similar to make the maintance easier.
Due to that, merge requests are not wanted to avoid licensing issues when adding something from a merge request to the proprietary version.
Are possible but only after talking with the developer before developing anything.

View file

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

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.VIBRATE" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- suppress DeprecatedClassUsageInspection -->
<uses-permission
android:name="android.permission.GET_TASKS"
@ -29,11 +30,14 @@
tools:ignore="ProtectedPermissions" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<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
android:name=".Application"
android:allowBackup="false"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/AppTheme">
@ -111,6 +115,19 @@
</intent-filter>
</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>
</manifest>

View file

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

View file

@ -22,4 +22,28 @@ object DatabaseMigrations {
database.execSQL("ALTER TABLE `device` ADD COLUMN `consider_reboot_manipulation` INTEGER NOT NULL DEFAULT 0")
}
}
val MIGRATE_TO_V5 = object: Migration(4, 5) {
override fun migrate(database: SupportSQLiteDatabase) {
// device table
database.execSQL("ALTER TABLE `device` ADD COLUMN `current_overlay_permission` TEXT NOT NULL DEFAULT \"not granted\"")
database.execSQL("ALTER TABLE `device` ADD COLUMN `highest_overlay_permission` TEXT NOT NULL DEFAULT \"not granted\"")
database.execSQL("ALTER TABLE `device` ADD COLUMN `current_accessibility_service_permission` INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE `device` ADD COLUMN `was_accessibility_service_permission` INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE `device` ADD COLUMN `enable_activity_level_blocking` INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE `device` ADD COLUMN `q_or_later` INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE `device` ADD COLUMN `default_user` TEXT NOT NULL DEFAULT \"\"")
database.execSQL("ALTER TABLE `device` ADD COLUMN `default_user_timeout` INTEGER NOT NULL DEFAULT 0")
// category table
database.execSQL("ALTER TABLE `category` ADD COLUMN `block_all_notifications` INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE `category` ADD COLUMN `time_warnings` INTEGER NOT NULL DEFAULT 0")
// app_activity table
database.execSQL("CREATE TABLE IF NOT EXISTS `app_activity` (`device_id` TEXT NOT NULL, `app_package_name` TEXT NOT NULL, `activity_class_name` TEXT NOT NULL, `activity_title` TEXT NOT NULL, PRIMARY KEY(`device_id`, `app_package_name`, `activity_class_name`))")
// allowed_contact table
database.execSQL("CREATE TABLE IF NOT EXISTS `allowed_contact` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `phone` TEXT NOT NULL)")
}
}
}

View file

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

View file

@ -37,6 +37,8 @@ object DatabaseBackupLowlevel {
private const val TIME_LIMIT_RULE = "timelimitRule"
private const val USED_TIME_ITEM = "usedTime"
private const val USER = "user"
private const val APP_ACTIVITY = "appActivity"
private const val ALLOWED_CONTACT = "allowedContact"
fun outputAsBackupJson(database: Database, outputStream: OutputStream) {
val writer = JsonWriter(OutputStreamWriter(outputStream, Charsets.UTF_8))
@ -77,6 +79,9 @@ object DatabaseBackupLowlevel {
handleCollection(TIME_LIMIT_RULE) { offset, pageSize -> database.timeLimitRules().getRulePageSync(offset, pageSize) }
handleCollection(USED_TIME_ITEM) { offset, pageSize -> database.usedTimes().getUsedTimePageSync(offset, pageSize) }
handleCollection(USER) { offset, pageSize -> database.user().getUserPageSync(offset, pageSize) }
handleCollection(APP_ACTIVITY) { offset, pageSize -> database.appActivity().getAppActivityPageSync(offset, pageSize) }
handleCollection(ALLOWED_CONTACT) { offset, pageSize -> database.allowedContact().getAllowedContactPageSync(offset, pageSize) }
writer.endObject().flush()
}
@ -168,6 +173,27 @@ object DatabaseBackupLowlevel {
reader.endArray()
}
APP_ACTIVITY -> {
reader.beginArray()
while (reader.hasNext()) {
database.appActivity().addAppActivitySync(AppActivity.parse(reader))
}
reader.endArray()
}
ALLOWED_CONTACT -> {
reader.beginArray()
while (reader.hasNext()) {
database.allowedContact().addContactSync(
// this will use an unused id
AllowedContact.parse(reader).copy(id = 0)
)
}
reader.endArray()
}
else -> reader.skipValue()
}
}

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")
abstract fun updateParentCategory(categoryId: String, parentCategoryId: String)
@Update
abstract fun updateCategorySync(category: Category)
}
data class CategoryShortInfo(

View file

@ -112,4 +112,19 @@ abstract class ConfigDao {
fun wasDeviceLockedSync() = getValueOfKeySync(ConfigurationItemType.WasDeviceLocked) == "true"
fun setWasDeviceLockedSync(value: Boolean) = updateValueSync(ConfigurationItemType.WasDeviceLocked, if (value) "true" else "false")
fun getForegroundAppQueryIntervalAsync(): LiveData<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")
abstract fun updateDeviceUser(deviceId: String, userId: String)
@Query("UPDATE device SET default_user = :defaultUserId WHERE id = :deviceId")
abstract fun updateDeviceDefaultUser(deviceId: String, defaultUserId: String)
@Update
abstract fun updateDeviceEntry(device: Device)

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")
val temporarilyBlocked: Boolean,
@ColumnInfo(name = "parent_category_id")
val parentCategoryId: String
val parentCategoryId: String,
@ColumnInfo(name = "block_all_notifications")
val blockAllNotifications: Boolean,
@ColumnInfo(name = "time_warnings")
val timeWarnings: Int
): JsonSerializable {
companion object {
const val MINUTES_PER_DAY = 60 * 24
@ -57,6 +61,8 @@ data class Category(
private const val EXTRA_TIME_IN_MILLIS = "extraTimeInMillis"
private const val TEMPORARILY_BLOCKED = "temporarilyBlocked"
private const val PARENT_CATEGORY_ID = "parentCategoryId"
private const val BlOCK_ALL_NOTIFICATIONS = "blockAllNotifications"
private const val TIME_WARNINGS = "timeWarnings"
fun parse(reader: JsonReader): Category {
var id: String? = null
@ -67,6 +73,8 @@ data class Category(
var temporarilyBlocked: Boolean? = null
// this field was added later so it has got a default value
var parentCategoryId = ""
var blockAllNotifications = false
var timeWarnings = 0
reader.beginObject()
@ -79,6 +87,8 @@ data class Category(
EXTRA_TIME_IN_MILLIS -> extraTimeInMillis = reader.nextLong()
TEMPORARILY_BLOCKED -> temporarilyBlocked = reader.nextBoolean()
PARENT_CATEGORY_ID -> parentCategoryId = reader.nextString()
BlOCK_ALL_NOTIFICATIONS -> blockAllNotifications = reader.nextBoolean()
TIME_WARNINGS -> timeWarnings = reader.nextInt()
else -> reader.skipValue()
}
}
@ -92,7 +102,9 @@ data class Category(
blockedMinutesInWeek = blockedMinutesInWeek!!,
extraTimeInMillis = extraTimeInMillis!!,
temporarilyBlocked = temporarilyBlocked!!,
parentCategoryId = parentCategoryId
parentCategoryId = parentCategoryId,
blockAllNotifications = blockAllNotifications,
timeWarnings = timeWarnings
)
}
}
@ -120,7 +132,22 @@ data class Category(
writer.name(EXTRA_TIME_IN_MILLIS).value(extraTimeInMillis)
writer.name(TEMPORARILY_BLOCKED).value(temporarilyBlocked)
writer.name(PARENT_CATEGORY_ID).value(parentCategoryId)
writer.name(BlOCK_ALL_NOTIFICATIONS).value(blockAllNotifications)
writer.name(TIME_WARNINGS).value(timeWarnings)
writer.endObject()
}
}
object CategoryTimeWarnings {
val durationToBitIndex = mapOf(
1000L * 60 to 0, // 1 minute
1000L * 60 * 3 to 1, // 3 minutes
1000L * 60 * 5 to 2, // 5 minutes
1000L * 60 * 10 to 3, // 10 minutes
1000L * 60 * 15 to 4 // 15 minutes
)
val durations = durationToBitIndex.keys
}

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

View file

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

View file

@ -65,8 +65,24 @@ data class Device(
val manipulationDidReboot: Boolean,
@ColumnInfo(name = "had_manipulation")
val hadManipulation: Boolean,
@ColumnInfo(name = "default_user")
val defaultUser: String,
@ColumnInfo(name = "default_user_timeout")
val defaultUserTimeout: Int,
@ColumnInfo(name = "consider_reboot_manipulation")
val considerRebootManipulation: Boolean
val considerRebootManipulation: Boolean,
@ColumnInfo(name = "current_overlay_permission")
val currentOverlayPermission: RuntimePermissionStatus,
@ColumnInfo(name = "highest_overlay_permission")
val highestOverlayPermission: RuntimePermissionStatus,
@ColumnInfo(name = "current_accessibility_service_permission")
val accessibilityServiceEnabled: Boolean,
@ColumnInfo(name = "was_accessibility_service_permission")
val wasAccessibilityServiceEnabled: Boolean,
@ColumnInfo(name = "enable_activity_level_blocking")
val enableActivityLevelBlocking: Boolean,
@ColumnInfo(name = "q_or_later")
val qOrLater: Boolean
): JsonSerializable {
companion object {
private const val ID = "id"
@ -85,7 +101,15 @@ data class Device(
private const val TRIED_DISABLING_DEVICE_ADMIN = "tdda"
private const val MANIPULATION_DID_REBOOT = "mdr"
private const val HAD_MANIPULATION = "hm"
private const val DEFAULT_USER = "du"
private const val DEFAULT_USER_TIMEOUT = "dut"
private const val CONSIDER_REBOOT_A_MANIPULATION = "cram"
private const val CURRENT_OVERLAY_PERMISSION = "cop"
private const val HIGHEST_OVERLAY_PERMISSION = "hop"
private const val ACCESSIBILITY_SERVICE_ENABLED = "ase"
private const val WAS_ACCESSIBILITY_SERVICE_ENABLED = "wase"
private const val ENABLE_ACTIVITY_LEVEL_BLOCKING = "ealb"
private const val Q_OR_LATER = "qol"
fun parse(reader: JsonReader): Device {
var id: String? = null
@ -104,7 +128,15 @@ data class Device(
var manipulationTriedDisablingDeviceAdmin: Boolean? = null
var manipulationDidReboot: Boolean = false
var hadManipulation: Boolean? = null
var defaultUser = ""
var defaultUserTimeout = 0
var considerRebootManipulation = false
var currentOverlayPermission = RuntimePermissionStatus.NotGranted
var highestOverlayPermission = RuntimePermissionStatus.NotGranted
var accessibilityServiceEnabled = false
var wasAccessibilityServiceEnabled = false
var enableActivityLevelBlocking = false
var qOrLater = false
reader.beginObject()
@ -126,7 +158,15 @@ data class Device(
TRIED_DISABLING_DEVICE_ADMIN -> manipulationTriedDisablingDeviceAdmin = reader.nextBoolean()
MANIPULATION_DID_REBOOT -> manipulationDidReboot = reader.nextBoolean()
HAD_MANIPULATION -> hadManipulation = reader.nextBoolean()
DEFAULT_USER -> defaultUser = reader.nextString()
DEFAULT_USER_TIMEOUT -> defaultUserTimeout = reader.nextInt()
CONSIDER_REBOOT_A_MANIPULATION -> considerRebootManipulation = reader.nextBoolean()
CURRENT_OVERLAY_PERMISSION -> currentOverlayPermission = RuntimePermissionStatusUtil.parse(reader.nextString())
HIGHEST_OVERLAY_PERMISSION -> highestOverlayPermission = RuntimePermissionStatusUtil.parse(reader.nextString())
ACCESSIBILITY_SERVICE_ENABLED -> accessibilityServiceEnabled = reader.nextBoolean()
WAS_ACCESSIBILITY_SERVICE_ENABLED -> wasAccessibilityServiceEnabled = reader.nextBoolean()
ENABLE_ACTIVITY_LEVEL_BLOCKING -> enableActivityLevelBlocking = reader.nextBoolean()
Q_OR_LATER -> qOrLater = reader.nextBoolean()
else -> reader.skipValue()
}
}
@ -150,7 +190,15 @@ data class Device(
manipulationTriedDisablingDeviceAdmin = manipulationTriedDisablingDeviceAdmin!!,
manipulationDidReboot = manipulationDidReboot,
hadManipulation = hadManipulation!!,
considerRebootManipulation = considerRebootManipulation
defaultUser = defaultUser,
defaultUserTimeout = defaultUserTimeout,
considerRebootManipulation = considerRebootManipulation,
currentOverlayPermission = currentOverlayPermission,
highestOverlayPermission = highestOverlayPermission,
accessibilityServiceEnabled = accessibilityServiceEnabled,
wasAccessibilityServiceEnabled = wasAccessibilityServiceEnabled,
enableActivityLevelBlocking = enableActivityLevelBlocking,
qOrLater = qOrLater
)
}
}
@ -198,7 +246,15 @@ data class Device(
writer.name(TRIED_DISABLING_DEVICE_ADMIN).value(manipulationTriedDisablingDeviceAdmin)
writer.name(MANIPULATION_DID_REBOOT).value(manipulationDidReboot)
writer.name(HAD_MANIPULATION).value(hadManipulation)
writer.name(DEFAULT_USER).value(defaultUser)
writer.name(DEFAULT_USER_TIMEOUT).value(defaultUserTimeout)
writer.name(CONSIDER_REBOOT_A_MANIPULATION).value(considerRebootManipulation)
writer.name(CURRENT_OVERLAY_PERMISSION).value(RuntimePermissionStatusUtil.serialize(currentOverlayPermission))
writer.name(HIGHEST_OVERLAY_PERMISSION).value(RuntimePermissionStatusUtil.serialize(highestOverlayPermission))
writer.name(ACCESSIBILITY_SERVICE_ENABLED).value(accessibilityServiceEnabled)
writer.name(WAS_ACCESSIBILITY_SERVICE_ENABLED).value(wasAccessibilityServiceEnabled)
writer.name(ENABLE_ACTIVITY_LEVEL_BLOCKING).value(enableActivityLevelBlocking)
writer.name(Q_OR_LATER).value(qOrLater)
writer.endObject()
}
@ -211,6 +267,10 @@ data class Device(
val manipulationOfNotificationAccess = currentNotificationAccessPermission != highestNotificationAccessPermission
@Transient
val manipulationOfAppVersion = currentAppVersion != highestAppVersion
@Transient
val manipulationOfOverlayPermission = currentOverlayPermission != highestOverlayPermission
@Transient
val manipulationOfAccessibilityService = accessibilityServiceEnabled != wasAccessibilityServiceEnabled
@Transient
val hasActiveManipulationWarning = manipulationOfProtectionLevel ||
@ -218,8 +278,16 @@ data class Device(
manipulationOfNotificationAccess ||
manipulationOfAppVersion ||
manipulationTriedDisablingDeviceAdmin ||
manipulationDidReboot
manipulationDidReboot ||
manipulationOfOverlayPermission ||
manipulationOfAccessibilityService
@Transient
val hasAnyManipulation = hasActiveManipulationWarning || hadManipulation
@Transient
val missingPermissionAtQOrLater = qOrLater &&
(!accessibilityServiceEnabled) &&
(currentOverlayPermission != RuntimePermissionStatus.Granted) &&
(currentProtectionLevel != ProtectionLevel.DeviceOwner)
}

View file

@ -15,6 +15,8 @@
*/
package io.timelimit.android.extensions
import android.text.Editable
import android.text.TextWatcher
import android.view.KeyEvent
import android.view.inputmethod.EditorInfo
import android.widget.EditText
@ -40,3 +42,19 @@ fun EditText.setOnEnterListenr(listener: () -> Unit) {
}
}
}
fun EditText.addOnTextChangedListener(listener: () -> Unit) {
this.addTextChangedListener(object: TextWatcher {
override fun afterTextChanged(s: Editable?) {
// ignore
}
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {
// ignore
}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
listener()
}
})
}

View file

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

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,15 +41,12 @@ class AdminReceiver: DeviceAdminReceiver() {
override fun onDisableRequested(context: Context, intent: Intent?): CharSequence {
runAsync {
val logic = DefaultAppLogic.with(context)
if (logic.database.config().getOwnDeviceId().waitForNullableValue() != null) {
ApplyActionUtil.applyAppLogicAction(
TriedDisablingDeviceAdminAction,
logic
action = TriedDisablingDeviceAdminAction,
appLogic = DefaultAppLogic.with(context),
ignoreIfDeviceIsNotConfigured = true
)
}
}
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.app.ActivityManager
import android.app.Application
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.admin.DevicePolicyManager
@ -27,20 +28,27 @@ import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import android.media.session.MediaSessionManager
import android.os.Build
import android.os.PowerManager
import android.os.UserManager
import android.provider.Settings
import android.util.Log
import android.view.KeyEvent
import android.widget.Toast
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import io.timelimit.android.BuildConfig
import io.timelimit.android.R
import io.timelimit.android.coroutines.runAsyncExpectForever
import io.timelimit.android.data.model.App
import io.timelimit.android.data.model.AppActivity
import io.timelimit.android.integration.platform.*
import io.timelimit.android.integration.platform.android.foregroundapp.ForegroundAppHelper
import io.timelimit.android.ui.lock.LockActivity
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.channels.consumeEach
import kotlinx.coroutines.delay
class AndroidIntegration(context: Context): PlatformIntegration(maximumProtectionLevel) {
@ -65,6 +73,7 @@ class AndroidIntegration(context: Context): PlatformIntegration(maximumProtectio
private val activityManager = this.context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
private val notificationManager = this.context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val deviceAdmin = ComponentName(context.applicationContext, AdminReceiver::class.java)
private val overlay = OverlayUtil(context as Application)
init {
AppsChangeListener.registerBroadcastReceiver(this.context, object : BroadcastReceiver() {
@ -78,10 +87,20 @@ class AndroidIntegration(context: Context): PlatformIntegration(maximumProtectio
return AndroidIntegrationApps.getLocalApps(context)
}
override fun getLocalAppActivities(deviceId: String): Collection<AppActivity> {
return AndroidIntegrationApps.getLocalAppActivities(deviceId, context)
}
override fun getLocalAppTitle(packageName: String): String? {
return AndroidIntegrationApps.getAppTitle(packageName, context)
}
override fun getLauncherAppPackageName(): String? {
return Intent(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_HOME)
.resolveActivity(context.packageManager)?.packageName
}
override fun getAppIcon(packageName: String): Drawable? {
return AndroidIntegrationApps.getAppIcon(packageName, context)
}
@ -90,8 +109,8 @@ class AndroidIntegration(context: Context): PlatformIntegration(maximumProtectio
return AdminStatus.getAdminStatus(context, policyManager)
}
override suspend fun getForegroundAppPackageName(): String? {
return foregroundAppHelper.getForegroundAppPackage()
override suspend fun getForegroundApp(result: ForegroundAppSpec, queryInterval: Long) {
foregroundAppHelper.getForegroundApp(result, queryInterval)
}
override fun getForegroundAppPermissionStatus(): RuntimePermissionStatus {
@ -128,6 +147,30 @@ class AndroidIntegration(context: Context): PlatformIntegration(maximumProtectio
}
}
override fun getOverlayPermissionStatus(): RuntimePermissionStatus = overlay.getOverlayPermissionStatus()
override fun isAccessibilityServiceEnabled(): Boolean {
val service = context.packageName + "/" + AccessibilityService::class.java.canonicalName
val accessibilityEnabled = try {
Settings.Secure.getInt(context.contentResolver, Settings.Secure.ACCESSIBILITY_ENABLED)
} catch (ex: Settings.SettingNotFoundException) {
0
}
if (accessibilityEnabled == 1) {
val enabledServicesString = Settings.Secure.getString(context.contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES)
if (!enabledServicesString.isNullOrEmpty()) {
if (enabledServicesString.split(":").contains(service)) {
return true
}
}
}
return false
}
override fun trySetLockScreenPassword(password: String): Boolean {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "set password")
@ -153,17 +196,55 @@ class AndroidIntegration(context: Context): PlatformIntegration(maximumProtectio
}
private var lastAppStatusMessage: AppStatusMessage? = null
private var appStatusMessageChannel = Channel<AppStatusMessage?>(capacity = Channel.CONFLATED)
override fun setAppStatusMessage(message: AppStatusMessage?) {
if (lastAppStatusMessage != message) {
lastAppStatusMessage = message
appStatusMessageChannel.offer(message)
}
}
init {
runAsyncExpectForever {
appStatusMessageChannel.consumeEach { message ->
BackgroundService.setStatusMessage(message, context)
delay(200)
}
}
}
override fun showAppLockScreen(currentPackageName: String) {
LockActivity.start(context, currentPackageName)
override fun showAppLockScreen(currentPackageName: String, currentActivityName: String?) {
LockActivity.start(context, currentPackageName, currentActivityName)
}
override fun muteAudioIfPossible(packageName: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
if (getNotificationAccessPermissionStatus() == NewPermissionStatus.Granted) {
val manager = context.getSystemService(Context.MEDIA_SESSION_SERVICE) as MediaSessionManager
val sessions = manager.getActiveSessions(ComponentName(context, NotificationListener::class.java))
val sessionsOfTheApp = sessions.filter { it.packageName == packageName }
sessionsOfTheApp.forEach { session ->
session.dispatchMediaButtonEvent(KeyEvent(
KeyEvent.ACTION_DOWN,
KeyEvent.KEYCODE_MEDIA_STOP
))
session.dispatchMediaButtonEvent(KeyEvent(
KeyEvent.ACTION_UP,
KeyEvent.KEYCODE_MEDIA_STOP
))
}
}
}
}
override fun setShowBlockingOverlay(show: Boolean) {
if (show) {
overlay.show()
} else {
overlay.hide()
}
}
override fun isScreenOn(): Boolean {
@ -176,7 +257,7 @@ class AndroidIntegration(context: Context): PlatformIntegration(maximumProtectio
override fun setShowNotificationToRevokeTemporarilyAllowedApps(show: Boolean) {
if (show) {
NotificationChannels.createAppStatusChannel(notificationManager, context)
NotificationChannels.createNotificationChannels(notificationManager, context)
val actionIntent = PendingIntent.getService(
context,
@ -206,6 +287,25 @@ class AndroidIntegration(context: Context): PlatformIntegration(maximumProtectio
}
}
override fun showTimeWarningNotification(title: String, text: String) {
NotificationChannels.createNotificationChannels(notificationManager, context)
notificationManager.notify(
NotificationIds.TIME_WARNING,
NotificationCompat.Builder(context, NotificationChannels.TIME_WARNING)
.setSmallIcon(R.drawable.ic_stat_timelapse)
.setContentTitle(title)
.setContentText(text)
.setWhen(System.currentTimeMillis())
.setShowWhen(true)
.setLocalOnly(true)
.setAutoCancel(false)
.setOngoing(false)
.setPriority(NotificationCompat.PRIORITY_HIGH)
.build()
)
}
override fun disableDeviceAdmin() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
if (policyManager.isDeviceOwnerApp(context.packageName)) {

View file

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

View file

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

View file

@ -25,14 +25,15 @@ object NotificationIds {
const val APP_STATUS = 1
const val NOTIFICATION_BLOCKED = 2
const val REVOKE_TEMPORARILY_ALLOWED_APPS = 3
const val APP_RESET = 4
const val TIME_WARNING = 4
}
object NotificationChannels {
const val APP_STATUS = "app status"
const val BLOCKED_NOTIFICATIONS_NOTIFICATION = "notification blocked notification"
const val TIME_WARNING = "time warning"
fun createAppStatusChannel(notificationManager: NotificationManager, context: Context) {
private fun createAppStatusChannel(notificationManager: NotificationManager, context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notificationManager.createNotificationChannel(
NotificationChannel(
@ -50,7 +51,7 @@ object NotificationChannels {
}
}
fun createBlockedNotificationChannel(notificationManager: NotificationManager, context: Context) {
private fun createBlockedNotificationChannel(notificationManager: NotificationManager, context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notificationManager.createNotificationChannel(
NotificationChannel(
@ -63,9 +64,31 @@ object NotificationChannels {
)
}
}
private fun createTimeWarningsNotificationChannel(notificationManager: NotificationManager, context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notificationManager.createNotificationChannel(
NotificationChannel(
NotificationChannels.TIME_WARNING,
context.getString(R.string.notification_channel_time_warning_title),
NotificationManager.IMPORTANCE_HIGH
).apply {
description = context.getString(R.string.notification_channel_time_warning_text)
}
)
}
}
fun createNotificationChannels(notificationManager: NotificationManager, context: Context) {
createAppStatusChannel(notificationManager, context)
createBlockedNotificationChannel(notificationManager, context)
createTimeWarningsNotificationChannel(notificationManager, context)
}
}
object PendingIntentIds {
const val OPEN_MAIN_APP = 1
const val REVOKE_TEMPORARILY_ALLOWED = 2
const val SWITCH_TO_DEFAULT_USER = 3
val DYNAMIC_NOTIFICATION_RANGE = 100..10000
}

View file

@ -35,17 +35,19 @@ import io.timelimit.android.logic.*
class NotificationListener: NotificationListenerService() {
companion object {
private const val LOG_TAG = "NotificationListenerLog"
private val SUPPORTS_HIDING_ONGOING_NOTIFICATIONS = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
}
private val appLogic: AppLogic by lazy { DefaultAppLogic.with(this) }
private val blockingReasonUtil: BlockingReasonUtil by lazy { BlockingReasonUtil(appLogic) }
private val notificationManager: NotificationManager by lazy { getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager }
private val queryAppTitleCache: QueryAppTitleCache by lazy { QueryAppTitleCache(appLogic.platformIntegration) }
private val lastOngoingNotificationHidden = mutableSetOf<String>()
override fun onCreate() {
super.onCreate()
NotificationChannels.createBlockedNotificationChannel(notificationManager, this)
NotificationChannels.createNotificationChannels(notificationManager, this)
}
override fun onNotificationPosted(sbn: StatusBarNotification) {
@ -58,9 +60,25 @@ class NotificationListener: NotificationListenerService() {
runAsync {
val reason = shouldRemoveNotification(sbn)
if (reason != BlockingReason.None) {
if (reason == BlockingReason.None) {
if (sbn.isOngoing) {
lastOngoingNotificationHidden.remove(sbn.packageName)
}
} else {
appLogic.platformIntegration.muteAudioIfPossible(sbn.packageName)
val success = try {
if (sbn.isOngoing && SUPPORTS_HIDING_ONGOING_NOTIFICATIONS) {
// only snooze for 5 seconds to show it again soon
snoozeNotification(sbn.key, 5000)
if (!lastOngoingNotificationHidden.add(sbn.packageName)) {
// skip showing again a notification that it was blocked
return@runAsync
}
} else {
cancelNotification(sbn.key)
}
true
} catch (ex: SecurityException) {
@ -91,6 +109,7 @@ class NotificationListener: NotificationListenerService() {
BlockingReason.TimeOver -> getString(R.string.lock_reason_short_time_over)
BlockingReason.TimeOverExtraTimeCanBeUsedLater -> getString(R.string.lock_reason_short_time_over)
BlockingReason.BlockedAtThisTime -> getString(R.string.lock_reason_short_blocked_time_area)
BlockingReason.NotificationsAreBlocked -> getString(R.string.lock_reason_short_notification_blocking)
BlockingReason.None -> throw IllegalStateException()
}
)
@ -109,25 +128,41 @@ class NotificationListener: NotificationListenerService() {
}
private suspend fun shouldRemoveNotification(sbn: StatusBarNotification): BlockingReason {
if (sbn.packageName == packageName || sbn.isOngoing) {
if (sbn.packageName == packageName) {
return BlockingReason.None
}
val blockingReason = blockingReasonUtil.getBlockingReason(sbn.packageName).waitForNonNullValue()
if (blockingReason == BlockingReason.None) {
if (sbn.isOngoing && (!SUPPORTS_HIDING_ONGOING_NOTIFICATIONS)) {
return BlockingReason.None
}
if (isSystemApp(sbn.packageName) && blockingReason == BlockingReason.NotPartOfAnCategory) {
val blockingReason = blockingReasonUtil.getBlockingReason(
packageName = sbn.packageName,
activityName = null
).waitForNonNullValue()
if (blockingReason.areNotificationsBlocked) {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "blocking notification of ${sbn.packageName} because notifications are blocked")
}
return BlockingReason.NotificationsAreBlocked
}
return when (blockingReason) {
is NoBlockingReason -> BlockingReason.None
is BlockedReasonDetails -> {
if (isSystemApp(sbn.packageName) && blockingReason.reason == BlockingReason.NotPartOfAnCategory) {
return BlockingReason.None
}
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "blocking notification of ${sbn.packageName} because $blockingReason")
Log.d(LOG_TAG, "blocking notification of ${sbn.packageName} because ${blockingReason.reason}")
}
return blockingReason
return blockingReason.reason
}
}
}
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.content.Context
import io.timelimit.android.integration.platform.ForegroundAppSpec
import io.timelimit.android.integration.platform.RuntimePermissionStatus
class CompatForegroundAppHelper(context: Context) : ForegroundAppHelper() {
private val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
override suspend fun getForegroundAppPackage(): String? {
return try {
activityManager.getRunningTasks(1)[0].topActivity.packageName
override suspend fun getForegroundApp(result: ForegroundAppSpec, queryInterval: Long) {
try {
val activity = activityManager.getRunningTasks(1)[0].topActivity
result.packageName = activity.packageName
result.activityName = activity.className
} catch (ex: NullPointerException) {
null
result.activityName = null
result.packageName = null
}
}

View file

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

View file

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

View file

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

View file

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

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() {
class ItemWrapper<R>(val value: LiveData<R>, var used: Boolean)

View file

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

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

View file

@ -30,17 +30,23 @@ import io.timelimit.android.data.model.*
import io.timelimit.android.date.DateInTimezone
import io.timelimit.android.date.getMinuteOfWeek
import io.timelimit.android.integration.platform.AppStatusMessage
import io.timelimit.android.integration.platform.ForegroundAppSpec
import io.timelimit.android.integration.platform.ProtectionLevel
import io.timelimit.android.integration.platform.android.AccessibilityService
import io.timelimit.android.integration.platform.android.AndroidIntegrationApps
import io.timelimit.android.livedata.*
import io.timelimit.android.sync.actions.UpdateDeviceStatusAction
import io.timelimit.android.sync.actions.apply.ApplyActionUtil
import io.timelimit.android.util.AndroidVersion
import io.timelimit.android.util.TimeTextUtil
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.*
class BackgroundTaskLogic(val appLogic: AppLogic) {
var pauseBackgroundLoop = false
companion object {
private const val CHECK_PERMISSION_INTERVAL = 10 * 1000L // all 10 seconds
private const val BACKGROUND_SERVICE_INTERVAL = 100L // all 100 ms
@ -107,12 +113,15 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
}
}
private val shouldDoAutomaticSignOut = SingleItemLiveDataCacheWithRequery { -> appLogic.defaultUserLogic.hasAutomaticSignOut()}
private val liveDataCaches = LiveDataCaches(arrayOf(
deviceUserEntryLive,
childCategories,
appCategories,
timeLimitRules,
usedTimesOfCategoryAndWeekByFirstDayOfWeek
usedTimesOfCategoryAndWeekByFirstDayOfWeek,
shouldDoAutomaticSignOut
))
private var usedTimeUpdateHelper: UsedTimeItemBatchUpdateHelper? = null
@ -125,6 +134,28 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
private val appTitleCache = QueryAppTitleCache(appLogic.platformIntegration)
private suspend fun openLockscreen(blockedAppPackageName: String, blockedAppActivityName: String?) {
appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage(
title = appTitleCache.query(blockedAppPackageName),
text = appLogic.context.getString(R.string.background_logic_opening_lockscreen)
))
appLogic.platformIntegration.setShowBlockingOverlay(true)
if (appLogic.platformIntegration.isAccessibilityServiceEnabled()) {
if (blockedAppPackageName != appLogic.platformIntegration.getLauncherAppPackageName()) {
AccessibilityService.instance?.showHomescreen()
delay(100)
AccessibilityService.instance?.showHomescreen()
delay(100)
}
}
appLogic.platformIntegration.showAppLockScreen(blockedAppPackageName, blockedAppActivityName)
}
private val foregroundAppSpec = ForegroundAppSpec.newInstance()
private suspend fun backgroundServiceLoop() {
while (true) {
// app must be enabled
@ -132,6 +163,7 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
usedTimeUpdateHelper?.commit(appLogic)
liveDataCaches.removeAllItems()
appLogic.platformIntegration.setAppStatusMessage(null)
appLogic.platformIntegration.setShowBlockingOverlay(false)
appLogic.enable.waitUntilValueMatches { it == true }
continue
@ -142,9 +174,31 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
if (deviceUserEntry == null || deviceUserEntry.type != UserType.Child) {
usedTimeUpdateHelper?.commit(appLogic)
val shouldDoAutomaticSignOut = shouldDoAutomaticSignOut.read()
if (shouldDoAutomaticSignOut.waitForNonNullValue()) {
appLogic.defaultUserLogic.reportScreenOn(appLogic.platformIntegration.isScreenOn())
appLogic.platformIntegration.setAppStatusMessage(
AppStatusMessage(
title = appLogic.context.getString(R.string.background_logic_timeout_title),
text = appLogic.context.getString(R.string.background_logic_timeout_text),
showSwitchToDefaultUserOption = true
)
)
appLogic.platformIntegration.setShowBlockingOverlay(false)
liveDataCaches.reportLoopDone()
appLogic.timeApi.sleep(BACKGROUND_SERVICE_INTERVAL)
} else {
liveDataCaches.removeAllItems()
appLogic.platformIntegration.setAppStatusMessage(null)
deviceUserEntryLive.read().waitUntilValueMatches { it != null && it.type == UserType.Child }
appLogic.platformIntegration.setShowBlockingOverlay(false)
val isChildSignedIn = deviceUserEntryLive.read().map { it != null && it.type == UserType.Child }
isChildSignedIn.or(shouldDoAutomaticSignOut).waitUntilValueMatches { it == true }
}
continue
}
@ -159,11 +213,17 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
val minuteOfWeek = getMinuteOfWeek(nowTimestamp, nowTimezone)
// eventually remove old used time data
if (dayChangeTracker.reportDayChange(nowDate.dayOfEpoch) == DayChangeTracker.DayChange.NowSinceLongerTime) {
UsedTimeDeleter.deleteOldUsedTimeItems(
run {
val dayChange = dayChangeTracker.reportDayChange(nowDate.dayOfEpoch)
fun deleteOldUsedTimes() = UsedTimeDeleter.deleteOldUsedTimeItems(
database = appLogic.database,
date = nowDate
)
if (dayChange == DayChangeTracker.DayChange.NowSinceLongerTime) {
deleteOldUsedTimes()
}
}
// get the categories
@ -173,33 +233,63 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
// get the current status
val isScreenOn = appLogic.platformIntegration.isScreenOn()
appLogic.defaultUserLogic.reportScreenOn(isScreenOn)
if (!isScreenOn) {
if (temporarilyAllowedApps.isNotEmpty()) {
resetTemporarilyAllowedApps()
}
}
val foregroundAppPackageName = appLogic.platformIntegration.getForegroundAppPackageName()
appLogic.platformIntegration.getForegroundApp(foregroundAppSpec, appLogic.getForegroundAppQueryInterval())
val foregroundAppPackageName = foregroundAppSpec.packageName
val foregroundAppActivityName = foregroundAppSpec.activityName
val activityLevelBlocking = appLogic.deviceEntry.value?.enableActivityLevelBlocking ?: false
fun showStatusMessageWithCurrentAppTitle(text: String, titlePrefix: String? = "") {
appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage(
titlePrefix + appTitleCache.query(foregroundAppPackageName ?: "invalid"),
text,
if (activityLevelBlocking) foregroundAppActivityName?.removePrefix(foregroundAppPackageName ?: "invalid") else null
))
}
// the following is not executed if the permission is missing
if (foregroundAppPackageName == BuildConfig.APPLICATION_ID) {
// this app itself runs now -> no need for an status message
usedTimeUpdateHelper?.commit(appLogic)
appLogic.platformIntegration.setAppStatusMessage(null)
} else if (foregroundAppPackageName != null && AndroidIntegrationApps.ignoredApps.contains(foregroundAppPackageName)) {
if (pauseBackgroundLoop) {
usedTimeUpdateHelper?.commit(appLogic)
appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage(
appTitleCache.query(foregroundAppPackageName),
appLogic.context.getString(R.string.background_logic_whitelisted)
title = appLogic.context.getString(R.string.background_logic_paused_title),
text = appLogic.context.getString(R.string.background_logic_paused_text)
))
appLogic.platformIntegration.setShowBlockingOverlay(false)
} else if (
(foregroundAppPackageName == BuildConfig.APPLICATION_ID) ||
(foregroundAppPackageName != null && AndroidIntegrationApps.ignoredApps.contains(foregroundAppPackageName))) {
usedTimeUpdateHelper?.commit(appLogic)
showStatusMessageWithCurrentAppTitle(
text = appLogic.context.getString(R.string.background_logic_whitelisted)
)
appLogic.platformIntegration.setShowBlockingOverlay(false)
} else if (foregroundAppPackageName != null && temporarilyAllowedApps.contains(foregroundAppPackageName)) {
usedTimeUpdateHelper?.commit(appLogic)
appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage(
appTitleCache.query(foregroundAppPackageName),
appLogic.context.getString(R.string.background_logic_temporarily_allowed)
))
showStatusMessageWithCurrentAppTitle(appLogic.context.getString(R.string.background_logic_temporarily_allowed))
appLogic.platformIntegration.setShowBlockingOverlay(false)
} else if (foregroundAppPackageName != null) {
val appCategory = appCategories.get(Pair(foregroundAppPackageName, categories.map { it.id })).waitForNullableValue()
val categoryIds = categories.map { it.id }
val appCategory = run {
val appLevelCategoryLive = appCategories.get(foregroundAppPackageName to categoryIds)
if (activityLevelBlocking) {
val appActivityCategoryLive = appCategories.get("$foregroundAppPackageName:$foregroundAppActivityName" to categoryIds)
appActivityCategoryLive.waitForNullableValue() ?: appLevelCategoryLive.waitForNullableValue()
} else {
appLevelCategoryLive.waitForNullableValue()
}
}
val category = categories.find { it.id == appCategory?.categoryId }
?: categories.find { it.id == deviceUserEntry.categoryForNotAssignedApps }
val parentCategory = categories.find { it.id == category?.parentCategoryId }
@ -207,39 +297,30 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
if (category == null) {
usedTimeUpdateHelper?.commit(appLogic)
appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage(
title = appTitleCache.query(foregroundAppPackageName),
text = appLogic.context.getString(R.string.background_logic_opening_lockscreen)
))
if (AndroidIntegrationApps.ignoredApps.contains(foregroundAppPackageName) == false) {
// don't suspend system apps which are whitelisted in any version
appLogic.platformIntegration.setSuspendedApps(listOf(foregroundAppPackageName), true)
appLogic.platformIntegration.showAppLockScreen(foregroundAppPackageName)
}
openLockscreen(foregroundAppPackageName, foregroundAppActivityName)
} else if (category.temporarilyBlocked or (parentCategory?.temporarilyBlocked == true)) {
usedTimeUpdateHelper?.commit(appLogic)
appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage(
title = appTitleCache.query(foregroundAppPackageName),
text = appLogic.context.getString(R.string.background_logic_opening_lockscreen)
))
appLogic.platformIntegration.showAppLockScreen(foregroundAppPackageName)
openLockscreen(foregroundAppPackageName, foregroundAppActivityName)
} else {
// disable time limits temporarily feature
if (nowTimestamp < deviceUserEntry.disableLimitsUntil) {
appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage(
title = appTitleCache.query(foregroundAppPackageName),
text = appLogic.context.getString(R.string.background_logic_limits_disabled)
))
showStatusMessageWithCurrentAppTitle(appLogic.context.getString(R.string.background_logic_limits_disabled))
appLogic.platformIntegration.setShowBlockingOverlay(false)
} else if (
// check blocked time areas
// directly blocked
(category.blockedMinutesInWeek.read(minuteOfWeek)) or
(parentCategory?.blockedMinutesInWeek?.read(minuteOfWeek) == true)
) {
usedTimeUpdateHelper?.commit(appLogic)
appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage(
title = appTitleCache.query(foregroundAppPackageName),
text = appLogic.context.getString(R.string.background_logic_opening_lockscreen)
))
appLogic.platformIntegration.showAppLockScreen(foregroundAppPackageName)
openLockscreen(foregroundAppPackageName, foregroundAppActivityName)
} else {
// check time limits
val rules = timeLimitRules.get(category.id).waitForNonNullValue()
@ -251,10 +332,11 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
// unlimited
usedTimeUpdateHelper?.commit(appLogic)
appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage(
category.title + " - " + appTitleCache.query(foregroundAppPackageName),
appLogic.context.getString(R.string.background_logic_no_timelimit)
))
showStatusMessageWithCurrentAppTitle(
text = appLogic.context.getString(R.string.background_logic_no_timelimit),
titlePrefix = category.title + " - "
)
appLogic.platformIntegration.setShowBlockingOverlay(false)
} else {
val usedTimes = usedTimesOfCategoryAndWeekByFirstDayOfWeek.get(Pair(category.id, nowDate.dayOfEpoch - nowDate.dayOfWeek)).waitForNonNullValue()
val parentUsedTimes = parentCategory?.let {
@ -317,42 +399,57 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
usedTimeUpdateHelper?.commit(appLogic)
appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage(
category.title + " - " + appTitleCache.query(foregroundAppPackageName),
appLogic.context.getString(R.string.background_logic_no_timelimit)
))
showStatusMessageWithCurrentAppTitle(
text = appLogic.context.getString(R.string.background_logic_no_timelimit),
titlePrefix = category.title + " - "
)
appLogic.platformIntegration.setShowBlockingOverlay(false)
} else {
// time limited
if (remaining.includingExtraTime > 0) {
var subtractExtraTime: Boolean
if (remaining.default == 0L) {
// using extra time
appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage(
category.title + " - " + appTitleCache.query(foregroundAppPackageName),
appLogic.context.getString(R.string.background_logic_using_extra_time, TimeTextUtil.remaining(remaining.includingExtraTime.toInt(), appLogic.context))
))
if (isScreenOn) {
newUsedTimeItemBatchUpdateHelper.addUsedTime(
Math.min(previousMainLogicExecutionTime, MAX_USED_TIME_PER_ROUND), // never save more than a second of used time
true,
appLogic
showStatusMessageWithCurrentAppTitle(
text = appLogic.context.getString(R.string.background_logic_using_extra_time, TimeTextUtil.remaining(remaining.includingExtraTime.toInt(), appLogic.context)),
titlePrefix = category.title + " - "
)
}
subtractExtraTime = true
} else {
// using normal contingent
showStatusMessageWithCurrentAppTitle(
text = TimeTextUtil.remaining(remaining.default.toInt(), appLogic.context),
titlePrefix = category.title + " - "
)
subtractExtraTime = false
}
appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage(
category.title + " - " + appTitleCache.query(foregroundAppPackageName),
TimeTextUtil.remaining(remaining.default.toInt(), appLogic.context)
))
appLogic.platformIntegration.setShowBlockingOverlay(false)
if (isScreenOn) {
// never save more than a second of used time
val timeToSubtract = Math.min(previousMainLogicExecutionTime, MAX_USED_TIME_PER_ROUND)
newUsedTimeItemBatchUpdateHelper.addUsedTime(
Math.min(previousMainLogicExecutionTime, MAX_USED_TIME_PER_ROUND), // never save more than a second of used time
false,
timeToSubtract,
subtractExtraTime,
appLogic
)
val oldRemainingTime = remaining.includingExtraTime
val newRemainingTime = oldRemainingTime - timeToSubtract
if (oldRemainingTime / (1000 * 60) != newRemainingTime / (1000 * 60)) {
// eventually show remaining time warning
val roundedNewTime = ((newRemainingTime / (1000 * 60)) + 1) * (1000 * 60)
val flagIndex = CategoryTimeWarnings.durationToBitIndex[roundedNewTime]
if (flagIndex != null && category.timeWarnings and (1 shl flagIndex) != 0) {
appLogic.platformIntegration.showTimeWarningNotification(
title = appLogic.context.getString(R.string.time_warning_not_title, category.title),
text = TimeTextUtil.remaining(roundedNewTime.toInt(), appLogic.context)
)
}
}
}
} else {
@ -360,11 +457,7 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
newUsedTimeItemBatchUpdateHelper.commit(appLogic)
appLogic.platformIntegration.setAppStatusMessage(AppStatusMessage(
title = appTitleCache.query(foregroundAppPackageName),
text = appLogic.context.getString(R.string.background_logic_opening_lockscreen)
))
appLogic.platformIntegration.showAppLockScreen(foregroundAppPackageName)
openLockscreen(foregroundAppPackageName, foregroundAppActivityName)
}
}
}
@ -375,6 +468,7 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
appLogic.context.getString(R.string.background_logic_idle_title),
appLogic.context.getString(R.string.background_logic_idle_text)
))
appLogic.platformIntegration.setShowBlockingOverlay(false)
}
} catch (ex: SecurityException) {
// this is handled by an other main loop (with a delay)
@ -383,6 +477,7 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
appLogic.context.getString(R.string.background_logic_error),
appLogic.context.getString(R.string.background_logic_error_permission)
))
appLogic.platformIntegration.setShowBlockingOverlay(false)
} catch (ex: Exception) {
if (BuildConfig.DEBUG) {
Log.w(LOG_TAG, "exception during running main loop", ex)
@ -392,6 +487,7 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
appLogic.context.getString(R.string.background_logic_error),
appLogic.context.getString(R.string.background_logic_error_internal)
))
appLogic.platformIntegration.setShowBlockingOverlay(false)
}
liveDataCaches.reportLoopDone()
@ -413,10 +509,11 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
if (deviceEntry != null) {
if (deviceEntry.currentAppVersion != currentAppVersion) {
ApplyActionUtil.applyAppLogicAction(
UpdateDeviceStatusAction.empty.copy(
action = UpdateDeviceStatusAction.empty.copy(
newAppVersion = currentAppVersion
),
appLogic
appLogic = appLogic,
ignoreIfDeviceIsNotConfigured = true
)
}
}
@ -446,10 +543,11 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
if (deviceEntry?.considerRebootManipulation == true) {
ApplyActionUtil.applyAppLogicAction(
UpdateDeviceStatusAction.empty.copy(
action = UpdateDeviceStatusAction.empty.copy(
didReboot = true
),
appLogic
appLogic = appLogic,
ignoreIfDeviceIsNotConfigured = true
)
}
}
@ -463,6 +561,9 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
val protectionLevel = appLogic.platformIntegration.getCurrentProtectionLevel()
val usageStatsPermission = appLogic.platformIntegration.getForegroundAppPermissionStatus()
val notificationAccess = appLogic.platformIntegration.getNotificationAccessPermissionStatus()
val overlayPermission = appLogic.platformIntegration.getOverlayPermissionStatus()
val accessibilityService = appLogic.platformIntegration.isAccessibilityServiceEnabled()
val qOrLater = AndroidVersion.qOrLater
var changes = UpdateDeviceStatusAction.empty
@ -488,8 +589,28 @@ class BackgroundTaskLogic(val appLogic: AppLogic) {
)
}
if (overlayPermission != deviceEntry.currentOverlayPermission) {
changes = changes.copy(
newOverlayPermission = overlayPermission
)
}
if (accessibilityService != deviceEntry.accessibilityServiceEnabled) {
changes = changes.copy(
newAccessibilityServiceEnabled = accessibilityService
)
}
if (qOrLater && !deviceEntry.qOrLater) {
changes = changes.copy(isQOrLaterNow = true)
}
if (changes != UpdateDeviceStatusAction.empty) {
ApplyActionUtil.applyAppLogicAction(changes, appLogic)
ApplyActionUtil.applyAppLogicAction(
action = changes,
appLogic = appLogic,
ignoreIfDeviceIsNotConfigured = true
)
}
}
}

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
* it under the terms of the GNU General Public License as published by
@ -20,10 +20,7 @@ import android.util.SparseLongArray
import androidx.lifecycle.LiveData
import androidx.lifecycle.Transformations
import io.timelimit.android.BuildConfig
import io.timelimit.android.data.model.Category
import io.timelimit.android.data.model.TimeLimitRule
import io.timelimit.android.data.model.User
import io.timelimit.android.data.model.UserType
import io.timelimit.android.data.model.*
import io.timelimit.android.date.DateInTimezone
import io.timelimit.android.date.getMinuteOfWeek
import io.timelimit.android.integration.platform.android.AndroidIntegrationApps
@ -37,69 +34,106 @@ enum class BlockingReason {
TemporarilyBlocked,
BlockedAtThisTime,
TimeOver,
TimeOverExtraTimeCanBeUsedLater
TimeOverExtraTimeCanBeUsedLater,
NotificationsAreBlocked
}
enum class BlockingLevel {
App,
Activity
}
sealed class BlockingReasonDetail {
abstract val areNotificationsBlocked: Boolean
}
data class NoBlockingReason(
override val areNotificationsBlocked: Boolean
): BlockingReasonDetail() {
companion object {
private val instanceWithoutNotificationsBlocked = NoBlockingReason(areNotificationsBlocked = false)
private val instanceWithNotificationsBlocked = NoBlockingReason(areNotificationsBlocked = true)
fun getInstance(areNotificationsBlocked: Boolean) = if (areNotificationsBlocked)
instanceWithNotificationsBlocked
else
instanceWithoutNotificationsBlocked
}
}
data class BlockedReasonDetails(
val reason: BlockingReason,
val level: BlockingLevel,
val categoryId: String?,
override val areNotificationsBlocked: Boolean
): BlockingReasonDetail()
class BlockingReasonUtil(private val appLogic: AppLogic) {
companion object {
private const val LOG_TAG = "BlockingReason"
}
fun getBlockingReason(packageName: String): LiveData<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
return appLogic.enable.switchMap {
enabled ->
if (enabled == null || enabled == false) {
liveDataFromValue(BlockingReason.None)
liveDataFromValue(NoBlockingReason.getInstance(areNotificationsBlocked = false) as BlockingReasonDetail)
} else {
appLogic.deviceUserEntry.switchMap {
user ->
if (user == null || user.type != UserType.Child) {
liveDataFromValue(BlockingReason.None)
liveDataFromValue(NoBlockingReason.getInstance(areNotificationsBlocked = false) as BlockingReasonDetail)
} else {
getBlockingReasonStep2(packageName, user, TimeZone.getTimeZone(user.timeZone))
getBlockingReasonStep2(packageName, activityName, user, TimeZone.getTimeZone(user.timeZone))
}
}
}
}
}
private fun getBlockingReasonStep2(packageName: String, child: User, timeZone: TimeZone): LiveData<BlockingReason> {
private fun getBlockingReasonStep2(packageName: String, activityName: String?, child: User, timeZone: TimeZone): LiveData<BlockingReasonDetail> {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "step 2")
}
// check internal whitelist
if (packageName == BuildConfig.APPLICATION_ID) {
return liveDataFromValue(BlockingReason.None)
return liveDataFromValue(NoBlockingReason.getInstance(areNotificationsBlocked = false))
} else if (AndroidIntegrationApps.ignoredApps.contains(packageName)) {
return liveDataFromValue(BlockingReason.None)
return liveDataFromValue(NoBlockingReason.getInstance(areNotificationsBlocked = false))
} else {
return getBlockingReasonStep3(packageName, child, timeZone)
return getBlockingReasonStep3(packageName, activityName, child, timeZone)
}
}
private fun getBlockingReasonStep3(packageName: String, child: User, timeZone: TimeZone): LiveData<BlockingReason> {
private fun getBlockingReasonStep3(packageName: String, activityName: String?, child: User, timeZone: TimeZone): LiveData<BlockingReasonDetail> {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "step 3")
}
// check temporarily allowed Apps
return appLogic.database.temporarilyAllowedApp().getTemporarilyAllowedApps().switchMap {
return appLogic.deviceId.switchMap {
if (it != null) {
appLogic.database.temporarilyAllowedApp().getTemporarilyAllowedApps()
} else {
liveDataFromValue(Collections.emptyList())
}
}.switchMap {
temporarilyAllowedApps ->
if (temporarilyAllowedApps.contains(packageName)) {
liveDataFromValue(BlockingReason.None)
liveDataFromValue(NoBlockingReason.getInstance(areNotificationsBlocked = false) as BlockingReasonDetail)
} else {
getBlockingReasonStep4(packageName, child, timeZone)
getBlockingReasonStep4(packageName, activityName, child, timeZone)
}
}
}
private fun getBlockingReasonStep4(packageName: String, child: User, timeZone: TimeZone): LiveData<BlockingReason> {
private fun getBlockingReasonStep4(packageName: String, activityName: String?, child: User, timeZone: TimeZone): LiveData<BlockingReasonDetail> {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "step 4")
}
@ -107,13 +141,27 @@ class BlockingReasonUtil(private val appLogic: AppLogic) {
return appLogic.database.category().getCategoriesByChildId(child.id).switchMap {
childCategories ->
Transformations.map(appLogic.database.categoryApp().getCategoryApp(childCategories.map { it.id }, packageName)) {
val categoryAppLevel = appLogic.database.categoryApp().getCategoryApp(childCategories.map { it.id }, packageName)
val categoryAppActivityLevel = enableActivityLevelFiltering.switchMap {
if (it)
appLogic.database.categoryApp().getCategoryApp(childCategories.map { it.id }, "$packageName:$activityName")
else
liveDataFromValue(null as CategoryApp?)
}
val categoryApp = categoryAppLevel.switchMap { appLevel ->
categoryAppActivityLevel.map { activityLevel ->
activityLevel?.let { it to BlockingLevel.Activity } ?: appLevel?.let { it to BlockingLevel.App }
}
}
Transformations.map(categoryApp) {
categoryApp ->
if (categoryApp == null) {
null
} else {
childCategories.find { it.id == categoryApp.categoryId }
childCategories.find { it.id == categoryApp.first.categoryId }?.let { it to categoryApp.second }
}
}
}.switchMap {
@ -127,22 +175,52 @@ class BlockingReasonUtil(private val appLogic: AppLogic) {
defaultCategory.switchMap { categoryEntry2 ->
if (categoryEntry2 == null) {
liveDataFromValue(BlockingReason.NotPartOfAnCategory)
liveDataFromValue(
BlockedReasonDetails(
areNotificationsBlocked = false,
level = BlockingLevel.App,
reason = BlockingReason.NotPartOfAnCategory,
categoryId = null
) as BlockingReasonDetail
)
} else {
getBlockingReasonStep4Point5(categoryEntry2, child, timeZone, false)
getBlockingReasonStep4Point5(categoryEntry2, child, timeZone, false, BlockingLevel.App)
}
}
} else {
getBlockingReasonStep4Point5(categoryEntry, child, timeZone, false)
getBlockingReasonStep4Point5(categoryEntry.first, child, timeZone, false, categoryEntry.second)
}
}
}
private fun getBlockingReasonStep4Point5(category: Category, child: User, timeZone: TimeZone, isParentCategory: Boolean): LiveData<BlockingReason> {
private fun getBlockingReasonStep4Point5(category: Category, child: User, timeZone: TimeZone, isParentCategory: Boolean, blockingLevel: BlockingLevel): LiveData<BlockingReasonDetail> {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "step 4.5")
}
val blockNotifications = category.blockAllNotifications
val nextLevel = getBlockingReasonStep4Point7(category, child, timeZone, isParentCategory, blockingLevel)
return nextLevel.map { blockingReason ->
if (blockingReason == BlockingReason.None) {
NoBlockingReason.getInstance(areNotificationsBlocked = blockNotifications)
} else {
BlockedReasonDetails(
areNotificationsBlocked = blockNotifications,
level = blockingLevel,
reason = blockingReason,
categoryId = category.id
)
}
}
}
private fun getBlockingReasonStep4Point7(category: Category, child: User, timeZone: TimeZone, isParentCategory: Boolean, blockingLevel: BlockingLevel): LiveData<BlockingReason> {
if (BuildConfig.DEBUG) {
Log.d(LOG_TAG, "step 4.7")
}
if (category.temporarilyBlocked) {
return liveDataFromValue(BlockingReason.TemporarilyBlocked)
}
@ -152,8 +230,10 @@ class BlockingReasonUtil(private val appLogic: AppLogic) {
if (child.disableLimitsUntil == 0L) {
areLimitsDisabled = liveDataFromValue(false)
} else {
areLimitsDisabled = timeInMillis.map { timeInMillis ->
child.disableLimitsUntil > timeInMillis
areLimitsDisabled = getTemporarilyTrustedTimeInMillis().map {
trustedTimeInMillis ->
trustedTimeInMillis != null && child.disableLimitsUntil > trustedTimeInMillis
}
}
@ -171,7 +251,7 @@ class BlockingReasonUtil(private val appLogic: AppLogic) {
if (parentCategory == null) {
liveDataFromValue(BlockingReason.None)
} else {
getBlockingReasonStep4Point5(parentCategory, child, timeZone, true)
getBlockingReasonStep4Point7(parentCategory, child, timeZone, true, blockingLevel)
}
}
} else {
@ -185,7 +265,7 @@ class BlockingReasonUtil(private val appLogic: AppLogic) {
Log.d(LOG_TAG, "step 5")
}
return Transformations.switchMap(getMinuteOfWeekLive(appLogic.timeApi, timeZone)) {
return Transformations.switchMap(getTrustedMinuteOfWeekLive(appLogic.timeApi, timeZone)) {
trustedMinuteOfWeek ->
if (category.blockedMinutesInWeek.dataNotToModify.isEmpty) {
@ -203,7 +283,7 @@ class BlockingReasonUtil(private val appLogic: AppLogic) {
Log.d(LOG_TAG, "step 6")
}
return getDateLive(appLogic.timeApi, timeZone).switchMap {
return getTrustedDateLive(appLogic.timeApi, timeZone).switchMap {
nowTrustedDate ->
appLogic.database.timeLimitRules().getTimeLimitRulesByCategory(category.id).switchMap {
@ -212,12 +292,20 @@ class BlockingReasonUtil(private val appLogic: AppLogic) {
if (rules.isEmpty()) {
liveDataFromValue(BlockingReason.None)
} else {
getBlockingReasonStep7(category, nowTrustedDate, rules)
getBlockingReasonStep6(category, nowTrustedDate, rules)
}
}
}
}
private fun getBlockingReasonStep6(category: Category, nowTrustedDate: DateInTimezone, rules: List<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> {
if (BuildConfig.DEBUG) {
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?> {
return liveDataFromFunction {
appLogic.timeApi.getCurrentTimeInMillis()
}
}
private fun getMinuteOfWeekLive(api: TimeApi, timeZone: TimeZone): LiveData<Int> = liveDataFromFunction {
getMinuteOfWeek(api.getCurrentTimeInMillis(), timeZone)
}.ignoreUnchanged()
private fun getTrustedMinuteOfWeekLive(api: TimeApi, timeZone: TimeZone): LiveData<Int> {
return object: LiveData<Int>() {
fun update() {
val timeInMillis = appLogic.timeApi.getCurrentTimeInMillis()
private fun getDateLive(api: TimeApi, timeZone: TimeZone): LiveData<DateInTimezone> = liveDataFromFunction {
DateInTimezone.newInstance(api.getCurrentTimeInMillis(), timeZone)
value = getMinuteOfWeek(timeInMillis, timeZone)
}
init {
update()
}
val scheduledUpdateRunnable = Runnable {
update()
scheduleUpdate()
}
fun scheduleUpdate() {
api.runDelayed(scheduledUpdateRunnable, 1000L /* every second */)
}
fun cancelScheduledUpdate() {
api.cancelScheduledAction(scheduledUpdateRunnable)
}
override fun onActive() {
super.onActive()
update()
scheduleUpdate()
}
override fun onInactive() {
super.onInactive()
cancelScheduledUpdate()
}
}.ignoreUnchanged()
}
private fun getTrustedDateLive(api: TimeApi, timeZone: TimeZone): LiveData<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 io.timelimit.android.coroutines.runAsyncExpectForever
import io.timelimit.android.data.model.AppActivity
import io.timelimit.android.data.model.UserType
import io.timelimit.android.livedata.*
import io.timelimit.android.sync.actions.AddInstalledAppsAction
import io.timelimit.android.sync.actions.InstalledApp
import io.timelimit.android.sync.actions.RemoveInstalledAppsAction
import io.timelimit.android.sync.actions.*
import io.timelimit.android.sync.actions.apply.ApplyActionUtil
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
@ -36,12 +35,13 @@ class SyncInstalledAppsLogic(val appLogic: AppLogic) {
init {
appLogic.platformIntegration.installedAppsChangeListener = Runnable { requestSync() }
appLogic.deviceEntryIfEnabled.map { it?.id + it?.currentUserId }.ignoreUnchanged().observeForever { requestSync() }
appLogic.deviceEntry.map { it?.id + it?.enableActivityLevelBlocking }.ignoreUnchanged().observeForever { requestSync() }
runAsyncExpectForever { syncLoop() }
}
private suspend fun syncLoop() {
requestSync.postValue(true)
while (true) {
requestSync.waitUntilValueMatches { it == true }
requestSync.value = false
@ -55,12 +55,10 @@ class SyncInstalledAppsLogic(val appLogic: AppLogic) {
private suspend fun doSyncNow() {
doSyncLock.withLock {
val userEntry = appLogic.deviceUserEntry.waitForNullableValue()
if (userEntry == null || userEntry.type != UserType.Child) {
return@withLock
}
val deviceEntry = appLogic.deviceEntry.waitForNullableValue() ?: return@withLock
val deviceId = deviceEntry.id
run {
val currentlyInstalled = appLogic.platformIntegration.getLocalApps().associateBy { app -> app.packageName }
val currentlySaved = appLogic.database.app().getApps().waitForNonNullValue().associateBy { app -> app.packageName }
@ -74,16 +72,16 @@ class SyncInstalledAppsLogic(val appLogic: AppLogic) {
// save the changes
if (itemsToRemove.isNotEmpty()) {
ApplyActionUtil.applyAppLogicAction(
RemoveInstalledAppsAction(packageNames = itemsToRemove.keys.toList()),
appLogic
action = RemoveInstalledAppsAction(packageNames = itemsToRemove.keys.toList()),
appLogic = appLogic,
ignoreIfDeviceIsNotConfigured = true
)
}
if (itemsToAdd.isNotEmpty()) {
ApplyActionUtil.applyAppLogicAction(
AddInstalledAppsAction(
apps = itemsToAdd.map {
(_, app) ->
action = AddInstalledAppsAction(
apps = itemsToAdd.map { (_, app) ->
InstalledApp(
packageName = app.packageName,
@ -93,9 +91,49 @@ class SyncInstalledAppsLogic(val appLogic: AppLogic) {
)
}
),
appLogic
appLogic = appLogic,
ignoreIfDeviceIsNotConfigured = true
)
}
}
run {
fun buildKey(activity: AppActivity) = "${activity.appPackageName}:${activity.activityClassName}"
val currentlyInstalled = (
if (deviceEntry.enableActivityLevelBlocking)
appLogic.platformIntegration.getLocalAppActivities(deviceId = deviceId)
else
emptyList()
).associateBy { buildKey(it) }
val currentlySaved = appLogic.database.appActivity().getAppActivitiesByDeviceIds(deviceIds = listOf(deviceId)).waitForNonNullValue().associateBy { buildKey(it) }
// skip all items for removal which are still saved locally
val itemsToRemove = HashMap(currentlySaved)
currentlyInstalled.forEach { (packageName, _) -> itemsToRemove.remove(packageName) }
// only add items which are not the same locally
val itemsToAdd = currentlyInstalled.filter { (packageName, app) -> currentlySaved[packageName] != app }
// save the changes
if (itemsToRemove.isNotEmpty() or itemsToAdd.isNotEmpty()) {
ApplyActionUtil.applyAppLogicAction(
action = UpdateAppActivitiesAction(
removedActivities = itemsToRemove.map { it.value.appPackageName to it.value.activityClassName },
updatedOrAddedActivities = itemsToAdd.map { item ->
AppActivityItem(
packageName = item.value.appPackageName,
className = item.value.activityClassName,
title = item.value.title
)
}
),
appLogic = appLogic,
ignoreIfDeviceIsNotConfigured = true
)
}
}
}
}
}

View file

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

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() {
init {
IdGenerator.assertIdValid(categoryId)
@ -126,6 +144,11 @@ data class UpdateCategoryTemporarilyBlockedAction(val categoryId: String, val bl
IdGenerator.assertIdValid(categoryId)
}
}
data class UpdateCategoryTimeWarningsAction(val categoryId: String, val enable: Boolean, val flags: Int): ParentAction() {
init {
IdGenerator.assertIdValid(categoryId)
}
}
data class SetCategoryForUnassignedApps(val childId: String, val categoryId: String): ParentAction() {
// category id can be empty
@ -155,16 +178,22 @@ data class UpdateDeviceStatusAction(
val newProtectionLevel: ProtectionLevel?,
val newUsageStatsPermissionStatus: RuntimePermissionStatus?,
val newNotificationAccessPermission: NewPermissionStatus?,
val newOverlayPermission: RuntimePermissionStatus?,
val newAccessibilityServiceEnabled: Boolean?,
val newAppVersion: Int?,
val didReboot: Boolean
val didReboot: Boolean,
val isQOrLaterNow: Boolean
): AppLogicAction() {
companion object {
val empty = UpdateDeviceStatusAction(
newProtectionLevel = null,
newUsageStatsPermissionStatus = null,
newNotificationAccessPermission = null,
newOverlayPermission = null,
newAccessibilityServiceEnabled = null,
newAppVersion = null,
didReboot = false
didReboot = false,
isQOrLaterNow = false
)
}
@ -182,6 +211,8 @@ data class IgnoreManipulationAction(
val ignoreAppDowngrade: Boolean,
val ignoreNotificationAccessManipulation: Boolean,
val ignoreUsageStatsAccessManipulation: Boolean,
val ignoreOverlayPermissionManipulation: Boolean,
val ignoreAccessibilityServiceManipulation: Boolean,
val ignoreReboot: Boolean,
val ignoreHadManipulation: Boolean
): ParentAction() {
@ -211,18 +242,50 @@ data class SetDeviceUserAction(val deviceId: String, val userId: String): Parent
}
}
data class SetDeviceDefaultUserAction(val deviceId: String, val defaultUserId: String): ParentAction() {
init {
IdGenerator.assertIdValid(deviceId)
if (defaultUserId.isNotEmpty()) {
IdGenerator.assertIdValid(defaultUserId)
}
}
}
data class SetDeviceDefaultUserTimeoutAction(val deviceId: String, val timeout: Int): ParentAction() {
init {
IdGenerator.assertIdValid(deviceId)
if (timeout < 0) {
throw IllegalArgumentException("can not set a negative default user timeout")
}
}
}
data class SetConsiderRebootManipulationAction(val deviceId: String, val considerRebootManipulation: Boolean): ParentAction() {
init {
IdGenerator.assertIdValid(deviceId)
}
}
data class UpdateEnableActivityLevelBlocking(val deviceId: String, val enable: Boolean): ParentAction() {
init {
IdGenerator.assertIdValid(deviceId)
}
}
data class UpdateCategoryBlockedTimesAction(val categoryId: String, val blockedTimes: ImmutableBitmask): ParentAction() {
init {
IdGenerator.assertIdValid(categoryId)
}
}
data class UpdateCategoryBlockAllNotificationsAction(val categoryId: String, val blocked: Boolean): ParentAction() {
init {
IdGenerator.assertIdValid(categoryId)
}
}
data class CreateTimeLimitRuleAction(val rule: TimeLimitRule): ParentAction()
data class UpdateTimeLimitRuleAction(val ruleId: String, val dayMask: Byte, val maximumTimeInMillis: Int, val applyToExtraTimeUsage: Boolean): ParentAction() {

View file

@ -30,14 +30,40 @@ import io.timelimit.android.sync.actions.dispatch.LocalDatabaseAppLogicActionDis
import io.timelimit.android.sync.actions.dispatch.LocalDatabaseParentActionDispatcher
object ApplyActionUtil {
suspend fun applyAppLogicAction(action: AppLogicAction, appLogic: AppLogic) {
applyAppLogicAction(action, appLogic.database, appLogic.manipulationLogic)
suspend fun applyAppLogicAction(
action: AppLogicAction,
appLogic: AppLogic,
ignoreIfDeviceIsNotConfigured: Boolean
) {
applyAppLogicAction(action, appLogic.database, appLogic.manipulationLogic, ignoreIfDeviceIsNotConfigured)
}
private suspend fun applyAppLogicAction(action: AppLogicAction, database: Database, manipulationLogic: ManipulationLogic) {
private suspend fun applyAppLogicAction(
action: AppLogicAction,
database: Database,
manipulationLogic: ManipulationLogic,
ignoreIfDeviceIsNotConfigured: Boolean
) {
// uncomment this if you need to know what's dispatching an action
/*
if (BuildConfig.DEBUG) {
try {
throw Exception()
} catch (ex: Exception) {
Log.d(LOG_TAG, "handling action: $action", ex)
}
}
*/
Threads.database.executeAndWait {
database.transaction().use {
LocalDatabaseAppLogicActionDispatcher.dispatchAppLogicActionSync(action, database.config().getOwnDeviceIdSync()!!, database, manipulationLogic)
val ownDeviceId = database.config().getOwnDeviceIdSync()
if (ownDeviceId == null && ignoreIfDeviceIsNotConfigured) {
return@executeAndWait
}
LocalDatabaseAppLogicActionDispatcher.dispatchAppLogicActionSync(action, ownDeviceId!!, database, manipulationLogic)
database.setTransactionSuccessful()
}

View file

@ -17,6 +17,7 @@ package io.timelimit.android.sync.actions.dispatch
import io.timelimit.android.data.Database
import io.timelimit.android.data.model.App
import io.timelimit.android.data.model.AppActivity
import io.timelimit.android.data.model.UsedTimeItem
import io.timelimit.android.integration.platform.NewPermissionStatusUtil
import io.timelimit.android.integration.platform.ProtectionLevelUtil
@ -148,6 +149,42 @@ object LocalDatabaseAppLogicActionDispatcher {
}
}
if (action.newOverlayPermission != null) {
if (device.currentOverlayPermission != action.newOverlayPermission) {
device = device.copy(
currentOverlayPermission = action.newOverlayPermission
)
if (RuntimePermissionStatusUtil.toInt(action.newOverlayPermission) > RuntimePermissionStatusUtil.toInt(device.highestOverlayPermission)) {
device = device.copy(
highestOverlayPermission = action.newOverlayPermission
)
}
if (device.currentOverlayPermission != device.highestOverlayPermission) {
device = device.copy(hadManipulation = true)
}
}
}
if (action.newAccessibilityServiceEnabled != null) {
if (device.accessibilityServiceEnabled != action.newAccessibilityServiceEnabled) {
device = device.copy(
accessibilityServiceEnabled = action.newAccessibilityServiceEnabled
)
if (action.newAccessibilityServiceEnabled) {
device = device.copy(
wasAccessibilityServiceEnabled = true
)
}
if (device.accessibilityServiceEnabled != device.wasAccessibilityServiceEnabled) {
device = device.copy(hadManipulation = true)
}
}
}
if (action.newAppVersion != null) {
if (device.currentAppVersion != action.newAppVersion) {
device = device.copy(
@ -167,6 +204,10 @@ object LocalDatabaseAppLogicActionDispatcher {
)
}
if (action.isQOrLaterNow && !device.qOrLater) {
device = device.copy(qOrLater = true)
}
database.device().updateDeviceEntry(device)
if (device.hasActiveManipulationWarning) {
@ -186,6 +227,52 @@ object LocalDatabaseAppLogicActionDispatcher {
manipulationLogic.lockDeviceSync()
null
}
is SignOutAtDeviceAction -> {
val deviceEntry = database.device().getDeviceByIdSync(database.config().getOwnDeviceIdSync()!!)!!
if (deviceEntry.defaultUser.isEmpty()) {
throw IllegalStateException("can not sign out without configured default user")
}
LocalDatabaseParentActionDispatcher.dispatchParentActionSync(
SetDeviceUserAction(
deviceId = deviceEntry.id,
userId = deviceEntry.defaultUser
),
database
)
null
}
is UpdateAppActivitiesAction -> {
if (action.updatedOrAddedActivities.isNotEmpty()) {
database.appActivity().addAppActivitiesSync(
action.updatedOrAddedActivities.map { item ->
AppActivity(
deviceId = deviceId,
appPackageName = item.packageName,
activityClassName = item.className,
title = item.title
)
}
)
}
if (action.removedActivities.isNotEmpty()) {
action.removedActivities.groupBy { it.first }.entries.forEach { item ->
val packageName = item.component1()
val activities = item.component2().map { it.second }
database.appActivity().deleteAppActivitiesSync(
deviceId = deviceId,
packageName = packageName,
activities = activities
)
}
}
null
}
}.let { }

View file

@ -74,7 +74,9 @@ object LocalDatabaseParentActionDispatcher {
blockedMinutesInWeek = ImmutableBitmask(BitSet()),
extraTimeInMillis = 0,
temporarilyBlocked = false,
parentCategoryId = ""
parentCategoryId = "",
blockAllNotifications = false,
timeWarnings = 0
))
}
is DeleteCategoryAction -> {
@ -271,6 +273,14 @@ object LocalDatabaseParentActionDispatcher {
deviceEntry = deviceEntry.copy(highestUsageStatsPermission = deviceEntry.currentUsageStatsPermission)
}
if (action.ignoreOverlayPermissionManipulation) {
deviceEntry = deviceEntry.copy(highestOverlayPermission = deviceEntry.currentOverlayPermission)
}
if (action.ignoreAccessibilityServiceManipulation) {
deviceEntry = deviceEntry.copy(wasAccessibilityServiceEnabled = deviceEntry.accessibilityServiceEnabled)
}
if (action.ignoreReboot) {
deviceEntry = deviceEntry.copy(manipulationDidReboot = false)
}
@ -328,6 +338,26 @@ object LocalDatabaseParentActionDispatcher {
timezone = action.timezone
)
}
is SetDeviceDefaultUserAction -> {
if (action.defaultUserId.isNotEmpty()) {
DatabaseValidation.assertUserExists(database, action.defaultUserId)
}
DatabaseValidation.assertDeviceExists(database, action.deviceId)
database.device().updateDeviceDefaultUser(
deviceId = action.deviceId,
defaultUserId = action.defaultUserId
)
}
is SetDeviceDefaultUserTimeoutAction -> {
val deviceEntry = database.device().getDeviceByIdSync(action.deviceId)
?: throw IllegalArgumentException("device not found")
database.device().updateDeviceEntry(deviceEntry.copy(
defaultUserTimeout = action.timeout
))
}
is SetConsiderRebootManipulationAction -> {
val deviceEntry = database.device().getDeviceByIdSync(action.deviceId)
?: throw IllegalArgumentException("device not found")
@ -338,6 +368,45 @@ object LocalDatabaseParentActionDispatcher {
)
)
}
is UpdateCategoryBlockAllNotificationsAction -> {
val categoryEntry = database.category().getCategoryByIdSync(action.categoryId)
?: throw IllegalArgumentException("can not update notification blocking for non exsistent category")
database.category().updateCategorySync(
categoryEntry.copy(
blockAllNotifications = action.blocked
)
)
}
is UpdateEnableActivityLevelBlocking -> {
val deviceEntry = database.device().getDeviceByIdSync(action.deviceId)
?: throw IllegalArgumentException("device not found")
database.device().updateDeviceEntry(
deviceEntry.copy(
enableActivityLevelBlocking = action.enable
)
)
}
is UpdateCategoryTimeWarningsAction -> {
val categoryEntry = database.category().getCategoryByIdSync(action.categoryId)
?: throw IllegalArgumentException("category not found")
val modified = if (action.enable)
categoryEntry.copy(
timeWarnings = categoryEntry.timeWarnings or action.flags
)
else
categoryEntry.copy(
timeWarnings = categoryEntry.timeWarnings and (action.flags.inv())
)
if (modified != categoryEntry) {
database.category().updateCategorySync(modified)
}
null
}
}.let { }
database.setTransactionSuccessful()

View file

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

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
}
}

View file

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

View file

@ -16,6 +16,7 @@
package io.timelimit.android.ui.lock
import android.content.Intent
import android.database.sqlite.SQLiteConstraintException
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@ -36,10 +37,7 @@ import io.timelimit.android.data.model.User
import io.timelimit.android.data.model.UserType
import io.timelimit.android.databinding.LockFragmentBinding
import io.timelimit.android.livedata.*
import io.timelimit.android.logic.AppLogic
import io.timelimit.android.logic.BlockingReason
import io.timelimit.android.logic.BlockingReasonUtil
import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.logic.*
import io.timelimit.android.sync.actions.AddCategoryAppsAction
import io.timelimit.android.sync.actions.IncrementCategoryExtraTimeAction
import io.timelimit.android.sync.actions.UpdateCategoryTemporarilyBlockedAction
@ -50,27 +48,39 @@ import io.timelimit.android.ui.main.getActivityViewModel
import io.timelimit.android.ui.manage.child.ManageChildFragmentArgs
import io.timelimit.android.ui.manage.child.advanced.managedisabletimelimits.ManageDisableTimelimitsViewHelper
import io.timelimit.android.ui.manage.child.category.create.CreateCategoryDialogFragment
import io.timelimit.android.ui.view.SelectTimeSpanViewListener
class LockFragment : Fragment() {
companion object {
private const val EXTRA_PACKAGE_NAME = "packageName"
private const val EXTRA_ACTIVITY = "activitiy"
fun newInstance(packageName: String): LockFragment {
fun newInstance(packageName: String, activity: String?): LockFragment {
val result = LockFragment()
val arguments = Bundle()
arguments.putString(EXTRA_PACKAGE_NAME, packageName)
if (activity != null) {
arguments.putString(EXTRA_ACTIVITY, activity)
}
result.arguments = arguments
return result
}
}
private val packageName: String by lazy { arguments!!.getString(EXTRA_PACKAGE_NAME) }
private val packageName: String by lazy { arguments!!.getString(EXTRA_PACKAGE_NAME)!! }
private val activityName: String? by lazy {
if (arguments!!.containsKey(EXTRA_ACTIVITY))
arguments!!.getString(EXTRA_ACTIVITY)
else
null
}
private val auth: ActivityViewModel by lazy { getActivityViewModel(activity!!) }
private val logic: AppLogic by lazy { DefaultAppLogic.with(context!!) }
private val title: String? by lazy { logic.platformIntegration.getLocalAppTitle(packageName) }
private val blockingReason: LiveData<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 {
val binding = LockFragmentBinding.inflate(layoutInflater, container, false)
@ -83,8 +93,14 @@ class LockFragment : Fragment() {
doesSupportAuth = liveDataFromValue(true)
)
val enableActivityLevelBlocking = logic.deviceEntry.map { it?.enableActivityLevelBlocking ?: false }
binding.packageName = packageName
enableActivityLevelBlocking.observe(this, Observer {
binding.activityName = if (it) activityName?.removePrefix(packageName) else null
})
if (title != null) {
binding.appTitle = title
} else {
@ -94,11 +110,16 @@ class LockFragment : Fragment() {
binding.appIcon.setImageDrawable(logic.platformIntegration.getAppIcon(packageName))
blockingReason.observe(this, Observer {
if (it == BlockingReason.None) {
activity!!.finish()
} else {
binding.reason = it
when (it) {
is NoBlockingReason -> activity!!.finish()
is BlockedReasonDetails -> {
binding.reason = it.reason
binding.blockedKindLabel = when (it.level) {
BlockingLevel.Activity -> "Activity"
BlockingLevel.App -> "App"
}
}
}.let { /* require handling all cases */ }
})
val categories = logic.deviceUserEntry.switchMap {
@ -124,13 +145,14 @@ class LockFragment : Fragment() {
} else {
val (_, categoryItems) = status
Transformations.map(logic.database.categoryApp().getCategoryApp(
categoryItems.map { it.id },
packageName
)) {
appEntry ->
categoryItems.find { it.id == appEntry?.categoryId }
blockingReason.map { reason ->
if (reason is BlockedReasonDetails) {
reason.categoryId
} else {
null
}
}.map { categoryId ->
categoryItems.find { it.id == categoryId }
}
}
}
@ -196,6 +218,8 @@ class LockFragment : Fragment() {
if (extraTimeToAdd > 0) {
binding.extraTimeBtnOk.isEnabled = false
binding.extraTimeSelection.clearNumberPickerFocus()
val categoryId = appCategory.waitForNullableValue()?.id
if (categoryId != null) {
@ -215,6 +239,22 @@ class LockFragment : Fragment() {
}
}
logic.database.config().getEnableAlternativeDurationSelectionAsync().observe(this, Observer {
binding.extraTimeSelection.enablePickerMode(it)
})
binding.extraTimeSelection.listener = object: SelectTimeSpanViewListener {
override fun onTimeSpanChanged(newTimeInMillis: Long) {
// ignore
}
override fun setEnablePickerMode(enable: Boolean) {
Threads.database.execute {
logic.database.config().setEnableAlternativeDurationSelectionSync(enable)
}
}
}
// bind disable time limits
logic.deviceUserEntry.observe(this, Observer {
child ->
@ -267,9 +307,16 @@ class LockFragment : Fragment() {
logic.platformIntegration.setSuspendedApps(listOf(packageName), false)
Threads.database.executeAndWait(Runnable {
try {
database.temporarilyAllowedApp().addTemporarilyAllowedAppSync(TemporarilyAllowedApp(
packageName = packageName
))
} catch (ex: SQLiteConstraintException) {
// ignore this
//
// this happens when touching that option more than once very fast
// or if the device is under load
}
})
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -26,6 +26,7 @@ import kotlin.properties.Delegates
class AddAppAdapter: RecyclerView.Adapter<ViewHolder>() {
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() }
val selectedApps = mutableSetOf<String>()
@ -35,6 +36,8 @@ class AddAppAdapter: RecyclerView.Adapter<ViewHolder>() {
notifyDataSetChanged()
}
override fun onAppLongClicked(app: App) = listener?.onAppLongClicked(app) ?: false
}
init {
@ -86,6 +89,10 @@ class AddAppAdapter: RecyclerView.Adapter<ViewHolder>() {
class ViewHolder(val binding: FragmentAddCategoryAppsItemBinding): RecyclerView.ViewHolder(binding.root)
interface ItemHandlers {
interface ItemHandlers: AddAppAdapterListener {
fun onAppClicked(app: App)
}
interface AddAppAdapterListener {
fun onAppLongClicked(app: App): Boolean
}

View file

@ -19,6 +19,7 @@ package io.timelimit.android.ui.manage.category.apps.add
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
@ -27,6 +28,7 @@ import androidx.lifecycle.Observer
import androidx.recyclerview.widget.LinearLayoutManager
import io.timelimit.android.R
import io.timelimit.android.data.Database
import io.timelimit.android.data.model.App
import io.timelimit.android.data.model.UserType
import io.timelimit.android.databinding.FragmentAddCategoryAppsBinding
import io.timelimit.android.extensions.showSafe
@ -39,6 +41,7 @@ import io.timelimit.android.sync.actions.AddCategoryAppsAction
import io.timelimit.android.ui.main.ActivityViewModel
import io.timelimit.android.ui.main.getActivityViewModel
import io.timelimit.android.ui.manage.category.ManageCategoryFragmentArgs
import io.timelimit.android.ui.manage.category.apps.addactivity.AddAppActivitiesDialogFragment
import io.timelimit.android.ui.view.AppFilterView
class AddCategoryAppsFragment : DialogFragment() {
@ -168,6 +171,26 @@ class AddCategoryAppsFragment : DialogFragment() {
adapter.notifyDataSetChanged()
}
adapter.listener = object: AddAppAdapterListener {
override fun onAppLongClicked(app: App): Boolean {
return if (adapter.selectedApps.isEmpty()) {
AddAppActivitiesDialogFragment.newInstance(
childId = params.childId,
categoryId = params.categoryId,
packageName = app.packageName
).show(fragmentManager!!)
dismissAllowingStateLoss()
true
} else {
Toast.makeText(context, R.string.category_apps_add_dialog_cannot_add_activities_already_sth_selected, Toast.LENGTH_LONG).show()
false
}
}
}
return AlertDialog.Builder(context!!, R.style.AppTheme)
.setView(binding.root)
.create()

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 com.google.android.material.snackbar.Snackbar
import io.timelimit.android.R
import io.timelimit.android.async.Threads
import io.timelimit.android.databinding.FragmentCategorySettingsBinding
import io.timelimit.android.logic.AppLogic
import io.timelimit.android.logic.DefaultAppLogic
@ -30,6 +31,7 @@ import io.timelimit.android.sync.actions.SetCategoryExtraTimeAction
import io.timelimit.android.ui.main.ActivityViewModel
import io.timelimit.android.ui.main.getActivityViewModel
import io.timelimit.android.ui.manage.category.ManageCategoryFragmentArgs
import io.timelimit.android.ui.view.SelectTimeSpanViewListener
class CategorySettingsFragment : Fragment() {
companion object {
@ -68,6 +70,20 @@ class CategorySettingsFragment : Fragment() {
auth = auth
)
CategoryNotificationFilter.bind(
view = binding.notificationFilter,
lifecycleOwner = this,
auth = auth,
categoryLive = categoryEntry
)
CategoryTimeWarningView.bind(
view = binding.timeWarnings,
auth = auth,
categoryLive = categoryEntry,
lifecycleOwner = this
)
binding.btnDeleteCategory.setOnClickListener { deleteCategory() }
binding.editCategoryTitleGo.setOnClickListener { renameCategory() }
@ -82,6 +98,8 @@ class CategorySettingsFragment : Fragment() {
})
binding.extraTimeBtnOk.setOnClickListener {
binding.extraTimeSelection.clearNumberPickerFocus()
val newExtraTime = binding.extraTimeSelection.timeInMillis
if (
@ -96,6 +114,22 @@ class CategorySettingsFragment : Fragment() {
}
}
appLogic.database.config().getEnableAlternativeDurationSelectionAsync().observe(this, Observer {
binding.extraTimeSelection.enablePickerMode(it)
})
binding.extraTimeSelection.listener = object: SelectTimeSpanViewListener {
override fun onTimeSpanChanged(newTimeInMillis: Long) {
// ignore
}
override fun setEnablePickerMode(enable: Boolean) {
Threads.database.execute {
appLogic.database.config().setEnableAlternativeDurationSelectionSync(enable)
}
}
}
return binding.root
}

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

View file

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

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() {
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
if (adapter.categories!![viewHolder.adapterPosition] == CategoriesIntroductionHeader) {
val index = viewHolder.adapterPosition
val item = if (index == RecyclerView.NO_POSITION) null else adapter.categories!![index]
if (item == CategoriesIntroductionHeader) {
return makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE, ItemTouchHelper.END) or
makeFlag(ItemTouchHelper.ACTION_STATE_IDLE, ItemTouchHelper.END)
} else {

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.data.model.Device
import io.timelimit.android.databinding.FragmentManageDeviceBinding
import io.timelimit.android.extensions.safeNavigate
import io.timelimit.android.integration.platform.ProtectionLevel
import io.timelimit.android.integration.platform.android.AdminReceiver
import io.timelimit.android.livedata.liveDataFromValue
@ -48,6 +49,8 @@ import io.timelimit.android.ui.main.ActivityViewModel
import io.timelimit.android.ui.main.ActivityViewModelHolder
import io.timelimit.android.ui.main.AuthenticationFab
import io.timelimit.android.ui.main.FragmentWithCustomTitle
import io.timelimit.android.ui.manage.device.manage.feature.ManageDeviceFeaturesFragment
import io.timelimit.android.ui.manage.device.manage.permission.ManageDevicePermissionsFragment
class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle {
private val activity: ActivityViewModelHolder by lazy { getActivity() as ActivityViewModelHolder }
@ -70,10 +73,6 @@ class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle {
activityViewModel = auth
)
val userSpinnerAdapter = ArrayAdapter<String>(context!!, android.R.layout.simple_spinner_item).apply {
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
}
// auth
AuthenticationFab.manageAuthenticationFab(
fab = binding.fab,
@ -83,89 +82,41 @@ class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle {
doesSupportAuth = liveDataFromValue(true)
)
// label, id
val userListItems = ArrayList<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 {
override fun openUsageStatsSettings() {
if (binding.isThisDevice == true) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
startActivity(
Intent(Settings.ACTION_USAGE_ACCESS_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
override fun showUserScreen() {
navigation.safeNavigate(
ManageDeviceFragmentDirections.actionManageDeviceFragmentToManageDeviceUserFragment(
deviceId = args.deviceId
),
R.id.manageDeviceFragment
)
}
}
}
override fun openNotificationAccessSettings() {
if (binding.isThisDevice == true) {
try {
startActivity(
Intent("android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS")
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
override fun showPermissionsScreen() {
navigation.safeNavigate(
ManageDeviceFragmentDirections.actionManageDeviceFragmentToManageDevicePermissionsFragment(
deviceId = args.deviceId
),
R.id.manageDeviceFragment
)
} catch (ex: Exception) {
Toast.makeText(
context,
R.string.error_general,
Toast.LENGTH_SHORT
).show()
}
}
}
override fun manageDeviceAdmin() {
if (binding.isThisDevice == true) {
val protectionLevel = logic.platformIntegration.getCurrentProtectionLevel()
if (protectionLevel == ProtectionLevel.None) {
if (InformAboutDeviceOwnerDialogFragment.shouldShow) {
startActivity(
Intent(DevicePolicyManager.ACTION_ADD_DEVICE_ADMIN)
.putExtra(
DevicePolicyManager.EXTRA_DEVICE_ADMIN,
ComponentName(context!!, AdminReceiver::class.java)
override fun showFeaturesScreen() {
navigation.safeNavigate(
ManageDeviceFragmentDirections.actionManageDeviceFragmentToManageDeviceFeaturesFragment(
deviceId = args.deviceId
),
R.id.manageDeviceFragment
)
)
} else {
InformAboutDeviceOwnerDialogFragment().show(fragmentManager!!)
}
} else {
startActivity(
Intent(Settings.ACTION_SECURITY_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
}
override fun editDeviceTitle() {
if (auth.requestAuthenticationOrReturnTrue()) {
UpdateDeviceTitleDialogFragment.newInstance(args.deviceId).show(fragmentManager!!)
}
override fun showManageScreen() {
navigation.safeNavigate(
ManageDeviceFragmentDirections.actionManageDeviceFragmentToManageDeviceAdvancedFragment(
deviceId = args.deviceId
),
R.id.manageDeviceFragment
)
}
override fun showAuthenticationScreen() {
@ -173,32 +124,6 @@ class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle {
}
}
binding.userSpinner.adapter = userSpinnerAdapter
binding.userSpinner.onItemSelectedListener = object: AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
val item = userListItems[position]
val userId = item.second
val device = deviceEntry.value
if (device != null) {
if (device.currentUserId != userId) {
if (!auth.tryDispatchParentAction(
SetDeviceUserAction(
deviceId = args.deviceId,
userId = userId
)
)) {
bindUserListSelection()
}
}
}
}
override fun onNothingSelected(parent: AdapterView<*>?) {
// nothing to do
}
}
deviceEntry.observe(this, Observer {
device ->
@ -207,7 +132,6 @@ class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle {
} else {
val now = logic.timeApi.getCurrentTimeInMillis()
binding.deviceTitle = device.name
binding.modelString = device.model
binding.addedAtString = getString(R.string.manage_device_added_at, DateUtils.getRelativeTimeSpanString(
device.addedAt,
@ -215,25 +139,9 @@ class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle {
DateUtils.HOUR_IN_MILLIS
))
binding.usageStatsAccess = device.currentUsageStatsPermission
binding.notificationAccessPermission = device.currentNotificationAccessPermission
binding.protectionLevel = device.currentProtectionLevel
binding.didAppDowngrade = device.currentAppVersion < device.highestAppVersion
}
})
mergeLiveData(deviceEntry, userEntries).observe(this, Observer {
val (device, users) = it!!
if (device != null && users != null) {
userListItems.clear()
userListItems.addAll(
users.map { user -> Pair(user.name, user.id) }
)
userListItems.add(Pair(getString(R.string.manage_device_current_user_none), ""))
bindUserListItems()
bindUserListSelection()
binding.permissionCardText = ManageDevicePermissionsFragment.getPreviewText(device, context!!)
binding.featureCardText = ManageDeviceFeaturesFragment.getPreviewText(device, context!!)
}
})
@ -264,35 +172,27 @@ class ManageDeviceFragment : Fragment(), FragmentWithCustomTitle {
user = userEntry
)
ManageDeviceTroubleshooting.bind(
view = binding.troubleshootingView,
userEntry = userEntry,
lifecycleOwner = this
ActivityLaunchPermissionRequiredAndMissing.bind(
view = binding.activityLaunchPermissionMissing,
lifecycleOwner = this,
device = deviceEntry,
user = userEntry
)
ManageDeviceRebootManipulationView.bind(
view = binding.deviceRebootManipulation,
lifecycleOwner = this,
deviceEntry = deviceEntry,
auth = auth
)
userEntry.observe(this, Observer {
binding.userCardText = it?.name ?: getString(R.string.manage_device_current_user_none)
})
return binding.root
}
override fun onResume() {
super.onResume()
logic.backgroundTaskLogic.syncDeviceStatusAsync()
}
override fun getCustomTitle() = deviceEntry.map { it?.name }
}
interface ManageDeviceFragmentHandlers {
fun openUsageStatsSettings()
fun openNotificationAccessSettings()
fun manageDeviceAdmin()
fun editDeviceTitle()
fun showUserScreen()
fun showPermissionsScreen()
fun showFeaturesScreen()
fun showManageScreen()
fun showAuthenticationScreen()
}

View file

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

View file

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

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
* 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 androidx.lifecycle.LifecycleOwner

View file

@ -13,7 +13,7 @@
* 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
package io.timelimit.android.ui.manage.device.manage.advanced
import android.os.Bundle
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
* 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.LiveData

View file

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

View file

@ -80,7 +80,10 @@ class OverviewFragment : CoroutineFragment() {
ItemTouchHelper(
object: ItemTouchHelper.Callback() {
override fun getMovementFlags(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder): Int {
if (adapter.data!![viewHolder.adapterPosition] == OverviewFragmentHeaderIntro) {
val index = viewHolder.adapterPosition
val item = if (index == RecyclerView.NO_POSITION) null else adapter.data!![index]
if (item == OverviewFragmentHeaderIntro) {
return makeFlag(ItemTouchHelper.ACTION_STATE_SWIPE, ItemTouchHelper.END) or
makeFlag(ItemTouchHelper.ACTION_STATE_IDLE, ItemTouchHelper.END)
} else {

View file

@ -24,7 +24,9 @@ sealed class OverviewFragmentItem
object OverviewFragmentHeaderUsers: OverviewFragmentItem()
object OverviewFragmentHeaderDevices: OverviewFragmentItem()
data class OverviewFragmentItemDevice(val device: Device, val deviceUser: User?, val isCurrentDevice: Boolean): OverviewFragmentItem() {
val isMissingRequiredPermission = deviceUser?.type == UserType.Child && device.currentUsageStatsPermission == RuntimePermissionStatus.NotGranted
val isMissingRequiredPermission = deviceUser?.type == UserType.Child && (
device.currentUsageStatsPermission == RuntimePermissionStatus.NotGranted || device.missingPermissionAtQOrLater
)
}
data class OverviewFragmentItemUser(val user: User, val temporarilyBlocked: Boolean, val limitsTemporarilyDisabled: Boolean): OverviewFragmentItem()
object OverviewFragmentActionAddUser: OverviewFragmentItem()

View file

@ -18,6 +18,7 @@ package io.timelimit.android.ui.setup
import android.app.admin.DevicePolicyManager
import android.content.ComponentName
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.Settings
@ -34,7 +35,7 @@ import io.timelimit.android.integration.platform.ProtectionLevel
import io.timelimit.android.integration.platform.android.AdminReceiver
import io.timelimit.android.logic.AppLogic
import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.ui.manage.device.manage.InformAboutDeviceOwnerDialogFragment
import io.timelimit.android.ui.manage.device.manage.permission.InformAboutDeviceOwnerDialogFragment
class SetupDevicePermissionsFragment : Fragment() {
private val logic: AppLogic by lazy { DefaultAppLogic.with(context!!) }
@ -93,6 +94,22 @@ class SetupDevicePermissionsFragment : Fragment() {
}
}
override fun openDrawOverOtherAppsScreen() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
startActivity(
Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:" + context!!.packageName))
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
override fun openAccessibilitySettings() {
startActivity(
Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
override fun gotoNextStep() {
navigation.safeNavigate(
SetupDevicePermissionsFragmentDirections
@ -113,6 +130,8 @@ class SetupDevicePermissionsFragment : Fragment() {
binding.notificationAccessPermission = platform.getNotificationAccessPermissionStatus()
binding.protectionLevel = platform.getCurrentProtectionLevel()
binding.usageStatsAccess = platform.getForegroundAppPermissionStatus()
binding.overlayPermission = platform.getOverlayPermissionStatus()
binding.accessibilityServiceEnabled = platform.isAccessibilityServiceEnabled()
}
override fun onResume() {
@ -126,5 +145,7 @@ interface SetupDevicePermissionsHandlers {
fun manageDeviceAdmin()
fun openUsageStatsSettings()
fun openNotificationAccessSettings()
fun openDrawOverOtherAppsScreen()
fun openAccessibilitySettings()
fun gotoNextStep()
}

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))
}

View file

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

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