diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e55ce3760..5820a3e16 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -106,7 +106,7 @@ android { debug { isMinifyEnabled = false isDebuggable = true - // applicationIdSuffix = ".debug" // Disabled to preserve settings between debug/release + // applicationIdSuffix = ".debug" // Keep debug separate from release for side-by-side installs versionNameSuffix = "-debug" // Build config fields for debug @@ -117,7 +117,7 @@ android { // Staging build type for testing release builds create("staging") { initWith(getByName("release")) - applicationIdSuffix = ".staging" + // applicationIdSuffix = ".staging" versionNameSuffix = "-staging" signingConfig = signingConfigs.getByName("debug") diff --git a/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt b/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt index ef544424e..26f0d833e 100644 --- a/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt +++ b/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt @@ -23,6 +23,7 @@ import com.arflix.tv.data.repository.CloudSyncRepository import com.arflix.tv.data.repository.RealtimeSyncManager import com.arflix.tv.data.repository.WatchlistRepository import com.arflix.tv.data.repository.ProfileManager +import com.arflix.tv.util.settingsDataStore import com.arflix.tv.util.AppLogger import com.arflix.tv.util.CrashlyticsProvider import com.arflix.tv.worker.TraktSyncWorker @@ -31,6 +32,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -55,6 +57,22 @@ class ArflixApplication : Application(), Configuration.Provider, ImageLoaderFact @Inject lateinit var watchlistRepository: WatchlistRepository + private suspend fun applyPersistedDnsProviderAtStartup() { + val prefs = settingsDataStore.data.first() + val activeProfileId = profileManager.getProfileIdSync() + val activeKeyName = profileManager.profileStringKeyFor(activeProfileId, "dns_provider").name + val activeRaw = prefs.asMap().entries + .firstOrNull { entry -> entry.key.name == activeKeyName } + ?.value as? String + val fallbackRaw = prefs.asMap().entries + .firstOrNull { entry -> entry.key.name.endsWith("_dns_provider") && entry.value is String } + ?.value as? String + val rawProvider = activeRaw ?: fallbackRaw ?: "system" + val provider = OkHttpProvider.parseDnsProvider(rawProvider) + OkHttpProvider.setDnsProvider(provider) + AppLogger.d("AppDns", "Startup DNS bootstrap profile=$activeProfileId provider=$provider raw=$rawProvider") + } + override fun onCreate() { super.onCreate() instance = this @@ -72,6 +90,8 @@ class ArflixApplication : Application(), Configuration.Provider, ImageLoaderFact runCatching { profileManager.initialize() } // Preload watchlist cache in background for instant display runCatching { watchlistRepository.getWatchlistItems() } + runCatching { applyPersistedDnsProviderAtStartup() } + .onFailure { AppLogger.w("AppDns", "Failed startup DNS bootstrap: ${it.message}") } if (!authRepository.getCurrentUserId().isNullOrBlank()) { // Pull cloud state shortly after startup for faster cross-device sync. delay(3_000L) diff --git a/app/src/main/kotlin/com/arflix/tv/MainActivity.kt b/app/src/main/kotlin/com/arflix/tv/MainActivity.kt index f69cdc68c..430dd1426 100644 --- a/app/src/main/kotlin/com/arflix/tv/MainActivity.kt +++ b/app/src/main/kotlin/com/arflix/tv/MainActivity.kt @@ -69,7 +69,9 @@ import com.arflix.tv.util.detectDeviceType import com.arflix.tv.util.deviceHasTouchScreen import com.arflix.tv.util.settingsDataStore import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.longPreferencesKey import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import androidx.compose.runtime.CompositionLocalProvider import androidx.hilt.navigation.compose.hiltViewModel @@ -116,6 +118,10 @@ import kotlin.math.sin @AndroidEntryPoint class MainActivity : ComponentActivity() { + companion object { + private val DNS_RELOAD_NONCE_KEY = longPreferencesKey("dns_reload_nonce") + } + @Inject lateinit var authRepository: Lazy @@ -196,6 +202,20 @@ class MainActivity : ComponentActivity() { val activeProfileLoaded by remember { profileRepository.get().activeProfileId.map { true } }.collectAsState(initial = false) + val dnsReloadNonce by remember { + this@MainActivity.settingsDataStore.data + .map { prefs -> prefs[DNS_RELOAD_NONCE_KEY] ?: 0L } + .distinctUntilChanged() + }.collectAsState(initial = 0L) + var previousDnsReloadNonce by remember { mutableStateOf(null) } + LaunchedEffect(dnsReloadNonce) { + val previous = previousDnsReloadNonce + previousDnsReloadNonce = dnsReloadNonce + if (previous != null && dnsReloadNonce > 0L && dnsReloadNonce != previous) { + // Reload only on explicit user DNS change events, never on startup reads. + this@MainActivity.recreate() + } + } val deviceType = when (deviceModeOverride) { "tv" -> DeviceType.TV "tablet" -> DeviceType.TABLET diff --git a/app/src/main/kotlin/com/arflix/tv/network/OkHttpProvider.kt b/app/src/main/kotlin/com/arflix/tv/network/OkHttpProvider.kt index 7df98e640..c61e5bb18 100644 --- a/app/src/main/kotlin/com/arflix/tv/network/OkHttpProvider.kt +++ b/app/src/main/kotlin/com/arflix/tv/network/OkHttpProvider.kt @@ -218,10 +218,32 @@ object OkHttpProvider { fun setDnsProvider(provider: AppDnsProvider) { selectedDnsProvider = provider + appConnectionPool.evictAll() + cancelInFlightTmdbCalls() Log.i(TAG, "Using DNS provider=$provider") - dnsScope.launch { - appConnectionPool.evictAll() - Log.i(TAG, "Evicted pooled app connections after DNS change") + Log.i(TAG, "Evicted pooled app connections after DNS change") + } + + private fun cancelInFlightTmdbCalls() { + val dispatcher = appClient?.dispatcher ?: return + val targetHosts = setOf("api.themoviedb.org", "image.tmdb.org") + + var canceled = 0 + dispatcher.runningCalls().forEach { call -> + if (call.request().url.host in targetHosts) { + call.cancel() + canceled++ + } + } + dispatcher.queuedCalls().forEach { call -> + if (call.request().url.host in targetHosts) { + call.cancel() + canceled++ + } + } + + if (canceled > 0) { + Log.i(TAG, "Canceled $canceled in-flight TMDB calls after DNS change") } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt index 5f85353d1..ed230b715 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt @@ -24,6 +24,7 @@ import com.arflix.tv.data.repository.CloudSyncRepository import com.arflix.tv.data.repository.LauncherContinueWatchingRepository import com.arflix.tv.data.repository.StreamRepository import com.arflix.tv.data.repository.IptvRepository +import com.arflix.tv.data.repository.ProfileManager import com.arflix.tv.data.repository.SyncStatus import com.arflix.tv.data.repository.WatchHistoryRepository import com.arflix.tv.data.repository.WatchlistRepository @@ -96,6 +97,7 @@ class HomeViewModel @Inject constructor( private val watchlistRepository: WatchlistRepository, private val cloudSyncRepository: CloudSyncRepository, private val launcherContinueWatchingRepository: LauncherContinueWatchingRepository, + private val profileManager: ProfileManager, @ApplicationContext private val context: Context ) : ViewModel() { private val imageLoader: ImageLoader by lazy(LazyThreadSafetyMode.NONE) { @@ -501,6 +503,82 @@ class HomeViewModel @Inject constructor( private val LOGO_CACHE_PUBLISH_THROTTLE_MS = if (isLowRamDevice) 400L else 180L private val LOGO_CACHE_IDLE_REQUIRED_MS = if (isLowRamDevice) 350L else 200L private val LOGO_CACHE_FAST_SCROLL_IDLE_MS = if (isLowRamDevice) 180L else 100L + private var observedDnsProvider: String? = null + + private fun normalizeDnsProvider(raw: String?): String { + return raw?.trim()?.uppercase(Locale.US) + ?.takeIf { it in setOf("SYSTEM", "CLOUDFLARE", "GOOGLE", "ADGUARD") } + ?: "SYSTEM" + } + + private fun observeDnsProviderChanges() { + viewModelScope.launch { + context.settingsDataStore.data + .map { prefs -> + val prefsMap = prefs.asMap() + val activeKeyName = profileManager.profileStringKey("dns_provider").name + val activeRaw = prefsMap.entries + .firstOrNull { entry -> entry.key.name == activeKeyName } + ?.value as? String + val fallbackRaw = prefsMap.entries + .firstOrNull { entry -> entry.key.name.endsWith("_dns_provider") && entry.value is String } + ?.value as? String + normalizeDnsProvider(activeRaw ?: fallbackRaw) + } + .distinctUntilChanged() + .collect { provider -> + if (observedDnsProvider == null) { + observedDnsProvider = provider + return@collect + } + if (provider == observedDnsProvider) { + return@collect + } + observedDnsProvider = provider + forceHardHomeReloadForDnsChange(provider) + } + } + } + + private fun forceHardHomeReloadForDnsChange(provider: String) { + System.err.println("HomeVM: DNS changed to $provider, forcing full content reload") + + loadHomeJob?.cancel() + customCatalogsJob?.cancel() + refreshContinueWatchingJob?.cancel() + watchedBadgesJob?.cancel() + prefetchJob?.cancel() + preloadCategoryJob?.cancel() + preloadCategoryPriorityJob?.cancel() + heroUpdateJob?.cancel() + heroDetailsJob?.cancel() + + usedPreloadedData = false + lastResolvedBaseCategories = emptyList() + lastContinueWatchingItems = emptyList() + lastContinueWatchingUpdateMs = 0L + savedCatalogById.clear() + categoryPaginationStates.clear() + preloadedRequests.clear() + iptvChannelMap.clear() + + _cardLogoUrls.value = emptyMap() + _uiState.value = _uiState.value.copy( + isLoading = true, + isInitialLoad = true, + categories = emptyList(), + error = null, + heroItem = null, + heroLogoUrl = null, + heroTrailerKey = null, + heroOverviewOverride = null, + previousHeroItem = null, + previousHeroLogoUrl = null, + isHeroTransitioning = false + ) + + loadHomeData() + } private fun getCachedLogo(key: String): String? = synchronized(logoCacheLock) { logoCache[key] @@ -640,6 +718,7 @@ class HomeViewModel @Inject constructor( } init { + observeDnsProviderChanges() // Load trailer auto-play setting viewModelScope.launch { try { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 331b91d1f..f9d2703a5 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -499,6 +499,7 @@ fun SettingsScreen( 11 -> openUiModeWarningDialog() 12 -> viewModel.setSkipProfileSelection(!uiState.skipProfileSelection) 13 -> openDnsProviderPicker() + } } 1 -> { // IPTV @@ -2149,7 +2150,7 @@ private fun GeneralSettings( trailerAutoPlay: Boolean = false, onSubtitleSizeClick: () -> Unit = {}, onSubtitleColorClick: () -> Unit = {}, - onTrailerAutoPlayToggle: (Boolean) -> Unit = {} + onTrailerAutoPlayToggle: (Boolean) -> Unit = {}, ) { Column { // ── Language & Subtitles ── @@ -2313,6 +2314,8 @@ private fun GeneralSettings( isFocused = focusedIndex == 13, onClick = onDnsProviderClick ) + + } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 1ce2c2c27..94fbf0ee0 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -5,6 +5,7 @@ import coil.Coil import com.arflix.tv.BuildConfig import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.arflix.tv.data.api.TraktDeviceCode @@ -157,6 +158,10 @@ class SettingsViewModel @Inject constructor( private val apkDownloader: ApkDownloader ) : ViewModel() { + companion object { + private const val DNS_RELOAD_NONCE_PREF = "dns_reload_nonce" + } + private val _uiState = MutableStateFlow(SettingsUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -182,6 +187,7 @@ class SettingsViewModel @Inject constructor( private fun subtitleSizeKey() = profileManager.profileStringKey("subtitle_size") private fun subtitleColorKey() = profileManager.profileStringKey("subtitle_color") private fun dnsProviderKey() = profileManager.profileStringKey("dns_provider") + private fun dnsReloadNonceKey() = longPreferencesKey(DNS_RELOAD_NONCE_PREF) private fun includeSpecialsKey() = profileManager.profileBooleanKey("include_specials") private fun includeSpecialsKeyFor(profileId: String) = profileManager.profileBooleanKeyFor(profileId, "include_specials") private val gson = Gson() @@ -271,6 +277,11 @@ class SettingsViewModel @Inject constructor( val dnsProviderValue = normalizeDnsProviderValue(prefs[dnsProviderKey()]) val includeSpecials = prefs[includeSpecialsKey()] ?: false + // Keep runtime DNS in sync with the saved preference. + withContext(Dispatchers.IO) { + OkHttpProvider.setDnsProvider(OkHttpProvider.parseDnsProvider(dnsProviderValue)) + } + // Check auth statuses val authState = authRepository.authState.first() val isLoggedIn = authState is AuthState.Authenticated @@ -832,22 +843,58 @@ class SettingsViewModel @Inject constructor( withContext(Dispatchers.IO) { OkHttpProvider.setDnsProvider(OkHttpProvider.parseDnsProvider(value)) - // Warm up the new DNS provider's lazy init off the main thread - // so the first image request doesn't block - runCatching { OkHttpProvider.dns.lookup("image.tmdb.org") } } context.settingsDataStore.edit { prefs -> prefs[dnsProviderKey()] = value + // Explicit signal for app-wide reload; only written on user-initiated DNS change. + prefs[dnsReloadNonceKey()] = System.currentTimeMillis() } _uiState.value = _uiState.value.copy( dnsProvider = dnsProviderLabel(value) ) - // Replace Coil image loader with one using the new DNS - val imageLoader = withContext(Dispatchers.IO) { - OkHttpProvider.createCoilImageLoader(context) + // Warm up DNS and rebuild Coil client without blocking UI transitions. + viewModelScope.launch(Dispatchers.IO) { + runCatching { OkHttpProvider.dns.lookup("image.tmdb.org") } + val imageLoader = OkHttpProvider.createCoilImageLoader(context) + withContext(Dispatchers.Main) { + Coil.setImageLoader(imageLoader) + } + } + } + } + + fun testDnsResolution(hostname: String = "example.com") { + viewModelScope.launch { + val selectedProvider = _uiState.value.dnsProvider + val result = withContext(Dispatchers.IO) { + runCatching { OkHttpProvider.dns.lookup(hostname) } } - Coil.setImageLoader(imageLoader) + + _uiState.value = result.fold( + onSuccess = { addresses -> + if (addresses.isEmpty()) { + _uiState.value.copy( + toastMessage = "DNS test failed: no records for $hostname ($selectedProvider)", + toastType = ToastType.ERROR + ) + } else { + val preview = addresses + .take(3) + .joinToString(", ") { it.hostAddress ?: "unknown" } + _uiState.value.copy( + toastMessage = "DNS OK ($selectedProvider): $preview", + toastType = ToastType.SUCCESS + ) + } + }, + onFailure = { error -> + _uiState.value.copy( + toastMessage = "DNS failed ($selectedProvider): ${error.message ?: "Unknown error"}", + toastType = ToastType.ERROR + ) + } + ) } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 83a25efce..0ad7bea0a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,3 +20,4 @@ dependencyResolutionManagement { rootProject.name = "ARVIO" include(":app") include(":benchmark") +include(":Arvio debug")