support "APK Extension" files aka .obb for large apps and games

OBB files are used in apps that need more than 100 megs to work well.  This
is apps like MAPS.ME or games that put map info, media, etc. into the OBB
file.  Also, OBB files provide a mechanism to deliver large data blobs that
do not need to be part of the APK.  For example, a game's assets do not
need to change often, so they can be shipped as an OBB, then APK updates do
not need to include all those assets for each update.

https://developer.android.com/google/play/expansion-files.html
This commit is contained in:
Hans-Christoph Steiner 2016-06-28 00:07:50 +02:00
parent 6eeaf8662a
commit cd9582c990
12 changed files with 227 additions and 5 deletions

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;
@ -141,6 +142,18 @@ public class RepoXMLHandler extends DefaultHandler {
curapk.maxSdkVersion = Apk.SDK_VERSION_MAX_VALUE;
}
break;
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 "added":
curapk.added = Utils.parseDate(str, null);
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(".")) {
@ -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

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

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

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