diff --git a/src/com/android/settings/applications/manageapplications/ManageApplicationsUtil.kt b/src/com/android/settings/applications/manageapplications/ManageApplicationsUtil.kt index 8313686f29f..216ce471a39 100644 --- a/src/com/android/settings/applications/manageapplications/ManageApplicationsUtil.kt +++ b/src/com/android/settings/applications/manageapplications/ManageApplicationsUtil.kt @@ -68,6 +68,7 @@ import com.android.settings.spa.app.specialaccess.MediaManagementAppsAppListProv import com.android.settings.spa.app.specialaccess.ModifySystemSettingsAppListProvider import com.android.settings.spa.app.specialaccess.NfcTagAppsSettingsProvider import com.android.settings.spa.app.specialaccess.WifiControlAppListProvider +import com.android.settings.spa.app.storage.StorageAppListPageProvider import com.android.settings.spa.notification.AppListNotificationsPageProvider import com.android.settings.spa.system.AppLanguagesPageProvider @@ -119,6 +120,9 @@ object ManageApplicationsUtil { LIST_TYPE_MAIN -> AllAppListPageProvider.name LIST_TYPE_NFC_TAG_APPS -> NfcTagAppsSettingsProvider.getAppListRoute() LIST_TYPE_USER_ASPECT_RATIO_APPS -> UserAspectRatioAppsPageProvider.name + // TODO(b/292165031) enable once sorting is supported + //LIST_TYPE_STORAGE -> StorageAppListPageProvider.Apps.name + //LIST_TYPE_GAMES -> StorageAppListPageProvider.Games.name else -> null } } diff --git a/src/com/android/settings/spa/SettingsSpaEnvironment.kt b/src/com/android/settings/spa/SettingsSpaEnvironment.kt index 7d90f8a4720..f08a2de4a53 100644 --- a/src/com/android/settings/spa/SettingsSpaEnvironment.kt +++ b/src/com/android/settings/spa/SettingsSpaEnvironment.kt @@ -36,6 +36,7 @@ import com.android.settings.spa.app.specialaccess.PictureInPictureListProvider import com.android.settings.spa.app.specialaccess.SpecialAppAccessPageProvider import com.android.settings.spa.app.specialaccess.WifiControlAppListProvider import com.android.settings.spa.app.specialaccess.UseFullScreenIntentAppListProvider +import com.android.settings.spa.app.storage.StorageAppListPageProvider import com.android.settings.spa.core.instrumentation.SpaLogProvider import com.android.settings.spa.development.UsageStatsPageProvider import com.android.settings.spa.development.compat.PlatformCompatAppListPageProvider @@ -92,6 +93,8 @@ open class SettingsSpaEnvironment(context: Context) : SpaEnvironment(context) { CloneAppInfoSettingsProvider, NetworkAndInternetPageProvider, AboutPhonePageProvider, + StorageAppListPageProvider.Apps, + StorageAppListPageProvider.Games, ) + togglePermissionAppListTemplate.createPageProviders(), rootPages = listOf( HomePageProvider.createSettingsPage() diff --git a/src/com/android/settings/spa/app/storage/StorageAppList.kt b/src/com/android/settings/spa/app/storage/StorageAppList.kt new file mode 100644 index 00000000000..8fc3eb54bc8 --- /dev/null +++ b/src/com/android/settings/spa/app/storage/StorageAppList.kt @@ -0,0 +1,141 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.spa.app.storage + +import android.content.Context +import android.content.pm.ApplicationInfo +import android.os.Bundle +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import com.android.settings.R +import com.android.settings.spa.app.appinfo.AppInfoSettingsProvider +import com.android.settingslib.spa.framework.common.SettingsPageProvider +import com.android.settingslib.spa.framework.util.filterItem +import com.android.settingslib.spa.framework.util.mapItem +import com.android.settingslib.spaprivileged.model.app.AppEntry +import com.android.settingslib.spaprivileged.model.app.AppListModel +import com.android.settingslib.spaprivileged.model.app.AppRecord +import com.android.settingslib.spaprivileged.template.app.AppList +import com.android.settingslib.spaprivileged.template.app.AppListInput +import com.android.settingslib.spaprivileged.template.app.AppListItem +import com.android.settingslib.spaprivileged.template.app.AppListItemModel +import com.android.settingslib.spaprivileged.template.app.AppListPage +import com.android.settingslib.spaprivileged.template.app.calculateSizeBytes +import com.android.settingslib.spaprivileged.template.app.getStorageSize +import kotlinx.coroutines.flow.Flow + +sealed class StorageAppListPageProvider(private val type: StorageType) : SettingsPageProvider { + @Composable + override fun Page(arguments: Bundle?) { + StorageAppListPage(type) + } + + object Apps : StorageAppListPageProvider(StorageType.Apps) { + override val name = "StorageAppList" + } + + object Games : StorageAppListPageProvider(StorageType.Games) { + override val name = "GameStorageAppList" + } +} + +sealed class StorageType( + @StringRes val titleResource: Int, + val filter: (AppRecordWithSize) -> Boolean +) { + object Apps : StorageType( + titleResource = R.string.apps_storage, + filter = { + (it.app.flags and ApplicationInfo.FLAG_IS_GAME) == 0 && + it.app.category != ApplicationInfo.CATEGORY_GAME + } + ) + object Games : StorageType( + titleResource = R.string.game_storage_settings, + filter = { + (it.app.flags and ApplicationInfo.FLAG_IS_GAME) != 0 || + it.app.category == ApplicationInfo.CATEGORY_GAME + } + ) +} + +@Composable +fun StorageAppListPage( + type: StorageType, + appList: @Composable AppListInput.() -> Unit = { AppList() } +) { + val context = LocalContext.current + AppListPage( + title = stringResource(type.titleResource), + listModel = when (type) { + StorageType.Apps -> remember(context) { StorageAppListModel(context, type) } + StorageType.Games -> remember(context) { StorageAppListModel(context, type) } + }, + showInstantApps = true, + matchAnyUserForAdmin = true, + appList = appList, + moreOptions = { }, // TODO(b/292165031) Sorting in Options not yet supported + ) +} + +data class AppRecordWithSize( + override val app: ApplicationInfo, + val size: Long +) : AppRecord + +class StorageAppListModel( + private val context: Context, + private val type: StorageType, + private val getStorageSummary: @Composable ApplicationInfo.() -> State = { + getStorageSize() + } +) : AppListModel { + override fun transform(userIdFlow: Flow, appListFlow: Flow>) = + appListFlow.mapItem { + AppRecordWithSize(it, it.calculateSizeBytes(context) ?: 0L) + } + + override fun filter( + userIdFlow: Flow, + option: Int, + recordListFlow: Flow> + ): Flow> = recordListFlow.filterItem { type.filter(it) } + + @Composable + override fun getSummary(option: Int, record: AppRecordWithSize): State { + val storageSummary = record.app.getStorageSummary() + return remember { + derivedStateOf { + storageSummary.value + } + } + } + + @Composable + override fun AppListItemModel.AppItem() { + AppListItem(onClick = AppInfoSettingsProvider.navigator(app = record.app)) + } + + override fun getComparator(option: Int) = compareByDescending> { + it.record.size + }.then(super.getComparator(option)) +} diff --git a/tests/spa_unit/src/com/android/settings/spa/app/storage/StorageAppListTest.kt b/tests/spa_unit/src/com/android/settings/spa/app/storage/StorageAppListTest.kt new file mode 100644 index 00000000000..836bf09ed13 --- /dev/null +++ b/tests/spa_unit/src/com/android/settings/spa/app/storage/StorageAppListTest.kt @@ -0,0 +1,144 @@ +/* + * Copyright (C) 2023 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.settings.spa.app.storage + +import android.content.Context +import android.content.pm.ApplicationInfo +import android.icu.text.CollationKey +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.settingslib.spa.testutils.firstWithTimeoutOrNull +import com.android.settingslib.spaprivileged.model.app.AppEntry +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class StorageAppListTest { + private val context: Context = ApplicationProvider.getApplicationContext() + + @Test + fun storageAppListPageProvider_apps_name() { + assertThat(StorageAppListPageProvider.Apps.name).isEqualTo("StorageAppList") + } + + @Test + fun storageAppListPageProvider_games_name() { + assertThat(StorageAppListPageProvider.Games.name).isEqualTo("GameStorageAppList") + } + + @Test + fun transform_containsSize() = runTest { + val listModel = StorageAppListModel(context, StorageType.Apps) + val recordListFlow = listModel.transform(flowOf(0), flowOf(listOf(APP))) + val recordList = recordListFlow.firstWithTimeoutOrNull()!! + assertThat(recordList).hasSize(1) + assertThat(recordList.first().app).isSameInstanceAs(APP) + assertThat(recordList.first().size).isEqualTo(0L) + } + + @Test + fun filter_apps_appWithoutGame() = runTest { + val listModel = StorageAppListModel(context, StorageType.Apps) + val recordListFlow = listModel.filter( + flowOf(0), + 0, + flowOf( + listOf( + AppRecordWithSize(APP, 1L), + AppRecordWithSize(APP2, 1L), + AppRecordWithSize(GAME, 1L) + ) + ) + ) + val recordList = recordListFlow.firstWithTimeoutOrNull()!! + assertThat(recordList).hasSize(2) + assertThat(recordList.none { it.app === GAME }).isTrue() + } + + @Test + fun filter_games_gameWithoutApp() = runTest { + val listModel = StorageAppListModel(context, StorageType.Games) + val recordListFlow = listModel.filter( + flowOf(0), + 0, + flowOf( + listOf( + AppRecordWithSize(APP, 1L), + AppRecordWithSize(APP2, 1L), + AppRecordWithSize(GAME, 1L) + ) + ) + ) + val recordList = recordListFlow.firstWithTimeoutOrNull()!! + assertThat(recordList).hasSize(1) + assertThat(recordList.all { it.app === GAME }).isTrue() + } + + @Test + fun getComparator_sortsByDescendingSize() { + val listModel = StorageAppListModel(context, StorageType.Apps) + val source = listOf( + AppEntry( + AppRecordWithSize(app = APP, size = 1L), + "app1", + CollationKey("first", byteArrayOf(0)) + ), + AppEntry( + AppRecordWithSize(app = APP2, size = 3L), + "app2", + CollationKey("second", byteArrayOf(0)) + ), + AppEntry( + AppRecordWithSize(app = APP3, size = 3L), + "app3", + CollationKey("third", byteArrayOf(0)) + ) + ) + + val result = source.sortedWith(listModel.getComparator(0)) + + assertThat(result[0].record.app).isSameInstanceAs(APP2) + assertThat(result[1].record.app).isSameInstanceAs(APP3) + assertThat(result[2].record.app).isSameInstanceAs(APP) + } + + private companion object { + const val APP_PACKAGE_NAME = "app.package.name" + const val APP_PACKAGE_NAME2 = "app.package.name2" + const val APP_PACKAGE_NAME3 = "app.package.name3" + const val GAME_PACKAGE_NAME = "game.package.name" + val APP = ApplicationInfo().apply { + packageName = APP_PACKAGE_NAME + flags = ApplicationInfo.FLAG_INSTALLED + } + val APP2 = ApplicationInfo().apply { + packageName = APP_PACKAGE_NAME2 + flags = ApplicationInfo.FLAG_INSTALLED + } + val APP3 = ApplicationInfo().apply { + packageName = APP_PACKAGE_NAME3 + flags = ApplicationInfo.FLAG_INSTALLED + } + val GAME = ApplicationInfo().apply { + packageName = GAME_PACKAGE_NAME + flags = ApplicationInfo.FLAG_INSTALLED or ApplicationInfo.FLAG_IS_GAME + } + } +} \ No newline at end of file