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
+2 -1
View File
@@ -16,7 +16,8 @@
android:label="@string/app_name" android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Epook"> android:theme="@style/Theme.Epook"
android:requestLegacyExternalStorage="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
+113 -277
View File
@@ -5,345 +5,171 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add 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.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.collectAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage import androidx.navigation.NavController
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.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController 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.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.detectTapGestures
import androidx.compose.foundation.gestures.detectDragGestures
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.runtime.remember
import androidx.compose.ui.input.pointer.pointerInput 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() { class MainActivity : ComponentActivity() {
private lateinit var bookStore: BookStore
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() bookStore = BookStore(this)
setContent { setContent {
App()
}
}
}
@Composable
fun App() {
val navController = rememberNavController() val navController = rememberNavController()
val context = LocalContext.current val snackbarHostState = remember { SnackbarHostState() }
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))
}
)
}
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
)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun BookshelfScreen(onBookClick: (String) -> Unit) {
val context = LocalContext.current
val bookStore = remember { BookStore(context) }
val books by bookStore.getAllBooks().collectAsState(initial = emptyList())
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var showDeleteDialog by remember { mutableStateOf(false) } EpookTheme {
var bookToDelete by remember { mutableStateOf<Book?>(null) } NavHost(
var showEmptyState by remember { mutableStateOf(false) } navController = navController,
startDestination = "main"
) {
composable("main") {
MainScreen(
navController = navController,
bookStore = bookStore,
snackbarHostState = snackbarHostState,
scope = scope
)
}
val launcher = rememberLauncherForActivityResult( composable(
contract = ActivityResultContracts.GetContent() route = "reader/{bookId}",
) { uri: Uri? -> arguments = listOf(
uri?.let { selectedUri -> navArgument("bookId") { type = NavType.StringType }
)
) { backStackEntry ->
val bookId = backStackEntry.arguments?.getString("bookId")
?: return@composable
ReaderScreen(
bookId = bookId,
onNavigateBack = { navController.popBackStack() }
)
}
}
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MainScreen(
navController: NavController,
bookStore: BookStore,
snackbarHostState: SnackbarHostState,
scope: CoroutineScope
) {
val context = LocalContext.current
val books by bookStore.getAllBooks().collectAsState(initial = emptyList())
fun importBook(uri: Uri, scope: CoroutineScope) {
scope.launch {
try { try {
val inputStream = context.contentResolver.openInputStream(selectedUri) val inputStream = context.contentResolver.openInputStream(uri)
val book = EpubReader().readEpub(inputStream) ?: throw IOException("Failed to open input stream")
val bookId = java.util.UUID.randomUUID().toString() val tempFile = File(context.filesDir, "temp_${System.currentTimeMillis()}.epub")
inputStream.use { input ->
val epubFile = File(context.filesDir, "book_$bookId.epub") tempFile.outputStream().use { output ->
context.contentResolver.openInputStream(selectedUri)?.use { input ->
FileOutputStream(epubFile).use { output ->
input.copyTo(output) input.copyTo(output)
} }
} }
val coverFile = book.coverImage?.let { coverImage -> try {
File(context.filesDir, "cover_$bookId.jpg").also { file -> val bookId = bookStore.importBook(tempFile)
FileOutputStream(file).use { output -> Timber.d("Successfully imported book with ID: $bookId")
output.write(coverImage.data) navController.navigate("reader/$bookId")
} } finally {
} tempFile.delete()
}
scope.launch {
bookStore.addBook(Book(
id = bookId,
title = book.title ?: "Unknown Title",
coverImageFile = coverFile,
filePath = epubFile.absolutePath
))
} }
} catch (e: Exception) { } catch (e: Exception) {
println("Error saving book: ${e.message}") Timber.e(e, "Error importing book")
e.printStackTrace()
}
}
}
LaunchedEffect(books) {
showEmptyState = books.isEmpty()
}
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 { scope.launch {
bookToDelete?.let { bookStore.deleteBook(it.id) } snackbarHostState.showSnackbar(
showDeleteDialog = false message = "Failed to import book: ${e.localizedMessage}"
bookToDelete = null
}
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
) )
) {
Text("Delete")
}
},
dismissButton = {
OutlinedButton(onClick = { showDeleteDialog = false }) {
Text("Cancel")
} }
} }
) }
}
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri ->
if (uri != null) {
importBook(uri, scope)
}
} }
Scaffold( Scaffold(
topBar = { snackbarHost = { SnackbarHost(snackbarHostState) },
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
)
)
},
floatingActionButton = { floatingActionButton = {
FloatingActionButton( FloatingActionButton(
onClick = { launcher.launch("application/epub+zip") }, onClick = { launcher.launch("application/epub+zip") }
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
) { ) {
Icon(Icons.Default.Add, contentDescription = "Add book") Icon(
imageVector = Icons.Default.Add,
contentDescription = "Add Book"
)
} }
} }
) { padding -> ) { padding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
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( LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 160.dp), columns = GridCells.Adaptive(minSize = 160.dp),
contentPadding = PaddingValues(16.dp), contentPadding = padding,
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
items( items(books) { book ->
items = books,
key = { it.id }
) { book ->
BookCard( BookCard(
book = book, book = book,
onClick = { onBookClick(book.id) }, onClick = { navController.navigate("reader/${book.id}") },
onLongClick = { onLongClick = {
bookToDelete = book scope.launch {
showDeleteDialog = true bookStore.deleteBook(book.id)
}, }
modifier = Modifier.animateItemPlacement() }
) )
} }
} }
} }
} }
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -373,7 +199,7 @@ private fun BookCard(
) { ) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
AsyncImage( AsyncImage(
model = book.coverImageFile ?: R.drawable.ic_launcher_background, model = book.coverPath ?: R.drawable.ic_launcher_background,
contentDescription = book.title, contentDescription = book.title,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
@@ -384,14 +210,24 @@ private fun BookCard(
.fillMaxWidth() .fillMaxWidth()
.align(Alignment.BottomCenter), .align(Alignment.BottomCenter),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f) color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f)
) {
Column(
modifier = Modifier.padding(12.dp)
) { ) {
Text( Text(
text = book.title, text = book.title,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(12.dp),
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis 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 kotlinx.coroutines.flow.Flow
import inhale.rip.epook.data.Book import inhale.rip.epook.data.Book
import nl.siegmann.epublib.domain.Book as EpubBook 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 // Move the Chapter data class outside the composable
private data class Chapter( private data class Chapter(
@@ -197,190 +202,103 @@ fun ReaderScreen(
) )
}, },
factory = { context -> factory = { context ->
var extractedPath: String? = null
WebView(context).apply { WebView(context).apply {
webViewClient = WebViewClient() webViewClient = object : WebViewClient() {
this.settings.apply { 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 javaScriptEnabled = true
builtInZoomControls = true builtInZoomControls = true
displayZoomControls = false 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 -> update = { webView ->
val chapter = chapters.getOrNull(currentChapterIndex)?.resource val chapter = chapters.getOrNull(currentChapterIndex)?.resource
chapter?.let { resource -> chapter?.let { resource ->
val html = """ scope.launch {
<html> try {
<head> val extractedPath = bookStore.getBook(bookId)?.extractedPath
<meta name="viewport" content="width=device-width, initial-scale=1.0"> ?: throw IllegalStateException("Book not properly extracted")
<style>
@import url('https://fonts.googleapis.com/css2?family=${currentSettings.fontFamily.replace(" ", "+")}&display=swap');
:root { val baseUrl = "file://$extractedPath/"
--text-color: #${textColor.toString(16)}; val fullUrl = baseUrl + resource.href.replace("../", "")
--bg-color: #${backgroundColor.toString(16)};
--accent-color: #${primaryColor.toString(16)};
}
html { Timber.d("Loading chapter from: $fullUrl")
scroll-behavior: smooth; Timber.d("Base URL: $baseUrl")
-webkit-text-size-adjust: 100%;
font-kerning: normal;
}
body { webView.loadUrl(fullUrl)
font-family: '${currentSettings.fontFamily}', system-ui, -apple-system, serif; } catch (e: Exception) {
font-size: ${currentSettings.fontSize}px; Timber.e(e, "Error loading chapter")
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
)
} }
} }
) )
@@ -1,10 +1,15 @@
package inhale.rip.epook.data package inhale.rip.epook.data
import java.io.File import java.io.File
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "books")
data class Book( data class Book(
val id: String, @PrimaryKey val id: String,
val title: String, val title: String,
val coverImageFile: File? = null, val author: String,
val filePath: 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 nl.siegmann.epublib.epub.EpubReader
import java.io.FileInputStream import java.io.FileInputStream
import timber.log.Timber 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") 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) { suspend fun addBook(book: Book) {
bookDao.insertBook(BookEntity( bookDao.insert(book.toEntity())
id = book.id,
title = book.title,
coverImagePath = book.coverImageFile?.absolutePath,
filePath = book.filePath,
readingPosition = 0
))
} }
suspend fun updateReadingPosition(bookId: String, position: Int) { suspend fun updateReadingPosition(bookId: String, position: Int) {
@@ -59,55 +56,155 @@ class BookStore(private val context: Context) {
} }
suspend fun deleteBook(bookId: String) { suspend fun deleteBook(bookId: String) {
Log.e(TAG, "🔥 DELETE BOOK STARTED 🔥")
Log.e(TAG, "Attempting to delete book with ID: $bookId")
try { try {
val bookEntity = bookDao.getBook(bookId) 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) { if (bookEntity != null) {
try { // 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) bookDao.deleteBook(bookEntity)
Log.e(TAG, "Successfully deleted book from Room database") Timber.d("Deleted book from database: ${bookEntity.title}")
// 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) { } catch (e: Exception) {
Log.e(TAG, "Error deleting book files", e) Timber.e(e, "Error deleting book")
throw e
} }
} }
Log.e(TAG, "🔥 DELETE BOOK COMPLETED SUCCESSFULLY 🔥") suspend fun getBook(bookId: String): Book? {
} catch (e: Exception) { return bookDao.getBook(bookId)?.toBook()
Log.e(TAG, "🔥 DELETE BOOK FAILED 🔥")
Log.e(TAG, "Error type: ${e.javaClass.simpleName}")
Log.e(TAG, "Error message: ${e.message}")
e.printStackTrace()
}
}
suspend fun getBook(bookId: String): EpubBook {
val bookEntity = bookDao.getBook(bookId) ?: throw IllegalArgumentException("Book not found")
return EpubReader().readEpub(FileInputStream(bookEntity.filePath))
} }
suspend fun getBookPath(bookId: String): String { suspend fun getBookPath(bookId: String): String {
return bookDao.getBook(bookId)?.filePath return bookDao.getBook(bookId)?.filePath
?: throw IllegalArgumentException("Book not found") ?: 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
}
} }