diff options
author | 2025-01-09 14:37:27 +0800 | |
---|---|---|
committer | 2025-01-09 18:11:43 -0800 | |
commit | 1a655df724a8883d6286001c60c83396e474a002 (patch) | |
tree | 330daeb3482f0585af632324d1367693da9e2296 | |
parent | 17016bdc9083e70df007da9a632ae7e4f8cd445f (diff) |
New AppStorageRepository to format app size
Change "480 B" to "480 byte".
Bug: 321861088
Flag: EXEMPT bug fix
Test: manual - on All apps and App info
Test: unit test
Change-Id: Ie90f8a522cf5982a998b20b4938290c714cdcedc
5 files changed, 228 insertions, 100 deletions
diff --git a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/framework/common/BytesFormatter.kt b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/framework/common/BytesFormatter.kt index 5b7e2a86135a..e6cc8a80ee38 100644 --- a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/framework/common/BytesFormatter.kt +++ b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/framework/common/BytesFormatter.kt @@ -24,6 +24,7 @@ import android.icu.text.NumberFormat import android.icu.text.UnicodeSet import android.icu.text.UnicodeSetSpanner import android.icu.util.Measure +import android.text.BidiFormatter import android.text.format.Formatter import android.text.format.Formatter.RoundedBytesResult import java.math.BigDecimal @@ -40,11 +41,17 @@ class BytesFormatter(resources: Resources) { constructor(context: Context) : this(context.resources) private val locale = resources.configuration.locales[0] + private val bidiFormatter = BidiFormatter.getInstance(locale) fun format(bytes: Long, useCase: UseCase): String { val rounded = RoundedBytesResult.roundBytes(bytes, useCase.flag) val numberFormatter = getNumberFormatter(rounded.fractionDigits) - return numberFormatter.formatRoundedBytesResult(rounded) + val formattedString = numberFormatter.formatRoundedBytesResult(rounded) + return if (useCase == UseCase.FileSize) { + formattedString.bidiWrap() + } else { + formattedString + } } fun formatWithUnits(bytes: Long, useCase: UseCase): Result { @@ -74,6 +81,14 @@ class BytesFormatter(resources: Resources) { } } + /** Wraps the source string in bidi formatting characters in RTL locales. */ + private fun String.bidiWrap(): String = + if (bidiFormatter.isRtlContext) { + bidiFormatter.unicodeWrap(this) + } else { + this + } + private companion object { fun String.removeFirst(removed: String): String = SPACES_AND_CONTROLS.trim(replaceFirst(removed, "")).toString() diff --git a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/model/app/AppStorageRepository.kt b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/model/app/AppStorageRepository.kt new file mode 100644 index 000000000000..6fd470c1e7aa --- /dev/null +++ b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/model/app/AppStorageRepository.kt @@ -0,0 +1,92 @@ +/* + * Copyright (C) 2025 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.settingslib.spaprivileged.model.app + +import android.content.Context +import android.content.pm.ApplicationInfo +import android.util.Log +import com.android.settingslib.spaprivileged.framework.common.BytesFormatter +import com.android.settingslib.spaprivileged.framework.common.storageStatsManager + +/** A repository interface for accessing and formatting app storage information. */ +interface AppStorageRepository { + /** + * Formats the size of an application into a human-readable string. + * + * This function retrieves the total size of the application, including APK file and its + * associated data. + * + * This function takes an [ApplicationInfo] object as input and returns a formatted string + * representing the size of the application. The size is formatted in units like kB, MB, GB, + * etc. + * + * @param app The [ApplicationInfo] object representing the application. + * @return A formatted string representing the size of the application. + */ + fun formatSize(app: ApplicationInfo): String + + /** + * Formats the size about an application into a human-readable string. + * + * @param sizeBytes The size in bytes to format. + * @return A formatted string representing the size about application. + */ + fun formatSizeBytes(sizeBytes: Long): String + + /** + * Calculates the size of an application in bytes. + * + * This function retrieves the total size of the application, including APK file and its + * associated data. + * + * @param app The [ApplicationInfo] object representing the application. + * @return The total size of the application in bytes, or null if the size could not be + * determined. + */ + fun calculateSizeBytes(app: ApplicationInfo): Long? +} + +class AppStorageRepositoryImpl(context: Context) : AppStorageRepository { + private val storageStatsManager = context.storageStatsManager + private val bytesFormatter = BytesFormatter(context) + + override fun formatSize(app: ApplicationInfo): String { + val sizeBytes = calculateSizeBytes(app) + return if (sizeBytes != null) formatSizeBytes(sizeBytes) else "" + } + + override fun formatSizeBytes(sizeBytes: Long): String = + bytesFormatter.format(sizeBytes, BytesFormatter.UseCase.FileSize) + + override fun calculateSizeBytes(app: ApplicationInfo): Long? = + try { + val stats = + storageStatsManager.queryStatsForPackage( + app.storageUuid, + app.packageName, + app.userHandle, + ) + stats.codeBytes + stats.dataBytes + } catch (e: Exception) { + Log.w(TAG, "Failed to query stats", e) + null + } + + companion object { + private const val TAG = "AppStorageRepository" + } +} diff --git a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/AppStorageSize.kt b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/AppStorageSize.kt index 7a4f81cc1321..7c98e9cd813b 100644 --- a/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/AppStorageSize.kt +++ b/packages/SettingsLib/SpaPrivileged/src/com/android/settingslib/spaprivileged/template/app/AppStorageSize.kt @@ -16,42 +16,30 @@ package com.android.settingslib.spaprivileged.template.app -import android.content.Context import android.content.pm.ApplicationInfo -import android.text.format.Formatter -import android.util.Log +import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.android.settingslib.spaprivileged.framework.common.storageStatsManager +import com.android.settingslib.spa.framework.compose.rememberContext import com.android.settingslib.spaprivileged.framework.compose.placeholder -import com.android.settingslib.spaprivileged.model.app.userHandle +import com.android.settingslib.spaprivileged.model.app.AppStorageRepository +import com.android.settingslib.spaprivileged.model.app.AppStorageRepositoryImpl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn -private const val TAG = "AppStorageSize" - @Composable -fun ApplicationInfo.getStorageSize(): State<String> { - val context = LocalContext.current - return remember(this) { - flow { - val sizeBytes = calculateSizeBytes(context) - this.emit(if (sizeBytes != null) Formatter.formatFileSize(context, sizeBytes) else "") - }.flowOn(Dispatchers.IO) - }.collectAsStateWithLifecycle(initialValue = placeholder()) -} +fun ApplicationInfo.getStorageSize(): State<String> = + getStorageSize(rememberContext(::AppStorageRepositoryImpl)) -fun ApplicationInfo.calculateSizeBytes(context: Context): Long? { - val storageStatsManager = context.storageStatsManager - return try { - val stats = storageStatsManager.queryStatsForPackage(storageUuid, packageName, userHandle) - stats.codeBytes + stats.dataBytes - } catch (e: Exception) { - Log.w(TAG, "Failed to query stats: $e") - null - } +@VisibleForTesting +@Composable +fun ApplicationInfo.getStorageSize(appStorageRepository: AppStorageRepository): State<String> { + val app = this + return remember(app) { + flow { emit(appStorageRepository.formatSize(app)) }.flowOn(Dispatchers.Default) + } + .collectAsStateWithLifecycle(initialValue = placeholder()) } diff --git a/packages/SettingsLib/SpaPrivileged/tests/src/com/android/settingslib/spaprivileged/model/app/AppStorageRepositoryTest.kt b/packages/SettingsLib/SpaPrivileged/tests/src/com/android/settingslib/spaprivileged/model/app/AppStorageRepositoryTest.kt new file mode 100644 index 000000000000..e8ec974bb0b8 --- /dev/null +++ b/packages/SettingsLib/SpaPrivileged/tests/src/com/android/settingslib/spaprivileged/model/app/AppStorageRepositoryTest.kt @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2025 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.settingslib.spaprivileged.model.app + +import android.app.usage.StorageStats +import android.app.usage.StorageStatsManager +import android.content.Context +import android.content.pm.ApplicationInfo +import android.content.pm.PackageManager.NameNotFoundException +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.android.settingslib.spaprivileged.framework.common.storageStatsManager +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.mock +import org.mockito.kotlin.spy +import org.mockito.kotlin.stub +import java.util.UUID + +@RunWith(AndroidJUnit4::class) +class AppStorageRepositoryTest { + private val app = ApplicationInfo().apply { storageUuid = UUID.randomUUID() } + + private val mockStorageStatsManager = + mock<StorageStatsManager> { + on { queryStatsForPackage(app.storageUuid, app.packageName, app.userHandle) } doReturn + STATS + } + + private val context: Context = + spy(ApplicationProvider.getApplicationContext()) { + on { storageStatsManager } doReturn mockStorageStatsManager + } + + private val repository = AppStorageRepositoryImpl(context) + + @Test + fun calculateSizeBytes() { + val sizeBytes = repository.calculateSizeBytes(app) + + assertThat(sizeBytes).isEqualTo(120) + } + + @Test + fun formatSize() { + val fileSize = repository.formatSize(app) + + assertThat(fileSize).isEqualTo("120 byte") + } + + @Test + fun formatSize_throwException() { + mockStorageStatsManager.stub { + on { queryStatsForPackage(app.storageUuid, app.packageName, app.userHandle) } doThrow + NameNotFoundException() + } + + val fileSize = repository.formatSize(app) + + assertThat(fileSize).isEqualTo("") + } + + @Test + fun formatSizeBytes() { + val fileSize = repository.formatSizeBytes(120) + + assertThat(fileSize).isEqualTo("120 byte") + } + + companion object { + private val STATS = + StorageStats().apply { + codeBytes = 100 + dataBytes = 20 + } + } +} diff --git a/packages/SettingsLib/SpaPrivileged/tests/src/com/android/settingslib/spaprivileged/template/app/AppStorageSizeTest.kt b/packages/SettingsLib/SpaPrivileged/tests/src/com/android/settingslib/spaprivileged/template/app/AppStorageSizeTest.kt index 60f3d0ce1be3..4f42c8254c39 100644 --- a/packages/SettingsLib/SpaPrivileged/tests/src/com/android/settingslib/spaprivileged/template/app/AppStorageSizeTest.kt +++ b/packages/SettingsLib/SpaPrivileged/tests/src/com/android/settingslib/spaprivileged/template/app/AppStorageSizeTest.kt @@ -16,98 +16,37 @@ package com.android.settingslib.spaprivileged.template.app -import android.app.usage.StorageStats -import android.app.usage.StorageStatsManager -import android.content.Context import android.content.pm.ApplicationInfo -import android.content.pm.PackageManager.NameNotFoundException -import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.test.junit4.createComposeRule -import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.android.settingslib.spa.framework.compose.stateOf -import com.android.settingslib.spaprivileged.framework.common.storageStatsManager -import com.android.settingslib.spaprivileged.model.app.userHandle -import java.util.UUID -import org.junit.Before +import com.android.settingslib.spaprivileged.model.app.AppStorageRepository import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.mockito.Mock -import org.mockito.Spy -import org.mockito.junit.MockitoJUnit -import org.mockito.junit.MockitoRule -import org.mockito.kotlin.whenever +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import java.util.UUID @RunWith(AndroidJUnit4::class) class AppStorageSizeTest { - @get:Rule - val mockito: MockitoRule = MockitoJUnit.rule() - - @get:Rule - val composeTestRule = createComposeRule() + @get:Rule val composeTestRule = createComposeRule() - @Spy - private val context: Context = ApplicationProvider.getApplicationContext() - - @Mock - private lateinit var storageStatsManager: StorageStatsManager - - private val app = ApplicationInfo().apply { - storageUuid = UUID.randomUUID() - } + private val app = ApplicationInfo().apply { storageUuid = UUID.randomUUID() } - @Before - fun setUp() { - whenever(context.storageStatsManager).thenReturn(storageStatsManager) - whenever( - storageStatsManager.queryStatsForPackage( - app.storageUuid, - app.packageName, - app.userHandle, - ) - ).thenReturn(STATS) - } + private val mockAppStorageRepository = + mock<AppStorageRepository> { on { formatSize(app) } doReturn SIZE } @Test fun getStorageSize() { var storageSize = stateOf("") - composeTestRule.setContent { - CompositionLocalProvider(LocalContext provides context) { - storageSize = app.getStorageSize() - } - } - - composeTestRule.waitUntil { storageSize.value == "120 B" } - } - - @Test - fun getStorageSize_throwException() { - var storageSize = stateOf("Computing") - whenever( - storageStatsManager.queryStatsForPackage( - app.storageUuid, - app.packageName, - app.userHandle, - ) - ).thenThrow(NameNotFoundException()) - - composeTestRule.setContent { - CompositionLocalProvider(LocalContext provides context) { - storageSize = app.getStorageSize() - } - } + composeTestRule.setContent { storageSize = app.getStorageSize(mockAppStorageRepository) } - composeTestRule.waitUntil { storageSize.value == "" } + composeTestRule.waitUntil { storageSize.value == SIZE } } - companion object { - private val STATS = StorageStats().apply { - codeBytes = 100 - dataBytes = 20 - cacheBytes = 3 - } + private companion object { + const val SIZE = "120 kB" } } |