- * This implemented to throw an {@link IllegalArgumentException} if the types
- * do not match what they are expected to be so that things fail fast. So that
- * means only types used in {@link App#toContentValues()} and
- * {@link Apk#toContentValues()} are implemented.
- */
-class ContentValuesCursor extends AbstractCursor {
-
- private final String[] keys;
- private final Object[] values;
-
- ContentValuesCursor(ContentValues contentValues) {
- super();
- keys = new String[contentValues.size()];
- values = new Object[contentValues.size()];
- int i = 0;
- for (Map.Entry
+ * This also handles downloading OBB "APK Extension" files for any APK that has one + * assigned to it. OBB files are queued up for download before the APK so that they + * are hopefully in place before the APK starts. That is not guaranteed though. + * + * @see APK Expansion Files */ public class InstallManagerService extends Service { private static final String TAG = "InstallManagerService"; private static final String ACTION_INSTALL = "org.fdroid.fdroid.installer.action.INSTALL"; + private static final String ACTION_CANCEL = "org.fdroid.fdroid.installer.action.CANCEL"; private static final String EXTRA_APP = "org.fdroid.fdroid.installer.extra.APP"; private static final String EXTRA_APK = "org.fdroid.fdroid.installer.extra.APK"; @@ -125,17 +138,25 @@ public class InstallManagerService extends Service { public int onStartCommand(Intent intent, int flags, int startId) { Utils.debugLog(TAG, "onStartCommand " + intent); - if (!ACTION_INSTALL.equals(intent.getAction())) { - Utils.debugLog(TAG, "Ignoring " + intent + " as it is not an " + ACTION_INSTALL + " intent"); - return START_NOT_STICKY; - } - String urlString = intent.getDataString(); if (TextUtils.isEmpty(urlString)) { Utils.debugLog(TAG, "empty urlString, nothing to do"); return START_NOT_STICKY; } + String action = intent.getAction(); + if (ACTION_CANCEL.equals(action)) { + DownloaderService.cancel(this, urlString); + Apk apk = getApkFromActive(urlString); + DownloaderService.cancel(this, apk.getPatchObbUrl()); + DownloaderService.cancel(this, apk.getMainObbUrl()); + cancelNotification(urlString); + return START_NOT_STICKY; + } else if (!ACTION_INSTALL.equals(action)) { + Utils.debugLog(TAG, "Ignoring " + intent + " as it is not an " + ACTION_INSTALL + " intent"); + return START_NOT_STICKY; + } + if (!intent.hasExtra(EXTRA_APP) || !intent.hasExtra(EXTRA_APK)) { Utils.debugLog(TAG, urlString + " did not include both an App and Apk instance, ignoring"); return START_NOT_STICKY; @@ -160,7 +181,9 @@ public class InstallManagerService extends Service { NotificationCompat.Builder builder = createNotificationBuilder(urlString, apk); notificationManager.notify(urlString.hashCode(), builder.build()); - registerDownloaderReceivers(urlString, builder); + registerApkDownloaderReceivers(urlString, builder); + getObb(urlString, apk.getMainObbUrl(), apk.getMainObbFile(), apk.obbMainFileSha256, builder); + getObb(urlString, apk.getPatchObbUrl(), apk.getPatchObbFile(), apk.obbPatchFileSha256, builder); File apkFilePath = ApkCache.getApkDownloadPath(this, intent.getData()); long apkFileSize = apkFilePath.length(); @@ -186,7 +209,72 @@ public class InstallManagerService extends Service { localBroadcastManager.sendBroadcast(intent); } - private void registerDownloaderReceivers(String urlString, final NotificationCompat.Builder builder) { + /** + * Check if any OBB files are available, and if so, download and install them. This + * also deletes any obsolete OBB files, per the spec, since there can be only one + * "main" and one "patch" OBB installed at a time. + * + * @see APK Expansion Files + */ + private void getObb(final String urlString, String obbUrlString, + final File obbDestFile, final String sha256, + final NotificationCompat.Builder builder) { + if (obbDestFile == null || obbDestFile.exists() || TextUtils.isEmpty(obbUrlString)) { + return; + } + final BroadcastReceiver downloadReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (Downloader.ACTION_STARTED.equals(action)) { + Utils.debugLog(TAG, action + " " + intent); + } else if (Downloader.ACTION_PROGRESS.equals(action)) { + + int bytesRead = intent.getIntExtra(Downloader.EXTRA_BYTES_READ, 0); + int totalBytes = intent.getIntExtra(Downloader.EXTRA_TOTAL_BYTES, 0); + builder.setProgress(totalBytes, bytesRead, false); + notificationManager.notify(urlString.hashCode(), builder.build()); + } else if (Downloader.ACTION_COMPLETE.equals(action)) { + localBroadcastManager.unregisterReceiver(this); + File localFile = new File(intent.getStringExtra(Downloader.EXTRA_DOWNLOAD_PATH)); + Uri localApkUri = Uri.fromFile(localFile); + Utils.debugLog(TAG, "OBB download completed " + intent.getDataString() + + " to " + localApkUri); + + try { + if (Hasher.isFileMatchingHash(localFile, sha256, "SHA-256")) { + Utils.debugLog(TAG, "Installing OBB " + localFile + " to " + obbDestFile); + FileUtils.forceMkdirParent(obbDestFile); + FileUtils.copyFile(localFile, obbDestFile); + FileFilter filter = new WildcardFileFilter( + obbDestFile.getName().substring(0, 4) + "*.obb"); + for (File f : obbDestFile.getParentFile().listFiles(filter)) { + if (!f.equals(obbDestFile)) { + Utils.debugLog(TAG, "Deleting obsolete OBB " + f); + FileUtils.deleteQuietly(f); + } + } + } else { + Utils.debugLog(TAG, localFile + " deleted, did not match hash: " + sha256); + } + } catch (IOException e) { + e.printStackTrace(); + } finally { + FileUtils.deleteQuietly(localFile); + } + } else if (Downloader.ACTION_INTERRUPTED.equals(action)) { + localBroadcastManager.unregisterReceiver(this); + } else { + throw new RuntimeException("intent action not handled!"); + } + } + }; + DownloaderService.queue(this, obbUrlString); + localBroadcastManager.registerReceiver(downloadReceiver, + DownloaderService.getIntentFilter(obbUrlString)); + } + + private void registerApkDownloaderReceivers(String urlString, final NotificationCompat.Builder builder) { BroadcastReceiver downloadReceiver = new BroadcastReceiver() { @Override @@ -303,7 +391,7 @@ public class InstallManagerService extends Service { .setContentIntent(getAppDetailsIntent(downloadUrlId, apk)) .setContentTitle(getString(R.string.downloading_apk, getAppName(apk))) .addAction(R.drawable.ic_cancel_black_24dp, getString(R.string.cancel), - DownloaderService.getCancelPendingIntent(this, urlString)) + getCancelPendingIntent(urlString)) .setSmallIcon(android.R.drawable.stat_sys_download) .setContentText(urlString) .setProgress(100, 0, true); @@ -348,7 +436,7 @@ public class InstallManagerService extends Service { if (TextUtils.isEmpty(name) || name.equals(new App().name)) { ContentResolver resolver = getContentResolver(); App app = AppProvider.Helper.findSpecificApp(resolver, apk.packageName, apk.repo, - new String[] {Schema.AppMetadataTable.Cols.NAME}); + new String[]{Schema.AppMetadataTable.Cols.NAME}); if (app == null || TextUtils.isEmpty(app.name)) { return; // do not have a name to display, so leave notification as is } @@ -456,6 +544,17 @@ public class InstallManagerService extends Service { return apk; } + private PendingIntent getCancelPendingIntent(String urlString) { + Intent intent = new Intent(this, InstallManagerService.class) + .setData(Uri.parse(urlString)) + .setAction(ACTION_CANCEL) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | IntentCompat.FLAG_ACTIVITY_CLEAR_TASK); + return PendingIntent.getService(this, + urlString.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT); + } + /** * Install an APK, checking the cache and downloading if necessary before starting the process. * All notifications are sent as an {@link Intent} via local broadcasts to be received by @@ -476,6 +575,13 @@ public class InstallManagerService extends Service { context.startService(intent); } + public static void cancel(Context context, String urlString) { + Intent intent = new Intent(context, InstallManagerService.class); + intent.setAction(ACTION_CANCEL); + intent.setData(Uri.parse(urlString)); + context.startService(intent); + } + /** * Returns a {@link Set} of the {@code urlString}s that are currently active. * {@code urlString}s are used as unique IDs throughout the 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 47c9bc74c..fffb47fab 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/InstallerFactory.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/InstallerFactory.java @@ -41,12 +41,12 @@ public class InstallerFactory { */ public static Installer create(Context context, Apk apk) { if (apk == null || TextUtils.isEmpty(apk.packageName)) { - throw new IllegalArgumentException("packageName must not be empty!"); + throw new IllegalArgumentException("Apk.packageName must not be empty: " + apk); } Installer installer; if (apk.packageName.equals(PrivilegedInstaller.PRIVILEGED_EXTENSION_PACKAGE_NAME)) { - // special case for "F-Droid Privileged Extension" + // special case for installing "Privileged Extension" with root installer = new ExtensionInstaller(context, apk); } else if (PrivilegedInstaller.isDefault(context)) { Utils.debugLog(TAG, "privileged extension correctly installed -> PrivilegedInstaller"); 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 3784c788c..da2736b05 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/InstallerService.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/InstallerService.java @@ -25,22 +25,32 @@ import android.content.Context; import android.content.Intent; import android.net.Uri; +import org.apache.commons.io.FileUtils; +import org.apache.commons.io.filefilter.WildcardFileFilter; import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.data.Apk; +import java.io.File; +import java.io.FileFilter; + /** * This service handles the install process of apk files and * uninstall process of apps. - *
+ ** This service is based on an IntentService because: * - no parallel installs/uninstalls should be allowed, * i.e., runs sequentially * - no cancel operation is needed. Cancelling an installation * would be the same as starting uninstall afterwards - *
+ ** The download URL is only used as the unique ID that represents this * particular apk throughout the whole install process in * {@link InstallManagerService}. + *
+ * This also handles deleting any associated OBB files when an app is
+ * uninstalled, as per the
+ *
+ * APK Expansion Files spec.
*/
public class InstallerService extends IntentService {
public static final String TAG = "InstallerService";
@@ -54,7 +64,7 @@ public class InstallerService extends IntentService {
@Override
protected void onHandleIntent(Intent intent) {
- Apk apk = intent.getParcelableExtra(Installer.EXTRA_APK);
+ final Apk apk = intent.getParcelableExtra(Installer.EXTRA_APK);
if (apk == null) {
Utils.debugLog(TAG, "ignoring intent with null EXTRA_APK: " + intent);
return;
@@ -67,6 +77,29 @@ public class InstallerService extends IntentService {
installer.installPackage(uri, downloadUri);
} else if (ACTION_UNINSTALL.equals(intent.getAction())) {
installer.uninstallPackage();
+ new Thread() {
+ @Override
+ public void run() {
+ setPriority(MIN_PRIORITY);
+ File mainObbFile = apk.getMainObbFile();
+ if (mainObbFile == null) {
+ return;
+ }
+ File obbDir = mainObbFile.getParentFile();
+ if (obbDir == null) {
+ return;
+ }
+ FileFilter filter = new WildcardFileFilter("*.obb");
+ File[] obbFiles = obbDir.listFiles(filter);
+ if (obbFiles == null) {
+ return;
+ }
+ for (File f : obbFiles) {
+ Utils.debugLog(TAG, "Uninstalling OBB " + f);
+ FileUtils.deleteQuietly(f);
+ }
+ }
+ }.start();
}
}
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 57588dd7c..0dfab580b 100644
--- a/app/src/main/java/org/fdroid/fdroid/net/DownloaderService.java
+++ b/app/src/main/java/org/fdroid/fdroid/net/DownloaderService.java
@@ -17,7 +17,6 @@
package org.fdroid.fdroid.net;
-import android.app.PendingIntent;
import android.app.Service;
import android.content.Context;
import android.content.Intent;
@@ -30,7 +29,6 @@ import android.os.Looper;
import android.os.Message;
import android.os.PatternMatcher;
import android.os.Process;
-import android.support.v4.content.IntentCompat;
import android.support.v4.content.LocalBroadcastManager;
import android.text.TextUtils;
@@ -154,17 +152,6 @@ public class DownloaderService extends Service {
return START_REDELIVER_INTENT; // if killed before completion, retry Intent
}
- public static PendingIntent getCancelPendingIntent(Context context, String urlString) {
- Intent cancelIntent = new Intent(context.getApplicationContext(), DownloaderService.class)
- .setData(Uri.parse(urlString))
- .setAction(ACTION_CANCEL)
- .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | IntentCompat.FLAG_ACTIVITY_CLEAR_TASK);
- return PendingIntent.getService(context.getApplicationContext(),
- urlString.hashCode(),
- cancelIntent,
- PendingIntent.FLAG_UPDATE_CURRENT);
- }
-
@Override
public void onDestroy() {
Utils.debugLog(TAG, "Destroying downloader service. Will move to background and stop our Looper.");
@@ -258,6 +245,9 @@ public class DownloaderService extends Service {
* @see #cancel(Context, String)
*/
public static void queue(Context context, String urlString) {
+ if (TextUtils.isEmpty(urlString)) {
+ return;
+ }
Utils.debugLog(TAG, "Preparing " + urlString + " to go into the download queue");
Intent intent = new Intent(context, DownloaderService.class);
intent.setAction(ACTION_QUEUE);
@@ -275,6 +265,9 @@ public class DownloaderService extends Service {
* @see #queue(Context, String)
*/
public static void cancel(Context context, String urlString) {
+ if (TextUtils.isEmpty(urlString)) {
+ return;
+ }
Utils.debugLog(TAG, "Preparing cancellation of " + urlString + " download");
Intent intent = new Intent(context, DownloaderService.class);
intent.setAction(ACTION_CANCEL);
diff --git a/app/src/test/java/org/fdroid/fdroid/updater/RepoXMLHandlerTest.java b/app/src/test/java/org/fdroid/fdroid/updater/RepoXMLHandlerTest.java
index 12751a40a..193c8dc23 100644
--- a/app/src/test/java/org/fdroid/fdroid/updater/RepoXMLHandlerTest.java
+++ b/app/src/test/java/org/fdroid/fdroid/updater/RepoXMLHandlerTest.java
@@ -26,6 +26,7 @@ import android.support.annotation.NonNull;
import android.text.TextUtils;
import android.util.Log;
+import org.apache.commons.io.FileUtils;
import org.fdroid.fdroid.BuildConfig;
import org.fdroid.fdroid.RepoXMLHandler;
import org.fdroid.fdroid.data.Apk;
@@ -33,15 +34,18 @@ import org.fdroid.fdroid.data.App;
import org.fdroid.fdroid.data.Repo;
import org.fdroid.fdroid.data.RepoPushRequest;
import org.fdroid.fdroid.mock.MockRepo;
+import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricGradleTestRunner;
import org.robolectric.annotation.Config;
+import org.robolectric.shadows.ShadowLog;
import org.xml.sax.InputSource;
import org.xml.sax.SAXException;
import org.xml.sax.XMLReader;
import java.io.BufferedInputStream;
+import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
@@ -70,6 +74,32 @@ public class RepoXMLHandlerTest {
private static final String FAKE_SIGNING_CERT = "012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345";
+ @Before
+ public void setUp() {
+ ShadowLog.stream = System.out;
+ }
+
+ @Test
+ public void testObbIndex() throws IOException {
+ writeResourceToObbDir("main.1101613.obb.main.twoversions.obb");
+ writeResourceToObbDir("main.1101615.obb.main.twoversions.obb");
+ writeResourceToObbDir("main.1434483388.obb.main.oldversion.obb");
+ writeResourceToObbDir("main.1619.obb.mainpatch.current.obb");
+ writeResourceToObbDir("patch.1619.obb.mainpatch.current.obb");
+ RepoDetails actualDetails = getFromFile("obbIndex.xml");
+ for (Apk indexApk : actualDetails.apks) {
+ Apk localApk = new Apk();
+ localApk.packageName = indexApk.packageName;
+ localApk.versionCode = indexApk.versionCode;
+ localApk.hashType = indexApk.hashType;
+ App.initInstalledObbFiles(localApk);
+ assertEquals(indexApk.obbMainFile, localApk.obbMainFile);
+ assertEquals(indexApk.obbMainFileSha256, localApk.obbMainFileSha256);
+ assertEquals(indexApk.obbPatchFile, localApk.obbPatchFile);
+ assertEquals(indexApk.obbPatchFileSha256, localApk.obbPatchFileSha256);
+ }
+ }
+
@Test
public void testSimpleIndex() {
Repo expectedRepo = new Repo();
@@ -871,4 +901,12 @@ public class RepoXMLHandlerTest {
}
}
+ private void writeResourceToObbDir(String assetName) throws IOException {
+ InputStream input = getClass().getClassLoader().getResourceAsStream(assetName);
+ String packageName = assetName.substring(assetName.indexOf("obb"),
+ assetName.lastIndexOf('.'));
+ File f = new File(App.getObbDir(packageName), assetName);
+ FileUtils.copyToFile(input, f);
+ input.close();
+ }
}
diff --git a/app/src/test/resources/main.1101613.obb.main.twoversions.obb b/app/src/test/resources/main.1101613.obb.main.twoversions.obb
new file mode 100644
index 000000000..421376db9
--- /dev/null
+++ b/app/src/test/resources/main.1101613.obb.main.twoversions.obb
@@ -0,0 +1 @@
+dummy
diff --git a/app/src/test/resources/main.1101615.obb.main.twoversions.obb b/app/src/test/resources/main.1101615.obb.main.twoversions.obb
new file mode 100644
index 000000000..421376db9
--- /dev/null
+++ b/app/src/test/resources/main.1101615.obb.main.twoversions.obb
@@ -0,0 +1 @@
+dummy
diff --git a/app/src/test/resources/main.1434483388.obb.main.oldversion.obb b/app/src/test/resources/main.1434483388.obb.main.oldversion.obb
new file mode 100644
index 000000000..421376db9
--- /dev/null
+++ b/app/src/test/resources/main.1434483388.obb.main.oldversion.obb
@@ -0,0 +1 @@
+dummy
diff --git a/app/src/test/resources/main.1619.obb.mainpatch.current.obb b/app/src/test/resources/main.1619.obb.mainpatch.current.obb
new file mode 100644
index 000000000..421376db9
--- /dev/null
+++ b/app/src/test/resources/main.1619.obb.mainpatch.current.obb
@@ -0,0 +1 @@
+dummy
diff --git a/app/src/test/resources/obbIndex.xml b/app/src/test/resources/obbIndex.xml
new file mode 100644
index 000000000..197d0eddf
--- /dev/null
+++ b/app/src/test/resources/obbIndex.xml
@@ -0,0 +1 @@
+