Skip to content
Open
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
27 changes: 25 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ plugins {
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.kotlin.serialization)
// kapt is already on the classpath via the Kotlin Gradle plugin — apply it
// without a version (declaring one conflicts: "already on the classpath").
id("org.jetbrains.kotlin.kapt")
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -160,8 +163,8 @@ android {
applicationId = "com.pinakes.app"
minSdk = 26
targetSdk = 35
versionCode = 3
versionName = "1.1.1"
versionCode = 4
versionName = "1.2.0"

vectorDrawables {
useSupportLibrary = true
Expand Down Expand Up @@ -218,6 +221,13 @@ android {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}

testOptions {
unitTests {
// Robolectric needs the merged Android resources for in-JVM Room/DAO tests.
isIncludeAndroidResources = true
}
}
}

// Wire the generated res directory into every variant. addGeneratedSourceDirectory
Expand Down Expand Up @@ -267,4 +277,17 @@ dependencies {
// Per-app language preferences (AppCompatDelegate.setApplicationLocales) +
// autoStoreLocales backport for API < 33.
implementation(libs.androidx.appcompat)

// Local cache: Room (offline catalog) + app-open refresh via the process lifecycle.
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
kapt(libs.androidx.room.compiler)
implementation(libs.androidx.lifecycle.process)

// Unit tests (JVM + Robolectric for Room DAO).
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.robolectric)
testImplementation(libs.androidx.room.testing)
testImplementation(libs.androidx.test.core)
}
42 changes: 40 additions & 2 deletions app/src/main/java/com/pinakes/app/PinakesApplication.kt
Original file line number Diff line number Diff line change
@@ -1,16 +1,54 @@
package com.pinakes.app

import android.app.Application
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.disk.DiskCache
import com.pinakes.app.di.ServiceLocator
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch

/** Application entry point; owns the single [ServiceLocator]. */
class PinakesApplication : Application() {
/** Application entry point; owns the single [ServiceLocator] and the Coil image loader. */
class PinakesApplication : Application(), ImageLoaderFactory {

lateinit var services: ServiceLocator
private set

private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)

override fun onCreate() {
super.onCreate()
services = ServiceLocator(this)

// Refresh the cached catalog every time the app comes to the foreground, so the
// offline catalog stays current without a network round-trip on every screen.
ProcessLifecycleOwner.get().lifecycle.addObserver(object : DefaultLifecycleObserver {
override fun onStart(owner: LifecycleOwner) {
if (!services.session.isLoggedIn()) return
appScope.launch { services.catalogRepository.refreshCatalog() }
}
})
}

/**
* App-wide Coil loader with a persistent 256 MB disk cache that ignores server cache
* headers, so book covers are downloaded once and reused across sessions instead of
* being re-fetched on every screen / app open.
*/
override fun newImageLoader(): ImageLoader =
ImageLoader.Builder(this)
.crossfade(true)
.respectCacheHeaders(false)
.diskCache {
DiskCache.Builder()
.directory(cacheDir.resolve("image_cache"))
.maxSizeBytes(256L * 1024 * 1024)
.build()
}
.build()
}
26 changes: 26 additions & 0 deletions app/src/main/java/com/pinakes/app/data/local/AppDatabase.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package com.pinakes.app.data.local

import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase

/** Local cache database. The cache is disposable, so destructive migration is fine. */
@Database(entities = [CachedBook::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
abstract fun catalogDao(): CatalogDao

companion object {
@Volatile
private var INSTANCE: AppDatabase? = null

fun get(context: Context): AppDatabase =
INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"pinakes-cache.db",
).fallbackToDestructiveMigration().build().also { INSTANCE = it }
}
}
}
64 changes: 64 additions & 0 deletions app/src/main/java/com/pinakes/app/data/local/CachedBook.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package com.pinakes.app.data.local

import androidx.room.Entity
import androidx.room.PrimaryKey
import com.pinakes.app.data.model.BookSummary

