library UI + reader mode
No files matched your search
@@ -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>
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
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>
|
||||
@@ -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;
|
||||
}
|
||||