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