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
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ The app never connects directly to Supabase and includes no backend credentials.

## Current implementation

- Bottom tabs: **Home | Apps | Kiwi | Games | Profile**. The **Kiwi** tab lists
exactly the apps that are live on the Kiwi Store web
([kiwistore.gothwadtech.com](https://kiwistore.gothwadtech.com/)) — it reads
the Worker catalogue only, so no upstream mirror ever leaks into it.
- All five tabs share one header: the Kiwi Store icon on the left, the search
field in the middle and the profile avatar on the right. Tapping the search
field opens the search screen, so there is no separate search tab.
- Settings → **App sources** shows everything the app runs on: the Kiwi Store
web catalogue, the F-Droid and IzzyOnDroid repositories with their URLs,
index sizes, listing counts, sync times and failover mirrors — plus a live
view of what is running right now (catalogue sync, download jobs, installer
back-ends, network and account).
- Live published apps and games from the Worker, with category filters.
- F-Droid's public repository index is merged into Home, Apps, and Search; Gothwad listings win when the same package is present in both sources. F-Droid metadata is cached in memory for six hours.
- F-Droid APKs download directly from F-Droid through the existing foreground download/install flow; they are not proxied, hosted, or counted by Gothwad. A compact “From F-Droid” attribution is shown on list and detail screens.
Expand Down
9 changes: 9 additions & 0 deletions app/src/main/java/com/gothwad/store/data/model/StoreModels.kt
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ data class StoreApp(
) {
val isGame: Boolean get() = kind == "game"
val hasRating: Boolean get() = ratingCount > 0

/**
* True for the listings that are published on the Kiwi Store web
* (kiwistore.gothwadtech.com). F-Droid and other upstream mirrors carry a
* `fdroid:` id and are not part of the web store, so the Kiwi tab skips
* them.
*/
val isKiwiStoreListing: Boolean
get() = !id.startsWith("fdroid:") && !source.equals("F-Droid", ignoreCase = true)
}

data class StoreRelease(
Expand Down
126 changes: 126 additions & 0 deletions app/src/main/java/com/gothwad/store/data/remote/FdroidCatalog.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,47 @@ import java.io.IOException
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger

/**
* What the app knows about one upstream repository: which repo it is, how big
* its index is, how many apps/releases it carries and when it was last
* synced. Shown in Settings so the user can see exactly what is mirrored.
*/
data class FdroidRepoStatus(
val name: String,
val repoUrl: String,
val indexUrl: String,
val apps: Int = 0,
val releases: Int = 0,
val indexBytes: Long = 0L,
val lastSyncedAt: Long = 0L,
val syncing: Boolean = false,
val error: String? = null,
)

/**
* Live status of the optional upstream catalogue (F-Droid + IzzyOnDroid).
*/
data class FdroidStatus(
val fdroid: FdroidRepoStatus = FdroidRepoStatus(
name = "F-Droid",
repoUrl = FdroidCatalog.REPO_URL,
indexUrl = FdroidCatalog.INDEX_URL,
),
val izzy: FdroidRepoStatus = FdroidRepoStatus(
name = "IzzyOnDroid",
repoUrl = FdroidCatalog.IZZY_REPO_URL,
indexUrl = FdroidCatalog.IZZY_INDEX_URL,
),
val mirrors: List<String> = FdroidCatalog.FDROID_MIRRORS,
val lastError: String? = null,
) {
val repos: List<FdroidRepoStatus> get() = listOf(fdroid, izzy)
val totalApps: Int get() = fdroid.apps + izzy.apps
val totalReleases: Int get() = fdroid.releases + izzy.releases
val totalIndexBytes: Long get() = fdroid.indexBytes + izzy.indexBytes
val syncing: Boolean get() = fdroid.syncing || izzy.syncing
}

/**
* Read-only client for F-Droid's public repository index (and IzzyOnDroid, the
* standard companion F-Droid repository bundled in Droid-ify). APK bytes stay
Expand Down Expand Up @@ -96,6 +137,19 @@ class FdroidCatalog(
*/
val catalogFlow: StateFlow<CatalogResponse?> = _catalogFlow.asStateFlow()

private val _status = MutableStateFlow(FdroidStatus())

/**
* Live status of both upstream repositories: sizes, sync times, mirrors
* and the current error. Settings shows this so the user can see what the
* app is actually running on.
*/
val status: StateFlow<FdroidStatus> = _status.asStateFlow()

private fun updateStatus(transform: (FdroidStatus) -> FdroidStatus) {
_status.value = transform(_status.value)
}

private val effectiveCacheDir: File by lazy {
val dir = cacheDir ?: File(System.getProperty("java.io.tmpdir", "/tmp"), "kiwistore-fdroid")
runCatching { dir.mkdirs() }
Expand Down Expand Up @@ -184,6 +238,25 @@ class FdroidCatalog(
if (diskFdroid != null) fdroidCached = diskFdroid
if (diskIzzy != null) izzyCached = diskIzzy

// The disk cache is a catalogue in its own right: report it right away
// so Settings shows the repo sizes even before a fresh sync finishes.
updateStatus { current ->
current.copy(
fdroid = current.fdroid.copy(
apps = fdroidCached?.apps?.size ?: 0,
releases = fdroidCached?.releases?.size ?: 0,
indexBytes = fdroidDiskFile.takeIf { it.exists() }?.length() ?: 0L,
lastSyncedAt = fdroidDiskFile.takeIf { it.exists() }?.lastModified() ?: 0L,
),
izzy = current.izzy.copy(
apps = izzyCached?.apps?.size ?: 0,
releases = izzyCached?.releases?.size ?: 0,
indexBytes = izzyDiskFile.takeIf { it.exists() }?.length() ?: 0L,
lastSyncedAt = izzyDiskFile.takeIf { it.exists() }?.lastModified() ?: 0L,
),
)
}

val merged = mergeCatalogs(fdroidCached, izzyCached)
if (merged.apps.isNotEmpty()) {
cached = merged
Expand All @@ -195,6 +268,7 @@ class FdroidCatalog(
else -> izzyMod
}
_lastError.value = null
updateStatus { current -> current.copy(lastError = null) }
_catalogFlow.value = merged
Log.i(TAG, "Loaded ${merged.apps.size} F-Droid apps from disk cache.")
}
Expand All @@ -211,6 +285,15 @@ class FdroidCatalog(
val firstReady = CompletableDeferred<CatalogResponse>()
activeSyncDeferred = firstReady

// Both repositories are about to be fetched: say so in the status
// so Settings can show that a sync is running.
updateStatus { current ->
current.copy(
fdroid = current.fdroid.copy(syncing = true, error = null),
izzy = current.izzy.copy(syncing = true, error = null),
)
}

catalogScope.launch {
val errors = mutableListOf<String>()
try {
Expand All @@ -223,6 +306,10 @@ class FdroidCatalog(
.onFailure { e ->
if (e is CancellationException) throw e
Log.w(TAG, "IzzyOnDroid sync failed: ${e.message}")
val reason = e.message ?: e::class.java.simpleName
updateStatus { current ->
current.copy(izzy = current.izzy.copy(syncing = false, error = reason))
}
synchronized(errors) {
errors += "IzzyOnDroid: ${e.message ?: e::class.java.simpleName}"
}
Expand All @@ -237,6 +324,10 @@ class FdroidCatalog(
.onFailure { e ->
if (e is CancellationException) throw e
Log.e(TAG, "Official F-Droid sync failed across mirrors: ${e.message}", e)
val reason = e.message ?: e::class.java.simpleName
updateStatus { current ->
current.copy(fdroid = current.fdroid.copy(syncing = false, error = reason))
}
synchronized(errors) {
errors += "F-Droid: ${e.message ?: e::class.java.simpleName}"
}
Expand All @@ -251,6 +342,7 @@ class FdroidCatalog(
if (finalCatalog != null && finalCatalog.apps.isNotEmpty()) {
cachedAt = System.currentTimeMillis()
_lastError.value = null
updateStatus { current -> current.copy(lastError = null) }
if (!firstReady.isCompleted) {
firstReady.complete(finalCatalog)
}
Expand All @@ -259,6 +351,7 @@ class FdroidCatalog(
errors.joinToString(" | ").ifBlank { "F-Droid is unavailable right now." }
}
_lastError.value = combinedMessage
updateStatus { current -> current.copy(lastError = combinedMessage) }
if (!firstReady.isCompleted) {
firstReady.completeExceptionally(IOException(combinedMessage))
}
Expand All @@ -267,6 +360,7 @@ class FdroidCatalog(
if (e !is CancellationException) {
val msg = e.message ?: "F-Droid is unavailable right now."
_lastError.value = msg
updateStatus { current -> current.copy(lastError = msg) }
if (!firstReady.isCompleted) {
firstReady.completeExceptionally(e)
}
Expand All @@ -277,6 +371,13 @@ class FdroidCatalog(
activeSyncDeferred = null
}
}
// Nothing is being fetched any more.
updateStatus { current ->
current.copy(
fdroid = current.fdroid.copy(syncing = false),
izzy = current.izzy.copy(syncing = false),
)
}
}
}

Expand All @@ -291,15 +392,40 @@ class FdroidCatalog(
stateMutex.withLock {
if (newFdroid != null && newFdroid.apps.isNotEmpty()) {
fdroidCached = newFdroid
updateStatus { current ->
current.copy(
fdroid = current.fdroid.copy(
apps = newFdroid.apps.size,
releases = newFdroid.releases.size,
indexBytes = fdroidDiskFile.takeIf { it.exists() }?.length() ?: 0L,
lastSyncedAt = System.currentTimeMillis(),
syncing = false,
error = null,
),
)
}
}
if (newIzzy != null && newIzzy.apps.isNotEmpty()) {
izzyCached = newIzzy
updateStatus { current ->
current.copy(
izzy = current.izzy.copy(
apps = newIzzy.apps.size,
releases = newIzzy.releases.size,
indexBytes = izzyDiskFile.takeIf { it.exists() }?.length() ?: 0L,
lastSyncedAt = System.currentTimeMillis(),
syncing = false,
error = null,
),
)
}
}
val merged = mergeCatalogs(fdroidCached, izzyCached)
if (merged.apps.isNotEmpty()) {
cached = merged
cachedAt = System.currentTimeMillis()
_lastError.value = null
updateStatus { current -> current.copy(lastError = null) }
_catalogFlow.value = merged
if (!firstReady.isCompleted) {
firstReady.complete(merged)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ class StoreRepository(
*/
val fdroidUpdates: StateFlow<CatalogResponse?> = fdroid.catalogFlow

/**
* Live status of the upstream catalogue: which repos are mirrored, how
* big they are, when they were last synced and what is running now.
*/
val fdroidStatus: StateFlow<FdroidStatus> = fdroid.status

/**
* The Kiwi Store catalogue only.
*
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/java/com/gothwad/store/ui/KiwiApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ fun KiwiApp() {
// init block; asking again here would only duplicate the traffic.
vm.refreshApps()
vm.refreshGames()
// The Kiwi tab is the web store: only the Worker catalogue, no F-Droid.
vm.refreshKiwi()
// The optional F-Droid catalogue is downloaded in the background and
// shows up as its own shelf once it arrives.
vm.loadFdroid()
Expand Down
17 changes: 15 additions & 2 deletions app/src/main/java/com/gothwad/store/ui/Routes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,23 @@ object Routes {
// Bottom navigation tabs
const val HOME = "home"
const val APPS = "apps"
const val SEARCH = "search"

/**
* The Kiwi tab: the listings that are live on the Kiwi Store web
* (kiwistore.gothwadtech.com) — nothing else.
*/
const val KIWI = "kiwi"
const val GAMES = "games"
const val ACCOUNT = "account"

val tabs = listOf(HOME, APPS, SEARCH, GAMES, ACCOUNT)
/**
* Search is not a tab any more. Every tab header carries a search action
* that opens this destination, so there is no separate search tab and no
* search bar in the tab headers.
*/
const val SEARCH = "search"

val tabs = listOf(HOME, APPS, KIWI, GAMES, ACCOUNT)

// Catalogue
const val CHARTS = "charts/{kind}"
Expand Down Expand Up @@ -67,6 +79,7 @@ object Routes {
const val APPEARANCE = "settings/appearance"
const val NOTIFICATIONS = "settings/notifications"
const val NETWORK = "settings/network"
const val SOURCES = "settings/sources"
const val PRIVACY = "settings/privacy"
const val ADVANCED = "settings/advanced"
const val DEVICE_INFO = "settings/device"
Expand Down
32 changes: 32 additions & 0 deletions app/src/main/java/com/gothwad/store/ui/StoreViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import com.gothwad.store.data.model.CatalogResponse
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.FdroidStatus
import com.gothwad.store.data.remote.StoreException
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
Expand Down Expand Up @@ -62,6 +63,14 @@ class StoreViewModel : ViewModel() {
private val _apps = MutableStateFlow<UiState<CatalogBundle>>(UiState.Loading)
val apps: StateFlow<UiState<CatalogBundle>> = _apps.asStateFlow()

/**
* The Kiwi tab: the listings that are live on the Kiwi Store web. The
* optional upstream catalogue (F-Droid) is deliberately *not* merged in
* here — the Kiwi tab is the web store, nothing else.
*/
private val _kiwi = MutableStateFlow<UiState<CatalogBundle>>(UiState.Loading)
val kiwi: StateFlow<UiState<CatalogBundle>> = _kiwi.asStateFlow()

private val _games = MutableStateFlow<UiState<CatalogBundle>>(UiState.Loading)
val games: StateFlow<UiState<CatalogBundle>> = _games.asStateFlow()

Expand Down Expand Up @@ -102,6 +111,12 @@ class StoreViewModel : ViewModel() {
/** Why the F-Droid catalogue is missing, if it is. */
val fdroidError: StateFlow<String?> = repository.fdroidError

/**
* Live status of the upstream catalogue (F-Droid + IzzyOnDroid): repo
* sizes, sync times and what is running right now. Settings shows it.
*/
val fdroidStatus: StateFlow<FdroidStatus> = repository.fdroidStatus

val downloads: StateFlow<List<DownloadTask>> = downloader.tasks
val installPhases: StateFlow<Map<String, InstallPhase>> = installer.phases

Expand All @@ -120,6 +135,7 @@ class StoreViewModel : ViewModel() {

private var homeJob: Job? = null
private var appsJob: Job? = null
private var kiwiJob: Job? = null
private var gamesJob: Job? = null
private var chartsJob: Job? = null
private var detailJob: Job? = null
Expand Down Expand Up @@ -236,6 +252,22 @@ class StoreViewModel : ViewModel() {
}
}

/**
* Loads the listings published on the Kiwi Store web. Only the Worker
* catalogue is used here — no F-Droid, no upstream mirrors — so the Kiwi
* tab shows exactly what the website shows.
*/
fun refreshKiwi() {
kiwiJob?.cancel()
kiwiJob = viewModelScope.launch {
if (_kiwi.value !is UiState.Ready) _kiwi.value = UiState.Loading
_kiwi.value = load {
val response = repository.firstPartyCatalog(null, null, "")
response.copy(apps = response.apps.filter { app -> app.isKiwiStoreListing })
}
}
}

fun refreshGames() {
gamesJob?.cancel()
gamesJob = viewModelScope.launch {
Expand Down
Loading
Loading