added library database

This commit is contained in:
sergeych committed 2026-05-17 01:26:14 +03:00
1 parent 8f8b466e89
commit 02a40c2589
10 files changed
+383 -100

No files matched your search

@@ -13,8 +13,8 @@ import android.provider.OpenableColumns
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import net.sergeych.toread.fb2.Fb2Binary
import net.sergeych.toread.fb2.Fb2Format
import net.sergeych.toread.storage.ContentAnchor
import net.sergeych.toread.storage.LibraryFileRecord
import net.sergeych.toread.storage.ReadingStateRecord
import net.sergeych.toread.storage.jdbc.H2LibraryDatabase
import net.sergeych.toread.storage.jdbc.LibraryScanner
@@ -75,32 +75,26 @@ actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = wit
actual suspend fun chooseLibraryScanDirectory(): String? = directoryChooser?.chooseDirectory()
actual suspend fun loadLibraryItems(): List<LibraryItem> = withContext(Dispatchers.IO) {
appendLibraryLog("load library items")
loadLibraryItemsPage(Int.MAX_VALUE, 0)
}
actual suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List<LibraryItem> = withContext(Dispatchers.IO) {
appendLibraryLog("load library items page limit=$limit offset=$offset")
openLibraryDatabase().useLibrary { db ->
val items = db.files.list().map { file ->
val book = file.bookId?.let(db.books::get)
val parsed = file.storageUri?.let { uri ->
runCatching {
readStorageUriBytes(uri)?.let { Fb2Format.parse(it, uri) }
}.getOrNull()
}
LibraryItem(
fileId = file.id,
bookId = file.bookId,
title = parsed?.title ?: book?.title ?: file.originalFilename ?: file.id,
authors = parsed?.authors?.mapNotNull { it.displayName.takeIf(String::isNotBlank) }.orEmpty(),
language = parsed?.language ?: book?.language,
date = parsed?.date,
format = file.format,
sizeBytes = file.sizeBytes,
storageUri = file.storageUri,
lastSeenAt = file.lastSeenAt,
coverImage = book?.coverImage ?: parsed?.libraryCoverBinary()?.imageBytes(),
coverImageMimeType = book?.coverImageMimeType ?: parsed?.libraryCoverBinary()?.contentType,
)
}
appendLibraryLog("loaded library items count=${items.size}")
items
db.files.listLibraryFiles(limit, offset).map { it.toLibraryItem() }
}
}
actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.files.getLibraryFile(fileId)?.toLibraryItem()
}
}
actual suspend fun loadLibraryItemCover(fileId: String): LibraryCover? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
val bookId = db.files.get(fileId)?.bookId ?: return@useLibrary null
db.books.getCover(bookId)?.let { LibraryCover(image = it.image, mimeType = it.mimeType) }
}
}
@@ -426,6 +420,20 @@ private fun appendLibraryLog(message: String) {
file.appendText("${System.currentTimeMillis()} $message\n")
}
private fun LibraryFileRecord.toLibraryItem(): LibraryItem =
LibraryItem(
fileId = fileId,
bookId = bookId,
title = title ?: originalFilename ?: fileId,
authors = authors,
language = language,
date = date,
format = format,
sizeBytes = sizeBytes,
storageUri = storageUri,
lastSeenAt = lastSeenAt,
)
private fun libraryLogFile(): File =
File(appContext.filesDir, "logs/toread.log")
@@ -1,6 +1,7 @@
package net.sergeych.toread
import android.Manifest
import android.app.AlertDialog
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
@@ -22,7 +23,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.Modifier
import androidx.core.view.WindowCompat
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.launch
class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
private lateinit var directoryLauncher: ActivityResultLauncher<Uri?>
@@ -64,16 +67,53 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
override suspend fun chooseDirectory(): String? {
val result = CompletableDeferred<String?>()
runOnUiThread {
if (pendingDirectoryChoice != null) {
if (pendingDirectoryChoice != null || pendingExternalFileAccess != null) {
result.complete(null)
} else {
pendingDirectoryChoice = result
directoryLauncher.launch(null)
showDirectoryChoice(result)
}
}
return result.await()
}
private fun showDirectoryChoice(result: CompletableDeferred<String?>) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
AlertDialog.Builder(this)
.setTitle("Choose library folder")
.setItems(arrayOf("Downloads", "Other folder...")) { _, which ->
when (which) {
0 -> chooseDownloadsDirectory(result)
else -> launchSystemDirectoryPicker(result)
}
}
.setNegativeButton(android.R.string.cancel) { _, _ -> result.complete(null) }
.setOnCancelListener { result.complete(null) }
.show()
} else {
launchSystemDirectoryPicker(result)
}
}
private fun launchSystemDirectoryPicker(result: CompletableDeferred<String?>) {
pendingDirectoryChoice = result
directoryLauncher.launch(null)
}
private fun chooseDownloadsDirectory(result: CompletableDeferred<String?>) {
if (hasBroadFileAccess()) {
result.complete(downloadsPath())
return
}
val accessResult = CompletableDeferred<Boolean>()
pendingExternalFileAccess = accessResult
lifecycleScope.launch {
val granted = accessResult.await()
result.complete(if (granted) downloadsPath() else null)
}
launchAllFilesAccessSettings()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
@@ -90,16 +130,7 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
}
pendingExternalFileAccess = result
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val appSettings = Intent(
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
Uri.parse("package:$packageName"),
)
val settingsIntent = if (appSettings.resolveActivity(packageManager) != null) {
appSettings
} else {
Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
}
allFilesAccessLauncher.launch(settingsIntent)
launchAllFilesAccessSettings()
} else {
readStoragePermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
}
@@ -121,6 +152,23 @@ class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
} else {
checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED
}
private fun launchAllFilesAccessSettings() {
val appSettings = Intent(
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
Uri.parse("package:$packageName"),
)
val settingsIntent = if (appSettings.resolveActivity(packageManager) != null) {
appSettings
} else {
Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
}
allFilesAccessLauncher.launch(settingsIntent)
}
private fun downloadsPath(): String =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.absolutePath
?: Environment.getExternalStorageDirectory().absolutePath
}
@Composable
@@ -58,6 +58,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -239,16 +240,42 @@ private fun LibraryScreen(
val scope = rememberCoroutineScope()
var busy by remember { mutableStateOf(false) }
var message by remember(state.message) { mutableStateOf(state.message) }
var items by remember(state.items) { mutableStateOf(state.items) }
var nextOffset by remember(state.items) { mutableStateOf(state.items.size) }
var loadingPage by remember(state.items) { mutableStateOf(false) }
var endReached by remember(state.items) { mutableStateOf(false) }
val coverCache = remember { mutableStateMapOf<String, LibraryCover?>() }
suspend fun loadPage(reset: Boolean = false) {
if (loadingPage) return
loadingPage = true
if (reset) {
items = emptyList()
nextOffset = 0
endReached = false
coverCache.clear()
}
val offset = if (reset) 0 else nextOffset
try {
val page = loadLibraryItemsPage(LibraryPageSize, offset)
items = if (reset) page else items + page
nextOffset = offset + page.size
endReached = page.size < LibraryPageSize
} catch (t: Throwable) {
message = t.message ?: "Could not load library."
endReached = true
} finally {
loadingPage = false
}
}
fun refresh(nextMessage: String? = message) {
scope.launch {
busy = true
try {
onStateChange(loadLibraryState(nextMessage, state.scanPath))
} finally {
busy = false
}
}
message = nextMessage
scope.launch { loadPage(reset = true) }
}
LaunchedEffect(state.scanPath, state.message) {
if (items.isEmpty() && !endReached) loadPage(reset = true)
}
Scaffold(
@@ -259,7 +286,7 @@ private fun LibraryScreen(
containerColor = MaterialTheme.colorScheme.surface,
),
actions = {
IconButton(onClick = { refresh() }, enabled = !busy) {
IconButton(onClick = { refresh() }, enabled = !busy && !loadingPage) {
Icon(Icons.Filled.Refresh, contentDescription = "Refresh library")
}
},
@@ -278,7 +305,11 @@ private fun LibraryScreen(
.background(readerBackground()),
) {
val wide = maxWidth >= 800.dp
if (state.items.isEmpty()) {
if (items.isEmpty() && loadingPage) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator()
}
} else if (items.isEmpty()) {
EmptyLibraryPane(modifier = Modifier.fillMaxSize().padding(if (wide) 24.dp else 14.dp))
} else {
LazyColumn(
@@ -286,9 +317,10 @@ private fun LibraryScreen(
contentPadding = PaddingValues(0.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
items(state.items, key = { it.fileId }) { item ->
items(items, key = { it.fileId }) { item ->
LibraryRow(
item = item,
coverCache = coverCache,
enabled = !busy,
onOpen = {
scope.launch {
@@ -301,12 +333,12 @@ private fun LibraryScreen(
AppState.Reader(
fileId = item.fileId,
book = book,
libraryItems = state.items,
libraryItems = items,
scanPath = state.scanPath,
message = message,
)
}.getOrElse {
AppState.Library(state.items, state.scanPath, it.message ?: "Could not open book.")
AppState.Library(items, state.scanPath, it.message ?: "Could not open book.")
}
onStateChange(next)
} finally {
@@ -319,12 +351,12 @@ private fun LibraryScreen(
busy = true
try {
val deleted = runCatching { deleteLibraryItem(item.fileId) }.getOrDefault(false)
onStateChange(
loadLibraryState(
if (deleted) "Removed ${item.title}." else "Could not remove ${item.title}.",
state.scanPath,
),
)
message = if (deleted) "Removed ${item.title}." else "Could not remove ${item.title}."
if (deleted) {
items = items.filterNot { it.fileId == item.fileId }
coverCache.remove(item.fileId)
nextOffset = (nextOffset - 1).coerceAtLeast(items.size)
}
} finally {
busy = false
}
@@ -332,6 +364,19 @@ private fun LibraryScreen(
},
)
}
if (!endReached) {
item(key = "load-more") {
LaunchedEffect(nextOffset, items.size) {
if (!loadingPage) loadPage()
}
Box(
modifier = Modifier.fillMaxWidth().padding(18.dp),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(modifier = Modifier.width(24.dp).height(24.dp), strokeWidth = 2.dp)
}
}
}
}
}
}
@@ -483,6 +528,7 @@ private fun EmptyLibraryPane(modifier: Modifier = Modifier) {
@Composable
private fun LibraryRow(
item: LibraryItem,
coverCache: MutableMap<String, LibraryCover?>,
enabled: Boolean,
onOpen: () -> Unit,
onDelete: () -> Unit,
@@ -496,7 +542,7 @@ private fun LibraryRow(
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
LibraryCover(item, modifier = Modifier.width(46.dp).aspectRatio(0.68f))
LibraryCover(item, coverCache, modifier = Modifier.width(46.dp).aspectRatio(0.68f))
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
item.title,
@@ -525,9 +571,19 @@ private fun LibraryRow(
}
@Composable
private fun LibraryCover(item: LibraryItem, modifier: Modifier = Modifier.width(54.dp).aspectRatio(0.68f)) {
val bitmap = remember(item.fileId, item.coverImage) {
item.coverImage?.let(::decodeImageBytes)
private fun LibraryCover(
item: LibraryItem,
coverCache: MutableMap<String, LibraryCover?>,
modifier: Modifier = Modifier.width(54.dp).aspectRatio(0.68f),
) {
LaunchedEffect(item.fileId) {
if (!coverCache.containsKey(item.fileId)) {
coverCache[item.fileId] = loadLibraryItemCover(item.fileId)
}
}
val cover = coverCache[item.fileId]
val bitmap = remember(item.fileId, cover?.image) {
cover?.image?.let(::decodeImageBytes)
}
Box(
modifier = modifier
@@ -1283,7 +1339,7 @@ private suspend fun loadStartupState(): AppState {
}
}
val activeFileId = loadActiveReadingFileId() ?: return library
val item = library.items.firstOrNull { it.fileId == activeFileId }
val item = loadLibraryItem(activeFileId)
if (item == null) {
saveActiveReadingFileId(null)
return library
@@ -1306,7 +1362,7 @@ private suspend fun loadStartupState(): AppState {
private suspend fun loadLibraryState(message: String? = null, scanPath: String? = null): AppState =
runCatching {
AppState.Library(
items = loadLibraryItems(),
items = emptyList(),
scanPath = scanPath ?: defaultLibraryScanPath().orEmpty(),
message = message,
)
@@ -1387,6 +1443,8 @@ fun Fb2Binary.imageBytes(): ByteArray = Base64.Default.decode(base64)
const val DefaultBookFileName: String = "Maraini_Zapiski-Terezy-Numy.G7vc8A.872381.fb2.zip"
private const val LibraryPageSize: Int = 50
expect fun loadDefaultBookBytes(): ByteArray?
expect fun decodeBookImage(binary: Fb2Binary): ImageBitmap?
@@ -18,6 +18,11 @@ data class LibraryItem(
val coverImageMimeType: String? = null,
)
data class LibraryCover(
val image: ByteArray,
val mimeType: String?,
)
data class LibraryScanReport(
val scannedFiles: Int,
val importedFiles: Int,
@@ -77,6 +82,12 @@ expect suspend fun chooseLibraryScanDirectory(): String?
expect suspend fun loadLibraryItems(): List<LibraryItem>
expect suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List<LibraryItem>
expect suspend fun loadLibraryItem(fileId: String): LibraryItem?
expect suspend fun loadLibraryItemCover(fileId: String): LibraryCover?
expect suspend fun scanLibrarySubtree(path: String, onProgress: (LibraryScanProgress) -> Unit): LibraryScanReport
expect suspend fun openLibraryBook(fileId: String): ByteArray?
@@ -3,7 +3,7 @@ package net.sergeych.toread
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import net.sergeych.toread.fb2.Fb2Binary
import net.sergeych.toread.fb2.Fb2Format
import net.sergeych.toread.storage.LibraryFileRecord
import net.sergeych.toread.storage.ContentAnchor
import net.sergeych.toread.storage.ReadingStateRecord
import net.sergeych.toread.storage.jdbc.H2LibraryDatabase
@@ -49,32 +49,26 @@ actual suspend fun chooseLibraryScanDirectory(): String? = withContext(Dispatche
}
actual suspend fun loadLibraryItems(): List<LibraryItem> = withContext(Dispatchers.IO) {
appendLibraryLog("load library items")
loadLibraryItemsPage(Int.MAX_VALUE, 0)
}
actual suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List<LibraryItem> = withContext(Dispatchers.IO) {
appendLibraryLog("load library items page limit=$limit offset=$offset")
openLibraryDatabase().useLibrary { db ->
val items = db.files.list().map { file ->
val book = file.bookId?.let(db.books::get)
val parsed = file.storageUri?.let { uri ->
runCatching {
File(uri).takeIf { it.isFile }?.readBytes()?.let { Fb2Format.parse(it, uri) }
}.getOrNull()
}
LibraryItem(
fileId = file.id,
bookId = file.bookId,
title = parsed?.title ?: book?.title ?: file.originalFilename ?: file.id,
authors = parsed?.authors?.mapNotNull { it.displayName.takeIf(String::isNotBlank) }.orEmpty(),
language = parsed?.language ?: book?.language,
date = parsed?.date,
format = file.format,
sizeBytes = file.sizeBytes,
storageUri = file.storageUri,
lastSeenAt = file.lastSeenAt,
coverImage = book?.coverImage ?: parsed?.libraryCoverBinary()?.imageBytes(),
coverImageMimeType = book?.coverImageMimeType ?: parsed?.libraryCoverBinary()?.contentType,
)
}
appendLibraryLog("loaded library items count=${items.size}")
items
db.files.listLibraryFiles(limit, offset).map { it.toLibraryItem() }
}
}
actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.files.getLibraryFile(fileId)?.toLibraryItem()
}
}
actual suspend fun loadLibraryItemCover(fileId: String): LibraryCover? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
val bookId = db.files.get(fileId)?.bookId ?: return@useLibrary null
db.books.getCover(bookId)?.let { LibraryCover(image = it.image, mimeType = it.mimeType) }
}
}
@@ -284,6 +278,20 @@ private fun appendLibraryLog(message: String) {
file.appendText("${System.currentTimeMillis()} $message\n")
}
private fun LibraryFileRecord.toLibraryItem(): LibraryItem =
LibraryItem(
fileId = fileId,
bookId = bookId,
title = title ?: originalFilename ?: fileId,
authors = authors,
language = language,
date = date,
format = format,
sizeBytes = sizeBytes,
storageUri = storageUri,
lastSeenAt = lastSeenAt,
)
private fun libraryLogFile(): File =
File(System.getProperty("user.home"), ".toread/toread.log")
@@ -19,6 +19,12 @@ actual suspend fun chooseLibraryScanDirectory(): String? = null
actual suspend fun loadLibraryItems(): List<LibraryItem> = emptyList()
actual suspend fun loadLibraryItemsPage(limit: Int, offset: Int): List<LibraryItem> = emptyList()
actual suspend fun loadLibraryItem(fileId: String): LibraryItem? = null
actual suspend fun loadLibraryItemCover(fileId: String): LibraryCover? = null
actual suspend fun scanLibrarySubtree(
path: String,
onProgress: (LibraryScanProgress) -> Unit,