OSDN Git Service

Augment diskstats dumpsys to have categorization and apps.
authorDaniel Nishi <dhnishi@google.com>
Tue, 13 Dec 2016 18:38:42 +0000 (10:38 -0800)
committerDaniel Nishi <dhnishi@google.com>
Tue, 20 Dec 2016 21:32:51 +0000 (13:32 -0800)
This adds a new service which opportunistically saves the
file system categorization information and the app sizes. This
information is fetched during a diskstats dumpsys call from a file
stored on the disk. This allows us to keep the dumpsys running quickly
while adding information which is costly to calculate.

Bug: 32207207
Test: System server instrumentation tests
Change-Id: Id59e84b9ad38a9debf3e46e5133ef06f7353829d

core/res/AndroidManifest.xml
services/core/java/com/android/server/DiskStatsService.java
services/core/java/com/android/server/storage/DiskStatsFileLogger.java [new file with mode: 0644]
services/core/java/com/android/server/storage/DiskStatsLoggingService.java [new file with mode: 0644]
services/core/java/com/android/server/storage/FileCollector.java
services/tests/servicestests/src/com/android/server/storage/DiskStatsFileLoggerTest.java [new file with mode: 0644]
services/tests/servicestests/src/com/android/server/storage/DiskStatsLoggingServiceTest.java [new file with mode: 0644]

index 632bcfe..dc09e64 100644 (file)
                  android:permission="android.permission.BIND_JOB_SERVICE" >
         </service>
 
-        <service
-            android:name="com.android.server.pm.BackgroundDexOptService"
-            android:exported="true"
-            android:permission="android.permission.BIND_JOB_SERVICE">
+        <service android:name="com.android.server.pm.BackgroundDexOptService"
+                 android:exported="true"
+                 android:permission="android.permission.BIND_JOB_SERVICE">
+        </service>
+
+        <service android:name="com.android.server.storage.DiskStatsLoggingService"
+                 android:permission="android.permission.BIND_JOB_SERVICE" >
         </service>
 
     </application>
index 8ca675a..dd95f67 100644 (file)
@@ -22,6 +22,15 @@ import android.os.Environment;
 import android.os.StatFs;
 import android.os.SystemClock;
 import android.os.storage.StorageManager;
+import android.util.Log;
+
+import com.android.server.storage.DiskStatsFileLogger;
+import com.android.server.storage.DiskStatsLoggingService;
+
+import libcore.io.IoUtils;
+
+import org.json.JSONException;
+import org.json.JSONObject;
 
 import java.io.File;
 import java.io.FileDescriptor;
@@ -35,11 +44,13 @@ import java.io.PrintWriter;
  */
 public class DiskStatsService extends Binder {
     private static final String TAG = "DiskStatsService";
+    private static final String DISKSTATS_DUMP_FILE = "/data/system/diskstats_cache.json";
 
     private final Context mContext;
 
     public DiskStatsService(Context context) {
         mContext = context;
+        DiskStatsLoggingService.schedule(context);
     }
 
     @Override
@@ -84,6 +95,10 @@ public class DiskStatsService extends Binder {
             pw.println("File-based Encryption: true");
         }
 
+        if (isCheckin(args)) {
+            reportCachedValues(pw);
+        }
+
         // TODO: Read /proc/yaffs and report interesting values;
         // add configurable (through args) performance test parameters.
     }
@@ -114,4 +129,45 @@ public class DiskStatsService extends Binder {
             return;
         }
     }
