Initial commit

This commit is contained in:
Aryan 2026-02-24 17:37:40 +05:30
commit 6072b2ba29
844 changed files with 220532 additions and 0 deletions

View file

@ -0,0 +1,172 @@
// AppDatabase.kt
package com.aryan.reader.data
import android.content.Context
import androidx.room.Database
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Database(entities = [RecentFileEntity::class, CustomFontEntity::class], version = 12, exportSchema = false)
@TypeConverters(FileTypeConverter::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun recentFileDao(): RecentFileDao
abstract fun customFontDao(): CustomFontDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE recent_files ADD COLUMN lastChapterIndex INTEGER")
db.execSQL("ALTER TABLE recent_files ADD COLUMN lastScrollYPosition INTEGER")
}
}
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
}
}
val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE recent_files ADD COLUMN lastPositionCfi TEXT")
}
}
val MIGRATION_4_5 = object : Migration(4, 5) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE recent_files ADD COLUMN progressPercentage REAL")
}
}
val MIGRATION_5_6 = object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE recent_files ADD COLUMN isRecent INTEGER NOT NULL DEFAULT 1")
}
}
val MIGRATION_6_7 = object : Migration(6, 7) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("""
CREATE TABLE recent_files_new (
bookId TEXT NOT NULL PRIMARY KEY,
uriString TEXT,
type TEXT NOT NULL,
displayName TEXT NOT NULL,
timestamp INTEGER NOT NULL,
coverImagePath TEXT,
title TEXT,
author TEXT,
lastChapterIndex INTEGER,
lastScrollYPosition INTEGER,
lastPage INTEGER,
lastPositionCfi TEXT,
progressPercentage REAL,
isRecent INTEGER NOT NULL DEFAULT 1,
isAvailable INTEGER NOT NULL DEFAULT 1
)
""")
db.execSQL("""
INSERT INTO recent_files_new (bookId, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastScrollYPosition, lastPositionCfi, progressPercentage, isRecent)
SELECT uriString, uriString, type, displayName, timestamp, coverImagePath, title, author, lastChapterIndex, lastScrollYPosition, lastPositionCfi, progressPercentage, isRecent FROM recent_files
""")
db.execSQL("DROP TABLE recent_files")
db.execSQL("ALTER TABLE recent_files_new RENAME TO recent_files")
}
}
val MIGRATION_7_8 = object : Migration(7, 8) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE recent_files ADD COLUMN lastModifiedTimestamp INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE recent_files ADD COLUMN isDeleted INTEGER NOT NULL DEFAULT 0")
}
}
val MIGRATION_8_9 = object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE recent_files ADD COLUMN locatorBlockIndex INTEGER")
db.execSQL("ALTER TABLE recent_files ADD COLUMN locatorCharOffset INTEGER")
db.execSQL("""
CREATE TABLE recent_files_new (
bookId TEXT NOT NULL PRIMARY KEY, uriString TEXT, type TEXT NOT NULL,
displayName TEXT NOT NULL, timestamp INTEGER NOT NULL, coverImagePath TEXT,
title TEXT, author TEXT, lastChapterIndex INTEGER, lastPage INTEGER,
lastPositionCfi TEXT, progressPercentage REAL,
isRecent INTEGER NOT NULL DEFAULT 1,
isAvailable INTEGER NOT NULL DEFAULT 1,
lastModifiedTimestamp INTEGER NOT NULL DEFAULT 0,
isDeleted INTEGER NOT NULL DEFAULT 0,
locatorBlockIndex INTEGER, locatorCharOffset INTEGER
)
""")
db.execSQL("""
INSERT INTO recent_files_new (
bookId, uriString, type, displayName, timestamp, coverImagePath, title, author,
lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent,
isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset
)
SELECT
bookId, uriString, type, displayName, timestamp, coverImagePath, title, author,
lastChapterIndex, lastPage, lastPositionCfi, progressPercentage, isRecent,
isAvailable, lastModifiedTimestamp, isDeleted, locatorBlockIndex, locatorCharOffset
FROM recent_files
""")
db.execSQL("DROP TABLE recent_files")
db.execSQL("ALTER TABLE recent_files_new RENAME TO recent_files")
}
}
val MIGRATION_9_10 = object : Migration(9, 10) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE recent_files ADD COLUMN bookmarks TEXT")
}
}
val MIGRATION_10_11 = object : Migration(10, 11) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE recent_files ADD COLUMN sourceFolderUri TEXT DEFAULT NULL")
}
}
val MIGRATION_11_12 = object : Migration(11, 12) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("""
CREATE TABLE IF NOT EXISTS `custom_fonts` (
`id` TEXT NOT NULL,
`displayName` TEXT NOT NULL,
`fileName` TEXT NOT NULL,
`fileExtension` TEXT NOT NULL,
`path` TEXT NOT NULL,
`timestamp` INTEGER NOT NULL,
`isDeleted` INTEGER NOT NULL DEFAULT 0,
PRIMARY KEY(`id`)
)
""")
}
}
fun getDatabase(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
val instance = Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"reader_database"
)
// 4. Add migration to builder
.addMigrations(
MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5,
MIGRATION_5_6, MIGRATION_6_7, MIGRATION_7_8, MIGRATION_8_9,
MIGRATION_9_10, MIGRATION_10_11, MIGRATION_11_12
)
.fallbackToDestructiveMigration(false)
.build()
INSTANCE = instance
instance
}
}
}
}

