unpacked css and html working

This commit is contained in:
inhale-dir 2024-12-13 14:46:55 +01:00
parent adcee47523
commit 1d3daf5c8c
5 changed files with 363 additions and 506 deletions

View File

@ -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"

View File

@ -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)
)
}
}
}
}

View File

@ -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")
}
}
}
}
)

View File

@ -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?
)

View File

@ -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
}
}