+
+    private boolean isCheckin(String[] args) {
+        for (String opt : args) {
+            if ("--checkin".equals(opt)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private void reportCachedValues(PrintWriter pw) {
+        try {
+            String jsonString = IoUtils.readFileAsString(DISKSTATS_DUMP_FILE);
+            JSONObject json = new JSONObject(jsonString);
+            pw.print("App Size: ");
+            pw.println(json.getLong(DiskStatsFileLogger.APP_SIZE_AGG_KEY));
+            pw.print("App Cache Size: ");
+            pw.println(json.getLong(DiskStatsFileLogger.APP_CACHE_AGG_KEY));
+            pw.print("Photos Size: ");
+            pw.println(json.getLong(DiskStatsFileLogger.PHOTOS_KEY));
+            pw.print("Videos Size: ");
+            pw.println(json.getLong(DiskStatsFileLogger.VIDEOS_KEY));
+            pw.print("Audio Size: ");
+            pw.println(json.getLong(DiskStatsFileLogger.AUDIO_KEY));
+            pw.print("Downloads Size: ");
+            pw.println(json.getLong(DiskStatsFileLogger.DOWNLOADS_KEY));
+            pw.print("System Size: ");
+            pw.println(json.getLong(DiskStatsFileLogger.SYSTEM_KEY));
+            pw.print("Other Size: ");
+            pw.println(json.getLong(DiskStatsFileLogger.MISC_KEY));
+            pw.print("Package Names: ");
+            pw.println(json.getJSONArray(DiskStatsFileLogger.PACKAGE_NAMES_KEY));
+            pw.print("App Sizes: ");
+            pw.println(json.getJSONArray(DiskStatsFileLogger.APP_SIZES_KEY));
+            pw.print("Cache Sizes: ");
+            pw.println(json.getJSONArray(DiskStatsFileLogger.APP_CACHES_KEY));
+        } catch (IOException | JSONException e) {
+            Log.w(TAG, "exception reading diskstats cache file", e);
+        }
+    }
+
 }
diff --git a/services/core/java/com/android/server/storage/DiskStatsFileLogger.java b/services/core/java/com/android/server/storage/DiskStatsFileLogger.java
new file mode 100644 (file)
index 0000000..22299df
--- /dev/null
@@ -0,0 +1,166 @@
+/*
+ * 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/LICENSE2.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.server.storage;
+
+import android.content.pm.PackageStats;
+import android.os.Environment;
+import android.util.ArrayMap;
+import android.util.Log;
+
+import com.android.server.storage.FileCollector.MeasurementResult;
+
+import org.json.JSONArray;
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.io.PrintWriter;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * DiskStatsFileLogger logs collected storage information to a file in a JSON format.
+ *
+ * The following information is cached in the file:
+ * 1. Size of images on disk.
+ * 2. Size of videos on disk.
+ * 3. Size of audio on disk.
+ * 4. Size of the downloads folder.
+ * 5. System size.
+ * 6. Aggregate and individual app and app cache sizes.
+ * 7. How much storage couldn't be categorized in one of the above categories.
+ */
+public class DiskStatsFileLogger {
+    private static final String TAG = "DiskStatsLogger";
+
+    public static final String PHOTOS_KEY = "photosSize";
+    public static final String VIDEOS_KEY = "videosSize";
+    public static final String AUDIO_KEY = "audioSize";
+    public static final String DOWNLOADS_KEY = "downloadsSize";
+    public static final String SYSTEM_KEY = "systemSize";
+    public static final String MISC_KEY = "otherSize";
+    public static final String APP_SIZE_AGG_KEY = "appSize";
+    public static final String APP_CACHE_AGG_KEY = "cacheSize";
+    public static final String PACKAGE_NAMES_KEY = "packageNames";
+    public static final String APP_SIZES_KEY = "appSizes";
+    public static final String APP_CACHES_KEY = "cacheSizes";
+    public static final String LAST_QUERY_TIMESTAMP_KEY = "queryTime";
+
+    private MeasurementResult mResult;
+    private long mDownloadsSize;
+    private long mSystemSize;
+    private List<PackageStats> mPackageStats;
+
+    /**
+     * Constructs a DiskStatsFileLogger with calculated measurement results.
+     */
+    public DiskStatsFileLogger(MeasurementResult result, MeasurementResult downloadsResult,
+            List<PackageStats> stats, long systemSize) {
+        mResult = result;
+        mDownloadsSize = downloadsResult.totalAccountedSize();
+        mSystemSize = systemSize;
+        mPackageStats = stats;
+    }
+
+    /**
+     * Dumps the storage collection output to a file.
+     * @param file File to write the output into.
+     * @throws FileNotFoundException
+     */
+    public void dumpToFile(File file) throws FileNotFoundException {
+        PrintWriter pw = new PrintWriter(file);
+        JSONObject representation = getJsonRepresentation();
+        if (representation != null) {
+            pw.println(representation);
+        }
+        pw.close();
+    }
+
+    private JSONObject getJsonRepresentation() {
+        JSONObject json = new JSONObject();
+        try {
+            json.put(LAST_QUERY_TIMESTAMP_KEY, System.currentTimeMillis());
+            json.put(PHOTOS_KEY, mResult.imagesSize);
+            json.put(VIDEOS_KEY, mResult.videosSize);
+            json.put(AUDIO_KEY, mResult.audioSize);
+            json.put(DOWNLOADS_KEY, mDownloadsSize);
+            json.put(SYSTEM_KEY, mSystemSize);
+            json.put(MISC_KEY, mResult.miscSize);
+            addAppsToJson(json);
+        } catch (JSONException e) {
+            Log.e(TAG, e.toString());
+            return null;
+        }
+
+        return json;
+    }
+
+    private void addAppsToJson(JSONObject json) throws JSONException {
+        JSONArray names = new JSONArray();
+        JSONArray appSizeList = new JSONArray();
+        JSONArray cacheSizeList = new JSONArray();
+
+        long appSizeSum = 0L;
+        long cacheSizeSum = 0L;
+        boolean isExternal = Environment.isExternalStorageEmulated();
+        for (Map.Entry<String, PackageStats> entry : mergePackagesAcrossUsers().entrySet()) {
+            PackageStats stat = entry.getValue();
+            long appSize = stat.codeSize + stat.dataSize;
+            long cacheSize = stat.cacheSize;
+            if (isExternal) {
+                appSize += stat.externalCodeSize + stat.externalDataSize;
+                cacheSize += stat.externalCacheSize;
+            }
+            appSizeSum += appSize;
+            cacheSizeSum += cacheSize;
+
+            names.put(stat.packageName);
+            appSizeList.put(appSize);
+            cacheSizeList.put(cacheSize);
+        }
+        json.put(PACKAGE_NAMES_KEY, names);
+        json.put(APP_SIZES_KEY, appSizeList);
+        json.put(APP_CACHES_KEY, cacheSizeList);
+        json.put(APP_SIZE_AGG_KEY, appSizeSum);
+        json.put(APP_CACHE_AGG_KEY, cacheSizeSum);
+    }
+
+    /**
+     * A given package may exist for multiple users with distinct sizes. This function merges
+     * the duplicated packages together and sums up their sizes to get the actual totals for the
+     * package.
+     * @return A mapping of package name to merged package stats.
+     */
+    private ArrayMap<String, PackageStats> mergePackagesAcrossUsers() {
+        ArrayMap<String, PackageStats> packageMap = new ArrayMap<>();
+        for (PackageStats stat : mPackageStats) {
+            PackageStats existingStats = packageMap.get(stat.packageName);
+            if (existingStats != null) {
+                existingStats.cacheSize += stat.cacheSize;
+                existingStats.codeSize += stat.codeSize;
+                existingStats.dataSize += stat.dataSize;
+                existingStats.externalCacheSize += stat.externalCacheSize;
+                existingStats.externalCodeSize += stat.externalCodeSize;
+                existingStats.externalDataSize += stat.externalDataSize;
+            } else {
+                packageMap.put(stat.packageName, new PackageStats(stat));
+            }
+        }
+        return packageMap;
+    }
+}
\ No newline at end of file
diff --git a/services/core/java/com/android/server/storage/DiskStatsLoggingService.java b/services/core/java/com/android/server/storage/DiskStatsLoggingService.java
new file mode 100644 (file)
index 0000000..4a86175
--- /dev/null
@@ -0,0 +1,170 @@
+/*
+ * 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/LICENSE2.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.server.storage;
+
+import android.app.job.JobInfo;
+import android.app.job.JobParameters;
+import android.app.job.JobScheduler;
+import android.app.job.JobService;
+import android.content.ComponentName;
+import android.content.Context;
+import android.content.pm.PackageStats;
+import android.os.AsyncTask;
+import android.os.BatteryManager;
+import android.os.Environment;
+import android.os.Environment.UserEnvironment;
+import android.os.UserHandle;
+import android.util.Log;
+
+import com.android.internal.annotations.VisibleForTesting;
+import com.android.server.storage.FileCollector.MeasurementResult;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * DiskStatsLoggingService is a JobService which collects storage categorization information and
+ * app size information on a roughly daily cadence.
+ */
+public class DiskStatsLoggingService extends JobService {
+    private static final String TAG = "DiskStatsLogService";
+    public static final String DUMPSYS_CACHE_PATH = "/data/system/diskstats_cache.json";
+    private static final int JOB_DISKSTATS_LOGGING = 0x4449534b; // DISK
+    private static ComponentName sDiskStatsLoggingService = new ComponentName(
+            "android",
+            DiskStatsLoggingService.class.getName());
+
+    @Override
+    public boolean onStartJob(JobParameters params) {
+        // We need to check the preconditions again because they may not be enforced for
+        // subsequent runs.
+        if (!isCharging(this)) {
+            jobFinished(params, true);
+            return false;
+        }
+
+        final int userId = UserHandle.myUserId();
+        UserEnvironment environment = new UserEnvironment(userId);
+        AppCollector collector = new AppCollector(this,
+                getPackageManager().getPrimaryStorageCurrentVolume());
+        LogRunnable task = new LogRunnable();
+        task.setRootDirectory(environment.getExternalStorageDirectory());
+        task.setDownloadsDirectory(
+                environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS));
+        task.setSystemSize(FileCollector.getSystemSize(this));
+        task.setLogOutputFile(new File(DUMPSYS_CACHE_PATH));
+        task.setAppCollector(collector);
+        task.setJobService(this, params);
+        AsyncTask.execute(task);
+        return true;
+    }
+
+    @Override
+    public boolean onStopJob(JobParameters params) {
+        // TODO: Try to stop being handled.
+        return false;
+    }
+
+    /**
+     * Schedules a DiskStats collection task. This task only runs on device idle while charging
+     * once every 24 hours.
+     * @param context Context to use to get a job scheduler.
+     */
+    public static void schedule(Context context) {
+        JobScheduler js = (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE);
+
+        js.schedule(new JobInfo.Builder(JOB_DISKSTATS_LOGGING, sDiskStatsLoggingService)
+                .setRequiresDeviceIdle(true)
+                .setRequiresCharging(true)
+                .setPeriodic(TimeUnit.DAYS.toMillis(1))
+                .build());
+    }
+
+    private static boolean isCharging(Context context) {
+        BatteryManager batteryManager = context.getSystemService(BatteryManager.class);
+        if (batteryManager != null) {
+            return batteryManager.isCharging();
+        }
+        return false;
+    }
+
+    @VisibleForTesting
+    static class LogRunnable implements Runnable {
+        private static final long TIMEOUT_MILLIS = TimeUnit.MINUTES.toMillis(5);
+
+        private JobService mJobService;
+        private JobParameters mParams;
+        private AppCollector mCollector;
+        private File mOutputFile;
+        private File mRootDirectory;
+        private File mDownloadsDirectory;
+        private long mSystemSize;
+
+        public void setRootDirectory(File file) {
+            mRootDirectory = file;
+        }
+
+        public void setDownloadsDirectory(File file) {
+            mDownloadsDirectory = file;
+        }
+
+        public void setAppCollector(AppCollector collector) {
+            mCollector = collector;
+        }
+
+        public void setLogOutputFile(File file) {
+            mOutputFile = file;
+        }
+
+        public void setSystemSize(long size) {
+            mSystemSize = size;
+        }
+
+        public void setJobService(JobService jobService, JobParameters params) {
+            mJobService = jobService;
+            mParams = params;
+        }
+
+        public void run() {
+            FileCollector.MeasurementResult mainCategories =
+                    FileCollector.getMeasurementResult(mRootDirectory);
+            FileCollector.MeasurementResult downloads =
+                    FileCollector.getMeasurementResult(mDownloadsDirectory);
+
+            logToFile(mainCategories, downloads, mCollector.getPackageStats(TIMEOUT_MILLIS),
+                    mSystemSize);
+
+            if (mJobService != null) {
+                mJobService.jobFinished(mParams, false);
+            }
+        }
+
+        private void logToFile(MeasurementResult mainCategories, MeasurementResult downloads,
+                List<PackageStats> stats, long systemSize) {
+            DiskStatsFileLogger logger = new DiskStatsFileLogger(mainCategories, downloads, stats,
+                    systemSize);
+            try {
+                mOutputFile.createNewFile();
+                logger.dumpToFile(mOutputFile);
+            } catch (IOException e) {
+                Log.e(TAG, "Exception while writing opportunistic disk file cache.", e);
+            }
+        }
+    }
+}
\ No newline at end of file
index b96eb69..90f9f13 100644 (file)
 package com.android.server.storage;
 
 import android.annotation.IntDef;
+import android.content.Context;
+import android.content.pm.PackageManager;
 import android.os.storage.StorageManager;
+import android.os.storage.VolumeInfo;
 import android.util.ArrayMap;
 
 import java.io.File;
@@ -150,6 +153,32 @@ public class FileCollector {
                 new MeasurementResult());
     }
 
