Merge branch 'obb-support' into 'master'

support for APK Extension files aka "OBB"

OBB files are used by lots of apps like games and MAPS.ME to distribute large chunks of data.  This adds basic support for distributing OBB files via F-Droid.  The idea is that they are installed before the APK, so that once the APK is installed, the OBB files are already in place and ready to use.  This also provides an F-Droid-specific Intent method for apps to fetch the OBB download URLs in case the app itself needs to handle the OBB download/update.  That is similar to how it works in Google Play.

The fdroidserver changes are already merged: https://gitlab.com/fdroid/fdroidserver/merge_requests/143

https://developer.android.com/google/play/expansion-files.html

See merge request !383
This commit is contained in:
Daniel Martí 2016-10-06 20:04:01 +00:00
commit 6cc8e6143d
22 changed files with 527 additions and 191 deletions

View File

@ -42,8 +42,7 @@
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="18" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_SETTINGS" />
<uses-permission android:name="android.permission.NFC" />
@ -430,6 +429,8 @@
<activity
android:name=".privileged.install.InstallExtensionDialogActivity"
android:theme="@style/AppThemeTransparent" />
<activity android:name=".data.ObbUrlActivity"
android:theme="@android:style/Theme.NoDisplay" />
<receiver
android:name=".privileged.install.InstallExtensionBootReceiver" >
<intent-filter>

View File

