Skip to content
Merged
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
129 changes: 64 additions & 65 deletions app/src/main/java/com/gothwad/store/data/remote/FdroidCatalog.kt
Original file line number Diff line number Diff line change
@@ -1,87 +1,64 @@
package com.gothwad.store.data.remote

import android.os.Build
import com.gothwad.store.BuildConfig
import com.gothwad.store.data.model.AppDetailResponse
import com.gothwad.store.data.model.CatalogResponse
import com.gothwad.store.data.model.StoreApp
import com.gothwad.store.data.model.StoreRelease
import com.squareup.moshi.JsonReader
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.util.concurrent.TimeUnit

/** Read-only client for F-Droid's public repository index. APK bytes stay on F-Droid. */
/**
* Read-only client for F-Droid's public repository index. APK bytes stay on
* F-Droid; only the metadata is mirrored into the app.
*
* The index is roughly 8 MB, so it is streamed through Moshi instead of being
* materialised as one giant string — otherwise the app runs out of heap on
* mid-range devices and the catalogue silently disappears.
*/
class FdroidCatalog(
private val http: OkHttpClient = OkHttpClient.Builder()
.connectTimeout(20, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.callTimeout(120, TimeUnit.SECONDS)
.build(),
) {
@Volatile private var cached: CatalogResponse? = null
@Volatile private var cachedAt: Long = 0

/** One download at a time: home, apps and games all ask for the index. */
private val mutex = Mutex()

private val _lastError = MutableStateFlow<String?>(null)

/**
* The last failure, so the UI can say "F-Droid is unavailable" instead of
* pretending the store only ever had first-party listings.
*/
val lastError: StateFlow<String?> = _lastError.asStateFlow()

suspend fun catalog(): CatalogResponse = withContext(Dispatchers.IO) {
cached?.takeIf { System.currentTimeMillis() - cachedAt < CACHE_MILLIS }?.let { return@withContext it }
val request = Request.Builder().url(INDEX_URL).header("Accept", "application/json").build()
http.newCall(request).execute().use { response ->
check(response.isSuccessful) { "F-Droid repository returned HTTP ${response.code}" }
val root = JSONObject(response.body?.string() ?: error("F-Droid repository returned an empty index."))
val apps = root.optJSONObject("apps") ?: JSONObject()
val packages = root.optJSONObject("packages") ?: JSONObject()
val listings = mutableListOf<StoreApp>()
val releases = mutableListOf<StoreRelease>()
val keys = apps.keys()
while (keys.hasNext()) {
val packageName = keys.next()
val meta = apps.optJSONObject(packageName) ?: continue
val versions = packages.optJSONArray(packageName) ?: continue
val compatible = (0 until versions.length()).mapNotNull { versions.optJSONObject(it) }
.filter { (it.optInt("minSdkVersion", 1)) <= android.os.Build.VERSION.SDK_INT }
.maxByOrNull { it.optLong("versionCode", 0L) } ?: continue
val apkName = compatible.optString("apkName").takeIf { it.isNotBlank() } ?: continue
val versionCode = compatible.optLong("versionCode", 0L)
val name = meta.optString("name").ifBlank { packageName.substringAfterLast('.') }
val summary = meta.optString("summary").ifBlank { meta.optString("description") }
val id = "fdroid:$packageName"
val app = StoreApp(
id = id,
packageName = packageName,
name = name,
developerName = meta.optString("authorName").ifBlank { meta.optString("authorEmail").ifBlank { "Open-source developer" } },
category = meta.optJSONArray("categories")?.optString(0).orEmpty(),
kind = "app",
status = "published",
shortDesc = summary,
fullDesc = meta.optString("description").ifBlank { summary },
iconUrl = meta.optString("icon").takeIf { it.isNotBlank() }?.let { "$REPO_URL/icons-640/$it" }.orEmpty(),
website = meta.optString("webSite").ifBlank { null },
source = "F-Droid",
)
val release = StoreRelease(
id = id,
appId = id,
versionCode = versionCode,
versionName = compatible.optString("versionName").ifBlank { versionCode.toString() },
apkProvider = "fdroid",
apkKey = apkName,
apkUrl = "$REPO_URL/$apkName",
apkSizeBytes = compatible.optLong("size", 0L),
apkSha256 = compatible.optString("hash").takeIf {
it.isNotBlank() && compatible.optString("hashType", "sha256").equals("sha256", ignoreCase = true)
},
minSdk = compatible.optInt("minSdkVersion", 1),
targetSdk = compatible.optInt("targetSdkVersion", 0).takeIf { it > 0 },
reviewStatus = "approved",
isLive = true,
)
listings += app
releases += release
}
CatalogResponse(apps = listings, releases = releases, source = "F-Droid").also {
cached = it
cachedAt = System.currentTimeMillis()
}
mutex.withLock {
// Another caller may have finished the download while we waited.
cached?.takeIf { System.currentTimeMillis() - cachedAt < CACHE_MILLIS }
?: try {
download()
} catch (cancellation: CancellationException) {
throw cancellation
} catch (error: Exception) {
_lastError.value = error.message ?: "F-Droid is unavailable right now."
throw error
}
}
}

Expand All @@ -94,9 +71,31 @@ class FdroidCatalog(
)
}

private fun download(): CatalogResponse {
val request = Request.Builder()
.url(INDEX_URL)
.header("Accept", "application/json")
.header("User-Agent", "KiwiStore-Android/${BuildConfig.VERSION_NAME}")
.build()
http.newCall(request).execute().use { response ->
if (!response.isSuccessful) error("F-Droid repository returned HTTP ${response.code}")
val body = response.body ?: error("F-Droid repository returned an empty index.")
val index = FdroidIndexParser.parse(JsonReader.of(body.source()), Build.VERSION.SDK_INT)
val catalog = index.toCatalog(REPO_URL)
check(catalog.apps.isNotEmpty()) {
"F-Droid index was read, but it has no APK compatible with Android ${Build.VERSION.SDK_INT}."
}
return catalog.also { parsed ->
cached = parsed
cachedAt = System.currentTimeMillis()
_lastError.value = null
}
}
}

companion object {
private const val INDEX_URL = "https://f-droid.org/repo/index-v1.json"
private const val REPO_URL = "https://f-droid.org/repo"
const val INDEX_URL = "https://f-droid.org/repo/index-v1.json"
const val REPO_URL = "https://f-droid.org/repo"
private const val CACHE_MILLIS = 6 * 60 * 60 * 1000L
}
}
Loading
Loading