library UI + reader mode

This commit is contained in:
sergeych committed 2026-05-17 00:53:18 +03:00
commit 8f8b466e89
81 files changed
+7502

No files matched your search

+105
View File
@@ -0,0 +1,105 @@
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidApplication)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeHotReload)
}
kotlin {
jvmToolchain(17)
androidTarget {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
jvm()
js {
browser()
binaries.executable()
}
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
browser()
binaries.executable()
}
sourceSets {
androidMain.dependencies {
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.core.ktx)
}
commonMain.dependencies {
implementation(libs.compose.runtime)
implementation(libs.compose.foundation)
implementation(libs.compose.material.icons.extended)
implementation(libs.compose.material3)
implementation(libs.compose.ui)
implementation(libs.compose.components.resources)
implementation(libs.compose.uiToolingPreview)
implementation(libs.androidx.lifecycle.viewmodelCompose)
implementation(libs.androidx.lifecycle.runtimeCompose)
implementation(libs.kotlinx.coroutinesCore)
implementation(projects.shared)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
jvmMain.dependencies {
implementation(compose.desktop.currentOs)
implementation(libs.kotlinx.coroutinesSwing)
}
}
}
android {
namespace = "net.sergeych.toread"
compileSdk = libs.versions.android.compileSdk.get().toInt()
defaultConfig {
applicationId = "net.sergeych.toread"
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1
versionName = "1.0"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
debugImplementation(libs.compose.uiTooling)
}
compose.desktop {
application {
mainClass = "net.sergeych.toread.MainKt"
nativeDistributions {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "net.sergeych.toread"
packageVersion = "1.0.0"
}
}
}
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32"/>
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:exported="true"
android:name=".MainActivity">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="content"/>
<data android:scheme="file"/>
<data android:mimeType="application/x-fictionbook+xml"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="content" android:mimeType="*/*" android:pathPattern=".*\\.fb2"/>
<data android:scheme="content" android:mimeType="*/*" android:pathPattern=".*\\.fb2\\.zip"/>
<data android:scheme="file" android:mimeType="*/*" android:pathPattern=".*\\.fb2"/>
<data android:scheme="file" android:mimeType="*/*" android:pathPattern=".*\\.fb2\\.zip"/>
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,442 @@
package net.sergeych.toread
import android.content.ContentResolver
import android.content.ComponentCallbacks
import android.content.Context
import android.content.Intent
import android.content.res.Configuration
import android.graphics.BitmapFactory
import android.net.Uri
import android.os.Environment
import android.provider.DocumentsContract
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.ReadingStateRecord
import net.sergeych.toread.storage.jdbc.H2LibraryDatabase
import net.sergeych.toread.storage.jdbc.LibraryScanner
import net.sergeych.toread.storage.jdbc.LibraryScanSummary
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
private lateinit var appContext: Context
private var directoryChooser: AndroidLibraryDirectoryChooser? = null
private var pendingOpenBookUri: Uri? = null
interface AndroidLibraryDirectoryChooser {
suspend fun chooseDirectory(): String?
suspend fun ensureExternalFileAccess(): Boolean
}
fun initToreadPlatform(context: Context, chooser: AndroidLibraryDirectoryChooser? = null) {
appContext = context.applicationContext
directoryChooser = chooser
}
actual fun loadDefaultBookBytes(): ByteArray? = null
actual fun decodeBookImage(binary: Fb2Binary): ImageBitmap? =
runCatching {
val bytes = binary.imageBytes()
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
}.getOrNull()
actual fun decodeImageBytes(bytes: ByteArray): ImageBitmap? =
runCatching {
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
}.getOrNull()
actual fun defaultLibraryScanPath(): String? =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)?.absolutePath
?: appContext.getExternalFilesDir(null)?.absolutePath
?: appContext.filesDir.absolutePath
fun rememberPlatformOpenBookIntent(intent: Intent?) {
pendingOpenBookUri = intent
?.takeIf { it.action == Intent.ACTION_VIEW }
?.data
}
actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = withContext(Dispatchers.IO) {
val uri = pendingOpenBookUri ?: return@withContext null
pendingOpenBookUri = null
val bytes = readStorageUriBytes(uri.toString()) ?: return@withContext null
PlatformOpenBookRequest(
id = "platform:${uri}",
displayName = displayNameFor(uri),
bytes = bytes,
)
}
actual suspend fun chooseLibraryScanDirectory(): String? = directoryChooser?.chooseDirectory()
actual suspend fun loadLibraryItems(): List<LibraryItem> = withContext(Dispatchers.IO) {
appendLibraryLog("load library items")
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
}
}
actual suspend fun scanLibrarySubtree(
path: String,
onProgress: (LibraryScanProgress) -> Unit,
): LibraryScanReport = withContext(Dispatchers.IO) {
appendLibraryLog("scan requested path=$path")
if (path.isContentUri()) {
scanLibraryContentTree(Uri.parse(path), onProgress)
} else {
if (path.requiresExternalFileAccess() && directoryChooser?.ensureExternalFileAccess() != true) {
error("All files access is required to scan $path.")
}
openLibraryDatabase().useLibrary { db ->
val summary = LibraryScanner(db, ::appendLibraryLog).scanSubtree(File(path)) {
onProgress(it.toLibraryScanProgress())
}
LibraryScanReport(
scannedFiles = summary.scannedFiles,
importedFiles = summary.importedFiles,
skippedFiles = summary.skippedFiles,
failedFiles = summary.failedFiles,
)
}
}
}
actual suspend fun openLibraryBook(fileId: String): ByteArray? = withContext(Dispatchers.IO) {
appendLibraryLog("open book fileId=$fileId")
openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary null
val bytes = file.storageUri?.let(::readStorageUriBytes)
appendLibraryLog("open book fileId=$fileId bytes=${bytes?.size ?: 0} uri=${file.storageUri}")
bytes
}
}
actual suspend fun deleteLibraryItem(fileId: String): Boolean = withContext(Dispatchers.IO) {
appendLibraryLog("delete fileId=$fileId")
openLibraryDatabase().useLibrary { db ->
val deleted = db.files.delete(fileId)
appendLibraryLog("delete fileId=$fileId deleted=$deleted")
deleted
}
}
actual suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary null
val clusterId = file.bodyClusterId ?: return@useLibrary null
db.readingStates.getForBodyCluster(clusterId)?.anchor?.formatHintsJson?.toReadingPosition()
}
}
actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingPosition) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary
val clusterId = file.bodyClusterId ?: return@useLibrary
db.readingStates.upsert(
ReadingStateRecord(
id = "state-$clusterId",
bodyClusterId = clusterId,
bodyId = file.bodyId,
anchor = ContentAnchor(
progress = position.itemIndex.toDouble(),
formatHintsJson = position.toFormatHintsJson(),
),
updatedAt = System.currentTimeMillis(),
),
)
}
}
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras()
val clusterId = file.bodyClusterId ?: return@useLibrary BookInfoExtras()
val readingPosition = db.readingStates.getForBodyCluster(clusterId)?.anchor?.formatHintsJson?.toReadingPosition()
BookInfoExtras(
bookmarks = db.bookmarks.listForBodyCluster(clusterId).map {
BookmarkInfo(
title = it.title,
selectedText = it.selectedTextSnapshot,
progress = it.anchor.progress,
updatedAt = it.updatedAt,
)
},
notes = db.notes.listForBodyCluster(clusterId).map {
NoteInfo(
text = it.text,
progress = it.anchor.progress,
updatedAt = it.updatedAt,
)
},
lastReadingPosition = readingPosition,
)
}
}
actual suspend fun loadActiveReadingFileId(): String? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(ActiveReadingFileIdFlag)?.takeIf { it.isNotBlank() }
}
}
actual suspend fun saveActiveReadingFileId(fileId: String?) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.setAppFlag(ActiveReadingFileIdFlag, fileId?.takeIf { it.isNotBlank() })
}
}
actual suspend fun loadThemeMode(): ThemeMode = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(ThemeModeFlag)?.let { value ->
runCatching { ThemeMode.valueOf(value) }.getOrNull()
} ?: ThemeMode.SYSTEM
}
}
actual suspend fun saveThemeMode(mode: ThemeMode) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.setAppFlag(ThemeModeFlag, mode.name)
}
}
actual fun isPlatformDarkTheme(): Boolean {
if (!::appContext.isInitialized) return false
val uiMode = appContext.resources.configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK
return uiMode == Configuration.UI_MODE_NIGHT_YES
}
actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit {
if (!::appContext.isInitialized) return {}
val callback = object : ComponentCallbacks {
override fun onConfigurationChanged(newConfig: Configuration) {
val uiMode = newConfig.uiMode and Configuration.UI_MODE_NIGHT_MASK
onChange(uiMode == Configuration.UI_MODE_NIGHT_YES)
}
override fun onLowMemory() = Unit
}
appContext.registerComponentCallbacks(callback)
return { appContext.unregisterComponentCallbacks(callback) }
}
actual fun libraryLogPath(): String? = libraryLogFile().absolutePath
private data class AndroidLibraryDocument(
val uri: Uri,
val name: String,
val sizeBytes: Long?,
val lastModifiedMillis: Long?,
)
private fun scanLibraryContentTree(
rootUri: Uri,
onProgress: (LibraryScanProgress) -> Unit,
): LibraryScanReport =
openLibraryDatabase().useLibrary { db ->
val scanner = LibraryScanner(db, ::appendLibraryLog)
var scanned = 0
var imported = 0
var skipped = 0
var failed = 0
var visited = 0
walkContentTree(
rootUri = rootUri,
onVisited = { name ->
visited += 1
if (visited % 50 == 0) {
onProgress(LibraryScanProgress(scanned, imported, skipped, failed, name))
}
},
) { document ->
scanned += 1
appendLibraryLog("scan document uri=${document.uri} name=${document.name} size=${document.sizeBytes ?: 0}")
onProgress(LibraryScanProgress(scanned, imported, skipped, failed, document.name))
val result = runCatching {
val bytes = appContext.contentResolver.openInputStream(document.uri)?.use { it.readBytes() }
?: error("Could not read ${document.name}")
scanner.importExternalFile(
bytes = bytes,
displayName = document.name,
storageUri = document.uri.toString(),
sizeBytes = document.sizeBytes,
lastModifiedMillis = document.lastModifiedMillis,
)
}
if (result.isSuccess) {
if (result.getOrThrow()) {
imported += 1
appendLibraryLog("scan imported uri=${document.uri}")
} else {
skipped += 1
appendLibraryLog("scan skipped duplicate uri=${document.uri}")
}
} else {
failed += 1
appendLibraryLog("scan failed uri=${document.uri} error=${result.exceptionOrNull()?.message}")
}
onProgress(LibraryScanProgress(scanned, imported, skipped, failed, document.name))
}
LibraryScanReport(
scannedFiles = scanned,
importedFiles = imported,
skippedFiles = skipped,
failedFiles = failed,
)
}
private fun walkContentTree(
rootUri: Uri,
onVisited: (String) -> Unit,
onBookFile: (AndroidLibraryDocument) -> Unit,
) {
val rootDocumentId = DocumentsContract.getTreeDocumentId(rootUri)
val rootDocumentUri = DocumentsContract.buildDocumentUriUsingTree(rootUri, rootDocumentId)
walkContentDocument(rootUri, rootDocumentUri, onVisited, onBookFile)
}
private fun walkContentDocument(
treeUri: Uri,
documentUri: Uri,
onVisited: (String) -> Unit,
onBookFile: (AndroidLibraryDocument) -> Unit,
) {
val childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(
treeUri,
DocumentsContract.getDocumentId(documentUri),
)
val columns = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_SIZE,
DocumentsContract.Document.COLUMN_LAST_MODIFIED,
)
appContext.contentResolver.query(childrenUri, columns, null, null, null)?.use { cursor ->
val idIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DOCUMENT_ID)
val nameIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_DISPLAY_NAME)
val mimeIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_MIME_TYPE)
val sizeIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_SIZE)
val modifiedIndex = cursor.getColumnIndexOrThrow(DocumentsContract.Document.COLUMN_LAST_MODIFIED)
while (cursor.moveToNext()) {
val documentId = cursor.getString(idIndex)
val name = cursor.getString(nameIndex).orEmpty()
val mimeType = cursor.getString(mimeIndex)
val childUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId)
onVisited(name)
if (mimeType == DocumentsContract.Document.MIME_TYPE_DIR) {
walkContentDocument(treeUri, childUri, onVisited, onBookFile)
} else if (name.isSupportedBookFile()) {
onBookFile(
AndroidLibraryDocument(
uri = childUri,
name = name,
sizeBytes = cursor.getNullableLong(sizeIndex),
lastModifiedMillis = cursor.getNullableLong(modifiedIndex),
)
)
}
}
}
}
private fun readStorageUriBytes(storageUri: String): ByteArray? =
if (storageUri.isContentUri()) {
appContext.contentResolver.openInputStream(Uri.parse(storageUri))?.use { it.readBytes() }
} else {
File(storageUri).takeIf { it.isFile }?.readBytes()
}
private fun String.isContentUri(): Boolean =
runCatching { Uri.parse(this).scheme == ContentResolver.SCHEME_CONTENT }.getOrDefault(false)
private fun String.requiresExternalFileAccess(): Boolean {
val externalRoot = Environment.getExternalStorageDirectory()?.absolutePath ?: return false
val appExternalRoot = appContext.getExternalFilesDir(null)?.absolutePath
return startsWith(externalRoot) && (appExternalRoot == null || !startsWith(appExternalRoot))
}
private fun String.isSupportedBookFile(): Boolean =
endsWith(".fb2", ignoreCase = true) || endsWith(".fb2.zip", ignoreCase = true)
private fun displayNameFor(uri: Uri): String =
appContext.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor ->
if (cursor.moveToFirst()) cursor.getString(0) else null
} ?: uri.lastPathSegment ?: uri.toString()
private fun LibraryScanSummary.toLibraryScanProgress(): LibraryScanProgress =
LibraryScanProgress(
scannedFiles = scannedFiles,
importedFiles = importedFiles,
skippedFiles = skippedFiles,
failedFiles = failedFiles,
currentFile = currentFile,
)
private fun android.database.Cursor.getNullableLong(index: Int): Long? =
if (isNull(index)) null else getLong(index)
private fun openLibraryDatabase(): H2LibraryDatabase {
val libraryDir = File(appContext.filesDir, "library")
val dbDir = File(libraryDir, "db")
dbDir.mkdirs()
return H2LibraryDatabase.openFile(File(dbDir, "library").absolutePath)
}
private fun <T> H2LibraryDatabase.useLibrary(block: (H2LibraryDatabase) -> T): T =
try {
block(this)
} finally {
close()
}
private fun appendLibraryLog(message: String) {
val file = libraryLogFile()
file.parentFile?.mkdirs()
file.appendText("${System.currentTimeMillis()} $message\n")
}
private fun libraryLogFile(): File =
File(appContext.filesDir, "logs/toread.log")
private fun ReadingPosition.toFormatHintsJson(): String =
"""{"firstVisibleItemIndex":$itemIndex,"firstVisibleItemScrollOffset":$scrollOffset}"""
private fun String.toReadingPosition(): ReadingPosition? {
val index = Regex(""""firstVisibleItemIndex"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
val offset = Regex(""""firstVisibleItemScrollOffset"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
return if (index != null && offset != null) ReadingPosition(index, offset) else null
}
private const val ActiveReadingFileIdFlag = "active_reading_file_id"
private const val ThemeModeFlag = "theme_mode"
@@ -0,0 +1,141 @@
package net.sergeych.toread
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.Modifier
import androidx.core.view.WindowCompat
import kotlinx.coroutines.CompletableDeferred
class MainActivity : ComponentActivity(), AndroidLibraryDirectoryChooser {
private lateinit var directoryLauncher: ActivityResultLauncher<Uri?>
private lateinit var allFilesAccessLauncher: ActivityResultLauncher<Intent>
private lateinit var readStoragePermissionLauncher: ActivityResultLauncher<String>
private var pendingDirectoryChoice: CompletableDeferred<String?>? = null
private var pendingExternalFileAccess: CompletableDeferred<Boolean>? = null
override fun onCreate(savedInstanceState: Bundle?) {
directoryLauncher = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { uri ->
uri?.let {
runCatching {
contentResolver.takePersistableUriPermission(it, Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
}
pendingDirectoryChoice?.complete(uri?.toString())
pendingDirectoryChoice = null
}
allFilesAccessLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
pendingExternalFileAccess?.complete(hasBroadFileAccess())
pendingExternalFileAccess = null
}
readStoragePermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
pendingExternalFileAccess?.complete(granted)
pendingExternalFileAccess = null
}
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, true)
initToreadPlatform(this, this)
rememberPlatformOpenBookIntent(intent)
setContent {
AndroidAppWindow {
App()
}
}
}
override suspend fun chooseDirectory(): String? {
val result = CompletableDeferred<String?>()
runOnUiThread {
if (pendingDirectoryChoice != null) {
result.complete(null)
} else {
pendingDirectoryChoice = result
directoryLauncher.launch(null)
}
}
return result.await()
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
rememberPlatformOpenBookIntent(intent)
}
override suspend fun ensureExternalFileAccess(): Boolean {
if (hasBroadFileAccess()) return true
val result = CompletableDeferred<Boolean>()
runOnUiThread {
if (pendingExternalFileAccess != null) {
result.complete(false)
return@runOnUiThread
}
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)
} else {
readStoragePermissionLauncher.launch(Manifest.permission.READ_EXTERNAL_STORAGE)
}
}
return result.await()
}
override fun onDestroy() {
pendingDirectoryChoice?.complete(null)
pendingDirectoryChoice = null
pendingExternalFileAccess?.complete(false)
pendingExternalFileAccess = null
super.onDestroy()
}
private fun hasBroadFileAccess(): Boolean =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Environment.isExternalStorageManager()
} else {
checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE) == PackageManager.PERMISSION_GRANTED
}
}
@Composable
private fun AndroidAppWindow(content: @Composable () -> Unit) {
androidx.compose.foundation.layout.Box(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Vertical)),
) {
content()
}
}
@Preview
@Composable
fun AppAndroidPreview() {
App()
}
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0"/>
<item
android:color="#00000000"
android:offset="1.0"/>
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000"/>
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z"/>
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF"/>
</vector>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="@android:style/Theme.Material.NoActionBar">
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
</style>
</resources>
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">toread</string>
</resources>
@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="AppTheme" parent="@android:style/Theme.Material.NoActionBar">
<item name="android:windowActionBar">false</item>
<item name="android:windowNoTitle">true</item>
</style>
</resources>
@@ -0,0 +1,44 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="450dp"
android:height="450dp"
android:viewportWidth="64"
android:viewportHeight="64">
<path
android:pathData="M56.25,18V46L32,60 7.75,46V18L32,4Z"
android:fillColor="#6075f2"/>
<path
android:pathData="m41.5,26.5v11L32,43V60L56.25,46V18Z"
android:fillColor="#6b57ff"/>
<path
android:pathData="m32,43 l-9.5,-5.5v-11L7.75,18V46L32,60Z">
<aapt:attr name="android:fillColor">
<gradient
android:centerX="23.131"
android:centerY="18.441"
android:gradientRadius="42.132"
android:type="radial">
<item android:offset="0" android:color="#FF5383EC"/>
<item android:offset="0.867" android:color="#FF7F52FF"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="M22.5,26.5 L32,21 41.5,26.5 56.25,18 32,4 7.75,18Z">
<aapt:attr name="android:fillColor">
<gradient
android:startX="44.172"
android:startY="4.377"
android:endX="17.973"
android:endY="34.035"
android:type="linear">
<item android:offset="0" android:color="#FF33C3FF"/>
<item android:offset="0.878" android:color="#FF5383EC"/>
</gradient>
</aapt:attr>
</path>
<path
android:pathData="m32,21 l9.526,5.5v11L32,43 22.474,37.5v-11z"
android:fillColor="#000000"/>
</vector>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,109 @@
package net.sergeych.toread
import net.sergeych.toread.fb2.Fb2Binary
import net.sergeych.toread.fb2.Fb2Book
data class LibraryItem(
val fileId: String,
val bookId: String?,
val title: String,
val authors: List<String> = emptyList(),
val language: String? = null,
val date: String? = null,
val format: String?,
val sizeBytes: Long?,
val storageUri: String?,
val lastSeenAt: Long?,
val coverImage: ByteArray? = null,
val coverImageMimeType: String? = null,
)
data class LibraryScanReport(
val scannedFiles: Int,
val importedFiles: Int,
val skippedFiles: Int,
val failedFiles: Int,
)
data class LibraryScanProgress(
val scannedFiles: Int,
val importedFiles: Int,
val skippedFiles: Int,
val failedFiles: Int,
val currentFile: String? = null,
)
data class PlatformOpenBookRequest(
val id: String,
val displayName: String,
val bytes: ByteArray,
)
data class ReadingPosition(
val itemIndex: Int,
val scrollOffset: Int,
)
data class BookInfoExtras(
val bookmarks: List<BookmarkInfo> = emptyList(),
val notes: List<NoteInfo> = emptyList(),
val lastReadingPosition: ReadingPosition? = null,
)
data class BookmarkInfo(
val title: String?,
val selectedText: String?,
val progress: Double?,
val updatedAt: Long,
)
data class NoteInfo(
val text: String,
val progress: Double?,
val updatedAt: Long,
)
enum class ThemeMode {
LIGHT,
DARK,
SYSTEM,
}
expect fun defaultLibraryScanPath(): String?
expect suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest?
expect suspend fun chooseLibraryScanDirectory(): String?
expect suspend fun loadLibraryItems(): List<LibraryItem>
expect suspend fun scanLibrarySubtree(path: String, onProgress: (LibraryScanProgress) -> Unit): LibraryScanReport
expect suspend fun openLibraryBook(fileId: String): ByteArray?
expect suspend fun deleteLibraryItem(fileId: String): Boolean
expect suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition?
expect suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingPosition)
expect suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras
expect suspend fun loadActiveReadingFileId(): String?
expect suspend fun saveActiveReadingFileId(fileId: String?)
expect suspend fun loadThemeMode(): ThemeMode
expect suspend fun saveThemeMode(mode: ThemeMode)
expect fun isPlatformDarkTheme(): Boolean
expect fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit
expect fun libraryLogPath(): String?
internal fun Fb2Book.libraryCoverBinary(): Fb2Binary? {
val image = coverImages.firstOrNull() ?: bodyImages.firstOrNull()
return image?.let(::binaryFor)
}
@@ -0,0 +1,12 @@
package net.sergeych.toread
import kotlin.test.Test
import kotlin.test.assertEquals
class ComposeAppCommonTest {
@Test
fun example() {
assertEquals(3, 1 + 2)
}
}
@@ -0,0 +1,311 @@
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.ContentAnchor
import net.sergeych.toread.storage.ReadingStateRecord
import net.sergeych.toread.storage.jdbc.H2LibraryDatabase
import net.sergeych.toread.storage.jdbc.LibraryScanner
import org.jetbrains.skia.Image
import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.TimeUnit
import javax.swing.JFileChooser
import kotlin.concurrent.thread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
actual fun loadDefaultBookBytes(): ByteArray? {
var current = File(System.getProperty("user.dir")).absoluteFile
while (true) {
val candidate = File(current, "test_books/$DefaultBookFileName")
if (candidate.isFile) return candidate.readBytes()
current = current.parentFile ?: break
}
return null
}
actual fun decodeBookImage(binary: Fb2Binary): ImageBitmap? =
runCatching { Image.makeFromEncoded(binary.imageBytes()).toComposeImageBitmap() }.getOrNull()
actual fun decodeImageBytes(bytes: ByteArray): ImageBitmap? =
runCatching { Image.makeFromEncoded(bytes).toComposeImageBitmap() }.getOrNull()
actual fun defaultLibraryScanPath(): String? = findProjectRoot()?.let { File(it, "test_books").absolutePath }
actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = null
actual suspend fun chooseLibraryScanDirectory(): String? = withContext(Dispatchers.IO) {
val chooser = JFileChooser(defaultLibraryScanPath())
chooser.fileSelectionMode = JFileChooser.DIRECTORIES_ONLY
chooser.dialogTitle = "Choose library folder"
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
chooser.selectedFile.absolutePath
} else {
null
}
}
actual suspend fun loadLibraryItems(): List<LibraryItem> = withContext(Dispatchers.IO) {
appendLibraryLog("load library items")
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
}
}
actual suspend fun scanLibrarySubtree(
path: String,
onProgress: (LibraryScanProgress) -> Unit,
): LibraryScanReport = withContext(Dispatchers.IO) {
appendLibraryLog("scan requested path=$path")
openLibraryDatabase().useLibrary { db ->
val summary = LibraryScanner(db, ::appendLibraryLog).scanSubtree(File(path)) {
onProgress(
LibraryScanProgress(
scannedFiles = it.scannedFiles,
importedFiles = it.importedFiles,
skippedFiles = it.skippedFiles,
failedFiles = it.failedFiles,
currentFile = it.currentFile,
)
)
}
LibraryScanReport(
scannedFiles = summary.scannedFiles,
importedFiles = summary.importedFiles,
skippedFiles = summary.skippedFiles,
failedFiles = summary.failedFiles,
)
}
}
actual suspend fun openLibraryBook(fileId: String): ByteArray? = withContext(Dispatchers.IO) {
appendLibraryLog("open book fileId=$fileId")
openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary null
val bytes = file.storageUri?.let { File(it) }?.takeIf { it.isFile }?.readBytes()
appendLibraryLog("open book fileId=$fileId bytes=${bytes?.size ?: 0} uri=${file.storageUri}")
bytes
}
}
actual suspend fun deleteLibraryItem(fileId: String): Boolean = withContext(Dispatchers.IO) {
appendLibraryLog("delete fileId=$fileId")
openLibraryDatabase().useLibrary { db ->
val deleted = db.files.delete(fileId)
appendLibraryLog("delete fileId=$fileId deleted=$deleted")
deleted
}
}
actual suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary null
val clusterId = file.bodyClusterId ?: return@useLibrary null
db.readingStates.getForBodyCluster(clusterId)?.anchor?.formatHintsJson?.toReadingPosition()
}
}
actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingPosition) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary
val clusterId = file.bodyClusterId ?: return@useLibrary
db.readingStates.upsert(
ReadingStateRecord(
id = "state-$clusterId",
bodyClusterId = clusterId,
bodyId = file.bodyId,
anchor = ContentAnchor(
progress = position.itemIndex.toDouble(),
formatHintsJson = position.toFormatHintsJson(),
),
updatedAt = System.currentTimeMillis(),
),
)
}
}
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
val file = db.files.get(fileId) ?: return@useLibrary BookInfoExtras()
val clusterId = file.bodyClusterId ?: return@useLibrary BookInfoExtras()
val readingPosition = db.readingStates.getForBodyCluster(clusterId)?.anchor?.formatHintsJson?.toReadingPosition()
BookInfoExtras(
bookmarks = db.bookmarks.listForBodyCluster(clusterId).map {
BookmarkInfo(
title = it.title,
selectedText = it.selectedTextSnapshot,
progress = it.anchor.progress,
updatedAt = it.updatedAt,
)
},
notes = db.notes.listForBodyCluster(clusterId).map {
NoteInfo(
text = it.text,
progress = it.anchor.progress,
updatedAt = it.updatedAt,
)
},
lastReadingPosition = readingPosition,
)
}
}
actual suspend fun loadActiveReadingFileId(): String? = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(ActiveReadingFileIdFlag)?.takeIf { it.isNotBlank() }
}
}
actual suspend fun saveActiveReadingFileId(fileId: String?) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.setAppFlag(ActiveReadingFileIdFlag, fileId?.takeIf { it.isNotBlank() })
}
}
actual suspend fun loadThemeMode(): ThemeMode = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.getAppFlag(ThemeModeFlag)?.let { value ->
runCatching { ThemeMode.valueOf(value) }.getOrNull()
} ?: ThemeMode.SYSTEM
}
}
actual suspend fun saveThemeMode(mode: ThemeMode) = withContext(Dispatchers.IO) {
openLibraryDatabase().useLibrary { db ->
db.setAppFlag(ThemeModeFlag, mode.name)
}
}
actual fun isPlatformDarkTheme(): Boolean {
val osName = System.getProperty("os.name").lowercase()
if (!osName.contains("linux")) return false
val gtkTheme = System.getenv("GTK_THEME").orEmpty()
if (gtkTheme.contains("dark", ignoreCase = true)) return true
val colorScheme = runCommand("gsettings", "get", "org.gnome.desktop.interface", "color-scheme")
if (colorScheme?.contains("prefer-dark", ignoreCase = true) == true) return true
val themeName = runCommand("gsettings", "get", "org.gnome.desktop.interface", "gtk-theme")
if (themeName?.contains("dark", ignoreCase = true) == true) return true
val kdeGlobals = File(System.getProperty("user.home"), ".config/kdeglobals")
return runCatching {
kdeGlobals.takeIf { it.isFile }
?.readText()
?.lineSequence()
?.any { line ->
line.startsWith("ColorScheme=", ignoreCase = true) &&
line.contains("dark", ignoreCase = true)
} == true
}.getOrDefault(false)
}
actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit {
val osName = System.getProperty("os.name").lowercase()
if (!osName.contains("linux")) return {}
val running = AtomicBoolean(true)
val worker = thread(name = "toread-theme-watch", isDaemon = true) {
var last = isPlatformDarkTheme()
while (running.get()) {
try {
Thread.sleep(1500)
val current = isPlatformDarkTheme()
if (current != last) {
last = current
onChange(current)
}
} catch (_: InterruptedException) {
return@thread
}
}
}
return {
running.set(false)
worker.interrupt()
}
}
actual fun libraryLogPath(): String? = libraryLogFile().absolutePath
private fun openLibraryDatabase(): H2LibraryDatabase {
val libraryDir = File(System.getProperty("user.home"), ".toread/library")
val dbDir = File(libraryDir, "db")
dbDir.mkdirs()
return H2LibraryDatabase.openFile(File(dbDir, "library").absolutePath)
}
private fun <T> H2LibraryDatabase.useLibrary(block: (H2LibraryDatabase) -> T): T =
try {
block(this)
} finally {
close()
}
private fun findProjectRoot(): File? {
var current = File(System.getProperty("user.dir")).absoluteFile
while (true) {
if (File(current, "test_books").isDirectory) return current
current = current.parentFile ?: break
}
return null
}
private fun appendLibraryLog(message: String) {
val file = libraryLogFile()
file.parentFile?.mkdirs()
file.appendText("${System.currentTimeMillis()} $message\n")
}
private fun libraryLogFile(): File =
File(System.getProperty("user.home"), ".toread/toread.log")
private fun ReadingPosition.toFormatHintsJson(): String =
"""{"firstVisibleItemIndex":$itemIndex,"firstVisibleItemScrollOffset":$scrollOffset}"""
private fun String.toReadingPosition(): ReadingPosition? {
val index = Regex(""""firstVisibleItemIndex"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
val offset = Regex(""""firstVisibleItemScrollOffset"\s*:\s*(\d+)""").find(this)?.groupValues?.getOrNull(1)?.toIntOrNull()
return if (index != null && offset != null) ReadingPosition(index, offset) else null
}
private fun runCommand(vararg command: String): String? =
runCatching {
val process = ProcessBuilder(*command).redirectErrorStream(true).start()
if (!process.waitFor(500, TimeUnit.MILLISECONDS)) {
process.destroy()
null
} else {
process.inputStream.bufferedReader().readText().trim()
}
}.getOrNull()
private const val ActiveReadingFileIdFlag = "active_reading_file_id"
private const val ThemeModeFlag = "theme_mode"
@@ -0,0 +1,13 @@
package net.sergeych.toread
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
fun main() = application {
Window(
onCloseRequest = ::exitApplication,
title = "toread",
) {
App()
}
}
@@ -0,0 +1,58 @@
package net.sergeych.toread
import androidx.compose.ui.graphics.ImageBitmap
import kotlinx.browser.window
import net.sergeych.toread.fb2.Fb2Binary
import org.w3c.dom.events.Event
actual fun loadDefaultBookBytes(): ByteArray? = null
actual fun decodeBookImage(binary: Fb2Binary): ImageBitmap? = null
actual fun decodeImageBytes(bytes: ByteArray): ImageBitmap? = null
actual fun defaultLibraryScanPath(): String? = null
actual suspend fun loadPlatformOpenBookRequest(): PlatformOpenBookRequest? = null
actual suspend fun chooseLibraryScanDirectory(): String? = null
actual suspend fun loadLibraryItems(): List<LibraryItem> = emptyList()
actual suspend fun scanLibrarySubtree(
path: String,
onProgress: (LibraryScanProgress) -> Unit,
): LibraryScanReport =
LibraryScanReport(scannedFiles = 0, importedFiles = 0, skippedFiles = 0, failedFiles = 1)
actual suspend fun openLibraryBook(fileId: String): ByteArray? = null
actual suspend fun deleteLibraryItem(fileId: String): Boolean = false
actual suspend fun loadLibraryReadingPosition(fileId: String): ReadingPosition? = null
actual suspend fun saveLibraryReadingPosition(fileId: String, position: ReadingPosition) = Unit
actual suspend fun loadBookInfoExtras(fileId: String): BookInfoExtras = BookInfoExtras()
actual suspend fun loadActiveReadingFileId(): String? = null
actual suspend fun saveActiveReadingFileId(fileId: String?) = Unit
actual suspend fun loadThemeMode(): ThemeMode = ThemeMode.SYSTEM
actual suspend fun saveThemeMode(mode: ThemeMode) = Unit
actual fun isPlatformDarkTheme(): Boolean =
window.matchMedia("(prefers-color-scheme: dark)").matches
actual fun watchPlatformDarkTheme(onChange: (Boolean) -> Unit): () -> Unit {
val query = window.matchMedia("(prefers-color-scheme: dark)")
val listener: (Event) -> Unit = {
onChange(query.matches)
}
query.addEventListener("change", listener)
return { query.removeEventListener("change", listener) }
}
actual fun libraryLogPath(): String? = null
@@ -0,0 +1,11 @@
package net.sergeych.toread
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.window.ComposeViewport
@OptIn(ExperimentalComposeUiApi::class)
fun main() {
ComposeViewport {
App()
}
}
@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>toread</title>
<link type="text/css" rel="stylesheet" href="styles.css">
</head>
<body style="text-align: center; align-content: center">
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 50 50" role="presentation">
<circle cx="25" cy="25" r="20" stroke="#ccc" stroke-width="4" fill="none"/>
<circle cx="25" cy="25" r="20" stroke="#333" stroke-width="4" fill="none" stroke-linecap="round"
stroke-dasharray="90 125">
<animateTransform attributeName="transform" type="rotate" from="0 25 25" to="360 25 25" dur="1s"
repeatCount="indefinite"/>
</circle>
</svg>
<script type="application/javascript" src="composeApp.js"></script>
</body>
</html>
@@ -0,0 +1,7 @@
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}