@ -29,6 +29,7 @@ import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.database.ContentObserver;
import android.graphics.Bitmap;
@ -964,6 +965,18 @@ public class AppDetails extends AppCompatActivity {
*/
private void uninstallApk() {
Apk apk = app.installedApk;
if (apk == null) {
// TODO ideally, app would be refreshed immediately after install, then this
// workaround would be unnecessary
try {
PackageInfo pi = packageManager.getPackageInfo(app.packageName, 0);
apk = ApkProvider.Helper.findApkFromAnyRepo(this, pi.packageName, pi.versionCode);
app.installedApk = apk;
} catch (PackageManager.NameNotFoundException e) {
e.printStackTrace();
return; // not installed
}
}
Installer installer = InstallerFactory.create(this, apk);
Intent intent = installer.getUninstallScreen();
if (intent != null) {
@ -1555,7 +1568,7 @@ public class AppDetails extends AppCompatActivity {
return;
}
DownloaderService.cancel(getContext(), appDetails.activeDownloadUrlString);
InstallManagerService.cancel(getContext(), appDetails.activeDownloadUrlString);
}
public void updateViews() {

View File

@ -97,6 +97,21 @@ public class Hasher {
return hashCache.equals(otherHash.toLowerCase(Locale.ENGLISH));
}
/**
* Checks the file against the provided hash, returning whether it is a match.
*/
public static boolean isFileMatchingHash(File file, String hash, String hashType) {
if (!file.exists()) {
return false;
}
try {
Hasher hasher = new Hasher(hashType, file);
return hasher.match(hash);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
}
}
public static String hex(Certificate cert) {
byte[] encoded;
try {

View File

@ -26,6 +26,7 @@ import org.fdroid.fdroid.data.Apk;
import org.fdroid.fdroid.data.App;
import org.fdroid.fdroid.data.Repo;
import org.fdroid.fdroid.data.RepoPushRequest;
import org.fdroid.fdroid.data.Schema.ApkTable;
import org.xml.sax.Attributes;
import org.xml.sax.SAXException;
import org.xml.sax.helpers.DefaultHandler;
@ -99,16 +100,16 @@ public class RepoXMLHandler extends DefaultHandler {
final String str = curchars.toString().trim();
if (curapk != null) {
switch (localName) {
case "version":
case ApkTable.Cols.VERSION_NAME:
curapk.versionName = str;
break;
case "versioncode":
case "versioncode": // ApkTable.Cols.VERSION_CODE
curapk.versionCode = Utils.parseInt(str, -1);
break;
case "size":
case ApkTable.Cols.SIZE:
curapk.size = Utils.parseInt(str, 0);
break;
case "hash":
case ApkTable.Cols.HASH:
if (currentApkHashType == null || "md5".equals(currentApkHashType)) {
if (curapk.hash == null) {
curapk.hash = str;
@ -119,38 +120,50 @@ public class RepoXMLHandler extends DefaultHandler {
curapk.hashType = "SHA-256";
}
break;
case "sig":
case ApkTable.Cols.SIGNATURE:
curapk.sig = str;
break;
case "srcname":
case ApkTable.Cols.SOURCE_NAME:
curapk.srcname = str;
break;
case "apkname":
case "apkname": // ApkTable.Cols.NAME
curapk.apkName = str;
break;
case "sdkver":
case "sdkver": // ApkTable.Cols.MIN_SDK_VERSION
curapk.minSdkVersion = Utils.parseInt(str, Apk.SDK_VERSION_MIN_VALUE);
break;
case "targetSdkVersion":
case ApkTable.Cols.TARGET_SDK_VERSION:
curapk.targetSdkVersion = Utils.parseInt(str, Apk.SDK_VERSION_MIN_VALUE);
break;
case "maxsdkver":
case "maxsdkver": // ApkTable.Cols.MAX_SDK_VERSION
curapk.maxSdkVersion = Utils.parseInt(str, Apk.SDK_VERSION_MAX_VALUE);
if (curapk.maxSdkVersion == 0) {
// before fc0df0dcf4dd0d5f13de82d7cd9254b2b48cb62d, this could be 0
curapk.maxSdkVersion = Apk.SDK_VERSION_MAX_VALUE;
}
break;
case "added":
case ApkTable.Cols.OBB_MAIN_FILE:
curapk.obbMainFile = str;
break;
case ApkTable.Cols.OBB_MAIN_FILE_SHA256:
curapk.obbMainFileSha256 = str;
break;
case ApkTable.Cols.OBB_PATCH_FILE:
curapk.obbPatchFile = str;
break;
case ApkTable.Cols.OBB_PATCH_FILE_SHA256:
curapk.obbPatchFileSha256 = str;
break;
case ApkTable.Cols.ADDED_DATE:
curapk.added = Utils.parseDate(str, null);
break;
case "permissions":
case ApkTable.Cols.PERMISSIONS:
curapk.permissions = Utils.parseCommaSeparatedString(str);
break;
case "features":
case ApkTable.Cols.FEATURES:
curapk.features = Utils.parseCommaSeparatedString(str);
break;
case "nativecode":
case ApkTable.Cols.NATIVE_CODE:
curapk.nativecode = Utils.parseCommaSeparatedString(str);
break;
}

View File

@ -10,6 +10,7 @@ import android.os.Parcelable;
import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.data.Schema.ApkTable.Cols;
import java.io.File;
import java.util.ArrayList;
import java.util.Date;
import java.util.HashSet;
@ -30,6 +31,10 @@ public class Apk extends ValueObject implements Comparable<Apk>, Parcelable {
public int minSdkVersion = SDK_VERSION_MIN_VALUE; // 0 if unknown
public int targetSdkVersion = SDK_VERSION_MIN_VALUE; // 0 if unknown
public int maxSdkVersion = SDK_VERSION_MAX_VALUE; // "infinity" if not set
public String obbMainFile;
public String obbMainFileSha256;
public String obbPatchFile;
public String obbPatchFileSha256;
public Date added;
public String[] permissions; // null if empty or
// unknown
@ -106,6 +111,18 @@ public class Apk extends ValueObject implements Comparable<Apk>, Parcelable {
case Cols.MAX_SDK_VERSION:
maxSdkVersion = cursor.getInt(i);
break;
case Cols.OBB_MAIN_FILE:
obbMainFile = cursor.getString(i);
break;
case Cols.OBB_MAIN_FILE_SHA256:
obbMainFileSha256 = cursor.getString(i);
break;
case Cols.OBB_PATCH_FILE:
obbPatchFile = cursor.getString(i);
break;
case Cols.OBB_PATCH_FILE_SHA256:
obbPatchFileSha256 = cursor.getString(i);
break;
case Cols.NAME:
apkName = cursor.getString(i);
break;
@ -146,13 +163,73 @@ public class Apk extends ValueObject implements Comparable<Apk>, Parcelable {
}
}
public String getUrl() {
private void checkRepoAddress() {
if (repoAddress == null || apkName == null) {
throw new IllegalStateException("Apk needs to have both Schema.ApkTable.Cols.REPO_ADDRESS and Schema.ApkTable.Cols.NAME set in order to calculate URL.");
}
}
public String getUrl() {
checkRepoAddress();
return repoAddress + "/" + apkName.replace(" ", "%20");
}
/**
* Get the URL to download the <i>main</i> expansion file, the primary
* expansion file for additional resources required by your application.
* The filename will always have the format:
* "main.<i>versionCode</i>.<i>packageName</i>.obb"
*
* @return a URL to download the OBB file that matches this APK
* @see #getPatchObbUrl()
* @see <a href="https://developer.android.com/google/play/expansion-files.html">APK Expansion Files</a>
*/
public String getMainObbUrl() {
if (repoAddress == null || obbMainFile == null) {
return null;
}
checkRepoAddress();
return repoAddress + "/" + obbMainFile;
}
/**
* Get the URL to download the optional <i>patch</i> expansion file, which
* is intended for small updates to the <i>main</i> expansion file.
* The filename will always have the format:
* "patch.<i>versionCode</i>.<i>packageName</i>.obb"
*
* @return a URL to download the OBB file that matches this APK
* @see #getMainObbUrl()
* @see <a href="https://developer.android.com/google/play/expansion-files.html">APK Expansion Files</a>
*/
public String getPatchObbUrl() {
if (repoAddress == null || obbPatchFile == null) {
return null;
}
checkRepoAddress();
return repoAddress + "/" + obbPatchFile;
}
/**
* Get the local {@link File} to the "main" OBB file.
*/
public File getMainObbFile() {
if (obbMainFile == null) {
return null;
}
return new File(App.getObbDir(packageName), obbMainFile);
}
/**
* Get the local {@link File} to the "patch" OBB file.
*/
public File getPatchObbFile() {
if (obbPatchFile == null) {
return null;
}
return new File(App.getObbDir(packageName), obbPatchFile);
}
public ArrayList<String> getFullPermissionList() {
if (this.permissions == null) {
return new ArrayList<>();
@ -180,7 +257,8 @@ public class Apk extends ValueObject implements Comparable<Apk>, Parcelable {
* FDroid just includes the constant name in the apk list, so we prefix it
* with "android.permission."
*
* see https://gitlab.com/fdroid/fdroidserver/blob/master/fdroidserver/update.py#L535#
* @see <a href="https://gitlab.com/fdroid/fdroidserver/blob/1afa8cfc/update.py#L91">
* More info into index - size, permissions, features, sdk version</a>
*/
private static String fdroidToAndroidPermission(String permission) {
if (!permission.contains(".")) {
@ -192,7 +270,7 @@ public class Apk extends ValueObject implements Comparable<Apk>, Parcelable {
@Override
public String toString() {
return packageName + " (version " + versionCode + ")";
return toContentValues().toString();
}
public ContentValues toContentValues() {
@ -210,6 +288,10 @@ public class Apk extends ValueObject implements Comparable<Apk>, Parcelable {
values.put(Cols.MIN_SDK_VERSION, minSdkVersion);
values.put(Cols.TARGET_SDK_VERSION, targetSdkVersion);
values.put(Cols.MAX_SDK_VERSION, maxSdkVersion);
values.put(Cols.OBB_MAIN_FILE, obbMainFile);
values.put(Cols.OBB_MAIN_FILE_SHA256, obbMainFileSha256);
values.put(Cols.OBB_PATCH_FILE, obbPatchFile);
values.put(Cols.OBB_PATCH_FILE_SHA256, obbPatchFileSha256);
values.put(Cols.ADDED_DATE, Utils.formatDate(added, ""));
values.put(Cols.PERMISSIONS, Utils.serializeCommaSeparatedString(permissions));
values.put(Cols.FEATURES, Utils.serializeCommaSeparatedString(features));
@ -245,6 +327,10 @@ public class Apk extends ValueObject implements Comparable<Apk>, Parcelable {
dest.writeInt(this.minSdkVersion);
dest.writeInt(this.targetSdkVersion);
dest.writeInt(this.maxSdkVersion);
dest.writeString(this.obbMainFile);
dest.writeString(this.obbMainFileSha256);
dest.writeString(this.obbPatchFile);
dest.writeString(this.obbPatchFileSha256);
dest.writeLong(this.added != null ? this.added.getTime() : -1);
dest.writeStringArray(this.permissions);
dest.writeStringArray(this.features);
@ -271,6 +357,10 @@ public class Apk extends ValueObject implements Comparable<Apk>, Parcelable {
this.minSdkVersion = in.readInt();
this.targetSdkVersion = in.readInt();
this.maxSdkVersion = in.readInt();
this.obbMainFile = in.readString();
this.obbMainFileSha256 = in.readString();
this.obbPatchFile = in.readString();
this.obbPatchFileSha256 = in.readString();
long tmpAdded = in.readLong();
this.added = tmpAdded == -1 ? null : new Date(tmpAdded);
this.permissions = in.createStringArray();

View File

@ -9,22 +9,27 @@ import android.content.pm.PackageManager;
import android.content.res.AssetManager;
import android.content.res.XmlResourceParser;
import android.database.Cursor;
import android.os.Environment;
import android.os.Parcel;
import android.os.Parcelable;
import android.text.TextUtils;
import android.util.Log;
import org.apache.commons.io.filefilter.RegexFileFilter;
import org.fdroid.fdroid.AppFilter;
import org.fdroid.fdroid.FDroidApp;
import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.data.Schema.AppMetadataTable.Cols;
import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import java.io.File;
import java.io.FileFilter;
import java.io.IOException;
import java.io.InputStream;
import java.security.cert.Certificate;
import java.security.cert.CertificateEncodingException;
import java.util.Arrays;
import java.util.Date;
import java.util.Enumeration;
import java.util.HashSet;
@ -33,8 +38,6 @@ import java.util.jar.JarFile;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.fdroid.fdroid.data.Schema.AppMetadataTable.Cols;
public class App extends ValueObject implements Comparable<App>, Parcelable {
private static final String TAG = "App";
@ -272,6 +275,17 @@ public class App extends ValueObject implements Comparable<App>, Parcelable {
initApkFromApkFile(context, this.installedApk, packageInfo, apkFile);
}
/**
* Get the directory where APK Expansion Files aka OBB files are stored for the app as
* specified by {@code packageName}.
*
* @see <a href="https://developer.android.com/google/play/expansion-files.html">APK Expansion Files</a>
*/
public static File getObbDir(String packageName) {
return new File(Environment.getExternalStorageDirectory().getAbsolutePath()
+ "/Android/obb/" + packageName);
}
private void setFromPackageInfo(PackageManager pm, PackageInfo packageInfo) {
this.packageName = packageInfo.packageName;
@ -324,6 +338,29 @@ public class App extends ValueObject implements Comparable<App>, Parcelable {
initInstalledApk(context, apk, packageInfo, apkFile);
}
public static void initInstalledObbFiles(Apk apk) {
File obbdir = getObbDir(apk.packageName);
FileFilter filter = new RegexFileFilter("(main|patch)\\.[0-9-][0-9]*\\." + apk.packageName + "\\.obb");
File[] files = obbdir.listFiles(filter);
if (files == null) {
return;
}
Arrays.sort(files);
for (File f : files) {
String filename = f.getName();
String[] segments = filename.split("\\.");
if (Integer.parseInt(segments[1]) <= apk.versionCode) {
if ("main".equals(segments[0])) {
apk.obbMainFile = filename;
apk.obbMainFileSha256 = Utils.getBinaryHash(f, apk.hashType);
} else if ("patch".equals(segments[0])) {
apk.obbPatchFile = filename;
apk.obbPatchFileSha256 = Utils.getBinaryHash(f, apk.hashType);
}
}
}
}
private void initInstalledApk(Context context, Apk apk, PackageInfo packageInfo, SanitizedFile apkFile)
throws IOException, CertificateEncodingException {
apk.compatible = true;
@ -339,6 +376,8 @@ public class App extends ValueObject implements Comparable<App>, Parcelable {
apk.apkName = apk.packageName + "_" + apk.versionCode + ".apk";
apk.installedFile = apkFile;
initInstalledObbFiles(apk);
JarFile apkJar = new JarFile(apkFile);
HashSet<String> abis = new HashSet<>(3);
Pattern pattern = Pattern.compile("^lib/([a-z0-9-]+)/.*");

View File

@ -1,93 +0,0 @@
package org.fdroid.fdroid.data;
import android.content.ContentValues;
import android.database.AbstractCursor;
import android.database.Cursor;
import android.os.Bundle;
import java.util.Map;
/**
* In order to keep {@link App#App(Cursor)} and {@link Apk#Apk(Cursor)} as
* efficient as possible, this wrapper class is used to instantiate {@code App}
* and {@code Apk} from {@link App#toContentValues()} and
* {@link Apk#toContentValues()} included as extras {@link Bundle}s in the
* {@link android.content.Intent} that starts
* {@link org.fdroid.fdroid.installer.InstallManagerService}
* <p>
* 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<String, Object> entry : contentValues.valueSet()) {
keys[i] = entry.getKey();
values[i] = entry.getValue();
i++;
}
moveToFirst();
}
@Override
public int getCount() {
return 1;
}
@Override
public String[] getColumnNames() {
return keys;
}
@Override
public String getString(int i) {
return (String) values[i];
}
@Override
public int getInt(int i) {
if (values[i] instanceof Long) {
return ((Long) values[i]).intValue();
} else if (values[i] instanceof Integer) {
return (int) values[i];
}
throw new IllegalArgumentException("unimplemented");
}
@Override
public long getLong(int i) {
if (values[i] instanceof Long) {
return (Long) values[i];
}
throw new IllegalArgumentException("Value is not a Long");
}
@Override
public short getShort(int i) {
throw new IllegalArgumentException("unimplemented");
}
@Override
public float getFloat(int i) {
throw new IllegalArgumentException("unimplemented");
}
@Override
public double getDouble(int i) {
throw new IllegalArgumentException("unimplemented");
}
@Override
public boolean isNull(int i) {
return values[i] == null;
}
}

View File

@ -92,6 +92,10 @@ class DBHelper extends SQLiteOpenHelper {
+ ApkTable.Cols.MIN_SDK_VERSION + " integer, "
+ ApkTable.Cols.TARGET_SDK_VERSION + " integer, "
+ ApkTable.Cols.MAX_SDK_VERSION + " integer, "
+ ApkTable.Cols.OBB_MAIN_FILE + " string, "
+ ApkTable.Cols.OBB_MAIN_FILE_SHA256 + " string, "
+ ApkTable.Cols.OBB_PATCH_FILE + " string, "
+ ApkTable.Cols.OBB_PATCH_FILE_SHA256 + " string, "
+ ApkTable.Cols.PERMISSIONS + " string, "
+ ApkTable.Cols.FEATURES + " string, "
+ ApkTable.Cols.NATIVE_CODE + " string, "
@ -154,7 +158,7 @@ class DBHelper extends SQLiteOpenHelper {
+ " );";
private static final String DROP_TABLE_INSTALLED_APP = "DROP TABLE " + InstalledAppTable.NAME + ";";
private static final int DB_VERSION = 63;
private static final int DB_VERSION = 64;
private final Context context;
@ -354,6 +358,24 @@ class DBHelper extends SQLiteOpenHelper {
lowerCaseApkHashes(db, oldVersion);
supportRepoPushRequests(db, oldVersion);
migrateToPackageTable(db, oldVersion);
addObbFiles(db, oldVersion);
}
private void addObbFiles(SQLiteDatabase db, int oldVersion) {
if (oldVersion >= 64) {
return;
}
Utils.debugLog(TAG, "Adding " + ApkTable.Cols.OBB_MAIN_FILE
+ ", " + ApkTable.Cols.OBB_PATCH_FILE
+ ", and hash columns to " + ApkTable.NAME);
db.execSQL("alter table " + ApkTable.NAME + " add column "
+ ApkTable.Cols.OBB_MAIN_FILE + " string");
db.execSQL("alter table " + ApkTable.NAME + " add column "
+ ApkTable.Cols.OBB_MAIN_FILE_SHA256 + " string");
db.execSQL("alter table " + ApkTable.NAME + " add column "
+ ApkTable.Cols.OBB_PATCH_FILE + " string");
db.execSQL("alter table " + ApkTable.NAME + " add column "
+ ApkTable.Cols.OBB_PATCH_FILE_SHA256 + " string");
}
private void migrateToPackageTable(SQLiteDatabase db, int oldVersion) {
@ -477,6 +499,10 @@ class DBHelper extends SQLiteOpenHelper {
+ ApkTable.Cols.MIN_SDK_VERSION + " integer, "
+ ApkTable.Cols.TARGET_SDK_VERSION + " integer, "
+ ApkTable.Cols.MAX_SDK_VERSION + " integer, "
+ ApkTable.Cols.OBB_MAIN_FILE + " string, "
+ ApkTable.Cols.OBB_MAIN_FILE_SHA256 + " string, "
+ ApkTable.Cols.OBB_PATCH_FILE + " string, "
+ ApkTable.Cols.OBB_PATCH_FILE_SHA256 + " string, "
+ ApkTable.Cols.PERMISSIONS + " string, "
+ ApkTable.Cols.FEATURES + " string, "
+ ApkTable.Cols.NATIVE_CODE + " string, "
@ -502,6 +528,10 @@ class DBHelper extends SQLiteOpenHelper {
ApkTable.Cols.MIN_SDK_VERSION,
ApkTable.Cols.TARGET_SDK_VERSION,
ApkTable.Cols.MAX_SDK_VERSION,
ApkTable.Cols.OBB_MAIN_FILE,
ApkTable.Cols.OBB_MAIN_FILE_SHA256,
ApkTable.Cols.OBB_PATCH_FILE,
ApkTable.Cols.OBB_PATCH_FILE_SHA256,
ApkTable.Cols.PERMISSIONS,
ApkTable.Cols.FEATURES,
ApkTable.Cols.NATIVE_CODE,

View File

@ -0,0 +1,69 @@
package org.fdroid.fdroid.data;
import android.app.Activity;
import android.content.ComponentName;
import android.content.Intent;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Bundle;
import org.fdroid.fdroid.Utils;
/**
* Replies with the public download URL for the OBB that belongs to the
* requesting app/version. If it doesn't know the OBB URL for the requesting
* app, the {@code resultCode} will be {@link Activity#RESULT_CANCELED}. The
* request must be sent with {@link Activity#startActivityForResult(Intent, int)}
* in order to receive a reply, which will include an {@link Intent} with the
* URL as data and the SHA-256 hash as a String {@code Intent} extra.
*/
public class ObbUrlActivity extends Activity {
public static final String TAG = "ObbUrlActivity";
public static final String ACTION_GET_OBB_MAIN_URL = "org.fdroid.fdroid.action.GET_OBB_MAIN_URL";
public static final String ACTION_GET_OBB_PATCH_URL = "org.fdroid.fdroid.action.GET_OBB_PATCH_URL";
public static final String EXTRA_SHA256 = "org.fdroid.fdroid.extra.SHA256";
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
Intent intent = getIntent();
ComponentName componentName = getCallingActivity();
setResult(RESULT_CANCELED);
if (intent != null && componentName != null) {
String action = intent.getAction();
String packageName = componentName.getPackageName();
Apk apk = null;
try {
PackageManager pm = getPackageManager();
PackageInfo packageInfo = pm.getPackageInfo(packageName, 0);
apk = ApkProvider.Helper.findApkFromAnyRepo(this, packageName, packageInfo.versionCode);
} catch (PackageManager.NameNotFoundException e) {
Utils.debugLog(TAG, e.getLocalizedMessage());
}
if (apk == null) {
Utils.debugLog(TAG, "got null APK for " + packageName);
} else if (ACTION_GET_OBB_MAIN_URL.equals(action)) {
String url = apk.getMainObbUrl();
if (url != null) {
intent.setData(Uri.parse(url));
intent.putExtra(EXTRA_SHA256, apk.obbMainFileSha256);
}
setResult(RESULT_OK, intent);
} else if (ACTION_GET_OBB_PATCH_URL.equals(action)) {
String url = apk.getPatchObbUrl();
if (url != null) {
intent.setData(Uri.parse(url));
intent.putExtra(EXTRA_SHA256, apk.obbPatchFileSha256);
}
setResult(RESULT_OK, intent);
}
}
finish();
}
}

View File

@ -165,6 +165,10 @@ public interface Schema {
String MIN_SDK_VERSION = "minSdkVersion";
String TARGET_SDK_VERSION = "targetSdkVersion";
String MAX_SDK_VERSION = "maxSdkVersion";
String OBB_MAIN_FILE = "obbMainFile";
String OBB_MAIN_FILE_SHA256 = "obbMainFileSha256";
String OBB_PATCH_FILE = "obbPatchFile";
String OBB_PATCH_FILE_SHA256 = "obbPatchFileSha256";
String PERMISSIONS = "permissions";
String FEATURES = "features";
String NATIVE_CODE = "nativecode";
@ -188,6 +192,7 @@ public interface Schema {
String[] ALL_COLS = {
APP_ID, VERSION_NAME, REPO_ID, HASH, VERSION_CODE, NAME,
SIZE, SIGNATURE, SOURCE_NAME, MIN_SDK_VERSION, TARGET_SDK_VERSION, MAX_SDK_VERSION,
OBB_MAIN_FILE, OBB_MAIN_FILE_SHA256, OBB_PATCH_FILE, OBB_PATCH_FILE_SHA256,
PERMISSIONS, FEATURES, NATIVE_CODE, HASH_TYPE, ADDED_DATE,
IS_COMPATIBLE, INCOMPATIBLE_REASONS,
};
@ -198,6 +203,7 @@ public interface Schema {
String[] ALL = {
_ID, APP_ID, Package.PACKAGE_NAME, VERSION_NAME, REPO_ID, HASH, VERSION_CODE, NAME,
SIZE, SIGNATURE, SOURCE_NAME, MIN_SDK_VERSION, TARGET_SDK_VERSION, MAX_SDK_VERSION,
OBB_MAIN_FILE, OBB_MAIN_FILE_SHA256, OBB_PATCH_FILE, OBB_PATCH_FILE_SHA256,
PERMISSIONS, FEATURES, NATIVE_CODE, HASH_TYPE, ADDED_DATE,
IS_COMPATIBLE, Repo.VERSION, Repo.ADDRESS, INCOMPATIBLE_REASONS,
};

View File

@ -31,7 +31,6 @@ import org.fdroid.fdroid.data.SanitizedFile;
import java.io.File;
import java.io.IOException;
import java.security.NoSuchAlgorithmException;
public class ApkCache {
@ -45,23 +44,16 @@ public class ApkCache {
*/
public static SanitizedFile copyApkFromCacheToFiles(Context context, File apkFile, Apk expectedApk)
throws IOException {
SanitizedFile sanitizedApkFile = null;
try {
sanitizedApkFile = SanitizedFile.knownSanitized(
SanitizedFile 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)) {
if (!Hasher.isFileMatchingHash(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() {
@ -76,19 +68,8 @@ public class ApkCache {
}
}
}.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);
return sanitizedApkFile;
}
/**
@ -108,19 +89,15 @@ public class ApkCache {
* 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);
}
Hasher.isFileMatchingHash(apkFile, apkToCheck.hash, apkToCheck.hashType);
}
/**
* 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, Uri, Apk)}
* Using {@link Installer#installPackage(Uri, Uri)}
* is fine since that does the right thing.
*/
public static File getApkCacheDir(Context context) {

View File

@ -14,10 +14,14 @@ import android.net.Uri;
import android.os.IBinder;
import android.support.v4.app.NotificationCompat;
import android.support.v4.app.TaskStackBuilder;
import android.support.v4.content.IntentCompat;
import android.support.v4.content.LocalBroadcastManager;
import android.text.TextUtils;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.filefilter.WildcardFileFilter;
import org.fdroid.fdroid.AppDetails;
import org.fdroid.fdroid.Hasher;
import org.fdroid.fdroid.R;
import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.compat.PackageManagerCompat;
@ -29,6 +33,8 @@ import org.fdroid.fdroid.net.Downloader;
import org.fdroid.fdroid.net.DownloaderService;
import java.io.File;
import java.io.FileFilter;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
@ -61,11 +67,18 @@ import java.util.Set;
* </ul></p>
* The implementations of {@link Uri#toString()} and {@link Intent#getDataString()} both
* include caching of the generated {@code String}, so it should be plenty fast.
* <p>
* 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 <a href="https://developer.android.com/google/play/expansion-files.html">APK Expansion Files</a>
*/
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 <a href="https://developer.android.com/google/play/expansion-files.html">APK Expansion Files</a>
*/
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

View File

@ -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");

View File

@ -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.
* <p/>
* <p>
* 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
* <p/>
* <p>
* The download URL is only used as the unique ID that represents this
* particular apk throughout the whole install process in
* {@link InstallManagerService}.
* <p>
* This also handles deleting any associated OBB files when an app is
* uninstalled, as per the
* <a href="https://developer.android.com/google/play/expansion-files.html">
* APK Expansion Files</a> 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();
}
}

View File

@ -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);

View File

@ -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();
}
}

View File

@ -0,0 +1 @@
dummy

View File

@ -0,0 +1 @@
dummy

View File

@ -0,0 +1 @@
dummy

View File

@ -0,0 +1 @@
dummy

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
dummy