feat(android): add DNS over HTTPS (DoH) support with multiple providers

This commit is contained in:
paregi12 2026-04-24 14:33:20 +05:30
parent 5fb414ea2f
commit 8f12d32a33
16 changed files with 312 additions and 6 deletions

View file

@ -225,6 +225,7 @@ kotlin {
implementation(libs.coil.gif)
implementation("androidx.recyclerview:recyclerview:1.4.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
implementation(libs.okhttp.dnsoverhttps)
implementation("com.google.code.gson:gson:2.11.0")
implementation("io.github.peerless2012:ass-media:0.4.0-beta01")
implementation(libs.ktor.client.android)

View file

@ -24,7 +24,7 @@ internal object TrailerExtractionPlatform {
)
private val httpClient = OkHttpClient.Builder()
.dns(IPv4FirstDns())
.dns(com.nuvio.app.core.network.AndroidDnsProvider)
.connectTimeout(TRAILER_REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.readTimeout(TRAILER_REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.writeTimeout(TRAILER_REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
@ -33,7 +33,7 @@ internal object TrailerExtractionPlatform {
.build()
private val probeClient = OkHttpClient.Builder()
.dns(IPv4FirstDns())
.dns(com.nuvio.app.core.network.AndroidDnsProvider)
.connectTimeout(2, TimeUnit.SECONDS)
.readTimeout(2, TimeUnit.SECONDS)
.followRedirects(true)

View file

@ -19,7 +19,8 @@ object AndroidAppUpdaterPlatform {
private const val ignoredTagKey = "ignored_release_tag"
private val httpClient = OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
.dns(com.nuvio.app.core.network.AndroidDnsProvider)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.followRedirects(true)

View file

@ -68,6 +68,7 @@ class MainActivity : ComponentActivity() {
SeasonViewModeStorage.initialize(applicationContext)
ThemeSettingsStorage.initialize(applicationContext)
PosterCardStyleStorage.initialize(applicationContext)
com.nuvio.app.features.settings.globalNetworkSettingsRepository = com.nuvio.app.features.settings.NetworkSettingsRepository(com.nuvio.app.features.settings.AndroidNetworkSettingsStorage(applicationContext))
TmdbSettingsStorage.initialize(applicationContext)
MdbListSettingsStorage.initialize(applicationContext)
TraktAuthStorage.initialize(applicationContext)

View file

@ -0,0 +1,127 @@
package com.nuvio.app.core.network
import com.nuvio.app.features.settings.DnsProvider
import com.nuvio.app.features.settings.globalNetworkSettingsRepository
import okhttp3.Dns
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import okhttp3.dnsoverhttps.DnsOverHttps
import java.net.InetAddress
import java.io.File
import okhttp3.Cache
object AndroidDnsProvider : Dns {
// A dedicated minimal client just for DoH resolutions
private val bootstrapClient by lazy {
OkHttpClient.Builder()
.dns(IPv4FirstDns(Dns.SYSTEM))
.build()
}
private val cloudflareDns by lazy {
DnsOverHttps.Builder().client(bootstrapClient)
.url("https://cloudflare-dns.com/dns-query".toHttpUrl())
.bootstrapDnsHosts(
InetAddress.getByName("1.1.1.1"),
InetAddress.getByName("1.0.0.1"),
InetAddress.getByName("2606:4700:4700::1111"),
InetAddress.getByName("2606:4700:4700::1001")
)
.build()
}
private val googleDns by lazy {
DnsOverHttps.Builder().client(bootstrapClient)
.url("https://dns.google/dns-query".toHttpUrl())
.bootstrapDnsHosts(
InetAddress.getByName("8.8.8.8"),
InetAddress.getByName("8.8.4.4"),
InetAddress.getByName("2001:4860:4860::8888"),
InetAddress.getByName("2001:4860:4860::8844")
)
.build()
}
private val quad9Dns by lazy {
DnsOverHttps.Builder().client(bootstrapClient)
.url("https://dns.quad9.net/dns-query".toHttpUrl())
.bootstrapDnsHosts(
InetAddress.getByName("9.9.9.9"),
InetAddress.getByName("149.112.112.112"),
InetAddress.getByName("2620:fe::fe"),
InetAddress.getByName("2620:fe::9")
)
.build()
}
private val adGuardDns by lazy {
DnsOverHttps.Builder().client(bootstrapClient)
.url("https://dns.adguard-dns.com/dns-query".toHttpUrl())
.bootstrapDnsHosts(
InetAddress.getByName("94.140.14.14"),
InetAddress.getByName("94.140.15.15"),
InetAddress.getByName("2a10:50c0::ad1"),
InetAddress.getByName("2a10:50c0::ad2")
)
.build()
}
private val nextDns by lazy {
DnsOverHttps.Builder().client(bootstrapClient)
.url("https://dns.nextdns.io".toHttpUrl())
.bootstrapDnsHosts(
InetAddress.getByName("45.90.28.0"),
InetAddress.getByName("45.90.30.0"),
InetAddress.getByName("2a07:a8c0::"),
InetAddress.getByName("2a07:a8c1::")
)
.build()
}
private val mullvadDns by lazy {
DnsOverHttps.Builder().client(bootstrapClient)
.url("https://doh.mullvad.net/dns-query".toHttpUrl())
.bootstrapDnsHosts(
InetAddress.getByName("194.242.2.4"),
InetAddress.getByName("2a07:e340::4")
)
.build()
}
private val openDns by lazy {
DnsOverHttps.Builder().client(bootstrapClient)
.url("https://doh.opendns.com/dns-query".toHttpUrl())
.bootstrapDnsHosts(
InetAddress.getByName("208.67.222.222"),
InetAddress.getByName("208.67.220.220"),
InetAddress.getByName("2620:119:35::35"),
InetAddress.getByName("2620:119:53::53")
)
.build()
}
private val systemDns = IPv4FirstDns(Dns.SYSTEM)
override fun lookup(hostname: String): List<InetAddress> {
val currentProvider = runCatching { globalNetworkSettingsRepository?.dnsProvider?.value }.getOrNull() ?: DnsProvider.SYSTEM
val activeDns = when (currentProvider) {
DnsProvider.CLOUDFLARE -> cloudflareDns
DnsProvider.GOOGLE -> googleDns
DnsProvider.QUAD9 -> quad9Dns
DnsProvider.ADGUARD -> adGuardDns
DnsProvider.NEXTDNS -> nextDns
DnsProvider.MULLVAD -> mullvadDns
DnsProvider.OPEN_DNS -> openDns
DnsProvider.SYSTEM -> systemDns
}
return try {
activeDns.lookup(hostname)
} catch (e: Exception) {
// Fallback to system DNS if DoH fails
systemDns.lookup(hostname)
}
}
}

View file

@ -44,7 +44,7 @@ actual object AddonStorage {
}
private val addonHttpClient = OkHttpClient.Builder()
.dns(IPv4FirstDns())
.dns(com.nuvio.app.core.network.AndroidDnsProvider)
.connectTimeout(60, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)

View file

@ -17,7 +17,8 @@ import java.net.URI
import java.util.concurrent.TimeUnit
private val downloadHttpClient = OkHttpClient.Builder()
.connectTimeout(60, TimeUnit.SECONDS)
.dns(com.nuvio.app.core.network.AndroidDnsProvider)
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.followRedirects(true)

View file

@ -47,7 +47,7 @@ internal object PlayerPlaybackNetworking {
private val playbackHttpClient: OkHttpClient by lazy {
OkHttpClient.Builder()
.dns(IPv4FirstDns())
.dns(com.nuvio.app.core.network.AndroidDnsProvider)
.sslSocketFactory(sslContext.socketFactory, trustAllManager)
.hostnameVerifier(playbackHostnameVerifier)
.connectTimeout(15, TimeUnit.SECONDS)

View file

@ -0,0 +1,16 @@
package com.nuvio.app.features.settings
import android.content.Context
import android.content.SharedPreferences
class AndroidNetworkSettingsStorage(context: Context) : NetworkSettingsStorage {
private val prefs: SharedPreferences = context.getSharedPreferences("nuvio_network_settings", Context.MODE_PRIVATE)
private val DNS_PROVIDER_KEY = "dns_provider"
override fun getDnsProvider(): String? =
prefs.getString(DNS_PROVIDER_KEY, null)
override fun setDnsProvider(provider: String) {
prefs.edit().putString(DNS_PROVIDER_KEY, provider).apply()
}
}

View file

@ -0,0 +1,41 @@
package com.nuvio.app.features.settings
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
internal fun LazyListScope.networkSettingsContent(
isTablet: Boolean,
) {
item {
val repository = globalNetworkSettingsRepository ?: return@item
val currentProvider by repository.dnsProvider.collectAsState()
androidx.compose.foundation.layout.Column(
modifier = Modifier.padding(horizontal = if (isTablet) 24.dp else 0.dp)
) {
SettingsSection(
title = "DNS OVER HTTPS (ANDROID ONLY)",
isTablet = isTablet
) {
SettingsGroup(isTablet = isTablet) {
DnsProvider.entries.forEachIndexed { index, provider ->
SettingsRadioRow(
title = provider.displayName,
description = if (provider == DnsProvider.SYSTEM) "Use the system's default DNS resolver." else "Encrypt DNS queries via ${provider.name.lowercase()}.",
selected = currentProvider == provider,
onClick = { repository.setDnsProvider(provider) },
isTablet = isTablet
)
if (index < DnsProvider.entries.lastIndex) {
SettingsGroupDivider(isTablet = isTablet)
}
}
}
}
}
}
}

View file

@ -0,0 +1,40 @@
package com.nuvio.app.features.settings
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
enum class DnsProvider(val displayName: String) {
SYSTEM("System Default (IPv4 Preferred)"),
CLOUDFLARE("Cloudflare (DoH)"),
GOOGLE("Google (DoH)"),
QUAD9("Quad9 (DoH)"),
ADGUARD("AdGuard (DoH)"),
NEXTDNS("NextDNS (DoH)"),
MULLVAD("Mullvad (DoH)"),
OPEN_DNS("OpenDNS (DoH)")
}
interface NetworkSettingsStorage {
fun getDnsProvider(): String?
fun setDnsProvider(provider: String)
}
class NetworkSettingsRepository(
private val storage: NetworkSettingsStorage
) {
private val _dnsProvider = MutableStateFlow(
runCatching {
val name = storage.getDnsProvider() ?: DnsProvider.SYSTEM.name
DnsProvider.valueOf(name)
}.getOrDefault(DnsProvider.SYSTEM)
)
val dnsProvider: StateFlow<DnsProvider> = _dnsProvider.asStateFlow()
fun setDnsProvider(provider: DnsProvider) {
storage.setDnsProvider(provider.name)
_dnsProvider.value = provider
}
}
var globalNetworkSettingsRepository: NetworkSettingsRepository? = null

View file

@ -334,6 +334,57 @@ internal fun SettingsSwitchRow(
}
}
@Composable
internal fun SettingsRadioRow(
title: String,
description: String? = null,
selected: Boolean,
enabled: Boolean = true,
isTablet: Boolean,
onClick: () -> Unit,
) {
val verticalPadding = if (isTablet) 16.dp else 14.dp
val horizontalPadding = if (isTablet) 20.dp else 16.dp
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = enabled) { onClick() }
.padding(horizontal = horizontalPadding, vertical = verticalPadding),
horizontalArrangement = Arrangement.Start,
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier
.weight(1f)
.padding(end = 12.dp)
.widthIn(max = if (isTablet) 560.dp else androidx.compose.ui.unit.Dp.Unspecified)
.alpha(if (enabled) 1f else 0.55f),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium,
)
if (!description.isNullOrBlank()) {
Text(
text = description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
androidx.compose.material3.RadioButton(
selected = selected,
onClick = null,
enabled = enabled,
modifier = Modifier.padding(start = 4.dp)
)
}
}
@Composable
internal fun HomescreenCatalogRow(
item: HomeCatalogSettingsItem,

View file

@ -31,6 +31,11 @@ internal enum class SettingsPage(
category = SettingsCategory.Account,
parentPage = Root,
),
Network(
title = "Network",
category = SettingsCategory.General,
parentPage = Root,
),
SupportersContributors(
title = "Supporters & Contributors",
category = SettingsCategory.About,

View file

@ -14,6 +14,7 @@ import androidx.compose.material.icons.rounded.Notifications
import androidx.compose.material.icons.rounded.Palette
import androidx.compose.material.icons.rounded.People
import androidx.compose.material.icons.rounded.PlayArrow
import androidx.compose.material.icons.rounded.Settings
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.ui.Modifier
@ -27,6 +28,7 @@ internal fun LazyListScope.settingsRootContent(
onAppearanceClick: () -> Unit,
onNotificationsClick: () -> Unit,
onContentDiscoveryClick: () -> Unit,
onNetworkClick: () -> Unit,
onIntegrationsClick: () -> Unit,
onTraktClick: () -> Unit,
onSupportersContributorsClick: () -> Unit,
@ -112,6 +114,16 @@ internal fun LazyListScope.settingsRootContent(
isTablet = isTablet,
onClick = onPlaybackClick,
)
if (com.nuvio.app.features.settings.globalNetworkSettingsRepository != null) {
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = "Network",
description = "Configure DNS over HTTPS.",
icon = Icons.Rounded.Settings,
isTablet = isTablet,
onClick = onNetworkClick,
)
}
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = "Integrations",

View file

@ -302,6 +302,7 @@ private fun MobileSettingsScreen(
onAppearanceClick = { onPageChange(SettingsPage.Appearance) },
onNotificationsClick = { onPageChange(SettingsPage.Notifications) },
onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) },
onNetworkClick = { onPageChange(SettingsPage.Network) },
onIntegrationsClick = { onPageChange(SettingsPage.Integrations) },
onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) },
onSupportersContributorsClick = onSupportersContributorsClick,
@ -396,6 +397,9 @@ private fun MobileSettingsScreen(
commentsEnabled = traktCommentsEnabled,
onCommentsEnabledChange = TraktCommentsSettings::setEnabled,
)
SettingsPage.Network -> networkSettingsContent(
isTablet = false,
)
}
}
}
@ -521,6 +525,7 @@ private fun TabletSettingsScreen(
onAppearanceClick = { openInlinePage(SettingsPage.Appearance) },
onNotificationsClick = { openInlinePage(SettingsPage.Notifications) },
onContentDiscoveryClick = { openInlinePage(SettingsPage.ContentDiscovery) },
onNetworkClick = { openInlinePage(SettingsPage.Network) },
onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) },
onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) },
onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) },
@ -618,6 +623,9 @@ private fun TabletSettingsScreen(
commentsEnabled = traktCommentsEnabled,
onCommentsEnabledChange = TraktCommentsSettings::setEnabled,
)
SettingsPage.Network -> networkSettingsContent(
isTablet = true,
)
}
}
}

View file

@ -26,6 +26,7 @@ quickjsKt = "1.0.1"
ksoup = "0.2.6"
reorderable = "3.0.0"
desugarJdkLibs = "2.1.5"
okhttp = "4.12.0"
[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
@ -75,6 +76,7 @@ quickjs-kt = { module = "io.github.dokar3:quickjs-kt", version.ref = "quickjsKt"
ksoup = { module = "com.fleeksoft.ksoup:ksoup", version.ref = "ksoup" }
reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" }
desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugarJdkLibs" }
okhttp-dnsoverhttps = { module = "com.squareup.okhttp3:okhttp-dnsoverhttps", version.ref = "okhttp" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }