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

Commit 9d3450ca authored by Chaohui Wang's avatar Chaohui Wang Committed by Android (Google) Code Review
Browse files

Merge "New AppStorageRepository to format app size" into main

parents 9c77f4eb 1a655df7
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"
    }
}