unpacked css and html working
This commit is contained in:
parent
adcee47523
commit
1d3daf5c8c
@ -16,7 +16,8 @@
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Epook">
|
||||
android:theme="@style/Theme.Epook"
|
||||
android:requestLegacyExternalStorage="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
|
||||
@ -5,341 +5,167 @@ import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Delete
|
||||
import androidx.compose.material.icons.automirrored.filled.MenuBook
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import inhale.rip.epook.data.Book
|
||||
import inhale.rip.epook.ui.theme.EpookTheme
|
||||
import nl.siegmann.epublib.epub.EpubReader
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import androidx.navigation.NavController
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import inhale.rip.epook.data.BookStore
|
||||
import kotlinx.coroutines.launch
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.navArgument
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.navigation.NavType
|
||||
import coil.compose.AsyncImage
|
||||
import inhale.rip.epook.data.Book
|
||||
import inhale.rip.epook.data.BookStore
|
||||
import inhale.rip.epook.ui.theme.EpookTheme
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.grid.rememberLazyGridState
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.ElevatedCard
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.LargeTopAppBar
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shadow
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.input.pointer.PointerInputScope
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
// import androidx.navigation.compose.NavHostController
|
||||
import inhale.rip.epook.data.SettingsStore
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
|
||||
// Add these imports at the top
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
// import androidx.compose.ui.text.style.TextAlign
|
||||
|
||||
// Add sealed class for navigation routes
|
||||
sealed class Screen(val route: String) {
|
||||
object BookList : Screen("bookList")
|
||||
object Settings : Screen("settings")
|
||||
object Reader : Screen("reader/{bookId}") {
|
||||
fun createRoute(bookId: String) = "reader/$bookId"
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
class MainActivity : ComponentActivity() {
|
||||
private lateinit var bookStore: BookStore
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
bookStore = BookStore(this)
|
||||
|
||||
setContent {
|
||||
App()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun App() {
|
||||
val navController = rememberNavController()
|
||||
val context = LocalContext.current
|
||||
val settingsStore = remember(context) { SettingsStore(context) }
|
||||
|
||||
EpookTheme {
|
||||
NavHost(navController = navController, startDestination = Screen.BookList.route) {
|
||||
composable(Screen.BookList.route) {
|
||||
BookshelfScreen(
|
||||
onBookClick = { bookId ->
|
||||
navController.navigate(Screen.Reader.createRoute(bookId))
|
||||
val navController = rememberNavController()
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
EpookTheme {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "main"
|
||||
) {
|
||||
composable("main") {
|
||||
MainScreen(
|
||||
navController = navController,
|
||||
bookStore = bookStore,
|
||||
snackbarHostState = snackbarHostState,
|
||||
scope = scope
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Screen.Reader.route,
|
||||
arguments = listOf(navArgument("bookId") { type = NavType.StringType })
|
||||
) { backStackEntry ->
|
||||
val bookId = backStackEntry.arguments?.getString("bookId") ?: ""
|
||||
ReaderScreen(
|
||||
bookId = bookId,
|
||||
onNavigateBack = { navController.navigateUp() }
|
||||
)
|
||||
}
|
||||
composable(Screen.Settings.route) {
|
||||
SettingsScreen(
|
||||
onNavigateBack = { navController.navigateUp() },
|
||||
settingsStore = settingsStore
|
||||
)
|
||||
|
||||
composable(
|
||||
route = "reader/{bookId}",
|
||||
arguments = listOf(
|
||||
navArgument("bookId") { type = NavType.StringType }
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val bookId = backStackEntry.arguments?.getString("bookId")
|
||||
?: return@composable
|
||||
ReaderScreen(
|
||||
bookId = bookId,
|
||||
onNavigateBack = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun BookshelfScreen(onBookClick: (String) -> Unit) {
|
||||
private fun MainScreen(
|
||||
navController: NavController,
|
||||
bookStore: BookStore,
|
||||
snackbarHostState: SnackbarHostState,
|
||||
scope: CoroutineScope
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val bookStore = remember { BookStore(context) }
|
||||
val books by bookStore.getAllBooks().collectAsState(initial = emptyList())
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
var bookToDelete by remember { mutableStateOf<Book?>(null) }
|
||||
var showEmptyState by remember { mutableStateOf(false) }
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent()
|
||||
) { uri: Uri? ->
|
||||
uri?.let { selectedUri ->
|
||||
fun importBook(uri: Uri, scope: CoroutineScope) {
|
||||
scope.launch {
|
||||
try {
|
||||
val inputStream = context.contentResolver.openInputStream(selectedUri)
|
||||
val book = EpubReader().readEpub(inputStream)
|
||||
|
||||
val bookId = java.util.UUID.randomUUID().toString()
|
||||
|
||||
val epubFile = File(context.filesDir, "book_$bookId.epub")
|
||||
context.contentResolver.openInputStream(selectedUri)?.use { input ->
|
||||
FileOutputStream(epubFile).use { output ->
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
?: throw IOException("Failed to open input stream")
|
||||
|
||||
val tempFile = File(context.filesDir, "temp_${System.currentTimeMillis()}.epub")
|
||||
inputStream.use { input ->
|
||||
tempFile.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
val coverFile = book.coverImage?.let { coverImage ->
|
||||
File(context.filesDir, "cover_$bookId.jpg").also { file ->
|
||||
FileOutputStream(file).use { output ->
|
||||
output.write(coverImage.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
bookStore.addBook(Book(
|
||||
id = bookId,
|
||||
title = book.title ?: "Unknown Title",
|
||||
coverImageFile = coverFile,
|
||||
filePath = epubFile.absolutePath
|
||||
))
|
||||
try {
|
||||
val bookId = bookStore.importBook(tempFile)
|
||||
Timber.d("Successfully imported book with ID: $bookId")
|
||||
navController.navigate("reader/$bookId")
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
println("Error saving book: ${e.message}")
|
||||
e.printStackTrace()
|
||||
Timber.e(e, "Error importing book")
|
||||
scope.launch {
|
||||
snackbarHostState.showSnackbar(
|
||||
message = "Failed to import book: ${e.localizedMessage}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(books) {
|
||||
showEmptyState = books.isEmpty()
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent()
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
importBook(uri, scope)
|
||||
}
|
||||
}
|
||||
|
||||
if (showDeleteDialog && bookToDelete != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Delete,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.error
|
||||
)
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
"Delete Book",
|
||||
style = MaterialTheme.typography.headlineSmall
|
||||
)
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
"Are you sure you want to delete '${bookToDelete?.title}'? This action cannot be undone.",
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
bookToDelete?.let { bookStore.deleteBook(it.id) }
|
||||
showDeleteDialog = false
|
||||
bookToDelete = null
|
||||
}
|
||||
},
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = MaterialTheme.colorScheme.error
|
||||
)
|
||||
) {
|
||||
Text("Delete")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
OutlinedButton(onClick = { showDeleteDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
LargeTopAppBar(
|
||||
title = {
|
||||
Column {
|
||||
Text(
|
||||
"Library",
|
||||
style = MaterialTheme.typography.headlineLarge
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = !showEmptyState,
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
Text(
|
||||
"${books.size} book${if (books.size != 1) "s" else ""}",
|
||||
style = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = TopAppBarDefaults.largeTopAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
scrolledContainerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
},
|
||||
snackbarHost = { SnackbarHost(snackbarHostState) },
|
||||
floatingActionButton = {
|
||||
FloatingActionButton(
|
||||
onClick = { launcher.launch("application/epub+zip") },
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
onClick = { launcher.launch("application/epub+zip") }
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add book")
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = "Add Book"
|
||||
)
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 160.dp),
|
||||
contentPadding = padding,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
if (showEmptyState) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(32.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.MenuBook,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(96.dp),
|
||||
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
"Your library is empty",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
"Tap the + button to add your first book",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f),
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 160.dp),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) {
|
||||
items(
|
||||
items = books,
|
||||
key = { it.id }
|
||||
) { book ->
|
||||
BookCard(
|
||||
book = book,
|
||||
onClick = { onBookClick(book.id) },
|
||||
onLongClick = {
|
||||
bookToDelete = book
|
||||
showDeleteDialog = true
|
||||
},
|
||||
modifier = Modifier.animateItemPlacement()
|
||||
)
|
||||
items(books) { book ->
|
||||
BookCard(
|
||||
book = book,
|
||||
onClick = { navController.navigate("reader/${book.id}") },
|
||||
onLongClick = {
|
||||
scope.launch {
|
||||
bookStore.deleteBook(book.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -373,7 +199,7 @@ private fun BookCard(
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
AsyncImage(
|
||||
model = book.coverImageFile ?: R.drawable.ic_launcher_background,
|
||||
model = book.coverPath ?: R.drawable.ic_launcher_background,
|
||||
contentDescription = book.title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
@ -385,13 +211,23 @@ private fun BookCard(
|
||||
.align(Alignment.BottomCenter),
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f)
|
||||
) {
|
||||
Text(
|
||||
text = book.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.padding(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = book.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = book.author,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,6 +44,11 @@ import java.io.FileInputStream
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import inhale.rip.epook.data.Book
|
||||
import nl.siegmann.epublib.domain.Book as EpubBook
|
||||
import java.io.File
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebResourceResponse
|
||||
import android.webkit.WebResourceError
|
||||
import android.webkit.WebSettings
|
||||
|
||||
// Move the Chapter data class outside the composable
|
||||
private data class Chapter(
|
||||
@ -197,190 +202,103 @@ fun ReaderScreen(
|
||||
)
|
||||
},
|
||||
factory = { context ->
|
||||
var extractedPath: String? = null
|
||||
|
||||
WebView(context).apply {
|
||||
webViewClient = WebViewClient()
|
||||
this.settings.apply {
|
||||
webViewClient = object : WebViewClient() {
|
||||
override fun onReceivedError(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest?,
|
||||
error: WebResourceError?
|
||||
) {
|
||||
Timber.e("WebView error loading ${request?.url}: ${error?.description}")
|
||||
super.onReceivedError(view, request, error)
|
||||
}
|
||||
|
||||
override fun shouldInterceptRequest(
|
||||
view: WebView?,
|
||||
request: WebResourceRequest
|
||||
): WebResourceResponse? {
|
||||
val url = request.url.toString()
|
||||
Timber.d("Loading resource: $url")
|
||||
|
||||
// Try to load the resource from the extracted directory
|
||||
try {
|
||||
if (url.endsWith(".css") && extractedPath != null) {
|
||||
// Try multiple possible CSS file locations
|
||||
val possiblePaths = listOf(
|
||||
url.substringAfterLast("/"), // stylesheet.css
|
||||
"Styles/${url.substringAfterLast("/")}", // Styles/stylesheet.css
|
||||
url.substringAfter("/extracted/") // Text/Styles/stylesheet.css
|
||||
)
|
||||
|
||||
for (cssPath in possiblePaths) {
|
||||
val cssFile = File(File(extractedPath!!), cssPath)
|
||||
if (cssFile.exists()) {
|
||||
Timber.d("Found CSS file at: ${cssFile.absolutePath}")
|
||||
return WebResourceResponse(
|
||||
"text/css",
|
||||
"UTF-8",
|
||||
cssFile.inputStream()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Timber.e("CSS file not found. Tried paths: ${possiblePaths.joinToString()}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error intercepting resource request")
|
||||
}
|
||||
|
||||
return super.shouldInterceptRequest(view, request)
|
||||
}
|
||||
}
|
||||
|
||||
settings.apply {
|
||||
javaScriptEnabled = true
|
||||
builtInZoomControls = true
|
||||
displayZoomControls = false
|
||||
allowFileAccess = true
|
||||
allowFileAccessFromFileURLs = true
|
||||
allowUniversalAccessFromFileURLs = true
|
||||
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
|
||||
domStorageEnabled = true
|
||||
|
||||
// Remove deprecated calls
|
||||
// setAppCacheEnabled and setAppCachePath are deprecated
|
||||
cacheMode = WebSettings.LOAD_DEFAULT
|
||||
}
|
||||
}.also { webView ->
|
||||
// Update extractedPath when loading URLs
|
||||
scope.launch {
|
||||
try {
|
||||
extractedPath = bookStore.getBook(bookId)?.extractedPath
|
||||
?: throw IllegalStateException("Book not properly extracted")
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error getting extracted path")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
update = { webView ->
|
||||
val chapter = chapters.getOrNull(currentChapterIndex)?.resource
|
||||
chapter?.let { resource ->
|
||||
val html = """
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=${currentSettings.fontFamily.replace(" ", "+")}&display=swap');
|
||||
|
||||
:root {
|
||||
--text-color: #${textColor.toString(16)};
|
||||
--bg-color: #${backgroundColor.toString(16)};
|
||||
--accent-color: #${primaryColor.toString(16)};
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
font-kerning: normal;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: '${currentSettings.fontFamily}', system-ui, -apple-system, serif;
|
||||
font-size: ${currentSettings.fontSize}px;
|
||||
line-height: ${currentSettings.lineHeight};
|
||||
padding: ${currentSettings.padding}px max(${currentSettings.padding}px, env(safe-area-inset-right)) ${currentSettings.padding}px max(${currentSettings.padding}px, env(safe-area-inset-left));
|
||||
margin: 0 auto;
|
||||
max-width: min(100%, 45em);
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
text-align: justify;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
font-feature-settings: "kern" 1, "liga" 1, "clig" 1, "calt" 1;
|
||||
hyphens: auto;
|
||||
-webkit-hyphens: auto;
|
||||
word-wrap: break-word;
|
||||
hanging-punctuation: first;
|
||||
padding-bottom: calc(${currentSettings.padding}px + 100px);
|
||||
}
|
||||
|
||||
#content-wrapper {
|
||||
min-height: 100vh;
|
||||
padding-bottom: 80px;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
text-indent: 1.5em;
|
||||
min-height: 1.5em;
|
||||
orphans: 2;
|
||||
widows: 2;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
line-height: 1.2;
|
||||
margin: 2em 0 1em 0;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
margin: 1.5em auto;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 1.5em 0;
|
||||
padding: 0.5em 1em;
|
||||
border-left: 3px solid var(--text-color);
|
||||
opacity: 0.8;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 2em auto;
|
||||
width: 50%;
|
||||
border: none;
|
||||
border-top: 1px solid var(--text-color);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
margin: 1.5em 0;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.75em;
|
||||
border: 1px solid var(--text-color);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--accent-color);
|
||||
opacity: 0.9;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* First paragraph after headings shouldn't be indented */
|
||||
h1 + p, h2 + p, h3 + p, h4 + p, h5 + p, h6 + p,
|
||||
blockquote p, li p {
|
||||
text-indent: 0;
|
||||
}
|
||||
|
||||
/* Lists styling */
|
||||
ul, ol {
|
||||
padding-left: 1.5em;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
li {
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
/* Small caps and dropcaps support */
|
||||
.smallcaps {
|
||||
font-variant: small-caps;
|
||||
}
|
||||
|
||||
.dropcap {
|
||||
float: left;
|
||||
font-size: 3em;
|
||||
line-height: 1;
|
||||
margin: 0 0.1em 0 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Poetry/verse support */
|
||||
.verse {
|
||||
white-space: pre-line;
|
||||
text-align: left;
|
||||
text-indent: 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Footnotes */
|
||||
.footnote {
|
||||
font-size: 0.9em;
|
||||
opacity: 0.8;
|
||||
border-top: 1px solid var(--text-color);
|
||||
margin-top: 2em;
|
||||
padding-top: 1em;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="content-wrapper">
|
||||
${String(resource.data, Charset.defaultCharset())}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
""".trimIndent()
|
||||
webView.loadDataWithBaseURL(
|
||||
"file:///android_asset/",
|
||||
html,
|
||||
"text/html",
|
||||
"UTF-8",
|
||||
null
|
||||
)
|
||||
scope.launch {
|
||||
try {
|
||||
val extractedPath = bookStore.getBook(bookId)?.extractedPath
|
||||
?: throw IllegalStateException("Book not properly extracted")
|
||||
|
||||
val baseUrl = "file://$extractedPath/"
|
||||
val fullUrl = baseUrl + resource.href.replace("../", "")
|
||||
|
||||
Timber.d("Loading chapter from: $fullUrl")
|
||||
Timber.d("Base URL: $baseUrl")
|
||||
|
||||
webView.loadUrl(fullUrl)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error loading chapter")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@ -1,10 +1,15 @@
|
||||
package inhale.rip.epook.data
|
||||
|
||||
import java.io.File
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "books")
|
||||
data class Book(
|
||||
val id: String,
|
||||
@PrimaryKey val id: String,
|
||||
val title: String,
|
||||
val coverImageFile: File? = null,
|
||||
val filePath: String
|
||||
val author: String,
|
||||
val path: String,
|
||||
val extractedPath: String,
|
||||
val coverPath: String?
|
||||
)
|
||||
@ -13,6 +13,9 @@ import nl.siegmann.epublib.domain.Book as EpubBook
|
||||
import nl.siegmann.epublib.epub.EpubReader
|
||||
import java.io.FileInputStream
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import org.jsoup.Jsoup
|
||||
import java.nio.charset.Charset
|
||||
|
||||
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "books")
|
||||
|
||||
@ -26,13 +29,7 @@ class BookStore(private val context: Context) {
|
||||
}
|
||||
|
||||
suspend fun addBook(book: Book) {
|
||||
bookDao.insertBook(BookEntity(
|
||||
id = book.id,
|
||||
title = book.title,
|
||||
coverImagePath = book.coverImageFile?.absolutePath,
|
||||
filePath = book.filePath,
|
||||
readingPosition = 0
|
||||
))
|
||||
bookDao.insert(book.toEntity())
|
||||
}
|
||||
|
||||
suspend fun updateReadingPosition(bookId: String, position: Int) {
|
||||
@ -59,55 +56,155 @@ class BookStore(private val context: Context) {
|
||||
}
|
||||
|
||||
suspend fun deleteBook(bookId: String) {
|
||||
Log.e(TAG, "🔥 DELETE BOOK STARTED 🔥")
|
||||
Log.e(TAG, "Attempting to delete book with ID: $bookId")
|
||||
|
||||
try {
|
||||
val bookEntity = bookDao.getBook(bookId)
|
||||
Log.e(TAG, if (bookEntity != null) {
|
||||
"Found book in database: ${bookEntity.title} (ID: ${bookEntity.id})"
|
||||
} else {
|
||||
"Book not found in database with ID: $bookId"
|
||||
})
|
||||
|
||||
if (bookEntity != null) {
|
||||
try {
|
||||
bookDao.deleteBook(bookEntity)
|
||||
Log.e(TAG, "Successfully deleted book from Room database")
|
||||
|
||||
// Delete associated files
|
||||
val bookFile = File(bookEntity.filePath)
|
||||
if (bookFile.exists() && bookFile.delete()) {
|
||||
Log.e(TAG, "Successfully deleted book file")
|
||||
}
|
||||
|
||||
bookEntity.coverImagePath?.let { coverPath ->
|
||||
val coverFile = File(coverPath)
|
||||
if (coverFile.exists() && coverFile.delete()) {
|
||||
Log.e(TAG, "Successfully deleted cover image")
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error deleting book files", e)
|
||||
// Delete the book directory containing the EPUB and extracted contents
|
||||
val bookDirectory = File(context.filesDir, "books/$bookId")
|
||||
if (bookDirectory.exists()) {
|
||||
bookDirectory.deleteRecursively()
|
||||
Timber.d("Deleted book directory: ${bookDirectory.absolutePath}")
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
bookDao.deleteBook(bookEntity)
|
||||
Timber.d("Deleted book from database: ${bookEntity.title}")
|
||||
}
|
||||
|
||||
Log.e(TAG, "🔥 DELETE BOOK COMPLETED SUCCESSFULLY 🔥")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "🔥 DELETE BOOK FAILED 🔥")
|
||||
Log.e(TAG, "Error type: ${e.javaClass.simpleName}")
|
||||
Log.e(TAG, "Error message: ${e.message}")
|
||||
e.printStackTrace()
|
||||
Timber.e(e, "Error deleting book")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getBook(bookId: String): EpubBook {
|
||||
val bookEntity = bookDao.getBook(bookId) ?: throw IllegalArgumentException("Book not found")
|
||||
return EpubReader().readEpub(FileInputStream(bookEntity.filePath))
|
||||
suspend fun getBook(bookId: String): Book? {
|
||||
return bookDao.getBook(bookId)?.toBook()
|
||||
}
|
||||
|
||||
suspend fun getBookPath(bookId: String): String {
|
||||
return bookDao.getBook(bookId)?.filePath
|
||||
?: throw IllegalArgumentException("Book not found")
|
||||
}
|
||||
|
||||
private fun extractEpub(book: EpubBook, baseDirectory: File) {
|
||||
try {
|
||||
// First extract CSS files
|
||||
book.resources.all
|
||||
.filter { resource ->
|
||||
resource.mediaType?.toString()?.contains("css") ?: false
|
||||
}
|
||||
.forEach { resource ->
|
||||
val cssPath = resource.href
|
||||
.replace("../", "")
|
||||
.replace("./", "")
|
||||
|
||||
val resourceFile = File(baseDirectory, cssPath)
|
||||
resourceFile.parentFile?.mkdirs()
|
||||
resourceFile.writeBytes(resource.data)
|
||||
|
||||
Timber.d("""
|
||||
CSS File Extracted:
|
||||
- Original href: ${resource.href}
|
||||
- Final path: $cssPath
|
||||
- Full path: ${resourceFile.absolutePath}
|
||||
- Size: ${resourceFile.length()}
|
||||
- Exists: ${resourceFile.exists()}
|
||||
- Content sample: ${String(resource.data.take(100).toByteArray())}
|
||||
""".trimIndent())
|
||||
}
|
||||
|
||||
// Then extract HTML files
|
||||
book.resources.all
|
||||
.filter { resource ->
|
||||
!(resource.mediaType?.toString()?.contains("css") ?: false)
|
||||
}
|
||||
.forEach { resource ->
|
||||
val resourcePath = resource.href.replace("../", "").replace("./", "")
|
||||
val resourceFile = File(baseDirectory, resourcePath)
|
||||
resourceFile.parentFile?.mkdirs()
|
||||
|
||||
if (resource.mediaType?.toString()?.contains("html") == true) {
|
||||
val content = String(resource.data, Charset.defaultCharset())
|
||||
|
||||
// Parse with XML parser to maintain proper XHTML structure
|
||||
val doc = Jsoup.parse(content, "", org.jsoup.parser.Parser.xmlParser())
|
||||
doc.outputSettings()
|
||||
.syntax(org.jsoup.nodes.Document.OutputSettings.Syntax.xml)
|
||||
.escapeMode(org.jsoup.nodes.Entities.EscapeMode.xhtml)
|
||||
.prettyPrint(false)
|
||||
|
||||
// Ensure head section exists
|
||||
var head = doc.head()
|
||||
if (head == null) {
|
||||
head = doc.createElement("head")
|
||||
doc.prependChild(head)
|
||||
}
|
||||
|
||||
// Process CSS links
|
||||
doc.select("link[rel=stylesheet]").forEach { link ->
|
||||
val cssHref = link.attr("href")
|
||||
.replace("../", "")
|
||||
.replace("./", "")
|
||||
|
||||
// Create a properly formatted self-closing link tag
|
||||
link.tagName("link")
|
||||
.attr("rel", "stylesheet")
|
||||
.attr("type", "text/css")
|
||||
.attr("href", cssHref)
|
||||
}
|
||||
|
||||
// Write properly formatted XHTML
|
||||
val xhtml = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN" "http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
|
||||
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
${doc.head().outerHtml()}
|
||||
${doc.body().outerHtml()}
|
||||
</html>""".trimIndent()
|
||||
|
||||
resourceFile.writeText(xhtml)
|
||||
} else {
|
||||
resourceFile.writeBytes(resource.data)
|
||||
}
|
||||
}
|
||||
|
||||
// Set permissions
|
||||
baseDirectory.walk().forEach { file ->
|
||||
file.setReadable(true, false)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "Error extracting EPUB: ${e.message}")
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun importBook(sourceFile: File): String {
|
||||
val bookId = UUID.randomUUID().toString()
|
||||
val epubBook = EpubReader().readEpub(FileInputStream(sourceFile))
|
||||
|
||||
// Create book directory
|
||||
val bookDirectory = File(context.filesDir, "books/$bookId")
|
||||
bookDirectory.mkdirs()
|
||||
|
||||
// Copy original EPUB file
|
||||
val epubFile = File(bookDirectory, "book.epub")
|
||||
sourceFile.copyTo(epubFile)
|
||||
|
||||
// Extract EPUB contents
|
||||
val extractedDirectory = File(bookDirectory, "extracted")
|
||||
extractedDirectory.mkdirs()
|
||||
extractEpub(epubBook, extractedDirectory)
|
||||
|
||||
// Save book metadata
|
||||
val book = Book(
|
||||
id = bookId,
|
||||
title = epubBook.title,
|
||||
author = epubBook.metadata.authors.firstOrNull()?.toString() ?: "Unknown",
|
||||
path = epubFile.absolutePath,
|
||||
extractedPath = extractedDirectory.absolutePath,
|
||||
coverPath = null // Handle cover separately if needed
|
||||
)
|
||||
|
||||
bookDao.insert(book.toEntity())
|
||||
return bookId
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user