From 16f97125d71f8e18534fc80cc19f826b93a4d736 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Dominik=20Sch=C3=BCrmann?= <dominik@dominikschuermann.de>
Date: Wed, 8 Jun 2016 15:46:04 +0200
Subject: [PATCH] Provide content Uris via FileProvider

* moves apk verification back inside the Installer class
* uses support libs FileProvider for content Uris
* move apk file caching and storage methods into
ApkCache class
---
 app/src/main/AndroidManifest.xml              |  10 ++
 .../java/org/fdroid/fdroid/AppDetails.java    |   4 +-
 .../org/fdroid/fdroid/CleanCacheService.java  |   3 +-
 .../main/java/org/fdroid/fdroid/Utils.java    |  54 ------
 .../org/fdroid/fdroid/installer/ApkCache.java | 165 ++++++++++++++++++
 .../fdroid/installer/ApkFileProvider.java     |  76 ++++++++
 .../fdroid/fdroid/installer/ApkVerifier.java  |  69 --------
 .../fdroid/installer/DefaultInstaller.java    |  12 +-
 .../installer/DefaultInstallerActivity.java   |  50 ++++--
 .../fdroid/installer/ExtensionInstaller.java  |   8 +-
 .../installer/InstallManagerService.java      |  37 +---
 .../fdroid/fdroid/installer/Installer.java    |  65 ++++---
 .../fdroid/installer/InstallerFactory.java    |  18 +-
 .../fdroid/installer/InstallerService.java    |  18 +-
 .../fdroid/installer/PrivilegedInstaller.java |   8 +-
 .../fdroid/fdroid/net/DownloaderService.java  |   3 +-
 app/src/main/res/xml/apk_file_provider.xml    |   6 +
 .../java/org/fdroid/fdroid/UtilsTest.java     |  40 -----
 .../fdroid/fdroid/installer/ApkCacheTest.java |  56 ++++++
 19 files changed, 445 insertions(+), 257 deletions(-)
 create mode 100644 app/src/main/java/org/fdroid/fdroid/installer/ApkCache.java
 create mode 100644 app/src/main/java/org/fdroid/fdroid/installer/ApkFileProvider.java
 create mode 100644 app/src/main/res/xml/apk_file_provider.xml
 create mode 100644 app/src/test/java/org/fdroid/fdroid/installer/ApkCacheTest.java

diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b5685b014..41acae8d8 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -95,6 +95,16 @@
             android:name="org.fdroid.fdroid.data.InstalledAppProvider"
             android:exported="false"/>
 
+        <provider
+            android:name="org.fdroid.fdroid.installer.ApkFileProvider"
+            android:authorities="org.fdroid.fdroid.installer.ApkFileProvider"
+            android:exported="false"
+            android:grantUriPermissions="true">
+            <meta-data
+                android:name="android.support.FILE_PROVIDER_PATHS"
+                android:resource="@xml/apk_file_provider" />
+        </provider>
+
         <meta-data
             android:name="android.app.default_searchable"
             android:value=".FDroid" />
