diff --git a/app/src/main/java/com/gothwad/store/data/remote/FdroidCatalog.kt b/app/src/main/java/com/gothwad/store/data/remote/FdroidCatalog.kt index a38b459..58f8e42 100644 --- a/app/src/main/java/com/gothwad/store/data/remote/FdroidCatalog.kt +++ b/app/src/main/java/com/gothwad/store/data/remote/FdroidCatalog.kt @@ -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(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 = _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() - val releases = mutableListOf() - 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 + } } } @@ -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 } } diff --git a/app/src/main/java/com/gothwad/store/data/remote/FdroidIndex.kt b/app/src/main/java/com/gothwad/store/data/remote/FdroidIndex.kt new file mode 100644 index 0000000..34c1904 --- /dev/null +++ b/app/src/main/java/com/gothwad/store/data/remote/FdroidIndex.kt @@ -0,0 +1,489 @@ +package com.gothwad.store.data.remote + +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 + +/** + * The small subset of F-Droid's v1 index that Kiwi Store retains. + * + * `apps` in index-v1.json is an array, while `packages` is an object keyed by + * package name. The parser below streams both sections and keeps only the + * newest APK compatible with this device, rather than building a second huge + * copy of the several-megabyte index in memory. + */ +data class FdroidIndex( + val apps: List, + val packages: Map, +) + +data class FdroidApp( + val packageName: String, + val name: String? = null, + val summary: String? = null, + val description: String? = null, + val icon: String? = null, + val webSite: String? = null, + val authorName: String? = null, + val authorEmail: String? = null, + val categories: List = emptyList(), + /** English localization, or the best available fallback localization. */ + val localized: FdroidLocalized? = null, +) + +data class FdroidLocalized( + val name: String? = null, + val summary: String? = null, + val description: String? = null, + val icon: String? = null, +) + +data class FdroidApk( + val apkName: String? = null, + val versionCode: Long? = null, + val versionName: String? = null, + val size: Long? = null, + val hash: String? = null, + val hashType: String? = null, + val minSdkVersion: Int? = null, + val maxSdkVersion: Int? = null, + val targetSdkVersion: Int? = null, +) + +/** Streaming parser for the official F-Droid index-v1 wire format. */ +object FdroidIndexParser { + + /** Reads the large index without materialising its full JSON tree. */ + fun parse(reader: JsonReader, sdkInt: Int): FdroidIndex { + val apps = LinkedHashMap() + val compatibleApks = LinkedHashMap() + + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + "apps" -> readApps(reader, apps) + "packages" -> readPackages(reader, sdkInt, compatibleApks) + else -> reader.skipValue() + } + } + reader.endObject() + + check(apps.isNotEmpty()) { + "F-Droid index did not contain any app metadata (expected an 'apps' array)." + } + return FdroidIndex(apps.values.toList(), compatibleApks) + } + + private fun readApps(reader: JsonReader, apps: MutableMap) { + check(reader.peek() == JsonReader.Token.BEGIN_ARRAY) { + "F-Droid index 'apps' field must be a JSON array." + } + reader.beginArray() + while (reader.hasNext()) { + readApp(reader)?.let { app -> apps[app.packageName] = app } + } + reader.endArray() + } + + private fun readApp(reader: JsonReader): FdroidApp? { + if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { + reader.skipValue() + return null + } + + var packageName: String? = null + var name: String? = null + var summary: String? = null + var description: String? = null + var icon: String? = null + var website: String? = null + var authorName: String? = null + var authorEmail: String? = null + var categories: List = emptyList() + var localized: FdroidLocalized? = null + + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + "packageName" -> packageName = reader.readStringValue() + "name" -> name = reader.readStringValue() + "summary" -> summary = reader.readStringValue() + "description" -> description = reader.readStringValue() + "icon" -> icon = reader.readStringValue() + "webSite" -> website = reader.readStringValue() + "authorName" -> authorName = reader.readStringValue() + "authorEmail" -> authorEmail = reader.readStringValue() + "categories" -> categories = readStringArray(reader) + "localized" -> localized = readLocalized(reader) + else -> reader.skipValue() + } + } + reader.endObject() + + val pkg = packageName?.trim().orEmpty() + if (pkg.isEmpty()) return null + return FdroidApp( + packageName = pkg, + name = name, + summary = summary, + description = description, + icon = icon, + webSite = website, + authorName = authorName, + authorEmail = authorEmail, + categories = categories, + localized = localized, + ) + } + + private fun readLocalized(reader: JsonReader): FdroidLocalized? { + if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { + reader.skipValue() + return null + } + + var english: FdroidLocalized? = null + var fallback: FdroidLocalized? = null + reader.beginObject() + while (reader.hasNext()) { + val locale = reader.nextName() + val value = readLocalizedEntry(reader) ?: continue + if (locale.equals("en-US", ignoreCase = true)) { + english = value + } else if (fallback == null && value.hasMetadata()) { + fallback = value + } + } + reader.endObject() + + // Prefer each English field, falling back to another translation when + // the English localization is incomplete. + return mergeLocalized(english, fallback) + } + + private fun readLocalizedEntry(reader: JsonReader): FdroidLocalized? { + if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { + reader.skipValue() + return null + } + var name: String? = null + var summary: String? = null + var description: String? = null + var icon: String? = null + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + "name" -> name = reader.readStringValue() + "summary" -> summary = reader.readStringValue() + "description" -> description = reader.readStringValue() + "icon" -> icon = reader.readStringValue() + else -> reader.skipValue() + } + } + reader.endObject() + return FdroidLocalized(name, summary, description, icon) + } + + private fun FdroidLocalized.hasMetadata(): Boolean = + !name.isNullOrBlank() || !summary.isNullOrBlank() || + !description.isNullOrBlank() || !icon.isNullOrBlank() + + private fun mergeLocalized( + preferred: FdroidLocalized?, + fallback: FdroidLocalized?, + ): FdroidLocalized? { + if (preferred == null) return fallback + if (fallback == null) return preferred + fun choose(first: String?, second: String?): String? = first?.takeIf { it.isNotBlank() } ?: second + return FdroidLocalized( + name = choose(preferred.name, fallback.name), + summary = choose(preferred.summary, fallback.summary), + description = choose(preferred.description, fallback.description), + icon = choose(preferred.icon, fallback.icon), + ) + } + + private fun readStringArray(reader: JsonReader): List { + if (reader.peek() != JsonReader.Token.BEGIN_ARRAY) { + reader.skipValue() + return emptyList() + } + val values = ArrayList() + reader.beginArray() + while (reader.hasNext()) { + reader.readStringValue()?.takeIf { it.isNotBlank() }?.let { values.add(it) } + } + reader.endArray() + return values + } + + private fun readPackages( + reader: JsonReader, + sdkInt: Int, + compatibleApks: MutableMap, + ) { + if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { + reader.skipValue() + return + } + reader.beginObject() + while (reader.hasNext()) { + val packageName = reader.nextName() + readPackageVersions(reader, sdkInt)?.let { compatibleApks[packageName] = it } + } + reader.endObject() + } + + private fun readPackageVersions(reader: JsonReader, sdkInt: Int): FdroidApk? { + if (reader.peek() != JsonReader.Token.BEGIN_ARRAY) { + reader.skipValue() + return null + } + var best: FdroidApk? = null + reader.beginArray() + while (reader.hasNext()) { + val apk = readApk(reader) ?: continue + val minSdk = apk.minSdkVersion ?: 1 + val maxSdk = apk.maxSdkVersion + if (minSdk > sdkInt || (maxSdk != null && maxSdk < sdkInt) || apk.apkName.isNullOrBlank()) continue + if (best == null || (apk.versionCode ?: 0L) > (best.versionCode ?: 0L)) best = apk + } + reader.endArray() + return best + } + + private fun readApk(reader: JsonReader): FdroidApk? { + if (reader.peek() != JsonReader.Token.BEGIN_OBJECT) { + reader.skipValue() + return null + } + var apkName: String? = null + var versionCode: Long? = null + var versionName: String? = null + var size: Long? = null + var hash: String? = null + var hashType: String? = null + var minSdkVersion: Int? = null + var maxSdkVersion: Int? = null + var targetSdkVersion: Int? = null + reader.beginObject() + while (reader.hasNext()) { + when (reader.nextName()) { + "apkName" -> apkName = reader.readStringValue() + "versionCode" -> versionCode = reader.readLongValue() + "versionName" -> versionName = reader.readStringValue() + "size" -> size = reader.readLongValue() + "hash" -> hash = reader.readStringValue() + "hashType" -> hashType = reader.readStringValue() + "minSdkVersion" -> minSdkVersion = reader.readLongValue()?.toIntOrNull() + "maxSdkVersion" -> maxSdkVersion = reader.readLongValue()?.toIntOrNull() + "targetSdkVersion" -> targetSdkVersion = reader.readLongValue()?.toIntOrNull() + else -> reader.skipValue() + } + } + reader.endObject() + return FdroidApk( + apkName = apkName, + versionCode = versionCode, + versionName = versionName, + size = size, + hash = hash, + hashType = hashType, + minSdkVersion = minSdkVersion, + maxSdkVersion = maxSdkVersion, + targetSdkVersion = targetSdkVersion, + ) + } + + private fun JsonReader.readStringValue(): String? = when (peek()) { + JsonReader.Token.NULL -> { + nextNull() + null + } + JsonReader.Token.STRING, JsonReader.Token.NUMBER -> nextString() + else -> { + skipValue() + null + } + } + + private fun JsonReader.readLongValue(): Long? = when (peek()) { + JsonReader.Token.NULL -> { + nextNull() + null + } + JsonReader.Token.NUMBER -> nextLong() + JsonReader.Token.STRING -> nextString().toLongOrNull() + else -> { + skipValue() + null + } + } + + private fun Long.toIntOrNull(): Int? = + takeIf { it in Int.MIN_VALUE.toLong()..Int.MAX_VALUE.toLong() }?.toInt() +} + +/** + * F-Droid category names mapped onto Kiwi Store category slugs. The upstream + * and first-party catalogues use different vocabularies, so category filters + * must be normalized before apps can show up on category screens. + */ +object FdroidCategories { + + private val slugs = mapOf( + "internet" to "browser", + "web" to "browser", + "connectivity" to "tools", + "phone & sms" to "communication", + "contacts" to "communication", + "instant messaging" to "communication", + "messaging" to "communication", + "email" to "communication", + "system" to "tools", + "development" to "tools", + "programming & development" to "tools", + "file managers" to "tools", + "files" to "tools", + "theming" to "tools", + "privacy" to "tools", + "security" to "tools", + "navigation & maps" to "tools", + "weather" to "tools", + "remote" to "tools", + "tethering" to "tools", + "status bar" to "tools", + "quick settings" to "tools", + "screen lock" to "tools", + "medical" to "tools", + "writing & notes" to "productivity", + "text & notes" to "productivity", + "productivity" to "productivity", + "time & calendar" to "productivity", + "calendar & agenda" to "productivity", + "note" to "productivity", + "task" to "productivity", + "science & education" to "education", + "education" to "education", + "book" to "education", + "reference" to "education", + "kids" to "education", + "multimedia" to "music", + "music & audio" to "music", + "audio" to "music", + "video" to "entertainment", + "photography" to "photo", + "images" to "photo", + "wallpapers" to "photo", + "games" to "entertainment", + "comics" to "entertainment", + "manga" to "entertainment", + "anime" to "entertainment", + "news & magazines" to "entertainment", + "readers & news" to "entertainment", + "sports & health" to "entertainment", + "money" to "finance", + "finance" to "finance", + ) + + /** Used when F-Droid lists a category Kiwi Store does not know. */ + const val DEFAULT_SLUG = "tools" + + /** The Kiwi Store slug for one F-Droid category, or null when unknown. */ + fun slug(category: String?): String? { + val key = category?.trim()?.lowercase().orEmpty() + if (key.isEmpty()) return null + slugs[key]?.let { return it } + // F-Droid splits games into many categories ("Party Game", "Arcade + // Game", …), while Kiwi Store keeps them under one slug. + if (key.contains("game")) return "entertainment" + return null + } + + fun slugFor(categories: List): String = + categories.firstNotNullOfOrNull { slug(it) } ?: DEFAULT_SLUG + + /** F-Droid keeps games in a category, not in a kind. */ + fun isGame(categories: List): Boolean = + categories.any { it.trim().contains("game", ignoreCase = true) } +} + +/** Converts the streamed index into the store's models. */ +fun FdroidIndex.toCatalog(repoUrl: String): CatalogResponse { + val releasesByPackage = packages + val listings = ArrayList(apps.size) + val releases = ArrayList(apps.size) + + for (meta in apps) { + val packageName = meta.packageName + val apk = releasesByPackage[packageName] ?: continue + val apkName = apk.apkName?.trim().orEmpty() + if (apkName.isBlank()) continue + + val versionCode = apk.versionCode ?: 0L + val localized = meta.localized + val name = localized?.name?.trim().orEmpty() + .ifBlank { meta.name?.trim().orEmpty() } + .ifBlank { packageName.substringAfterLast('.') } + val summary = localized?.summary?.trim().orEmpty() + .ifBlank { meta.summary?.trim().orEmpty() } + .ifBlank { localized?.description?.trim().orEmpty() } + .ifBlank { meta.description?.trim().orEmpty() } + val description = localized?.description?.trim().orEmpty() + .ifBlank { meta.description?.trim().orEmpty() } + .ifBlank { summary } + + val categories = meta.categories.mapNotNull { category -> + category.trim().takeIf { it.isNotEmpty() } + } + val id = "fdroid:$packageName" + + listings += StoreApp( + id = id, + packageName = packageName, + name = name, + developerName = meta.authorName?.trim().orEmpty() + .ifBlank { meta.authorEmail?.trim().orEmpty() } + .ifBlank { "Open-source developer" }, + category = FdroidCategories.slugFor(categories), + kind = if (FdroidCategories.isGame(categories)) "game" else "app", + status = "published", + shortDesc = summary, + fullDesc = description, + iconUrl = fdroidIconUrl(meta, packageName, repoUrl), + website = meta.webSite?.trim()?.takeIf { it.isNotBlank() }, + source = "F-Droid", + ) + + releases += StoreRelease( + id = id, + appId = id, + versionCode = versionCode, + versionName = apk.versionName?.trim().orEmpty().ifBlank { versionCode.toString() }, + apkProvider = "fdroid", + apkKey = apkName, + apkUrl = "$repoUrl/$apkName", + apkSizeBytes = apk.size ?: 0L, + apkSha256 = apk.hash?.trim().orEmpty().takeIf { + it.isNotBlank() && apk.hashType.orEmpty().ifBlank { "sha256" }.equals("sha256", ignoreCase = true) + }, + minSdk = apk.minSdkVersion ?: 1, + targetSdk = apk.targetSdkVersion?.takeIf { it > 0 }, + reviewStatus = "approved", + isLive = true, + ) + } + + return CatalogResponse(apps = listings, releases = releases, source = "F-Droid") +} + +/** + * The index's app icon is the versioned image in icons-640/. The localized + * icon is a fallback hosted under the package's locale directory. + */ +private fun fdroidIconUrl(meta: FdroidApp, packageName: String, repoUrl: String): String { + meta.icon?.trim()?.takeIf { it.isNotBlank() }?.let { return "$repoUrl/icons-640/$it" } + meta.localized?.icon?.trim()?.takeIf { it.isNotBlank() }?.let { return "$repoUrl/$packageName/en-US/$it" } + return "" +} diff --git a/app/src/main/java/com/gothwad/store/data/remote/StoreRepository.kt b/app/src/main/java/com/gothwad/store/data/remote/StoreRepository.kt index 19559fb..3425b0a 100644 --- a/app/src/main/java/com/gothwad/store/data/remote/StoreRepository.kt +++ b/app/src/main/java/com/gothwad/store/data/remote/StoreRepository.kt @@ -1,5 +1,6 @@ package com.gothwad.store.data.remote +import android.util.Log import com.gothwad.store.data.model.ApiErrorBody import com.gothwad.store.data.model.CatalogResponse import com.gothwad.store.data.model.AppDetailResponse @@ -13,6 +14,7 @@ import com.gothwad.store.data.model.StoreRelease import com.gothwad.store.data.model.StoreReview import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.withContext import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody @@ -33,25 +35,61 @@ class StoreRepository( private val fdroid: FdroidCatalog = FdroidCatalog(), ) { - suspend fun catalog(kind: String?, category: String?, query: String): CatalogResponse { + /** + * Why the optional F-Droid catalogue is missing, if it is. Null means the + * catalogue loaded fine. + */ + val fdroidError: StateFlow = fdroid.lastError + + /** + * The Kiwi Store catalogue only. + * + * Split out from [catalog] so the UI can show first-party listings right + * away and add the F-Droid catalogue once its (large) index has been + * downloaded — waiting for ~8 MB before rendering anything leaves the + * user staring at a spinner. + */ + suspend fun firstPartyCatalog(kind: String?, category: String?, query: String): CatalogResponse { val trimmed = query.trim() val response = call { if (trimmed.isNotEmpty()) api.search(trimmed, kind) else api.apps(kind, category) } assertConfigured(response.source) - return combine(response.published(), trimmed, kind, category) + return response.published() } - suspend fun charts(kind: String?): CatalogResponse { - val response = call { api.charts(kind) } + /** The Kiwi Store home bundle, without the optional upstream catalogue. */ + suspend fun firstPartyHome(kind: String?): CatalogResponse { + val response = call { api.home(kind) } assertConfigured(response.source) return response.published() } + /** Adds the optional F-Droid catalogue to an already loaded response. */ + suspend fun withFdroid( + base: CatalogResponse, + kind: String?, + category: String?, + query: String = "", + ): CatalogResponse = combine(base, query.trim(), kind, category) + + /** The optional F-Droid catalogue on its own. */ + suspend fun fdroidCatalog(): CatalogResponse = fdroid.catalog() + + suspend fun catalog(kind: String?, category: String?, query: String): CatalogResponse { + val base = firstPartyCatalog(kind, category, query) + return combine(base, query.trim(), kind, category) + } + suspend fun home(kind: String?): CatalogResponse { - val response = call { api.home(kind) } + val base = firstPartyHome(kind) + return combine(base, "", kind, null) + } + + suspend fun charts(kind: String?): CatalogResponse { + val response = call { api.charts(kind) } assertConfigured(response.source) - return combine(response.published(), "", null, null) + return response.published() } /** @@ -75,13 +113,13 @@ class StoreRepository( category: String?, ): CatalogResponse { // The first-party catalogue remains usable if the optional upstream is unavailable. - val external = runCatching { fdroid.catalog() }.getOrNull() ?: return gothwad + val external = loadFdroid() ?: return gothwad val ownedPackages = gothwad.apps.mapTo(mutableSetOf()) { it.packageName } val q = query.trim().lowercase() val candidates = external.apps.filter { app -> app.packageName !in ownedPackages && - (kind.isNullOrBlank() || kind == "app" || kind == "both") && - (category.isNullOrBlank() || app.category.equals(category, ignoreCase = true)) && + kindMatches(app, kind) && + categoryMatches(app, category) && (q.isEmpty() || app.name.contains(q, true) || app.packageName.contains(q, true) || app.shortDesc.contains(q, true) || app.developerName.contains(q, true)) } @@ -94,6 +132,36 @@ class StoreRepository( ) } + /** F-Droid keeps games in a category and everything else in "app". */ + private fun kindMatches(app: StoreApp, kind: String?): Boolean = + when (kind?.trim()?.lowercase()) { + null, "", "both" -> true + "app" -> app.kind == "app" + "game" -> app.kind == "game" + else -> true + } + + /** Both catalogues use Kiwi Store slugs, so a plain comparison is enough. */ + private fun categoryMatches(app: StoreApp, category: String?): Boolean { + val wanted = category?.trim().orEmpty() + if (wanted.isEmpty()) return true + return app.category.equals(wanted, ignoreCase = true) + } + + /** + * F-Droid is optional: a failure must never break the Kiwi catalogue, but + * it must not be silent either — the reason stays visible on the + * diagnostics screen instead of vanishing. + */ + private suspend fun loadFdroid(): CatalogResponse? = try { + fdroid.catalog() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + Log.w(TAG, "F-Droid catalogue unavailable: ${error.message}", error) + null + } + private fun CatalogResponse.published(): CatalogResponse = copy( apps = apps.filter { it.status == "published" }.distinctBy { it.id }, ) @@ -199,6 +267,10 @@ class StoreRepository( else -> StoreException(serverMessage ?: "The Store request failed ($code).", StoreException.Kind.SERVER, code) } } + + private companion object { + const val TAG = "StoreRepository" + } } /** Small typed request bodies; Moshi's reflection adapter serialises them. */ diff --git a/app/src/main/java/com/gothwad/store/ui/KiwiApp.kt b/app/src/main/java/com/gothwad/store/ui/KiwiApp.kt index 8902ad4..2fe886d 100644 --- a/app/src/main/java/com/gothwad/store/ui/KiwiApp.kt +++ b/app/src/main/java/com/gothwad/store/ui/KiwiApp.kt @@ -54,6 +54,9 @@ fun KiwiApp() { // init block; asking again here would only duplicate the traffic. vm.refreshApps() vm.refreshGames() + // The optional F-Droid catalogue is downloaded in the background and + // shows up as its own shelf once it arrives. + vm.loadFdroid() } val startDestination = when { diff --git a/app/src/main/java/com/gothwad/store/ui/StoreViewModel.kt b/app/src/main/java/com/gothwad/store/ui/StoreViewModel.kt index 28e68a2..3ac0bfd 100644 --- a/app/src/main/java/com/gothwad/store/ui/StoreViewModel.kt +++ b/app/src/main/java/com/gothwad/store/ui/StoreViewModel.kt @@ -16,6 +16,7 @@ import com.gothwad.store.data.model.StoreApp import com.gothwad.store.data.model.StoreCategory import com.gothwad.store.data.model.StoreRelease import com.gothwad.store.data.remote.StoreException +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow @@ -90,6 +91,16 @@ class StoreViewModel : ViewModel() { private val _knownApps = MutableStateFlow>(emptyMap()) val knownApps: StateFlow> = _knownApps.asStateFlow() + /** + * The optional F-Droid catalogue, kept apart from the first-party + * listings so Home can show it as its own shelf. + */ + private val _fdroid = MutableStateFlow(CatalogBundle()) + val fdroid: StateFlow = _fdroid.asStateFlow() + + /** Why the F-Droid catalogue is missing, if it is. */ + val fdroidError: StateFlow = repository.fdroidError + val downloads: StateFlow> = downloader.tasks val installPhases: StateFlow> = installer.phases @@ -100,6 +111,7 @@ class StoreViewModel : ViewModel() { private var detailJob: Job? = null private var searchJob: Job? = null private var categoryJob: Job? = null + private var fdroidJob: Job? = null init { refreshHome() @@ -111,7 +123,7 @@ class StoreViewModel : ViewModel() { homeJob?.cancel() homeJob = viewModelScope.launch { if (showLoading) _home.value = UiState.Loading - _home.value = load { repository.home(null) } + _home.value = load { repository.firstPartyHome(null) } } } @@ -119,7 +131,11 @@ class StoreViewModel : ViewModel() { appsJob?.cancel() appsJob = viewModelScope.launch { _apps.value = UiState.Loading - _apps.value = load { repository.catalog(kind, category, "") } + _apps.value = loadWithUpstream( + firstParty = { repository.firstPartyCatalog(kind, category, "") }, + merged = { repository.withFdroid(it, kind, category) }, + publish = { _apps.value = it }, + ) } } @@ -127,7 +143,31 @@ class StoreViewModel : ViewModel() { gamesJob?.cancel() gamesJob = viewModelScope.launch { _games.value = UiState.Loading - _games.value = load { repository.catalog("game", null, "") } + _games.value = loadWithUpstream( + firstParty = { repository.firstPartyCatalog("game", null, "") }, + merged = { repository.withFdroid(it, "game", null) }, + publish = { _games.value = it }, + ) + } + } + + /** + * Loads the optional F-Droid catalogue once. It is a separate shelf on + * Home and an extra source in Apps, Search and the category pages. + */ + fun loadFdroid() { + if (fdroidJob?.isActive == true) return + fdroidJob = viewModelScope.launch { + val response = try { + repository.fdroidCatalog() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (_: Exception) { + return@launch + } + val index = repository.releaseIndex(response) + if (index.isNotEmpty()) _releases.value = _releases.value + index + _fdroid.value = CatalogBundle(response.apps, index) } } @@ -135,7 +175,11 @@ class StoreViewModel : ViewModel() { chartsJob?.cancel() chartsJob = viewModelScope.launch { _charts.value = UiState.Loading - _charts.value = load { repository.charts(kind) } + _charts.value = loadWithUpstream( + firstParty = { repository.charts(kind) }, + merged = { repository.withFdroid(it, kind, null) }, + publish = { _charts.value = it }, + ) } } @@ -154,9 +198,14 @@ class StoreViewModel : ViewModel() { fun loadCategory(slug: String, kind: String?) { categoryJob?.cancel() + val filter = kind?.takeIf { it != "both" } categoryJob = viewModelScope.launch { _categoryApps.value = UiState.Loading - _categoryApps.value = load { repository.catalog(kind?.takeIf { it != "both" }, slug, "") } + _categoryApps.value = loadWithUpstream( + firstParty = { repository.firstPartyCatalog(filter, slug, "") }, + merged = { repository.withFdroid(it, filter, slug) }, + publish = { _categoryApps.value = it }, + ) } } @@ -170,7 +219,11 @@ class StoreViewModel : ViewModel() { searchJob = viewModelScope.launch { delay(debounceMs) _searchResults.value = UiState.Loading - _searchResults.value = load { repository.catalog(null, null, trimmed) } + _searchResults.value = loadWithUpstream( + firstParty = { repository.firstPartyCatalog(null, null, trimmed) }, + merged = { repository.withFdroid(it, null, null, trimmed) }, + publish = { _searchResults.value = it }, + ) } } @@ -376,20 +429,48 @@ class StoreViewModel : ViewModel() { } // ── Internals ───────────────────────────────────────────────────────── - private suspend fun load(block: suspend () -> CatalogResponse): UiState = - runCatching { block() }.fold( - onSuccess = { response -> - val index = repository.releaseIndex(response) - if (index.isNotEmpty()) _releases.value = _releases.value + index - if (response.apps.isNotEmpty()) { - _knownApps.value = _knownApps.value + response.apps.associateBy { it.id } - } - if (response.apps.isEmpty()) UiState.Ready(CatalogBundle()) else UiState.Ready( - CatalogBundle(response.apps, index), - ) - }, - onFailure = { it.toUiState() }, + private suspend fun load(block: suspend () -> CatalogResponse): UiState = try { + val response = block() + val index = repository.releaseIndex(response) + if (index.isNotEmpty()) _releases.value = _releases.value + index + if (response.apps.isNotEmpty()) { + _knownApps.value = _knownApps.value + response.apps.associateBy { it.id } + } + if (response.apps.isEmpty()) UiState.Ready(CatalogBundle()) else UiState.Ready( + CatalogBundle(response.apps, index), ) + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + error.toUiState() + } + + /** + * Publishes the first-party catalogue right away and then adds the + * optional F-Droid catalogue once its index has been downloaded. + * + * F-Droid's index is several megabytes, so waiting for the merge before + * rendering anything would leave the user staring at a spinner on a slow + * connection. If the upstream catalogue cannot be reached, the + * first-party list simply stays. + */ + private suspend fun loadWithUpstream( + firstParty: suspend () -> CatalogResponse, + merged: suspend (CatalogResponse) -> CatalogResponse, + publish: (UiState) -> Unit, + ): UiState { + val base = try { + firstParty() + } catch (cancellation: CancellationException) { + throw cancellation + } catch (error: Exception) { + return error.toUiState() + } + val first = load { base } + publish(first) + val second = load { merged(base) } + return if (second is UiState.Ready) second else first + } private fun Throwable.toUiState(): UiState.Failed { val message = message?.takeIf { it.isNotBlank() } ?: "Could not load Kiwi Store." diff --git a/app/src/main/java/com/gothwad/store/ui/screens/HomeScreen.kt b/app/src/main/java/com/gothwad/store/ui/screens/HomeScreen.kt index 45471d2..557b9f7 100644 --- a/app/src/main/java/com/gothwad/store/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/gothwad/store/ui/screens/HomeScreen.kt @@ -34,7 +34,9 @@ import androidx.compose.material.icons.filled.Storefront import androidx.compose.material.icons.filled.Work import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector @@ -50,6 +52,7 @@ import com.gothwad.store.ui.StoreViewModel import com.gothwad.store.ui.UiState import com.gothwad.store.ui.components.AppGridCard import com.gothwad.store.ui.components.AppRowItem +import com.gothwad.store.ui.components.BannerTone import com.gothwad.store.ui.components.AvatarAction import com.gothwad.store.ui.components.BannerCard import com.gothwad.store.ui.components.CategoryTile @@ -59,6 +62,7 @@ import com.gothwad.store.ui.components.EmptyState import com.gothwad.store.ui.components.ErrorState import com.gothwad.store.ui.components.GameCard import com.gothwad.store.ui.components.GothwadScaffold +import com.gothwad.store.ui.components.InfoBanner import com.gothwad.store.ui.components.GothwadTopBar import com.gothwad.store.ui.components.PlayChip import com.gothwad.store.ui.components.SectionHeader @@ -75,6 +79,10 @@ fun HomeScreen(vm: StoreViewModel, nav: NavController) { val state by vm.home.collectAsStateWithLifecycle() val releases by vm.releases.collectAsStateWithLifecycle() val categories by vm.categories.collectAsStateWithLifecycle() + val fdroidApps by vm.fdroid.collectAsStateWithLifecycle() + val fdroidError by vm.fdroidError.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { vm.loadFdroid() } GothwadScaffold( topBar = { @@ -99,6 +107,8 @@ fun HomeScreen(vm: StoreViewModel, nav: NavController) { vm = vm, nav = nav, releases = releases, + fdroidApps = fdroidApps.apps, + fdroidError = fdroidError, bottomPadding = padding.calculateBottomPadding(), ) } @@ -112,11 +122,23 @@ private fun HomeContent( vm: StoreViewModel, nav: NavController, releases: Map, + fdroidApps: List, + fdroidError: String?, bottomPadding: androidx.compose.ui.unit.Dp, ) { val apps = bundle.apps - if (apps.isEmpty()) { + if (apps.isEmpty() && fdroidApps.isEmpty()) { Column(Modifier.fillMaxWidth()) { + fdroidError?.let { error -> + InfoBanner( + title = "F-Droid catalogue unavailable", + message = error, + tone = BannerTone.Warning, + action = { + TextButton(onClick = vm::loadFdroid) { Text("Retry") } + }, + ) + } EmptyState( title = "Nothing published yet", message = "Kiwi Store is connected to the live catalogue, but no app or game has " + @@ -134,11 +156,25 @@ private fun HomeContent( val suggested = (nonGames + games).take(12) val trending = apps.sortedByDescending { it.downloadsCount }.take(5) val topFree = nonGames.ifEmpty { apps }.sortedByDescending { it.downloadsCount }.take(9) + val firstPartyPackages = apps.mapTo(mutableSetOf()) { it.packageName } + val fdroidPicks = fdroidApps.filterNot { it.packageName in firstPartyPackages }.take(12) LazyColumn( modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(top = 8.dp, bottom = bottomPadding + Spacing.bottomContentPadding), ) { + if (fdroidError != null) { + item { + InfoBanner( + title = "F-Droid catalogue unavailable", + message = fdroidError, + tone = BannerTone.Warning, + action = { + TextButton(onClick = vm::loadFdroid) { Text("Retry") } + }, + ) + } + } item { BannerCard( title = "New & updated", @@ -188,6 +224,32 @@ private fun HomeContent( } } + if (fdroidPicks.isNotEmpty()) { + item { + SectionHeader( + title = "Open-source picks", + subtitle = "Free and open-source apps from F-Droid", + onSeeAll = { nav.navigate(Routes.APPS) }, + ) + } + item { + LazyRow( + contentPadding = PaddingValues(horizontal = Spacing.screenH), + horizontalArrangement = Arrangement.spacedBy(2.dp), + ) { + items(fdroidPicks, key = { "fdroid-${it.id}" }) { app -> + val action = rememberInstallAction(app, releases[app.id]) + SmallAppCard( + app = app, + action = action, + onClick = { nav.navigate(Routes.app(app.id)) }, + onActionClick = { vm.onAppAction(app) }, + ) + } + } + } + } + if (topFree.isNotEmpty()) { item { SectionHeader(title = "Top free", onSeeAll = { nav.navigate(Routes.charts("app")) }) } items(topFree.chunked(3), key = { row -> "grid-${row.first().id}" }) { row -> diff --git a/app/src/main/java/com/gothwad/store/ui/screens/ListingScreens.kt b/app/src/main/java/com/gothwad/store/ui/screens/ListingScreens.kt index fa7f968..6b4178b 100644 --- a/app/src/main/java/com/gothwad/store/ui/screens/ListingScreens.kt +++ b/app/src/main/java/com/gothwad/store/ui/screens/ListingScreens.kt @@ -19,6 +19,8 @@ import androidx.compose.material.icons.filled.NewReleases import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.TrendingUp import androidx.compose.material.icons.filled.Whatshot +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -38,12 +40,14 @@ import com.gothwad.store.ui.StoreViewModel import com.gothwad.store.ui.UiState import com.gothwad.store.ui.components.AppGridCard import com.gothwad.store.ui.components.AppRowItem +import com.gothwad.store.ui.components.BannerTone import com.gothwad.store.ui.components.AvatarAction import com.gothwad.store.ui.components.CategoryTile import com.gothwad.store.ui.components.DividerLine import com.gothwad.store.ui.components.DownloadsAction import com.gothwad.store.ui.components.EmptyState import com.gothwad.store.ui.components.ErrorState +import com.gothwad.store.ui.components.InfoBanner import com.gothwad.store.ui.components.GothwadScaffold import com.gothwad.store.ui.components.GothwadTopBar import com.gothwad.store.ui.components.PlayChip @@ -53,6 +57,21 @@ import com.gothwad.store.ui.components.SkeletonList import com.gothwad.store.ui.components.rememberInstallAction import com.gothwad.store.ui.theme.Spacing +@Composable +private fun FdroidStatusNotice(vm: StoreViewModel, onRetry: () -> Unit) { + val error by vm.fdroidError.collectAsStateWithLifecycle() + if (error != null) { + InfoBanner( + title = "F-Droid catalogue unavailable", + message = error.orEmpty(), + tone = BannerTone.Warning, + action = { + TextButton(onClick = onRetry) { Text("Retry") } + }, + ) + } +} + @Composable fun AppsScreen(vm: StoreViewModel, nav: NavController) { val state by vm.apps.collectAsStateWithLifecycle() @@ -77,6 +96,7 @@ fun AppsScreen(vm: StoreViewModel, nav: NavController) { }, ) { padding -> Column(Modifier.fillMaxSize().padding(padding)) { + FdroidStatusNotice(vm, onRetry = { vm.refreshApps() }) LazyRow( contentPadding = PaddingValues(horizontal = Spacing.screenH, vertical = 10.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), @@ -133,6 +153,7 @@ fun GamesScreen(vm: StoreViewModel, nav: NavController) { }, ) { padding -> Column(Modifier.fillMaxSize().padding(padding)) { + FdroidStatusNotice(vm, onRetry = vm::refreshGames) when (val current = state) { UiState.Loading -> SkeletonList(rows = 7) is UiState.Failed -> ErrorState(current.message, onRetry = { vm.refreshGames() }) diff --git a/app/src/main/java/com/gothwad/store/ui/screens/SettingsScreens.kt b/app/src/main/java/com/gothwad/store/ui/screens/SettingsScreens.kt index f5a49fa..753f089 100644 --- a/app/src/main/java/com/gothwad/store/ui/screens/SettingsScreens.kt +++ b/app/src/main/java/com/gothwad/store/ui/screens/SettingsScreens.kt @@ -36,6 +36,7 @@ import kotlinx.coroutines.launch import com.gothwad.store.BuildConfig import com.gothwad.store.core.AppGraph import com.gothwad.store.core.Format +import com.gothwad.store.data.remote.FdroidCatalog import com.gothwad.store.data.settings.ChartTab import com.gothwad.store.data.settings.ListingLayout import com.gothwad.store.data.settings.ThemeMode @@ -603,6 +604,16 @@ fun AdvancedScreen(vm: StoreViewModel, nav: NavController) { KeyValueRow("OAuth client", BuildConfig.OAUTH_CLIENT_ID) } } + item { SectionHeader(title = "Catalogue sources") } + item { + Column { + val fdroid by vm.fdroid.collectAsStateWithLifecycle() + val fdroidError by vm.fdroidError.collectAsStateWithLifecycle() + KeyValueRow("F-Droid index", FdroidCatalog.INDEX_URL) + KeyValueRow("F-Droid apps", "${fdroid.apps.size}") + fdroidError?.let { KeyValueRow("F-Droid problem", it) } + } + } item { SectionHeader(title = "Maintenance") } item { SettingsGroup { diff --git a/app/src/test/java/com/gothwad/store/FdroidIndexTest.kt b/app/src/test/java/com/gothwad/store/FdroidIndexTest.kt new file mode 100644 index 0000000..46d0416 --- /dev/null +++ b/app/src/test/java/com/gothwad/store/FdroidIndexTest.kt @@ -0,0 +1,257 @@ +package com.gothwad.store + +import com.gothwad.store.data.remote.FdroidCategories +import com.gothwad.store.data.remote.FdroidIndex +import com.gothwad.store.data.remote.FdroidIndexParser +import com.gothwad.store.data.remote.toCatalog +import com.squareup.moshi.JsonReader +import okio.Buffer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * F-Droid's `index-v1.json` is the only upstream catalogue the app reads, so + * its exact shape is pinned here. + * + * The important detail: `apps` is an **array** of app objects while `packages` + * is a map of package name -> APK list, and the human readable name usually + * lives inside `localized["en-US"]`. Reading `apps` as a JSON object silently + * produced an empty catalogue, which is why no F-Droid app ever showed up. + * + * The payload below is trimmed from the real `https://f-droid.org/repo/index-v1.json`. + */ +class FdroidIndexTest { + + private val indexPayload = """ + { + "repo": { "timestamp": 1792190113856, "version": 30000, "name": "F-Droid" }, + "requests": { "install": [], "uninstall": [] }, + "apps": [ + { + "packageName": "com.terokarvinen.x54ask", + "authorName": "Tero Karvinen", + "authorWebSite": "https://terokarvinen.com/", + "categories": ["Calendar & Agenda", "Note", "Task"], + "suggestedVersionName": "1.1.2", + "suggestedVersionCode": "1010200", + "license": "GPL-3.0-or-later", + "added": 1788427366142, + "icon": "com.terokarvinen.x54ask.1010200.png", + "lastUpdated": 1788427366142, + "localized": { + "en-US": { + "name": "0x54ask", + "summary": "Todo.txt manager. Offline, works with Syncthing.", + "description": "0x54ask is a todo.txt manager.", + "icon": "icon_FOzSYq6etfsaWRiMc7bx-8vLVKtsug1dmhT9NvxRj9w=.png" + } + } + }, + { + "packageName": "org.fdroid.fdroid", + "name": "F-Droid", + "summary": "The official F-Droid Free Software repository.", + "description": "F-Droid is an installable app catalogue.", + "license": "GPL-2.0-or-later", + "categories": ["System"], + "icon": "org.fdroid.fdroid.1017010.png", + "webSite": "https://f-droid.org", + "added": 1457568000000, + "lastUpdated": 1498176000000 + }, + { + "packageName": "com.example.oldgame", + "name": "Retro Racer", + "summary": "A retro racing game.", + "categories": ["Games"], + "icon": "com.example.oldgame.1.png", + "added": 1, + "lastUpdated": 1 + }, + { + "packageName": "com.example.toonew", + "name": "Too New", + "categories": ["Internet"], + "icon": "com.example.toonew.1.png" + }, + { + "packageName": "com.example.party", + "name": "Party Game", + "summary": "A party game.", + "categories": ["Party Game", "Science & Education"], + "icon": "com.example.party.1.png" + } + ], + "packages": { + "com.terokarvinen.x54ask": [ + { + "apkName": "com.terokarvinen.x54ask_1010200.apk", + "hash": "aaaa", + "hashType": "sha256", + "minSdkVersion": 29, + "size": 12345, + "targetSdkVersion": 36, + "versionCode": 1010200, + "versionName": "1.1.2" + }, + { + "apkName": "com.terokarvinen.x54ask_1000100.apk", + "hash": "bbbb", + "hashType": "sha256", + "minSdkVersion": 21, + "size": 12000, + "targetSdkVersion": 33, + "versionCode": 1000100, + "versionName": "1.0.1" + } + ], + "org.fdroid.fdroid": [ + { + "apkName": "org.fdroid.fdroid_1017010.apk", + "hash": "cccc", + "hashType": "sha256", + "minSdkVersion": 21, + "size": 9999, + "targetSdkVersion": 34, + "versionCode": 1017010, + "versionName": "1.0.17" + } + ], + "com.example.oldgame": [ + { + "apkName": "com.example.oldgame_1.apk", + "hash": "dddd", + "hashType": "sha256", + "minSdkVersion": 21, + "size": 5555, + "targetSdkVersion": 30, + "versionCode": 1, + "versionName": "1.0" + } + ], + "com.example.toonew": [ + { + "apkName": "com.example.toonew_1.apk", + "hash": "eeee", + "hashType": "sha256", + "minSdkVersion": 40, + "size": 4444, + "targetSdkVersion": 40, + "versionCode": 1, + "versionName": "1.0" + } + ] + } + } + """.trimIndent() + + private fun parse(sdkInt: Int = 34): FdroidIndex = + FdroidIndexParser.parse(JsonReader.of(Buffer().writeUtf8(indexPayload)), sdkInt) + + @Test(expected = IllegalStateException::class) + fun `apps encoded as an object is rejected instead of silently becoming empty`() { + val wrongShape = """{"apps": {}, "packages": {}}""" + FdroidIndexParser.parse(JsonReader.of(Buffer().writeUtf8(wrongShape)), sdkInt = 34) + } + + @Test + fun `apps is an array and every entry reaches the catalogue`() { + val index = parse() + assertEquals(5, index.apps.size) + // The parser retains only a single compatible APK per package; the + // API-40 app is excluded on this API-34 test device. + assertEquals(3, index.packages.size) + + val catalog = index.toCatalog(repoUrl = "https://f-droid.org/repo") + // "com.example.toonew" needs API 40 and "com.example.party" has no APK. + assertEquals(3, catalog.apps.size) + assertEquals("F-Droid", catalog.source) + } + + @Test + fun `localized name wins over the package name`() { + val catalog = parse().toCatalog(repoUrl = "https://f-droid.org/repo") + val app = catalog.apps.first { it.packageName == "com.terokarvinen.x54ask" } + assertEquals("0x54ask", app.name) + assertEquals("fdroid:com.terokarvinen.x54ask", app.id) + assertEquals("Todo.txt manager. Offline, works with Syncthing.", app.shortDesc) + assertEquals("0x54ask is a todo.txt manager.", app.fullDesc) + assertEquals("Tero Karvinen", app.developerName) + assertEquals("published", app.status) + assertEquals("F-Droid", app.source) + } + + @Test + fun `f-droid categories map onto kiwi store slugs`() { + val catalog = parse().toCatalog(repoUrl = "https://f-droid.org/repo") + assertEquals( + "productivity", + catalog.apps.first { it.packageName == "com.terokarvinen.x54ask" }.category, + ) + assertEquals("tools", catalog.apps.first { it.packageName == "org.fdroid.fdroid" }.category) + // "Games" is a category in F-Droid but a kind in Kiwi Store. + val game = catalog.apps.first { it.packageName == "com.example.oldgame" } + assertEquals("game", game.kind) + assertEquals("entertainment", game.category) + } + + @Test + fun `category lookup knows every seeded slug`() { + assertEquals("browser", FdroidCategories.slug("Internet")) + assertEquals("communication", FdroidCategories.slug("Phone & SMS")) + assertEquals("education", FdroidCategories.slug("Science & Education")) + assertEquals("music", FdroidCategories.slug("Music & Audio")) + assertEquals("photo", FdroidCategories.slug("Photography")) + assertEquals("entertainment", FdroidCategories.slug("Party Game")) + assertTrue(FdroidCategories.isGame(listOf("Party Game"))) + assertEquals(null, FdroidCategories.slug("Something Kiwi Store does not know")) + assertEquals("tools", FdroidCategories.slugFor(listOf("Something Kiwi Store does not know"))) + assertEquals("tools", FdroidCategories.slugFor(emptyList())) + } + + @Test + fun `the newest build that runs on the device wins`() { + val modern = parse().toCatalog(repoUrl = "https://f-droid.org/repo") + val release = modern.releases.first { it.appId == "fdroid:com.terokarvinen.x54ask" } + assertEquals(1010200L, release.versionCode) + assertEquals("1.1.2", release.versionName) + assertEquals("fdroid", release.apkProvider) + assertEquals("https://f-droid.org/repo/com.terokarvinen.x54ask_1010200.apk", release.apkUrl) + assertEquals(12345L, release.apkSizeBytes) + assertEquals("aaaa", release.apkSha256) + assertEquals(29, release.minSdk!!) + assertEquals(36, release.targetSdk!!) + assertTrue(release.isLive) + assertEquals("approved", release.reviewStatus) + + // On API 24 only the older build still runs. + val legacy = parse(sdkInt = 24).toCatalog(repoUrl = "https://f-droid.org/repo") + val older = legacy.releases.first { it.appId == "fdroid:com.terokarvinen.x54ask" } + assertEquals(1000100L, older.versionCode) + assertEquals("https://f-droid.org/repo/com.terokarvinen.x54ask_1000100.apk", older.apkUrl) + } + + @Test + fun `icons point at the f-droid repository`() { + val catalog = parse().toCatalog(repoUrl = "https://f-droid.org/repo") + assertEquals( + "https://f-droid.org/repo/icons-640/com.terokarvinen.x54ask.1010200.png", + catalog.apps.first { it.packageName == "com.terokarvinen.x54ask" }.iconUrl, + ) + assertEquals( + "https://f-droid.org/repo/icons-640/org.fdroid.fdroid.1017010.png", + catalog.apps.first { it.packageName == "org.fdroid.fdroid" }.iconUrl, + ) + } + + @Test + fun `an app without a published apk never reaches the store`() { + val index = parse() + val withoutApk = index.copy(packages = index.packages - "com.example.oldgame") + val catalog = withoutApk.toCatalog(repoUrl = "https://f-droid.org/repo") + assertNotNull(catalog) + assertTrue(catalog.apps.none { it.packageName == "com.example.oldgame" }) + } +}