diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index b74f8bd..1d2823c 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,6 +1,7 @@
+
diff --git a/app/src/main/java/com/aryan/reader/HomeScreen.kt b/app/src/main/java/com/aryan/reader/HomeScreen.kt
index e56b1dc..6da6802 100644
--- a/app/src/main/java/com/aryan/reader/HomeScreen.kt
+++ b/app/src/main/java/com/aryan/reader/HomeScreen.kt
@@ -55,6 +55,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Cloud
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.FolderSpecial
import androidx.compose.material.icons.filled.FormatListNumbered
@@ -656,6 +657,27 @@ fun RecentFileCard(
}
}
+ val isOpdsStream = item.uriString?.startsWith("opds-pse://") == true
+ if (isOpdsStream) {
+ Box(
+ modifier = Modifier
+ .align(Alignment.TopEnd)
+ .padding(8.dp)
+ .background(
+ color = MaterialTheme.colorScheme.tertiaryContainer,
+ shape = CircleShape
+ )
+ .padding(4.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Default.Cloud,
+ contentDescription = "OPDS Stream",
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.onTertiaryContainer
+ )
+ }
+ }
+
if (isPinned) {
Box(
modifier = Modifier
diff --git a/app/src/main/java/com/aryan/reader/LibraryScreen.kt b/app/src/main/java/com/aryan/reader/LibraryScreen.kt
index ab17b17..ffdd5b4 100644
--- a/app/src/main/java/com/aryan/reader/LibraryScreen.kt
+++ b/app/src/main/java/com/aryan/reader/LibraryScreen.kt
@@ -20,6 +20,7 @@
// LibraryScreen.kt
package com.aryan.reader
+import android.net.Uri
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
@@ -32,6 +33,10 @@ import androidx.compose.material.icons.filled.PushPin
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
+import com.aryan.reader.opds.OpdsViewModel
+import com.aryan.reader.opds.OpdsEntry
+import com.aryan.reader.opds.OpdsCatalog
+import org.jsoup.Jsoup
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.AssistChip
@@ -40,6 +45,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -53,15 +59,22 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Cloud
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material.icons.filled.FolderSpecial
import androidx.compose.material.icons.filled.Info
@@ -72,6 +85,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExtendedFloatingActionButton
+import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -103,13 +117,18 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.aryan.reader.data.RecentFileItem
+import com.aryan.reader.opds.OpdsAcquisition
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -137,7 +156,7 @@ fun LibraryScreen(
val rawLibraryFiles = uiState.rawLibraryFiles
val pagerState = rememberPagerState(
initialPage = uiState.libraryScreenStartPage,
- pageCount = { 3 }
+ pageCount = { 4 }
)
val containsFolderItems = remember(selectedItems) {
@@ -281,6 +300,20 @@ fun LibraryScreen(
lastFolderScanTime = uiState.lastFolderScanTime,
isLoading = uiState.isLoading,
isRefreshing = uiState.isRefreshing,
+ onOpdsBookDownloaded = { uri, title ->
+ viewModel.showBanner("Downloaded $title")
+ viewModel.onFileSelected(uri, isFromRecent = false)
+ },
+ onStreamOpdsBook = { entry, catalog ->
+ viewModel.streamOpdsBook(
+ bookId = entry.id,
+ title = entry.title,
+ urlTemplate = entry.pseUrlTemplate!!,
+ pageCount = entry.pseCount!!,
+ catalogId = catalog?.id
+ )
+ },
+ onDeleteCatalogStreams = viewModel::deleteStreamedBooksForCatalog
)
@@ -502,11 +535,14 @@ fun LibraryScreenContent(
isRefreshing: Boolean,
syncedFolders: List,
onRemoveFolderClick: (SyncedFolder) -> Unit,
+ onOpdsBookDownloaded: (Uri, String) -> Unit,
+ onStreamOpdsBook: (OpdsEntry, OpdsCatalog?) -> Unit,
+ onDeleteCatalogStreams: (String) -> Unit,
) {
val isBookContextualModeActive = selectedItems.isNotEmpty()
val isShelfContextualModeActive = selectedShelves.isNotEmpty()
var showSortMenu by remember { mutableStateOf(false) }
- val tabTitles = listOf("All Books", "Shelves", "Folders")
+ val tabTitles = listOf("All Books", "Shelves", "Folders", "Catalogs")
val searchFocusRequester = remember { FocusRequester() }
var textFieldValue by remember(isSearchActive) {
@@ -769,6 +805,15 @@ fun LibraryScreenContent(
isLoading = isLoading || isRefreshing
)
}
+ 3 -> {
+ OpdsTab(
+ localLibraryFiles = rawLibraryFiles,
+ onBookDownloaded = onOpdsBookDownloaded,
+ onReadBook = onItemClick,
+ onStreamBook = onStreamOpdsBook,
+ onDeleteCatalogStreams = onDeleteCatalogStreams
+ )
+ }
}
}
}
@@ -1302,6 +1347,16 @@ private fun LibraryListItem(
)
Spacer(modifier = Modifier.width(4.dp))
}
+ val isOpdsStream = item.uriString?.startsWith("opds-pse://") == true
+ if (isOpdsStream) {
+ Icon(
+ imageVector = Icons.Default.Cloud,
+ contentDescription = "OPDS Stream",
+ modifier = Modifier.size(16.dp),
+ tint = MaterialTheme.colorScheme.tertiary
+ )
+ Spacer(modifier = Modifier.width(4.dp))
+ }
if (isPinned) {
Icon(
@@ -1503,7 +1558,7 @@ private fun FolderSyncScreen(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
- androidx.compose.material3.FilledTonalButton(
+ FilledTonalButton(
onClick = onScanNowClick,
enabled = !isLoading,
modifier = Modifier.weight(1f),
@@ -1821,4 +1876,852 @@ fun LibraryFilterSheet(
Spacer(modifier = Modifier.height(32.dp))
}
}
+}
+
+@Composable
+fun OpdsTab(
+ localLibraryFiles: List,
+ onBookDownloaded: (Uri, String) -> Unit,
+ onReadBook: (RecentFileItem) -> Unit,
+ onStreamBook: (OpdsEntry, OpdsCatalog?) -> Unit,
+ onDeleteCatalogStreams: (String) -> Unit,
+ opdsViewModel: OpdsViewModel = viewModel()
+) {
+ val uiState by opdsViewModel.uiState.collectAsStateWithLifecycle()
+ val downloadingState by opdsViewModel.downloadingState.collectAsStateWithLifecycle()
+ val downloadingEntries by opdsViewModel.downloadingEntries.collectAsStateWithLifecycle()
+ val context = LocalContext.current
+ var selectedEntry by remember { mutableStateOf(null) }
+ var showCatalogDialog by remember { mutableStateOf(false) }
+ var editingCatalog by remember { mutableStateOf(null) }
+ var catalogToDelete by remember { mutableStateOf(null) }
+
+ BackHandler(enabled = uiState.isViewingCatalog) {
+ opdsViewModel.navigateBack()
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ if (!uiState.isViewingCatalog) {
+ Box(modifier = Modifier.fillMaxSize()) {
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(
+ start = 16.dp,
+ end = 16.dp,
+ top = 16.dp,
+ bottom = 88.dp
+ ),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ items(uiState.catalogs, key = { it.id }) { catalog ->
+ OpdsCatalogCard(
+ catalog = catalog,
+ onClick = { opdsViewModel.openCatalog(catalog) },
+ onEdit = if (catalog.isDefault) null else {
+ {
+ editingCatalog = catalog
+ showCatalogDialog = true
+ }
+ },
+ onDelete = if (catalog.isDefault) null else {
+ { catalogToDelete = catalog }
+ })
+ }
+ }
+
+ ExtendedFloatingActionButton(
+ text = { Text("Add Catalog") },
+ icon = { Icon(Icons.Default.Add, "Add") },
+ onClick = {
+ editingCatalog = null
+ showCatalogDialog = true
+ },
+ modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp)
+ )
+ }
+ } else {
+ // Screen 2: Viewing a specific feed/catalog
+ Box(modifier = Modifier.fillMaxSize()) {
+ Column(modifier = Modifier.fillMaxSize()) {
+ Surface(
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 2.dp,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ var showSearch by remember { mutableStateOf(false) }
+ var query by remember { mutableStateOf("") }
+
+ val searchFocusRequester = remember { FocusRequester() }
+
+ LaunchedEffect(showSearch) {
+ if (showSearch) {
+ delay(100)
+ searchFocusRequester.requestFocus()
+ }
+ }
+
+ Box(modifier = Modifier.fillMaxWidth()) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.fillMaxWidth().height(64.dp)
+ .padding(horizontal = 4.dp)
+ ) {
+ IconButton(onClick = {
+ if (showSearch) {
+ showSearch = false
+ query = ""
+ } else {
+ opdsViewModel.navigateBack()
+ }
+ }) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
+ }
+
+ if (showSearch) {
+ OutlinedTextField(
+ value = query,
+ onValueChange = { query = it },
+ placeholder = { Text("Search catalog...") },
+ modifier = Modifier.weight(1f).padding(vertical = 4.dp)
+ .focusRequester(searchFocusRequester),
+ singleLine = true,
+ colors = TextFieldDefaults.colors(
+ focusedContainerColor = Color.Transparent,
+ unfocusedContainerColor = Color.Transparent,
+ disabledContainerColor = Color.Transparent,
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ ),
+ trailingIcon = {
+ IconButton(onClick = {
+ if (query.isNotBlank()) {
+ opdsViewModel.search(query)
+ showSearch = false
+ query = ""
+ }
+ }) {
+ Icon(Icons.Default.Search, "Search")
+ }
+ },
+ keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
+ imeAction = androidx.compose.ui.text.input.ImeAction.Search
+ ),
+ keyboardActions = androidx.compose.foundation.text.KeyboardActions(
+ onSearch = {
+ if (query.isNotBlank()) {
+ opdsViewModel.search(query)
+ showSearch = false
+ query = ""
+ }
+ })
+ )
+ } else {
+ Text(
+ text = uiState.currentFeed?.title ?: "Loading...",
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier.weight(1f).padding(horizontal = 8.dp)
+ )
+ if (uiState.searchUrlTemplate != null) {
+ IconButton(onClick = { showSearch = true }) {
+ Icon(Icons.Default.Search, "Search")
+ }
+ }
+ }
+ }
+
+ if (uiState.isLoading) {
+ androidx.compose.material3.LinearProgressIndicator(
+ modifier = Modifier.fillMaxWidth().align(Alignment.BottomCenter)
+ )
+ }
+ }
+ }
+
+ if (uiState.currentFeed?.entries?.isEmpty() == true && !uiState.isLoading) {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text("This feed is empty.")
+ }
+ } else {
+ val facets = uiState.currentFeed?.facets ?: emptyList()
+ if (facets.isNotEmpty()) {
+ val groups = facets.groupBy { it.group }
+ LazyRow(
+ modifier = Modifier.fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ groups.forEach { (groupName, groupFacets) ->
+ item(key = groupName) {
+ var expanded by remember { mutableStateOf(false) }
+ val activeFacet = groupFacets.find { it.isActive }
+ ?: groupFacets.firstOrNull()
+
+ Box {
+ FilterChip(
+ selected = activeFacet?.isActive == true,
+ onClick = { expanded = true },
+ label = { Text("${groupName}: ${activeFacet?.title ?: "Select"}") },
+ trailingIcon = {
+ Icon(
+ Icons.Default.ArrowDropDown,
+ null
+ )
+ })
+ DropdownMenu(
+ expanded = expanded,
+ onDismissRequest = { expanded = false }) {
+ groupFacets.forEach { facet ->
+ DropdownMenuItem(
+ text = { Text(facet.title) },
+ onClick = {
+ expanded = false
+ opdsViewModel.openFeedUrl(facet.url)
+ },
+ trailingIcon = if (facet.isActive) {
+ { Icon(Icons.Default.Check, null) }
+ } else null)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ LazyColumn(
+ modifier = Modifier.fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ val entries = uiState.currentFeed?.entries ?: emptyList()
+ itemsIndexed(
+ entries,
+ key = { index, item -> "${item.id}_$index" }) { index, entry ->
+
+ if (index == entries.lastIndex) {
+ LaunchedEffect(index) { opdsViewModel.loadNextPage() }
+ }
+
+ if (entry.isNavigation) {
+ OpdsNavigationCard(entry) { opdsViewModel.openFeedUrl(it) }
+ } else {
+ OpdsBookCard(
+ entry = entry,
+ localLibraryFiles = localLibraryFiles,
+ downloadState = downloadingState[entry.id],
+ onDownloadClick = { acquisition ->
+ opdsViewModel.downloadBook(
+ entry, acquisition, context
+ ) { downloadedUri ->
+ onBookDownloaded(downloadedUri, entry.title)
+ }
+ },
+ onReadClick = onReadBook,
+ onStreamClick = {
+ onStreamBook(
+ entry,
+ uiState.currentCatalog
+ )
+ },
+ onClick = { selectedEntry = entry })
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // Error Banner overlay
+ uiState.errorMessage?.let { error ->
+ LaunchedEffect(error) {
+ delay(4000)
+ opdsViewModel.clearError()
+ }
+ Surface(
+ color = MaterialTheme.colorScheme.errorContainer,
+ shape = MaterialTheme.shapes.medium,
+ modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp)
+ .padding(bottom = 70.dp)
+ ) {
+ Text(
+ text = error,
+ color = MaterialTheme.colorScheme.onErrorContainer,
+ modifier = Modifier.padding(16.dp)
+ )
+ }
+ }
+
+ if (selectedEntry != null) {
+ OpdsBookDetailsSheet(
+ entry = selectedEntry!!,
+ localLibraryFiles = localLibraryFiles,
+ downloadState = downloadingState[selectedEntry!!.id],
+ onDownloadFormat = { acquisition ->
+ opdsViewModel.downloadBook(selectedEntry!!, acquisition, context) { downloadedUri ->
+ onBookDownloaded(downloadedUri, selectedEntry!!.title)
+ }
+ },
+ onReadClick = onReadBook,
+ onStreamClick = { selectedEntry?.let { onStreamBook(it, uiState.currentCatalog) } },
+ onAuthorOrCategoryClick = { url, fallbackName ->
+ if (url != null) opdsViewModel.openFeedUrl(url)
+ else opdsViewModel.search(fallbackName)
+ selectedEntry = null
+ },
+ onDismiss = { selectedEntry = null }
+ )
+ }
+ }
+
+ // Dynamic Add/Edit Dialog
+ if (showCatalogDialog) {
+ var newTitle by remember(editingCatalog) { mutableStateOf(editingCatalog?.title ?: "") }
+ var newUrl by remember(editingCatalog) { mutableStateOf(editingCatalog?.url ?: "") }
+ var newUsername by remember(editingCatalog) { mutableStateOf(editingCatalog?.username ?: "") }
+ var newPassword by remember(editingCatalog) { mutableStateOf(editingCatalog?.password ?: "") }
+
+ val isEditMode = editingCatalog != null
+
+ AlertDialog(
+ onDismissRequest = {
+ showCatalogDialog = false
+ editingCatalog = null
+ },
+ title = { Text(if (isEditMode) "Edit Catalog" else "Add OPDS Catalog") },
+ text = {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ OutlinedTextField(
+ value = newTitle,
+ onValueChange = { newTitle = it },
+ label = { Text("Catalog Name") },
+ singleLine = true
+ )
+ OutlinedTextField(
+ value = newUrl,
+ onValueChange = { newUrl = it },
+ label = { Text("URL") },
+ placeholder = { Text("e.g. http://192.168.1.50:8080/opds") },
+ singleLine = true
+ )
+ Text(
+ "Authentication (Optional)",
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ OutlinedTextField(
+ value = newUsername,
+ onValueChange = { newUsername = it },
+ label = { Text("Username") },
+ singleLine = true
+ )
+ OutlinedTextField(
+ value = newPassword,
+ onValueChange = { newPassword = it },
+ label = { Text("Password") },
+ singleLine = true,
+ visualTransformation = PasswordVisualTransformation(),
+ keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Password)
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ if (isEditMode) {
+ opdsViewModel.updateCatalog(editingCatalog!!.id, newTitle, newUrl, newUsername, newPassword)
+ } else {
+ opdsViewModel.addCatalog(newTitle, newUrl, newUsername, newPassword)
+ }
+ showCatalogDialog = false
+ editingCatalog = null
+ },
+ enabled = newTitle.isNotBlank() && newUrl.isNotBlank()
+ ) { Text("Save") }
+ },
+ dismissButton = {
+ TextButton(onClick = {
+ showCatalogDialog = false
+ editingCatalog = null
+ }) { Text("Cancel") }
+ }
+ )
+ }
+
+ if (catalogToDelete != null) {
+ val streamedBooksCount = localLibraryFiles.count { it.uriString?.contains("catalogId=${catalogToDelete!!.id}") == true }
+ AlertDialog(
+ onDismissRequest = { catalogToDelete = null },
+ title = { Text("Delete Catalog") },
+ text = {
+ Column {
+ Text("Are you sure you want to delete '${catalogToDelete!!.title}'?")
+ if (streamedBooksCount > 0) {
+ Spacer(modifier = Modifier.height(8.dp))
+ Text(
+ "Deleting this catalog will also permanently remove $streamedBooksCount streaming books associated with it from your library.",
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ opdsViewModel.removeCatalog(catalogToDelete!!.id)
+ if (streamedBooksCount > 0) {
+ onDeleteCatalogStreams(catalogToDelete!!.id)
+ }
+ catalogToDelete = null
+ },
+ colors = androidx.compose.material3.ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error)
+ ) { Text("Delete") }
+ },
+ dismissButton = {
+ TextButton(onClick = { catalogToDelete = null }) { Text("Cancel") }
+ }
+ )
+ }
+}
+
+@Composable
+fun OpdsCatalogCard(catalog: OpdsCatalog, onClick: () -> Unit, onEdit: (() -> Unit)?, onDelete: (() -> Unit)?) {
+ Surface(
+ onClick = onClick,
+ shape = MaterialTheme.shapes.medium,
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Icon(Icons.Default.FolderSpecial, contentDescription = null, tint = MaterialTheme.colorScheme.primary)
+ Spacer(modifier = Modifier.width(16.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text(catalog.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+ if (catalog.isDefault) {
+ Spacer(modifier = Modifier.width(8.dp))
+ Surface(
+ color = MaterialTheme.colorScheme.secondaryContainer,
+ shape = MaterialTheme.shapes.small
+ ) {
+ Text(
+ text = "Preset",
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.onSecondaryContainer,
+ modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
+ )
+ }
+ }
+ }
+ Text(catalog.url, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ if (onEdit != null) {
+ IconButton(onClick = onEdit) {
+ Icon(Icons.Default.Edit, contentDescription = "Edit")
+ }
+ }
+ if (onDelete != null) {
+ IconButton(onClick = onDelete) {
+ Icon(Icons.Default.Delete, contentDescription = "Remove")
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun OpdsNavigationCard(entry: OpdsEntry, onClick: (String) -> Unit) {
+ Surface(
+ onClick = { entry.navigationUrl?.let { onClick(it) } },
+ shape = MaterialTheme.shapes.medium,
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(16.dp)
+ ) {
+ Icon(Icons.Default.Folder, contentDescription = null, tint = MaterialTheme.colorScheme.secondary)
+ Spacer(modifier = Modifier.width(16.dp))
+ Column {
+ Text(entry.title, style = MaterialTheme.typography.titleMedium)
+ entry.summary?.let {
+ val cleanSummary = remember(it) { Jsoup.parse(it).text() }
+ Text(cleanSummary, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis)
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun OpdsBookCard(
+ entry: OpdsEntry,
+ localLibraryFiles: List,
+ downloadState: OpdsViewModel.DownloadState?,
+ onDownloadClick: (OpdsAcquisition) -> Unit,
+ onReadClick: (RecentFileItem) -> Unit,
+ onStreamClick: () -> Unit,
+ onClick: () -> Unit
+) {
+ val libraryItem = remember(entry, localLibraryFiles) {
+ localLibraryFiles.find { it.title.equals(entry.title, ignoreCase = true) || it.displayName.equals(entry.title, ignoreCase = true) }
+ }
+ val isDownloading = downloadState?.isDownloading == true
+ val progress = downloadState?.progress
+ val uniqueAcquisitions = remember(entry.acquisitions) {
+ entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority }
+ }
+ var showFormatMenu by remember { mutableStateOf(false) }
+
+ Surface(
+ onClick = onClick,
+ shape = MaterialTheme.shapes.medium,
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(modifier = Modifier.padding(12.dp)) {
+ AsyncImage(
+ model = entry.coverUrl,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .size(width = 70.dp, height = 100.dp)
+ .clip(MaterialTheme.shapes.small)
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ )
+ Spacer(modifier = Modifier.width(16.dp))
+ Column(modifier = Modifier.weight(1f)) {
+ Text(entry.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis)
+ entry.author?.let {
+ Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
+ }
+ entry.summary?.let {
+ val cleanSummary = remember(it) { Jsoup.parse(it).text() }
+ Text(cleanSummary, style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.padding(top = 4.dp))
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+
+ if (libraryItem != null) {
+ androidx.compose.material3.OutlinedButton(
+ onClick = { onReadClick(libraryItem) },
+ contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
+ ) {
+ Icon(Icons.Default.Check, null, modifier = Modifier.size(16.dp))
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("Read")
+ }
+ } else if (isDownloading) {
+ Column(modifier = Modifier.fillMaxWidth()) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text("Downloading...", style = MaterialTheme.typography.labelMedium)
+ Spacer(modifier = Modifier.weight(1f))
+ if (progress != null) {
+ Text("${(progress * 100).toInt()}%", style = MaterialTheme.typography.labelMedium)
+ }
+ }
+ Spacer(modifier = Modifier.height(4.dp))
+ if (progress != null) {
+ androidx.compose.material3.LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth())
+ } else {
+ androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
+ }
+ }
+ } else {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ if (entry.isStreamable) {
+ FilledTonalButton(
+ onClick = onStreamClick,
+ contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
+ ) {
+ Icon(painterResource(id = R.drawable.play), null, modifier = Modifier.size(16.dp))
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("Stream")
+ }
+ }
+
+ Box {
+ FilledTonalButton(
+ onClick = {
+ if (uniqueAcquisitions.size == 1) {
+ onDownloadClick(uniqueAcquisitions.first())
+ } else if (uniqueAcquisitions.size > 1) {
+ showFormatMenu = true
+ }
+ },
+ enabled = uniqueAcquisitions.isNotEmpty(),
+ contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
+ ) {
+ if (uniqueAcquisitions.isEmpty()) {
+ Icon(Icons.Default.Info, null, modifier = Modifier.size(16.dp))
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("Unavailable")
+ } else {
+ Icon(Icons.Default.Add, null, modifier = Modifier.size(16.dp))
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("Download")
+ }
+ }
+ }
+ DropdownMenu(
+ expanded = showFormatMenu,
+ onDismissRequest = { showFormatMenu = false }
+ ) {
+ uniqueAcquisitions.forEach { acq ->
+ DropdownMenuItem(
+ text = { Text(acq.formatName) },
+ onClick = {
+ showFormatMenu = false
+ onDownloadClick(acq)
+ }
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun OpdsBookDetailsSheet(
+ entry: OpdsEntry,
+ localLibraryFiles: List,
+ downloadState: OpdsViewModel.DownloadState?,
+ onDownloadFormat: (OpdsAcquisition) -> Unit,
+ onReadClick: (RecentFileItem) -> Unit,
+ onStreamClick: () -> Unit,
+ onAuthorOrCategoryClick: (String?, String) -> Unit,
+ onDismiss: () -> Unit
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+ val libraryItem = remember(entry, localLibraryFiles) {
+ localLibraryFiles.find { it.title.equals(entry.title, ignoreCase = true) || it.displayName.equals(entry.title, ignoreCase = true) }
+ }
+ val isDownloading = downloadState?.isDownloading == true
+ val progress = downloadState?.progress
+ val uniqueAcquisitions = remember(entry.acquisitions) {
+ entry.acquisitions.distinctBy { it.formatName }.sortedByDescending { it.priority }
+ }
+
+ ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp, vertical = 8.dp)
+ .verticalScroll(rememberScrollState()),
+ verticalArrangement = Arrangement.spacedBy(16.dp)
+ ) {
+ Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ AsyncImage(
+ model = entry.coverUrl,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = Modifier
+ .size(width = 110.dp, height = 160.dp)
+ .clip(MaterialTheme.shapes.medium)
+ .background(MaterialTheme.colorScheme.surfaceVariant)
+ )
+
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = entry.title,
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ lineHeight = 28.sp
+ )
+
+ if (entry.authors.isNotEmpty()) {
+ Spacer(modifier = Modifier.height(4.dp))
+ FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ entry.authors.forEach { author ->
+ Text(
+ text = author.name,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.clickable {
+ onAuthorOrCategoryClick(author.url, author.name)
+ }
+ )
+ }
+ }
+ }
+
+ entry.series?.takeIf { it.isNotBlank() }?.let { series ->
+ Spacer(modifier = Modifier.height(8.dp))
+ val seriesText = if (!entry.seriesIndex.isNullOrBlank()) "$series #${entry.seriesIndex}" else series
+ Text(
+ text = seriesText,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.clickable {
+ onAuthorOrCategoryClick(null, series)
+ }
+ )
+ }
+ }
+ }
+
+ if (libraryItem != null) {
+ androidx.compose.material3.Button(
+ onClick = {
+ onDismiss()
+ onReadClick(libraryItem)
+ },
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.medium
+ ) {
+ Icon(Icons.Default.Check, contentDescription = "Read")
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Read", fontWeight = FontWeight.Bold)
+ }
+ }
+
+ if (isDownloading) {
+ Column(modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Text("Downloading...", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+ Spacer(modifier = Modifier.weight(1f))
+ if (progress != null) {
+ Text("${(progress * 100).toInt()}%", style = MaterialTheme.typography.titleMedium)
+ }
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ if (progress != null) {
+ androidx.compose.material3.LinearProgressIndicator(progress = { progress }, modifier = Modifier.fillMaxWidth().height(8.dp))
+ } else {
+ androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(8.dp))
+ }
+ }
+ } else if (uniqueAcquisitions.isNotEmpty() || entry.isStreamable) {
+ if (entry.isStreamable) {
+ androidx.compose.material3.Button(
+ onClick = {
+ onStreamClick()
+ onDismiss()
+ },
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.medium
+ ) {
+ Icon(painterResource(id = R.drawable.play), null, modifier = Modifier.size(18.dp))
+ Spacer(modifier = Modifier.width(8.dp))
+ Text("Stream Now", fontWeight = FontWeight.Bold)
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ }
+
+ if (uniqueAcquisitions.isNotEmpty()) {
+ Text(
+ "Download Format",
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ uniqueAcquisitions.forEach { acq ->
+ FilledTonalButton(onClick = { onDownloadFormat(acq) }) {
+ Icon(Icons.Default.Add, null, modifier = Modifier.size(18.dp))
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(acq.formatName, fontWeight = FontWeight.Bold)
+ }
+ }
+ }
+ }
+ } else {
+ Text("No supported formats available.", color = MaterialTheme.colorScheme.error)
+ }
+
+ if (entry.categories.isNotEmpty()) {
+ FlowRow(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ entry.categories.distinct().forEach { category ->
+ Surface(
+ shape = MaterialTheme.shapes.small,
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ onClick = { onAuthorOrCategoryClick(null, category) }
+ ) {
+ Text(
+ text = category,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)
+ )
+ }
+ }
+ }
+ }
+
+ val hasSecondaryMeta = !entry.publisher.isNullOrBlank() || !entry.published.isNullOrBlank() || !entry.language.isNullOrBlank()
+ if (hasSecondaryMeta) {
+ Surface(
+ shape = MaterialTheme.shapes.medium,
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Row(
+ modifier = Modifier.padding(16.dp),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ entry.publisher?.takeIf { it.isNotBlank() }?.let {
+ Column(modifier = Modifier.weight(1f)) {
+ Text("PUBLISHER", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Text(it, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, maxLines = 2, overflow = TextOverflow.Ellipsis)
+ }
+ }
+ entry.published?.takeIf { it.isNotBlank() }?.let {
+ Column(modifier = Modifier.weight(1f)) {
+ Text("PUBLISHED", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ val cleanDate = it.substringBefore("T")
+ Text(cleanDate, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
+ }
+ }
+ entry.language?.takeIf { it.isNotBlank() }?.let {
+ Column(modifier = Modifier.weight(1f)) {
+ Text("LANGUAGE", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
+ Text(it.uppercase(), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
+ }
+ }
+ }
+ }
+ }
+
+ if (!entry.summary.isNullOrBlank()) {
+ Text("Synopsis", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
+
+ val cleanSummary = remember(entry.summary) {
+ val preProcessed = entry.summary
+ .replace("
", "\n")
+ .replace("
", "\n\n")
+ Jsoup.parse(preProcessed).text().trim()
+ }
+
+ Text(
+ text = cleanSummary,
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurface,
+ lineHeight = 24.sp,
+ modifier = Modifier.padding(bottom = 48.dp)
+ )
+ } else {
+ Spacer(modifier = Modifier.height(48.dp))
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/aryan/reader/MainViewModel.kt b/app/src/main/java/com/aryan/reader/MainViewModel.kt
index 5c1d91a..54b7ff9 100644
--- a/app/src/main/java/com/aryan/reader/MainViewModel.kt
+++ b/app/src/main/java/com/aryan/reader/MainViewModel.kt
@@ -455,6 +455,46 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
+ fun streamOpdsBook(
+ bookId: String,
+ title: String,
+ urlTemplate: String,
+ pageCount: Int,
+ catalogId: String?
+ ) {
+ val encodedUrl = Uri.encode(urlTemplate)
+ val safeId = Uri.encode(bookId)
+ val catId = catalogId?.let { "&catalogId=${Uri.encode(it)}" } ?: ""
+
+ val uriString = "opds-pse://stream?id=$safeId&count=$pageCount&url=$encodedUrl$catId"
+ openBook(uriString.toUri(), bookId, FileType.CBZ, title)
+ }
+
+ fun deleteStreamedBooksForCatalog(catalogId: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ val filesToDelete = recentFilesRepository.getAllFilesForSync().filter {
+ it.uriString?.contains("catalogId=$catalogId") == true
+ }
+ if (filesToDelete.isNotEmpty()) {
+ val ids = filesToDelete.map { it.bookId }
+ ids.forEach { bookId ->
+ pdfTextRepository.clearBookText(bookId)
+ clearImportedFileCache(bookId)
+ try {
+ val cacheDir = File(appContext.cacheDir, "opds_stream_${bookId.hashCode()}")
+ if (cacheDir.exists()) cacheDir.deleteRecursively()
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to clean stream cache for $bookId")
+ }
+ }
+ recentFilesRepository.deleteFilePermanently(ids)
+ withContext(Dispatchers.Main) {
+ showBanner("Removed ${filesToDelete.size} streaming books.")
+ }
+ }
+ }
+ }
+
private val _reviewRequestEvent = Channel(Channel.BUFFERED)
val reviewRequestEvent = _reviewRequestEvent.receiveAsFlow()
private var hasRequestedReviewInThisSession = false
@@ -1169,6 +1209,11 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private fun uploadSingleBookMetadata(book: RecentFileItem) {
if (!uiState.value.isSyncEnabled) return
+ if (book.uriString?.startsWith("opds-pse") == true) {
+ Timber.d("Skipping metadata sync for OPDS stream book: ${book.displayName}")
+ return
+ }
+
if (book.sourceFolderUri != null) {
Timber.d("Skipping metadata sync for local folder book: ${book.displayName}")
return
@@ -2027,11 +2072,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
val localBooks = withContext(Dispatchers.IO) {
val allFiles = recentFilesRepository.getAllFilesForSync()
- if (_internalState.value.isFolderSyncEnabled) {
+ val filtered = if (_internalState.value.isFolderSyncEnabled) {
allFiles
} else {
allFiles.filter { it.sourceFolderUri == null }
}
+ filtered.filterNot { it.uriString?.startsWith("opds-pse") == true }
}
val localShelfNames = prefs.getStringSet(KEY_SHELVES, emptySet()).orEmpty()
@@ -2424,7 +2470,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
if (coverBitmap != null) {
coverPath = recentFilesRepository.saveCoverToCache(coverBitmap, uri)
}
- } else if (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) {
+ } else if (uri.scheme != "opds-pse" && (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7)) {
var cacheFile: File? = null
try {
cacheFile = File(appContext.cacheDir, "temp_archive_cover_${System.currentTimeMillis()}.${type.name.lowercase()}")
@@ -2524,6 +2570,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val oneHourAgo = System.currentTimeMillis() - TimeUnit.HOURS.toMillis(1)
val allDbIds = recentFilesRepository.getAllFilesForSync().map { it.bookId }.toSet()
+ val validStreamHashes = allDbIds.map { it.hashCode().toString() }.toSet()
cacheDir.listFiles()?.forEach { file ->
val name = file.name
@@ -2538,6 +2585,12 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val deleted = file.deleteRecursively()
if (deleted) Timber.d("Sweeper cleaned orphaned extracted cache for: $bookId")
}
+ } else if (name.startsWith("opds_stream_")) {
+ val bookIdHash = name.removePrefix("opds_stream_")
+ if (bookIdHash !in validStreamHashes) {
+ val deleted = if (file.isDirectory) file.deleteRecursively() else file.delete()
+ if (deleted) Timber.d("Sweeper cleaned orphaned OPDS stream cache for hash: $bookIdHash")
+ }
}
}
val legacyExtractedDir = File(cacheDir, "extracted_epubs")
@@ -2837,20 +2890,22 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.tag("FileOpenPerf")
.d("[$bookId] openBook START | type=$type | displayName=$originalDisplayName")
- try {
- val cursor = appContext.contentResolver.query(uri, null, null, null, null)
- cursor?.use {
- if (it.moveToFirst()) {
- val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
- val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
- val size = if (sizeIndex != -1) it.getLong(sizeIndex) else -1L
- val name = if (nameIndex != -1) it.getString(nameIndex) else "unknown"
- Timber.tag("FileOpenPerf")
- .d("[$bookId] File details | name=$name | size=${size} bytes | sizeMB=${size / (1024.0 * 1024)}")
+ if (uri.scheme != "opds-pse") {
+ try {
+ val cursor = appContext.contentResolver.query(uri, null, null, null, null)
+ cursor?.use {
+ if (it.moveToFirst()) {
+ val sizeIndex = it.getColumnIndex(OpenableColumns.SIZE)
+ val nameIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
+ val size = if (sizeIndex != -1) it.getLong(sizeIndex) else -1L
+ val name = if (nameIndex != -1) it.getString(nameIndex) else "unknown"
+ Timber.tag("FileOpenPerf")
+ .d("[$bookId] File details | name=$name | size=${size} bytes | sizeMB=${size / (1024.0 * 1024)}")
+ }
}
+ } catch (e: Exception) {
+ Timber.tag("FileOpenPerf").e(e, "[$bookId] Failed to get file details")
}
- } catch (e: Exception) {
- Timber.tag("FileOpenPerf").e(e, "[$bookId] Failed to get file details")
}
viewModelScope.launch {
diff --git a/app/src/main/java/com/aryan/reader/SharedComposables.kt b/app/src/main/java/com/aryan/reader/SharedComposables.kt
index 30a88bc..48b602e 100644
--- a/app/src/main/java/com/aryan/reader/SharedComposables.kt
+++ b/app/src/main/java/com/aryan/reader/SharedComposables.kt
@@ -335,8 +335,11 @@ fun FileInfoDialog(item: RecentFileItem, onDismiss: () -> Unit, onUpdateName: (S
}
val context = LocalContext.current
+ val isOpdsStream = item.uriString?.startsWith("opds-pse://") == true
val pathText = remember(item.sourceFolderUri, item.uriString, item.displayName, context) {
- if (item.sourceFolderUri != null && item.uriString != null) {
+ if (isOpdsStream) {
+ "Source: OPDS Stream"
+ } else if (item.sourceFolderUri != null && item.uriString != null) {
try {
val uri = item.uriString.toUri()
val docId = if (android.provider.DocumentsContract.isDocumentUri(context, uri)) {
diff --git a/app/src/main/java/com/aryan/reader/opds/OpdsModels.kt b/app/src/main/java/com/aryan/reader/opds/OpdsModels.kt
new file mode 100644
index 0000000..15747ac
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/opds/OpdsModels.kt
@@ -0,0 +1,93 @@
+// OpdsModels.kt
+package com.aryan.reader.opds
+
+data class OpdsCatalog(
+ val id: String,
+ val title: String,
+ val url: String,
+ val isDefault: Boolean = false,
+ val username: String? = null,
+ val password: String? = null
+)
+
+data class OpdsFacet(
+ val title: String,
+ val group: String,
+ val url: String,
+ val isActive: Boolean
+)
+
+data class OpdsFeed(
+ val title: String,
+ val entries: List,
+ val nextUrl: String?,
+ val searchUrl: String? = null,
+ val facets: List = emptyList()
+)
+
+data class OpdsAuthor(
+ val name: String,
+ val url: String?
+)
+
+data class OpdsAcquisition(
+ val url: String,
+ val mimeType: String
+) {
+ val formatName: String
+ get() = when {
+ mimeType.contains("epub") -> "EPUB"
+ mimeType.contains("pdf") -> "PDF"
+ mimeType.contains("mobi") || mimeType.contains("x-mobipocket-ebook") -> "MOBI"
+ mimeType.contains("fictionbook") || mimeType.contains("fb2") -> "FB2"
+ mimeType.contains("cbz") || mimeType.contains("comicbook") -> "CBZ"
+ mimeType.contains("cbr") || mimeType.contains("rar") -> "CBR"
+ mimeType.contains("txt") || mimeType.contains("text/plain") -> "TXT"
+ else -> mimeType.substringAfterLast("/").uppercase()
+ }
+
+ val priority: Int
+ get() = when (formatName) {
+ "EPUB" -> 5
+ "PDF" -> 4
+ "MOBI" -> 3
+ "FB2" -> 2
+ "CBZ" -> 1
+ "TXT" -> 0
+ else -> -1
+ }
+}
+
+data class OpdsEntry(
+ val id: String,
+ val title: String,
+ val summary: String?,
+ val authors: List = emptyList(),
+ val coverUrl: String?,
+ val acquisitions: List = emptyList(),
+ val navigationUrl: String?,
+ val publisher: String? = null,
+ val published: String? = null,
+ val language: String? = null,
+ val series: String? = null,
+ val seriesIndex: String? = null,
+ val categories: List = emptyList(),
+ // ADD THESE:
+ val pseCount: Int? = null,
+ val pseUrlTemplate: String? = null
+) {
+ val author: String?
+ get() = authors.firstOrNull()?.name
+
+ val bestAcquisition: OpdsAcquisition?
+ get() = acquisitions.maxByOrNull { it.priority }
+
+ val isAcquisition: Boolean
+ get() = acquisitions.isNotEmpty()
+
+ val isNavigation: Boolean
+ get() = navigationUrl != null && acquisitions.isEmpty()
+
+ val isStreamable: Boolean
+ get() = pseUrlTemplate != null && pseCount != null && pseCount > 0
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aryan/reader/opds/OpdsParser.kt b/app/src/main/java/com/aryan/reader/opds/OpdsParser.kt
new file mode 100644
index 0000000..00b2c13
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/opds/OpdsParser.kt
@@ -0,0 +1,486 @@
+// OpdsParser.kt
+package com.aryan.reader.opds
+
+import android.util.Xml
+import org.json.JSONArray
+import org.json.JSONObject
+import org.xmlpull.v1.XmlPullParser
+import timber.log.Timber
+import java.io.InputStream
+import java.util.UUID
+
+class OpdsParser {
+
+ fun parse(bodyString: String, baseUrl: String): OpdsFeed {
+ val trimmed = bodyString.trimStart()
+ return if (trimmed.startsWith("{")) {
+ Timber.tag("OpdsDebug").d("Detected OPDS 2.0 (JSON) feed")
+ parseOpds2(trimmed, baseUrl)
+ } else {
+ Timber.tag("OpdsDebug").d("Detected OPDS 1.x (XML) feed")
+ parseOpds1(trimmed.byteInputStream(), baseUrl)
+ }
+ }
+
+ // --- OPDS 2.0 (JSON) Parsing ---
+
+ private fun parseOpds2(jsonString: String, baseUrl: String): OpdsFeed {
+ val root = JSONObject(jsonString)
+ val metadata = root.optJSONObject("metadata")
+ val title = metadata?.optString("title") ?: "OPDS 2.0 Feed"
+
+ var nextUrl: String? = null
+ var searchUrl: String? = null
+ val facets = mutableListOf()
+
+ // Root Links
+ val links = root.optJSONArray("links")
+ if (links != null) {
+ for (i in 0 until links.length()) {
+ val link = links.getJSONObject(i)
+ val relArray = link.optJSONArray("rel")
+ val rels = mutableListOf()
+ if (relArray != null) {
+ for (j in 0 until relArray.length()) rels.add(relArray.getString(j))
+ } else if (link.has("rel")) {
+ val rel = link.optString("rel")
+ if (rel.isNotBlank()) rels.add(rel)
+ }
+
+ val href = link.optString("href")
+ if (href.isNotEmpty()) {
+ val resolvedHref = resolveUrl(baseUrl, href)
+ if (rels.contains("next")) {
+ nextUrl = resolvedHref
+ } else if (rels.contains("search")) {
+ searchUrl = resolvedHref
+ }
+ }
+ }
+ }
+
+ // Facets
+ val facetsArray = root.optJSONArray("facets")
+ if (facetsArray != null) {
+ for (i in 0 until facetsArray.length()) {
+ val facetObj = facetsArray.getJSONObject(i)
+ val group = facetObj.optJSONObject("metadata")?.optString("title") ?: "Filter"
+ val facetLinks = facetObj.optJSONArray("links")
+ if (facetLinks != null) {
+ for (j in 0 until facetLinks.length()) {
+ val link = facetLinks.getJSONObject(j)
+ val href = link.optString("href")
+ if (href.isNotEmpty()) {
+ val titleFacet = link.optString("title", "Facet")
+ val properties = link.optJSONObject("properties")
+ val active = properties?.optBoolean("active", false) ?: false
+ facets.add(OpdsFacet(titleFacet, group, resolveUrl(baseUrl, href), active))
+ }
+ }
+ }
+ }
+ }
+
+ val entries = mutableListOf()
+
+ // Publications
+ val publications = root.optJSONArray("publications")
+ if (publications != null) {
+ for (i in 0 until publications.length()) {
+ entries.add(parseOpds2Publication(publications.getJSONObject(i), baseUrl))
+ }
+ }
+
+ // Navigation
+ val navigation = root.optJSONArray("navigation")
+ if (navigation != null) {
+ for (i in 0 until navigation.length()) {
+ entries.add(parseOpds2Navigation(navigation.getJSONObject(i), baseUrl))
+ }
+ }
+
+ // Groups (Collections containing sub-navigation or sub-publications)
+ val groups = root.optJSONArray("groups")
+ if (groups != null) {
+ for (i in 0 until groups.length()) {
+ val group = groups.getJSONObject(i)
+ val groupTitle = group.optJSONObject("metadata")?.optString("title") ?: ""
+
+ val groupNav = group.optJSONArray("navigation")
+ if (groupNav != null) {
+ for (j in 0 until groupNav.length()) {
+ entries.add(parseOpds2Navigation(groupNav.getJSONObject(j), baseUrl))
+ }
+ }
+
+ val groupPubs = group.optJSONArray("publications")
+ if (groupPubs != null) {
+ for (j in 0 until groupPubs.length()) {
+ entries.add(parseOpds2Publication(groupPubs.getJSONObject(j), baseUrl))
+ }
+ }
+
+ val groupLinks = group.optJSONArray("links")
+ if (groupLinks != null) {
+ for (j in 0 until groupLinks.length()) {
+ val link = groupLinks.getJSONObject(j)
+ val href = link.optString("href")
+ if (href.isNotEmpty()) {
+ val linkTitle = link.optString("title", groupTitle)
+ entries.add(OpdsEntry(
+ id = href,
+ title = linkTitle,
+ summary = null,
+ authors = emptyList(),
+ coverUrl = null,
+ acquisitions = emptyList(),
+ navigationUrl = resolveUrl(baseUrl, href)
+ ))
+ }
+ }
+ }
+ }
+ }
+
+ return OpdsFeed(title, entries, nextUrl, searchUrl, facets)
+ }
+
+ private fun parseOpds2Publication(pub: JSONObject, baseUrl: String): OpdsEntry {
+ val metadata = pub.optJSONObject("metadata")
+ val title = metadata?.optString("title") ?: "Unknown Title"
+ val id = metadata?.optString("identifier") ?: pub.optString("id", UUID.randomUUID().toString())
+ val summary = metadata?.optString("description") ?: metadata?.optString("summary")
+ val language = metadata?.optString("language")
+ val publisher = metadata?.optString("publisher")
+ val published = metadata?.optString("published")
+
+ val authors = mutableListOf()
+ val authorObj = metadata?.opt("author")
+ if (authorObj is String) {
+ authors.add(OpdsAuthor(authorObj, null))
+ } else if (authorObj is JSONArray) {
+ for (i in 0 until authorObj.length()) {
+ val item = authorObj.get(i)
+ if (item is String) authors.add(OpdsAuthor(item, null))
+ else if (item is JSONObject) {
+ val name = item.optString("name")
+ var uri: String? = null
+ val links = item.optJSONArray("links")
+ if (links != null && links.length() > 0) {
+ uri = resolveUrl(baseUrl, links.getJSONObject(0).optString("href"))
+ }
+ if (name.isNotBlank()) authors.add(OpdsAuthor(name, uri))
+ }
+ }
+ } else if (authorObj is JSONObject) {
+ val name = authorObj.optString("name")
+ var uri: String? = null
+ val links = authorObj.optJSONArray("links")
+ if (links != null && links.length() > 0) {
+ uri = resolveUrl(baseUrl, links.getJSONObject(0).optString("href"))
+ }
+ if (name.isNotBlank()) authors.add(OpdsAuthor(name, uri))
+ }
+
+ val categories = mutableListOf()
+ when (val subjectObj = metadata?.opt("subject")) {
+ is String -> categories.add(subjectObj)
+ is JSONArray -> {
+ for (i in 0 until subjectObj.length()) {
+ val subj = subjectObj.get(i)
+ if (subj is String) categories.add(subj)
+ else if (subj is JSONObject) categories.add(subj.optString("name"))
+ }
+ }
+ is JSONObject -> {
+ categories.add(subjectObj.optString("name"))
+ }
+ }
+
+ var series: String? = null
+ var seriesIndex: String? = null
+ val belongsTo = metadata?.optJSONObject("belongsTo")
+ if (belongsTo != null) {
+ val seriesObj = belongsTo.opt("series")
+ if (seriesObj is String) {
+ series = seriesObj
+ } else if (seriesObj is JSONObject) {
+ series = seriesObj.optString("name")
+ if (seriesObj.has("position")) {
+ seriesIndex = seriesObj.optDouble("position").toString().removeSuffix(".0")
+ }
+ } else if (seriesObj is JSONArray && seriesObj.length() > 0) {
+ val firstSeries = seriesObj.get(0)
+ if (firstSeries is String) {
+ series = firstSeries
+ } else if (firstSeries is JSONObject) {
+ series = firstSeries.optString("name")
+ if (firstSeries.has("position")) {
+ seriesIndex = firstSeries.optDouble("position").toString().removeSuffix(".0")
+ }
+ }
+ }
+ }
+
+ var coverUrl: String? = null
+ val images = pub.optJSONArray("images")
+ if (images != null && images.length() > 0) {
+ for (i in 0 until images.length()) {
+ val image = images.getJSONObject(i)
+ val href = image.optString("href")
+ if (href.isNotEmpty()) {
+ val resolvedHref = resolveUrl(baseUrl, href)
+ if (coverUrl == null) coverUrl = resolvedHref
+ val rels = image.opt("rel")
+ var isCover = false
+ if (rels is String && rels == "cover") isCover = true
+ else if (rels is JSONArray) {
+ for (j in 0 until rels.length()) if (rels.optString(j) == "cover") isCover = true
+ }
+ if (isCover) {
+ coverUrl = resolvedHref
+ break
+ }
+ }
+ }
+ }
+
+ val acquisitions = mutableListOf()
+ var pseCount: Int? = null
+ var pseUrlTemplate: String? = null
+
+ val links = pub.optJSONArray("links")
+ if (links != null) {
+ for (i in 0 until links.length()) {
+ val link = links.getJSONObject(i)
+ val href = link.optString("href")
+ if (href.isNotEmpty()) {
+ val rels = link.opt("rel")
+
+ var isStream = false
+ if (rels is String && rels == "http://vaemendis.net/opds-pse/stream") isStream = true
+ else if (rels is JSONArray) {
+ for (j in 0 until rels.length()) if (rels.optString(j) == "http://vaemendis.net/opds-pse/stream") isStream = true
+ }
+ if (isStream) {
+ pseUrlTemplate = resolveUrl(baseUrl, href)
+ val properties = link.optJSONObject("properties")
+ pseCount = properties?.optInt("numberOfItems")?.takeIf { it > 0 }
+ }
+
+ var isAcquisition = false
+ if (rels is String && rels.contains("acquisition")) isAcquisition = true
+ else if (rels is JSONArray) {
+ for (j in 0 until rels.length()) if (rels.optString(j).contains("acquisition")) isAcquisition = true
+ }
+
+ if (isAcquisition) {
+ val type = link.optString("type") ?: ""
+ acquisitions.add(OpdsAcquisition(resolveUrl(baseUrl, href), type))
+ }
+ }
+ }
+ }
+
+ return OpdsEntry(
+ id = id, title = title, summary = summary, authors = authors,
+ coverUrl = coverUrl, acquisitions = acquisitions,
+ navigationUrl = null, publisher = publisher, published = published,
+ language = language, series = series, seriesIndex = seriesIndex, categories = categories,
+ pseCount = pseCount, pseUrlTemplate = pseUrlTemplate
+ )
+ }
+
+ private fun parseOpds2Navigation(nav: JSONObject, baseUrl: String): OpdsEntry {
+ val title = nav.optString("title", "Unknown")
+ val href = nav.optString("href")
+ val summary = nav.optString("description", null)
+ val navigationUrl = if (href.isNotEmpty()) resolveUrl(baseUrl, href) else null
+
+ return OpdsEntry(
+ id = href, title = title, summary = summary, authors = emptyList(),
+ coverUrl = null, acquisitions = emptyList(),
+ navigationUrl = navigationUrl
+ )
+ }
+
+ // --- OPDS 1.x (XML) Parsing ---
+
+ private fun parseOpds1(inputStream: InputStream, baseUrl: String): OpdsFeed {
+ return inputStream.use {
+ val parser: XmlPullParser = Xml.newPullParser()
+ parser.setFeature(XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
+ parser.setInput(it, null)
+ parser.nextTag()
+ Timber.tag("OpdsDebug").d($$"Parser started at root tag: <${parser.name}>")
+ readFeed(parser, baseUrl)
+ }
+ }
+
+ private fun readFeed(parser: XmlPullParser, baseUrl: String): OpdsFeed {
+ var title = ""
+ var nextUrl: String? = null
+ var searchUrl: String? = null
+ val entries = mutableListOf()
+ val facets = mutableListOf()
+
+ parser.require(XmlPullParser.START_TAG, null, "feed")
+ while (parser.next() != XmlPullParser.END_TAG) {
+ if (parser.eventType != XmlPullParser.START_TAG) continue
+
+ when (parser.name.substringAfter(":")) {
+ "title" -> title = readText(parser)
+ "entry" -> entries.add(readEntry(parser, baseUrl))
+ "link" -> {
+ val rel = parser.getAttributeValue(null, "rel")
+ val href = parser.getAttributeValue(null, "href")
+ val linkTitle = parser.getAttributeValue(null, "title")
+ val facetGroup = parser.getAttributeValue(null, "opds:facetGroup") ?: "Filter"
+ val activeFacet = parser.getAttributeValue(null, "opds:activeFacet") == "true"
+
+ if (rel == "next") {
+ nextUrl = resolveUrl(baseUrl, href ?: "")
+ } else if (rel == "search") {
+ searchUrl = resolveUrl(baseUrl, href ?: "")
+ } else if (rel == "facet" || rel == "http://opds-spec.org/facet") {
+ if (href != null && linkTitle != null) {
+ facets.add(OpdsFacet(linkTitle, facetGroup, resolveUrl(baseUrl, href), activeFacet))
+ }
+ }
+ skip(parser)
+ }
+ else -> skip(parser)
+ }
+ }
+ return OpdsFeed(title, entries, nextUrl, searchUrl, facets)
+ }
+
+ private fun readEntry(parser: XmlPullParser, baseUrl: String): OpdsEntry {
+ parser.require(XmlPullParser.START_TAG, null, "entry")
+ var id = ""; var title = ""; var summary: String? = null
+ var coverUrl: String? = null; var navigationUrl: String? = null
+ var publisher: String? = null; var published: String? = null; var language: String? = null
+ var series: String? = null; var seriesIndex: String? = null
+ var pseCount: Int? = null
+ var pseUrlTemplate: String? = null
+ val authors = mutableListOf()
+ val categories = mutableListOf()
+ val acquisitions = mutableListOf()
+
+ while (parser.next() != XmlPullParser.END_TAG) {
+ if (parser.eventType != XmlPullParser.START_TAG) continue
+
+ when (val tagName = parser.name.substringAfter(":")) {
+ "id" -> id = readText(parser)
+ "title" -> title = readText(parser)
+ "summary", "content" -> summary = readText(parser)
+ "author" -> authors.add(readAuthor(parser, baseUrl))
+ "publisher" -> publisher = readText(parser)
+ "language" -> language = language ?: readText(parser)
+ "issued", "published", "updated" -> {
+ val date = readText(parser)
+ if (published == null || tagName != "updated") published = date
+ }
+ "category" -> {
+ val label = parser.getAttributeValue(null, "label")
+ val term = parser.getAttributeValue(null, "term")
+ val cat = label ?: term
+ if (!cat.isNullOrBlank()) categories.add(cat)
+ skip(parser)
+ }
+ "meta" -> {
+ val property = parser.getAttributeValue(null, "property") ?: parser.getAttributeValue(null, "name")
+ val content = parser.getAttributeValue(null, "content")
+ val textContent = readText(parser)
+ if (property == "calibre:series") series = content ?: textContent.takeIf { it.isNotBlank() }
+ else if (property == "calibre:series_index") seriesIndex = content ?: textContent.takeIf { it.isNotBlank() }
+ }
+ "link" -> {
+ val rel = parser.getAttributeValue(null, "rel") ?: ""
+ val href = parser.getAttributeValue(null, "href") ?: ""
+ val type = parser.getAttributeValue(null, "type") ?: ""
+ val linkTitle = parser.getAttributeValue(null, "title")
+
+ if (rel == "http://vaemendis.net/opds-pse/stream") {
+ pseUrlTemplate = resolveUrl(baseUrl, href)
+ val countStr = parser.getAttributeValue(null, "pse:count")
+ pseCount = countStr?.toIntOrNull()
+ }
+
+ if (rel == "http://calibre-ebook.com/opds/series") {
+ if (series == null) series = linkTitle
+ }
+
+ if (href.isNotEmpty()) {
+ val absoluteUrl = resolveUrl(baseUrl, href)
+
+ if (rel.contains("http://opds-spec.org/image")) {
+ if (coverUrl == null || rel.contains("thumbnail")) coverUrl = absoluteUrl
+ } else if (rel.contains("http://opds-spec.org/acquisition")) {
+ acquisitions.add(OpdsAcquisition(absoluteUrl, type))
+ } else if (type.contains("profile=opds-catalog") || type.contains("application/atom+xml")) {
+ if (navigationUrl == null) navigationUrl = absoluteUrl
+ } else if (rel == "subsection" || rel == "collection" || rel == "start") {
+ if (navigationUrl == null) navigationUrl = absoluteUrl
+ }
+ }
+ skip(parser)
+ }
+ else -> skip(parser)
+ }
+ }
+ return OpdsEntry(id, title, summary, authors, coverUrl, acquisitions, navigationUrl, publisher, published, language, series, seriesIndex, categories, pseCount, pseUrlTemplate)
+ }
+
+ private fun readAuthor(parser: XmlPullParser, baseUrl: String): OpdsAuthor {
+ var name = ""
+ var uri: String? = null
+ while (parser.next() != XmlPullParser.END_TAG) {
+ if (parser.eventType != XmlPullParser.START_TAG) continue
+ when (parser.name.substringAfter(":")) {
+ "name" -> name = readText(parser)
+ "uri" -> uri = resolveUrl(baseUrl, readText(parser))
+ else -> skip(parser)
+ }
+ }
+ return OpdsAuthor(name, uri)
+ }
+
+ private fun readText(parser: XmlPullParser): String {
+ val result = StringBuilder()
+ var depth = 1
+
+ while (depth != 0) {
+ when (parser.next()) {
+ XmlPullParser.TEXT, XmlPullParser.CDSECT, XmlPullParser.ENTITY_REF -> {
+ result.append(parser.text)
+ }
+ XmlPullParser.START_TAG -> depth++
+ XmlPullParser.END_TAG -> depth--
+ }
+ }
+ return result.toString().trim()
+ }
+
+ private fun skip(parser: XmlPullParser) {
+ if (parser.eventType != XmlPullParser.START_TAG) throw java.lang.IllegalStateException()
+ var depth = 1
+ while (depth != 0) {
+ when (parser.next()) {
+ XmlPullParser.END_TAG -> depth--
+ XmlPullParser.START_TAG -> depth++
+ }
+ }
+ }
+
+ private fun resolveUrl(baseUrl: String, href: String): String {
+ return try {
+ val resolved = java.net.URL(java.net.URL(baseUrl), href).toString()
+
+ resolved.replace("http://m.gutenberg.org", "https://m.gutenberg.org")
+ .replace("http://www.gutenberg.org", "https://www.gutenberg.org")
+ } catch (_: Exception) {
+ href
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aryan/reader/opds/OpdsRepository.kt b/app/src/main/java/com/aryan/reader/opds/OpdsRepository.kt
new file mode 100644
index 0000000..26e8ec6
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/opds/OpdsRepository.kt
@@ -0,0 +1,265 @@
+package com.aryan.reader.opds
+
+import android.content.Context
+import android.content.SharedPreferences
+import androidx.core.content.edit
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import org.json.JSONArray
+import org.json.JSONObject
+import timber.log.Timber
+import java.security.MessageDigest
+import java.util.UUID
+
+class OpdsRepository(context: Context) {
+ private val prefs: SharedPreferences = context.getSharedPreferences("reader_opds_prefs", Context.MODE_PRIVATE)
+ private val parser = OpdsParser()
+
+ companion object {
+ private const val KEY_CATALOGS_JSON = "opds_catalogs_json"
+
+ val sharedHttpClient: OkHttpClient by lazy {
+ OkHttpClient.Builder().build()
+ }
+ }
+
+ private val httpClient = sharedHttpClient
+
+ fun getCatalogs(): List {
+ val jsonString = prefs.getString(KEY_CATALOGS_JSON, null)
+ val catalogs = mutableListOf()
+
+ if (jsonString != null) {
+ try {
+ val jsonArray = JSONArray(jsonString)
+ for (i in 0 until jsonArray.length()) {
+ val obj = jsonArray.getJSONObject(i)
+ catalogs.add(
+ OpdsCatalog(
+ id = obj.getString("id"),
+ title = obj.getString("title"),
+ url = obj.getString("url"),
+ isDefault = obj.optBoolean("isDefault", false),
+ username = obj.optString("username", "").takeIf { it.isNotBlank() },
+ password = obj.optString("password", "").takeIf { it.isNotBlank() }
+ )
+ )
+ }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ }
+ }
+
+ if (catalogs.isEmpty()) {
+ catalogs.add(OpdsCatalog(UUID.randomUUID().toString(), "Project Gutenberg", "https://m.gutenberg.org/ebooks.opds/", isDefault = true))
+ saveCatalogs(catalogs)
+ }
+
+ return catalogs
+ }
+
+ private fun resolveUrl(baseUrl: String, href: String): String {
+ return try {
+ val resolved = java.net.URL(java.net.URL(baseUrl), href).toString()
+
+ resolved.replace("http://m.gutenberg.org", "https://m.gutenberg.org")
+ .replace("http://www.gutenberg.org", "https://www.gutenberg.org")
+ } catch (_: Exception) {
+ href
+ }
+ }
+
+ suspend fun getSearchTemplate(openSearchUrl: String): String? = withContext(Dispatchers.IO) {
+ try {
+ val request = Request.Builder().url(openSearchUrl).build()
+ val response = httpClient.newCall(request).execute()
+ val body = response.body?.string() ?: return@withContext null
+
+ val parser = android.util.Xml.newPullParser()
+ parser.setFeature(org.xmlpull.v1.XmlPullParser.FEATURE_PROCESS_NAMESPACES, false)
+ parser.setInput(body.byteInputStream(), null)
+ var eventType = parser.eventType
+
+ while (eventType != org.xmlpull.v1.XmlPullParser.END_DOCUMENT) {
+ if (eventType == org.xmlpull.v1.XmlPullParser.START_TAG && parser.name.equals("Url", ignoreCase = true)) {
+ val type = parser.getAttributeValue(null, "type")
+ if (type != null && (type.contains("atom+xml") || type.contains("opds+xml"))) {
+ val template = parser.getAttributeValue(null, "template")
+ if (template != null) {
+ val resolvedTemplate = resolveUrl(openSearchUrl, template)
+ Timber.tag("OpdsDebug").d("Resolved search template: $resolvedTemplate")
+ return@withContext resolvedTemplate
+ }
+ }
+ }
+ eventType = parser.next()
+ }
+ null
+ } catch (e: Exception) {
+ Timber.e(e, "Failed to fetch OpenSearch template")
+ null
+ }
+ }
+
+ fun addCatalog(title: String, url: String, username: String? = null, password: String? = null) {
+ val current = getCatalogs().toMutableList()
+ current.add(OpdsCatalog(UUID.randomUUID().toString(), title, url, username = username, password = password))
+ saveCatalogs(current)
+ }
+
+ fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) {
+ val current = getCatalogs().toMutableList()
+ val index = current.indexOfFirst { it.id == id }
+ if (index != -1 && !current[index].isDefault) {
+ current[index] = current[index].copy(
+ title = title.trim(),
+ url = url.trim(),
+ username = username?.trim().takeIf { !it.isNullOrBlank() },
+ password = password?.trim().takeIf { !it.isNullOrBlank() }
+ )
+ saveCatalogs(current)
+ }
+ }
+
+ fun removeCatalog(id: String) {
+ val current = getCatalogs().toMutableList()
+ val toRemove = current.find { it.id == id }
+ if (toRemove?.isDefault == true) {
+ return
+ }
+ current.removeAll { it.id == id }
+ saveCatalogs(current)
+ }
+
+ private fun saveCatalogs(catalogs: List) {
+ val jsonArray = JSONArray()
+ catalogs.forEach { catalog ->
+ val obj = JSONObject()
+ obj.put("id", catalog.id)
+ obj.put("title", catalog.title)
+ obj.put("url", catalog.url)
+ obj.put("isDefault", catalog.isDefault)
+ if (catalog.username != null) obj.put("username", catalog.username)
+ if (catalog.password != null) obj.put("password", catalog.password)
+ jsonArray.put(obj)
+ }
+ prefs.edit { putString(KEY_CATALOGS_JSON, jsonArray.toString()) }
+ }
+
+ fun getAuthenticatedClient(username: String?, password: String?): OkHttpClient {
+ return httpClient.newBuilder()
+ .authenticator(OpdsAuthenticator(username, password))
+ .build()
+ }
+
+ class OpdsAuthenticator(private val user: String?, private val pass: String?) : okhttp3.Authenticator {
+ private var cnonceCount = 0
+
+ override fun authenticate(route: okhttp3.Route?, response: okhttp3.Response): Request? {
+ if (user.isNullOrBlank() || pass.isNullOrBlank()) return null
+
+ if (response.request.header("Authorization") != null) {
+ return null
+ }
+
+ val wwwAuth = response.header("WWW-Authenticate") ?: return null
+
+ if (wwwAuth.startsWith("Basic", ignoreCase = true)) {
+ val credential = okhttp3.Credentials.basic(user, pass)
+ return response.request.newBuilder().header("Authorization", credential).build()
+ }
+
+ if (wwwAuth.startsWith("Digest", ignoreCase = true)) {
+ val realm = extractParam(wwwAuth, "realm") ?: ""
+ val nonce = extractParam(wwwAuth, "nonce") ?: ""
+ val qop = extractParam(wwwAuth, "qop")
+ val opaque = extractParam(wwwAuth, "opaque")
+
+ cnonceCount++
+ val nc = String.format("%08x", cnonceCount)
+ val cnonce = UUID.randomUUID().toString().replace("-", "")
+
+ val url = response.request.url
+ val uri = url.encodedPath + (if (url.encodedQuery != null) "?${url.encodedQuery}" else "")
+
+ val ha1 = md5("$user:$realm:$pass")
+ val ha2 = md5("${response.request.method}:$uri")
+
+ val responseHash = if (qop != null) {
+ md5("$ha1:$nonce:$nc:$cnonce:$qop:$ha2")
+ } else {
+ md5("$ha1:$nonce:$ha2")
+ }
+
+ val digestHeader = buildString {
+ append("Digest username=\"$user\", ")
+ append("realm=\"$realm\", ")
+ append("nonce=\"$nonce\", ")
+ append("uri=\"$uri\", ")
+ append("response=\"$responseHash\"")
+ if (qop != null) {
+ append(", qop=$qop, nc=$nc, cnonce=\"$cnonce\"")
+ }
+ if (opaque != null) {
+ append(", opaque=\"$opaque\"")
+ }
+ }
+
+ return response.request.newBuilder()
+ .header("Authorization", digestHeader)
+ .build()
+ }
+
+ return null
+ }
+
+ private fun extractParam(header: String, param: String): String? {
+ val match = Regex("$param=\"([^\"]+)\"").find(header) ?: Regex("$param=([^,\\s]+)").find(header)
+ return match?.groupValues?.get(1)
+ }
+
+ private fun md5(input: String): String {
+ val bytes = MessageDigest.getInstance("MD5").digest(input.toByteArray())
+ return bytes.joinToString("") { "%02x".format(it) }
+ }
+ }
+
+
+ suspend fun fetchFeed(url: String, username: String? = null, password: String? = null): Result = withContext(Dispatchers.IO) {
+ Timber.tag("OpdsDebug").d("Starting fetch for URL: $url")
+ try {
+ val client = getAuthenticatedClient(username, password)
+
+ val request = Request.Builder()
+ .url(url.trim())
+ .header("User-Agent", "EpistemeReader/1.0 (Android)")
+ .build()
+
+ Timber.tag("OpdsDebug").d("Executing network call...")
+ val response = client.newCall(request).execute()
+
+ Timber.tag("OpdsDebug").d("Response Code: ${response.code}")
+
+ if (!response.isSuccessful) {
+ val errorMsg = "HTTP ${response.code}: ${response.message}"
+ Timber.tag("OpdsDebug").e("Fetch failed: $errorMsg")
+ return@withContext Result.failure(Exception(errorMsg))
+ }
+
+ val bodyString = response.body?.string()
+ if (bodyString.isNullOrBlank()) {
+ return@withContext Result.failure(Exception("Empty response body"))
+ }
+
+ val feed = parser.parse(bodyString, url)
+
+ Timber.tag("OpdsDebug").d("Parsing complete. Found ${feed.entries.size} entries.")
+ Result.success(feed)
+ } catch (e: Exception) {
+ Timber.tag("OpdsDebug").e(e, "Exception during fetch/parse at URL: $url")
+ Result.failure(e)
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt b/app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt
new file mode 100644
index 0000000..303ed2d
--- /dev/null
+++ b/app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt
@@ -0,0 +1,224 @@
+package com.aryan.reader.opds
+
+import android.app.Application
+import android.content.Context
+import android.net.Uri
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import okhttp3.Request
+import timber.log.Timber
+import java.io.File
+
+data class OpdsScreenState(
+ val catalogs: List = emptyList(),
+ val currentCatalog: OpdsCatalog? = null,
+ val currentFeed: OpdsFeed? = null,
+ val isLoading: Boolean = false,
+ val errorMessage: String? = null,
+ val isViewingCatalog: Boolean = false,
+ val searchUrlTemplate: String? = null
+)
+
+class OpdsViewModel(application: Application) : AndroidViewModel(application) {
+ private val repository = OpdsRepository(application)
+
+ private val _uiState = MutableStateFlow(OpdsScreenState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private val urlStack = mutableListOf()
+
+ private val _downloadingEntries = MutableStateFlow>(emptySet())
+ val downloadingEntries: StateFlow> = _downloadingEntries.asStateFlow()
+
+ private fun fetchUrl(url: String, isPagination: Boolean = false) {
+ viewModelScope.launch {
+ val catalog = _uiState.value.currentCatalog
+ _uiState.update { it.copy(isLoading = true, errorMessage = null, isViewingCatalog = true) }
+
+ val result = repository.fetchFeed(url, catalog?.username, catalog?.password)
+ result.onSuccess { newFeed ->
+ val template = newFeed.searchUrl ?: _uiState.value.searchUrlTemplate
+ if (!isPagination) {
+ if (urlStack.isEmpty() || urlStack.last() != url) {
+ urlStack.add(url)
+ }
+ _uiState.update { it.copy(isLoading = false, currentFeed = newFeed, searchUrlTemplate = template) }
+ } else {
+ _uiState.update { state ->
+ val currentEntries = state.currentFeed?.entries ?: emptyList()
+ state.copy(
+ isLoading = false,
+ currentFeed = newFeed.copy(entries = currentEntries + newFeed.entries),
+ searchUrlTemplate = template
+ )
+ }
+ }
+ }.onFailure { e ->
+ _uiState.update { it.copy(isLoading = false, errorMessage = "Failed to load feed: ${e.message}") }
+ }
+ }
+ }
+
+ fun loadNextPage() {
+ val nextUrl = _uiState.value.currentFeed?.nextUrl
+ if (nextUrl != null && !_uiState.value.isLoading) {
+ fetchUrl(nextUrl, isPagination = true)
+ }
+ }
+
+ data class DownloadState(val isDownloading: Boolean, val progress: Float? = null)
+
+ private val _downloadingState = MutableStateFlow