diff --git a/App.tsx b/App.tsx index 7ce219f0..40642842 100644 --- a/App.tsx +++ b/App.tsx @@ -39,6 +39,7 @@ import UpdatePopup from './src/components/UpdatePopup'; import MajorUpdateOverlay from './src/components/MajorUpdateOverlay'; import { useGithubMajorUpdate } from './src/hooks/useGithubMajorUpdate'; import { useUpdatePopup } from './src/hooks/useUpdatePopup'; +import { useSettings } from './src/hooks/useSettings'; import * as Sentry from '@sentry/react-native'; import UpdateService from './src/services/updateService'; import { memoryMonitorService } from './src/services/memoryMonitorService'; @@ -48,6 +49,7 @@ import { ToastProvider } from './src/contexts/ToastContext'; import { mmkvStorage } from './src/services/mmkvStorage'; import { CampaignManager } from './src/components/promotions/CampaignManager'; import { isErrorReportingEnabledSync } from './src/services/telemetryService'; +import { networkPrivacyService } from './src/services/networkPrivacyService'; import { supabaseSyncService } from './src/services/supabaseSyncService'; // Initialize Sentry with privacy-first defaults @@ -118,6 +120,7 @@ const ThemedApp = () => { } catch { } }, []); const { currentTheme } = useTheme(); + const { settings, isLoaded: isSettingsLoaded } = useSettings(); const [isAppReady, setIsAppReady] = useState(false); const [hasCompletedOnboarding, setHasCompletedOnboarding] = useState(null); @@ -211,6 +214,23 @@ const ThemedApp = () => { initializeApp(); }, []); + // Sync persisted DoH preferences to the native Android networking stack. + useEffect(() => { + if (!isSettingsLoaded) return; + networkPrivacyService.applyConfig({ + enabled: settings.dnsOverHttpsEnabled, + mode: settings.dnsOverHttpsMode, + provider: settings.dnsOverHttpsProvider, + customUrl: settings.dnsOverHttpsCustomUrl, + }); + }, [ + isSettingsLoaded, + settings.dnsOverHttpsEnabled, + settings.dnsOverHttpsMode, + settings.dnsOverHttpsProvider, + settings.dnsOverHttpsCustomUrl, + ]); + // Create custom themes based on current theme const customDarkTheme = { ...CustomDarkTheme, diff --git a/android/app/build.gradle b/android/app/build.gradle index 1ca68471..d2556de5 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -244,6 +244,12 @@ dependencies { implementation jscFlavor } + // DoH resolver used by custom OkHttp DNS implementation + implementation "com.squareup.okhttp3:okhttp-dnsoverhttps:4.9.2" + + // MMKV for native storage access (compile-only to avoid duplicates) + compileOnly "com.tencent:mmkv:1.3.9" + // Include only FFmpeg decoder AAR to avoid duplicates with Maven Media3 implementation files("libs/lib-decoder-ffmpeg-release.aar") diff --git a/android/app/src/main/java/com/nuvio/app/MainApplication.kt b/android/app/src/main/java/com/nuvio/app/MainApplication.kt index 497e21a1..d3b2b9d2 100644 --- a/android/app/src/main/java/com/nuvio/app/MainApplication.kt +++ b/android/app/src/main/java/com/nuvio/app/MainApplication.kt @@ -13,9 +13,13 @@ import com.facebook.react.common.ReleaseLevel import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint import com.facebook.react.defaults.DefaultReactNativeHost +import com.facebook.react.modules.network.OkHttpClientProvider import expo.modules.ApplicationLifecycleDispatcher import expo.modules.ReactNativeHostWrapper import com.nuvio.app.mpv.MpvPackage +import com.nuvio.app.network.DoHOkHttpFactory +import com.nuvio.app.network.DoHState +import com.nuvio.app.network.NetworkPrivacyPackage class MainApplication : Application(), ReactApplication { @@ -25,7 +29,8 @@ class MainApplication : Application(), ReactApplication { override fun getPackages(): List = PackageList(this).packages.apply { // Packages that cannot be autolinked yet can be added manually here, for example: - add(com.nuvio.app.mpv.MpvPackage()) + add(MpvPackage()) + add(NetworkPrivacyPackage()) } override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry" @@ -41,6 +46,9 @@ class MainApplication : Application(), ReactApplication { override fun onCreate() { super.onCreate() + // Initialize DNS-over-HTTPS from persisted storage immediately on startup + DoHState.initializeFromStorage(this) + OkHttpClientProvider.setOkHttpClientFactory(DoHOkHttpFactory(applicationContext)) DefaultNewArchitectureEntryPoint.releaseLevel = try { ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase()) } catch (e: IllegalArgumentException) { diff --git a/android/app/src/main/java/com/nuvio/app/network/DoHConfig.kt b/android/app/src/main/java/com/nuvio/app/network/DoHConfig.kt new file mode 100644 index 00000000..236a0341 --- /dev/null +++ b/android/app/src/main/java/com/nuvio/app/network/DoHConfig.kt @@ -0,0 +1,20 @@ +package com.nuvio.app.network + +data class DoHConfig( + val enabled: Boolean = false, + val mode: String = MODE_OFF, + val provider: String = PROVIDER_CLOUDFLARE, + val customUrl: String = "" +) { + companion object { + const val MODE_OFF = "off" + const val MODE_AUTO = "auto" + const val MODE_STRICT = "strict" + + const val PROVIDER_CLOUDFLARE = "cloudflare" + const val PROVIDER_GOOGLE = "google" + const val PROVIDER_QUAD9 = "quad9" + const val PROVIDER_CUSTOM = "custom" + } +} + diff --git a/android/app/src/main/java/com/nuvio/app/network/DoHOkHttpFactory.kt b/android/app/src/main/java/com/nuvio/app/network/DoHOkHttpFactory.kt new file mode 100644 index 00000000..38480216 --- /dev/null +++ b/android/app/src/main/java/com/nuvio/app/network/DoHOkHttpFactory.kt @@ -0,0 +1,17 @@ +package com.nuvio.app.network + +import android.content.Context +import com.facebook.react.modules.network.OkHttpClientFactory +import com.facebook.react.modules.network.OkHttpClientProvider +import okhttp3.OkHttpClient + +class DoHOkHttpFactory( + private val appContext: Context +) : OkHttpClientFactory { + override fun createNewNetworkModuleClient(): OkHttpClient { + return OkHttpClientProvider.createClientBuilder(appContext) + .dns(SwitchableDns) + .build() + } +} + diff --git a/android/app/src/main/java/com/nuvio/app/network/DoHState.kt b/android/app/src/main/java/com/nuvio/app/network/DoHState.kt new file mode 100644 index 00000000..ed72c017 --- /dev/null +++ b/android/app/src/main/java/com/nuvio/app/network/DoHState.kt @@ -0,0 +1,162 @@ +package com.nuvio.app.network + +import okhttp3.Dns +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.dnsoverhttps.DnsOverHttps +import java.net.InetAddress +import java.util.concurrent.TimeUnit +import android.content.Context +import com.tencent.mmkv.MMKV +import org.json.JSONObject + +private data class DoHProviderDefinition( + val url: String, + val bootstrapHosts: List +) + +object DoHState { + private const val SETTINGS_KEY = "app_settings" + private const val USER_CURRENT_KEY = "@user:current" + + private val providerDefinitions = mapOf( + DoHConfig.PROVIDER_CLOUDFLARE to DoHProviderDefinition( + url = "https://cloudflare-dns.com/dns-query", + bootstrapHosts = listOf("1.1.1.1", "1.0.0.1") + ), + DoHConfig.PROVIDER_GOOGLE to DoHProviderDefinition( + url = "https://dns.google/dns-query", + bootstrapHosts = listOf("8.8.8.8", "8.8.4.4") + ), + DoHConfig.PROVIDER_QUAD9 to DoHProviderDefinition( + url = "https://dns.quad9.net/dns-query", + bootstrapHosts = listOf("9.9.9.9", "149.112.112.112") + ) + ) + + private val bootstrapClient: OkHttpClient = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .writeTimeout(10, TimeUnit.SECONDS) + .build() + + @Volatile + private var config: DoHConfig = DoHConfig() + + @Volatile + private var dohDns: Dns? = null + + /** + * Initializes DoH configuration from MMKV storage. + * Call this during app onCreate to ensure "Instant-On" protection. + */ + fun initializeFromStorage(context: Context) { + try { + // Initialize MMKV if not already done + MMKV.initialize(context) + val mmkv = MMKV.defaultMMKV() ?: return + + // 1. Determine current scope (multi-user support) + val scope = mmkv.decodeString(USER_CURRENT_KEY) ?: "local" + val scopedSettingsKey = "@user:$scope:$SETTINGS_KEY" + + // 2. Try to read settings from scoped key, fallback to legacy + val settingsJson: String? = mmkv.decodeString(scopedSettingsKey) ?: mmkv.decodeString(SETTINGS_KEY) + + settingsJson?.let { jsonString -> + val json = JSONObject(jsonString) + + val nextConfig = DoHConfig( + enabled = json.optBoolean("dnsOverHttpsEnabled", false), + mode = json.optString("dnsOverHttpsMode", DoHConfig.MODE_AUTO) ?: DoHConfig.MODE_AUTO, + provider = json.optString("dnsOverHttpsProvider", DoHConfig.PROVIDER_CLOUDFLARE) ?: DoHConfig.PROVIDER_CLOUDFLARE, + customUrl = json.optString("dnsOverHttpsCustomUrl", "") ?: "" + ) + + updateConfig(nextConfig) + } + } catch (e: Exception) { + // Fallback to default (Off) on any error + } + } + + @Synchronized + fun updateConfig(nextConfig: DoHConfig) { + val normalized = normalize(nextConfig) + config = normalized + dohDns = buildDoHDns(normalized) + } + + fun currentConfig(): DoHConfig = config + + fun currentDoHDns(): Dns? = dohDns + + private fun normalize(input: DoHConfig): DoHConfig { + val normalizedMode = when (input.mode) { + DoHConfig.MODE_OFF, DoHConfig.MODE_AUTO, DoHConfig.MODE_STRICT -> input.mode + else -> DoHConfig.MODE_OFF + } + + val normalizedProvider = when (input.provider) { + DoHConfig.PROVIDER_CLOUDFLARE, + DoHConfig.PROVIDER_GOOGLE, + DoHConfig.PROVIDER_QUAD9, + DoHConfig.PROVIDER_CUSTOM -> input.provider + else -> DoHConfig.PROVIDER_CLOUDFLARE + } + + val trimmedCustomUrl = input.customUrl.trim() + + return if (!input.enabled || normalizedMode == DoHConfig.MODE_OFF) { + DoHConfig( + enabled = false, + mode = DoHConfig.MODE_OFF, + provider = normalizedProvider, + customUrl = if (normalizedProvider == DoHConfig.PROVIDER_CUSTOM) trimmedCustomUrl else "" + ) + } else { + DoHConfig( + enabled = true, + mode = normalizedMode, + provider = normalizedProvider, + customUrl = if (normalizedProvider == DoHConfig.PROVIDER_CUSTOM) trimmedCustomUrl else "" + ) + } + } + + private fun buildDoHDns(config: DoHConfig): Dns? { + if (!config.enabled || config.mode == DoHConfig.MODE_OFF) { + return null + } + + val dohUrl = resolveDoHUrl(config) ?: return null + val builder = DnsOverHttps.Builder() + .client(bootstrapClient) + .url(dohUrl) + .resolvePrivateAddresses(true) + .includeIPv6(true) + + val bootstrapHosts = resolveBootstrapHosts(config) + if (bootstrapHosts.isNotEmpty()) { + builder.bootstrapDnsHosts(*bootstrapHosts.toTypedArray()) + } + + return builder.build() + } + + private fun resolveDoHUrl(config: DoHConfig) = when (config.provider) { + DoHConfig.PROVIDER_CUSTOM -> config.customUrl.toHttpUrlOrNull() + else -> providerDefinitions[config.provider]?.url?.toHttpUrlOrNull() + } + + private fun resolveBootstrapHosts(config: DoHConfig): List { + val provider = providerDefinitions[config.provider] ?: return emptyList() + return provider.bootstrapHosts.mapNotNull { host -> + try { + InetAddress.getByName(host) + } catch (_: Exception) { + null + } + } + } +} diff --git a/android/app/src/main/java/com/nuvio/app/network/NetworkPrivacyModule.kt b/android/app/src/main/java/com/nuvio/app/network/NetworkPrivacyModule.kt new file mode 100644 index 00000000..b0cca429 --- /dev/null +++ b/android/app/src/main/java/com/nuvio/app/network/NetworkPrivacyModule.kt @@ -0,0 +1,57 @@ +package com.nuvio.app.network + +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.ReadableMap + +class NetworkPrivacyModule( + reactContext: ReactApplicationContext +) : ReactContextBaseJavaModule(reactContext) { + + override fun getName(): String = "NetworkPrivacyModule" + + @ReactMethod + fun applyDohConfig(configMap: ReadableMap, promise: Promise) { + try { + val nextConfig = DoHConfig( + enabled = configMap.getBooleanOrDefault("enabled", false), + mode = configMap.getStringOrDefault("mode", DoHConfig.MODE_OFF), + provider = configMap.getStringOrDefault("provider", DoHConfig.PROVIDER_CLOUDFLARE), + customUrl = configMap.getStringOrDefault("customUrl", "") + ) + + DoHState.updateConfig(nextConfig) + promise.resolve(null) + } catch (error: Exception) { + promise.reject("DOH_APPLY_FAILED", "Failed to apply DoH configuration", error) + } + } + + @ReactMethod + fun getDohConfig(promise: Promise) { + try { + val current = DoHState.currentConfig() + val payload = Arguments.createMap().apply { + putBoolean("enabled", current.enabled) + putString("mode", current.mode) + putString("provider", current.provider) + putString("customUrl", current.customUrl) + } + promise.resolve(payload) + } catch (error: Exception) { + promise.reject("DOH_READ_FAILED", "Failed to read DoH configuration", error) + } + } + + private fun ReadableMap.getBooleanOrDefault(key: String, fallback: Boolean): Boolean { + return if (hasKey(key) && !isNull(key)) getBoolean(key) else fallback + } + + private fun ReadableMap.getStringOrDefault(key: String, fallback: String): String { + return if (hasKey(key) && !isNull(key)) getString(key) ?: fallback else fallback + } +} + diff --git a/android/app/src/main/java/com/nuvio/app/network/NetworkPrivacyPackage.kt b/android/app/src/main/java/com/nuvio/app/network/NetworkPrivacyPackage.kt new file mode 100644 index 00000000..630b4bc6 --- /dev/null +++ b/android/app/src/main/java/com/nuvio/app/network/NetworkPrivacyPackage.kt @@ -0,0 +1,17 @@ +package com.nuvio.app.network + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class NetworkPrivacyPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List { + return listOf(NetworkPrivacyModule(reactContext)) + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + return emptyList() + } +} + diff --git a/android/app/src/main/java/com/nuvio/app/network/SwitchableDns.kt b/android/app/src/main/java/com/nuvio/app/network/SwitchableDns.kt new file mode 100644 index 00000000..52b323c3 --- /dev/null +++ b/android/app/src/main/java/com/nuvio/app/network/SwitchableDns.kt @@ -0,0 +1,36 @@ +package com.nuvio.app.network + +import okhttp3.Dns +import java.net.InetAddress +import java.net.UnknownHostException + +object SwitchableDns : Dns { + override fun lookup(hostname: String): List { + val activeConfig = DoHState.currentConfig() + if (!activeConfig.enabled || activeConfig.mode == DoHConfig.MODE_OFF) { + return Dns.SYSTEM.lookup(hostname) + } + + val dohDns = DoHState.currentDoHDns() + if (dohDns == null) { + return when (activeConfig.mode) { + DoHConfig.MODE_STRICT -> { + throw UnknownHostException("DoH is enabled in strict mode, but resolver configuration is invalid.") + } + else -> Dns.SYSTEM.lookup(hostname) + } + } + + return try { + dohDns.lookup(hostname) + } catch (error: Exception) { + if (activeConfig.mode == DoHConfig.MODE_STRICT) { + val wrapped = UnknownHostException("DoH lookup failed in strict mode for $hostname") + wrapped.initCause(error) + throw wrapped + } + Dns.SYSTEM.lookup(hostname) + } + } +} + diff --git a/ios/Nuvio/AppDelegate.swift b/ios/Nuvio/AppDelegate.swift index be0e7f70..d3da1862 100644 --- a/ios/Nuvio/AppDelegate.swift +++ b/ios/Nuvio/AppDelegate.swift @@ -6,6 +6,7 @@ import GoogleCast // @generated end react-native-google-cast-import import React import ReactAppDependencyProvider +import Network @UIApplicationMain public class AppDelegate: ExpoAppDelegate { @@ -94,3 +95,152 @@ class ReactNativeDelegate: ExpoReactNativeFactoryDelegate { } } + +private struct IOSDoHConfig { + static let modeOff = "off" + static let modeAuto = "auto" + static let modeStrict = "strict" + + static let providerCloudflare = "cloudflare" + static let providerGoogle = "google" + static let providerQuad9 = "quad9" + static let providerCustom = "custom" + + let enabled: Bool + let mode: String + let provider: String + let customUrl: String + + static func normalized(from raw: [String: Any]) -> IOSDoHConfig { + let requestedEnabled = (raw["enabled"] as? Bool) ?? false + let requestedMode = ((raw["mode"] as? String) ?? modeOff).lowercased() + let requestedProvider = ((raw["provider"] as? String) ?? providerCloudflare).lowercased() + let requestedCustomUrl = ((raw["customUrl"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + + let normalizedMode: String + switch requestedMode { + case modeAuto, modeStrict: + normalizedMode = requestedMode + default: + normalizedMode = modeOff + } + + let normalizedProvider: String + switch requestedProvider { + case providerCloudflare, providerGoogle, providerQuad9, providerCustom: + normalizedProvider = requestedProvider + default: + normalizedProvider = providerCloudflare + } + + let enabled = requestedEnabled && normalizedMode != modeOff + + return IOSDoHConfig( + enabled: enabled, + mode: enabled ? normalizedMode : modeOff, + provider: normalizedProvider, + customUrl: normalizedProvider == providerCustom ? requestedCustomUrl : "" + ) + } + + var resolverURL: URL? { + switch provider { + case IOSDoHConfig.providerCloudflare: + return URL(string: "https://cloudflare-dns.com/dns-query") + case IOSDoHConfig.providerGoogle: + return URL(string: "https://dns.google/dns-query") + case IOSDoHConfig.providerQuad9: + return URL(string: "https://dns.quad9.net/dns-query") + case IOSDoHConfig.providerCustom: + guard let candidate = URL(string: customUrl), candidate.scheme?.lowercased() == "https" else { + return nil + } + return candidate + default: + return nil + } + } + + var asDictionary: [String: Any] { + [ + "enabled": enabled, + "mode": mode, + "provider": provider, + "customUrl": customUrl, + ] + } +} + +private final class IOSDoHState { + static let shared = IOSDoHState() + + private let lock = NSLock() + private var config = IOSDoHConfig.normalized(from: [:]) + + func apply(_ nextConfig: IOSDoHConfig) { + lock.lock() + config = nextConfig + lock.unlock() + applyToNetworkStack(nextConfig) + } + + func current() -> IOSDoHConfig { + lock.lock() + defer { lock.unlock() } + return config + } + + private func applyToNetworkStack(_ config: IOSDoHConfig) { + if #available(iOS 14.0, *) { + let privacyContext = NWParameters.PrivacyContext.default + if !config.enabled || config.mode == IOSDoHConfig.modeOff { + privacyContext.requireEncryptedNameResolution(false, fallbackResolver: nil) + return + } + + // Keep AUTO mode resilient: if custom URL is invalid, fall back to system DNS. + if config.mode == IOSDoHConfig.modeAuto, config.resolverURL == nil { + privacyContext.requireEncryptedNameResolution(false, fallbackResolver: nil) + return + } + + let fallbackURL: URL? = config.mode == IOSDoHConfig.modeAuto ? config.resolverURL : nil + privacyContext.requireEncryptedNameResolution(true, fallbackResolver: fallbackURL) + } + } +} + +@objc(NetworkPrivacyModule) +class NetworkPrivacyModule: NSObject, RCTBridgeModule { + static func moduleName() -> String! { + "NetworkPrivacyModule" + } + + static func requiresMainQueueSetup() -> Bool { + false + } + + @objc(applyDohConfig:resolver:rejecter:) + func applyDohConfig( + _ configMap: NSDictionary, + resolver resolve: RCTPromiseResolveBlock, + rejecter reject: RCTPromiseRejectBlock + ) { + guard let rawConfig = configMap as? [String: Any] else { + reject("DOH_INVALID_PAYLOAD", "Invalid DoH configuration payload", nil) + return + } + + let normalized = IOSDoHConfig.normalized(from: rawConfig) + IOSDoHState.shared.apply(normalized) + resolve(nil) + } + + @objc(getDohConfig:rejecter:) + func getDohConfig( + _ resolve: RCTPromiseResolveBlock, + rejecter reject: RCTPromiseRejectBlock + ) { + resolve(IOSDoHState.shared.current().asDictionary) + } +} diff --git a/src/hooks/useSettings.ts b/src/hooks/useSettings.ts index ca9dca76..152618c4 100644 --- a/src/hooks/useSettings.ts +++ b/src/hooks/useSettings.ts @@ -109,6 +109,11 @@ export interface AppSettings { videoPlayerEngine: 'auto' | 'mpv'; // Video player engine: auto (ExoPlayer primary, MPV fallback) or mpv (MPV only) decoderMode: 'auto' | 'sw' | 'hw' | 'hw+'; // Decoder mode: auto (auto-copy), sw (software), hw (mediacodec-copy), hw+ (mediacodec) gpuMode: 'gpu' | 'gpu-next'; // GPU rendering mode: gpu (standard) or gpu-next (advanced HDR/color) + // Network privacy + dnsOverHttpsEnabled: boolean; // Enable DNS over HTTPS at the native networking layer + dnsOverHttpsMode: 'off' | 'auto' | 'strict'; // off = disabled, auto = fallback to system DNS, strict = DoH only + dnsOverHttpsProvider: 'cloudflare' | 'google' | 'quad9' | 'custom'; // Selected DoH resolver provider + dnsOverHttpsCustomUrl: string; // Custom DoH resolver URL (when provider=custom) showDiscover: boolean; // Audio/Subtitle Language Preferences preferredSubtitleLanguage: string; // Preferred language for subtitles (ISO 639-1 code, e.g., 'en', 'es', 'fr') @@ -199,6 +204,10 @@ export const DEFAULT_SETTINGS: AppSettings = { videoPlayerEngine: 'auto', // Default to auto (ExoPlayer primary, MPV fallback) decoderMode: 'auto', // Default to auto (best compatibility and performance) gpuMode: 'gpu', // Default to gpu (gpu-next for advanced HDR) + dnsOverHttpsEnabled: false, // Off by default for safe rollout + dnsOverHttpsMode: 'auto', // Keep auto as default mode when enabled + dnsOverHttpsProvider: 'cloudflare', // Default curated provider + dnsOverHttpsCustomUrl: '', // Empty unless custom provider is selected showDiscover: true, // Show Discover section in SearchScreen // Audio/Subtitle Language Preferences preferredSubtitleLanguage: 'en', // Default to English subtitles diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c696c261..11e34414 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -688,6 +688,7 @@ "ai_assistant": "AI ASSISTANT", "video_player": "VIDEO PLAYER", "audio_subtitles": "AUDIO & SUBTITLES", + "network_privacy": "NETWORK PRIVACY", "media": "MEDIA", "notifications": "NOTIFICATIONS", "testing": "TESTING", @@ -737,6 +738,15 @@ "subtitle_source": "Subtitle Source Priority", "auto_select_subs": "Auto-Select Subtitles", "auto_select_subs_desc": "Automatically select subtitles matching your preferences", + "doh_enabled": "DNS over HTTPS", + "doh_enabled_desc": "Encrypt DNS lookups for supported requests", + "doh_mode": "Mode", + "doh_provider": "Resolver Provider", + "doh_custom_url": "CUSTOM DOH URL", + "doh_custom_url_placeholder": "https://dns.example.com/dns-query", + "doh_custom_url_saved": "Custom DoH URL saved", + "doh_custom_url_cleared": "Custom DoH URL cleared", + "doh_custom_url_invalid": "Please enter a valid HTTPS DoH URL", "show_trailers": "Show Trailers", "show_trailers_desc": "Display trailers in hero section", "enable_downloads": "Enable Downloads", @@ -762,7 +772,21 @@ "external_first": "External First", "external_first_desc": "Prefer addon subtitles, then embedded", "any_available": "Any Available", - "any_available_desc": "Use first available subtitle track" + "any_available_desc": "Use first available subtitle track", + "doh_mode_off": "Off", + "doh_mode_off_desc": "Use regular system DNS", + "doh_mode_auto": "Auto (Fallback)", + "doh_mode_auto_desc": "Prefer DoH and fallback to system DNS if needed", + "doh_mode_strict": "Strict (DoH Only)", + "doh_mode_strict_desc": "Fail requests when DoH resolution is unavailable", + "doh_provider_cloudflare": "Cloudflare", + "doh_provider_cloudflare_desc": "Fast public resolver (1.1.1.1)", + "doh_provider_google": "Google", + "doh_provider_google_desc": "Google Public DNS over HTTPS", + "doh_provider_quad9": "Quad9", + "doh_provider_quad9_desc": "Security-focused resolver (9.9.9.9)", + "doh_provider_custom": "Custom URL", + "doh_provider_custom_desc": "Use your own DoH endpoint" }, "clear_data_desc": "This will reset all settings and clear all cached data. Are you sure?", "app_updates": "App Updates", diff --git a/src/screens/settings/PlaybackSettingsScreen.tsx b/src/screens/settings/PlaybackSettingsScreen.tsx index ed0abd6b..728f6d2c 100644 --- a/src/screens/settings/PlaybackSettingsScreen.tsx +++ b/src/screens/settings/PlaybackSettingsScreen.tsx @@ -9,12 +9,13 @@ import { RootStackParamList } from '../../navigation/AppNavigator'; import ScreenHeader from '../../components/common/ScreenHeader'; import { SettingsCard, SettingItem, CustomSwitch, ChevronRight } from './SettingsComponents'; import { useRealtimeConfig } from '../../hooks/useRealtimeConfig'; -import { MaterialIcons } from '@expo/vector-icons'; +import { MaterialIcons, Ionicons } from '@expo/vector-icons'; import { BottomSheetModal, BottomSheetScrollView, BottomSheetBackdrop } from '@gorhom/bottom-sheet'; import { useTranslation } from 'react-i18next'; import { SvgXml } from 'react-native-svg'; import { toastService } from '../../services/toastService'; import { introService } from '../../services/introService'; +import { DoHMode, DoHProvider } from '../../services/networkPrivacyService'; const { width } = Dimensions.get('window'); @@ -66,9 +67,21 @@ const AVAILABLE_LANGUAGES = [ ]; const SUBTITLE_SOURCE_OPTIONS = [ - { value: 'internal', label: 'Internal First', description: 'Prefer embedded subtitles, then external' }, - { value: 'external', label: 'External First', description: 'Prefer addon subtitles, then embedded' }, - { value: 'any', label: 'Any Available', description: 'Use first available subtitle track' }, + { value: 'internal', label: 'Internal First', description: 'Prefer embedded subtitles, then external' }, + { value: 'external', label: 'External First', description: 'Prefer addon subtitles, then embedded' }, + { value: 'any', label: 'Any Available', description: 'Use first available subtitle track' }, +]; + +const DOH_MODE_OPTIONS: Array<{ value: DoHMode; labelKey: string; descKey: string }> = [ + { value: 'auto', labelKey: 'settings.options.doh_mode_auto', descKey: 'settings.options.doh_mode_auto_desc' }, + { value: 'strict', labelKey: 'settings.options.doh_mode_strict', descKey: 'settings.options.doh_mode_strict_desc' }, +]; + +const DOH_PROVIDER_OPTIONS: Array<{ value: DoHProvider; labelKey: string; descKey: string }> = [ + { value: 'cloudflare', labelKey: 'settings.options.doh_provider_cloudflare', descKey: 'settings.options.doh_provider_cloudflare_desc' }, + { value: 'google', labelKey: 'settings.options.doh_provider_google', descKey: 'settings.options.doh_provider_google_desc' }, + { value: 'quad9', labelKey: 'settings.options.doh_provider_quad9', descKey: 'settings.options.doh_provider_quad9_desc' }, + { value: 'custom', labelKey: 'settings.options.doh_provider_custom', descKey: 'settings.options.doh_provider_custom_desc' }, ]; // Props for the reusable content component @@ -78,7 +91,6 @@ interface PlaybackSettingsContentProps { /** * Reusable PlaybackSettingsContent component - * Can be used inline (tablets) or wrapped in a screen (mobile) */ export const PlaybackSettingsContent: React.FC = ({ isTablet = false }) => { const navigation = useNavigation>(); @@ -89,6 +101,7 @@ export const PlaybackSettingsContent: React.FC = ( const [introDbLogoXml, setIntroDbLogoXml] = useState(null); const [apiKeyInput, setApiKeyInput] = useState(settings?.introDbApiKey || ''); + const [customDohUrlInput, setCustomDohUrlInput] = useState(settings?.dnsOverHttpsCustomUrl || ''); const [isVerifyingKey, setIsVerifyingKey] = useState(false); const isMounted = useRef(true); @@ -104,6 +117,10 @@ export const PlaybackSettingsContent: React.FC = ( setApiKeyInput(settings?.introDbApiKey || ''); }, [settings?.introDbApiKey]); + useEffect(() => { + setCustomDohUrlInput(settings?.dnsOverHttpsCustomUrl || ''); + }, [settings?.dnsOverHttpsCustomUrl]); + const handleApiKeySubmit = async () => { if (!apiKeyInput.trim()) { updateSetting('introDbApiKey', ''); @@ -131,13 +148,10 @@ export const PlaybackSettingsContent: React.FC = ( try { const res = await fetch(INTRODB_LOGO_URI); let xml = await res.text(); - // Inline CSS class-based styles because react-native-svg doesn't support