feat(progress): implement reading and playback progress sync with bookshelf-api
- Add ProgressRepository + impl to push reading/playback progress to bookshelf-api - Add SyncReadingProgressUseCase and SyncPlaybackProgressUseCase - Add request models for bookshelf-api and Audiobookshelf progress endpoints - Add audio progress fields (audioCurrentFile, audioCurrentPosition) to Book/BookEntity - Add Room migration 17->18 and generated schema - Add BookDao/BookRepository query for books with remote IDs - Add ProgressSyncWorker scheduled from Application.onCreate - Hook SyncReadingProgressUseCase into ReaderModel progress updates - Add AudioPlayerProgress placeholder with TODO for playback sync hook - Add Hilt binding for ProgressRepository and missing string resources
This commit is contained in:
parent
ed3afc1f16
commit
a8a1417b9d
24 changed files with 981 additions and 2 deletions
|
|
@ -0,0 +1,500 @@
|
||||||
|
{
|
||||||
|
"formatVersion": 1,
|
||||||
|
"database": {
|
||||||
|
"version": 18,
|
||||||
|
"identityHash": "989c23bd1fdbd69a7d65b94ae6cf381b",
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"tableName": "BookEntity",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `author` TEXT NOT NULL, `description` TEXT, `filePath` TEXT NOT NULL, `scrollIndex` INTEGER NOT NULL, `scrollOffset` INTEGER NOT NULL, `progress` REAL NOT NULL, `image` TEXT, `categories` TEXT NOT NULL DEFAULT '[]', `remoteId` TEXT NOT NULL DEFAULT '', `libraryId` TEXT NOT NULL DEFAULT '', `mediaType` TEXT NOT NULL DEFAULT '', `hasAudio` INTEGER NOT NULL DEFAULT 0, `hasEbook` INTEGER NOT NULL DEFAULT 0, `audioDuration` INTEGER NOT NULL DEFAULT 0, `audioCurrentFile` TEXT NOT NULL DEFAULT '', `audioCurrentPosition` INTEGER NOT NULL DEFAULT 0, `coverUrl` TEXT NOT NULL DEFAULT '', `lastSyncedAt` INTEGER NOT NULL DEFAULT 0)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "author",
|
||||||
|
"columnName": "author",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "description",
|
||||||
|
"columnName": "description",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "filePath",
|
||||||
|
"columnName": "filePath",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "scrollIndex",
|
||||||
|
"columnName": "scrollIndex",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "scrollOffset",
|
||||||
|
"columnName": "scrollOffset",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "progress",
|
||||||
|
"columnName": "progress",
|
||||||
|
"affinity": "REAL",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "image",
|
||||||
|
"columnName": "image",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "categories",
|
||||||
|
"columnName": "categories",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "'[]'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "remoteId",
|
||||||
|
"columnName": "remoteId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "libraryId",
|
||||||
|
"columnName": "libraryId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "mediaType",
|
||||||
|
"columnName": "mediaType",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "hasAudio",
|
||||||
|
"columnName": "hasAudio",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "hasEbook",
|
||||||
|
"columnName": "hasEbook",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "audioDuration",
|
||||||
|
"columnName": "audioDuration",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "audioCurrentFile",
|
||||||
|
"columnName": "audioCurrentFile",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "audioCurrentPosition",
|
||||||
|
"columnName": "audioCurrentPosition",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "coverUrl",
|
||||||
|
"columnName": "coverUrl",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "''"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "lastSyncedAt",
|
||||||
|
"columnName": "lastSyncedAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "0"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "HistoryEntity",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `time` INTEGER NOT NULL)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "bookId",
|
||||||
|
"columnName": "bookId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "time",
|
||||||
|
"columnName": "time",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "ColorPresetEntity",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `name` TEXT NOT NULL, `backgroundColor` INTEGER NOT NULL, `fontColor` INTEGER NOT NULL, `isSelected` INTEGER NOT NULL, `order` INTEGER NOT NULL)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "name",
|
||||||
|
"columnName": "name",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "backgroundColor",
|
||||||
|
"columnName": "backgroundColor",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "fontColor",
|
||||||
|
"columnName": "fontColor",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "isSelected",
|
||||||
|
"columnName": "isSelected",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "order",
|
||||||
|
"columnName": "order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "CategoryEntity",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `sortOrder` TEXT NOT NULL DEFAULT 'LAST_READ', `sortOrderDescending` INTEGER NOT NULL DEFAULT 1)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "title",
|
||||||
|
"columnName": "title",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "order",
|
||||||
|
"columnName": "order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sortOrder",
|
||||||
|
"columnName": "sortOrder",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "'LAST_READ'"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "sortOrderDescending",
|
||||||
|
"columnName": "sortOrderDescending",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true,
|
||||||
|
"defaultValue": "1"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "audio_file",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `fileId` TEXT NOT NULL, `remotePath` TEXT, `localPath` TEXT, `duration` REAL NOT NULL, `order` INTEGER NOT NULL, FOREIGN KEY(`bookId`) REFERENCES `BookEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "bookId",
|
||||||
|
"columnName": "bookId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "fileId",
|
||||||
|
"columnName": "fileId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "remotePath",
|
||||||
|
"columnName": "remotePath",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "localPath",
|
||||||
|
"columnName": "localPath",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "duration",
|
||||||
|
"columnName": "duration",
|
||||||
|
"affinity": "REAL",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "order",
|
||||||
|
"columnName": "order",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_audio_file_bookId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"bookId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_audio_file_bookId` ON `${TABLE_NAME}` (`bookId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "BookEntity",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"bookId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "ebook_file",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `fileId` TEXT NOT NULL, `format` TEXT NOT NULL, `remotePath` TEXT, `localPath` TEXT, FOREIGN KEY(`bookId`) REFERENCES `BookEntity`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "bookId",
|
||||||
|
"columnName": "bookId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "fileId",
|
||||||
|
"columnName": "fileId",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "format",
|
||||||
|
"columnName": "format",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "remotePath",
|
||||||
|
"columnName": "remotePath",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "localPath",
|
||||||
|
"columnName": "localPath",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_ebook_file_bookId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"bookId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_ebook_file_bookId` ON `${TABLE_NAME}` (`bookId`)"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"foreignKeys": [
|
||||||
|
{
|
||||||
|
"table": "BookEntity",
|
||||||
|
"onDelete": "CASCADE",
|
||||||
|
"onUpdate": "NO ACTION",
|
||||||
|
"columns": [
|
||||||
|
"bookId"
|
||||||
|
],
|
||||||
|
"referencedColumns": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tableName": "cached_file",
|
||||||
|
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookId` INTEGER NOT NULL, `type` TEXT NOT NULL, `remoteUrl` TEXT NOT NULL, `localPath` TEXT, `status` TEXT NOT NULL, `progress` REAL NOT NULL, `createdAt` INTEGER NOT NULL)",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"fieldPath": "id",
|
||||||
|
"columnName": "id",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "bookId",
|
||||||
|
"columnName": "bookId",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "type",
|
||||||
|
"columnName": "type",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "remoteUrl",
|
||||||
|
"columnName": "remoteUrl",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "localPath",
|
||||||
|
"columnName": "localPath",
|
||||||
|
"affinity": "TEXT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "status",
|
||||||
|
"columnName": "status",
|
||||||
|
"affinity": "TEXT",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "progress",
|
||||||
|
"columnName": "progress",
|
||||||
|
"affinity": "REAL",
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fieldPath": "createdAt",
|
||||||
|
"columnName": "createdAt",
|
||||||
|
"affinity": "INTEGER",
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"primaryKey": {
|
||||||
|
"autoGenerate": true,
|
||||||
|
"columnNames": [
|
||||||
|
"id"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"indices": [
|
||||||
|
{
|
||||||
|
"name": "index_cached_file_bookId",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"bookId"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_cached_file_bookId` ON `${TABLE_NAME}` (`bookId`)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "index_cached_file_type",
|
||||||
|
"unique": false,
|
||||||
|
"columnNames": [
|
||||||
|
"type"
|
||||||
|
],
|
||||||
|
"orders": [],
|
||||||
|
"createSql": "CREATE INDEX IF NOT EXISTS `index_cached_file_type` ON `${TABLE_NAME}` (`type`)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"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, '989c23bd1fdbd69a7d65b94ae6cf381b')"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,13 +7,20 @@
|
||||||
package org.dueattendant149.bookshelf
|
package org.dueattendant149.bookshelf
|
||||||
|
|
||||||
import android.app.Application
|
import android.app.Application
|
||||||
|
import androidx.work.WorkManager
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
import org.dueattendant149.bookshelf.core.crash.CrashHandler
|
import org.dueattendant149.bookshelf.core.crash.CrashHandler
|
||||||
|
import org.dueattendant149.bookshelf.data.worker.ProgressSyncWorker
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
@HiltAndroidApp
|
@HiltAndroidApp
|
||||||
class Application : Application() {
|
class Application : Application() {
|
||||||
|
@Inject
|
||||||
|
lateinit var workManager: WorkManager
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
Thread.setDefaultUncaughtExceptionHandler(CrashHandler(this))
|
Thread.setDefaultUncaughtExceptionHandler(CrashHandler(this))
|
||||||
|
ProgressSyncWorker.schedule(workManager)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -66,6 +66,7 @@ object AppModule {
|
||||||
DatabaseHelper.MANUAL_MIGRATION_14_15, // remove author nullability from BookEntity
|
DatabaseHelper.MANUAL_MIGRATION_14_15, // remove author nullability from BookEntity
|
||||||
DatabaseHelper.MANUAL_MIGRATION_15_16, // merge CategoryEntity and CategorySortEntity
|
DatabaseHelper.MANUAL_MIGRATION_15_16, // merge CategoryEntity and CategorySortEntity
|
||||||
DatabaseHelper.MANUAL_MIGRATION_16_17, // add remote bookshelf fields + audio/ebook/cache tables
|
DatabaseHelper.MANUAL_MIGRATION_16_17, // add remote bookshelf fields + audio/ebook/cache tables
|
||||||
|
DatabaseHelper.MANUAL_MIGRATION_17_18, // add audio progress fields
|
||||||
).allowMainThreadQueries().build().also { database ->
|
).allowMainThreadQueries().build().also { database ->
|
||||||
// Additional Migrations
|
// Additional Migrations
|
||||||
DatabaseHelper.AUTO_MIGRATION_7_8.removeBooksDir(app)
|
DatabaseHelper.AUTO_MIGRATION_7_8.removeBooksDir(app)
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ import org.dueattendant149.bookshelf.data.repository.ColorPresetRepositoryImpl
|
||||||
import org.dueattendant149.bookshelf.data.repository.FileSystemRepositoryImpl
|
import org.dueattendant149.bookshelf.data.repository.FileSystemRepositoryImpl
|
||||||
import org.dueattendant149.bookshelf.data.repository.HistoryRepositoryImpl
|
import org.dueattendant149.bookshelf.data.repository.HistoryRepositoryImpl
|
||||||
import org.dueattendant149.bookshelf.data.repository.PermissionRepositoryImpl
|
import org.dueattendant149.bookshelf.data.repository.PermissionRepositoryImpl
|
||||||
|
import org.dueattendant149.bookshelf.data.repository.ProgressRepositoryImpl
|
||||||
import org.dueattendant149.bookshelf.data.repository.RemoteLibraryRepositoryImpl
|
import org.dueattendant149.bookshelf.data.repository.RemoteLibraryRepositoryImpl
|
||||||
import org.dueattendant149.bookshelf.domain.repository.BookRepository
|
import org.dueattendant149.bookshelf.domain.repository.BookRepository
|
||||||
import org.dueattendant149.bookshelf.domain.repository.CategoryRepository
|
import org.dueattendant149.bookshelf.domain.repository.CategoryRepository
|
||||||
|
|
@ -96,6 +97,12 @@ abstract class RepositoryModule {
|
||||||
remoteLibraryRepositoryImpl: RemoteLibraryRepositoryImpl
|
remoteLibraryRepositoryImpl: RemoteLibraryRepositoryImpl
|
||||||
): RemoteLibraryRepository
|
): RemoteLibraryRepository
|
||||||
|
|
||||||
|
@Binds
|
||||||
|
@Singleton
|
||||||
|
abstract fun bindProgressRepository(
|
||||||
|
progressRepositoryImpl: ProgressRepositoryImpl
|
||||||
|
): org.dueattendant149.bookshelf.domain.repository.ProgressRepository
|
||||||
|
|
||||||
@Binds
|
@Binds
|
||||||
@Singleton
|
@Singleton
|
||||||
abstract fun bindBookMapper(
|
abstract fun bindBookMapper(
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,10 @@ data class BookEntity(
|
||||||
@ColumnInfo(defaultValue = "0")
|
@ColumnInfo(defaultValue = "0")
|
||||||
val audioDuration: Long = 0L,
|
val audioDuration: Long = 0L,
|
||||||
@ColumnInfo(defaultValue = "")
|
@ColumnInfo(defaultValue = "")
|
||||||
|
val audioCurrentFile: String = "",
|
||||||
|
@ColumnInfo(defaultValue = "0")
|
||||||
|
val audioCurrentPosition: Long = 0L,
|
||||||
|
@ColumnInfo(defaultValue = "")
|
||||||
val coverUrl: String = "",
|
val coverUrl: String = "",
|
||||||
@ColumnInfo(defaultValue = "0")
|
@ColumnInfo(defaultValue = "0")
|
||||||
val lastSyncedAt: Long = 0L,
|
val lastSyncedAt: Long = 0L,
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,9 @@ interface BookDao {
|
||||||
@Query("SELECT * FROM bookentity WHERE libraryId=:libraryId")
|
@Query("SELECT * FROM bookentity WHERE libraryId=:libraryId")
|
||||||
suspend fun findBooksByLibraryId(libraryId: String): List<BookEntity>
|
suspend fun findBooksByLibraryId(libraryId: String): List<BookEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM bookentity WHERE remoteId != ''")
|
||||||
|
suspend fun findBooksWithRemoteId(): List<BookEntity>
|
||||||
|
|
||||||
@Delete
|
@Delete
|
||||||
suspend fun deleteBook(book: BookEntity): Int
|
suspend fun deleteBook(book: BookEntity): Int
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ import java.io.File
|
||||||
EbookFileEntity::class,
|
EbookFileEntity::class,
|
||||||
CachedFileEntity::class,
|
CachedFileEntity::class,
|
||||||
],
|
],
|
||||||
version = 17,
|
version = 18,
|
||||||
autoMigrations = [
|
autoMigrations = [
|
||||||
AutoMigration(1, 2),
|
AutoMigration(1, 2),
|
||||||
AutoMigration(2, 3),
|
AutoMigration(2, 3),
|
||||||
|
|
@ -203,6 +203,17 @@ object DatabaseHelper {
|
||||||
@DeleteTable("CategorySortEntity")
|
@DeleteTable("CategorySortEntity")
|
||||||
class AUTO_MIGRATION_15_16 : AutoMigrationSpec
|
class AUTO_MIGRATION_15_16 : AutoMigrationSpec
|
||||||
|
|
||||||
|
val MANUAL_MIGRATION_17_18 = object : Migration(17, 18) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
database.execSQL(
|
||||||
|
"ALTER TABLE BookEntity ADD COLUMN audioCurrentFile TEXT NOT NULL DEFAULT ''"
|
||||||
|
)
|
||||||
|
database.execSQL(
|
||||||
|
"ALTER TABLE BookEntity ADD COLUMN audioCurrentPosition INTEGER NOT NULL DEFAULT 0"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val MANUAL_MIGRATION_16_17 = object : Migration(16, 17) {
|
val MANUAL_MIGRATION_16_17 = object : Migration(16, 17) {
|
||||||
override fun migrate(database: SupportSQLiteDatabase) {
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
// Add remote / bookshelf columns to BookEntity
|
// Add remote / bookshelf columns to BookEntity
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ class BookMapperImpl @Inject constructor() : BookMapper {
|
||||||
hasAudio = book.hasAudio,
|
hasAudio = book.hasAudio,
|
||||||
hasEbook = book.hasEbook,
|
hasEbook = book.hasEbook,
|
||||||
audioDuration = book.audioDuration,
|
audioDuration = book.audioDuration,
|
||||||
|
audioCurrentFile = book.audioCurrentFile,
|
||||||
|
audioCurrentPosition = book.audioCurrentPosition,
|
||||||
coverUrl = book.coverUrl,
|
coverUrl = book.coverUrl,
|
||||||
lastSyncedAt = book.lastSyncedAt,
|
lastSyncedAt = book.lastSyncedAt,
|
||||||
)
|
)
|
||||||
|
|
@ -59,6 +61,8 @@ class BookMapperImpl @Inject constructor() : BookMapper {
|
||||||
hasAudio = bookEntity.hasAudio,
|
hasAudio = bookEntity.hasAudio,
|
||||||
hasEbook = bookEntity.hasEbook,
|
hasEbook = bookEntity.hasEbook,
|
||||||
audioDuration = bookEntity.audioDuration,
|
audioDuration = bookEntity.audioDuration,
|
||||||
|
audioCurrentFile = bookEntity.audioCurrentFile,
|
||||||
|
audioCurrentPosition = bookEntity.audioCurrentPosition,
|
||||||
coverUrl = bookEntity.coverUrl,
|
coverUrl = bookEntity.coverUrl,
|
||||||
lastSyncedAt = bookEntity.lastSyncedAt,
|
lastSyncedAt = bookEntity.lastSyncedAt,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,11 @@
|
||||||
package org.dueattendant149.bookshelf.data.remote.audiobookshelf
|
package org.dueattendant149.bookshelf.data.remote.audiobookshelf
|
||||||
|
|
||||||
import okhttp3.ResponseBody
|
import okhttp3.ResponseBody
|
||||||
|
import org.dueattendant149.bookshelf.data.remote.audiobookshelf.PlaybackProgressUpdateRequest
|
||||||
import retrofit2.Response
|
import retrofit2.Response
|
||||||
|
import retrofit2.http.Body
|
||||||
import retrofit2.http.GET
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.POST
|
||||||
import retrofit2.http.Path
|
import retrofit2.http.Path
|
||||||
import retrofit2.http.Streaming
|
import retrofit2.http.Streaming
|
||||||
|
|
||||||
|
|
@ -34,4 +37,10 @@ interface AudiobookshelfApiService {
|
||||||
suspend fun getProgress(
|
suspend fun getProgress(
|
||||||
@Path("itemId") itemId: String,
|
@Path("itemId") itemId: String,
|
||||||
): Response<String>
|
): Response<String>
|
||||||
|
|
||||||
|
@POST("api/me/progress/{itemId}")
|
||||||
|
suspend fun updateProgress(
|
||||||
|
@Path("itemId") itemId: String,
|
||||||
|
@Body request: PlaybackProgressUpdateRequest,
|
||||||
|
): Response<String>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package org.dueattendant149.bookshelf.data.remote.audiobookshelf
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request body for updating media progress directly on an Audiobookshelf server.
|
||||||
|
*
|
||||||
|
* Field names follow the ABS `/api/me/progress/{itemId}` endpoint contract.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PlaybackProgressUpdateRequest(
|
||||||
|
@SerialName("currentTime")
|
||||||
|
val currentTime: Double,
|
||||||
|
@SerialName("duration")
|
||||||
|
val duration: Double,
|
||||||
|
@SerialName("progress")
|
||||||
|
val progress: Double,
|
||||||
|
@SerialName("episodeId")
|
||||||
|
val episodeId: String? = null,
|
||||||
|
)
|
||||||
|
|
@ -6,8 +6,10 @@ import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadRequ
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadResponse
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadResponse
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.DownloadsListResponse
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryResponse
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.LibraryResponse
|
||||||
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastRequest
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastRequest
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastResponse
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PodcastResponse
|
||||||
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.ProgressUpdateRequest
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchRequest
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.SearchResponse
|
||||||
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsCreateRequest
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.TtsCreateRequest
|
||||||
|
|
@ -44,6 +46,18 @@ interface BookshelfApiService {
|
||||||
@Path("itemId") itemId: String,
|
@Path("itemId") itemId: String,
|
||||||
): Response<String>
|
): Response<String>
|
||||||
|
|
||||||
|
@POST("api/v1/books/{itemId}/progress")
|
||||||
|
suspend fun updateReadingProgress(
|
||||||
|
@Path("itemId") itemId: String,
|
||||||
|
@Body request: ProgressUpdateRequest,
|
||||||
|
): Response<String>
|
||||||
|
|
||||||
|
@POST("api/v1/books/{itemId}/playback-progress")
|
||||||
|
suspend fun updatePlaybackProgress(
|
||||||
|
@Path("itemId") itemId: String,
|
||||||
|
@Body request: PlaybackProgressUpdateRequest,
|
||||||
|
): Response<String>
|
||||||
|
|
||||||
@GET("api/v1/books/library/{libraryId}/search")
|
@GET("api/v1/books/library/{libraryId}/search")
|
||||||
suspend fun searchLibrary(
|
suspend fun searchLibrary(
|
||||||
@Path("libraryId") libraryId: String,
|
@Path("libraryId") libraryId: String,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request body for updating the audio playback progress of a book on the bookshelf-api.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PlaybackProgressUpdateRequest(
|
||||||
|
val itemId: String,
|
||||||
|
val libraryId: String,
|
||||||
|
@SerialName("current_file")
|
||||||
|
val currentFile: String,
|
||||||
|
@SerialName("current_position")
|
||||||
|
val currentPosition: Long,
|
||||||
|
val duration: Long,
|
||||||
|
@SerialName("updated_at")
|
||||||
|
val updatedAt: Long,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package org.dueattendant149.bookshelf.data.remote.bookshelfapi.model
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerialName
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request body for updating the local reading progress of a book on the bookshelf-api.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ProgressUpdateRequest(
|
||||||
|
val itemId: String,
|
||||||
|
val libraryId: String,
|
||||||
|
val scrollIndex: Int,
|
||||||
|
val scrollOffset: Int,
|
||||||
|
val progress: Float,
|
||||||
|
@SerialName("last_chapter")
|
||||||
|
val lastChapter: String? = null,
|
||||||
|
@SerialName("updated_at")
|
||||||
|
val updatedAt: Long,
|
||||||
|
)
|
||||||
|
|
@ -92,4 +92,10 @@ class BookRepositoryImpl @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getBooksWithRemoteId(): Result<List<Book>> = runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
database.bookDao.findBooksWithRemoteId().map { bookMapper.toBook(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.data.repository
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.dueattendant149.bookshelf.data.local.room.BookDatabase
|
||||||
|
import org.dueattendant149.bookshelf.data.mapper.book.BookMapper
|
||||||
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.BookshelfApiClientFactory
|
||||||
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.PlaybackProgressUpdateRequest
|
||||||
|
import org.dueattendant149.bookshelf.data.remote.bookshelfapi.model.ProgressUpdateRequest
|
||||||
|
import org.dueattendant149.bookshelf.data.settings.ServerSettings
|
||||||
|
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||||
|
import org.dueattendant149.bookshelf.domain.repository.ProgressRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class ProgressRepositoryImpl
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val database: BookDatabase,
|
||||||
|
private val bookMapper: BookMapper,
|
||||||
|
private val clientFactory: BookshelfApiClientFactory,
|
||||||
|
private val serverSettings: ServerSettings,
|
||||||
|
) : ProgressRepository {
|
||||||
|
override suspend fun syncReadingProgress(
|
||||||
|
book: Book,
|
||||||
|
lastChapter: String?,
|
||||||
|
): Result<Unit> = runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val client = client() ?: return@withContext
|
||||||
|
val request =
|
||||||
|
ProgressUpdateRequest(
|
||||||
|
itemId = book.remoteId,
|
||||||
|
libraryId = book.libraryId,
|
||||||
|
scrollIndex = book.scrollIndex,
|
||||||
|
scrollOffset = book.scrollOffset,
|
||||||
|
progress = book.progress,
|
||||||
|
lastChapter = lastChapter,
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = client.updateReadingProgress(book.remoteId, request)
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
throw RuntimeException("Failed to sync reading progress: ${response.code()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLastSyncedAt(book)
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
Log.e(TAG, "syncReadingProgress failed for ${book.remoteId}", it)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun syncPlaybackProgress(
|
||||||
|
book: Book,
|
||||||
|
currentFile: String,
|
||||||
|
position: Long,
|
||||||
|
duration: Long,
|
||||||
|
): Result<Unit> = runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val client = client() ?: return@withContext
|
||||||
|
val request =
|
||||||
|
PlaybackProgressUpdateRequest(
|
||||||
|
itemId = book.remoteId,
|
||||||
|
libraryId = book.libraryId,
|
||||||
|
currentFile = currentFile,
|
||||||
|
currentPosition = position,
|
||||||
|
duration = duration,
|
||||||
|
updatedAt = System.currentTimeMillis(),
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = client.updatePlaybackProgress(book.remoteId, request)
|
||||||
|
if (!response.isSuccessful) {
|
||||||
|
throw RuntimeException("Failed to sync playback progress: ${response.code()}")
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLastSyncedAt(book)
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
Log.e(TAG, "syncPlaybackProgress failed for ${book.remoteId}", it)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getBooksWithRemoteId(): Result<List<Book>> = runCatching {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
database.bookDao.findBooksWithRemoteId().map { bookMapper.toBook(it) }
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
Log.e(TAG, "getBooksWithRemoteId failed", it)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun client() =
|
||||||
|
serverSettings.getBookshelfUrl()?.let { url ->
|
||||||
|
clientFactory.provideClient(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun updateLastSyncedAt(book: Book) {
|
||||||
|
val entity = bookMapper.toBookEntity(book.copy(lastSyncedAt = System.currentTimeMillis()))
|
||||||
|
database.bookDao.updateBook(entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ProgressRepository"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.data.worker
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.work.Constraints
|
||||||
|
import androidx.work.CoroutineWorker
|
||||||
|
import androidx.work.ExistingPeriodicWorkPolicy
|
||||||
|
import androidx.work.NetworkType
|
||||||
|
import androidx.work.PeriodicWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import dagger.hilt.EntryPoint
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.EntryPointAccessors
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import org.dueattendant149.bookshelf.domain.use_case.remote.SyncPlaybackProgressUseCase
|
||||||
|
import org.dueattendant149.bookshelf.domain.use_case.remote.SyncReadingProgressUseCase
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Periodic worker that syncs reading and playback progress for all books
|
||||||
|
* that have a remote (bookshelf-api) identifier.
|
||||||
|
*/
|
||||||
|
class ProgressSyncWorker(
|
||||||
|
context: Context,
|
||||||
|
params: WorkerParameters,
|
||||||
|
) : CoroutineWorker(context, params) {
|
||||||
|
companion object {
|
||||||
|
const val WORK_NAME = "progress-sync-worker"
|
||||||
|
|
||||||
|
fun schedule(workManager: WorkManager) {
|
||||||
|
val request =
|
||||||
|
PeriodicWorkRequestBuilder<ProgressSyncWorker>(15, TimeUnit.MINUTES)
|
||||||
|
.setConstraints(
|
||||||
|
Constraints
|
||||||
|
.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.build(),
|
||||||
|
)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
workManager.enqueueUniquePeriodicWork(
|
||||||
|
WORK_NAME,
|
||||||
|
ExistingPeriodicWorkPolicy.KEEP,
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val entryPoint: ProgressSyncEntryPoint by lazy {
|
||||||
|
EntryPointAccessors.fromApplication(applicationContext, ProgressSyncEntryPoint::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
val syncReading = entryPoint.syncReadingProgressUseCase()
|
||||||
|
val syncPlayback = entryPoint.syncPlaybackProgressUseCase()
|
||||||
|
|
||||||
|
val books = entryPoint.progressRepository().getBooksWithRemoteId().getOrElse {
|
||||||
|
Log.e(TAG, "Failed to load books with remote IDs", it)
|
||||||
|
return Result.retry()
|
||||||
|
}
|
||||||
|
|
||||||
|
var failures = 0
|
||||||
|
books.forEach { book ->
|
||||||
|
syncReading(book, lastChapter = null).onFailure {
|
||||||
|
Log.w(TAG, "Reading progress sync failed for ${book.remoteId}", it)
|
||||||
|
failures++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (book.hasAudio && book.audioDuration > 0L) {
|
||||||
|
syncPlayback(
|
||||||
|
book = book,
|
||||||
|
currentFile = book.audioCurrentFile,
|
||||||
|
position = book.audioCurrentPosition,
|
||||||
|
duration = book.audioDuration,
|
||||||
|
).onFailure {
|
||||||
|
Log.w(TAG, "Playback progress sync failed for ${book.remoteId}", it)
|
||||||
|
failures++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (failures == 0 || failures < books.size) {
|
||||||
|
Result.success()
|
||||||
|
} else {
|
||||||
|
Result.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@EntryPoint
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
interface ProgressSyncEntryPoint {
|
||||||
|
fun syncReadingProgressUseCase(): SyncReadingProgressUseCase
|
||||||
|
fun syncPlaybackProgressUseCase(): SyncPlaybackProgressUseCase
|
||||||
|
fun progressRepository(): org.dueattendant149.bookshelf.domain.repository.ProgressRepository
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val TAG = "ProgressSyncWorker"
|
||||||
|
|
@ -38,6 +38,8 @@ data class Book(
|
||||||
val hasAudio: Boolean = false,
|
val hasAudio: Boolean = false,
|
||||||
val hasEbook: Boolean = false,
|
val hasEbook: Boolean = false,
|
||||||
val audioDuration: Long = 0L,
|
val audioDuration: Long = 0L,
|
||||||
|
val audioCurrentFile: String = "",
|
||||||
|
val audioCurrentPosition: Long = 0L,
|
||||||
val coverUrl: String = "",
|
val coverUrl: String = "",
|
||||||
val lastSyncedAt: Long = 0L,
|
val lastSyncedAt: Long = 0L,
|
||||||
) : Parcelable {
|
) : Parcelable {
|
||||||
|
|
@ -60,6 +62,8 @@ data class Book(
|
||||||
hasAudio = false,
|
hasAudio = false,
|
||||||
hasEbook = false,
|
hasEbook = false,
|
||||||
audioDuration = 0L,
|
audioDuration = 0L,
|
||||||
|
audioCurrentFile = "",
|
||||||
|
audioCurrentPosition = 0L,
|
||||||
coverUrl = "",
|
coverUrl = "",
|
||||||
lastSyncedAt = 0L,
|
lastSyncedAt = 0L,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.domain.model.player
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Placeholder for the audio player progress state.
|
||||||
|
*
|
||||||
|
* TODO: When implementing the ExoPlayer/MediaSession audio player,
|
||||||
|
* hook [org.dueattendant149.bookshelf.domain.use_case.remote.SyncPlaybackProgressUseCase]
|
||||||
|
* into the playback position update flow, e.g.:
|
||||||
|
*
|
||||||
|
* ```
|
||||||
|
* val book = ...
|
||||||
|
* val currentFile = player.currentMediaItem?.mediaId ?: book.audioCurrentFile
|
||||||
|
* val position = player.currentPosition.coerceAtLeast(0L)
|
||||||
|
* val duration = player.duration.coerceAtLeast(book.audioDuration)
|
||||||
|
* syncPlaybackProgressUseCase(book, currentFile, position, duration)
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
data class AudioPlayerProgress(
|
||||||
|
val bookId: Int,
|
||||||
|
val currentFile: String,
|
||||||
|
val position: Long,
|
||||||
|
val duration: Long,
|
||||||
|
)
|
||||||
|
|
@ -43,4 +43,6 @@ interface BookRepository {
|
||||||
suspend fun getDefaultCover(
|
suspend fun getDefaultCover(
|
||||||
book: Book
|
book: Book
|
||||||
): Result<CoverImage?>
|
): Result<CoverImage?>
|
||||||
|
|
||||||
|
suspend fun getBooksWithRemoteId(): Result<List<Book>>
|
||||||
}
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.domain.repository
|
||||||
|
|
||||||
|
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||||
|
|
||||||
|
interface ProgressRepository {
|
||||||
|
suspend fun syncReadingProgress(
|
||||||
|
book: Book,
|
||||||
|
lastChapter: String? = null,
|
||||||
|
): Result<Unit>
|
||||||
|
|
||||||
|
suspend fun syncPlaybackProgress(
|
||||||
|
book: Book,
|
||||||
|
currentFile: String,
|
||||||
|
position: Long,
|
||||||
|
duration: Long,
|
||||||
|
): Result<Unit>
|
||||||
|
|
||||||
|
suspend fun getBooksWithRemoteId(): Result<List<Book>>
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.domain.use_case.remote
|
||||||
|
|
||||||
|
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||||
|
import org.dueattendant149.bookshelf.domain.repository.ProgressRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class SyncPlaybackProgressUseCase
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val progressRepository: ProgressRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
book: Book,
|
||||||
|
currentFile: String,
|
||||||
|
position: Long,
|
||||||
|
duration: Long,
|
||||||
|
): Result<Unit> {
|
||||||
|
if (book.remoteId.isBlank()) {
|
||||||
|
return Result.success(Unit)
|
||||||
|
}
|
||||||
|
return progressRepository.syncPlaybackProgress(
|
||||||
|
book = book,
|
||||||
|
currentFile = currentFile,
|
||||||
|
position = position,
|
||||||
|
duration = duration,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
/*
|
||||||
|
* Book's Story — free and open-source Material You eBook reader.
|
||||||
|
* Copyright (C) 2024-2026 Acclorite
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-only
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.dueattendant149.bookshelf.domain.use_case.remote
|
||||||
|
|
||||||
|
import org.dueattendant149.bookshelf.domain.model.library.Book
|
||||||
|
import org.dueattendant149.bookshelf.domain.repository.ProgressRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class SyncReadingProgressUseCase
|
||||||
|
@Inject
|
||||||
|
constructor(
|
||||||
|
private val progressRepository: ProgressRepository,
|
||||||
|
) {
|
||||||
|
suspend operator fun invoke(
|
||||||
|
book: Book,
|
||||||
|
lastChapter: String? = null,
|
||||||
|
): Result<Unit> {
|
||||||
|
if (book.remoteId.isBlank()) {
|
||||||
|
return Result.success(Unit)
|
||||||
|
}
|
||||||
|
return progressRepository.syncReadingProgress(book, lastChapter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -37,6 +37,7 @@ import org.dueattendant149.bookshelf.domain.use_case.book.GetChapterProgressUseC
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.book.GetTextUseCase
|
import org.dueattendant149.bookshelf.domain.use_case.book.GetTextUseCase
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.book.UpdateBookUseCase
|
import org.dueattendant149.bookshelf.domain.use_case.book.UpdateBookUseCase
|
||||||
import org.dueattendant149.bookshelf.domain.use_case.history.GetHistoryForBookUseCase
|
import org.dueattendant149.bookshelf.domain.use_case.history.GetHistoryForBookUseCase
|
||||||
|
import org.dueattendant149.bookshelf.domain.use_case.remote.SyncReadingProgressUseCase
|
||||||
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
|
import org.dueattendant149.bookshelf.presentation.history.HistoryScreen
|
||||||
import org.dueattendant149.bookshelf.presentation.library.LibraryScreen
|
import org.dueattendant149.bookshelf.presentation.library.LibraryScreen
|
||||||
import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint
|
import org.dueattendant149.bookshelf.presentation.reader.model.Checkpoint
|
||||||
|
|
@ -50,7 +51,8 @@ class ReaderModel @Inject constructor(
|
||||||
private val getTextUseCase: GetTextUseCase,
|
private val getTextUseCase: GetTextUseCase,
|
||||||
private val getBookUseCase: GetBookUseCase,
|
private val getBookUseCase: GetBookUseCase,
|
||||||
private val getHistoryForBookUseCase: GetHistoryForBookUseCase,
|
private val getHistoryForBookUseCase: GetHistoryForBookUseCase,
|
||||||
private val getChapterProgressUseCase: GetChapterProgressUseCase
|
private val getChapterProgressUseCase: GetChapterProgressUseCase,
|
||||||
|
private val syncReadingProgressUseCase: SyncReadingProgressUseCase,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
@ -177,6 +179,10 @@ class ReaderModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
updateBookUseCase(_state.value.book)
|
updateBookUseCase(_state.value.book)
|
||||||
|
syncReadingProgressUseCase(
|
||||||
|
_state.value.book,
|
||||||
|
_state.value.currentChapter?.title,
|
||||||
|
)
|
||||||
|
|
||||||
LibraryScreen.refreshListChannel.trySend(300)
|
LibraryScreen.refreshListChannel.trySend(300)
|
||||||
HistoryScreen.refreshListChannel.trySend(300)
|
HistoryScreen.refreshListChannel.trySend(300)
|
||||||
|
|
@ -286,6 +292,10 @@ class ReaderModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
updateBookUseCase(_state.value.book)
|
updateBookUseCase(_state.value.book)
|
||||||
|
syncReadingProgressUseCase(
|
||||||
|
_state.value.book,
|
||||||
|
_state.value.currentChapter?.title,
|
||||||
|
)
|
||||||
|
|
||||||
LibraryScreen.refreshListChannel.trySend(0)
|
LibraryScreen.refreshListChannel.trySend(0)
|
||||||
HistoryScreen.refreshListChannel.trySend(0)
|
HistoryScreen.refreshListChannel.trySend(0)
|
||||||
|
|
@ -448,6 +458,10 @@ class ReaderModel @Inject constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
updateBookUseCase(_state.value.book)
|
updateBookUseCase(_state.value.book)
|
||||||
|
syncReadingProgressUseCase(
|
||||||
|
_state.value.book,
|
||||||
|
_state.value.currentChapter?.title,
|
||||||
|
)
|
||||||
|
|
||||||
LibraryScreen.refreshListChannel.trySend(0)
|
LibraryScreen.refreshListChannel.trySend(0)
|
||||||
HistoryScreen.refreshListChannel.trySend(0)
|
HistoryScreen.refreshListChannel.trySend(0)
|
||||||
|
|
|
||||||
|
|
@ -532,6 +532,8 @@
|
||||||
<string name="bookshelf_cache_failed">Failed</string>
|
<string name="bookshelf_cache_failed">Failed</string>
|
||||||
<string name="bookshelf_download_action">Download</string>
|
<string name="bookshelf_download_action">Download</string>
|
||||||
<string name="bookshelf_delete_cache_action">Delete</string>
|
<string name="bookshelf_delete_cache_action">Delete</string>
|
||||||
|
<string name="progress_sync_failed">Failed to sync reading progress</string>
|
||||||
|
<string name="playback_progress_sync_failed">Failed to sync playback progress</string>
|
||||||
<string name="arrow_content_desc">Arrow</string>
|
<string name="arrow_content_desc">Arrow</string>
|
||||||
<string name="reset_start_content_desc">Reset start guide</string>
|
<string name="reset_start_content_desc">Reset start guide</string>
|
||||||
<string name="open_in_web_content_desc">Open in web</string>
|
<string name="open_in_web_content_desc">Open in web</string>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue