First pass at adding the cache quota suggestions.

This currently integrates with installd, but not with
any framework API to expose this information to apps.

The first pass, as per the design doc, adds a service
which polls for large changes in the file system free space.
If enough spaces changes, it begins a recalculation of the
cache quotas and pipes the information down to installd.
This calculation is done in the updateable ExtServices.

Further enhancements in later patches include integrating this
to listen to package install and removal events, caching the
last computed quota values into an XML file on disk to load
on boot, and exposing the information to apps.

Bug: 33965858
Test: ExtServices unit test

Change-Id: Ie39f228b73532cb6ce2f98529f7c5df0839202ae
diff --git a/packages/ExtServices/Android.mk b/packages/ExtServices/Android.mk
index e8a4007..d0c2b9f 100644
--- a/packages/ExtServices/Android.mk
+++ b/packages/ExtServices/Android.mk
@@ -34,7 +34,8 @@
 
 include $(BUILD_PACKAGE)
 
-
-
-
+# Use the following include to make our test apk.
+ifeq ($(strip $(LOCAL_PACKAGE_OVERRIDES)),)
+include $(call all-makefiles-under, $(LOCAL_PATH))
+endif
 
diff --git a/packages/ExtServices/AndroidManifest.xml b/packages/ExtServices/AndroidManifest.xml
index f442219..f3d8983 100644
--- a/packages/ExtServices/AndroidManifest.xml
+++ b/packages/ExtServices/AndroidManifest.xml
@@ -25,6 +25,13 @@
         android:defaultToDeviceProtectedStorage="true"
         android:directBootAware="true">
 
+        <service android:name=".storage.CacheQuotaServiceImpl"
+             android:permission="android.permission.BIND_CACHE_QUOTA_SERVICE">
+            <intent-filter>
+                <action android:name="android.app.usage.CacheQuotaService" />
+            </intent-filter>
+        </service>
+
         <library android:name="android.ext.services"/>
     </application>
 
