Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 1a655df7 authored by Chaohui Wang's avatar Chaohui Wang
Browse files

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
parent 17016bdc
Loading
Loading
Loading
Loading
+16 −1
Original line number Diff line number Diff line
@@ -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()
+92 −0
Original line number Diff line number Diff line
/*
 * 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"
    }
}
+14 −26
Original line number Diff line number Diff line
@@ -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())
}
+94 −0
Original line number Diff line number Diff line
/*
 * 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
            }
    }
}
+12 −73
Original line number Diff line number Diff line
@@ -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()
    private val app = ApplicationInfo().apply { storageUuid = UUID.randomUUID() }

    @Mock
    private lateinit var storageStatsManager: StorageStatsManager

    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.setContent { storageSize = app.getStorageSize(mockAppStorageRepository) }

        composeTestRule.waitUntil { storageSize.value == "120 B" }
        composeTestRule.waitUntil { storageSize.value == SIZE }
    }

    @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.waitUntil { storageSize.value == "" }
    }

    companion object {
        private val STATS = StorageStats().apply {
            codeBytes = 100
            dataBytes = 20
            cacheBytes = 3
        }
    private companion object {
        const val SIZE = "120 kB"
    }
}