Extract app data usage to AppDataUsageRepository

From DataUsageList for better organization and testing.

Bug: 290856342
Test: manual - on DataUsageList
Test: unit test
Change-Id: I97e327a220d40942b9345ec7f1f8c466ac1fc9da
diff --git a/src/com/android/settings/datausage/DataUsageList.java b/src/com/android/settings/datausage/DataUsageList.java
index 5c52797..b030219 100644
--- a/src/com/android/settings/datausage/DataUsageList.java
+++ b/src/com/android/settings/datausage/DataUsageList.java
@@ -14,32 +14,23 @@
 
 package com.android.settings.datausage;
 
-import static android.app.usage.NetworkStats.Bucket.UID_REMOVED;
-import static android.app.usage.NetworkStats.Bucket.UID_TETHERING;
-import static android.net.NetworkPolicyManager.POLICY_REJECT_METERED_BACKGROUND;
-
 import android.app.Activity;
 import android.app.ActivityManager;
 import android.app.settings.SettingsEnums;
 import android.app.usage.NetworkStats;
-import android.app.usage.NetworkStats.Bucket;
 import android.content.Context;
 import android.content.Intent;
-import android.content.pm.UserInfo;
 import android.graphics.Color;
 import android.net.ConnectivityManager;
 import android.net.NetworkPolicy;
 import android.net.NetworkTemplate;
 import android.os.Bundle;
-import android.os.Process;
-import android.os.UserHandle;
 import android.os.UserManager;
 import android.provider.Settings;
 import android.telephony.SubscriptionInfo;
 import android.telephony.SubscriptionManager;
 import android.util.EventLog;
 import android.util.Log;
-import android.util.SparseArray;
 import android.view.View;
 import android.view.View.AccessibilityDelegate;
 import android.view.accessibility.AccessibilityEvent;
@@ -60,6 +51,7 @@
 import com.android.settings.R;
 import com.android.settings.core.SubSettingLauncher;
 import com.android.settings.datausage.CycleAdapter.SpinnerInterface;
+import com.android.settings.datausage.lib.AppDataUsageRepository;
 import com.android.settings.network.MobileDataEnabledListener;
 import com.android.settings.network.MobileNetworkRepository;
 import com.android.settings.network.ProxySubscriptionManager;
@@ -69,13 +61,10 @@
 import com.android.settingslib.net.NetworkCycleChartData;
 import com.android.settingslib.net.NetworkCycleChartDataLoader;
 import com.android.settingslib.net.NetworkStatsSummaryLoader;
-import com.android.settingslib.net.UidDetail;
 import com.android.settingslib.net.UidDetailProvider;
 import com.android.settingslib.utils.ThreadUtils;
 
 import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
 import java.util.Optional;
@@ -423,110 +412,19 @@
     }
 
     /**
-     * Bind the given {@link NetworkStats}, or {@code null} to clear list.
+     * Bind the given buckets.
      */
