feat: Add Android DNS Over HTTPS Support

[skip ci]

Closes #478
This commit is contained in:
Rajarshee Chatterjee
2026-07-26 00:23:07 +05:30
parent 164f5d2592
commit 4d98247980
13 changed files with 399 additions and 2 deletions
+20
View File
@@ -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'
}
@@ -0,0 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<provider
android:name="expo.modules.nativedoh.DohInitializationProvider"
android:authorities="${applicationId}.doh-initializer"
android:exported="false"
android:initOrder="100" />
</application>
</manifest>
@@ -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<out String>?,
selection: String?,
selectionArgs: Array<out String>?,
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<out String>?): Int = 0
override fun update(
uri: Uri,
values: ContentValues?,
selection: String?,
selectionArgs: Array<out String>?,
): Int = 0
}
@@ -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()
}
}
@@ -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<String>,
)
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()
}
}
@@ -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)
}
}
}
@@ -0,0 +1,6 @@
{
"platforms": ["android"],
"android": {
"modules": ["expo.modules.nativedoh.NativeDohModule"]
}
}
+23
View File
@@ -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<NativeDohModule>('NativeDoh');
+3
View File
@@ -0,0 +1,3 @@
{
"name": "native-doh"
}
+6 -1
View File
@@ -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": {
+5
View File
@@ -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';
@@ -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 (
<Dialog.Root visible={visible} onDismiss={onDismiss}>
<Dialog.Title>
{getString('advancedSettingsScreen.dnsOverHttps')}
</Dialog.Title>
<Dialog.Description>
{getString('advancedSettingsScreen.dnsOverHttpsDescription')}
</Dialog.Description>
<Dialog.ScrollArea>
<FlatList
data={DOH_PROVIDERS}
keyExtractor={item => item.id.toString()}
renderItem={({ item }) => (
<RadioButton
label={item.label}
status={provider === item.id}
onPress={() => selectProvider(item.id)}
theme={theme}
/>
)}
style={styles.list}
/>
</Dialog.ScrollArea>
<Dialog.Actions>
<Dialog.Action onPress={onDismiss}>
{getString('common.cancel')}
</Dialog.Action>
</Dialog.Actions>
</Dialog.Root>
);
};
export default DohProviderDialog;
const styles = StyleSheet.create({
list: {
maxHeight: 480,
},
});
@@ -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<DohProviderId>(
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 (
<SafeAreaView excludeTop>
<Appbar
@@ -86,11 +101,24 @@ const AdvancedSettings = ({ navigation }: AdvancedSettingsScreenProps) => {
onPress={showDeleteReadChaptersDialog}
theme={theme}
/>
</List.Section>
<List.Section>
<List.SubHeader theme={theme}>
{getString('advancedSettingsScreen.networking')}
</List.SubHeader>
<List.Item
title={getString('webview.clearCookies')}
onPress={clearCookies}
theme={theme}
/>
{Platform.OS === 'android' && NativeDoh ? (
<List.Item
title={getString('advancedSettingsScreen.dnsOverHttps')}
description={dohProviderLabel}
onPress={showDohProviderDialog}
theme={theme}
/>
) : null}
<List.Item
title={getString('advancedSettingsScreen.userAgent')}
description={userAgent}
@@ -130,6 +158,13 @@ const AdvancedSettings = ({ navigation }: AdvancedSettingsScreenProps) => {
onDismiss={hideClearUpdatesDialog}
/>
<DohProviderDialog
provider={dohProvider}
visible={dohProviderDialogVisible}
onDismiss={hideDohProviderDialog}
onSelect={setDohProvider}
/>
<Dialog.Root
visible={userAgentModalVisible}
onDismiss={hideUserAgentModal}