View file

@ -0,0 +1,35 @@
// CustomFontDao.kt
package com.aryan.reader.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface CustomFontDao {
@Query("SELECT * FROM custom_fonts WHERE isDeleted = 0 ORDER BY displayName ASC")
fun getAllFonts(): Flow<List<CustomFontEntity>>
@Query("SELECT * FROM custom_fonts WHERE isDeleted = 0")
suspend fun getAllFontsList(): List<CustomFontEntity>
@Query("SELECT * FROM custom_fonts")
suspend fun getAllFontsIncludingDeleted(): List<CustomFontEntity>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertFont(font: CustomFontEntity)
@Query("SELECT * FROM custom_fonts WHERE id = :id")
suspend fun getFontById(id: String): CustomFontEntity?
@Query("UPDATE custom_fonts SET isDeleted = 1 WHERE id = :id")
suspend fun markAsDeleted(id: String)
@Query("DELETE FROM custom_fonts WHERE id = :id")
suspend fun deletePermanently(id: String)
@Query("SELECT * FROM custom_fonts WHERE fileName = :fileName LIMIT 1")
suspend fun getFontByFileName(fileName: String): CustomFontEntity?
}

View file

@ -0,0 +1,16 @@
package com.aryan.reader.data
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity(tableName = "custom_fonts")
data class CustomFontEntity(
@PrimaryKey val id: String, // UUID
val displayName: String,
val fileName: String, // The actual filename on disk (e.g., font_uuid.ttf)
val fileExtension: String, // ttf, otf, woff2
val path: String, // Absolute path to the file
val timestamp: Long,
@ColumnInfo(defaultValue = "0") val isDeleted: Boolean = false
)

View file

@ -0,0 +1,17 @@
// FileTypeConverter.kt
package com.aryan.reader.data
import androidx.room.TypeConverter
import com.aryan.reader.FileType
class FileTypeConverter {
@TypeConverter
fun fromFileType(fileType: FileType?): String? {
return fileType?.name
}
@TypeConverter
fun toFileType(name: String?): FileType? {
return name?.let { FileType.valueOf(it) }
}
}

View file

