Initial commit
This commit is contained in:
commit
6072b2ba29
844 changed files with 220532 additions and 0 deletions
172
app/src/main/java/com/aryan/reader/data/AppDatabase.kt
Normal file
172
app/src/main/java/com/aryan/reader/data/AppDatabase.kt
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
35
app/src/main/java/com/aryan/reader/data/CustomFontDao.kt
Normal file
35
app/src/main/java/com/aryan/reader/data/CustomFontDao.kt
Normal 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?
|
||||
}
|
||||
16
app/src/main/java/com/aryan/reader/data/CustomFontEntity.kt
Normal file
16
app/src/main/java/com/aryan/reader/data/CustomFontEntity.kt
Normal 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
|
||||
)
|
||||
17
app/src/main/java/com/aryan/reader/data/FileTypeConverter.kt
Normal file
17
app/src/main/java/com/aryan/reader/data/FileTypeConverter.kt
Normal 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) }
|
||||
}
|
||||
}
|
||||
137
app/src/main/java/com/aryan/reader/data/FontsRepository.kt
Normal file
137
app/src/main/java/com/aryan/reader/data/FontsRepository.kt
Normal 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
|
||||
}
|
||||
}
|
||||
25
app/src/main/java/com/aryan/reader/data/PurchaseEntities.kt
Normal file
25
app/src/main/java/com/aryan/reader/data/PurchaseEntities.kt
Normal 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
|
||||
)
|
||||
59
app/src/main/java/com/aryan/reader/data/RecentFileDao.kt
Normal file
59
app/src/main/java/com/aryan/reader/data/RecentFileDao.kt
Normal 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)
|
||||
}
|
||||
33
app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt
Normal file
33
app/src/main/java/com/aryan/reader/data/RecentFileEntity.kt
Normal 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?
|
||||
)
|
||||
126
app/src/main/java/com/aryan/reader/data/RecentFileItem.kt
Normal file
126
app/src/main/java/com/aryan/reader/data/RecentFileItem.kt
Normal 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
|
||||
)
|
||||
}
|
||||
201
app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt
Normal file
201
app/src/main/java/com/aryan/reader/data/RecentFilesRepository.kt
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue