From 15264a31ae2d6eb86f51ab2f7cf42b4d7f9ffbbf Mon Sep 17 00:00:00 2001 From: Aryan Date: Mon, 30 Mar 2026 21:42:06 +0530 Subject: [PATCH] Support opds (#133) * Add OPDS catalog support for book discovery and downloading * Implement OPDS search, improved book discovery, and enhanced UI details. * Improve OPDS search and catalog management * Add authentication support and extended metadata to the OPDS reader. * Add support for OPDS 2.0 (JSON) feeds * Enhance OPDS book downloading with progress tracking and multi-format support. * Enhance OPDS book management and UI * Add support for OPDS-PSE (Page Streaming Extension) streaming * Improve OPDS streaming and catalog management --- app/src/main/AndroidManifest.xml | 1 + .../main/java/com/aryan/reader/HomeScreen.kt | 22 + .../java/com/aryan/reader/LibraryScreen.kt | 909 +++++++++++++++++- .../java/com/aryan/reader/MainViewModel.kt | 83 +- .../com/aryan/reader/SharedComposables.kt | 5 +- .../java/com/aryan/reader/opds/OpdsModels.kt | 93 ++ .../java/com/aryan/reader/opds/OpdsParser.kt | 486 ++++++++++ .../com/aryan/reader/opds/OpdsRepository.kt | 265 +++++ .../com/aryan/reader/opds/OpdsViewModel.kt | 224 +++++ .../com/aryan/reader/pdf/PdfViewerScreen.kt | 9 +- .../com/aryan/reader/pdf/UniversalDocument.kt | 133 ++- .../main/res/xml/network_security_config.xml | 11 +- 12 files changed, 2208 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/com/aryan/reader/opds/OpdsModels.kt create mode 100644 app/src/main/java/com/aryan/reader/opds/OpdsParser.kt create mode 100644 app/src/main/java/com/aryan/reader/opds/OpdsRepository.kt create mode 100644 app/src/main/java/com/aryan/reader/opds/OpdsViewModel.kt 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>(emptyMap()) + val downloadingState: StateFlow> = _downloadingState.asStateFlow() + + fun downloadBook(entry: OpdsEntry, acquisition: OpdsAcquisition, context: Context, onDownloaded: (Uri) -> Unit) { + val downloadUrl = acquisition.url + val catalog = _uiState.value.currentCatalog + viewModelScope.launch(Dispatchers.IO) { + _downloadingState.update { it + (entry.id to DownloadState(true, 0f)) } + try { + val client = repository.getAuthenticatedClient(catalog?.username, catalog?.password) + val request = Request.Builder().url(downloadUrl).build() + + val response = client.newCall(request).execute() + + if (response.isSuccessful) { + val body = response.body ?: throw Exception("Empty body") + val contentLength = body.contentLength() + + val ext = when (acquisition.formatName) { + "EPUB" -> ".epub" + "PDF" -> ".pdf" + "MOBI" -> ".mobi" + "FB2" -> ".fb2" + "CBZ" -> ".cbz" + "CBR" -> ".cbr" + "TXT" -> ".txt" + else -> ".epub" + } + + val safeTitle = entry.title.replace(Regex("[^a-zA-Z0-9.-]"), "_").take(50) + val tempFile = File(context.cacheDir, "opds_dl_${safeTitle}$ext") + + val input = body.byteStream() + val output = tempFile.outputStream() + val buffer = ByteArray(8 * 1024) + var bytesRead: Int + var totalRead = 0L + var lastProgressUpdate = System.currentTimeMillis() + + input.use { inp -> + output.use { out -> + while (inp.read(buffer).also { bytesRead = it } != -1) { + out.write(buffer, 0, bytesRead) + totalRead += bytesRead + if (contentLength > 0) { + val now = System.currentTimeMillis() + // Throttle UI updates to 4-5 fps + if (now - lastProgressUpdate > 200) { + val progress = totalRead.toFloat() / contentLength.toFloat() + _downloadingState.update { it + (entry.id to DownloadState(true, progress)) } + lastProgressUpdate = now + } + } + } + } + } + + withContext(Dispatchers.Main) { + onDownloaded(Uri.fromFile(tempFile)) + } + } else { + Timber.e("Download failed: ${response.code}") + _uiState.update { it.copy(errorMessage = "Download failed: ${response.message}") } + } + } catch (e: Exception) { + Timber.e(e, "Download error") + _uiState.update { it.copy(errorMessage = "Download error: ${e.message}") } + } finally { + _downloadingState.update { it - entry.id } + } + } + } + + init { + loadCatalogs() + } + + private fun loadCatalogs() { + _uiState.update { it.copy(catalogs = repository.getCatalogs()) } + } + + fun addCatalog(title: String, url: String, username: String?, password: String?) { + repository.addCatalog(title, url, username, password) + loadCatalogs() + } + + fun removeCatalog(id: String) { + repository.removeCatalog(id) + loadCatalogs() + } + + fun openCatalog(catalog: OpdsCatalog) { + urlStack.clear() + _uiState.update { it.copy(searchUrlTemplate = null, currentCatalog = catalog) } + fetchUrl(catalog.url) + } + + fun openFeedUrl(url: String) { + fetchUrl(url) + } + + fun navigateBack(): Boolean { + if (urlStack.size > 1) { + urlStack.removeAt(urlStack.lastIndex) + val previousUrl = urlStack.last() + urlStack.removeAt(urlStack.lastIndex) + fetchUrl(previousUrl) + return true + } else { + urlStack.clear() + _uiState.update { it.copy(isViewingCatalog = false, currentFeed = null, searchUrlTemplate = null, currentCatalog = null) } + return false + } + } + + fun updateCatalog(id: String, title: String, url: String, username: String?, password: String?) { + repository.updateCatalog(id, title, url, username, password) + loadCatalogs() + } + + fun search(query: String) { + val searchLink = _uiState.value.searchUrlTemplate ?: return + + viewModelScope.launch { + _uiState.update { it.copy(isLoading = true, errorMessage = null) } + + val template = if (!searchLink.contains("{searchTerms}")) { + repository.getSearchTemplate(searchLink) ?: searchLink + } else { + searchLink + } + + val finalUrl = if (template.contains("{searchTerms}")) { + template.replace("{searchTerms}", Uri.encode(query)) + } else { + val separator = if (template.contains("?")) "&" else "?" + "$template${separator}query=${Uri.encode(query)}" + } + + openFeedUrl(finalUrl) + } + } + + fun clearError() { + _uiState.update { it.copy(errorMessage = null) } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt index fa161ab..84c189a 100644 --- a/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt +++ b/app/src/main/java/com/aryan/reader/pdf/PdfViewerScreen.kt @@ -3089,14 +3089,17 @@ fun PdfViewerScreen( try { withContext(Dispatchers.IO) { Timber.d("Opening ParcelFileDescriptor for URI: $pdfUri") - currentPfdOpened = context.contentResolver.openFileDescriptor(pdfUri, "r") - if (currentPfdOpened == null) throw Exception("Failed to open ParcelFileDescriptor") + + if (pdfUri.scheme != "opds-pse") { + currentPfdOpened = context.contentResolver.openFileDescriptor(pdfUri, "r") + if (currentPfdOpened == null) throw Exception("Failed to open ParcelFileDescriptor") + } val doc = DocumentFactory.loadDocument(context, pdfUri, uiState.selectedFileType ?: FileType.PDF, documentPassword, pdfiumCore) if (!isActive) { doc.close() - currentPfdOpened.close() + currentPfdOpened?.close() return@withContext } diff --git a/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt b/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt index 1d1cb06..699470b 100644 --- a/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt +++ b/app/src/main/java/com/aryan/reader/pdf/UniversalDocument.kt @@ -24,9 +24,11 @@ import java.io.File import me.zhanghai.android.libarchive.Archive import me.zhanghai.android.libarchive.ArchiveEntry import me.zhanghai.android.libarchive.ArchiveException +import okhttp3.Request import timber.log.Timber import java.util.UUID import java.util.zip.ZipFile +import androidx.core.graphics.createBitmap interface ReaderDocument : AutoCloseable { suspend fun getPageCount(): Int @@ -68,6 +70,13 @@ interface ReaderWebLinks : AutoCloseable { object DocumentFactory { suspend fun loadDocument(context: Context, uri: Uri, type: FileType, password: String?, pdfiumCore: PdfiumCoreKt): ReaderDocument { + if (uri.scheme == "opds-pse") { + val bookId = uri.getQueryParameter("id") ?: UUID.randomUUID().toString() + val urlTemplate = uri.getQueryParameter("url") ?: "" + val count = uri.getQueryParameter("count")?.toIntOrNull() ?: 0 + val catalogId = uri.getQueryParameter("catalogId") + return OpdsStreamDocumentWrapper(context, bookId, urlTemplate, count, catalogId) + } return if (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) { val cacheFile = File(context.cacheDir, "temp_comic_${System.currentTimeMillis()}.${type.name.lowercase()}") withContext(Dispatchers.IO) { @@ -117,13 +126,36 @@ class PdfPageWrapper(val pdfPage: PdfPageKt) : ReaderPage { } override fun getNativePointer(): Long { - return try { - val field = pdfPage.javaClass.getDeclaredField("mNativePagePtr") - field.isAccessible = true - field.get(pdfPage) as? Long ?: 0L - } catch (_: Exception) { - 0L + return extractNativePointer(pdfPage) + } + + private fun extractNativePointer(obj: Any): Long { + val priorityFields = listOf("page", "mNativePagePtr", "pagePtr", "mNativePage") + + for (name in priorityFields) { + try { + val field = obj.javaClass.getDeclaredField(name) + field.isAccessible = true + val value = field.get(obj) + if (value is Long && value != 0L) return value + if (value != null && value !is Long) { + val nestedPtr = extractNativePointer(value) + if (nestedPtr != 0L) return nestedPtr + } + } catch (_: Exception) {} } + + try { + for (field in obj.javaClass.declaredFields) { + if (field.type == Long::class.java || field.type == Long::class.javaPrimitiveType) { + field.isAccessible = true + val value = field.get(obj) as Long + if (value > 0xFFFFFFFFL) return value + } + } + } catch (_: Exception) {} + + return 0L } override fun close() { pdfPage.close() } @@ -388,4 +420,93 @@ class ArchivePageWrapper(imageBytes: ByteArray) : ReaderPage { override fun close() { decoder?.recycle() } +} + +class OpdsStreamDocumentWrapper( + private val context: Context, + private val bookId: String, + private val urlTemplate: String, + private val pageCount: Int, + private val catalogId: String? +) : ReaderDocument { + private val cacheDir = File(context.cacheDir, "opds_stream_${bookId.hashCode()}").apply { mkdirs() } + + private val catalog = catalogId?.let { + com.aryan.reader.opds.OpdsRepository(context).getCatalogs().find { c -> c.id == it } + } + + private val client = com.aryan.reader.opds.OpdsRepository.sharedHttpClient.newBuilder() + .apply { + if (!catalog?.username.isNullOrBlank() && !catalog.password.isNullOrBlank()) { + authenticator(com.aryan.reader.opds.OpdsRepository.OpdsAuthenticator(catalog.username, catalog.password)) + } + } + .build() + + private fun createErrorPageBytes(): ByteArray { + val bitmap = createBitmap(800, 1200) + val canvas = Canvas(bitmap) + canvas.drawColor(android.graphics.Color.DKGRAY) + val paint = Paint().apply { + color = android.graphics.Color.WHITE + textSize = 40f + textAlign = Paint.Align.CENTER + } + canvas.drawText("Page Unavailable", 400f, 600f, paint) + val stream = java.io.ByteArrayOutputStream() + bitmap.compress(Bitmap.CompressFormat.JPEG, 80, stream) + return stream.toByteArray() + } + + override suspend fun getPageCount() = pageCount + + override suspend fun openPage(pageIndex: Int): ReaderPage? = withContext(Dispatchers.IO) { + if (pageIndex !in 0 until pageCount) return@withContext null + + val cachedFile = File(cacheDir, "page_$pageIndex.jpg") + if (cachedFile.exists() && cachedFile.length() > 0) { + try { + return@withContext ArchivePageWrapper(cachedFile.readBytes()) + } catch (e: Exception) { + Timber.e(e, "Failed to read cached stream page") + } + } + + val finalUrlTemplate = if (catalog != null && urlTemplate.startsWith("http")) { + try { + val oldUrl = java.net.URL(urlTemplate) + val newUrl = java.net.URL(catalog.url) + val oldBase = "${oldUrl.protocol}://${oldUrl.authority}" + val newBase = "${newUrl.protocol}://${newUrl.authority}" + urlTemplate.replace(oldBase, newBase) + } catch (_: Exception) { + urlTemplate + } + } else urlTemplate + + val url = finalUrlTemplate.replace("{pageNumber}", pageIndex.toString()) + .replace("{maxWidth}", "1600") + + val request = Request.Builder().url(url).build() + try { + val response = client.newCall(request).execute() + if (response.isSuccessful) { + val bytes = response.body?.bytes() + if (bytes != null && bytes.isNotEmpty()) { + cachedFile.writeBytes(bytes) + return@withContext ArchivePageWrapper(bytes) + } + } else { + Timber.e("Stream page failed with HTTP ${response.code}") + } + } catch (e: Exception) { + Timber.e(e, "Failed to fetch stream page $pageIndex") + } + + return@withContext ArchivePageWrapper(createErrorPageBytes()) + } + + override suspend fun getTableOfContents() = emptyList() + + override fun close() {} } \ No newline at end of file diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml index 5d9ae56..f18e1f0 100644 --- a/app/src/main/res/xml/network_security_config.xml +++ b/app/src/main/res/xml/network_security_config.xml @@ -1,9 +1,8 @@ - - 192.168.141.181 - - - 192.168.31.49 - + + + + + \ No newline at end of file