@ -0,0 +1,137 @@
// FontsRepository.kt
package com.aryan.reader.data
import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.FileOutputStream
import java.util.UUID
private const val FONTS_DIR = "custom_fonts"
class FontsRepository(private val context: Context) {
private val fontDao = AppDatabase.getDatabase(context).customFontDao()
private val fontsDir = File(context.filesDir, FONTS_DIR)
init {
if (!fontsDir.exists()) {
fontsDir.mkdirs()
}
}
fun getAllFonts(): Flow<List<CustomFontEntity>> = fontDao.getAllFonts()
suspend fun getAllFontsForSync(): List<CustomFontEntity> = fontDao.getAllFontsIncludingDeleted()
@Suppress("unused")
suspend fun getFontById(id: String): CustomFontEntity? = fontDao.getFontById(id)
// Used when downloading from cloud
fun getFontFile(fileName: String): File {
return File(fontsDir, fileName)
}
suspend fun addFontFromSync(metadata: FontMetadata) = withContext(Dispatchers.IO) {
val fontFile = File(fontsDir, metadata.fileName)
val entity = CustomFontEntity(
id = metadata.id,
displayName = metadata.displayName,
fileName = metadata.fileName,
fileExtension = metadata.fileExtension,
path = fontFile.absolutePath,
timestamp = metadata.timestamp,
isDeleted = metadata.isDeleted
)
fontDao.insertFont(entity)
}
suspend fun importFont(uri: Uri): Result<CustomFontEntity> = withContext(Dispatchers.IO) {
try {
val contentResolver = context.contentResolver
val originalName = getFileName(uri) ?: "unknown.ttf"
val extension = originalName.substringAfterLast('.', "").lowercase()
if (extension !in listOf("ttf", "otf", "woff2")) {
return@withContext Result.failure(Exception("Unsupported font format. Please use TTF, OTF, or WOFF2."))
}
val fontId = UUID.randomUUID().toString()
val internalFileName = "font_${fontId}.$extension"
val destinationFile = File(fontsDir, internalFileName)
contentResolver.openInputStream(uri)?.use { input ->
FileOutputStream(destinationFile).use { output ->
input.copyTo(output)
}
}
val displayName = originalName.substringBeforeLast('.')
val entity = CustomFontEntity(
id = fontId,
displayName = displayName,
fileName = internalFileName,
fileExtension = extension,
path = destinationFile.absolutePath,
timestamp = System.currentTimeMillis()
)
fontDao.insertFont(entity)
Timber.d("Imported font: $displayName to ${destinationFile.absolutePath}")
Result.success(entity)
} catch (e: Exception) {
Timber.e(e, "Failed to import font")
Result.failure(e)
}
}
suspend fun deleteFont(fontId: String) = withContext(Dispatchers.IO) {
val font = fontDao.getFontById(fontId) ?: return@withContext
fontDao.markAsDeleted(fontId)
// We delete the file locally to save space, but keep the DB entry as tombstone for sync
val file = File(font.path)
if (file.exists()) {
file.delete()
}
Timber.d("Deleted font locally: ${font.displayName}")
}
suspend fun deletePermanently(fontId: String) = withContext(Dispatchers.IO) {
val font = fontDao.getFontById(fontId)
font?.let {
val file = File(it.path)
if(file.exists()) file.delete()
}
fontDao.deletePermanently(fontId)
}
private fun getFileName(uri: Uri): String? {
var result: String? = null
if (uri.scheme == "content") {
val cursor = context.contentResolver.query(uri, null, null, null, null)
cursor?.use {
if (it.moveToFirst()) {
val index = it.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (index != -1) {
result = it.getString(index)
}
}
}
}
if (result == null) {
result = uri.path
val cut = result?.lastIndexOf('/')
if (cut != -1) {
result = result?.substring(cut!! + 1)
}
}
return result
}
}

View file

@ -0,0 +1,25 @@
package com.aryan.reader.data
/**
* Agnostic representation of a purchase to decouple MainViewModel from Billing Library.
*/
data class PurchaseEntity(
val orderId: String?,
val products: List<String>,
val purchaseToken: String,
val purchaseTime: Long,
val isAcknowledged: Boolean,
val isAutoRenewing: Boolean
)
/**
* Agnostic representation of product details.
*/
data class ProductDetailsEntity(
val productId: String,
val name: String,
val description: String,
val formattedPrice: String,
val currencyCode: String,
val priceAmountMicros: Long
)

View file

