Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add launcher shortcuts to quickly jump back to last read book #183

Merged
merged 2 commits into from
Jun 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions app/schemas/com.starry.myne.database.MyneDatabase/5.json
Original file line number Diff line number Diff line change
@@ -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')"
]
}
}
6 changes: 6 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,12 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>

<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="myne_lc_shortcut" />
</intent-filter>

<meta-data
android:name="android.app.lib_name"
android:value="" />
Expand Down
28 changes: 27 additions & 1 deletion app/src/main/java/com/starry/myne/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
}
)
}
}
64 changes: 63 additions & 1 deletion app/src/main/java/com/starry/myne/MainViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<Boolean> = mutableStateOf(true)
val isLoading: State<Boolean> = _isLoading
Expand All @@ -40,6 +53,17 @@ class MainViewModel @Inject constructor(private val welcomeDataStore: WelcomeDat
mutableStateOf(Screens.WelcomeScreen.route)
val startDestination: State<String> = _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.
Expand All @@ -55,4 +79,42 @@ class MainViewModel @Inject constructor(private val welcomeDataStore: WelcomeDat
}
}
}

fun buildDynamicShortcuts(
context: Context,
limit: Int,
onComplete: (List<ShortcutInfo>) -> 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) }
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
15 changes: 7 additions & 8 deletions app/src/main/java/com/starry/myne/database/reader/ReaderDao.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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<ReaderData>

@Query("SELECT * FROM reader_table WHERE library_item_id = :libraryItemId")
fun getReaderDataAsFlow(libraryItemId: Int): Flow<ReaderData?>
fun getReaderDataAsFlow(libraryItemId: Int): Flow<ReaderData>?
}
14 changes: 12 additions & 2 deletions app/src/main/java/com/starry/myne/database/reader/ReaderData.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}
Loading
Loading