Support more formats (#112)

* Added support for FB2 book format

* Add support for CBZ files by introducing a `ReaderDocument` abstraction.

Key changes:
- Defined `ReaderDocument`, `ReaderPage`, and `ReaderTextPage` interfaces to provide a unified API for different document types.
- Implemented `PdfDocumentWrapper` and `CbzDocumentWrapper` to handle PDF and CBZ files respectively.
- Updated `PdfViewerScreen`, `PdfPageComposable`, and `MainViewModel` to use the new unified document interfaces.
- Added CBZ file type detection and cover generation logic.

* Add support for CBR and CB7 comic book formats

- Add `me.zhanghai.android.libarchive` dependency to support RAR and 7z archives.
- Implement `ArchiveDocumentWrapper` using `libarchive` to handle CBZ, CBR, and CB7 files uniformly, replacing the previous CBZ-only implementation.
- Update `MainViewModel` and `DocumentFactory` to recognize and process `.cbr` and `.cb7` extensions and their associated MIME types.
- Add support for extracting and caching cover images from CBR and CB7 archives.
- Update UI components (`HomeScreen`, `LibraryScreen`, `AppNavigation`) to handle the new comic book file types.
This commit is contained in:
Aryan 2026-03-26 12:27:56 +05:30 committed by GitHub
parent 32e29dfc07
commit 4db2c97a30
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 871 additions and 93 deletions

View file

@ -111,6 +111,7 @@ import java.util.Date
import java.util.UUID
import java.util.concurrent.CancellationException
import java.util.concurrent.TimeUnit
import androidx.core.graphics.createBitmap
private const val KEY_RENDER_MODE = "render_mode"
private const val KEY_FOLDER_SYNC_ENABLED = "folder_sync_enabled"
@ -134,7 +135,7 @@ enum class AddBooksSource(val displayName: String) {
}
enum class FileType {
PDF, EPUB, MOBI, MD, TXT, HTML
PDF, EPUB, MOBI, MD, TXT, HTML, FB2, CBZ, CBR, CB7
}
enum class RenderMode {
@ -239,6 +240,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
private val bookCacheDao = BookCacheDatabase.getDatabase(application).bookCacheDao()
private val epubParser = EpubParser(appContext)
private val mobiParser = MobiParser(appContext)
private val fb2Parser = com.aryan.reader.epub.Fb2Parser(appContext)
private val singleFileImporter = SingleFileImporter(appContext)
private val bookImporter = BookImporter(appContext)
private val prefs: SharedPreferences =
@ -2327,7 +2329,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
var author: String? = null
var bookForMetadata = epubBook
if (bookForMetadata == null && (type == FileType.EPUB || type == FileType.MOBI || type == FileType.MD || type == FileType.TXT || type == FileType.HTML)) {
if (bookForMetadata == null && (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML)) {
Timber.d("Parsing downloaded book for cover/metadata: $displayName")
Timber.tag("FileOpenPerf")
.d("[$bookId] addFileToRecent: Starting metadata parsing (no book provided)")
@ -2352,6 +2354,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
)
}
FileType.FB2 -> {
fb2Parser.createFb2Book(
inputStream = inputStream,
originalBookNameHint = displayName
)
}
else -> {
singleFileImporter.importSingleFile(
inputStream,
@ -2379,7 +2388,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val finalBookMetadata = bookForMetadata
if ((type == FileType.EPUB || type == FileType.MOBI || type == FileType.MD || type == FileType.TXT || type == FileType.HTML) && finalBookMetadata != null) {
if ((type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML) && finalBookMetadata != null) {
title =
finalBookMetadata.title.takeIf { it.isNotBlank() && it != "content" } ?: displayName
@ -2390,12 +2399,45 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
finalBookMetadata.coverImage?.let { cover ->
coverPath = recentFilesRepository.saveCoverToCache(cover, uri)
}
} else if (type == FileType.PDF) {
} else if (type == FileType.PDF || type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) {
title = displayName
val pdfCoverGenerator = PdfCoverGenerator(appContext)
val coverBitmap = pdfCoverGenerator.generateCover(uri)
if (coverBitmap != null) {
coverPath = recentFilesRepository.saveCoverToCache(coverBitmap, uri)
if (type == FileType.PDF) {
val pdfCoverGenerator = PdfCoverGenerator(appContext)
val coverBitmap = pdfCoverGenerator.generateCover(uri)
if (coverBitmap != null) {
coverPath = recentFilesRepository.saveCoverToCache(coverBitmap, uri)
}
} else if (type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) {
try {
val cacheFile = File(appContext.cacheDir, "temp_archive_cover_${System.currentTimeMillis()}.${type.name.lowercase()}")
withContext(Dispatchers.IO) {
appContext.contentResolver.openInputStream(uri)?.use { input ->
cacheFile.outputStream().use { output -> input.copyTo(output) }
}
}
val archiveDoc = com.aryan.reader.pdf.ArchiveDocumentWrapper(cacheFile)
if (archiveDoc.getPageCount() > 0) {
val page = archiveDoc.openPage(0)
if (page != null) {
val w = page.getPageWidthPoint()
val h = page.getPageHeightPoint()
if (w > 0 && h > 0) {
val targetHeight = 800
val targetWidth = (targetHeight * (w.toFloat() / h.toFloat())).toInt()
if (targetWidth > 0) {
val bitmap = createBitmap(targetWidth, targetHeight)
page.renderPageBitmap(bitmap, 0, 0, targetWidth, targetHeight, false)
coverPath = recentFilesRepository.saveCoverToCache(bitmap, uri)
}
}
page.close()
}
}
archiveDoc.close()
} catch (e: Exception) {
Timber.e(e, "Error generating CBZ cover")
}
}
}
@ -2553,7 +2595,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
val type = item.type
val bookId = item.bookId
if (type == FileType.PDF) {
if (type == FileType.PDF || type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) {
_internalState.update {
it.copy(
selectedEpubUri = null,
@ -2750,7 +2792,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
)
}
if (type == FileType.PDF) {
if (type == FileType.PDF || type == FileType.CBZ || type == FileType.CBR || type == FileType.CB7) {
viewModelScope.launch {
val recentItem = recentFilesRepository.getFileByBookId(bookId)
@ -2779,7 +2821,7 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
sourceFolderUri = null
)
}
} else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.MD || type == FileType.TXT || type == FileType.HTML) {
} else if (type == FileType.EPUB || type == FileType.MOBI || type == FileType.FB2 || type == FileType.MD || type == FileType.TXT || type == FileType.HTML) {
viewModelScope.launch {
val recentItem = recentFilesRepository.getFileByBookId(bookId)
if (recentItem?.sourceFolderUri != null) {
@ -2818,6 +2860,10 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
loadMobi(uri, bookId, customDisplayName = originalDisplayName)
}
FileType.FB2 -> {
loadFb2(uri, bookId, customDisplayName = originalDisplayName)
}
else -> {
loadSingleFile(
uri, bookId, type, customDisplayName = originalDisplayName
@ -2829,6 +2875,41 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
}
}
private fun loadFb2(uri: Uri, bookId: String, customDisplayName: String? = null) {
val loadStart = System.currentTimeMillis()
Timber.tag("FileOpenPerf").d("[$bookId] loadFb2 START")
viewModelScope.launch {
if (!_internalState.value.isLoading) {
_internalState.update { it.copy(isLoading = true, errorMessage = null) }
}
Timber.d("Starting FB2 parsing for URI: $uri")
try {
val fb2Book = withContext(Dispatchers.IO) {
appContext.contentResolver.openInputStream(uri).use { inputStream ->
if (inputStream == null) throw Exception("Could not open input stream")
fb2Parser.createFb2Book(
inputStream,
originalBookNameHint = customDisplayName ?: getFileNameFromUri(uri, appContext) ?: "unknown.fb2"
)
}
}
Timber.i("FB2 parsing successful. Title: ${fb2Book.title}")
Timber.tag("FileOpenPerf").d("[$bookId] loadFb2 completed | chapters=${fb2Book.chapters.size} | elapsed=${System.currentTimeMillis() - loadStart}ms")
addFileToRecent(
uri, FileType.FB2, bookId, fb2Book, customDisplayName, isRecent = true, sourceFolderUri = null
)
_internalState.update { it.copy(selectedEpubBook = fb2Book, isLoading = false) }
} catch (e: Exception) {
Timber.e(e, "Error parsing FB2 for URI: $uri")
_internalState.update {
it.copy(errorMessage = "Failed to load FB2: ${e.message}", isLoading = false)
}
}
}
}
private fun loadSingleFile(
uri: Uri,
bookId: String,
@ -2896,8 +2977,18 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
Timber.d("Determining type for: $uri | Mime: $mimeType | Name: $fileName")
return when (mimeType) {
"application/zip", "application/vnd.comicbook+zip", "application/x-cbz" -> {
if (fileName?.endsWith(".cbz", ignoreCase = true) == true) FileType.CBZ else null
}
"application/vnd.comicbook-rar", "application/x-cbr", "application/x-rar-compressed" -> {
if (fileName?.endsWith(".cbr", ignoreCase = true) == true) FileType.CBR else null
}
"application/x-cb7", "application/x-7z-compressed" -> {
if (fileName?.endsWith(".cb7", ignoreCase = true) == true) FileType.CB7 else null
}
"application/pdf" -> FileType.PDF
"application/epub+zip" -> FileType.EPUB
"application/x-fictionbook+xml", "application/x-zip-compressed-fb2" -> FileType.FB2
"application/x-mobipocket-ebook", "application/vnd.amazon.ebook", "application/vnd.amazon.mobi8-ebook" -> FileType.MOBI
"text/markdown", "text/x-markdown" -> FileType.MD
"text/html", "application/xhtml+xml" -> FileType.HTML
@ -2915,6 +3006,9 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
else -> {
when {
fileName?.endsWith(".cbz", ignoreCase = true) == true -> FileType.CBZ
fileName?.endsWith(".cbr", ignoreCase = true) == true -> FileType.CBR
fileName?.endsWith(".cb7", ignoreCase = true) == true -> FileType.CB7
fileName?.endsWith(".pdf", ignoreCase = true) == true -> FileType.PDF
fileName?.endsWith(".epub", ignoreCase = true) == true -> FileType.EPUB
fileName?.endsWith(
@ -2937,6 +3031,13 @@ open class MainViewModel(application: Application) : AndroidViewModel(applicatio
) == true -> FileType.MD
fileName?.endsWith(".txt", ignoreCase = true) == true -> FileType.TXT
fileName?.endsWith(
".fb2",
ignoreCase = true
) == true || fileName?.endsWith(
".fb2.zip",
ignoreCase = true
) == true -> FileType.FB2
fileName?.endsWith(
".html",
ignoreCase = true