mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +00:00
feat(android): add DNS over HTTPS (DoH) support with multiple providers
This commit is contained in:
parent
5fb414ea2f
commit
8f12d32a33
16 changed files with 312 additions and 6 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
Loading…
Reference in a new issue