Allow device specific app category assignments

This commit is contained in:
Jonas Lochmann 2022-03-14 01:00:00 +01:00
parent d4bfa37caf
commit d9a5f31d5a
No known key found for this signature in database
GPG key ID: 8B8C9AEE10FA5B36
40 changed files with 957 additions and 563 deletions

View file

@ -377,7 +377,7 @@
"notNull": true "notNull": true
}, },
{ {
"fieldPath": "packageName", "fieldPath": "appSpecifierString",
"columnName": "package_name", "columnName": "package_name",
"affinity": "TEXT", "affinity": "TEXT",
"notNull": true "notNull": true

View file

@ -1,3 +1,18 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.data.dao package io.timelimit.android.data.dao
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
@ -6,6 +21,7 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
import androidx.room.Query import androidx.room.Query
import io.timelimit.android.data.model.AppActivity import io.timelimit.android.data.model.AppActivity
import io.timelimit.android.data.model.AppActivityTitleAndClassNameItem
@Dao @Dao
interface AppActivityDao { interface AppActivityDao {
@ -15,8 +31,8 @@ interface AppActivityDao {
@Query("SELECT * FROM app_activity WHERE device_id IN (:deviceIds)") @Query("SELECT * FROM app_activity WHERE device_id IN (:deviceIds)")
fun getAppActivitiesByDeviceIds(deviceIds: List<String>): LiveData<List<AppActivity>> fun getAppActivitiesByDeviceIds(deviceIds: List<String>): LiveData<List<AppActivity>>
@Query("SELECT * FROM app_activity WHERE app_package_name = :packageName") @Query("SELECT DISTINCT activity_class_name, activity_title FROM app_activity WHERE app_package_name = :packageName")
fun getAppActivitiesByPackageName(packageName: String): LiveData<List<AppActivity>> fun getAppActivitiesByPackageName(packageName: String): LiveData<List<AppActivityTitleAndClassNameItem>>
@Insert(onConflict = OnConflictStrategy.REPLACE) @Insert(onConflict = OnConflictStrategy.REPLACE)
fun addAppActivitySync(item: AppActivity) fun addAppActivitySync(item: AppActivity)

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 - 2021 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -17,9 +17,7 @@ package io.timelimit.android.data.dao
import androidx.lifecycle.LiveData import androidx.lifecycle.LiveData
import androidx.room.* import androidx.room.*
import io.timelimit.android.data.model.Device import io.timelimit.android.data.model.*
import io.timelimit.android.data.model.NetworkTime
import io.timelimit.android.data.model.NetworkTimeAdapter
import io.timelimit.android.integration.platform.NewPermissionStatusConverter import io.timelimit.android.integration.platform.NewPermissionStatusConverter
import io.timelimit.android.integration.platform.ProtectionLevelConverter import io.timelimit.android.integration.platform.ProtectionLevelConverter
import io.timelimit.android.integration.platform.RuntimePermissionStatusConverter import io.timelimit.android.integration.platform.RuntimePermissionStatusConverter
@ -41,6 +39,9 @@ abstract class DeviceDao {
@Query("SELECT * FROM device ORDER BY id") @Query("SELECT * FROM device ORDER BY id")
abstract fun getAllDevicesLive(): LiveData<List<Device>> abstract fun getAllDevicesLive(): LiveData<List<Device>>
@Query("SELECT id, name FROM device")
abstract fun getDeviceNamesLive(): LiveData<List<DeviceName>>
@Query("SELECT * FROM device") @Query("SELECT * FROM device")
abstract fun getAllDevicesSync(): List<Device> abstract fun getAllDevicesSync(): List<Device>
@ -68,6 +69,9 @@ abstract class DeviceDao {
@Query("SELECT * FROM device WHERE current_user_id = :userId") @Query("SELECT * FROM device WHERE current_user_id = :userId")
abstract fun getDevicesByUserId(userId: String): LiveData<List<Device>> abstract fun getDevicesByUserId(userId: String): LiveData<List<Device>>
@Query("SELECT id FROM device WHERE current_user_id = :userId")
abstract fun getDevicesIdByUserId(userId: String): LiveData<List<DeviceId>>
@Query("UPDATE device SET apps_version = \"\"") @Query("UPDATE device SET apps_version = \"\"")
abstract fun deleteAllInstalledAppsVersions() abstract fun deleteAllInstalledAppsVersions()

View file

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

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -21,13 +21,14 @@ import androidx.room.ColumnInfo
import androidx.room.Entity import androidx.room.Entity
import io.timelimit.android.data.IdGenerator import io.timelimit.android.data.IdGenerator
import io.timelimit.android.data.JsonSerializable import io.timelimit.android.data.JsonSerializable
import io.timelimit.android.data.model.derived.AppSpecifier
@Entity(primaryKeys = ["category_id", "package_name"], tableName = "category_app") @Entity(primaryKeys = ["category_id", "package_name"], tableName = "category_app")
data class CategoryApp( data class CategoryApp(
@ColumnInfo(index = true, name = "category_id") @ColumnInfo(index = true, name = "category_id")
val categoryId: String, val categoryId: String,
@ColumnInfo(index = true, name = "package_name") @ColumnInfo(index = true, name = "package_name")
val packageName: String val appSpecifierString: String // originally a packageName, but can contain more than that
): JsonSerializable { ): JsonSerializable {
companion object { companion object {
private const val CATEGORY_ID = "c" private const val CATEGORY_ID = "c"
@ -51,26 +52,18 @@ data class CategoryApp(
return CategoryApp( return CategoryApp(
categoryId = categoryId!!, categoryId = categoryId!!,
packageName = packageName!! appSpecifierString = packageName!!
) )
} }
} }
@delegate:Transient @delegate:Transient
val packageNameWithoutActivityName: String by lazy { val appSpecifier: AppSpecifier by lazy { AppSpecifier.decode(appSpecifierString) }
if (specifiesActivity)
packageName.substring(0, packageName.indexOf(":"))
else
packageName
}
@Transient
val specifiesActivity = packageName.contains(":")
init { init {
IdGenerator.assertIdValid(categoryId) IdGenerator.assertIdValid(categoryId)
if (packageName.isEmpty()) { if (appSpecifierString.isEmpty()) {
throw IllegalArgumentException() throw IllegalArgumentException()
} }
} }
@ -79,7 +72,7 @@ data class CategoryApp(
writer.beginObject() writer.beginObject()
writer.name(CATEGORY_ID).value(categoryId) writer.name(CATEGORY_ID).value(categoryId)
writer.name(PACKAGE_NAME).value(packageName) writer.name(PACKAGE_NAME).value(appSpecifierString)
writer.endObject() writer.endObject()
} }

View file

@ -0,0 +1,23 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.data.model
import androidx.room.ColumnInfo
data class DeviceId (
@ColumnInfo(name = "id")
val id: String
)

View file

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

View file

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

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 - 2021 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -34,7 +34,7 @@ data class UserRelatedData(
val categoryApps: List<CategoryApp> val categoryApps: List<CategoryApp>
): Observer { ): Observer {
companion object { 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( private val relatedTables = arrayOf(
Table.User, Table.Category, Table.TimeLimitRule, 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 // notFoundCategoryApp is a workaround because the lru cache does not support null
private val categoryAppLruCache = object: LruCache<String, CategoryApp>(8) { private val categoryAppLruCache = object: LruCache<String, CategoryApp>(8) {
override fun create(key: String): CategoryApp { 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? { private fun findCategoryApp(appSpecifier: AppSpecifier): CategoryApp? {
val item = categoryAppLruCache[packageName] val item = categoryAppLruCache[appSpecifier.encode()]
// important: strict equality/ same object instance // important: strict equality/ same object instance
if (item === notFoundCategoryApp) { if (item === notFoundCategoryApp) {
@ -85,6 +85,15 @@ data class UserRelatedData(
return item 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 userInvalidated = false
private var categoriesInvalidated = false private var categoriesInvalidated = false

View file

@ -223,7 +223,7 @@ class AppSetupLogic(private val appLogic: AppLogic) {
.map { .map {
CategoryApp( CategoryApp(
categoryId = allowedAppsCategoryId, categoryId = allowedAppsCategoryId,
packageName = it.packageName appSpecifierString = it.packageName
) )
} }
) )

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -152,7 +152,7 @@ class SuspendAppsLogic(private val appLogic: AppLogic): Observer {
lastDefaultCategory = defaultCategory lastDefaultCategory = defaultCategory
val installedApps = appLogic.platformIntegration.getLocalAppPackageNames() val installedApps = appLogic.platformIntegration.getLocalAppPackageNames()
val prepared = getAppsWithCategories(installedApps, userRelatedData, blockingAtActivityLevel) val prepared = getAppsWithCategories(installedApps, userRelatedData, blockingAtActivityLevel, userAndDeviceRelatedData.deviceRelatedData.deviceEntry.id)
val appsToBlock = mutableListOf<String>() val appsToBlock = mutableListOf<String>()
installedApps.forEach { packageName -> installedApps.forEach { packageName ->
@ -169,19 +169,32 @@ class SuspendAppsLogic(private val appLogic: AppLogic): Observer {
} }
} }
private fun getAppsWithCategories(packageNames: List<String>, data: UserRelatedData, blockingAtActivityLevel: Boolean): Map<String, Set<String>> { private fun getAppsWithCategories(packageNames: List<String>, data: UserRelatedData, blockingAtActivityLevel: Boolean, deviceId: String): Map<String, Set<String>> {
val categoryForUnassignedApps = data.categoryById[data.user.categoryForNotAssignedApps] 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) { if (blockingAtActivityLevel) {
val categoriesByPackageName = data.categoryApps.groupBy { it.packageNameWithoutActivityName } val categoriesByPackageName = effectiveCategoryApps.groupBy { it.appSpecifier.packageName }
val result = mutableMapOf<String, Set<String>>() val result = mutableMapOf<String, Set<String>>()
packageNames.forEach { packageName -> packageNames.forEach { packageName ->
val categoriesItems = categoriesByPackageName[packageName] val categoriesItems = categoriesByPackageName[packageName]
val categories = (categoriesItems?.map { it.categoryId }?.toSet() ?: emptySet()).toMutableSet() 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 (!isMainAppIncluded) {
if (categoryForOtherSystemApps != null && appLogic.platformIntegration.isSystemImageApp(packageName)) { if (categoryForOtherSystemApps != null && appLogic.platformIntegration.isSystemImageApp(packageName)) {
@ -196,7 +209,9 @@ class SuspendAppsLogic(private val appLogic: AppLogic): Observer {
return result return result
} else { } else {
val categoryByPackageName = data.categoryApps.associateBy { it.packageName } val categoryByPackageName = effectiveCategoryApps
.filter { it.appSpecifier.activityName == null }
.associateBy { it.appSpecifier.packageName }
val result = mutableMapOf<String, Set<String>>() val result = mutableMapOf<String, Set<String>>()

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -73,15 +73,31 @@ sealed class AppBaseHandling {
} else if (foregroundAppPackageName != null) { } else if (foregroundAppPackageName != null) {
val appCategory = run { val appCategory = run {
val tryActivityLevelBlocking = deviceRelatedData.deviceEntry.enableActivityLevelBlocking && foregroundAppActivityName != null val tryActivityLevelBlocking = deviceRelatedData.deviceEntry.enableActivityLevelBlocking && foregroundAppActivityName != null
val appLevelCategory = userRelatedData.findCategoryApp(foregroundAppPackageName) ?: run { val appLevelCategory = userRelatedData.findCategoryAppTryDeviceSpecificFirst(
if (isSystemImageApp) userRelatedData.findCategoryApp(DummyApps.NOT_ASSIGNED_SYSTEM_IMAGE_APP) else null 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) { if (tryActivityLevelBlocking) {
userRelatedData.findCategoryApp("$foregroundAppPackageName:$foregroundAppActivityName") val activityLevelCategory = userRelatedData.findCategoryAppTryDeviceSpecificFirst(
} else { packageName = foregroundAppPackageName,
null activityName = foregroundAppActivityName,
}) ?: appLevelCategory 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] val startCategory = userRelatedData.categoryById[appCategory?.categoryId]
@ -95,8 +111,7 @@ sealed class AppBaseHandling {
return UseCategories( return UseCategories(
categoryIds = categoryIds, categoryIds = categoryIds,
shouldCount = !pauseCounting, shouldCount = !pauseCounting,
level = when (appCategory?.specifiesActivity) { level = when (appCategory == null || appCategory.appSpecifier.activityName != null) {
null -> BlockingLevel.Activity // occurs when using a default category
true -> BlockingLevel.Activity true -> BlockingLevel.Activity
false -> BlockingLevel.App false -> BlockingLevel.App
}, },

View file

@ -458,7 +458,7 @@ object ApplyServerDataStatus {
database.categoryApp().addCategoryAppsSync(item.assignedApps.map { database.categoryApp().addCategoryAppsSync(item.assignedApps.map {
CategoryApp( CategoryApp(
categoryId = item.categoryId, categoryId = item.categoryId,
packageName = it appSpecifierString = it
) )
}) })

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 - 2021 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -53,6 +53,10 @@ object LocalDatabaseParentActionDispatcher {
val allCategoriesOfChild = database.category().getCategoriesByChildIdSync(categoryEntry.childId) val allCategoriesOfChild = database.category().getCategoriesByChildIdSync(categoryEntry.childId)
if (fromChildSelfLimitAddChildUserId != null) { 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 parentCategoriesOfTargetCategory = allCategoriesOfChild.getCategoryWithParentCategories(action.categoryId)
val userEntry = database.user().getUserByIdSync(fromChildSelfLimitAddChildUserId) ?: throw RuntimeException("user not found") val userEntry = database.user().getUserByIdSync(fromChildSelfLimitAddChildUserId) ?: throw RuntimeException("user not found")
val validatedDefaultCategoryId = (allCategoriesOfChild.find { val validatedDefaultCategoryId = (allCategoriesOfChild.find {
@ -99,7 +103,7 @@ object LocalDatabaseParentActionDispatcher {
action.packageNames.map { action.packageNames.map {
CategoryApp( CategoryApp(
categoryId = action.categoryId, categoryId = action.categoryId,
packageName = it appSpecifierString = it
) )
} }
) )

View file

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

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -18,39 +18,23 @@ package io.timelimit.android.ui.manage.category.apps.add
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import io.timelimit.android.data.model.App
import io.timelimit.android.databinding.FragmentAddCategoryAppsItemBinding import io.timelimit.android.databinding.FragmentAddCategoryAppsItemBinding
import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.logic.DummyApps import io.timelimit.android.logic.DummyApps
import kotlin.properties.Delegates import kotlin.properties.Delegates
class AddAppAdapter: RecyclerView.Adapter<ViewHolder>() { class AddAppAdapter: RecyclerView.Adapter<ViewHolder>() {
var data: List<App>? by Delegates.observable(null as List<App>?) { _, _, _ -> notifyDataSetChanged() } var data: List<AddAppListItem> by Delegates.observable(emptyList()) { _, _, _ -> notifyDataSetChanged() }
var listener: AddAppAdapterListener? = null var listener: AddAppAdapterListener? = null
var categoryTitleByPackageName: Map<String, String> by Delegates.observable(emptyMap()) { _, _, _ -> notifyDataSetChanged() }
var selectedApps: Set<String> by Delegates.observable(emptySet()) { _, _, _ -> notifyDataSetChanged() } var selectedApps: Set<String> by Delegates.observable(emptySet()) { _, _, _ -> notifyDataSetChanged() }
init { init {
setHasStableIds(true) setHasStableIds(true)
} }
private fun getItem(position: Int): App { private fun getItem(position: Int): AddAppListItem = data[position]
return data!![position] override fun getItemId(position: Int): Long = getItem(position).hashCode().toLong()
} override fun getItemCount(): Int = data.size
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
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
FragmentAddCategoryAppsItemBinding.inflate( FragmentAddCategoryAppsItemBinding.inflate(
@ -65,10 +49,13 @@ class AddAppAdapter: RecyclerView.Adapter<ViewHolder>() {
val context = holder.itemView.context val context = holder.itemView.context
holder.apply { holder.apply {
binding.item = item binding.title = item.title
binding.subtitle = item.packageName
binding.currentCategoryTitle = item.currentCategoryName
binding.checked = selectedApps.contains(item.packageName) binding.checked = selectedApps.contains(item.packageName)
binding.currentCategoryTitle = categoryTitleByPackageName[item.packageName] binding.card.setOnClickListener { listener?.onAppClicked(item) }
binding.handlers = listener binding.card.setOnLongClickListener { listener?.onAppLongClicked(item) ?: false }
binding.showIcon = true
binding.executePendingBindings() binding.executePendingBindings()
binding.icon.setImageDrawable( binding.icon.setImageDrawable(
@ -83,6 +70,6 @@ class AddAppAdapter: RecyclerView.Adapter<ViewHolder>() {
class ViewHolder(val binding: FragmentAddCategoryAppsItemBinding): RecyclerView.ViewHolder(binding.root) class ViewHolder(val binding: FragmentAddCategoryAppsItemBinding): RecyclerView.ViewHolder(binding.root)
interface AddAppAdapterListener { interface AddAppAdapterListener {
fun onAppClicked(app: App) fun onAppClicked(app: AddAppListItem)
fun onAppLongClicked(app: App): Boolean fun onAppLongClicked(app: AddAppListItem): Boolean
} }

View file

@ -0,0 +1,22 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.ui.manage.category.apps.add
data class AddAppListItem (
val title: String,
val packageName: String,
val currentCategoryName: String?
)

View file

@ -0,0 +1,206 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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<AddAppsParams>()
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<Boolean>().apply { value = false }
val showAppsFromOtherCategories = MutableLiveData<Boolean>().apply { value = false }
val assignToThisDeviceOnly = MutableLiveData<Boolean>().apply { value = false }
val filter = MutableLiveData<AppFilterView.AppFilter>().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<EmptyViewText> = 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?)
}

View file

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

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 - 2021 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -15,7 +15,6 @@
*/ */
package io.timelimit.android.ui.manage.category.apps.add package io.timelimit.android.ui.manage.category.apps.add
import android.app.Dialog import android.app.Dialog
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
@ -27,24 +26,18 @@ import androidx.appcompat.app.AlertDialog
import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LiveData import androidx.fragment.app.viewModels
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.appbar.AppBarLayout import com.google.android.material.appbar.AppBarLayout
import io.timelimit.android.R 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.databinding.FragmentAddCategoryAppsBinding
import io.timelimit.android.extensions.showSafe 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.sync.actions.AddCategoryAppsAction
import io.timelimit.android.ui.main.ActivityViewModel import io.timelimit.android.ui.main.ActivityViewModel
import io.timelimit.android.ui.main.getActivityViewModel import io.timelimit.android.ui.main.getActivityViewModel
import io.timelimit.android.ui.manage.category.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.manage.category.apps.addactivity.AddAppActivitiesDialogFragment
import io.timelimit.android.ui.view.AppFilterView import io.timelimit.android.ui.view.AppFilterView
@ -53,23 +46,18 @@ class AddCategoryAppsFragment : DialogFragment() {
private const val DIALOG_TAG = "x" private const val DIALOG_TAG = "x"
private const val STATUS_PACKAGE_NAMES = "d" private const val STATUS_PACKAGE_NAMES = "d"
private const val STATUS_EDUCATED = "e" private const val STATUS_EDUCATED = "e"
private const val PARAM_CHILD_ID = "childId" private const val PARAMS = "params"
private const val PARAM_CATEGORY_ID = "categoryId"
private const val PARAM_CHILD_ADD_LIMIT_MODE = "addLimitMode"
fun newInstance(childId: String, categoryId: String, childAddLimitMode: Boolean) = AddCategoryAppsFragment().apply { fun newInstance(params: AddAppsParams) = AddCategoryAppsFragment().apply {
arguments = Bundle().apply { arguments = Bundle().apply { putParcelable(PARAMS, params) }
putString(PARAM_CHILD_ID, childId)
putString(PARAM_CATEGORY_ID, categoryId)
putBoolean(PARAM_CHILD_ADD_LIMIT_MODE, childAddLimitMode)
}
} }
} }
private val database: Database by lazy { DefaultAppLogic.with(requireContext()).database }
private val auth: ActivityViewModel by lazy { getActivityViewModel(requireActivity()) } private val auth: ActivityViewModel by lazy { getActivityViewModel(requireActivity()) }
private val adapter = AddAppAdapter() private val adapter = AddAppAdapter()
private var didEducateAboutAddingAssignedApps = false private var didEducateAboutAddingAssignedApps = false
private val baseModel: AddAppsOrActivitiesModel by viewModels()
private val model: AddAppsModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@ -89,167 +77,43 @@ class AddCategoryAppsFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val binding = FragmentAddCategoryAppsBinding.inflate(LayoutInflater.from(context)) val binding = FragmentAddCategoryAppsBinding.inflate(LayoutInflater.from(context))
val childId = requireArguments().getString(PARAM_CHILD_ID)!! val params = requireArguments().getParcelable<AddAppsParams>(PARAMS)!!
val categoryId = requireArguments().getString(PARAM_CATEGORY_ID)!!
val childAddLimitMode = requireArguments().getBoolean(PARAM_CHILD_ADD_LIMIT_MODE)
auth.authenticatedUserOrChild.observe(this, Observer { baseModel.init(params)
val parentAuthValid = it?.second?.type == UserType.Parent baseModel.isAuthValid(auth).observe(this) { if (!it) dismissAllowingStateLoss() }
val childAuthValid = it?.second?.id == childId && childAddLimitMode
val authValid = parentAuthValid || childAuthValid
if (!authValid) { model.init(params)
dismissAllowingStateLoss()
}
})
val filter = AppFilterView.getFilterLive(binding.filter) model.showAppsFromOtherDevicesChecked.value = binding.showAppsFromUnassignedDevices.isChecked
val isLocalMode = database.config().getDeviceAuthTokenAsync().map { it.isEmpty() } model.showAppsFromOtherCategories.value = binding.showOtherCategoriesApps.isChecked
val showAppsFromOtherDevicesChecked = MutableLiveData<Boolean>().apply { model.assignToThisDeviceOnly.value = binding.assignToThisDeviceOnly.isChecked
value = binding.showAppsFromUnassignedDevices.isChecked
}
val realShowAppsFromAllDevices = isLocalMode.switchMap { localMode ->
if (localMode) {
liveDataFromNonNullValue(true)
} else {
showAppsFromOtherDevicesChecked
}
}
binding.showAppsFromUnassignedDevices.setOnCheckedChangeListener { _, isChecked -> binding.showAppsFromUnassignedDevices.setOnCheckedChangeListener { _, isChecked ->
showAppsFromOtherDevicesChecked.value = isChecked model.showAppsFromOtherDevicesChecked.value = isChecked
} }
isLocalMode.observe(this, Observer { binding.assignToThisDeviceOnly.setOnCheckedChangeListener { _, isChecked ->
binding.showAppsFromUnassignedDevices.visibility = if (it) View.GONE else View.VISIBLE model.assignToThisDeviceOnly.value = isChecked
}) }
val showAppsFromOtherCategories = MutableLiveData<Boolean>().apply { value = binding.showOtherCategoriesApps.isChecked } AppFilterView.getFilterLive(binding.filter).observe(this) { model.filter.value = it }
binding.showOtherCategoriesApps.setOnCheckedChangeListener { _, isChecked -> showAppsFromOtherCategories.value = isChecked }
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.layoutManager = LinearLayoutManager(context)
binding.recycler.adapter = adapter binding.recycler.adapter = adapter
val childDeviceIds = database.device().getDevicesByUserId(childId) model.listItems.observe(this, Observer {
.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<String, String>()
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<String?> = 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<String?>
}
}
listItems.observe(this, Observer {
val selectedPackageNames = adapter.selectedApps val selectedPackageNames = adapter.selectedApps
val visiblePackageNames = it.map { it.packageName }.toSet() val visiblePackageNames = it.map { it.packageName }.toSet()
val hiddenSelectedPackageNames = selectedPackageNames.toMutableSet().apply { removeAll(visiblePackageNames) }.size 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) 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 { binding.someOptionsDisabledDueToChildAuthentication = params.isSelfLimitAddingMode
adapter.categoryTitleByPackageName = it
})
binding.someOptionsDisabledDueToChildAuthentication = childAddLimitMode model.deviceIdLive.observe(this) {/* keep loaded */}
binding.addAppsButton.setOnClickListener { binding.addAppsButton.setOnClickListener {
val packageNames = adapter.selectedApps.toList() val packageNames = adapter.selectedApps.toList()
if (packageNames.isNotEmpty()) { if (packageNames.isNotEmpty()) {
val deviceSpecific = binding.assignToThisDeviceOnly.isChecked && !params.isSelfLimitAddingMode
val deviceId = model.deviceIdLive.value
if (deviceSpecific && deviceId == null) return@setOnClickListener
auth.tryDispatchParentAction( auth.tryDispatchParentAction(
action = AddCategoryAppsAction( action = AddCategoryAppsAction(
categoryId = categoryId, categoryId = params.categoryId,
packageNames = packageNames packageNames = if (deviceSpecific) packageNames.map { "$it@$deviceId" } else packageNames
), ),
allowAsChild = childAddLimitMode allowAsChild = params.isSelfLimitAddingMode
) )
} }
dismiss() dismiss()
} }
binding.cancelButton.setOnClickListener { binding.cancelButton.setOnClickListener { dismiss() }
dismiss()
}
binding.selectAllButton.setOnClickListener { binding.selectAllButton.setOnClickListener {
adapter.selectedApps = adapter.selectedApps + (adapter.data?.map { it.packageName }?.toSet() ?: emptySet()) adapter.selectedApps = adapter.selectedApps + (adapter.data?.map { it.packageName }?.toSet() ?: emptySet())
} }
adapter.listener = object: AddAppAdapterListener { adapter.listener = object: AddAppAdapterListener {
override fun onAppClicked(app: App) { override fun onAppClicked(app: AddAppListItem) {
if (adapter.selectedApps.contains(app.packageName)) { if (adapter.selectedApps.contains(app.packageName)) {
adapter.selectedApps = adapter.selectedApps - setOf(app.packageName) adapter.selectedApps = adapter.selectedApps - setOf(app.packageName)
} else { } else {
if (!didEducateAboutAddingAssignedApps) { if (!didEducateAboutAddingAssignedApps) {
if (adapter.categoryTitleByPackageName[app.packageName] != null) { if (app.currentCategoryName != null) {
didEducateAboutAddingAssignedApps = true didEducateAboutAddingAssignedApps = true
AddAlreadyAssignedAppsInfoDialog().show(fragmentManager!!) 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()) { return if (adapter.selectedApps.isEmpty()) {
AddAppActivitiesDialogFragment.newInstance( AddAppActivitiesDialogFragment.newInstance(AddActivitiesParams(
childId = childId, base = params,
categoryId = categoryId, packageName = app.packageName
packageName = app.packageName, )).show(parentFragmentManager)
childAddLimitMode = childAddLimitMode
).show(parentFragmentManager)
dismissAllowingStateLoss() dismissAllowingStateLoss()

View file

@ -0,0 +1,131 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
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<AddActivitiesParams>()
fun init(params: AddActivitiesParams) {
if (didInit) return
paramsLive.value = params
didInit = true
}
val searchTerm = MutableLiveData<String>().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<List<AddActivityListItem>> = 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
}
}

View file

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

View file

@ -0,0 +1,22 @@
/*
* TimeLimit Copyright <C> 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 <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.ui.manage.category.apps.addactivity
data class AddActivityListItem (
val title: String,
val className: String,
val currentCategoryTitle: String?
)

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -21,173 +21,100 @@ import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.lifecycle.MutableLiveData import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import io.timelimit.android.R import io.timelimit.android.R
import io.timelimit.android.data.extensions.getCategoryWithParentCategories
import io.timelimit.android.data.model.UserType
import io.timelimit.android.databinding.FragmentAddCategoryActivitiesBinding import io.timelimit.android.databinding.FragmentAddCategoryActivitiesBinding
import io.timelimit.android.extensions.addOnTextChangedListener import io.timelimit.android.extensions.addOnTextChangedListener
import io.timelimit.android.extensions.showSafe 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.sync.actions.AddCategoryAppsAction
import io.timelimit.android.ui.main.getActivityViewModel import io.timelimit.android.ui.main.getActivityViewModel
import io.timelimit.android.ui.manage.category.apps.AddAppsOrActivitiesModel
class AddAppActivitiesDialogFragment: DialogFragment() { class AddAppActivitiesDialogFragment: DialogFragment() {
companion object { companion object {
private const val DIALOG_TAG = "AddAppActivitiesDialogFragment" private const val DIALOG_TAG = "AddAppActivitiesDialogFragment"
private const val CHILD_ID = "childId" private const val PARAMS = "params"
private const val CATEGORY_ID = "categoryId"
private const val PACKAGE_NAME = "packageName"
private const val SELECTED_ACTIVITIES = "selectedActivities" 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 { fun newInstance(params: AddActivitiesParams) = AddAppActivitiesDialogFragment().apply {
arguments = Bundle().apply { arguments = Bundle().apply { putParcelable(PARAMS, params) }
putString(CHILD_ID, childId)
putString(CATEGORY_ID, categoryId)
putString(PACKAGE_NAME, packageName)
putBoolean(CHILD_ADD_LIMIT_MODE, childAddLimitMode)
}
} }
} }
val adapter = AddAppActivityAdapter() private val adapter = AddAppActivityAdapter()
private val baseModel: AddAppsOrActivitiesModel by viewModels()
private val model: AddActivitiesModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
if (savedInstanceState != null) { if (savedInstanceState != null) {
adapter.selectedActiviities.clear() adapter.selectedActivities.clear()
savedInstanceState.getStringArray(SELECTED_ACTIVITIES)!!.forEach { adapter.selectedActiviities.add(it) } savedInstanceState.getStringArray(SELECTED_ACTIVITIES)!!.forEach { adapter.selectedActivities.add(it) }
} }
} }
override fun onSaveInstanceState(outState: Bundle) { override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState) super.onSaveInstanceState(outState)
outState.putStringArray(SELECTED_ACTIVITIES, adapter.selectedActiviities.toTypedArray()) outState.putStringArray(SELECTED_ACTIVITIES, adapter.selectedActivities.toTypedArray())
} }
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val appPackageName = arguments!!.getString(PACKAGE_NAME)!! val params = requireArguments().getParcelable<AddActivitiesParams>(PARAMS)!!
val categoryId = arguments!!.getString(CATEGORY_ID)!! val auth = getActivityViewModel(requireActivity())
val childId = arguments!!.getString(CHILD_ID)!! val binding = FragmentAddCategoryActivitiesBinding.inflate(LayoutInflater.from(context))
val childAddLimitMode = arguments!!.getBoolean(CHILD_ADD_LIMIT_MODE)
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.authenticatedUserOrChild.observe(this, Observer { baseModel.init(params.base)
val parentAuthenticated = it?.second?.type == UserType.Parent baseModel.isAuthValid(auth).observe(this) { if (!it) dismissAllowingStateLoss() }
val childAuthenticated = it?.second?.id == childId && childAddLimitMode
val anyoneAuthenticated = parentAuthenticated || childAuthenticated
if (!anyoneAuthenticated) { model.init(params)
dismissAllowingStateLoss() model.searchTerm.value = binding.search.text.toString()
} binding.search.addOnTextChangedListener { model.searchTerm.value = binding.search.text.toString() }
})
val logic = DefaultAppLogic.with(context!!) binding.recycler.layoutManager = LinearLayoutManager(requireContext())
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.adapter = adapter binding.recycler.adapter = adapter
filteredActivities.observe(this, Observer { list -> model.filteredActivities.observe(this) { list ->
val selectedActivities = adapter.selectedActiviities val selectedActivities = adapter.selectedActivities
val visibleActivities = list.map { it.activityClassName } val visibleActivities = list.map { it.className }
val hiddenSelectedActivities = selectedActivities.toMutableSet().apply { removeAll(visibleActivities) }.size val hiddenSelectedActivities = selectedActivities.toMutableSet().apply { removeAll(visibleActivities) }.size
adapter.data = list adapter.data = list
binding.hiddenEntries = if (hiddenSelectedActivities == 0) binding.hiddenEntries = if (hiddenSelectedActivities == 0)
null null
else else
resources.getQuantityString(R.plurals.category_apps_add_dialog_hidden_entries, hiddenSelectedActivities, hiddenSelectedActivities) resources.getQuantityString(R.plurals.category_apps_add_dialog_hidden_entries, hiddenSelectedActivities, hiddenSelectedActivities)
}) }
val emptyViewText = allActivitiesLive.switchMap { all -> model.emptyViewText.observe(this) {
shownActivities.switchMap { shown -> binding.emptyViewText = when (it!!) {
filteredActivities.map { filtered -> AddActivitiesModel.EmptyViewText.None -> null
if (filtered.isNotEmpty()) AddActivitiesModel.EmptyViewText.EmptyShown -> getString(R.string.category_apps_add_activity_empty_shown)
null AddActivitiesModel.EmptyViewText.EmptyFiltered -> getString(R.string.category_apps_add_activity_empty_filtered)
else if (all.isNotEmpty()) AddActivitiesModel.EmptyViewText.EmptyUnfiltered -> getString(R.string.category_apps_add_activity_empty_unfiltered)
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)
}
} }
} }
emptyViewText.observe(this, Observer { binding.someOptionsDisabledDueToChildAuthentication = params.base.isSelfLimitAddingMode
binding.emptyViewText = it
})
binding.someOptionsDisabledDueToChildAuthentication = childAddLimitMode
binding.cancelButton.setOnClickListener { dismissAllowingStateLoss() } binding.cancelButton.setOnClickListener { dismissAllowingStateLoss() }
binding.addActivitiesButton.setOnClickListener { binding.addActivitiesButton.setOnClickListener {
if (adapter.selectedActiviities.isNotEmpty()) { if (adapter.selectedActivities.isNotEmpty()) {
auth.tryDispatchParentAction( auth.tryDispatchParentAction(
action = AddCategoryAppsAction( action = AddCategoryAppsAction(
categoryId = categoryId, categoryId = params.base.categoryId,
packageNames = adapter.selectedActiviities.toList().map { "$appPackageName:$it" } packageNames = adapter.selectedActivities.toList().map { "${params.packageName}:$it" }
), ),
allowAsChild = childAddLimitMode allowAsChild = params.base.isSelfLimitAddingMode
) )
} }
dismissAllowingStateLoss() dismissAllowingStateLoss()
} }
return AlertDialog.Builder(context!!, R.style.AppTheme) return AlertDialog.Builder(requireContext(), R.style.AppTheme)
.setView(binding.root) .setView(binding.root)
.create() .create()
} }

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -18,58 +18,54 @@ package io.timelimit.android.ui.manage.category.apps.addactivity
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.ViewGroup import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import io.timelimit.android.data.model.AppActivity import io.timelimit.android.databinding.FragmentAddCategoryAppsItemBinding
import io.timelimit.android.databinding.FragmentAddCategoryActivitiesItemBinding
import io.timelimit.android.extensions.toggle import io.timelimit.android.extensions.toggle
import kotlin.properties.Delegates import kotlin.properties.Delegates
class AddAppActivityAdapter: RecyclerView.Adapter<ViewHolder>() { class AddAppActivityAdapter: RecyclerView.Adapter<ViewHolder>() {
var data: List<AppActivity>? by Delegates.observable(null as List<AppActivity>?) { _, _, _ -> notifyDataSetChanged() } var data: List<AddActivityListItem>? by Delegates.observable(emptyList()) { _, _, _ -> notifyDataSetChanged() }
val selectedActiviities = mutableSetOf<String>()
private val itemHandlers = object: ItemHandlers { val selectedActivities = mutableSetOf<String>()
override fun onActivityClicked(activity: AppActivity) {
selectedActiviities.toggle(activity.activityClassName)
notifyDataSetChanged()
}
}
init { init {
setHasStableIds(true) setHasStableIds(true)
} }
private fun getItem(position: Int): AppActivity { private fun getItem(position: Int): AddActivityListItem {
return data!![position] return data!![position]
} }
override fun getItemId(position: Int): Long { 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 getItemCount(): Int = this.data?.size ?: 0
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder( override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) = ViewHolder(
FragmentAddCategoryActivitiesItemBinding.inflate( FragmentAddCategoryAppsItemBinding.inflate(
LayoutInflater.from(parent.context), LayoutInflater.from(parent.context),
parent, parent,
false false
).apply { handlers = itemHandlers } )
) )
override fun onBindViewHolder(holder: ViewHolder, position: Int) { override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val item = getItem(position) val item = getItem(position)
holder.apply { holder.apply {
binding.item = item binding.title = item.title
binding.checked = selectedActiviities.contains(item.activityClassName) binding.currentCategoryTitle = item.currentCategoryTitle
binding.subtitle = item.className
binding.showIcon = false
binding.checked = selectedActivities.contains(item.className)
binding.executePendingBindings() binding.executePendingBindings()
binding.card.setOnClickListener {
selectedActivities.toggle(item.className)
binding.checked = selectedActivities.contains(item.className)
}
} }
} }
} }
class ViewHolder(val binding: FragmentAddCategoryActivitiesItemBinding): RecyclerView.ViewHolder(binding.root) class ViewHolder(val binding: FragmentAddCategoryAppsItemBinding): RecyclerView.ViewHolder(binding.root)
interface ItemHandlers {
fun onActivityClicked(activity: AppActivity)
}

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 - 2021 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -13,9 +13,7 @@
* You should have received a copy of the GNU General Public License * You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package io.timelimit.android.ui.manage.category.apps package io.timelimit.android.ui.manage.category.appsandrules
import io.timelimit.android.ui.manage.category.appsandrules.AppAndRuleItem
interface AppAdapterHandlers { interface AppAdapterHandlers {
fun onAppClicked(app: AppAndRuleItem.AppEntry) fun onAppClicked(app: AppAndRuleItem.AppEntry)

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 - 2021 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -27,7 +27,6 @@ import io.timelimit.android.date.DateInTimezone
import io.timelimit.android.extensions.MinuteOfDay import io.timelimit.android.extensions.MinuteOfDay
import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.logic.DummyApps 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.ui.manage.category.timelimit_rules.TimeLimitRulesHandlers
import io.timelimit.android.util.DayNameUtil import io.timelimit.android.util.DayNameUtil
import io.timelimit.android.util.TimeTextUtil import io.timelimit.android.util.TimeTextUtil
@ -58,7 +57,7 @@ class AppAndRuleAdapter: RecyclerView.Adapter<AppAndRuleAdapter.Holder>() {
override fun getItemId(position: Int): Long = items[position].let { item -> override fun getItemId(position: Int): Long = items[position].let { item ->
when (item) { when (item) {
is AppAndRuleItem.AppEntry -> item.packageName.hashCode() is AppAndRuleItem.AppEntry -> item.specifier.hashCode()
is AppAndRuleItem.RuleEntry -> item.rule.id.hashCode() is AppAndRuleItem.RuleEntry -> item.rule.id.hashCode()
else -> item.hashCode() else -> item.hashCode()
} }
@ -137,16 +136,17 @@ class AppAndRuleAdapter: RecyclerView.Adapter<AppAndRuleAdapter.Holder>() {
val binding = holder.itemView.tag as FragmentCategoryAppsItemBinding val binding = holder.itemView.tag as FragmentCategoryAppsItemBinding
val context = binding.root.context val context = binding.root.context
binding.item = item binding.title = item.title
binding.handlers = handlers 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.executePendingBindings()
binding.root.setOnLongClickListener { handlers?.onAppLongClicked(item) ?: false }
binding.icon.setImageDrawable( binding.icon.setImageDrawable(
DummyApps.getIcon(item.packageNameWithoutActivityName, context) ?: DummyApps.getIcon(item.specifier.packageName, context) ?:
DefaultAppLogic.with(context) DefaultAppLogic.with(context)
.platformIntegration.getAppIcon(item.packageNameWithoutActivityName) .platformIntegration.getAppIcon(item.specifier.packageName)
) )
} }
AppAndRuleItem.AddAppItem -> {/* nothing to do */} AppAndRuleItem.AddAppItem -> {/* nothing to do */}

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -17,9 +17,10 @@
package io.timelimit.android.ui.manage.category.appsandrules package io.timelimit.android.ui.manage.category.appsandrules
import io.timelimit.android.data.model.TimeLimitRule import io.timelimit.android.data.model.TimeLimitRule
import io.timelimit.android.data.model.derived.AppSpecifier
sealed class AppAndRuleItem { 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 AddAppItem: AppAndRuleItem()
object ExpandAppsItem: AppAndRuleItem() object ExpandAppsItem: AppAndRuleItem()
data class RuleEntry(val rule: TimeLimitRule): AppAndRuleItem() data class RuleEntry(val rule: TimeLimitRule): AppAndRuleItem()

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -24,6 +24,7 @@ import io.timelimit.android.data.extensions.getDateLive
import io.timelimit.android.data.model.HintsToShow import io.timelimit.android.data.model.HintsToShow
import io.timelimit.android.extensions.takeDistributedElements import io.timelimit.android.extensions.takeDistributedElements
import io.timelimit.android.livedata.map import io.timelimit.android.livedata.map
import io.timelimit.android.livedata.mergeLiveDataWaitForValues
import io.timelimit.android.livedata.switchMap import io.timelimit.android.livedata.switchMap
import io.timelimit.android.logic.DefaultAppLogic import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.logic.DummyApps import io.timelimit.android.logic.DummyApps
@ -108,27 +109,30 @@ class AppsAndRulesModel(application: Application): AndroidViewModel(application)
private val installedApps = database.app().getAllApps() 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 appsOfThisCategory = categoryIdLive.switchMap { categoryId -> database.categoryApp().getCategoryApps(categoryId) }
private val appsOfCategoryWithNames = installedApps.switchMap { allApps -> private val appsOfCategoryWithNames = mergeLiveDataWaitForValues(installedAppsIndexed, appsOfThisCategory, deviceNamesIndexedLive)
appsOfThisCategory.map { apps -> .map { (allAppsIndexed, appsOfThisCategory, deviceNamesIndexed) ->
apps.map { categoryApp -> appsOfThisCategory.map { categoryApp ->
val title = DummyApps.getTitle(categoryApp.packageNameWithoutActivityName, getApplication()) ?: val title = DummyApps.getTitle(categoryApp.appSpecifier.packageName, getApplication()) ?:
allApps.find { app -> app.packageName == categoryApp.packageNameWithoutActivityName }?.title 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 -> private val appEntries = appsOfCategoryWithNames.map { apps ->
apps.map { (app, title) -> apps.sortedBy { it.title.lowercase() }
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) }
} }
private val fullAppScreenContent = showAllAppsLive.switchMap { showAllApps -> private val fullAppScreenContent = showAllAppsLive.switchMap { showAllApps ->

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 - 2021 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -39,6 +39,7 @@ import io.timelimit.android.sync.actions.RemoveCategoryAppsAction
import io.timelimit.android.sync.actions.UpdateTimeLimitRuleAction import io.timelimit.android.sync.actions.UpdateTimeLimitRuleAction
import io.timelimit.android.ui.main.ActivityViewModel import io.timelimit.android.ui.main.ActivityViewModel
import io.timelimit.android.ui.main.getActivityViewModel import io.timelimit.android.ui.main.getActivityViewModel
import io.timelimit.android.ui.manage.category.apps.add.AddAppsParams
import io.timelimit.android.ui.manage.category.apps.add.AddCategoryAppsFragment 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.EditTimeLimitRuleDialogFragment
import io.timelimit.android.ui.manage.category.timelimit_rules.edit.EditTimeLimitRuleDialogFragmentListener import io.timelimit.android.ui.manage.category.timelimit_rules.edit.EditTimeLimitRuleDialogFragmentListener
@ -135,7 +136,7 @@ abstract class CategoryAppsAndRulesFragment: Fragment(), Handlers, EditTimeLimit
if (auth.tryDispatchParentAction( if (auth.tryDispatchParentAction(
RemoveCategoryAppsAction( RemoveCategoryAppsAction(
categoryId = categoryId, 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) 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( auth.tryDispatchParentAction(
AddCategoryAppsAction( AddCategoryAppsAction(
categoryId = categoryId, 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()) { return if (auth.requestAuthenticationOrReturnTrue()) {
AssignAppCategoryDialogFragment.newInstance( AssignAppCategoryDialogFragment.newInstance(
childId = childId, childId = childId,
appPackageName = app.packageName appPackageName = app.specifier.encode()
).show(parentFragmentManager) ).show(parentFragmentManager)
true true
@ -164,11 +165,11 @@ abstract class CategoryAppsAndRulesFragment: Fragment(), Handlers, EditTimeLimit
override fun onAddAppsClicked() { override fun onAddAppsClicked() {
if (auth.requestAuthenticationOrReturnTrueAllowChild(childId = childId)) { if (auth.requestAuthenticationOrReturnTrueAllowChild(childId = childId)) {
AddCategoryAppsFragment.newInstance( AddCategoryAppsFragment.newInstance(AddAppsParams(
childId = childId, childId = childId,
categoryId = categoryId, categoryId = categoryId,
childAddLimitMode = !auth.isParentAuthenticated() isSelfLimitAddingMode = !auth.isParentAuthenticated()
).show(parentFragmentManager) )).show(parentFragmentManager)
} }
} }

View file

@ -124,7 +124,7 @@ object DuplicateChildActions {
result.add(AddCategoryAppsAction( result.add(AddCategoryAppsAction(
categoryId = newCategoryId, categoryId = newCategoryId,
packageNames = oldApps.map { it.packageName } packageNames = oldApps.map { it.appSpecifierString }
)) ))
} }

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 - 2021 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -64,13 +64,17 @@ class ChildAppsModel(application: Application): AndroidViewModel(application) {
val listContentLive = childAppsLive.switchMap { childApps -> val listContentLive = childAppsLive.switchMap { childApps ->
childCategoriesLive.switchMap { categories -> childCategoriesLive.switchMap { categories ->
childCategoryAppsLive.switchMap { categoryApps -> 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 -> appFilterLive.ignoreUnchanged().switchMap { appFilter ->
val filteredChildApps = childApps.filter { appFilter.matches(it) } val filteredChildApps = childApps.filter { appFilter.matches(it) }
modeLive.ignoreUnchanged().map { mode -> modeLive.ignoreUnchanged().map { mode ->
when (mode!!) { when (mode!!) {
ChildAppsMode.SortByCategory -> { ChildAppsMode.SortByCategory -> {
val categoryAppByPackageName = categoryApps.associateBy { it.packageName }
val appsByCategoryId = filteredChildApps.groupBy { app -> val appsByCategoryId = filteredChildApps.groupBy { app ->
categoryAppByPackageName[app.packageName]?.categoryId categoryAppByPackageName[app.packageName]?.categoryId
} }
@ -117,7 +121,6 @@ class ChildAppsModel(application: Application): AndroidViewModel(application) {
} }
ChildAppsMode.SortByTitle -> { ChildAppsMode.SortByTitle -> {
val categoryById = categories.associateBy { it.id } val categoryById = categories.associateBy { it.id }
val categoryAppByPackageName = categoryApps.associateBy { it.packageName }
filteredChildApps filteredChildApps
.distinctBy { it.packageName } .distinctBy { it.packageName }

View file

@ -194,7 +194,7 @@ class SetupDeviceFragment : Fragment(), FragmentWithCustomTitle {
val appsAssignedToTheUser = categoriesOfTheSelectedUser.switchMap { categories -> val appsAssignedToTheUser = categoriesOfTheSelectedUser.switchMap { categories ->
logic.database.categoryApp().getCategoryApps(categories.map { it.id }).map { categoryApps -> logic.database.categoryApp().getCategoryApps(categories.map { it.id }).map { categoryApps ->
categoryApps.map { it.packageName }.toSet() categoryApps.map { it.appSpecifierString }.toSet()
} }
} }

View file

@ -1,5 +1,5 @@
/* /*
* TimeLimit Copyright <C> 2019 - 2021 Jonas Lochmann * TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by * it under the terms of the GNU General Public License as published by
@ -132,7 +132,8 @@ class SetupDeviceModel(application: Application): AndroidViewModel(application)
val alreadyAssignedApps = Threads.database.executeAndWait { val alreadyAssignedApps = Threads.database.executeAndWait {
logic.database.categoryApp().getCategoryAppsByUserIdSync(realUserId) logic.database.categoryApp().getCategoryAppsByUserIdSync(realUserId)
.map { it.packageName } .filter { it.appSpecifier.deviceId == null }
.map { it.appSpecifier.packageName }
.toSet() .toSet()
} }

View file

@ -1,100 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
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/>.
-->
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="item"
type="io.timelimit.android.data.model.AppActivity" />
<variable
name="currentCategoryTitle"
type="String" />
<variable
name="handlers"
type="io.timelimit.android.ui.manage.category.apps.addactivity.ItemHandlers" />
<variable
name="checked"
type="Boolean" />
<import type="android.view.View" />
<import type="android.text.TextUtils" />
</data>
<androidx.cardview.widget.CardView
android:onClick="@{() -> handlers.onActivityClicked(item)}"
android:foreground="?selectableItemBackground"
app:cardUseCompatPadding="true"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:orientation="horizontal"
android:layout_width="match_parent"
android:layout_height="wrap_content">
<LinearLayout
android:layout_weight="1"
android:padding="8dp"
android:orientation="vertical"
android:layout_width="0dp"
android:layout_height="wrap_content">
<TextView
android:textAppearance="?android:textAppearanceLarge"
tools:text="Android Settings"
android:text="@{item.title}"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:visibility="@{TextUtils.isEmpty(currentCategoryTitle) ? View.GONE : View.VISIBLE}"
android:textAppearance="?android:textAppearanceMedium"
tools:text="@string/category_apps_add_dialog_already_assigned_to"
android:textColor="@color/colorPrimary"
android:text="@{@string/category_apps_add_dialog_already_assigned_to(currentCategoryTitle)}"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:textAppearance="?android:textAppearanceSmall"
tools:text="com.android.settings"
android:text="@{item.activityClassName}"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
<ImageView
android:visibility="@{safeUnbox(checked) ? View.VISIBLE : View.GONE}"
android:tint="?colorPrimary"
android:layout_margin="8dp"
android:layout_gravity="center_vertical"
android:src="@drawable/ic_check_box_black_24dp"
android:layout_width="48dp"
android:layout_height="48dp" />
<ImageView
android:visibility="@{safeUnbox(checked) ? View.GONE : View.VISIBLE}"
android:tint="@color/gray"
android:layout_margin="8dp"
android:layout_gravity="center_vertical"
android:src="@drawable/ic_check_box_outline_blank_black_24dp"
android:layout_width="48dp"
android:layout_height="48dp" />
</LinearLayout>
</androidx.cardview.widget.CardView>
</layout>

View file

@ -1,5 +1,5 @@
<!-- <!--
TimeLimit Copyright <C> 2019 - 2020 Jonas Lochmann TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License. the Free Software Foundation version 3 of the License.
@ -71,6 +71,13 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" /> android:layout_height="wrap_content" />
<CheckBox
android:checked="false"
android:id="@+id/assign_to_this_device_only"
android:text="@string/category_apps_add_dialog_assign_current_device_only"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout> </LinearLayout>
</com.google.android.material.appbar.AppBarLayout> </com.google.android.material.appbar.AppBarLayout>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
TimeLimit Copyright <C> 2019 Jonas Lochmann TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License. the Free Software Foundation version 3 of the License.
@ -18,28 +18,31 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<data> <data>
<variable <variable
name="item" name="title"
type="io.timelimit.android.data.model.App" /> type="String" />
<variable
name="subtitle"
type="String" />
<variable <variable
name="currentCategoryTitle" name="currentCategoryTitle"
type="String" /> type="String" />
<variable
name="handlers"
type="io.timelimit.android.ui.manage.category.apps.add.AddAppAdapterListener" />
<variable <variable
name="checked" name="checked"
type="Boolean" /> type="Boolean" />
<variable
name="showIcon"
type="boolean" />
<import type="android.view.View" /> <import type="android.view.View" />
<import type="android.text.TextUtils" /> <import type="android.text.TextUtils" />
</data> </data>
<androidx.cardview.widget.CardView <androidx.cardview.widget.CardView
android:onClick="@{() -> handlers.onAppClicked(item)}" android:id="@+id/card"
android:onLongClick="@{() -> handlers.onAppLongClicked(item)}"
android:foreground="?selectableItemBackground" android:foreground="?selectableItemBackground"
app:cardUseCompatPadding="true" app:cardUseCompatPadding="true"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -50,6 +53,7 @@
android:layout_height="wrap_content"> android:layout_height="wrap_content">
<ImageView <ImageView
android:visibility="@{showIcon ? View.VISIBLE : View.GONE}"
android:id="@+id/icon" android:id="@+id/icon"
tools:src="@mipmap/ic_launcher" tools:src="@mipmap/ic_launcher"
android:layout_margin="8dp" android:layout_margin="8dp"
@ -66,7 +70,7 @@
<TextView <TextView
android:textAppearance="?android:textAppearanceLarge" android:textAppearance="?android:textAppearanceLarge"
tools:text="Android Settings" tools:text="Android Settings"
android:text="@{item.title}" android:text="@{title}"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" /> android:layout_height="wrap_content" />
@ -82,7 +86,7 @@
<TextView <TextView
android:textAppearance="?android:textAppearanceSmall" android:textAppearance="?android:textAppearanceSmall"
tools:text="com.android.settings" tools:text="com.android.settings"
android:text="@{item.packageName}" android:text="@{subtitle}"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" /> android:layout_height="wrap_content" />
</LinearLayout> </LinearLayout>

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- <!--
TimeLimit Copyright <C> 2019 Jonas Lochmann TimeLimit Copyright <C> 2019 - 2022 Jonas Lochmann
This program is free software: you can redistribute it and/or modify This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License. the Free Software Foundation version 3 of the License.
@ -18,18 +18,23 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<data> <data>
<variable <variable
name="item" name="title"
type="io.timelimit.android.ui.manage.category.appsandrules.AppAndRuleItem.AppEntry" /> type="String" />
<variable <variable
name="handlers" name="deviceName"
type="io.timelimit.android.ui.manage.category.apps.AppAdapterHandlers" /> type="String" />
<variable
name="subtitle"
type="String" />
<import type="android.text.TextUtils" />
<import type="android.view.View" /> <import type="android.view.View" />
</data> </data>
<androidx.cardview.widget.CardView <androidx.cardview.widget.CardView
android:onClick="@{() -> handlers.onAppClicked(item)}" android:id="@+id/card"
android:foreground="?selectableItemBackground" android:foreground="?selectableItemBackground"
app:cardUseCompatPadding="true" app:cardUseCompatPadding="true"
android:layout_width="match_parent" android:layout_width="match_parent"
@ -56,14 +61,23 @@
<TextView <TextView
android:textAppearance="?android:textAppearanceLarge" android:textAppearance="?android:textAppearanceLarge"
tools:text="Android Settings" tools:text="Android Settings"
android:text="@{item.title}" android:text="@{title}"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
<TextView
android:textAppearance="?android:textAppearanceMedium"
android:textColor="?colorAccent"
tools:text="AVD 1"
android:text="@{deviceName}"
android:visibility="@{TextUtils.isEmpty(deviceName) ? View.GONE : View.VISIBLE}"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" /> android:layout_height="wrap_content" />
<TextView <TextView
android:textAppearance="?android:textAppearanceSmall" android:textAppearance="?android:textAppearanceSmall"
tools:text="com.android.settings" tools:text="com.android.settings"
android:text="@{item.packageName}" android:text="@{subtitle}"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" /> android:layout_height="wrap_content" />
</LinearLayout> </LinearLayout>

View file

@ -241,6 +241,7 @@
<string name="category_apps_add_dialog_select_all">Alle auswählen</string> <string name="category_apps_add_dialog_select_all">Alle auswählen</string>
<string name="category_apps_add_dialog_show_assigned_to_other_category">Zu anderen Kategorien zugeordnete Apps anzeigen</string> <string name="category_apps_add_dialog_show_assigned_to_other_category">Zu anderen Kategorien zugeordnete Apps anzeigen</string>
<string name="category_apps_add_dialog_show_from_other_devices">Apps von Geräten, die nicht diesem Kind zugeordnet wurden, anzeigen</string> <string name="category_apps_add_dialog_show_from_other_devices">Apps von Geräten, die nicht diesem Kind zugeordnet wurden, anzeigen</string>
<string name="category_apps_add_dialog_assign_current_device_only">Nur für dieses Gerät zuweisen</string>
<plurals name="category_apps_add_dialog_hidden_entries"> <plurals name="category_apps_add_dialog_hidden_entries">
<item quantity="one">%d ausgeblendeten Eintrag ausgewählt</item> <item quantity="one">%d ausgeblendeten Eintrag ausgewählt</item>
<item quantity="other">%s ausgeblendete Einträge ausgewählt</item> <item quantity="other">%s ausgeblendete Einträge ausgewählt</item>

View file

@ -281,6 +281,7 @@
<string name="category_apps_add_dialog_show_sys_apps">Show system Apps</string> <string name="category_apps_add_dialog_show_sys_apps">Show system Apps</string>
<string name="category_apps_add_dialog_show_assigned_to_other_category">Show Apps assigned to other categories</string> <string name="category_apps_add_dialog_show_assigned_to_other_category">Show Apps assigned to other categories</string>
<string name="category_apps_add_dialog_show_from_other_devices">Show Apps from devices not assigned to this child</string> <string name="category_apps_add_dialog_show_from_other_devices">Show Apps from devices not assigned to this child</string>
<string name="category_apps_add_dialog_assign_current_device_only">Assign for this device only</string>
<string name="category_apps_add_dialog_select_all">Select all</string> <string name="category_apps_add_dialog_select_all">Select all</string>
<plurals name="category_apps_add_dialog_hidden_entries"> <plurals name="category_apps_add_dialog_hidden_entries">
<item quantity="one">%d hidden entry selected</item> <item quantity="one">%d hidden entry selected</item>