Skip to content
Draft
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
54 changes: 48 additions & 6 deletions app/src/main/java/app/revanced/manager/ManagerApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,20 @@ 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
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
Expand All @@ -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()
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
}
}
7 changes: 7 additions & 0 deletions app/src/main/java/app/revanced/manager/di/HttpModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/app/revanced/manager/di/RepositoryModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this class works for chained fallbacks and it is also overcomplicated. You simply need one persistence for a schema:

API(url: String, fallback: String?, restore: API?)

If current API is down -> fallback
If restore != null, in an interval probe all restore APIs recursively, the earliest wins.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reworked EndpointState into the priority-chain setup you described. Endpoints are now represented as "ApiEndpoint(url, fallback)" and the behavior matches what we discussed on Discord, if i rememeber correctly.

Requests always try the active endpoint first. if that one is down we walk the chain from the top and pick the first reachable endpoint, so higher-priority endpoints always win. for example if we are currently on C and it dies while A or B are available we will move back up to A/B instead of falling further down to D.

I also added the recovery logic for backup endpoints. while we are running on a backup, every 5 minutes we check whether any higher-priority endpoints have come back. If they have, we switch to the highest priority one that's reachable. over time that means we naturally climb back up the chain (e.g C then to B tehn to A) until we're back on the primary then the recovery loop stops doing anything.

I didnt keep an explicit "restore" link on the endpoint node. A doubly-linked immutable structure (A.fallback = B and B.restore = A) is awkward to construct with vals so the node only stores the fallback direction. when we need restore candidates we derive them by walking the chain from the head. Same behavior, just simpler to build and maintain ig.

Also worth noting that /about only exposes a single fallback today, so in practice we are still running with two tiers. The code just no longer assumes that's the limit, so if we add more fallback levels later, it should work without any further changes.

the literal "restore" field can be added back if you'd rather keep the structure closer to the original proposal, since we kinda....shifted a lot during the...journey?

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<ApiEndpoint> = generateSequence(this) { it.fallback }
}

// URL of the endpoint currently in use, null means the primary (chain head).
private val _activeUrl = MutableStateFlow<String?>(null)
val activeUrl: StateFlow<String?> = _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<String> =
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<ApiEndpoint> {
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"
}
}
130 changes: 100 additions & 30 deletions app/src/main/java/app/revanced/manager/network/api/ReVancedAPI.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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 <reified T> request(api: String, apiVersion: String, route: String): APIResponse<T> =
withContext(Dispatchers.IO) {
val fullUrl = "$api/$apiVersion/$route"
try {
Log.d("API", "Requesting: $fullUrl")
suspend fun getAnnouncements(): APIResponse<List<ReVancedAnnouncement>> =
request("announcements")

client.request {
url(fullUrl)
}
suspend fun getLatestAppInfo(): APIResponse<ReVancedAsset> =
request("manager${prefs.useManagerPrereleases.prereleaseString()}")

} catch (e: Exception) {
Log.e("API", "Failed request: $fullUrl", e)
throw e
}
}
suspend fun getAppHistory(): APIResponse<List<ReVancedAssetHistory>> =
request("manager/history${prefs.useManagerPrereleases.prereleaseString()}")

private suspend inline fun <reified T> request(route: String, apiVersion: String = defaultApiVersion) = request<T>(apiUrl(), apiVersion, route)
suspend fun getPatchesUpdate(): APIResponse<ReVancedAsset> =
request("patches${prefs.usePatchesPrereleases.prereleaseString()}")

suspend fun getAnnouncements() = request<List<ReVancedAnnouncement>>("announcements")
suspend fun getPatchesHistory(
apiUrl: String,
prerelease: Boolean,
): APIResponse<List<ReVancedAssetHistory>> =
requestForSource(apiUrl, "patches/history${prerelease.prereleaseString()}")

suspend fun getLatestAppInfo() =
request<ReVancedAsset>("manager${prefs.useManagerPrereleases.prereleaseString()}")
suspend fun getDownloaderUpdate(): APIResponse<ReVancedAsset> =
request("manager/downloaders${prefs.useDownloaderPrerelease.prereleaseString()}")

suspend fun getAppHistory() = request<List<ReVancedAssetHistory>>("manager/history${prefs.useManagerPrereleases.prereleaseString()}")
suspend fun getContributors(): APIResponse<List<ReVancedGitRepository>> =
request("contributors")

suspend fun getPatchesUpdate() = request<ReVancedAsset>("patches${prefs.usePatchesPrereleases.prereleaseString()}")
suspend fun getInfo(): APIResponse<ReVancedInfo> {
val (response, servedBy) = requestTracked<ReVancedInfo>("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<List<ReVancedAssetHistory>>(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<ReVancedAsset>("manager/downloaders${prefs.useDownloaderPrerelease.prereleaseString()}")
private suspend fun probe(baseUrl: String): Boolean =
withTimeoutOrNull(PROBE_TIMEOUT_MS) {
http.request<ReVancedInfo> { url("$baseUrl/$defaultApiVersion/about") } is APIResponse.Success
} ?: false

suspend fun getContributors() = request<List<ReVancedGitRepository>>("contributors")
private suspend inline fun <reified T> request(route: String): APIResponse<T> =
requestTracked<T>(route).first

suspend fun getInfo() = request<ReVancedInfo>("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 <reified T> requestForSource(
apiUrl: String,
route: String,
): APIResponse<T> =
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 <reified T> requestTracked(
route: String,
): Pair<APIResponse<T>, String?> = withContext(Dispatchers.IO) {
val active = endpointState.activeEndpoint()
val activeResponse = directRequest<T>(active.url, route)
if (activeResponse is APIResponse.Success) {
return@withContext activeResponse to active.url
}

var lastFailure: APIResponse<T> = activeResponse
endpointState.chain().asSequence()
.filter { it.url != active.url }
.forEach { endpoint ->
val response = directRequest<T>(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 <reified T> directRequest(
baseUrl: String,
route: String,
): APIResponse<T> = 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<Boolean>.prereleaseString() = if (get()) "/prerelease" else ""
fun Boolean.prereleaseString() = if (this) "/prerelease" else ""
}
}
}
Loading