/**
* A catalog list item cached locally so the catalog renders instantly and works
* offline (no per-open network round-trip for the list or its covers). Mirrors
* [BookSummary]; [position] preserves the server's ordering for the snapshot.
*/
@Entity(tableName = "cached_books")
data class CachedBook(
@PrimaryKey val id: Int,
val position: Int,
val title: String,
val subtitle: String?,
val author: String?,
val publisher: String?,
val genre: String?,
val year: Int?,
val language: String?,
val mediaType: String?,
val isbn13: String?,
val coverUrl: String?,
val copiesTotal: Int,
val copiesAvailable: Int,
val loanableNow: Boolean,
)

fun CachedBook.toSummary(): BookSummary = BookSummary(
id = id,
title = title,
subtitle = subtitle,
author = author,
publisher = publisher,
genre = genre,
year = year,
language = language,
mediaType = mediaType,
isbn13 = isbn13,
coverUrl = coverUrl,
copiesTotal = copiesTotal,
copiesAvailable = copiesAvailable,
loanableNow = loanableNow,
)

fun BookSummary.toCached(position: Int): CachedBook = CachedBook(
id = id,
position = position,
title = title,
subtitle = subtitle,
author = author,
publisher = publisher,
genre = genre,
year = year,
language = language,
mediaType = mediaType,
isbn13 = isbn13,
coverUrl = coverUrl,
copiesTotal = copiesTotal,
copiesAvailable = copiesAvailable,
loanableNow = loanableNow,
)
32 changes: 32 additions & 0 deletions app/src/main/java/com/pinakes/app/data/local/CatalogDao.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.pinakes.app.data.local

import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import kotlinx.coroutines.flow.Flow