@ -0,0 +1,59 @@
// RecentFileDao.kt
package com.aryan.reader.data
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import kotlinx.coroutines.flow.Flow
@Dao
interface RecentFileDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertOrUpdateFile(file: RecentFileEntity)
@Query("SELECT * FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC")
fun getRecentFiles(): Flow<List<RecentFileEntity>>
@Query("SELECT * FROM recent_files")
suspend fun getAllFiles(): List<RecentFileEntity>
@Query("SELECT * FROM recent_files WHERE isDeleted = 0 ORDER BY timestamp DESC LIMIT :limit")
fun getRecentFilesList(limit: Int): List<RecentFileEntity>
@Query("DELETE FROM recent_files WHERE bookId IN (:bookIds)")
suspend fun deleteFilePermanently(bookIds: List<String>)
@Query("UPDATE recent_files SET isDeleted = 1, isAvailable = 0, lastModifiedTimestamp = :timestamp WHERE bookId IN (:bookIds)")
suspend fun markAsDeleted(bookIds: List<String>, timestamp: Long)
@Query("SELECT * FROM recent_files WHERE lastModifiedTimestamp > :sinceTimestamp")
suspend fun getModifiedSince(sinceTimestamp: Long): List<RecentFileEntity>
@Query("SELECT COUNT(*) FROM recent_files")
suspend fun count(): Int
@Query("SELECT * FROM recent_files WHERE bookId = :bookId")
suspend fun getFileByBookId(bookId: String): RecentFileEntity?
@Query("SELECT * FROM recent_files WHERE uriString = :uriString")
suspend fun getFileByUri(uriString: String): RecentFileEntity?
@Query("DELETE FROM recent_files")
suspend fun clearAll()
@Query("UPDATE recent_files SET lastPositionCfi = :cfi, lastChapterIndex = :chapterIndex, locatorBlockIndex = :blockIndex, locatorCharOffset = :charOffset, progressPercentage = :progress, timestamp = :timestamp, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")
suspend fun updateEpubReadingPosition(bookId: String, cfi: String?, chapterIndex: Int, blockIndex: Int, charOffset: Int, progress: Float, timestamp: Long)
@Query("UPDATE recent_files SET lastPage = :page, progressPercentage = :progress, timestamp = :timestamp, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")
suspend fun updatePdfReadingPosition(bookId: String, page: Int, progress: Float, timestamp: Long)
@Query("UPDATE recent_files SET bookmarks = :bookmarksJson, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")
suspend fun updateBookmarks(bookId: String, bookmarksJson: String, timestamp: Long)
@Query("UPDATE recent_files SET isAvailable = 1, uriString = :uriString, timestamp = :timestamp, lastModifiedTimestamp = :timestamp WHERE bookId = :bookId")
suspend fun updateBookAvailability(bookId: String, uriString: String, timestamp: Long)
@Query("UPDATE recent_files SET isRecent = 0, lastModifiedTimestamp = :timestamp WHERE bookId IN (:bookIds)")
suspend fun markAsNotRecent(bookIds: List<String>, timestamp: Long)
}

View file

@ -0,0 +1,33 @@
// RecentFileEntity.kt
package com.aryan.reader.data
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.TypeConverters
import com.aryan.reader.FileType
@Entity(tableName = "recent_files")
@TypeConverters(FileTypeConverter::class)
data class RecentFileEntity(
@PrimaryKey val bookId: String,
val uriString: String?,
val type: FileType,
val displayName: String,
val timestamp: Long,
val coverImagePath: String?,
val title: String?,
val author: String?,
@ColumnInfo(name = "lastChapterIndex") val lastChapterIndex: Int?,
val lastPage: Int?,
@ColumnInfo(name = "lastPositionCfi") val lastPositionCfi: String?,
@ColumnInfo(name = "progressPercentage") val progressPercentage: Float?,
@ColumnInfo(defaultValue = "1") val isRecent: Boolean,
@ColumnInfo(defaultValue = "1") val isAvailable: Boolean,
val lastModifiedTimestamp: Long,
@ColumnInfo(defaultValue = "0") val isDeleted: Boolean,
val locatorBlockIndex: Int?,
val locatorCharOffset: Int?,
val bookmarks: String?,
@ColumnInfo(defaultValue = "NULL") val sourceFolderUri: String?
)

View file

