Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
42 changes: 42 additions & 0 deletions .agent/rules/project-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
trigger: always_on
---

# Project Context and Guidelines

## About Project

This is an offline first Android client app for the [audiobookshelf](https://github.com/advplyr/audiobookshelf) server.

## Feature Requirements (Offline first behavior)

- As the app is an offline-first app, assume that the server is not always reachable.
- Playback progress should be saved in local database first **immediately**.
- The app should watch the network changes, such as connecting to a new wifi network, or lan network, or disconnecting from a network and connecting to a new network, disconnecting from wifi and connecting to celular network etc, and try to ping or reachout the audiobook server to check whether the server is reachable.
- If the server is reachable, the app should sync the local progress to the server and pull the latest progress updates from the server to the local database, it should merge the updates from both.
- If the server is reachable, and some chapters of any audiobook is download, i.e. available offline, then offline track should be given priority for playback.
- If the offline track is deleted / cleared while the book is being played, it should fallback to the online URL if the server is reachable, else it should pause the playback, and make sure to correctly store the last
- The app must be fully functional for downloaded content when offline.

## Ensure Stability

- Ensure null-safety when converting data (e.g., check for division by zero in percentage calculations).
- Changes must be verified by building the app and ensuring logic holds (e.g., uninstall/reinstall for clean state tests).

## Overall Functionality

- When the app loads, it should load the offline available book content immediately, then in the background reach out to the server (if the server is reachable) and fetch the full list of the books, continue listening section books and update the UI seemlessly.
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix spelling error: "seamlessly".

The word "seemlessly" is misspelled; it should be "seamlessly".

🔎 Proposed fix
-- When the app loads, it should load the offline available book content immediately, then in the background reach out to the server (if the server is reachable) and fetch the full list of the books, continue listening section books and update the UI seemlessly.
+- When the app loads, it should load the offline available book content immediately, then in the background reach out to the server (if the server is reachable) and fetch the full list of the books, continue listening section books and update the UI seamlessly.
🤖 Prompt for AI Agents
In .agent/rules/project-context.md around line 28, the word "seemlessly" is
misspelled; replace it with "seamlessly" so the sentence reads "...update the UI
seamlessly."

- The app should cache all the book's metadata in local database to optimise the app load time, Only the chapters / audio tracks should not be cached automatically / by default. Chapters should be downloaded and cached on demand by the user, using the download chapters/book functionality.
- When the app loads, if the server is not reachable, it shouldn't show long loading screen, trying to fetch the books from the server, it should load the book's list from the local database, however it should only show the books whos' chapters are downloaded and available offline can be played.
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix grammar error: "whose" (no apostrophe).

The possessive form should be "whose", not "whos'".

🔎 Proposed fix
-- When the app loads, if the server is not reachable, it shouldn't show long loading screen, trying to fetch the books from the server, it should load the book's list from the local database, however it should only show the books whos' chapters are downloaded and available offline can be played.
+- When the app loads, if the server is not reachable, it shouldn't show long loading screen, trying to fetch the books from the server, it should load the book's list from the local database, however it should only show the books whose chapters are downloaded and available offline can be played.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- When the app loads, if the server is not reachable, it shouldn't show long loading screen, trying to fetch the books from the server, it should load the book's list from the local database, however it should only show the books whos' chapters are downloaded and available offline can be played.
- When the app loads, if the server is not reachable, it shouldn't show long loading screen, trying to fetch the books from the server, it should load the book's list from the local database, however it should only show the books whose chapters are downloaded and available offline can be played.
🤖 Prompt for AI Agents
In .agent/rules/project-context.md around line 30, fix the grammar by replacing
the incorrect possessive "whos'" with "whose" and adjust surrounding wording for
clarity; update the sentence to read something like: "When the app loads, if the
server is not reachable, it shouldn't show a long loading screen trying to fetch
books from the server; it should load the book list from the local database, and
only show books whose chapters are downloaded and available offline for
playback."

- When the server becomes reachable, it should update the books list, as now all the books can be played from local cahce or online from the server.
- When the network is switched, the app should trigger checking whether the server is still reachable or not, if not reachable, it should update the UI to only show offline available ready to play books.

## General Guidelines and Standards

- **Colors**: Must be referenced from `Color.kt` / `Theme.kt`. Do not use raw hex values (e.g., `0xFF...`) in Composables.
- **Dimensions**: Must use `Spacing.kt` (e.g., `Spacing.md`, `Spacing.lg`) for padding, margins, and layout dimensions.
- **Design System**: Adhere effectively to the spacing system and color palette defined in the project.
- **ABSOLUTELY NO** hardcoded user-facing strings in UI code. All strings must be extracted to `strings.xml` and accessed via `stringResource`.
- Use `associateBy` or proper indexing for collection lookups (O(1)) instead of nested loops (O(N^2)) when synchronizing data.
- Avoid expensive operations on the main thread.
- No code duplication, keep the code clean and easy to maintain.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ google-services.json

# Schemas
app/schemas
.DS_Store
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import javax.inject.Singleton
class LibraryPageResponseConverter
@Inject
constructor() {
fun apply(response: LibraryItemsResponse): PagedItems<Book> =
fun apply(
response: LibraryItemsResponse,
libraryId: String,
): PagedItems<Book> =
response
.results
.mapNotNull {
Expand All @@ -22,6 +25,8 @@ class LibraryPageResponseConverter
series = it.media.metadata.seriesName,
subtitle = it.media.metadata.subtitle,
author = it.media.metadata.authorName,
duration = 0.0,
libraryId = libraryId,
)
}.let {
PagedItems(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ class LibraryAudiobookshelfChannel
pageNumber = pageNumber,
sort = option,
direction = direction,
).map { libraryPageResponseConverter.apply(it) }
).map { libraryPageResponseConverter.apply(it, libraryId) }
}

