diff --git a/app/src/main/java/app/revanced/manager/ManagerApplication.kt b/app/src/main/java/app/revanced/manager/ManagerApplication.kt index 4be3d96171..e299037508 100644 --- a/app/src/main/java/app/revanced/manager/ManagerApplication.kt +++ b/app/src/main/java/app/revanced/manager/ManagerApplication.kt @@ -10,6 +10,8 @@ import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.repository.DownloadedAppRepository import app.revanced.manager.domain.repository.DownloaderRepository import app.revanced.manager.domain.repository.PatchBundleRepository +import app.revanced.manager.network.api.EndpointState +import app.revanced.manager.network.api.ReVancedAPI import app.revanced.manager.util.tag import kotlinx.coroutines.Dispatchers import coil.Coil @@ -17,7 +19,11 @@ import coil.ImageLoader import com.topjohnwu.superuser.Shell import com.topjohnwu.superuser.internal.BuilderImpl import kotlinx.coroutines.MainScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.minutes import me.zhanghai.android.appiconloader.coil.AppIconFetcher import me.zhanghai.android.appiconloader.coil.AppIconKeyer import org.koin.android.ext.android.inject @@ -34,6 +40,8 @@ class ManagerApplication : Application() { private val downloaderRepository: DownloaderRepository by inject() private val downloadedAppsRepository: DownloadedAppRepository by inject() private val fs: Filesystem by inject() + private val endpointState: EndpointState by inject() + private val reVancedAPI: ReVancedAPI by inject() override fun onCreate() { super.onCreate() @@ -78,16 +86,37 @@ class ManagerApplication : Application() { downloaderRepository.reload() } scope.launch(Dispatchers.Default) { - arrayOf(patchBundleRepository, downloaderRepository).forEach { - with(it) { - reload() - updateCheck(force = false) - } - } + runUpdateChecks() } scope.launch(Dispatchers.Default) { downloadedAppsRepository.cleanUp() } + + // Only while the session is on a backup endpoint, periodically probe the higher-priority + // endpoints and switch back to the earliest reachable one silently. Endpoint URLs are + // resolved per request, so the switch takes effect without restarting the app. + // + // collectLatest restarts this block whenever the active endpoint changes: after a partial + // restore (e.g. C -> B while the primary is still down) the active URL is still non-null, so + // probing resumes for the remaining higher-priority endpoints until the primary is reached, + // at which point activeUrl is null and the loop stays idle. + scope.launch(Dispatchers.IO) { + endpointState.activeUrl + .filterNotNull() + .collectLatest { + while (true) { + delay(PRIMARY_RECONNECT_INTERVAL) + val restored = reVancedAPI.restoreHigherPriorityEndpoint() + if (restored != null) { + Log.i(tag, "Higher-priority API endpoint recovered, switched to ${restored.url}") + // The startup update check may have run against a backup; re-run it now + // that we are on a higher-priority endpoint so results reflect it. + runUpdateChecks() + break + } + } + } + } registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { private var firstActivityCreated = false @@ -118,4 +147,17 @@ class ManagerApplication : Application() { mkdirs() } } + + private suspend fun runUpdateChecks() { + arrayOf(patchBundleRepository, downloaderRepository).forEach { + with(it) { + reload() + updateCheck(force = false) + } + } + } + + private companion object { + val PRIMARY_RECONNECT_INTERVAL = 5.minutes + } } \ No newline at end of file diff --git a/app/src/main/java/app/revanced/manager/di/HttpModule.kt b/app/src/main/java/app/revanced/manager/di/HttpModule.kt index 1d827ce633..893a2514de 100644 --- a/app/src/main/java/app/revanced/manager/di/HttpModule.kt +++ b/app/src/main/java/app/revanced/manager/di/HttpModule.kt @@ -6,6 +6,8 @@ import io.ktor.client.* import io.ktor.client.engine.okhttp.* import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.UserAgent +import io.ktor.client.plugins.cache.HttpCache +import io.ktor.client.plugins.cache.storage.FileStorage import io.ktor.client.plugins.contentnegotiation.* import io.ktor.serialization.kotlinx.json.* import kotlinx.serialization.json.Json @@ -39,6 +41,11 @@ val httpModule = module { install(ContentNegotiation) { json(json) } + install(HttpCache) { + publicStorage( + FileStorage(context.cacheDir.resolve("http_cache").apply { mkdirs() }) + ) + } install(HttpTimeout) { socketTimeoutMillis = 10000 } diff --git a/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt b/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt index 952513cf9d..ab44eacdf2 100644 --- a/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt +++ b/app/src/main/java/app/revanced/manager/di/RepositoryModule.kt @@ -4,12 +4,14 @@ import app.revanced.manager.data.platform.Filesystem import app.revanced.manager.data.platform.NetworkInfo import app.revanced.manager.domain.repository.* import app.revanced.manager.domain.worker.WorkerRepository +import app.revanced.manager.network.api.EndpointState import app.revanced.manager.network.api.ReVancedAPI import org.koin.core.module.dsl.createdAtStart import org.koin.core.module.dsl.singleOf import org.koin.dsl.module val repositoryModule = module { + singleOf(::EndpointState) singleOf(::ReVancedAPI) singleOf(::ManagerUpdateRepository) singleOf(::AnnouncementRepository) diff --git a/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt b/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt index 74ccbee16a..1bddbcd4d8 100644 --- a/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt +++ b/app/src/main/java/app/revanced/manager/domain/manager/PreferencesManager.kt @@ -2,6 +2,7 @@ package app.revanced.manager.domain.manager import android.content.Context import app.revanced.manager.domain.manager.base.BasePreferencesManager +import app.revanced.manager.network.api.EndpointState import app.revanced.manager.patcher.logger.LogLevel import app.revanced.manager.ui.theme.Theme import app.revanced.manager.util.isDebuggable @@ -13,7 +14,8 @@ class PreferencesManager( val pureBlackTheme = booleanPreference("pure_black_theme", false) val theme = enumPreference("theme", Theme.SYSTEM) - val api = stringPreference("api_url", "https://api.revanced.app") + val api = stringPreference("api_url", EndpointState.DEFAULT_PRIMARY_API_URL) + val apiFallback = stringPreference("api_fallback_url", EndpointState.DEFAULT_FALLBACK_API_URL) val useProcessRuntime = booleanPreference("use_process_runtime", false) val patcherProcessMemoryLimit = intPreference("process_runtime_memory_limit", 700) diff --git a/app/src/main/java/app/revanced/manager/network/api/EndpointState.kt b/app/src/main/java/app/revanced/manager/network/api/EndpointState.kt new file mode 100644 index 0000000000..1aa5612519 --- /dev/null +++ b/app/src/main/java/app/revanced/manager/network/api/EndpointState.kt @@ -0,0 +1,77 @@ +package app.revanced.manager.network.api + +import android.util.Log +import app.revanced.manager.domain.manager.PreferencesManager +import app.revanced.manager.util.tag +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class EndpointState( + private val prefs: PreferencesManager, +) { + + // A node in the prioritised API endpoint chain. + // [fallback] is the next lower-priority endpoint to try while this one is unreachable so the chain reads from the primary/head downward. + // Restoration to a higher-priority endpoint is derived by walking from the head (Ctrl + F("restoreCandidates")., the chain is single-direction because a doubly-linked immutable chain is not constructible. + + data class ApiEndpoint( + val url: String, + val fallback: ApiEndpoint?, + ) { + fun asSequence(): Sequence = generateSequence(this) { it.fallback } + } + + // URL of the endpoint currently in use, null means the primary (chain head). + private val _activeUrl = MutableStateFlow(null) + val activeUrl: StateFlow = _activeUrl.asStateFlow() + + suspend fun primaryUrl(): String = prefs.api.get().trimEnd('/') + + // the persisted endpoints, ordered from primary downward. The API currently advertises a single backup but the chain is built from a list so additional tiers need no structural changes. + + private suspend fun endpointUrls(): List = + listOf(primaryUrl(), prefs.apiFallback.get().trimEnd('/')).distinct() + + // The endpoint chain built from persisted configuration, head (primary) first. + suspend fun chain(): ApiEndpoint { + var node: ApiEndpoint? = null + endpointUrls().asReversed().forEach { url -> node = ApiEndpoint(url, node) } + return node!! + } + + // The endpoint currently in use or the chain head when on the primary. + suspend fun activeEndpoint(): ApiEndpoint { + val chain = chain() + val active = _activeUrl.value ?: return chain + return chain.asSequence().firstOrNull { it.url == active } ?: chain + } + + // Higher-priority endpoints to probe for restoration, ordered from the primary downward. + suspend fun restoreCandidates(): List { + val active = _activeUrl.value ?: return emptyList() + return chain().asSequence().takeWhile { it.url != active }.toList() + } + + // Records [endpoint] as the active endpoint. Resets to the primary when the head is selected. + suspend fun setActive(endpoint: ApiEndpoint) { + _activeUrl.value = endpoint.url.takeIf { it != primaryUrl() } + } + + suspend fun updateFallbackFromAbout(advertised: String?) { + val normalized = advertised?.trim()?.trimEnd('/').orEmpty() + if (normalized.isEmpty()) return + if (!normalized.startsWith("https://")) { + Log.w(tag, "EndpointState: ignoring non-HTTPS fallback URL from /about: $normalized") + return + } + if (normalized == prefs.apiFallback.get().trimEnd('/')) return + Log.i(tag, "EndpointState: updating persisted fallback endpoint to $normalized") + prefs.apiFallback.update(normalized) + } + + companion object { + const val DEFAULT_PRIMARY_API_URL = "https://api.revanced.app" + const val DEFAULT_FALLBACK_API_URL = "https://backup-api.revanced.app" + } +} diff --git a/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt b/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt index adcddcc3b8..c977125092 100644 --- a/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt +++ b/app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt @@ -1,6 +1,5 @@ package app.revanced.manager.network.api -import android.util.Log import app.revanced.manager.domain.manager.PreferencesManager import app.revanced.manager.domain.manager.base.Preference import app.revanced.manager.network.dto.ReVancedAnnouncement @@ -10,55 +9,126 @@ import app.revanced.manager.network.dto.ReVancedGitRepository import app.revanced.manager.network.dto.ReVancedInfo import app.revanced.manager.network.service.HttpService import app.revanced.manager.network.utils.APIResponse +import io.ktor.client.plugins.retry +import io.ktor.client.request.url import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext -import io.ktor.client.request.url +import kotlinx.coroutines.withTimeoutOrNull class ReVancedAPI( - private val client: HttpService, - private val prefs: PreferencesManager + private val http: HttpService, + private val prefs: PreferencesManager, + private val endpointState: EndpointState, ) { - private suspend fun apiUrl() = prefs.api.get() private val defaultApiVersion = "v5" - private suspend inline fun request(api: String, apiVersion: String, route: String): APIResponse = - withContext(Dispatchers.IO) { - val fullUrl = "$api/$apiVersion/$route" - try { - Log.d("API", "Requesting: $fullUrl") + suspend fun getAnnouncements(): APIResponse> = + request("announcements") - client.request { - url(fullUrl) - } + suspend fun getLatestAppInfo(): APIResponse = + request("manager${prefs.useManagerPrereleases.prereleaseString()}") - } catch (e: Exception) { - Log.e("API", "Failed request: $fullUrl", e) - throw e - } - } + suspend fun getAppHistory(): APIResponse> = + request("manager/history${prefs.useManagerPrereleases.prereleaseString()}") - private suspend inline fun request(route: String, apiVersion: String = defaultApiVersion) = request(apiUrl(), apiVersion, route) + suspend fun getPatchesUpdate(): APIResponse = + request("patches${prefs.usePatchesPrereleases.prereleaseString()}") - suspend fun getAnnouncements() = request>("announcements") + suspend fun getPatchesHistory( + apiUrl: String, + prerelease: Boolean, + ): APIResponse> = + requestForSource(apiUrl, "patches/history${prerelease.prereleaseString()}") - suspend fun getLatestAppInfo() = - request("manager${prefs.useManagerPrereleases.prereleaseString()}") + suspend fun getDownloaderUpdate(): APIResponse = + request("manager/downloaders${prefs.useDownloaderPrerelease.prereleaseString()}") - suspend fun getAppHistory() = request>("manager/history${prefs.useManagerPrereleases.prereleaseString()}") + suspend fun getContributors(): APIResponse> = + request("contributors") - suspend fun getPatchesUpdate() = request("patches${prefs.usePatchesPrereleases.prereleaseString()}") + suspend fun getInfo(): APIResponse { + val (response, servedBy) = requestTracked("about") + if (response is APIResponse.Success && servedBy == endpointState.primaryUrl()) { + endpointState.updateFallbackFromAbout(response.data.api?.fallback) + } + return response + } - suspend fun getPatchesHistory(apiUrl: String, prerelease: Boolean) = - request>(apiUrl, defaultApiVersion, "patches/history${prerelease.prereleaseString()}") + + // Probes the higher-priority endpoints above the active one and restores the earliest reachable one (the primary wins over any intermedieate backup). returns the restored endpoint or null if none higher than the active endpoint is currently reachable. + suspend fun restoreHigherPriorityEndpoint(): EndpointState.ApiEndpoint? = withContext(Dispatchers.IO) { + endpointState.restoreCandidates().firstOrNull { endpoint -> + probe(endpoint.url) + }?.also { endpointState.setActive(it) } + } - suspend fun getDownloaderUpdate() = request("manager/downloaders${prefs.useDownloaderPrerelease.prereleaseString()}") + private suspend fun probe(baseUrl: String): Boolean = + withTimeoutOrNull(PROBE_TIMEOUT_MS) { + http.request { url("$baseUrl/$defaultApiVersion/about") } is APIResponse.Success + } ?: false - suspend fun getContributors() = request>("contributors") + private suspend inline fun request(route: String): APIResponse = + requestTracked(route).first - suspend fun getInfo() = request("about") + /** + * Routes a request for a patch-bundle source. Only the official source (the configured + * primary endpoint) participates in the primary -> fallback logic; a user-added custom + * remote source queries its own server directly and is never redirected to the backup. + */ + private suspend inline fun requestForSource( + apiUrl: String, + route: String, + ): APIResponse = + if (apiUrl == endpointState.primaryUrl()) { + request(route) + } else { + directRequest(apiUrl, route) + } + + // Issue a request against the active endpoint and returns the first success with the URL that served it. + // If the active endpoint is unreachable, the whole chain is walked from the primary downward so a higher-priority endpoint is preferred over a lower-priority one: i.e recovery up the chain is attempted before falling further down. The serving endpoint becomes active. + private suspend inline fun requestTracked( + route: String, + ): Pair, String?> = withContext(Dispatchers.IO) { + val active = endpointState.activeEndpoint() + val activeResponse = directRequest(active.url, route) + if (activeResponse is APIResponse.Success) { + return@withContext activeResponse to active.url + } + + var lastFailure: APIResponse = activeResponse + endpointState.chain().asSequence() + .filter { it.url != active.url } + .forEach { endpoint -> + val response = directRequest(endpoint.url, route) + if (response is APIResponse.Success) { + endpointState.setActive(endpoint) + return@withContext response to endpoint.url + } + lastFailure = response + } + lastFailure to null + } + + private suspend inline fun directRequest( + baseUrl: String, + route: String, + ): APIResponse = http.request { + url("$baseUrl/$defaultApiVersion/$route") + retry { + maxRetries = MAX_RETRIES + retryOnServerErrors() + retryOnException(retryOnTimeout = true) + exponentialDelay(base = 2.0, baseDelayMs = BACKOFF_BASE_MS) + } + } private companion object { + const val MAX_RETRIES = 3 + const val BACKOFF_BASE_MS = 250L + const val PROBE_TIMEOUT_MS = 5_000L + suspend fun Preference.prereleaseString() = if (get()) "/prerelease" else "" fun Boolean.prereleaseString() = if (this) "/prerelease" else "" } -} \ No newline at end of file +} diff --git a/app/src/main/java/app/revanced/manager/network/dto/ReVancedInfo.kt b/app/src/main/java/app/revanced/manager/network/dto/ReVancedInfo.kt index 8e72547b97..9e1f64579f 100644 --- a/app/src/main/java/app/revanced/manager/network/dto/ReVancedInfo.kt +++ b/app/src/main/java/app/revanced/manager/network/dto/ReVancedInfo.kt @@ -13,6 +13,12 @@ data class ReVancedInfo( val contact: ReVancedContact, val socials: List, val donations: ReVancedDonation, + val api: ReVancedApiInfo? = null, +) + +@Serializable +data class ReVancedApiInfo( + val fallback: String? = null, ) @Serializable diff --git a/app/src/main/java/app/revanced/manager/network/utils/APIResponse.kt b/app/src/main/java/app/revanced/manager/network/utils/APIResponse.kt index 04765f888f..e206b2b1c2 100644 --- a/app/src/main/java/app/revanced/manager/network/utils/APIResponse.kt +++ b/app/src/main/java/app/revanced/manager/network/utils/APIResponse.kt @@ -14,7 +14,7 @@ sealed interface APIResponse { data class Failure(val error: APIFailure) : APIResponse } -class APIError(code: HttpStatusCode, body: String?) : Exception("HTTP Code $code, Body: $body") +class APIError(val code: HttpStatusCode, body: String?) : Exception("HTTP Code $code, Body: $body") class APIFailure(error: Throwable, body: String?) : Exception(body ?: error.message, error)