diff --git a/app/schemas/io.timelimit.android.data.RoomDatabase/39.json b/app/schemas/io.timelimit.android.data.RoomDatabase/39.json index 4708db7..cb4b6a5 100644 --- a/app/schemas/io.timelimit.android.data.RoomDatabase/39.json +++ b/app/schemas/io.timelimit.android.data.RoomDatabase/39.json @@ -377,7 +377,7 @@ "notNull": true }, { - "fieldPath": "packageName", + "fieldPath": "appSpecifierString", "columnName": "package_name", "affinity": "TEXT", "notNull": true diff --git a/app/src/main/java/io/timelimit/android/data/dao/AppActivityDao.kt b/app/src/main/java/io/timelimit/android/data/dao/AppActivityDao.kt index af6feef..78b700f 100644 --- a/app/src/main/java/io/timelimit/android/data/dao/AppActivityDao.kt +++ b/app/src/main/java/io/timelimit/android/data/dao/AppActivityDao.kt @@ -1,3 +1,18 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ package io.timelimit.android.data.dao import androidx.lifecycle.LiveData @@ -6,6 +21,7 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import io.timelimit.android.data.model.AppActivity +import io.timelimit.android.data.model.AppActivityTitleAndClassNameItem @Dao interface AppActivityDao { @@ -15,8 +31,8 @@ interface AppActivityDao { @Query("SELECT * FROM app_activity WHERE device_id IN (:deviceIds)") fun getAppActivitiesByDeviceIds(deviceIds: List): LiveData> - @Query("SELECT * FROM app_activity WHERE app_package_name = :packageName") - fun getAppActivitiesByPackageName(packageName: String): LiveData> + @Query("SELECT DISTINCT activity_class_name, activity_title FROM app_activity WHERE app_package_name = :packageName") + fun getAppActivitiesByPackageName(packageName: String): LiveData> @Insert(onConflict = OnConflictStrategy.REPLACE) fun addAppActivitySync(item: AppActivity) diff --git a/app/src/main/java/io/timelimit/android/data/dao/DeviceDao.kt b/app/src/main/java/io/timelimit/android/data/dao/DeviceDao.kt index 88d0551..1c25973 100644 --- a/app/src/main/java/io/timelimit/android/data/dao/DeviceDao.kt +++ b/app/src/main/java/io/timelimit/android/data/dao/DeviceDao.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2021 Jonas Lochmann + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,9 +17,7 @@ package io.timelimit.android.data.dao import androidx.lifecycle.LiveData import androidx.room.* -import io.timelimit.android.data.model.Device -import io.timelimit.android.data.model.NetworkTime -import io.timelimit.android.data.model.NetworkTimeAdapter +import io.timelimit.android.data.model.* import io.timelimit.android.integration.platform.NewPermissionStatusConverter import io.timelimit.android.integration.platform.ProtectionLevelConverter import io.timelimit.android.integration.platform.RuntimePermissionStatusConverter @@ -41,6 +39,9 @@ abstract class DeviceDao { @Query("SELECT * FROM device ORDER BY id") abstract fun getAllDevicesLive(): LiveData> + @Query("SELECT id, name FROM device") + abstract fun getDeviceNamesLive(): LiveData> + @Query("SELECT * FROM device") abstract fun getAllDevicesSync(): List @@ -68,6 +69,9 @@ abstract class DeviceDao { @Query("SELECT * FROM device WHERE current_user_id = :userId") abstract fun getDevicesByUserId(userId: String): LiveData> + @Query("SELECT id FROM device WHERE current_user_id = :userId") + abstract fun getDevicesIdByUserId(userId: String): LiveData> + @Query("UPDATE device SET apps_version = \"\"") abstract fun deleteAllInstalledAppsVersions() diff --git a/app/src/main/java/io/timelimit/android/data/model/AppActivityTitleAndClassNameItem.kt b/app/src/main/java/io/timelimit/android/data/model/AppActivityTitleAndClassNameItem.kt new file mode 100644 index 0000000..ef4912a --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/model/AppActivityTitleAndClassNameItem.kt @@ -0,0 +1,24 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */package io.timelimit.android.data.model + +import androidx.room.ColumnInfo + +class AppActivityTitleAndClassNameItem ( + @ColumnInfo(name = "activity_class_name") + val activityClassName: String, + @ColumnInfo(name = "activity_title") + val title: String +) \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/model/CategoryApp.kt b/app/src/main/java/io/timelimit/android/data/model/CategoryApp.kt index 7118f3a..6bac37f 100644 --- a/app/src/main/java/io/timelimit/android/data/model/CategoryApp.kt +++ b/app/src/main/java/io/timelimit/android/data/model/CategoryApp.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 Jonas Lochmann + * TimeLimit Copyright 2019 - 2022 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 @@ -21,13 +21,14 @@ import androidx.room.ColumnInfo import androidx.room.Entity import io.timelimit.android.data.IdGenerator import io.timelimit.android.data.JsonSerializable +import io.timelimit.android.data.model.derived.AppSpecifier @Entity(primaryKeys = ["category_id", "package_name"], tableName = "category_app") data class CategoryApp( @ColumnInfo(index = true, name = "category_id") val categoryId: String, @ColumnInfo(index = true, name = "package_name") - val packageName: String + val appSpecifierString: String // originally a packageName, but can contain more than that ): JsonSerializable { companion object { private const val CATEGORY_ID = "c" @@ -51,26 +52,18 @@ data class CategoryApp( return CategoryApp( categoryId = categoryId!!, - packageName = packageName!! + appSpecifierString = packageName!! ) } } @delegate:Transient - val packageNameWithoutActivityName: String by lazy { - if (specifiesActivity) - packageName.substring(0, packageName.indexOf(":")) - else - packageName - } - - @Transient - val specifiesActivity = packageName.contains(":") + val appSpecifier: AppSpecifier by lazy { AppSpecifier.decode(appSpecifierString) } init { IdGenerator.assertIdValid(categoryId) - if (packageName.isEmpty()) { + if (appSpecifierString.isEmpty()) { throw IllegalArgumentException() } } @@ -79,7 +72,7 @@ data class CategoryApp( writer.beginObject() writer.name(CATEGORY_ID).value(categoryId) - writer.name(PACKAGE_NAME).value(packageName) + writer.name(PACKAGE_NAME).value(appSpecifierString) writer.endObject() } diff --git a/app/src/main/java/io/timelimit/android/data/model/DeviceId.kt b/app/src/main/java/io/timelimit/android/data/model/DeviceId.kt new file mode 100644 index 0000000..be76f94 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/model/DeviceId.kt @@ -0,0 +1,23 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.data.model + +import androidx.room.ColumnInfo + +data class DeviceId ( + @ColumnInfo(name = "id") + val id: String +) \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/model/DeviceName.kt b/app/src/main/java/io/timelimit/android/data/model/DeviceName.kt new file mode 100644 index 0000000..0108948 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/model/DeviceName.kt @@ -0,0 +1,25 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.data.model + +import androidx.room.ColumnInfo + +data class DeviceName ( + @ColumnInfo(name = "id") + val id: String, + @ColumnInfo(name = "name") + val name: String +) \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/model/derived/AppSpecifier.kt b/app/src/main/java/io/timelimit/android/data/model/derived/AppSpecifier.kt new file mode 100644 index 0000000..96ea68f --- /dev/null +++ b/app/src/main/java/io/timelimit/android/data/model/derived/AppSpecifier.kt @@ -0,0 +1,67 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.data.model.derived + +import kotlin.text.StringBuilder + +data class AppSpecifier(val packageName: String, val activityName: String?, val deviceId: String?) { + companion object { + fun decode(input: String): AppSpecifier { + val activityIndex = input.indexOf(':') + val deviceIndex = input.indexOf('@', startIndex = activityIndex) + val packageNameEndIndex = if (activityIndex != -1) activityIndex else deviceIndex + + val packageName = if (packageNameEndIndex == -1) input else input.substring(0, packageNameEndIndex) + val activityName = if (activityIndex == -1) null else { + input.substring(activityIndex + 1, if (deviceIndex == -1) input.length else deviceIndex) + } + val deviceId = if (deviceIndex == -1) null else input.substring(deviceIndex + 1) + + return AppSpecifier( + packageName = packageName, + activityName = activityName, + deviceId = deviceId + ) + } + } + + init { + if (packageName.indexOf(':') != -1 || packageName.indexOf(':') != -1) { + throw InvalidValueException() + } + + if (activityName != null && activityName?.indexOf('@') != -1) { + throw InvalidValueException() + } + } + + fun encode(): String = StringBuilder().let { builder -> + builder.append(packageName) + + if (activityName != null) { + builder.append(':').append(activityName) + } + + if (deviceId != null) { + builder.append('@').append(deviceId) + } + + builder.trimToSize() + builder.toString() + } + + class InvalidValueException: RuntimeException() +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/data/model/derived/UserRelatedData.kt b/app/src/main/java/io/timelimit/android/data/model/derived/UserRelatedData.kt index 3ba108e..9ba06a0 100644 --- a/app/src/main/java/io/timelimit/android/data/model/derived/UserRelatedData.kt +++ b/app/src/main/java/io/timelimit/android/data/model/derived/UserRelatedData.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2021 Jonas Lochmann + * TimeLimit Copyright 2019 - 2022 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 @@ -34,7 +34,7 @@ data class UserRelatedData( val categoryApps: List ): Observer { companion object { - private val notFoundCategoryApp = CategoryApp(categoryId = IdGenerator.generateId(), packageName = BuildConfig.APPLICATION_ID) + private val notFoundCategoryApp = CategoryApp(categoryId = IdGenerator.generateId(), appSpecifierString = BuildConfig.APPLICATION_ID) private val relatedTables = arrayOf( Table.User, Table.Category, Table.TimeLimitRule, @@ -72,11 +72,11 @@ data class UserRelatedData( // notFoundCategoryApp is a workaround because the lru cache does not support null private val categoryAppLruCache = object: LruCache(8) { override fun create(key: String): CategoryApp { - return categoryApps.find { it.packageName == key } ?: notFoundCategoryApp + return categoryApps.find { it.appSpecifierString == key } ?: notFoundCategoryApp } } - fun findCategoryApp(packageName: String): CategoryApp? { - val item = categoryAppLruCache[packageName] + private fun findCategoryApp(appSpecifier: AppSpecifier): CategoryApp? { + val item = categoryAppLruCache[appSpecifier.encode()] // important: strict equality/ same object instance if (item === notFoundCategoryApp) { @@ -85,6 +85,15 @@ data class UserRelatedData( return item } } + fun findCategoryAppTryDeviceSpecificFirst(packageName: String, activityName: String?, deviceId: String): CategoryApp? = findCategoryApp(AppSpecifier( + packageName = packageName, + activityName = activityName, + deviceId = deviceId + )) ?: findCategoryApp(AppSpecifier( + packageName = packageName, + activityName = activityName, + deviceId = null + )) private var userInvalidated = false private var categoriesInvalidated = false diff --git a/app/src/main/java/io/timelimit/android/logic/AppSetupLogic.kt b/app/src/main/java/io/timelimit/android/logic/AppSetupLogic.kt index cf11d1f..f788251 100644 --- a/app/src/main/java/io/timelimit/android/logic/AppSetupLogic.kt +++ b/app/src/main/java/io/timelimit/android/logic/AppSetupLogic.kt @@ -223,7 +223,7 @@ class AppSetupLogic(private val appLogic: AppLogic) { .map { CategoryApp( categoryId = allowedAppsCategoryId, - packageName = it.packageName + appSpecifierString = it.packageName ) } ) diff --git a/app/src/main/java/io/timelimit/android/logic/SuspendAppsLogic.kt b/app/src/main/java/io/timelimit/android/logic/SuspendAppsLogic.kt index 62b5f44..bbc5989 100644 --- a/app/src/main/java/io/timelimit/android/logic/SuspendAppsLogic.kt +++ b/app/src/main/java/io/timelimit/android/logic/SuspendAppsLogic.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * TimeLimit Copyright 2019 - 2022 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 @@ -152,7 +152,7 @@ class SuspendAppsLogic(private val appLogic: AppLogic): Observer { lastDefaultCategory = defaultCategory val installedApps = appLogic.platformIntegration.getLocalAppPackageNames() - val prepared = getAppsWithCategories(installedApps, userRelatedData, blockingAtActivityLevel) + val prepared = getAppsWithCategories(installedApps, userRelatedData, blockingAtActivityLevel, userAndDeviceRelatedData.deviceRelatedData.deviceEntry.id) val appsToBlock = mutableListOf() installedApps.forEach { packageName -> @@ -169,19 +169,32 @@ class SuspendAppsLogic(private val appLogic: AppLogic): Observer { } } - private fun getAppsWithCategories(packageNames: List, data: UserRelatedData, blockingAtActivityLevel: Boolean): Map> { + private fun getAppsWithCategories(packageNames: List, data: UserRelatedData, blockingAtActivityLevel: Boolean, deviceId: String): Map> { val categoryForUnassignedApps = data.categoryById[data.user.categoryForNotAssignedApps] - val categoryForOtherSystemApps = data.findCategoryApp(DummyApps.NOT_ASSIGNED_SYSTEM_IMAGE_APP)?.categoryId?.let { data.categoryById[it] } + val categoryForOtherSystemApps = data.findCategoryAppTryDeviceSpecificFirst( + packageName = DummyApps.NOT_ASSIGNED_SYSTEM_IMAGE_APP, + activityName = null, + deviceId = deviceId + )?.categoryId?.let { data.categoryById[it] } + + val globalCategoryApps = data.categoryApps.filter { it.appSpecifier.deviceId == null } + val localCategoryApps = data.categoryApps.filter { it.appSpecifier.deviceId == deviceId } + val localCategoryAppsParams = localCategoryApps.map { it.appSpecifier.packageName to it.appSpecifier.activityName }.toSet() + val effectiveGlobalCategoryApps = globalCategoryApps.filter { + !localCategoryAppsParams.contains(it.appSpecifier.packageName to it.appSpecifier.activityName) && + !localCategoryAppsParams.contains(it.appSpecifier.packageName to null) + } + val effectiveCategoryApps = effectiveGlobalCategoryApps + localCategoryApps if (blockingAtActivityLevel) { - val categoriesByPackageName = data.categoryApps.groupBy { it.packageNameWithoutActivityName } + val categoriesByPackageName = effectiveCategoryApps.groupBy { it.appSpecifier.packageName } val result = mutableMapOf>() packageNames.forEach { packageName -> val categoriesItems = categoriesByPackageName[packageName] val categories = (categoriesItems?.map { it.categoryId }?.toSet() ?: emptySet()).toMutableSet() - val isMainAppIncluded = categoriesItems?.find { !it.specifiesActivity } != null + val isMainAppIncluded = categoriesItems?.find { it.appSpecifier.activityName == null } != null if (!isMainAppIncluded) { if (categoryForOtherSystemApps != null && appLogic.platformIntegration.isSystemImageApp(packageName)) { @@ -196,7 +209,9 @@ class SuspendAppsLogic(private val appLogic: AppLogic): Observer { return result } else { - val categoryByPackageName = data.categoryApps.associateBy { it.packageName } + val categoryByPackageName = effectiveCategoryApps + .filter { it.appSpecifier.activityName == null } + .associateBy { it.appSpecifier.packageName } val result = mutableMapOf>() diff --git a/app/src/main/java/io/timelimit/android/logic/blockingreason/AppBaseHandling.kt b/app/src/main/java/io/timelimit/android/logic/blockingreason/AppBaseHandling.kt index b84f91e..594047c 100644 --- a/app/src/main/java/io/timelimit/android/logic/blockingreason/AppBaseHandling.kt +++ b/app/src/main/java/io/timelimit/android/logic/blockingreason/AppBaseHandling.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * TimeLimit Copyright 2019 - 2022 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 @@ -73,15 +73,31 @@ sealed class AppBaseHandling { } else if (foregroundAppPackageName != null) { val appCategory = run { val tryActivityLevelBlocking = deviceRelatedData.deviceEntry.enableActivityLevelBlocking && foregroundAppActivityName != null - val appLevelCategory = userRelatedData.findCategoryApp(foregroundAppPackageName) ?: run { - if (isSystemImageApp) userRelatedData.findCategoryApp(DummyApps.NOT_ASSIGNED_SYSTEM_IMAGE_APP) else null + val appLevelCategory = userRelatedData.findCategoryAppTryDeviceSpecificFirst( + packageName = foregroundAppPackageName, + activityName = null, + deviceId = deviceRelatedData.deviceEntry.id + ) ?: run { + if (isSystemImageApp) userRelatedData.findCategoryAppTryDeviceSpecificFirst( + packageName = DummyApps.NOT_ASSIGNED_SYSTEM_IMAGE_APP, + activityName = null, + deviceId = deviceRelatedData.deviceEntry.id + ) else null } - (if (tryActivityLevelBlocking) { - userRelatedData.findCategoryApp("$foregroundAppPackageName:$foregroundAppActivityName") - } else { - null - }) ?: appLevelCategory + if (tryActivityLevelBlocking) { + val activityLevelCategory = userRelatedData.findCategoryAppTryDeviceSpecificFirst( + packageName = foregroundAppPackageName, + activityName = foregroundAppActivityName, + deviceId = deviceRelatedData.deviceEntry.id + ) + + val appLevelCategoryMoreImportant = appLevelCategory != null && activityLevelCategory != null && + appLevelCategory.appSpecifier.deviceId != null && + activityLevelCategory.appSpecifier.deviceId == null + + if (activityLevelCategory == null || appLevelCategoryMoreImportant) appLevelCategory else activityLevelCategory + } else appLevelCategory } val startCategory = userRelatedData.categoryById[appCategory?.categoryId] @@ -95,8 +111,7 @@ sealed class AppBaseHandling { return UseCategories( categoryIds = categoryIds, shouldCount = !pauseCounting, - level = when (appCategory?.specifiesActivity) { - null -> BlockingLevel.Activity // occurs when using a default category + level = when (appCategory == null || appCategory.appSpecifier.activityName != null) { true -> BlockingLevel.Activity false -> BlockingLevel.App }, diff --git a/app/src/main/java/io/timelimit/android/sync/ApplyServerDataStatus.kt b/app/src/main/java/io/timelimit/android/sync/ApplyServerDataStatus.kt index 7bce6c6..247585f 100644 --- a/app/src/main/java/io/timelimit/android/sync/ApplyServerDataStatus.kt +++ b/app/src/main/java/io/timelimit/android/sync/ApplyServerDataStatus.kt @@ -458,7 +458,7 @@ object ApplyServerDataStatus { database.categoryApp().addCategoryAppsSync(item.assignedApps.map { CategoryApp( categoryId = item.categoryId, - packageName = it + appSpecifierString = it ) }) diff --git a/app/src/main/java/io/timelimit/android/sync/actions/dispatch/ParentAction.kt b/app/src/main/java/io/timelimit/android/sync/actions/dispatch/ParentAction.kt index 9c22f0a..f7cd8ec 100644 --- a/app/src/main/java/io/timelimit/android/sync/actions/dispatch/ParentAction.kt +++ b/app/src/main/java/io/timelimit/android/sync/actions/dispatch/ParentAction.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2021 Jonas Lochmann + * TimeLimit Copyright 2019 - 2022 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 @@ -53,6 +53,10 @@ object LocalDatabaseParentActionDispatcher { val allCategoriesOfChild = database.category().getCategoriesByChildIdSync(categoryEntry.childId) if (fromChildSelfLimitAddChildUserId != null) { + if (action.packageNames.find { it.contains('@') } != null) { + throw RuntimeException("can not do device specific assignments as child") + } + val parentCategoriesOfTargetCategory = allCategoriesOfChild.getCategoryWithParentCategories(action.categoryId) val userEntry = database.user().getUserByIdSync(fromChildSelfLimitAddChildUserId) ?: throw RuntimeException("user not found") val validatedDefaultCategoryId = (allCategoriesOfChild.find { @@ -99,7 +103,7 @@ object LocalDatabaseParentActionDispatcher { action.packageNames.map { CategoryApp( categoryId = action.categoryId, - packageName = it + appSpecifierString = it ) } ) diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/apps/AddAppsOrActivitiesModel.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/AddAppsOrActivitiesModel.kt new file mode 100644 index 0000000..720d3f0 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/AddAppsOrActivitiesModel.kt @@ -0,0 +1,47 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.category.apps + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.map +import androidx.lifecycle.switchMap +import io.timelimit.android.data.model.UserType +import io.timelimit.android.ui.main.ActivityViewModel +import io.timelimit.android.ui.manage.category.apps.add.AddAppsParams + +class AddAppsOrActivitiesModel(application: Application): AndroidViewModel(application) { + private var didInit = false + private var paramsLive = MutableLiveData() + + fun init(params: AddAppsParams) { + if (didInit) return + + paramsLive.value = params + didInit = true + } + + fun isAuthValid(auth: ActivityViewModel) = paramsLive.switchMap { params -> + auth.authenticatedUserOrChild.map { + val parentAuthValid = it?.second?.type == UserType.Parent + val childAuthValid = it?.second?.id == params.childId && params.isSelfLimitAddingMode + val authValid = parentAuthValid || childAuthValid + + authValid + } + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/apps/add/AddAppAdapter.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/add/AddAppAdapter.kt index 4bfcbe3..b41a80c 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/category/apps/add/AddAppAdapter.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/add/AddAppAdapter.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * TimeLimit Copyright 2019 - 2022 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 @@ -18,39 +18,23 @@ package io.timelimit.android.ui.manage.category.apps.add import android.view.LayoutInflater import android.view.ViewGroup import androidx.recyclerview.widget.RecyclerView -import io.timelimit.android.data.model.App import io.timelimit.android.databinding.FragmentAddCategoryAppsItemBinding import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.logic.DummyApps import kotlin.properties.Delegates class AddAppAdapter: RecyclerView.Adapter() { - var data: List? by Delegates.observable(null as List?) { _, _, _ -> notifyDataSetChanged() } + var data: List by Delegates.observable(emptyList()) { _, _, _ -> notifyDataSetChanged() } var listener: AddAppAdapterListener? = null - var categoryTitleByPackageName: Map by Delegates.observable(emptyMap()) { _, _, _ -> notifyDataSetChanged() } var selectedApps: Set by Delegates.observable(emptySet()) { _, _, _ -> notifyDataSetChanged() } init { setHasStableIds(true) } - private fun getItem(position: Int): App { - return data!![position] - } - - override fun getItemId(position: Int): Long { - return getItem(position).hashCode().toLong() - } - - override fun getItemCount(): Int { - val data = this.data - - if (data == null) { - return 0 - } else { - return data.size - } - } + private fun getItem(position: Int): AddAppListItem = data[position] + override fun getItemId(position: Int): Long = getItem(position).hashCode().toLong() + override fun getItemCount(): Int = data.size override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder( FragmentAddCategoryAppsItemBinding.inflate( @@ -65,10 +49,13 @@ class AddAppAdapter: RecyclerView.Adapter() { val context = holder.itemView.context holder.apply { - binding.item = item + binding.title = item.title + binding.subtitle = item.packageName + binding.currentCategoryTitle = item.currentCategoryName binding.checked = selectedApps.contains(item.packageName) - binding.currentCategoryTitle = categoryTitleByPackageName[item.packageName] - binding.handlers = listener + binding.card.setOnClickListener { listener?.onAppClicked(item) } + binding.card.setOnLongClickListener { listener?.onAppLongClicked(item) ?: false } + binding.showIcon = true binding.executePendingBindings() binding.icon.setImageDrawable( @@ -83,6 +70,6 @@ class AddAppAdapter: RecyclerView.Adapter() { class ViewHolder(val binding: FragmentAddCategoryAppsItemBinding): RecyclerView.ViewHolder(binding.root) interface AddAppAdapterListener { - fun onAppClicked(app: App) - fun onAppLongClicked(app: App): Boolean + fun onAppClicked(app: AddAppListItem) + fun onAppLongClicked(app: AddAppListItem): Boolean } \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/apps/add/AddAppListItem.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/add/AddAppListItem.kt new file mode 100644 index 0000000..63c3315 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/add/AddAppListItem.kt @@ -0,0 +1,22 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.category.apps.add + +data class AddAppListItem ( + val title: String, + val packageName: String, + val currentCategoryName: String? +) \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/apps/add/AddAppsModel.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/add/AddAppsModel.kt new file mode 100644 index 0000000..d945a37 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/add/AddAppsModel.kt @@ -0,0 +1,206 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.category.apps.add + +import android.app.Application +import androidx.lifecycle.* +import androidx.lifecycle.map +import io.timelimit.android.data.extensions.getCategoryWithParentCategories +import io.timelimit.android.data.model.App +import io.timelimit.android.data.model.derived.CategoryRelatedData +import io.timelimit.android.livedata.* +import io.timelimit.android.livedata.ignoreUnchanged +import io.timelimit.android.livedata.switchMap +import io.timelimit.android.logic.DefaultAppLogic +import io.timelimit.android.logic.DummyApps +import io.timelimit.android.ui.view.AppFilterView +import kotlin.collections.map + +class AddAppsModel(application: Application): AndroidViewModel(application) { + private var didInit = false + private var paramsLive = MutableLiveData() + + fun init(params: AddAppsParams) { + if (didInit) return + + paramsLive.value = params + didInit = true + } + + private val logic = DefaultAppLogic.with(application) + private val database = logic.database + + val showAppsFromOtherDevicesChecked = MutableLiveData().apply { value = false } + val showAppsFromOtherCategories = MutableLiveData().apply { value = false } + val assignToThisDeviceOnly = MutableLiveData().apply { value = false } + val filter = MutableLiveData().apply { value = AppFilterView.AppFilter.dummy } + + val isLocalMode = logic.fullVersion.isLocalMode + val showDeviceSpecificAssignmentOption = isLocalMode.invert().and(paramsLive.map { !it.isSelfLimitAddingMode }) + val deviceIdLive = logic.deviceId + + private val effectiveAssignToThisDeviceOnly = assignToThisDeviceOnly.and(showDeviceSpecificAssignmentOption) + + private val realShowAppsFromAllDevices = isLocalMode.switchMap { localMode -> + if (localMode) liveDataFromNonNullValue(true) + else showAppsFromOtherDevicesChecked + } + + private val childDeviceIds = paramsLive.switchMap { params -> + database.device().getDevicesIdByUserId(params.childId).map { devices -> devices.map { it.id } } + }.ignoreUnchanged() + + private val globalChildDeviceCounter = database.device().countDevicesWithChildUser() + + private val hasChildDeviceIds = childDeviceIds.map { it.isNotEmpty() } + + private val appsAtAssignedDevices = childDeviceIds + .switchMap { database.app().getAppsByDeviceIds(it) } + + private val appsAtAllDevices = database.app().getAllApps() + + private val installedApps = realShowAppsFromAllDevices.switchMap { appsFromAllDevices -> + if (appsFromAllDevices) appsAtAllDevices else appsAtAssignedDevices + }.map { list -> + if (list.isEmpty()) list + else list + DummyApps.getApps(deviceId = list.first().deviceId, context = getApplication()) + }.map { apps -> apps.distinctBy { app -> app.packageName } } + + private val userRelatedDataLive = paramsLive.switchMap { params -> + database.derivedDataDao().getUserRelatedDataLive(params.childId) + } + + private val categoryByAppSpecifierLive = userRelatedDataLive.map { data -> + data?.categoryApps?.associateBy { it.appSpecifierString }?.mapValues { + data.categoryById.get(it.value.categoryId) + } ?: emptyMap() + } + + private val installedAppsWithCurrentCategories = mergeLiveDataWaitForValues(deviceIdLive, categoryByAppSpecifierLive, installedApps, effectiveAssignToThisDeviceOnly) + .map { (deviceId, categoryByAppSpecifier, apps, assignToThisDeviceOnly) -> + apps.map { + AppWithCategory(it, categoryByAppSpecifier.get( + if (assignToThisDeviceOnly) "${it.packageName}@$deviceId" else it.packageName + )) + } + } + + private val shownApps = mergeLiveDataWaitForValues(paramsLive, userRelatedDataLive, installedAppsWithCurrentCategories) + .map { (params, userRelatedData, installedApps) -> + if (params.isSelfLimitAddingMode) { + if (userRelatedData == null || !userRelatedData.categoryById.containsKey(params.categoryId)) + emptyList() + else { + val parentCategories = + userRelatedData.getCategoryWithParentCategories(params.categoryId) + val defaultCategory = + userRelatedData.categoryById[userRelatedData.user.categoryForNotAssignedApps] + val allowAppsWithoutCategory = + defaultCategory != null && parentCategories.contains(defaultCategory.category.id) + val packageNameToCategoryId = + userRelatedData.categoryApps + .filter { it.appSpecifier.deviceId == null } + .associateBy { it.appSpecifier.packageName } + + installedApps.filter { app -> + val appCategoryId = packageNameToCategoryId[app.app.packageName]?.categoryId + val categoryNotFound = !userRelatedData.categoryById.containsKey(appCategoryId) + + parentCategories.contains(appCategoryId) || (categoryNotFound && allowAppsWithoutCategory) + } + } + } else installedApps + } + + val listItems = filter.switchMap { filter -> + shownApps.map { filter to it } + }.map { (search, apps) -> + apps.filter { search.matches(it.app) } + }.switchMap { apps -> + showAppsFromOtherCategories.map { showOtherCategeories -> + if (showOtherCategeories) apps + else apps.filter { it.category == null } + } + }.map { apps -> + apps.sortedBy { app -> app.app.title.lowercase() } + }.map { apps -> + apps.map { app -> + AddAppListItem( + title = app.app.title, + packageName = app.app.packageName, + currentCategoryName = app.category?.category?.title + ) + } + } + + val emptyViewText: LiveData = listItems.switchMap { items -> + if (items.isNotEmpty()) { + // list is not empty ... + liveDataFromNonNullValue(EmptyViewText.None) + } else /* items.isEmpty() */ { + shownApps.switchMap { shownApps -> + if (shownApps.isNotEmpty()) { + liveDataFromNonNullValue(EmptyViewText.EmptyDueToFilter) + } else /* if (shownApps.isEmpty()) */ { + installedApps.switchMap { installedApps -> + if (installedApps.isNotEmpty()) { + liveDataFromNonNullValue(EmptyViewText.EmptyDueToChildMode) + } else /* if (installedApps.isEmpty()) */ { + isLocalMode.switchMap { isLocalMode -> + if (isLocalMode) { + liveDataFromNonNullValue(EmptyViewText.EmptyLocalMode) + } else { + showAppsFromOtherDevicesChecked.switchMap { showAppsFromOtherDevicesChecked -> + if (showAppsFromOtherDevicesChecked) { + globalChildDeviceCounter.map { childDeviceCounter -> + if (childDeviceCounter == 0L) { + EmptyViewText.EmptyAllDevicesNoAppsNoChildDevices + } else { + EmptyViewText.EmptyAllDevicesNoAppsButChildDevices + } + } + } else { + hasChildDeviceIds.map { hasChildDeviceIds -> + if (hasChildDeviceIds) { + EmptyViewText.EmptyChildDevicesHaveNoApps + } else { + EmptyViewText.EmptyNoChildDevices + } + } + } + } + } + } + } + } + } + } + } + } + + enum class EmptyViewText { + None, + EmptyDueToFilter, + EmptyDueToChildMode, + EmptyLocalMode, + EmptyAllDevicesNoAppsNoChildDevices, + EmptyAllDevicesNoAppsButChildDevices, + EmptyChildDevicesHaveNoApps, + EmptyNoChildDevices + } + + internal data class AppWithCategory (val app: App, val category: CategoryRelatedData?) +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/apps/add/AddAppsParams.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/add/AddAppsParams.kt new file mode 100644 index 0000000..84ab432 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/add/AddAppsParams.kt @@ -0,0 +1,26 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.category.apps.add + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class AddAppsParams ( + val childId: String, + val categoryId: String, + val isSelfLimitAddingMode: Boolean +): Parcelable diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/apps/add/AddCategoryAppsFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/add/AddCategoryAppsFragment.kt index dc8894b..9b87fd5 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/category/apps/add/AddCategoryAppsFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/add/AddCategoryAppsFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2021 Jonas Lochmann + * TimeLimit Copyright 2019 - 2022 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 @@ -15,7 +15,6 @@ */ package io.timelimit.android.ui.manage.category.apps.add - import android.app.Dialog import android.os.Bundle import android.view.LayoutInflater @@ -27,24 +26,18 @@ import androidx.appcompat.app.AlertDialog import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentManager -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData +import androidx.fragment.app.viewModels import androidx.lifecycle.Observer import androidx.recyclerview.widget.LinearLayoutManager import com.google.android.material.appbar.AppBarLayout import io.timelimit.android.R -import io.timelimit.android.data.Database -import io.timelimit.android.data.extensions.getCategoryWithParentCategories -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 -import io.timelimit.android.livedata.* -import io.timelimit.android.logic.DefaultAppLogic -import io.timelimit.android.logic.DummyApps 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.apps.AddAppsOrActivitiesModel +import io.timelimit.android.ui.manage.category.apps.addactivity.AddActivitiesParams import io.timelimit.android.ui.manage.category.apps.addactivity.AddAppActivitiesDialogFragment import io.timelimit.android.ui.view.AppFilterView @@ -53,23 +46,18 @@ class AddCategoryAppsFragment : DialogFragment() { private const val DIALOG_TAG = "x" private const val STATUS_PACKAGE_NAMES = "d" private const val STATUS_EDUCATED = "e" - private const val PARAM_CHILD_ID = "childId" - private const val PARAM_CATEGORY_ID = "categoryId" - private const val PARAM_CHILD_ADD_LIMIT_MODE = "addLimitMode" + private const val PARAMS = "params" - fun newInstance(childId: String, categoryId: String, childAddLimitMode: Boolean) = AddCategoryAppsFragment().apply { - arguments = Bundle().apply { - putString(PARAM_CHILD_ID, childId) - putString(PARAM_CATEGORY_ID, categoryId) - putBoolean(PARAM_CHILD_ADD_LIMIT_MODE, childAddLimitMode) - } + fun newInstance(params: AddAppsParams) = AddCategoryAppsFragment().apply { + arguments = Bundle().apply { putParcelable(PARAMS, params) } } } - private val database: Database by lazy { DefaultAppLogic.with(requireContext()).database } private val auth: ActivityViewModel by lazy { getActivityViewModel(requireActivity()) } private val adapter = AddAppAdapter() private var didEducateAboutAddingAssignedApps = false + private val baseModel: AddAppsOrActivitiesModel by viewModels() + private val model: AddAppsModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -89,167 +77,43 @@ class AddCategoryAppsFragment : DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val binding = FragmentAddCategoryAppsBinding.inflate(LayoutInflater.from(context)) - val childId = requireArguments().getString(PARAM_CHILD_ID)!! - val categoryId = requireArguments().getString(PARAM_CATEGORY_ID)!! - val childAddLimitMode = requireArguments().getBoolean(PARAM_CHILD_ADD_LIMIT_MODE) + val params = requireArguments().getParcelable(PARAMS)!! - auth.authenticatedUserOrChild.observe(this, Observer { - val parentAuthValid = it?.second?.type == UserType.Parent - val childAuthValid = it?.second?.id == childId && childAddLimitMode - val authValid = parentAuthValid || childAuthValid + baseModel.init(params) + baseModel.isAuthValid(auth).observe(this) { if (!it) dismissAllowingStateLoss() } - if (!authValid) { - dismissAllowingStateLoss() - } - }) + model.init(params) - val filter = AppFilterView.getFilterLive(binding.filter) - val isLocalMode = database.config().getDeviceAuthTokenAsync().map { it.isEmpty() } - val showAppsFromOtherDevicesChecked = MutableLiveData().apply { - value = binding.showAppsFromUnassignedDevices.isChecked - } - val realShowAppsFromAllDevices = isLocalMode.switchMap { localMode -> - if (localMode) { - liveDataFromNonNullValue(true) - } else { - showAppsFromOtherDevicesChecked - } - } + model.showAppsFromOtherDevicesChecked.value = binding.showAppsFromUnassignedDevices.isChecked + model.showAppsFromOtherCategories.value = binding.showOtherCategoriesApps.isChecked + model.assignToThisDeviceOnly.value = binding.assignToThisDeviceOnly.isChecked binding.showAppsFromUnassignedDevices.setOnCheckedChangeListener { _, isChecked -> - showAppsFromOtherDevicesChecked.value = isChecked + model.showAppsFromOtherDevicesChecked.value = isChecked } - isLocalMode.observe(this, Observer { - binding.showAppsFromUnassignedDevices.visibility = if (it) View.GONE else View.VISIBLE - }) + binding.assignToThisDeviceOnly.setOnCheckedChangeListener { _, isChecked -> + model.assignToThisDeviceOnly.value = isChecked + } - val showAppsFromOtherCategories = MutableLiveData().apply { value = binding.showOtherCategoriesApps.isChecked } - binding.showOtherCategoriesApps.setOnCheckedChangeListener { _, isChecked -> showAppsFromOtherCategories.value = isChecked } + AppFilterView.getFilterLive(binding.filter).observe(this) { model.filter.value = it } + + model.isLocalMode.observe(this) { + binding.showAppsFromUnassignedDevices.visibility = if (it) View.GONE else View.VISIBLE + } + + model.showDeviceSpecificAssignmentOption.observe(this) { + binding.assignToThisDeviceOnly.visibility = if (it) View.VISIBLE else View.GONE + } + + binding.showOtherCategoriesApps.setOnCheckedChangeListener { _, isChecked -> + model.showAppsFromOtherCategories.value = isChecked + } binding.recycler.layoutManager = LinearLayoutManager(context) binding.recycler.adapter = adapter - val childDeviceIds = database.device().getDevicesByUserId(childId) - .map { devices -> devices.map { it.id } } - .ignoreUnchanged() - - val globalChildDeviceCounter = database.device().countDevicesWithChildUser() - - val hasChildDeviceIds = childDeviceIds.map { it.isNotEmpty() } - - val appsAtAssignedDevices = childDeviceIds - .switchMap { database.app().getAppsByDeviceIds(it) } - - val appsAtAllDevices = database.app().getAllApps() - - val installedApps = realShowAppsFromAllDevices.switchMap { appsFromAllDevices -> - if (appsFromAllDevices) appsAtAllDevices else appsAtAssignedDevices - }.map { list -> - if (list.isEmpty()) list else list + DummyApps.getApps(deviceId = list.first().deviceId, context = requireContext()) - }.map { apps -> apps.distinctBy { app -> app.packageName } } - - val userRelatedDataLive = database.derivedDataDao().getUserRelatedDataLive(childId) - - val categoryTitleByPackageName = userRelatedDataLive.map { userRelatedData -> - val result = mutableMapOf() - - userRelatedData?.categoryApps?.forEach { app -> - result[app.packageName] = userRelatedData.categoryById[app.categoryId]!!.category.title - } - - result - } - - val packageNamesAssignedToOtherCategories = userRelatedDataLive - .map { it?.categoryApps?.map { app -> app.packageName }?.toSet() ?: emptySet() } - - val shownApps = if (childAddLimitMode) { - userRelatedDataLive.switchMap { userRelatedData -> - installedApps.map { installedApps -> - if (userRelatedData == null || !userRelatedData.categoryById.containsKey(categoryId)) - emptyList() - else { - val parentCategories = userRelatedData.getCategoryWithParentCategories(categoryId) - val defaultCategory = userRelatedData.categoryById[userRelatedData.user.categoryForNotAssignedApps] - val allowAppsWithoutCategory = defaultCategory != null && parentCategories.contains(defaultCategory.category.id) - val packageNameToCategoryId = userRelatedData.categoryApps.associateBy { it.packageName } - - installedApps.filter { app -> - val appCategoryId = packageNameToCategoryId[app.packageName]?.categoryId - val categoryNotFound = !userRelatedData.categoryById.containsKey(appCategoryId) - - parentCategories.contains(appCategoryId) || (categoryNotFound && allowAppsWithoutCategory) - } - } - } - } - } else installedApps - - val listItems = filter.switchMap { filter -> - shownApps.map { filter to it } - }.map { (search, apps) -> - apps.filter { search.matches(it) } - }.switchMap { apps -> - showAppsFromOtherCategories.switchMap { showOtherCategeories -> - if (showOtherCategeories) { - liveDataFromNonNullValue(apps) - } else { - packageNamesAssignedToOtherCategories.map { packagesFromOtherCategories -> - apps.filterNot { packagesFromOtherCategories.contains(it.packageName) } - } - } - } - }.map { apps -> - apps.sortedBy { app -> app.title.toLowerCase() } - } - - val emptyViewText: LiveData = listItems.switchMap { items -> - if (items.isNotEmpty()) { - // list is not empty ... - liveDataFromNullableValue(null as String?) - } else /* items.isEmpty() */ { - shownApps.switchMap { shownApps -> - if (shownApps.isNotEmpty()) { - liveDataFromNullableValue(getString(R.string.category_apps_add_empty_due_to_filter) as String?) - } else /* if (shownApps.isEmpty()) */ { - installedApps.switchMap { installedApps -> - if (installedApps.isNotEmpty()) { - liveDataFromNullableValue(getString(R.string.category_apps_add_empty_due_to_child_mode) as String?) - } else /* if (installedApps.isEmpty()) */ { - isLocalMode.switchMap { isLocalMode -> - if (isLocalMode) { - liveDataFromNullableValue(getString(R.string.category_apps_add_empty_local_mode) as String?) - } else { - showAppsFromOtherDevicesChecked.switchMap { showAppsFromOtherDevicesChecked -> - if (showAppsFromOtherDevicesChecked) { - globalChildDeviceCounter.map { childDeviceCounter -> - if (childDeviceCounter == 0L) { - getString(R.string.category_apps_add_empty_all_devices_no_apps_no_childs) as String? - } else { - getString(R.string.category_apps_add_empty_all_devices_no_apps_but_child_devices) as String? - } - } - } else { - hasChildDeviceIds.map { hasChildDeviceIds -> - if (hasChildDeviceIds) { - getString(R.string.category_apps_add_empty_child_devices_no_apps) as String? - } else { - getString(R.string.category_apps_add_empty_no_child_devices) as String? - } - } - } - } - } - } - } - } - } - } as LiveData - } - } - - listItems.observe(this, Observer { + model.listItems.observe(this, Observer { val selectedPackageNames = adapter.selectedApps val visiblePackageNames = it.map { it.packageName }.toSet() val hiddenSelectedPackageNames = selectedPackageNames.toMutableSet().apply { removeAll(visiblePackageNames) }.size @@ -261,45 +125,57 @@ class AddCategoryAppsFragment : DialogFragment() { resources.getQuantityString(R.plurals.category_apps_add_dialog_hidden_entries, hiddenSelectedPackageNames, hiddenSelectedPackageNames) }) - emptyViewText.observe(this, Observer { binding.emptyText = it }) + model.emptyViewText.observe(this) { + binding.emptyText = when (it!!) { + AddAppsModel.EmptyViewText.None -> null + AddAppsModel.EmptyViewText.EmptyDueToFilter -> getString(R.string.category_apps_add_empty_due_to_filter) + AddAppsModel.EmptyViewText.EmptyDueToChildMode -> getString(R.string.category_apps_add_empty_due_to_child_mode) + AddAppsModel.EmptyViewText.EmptyLocalMode -> getString(R.string.category_apps_add_empty_local_mode) + AddAppsModel.EmptyViewText.EmptyAllDevicesNoAppsNoChildDevices -> getString(R.string.category_apps_add_empty_all_devices_no_apps_no_childs) + AddAppsModel.EmptyViewText.EmptyAllDevicesNoAppsButChildDevices -> getString(R.string.category_apps_add_empty_all_devices_no_apps_but_child_devices) + AddAppsModel.EmptyViewText.EmptyChildDevicesHaveNoApps -> getString(R.string.category_apps_add_empty_child_devices_no_apps) + AddAppsModel.EmptyViewText.EmptyNoChildDevices -> getString(R.string.category_apps_add_empty_no_child_devices) + } + } - categoryTitleByPackageName.observe(this, Observer { - adapter.categoryTitleByPackageName = it - }) + binding.someOptionsDisabledDueToChildAuthentication = params.isSelfLimitAddingMode - binding.someOptionsDisabledDueToChildAuthentication = childAddLimitMode + model.deviceIdLive.observe(this) {/* keep loaded */} binding.addAppsButton.setOnClickListener { val packageNames = adapter.selectedApps.toList() if (packageNames.isNotEmpty()) { + val deviceSpecific = binding.assignToThisDeviceOnly.isChecked && !params.isSelfLimitAddingMode + val deviceId = model.deviceIdLive.value + + if (deviceSpecific && deviceId == null) return@setOnClickListener + auth.tryDispatchParentAction( action = AddCategoryAppsAction( - categoryId = categoryId, - packageNames = packageNames + categoryId = params.categoryId, + packageNames = if (deviceSpecific) packageNames.map { "$it@$deviceId" } else packageNames ), - allowAsChild = childAddLimitMode + allowAsChild = params.isSelfLimitAddingMode ) } dismiss() } - binding.cancelButton.setOnClickListener { - dismiss() - } + binding.cancelButton.setOnClickListener { dismiss() } binding.selectAllButton.setOnClickListener { adapter.selectedApps = adapter.selectedApps + (adapter.data?.map { it.packageName }?.toSet() ?: emptySet()) } adapter.listener = object: AddAppAdapterListener { - override fun onAppClicked(app: App) { + override fun onAppClicked(app: AddAppListItem) { if (adapter.selectedApps.contains(app.packageName)) { adapter.selectedApps = adapter.selectedApps - setOf(app.packageName) } else { if (!didEducateAboutAddingAssignedApps) { - if (adapter.categoryTitleByPackageName[app.packageName] != null) { + if (app.currentCategoryName != null) { didEducateAboutAddingAssignedApps = true AddAlreadyAssignedAppsInfoDialog().show(fragmentManager!!) @@ -310,14 +186,12 @@ class AddCategoryAppsFragment : DialogFragment() { } } - override fun onAppLongClicked(app: App): Boolean { + override fun onAppLongClicked(app: AddAppListItem): Boolean { return if (adapter.selectedApps.isEmpty()) { - AddAppActivitiesDialogFragment.newInstance( - childId = childId, - categoryId = categoryId, - packageName = app.packageName, - childAddLimitMode = childAddLimitMode - ).show(parentFragmentManager) + AddAppActivitiesDialogFragment.newInstance(AddActivitiesParams( + base = params, + packageName = app.packageName + )).show(parentFragmentManager) dismissAllowingStateLoss() diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/apps/addactivity/AddActivitiesModel.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/addactivity/AddActivitiesModel.kt new file mode 100644 index 0000000..65d5e5f --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/addactivity/AddActivitiesModel.kt @@ -0,0 +1,131 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.category.apps.addactivity + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import io.timelimit.android.data.extensions.getCategoryWithParentCategories +import io.timelimit.android.livedata.map +import io.timelimit.android.livedata.mergeLiveDataWaitForValues +import io.timelimit.android.livedata.switchMap +import io.timelimit.android.logic.DefaultAppLogic + +class AddActivitiesModel(application: Application): AndroidViewModel(application) { + private var didInit = false + private val paramsLive = MutableLiveData() + + fun init(params: AddActivitiesParams) { + if (didInit) return + + paramsLive.value = params + didInit = true + } + + val searchTerm = MutableLiveData().apply { value = "" } + + private val logic = DefaultAppLogic.with(application) + + private val allActivitiesLive = paramsLive.switchMap { params -> + logic.database.appActivity().getAppActivitiesByPackageName(params.packageName) + } + + private val userRelatedDataLive = paramsLive.switchMap { params -> + logic.database.derivedDataDao().getUserRelatedDataLive(params.base.childId) + } + + private val installedAppsWithCurrentCategories = mergeLiveDataWaitForValues(paramsLive, allActivitiesLive, userRelatedDataLive) + .map { (params, activities, userRelatedData) -> + val categoryByAppSpecifier = userRelatedData?.categoryApps?.associateBy { it.appSpecifierString }?.mapValues { + userRelatedData.categoryById.get(it.value.categoryId) + } ?: emptyMap() + + activities.map { activity -> + val specifier = "${params.packageName}:${activity.activityClassName}" + val category = categoryByAppSpecifier[specifier]?.category + + AddActivityListItem( + title = activity.title, + className = activity.activityClassName, + currentCategoryTitle = category?.title + ) + } + } + + private val shownActivities: LiveData> = mergeLiveDataWaitForValues(paramsLive, userRelatedDataLive, installedAppsWithCurrentCategories) + .map { (params, userRelatedData, allActivities) -> + if (params.base.isSelfLimitAddingMode) { + if (userRelatedData == null || !userRelatedData.categoryById.containsKey(params.base.categoryId)) + emptyList() + else { + val parentCategories = userRelatedData.getCategoryWithParentCategories(params.base.categoryId) + val defaultCategory = userRelatedData.categoryById[userRelatedData.user.categoryForNotAssignedApps] + + val componentToCategoryApp = userRelatedData.categoryApps + .filter { it.appSpecifier.packageName == params.packageName && it.appSpecifier.deviceId == null } + .associateBy { it.appSpecifier.activityName ?: ":" } + + val baseAppCategoryOrDefaultCategory = + userRelatedData.categoryById[componentToCategoryApp[":"]?.categoryId] + ?: defaultCategory + + val isBaseAppInParentCategory = parentCategories.contains(baseAppCategoryOrDefaultCategory?.category?.id) + + allActivities.filter { activity -> + val activityCategoryItem = userRelatedData.categoryById[componentToCategoryApp[activity.className]?.categoryId] + val activityItselfInParentCategory = parentCategories.contains(activityCategoryItem?.category?.id) + val activityItselfUnassigned = activityCategoryItem == null + + (isBaseAppInParentCategory && activityItselfUnassigned) || activityItselfInParentCategory + } + } + } else allActivities + } + + val filteredActivities = shownActivities.switchMap { activities -> + searchTerm.map { term -> + if (term.isEmpty()) { + activities + } else { + activities.filter { it.className.contains(term, ignoreCase = true) or it.title.contains(term, ignoreCase = true) } + } + } + } + + val emptyViewText = allActivitiesLive.switchMap { all -> + shownActivities.switchMap { shown -> + filteredActivities.map { filtered -> + if (filtered.isNotEmpty()) + EmptyViewText.None + else if (all.isNotEmpty()) + if (shown.isEmpty()) + EmptyViewText.EmptyShown + else + EmptyViewText.EmptyFiltered + else /* (all.isEmpty()) */ + EmptyViewText.EmptyUnfiltered + } + } + } + + enum class EmptyViewText { + None, + EmptyShown, + EmptyFiltered, + EmptyUnfiltered + } +} \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/apps/addactivity/AddActivitiesParams.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/addactivity/AddActivitiesParams.kt new file mode 100644 index 0000000..96f0a40 --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/addactivity/AddActivitiesParams.kt @@ -0,0 +1,26 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.category.apps.addactivity + +import android.os.Parcelable +import io.timelimit.android.ui.manage.category.apps.add.AddAppsParams +import kotlinx.parcelize.Parcelize + +@Parcelize +data class AddActivitiesParams ( + val base: AddAppsParams, + val packageName: String +): Parcelable \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/apps/addactivity/AddActivityListItem.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/addactivity/AddActivityListItem.kt new file mode 100644 index 0000000..5f1735a --- /dev/null +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/addactivity/AddActivityListItem.kt @@ -0,0 +1,22 @@ +/* + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation version 3 of the License. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package io.timelimit.android.ui.manage.category.apps.addactivity + +data class AddActivityListItem ( + val title: String, + val className: String, + val currentCategoryTitle: String? +) \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/apps/addactivity/AddAppActivitiesDialogFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/addactivity/AddAppActivitiesDialogFragment.kt index ab8afaf..ee84029 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/category/apps/addactivity/AddAppActivitiesDialogFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/addactivity/AddAppActivitiesDialogFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * TimeLimit Copyright 2019 - 2022 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 @@ -21,173 +21,100 @@ 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.fragment.app.viewModels import androidx.recyclerview.widget.LinearLayoutManager import io.timelimit.android.R -import io.timelimit.android.data.extensions.getCategoryWithParentCategories -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 +import io.timelimit.android.ui.manage.category.apps.AddAppsOrActivitiesModel 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 PARAMS = "params" private const val SELECTED_ACTIVITIES = "selectedActivities" - private const val CHILD_ADD_LIMIT_MODE = "childAddLimitMode" - fun newInstance(childId: String, categoryId: String, packageName: String, childAddLimitMode: Boolean) = AddAppActivitiesDialogFragment().apply { - arguments = Bundle().apply { - putString(CHILD_ID, childId) - putString(CATEGORY_ID, categoryId) - putString(PACKAGE_NAME, packageName) - putBoolean(CHILD_ADD_LIMIT_MODE, childAddLimitMode) - } + fun newInstance(params: AddActivitiesParams) = AddAppActivitiesDialogFragment().apply { + arguments = Bundle().apply { putParcelable(PARAMS, params) } } } - val adapter = AddAppActivityAdapter() + private val adapter = AddAppActivityAdapter() + private val baseModel: AddAppsOrActivitiesModel by viewModels() + private val model: AddActivitiesModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (savedInstanceState != null) { - adapter.selectedActiviities.clear() - savedInstanceState.getStringArray(SELECTED_ACTIVITIES)!!.forEach { adapter.selectedActiviities.add(it) } + adapter.selectedActivities.clear() + savedInstanceState.getStringArray(SELECTED_ACTIVITIES)!!.forEach { adapter.selectedActivities.add(it) } } } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - outState.putStringArray(SELECTED_ACTIVITIES, adapter.selectedActiviities.toTypedArray()) + outState.putStringArray(SELECTED_ACTIVITIES, adapter.selectedActivities.toTypedArray()) } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val appPackageName = arguments!!.getString(PACKAGE_NAME)!! - val categoryId = arguments!!.getString(CATEGORY_ID)!! - val childId = arguments!!.getString(CHILD_ID)!! - val childAddLimitMode = arguments!!.getBoolean(CHILD_ADD_LIMIT_MODE) - val auth = getActivityViewModel(activity!!) - val binding = FragmentAddCategoryActivitiesBinding.inflate(LayoutInflater.from(context!!)) - val searchTerm = MutableLiveData().apply { value = binding.search.text.toString() } - binding.search.addOnTextChangedListener { searchTerm.value = binding.search.text.toString() } + val params = requireArguments().getParcelable(PARAMS)!! + val auth = getActivityViewModel(requireActivity()) + val binding = FragmentAddCategoryActivitiesBinding.inflate(LayoutInflater.from(context)) - auth.authenticatedUserOrChild.observe(this, Observer { - val parentAuthenticated = it?.second?.type == UserType.Parent - val childAuthenticated = it?.second?.id == childId && childAddLimitMode - val anyoneAuthenticated = parentAuthenticated || childAuthenticated + baseModel.init(params.base) + baseModel.isAuthValid(auth).observe(this) { if (!it) dismissAllowingStateLoss() } - if (!anyoneAuthenticated) { - dismissAllowingStateLoss() - } - }) + model.init(params) + model.searchTerm.value = binding.search.text.toString() + binding.search.addOnTextChangedListener { model.searchTerm.value = binding.search.text.toString() } - val logic = DefaultAppLogic.with(context!!) - val allActivitiesLive = logic.database.appActivity().getAppActivitiesByPackageName(appPackageName).map { activities -> - activities.distinctBy { it.activityClassName } - } - - val userRelatedDataLive = logic.database.derivedDataDao().getUserRelatedDataLive(childId) - - val shownActivities = if (childAddLimitMode) { - userRelatedDataLive.switchMap { userRelatedData -> - allActivitiesLive.map { allActivities -> - if (userRelatedData == null || !userRelatedData.categoryById.containsKey(categoryId)) - emptyList() - else { - val parentCategories = userRelatedData.getCategoryWithParentCategories(categoryId) - val defaultCategory = userRelatedData.categoryById[userRelatedData.user.categoryForNotAssignedApps] - val relatedPackageNameToCategoryId = userRelatedData.categoryApps - .filter { it.packageNameWithoutActivityName == appPackageName } - .associateBy { it.packageName } - val baseAppCategoryOrDefaultCategory = userRelatedData.categoryById[relatedPackageNameToCategoryId[appPackageName]?.categoryId] ?: defaultCategory - val baseAppCategoryInParentCategoryOrMatchingUnassigned = parentCategories.contains(baseAppCategoryOrDefaultCategory?.category?.id) - - allActivities.filter { activity -> - val activityCategoryItem = userRelatedData.categoryById[relatedPackageNameToCategoryId[activity.appPackageName + ":" + activity.activityClassName]?.categoryId] - val activityItselfInParentCategory = parentCategories.contains(activityCategoryItem?.category?.id) - val activityItselfUnassigned = activityCategoryItem == null - - (baseAppCategoryInParentCategoryOrMatchingUnassigned && activityItselfUnassigned) || activityItselfInParentCategory - } - } - } - } - } else allActivitiesLive - - val filteredActivities = shownActivities.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.layoutManager = LinearLayoutManager(requireContext()) binding.recycler.adapter = adapter - filteredActivities.observe(this, Observer { list -> - val selectedActivities = adapter.selectedActiviities - val visibleActivities = list.map { it.activityClassName } + model.filteredActivities.observe(this) { list -> + val selectedActivities = adapter.selectedActivities + val visibleActivities = list.map { it.className } 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 = allActivitiesLive.switchMap { all -> - shownActivities.switchMap { shown -> - filteredActivities.map { filtered -> - if (filtered.isNotEmpty()) - null - else if (all.isNotEmpty()) - if (shown.isEmpty()) - getString(R.string.category_apps_add_activity_empty_shown) - else - getString(R.string.category_apps_add_activity_empty_filtered) - else /* (all.isEmpty()) */ - getString(R.string.category_apps_add_activity_empty_unfiltered) - } + model.emptyViewText.observe(this) { + binding.emptyViewText = when (it!!) { + AddActivitiesModel.EmptyViewText.None -> null + AddActivitiesModel.EmptyViewText.EmptyShown -> getString(R.string.category_apps_add_activity_empty_shown) + AddActivitiesModel.EmptyViewText.EmptyFiltered -> getString(R.string.category_apps_add_activity_empty_filtered) + AddActivitiesModel.EmptyViewText.EmptyUnfiltered -> getString(R.string.category_apps_add_activity_empty_unfiltered) } } - emptyViewText.observe(this, Observer { - binding.emptyViewText = it - }) - - binding.someOptionsDisabledDueToChildAuthentication = childAddLimitMode - + binding.someOptionsDisabledDueToChildAuthentication = params.base.isSelfLimitAddingMode binding.cancelButton.setOnClickListener { dismissAllowingStateLoss() } binding.addActivitiesButton.setOnClickListener { - if (adapter.selectedActiviities.isNotEmpty()) { + if (adapter.selectedActivities.isNotEmpty()) { auth.tryDispatchParentAction( action = AddCategoryAppsAction( - categoryId = categoryId, - packageNames = adapter.selectedActiviities.toList().map { "$appPackageName:$it" } + categoryId = params.base.categoryId, + packageNames = adapter.selectedActivities.toList().map { "${params.packageName}:$it" } ), - allowAsChild = childAddLimitMode + allowAsChild = params.base.isSelfLimitAddingMode ) } dismissAllowingStateLoss() } - return AlertDialog.Builder(context!!, R.style.AppTheme) + return AlertDialog.Builder(requireContext(), R.style.AppTheme) .setView(binding.root) .create() } diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/apps/addactivity/AddAppActivityAdapter.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/addactivity/AddAppActivityAdapter.kt index 94ef969..b50206d 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/category/apps/addactivity/AddAppActivityAdapter.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/apps/addactivity/AddAppActivityAdapter.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 Jonas Lochmann + * TimeLimit Copyright 2019 - 2022 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 @@ -18,58 +18,54 @@ 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.AppActivity -import io.timelimit.android.databinding.FragmentAddCategoryActivitiesItemBinding +import io.timelimit.android.databinding.FragmentAddCategoryAppsItemBinding import io.timelimit.android.extensions.toggle import kotlin.properties.Delegates class AddAppActivityAdapter: RecyclerView.Adapter() { - var data: List? by Delegates.observable(null as List?) { _, _, _ -> notifyDataSetChanged() } - val selectedActiviities = mutableSetOf() + var data: List? by Delegates.observable(emptyList()) { _, _, _ -> notifyDataSetChanged() } - private val itemHandlers = object: ItemHandlers { - override fun onActivityClicked(activity: AppActivity) { - selectedActiviities.toggle(activity.activityClassName) - - notifyDataSetChanged() - } - } + val selectedActivities = mutableSetOf() init { setHasStableIds(true) } - private fun getItem(position: Int): AppActivity { + private fun getItem(position: Int): AddActivityListItem { return data!![position] } override fun getItemId(position: Int): Long { - return getItem(position).activityClassName.hashCode().toLong() + return getItem(position).className.hashCode().toLong() } override fun getItemCount(): Int = this.data?.size ?: 0 override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder( - FragmentAddCategoryActivitiesItemBinding.inflate( + FragmentAddCategoryAppsItemBinding.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.title = item.title + binding.currentCategoryTitle = item.currentCategoryTitle + binding.subtitle = item.className + binding.showIcon = false + binding.checked = selectedActivities.contains(item.className) binding.executePendingBindings() + + binding.card.setOnClickListener { + selectedActivities.toggle(item.className) + binding.checked = selectedActivities.contains(item.className) + } } } } -class ViewHolder(val binding: FragmentAddCategoryActivitiesItemBinding): RecyclerView.ViewHolder(binding.root) - -interface ItemHandlers { - fun onActivityClicked(activity: AppActivity) -} \ No newline at end of file +class ViewHolder(val binding: FragmentAddCategoryAppsItemBinding): RecyclerView.ViewHolder(binding.root) \ No newline at end of file diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/apps/AppAdapterHandlers.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/appsandrules/AppAdapterHandlers.kt similarity index 81% rename from app/src/main/java/io/timelimit/android/ui/manage/category/apps/AppAdapterHandlers.kt rename to app/src/main/java/io/timelimit/android/ui/manage/category/appsandrules/AppAdapterHandlers.kt index df8f8d3..9cf867f 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/category/apps/AppAdapterHandlers.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/appsandrules/AppAdapterHandlers.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2021 Jonas Lochmann + * TimeLimit Copyright 2019 - 2022 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 @@ -13,9 +13,7 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ -package io.timelimit.android.ui.manage.category.apps - -import io.timelimit.android.ui.manage.category.appsandrules.AppAndRuleItem +package io.timelimit.android.ui.manage.category.appsandrules interface AppAdapterHandlers { fun onAppClicked(app: AppAndRuleItem.AppEntry) diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/appsandrules/AppAndRuleAdapter.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/appsandrules/AppAndRuleAdapter.kt index 424be00..ecf7fde 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/category/appsandrules/AppAndRuleAdapter.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/appsandrules/AppAndRuleAdapter.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2021 Jonas Lochmann + * TimeLimit Copyright 2019 - 2022 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 @@ -27,7 +27,6 @@ import io.timelimit.android.date.DateInTimezone import io.timelimit.android.extensions.MinuteOfDay import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.logic.DummyApps -import io.timelimit.android.ui.manage.category.apps.AppAdapterHandlers import io.timelimit.android.ui.manage.category.timelimit_rules.TimeLimitRulesHandlers import io.timelimit.android.util.DayNameUtil import io.timelimit.android.util.TimeTextUtil @@ -58,7 +57,7 @@ class AppAndRuleAdapter: RecyclerView.Adapter() { override fun getItemId(position: Int): Long = items[position].let { item -> when (item) { - is AppAndRuleItem.AppEntry -> item.packageName.hashCode() + is AppAndRuleItem.AppEntry -> item.specifier.hashCode() is AppAndRuleItem.RuleEntry -> item.rule.id.hashCode() else -> item.hashCode() } @@ -137,16 +136,17 @@ class AppAndRuleAdapter: RecyclerView.Adapter() { val binding = holder.itemView.tag as FragmentCategoryAppsItemBinding val context = binding.root.context - binding.item = item - binding.handlers = handlers + binding.title = item.title + binding.deviceName = item.deviceName + binding.subtitle = item.specifier.copy(deviceId = null).encode() + binding.card.setOnClickListener { handlers?.onAppClicked(item) } + binding.card.setOnLongClickListener { handlers?.onAppLongClicked(item) ?: false } binding.executePendingBindings() - binding.root.setOnLongClickListener { handlers?.onAppLongClicked(item) ?: false } - binding.icon.setImageDrawable( - DummyApps.getIcon(item.packageNameWithoutActivityName, context) ?: + DummyApps.getIcon(item.specifier.packageName, context) ?: DefaultAppLogic.with(context) - .platformIntegration.getAppIcon(item.packageNameWithoutActivityName) + .platformIntegration.getAppIcon(item.specifier.packageName) ) } AppAndRuleItem.AddAppItem -> {/* nothing to do */} diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/appsandrules/AppAndRuleItem.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/appsandrules/AppAndRuleItem.kt index b552ee8..5d58b93 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/category/appsandrules/AppAndRuleItem.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/appsandrules/AppAndRuleItem.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * TimeLimit Copyright 2019 - 2022 Jonas Lochmann * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -17,9 +17,10 @@ package io.timelimit.android.ui.manage.category.appsandrules import io.timelimit.android.data.model.TimeLimitRule +import io.timelimit.android.data.model.derived.AppSpecifier sealed class AppAndRuleItem { - data class AppEntry(val title: String, val packageName: String, val packageNameWithoutActivityName: String): AppAndRuleItem() + data class AppEntry(val title: String, val deviceName: String?, val specifier: AppSpecifier): AppAndRuleItem() object AddAppItem: AppAndRuleItem() object ExpandAppsItem: AppAndRuleItem() data class RuleEntry(val rule: TimeLimitRule): AppAndRuleItem() diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/appsandrules/AppsAndRulesModel.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/appsandrules/AppsAndRulesModel.kt index 985ccdd..c4ec2ca 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/category/appsandrules/AppsAndRulesModel.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/appsandrules/AppsAndRulesModel.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2020 Jonas Lochmann + * TimeLimit Copyright 2019 - 2022 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 @@ -24,6 +24,7 @@ import io.timelimit.android.data.extensions.getDateLive import io.timelimit.android.data.model.HintsToShow import io.timelimit.android.extensions.takeDistributedElements import io.timelimit.android.livedata.map +import io.timelimit.android.livedata.mergeLiveDataWaitForValues import io.timelimit.android.livedata.switchMap import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.logic.DummyApps @@ -108,27 +109,30 @@ class AppsAndRulesModel(application: Application): AndroidViewModel(application) private val installedApps = database.app().getAllApps() + private val installedAppsIndexed = installedApps.map { apps -> apps.associateBy { it.packageName } } + + private val deviceNamesIndexedLive = database.device().getDeviceNamesLive().map { items -> items.associateBy { it.id } } + private val appsOfThisCategory = categoryIdLive.switchMap { categoryId -> database.categoryApp().getCategoryApps(categoryId) } - private val appsOfCategoryWithNames = installedApps.switchMap { allApps -> - appsOfThisCategory.map { apps -> - apps.map { categoryApp -> - val title = DummyApps.getTitle(categoryApp.packageNameWithoutActivityName, getApplication()) ?: - allApps.find { app -> app.packageName == categoryApp.packageNameWithoutActivityName }?.title + private val appsOfCategoryWithNames = mergeLiveDataWaitForValues(installedAppsIndexed, appsOfThisCategory, deviceNamesIndexedLive) + .map { (allAppsIndexed, appsOfThisCategory, deviceNamesIndexed) -> + appsOfThisCategory.map { categoryApp -> + val title = DummyApps.getTitle(categoryApp.appSpecifier.packageName, getApplication()) ?: + allAppsIndexed[categoryApp.appSpecifier.packageName]?.title - categoryApp to title + AppAndRuleItem.AppEntry( + title = title ?: "app not found", + specifier = categoryApp.appSpecifier, + deviceName = categoryApp.appSpecifier.deviceId?.let { deviceId -> + deviceNamesIndexed[deviceId]?.name ?: "removed device" + } + ) } } - } private val appEntries = appsOfCategoryWithNames.map { apps -> - apps.map { (app, title) -> - if (title != null) { - AppAndRuleItem.AppEntry(title, app.packageName, app.packageNameWithoutActivityName) - } else { - AppAndRuleItem.AppEntry("app not found", app.packageName, app.packageNameWithoutActivityName) - } - }.sortedBy { it.title.toLowerCase(Locale.US) } + apps.sortedBy { it.title.lowercase() } } private val fullAppScreenContent = showAllAppsLive.switchMap { showAllApps -> diff --git a/app/src/main/java/io/timelimit/android/ui/manage/category/appsandrules/CategoryAppsAndRulesFragment.kt b/app/src/main/java/io/timelimit/android/ui/manage/category/appsandrules/CategoryAppsAndRulesFragment.kt index 9275525..7bb5893 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/category/appsandrules/CategoryAppsAndRulesFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/category/appsandrules/CategoryAppsAndRulesFragment.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2021 Jonas Lochmann + * TimeLimit Copyright 2019 - 2022 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 @@ -39,6 +39,7 @@ import io.timelimit.android.sync.actions.RemoveCategoryAppsAction 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.manage.category.apps.add.AddAppsParams import io.timelimit.android.ui.manage.category.apps.add.AddCategoryAppsFragment import io.timelimit.android.ui.manage.category.timelimit_rules.edit.EditTimeLimitRuleDialogFragment import io.timelimit.android.ui.manage.category.timelimit_rules.edit.EditTimeLimitRuleDialogFragmentListener @@ -135,7 +136,7 @@ abstract class CategoryAppsAndRulesFragment: Fragment(), Handlers, EditTimeLimit if (auth.tryDispatchParentAction( RemoveCategoryAppsAction( categoryId = categoryId, - packageNames = listOf(app.packageName) + packageNames = listOf(app.specifier.encode()) ) )) { Snackbar.make(requireView(), getString(R.string.category_apps_item_removed_toast, app.title), Snackbar.LENGTH_SHORT) @@ -143,7 +144,7 @@ abstract class CategoryAppsAndRulesFragment: Fragment(), Handlers, EditTimeLimit auth.tryDispatchParentAction( AddCategoryAppsAction( categoryId = categoryId, - packageNames = listOf(app.packageName) + packageNames = listOf(app.specifier.encode()) ) ) } @@ -155,7 +156,7 @@ abstract class CategoryAppsAndRulesFragment: Fragment(), Handlers, EditTimeLimit return if (auth.requestAuthenticationOrReturnTrue()) { AssignAppCategoryDialogFragment.newInstance( childId = childId, - appPackageName = app.packageName + appPackageName = app.specifier.encode() ).show(parentFragmentManager) true @@ -164,11 +165,11 @@ abstract class CategoryAppsAndRulesFragment: Fragment(), Handlers, EditTimeLimit override fun onAddAppsClicked() { if (auth.requestAuthenticationOrReturnTrueAllowChild(childId = childId)) { - AddCategoryAppsFragment.newInstance( - childId = childId, - categoryId = categoryId, - childAddLimitMode = !auth.isParentAuthenticated() - ).show(parentFragmentManager) + AddCategoryAppsFragment.newInstance(AddAppsParams( + childId = childId, + categoryId = categoryId, + isSelfLimitAddingMode = !auth.isParentAuthenticated() + )).show(parentFragmentManager) } } diff --git a/app/src/main/java/io/timelimit/android/ui/manage/child/advanced/duplicate/DuplicateChildActions.kt b/app/src/main/java/io/timelimit/android/ui/manage/child/advanced/duplicate/DuplicateChildActions.kt index df4d4f3..0014c8d 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/child/advanced/duplicate/DuplicateChildActions.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/child/advanced/duplicate/DuplicateChildActions.kt @@ -124,7 +124,7 @@ object DuplicateChildActions { result.add(AddCategoryAppsAction( categoryId = newCategoryId, - packageNames = oldApps.map { it.packageName } + packageNames = oldApps.map { it.appSpecifierString } )) } diff --git a/app/src/main/java/io/timelimit/android/ui/manage/child/apps/ChildAppsModel.kt b/app/src/main/java/io/timelimit/android/ui/manage/child/apps/ChildAppsModel.kt index d5ff28c..6b1370d 100644 --- a/app/src/main/java/io/timelimit/android/ui/manage/child/apps/ChildAppsModel.kt +++ b/app/src/main/java/io/timelimit/android/ui/manage/child/apps/ChildAppsModel.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2021 Jonas Lochmann + * TimeLimit Copyright 2019 - 2022 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 @@ -64,13 +64,17 @@ class ChildAppsModel(application: Application): AndroidViewModel(application) { val listContentLive = childAppsLive.switchMap { childApps -> childCategoriesLive.switchMap { categories -> childCategoryAppsLive.switchMap { categoryApps -> + // only show items that are not device specific + val categoryAppByPackageName = categoryApps + .filter { it.appSpecifier.deviceId == null } + .associateBy { it.appSpecifier.packageName } + appFilterLive.ignoreUnchanged().switchMap { appFilter -> val filteredChildApps = childApps.filter { appFilter.matches(it) } modeLive.ignoreUnchanged().map { mode -> when (mode!!) { ChildAppsMode.SortByCategory -> { - val categoryAppByPackageName = categoryApps.associateBy { it.packageName } val appsByCategoryId = filteredChildApps.groupBy { app -> categoryAppByPackageName[app.packageName]?.categoryId } @@ -117,7 +121,6 @@ class ChildAppsModel(application: Application): AndroidViewModel(application) { } ChildAppsMode.SortByTitle -> { val categoryById = categories.associateBy { it.id } - val categoryAppByPackageName = categoryApps.associateBy { it.packageName } filteredChildApps .distinctBy { it.packageName } diff --git a/app/src/main/java/io/timelimit/android/ui/setup/device/SetupDeviceFragment.kt b/app/src/main/java/io/timelimit/android/ui/setup/device/SetupDeviceFragment.kt index 39931be..22fb7ac 100644 --- a/app/src/main/java/io/timelimit/android/ui/setup/device/SetupDeviceFragment.kt +++ b/app/src/main/java/io/timelimit/android/ui/setup/device/SetupDeviceFragment.kt @@ -194,7 +194,7 @@ class SetupDeviceFragment : Fragment(), FragmentWithCustomTitle { val appsAssignedToTheUser = categoriesOfTheSelectedUser.switchMap { categories -> logic.database.categoryApp().getCategoryApps(categories.map { it.id }).map { categoryApps -> - categoryApps.map { it.packageName }.toSet() + categoryApps.map { it.appSpecifierString }.toSet() } } diff --git a/app/src/main/java/io/timelimit/android/ui/setup/device/SetupDeviceModel.kt b/app/src/main/java/io/timelimit/android/ui/setup/device/SetupDeviceModel.kt index df40e8a..6d32761 100644 --- a/app/src/main/java/io/timelimit/android/ui/setup/device/SetupDeviceModel.kt +++ b/app/src/main/java/io/timelimit/android/ui/setup/device/SetupDeviceModel.kt @@ -1,5 +1,5 @@ /* - * TimeLimit Copyright 2019 - 2021 Jonas Lochmann + * TimeLimit Copyright 2019 - 2022 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 @@ -132,7 +132,8 @@ class SetupDeviceModel(application: Application): AndroidViewModel(application) val alreadyAssignedApps = Threads.database.executeAndWait { logic.database.categoryApp().getCategoryAppsByUserIdSync(realUserId) - .map { it.packageName } + .filter { it.appSpecifier.deviceId == null } + .map { it.appSpecifier.packageName } .toSet() } diff --git a/app/src/main/res/layout/fragment_add_category_activities_item.xml b/app/src/main/res/layout/fragment_add_category_activities_item.xml deleted file mode 100644 index 4b533c8..0000000 --- a/app/src/main/res/layout/fragment_add_category_activities_item.xml +++ /dev/null @@ -1,100 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/fragment_add_category_apps.xml b/app/src/main/res/layout/fragment_add_category_apps.xml index d9e381d..3efd635 100644 --- a/app/src/main/res/layout/fragment_add_category_apps.xml +++ b/app/src/main/res/layout/fragment_add_category_apps.xml @@ -1,5 +1,5 @@