diff --git a/modules/native-doh/android/build.gradle b/modules/native-doh/android/build.gradle
new file mode 100644
index 00000000..a88c5965
--- /dev/null
+++ b/modules/native-doh/android/build.gradle
@@ -0,0 +1,20 @@
+plugins {
+ id 'com.android.library'
+ id 'expo-module-gradle-plugin'
+}
+
+group = 'expo.modules.nativedoh'
+version = '0.1.0'
+
+android {
+ namespace "expo.modules.nativedoh"
+ defaultConfig {
+ versionCode 1
+ versionName "0.1.0"
+ }
+}
+
+dependencies {
+ implementation 'com.facebook.react:react-android'
+ implementation 'com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.2'
+}
diff --git a/modules/native-doh/android/src/main/AndroidManifest.xml b/modules/native-doh/android/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..0c85249a
--- /dev/null
+++ b/modules/native-doh/android/src/main/AndroidManifest.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
diff --git a/modules/native-doh/android/src/main/java/expo/modules/nativedoh/DohInitializationProvider.kt b/modules/native-doh/android/src/main/java/expo/modules/nativedoh/DohInitializationProvider.kt
new file mode 100644
index 00000000..d637a2f3
--- /dev/null
+++ b/modules/native-doh/android/src/main/java/expo/modules/nativedoh/DohInitializationProvider.kt
@@ -0,0 +1,36 @@
+package expo.modules.nativedoh
+
+import android.content.ContentProvider
+import android.content.ContentValues
+import android.database.Cursor
+import android.net.Uri
+
+class DohInitializationProvider : ContentProvider() {
+ override fun onCreate(): Boolean {
+ context?.let {
+ installDohClientFactory(it, DohPreferences.getProvider(it))
+ }
+ return true
+ }
+
+ override fun query(
+ uri: Uri,
+ projection: Array?,
+ selection: String?,
+ selectionArgs: Array?,
+ sortOrder: String?,
+ ): Cursor? = null
+
+ override fun getType(uri: Uri): String? = null
+
+ override fun insert(uri: Uri, values: ContentValues?): Uri? = null
+
+ override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0
+
+ override fun update(
+ uri: Uri,
+ values: ContentValues?,
+ selection: String?,
+ selectionArgs: Array?,
+ ): Int = 0
+}
diff --git a/modules/native-doh/android/src/main/java/expo/modules/nativedoh/DohPreferences.kt b/modules/native-doh/android/src/main/java/expo/modules/nativedoh/DohPreferences.kt
new file mode 100644
index 00000000..16976fbe
--- /dev/null
+++ b/modules/native-doh/android/src/main/java/expo/modules/nativedoh/DohPreferences.kt
@@ -0,0 +1,20 @@
+package expo.modules.nativedoh
+
+import android.content.Context
+
+object DohPreferences {
+ private const val PREFERENCES_NAME = "lnreader_network"
+ private const val PROVIDER_KEY = "doh_provider"
+
+ fun getProvider(context: Context): Int =
+ context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
+ .getInt(PROVIDER_KEY, DohProviders.DISABLED)
+
+ fun setProvider(context: Context, provider: Int) {
+ require(provider in DohProviders.ALL) { "Unknown DNS over HTTPS provider: $provider" }
+ context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
+ .edit()
+ .putInt(PROVIDER_KEY, provider)
+ .commit()
+ }
+}
diff --git a/modules/native-doh/android/src/main/java/expo/modules/nativedoh/DohProviders.kt b/modules/native-doh/android/src/main/java/expo/modules/nativedoh/DohProviders.kt
new file mode 100644
index 00000000..c58aadee
--- /dev/null
+++ b/modules/native-doh/android/src/main/java/expo/modules/nativedoh/DohProviders.kt
@@ -0,0 +1,129 @@
+package expo.modules.nativedoh
+
+import com.facebook.react.modules.network.OkHttpClientProvider
+import java.net.InetAddress
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.dnsoverhttps.DnsOverHttps
+
+object DohProviders {
+ const val DISABLED = 0
+ const val CLOUDFLARE = 1
+ const val GOOGLE = 2
+ const val ADGUARD = 3
+ const val QUAD9 = 4
+ const val ALIDNS = 5
+ const val DNSPOD = 6
+ const val DNS_360 = 7
+ const val QUAD_101 = 8
+ const val MULLVAD = 9
+ const val CONTROL_D = 10
+ const val NJALLA = 11
+ const val SHECAN = 12
+
+ val ALL = DISABLED..SHECAN
+}
+
+data class DohProvider(
+ val url: String,
+ val bootstrapHosts: List,
+)
+
+private val providers = mapOf(
+ DohProviders.CLOUDFLARE to DohProvider(
+ "https://cloudflare-dns.com/dns-query",
+ listOf(
+ "162.159.36.1",
+ "162.159.46.1",
+ "1.1.1.1",
+ "1.0.0.1",
+ "162.159.132.53",
+ "2606:4700:4700::1111",
+ "2606:4700:4700::1001",
+ "2606:4700:4700::0064",
+ "2606:4700:4700::6400",
+ ),
+ ),
+ DohProviders.GOOGLE to DohProvider(
+ "https://dns.google/dns-query",
+ listOf(
+ "8.8.4.4",
+ "8.8.8.8",
+ "2001:4860:4860::8888",
+ "2001:4860:4860::8844",
+ ),
+ ),
+ DohProviders.ADGUARD to DohProvider(
+ "https://dns-unfiltered.adguard.com/dns-query",
+ listOf(
+ "94.140.14.140",
+ "94.140.14.141",
+ "2a10:50c0::1:ff",
+ "2a10:50c0::2:ff",
+ ),
+ ),
+ DohProviders.QUAD9 to DohProvider(
+ "https://dns.quad9.net/dns-query",
+ listOf("9.9.9.9", "149.112.112.112", "2620:fe::fe", "2620:fe::9"),
+ ),
+ DohProviders.ALIDNS to DohProvider(
+ "https://dns.alidns.com/dns-query",
+ listOf("223.5.5.5", "223.6.6.6", "2400:3200::1", "2400:3200:baba::1"),
+ ),
+ DohProviders.DNSPOD to DohProvider(
+ "https://doh.pub/dns-query",
+ listOf("1.12.12.12", "120.53.53.53"),
+ ),
+ DohProviders.DNS_360 to DohProvider(
+ "https://doh.360.cn/dns-query",
+ listOf(
+ "101.226.4.6",
+ "218.30.118.6",
+ "123.125.81.6",
+ "140.207.198.6",
+ "180.163.249.75",
+ "101.199.113.208",
+ "36.99.170.86",
+ ),
+ ),
+ DohProviders.QUAD_101 to DohProvider(
+ "https://dns.twnic.tw/dns-query",
+ listOf("101.101.101.101", "2001:de4::101", "2001:de4::102"),
+ ),
+ DohProviders.MULLVAD to DohProvider(
+ "https://dns.mullvad.net/dns-query",
+ listOf("194.242.2.2", "2a07:e340::2"),
+ ),
+ DohProviders.CONTROL_D to DohProvider(
+ "https://freedns.controld.com/p0",
+ listOf("76.76.2.0", "76.76.10.0", "2606:1a40::", "2606:1a40:1::"),
+ ),
+ DohProviders.NJALLA to DohProvider(
+ "https://dns.njal.la/dns-query",
+ listOf("95.215.19.53", "2001:67c:2354:2::53"),
+ ),
+ DohProviders.SHECAN to DohProvider(
+ "https://free.shecan.ir/dns-query",
+ listOf("178.22.122.100", "185.51.200.2"),
+ ),
+)
+
+fun installDohClientFactory(context: android.content.Context, providerId: Int) {
+ val provider = providers[providerId] ?: return
+ val applicationContext = context.applicationContext
+
+ OkHttpClientProvider.setOkHttpClientFactory {
+ val builder = OkHttpClientProvider.createClientBuilder(applicationContext)
+ builder.dns(
+ DnsOverHttps.Builder()
+ .client(builder.build())
+ .url(provider.url.toHttpUrl())
+ .bootstrapDnsHosts(
+ *provider.bootstrapHosts
+ .map(InetAddress::getByName)
+ .toTypedArray(),
+ )
+ .build(),
+ )
+ builder.build()
+ }
+}
diff --git a/modules/native-doh/android/src/main/java/expo/modules/nativedoh/NativeDohModule.kt b/modules/native-doh/android/src/main/java/expo/modules/nativedoh/NativeDohModule.kt
new file mode 100644
index 00000000..9ff42b12
--- /dev/null
+++ b/modules/native-doh/android/src/main/java/expo/modules/nativedoh/NativeDohModule.kt
@@ -0,0 +1,18 @@
+package expo.modules.nativedoh
+
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+class NativeDohModule : Module() {
+ override fun definition() = ModuleDefinition {
+ Name("NativeDoh")
+
+ Function("getProvider") {
+ DohPreferences.getProvider(appContext.reactContext!!)
+ }
+
+ Function("setProvider") { provider: Int ->
+ DohPreferences.setProvider(appContext.reactContext!!, provider)
+ }
+ }
+}
diff --git a/modules/native-doh/expo-module.config.json b/modules/native-doh/expo-module.config.json
new file mode 100644
index 00000000..202d041e
--- /dev/null
+++ b/modules/native-doh/expo-module.config.json
@@ -0,0 +1,6 @@
+{
+ "platforms": ["android"],
+ "android": {
+ "modules": ["expo.modules.nativedoh.NativeDohModule"]
+ }
+}
diff --git a/modules/native-doh/index.ts b/modules/native-doh/index.ts
new file mode 100644
index 00000000..3c6706d8
--- /dev/null
+++ b/modules/native-doh/index.ts
@@ -0,0 +1,23 @@
+import { requireOptionalNativeModule } from 'expo-modules-core';
+
+export type DohProviderId =
+ | 0
+ | 1
+ | 2
+ | 3
+ | 4
+ | 5
+ | 6
+ | 7
+ | 8
+ | 9
+ | 10
+ | 11
+ | 12;
+
+type NativeDohModule = {
+ getProvider(): DohProviderId;
+ setProvider(provider: DohProviderId): void;
+};
+
+export default requireOptionalNativeModule('NativeDoh');
diff --git a/modules/native-doh/package.json b/modules/native-doh/package.json
new file mode 100644
index 00000000..6030afe5
--- /dev/null
+++ b/modules/native-doh/package.json
@@ -0,0 +1,3 @@
+{
+ "name": "native-doh"
+}
diff --git a/src/i18n/languages/en/strings.json b/src/i18n/languages/en/strings.json
index 698e2053..915a89ec 100644
--- a/src/i18n/languages/en/strings.json
+++ b/src/i18n/languages/en/strings.json
@@ -22,17 +22,22 @@
"dataManagement": "Data Management",
"deleteReadChapters": "Delete read chapters",
"deleteReadChaptersDialogTitle": "All chapters marked as read will be deleted.",
+ "disabled": "Disabled",
+ "dnsOverHttps": "DNS over HTTPS (DoH)",
+ "dnsOverHttpsDescription": "Uses the selected resolver for app requests and downloads. WebView traffic is not affected.",
"importEpub": "Import EPUB",
"importFailed": "Error importing EPUB:",
"importNovel": "Import Novel",
"importStaticFiles": "Import Static Files",
"novelInsertFailed": "novel insert failed",
+ "networking": "Networking",
"useFAB": "Use FAB instead of button",
"userAgent": "User Agent",
"recreateDBIndexes": "Recreate DB indexes",
"recreateDBIndexesToast": "Recreated DB indexes",
"recreateDBIndexesDialogTitle": "All DB indexes will be recreated.\nThis may take a while.",
- "recreateDBIndexesDesc": "Recreates DB indexes. This may improve performance on slower devices."
+ "recreateDBIndexesDesc": "Recreates DB indexes. This may improve performance on slower devices.",
+ "restartRequired": "Restart LNReader to apply this change."
},
"appearance": "Appearance",
"appearanceScreen": {
diff --git a/src/i18n/types/index.ts b/src/i18n/types/index.ts
index 51f30664..25f3a69b 100644
--- a/src/i18n/types/index.ts
+++ b/src/i18n/types/index.ts
@@ -23,17 +23,22 @@ export interface StringMap {
'advancedSettingsScreen.dataManagement': 'string';
'advancedSettingsScreen.deleteReadChapters': 'string';
'advancedSettingsScreen.deleteReadChaptersDialogTitle': 'string';
+ 'advancedSettingsScreen.disabled': 'string';
+ 'advancedSettingsScreen.dnsOverHttps': 'string';
+ 'advancedSettingsScreen.dnsOverHttpsDescription': 'string';
'advancedSettingsScreen.importEpub': 'string';
'advancedSettingsScreen.importFailed': 'string';
'advancedSettingsScreen.importNovel': 'string';
'advancedSettingsScreen.importStaticFiles': 'string';
'advancedSettingsScreen.novelInsertFailed': 'string';
+ 'advancedSettingsScreen.networking': 'string';
'advancedSettingsScreen.useFAB': 'string';
'advancedSettingsScreen.userAgent': 'string';
'advancedSettingsScreen.recreateDBIndexes': 'string';
'advancedSettingsScreen.recreateDBIndexesToast': 'string';
'advancedSettingsScreen.recreateDBIndexesDialogTitle': 'string';
'advancedSettingsScreen.recreateDBIndexesDesc': 'string';
+ 'advancedSettingsScreen.restartRequired': 'string';
'appearance': 'string';
'appearanceScreen.accentColor': 'string';
'appearanceScreen.alwaysShowNavLabels': 'string';
diff --git a/src/screens/settings/DohProviderDialog.tsx b/src/screens/settings/DohProviderDialog.tsx
new file mode 100644
index 00000000..2de27f21
--- /dev/null
+++ b/src/screens/settings/DohProviderDialog.tsx
@@ -0,0 +1,88 @@
+import { FlatList, StyleSheet } from 'react-native';
+
+import NativeDoh, { DohProviderId } from '@modules/native-doh';
+import { Dialog, RadioButton } from '@components';
+import { useTheme } from '@hooks/persisted';
+import { getString } from '@i18n/translations';
+import { showToast } from '@utils/showToast';
+
+export const DOH_PROVIDERS: readonly {
+ id: DohProviderId;
+ label: string;
+}[] = [
+ { id: 0, label: getString('advancedSettingsScreen.disabled') },
+ { id: 1, label: 'Cloudflare' },
+ { id: 2, label: 'Google' },
+ { id: 3, label: 'AdGuard' },
+ { id: 4, label: 'Quad9' },
+ { id: 5, label: 'AliDNS' },
+ { id: 6, label: 'DNSPod' },
+ { id: 7, label: '360' },
+ { id: 8, label: 'Quad 101' },
+ { id: 9, label: 'Mullvad' },
+ { id: 10, label: 'Control D' },
+ { id: 11, label: 'Njalla' },
+ { id: 12, label: 'Shecan' },
+];
+
+interface DohProviderDialogProps {
+ provider: DohProviderId;
+ visible: boolean;
+ onDismiss: () => void;
+ onSelect: (provider: DohProviderId) => void;
+}
+
+const DohProviderDialog = ({
+ provider,
+ visible,
+ onDismiss,
+ onSelect,
+}: DohProviderDialogProps) => {
+ const theme = useTheme();
+
+ const selectProvider = (nextProvider: DohProviderId) => {
+ NativeDoh?.setProvider(nextProvider);
+ onSelect(nextProvider);
+ onDismiss();
+ showToast(getString('advancedSettingsScreen.restartRequired'));
+ };
+
+ return (
+
+
+ {getString('advancedSettingsScreen.dnsOverHttps')}
+
+
+ {getString('advancedSettingsScreen.dnsOverHttpsDescription')}
+
+
+ item.id.toString()}
+ renderItem={({ item }) => (
+ selectProvider(item.id)}
+ theme={theme}
+ />
+ )}
+ style={styles.list}
+ />
+
+
+
+ {getString('common.cancel')}
+
+
+
+ );
+};
+
+export default DohProviderDialog;
+
+const styles = StyleSheet.create({
+ list: {
+ maxHeight: 480,
+ },
+});
diff --git a/src/screens/settings/SettingsAdvancedScreen.tsx b/src/screens/settings/SettingsAdvancedScreen.tsx
index c01a3fe4..e013ff26 100644
--- a/src/screens/settings/SettingsAdvancedScreen.tsx
+++ b/src/screens/settings/SettingsAdvancedScreen.tsx
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { TextInput } from 'react-native-paper';
+import { Platform, ScrollView, StyleSheet } from 'react-native';
import { deleteCachedNovels, useTheme, useUserAgent } from '@hooks/persisted';
import { showToast } from '@utils/showToast';
@@ -15,10 +16,11 @@ import {
import { Appbar, Dialog, List, SafeAreaView } from '@components';
import { AdvancedSettingsScreenProps } from '@navigators/types';
-import { ScrollView, StyleSheet } from 'react-native';
import { getUserAgentSync } from 'react-native-device-info';
import CookieManager from '@preeternal/react-native-cookie-manager';
import { store } from '@plugins/helpers/storage';
+import NativeDoh, { DohProviderId } from '@modules/native-doh';
+import DohProviderDialog, { DOH_PROVIDERS } from './DohProviderDialog';
const AdvancedSettings = ({ navigation }: AdvancedSettingsScreenProps) => {
const theme = useTheme();
@@ -30,6 +32,9 @@ const AdvancedSettings = ({ navigation }: AdvancedSettingsScreenProps) => {
const { userAgent, setUserAgent } = useUserAgent();
const [userAgentInput, setUserAgentInput] = useState(userAgent);
+ const [dohProvider, setDohProvider] = useState(
+ NativeDoh?.getProvider() ?? 0,
+ );
/**
* Confirm Clear Database Dialog
*/
@@ -53,6 +58,16 @@ const AdvancedSettings = ({ navigation }: AdvancedSettingsScreenProps) => {
setFalse: hideUserAgentModal,
} = useBoolean();
+ const {
+ value: dohProviderDialogVisible,
+ setTrue: showDohProviderDialog,
+ setFalse: hideDohProviderDialog,
+ } = useBoolean();
+
+ const dohProviderLabel =
+ DOH_PROVIDERS.find(provider => provider.id === dohProvider)?.label ??
+ DOH_PROVIDERS[0].label;
+
return (
{
onPress={showDeleteReadChaptersDialog}
theme={theme}
/>
+
+
+
+ {getString('advancedSettingsScreen.networking')}
+
+ {Platform.OS === 'android' && NativeDoh ? (
+
+ ) : null}
{
onDismiss={hideClearUpdatesDialog}
/>
+
+