mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-03-11 17:45:38 +00:00
Merge 49e6c1f500 into 7d60a0c43f
This commit is contained in:
commit
afe8f8675f
15 changed files with 899 additions and 20 deletions
20
App.tsx
20
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<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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
20
android/app/src/main/java/com/nuvio/app/network/DoHConfig.kt
Normal file
20
android/app/src/main/java/com/nuvio/app/network/DoHConfig.kt
Normal 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"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
162
android/app/src/main/java/com/nuvio/app/network/DoHState.kt
Normal file
162
android/app/src/main/java/com/nuvio/app/network/DoHState.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>>();
|
||||
|
|
|
|||
|
|
@ -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={[
|
||||
|
|
|
|||
119
src/services/networkPrivacyService.ts
Normal file
119
src/services/networkPrivacyService.ts
Normal 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();
|
||||
Loading…
Reference in a new issue