Merge branch 'add-new-index-format' into 'master'

Add new index format to support localization and graphics

See merge request 
This commit is contained in:
Hans-Christoph Steiner 2017-03-31 17:44:07 +00:00
commit c69f443506
43 changed files with 4362 additions and 160 deletions

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

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

@ -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.** {
*;
}

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

@ -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,38 +130,10 @@ public class AppDetails2 extends AppCompatActivity implements ShareChooserDialog
recyclerView.setAdapter(adapter);
// Load the feature graphic, if present
if (!TextUtils.isEmpty(app.iconUrl)) {
final FeatureImage featureImage = (FeatureImage) findViewById(R.id.feature_graphic);
DisplayImageOptions displayImageOptions = Utils.getImageLoadingOptions().build();
ImageLoader.getInstance().loadImage(app.iconUrl, 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) {
}
});
}
final FeatureImage featureImage = (FeatureImage) findViewById(R.id.feature_graphic);
DisplayImageOptions displayImageOptions = Utils.getImageLoadingOptions().build();
String featureGraphicUrl = app.getFeatureGraphicUrl(this);
featureImage.loadImageAndDisplay(ImageLoader.getInstance(), displayImageOptions, featureGraphicUrl, app.iconUrl);
}
private String getPackageNameFromIntent(Intent intent) {

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

@ -0,0 +1,359 @@
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.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;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.InjectableValues;
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.
* <p>
* 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";
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;
}
/**
* 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.
*
* @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 = getObjectMapperInstance(repo.getId());
JsonFactory f = mapper.getFactory();
JsonParser parser = f.createParser(indexInputStream);
HashMap<String, Object> repoMap = null;
App[] apps = null;
Map<String, String[]> requests = null;
Map<String, List<Apk>> 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;
}
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 = 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) {
List<Apk> apks = null;
if (packages != null) {
apks = packages.get(app.packageName);
}
if (apks == null) {
Log.i(TAG, "processIndexV1 empty packages");
apks = new ArrayList<Apk>(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<String, Object> 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<String, Object> repoMap, String key) {
Object value = repoMap.get(key);
if (value != null && value instanceof String) {
return (String) value;
}
return null;
}
private String[] getStringArrayRepoValue(Map<String, Object> repoMap, String key) {
Object value = repoMap.get(key);
if (value != null && value instanceof String[]) {
return (String[]) value;
}
return null;
}
private HashMap<String, Object> parseRepo(ObjectMapper mapper, JsonParser parser) throws IOException {
TypeReference<HashMap<String, Object>> typeRef = new TypeReference<HashMap<String, Object>>() {
};
parser.nextToken();
parser.nextToken();
return mapper.readValue(parser, typeRef);
}
private Map<String, String[]> parseRequests(ObjectMapper mapper, JsonParser parser) throws IOException {
TypeReference<HashMap<String, String[]>> typeRef = new TypeReference<HashMap<String, String[]>>() {
};
parser.nextToken(); // START_OBJECT
return mapper.readValue(parser, typeRef);
}
private App[] parseApps(ObjectMapper mapper, JsonParser parser) throws IOException {
TypeReference<App[]> typeRef = new TypeReference<App[]>() {
};
parser.nextToken(); // START_ARRAY
return mapper.readValue(parser, typeRef);
}
private Map<String, List<Apk>> parsePackages(ObjectMapper mapper, JsonParser parser) throws IOException {
TypeReference<HashMap<String, List<Apk>>> typeRef = new TypeReference<HashMap<String, List<Apk>>>() {
};
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.
* <p>
* Index V1 works with two copies of the signing certificate:
* <li>in the downloaded jar</li>
* <li>stored in the local database</li>
* <p>
* 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.
* <p>
* This is also responsible for adding the {@link Repo} instance to the
* database for the first time.
* <p>
* 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!");
}
}

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

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

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

@ -8,8 +8,9 @@ import android.os.Build;
import android.os.Parcel;
import android.os.Parcelable;
import android.support.annotation.NonNull;
import org.fdroid.fdroid.BuildConfig;
import com.fasterxml.jackson.annotation.JacksonInject;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.fdroid.fdroid.RepoXMLHandler;
import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.data.Schema.ApkTable.Cols;
@ -18,17 +19,50 @@ 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}.
* <p>
* <b>Do not rename these instance variables without careful consideration!</b>
* They are mapped to JSON field names, the {@code fdroidserver} internal variable
* names, and the {@code fdroiddata} YAML field names. Only the instance variables
* decorated with {@code @JsonIgnore} are not directly mapped.
* <p>
* <b>NOTE:</b>If an instance variable is only meant for internal state, and not for
* representing data coming from the server, then it must also be decorated with
* {@code @JsonIgnore} to prevent abuse! The tests for
* {@link org.fdroid.fdroid.IndexV1Updater} will also have to be updated.
*
* @see <a href="https://gitlab.com/fdroid/fdroiddata">fdroiddata</a>
* @see <a href="https://gitlab.com/fdroid/fdroidserver">fdroidserver</a>
*/
public class Apk extends ValueObject implements Comparable<Apk>, 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
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.
@JacksonInject("repoId")
public long repoId; // the database ID of the repo it comes from
// 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
@ -56,13 +90,7 @@ public class Apk extends ValueObject implements Comparable<Apk>, 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
@ -71,15 +99,8 @@ public class Apk extends ValueObject implements Comparable<Apk>, Parcelable {
*/
public String srcname;
public int repoVersion;
public String repoAddress;
public String[] incompatibleReasons;
/**
* A descriptive text for what has changed in the latest version.
*/
public String whatsNew;
public String[] antiFeatures;
/**
@ -94,16 +115,18 @@ public class Apk extends ValueObject implements Comparable<Apk>, 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.
*
* <p>
* 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}
*
* <p>
* <ul>
* <li>{@link Apk#packageName}
* <li>{@link Apk#versionName}
* <li>{@link Apk#versionCode}
* <li>{@link Apk#hash}
* <li>{@link Apk#hashType}
* </ul>
* <p>
* 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
@ -125,7 +148,7 @@ public class Apk extends ValueObject implements Comparable<Apk>, 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) {
@ -189,7 +212,7 @@ public class Apk extends ValueObject implements Comparable<Apk>, 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);
@ -217,13 +240,6 @@ public class Apk extends ValueObject implements Comparable<Apk>, 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() {
@ -232,6 +248,7 @@ public class Apk extends ValueObject implements Comparable<Apk>, Parcelable {
}
}
@JsonIgnore // prevent tests from failing due to nulls in checkRepoAddress()
public String getUrl() {
checkRepoAddress();
return repoAddress + "/" + apkName.replace(" ", "%20");
@ -303,7 +320,7 @@ public class Apk extends ValueObject implements Comparable<Apk>, 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);
@ -347,7 +364,7 @@ public class Apk extends ValueObject implements Comparable<Apk>, 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);
@ -378,7 +395,7 @@ public class Apk extends ValueObject implements Comparable<Apk>, 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();
@ -429,4 +446,27 @@ public class Apk extends ValueObject implements Comparable<Apk>, 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<String> 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()]);
}
}

@ -16,6 +16,10 @@ 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;
import org.apache.commons.io.filefilter.RegexFileFilter;
import org.fdroid.fdroid.AppFilter;
import org.fdroid.fdroid.FDroidApp;
@ -30,39 +34,94 @@ 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;
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}.
* <p>
* <b>Do not rename these instance variables without careful consideration!</b>
* They are mapped to JSON field names, the {@code fdroidserver} internal variable
* names, and the {@code fdroiddata} YAML field names. Only the instance variables
* decorated with {@code @JsonIgnore} are not directly mapped.
* <p>
* <b>NOTE:</b>If an instance variable is only meant for internal state, and not for
* representing data coming from the server, then it must also be decorated with
* {@code @JsonIgnore} to prevent abuse! The tests for
* {@link org.fdroid.fdroid.IndexV1Updater} will also have to be updated.
*
* @see <a href="https://gitlab.com/fdroid/fdroiddata">fdroiddata</a>
* @see <a href="https://gitlab.com/fdroid/fdroidserver">fdroidserver</a>
*/
public class App extends ValueObject implements Comparable<App>, Parcelable {
@JsonIgnore
private static final String TAG = "App";
// 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.
* 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.
*/
@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;
@JacksonInject("repoId")
public long repoId;
// the remaining properties are set directly from the index metadata
public String packageName = "unknown";
public String name = "Unknown";
public String summary = "Unknown application";
public String icon;
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;
@ -118,8 +177,6 @@ public class App extends ValueObject implements Comparable<App>, Parcelable {
@Deprecated
public String[] requirements;
private AppPrefs prefs;
/**
* To be displayed at 48dp (x1.0)
*/
@ -130,16 +187,6 @@ public class App extends ValueObject implements Comparable<App>, 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";
}
@ -246,6 +293,30 @@ public class App extends ValueObject implements Comparable<App>, 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;
@ -276,6 +347,175 @@ public class App extends ValueObject implements Comparable<App>, 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.
* <p>
* 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<String, Map<String, Object>> localized) { // NOPMD
Locale defaultLocale = Locale.getDefault();
String languageTag = defaultLocale.getLanguage();
String localeTag = languageTag + "-" + defaultLocale.getCountry();
Set<String> locales = localized.keySet();
Set<String> 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<String, Map<String, Object>> localized,
Set<String> 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<String, Map<String, Object>> localized,
Set<String> 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<String, Map<String, Object>> localized,
Set<String> locales, String key) {
try {
for (String locale : locales) {
if (localized.containsKey(locale)) {
ArrayList<String> entry = (ArrayList<String>) 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<String> list = new ArrayList<>();
if (phoneScreenshots != null) {
Collections.addAll(list, phoneScreenshots);
}
if (sevenInchScreenshots != null) {
Collections.addAll(list, sevenInchScreenshots);
}
if (tenInchScreenshots != null) {
Collections.addAll(list, tenInchScreenshots);
}
if (tvScreenshots != null) {
Collections.addAll(list, tvScreenshots);
}
if (wearScreenshots != null) {
Collections.addAll(list, wearScreenshots);
}
String[] result = new String[list.size()];
int i = 0;
for (String url : list) {
result[i] = repo.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}.
@ -508,6 +748,14 @@ public class App extends ValueObject implements Comparable<App>, 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;
@ -657,6 +905,14 @@ public class App extends ValueObject implements Comparable<App>, 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);
@ -696,6 +952,14 @@ public class App extends ValueObject implements Comparable<App>, 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());
@ -703,6 +967,7 @@ public class App extends ValueObject implements Comparable<App>, Parcelable {
this.id = in.readLong();
}
@JsonIgnore
public static final Parcelable.Creator<App> CREATOR = new Parcelable.Creator<App>() {
@Override
public App createFromParcel(Parcel source) {

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

@ -34,6 +34,19 @@ 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.
* <p>
* <b>Do not rename these instance variables without careful consideration!</b>
* They are mapped to JSON field names, the {@code fdroidserver} internal variable
* names, and the {@code fdroiddata} YAML field names. Only the instance variables
* decorated with {@code @JsonIgnore} are not directly mapped.
*
* @see <a href="https://gitlab.com/fdroid/fdroiddata">fdroiddata</a>
* @see <a href="https://gitlab.com/fdroid/fdroidserver">fdroidserver</a>
*/
public class Repo extends ValueObject {
public static final int VERSION_DENSITY_SPECIFIC_ICONS = 11;
@ -42,6 +55,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;
@ -148,6 +163,9 @@ public class Repo extends ValueObject {
}
}
/**
* @return the database ID to find this repo in the database
*/
public long getId() {
return id;
}

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

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

@ -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<String> pathParts = uri.getPathSegments();
String packageName = pathParts.get(2);
long repoId = Long.parseLong(pathParts.get(1));

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

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

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

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

@ -32,11 +32,10 @@ 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.BuildConfig;
import org.fdroid.fdroid.Preferences;
import org.fdroid.fdroid.R;
import org.fdroid.fdroid.Utils;
@ -126,7 +125,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);
@ -135,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;
@ -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);
}
@ -805,7 +814,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));

