Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")

Expand Down
20 changes: 20 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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)
Expand Down
20 changes: 20 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<AuthRepository>

Expand Down Expand Up @@ -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<Long?>(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
Expand Down
28 changes: 25 additions & 3 deletions app/src/main/kotlin/com/arflix/tv/network/OkHttpProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
}

Expand Down
79 changes: 79 additions & 0 deletions app/src/main/kotlin/com/arflix/tv/ui/screens/home/HomeViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -640,6 +718,7 @@ class HomeViewModel @Inject constructor(
}

init {
observeDnsProviderChanges()
// Load trailer auto-play setting
viewModelScope.launch {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,7 @@ fun SettingsScreen(
11 -> openUiModeWarningDialog()
12 -> viewModel.setSkipProfileSelection(!uiState.skipProfileSelection)
13 -> openDnsProviderPicker()

}
}
1 -> { // IPTV
Expand Down Expand Up @@ -2149,7 +2150,7 @@ private fun GeneralSettings(
trailerAutoPlay: Boolean = false,
onSubtitleSizeClick: () -> Unit = {},
onSubtitleColorClick: () -> Unit = {},
onTrailerAutoPlayToggle: (Boolean) -> Unit = {}
onTrailerAutoPlayToggle: (Boolean) -> Unit = {},
) {
Column {
// ── Language & Subtitles ──
Expand Down Expand Up @@ -2313,6 +2314,8 @@ private fun GeneralSettings(
isFocused = focusedIndex == 13,
onClick = onDnsProviderClick
)


}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<SettingsUiState> = _uiState.asStateFlow()

Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
)
}
)
}
}

Expand Down
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,4 @@ dependencyResolutionManagement {
rootProject.name = "ARVIO"
include(":app")
include(":benchmark")
include(":Arvio debug")
Loading