@ -0,0 +1,126 @@
// RecentFileItem.kt
package com.aryan.reader.data
import android.net.Uri
import com.aryan.reader.FileType
import androidx.core.net.toUri
data class RecentFileItem(
val bookId: String,
val uriString: String?,
val type: FileType,
val displayName: String,
val timestamp: Long,
val coverImagePath: String? = null,
val title: String? = null,
val author: String? = null,
val lastChapterIndex: Int? = null,
val lastPage: Int? = null,
val lastPositionCfi: String? = null,
val locatorBlockIndex: Int? = null,
val locatorCharOffset: Int? = null,
val progressPercentage: Float? = null,
val isRecent: Boolean = true,
val isAvailable: Boolean = true,
val lastModifiedTimestamp: Long = 0L,
val isDeleted: Boolean = false,
val bookmarksJson: String? = null,
val sourceFolderUri: String? = null
) {
fun getUri(): Uri? = uriString?.toUri()
}
fun RecentFileEntity.toRecentFileItem(): RecentFileItem {
return RecentFileItem(
bookId = this.bookId,
uriString = this.uriString,
type = this.type,
displayName = this.displayName,
timestamp = this.timestamp,
coverImagePath = this.coverImagePath,
title = this.title,
author = this.author,
lastChapterIndex = this.lastChapterIndex,
locatorBlockIndex = this.locatorBlockIndex,
locatorCharOffset = this.locatorCharOffset,
lastPage = this.lastPage,
lastPositionCfi = this.lastPositionCfi,
progressPercentage = this.progressPercentage,
isRecent = this.isRecent,
isAvailable = this.isAvailable,
lastModifiedTimestamp = this.lastModifiedTimestamp,
isDeleted = this.isDeleted,
bookmarksJson = this.bookmarks,
sourceFolderUri = this.sourceFolderUri
)
}
fun RecentFileItem.toRecentFileEntity(): RecentFileEntity {
return RecentFileEntity(
bookId = this.bookId,
uriString = this.uriString,
type = this.type,
displayName = this.displayName,
timestamp = this.timestamp,
coverImagePath = this.coverImagePath,
title = this.title,
author = this.author,
lastChapterIndex = this.lastChapterIndex,
locatorBlockIndex = this.locatorBlockIndex,
locatorCharOffset = this.locatorCharOffset,
lastPage = this.lastPage,
lastPositionCfi = this.lastPositionCfi,
progressPercentage = this.progressPercentage,
isRecent = this.isRecent,
isAvailable = this.isAvailable,
lastModifiedTimestamp = this.lastModifiedTimestamp,
isDeleted = this.isDeleted,
bookmarks = this.bookmarksJson,
sourceFolderUri = this.sourceFolderUri
)
}
fun RecentFileItem.toBookMetadata(): BookMetadata {
return BookMetadata(
bookId = this.bookId,
title = this.title,
author = this.author,
displayName = this.displayName,
type = this.type.name,
lastPositionCfi = this.lastPositionCfi,
lastChapterIndex = this.lastChapterIndex,
locatorBlockIndex = this.locatorBlockIndex,
locatorCharOffset = this.locatorCharOffset,
lastPage = this.lastPage,
progressPercentage = this.progressPercentage,
isRecent = this.isRecent,
isDeleted = this.isDeleted,
lastModifiedTimestamp = this.lastModifiedTimestamp,
bookmarksJson = this.bookmarksJson,
hasAnnotations = false
)
}
fun BookMetadata.toRecentFileItem(): RecentFileItem {
return RecentFileItem(
bookId = this.bookId,
uriString = null,
type = try { FileType.valueOf(this.type) } catch (_: Exception) { FileType.EPUB },
displayName = this.displayName,
timestamp = this.lastModifiedTimestamp,
coverImagePath = null,
title = this.title,
author = this.author,
lastChapterIndex = this.lastChapterIndex,
locatorBlockIndex = this.locatorBlockIndex,
locatorCharOffset = this.locatorCharOffset,
lastPositionCfi = this.lastPositionCfi,
lastPage = this.lastPage,
progressPercentage = this.progressPercentage,
isRecent = this.isRecent,
isAvailable = false,
lastModifiedTimestamp = this.lastModifiedTimestamp,
isDeleted = this.isDeleted,
bookmarksJson = this.bookmarksJson
)
}

View file

