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
},
{
"fieldPath": "packageName",
"fieldPath": "appSpecifierString",
"columnName": "package_name",
"affinity": "TEXT",
"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
import androidx.lifecycle.LiveData
@ -6,6 +21,7 @@ import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import io.timelimit.android.data.model.AppActivity
import io.timelimit.android.data.model.AppActivityTitleAndClassNameItem
@Dao
interface AppActivityDao {
@ -15,8 +31,8 @@ interface AppActivityDao {
@Query("SELECT * FROM app_activity WHERE device_id IN (:deviceIds)")
fun getAppActivitiesByDeviceIds(deviceIds: List<String>): LiveData<List<AppActivity>>
@Query("SELECT * FROM app_activity WHERE app_package_name = :packageName")
fun getAppActivitiesByPackageName(packageName: String): LiveData<List<AppActivity>>
@Query("SELECT DISTINCT activity_class_name, activity_title FROM app_activity WHERE app_package_name = :packageName")
fun getAppActivitiesByPackageName(packageName: String): LiveData<List<AppActivityTitleAndClassNameItem>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
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
* it under the terms of the GNU General Public License as published by
@ -17,9 +17,7 @@ package io.timelimit.android.data.dao
import androidx.lifecycle.LiveData
import androidx.room.*
import io.timelimit.android.data.model.Device
import io.timelimit.android.data.model.NetworkTime
import io.timelimit.android.data.model.NetworkTimeAdapter
import io.timelimit.android.data.model.*
import io.timelimit.android.integration.platform.NewPermissionStatusConverter
import io.timelimit.android.integration.platform.ProtectionLevelConverter
import io.timelimit.android.integration.platform.RuntimePermissionStatusConverter
@ -41,6 +39,9 @@ abstract class DeviceDao {
@Query("SELECT * FROM device ORDER BY id")
abstract fun getAllDevicesLive(): LiveData<List<Device>>
@Query("SELECT id, name FROM device")
abstract fun getDeviceNamesLive(): LiveData<List<DeviceName>>
@Query("SELECT * FROM device")
abstract fun getAllDevicesSync(): List<Device>
@ -68,6 +69,9 @@ abstract class DeviceDao {
@Query("SELECT * FROM device WHERE current_user_id = :userId")
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 = \"\"")
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
* it under the terms of the GNU General Public License as published by
@ -21,13 +21,14 @@ import androidx.room.ColumnInfo
import androidx.room.Entity
import io.timelimit.android.data.IdGenerator
import io.timelimit.android.data.JsonSerializable
import io.timelimit.android.data.model.derived.AppSpecifier
@Entity(primaryKeys = ["category_id", "package_name"], tableName = "category_app")
data class CategoryApp(
@ColumnInfo(index = true, name = "category_id")
val categoryId: String,
@ColumnInfo(index = true, name = "package_name")
val packageName: String
val appSpecifierString: String // originally a packageName, but can contain more than that
): JsonSerializable {
companion object {
private const val CATEGORY_ID = "c"
@ -51,26 +52,18 @@ data class CategoryApp(
return CategoryApp(
categoryId = categoryId!!,
packageName = packageName!!
appSpecifierString = packageName!!
)
}
}
@delegate:Transient
val packageNameWithoutActivityName: String by lazy {
if (specifiesActivity)
packageName.substring(0, packageName.indexOf(":"))
else
packageName
}
@Transient
val specifiesActivity = packageName.contains(":")
val appSpecifier: AppSpecifier by lazy { AppSpecifier.decode(appSpecifierString) }
init {
IdGenerator.assertIdValid(categoryId)
if (packageName.isEmpty()) {
if (appSpecifierString.isEmpty()) {
throw IllegalArgumentException()
}
}
@ -79,7 +72,7 @@ data class CategoryApp(
writer.beginObject()
writer.name(CATEGORY_ID).value(categoryId)
writer.name(PACKAGE_NAME).value(packageName)
writer.name(PACKAGE_NAME).value(appSpecifierString)
writer.endObject()
}

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
* it under the terms of the GNU General Public License as published by
@ -34,7 +34,7 @@ data class UserRelatedData(
val categoryApps: List<CategoryApp>
): Observer {
companion object {
private val notFoundCategoryApp = CategoryApp(categoryId = IdGenerator.generateId(), packageName = BuildConfig.APPLICATION_ID)
private val notFoundCategoryApp = CategoryApp(categoryId = IdGenerator.generateId(), appSpecifierString = BuildConfig.APPLICATION_ID)
private val relatedTables = arrayOf(
Table.User, Table.Category, Table.TimeLimitRule,
@ -72,11 +72,11 @@ data class UserRelatedData(
// notFoundCategoryApp is a workaround because the lru cache does not support null
private val categoryAppLruCache = object: LruCache<String, CategoryApp>(8) {
override fun create(key: String): CategoryApp {
return categoryApps.find { it.packageName == key } ?: notFoundCategoryApp
return categoryApps.find { it.appSpecifierString == key } ?: notFoundCategoryApp
}
}
fun findCategoryApp(packageName: String): CategoryApp? {
val item = categoryAppLruCache[packageName]
private fun findCategoryApp(appSpecifier: AppSpecifier): CategoryApp? {
val item = categoryAppLruCache[appSpecifier.encode()]
// important: strict equality/ same object instance
if (item === notFoundCategoryApp) {
@ -85,6 +85,15 @@ data class UserRelatedData(
return item
}
}
fun findCategoryAppTryDeviceSpecificFirst(packageName: String, activityName: String?, deviceId: String): CategoryApp? = findCategoryApp(AppSpecifier(
packageName = packageName,
activityName = activityName,
deviceId = deviceId
)) ?: findCategoryApp(AppSpecifier(
packageName = packageName,
activityName = activityName,
deviceId = null
))
private var userInvalidated = false
private var categoriesInvalidated = false

View file

@ -223,7 +223,7 @@ class AppSetupLogic(private val appLogic: AppLogic) {
.map {
CategoryApp(
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
* it under the terms of the GNU General Public License as published by
@ -152,7 +152,7 @@ class SuspendAppsLogic(private val appLogic: AppLogic): Observer {
lastDefaultCategory = defaultCategory
val installedApps = appLogic.platformIntegration.getLocalAppPackageNames()
val prepared = getAppsWithCategories(installedApps, userRelatedData, blockingAtActivityLevel)
val prepared = getAppsWithCategories(installedApps, userRelatedData, blockingAtActivityLevel, userAndDeviceRelatedData.deviceRelatedData.deviceEntry.id)
val appsToBlock = mutableListOf<String>()
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 categoryForOtherSystemApps = data.findCategoryApp(DummyApps.NOT_ASSIGNED_SYSTEM_IMAGE_APP)?.categoryId?.let { data.categoryById[it] }
val categoryForOtherSystemApps = data.findCategoryAppTryDeviceSpecificFirst(
packageName = DummyApps.NOT_ASSIGNED_SYSTEM_IMAGE_APP,
activityName = null,
deviceId = deviceId
)?.categoryId?.let { data.categoryById[it] }
val globalCategoryApps = data.categoryApps.filter { it.appSpecifier.deviceId == null }
val localCategoryApps = data.categoryApps.filter { it.appSpecifier.deviceId == deviceId }
val localCategoryAppsParams = localCategoryApps.map { it.appSpecifier.packageName to it.appSpecifier.activityName }.toSet()
val effectiveGlobalCategoryApps = globalCategoryApps.filter {
!localCategoryAppsParams.contains(it.appSpecifier.packageName to it.appSpecifier.activityName) &&
!localCategoryAppsParams.contains(it.appSpecifier.packageName to null)
}
val effectiveCategoryApps = effectiveGlobalCategoryApps + localCategoryApps
if (blockingAtActivityLevel) {
val categoriesByPackageName = data.categoryApps.groupBy { it.packageNameWithoutActivityName }
val categoriesByPackageName = effectiveCategoryApps.groupBy { it.appSpecifier.packageName }
val result = mutableMapOf<String, Set<String>>()
packageNames.forEach { packageName ->
val categoriesItems = categoriesByPackageName[packageName]
val categories = (categoriesItems?.map { it.categoryId }?.toSet() ?: emptySet()).toMutableSet()
val isMainAppIncluded = categoriesItems?.find { !it.specifiesActivity } != null
val isMainAppIncluded = categoriesItems?.find { it.appSpecifier.activityName == null } != null
if (!isMainAppIncluded) {
if (categoryForOtherSystemApps != null && appLogic.platformIntegration.isSystemImageApp(packageName)) {
@ -196,7 +209,9 @@ class SuspendAppsLogic(private val appLogic: AppLogic): Observer {
return result
} else {
val categoryByPackageName = data.categoryApps.associateBy { it.packageName }
val categoryByPackageName = effectiveCategoryApps
.filter { it.appSpecifier.activityName == null }
.associateBy { it.appSpecifier.packageName }
val result = mutableMapOf<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
* it under the terms of the GNU General Public License as published by
@ -73,15 +73,31 @@ sealed class AppBaseHandling {
} else if (foregroundAppPackageName != null) {
val appCategory = run {
val tryActivityLevelBlocking = deviceRelatedData.deviceEntry.enableActivityLevelBlocking && foregroundAppActivityName != null
val appLevelCategory = userRelatedData.findCategoryApp(foregroundAppPackageName) ?: run {
if (isSystemImageApp) userRelatedData.findCategoryApp(DummyApps.NOT_ASSIGNED_SYSTEM_IMAGE_APP) else null
val appLevelCategory = userRelatedData.findCategoryAppTryDeviceSpecificFirst(
packageName = foregroundAppPackageName,
activityName = null,
deviceId = deviceRelatedData.deviceEntry.id
) ?: run {
if (isSystemImageApp) userRelatedData.findCategoryAppTryDeviceSpecificFirst(
packageName = DummyApps.NOT_ASSIGNED_SYSTEM_IMAGE_APP,
activityName = null,
deviceId = deviceRelatedData.deviceEntry.id
) else null
}
(if (tryActivityLevelBlocking) {
userRelatedData.findCategoryApp("$foregroundAppPackageName:$foregroundAppActivityName")
} else {
null
}) ?: appLevelCategory
if (tryActivityLevelBlocking) {
val activityLevelCategory = userRelatedData.findCategoryAppTryDeviceSpecificFirst(
packageName = foregroundAppPackageName,
activityName = foregroundAppActivityName,
deviceId = deviceRelatedData.deviceEntry.id
)
val appLevelCategoryMoreImportant = appLevelCategory != null && activityLevelCategory != null &&
appLevelCategory.appSpecifier.deviceId != null &&
activityLevelCategory.appSpecifier.deviceId == null
if (activityLevelCategory == null || appLevelCategoryMoreImportant) appLevelCategory else activityLevelCategory
} else appLevelCategory
}
val startCategory = userRelatedData.categoryById[appCategory?.categoryId]
@ -95,8 +111,7 @@ sealed class AppBaseHandling {
return UseCategories(
categoryIds = categoryIds,
shouldCount = !pauseCounting,
level = when (appCategory?.specifiesActivity) {
null -> BlockingLevel.Activity // occurs when using a default category
level = when (appCategory == null || appCategory.appSpecifier.activityName != null) {
true -> BlockingLevel.Activity
false -> BlockingLevel.App
},

View file

@ -458,7 +458,7 @@ object ApplyServerDataStatus {
database.categoryApp().addCategoryAppsSync(item.assignedApps.map {
CategoryApp(
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
* it under the terms of the GNU General Public License as published by
@ -53,6 +53,10 @@ object LocalDatabaseParentActionDispatcher {
val allCategoriesOfChild = database.category().getCategoriesByChildIdSync(categoryEntry.childId)
if (fromChildSelfLimitAddChildUserId != null) {
if (action.packageNames.find { it.contains('@') } != null) {
throw RuntimeException("can not do device specific assignments as child")
}
val parentCategoriesOfTargetCategory = allCategoriesOfChild.getCategoryWithParentCategories(action.categoryId)
val userEntry = database.user().getUserByIdSync(fromChildSelfLimitAddChildUserId) ?: throw RuntimeException("user not found")
val validatedDefaultCategoryId = (allCategoriesOfChild.find {
@ -99,7 +103,7 @@ object LocalDatabaseParentActionDispatcher {
action.packageNames.map {
CategoryApp(
categoryId = action.categoryId,
packageName = it
appSpecifierString = it
)
}
)

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

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
* it under the terms of the GNU General Public License as published by
@ -15,7 +15,6 @@
*/
package io.timelimit.android.ui.manage.category.apps.add
import android.app.Dialog
import android.os.Bundle
import android.view.LayoutInflater
@ -27,24 +26,18 @@ import androidx.appcompat.app.AlertDialog
import androidx.coordinatorlayout.widget.CoordinatorLayout
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.appbar.AppBarLayout
import io.timelimit.android.R
import io.timelimit.android.data.Database
import io.timelimit.android.data.extensions.getCategoryWithParentCategories
import io.timelimit.android.data.model.App
import io.timelimit.android.data.model.UserType
import io.timelimit.android.databinding.FragmentAddCategoryAppsBinding
import io.timelimit.android.extensions.showSafe
import io.timelimit.android.livedata.*
import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.logic.DummyApps
import io.timelimit.android.sync.actions.AddCategoryAppsAction
import io.timelimit.android.ui.main.ActivityViewModel
import io.timelimit.android.ui.main.getActivityViewModel
import io.timelimit.android.ui.manage.category.apps.AddAppsOrActivitiesModel
import io.timelimit.android.ui.manage.category.apps.addactivity.AddActivitiesParams
import io.timelimit.android.ui.manage.category.apps.addactivity.AddAppActivitiesDialogFragment
import io.timelimit.android.ui.view.AppFilterView
@ -53,23 +46,18 @@ class AddCategoryAppsFragment : DialogFragment() {
private const val DIALOG_TAG = "x"
private const val STATUS_PACKAGE_NAMES = "d"
private const val STATUS_EDUCATED = "e"
private const val PARAM_CHILD_ID = "childId"
private const val PARAM_CATEGORY_ID = "categoryId"
private const val PARAM_CHILD_ADD_LIMIT_MODE = "addLimitMode"
private const val PARAMS = "params"
fun newInstance(childId: String, categoryId: String, childAddLimitMode: Boolean) = AddCategoryAppsFragment().apply {
arguments = Bundle().apply {
putString(PARAM_CHILD_ID, childId)
putString(PARAM_CATEGORY_ID, categoryId)
putBoolean(PARAM_CHILD_ADD_LIMIT_MODE, childAddLimitMode)
}
fun newInstance(params: AddAppsParams) = AddCategoryAppsFragment().apply {
arguments = Bundle().apply { putParcelable(PARAMS, params) }
}
}
private val database: Database by lazy { DefaultAppLogic.with(requireContext()).database }
private val auth: ActivityViewModel by lazy { getActivityViewModel(requireActivity()) }
private val adapter = AddAppAdapter()
private var didEducateAboutAddingAssignedApps = false
private val baseModel: AddAppsOrActivitiesModel by viewModels()
private val model: AddAppsModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -89,167 +77,43 @@ class AddCategoryAppsFragment : DialogFragment() {
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val binding = FragmentAddCategoryAppsBinding.inflate(LayoutInflater.from(context))
val childId = requireArguments().getString(PARAM_CHILD_ID)!!
val categoryId = requireArguments().getString(PARAM_CATEGORY_ID)!!
val childAddLimitMode = requireArguments().getBoolean(PARAM_CHILD_ADD_LIMIT_MODE)
val params = requireArguments().getParcelable<AddAppsParams>(PARAMS)!!
auth.authenticatedUserOrChild.observe(this, Observer {
val parentAuthValid = it?.second?.type == UserType.Parent
val childAuthValid = it?.second?.id == childId && childAddLimitMode
val authValid = parentAuthValid || childAuthValid
baseModel.init(params)
baseModel.isAuthValid(auth).observe(this) { if (!it) dismissAllowingStateLoss() }
if (!authValid) {
dismissAllowingStateLoss()
}
})
model.init(params)
val filter = AppFilterView.getFilterLive(binding.filter)
val isLocalMode = database.config().getDeviceAuthTokenAsync().map { it.isEmpty() }
val showAppsFromOtherDevicesChecked = MutableLiveData<Boolean>().apply {
value = binding.showAppsFromUnassignedDevices.isChecked
}
val realShowAppsFromAllDevices = isLocalMode.switchMap { localMode ->
if (localMode) {
liveDataFromNonNullValue(true)
} else {
showAppsFromOtherDevicesChecked
}
}
model.showAppsFromOtherDevicesChecked.value = binding.showAppsFromUnassignedDevices.isChecked
model.showAppsFromOtherCategories.value = binding.showOtherCategoriesApps.isChecked
model.assignToThisDeviceOnly.value = binding.assignToThisDeviceOnly.isChecked
binding.showAppsFromUnassignedDevices.setOnCheckedChangeListener { _, isChecked ->
showAppsFromOtherDevicesChecked.value = isChecked
model.showAppsFromOtherDevicesChecked.value = isChecked
}
isLocalMode.observe(this, Observer {
binding.showAppsFromUnassignedDevices.visibility = if (it) View.GONE else View.VISIBLE
})
binding.assignToThisDeviceOnly.setOnCheckedChangeListener { _, isChecked ->
model.assignToThisDeviceOnly.value = isChecked
}
val showAppsFromOtherCategories = MutableLiveData<Boolean>().apply { value = binding.showOtherCategoriesApps.isChecked }
binding.showOtherCategoriesApps.setOnCheckedChangeListener { _, isChecked -> showAppsFromOtherCategories.value = isChecked }
AppFilterView.getFilterLive(binding.filter).observe(this) { model.filter.value = it }
model.isLocalMode.observe(this) {
binding.showAppsFromUnassignedDevices.visibility = if (it) View.GONE else View.VISIBLE
}
model.showDeviceSpecificAssignmentOption.observe(this) {
binding.assignToThisDeviceOnly.visibility = if (it) View.VISIBLE else View.GONE
}
binding.showOtherCategoriesApps.setOnCheckedChangeListener { _, isChecked ->
model.showAppsFromOtherCategories.value = isChecked
}
binding.recycler.layoutManager = LinearLayoutManager(context)
binding.recycler.adapter = adapter
val childDeviceIds = database.device().getDevicesByUserId(childId)
.map { devices -> devices.map { it.id } }
.ignoreUnchanged()
val globalChildDeviceCounter = database.device().countDevicesWithChildUser()
val hasChildDeviceIds = childDeviceIds.map { it.isNotEmpty() }
val appsAtAssignedDevices = childDeviceIds
.switchMap { database.app().getAppsByDeviceIds(it) }
val appsAtAllDevices = database.app().getAllApps()
val installedApps = realShowAppsFromAllDevices.switchMap { appsFromAllDevices ->
if (appsFromAllDevices) appsAtAllDevices else appsAtAssignedDevices
}.map { list ->
if (list.isEmpty()) list else list + DummyApps.getApps(deviceId = list.first().deviceId, context = requireContext())
}.map { apps -> apps.distinctBy { app -> app.packageName } }
val userRelatedDataLive = database.derivedDataDao().getUserRelatedDataLive(childId)
val categoryTitleByPackageName = userRelatedDataLive.map { userRelatedData ->
val result = mutableMapOf<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 {
model.listItems.observe(this, Observer {
val selectedPackageNames = adapter.selectedApps
val visiblePackageNames = it.map { it.packageName }.toSet()
val hiddenSelectedPackageNames = selectedPackageNames.toMutableSet().apply { removeAll(visiblePackageNames) }.size
@ -261,45 +125,57 @@ class AddCategoryAppsFragment : DialogFragment() {
resources.getQuantityString(R.plurals.category_apps_add_dialog_hidden_entries, hiddenSelectedPackageNames, hiddenSelectedPackageNames)
})
emptyViewText.observe(this, Observer { binding.emptyText = it })
model.emptyViewText.observe(this) {
binding.emptyText = when (it!!) {
AddAppsModel.EmptyViewText.None -> null
AddAppsModel.EmptyViewText.EmptyDueToFilter -> getString(R.string.category_apps_add_empty_due_to_filter)
AddAppsModel.EmptyViewText.EmptyDueToChildMode -> getString(R.string.category_apps_add_empty_due_to_child_mode)
AddAppsModel.EmptyViewText.EmptyLocalMode -> getString(R.string.category_apps_add_empty_local_mode)
AddAppsModel.EmptyViewText.EmptyAllDevicesNoAppsNoChildDevices -> getString(R.string.category_apps_add_empty_all_devices_no_apps_no_childs)
AddAppsModel.EmptyViewText.EmptyAllDevicesNoAppsButChildDevices -> getString(R.string.category_apps_add_empty_all_devices_no_apps_but_child_devices)
AddAppsModel.EmptyViewText.EmptyChildDevicesHaveNoApps -> getString(R.string.category_apps_add_empty_child_devices_no_apps)
AddAppsModel.EmptyViewText.EmptyNoChildDevices -> getString(R.string.category_apps_add_empty_no_child_devices)
}
}
categoryTitleByPackageName.observe(this, Observer {
adapter.categoryTitleByPackageName = it
})
binding.someOptionsDisabledDueToChildAuthentication = params.isSelfLimitAddingMode
binding.someOptionsDisabledDueToChildAuthentication = childAddLimitMode
model.deviceIdLive.observe(this) {/* keep loaded */}
binding.addAppsButton.setOnClickListener {
val packageNames = adapter.selectedApps.toList()
if (packageNames.isNotEmpty()) {
val deviceSpecific = binding.assignToThisDeviceOnly.isChecked && !params.isSelfLimitAddingMode
val deviceId = model.deviceIdLive.value
if (deviceSpecific && deviceId == null) return@setOnClickListener
auth.tryDispatchParentAction(
action = AddCategoryAppsAction(
categoryId = categoryId,
packageNames = packageNames
categoryId = params.categoryId,
packageNames = if (deviceSpecific) packageNames.map { "$it@$deviceId" } else packageNames
),
allowAsChild = childAddLimitMode
allowAsChild = params.isSelfLimitAddingMode
)
}
dismiss()
}
binding.cancelButton.setOnClickListener {
dismiss()
}
binding.cancelButton.setOnClickListener { dismiss() }
binding.selectAllButton.setOnClickListener {
adapter.selectedApps = adapter.selectedApps + (adapter.data?.map { it.packageName }?.toSet() ?: emptySet())
}
adapter.listener = object: AddAppAdapterListener {
override fun onAppClicked(app: App) {
override fun onAppClicked(app: AddAppListItem) {
if (adapter.selectedApps.contains(app.packageName)) {
adapter.selectedApps = adapter.selectedApps - setOf(app.packageName)
} else {
if (!didEducateAboutAddingAssignedApps) {
if (adapter.categoryTitleByPackageName[app.packageName] != null) {
if (app.currentCategoryName != null) {
didEducateAboutAddingAssignedApps = true
AddAlreadyAssignedAppsInfoDialog().show(fragmentManager!!)
@ -310,14 +186,12 @@ class AddCategoryAppsFragment : DialogFragment() {
}
}
override fun onAppLongClicked(app: App): Boolean {
override fun onAppLongClicked(app: AddAppListItem): Boolean {
return if (adapter.selectedApps.isEmpty()) {
AddAppActivitiesDialogFragment.newInstance(
childId = childId,
categoryId = categoryId,
packageName = app.packageName,
childAddLimitMode = childAddLimitMode
).show(parentFragmentManager)
AddAppActivitiesDialogFragment.newInstance(AddActivitiesParams(
base = params,
packageName = app.packageName
)).show(parentFragmentManager)
dismissAllowingStateLoss()

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
* it under the terms of the GNU General Public License as published by
@ -21,173 +21,100 @@ import android.view.LayoutInflater
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.Observer
import androidx.fragment.app.viewModels
import androidx.recyclerview.widget.LinearLayoutManager
import io.timelimit.android.R
import io.timelimit.android.data.extensions.getCategoryWithParentCategories
import io.timelimit.android.data.model.UserType
import io.timelimit.android.databinding.FragmentAddCategoryActivitiesBinding
import io.timelimit.android.extensions.addOnTextChangedListener
import io.timelimit.android.extensions.showSafe
import io.timelimit.android.livedata.map
import io.timelimit.android.livedata.switchMap
import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.sync.actions.AddCategoryAppsAction
import io.timelimit.android.ui.main.getActivityViewModel
import io.timelimit.android.ui.manage.category.apps.AddAppsOrActivitiesModel
class AddAppActivitiesDialogFragment: DialogFragment() {
companion object {
private const val DIALOG_TAG = "AddAppActivitiesDialogFragment"
private const val CHILD_ID = "childId"
private const val CATEGORY_ID = "categoryId"
private const val PACKAGE_NAME = "packageName"
private const val PARAMS = "params"
private const val SELECTED_ACTIVITIES = "selectedActivities"
private const val CHILD_ADD_LIMIT_MODE = "childAddLimitMode"
fun newInstance(childId: String, categoryId: String, packageName: String, childAddLimitMode: Boolean) = AddAppActivitiesDialogFragment().apply {
arguments = Bundle().apply {
putString(CHILD_ID, childId)
putString(CATEGORY_ID, categoryId)
putString(PACKAGE_NAME, packageName)
putBoolean(CHILD_ADD_LIMIT_MODE, childAddLimitMode)
}
fun newInstance(params: AddActivitiesParams) = AddAppActivitiesDialogFragment().apply {
arguments = Bundle().apply { putParcelable(PARAMS, params) }
}
}
val adapter = AddAppActivityAdapter()
private val adapter = AddAppActivityAdapter()
private val baseModel: AddAppsOrActivitiesModel by viewModels()
private val model: AddActivitiesModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (savedInstanceState != null) {
adapter.selectedActiviities.clear()
savedInstanceState.getStringArray(SELECTED_ACTIVITIES)!!.forEach { adapter.selectedActiviities.add(it) }
adapter.selectedActivities.clear()
savedInstanceState.getStringArray(SELECTED_ACTIVITIES)!!.forEach { adapter.selectedActivities.add(it) }
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putStringArray(SELECTED_ACTIVITIES, adapter.selectedActiviities.toTypedArray())
outState.putStringArray(SELECTED_ACTIVITIES, adapter.selectedActivities.toTypedArray())
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val appPackageName = arguments!!.getString(PACKAGE_NAME)!!
val categoryId = arguments!!.getString(CATEGORY_ID)!!
val childId = arguments!!.getString(CHILD_ID)!!
val childAddLimitMode = arguments!!.getBoolean(CHILD_ADD_LIMIT_MODE)
val auth = getActivityViewModel(activity!!)
val binding = FragmentAddCategoryActivitiesBinding.inflate(LayoutInflater.from(context!!))
val searchTerm = MutableLiveData<String>().apply { value = binding.search.text.toString() }
binding.search.addOnTextChangedListener { searchTerm.value = binding.search.text.toString() }
val params = requireArguments().getParcelable<AddActivitiesParams>(PARAMS)!!
val auth = getActivityViewModel(requireActivity())
val binding = FragmentAddCategoryActivitiesBinding.inflate(LayoutInflater.from(context))
auth.authenticatedUserOrChild.observe(this, Observer {
val parentAuthenticated = it?.second?.type == UserType.Parent
val childAuthenticated = it?.second?.id == childId && childAddLimitMode
val anyoneAuthenticated = parentAuthenticated || childAuthenticated
baseModel.init(params.base)
baseModel.isAuthValid(auth).observe(this) { if (!it) dismissAllowingStateLoss() }
if (!anyoneAuthenticated) {
dismissAllowingStateLoss()
}
})
model.init(params)
model.searchTerm.value = binding.search.text.toString()
binding.search.addOnTextChangedListener { model.searchTerm.value = binding.search.text.toString() }
val logic = DefaultAppLogic.with(context!!)
val allActivitiesLive = logic.database.appActivity().getAppActivitiesByPackageName(appPackageName).map { activities ->
activities.distinctBy { it.activityClassName }
}
val userRelatedDataLive = logic.database.derivedDataDao().getUserRelatedDataLive(childId)
val shownActivities = if (childAddLimitMode) {
userRelatedDataLive.switchMap { userRelatedData ->
allActivitiesLive.map { allActivities ->
if (userRelatedData == null || !userRelatedData.categoryById.containsKey(categoryId))
emptyList()
else {
val parentCategories = userRelatedData.getCategoryWithParentCategories(categoryId)
val defaultCategory = userRelatedData.categoryById[userRelatedData.user.categoryForNotAssignedApps]
val relatedPackageNameToCategoryId = userRelatedData.categoryApps
.filter { it.packageNameWithoutActivityName == appPackageName }
.associateBy { it.packageName }
val baseAppCategoryOrDefaultCategory = userRelatedData.categoryById[relatedPackageNameToCategoryId[appPackageName]?.categoryId] ?: defaultCategory
val baseAppCategoryInParentCategoryOrMatchingUnassigned = parentCategories.contains(baseAppCategoryOrDefaultCategory?.category?.id)
allActivities.filter { activity ->
val activityCategoryItem = userRelatedData.categoryById[relatedPackageNameToCategoryId[activity.appPackageName + ":" + activity.activityClassName]?.categoryId]
val activityItselfInParentCategory = parentCategories.contains(activityCategoryItem?.category?.id)
val activityItselfUnassigned = activityCategoryItem == null
(baseAppCategoryInParentCategoryOrMatchingUnassigned && activityItselfUnassigned) || activityItselfInParentCategory
}
}
}
}
} else allActivitiesLive
val filteredActivities = shownActivities.switchMap { activities ->
searchTerm.map { term ->
if (term.isEmpty()) {
activities
} else {
activities.filter { it.activityClassName.contains(term, ignoreCase = true) or it.title.contains(term, ignoreCase = true) }
}
}
}
binding.recycler.layoutManager = LinearLayoutManager(context!!)
binding.recycler.layoutManager = LinearLayoutManager(requireContext())
binding.recycler.adapter = adapter
filteredActivities.observe(this, Observer { list ->
val selectedActivities = adapter.selectedActiviities
val visibleActivities = list.map { it.activityClassName }
model.filteredActivities.observe(this) { list ->
val selectedActivities = adapter.selectedActivities
val visibleActivities = list.map { it.className }
val hiddenSelectedActivities = selectedActivities.toMutableSet().apply { removeAll(visibleActivities) }.size
adapter.data = list
binding.hiddenEntries = if (hiddenSelectedActivities == 0)
null
else
resources.getQuantityString(R.plurals.category_apps_add_dialog_hidden_entries, hiddenSelectedActivities, hiddenSelectedActivities)
})
}
val emptyViewText = allActivitiesLive.switchMap { all ->
shownActivities.switchMap { shown ->
filteredActivities.map { filtered ->
if (filtered.isNotEmpty())
null
else if (all.isNotEmpty())
if (shown.isEmpty())
getString(R.string.category_apps_add_activity_empty_shown)
else
getString(R.string.category_apps_add_activity_empty_filtered)
else /* (all.isEmpty()) */
getString(R.string.category_apps_add_activity_empty_unfiltered)
}
model.emptyViewText.observe(this) {
binding.emptyViewText = when (it!!) {
AddActivitiesModel.EmptyViewText.None -> null
AddActivitiesModel.EmptyViewText.EmptyShown -> getString(R.string.category_apps_add_activity_empty_shown)
AddActivitiesModel.EmptyViewText.EmptyFiltered -> getString(R.string.category_apps_add_activity_empty_filtered)
AddActivitiesModel.EmptyViewText.EmptyUnfiltered -> getString(R.string.category_apps_add_activity_empty_unfiltered)
}
}
emptyViewText.observe(this, Observer {
binding.emptyViewText = it
})
binding.someOptionsDisabledDueToChildAuthentication = childAddLimitMode
binding.someOptionsDisabledDueToChildAuthentication = params.base.isSelfLimitAddingMode
binding.cancelButton.setOnClickListener { dismissAllowingStateLoss() }
binding.addActivitiesButton.setOnClickListener {
if (adapter.selectedActiviities.isNotEmpty()) {
if (adapter.selectedActivities.isNotEmpty()) {
auth.tryDispatchParentAction(
action = AddCategoryAppsAction(
categoryId = categoryId,
packageNames = adapter.selectedActiviities.toList().map { "$appPackageName:$it" }
categoryId = params.base.categoryId,
packageNames = adapter.selectedActivities.toList().map { "${params.packageName}:$it" }
),
allowAsChild = childAddLimitMode
allowAsChild = params.base.isSelfLimitAddingMode
)
}
dismissAllowingStateLoss()
}
return AlertDialog.Builder(context!!, R.style.AppTheme)
return AlertDialog.Builder(requireContext(), R.style.AppTheme)
.setView(binding.root)
.create()
}

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

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
* it under the terms of the GNU General Public License as published by
@ -13,9 +13,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package io.timelimit.android.ui.manage.category.apps
import io.timelimit.android.ui.manage.category.appsandrules.AppAndRuleItem
package io.timelimit.android.ui.manage.category.appsandrules
interface AppAdapterHandlers {
fun onAppClicked(app: AppAndRuleItem.AppEntry)

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
* it under the terms of the GNU General Public License as published by
@ -27,7 +27,6 @@ import io.timelimit.android.date.DateInTimezone
import io.timelimit.android.extensions.MinuteOfDay
import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.logic.DummyApps
import io.timelimit.android.ui.manage.category.apps.AppAdapterHandlers
import io.timelimit.android.ui.manage.category.timelimit_rules.TimeLimitRulesHandlers
import io.timelimit.android.util.DayNameUtil
import io.timelimit.android.util.TimeTextUtil
@ -58,7 +57,7 @@ class AppAndRuleAdapter: RecyclerView.Adapter<AppAndRuleAdapter.Holder>() {
override fun getItemId(position: Int): Long = items[position].let { item ->
when (item) {
is AppAndRuleItem.AppEntry -> item.packageName.hashCode()
is AppAndRuleItem.AppEntry -> item.specifier.hashCode()
is AppAndRuleItem.RuleEntry -> item.rule.id.hashCode()
else -> item.hashCode()
}
@ -137,16 +136,17 @@ class AppAndRuleAdapter: RecyclerView.Adapter<AppAndRuleAdapter.Holder>() {
val binding = holder.itemView.tag as FragmentCategoryAppsItemBinding
val context = binding.root.context
binding.item = item
binding.handlers = handlers
binding.title = item.title
binding.deviceName = item.deviceName
binding.subtitle = item.specifier.copy(deviceId = null).encode()
binding.card.setOnClickListener { handlers?.onAppClicked(item) }
binding.card.setOnLongClickListener { handlers?.onAppLongClicked(item) ?: false }
binding.executePendingBindings()
binding.root.setOnLongClickListener { handlers?.onAppLongClicked(item) ?: false }
binding.icon.setImageDrawable(
DummyApps.getIcon(item.packageNameWithoutActivityName, context) ?:
DummyApps.getIcon(item.specifier.packageName, context) ?:
DefaultAppLogic.with(context)
.platformIntegration.getAppIcon(item.packageNameWithoutActivityName)
.platformIntegration.getAppIcon(item.specifier.packageName)
)
}
AppAndRuleItem.AddAppItem -> {/* nothing to do */}

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
* it under the terms of the GNU General Public License as published by
@ -17,9 +17,10 @@
package io.timelimit.android.ui.manage.category.appsandrules
import io.timelimit.android.data.model.TimeLimitRule
import io.timelimit.android.data.model.derived.AppSpecifier
sealed class AppAndRuleItem {
data class AppEntry(val title: String, val packageName: String, val packageNameWithoutActivityName: String): AppAndRuleItem()
data class AppEntry(val title: String, val deviceName: String?, val specifier: AppSpecifier): AppAndRuleItem()
object AddAppItem: AppAndRuleItem()
object ExpandAppsItem: AppAndRuleItem()
data class RuleEntry(val rule: TimeLimitRule): AppAndRuleItem()

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
* it under the terms of the GNU General Public License as published by
@ -24,6 +24,7 @@ import io.timelimit.android.data.extensions.getDateLive
import io.timelimit.android.data.model.HintsToShow
import io.timelimit.android.extensions.takeDistributedElements
import io.timelimit.android.livedata.map
import io.timelimit.android.livedata.mergeLiveDataWaitForValues
import io.timelimit.android.livedata.switchMap
import io.timelimit.android.logic.DefaultAppLogic
import io.timelimit.android.logic.DummyApps
@ -108,27 +109,30 @@ class AppsAndRulesModel(application: Application): AndroidViewModel(application)
private val installedApps = database.app().getAllApps()
private val installedAppsIndexed = installedApps.map { apps -> apps.associateBy { it.packageName } }
private val deviceNamesIndexedLive = database.device().getDeviceNamesLive().map { items -> items.associateBy { it.id } }
private val appsOfThisCategory = categoryIdLive.switchMap { categoryId -> database.categoryApp().getCategoryApps(categoryId) }
private val appsOfCategoryWithNames = installedApps.switchMap { allApps ->
appsOfThisCategory.map { apps ->
apps.map { categoryApp ->
val title = DummyApps.getTitle(categoryApp.packageNameWithoutActivityName, getApplication()) ?:
allApps.find { app -> app.packageName == categoryApp.packageNameWithoutActivityName }?.title
private val appsOfCategoryWithNames = mergeLiveDataWaitForValues(installedAppsIndexed, appsOfThisCategory, deviceNamesIndexedLive)
.map { (allAppsIndexed, appsOfThisCategory, deviceNamesIndexed) ->
appsOfThisCategory.map { categoryApp ->
val title = DummyApps.getTitle(categoryApp.appSpecifier.packageName, getApplication()) ?:
allAppsIndexed[categoryApp.appSpecifier.packageName]?.title
categoryApp to title
AppAndRuleItem.AppEntry(
title = title ?: "app not found",
specifier = categoryApp.appSpecifier,
deviceName = categoryApp.appSpecifier.deviceId?.let { deviceId ->
deviceNamesIndexed[deviceId]?.name ?: "removed device"
}
)
}
}
}
private val appEntries = appsOfCategoryWithNames.map { apps ->
apps.map { (app, title) ->
if (title != null) {
AppAndRuleItem.AppEntry(title, app.packageName, app.packageNameWithoutActivityName)
} else {
AppAndRuleItem.AppEntry("app not found", app.packageName, app.packageNameWithoutActivityName)
}
}.sortedBy { it.title.toLowerCase(Locale.US) }
apps.sortedBy { it.title.lowercase() }
}
private val fullAppScreenContent = showAllAppsLive.switchMap { showAllApps ->

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
* it under the terms of the GNU General Public License as published by
@ -39,6 +39,7 @@ import io.timelimit.android.sync.actions.RemoveCategoryAppsAction
import io.timelimit.android.sync.actions.UpdateTimeLimitRuleAction
import io.timelimit.android.ui.main.ActivityViewModel
import io.timelimit.android.ui.main.getActivityViewModel
import io.timelimit.android.ui.manage.category.apps.add.AddAppsParams
import io.timelimit.android.ui.manage.category.apps.add.AddCategoryAppsFragment
import io.timelimit.android.ui.manage.category.timelimit_rules.edit.EditTimeLimitRuleDialogFragment
import io.timelimit.android.ui.manage.category.timelimit_rules.edit.EditTimeLimitRuleDialogFragmentListener
@ -135,7 +136,7 @@ abstract class CategoryAppsAndRulesFragment: Fragment(), Handlers, EditTimeLimit
if (auth.tryDispatchParentAction(
RemoveCategoryAppsAction(
categoryId = categoryId,
packageNames = listOf(app.packageName)
packageNames = listOf(app.specifier.encode())
)
)) {
Snackbar.make(requireView(), getString(R.string.category_apps_item_removed_toast, app.title), Snackbar.LENGTH_SHORT)
@ -143,7 +144,7 @@ abstract class CategoryAppsAndRulesFragment: Fragment(), Handlers, EditTimeLimit
auth.tryDispatchParentAction(
AddCategoryAppsAction(
categoryId = categoryId,
packageNames = listOf(app.packageName)
packageNames = listOf(app.specifier.encode())
)
)
}
@ -155,7 +156,7 @@ abstract class CategoryAppsAndRulesFragment: Fragment(), Handlers, EditTimeLimit
return if (auth.requestAuthenticationOrReturnTrue()) {
AssignAppCategoryDialogFragment.newInstance(
childId = childId,
appPackageName = app.packageName
appPackageName = app.specifier.encode()
).show(parentFragmentManager)
true
@ -164,11 +165,11 @@ abstract class CategoryAppsAndRulesFragment: Fragment(), Handlers, EditTimeLimit
override fun onAddAppsClicked() {
if (auth.requestAuthenticationOrReturnTrueAllowChild(childId = childId)) {
AddCategoryAppsFragment.newInstance(
childId = childId,
categoryId = categoryId,
childAddLimitMode = !auth.isParentAuthenticated()
).show(parentFragmentManager)
AddCategoryAppsFragment.newInstance(AddAppsParams(
childId = childId,
categoryId = categoryId,
isSelfLimitAddingMode = !auth.isParentAuthenticated()
)).show(parentFragmentManager)
}
}

View file

@ -124,7 +124,7 @@ object DuplicateChildActions {
result.add(AddCategoryAppsAction(
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
* it under the terms of the GNU General Public License as published by
@ -64,13 +64,17 @@ class ChildAppsModel(application: Application): AndroidViewModel(application) {
val listContentLive = childAppsLive.switchMap { childApps ->
childCategoriesLive.switchMap { categories ->
childCategoryAppsLive.switchMap { categoryApps ->
// only show items that are not device specific
val categoryAppByPackageName = categoryApps
.filter { it.appSpecifier.deviceId == null }
.associateBy { it.appSpecifier.packageName }
appFilterLive.ignoreUnchanged().switchMap { appFilter ->
val filteredChildApps = childApps.filter { appFilter.matches(it) }
modeLive.ignoreUnchanged().map { mode ->
when (mode!!) {
ChildAppsMode.SortByCategory -> {
val categoryAppByPackageName = categoryApps.associateBy { it.packageName }
val appsByCategoryId = filteredChildApps.groupBy { app ->
categoryAppByPackageName[app.packageName]?.categoryId
}
@ -117,7 +121,6 @@ class ChildAppsModel(application: Application): AndroidViewModel(application) {
}
ChildAppsMode.SortByTitle -> {
val categoryById = categories.associateBy { it.id }
val categoryAppByPackageName = categoryApps.associateBy { it.packageName }
filteredChildApps
.distinctBy { it.packageName }

View file

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

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
* it under the terms of the GNU General Public License as published by
@ -132,7 +132,8 @@ class SetupDeviceModel(application: Application): AndroidViewModel(application)
val alreadyAssignedApps = Threads.database.executeAndWait {
logic.database.categoryApp().getCategoryAppsByUserIdSync(realUserId)
.map { it.packageName }
.filter { it.appSpecifier.deviceId == null }
.map { it.appSpecifier.packageName }
.toSet()
}

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
it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License.
@ -71,6 +71,13 @@
android:layout_width="match_parent"
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>
</com.google.android.material.appbar.AppBarLayout>

View file

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

View file

@ -1,6 +1,6 @@
<?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
it under the terms of the GNU General Public License as published by
the Free Software Foundation version 3 of the License.
@ -18,18 +18,23 @@
xmlns:tools="http://schemas.android.com/tools">
<data>
<variable
name="item"
type="io.timelimit.android.ui.manage.category.appsandrules.AppAndRuleItem.AppEntry" />
name="title"
type="String" />
<variable
name="handlers"
type="io.timelimit.android.ui.manage.category.apps.AppAdapterHandlers" />
name="deviceName"
type="String" />
<variable
name="subtitle"
type="String" />
<import type="android.text.TextUtils" />
<import type="android.view.View" />
</data>
<androidx.cardview.widget.CardView
android:onClick="@{() -> handlers.onAppClicked(item)}"
android:id="@+id/card"
android:foreground="?selectableItemBackground"
app:cardUseCompatPadding="true"
android:layout_width="match_parent"
@ -56,14 +61,23 @@
<TextView
android:textAppearance="?android:textAppearanceLarge"
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_height="wrap_content" />
<TextView
android:textAppearance="?android:textAppearanceSmall"
tools:text="com.android.settings"
android:text="@{item.packageName}"
android:text="@{subtitle}"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</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_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_assign_current_device_only">Nur für dieses Gerät zuweisen</string>
<plurals name="category_apps_add_dialog_hidden_entries">
<item quantity="one">%d ausgeblendeten Eintrag 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_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_assign_current_device_only">Assign for this device only</string>
<string name="category_apps_add_dialog_select_all">Select all</string>
<plurals name="category_apps_add_dialog_hidden_entries">
<item quantity="one">%d hidden entry selected</item>