override suspend fun searchBooks(
Expand All @@ -87,7 +87,7 @@ class LibraryAudiobookshelfChannel
searchResult
.map { it.book }
.map { it.map { response -> response.libraryItem } }
.map { librarySearchItemsConverter.apply(it) }
.map { librarySearchItemsConverter.apply(it, libraryId) }
}

val byAuthor =
Expand All @@ -106,7 +106,7 @@ class LibraryAudiobookshelfChannel
onFailure = { emptyList() },
)
}
}.map { librarySearchItemsConverter.apply(it) }
}.map { librarySearchItemsConverter.apply(it, libraryId) }
}

val bySeries: Deferred<OperationResult<List<Book>>> =
Expand All @@ -125,7 +125,7 @@ class LibraryAudiobookshelfChannel
)
}
}.map { result -> result.map { it.libraryItem } }
.map { result -> result.let { librarySearchItemsConverter.apply(it) } }
.map { result -> result.let { librarySearchItemsConverter.apply(it, libraryId) } }
}

mergeBooks(byTitle, byAuthor, bySeries)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ import org.grakovne.lissen.lib.domain.BookSeries
import org.grakovne.lissen.lib.domain.DetailedItem
import org.grakovne.lissen.lib.domain.MediaProgress
import org.grakovne.lissen.lib.domain.PlayingChapter
import java.time.LocalDate
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import javax.inject.Inject
import javax.inject.Singleton

Expand Down Expand Up @@ -88,7 +91,7 @@ class BookResponseConverter
chapters = maybeChapters ?: filesAsChapters(),
libraryId = item.libraryId,
localProvided = false,
year = item.media.metadata.publishedYear,
year = extractYear(item.media.metadata.publishedYear),
abstract = item.media.metadata.description,
publisher = item.media.metadata.publisher,
series =
Expand All @@ -115,4 +118,28 @@ class BookResponseConverter
},
)
}

