Compare commits

5 Commits

Author SHA1 Message Date
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 711 additions and 433 deletions
+79
View File
@@ -0,0 +1,79 @@
# 📚 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
+6 -1
View File
@@ -13,7 +13,7 @@ android {
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0"
versionName = "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -39,6 +39,7 @@ android {
}
buildFeatures {
compose = true
buildConfig = true
}
composeOptions {
@@ -148,4 +149,8 @@ dependencies {
implementation("com.google.android.material:material:1.11.0")
implementation("androidx.recyclerview:recyclerview:1.3.2")
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: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"
+230 -277
View File
@@ -5,240 +5,172 @@ 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.material.icons.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 kotlinx.coroutines.flow.map
import androidx.compose.foundation.background
import androidx.compose.ui.graphics.Brush
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.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"
}
}
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.SmallTopAppBar
@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() }
EpookTheme {
NavHost(
navController = navController,
startDestination = "main"
) {
composable("main") {
MainScreen(
navController = navController,
bookStore = bookStore,
snackbarHostState = snackbarHostState
)
}
)
}
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) {
fun MainScreen(
navController: NavController,
bookStore: BookStore,
snackbarHostState: SnackbarHostState,
modifier: Modifier = Modifier
) {
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) }
val books by bookStore.getAllBooks().collectAsState(initial = emptyList())
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.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)
}
}
if (showDeleteDialog && bookToDelete != null) {
// Delete confirmation dialog
bookToDelete?.let { book ->
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
)
onDismissRequest = { bookToDelete = null },
title = { Text("Delete Book") },
text = {
Text("Are you sure you want to delete \"${book.title}\"?")
},
confirmButton = {
Button(
TextButton(
onClick = {
scope.launch {
bookToDelete?.let { bookStore.deleteBook(it.id) }
showDeleteDialog = false
bookStore.deleteBook(book.id)
bookToDelete = null
}
},
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
}
) {
Text("Delete")
Text("Delete", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
OutlinedButton(onClick = { showDeleteDialog = false }) {
TextButton(onClick = { bookToDelete = null }) {
Text("Cancel")
}
}
@@ -247,104 +179,95 @@ fun BookshelfScreen(onBookClick: (String) -> Unit) {
Scaffold(
topBar = {
LargeTopAppBar(
SmallTopAppBar(
title = {
Column {
Text(
"Library",
style = MaterialTheme.typography.headlineLarge
text = "My Library",
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(
containerColor = MaterialTheme.colorScheme.surface,
scrolledContainerColor = MaterialTheme.colorScheme.surface
colors = TopAppBarDefaults.smallTopAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
floatingActionButton = {
FloatingActionButton(
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 ->
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)
if (books.isEmpty()) {
EmptyLibraryMessage(
modifier = Modifier
.fillMaxSize()
.padding(padding)
)
} else {
LazyVerticalGrid(
columns = GridCells.Adaptive(minSize = 140.dp),
contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
items(books) { book ->
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)
@Composable
private fun BookCard(
@@ -356,43 +279,73 @@ private fun BookCard(
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
ElevatedCard(
modifier = modifier
.fillMaxWidth()
.aspectRatio(0.7f)
.pointerInput(Unit) {
detectTapGestures(
onTap = { onClick() },
onLongPress = { onLongClick() }
)
},
elevation = CardDefaults.elevatedCardElevation(
defaultElevation = 4.dp,
pressedElevation = if (isPressed) 8.dp else 4.dp
)
Column(
modifier = modifier.width(140.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(modifier = Modifier.fillMaxSize()) {
AsyncImage(
model = book.coverImageFile ?: R.drawable.ic_launcher_background,
contentDescription = book.title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(0.67f) // Standard book cover ratio
.pointerInput(Unit) {
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
)
Surface(
modifier = Modifier
.fillMaxWidth()
.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
) {
Box(modifier = Modifier.fillMaxSize()) {
// Book cover
AsyncImage(
model = book.coverPath ?: R.drawable.ic_book_24dp,
contentDescription = book.title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
)
// Gradient overlay
Box(
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
.align(Alignment.BottomCenter)
.background(
brush = Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.7f)
)
)
)
)
}
}
// 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)
)
}
}
+195 -109
View File
@@ -7,6 +7,7 @@ import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@@ -44,6 +45,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(
@@ -72,8 +78,10 @@ fun ReaderScreen(
var showChapterList by remember { mutableStateOf(false) }
var showSettings by remember { mutableStateOf(false) }
val backgroundColor = MaterialTheme.colorScheme.background.toArgb()
val textColor = MaterialTheme.colorScheme.onBackground.toArgb()
var currentX by remember { mutableStateOf(0f) }
var extractedPath by remember { mutableStateOf<String?>(null) }
var webView by remember { mutableStateOf<WebView?>(null) }
LaunchedEffect(Unit) {
try {
@@ -141,143 +149,221 @@ fun ReaderScreen(
}
}
Scaffold(
topBar = {
AnimatedVisibility(
visible = showControls,
enter = fadeIn() + slideInVertically(),
exit = fadeOut() + slideOutVertically()
) {
TopAppBar(
title = {
Column {
Text(
text = book?.title ?: "Reader",
style = MaterialTheme.typography.titleLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(
text = "Chapter ${currentChapterIndex + 1} of ${chapters.size}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
}
},
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
}
},
actions = {
IconButton(onClick = { showChapterList = true }) {
Icon(Icons.AutoMirrored.Filled.List, contentDescription = "Chapters")
}
IconButton(onClick = { showSettings = true }) {
Icon(Icons.Default.Settings, contentDescription = "Settings")
}
}
)
}
}
) { padding ->
Box(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
if (chapters.isNotEmpty()) {
AndroidView(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) {
detectTapGestures(
onTap = { showControls = !showControls }
)
.padding(bottom = if (showControls) 80.dp else 0.dp)
.pointerInput(Unit) {
detectTapGestures(
onTap = { showControls = !showControls }
)
}
.pointerInput(Unit) {
var initialX = 0f
detectHorizontalDragGestures(
onDragStart = { offset ->
initialX = offset.x
},
factory = { context ->
WebView(context).apply {
webViewClient = WebViewClient()
this.settings.apply {
javaScriptEnabled = true
builtInZoomControls = true
displayZoomControls = false
onDragEnd = {
// Implement drag threshold for swipe
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--
}
}
}
},
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)};
) { change, _ ->
currentX = change.position.x
change.consume()
}
}
) {
AndroidView(
factory = { context ->
WebView(context).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 {
if (url.endsWith(".css") && extractedPath != null) {
val possiblePaths = listOf(
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()
)
}
</style>
</head>
<body>
${String(resource.data, Charset.defaultCharset())}
</body>
</html>
""".trimIndent()
webView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null)
}
}
} 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
@Suppress("DEPRECATION")
allowFileAccessFromFileURLs = true
@Suppress("DEPRECATION")
allowUniversalAccessFromFileURLs = true
mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
domStorageEnabled = true
cacheMode = WebSettings.LOAD_DEFAULT
}
}.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.fillMaxSize()
)
}
// Navigation controls
AnimatedVisibility(
visible = showControls,
modifier = Modifier.align(Alignment.BottomCenter),
enter = fadeIn() + slideInVertically { it },
exit = fadeOut() + slideOutVertically { it }
// 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(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f),
tonalElevation = 3.dp
) {
Column {
// Chapter progress
LinearProgressIndicator(
progress = { currentChapterIndex.toFloat() / (chapters.size - 1).coerceAtLeast(1).toFloat() },
modifier = Modifier
.fillMaxWidth()
.height(2.dp)
)
// Controls
Row(
modifier = Modifier
.padding(16.dp)
.fillMaxWidth(),
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Previous chapter
IconButton(
onClick = { if (currentChapterIndex > 0) currentChapterIndex-- },
enabled = currentChapterIndex > 0
) {
Icon(Icons.AutoMirrored.Filled.NavigateBefore, "Previous chapter")
Icon(
imageVector = Icons.AutoMirrored.Filled.NavigateBefore,
contentDescription = "Previous Chapter"
)
}
Text(
text = "${currentChapterIndex + 1}/${chapters.size}",
style = MaterialTheme.typography.titleMedium
)
// Chapter selector
TextButton(
onClick = { showChapterList = true }
) {
Text(
text = "Chapter ${currentChapterIndex + 1}/${chapters.size}",
style = MaterialTheme.typography.bodyMedium
)
}
// Next chapter
IconButton(
onClick = { if (currentChapterIndex < chapters.size - 1) currentChapterIndex++ },
enabled = currentChapterIndex < chapters.size - 1
) {
Icon(Icons.AutoMirrored.Filled.NavigateNext, "Next chapter")
Icon(
imageVector = Icons.AutoMirrored.Filled.NavigateNext,
contentDescription = "Next Chapter"
)
}
}
}
}
}
// Back button overlay
AnimatedVisibility(
visible = showControls,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.TopStart)
.statusBarsPadding()
.padding(8.dp)
) {
IconButton(
onClick = onNavigateBack,
colors = IconButtonDefaults.iconButtonColors(
containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f),
contentColor = MaterialTheme.colorScheme.onSurface
)
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back"
)
}
}
}
// Chapter list dialog
@@ -1,10 +1,15 @@
package inhale.rip.epook.data
import java.io.File
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "books")
data class Book(
val id: String,
@PrimaryKey val id: String,
val title: String,
val coverImageFile: File? = null,
val filePath: String
val author: String,
val path: String,
val extractedPath: String,
val coverPath: String?
)
@@ -13,6 +13,9 @@ import nl.siegmann.epublib.domain.Book as EpubBook
import nl.siegmann.epublib.epub.EpubReader
import java.io.FileInputStream
import timber.log.Timber
import java.util.UUID
import org.jsoup.Jsoup
import java.nio.charset.Charset
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "books")
@@ -26,13 +29,7 @@ class BookStore(private val context: Context) {
}
suspend fun addBook(book: Book) {
bookDao.insertBook(BookEntity(
id = book.id,
title = book.title,
coverImagePath = book.coverImageFile?.absolutePath,
filePath = book.filePath,
readingPosition = 0
))
bookDao.insert(book.toEntity())
}
suspend fun updateReadingPosition(bookId: String, position: Int) {
@@ -59,55 +56,207 @@ 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 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
}
}