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,