@ -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<RecyclerView.ViewHolder> 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<Recycle
if (position == selectedPosition) {
this.selectedView = vh.itemView;
}
// For now, use the screenshot placeholder
ImageLoader.getInstance().displayImage(ImageDownloader.Scheme.ASSETS.wrap("screenshot_placeholder.png"), vh.image, displayImageOptions);
ImageLoader.getInstance().displayImage(screenshots[position],
vh.image, displayImageOptions);
}
@Override
@ -59,7 +60,7 @@ public class ScreenShotsRecyclerViewAdapter extends RecyclerView.Adapter<Recycle
@Override
public int getItemCount() {
return 7;
return screenshots.length;
}
@Override

@ -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) { }
}
}

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

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

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

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

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

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

@ -0,0 +1,442 @@
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.mock.RepoDetails;
import org.junit.After;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
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;
import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.core.IsNot.not;
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)
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";
@Before
public void setup() {
Preferences.setup(context);
}
@After
public void tearDown() {
Preferences.clearSingletonForTesting();
}
@Test
public void testIndexV1Processing() throws IOException, RepoUpdater.UpdateException {
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);
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.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");
}
@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 = 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);
JsonFactory f = mapper.getFactory();
JsonParser parser = f.createParser(TestUtils.copyResourceToTempFile("guardianproject_index-v1.json"));
Repo repo = null;
App[] apps = null;
Map<String, String[]> requests = null;
Map<String, List<Apk>> 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<String> installRequests = new ArrayList<>();
for (RepoPushRequest repoPushRequest : indexV0Details.repoPushRequestList) {
if ("install".equals(repoPushRequest.request)) {
installRequests.add(repoPushRequest.packageName);
}
}
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.
* <p>
* 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 {
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",
"repoId",
"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",
"TAG",
};
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.
* <p>
* 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[]{
"added",
"antiFeatures",
"apkName",
"appId",
"features",
"hash",
"hashType",
"incompatibleReasons",
"maxSdkVersion",
"minSdkVersion",
"nativecode",
"obbMainFile",
"obbMainFileSha256",
"obbPatchFile",
"obbPatchFileSha256",
"packageName",
"repoId",
"requestedPermissions",
"sig",
"size",
"srcname",
"targetSdkVersion",
"versionCode",
"versionName",
};
String[] ignoredInApk = new String[]{
"compatible",
"CREATOR",
"installedFile",
"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 = IndexV1Updater.getObjectMapperInstance(FAKE_REPO_ID);
String objectAsString = mapper.writeValueAsString(instance);
Set<String> 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 = IndexV1Updater.getObjectMapperInstance(FAKE_REPO_ID);
JsonFactory f = mapper.getFactory();
JsonParser parser = f.createParser(TestUtils.copyResourceToTempFile("all_fields_index-v1.json"));
Repo repo = null;
App[] apps = null;
Map<String, List<Apk>> 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<String> 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<String> packageFields = getFields(apk);
for (String field : packageFields) {
assertNotEquals("secret", field);
}
}
private Set<String> getFields(Object instance) {
SortedSet<String> 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();
parser.nextToken();
ObjectReader repoReader = mapper.readerFor(Repo.class);
return repoReader.readValue(parser, Repo.class);
}
private Map<String, String[]> parseRequests(ObjectMapper mapper, JsonParser parser) throws IOException {
TypeReference<HashMap<String, String[]>> typeRef = new TypeReference<HashMap<String, String[]>>() {
};
parser.nextToken(); // START_OBJECT
return mapper.readValue(parser, typeRef);
}
private App[] parseApps(ObjectMapper mapper, JsonParser parser) throws IOException {
TypeReference<App[]> typeRef = new TypeReference<App[]>() {
};
parser.nextToken(); // START_ARRAY
return mapper.readValue(parser, typeRef);
}
private Map<String, List<Apk>> parsePackages(ObjectMapper mapper, JsonParser parser) throws IOException {
TypeReference<HashMap<String, List<Apk>>> typeRef = new TypeReference<HashMap<String, List<Apk>>>() {
};
parser.nextToken(); // START_OBJECT
return mapper.readValue(parser, typeRef);
}
@NonNull
private RepoDetails getFromFile(String indexFilename, int pushRequests) {
return RepoXMLHandlerTest.getFromFile(getClass().getClassLoader(), indexFilename, pushRequests);
}
}

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

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

@ -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 <b>Description</b>";
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

