1 /* 2 * Copyright (C) 2022 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 17 package com.android.settingslib.spaprivileged.template.app 18 19 import android.content.Intent 20 import android.content.IntentFilter 21 import android.os.UserHandle 22 import androidx.compose.foundation.layout.Column 23 import androidx.compose.foundation.layout.PaddingValues 24 import androidx.compose.foundation.layout.fillMaxSize 25 import androidx.compose.foundation.lazy.LazyColumn 26 import androidx.compose.runtime.Composable 27 import androidx.compose.runtime.LaunchedEffect 28 import androidx.compose.runtime.State 29 import androidx.compose.runtime.collectAsState 30 import androidx.compose.runtime.remember 31 import androidx.compose.ui.Modifier 32 import androidx.compose.ui.res.stringResource 33 import androidx.compose.ui.unit.Dp 34 import androidx.lifecycle.viewmodel.compose.viewModel 35 import com.android.settingslib.spa.framework.compose.LifecycleEffect 36 import com.android.settingslib.spa.framework.compose.LogCompositions 37 import com.android.settingslib.spa.framework.compose.TimeMeasurer.Companion.rememberTimeMeasurer 38 import com.android.settingslib.spa.framework.compose.rememberLazyListStateAndHideKeyboardWhenStartScroll 39 import com.android.settingslib.spa.framework.compose.toState 40 import com.android.settingslib.spa.widget.ui.CategoryTitle 41 import com.android.settingslib.spa.widget.ui.PlaceholderTitle 42 import com.android.settingslib.spa.widget.ui.Spinner 43 import com.android.settingslib.spa.widget.ui.SpinnerOption 44 import com.android.settingslib.spaprivileged.R 45 import com.android.settingslib.spaprivileged.framework.compose.DisposableBroadcastReceiverAsUser 46 import com.android.settingslib.spaprivileged.model.app.AppEntry 47 import com.android.settingslib.spaprivileged.model.app.AppListData 48 import com.android.settingslib.spaprivileged.model.app.AppListModel 49 import com.android.settingslib.spaprivileged.model.app.AppListViewModel 50 import com.android.settingslib.spaprivileged.model.app.AppRecord 51 import com.android.settingslib.spaprivileged.model.app.IAppListViewModel 52 import com.android.settingslib.spaprivileged.model.app.userId 53 import kotlinx.coroutines.Dispatchers 54 import kotlinx.coroutines.flow.MutableStateFlow 55 56 private const val TAG = "AppList" 57 private const val CONTENT_TYPE_HEADER = "header" 58 59 /** 60 * The config used to load the App List. 61 */ 62 data class AppListConfig( 63 val userIds: List<Int>, 64 val showInstantApps: Boolean, 65 val matchAnyUserForAdmin: Boolean, 66 ) 67 68 data class AppListState( 69 val showSystem: State<Boolean>, 70 val searchQuery: State<String>, 71 ) 72 73 data class AppListInput<T : AppRecord>( 74 val config: AppListConfig, 75 val listModel: AppListModel<T>, 76 val state: AppListState, 77 val header: @Composable () -> Unit, 78 val noItemMessage: String? = null, 79 val bottomPadding: Dp, 80 ) 81 82 /** 83 * The template to render an App List. 84 * 85 * This UI element will take the remaining space on the screen to show the App List. 86 */ 87 @Composable 88 fun <T : AppRecord> AppListInput<T>.AppList() { 89 AppListImpl { rememberViewModel(config, listModel, state) } 90 } 91 92 @Composable 93 internal fun <T : AppRecord> AppListInput<T>.AppListImpl( 94 viewModelSupplier: @Composable () -> IAppListViewModel<T>, 95 ) { 96 LogCompositions(TAG, config.userIds.toString()) 97 val viewModel = viewModelSupplier() 98 Column(Modifier.fillMaxSize()) { 99 val optionsState = viewModel.spinnerOptionsFlow.collectAsState(null, Dispatchers.IO) 100 SpinnerOptions(optionsState, viewModel.optionFlow) 101 val appListData = viewModel.appListDataFlow.collectAsState(null, Dispatchers.IO) 102 listModel.AppListWidget(appListData, header, bottomPadding, noItemMessage) 103 } 104 } 105 106 @Composable 107 private fun SpinnerOptions( 108 optionsState: State<List<SpinnerOption>?>, 109 optionFlow: MutableStateFlow<Int?>, 110 ) { 111 val options = optionsState.value 112 LaunchedEffect(options) { 113 if (options != null && !options.any { it.id == optionFlow.value }) { 114 // Reset to first option if the available options changed, and the current selected one 115 // does not in the new options. 116 optionFlow.value = options.let { it.firstOrNull()?.id ?: -1 } 117 } 118 } 119 if (options != null) { 120 Spinner(options, optionFlow.collectAsState().value) { optionFlow.value = it } 121 } 122 } 123 124 @Composable 125 private fun <T : AppRecord> AppListModel<T>.AppListWidget( 126 appListData: State<AppListData<T>?>, 127 header: @Composable () -> Unit, 128 bottomPadding: Dp, 129 noItemMessage: String? 130 ) { 131 val timeMeasurer = rememberTimeMeasurer(TAG) 132 appListData.value?.let { (list, option) -> 133 timeMeasurer.logFirst("app list first loaded") 134 if (list.isEmpty()) { 135 header() 136 PlaceholderTitle(noItemMessage ?: stringResource(R.string.no_applications)) 137 return 138 } 139 LazyColumn( 140 modifier = Modifier.fillMaxSize(), 141 state = rememberLazyListStateAndHideKeyboardWhenStartScroll(), 142 contentPadding = PaddingValues(bottom = bottomPadding), 143 ) { 144 item(contentType = CONTENT_TYPE_HEADER) { 145 header() 146 } 147 148 items(count = list.size, key = { list[it].record.itemKey(option) }) { 149 remember(list) { getGroupTitleIfFirst(option, list, it) } 150 ?.let { group -> CategoryTitle(title = group) } 151 152 val appEntry = list[it] 153 val summary = getSummary(option, appEntry.record) ?: "".toState() 154 remember(appEntry) { 155 AppListItemModel(appEntry.record, appEntry.label, summary) 156 }.AppItem() 157 } 158 } 159 } 160 } 161 162 private fun <T : AppRecord> T.itemKey(option: Int) = 163 listOf(option, app.packageName, app.userId) 164 165 /** Returns group title if this is the first item of the group. */ 166 private fun <T : AppRecord> AppListModel<T>.getGroupTitleIfFirst( 167 option: Int, 168 list: List<AppEntry<T>>, 169 index: Int, 170 ): String? = getGroupTitle(option, list[index].record)?.takeIf { 171 index == 0 || it != getGroupTitle(option, list[index - 1].record) 172 } 173 174 @Composable 175 private fun <T : AppRecord> rememberViewModel( 176 config: AppListConfig, 177 listModel: AppListModel<T>, 178 state: AppListState, 179 ): AppListViewModel<T> { 180 val viewModel: AppListViewModel<T> = viewModel(key = config.userIds.toString()) 181 viewModel.appListConfig.setIfAbsent(config) 182 viewModel.listModel.setIfAbsent(listModel) 183 viewModel.showSystem.Sync(state.showSystem) 184 viewModel.searchQuery.Sync(state.searchQuery) 185 186 LifecycleEffect(onStart = { viewModel.reloadApps() }) 187 val intentFilter = IntentFilter(Intent.ACTION_PACKAGE_ADDED).apply { 188 addAction(Intent.ACTION_PACKAGE_REMOVED) 189 addAction(Intent.ACTION_PACKAGE_CHANGED) 190 addDataScheme("package") 191 } 192 for (userId in config.userIds) { 193 DisposableBroadcastReceiverAsUser( 194 intentFilter = intentFilter, 195 userHandle = UserHandle.of(userId), 196 ) { viewModel.reloadApps() } 197 } 198 return viewModel 199 } 200