This commit is contained in:
paregi12 2026-03-09 08:50:06 +01:00 committed by GitHub
commit afe8f8675f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 899 additions and 20 deletions

20
App.tsx
View file

@ -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<boolean | null>(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,

View file

@ -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")

View file

@ -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<ReactPackage> =
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) {

View file

@ -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"
}
}

View file

@ -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()
}
}

View file

@ -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<String>
)
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<InetAddress> {
val provider = providerDefinitions[config.provider] ?: return emptyList()
return provider.bootstrapHosts.mapNotNull { host ->
try {
InetAddress.getByName(host)
} catch (_: Exception) {
null
}
}
}
}

View file

@ -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
}
}

View file

@ -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<NativeModule> {
return listOf(NetworkPrivacyModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}

View file

@ -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<InetAddress> {
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)
}
}
}

View file

@ -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)
}
}

View file

@ -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

View file

@ -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",

View file

@ -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<PlaybackSettingsContentProps> = ({ isTablet = false }) => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();
@ -89,6 +101,7 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
const [introDbLogoXml, setIntroDbLogoXml] = useState<string | null>(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<PlaybackSettingsContentProps> = (
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<PlaybackSettingsContentProps> = (
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 <style> class selectors
// Map known classes from the IntroDB logo to equivalent inline attributes
xml = xml.replace(/class="cls-4"/g, 'fill="url(#linear-gradient)"');
xml = xml.replace(/class="cls-3"/g, 'fill="#141414" opacity=".38"');
xml = xml.replace(/class="cls-1"/g, 'fill="url(#linear-gradient-2)" opacity=".53"');
xml = xml.replace(/class="cls-2"/g, 'fill="url(#linear-gradient-3)" opacity=".53"');
// Remove the <style> block to avoid unsupported CSS
xml = xml.replace(/<style>[\s\S]*?<\/style>/, '');
if (!cancelled) setIntroDbLogoXml(xml);
} catch {
@ -160,30 +174,56 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
const audioLanguageSheetRef = useRef<BottomSheetModal>(null);
const subtitleLanguageSheetRef = useRef<BottomSheetModal>(null);
const subtitleSourceSheetRef = useRef<BottomSheetModal>(null);
const dohModeSheetRef = useRef<BottomSheetModal>(null);
const dohProviderSheetRef = useRef<BottomSheetModal>(null);
// Snap points
const languageSnapPoints = useMemo(() => ['70%'], []);
const sourceSnapPoints = useMemo(() => ['45%'], []);
const dohModeSnapPoints = useMemo(() => ['40%'], []);
const dohProviderSnapPoints = useMemo(() => ['52%'], []);
// Handlers to present sheets - ensure only one is open at a time
// Handlers
const openAudioLanguageSheet = useCallback(() => {
subtitleLanguageSheetRef.current?.dismiss();
subtitleSourceSheetRef.current?.dismiss();
dohModeSheetRef.current?.dismiss();
dohProviderSheetRef.current?.dismiss();
setTimeout(() => audioLanguageSheetRef.current?.present(), 100);
}, []);
const openSubtitleLanguageSheet = useCallback(() => {
audioLanguageSheetRef.current?.dismiss();
subtitleSourceSheetRef.current?.dismiss();
dohModeSheetRef.current?.dismiss();
dohProviderSheetRef.current?.dismiss();
setTimeout(() => subtitleLanguageSheetRef.current?.present(), 100);
}, []);
const openSubtitleSourceSheet = useCallback(() => {
audioLanguageSheetRef.current?.dismiss();
subtitleLanguageSheetRef.current?.dismiss();
dohModeSheetRef.current?.dismiss();
dohProviderSheetRef.current?.dismiss();
setTimeout(() => subtitleSourceSheetRef.current?.present(), 100);
}, []);
const openDoHModeSheet = useCallback(() => {
audioLanguageSheetRef.current?.dismiss();
subtitleLanguageSheetRef.current?.dismiss();
subtitleSourceSheetRef.current?.dismiss();
dohProviderSheetRef.current?.dismiss();
setTimeout(() => dohModeSheetRef.current?.present(), 100);
}, []);
const openDoHProviderSheet = useCallback(() => {
audioLanguageSheetRef.current?.dismiss();
subtitleLanguageSheetRef.current?.dismiss();
subtitleSourceSheetRef.current?.dismiss();
dohModeSheetRef.current?.dismiss();
setTimeout(() => dohProviderSheetRef.current?.present(), 100);
}, []);
const isItemVisible = (itemId: string) => {
if (!config?.items) return true;
const item = config.items[itemId];
@ -207,6 +247,19 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
return t('settings.options.internal_first');
};
const getDoHModeLabel = (value: DoHMode) => {
if (value === 'off') return t('settings.options.doh_mode_off', { defaultValue: 'Off' });
if (value === 'strict') return t('settings.options.doh_mode_strict', { defaultValue: 'Strict (DoH Only)' });
return t('settings.options.doh_mode_auto', { defaultValue: 'Auto (Fallback)' });
};
const getDoHProviderLabel = (value: DoHProvider) => {
if (value === 'google') return t('settings.options.doh_provider_google', { defaultValue: 'Google' });
if (value === 'quad9') return t('settings.options.doh_provider_quad9', { defaultValue: 'Quad9' });
if (value === 'custom') return t('settings.options.doh_provider_custom', { defaultValue: 'Custom URL' });
return t('settings.options.doh_provider_cloudflare', { defaultValue: 'Cloudflare' });
};
// Render backdrop for bottom sheets
const renderBackdrop = useCallback(
(props: any) => (
@ -235,6 +288,38 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
subtitleSourceSheetRef.current?.dismiss();
};
const handleSelectDoHMode = (value: DoHMode) => {
updateSetting('dnsOverHttpsMode', value);
dohModeSheetRef.current?.dismiss();
};
const handleSelectDoHProvider = (value: DoHProvider) => {
updateSetting('dnsOverHttpsProvider', value);
dohProviderSheetRef.current?.dismiss();
};
const handleSaveCustomDoHUrl = () => {
const trimmed = customDohUrlInput.trim();
if (!trimmed) {
updateSetting('dnsOverHttpsCustomUrl', '');
toastService.success(t('settings.items.doh_custom_url_cleared', { defaultValue: 'Custom DoH URL cleared' }));
return;
}
try {
const parsed = new URL(trimmed);
if (parsed.protocol !== 'https:') {
throw new Error('DoH URL must use HTTPS');
}
} catch {
toastService.error(t('settings.items.doh_custom_url_invalid', { defaultValue: 'Please enter a valid HTTPS DoH URL' }));
return;
}
updateSetting('dnsOverHttpsCustomUrl', trimmed);
toastService.success(t('settings.items.doh_custom_url_saved', { defaultValue: 'Custom DoH URL saved' }));
};
return (
<>
{hasVisibleItems(['video_player']) && (
@ -356,11 +441,74 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
onValueChange={(value) => updateSetting('enableSubtitleAutoSelect', value)}
/>
)}
isLast
isLast={isTablet}
isTablet={isTablet}
/>
</SettingsCard>
<SettingsCard title={t('settings.sections.network_privacy', { defaultValue: 'Network Privacy' })} isTablet={isTablet}>
<SettingItem
title={t('settings.items.doh_enabled', { defaultValue: 'DNS over HTTPS' })}
description={t('settings.items.doh_enabled_desc', { defaultValue: 'Encrypt DNS lookups for supported requests' })}
icon="shield"
renderControl={() => (
<CustomSwitch
value={settings?.dnsOverHttpsEnabled ?? false}
onValueChange={(value) => updateSetting('dnsOverHttpsEnabled', value)}
/>
)}
isLast={!settings?.dnsOverHttpsEnabled}
isTablet={isTablet}
/>
{settings?.dnsOverHttpsEnabled && (
<>
<SettingItem
title={t('settings.items.doh_mode', { defaultValue: 'Mode' })}
description={getDoHModeLabel((settings?.dnsOverHttpsMode || 'auto') as DoHMode)}
icon="sliders"
renderControl={() => <ChevronRight />}
onPress={openDoHModeSheet}
isTablet={isTablet}
/>
<SettingItem
title={t('settings.items.doh_provider', { defaultValue: 'Resolver Provider' })}
description={getDoHProviderLabel((settings?.dnsOverHttpsProvider || 'cloudflare') as DoHProvider)}
icon="globe"
renderControl={() => <ChevronRight />}
onPress={openDoHProviderSheet}
isLast={settings?.dnsOverHttpsProvider !== 'custom'}
isTablet={isTablet}
/>
{settings?.dnsOverHttpsProvider === 'custom' && (
<View style={styles.inputContainer}>
<Text style={styles.inputLabel}>
{t('settings.items.doh_custom_url', { defaultValue: 'CUSTOM DOH URL' })}
</Text>
<View style={styles.apiKeyRow}>
<TextInput
style={[styles.input, { flex: 1, marginRight: 10, color: currentTheme.colors.highEmphasis }]}
value={customDohUrlInput}
onChangeText={setCustomDohUrlInput}
placeholder={t('settings.items.doh_custom_url_placeholder', { defaultValue: 'https://dns.example.com/dns-query' })}
placeholderTextColor={currentTheme.colors.mediumEmphasis}
autoCapitalize="none"
autoCorrect={false}
/>
<TouchableOpacity
style={styles.confirmButton}
onPress={handleSaveCustomDoHUrl}
>
<MaterialIcons name="check" size={24} color="black" />
</TouchableOpacity>
</View>
</View>
)}
</>
)}
</SettingsCard>
{hasVisibleItems(['show_trailers', 'enable_downloads']) && (
<SettingsCard title={t('settings.sections.media')} isTablet={isTablet}>
{isItemVisible('show_trailers') && (
@ -411,7 +559,6 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
</SettingsCard>
)}
{/* Audio Language Bottom Sheet */}
<BottomSheetModal
ref={audioLanguageSheetRef}
index={0}
@ -452,7 +599,6 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
</BottomSheetScrollView>
</BottomSheetModal>
{/* Subtitle Language Bottom Sheet */}
<BottomSheetModal
ref={subtitleLanguageSheetRef}
index={0}
@ -493,7 +639,6 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
</BottomSheetScrollView>
</BottomSheetModal>
{/* Subtitle Source Priority Bottom Sheet */}
<BottomSheetModal
ref={subtitleSourceSheetRef}
index={0}
@ -537,13 +682,98 @@ export const PlaybackSettingsContent: React.FC<PlaybackSettingsContentProps> = (
})}
</BottomSheetScrollView>
</BottomSheetModal>
<BottomSheetModal
ref={dohModeSheetRef}
index={0}
snapPoints={dohModeSnapPoints}
enableDynamicSizing={false}
enablePanDownToClose={true}
backdropComponent={renderBackdrop}
backgroundStyle={{ backgroundColor: '#1a1a1a' }}
handleIndicatorStyle={{ backgroundColor: 'rgba(255,255,255,0.3)' }}
>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>{t('settings.items.doh_mode', { defaultValue: 'Mode' })}</Text>
</View>
<BottomSheetScrollView contentContainerStyle={styles.sheetContent}>
{DOH_MODE_OPTIONS.map((option) => {
const currentMode = (settings?.dnsOverHttpsMode === 'strict' ? 'strict' : 'auto') as DoHMode;
const isSelected = option.value === currentMode;
return (
<TouchableOpacity
key={option.value}
style={[
styles.sourceItem,
isSelected && { backgroundColor: currentTheme.colors.primary + '20', borderColor: currentTheme.colors.primary }
]}
onPress={() => handleSelectDoHMode(option.value)}
>
<View style={styles.sourceItemContent}>
<Text style={[styles.sourceLabel, { color: isSelected ? currentTheme.colors.primary : '#fff' }]}>
{t(option.labelKey, { defaultValue: option.value.toUpperCase() })}
</Text>
<Text style={styles.sourceDescription}>
{t(option.descKey, { defaultValue: '' })}
</Text>
</View>
{isSelected && (
<MaterialIcons name="check" size={20} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
);
})}
</BottomSheetScrollView>
</BottomSheetModal>
<BottomSheetModal
ref={dohProviderSheetRef}
index={0}
snapPoints={dohProviderSnapPoints}
enableDynamicSizing={false}
enablePanDownToClose={true}
backdropComponent={renderBackdrop}
backgroundStyle={{ backgroundColor: '#1a1a1a' }}
handleIndicatorStyle={{ backgroundColor: 'rgba(255,255,255,0.3)' }}
>
<View style={styles.sheetHeader}>
<Text style={styles.sheetTitle}>{t('settings.items.doh_provider', { defaultValue: 'Resolver Provider' })}</Text>
</View>
<BottomSheetScrollView contentContainerStyle={styles.sheetContent}>
{DOH_PROVIDER_OPTIONS.map((option) => {
const currentProvider = (settings?.dnsOverHttpsProvider || 'cloudflare') as DoHProvider;
const isSelected = option.value === currentProvider;
return (
<TouchableOpacity
key={option.value}
style={[
styles.sourceItem,
isSelected && { backgroundColor: currentTheme.colors.primary + '20', borderColor: currentTheme.colors.primary }
]}
onPress={() => handleSelectDoHProvider(option.value)}
>
<View style={styles.sourceItemContent}>
<Text style={[styles.sourceLabel, { color: isSelected ? currentTheme.colors.primary : '#fff' }]}>
{t(option.labelKey, { defaultValue: option.value })}
</Text>
<Text style={styles.sourceDescription}>
{t(option.descKey, { defaultValue: '' })}
</Text>
</View>
{isSelected && (
<MaterialIcons name="check" size={20} color={currentTheme.colors.primary} />
)}
</TouchableOpacity>
);
})}
</BottomSheetScrollView>
</BottomSheetModal>
</>
);
};
/**
* PlaybackSettingsScreen - Wrapper for mobile navigation
* Uses PlaybackSettingsContent internally
*/
const PlaybackSettingsScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp<RootStackParamList>>();

View file

@ -59,6 +59,7 @@ interface SettingItemProps {
badge?: string | number;
isTablet?: boolean;
descriptionNumberOfLines?: number;
disabled?: boolean;
}
export const SettingItem: React.FC<SettingItemProps> = ({
@ -71,20 +72,23 @@ export const SettingItem: React.FC<SettingItemProps> = ({
onPress,
badge,
isTablet: isTabletProp = false,
descriptionNumberOfLines = 1
descriptionNumberOfLines = 1,
disabled = false
}) => {
const { currentTheme } = useTheme();
const useTabletStyle = isTabletProp || isTablet;
return (
<TouchableOpacity
activeOpacity={0.6}
onPress={onPress}
activeOpacity={disabled ? 1 : 0.6}
onPress={disabled ? undefined : onPress}
disabled={disabled}
style={[
styles.settingItem,
!isLast && styles.settingItemBorder,
{ borderBottomColor: currentTheme.colors.elevation2 },
useTabletStyle && styles.tabletSettingItem
useTabletStyle && styles.tabletSettingItem,
disabled && { opacity: 0.4 }
]}
>
<View style={[

View file

@ -0,0 +1,119 @@
import { NativeModules, Platform } from 'react-native';
import type { AppSettings } from '../hooks/useSettings';
import { logger } from '../utils/logger';
export type DoHMode = 'off' | 'auto' | 'strict';
export type DoHProvider = 'cloudflare' | 'google' | 'quad9' | 'custom';
export interface DoHConfig {
enabled: boolean;
mode: DoHMode;
provider: DoHProvider;
customUrl: string;
}
interface NetworkPrivacyNativeModule {
applyDohConfig: (config: DoHConfig) => Promise<void>;
}
const NATIVE_DOH_MODULE: NetworkPrivacyNativeModule | undefined =
Platform.OS === 'android' || Platform.OS === 'ios'
? (NativeModules.NetworkPrivacyModule as NetworkPrivacyNativeModule | undefined)
: undefined;
const DEFAULT_DOH_CONFIG: DoHConfig = {
enabled: false,
mode: 'off',
provider: 'cloudflare',
customUrl: '',
};
const VALID_MODES: ReadonlyArray<DoHMode> = ['off', 'auto', 'strict'];
const VALID_PROVIDERS: ReadonlyArray<DoHProvider> = ['cloudflare', 'google', 'quad9', 'custom'];
const normalizeMode = (mode: unknown): DoHMode => {
if (typeof mode === 'string' && VALID_MODES.includes(mode as DoHMode)) {
return mode as DoHMode;
}
return DEFAULT_DOH_CONFIG.mode;
};
const normalizeProvider = (provider: unknown): DoHProvider => {
if (typeof provider === 'string' && VALID_PROVIDERS.includes(provider as DoHProvider)) {
return provider as DoHProvider;
}
return DEFAULT_DOH_CONFIG.provider;
};
const toConfigKey = (config: DoHConfig): string =>
`${config.enabled}:${config.mode}:${config.provider}:${config.customUrl}`;
const normalizeDoHConfig = (config: Partial<DoHConfig>): DoHConfig => {
const normalized: DoHConfig = {
enabled: Boolean(config.enabled),
mode: normalizeMode(config.mode),
provider: normalizeProvider(config.provider),
customUrl: typeof config.customUrl === 'string' ? config.customUrl.trim() : '',
};
if (!normalized.enabled || normalized.mode === 'off') {
return {
enabled: false,
mode: 'off',
provider: normalized.provider,
customUrl: normalized.provider === 'custom' ? normalized.customUrl : '',
};
}
if (normalized.provider !== 'custom') {
normalized.customUrl = '';
}
return normalized;
};
const settingsToDoHConfig = (settings: AppSettings): DoHConfig =>
normalizeDoHConfig({
enabled: settings.dnsOverHttpsEnabled,
mode: settings.dnsOverHttpsMode,
provider: settings.dnsOverHttpsProvider,
customUrl: settings.dnsOverHttpsCustomUrl,
});
class NetworkPrivacyService {
private lastAppliedConfigKey: string | null = null;
async applyFromSettings(settings: AppSettings): Promise<void> {
await this.applyConfig(settingsToDoHConfig(settings));
}
async applyConfig(config: Partial<DoHConfig>): Promise<void> {
if (!NATIVE_DOH_MODULE?.applyDohConfig) {
return;
}
const normalized = normalizeDoHConfig(config);
const nextKey = toConfigKey(normalized);
if (this.lastAppliedConfigKey === nextKey) {
return;
}
try {
await NATIVE_DOH_MODULE.applyDohConfig(normalized);
this.lastAppliedConfigKey = nextKey;
if (!normalized.enabled || normalized.mode === 'off') {
logger.log('[NetworkPrivacyService] DNS-over-HTTPS disabled (System DNS active)');
} else {
logger.log('[NetworkPrivacyService] Applied DoH config', {
mode: normalized.mode,
provider: normalized.provider,
});
}
} catch (error) {
logger.error('[NetworkPrivacyService] Failed to apply DoH config', error);
}
}
}
export const networkPrivacyService = new NetworkPrivacyService();