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"/> + + + + 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 + * + * 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 + * + * 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. + *

+ * APK handling for installations: + * 1. APKs are downloaded into a cache directory that is either created on SD card + * "/Android/data/[app_package_name]/cache/apks" (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, + * "/data/data/[app_package_name]/files/install-$random.apk". + * 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 @@ + + + + \ 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()); + } +}