From 4d98247980c21f892df6cf0da19ecad8a6f0d136 Mon Sep 17 00:00:00 2001 From: Rajarshee Chatterjee Date: Sun, 26 Jul 2026 00:23:07 +0530 Subject: [PATCH] feat: Add Android DNS Over HTTPS Support [skip ci] Closes #478 --- modules/native-doh/android/build.gradle | 20 +++ .../android/src/main/AndroidManifest.xml | 9 ++ .../nativedoh/DohInitializationProvider.kt | 36 +++++ .../expo/modules/nativedoh/DohPreferences.kt | 20 +++ .../expo/modules/nativedoh/DohProviders.kt | 129 ++++++++++++++++++ .../expo/modules/nativedoh/NativeDohModule.kt | 18 +++ modules/native-doh/expo-module.config.json | 6 + modules/native-doh/index.ts | 23 ++++ modules/native-doh/package.json | 3 + src/i18n/languages/en/strings.json | 7 +- src/i18n/types/index.ts | 5 + src/screens/settings/DohProviderDialog.tsx | 88 ++++++++++++ .../settings/SettingsAdvancedScreen.tsx | 37 ++++- 13 files changed, 399 insertions(+), 2 deletions(-) create mode 100644 modules/native-doh/android/build.gradle create mode 100644 modules/native-doh/android/src/main/AndroidManifest.xml create mode 100644 modules/native-doh/android/src/main/java/expo/modules/nativedoh/DohInitializationProvider.kt create mode 100644 modules/native-doh/android/src/main/java/expo/modules/nativedoh/DohPreferences.kt create mode 100644 modules/native-doh/android/src/main/java/expo/modules/nativedoh/DohProviders.kt create mode 100644 modules/native-doh/android/src/main/java/expo/modules/nativedoh/NativeDohModule.kt create mode 100644 modules/native-doh/expo-module.config.json create mode 100644 modules/native-doh/index.ts create mode 100644 modules/native-doh/package.json create mode 100644 src/screens/settings/DohProviderDialog.tsx 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} /> + +