private fun extractYear(rawYear: String?): String? {
if (rawYear.isNullOrBlank()) {
return null
}

// 1. If it's explicitly 4 digits, assume it's a year
if (rawYear.matches(Regex("^\\d{4}$"))) {
return rawYear
}

return try {
// 2. Try parsing as ZonedDateTime (ISO 8601 with timezone, e.g. 2010-10-07T07:13:01Z)
ZonedDateTime.parse(rawYear).year.toString()
} catch (e: Exception) {
try {
// 3. Try parsing as LocalDate (yyyy-MM-dd)
LocalDate.parse(rawYear).year.toString()
} catch (e: Exception) {
// 4. Fallback: If it starts with 4 digits, take them
Regex("^(\\d{4})").find(rawYear)?.groupValues?.get(1) ?: rawYear
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,21 @@ import javax.inject.Singleton
class LibrarySearchItemsConverter
@Inject
constructor() {
fun apply(response: List<LibraryItem>) =
response
.mapNotNull {
val title = it.media.metadata.title ?: return@mapNotNull null
fun apply(
response: List<LibraryItem>,
libraryId: String,
) = response
.mapNotNull {
val title = it.media.metadata.title ?: return@mapNotNull null

Book(
id = it.id,
title = title,
series = it.media.metadata.seriesName,
subtitle = it.media.metadata.subtitle,
author = it.media.metadata.authorName,
)
}
Book(
id = it.id,
title = title,
series = it.media.metadata.seriesName,
subtitle = it.media.metadata.subtitle,
author = it.media.metadata.authorName,
duration = 0.0,
libraryId = libraryId,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ class PodcastAudiobookshelfChannel
pageNumber = pageNumber,
sort = option,
direction = direction,
).map { podcastPageResponseConverter.apply(it) }
).map { podcastPageResponseConverter.apply(it, libraryId) }
}

override suspend fun searchBooks(
Expand All @@ -83,7 +83,7 @@ class PodcastAudiobookshelfChannel
.searchPodcasts(libraryId, query, limit)
.map { it.podcast }
.map { it.map { response -> response.libraryItem } }
.map { podcastSearchItemsConverter.apply(it) }
.map { podcastSearchItemsConverter.apply(it, libraryId) }
}

byTitle.await()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import javax.inject.Singleton
class PodcastPageResponseConverter
@Inject
constructor() {
fun apply(response: PodcastItemsResponse): PagedItems<Book> =
fun apply(
response: PodcastItemsResponse,
libraryId: String,
): PagedItems<Book> =
response
.results
.mapNotNull {
Expand All @@ -22,6 +25,8 @@ class PodcastPageResponseConverter
subtitle = null,
series = null,
author = it.media.metadata.author,
duration = 0.0,
libraryId = libraryId,
)
}.let {
PagedItems(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ import javax.inject.Singleton
class PodcastSearchItemsConverter
@Inject
constructor() {
fun apply(response: List<PodcastItem>): List<Book> {
fun apply(
response: List<PodcastItem>,
libraryId: String,
): List<Book> {
return response
.mapNotNull {
val title = it.media.metadata.title ?: return@mapNotNull null
Expand All @@ -20,6 +23,8 @@ class PodcastSearchItemsConverter
subtitle = null,
series = null,
author = it.media.metadata.author,
duration = 0.0,
libraryId = libraryId,
)
}
}
Expand Down
73 changes: 72 additions & 1 deletion app/src/main/kotlin/org/grakovne/lissen/common/NetworkService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,16 @@ import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import org.grakovne.lissen.lib.domain.NetworkType
import org.grakovne.lissen.persistence.preferences.LissenSharedPreferences
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
Expand All @@ -17,29 +26,90 @@ class NetworkService
@Inject
constructor(
@ApplicationContext private val context: Context,
private val preferences: LissenSharedPreferences,
) : RunningComponent {
private val connectivityManager = context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager

private var cachedNetworkHandle: Long? = null
private var cachedSsid: String? = null

private val _networkStatus = MutableStateFlow(false)
val networkStatus: StateFlow<Boolean> = _networkStatus

private val _isServerAvailable = MutableStateFlow(false)
val isServerAvailable: StateFlow<Boolean> = _isServerAvailable

private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())

override fun onCreate() {
val networkRequest =
NetworkRequest
.Builder()
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
.addTransportType(NetworkCapabilities.TRANSPORT_ETHERNET)
.build()

val networkCallback =
object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
checkServerAvailability()
}

override fun onLost(network: Network) {
if (cachedNetworkHandle == network.getNetworkHandle()) {
cachedSsid = null
}
checkServerAvailability()
}

override fun onCapabilitiesChanged(
network: Network,
networkCapabilities: NetworkCapabilities,
) {
checkServerAvailability()
}
}

connectivityManager.registerNetworkCallback(networkRequest, networkCallback)
checkServerAvailability()
}

private var checkJob: Job? = null

private fun checkServerAvailability() {
checkJob?.cancel()
checkJob =
scope.launch {
delay(500)
val isConnectedToInternet = isNetworkAvailable()
_networkStatus.emit(isConnectedToInternet)

if (!isConnectedToInternet) {
_isServerAvailable.emit(false)
return@launch
}

val hostUrl = preferences.getHost()
if (hostUrl.isNullOrBlank()) {
_isServerAvailable.emit(isConnectedToInternet)
return@launch
}

try {
val url = java.net.URL(hostUrl)
val port = if (url.port == -1) url.defaultPort else url.port
val socket = java.net.Socket()
val address = java.net.InetSocketAddress(url.host, port)

socket.connect(address, 2000)
socket.close()
_isServerAvailable.emit(true)
} catch (e: Exception) {
Timber.e(e, "Server reachability check failed for $hostUrl")
_isServerAvailable.emit(false)
}
}
}

fun isNetworkAvailable(): Boolean {
Expand Down Expand Up @@ -70,7 +140,8 @@ class NetworkService

if (!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) return null

val wifiManager = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as android.net.wifi.WifiManager
val wifiManager =
context.applicationContext.getSystemService(Context.WIFI_SERVICE) as android.net.wifi.WifiManager
val wifiInfo = wifiManager.connectionInfo
val ssid = wifiInfo.ssid

Expand Down
Loading