package org.fdroid.fdroid.data;
import android.content.ContentValues;
import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.content.pm.FeatureInfo;
import android.content.pm.PackageInfo;
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.support.annotation.Nullable;
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;
import java.util.jar.JarEntry;
import java.util.jar.JarFile;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class App extends ValueObject implements Comparable<App>, Parcelable {
private static final String TAG = "App";
/**
* True if compatible with the device (i.e. if at least one apk is)
*/
public boolean compatible;
public String packageName = "unknown";
public String name = "Unknown";
/**
* This is primarily for the purpose of saving app metadata when parsing an index.xml file.
* At most other times, we don't particularly care which repo an {@link App} object came from.
* It is pretty much transparent, because the metadata will be populated from the repo with
* the highest priority. The UI doesn't care normally _which_ repo provided the metadata.
*/
public long repoId;
public String summary = "Unknown application";
public String icon;
public String description;
public String license = "Unknown";
public String author;
public String email;
public String webURL;
public String trackerURL;
public String sourceURL;
public String changelogURL;
public String donateURL;
public String bitcoinAddr;
public String litecoinAddr;
public String flattrID;
public String upstreamVersionName;
public int upstreamVersionCode;
/**
* Unlike other public fields, this is only accessible via a getter, to
* emphasise that setting it wont do anything. In order to change this,
* you need to change suggestedVersionCode to an apk which is in the
* apk table.
*/
private String suggestedVersionName;
public int suggestedVersionCode;
public Date added;
public Date lastUpdated;
/**
* List of categories (as defined in the metadata documentation) or null if there aren't any.
* This is only populated when parsing a repository. If you need to know about the categories
* an app is in any other part of F-Droid, use the {@link CategoryProvider}.
*/
public String[] categories;
/**
* List of anti-features (as defined in the metadata documentation) or null if there aren't any.
*/
public String[] antiFeatures;
/**
* List of special requirements (such as root privileges) or null if there aren't any.
*/
public String[] requirements;
private AppPrefs prefs;
/**
* To be displayed at 48dp (x1.0)
*/
public String iconUrl;
/**
* To be displayed at 72dp (x1.5)
*/
public String iconUrlLarge;
public String installedVersionName;
public int installedVersionCode;
public Apk installedApk; // might be null if not installed
public String installedSig;
private long id;
public static String getIconName(String packageName, int versionCode) {
return packageName + "_" + versionCode + ".png";
}
@Override
public int compareTo(App app) {
return name.compareToIgnoreCase(app.name);
}
public App() {
}
public App(Cursor cursor) {
checkCursorPosition(cursor);
for (int i = 0; i < cursor.getColumnCount(); i++) {
String n = cursor.getColumnName(i);
switch (n) {
case Cols.ROW_ID:
id = cursor.getLong(i);
break;
case Cols.REPO_ID:
repoId = cursor.getLong(i);
break;
case Cols.IS_COMPATIBLE:
compatible = cursor.getInt(i) == 1;
break;
case Cols.Package.PACKAGE_NAME:
packageName = cursor.getString(i);
break;
case Cols.NAME:
name = cursor.getString(i);
break;
case Cols.SUMMARY:
summary = cursor.getString(i);
break;
case Cols.ICON:
icon = cursor.getString(i);
break;
case Cols.DESCRIPTION:
description = cursor.getString(i);
break;
case Cols.LICENSE:
license = cursor.getString(i);
break;
case Cols.AUTHOR:
author = cursor.getString(i);
break;
case Cols.EMAIL:
email = cursor.getString(i);
break;
case Cols.WEB_URL:
webURL = cursor.getString(i);
break;
case Cols.TRACKER_URL:
trackerURL = cursor.getString(i);
break;
case Cols.SOURCE_URL:
sourceURL = cursor.getString(i);
break;
case Cols.CHANGELOG_URL:
changelogURL = cursor.getString(i);
break;
case Cols.DONATE_URL:
donateURL = cursor.getString(i);
break;
case Cols.BITCOIN_ADDR:
bitcoinAddr = cursor.getString(i);
break;
case Cols.LITECOIN_ADDR:
litecoinAddr = cursor.getString(i);
break;
case Cols.FLATTR_ID:
flattrID = cursor.getString(i);
break;
case Cols.SuggestedApk.VERSION_NAME:
suggestedVersionName = cursor.getString(i);
break;
case Cols.SUGGESTED_VERSION_CODE:
suggestedVersionCode = cursor.getInt(i);
break;
case Cols.UPSTREAM_VERSION_CODE:
upstreamVersionCode = cursor.getInt(i);
break;
case Cols.UPSTREAM_VERSION_NAME:
upstreamVersionName = cursor.getString(i);
break;
case Cols.ADDED:
added = Utils.parseDate(cursor.getString(i), null);
break;
case Cols.LAST_UPDATED:
lastUpdated = Utils.parseDate(cursor.getString(i), null);
break;
case Cols.ANTI_FEATURES:
antiFeatures = Utils.parseCommaSeparatedString(cursor.getString(i));
break;
case Cols.REQUIREMENTS:
requirements = Utils.parseCommaSeparatedString(cursor.getString(i));
break;
case Cols.ICON_URL:
iconUrl = cursor.getString(i);
break;
case Cols.ICON_URL_LARGE:
iconUrlLarge = cursor.getString(i);
break;
case Cols.InstalledApp.VERSION_CODE:
installedVersionCode = cursor.getInt(i);
break;
case Cols.InstalledApp.VERSION_NAME:
installedVersionName = cursor.getString(i);
break;
case Cols.InstalledApp.SIGNATURE:
installedSig = cursor.getString(i);
break;
case "_id":
break;
default:
Log.e(TAG, "Unknown column name " + n);
}
}
}
/**
* Instantiate from a locally installed package.
*/
public App(Context context, PackageManager pm, String packageName)
throws CertificateEncodingException, IOException, PackageManager.NameNotFoundException {
PackageInfo packageInfo = pm.getPackageInfo(packageName, PackageManager.GET_PERMISSIONS);
setFromPackageInfo(pm, packageInfo);
this.installedApk = new Apk();
SanitizedFile apkFile = SanitizedFile.knownSanitized(packageInfo.applicationInfo.publicSourceDir);
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;
final String installerPackageName = pm.getInstallerPackageName(packageName);
CharSequence installerPackageLabel = null;
if (!TextUtils.isEmpty(installerPackageName)) {
try {
ApplicationInfo installerAppInfo = pm.getApplicationInfo(installerPackageName,
PackageManager.GET_META_DATA);
installerPackageLabel = installerAppInfo.loadLabel(pm);
} catch (PackageManager.NameNotFoundException e) {
Log.w(TAG, "Could not get app info: " + installerPackageName, e);
}
}
if (TextUtils.isEmpty(installerPackageLabel)) {
installerPackageLabel = installerPackageName;
}
ApplicationInfo appInfo = packageInfo.applicationInfo;
final CharSequence appDescription = appInfo.loadDescription(pm);
if (TextUtils.isEmpty(appDescription)) {
this.summary = "(installed by " + installerPackageLabel + ")";
} else if (appDescription.length() > 40) {
this.summary = (String) appDescription.subSequence(0, 40);
} else {
this.summary = (String) appDescription;
}
this.added = new Date(packageInfo.firstInstallTime);
this.lastUpdated = new Date(packageInfo.lastUpdateTime);
this.description = "<p>";
if (!TextUtils.isEmpty(appDescription)) {
this.description += appDescription + "\n";
}
this.description += "(installed by " + installerPackageLabel
+ ", first installed on " + this.added
+ ", last updated on " + this.lastUpdated + ")</p>";
this.name = (String) appInfo.loadLabel(pm);
this.icon = getIconName(packageName, packageInfo.versionCode);
this.installedVersionName = packageInfo.versionName;
this.installedVersionCode = packageInfo.versionCode;
this.compatible = true;
}
private void initApkFromApkFile(Context context, Apk apk, PackageInfo packageInfo, SanitizedFile apkFile)
throws IOException, CertificateEncodingException {
// TODO include signature hash calculation here
apk.hashType = "sha256";
apk.hash = Utils.getBinaryHash(apkFile, apk.hashType);
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;
apk.versionName = packageInfo.versionName;
apk.versionCode = packageInfo.versionCode;
apk.added = this.added;
int[] minTargetMax = getMinTargetMaxSdkVersions(context, packageName);
apk.minSdkVersion = minTargetMax[0];
apk.targetSdkVersion = minTargetMax[1];
apk.maxSdkVersion = minTargetMax[2];
apk.packageName = this.packageName;
apk.requestedPermissions = packageInfo.requestedPermissions;
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-]+)/.*");
for (Enumeration<JarEntry> jarEntries = apkJar.entries(); jarEntries.hasMoreElements();) {
JarEntry jarEntry = jarEntries.nextElement();
Matcher matcher = pattern.matcher(jarEntry.getName());
if (matcher.matches()) {
abis.add(matcher.group(1));
}
}
apk.nativecode = abis.toArray(new String[abis.size()]);
final FeatureInfo[] features = packageInfo.reqFeatures;
if (features != null && features.length > 0) {
apk.features = new String[features.length];
for (int i = 0; i < features.length; i++) {
apk.features[i] = features[i].name;
}
}
final JarEntry aSignedEntry = (JarEntry) apkJar.getEntry("AndroidManifest.xml");
if (aSignedEntry == null) {
apkJar.close();
throw new CertificateEncodingException("null signed entry!");
}
byte[] rawCertBytes;
// Due to a bug in android 5.0 lollipop, the inclusion of BouncyCastle causes
// breakage when verifying the signature of most .jars. For more
// details, check out https://gitlab.com/fdroid/fdroidclient/issues/111.
try {
FDroidApp.disableSpongyCastleOnLollipop();
final InputStream tmpIn = apkJar.getInputStream(aSignedEntry);
byte[] buff = new byte[2048];
//noinspection StatementWithEmptyBody
while (tmpIn.read(buff, 0, buff.length) != -1) {
/*
* NOP - apparently have to READ from the JarEntry before you can
* call getCerficates() and have it return != null. Yay Java.
*/
}
tmpIn.close();
if (aSignedEntry.getCertificates() == null
|| aSignedEntry.getCertificates().length == 0) {
apkJar.close();
throw new CertificateEncodingException("No Certificates found!");
}
final Certificate signer = aSignedEntry.getCertificates()[0];
rawCertBytes = signer.getEncoded();
} finally {
FDroidApp.enableSpongyCastleOnLollipop();
}
apkJar.close();
/*
* I don't fully understand the loop used here. I've copied it verbatim
* from getsig.java bundled with FDroidServer. I *believe* it is taking
* the raw byte encoding of the certificate & converting it to a byte
* array of the hex representation of the original certificate byte
* array. This is then MD5 sum'd. It's a really bad way to be doing this
* if I'm right... If I'm not right, I really don't know! see lines
* 67->75 in getsig.java bundled with Fdroidserver
*/
final byte[] fdroidSig = new byte[rawCertBytes.length * 2];
for (int j = 0; j < rawCertBytes.length; j++) {
byte v = rawCertBytes[j];
int d = (v >> 4) & 0xF;
fdroidSig[j * 2] = (byte) (d >= 10 ? ('a' + d - 10) : ('0' + d));
d = v & 0xF;
fdroidSig[j * 2 + 1] = (byte) (d >= 10 ? ('a' + d - 10) : ('0' + d));
}
apk.sig = Utils.hashBytes(fdroidSig, "md5");
}
public boolean isValid() {
if (TextUtils.isEmpty(this.name)
|| TextUtils.isEmpty(this.packageName)) {
return false;
}
if (this.installedApk == null) {
return false;
}
if (TextUtils.isEmpty(this.installedApk.sig)) {
return false;
}
final File apkFile = this.installedApk.installedFile;
return !(apkFile == null || !apkFile.canRead());
}
public ContentValues toContentValues() {
final ContentValues values = new ContentValues();
// Intentionally don't put "ROW_ID" in here, because we don't ever want to change that
// primary key generated by sqlite.
values.put(Cols.Package.PACKAGE_NAME, packageName);
values.put(Cols.NAME, name);
values.put(Cols.REPO_ID, repoId);
values.put(Cols.SUMMARY, summary);
values.put(Cols.ICON, icon);
values.put(Cols.ICON_URL, iconUrl);
values.put(Cols.ICON_URL_LARGE, iconUrlLarge);
values.put(Cols.DESCRIPTION, description);
values.put(Cols.LICENSE, license);
values.put(Cols.AUTHOR, author);
values.put(Cols.EMAIL, email);
values.put(Cols.WEB_URL, webURL);
values.put(Cols.TRACKER_URL, trackerURL);
values.put(Cols.SOURCE_URL, sourceURL);
values.put(Cols.CHANGELOG_URL, changelogURL);
values.put(Cols.DONATE_URL, donateURL);
values.put(Cols.BITCOIN_ADDR, bitcoinAddr);
values.put(Cols.LITECOIN_ADDR, litecoinAddr);
values.put(Cols.FLATTR_ID, flattrID);
values.put(Cols.ADDED, Utils.formatDate(added, ""));
values.put(Cols.LAST_UPDATED, Utils.formatDate(lastUpdated, ""));
values.put(Cols.SUGGESTED_VERSION_CODE, suggestedVersionCode);
values.put(Cols.UPSTREAM_VERSION_NAME, upstreamVersionName);
values.put(Cols.UPSTREAM_VERSION_CODE, upstreamVersionCode);
values.put(Cols.ForWriting.Categories.CATEGORIES, Utils.serializeCommaSeparatedString(categories));
values.put(Cols.ANTI_FEATURES, Utils.serializeCommaSeparatedString(antiFeatures));
values.put(Cols.REQUIREMENTS, Utils.serializeCommaSeparatedString(requirements));
values.put(Cols.IS_COMPATIBLE, compatible ? 1 : 0);
return values;
}
public boolean isInstalled() {
return installedVersionCode > 0;
}
/**
* True if there are new versions (apks) available
*/
public boolean hasUpdates() {
boolean updates = false;
if (suggestedVersionCode > 0) {
updates = installedVersionCode > 0 && installedVersionCode < suggestedVersionCode;
}
return updates;
}
public AppPrefs getPrefs(Context context) {
if (prefs == null) {
prefs = AppPrefsProvider.Helper.getPrefsOrDefault(context, this);
}
return prefs;
}
/**
* True if there are new versions (apks) available and the user wants
* to be notified about them
*/
public boolean canAndWantToUpdate(Context context) {
boolean canUpdate = hasUpdates();
AppPrefs prefs = getPrefs(context);
boolean wantsUpdate = !prefs.ignoreAllUpdates && prefs.ignoreThisUpdate < suggestedVersionCode;
return canUpdate && wantsUpdate && !isFiltered();
}
/**
* Whether the app is filtered or not based on AntiFeatures and root
* permission (set in the Settings page)
*/
public boolean isFiltered() {
return new AppFilter().filter(this);
}
@Nullable
public String getBitcoinUri() {
return TextUtils.isEmpty(bitcoinAddr) ? null : "bitcoin:" + bitcoinAddr;
}
@Nullable
public String getLitecoinUri() {
return TextUtils.isEmpty(litecoinAddr) ? null : "litecoin:" + litecoinAddr;
}
@Nullable
public String getFlattrUri() {
return TextUtils.isEmpty(flattrID) ? null : "https://flattr.com/thing/" + flattrID;
}
public String getSuggestedVersionName() {
return suggestedVersionName;
}
/**
* {@link PackageManager} doesn't give us {@code minSdkVersion}, {@code targetSdkVersion},
* and {@code maxSdkVersion}, so we have to parse it straight from {@code <uses-sdk>} in
* {@code AndroidManifest.xml}. If {@code targetSdkVersion} is not set, then it is
* equal to {@code minSdkVersion}
*
* @see <a href="https://developer.android.com/guide/topics/manifest/uses-sdk-element.html"><uses-sdk> element</a>
*/
private static int[] getMinTargetMaxSdkVersions(Context context, String packageName) {
int minSdkVersion = Apk.SDK_VERSION_MIN_VALUE;
int targetSdkVersion = Apk.SDK_VERSION_MIN_VALUE;
int maxSdkVersion = Apk.SDK_VERSION_MAX_VALUE;
try {
AssetManager am = context.createPackageContext(packageName, 0).getAssets();
XmlResourceParser xml = am.openXmlResourceParser("AndroidManifest.xml");
int eventType = xml.getEventType();
while (eventType != XmlPullParser.END_DOCUMENT) {
if (eventType == XmlPullParser.START_TAG && "uses-sdk".equals(xml.getName())) {
for (int j = 0; j < xml.getAttributeCount(); j++) {
if (xml.getAttributeName(j).equals("minSdkVersion")) {
minSdkVersion = Integer.parseInt(xml.getAttributeValue(j));
} else if (xml.getAttributeName(j).equals("targetSdkVersion")) {
targetSdkVersion = Integer.parseInt(xml.getAttributeValue(j));
} else if (xml.getAttributeName(j).equals("maxSdkVersion")) {
maxSdkVersion = Integer.parseInt(xml.getAttributeValue(j));
}
}
break;
}
eventType = xml.nextToken();
}
} catch (PackageManager.NameNotFoundException | IOException | XmlPullParserException e) {
Log.e(TAG, "Could not get min/max sdk version", e);
}
if (targetSdkVersion < minSdkVersion) {
targetSdkVersion = minSdkVersion;
}
return new int[]{minSdkVersion, targetSdkVersion, maxSdkVersion};
}
public long getId() {
return id;
}
@Override
public int describeContents() {
return 0;
}
@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeByte(this.compatible ? (byte) 1 : (byte) 0);
dest.writeString(this.packageName);
dest.writeString(this.name);
dest.writeLong(this.repoId);
dest.writeString(this.summary);
dest.writeString(this.icon);
dest.writeString(this.description);
dest.writeString(this.license);
dest.writeString(this.author);
dest.writeString(this.email);
dest.writeString(this.webURL);
dest.writeString(this.trackerURL);
dest.writeString(this.sourceURL);
dest.writeString(this.changelogURL);
dest.writeString(this.donateURL);
dest.writeString(this.bitcoinAddr);
dest.writeString(this.litecoinAddr);
dest.writeString(this.flattrID);
dest.writeString(this.upstreamVersionName);
dest.writeInt(this.upstreamVersionCode);
dest.writeString(this.suggestedVersionName);
dest.writeInt(this.suggestedVersionCode);
dest.writeLong(this.added != null ? this.added.getTime() : -1);
dest.writeLong(this.lastUpdated != null ? this.lastUpdated.getTime() : -1);
dest.writeStringArray(this.categories);
dest.writeStringArray(this.antiFeatures);
dest.writeStringArray(this.requirements);
dest.writeString(this.iconUrl);
dest.writeString(this.iconUrlLarge);
dest.writeString(this.installedVersionName);
dest.writeInt(this.installedVersionCode);
dest.writeParcelable(this.installedApk, flags);
dest.writeString(this.installedSig);
dest.writeLong(this.id);
}
protected App(Parcel in) {
this.compatible = in.readByte() != 0;
this.packageName = in.readString();
this.name = in.readString();
this.repoId = in.readLong();
this.summary = in.readString();
this.icon = in.readString();
this.description = in.readString();
this.license = in.readString();
this.author = in.readString();
this.email = in.readString();
this.webURL = in.readString();
this.trackerURL = in.readString();
this.sourceURL = in.readString();
this.changelogURL = in.readString();
this.donateURL = in.readString();
this.bitcoinAddr = in.readString();
this.litecoinAddr = in.readString();
this.flattrID = in.readString();
this.upstreamVersionName = in.readString();
this.upstreamVersionCode = in.readInt();
this.suggestedVersionName = in.readString();
this.suggestedVersionCode = in.readInt();
long tmpAdded = in.readLong();
this.added = tmpAdded == -1 ? null : new Date(tmpAdded);
long tmpLastUpdated = in.readLong();
this.lastUpdated = tmpLastUpdated == -1 ? null : new Date(tmpLastUpdated);
this.categories = in.createStringArray();
this.antiFeatures = in.createStringArray();
this.requirements = in.createStringArray();
this.iconUrl = in.readString();
this.iconUrlLarge = in.readString();
this.installedVersionName = in.readString();
this.installedVersionCode = in.readInt();
this.installedApk = in.readParcelable(Apk.class.getClassLoader());
this.installedSig = in.readString();
this.id = in.readLong();
}
public static final Parcelable.Creator<App> CREATOR = new Parcelable.Creator<App>() {
@Override
public App createFromParcel(Parcel source) {
return new App(source);
}
@Override
public App[] newArray(int size) {
return new App[size];
}
};
}