7 Commits

Author SHA1 Message Date
inhale-dir 1122e06437 fixes & removed settings 2024-12-13 15:43:16 +01:00
inhale-dir 3295816550 bug fixes after changes 2024-12-13 15:30:29 +01:00
inhale-dir 6e795bd7d1 first official release 2024-12-13 15:14:13 +01:00
inhale-dir d73f54eeb4 book cover fixed 2024-12-13 15:01:09 +01:00
inhale-dir 9400ff5535 fixed font rendering 2024-12-13 14:49:56 +01:00
inhale-dir 1d3daf5c8c unpacked css and html working 2024-12-13 14:46:55 +01:00
inhale-dir adcee47523 Fixed bottom != bottom 2024-12-13 13:40:13 +01:00
7 changed files with 757 additions and 609 deletions
+106
View File
@@ -0,0 +1,106 @@
# 📚 Epook - Modern EPUB Reader
Epook is a sleek, modern EPUB reader built for Android using Jetpack Compose. It provides a clean, intuitive interface for reading your favorite ebooks with powerful features and smooth animations.
## ✨ Features
### 📖 Reading Experience
- Fluid chapter navigation with swipe gestures
- Progress tracking across reading sessions
- Interactive chapter selection through table of contents
- Smooth animations for UI elements
- Full-screen reading mode with tap controls
### 📱 User Interface
- Material Design 3 with dynamic theming
- Animated controls overlay
- Bottom navigation bar with chapter progress
- Clean, minimalist book library view
- Beautiful book cards with cover display
### ⚙️ Customization
- Adjustable font size (12-24sp)
- Customizable line height (1.0x-2.0x)
- Margin control (8-32dp)
- Multiple font family options:
- Georgia
- Roboto
- Times New Roman
- Arial
- Verdana
### 📚 Library Management
- Import EPUB files
- Automatic cover image extraction
- Book deletion with confirmation
- Reading progress persistence
- Organized book collection view
### Technical Features
- CSS stylesheet handling
- HTML content processing
- Efficient file management
- WebView-based rendering
- Resource caching
## 🛠️ Technical Stack
- **UI Framework**: Jetpack Compose
- **Language**: Kotlin
- **Architecture**: MVVM
- **Storage**: Room Database
- **EPUB Processing**: epublib
- **HTML Processing**: JSoup
- **Image Loading**: Coil
- **Logging**: Timber
## 🎯 Upcoming Features
- [ ] Search functionality
- [ ] Bookmarks
- [ ] Highlights and notes
- [ ] Dark mode support
- [ ] Reading statistics
- [ ] Custom themes
- [ ] Cloud sync
## 🤝 Contributing
Contributions are welcome! Feel free to submit issues and pull requests.
## 📄 License
This project is licensed under the MIT License - see the LICENSE file for details.
## 🙏 Acknowledgments
- [epublib](https://github.com/psiegman/epublib) for EPUB processing
- [Jsoup](https://jsoup.org/) for HTML parsing
- [Material Design 3](https://m3.material.io/) for design guidelines
## 🎨 App Icon Design
### Primary Design
A minimalist, modern book icon featuring:
- A stylized open book in Material Design style
- Primary color: Deep Purple (#6750A4) with white pages
- Subtle gradient background from lighter to darker purple
- Rounded corners following Material Design 3 guidelines
- Clean, simple lines with minimal detail
### Specifications
- Size: 512x512px (Play Store master icon)
- Adaptive Icon Layers:
- Foreground: Book icon in white/light purple
- Background: Gradient from #6750A4 to #4F378B
- Safe zone: 384x384px centered
- Corner radius: 100dp (Material 3 spec)
### Alternative Sizes
- 48x48dp (mdpi)
- 72x72dp (hdpi)
- 96x96dp (xhdpi)
- 144x144dp (xxhdpi)
- 192x192dp (xxxhdpi)
### Design Elements
+6 -1
View File
@@ -13,7 +13,7 @@ android {
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = 1
versionName = "1.0" versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -39,6 +39,7 @@ android {
} }
buildFeatures { buildFeatures {
compose = true compose = true
buildConfig = true
} }
composeOptions { composeOptions {
@@ -148,4 +149,8 @@ dependencies {
implementation("com.google.android.material:material:1.11.0") implementation("com.google.android.material:material:1.11.0")
implementation("androidx.recyclerview:recyclerview:1.3.2") implementation("androidx.recyclerview:recyclerview:1.3.2")
implementation("androidx.legacy:legacy-support-v4:1.0.0") implementation("androidx.legacy:legacy-support-v4:1.0.0")
// Add these dependencies
implementation("org.json:json:20231013")
implementation("com.jakewharton.timber:timber:5.0.1")
} }
+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"
+224 -271
View File
@@ -5,240 +5,172 @@ 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.MenuBook
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 kotlinx.coroutines.flow.map
import androidx.compose.animation.fadeIn import androidx.compose.foundation.background
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.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shadow import androidx.compose.ui.geometry.Offset
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.sp import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.ui.input.pointer.PointerInputScope import androidx.compose.material3.SmallTopAppBar
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() val navController = rememberNavController()
} val snackbarHostState = remember { SnackbarHostState() }
}
}
@Composable EpookTheme {
fun App() { NavHost(
val navController = rememberNavController() navController = navController,
val context = LocalContext.current startDestination = "main"
val settingsStore = remember(context) { SettingsStore(context) } ) {
composable("main") {
EpookTheme { MainScreen(
NavHost(navController = navController, startDestination = Screen.BookList.route) { navController = navController,
composable(Screen.BookList.route) { bookStore = bookStore,
BookshelfScreen( snackbarHostState = snackbarHostState
onBookClick = { bookId -> )
navController.navigate(Screen.Reader.createRoute(bookId))
} }
)
} composable(
composable( route = "reader/{bookId}",
route = Screen.Reader.route, arguments = listOf(
arguments = listOf(navArgument("bookId") { type = NavType.StringType }) navArgument("bookId") { type = NavType.StringType }
) { backStackEntry -> )
val bookId = backStackEntry.arguments?.getString("bookId") ?: "" ) { backStackEntry ->
ReaderScreen( val bookId = backStackEntry.arguments?.getString("bookId")
bookId = bookId, ?: return@composable
onNavigateBack = { navController.navigateUp() } ReaderScreen(
) bookId = bookId,
} onNavigateBack = { navController.popBackStack() }
composable(Screen.Settings.route) { )
SettingsScreen( }
onNavigateBack = { navController.navigateUp() }, }
settingsStore = settingsStore
)
} }
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun BookshelfScreen(onBookClick: (String) -> Unit) { fun MainScreen(
navController: NavController,
bookStore: BookStore,
snackbarHostState: SnackbarHostState,
modifier: Modifier = Modifier
) {
val context = LocalContext.current val context = LocalContext.current
val bookStore = remember { BookStore(context) }
val books by bookStore.getAllBooks().collectAsState(initial = emptyList())
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val books by bookStore.getAllBooks().collectAsState(initial = emptyList())
var showDeleteDialog by remember { mutableStateOf(false) }
var bookToDelete by remember { mutableStateOf<Book?>(null) } var bookToDelete by remember { mutableStateOf<Book?>(null) }
var showEmptyState by remember { mutableStateOf(false) }
val launcher = rememberLauncherForActivityResult( fun importBook(uri: Uri) {
contract = ActivityResultContracts.GetContent() scope.launch {
) { uri: Uri? ->
uri?.let { selectedUri ->
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() scope.launch {
snackbarHostState.showSnackbar(
message = "Failed to import book: ${e.localizedMessage}"
)
}
} }
} }
} }
LaunchedEffect(books) { val launcher = rememberLauncherForActivityResult(
showEmptyState = books.isEmpty() contract = ActivityResultContracts.GetContent()
) { uri ->
if (uri != null) {
importBook(uri)
}
} }
if (showDeleteDialog && bookToDelete != null) { // Delete confirmation dialog
bookToDelete?.let { book ->
AlertDialog( AlertDialog(
onDismissRequest = { showDeleteDialog = false }, onDismissRequest = { bookToDelete = null },
icon = { title = { Text("Delete Book") },
Icon(
imageVector = Icons.Filled.Delete,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
},
title = {
Text(
"Delete Book",
style = MaterialTheme.typography.headlineSmall
)
},
text = { text = {
Text( Text("Are you sure you want to delete \"${book.title}\"?")
"Are you sure you want to delete '${bookToDelete?.title}'? This action cannot be undone.",
style = MaterialTheme.typography.bodyLarge
)
}, },
confirmButton = { confirmButton = {
Button( TextButton(
onClick = { onClick = {
scope.launch { scope.launch {
bookToDelete?.let { bookStore.deleteBook(it.id) } bookStore.deleteBook(book.id)
showDeleteDialog = false
bookToDelete = null bookToDelete = null
} }
}, }
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) { ) {
Text("Delete") Text("Delete", color = MaterialTheme.colorScheme.error)
} }
}, },
dismissButton = { dismissButton = {
OutlinedButton(onClick = { showDeleteDialog = false }) { TextButton(onClick = { bookToDelete = null }) {
Text("Cancel") Text("Cancel")
} }
} }
@@ -247,104 +179,95 @@ fun BookshelfScreen(onBookClick: (String) -> Unit) {
Scaffold( Scaffold(
topBar = { topBar = {
LargeTopAppBar( SmallTopAppBar(
title = { title = {
Column { Column {
Text( Text(
"Library", text = "My Library",
style = MaterialTheme.typography.headlineLarge style = MaterialTheme.typography.titleLarge
)
Text(
text = "${books.size} book${if (books.size != 1) "s" else ""}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
) )
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( colors = TopAppBarDefaults.smallTopAppBarColors(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface
scrolledContainerColor = MaterialTheme.colorScheme.surface
) )
) )
}, },
snackbarHost = { SnackbarHost(snackbarHostState) },
floatingActionButton = { floatingActionButton = {
FloatingActionButton( FloatingActionButton(
onClick = { launcher.launch("application/epub+zip") }, onClick = { launcher.launch("application/epub+zip") },
containerColor = MaterialTheme.colorScheme.primaryContainer, containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer contentColor = MaterialTheme.colorScheme.onPrimaryContainer
) { ) {
Icon(Icons.Default.Add, contentDescription = "Add book") Icon(
imageVector = Icons.Default.Add,
contentDescription = "Add Book"
)
} }
} }
) { padding -> ) { padding ->
Box( if (books.isEmpty()) {
modifier = Modifier EmptyLibraryMessage(
.fillMaxSize() modifier = Modifier
.padding(padding) .fillMaxSize()
) { .padding(padding)
if (showEmptyState) { )
Column( } else {
modifier = Modifier LazyVerticalGrid(
.fillMaxSize() columns = GridCells.Adaptive(minSize = 140.dp),
.padding(32.dp), contentPadding = PaddingValues(16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.spacedBy(24.dp),
) { modifier = Modifier
Icon( .fillMaxSize()
imageVector = Icons.AutoMirrored.Filled.MenuBook, .padding(padding)
contentDescription = null, ) {
modifier = Modifier.size(96.dp), items(books) { book ->
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f) BookCard(
book = book,
onClick = { navController.navigate("reader/${book.id}") },
onLongClick = { bookToDelete = book }
) )
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()
)
}
} }
} }
} }
} }
} }
@Composable
private fun EmptyLibraryMessage(modifier: Modifier = Modifier) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.MenuBook,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "Your library is empty",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Tap + to add your first book",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
)
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun BookCard( private fun BookCard(
@@ -356,43 +279,73 @@ private fun BookCard(
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState() val isPressed by interactionSource.collectIsPressedAsState()
ElevatedCard( Column(
modifier = modifier modifier = modifier.width(140.dp),
.fillMaxWidth() horizontalAlignment = Alignment.CenterHorizontally
.aspectRatio(0.7f)
.pointerInput(Unit) {
detectTapGestures(
onTap = { onClick() },
onLongPress = { onLongClick() }
)
},
elevation = CardDefaults.elevatedCardElevation(
defaultElevation = 4.dp,
pressedElevation = if (isPressed) 8.dp else 4.dp
)
) { ) {
Box(modifier = Modifier.fillMaxSize()) { ElevatedCard(
AsyncImage( modifier = Modifier
model = book.coverImageFile ?: R.drawable.ic_launcher_background, .fillMaxWidth()
contentDescription = book.title, .aspectRatio(0.67f) // Standard book cover ratio
modifier = Modifier.fillMaxSize(), .pointerInput(Unit) {
contentScale = ContentScale.Crop detectTapGestures(
onTap = { onClick() },
onLongPress = { onLongClick() }
)
}
.shadow(
elevation = if (isPressed) 8.dp else 4.dp,
shape = RoundedCornerShape(8.dp)
),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
) )
) {
Box(modifier = Modifier.fillMaxSize()) {
// Book cover
AsyncImage(
model = book.coverPath ?: R.drawable.ic_book_24dp,
contentDescription = book.title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
Surface( // Gradient overlay
modifier = Modifier Box(
.fillMaxWidth() modifier = Modifier
.align(Alignment.BottomCenter), .fillMaxWidth()
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f) .height(80.dp)
) { .align(Alignment.BottomCenter)
Text( .background(
text = book.title, brush = Brush.verticalGradient(
style = MaterialTheme.typography.titleMedium, colors = listOf(
modifier = Modifier.padding(12.dp), Color.Transparent,
maxLines = 2, Color.Black.copy(alpha = 0.7f)
overflow = TextOverflow.Ellipsis )
)
)
) )
} }
} }
// Book info below the card
Text(
text = book.title,
style = MaterialTheme.typography.titleSmall,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 8.dp)
)
Text(
text = book.author,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 2.dp)
)
} }
} }
+212 -283
View File
@@ -7,6 +7,7 @@ import androidx.compose.animation.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -30,8 +31,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import inhale.rip.epook.data.BookStore import inhale.rip.epook.data.BookStore
import inhale.rip.epook.data.Settings
import inhale.rip.epook.data.SettingsStore
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.jsoup.Jsoup import org.jsoup.Jsoup
@@ -44,6 +43,12 @@ 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
import android.view.View
// Move the Chapter data class outside the composable // Move the Chapter data class outside the composable
private data class Chapter( private data class Chapter(
@@ -52,6 +57,9 @@ private data class Chapter(
val resource: Resource val resource: Resource
) )
// First, add a constant for the swipe area height
private const val SWIPE_AREA_HEIGHT = 80 // in dp
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ReaderScreen( fun ReaderScreen(
@@ -60,9 +68,7 @@ fun ReaderScreen(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val bookStore = remember { BookStore(context) } val bookStore = remember { BookStore(context) }
val settingsStore = remember { SettingsStore(context) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var currentSettings by remember { mutableStateOf(Settings()) }
var currentChapterIndex by remember { mutableStateOf(0) } var currentChapterIndex by remember { mutableStateOf(0) }
var chapters by remember { mutableStateOf<List<Chapter>>(emptyList()) } var chapters by remember { mutableStateOf<List<Chapter>>(emptyList()) }
@@ -70,18 +76,11 @@ fun ReaderScreen(
var showControls by remember { mutableStateOf(true) } var showControls by remember { mutableStateOf(true) }
var showChapterList by remember { mutableStateOf(false) } var showChapterList by remember { mutableStateOf(false) }
var showSettings by remember { mutableStateOf(false) }
val backgroundColor = MaterialTheme.colorScheme.background.toArgb() var currentX by remember { mutableStateOf(0f) }
val textColor = MaterialTheme.colorScheme.onBackground.toArgb()
LaunchedEffect(Unit) { var extractedPath by remember { mutableStateOf<String?>(null) }
try { var webView by remember { mutableStateOf<WebView?>(null) }
currentSettings = settingsStore.getSettings()
} catch (e: Exception) {
Timber.e(e, "Error loading settings")
}
}
LaunchedEffect(bookId) { LaunchedEffect(bookId) {
try { try {
@@ -141,138 +140,228 @@ fun ReaderScreen(
} }
} }
Scaffold( Box(
topBar = { modifier = Modifier
AnimatedVisibility( .fillMaxSize()
visible = showControls, .background(MaterialTheme.colorScheme.background)
enter = fadeIn() + slideInVertically(), ) {
exit = fadeOut() + slideOutVertically() // WebView without swipe gesture
) { AndroidView(
TopAppBar( factory = { context ->
title = { WebView(context).apply {
Column { webViewClient = object : WebViewClient() {
Text( override fun onReceivedError(
text = book?.title ?: "Reader", view: WebView?,
style = MaterialTheme.typography.titleLarge, request: WebResourceRequest?,
maxLines = 1, error: WebResourceError?
overflow = TextOverflow.Ellipsis ) {
) Timber.e("WebView error loading ${request?.url}: ${error?.description}")
Text( super.onReceivedError(view, request, error)
text = "Chapter ${currentChapterIndex + 1} of ${chapters.size}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
} }
},
navigationIcon = { override fun shouldInterceptRequest(
IconButton(onClick = onNavigateBack) { view: WebView?,
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") request: WebResourceRequest
} ): WebResourceResponse? {
}, val url = request.url.toString()
actions = { Timber.d("Loading resource: $url")
IconButton(onClick = { showChapterList = true }) {
Icon(Icons.AutoMirrored.Filled.List, contentDescription = "Chapters") try {
} if (url.endsWith(".css") && extractedPath != null) {
IconButton(onClick = { showSettings = true }) { val possiblePaths = listOf(
Icon(Icons.Default.Settings, contentDescription = "Settings") url.substringAfterLast("/"),
"Styles/${url.substringAfterLast("/")}",
url.substringAfter("/extracted/")
)
for (cssPath in possiblePaths) {
val cssFile = File(extractedPath!!, cssPath)
if (cssFile.exists()) {
Timber.d("Found CSS file at: ${cssFile.absolutePath}")
return WebResourceResponse(
"text/css",
"UTF-8",
cssFile.inputStream()
)
}
}
}
} catch (e: Exception) {
Timber.e(e, "Error intercepting resource request")
}
return super.shouldInterceptRequest(view, request)
} }
} }
)
} settings.apply {
} javaScriptEnabled = true
) { padding -> builtInZoomControls = true
Box( displayZoomControls = false
allowFileAccess = true
@Suppress("DEPRECATION")
allowFileAccessFromFileURLs = true
@Suppress("DEPRECATION")
allowUniversalAccessFromFileURLs = true
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
domStorageEnabled = true
cacheMode = WebSettings.LOAD_DEFAULT
// Add these specific scrolling optimizations
@Suppress("DEPRECATION")
setRenderPriority(WebSettings.RenderPriority.HIGH)
// Disable features that might cause stuttering
loadsImagesAutomatically = true
blockNetworkImage = true // Since we're reading locally
blockNetworkLoads = true // Since we're reading locally
// Enable hardware acceleration
setLayerType(View.LAYER_TYPE_HARDWARE, null)
}
// Set better scrolling properties
overScrollMode = View.OVER_SCROLL_NEVER
isVerticalScrollBarEnabled = false // Hide scrollbar for smoother scrolling
// Set scroll sensitivity
@Suppress("DEPRECATION")
setScrollBarStyle(View.SCROLLBARS_INSIDE_OVERLAY)
// Enable smooth scrolling
isScrollContainer = true
// Set better touch handling
isNestedScrollingEnabled = true
}.also { webView = it }
},
update = { view ->
val chapter = chapters.getOrNull(currentChapterIndex)?.resource
chapter?.let { resource ->
scope.launch {
try {
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")
view.loadUrl(fullUrl)
} catch (e: Exception) {
Timber.e(e, "Error loading chapter")
}
}
}
},
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(padding) .padding(bottom = if (showControls) SWIPE_AREA_HEIGHT.dp else 0.dp)
) { )
if (chapters.isNotEmpty()) {
AndroidView(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures(
onTap = { showControls = !showControls }
)
},
factory = { context ->
WebView(context).apply {
webViewClient = WebViewClient()
this.settings.apply {
javaScriptEnabled = true
builtInZoomControls = true
displayZoomControls = false
}
}
},
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>
body {
font-family: ${currentSettings.fontFamily};
font-size: ${currentSettings.fontSize}px;
line-height: ${currentSettings.lineHeight};
padding: ${currentSettings.padding}px;
margin: 0;
background-color: #${backgroundColor.toString(16)};
color: #${textColor.toString(16)};
}
</style>
</head>
<body>
${String(resource.data, Charset.defaultCharset())}
</body>
</html>
""".trimIndent()
webView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null)
}
}
)
}
// Navigation controls // Add swipe area at the bottom with tap gesture
AnimatedVisibility( Box(
visible = showControls, modifier = Modifier
modifier = Modifier.align(Alignment.BottomCenter), .fillMaxWidth()
enter = fadeIn() + slideInVertically { it }, .height(SWIPE_AREA_HEIGHT.dp)
exit = fadeOut() + slideOutVertically { it } .align(Alignment.BottomCenter)
.pointerInput(Unit) {
detectTapGestures(
onTap = { showControls = !showControls }
)
}
.pointerInput(Unit) {
var initialX = 0f
detectHorizontalDragGestures(
onDragStart = { offset ->
initialX = offset.x
},
onDragEnd = {
val dragThreshold = size.width * 0.2f // 20% of screen width
val dragDistance = initialX - currentX
when {
dragDistance > dragThreshold && currentChapterIndex < chapters.size - 1 -> {
// Swiped left - next chapter
currentChapterIndex++
}
dragDistance < -dragThreshold && currentChapterIndex > 0 -> {
// Swiped right - previous chapter
currentChapterIndex--
}
}
currentX = 0f
}
) { change, dragAmount ->
currentX = change.position.x
change.consume()
}
}
)
// Controls overlay
AnimatedVisibility(
visible = showControls,
enter = slideInVertically(initialOffsetY = { it }),
exit = slideOutVertically(targetOffsetY = { it }),
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
) {
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f),
tonalElevation = 8.dp,
shadowElevation = 8.dp
) { ) {
Surface( Column {
modifier = Modifier // Chapter progress
.fillMaxWidth() LinearProgressIndicator(
.padding(16.dp), progress = { currentChapterIndex.toFloat() / (chapters.size - 1).coerceAtLeast(1).toFloat() },
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f), modifier = Modifier
tonalElevation = 3.dp .fillMaxWidth()
) { .height(2.dp)
)
// Controls
Row( Row(
modifier = Modifier modifier = Modifier
.padding(16.dp) .fillMaxWidth()
.fillMaxWidth(), .padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Previous chapter
IconButton( IconButton(
onClick = { if (currentChapterIndex > 0) currentChapterIndex-- }, onClick = { if (currentChapterIndex > 0) currentChapterIndex-- },
enabled = currentChapterIndex > 0 enabled = currentChapterIndex > 0
) { ) {
Icon(Icons.AutoMirrored.Filled.NavigateBefore, "Previous chapter") Icon(
imageVector = Icons.AutoMirrored.Filled.NavigateBefore,
contentDescription = "Previous Chapter"
)
} }
Text( // Chapter selector
text = "${currentChapterIndex + 1}/${chapters.size}", TextButton(
style = MaterialTheme.typography.titleMedium onClick = { showChapterList = true }
) ) {
Text(
text = "Chapter ${currentChapterIndex + 1}/${chapters.size}",
style = MaterialTheme.typography.bodyMedium
)
}
// Next chapter
IconButton( IconButton(
onClick = { if (currentChapterIndex < chapters.size - 1) currentChapterIndex++ }, onClick = { if (currentChapterIndex < chapters.size - 1) currentChapterIndex++ },
enabled = currentChapterIndex < chapters.size - 1 enabled = currentChapterIndex < chapters.size - 1
) { ) {
Icon(Icons.AutoMirrored.Filled.NavigateNext, "Next chapter") Icon(
imageVector = Icons.AutoMirrored.Filled.NavigateNext,
contentDescription = "Next Chapter"
)
} }
} }
} }
@@ -328,164 +417,4 @@ fun ReaderScreen(
} }
) )
} }
// Add Settings Dialog
if (showSettings) {
AlertDialog(
onDismissRequest = { showSettings = false },
title = {
Text(
"Reader Settings",
style = MaterialTheme.typography.headlineSmall
)
},
text = {
Column(
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Font Size Slider
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Font Size",
style = MaterialTheme.typography.titleMedium
)
Text(
"${currentSettings.fontSize.toInt()}sp",
style = MaterialTheme.typography.bodyMedium
)
}
Slider(
value = currentSettings.fontSize,
onValueChange = { newSize ->
scope.launch {
val newSettings = currentSettings.copy(fontSize = newSize)
settingsStore.saveSettings(newSettings)
currentSettings = newSettings
}
},
valueRange = 12f..24f,
steps = 11
)
}
// Line Height Slider
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Line Height",
style = MaterialTheme.typography.titleMedium
)
Text(
"%.1fx".format(currentSettings.lineHeight),
style = MaterialTheme.typography.bodyMedium
)
}
Slider(
value = currentSettings.lineHeight,
onValueChange = { newHeight ->
scope.launch {
val newSettings = currentSettings.copy(lineHeight = newHeight)
settingsStore.saveSettings(newSettings)
currentSettings = newSettings
}
},
valueRange = 1f..2f,
steps = 9
)
}
// Padding Slider
Column {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Margin",
style = MaterialTheme.typography.titleMedium
)
Text(
"${currentSettings.padding.toInt()}dp",
style = MaterialTheme.typography.bodyMedium
)
}
Slider(
value = currentSettings.padding,
onValueChange = { newPadding ->
scope.launch {
val newSettings = currentSettings.copy(padding = newPadding)
settingsStore.saveSettings(newSettings)
currentSettings = newSettings
}
},
valueRange = 8f..32f,
steps = 11
)
}
// Font Family Dropdown
Column {
Text(
"Font Family",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
val fontFamilies = listOf("Georgia", "Roboto", "Times New Roman", "Arial", "Verdana")
var expanded by remember { mutableStateOf(false) }
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded }
) {
OutlinedTextField(
value = currentSettings.fontFamily,
onValueChange = {},
readOnly = true,
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
modifier = Modifier
.menuAnchor()
.fillMaxWidth()
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
fontFamilies.forEach { font ->
DropdownMenuItem(
text = { Text(font) },
onClick = {
scope.launch {
val newSettings = currentSettings.copy(fontFamily = font)
settingsStore.saveSettings(newSettings)
currentSettings = newSettings
}
expanded = false
}
)
}
}
}
}
}
},
confirmButton = {
TextButton(onClick = { showSettings = false }) {
Text("Close")
}
}
)
}
} }
@@ -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,207 @@ 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
bookDao.deleteBook(bookEntity) val bookDirectory = File(context.filesDir, "books/$bookId")
Log.e(TAG, "Successfully deleted book from Room database") if (bookDirectory.exists()) {
bookDirectory.deleteRecursively()
// Delete associated files Timber.d("Deleted book directory: ${bookDirectory.absolutePath}")
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)
} }
}
Log.e(TAG, "🔥 DELETE BOOK COMPLETED SUCCESSFULLY 🔥") // Delete from database
bookDao.deleteBook(bookEntity)
Timber.d("Deleted book from database: ${bookEntity.title}")
}
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "🔥 DELETE BOOK FAILED 🔥") Timber.e(e, "Error deleting book")
Log.e(TAG, "Error type: ${e.javaClass.simpleName}") throw e
Log.e(TAG, "Error message: ${e.message}")
e.printStackTrace()
} }
} }
suspend fun getBook(bookId: String): EpubBook { suspend fun getBook(bookId: String): Book? {
val bookEntity = bookDao.getBook(bookId) ?: throw IllegalArgumentException("Book not found") return bookDao.getBook(bookId)?.toBook()
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 fonts and CSS files
book.resources.all
.filter { resource ->
val mediaType = resource.mediaType?.toString() ?: ""
mediaType.contains("css") ||
mediaType.contains("font") ||
resource.href.endsWith(".otf") ||
resource.href.endsWith(".ttf") ||
resource.href.endsWith(".woff") ||
resource.href.endsWith(".woff2")
}
.forEach { resource ->
// Extract to both original location and root fonts directory
val paths = listOf(
resource.href.replace("../", "").replace("./", ""),
"Fonts/${resource.href.substringAfterLast("/")}",
"Text/Fonts/${resource.href.substringAfterLast("/")}"
).distinct()
paths.forEach { path ->
val resourceFile = File(baseDirectory, path)
resourceFile.parentFile?.mkdirs()
resourceFile.writeBytes(resource.data)
// Log font file extraction
if (resource.href.matches(Regex(".+\\.(otf|ttf|woff|woff2)$"))) {
Timber.d("""
Font File Extracted:
- Original href: ${resource.href}
- Path: $path
- Full path: ${resourceFile.absolutePath}
- Size: ${resourceFile.length()}
- Exists: ${resourceFile.exists()}
- Media Type: ${resource.mediaType}
""".trimIndent())
}
}
}
// Then extract remaining files
book.resources.all
.filter { resource ->
val mediaType = resource.mediaType?.toString() ?: ""
!(mediaType.contains("css") ||
mediaType.contains("font") ||
resource.href.endsWith(".otf") ||
resource.href.endsWith(".ttf") ||
resource.href.endsWith(".woff") ||
resource.href.endsWith(".woff2"))
}
.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)
// Process CSS links
doc.select("link[rel=stylesheet]").forEach { link ->
val cssHref = link.attr("href")
.replace("../", "")
.replace("./", "")
link.tagName("link")
.attr("rel", "stylesheet")
.attr("type", "text/css")
.attr("href", cssHref)
}
// Update font references in CSS and style tags
doc.select("style").forEach { style ->
val cssContent = style.html()
val updatedCss = cssContent.replace(
Regex("url\\(['\"]?(.*?)['\"]?\\)"),
{ matchResult ->
val fontPath = matchResult.groupValues[1]
.replace("../", "")
.replace("./", "")
.substringAfterLast("/")
"url('Fonts/$fontPath')"
}
)
style.html(updatedCss)
}
// 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)
if (file.isDirectory) {
file.setExecutable(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()
// Extract and save cover image
var coverPath: String? = null
// Try to get cover from the book's resources
epubBook.resources.all
.firstOrNull { resource ->
resource.mediaType?.toString()?.contains("image") == true &&
(resource.href.contains("cover") || resource.id?.contains("cover") == true)
}?.let { coverResource ->
val coverFile = File(bookDirectory, "cover.jpg")
coverFile.writeBytes(coverResource.data)
coverPath = "file://${coverFile.absolutePath}"
Timber.d("Saved cover image from resources to: $coverPath")
} ?: epubBook.coverImage?.let { cover ->
val coverFile = File(bookDirectory, "cover.jpg")
coverFile.writeBytes(cover.data)
coverPath = "file://${coverFile.absolutePath}"
Timber.d("Saved direct cover image to: $coverPath")
}
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 = coverPath
)
bookDao.insert(book.toEntity())
return bookId
}
} }