1460 lines
57 KiB
Java
1460 lines
57 KiB
Java
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.Resources;
|
|
import android.content.res.XmlResourceParser;
|
|
import android.database.Cursor;
|
|
import android.graphics.drawable.Drawable;
|
|
import android.net.Uri;
|
|
import android.os.Build;
|
|
import android.os.Environment;
|
|
import android.os.LocaleList;
|
|
import android.os.Parcel;
|
|
import android.os.Parcelable;
|
|
import android.text.TextUtils;
|
|
import android.util.Log;
|
|
|
|
import com.bumptech.glide.Glide;
|
|
import com.bumptech.glide.RequestBuilder;
|
|
import com.fasterxml.jackson.annotation.JacksonInject;
|
|
import com.fasterxml.jackson.annotation.JsonIgnore;
|
|
import com.fasterxml.jackson.annotation.JsonProperty;
|
|
|
|
import org.apache.commons.io.filefilter.RegexFileFilter;
|
|
import org.fdroid.download.DownloadRequest;
|
|
import org.fdroid.fdroid.Preferences;
|
|
import org.fdroid.fdroid.R;
|
|
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.ArrayList;
|
|
import java.util.Arrays;
|
|
import java.util.Collections;
|
|
import java.util.Date;
|
|
import java.util.Enumeration;
|
|
import java.util.HashMap;
|
|
import java.util.HashSet;
|
|
import java.util.List;
|
|
import java.util.Locale;
|
|
import java.util.Map;
|
|
import java.util.Set;
|
|
import java.util.jar.JarEntry;
|
|
import java.util.jar.JarFile;
|
|
import java.util.regex.Matcher;
|
|
import java.util.regex.Pattern;
|
|
|
|
import androidx.annotation.NonNull;
|
|
import androidx.annotation.Nullable;
|
|
import androidx.core.os.ConfigurationCompat;
|
|
import androidx.core.os.LocaleListCompat;
|
|
|
|
/**
|
|
* Represents an application, its availability, and its current installed state.
|
|
* This represents the app in general, for a specific version of this app, see
|
|
* {@link Apk}.
|
|
* <p>
|
|
* <b>Do not rename these instance variables without careful consideration!</b>
|
|
* They are mapped to JSON field names, the {@code fdroidserver} internal variable
|
|
* names, and the {@code fdroiddata} YAML field names. Only the instance variables
|
|
* decorated with {@code @JsonIgnore} are not directly mapped.
|
|
* <p>
|
|
* <b>NOTE:</b>If an instance variable is only meant for internal state, and not for
|
|
* representing data coming from the server, then it must also be decorated with
|
|
* {@code @JsonIgnore} to prevent abuse! The tests for
|
|
* {@link org.fdroid.fdroid.IndexV1Updater} will also have to be updated.
|
|
*
|
|
* @see <a href="https://gitlab.com/fdroid/fdroiddata">fdroiddata</a>
|
|
* @see <a href="https://gitlab.com/fdroid/fdroidserver">fdroidserver</a>
|
|
*/
|
|
public class App extends ValueObject implements Comparable<App>, Parcelable {
|
|
|
|
@JsonIgnore
|
|
private static final String TAG = "App";
|
|
|
|
/**
|
|
* {@link LocaleListCompat} for finding the right app description material.
|
|
* It is set globally static to a) cache this value, since there are thousands
|
|
* of {@link App} entries, and b) make it easy to test {@link #setLocalized(Map)} )}
|
|
*/
|
|
@JsonIgnore
|
|
public static LocaleListCompat systemLocaleList;
|
|
|
|
// these properties are not from the index metadata, but represent the state on the device
|
|
/**
|
|
* True if compatible with the device (i.e. if at least one apk is)
|
|
*/
|
|
@JsonIgnore
|
|
public boolean compatible;
|
|
@JsonIgnore
|
|
public Apk installedApk; // might be null if not installed
|
|
@JsonIgnore
|
|
public String installedSig;
|
|
@JsonIgnore
|
|
public int installedVersionCode;
|
|
@JsonIgnore
|
|
public String installedVersionName;
|
|
@JsonIgnore
|
|
private long id;
|
|
@JsonIgnore
|
|
private AppPrefs prefs;
|
|
@JsonIgnore
|
|
@NonNull
|
|
public String preferredSigner;
|
|
@JsonIgnore
|
|
public boolean isApk;
|
|
|
|
/**
|
|
* Has this {@code App} been localized into one of the user's current locales.
|
|
*/
|
|
@JsonIgnore
|
|
boolean isLocalized;
|
|
|
|
/**
|
|
* 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.
|
|
* This is required for getting the full URL to the various graphics and screenshots.
|
|
*/
|
|
@JacksonInject("repoId")
|
|
public long repoId;
|
|
|
|
// the remaining properties are set directly from the index metadata
|
|
public String packageName = "unknown";
|
|
public String name = "Unknown";
|
|
|
|
public String summary = "Unknown application";
|
|
@JsonProperty("icon")
|
|
public String iconFromApk;
|
|
|
|
public String description;
|
|
|
|
/**
|
|
* A descriptive text for what has changed in this version.
|
|
*/
|
|
public String whatsNew;
|
|
|
|
public String featureGraphic;
|
|
public String promoGraphic;
|
|
public String tvBanner;
|
|
|
|
public String[] phoneScreenshots = new String[0];
|
|
public String[] sevenInchScreenshots = new String[0];
|
|
public String[] tenInchScreenshots = new String[0];
|
|
public String[] tvScreenshots = new String[0];
|
|
public String[] wearScreenshots = new String[0];
|
|
|
|
public String license;
|
|
|
|
public String authorName;
|
|
public String authorEmail;
|
|
|
|
public String webSite;
|
|
|
|
public String issueTracker;
|
|
|
|
public String sourceCode;
|
|
|
|
public String translation;
|
|
|
|
public String video;
|
|
|
|
public String changelog;
|
|
|
|
public String donate;
|
|
|
|
public String bitcoin;
|
|
|
|
public String litecoin;
|
|
|
|
public String flattrID;
|
|
|
|
public String liberapay;
|
|
|
|
public String openCollective;
|
|
|
|
/**
|
|
* This matches {@code CurrentVersion} in build metadata files.
|
|
*
|
|
* @see <a href="https://f-droid.org/docs/Build_Metadata_Reference/#CurrentVersion">CurrentVersion</a>
|
|
*/
|
|
public String suggestedVersionName;
|
|
|
|
/**
|
|
* This matches {@code CurrentVersionCode} in build metadata files. Java
|
|
* inits {@code int}s to 0. Since it is valid to have a negative Version
|
|
* Code, this is inited to {@link Integer#MIN_VALUE};
|
|
*
|
|
* @see <a href="https://f-droid.org/docs/Build_Metadata_Reference/#CurrentVersionCode">CurrentVersionCode</a>
|
|
*/
|
|
public int suggestedVersionCode = Integer.MIN_VALUE;
|
|
|
|
/**
|
|
* 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 {@link #autoInstallVersionCode} to an APK which is
|
|
* in the {@link Schema.ApkTable} table.
|
|
*/
|
|
private String autoInstallVersionName;
|
|
|
|
/**
|
|
* The version that will be automatically installed if the user does not
|
|
* choose a specific version.
|
|
* TODO this should probably be converted to init to {@link Integer#MIN_VALUE} like {@link #suggestedVersionCode}
|
|
*/
|
|
public int autoInstallVersionCode;
|
|
|
|
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;
|
|
|
|
/**
|
|
* Requires root access (only ever used for root)
|
|
*/
|
|
@Deprecated
|
|
public String[] requirements;
|
|
|
|
/**
|
|
* URL to download the app's icon. (Set only from localized block, see also
|
|
* {@link #iconFromApk} and {@link #getIconUrl(Context)}
|
|
*/
|
|
private String iconUrl;
|
|
|
|
public static String getIconName(String packageName, int versionCode) {
|
|
return packageName + "_" + versionCode + ".png";
|
|
}
|
|
|
|
@Override
|
|
public int compareTo(@NonNull App app) {
|
|
return name.compareToIgnoreCase(app.name);
|
|
}
|
|
|
|
public App() {
|
|
}
|
|
|
|
public App(final Cursor cursor) {
|
|
|
|
checkCursorPosition(cursor);
|
|
|
|
final int cursorColumnCount = cursor.getColumnCount();
|
|
for (int i = 0; i < cursorColumnCount; i++) {
|
|
final 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:
|
|
iconFromApk = cursor.getString(i);
|
|
break;
|
|
case Cols.DESCRIPTION:
|
|
description = cursor.getString(i);
|
|
break;
|
|
case Cols.WHATSNEW:
|
|
whatsNew = cursor.getString(i);
|
|
break;
|
|
case Cols.LICENSE:
|
|
license = cursor.getString(i);
|
|
break;
|
|
case Cols.AUTHOR_NAME:
|
|
authorName = cursor.getString(i);
|
|
break;
|
|
case Cols.AUTHOR_EMAIL:
|
|
authorEmail = cursor.getString(i);
|
|
break;
|
|
case Cols.WEBSITE:
|
|
webSite = cursor.getString(i);
|
|
break;
|
|
case Cols.ISSUE_TRACKER:
|
|
issueTracker = cursor.getString(i);
|
|
break;
|
|
case Cols.SOURCE_CODE:
|
|
sourceCode = cursor.getString(i);
|
|
break;
|
|
case Cols.TRANSLATION:
|
|
translation = cursor.getString(i);
|
|
break;
|
|
case Cols.VIDEO:
|
|
video = cursor.getString(i);
|
|
break;
|
|
case Cols.CHANGELOG:
|
|
changelog = cursor.getString(i);
|
|
break;
|
|
case Cols.DONATE:
|
|
donate = cursor.getString(i);
|
|
break;
|
|
case Cols.BITCOIN:
|
|
bitcoin = cursor.getString(i);
|
|
break;
|
|
case Cols.LITECOIN:
|
|
litecoin = cursor.getString(i);
|
|
break;
|
|
case Cols.FLATTR_ID:
|
|
flattrID = cursor.getString(i);
|
|
break;
|
|
case Cols.LIBERAPAY:
|
|
liberapay = cursor.getString(i);
|
|
break;
|
|
case Cols.OPEN_COLLECTIVE:
|
|
openCollective = cursor.getString(i);
|
|
break;
|
|
case Cols.AutoInstallApk.VERSION_NAME:
|
|
autoInstallVersionName = cursor.getString(i);
|
|
break;
|
|
case Cols.PREFERRED_SIGNER:
|
|
preferredSigner = cursor.getString(i);
|
|
break;
|
|
case Cols.AUTO_INSTALL_VERSION_CODE:
|
|
autoInstallVersionCode = cursor.getInt(i);
|
|
break;
|
|
case Cols.SUGGESTED_VERSION_CODE:
|
|
suggestedVersionCode = cursor.getInt(i);
|
|
break;
|
|
case Cols.SUGGESTED_VERSION_NAME:
|
|
suggestedVersionName = 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.FEATURE_GRAPHIC:
|
|
featureGraphic = cursor.getString(i);
|
|
break;
|
|
case Cols.PROMO_GRAPHIC:
|
|
promoGraphic = cursor.getString(i);
|
|
break;
|
|
case Cols.TV_BANNER:
|
|
tvBanner = cursor.getString(i);
|
|
break;
|
|
case Cols.PHONE_SCREENSHOTS:
|
|
phoneScreenshots = Utils.parseCommaSeparatedString(cursor.getString(i));
|
|
break;
|
|
case Cols.SEVEN_INCH_SCREENSHOTS:
|
|
sevenInchScreenshots = Utils.parseCommaSeparatedString(cursor.getString(i));
|
|
break;
|
|
case Cols.TEN_INCH_SCREENSHOTS:
|
|
tenInchScreenshots = Utils.parseCommaSeparatedString(cursor.getString(i));
|
|
break;
|
|
case Cols.TV_SCREENSHOTS:
|
|
tvScreenshots = Utils.parseCommaSeparatedString(cursor.getString(i));
|
|
break;
|
|
case Cols.WEAR_SCREENSHOTS:
|
|
wearScreenshots = Utils.parseCommaSeparatedString(cursor.getString(i));
|
|
break;
|
|
case Cols.IS_APK:
|
|
isApk = cursor.getInt(i) == 1;
|
|
break;
|
|
case Cols.IS_LOCALIZED:
|
|
isLocalized = cursor.getInt(i) == 1;
|
|
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.
|
|
* <p>
|
|
* Initializes an {@link App} instances from an APK file. Since the file
|
|
* could in the cache, and files can disappear from the cache at any time,
|
|
* this needs to be quite defensive ensuring that {@code apkFile} still
|
|
* exists.
|
|
*/
|
|
@Nullable
|
|
public static App getInstance(Context context, PackageManager pm, InstalledApp installedApp, String packageName)
|
|
throws CertificateEncodingException, IOException, PackageManager.NameNotFoundException {
|
|
App app = new App();
|
|
PackageInfo packageInfo = pm.getPackageInfo(packageName, PackageManager.GET_PERMISSIONS);
|
|
SanitizedFile apkFile = SanitizedFile.knownSanitized(packageInfo.applicationInfo.publicSourceDir);
|
|
app.installedApk = new Apk();
|
|
if (installedApp != null) {
|
|
app.installedApk.hashType = installedApp.getHashType();
|
|
app.installedApk.hash = installedApp.getHash();
|
|
} else if (apkFile.canRead()) {
|
|
String hashType = "sha256";
|
|
String hash = Utils.getFileHexDigest(apkFile, hashType);
|
|
if (TextUtils.isEmpty(hash)) {
|
|
return null;
|
|
}
|
|
app.installedApk.hashType = hashType;
|
|
app.installedApk.hash = hash;
|
|
}
|
|
|
|
app.setFromPackageInfo(pm, packageInfo);
|
|
app.initInstalledApk(context, app.installedApk, packageInfo, apkFile);
|
|
return app;
|
|
}
|
|
|
|
/**
|
|
* In order to format all in coming descriptions before they are written
|
|
* out to the database and used elsewhere, this is needed to intercept
|
|
* the setting of {@link App#description} to insert the format method.
|
|
*/
|
|
@JsonProperty("description")
|
|
private void setDescription(String description) { // NOPMD
|
|
this.description = formatDescription(description);
|
|
}
|
|
|
|
/**
|
|
* Set the Package Name property while ensuring it is sanitized.
|
|
*/
|
|
@JsonProperty("packageName")
|
|
void setPackageName(String packageName) {
|
|
if (Utils.isSafePackageName(packageName)) {
|
|
this.packageName = packageName;
|
|
} else {
|
|
throw new IllegalArgumentException("Repo index app entry includes unsafe packageName: '"
|
|
+ packageName + "'");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* {@link #liberapay} was originally included using a numeric ID, now it is a
|
|
* username. This should not override {@link #liberapay} if that is already set.
|
|
*/
|
|
@JsonProperty("liberapayID")
|
|
void setLiberapayID(String liberapayId) { // NOPMD
|
|
if (TextUtils.isEmpty(liberapayId) || !TextUtils.isEmpty(liberapay)) {
|
|
return;
|
|
}
|
|
try {
|
|
int id = Integer.parseInt(liberapayId);
|
|
if (id > 0) {
|
|
liberapay = "~" + liberapayId;
|
|
}
|
|
} catch (NumberFormatException e) {
|
|
// ignored
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parses the {@code localized} block in the incoming index metadata,
|
|
* choosing the best match in terms of locale/language while filling as
|
|
* many fields as possible. It first sets up a locale list based on user
|
|
* preference and the locales available for this app, then picks the texts
|
|
* based on that list. One thing that makes this tricky is that any given
|
|
* locale block in the index might not have all the fields. So when filling
|
|
* out each value, it needs to go through the whole preference list each time,
|
|
* rather than just taking the whole block for a specific locale. This is to
|
|
* ensure that there is something to show, as often as possible.
|
|
* <p>
|
|
* It is still possible that the fields will be loaded directly by Jackson
|
|
* without any locale info. This comes from the old-style, inline app metadata
|
|
* fields that do not have locale info. They should not be used if the
|
|
* {@code localized} block is included in the index. Also, null strings in
|
|
* the {@code localized} block should not overwrite Name/Summary/Description
|
|
* strings with empty/null if they were set directly by Jackson.
|
|
* <ol>
|
|
* <li>the country variant {@code de-AT} from the user locale list
|
|
* <li>only the language {@code de} from the above locale
|
|
* <li>next locale in the user's preference list ({@code >= android-24})
|
|
* <li>{@code en-US} since its the most common English for software
|
|
* <li>the first available {@code en} locale
|
|
* </ol>
|
|
* <p>
|
|
* The system-wide language preference list was added in {@code android-24}.
|
|
*
|
|
* @see <a href="https://developer.android.com/guide/topics/resources/multilingual-support">Android language and locale resolution overview</a>
|
|
*/
|
|
@JsonProperty("localized")
|
|
void setLocalized(Map<String, Map<String, Object>> localized) { // NOPMD
|
|
if (systemLocaleList == null) {
|
|
systemLocaleList = ConfigurationCompat.getLocales(Resources.getSystem().getConfiguration());
|
|
}
|
|
Set<String> supportedLocales = localized.keySet();
|
|
setIsLocalized(supportedLocales);
|
|
String value = getLocalizedEntry(localized, supportedLocales, "whatsNew");
|
|
if (!TextUtils.isEmpty(value)) {
|
|
whatsNew = value;
|
|
}
|
|
|
|
value = getLocalizedEntry(localized, supportedLocales, "video");
|
|
if (!TextUtils.isEmpty(value)) {
|
|
video = value.trim();
|
|
}
|
|
value = getLocalizedEntry(localized, supportedLocales, "name");
|
|
if (!TextUtils.isEmpty(value)) {
|
|
name = value.trim();
|
|
}
|
|
value = getLocalizedEntry(localized, supportedLocales, "summary");
|
|
if (!TextUtils.isEmpty(value)) {
|
|
summary = value.trim();
|
|
}
|
|
value = getLocalizedEntry(localized, supportedLocales, "description");
|
|
if (!TextUtils.isEmpty(value)) {
|
|
description = formatDescription(value);
|
|
}
|
|
value = getLocalizedGraphicsEntry(localized, supportedLocales, "icon");
|
|
if (!TextUtils.isEmpty(value)) {
|
|
iconUrl = value;
|
|
}
|
|
|
|
featureGraphic = getLocalizedGraphicsEntry(localized, supportedLocales, "featureGraphic");
|
|
promoGraphic = getLocalizedGraphicsEntry(localized, supportedLocales, "promoGraphic");
|
|
tvBanner = getLocalizedGraphicsEntry(localized, supportedLocales, "tvBanner");
|
|
|
|
wearScreenshots = getLocalizedListEntry(localized, supportedLocales, "wearScreenshots");
|
|
phoneScreenshots = getLocalizedListEntry(localized, supportedLocales, "phoneScreenshots");
|
|
sevenInchScreenshots = getLocalizedListEntry(localized, supportedLocales, "sevenInchScreenshots");
|
|
tenInchScreenshots = getLocalizedListEntry(localized, supportedLocales, "tenInchScreenshots");
|
|
tvScreenshots = getLocalizedListEntry(localized, supportedLocales, "tvScreenshots");
|
|
}
|
|
|
|
/**
|
|
* Sets the boolean flag {@link #isLocalized} if this app entry has an localized
|
|
* entry in one of the user's current locales.
|
|
*
|
|
* @see org.fdroid.fdroid.views.main.WhatsNewViewBinder#onCreateLoader(int, android.os.Bundle)
|
|
*/
|
|
private void setIsLocalized(Set<String> supportedLocales) {
|
|
isLocalized = false;
|
|
for (int i = 0; i < systemLocaleList.size(); i++) {
|
|
String language = systemLocaleList.get(i).getLanguage();
|
|
for (String supportedLocale : supportedLocales) {
|
|
if (language.equals(supportedLocale.split("-")[0])) {
|
|
isLocalized = true;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns the right localized version of this entry, based on an imitation of
|
|
* the logic that Android uses.
|
|
*
|
|
* @see LocaleList
|
|
*/
|
|
private String getLocalizedEntry(Map<String, Map<String, Object>> localized,
|
|
Set<String> supportedLocales, @NonNull String key) {
|
|
Map<String, Object> localizedLocaleMap = getLocalizedLocaleMap(localized, supportedLocales, key);
|
|
if (localizedLocaleMap != null && !localizedLocaleMap.isEmpty()) {
|
|
for (Object entry : localizedLocaleMap.values()) {
|
|
return (String) entry; // NOPMD
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private String getLocalizedGraphicsEntry(Map<String, Map<String, Object>> localized,
|
|
Set<String> supportedLocales, @NonNull String key) {
|
|
Map<String, Object> localizedLocaleMap = getLocalizedLocaleMap(localized, supportedLocales, key);
|
|
if (localizedLocaleMap != null && !localizedLocaleMap.isEmpty()) {
|
|
for (String locale : localizedLocaleMap.keySet()) {
|
|
return locale + "/" + localizedLocaleMap.get(locale); // NOPMD
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private String[] getLocalizedListEntry(Map<String, Map<String, Object>> localized,
|
|
Set<String> supportedLocales, @NonNull String key) {
|
|
Map<String, Object> localizedLocaleMap = getLocalizedLocaleMap(localized, supportedLocales, key);
|
|
if (localizedLocaleMap != null && !localizedLocaleMap.isEmpty()) {
|
|
for (String locale : localizedLocaleMap.keySet()) {
|
|
ArrayList<String> entry = (ArrayList<String>) localizedLocaleMap.get(locale);
|
|
if (entry != null && entry.size() > 0) {
|
|
String[] result = new String[entry.size()];
|
|
int i = 0;
|
|
for (String e : entry) {
|
|
result[i] = locale + "/" + key + "/" + e;
|
|
i++;
|
|
}
|
|
return result;
|
|
}
|
|
}
|
|
}
|
|
return new String[0];
|
|
}
|
|
|
|
/**
|
|
* Return one matching entry from the {@code localized} block in the app entry
|
|
* in the index JSON.
|
|
*/
|
|
private Map<String, Object> getLocalizedLocaleMap(Map<String, Map<String, Object>> localized,
|
|
Set<String> supportedLocales, @NonNull String key) {
|
|
String[] localesToUse = getLocalesForKey(localized, supportedLocales, key);
|
|
if (localesToUse.length > 0) {
|
|
Locale firstMatch = systemLocaleList.getFirstMatch(localesToUse);
|
|
if (firstMatch != null) {
|
|
for (String languageTag : new String[]{toLanguageTag(firstMatch), null}) {
|
|
if (languageTag == null) {
|
|
languageTag = getFallbackLanguageTag(firstMatch, localesToUse); // NOPMD
|
|
}
|
|
Map<String, Object> localeEntry = localized.get(languageTag);
|
|
if (localeEntry != null && localeEntry.containsKey(key)) {
|
|
Object value = localeEntry.get(key);
|
|
if (value != null) {
|
|
Map<String, Object> localizedLocaleMap = new HashMap<>();
|
|
localizedLocaleMap.put(languageTag, value);
|
|
return localizedLocaleMap;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Replace with {@link Locale#toLanguageTag()} once
|
|
* {@link android.os.Build.VERSION_CODES#LOLLIPOP} is {@code minSdkVersion}
|
|
*/
|
|
private String toLanguageTag(Locale firstMatch) {
|
|
if (Build.VERSION.SDK_INT < 21) {
|
|
return firstMatch.toString().replace("_", "-");
|
|
} else {
|
|
return firstMatch.toLanguageTag();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get all locales that have an entry for {@code key}.
|
|
*/
|
|
private String[] getLocalesForKey(Map<String, Map<String, Object>> localized,
|
|
Set<String> supportedLocales, @NonNull String key) {
|
|
Set<String> localesToUse = new HashSet<>();
|
|
for (String locale : supportedLocales) {
|
|
Map<String, Object> localeEntry = localized.get(locale);
|
|
if (localeEntry != null && localeEntry.get(key) != null) {
|
|
localesToUse.add(locale);
|
|
}
|
|
}
|
|
return localesToUse.toArray(new String[0]);
|
|
}
|
|
|
|
/**
|
|
* Look for the first language-country match for languages with multiple scripts.
|
|
* Then look for a language-only match, for when there is no exact
|
|
* {@link Locale} match. Then try a locale with the same language, but
|
|
* different country. If there are still no matches, return the {@code en-US}
|
|
* entry. If all else fails, try to return the first existing English locale.
|
|
*/
|
|
private String getFallbackLanguageTag(Locale firstMatch, String[] localesToUse) {
|
|
final String firstMatchLanguageCountry = firstMatch.getLanguage() + "-" + firstMatch.getCountry();
|
|
for (String languageTag : localesToUse) {
|
|
if (languageTag.equals(firstMatchLanguageCountry)) {
|
|
return languageTag;
|
|
}
|
|
}
|
|
final String firstMatchLanguage = firstMatch.getLanguage();
|
|
String englishLastResort = null;
|
|
for (String languageTag : localesToUse) {
|
|
if (languageTag.equals(firstMatchLanguage)) {
|
|
return languageTag;
|
|
} else if ("en-US".equals(languageTag)) {
|
|
englishLastResort = languageTag;
|
|
}
|
|
}
|
|
for (String languageTag : localesToUse) {
|
|
String languageToUse = languageTag.split("-")[0];
|
|
if (firstMatchLanguage.equals(languageToUse)) {
|
|
return languageTag;
|
|
} else if (englishLastResort == null && "en".equals(languageToUse)) {
|
|
englishLastResort = languageTag;
|
|
}
|
|
}
|
|
return englishLastResort;
|
|
}
|
|
|
|
/**
|
|
* Returns the app description text with all newlines replaced by {@code <br>}
|
|
*/
|
|
public static String formatDescription(String description) {
|
|
return description.replace("\n", "<br>");
|
|
}
|
|
|
|
/**
|
|
* Get the URL with the standard path for displaying in a browser.
|
|
*/
|
|
@NonNull
|
|
public Uri getShareUri(Context context) {
|
|
Repo repo = RepoProvider.Helper.findById(context, repoId);
|
|
return Uri.parse(repo.address).buildUpon()
|
|
.path(String.format("/packages/%s/", packageName))
|
|
.build();
|
|
}
|
|
|
|
public RequestBuilder<Drawable> loadWithGlide(Context context) {
|
|
Repo repo = RepoProvider.Helper.findById(context, repoId);
|
|
if (repo.address.startsWith("content://")) {
|
|
return Glide.with(context).load(getIconUrl(context, repo));
|
|
} else {
|
|
return Glide.with(context).load(getDownloadRequest(context, repo));
|
|
}
|
|
}
|
|
|
|
@Nullable
|
|
@Deprecated // not taking mirrors into account
|
|
public String getIconUrl(Context context, Repo repo) {
|
|
if (TextUtils.isEmpty(iconUrl)) {
|
|
if (TextUtils.isEmpty(iconFromApk)) {
|
|
return null;
|
|
}
|
|
if (iconFromApk.endsWith(".xml")) {
|
|
// We cannot use xml ressources as icons. F-Droid server should not include them
|
|
// https://gitlab.com/fdroid/fdroidserver/issues/344
|
|
return null;
|
|
}
|
|
String iconsDir;
|
|
if (repo.version >= Repo.VERSION_DENSITY_SPECIFIC_ICONS) {
|
|
iconsDir = Utils.getIconsDir(context, 1.0);
|
|
} else {
|
|
iconsDir = Utils.FALLBACK_ICONS_DIR;
|
|
}
|
|
return repo.getFileUrl(iconsDir, iconFromApk);
|
|
}
|
|
return repo.getFileUrl(packageName, iconUrl);
|
|
}
|
|
|
|
@Nullable
|
|
@Deprecated // not taking mirrors into account
|
|
public String getIconUrl(Context context) {
|
|
Repo repo = RepoProvider.Helper.findById(context, repoId);
|
|
return getIconUrl(context, repo);
|
|
}
|
|
|
|
@Nullable
|
|
public DownloadRequest getDownloadRequest(Context context, Repo repo) {
|
|
String path;
|
|
if (TextUtils.isEmpty(iconUrl)) {
|
|
if (TextUtils.isEmpty(iconFromApk)) {
|
|
return null;
|
|
}
|
|
if (iconFromApk.endsWith(".xml")) {
|
|
// We cannot use xml resources as icons. F-Droid server should not include them
|
|
// https://gitlab.com/fdroid/fdroidserver/issues/344
|
|
return null;
|
|
}
|
|
String iconsDir;
|
|
if (repo.version >= Repo.VERSION_DENSITY_SPECIFIC_ICONS) {
|
|
iconsDir = Utils.getIconsDir(context, 1.0);
|
|
} else {
|
|
iconsDir = Utils.FALLBACK_ICONS_DIR;
|
|
}
|
|
path = repo.getPath(iconsDir, iconFromApk);
|
|
} else {
|
|
path = repo.getPath(packageName, iconUrl);
|
|
}
|
|
return repo.getDownloadRequest(path);
|
|
}
|
|
|
|
@Nullable
|
|
public DownloadRequest getDownloadRequest(Context context) {
|
|
Repo repo = RepoProvider.Helper.findById(context, repoId);
|
|
return getDownloadRequest(context, repo);
|
|
}
|
|
|
|
public String getFeatureGraphicUrl(Context context) {
|
|
if (TextUtils.isEmpty(featureGraphic)) {
|
|
return null;
|
|
}
|
|
Repo repo = RepoProvider.Helper.findById(context, repoId);
|
|
return repo.getFileUrl(packageName, featureGraphic);
|
|
}
|
|
|
|
public String getPromoGraphic(Context context) {
|
|
if (TextUtils.isEmpty(promoGraphic)) {
|
|
return null;
|
|
}
|
|
Repo repo = RepoProvider.Helper.findById(context, repoId);
|
|
return repo.getFileUrl(packageName, promoGraphic);
|
|
}
|
|
|
|
public String getTvBanner(Context context) {
|
|
if (TextUtils.isEmpty(tvBanner)) {
|
|
return null;
|
|
}
|
|
Repo repo = RepoProvider.Helper.findById(context, repoId);
|
|
return repo.getFileUrl(packageName, tvBanner);
|
|
}
|
|
|
|
public String[] getAllScreenshots(Context context) {
|
|
Repo repo = RepoProvider.Helper.findById(context, repoId);
|
|
ArrayList<String> list = new ArrayList<>();
|
|
if (phoneScreenshots != null) {
|
|
Collections.addAll(list, phoneScreenshots);
|
|
}
|
|
if (sevenInchScreenshots != null) {
|
|
Collections.addAll(list, sevenInchScreenshots);
|
|
}
|
|
if (tenInchScreenshots != null) {
|
|
Collections.addAll(list, tenInchScreenshots);
|
|
}
|
|
if (tvScreenshots != null) {
|
|
Collections.addAll(list, tvScreenshots);
|
|
}
|
|
if (wearScreenshots != null) {
|
|
Collections.addAll(list, wearScreenshots);
|
|
}
|
|
String[] result = new String[list.size()];
|
|
int i = 0;
|
|
for (String url : list) {
|
|
result[i] = repo.getFileUrl(packageName, url);
|
|
i++;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* 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.iconFromApk = getIconName(packageName, packageInfo.versionCode);
|
|
this.installedVersionName = packageInfo.versionName;
|
|
this.installedVersionCode = packageInfo.versionCode;
|
|
this.compatible = true;
|
|
}
|
|
|
|
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.getFileHexDigest(f, apk.hashType);
|
|
} else if ("patch".equals(segments[0])) {
|
|
apk.obbPatchFile = filename;
|
|
apk.obbPatchFileSha256 = Utils.getFileHexDigest(f, apk.hashType);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@SuppressWarnings("EmptyForIteratorPad")
|
|
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";
|
|
|
|
initInstalledObbFiles(apk);
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
if (!apkFile.canRead()) {
|
|
return;
|
|
}
|
|
|
|
apk.installedFile = apkFile;
|
|
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 JarEntry aSignedEntry = (JarEntry) apkJar.getEntry("AndroidManifest.xml");
|
|
|
|
if (aSignedEntry == null) {
|
|
apkJar.close();
|
|
throw new CertificateEncodingException("null signed entry!");
|
|
}
|
|
|
|
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];
|
|
byte[] rawCertBytes = signer.getEncoded();
|
|
apkJar.close();
|
|
|
|
apk.sig = Utils.getsig(rawCertBytes);
|
|
}
|
|
|
|
/**
|
|
* Attempts to find the installed {@link Apk} from the database. If not found, will lookup the
|
|
* {@link InstalledAppProvider} to find the details of the installed app and use that to
|
|
* instantiate an {@link Apk} to be returned.
|
|
* <p>
|
|
* Cases where an {@link Apk} will not be found in the database and for which we fall back to
|
|
* the {@link InstalledAppProvider} include:
|
|
* <li>System apps which are provided by a repository, but for which the version code bundled
|
|
* with the system is not included in the repository.</li>
|
|
* <li>Regular apps from a repository, where the installed version is old enough that it is no
|
|
* longer available in the repository.</li>
|
|
*/
|
|
@Nullable
|
|
public Apk getInstalledApk(Context context) {
|
|
try {
|
|
PackageInfo pi = context.getPackageManager().getPackageInfo(this.packageName, 0);
|
|
Apk apk = ApkProvider.Helper.findApkFromAnyRepo(context, pi.packageName, pi.versionCode);
|
|
if (apk == null) {
|
|
InstalledApp installedApp = InstalledAppProvider.Helper.findByPackageName(context, pi.packageName);
|
|
if (installedApp == null) {
|
|
throw new IllegalStateException("No installed app found when trying to uninstall");
|
|
}
|
|
apk = new Apk(installedApp);
|
|
}
|
|
return apk;
|
|
} catch (PackageManager.NameNotFoundException e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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, iconFromApk);
|
|
values.put(Cols.ICON_URL, iconUrl);
|
|
values.put(Cols.DESCRIPTION, description);
|
|
values.put(Cols.WHATSNEW, whatsNew);
|
|
values.put(Cols.LICENSE, license);
|
|
values.put(Cols.AUTHOR_NAME, authorName);
|
|
values.put(Cols.AUTHOR_EMAIL, authorEmail);
|
|
values.put(Cols.WEBSITE, webSite);
|
|
values.put(Cols.ISSUE_TRACKER, issueTracker);
|
|
values.put(Cols.SOURCE_CODE, sourceCode);
|
|
values.put(Cols.TRANSLATION, translation);
|
|
values.put(Cols.VIDEO, video);
|
|
values.put(Cols.CHANGELOG, changelog);
|
|
values.put(Cols.DONATE, donate);
|
|
values.put(Cols.BITCOIN, bitcoin);
|
|
values.put(Cols.LITECOIN, litecoin);
|
|
values.put(Cols.FLATTR_ID, flattrID);
|
|
values.put(Cols.LIBERAPAY, liberapay);
|
|
values.put(Cols.OPEN_COLLECTIVE, openCollective);
|
|
values.put(Cols.ADDED, Utils.formatDate(added, ""));
|
|
values.put(Cols.LAST_UPDATED, Utils.formatDate(lastUpdated, ""));
|
|
values.put(Cols.PREFERRED_SIGNER, preferredSigner);
|
|
values.put(Cols.AUTO_INSTALL_VERSION_CODE, autoInstallVersionCode);
|
|
values.put(Cols.SUGGESTED_VERSION_NAME, suggestedVersionName);
|
|
values.put(Cols.SUGGESTED_VERSION_CODE, suggestedVersionCode);
|
|
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.FEATURE_GRAPHIC, featureGraphic);
|
|
values.put(Cols.PROMO_GRAPHIC, promoGraphic);
|
|
values.put(Cols.TV_BANNER, tvBanner);
|
|
values.put(Cols.PHONE_SCREENSHOTS, Utils.serializeCommaSeparatedString(phoneScreenshots));
|
|
values.put(Cols.SEVEN_INCH_SCREENSHOTS, Utils.serializeCommaSeparatedString(sevenInchScreenshots));
|
|
values.put(Cols.TEN_INCH_SCREENSHOTS, Utils.serializeCommaSeparatedString(tenInchScreenshots));
|
|
values.put(Cols.TV_SCREENSHOTS, Utils.serializeCommaSeparatedString(tvScreenshots));
|
|
values.put(Cols.WEAR_SCREENSHOTS, Utils.serializeCommaSeparatedString(wearScreenshots));
|
|
values.put(Cols.IS_COMPATIBLE, compatible ? 1 : 0);
|
|
values.put(Cols.IS_APK, isApk ? 1 : 0);
|
|
values.put(Cols.IS_LOCALIZED, isLocalized ? 1 : 0);
|
|
|
|
return values;
|
|
}
|
|
|
|
public boolean isInstalled(Context context) {
|
|
// First check isApk() before isMediaInstalled() because the latter is quite expensive,
|
|
// hitting the database for each apk version, then the disk to check for installed media.
|
|
return installedVersionCode > 0 || (!isApk() && isMediaInstalled(context));
|
|
}
|
|
|
|
private boolean isApk() {
|
|
return isApk;
|
|
}
|
|
|
|
public boolean isMediaInstalled(Context context) {
|
|
return getMediaApkifInstalled(context) != null;
|
|
}
|
|
|
|
/**
|
|
* Gets the installed media apk from all the apks of this {@link App}, if any.
|
|
*
|
|
* @return The installed media {@link Apk} if it exists, null otherwise.
|
|
*/
|
|
public Apk getMediaApkifInstalled(Context context) {
|
|
// This is always null for media files. We could skip the code below completely if it wasn't
|
|
if (this.installedApk != null && !this.installedApk.isApk() && this.installedApk.isMediaInstalled(context)) {
|
|
return this.installedApk;
|
|
}
|
|
// This code comes from AppDetailsRecyclerViewAdapter
|
|
final List<Apk> apks = ApkProvider.Helper.findByPackageName(context, this.packageName);
|
|
for (final Apk apk : apks) {
|
|
boolean allowByCompatability = apk.compatible || Preferences.get().showIncompatibleVersions();
|
|
boolean allowBySig = this.installedSig == null || TextUtils.equals(this.installedSig, apk.sig);
|
|
if (allowByCompatability && allowBySig) {
|
|
if (!apk.isApk()) {
|
|
if (apk.isMediaInstalled(context)) {
|
|
return apk;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* True if there are new versions (apks) available
|
|
*/
|
|
public boolean hasUpdates() {
|
|
boolean updates = false;
|
|
if (autoInstallVersionCode > 0) {
|
|
updates = installedVersionCode > 0 && installedVersionCode < autoInstallVersionCode;
|
|
}
|
|
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 < autoInstallVersionCode;
|
|
return canUpdate && wantsUpdate;
|
|
}
|
|
|
|
/**
|
|
* @return if the given app should be filtered out based on the
|
|
* {@link Preferences#PREF_SHOW_ANTI_FEATURES Show Anti-Features Setting}
|
|
*/
|
|
public boolean isDisabledByAntiFeatures(Context context) {
|
|
if (this.antiFeatures == null) {
|
|
return false;
|
|
}
|
|
|
|
List<String> chooseableAntiFeatures = Arrays.asList(
|
|
context.getResources().getStringArray(R.array.antifeaturesValues)
|
|
);
|
|
|
|
Set<String> shownAntiFeatures = Preferences.get().showAppsWithAntiFeatures();
|
|
|
|
for (String antiFeature : this.antiFeatures) {
|
|
if (chooseableAntiFeatures.contains(antiFeature)) {
|
|
if (!shownAntiFeatures.contains(antiFeature)) {
|
|
return true;
|
|
}
|
|
} else {
|
|
if (!shownAntiFeatures.contains(context.getResources().getString(R.string.antiothers_key))) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
@Nullable
|
|
public String getBitcoinUri() {
|
|
return TextUtils.isEmpty(bitcoin) ? null : "bitcoin:" + bitcoin;
|
|
}
|
|
|
|
@Nullable
|
|
public String getLitecoinUri() {
|
|
return TextUtils.isEmpty(bitcoin) ? null : "litecoin:" + bitcoin;
|
|
}
|
|
|
|
@Nullable
|
|
public String getOpenCollectiveUri() {
|
|
return TextUtils.isEmpty(openCollective) ? null : "https://opencollective.com/"
|
|
+ openCollective + "/donate/";
|
|
}
|
|
|
|
@Nullable
|
|
public String getFlattrUri() {
|
|
return TextUtils.isEmpty(flattrID) ? null : "https://flattr.com/thing/" + flattrID;
|
|
}
|
|
|
|
@Nullable
|
|
public String getLiberapayUri() {
|
|
return TextUtils.isEmpty(liberapay) ? null : "https://liberapay.com/" + liberapay;
|
|
}
|
|
|
|
/**
|
|
* @see App#autoInstallVersionName for why this uses a getter while other member variables are
|
|
* publicly accessible.
|
|
*/
|
|
public String getAutoInstallVersionName() {
|
|
return autoInstallVersionName;
|
|
}
|
|
|
|
/**
|
|
* {@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></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
|
|
| NumberFormatException 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;
|
|
}
|
|
|
|
public boolean isUninstallable(Context context) {
|
|
if (this.isMediaInstalled(context)) {
|
|
return true;
|
|
} else if (this.isInstalled(context)) {
|
|
PackageManager pm = context.getPackageManager();
|
|
ApplicationInfo appInfo;
|
|
try {
|
|
appInfo = pm.getApplicationInfo(this.packageName,
|
|
PackageManager.GET_UNINSTALLED_PACKAGES);
|
|
} catch (PackageManager.NameNotFoundException e) {
|
|
return false;
|
|
}
|
|
|
|
// System apps aren't uninstallable.
|
|
final boolean isSystem = (appInfo.flags & ApplicationInfo.FLAG_SYSTEM) != 0;
|
|
return !isSystem && this.isInstalled(context);
|
|
} else {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
@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.iconFromApk);
|
|
dest.writeString(this.description);
|
|
dest.writeString(this.whatsNew);
|
|
dest.writeString(this.license);
|
|
dest.writeString(this.authorName);
|
|
dest.writeString(this.authorEmail);
|
|
dest.writeString(this.webSite);
|
|
dest.writeString(this.issueTracker);
|
|
dest.writeString(this.sourceCode);
|
|
dest.writeString(this.translation);
|
|
dest.writeString(this.video);
|
|
dest.writeString(this.changelog);
|
|
dest.writeString(this.donate);
|
|
dest.writeString(this.bitcoin);
|
|
dest.writeString(this.litecoin);
|
|
dest.writeString(this.flattrID);
|
|
dest.writeString(this.liberapay);
|
|
dest.writeString(this.openCollective);
|
|
dest.writeString(this.preferredSigner);
|
|
dest.writeString(this.suggestedVersionName);
|
|
dest.writeInt(this.suggestedVersionCode);
|
|
dest.writeString(this.autoInstallVersionName);
|
|
dest.writeInt(this.autoInstallVersionCode);
|
|
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.featureGraphic);
|
|
dest.writeString(this.promoGraphic);
|
|
dest.writeString(this.tvBanner);
|
|
dest.writeStringArray(this.phoneScreenshots);
|
|
dest.writeStringArray(this.sevenInchScreenshots);
|
|
dest.writeStringArray(this.tenInchScreenshots);
|
|
dest.writeStringArray(this.tvScreenshots);
|
|
dest.writeStringArray(this.wearScreenshots);
|
|
dest.writeByte(this.isApk ? (byte) 1 : (byte) 0);
|
|
dest.writeByte(this.isLocalized ? (byte) 1 : (byte) 0);
|
|
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.iconFromApk = in.readString();
|
|
this.description = in.readString();
|
|
this.whatsNew = in.readString();
|
|
this.license = in.readString();
|
|
this.authorName = in.readString();
|
|
this.authorEmail = in.readString();
|
|
this.webSite = in.readString();
|
|
this.issueTracker = in.readString();
|
|
this.sourceCode = in.readString();
|
|
this.translation = in.readString();
|
|
this.video = in.readString();
|
|
this.changelog = in.readString();
|
|
this.donate = in.readString();
|
|
this.bitcoin = in.readString();
|
|
this.litecoin = in.readString();
|
|
this.flattrID = in.readString();
|
|
this.liberapay = in.readString();
|
|
this.openCollective = in.readString();
|
|
this.preferredSigner = in.readString();
|
|
this.suggestedVersionName = in.readString();
|
|
this.suggestedVersionCode = in.readInt();
|
|
this.autoInstallVersionName = in.readString();
|
|
this.autoInstallVersionCode = 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.featureGraphic = in.readString();
|
|
this.promoGraphic = in.readString();
|
|
this.tvBanner = in.readString();
|
|
this.phoneScreenshots = in.createStringArray();
|
|
this.sevenInchScreenshots = in.createStringArray();
|
|
this.tenInchScreenshots = in.createStringArray();
|
|
this.tvScreenshots = in.createStringArray();
|
|
this.wearScreenshots = in.createStringArray();
|
|
this.isApk = in.readByte() != 0;
|
|
this.isLocalized = in.readByte() != 0;
|
|
this.installedVersionName = in.readString();
|
|
this.installedVersionCode = in.readInt();
|
|
this.installedApk = in.readParcelable(Apk.class.getClassLoader());
|
|
this.installedSig = in.readString();
|
|
this.id = in.readLong();
|
|
}
|
|
|
|
@JsonIgnore
|
|
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];
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Choose the signature which we should encourage the user to install.
|
|
* Usually, we want the {@link #preferredSigner} rather than any random signature.
|
|
* However, if the app is installed, then we override this and instead want to only encourage
|
|
* the user to try and install versions with that signature (because thats all the OS will let
|
|
* them do).
|
|
* <p>
|
|
* Will return null for any {@link App} which represents media (instead of an apk) and thus
|
|
* doesn't have a signer.
|
|
*/
|
|
@Nullable
|
|
public String getMostAppropriateSignature() {
|
|
if (!TextUtils.isEmpty(installedSig)) {
|
|
return installedSig;
|
|
} else if (!TextUtils.isEmpty(preferredSigner)) {
|
|
return preferredSigner;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
}
|