@ -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": "<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 \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 <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>",
"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"
}
]
}
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,790 @@
<?xml version="1.0" encoding="utf-8"?>
<fdroid>
<repo icon="guardianproject.png" name="Guardian Project Official Releases" pubkey="308204e1308202c9a0030201020204483450fa300d06092a864886f70d01010b050030213110300e060355040b1307462d44726f6964310d300b06035504031304736f7661301e170d3136303832333133333131365a170d3434303130393133333131365a30213110300e060355040b1307462d44726f6964310d300b06035504031304736f766130820222300d06092a864886f70d01010105000382020f003082020a0282020100dfdcd120f3ab224999dddf4ea33ea588d295e4d7130bef48c143e9d76e5c0e0e9e5d45e64208e35feebc79a83f08939dd6a343b7d1e2179930a105a1249ccd36d88ff3feffc6e4dc53dae0163a7876dd45ecc1ddb0adf5099aa56c1a84b52affcd45d0711ffa4de864f35ac0333ebe61ea8673eeda35a88f6af678cc4d0f80b089338ac8f2a8279a64195c611d19445cab3fd1a020afed9bd739bb95142fb2c00a8f847db5ef3325c814f8eb741bacf86ed3907bfe6e4564d2de5895df0c263824e0b75407589bae2d3a4666c13b92102d8781a8ee9bb4a5a1a78c4a9c21efdaf5584da42e84418b28f5a81d0456a3dc5b420991801e6b21e38c99bbe018a5b2d690894a114bc860d35601416aa4dc52216aff8a288d4775cddf8b72d45fd2f87303a8e9c0d67e442530be28eaf139894337266e0b33d57f949256ab32083bcc545bc18a83c9ab8247c12aea037e2b68dee31c734cb1f04f241d3b94caa3a2b258ffaf8e6eae9fbbe029a934dc0a0859c5f120334812693a1c09352340a39f2a678dbc1afa2a978bfee43afefcb7e224a58af2f3d647e5745db59061236b8af6fcfd93b3602f9e456978534f3a7851e800071bf56da80401c81d91c45f82568373af0576b1cc5eef9b85654124b6319770be3cdba3fbebe3715e8918fb6c8966624f3d0e815effac3d2ee06dd34ab9c693218b2c7c06ba99d6b74d4f17b8c3cb0203010001a321301f301d0603551d0e04160414d62bee9f3798509546acc62eb1de14b08b954d4f300d06092a864886f70d01010b05000382020100743f7c5692085895f9d1fffad390fb4202c15f123ed094df259185960fd6dadf66cb19851070f180297bba4e6996a4434616573b375cfee94fee73a4505a7ec29136b7e6c22e6436290e3686fe4379d4e3140ec6a08e70cfd3ed5b634a5eb5136efaaabf5f38e0432d3d79568a556970b8cfba2972f5d23a3856d8a981b9e9bbbbb88f35e708bde9cbc5f681cbd974085b9da28911296fe2579fa64bbe9fa0b93475a7a8db051080b0c5fade0d1c018e7858cd4cbe95145b0620e2f632cbe0f8af9cbf22e2fdaa72245ae31b0877b07181cc69dd2df74454251d8de58d25e76354abe7eb690f22e59b08795a8f2c98c578e0599503d9085927634072c82c9f82abd50fd12b8fd1a9d1954eb5cc0b4cfb5796b5aaec0356643b4a65a368442d92ef94edd3ac6a2b7fe3571b8cf9f462729228aab023ef9183f73792f5379633ccac51079177d604c6bc1873ada6f07d8da6d68c897e88a5fa5d63fdb8df820f46090e0716e7562dd3c140ba279a65b996f60addb0abe29d4bf2f5abe89480771d492307b926d91f02f341b2148502903c43d40f3c6c86a811d060711f0698b384acdcc0add44eb54e42962d3d041accc715afd49407715adc09350cb55e8d9281a3b0b6b5fcd91726eede9b7c8b13afdebb2c2b377629595f1096ba62fb14946dbac5f3c5f0b4e5b712e7acc7dcf6c46cdc5e6d6dfdeee55a0c92c2d70f080ac6" timestamp="1488828510" url="https://guardianproject.info/fdroid/repo" version="18">
<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. </description>
<mirror>http://bdf2wcxujkg6qqff.onion/fdroid/repo</mirror>
<mirror>https://guardianproject.info/fdroid/repo</mirror>
<mirror>https://s3.amazonaws.com/guardianproject/fdroid/repo</mirror>
</repo>
<install packageName="org.torproject.android"/>
<install packageName="info.guardianproject.orfox"/>
<application id="info.guardianproject.cacert">
<id>info.guardianproject.cacert</id>
<added>2013-08-19</added>
<lastupdated>2013-10-31</lastupdated>
<name>CACertMan</name>
<summary>Disable untrusted certificates</summary>
<icon>info.guardianproject.cacert.4.png</icon>
<desc>&lt;p&gt;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.&lt;/p&gt;&lt;p&gt;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.&lt;/p&gt;&lt;p&gt;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 doesnt work, then you will need to make your /system partition read-write by using a file explorer like &lt;a href="fdroid.app:com.ghostsq.commander"&gt;Ghost Commander&lt;/a&gt; or via a command in &lt;a href="fdroid.app:jackpal.androidterm"&gt;Terminal Emulator&lt;/a&gt;.&lt;/p&gt;</desc>
<license>GPLv3</license>
<categories>Security,GuardianProject</categories>
<category>Security</category>
<web>https://guardianproject.info/2011/09/05/cacertman-app-to-address-diginotar-other-bad-cas</web>
<source>https://github.com/guardianproject/cacert</source>
<tracker>https://github.com/guardianproject/cacert/issues</tracker>
<marketversion/>
<marketvercode>999999999</marketvercode>
<requirements>root</requirements>
<package>
<version>0.0.2.20111012</version>
<versioncode>4</versioncode>
<apkname>CACertMan-0.0.2-alpha-20111011.apk</apkname>
<hash type="sha256">251ebd40ce4a281a2292692707fb1e9c91428994cbad80a416a297db51069eb8</hash>
<size>172263</size>
<sdkver>7</sdkver>
<targetSdkVersion>7</targetSdkVersion>
<added>2013-08-19</added>
<sig>a0eeebb161f946e3516945fae8a92a3e</sig>
</package>
<package>
<version>0.0.2-20110906</version>
<versioncode>3</versioncode>
<apkname>CACertMan-0.0.2-20110906.apk</apkname>
<hash type="sha256">c217c49abe5134007ceb2623a6189a73fa02af9d2b2bbcc5cbc4cb5da7b36a5d</hash>
<size>170305</size>
<sdkver>8</sdkver>
<targetSdkVersion>8</targetSdkVersion>
<added>2013-10-31</added>
<sig>a0eeebb161f946e3516945fae8a92a3e</sig>
</package>
</application>
<application id="org.witness.informacam.app">
<id>org.witness.informacam.app</id>
<added>2013-12-11</added>
<lastupdated>2015-11-03</lastupdated>
<name>CameraV</name>
<summary>An InformaCam app to generate verifiable media</summary>
<icon>org.witness.informacam.app.206.png</icon>
<desc>&lt;p&gt;An InformaCam app to generate verifiable media.&lt;/p&gt;</desc>
<license>GPLv3</license>
<categories>Development,GuardianProject</categories>
<category>Development</category>
<web>https://guardianproject.info/apps/camerav/</web>
<source>https://github.com/guardianproject/CameraV</source>
<tracker>https://dev.guardianproject.info/projects/informacam/issues</tracker>
<bitcoin>1Fi5xUHiAPRKxHvyUGVFGt9extBe8Srdbk</bitcoin>
<marketversion/>
<marketvercode>9999999</marketvercode>
<package>
<version>0.2.6</version>
<versioncode>206</versioncode>
<apkname>CameraVApp-release-0.2.6.apk</apkname>
<hash type="sha256">508f453e26c8c83dba858b53b21d909d549fe5646d01eb198c96c22d8e521e7c</hash>
<size>24123646</size>
<sdkver>16</sdkver>
<targetSdkVersion>21</targetSdkVersion>
<added>2015-11-03</added>
<sig>d70ac6a02b53ebdd1354ea7af7b9ceee</sig>
<permissions>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</permissions>
<nativecode>armeabi,armeabi-v7a,x86</nativecode>
</package>
<package>
<version>0.2.4</version>
<versioncode>204</versioncode>
<apkname>CameraV-release-0.2.4.apk</apkname>
<hash type="sha256">a10eefaed5a12c353525b07e655f6959fe1eb06cd5c549be56afaca6db0c6ce0</hash>
<size>24062229</size>
<sdkver>16</sdkver>
<targetSdkVersion>21</targetSdkVersion>
<added>2015-10-02</added>
<sig>d70ac6a02b53ebdd1354ea7af7b9ceee</sig>
<permissions>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</permissions>
<nativecode>armeabi,armeabi-v7a,x86</nativecode>
</package>
<package>
<version>0.2.2</version>
<versioncode>202</versioncode>
<apkname>CameraVApp-release-0.2.2.apk</apkname>
<hash type="sha256">8b17cbe2a5cb777b49f5ef67a390f9d9d68765c90213ac64f3ca0456860dc9b7</hash>
<size>24283932</size>
<sdkver>16</sdkver>
<targetSdkVersion>21</targetSdkVersion>
<added>2015-09-16</added>
<sig>d70ac6a02b53ebdd1354ea7af7b9ceee</sig>
<permissions>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</permissions>
<nativecode>armeabi,armeabi-v7a,x86</nativecode>
</package>
</application>
<application id="info.guardianproject.otr.app.im">
<id>info.guardianproject.otr.app.im</id>
<added>2013-03-19</added>
<lastupdated>2016-12-06</lastupdated>
<name>ChatSecure</name>
<summary>Instant Messaging client with OTR</summary>
<icon>info.guardianproject.otr.app.im.1423001.png</icon>
<desc>&lt;p&gt;XMPP (Jabber) client that can do end-to-end encryption using the &lt;a href="http://en.wikipedia.org/wiki/Off-the-Record_Messaging"&gt;Off-the-Record Messaging&lt;/a&gt; protocol and can anonymize your chats via the &lt;a href="fdroid.app:org.torproject.android"&gt;Orbot&lt;/a&gt; app (root not required).&lt;/p&gt;&lt;p&gt;The app used to be called GibberBot.&lt;/p&gt;</desc>
<license>Apache2</license>
<categories>Internet,GuardianProject</categories>
<category>Internet</category>
<web>https://dev.guardianproject.info/projects/gibberbot</web>
<source>https://github.com/guardianproject/Gibberbot</source>
<tracker>https://dev.guardianproject.info/projects/gibberbot</tracker>
<marketversion/>
<marketvercode>999999999</marketvercode>
<package>
<version>14.2.3</version>
<versioncode>1423001</versioncode>
<apkname>ChatSecure-v14.2.3a.apk</apkname>
<hash type="sha256">36d7d71c8a2115bdd2bd63bb639af286ee3242cce11cdb5c53378d1a7f35528e</hash>
<size>10502397</size>
<sdkver>9</sdkver>
<targetSdkVersion>21</targetSdkVersion>
<added>2016-02-03</added>
<sig>a0eeebb161f946e3516945fae8a92a3e</sig>
<permissions>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</permissions>
<nativecode>armeabi,x86</nativecode>
</package>
<package>
<version>14.2.2</version>
<versioncode>1422001</versioncode>
<apkname>ChatSecure-v14.2.2.apk</apkname>
<hash type="sha256">9d4620fec0c7837ddffccde7918d7a7db0976fbcd361b96659abd93b5cc0d9e3</hash>
<size>10502135</size>
<sdkver>9</sdkver>
<targetSdkVersion>21</targetSdkVersion>
<added>2016-12-06</added>
<sig>a0eeebb161f946e3516945fae8a92a3e</sig>
<permissions>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</permissions>
<nativecode>armeabi,x86</nativecode>
</package>
<package>
<version>14.2.1</version>
<versioncode>1421001</versioncode>
<apkname>ChatSecure-v14.2.1.apk</apkname>
<hash type="sha256">f82a3a7a823f5540b335743eb1399d0fd1f61bc68958750b5ef6aa0d95ad9a54</hash>
<size>10463010</size>
<sdkver>9</sdkver>
<targetSdkVersion>21</targetSdkVersion>
<added>2015-08-24</added>
<sig>a0eeebb161f946e3516945fae8a92a3e</sig>
<permissions>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</permissions>
<nativecode>armeabi,x86</nativecode>
</package>
</application>
<application id="info.guardianproject.soundrecorder">
<id>info.guardianproject.soundrecorder</id>
<added>2013-12-13</added>
<lastupdated>2013-12-13</lastupdated>
<name>ChatSecureVoicePlugin</name>
<summary>ChatSecure Voice Messaging</summary>
<icon>info.guardianproject.soundrecorder.2.png</icon>
<desc>&lt;p&gt;This is a plugin for &lt;a href="fdroid.app:info.guardianproject.otr.app.im"&gt;ChatSecure&lt;/a&gt;. 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.&lt;/p&gt;&lt;p&gt; * For use with &lt;a href="fdroid.app:info.guardianproject.otr.app.im"&gt;ChatSecure&lt;/a&gt;'s encrypted "Off-the-record" data stream * Works over Tor - the ONLY Onion-routed voice messaging system, for total anonymity&lt;/p&gt;</desc>
<license>SIL Open Font License, MIT License and the CC 3.0 License [CC-By with attribution requirement waived]</license>
<categories>Multimedia,Security,GuardianProject</categories>
<category>Multimedia</category>
<web>https://guardianproject.info/apps/chatsecure</web>
<source>https://github.com/guardianproject/ChatSecureVoicePlugin</source>
<tracker>https://dev.guardianproject.info/projects/chatsecure/issues</tracker>
<marketversion/>
<marketvercode>999999999</marketvercode>
<package>
<version>0.2</version>
<versioncode>2</versioncode>
<apkname>ChatSecureVoiceMessaging-0.2.apk</apkname>
<hash type="sha256">abae18cc9cfa62fca5dce072c4c50d41b4fece506967ce9a3e2711cd1031dbee</hash>
<size>394212</size>
<sdkver>10</sdkver>
<targetSdkVersion>10</targetSdkVersion>
<added>2013-12-13</added>
<sig>a0eeebb161f946e3516945fae8a92a3e</sig>
<permissions>READ_PHONE_STATE,READ_EXTERNAL_STORAGE,RECORD_AUDIO,WRITE_EXTERNAL_STORAGE,WAKE_LOCK</permissions>
</package>
</application>
<application id="info.guardianproject.checkey">
<id>info.guardianproject.checkey</id>
<added>2014-07-12</added>
<lastupdated>2015-03-09</lastupdated>
<name>Checkey</name>
<summary>Info on local apps</summary>
<icon>info.guardianproject.checkey.102.png</icon>
<desc>&lt;p&gt;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.&lt;/p&gt;</desc>
<license>GPLv3</license>
<categories>Development,GuardianProject</categories>
<category>Development</category>
<web>https://dev.guardianproject.info/projects/checkey</web>
<source>https://github.com/guardianproject/checkey</source>
<tracker>https://dev.guardianproject.info/projects/checkey/issues</tracker>
<bitcoin>1Fi5xUHiAPRKxHvyUGVFGt9extBe8Srdbk</bitcoin>
<marketversion/>
<marketvercode>9999999</marketvercode>
<package>
<version>0.1.2</version>
<versioncode>102</versioncode>
<apkname>Checkey-0.1.2.apk</apkname>
<hash type="sha256">754701dbac52de5ca3930c2393970c03ef9aa07d1456911e9bf254d6014e0645</hash>
<size>842881</size>
<sdkver>8</sdkver>
<targetSdkVersion>21</targetSdkVersion>
<added>2015-03-09</added>
<sig>d70ac6a02b53ebdd1354ea7af7b9ceee</sig>
<permissions>INTERNET</permissions>
</package>
<package>
<version>0.1.1</version>
<versioncode>101</versioncode>
<apkname>Checkey-0.1.1.apk</apkname>
<hash type="sha256">2d81f339bb69626af42e8868dc6928c9072ebcbae76e1ff5ac8172e78ebe9cdd</hash>
<size>967083</size>
<sdkver>8</sdkver>
<targetSdkVersion>21</targetSdkVersion>
<added>2015-01-28</added>
<sig>d70ac6a02b53ebdd1354ea7af7b9ceee</sig>
<permissions>INTERNET</permissions>
</package>
<package>
<version>0.1</version>
<versioncode>1</versioncode>
<apkname>Checkey-0.1.apk</apkname>
<hash type="sha256">a8e3c102d5279a3029d0eebdeda2ffdbe1f8a3493ea7dbdc31a11affc708ee57</hash>
<size>878679</size>
<sdkver>8</sdkver>
<targetSdkVersion>19</targetSdkVersion>
<added>2014-07-12</added>
<sig>d70ac6a02b53ebdd1354ea7af7b9ceee</sig>
<permissions>INTERNET</permissions>
</package>
</application>
<application id="info.guardianproject.courier">
<id>info.guardianproject.courier</id>
<added>2014-05-14</added>
<lastupdated>2014-06-26</lastupdated>
<name>Courier</name>
<summary>Privacy-aware RSS feed reader</summary>
<icon>info.guardianproject.courier.15.png</icon>
<desc>&lt;p&gt;No description available&lt;/p&gt;</desc>
<license>GPLv3</license>
<categories>Reading,GuardianProject</categories>
<category>Reading</category>
<web/>
<source>https://github.com/guardianproject/securereader</source>
<tracker/>
<marketversion/>
<marketvercode>999999999</marketvercode>
<package>
<version>0.1.9</version>
<versioncode>15</versioncode>
<apkname>Courier-0.1.9.apk</apkname>
<hash type="sha256">bf6566da1f90831887f5bf5605f8d816b1f7f694969459dec599b8bc01a827d3</hash>
<size>16484753</size>
<sdkver>9</sdkver>
<targetSdkVersion>15</targetSdkVersion>
<added>2014-06-26</added>
<sig>d70ac6a02b53ebdd1354ea7af7b9ceee</sig>
<permissions>READ_EXTERNAL_STORAGE,BLUETOOTH_ADMIN,VIBRATE,ACCESS_WIFI_STATE,INTERNET,ACCESS_NETWORK_STATE,BLUETOOTH,WRITE_EXTERNAL_STORAGE</permissions>
<nativecode>armeabi,x86</nativecode>
</package>
<package>
<version>0.1.8</version>
<versioncode>14</versioncode>
<apkname>Courier-0.1.8.apk</apkname>
<hash type="sha256">e013db095e8da843fae5ac44be6152e51377ee717e5c8a7b6d913d7720566b5a</hash>
<size>16536125</size>
<sdkver>9</sdkver>
<targetSdkVersion>15</targetSdkVersion>
<added>2014-05-14</added>
<sig>d70ac6a02b53ebdd1354ea7af7b9ceee</sig>
<permissions>READ_EXTERNAL_STORAGE,BLUETOOTH_ADMIN,VIBRATE,ACCESS_WIFI_STATE,INTERNET,ACCESS_NETWORK_STATE,BLUETOOTH,WRITE_EXTERNAL_STORAGE</permissions>
<nativecode>armeabi,x86</nativecode>
</package>
</application>
<application id="info.guardianproject.lildebi">
<id>info.guardianproject.lildebi</id>
<added>2013-02-06</added>
<lastupdated>2015-01-26</lastupdated>
<name>Lil' Debi</name>
<summary>Run Debian on your phone</summary>
<icon>info.guardianproject.lildebi.5400.png</icon>
<desc>&lt;p&gt;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.&lt;/p&gt;&lt;p&gt;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 &lt;a href="fdroid.app:jackpal.androidterm"&gt;Terminal Emulator&lt;/a&gt;—just run `/debian/shell` to get a Debian shell.&lt;/p&gt;&lt;p&gt;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.&lt;/p&gt;&lt;p&gt;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.&lt;/p&gt;&lt;p&gt;Requires root: Yes, because it needs to run debootstrap, create dirs in /, mount/umount, etc.&lt;/p&gt;</desc>
<license>GPLv3</license>
<categories>Development,GuardianProject</categories>
<category>Development</category>
<web>https://github.com/guardianproject/lildebi/wiki</web>
<source>https://github.com/guardianproject/lildebi</source>
<tracker>https://github.com/guardianproject/lildebi/issues</tracker>
<bitcoin>1Fi5xUHiAPRKxHvyUGVFGt9extBe8Srdbk</bitcoin>
<marketversion/>
<marketvercode>999999999</marketvercode>
<requirements>root</requirements>
<package>
<version>0.5.4</version>
<versioncode>5400</versioncode>
<apkname>LilDebi-0.5.4-release.apk</apkname>
<hash type="sha256">2c490376d8853fae04e79541f5d61e66a42ed0e890208945a11036c4a7b111da</hash>
<size>1876705</size>
<sdkver>8</sdkver>
<targetSdkVersion>20</targetSdkVersion>
<maxsdkver>20</maxsdkver>
<added>2015-01-26</added>
<sig>a0eeebb161f946e3516945fae8a92a3e</sig>
<permissions>RECEIVE_BOOT_COMPLETED,READ_EXTERNAL_STORAGE,ACCESS_SUPERUSER,WRITE_EXTERNAL_STORAGE,INTERNET,ACCESS_NETWORK_STATE,jackpal.androidterm.permission.RUN_SCRIPT,WAKE_LOCK</permissions>
</package>
<package>
<version>0.5.3</version>
<versioncode>5300</versioncode>
<apkname>LilDebi-0.5.3-release.apk</apkname>
<hash type="sha256">01c5a8e1fd778c141e70633d14f1b69228d6f492961098616e0446c116cf9e44</hash>
<size>1879560</size>
<sdkver>8</sdkver>
<targetSdkVersion>19</targetSdkVersion>
<added>2015-01-26</added>
<sig>a0eeebb161f946e3516945fae8a92a3e</sig>
<permissions>RECEIVE_BOOT_COMPLETED,READ_EXTERNAL_STORAGE,ACCESS_SUPERUSER,WRITE_EXTERNAL_STORAGE,INTERNET,ACCESS_NETWORK_STATE,jackpal.androidterm.permission.RUN_SCRIPT,WAKE_LOCK</permissions>
</package>
<package>
<version>0.5.2</version>
<versioncode>5200</versioncode>
<apkname>LilDebi-0.5.2-release.apk</apkname>
<hash type="sha256">07fa3dfb690e44eb540942ba2a51718c72351c91a253a56a0c90649f6d8903dd</hash>
<size>1861790</size>
<sdkver>8</sdkver>
<targetSdkVersion>19</targetSdkVersion>
<added>2014-10-22</added>
<sig>a0eeebb161f946e3516945fae8a92a3e</sig>
<permissions>RECEIVE_BOOT_COMPLETED,READ_EXTERNAL_STORAGE,ACCESS_SUPERUSER,WRITE_EXTERNAL_STORAGE,INTERNET,ACCESS_NETWORK_STATE,jackpal.androidterm.permission.RUN_SCRIPT,WAKE_LOCK</permissions>
</package>
</application>
<application id="info.guardianproject.locationprivacy">
<id>info.guardianproject.locationprivacy</id>
<added>2015-01-29</added>
<lastupdated>2016-12-06</lastupdated>
<name>LocationPrivacy</name>
<summary>privacy filters for when you are sharing your location</summary>
<icon>info.guardianproject.locationprivacy.30.png</icon>
<desc>&lt;p&gt;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.&lt;/p&gt;&lt;p&gt;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.&lt;/p&gt;&lt;p&gt;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&lt;/p&gt;&lt;p&gt;Dont see your language? Join us and help translate the app: https://www.transifex.com/projects/p/locationprivacy&lt;/p&gt;</desc>
<license>GPLv3</license>
<categories>Navigation,Security,GuardianProject</categories>
<category>Navigation</category>
<web>https://dev.guardianproject.info/projects/panic</web>
<source>https://github.com/guardianproject/LocationPrivacy</source>
<tracker>https://dev.guardianproject.info/projects/panic/issues</tracker>
<bitcoin>1Fi5xUHiAPRKxHvyUGVFGt9extBe8Srdbk</bitcoin>
<marketversion/>
<marketvercode>9999999</marketvercode>
<package>
<version>0.3</version>
<versioncode>30</versioncode>
<apkname>LocationPrivacy-0.3.apk</apkname>
<hash type="sha256">ec2b2c6e3a99422fbe8229711dfc7b741961c2ba7bc171c745818d8b76fc4d63</hash>
<size>1130602</size>
<sdkver>9</sdkver>
<targetSdkVersion>22</targetSdkVersion>
<added>2016-12-06</added>
<sig>d70ac6a02b53ebdd1354ea7af7b9ceee</sig>
<permissions>INTERNET</permissions>
</package>
<package>
<version>0.2</version>
<versioncode>20</versioncode>
<apkname>LocationPrivacy-0.2.apk</apkname>
<hash type="sha256">3cad63152ef9b04e1c2b880c286a80c65c083880612aaa36c0c4480b96adfea8</hash>
<size>1129409</size>
<sdkver>9</sdkver>
<targetSdkVersion>22</targetSdkVersion>
<added>2016-12-06</added>
<sig>d70ac6a02b53ebdd1354ea7af7b9ceee</sig>
<permissions>INTERNET</permissions>
</package>
<package>
<version>0.1</version>
<versioncode>10</versioncode>
<apkname>LocationPrivacy-0.1.apk</apkname>
<hash type="sha256">130cfcc8b916682d974aa4e13385b47bdc23d07b0de852640563b880aeb61d1f</hash>
<size>818384</size>
<sdkver>8</sdkver>
<targetSdkVersion>21</targetSdkVersion>
<added>2015-01-29</added>
<sig>d70ac6a02b53ebdd1354ea7af7b9ceee</sig>
<permissions>INTERNET</permissions>
</package>
</application>
<application id="info.guardianproject.notepadbot">
<id>info.guardianproject.notepadbot</id>
<added>2013-01-16</added>
<lastupdated>2014-03-10</lastupdated>
<name>NoteCipher</name>
<summary>Notepad with lock</summary>
<icon>info.guardianproject.notepadbot.12.png</icon>
<desc>&lt;p&gt;Simple app for taking notes that encrypts everything behind a password.&lt;/p&gt;&lt;p&gt;Status: Beta.&lt;/p&gt;</desc>
<license>Apache2</license>
<categories>Office,GuardianProject</categories>
<category>Office</category>
<web>https://guardianproject.info</web>
<source>https://github.com/guardianproject/notecipher</source>
<tracker>https://github.com/guardianproject/notecipher/issues</tracker>
<marketversion/>
<marketvercode>999999999</marketvercode>
<package>
<version>0.1</version>
<versioncode>12</versioncode>
<apkname>NoteCipher-beta-0.1.apk</apkname>
<hash type="sha256">b560a3d6364c32990ea7505f53b019f64fde597d67513f41a50e7d034af48caa</hash>
<size>7321123</size>
<sdkver>10</sdkver>
<targetSdkVersion>19</targetSdkVersion>
<added>2014-03-10</added>
<sig>a0eeebb161f946e3516945fae8a92a3e</sig>
<permissions>VIBRATE,READ_EXTERNAL_STORAGE,WRITE_EXTERNAL_STORAGE</permissions>
<nativecode>armeabi,x86</nativecode>
</package>
<package>
<version>0.0.7.1</version>
<versioncode>11</versioncode>
<apkname>NoteCipher-0.0.7.1.apk</apkname>
<hash type="sha256">da518f13206d2218234bfcc83205b7b2b81ec67a4cc448f818c617332235e700</hash>
<size>3729342</size>
<sdkver>11</sdkver>
<targetSdkVersion>17</targetSdkVersion>
<added>2013-10-31</added>
<sig>a0eeebb161f946e3516945fae8a92a3e</sig>
<permissions>READ_EXTERNAL_STORAGE,WRITE_EXTERNAL_STORAGE</permissions>
<nativecode>armeabi</nativecode>
</package>
<package>
<version>0.0.7</version>
<versioncode>10</versioncode>
<apkname>NoteCipher-0.0.7.apk</apkname>
<hash type="sha256">8fa7536a87634c6b3441053c4f16315e4fd5aa6ef672a0026a594c107308d7bf</hash>
<size>3731119</size>
<sdkver>7</sdkver>
<targetSdkVersion>17</targetSdkVersion>
<added>2013-01-16</added>
<sig>a0eeebb161f946e3516945fae8a92a3e</sig>
<permissions>READ_EXTERNAL_STORAGE,WRITE_EXTERNAL_STORAGE</permissions>
<nativecode>armeabi</nativecode>
</package>
</application>
<application id="org.witness.sscphase1">
<id>org.witness.sscphase1</id>
<added>2013-08-19</added>
<lastupdated>2013-10-31</lastupdated>
<name>ObscuraCam</name>
<summary>A camera app that keeps certain information private</summary>
<icon>org.witness.sscphase1.34.png</icon>
<desc>&lt;p&gt;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 childs 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.&lt;/p&gt;&lt;p&gt;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.&lt;/p&gt;&lt;p&gt;This app will also remove all identifying data stored in photos including GPS location data and phone make &amp;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.&lt;/p&gt;</desc>
<license>GPLv3</license>
<categories>Multimedia,Security,GuardianProject</categories>
<category>Multimedia</category>
<web>https://guardianproject.info/apps/obscuracam</web>
<source>https://github.com/guardianproject/obscuracam</source>
<tracker>https://github.com/guardianproject/obscuracam/issues</tracker>
<marketversion/>
<marketvercode>999999999</marketvercode>
<package>
<version>2.0-RC2b</version>
<versioncode>34</versioncode>
<apkname>ObscuraCam-2.0-RC2b.apk</apkname>
<hash type="sha256">eeea54985c96769524ec82fb1d3599b193a2d20d1f57f3afc4c97b11bd48df8f</hash>
<size>8240221</size>
<sdkver>10</sdkver>
<targetSdkVersion>11</targetSdkVersion>
<added>2013-10-31</added>
<sig>a0eeebb161f946e3516945fae8a92a3e</sig>
<permissions>READ_EXTERNAL_STORAGE,READ_MEDIA_STORAGE,WRITE_MEDIA_STORAGE,VIBRATE,WAKE_LOCK,WRITE_EXTERNAL_STORAGE</permissions>
<nativecode>armeabi</nativecode>
</package>
<package>
<version>1.2-FINAL</version>
<versioncode>25</versioncode>
<apkname>ObscuraCam-1.2-FINAL.apk</apkname>
<hash type="sha256">fc4b1e26b09ab79b1ab174e8985b89985a0110f9d97d2b0472e529c85e3a1d89</hash>
<size>1728825</size>
<sdkver>8</sdkver>
<targetSdkVersion>8</targetSdkVersion>
<added>2013-08-19</added>
<sig>a0eeebb161f946e3516945fae8a92a3e</sig>
<permissions>VIBRATE,READ_EXTERNAL_STORAGE,WRITE_EXTERNAL_STORAGE</permissions>
<nativecode>armeabi</nativecode>
</package>
</application>
<application id="org.torproject.android">
<id>org.torproject.android</id>
<added>2013-07-22</added>
<lastupdated>2016-12-06</lastupdated>
<name>Orbot</name>
<summary>Tor (anonymity) client</summary>
<icon>org.torproject.android.15208000.png</icon>
<desc>&lt;p&gt;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.&lt;/p&gt;&lt;p&gt;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.&lt;/p&gt;&lt;p&gt;If you don't have root access, there are some apps that are designed to work closely with tor or allow proxied connections: &lt;a href="fdroid.app:info.guardianproject.otr.app.im"&gt;ChatSecure&lt;/a&gt;, &lt;a href="fdroid.app:info.guardianproject.browser"&gt;Orweb&lt;/a&gt; and &lt;a href="fdroid.app:org.mariotaku.twidere"&gt;Twidere&lt;/a&gt;. There is also a proxy configurator addon for &lt;a href="fdroid.app:org.mozilla.firefox"&gt;org.mozilla.firefox&lt;/a&gt; called &lt;a href="https://github.com/guardianproject/ProxyMob/downloads"&gt;ProxyMob&lt;/a&gt; (not yet available from the Mozilla addon site).&lt;/p&gt;&lt;p&gt;Requires root: No, but you will need to use apps that allow proxies if root is not granted.&lt;/p&gt;</desc>
<license>NewBSD</license>
<categories>Security,Internet,GuardianProject</categories>
<category>Security</category>
<web>http://www.torproject.org/docs/android.html.en</web>
<source>https://gitweb.torproject.org/orbot.git</source>
<tracker>https://dev.guardianproject.info/projects/orbot/issues</tracker>
<donate>https://www.torproject.org/donate/donate.html.en</donate>
<flattr>5649</flattr>
<marketversion/>
<marketvercode>999999999</marketvercode>
<package>
<version>15.2.0-RC-8-multi</version>
<versioncode>15208000</versioncode>
<apkname>Orbot-v15.2.0-RC-8-multi.apk</apkname>
<hash type="sha256">3758e1b6e6b9a3b7848b253d08d6c0b1b1b3223184da4bd2ba1aaff8cf676357</hash>
<size>12296544</size>
<sdkver>16</sdkver>
<targetSdkVersion>23</targetSdkVersion>
<added>2016-11-07</added>
<sig>8bd7e51b479aeba908ff46ada3305a29</sig>
<permissions>ACCESS_NETWORK_STATE,ACCESS_SUPERUSER,INTERNET,RECEIVE_BOOT_COMPLETED</permissions>
<nativecode>armeabi,x86</nativecode>
</package>
<package>
<version>15.2.0-RC-7-multi</version>
<versioncode>15207000</versioncode>
<apkname>Orbot-v15.2.0-RC-7-multi.apk</apkname>
<hash type="sha256">8dc3edf0a9799eb23b5e478e15547e38831b28cc3e88b049aa5f41b7b72e7bf9</hash>
<size>12457510</size>
<sdkver>16</sdkver>
<targetSdkVersion>23</targetSdkVersion>
<added>2016-11-04</added>
<sig>8bd7e51b479aeba908ff46ada3305a29</sig>
<permissions>ACCESS_NETWORK_STATE,ACCESS_SUPERUSER,INTERNET,RECEIVE_BOOT_COMPLETED</permissions>
<nativecode>arm64-v8a,armeabi,armeabi-v7a,x86</nativecode>
</package>
<package>
<version>15.2.0-RC-5</version>
<versioncode>15205000</versioncode>
<apkname>Orbot-v15.2.0-RC-5-arm.apk</apkname>
<hash type="sha256">51c7e2b6a6de542e0d44f82d89ddf1d3216ec7a28297381ef15b12da2f3246f7</hash>
<size>7600548</size>
<sdkver>16</sdkver>
<targetSdkVersion>23</targetSdkVersion>
<added>2016-11-03</added>
<sig>8bd7e51b479aeba908ff46ada3305a29</sig>
<permissions>ACCESS_NETWORK_STATE,ACCESS_SUPERUSER,INTERNET,RECEIVE_BOOT_COMPLETED</permissions>
<nativecode>arm64-v8a,armeabi,armeabi-v7a</nativecode>
</package>
</application>
<application id="info.guardianproject.orfox">
<id>info.guardianproject.orfox</id>
<added>2016-09-24</added>
<lastupdated>2016-12-06</lastupdated>
<name>Orfox</name>
<summary>Orfox: Tor Browser for Android</summary>
<icon>info.guardianproject.orfox.4.png</icon>
<desc>&lt;p&gt;Orfox is the most privacy-enhancing web browser on Android, for visiting any website, even if its normally censored, monitored, or on the hidden web. It is a port of the desktop Tor Browser to the Android version of Firefox.&lt;/p&gt;&lt;p&gt;Orfox is a companion browser to &lt;a href="fdroid.app:org.torproject.android"&gt;Orbot&lt;/a&gt;, 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.&lt;/p&gt;&lt;p&gt;Orfox replaces &lt;a href="fdroid.app:info.guardianproject.browser"&gt;Orweb&lt;/a&gt; as your private browser.&lt;/p&gt;</desc>
<license>MPL</license>
<categories>Internet,Security,GuardianProject</categories>
<category>Internet</category>
<web/>
<source>https://github.com/guardianproject/orfox</source>
<tracker>https://dev.guardianproject.info/projects/orfox/issues?set_filter=1</tracker>
<marketversion/>
<marketvercode>999999999</marketvercode>
<package>
<version>Fennec-45.5.1esr/TorBrowser-6.5-1/Orfox-1.2.1</version>
<versioncode>4</versioncode>
<apkname>Orfox-1.2.1-TorBrowser-6.5-Fennec45.5.1-build2.apk</apkname>
<hash type="sha256">d43032e79c7c31cabb194b8c1c4b14fbf73dd2cfda958ba415879ddf2f38ace2</hash>
<size>35273126</size>
<sdkver>9</sdkver>
<targetSdkVersion>22</targetSdkVersion>
<added>2016-12-06</added>
<sig>d70ac6a02b53ebdd1354ea7af7b9ceee</sig>
<permissions>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</permissions>
<nativecode>armeabi-v7a</nativecode>
</package>
<package>
<version>Fennec-45.4.0esr/TorBrowser-6.5-1/Orfox-1.2</version>
<versioncode>3</versioncode>
<apkname>Orfox-1.2-TorBrowser-6.5-Fennec45.4.0.apk</apkname>
<hash type="sha256">9b5f6614b94a47ae561e8c974d42056ba6cb6da520766deda09aec3699aeff94</hash>
<size>35242066</size>
<sdkver>9</sdkver>
<targetSdkVersion>22</targetSdkVersion>
<added>2016-09-24</added>
<sig>d70ac6a02b53ebdd1354ea7af7b9ceee</sig>
<permissions>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</permissions>
<nativecode>armeabi-v7a</nativecode>
</package>
</application>
<application id="info.guardianproject.browser">
<id>info.guardianproject.browser</id>
<added>2012-10-22</added>
<lastupdated>2015-11-26</lastupdated>
<name>Orweb</name>
<summary>Privacy-enhanced browser</summary>
<icon>info.guardianproject.browser.7010.png</icon>
<desc>&lt;p&gt;Orweb is a companion browser to &lt;a href="fdroid.app:org.torproject.android"&gt;Orbot&lt;/a&gt;, the port of Tor to Android.&lt;/p&gt;&lt;p&gt;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.&lt;/p&gt;</desc>
<license>GPL</license>
<categories>Internet,Security,GuardianProject</categories>
<category>Internet</category>
<web>https://guardianproject.info/apps/orweb</web>
<source>https://github.com/guardianproject/orweb</source>
<tracker>https://dev.guardianproject.info/projects/orweb/issues?set_filter=1</tracker>
<marketversion/>
<marketvercode>999999999</marketvercode>
<package>
<version>0.7.1</version>
<versioncode>7010</versioncode>
<apkname>Orweb-0.7.1.apk</apkname>
<hash type="sha256">949d65d6e8a1eadd0aa626bdc7c5a3e2b0dbe5a38dea1d725cce2a34ec84f0d4</hash>
<size>2424394</size>
<sdkver>9</sdkver>
<targetSdkVersion>21</targetSdkVersion>
<added>2015-11-26</added>
<sig>a0eeebb161f946e3516945fae8a92a3e</sig>
<permissions>READ_EXTERNAL_STORAGE,INTERNET,WRITE_EXTERNAL_STORAGE</permissions>
</package>
<package>
<version>0.7</version>
<versioncode>28</versioncode>
<apkname>Orweb-release-0.7.apk</apkname>
<hash type="sha256">763541f43f5dc136744b4361fe67d36f25cc036526d6c3e934287d72d1b411ab</hash>
<size>1244875</size>
<sdkver>9</sdkver>
<targetSdkVersion>18</targetSdkVersion>
<added>2014-11-13</added>
<sig>a0eeebb161f946e3516945fae8a92a3e</sig>
<permissions>READ_EXTERNAL_STORAGE,INTERNET,WRITE_EXTERNAL_STORAGE</permissions>
</package>
<package>
<version>0.6.1</version>
<versioncode>27</versioncode>
<apkname>Orweb-release-0.6.1.apk</apkname>
<hash type="sha256">103f4a98fa282923c07e445b2a383e946b6c15e10ed08005af3d0743249a0359</hash>
<size>931433</size>
<sdkver>9</sdkver>
<targetSdkVersion>19</targetSdkVersion>
<added>2014-06-30</added>
<sig>a0eeebb161f946e3516945fae8a92a3e</sig>
<permissions>READ_EXTERNAL_STORAGE,INTERNET,WRITE_EXTERNAL_STORAGE</permissions>
</package>
</application>
<application id="info.guardianproject.pixelknot">
<id>info.guardianproject.pixelknot</id>
<added>2013-02-26</added>
<lastupdated>2016-12-06</lastupdated>
<name>PixelKnot</name>
<summary>Hide messages inside files</summary>
<icon>info.guardianproject.pixelknot.100.png</icon>
<desc>&lt;p&gt;Image steganography app with old school F5 steganography&lt;/p&gt;</desc>
<license>GPLv3</license>
<categories>Office,GuardianProject</categories>
<category>Office</category>
<web>https://guardianproject.info</web>
<source>https://github.com/guardianproject/PixelKnot</source>
<tracker>https://github.com/guardianproject/PixelKnot/issues</tracker>
<marketversion/>
<marketvercode>999999999</marketvercode>
<package>
<version>1.0.0</version>
<versioncode>100</versioncode>
<apkname>PixelKnot-release-1.0.0.apk</apkname>
<hash type="sha256">f97557cf7ec81ade50c308c5552dc6dc827d0e02ce90f84b1df6b7477d9f5a39</hash>
<size>1983586</size>
<sdkver>17</sdkver>
<targetSdkVersion>25</targetSdkVersion>
<added>2016-12-06</added>
<sig>a0eeebb161f946e3516945fae8a92a3e</sig>
<permissions>VIBRATE,READ_EXTERNAL_STORAGE,WRITE_EXTERNAL_STORAGE</permissions>
<uses-permission maxSdkVersion="18" name="android.permission.VIBRATE"/>
<uses-permission maxSdkVersion="18" name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<nativecode>arm64-v8a,armeabi,armeabi-v7a,mips,mips64,x86,x86_64</nativecode>
</package>
<package>
<version>0.3.3</version>
<versioncode>6</versioncode>
<apkname>PixelKnot-release-0.3.3.apk</apkname>
<hash type="sha256">6beede8519a9e87ba8edaa5a76f203cfefd5f39eb911e789031cc6e911714b89</hash>
<size>4751233</size>
<sdkver>14</sdkver>
<targetSdkVersion>17</targetSdkVersion>
<added>2015-06-26</added>
<sig>a0eeebb161f946e3516945fae8a92a3e</sig>
<permissions>CAMERA,READ_EXTERNAL_STORAGE,WRITE_EXTERNAL_STORAGE</permissions>
<nativecode>armeabi</nativecode>
</package>
<package>
<version>0.3.1</version>
<versioncode>4</versioncode>
<apkname>PixelKnot-release-0.3.1.apk</apkname>
<hash type="sha256">a3101fe8a2d47ab205cb00459fa62c639a6fac4538f6cd9d06eb48d2965c4d21</hash>
<size>3976822</size>
<sdkver>9</sdkver>
<targetSdkVersion>17</targetSdkVersion>
<added>2013-07-22</added>
<sig>a0eeebb161f946e3516945fae8a92a3e</sig>
<permissions>CAMERA,READ_EXTERNAL_STORAGE,WRITE_EXTERNAL_STORAGE</permissions>
<nativecode>armeabi</nativecode>
</package>
</application>
<application id="info.guardianproject.ripple">
<id>info.guardianproject.ripple</id>
<added>2016-12-06</added>
<lastupdated>2016-12-06</lastupdated>
<name>Ripple</name>
<summary>Trigger apps to protect your privacy when in anxious or panic situations</summary>
<icon>info.guardianproject.ripple.75.png</icon>
<desc>&lt;p&gt;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.&lt;/p&gt;&lt;p&gt;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.&lt;/p&gt;&lt;p&gt;Here are two example scenarios:&lt;/p&gt;&lt;ul&gt;&lt;li&gt; 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.&lt;/li&gt;&lt;/ul&gt;&lt;ul&gt;&lt;li&gt; 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.&lt;/li&gt;&lt;/ul&gt;&lt;p&gt;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:&lt;/p&gt;&lt;p&gt;&lt;a href="https://guardianproject.info/tag/panic"&gt;https://guardianproject.info/tag/panic&lt;/a&gt;&lt;a href="https://dev.guardianproject.info/projects/panic"&gt;https://dev.guardianproject.info/projects/panic&lt;/a&gt; Dont see your language? Join us and help translate the app: &lt;a href="https://www.transifex.com/projects/p/rippleapp"&gt;https://www.transifex.com/projects/p/rippleapp&lt;/a&gt;&lt;/p&gt;&lt;p&gt;==Learn More==&lt;/p&gt;&lt;p&gt;★ ABOUT US: Guardian Project is a group of developers that make secure mobile apps and open-source code for a better tomorrow ★ OUR WEBSITE: &lt;a href="https://GuardianProject.info"&gt;https://GuardianProject.info&lt;/a&gt; ★ ON TWITTER: &lt;a href="https://twitter.com/guardianproject"&gt;https://twitter.com/guardianproject&lt;/a&gt; ★ FREE SOFTWARE: Ripple is free software. You can take a look at our source code, or contribute to help make Ripple even better: &lt;a href="https://github.com/guardianproject/Ripple"&gt;https://github.com/guardianproject/Ripple&lt;/a&gt; ★ MESSAGE US: Are we missing your favorite feature? Found an annoying bug? Please tell us! Wed love to hear from you. Send us an email: support@guardianproject.info or find us in our chat room &lt;a href="https://guardianproject.info/contact"&gt;https://guardianproject.info/contact&lt;/a&gt;&lt;/p&gt;</desc>
<license>GPLv3</license>
<categories>Navigation,Security,GuardianProject</categories>
<category>Navigation</category>
<web>https://dev.guardianproject.info/projects/panic</web>
<source>https://github.com/guardianproject/ripple</source>
<tracker>https://dev.guardianproject.info/projects/panic/issues</tracker>
<bitcoin>1Fi5xUHiAPRKxHvyUGVFGt9extBe8Srdbk</bitcoin>
<marketversion/>
<marketvercode>9999999</marketvercode>
<package>
<version>0.2</version>
<versioncode>75</versioncode>
<apkname>Ripple-0.2-release.apk</apkname>
<hash type="sha256">4b14b1b402f0197e1e6ffe2c11e052432fc8a52749f5f02d9cc67799658df239</hash>
<size>1669315</size>
<sdkver>10</sdkver>
<targetSdkVersion>23</targetSdkVersion>
<added>2016-12-06</added>
<sig>d70ac6a02b53ebdd1354ea7af7b9ceee</sig>
</package>
<package>
<version>0.1</version>
<versioncode>2</versioncode>
<apkname>Ripple-0.1.apk</apkname>
<hash type="sha256">9fd24cbb3552123e6ee119f912f1646dd21cd7a683734a8d502d8b44854a284b</hash>
<size>1670285</size>
<sdkver>10</sdkver>
<targetSdkVersion>23</targetSdkVersion>
<added>2016-12-06</added>
<sig>d70ac6a02b53ebdd1354ea7af7b9ceee</sig>
</package>
<package>
<version>0.0</version>
<versioncode>1</versioncode>
<apkname>Ripple-0.0.apk</apkname>
<hash type="sha256">025894a5f3a39a288ee60bb6c9cc2c559d395f22fed020d1086308ba12df85a3</hash>
<size>1664407</size>
<sdkver>10</sdkver>
<targetSdkVersion>23</targetSdkVersion>
<added>2016-12-06</added>
<sig>d70ac6a02b53ebdd1354ea7af7b9ceee</sig>
</package>
</application>
<application id="info.guardianproject.chatsecure.emoji.core">
<id>info.guardianproject.chatsecure.emoji.core</id>
<added>2013-12-02</added>
<lastupdated>2013-12-02</lastupdated>
<name>StickerPack</name>
<summary>ChatSecure Open Emoji Plugin</summary>
<icon>info.guardianproject.chatsecure.emoji.core.1.png</icon>
<desc>&lt;p&gt;Plugin for &lt;a href="fdroid.app:info.guardianproject.otr.app.im"&gt;ChatSecure&lt;/a&gt; to support for core emoji input and display. Based on "Phantom Open Emoji" project.&lt;/p&gt;</desc>
<license>SIL Open Font License, MIT License and the CC 3.0 License [CC-By with attribution requirement waived]</license>
<categories>Multimedia,Security,GuardianProject</categories>
<category>Multimedia</category>
<web>https://guardianproject.info/apps/chatsecure</web>
<source>https://github.com/guardianproject/ChatSecureVoicePlugin</source>
<tracker>https://dev.guardianproject.info/projects/chatsecure/issues</tracker>
<marketversion/>
<marketvercode>999999999</marketvercode>
<package>
<version>1.0</version>
<versioncode>1</versioncode>
<apkname>ChatSecurePluginOpenEmoji-release-v1.apk</apkname>
<hash type="sha256">131c1ebaf795c3f053701285699f0b7e517de1c7fdba56e247b1ec31766b2808</hash>
<size>1814271</size>
<sdkver>8</sdkver>
<targetSdkVersion>17</targetSdkVersion>
<added>2013-12-02</added>
<sig>a0eeebb161f946e3516945fae8a92a3e</sig>
</package>
</application>
</fdroid>

Binary file not shown.

Binary file not shown.

Binary file not shown.

@ -35,7 +35,7 @@
<module name="AvoidStarImport" />
<module name="AvoidStaticImport">
<property name="excludes"
value="org.fdroid.fdroid.Assert.*, org.assertj.core.api.Assertions.*, org.junit.Assert.*, org.junit.Assume.*, org.junit.internal.matchers.ThrowableMessageMatcher.*, 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" />
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" />
</module>
<module name="RedundantImport" />
<module name="UnusedImports" />