diff --git a/app/src/main/java/org/fdroid/fdroid/AppDetails.java b/app/src/main/java/org/fdroid/fdroid/AppDetails.java
index 69b03523e..c96738ef2 100644
--- a/app/src/main/java/org/fdroid/fdroid/AppDetails.java
+++ b/app/src/main/java/org/fdroid/fdroid/AppDetails.java
@@ -971,7 +971,7 @@ public class AppDetails extends AppCompatActivity {
     }
 
     private void initiateInstall(Apk apk) {
-        Installer installer = InstallerFactory.create(this, apk.packageName);
+        Installer installer = InstallerFactory.create(this, apk);
         Intent intent = installer.getPermissionScreen(apk);
         if (intent != null) {
             // permission screen required
@@ -990,7 +990,7 @@ public class AppDetails extends AppCompatActivity {
     }
 
     private void uninstallApk(String packageName) {
-        Installer installer = InstallerFactory.create(this, packageName);
+        Installer installer = InstallerFactory.create(this, null);
         Intent intent = installer.getUninstallScreen(packageName);
         if (intent != null) {
             // uninstall screen required
diff --git a/app/src/main/java/org/fdroid/fdroid/CleanCacheService.java b/app/src/main/java/org/fdroid/fdroid/CleanCacheService.java
index cea679d9f..d51719917 100644
--- a/app/src/main/java/org/fdroid/fdroid/CleanCacheService.java
+++ b/app/src/main/java/org/fdroid/fdroid/CleanCacheService.java
@@ -9,6 +9,7 @@ import android.os.Process;
 import android.os.SystemClock;
 
 import org.apache.commons.io.FileUtils;
+import org.fdroid.fdroid.installer.ApkCache;
 
 import java.io.File;
 
@@ -51,7 +52,7 @@ public class CleanCacheService extends IntentService {
             return;
         }
         Process.setThreadPriority(Process.THREAD_PRIORITY_LOWEST);
-        Utils.clearOldFiles(Utils.getApkCacheDir(this), Preferences.get().getKeepCacheTime());
+        ApkCache.clearApkCache(this);
         deleteStrayIndexFiles();
         deleteOldInstallerFiles();
     }
diff --git a/app/src/main/java/org/fdroid/fdroid/Utils.java b/app/src/main/java/org/fdroid/fdroid/Utils.java
index 5e6d2f7ea..fb1c64415 100644
--- a/app/src/main/java/org/fdroid/fdroid/Utils.java
+++ b/app/src/main/java/org/fdroid/fdroid/Utils.java
@@ -33,9 +33,7 @@ import android.util.Log;
 import com.nostra13.universalimageloader.core.DisplayImageOptions;
 import com.nostra13.universalimageloader.core.assist.ImageScaleType;
 import com.nostra13.universalimageloader.core.display.FadeInBitmapDisplayer;
-import com.nostra13.universalimageloader.utils.StorageUtils;
 
-import org.apache.commons.io.FileUtils;
 import org.fdroid.fdroid.compat.FileCompat;
 import org.fdroid.fdroid.data.Repo;
 import org.fdroid.fdroid.data.SanitizedFile;
@@ -274,58 +272,6 @@ public final class Utils {
         return Uri.parse("package:" + packageName);
     }
 
-    /**
-     * This location is only for caching, do not install directly from this location
-     * because if the file is on the External Storage, any other app could swap out
-     * the APK while the install was in process, allowing malware to install things.
-     * Using {@link Installer#installPackage(File, String, String)}
-     * is fine since that does the right thing.
-     */
-    public static File getApkCacheDir(Context context) {
-        File apkCacheDir = new File(StorageUtils.getCacheDirectory(context, true), "apks");
-        if (apkCacheDir.isFile()) {
-            apkCacheDir.delete();
-        }
-        if (!apkCacheDir.exists()) {
-            apkCacheDir.mkdir();
-        }
-        return apkCacheDir;
-    }
-
-    /**
-     * Get the full path for where an APK URL will be downloaded into.
-     */
-    public static SanitizedFile getApkDownloadPath(Context context, Uri uri) {
-        File dir = new File(Utils.getApkCacheDir(context), uri.getHost() + "-" + uri.getPort());
-        if (!dir.exists()) {
-            dir.mkdirs();
-        }
-        return new SanitizedFile(dir, uri.getLastPathSegment());
-    }
-
-    /**
-     * Recursively delete files in {@code dir} that were last modified
-     * {@code secondsAgo} seconds ago, e.g. when it was downloaded.
-     *
-     * @param dir        The directory to recurse in
-     * @param secondsAgo The number of seconds old that marks a file for deletion.
-     */
-    public static void clearOldFiles(File dir, long secondsAgo) {
-        if (dir == null) {
-            return;
-        }
-        long olderThan = System.currentTimeMillis() - (secondsAgo * 1000L);
-        for (File f : dir.listFiles()) {
-            if (f.isDirectory()) {
-                clearOldFiles(f, olderThan);
-                f.delete();
-            }
-            if (FileUtils.isFileOlder(f, olderThan)) {
-                f.delete();
-            }
-        }
-    }
-
     public static String calcFingerprint(String keyHexString) {
         if (TextUtils.isEmpty(keyHexString)
                 || keyHexString.matches(".*[^a-fA-F0-9].*")) {
diff --git a/app/src/main/java/org/fdroid/fdroid/installer/ApkCache.java b/app/src/main/java/org/fdroid/fdroid/installer/ApkCache.java
new file mode 100644
index 000000000..b0dc8b3b8
--- /dev/null
+++ b/app/src/main/java/org/fdroid/fdroid/installer/ApkCache.java
@@ -0,0 +1,165 @@
+/*
+ * Copyright (C) 2016 Dominik Schürmann <dominik@dominikschuermann.de>
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 3
+ * of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
+ * MA 02110-1301, USA.
+ */
+
+package org.fdroid.fdroid.installer;
+
+import android.content.Context;
+import android.net.Uri;
+
+import com.nostra13.universalimageloader.utils.StorageUtils;
+
+import org.apache.commons.io.FileUtils;
+import org.fdroid.fdroid.Hasher;
+import org.fdroid.fdroid.Preferences;
+import org.fdroid.fdroid.data.Apk;
+import org.fdroid.fdroid.data.SanitizedFile;
+
+import java.io.File;
+import java.io.IOException;
+import java.security.NoSuchAlgorithmException;
+
+public class ApkCache {
+
+    private static final String CACHE_DIR = "apks";
+
+    /**
+     * Copy the APK to the safe location inside of the protected area
+     * of the app to prevent attacks based on other apps swapping the file
+     * out during the install process. Most likely, apkFile was just downloaded,
+     * so it should still be in the RAM disk cache.
+     */
+    public static SanitizedFile copyApkFromCacheToFiles(Context context, File apkFile, Apk expectedApk)
+            throws IOException {
+        SanitizedFile sanitizedApkFile = null;
+
+        try {
+            sanitizedApkFile = SanitizedFile.knownSanitized(
+                    File.createTempFile("install-", ".apk", context.getFilesDir()));
+            FileUtils.copyFile(apkFile, sanitizedApkFile);
+
+            // verify copied file's hash with expected hash from Apk class
+            if (!verifyApkFile(sanitizedApkFile, expectedApk.hash, expectedApk.hashType)) {
+                FileUtils.deleteQuietly(apkFile);
+                throw new IOException(apkFile + " failed to verify!");
+            }
+
+            return sanitizedApkFile;
+        } catch (NoSuchAlgorithmException e) {
+            throw new RuntimeException(e);
+        } finally {
+            // 20 minutes the start of the install process, delete the file
+            final File apkToDelete = sanitizedApkFile;
+            new Thread() {
+                @Override
+                public void run() {
+                    android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_LOWEST);
+                    try {
+                        Thread.sleep(1200000);
+                    } catch (InterruptedException ignored) {
+                    } finally {
+                        FileUtils.deleteQuietly(apkToDelete);
+                    }
+                }
+            }.start();
+        }
+    }
+
+    /**
+     * Checks the APK file against the provided hash, returning whether it is a match.
+     */
+    private static boolean verifyApkFile(File apkFile, String hash, String hashType)
+            throws NoSuchAlgorithmException {
+        if (!apkFile.exists()) {
+            return false;
+        }
+        Hasher hasher = new Hasher(hashType, apkFile);
+        return hasher.match(hash);
+    }
+
+    /**
+     * Get the full path for where an APK URL will be downloaded into.
+     */
+    public static SanitizedFile getApkDownloadPath(Context context, Uri uri) {
+        File dir = new File(getApkCacheDir(context), uri.getHost() + "-" + uri.getPort());
+        if (!dir.exists()) {
+            dir.mkdirs();
+        }
+        return new SanitizedFile(dir, uri.getLastPathSegment());
+    }
+
+    /**
+     * Verifies the size of the file on disk matches, and then hashes the file to compare with what
+     * we received from the signed repo (i.e. {@link Apk#hash} and {@link Apk#hashType}).
+     * Bails out if the file sizes don't match to prevent having to do the work of hashing the file.
+     */
+    public static boolean apkIsCached(File apkFile, Apk apkToCheck) {
+        try {
+            return apkFile.length() == apkToCheck.size &&
+                    verifyApkFile(apkFile, apkToCheck.hash, apkToCheck.hashType);
+        } catch (NoSuchAlgorithmException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    public static void clearApkCache(Context context) {
+        clearOldFiles(getApkCacheDir(context), Preferences.get().getKeepCacheTime());
+    }
+
+
+    /**
+     * This location is only for caching, do not install directly from this location
+     * because if the file is on the External Storage, any other app could swap out
+     * the APK while the install was in process, allowing malware to install things.
+     * Using {@link Installer#installPackage(Uri localApkUri, Uri downloadUri, String packageName)}
+     * is fine since that does the right thing.
+     */
+    private static File getApkCacheDir(Context context) {
+        File apkCacheDir = new File(StorageUtils.getCacheDirectory(context, true), CACHE_DIR);
+        if (apkCacheDir.isFile()) {
+            apkCacheDir.delete();
+        }
+        if (!apkCacheDir.exists()) {
+            apkCacheDir.mkdir();
+        }
+        return apkCacheDir;
+    }
+
+    /**
+     * Recursively delete files in {@code dir} that were last modified
+     * {@code secondsAgo} seconds ago, e.g. when it was downloaded.
+     *
+     * @param dir        The directory to recurse in
+     * @param secondsAgo The number of seconds old that marks a file for deletion.
+     */
+    public static void clearOldFiles(File dir, long secondsAgo) {
+        if (dir == null) {
+            return;
+        }
+        long olderThan = System.currentTimeMillis() - (secondsAgo * 1000L);
+        for (File f : dir.listFiles()) {
+            if (f.isDirectory()) {
+                clearOldFiles(f, olderThan);
+                f.delete();
+            }
+            if (FileUtils.isFileOlder(f, olderThan)) {
+                f.delete();
+            }
+        }
+    }
+}
diff --git a/app/src/main/java/org/fdroid/fdroid/installer/ApkFileProvider.java b/app/src/main/java/org/fdroid/fdroid/installer/ApkFileProvider.java
new file mode 100644
index 000000000..0c81a5381
--- /dev/null
+++ b/app/src/main/java/org/fdroid/fdroid/installer/ApkFileProvider.java
@@ -0,0 +1,76 @@
+/*
+ * Copyright (C) 2016 Dominik Schürmann <dominik@dominikschuermann.de>
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 3
+ * of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
+ * MA 02110-1301, USA.
+ */
+
+package org.fdroid.fdroid.installer;
+
+import android.content.Context;
+import android.net.Uri;
+import android.support.v4.content.FileProvider;
+
+import org.fdroid.fdroid.data.Apk;
+import org.fdroid.fdroid.data.SanitizedFile;
+
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * This class has helper methods for preparing apks for installation.
+ * <p/>
+ * APK handling for installations:
+ * 1. APKs are downloaded into a cache directory that is either created on SD card
+ * <i>"/Android/data/[app_package_name]/cache/apks"</i> (if card is mounted and app has
+ * appropriate permission) or on device's file system depending incoming parameters.
+ * 2. Before installation, the APK is copied into the private data directory of the F-Droid,
+ * <i>"/data/data/[app_package_name]/files/install-$random.apk"</i>.
+ * 3. The hash of the file is checked against the expected hash from the repository
+ * 4. For Android < 7, a file Uri pointing to the File is returned, for Android >= 7,
+ * a content Uri is returned using support lib's FileProvider.
+ */
+public class ApkFileProvider extends FileProvider {
+
+    public static final String AUTHORITY = "org.fdroid.fdroid.installer.ApkFileProvider";
+
+    /**
+     * Copies the APK into private data directory of F-Droid and returns a "file" or "content" Uri
+     * to be used for installation.
+     */
+    public static Uri getSafeUri(Context context, Uri localApkUri, Apk expectedApk, boolean useContentUri)
+            throws IOException {
+        File apkFile = new File(localApkUri.getPath());
+
+        SanitizedFile sanitizedApkFile =
+                ApkCache.copyApkFromCacheToFiles(context, apkFile, expectedApk);
+
+        if (useContentUri) {
+            // return a content Uri using support libs FileProvider
+
+            return getUriForFile(context, AUTHORITY, sanitizedApkFile);
+        }
+
+        // Need the apk to be world readable, so that the installer is able to read it.
+        // Note that saving it into external storage for the purpose of letting the installer
+        // have access is insecure, because apps with permission to write to the external
+        // storage can overwrite the app between F-Droid asking for it to be installed and
+        // the installer actually installing it.
+        sanitizedApkFile.setReadable(true, false);
+
+        return Uri.fromFile(sanitizedApkFile);
+    }
+
+}
diff --git a/app/src/main/java/org/fdroid/fdroid/installer/ApkVerifier.java b/app/src/main/java/org/fdroid/fdroid/installer/ApkVerifier.java
index 649bedf51..bfb902009 100644
--- a/app/src/main/java/org/fdroid/fdroid/installer/ApkVerifier.java
+++ b/app/src/main/java/org/fdroid/fdroid/installer/ApkVerifier.java
@@ -26,15 +26,9 @@ import android.net.Uri;
 import android.text.TextUtils;
 import android.util.Log;
 
-import org.apache.commons.io.FileUtils;
-import org.fdroid.fdroid.Hasher;
 import org.fdroid.fdroid.Utils;
 import org.fdroid.fdroid.data.Apk;
-import org.fdroid.fdroid.data.SanitizedFile;
 
-import java.io.File;
-import java.io.IOException;
-import java.security.NoSuchAlgorithmException;
 import java.util.Arrays;
 import java.util.HashSet;
 
@@ -47,13 +41,11 @@ public class ApkVerifier {
 
     private static final String TAG = "ApkVerifier";
 
-    private final Context context;
     private final Uri localApkUri;
     private final Apk expectedApk;
     private final PackageManager pm;
 
     ApkVerifier(Context context, Uri localApkUri, Apk expectedApk) {
-        this.context = context;
         this.localApkUri = localApkUri;
         this.expectedApk = expectedApk;
         this.pm = context.getPackageManager();
@@ -107,67 +99,6 @@ public class ApkVerifier {
         return new HashSet<>(Arrays.asList(localApkInfo.requestedPermissions));
     }
 
-    public Uri getSafeUri() throws ApkVerificationException {
-        File apkFile = new File(localApkUri.getPath());
-
-        SanitizedFile sanitizedApkFile = null;
-        try {
-
-            /* Always copy the APK to the safe location inside of the protected area
-             * of the app to prevent attacks based on other apps swapping the file
-             * out during the install process. Most likely, apkFile was just downloaded,
-             * so it should still be in the RAM disk cache */
-            sanitizedApkFile = SanitizedFile.knownSanitized(File.createTempFile("install-", ".apk",
-                    context.getFilesDir()));
-            FileUtils.copyFile(apkFile, sanitizedApkFile);
-            if (!verifyApkFile(sanitizedApkFile, expectedApk.hash, expectedApk.hashType)) {
-                FileUtils.deleteQuietly(apkFile);
-                throw new ApkVerificationException(apkFile + " failed to verify!");
-            }
-            apkFile = null; // ensure this is not used now that its copied to apkToInstall
-
-            // Need the apk to be world readable, so that the installer is able to read it.
-            // Note that saving it into external storage for the purpose of letting the installer
-            // have access is insecure, because apps with permission to write to the external
-            // storage can overwrite the app between F-Droid asking for it to be installed and
-            // the installer actually installing it.
-            sanitizedApkFile.setReadable(true, false);
-
-        } catch (IOException | NoSuchAlgorithmException e) {
-            throw new ApkVerificationException(e);
-        } finally {
-            // 20 minutes the start of the install process, delete the file
-            final File apkToDelete = sanitizedApkFile;
-            new Thread() {
-                @Override
-                public void run() {
-                    android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_LOWEST);
-                    try {
-                        Thread.sleep(1200000);
-                    } catch (InterruptedException e) {
-                        e.printStackTrace();
-                    } finally {
-                        FileUtils.deleteQuietly(apkToDelete);
-                    }
-                }
-            }.start();
-        }
-
-        return Uri.fromFile(sanitizedApkFile);
-    }
-
-    /**
-     * Checks the APK file against the provided hash, returning whether it is a match.
-     */
-    static boolean verifyApkFile(File apkFile, String hash, String hashType)
-            throws NoSuchAlgorithmException {
-        if (!apkFile.exists()) {
-            return false;
-        }
-        Hasher hasher = new Hasher(hashType, apkFile);
-        return hasher.match(hash);
-    }
-
     public static class ApkVerificationException extends Exception {
 
         public ApkVerificationException(String message) {
diff --git a/app/src/main/java/org/fdroid/fdroid/installer/DefaultInstaller.java b/app/src/main/java/org/fdroid/fdroid/installer/DefaultInstaller.java
index 7f48e7ca3..9afc00d2c 100644
--- a/app/src/main/java/org/fdroid/fdroid/installer/DefaultInstaller.java
+++ b/app/src/main/java/org/fdroid/fdroid/installer/DefaultInstaller.java
@@ -23,8 +23,10 @@ import android.app.PendingIntent;
 import android.content.Context;
 import android.content.Intent;
 import android.net.Uri;
+import android.os.Build;
 
 import org.fdroid.fdroid.Utils;
+import org.fdroid.fdroid.data.Apk;
 
 import java.io.File;
 
@@ -44,7 +46,7 @@ public class DefaultInstaller extends Installer {
     }
 
     @Override
-    protected void installPackage(Uri localApkUri, Uri downloadUri, String packageName) {
+    protected void installPackageInternal(Uri localApkUri, Uri downloadUri, Apk apk) {
         sendBroadcastInstall(downloadUri, Installer.ACTION_INSTALL_STARTED);
 
         Utils.debugLog(TAG, "DefaultInstaller uri: " + localApkUri + " file: " + new File(localApkUri.getPath()));
@@ -86,4 +88,12 @@ public class DefaultInstaller extends Installer {
     protected boolean isUnattended() {
         return false;
     }
+
+    @Override
+    protected boolean supportsContentUri() {
+        // TODO: replace Android N check with proper version code
+        //if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+        // Android N only supports content Uris
+        return "N".equals(Build.VERSION.CODENAME);
+    }
 }
diff --git a/app/src/main/java/org/fdroid/fdroid/installer/DefaultInstallerActivity.java b/app/src/main/java/org/fdroid/fdroid/installer/DefaultInstallerActivity.java
index 420f37a05..9b65f62c3 100644
--- a/app/src/main/java/org/fdroid/fdroid/installer/DefaultInstallerActivity.java
+++ b/app/src/main/java/org/fdroid/fdroid/installer/DefaultInstallerActivity.java
@@ -79,10 +79,12 @@ public class DefaultInstallerActivity extends FragmentActivity {
             throw new RuntimeException("Set the data uri to point to an apk location!");
         }
         // https://code.google.com/p/android/issues/detail?id=205827
-        if ((Build.VERSION.SDK_INT <= Build.VERSION_CODES.M)
-                && (!uri.getScheme().equals("file"))) {
-            throw new RuntimeException("PackageInstaller <= Android 6 only supports file scheme!");
-        }
+        // TODO: re-enable after Android N release
+        //if ((Build.VERSION.SDK_INT <= Build.VERSION_CODES.M)
+        //        && (!uri.getScheme().equals("file"))) {
+        //    throw new RuntimeException("PackageInstaller <= Android 6 only supports file scheme!");
+        //}
+        // TODO: replace with proper version check after Android N release
         if (("N".equals(Build.VERSION.CODENAME))
                 && (!uri.getScheme().equals("content"))) {
             throw new RuntimeException("PackageInstaller >= Android N only supports content scheme!");
@@ -91,26 +93,41 @@ public class DefaultInstallerActivity extends FragmentActivity {
         Intent intent = new Intent();
         intent.setData(uri);
 
+        // Note regarding EXTRA_NOT_UNKNOWN_SOURCE:
+        // works only when being installed as system-app
+        // https://code.google.com/p/android/issues/detail?id=42253
+
         if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
             intent.setAction(Intent.ACTION_VIEW);
             intent.setType("application/vnd.android.package-archive");
-        } else {
+        } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) {
+            intent.setAction(Intent.ACTION_INSTALL_PACKAGE);
+            intent.putExtra(Intent.EXTRA_RETURN_RESULT, true);
+            intent.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true);
+            intent.putExtra(Intent.EXTRA_ALLOW_REPLACE, true);
+        } else if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
+            intent.setAction(Intent.ACTION_INSTALL_PACKAGE);
+            intent.putExtra(Intent.EXTRA_RETURN_RESULT, true);
+            intent.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true);
+        } else { // Android N
             intent.setAction(Intent.ACTION_INSTALL_PACKAGE);
-
             // EXTRA_RETURN_RESULT throws a RuntimeException on N
             // https://gitlab.com/fdroid/fdroidclient/issues/631
-            if (!"N".equals(Build.VERSION.CODENAME)) {
-                intent.putExtra(Intent.EXTRA_RETURN_RESULT, true);
-            }
-
-            // following extras only work when being installed as system-app
-            // https://code.google.com/p/android/issues/detail?id=42253
+            //intent.putExtra(Intent.EXTRA_RETURN_RESULT, true);
             intent.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true);
+            // grant READ permission for this content Uri
+            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+        }
 
-            if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) {
-                // deprecated in Android 4.1
-                intent.putExtra(Intent.EXTRA_ALLOW_REPLACE, true);
-            }
+        // TODO: remove whole block after Android N release
+        if ("N".equals(Build.VERSION.CODENAME)) {
+            intent.setAction(Intent.ACTION_INSTALL_PACKAGE);
+            // EXTRA_RETURN_RESULT throws a RuntimeException on N
+            // https://gitlab.com/fdroid/fdroidclient/issues/631
+            intent.putExtra(Intent.EXTRA_RETURN_RESULT, false);
+            intent.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true);
+            // grant READ permission for this content Uri
+            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
         }
 
         try {
@@ -171,6 +188,7 @@ public class DefaultInstallerActivity extends FragmentActivity {
                     break;
                 }
 
+                // TODO: remove Android N hack after release
                 // Fallback on N for https://gitlab.com/fdroid/fdroidclient/issues/631
                 if ("N".equals(Build.VERSION.CODENAME)) {
                     installer.sendBroadcastInstall(downloadUri, Installer.ACTION_INSTALL_COMPLETE);
diff --git a/app/src/main/java/org/fdroid/fdroid/installer/ExtensionInstaller.java b/app/src/main/java/org/fdroid/fdroid/installer/ExtensionInstaller.java
index a17565dd3..d40137c2d 100644
--- a/app/src/main/java/org/fdroid/fdroid/installer/ExtensionInstaller.java
+++ b/app/src/main/java/org/fdroid/fdroid/installer/ExtensionInstaller.java
@@ -25,6 +25,7 @@ import android.content.Intent;
 import android.net.Uri;
 
 import org.fdroid.fdroid.BuildConfig;
+import org.fdroid.fdroid.data.Apk;
 import org.fdroid.fdroid.privileged.install.InstallExtensionDialogActivity;
 
 import java.io.File;
@@ -43,7 +44,7 @@ public class ExtensionInstaller extends Installer {
     }
 
     @Override
-    protected void installPackage(Uri localApkUri, Uri downloadUri, String packageName) {
+    protected void installPackageInternal(Uri localApkUri, Uri downloadUri, Apk apk) {
         // extension must be signed with the same public key as main F-Droid
         // NOTE: Disabled for debug builds to be able to test official extension from repo
         ApkSignatureVerifier signatureVerifier = new ApkSignatureVerifier(context);
@@ -93,4 +94,9 @@ public class ExtensionInstaller extends Installer {
     protected boolean isUnattended() {
         return false;
     }
+
+    @Override
+    protected boolean supportsContentUri() {
+        return false;
+    }
 }
diff --git a/app/src/main/java/org/fdroid/fdroid/installer/InstallManagerService.java b/app/src/main/java/org/fdroid/fdroid/installer/InstallManagerService.java
index 971ed732d..a838fc3e1 100644
--- a/app/src/main/java/org/fdroid/fdroid/installer/InstallManagerService.java
+++ b/app/src/main/java/org/fdroid/fdroid/installer/InstallManagerService.java
@@ -15,7 +15,6 @@ import android.support.v4.app.NotificationCompat;
 import android.support.v4.app.TaskStackBuilder;
 import android.support.v4.content.LocalBroadcastManager;
 import android.text.TextUtils;
-import android.util.Log;
 
 import org.fdroid.fdroid.AppDetails;
 import org.fdroid.fdroid.R;
@@ -27,7 +26,6 @@ import org.fdroid.fdroid.net.Downloader;
 import org.fdroid.fdroid.net.DownloaderService;
 
 import java.io.File;
-import java.security.NoSuchAlgorithmException;
 import java.util.HashMap;
 import java.util.Map;
 import java.util.Set;
@@ -162,12 +160,12 @@ public class InstallManagerService extends Service {
 
         registerDownloaderReceivers(urlString, builder);
 
-        File apkFilePath = Utils.getApkDownloadPath(this, intent.getData());
+        File apkFilePath = ApkCache.getApkDownloadPath(this, intent.getData());
         long apkFileSize = apkFilePath.length();
         if (!apkFilePath.exists() || apkFileSize < apk.size) {
             Utils.debugLog(TAG, "download " + urlString + " " + apkFilePath);
             DownloaderService.queue(this, urlString);
-        } else if (apkIsCached(apkFilePath, apk)) {
+        } else if (ApkCache.apkIsCached(apkFilePath, apk)) {
             Utils.debugLog(TAG, "skip download, we have it, straight to install " + urlString + " " + apkFilePath);
             sendBroadcast(intent.getData(), Downloader.ACTION_STARTED, apkFilePath);
             sendBroadcast(intent.getData(), Downloader.ACTION_COMPLETE, apkFilePath);
@@ -179,21 +177,6 @@ public class InstallManagerService extends Service {
         return START_REDELIVER_INTENT; // if killed before completion, retry Intent
     }
 
-    /**
-     * Verifies the size of the file on disk matches, and then hashes the file to compare with what
-     * we received from the signed repo (i.e. {@link Apk#hash} and {@link Apk#hashType}).
-     * Bails out if the file sizes don't match to prevent having to do the work of hashing the file.
-     */
-    private static boolean apkIsCached(File apkFile, Apk apkToCheck) {
-        try {
-            return apkFile.length() == apkToCheck.size &&
-                    ApkVerifier.verifyApkFile(apkFile, apkToCheck.hash, apkToCheck.hashType);
-        } catch (NoSuchAlgorithmException e) {
-            e.printStackTrace();
-            return false;
-        }
-    }
-
     private void sendBroadcast(Uri uri, String action, File file) {
         Intent intent = new Intent(action);
         intent.setData(uri);
@@ -238,21 +221,7 @@ public class InstallManagerService extends Service {
 
                 Apk apk = ACTIVE_APKS.get(urlString);
 
-                Uri sanitizedUri;
-                try {
-                    ApkVerifier apkVerifier = new ApkVerifier(context, localApkUri, apk);
-                    apkVerifier.verifyApk();
-                    sanitizedUri = apkVerifier.getSafeUri();
-                } catch (ApkVerifier.ApkVerificationException e) {
-                    Log.e(TAG, "ApkVerifier failed", e);
-                    String title = String.format(
-                            getString(R.string.install_error_notify_title),
-                            apk.packageName);
-                    notifyError(urlString, title, e.getMessage());
-                    return;
-                }
-
-                InstallerService.install(context, sanitizedUri, downloadUri, apk.packageName);
+                InstallerService.install(context, localApkUri, downloadUri, apk);
             }
         };
         BroadcastReceiver interruptedReceiver = new BroadcastReceiver() {
diff --git a/app/src/main/java/org/fdroid/fdroid/installer/Installer.java b/app/src/main/java/org/fdroid/fdroid/installer/Installer.java
index c143b1033..ff94f2563 100644
--- a/app/src/main/java/org/fdroid/fdroid/installer/Installer.java
+++ b/app/src/main/java/org/fdroid/fdroid/installer/Installer.java
@@ -28,6 +28,7 @@ import android.os.Build;
 import android.os.PatternMatcher;
 import android.support.v4.content.LocalBroadcastManager;
 import android.text.TextUtils;
+import android.util.Log;
 
 import org.fdroid.fdroid.data.Apk;
 import org.fdroid.fdroid.data.ApkProvider;
@@ -36,6 +37,8 @@ import org.fdroid.fdroid.privileged.views.AppSecurityPermissions;
 import org.fdroid.fdroid.privileged.views.InstallConfirmActivity;
 import org.fdroid.fdroid.privileged.views.UninstallDialogActivity;
 
+import java.io.IOException;
+
 /**
  * Handles the actual install process.  Subclasses implement the details.
  */
@@ -43,6 +46,8 @@ public abstract class Installer {
     final Context context;
     private final LocalBroadcastManager localBroadcastManager;
 
+    private static final String TAG = "Installer";
+
     public static final String ACTION_INSTALL_STARTED = "org.fdroid.fdroid.installer.Installer.action.INSTALL_STARTED";
     public static final String ACTION_INSTALL_COMPLETE = "org.fdroid.fdroid.installer.Installer.action.INSTALL_COMPLETE";
     public static final String ACTION_INSTALL_INTERRUPTED = "org.fdroid.fdroid.installer.Installer.action.INSTALL_INTERRUPTED";
@@ -62,23 +67,11 @@ public abstract class Installer {
      * @see Intent#EXTRA_ORIGINATING_URI
      */
     static final String EXTRA_DOWNLOAD_URI = "org.fdroid.fdroid.installer.Installer.extra.DOWNLOAD_URI";
+    public static final String EXTRA_APK = "org.fdroid.fdroid.installer.Installer.extra.APK";
     public static final String EXTRA_PACKAGE_NAME = "org.fdroid.fdroid.installer.Installer.extra.PACKAGE_NAME";
     public static final String EXTRA_USER_INTERACTION_PI = "org.fdroid.fdroid.installer.Installer.extra.USER_INTERACTION_PI";
     public static final String EXTRA_ERROR_MESSAGE = "org.fdroid.fdroid.net.installer.Installer.extra.ERROR_MESSAGE";
 
-    public static class InstallFailedException extends Exception {
-
-        private static final long serialVersionUID = -8343133906463328027L;
-
-        public InstallFailedException(String message) {
-            super(message);
-        }
-
-        public InstallFailedException(Throwable cause) {
-            super(cause);
-        }
-    }
-
     Installer(Context context) {
         this.context = context;
         localBroadcastManager = LocalBroadcastManager.getInstance(context);
@@ -150,8 +143,7 @@ public abstract class Installer {
         return intent;
     }
 
-    void sendBroadcastInstall(Uri downloadUri, String action,
-                                     PendingIntent pendingIntent) {
+    void sendBroadcastInstall(Uri downloadUri, String action, PendingIntent pendingIntent) {
         sendBroadcastInstall(downloadUri, action, pendingIntent, null);
     }
 
@@ -164,7 +156,7 @@ public abstract class Installer {
     }
 
     void sendBroadcastInstall(Uri downloadUri, String action,
-                                     PendingIntent pendingIntent, String errorMessage) {
+                              PendingIntent pendingIntent, String errorMessage) {
         Intent intent = new Intent(action);
         intent.setData(downloadUri);
         intent.putExtra(Installer.EXTRA_USER_INTERACTION_PI, pendingIntent);
@@ -182,13 +174,12 @@ public abstract class Installer {
         sendBroadcastUninstall(packageName, action, null, null);
     }
 
-    void sendBroadcastUninstall(String packageName, String action,
-                                       PendingIntent pendingIntent) {
+    void sendBroadcastUninstall(String packageName, String action, PendingIntent pendingIntent) {
         sendBroadcastUninstall(packageName, action, pendingIntent, null);
     }
 
     void sendBroadcastUninstall(String packageName, String action,
-                                       PendingIntent pendingIntent, String errorMessage) {
+                                PendingIntent pendingIntent, String errorMessage) {
         Uri uri = Uri.fromParts("package", packageName, null);
 
         Intent intent = new Intent(action);
@@ -229,13 +220,40 @@ public abstract class Installer {
     }
 
     /**
+     * Install apk
+     *
      * @param localApkUri points to the local copy of the APK to be installed
      * @param downloadUri serves as the unique ID for all actions related to the
      *                    installation of that specific APK
-     * @param packageName package name of the app that should be installed
+     * @param apk         apk object of the app that should be installed
      */
-    protected abstract void installPackage(Uri localApkUri, Uri downloadUri, String packageName);
+    public void installPackage(Uri localApkUri, Uri downloadUri, Apk apk) {
+        Uri sanitizedUri;
+        try {
+            // verify that permissions of the apk file match the ones from the apk object
+            ApkVerifier apkVerifier = new ApkVerifier(context, localApkUri, apk);
+            apkVerifier.verifyApk();
 
+            // move apk file to private directory for installation and check hash
+            sanitizedUri = ApkFileProvider.getSafeUri(
+                    context, localApkUri, apk, supportsContentUri());
+        } catch (ApkVerifier.ApkVerificationException | IOException e) {
+            Log.e(TAG, "ApkVerifier / ApkFileProvider failed", e);
+            sendBroadcastInstall(downloadUri, Installer.ACTION_INSTALL_INTERRUPTED,
+                    e.getMessage());
+            return;
+        }
+
+        installPackageInternal(sanitizedUri, downloadUri, apk);
+    }
+
+    protected abstract void installPackageInternal(Uri localApkUri, Uri downloadUri, Apk apk);
+
+    /**
+     * Uninstall app
+     *
+     * @param packageName package name of the app that should be uninstalled
+     */
     protected abstract void uninstallPackage(String packageName);
 
     /**
@@ -244,4 +262,9 @@ public abstract class Installer {
      */
     protected abstract boolean isUnattended();
 
+    /**
+     * @return true if the Installer supports content Uris and not just file Uris
+     */
+    protected abstract boolean supportsContentUri();
+
 }
diff --git a/app/src/main/java/org/fdroid/fdroid/installer/InstallerFactory.java b/app/src/main/java/org/fdroid/fdroid/installer/InstallerFactory.java
index f2ecd7b6a..b35686e4e 100644
--- a/app/src/main/java/org/fdroid/fdroid/installer/InstallerFactory.java
+++ b/app/src/main/java/org/fdroid/fdroid/installer/InstallerFactory.java
@@ -24,6 +24,7 @@ import android.util.Log;
 
 import org.fdroid.fdroid.Preferences;
 import org.fdroid.fdroid.Utils;
+import org.fdroid.fdroid.data.Apk;
 
 public class InstallerFactory {
 
@@ -34,17 +35,16 @@ public class InstallerFactory {
      * Either DefaultInstaller, PrivilegedInstaller, or in the special
      * case to install the "F-Droid Privileged Extension" ExtensionInstaller.
      *
-     * @param context     current {@link Context}
-     * @param packageName package name of apk to be installed. Required to select
-     *                    the ExtensionInstaller.
-     *                    If this is null, the ExtensionInstaller will never be returned.
+     * @param context current {@link Context}
+     * @param apk     apk to be installed. Required to select the ExtensionInstaller.
+     *                If this is null, the ExtensionInstaller will never be returned.
      * @return instance of an Installer
      */
-    public static Installer create(Context context, String packageName) {
+    public static Installer create(Context context, Apk apk) {
         Installer installer;
 
-        if (packageName != null
-                && packageName.equals(PrivilegedInstaller.PRIVILEGED_EXTENSION_PACKAGE_NAME)) {
+        if (apk != null
+                && apk.packageName.equals(PrivilegedInstaller.PRIVILEGED_EXTENSION_PACKAGE_NAME)) {
             // special case for "F-Droid Privileged Extension"
             installer = new ExtensionInstaller(context);
         } else if (isPrivilegedInstallerEnabled()) {
@@ -54,9 +54,7 @@ public class InstallerFactory {
 
                 installer = new PrivilegedInstaller(context);
             } else {
-                Log.e(TAG, "PrivilegedInstaller is enabled in prefs, but permissions are not granted!");
-                // TODO: better error handling?
-
+                Log.e(TAG, "PrivilegedInstaller is enabled in prefs, but not working correctly!");
                 // fallback to default installer
                 installer = new DefaultInstaller(context);
             }
diff --git a/app/src/main/java/org/fdroid/fdroid/installer/InstallerService.java b/app/src/main/java/org/fdroid/fdroid/installer/InstallerService.java
index 7e08806c2..e12282770 100644
--- a/app/src/main/java/org/fdroid/fdroid/installer/InstallerService.java
+++ b/app/src/main/java/org/fdroid/fdroid/installer/InstallerService.java
@@ -23,6 +23,9 @@ import android.app.IntentService;
 import android.content.Context;
 import android.content.Intent;
 import android.net.Uri;
+import android.os.Parcelable;
+
+import org.fdroid.fdroid.data.Apk;
 
 /**
  * This service handles the install process of apk files and
@@ -49,14 +52,17 @@ public class InstallerService extends IntentService {
 
     @Override
     protected void onHandleIntent(Intent intent) {
-        String packageName = intent.getStringExtra(Installer.EXTRA_PACKAGE_NAME);
-        Installer installer = InstallerFactory.create(this, packageName);
+        Parcelable apkParcel = intent.getParcelableExtra(Installer.EXTRA_APK);
+        Apk apk = apkParcel == null ? null : new Apk(apkParcel);
+
+        Installer installer = InstallerFactory.create(this, apk);
 
         if (ACTION_INSTALL.equals(intent.getAction())) {
             Uri uri = intent.getData();
             Uri downloadUri = intent.getParcelableExtra(Installer.EXTRA_DOWNLOAD_URI);
-            installer.installPackage(uri, downloadUri, packageName);
+            installer.installPackage(uri, downloadUri, apk);
         } else if (ACTION_UNINSTALL.equals(intent.getAction())) {
+            String packageName = intent.getStringExtra(Installer.EXTRA_PACKAGE_NAME);
             installer.uninstallPackage(packageName);
         }
     }
@@ -67,14 +73,14 @@ public class InstallerService extends IntentService {
      * @param context     this app's {@link Context}
      * @param localApkUri {@link Uri} pointing to (downloaded) local apk file
      * @param downloadUri {@link Uri} where the apk has been downloaded from
-     * @param packageName package name of the app that should be installed
+     * @param apk         apk object of app that should be installed
      */
-    public static void install(Context context, Uri localApkUri, Uri downloadUri, String packageName) {
+    public static void install(Context context, Uri localApkUri, Uri downloadUri, Apk apk) {
         Intent intent = new Intent(context, InstallerService.class);
         intent.setAction(ACTION_INSTALL);
         intent.setData(localApkUri);
         intent.putExtra(Installer.EXTRA_DOWNLOAD_URI, downloadUri);
-        intent.putExtra(Installer.EXTRA_PACKAGE_NAME, packageName);
+        intent.putExtra(Installer.EXTRA_APK, apk.toContentValues());
         context.startService(intent);
     }
 
diff --git a/app/src/main/java/org/fdroid/fdroid/installer/PrivilegedInstaller.java b/app/src/main/java/org/fdroid/fdroid/installer/PrivilegedInstaller.java
index ec8039880..ee409c1c9 100644
--- a/app/src/main/java/org/fdroid/fdroid/installer/PrivilegedInstaller.java
+++ b/app/src/main/java/org/fdroid/fdroid/installer/PrivilegedInstaller.java
@@ -31,6 +31,7 @@ import android.os.RemoteException;
 import android.util.Log;
 
 import org.fdroid.fdroid.R;
+import org.fdroid.fdroid.data.Apk;
 import org.fdroid.fdroid.privileged.IPrivilegedCallback;
 import org.fdroid.fdroid.privileged.IPrivilegedService;
 
@@ -297,7 +298,7 @@ public class PrivilegedInstaller extends Installer {
     }
 
     @Override
-    protected void installPackage(final Uri localApkUri, final Uri downloadUri, String packageName) {
+    protected void installPackageInternal(final Uri localApkUri, final Uri downloadUri, Apk apk) {
         sendBroadcastInstall(downloadUri, Installer.ACTION_INSTALL_STARTED);
 
         ServiceConnection mServiceConnection = new ServiceConnection() {
@@ -396,4 +397,9 @@ public class PrivilegedInstaller extends Installer {
         return true;
     }
 
+    @Override
+    protected boolean supportsContentUri() {
+        // TODO: correct?
+        return false;
+    }
 }
diff --git a/app/src/main/java/org/fdroid/fdroid/net/DownloaderService.java b/app/src/main/java/org/fdroid/fdroid/net/DownloaderService.java
index d3e7e6e90..98fcb4c21 100644
--- a/app/src/main/java/org/fdroid/fdroid/net/DownloaderService.java
+++ b/app/src/main/java/org/fdroid/fdroid/net/DownloaderService.java
@@ -37,6 +37,7 @@ import android.text.TextUtils;
 import org.fdroid.fdroid.ProgressListener;
 import org.fdroid.fdroid.Utils;
 import org.fdroid.fdroid.data.SanitizedFile;
+import org.fdroid.fdroid.installer.ApkCache;
 
 import java.io.File;
 import java.io.IOException;
@@ -196,7 +197,7 @@ public class DownloaderService extends Service {
      */
     protected void handleIntent(Intent intent) {
         final Uri uri = intent.getData();
-        final SanitizedFile localFile = Utils.getApkDownloadPath(this, uri);
+        final SanitizedFile localFile = ApkCache.getApkDownloadPath(this, uri);
         sendBroadcast(uri, Downloader.ACTION_STARTED, localFile);
 
         try {
diff --git a/app/src/main/res/xml/apk_file_provider.xml b/app/src/main/res/xml/apk_file_provider.xml
new file mode 100644
index 000000000..f5475388c
--- /dev/null
+++ b/app/src/main/res/xml/apk_file_provider.xml
@@ -0,0 +1,6 @@
+<?xml version="1.0" encoding="utf-8"?>
+<paths>
+    <files-path
+        name="files"
+        path="." />
+</paths>
\ No newline at end of file
diff --git a/app/src/test/java/org/fdroid/fdroid/UtilsTest.java b/app/src/test/java/org/fdroid/fdroid/UtilsTest.java
index 7ec649ca3..286e97664 100644
--- a/app/src/test/java/org/fdroid/fdroid/UtilsTest.java
+++ b/app/src/test/java/org/fdroid/fdroid/UtilsTest.java
@@ -3,16 +3,12 @@ package org.fdroid.fdroid;
 
 import android.content.Context;
 
-import org.apache.commons.io.FileUtils;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.robolectric.RobolectricGradleTestRunner;
 import org.robolectric.RuntimeEnvironment;
 import org.robolectric.annotation.Config;
 
-import java.io.File;
-import java.io.IOException;
-
 import static org.junit.Assert.assertEquals;
 import static org.junit.Assert.assertFalse;
 import static org.junit.Assert.assertTrue;
@@ -144,40 +140,4 @@ public class UtilsTest {
 
     // TODO write tests that work with a Certificate
 
-    @Test
-    public void testClearOldFiles() throws IOException, InterruptedException {
-        File tempDir = new File(System.getProperty("java.io.tmpdir"));
-        assertTrue(tempDir.isDirectory());
-        assertTrue(tempDir.canWrite());
-
-        File dir = new File(tempDir, "F-Droid-test.clearOldFiles");
-        FileUtils.deleteQuietly(dir);
-        assertTrue(dir.mkdirs());
-        assertTrue(dir.isDirectory());
-
-        File first = new File(dir, "first");
-        first.deleteOnExit();
-
-        File second = new File(dir, "second");
-        second.deleteOnExit();
-
-        assertFalse(first.exists());
-        assertFalse(second.exists());
-
-        assertTrue(first.createNewFile());
-        assertTrue(first.exists());
-
-        Thread.sleep(7000);
-        assertTrue(second.createNewFile());
-        assertTrue(second.exists());
-
-        Utils.clearOldFiles(dir, 3);
-        assertFalse(first.exists());
-        assertTrue(second.exists());
-
-        Thread.sleep(7000);
-        Utils.clearOldFiles(dir, 3);
-        assertFalse(first.exists());
-        assertFalse(second.exists());
-    }
 }
diff --git a/app/src/test/java/org/fdroid/fdroid/installer/ApkCacheTest.java b/app/src/test/java/org/fdroid/fdroid/installer/ApkCacheTest.java
new file mode 100644
index 000000000..13ca70fea
--- /dev/null
+++ b/app/src/test/java/org/fdroid/fdroid/installer/ApkCacheTest.java
@@ -0,0 +1,56 @@
+package org.fdroid.fdroid.installer;
+
+import org.apache.commons.io.FileUtils;
+import org.fdroid.fdroid.BuildConfig;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricGradleTestRunner;
+import org.robolectric.annotation.Config;
+
+import java.io.File;
+import java.io.IOException;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+@Config(constants = BuildConfig.class)
+@RunWith(RobolectricGradleTestRunner.class)
+public class ApkCacheTest {
+
+    @Test
+    public void testClearOldFiles() throws IOException, InterruptedException {
+        File tempDir = new File(System.getProperty("java.io.tmpdir"));
+        assertTrue(tempDir.isDirectory());
+        assertTrue(tempDir.canWrite());
+
+        File dir = new File(tempDir, "F-Droid-test.clearOldFiles");
+        FileUtils.deleteQuietly(dir);
+        assertTrue(dir.mkdirs());
+        assertTrue(dir.isDirectory());
+
+        File first = new File(dir, "first");
+        first.deleteOnExit();
+
+        File second = new File(dir, "second");
+        second.deleteOnExit();
+
+        assertFalse(first.exists());
+        assertFalse(second.exists());
+
+        assertTrue(first.createNewFile());
+        assertTrue(first.exists());
+
+        Thread.sleep(7000);
+        assertTrue(second.createNewFile());
+        assertTrue(second.exists());
+
+        ApkCache.clearOldFiles(dir, 3);
+        assertFalse(first.exists());
+        assertTrue(second.exists());
+
+        Thread.sleep(7000);
+        ApkCache.clearOldFiles(dir, 3);
+        assertFalse(first.exists());
+        assertFalse(second.exists());
+    }
+}