@ -0,0 +1,201 @@
// RecentFilesRepository.kt
package com.aryan.reader.data
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import timber.log.Timber
import com.aryan.reader.BookImporter
import com.aryan.reader.paginatedreader.Locator
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
private const val COVER_CACHE_DIR = "cover_cache"
class RecentFilesRepository(context: Context) {
private val recentFileDao = AppDatabase.getDatabase(context).recentFileDao()
private val coverCacheDir = File(context.filesDir, COVER_CACHE_DIR)
private val bookImporter = BookImporter(context)
init {
if (!coverCacheDir.exists()) {
coverCacheDir.mkdirs()
}
}
fun getRecentFilesFlow(): Flow<List<RecentFileItem>> {
return recentFileDao.getRecentFiles().map { entities ->
entities.map { it.toRecentFileItem() }
}
}
suspend fun getFileByBookId(bookId: String): RecentFileItem? = withContext(Dispatchers.IO) {
return@withContext recentFileDao.getFileByBookId(bookId)?.toRecentFileItem()
}
suspend fun getFileByUri(uriString: String): RecentFileItem? = withContext(Dispatchers.IO) {
return@withContext recentFileDao.getFileByUri(uriString)?.toRecentFileItem()
}
suspend fun getAllFilesForSync(): List<RecentFileItem> = withContext(Dispatchers.IO) {
return@withContext recentFileDao.getAllFiles().map { it.toRecentFileItem() }
}
suspend fun clearAllLocalData() = withContext(Dispatchers.IO) {
recentFileDao.clearAll()
if (coverCacheDir.exists()) {
coverCacheDir.deleteRecursively()
}
coverCacheDir.mkdirs()
Timber.d("Cleared all local book data and cover cache.")
}
suspend fun addRecentFile(item: RecentFileItem) = withContext(Dispatchers.IO) {
Timber.d("SyncDebug: addRecentFile called for bookId: ${item.bookId}")
Timber.d("SyncDebug: -> Incoming item: title='${item.title}', uri='${item.uriString}', isAvailable=${item.isAvailable}, isDeleted=${item.isDeleted}, isRecent=${item.isRecent}")
val existingItem = recentFileDao.getFileByBookId(item.bookId)
Timber.d("SyncDebug: -> Existing item found: ${existingItem != null}")
if (existingItem != null) {
Timber.d("SyncDebug: -> Existing item details: title='${existingItem.title}', uri='${existingItem.uriString}', isAvailable=${existingItem.isAvailable}, isRecent=${existingItem.isRecent}")
}
val entityToInsert = if (existingItem != null) {
item.toRecentFileEntity().copy(
uriString = existingItem.uriString ?: item.uriString,
isAvailable = existingItem.isAvailable || item.isAvailable,
coverImagePath = item.coverImagePath ?: existingItem.coverImagePath,
title = item.title ?: existingItem.title,
author = item.author ?: existingItem.author,
lastChapterIndex = item.lastChapterIndex ?: existingItem.lastChapterIndex,
lastPage = item.lastPage ?: existingItem.lastPage,
lastPositionCfi = item.lastPositionCfi ?: existingItem.lastPositionCfi,
locatorBlockIndex = item.locatorBlockIndex ?: existingItem.locatorBlockIndex,
locatorCharOffset = item.locatorCharOffset ?: existingItem.locatorCharOffset,
bookmarks = item.bookmarksJson ?: existingItem.bookmarks,
progressPercentage = item.progressPercentage ?: existingItem.progressPercentage,
isRecent = item.isRecent,
isDeleted = item.isDeleted,
sourceFolderUri = item.sourceFolderUri ?: existingItem.sourceFolderUri
)
} else {
item.toRecentFileEntity()
}
Timber.d("SyncDebug: -> Final entity to insert: uri='${entityToInsert.uriString}', isAvailable=${entityToInsert.isAvailable}, isDeleted=${entityToInsert.isDeleted}, isRecent=${entityToInsert.isRecent}")
recentFileDao.insertOrUpdateFile(entityToInsert)
Timber.d("Added/Updated recent file in DB: ${item.displayName}")
}
suspend fun updateEpubReadingPosition(uriString: String, locator: Locator, cfiForWebView: String?, progress: Float) = withContext(Dispatchers.IO) {
val item = recentFileDao.getFileByUri(uriString)
if (item != null) {
val currentTime = System.currentTimeMillis()
recentFileDao.updateEpubReadingPosition(
bookId = item.bookId,
cfi = cfiForWebView,
chapterIndex = locator.chapterIndex,
blockIndex = locator.blockIndex,
charOffset = locator.charOffset,
progress = progress,
timestamp = currentTime
)
Timber.d("Updated EPUB reading position for ${item.bookId} to Locator: $locator, Progress: $progress%")
}
}
suspend fun updateBookmarks(bookId: String, bookmarksJson: String) = withContext(Dispatchers.IO) {
val currentTime = System.currentTimeMillis()
recentFileDao.updateBookmarks(bookId, bookmarksJson, currentTime)
Timber.d("Updated bookmarks for $bookId")
}
suspend fun updatePdfReadingPosition(uriString: String, page: Int, progress: Float) = withContext(Dispatchers.IO) {
val item = recentFileDao.getFileByUri(uriString)
if (item != null) {
val currentTime = System.currentTimeMillis()
recentFileDao.updatePdfReadingPosition(item.bookId, page, progress, currentTime)
Timber.d("Updated PDF reading position for ${item.bookId} to page $page, progress $progress%")
}
}
@Suppress("unused")
suspend fun makeBookAvailable(bookId: String, internalUri: Uri) = withContext(Dispatchers.IO) {
val currentTime = System.currentTimeMillis()
recentFileDao.updateBookAvailability(bookId, internalUri.toString(), currentTime)
Timber.d("Made book available locally: $bookId at URI $internalUri")
}
suspend fun markAsNotRecent(bookIds: List<String>) = withContext(Dispatchers.IO) {
if (bookIds.isNotEmpty()) {
Timber.d("DeleteDebug: DAO - Marking ${bookIds.size} items as not recent.")
recentFileDao.markAsNotRecent(bookIds, System.currentTimeMillis())
}
}
suspend fun markAsDeleted(bookIds: List<String>) = withContext(Dispatchers.IO) {
if (bookIds.isNotEmpty()) {
recentFileDao.markAsDeleted(bookIds, System.currentTimeMillis())
Timber.d("DeleteDebug: DAO - Marked ${bookIds.size} items as deleted.")
}
}
suspend fun deleteFilePermanently(bookIds: List<String>) = withContext(Dispatchers.IO) {
if (bookIds.isEmpty()) return@withContext
val itemsToRemove = bookIds.mapNotNull { recentFileDao.getFileByBookId(it) }
if (itemsToRemove.isNotEmpty()) {
Timber.d("DeleteDebug: DAO - Permanently deleting ${itemsToRemove.size} files.")
itemsToRemove.forEach { item ->
item.coverImagePath?.let { deleteCachedCover(it) }
item.uriString?.let { bookImporter.deleteBookByUriString(it) }
}
recentFileDao.deleteFilePermanently(itemsToRemove.map { it.bookId })
Timber.d("Permanently removed recent files from DB.")
} else {
Timber.w("DeleteDebug: DAO - Files not found for permanent deletion.")
}
}
private fun getCoverCacheDirInternal(): File {
if (!coverCacheDir.exists()) {
coverCacheDir.mkdirs()
}
return coverCacheDir
}
suspend fun saveCoverToCache(bitmap: Bitmap, uri: Uri): String? = withContext(Dispatchers.IO) {
val cacheDir = getCoverCacheDirInternal()
val filename = "cover_${uri.toString().hashCode()}.png"
val file = File(cacheDir, filename)
var fos: FileOutputStream? = null
try {
fos = FileOutputStream(file)
bitmap.compress(Bitmap.CompressFormat.PNG, 90, fos)
Timber.d("Saved cover image to: ${file.absolutePath}")
return@withContext file.absolutePath
} catch (e: Exception) {
Timber.e(e, "Failed to save cover image to cache for $uri")
file.delete()
return@withContext null
} finally {
fos?.close()
}
}
private fun deleteCachedCover(filePath: String): Boolean {
val file = File(filePath)
val deleted = file.delete()
if (deleted) {
Timber.d("Deleted cached cover: $filePath")
} else {
Timber.w("Failed to delete cached cover: $filePath")
}
return deleted
}
}