+    /**
+     * Returns the size of a system for a given context. This is done by finding the difference
+     * between the shared data and the total primary storage size.
+     * @param context Context to use to get storage information.
+     */
+    public static long getSystemSize(Context context) {
+        PackageManager pm = context.getPackageManager();
+        VolumeInfo primaryVolume = pm.getPrimaryStorageCurrentVolume();
+
+        StorageManager sm = context.getSystemService(StorageManager.class);
+        VolumeInfo shared = sm.findEmulatedForPrivate(primaryVolume);
+        if (shared == null) {
+            return 0;
+        }
+
+        final long sharedDataSize = shared.getPath().getTotalSpace();
+        long systemSize = sm.getPrimaryStorageSize() - sharedDataSize;
+
+        // This case is not exceptional -- we just fallback to the shared data volume in this case.
+        if (systemSize <= 0) {
+            return 0;
+        }
+
+        return systemSize;
+    }
+
     private static MeasurementResult collectFiles(File file, MeasurementResult result) {
         File[] files = file.listFiles();
 
diff --git a/services/tests/servicestests/src/com/android/server/storage/DiskStatsFileLoggerTest.java b/services/tests/servicestests/src/com/android/server/storage/DiskStatsFileLoggerTest.java
new file mode 100644 (file)
index 0000000..2aca702
--- /dev/null
@@ -0,0 +1,239 @@
+/*
+ * 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.
+ */
+
+package com.android.server.storage;
+
+import android.content.pm.PackageStats;
+import android.test.AndroidTestCase;
+import android.util.ArraySet;
+import libcore.io.IoUtils;
+import org.json.JSONArray;
+import org.json.JSONObject;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+
+import java.io.File;
+import java.util.ArrayList;
+
+import static com.google.common.truth.Truth.assertThat;
+
+@RunWith(JUnit4.class)
+public class DiskStatsFileLoggerTest extends AndroidTestCase {
+    @Rule public TemporaryFolder temporaryFolder;
+    public FileCollector.MeasurementResult mMainResult;
+    public FileCollector.MeasurementResult mDownloadsResult;
+    private ArrayList<PackageStats> mPackages;
+    private File mOutputFile;
+
+    @Before
+    public void setUp() throws Exception {
+        super.setUp();
+        temporaryFolder = new TemporaryFolder();
+        temporaryFolder.create();
+        mOutputFile = temporaryFolder.newFile();
+        mMainResult = new FileCollector.MeasurementResult();
+        mDownloadsResult = new FileCollector.MeasurementResult();
+        mPackages = new ArrayList<>();
+    }
+
+    @Test
+    public void testEmptyStorage() throws Exception {
+        DiskStatsFileLogger logger = new DiskStatsFileLogger(
+                mMainResult, mDownloadsResult,mPackages, 0L);
+
+        logger.dumpToFile(mOutputFile);
+
+        JSONObject output = getOutputFileAsJson();
+        assertThat(output.getLong(DiskStatsFileLogger.PHOTOS_KEY)).isEqualTo(0L);
+        assertThat(output.getLong(DiskStatsFileLogger.VIDEOS_KEY)).isEqualTo(0L);
+        assertThat(output.getLong(DiskStatsFileLogger.AUDIO_KEY)).isEqualTo(0L);
+        assertThat(output.getLong(DiskStatsFileLogger.DOWNLOADS_KEY)).isEqualTo(0L);
+        assertThat(output.getLong(DiskStatsFileLogger.SYSTEM_KEY)).isEqualTo(0L);
+        assertThat(output.getLong(DiskStatsFileLogger.MISC_KEY)).isEqualTo(0L);
+        assertThat(output.getLong(DiskStatsFileLogger.APP_SIZE_AGG_KEY)).isEqualTo(0L);
+        assertThat(output.getLong(DiskStatsFileLogger.APP_CACHE_AGG_KEY)).isEqualTo(0L);
+        assertThat(
+                output.getJSONArray(DiskStatsFileLogger.PACKAGE_NAMES_KEY).length()).isEqualTo(0L);
+        assertThat(output.getJSONArray(DiskStatsFileLogger.APP_SIZES_KEY).length()).isEqualTo(0L);
+        assertThat(output.getJSONArray(DiskStatsFileLogger.APP_CACHES_KEY).length()).isEqualTo(0L);
+    }
+
+    @Test
+    public void testMeasurementResultsReported() throws Exception {
+        mMainResult.audioSize = 1;
+        mMainResult.imagesSize = 10;
+        mMainResult.miscSize = 100;
+        mDownloadsResult.miscSize = 1000;
+        DiskStatsFileLogger logger = new DiskStatsFileLogger(
+                mMainResult, mDownloadsResult,mPackages, 3L);
+
+        logger.dumpToFile(mOutputFile);
+
+        JSONObject output = getOutputFileAsJson();
+        assertThat(output.getLong(DiskStatsFileLogger.AUDIO_KEY)).isEqualTo(1L);
+        assertThat(output.getLong(DiskStatsFileLogger.PHOTOS_KEY)).isEqualTo(10L);
+        assertThat(output.getLong(DiskStatsFileLogger.MISC_KEY)).isEqualTo(100L);
+        assertThat(output.getLong(DiskStatsFileLogger.DOWNLOADS_KEY)).isEqualTo(1000L);
+        assertThat(output.getLong(DiskStatsFileLogger.SYSTEM_KEY)).isEqualTo(3L);
+    }
+
+    @Test
+    public void testAppsReported() throws Exception {
+        PackageStats firstPackage = new PackageStats("com.test.app");
+        firstPackage.codeSize = 100;
+        firstPackage.dataSize = 1000;
+        firstPackage.cacheSize = 20;
+        mPackages.add(firstPackage);
+
+        PackageStats secondPackage = new PackageStats("com.test.app2");
+        secondPackage.codeSize = 10;
+        secondPackage.dataSize = 1;
+        secondPackage.cacheSize = 2;
+        mPackages.add(secondPackage);
+
+        DiskStatsFileLogger logger = new DiskStatsFileLogger(
+                mMainResult, mDownloadsResult, mPackages, 0L);
+        logger.dumpToFile(mOutputFile);
+
+        JSONObject output = getOutputFileAsJson();
+        assertThat(output.getLong(DiskStatsFileLogger.APP_SIZE_AGG_KEY)).isEqualTo(1111);
+        assertThat(output.getLong(DiskStatsFileLogger.APP_CACHE_AGG_KEY)).isEqualTo(22);
+
+        JSONArray packageNames = output.getJSONArray(DiskStatsFileLogger.PACKAGE_NAMES_KEY);
+        assertThat(packageNames.length()).isEqualTo(2);
+        JSONArray appSizes = output.getJSONArray(DiskStatsFileLogger.APP_SIZES_KEY);
+        assertThat(appSizes.length()).isEqualTo(2);
+        JSONArray cacheSizes = output.getJSONArray(DiskStatsFileLogger.APP_CACHES_KEY);
+        assertThat(cacheSizes.length()).isEqualTo(2);
+
+        // We need to do this crazy Set over this because the DiskStatsFileLogger provides no
+        // guarantee of the ordering of the apps in its output. By using a set, we avoid any order
+        // problems.
+        ArraySet<AppSizeGrouping> apps = new ArraySet<>();
+        for (int i = 0; i < packageNames.length(); i++) {
+            AppSizeGrouping app = new AppSizeGrouping(packageNames.getString(i),
+                    appSizes.getLong(i), cacheSizes.getLong(i));
+            apps.add(app);
+        }
+        assertThat(apps).containsAllOf(new AppSizeGrouping("com.test.app", 1100, 20),
+                new AppSizeGrouping("com.test.app2", 11, 2));
+    }
+
+    @Test
+    public void testEmulatedExternalStorageCounted() throws Exception {
+        PackageStats app = new PackageStats("com.test.app");
+        app.dataSize = 1000;
+        app.externalDataSize = 1000;
+        app.cacheSize = 20;
+        mPackages.add(app);
+
+        DiskStatsFileLogger logger = new DiskStatsFileLogger(
+                mMainResult, mDownloadsResult, mPackages, 0L);
+        logger.dumpToFile(mOutputFile);
+
+        JSONObject output = getOutputFileAsJson();
+        JSONArray appSizes = output.getJSONArray(DiskStatsFileLogger.APP_SIZES_KEY);
+        assertThat(appSizes.length()).isEqualTo(1);
+        assertThat(appSizes.getLong(0)).isEqualTo(2000);
+    }
+
+    @Test
+    public void testDuplicatePackageNameIsMergedAcrossMultipleUsers() throws Exception {
+        PackageStats app = new PackageStats("com.test.app");
+        app.dataSize = 1000;
+        app.externalDataSize = 1000;
+        app.cacheSize = 20;
+        app.userHandle = 0;
+        mPackages.add(app);
+
+        PackageStats secondApp = new PackageStats("com.test.app");
+        secondApp.dataSize = 100;
+        secondApp.externalDataSize = 100;
+        secondApp.cacheSize = 2;
+        secondApp.userHandle = 1;
+        mPackages.add(secondApp);
+
+        DiskStatsFileLogger logger = new DiskStatsFileLogger(
+                mMainResult, mDownloadsResult, mPackages, 0L);
+        logger.dumpToFile(mOutputFile);
+
+        JSONObject output = getOutputFileAsJson();
+        assertThat(output.getLong(DiskStatsFileLogger.APP_SIZE_AGG_KEY)).isEqualTo(2200);
+        assertThat(output.getLong(DiskStatsFileLogger.APP_CACHE_AGG_KEY)).isEqualTo(22);
+        JSONArray packageNames = output.getJSONArray(DiskStatsFileLogger.PACKAGE_NAMES_KEY);
+        assertThat(packageNames.length()).isEqualTo(1);
+        assertThat(packageNames.getString(0)).isEqualTo("com.test.app");
+
+        JSONArray appSizes = output.getJSONArray(DiskStatsFileLogger.APP_SIZES_KEY);
+        assertThat(appSizes.length()).isEqualTo(1);
+        assertThat(appSizes.getLong(0)).isEqualTo(2200);
+
+        JSONArray cacheSizes = output.getJSONArray(DiskStatsFileLogger.APP_CACHES_KEY);
+        assertThat(cacheSizes.length()).isEqualTo(1);
+        assertThat(cacheSizes.getLong(0)).isEqualTo(22);
+    }
+
+    private JSONObject getOutputFileAsJson() throws Exception {
+        return new JSONObject(IoUtils.readFileAsString(mOutputFile.getAbsolutePath()));
+    }
+
+    /**
+     * This class exists for putting zipped app size information arrays into a set for comparison
+     * purposes.
+     */
+    private class AppSizeGrouping {
+        public String packageName;
+        public long appSize;
+        public long cacheSize;
+
+        public AppSizeGrouping(String packageName, long appSize, long cacheSize) {
+            this.packageName = packageName;
+            this.appSize = appSize;
+            this.cacheSize = cacheSize;
+        }
+
+        @Override
+        public int hashCode() {
+            int result = 17;
+            result = 37 * result + (int)(appSize ^ (appSize >>> 32));
+            result = 37 * result + (int)(cacheSize ^ (cacheSize >>> 32));
+            result = 37 * result + packageName.hashCode();
+            return result;
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (!(o instanceof AppSizeGrouping)) {
+                return false;
+            }
+            if (this == o) {
+                return true;
+            }
+            AppSizeGrouping grouping = (AppSizeGrouping) o;
+            return packageName.equals(grouping.packageName) && appSize == grouping.appSize &&
+                    cacheSize == grouping.cacheSize;
+        }
+
+        @Override
+        public String toString() {
+            return packageName + " " + appSize + " " + cacheSize;
+        }
+    }
+}
\ No newline at end of file
diff --git a/services/tests/servicestests/src/com/android/server/storage/DiskStatsLoggingServiceTest.java b/services/tests/servicestests/src/com/android/server/storage/DiskStatsLoggingServiceTest.java
new file mode 100644 (file)
index 0000000..357ce74
--- /dev/null
@@ -0,0 +1,142 @@
+/*
+ * 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.
+ */
+
+package com.android.server.storage;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import static org.mockito.Matchers.anyInt;
+import static org.mockito.Mockito.when;
+
+import android.content.pm.PackageStats;
+import android.test.AndroidTestCase;
+
+import com.android.server.storage.DiskStatsLoggingService.LogRunnable;
+
+import libcore.io.IoUtils;
+
+import org.json.JSONObject;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Mock;
+import org.mockito.MockitoAnnotations;
+
+import java.io.File;
+import java.io.PrintStream;
+import java.util.ArrayList;
+
+@RunWith(JUnit4.class)
+public class DiskStatsLoggingServiceTest extends AndroidTestCase {
+    @Rule public TemporaryFolder mTemporaryFolder;
+    @Rule public TemporaryFolder mDownloads;
+    @Rule public TemporaryFolder mRootDirectory;
+    @Mock private AppCollector mCollector;
+    private File mInputFile;
+
+
+    @Before
+    public void setUp() throws Exception {
+        super.setUp();
+        MockitoAnnotations.initMocks(this);
+        mTemporaryFolder = new TemporaryFolder();
+        mTemporaryFolder.create();
+        mInputFile = mTemporaryFolder.newFile();
+        mDownloads = new TemporaryFolder();
+        mDownloads.create();
+        mRootDirectory = new TemporaryFolder();
+        mRootDirectory.create();
+    }
+
+    @Test
+    public void testEmptyLog() throws Exception {
+        LogRunnable task = new LogRunnable();
+        task.setAppCollector(mCollector);
+        task.setDownloadsDirectory(mDownloads.getRoot());
+        task.setRootDirectory(mRootDirectory.getRoot());
+        task.setLogOutputFile(mInputFile);
+        task.setSystemSize(0L);
+        task.run();
+
+        JSONObject json = getJsonOutput();
+        assertThat(json.getLong(DiskStatsFileLogger.PHOTOS_KEY)).isEqualTo(0L);
+        assertThat(json.getLong(DiskStatsFileLogger.VIDEOS_KEY)).isEqualTo(0L);
+        assertThat(json.getLong(DiskStatsFileLogger.AUDIO_KEY)).isEqualTo(0L);
+        assertThat(json.getLong(DiskStatsFileLogger.DOWNLOADS_KEY)).isEqualTo(0L);
+        assertThat(json.getLong(DiskStatsFileLogger.SYSTEM_KEY)).isEqualTo(0L);
+        assertThat(json.getLong(DiskStatsFileLogger.MISC_KEY)).isEqualTo(0L);
+        assertThat(json.getLong(DiskStatsFileLogger.APP_SIZE_AGG_KEY)).isEqualTo(0L);
+        assertThat(json.getLong(DiskStatsFileLogger.APP_CACHE_AGG_KEY)).isEqualTo(0L);
+        assertThat(
+                json.getJSONArray(DiskStatsFileLogger.PACKAGE_NAMES_KEY).length()).isEqualTo(0L);
+        assertThat(json.getJSONArray(DiskStatsFileLogger.APP_SIZES_KEY).length()).isEqualTo(0L);
+        assertThat(json.getJSONArray(DiskStatsFileLogger.APP_CACHES_KEY).length()).isEqualTo(0L);
+    }
+
+    @Test
+    public void testPopulatedLogTask() throws Exception {
+        // Write data to directories.
+        writeDataToFile(mDownloads.newFile(), "lol");
+        writeDataToFile(mRootDirectory.newFile("test.jpg"), "1234");
+        writeDataToFile(mRootDirectory.newFile("test.mp4"), "12345");
+        writeDataToFile(mRootDirectory.newFile("test.mp3"), "123456");
+        writeDataToFile(mRootDirectory.newFile("test.whatever"), "1234567");
+
+        // Write apps.
+        ArrayList<PackageStats> apps = new ArrayList<>();
+        PackageStats testApp = new PackageStats("com.test.app");
+        testApp.dataSize = 5L;
+        testApp.cacheSize = 55L;
+        testApp.codeSize = 10L;
+        apps.add(testApp);
+        when(mCollector.getPackageStats(anyInt())).thenReturn(apps);
+
+        LogRunnable task = new LogRunnable();
+        task.setAppCollector(mCollector);
+        task.setDownloadsDirectory(mDownloads.getRoot());
+        task.setRootDirectory(mRootDirectory.getRoot());
+        task.setLogOutputFile(mInputFile);
+        task.setSystemSize(10L);
+        task.run();
+
+        JSONObject json = getJsonOutput();
+        assertThat(json.getLong(DiskStatsFileLogger.PHOTOS_KEY)).isEqualTo(4L);
+        assertThat(json.getLong(DiskStatsFileLogger.VIDEOS_KEY)).isEqualTo(5L);
+        assertThat(json.getLong(DiskStatsFileLogger.AUDIO_KEY)).isEqualTo(6L);
+        assertThat(json.getLong(DiskStatsFileLogger.DOWNLOADS_KEY)).isEqualTo(3L);
+        assertThat(json.getLong(DiskStatsFileLogger.SYSTEM_KEY)).isEqualTo(10L);
+        assertThat(json.getLong(DiskStatsFileLogger.MISC_KEY)).isEqualTo(7L);
+        assertThat(json.getLong(DiskStatsFileLogger.APP_SIZE_AGG_KEY)).isEqualTo(15L);
+        assertThat(json.getLong(DiskStatsFileLogger.APP_CACHE_AGG_KEY)).isEqualTo(55L);
+        assertThat(
+                json.getJSONArray(DiskStatsFileLogger.PACKAGE_NAMES_KEY).length()).isEqualTo(1L);
+        assertThat(json.getJSONArray(DiskStatsFileLogger.APP_SIZES_KEY).length()).isEqualTo(1L);
+        assertThat(json.getJSONArray(DiskStatsFileLogger.APP_CACHES_KEY).length()).isEqualTo(1L);
+    }
+
+    private void writeDataToFile(File f, String data) throws Exception{
+        PrintStream out = new PrintStream(f);
+        out.print(data);
+        out.close();
+    }
+
+    private JSONObject getJsonOutput() throws Exception {
+        return new JSONObject(IoUtils.readFileAsString(mInputFile.getAbsolutePath()));
+    }
+}