diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index f3cbd6f17..397de5db1 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -263,6 +263,9 @@
         <service
                 android:name=".installer.InstallHistoryService"
                 android:exported="false"/>
+        <service
+                android:name=".installer.ObfInstallerService"
+                android:exported="false"/>
         <service
                 android:name=".data.InstalledAppProviderService"
                 android:permission="android.permission.BIND_JOB_SERVICE"
diff --git a/app/src/main/java/org/fdroid/fdroid/data/Apk.java b/app/src/main/java/org/fdroid/fdroid/data/Apk.java
index abb480930..5d17d68fa 100644
--- a/app/src/main/java/org/fdroid/fdroid/data/Apk.java
+++ b/app/src/main/java/org/fdroid/fdroid/data/Apk.java
@@ -592,7 +592,13 @@ public class Apk extends ValueObject implements Comparable<Apk>, Parcelable {
             try {
                 File cachedFile = ApkCache.getApkDownloadPath(context, this.getCanonicalUrl());
                 ZipFile zipFile = new ZipFile(cachedFile);
-                if (zipFile.getEntry("META-INF/com/google/android/update-binary") != null) {
+                if (zipFile.size() == 1) {
+                    String name = zipFile.entries().nextElement().getName();
+                    if (name != null && name.endsWith(".obf")) {
+                        // temporarily cache this, it will be deleted after unzipping
+                        return context.getCacheDir();
+                    }
+                } else if (zipFile.getEntry("META-INF/com/google/android/update-binary") != null) {
                     // Over-The-Air update ZIP files
                     return new File(context.getApplicationInfo().dataDir + "/ota");
                 }
diff --git a/app/src/main/java/org/fdroid/fdroid/installer/FileInstallerActivity.java b/app/src/main/java/org/fdroid/fdroid/installer/FileInstallerActivity.java
index ab3e5c9f1..e084ec18e 100644
--- a/app/src/main/java/org/fdroid/fdroid/installer/FileInstallerActivity.java
+++ b/app/src/main/java/org/fdroid/fdroid/installer/FileInstallerActivity.java
@@ -5,12 +5,8 @@ import android.content.DialogInterface;
 import android.content.Intent;
 import android.content.pm.PackageManager;
 import android.net.Uri;
-import android.os.Build;
 import android.os.Bundle;
-import android.os.StrictMode;
-import android.util.Log;
 import android.view.ContextThemeWrapper;
-import android.webkit.MimeTypeMap;
 import android.widget.Toast;
 import androidx.annotation.NonNull;
 import androidx.appcompat.app.AlertDialog;
@@ -25,7 +21,6 @@ import org.fdroid.fdroid.data.Apk;
 
 import java.io.File;
 import java.io.IOException;
-import java.lang.reflect.Method;
 
 public class FileInstallerActivity extends FragmentActivity {
 
@@ -164,10 +159,11 @@ public class FileInstallerActivity extends FragmentActivity {
         }
         if (apk.isMediaInstalled(activity.getApplicationContext())) { // Copying worked
             Utils.debugLog(TAG, "Copying worked: " + localApkUri.getPath());
-            Toast.makeText(this, String.format(this.getString(R.string.app_installed_media), path.toString()),
-                    Toast.LENGTH_LONG).show();
-            installer.sendBroadcastInstall(canonicalUri, Installer.ACTION_INSTALL_COMPLETE);
-            postInstall(path);
+            if (!postInstall(canonicalUri, apk, path)) {
+                Toast.makeText(this, String.format(this.getString(R.string.app_installed_media), path.toString()),
+                        Toast.LENGTH_LONG).show();
+                installer.sendBroadcastInstall(canonicalUri, Installer.ACTION_INSTALL_COMPLETE);
+            }
         } else {
             installer.sendBroadcastInstall(canonicalUri, Installer.ACTION_INSTALL_INTERRUPTED);
         }
@@ -176,33 +172,16 @@ public class FileInstallerActivity extends FragmentActivity {
 
     /**
      * Run any file-type-specific processes after the file has been copied into place.
-     * <p>
-     * When this was written, OsmAnd only supported importing OBF files via a
-     * {@code file:///} URL, so this disables {@link android.os.FileUriExposedException}.
+     *
+     * @return whether this handles sending the {@link Installer#ACTION_INSTALL_COMPLETE}
+     * broadcast.
      */
-    private void postInstall(File path) {
-        if (path.getName().endsWith(".obf")) {
-            if (Build.VERSION.SDK_INT >= 24) {
-                try {
-                    Method m = StrictMode.class.getMethod("disableDeathOnFileUriExposure");
-                    m.invoke(null);
-                } catch (Exception e) {
-                    e.printStackTrace();
-                }
-            }
-            Intent intent = new Intent(Intent.ACTION_VIEW);
-            intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
-            String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension("obf");
-            intent.setDataAndType(Uri.fromFile(path), mimeType);
-            if (Build.VERSION.SDK_INT >= 23) {
-                intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
-            }
-            if (intent.resolveActivity(getPackageManager()) != null) {
-                startActivity(intent);
-            } else {
-                Log.i(TAG, "No Activity available to handle " + intent);
-            }
+    private boolean postInstall(Uri canonicalUri, Apk apk, File path) {
+        if (path.getName().endsWith(".obf") || path.getName().endsWith(".obf.zip")) {
+            ObfInstallerService.install(this, canonicalUri, apk, path);
+            return true;
         }
+        return false;
     }
 
     private void uninstallPackage(Apk apk) {
diff --git a/app/src/main/java/org/fdroid/fdroid/installer/ObfInstallerService.java b/app/src/main/java/org/fdroid/fdroid/installer/ObfInstallerService.java
new file mode 100644
index 000000000..8673b7fba
--- /dev/null
+++ b/app/src/main/java/org/fdroid/fdroid/installer/ObfInstallerService.java
@@ -0,0 +1,122 @@
+package org.fdroid.fdroid.installer;
+
+import android.app.IntentService;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Build;
+import android.os.Environment;
+import android.os.StrictMode;
+import android.text.TextUtils;
+import android.util.Log;
+import android.webkit.MimeTypeMap;
+import org.apache.commons.io.FileUtils;
+import org.fdroid.fdroid.data.Apk;
+
+import java.io.File;
+import java.io.IOException;
+import java.lang.reflect.Method;
+import java.util.zip.ZipEntry;
+import java.util.zip.ZipFile;
+
+/**
+ * An {@link IntentService} subclass for installing {@code .obf} and {@code .obf.zip}
+ * map files into OsmAnd.  This will unzip the {@code .obf}
+ */
+public class ObfInstallerService extends IntentService {
+    private static final String TAG = "ObfInstallerService";
+
+    private static final String ACTION_INSTALL_OBF = "org.fdroid.fdroid.installer.action.INSTALL_OBF";
+
+    private static final String EXTRA_OBF_PATH = "org.fdroid.fdroid.installer.extra.OBF_PATH";
+
+    public ObfInstallerService() {
+        super("ObfInstallerService");
+    }
+
+    public static void install(Context context, Uri canonicalUri, Apk apk, File path) {
+        Intent intent = new Intent(context, ObfInstallerService.class);
+        intent.setAction(ACTION_INSTALL_OBF);
+        intent.putExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL, canonicalUri.toString());
+        intent.putExtra(Installer.EXTRA_APK, apk);
+        intent.putExtra(EXTRA_OBF_PATH, path.getAbsolutePath());
+        context.startService(intent);
+    }
+
+    @Override
+    protected void onHandleIntent(Intent intent) {
+        if (intent == null || !ACTION_INSTALL_OBF.equals(intent.getAction())) {
+            Log.e(TAG, "received invalid intent: " + intent);
+            return;
+        }
+        Uri canonicalUri = Uri.parse(intent.getStringExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL));
+        final Apk apk = intent.getParcelableExtra(Installer.EXTRA_APK);
+        final String path = intent.getStringExtra(EXTRA_OBF_PATH);
+        final String extension = MimeTypeMap.getFileExtensionFromUrl(path);
+        if ("obf".equals(extension)) {
+            sendPostInstallAndCompleteIntents(canonicalUri, apk, new File(path));
+            return;
+        }
+        if (!"zip".equals(extension)) {
+            sendBroadcastInstall(Installer.ACTION_INSTALL_INTERRUPTED, canonicalUri, apk,
+                    "Only .obf and .zip files are supported: " + path);
+            return;
+        }
+        try {
+            File zip = new File(path);
+            ZipFile zipFile = new ZipFile(zip);
+            if (zipFile.size() < 1) {
+                sendBroadcastInstall(Installer.ACTION_INSTALL_INTERRUPTED, canonicalUri, apk,
+                        "Corrupt or empty ZIP file!");
+            }
+            ZipEntry zipEntry = zipFile.entries().nextElement();
+            File extracted = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
+                    zipEntry.getName());
+            FileUtils.copyInputStreamToFile(zipFile.getInputStream(zipEntry), extracted);
+            zip.delete();
+            sendPostInstallAndCompleteIntents(canonicalUri, apk, extracted);
+        } catch (IOException e) {
+            e.printStackTrace();
+            sendBroadcastInstall(Installer.ACTION_INSTALL_INTERRUPTED, canonicalUri, apk, e.getMessage());
+        }
+    }
+
+    private void sendBroadcastInstall(String action, Uri canonicalUri, Apk apk, String msg) {
+        Installer.sendBroadcastInstall(this, canonicalUri, action, apk, null, msg);
+    }
+
+    /**
+     * Once the file is downloaded and installed, send an {@link Intent} to
+     * let map apps know that the file is available for install.
+     * <p>
+     * When this was written, OsmAnd only supported importing OBF files via a
+     * {@code file:///} URL, so this disables {@link android.os.FileUriExposedException}.
+     */
+    void sendPostInstallAndCompleteIntents(Uri canonicalUri, Apk apk, File file) {
+        if (Build.VERSION.SDK_INT >= 24) {
+            try {
+                Method m = StrictMode.class.getMethod("disableDeathOnFileUriExposure");
+                m.invoke(null);
+            } catch (Exception e) {
+                e.printStackTrace();
+            }
+        }
+
+        Intent intent = new Intent(Intent.ACTION_VIEW);
+        intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_NEW_TASK);
+        String mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension("obf");
+        if (TextUtils.isEmpty(mimeType)) {
+            mimeType = "application/octet-stream";
+        }
+        intent.setDataAndType(Uri.fromFile(file), mimeType);
+        if (Build.VERSION.SDK_INT >= 23) {
+            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
+        }
+        if (intent != null && intent.resolveActivity(getPackageManager()) != null) {
+            startActivity(intent);
+        } else {
+            Log.i(TAG, "No Activity available to handle " + intent);
+        }
+        sendBroadcastInstall(Installer.ACTION_INSTALL_COMPLETE, canonicalUri, apk, null);
+    }
+}
\ No newline at end of file
diff --git a/app/src/test/java/org/fdroid/fdroid/data/ApkTest.java b/app/src/test/java/org/fdroid/fdroid/data/ApkTest.java
index 96bda89ec..93c5124c1 100644
--- a/app/src/test/java/org/fdroid/fdroid/data/ApkTest.java
+++ b/app/src/test/java/org/fdroid/fdroid/data/ApkTest.java
@@ -75,7 +75,7 @@ public class ApkTest {
         assertFalse(apk.isApk());
         copyResourceFileToCache(apk);
         File path = apk.getMediaInstallPath(context);
-        assertEquals(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), path);
+        assertEquals(context.getCacheDir(), path);
     }
 
     private void copyResourceFileToCache(Apk apk) throws IOException {