From 7e0ae10e84f738d141ff32a3c5be6164bb6d4012 Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Mon, 27 Feb 2017 14:24:35 +0100 Subject: [PATCH 01/19] uses-permission fields as Apk instance vars This adds support for the index fields: uses-permission and uses-permission-sdk-23. For most index fields, Jackson handles directly mapping the incoming data to the instance vars based on the matching field/var names. For uses-permission*, methods are declared for handling those properties in the incoming index. These fields will be ignored when using the v0 index.xml format. --- .../main/java/org/fdroid/fdroid/data/Apk.java | 25 ++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/org/fdroid/fdroid/data/Apk.java b/app/src/main/java/org/fdroid/fdroid/data/Apk.java index e08078756..f7e6c3df8 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/Apk.java +++ b/app/src/main/java/org/fdroid/fdroid/data/Apk.java @@ -8,7 +8,7 @@ import android.os.Build; import android.os.Parcel; import android.os.Parcelable; import android.support.annotation.NonNull; - +import com.fasterxml.jackson.annotation.JsonProperty; import org.fdroid.fdroid.BuildConfig; import org.fdroid.fdroid.RepoXMLHandler; import org.fdroid.fdroid.Utils; @@ -429,4 +429,27 @@ public class Apk extends ValueObject implements Comparable, Parcelable { return null; } + @JsonProperty("uses-permission") + private void setUsesPermission(Object[][] permissions) { // NOPMD + setRequestedPermissions(permissions, 0); + } + + @JsonProperty("uses-permission-sdk-23") + private void setUsesPermissionSdk23(Object[][] permissions) { // NOPMD + setRequestedPermissions(permissions, 23); + } + + private void setRequestedPermissions(Object[][] permissions, int minSdk) { + HashSet set = new HashSet<>(); + for (Object[] versions : permissions) { + int maxSdk = Integer.MAX_VALUE; + if (versions[1] != null) { + maxSdk = (int) versions[1]; + } + if (minSdk <= Build.VERSION.SDK_INT && Build.VERSION.SDK_INT <= maxSdk) { + set.add((String) versions[0]); + } + } + requestedPermissions = set.toArray(new String[set.size()]); + } } From d769dcfc6076bd22038c082d675f6052f02a44bc Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Wed, 22 Mar 2017 21:38:33 +0100 Subject: [PATCH 02/19] support new index-v1 format using Jackson parser This adds support for parsing the new index-v1.json data as defined in fdroidserver!221. This new index metadata format is required to support localization, graphics, screenshots, etc. refs #15 --- app/build.gradle | 10 +- app/proguard-rules.pro | 10 + .../org/fdroid/fdroid/IndexV1Updater.java | 335 +++ .../java/org/fdroid/fdroid/RepoUpdater.java | 38 +- .../java/org/fdroid/fdroid/UpdateService.java | 12 +- .../main/java/org/fdroid/fdroid/data/Apk.java | 32 +- .../main/java/org/fdroid/fdroid/data/App.java | 17 + .../java/org/fdroid/fdroid/data/Repo.java | 10 + .../org/fdroid/fdroid/net/Downloader.java | 8 + .../org/fdroid/fdroid/net/HttpDownloader.java | 23 +- .../fdroid/net/LocalFileDownloader.java | 6 +- .../fdroid/updater/IndexV1UpdaterTest.java | 205 ++ .../fdroid/updater/MultiRepoUpdaterTest.java | 2 +- .../fdroid/updater/RepoXMLHandlerTest.java | 9 +- .../resources/guardianproject_index-v1.json | 1974 +++++++++++++++++ .../test/resources/guardianproject_index.xml | 790 +++++++ .../resources/testy.at.or.at_index-v1.jar | Bin 0 -> 24734 bytes .../testy.at.or.at_no-.RSA_index-v1.jar | Bin 0 -> 22726 bytes .../testy.at.or.at_no-.SF_index-v1.jar | Bin 0 -> 24353 bytes ...testy.at.or.at_no-MANIFEST.MF_index-v1.jar | Bin 0 -> 24423 bytes .../testy.at.or.at_no-signature_index-v1.jar | Bin 0 -> 22162 bytes 21 files changed, 3437 insertions(+), 44 deletions(-) create mode 100644 app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java create mode 100644 app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java create mode 100644 app/src/test/resources/guardianproject_index-v1.json create mode 100644 app/src/test/resources/guardianproject_index.xml create mode 100644 app/src/test/resources/testy.at.or.at_index-v1.jar create mode 100644 app/src/test/resources/testy.at.or.at_no-.RSA_index-v1.jar create mode 100644 app/src/test/resources/testy.at.or.at_no-.SF_index-v1.jar create mode 100644 app/src/test/resources/testy.at.or.at_no-MANIFEST.MF_index-v1.jar create mode 100644 app/src/test/resources/testy.at.or.at_no-signature_index-v1.jar diff --git a/app/build.gradle b/app/build.gradle index 2ee1df806..3d4bcb75e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -58,13 +58,14 @@ dependencies { exclude module: 'design' } - testCompile 'junit:junit:4.12' + compile 'com.fasterxml.jackson.core:jackson-core:2.8.7' + compile 'com.fasterxml.jackson.core:jackson-annotations:2.8.7' + compile 'com.fasterxml.jackson.core:jackson-databind:2.8.7' testCompile "org.robolectric:robolectric:3.3.1" - + testCompile 'junit:junit:4.12' // As per https://github.com/robolectric/robolectric/issues/1932#issuecomment-219796474 testCompile 'org.khronos:opengl-api:gl1.1-android-2.1_r1' - testCompile "org.mockito:mockito-core:1.10.19" androidTestCompile "com.android.support:support-annotations:${supportLibVersion}" @@ -129,6 +130,9 @@ if (!hasProperty('sourceDeps')) { 'com.android.support:support-v4:cd030f875dc7ee73b58e17598f368a2e12824fb3ceb4ed515ed815a47160228c', 'com.android.support:support-vector-drawable:d79752fd68db5a8f5c18125517dafb9e4d7b593c755d188986010e15edd62454', 'com.android.support:transition:5a4adefb1b410b23ad62b4477bc612edc47d3dfc8efed488deb8223b70b510d7', + 'com.fasterxml.jackson.core:jackson-annotations:6b7802f6c22c09c4a92a2ebeb76e755c3c0a58dfbf419835fae470d89e469b86', + 'com.fasterxml.jackson.core:jackson-core:256ff34118ab292d1b4f3ee4d2c3e5e5f0f609d8e07c57e8ad1f51c46d4fbb46', + 'com.fasterxml.jackson.core:jackson-databind:4f74337b6d18664be0f5b15c6664b17aa3972c9c175092328b139b894ff66f19', 'com.github.pserwylo:BottomNavigation:83d7941a7a8d21ba1a8a708cd683b1bb07c6cf898044dc92eadf18a7a7d54f90', 'com.google.zxing:core:b4d82452e7a6bf6ec2698904b332431717ed8f9a850224f295aec89de80f2259', 'com.hannesdorfmann:adapterdelegates3:1b20d099d6e7afe57aceca13b713b386959d94a247c3c06a7aeb65b866ece02f', diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 65c2e1e09..517c234ec 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -44,3 +44,13 @@ # - https://github.com/ReactiveX/RxJava/issues/1415#issuecomment-48390883 # - https://github.com/ReactiveX/RxJava/blob/1.x/src/main/java/rx/internal/util/unsafe/UnsafeAccess.java#L23 -dontwarn rx.internal.util.** + +-keepattributes *Annotation*,EnclosingMethod,Signature +-keepnames class com.fasterxml.jackson.** { *; } +-dontwarn com.fasterxml.jackson.databind.ext.** +-keep class org.codehaus.** { *; } +-keepclassmembers public final enum org.codehaus.jackson.annotate.JsonAutoDetect$Visibility { +public static final org.codehaus.jackson.annotate.JsonAutoDetect$Visibility *; } +-keep public class your.class.** { + *; +} \ No newline at end of file diff --git a/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java b/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java new file mode 100644 index 000000000..9b8001d0b --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java @@ -0,0 +1,335 @@ +package org.fdroid.fdroid; + +import android.content.ContentValues; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.support.annotation.NonNull; +import android.support.v4.content.LocalBroadcastManager; +import android.text.TextUtils; +import android.util.Log; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.apache.commons.io.FileUtils; +import org.fdroid.fdroid.data.Apk; +import org.fdroid.fdroid.data.App; +import org.fdroid.fdroid.data.Repo; +import org.fdroid.fdroid.data.RepoPersister; +import org.fdroid.fdroid.data.RepoProvider; +import org.fdroid.fdroid.data.Schema; +import org.fdroid.fdroid.net.Downloader; +import org.fdroid.fdroid.net.DownloaderFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +/** + * Receives the index data about all available apps and packages via the V1 + * JSON data {@link #DATA_FILE_NAME}, embedded in a signed jar + * {@link #SIGNED_FILE_NAME}. This uses the Jackson library to parse the JSON, + * with {@link App} and {@link Apk} being instantiated directly from the JSON + * by Jackson. This is possible but not wise to do with {@link Repo} since that + * class has many fields that are related to security components of the + * implementation internal to this app. + */ +public class IndexV1Updater extends RepoUpdater { + public static final String TAG = "IndexV1Updater"; + + private static final String SIGNED_FILE_NAME = "index-v1.jar"; + public static final String DATA_FILE_NAME = "index-v1.json"; + + private final LocalBroadcastManager localBroadcastManager; + + public IndexV1Updater(@NonNull Context context, @NonNull Repo repo) { + super(context, repo); + this.localBroadcastManager = LocalBroadcastManager.getInstance(this.context); + } + + /** + * @return whether this successfully found an index of this version + * @throws RepoUpdater.UpdateException + */ + @Override + public boolean update() throws RepoUpdater.UpdateException { + + if (repo.isSwap) { + // swap repos do not support index-v1 + return false; + } + Downloader downloader = null; + InputStream indexInputStream = null; + try { + // read file name from file + final Uri dataUri = Uri.parse(repo.address).buildUpon().appendPath(SIGNED_FILE_NAME).build(); + downloader = DownloaderFactory.create(context, dataUri.toString()); + downloader.setCacheTag(repo.lastetag); + downloader.setListener(new ProgressListener() { + @Override + public void onProgress(URL sourceUrl, int bytesRead, int totalBytes) { + Intent intent = new Intent(Downloader.ACTION_PROGRESS); + intent.setData(dataUri); + intent.putExtra(Downloader.EXTRA_BYTES_READ, bytesRead); + intent.putExtra(Downloader.EXTRA_TOTAL_BYTES, totalBytes); + localBroadcastManager.sendBroadcast(intent); + } + }); + downloader.download(); + if (downloader.isNotFound()) { + return false; + } + if (downloader.isCached()) { + // The index is unchanged since we last read it. We just mark + // everything that came from this repo as being updated. + Utils.debugLog(TAG, "Repo index for " + dataUri + " is up to date (by etag)"); + } + hasChanged = downloader.hasChanged(); + + if (!hasChanged) { + return true; + } + + JarFile jarFile = new JarFile(downloader.outputFile, true); + JarEntry indexEntry = (JarEntry) jarFile.getEntry(DATA_FILE_NAME); + indexInputStream = new ProgressBufferedInputStream(jarFile.getInputStream(indexEntry), + processXmlProgressListener, new URL(repo.address), (int) indexEntry.getSize()); + processIndexV1(indexInputStream, indexEntry, downloader.getCacheTag()); + + } catch (IOException e) { + if (downloader != null) { + FileUtils.deleteQuietly(downloader.outputFile); + } + throw new RepoUpdater.UpdateException(repo, "Error getting index file", e); + } catch (InterruptedException e) { + // ignored if canceled, the local database just won't be updated + } + + return true; + } + + /** + * Parses the index and feeds it to the database via {@link Repo}, {@link App}, + * and {@link Apk} instances. + * + * @param indexInputStream {@link InputStream} to {@code index-v1.json} + * @param cacheTag the {@code etag} value from HTTP headers + * @throws IOException + * @throws UpdateException + */ + public void processIndexV1(InputStream indexInputStream, JarEntry indexEntry, String cacheTag) + throws IOException, UpdateException { + ObjectMapper mapper = new ObjectMapper(); + JsonFactory f = mapper.getFactory(); + JsonParser parser = f.createParser(indexInputStream); + HashMap repoMap = null; + App[] apps = null; + Map requests = null; + Map> packages = null; + + parser.nextToken(); // go into the main object block + while (true) { + String fieldName = parser.nextFieldName(); + if (fieldName == null) { + break; + } + switch (fieldName) { + case "repo": + repoMap = parseRepo(mapper, parser); + break; + case "requests": + requests = parseRequests(mapper, parser); + break; + case "apps": + apps = parseApps(mapper, parser); + break; + case "packages": + packages = parsePackages(mapper, parser); + break; + } + } + parser.close(); // ensure resources get cleaned up timely and properly + + if (repoMap == null) { + return; + } + + /* TODO + if (timestamp < repo.timestamp) { + throw new RepoUpdater.UpdateException(repo, "index.jar is older that current index! " + + timestamp + " < " + repo.timestamp); + } + */ + // TODO handle maxage, convert to "expiration" Date instance + + X509Certificate certificate = getSigningCertFromJar(indexEntry); + verifySigningCertificate(certificate); + Utils.debugLog(TAG, "Repo signature verified, saving app metadata to database."); + + // timestamp is absolutely required + repo.timestamp = (Long) repoMap.get("timestamp"); + // below are optional, can be null + repo.name = getStringRepoValue(repoMap, "name"); + repo.icon = getStringRepoValue(repoMap, "icon"); + repo.description = getStringRepoValue(repoMap, "description"); + repo.mirrors = getStringArrayRepoValue(repoMap, "mirrors"); + // below are optional, can be default value + repo.maxage = getIntRepoValue(repoMap, "maxage"); + repo.version = getIntRepoValue(repoMap, "version"); + + RepoPersister repoPersister = new RepoPersister(context, repo); + if (apps != null && apps.length > 0) { + for (App app : apps) { + app.repoId = repo.getId(); // TODO this should be "injected" i.e. @JacksonInject + List apks = null; + if (packages != null) { + apks = packages.get(app.packageName); + } + if (apks == null) { + Log.i(TAG, "processIndexV1 empty packages"); + apks = new ArrayList(0); + } + repoPersister.saveToDb(app, apks); + } + } + + // TODO send event saying moving on to committing to db + ContentValues values = prepareRepoDetailsForSaving(repo.name, + repo.description, repo.maxage, repo.version, repo.timestamp, repo.icon, + repo.mirrors, cacheTag); + repoPersister.commit(values); + + + // TODO RepoUpdater.processRepoPushRequests(context, repoPushRequestList); + Utils.debugLog(TAG, "Repo Push Requests: " + requests); + } + + private int getIntRepoValue(Map repoMap, String key) { + Object value = repoMap.get(key); + if (value != null && value instanceof Integer) { + return (Integer) value; + } + return Repo.INT_UNSET_VALUE; + } + + private String getStringRepoValue(Map repoMap, String key) { + Object value = repoMap.get(key); + if (value != null && value instanceof String) { + return (String) value; + } + return null; + } + + private String[] getStringArrayRepoValue(Map repoMap, String key) { + Object value = repoMap.get(key); + if (value != null && value instanceof String[]) { + return (String[]) value; + } + return null; + } + + private HashMap parseRepo(ObjectMapper mapper, JsonParser parser) throws IOException { + TypeReference> typeRef = new TypeReference>() { + }; + parser.nextToken(); + parser.nextToken(); + return mapper.readValue(parser, typeRef); + } + + private Map parseRequests(ObjectMapper mapper, JsonParser parser) throws IOException { + TypeReference> typeRef = new TypeReference>() { + }; + parser.nextToken(); // START_OBJECT + return mapper.readValue(parser, typeRef); + } + + private App[] parseApps(ObjectMapper mapper, JsonParser parser) throws IOException { + TypeReference typeRef = new TypeReference() { + }; + parser.nextToken(); // START_ARRAY + return mapper.readValue(parser, typeRef); + } + + private Map> parsePackages(ObjectMapper mapper, JsonParser parser) throws IOException { + TypeReference>> typeRef = new TypeReference>>() { + }; + parser.nextToken(); // START_OBJECT + return mapper.readValue(parser, typeRef); + } + + /** + * Verify that the signing certificate used to sign {@link #SIGNED_FILE_NAME} + * matches the signing stored in the database for this repo. {@link #repo} and + * {@code repo.signingCertificate} must be pre-loaded from the database before + * running this, if this is an existing repo. If the repo does not exist, + * this will run the TOFU process. + *

+ * Index V1 works with two copies of the signing certificate: + *

  • in the downloaded jar
  • + *
  • stored in the local database
  • + *

    + * A new repo can be added with or without the fingerprint of the signing + * certificate. If no fingerprint is supplied, then do a pure TOFU and just + * store the certificate as valid. If there is a fingerprint, then first + * check that the signing certificate in the jar matches that fingerprint. + *

    + * This is also responsible for adding the {@link Repo} instance to the + * database for the first time. + *

    + * This is the same as {@link RepoUpdater#verifyCerts(String, X509Certificate)}, + * {@link RepoUpdater#verifyAndStoreTOFUCerts(String, X509Certificate)}, and + * {@link RepoUpdater#assertSigningCertFromXmlCorrect()} except there is no + * embedded copy of the signing certificate in the index data. + * + * @param rawCertFromJar the {@link X509Certificate} embedded in the downloaded jar + * @see RepoUpdater#verifyAndStoreTOFUCerts(String, X509Certificate) + * @see RepoUpdater#verifyCerts(String, X509Certificate) + * @see RepoUpdater#assertSigningCertFromXmlCorrect() + */ + private void verifySigningCertificate(X509Certificate rawCertFromJar) throws SigningException { + String certFromJar = Hasher.hex(rawCertFromJar); + + if (TextUtils.isEmpty(certFromJar)) { + throw new SigningException(repo, + SIGNED_FILE_NAME + " must have an included signing certificate!"); + } + + if (repo.signingCertificate == null) { + if (repo.fingerprint != null) { + String fingerprintFromJar = Utils.calcFingerprint(rawCertFromJar); + if (!repo.fingerprint.equalsIgnoreCase(fingerprintFromJar)) { + throw new SigningException(repo, + "Supplied certificate fingerprint does not match!"); + } + } + Utils.debugLog(TAG, "Saving new signing certificate to database for " + repo.address); + ContentValues values = new ContentValues(2); + values.put(Schema.RepoTable.Cols.LAST_UPDATED, Utils.formatDate(new Date(), "")); + values.put(Schema.RepoTable.Cols.SIGNING_CERT, Hasher.hex(rawCertFromJar)); + RepoProvider.Helper.update(context, repo, values); + repo.signingCertificate = certFromJar; + } + + if (TextUtils.isEmpty(repo.signingCertificate)) { + throw new SigningException(repo, "A empty repo signing certificate is invalid!"); + } + + if (repo.signingCertificate.equals(certFromJar)) { + return; // we have a match! + } + + throw new SigningException(repo, "Signing certificate does not match!"); + } + +} diff --git a/app/src/main/java/org/fdroid/fdroid/RepoUpdater.java b/app/src/main/java/org/fdroid/fdroid/RepoUpdater.java index a29899c89..e2abe0df2 100644 --- a/app/src/main/java/org/fdroid/fdroid/RepoUpdater.java +++ b/app/src/main/java/org/fdroid/fdroid/RepoUpdater.java @@ -21,6 +21,8 @@ */ package org.fdroid.fdroid; +// TODO move to org.fdroid.fdroid.updater +// TODO reduce visibility of methods once in .updater package (.e.g tests need it public now) import android.content.ContentResolver; import android.content.ContentValues; @@ -83,21 +85,22 @@ import javax.xml.parsers.SAXParserFactory; * very careful with the changes that you are making! */ public class RepoUpdater { - + //TODO rename RepoUpdater to IndexV0Updater private static final String TAG = "RepoUpdater"; private final String indexUrl; @NonNull - private final Context context; + final Context context; @NonNull - private final Repo repo; - private boolean hasChanged; + final Repo repo; + boolean hasChanged; @Nullable - private ProgressListener downloadProgressListener; - private ProgressListener committingProgressListener; - private ProgressListener processXmlProgressListener; + ProgressListener downloadProgressListener; + ProgressListener committingProgressListener; + ProgressListener processXmlProgressListener; + private String cacheTag; private X509Certificate signingCertFromJar; @@ -174,10 +177,10 @@ public class RepoUpdater { * a single file, {@code index.xml}. This takes the {@code index.jar}, verifies the * signature, then returns the unzipped {@code index.xml}. * + * @return whether this version of the repo index was found and processed * @throws UpdateException All error states will come from here. */ - public void update() throws UpdateException { - + public boolean update() throws UpdateException { final Downloader downloader = downloadIndex(); hasChanged = downloader.hasChanged(); @@ -188,6 +191,7 @@ public class RepoUpdater { processDownloadedFile(downloader.outputFile); processRepoPushRequests(); } + return true; } private ContentValues repoDetailsToSave; @@ -200,7 +204,7 @@ public class RepoUpdater { int version, long timestamp, String icon, String[] mirrors) { signingCertFromIndexXml = signingCert; repoDetailsToSave = prepareRepoDetailsForSaving(name, description, maxAge, version, - timestamp, icon, mirrors); + timestamp, icon, mirrors, cacheTag); } @Override @@ -297,9 +301,9 @@ public class RepoUpdater { * Update tracking data for the repo represented by this instance (index version, etag, * description, human-readable name, etc. */ - private ContentValues prepareRepoDetailsForSaving(String name, String description, int maxAge, - int version, long timestamp, String icon, - String[] mirrors) { + ContentValues prepareRepoDetailsForSaving(String name, String description, int maxAge, + int version, long timestamp, String icon, + String[] mirrors, String cacheTag) { ContentValues values = new ContentValues(); values.put(RepoTable.Cols.LAST_UPDATED, Utils.formatTime(new Date(), "")); @@ -308,12 +312,12 @@ public class RepoUpdater { values.put(RepoTable.Cols.LAST_ETAG, cacheTag); } - if (version != -1 && version != repo.version) { + if (version != Repo.INT_UNSET_VALUE && version != repo.version) { Utils.debugLog(TAG, "Repo specified a new version: from " + repo.version + " to " + version); values.put(RepoTable.Cols.VERSION, version); } - if (maxAge != -1 && maxAge != repo.maxage) { + if (maxAge != Repo.INT_UNSET_VALUE && maxAge != repo.maxage) { Utils.debugLog(TAG, "Repo specified a new maximum age - updated"); values.put(RepoTable.Cols.MAX_AGE, maxAge); } @@ -372,7 +376,7 @@ public class RepoUpdater { * signing setups that would be valid for a regular jar. This validates those * restrictions. */ - private X509Certificate getSigningCertFromJar(JarEntry jarEntry) throws SigningException { + X509Certificate getSigningCertFromJar(JarEntry jarEntry) throws SigningException { final CodeSigner[] codeSigners = jarEntry.getCodeSigners(); if (codeSigners == null || codeSigners.length == 0) { throw new SigningException(repo, "No signature found in index"); @@ -458,7 +462,7 @@ public class RepoUpdater { * should always accept, prompt the user, or ignore those requests on a * per repo basis. */ - private void processRepoPushRequests() { + void processRepoPushRequests() { PackageManager pm = context.getPackageManager(); for (RepoPushRequest repoPushRequest : repoPushRequestList) { diff --git a/app/src/main/java/org/fdroid/fdroid/UpdateService.java b/app/src/main/java/org/fdroid/fdroid/UpdateService.java index 4dcb1c000..265651e6f 100644 --- a/app/src/main/java/org/fdroid/fdroid/UpdateService.java +++ b/app/src/main/java/org/fdroid/fdroid/UpdateService.java @@ -393,10 +393,16 @@ public class UpdateService extends IntentService { } sendStatus(this, STATUS_INFO, getString(R.string.status_connecting_to_repo, repo.address)); - RepoUpdater updater = new RepoUpdater(getBaseContext(), repo); - setProgressListeners(updater); + + try { - updater.update(); + RepoUpdater updater = new IndexV1Updater(this, repo); + //TODO setProgressListeners(updater); + if (!updater.update()) { + updater = new RepoUpdater(getBaseContext(), repo); + setProgressListeners(updater); + updater.update(); + } if (updater.hasChanged()) { updatedRepos++; changes = true; diff --git a/app/src/main/java/org/fdroid/fdroid/data/Apk.java b/app/src/main/java/org/fdroid/fdroid/data/Apk.java index f7e6c3df8..3ba7d9359 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/Apk.java +++ b/app/src/main/java/org/fdroid/fdroid/data/Apk.java @@ -8,6 +8,7 @@ import android.os.Build; import android.os.Parcel; import android.os.Parcelable; import android.support.annotation.NonNull; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import org.fdroid.fdroid.BuildConfig; import org.fdroid.fdroid.RepoXMLHandler; @@ -18,6 +19,21 @@ import java.io.File; import java.util.Date; import java.util.HashSet; +/** + * Represents a single package of an application. This represents one particular + * package of a given application, for info about the app in general, see + * {@link App}. + *

    + * Do not rename these instance variables without careful consideration! + * They are mapped to JSON field names, the {@code fdroidserver} internal variable + * names, and the {@code fdroiddata} YAML field names. Only the instance variables + * listed in {@code @JsonIgnoreProperties} are not directly mapped. + * + * @see fdroiddata + * @see fdroidserver + */ +@JsonIgnoreProperties({"compatible", "CREATOR", "installedFile", "repo", "repoAddress", + "repoVersion",}) public class Apk extends ValueObject implements Comparable, Parcelable { // Using only byte-range keeps it only 8-bits in the SQLite database @@ -94,16 +110,16 @@ public class Apk extends ValueObject implements Comparable, Parcelable { * If you need an {@link Apk} but it is no longer in the database any more (e.g. because the * version you have installed is no longer in the repository metadata) then you can instantiate * an {@link Apk} via an {@link InstalledApp} instance. - * + *

    * Note: Many of the fields on this instance will not be known in this circumstance. Currently * the only things that are known are: - * - * + {@link Apk#packageName} - * + {@link Apk#versionName} - * + {@link Apk#versionCode} - * + {@link Apk#hash} - * + {@link Apk#hashType} - * + *

    + * + {@link Apk#packageName} + * + {@link Apk#versionName} + * + {@link Apk#versionCode} + * + {@link Apk#hash} + * + {@link Apk#hashType} + *

    * This could instead be implemented by accepting a {@link PackageInfo} and it would get much * the same information, but it wouldn't have the hash of the package. Seeing as we've already * done the hard work to calculate that hash and stored it in the database, we may as well use diff --git a/app/src/main/java/org/fdroid/fdroid/data/App.java b/app/src/main/java/org/fdroid/fdroid/data/App.java index fdfe675d6..65f4033b9 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/App.java +++ b/app/src/main/java/org/fdroid/fdroid/data/App.java @@ -16,6 +16,8 @@ import android.support.annotation.Nullable; import android.text.TextUtils; import android.util.Log; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + import org.apache.commons.io.filefilter.RegexFileFilter; import org.fdroid.fdroid.AppFilter; import org.fdroid.fdroid.FDroidApp; @@ -39,6 +41,21 @@ import java.util.jar.JarFile; import java.util.regex.Matcher; import java.util.regex.Pattern; +/** + * 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}. + *

    + * Do not rename these instance variables without careful consideration! + * They are mapped to JSON field names, the {@code fdroidserver} internal variable + * names, and the {@code fdroiddata} YAML field names. Only the instance variables + * listed in {@code @JsonIgnoreProperties} are not directly mapped. + * + * @see fdroiddata + * @see fdroidserver + */ +@JsonIgnoreProperties({"compatible", "CREATOR", "id", "installedApk", "installedSig", + "installedVersionCode", "installedVersionName", "prefs", "repoId", }) public class App extends ValueObject implements Comparable, Parcelable { private static final String TAG = "App"; diff --git a/app/src/main/java/org/fdroid/fdroid/data/Repo.java b/app/src/main/java/org/fdroid/fdroid/data/Repo.java index 589c5d0ce..288a41388 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/Repo.java +++ b/app/src/main/java/org/fdroid/fdroid/data/Repo.java @@ -34,6 +34,14 @@ import java.net.MalformedURLException; import java.net.URL; import java.util.Date; + +/** + * Represents a the descriptive info and metadata about a given repo, as provided + * by the repo index. This also keeps track of the state of the repo. + * + * @see fdroiddata + * @see fdroidserver + */ public class Repo extends ValueObject { public static final int VERSION_DENSITY_SPECIFIC_ICONS = 11; @@ -42,6 +50,8 @@ public class Repo extends ValueObject { public static final int PUSH_REQUEST_PROMPT = 1; public static final int PUSH_REQUEST_ACCEPT_ALWAYS = 2; + public static final int INT_UNSET_VALUE = -1; + // these are never set by the Apk/package index metadata protected long id; public String address; diff --git a/app/src/main/java/org/fdroid/fdroid/net/Downloader.java b/app/src/main/java/org/fdroid/fdroid/net/Downloader.java index 0d5692dad..f44d50334 100644 --- a/app/src/main/java/org/fdroid/fdroid/net/Downloader.java +++ b/app/src/main/java/org/fdroid/fdroid/net/Downloader.java @@ -34,6 +34,7 @@ public abstract class Downloader { final URL sourceUrl; String cacheTag; + boolean notFound; /** * For sending download progress, should only be called in {@link #progressTask} @@ -86,6 +87,13 @@ public abstract class Downloader { public abstract boolean isCached(); + /** + * @return whether the requested file was not found in the repo (e.g. HTTP 404 Not Found) + */ + public boolean isNotFound() { + return notFound; + } + void downloadFromStream(int bufferSize, boolean resumable) throws IOException, InterruptedException { Utils.debugLog(TAG, "Downloading from stream"); InputStream input = null; diff --git a/app/src/main/java/org/fdroid/fdroid/net/HttpDownloader.java b/app/src/main/java/org/fdroid/fdroid/net/HttpDownloader.java index d792ed2b0..de4b272e7 100644 --- a/app/src/main/java/org/fdroid/fdroid/net/HttpDownloader.java +++ b/app/src/main/java/org/fdroid/fdroid/net/HttpDownloader.java @@ -79,17 +79,25 @@ public class HttpDownloader extends Downloader { */ @Override public void download() throws IOException, InterruptedException { - boolean resumable = false; - long fileLength = outputFile.length(); - // get the file size from the server HttpURLConnection tmpConn = getConnection(); tmpConn.setRequestMethod("HEAD"); int contentLength = -1; - if (tmpConn.getResponseCode() == 200) { - contentLength = tmpConn.getContentLength(); - } + int statusCode = tmpConn.getResponseCode(); tmpConn.disconnect(); + switch (statusCode) { + case 200: + contentLength = tmpConn.getContentLength(); + break; + case 404: + notFound = true; + return; + default: + Utils.debugLog(TAG, "HEAD check of " + sourceUrl + " returned " + statusCode + ": " + + tmpConn.getResponseMessage()); + } + boolean resumable = false; + long fileLength = outputFile.length(); if (fileLength > contentLength) { FileUtils.deleteQuietly(outputFile); } else if (fileLength == contentLength && outputFile.isFile()) { @@ -127,9 +135,6 @@ public class HttpDownloader extends Downloader { return connection; } - /** - * @return Whether the connection is resumable or not - */ private void setupConnection(boolean resumable) throws IOException { if (connection != null) { return; diff --git a/app/src/main/java/org/fdroid/fdroid/net/LocalFileDownloader.java b/app/src/main/java/org/fdroid/fdroid/net/LocalFileDownloader.java index 2207a689f..e684aa693 100644 --- a/app/src/main/java/org/fdroid/fdroid/net/LocalFileDownloader.java +++ b/app/src/main/java/org/fdroid/fdroid/net/LocalFileDownloader.java @@ -43,7 +43,11 @@ public class LocalFileDownloader extends Downloader { @Override public void download() throws IOException, InterruptedException { - downloadFromStream(1024 * 50, false); + if (new File(sourceUrl.getPath()).exists()) { + downloadFromStream(1024 * 50, false); + } else { + notFound = true; + } } @Override diff --git a/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java b/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java new file mode 100644 index 000000000..1731bf53b --- /dev/null +++ b/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java @@ -0,0 +1,205 @@ +package org.fdroid.fdroid.updater; + +import android.support.annotation.NonNull; +import android.util.Log; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.ObjectReader; + +import org.apache.commons.io.IOUtils; +import org.fdroid.fdroid.BuildConfig; +import org.fdroid.fdroid.IndexV1Updater; +import org.fdroid.fdroid.Preferences; +import org.fdroid.fdroid.RepoUpdater; +import org.fdroid.fdroid.TestUtils; +import org.fdroid.fdroid.data.Apk; +import org.fdroid.fdroid.data.App; +import org.fdroid.fdroid.data.FDroidProviderTest; +import org.fdroid.fdroid.data.Repo; +import org.fdroid.fdroid.data.RepoPushRequest; +import org.fdroid.fdroid.mock.RepoDetails; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricGradleTestRunner; +import org.robolectric.annotation.Config; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +// TODO: Use sdk=24 when Robolectric supports this +@Config(constants = BuildConfig.class, sdk = 23) +@RunWith(RobolectricGradleTestRunner.class) +public class IndexV1UpdaterTest extends FDroidProviderTest { + public static final String TAG = "IndexV1UpdaterTest"; + + private static final String TESTY_JAR = "testy.at.or.at_index-v1.jar"; + private static final String TESTY_CERT = "308204e1308202c9a0030201020204483450fa300d06092a864886f70d01010b050030213110300e060355040b1307462d44726f6964310d300b06035504031304736f7661301e170d3136303832333133333131365a170d3434303130393133333131365a30213110300e060355040b1307462d44726f6964310d300b06035504031304736f766130820222300d06092a864886f70d01010105000382020f003082020a0282020100dfdcd120f3ab224999dddf4ea33ea588d295e4d7130bef48c143e9d76e5c0e0e9e5d45e64208e35feebc79a83f08939dd6a343b7d1e2179930a105a1249ccd36d88ff3feffc6e4dc53dae0163a7876dd45ecc1ddb0adf5099aa56c1a84b52affcd45d0711ffa4de864f35ac0333ebe61ea8673eeda35a88f6af678cc4d0f80b089338ac8f2a8279a64195c611d19445cab3fd1a020afed9bd739bb95142fb2c00a8f847db5ef3325c814f8eb741bacf86ed3907bfe6e4564d2de5895df0c263824e0b75407589bae2d3a4666c13b92102d8781a8ee9bb4a5a1a78c4a9c21efdaf5584da42e84418b28f5a81d0456a3dc5b420991801e6b21e38c99bbe018a5b2d690894a114bc860d35601416aa4dc52216aff8a288d4775cddf8b72d45fd2f87303a8e9c0d67e442530be28eaf139894337266e0b33d57f949256ab32083bcc545bc18a83c9ab8247c12aea037e2b68dee31c734cb1f04f241d3b94caa3a2b258ffaf8e6eae9fbbe029a934dc0a0859c5f120334812693a1c09352340a39f2a678dbc1afa2a978bfee43afefcb7e224a58af2f3d647e5745db59061236b8af6fcfd93b3602f9e456978534f3a7851e800071bf56da80401c81d91c45f82568373af0576b1cc5eef9b85654124b6319770be3cdba3fbebe3715e8918fb6c8966624f3d0e815effac3d2ee06dd34ab9c693218b2c7c06ba99d6b74d4f17b8c3cb0203010001a321301f301d0603551d0e04160414d62bee9f3798509546acc62eb1de14b08b954d4f300d06092a864886f70d01010b05000382020100743f7c5692085895f9d1fffad390fb4202c15f123ed094df259185960fd6dadf66cb19851070f180297bba4e6996a4434616573b375cfee94fee73a4505a7ec29136b7e6c22e6436290e3686fe4379d4e3140ec6a08e70cfd3ed5b634a5eb5136efaaabf5f38e0432d3d79568a556970b8cfba2972f5d23a3856d8a981b9e9bbbbb88f35e708bde9cbc5f681cbd974085b9da28911296fe2579fa64bbe9fa0b93475a7a8db051080b0c5fade0d1c018e7858cd4cbe95145b0620e2f632cbe0f8af9cbf22e2fdaa72245ae31b0877b07181cc69dd2df74454251d8de58d25e76354abe7eb690f22e59b08795a8f2c98c578e0599503d9085927634072c82c9f82abd50fd12b8fd1a9d1954eb5cc0b4cfb5796b5aaec0356643b4a65a368442d92ef94edd3ac6a2b7fe3571b8cf9f462729228aab023ef9183f73792f5379633ccac51079177d604c6bc1873ada6f07d8da6d68c897e88a5fa5d63fdb8df820f46090e0716e7562dd3c140ba279a65b996f60addb0abe29d4bf2f5abe89480771d492307b926d91f02f341b2148502903c43d40f3c6c86a811d060711f0698b384acdcc0add44eb54e42962d3d041accc715afd49407715adc09350cb55e8d9281a3b0b6b5fcd91726eede9b7c8b13afdebb2c2b377629595f1096ba62fb14946dbac5f3c5f0b4e5b712e7acc7dcf6c46cdc5e6d6dfdeee55a0c92c2d70f080ac6"; + + @Test + public void testIndexV1Processing() throws IOException, RepoUpdater.UpdateException { + Preferences.setup(context); + Repo repo = MultiRepoUpdaterTest.createRepo("Testy", TESTY_JAR, context, TESTY_CERT); + IndexV1Updater updater = new IndexV1Updater(context, repo); + JarFile jarFile = new JarFile(TestUtils.copyResourceToTempFile(TESTY_JAR), true); + Log.i(TAG, "jarFile " + jarFile); + JarEntry indexEntry = (JarEntry) jarFile.getEntry(IndexV1Updater.DATA_FILE_NAME); + InputStream indexInputStream = jarFile.getInputStream(indexEntry); + updater.processIndexV1(indexInputStream, indexEntry, "fakeEtag"); + IOUtils.closeQuietly(indexInputStream); + } + + @Test(expected = RepoUpdater.SigningException.class) + public void testIndexV1WithWrongCert() throws IOException, RepoUpdater.UpdateException { + String badCert = "308202ed308201d5a003020102020426ffa009300d06092a864886f70d01010b05003027310b300906035504061302444531183016060355040a130f4e4f47415050532050726f6a656374301e170d3132313030363132303533325a170d3337303933303132303533325a3027310b300906035504061302444531183016060355040a130f4e4f47415050532050726f6a65637430820122300d06092a864886f70d01010105000382010f003082010a02820101009a8d2a5336b0eaaad89ce447828c7753b157459b79e3215dc962ca48f58c2cd7650df67d2dd7bda0880c682791f32b35c504e43e77b43c3e4e541f86e35a8293a54fb46e6b16af54d3a4eda458f1a7c8bc1b7479861ca7043337180e40079d9cdccb7e051ada9b6c88c9ec635541e2ebf0842521c3024c826f6fd6db6fd117c74e859d5af4db04448965ab5469b71ce719939a06ef30580f50febf96c474a7d265bb63f86a822ff7b643de6b76e966a18553c2858416cf3309dd24278374bdd82b4404ef6f7f122cec93859351fc6e5ea947e3ceb9d67374fe970e593e5cd05c905e1d24f5a5484f4aadef766e498adf64f7cf04bddd602ae8137b6eea40722d0203010001a321301f301d0603551d0e04160414110b7aa9ebc840b20399f69a431f4dba6ac42a64300d06092a864886f70d01010b0500038201010007c32ad893349cf86952fb5a49cfdc9b13f5e3c800aece77b2e7e0e9c83e34052f140f357ec7e6f4b432dc1ed542218a14835acd2df2deea7efd3fd5e8f1c34e1fb39ec6a427c6e6f4178b609b369040ac1f8844b789f3694dc640de06e44b247afed11637173f36f5886170fafd74954049858c6096308fc93c1bc4dd5685fa7a1f982a422f2a3b36baa8c9500474cf2af91c39cbec1bc898d10194d368aa5e91f1137ec115087c31962d8f76cd120d28c249cf76f4c70f5baa08c70a7234ce4123be080cee789477401965cfe537b924ef36747e8caca62dfefdd1a6288dcb1c4fd2aaa6131a7ad254e9742022cfd597d2ca5c660ce9e41ff537e5a4041e37"; + Repo repo = MultiRepoUpdaterTest.createRepo("Testy", TESTY_JAR, context, badCert); + IndexV1Updater updater = new IndexV1Updater(context, repo); + JarFile jarFile = new JarFile(TestUtils.copyResourceToTempFile(TESTY_JAR), true); + JarEntry indexEntry = (JarEntry) jarFile.getEntry(IndexV1Updater.DATA_FILE_NAME); + InputStream indexInputStream = jarFile.getInputStream(indexEntry); + updater.processIndexV1(indexInputStream, indexEntry, "fakeEtag"); + fail(); // it should never reach here, it should throw a SigningException + getClass().getResourceAsStream("foo"); + } + + @Test(expected = RepoUpdater.SigningException.class) + public void testIndexV1WithBadTestyJarNoManifest() throws IOException, RepoUpdater.UpdateException { + testBadTestyJar("testy.at.or.at_no-MANIFEST.MF_index-v1.jar"); + } + + @Test(expected = RepoUpdater.SigningException.class) + public void testIndexV1WithBadTestyJarNoSigningCert() throws IOException, RepoUpdater.UpdateException { + testBadTestyJar("testy.at.or.at_no-.RSA_index-v1.jar"); + } + + @Test(expected = RepoUpdater.SigningException.class) + public void testIndexV1WithBadTestyJarNoSignature() throws IOException, RepoUpdater.UpdateException { + testBadTestyJar("testy.at.or.at_no-.SF_index-v1.jar"); + } + + @Test(expected = RepoUpdater.SigningException.class) + public void testIndexV1WithBadTestyJarNoSignatureFiles() throws IOException, RepoUpdater.UpdateException { + testBadTestyJar("testy.at.or.at_no-signature_index-v1.jar"); + } + + private void testBadTestyJar(String jar) throws IOException, RepoUpdater.UpdateException { + Repo repo = MultiRepoUpdaterTest.createRepo("Testy", jar, context, TESTY_CERT); + IndexV1Updater updater = new IndexV1Updater(context, repo); + JarFile jarFile = new JarFile(TestUtils.copyResourceToTempFile(jar), true); + JarEntry indexEntry = (JarEntry) jarFile.getEntry(IndexV1Updater.DATA_FILE_NAME); + InputStream indexInputStream = jarFile.getInputStream(indexEntry); + updater.processIndexV1(indexInputStream, indexEntry, "fakeEtag"); + fail(); // it should never reach here, it should throw a SigningException + } + + @Test + public void testJacksonParsing() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + // the app ignores all unknown fields when complete, do not ignore during dev to catch mistakes + mapper.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + JsonFactory f = mapper.getFactory(); + JsonParser parser = f.createParser(TestUtils.copyResourceToTempFile("guardianproject_index-v1.json")); + + Repo repo = null; + App[] apps = null; + Map requests = null; + Map> packages = null; + + parser.nextToken(); // go into the main object block + while (true) { + String fieldName = parser.nextFieldName(); + if (fieldName == null) { + break; + } + switch (fieldName) { + case "repo": + repo = parseRepo(mapper, parser); + break; + case "requests": + requests = parseRequests(mapper, parser); + break; + case "apps": + apps = parseApps(mapper, parser); + break; + case "packages": + packages = parsePackages(mapper, parser); + break; + } + } + parser.close(); // ensure resources get cleaned up timely and properly + + RepoDetails indexV0Details = getFromFile("guardianproject_index.xml", + Repo.PUSH_REQUEST_ACCEPT_ALWAYS); + indexV0Details.apps.size(); + + System.out.println("total apps: " + apps.length + " " + indexV0Details.apps.size()); + assertEquals(indexV0Details.apps.size(), apps.length); + assertEquals(apps.length, packages.size()); + + int totalApks = 0; + for (String packageName : packages.keySet()) { + totalApks += packages.get(packageName).size(); + } + assertEquals(totalApks, indexV0Details.apks.size()); + + assertEquals(indexV0Details.icon, repo.icon); + assertEquals(indexV0Details.timestamp, repo.timestamp / 1000); // V1 is in millis + assertEquals(indexV0Details.name, repo.name); + assertArrayEquals(indexV0Details.mirrors, repo.mirrors); + + ArrayList installRequests = new ArrayList<>(); + for (RepoPushRequest repoPushRequest : indexV0Details.repoPushRequestList) { + if ("install".equals(repoPushRequest.request)) { + installRequests.add(repoPushRequest.packageName); + } + } + assertArrayEquals(installRequests.toArray(), requests.get("install")); + } + + private Repo parseRepo(ObjectMapper mapper, JsonParser parser) throws IOException { + System.out.println("parseRepo "); + parser.nextToken(); + parser.nextToken(); + ObjectReader repoReader = mapper.readerFor(Repo.class); + return repoReader.readValue(parser, Repo.class); + } + + private Map parseRequests(ObjectMapper mapper, JsonParser parser) throws IOException { + TypeReference> typeRef = new TypeReference>() { + }; + parser.nextToken(); // START_OBJECT + return mapper.readValue(parser, typeRef); + } + + private App[] parseApps(ObjectMapper mapper, JsonParser parser) throws IOException { + TypeReference typeRef = new TypeReference() { + }; + parser.nextToken(); // START_ARRAY + return mapper.readValue(parser, typeRef); + } + + private Map> parsePackages(ObjectMapper mapper, JsonParser parser) throws IOException { + TypeReference>> typeRef = new TypeReference>>() { + }; + parser.nextToken(); // START_OBJECT + return mapper.readValue(parser, typeRef); + } + + @NonNull + private RepoDetails getFromFile(String indexFilename, int pushRequests) { + return RepoXMLHandlerTest.getFromFile(getClass().getClassLoader(), indexFilename, pushRequests); + } +} diff --git a/app/src/test/java/org/fdroid/fdroid/updater/MultiRepoUpdaterTest.java b/app/src/test/java/org/fdroid/fdroid/updater/MultiRepoUpdaterTest.java index 08aadb30d..e457b217a 100644 --- a/app/src/test/java/org/fdroid/fdroid/updater/MultiRepoUpdaterTest.java +++ b/app/src/test/java/org/fdroid/fdroid/updater/MultiRepoUpdaterTest.java @@ -159,7 +159,7 @@ public abstract class MultiRepoUpdaterTest extends FDroidProviderTest { return createRepo(name, uri, context, PUB_KEY); } - protected Repo createRepo(String name, String uri, Context context, String signingCert) { + static Repo createRepo(String name, String uri, Context context, String signingCert) { Repo repo = new Repo(); repo.signingCertificate = signingCert; repo.address = uri; diff --git a/app/src/test/java/org/fdroid/fdroid/updater/RepoXMLHandlerTest.java b/app/src/test/java/org/fdroid/fdroid/updater/RepoXMLHandlerTest.java index 38acb06cd..b9f7f9e70 100644 --- a/app/src/test/java/org/fdroid/fdroid/updater/RepoXMLHandlerTest.java +++ b/app/src/test/java/org/fdroid/fdroid/updater/RepoXMLHandlerTest.java @@ -842,8 +842,13 @@ public class RepoXMLHandlerTest { @NonNull private RepoDetails getFromFile(String indexFilename, int pushRequests) { - Log.i(TAG, "test file: " + getClass().getClassLoader().getResource(indexFilename)); - InputStream inputStream = getClass().getClassLoader().getResourceAsStream(indexFilename); + return getFromFile(getClass().getClassLoader(), indexFilename, pushRequests); + } + + @NonNull + static RepoDetails getFromFile(ClassLoader classLoader, String indexFilename, int pushRequests) { + Log.i(TAG, "test file: " + classLoader.getResource(indexFilename)); + InputStream inputStream = classLoader.getResourceAsStream(indexFilename); return RepoDetails.getFromFile(inputStream, pushRequests); } diff --git a/app/src/test/resources/guardianproject_index-v1.json b/app/src/test/resources/guardianproject_index-v1.json new file mode 100644 index 000000000..750325ce1 --- /dev/null +++ b/app/src/test/resources/guardianproject_index-v1.json @@ -0,0 +1,1974 @@ +{ + "repo": { + "timestamp": 1488828510109, + "version": 18, + "name": "Guardian Project Official Releases", + "icon": "guardianproject.png", + "address": "https://guardianproject.info/fdroid/repo", + "description": "The official app repository of The Guardian Project. Applications in this repository are official binaries build by the original application developers and signed by the same key as the APKs that are released in the Google Play store. ", + "mirrors": [ + "http://bdf2wcxujkg6qqff.onion/fdroid/repo", + "https://guardianproject.info/fdroid/repo", + "https://s3.amazonaws.com/guardianproject/fdroid/repo" + ] + }, + "requests": { + "uninstall": [], + "install": [ + "org.torproject.android", + "info.guardianproject.orfox" + ] + }, + "apps": [ + { + "categories": [ + "Security", + "GuardianProject" + ], + "suggestedVersionCode": "999999999", + "description": "

    Android 4+ allows you to disable certificates from the system Settings and root isn't required, so try that first if you want to manually mess with the certificates. The app won't work with Android 4+ anyway.

    An app to manage security certificates on your phone also containing a version of the Android CACert keystore derived from Mozilla. If a certificate has recently become untrusted you can either install an update to this app or you can backup and remove certificates by yourself.

    Requires root: Yes, it writes to the system partition. You will need a device that has the \u2018grep\u2019 command on it (via busybox: present on most custom ROMs). If the \u2018save\u2019 doesn\u2019t work, then you will need to make your /system partition read-write by using a file explorer like Ghost Commander or via a command in Terminal Emulator.

    ", + "issueTracker": "https://github.com/guardianproject/cacert/issues", + "license": "GPLv3", + "name": "CACertMan", + "sourceCode": "https://github.com/guardianproject/cacert", + "summary": "Disable untrusted certificates", + "webSite": "https://guardianproject.info/2011/09/05/cacertman-app-to-address-diginotar-other-bad-cas", + "added": 1376863200000, + "icon": "info.guardianproject.cacert.4.png", + "packageName": "info.guardianproject.cacert", + "lastUpdated": 1383174000000 + }, + { + "bitcoin": "1Fi5xUHiAPRKxHvyUGVFGt9extBe8Srdbk", + "categories": [ + "Development", + "GuardianProject" + ], + "suggestedVersionCode": "9999999", + "description": "

    An InformaCam app to generate verifiable media.

    ", + "issueTracker": "https://dev.guardianproject.info/projects/informacam/issues", + "license": "GPLv3", + "name": "CameraV", + "sourceCode": "https://github.com/guardianproject/CameraV", + "summary": "An InformaCam app to generate verifiable media", + "webSite": "https://guardianproject.info/apps/camerav/", + "added": 1386716400000, + "icon": "org.witness.informacam.app.206.png", + "packageName": "org.witness.informacam.app", + "lastUpdated": 1446505200000 + }, + { + "categories": [ + "Internet", + "GuardianProject" + ], + "suggestedVersionCode": "999999999", + "description": "

    XMPP (Jabber) client that can do end-to-end encryption using the Off-the-Record Messaging protocol and can anonymize your chats via the Orbot app (root not required).

    The app used to be called GibberBot.

    ", + "issueTracker": "https://dev.guardianproject.info/projects/gibberbot", + "license": "Apache2", + "name": "ChatSecure", + "sourceCode": "https://github.com/guardianproject/Gibberbot", + "summary": "Instant Messaging client with OTR", + "webSite": "https://dev.guardianproject.info/projects/gibberbot", + "added": 1363647600000, + "icon": "info.guardianproject.otr.app.im.1423001.png", + "packageName": "info.guardianproject.otr.app.im", + "lastUpdated": 1481029619791 + }, + { + "categories": [ + "Multimedia", + "Security", + "GuardianProject" + ], + "suggestedVersionCode": "999999999", + "description": "

    This is a plugin for ChatSecure. It does not have any function on its own. For Your Ears Only... completely private, end-to-end encryption voice message recording, sending, receiving and playback.

    * For use with ChatSecure's encrypted \"Off-the-record\" data stream * Works over Tor - the ONLY Onion-routed voice messaging system, for total anonymity

    ", + "issueTracker": "https://dev.guardianproject.info/projects/chatsecure/issues", + "license": "SIL Open Font License, MIT License and the CC 3.0 License [CC-By with attribution requirement waived]", + "name": "ChatSecureVoicePlugin", + "sourceCode": "https://github.com/guardianproject/ChatSecureVoicePlugin", + "summary": "ChatSecure Voice Messaging", + "webSite": "https://guardianproject.info/apps/chatsecure", + "added": 1386889200000, + "icon": "info.guardianproject.soundrecorder.2.png", + "packageName": "info.guardianproject.soundrecorder", + "lastUpdated": 1386889200000 + }, + { + "bitcoin": "1Fi5xUHiAPRKxHvyUGVFGt9extBe8Srdbk", + "categories": [ + "Development", + "GuardianProject" + ], + "suggestedVersionCode": "9999999", + "description": "

    Checkey is a utility for getting information about the APKs that are installed on your device. Starting with a list of all of the apps that you have installed on your device, it will show you the APK signature with a single touch, and provides links to virustotal.com and androidobservatory.org to easily access the profiles of that APK. It will also let you export the signing certificate and generate ApkSignaturePin pin files for use with the TrustedIntents library.

    ", + "issueTracker": "https://dev.guardianproject.info/projects/checkey/issues", + "license": "GPLv3", + "name": "Checkey", + "sourceCode": "https://github.com/guardianproject/checkey", + "summary": "Info on local apps", + "webSite": "https://dev.guardianproject.info/projects/checkey", + "added": 1405116000000, + "icon": "info.guardianproject.checkey.102.png", + "packageName": "info.guardianproject.checkey", + "lastUpdated": 1425855600000 + }, + { + "categories": [ + "Reading", + "GuardianProject" + ], + "suggestedVersionCode": "999999999", + "description": "

    No description available

    ", + "license": "GPLv3", + "name": "Courier", + "sourceCode": "https://github.com/guardianproject/securereader", + "summary": "Privacy-aware RSS feed reader", + "added": 1400018400000, + "icon": "info.guardianproject.courier.15.png", + "packageName": "info.guardianproject.courier", + "lastUpdated": 1403733600000 + }, + { + "bitcoin": "1Fi5xUHiAPRKxHvyUGVFGt9extBe8Srdbk", + "categories": [ + "Development", + "GuardianProject" + ], + "suggestedVersionCode": "999999999", + "description": "

    Lil' Debi builds up a whole Debian chroot on your phone entirely using debootstrap. You choose the release, mirror, and size of the disk image, and away it goes. It could take up to an hour on a slow device.

    Then it has a simple chroot manager that fscks your disk, mounts/unmounts things, starts/stops sshd if you have it installed, etc. You can also then use \u2018apt-get\u2019 to install any package that is released for ARM processors. This includes things like a complete real shell, Tor, TraceRouteTCP, iwconfig/ipconfig, and other security and crypto tools. Works well with Terminal Emulator\u2014just run `/debian/shell` to get a Debian shell.

    The aim of Lil\u2019 Debi is to provide a transparent and tightly integrated Debian install on your Android device. It mounts all of your Android partitions in Debian space, so you see a fusion of both systems. It's even possible to have Lil\u2019 Debi launch the normal Debian init start-up scripts when it starts, so that all you need to do is apt-get install and any servers you install will just work.

    Lil' Debi works with as few modifications to the Android system as possible. Currently, it only adds a /bin symlink, and a /debian mount directory. It does not touch /system at all.

    Requires root: Yes, because it needs to run debootstrap, create dirs in /, mount/umount, etc.

    ", + "issueTracker": "https://github.com/guardianproject/lildebi/issues", + "license": "GPLv3", + "name": "Lil' Debi", + "sourceCode": "https://github.com/guardianproject/lildebi", + "summary": "Run Debian on your phone", + "webSite": "https://github.com/guardianproject/lildebi/wiki", + "added": 1360105200000, + "icon": "info.guardianproject.lildebi.5400.png", + "packageName": "info.guardianproject.lildebi", + "lastUpdated": 1422226800000 + }, + { + "bitcoin": "1Fi5xUHiAPRKxHvyUGVFGt9extBe8Srdbk", + "categories": [ + "Navigation", + "Security", + "GuardianProject" + ], + "suggestedVersionCode": "9999999", + "description": "

    LocationPrivacy is not really app but rather a set of \"Intent Filters\" for all of the various ways of sharing location. When you share location from one app, LocationPrivacy offers itself as an option. It then recognizes insecure methods of sharing location, and then converts them to more secure methods. This mostly means that it rewrites URLs to use https, and even to use `geo:` URIs, which can work on fully offline setups. LocationPrivacy mostly works by reading the location information from the URL itself. For many URLs, LocationPrivacy must actually load some of the webpage in order to get the location.

    LocationPrivacy can also serve as a way to redirect all location links to your favorite mapping app. All map apps in Android can view `geo:` URIs, and LocationPrivacy converts many kinds of links to `geo:` URIs, including: Google Maps, OpenStreetMap, Amap, Baidu Map, QQ Map, Nokia HERE, Yandex Maps.

    This was started as part of the T2 Panic work, since sharing location is so often a part of panic apps. Follow our progress here: https://guardianproject.info/tag/panic

    Don\u2019t see your language? Join us and help translate the app: https://www.transifex.com/projects/p/locationprivacy

    ", + "issueTracker": "https://dev.guardianproject.info/projects/panic/issues", + "license": "GPLv3", + "name": "LocationPrivacy", + "sourceCode": "https://github.com/guardianproject/LocationPrivacy", + "summary": "privacy filters for when you are sharing your location", + "webSite": "https://dev.guardianproject.info/projects/panic", + "added": 1422486000000, + "icon": "info.guardianproject.locationprivacy.30.png", + "packageName": "info.guardianproject.locationprivacy", + "lastUpdated": 1481029454284 + }, + { + "categories": [ + "Office", + "GuardianProject" + ], + "suggestedVersionCode": "999999999", + "description": "

    Simple app for taking notes that encrypts everything behind a password.

    Status: Beta.

    ", + "issueTracker": "https://github.com/guardianproject/notecipher/issues", + "license": "Apache2", + "name": "NoteCipher", + "sourceCode": "https://github.com/guardianproject/notecipher", + "summary": "Notepad with lock", + "webSite": "https://guardianproject.info", + "added": 1358290800000, + "icon": "info.guardianproject.notepadbot.12.png", + "packageName": "info.guardianproject.notepadbot", + "lastUpdated": 1394406000000 + }, + { + "categories": [ + "Multimedia", + "Security", + "GuardianProject" + ], + "suggestedVersionCode": "999999999", + "description": "

    Ever capture someone in a photo or video, then realize they may not want to be in it? Not comfortable posting a friend, family member or child\u2019s face on the internet? Worried about the geolocation data in the picture giving away private hideaway? Tired of Facebook, Google and other sites \u201cauto detecting\u201d faces in your photos? Then this is for you, giving you the power to better protect the identity of those captures in your photos, before you post them online.

    Take a picture or load a photo or video from the Gallery, and ObscuraCam will automatically detect faces that you can pixelate, redact (blackout) or protect with funny nose and glasses. You can also invert pixelate, so that only the person you select is visible, and no one in the background can be recognized.

    This app will also remove all identifying data stored in photos including GPS location data and phone make & model. You can save the protected photo back to the Gallery, or share it directly to Facebook, Twitter or any other \u201cShare\u201d enabled app.

    ", + "issueTracker": "https://github.com/guardianproject/obscuracam/issues", + "license": "GPLv3", + "name": "ObscuraCam", + "sourceCode": "https://github.com/guardianproject/obscuracam", + "summary": "A camera app that keeps certain information private", + "webSite": "https://guardianproject.info/apps/obscuracam", + "added": 1376863200000, + "icon": "org.witness.sscphase1.34.png", + "packageName": "org.witness.sscphase1", + "lastUpdated": 1383174000000 + }, + { + "categories": [ + "Security", + "Internet", + "GuardianProject" + ], + "suggestedVersionCode": "999999999", + "description": "

    Tor is both software and an open network that helps you defend against network surveillance that threatens personal freedom and privacy, confidential business activities and relationships.

    Orbot allows access to Tor by accessing a local SOCKS or HTTP proxy. On a rooted device, the proxying can be completely transparent i.e. the app that accesses the network need not be aware of the proxy's existence; you can choose which apps go via the proxy in the settings.

    If you don't have root access, there are some apps that are designed to work closely with tor or allow proxied connections: ChatSecure, Orweb and Twidere. There is also a proxy configurator addon for org.mozilla.firefox called ProxyMob (not yet available from the Mozilla addon site).

    Requires root: No, but you will need to use apps that allow proxies if root is not granted.

    ", + "donate": "https://www.torproject.org/donate/donate.html.en", + "flattrID": "5649", + "issueTracker": "https://dev.guardianproject.info/projects/orbot/issues", + "license": "NewBSD", + "name": "Orbot", + "sourceCode": "https://gitweb.torproject.org/orbot.git", + "summary": "Tor (anonymity) client", + "webSite": "http://www.torproject.org/docs/android.html.en", + "added": 1374444000000, + "icon": "org.torproject.android.15208000.png", + "packageName": "org.torproject.android", + "lastUpdated": 1481029552790 + }, + { + "categories": [ + "Internet", + "Security", + "GuardianProject" + ], + "suggestedVersionCode": "999999999", + "description": "

    Orfox is the most privacy-enhancing web browser on Android, for visiting any website, even if it\u2019s normally censored, monitored, or on the hidden web. It is a port of the desktop Tor Browser to the Android version of Firefox.

    Orfox is a companion browser to Orbot, the port of Tor to Android. Orbot anonymizes internet traffic by routing it through many different stages and you must have that enabled first, though root isn't needed. Orfox disables certain other browser features that could be used to identify you.

    Orfox replaces Orweb as your private browser.

    ", + "issueTracker": "https://dev.guardianproject.info/projects/orfox/issues?set_filter=1", + "license": "MPL", + "name": "Orfox", + "sourceCode": "https://github.com/guardianproject/orfox", + "summary": "Orfox: Tor Browser for Android", + "added": 1474668000000, + "icon": "info.guardianproject.orfox.4.png", + "packageName": "info.guardianproject.orfox", + "lastUpdated": 1481029470199 + }, + { + "categories": [ + "Internet", + "Security", + "GuardianProject" + ], + "suggestedVersionCode": "999999999", + "description": "

    Orweb is a companion browser to Orbot, the port of Tor to Android.

    Orbot anonymizes internet traffic by routing it through many different stages and you must have that enabled first, though root isn't needed. Orweb disables certain other browser features that could be used to identify you.

    ", + "issueTracker": "https://dev.guardianproject.info/projects/orweb/issues?set_filter=1", + "license": "GPL", + "name": "Orweb", + "sourceCode": "https://github.com/guardianproject/orweb", + "summary": "Privacy-enhanced browser", + "webSite": "https://guardianproject.info/apps/orweb", + "added": 1350856800000, + "icon": "info.guardianproject.browser.7010.png", + "packageName": "info.guardianproject.browser", + "lastUpdated": 1448492400000 + }, + { + "categories": [ + "Office", + "GuardianProject" + ], + "suggestedVersionCode": "999999999", + "description": "

    Image steganography app with old school F5 steganography

    ", + "issueTracker": "https://github.com/guardianproject/PixelKnot/issues", + "license": "GPLv3", + "name": "PixelKnot", + "sourceCode": "https://github.com/guardianproject/PixelKnot", + "summary": "Hide messages inside files", + "webSite": "https://guardianproject.info", + "added": 1361833200000, + "icon": "info.guardianproject.pixelknot.100.png", + "packageName": "info.guardianproject.pixelknot", + "lastUpdated": 1481029472671 + }, + { + "bitcoin": "1Fi5xUHiAPRKxHvyUGVFGt9extBe8Srdbk", + "categories": [ + "Navigation", + "Security", + "GuardianProject" + ], + "suggestedVersionCode": "9999999", + "description": "

    Ripple is a \"panic button\" that can send it's trigger message to any app that is a \"panic responder\". Such apps can do things like lock, disguise themselves, delete private data, send an emergency message, and more. It is meant for situations where there is time to react, but where users need to be sure it is not mistakenly set off.

    This is a BETA version of this app! We are working now to add support to as many other apps as possible. ChatSecure, Orweb, Umbrella, and Zom already have support, these apps are coming soon: Courier, PanicButton, OpenKeychain, Orfox, SMSSecure, and StoryMaker.

    Here are two example scenarios:

    • An organization gets regularly raided by the security forces, who search all of the computers and mobile devices on the premises. The organization usually has at least a minute or two of warning before a raid starts. They need a very reliable way to trigger wiping all of the data from the sensitive apps.
    • An aid worker has lots of sensitive data about people on their device. They regularly sync that data up with a secure, central database. Occasionally, the aid worker has to leave the country on very short notice. The border guards regularly download the entire contents of mobile devices of people crossing through. While waiting in line at the border, the aid worker sees the border guards seizing people's devices, and then remembers all the data on the device, so she unlocks her phone and hits the wipe trigger, which wipes all sensitive apps from the device. When the aid worker returns to the central office, the device is again synced up with the central database.

    This was started as part of the T2 Panic work, since sharing location is so often a part of panic apps. Follow our progress here:

    \u2605 https://guardianproject.info/tag/panic \u2605 https://dev.guardianproject.info/projects/panic Don\u2019t see your language? Join us and help translate the app: https://www.transifex.com/projects/p/rippleapp

    ==Learn More==

    \u2605 ABOUT US: Guardian Project is a group of developers that make secure mobile apps and open-source code for a better tomorrow \u2605 OUR WEBSITE: https://GuardianProject.info \u2605 ON TWITTER: https://twitter.com/guardianproject \u2605 FREE SOFTWARE: Ripple is free software. You can take a look at our source code, or contribute to help make Ripple even better: https://github.com/guardianproject/Ripple \u2605 MESSAGE US: Are we missing your favorite feature? Found an annoying bug? Please tell us! We\u2019d love to hear from you. Send us an email: support@guardianproject.info or find us in our chat room https://guardianproject.info/contact

    ", + "issueTracker": "https://dev.guardianproject.info/projects/panic/issues", + "license": "GPLv3", + "name": "Ripple", + "sourceCode": "https://github.com/guardianproject/ripple", + "summary": "Trigger apps to protect your privacy when in anxious or panic situations", + "webSite": "https://dev.guardianproject.info/projects/panic", + "added": 1481029448654, + "icon": "info.guardianproject.ripple.75.png", + "packageName": "info.guardianproject.ripple", + "lastUpdated": 1481029466419 + }, + { + "categories": [ + "Multimedia", + "Security", + "GuardianProject" + ], + "suggestedVersionCode": "999999999", + "description": "

    Plugin for ChatSecure to support for core emoji input and display. Based on \"Phantom Open Emoji\" project.

    ", + "issueTracker": "https://dev.guardianproject.info/projects/chatsecure/issues", + "license": "SIL Open Font License, MIT License and the CC 3.0 License [CC-By with attribution requirement waived]", + "name": "StickerPack", + "sourceCode": "https://github.com/guardianproject/ChatSecureVoicePlugin", + "summary": "ChatSecure Open Emoji Plugin", + "webSite": "https://guardianproject.info/apps/chatsecure", + "added": 1385938800000, + "icon": "info.guardianproject.chatsecure.emoji.core.1.png", + "packageName": "info.guardianproject.chatsecure.emoji.core", + "lastUpdated": 1385938800000 + } + ], + "packages": { + "info.guardianproject.courier": [ + { + "added": 1400018400000, + "apkName": "Courier-0.1.8.apk", + "hash": "e013db095e8da843fae5ac44be6152e51377ee717e5c8a7b6d913d7720566b5a", + "hashType": "sha256", + "minSdkVersion": "9", + "nativecode": [ + "armeabi", + "x86" + ], + "packageName": "info.guardianproject.courier", + "sig": "d70ac6a02b53ebdd1354ea7af7b9ceee", + "size": 16536125, + "targetSdkVersion": "15", + "uses-permission": [ + [ + "android.permission.READ_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.INTERNET", + null + ], + [ + "android.permission.BLUETOOTH", + null + ], + [ + "android.permission.BLUETOOTH_ADMIN", + null + ], + [ + "android.permission.VIBRATE", + null + ], + [ + "android.permission.ACCESS_NETWORK_STATE", + null + ], + [ + "android.permission.WRITE_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.ACCESS_WIFI_STATE", + null + ] + ], + "versionCode": 14, + "versionName": "0.1.8" + }, + { + "added": 1403733600000, + "apkName": "Courier-0.1.9.apk", + "hash": "bf6566da1f90831887f5bf5605f8d816b1f7f694969459dec599b8bc01a827d3", + "hashType": "sha256", + "minSdkVersion": "9", + "nativecode": [ + "armeabi", + "x86" + ], + "packageName": "info.guardianproject.courier", + "sig": "d70ac6a02b53ebdd1354ea7af7b9ceee", + "size": 16484753, + "targetSdkVersion": "15", + "uses-permission": [ + [ + "android.permission.READ_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.INTERNET", + null + ], + [ + "android.permission.BLUETOOTH", + null + ], + [ + "android.permission.BLUETOOTH_ADMIN", + null + ], + [ + "android.permission.VIBRATE", + null + ], + [ + "android.permission.ACCESS_NETWORK_STATE", + null + ], + [ + "android.permission.WRITE_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.ACCESS_WIFI_STATE", + null + ] + ], + "versionCode": 15, + "versionName": "0.1.9" + } + ], + "info.guardianproject.cacert": [ + { + "added": 1376863200000, + "apkName": "CACertMan-0.0.2-alpha-20111011.apk", + "hash": "251ebd40ce4a281a2292692707fb1e9c91428994cbad80a416a297db51069eb8", + "hashType": "sha256", + "minSdkVersion": "7", + "packageName": "info.guardianproject.cacert", + "sig": "a0eeebb161f946e3516945fae8a92a3e", + "size": 172263, + "targetSdkVersion": "7", + "versionCode": 4, + "versionName": "0.0.2.20111012" + }, + { + "added": 1383174000000, + "apkName": "CACertMan-0.0.2-20110906.apk", + "hash": "c217c49abe5134007ceb2623a6189a73fa02af9d2b2bbcc5cbc4cb5da7b36a5d", + "hashType": "sha256", + "minSdkVersion": "8", + "packageName": "info.guardianproject.cacert", + "sig": "a0eeebb161f946e3516945fae8a92a3e", + "size": 170305, + "targetSdkVersion": "8", + "versionCode": 3, + "versionName": "0.0.2-20110906" + } + ], + "info.guardianproject.soundrecorder": [ + { + "added": 1386889200000, + "apkName": "ChatSecureVoiceMessaging-0.2.apk", + "hash": "abae18cc9cfa62fca5dce072c4c50d41b4fece506967ce9a3e2711cd1031dbee", + "hashType": "sha256", + "minSdkVersion": "10", + "packageName": "info.guardianproject.soundrecorder", + "sig": "a0eeebb161f946e3516945fae8a92a3e", + "size": 394212, + "targetSdkVersion": "10", + "uses-permission": [ + [ + "android.permission.WRITE_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.READ_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.RECORD_AUDIO", + null + ], + [ + "android.permission.WAKE_LOCK", + null + ], + [ + "android.permission.READ_PHONE_STATE", + null + ] + ], + "versionCode": 2, + "versionName": "0.2" + } + ], + "info.guardianproject.browser": [ + { + "added": 1404079200000, + "apkName": "Orweb-release-0.6.1.apk", + "hash": "103f4a98fa282923c07e445b2a383e946b6c15e10ed08005af3d0743249a0359", + "hashType": "sha256", + "minSdkVersion": "9", + "packageName": "info.guardianproject.browser", + "sig": "a0eeebb161f946e3516945fae8a92a3e", + "size": 931433, + "targetSdkVersion": "19", + "uses-permission": [ + [ + "android.permission.WRITE_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.READ_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.INTERNET", + null + ] + ], + "versionCode": 27, + "versionName": "0.6.1" + }, + { + "added": 1448492400000, + "apkName": "Orweb-0.7.1.apk", + "hash": "949d65d6e8a1eadd0aa626bdc7c5a3e2b0dbe5a38dea1d725cce2a34ec84f0d4", + "hashType": "sha256", + "minSdkVersion": "9", + "packageName": "info.guardianproject.browser", + "sig": "a0eeebb161f946e3516945fae8a92a3e", + "size": 2424394, + "targetSdkVersion": "21", + "uses-permission": [ + [ + "android.permission.WRITE_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.READ_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.INTERNET", + null + ] + ], + "versionCode": 7010, + "versionName": "0.7.1" + }, + { + "added": 1415833200000, + "apkName": "Orweb-release-0.7.apk", + "hash": "763541f43f5dc136744b4361fe67d36f25cc036526d6c3e934287d72d1b411ab", + "hashType": "sha256", + "minSdkVersion": "9", + "packageName": "info.guardianproject.browser", + "sig": "a0eeebb161f946e3516945fae8a92a3e", + "size": 1244875, + "targetSdkVersion": "18", + "uses-permission": [ + [ + "android.permission.WRITE_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.READ_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.INTERNET", + null + ] + ], + "versionCode": 28, + "versionName": "0.7" + } + ], + "info.guardianproject.notepadbot": [ + { + "added": 1394406000000, + "apkName": "NoteCipher-beta-0.1.apk", + "hash": "b560a3d6364c32990ea7505f53b019f64fde597d67513f41a50e7d034af48caa", + "hashType": "sha256", + "minSdkVersion": "10", + "nativecode": [ + "armeabi", + "x86" + ], + "packageName": "info.guardianproject.notepadbot", + "sig": "a0eeebb161f946e3516945fae8a92a3e", + "size": 7321123, + "targetSdkVersion": "19", + "uses-permission": [ + [ + "android.permission.READ_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.WRITE_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.VIBRATE", + null + ] + ], + "versionCode": 12, + "versionName": "0.1" + }, + { + "added": 1358290800000, + "apkName": "NoteCipher-0.0.7.apk", + "hash": "8fa7536a87634c6b3441053c4f16315e4fd5aa6ef672a0026a594c107308d7bf", + "hashType": "sha256", + "minSdkVersion": "7", + "nativecode": [ + "armeabi" + ], + "packageName": "info.guardianproject.notepadbot", + "sig": "a0eeebb161f946e3516945fae8a92a3e", + "size": 3731119, + "targetSdkVersion": "17", + "uses-permission": [ + [ + "android.permission.READ_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.WRITE_EXTERNAL_STORAGE", + null + ] + ], + "versionCode": 10, + "versionName": "0.0.7" + }, + { + "added": 1383174000000, + "apkName": "NoteCipher-0.0.7.1.apk", + "hash": "da518f13206d2218234bfcc83205b7b2b81ec67a4cc448f818c617332235e700", + "hashType": "sha256", + "minSdkVersion": "11", + "nativecode": [ + "armeabi" + ], + "packageName": "info.guardianproject.notepadbot", + "sig": "a0eeebb161f946e3516945fae8a92a3e", + "size": 3729342, + "targetSdkVersion": "17", + "uses-permission": [ + [ + "android.permission.READ_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.WRITE_EXTERNAL_STORAGE", + null + ] + ], + "versionCode": 11, + "versionName": "0.0.7.1" + } + ], + "info.guardianproject.orfox": [ + { + "added": 1481029470199, + "apkName": "Orfox-1.2.1-TorBrowser-6.5-Fennec45.5.1-build2.apk", + "hash": "d43032e79c7c31cabb194b8c1c4b14fbf73dd2cfda958ba415879ddf2f38ace2", + "hashType": "sha256", + "minSdkVersion": "9", + "nativecode": [ + "armeabi-v7a" + ], + "packageName": "info.guardianproject.orfox", + "sig": "d70ac6a02b53ebdd1354ea7af7b9ceee", + "size": 35273126, + "targetSdkVersion": "22", + "uses-permission": [ + [ + "android.permission.READ_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.DOWNLOAD_WITHOUT_NOTIFICATION", + null + ], + [ + "info.guardianproject.orfox.permissions.BROWSER_PROVIDER", + null + ], + [ + "com.android.launcher.permission.UNINSTALL_SHORTCUT", + null + ], + [ + "android.permission.WAKE_LOCK", + null + ], + [ + "android.permission.INTERNET", + null + ], + [ + "android.permission.CHANGE_WIFI_STATE", + null + ], + [ + "info.guardianproject.orfox.permissions.FORMHISTORY_PROVIDER", + null + ], + [ + "android.permission.VIBRATE", + null + ], + [ + "android.permission.ACCESS_NETWORK_STATE", + null + ], + [ + "com.android.launcher.permission.INSTALL_SHORTCUT", + null + ], + [ + "com.android.browser.permission.READ_HISTORY_BOOKMARKS", + null + ], + [ + "android.permission.WRITE_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.ACCESS_WIFI_STATE", + null + ], + [ + "info.guardianproject.orfox.permissions.PASSWORD_PROVIDER", + null + ] + ], + "versionCode": 4, + "versionName": "Fennec-45.5.1esr/TorBrowser-6.5-1/Orfox-1.2.1" + }, + { + "added": 1474668000000, + "apkName": "Orfox-1.2-TorBrowser-6.5-Fennec45.4.0.apk", + "hash": "9b5f6614b94a47ae561e8c974d42056ba6cb6da520766deda09aec3699aeff94", + "hashType": "sha256", + "minSdkVersion": "9", + "nativecode": [ + "armeabi-v7a" + ], + "packageName": "info.guardianproject.orfox", + "sig": "d70ac6a02b53ebdd1354ea7af7b9ceee", + "size": 35242066, + "targetSdkVersion": "22", + "uses-permission": [ + [ + "android.permission.READ_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.DOWNLOAD_WITHOUT_NOTIFICATION", + null + ], + [ + "info.guardianproject.orfox.permissions.BROWSER_PROVIDER", + null + ], + [ + "com.android.launcher.permission.UNINSTALL_SHORTCUT", + null + ], + [ + "android.permission.WAKE_LOCK", + null + ], + [ + "android.permission.INTERNET", + null + ], + [ + "android.permission.CHANGE_WIFI_STATE", + null + ], + [ + "info.guardianproject.orfox.permissions.FORMHISTORY_PROVIDER", + null + ], + [ + "android.permission.VIBRATE", + null + ], + [ + "android.permission.ACCESS_NETWORK_STATE", + null + ], + [ + "com.android.launcher.permission.INSTALL_SHORTCUT", + null + ], + [ + "com.android.browser.permission.READ_HISTORY_BOOKMARKS", + null + ], + [ + "android.permission.WRITE_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.ACCESS_WIFI_STATE", + null + ], + [ + "info.guardianproject.orfox.permissions.PASSWORD_PROVIDER", + null + ] + ], + "versionCode": 3, + "versionName": "Fennec-45.4.0esr/TorBrowser-6.5-1/Orfox-1.2" + } + ], + "info.guardianproject.chatsecure.emoji.core": [ + { + "added": 1385938800000, + "apkName": "ChatSecurePluginOpenEmoji-release-v1.apk", + "hash": "131c1ebaf795c3f053701285699f0b7e517de1c7fdba56e247b1ec31766b2808", + "hashType": "sha256", + "minSdkVersion": "8", + "packageName": "info.guardianproject.chatsecure.emoji.core", + "sig": "a0eeebb161f946e3516945fae8a92a3e", + "size": 1814271, + "targetSdkVersion": "17", + "versionCode": 1, + "versionName": "1.0" + } + ], + "info.guardianproject.pixelknot": [ + { + "added": 1374444000000, + "apkName": "PixelKnot-release-0.3.1.apk", + "hash": "a3101fe8a2d47ab205cb00459fa62c639a6fac4538f6cd9d06eb48d2965c4d21", + "hashType": "sha256", + "minSdkVersion": "9", + "nativecode": [ + "armeabi" + ], + "packageName": "info.guardianproject.pixelknot", + "sig": "a0eeebb161f946e3516945fae8a92a3e", + "size": 3976822, + "targetSdkVersion": "17", + "uses-permission": [ + [ + "android.permission.READ_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.WRITE_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.CAMERA", + null + ] + ], + "versionCode": 4, + "versionName": "0.3.1" + }, + { + "added": 1481029472671, + "apkName": "PixelKnot-release-1.0.0.apk", + "hash": "f97557cf7ec81ade50c308c5552dc6dc827d0e02ce90f84b1df6b7477d9f5a39", + "hashType": "sha256", + "minSdkVersion": "17", + "nativecode": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a", + "mips", + "mips64", + "x86", + "x86_64" + ], + "packageName": "info.guardianproject.pixelknot", + "sig": "a0eeebb161f946e3516945fae8a92a3e", + "size": 1983586, + "targetSdkVersion": "25", + "uses-permission": [ + [ + "android.permission.READ_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.WRITE_EXTERNAL_STORAGE", + 18 + ], + [ + "android.permission.VIBRATE", + 18 + ] + ], + "versionCode": 100, + "versionName": "1.0.0" + }, + { + "added": 1435269600000, + "apkName": "PixelKnot-release-0.3.3.apk", + "hash": "6beede8519a9e87ba8edaa5a76f203cfefd5f39eb911e789031cc6e911714b89", + "hashType": "sha256", + "minSdkVersion": "14", + "nativecode": [ + "armeabi" + ], + "packageName": "info.guardianproject.pixelknot", + "sig": "a0eeebb161f946e3516945fae8a92a3e", + "size": 4751233, + "targetSdkVersion": "17", + "uses-permission": [ + [ + "android.permission.READ_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.WRITE_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.CAMERA", + null + ] + ], + "versionCode": 6, + "versionName": "0.3.3" + } + ], + "org.witness.informacam.app": [ + { + "added": 1443736800000, + "apkName": "CameraV-release-0.2.4.apk", + "hash": "a10eefaed5a12c353525b07e655f6959fe1eb06cd5c549be56afaca6db0c6ce0", + "hashType": "sha256", + "minSdkVersion": "16", + "nativecode": [ + "armeabi", + "armeabi-v7a", + "x86" + ], + "packageName": "org.witness.informacam.app", + "sig": "d70ac6a02b53ebdd1354ea7af7b9ceee", + "size": 24062229, + "targetSdkVersion": "21", + "uses-permission": [ + [ + "android.permission.KILL_BACKGROUND_PROCESSES", + null + ], + [ + "android.permission.READ_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.RECORD_AUDIO", + null + ], + [ + "android.permission.ACCESS_COARSE_LOCATION", + null + ], + [ + "android.permission.CAMERA", + null + ], + [ + "android.permission.BLUETOOTH", + null + ], + [ + "android.permission.WAKE_LOCK", + null + ], + [ + "android.permission.READ_PHONE_STATE", + null + ], + [ + "android.permission.USE_CREDENTIALS", + null + ], + [ + "android.permission.INTERNET", + null + ], + [ + "android.permission.GET_ACCOUNTS", + null + ], + [ + "android.permission.CHANGE_WIFI_STATE", + null + ], + [ + "android.permission.BLUETOOTH_ADMIN", + null + ], + [ + "android.permission.VIBRATE", + null + ], + [ + "android.permission.ACCESS_NETWORK_STATE", + null + ], + [ + "android.permission.ACCESS_FINE_LOCATION", + null + ], + [ + "android.permission.WRITE_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.ACCESS_WIFI_STATE", + null + ], + [ + "android.permission.GET_TASKS", + null + ] + ], + "versionCode": 204, + "versionName": "0.2.4" + }, + { + "added": 1446505200000, + "apkName": "CameraVApp-release-0.2.6.apk", + "hash": "508f453e26c8c83dba858b53b21d909d549fe5646d01eb198c96c22d8e521e7c", + "hashType": "sha256", + "minSdkVersion": "16", + "nativecode": [ + "armeabi", + "armeabi-v7a", + "x86" + ], + "packageName": "org.witness.informacam.app", + "sig": "d70ac6a02b53ebdd1354ea7af7b9ceee", + "size": 24123646, + "targetSdkVersion": "21", + "uses-permission": [ + [ + "android.permission.KILL_BACKGROUND_PROCESSES", + null + ], + [ + "android.permission.READ_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.RECORD_AUDIO", + null + ], + [ + "android.permission.ACCESS_COARSE_LOCATION", + null + ], + [ + "android.permission.CAMERA", + null + ], + [ + "android.permission.BLUETOOTH", + null + ], + [ + "android.permission.WAKE_LOCK", + null + ], + [ + "android.permission.READ_PHONE_STATE", + null + ], + [ + "android.permission.USE_CREDENTIALS", + null + ], + [ + "android.permission.INTERNET", + null + ], + [ + "android.permission.GET_TOP_ACTIVITY_INFO", + null + ], + [ + "android.permission.GET_ACCOUNTS", + null + ], + [ + "android.permission.CHANGE_WIFI_STATE", + null + ], + [ + "android.permission.BLUETOOTH_ADMIN", + null + ], + [ + "android.permission.VIBRATE", + null + ], + [ + "android.permission.ACCESS_NETWORK_STATE", + null + ], + [ + "android.permission.ACCESS_FINE_LOCATION", + null + ], + [ + "android.permission.WRITE_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.ACCESS_WIFI_STATE", + null + ] + ], + "versionCode": 206, + "versionName": "0.2.6" + }, + { + "added": 1442354400000, + "apkName": "CameraVApp-release-0.2.2.apk", + "hash": "8b17cbe2a5cb777b49f5ef67a390f9d9d68765c90213ac64f3ca0456860dc9b7", + "hashType": "sha256", + "minSdkVersion": "16", + "nativecode": [ + "armeabi", + "armeabi-v7a", + "x86" + ], + "packageName": "org.witness.informacam.app", + "sig": "d70ac6a02b53ebdd1354ea7af7b9ceee", + "size": 24283932, + "targetSdkVersion": "21", + "uses-permission": [ + [ + "android.permission.KILL_BACKGROUND_PROCESSES", + null + ], + [ + "android.permission.READ_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.RECORD_AUDIO", + null + ], + [ + "android.permission.ACCESS_COARSE_LOCATION", + null + ], + [ + "android.permission.CAMERA", + null + ], + [ + "android.permission.BLUETOOTH", + null + ], + [ + "android.permission.WAKE_LOCK", + null + ], + [ + "android.permission.READ_PHONE_STATE", + null + ], + [ + "android.permission.USE_CREDENTIALS", + null + ], + [ + "android.permission.INTERNET", + null + ], + [ + "android.permission.GET_ACCOUNTS", + null + ], + [ + "android.permission.CHANGE_WIFI_STATE", + null + ], + [ + "android.permission.BLUETOOTH_ADMIN", + null + ], + [ + "android.permission.VIBRATE", + null + ], + [ + "android.permission.ACCESS_NETWORK_STATE", + null + ], + [ + "android.permission.ACCESS_FINE_LOCATION", + null + ], + [ + "android.permission.WRITE_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.ACCESS_WIFI_STATE", + null + ], + [ + "android.permission.GET_TASKS", + null + ] + ], + "versionCode": 202, + "versionName": "0.2.2" + } + ], + "info.guardianproject.checkey": [ + { + "added": 1425855600000, + "apkName": "Checkey-0.1.2.apk", + "hash": "754701dbac52de5ca3930c2393970c03ef9aa07d1456911e9bf254d6014e0645", + "hashType": "sha256", + "minSdkVersion": "8", + "packageName": "info.guardianproject.checkey", + "sig": "d70ac6a02b53ebdd1354ea7af7b9ceee", + "size": 842881, + "targetSdkVersion": "21", + "uses-permission": [ + [ + "android.permission.INTERNET", + null + ] + ], + "versionCode": 102, + "versionName": "0.1.2" + }, + { + "added": 1422399600000, + "apkName": "Checkey-0.1.1.apk", + "hash": "2d81f339bb69626af42e8868dc6928c9072ebcbae76e1ff5ac8172e78ebe9cdd", + "hashType": "sha256", + "minSdkVersion": "8", + "packageName": "info.guardianproject.checkey", + "sig": "d70ac6a02b53ebdd1354ea7af7b9ceee", + "size": 967083, + "targetSdkVersion": "21", + "uses-permission": [ + [ + "android.permission.INTERNET", + null + ] + ], + "versionCode": 101, + "versionName": "0.1.1" + }, + { + "added": 1405116000000, + "apkName": "Checkey-0.1.apk", + "hash": "a8e3c102d5279a3029d0eebdeda2ffdbe1f8a3493ea7dbdc31a11affc708ee57", + "hashType": "sha256", + "minSdkVersion": "8", + "packageName": "info.guardianproject.checkey", + "sig": "d70ac6a02b53ebdd1354ea7af7b9ceee", + "size": 878679, + "targetSdkVersion": "19", + "uses-permission": [ + [ + "android.permission.INTERNET", + null + ] + ], + "versionCode": 1, + "versionName": "0.1" + } + ], + "info.guardianproject.lildebi": [ + { + "added": 1413928800000, + "apkName": "LilDebi-0.5.2-release.apk", + "hash": "07fa3dfb690e44eb540942ba2a51718c72351c91a253a56a0c90649f6d8903dd", + "hashType": "sha256", + "minSdkVersion": "8", + "packageName": "info.guardianproject.lildebi", + "sig": "a0eeebb161f946e3516945fae8a92a3e", + "size": 1861790, + "targetSdkVersion": "19", + "uses-permission": [ + [ + "android.permission.READ_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.ACCESS_SUPERUSER", + null + ], + [ + "android.permission.WAKE_LOCK", + null + ], + [ + "android.permission.INTERNET", + null + ], + [ + "android.permission.RECEIVE_BOOT_COMPLETED", + null + ], + [ + "android.permission.ACCESS_NETWORK_STATE", + null + ], + [ + "jackpal.androidterm.permission.RUN_SCRIPT", + null + ], + [ + "android.permission.WRITE_EXTERNAL_STORAGE", + null + ] + ], + "versionCode": 5200, + "versionName": "0.5.2" + }, + { + "added": 1422226800000, + "apkName": "LilDebi-0.5.3-release.apk", + "hash": "01c5a8e1fd778c141e70633d14f1b69228d6f492961098616e0446c116cf9e44", + "hashType": "sha256", + "minSdkVersion": "8", + "packageName": "info.guardianproject.lildebi", + "sig": "a0eeebb161f946e3516945fae8a92a3e", + "size": 1879560, + "targetSdkVersion": "19", + "uses-permission": [ + [ + "android.permission.READ_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.ACCESS_SUPERUSER", + null + ], + [ + "android.permission.WAKE_LOCK", + null + ], + [ + "android.permission.INTERNET", + null + ], + [ + "android.permission.RECEIVE_BOOT_COMPLETED", + null + ], + [ + "android.permission.ACCESS_NETWORK_STATE", + null + ], + [ + "jackpal.androidterm.permission.RUN_SCRIPT", + null + ], + [ + "android.permission.WRITE_EXTERNAL_STORAGE", + null + ] + ], + "versionCode": 5300, + "versionName": "0.5.3" + }, + { + "added": 1422226800000, + "apkName": "LilDebi-0.5.4-release.apk", + "hash": "2c490376d8853fae04e79541f5d61e66a42ed0e890208945a11036c4a7b111da", + "hashType": "sha256", + "maxSdkVersion": "20", + "minSdkVersion": "8", + "packageName": "info.guardianproject.lildebi", + "sig": "a0eeebb161f946e3516945fae8a92a3e", + "size": 1876705, + "targetSdkVersion": "20", + "uses-permission": [ + [ + "android.permission.READ_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.ACCESS_SUPERUSER", + null + ], + [ + "android.permission.WAKE_LOCK", + null + ], + [ + "android.permission.INTERNET", + null + ], + [ + "android.permission.RECEIVE_BOOT_COMPLETED", + null + ], + [ + "android.permission.ACCESS_NETWORK_STATE", + null + ], + [ + "jackpal.androidterm.permission.RUN_SCRIPT", + null + ], + [ + "android.permission.WRITE_EXTERNAL_STORAGE", + null + ] + ], + "versionCode": 5400, + "versionName": "0.5.4" + } + ], + "info.guardianproject.otr.app.im": [ + { + "added": 1481029471580, + "apkName": "ChatSecure-v14.2.2.apk", + "hash": "9d4620fec0c7837ddffccde7918d7a7db0976fbcd361b96659abd93b5cc0d9e3", + "hashType": "sha256", + "minSdkVersion": "9", + "nativecode": [ + "armeabi", + "x86" + ], + "packageName": "info.guardianproject.otr.app.im", + "sig": "a0eeebb161f946e3516945fae8a92a3e", + "size": 10502135, + "targetSdkVersion": "21", + "uses-permission": [ + [ + "android.permission.CHANGE_WIFI_MULTICAST_STATE", + null + ], + [ + "android.permission.READ_EXTERNAL_STORAGE", + null + ], + [ + "info.guardianproject.otr.app.providers.imps.permission.READ_ONLY", + null + ], + [ + "info.guardianproject.otr.app.im.permission.IM_SERVICE", + null + ], + [ + "com.google.android.googleapps.permission.GOOGLE_AUTH", + null + ], + [ + "android.permission.WAKE_LOCK", + null + ], + [ + "android.permission.INTERNET", + null + ], + [ + "android.permission.USE_CREDENTIALS", + null + ], + [ + "android.permission.UPDATE_APP_OPS_STATS", + null + ], + [ + "android.permission.RECEIVE_BOOT_COMPLETED", + null + ], + [ + "android.permission.GET_ACCOUNTS", + null + ], + [ + "info.guardianproject.otr.app.providers.imps.permission.WRITE_ONLY", + null + ], + [ + "android.permission.MANAGE_ACCOUNTS", + null + ], + [ + "android.permission.VIBRATE", + null + ], + [ + "android.permission.ACCESS_NETWORK_STATE", + null + ], + [ + "android.permission.WRITE_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.ACCESS_WIFI_STATE", + null + ] + ], + "versionCode": 1422001, + "versionName": "14.2.2" + }, + { + "added": 1454454000000, + "apkName": "ChatSecure-v14.2.3a.apk", + "hash": "36d7d71c8a2115bdd2bd63bb639af286ee3242cce11cdb5c53378d1a7f35528e", + "hashType": "sha256", + "minSdkVersion": "9", + "nativecode": [ + "armeabi", + "x86" + ], + "packageName": "info.guardianproject.otr.app.im", + "sig": "a0eeebb161f946e3516945fae8a92a3e", + "size": 10502397, + "targetSdkVersion": "21", + "uses-permission": [ + [ + "android.permission.CHANGE_WIFI_MULTICAST_STATE", + null + ], + [ + "android.permission.READ_EXTERNAL_STORAGE", + null + ], + [ + "info.guardianproject.otr.app.providers.imps.permission.READ_ONLY", + null + ], + [ + "info.guardianproject.otr.app.im.permission.IM_SERVICE", + null + ], + [ + "com.google.android.googleapps.permission.GOOGLE_AUTH", + null + ], + [ + "android.permission.WAKE_LOCK", + null + ], + [ + "android.permission.INTERNET", + null + ], + [ + "android.permission.USE_CREDENTIALS", + null + ], + [ + "android.permission.UPDATE_APP_OPS_STATS", + null + ], + [ + "android.permission.RECEIVE_BOOT_COMPLETED", + null + ], + [ + "android.permission.GET_ACCOUNTS", + null + ], + [ + "info.guardianproject.otr.app.providers.imps.permission.WRITE_ONLY", + null + ], + [ + "android.permission.MANAGE_ACCOUNTS", + null + ], + [ + "android.permission.VIBRATE", + null + ], + [ + "android.permission.ACCESS_NETWORK_STATE", + null + ], + [ + "android.permission.WRITE_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.ACCESS_WIFI_STATE", + null + ] + ], + "versionCode": 1423001, + "versionName": "14.2.3" + }, + { + "added": 1440367200000, + "apkName": "ChatSecure-v14.2.1.apk", + "hash": "f82a3a7a823f5540b335743eb1399d0fd1f61bc68958750b5ef6aa0d95ad9a54", + "hashType": "sha256", + "minSdkVersion": "9", + "nativecode": [ + "armeabi", + "x86" + ], + "packageName": "info.guardianproject.otr.app.im", + "sig": "a0eeebb161f946e3516945fae8a92a3e", + "size": 10463010, + "targetSdkVersion": "21", + "uses-permission": [ + [ + "android.permission.CHANGE_WIFI_MULTICAST_STATE", + null + ], + [ + "android.permission.READ_EXTERNAL_STORAGE", + null + ], + [ + "info.guardianproject.otr.app.providers.imps.permission.READ_ONLY", + null + ], + [ + "info.guardianproject.otr.app.im.permission.IM_SERVICE", + null + ], + [ + "com.google.android.googleapps.permission.GOOGLE_AUTH", + null + ], + [ + "android.permission.WAKE_LOCK", + null + ], + [ + "android.permission.INTERNET", + null + ], + [ + "android.permission.USE_CREDENTIALS", + null + ], + [ + "android.permission.UPDATE_APP_OPS_STATS", + null + ], + [ + "android.permission.RECEIVE_BOOT_COMPLETED", + null + ], + [ + "android.permission.GET_ACCOUNTS", + null + ], + [ + "info.guardianproject.otr.app.providers.imps.permission.WRITE_ONLY", + null + ], + [ + "android.permission.MANAGE_ACCOUNTS", + null + ], + [ + "android.permission.VIBRATE", + null + ], + [ + "android.permission.ACCESS_NETWORK_STATE", + null + ], + [ + "android.permission.WRITE_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.ACCESS_WIFI_STATE", + null + ] + ], + "versionCode": 1421001, + "versionName": "14.2.1" + } + ], + "org.torproject.android": [ + { + "added": 1478127600000, + "apkName": "Orbot-v15.2.0-RC-5-arm.apk", + "hash": "51c7e2b6a6de542e0d44f82d89ddf1d3216ec7a28297381ef15b12da2f3246f7", + "hashType": "sha256", + "minSdkVersion": "16", + "nativecode": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a" + ], + "packageName": "org.torproject.android", + "sig": "8bd7e51b479aeba908ff46ada3305a29", + "size": 7600548, + "targetSdkVersion": "23", + "uses-permission": [ + [ + "android.permission.ACCESS_SUPERUSER", + null + ], + [ + "android.permission.INTERNET", + null + ], + [ + "android.permission.ACCESS_NETWORK_STATE", + null + ], + [ + "android.permission.RECEIVE_BOOT_COMPLETED", + null + ] + ], + "versionCode": 15205000, + "versionName": "15.2.0-RC-5" + }, + { + "added": 1478214000000, + "apkName": "Orbot-v15.2.0-RC-7-multi.apk", + "hash": "8dc3edf0a9799eb23b5e478e15547e38831b28cc3e88b049aa5f41b7b72e7bf9", + "hashType": "sha256", + "minSdkVersion": "16", + "nativecode": [ + "arm64-v8a", + "armeabi", + "armeabi-v7a", + "x86" + ], + "packageName": "org.torproject.android", + "sig": "8bd7e51b479aeba908ff46ada3305a29", + "size": 12457510, + "targetSdkVersion": "23", + "uses-permission": [ + [ + "android.permission.ACCESS_SUPERUSER", + null + ], + [ + "android.permission.INTERNET", + null + ], + [ + "android.permission.ACCESS_NETWORK_STATE", + null + ], + [ + "android.permission.RECEIVE_BOOT_COMPLETED", + null + ] + ], + "versionCode": 15207000, + "versionName": "15.2.0-RC-7-multi" + }, + { + "added": 1478473200000, + "apkName": "Orbot-v15.2.0-RC-8-multi.apk", + "hash": "3758e1b6e6b9a3b7848b253d08d6c0b1b1b3223184da4bd2ba1aaff8cf676357", + "hashType": "sha256", + "minSdkVersion": "16", + "nativecode": [ + "armeabi", + "x86" + ], + "packageName": "org.torproject.android", + "sig": "8bd7e51b479aeba908ff46ada3305a29", + "size": 12296544, + "targetSdkVersion": "23", + "uses-permission": [ + [ + "android.permission.ACCESS_SUPERUSER", + null + ], + [ + "android.permission.INTERNET", + null + ], + [ + "android.permission.ACCESS_NETWORK_STATE", + null + ], + [ + "android.permission.RECEIVE_BOOT_COMPLETED", + null + ] + ], + "versionCode": 15208000, + "versionName": "15.2.0-RC-8-multi" + } + ], + "org.witness.sscphase1": [ + { + "added": 1376863200000, + "apkName": "ObscuraCam-1.2-FINAL.apk", + "hash": "fc4b1e26b09ab79b1ab174e8985b89985a0110f9d97d2b0472e529c85e3a1d89", + "hashType": "sha256", + "minSdkVersion": "8", + "nativecode": [ + "armeabi" + ], + "packageName": "org.witness.sscphase1", + "sig": "a0eeebb161f946e3516945fae8a92a3e", + "size": 1728825, + "targetSdkVersion": "8", + "uses-permission": [ + [ + "android.permission.READ_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.WRITE_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.VIBRATE", + null + ] + ], + "versionCode": 25, + "versionName": "1.2-FINAL" + }, + { + "added": 1383174000000, + "apkName": "ObscuraCam-2.0-RC2b.apk", + "hash": "eeea54985c96769524ec82fb1d3599b193a2d20d1f57f3afc4c97b11bd48df8f", + "hashType": "sha256", + "minSdkVersion": "10", + "nativecode": [ + "armeabi" + ], + "packageName": "org.witness.sscphase1", + "sig": "a0eeebb161f946e3516945fae8a92a3e", + "size": 8240221, + "targetSdkVersion": "11", + "uses-permission": [ + [ + "android.permission.READ_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.READ_MEDIA_STORAGE", + null + ], + [ + "android.permission.WRITE_MEDIA_STORAGE", + null + ], + [ + "android.permission.WAKE_LOCK", + null + ], + [ + "android.permission.VIBRATE", + null + ], + [ + "android.permission.WRITE_EXTERNAL_STORAGE", + null + ] + ], + "versionCode": 34, + "versionName": "2.0-RC2b" + } + ], + "info.guardianproject.locationprivacy": [ + { + "added": 1481029454284, + "apkName": "LocationPrivacy-0.3.apk", + "hash": "ec2b2c6e3a99422fbe8229711dfc7b741961c2ba7bc171c745818d8b76fc4d63", + "hashType": "sha256", + "minSdkVersion": "9", + "packageName": "info.guardianproject.locationprivacy", + "sig": "d70ac6a02b53ebdd1354ea7af7b9ceee", + "size": 1130602, + "targetSdkVersion": "22", + "uses-permission": [ + [ + "android.permission.INTERNET", + null + ] + ], + "versionCode": 30, + "versionName": "0.3" + }, + { + "added": 1422486000000, + "apkName": "LocationPrivacy-0.1.apk", + "hash": "130cfcc8b916682d974aa4e13385b47bdc23d07b0de852640563b880aeb61d1f", + "hashType": "sha256", + "minSdkVersion": "8", + "packageName": "info.guardianproject.locationprivacy", + "sig": "d70ac6a02b53ebdd1354ea7af7b9ceee", + "size": 818384, + "targetSdkVersion": "21", + "uses-permission": [ + [ + "android.permission.INTERNET", + null + ] + ], + "versionCode": 10, + "versionName": "0.1" + }, + { + "added": 1481029439166, + "apkName": "LocationPrivacy-0.2.apk", + "hash": "3cad63152ef9b04e1c2b880c286a80c65c083880612aaa36c0c4480b96adfea8", + "hashType": "sha256", + "minSdkVersion": "9", + "packageName": "info.guardianproject.locationprivacy", + "sig": "d70ac6a02b53ebdd1354ea7af7b9ceee", + "size": 1129409, + "targetSdkVersion": "22", + "uses-permission": [ + [ + "android.permission.INTERNET", + null + ] + ], + "versionCode": 20, + "versionName": "0.2" + } + ], + "info.guardianproject.ripple": [ + { + "added": 1481029466419, + "apkName": "Ripple-0.1.apk", + "hash": "9fd24cbb3552123e6ee119f912f1646dd21cd7a683734a8d502d8b44854a284b", + "hashType": "sha256", + "minSdkVersion": "10", + "packageName": "info.guardianproject.ripple", + "sig": "d70ac6a02b53ebdd1354ea7af7b9ceee", + "size": 1670285, + "targetSdkVersion": "23", + "versionCode": 2, + "versionName": "0.1" + }, + { + "added": 1481029448654, + "apkName": "Ripple-0.0.apk", + "hash": "025894a5f3a39a288ee60bb6c9cc2c559d395f22fed020d1086308ba12df85a3", + "hashType": "sha256", + "minSdkVersion": "10", + "packageName": "info.guardianproject.ripple", + "sig": "d70ac6a02b53ebdd1354ea7af7b9ceee", + "size": 1664407, + "targetSdkVersion": "23", + "versionCode": 1, + "versionName": "0.0" + }, + { + "added": 1481029455428, + "apkName": "Ripple-0.2-release.apk", + "hash": "4b14b1b402f0197e1e6ffe2c11e052432fc8a52749f5f02d9cc67799658df239", + "hashType": "sha256", + "minSdkVersion": "10", + "packageName": "info.guardianproject.ripple", + "sig": "d70ac6a02b53ebdd1354ea7af7b9ceee", + "size": 1669315, + "targetSdkVersion": "23", + "versionCode": 75, + "versionName": "0.2" + } + ] + } +} diff --git a/app/src/test/resources/guardianproject_index.xml b/app/src/test/resources/guardianproject_index.xml new file mode 100644 index 000000000..25865f6b6 --- /dev/null +++ b/app/src/test/resources/guardianproject_index.xml @@ -0,0 +1,790 @@ + + + + The official app repository of The Guardian Project. Applications in this repository are official binaries build by the original application developers and signed by the same key as the APKs that are released in the Google Play store. + http://bdf2wcxujkg6qqff.onion/fdroid/repo + https://guardianproject.info/fdroid/repo + https://s3.amazonaws.com/guardianproject/fdroid/repo + + + + + info.guardianproject.cacert + 2013-08-19 + 2013-10-31 + CACertMan + Disable untrusted certificates + info.guardianproject.cacert.4.png + <p>Android 4+ allows you to disable certificates from the system Settings and root isn't required, so try that first if you want to manually mess with the certificates. The app won't work with Android 4+ anyway.</p><p>An app to manage security certificates on your phone also containing a version of the Android CACert keystore derived from Mozilla. If a certificate has recently become untrusted you can either install an update to this app or you can backup and remove certificates by yourself.</p><p>Requires root: Yes, it writes to the system partition. You will need a device that has the ‘grep’ command on it (via busybox: present on most custom ROMs). If the ‘save’ doesn’t work, then you will need to make your /system partition read-write by using a file explorer like <a href="fdroid.app:com.ghostsq.commander">Ghost Commander</a> or via a command in <a href="fdroid.app:jackpal.androidterm">Terminal Emulator</a>.</p> + GPLv3 + Security,GuardianProject + Security + https://guardianproject.info/2011/09/05/cacertman-app-to-address-diginotar-other-bad-cas + https://github.com/guardianproject/cacert + https://github.com/guardianproject/cacert/issues + + 999999999 + root + + 0.0.2.20111012 + 4 + CACertMan-0.0.2-alpha-20111011.apk + 251ebd40ce4a281a2292692707fb1e9c91428994cbad80a416a297db51069eb8 + 172263 + 7 + 7 + 2013-08-19 + a0eeebb161f946e3516945fae8a92a3e + + + 0.0.2-20110906 + 3 + CACertMan-0.0.2-20110906.apk + c217c49abe5134007ceb2623a6189a73fa02af9d2b2bbcc5cbc4cb5da7b36a5d + 170305 + 8 + 8 + 2013-10-31 + a0eeebb161f946e3516945fae8a92a3e + + + + org.witness.informacam.app + 2013-12-11 + 2015-11-03 + CameraV + An InformaCam app to generate verifiable media + org.witness.informacam.app.206.png + <p>An InformaCam app to generate verifiable media.</p> + GPLv3 + Development,GuardianProject + Development + https://guardianproject.info/apps/camerav/ + https://github.com/guardianproject/CameraV + https://dev.guardianproject.info/projects/informacam/issues + 1Fi5xUHiAPRKxHvyUGVFGt9extBe8Srdbk + + 9999999 + + 0.2.6 + 206 + CameraVApp-release-0.2.6.apk + 508f453e26c8c83dba858b53b21d909d549fe5646d01eb198c96c22d8e521e7c + 24123646 + 16 + 21 + 2015-11-03 + d70ac6a02b53ebdd1354ea7af7b9ceee + CAMERA,READ_EXTERNAL_STORAGE,BLUETOOTH_ADMIN,USE_CREDENTIALS,RECORD_AUDIO,VIBRATE,WRITE_EXTERNAL_STORAGE,CHANGE_WIFI_STATE,ACCESS_WIFI_STATE,ACCESS_FINE_LOCATION,GET_ACCOUNTS,INTERNET,ACCESS_COARSE_LOCATION,READ_PHONE_STATE,KILL_BACKGROUND_PROCESSES,GET_TOP_ACTIVITY_INFO,ACCESS_NETWORK_STATE,BLUETOOTH,WAKE_LOCK + armeabi,armeabi-v7a,x86 + + + 0.2.4 + 204 + CameraV-release-0.2.4.apk + a10eefaed5a12c353525b07e655f6959fe1eb06cd5c549be56afaca6db0c6ce0 + 24062229 + 16 + 21 + 2015-10-02 + d70ac6a02b53ebdd1354ea7af7b9ceee + CAMERA,READ_EXTERNAL_STORAGE,BLUETOOTH_ADMIN,USE_CREDENTIALS,RECORD_AUDIO,VIBRATE,WRITE_EXTERNAL_STORAGE,CHANGE_WIFI_STATE,ACCESS_WIFI_STATE,ACCESS_FINE_LOCATION,GET_ACCOUNTS,INTERNET,ACCESS_COARSE_LOCATION,READ_PHONE_STATE,KILL_BACKGROUND_PROCESSES,ACCESS_NETWORK_STATE,GET_TASKS,BLUETOOTH,WAKE_LOCK + armeabi,armeabi-v7a,x86 + + + 0.2.2 + 202 + CameraVApp-release-0.2.2.apk + 8b17cbe2a5cb777b49f5ef67a390f9d9d68765c90213ac64f3ca0456860dc9b7 + 24283932 + 16 + 21 + 2015-09-16 + d70ac6a02b53ebdd1354ea7af7b9ceee + CAMERA,READ_EXTERNAL_STORAGE,BLUETOOTH_ADMIN,USE_CREDENTIALS,RECORD_AUDIO,VIBRATE,WRITE_EXTERNAL_STORAGE,CHANGE_WIFI_STATE,ACCESS_WIFI_STATE,ACCESS_FINE_LOCATION,GET_ACCOUNTS,INTERNET,ACCESS_COARSE_LOCATION,READ_PHONE_STATE,KILL_BACKGROUND_PROCESSES,ACCESS_NETWORK_STATE,GET_TASKS,BLUETOOTH,WAKE_LOCK + armeabi,armeabi-v7a,x86 + + + + info.guardianproject.otr.app.im + 2013-03-19 + 2016-12-06 + ChatSecure + Instant Messaging client with OTR + info.guardianproject.otr.app.im.1423001.png + <p>XMPP (Jabber) client that can do end-to-end encryption using the <a href="http://en.wikipedia.org/wiki/Off-the-Record_Messaging">Off-the-Record Messaging</a> protocol and can anonymize your chats via the <a href="fdroid.app:org.torproject.android">Orbot</a> app (root not required).</p><p>The app used to be called GibberBot.</p> + Apache2 + Internet,GuardianProject + Internet + https://dev.guardianproject.info/projects/gibberbot + https://github.com/guardianproject/Gibberbot + https://dev.guardianproject.info/projects/gibberbot + + 999999999 + + 14.2.3 + 1423001 + ChatSecure-v14.2.3a.apk + 36d7d71c8a2115bdd2bd63bb639af286ee3242cce11cdb5c53378d1a7f35528e + 10502397 + 9 + 21 + 2016-02-03 + a0eeebb161f946e3516945fae8a92a3e + info.guardianproject.otr.app.providers.imps.permission.READ_ONLY,RECEIVE_BOOT_COMPLETED,CHANGE_WIFI_MULTICAST_STATE,READ_EXTERNAL_STORAGE,com.google.android.googleapps.permission.GOOGLE_AUTH,USE_CREDENTIALS,VIBRATE,WRITE_EXTERNAL_STORAGE,GET_ACCOUNTS,ACCESS_WIFI_STATE,UPDATE_APP_OPS_STATS,INTERNET,info.guardianproject.otr.app.im.permission.IM_SERVICE,ACCESS_NETWORK_STATE,MANAGE_ACCOUNTS,info.guardianproject.otr.app.providers.imps.permission.WRITE_ONLY,WAKE_LOCK + armeabi,x86 + + + 14.2.2 + 1422001 + ChatSecure-v14.2.2.apk + 9d4620fec0c7837ddffccde7918d7a7db0976fbcd361b96659abd93b5cc0d9e3 + 10502135 + 9 + 21 + 2016-12-06 + a0eeebb161f946e3516945fae8a92a3e + info.guardianproject.otr.app.providers.imps.permission.READ_ONLY,RECEIVE_BOOT_COMPLETED,CHANGE_WIFI_MULTICAST_STATE,READ_EXTERNAL_STORAGE,com.google.android.googleapps.permission.GOOGLE_AUTH,USE_CREDENTIALS,VIBRATE,WRITE_EXTERNAL_STORAGE,GET_ACCOUNTS,ACCESS_WIFI_STATE,UPDATE_APP_OPS_STATS,INTERNET,info.guardianproject.otr.app.im.permission.IM_SERVICE,ACCESS_NETWORK_STATE,MANAGE_ACCOUNTS,info.guardianproject.otr.app.providers.imps.permission.WRITE_ONLY,WAKE_LOCK + armeabi,x86 + + + 14.2.1 + 1421001 + ChatSecure-v14.2.1.apk + f82a3a7a823f5540b335743eb1399d0fd1f61bc68958750b5ef6aa0d95ad9a54 + 10463010 + 9 + 21 + 2015-08-24 + a0eeebb161f946e3516945fae8a92a3e + info.guardianproject.otr.app.providers.imps.permission.READ_ONLY,RECEIVE_BOOT_COMPLETED,CHANGE_WIFI_MULTICAST_STATE,READ_EXTERNAL_STORAGE,com.google.android.googleapps.permission.GOOGLE_AUTH,USE_CREDENTIALS,VIBRATE,WRITE_EXTERNAL_STORAGE,GET_ACCOUNTS,ACCESS_WIFI_STATE,UPDATE_APP_OPS_STATS,INTERNET,info.guardianproject.otr.app.im.permission.IM_SERVICE,ACCESS_NETWORK_STATE,MANAGE_ACCOUNTS,info.guardianproject.otr.app.providers.imps.permission.WRITE_ONLY,WAKE_LOCK + armeabi,x86 + + + + info.guardianproject.soundrecorder + 2013-12-13 + 2013-12-13 + ChatSecureVoicePlugin + ChatSecure Voice Messaging + info.guardianproject.soundrecorder.2.png + <p>This is a plugin for <a href="fdroid.app:info.guardianproject.otr.app.im">ChatSecure</a>. It does not have any function on its own. For Your Ears Only... completely private, end-to-end encryption voice message recording, sending, receiving and playback.</p><p> * For use with <a href="fdroid.app:info.guardianproject.otr.app.im">ChatSecure</a>'s encrypted "Off-the-record" data stream * Works over Tor - the ONLY Onion-routed voice messaging system, for total anonymity</p> + SIL Open Font License, MIT License and the CC 3.0 License [CC-By with attribution requirement waived] + Multimedia,Security,GuardianProject + Multimedia + https://guardianproject.info/apps/chatsecure + https://github.com/guardianproject/ChatSecureVoicePlugin + https://dev.guardianproject.info/projects/chatsecure/issues + + 999999999 + + 0.2 + 2 + ChatSecureVoiceMessaging-0.2.apk + abae18cc9cfa62fca5dce072c4c50d41b4fece506967ce9a3e2711cd1031dbee + 394212 + 10 + 10 + 2013-12-13 + a0eeebb161f946e3516945fae8a92a3e + READ_PHONE_STATE,READ_EXTERNAL_STORAGE,RECORD_AUDIO,WRITE_EXTERNAL_STORAGE,WAKE_LOCK + + + + info.guardianproject.checkey + 2014-07-12 + 2015-03-09 + Checkey + Info on local apps + info.guardianproject.checkey.102.png + <p>Checkey is a utility for getting information about the APKs that are installed on your device. Starting with a list of all of the apps that you have installed on your device, it will show you the APK signature with a single touch, and provides links to virustotal.com and androidobservatory.org to easily access the profiles of that APK. It will also let you export the signing certificate and generate ApkSignaturePin pin files for use with the TrustedIntents library.</p> + GPLv3 + Development,GuardianProject + Development + https://dev.guardianproject.info/projects/checkey + https://github.com/guardianproject/checkey + https://dev.guardianproject.info/projects/checkey/issues + 1Fi5xUHiAPRKxHvyUGVFGt9extBe8Srdbk + + 9999999 + + 0.1.2 + 102 + Checkey-0.1.2.apk + 754701dbac52de5ca3930c2393970c03ef9aa07d1456911e9bf254d6014e0645 + 842881 + 8 + 21 + 2015-03-09 + d70ac6a02b53ebdd1354ea7af7b9ceee + INTERNET + + + 0.1.1 + 101 + Checkey-0.1.1.apk + 2d81f339bb69626af42e8868dc6928c9072ebcbae76e1ff5ac8172e78ebe9cdd + 967083 + 8 + 21 + 2015-01-28 + d70ac6a02b53ebdd1354ea7af7b9ceee + INTERNET + + + 0.1 + 1 + Checkey-0.1.apk + a8e3c102d5279a3029d0eebdeda2ffdbe1f8a3493ea7dbdc31a11affc708ee57 + 878679 + 8 + 19 + 2014-07-12 + d70ac6a02b53ebdd1354ea7af7b9ceee + INTERNET + + + + info.guardianproject.courier + 2014-05-14 + 2014-06-26 + Courier + Privacy-aware RSS feed reader + info.guardianproject.courier.15.png + <p>No description available</p> + GPLv3 + Reading,GuardianProject + Reading + + https://github.com/guardianproject/securereader + + + 999999999 + + 0.1.9 + 15 + Courier-0.1.9.apk + bf6566da1f90831887f5bf5605f8d816b1f7f694969459dec599b8bc01a827d3 + 16484753 + 9 + 15 + 2014-06-26 + d70ac6a02b53ebdd1354ea7af7b9ceee + READ_EXTERNAL_STORAGE,BLUETOOTH_ADMIN,VIBRATE,ACCESS_WIFI_STATE,INTERNET,ACCESS_NETWORK_STATE,BLUETOOTH,WRITE_EXTERNAL_STORAGE + armeabi,x86 + + + 0.1.8 + 14 + Courier-0.1.8.apk + e013db095e8da843fae5ac44be6152e51377ee717e5c8a7b6d913d7720566b5a + 16536125 + 9 + 15 + 2014-05-14 + d70ac6a02b53ebdd1354ea7af7b9ceee + READ_EXTERNAL_STORAGE,BLUETOOTH_ADMIN,VIBRATE,ACCESS_WIFI_STATE,INTERNET,ACCESS_NETWORK_STATE,BLUETOOTH,WRITE_EXTERNAL_STORAGE + armeabi,x86 + + + + info.guardianproject.lildebi + 2013-02-06 + 2015-01-26 + Lil' Debi + Run Debian on your phone + info.guardianproject.lildebi.5400.png + <p>Lil' Debi builds up a whole Debian chroot on your phone entirely using debootstrap. You choose the release, mirror, and size of the disk image, and away it goes. It could take up to an hour on a slow device.</p><p>Then it has a simple chroot manager that fscks your disk, mounts/unmounts things, starts/stops sshd if you have it installed, etc. You can also then use ‘apt-get’ to install any package that is released for ARM processors. This includes things like a complete real shell, Tor, TraceRouteTCP, iwconfig/ipconfig, and other security and crypto tools. Works well with <a href="fdroid.app:jackpal.androidterm">Terminal Emulator</a>—just run `/debian/shell` to get a Debian shell.</p><p>The aim of Lil’ Debi is to provide a transparent and tightly integrated Debian install on your Android device. It mounts all of your Android partitions in Debian space, so you see a fusion of both systems. It's even possible to have Lil’ Debi launch the normal Debian init start-up scripts when it starts, so that all you need to do is apt-get install and any servers you install will just work.</p><p>Lil' Debi works with as few modifications to the Android system as possible. Currently, it only adds a /bin symlink, and a /debian mount directory. It does not touch /system at all.</p><p>Requires root: Yes, because it needs to run debootstrap, create dirs in /, mount/umount, etc.</p> + GPLv3 + Development,GuardianProject + Development + https://github.com/guardianproject/lildebi/wiki + https://github.com/guardianproject/lildebi + https://github.com/guardianproject/lildebi/issues + 1Fi5xUHiAPRKxHvyUGVFGt9extBe8Srdbk + + 999999999 + root + + 0.5.4 + 5400 + LilDebi-0.5.4-release.apk + 2c490376d8853fae04e79541f5d61e66a42ed0e890208945a11036c4a7b111da + 1876705 + 8 + 20 + 20 + 2015-01-26 + a0eeebb161f946e3516945fae8a92a3e + RECEIVE_BOOT_COMPLETED,READ_EXTERNAL_STORAGE,ACCESS_SUPERUSER,WRITE_EXTERNAL_STORAGE,INTERNET,ACCESS_NETWORK_STATE,jackpal.androidterm.permission.RUN_SCRIPT,WAKE_LOCK + + + 0.5.3 + 5300 + LilDebi-0.5.3-release.apk + 01c5a8e1fd778c141e70633d14f1b69228d6f492961098616e0446c116cf9e44 + 1879560 + 8 + 19 + 2015-01-26 + a0eeebb161f946e3516945fae8a92a3e + RECEIVE_BOOT_COMPLETED,READ_EXTERNAL_STORAGE,ACCESS_SUPERUSER,WRITE_EXTERNAL_STORAGE,INTERNET,ACCESS_NETWORK_STATE,jackpal.androidterm.permission.RUN_SCRIPT,WAKE_LOCK + + + 0.5.2 + 5200 + LilDebi-0.5.2-release.apk + 07fa3dfb690e44eb540942ba2a51718c72351c91a253a56a0c90649f6d8903dd + 1861790 + 8 + 19 + 2014-10-22 + a0eeebb161f946e3516945fae8a92a3e + RECEIVE_BOOT_COMPLETED,READ_EXTERNAL_STORAGE,ACCESS_SUPERUSER,WRITE_EXTERNAL_STORAGE,INTERNET,ACCESS_NETWORK_STATE,jackpal.androidterm.permission.RUN_SCRIPT,WAKE_LOCK + + + + info.guardianproject.locationprivacy + 2015-01-29 + 2016-12-06 + LocationPrivacy + privacy filters for when you are sharing your location + info.guardianproject.locationprivacy.30.png + <p>LocationPrivacy is not really app but rather a set of "Intent Filters" for all of the various ways of sharing location. When you share location from one app, LocationPrivacy offers itself as an option. It then recognizes insecure methods of sharing location, and then converts them to more secure methods. This mostly means that it rewrites URLs to use https, and even to use `geo:` URIs, which can work on fully offline setups. LocationPrivacy mostly works by reading the location information from the URL itself. For many URLs, LocationPrivacy must actually load some of the webpage in order to get the location.</p><p>LocationPrivacy can also serve as a way to redirect all location links to your favorite mapping app. All map apps in Android can view `geo:` URIs, and LocationPrivacy converts many kinds of links to `geo:` URIs, including: Google Maps, OpenStreetMap, Amap, Baidu Map, QQ Map, Nokia HERE, Yandex Maps.</p><p>This was started as part of the T2 Panic work, since sharing location is so often a part of panic apps. Follow our progress here: https://guardianproject.info/tag/panic</p><p>Don’t see your language? Join us and help translate the app: https://www.transifex.com/projects/p/locationprivacy</p> + GPLv3 + Navigation,Security,GuardianProject + Navigation + https://dev.guardianproject.info/projects/panic + https://github.com/guardianproject/LocationPrivacy + https://dev.guardianproject.info/projects/panic/issues + 1Fi5xUHiAPRKxHvyUGVFGt9extBe8Srdbk + + 9999999 + + 0.3 + 30 + LocationPrivacy-0.3.apk + ec2b2c6e3a99422fbe8229711dfc7b741961c2ba7bc171c745818d8b76fc4d63 + 1130602 + 9 + 22 + 2016-12-06 + d70ac6a02b53ebdd1354ea7af7b9ceee + INTERNET + + + 0.2 + 20 + LocationPrivacy-0.2.apk + 3cad63152ef9b04e1c2b880c286a80c65c083880612aaa36c0c4480b96adfea8 + 1129409 + 9 + 22 + 2016-12-06 + d70ac6a02b53ebdd1354ea7af7b9ceee + INTERNET + + + 0.1 + 10 + LocationPrivacy-0.1.apk + 130cfcc8b916682d974aa4e13385b47bdc23d07b0de852640563b880aeb61d1f + 818384 + 8 + 21 + 2015-01-29 + d70ac6a02b53ebdd1354ea7af7b9ceee + INTERNET + + + + info.guardianproject.notepadbot + 2013-01-16 + 2014-03-10 + NoteCipher + Notepad with lock + info.guardianproject.notepadbot.12.png + <p>Simple app for taking notes that encrypts everything behind a password.</p><p>Status: Beta.</p> + Apache2 + Office,GuardianProject + Office + https://guardianproject.info + https://github.com/guardianproject/notecipher + https://github.com/guardianproject/notecipher/issues + + 999999999 + + 0.1 + 12 + NoteCipher-beta-0.1.apk + b560a3d6364c32990ea7505f53b019f64fde597d67513f41a50e7d034af48caa + 7321123 + 10 + 19 + 2014-03-10 + a0eeebb161f946e3516945fae8a92a3e + VIBRATE,READ_EXTERNAL_STORAGE,WRITE_EXTERNAL_STORAGE + armeabi,x86 + + + 0.0.7.1 + 11 + NoteCipher-0.0.7.1.apk + da518f13206d2218234bfcc83205b7b2b81ec67a4cc448f818c617332235e700 + 3729342 + 11 + 17 + 2013-10-31 + a0eeebb161f946e3516945fae8a92a3e + READ_EXTERNAL_STORAGE,WRITE_EXTERNAL_STORAGE + armeabi + + + 0.0.7 + 10 + NoteCipher-0.0.7.apk + 8fa7536a87634c6b3441053c4f16315e4fd5aa6ef672a0026a594c107308d7bf + 3731119 + 7 + 17 + 2013-01-16 + a0eeebb161f946e3516945fae8a92a3e + READ_EXTERNAL_STORAGE,WRITE_EXTERNAL_STORAGE + armeabi + + + + org.witness.sscphase1 + 2013-08-19 + 2013-10-31 + ObscuraCam + A camera app that keeps certain information private + org.witness.sscphase1.34.png + <p>Ever capture someone in a photo or video, then realize they may not want to be in it? Not comfortable posting a friend, family member or child’s face on the internet? Worried about the geolocation data in the picture giving away private hideaway? Tired of Facebook, Google and other sites “auto detecting” faces in your photos? Then this is for you, giving you the power to better protect the identity of those captures in your photos, before you post them online.</p><p>Take a picture or load a photo or video from the Gallery, and ObscuraCam will automatically detect faces that you can pixelate, redact (blackout) or protect with funny nose and glasses. You can also invert pixelate, so that only the person you select is visible, and no one in the background can be recognized.</p><p>This app will also remove all identifying data stored in photos including GPS location data and phone make &amp; model. You can save the protected photo back to the Gallery, or share it directly to Facebook, Twitter or any other “Share” enabled app.</p> + GPLv3 + Multimedia,Security,GuardianProject + Multimedia + https://guardianproject.info/apps/obscuracam + https://github.com/guardianproject/obscuracam + https://github.com/guardianproject/obscuracam/issues + + 999999999 + + 2.0-RC2b + 34 + ObscuraCam-2.0-RC2b.apk + eeea54985c96769524ec82fb1d3599b193a2d20d1f57f3afc4c97b11bd48df8f + 8240221 + 10 + 11 + 2013-10-31 + a0eeebb161f946e3516945fae8a92a3e + READ_EXTERNAL_STORAGE,READ_MEDIA_STORAGE,WRITE_MEDIA_STORAGE,VIBRATE,WAKE_LOCK,WRITE_EXTERNAL_STORAGE + armeabi + + + 1.2-FINAL + 25 + ObscuraCam-1.2-FINAL.apk + fc4b1e26b09ab79b1ab174e8985b89985a0110f9d97d2b0472e529c85e3a1d89 + 1728825 + 8 + 8 + 2013-08-19 + a0eeebb161f946e3516945fae8a92a3e + VIBRATE,READ_EXTERNAL_STORAGE,WRITE_EXTERNAL_STORAGE + armeabi + + + + org.torproject.android + 2013-07-22 + 2016-12-06 + Orbot + Tor (anonymity) client + org.torproject.android.15208000.png + <p>Tor is both software and an open network that helps you defend against network surveillance that threatens personal freedom and privacy, confidential business activities and relationships.</p><p>Orbot allows access to Tor by accessing a local SOCKS or HTTP proxy. On a rooted device, the proxying can be completely transparent i.e. the app that accesses the network need not be aware of the proxy's existence; you can choose which apps go via the proxy in the settings.</p><p>If you don't have root access, there are some apps that are designed to work closely with tor or allow proxied connections: <a href="fdroid.app:info.guardianproject.otr.app.im">ChatSecure</a>, <a href="fdroid.app:info.guardianproject.browser">Orweb</a> and <a href="fdroid.app:org.mariotaku.twidere">Twidere</a>. There is also a proxy configurator addon for <a href="fdroid.app:org.mozilla.firefox">org.mozilla.firefox</a> called <a href="https://github.com/guardianproject/ProxyMob/downloads">ProxyMob</a> (not yet available from the Mozilla addon site).</p><p>Requires root: No, but you will need to use apps that allow proxies if root is not granted.</p> + NewBSD + Security,Internet,GuardianProject + Security + http://www.torproject.org/docs/android.html.en + https://gitweb.torproject.org/orbot.git + https://dev.guardianproject.info/projects/orbot/issues + https://www.torproject.org/donate/donate.html.en + 5649 + + 999999999 + + 15.2.0-RC-8-multi + 15208000 + Orbot-v15.2.0-RC-8-multi.apk + 3758e1b6e6b9a3b7848b253d08d6c0b1b1b3223184da4bd2ba1aaff8cf676357 + 12296544 + 16 + 23 + 2016-11-07 + 8bd7e51b479aeba908ff46ada3305a29 + ACCESS_NETWORK_STATE,ACCESS_SUPERUSER,INTERNET,RECEIVE_BOOT_COMPLETED + armeabi,x86 + + + 15.2.0-RC-7-multi + 15207000 + Orbot-v15.2.0-RC-7-multi.apk + 8dc3edf0a9799eb23b5e478e15547e38831b28cc3e88b049aa5f41b7b72e7bf9 + 12457510 + 16 + 23 + 2016-11-04 + 8bd7e51b479aeba908ff46ada3305a29 + ACCESS_NETWORK_STATE,ACCESS_SUPERUSER,INTERNET,RECEIVE_BOOT_COMPLETED + arm64-v8a,armeabi,armeabi-v7a,x86 + + + 15.2.0-RC-5 + 15205000 + Orbot-v15.2.0-RC-5-arm.apk + 51c7e2b6a6de542e0d44f82d89ddf1d3216ec7a28297381ef15b12da2f3246f7 + 7600548 + 16 + 23 + 2016-11-03 + 8bd7e51b479aeba908ff46ada3305a29 + ACCESS_NETWORK_STATE,ACCESS_SUPERUSER,INTERNET,RECEIVE_BOOT_COMPLETED + arm64-v8a,armeabi,armeabi-v7a + + + + info.guardianproject.orfox + 2016-09-24 + 2016-12-06 + Orfox + Orfox: Tor Browser for Android + info.guardianproject.orfox.4.png + <p>Orfox is the most privacy-enhancing web browser on Android, for visiting any website, even if it’s normally censored, monitored, or on the hidden web. It is a port of the desktop Tor Browser to the Android version of Firefox.</p><p>Orfox is a companion browser to <a href="fdroid.app:org.torproject.android">Orbot</a>, the port of Tor to Android. Orbot anonymizes internet traffic by routing it through many different stages and you must have that enabled first, though root isn't needed. Orfox disables certain other browser features that could be used to identify you.</p><p>Orfox replaces <a href="fdroid.app:info.guardianproject.browser">Orweb</a> as your private browser.</p> + MPL + Internet,Security,GuardianProject + Internet + + https://github.com/guardianproject/orfox + https://dev.guardianproject.info/projects/orfox/issues?set_filter=1 + + 999999999 + + Fennec-45.5.1esr/TorBrowser-6.5-1/Orfox-1.2.1 + 4 + Orfox-1.2.1-TorBrowser-6.5-Fennec45.5.1-build2.apk + d43032e79c7c31cabb194b8c1c4b14fbf73dd2cfda958ba415879ddf2f38ace2 + 35273126 + 9 + 22 + 2016-12-06 + d70ac6a02b53ebdd1354ea7af7b9ceee + info.guardianproject.orfox.permissions.FORMHISTORY_PROVIDER,info.guardianproject.orfox.permissions.PASSWORD_PROVIDER,READ_EXTERNAL_STORAGE,VIBRATE,WRITE_EXTERNAL_STORAGE,CHANGE_WIFI_STATE,ACCESS_WIFI_STATE,DOWNLOAD_WITHOUT_NOTIFICATION,INTERNET,com.android.launcher.permission.UNINSTALL_SHORTCUT,info.guardianproject.orfox.permissions.BROWSER_PROVIDER,com.android.browser.permission.READ_HISTORY_BOOKMARKS,com.android.launcher.permission.INSTALL_SHORTCUT,ACCESS_NETWORK_STATE,WAKE_LOCK + armeabi-v7a + + + Fennec-45.4.0esr/TorBrowser-6.5-1/Orfox-1.2 + 3 + Orfox-1.2-TorBrowser-6.5-Fennec45.4.0.apk + 9b5f6614b94a47ae561e8c974d42056ba6cb6da520766deda09aec3699aeff94 + 35242066 + 9 + 22 + 2016-09-24 + d70ac6a02b53ebdd1354ea7af7b9ceee + info.guardianproject.orfox.permissions.FORMHISTORY_PROVIDER,info.guardianproject.orfox.permissions.PASSWORD_PROVIDER,READ_EXTERNAL_STORAGE,VIBRATE,WRITE_EXTERNAL_STORAGE,CHANGE_WIFI_STATE,ACCESS_WIFI_STATE,DOWNLOAD_WITHOUT_NOTIFICATION,INTERNET,com.android.launcher.permission.UNINSTALL_SHORTCUT,info.guardianproject.orfox.permissions.BROWSER_PROVIDER,com.android.browser.permission.READ_HISTORY_BOOKMARKS,com.android.launcher.permission.INSTALL_SHORTCUT,ACCESS_NETWORK_STATE,WAKE_LOCK + armeabi-v7a + + + + info.guardianproject.browser + 2012-10-22 + 2015-11-26 + Orweb + Privacy-enhanced browser + info.guardianproject.browser.7010.png + <p>Orweb is a companion browser to <a href="fdroid.app:org.torproject.android">Orbot</a>, the port of Tor to Android.</p><p>Orbot anonymizes internet traffic by routing it through many different stages and you must have that enabled first, though root isn't needed. Orweb disables certain other browser features that could be used to identify you.</p> + GPL + Internet,Security,GuardianProject + Internet + https://guardianproject.info/apps/orweb + https://github.com/guardianproject/orweb + https://dev.guardianproject.info/projects/orweb/issues?set_filter=1 + + 999999999 + + 0.7.1 + 7010 + Orweb-0.7.1.apk + 949d65d6e8a1eadd0aa626bdc7c5a3e2b0dbe5a38dea1d725cce2a34ec84f0d4 + 2424394 + 9 + 21 + 2015-11-26 + a0eeebb161f946e3516945fae8a92a3e + READ_EXTERNAL_STORAGE,INTERNET,WRITE_EXTERNAL_STORAGE + + + 0.7 + 28 + Orweb-release-0.7.apk + 763541f43f5dc136744b4361fe67d36f25cc036526d6c3e934287d72d1b411ab + 1244875 + 9 + 18 + 2014-11-13 + a0eeebb161f946e3516945fae8a92a3e + READ_EXTERNAL_STORAGE,INTERNET,WRITE_EXTERNAL_STORAGE + + + 0.6.1 + 27 + Orweb-release-0.6.1.apk + 103f4a98fa282923c07e445b2a383e946b6c15e10ed08005af3d0743249a0359 + 931433 + 9 + 19 + 2014-06-30 + a0eeebb161f946e3516945fae8a92a3e + READ_EXTERNAL_STORAGE,INTERNET,WRITE_EXTERNAL_STORAGE + + + + info.guardianproject.pixelknot + 2013-02-26 + 2016-12-06 + PixelKnot + Hide messages inside files + info.guardianproject.pixelknot.100.png + <p>Image steganography app with old school F5 steganography</p> + GPLv3 + Office,GuardianProject + Office + https://guardianproject.info + https://github.com/guardianproject/PixelKnot + https://github.com/guardianproject/PixelKnot/issues + + 999999999 + + 1.0.0 + 100 + PixelKnot-release-1.0.0.apk + f97557cf7ec81ade50c308c5552dc6dc827d0e02ce90f84b1df6b7477d9f5a39 + 1983586 + 17 + 25 + 2016-12-06 + a0eeebb161f946e3516945fae8a92a3e + VIBRATE,READ_EXTERNAL_STORAGE,WRITE_EXTERNAL_STORAGE + + + arm64-v8a,armeabi,armeabi-v7a,mips,mips64,x86,x86_64 + + + 0.3.3 + 6 + PixelKnot-release-0.3.3.apk + 6beede8519a9e87ba8edaa5a76f203cfefd5f39eb911e789031cc6e911714b89 + 4751233 + 14 + 17 + 2015-06-26 + a0eeebb161f946e3516945fae8a92a3e + CAMERA,READ_EXTERNAL_STORAGE,WRITE_EXTERNAL_STORAGE + armeabi + + + 0.3.1 + 4 + PixelKnot-release-0.3.1.apk + a3101fe8a2d47ab205cb00459fa62c639a6fac4538f6cd9d06eb48d2965c4d21 + 3976822 + 9 + 17 + 2013-07-22 + a0eeebb161f946e3516945fae8a92a3e + CAMERA,READ_EXTERNAL_STORAGE,WRITE_EXTERNAL_STORAGE + armeabi + + + + info.guardianproject.ripple + 2016-12-06 + 2016-12-06 + Ripple + Trigger apps to protect your privacy when in anxious or panic situations + info.guardianproject.ripple.75.png + <p>Ripple is a "panic button" that can send it's trigger message to any app that is a "panic responder". Such apps can do things like lock, disguise themselves, delete private data, send an emergency message, and more. It is meant for situations where there is time to react, but where users need to be sure it is not mistakenly set off.</p><p>This is a BETA version of this app! We are working now to add support to as many other apps as possible. ChatSecure, Orweb, Umbrella, and Zom already have support, these apps are coming soon: Courier, PanicButton, OpenKeychain, Orfox, SMSSecure, and StoryMaker.</p><p>Here are two example scenarios:</p><ul><li> An organization gets regularly raided by the security forces, who search all of the computers and mobile devices on the premises. The organization usually has at least a minute or two of warning before a raid starts. They need a very reliable way to trigger wiping all of the data from the sensitive apps.</li></ul><ul><li> An aid worker has lots of sensitive data about people on their device. They regularly sync that data up with a secure, central database. Occasionally, the aid worker has to leave the country on very short notice. The border guards regularly download the entire contents of mobile devices of people crossing through. While waiting in line at the border, the aid worker sees the border guards seizing people's devices, and then remembers all the data on the device, so she unlocks her phone and hits the wipe trigger, which wipes all sensitive apps from the device. When the aid worker returns to the central office, the device is again synced up with the central database.</li></ul><p>This was started as part of the T2 Panic work, since sharing location is so often a part of panic apps. Follow our progress here:</p><p>★ <a href="https://guardianproject.info/tag/panic">https://guardianproject.info/tag/panic</a> ★ <a href="https://dev.guardianproject.info/projects/panic">https://dev.guardianproject.info/projects/panic</a> Don’t see your language? Join us and help translate the app: <a href="https://www.transifex.com/projects/p/rippleapp">https://www.transifex.com/projects/p/rippleapp</a></p><p>==Learn More==</p><p>★ ABOUT US: Guardian Project is a group of developers that make secure mobile apps and open-source code for a better tomorrow ★ OUR WEBSITE: <a href="https://GuardianProject.info">https://GuardianProject.info</a> ★ ON TWITTER: <a href="https://twitter.com/guardianproject">https://twitter.com/guardianproject</a> ★ FREE SOFTWARE: Ripple is free software. You can take a look at our source code, or contribute to help make Ripple even better: <a href="https://github.com/guardianproject/Ripple">https://github.com/guardianproject/Ripple</a> ★ MESSAGE US: Are we missing your favorite feature? Found an annoying bug? Please tell us! We’d love to hear from you. Send us an email: support@guardianproject.info or find us in our chat room <a href="https://guardianproject.info/contact">https://guardianproject.info/contact</a></p> + GPLv3 + Navigation,Security,GuardianProject + Navigation + https://dev.guardianproject.info/projects/panic + https://github.com/guardianproject/ripple + https://dev.guardianproject.info/projects/panic/issues + 1Fi5xUHiAPRKxHvyUGVFGt9extBe8Srdbk + + 9999999 + + 0.2 + 75 + Ripple-0.2-release.apk + 4b14b1b402f0197e1e6ffe2c11e052432fc8a52749f5f02d9cc67799658df239 + 1669315 + 10 + 23 + 2016-12-06 + d70ac6a02b53ebdd1354ea7af7b9ceee + + + 0.1 + 2 + Ripple-0.1.apk + 9fd24cbb3552123e6ee119f912f1646dd21cd7a683734a8d502d8b44854a284b + 1670285 + 10 + 23 + 2016-12-06 + d70ac6a02b53ebdd1354ea7af7b9ceee + + + 0.0 + 1 + Ripple-0.0.apk + 025894a5f3a39a288ee60bb6c9cc2c559d395f22fed020d1086308ba12df85a3 + 1664407 + 10 + 23 + 2016-12-06 + d70ac6a02b53ebdd1354ea7af7b9ceee + + + + info.guardianproject.chatsecure.emoji.core + 2013-12-02 + 2013-12-02 + StickerPack + ChatSecure Open Emoji Plugin + info.guardianproject.chatsecure.emoji.core.1.png + <p>Plugin for <a href="fdroid.app:info.guardianproject.otr.app.im">ChatSecure</a> to support for core emoji input and display. Based on "Phantom Open Emoji" project.</p> + SIL Open Font License, MIT License and the CC 3.0 License [CC-By with attribution requirement waived] + Multimedia,Security,GuardianProject + Multimedia + https://guardianproject.info/apps/chatsecure + https://github.com/guardianproject/ChatSecureVoicePlugin + https://dev.guardianproject.info/projects/chatsecure/issues + + 999999999 + + 1.0 + 1 + ChatSecurePluginOpenEmoji-release-v1.apk + 131c1ebaf795c3f053701285699f0b7e517de1c7fdba56e247b1ec31766b2808 + 1814271 + 8 + 17 + 2013-12-02 + a0eeebb161f946e3516945fae8a92a3e + + + diff --git a/app/src/test/resources/testy.at.or.at_index-v1.jar b/app/src/test/resources/testy.at.or.at_index-v1.jar new file mode 100644 index 0000000000000000000000000000000000000000..11cfd5f2cd13106fae484f1f69334d19399f4322 GIT binary patch literal 24734 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 literal 0 HcmV?d00001 diff --git a/app/src/test/resources/testy.at.or.at_no-.RSA_index-v1.jar b/app/src/test/resources/testy.at.or.at_no-.RSA_index-v1.jar new file mode 100644 index 0000000000000000000000000000000000000000..9b3267a73ee89642d1d8da46fd7b73ef97c73f3c GIT binary patch literal 22726 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 R!QXA%yWVm){xbf%`VWXF0+9d! literal 0 HcmV?d00001 diff --git a/app/src/test/resources/testy.at.or.at_no-.SF_index-v1.jar b/app/src/test/resources/testy.at.or.at_no-.SF_index-v1.jar new file mode 100644 index 0000000000000000000000000000000000000000..113a17e553895da12997d78cfdd8f90b2aa16516 GIT binary patch literal 24353 zcmV)?K!U$eO9KQH000OG0A{3!No*W@jDr9G0EYko02BZK08K?yK`lv6MlVf4PDw^Z zQ&cWZM)OU~%S=lxF3}B3Eh^5;&$Ci6)HC4aaxO|uEJ;n#b*cmjnCltD8yXoZX!sW; zCg-FoIOi7?qa`AHcCFZ7DDP-oQq*myb8R}&f=jZWq1$#Ie>bhj60}Z!Q zaPiakaWyVA^7ryFcL{d&it_LaH_oig3GsKe1(^T@6aWAS2mofJh)LnOPira% z0040Z000pH002!zR6#9CPDU?NPgX%LQd2>VlzBK5h#$ty!3<)Q2@$z#Fq&!HQf_h= zF^))%BIHVx72~Ket{GQ38Vq(a%0#mmve-yD*42mxv*V@^Q8|Bp&$G|&{(gV$pWpX+ z-}n2^`+QJ1NH8D2N{VI5Hz5E7Sb~FiqHqv5AOL_CMDg?SstSXElDs@9!M{y_5*&C2 zg#-VA!U5lwfPerX5NxU8u!R!(%L5QRh{&NuP{Mqm%V0rKh&j?U{8oT3TIitYKmmz@ zBW^``qh!Q|&{`;+GZ-`mgGOt)i3@3HpwKAYKhJ+tfdk6_^D%&z2ZRH{cn)*{APxxN znPd;huG8c%JRYC4qnxXFIG8&dwdLEix%%Ygo z4{*gFqe^*86^nYcK4h-%?RQSI9mhCgdeKqiW}n)|>CZO!3o3#nlNc)dy=MJkC$?`Y<18}k$6Wp2t%r9E!qNL19avhY@i zt-G!cIRlIW%h{LY0{2r-rCZ$Yog{=0dkub%08tlOS#hRHsFqWUUv%*%+R7n7|h}1?f6%6Q-CcgHS8if1=Ay6dvA1Bq98vDz7S~SHs-JCF z_tioOYo+X�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:)u) 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 literal 0 HcmV?d00001 diff --git a/app/src/test/resources/testy.at.or.at_no-MANIFEST.MF_index-v1.jar b/app/src/test/resources/testy.at.or.at_no-MANIFEST.MF_index-v1.jar new file mode 100644 index 0000000000000000000000000000000000000000..feee0f85da78c2745c6968ee41b9e7f09cb0fe9e GIT binary patch literal 24423 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 OTlwbHxG~QX5&Z|qQ!!uw literal 0 HcmV?d00001 From 9d97546c4f1d1f2789d627e699acb657708b7a11 Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Tue, 6 Dec 2016 11:04:22 +0100 Subject: [PATCH 03/19] support localized text and graphics in index-v1 metadata This sets the App instance variables using the localized index-v1 fields. It trys to fill as many fields as possible, falling back to locales of the same language, then finally English. This is based on the Jackson JSON parser's ability to map a JSON key to a method, e.g. @JsonProperty("localized") --- .../java/org/fdroid/fdroid/AppDetails2.java | 5 +- .../main/java/org/fdroid/fdroid/data/App.java | 237 ++++++++++++++++++ .../java/org/fdroid/fdroid/data/DBHelper.java | 51 +++- .../java/org/fdroid/fdroid/data/Schema.java | 12 + .../views/ScreenShotsRecyclerViewAdapter.java | 11 +- 5 files changed, 308 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/fdroid/fdroid/AppDetails2.java b/app/src/main/java/org/fdroid/fdroid/AppDetails2.java index 1ce4ef10c..701bd932e 100644 --- a/app/src/main/java/org/fdroid/fdroid/AppDetails2.java +++ b/app/src/main/java/org/fdroid/fdroid/AppDetails2.java @@ -136,10 +136,11 @@ public class AppDetails2 extends AppCompatActivity implements ShareChooserDialog recyclerView.setAdapter(adapter); // Load the feature graphic, if present - if (!TextUtils.isEmpty(app.iconUrl)) { + String featureGraphicUrl = app.getFeatureGraphicUrl(this); + if (!TextUtils.isEmpty(featureGraphicUrl)) { final FeatureImage featureImage = (FeatureImage) findViewById(R.id.feature_graphic); DisplayImageOptions displayImageOptions = Utils.getImageLoadingOptions().build(); - ImageLoader.getInstance().loadImage(app.iconUrl, displayImageOptions, new ImageLoadingListener() { + ImageLoader.getInstance().loadImage(featureGraphicUrl, displayImageOptions, new ImageLoadingListener() { @Override public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) { if (featureImage != null) { diff --git a/app/src/main/java/org/fdroid/fdroid/data/App.java b/app/src/main/java/org/fdroid/fdroid/data/App.java index 65f4033b9..cedeaf062 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/App.java +++ b/app/src/main/java/org/fdroid/fdroid/data/App.java @@ -17,6 +17,7 @@ import android.text.TextUtils; import android.util.Log; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; import org.apache.commons.io.filefilter.RegexFileFilter; import org.fdroid.fdroid.AppFilter; @@ -32,10 +33,16 @@ 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.HashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; import java.util.jar.JarEntry; import java.util.jar.JarFile; import java.util.regex.Matcher; @@ -73,6 +80,7 @@ public class App extends ValueObject implements Comparable, Parcelable { * 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. */ public long repoId; public String summary = "Unknown application"; @@ -80,6 +88,18 @@ public class App extends ValueObject implements Comparable, Parcelable { public String description; + public String video; + + 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 = "Unknown"; public String authorName; @@ -263,6 +283,30 @@ public class App extends ValueObject implements Comparable, Parcelable { case Cols.ICON_URL_LARGE: iconUrlLarge = 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.InstalledApp.VERSION_CODE: installedVersionCode = cursor.getInt(i); break; @@ -293,6 +337,175 @@ public class App extends ValueObject implements Comparable, Parcelable { initApkFromApkFile(context, this.installedApk, packageInfo, apkFile); } + /** + * 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. The first English locale found is loaded, then + * {@code en-US} is loaded over that, since that's the most common English + * for software. Then the first language match, and then finally the + * current locale for this device, given it precedence over all the others. + *

    + * It is still possible that the fields will be loaded directly without any + * locale info. This comes from the old-style {@code .txt} app metadata + * fields that do not have locale info. They should not be used if the + * {@code Localized} block is specified. + */ + @JsonProperty("localized") + private void setLocalized(Map> localized) { // NOPMD + Locale defaultLocale = Locale.getDefault(); + String languageTag = defaultLocale.getLanguage(); + String localeTag = languageTag + "-" + defaultLocale.getCountry(); + Set locales = localized.keySet(); + Set localesToUse = new TreeSet<>(); + + if (locales.contains(localeTag)) { + localesToUse.add(localeTag); + } + for (String l : locales) { + if (l.startsWith(languageTag)) { + localesToUse.add(l); + break; + } + } + if (locales.contains("en-US")) { + localesToUse.add("en-US"); + } + for (String l : locales) { + if (l.startsWith("en")) { + localesToUse.add(l); + break; + } + } + // if key starts with Upper case, its set by humans + // Name, Summary, Description existed before localization so their values can be set directly + video = getLocalizedEntry(localized, localesToUse, "Video"); + String value = getLocalizedEntry(localized, localesToUse, "Name"); + if (!TextUtils.isEmpty(value)) { + name = value; + } + value = getLocalizedEntry(localized, localesToUse, "Summary"); + if (!TextUtils.isEmpty(value)) { + summary = value; + } + description = getLocalizedEntry(localized, localesToUse, "Description"); + if (!TextUtils.isEmpty(value)) { + description = value; + } + + // if key starts with lower case, its generated based on finding the files + featureGraphic = getLocalizedGraphicsEntry(localized, localesToUse, "featureGraphic"); + promoGraphic = getLocalizedGraphicsEntry(localized, localesToUse, "promoGraphic"); + tvBanner = getLocalizedGraphicsEntry(localized, localesToUse, "tvBanner"); + + wearScreenshots = setLocalizedListEntry(localized, localesToUse, "wearScreenshots"); + phoneScreenshots = setLocalizedListEntry(localized, localesToUse, "phoneScreenshots"); + sevenInchScreenshots = setLocalizedListEntry(localized, localesToUse, "sevenInchScreenshots"); + tenInchScreenshots = setLocalizedListEntry(localized, localesToUse, "tenInchScreenshots"); + tvScreenshots = setLocalizedListEntry(localized, localesToUse, "tvScreenshots"); + } + + private String getLocalizedEntry(Map> localized, + Set locales, String key) { + try { + for (String locale : locales) { + if (localized.containsKey(locale)) { + return (String) localized.get(locale).get(key); + } + } + } catch (ClassCastException e) { + Utils.debugLog(TAG, e.getMessage()); + } + return null; + } + + private String getLocalizedGraphicsEntry(Map> localized, + Set locales, String key) { + try { + for (String locale : locales) { + if (localized.containsKey(locale)) { + return locale + "/" + localized.get(locale).get(key); + } + } + } catch (ClassCastException e) { + Utils.debugLog(TAG, e.getMessage()); + } + return null; + } + + private String[] setLocalizedListEntry(Map> localized, + Set locales, String key) { + try { + for (String locale : locales) { + if (localized.containsKey(locale)) { + ArrayList entry = (ArrayList) localized.get(locale).get(key); + 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; + } + } + } + } catch (ClassCastException e) { + Utils.debugLog(TAG, e.getMessage()); + } + return new String[0]; + } + + public String getFeatureGraphicUrl(Context context) { + if (TextUtils.isEmpty(featureGraphic)) { + return null; + } + Repo repo = RepoProvider.Helper.findById(context, repoId); + return repo.address + "/" + packageName + "/" + featureGraphic; + } + + public String getPromoGraphic(Context context) { + if (TextUtils.isEmpty(promoGraphic)) { + return null; + } + Repo repo = RepoProvider.Helper.findById(context, repoId); + return repo.address + "/" + packageName + "/" + promoGraphic; + } + + public String getTvBanner(Context context) { + if (TextUtils.isEmpty(tvBanner)) { + return null; + } + Repo repo = RepoProvider.Helper.findById(context, repoId); + return repo.address + "/" + packageName + "/" + tvBanner; + } + + public String[] getAllScreenshots(Context context) { + Repo repo = RepoProvider.Helper.findById(context, repoId); + ArrayList 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.address + "/" + 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}. @@ -525,6 +738,14 @@ public class App extends ValueObject implements Comparable, Parcelable { 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); return values; @@ -674,6 +895,14 @@ public class App extends ValueObject implements Comparable, Parcelable { dest.writeStringArray(this.requirements); dest.writeString(this.iconUrl); dest.writeString(this.iconUrlLarge); + 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.writeString(this.installedVersionName); dest.writeInt(this.installedVersionCode); dest.writeParcelable(this.installedApk, flags); @@ -713,6 +942,14 @@ public class App extends ValueObject implements Comparable, Parcelable { this.requirements = in.createStringArray(); this.iconUrl = in.readString(); this.iconUrlLarge = 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.installedVersionName = in.readString(); this.installedVersionCode = in.readInt(); this.installedApk = in.readParcelable(Apk.class.getClassLoader()); diff --git a/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java b/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java index 223a2197b..079972d5b 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java +++ b/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java @@ -140,6 +140,14 @@ class DBHelper extends SQLiteOpenHelper { + AppMetadataTable.Cols.IS_COMPATIBLE + " int not null," + AppMetadataTable.Cols.ICON_URL + " text, " + AppMetadataTable.Cols.ICON_URL_LARGE + " text, " + + AppMetadataTable.Cols.FEATURE_GRAPHIC + " string," + + AppMetadataTable.Cols.PROMO_GRAPHIC + " string," + + AppMetadataTable.Cols.TV_BANNER + " string," + + AppMetadataTable.Cols.PHONE_SCREENSHOTS + " string," + + AppMetadataTable.Cols.SEVEN_INCH_SCREENSHOTS + " string," + + AppMetadataTable.Cols.TEN_INCH_SCREENSHOTS + " string," + + AppMetadataTable.Cols.TV_SCREENSHOTS + " string," + + AppMetadataTable.Cols.WEAR_SCREENSHOTS + " string," + "primary key(" + AppMetadataTable.Cols.PACKAGE_ID + ", " + AppMetadataTable.Cols.REPO_ID + "));"; private static final String CREATE_TABLE_APP_PREFS = "CREATE TABLE " + AppPrefsTable.NAME @@ -182,7 +190,7 @@ class DBHelper extends SQLiteOpenHelper { + InstalledAppTable.Cols.HASH + " TEXT NOT NULL" + " );"; - protected static final int DB_VERSION = 66; + protected static final int DB_VERSION = 67; private final Context context; @@ -263,6 +271,47 @@ class DBHelper extends SQLiteOpenHelper { addObbFiles(db, oldVersion); addCategoryTables(db, oldVersion); addIndexV1Fields(db, oldVersion); + addIndexV1AppFields(db, oldVersion); + } + + private void addIndexV1AppFields(SQLiteDatabase db, int oldVersion) { + if (oldVersion >= 67) { + return; + } + // Strings + if (!columnExists(db, AppMetadataTable.NAME, AppMetadataTable.Cols.FEATURE_GRAPHIC)) { + Utils.debugLog(TAG, "Adding " + AppMetadataTable.Cols.FEATURE_GRAPHIC + " field to " + AppMetadataTable.NAME + " table in db."); + db.execSQL("alter table " + AppMetadataTable.NAME + " add column " + AppMetadataTable.Cols.FEATURE_GRAPHIC + " string;"); + } + if (!columnExists(db, AppMetadataTable.NAME, AppMetadataTable.Cols.PROMO_GRAPHIC)) { + Utils.debugLog(TAG, "Adding " + AppMetadataTable.Cols.PROMO_GRAPHIC + " field to " + AppMetadataTable.NAME + " table in db."); + db.execSQL("alter table " + AppMetadataTable.NAME + " add column " + AppMetadataTable.Cols.PROMO_GRAPHIC + " string;"); + } + if (!columnExists(db, AppMetadataTable.NAME, AppMetadataTable.Cols.TV_BANNER)) { + Utils.debugLog(TAG, "Adding " + AppMetadataTable.Cols.TV_BANNER + " field to " + AppMetadataTable.NAME + " table in db."); + db.execSQL("alter table " + AppMetadataTable.NAME + " add column " + AppMetadataTable.Cols.TV_BANNER + " string;"); + } + // String Arrays + if (!columnExists(db, AppMetadataTable.NAME, AppMetadataTable.Cols.PHONE_SCREENSHOTS)) { + Utils.debugLog(TAG, "Adding " + AppMetadataTable.Cols.PHONE_SCREENSHOTS + " field to " + AppMetadataTable.NAME + " table in db."); + db.execSQL("alter table " + AppMetadataTable.NAME + " add column " + AppMetadataTable.Cols.PHONE_SCREENSHOTS + " string;"); + } + if (!columnExists(db, AppMetadataTable.NAME, AppMetadataTable.Cols.SEVEN_INCH_SCREENSHOTS)) { + Utils.debugLog(TAG, "Adding " + AppMetadataTable.Cols.SEVEN_INCH_SCREENSHOTS + " field to " + AppMetadataTable.NAME + " table in db."); + db.execSQL("alter table " + AppMetadataTable.NAME + " add column " + AppMetadataTable.Cols.SEVEN_INCH_SCREENSHOTS + " string;"); + } + if (!columnExists(db, AppMetadataTable.NAME, AppMetadataTable.Cols.TEN_INCH_SCREENSHOTS)) { + Utils.debugLog(TAG, "Adding " + AppMetadataTable.Cols.TEN_INCH_SCREENSHOTS + " field to " + AppMetadataTable.NAME + " table in db."); + db.execSQL("alter table " + AppMetadataTable.NAME + " add column " + AppMetadataTable.Cols.TEN_INCH_SCREENSHOTS + " string;"); + } + if (!columnExists(db, AppMetadataTable.NAME, AppMetadataTable.Cols.TV_SCREENSHOTS)) { + Utils.debugLog(TAG, "Adding " + AppMetadataTable.Cols.TV_SCREENSHOTS + " field to " + AppMetadataTable.NAME + " table in db."); + db.execSQL("alter table " + AppMetadataTable.NAME + " add column " + AppMetadataTable.Cols.TV_SCREENSHOTS + " string;"); + } + if (!columnExists(db, AppMetadataTable.NAME, AppMetadataTable.Cols.WEAR_SCREENSHOTS)) { + Utils.debugLog(TAG, "Adding " + AppMetadataTable.Cols.WEAR_SCREENSHOTS + " field to " + AppMetadataTable.NAME + " table in db."); + db.execSQL("alter table " + AppMetadataTable.NAME + " add column " + AppMetadataTable.Cols.WEAR_SCREENSHOTS + " string;"); + } } private void addIndexV1Fields(SQLiteDatabase db, int oldVersion) { diff --git a/app/src/main/java/org/fdroid/fdroid/data/Schema.java b/app/src/main/java/org/fdroid/fdroid/data/Schema.java index 6c0491fba..73d26baec 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/Schema.java +++ b/app/src/main/java/org/fdroid/fdroid/data/Schema.java @@ -143,6 +143,14 @@ public interface Schema { String REQUIREMENTS = "requirements"; String ICON_URL = "iconUrl"; String ICON_URL_LARGE = "iconUrlLarge"; + String FEATURE_GRAPHIC = "featureGraphic"; + String PROMO_GRAPHIC = "promoGraphic"; + String TV_BANNER = "tvBanner"; + String PHONE_SCREENSHOTS = "phoneScreenshots"; + String SEVEN_INCH_SCREENSHOTS = "sevenInchScreenshots"; + String TEN_INCH_SCREENSHOTS = "tenInchScreenshots"; + String TV_SCREENSHOTS = "tvScreenshots"; + String WEAR_SCREENSHOTS = "wearScreenshots"; interface SuggestedApk { String VERSION_NAME = "suggestedApkVersion"; @@ -180,6 +188,8 @@ public interface Schema { CHANGELOG, DONATE, BITCOIN, LITECOIN, FLATTR_ID, UPSTREAM_VERSION_NAME, UPSTREAM_VERSION_CODE, ADDED, LAST_UPDATED, ANTI_FEATURES, REQUIREMENTS, ICON_URL, ICON_URL_LARGE, + FEATURE_GRAPHIC, PROMO_GRAPHIC, TV_BANNER, PHONE_SCREENSHOTS, + SEVEN_INCH_SCREENSHOTS, TEN_INCH_SCREENSHOTS, TV_SCREENSHOTS, WEAR_SCREENSHOTS, SUGGESTED_VERSION_CODE, }; @@ -194,6 +204,8 @@ public interface Schema { CHANGELOG, DONATE, BITCOIN, LITECOIN, FLATTR_ID, UPSTREAM_VERSION_NAME, UPSTREAM_VERSION_CODE, ADDED, LAST_UPDATED, ANTI_FEATURES, REQUIREMENTS, ICON_URL, ICON_URL_LARGE, + FEATURE_GRAPHIC, PROMO_GRAPHIC, TV_BANNER, PHONE_SCREENSHOTS, + SEVEN_INCH_SCREENSHOTS, TEN_INCH_SCREENSHOTS, TV_SCREENSHOTS, WEAR_SCREENSHOTS, SUGGESTED_VERSION_CODE, SuggestedApk.VERSION_NAME, InstalledApp.VERSION_CODE, InstalledApp.VERSION_NAME, InstalledApp.SIGNATURE, Package.PACKAGE_NAME, diff --git a/app/src/main/java/org/fdroid/fdroid/views/ScreenShotsRecyclerViewAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/ScreenShotsRecyclerViewAdapter.java index 08b2ac923..82034b461 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/ScreenShotsRecyclerViewAdapter.java +++ b/app/src/main/java/org/fdroid/fdroid/views/ScreenShotsRecyclerViewAdapter.java @@ -12,20 +12,21 @@ import android.widget.ImageView; import com.nostra13.universalimageloader.core.DisplayImageOptions; import com.nostra13.universalimageloader.core.ImageLoader; import com.nostra13.universalimageloader.core.assist.ImageScaleType; -import com.nostra13.universalimageloader.core.download.ImageDownloader; import org.fdroid.fdroid.R; import org.fdroid.fdroid.data.App; public class ScreenShotsRecyclerViewAdapter extends RecyclerView.Adapter implements LinearLayoutManagerSnapHelper.LinearSnapHelperListener { + private final String[] screenshots; private final DisplayImageOptions displayImageOptions; private View selectedView; private int selectedPosition; private final int selectedItemElevation; private final int unselectedItemMargin; - public ScreenShotsRecyclerViewAdapter(Context context, @SuppressWarnings("unused") App app) { + public ScreenShotsRecyclerViewAdapter(Context context, App app) { super(); + screenshots = app.getAllScreenshots(context); selectedPosition = 0; selectedItemElevation = context.getResources().getDimensionPixelSize(R.dimen.details_screenshot_selected_elevation); unselectedItemMargin = context.getResources().getDimensionPixelSize(R.dimen.details_screenshot_margin); @@ -46,8 +47,8 @@ public class ScreenShotsRecyclerViewAdapter extends RecyclerView.Adapter Date: Tue, 6 Dec 2016 11:29:47 +0100 Subject: [PATCH 04/19] ignore unknown fields in index-v1 JSON data Having Jackson set to ignore unknown fields in the incoming JSON data, instead of throwing an Exception, means that we can add any fields to the JSON without having to rev the index version, and older clients will still parse it fine. This is basically the same as in index.xml. --- app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java b/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java index 9b8001d0b..97b82c773 100644 --- a/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java +++ b/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java @@ -12,6 +12,7 @@ import android.util.Log; import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.commons.io.FileUtils; @@ -131,6 +132,7 @@ public class IndexV1Updater extends RepoUpdater { public void processIndexV1(InputStream indexInputStream, JarEntry indexEntry, String cacheTag) throws IOException, UpdateException { ObjectMapper mapper = new ObjectMapper(); + mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); JsonFactory f = mapper.getFactory(); JsonParser parser = f.createParser(indexInputStream); HashMap repoMap = null; From 40643855c4ab31e96c1d58f2265f33ae36cd7cde Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Wed, 22 Mar 2017 16:14:43 +0100 Subject: [PATCH 05/19] do not let Jackson set instance vars that never come from index Tell the Jackson JSON parser to ignore App/Apk fields that should never come from the index, but instead are set locally to keep track of the current state of things on the device. There are two forms of tests to enforce that the proper things get ignored. It is not possible to do this with decorators alone, so I chose to use @JsonIgnore and leave the variables we want filled in undecorated. Also, all of the instance variables in Apk/App/Repo should come directly from the index metadata so that they are pure data classes. Currently some state info is stored in them, those are decorated with @JsonIgnore. The tests then include lists of accepted and ignored properties, and anything that is not in those lists will cause the tests to fail. So if someone is adding a new instance variable, they will get a fail until the tests are updated. One set of tests actually writes blank instances out as JSON since that's the easiest test to write, and Jackson treats @JsonIgnore the same in both directions. Then there is another test that reads a JSON file with added, unsupported values to make sure that they are properly ignored. --- .../main/java/org/fdroid/fdroid/data/Apk.java | 48 ++-- .../main/java/org/fdroid/fdroid/data/App.java | 49 ++-- .../java/org/fdroid/fdroid/data/Repo.java | 5 + .../fdroid/updater/IndexV1UpdaterTest.java | 225 ++++++++++++++++-- .../test/resources/all_fields_index-v1.json | 93 ++++++++ config/checkstyle/checkstyle.xml | 2 +- 6 files changed, 360 insertions(+), 62 deletions(-) create mode 100644 app/src/test/resources/all_fields_index-v1.json diff --git a/app/src/main/java/org/fdroid/fdroid/data/Apk.java b/app/src/main/java/org/fdroid/fdroid/data/Apk.java index 3ba7d9359..00fe9711d 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/Apk.java +++ b/app/src/main/java/org/fdroid/fdroid/data/Apk.java @@ -8,7 +8,7 @@ import android.os.Build; import android.os.Parcel; import android.os.Parcelable; import android.support.annotation.NonNull; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import org.fdroid.fdroid.BuildConfig; import org.fdroid.fdroid.RepoXMLHandler; @@ -27,24 +27,41 @@ import java.util.HashSet; * Do not rename these instance variables without careful consideration! * They are mapped to JSON field names, the {@code fdroidserver} internal variable * names, and the {@code fdroiddata} YAML field names. Only the instance variables - * listed in {@code @JsonIgnoreProperties} are not directly mapped. + * decorated with {@code @JsonIgnore} are not directly mapped. + *

    + * NOTE: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 fdroiddata * @see fdroidserver */ -@JsonIgnoreProperties({"compatible", "CREATOR", "installedFile", "repo", "repoAddress", - "repoVersion",}) public class Apk extends ValueObject implements Comparable, Parcelable { // Using only byte-range keeps it only 8-bits in the SQLite database + @JsonIgnore public static final int SDK_VERSION_MAX_VALUE = Byte.MAX_VALUE; + @JsonIgnore public static final int SDK_VERSION_MIN_VALUE = 0; + // these are never set by the Apk/package index metadata + @JsonIgnore + public long repo; // ID of the repo it comes from + @JsonIgnore + String repoAddress; + @JsonIgnore + int repoVersion; + @JsonIgnore + public SanitizedFile installedFile; // the .apk file on this device's filesystem + @JsonIgnore + public boolean compatible; // True if compatible with the device. + + // these come directly from the index metadata public String packageName; public String versionName; public int versionCode; public int size; // Size in bytes - 0 means we don't know! - public long repo; // ID of the repo it comes from public String hash; // checksum of the APK, in lowercase hex public String hashType; public int minSdkVersion = SDK_VERSION_MIN_VALUE; // 0 if unknown @@ -72,13 +89,7 @@ public class Apk extends ValueObject implements Comparable, Parcelable { */ public String sig; - /** - * True if compatible with the device. - */ - public boolean compatible; - public String apkName; // F-Droid style APK name - public SanitizedFile installedFile; // the .apk file on this device's filesystem /** * If not null, this is the name of the source tarball for the @@ -87,8 +98,6 @@ public class Apk extends ValueObject implements Comparable, Parcelable { */ public String srcname; - public int repoVersion; - public String repoAddress; public String[] incompatibleReasons; /** @@ -114,11 +123,13 @@ public class Apk extends ValueObject implements Comparable, Parcelable { * Note: Many of the fields on this instance will not be known in this circumstance. Currently * the only things that are known are: *

    - * + {@link Apk#packageName} - * + {@link Apk#versionName} - * + {@link Apk#versionCode} - * + {@link Apk#hash} - * + {@link Apk#hashType} + *

      + *
    • {@link Apk#packageName} + *
    • {@link Apk#versionName} + *
    • {@link Apk#versionCode} + *
    • {@link Apk#hash} + *
    • {@link Apk#hashType} + *
    *

    * This could instead be implemented by accepting a {@link PackageInfo} and it would get much * the same information, but it wouldn't have the hash of the package. Seeing as we've already @@ -248,6 +259,7 @@ public class Apk extends ValueObject implements Comparable, Parcelable { } } + @JsonIgnore // prevent tests from failing due to nulls in checkRepoAddress() public String getUrl() { checkRepoAddress(); return repoAddress + "/" + apkName.replace(" ", "%20"); diff --git a/app/src/main/java/org/fdroid/fdroid/data/App.java b/app/src/main/java/org/fdroid/fdroid/data/App.java index cedeaf062..beae0e747 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/App.java +++ b/app/src/main/java/org/fdroid/fdroid/data/App.java @@ -16,7 +16,7 @@ import android.support.annotation.Nullable; import android.text.TextUtils; import android.util.Log; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import org.apache.commons.io.filefilter.RegexFileFilter; @@ -56,25 +56,27 @@ import java.util.regex.Pattern; * Do not rename these instance variables without careful consideration! * They are mapped to JSON field names, the {@code fdroidserver} internal variable * names, and the {@code fdroiddata} YAML field names. Only the instance variables - * listed in {@code @JsonIgnoreProperties} are not directly mapped. + * decorated with {@code @JsonIgnore} are not directly mapped. + *

    + * NOTE: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 fdroiddata * @see fdroidserver */ -@JsonIgnoreProperties({"compatible", "CREATOR", "id", "installedApk", "installedSig", - "installedVersionCode", "installedVersionName", "prefs", "repoId", }) public class App extends ValueObject implements Comparable, Parcelable { + @JsonIgnore private static final String TAG = "App"; + // 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; - - 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. @@ -82,7 +84,25 @@ public class App extends ValueObject implements Comparable, Parcelable { * 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. */ + @JsonIgnore public long repoId; + @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; + + // the remaining properties are set directly from the index metadata + public String packageName = "unknown"; + public String name = "Unknown"; + public String summary = "Unknown application"; public String icon; @@ -155,8 +175,6 @@ public class App extends ValueObject implements Comparable, Parcelable { @Deprecated public String[] requirements; - private AppPrefs prefs; - /** * To be displayed at 48dp (x1.0) */ @@ -167,16 +185,6 @@ public class App extends ValueObject implements Comparable, Parcelable { */ 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"; } @@ -957,6 +965,7 @@ public class App extends ValueObject implements Comparable, Parcelable { this.id = in.readLong(); } + @JsonIgnore public static final Parcelable.Creator CREATOR = new Parcelable.Creator() { @Override public App createFromParcel(Parcel source) { diff --git a/app/src/main/java/org/fdroid/fdroid/data/Repo.java b/app/src/main/java/org/fdroid/fdroid/data/Repo.java index 288a41388..e4dc96ebd 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/Repo.java +++ b/app/src/main/java/org/fdroid/fdroid/data/Repo.java @@ -38,6 +38,11 @@ import java.util.Date; /** * Represents a the descriptive info and metadata about a given repo, as provided * by the repo index. This also keeps track of the state of the repo. + *

    + * Do not rename these instance variables without careful consideration! + * 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. * * @see fdroiddata * @see fdroidserver diff --git a/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java b/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java index 1731bf53b..9967a3113 100644 --- a/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java +++ b/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java @@ -2,47 +2,35 @@ package org.fdroid.fdroid.updater; import android.support.annotation.NonNull; import android.util.Log; - import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; - import org.apache.commons.io.IOUtils; -import org.fdroid.fdroid.BuildConfig; -import org.fdroid.fdroid.IndexV1Updater; -import org.fdroid.fdroid.Preferences; -import org.fdroid.fdroid.RepoUpdater; -import org.fdroid.fdroid.TestUtils; -import org.fdroid.fdroid.data.Apk; -import org.fdroid.fdroid.data.App; -import org.fdroid.fdroid.data.FDroidProviderTest; -import org.fdroid.fdroid.data.Repo; -import org.fdroid.fdroid.data.RepoPushRequest; +import org.fdroid.fdroid.*; +import org.fdroid.fdroid.data.*; import org.fdroid.fdroid.mock.RepoDetails; import org.junit.Test; import org.junit.runner.RunWith; -import org.robolectric.RobolectricGradleTestRunner; +import org.robolectric.RobolectricTestRunner; import org.robolectric.annotation.Config; import java.io.IOException; import java.io.InputStream; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; +import java.lang.reflect.Field; +import java.util.*; import java.util.jar.JarEntry; import java.util.jar.JarFile; -import static org.junit.Assert.assertArrayEquals; -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.fail; +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.core.IsNot.not; +import static org.junit.Assert.*; -// TODO: Use sdk=24 when Robolectric supports this -@Config(constants = BuildConfig.class, sdk = 23) -@RunWith(RobolectricGradleTestRunner.class) +@Config(constants = BuildConfig.class, sdk = 24) +@RunWith(RobolectricTestRunner.class) public class IndexV1UpdaterTest extends FDroidProviderTest { public static final String TAG = "IndexV1UpdaterTest"; @@ -169,6 +157,197 @@ public class IndexV1UpdaterTest extends FDroidProviderTest { assertArrayEquals(installRequests.toArray(), requests.get("install")); } + /** + * Test that all the fields are properly marked as whether Jackson should ignore them + * or not. Technically this test goes the opposite direction of how its being used: + * it writes out {@link App} and {@link Apk} instances to JSON using Jackson. With the + * index parsing, Jackson is always reading JSON into {@link App} and {@link Apk} + * instances. {@code @JsonIgnoreProperties} applies to both directions. + *

    + * The test sets come from the top of {@link App} and {@link Apk}. + */ + @Test + public void testJsonIgnoreApp() throws JsonProcessingException { + String[] allowedInApp = new String[]{ + "added", + "antiFeatures", + "authorEmail", + "authorName", + "bitcoin", + "categories", + "changelog", + "description", + "donate", + "featureGraphic", + "flattrID", + "icon", + "iconUrl", + "iconUrlLarge", + "issueTracker", + "lastUpdated", + "license", + "litecoin", + "name", + "packageName", + "phoneScreenshots", + "promoGraphic", + "requirements", + "sevenInchScreenshots", + "sourceCode", + "suggestedVersionCode", + "suggestedVersionName", + "summary", + "tenInchScreenshots", + "tvBanner", + "tvScreenshots", + "upstreamVersionCode", + "upstreamVersionName", + "video", + "wearScreenshots", + "webSite", + }; + String[] ignoredInApp = new String[]{ + "compatible", + "CREATOR", + "id", + "installedApk", + "installedSig", + "installedVersionCode", + "installedVersionName", + "prefs", + "repoId", + "TAG", + }; + runJsonIgnoreTest(new App(), allowedInApp, ignoredInApp); + } + + @Test + public void testJsonIgnoreApk() throws JsonProcessingException { + String[] allowedInApk = new String[]{ + "added", + "antiFeatures", + "apkName", + "appId", + "features", + "hash", + "hashType", + "incompatibleReasons", + "maxSdkVersion", + "minSdkVersion", + "nativecode", + "obbMainFile", + "obbMainFileSha256", + "obbPatchFile", + "obbPatchFileSha256", + "packageName", + "requestedPermissions", + "sig", + "size", + "srcname", + "targetSdkVersion", + "versionCode", + "versionName", + }; + String[] ignoredInApk = new String[]{ + "compatible", + "CREATOR", + "installedFile", + "repo", + "repoAddress", + "repoVersion", + "SDK_VERSION_MAX_VALUE", + "SDK_VERSION_MIN_VALUE", + "url", + }; + runJsonIgnoreTest(new Apk(), allowedInApk, ignoredInApk); + } + + private void runJsonIgnoreTest(Object instance, String[] allowed, String[] ignored) + throws JsonProcessingException { + ObjectMapper mapper = new ObjectMapper(); + String objectAsString = mapper.writeValueAsString(instance); + Set fields = getFields(instance); + for (String field : ignored) { + assertThat(objectAsString, not(containsString("\"" + field + "\""))); + fields.remove(field); + } + for (String field : allowed) { + fields.remove(field); + } + if (fields.size() > 0) { + System.out.print(instance.getClass() + " has fields not setup for Jackson: "); + for (String field : fields) { + System.out.print("\"" + field + "\", "); + } + System.out.println("\nRead class javadoc for more info."); + } + assertEquals(0, fields.size()); + } + + @Test + public void testInstanceVariablesAreProperlyMarked() throws IOException { + ObjectMapper mapper = new ObjectMapper(); + // testing with unknown metadata in it + mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + JsonFactory f = mapper.getFactory(); + JsonParser parser = f.createParser(TestUtils.copyResourceToTempFile("all_fields_index-v1.json")); + + Repo repo = null; + App[] apps = null; + Map> packages = null; + + parser.nextToken(); // go into the main object block + while (true) { + String fieldName = parser.nextFieldName(); + if (fieldName == null) { + break; + } + switch (fieldName) { + case "repo": + repo = parseRepo(mapper, parser); + break; + case "apps": + apps = parseApps(mapper, parser); + break; + case "packages": + packages = parsePackages(mapper, parser); + break; + } + } + parser.close(); // ensure resources get cleaned up timely and properly + + assertEquals(1, apps.length); + assertEquals(1, packages.size()); + assertEquals(1488828510109L, repo.timestamp); + assertEquals("GPLv3", apps[0].license); + + Set appFields = getFields(apps[0]); + for (String field : appFields) { + assertNotEquals("secret", field); + } + + Apk apk = packages.get("info.guardianproject.cacert").get(0); + assertEquals("e013db095e8da843fae5ac44be6152e51377ee717e5c8a7b6d913d7720566b5a", apk.hash); + Set packageFields = getFields(apk); + for (String field : packageFields) { + assertNotEquals("secret", field); + } + } + + private Set getFields(Object instance) { + SortedSet output = new TreeSet<>(); + + //determine fields declared in this class only (no fields of superclass) + Field[] fields = instance.getClass().getDeclaredFields(); + + //print field names paired with their values + for (Field field : fields) { + output.add(field.getName()); + } + + return output; + } + private Repo parseRepo(ObjectMapper mapper, JsonParser parser) throws IOException { System.out.println("parseRepo "); parser.nextToken(); diff --git a/app/src/test/resources/all_fields_index-v1.json b/app/src/test/resources/all_fields_index-v1.json new file mode 100644 index 000000000..9b31fe47e --- /dev/null +++ b/app/src/test/resources/all_fields_index-v1.json @@ -0,0 +1,93 @@ +{ + "repo": { + "timestamp": 1488828510109, + "version": 18, + "name": "Guardian Project Official Releases", + "icon": "guardianproject.png", + "address": "https://guardianproject.info/fdroid/repo", + "description": "The official app repository of The Guardian Project. Applications in this repository are official binaries build by the original application developers and signed by the same key as the APKs that are released in the Google Play store. ", + "secret": "trying to sneak something in", + "mirrors": [ + "http://bdf2wcxujkg6qqff.onion/fdroid/repo", + "https://guardianproject.info/fdroid/repo", + "https://s3.amazonaws.com/guardianproject/fdroid/repo" + ] + }, + "apps": [ + { + "categories": [ + "Security", + "GuardianProject" + ], + "suggestedVersionCode": "999999999", + "description": "

    Android 4+ allows you to disable certificates from the system Settings and root isn't required, so try that first if you want to manually mess with the certificates. The app won't work with Android 4+ anyway.

    An app to manage security certificates on your phone also containing a version of the Android CACert keystore derived from Mozilla. If a certificate has recently become untrusted you can either install an update to this app or you can backup and remove certificates by yourself.

    Requires root: Yes, it writes to the system partition. You will need a device that has the \u2018grep\u2019 command on it (via busybox: present on most custom ROMs). If the \u2018save\u2019 doesn\u2019t work, then you will need to make your /system partition read-write by using a file explorer like Ghost Commander or via a command in Terminal Emulator.

    ", + "issueTracker": "https://github.com/guardianproject/cacert/issues", + "license": "GPLv3", + "name": "CACertMan", + "secret": "trying to sneak something in", + "sourceCode": "https://github.com/guardianproject/cacert", + "summary": "Disable untrusted certificates", + "webSite": "https://guardianproject.info/2011/09/05/cacertman-app-to-address-diginotar-other-bad-cas", + "added": 1376863200000, + "icon": "info.guardianproject.cacert.4.png", + "packageName": "info.guardianproject.cacert", + "lastUpdated": 1383174000000 + } + ], + "packages": { + "info.guardianproject.cacert": [ + { + "added": 1400018400000, + "apkName": "Courier-0.1.8.apk", + "hash": "e013db095e8da843fae5ac44be6152e51377ee717e5c8a7b6d913d7720566b5a", + "hashType": "sha256", + "minSdkVersion": "9", + "nativecode": [ + "armeabi", + "x86" + ], + "packageName": "info.guardianproject.courier", + "secret": "trying to sneak something in", + "sig": "d70ac6a02b53ebdd1354ea7af7b9ceee", + "size": 16536125, + "targetSdkVersion": "15", + "uses-permission": [ + [ + "android.permission.READ_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.INTERNET", + null + ], + [ + "android.permission.BLUETOOTH", + null + ], + [ + "android.permission.BLUETOOTH_ADMIN", + null + ], + [ + "android.permission.VIBRATE", + null + ], + [ + "android.permission.ACCESS_NETWORK_STATE", + null + ], + [ + "android.permission.WRITE_EXTERNAL_STORAGE", + null + ], + [ + "android.permission.ACCESS_WIFI_STATE", + null + ] + ], + "versionCode": 14, + "versionName": "0.1.8" + } + ] + } +} diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml index a6650dd65..a18e6537c 100644 --- a/config/checkstyle/checkstyle.xml +++ b/config/checkstyle/checkstyle.xml @@ -35,7 +35,7 @@ + value="org.fdroid.fdroid.Assert.*, org.assertj.core.api.Assertions.*, org.junit.Assert.*, org.junit.Assume.*, org.junit.internal.matchers.ThrowableMessageMatcher.*, org.hamcrest.core.IsNot.*, org.hamcrest.CoreMatchers.*, org.hamcrest.Matchers.*, org.springframework.boot.configurationprocessor.ConfigurationMetadataMatchers.*, org.springframework.boot.configurationprocessor.TestCompiler.*, org.mockito.Mockito.*, org.mockito.BDDMockito.*, org.mockito.Matchers.*, org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*, org.springframework.test.web.servlet.result.MockMvcResultMatchers.*, org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*, org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.*, org.springframework.hateoas.mvc.ControllerLinkBuilder.linkTo" /> From 517321356d2bd34d95b9577e521b975839b5bba1 Mon Sep 17 00:00:00 2001 From: Peter Serwylo Date: Wed, 8 Mar 2017 13:04:22 +1100 Subject: [PATCH 06/19] the database does not support null Description index-v1 does not send empty values. The description was historically set to "No description available" on the server side, and in index.xml. The database then inherited this behavior, and does not support no description. In the long run, it would be good to sync up the database with the index-v1 metadata, but perhasp then we'd have to add a million null guards, which wouldn't be worth it. --- .../main/java/org/fdroid/fdroid/data/TempAppProvider.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/java/org/fdroid/fdroid/data/TempAppProvider.java b/app/src/main/java/org/fdroid/fdroid/data/TempAppProvider.java index 296861008..9c9daba22 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/TempAppProvider.java +++ b/app/src/main/java/org/fdroid/fdroid/data/TempAppProvider.java @@ -153,6 +153,11 @@ public class TempAppProvider extends AppProvider { throw new UnsupportedOperationException("Update not supported for " + uri + "."); } + if (values.containsKey(Cols.DESCRIPTION) && values.getAsString(Cols.DESCRIPTION) == null) { + // the database does not let a description be set as null + values.put(Cols.DESCRIPTION, ""); + } + List pathParts = uri.getPathSegments(); String packageName = pathParts.get(2); long repoId = Long.parseLong(pathParts.get(1)); From 5aa44a4d74517c667d878e545792ade795f46f41 Mon Sep 17 00:00:00 2001 From: Peter Serwylo Date: Wed, 8 Mar 2017 13:04:51 +1100 Subject: [PATCH 07/19] Handle icon 404s without crashing. Resume settings without crashing on API > 17. The image loading code for the app cards was presuming that the icon returned did indeed exist. In this case, it crashed due to trying to decode a `null` image. I noticed that when returning to the settings fragment (e.g. by closing then reopening F-Droid while viewing), it will attempt to re-remove the priviledged preference. This causes a crash, so we check to see that we still have the preference before deciding to remove it. --- .../org/fdroid/fdroid/views/categories/AppCardController.java | 2 +- .../fdroid/fdroid/views/fragments/PreferencesFragment.java | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/org/fdroid/fdroid/views/categories/AppCardController.java b/app/src/main/java/org/fdroid/fdroid/views/categories/AppCardController.java index 343d9e5a0..ea6e8c67f 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/categories/AppCardController.java +++ b/app/src/main/java/org/fdroid/fdroid/views/categories/AppCardController.java @@ -150,7 +150,7 @@ public class AppCardController extends RecyclerView.ViewHolder implements ImageL @Override public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) { - if (featuredImage != null) { + if (featuredImage != null && loadedImage != null) { new Palette.Builder(loadedImage).generate(new Palette.PaletteAsyncListener() { @Override public void onGenerated(Palette palette) { diff --git a/app/src/main/java/org/fdroid/fdroid/views/fragments/PreferencesFragment.java b/app/src/main/java/org/fdroid/fdroid/views/fragments/PreferencesFragment.java index bd0ee94b2..c190ac645 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/fragments/PreferencesFragment.java +++ b/app/src/main/java/org/fdroid/fdroid/views/fragments/PreferencesFragment.java @@ -227,7 +227,9 @@ public class PreferencesFragment extends PreferenceFragment // way to easily install from here. if (Build.VERSION.SDK_INT > 19 && !installed) { PreferenceCategory other = (PreferenceCategory) findPreference("pref_category_other"); - other.removePreference(pref); + if (pref != null) { + other.removePreference(pref); + } } else { pref.setEnabled(installed); pref.setDefaultValue(installed); From 8cfe1d3584662e63a4b60f95fc8d04138ddc6f71 Mon Sep 17 00:00:00 2001 From: Peter Serwylo Date: Tue, 21 Mar 2017 12:49:22 +1100 Subject: [PATCH 08/19] Correctly populate FeatureImage with `app.featureGraphic`. Fall back to extracting the colour from the apps icon if that is all that is available. --- .../java/org/fdroid/fdroid/AppDetails2.java | 41 +------------ .../fdroid/views/apps/FeatureImage.java | 57 +++++++++++++++++++ .../views/categories/AppCardController.java | 22 +++++-- app/src/main/res/layout/app_card_featured.xml | 2 + 4 files changed, 80 insertions(+), 42 deletions(-) diff --git a/app/src/main/java/org/fdroid/fdroid/AppDetails2.java b/app/src/main/java/org/fdroid/fdroid/AppDetails2.java index 701bd932e..55c2ac73f 100644 --- a/app/src/main/java/org/fdroid/fdroid/AppDetails2.java +++ b/app/src/main/java/org/fdroid/fdroid/AppDetails2.java @@ -9,8 +9,6 @@ import android.content.DialogInterface; import android.content.Intent; import android.content.pm.PackageInfo; import android.content.pm.PackageManager; -import android.graphics.Bitmap; -import android.graphics.Color; import android.net.Uri; import android.os.Bundle; import android.support.design.widget.AppBarLayout; @@ -18,7 +16,6 @@ import android.support.design.widget.CoordinatorLayout; import android.support.v4.content.LocalBroadcastManager; import android.support.v7.app.AlertDialog; import android.support.v7.app.AppCompatActivity; -import android.support.v7.graphics.Palette; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; import android.support.v7.widget.Toolbar; @@ -26,13 +23,10 @@ import android.text.TextUtils; import android.util.Log; import android.view.Menu; import android.view.MenuItem; -import android.view.View; import android.widget.Toast; import com.nostra13.universalimageloader.core.DisplayImageOptions; import com.nostra13.universalimageloader.core.ImageLoader; -import com.nostra13.universalimageloader.core.assist.FailReason; -import com.nostra13.universalimageloader.core.listener.ImageLoadingListener; import org.fdroid.fdroid.data.Apk; import org.fdroid.fdroid.data.ApkProvider; @@ -136,39 +130,10 @@ public class AppDetails2 extends AppCompatActivity implements ShareChooserDialog recyclerView.setAdapter(adapter); // Load the feature graphic, if present + final FeatureImage featureImage = (FeatureImage) findViewById(R.id.feature_graphic); + DisplayImageOptions displayImageOptions = Utils.getImageLoadingOptions().build(); String featureGraphicUrl = app.getFeatureGraphicUrl(this); - if (!TextUtils.isEmpty(featureGraphicUrl)) { - final FeatureImage featureImage = (FeatureImage) findViewById(R.id.feature_graphic); - DisplayImageOptions displayImageOptions = Utils.getImageLoadingOptions().build(); - ImageLoader.getInstance().loadImage(featureGraphicUrl, displayImageOptions, new ImageLoadingListener() { - @Override - public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) { - if (featureImage != null) { - new Palette.Builder(loadedImage).generate(new Palette.PaletteAsyncListener() { - @Override - public void onGenerated(Palette palette) { - featureImage.setColour(palette.getDominantColor(Color.LTGRAY)); - } - }); - } - } - - @Override - public void onLoadingStarted(String imageUri, View view) { - - } - - @Override - public void onLoadingFailed(String imageUri, View view, FailReason failReason) { - - } - - @Override - public void onLoadingCancelled(String imageUri, View view) { - - } - }); - } + featureImage.loadImageAndDisplay(ImageLoader.getInstance(), displayImageOptions, featureGraphicUrl, app.iconUrl); } private String getPackageNameFromIntent(Intent intent) { diff --git a/app/src/main/java/org/fdroid/fdroid/views/apps/FeatureImage.java b/app/src/main/java/org/fdroid/fdroid/views/apps/FeatureImage.java index 08dd3cdcd..7b728e3b4 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/apps/FeatureImage.java +++ b/app/src/main/java/org/fdroid/fdroid/views/apps/FeatureImage.java @@ -3,6 +3,7 @@ package org.fdroid.fdroid.views.apps; import android.animation.ValueAnimator; import android.annotation.TargetApi; import android.content.Context; +import android.graphics.Bitmap; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Paint; @@ -11,10 +12,18 @@ import android.graphics.Point; import android.graphics.PorterDuff; import android.os.Build; import android.support.annotation.ColorInt; +import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.v7.graphics.Palette; import android.support.v7.widget.AppCompatImageView; +import android.text.TextUtils; import android.util.AttributeSet; +import android.view.View; + +import com.nostra13.universalimageloader.core.DisplayImageOptions; +import com.nostra13.universalimageloader.core.ImageLoader; +import com.nostra13.universalimageloader.core.assist.FailReason; +import com.nostra13.universalimageloader.core.listener.ImageLoadingListener; import java.util.Random; @@ -225,4 +234,52 @@ public class FeatureImage extends AppCompatImageView { return path; } + + public void loadImageAndDisplay(@NonNull ImageLoader loader, @NonNull DisplayImageOptions imageOptions, @Nullable String featureImageToShow, @Nullable String fallbackImageToExtractColours) { + if (!TextUtils.isEmpty(featureImageToShow)) { + loadImageAndDisplay(loader, imageOptions, featureImageToShow); + } else if (!TextUtils.isEmpty(fallbackImageToExtractColours)) { + loadImageAndExtractColour(loader, imageOptions, fallbackImageToExtractColours); + } + } + + private void loadImageAndExtractColour(@NonNull ImageLoader loader, @NonNull DisplayImageOptions imageOptions, String url) { + loader.loadImage(url, imageOptions, new ImageLoadingAdapter() { + @Override + public void onLoadingComplete(String imageUri, View view, final Bitmap loadedImage) { + if (loadedImage != null) { + new Palette.Builder(loadedImage).generate(new Palette.PaletteAsyncListener() { + @Override + public void onGenerated(Palette palette) { + if (palette != null) { + setColour(palette.getDominantColor(Color.LTGRAY)); + } + } + }); + } + } + }); + } + + public void loadImageAndDisplay(@NonNull ImageLoader loader, @NonNull DisplayImageOptions imageOptions, String url) { + loader.loadImage(url, imageOptions, new ImageLoadingAdapter() { + @Override + public void onLoadingComplete(String imageUri, View view, final Bitmap loadedImage) { + if (loadedImage != null) { + setImageBitmap(loadedImage); + } + } + }); + } + + private abstract static class ImageLoadingAdapter implements ImageLoadingListener { + @Override + public void onLoadingStarted(String imageUri, View view) { } + + @Override + public void onLoadingFailed(String imageUri, View view, FailReason failReason) { } + + @Override + public void onLoadingCancelled(String imageUri, View view) { } + } } diff --git a/app/src/main/java/org/fdroid/fdroid/views/categories/AppCardController.java b/app/src/main/java/org/fdroid/fdroid/views/categories/AppCardController.java index ea6e8c67f..a979ae3f6 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/categories/AppCardController.java +++ b/app/src/main/java/org/fdroid/fdroid/views/categories/AppCardController.java @@ -13,6 +13,7 @@ import android.support.v4.app.ActivityOptionsCompat; import android.support.v4.util.Pair; import android.support.v7.graphics.Palette; import android.support.v7.widget.RecyclerView; +import android.text.TextUtils; import android.view.View; import android.widget.ImageView; import android.widget.TextView; @@ -110,12 +111,23 @@ public class AppCardController extends RecyclerView.ViewHolder implements ImageL } } + ImageLoader.getInstance().displayImage(app.iconUrl, icon, displayImageOptions, this); + if (featuredImage != null) { featuredImage.setColour(0); featuredImage.setImageDrawable(null); - } - ImageLoader.getInstance().displayImage(app.iconUrl, icon, displayImageOptions, this); + // Note: We could call the convenience function loadImageAndDisplay(ImageLoader, DisplayImageOptions, String, String) + // which includes a fallback for when currentApp.featureGraphic is empty. However we need + // to take care of also loading the icon (regardless of whether there is a featureGraphic + // or not for this app) so that we can display the icon to the user. We will use the + // load complete listener for the icon to decide whether we need to extract the colour + // from that icon and assign to the `FeatureImage` (or whether we should wait for the + // feature image to be loaded). + if (!TextUtils.isEmpty(app.featureGraphic)) { + featuredImage.loadImageAndDisplay(ImageLoader.getInstance(), displayImageOptions, app.featureGraphic); + } + } } /** @@ -145,12 +157,14 @@ public class AppCardController extends RecyclerView.ViewHolder implements ImageL // Icon loader callbacks // // Most are unused, the main goal is to specify a background colour for the featured image if - // no featured image is specified in the apps metadata. + // no featured image is specified in the apps metadata. If an image is specified, then it will + // get loaded using the `FeatureImage.loadImageAndDisplay()` method and so we don't need to do + // anything special here. // ============================================================================================= @Override public void onLoadingComplete(String imageUri, View view, Bitmap loadedImage) { - if (featuredImage != null && loadedImage != null) { + if (currentApp != null && TextUtils.isEmpty(currentApp.featureGraphic) && featuredImage != null && loadedImage != null) { new Palette.Builder(loadedImage).generate(new Palette.PaletteAsyncListener() { @Override public void onGenerated(Palette palette) { diff --git a/app/src/main/res/layout/app_card_featured.xml b/app/src/main/res/layout/app_card_featured.xml index d74565aac..a5d7b47e1 100644 --- a/app/src/main/res/layout/app_card_featured.xml +++ b/app/src/main/res/layout/app_card_featured.xml @@ -12,6 +12,8 @@ android:layout_width="0dp" android:layout_height="120dp" tools:src="@color/fdroid_green" + android:scaleType="centerCrop" + android:fitsSystemWindows="true" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" /> From 38d21cd178031d677cb9b636bbf130d370c16c82 Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Wed, 22 Mar 2017 14:13:31 +0100 Subject: [PATCH 09/19] check timestamp with index-v1 An important security protection is erroring when the index-v1.jar is older than what is currently in the database. If the current or older jar is allowed to be parsed, then a malicious server or Man-In-The-Middle could replay old version of the index-v1.jar to prevent the clients from learning about updates that fix security issues --- .../org/fdroid/fdroid/IndexV1Updater.java | 15 +++++++------- .../fdroid/updater/IndexV1UpdaterTest.java | 20 ++++++++++++++++++- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java b/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java index 97b82c773..5e702d957 100644 --- a/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java +++ b/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java @@ -167,20 +167,19 @@ public class IndexV1Updater extends RepoUpdater { return; } - /* TODO - if (timestamp < repo.timestamp) { - throw new RepoUpdater.UpdateException(repo, "index.jar is older that current index! " - + timestamp + " < " + repo.timestamp); - } - */ - // TODO handle maxage, convert to "expiration" Date instance + long timestamp = (Long) repoMap.get("timestamp") / 1000; + + if (repo.timestamp > timestamp) { + throw new RepoUpdater.UpdateException(repo, "index.jar is older that current index! " + + timestamp + " < " + repo.timestamp); + } X509Certificate certificate = getSigningCertFromJar(indexEntry); verifySigningCertificate(certificate); Utils.debugLog(TAG, "Repo signature verified, saving app metadata to database."); // timestamp is absolutely required - repo.timestamp = (Long) repoMap.get("timestamp"); + repo.timestamp = timestamp; // below are optional, can be null repo.name = getStringRepoValue(repoMap, "name"); repo.icon = getStringRepoValue(repoMap, "icon"); diff --git a/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java b/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java index 9967a3113..1bc261142 100644 --- a/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java +++ b/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java @@ -27,7 +27,11 @@ import java.util.jar.JarFile; import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.core.IsNot.not; -import static org.junit.Assert.*; +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; +import static org.junit.Assert.assertThat; +import static org.junit.Assert.fail; @Config(constants = BuildConfig.class, sdk = 24) @RunWith(RobolectricTestRunner.class) @@ -41,6 +45,7 @@ public class IndexV1UpdaterTest extends FDroidProviderTest { public void testIndexV1Processing() throws IOException, RepoUpdater.UpdateException { Preferences.setup(context); Repo repo = MultiRepoUpdaterTest.createRepo("Testy", TESTY_JAR, context, TESTY_CERT); + repo.timestamp = 1481222110; IndexV1Updater updater = new IndexV1Updater(context, repo); JarFile jarFile = new JarFile(TestUtils.copyResourceToTempFile(TESTY_JAR), true); Log.i(TAG, "jarFile " + jarFile); @@ -63,6 +68,19 @@ public class IndexV1UpdaterTest extends FDroidProviderTest { getClass().getResourceAsStream("foo"); } + @Test(expected = RepoUpdater.UpdateException.class) + public void testIndexV1WithOldTimestamp() throws IOException, RepoUpdater.UpdateException { + Repo repo = MultiRepoUpdaterTest.createRepo("Testy", TESTY_JAR, context, TESTY_CERT); + repo.timestamp = System.currentTimeMillis() / 1000; + IndexV1Updater updater = new IndexV1Updater(context, repo); + JarFile jarFile = new JarFile(TestUtils.copyResourceToTempFile(TESTY_JAR), true); + JarEntry indexEntry = (JarEntry) jarFile.getEntry(IndexV1Updater.DATA_FILE_NAME); + InputStream indexInputStream = jarFile.getInputStream(indexEntry); + updater.processIndexV1(indexInputStream, indexEntry, "fakeEtag"); + fail(); // it should never reach here, it should throw a SigningException + getClass().getResourceAsStream("foo"); + } + @Test(expected = RepoUpdater.SigningException.class) public void testIndexV1WithBadTestyJarNoManifest() throws IOException, RepoUpdater.UpdateException { testBadTestyJar("testy.at.or.at_no-MANIFEST.MF_index-v1.jar"); From 6f58c2a13d56e0fd81a00eba67faf9cd7353efb4 Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Wed, 22 Mar 2017 13:58:27 +0100 Subject: [PATCH 10/19] make Jackson inject repoId rather than looping later to add it repoId is used in Repo, App, and Apk instances to point to the Repo data in the database. It does not come from the index files, but rather the client database. --- app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java | 3 ++- app/src/main/java/org/fdroid/fdroid/data/Apk.java | 6 ++++-- app/src/main/java/org/fdroid/fdroid/data/App.java | 6 ++++-- .../org/fdroid/fdroid/updater/IndexV1UpdaterTest.java | 8 ++++++-- 4 files changed, 16 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java b/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java index 5e702d957..38591e4cf 100644 --- a/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java +++ b/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java @@ -13,6 +13,7 @@ import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.InjectableValues; import com.fasterxml.jackson.databind.ObjectMapper; import org.apache.commons.io.FileUtils; @@ -133,6 +134,7 @@ public class IndexV1Updater extends RepoUpdater { throws IOException, UpdateException { ObjectMapper mapper = new ObjectMapper(); mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + mapper.setInjectableValues(new InjectableValues.Std().addValue(long.class, repo.getId())); JsonFactory f = mapper.getFactory(); JsonParser parser = f.createParser(indexInputStream); HashMap repoMap = null; @@ -192,7 +194,6 @@ public class IndexV1Updater extends RepoUpdater { RepoPersister repoPersister = new RepoPersister(context, repo); if (apps != null && apps.length > 0) { for (App app : apps) { - app.repoId = repo.getId(); // TODO this should be "injected" i.e. @JacksonInject List apks = null; if (packages != null) { apks = packages.get(app.packageName); diff --git a/app/src/main/java/org/fdroid/fdroid/data/Apk.java b/app/src/main/java/org/fdroid/fdroid/data/Apk.java index 00fe9711d..da948f92e 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/Apk.java +++ b/app/src/main/java/org/fdroid/fdroid/data/Apk.java @@ -8,6 +8,7 @@ import android.os.Build; import android.os.Parcel; import android.os.Parcelable; import android.support.annotation.NonNull; +import com.fasterxml.jackson.annotation.JacksonInject; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import org.fdroid.fdroid.BuildConfig; @@ -47,8 +48,6 @@ public class Apk extends ValueObject implements Comparable, Parcelable { // these are never set by the Apk/package index metadata @JsonIgnore - public long repo; // ID of the repo it comes from - @JsonIgnore String repoAddress; @JsonIgnore int repoVersion; @@ -57,6 +56,9 @@ public class Apk extends ValueObject implements Comparable, Parcelable { @JsonIgnore public boolean compatible; // True if compatible with the device. + @JacksonInject + public long repo; // ID of the repo it comes from + // these come directly from the index metadata public String packageName; public String versionName; diff --git a/app/src/main/java/org/fdroid/fdroid/data/App.java b/app/src/main/java/org/fdroid/fdroid/data/App.java index beae0e747..4eb13bb3b 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/App.java +++ b/app/src/main/java/org/fdroid/fdroid/data/App.java @@ -16,6 +16,7 @@ import android.support.annotation.Nullable; import android.text.TextUtils; import android.util.Log; +import com.fasterxml.jackson.annotation.JacksonInject; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; @@ -85,8 +86,6 @@ public class App extends ValueObject implements Comparable, Parcelable { * This is required for getting the full URL to the various graphics and screenshots. */ @JsonIgnore - public long repoId; - @JsonIgnore public Apk installedApk; // might be null if not installed @JsonIgnore public String installedSig; @@ -99,6 +98,9 @@ public class App extends ValueObject implements Comparable, Parcelable { @JsonIgnore private AppPrefs prefs; + @JacksonInject + public long repoId; + // the remaining properties are set directly from the index metadata public String packageName = "unknown"; public String name = "Unknown"; diff --git a/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java b/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java index 1bc261142..be110e7fd 100644 --- a/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java +++ b/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java @@ -7,6 +7,7 @@ import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.InjectableValues; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; import org.apache.commons.io.IOUtils; @@ -38,6 +39,7 @@ import static org.junit.Assert.fail; public class IndexV1UpdaterTest extends FDroidProviderTest { public static final String TAG = "IndexV1UpdaterTest"; + private static final long FAKE_REPO_ID = 0xdeadbeef; private static final String TESTY_JAR = "testy.at.or.at_index-v1.jar"; private static final String TESTY_CERT = "308204e1308202c9a0030201020204483450fa300d06092a864886f70d01010b050030213110300e060355040b1307462d44726f6964310d300b06035504031304736f7661301e170d3136303832333133333131365a170d3434303130393133333131365a30213110300e060355040b1307462d44726f6964310d300b06035504031304736f766130820222300d06092a864886f70d01010105000382020f003082020a0282020100dfdcd120f3ab224999dddf4ea33ea588d295e4d7130bef48c143e9d76e5c0e0e9e5d45e64208e35feebc79a83f08939dd6a343b7d1e2179930a105a1249ccd36d88ff3feffc6e4dc53dae0163a7876dd45ecc1ddb0adf5099aa56c1a84b52affcd45d0711ffa4de864f35ac0333ebe61ea8673eeda35a88f6af678cc4d0f80b089338ac8f2a8279a64195c611d19445cab3fd1a020afed9bd739bb95142fb2c00a8f847db5ef3325c814f8eb741bacf86ed3907bfe6e4564d2de5895df0c263824e0b75407589bae2d3a4666c13b92102d8781a8ee9bb4a5a1a78c4a9c21efdaf5584da42e84418b28f5a81d0456a3dc5b420991801e6b21e38c99bbe018a5b2d690894a114bc860d35601416aa4dc52216aff8a288d4775cddf8b72d45fd2f87303a8e9c0d67e442530be28eaf139894337266e0b33d57f949256ab32083bcc545bc18a83c9ab8247c12aea037e2b68dee31c734cb1f04f241d3b94caa3a2b258ffaf8e6eae9fbbe029a934dc0a0859c5f120334812693a1c09352340a39f2a678dbc1afa2a978bfee43afefcb7e224a58af2f3d647e5745db59061236b8af6fcfd93b3602f9e456978534f3a7851e800071bf56da80401c81d91c45f82568373af0576b1cc5eef9b85654124b6319770be3cdba3fbebe3715e8918fb6c8966624f3d0e815effac3d2ee06dd34ab9c693218b2c7c06ba99d6b74d4f17b8c3cb0203010001a321301f301d0603551d0e04160414d62bee9f3798509546acc62eb1de14b08b954d4f300d06092a864886f70d01010b05000382020100743f7c5692085895f9d1fffad390fb4202c15f123ed094df259185960fd6dadf66cb19851070f180297bba4e6996a4434616573b375cfee94fee73a4505a7ec29136b7e6c22e6436290e3686fe4379d4e3140ec6a08e70cfd3ed5b634a5eb5136efaaabf5f38e0432d3d79568a556970b8cfba2972f5d23a3856d8a981b9e9bbbbb88f35e708bde9cbc5f681cbd974085b9da28911296fe2579fa64bbe9fa0b93475a7a8db051080b0c5fade0d1c018e7858cd4cbe95145b0620e2f632cbe0f8af9cbf22e2fdaa72245ae31b0877b07181cc69dd2df74454251d8de58d25e76354abe7eb690f22e59b08795a8f2c98c578e0599503d9085927634072c82c9f82abd50fd12b8fd1a9d1954eb5cc0b4cfb5796b5aaec0356643b4a65a368442d92ef94edd3ac6a2b7fe3571b8cf9f462729228aab023ef9183f73792f5379633ccac51079177d604c6bc1873ada6f07d8da6d68c897e88a5fa5d63fdb8df820f46090e0716e7562dd3c140ba279a65b996f60addb0abe29d4bf2f5abe89480771d492307b926d91f02f341b2148502903c43d40f3c6c86a811d060711f0698b384acdcc0add44eb54e42962d3d041accc715afd49407715adc09350cb55e8d9281a3b0b6b5fcd91726eede9b7c8b13afdebb2c2b377629595f1096ba62fb14946dbac5f3c5f0b4e5b712e7acc7dcf6c46cdc5e6d6dfdeee55a0c92c2d70f080ac6"; @@ -116,6 +118,7 @@ public class IndexV1UpdaterTest extends FDroidProviderTest { ObjectMapper mapper = new ObjectMapper(); // the app ignores all unknown fields when complete, do not ignore during dev to catch mistakes mapper.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + mapper.setInjectableValues(new InjectableValues.Std().addValue(long.class, FAKE_REPO_ID)); JsonFactory f = mapper.getFactory(); JsonParser parser = f.createParser(TestUtils.copyResourceToTempFile("guardianproject_index-v1.json")); @@ -209,6 +212,7 @@ public class IndexV1UpdaterTest extends FDroidProviderTest { "packageName", "phoneScreenshots", "promoGraphic", + "repoId", "requirements", "sevenInchScreenshots", "sourceCode", @@ -233,7 +237,6 @@ public class IndexV1UpdaterTest extends FDroidProviderTest { "installedVersionCode", "installedVersionName", "prefs", - "repoId", "TAG", }; runJsonIgnoreTest(new App(), allowedInApp, ignoredInApp); @@ -258,6 +261,7 @@ public class IndexV1UpdaterTest extends FDroidProviderTest { "obbPatchFile", "obbPatchFileSha256", "packageName", + "repo", "requestedPermissions", "sig", "size", @@ -270,7 +274,6 @@ public class IndexV1UpdaterTest extends FDroidProviderTest { "compatible", "CREATOR", "installedFile", - "repo", "repoAddress", "repoVersion", "SDK_VERSION_MAX_VALUE", @@ -307,6 +310,7 @@ public class IndexV1UpdaterTest extends FDroidProviderTest { ObjectMapper mapper = new ObjectMapper(); // testing with unknown metadata in it mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + mapper.setInjectableValues(new InjectableValues.Std().addValue(long.class, FAKE_REPO_ID)); JsonFactory f = mapper.getFactory(); JsonParser parser = f.createParser(TestUtils.copyResourceToTempFile("all_fields_index-v1.json")); From f86b65e12a1831a045c337b6f24f225dad681699 Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Tue, 21 Mar 2017 12:14:47 +0100 Subject: [PATCH 11/19] rename Apk.repo to match App.repoId and Repo.id Somehow, the Apk class has its Repo ID variable confusingly named just "repo", which throughout the code is used to represent an instance of Repo. --- app/src/main/java/org/fdroid/fdroid/AppDetails.java | 2 +- .../org/fdroid/fdroid/AppUpdateStatusManager.java | 2 +- .../main/java/org/fdroid/fdroid/RepoXMLHandler.java | 2 +- app/src/main/java/org/fdroid/fdroid/data/Apk.java | 12 ++++++------ .../java/org/fdroid/fdroid/data/RepoPersister.java | 2 +- .../privileged/views/InstallConfirmActivity.java | 2 +- .../fdroid/views/AppDetailsRecyclerViewAdapter.java | 2 +- .../java/org/fdroid/fdroid/data/ApkProviderTest.java | 4 ++-- .../fdroid/fdroid/updater/IndexV1UpdaterTest.java | 2 +- 9 files changed, 15 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/org/fdroid/fdroid/AppDetails.java b/app/src/main/java/org/fdroid/fdroid/AppDetails.java index fec76994c..ec52e6d56 100644 --- a/app/src/main/java/org/fdroid/fdroid/AppDetails.java +++ b/app/src/main/java/org/fdroid/fdroid/AppDetails.java @@ -228,7 +228,7 @@ public class AppDetails extends AppCompatActivity { holder.status.setText(getInstalledStatus(apk)); holder.repository.setText(getString(R.string.repo_provider, - RepoProvider.Helper.findById(getContext(), apk.repo).getName())); + RepoProvider.Helper.findById(getContext(), apk.repoId).getName())); if (apk.size > 0) { holder.size.setText(Utils.getFriendlySize(apk.size)); diff --git a/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java b/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java index 5f7788ef5..39f45d5da 100644 --- a/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java +++ b/app/src/main/java/org/fdroid/fdroid/AppUpdateStatusManager.java @@ -218,7 +218,7 @@ public final class AppUpdateStatusManager { private AppUpdateStatus createAppEntry(Apk apk, Status status, PendingIntent intent) { synchronized (appMapping) { ContentResolver resolver = context.getContentResolver(); - App app = AppProvider.Helper.findSpecificApp(resolver, apk.packageName, apk.repo); + App app = AppProvider.Helper.findSpecificApp(resolver, apk.packageName, apk.repoId); AppUpdateStatus ret = new AppUpdateStatus(app, apk, status, intent); appMapping.put(apk.getUrl(), ret); return ret; diff --git a/app/src/main/java/org/fdroid/fdroid/RepoXMLHandler.java b/app/src/main/java/org/fdroid/fdroid/RepoXMLHandler.java index 345b0d890..df9b625d1 100644 --- a/app/src/main/java/org/fdroid/fdroid/RepoXMLHandler.java +++ b/app/src/main/java/org/fdroid/fdroid/RepoXMLHandler.java @@ -359,7 +359,7 @@ public class RepoXMLHandler extends DefaultHandler { } else if ("package".equals(localName) && curapp != null && curapk == null) { curapk = new Apk(); curapk.packageName = curapp.packageName; - curapk.repo = repo.getId(); + curapk.repoId = repo.getId(); currentApkHashType = null; } else if ("hash".equals(localName) && curapk != null) { diff --git a/app/src/main/java/org/fdroid/fdroid/data/Apk.java b/app/src/main/java/org/fdroid/fdroid/data/Apk.java index da948f92e..70af083ca 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/Apk.java +++ b/app/src/main/java/org/fdroid/fdroid/data/Apk.java @@ -57,7 +57,7 @@ public class Apk extends ValueObject implements Comparable, Parcelable { public boolean compatible; // True if compatible with the device. @JacksonInject - public long repo; // ID of the repo it comes from + public long repoId; // ID of the repo it comes from // these come directly from the index metadata public String packageName; @@ -154,7 +154,7 @@ public class Apk extends ValueObject implements Comparable, Parcelable { // If we are being created from an InstalledApp, it is because we couldn't load it from the // apk table in the database, indicating it is not available in any of our repos. - repo = 0; + repoId = 0; } public Apk(Cursor cursor) { @@ -218,7 +218,7 @@ public class Apk extends ValueObject implements Comparable, Parcelable { incompatibleReasons = Utils.parseCommaSeparatedString(cursor.getString(i)); break; case Cols.REPO_ID: - repo = cursor.getInt(i); + repoId = cursor.getInt(i); break; case Cols.SIGNATURE: sig = cursor.getString(i); @@ -333,7 +333,7 @@ public class Apk extends ValueObject implements Comparable, Parcelable { values.put(Cols.APP_ID, appId); values.put(Cols.VERSION_NAME, versionName); values.put(Cols.VERSION_CODE, versionCode); - values.put(Cols.REPO_ID, repo); + values.put(Cols.REPO_ID, repoId); values.put(Cols.HASH, hash); values.put(Cols.HASH_TYPE, hashType); values.put(Cols.SIGNATURE, sig); @@ -377,7 +377,7 @@ public class Apk extends ValueObject implements Comparable, Parcelable { dest.writeString(this.versionName); dest.writeInt(this.versionCode); dest.writeInt(this.size); - dest.writeLong(this.repo); + dest.writeLong(this.repoId); dest.writeString(this.hash); dest.writeString(this.hashType); dest.writeInt(this.minSdkVersion); @@ -408,7 +408,7 @@ public class Apk extends ValueObject implements Comparable, Parcelable { this.versionName = in.readString(); this.versionCode = in.readInt(); this.size = in.readInt(); - this.repo = in.readLong(); + this.repoId = in.readLong(); this.hash = in.readString(); this.hashType = in.readString(); this.minSdkVersion = in.readInt(); diff --git a/app/src/main/java/org/fdroid/fdroid/data/RepoPersister.java b/app/src/main/java/org/fdroid/fdroid/data/RepoPersister.java index 63e9d76fc..f13d678eb 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/RepoPersister.java +++ b/app/src/main/java/org/fdroid/fdroid/data/RepoPersister.java @@ -185,7 +185,7 @@ public class RepoPersister { for (Apk apk : packages) { boolean exists = false; for (Apk existing : existingApks) { - if (existing.repo == apk.repo && existing.packageName.equals(apk.packageName) && existing.versionCode == apk.versionCode) { + if (existing.repoId == apk.repoId && existing.packageName.equals(apk.packageName) && existing.versionCode == apk.versionCode) { exists = true; break; } diff --git a/app/src/main/java/org/fdroid/fdroid/privileged/views/InstallConfirmActivity.java b/app/src/main/java/org/fdroid/fdroid/privileged/views/InstallConfirmActivity.java index 230efd70d..feb034c58 100644 --- a/app/src/main/java/org/fdroid/fdroid/privileged/views/InstallConfirmActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/privileged/views/InstallConfirmActivity.java @@ -191,7 +191,7 @@ public class InstallConfirmActivity extends FragmentActivity implements OnCancel intent = getIntent(); Uri uri = intent.getData(); Apk apk = ApkProvider.Helper.findByUri(this, uri, Schema.ApkTable.Cols.ALL); - app = AppProvider.Helper.findSpecificApp(getContentResolver(), apk.packageName, apk.repo, Schema.AppMetadataTable.Cols.ALL); + app = AppProvider.Helper.findSpecificApp(getContentResolver(), apk.packageName, apk.repoId, Schema.AppMetadataTable.Cols.ALL); appDiff = new AppDiff(getPackageManager(), apk); diff --git a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java index e64e3a6a9..167faed44 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java +++ b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java @@ -805,7 +805,7 @@ public class AppDetailsRecyclerViewAdapter status.setText(getInstalledStatus(apk)); repository.setText(context.getString(R.string.repo_provider, - RepoProvider.Helper.findById(context, apk.repo).getName())); + RepoProvider.Helper.findById(context, apk.repoId).getName())); if (apk.size > 0) { size.setText(Utils.getFriendlySize(apk.size)); diff --git a/app/src/test/java/org/fdroid/fdroid/data/ApkProviderTest.java b/app/src/test/java/org/fdroid/fdroid/data/ApkProviderTest.java index de7eb532c..343344fdb 100644 --- a/app/src/test/java/org/fdroid/fdroid/data/ApkProviderTest.java +++ b/app/src/test/java/org/fdroid/fdroid/data/ApkProviderTest.java @@ -235,7 +235,7 @@ public class ApkProviderTest extends FDroidProviderTest { assertEquals("Some features", apk.features[0]); assertEquals("com.example.com", apk.packageName); assertEquals(1, apk.versionCode); - assertEquals(10, apk.repo); + assertEquals(10, apk.repoId); } @Test @@ -481,7 +481,7 @@ public class ApkProviderTest extends FDroidProviderTest { protected void assertBelongsToRepo(Cursor apkCursor, long repoId) { for (Apk apk : ApkProvider.Helper.cursorToList(apkCursor)) { - assertEquals(repoId, apk.repo); + assertEquals(repoId, apk.repoId); } } diff --git a/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java b/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java index be110e7fd..72563bdbf 100644 --- a/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java +++ b/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java @@ -261,7 +261,7 @@ public class IndexV1UpdaterTest extends FDroidProviderTest { "obbPatchFile", "obbPatchFileSha256", "packageName", - "repo", + "repoId", "requestedPermissions", "sig", "size", From 9d026bbdbc5c9fecd61fc6597f807a0d1e3309fb Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Wed, 22 Mar 2017 21:45:59 +0100 Subject: [PATCH 12/19] use string name for JacksonInject to make it easy to read java --- .../org/fdroid/fdroid/IndexV1Updater.java | 2 +- .../main/java/org/fdroid/fdroid/data/Apk.java | 4 ++-- .../main/java/org/fdroid/fdroid/data/App.java | 2 +- .../java/org/fdroid/fdroid/data/Repo.java | 3 +++ .../fdroid/updater/IndexV1UpdaterTest.java | 24 +++++++++++++++---- 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java b/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java index 38591e4cf..f5d586e2c 100644 --- a/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java +++ b/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java @@ -134,7 +134,7 @@ public class IndexV1Updater extends RepoUpdater { throws IOException, UpdateException { ObjectMapper mapper = new ObjectMapper(); mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); - mapper.setInjectableValues(new InjectableValues.Std().addValue(long.class, repo.getId())); + mapper.setInjectableValues(new InjectableValues.Std().addValue("repoId", repo.getId())); JsonFactory f = mapper.getFactory(); JsonParser parser = f.createParser(indexInputStream); HashMap repoMap = null; diff --git a/app/src/main/java/org/fdroid/fdroid/data/Apk.java b/app/src/main/java/org/fdroid/fdroid/data/Apk.java index 70af083ca..66c86d3ce 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/Apk.java +++ b/app/src/main/java/org/fdroid/fdroid/data/Apk.java @@ -56,8 +56,8 @@ public class Apk extends ValueObject implements Comparable, Parcelable { @JsonIgnore public boolean compatible; // True if compatible with the device. - @JacksonInject - public long repoId; // ID of the repo it comes from + @JacksonInject("repoId") + public long repoId; // the database ID of the repo it comes from // these come directly from the index metadata public String packageName; diff --git a/app/src/main/java/org/fdroid/fdroid/data/App.java b/app/src/main/java/org/fdroid/fdroid/data/App.java index 4eb13bb3b..f869db5c0 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/App.java +++ b/app/src/main/java/org/fdroid/fdroid/data/App.java @@ -98,7 +98,7 @@ public class App extends ValueObject implements Comparable, Parcelable { @JsonIgnore private AppPrefs prefs; - @JacksonInject + @JacksonInject("repoId") public long repoId; // the remaining properties are set directly from the index metadata diff --git a/app/src/main/java/org/fdroid/fdroid/data/Repo.java b/app/src/main/java/org/fdroid/fdroid/data/Repo.java index e4dc96ebd..1c408ce14 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/Repo.java +++ b/app/src/main/java/org/fdroid/fdroid/data/Repo.java @@ -163,6 +163,9 @@ public class Repo extends ValueObject { } } + /** + * @return the database ID to find this repo in the database + */ public long getId() { return id; } diff --git a/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java b/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java index 72563bdbf..fef637a85 100644 --- a/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java +++ b/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java @@ -11,8 +11,16 @@ import com.fasterxml.jackson.databind.InjectableValues; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; import org.apache.commons.io.IOUtils; -import org.fdroid.fdroid.*; -import org.fdroid.fdroid.data.*; +import org.fdroid.fdroid.BuildConfig; +import org.fdroid.fdroid.IndexV1Updater; +import org.fdroid.fdroid.Preferences; +import org.fdroid.fdroid.RepoUpdater; +import org.fdroid.fdroid.TestUtils; +import org.fdroid.fdroid.data.Apk; +import org.fdroid.fdroid.data.App; +import org.fdroid.fdroid.data.FDroidProviderTest; +import org.fdroid.fdroid.data.Repo; +import org.fdroid.fdroid.data.RepoPushRequest; import org.fdroid.fdroid.mock.RepoDetails; import org.junit.Test; import org.junit.runner.RunWith; @@ -22,7 +30,13 @@ import org.robolectric.annotation.Config; import java.io.IOException; import java.io.InputStream; import java.lang.reflect.Field; -import java.util.*; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.SortedSet; +import java.util.TreeSet; import java.util.jar.JarEntry; import java.util.jar.JarFile; @@ -118,7 +132,7 @@ public class IndexV1UpdaterTest extends FDroidProviderTest { ObjectMapper mapper = new ObjectMapper(); // the app ignores all unknown fields when complete, do not ignore during dev to catch mistakes mapper.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); - mapper.setInjectableValues(new InjectableValues.Std().addValue(long.class, FAKE_REPO_ID)); + mapper.setInjectableValues(new InjectableValues.Std().addValue("repoId", FAKE_REPO_ID)); JsonFactory f = mapper.getFactory(); JsonParser parser = f.createParser(TestUtils.copyResourceToTempFile("guardianproject_index-v1.json")); @@ -310,7 +324,7 @@ public class IndexV1UpdaterTest extends FDroidProviderTest { ObjectMapper mapper = new ObjectMapper(); // testing with unknown metadata in it mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); - mapper.setInjectableValues(new InjectableValues.Std().addValue(long.class, FAKE_REPO_ID)); + mapper.setInjectableValues(new InjectableValues.Std().addValue("repoId", FAKE_REPO_ID)); JsonFactory f = mapper.getFactory(); JsonParser parser = f.createParser(TestUtils.copyResourceToTempFile("all_fields_index-v1.json")); From 2a2e475bdc7dac044bb23069ae6e447954c09a13 Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Wed, 22 Mar 2017 13:53:50 +0100 Subject: [PATCH 13/19] index-v1 future proof test for uses-permission The parser should accept additional elements to each uses-permission entry, in case more XML attributes is added to . has had two attributes since the beginning. Other changes to this JSON data structure are bad index-v1 format, and will cause crashes: * Removing an element e.g. null from a uses-permission entry would be invalid index-v1 JSON, since that structure mirrors the uses-permission AndroidManifest.xml element, which has a long standing fixed definition of name/maxSdkVersion. That should crash so that fdroidserver authors know they are generating invalid index-v1. * setting versionCode to anything but an int is invalid index-v1 JSON, and should crash. versionCode has been defined as an 32-bit signed integer value since the beginning of Android. * has been defined as a string since the beginning of Android. https://developer.android.com/guide/topics/manifest/uses-permission-element.html --- app/src/test/resources/guardianproject_index-v1.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/test/resources/guardianproject_index-v1.json b/app/src/test/resources/guardianproject_index-v1.json index 750325ce1..9b86356c7 100644 --- a/app/src/test/resources/guardianproject_index-v1.json +++ b/app/src/test/resources/guardianproject_index-v1.json @@ -889,11 +889,15 @@ ], [ "android.permission.WRITE_EXTERNAL_STORAGE", - 18 + 18, + null, + "another fake future proofing test" ], [ "android.permission.VIBRATE", - 18 + 18, + 1234567, + "future proof test" ] ], "versionCode": 100, From 28bcbc548a07b2359124e2b0acbede355a899c60 Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Wed, 22 Mar 2017 21:47:47 +0100 Subject: [PATCH 14/19] use central method to setup Jackson in locked down setup This is based on @pserwlyo's work. The App and Apk classes currently need just the public instance variables auto-filled by Jackson, so everything else is considered opt-in, via @JsonProperty declarations. This is currently only used for setLocalized(), setUsesPermission(), and setUsesPermissionSdk23(). # Conflicts: # app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java --- .../org/fdroid/fdroid/IndexV1Updater.java | 28 ++++++++++++++++-- .../fdroid/updater/IndexV1UpdaterTest.java | 29 +++++++++++++------ 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java b/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java index f5d586e2c..5118dbf90 100644 --- a/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java +++ b/app/src/main/java/org/fdroid/fdroid/IndexV1Updater.java @@ -9,6 +9,8 @@ import android.support.v4.content.LocalBroadcastManager; import android.text.TextUtils; import android.util.Log; +import com.fasterxml.jackson.annotation.JsonAutoDetect; +import com.fasterxml.jackson.annotation.PropertyAccessor; import com.fasterxml.jackson.core.JsonFactory; import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.type.TypeReference; @@ -46,6 +48,12 @@ import java.util.jar.JarFile; * by Jackson. This is possible but not wise to do with {@link Repo} since that * class has many fields that are related to security components of the * implementation internal to this app. + *

    + * All non-{@code public} fields and fields tagged with {@code @JsonIgnore} are + * ignored. All methods are ignored unless they are tagged with {@code @JsonProperty}. + * This setup prevents the situation where future developers add variables to the + * App/Apk classes, resulting in malicious servers being able to populate those + * variables. */ public class IndexV1Updater extends RepoUpdater { public static final String TAG = "IndexV1Updater"; @@ -121,6 +129,22 @@ public class IndexV1Updater extends RepoUpdater { return true; } + /** + * Get the standard {@link ObjectMapper} instance used for parsing {@code index-v1.json}. + * This ignores unknown properties so that old releases won't crash when new things are + * added to {@code index-v1.json}. This is required for both forward compatibility, + * but also because ignoring such properties when coming from a malicious server seems + * reasonable anyway. + */ + public static ObjectMapper getObjectMapperInstance(long repoId) { + ObjectMapper mapper = new ObjectMapper(); + mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + mapper.setInjectableValues(new InjectableValues.Std().addValue("repoId", repoId)); + mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.NONE); + mapper.setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.PUBLIC_ONLY); + return mapper; + } + /** * Parses the index and feeds it to the database via {@link Repo}, {@link App}, * and {@link Apk} instances. @@ -132,9 +156,7 @@ public class IndexV1Updater extends RepoUpdater { */ public void processIndexV1(InputStream indexInputStream, JarEntry indexEntry, String cacheTag) throws IOException, UpdateException { - ObjectMapper mapper = new ObjectMapper(); - mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); - mapper.setInjectableValues(new InjectableValues.Std().addValue("repoId", repo.getId())); + ObjectMapper mapper = getObjectMapperInstance(repo.getId()); JsonFactory f = mapper.getFactory(); JsonParser parser = f.createParser(indexInputStream); HashMap repoMap = null; diff --git a/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java b/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java index fef637a85..9abb974ca 100644 --- a/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java +++ b/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java @@ -7,7 +7,6 @@ import com.fasterxml.jackson.core.JsonParser; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.DeserializationFeature; -import com.fasterxml.jackson.databind.InjectableValues; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectReader; import org.apache.commons.io.IOUtils; @@ -129,10 +128,9 @@ public class IndexV1UpdaterTest extends FDroidProviderTest { @Test public void testJacksonParsing() throws IOException { - ObjectMapper mapper = new ObjectMapper(); + ObjectMapper mapper = IndexV1Updater.getObjectMapperInstance(FAKE_REPO_ID); // the app ignores all unknown fields when complete, do not ignore during dev to catch mistakes mapper.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); - mapper.setInjectableValues(new InjectableValues.Std().addValue("repoId", FAKE_REPO_ID)); JsonFactory f = mapper.getFactory(); JsonParser parser = f.createParser(TestUtils.copyResourceToTempFile("guardianproject_index-v1.json")); @@ -199,7 +197,10 @@ public class IndexV1UpdaterTest extends FDroidProviderTest { * index parsing, Jackson is always reading JSON into {@link App} and {@link Apk} * instances. {@code @JsonIgnoreProperties} applies to both directions. *

    - * The test sets come from the top of {@link App} and {@link Apk}. + * If this test fails for you, then it probably means you are adding a new instance + * variable to {@link App}. In that case, you need to add it to the appropriate + * list here. If it is allowed, make sure this new instance variable is in sync with + * {@code index-v1.json} and {@code fdroidserver}. */ @Test public void testJsonIgnoreApp() throws JsonProcessingException { @@ -256,6 +257,19 @@ public class IndexV1UpdaterTest extends FDroidProviderTest { runJsonIgnoreTest(new App(), allowedInApp, ignoredInApp); } + + /** + * Test that all the fields are properly marked as whether Jackson should ignore them + * or not. Technically this test goes the opposite direction of how its being used: + * it writes out {@link App} and {@link Apk} instances to JSON using Jackson. With the + * index parsing, Jackson is always reading JSON into {@link App} and {@link Apk} + * instances. {@code @JsonIgnoreProperties} applies to both directions. + *

    + * If this test fails for you, then it probably means you are adding a new instance + * variable to {@link Apk}. In that case, you need to add it to the appropriate + * list here. If it is allowed, make sure this new instance variable is in sync with + * {@code index-v1.json} and {@code fdroidserver}. + */ @Test public void testJsonIgnoreApk() throws JsonProcessingException { String[] allowedInApk = new String[]{ @@ -299,7 +313,7 @@ public class IndexV1UpdaterTest extends FDroidProviderTest { private void runJsonIgnoreTest(Object instance, String[] allowed, String[] ignored) throws JsonProcessingException { - ObjectMapper mapper = new ObjectMapper(); + ObjectMapper mapper = IndexV1Updater.getObjectMapperInstance(FAKE_REPO_ID); String objectAsString = mapper.writeValueAsString(instance); Set fields = getFields(instance); for (String field : ignored) { @@ -321,10 +335,7 @@ public class IndexV1UpdaterTest extends FDroidProviderTest { @Test public void testInstanceVariablesAreProperlyMarked() throws IOException { - ObjectMapper mapper = new ObjectMapper(); - // testing with unknown metadata in it - mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); - mapper.setInjectableValues(new InjectableValues.Std().addValue("repoId", FAKE_REPO_ID)); + ObjectMapper mapper = IndexV1Updater.getObjectMapperInstance(FAKE_REPO_ID); JsonFactory f = mapper.getFactory(); JsonParser parser = f.createParser(TestUtils.copyResourceToTempFile("all_fields_index-v1.json")); From d90c773161a07592a7f7c6048aace0b61a377812 Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Thu, 23 Mar 2017 12:29:21 +0100 Subject: [PATCH 15/19] do not show screenshots view in AppDetails if none are available App.getAllScreenshots() works nicely here, but its probably a temporary measure until we figure out how to handle the various kinds of screenshots (TV, Wear, etc). --- .../fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java index 167faed44..48b1b868a 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java +++ b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java @@ -32,11 +32,9 @@ import android.widget.LinearLayout; import android.widget.ProgressBar; import android.widget.TextView; import android.widget.Toast; - import com.nostra13.universalimageloader.core.DisplayImageOptions; import com.nostra13.universalimageloader.core.ImageLoader; import com.nostra13.universalimageloader.core.assist.ImageScaleType; - import org.fdroid.fdroid.Preferences; import org.fdroid.fdroid.R; import org.fdroid.fdroid.Utils; @@ -126,7 +124,9 @@ public class AppDetailsRecyclerViewAdapter items.clear(); } addItem(VIEWTYPE_HEADER); - addItem(VIEWTYPE_SCREENSHOTS); + if (app.getAllScreenshots(context).length > 0) { + addItem(VIEWTYPE_SCREENSHOTS); + } addItem(VIEWTYPE_DONATE); addItem(VIEWTYPE_LINKS); addItem(VIEWTYPE_PERMISSIONS); From b08dfdcb808f74c552f3a8a01ffa7be86a9ff8c8 Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Wed, 29 Mar 2017 18:12:07 +0200 Subject: [PATCH 16/19] move "What's New" placeholder to where its used There is still quite a bit to figure out in the data format of the per-package "What's New" entries, and its breaking the tests, so move the placeholder code to the one spot where the placeholder whatsNew entry is used. --- app/src/main/java/org/fdroid/fdroid/data/Apk.java | 13 ------------- .../fdroid/views/AppDetailsRecyclerViewAdapter.java | 13 +++++++++++-- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/org/fdroid/fdroid/data/Apk.java b/app/src/main/java/org/fdroid/fdroid/data/Apk.java index 66c86d3ce..5b65ad9ac 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/Apk.java +++ b/app/src/main/java/org/fdroid/fdroid/data/Apk.java @@ -11,7 +11,6 @@ import android.support.annotation.NonNull; import com.fasterxml.jackson.annotation.JacksonInject; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; -import org.fdroid.fdroid.BuildConfig; import org.fdroid.fdroid.RepoXMLHandler; import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.data.Schema.ApkTable.Cols; @@ -102,11 +101,6 @@ public class Apk extends ValueObject implements Comparable, Parcelable { public String[] incompatibleReasons; - /** - * A descriptive text for what has changed in the latest version. - */ - public String whatsNew; - public String[] antiFeatures; /** @@ -246,13 +240,6 @@ public class Apk extends ValueObject implements Comparable, Parcelable { break; } } - - // For now, just populate "what's new" with placeholder (or leave blank) - if (BuildConfig.DEBUG) { - if (Math.random() > 0.5) { - whatsNew = "This section will contain the 'what's new' information for the apk.\n\n\t• Bug fixes."; - } - } } private void checkRepoAddress() { diff --git a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java index 48b1b868a..05fb567a1 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java +++ b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java @@ -35,6 +35,7 @@ import android.widget.Toast; import com.nostra13.universalimageloader.core.DisplayImageOptions; import com.nostra13.universalimageloader.core.ImageLoader; import com.nostra13.universalimageloader.core.assist.ImageScaleType; +import org.fdroid.fdroid.BuildConfig; import org.fdroid.fdroid.Preferences; import org.fdroid.fdroid.R; import org.fdroid.fdroid.Utils; @@ -413,7 +414,15 @@ public class AppDetailsRecyclerViewAdapter lastUpdateView.setVisibility(View.GONE); } Apk suggestedApk = getSuggestedApk(); - if (suggestedApk == null || TextUtils.isEmpty(suggestedApk.whatsNew)) { + // TODO replace this whatsNew test code with what comes from suggestedApk once that exists + //if (suggestedApk == null || TextUtils.isEmpty(suggestedApk.whatsNew)) { + String whatsNew = null; + if (BuildConfig.DEBUG) { + if (Math.random() > 0.5) { + whatsNew = "This section will contain the 'what's new' information for the apk.\n\n\t• Bug fixes."; + } + } + if (suggestedApk == null || TextUtils.isEmpty(whatsNew)) { whatsNewView.setVisibility(View.GONE); } else { //noinspection deprecation Ignore deprecation because the suggested way is only available in API 24. @@ -422,7 +431,7 @@ public class AppDetailsRecyclerViewAdapter StringBuilder sbWhatsNew = new StringBuilder(); sbWhatsNew.append(whatsNewView.getContext().getString(R.string.details_new_in_version, suggestedApk.versionName).toUpperCase(locale)); sbWhatsNew.append("\n\n"); - sbWhatsNew.append(suggestedApk.whatsNew); + sbWhatsNew.append("This section will contain the 'what's new' information for the apk.\n\n\t• Bug fixes."); whatsNewView.setText(sbWhatsNew); whatsNewView.setVisibility(View.VISIBLE); } From fb6f61c2260bdd3b198b88348d05608e61be12fb Mon Sep 17 00:00:00 2001 From: Peter Serwylo Date: Fri, 31 Mar 2017 09:07:23 +1100 Subject: [PATCH 17/19] Instructions for getting tests to run in AS again. --- CONTRIBUTING.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 595d555ab..d04505f9f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,6 +73,17 @@ Note that the CI already runs the tests on an emulator, so you don't necessarily have to do this yourself if you open a merge request as the tests will get run. +### Running tests in Android Studio + +Later versions of Android Studio require tests to be run with a "Working directory" +of `$MODULE_DIR$`. +[To make this the default behaviour](https://code.google.com/p/android/issues/detail?id=158015#c11), +close any projects to get the Welcome dialog. Then choose _Configure > Project Defaults > +Run Configurations > Defaults > Android JUnit_, and change "Working directory" +to `$MODULE_DIR$`. If you already have a project setup in Android Studio, you +may also need to change the default in _Run > Edit Configurations... > Defaults > +Android JUnit_. + ## Versioning Each stable version follows the `X.Y` pattern. Hotfix releases - i.e. when a From e34f289246f653c2f90b53cdeeaacf0044fa1f53 Mon Sep 17 00:00:00 2001 From: Peter Serwylo Date: Fri, 31 Mar 2017 09:11:50 +1100 Subject: [PATCH 18/19] Setup and teardown preferences singleton for new tests. Depending on the order the tests are run, this may have resulted in an exception due to the singleton being setup multiple times. --- .../fdroid/fdroid/updater/IndexV1UpdaterTest.java | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java b/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java index 9abb974ca..970f4ec84 100644 --- a/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java +++ b/app/src/test/java/org/fdroid/fdroid/updater/IndexV1UpdaterTest.java @@ -21,6 +21,8 @@ import org.fdroid.fdroid.data.FDroidProviderTest; import org.fdroid.fdroid.data.Repo; import org.fdroid.fdroid.data.RepoPushRequest; import org.fdroid.fdroid.mock.RepoDetails; +import org.junit.After; +import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.robolectric.RobolectricTestRunner; @@ -56,9 +58,18 @@ public class IndexV1UpdaterTest extends FDroidProviderTest { private static final String TESTY_JAR = "testy.at.or.at_index-v1.jar"; private static final String TESTY_CERT = "308204e1308202c9a0030201020204483450fa300d06092a864886f70d01010b050030213110300e060355040b1307462d44726f6964310d300b06035504031304736f7661301e170d3136303832333133333131365a170d3434303130393133333131365a30213110300e060355040b1307462d44726f6964310d300b06035504031304736f766130820222300d06092a864886f70d01010105000382020f003082020a0282020100dfdcd120f3ab224999dddf4ea33ea588d295e4d7130bef48c143e9d76e5c0e0e9e5d45e64208e35feebc79a83f08939dd6a343b7d1e2179930a105a1249ccd36d88ff3feffc6e4dc53dae0163a7876dd45ecc1ddb0adf5099aa56c1a84b52affcd45d0711ffa4de864f35ac0333ebe61ea8673eeda35a88f6af678cc4d0f80b089338ac8f2a8279a64195c611d19445cab3fd1a020afed9bd739bb95142fb2c00a8f847db5ef3325c814f8eb741bacf86ed3907bfe6e4564d2de5895df0c263824e0b75407589bae2d3a4666c13b92102d8781a8ee9bb4a5a1a78c4a9c21efdaf5584da42e84418b28f5a81d0456a3dc5b420991801e6b21e38c99bbe018a5b2d690894a114bc860d35601416aa4dc52216aff8a288d4775cddf8b72d45fd2f87303a8e9c0d67e442530be28eaf139894337266e0b33d57f949256ab32083bcc545bc18a83c9ab8247c12aea037e2b68dee31c734cb1f04f241d3b94caa3a2b258ffaf8e6eae9fbbe029a934dc0a0859c5f120334812693a1c09352340a39f2a678dbc1afa2a978bfee43afefcb7e224a58af2f3d647e5745db59061236b8af6fcfd93b3602f9e456978534f3a7851e800071bf56da80401c81d91c45f82568373af0576b1cc5eef9b85654124b6319770be3cdba3fbebe3715e8918fb6c8966624f3d0e815effac3d2ee06dd34ab9c693218b2c7c06ba99d6b74d4f17b8c3cb0203010001a321301f301d0603551d0e04160414d62bee9f3798509546acc62eb1de14b08b954d4f300d06092a864886f70d01010b05000382020100743f7c5692085895f9d1fffad390fb4202c15f123ed094df259185960fd6dadf66cb19851070f180297bba4e6996a4434616573b375cfee94fee73a4505a7ec29136b7e6c22e6436290e3686fe4379d4e3140ec6a08e70cfd3ed5b634a5eb5136efaaabf5f38e0432d3d79568a556970b8cfba2972f5d23a3856d8a981b9e9bbbbb88f35e708bde9cbc5f681cbd974085b9da28911296fe2579fa64bbe9fa0b93475a7a8db051080b0c5fade0d1c018e7858cd4cbe95145b0620e2f632cbe0f8af9cbf22e2fdaa72245ae31b0877b07181cc69dd2df74454251d8de58d25e76354abe7eb690f22e59b08795a8f2c98c578e0599503d9085927634072c82c9f82abd50fd12b8fd1a9d1954eb5cc0b4cfb5796b5aaec0356643b4a65a368442d92ef94edd3ac6a2b7fe3571b8cf9f462729228aab023ef9183f73792f5379633ccac51079177d604c6bc1873ada6f07d8da6d68c897e88a5fa5d63fdb8df820f46090e0716e7562dd3c140ba279a65b996f60addb0abe29d4bf2f5abe89480771d492307b926d91f02f341b2148502903c43d40f3c6c86a811d060711f0698b384acdcc0add44eb54e42962d3d041accc715afd49407715adc09350cb55e8d9281a3b0b6b5fcd91726eede9b7c8b13afdebb2c2b377629595f1096ba62fb14946dbac5f3c5f0b4e5b712e7acc7dcf6c46cdc5e6d6dfdeee55a0c92c2d70f080ac6"; + @Before + public void setup() { + Preferences.setup(context); + } + + @After + public void tearDown() { + Preferences.clearSingletonForTesting(); + } + @Test public void testIndexV1Processing() throws IOException, RepoUpdater.UpdateException { - Preferences.setup(context); Repo repo = MultiRepoUpdaterTest.createRepo("Testy", TESTY_JAR, context, TESTY_CERT); repo.timestamp = 1481222110; IndexV1Updater updater = new IndexV1Updater(context, repo); From c3424f9ff3e674001f5a6aaad9e43a97cd9a3bf2 Mon Sep 17 00:00:00 2001 From: Peter Serwylo Date: Fri, 31 Mar 2017 09:40:56 +1100 Subject: [PATCH 19/19] Update AppDetailsAdapterTest for apps that don't have screenshots. While here, also added a couple of new test cases to better ensure the adapter plays nicely with its items when receiving an app with specific states. --- .../views/AppDetailsRecyclerViewAdapter.java | 2 +- .../fdroid/fdroid/data/AppProviderTest.java | 2 +- .../fdroid/fdroid/data/RepoProviderTest.java | 11 +++-- .../fdroid/views/AppDetailsAdapterTest.java | 46 +++++++++++++++++-- 4 files changed, 52 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java index 05fb567a1..a85b05672 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java +++ b/app/src/main/java/org/fdroid/fdroid/views/AppDetailsRecyclerViewAdapter.java @@ -136,7 +136,7 @@ public class AppDetailsRecyclerViewAdapter notifyDataSetChanged(); } - private void setShowVersions(boolean showVersions) { + void setShowVersions(boolean showVersions) { this.showVersions = showVersions; boolean itemsWereRemoved = items.removeAll(versions); int startIndex = items.indexOf(VIEWTYPE_VERSIONS) + 1; diff --git a/app/src/test/java/org/fdroid/fdroid/data/AppProviderTest.java b/app/src/test/java/org/fdroid/fdroid/data/AppProviderTest.java index 1abe19618..7e6697d19 100644 --- a/app/src/test/java/org/fdroid/fdroid/data/AppProviderTest.java +++ b/app/src/test/java/org/fdroid/fdroid/data/AppProviderTest.java @@ -304,6 +304,6 @@ public class AppProviderTest extends FDroidProviderTest { AppProvider.Helper.recalculatePreferredMetadata(context); - return AppProvider.Helper.findSpecificApp(context.getContentResolver(), id, 1, Cols.ALL); + return AppProvider.Helper.findSpecificApp(context.getContentResolver(), id, repoId, Cols.ALL); } } diff --git a/app/src/test/java/org/fdroid/fdroid/data/RepoProviderTest.java b/app/src/test/java/org/fdroid/fdroid/data/RepoProviderTest.java index 85c3b641f..ae6495920 100644 --- a/app/src/test/java/org/fdroid/fdroid/data/RepoProviderTest.java +++ b/app/src/test/java/org/fdroid/fdroid/data/RepoProviderTest.java @@ -23,6 +23,7 @@ package org.fdroid.fdroid.data; import android.app.Application; import android.content.ContentValues; +import android.content.Context; import android.net.Uri; import android.support.annotation.Nullable; @@ -125,6 +126,7 @@ public class RepoProviderTest extends FDroidProviderTest { assertEquals(4, RepoProvider.Helper.all(context).size()); Repo mock1 = insertRepo( + context, "https://mock-repo-1.example.com/fdroid/repo", "Just a made up repo", "ABCDEF1234567890", @@ -132,6 +134,7 @@ public class RepoProviderTest extends FDroidProviderTest { ); Repo mock2 = insertRepo( + context, "http://mock-repo-2.example.com/fdroid/repo", "Mock repo without a name", "0123456789ABCDEF" @@ -167,6 +170,7 @@ public class RepoProviderTest extends FDroidProviderTest { @Test public void canDeleteRepo() { Repo mock1 = insertRepo( + context, "https://mock-repo-1.example.com/fdroid/repo", "Just a made up repo", "ABCDEF1234567890", @@ -174,6 +178,7 @@ public class RepoProviderTest extends FDroidProviderTest { ); Repo mock2 = insertRepo( + context, "http://mock-repo-2.example.com/fdroid/repo", "Mock repo without a name", "0123456789ABCDEF" @@ -191,11 +196,11 @@ public class RepoProviderTest extends FDroidProviderTest { assertEquals(mock2.id, afterDelete.get(4).id); } - protected Repo insertRepo(String address, String description, String fingerprint) { - return insertRepo(address, description, fingerprint, null); + public Repo insertRepo(Context context, String address, String description, String fingerprint) { + return insertRepo(context, address, description, fingerprint, null); } - protected Repo insertRepo(String address, String description, String fingerprint, @Nullable String name) { + public static Repo insertRepo(Context context, String address, String description, String fingerprint, @Nullable String name) { ContentValues values = new ContentValues(); values.put(RepoTable.Cols.ADDRESS, address); values.put(RepoTable.Cols.DESCRIPTION, description); diff --git a/app/src/test/java/org/fdroid/fdroid/views/AppDetailsAdapterTest.java b/app/src/test/java/org/fdroid/fdroid/views/AppDetailsAdapterTest.java index a075e8783..1cb95465f 100644 --- a/app/src/test/java/org/fdroid/fdroid/views/AppDetailsAdapterTest.java +++ b/app/src/test/java/org/fdroid/fdroid/views/AppDetailsAdapterTest.java @@ -1,6 +1,7 @@ package org.fdroid.fdroid.views; import android.app.Application; +import android.content.ContentValues; import android.support.v7.widget.RecyclerView; import android.view.LayoutInflater; import android.view.ViewGroup; @@ -8,12 +9,17 @@ import android.view.ViewGroup; import com.nostra13.universalimageloader.core.ImageLoader; import com.nostra13.universalimageloader.core.ImageLoaderConfiguration; +import org.fdroid.fdroid.Assert; import org.fdroid.fdroid.BuildConfig; +import org.fdroid.fdroid.Preferences; import org.fdroid.fdroid.R; import org.fdroid.fdroid.data.Apk; import org.fdroid.fdroid.data.App; +import org.fdroid.fdroid.data.AppProviderTest; import org.fdroid.fdroid.data.FDroidProvider; import org.fdroid.fdroid.data.FDroidProviderTest; +import org.fdroid.fdroid.data.Repo; +import org.fdroid.fdroid.data.RepoProviderTest; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -27,22 +33,35 @@ import static org.junit.Assert.assertEquals; @RunWith(RobolectricTestRunner.class) public class AppDetailsAdapterTest extends FDroidProviderTest { + private App app; + @Before public void setup() { ImageLoader.getInstance().init(ImageLoaderConfiguration.createDefault(context)); + Preferences.setup(context); + + Repo repo = RepoProviderTest.insertRepo(context, "http://www.example.com/fdroid/repo", "", "", "Test Repo"); + app = AppProviderTest.insertApp(contentResolver, context, "com.example.app", "Test App", new ContentValues(), repo.getId()); } @After public void teardown() { ImageLoader.getInstance().destroy(); FDroidProvider.clearDbHelperSingleton(); + Preferences.clearSingletonForTesting(); } @Test - public void appWithNoVersions() { - App app = new App(); - app.name = "Test App"; - app.description = "Test App Description"; + public void appWithNoVersionsOrScreenshots() { + AppDetailsRecyclerViewAdapter adapter = new AppDetailsRecyclerViewAdapter(context, app, dummyCallbacks); + populateViewHolders(adapter); + + assertEquals(3, adapter.getItemCount()); + } + + @Test + public void appWithScreenshots() { + app.phoneScreenshots = new String[] {"screenshot1.png", "screenshot2.png"}; AppDetailsRecyclerViewAdapter adapter = new AppDetailsRecyclerViewAdapter(context, app, dummyCallbacks); populateViewHolders(adapter); @@ -51,6 +70,25 @@ public class AppDetailsAdapterTest extends FDroidProviderTest { } + @Test + public void appWithVersions() { + Assert.insertApk(context, app, 1); + Assert.insertApk(context, app, 2); + Assert.insertApk(context, app, 3); + + AppDetailsRecyclerViewAdapter adapter = new AppDetailsRecyclerViewAdapter(context, app, dummyCallbacks); + populateViewHolders(adapter); + + // Starts collapsed, now showing versions at all. + assertEquals(3, adapter.getItemCount()); + + adapter.setShowVersions(true); + assertEquals(6, adapter.getItemCount()); + + adapter.setShowVersions(false); + assertEquals(3, adapter.getItemCount()); + } + /** * Ensures that every single item in the adapter gets its view holder created and bound. * Doesn't care about what type of holder it should be, the adapter is able to figure all that