book-reader/app/src/main/java/com/aryan/reader/FolderSyncWorker.kt
Aryan c6363f3061
Update readme (#3)
docs: Revamp README and add APK naming convention

This commit introduces a complete overhaul of the `README.md` to improve project presentation. Key changes include:
*   Adds a new header with the app icon and a Google Play badge.
*   Updates the feature graphic and includes a note distinguishing the OSS version from the Play Store version.
*   Refines the "Open Source Libraries" section to be more structured.
*   Moves the main preview image into the `docs` directory.

Additionally, the `app/build.gradle.kts` file is updated to configure a standardized naming convention for output APK files, including flavor, version, and build type.

* refactor copyright headers and fix README icon alignment

This commit introduces two main changes across the codebase:

The `spotless/copyright.kt` template have been updated for consistency:
*   The author name is simplified from "Episteme Authors" to "Episteme".
*   The contact email prefix is shortened from "Electronic mail:" to "mail:".

Second, the `README.md` file is adjusted to correct the vertical alignment of the app icon with the project title.
2026-02-25 21:30:28 +05:30

223 lines
No EOL
8.7 KiB
Kotlin

/*
* Episteme Reader - A native Android document reader.
* Copyright (C) 2026 Episteme
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* mail: epistemereader@gmail.com
*/
package com.aryan.reader
import android.content.Context
import android.net.Uri
import timber.log.Timber
import androidx.core.net.toUri
import androidx.documentfile.provider.DocumentFile
import androidx.work.CoroutineWorker
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.aryan.reader.data.RecentFileItem
import com.aryan.reader.data.RecentFilesRepository
import com.aryan.reader.epub.EpubParser
import com.aryan.reader.epub.MobiParser
import com.aryan.reader.pdf.PdfCoverGenerator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import androidx.core.content.edit
class FolderSyncWorker(
private val appContext: Context,
workerParams: WorkerParameters
) : CoroutineWorker(appContext, workerParams) {
private val recentFilesRepository = RecentFilesRepository(appContext)
private val bookImporter = BookImporter(appContext)
private val epubParser = EpubParser(appContext)
private val mobiParser = MobiParser(appContext)
private val pdfCoverGenerator = PdfCoverGenerator(appContext)
companion object {
const val WORK_NAME = "FolderSyncWorker"
}
override suspend fun doWork(): Result {
Timber.d("Worker starting folder sync check.")
val prefs = appContext.getSharedPreferences("reader_user_prefs", Context.MODE_PRIVATE)
val folderUriString = prefs.getString(MainViewModel.KEY_SYNCED_FOLDER_URI, null)
if (folderUriString.isNullOrBlank()) {
Timber.d("No sync folder configured. Worker stopping.")
return Result.success()
}
val folderUri = folderUriString.toUri()
return withContext(Dispatchers.IO) {
try {
val documentTree = DocumentFile.fromTreeUri(appContext, folderUri)
if (documentTree == null || !documentTree.isDirectory) {
Timber.e("Could not read the synced folder URI: $folderUriString. Cancelling worker.")
WorkManager.getInstance(appContext).cancelUniqueWork(WORK_NAME)
return@withContext Result.failure()
}
val filesToScan = mutableListOf<DocumentFile>()
val fileQueue = ArrayDeque<DocumentFile>()
documentTree.listFiles().let { fileQueue.addAll(it) }
while (fileQueue.isNotEmpty()) {
val file = fileQueue.removeAt(0)
if (file.isDirectory) {
file.listFiles().let { fileQueue.addAll(it) }
} else if (file.isFile) {
val fileName = file.name ?: ""
if (fileName.endsWith(".pdf", true) || fileName.endsWith(".epub", true) || fileName.endsWith(".mobi", true) || fileName.endsWith(".azw3", true)) {
filesToScan.add(file)
}
}
}
var importedCount = 0
for (file in filesToScan) {
val importResult = prepareBookForImport(file.uri)
if (importResult != null) {
val (internalUri, bookId, type) = importResult
val displayName = file.name ?: "Unknown File"
addBookToDatabase(internalUri, type, bookId, displayName, folderUriString)
importedCount++
}
}
if (importedCount > 0) {
Timber.d("Worker successfully imported $importedCount new book(s).")
} else {
Timber.d("Worker found no new books to import.")
}
prefs.edit {
putLong(
MainViewModel.KEY_LAST_FOLDER_SCAN_TIME,
System.currentTimeMillis()
)
}
Result.success()
} catch (e: Exception) {
Timber.e(e, "Error during folder sync worker execution.")
Result.failure()
}
}
}
private suspend fun prepareBookForImport(externalUri: Uri): Triple<Uri, String, FileType>? {
val type = getFileTypeFromUri(externalUri, appContext) ?: return null
val hash = FileHasher.calculateSha256 {
appContext.contentResolver.openInputStream(externalUri)
} ?: return null
if (recentFilesRepository.getFileByBookId(hash) != null) {
return null // Already exists
}
val internalFile = bookImporter.importBook(externalUri) ?: return null
return Triple(internalFile.toUri(), hash, type)
}
private fun getFileNameFromUri(uri: Uri): String? {
return DocumentFile.fromSingleUri(appContext, uri)?.name
}
private suspend fun addBookToDatabase(
uri: Uri,
type: FileType,
bookId: String,
displayName: String,
sourceFolderUri: String
) {
var coverPath: String? = null
var title: String? = null
var author: String? = null
if (type == FileType.EPUB || type == FileType.MOBI) {
val book = withContext(Dispatchers.IO) {
appContext.contentResolver.openInputStream(uri)?.use { inputStream ->
if (type == FileType.EPUB) {
epubParser.createEpubBook(
inputStream = inputStream,
originalBookNameHint = displayName
)
} else {
mobiParser.createMobiBook(
inputStream = inputStream,
originalBookNameHint = displayName
)
}
}
}
if (book != null) {
title = book.title.takeIf { it.isNotBlank() } ?: displayName
author = book.author.takeIf { it.isNotBlank() }
book.coverImage?.let {
coverPath = recentFilesRepository.saveCoverToCache(it, uri)
}
}
} else if (type == FileType.PDF) {
title = displayName
pdfCoverGenerator.generateCover(uri)?.let {
coverPath = recentFilesRepository.saveCoverToCache(it, uri)
}
}
val newItem = RecentFileItem(
bookId = bookId,
uriString = uri.toString(),
type = type,
displayName = displayName,
timestamp = System.currentTimeMillis(),
coverImagePath = coverPath,
title = title,
author = author,
isAvailable = true,
lastModifiedTimestamp = System.currentTimeMillis(),
isDeleted = false,
isRecent = false, // Books from folder sync should not appear on the Home screen
sourceFolderUri = sourceFolderUri
)
recentFilesRepository.addRecentFile(newItem)
Timber.i("Worker added new book to database: $displayName")
}
private fun getFileTypeFromUri(uri: Uri, context: Context): FileType? {
val mimeType = context.contentResolver.getType(uri)
return when (mimeType) {
"application/pdf" -> FileType.PDF
"application/epub+zip" -> FileType.EPUB
"application/x-mobipocket-ebook",
"application/vnd.amazon.ebook",
"application/vnd.amazon.mobi8-ebook" -> FileType.MOBI
else -> {
val path = getFileNameFromUri(uri)
when {
path?.endsWith(".pdf", ignoreCase = true) == true -> FileType.PDF
path?.endsWith(".epub", ignoreCase = true) == true -> FileType.EPUB
path?.endsWith(".mobi", ignoreCase = true) == true -> FileType.MOBI
path?.endsWith(".azw3", ignoreCase = true) == true -> FileType.MOBI
path?.endsWith(".prc", ignoreCase = true) == true -> FileType.MOBI
else -> null
}
}
}
}
}