diff --git a/app/schemas/com.starry.myne.database.MyneDatabase/5.json b/app/schemas/com.starry.myne.database.MyneDatabase/5.json new file mode 100644 index 00000000..115cf3da --- /dev/null +++ b/app/schemas/com.starry.myne.database.MyneDatabase/5.json @@ -0,0 +1,116 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "a45c727254ac24ff3a2e67f604e06159", + "entities": [ + { + "tableName": "book_library", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`book_id` INTEGER NOT NULL, `title` TEXT NOT NULL, `authors` TEXT NOT NULL, `file_path` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `is_external_book` INTEGER NOT NULL DEFAULT false, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "bookId", + "columnName": "book_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authors", + "columnName": "authors", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "filePath", + "columnName": "file_path", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isExternalBook", + "columnName": "is_external_book", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "false" + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "reader_table", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`library_item_id` INTEGER NOT NULL, `last_chapter_index` INTEGER NOT NULL, `last_chapter_offset` INTEGER NOT NULL, `last_read_time` INTEGER NOT NULL DEFAULT 0, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL)", + "fields": [ + { + "fieldPath": "libraryItemId", + "columnName": "library_item_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastChapterIndex", + "columnName": "last_chapter_index", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastChapterOffset", + "columnName": "last_chapter_offset", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadTime", + "columnName": "last_read_time", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a45c727254ac24ff3a2e67f604e06159')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 999ebb07..449b9d48 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -25,6 +25,12 @@ + + + + + + diff --git a/app/src/main/java/com/starry/myne/MainActivity.kt b/app/src/main/java/com/starry/myne/MainActivity.kt index d444ebc3..0d5395b7 100644 --- a/app/src/main/java/com/starry/myne/MainActivity.kt +++ b/app/src/main/java/com/starry/myne/MainActivity.kt @@ -16,7 +16,9 @@ package com.starry.myne +import android.content.pm.ShortcutManager import android.os.Bundle +import android.util.Log import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.appcompat.app.AppCompatActivity @@ -73,9 +75,33 @@ class MainActivity : AppCompatActivity() { initial = NetworkObserver.Status.Unavailable ) - MainScreen(startDestination = startDestination, networkStatus = status) + MainScreen( + intent = intent, + startDestination = startDestination, + networkStatus = status + ) } } } } + + override fun onPause() { + super.onPause() + updateShortcuts() + } + + private fun updateShortcuts() { + val shortcutManager = getSystemService(ShortcutManager::class.java) + mainViewModel.buildDynamicShortcuts( + context = this, + limit = shortcutManager.maxShortcutCountPerActivity, + onComplete = { shortcuts -> + try { + shortcutManager.dynamicShortcuts = shortcuts + } catch (e: IllegalArgumentException) { + Log.e("MainActivity", "Error setting dynamic shortcuts", e) + } + } + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/starry/myne/MainViewModel.kt b/app/src/main/java/com/starry/myne/MainViewModel.kt index ee4fb2be..b84a343c 100644 --- a/app/src/main/java/com/starry/myne/MainViewModel.kt +++ b/app/src/main/java/com/starry/myne/MainViewModel.kt @@ -17,21 +17,34 @@ package com.starry.myne +import android.content.Context +import android.content.Intent +import android.content.pm.ShortcutInfo +import android.graphics.drawable.Icon +import android.net.Uri import androidx.compose.runtime.MutableState import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.starry.myne.database.library.LibraryDao +import com.starry.myne.database.reader.ReaderDao import com.starry.myne.ui.navigation.BottomBarScreen import com.starry.myne.ui.navigation.Screens import com.starry.myne.ui.screens.welcome.viewmodels.WelcomeDataStore import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import javax.inject.Inject @HiltViewModel -class MainViewModel @Inject constructor(private val welcomeDataStore: WelcomeDataStore) : +class MainViewModel @Inject constructor( + private val welcomeDataStore: WelcomeDataStore, + private val libraryDao: LibraryDao, + private val readerDao: ReaderDao +) : ViewModel() { private val _isLoading: MutableState = mutableStateOf(true) val isLoading: State = _isLoading @@ -40,6 +53,17 @@ class MainViewModel @Inject constructor(private val welcomeDataStore: WelcomeDat mutableStateOf(Screens.WelcomeScreen.route) val startDestination: State = _startDestination + companion object { + // Must be same as the one in AndroidManifest.xml + const val LAUNCHER_SHORTCUT_SCHEME = "myne_lc_shortcut" + + // Key to get goalId from intent. + const val LC_SC_LIBRARY_ITEM_ID = "lc_shortcut_library_item_id" + + // Key to detect new goal shortcut. + const val LC_SC_BOOK_LIBRARY = "lc_shortcut_book_library" + } + init { viewModelScope.launch { // Check if user has completed onboarding. @@ -55,4 +79,42 @@ class MainViewModel @Inject constructor(private val welcomeDataStore: WelcomeDat } } } + + fun buildDynamicShortcuts( + context: Context, + limit: Int, + onComplete: (List) -> Unit + ) { + viewModelScope.launch(Dispatchers.IO) { + val libraryItems = readerDao.getAllReaderItems() + .sortedByDescending { it.lastReadTime } + .take(limit - 1).mapNotNull { + libraryDao.getItemById(it.libraryItemId) + } + + val libraryShortcut = ShortcutInfo.Builder(context, "library").apply { + setShortLabel(context.getString(R.string.library_header)) + setIcon(Icon.createWithResource(context, R.drawable.ic_nav_library)) + setIntent(Intent().apply { + action = Intent.ACTION_VIEW + data = Uri.parse("$LAUNCHER_SHORTCUT_SCHEME://library") + putExtra(LC_SC_BOOK_LIBRARY, true) + }) + }.build() + + val shortcuts = listOf(libraryShortcut) + libraryItems.map { + ShortcutInfo.Builder(context, "library_item_${it.id}").apply { + setShortLabel(it.title) + setIcon(Icon.createWithResource(context, R.drawable.ic_library_external_item)) + setIntent(Intent().apply { + action = Intent.ACTION_VIEW + data = Uri.parse("$LAUNCHER_SHORTCUT_SCHEME://library_item/${it.id}") + putExtra(LC_SC_LIBRARY_ITEM_ID, it.id) + }) + }.build() + } + + withContext(Dispatchers.Main) { onComplete(shortcuts) } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/starry/myne/database/MyneDatabase.kt b/app/src/main/java/com/starry/myne/database/MyneDatabase.kt index 6b974131..1a423528 100644 --- a/app/src/main/java/com/starry/myne/database/MyneDatabase.kt +++ b/app/src/main/java/com/starry/myne/database/MyneDatabase.kt @@ -30,11 +30,12 @@ import com.starry.myne.helpers.Constants @Database( entities = [LibraryItem::class, ReaderData::class], - version = 4, + version = 5, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3), + AutoMigration(from = 4, to = 5), ] ) abstract class MyneDatabase : RoomDatabase() { diff --git a/app/src/main/java/com/starry/myne/database/reader/ReaderDao.kt b/app/src/main/java/com/starry/myne/database/reader/ReaderDao.kt index 9744bf06..08e722f3 100644 --- a/app/src/main/java/com/starry/myne/database/reader/ReaderDao.kt +++ b/app/src/main/java/com/starry/myne/database/reader/ReaderDao.kt @@ -21,6 +21,7 @@ import androidx.room.Dao import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query +import androidx.room.Update import kotlinx.coroutines.flow.Flow @@ -33,17 +34,15 @@ interface ReaderDao { @Query("DELETE FROM reader_table WHERE library_item_id = :libraryItemId") fun delete(libraryItemId: Int) - @Query( - "UPDATE reader_table SET " - + "last_chapter_index = :lastChapterIndex," - + "last_chapter_offset = :lastChapterOffset" - + " WHERE library_item_id = :libraryItemId" - ) - fun update(libraryItemId: Int, lastChapterIndex: Int, lastChapterOffset: Int) + @Update + fun update(readerData: ReaderData) @Query("SELECT * FROM reader_table WHERE library_item_id = :libraryItemId") fun getReaderData(libraryItemId: Int): ReaderData? + @Query("SELECT * FROM reader_table") + fun getAllReaderItems(): List + @Query("SELECT * FROM reader_table WHERE library_item_id = :libraryItemId") - fun getReaderDataAsFlow(libraryItemId: Int): Flow + fun getReaderDataAsFlow(libraryItemId: Int): Flow? } \ No newline at end of file diff --git a/app/src/main/java/com/starry/myne/database/reader/ReaderData.kt b/app/src/main/java/com/starry/myne/database/reader/ReaderData.kt index 83db53db..83fe55f0 100644 --- a/app/src/main/java/com/starry/myne/database/reader/ReaderData.kt +++ b/app/src/main/java/com/starry/myne/database/reader/ReaderData.kt @@ -20,16 +20,26 @@ package com.starry.myne.database.reader import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey +import java.util.Locale @Entity(tableName = "reader_table") data class ReaderData( @ColumnInfo(name = "library_item_id") val libraryItemId: Int, @ColumnInfo(name = "last_chapter_index") val lastChapterIndex: Int, - @ColumnInfo(name = "last_chapter_offset") val lastChapterOffset: Int + @ColumnInfo(name = "last_chapter_offset") val lastChapterOffset: Int, + // Added in database schema version 5 + @ColumnInfo( + name = "last_read_time", + defaultValue = "0" + ) val lastReadTime: Long = System.currentTimeMillis() ) { @PrimaryKey(autoGenerate = true) var id: Int = 0 fun getProgressPercent(totalChapters: Int) = - String.format("%.2f", ((lastChapterIndex + 1).toFloat() / totalChapters.toFloat()) * 100f) + String.format( + locale = Locale.US, + format = "%.2f", + ((lastChapterIndex + 1).toFloat() / totalChapters.toFloat()) * 100f + ) } \ No newline at end of file diff --git a/app/src/main/java/com/starry/myne/epub/EpubParser.kt b/app/src/main/java/com/starry/myne/epub/EpubParser.kt index fe82362f..dbe0a021 100644 --- a/app/src/main/java/com/starry/myne/epub/EpubParser.kt +++ b/app/src/main/java/com/starry/myne/epub/EpubParser.kt @@ -253,7 +253,7 @@ class EpubParser { tocNavPoints: List, files: Map, hrefRootPath: File ): List { // Parse each chapter entry. - return tocNavPoints.flatMap { navPoint -> + return tocNavPoints.flatMapIndexed { index, navPoint -> val title = navPoint.selectFirstChildTag("navLabel")?.selectFirstChildTag("text")?.textContent val chapterSrc = navPoint.selectFirstChildTag("content")?.getAttributeValue("src") @@ -289,7 +289,9 @@ class EpubParser { if (res != null) { listOf( EpubChapter( - absPath = chapterSrc, title = title ?: "", body = res.body + absPath = chapterSrc, + title = title?.takeIf { it.isNotEmpty() } ?: "Chapter $index", + body = res.body ) ) } else { @@ -311,11 +313,14 @@ class EpubParser { var chapterIndex = 0 val chapterExtensions = listOf("xhtml", "xml", "html", "htm").map { ".$it" } return spine.selectChildTag("itemref") - .mapNotNull { manifestItems[it.getAttribute("idref")] }.filter { item -> + .mapNotNull { manifestItems[it.getAttribute("idref")] } + .filter { item -> chapterExtensions.any { item.absPath.endsWith(it, ignoreCase = true) } || item.mediaType.startsWith("image/") - }.mapNotNull { files[it.absPath]?.let { file -> it to file } }.map { (item, file) -> + }.mapNotNull { + files[it.absPath]?.let { file -> it to file } + }.map { (item, file) -> val parser = EpubXMLFileParser(file.absPath, file.data, files) if (item.mediaType.startsWith("image/")) { TempEpubChapter( @@ -342,9 +347,11 @@ class EpubParser { }.groupBy { it.chapterIndex }.map { (index, list) -> - EpubChapter(absPath = list.first().url, - title = list.first().title ?: "Chapter $index", - body = list.joinToString("\n\n") { it.body }) + EpubChapter( + absPath = list.first().url, + title = list.first().title?.takeIf { it.isNotBlank() } ?: "Chapter $index", + body = list.joinToString("\n\n") { it.body } + ) }.filter { it.body.isNotBlank() } @@ -362,7 +369,8 @@ class EpubParser { } val listedImages = - manifestItems.asSequence().map { it.value }.filter { it.mediaType.startsWith("image") } + manifestItems.asSequence() + .map { it.value }.filter { it.mediaType.startsWith("image") } .mapNotNull { files[it.absPath] } .map { EpubImage(absPath = it.absPath, image = it.data) } diff --git a/app/src/main/java/com/starry/myne/ui/screens/main/MainScreen.kt b/app/src/main/java/com/starry/myne/ui/screens/main/MainScreen.kt index 1f63a825..babd3f2f 100644 --- a/app/src/main/java/com/starry/myne/ui/screens/main/MainScreen.kt +++ b/app/src/main/java/com/starry/myne/ui/screens/main/MainScreen.kt @@ -17,6 +17,7 @@ package com.starry.myne.ui.screens.main import android.annotation.SuppressLint +import android.content.Intent import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.slideInVertically import androidx.compose.animation.slideOutVertically @@ -37,7 +38,10 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -47,13 +51,16 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.navigation.NavController import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import com.starry.myne.MainViewModel import com.starry.myne.helpers.NetworkObserver import com.starry.myne.ui.navigation.BottomBarScreen import com.starry.myne.ui.navigation.NavGraph +import com.starry.myne.ui.navigation.Screens import com.starry.myne.ui.theme.figeronaFont /** @@ -64,6 +71,7 @@ val bottomNavPadding = 70.dp @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") @Composable fun MainScreen( + intent: Intent, startDestination: String, networkStatus: NetworkObserver.Status, ) { @@ -78,6 +86,14 @@ fun MainScreen( navController = navController, networkStatus = networkStatus ) + + val shouldHandleShortCut = remember { mutableStateOf(false) } + LaunchedEffect(key1 = true) { + shouldHandleShortCut.value = true + } + if (shouldHandleShortCut.value) { + HandleShortcutIntent(intent, navController) + } } } @@ -162,4 +178,22 @@ private fun CustomBottomNavigationItem( } } } +} + +@Composable +private fun HandleShortcutIntent(intent: Intent, navController: NavController) { + val data = intent.data + if (data != null && data.scheme == MainViewModel.LAUNCHER_SHORTCUT_SCHEME) { + val libraryItemId = intent.getIntExtra(MainViewModel.LC_SC_LIBRARY_ITEM_ID, -100) + if (libraryItemId != -100) { + navController.navigate(Screens.ReaderDetailScreen.withLibraryItemId(libraryItemId.toString())) + return + } + if (intent.getBooleanExtra(MainViewModel.LC_SC_BOOK_LIBRARY, false)) { + navController.navigate(BottomBarScreen.Library.route) { + popUpTo(navController.graph.findStartDestination().id) + launchSingleTop = true + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/com/starry/myne/ui/screens/reader/viewmodels/ReaderDetailViewModel.kt b/app/src/main/java/com/starry/myne/ui/screens/reader/viewmodels/ReaderDetailViewModel.kt index 38bf86da..84c67b89 100644 --- a/app/src/main/java/com/starry/myne/ui/screens/reader/viewmodels/ReaderDetailViewModel.kt +++ b/app/src/main/java/com/starry/myne/ui/screens/reader/viewmodels/ReaderDetailViewModel.kt @@ -61,16 +61,19 @@ class ReaderDetailViewModel @Inject constructor( var state by mutableStateOf(ReaderDetailScreenState()) - val readerData: Flow? - get() = _readerData - private var _readerData: Flow? = null + var readerData: Flow? = null + private set fun loadEbookData(libraryItemId: String, networkStatus: NetworkObserver.Status) { viewModelScope.launch(Dispatchers.IO) { - // Library item is not null as this screen is only accessible from the library. - val libraryItem = libraryDao.getItemById(libraryItemId.toInt())!! + val libraryItem = libraryDao.getItemById(libraryItemId.toInt()) + // Check if library item exists. + if (libraryItem == null) { + state = state.copy(isLoading = false, error = "Library item not found.") + return@launch + } // Get reader data if it exists. - _readerData = readerDao.getReaderDataAsFlow(libraryItemId.toInt()) + readerData = readerDao.getReaderDataAsFlow(libraryItemId.toInt()) val coverImage: String? = try { if (!libraryItem.isExternalBook && networkStatus == NetworkObserver.Status.Available diff --git a/app/src/main/java/com/starry/myne/ui/screens/reader/viewmodels/ReaderViewModel.kt b/app/src/main/java/com/starry/myne/ui/screens/reader/viewmodels/ReaderViewModel.kt index 50fbeeba..97d9fef3 100644 --- a/app/src/main/java/com/starry/myne/ui/screens/reader/viewmodels/ReaderViewModel.kt +++ b/app/src/main/java/com/starry/myne/ui/screens/reader/viewmodels/ReaderViewModel.kt @@ -105,13 +105,22 @@ class ReaderViewModel @Inject constructor( fun updateReaderProgress(libraryItemId: Int, chapterIndex: Int, chapterOffset: Int) { viewModelScope.launch(Dispatchers.IO) { - if (readerDao.getReaderData(libraryItemId) != null && chapterIndex != state.epubBook?.chapters!!.size - 1) { - readerDao.update(libraryItemId, chapterIndex, chapterOffset) + val readerData = readerDao.getReaderData(libraryItemId) + // if the user is not on last chapter, save the progress. + if (readerData != null && chapterIndex != state.epubBook?.chapters!!.size - 1) { + val newReaderData = readerData.copy( + lastChapterIndex = chapterIndex, + lastChapterOffset = chapterOffset, + lastReadTime = System.currentTimeMillis() + ) + newReaderData.id = readerData.id + readerDao.update(newReaderData) } else if (chapterIndex == state.epubBook?.chapters!!.size - 1) { // if the user has reached last chapter, delete this book - // from reader database instead of saving it's progress . - readerDao.getReaderData(libraryItemId)?.let { readerDao.delete(it.libraryItemId) } + // from reader database instead of saving it's progress. + readerData?.let { readerDao.delete(it.libraryItemId) } } else { + // if the user is reading this book for the first time, save the progress. readerDao.insert( readerData = ReaderData( libraryItemId,