From a8e620a9967ff4d10a90dd190d7973f5b6014d94 Mon Sep 17 00:00:00 2001 From: Matthew Bogner Date: Fri, 6 Dec 2024 13:42:30 +0000 Subject: [PATCH] MirrorChooser orders mirrors using location and error counts --- .../fdroid/fdroid/net/HttpDownloaderTest.java | 1 + .../java/org/fdroid/fdroid/Preferences.java | 52 ++++++- .../org/fdroid/fdroid/RepoUpdateManager.kt | 5 +- .../fdroid/fdroid/net/DownloaderFactory.java | 8 +- .../net/FDroidMirrorParameterManager.java | 88 ++++++++++++ .../fdroid/views/PreferencesFragment.java | 9 ++ app/src/main/res/values/strings.xml | 2 + app/src/main/res/xml/preferences.xml | 13 +- gradle/wrapper/gradle-wrapper.properties | 1 + libs/download/build.gradle | 7 + .../kotlin/org/fdroid/download/HttpManager.kt | 3 +- .../org/fdroid/download/MirrorChooser.kt | 124 ++++++++++++++-- .../fdroid/download/MirrorParameterManager.kt | 32 +++++ .../org/fdroid/download/MirrorChooserTest.kt | 134 +++++++++++++++++- 14 files changed, 454 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/org/fdroid/fdroid/net/FDroidMirrorParameterManager.java create mode 100644 libs/download/src/commonMain/kotlin/org/fdroid/download/MirrorParameterManager.kt diff --git a/app/src/androidTest/java/org/fdroid/fdroid/net/HttpDownloaderTest.java b/app/src/androidTest/java/org/fdroid/fdroid/net/HttpDownloaderTest.java index 682dfe212..96476cca7 100644 --- a/app/src/androidTest/java/org/fdroid/fdroid/net/HttpDownloaderTest.java +++ b/app/src/androidTest/java/org/fdroid/fdroid/net/HttpDownloaderTest.java @@ -36,6 +36,7 @@ public class HttpDownloaderTest { FDroidApp.queryString, null, null, + null, true ); private static final Collection> URLS; diff --git a/app/src/main/java/org/fdroid/fdroid/Preferences.java b/app/src/main/java/org/fdroid/fdroid/Preferences.java index 07f21de92..db2cad840 100644 --- a/app/src/main/java/org/fdroid/fdroid/Preferences.java +++ b/app/src/main/java/org/fdroid/fdroid/Preferences.java @@ -35,6 +35,8 @@ import androidx.annotation.Nullable; import androidx.core.content.ContextCompat; import androidx.preference.PreferenceManager; +import com.google.common.collect.Lists; + import org.fdroid.fdroid.data.Apk; import org.fdroid.fdroid.data.DBHelper; import org.fdroid.fdroid.installer.PrivilegedInstaller; @@ -43,7 +45,6 @@ import org.fdroid.fdroid.net.ConnectivityMonitorService; import java.net.InetAddress; import java.net.UnknownHostException; import java.util.ArrayList; -import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; @@ -125,6 +126,8 @@ public final class Preferences implements SharedPreferences.OnSharedPreferenceCh public static final String PREF_LANGUAGE = "language"; public static final String PREF_USE_DNS_CACHE = "useDnsCache"; public static final String PREF_DNS_CACHE = "dnsCache"; + public static final String PREF_MIRROR_ERROR_DATA = "mirrorErrorData"; + public static final String PREF_PREFER_FOREIGN = "preferForeign"; public static final String PREF_USE_TOR = "useTor"; public static final String PREF_ENABLE_PROXY = "enableProxy"; public static final String PREF_PROXY_HOST = "proxyHost"; @@ -576,7 +579,7 @@ public final class Preferences implements SharedPreferences.OnSharedPreferenceCh ipList.add(InetAddress.getByName(ip)); } catch (UnknownHostException e) { // should not occur, if an ip address is supplied only the format is checked. - Log.e("Preferences", "Exception thrown when converting " + ip, e); + Log.e(TAG, "Exception thrown when converting " + ip, e); } } dnsMap.put(url, ipList); @@ -605,13 +608,56 @@ public final class Preferences implements SharedPreferences.OnSharedPreferenceCh HashMap> output = new HashMap>(); for (String line : string.split("\n")) { String[] items = line.split(" "); - ArrayList list = new ArrayList<>(Arrays.asList(items)); + List list = Lists.newArrayList(items); String key = list.remove(0); output.put(key, list); } return output; } + private String intMapToString(HashMap intMap) { + String output = ""; + for (String key : intMap.keySet()) { + if (!output.isEmpty()) { + output = output + "\n"; + } + output = output + key + " " + intMap.get(key); + } + return output; + } + + private HashMap stringToIntMap(String mapString) { + HashMap output = new HashMap(); + for (String line : mapString.split("\n")) { + String[] pair = line.split(" "); + output.put(pair[0], Integer.valueOf(pair[1])); + } + return output; + } + + public void setPreferForeignValue(boolean newValue) { + preferences.edit().putBoolean(PREF_PREFER_FOREIGN, newValue).apply(); + } + + public boolean isPreferForeignSet() { + return preferences.getBoolean(PREF_PREFER_FOREIGN, false); + } + + public void setMirrorErrorData(HashMap mirrorErrorMap) { + preferences.edit().putString(PREF_MIRROR_ERROR_DATA, intMapToString(mirrorErrorMap)).apply(); + } + + public HashMap getMirrorErrorData() { + HashMap mirrorDataMap = new HashMap(); + String mapString = preferences.getString(PREF_MIRROR_ERROR_DATA, ""); + if (mapString == null || mapString.isEmpty()) { + // no-op, return empty map to avoid null issues + } else { + mirrorDataMap = stringToIntMap(mapString); + } + return mirrorDataMap; + } + /** * This preference's default is set dynamically based on whether Orbot is * installed. If Orbot is installed, default to using Tor, the user can still override diff --git a/app/src/main/java/org/fdroid/fdroid/RepoUpdateManager.kt b/app/src/main/java/org/fdroid/fdroid/RepoUpdateManager.kt index 0b068617f..8c0c4e399 100644 --- a/app/src/main/java/org/fdroid/fdroid/RepoUpdateManager.kt +++ b/app/src/main/java/org/fdroid/fdroid/RepoUpdateManager.kt @@ -4,7 +4,6 @@ import android.content.Context import android.os.Handler import android.os.Looper import android.util.Log -import android.widget.Toast import android.widget.Toast.LENGTH_LONG import androidx.annotation.WorkerThread import androidx.lifecycle.asLiveData @@ -155,7 +154,9 @@ class RepoUpdateManager @JvmOverloads constructor( } // can't show Toast from background thread, so we need to move this to UiThread Handler(Looper.getMainLooper()).post { - Toast.makeText(context, msgBuilder.toString(), LENGTH_LONG).show() + // can only post toast messages on the ui thread but this may + // be called from code that is executed by runOffUiThread() + Utils.showToastFromService(context, msgBuilder.toString(), LENGTH_LONG) } } diff --git a/app/src/main/java/org/fdroid/fdroid/net/DownloaderFactory.java b/app/src/main/java/org/fdroid/fdroid/net/DownloaderFactory.java index 842d545cc..19f99c36d 100644 --- a/app/src/main/java/org/fdroid/fdroid/net/DownloaderFactory.java +++ b/app/src/main/java/org/fdroid/fdroid/net/DownloaderFactory.java @@ -33,8 +33,12 @@ public class DownloaderFactory extends org.fdroid.download.DownloaderFactory { private static final String TAG = "DownloaderFactory"; // TODO move to application object or inject where needed public static final DownloaderFactory INSTANCE = new DownloaderFactory(); - public static final HttpManager HTTP_MANAGER = - new HttpManager(Utils.getUserAgent(), FDroidApp.queryString, NetCipher.getProxy(), new DnsWithCache()); + public static final HttpManager HTTP_MANAGER = new HttpManager( + Utils.getUserAgent(), + FDroidApp.queryString, + NetCipher.getProxy(), + new DnsWithCache(), + new FDroidMirrorParameterManager()); @NonNull @Override diff --git a/app/src/main/java/org/fdroid/fdroid/net/FDroidMirrorParameterManager.java b/app/src/main/java/org/fdroid/fdroid/net/FDroidMirrorParameterManager.java new file mode 100644 index 000000000..7d8f22234 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/net/FDroidMirrorParameterManager.java @@ -0,0 +1,88 @@ +package org.fdroid.fdroid.net; + +import android.content.Context; +import android.telephony.TelephonyManager; + +import androidx.annotation.NonNull; +import androidx.core.os.LocaleListCompat; + +import org.fdroid.download.MirrorParameterManager; +import org.fdroid.fdroid.FDroidApp; +import org.fdroid.fdroid.Preferences; +import org.fdroid.fdroid.data.App; + +import java.util.HashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +public class FDroidMirrorParameterManager implements MirrorParameterManager { + + private volatile HashMap errorCache; + private static final int DELAY_TIME = 1; + private static final TimeUnit DELAY_UNIT = TimeUnit.SECONDS; + private volatile boolean writeErrorScheduled = false; + + private final Runnable delayedErrorWrite = () -> { + Preferences prefs = Preferences.get(); + prefs.setMirrorErrorData(errorCache); + writeErrorScheduled = false; + }; + + private final ScheduledExecutorService writeErrorExecutor = Executors.newSingleThreadScheduledExecutor(); + + public FDroidMirrorParameterManager() { + Preferences prefs = Preferences.get(); + errorCache = prefs.getMirrorErrorData(); + } + + public void updateErrorCacheAndPrefs(@NonNull String url, @NonNull Integer errorCount) { + errorCache.put(url, errorCount); + if (!writeErrorScheduled) { + writeErrorScheduled = true; + writeErrorExecutor.schedule(delayedErrorWrite, DELAY_TIME, DELAY_UNIT); + } + } + + @Override + public void incrementMirrorErrorCount(@NonNull String mirrorUrl) { + if (errorCache.containsKey(mirrorUrl)) { + updateErrorCacheAndPrefs(mirrorUrl, errorCache.get(mirrorUrl) + 1); + } else { + updateErrorCacheAndPrefs(mirrorUrl, 1); + } + } + + @Override + public int getMirrorErrorCount(@NonNull String mirrorUrl) { + if (errorCache.containsKey(mirrorUrl)) { + return errorCache.get(mirrorUrl); + } else { + return 0; + } + } + + @Override + public boolean preferForeignMirrors() { + Preferences prefs = Preferences.get(); + return prefs.isPreferForeignSet(); + } + + @NonNull + @Override + public String getCurrentLocation() { + TelephonyManager tm = (TelephonyManager) FDroidApp.getInstance().getSystemService(Context.TELEPHONY_SERVICE); + if (tm.getSimCountryIso() != null) { + return tm.getSimCountryIso(); + } else if (tm.getNetworkCountryIso() != null) { + return tm.getNetworkCountryIso(); + } else { + LocaleListCompat localeList = App.getLocales(); + if (localeList != null && localeList.size() > 0) { + return localeList.get(0).getCountry(); + } else { + return ""; + } + } + } +} diff --git a/app/src/main/java/org/fdroid/fdroid/views/PreferencesFragment.java b/app/src/main/java/org/fdroid/fdroid/views/PreferencesFragment.java index 34dbcbf8a..5d1fe93a9 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/PreferencesFragment.java +++ b/app/src/main/java/org/fdroid/fdroid/views/PreferencesFragment.java @@ -118,6 +118,7 @@ public class PreferencesFragment extends PreferenceFragmentCompat private LiveSeekBarPreference updateIntervalSeekBar; private SwitchPreferenceCompat enableProxyCheckPref; private SwitchPreferenceCompat useDnsCacheCheckPref; + private SwitchPreferenceCompat preferForeignCheckPref; private SwitchPreferenceCompat useTorCheckPref; private Preference updateAutoDownloadPref; private SwitchPreferenceCompat keepInstallHistoryPref; @@ -161,6 +162,8 @@ public class PreferencesFragment extends PreferenceFragmentCompat } useDnsCacheCheckPref = ObjectsCompat.requireNonNull(findPreference(Preferences.PREF_USE_DNS_CACHE)); + preferForeignCheckPref = ObjectsCompat.requireNonNull(findPreference(Preferences.PREF_PREFER_FOREIGN)); + useTorCheckPref = ObjectsCompat.requireNonNull(findPreference(Preferences.PREF_USE_TOR)); useTorCheckPref.setOnPreferenceChangeListener(useTorChangedListener); enableProxyCheckPref = ObjectsCompat.requireNonNull(findPreference(Preferences.PREF_ENABLE_PROXY)); @@ -548,6 +551,11 @@ public class PreferencesFragment extends PreferenceFragmentCompat useDnsCacheCheckPref.setChecked(Preferences.get().isDnsCacheEnabled()); } + private void initPreferForeignPreference() { + preferForeignCheckPref.setDefaultValue(false); + preferForeignCheckPref.setChecked(Preferences.get().isPreferForeignSet()); + } + /** * The default for "Use Tor" is dynamically set based on whether Orbot is installed. */ @@ -600,6 +608,7 @@ public class PreferencesFragment extends PreferenceFragmentCompat initAutoFetchUpdatesPreference(); initPrivilegedInstallerPreference(); initUseDnsCachePreference(); + initPreferForeignPreference(); initUseTorPreference(requireContext().getApplicationContext()); updateIpfsGatewaySummary(); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8f8e25dcb..50d3fb4c5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -372,6 +372,8 @@ This often occurs with apps installed via Google Play or other sources, if they Use DNS Cache Use cached results to minimize DNS queries. + Prefer Foreign Mirrors + Try mirrors that are located outside your country first, e.g. if foreign protections are stronger. Use Tor Force download traffic through Tor for increased privacy. Requires Orbot Proxy diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index a374202c1..188726b87 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -121,10 +121,6 @@ android:targetClass="org.fdroid.fdroid.views.IpfsGatewaySettingsActivity" android:targetPackage="@string/applicationId" /> - + + + = getHttpClientEngineFactory( customDns ), diff --git a/libs/download/src/commonMain/kotlin/org/fdroid/download/MirrorChooser.kt b/libs/download/src/commonMain/kotlin/org/fdroid/download/MirrorChooser.kt index bebed90da..934f3c68a 100644 --- a/libs/download/src/commonMain/kotlin/org/fdroid/download/MirrorChooser.kt +++ b/libs/download/src/commonMain/kotlin/org/fdroid/download/MirrorChooser.kt @@ -1,6 +1,5 @@ package org.fdroid.download -import io.ktor.client.network.sockets.SocketTimeoutException import io.ktor.client.plugins.ResponseException import io.ktor.http.HttpStatusCode.Companion.Forbidden import io.ktor.http.HttpStatusCode.Companion.NotFound @@ -30,20 +29,30 @@ internal abstract class MirrorChooserImpl : MirrorChooser { request: suspend (mirror: Mirror, url: Url) -> T, ): T { val mirrors = if (downloadRequest.proxy == null) { + // keep ordered mirror list rather than reverting back to raw list from request + val orderedMirrors = orderMirrors(downloadRequest) // if we don't use a proxy, filter out onion mirrors (won't work without Orbot) - val orderedMirrors = - orderMirrors(downloadRequest).filter { mirror -> !mirror.isOnion() } - // if we only have onion mirrors, take what we have and expect errors - orderedMirrors.ifEmpty { downloadRequest.mirrors } + val filteredMirrors = orderedMirrors.filter { mirror -> !mirror.isOnion() } + if (filteredMirrors.isEmpty()) { + // if we only have onion mirrors, take what we have and expect errors + orderedMirrors + } else { + filteredMirrors + } } else { orderMirrors(downloadRequest) } + + if (mirrors.isEmpty()) { + error("No valid mirrors were found. Check settings.") + } + mirrors.forEachIndexed { index, mirror -> val ipfsCidV1 = downloadRequest.indexFile.ipfsCidV1 val url = if (mirror.isIpfsGateway) { if (ipfsCidV1 == null) { val e = IOException("Got IPFS gateway without CID") - throwOnLastMirror(e, index == mirrors.size - 1) + handleException(e, mirror, index, mirrors.size) return@forEachIndexed } else mirror.getUrl(ipfsCidV1) } else { @@ -57,20 +66,19 @@ internal abstract class MirrorChooserImpl : MirrorChooser { // don't try other mirrors if we got NotFount response and downloaded a repo if (downloadRequest.tryFirstMirror != null && e.response.status == NotFound) throw e // also throw if this is the last mirror to try, otherwise try next - throwOnLastMirror(e, index == mirrors.size - 1) + handleException(e, mirror, index, mirrors.size) } catch (e: IOException) { - throwOnLastMirror(e, index == mirrors.size - 1) - } catch (e: SocketTimeoutException) { - throwOnLastMirror(e, index == mirrors.size - 1) + handleException(e, mirror, index, mirrors.size) } catch (e: NoResumeException) { // continue to next mirror, if we need to resume, but this one doesn't support it - throwOnLastMirror(e, index == mirrors.size - 1) + handleException(e, mirror, index, mirrors.size) } } error("Reached code that was thought to be unreachable.") } - private fun throwOnLastMirror(e: Exception, wasLastMirror: Boolean) { + open fun handleException(e: Exception, mirror: Mirror, mirrorIndex: Int, mirrorCount: Int) { + val wasLastMirror = mirrorIndex == mirrorCount - 1 log.info { val info = if (e is ResponseException) e.response.status.toString() else e::class.simpleName ?: "" @@ -97,3 +105,95 @@ internal class MirrorChooserRandom : MirrorChooserImpl() { } } + +internal class MirrorChooserWithParameters( + private val mirrorParameterManager: MirrorParameterManager? = null +) : MirrorChooserImpl() { + + override fun orderMirrors(downloadRequest: DownloadRequest): List { + val errorComparator = Comparator { mirror1: Mirror, mirror2: Mirror -> + // if no parameter manager is available, default to 0 (should return equal) + val error1 = mirrorParameterManager?.getMirrorErrorCount(mirror1.baseUrl) ?: 0 + val error2 = mirrorParameterManager?.getMirrorErrorCount(mirror2.baseUrl) ?: 0 + + // prefer mirrors with fewer errors + error1.compareTo(error2) + } + + val mirrorList: MutableList = mutableListOf() + + if (mirrorParameterManager != null && + mirrorParameterManager.getCurrentLocation().isNotEmpty() + ) { + // if we have access to mirror parameters and the current location, + // then use that information to sort the mirror list + val mirrorFilteredList: List = sortMirrorsByLocation( + mirrorParameterManager.preferForeignMirrors(), + downloadRequest.mirrors, + mirrorParameterManager.getCurrentLocation(), + errorComparator + ) + mirrorList.addAll(mirrorFilteredList) + } else { + // shuffle initial list so all viable mirrors will be tried + // then sort list to avoid mirrors that have caused errors + val mirrorCompleteList: List = + downloadRequest.mirrors + .toMutableList() + .apply { shuffle() } + .sortedWith(errorComparator) + mirrorList.addAll(mirrorCompleteList) + } + + // respect the mirror to try first, if set + if (downloadRequest.tryFirstMirror != null) { + mirrorList.sortBy { if (it == downloadRequest.tryFirstMirror) 0 else 1 } + } + + return mirrorList + } + + private fun sortMirrorsByLocation( + foreignMirrorsPreferred: Boolean, + availableMirrorList: List, + currentLocation: String, + mirrorComparator: Comparator + ): List { + // shuffle initial list so all viable mirrors will be tried + // then sort list to avoid mirrors that have caused errors + val mirrorList: MutableList = mutableListOf() + val sortedList: List = availableMirrorList + .toMutableList() + .apply { shuffle() } + .sortedWith(mirrorComparator) + + val domesticList: List = sortedList.filter { mirror -> + !mirror.countryCode.isNullOrEmpty() && currentLocation == mirror.countryCode + } + val foreignList: List = sortedList.filter { mirror -> + !mirror.countryCode.isNullOrEmpty() && currentLocation != mirror.countryCode + } + val unknownList: List = sortedList.filter { mirror -> + mirror.countryCode.isNullOrEmpty() + } + + if (foreignMirrorsPreferred) { + mirrorList.addAll(foreignList) + mirrorList.addAll(unknownList) + mirrorList.addAll(domesticList) + } else { + mirrorList.addAll(domesticList) + mirrorList.addAll(unknownList) + mirrorList.addAll(foreignList) + } + + return mirrorList + } + + override fun handleException(e: Exception, mirror: Mirror, mirrorIndex: Int, mirrorCount: Int) { + if (e is ResponseException || e is IOException) { + mirrorParameterManager?.incrementMirrorErrorCount(mirror.baseUrl) + } + super.handleException(e, mirror, mirrorIndex, mirrorCount) + } +} diff --git a/libs/download/src/commonMain/kotlin/org/fdroid/download/MirrorParameterManager.kt b/libs/download/src/commonMain/kotlin/org/fdroid/download/MirrorParameterManager.kt new file mode 100644 index 000000000..a1a7fe75a --- /dev/null +++ b/libs/download/src/commonMain/kotlin/org/fdroid/download/MirrorParameterManager.kt @@ -0,0 +1,32 @@ +package org.fdroid.download + +/** + * This is an interface for providing access to stored parameters for mirrors without adding + * additional dependencies. The expectation is that this will be used to store and retrieve + * data about mirror performance to use when ordering mirror for subsequent tests. + * + * Currently it supports success and error count, but other parameters could be added later. + */ + +public interface MirrorParameterManager { + + /** + * Set or get the number of failed attempts to access the specified mirror. The intent + * is to order mirrors for subsequent tests based on the number of failures. + */ + public fun incrementMirrorErrorCount(mirrorUrl: String) + + public fun getMirrorErrorCount(mirrorUrl: String): Int + + /** + * Returns true or false depending on whether the location preference has been enabled. This + * preference reflects whether mirrors matching your location should get priority. + */ + public fun preferForeignMirrors(): Boolean + + /** + * Returns the country code of the user's current location + */ + public fun getCurrentLocation(): String + +} diff --git a/libs/download/src/commonTest/kotlin/org/fdroid/download/MirrorChooserTest.kt b/libs/download/src/commonTest/kotlin/org/fdroid/download/MirrorChooserTest.kt index e697e9601..5e7925bf0 100644 --- a/libs/download/src/commonTest/kotlin/org/fdroid/download/MirrorChooserTest.kt +++ b/libs/download/src/commonTest/kotlin/org/fdroid/download/MirrorChooserTest.kt @@ -2,6 +2,8 @@ package org.fdroid.download import io.ktor.client.network.sockets.SocketTimeoutException import io.ktor.utils.io.errors.IOException +import io.mockk.every +import io.mockk.mockk import org.fdroid.getIndexFile import org.fdroid.runSuspend import kotlin.random.Random @@ -12,8 +14,27 @@ import kotlin.test.assertTrue class MirrorChooserTest { - private val mirrors = listOf(Mirror("foo"), Mirror("bar"), Mirror("42"), Mirror("1337")) + private val mirrors = listOf( + Mirror("foo"), + Mirror("bar"), + Mirror("42"), + Mirror("1337")) + private val mirrorsLocation = listOf( + Mirror(baseUrl = "unknown_1", countryCode = null), + Mirror(baseUrl = "unknown_2", countryCode = null), + Mirror(baseUrl = "unknown_3", countryCode = null), + Mirror(baseUrl = "local_1", countryCode = "HERE"), + Mirror(baseUrl = "local_2", countryCode = "HERE"), + Mirror(baseUrl = "local_3", countryCode = "HERE"), + Mirror(baseUrl = "remote_1", countryCode = "THERE"), + Mirror(baseUrl = "remote_2", countryCode = "THERE"), + Mirror(baseUrl = "remote_3", countryCode = "THERE")) private val downloadRequest = DownloadRequest("foo", mirrors) + private val downloadRequestLocation = DownloadRequest("location", mirrorsLocation) + private val downloadRequestTryFIrst = DownloadRequest( + path = "location", + mirrors = mirrorsLocation, + tryFirstMirror = Mirror(baseUrl = "remote_1", countryCode = "THERE")) private val ipfsIndexFile = getIndexFile(name = "foo", ipfsCidV1 = "CIDv1") @@ -141,4 +162,115 @@ class MirrorChooserTest { assertEquals("Got IPFS gateway without CID", e.message) } + @Test + fun testMirrorChooserDomesticLocation() { + val mockManager = mockk(relaxed = true) + every { mockManager.getCurrentLocation() } returns "HERE" + every { mockManager.preferForeignMirrors() } returns false + + val mirrorChooser = MirrorChooserWithParameters(mockManager) + + // test domestic mirror preference + val domesticList = mirrorChooser.orderMirrors(downloadRequestLocation) + // confirm the list contains all mirrors + assertEquals(9, domesticList.size) + // mirrors that are local should be included first + assertEquals("HERE", domesticList[0].countryCode) + assertEquals("HERE", domesticList[1].countryCode) + assertEquals("HERE", domesticList[2].countryCode) + assertEquals(null, domesticList[3].countryCode) + } + + @Test + fun testMirrorChooserForeignLocation() { + val mockManager = mockk(relaxed = true) + every { mockManager.getCurrentLocation() } returns "HERE" + every { mockManager.preferForeignMirrors() } returns true + + val mirrorChooser = MirrorChooserWithParameters(mockManager) + + // test foreign mirror preference + val foreignList = mirrorChooser.orderMirrors(downloadRequestLocation) + // confirm the list contains all mirrors + assertEquals(9, foreignList.size) + // mirrors that are remote should be included first + assertEquals("THERE", foreignList[0].countryCode) + assertEquals("THERE", foreignList[1].countryCode) + assertEquals("THERE", foreignList[2].countryCode) + assertEquals(null, foreignList[3].countryCode) + } + + @Test + fun testMirrorChooserErrorSort() { + val mockManager = mockk(relaxed = true) + every { mockManager.getCurrentLocation() } returns "HERE" + every { mockManager.preferForeignMirrors() } returns false + every { mockManager.getMirrorErrorCount("local_1") } returns 5 + every { mockManager.getMirrorErrorCount("local_2") } returns 3 + every { mockManager.getMirrorErrorCount("local_3") } returns 1 + + val mirrorChooser = MirrorChooserWithParameters(mockManager) + + // test error sorting with domestic mirror preference + val orderedList = mirrorChooser.orderMirrors(downloadRequestLocation) + // confirm the list contains all mirrors + assertEquals(9, orderedList.size) + // mirrors that have fewer errors should be included first + assertEquals("local_3", orderedList[0].baseUrl) + assertEquals("local_2", orderedList[1].baseUrl) + assertEquals("local_1", orderedList[2].baseUrl) + } + + @Test + fun testMirrorChooserDomesticWithTryFirst() { + val mockManager = mockk(relaxed = true) + every { mockManager.getCurrentLocation() } returns "HERE" + every { mockManager.preferForeignMirrors() } returns false + + val mirrorChooser = MirrorChooserWithParameters(mockManager) + + // test tryfirst mirror parameter + val tryFirstList = mirrorChooser.orderMirrors(downloadRequestTryFIrst) + // confirm the list contains all mirrors + assertEquals(9, tryFirstList.size) + // tryfirst mirror should be included before local mirrors + assertEquals("remote_1", tryFirstList[0].baseUrl) + assertEquals("HERE", tryFirstList[1].countryCode) + assertEquals("HERE", tryFirstList[2].countryCode) + assertEquals("HERE", tryFirstList[3].countryCode) + } + + @Test + fun testMirrorChooserRandomization() { + val mockManager = mockk(relaxed = true) + every { mockManager.getCurrentLocation() } returns "HERE" + every { mockManager.preferForeignMirrors() } returns false + + val mirrorChooser = MirrorChooserWithParameters(mockManager) + + // repeat test to verify that if error count is equal the order isn't always the same + var count1 = 0 + var count2 = 0 + var count3 = 0 + var countX = 0 + repeat(100) { + // test error sorting with domestic mirror preference + val orderedList = mirrorChooser.orderMirrors(downloadRequestLocation) + if (orderedList[0].baseUrl.equals("local_1")) { + count1++ + } else if (orderedList[0].baseUrl.equals("local_2")) { + count2++ + } else if (orderedList[0].baseUrl.equals("local_3")) { + count3++ + } else { + countX++ + } + } + // all domestic urls should have appeared first in the list at least once + assertTrue { count1 > 0 } + assertTrue { count2 > 0 } + assertTrue { count3 > 0 } + // no foreign urls should should have appeared first in the list + assertEquals(0, countX) + } }