From 2f0cb30ad001ae15850acb54fca475264d368b6e Mon Sep 17 00:00:00 2001 From: Hans-Christoph Steiner Date: Thu, 29 Mar 2018 14:53:38 +0200 Subject: [PATCH] support adding custom mirrors to any existing repo, via "App Repo" This lets people add any URL as a mirror to an existing repo. The UX is people add URLs via any of the normal ways of adding a new repo via Intents, like clicking URLs, QRCodes, etc. --- .../java/org/fdroid/fdroid/data/DBHelper.java | 100 ++++++++++-------- .../java/org/fdroid/fdroid/data/Repo.java | 47 ++++++-- .../java/org/fdroid/fdroid/data/Schema.java | 3 +- .../fdroid/views/ManageReposActivity.java | 87 +++++++++++++-- .../fdroid/views/RepoDetailsActivity.java | 14 +++ app/src/main/res/values/strings.xml | 2 + 6 files changed, 188 insertions(+), 65 deletions(-) diff --git a/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java b/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java index 45a936473..e0534384f 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java +++ b/app/src/main/java/org/fdroid/fdroid/data/DBHelper.java @@ -88,35 +88,36 @@ public class DBHelper extends SQLiteOpenHelper { + RepoTable.Cols.TIMESTAMP + " integer not null default 0, " + RepoTable.Cols.ICON + " string, " + RepoTable.Cols.MIRRORS + " string, " + + RepoTable.Cols.USER_MIRRORS + " string, " + RepoTable.Cols.PUSH_REQUESTS + " integer not null default " + Repo.PUSH_REQUEST_IGNORE + ");"; static final String CREATE_TABLE_APK = "CREATE TABLE " + ApkTable.NAME + " ( " - + ApkTable.Cols.APP_ID + " integer not null, " - + ApkTable.Cols.VERSION_NAME + " text not null, " - + ApkTable.Cols.REPO_ID + " integer not null, " - + ApkTable.Cols.HASH + " text not null, " - + ApkTable.Cols.VERSION_CODE + " int not null," - + ApkTable.Cols.NAME + " text not null, " - + ApkTable.Cols.SIZE + " int not null, " - + ApkTable.Cols.SIGNATURE + " string, " - + ApkTable.Cols.SOURCE_NAME + " string, " - + ApkTable.Cols.MIN_SDK_VERSION + " integer, " - + ApkTable.Cols.TARGET_SDK_VERSION + " integer, " - + ApkTable.Cols.MAX_SDK_VERSION + " integer, " - + ApkTable.Cols.OBB_MAIN_FILE + " string, " - + ApkTable.Cols.OBB_MAIN_FILE_SHA256 + " string, " - + ApkTable.Cols.OBB_PATCH_FILE + " string, " - + ApkTable.Cols.OBB_PATCH_FILE_SHA256 + " string, " - + ApkTable.Cols.REQUESTED_PERMISSIONS + " string, " - + ApkTable.Cols.FEATURES + " string, " - + ApkTable.Cols.NATIVE_CODE + " string, " - + ApkTable.Cols.HASH_TYPE + " string, " - + ApkTable.Cols.ADDED_DATE + " string, " - + ApkTable.Cols.IS_COMPATIBLE + " int not null, " - + ApkTable.Cols.INCOMPATIBLE_REASONS + " text" - + ");"; + + ApkTable.Cols.APP_ID + " integer not null, " + + ApkTable.Cols.VERSION_NAME + " text not null, " + + ApkTable.Cols.REPO_ID + " integer not null, " + + ApkTable.Cols.HASH + " text not null, " + + ApkTable.Cols.VERSION_CODE + " int not null," + + ApkTable.Cols.NAME + " text not null, " + + ApkTable.Cols.SIZE + " int not null, " + + ApkTable.Cols.SIGNATURE + " string, " + + ApkTable.Cols.SOURCE_NAME + " string, " + + ApkTable.Cols.MIN_SDK_VERSION + " integer, " + + ApkTable.Cols.TARGET_SDK_VERSION + " integer, " + + ApkTable.Cols.MAX_SDK_VERSION + " integer, " + + ApkTable.Cols.OBB_MAIN_FILE + " string, " + + ApkTable.Cols.OBB_MAIN_FILE_SHA256 + " string, " + + ApkTable.Cols.OBB_PATCH_FILE + " string, " + + ApkTable.Cols.OBB_PATCH_FILE_SHA256 + " string, " + + ApkTable.Cols.REQUESTED_PERMISSIONS + " string, " + + ApkTable.Cols.FEATURES + " string, " + + ApkTable.Cols.NATIVE_CODE + " string, " + + ApkTable.Cols.HASH_TYPE + " string, " + + ApkTable.Cols.ADDED_DATE + " string, " + + ApkTable.Cols.IS_COMPATIBLE + " int not null, " + + ApkTable.Cols.INCOMPATIBLE_REASONS + " text" + + ");"; static final String CREATE_TABLE_APP_METADATA = "CREATE TABLE " + AppMetadataTable.NAME + " ( " @@ -181,7 +182,7 @@ public class DBHelper extends SQLiteOpenHelper { * app metadata id, because it can instead look through the primary key index. This can be * observed by flipping the order of the primary key columns, and noting the resulting sqlite * logs along the lines of: - * E/SQLiteLog(14164): (284) automatic index on fdroid_categoryAppMetadataJoin(appMetadataId) + * E/SQLiteLog(14164): (284) automatic index on fdroid_categoryAppMetadataJoin(appMetadataId) */ static final String CREATE_TABLE_CAT_JOIN = "CREATE TABLE " + CatJoinTable.NAME + " ( " @@ -214,7 +215,7 @@ public class DBHelper extends SQLiteOpenHelper { + "primary key(" + ApkAntiFeatureJoinTable.Cols.APK_ID + ", " + ApkAntiFeatureJoinTable.Cols.ANTI_FEATURE_ID + ") " + " );"; - protected static final int DB_VERSION = 77; + protected static final int DB_VERSION = 78; private final Context context; @@ -321,6 +322,17 @@ public class DBHelper extends SQLiteOpenHelper { addApkAntiFeatures(db, oldVersion); addIgnoreVulnPref(db, oldVersion); addLiberapayID(db, oldVersion); + addUserMirrorsFields(db, oldVersion); + } + + private void addUserMirrorsFields(SQLiteDatabase db, int oldVersion) { + if (oldVersion >= 78) { + return; + } + if (!columnExists(db, RepoTable.NAME, RepoTable.Cols.USER_MIRRORS)) { + Utils.debugLog(TAG, "Adding " + RepoTable.Cols.USER_MIRRORS + " field to " + RepoTable.NAME + " table in db."); + db.execSQL("alter table " + RepoTable.NAME + " add column " + RepoTable.Cols.USER_MIRRORS + " string;"); + } } private void addLiberapayID(SQLiteDatabase db, int oldVersion) { @@ -581,7 +593,7 @@ public class DBHelper extends SQLiteOpenHelper { updateRepoPriority(db, gpPubKey, gpArchiveAddress, 4); int priority = 5; - String[] projection = new String[] {RepoTable.Cols.SIGNING_CERT, RepoTable.Cols.ADDRESS}; + String[] projection = new String[]{RepoTable.Cols.SIGNING_CERT, RepoTable.Cols.ADDRESS}; // Order by ID, because that is a good analogy for the order in which they were added. // The order in which they were added is likely the order they present in the ManageRepos activity. @@ -606,7 +618,7 @@ public class DBHelper extends SQLiteOpenHelper { RepoTable.NAME, values, RepoTable.Cols.SIGNING_CERT + " = ? AND " + RepoTable.Cols.ADDRESS + " = ?", - new String[] {signingCert, address} + new String[]{signingCert, address} ); } @@ -630,15 +642,15 @@ public class DBHelper extends SQLiteOpenHelper { Utils.debugLog(TAG, "Migrating app preferences to separate table"); db.execSQL( "INSERT INTO " + AppPrefsTable.NAME + " (" - + AppPrefsTable.Cols.PACKAGE_NAME + ", " - + AppPrefsTable.Cols.IGNORE_THIS_UPDATE + ", " - + AppPrefsTable.Cols.IGNORE_ALL_UPDATES - + ") SELECT " - + "id, " - + "ignoreThisUpdate, " - + "ignoreAllUpdates " - + "FROM " + AppMetadataTable.NAME + " " - + "WHERE ignoreThisUpdate > 0 OR ignoreAllUpdates > 0" + + AppPrefsTable.Cols.PACKAGE_NAME + ", " + + AppPrefsTable.Cols.IGNORE_THIS_UPDATE + ", " + + AppPrefsTable.Cols.IGNORE_ALL_UPDATES + + ") SELECT " + + "id, " + + "ignoreThisUpdate, " + + "ignoreAllUpdates " + + "FROM " + AppMetadataTable.NAME + " " + + "WHERE ignoreThisUpdate > 0 OR ignoreAllUpdates > 0" ); resetTransient(db); @@ -687,7 +699,7 @@ public class DBHelper extends SQLiteOpenHelper { db.execSQL(createTableDdl); - String nonPackageNameFields = TextUtils.join(", ", new String[] { + String nonPackageNameFields = TextUtils.join(", ", new String[]{ ApkTable.Cols.APP_ID, ApkTable.Cols.VERSION_NAME, ApkTable.Cols.REPO_ID, @@ -766,7 +778,7 @@ public class DBHelper extends SQLiteOpenHelper { } List oldrepos = new ArrayList<>(); Cursor cursor = db.query(RepoTable.NAME, - new String[] {RepoTable.Cols.ADDRESS, RepoTable.Cols.IN_USE, RepoTable.Cols.SIGNING_CERT}, + new String[]{RepoTable.Cols.ADDRESS, RepoTable.Cols.IN_USE, RepoTable.Cols.SIGNING_CERT}, null, null, null, null, null); if (cursor != null) { if (cursor.getCount() > 0) { @@ -847,7 +859,7 @@ public class DBHelper extends SQLiteOpenHelper { } List oldrepos = new ArrayList<>(); Cursor cursor = db.query(RepoTable.NAME, - new String[] {RepoTable.Cols.ADDRESS, RepoTable.Cols.SIGNING_CERT}, + new String[]{RepoTable.Cols.ADDRESS, RepoTable.Cols.SIGNING_CERT}, null, null, null, null, null); if (cursor != null) { if (cursor.getCount() > 0) { @@ -865,7 +877,7 @@ public class DBHelper extends SQLiteOpenHelper { for (final Repo repo : oldrepos) { ContentValues values = new ContentValues(); values.put(RepoTable.Cols.FINGERPRINT, Utils.calcFingerprint(repo.signingCertificate)); - db.update(RepoTable.NAME, values, RepoTable.Cols.ADDRESS + " = ?", new String[] {repo.address}); + db.update(RepoTable.NAME, values, RepoTable.Cols.ADDRESS + " = ?", new String[]{repo.address}); } } @@ -954,7 +966,7 @@ public class DBHelper extends SQLiteOpenHelper { db.execSQL(createTableDdl); - String nonIdFields = TextUtils.join(", ", new String[] { + String nonIdFields = TextUtils.join(", ", new String[]{ RepoTable.Cols.ADDRESS, RepoTable.Cols.NAME, RepoTable.Cols.DESCRIPTION, @@ -1235,8 +1247,8 @@ public class DBHelper extends SQLiteOpenHelper { } private static boolean tableExists(SQLiteDatabase db, String table) { - Cursor cursor = db.query("sqlite_master", new String[] {"name"}, - "type = 'table' AND name = ?", new String[] {table}, null, null, null); + Cursor cursor = db.query("sqlite_master", new String[]{"name"}, + "type = 'table' AND name = ?", new String[]{table}, null, null, null); boolean exists = cursor.getCount() > 0; cursor.close(); diff --git a/app/src/main/java/org/fdroid/fdroid/data/Repo.java b/app/src/main/java/org/fdroid/fdroid/data/Repo.java index eafc06d78..348b19be6 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/Repo.java +++ b/app/src/main/java/org/fdroid/fdroid/data/Repo.java @@ -26,13 +26,13 @@ package org.fdroid.fdroid.data; import android.content.ContentValues; import android.database.Cursor; import android.text.TextUtils; - import org.fdroid.fdroid.FDroidApp; import org.fdroid.fdroid.Utils; import org.fdroid.fdroid.data.Schema.RepoTable.Cols; import java.net.MalformedURLException; import java.net.URL; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Date; @@ -94,6 +94,11 @@ public class Repo extends ValueObject { /** Official mirrors of this repo, considered automatically interchangeable */ public String[] mirrors; + /** + * Mirrors added by the user, either by UI input or by attaching removeable storage + */ + public String[] userMirrors; + /** How to treat push requests included in this repo's index XML */ public int pushRequests = PUSH_REQUEST_IGNORE; @@ -160,6 +165,9 @@ public class Repo extends ValueObject { case Cols.MIRRORS: mirrors = Utils.parseCommaSeparatedString(cursor.getString(i)); break; + case Cols.USER_MIRRORS: + userMirrors = Utils.parseCommaSeparatedString(cursor.getString(i)); + break; case Cols.PUSH_REQUESTS: pushRequests = cursor.getInt(i); break; @@ -297,26 +305,43 @@ public class Repo extends ValueObject { mirrors = Utils.parseCommaSeparatedString(values.getAsString(Cols.MIRRORS)); } + if (values.containsKey(Cols.USER_MIRRORS)) { + userMirrors = Utils.parseCommaSeparatedString(values.getAsString(Cols.USER_MIRRORS)); + } + if (values.containsKey(Cols.PUSH_REQUESTS)) { pushRequests = toInt(values.getAsInteger(Cols.PUSH_REQUESTS)); } } public boolean hasMirrors() { - return mirrors != null && mirrors.length > 1; + return (mirrors != null && mirrors.length > 1) + || (userMirrors != null && userMirrors.length > 0); } + public List getMirrorList() { + final ArrayList allMirrors = new ArrayList(); + if (userMirrors != null) { + allMirrors.addAll(Arrays.asList(userMirrors)); + } + if (mirrors != null) { + allMirrors.addAll(Arrays.asList(mirrors)); + } + return allMirrors; + } + + /** + * Get the number of available mirrors, including the canonical repo. + */ public int getMirrorCount() { int count = 0; - if (mirrors != null && mirrors.length > 1) { - for (String m: mirrors) { - if (!m.equals(address)) { - if (FDroidApp.isUsingTor()) { + for (String m : getMirrorList()) { + if (!m.equals(address)) { + if (FDroidApp.isUsingTor()) { + count++; + } else { + if (!m.contains(".onion")) { count++; - } else { - if (!m.contains(".onion")) { - count++; - } } } } @@ -328,7 +353,7 @@ public class Repo extends ValueObject { if (TextUtils.isEmpty(lastWorkingMirror)) { lastWorkingMirror = address; } - List shuffledMirrors = Arrays.asList(mirrors); + List shuffledMirrors = getMirrorList(); Collections.shuffle(shuffledMirrors); if (shuffledMirrors.size() > 1) { for (String m : shuffledMirrors) { diff --git a/app/src/main/java/org/fdroid/fdroid/data/Schema.java b/app/src/main/java/org/fdroid/fdroid/data/Schema.java index 9bcd11103..3642ebcd9 100644 --- a/app/src/main/java/org/fdroid/fdroid/data/Schema.java +++ b/app/src/main/java/org/fdroid/fdroid/data/Schema.java @@ -362,12 +362,13 @@ public interface Schema { String TIMESTAMP = "timestamp"; String ICON = "icon"; String MIRRORS = "mirrors"; + String USER_MIRRORS = "userMirrors"; String PUSH_REQUESTS = "pushRequests"; String[] ALL = { _ID, ADDRESS, NAME, DESCRIPTION, IN_USE, PRIORITY, SIGNING_CERT, FINGERPRINT, MAX_AGE, LAST_UPDATED, LAST_ETAG, VERSION, IS_SWAP, - USERNAME, PASSWORD, TIMESTAMP, ICON, MIRRORS, PUSH_REQUESTS, + USERNAME, PASSWORD, TIMESTAMP, ICON, MIRRORS, USER_MIRRORS, PUSH_REQUESTS, }; } } diff --git a/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java b/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java index 9be324d88..e552b639a 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/views/ManageReposActivity.java @@ -61,12 +61,15 @@ import org.fdroid.fdroid.data.Repo; import org.fdroid.fdroid.data.RepoProvider; import org.fdroid.fdroid.data.Schema.RepoTable; +import java.io.File; import java.io.IOException; import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; +import java.util.Arrays; +import java.util.HashMap; import java.util.Locale; @SuppressWarnings("LineLength") @@ -76,7 +79,7 @@ public class ManageReposActivity extends AppCompatActivity implements LoaderMana private static final String DEFAULT_NEW_REPO_TEXT = "https://"; private enum AddRepoState { - DOESNT_EXIST, EXISTS_FINGERPRINT_MISMATCH, EXISTS_FINGERPRINT_MATCH, + DOESNT_EXIST, EXISTS_FINGERPRINT_MISMATCH, EXISTS_ADD_MIRROR, EXISTS_DISABLED, EXISTS_ENABLED, EXISTS_UPGRADABLE_TO_SIGNED, INVALID_URL, IS_SWAP } @@ -213,18 +216,35 @@ public class ManageReposActivity extends AppCompatActivity implements LoaderMana private class AddRepo { private final Context context; + private final HashMap urlRepoMap = new HashMap<>(); + private final HashMap fingerprintRepoMap = new HashMap<>(); private final AlertDialog addRepoDialog; - private final TextView overwriteMessage; private final ColorStateList defaultTextColour; private final Button addButton; private AddRepoState addRepoState; + /** + * Create new instance, setup GUI, and build maps for quickly looking + * up repos based on URL or fingerprint. These need to be in maps + * since the user input is validated as they are typing. This also + * checks that the repo type matches, e.g. "repo" or "archive". + */ AddRepo(String newAddress, String newFingerprint, final String username, final String password) { context = ManageReposActivity.this; + for (Repo repo : RepoProvider.Helper.all(context)) { + urlRepoMap.put(repo.address, repo); + for (String url : repo.getMirrorList()) { + urlRepoMap.put(url, repo); + } + if (TextUtils.equals(getRepoType(newAddress), getRepoType(repo.address))) { + fingerprintRepoMap.put(repo.fingerprint, repo); + } + } + final View view = getLayoutInflater().inflate(R.layout.addrepo, null); addRepoDialog = new AlertDialog.Builder(context).setView(view).create(); final EditText uriEditText = (EditText) view.findViewById(R.id.edit_uri); @@ -297,7 +317,7 @@ public class ManageReposActivity extends AppCompatActivity implements LoaderMana case EXISTS_DISABLED: case EXISTS_UPGRADABLE_TO_SIGNED: - case EXISTS_FINGERPRINT_MATCH: + case EXISTS_ADD_MIRROR: updateAndEnableExistingRepo(url, fp); finishedAddingRepo(); break; @@ -347,9 +367,31 @@ public class ManageReposActivity extends AppCompatActivity implements LoaderMana validateRepoDetails(newAddress == null ? "" : newAddress, newFingerprint == null ? "" : newFingerprint); } + /** + * Gets the repo type as represented by the final segment of the path. This is + * a bit trickier with {@code content://} URLs, since they might have + * encoded "/" chars in it, for example: + * {@code content://authority/tree/313E-1F1C%3A/document/313E-1F1C%3Aguardianproject.info%2Ffdroid%2Frepo} + */ + private String getRepoType(String url) { + String last = Uri.parse(url).getLastPathSegment(); + if (last == null) { + return ""; + } else { + return new File(last).getName(); + } + } + /** * Compare the repo and the fingerprint against existing repositories, to see if this - * repo matches and display a relevant message to the user if that is the case. + * repo matches and display a relevant message to the user if that is the case. There + * are many different cases to handle: + *
    + *
  • a signed repo with a {@link Repo#address URL} and fingerprint that matches + *
  • a signed repo with a matching fingerprint and URL that matches a mirror + *
  • a signed repo with a matching fingerprint, but the URL doesn't match any known mirror + *
  • an unsigned repo and no fingerprint was supplied + *
*/ private void validateRepoDetails(@NonNull String uri, @NonNull String fingerprint) { @@ -361,7 +403,10 @@ public class ManageReposActivity extends AppCompatActivity implements LoaderMana // to the user until they try to save the repo. } - final Repo repo = !TextUtils.isEmpty(uri) ? RepoProvider.Helper.findByAddress(context, uri) : null; + Repo repo = fingerprintRepoMap.get(fingerprint); + if (repo == null) { + repo = urlRepoMap.get(uri); + } if (repo == null) { repoDoesntExist(repo); @@ -373,9 +418,10 @@ public class ManageReposActivity extends AppCompatActivity implements LoaderMana } else if (repo.fingerprint != null && !repo.fingerprint.equalsIgnoreCase(fingerprint)) { repoFingerprintDoesntMatch(repo); } else { - // Could be either an unsigned repo, and no fingerprint was supplied, - // or it could be a signed repo with a matching fingerprint. - if (repo.inuse) { + if (!TextUtils.equals(repo.address, uri) + && !repo.getMirrorList().contains(uri)) { + repoExistsAddMirror(repo); + } else if (repo.inuse) { repoExistsAndEnabled(repo); } else { repoExistsAndDisabled(repo); @@ -659,11 +705,34 @@ public class ManageReposActivity extends AppCompatActivity implements LoaderMana } Utils.debugLog(TAG, "Enabling existing repo: " + url); - Repo repo = RepoProvider.Helper.findByAddress(context, url); + Repo repo = fingerprintRepoMap.get(fingerprint); + if (repo == null) { + repo = RepoProvider.Helper.findByAddress(context, url); + } + ContentValues values = new ContentValues(2); values.put(RepoTable.Cols.IN_USE, 1); values.put(RepoTable.Cols.FINGERPRINT, fingerprint); + if (!TextUtils.equals(url, repo.address)) { + boolean addUserMirror = true; + for (String mirror : repo.getMirrorList()) { + if (TextUtils.equals(mirror, url)) { + addUserMirror = false; + } + } + if (addUserMirror) { + if (repo.userMirrors == null) { + repo.userMirrors = new String[]{url}; + } else { + int last = repo.userMirrors.length; + repo.userMirrors = Arrays.copyOf(repo.userMirrors, last); + repo.userMirrors[last] = url; + } + values.put(RepoTable.Cols.USER_MIRRORS, Utils.serializeCommaSeparatedString(repo.userMirrors)); + } + } RepoProvider.Helper.update(context, repo, values); + notifyDataSetChanged(); finishedAddingRepo(); } diff --git a/app/src/main/java/org/fdroid/fdroid/views/RepoDetailsActivity.java b/app/src/main/java/org/fdroid/fdroid/views/RepoDetailsActivity.java index 2962ed863..f22785318 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/RepoDetailsActivity.java +++ b/app/src/main/java/org/fdroid/fdroid/views/RepoDetailsActivity.java @@ -109,6 +109,7 @@ public class RepoDetailsActivity extends ActionBarActivity { RepoTable.Cols.ADDRESS, RepoTable.Cols.FINGERPRINT, RepoTable.Cols.MIRRORS, + RepoTable.Cols.USER_MIRRORS, }; repo = RepoProvider.Helper.findById(this, repoId, projection); @@ -340,6 +341,19 @@ public class RepoDetailsActivity extends ActionBarActivity { } officialMirrorsText.setText(builder.toString()); } + if (repo.userMirrors != null) { + TextView userMirrorsLabel = (TextView) repoView.findViewById(R.id.label_user_mirrors); + userMirrorsLabel.setVisibility(View.VISIBLE); + TextView userMirrorsText = (TextView) repoView.findViewById(R.id.text_user_mirrors); + userMirrorsText.setVisibility(View.VISIBLE); + StringBuilder builder = new StringBuilder(); + for (String url : repo.userMirrors) { + builder.append("◦ "); + builder.append(url); + builder.append('\n'); + } + userMirrorsText.setText(builder.toString()); + } name.setText(repo.name); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 855087c95..a38fd786a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -116,6 +116,7 @@ This often occurs with apps installed via Google Play or other sources, if they No Add new repository Add + Add mirror Links Versions More @@ -138,6 +139,7 @@ This often occurs with apps installed via Google Play or other sources, if they %1$s is already setup, confirm that you want to re-enable it. %1$s is already setup and enabled. First delete %1$s in order to add this with a conflicting key. + This is a copy of %1$s, add it as a mirror? Bad fingerprint This is not a valid URL. Ignoring malformed repo URI: %s