diff --git a/app/build.gradle b/app/build.gradle index ab607a5e5..9998dc4b2 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -193,6 +193,7 @@ dependencies { } annotationProcessor "com.github.bumptech.glide:compiler:4.14.2" + implementation 'com.squareup.okhttp3:okhttp:4.10.0' implementation 'org.bouncycastle:bcprov-jdk15to18:1.71' fullImplementation 'info.guardianproject.panic:panic:1.0' fullImplementation 'org.bouncycastle:bcpkix-jdk15to18:1.71' diff --git a/app/src/androidTest/java/org/fdroid/fdroid/net/DnsWithCacheTest.java b/app/src/androidTest/java/org/fdroid/fdroid/net/DnsWithCacheTest.java new file mode 100644 index 000000000..6915d2829 --- /dev/null +++ b/app/src/androidTest/java/org/fdroid/fdroid/net/DnsWithCacheTest.java @@ -0,0 +1,78 @@ +package org.fdroid.fdroid.net; + +import static org.junit.Assert.assertEquals; + +import org.fdroid.fdroid.Preferences; +import org.junit.Test; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Arrays; +import java.util.List; + +public class DnsWithCacheTest { + + private static final String URL_1 = "fdroid.com"; + private static final String URL_2 = "fdroid.org"; + private static final String URL_3 = "fdroid.net"; + + private static final InetAddress IP_1; + private static final InetAddress IP_2; + private static final InetAddress IP_3; + + static { + try { + IP_1 = InetAddress.getByName("127.0.0.1"); + IP_2 = InetAddress.getByName("127.0.0.2"); + IP_3 = InetAddress.getByName("127.0.0.3"); + } catch (UnknownHostException e) { + throw new RuntimeException(e); + } + } + + private static final List LIST_1 = Arrays.asList(IP_1, IP_2, IP_3); + private static final List LIST_2 = Arrays.asList(IP_2); + private static final List LIST_3 = Arrays.asList(IP_3); + + @Test + public void basicCacheTest() throws IOException, InterruptedException { + // test setup + Preferences prefs = Preferences.get(); + prefs.setDnsCacheEnabledValue(true); + DnsWithCache testObject = new DnsWithCache(); + + // populate cache + testObject.updateCacheAndPrefs(URL_1, LIST_1); + testObject.updateCacheAndPrefs(URL_2, LIST_2); + testObject.updateCacheAndPrefs(URL_3, LIST_3); + + // check for cached lookup results + List testList = testObject.lookup(URL_1); + assertEquals(3, testList.size()); + assertEquals(IP_1.getHostAddress(), testList.get(0).getHostAddress()); + assertEquals(IP_2.getHostAddress(), testList.get(1).getHostAddress()); + assertEquals(IP_3.getHostAddress(), testList.get(2).getHostAddress()); + + // toggle preference (false) + prefs.setDnsCacheEnabledValue(false); + + // attempt non-cached lookup + boolean gotException = false; + try { + testList = testObject.lookup(URL_1); + } catch (UnknownHostException e) { + // test urls are not valid + gotException = true; + } + assertEquals(true, gotException); + + // toggle preference (true) + prefs.setDnsCacheEnabledValue(true); + + // confirm lookup results remain in cache + testList = testObject.lookup(URL_2); + assertEquals(1, testList.size()); + assertEquals(IP_2.getHostAddress(), testList.get(0).getHostAddress()); + } +} 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 211d859e9..32510b95a 100644 --- a/app/src/androidTest/java/org/fdroid/fdroid/net/HttpDownloaderTest.java +++ b/app/src/androidTest/java/org/fdroid/fdroid/net/HttpDownloaderTest.java @@ -31,7 +31,13 @@ import java.util.concurrent.TimeUnit; public class HttpDownloaderTest { private static final String TAG = "HttpDownloaderTest"; - private final HttpManager httpManager = new HttpManager(Utils.getUserAgent(), FDroidApp.queryString, null, true); + private final HttpManager httpManager = new HttpManager( + Utils.getUserAgent(), + FDroidApp.queryString, + null, + null, + true + ); private static final Collection> URLS; // https://developer.android.com/reference/javax/net/ssl/SSLContext diff --git a/app/src/main/java/org/fdroid/fdroid/Preferences.java b/app/src/main/java/org/fdroid/fdroid/Preferences.java index c7f9e0725..d46615515 100644 --- a/app/src/main/java/org/fdroid/fdroid/Preferences.java +++ b/app/src/main/java/org/fdroid/fdroid/Preferences.java @@ -35,10 +35,14 @@ 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.installer.PrivilegedInstaller; import org.fdroid.fdroid.net.ConnectivityMonitorService; +import java.net.InetAddress; +import java.net.UnknownHostException; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -118,6 +122,8 @@ public final class Preferences implements SharedPreferences.OnSharedPreferenceCh public static final String PREF_LOCAL_REPO_HTTPS = "localRepoHttps"; private static final String PREF_SCAN_REMOVABLE_STORAGE = "scanRemovableStorage"; 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_USE_TOR = "useTor"; public static final String PREF_ENABLE_PROXY = "enableProxy"; public static final String PREF_PROXY_HOST = "proxyHost"; @@ -512,6 +518,98 @@ public final class Preferences implements SharedPreferences.OnSharedPreferenceCh } } + public void setDnsCacheEnabledValue(boolean newValue) { + preferences.edit().putBoolean(PREF_USE_DNS_CACHE, newValue).apply(); + } + + public boolean isDnsCacheEnabled() { + return preferences.getBoolean(PREF_USE_DNS_CACHE, false); + } + + public void updateDnsCache(String urlString, List ipList) { + // existing list is replaced, so make sure new list has values + if (ipList == null || ipList.isEmpty()) { + return; + } else { + HashMap> dnsMap = getDnsCache(); + dnsMap.put(urlString, ipList); + setDnsCache(dnsMap); + } + } + + public void setDnsCache(HashMap> dnsMap) { + HashMap> stringMap = new HashMap>(); + for (String url : dnsMap.keySet()) { + List stringList = new ArrayList(); + for (InetAddress ip : dnsMap.get(url)) { + stringList.add(ip.getHostAddress()); + } + stringMap.put(url, stringList); + } + preferences.edit().putString(PREF_DNS_CACHE, listMapToString(stringMap)).apply(); + } + + public List queryDnsCache(String urlString) { + HashMap> dnsMap = getDnsCache(); + if (dnsMap.containsKey(urlString)) { + return dnsMap.get(urlString); + } else { + // returns empty list to avoid null issues + return new ArrayList(); + } + } + + public HashMap> getDnsCache() { + HashMap> dnsMap = new HashMap>(); + String mapString = preferences.getString(PREF_DNS_CACHE, ""); + if (mapString == null || mapString.isEmpty()) { + // returns empty map to avoid null issues + return dnsMap; + } + HashMap> stringMap = stringToListMap(mapString); + for (String url : stringMap.keySet()) { + List ipList = new ArrayList(); + for (String ip : stringMap.get(url)) { + try { + 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); + } + } + dnsMap.put(url, ipList); + } + return dnsMap; + } + + private String listMapToString(HashMap> listMap) { + String output = ""; + for (String key : listMap.keySet()) { + if (!output.isEmpty()) { + output = output + "\n"; + } + output = output + key; + for (String item : listMap.get(key)) { + if (!output.isEmpty()) { + output = output + " "; + } + output = output + item; + } + } + return output; + } + + private HashMap> stringToListMap(String string) { + HashMap> output = new HashMap>(); + for (String line : string.split("\n")) { + String[] items = line.split(" "); + List list = Lists.newArrayList(items); + String key = list.remove(0); + output.put(key, list); + } + return output; + } + /** * 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/net/DnsWithCache.java b/app/src/main/java/org/fdroid/fdroid/net/DnsWithCache.java new file mode 100644 index 000000000..eb6ee37f9 --- /dev/null +++ b/app/src/main/java/org/fdroid/fdroid/net/DnsWithCache.java @@ -0,0 +1,67 @@ +package org.fdroid.fdroid.net; + +import androidx.annotation.NonNull; + +import org.fdroid.fdroid.Preferences; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import okhttp3.Dns; + +public class DnsWithCache implements Dns { + + private volatile HashMap> cache; + private static final int DELAY_TIME = 1; + private static final TimeUnit DELAY_UNIT = TimeUnit.SECONDS; + private volatile boolean writeScheduled = false; + private final Runnable delayedWrite = () -> { + Preferences prefs = Preferences.get(); + prefs.setDnsCache(cache); + writeScheduled = false; + }; + + private final ScheduledExecutorService writeExecutor = Executors.newSingleThreadScheduledExecutor(); + + public DnsWithCache() { + Preferences prefs = Preferences.get(); + cache = prefs.getDnsCache(); + } + + public void updateCacheAndPrefs(@NonNull String url, @NonNull List ipList) { + updateCache(url, ipList); + if (!writeScheduled) { + writeScheduled = true; + writeExecutor.schedule(delayedWrite, DELAY_TIME, DELAY_UNIT); + } + } + + public void updateCache(@NonNull String url, @NonNull List ipList) { + if (cache == null) { + cache = new HashMap>(); + } + cache.put(url, ipList); + } + + @NonNull + @Override + public List lookup(@NonNull String url) throws UnknownHostException { + Preferences prefs = Preferences.get(); + if (!prefs.isDnsCacheEnabled() + || cache == null + || !cache.keySet().contains(url)) { + // do dns lookup and cache ip list + List ipList = Dns.SYSTEM.lookup(url); + updateCacheAndPrefs(url, ipList); + return ipList; + } else { + // return cached ip list if available + return cache.get(url); + } + } +} 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 d2f4d72d1..4e212afe3 100644 --- a/app/src/main/java/org/fdroid/fdroid/net/DownloaderFactory.java +++ b/app/src/main/java/org/fdroid/fdroid/net/DownloaderFactory.java @@ -33,7 +33,7 @@ public class DownloaderFactory extends org.fdroid.download.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 HttpManager(Utils.getUserAgent(), FDroidApp.queryString, NetCipher.getProxy(), new DnsWithCache()); @NonNull @Override 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 413e878a0..613f03ece 100644 --- a/app/src/main/java/org/fdroid/fdroid/views/PreferencesFragment.java +++ b/app/src/main/java/org/fdroid/fdroid/views/PreferencesFragment.java @@ -112,6 +112,7 @@ public class PreferencesFragment extends PreferenceFragmentCompat private LiveSeekBarPreference overDataSeekBar; private LiveSeekBarPreference updateIntervalSeekBar; private SwitchPreferenceCompat enableProxyCheckPref; + private SwitchPreferenceCompat useDnsCacheCheckPref; private SwitchPreferenceCompat useTorCheckPref; private Preference updateAutoDownloadPref; private SwitchPreferenceCompat keepInstallHistoryPref; @@ -154,6 +155,7 @@ public class PreferencesFragment extends PreferenceFragmentCompat installHistoryPref.setTitle(R.string.install_history); } + useDnsCacheCheckPref = ObjectsCompat.requireNonNull(findPreference(Preferences.PREF_USE_DNS_CACHE)); useTorCheckPref = ObjectsCompat.requireNonNull(findPreference(Preferences.PREF_USE_TOR)); useTorCheckPref.setOnPreferenceChangeListener(useTorChangedListener); enableProxyCheckPref = ObjectsCompat.requireNonNull(findPreference(Preferences.PREF_ENABLE_PROXY)); @@ -510,6 +512,11 @@ public class PreferencesFragment extends PreferenceFragmentCompat } } + private void initUseDnsCachePreference() { + useDnsCacheCheckPref.setDefaultValue(false); + useDnsCacheCheckPref.setChecked(Preferences.get().isDnsCacheEnabled()); + } + /** * The default for "Use Tor" is dynamically set based on whether Orbot is installed. */ @@ -561,6 +568,7 @@ public class PreferencesFragment extends PreferenceFragmentCompat initAutoFetchUpdatesPreference(); initPrivilegedInstallerPreference(); + initUseDnsCachePreference(); initUseTorPreference(requireContext().getApplicationContext()); updateIpfsGatewaySummary(); diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c4532e927..5a9e4a084 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -346,6 +346,8 @@ This often occurs with apps installed via Google Play or other sources, if they Skip Try again + Use DNS Cache + Use cached results to minimize DNS queries. 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 5ccc1d586..a374202c1 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -121,6 +121,10 @@ android:targetClass="org.fdroid.fdroid.views.IpfsGatewaySettingsActivity" android:targetPackage="@string/applicationId" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/libs/database/build.gradle b/libs/database/build.gradle index 85224f68a..5e43d9a11 100644 --- a/libs/database/build.gradle +++ b/libs/database/build.gradle @@ -90,6 +90,7 @@ dependencies { testImplementation 'ch.qos.logback:logback-classic:1.4.5' testImplementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.7.3' testImplementation 'app.cash.turbine:turbine:1.0.0' + testImplementation 'com.squareup.okhttp3:okhttp:4.10.0' androidTestImplementation project(":libs:sharedTest") androidTestImplementation 'io.mockk:mockk-android:1.12.3' // 1.12.4 has strange error diff --git a/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpManager.kt b/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpManager.kt index 8d1e92961..d63e62b7e 100644 --- a/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpManager.kt +++ b/libs/download/src/androidMain/kotlin/org/fdroid/download/HttpManager.kt @@ -15,7 +15,7 @@ import java.io.InputStream import java.net.InetAddress import java.security.MessageDigest -internal actual fun getHttpClientEngineFactory(): HttpClientEngineFactory<*> { +internal actual fun getHttpClientEngineFactory(customDns: Dns?): HttpClientEngineFactory<*> { return object : HttpClientEngineFactory { private val connectionSpecs = listOf( RESTRICTED_TLS, // order matters here, so we put restricted before modern @@ -28,6 +28,8 @@ internal actual fun getHttpClientEngineFactory(): HttpClientEngineFactory<*> { config { if (proxy.isTor()) { // don't allow DNS requests when using Tor dns(NoDns()) + } else if (customDns != null) { + dns(customDns) } hostnameVerifier { hostname, session -> session?.sessionContext?.sessionTimeout = 60 diff --git a/libs/download/src/commonMain/kotlin/org/fdroid/download/HttpManager.kt b/libs/download/src/commonMain/kotlin/org/fdroid/download/HttpManager.kt index 6f4a3b001..1c21e50ce 100644 --- a/libs/download/src/commonMain/kotlin/org/fdroid/download/HttpManager.kt +++ b/libs/download/src/commonMain/kotlin/org/fdroid/download/HttpManager.kt @@ -37,18 +37,22 @@ import io.ktor.utils.io.core.isEmpty import io.ktor.utils.io.core.readBytes import io.ktor.utils.io.writeFully import mu.KotlinLogging +import okhttp3.Dns import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import kotlin.coroutines.cancellation.CancellationException -internal expect fun getHttpClientEngineFactory(): HttpClientEngineFactory<*> +internal expect fun getHttpClientEngineFactory(customDns: Dns?): HttpClientEngineFactory<*> public open class HttpManager @JvmOverloads constructor( private val userAgent: String, queryString: String? = null, proxyConfig: ProxyConfig? = null, + customDns: Dns? = null, private val highTimeouts: Boolean = false, private val mirrorChooser: MirrorChooser = MirrorChooserRandom(), - private val httpClientEngineFactory: HttpClientEngineFactory<*> = getHttpClientEngineFactory(), + private val httpClientEngineFactory: HttpClientEngineFactory<*> = getHttpClientEngineFactory( + customDns + ), ) { public companion object { diff --git a/libs/download/src/jvmMain/kotlin/org/fdroid/download/HttpManager.kt b/libs/download/src/jvmMain/kotlin/org/fdroid/download/HttpManager.kt index 7049c7ea6..a8de8629d 100644 --- a/libs/download/src/jvmMain/kotlin/org/fdroid/download/HttpManager.kt +++ b/libs/download/src/jvmMain/kotlin/org/fdroid/download/HttpManager.kt @@ -3,6 +3,6 @@ package org.fdroid.download import io.ktor.client.engine.HttpClientEngineFactory import io.ktor.client.engine.cio.CIO -internal actual fun getHttpClientEngineFactory(): HttpClientEngineFactory<*> { +internal actual fun getHttpClientEngineFactory(customDns: Dns?): HttpClientEngineFactory<*> { return CIO } diff --git a/libs/download/src/nativeMain/kotlin/org/fdroid/download/HttpManager.kt b/libs/download/src/nativeMain/kotlin/org/fdroid/download/HttpManager.kt index a7289d4ff..fa11843dc 100644 --- a/libs/download/src/nativeMain/kotlin/org/fdroid/download/HttpManager.kt +++ b/libs/download/src/nativeMain/kotlin/org/fdroid/download/HttpManager.kt @@ -3,6 +3,6 @@ package org.fdroid.download import io.ktor.client.engine.HttpClientEngineFactory import io.ktor.client.engine.curl.Curl -internal actual fun getHttpClientEngineFactory(): HttpClientEngineFactory<*> { +internal actual fun getHttpClientEngineFactory(customDns: Dns?): HttpClientEngineFactory<*> { return Curl }