-    private void bindStats(NetworkStats stats, int[] restrictedUids) {
+    private void bindStats(List<AppDataUsageRepository.Bucket> buckets) {
         mApps.removeAll();
-        if (stats == null) {
-            if (LOGD) {
-                Log.d(TAG, "No network stats data. App list cleared.");
-            }
-            return;
-        }
-
-        final ArrayList<AppItem> items = new ArrayList<>();
-        long largest = 0;
-
-        final int currentUserId = ActivityManager.getCurrentUser();
-        final UserManager userManager = UserManager.get(getContext());
-        final List<UserHandle> profiles = userManager.getUserProfiles();
-        final SparseArray<AppItem> knownItems = new SparseArray<AppItem>();
-
-        final Bucket bucket = new Bucket();
-        while (stats.hasNextBucket() && stats.getNextBucket(bucket)) {
-            // Decide how to collapse items together
-            final int uid = bucket.getUid();
-            final int collapseKey;
-            final int category;
-            final int userId = UserHandle.getUserId(uid);
-            if (UserHandle.isApp(uid) || Process.isSdkSandboxUid(uid)) {
-                if (profiles.contains(new UserHandle(userId))) {
-                    if (userId != currentUserId) {
-                        // Add to a managed user item.
-                        final int managedKey = UidDetailProvider.buildKeyForUser(userId);
-                        largest = accumulate(managedKey, knownItems, bucket,
-                            AppItem.CATEGORY_USER, items, largest);
-                    }
-                    // Map SDK sandbox back to its corresponding app
-                    if (Process.isSdkSandboxUid(uid)) {
-                        collapseKey = Process.getAppUidForSdkSandboxUid(uid);
-                    } else {
-                        collapseKey = uid;
-                    }
-                    category = AppItem.CATEGORY_APP;
-                } else {
-                    // If it is a removed user add it to the removed users' key
-                    final UserInfo info = userManager.getUserInfo(userId);
-                    if (info == null) {
-                        collapseKey = UID_REMOVED;
-                        category = AppItem.CATEGORY_APP;
-                    } else {
-                        // Add to other user item.
-                        collapseKey = UidDetailProvider.buildKeyForUser(userId);
-                        category = AppItem.CATEGORY_USER;
-                    }
-                }
-            } else if (uid == UID_REMOVED || uid == UID_TETHERING
-                    || uid == Process.OTA_UPDATE_UID) {
-                collapseKey = uid;
-                category = AppItem.CATEGORY_APP;
-            } else {
-                collapseKey = android.os.Process.SYSTEM_UID;
-                category = AppItem.CATEGORY_APP;
-            }
-            largest = accumulate(collapseKey, knownItems, bucket, category, items, largest);
-        }
-        stats.close();
-
-        for (final int uid : restrictedUids) {
-            // Only splice in restricted state for current user or managed users
-            if (!profiles.contains(UserHandle.getUserHandleForUid(uid))) {
-                continue;
-            }
-
-            AppItem item = knownItems.get(uid);
-            if (item == null) {
-                item = new AppItem(uid);
-                item.total = -1;
-                item.addUid(uid);
-                items.add(item);
-                knownItems.put(item.key, item);
-            }
-            item.restricted = true;
-        }
-
-        Collections.sort(items);
-        final List<String> packageNames = Arrays.asList(getContext().getResources().getStringArray(
-                R.array.datausage_hiding_carrier_service_package_names));
-        // When there is no specified SubscriptionInfo, Wi-Fi data usage will be displayed.
-        // In this case, the carrier service package also needs to be hidden.
-        boolean shouldHidePackageName = mSubscriptionInfoEntity == null
-                || Arrays.stream(getContext().getResources().getIntArray(
-                        R.array.datausage_hiding_carrier_service_carrier_id))
-                .anyMatch(carrierId -> (carrierId == mSubscriptionInfoEntity.carrierId));
-
-        for (var item : items) {
-            UidDetail detail = mUidDetailProvider.getUidDetail(item.key, true);
-            // Do not show carrier service package in data usage list if it should be hidden for
-            // the carrier.
-            if (detail != null && shouldHidePackageName && packageNames.contains(
-                    detail.packageName)) {
-                continue;
-            }
-
-            final int percentTotal = largest != 0 ? (int) (item.total * 100 / largest) : 0;
+        AppDataUsageRepository repository = new AppDataUsageRepository(
+                requireContext(),
+                ActivityManager.getCurrentUser(),
+                mSubscriptionInfoEntity == null ? null : mSubscriptionInfoEntity.carrierId,
+                appItem -> mUidDetailProvider.getUidDetail(appItem.key, true).packageName
+        );
+        for (var itemPercentPair : repository.getAppPercent(buckets)) {
             final AppDataUsagePreference preference = new AppDataUsagePreference(getContext(),
-                    item, percentTotal, mUidDetailProvider);
+                    itemPercentPair.getFirst(), itemPercentPair.getSecond(), mUidDetailProvider);
             preference.setOnPreferenceClickListener(p -> {
                 AppDataUsagePreference pref = (AppDataUsagePreference) p;
                 startAppDataUsage(pref.getItem());
@@ -565,30 +463,6 @@
                 .launch();
     }
 
-    /**
-     * Accumulate data usage of a network stats entry for the item mapped by the collapse key.
-     * Creates the item if needed.
-     *
-     * @param collapseKey  the collapse key used to map the item.
-     * @param knownItems   collection of known (already existing) items.
-     * @param bucket       the network stats bucket to extract data usage from.
-     * @param itemCategory the item is categorized on the list view by this category. Must be
-     */
-    private static long accumulate(int collapseKey, final SparseArray<AppItem> knownItems,
-            Bucket bucket, int itemCategory, ArrayList<AppItem> items, long largest) {
-        final int uid = bucket.getUid();
-        AppItem item = knownItems.get(collapseKey);
-        if (item == null) {
-            item = new AppItem(collapseKey);
-            item.category = itemCategory;
-            items.add(item);
-            knownItems.put(item.key, item);
-        }
-        item.addUid(uid);
-        item.total += bucket.getRxBytes() + bucket.getTxBytes();
-        return Math.max(largest, item.total);
-    }
-
     private final OnItemSelectedListener mCycleListener = new OnItemSelectedListener() {
         @Override
         public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
@@ -643,15 +517,13 @@
                 @Override
                 public void onLoadFinished(
                         @NonNull Loader<NetworkStats> loader, NetworkStats data) {
-                    final int[] restrictedUids = services.mPolicyManager.getUidsWithPolicy(
-                            POLICY_REJECT_METERED_BACKGROUND);
-                    bindStats(data, restrictedUids);
+                    bindStats(AppDataUsageRepository.Companion.convertToBuckets(data));
                     updateEmptyVisible();
                 }
 
                 @Override
                 public void onLoaderReset(@NonNull Loader<NetworkStats> loader) {
-                    bindStats(null, new int[0]);
+                    mApps.removeAll();
                     updateEmptyVisible();
                 }
 
diff --git a/src/com/android/settings/datausage/lib/AppDataUsageRepository.kt b/src/com/android/settings/datausage/lib/AppDataUsageRepository.kt
new file mode 100644
index 0000000..3813af5
--- /dev/null
+++ b/src/com/android/settings/datausage/lib/AppDataUsageRepository.kt
@@ -0,0 +1,192 @@
+/*
+ * 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.datausage.lib
+
+import android.app.usage.NetworkStats
+import android.content.Context
+import android.net.NetworkPolicyManager
+import android.os.Process
+import android.os.UserHandle
+import android.util.SparseArray
+import com.android.settings.R
+import com.android.settingslib.AppItem
+import com.android.settingslib.net.UidDetailProvider
+import com.android.settingslib.spaprivileged.framework.common.userManager
+
+class AppDataUsageRepository(
+    private val context: Context,
+    private val currentUserId: Int,
+    private val carrierId: Int?,
+    private val getPackageName: (AppItem) -> String,
+) {
+    data class Bucket(
+        val uid: Int,
+        val bytes: Long,
+    )
+
+    fun getAppPercent(buckets: List<Bucket>): List<Pair<AppItem, Int>> {
+        val items = ArrayList<AppItem>()
+        val knownItems = SparseArray<AppItem>()
+        val profiles = context.userManager.userProfiles
+        bindStats(buckets, profiles, knownItems, items)
+        val restrictedUids = context.getSystemService(NetworkPolicyManager::class.java)!!
+            .getUidsWithPolicy(NetworkPolicyManager.POLICY_REJECT_METERED_BACKGROUND)
+        for (uid in restrictedUids) {
+            // Only splice in restricted state for current user or managed users
+            if (!profiles.contains(UserHandle.getUserHandleForUid(uid))) {
+                continue
+            }
+            var item = knownItems[uid]
+            if (item == null) {
+                item = AppItem(uid)
+                item.total = 0
+                item.addUid(uid)
+                items.add(item)
+                knownItems.put(item.key, item)
+            }
+            item.restricted = true
+        }
+
+        val filteredItems = filterItems(items).sorted()
+        val largest: Long = filteredItems.maxOfOrNull { it.total } ?: 0
+        return filteredItems.map { item ->
+            val percentTotal = if (largest > 0) (item.total * 100 / largest).toInt() else 0
+            item to percentTotal
+        }
+    }
+
+    private fun filterItems(items: List<AppItem>): List<AppItem> {
+        // When there is no specified SubscriptionInfo, Wi-Fi data usage will be displayed.
+        // In this case, the carrier service package also needs to be hidden.
+        if (carrierId != null && carrierId !in context.resources.getIntArray(
+                R.array.datausage_hiding_carrier_service_carrier_id
+            )
+        ) {
+            return items
+        }
+        val hiddenPackageNames = context.resources.getStringArray(
+            R.array.datausage_hiding_carrier_service_package_names
+        )
+        return items.filter { item ->
+            // Do not show carrier service package in data usage list if it should be hidden for
+            // the carrier.
+            getPackageName(item) !in hiddenPackageNames
+        }
+    }
+
+    private fun bindStats(
+        buckets: List<Bucket>,
+        profiles: MutableList<UserHandle>,
+        knownItems: SparseArray<AppItem>,
+        items: ArrayList<AppItem>,
+    ) {
+        for (bucket in buckets) {
+            // Decide how to collapse items together
+            val uid = bucket.uid
+            val collapseKey: Int
+            val category: Int
+            val userId = UserHandle.getUserId(uid)
+            if (UserHandle.isApp(uid) || Process.isSdkSandboxUid(uid)) {
+                if (profiles.contains(UserHandle(userId))) {
+                    if (userId != currentUserId) {
+                        // Add to a managed user item.
+                        accumulate(
+                            collapseKey = UidDetailProvider.buildKeyForUser(userId),
+                            knownItems = knownItems,
+                            bucket = bucket,
+                            itemCategory = AppItem.CATEGORY_USER,
+                            items = items,
+                        )
+                    }
+                    // Map SDK sandbox back to its corresponding app
+                    collapseKey = if (Process.isSdkSandboxUid(uid)) {
+                        Process.getAppUidForSdkSandboxUid(uid)
+                    } else {
+                        uid
+                    }
+                    category = AppItem.CATEGORY_APP
+                } else {
+                    // If it is a removed user add it to the removed users' key
+                    if (context.userManager.getUserInfo(userId) == null) {
+                        collapseKey = NetworkStats.Bucket.UID_REMOVED
+                        category = AppItem.CATEGORY_APP
+                    } else {
+                        // Add to other user item.
+                        collapseKey = UidDetailProvider.buildKeyForUser(userId)
+                        category = AppItem.CATEGORY_USER
+                    }
+                }
+            } else if (uid == NetworkStats.Bucket.UID_REMOVED ||
+                uid == NetworkStats.Bucket.UID_TETHERING ||
+                uid == Process.OTA_UPDATE_UID
+            ) {
+                collapseKey = uid
+                category = AppItem.CATEGORY_APP
+            } else {
+                collapseKey = Process.SYSTEM_UID
+                category = AppItem.CATEGORY_APP
+            }
+            accumulate(
+                collapseKey = collapseKey,
+                knownItems = knownItems,
+                bucket = bucket,
+                itemCategory = category,
+                items = items,
+            )
+        }
+    }
+
+    /**
+     * Accumulate data usage of a network stats entry for the item mapped by the collapse key.
+     * Creates the item if needed.
+     *
+     * @param collapseKey  the collapse key used to map the item.
+     * @param knownItems   collection of known (already existing) items.
+     * @param bucket       the network stats bucket to extract data usage from.
+     * @param itemCategory the item is categorized on the list view by this category. Must be
+     */
+    private fun accumulate(
+        collapseKey: Int,
+        knownItems: SparseArray<AppItem>,
+        bucket: Bucket,
+        itemCategory: Int,
+        items: ArrayList<AppItem>,
+    ) {
+        var item = knownItems[collapseKey]
+        if (item == null) {
+            item = AppItem(collapseKey)
+            item.category = itemCategory
+            items.add(item)
+            knownItems.put(item.key, item)
+        }
+        item.addUid(bucket.uid)
+        item.total += bucket.bytes
+    }
+
+    companion object {
+        fun convertToBuckets(stats: NetworkStats): List<Bucket> {
+            val buckets = mutableListOf<Bucket>()
+            stats.use {
+                val bucket = NetworkStats.Bucket()
+                while (it.getNextBucket(bucket)) {
+                    buckets += Bucket(uid = bucket.uid, bytes = bucket.rxBytes + bucket.txBytes)
+                }
+            }
+            return buckets
+        }
+    }
+}
diff --git a/tests/spa_unit/src/com/android/settings/datausage/lib/AppDataUsageRepositoryTest.kt b/tests/spa_unit/src/com/android/settings/datausage/lib/AppDataUsageRepositoryTest.kt
new file mode 100644
index 0000000..016d6d2
--- /dev/null
+++ b/tests/spa_unit/src/com/android/settings/datausage/lib/AppDataUsageRepositoryTest.kt
@@ -0,0 +1,131 @@
+/*
+ * 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.datausage.lib
+
+import android.content.Context
+import android.content.pm.UserInfo
+import android.content.res.Resources
+import android.net.NetworkPolicyManager
+import android.os.UserHandle
+import android.os.UserManager
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.android.settings.R
+import com.android.settings.datausage.lib.AppDataUsageRepository.Bucket
+import com.android.settingslib.AppItem
+import com.android.settingslib.spaprivileged.framework.common.userManager
+import com.google.common.truth.Truth.assertThat
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.mockito.junit.MockitoJUnit
+import org.mockito.junit.MockitoRule
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
+import org.mockito.kotlin.spy
+
+@RunWith(AndroidJUnit4::class)
+class AppDataUsageRepositoryTest {
+    @get:Rule
+    val mockito: MockitoRule = MockitoJUnit.rule()
+
+    private val mockUserManager = mock<UserManager> {
+        on { userProfiles } doReturn listOf(UserHandle.of(USER_ID))
+        on { getUserInfo(USER_ID) } doReturn UserInfo(USER_ID, "", 0)
+    }
+
+    private val mockNetworkPolicyManager = mock<NetworkPolicyManager> {
+        on { getUidsWithPolicy(NetworkPolicyManager.POLICY_REJECT_METERED_BACKGROUND) } doReturn
+            intArrayOf()
+    }
+
+    private val mockResources = mock<Resources> {
+        on { getIntArray(R.array.datausage_hiding_carrier_service_carrier_id) } doReturn
+            intArrayOf(HIDING_CARRIER_ID)
+
+        on { getStringArray(R.array.datausage_hiding_carrier_service_package_names) } doReturn
+            arrayOf(HIDING_PACKAGE_NAME)
+    }
+
+    private val context: Context = spy(ApplicationProvider.getApplicationContext()) {
+        on { userManager } doReturn mockUserManager
+        on { getSystemService(NetworkPolicyManager::class.java) } doReturn mockNetworkPolicyManager
+        on { resources } doReturn mockResources
+    }
+
+    @Test
+    fun getAppPercent_noAppToHide() {
+        val repository = AppDataUsageRepository(
+            context = context,
+            currentUserId = USER_ID,
+            carrierId = null,
+            getPackageName = { "" },
+        )
+        val buckets = listOf(
+            Bucket(uid = APP_ID_1, bytes = 1),
+            Bucket(uid = APP_ID_2, bytes = 2),
+        )
+
+        val appPercentList = repository.getAppPercent(buckets)
+
+        assertThat(appPercentList).hasSize(2)
+        appPercentList[0].first.apply {
+            assertThat(key).isEqualTo(APP_ID_2)
+            assertThat(category).isEqualTo(AppItem.CATEGORY_APP)
+            assertThat(total).isEqualTo(2)
+        }
+        assertThat(appPercentList[0].second).isEqualTo(100)
+        appPercentList[1].first.apply {
+            assertThat(key).isEqualTo(APP_ID_1)
+            assertThat(category).isEqualTo(AppItem.CATEGORY_APP)
+            assertThat(total).isEqualTo(1)
+        }
+        assertThat(appPercentList[1].second).isEqualTo(50)
+    }
+
+    @Test
+    fun getAppPercent_hasAppToHide() {
+        val repository = AppDataUsageRepository(
+            context = context,
+            currentUserId = USER_ID,
+            carrierId = HIDING_CARRIER_ID,
+            getPackageName = { if (it.key == APP_ID_1) HIDING_PACKAGE_NAME else "" },
+        )
+        val buckets = listOf(
+            Bucket(uid = APP_ID_1, bytes = 1),
+            Bucket(uid = APP_ID_2, bytes = 2),
+        )
+
+        val appPercentList = repository.getAppPercent(buckets)
+
+        assertThat(appPercentList).hasSize(1)
+        appPercentList[0].first.apply {
+            assertThat(key).isEqualTo(APP_ID_2)
+            assertThat(category).isEqualTo(AppItem.CATEGORY_APP)
+            assertThat(total).isEqualTo(2)
+        }
+        assertThat(appPercentList[0].second).isEqualTo(100)
+    }
+
+    private companion object {
+        const val USER_ID = 1
+        const val APP_ID_1 = 110001
+        const val APP_ID_2 = 110002
+        const val HIDING_CARRIER_ID = 4
+        const val HIDING_PACKAGE_NAME = "hiding.package.name"
+    }
+}