diff --git a/packages/ExtServices/src/android/ext/services/storage/CacheQuotaServiceImpl.java b/packages/ExtServices/src/android/ext/services/storage/CacheQuotaServiceImpl.java
new file mode 100644
index 0000000..18863ca
--- /dev/null
+++ b/packages/ExtServices/src/android/ext/services/storage/CacheQuotaServiceImpl.java
@@ -0,0 +1,144 @@
+
+/*
+ * Copyright (C) 2017 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 android.ext.services.storage;
+
+import android.app.usage.CacheQuotaHint;
+import android.app.usage.CacheQuotaService;
+import android.os.Environment;
+import android.os.storage.StorageManager;
+import android.os.storage.VolumeInfo;
+import android.util.ArrayMap;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+/**
+ * CacheQuotaServiceImpl implements the CacheQuotaService with a strategy for populating the quota
+ * of {@link CacheQuotaHint}.
+ */
+public class CacheQuotaServiceImpl extends CacheQuotaService {
+    private static final double CACHE_RESERVE_RATIO = 0.15;
+
+    @Override
+    public List<CacheQuotaHint> onComputeCacheQuotaHints(List<CacheQuotaHint> requests) {
+        ArrayMap<String, List<CacheQuotaHint>> byUuid = new ArrayMap<>();
+        final int requestCount = requests.size();
+        for (int i = 0; i < requestCount; i++) {
+            CacheQuotaHint request = requests.get(i);
+            String uuid = request.getVolumeUuid();
+            List<CacheQuotaHint> listForUuid = byUuid.get(uuid);
+            if (listForUuid == null) {
+                listForUuid = new ArrayList<>();
+                byUuid.put(uuid, listForUuid);
+            }
+            listForUuid.add(request);
+        }
+
+        List<CacheQuotaHint> processed = new ArrayList<>();
+        byUuid.entrySet().forEach(
+                requestListEntry -> {
+                    // Collapse all usage stats to the same uid.
+                    Map<Integer, List<CacheQuotaHint>> byUid = requestListEntry.getValue()
+                            .stream()
+                            .collect(Collectors.groupingBy(CacheQuotaHint::getUid));
+                    byUid.values().forEach(uidGroupedList -> {
+                        int size = uidGroupedList.size();
+                        if (size < 2) {
+                            return;
+                        }
+                        CacheQuotaHint first = uidGroupedList.get(0);
+                        for (int i = 1; i < size; i++) {
+                            /* Note: We can't use the UsageStats built-in addition function because
+                                     UIDs may span multiple packages and usage stats adding has
+                                     matching package names as a precondition. */
+                            first.getUsageStats().mTotalTimeInForeground +=
+                                    uidGroupedList.get(i).getUsageStats().mTotalTimeInForeground;
+                        }
+                    });
+
+                    // Because the foreground stats have been added to the first element, we need
+                    // a list of only the first values (which contain the merged foreground time).
+                    List<CacheQuotaHint> flattenedRequests =
+                            byUid.values()
+                                 .stream()
+                                 .map(entryList -> entryList.get(0))
+                                 .filter(entry -> entry.getUsageStats().mTotalTimeInForeground != 0)
+                                 .sorted(sCacheQuotaRequestComparator)
+                                 .collect(Collectors.toList());
+
+                    // Because the elements are sorted, we can use the index to also be the sorted
+                    // index for cache quota calculation.
+                    double sum = getSumOfFairShares(flattenedRequests.size());
+                    String uuid = requestListEntry.getKey();
+                    long reservedSize = getReservedCacheSize(uuid);
+                    for (int count = 0; count < flattenedRequests.size(); count++) {
+                        double share = getFairShareForPosition(count) / sum;
+                        CacheQuotaHint entry = flattenedRequests.get(count);
+                        CacheQuotaHint.Builder builder = new CacheQuotaHint.Builder(entry);
+                        builder.setQuota(Math.round(share * reservedSize));
+                        processed.add(builder.build());
+                    }
+                }
+        );
+
+        return processed.stream()
+                .filter(request -> request.getQuota() > 0).collect(Collectors.toList());
+    }
+
+    private double getFairShareForPosition(int position) {
+        double value = 1.0 / Math.log(position + 3) - 0.285;
+        return (value > 0.01) ? value : 0.01;
+    }
+
+    private double getSumOfFairShares(int size) {
+        double sum = 0;
+        for (int i = 0; i < size; i++) {
+            sum += getFairShareForPosition(i);
+        }
+        return sum;
+    }
+
+    private long getReservedCacheSize(String uuid) {
+        // TODO: Revisit the cache size after running more storage tests.
+        // TODO: Figure out how to ensure ExtServices has the permissions to call
+        //       StorageStatsManager, because this is ignoring the cache...
+        StorageManager storageManager = getSystemService(StorageManager.class);
+        long freeBytes = 0;
+        if (uuid == StorageManager.UUID_PRIVATE_INTERNAL) { // regular equals because of null
+            freeBytes = Environment.getDataDirectory().getFreeSpace();
+        } else {
+            final VolumeInfo vol = storageManager.findVolumeByUuid(uuid);
+            freeBytes = vol.getPath().getFreeSpace();
+        }
+        return Math.round(freeBytes * CACHE_RESERVE_RATIO);
+    }
+
+    // Compares based upon foreground time.
+    private static Comparator<CacheQuotaHint> sCacheQuotaRequestComparator =
+            new Comparator<CacheQuotaHint>() {
+        @Override
+        public int compare(CacheQuotaHint o, CacheQuotaHint t1) {
+            long x = t1.getUsageStats().getTotalTimeInForeground();
+            long y = o.getUsageStats().getTotalTimeInForeground();
+            return (x < y) ? -1 : ((x == y) ? 0 : 1);
+        }
+    };
+}
diff --git a/packages/ExtServices/tests/Android.mk b/packages/ExtServices/tests/Android.mk
new file mode 100644
index 0000000..cb3c352
--- /dev/null
+++ b/packages/ExtServices/tests/Android.mk
@@ -0,0 +1,24 @@
+LOCAL_PATH:= $(call my-dir)
+include $(CLEAR_VARS)
+
+# We only want this apk build for tests.
+LOCAL_MODULE_TAGS := tests
+LOCAL_CERTIFICATE := platform
+
+LOCAL_JAVA_LIBRARIES := android.test.runner
+
+LOCAL_STATIC_JAVA_LIBRARIES := \
+    android-support-test \
+    mockito-target-minus-junit4 \
+    espresso-core \
+    truth-prebuilt \
+    legacy-android-test
+
+# Include all test java files.
+LOCAL_SRC_FILES := $(call all-java-files-under, src)
+
+LOCAL_PACKAGE_NAME := ExtServicesUnitTests
+
+LOCAL_INSTRUMENTATION_FOR := ExtServices
+
+include $(BUILD_PACKAGE)
diff --git a/packages/ExtServices/tests/AndroidManifest.xml b/packages/ExtServices/tests/AndroidManifest.xml
new file mode 100644
index 0000000..e6c7b97
--- /dev/null
+++ b/packages/ExtServices/tests/AndroidManifest.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2016 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.
+-->
+
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+          package="android.ext.services.tests.unit">
+
+    <application>
+        <uses-library android:name="android.test.runner" />
+    </application>
+
+    <instrumentation android:name="android.support.test.runner.AndroidJUnitRunner"
+                     android:targetPackage="android.ext.services"
+                     android:label="ExtServices Test Cases">
+    </instrumentation>
+
+</manifest>
\ No newline at end of file
diff --git a/packages/ExtServices/tests/src/android/ext/services/storage/CacheQuotaServiceImplTest.java b/packages/ExtServices/tests/src/android/ext/services/storage/CacheQuotaServiceImplTest.java
new file mode 100644
index 0000000..cc1699a
--- /dev/null
+++ b/packages/ExtServices/tests/src/android/ext/services/storage/CacheQuotaServiceImplTest.java
@@ -0,0 +1,150 @@
+/*
+ * Copyright (C) 2017 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 android.ext.services.storage;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Mockito.when;
+
+import android.app.usage.CacheQuotaHint;
+import android.app.usage.UsageStats;
+import android.content.Context;
+import android.content.ContextWrapper;
+import android.content.Intent;
+import android.os.storage.StorageManager;
+import android.os.storage.VolumeInfo;
+import android.test.ServiceTestCase;
+
+import org.junit.Before;
+import org.junit.Test;
+import org.mockito.Answers;
+import org.mockito.Mock;
+import org.mockito.Mockito;
+import org.mockito.MockitoAnnotations;
+
+import java.io.File;
+import java.util.ArrayList;
+import java.util.List;
+
+public class CacheQuotaServiceImplTest extends ServiceTestCase<CacheQuotaServiceImpl> {
+    private static final String sTestVolUuid = "uuid";
+    private static final String sSecondTestVolUuid = "otherUuid";
+
+    @Mock private Context mContext;
+    @Mock private File mFile;
+    @Mock private VolumeInfo mVolume;
+    @Mock(answer = Answers.RETURNS_DEEP_STUBS) private StorageManager mStorageManager;
+
+    public CacheQuotaServiceImplTest() {
+        super(CacheQuotaServiceImpl.class);
+    }
+
+    @Before
+    public void setUp() throws Exception {
+        super.setUp();
+        MockitoAnnotations.initMocks(this);
+        mContext = Mockito.spy(new ContextWrapper(getSystemContext()));
+        setContext(mContext);
+        when(mContext.getSystemService(Context.STORAGE_SERVICE)).thenReturn(mStorageManager);
+
+        when(mFile.getFreeSpace()).thenReturn(10000L);
+        when(mVolume.getPath()).thenReturn(mFile);
+        when(mStorageManager.findVolumeByUuid(sTestVolUuid)).thenReturn(mVolume);
+        when(mStorageManager.findVolumeByUuid(sSecondTestVolUuid)).thenReturn(mVolume);
+
+        Intent intent = new Intent(getContext(), CacheQuotaServiceImpl.class);
+        startService(intent);
+    }
+
+    @Test
+    public void testNoApps() {
+        CacheQuotaServiceImpl service = getService();
+        assertEquals(service.onComputeCacheQuotaHints(new ArrayList()).size(), 0);
+    }
+
+    @Test
+    public void testOneApp() throws Exception {
+        ArrayList<CacheQuotaHint> requests = new ArrayList<>();
+        CacheQuotaHint request = makeNewRequest("com.test", sTestVolUuid, 1001, 100L);
+        requests.add(request);
+
+        List<CacheQuotaHint> output = getService().onComputeCacheQuotaHints(requests);
+
+        assertThat(output).hasSize(1);
+        assertThat(output.get(0).getQuota()).isEqualTo(1500L);
+    }
+
+    @Test
+    public void testTwoAppsOneVolume() throws Exception {
+        ArrayList<CacheQuotaHint> requests = new ArrayList<>();
+        requests.add(makeNewRequest("com.test", sTestVolUuid, 1001, 100L));
+        requests.add(makeNewRequest("com.test2", sTestVolUuid, 1002, 99L));
+
+        List<CacheQuotaHint> output = getService().onComputeCacheQuotaHints(requests);
+
+        // Note that the sizes are just the cache area split up.
+        assertThat(output).hasSize(2);
+        assertThat(output.get(0).getQuota()).isEqualTo(883);
+        assertThat(output.get(1).getQuota()).isEqualTo(1500 - 883);
+    }
+
+    @Test
+    public void testTwoAppsTwoVolumes() throws Exception {
+        ArrayList<CacheQuotaHint> requests = new ArrayList<>();
+        requests.add(makeNewRequest("com.test", sTestVolUuid, 1001, 100L));
+        requests.add(makeNewRequest("com.test2", sSecondTestVolUuid, 1002, 99L));
+
+        List<CacheQuotaHint> output = getService().onComputeCacheQuotaHints(requests);
+
+        assertThat(output).hasSize(2);
+        assertThat(output.get(0).getQuota()).isEqualTo(1500);
+        assertThat(output.get(1).getQuota()).isEqualTo(1500);
+    }
+
+    @Test
+    public void testMultipleAppsPerUidIsCollated() throws Exception {
+        ArrayList<CacheQuotaHint> requests = new ArrayList<>();
+        requests.add(makeNewRequest("com.test", sTestVolUuid, 1001, 100L));
+        requests.add(makeNewRequest("com.test2", sTestVolUuid, 1001, 99L));
+
+        List<CacheQuotaHint> output = getService().onComputeCacheQuotaHints(requests);
+
+        assertThat(output).hasSize(1);
+        assertThat(output.get(0).getQuota()).isEqualTo(1500);
+    }
+
+    @Test
+    public void testTwoAppsTwoVolumesTwoUuidsShouldBESeparate() throws Exception {
+        ArrayList<CacheQuotaHint> requests = new ArrayList<>();
+        requests.add(makeNewRequest("com.test", sTestVolUuid, 1001, 100L));
+        requests.add(makeNewRequest("com.test2", sSecondTestVolUuid, 1001, 99L));
+
+        List<CacheQuotaHint> output = getService().onComputeCacheQuotaHints(requests);
+
+        assertThat(output).hasSize(2);
+        assertThat(output.get(0).getQuota()).isEqualTo(1500);
+        assertThat(output.get(1).getQuota()).isEqualTo(1500);
+    }
+
+    private CacheQuotaHint makeNewRequest(String packageName, String uuid, int uid, long foregroundTime) {
+        UsageStats stats = new UsageStats();
+        stats.mPackageName = packageName;
+        stats.mTotalTimeInForeground = foregroundTime;
+        return new CacheQuotaHint.Builder()
+                .setVolumeUuid(uuid).setUid(uid).setUsageStats(stats).setQuota(-1).build();
+    }
+}