@Dao
interface CatalogDao {

/** Reactive cached catalog snapshot, in the server's order. */
@Query("SELECT * FROM cached_books ORDER BY position ASC")
fun observeAll(): Flow<List<CachedBook>>

@Query("SELECT COUNT(*) FROM cached_books")
suspend fun count(): Int

@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(books: List<CachedBook>)

@Query("DELETE FROM cached_books")
suspend fun clear()

/** Atomically replace the whole cache with a fresh catalog snapshot. */
@Transaction
suspend fun replaceAll(books: List<CachedBook>) {
clear()
insertAll(books)
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.pinakes.app.data.repository

import com.pinakes.app.data.local.CatalogDao
import com.pinakes.app.data.local.toCached
import com.pinakes.app.data.local.toSummary
import com.pinakes.app.data.model.AvailabilityCalendar
import com.pinakes.app.data.model.BookDetail
import com.pinakes.app.data.model.BookSummary
Expand All @@ -9,6 +12,8 @@ import com.pinakes.app.data.network.ErrorCodes
import com.pinakes.app.data.network.NetworkModule
import com.pinakes.app.data.network.apiCall
import com.pinakes.app.data.network.apiResponse
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map

/** Filters for a catalog search; nulls are omitted from the query. */
data class SearchFilters(
Expand All @@ -33,11 +38,39 @@ data class SearchPage(
* Catalog browsing: cursor-paginated search, book detail (with ETag/304 caching) and the
* genre tree. The ETag cache lets a re-fetch of the same book reuse the last payload on 304.
*/
class CatalogRepository(private val network: NetworkModule) {
class CatalogRepository(
private val network: NetworkModule,
private val catalogDao: CatalogDao,
) {

// Small in-memory ETag cache for book detail: id -> (etag, payload).
private val detailCache = HashMap<Int, Pair<String?, BookDetail>>()

/**
* Reactive cached catalog snapshot (offline-first). Emits whatever Room holds —
* including offline — so the UI can render instantly without a network round-trip
* for the list or, via Coil's disk cache, its covers. Empty until the first refresh.
*/
fun observeCachedCatalog(): Flow<List<BookSummary>> =
catalogDao.observeAll().map { rows -> rows.map { it.toSummary() } }

/** True once a catalog snapshot has been cached at least once. */
suspend fun hasCachedCatalog(): Boolean = catalogDao.count() > 0

/**
* Refresh the cached catalog from the network (first page, unfiltered) and replace
* the Room snapshot atomically. On network failure the existing cache is kept, so a
* refresh-on-open that fails never wipes the offline catalog.
*/
suspend fun refreshCatalog(limit: Int = 40): ApiResult<Unit> =
when (val res = search(SearchFilters(), limit = limit)) {
is ApiResult.Success -> {
catalogDao.replaceAll(res.data.items.mapIndexed { i, b -> b.toCached(i) })
ApiResult.Success(Unit, res.meta)
}
is ApiResult.Failure -> res
}

suspend fun search(
filters: SearchFilters,
cursor: String? = null,
Expand Down
6 changes: 5 additions & 1 deletion app/src/main/java/com/pinakes/app/di/ServiceLocator.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.pinakes.app.di

import android.content.Context
import com.pinakes.app.data.local.AppDatabase
import com.pinakes.app.data.network.NetworkModule
import com.pinakes.app.data.repository.AuthRepository
import com.pinakes.app.data.repository.CatalogRepository
Expand Down Expand Up @@ -30,8 +31,11 @@ class ServiceLocator(context: Context) {

val network: NetworkModule = NetworkModule(session)

/** Local cache DB (offline catalog snapshot). */
val database: AppDatabase = AppDatabase.get(context.applicationContext)

val authRepository: AuthRepository by lazy { AuthRepository(network, session, features) }
val catalogRepository: CatalogRepository by lazy { CatalogRepository(network) }
val catalogRepository: CatalogRepository by lazy { CatalogRepository(network, database.catalogDao()) }
val libraryRepository: LibraryRepository by lazy { LibraryRepository(network) }
val wishlistRepository: WishlistRepository by lazy { WishlistRepository(network) }
val profileRepository: ProfileRepository by lazy { ProfileRepository(network) }
Expand Down
48 changes: 36 additions & 12 deletions app/src/main/java/com/pinakes/app/ui/screens/home/HomeViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@ import androidx.lifecycle.viewModelScope
import com.pinakes.app.data.model.BookSummary
import com.pinakes.app.data.network.ApiResult
import com.pinakes.app.data.repository.CatalogRepository
import com.pinakes.app.data.repository.SearchFilters
import com.pinakes.app.data.store.SessionStore
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch

Expand All @@ -34,24 +34,48 @@ class HomeViewModel(
)
val state: StateFlow<HomeUiState> = _state.asStateFlow()

init { load() }
init {
observeCache()
refresh()
}

fun load() {
_state.update { it.copy(loading = true, error = null) }
/**
* Offline-first: render the "Available now" shelf from the locally-cached catalog
* snapshot immediately (works with no network), filtering to currently-loanable
* copies. The shelf updates automatically when [refresh] replaces the cache.
*/
private fun observeCache() {
viewModelScope.launch {
// "Available now" shelf: full catalog filtered to currently-loanable copies.
when (val res = catalog.search(SearchFilters(availableOnly = true), limit = 20)) {
is ApiResult.Success -> _state.update {
it.copy(available = res.data.items, loading = false, error = null)
}
is ApiResult.Failure -> _state.update {
it.copy(loading = false, error = res.message.takeIf { m -> m.isNotBlank() })
catalog.observeCachedCatalog().collectLatest { books ->
val available = books.filter { it.available }
_state.update { it.copy(available = available, loading = false, error = null) }
}
}
}

/**
* Pull a fresh catalog snapshot from the network into the cache. Called on init and
* on every app foreground. A failure only surfaces an error when there is nothing
* cached to show — otherwise the cached catalog stays on screen.
*/
fun refresh() {
viewModelScope.launch {
when (val res = catalog.refreshCatalog()) {
is ApiResult.Success -> _state.update { it.copy(loading = false, error = null) }
is ApiResult.Failure -> {
val hasCache = catalog.hasCachedCatalog()
_state.update {
it.copy(
loading = false,
error = if (hasCache) null else res.message.takeIf { m -> m.isNotBlank() },
)
}
}
}
}
}

fun retry() = load()
fun retry() = refresh()

class Factory(
private val catalog: CatalogRepository,
Expand Down
Loading