diff --git a/app/src/androidTest/java/org/fdroid/fdroid/AssetUtils.java b/app/src/androidTest/java/org/fdroid/fdroid/AssetUtils.java index 65a299b7d..95e12a021 100644 --- a/app/src/androidTest/java/org/fdroid/fdroid/AssetUtils.java +++ b/app/src/androidTest/java/org/fdroid/fdroid/AssetUtils.java @@ -1,8 +1,8 @@ package org.fdroid.fdroid; import android.content.Context; -import androidx.annotation.Nullable; import android.util.Log; +import androidx.annotation.Nullable; import java.io.File; import java.io.FileOutputStream; @@ -16,6 +16,9 @@ public class AssetUtils { private static final String TAG = "Utils"; + /** + * This requires {@link Context} from {@link android.app.Instrumentation#getContext()} + */ @Nullable public static File copyAssetToDir(Context context, String assetName, File directory) { File tempFile = null; @@ -28,6 +31,7 @@ public class AssetUtils { output = new FileOutputStream(tempFile); Utils.copy(input, output); } catch (IOException e) { + Log.e(TAG, "Check the context is from Instrumentation.getContext()"); fail(e.getMessage()); } finally { Utils.closeQuietly(output); diff --git a/app/src/androidTest/java/org/fdroid/fdroid/MainActivityEspressoTest.java b/app/src/androidTest/java/org/fdroid/fdroid/MainActivityEspressoTest.java index 071609401..2f1b5877f 100644 --- a/app/src/androidTest/java/org/fdroid/fdroid/MainActivityEspressoTest.java +++ b/app/src/androidTest/java/org/fdroid/fdroid/MainActivityEspressoTest.java @@ -7,10 +7,10 @@ import android.content.Context; import android.os.Build; import androidx.test.core.app.ApplicationProvider; +import androidx.test.filters.LargeTest; import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.espresso.IdlingPolicies; import androidx.test.espresso.ViewInteraction; -import androidx.test.filters.LargeTest; import androidx.test.rule.ActivityTestRule; import androidx.test.rule.GrantPermissionRule; import androidx.test.ext.junit.runners.AndroidJUnit4; @@ -206,7 +206,7 @@ public class MainActivityEspressoTest { onView(withId(R.id.version)).check(matches(isDisplayed())); onView(withId(R.id.ok_button)).perform(click()); - onView(withId(R.id.list)).perform(swipeUp()).perform(swipeUp()).perform(swipeUp()); + onView(withId(android.R.id.list_container)).perform(swipeUp()).perform(swipeUp()).perform(swipeUp()); } @LargeTest diff --git a/app/src/androidTest/java/org/fdroid/fdroid/installer/ApkVerifierTest.java b/app/src/androidTest/java/org/fdroid/fdroid/installer/ApkVerifierTest.java index d40cce4a9..ee18b006f 100644 --- a/app/src/androidTest/java/org/fdroid/fdroid/installer/ApkVerifierTest.java +++ b/app/src/androidTest/java/org/fdroid/fdroid/installer/ApkVerifierTest.java @@ -22,10 +22,10 @@ package org.fdroid.fdroid.installer; import android.app.Instrumentation; import android.net.Uri; import android.os.Build; -import androidx.annotation.NonNull; -import androidx.test.platform.app.InstrumentationRegistry; -import androidx.test.ext.junit.runners.AndroidJUnit4; import android.util.Log; +import androidx.annotation.NonNull; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; import org.fdroid.fdroid.AssetUtils; import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.compat.FileCompatTest; @@ -113,7 +113,7 @@ public class ApkVerifierTest { Apk apk = new Apk(); apk.packageName = "org.fdroid.permissions.sdk14"; apk.targetSdkVersion = 14; - String[] noPrefixPermissions = new String[]{ + ArrayList noPrefixPermissionsList = new ArrayList<>(Arrays.asList( "AUTHENTICATE_ACCOUNTS", "MANAGE_ACCOUNTS", "READ_PROFILE", @@ -129,8 +129,13 @@ public class ApkVerifierTest { "READ_SYNC_SETTINGS", "WRITE_SYNC_SETTINGS", "WRITE_CALL_LOG", // implied-permission! - "READ_CALL_LOG", // implied-permission! - }; + "READ_CALL_LOG" // implied-permission! + )); + if (Build.VERSION.SDK_INT >= 29) { + noPrefixPermissionsList.add("android.permission.ACCESS_MEDIA_LOCATION"); + } + String[] noPrefixPermissions = noPrefixPermissionsList.toArray(new String[0]); + for (int i = 0; i < noPrefixPermissions.length; i++) { noPrefixPermissions[i] = RepoXMLHandler.fdroidToAndroidPermission(noPrefixPermissions[i]); } @@ -177,7 +182,7 @@ public class ApkVerifierTest { Apk apk = new Apk(); apk.packageName = "org.fdroid.permissions.sdk14"; apk.targetSdkVersion = 14; - apk.requestedPermissions = new String[]{ + TreeSet expectedSet = new TreeSet<>(Arrays.asList( "android.permission.AUTHENTICATE_ACCOUNTS", "android.permission.MANAGE_ACCOUNTS", "android.permission.READ_PROFILE", @@ -193,8 +198,12 @@ public class ApkVerifierTest { "android.permission.READ_SYNC_SETTINGS", "android.permission.WRITE_SYNC_SETTINGS", "android.permission.WRITE_CALL_LOG", // implied-permission! - "android.permission.READ_CALL_LOG", // implied-permission! - }; + "android.permission.READ_CALL_LOG"// implied-permission! + )); + if (Build.VERSION.SDK_INT >= 29) { + expectedSet.add("android.permission.ACCESS_MEDIA_LOCATION"); + } + apk.requestedPermissions = expectedSet.toArray(new String[0]); Uri uri = Uri.fromFile(sdk14Apk); @@ -371,6 +380,9 @@ public class ApkVerifierTest { "android.permission.MANAGE_ACCOUNTS" )); } + if (Build.VERSION.SDK_INT >= 29) { + expectedSet.add("android.permission.ACCESS_MEDIA_LOCATION"); + } Apk apk = actualDetails.apks.get(1); Log.i(TAG, "APK: " + apk.apkName); HashSet actualSet = new HashSet<>(Arrays.asList(apk.requestedPermissions)); @@ -407,6 +419,9 @@ public class ApkVerifierTest { "org.dmfs.permission.READ_TASKS", "org.dmfs.permission.WRITE_TASKS" )); + if (Build.VERSION.SDK_INT >= 29) { + expectedSet.add("android.permission.ACCESS_MEDIA_LOCATION"); + } expectedPermissions = expectedSet.toArray(new String[expectedSet.size()]); apk = actualDetails.apks.get(2); Log.i(TAG, "APK: " + apk.apkName); 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 @@ + , Parcelable { } /** - * Get the install path for a "non-apk" media file - * Defaults to {@link android.os.Environment#DIRECTORY_DOWNLOADS} + * Get the install path for a "non-apk" media file, with special cases for + * files that can be usefully installed without PrivilegedExtension. + * Defaults to {@link android.os.Environment#DIRECTORY_DOWNLOADS}. * * @return the install path for this {@link Apk} + * @link Inside OTA Packages */ public File getMediaInstallPath(Context context) { @@ -562,12 +569,15 @@ public class Apk extends ValueObject implements Comparable, Parcelable { String fileExtension = MimeTypeMap.getFileExtensionFromUrl(this.getCanonicalUrl()); if (TextUtils.isEmpty(fileExtension)) return path; MimeTypeMap mimeTypeMap = MimeTypeMap.getSingleton(); - String[] mimeType = mimeTypeMap.getMimeTypeFromExtension(fileExtension).split("/"); - String topLevelType; - if (mimeType.length == 0) { - topLevelType = ""; - } else { - topLevelType = mimeType[0]; + String mimeType = mimeTypeMap.getMimeTypeFromExtension(fileExtension); + String topLevelType = null; + if (!TextUtils.isEmpty(mimeType)) { + String[] mimeTypeSections = mimeType.split("/"); + if (mimeTypeSections.length == 0) { + topLevelType = ""; + } else { + topLevelType = mimeTypeSections[0]; + } } if ("audio".equals(topLevelType)) { path = Environment.getExternalStoragePublicDirectory( @@ -578,17 +588,42 @@ public class Apk extends ValueObject implements Comparable, Parcelable { } else if ("video".equals(topLevelType)) { path = Environment.getExternalStoragePublicDirectory( Environment.DIRECTORY_MOVIES); - // TODO support OsmAnd map files, other map apps? - //} else if (mimeTypeMap.hasExtension("map")) { // OsmAnd map files - //} else if (this.apkName.matches(".*.ota_[0-9]*.zip")) { // Over-The-Air update ZIP files - } else if (this.apkName.endsWith(".zip")) { // Over-The-Air update ZIP files - path = new File(context.getApplicationInfo().dataDir + "/ota"); + } else if ("zip".equals(fileExtension)) { + try { + File cachedFile = ApkCache.getApkDownloadPath(context, this.getCanonicalUrl()); + ZipFile zipFile = new ZipFile(cachedFile); + 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"); + } + } catch (IOException e) { + // this should happen when running isMediaInstalled() and the file isn't installed + // other cases are probably bugs + if (BuildConfig.DEBUG) e.printStackTrace(); + } + return path; + } else if ("apk".equals(fileExtension)) { + throw new IllegalStateException("APKs should not be handled in the media install path!"); } return path; } + public File getInstalledMediaFile(Context context) { + return new File(this.getMediaInstallPath(context), SanitizedFile.sanitizeFileName(this.apkName)); + } + + /** + * Check whether a media file is "installed" as based on the file type's + * install path, derived in {@link #getMediaInstallPath(Context)} + */ public boolean isMediaInstalled(Context context) { - return new File(this.getMediaInstallPath(context), SanitizedFile.sanitizeFileName(this.apkName)).isFile(); + return getInstalledMediaFile(context).isFile(); } /** @@ -598,6 +633,7 @@ public class Apk extends ValueObject implements Comparable, Parcelable { * @return true if this is an apk instead of a non-apk/media file */ public boolean isApk() { - return this.apkName == null || this.apkName.endsWith(".apk"); + return apkName == null + || apkName.substring(apkName.length() - 4).toLowerCase(Locale.ENGLISH).endsWith(".apk"); } } 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 b97738fc7..b20db721d 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/ApkVerifier.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/ApkVerifier.java @@ -23,9 +23,9 @@ import android.content.Context; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; import android.net.Uri; -import androidx.annotation.Nullable; import android.text.TextUtils; import android.util.Log; +import androidx.annotation.Nullable; import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.data.Apk; 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 4b7d3edc1..4003d042a 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/DefaultInstaller.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/DefaultInstaller.java @@ -47,7 +47,7 @@ public class DefaultInstaller extends Installer { Intent installIntent = new Intent(context, DefaultInstallerActivity.class); installIntent.setAction(DefaultInstallerActivity.ACTION_INSTALL_PACKAGE); - installIntent.putExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL, canonicalUri); + installIntent.putExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL, canonicalUri.toString()); installIntent.putExtra(Installer.EXTRA_APK, apk); installIntent.setData(localApkUri); 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 b60b498c0..595718b83 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/DefaultInstallerActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/DefaultInstallerActivity.java @@ -67,7 +67,7 @@ public class DefaultInstallerActivity extends FragmentActivity { installer = new DefaultInstaller(this, apk); if (ACTION_INSTALL_PACKAGE.equals(action)) { Uri localApkUri = intent.getData(); - canonicalUri = intent.getParcelableExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL); + canonicalUri = Uri.parse(intent.getStringExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL)); installPackage(localApkUri); } else if (ACTION_UNINSTALL_PACKAGE.equals(action)) { uninstallPackage(apk.packageName); diff --git a/app/src/main/java/org/fdroid/fdroid/installer/FileInstaller.java b/app/src/main/java/org/fdroid/fdroid/installer/FileInstaller.java index 853ff8329..3d86123b6 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/FileInstaller.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/FileInstaller.java @@ -51,7 +51,7 @@ public class FileInstaller extends Installer { protected void installPackageInternal(Uri localApkUri, Uri canonicalUri) { Intent installIntent = new Intent(context, FileInstallerActivity.class); installIntent.setAction(FileInstallerActivity.ACTION_INSTALL_FILE); - installIntent.putExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL, canonicalUri); + installIntent.putExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL, canonicalUri.toString()); installIntent.putExtra(Installer.EXTRA_APK, apk); installIntent.setData(localApkUri); 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 7896ac3fe..e084ec18e 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/FileInstallerActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/FileInstallerActivity.java @@ -6,13 +6,13 @@ import android.content.Intent; import android.content.pm.PackageManager; import android.net.Uri; import android.os.Bundle; -import androidx.annotation.NonNull; -import androidx.core.app.ActivityCompat; -import androidx.fragment.app.FragmentActivity; -import androidx.core.content.ContextCompat; -import androidx.appcompat.app.AlertDialog; import android.view.ContextThemeWrapper; import android.widget.Toast; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.FragmentActivity; import org.apache.commons.io.FileUtils; import org.fdroid.fdroid.FDroidApp; import org.fdroid.fdroid.R; @@ -53,10 +53,10 @@ public class FileInstallerActivity extends FragmentActivity { Intent intent = getIntent(); String action = intent.getAction(); localApkUri = intent.getData(); - canonicalUri = intent.getParcelableExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL); apk = intent.getParcelableExtra(Installer.EXTRA_APK); installer = new FileInstaller(this, apk); if (ACTION_INSTALL_FILE.equals(action)) { + canonicalUri = Uri.parse(intent.getStringExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL)); if (hasStoragePermission()) { installPackage(localApkUri, canonicalUri, apk); } else { @@ -64,6 +64,7 @@ public class FileInstallerActivity extends FragmentActivity { act = 1; } } else if (ACTION_UNINSTALL_FILE.equals(action)) { + canonicalUri = null; if (hasStoragePermission()) { uninstallPackage(apk); } else { @@ -148,28 +149,44 @@ public class FileInstallerActivity extends FragmentActivity { private void installPackage(Uri localApkUri, Uri canonicalUri, Apk apk) { Utils.debugLog(TAG, "Installing: " + localApkUri.getPath()); - File path = apk.getMediaInstallPath(activity.getApplicationContext()); - path.mkdirs(); + File path = apk.getInstalledMediaFile(activity.getApplicationContext()); + path.getParentFile().mkdirs(); try { - FileUtils.copyFileToDirectory(new File(localApkUri.getPath()), path); + FileUtils.copyFile(new File(localApkUri.getPath()), path); } catch (IOException e) { Utils.debugLog(TAG, "Failed to copy: " + e.getMessage()); installer.sendBroadcastInstall(canonicalUri, Installer.ACTION_INSTALL_INTERRUPTED); } 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); + 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); } finish(); } + /** + * Run any file-type-specific processes after the file has been copied into place. + * + * @return whether this handles sending the {@link Installer#ACTION_INSTALL_COMPLETE} + * broadcast. + */ + 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) { if (apk.isMediaInstalled(activity.getApplicationContext())) { - File file = new File(apk.getMediaInstallPath(activity.getApplicationContext()), apk.apkName); + File file = apk.getInstalledMediaFile(activity.getApplicationContext()); if (!file.delete()) { installer.sendBroadcastUninstall(Installer.ACTION_UNINSTALL_INTERRUPTED); return; 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 7a7cc2c62..abe8a784e 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/InstallManagerService.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/InstallManagerService.java @@ -10,10 +10,10 @@ import android.content.SharedPreferences; import android.content.pm.PackageInfo; import android.net.Uri; import android.os.IBinder; -import androidx.annotation.NonNull; -import androidx.localbroadcastmanager.content.LocalBroadcastManager; import android.text.TextUtils; import android.util.Log; +import androidx.annotation.NonNull; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; import org.apache.commons.io.FileUtils; import org.apache.commons.io.filefilter.WildcardFileFilter; import org.fdroid.fdroid.AppUpdateStatusManager; @@ -68,7 +68,8 @@ import java.io.IOException; *
  • for a {@code String} ID, use {@code canonicalUrl}, {@link Uri#toString()}, or * {@link Intent#getDataString()} *
  • for an {@code int} ID, use {@link String#hashCode()} or {@link Uri#hashCode()} - *
  • for an {@link Intent} extra, use {@link org.fdroid.fdroid.net.Downloader#EXTRA_CANONICAL_URL} + *
  • for an {@link Intent} extra, use {@link org.fdroid.fdroid.net.Downloader#EXTRA_CANONICAL_URL} and include a + * {@link String} instance *

    * The implementations of {@link Uri#toString()} and {@link Intent#getDataString()} both * include caching of the generated {@code String}, so it should be plenty fast. 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 fe38c8326..ba2bde3bf 100644 --- a/app/src/main/java/org/fdroid/fdroid/installer/InstallerService.java +++ b/app/src/main/java/org/fdroid/fdroid/installer/InstallerService.java @@ -74,7 +74,7 @@ public class InstallerService extends JobIntentService { if (ACTION_INSTALL.equals(intent.getAction())) { Uri uri = intent.getData(); - Uri canonicalUri = intent.getParcelableExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL); + Uri canonicalUri = Uri.parse(intent.getStringExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL)); installer.installPackage(uri, canonicalUri); } else if (ACTION_UNINSTALL.equals(intent.getAction())) { installer.uninstallPackage(); @@ -124,7 +124,7 @@ public class InstallerService extends JobIntentService { Intent intent = new Intent(context, InstallerService.class); intent.setAction(ACTION_INSTALL); intent.setData(localApkUri); - intent.putExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL, canonicalUri); + intent.putExtra(org.fdroid.fdroid.net.Downloader.EXTRA_CANONICAL_URL, canonicalUri.toString()); intent.putExtra(Installer.EXTRA_APK, apk); enqueueWork(context, intent); } 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. + *

    + * 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/main/java/org/fdroid/fdroid/net/Downloader.java b/app/src/main/java/org/fdroid/fdroid/net/Downloader.java index c399013ad..11c7320f9 100644 --- a/app/src/main/java/org/fdroid/fdroid/net/Downloader.java +++ b/app/src/main/java/org/fdroid/fdroid/net/Downloader.java @@ -1,8 +1,8 @@ package org.fdroid.fdroid.net; import android.net.Uri; -import androidx.annotation.NonNull; import android.text.format.DateUtils; +import androidx.annotation.NonNull; import org.fdroid.fdroid.ProgressListener; import org.fdroid.fdroid.Utils; @@ -34,6 +34,8 @@ public abstract class Downloader { /** * Unique ID used to represent this specific package's install process, * including {@link android.app.Notification}s, also known as {@code canonicalUrl}. + * Careful about types, this should always be a {@link String}, so it can + * be handled on the receiving side by {@link android.content.Intent#getStringArrayExtra(String)}. * * @see org.fdroid.fdroid.installer.InstallManagerService * @see android.content.Intent#EXTRA_ORIGINATING_URI diff --git a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java index 5673b2a79..7cef1e286 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java +++ b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java @@ -9,20 +9,6 @@ import android.content.res.Resources; import android.graphics.Rect; import android.net.Uri; import android.os.Build; -import androidx.annotation.DrawableRes; -import androidx.annotation.LayoutRes; -import androidx.annotation.NonNull; -import androidx.core.content.ContextCompat; -import androidx.core.content.FileProvider; -import androidx.core.os.ConfigurationCompat; -import androidx.core.os.LocaleListCompat; -import androidx.core.view.ViewCompat; -import androidx.core.widget.TextViewCompat; -import androidx.appcompat.app.AlertDialog; -import androidx.gridlayout.widget.GridLayout; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.LinearSmoothScroller; -import androidx.recyclerview.widget.RecyclerView; import android.text.Html; import android.text.Spannable; import android.text.Spanned; @@ -42,6 +28,20 @@ import android.widget.LinearLayout; import android.widget.ProgressBar; import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.DrawableRes; +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.ContextCompat; +import androidx.core.content.FileProvider; +import androidx.core.os.ConfigurationCompat; +import androidx.core.os.LocaleListCompat; +import androidx.core.view.ViewCompat; +import androidx.core.widget.TextViewCompat; +import androidx.gridlayout.widget.GridLayout; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.LinearSmoothScroller; +import androidx.recyclerview.widget.RecyclerView; import org.apache.commons.io.FilenameUtils; import org.fdroid.fdroid.Preferences; import org.fdroid.fdroid.R; @@ -405,7 +405,7 @@ public class AppDetailsRecyclerViewAdapter whatsNewView = (TextView) view.findViewById(R.id.whats_new); descriptionView = (TextView) view.findViewById(R.id.description); descriptionMoreView = (TextView) view.findViewById(R.id.description_more); - antiFeaturesSectionView = view.findViewById(R.id.anti_features_section); + antiFeaturesSectionView = view.findViewById(R.id.anti_features_section); antiFeaturesLabelView = (TextView) view.findViewById(R.id.label_anti_features); antiFeaturesWarningView = view.findViewById(R.id.anti_features_warning); antiFeaturesListingView = view.findViewById(R.id.anti_features_full_listing); @@ -604,7 +604,7 @@ public class AppDetailsRecyclerViewAdapter } }); } else if (!app.isApk && mediaApk != null) { - final File installedFile = new File(mediaApk.getMediaInstallPath(context), mediaApk.apkName); + final File installedFile = mediaApk.getInstalledMediaFile(context); if (!installedFile.toString().startsWith(context.getApplicationInfo().dataDir)) { final Intent viewIntent = new Intent(Intent.ACTION_VIEW); Uri uri = FileProvider.getUriForFile(context, Installer.AUTHORITY, installedFile); diff --git a/app/src/test/java/org/fdroid/fdroid/TestUtils.java b/app/src/test/java/org/fdroid/fdroid/TestUtils.java index ef5a46804..170f03f8a 100644 --- a/app/src/test/java/org/fdroid/fdroid/TestUtils.java +++ b/app/src/test/java/org/fdroid/fdroid/TestUtils.java @@ -7,9 +7,7 @@ import android.content.Context; import android.content.ContextWrapper; import android.content.pm.ProviderInfo; import android.net.Uri; - import androidx.test.core.app.ApplicationProvider; - import org.fdroid.fdroid.data.Apk; import org.fdroid.fdroid.data.ApkProvider; import org.fdroid.fdroid.data.App; @@ -97,13 +95,13 @@ public class TestUtils { } public static App insertApp(Context context, String packageName, String appName, int suggestedVersionCode, - String repoUrl, String preferredSigner) { + String repoUrl, String preferredSigner) { Repo repo = ensureRepo(context, repoUrl); return insertApp(context, packageName, appName, suggestedVersionCode, repo, preferredSigner); } public static App insertApp(Context context, String packageName, String appName, int suggestedVersionCode, - Repo repo, String preferredSigner) { + Repo repo, String preferredSigner) { ContentValues values = new ContentValues(); values.put(Schema.AppMetadataTable.Cols.REPO_ID, repo.getId()); values.put(Schema.AppMetadataTable.Cols.SUGGESTED_VERSION_CODE, suggestedVersionCode); diff --git a/app/src/test/java/org/fdroid/fdroid/data/ApkTest.java b/app/src/test/java/org/fdroid/fdroid/data/ApkTest.java new file mode 100644 index 000000000..93c5124c1 --- /dev/null +++ b/app/src/test/java/org/fdroid/fdroid/data/ApkTest.java @@ -0,0 +1,85 @@ +package org.fdroid.fdroid.data; + +import android.content.ContextWrapper; +import android.os.Environment; +import android.webkit.MimeTypeMap; +import androidx.test.core.app.ApplicationProvider; +import org.apache.commons.io.FileUtils; +import org.fdroid.fdroid.installer.ApkCache; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.Shadows; +import org.robolectric.shadows.ShadowLog; +import org.robolectric.shadows.ShadowMimeTypeMap; + +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; + +@RunWith(RobolectricTestRunner.class) +public class ApkTest { + public static final String TAG = "ApkTest"; + + private static ContextWrapper context; + + @Before + public final void setUp() { + context = ApplicationProvider.getApplicationContext(); + ShadowMimeTypeMap mimeTypeMap = Shadows.shadowOf(MimeTypeMap.getSingleton()); + mimeTypeMap.addExtensionMimeTypMapping("apk", "application/vnd.android.package-archive"); + mimeTypeMap.addExtensionMimeTypMapping("obf", "application/octet-stream"); + mimeTypeMap.addExtensionMimeTypMapping("zip", "application/zip"); + ShadowLog.stream = System.out; + } + + @Test(expected = IllegalStateException.class) + public void testGetMediaInstallPathWithApk() { + Apk apk = new Apk(); + apk.apkName = "test.apk"; + apk.repoAddress = "https://example.com/fdroid/repo"; + assertTrue(apk.isApk()); + apk.getMediaInstallPath(context); + } + + @Test + public void testGetMediaInstallPathWithOta() throws IOException { + Apk apk = new Apk(); + apk.apkName = "org.fdroid.fdroid.privileged.ota_2110.zip"; + apk.repoAddress = "https://example.com/fdroid/repo"; + assertFalse(apk.isApk()); + copyResourceFileToCache(apk); + File path = apk.getMediaInstallPath(context); + assertEquals(new File(context.getApplicationInfo().dataDir + "/ota"), path); + } + + @Test + public void testGetMediaInstallPathWithObf() { + Apk apk = new Apk(); + apk.apkName = "Norway_bouvet_europe_2.obf"; + apk.repoAddress = "https://example.com/fdroid/repo"; + assertFalse(apk.isApk()); + File path = apk.getMediaInstallPath(context); + assertEquals(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), path); + } + + @Test + public void testGetMediaInstallPathWithObfZip() throws IOException { + Apk apk = new Apk(); + apk.apkName = "Norway_bouvet_europe_2.obf.zip"; + apk.repoAddress = "https://example.com/fdroid/repo"; + assertFalse(apk.isApk()); + copyResourceFileToCache(apk); + File path = apk.getMediaInstallPath(context); + assertEquals(context.getCacheDir(), path); + } + + private void copyResourceFileToCache(Apk apk) throws IOException { + FileUtils.copyInputStreamToFile(getClass().getClassLoader().getResource(apk.apkName).openStream(), + ApkCache.getApkDownloadPath(context, apk.getCanonicalUrl())); + } +} 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..1069e0d6d --- /dev/null +++ b/app/src/test/java/org/fdroid/fdroid/installer/ApkCacheTest.java @@ -0,0 +1,62 @@ +package org.fdroid.fdroid.installer; + +import android.content.ContextWrapper; +import android.util.Log; +import androidx.test.core.app.ApplicationProvider; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.shadows.ShadowLog; + +import java.io.File; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +@RunWith(RobolectricTestRunner.class) +public class ApkCacheTest { + private static final String TAG = "ApkCacheTest"; + + private ContextWrapper context; + private File cacheDir; + + @Before + public final void setUp() { + context = ApplicationProvider.getApplicationContext(); + cacheDir = ApkCache.getApkCacheDir(context); + ShadowLog.stream = System.out; + } + + @Test + public void testGetApkCacheDir() { + Log.i(TAG, "path: " + cacheDir); + assertTrue("Must be full path", cacheDir.isAbsolute()); + assertTrue("Must be a directory", cacheDir.isDirectory()); + assertTrue("Must be writable", cacheDir.canWrite()); + } + + @Test + public void testGetApkDownloadPath() { + assertEquals("Should be in folder based on repo hostname", + new File(cacheDir, "f-droid.org--1/org.fdroid.fdroid_1008000.apk"), + ApkCache.getApkDownloadPath(context, + "https://f-droid.org/repo/org.fdroid.fdroid_1008000.apk")); + assertEquals("Should be in folder based on repo hostname with port number", + new File(cacheDir, "192.168.234.12-8888/sun.bob.leela_2.apk"), + ApkCache.getApkDownloadPath(context, + "http://192.168.234.12:8888/fdroid/repo/sun.bob.leela_2.apk")); + assertEquals("Should work for OTA files also", + new File(cacheDir, "f-droid.org--1/org.fdroid.fdroid.privileged.ota_2110.zip"), + ApkCache.getApkDownloadPath(context, + "http://f-droid.org/fdroid/repo/org.fdroid.fdroid.privileged.ota_2110.zip")); + assertEquals("Should work for ZIP files also", + new File(cacheDir, "example.com--1/Norway_bouvet_europe_2.obf.zip"), + ApkCache.getApkDownloadPath(context, + "https://example.com/fdroid/repo/Norway_bouvet_europe_2.obf.zip")); + assertEquals("Should work for OBF files also", + new File(cacheDir, "example.com--1/Norway_bouvet_europe_2.obf"), + ApkCache.getApkDownloadPath(context, + "https://example.com/fdroid/repo/Norway_bouvet_europe_2.obf")); + } +} diff --git a/app/src/test/java/org/fdroid/fdroid/installer/FileInstallerTest.java b/app/src/test/java/org/fdroid/fdroid/installer/FileInstallerTest.java new file mode 100644 index 000000000..0fb3976a2 --- /dev/null +++ b/app/src/test/java/org/fdroid/fdroid/installer/FileInstallerTest.java @@ -0,0 +1,47 @@ +package org.fdroid.fdroid.installer; + +import android.content.ContextWrapper; +import androidx.test.core.app.ApplicationProvider; +import org.fdroid.fdroid.Preferences; +import org.fdroid.fdroid.TestUtils; +import org.fdroid.fdroid.data.Apk; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.shadows.ShadowLog; + +import java.io.IOException; +import java.util.Enumeration; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; + +@RunWith(RobolectricTestRunner.class) +public class FileInstallerTest { + public static final String TAG = "FileInstallerTest"; + + private ContextWrapper context; + + @Before + public final void setUp() { + context = ApplicationProvider.getApplicationContext(); + Preferences.setupForTests(context); + ShadowLog.stream = System.out; + } + + @Test + public void testInstallOtaZip() { + Apk apk = new Apk(); + apk.apkName = "org.fdroid.fdroid.privileged.ota_2010.zip"; + apk.packageName = "org.fdroid.fdroid.privileged.ota"; + apk.versionCode = 2010; + assertFalse(apk.isApk()); + Installer installer = InstallerFactory.create(context, apk); + assertEquals("should be a FileInstaller", + FileInstaller.class, + installer.getClass()); + } +} diff --git a/app/src/test/java/org/fdroid/fdroid/installer/InstallerFactoryTest.java b/app/src/test/java/org/fdroid/fdroid/installer/InstallerFactoryTest.java new file mode 100644 index 000000000..a455fd57d --- /dev/null +++ b/app/src/test/java/org/fdroid/fdroid/installer/InstallerFactoryTest.java @@ -0,0 +1,50 @@ +package org.fdroid.fdroid.installer; + +import android.content.ContextWrapper; +import androidx.test.core.app.ApplicationProvider; +import org.fdroid.fdroid.Preferences; +import org.fdroid.fdroid.data.Apk; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; + +import static org.junit.Assert.assertEquals; + +@RunWith(RobolectricTestRunner.class) +public class InstallerFactoryTest { + + private ContextWrapper context; + + @Before + public final void setUp() { + context = ApplicationProvider.getApplicationContext(); + Preferences.setupForTests(context); + } + + @Test + public void testApkInstallerInstance() { + for (String filename : new String[]{"test.apk", "A.APK", "b.ApK"}) { + Apk apk = new Apk(); + apk.apkName = filename; + apk.packageName = "test"; + Installer installer = InstallerFactory.create(context, apk); + assertEquals(filename + " should use a DefaultInstaller", + DefaultInstaller.class, + installer.getClass()); + } + } + + @Test + public void testFileInstallerInstance() { + for (String filename : new String[]{"org.fdroid.fdroid.privileged.ota_2110.zip", "test.ZIP"}) { + Apk apk = new Apk(); + apk.apkName = filename; + apk.packageName = "cafe0088"; + Installer installer = InstallerFactory.create(context, apk); + assertEquals("should be a FileInstaller", + FileInstaller.class, + installer.getClass()); + } + } +} diff --git a/app/src/test/resources/Norway_bouvet_europe_2.obf.zip b/app/src/test/resources/Norway_bouvet_europe_2.obf.zip new file mode 100644 index 000000000..11bb8e4ee Binary files /dev/null and b/app/src/test/resources/Norway_bouvet_europe_2.obf.zip differ diff --git a/app/src/test/resources/org.fdroid.fdroid.privileged.ota_2110.zip b/app/src/test/resources/org.fdroid.fdroid.privileged.ota_2110.zip new file mode 100644 index 000000000..aa1e377d1 Binary files /dev/null and b/app/src/test/resources/org.fdroid.fdroid.privileged.ota_2110.zip differ