feat: improve library and audiobook workflow
This commit is contained in:
+5
-10
@@ -1,13 +1,7 @@
|
||||
plugins {
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
}
|
||||
val audiobookApiTokenFile = file("${System.getProperty("user.home")}/.aletheia/audiobook-api-token.txt")
|
||||
val audiobookApiToken = if (audiobookApiTokenFile.isFile) {
|
||||
audiobookApiTokenFile.readText().trim()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
fun quotedBuildConfig(value: String): String =
|
||||
"\"${value.replace("\\", "\\\\").replace("\"", "\\\"")}\""
|
||||
|
||||
@@ -19,15 +13,14 @@ android {
|
||||
applicationId = "com.aletheia.app"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 50
|
||||
versionName = "2.39"
|
||||
versionCode = 55
|
||||
versionName = "2.44"
|
||||
|
||||
buildConfigField(
|
||||
"String",
|
||||
"AUDIOBOOK_API_BASE_URL",
|
||||
quotedBuildConfig("https://argus.kusoft.xyz/aletheia-tts/")
|
||||
)
|
||||
buildConfigField("String", "AUDIOBOOK_API_TOKEN", quotedBuildConfig(audiobookApiToken))
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
@@ -76,6 +69,8 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.media3:media3-exoplayer:1.9.3")
|
||||
implementation("androidx.media3:media3-session:1.9.3")
|
||||
implementation("androidx.core:core-ktx:1.15.0")
|
||||
implementation("androidx.appcompat:appcompat:1.7.0")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.aletheia.app.data
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionCommand
|
||||
import androidx.media3.session.SessionToken
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.aletheia.app.AletheiaApplication
|
||||
import com.aletheia.app.audiobook.AudioPlaybackService
|
||||
import com.aletheia.app.model.AudioBook
|
||||
import com.aletheia.app.model.Book
|
||||
import com.aletheia.app.ui.main.MainActivity
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
class AudioRegressionTest {
|
||||
@Test fun oldCacheAudioMovesToPermanentStorageWithoutLosingPosition() {
|
||||
val app = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext as AletheiaApplication
|
||||
val sourceId = app.databaseHelper.saveBook(Book(title = "Migration fixture"))
|
||||
val old = File(app.cacheDir, "audiobooks/migration-$sourceId.m4a").apply { parentFile!!.mkdirs(); writeBytes(byteArrayOf(1, 2, 3)) }
|
||||
val id = app.databaseHelper.saveAudioBook(AudioBook(sourceBookId = sourceId, title = "Migration", author = "", status = AudioBook.Status.READY, filePath = old.path, positionMs = 3210))
|
||||
try {
|
||||
val migrated = requireNotNull(app.audioBookRepository.getById(id))
|
||||
assertTrue(File(migrated.filePath).parentFile == File(app.filesDir, "Audiobooks"))
|
||||
assertArrayEquals(byteArrayOf(1, 2, 3), File(migrated.filePath).readBytes())
|
||||
assertFalse(old.exists())
|
||||
assertEquals(3210L, migrated.positionMs)
|
||||
} finally {
|
||||
app.audioBookRepository.getById(id)?.let(app.audioBookRepository::delete)
|
||||
app.databaseHelper.getBookById(sourceId)?.let(app.databaseHelper::deleteBook)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun sleepTimerStopsServicePlaybackWithScreenOff() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val context = instrumentation.targetContext
|
||||
val audio = File(context.cacheDir, "sleep-timer-test.wav")
|
||||
val sampleBytes = 8000 * 2 * 2
|
||||
val bytes = ByteBuffer.allocate(44 + sampleBytes).order(ByteOrder.LITTLE_ENDIAN)
|
||||
bytes.put("RIFF".toByteArray()).putInt(36 + sampleBytes).put("WAVEfmt ".toByteArray())
|
||||
.putInt(16).putShort(1).putShort(1).putInt(8000).putInt(16000).putShort(2).putShort(16)
|
||||
.put("data".toByteArray()).putInt(sampleBytes)
|
||||
audio.writeBytes(bytes.array())
|
||||
ActivityScenario.launch(MainActivity::class.java).use {
|
||||
val future = MediaController.Builder(context, SessionToken(context, ComponentName(context, AudioPlaybackService::class.java))).buildAsync()
|
||||
val player = future.get(20, TimeUnit.SECONDS)
|
||||
try {
|
||||
instrumentation.runOnMainSync {
|
||||
player.setMediaItem(MediaItem.fromUri(android.net.Uri.fromFile(audio)))
|
||||
player.repeatMode = Player.REPEAT_MODE_ONE
|
||||
player.prepare()
|
||||
player.play()
|
||||
}
|
||||
await(10000) { player.isPlaying }
|
||||
instrumentation.runOnMainSync {
|
||||
player.sendCustomCommand(SessionCommand(AudioPlaybackService.SLEEP_COMMAND, Bundle.EMPTY), Bundle().apply { putInt("minutes", 1) })
|
||||
}
|
||||
await(5000) { player.sessionExtras.getLong(AudioPlaybackService.SLEEP_DEADLINE) > 0 }
|
||||
instrumentation.uiAutomation.executeShellCommand("input keyevent KEYCODE_SLEEP").close()
|
||||
Thread.sleep(1500)
|
||||
instrumentation.runOnMainSync { assertTrue("Playback should continue in background", player.isPlaying) }
|
||||
await(65000) { !player.isPlaying && player.sessionExtras.getLong(AudioPlaybackService.SLEEP_DEADLINE) == 0L }
|
||||
} finally {
|
||||
instrumentation.uiAutomation.executeShellCommand("input keyevent KEYCODE_WAKEUP").close()
|
||||
instrumentation.runOnMainSync { player.stop(); player.clearMediaItems(); MediaController.releaseFuture(future) }
|
||||
context.stopService(Intent(context, AudioPlaybackService::class.java))
|
||||
audio.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun await(timeoutMs: Long, check: () -> Boolean) {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val end = android.os.SystemClock.elapsedRealtime() + timeoutMs
|
||||
do {
|
||||
var ready = false
|
||||
instrumentation.runOnMainSync { ready = check() }
|
||||
if (ready) return
|
||||
Thread.sleep(100)
|
||||
} while (android.os.SystemClock.elapsedRealtime() < end)
|
||||
fail("Playback state timed out after $timeoutMs ms")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.aletheia.app.data
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.aletheia.app.model.Book
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import java.util.UUID
|
||||
|
||||
class DatabaseRegressionTest {
|
||||
@Test fun version8UpgradePreservesLegacyRowsAndReadingPosition() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val name = "migration-${UUID.randomUUID()}.db"
|
||||
// Original books schema from release 2.39 (database version 8).
|
||||
context.openOrCreateDatabase(name, 0, null).use { old ->
|
||||
old.execSQL("""CREATE TABLE books (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, author TEXT NOT NULL,
|
||||
file_path TEXT NOT NULL, file_name TEXT NOT NULL, format TEXT NOT NULL,
|
||||
cover_image BLOB, reading_progress REAL NOT NULL DEFAULT 0, last_cfi TEXT,
|
||||
last_chapter TEXT, locations TEXT, date_added INTEGER NOT NULL, last_read INTEGER NOT NULL,
|
||||
total_pages INTEGER NOT NULL DEFAULT 0, current_page INTEGER NOT NULL DEFAULT 0, remote_id TEXT)""")
|
||||
repeat(2) { old.execSQL("INSERT INTO books(title,author,file_path,file_name,format,date_added,last_read,remote_id,reading_progress,last_cfi) VALUES('Legacy','Author','','','epub',1,2,'42',0.75,'old-cfi')") }
|
||||
old.version = 8
|
||||
}
|
||||
val upgraded = AppDatabaseHelper(context, name)
|
||||
try {
|
||||
val books = upgraded.getAllBooks()
|
||||
assertEquals(2, books.size)
|
||||
books.forEach { assertNull(it.catalogSource); assertEquals(0.75, it.readingProgress, 0.0); assertEquals("old-cfi", it.lastCfi) }
|
||||
assertEquals(9, upgraded.readableDatabase.version)
|
||||
} finally { upgraded.close(); context.deleteDatabase(name) }
|
||||
}
|
||||
|
||||
@Test fun generationUpdatesPreservePlaybackPosition() = isolated { db ->
|
||||
val book = db.saveBook(Book(title = "Audio source"))
|
||||
val snapshot = com.aletheia.app.model.AudioBook(sourceBookId = book, title = "Audio", author = "Author")
|
||||
val id = db.saveAudioBook(snapshot)
|
||||
db.updateAudioPosition(id, 123456L)
|
||||
db.saveAudioBook(snapshot.copy(id = id, currentChapter = "New metadata"))
|
||||
assertEquals(123456L, db.getAudioBookById(id)?.positionMs)
|
||||
}
|
||||
|
||||
@Test fun metadataAndLocationUpdatesPreserveNewestReadingPosition() = isolated { db ->
|
||||
val old = Book(title = "Книга", author = "Автор")
|
||||
val id = db.saveBook(old)
|
||||
db.updateReadingState(id, 0.75, "epubcfi(/6/4)", "Глава", 75, 100)
|
||||
db.updateMetadata(old.copy(id = id, coverImage = byteArrayOf(1, 2, 3)))
|
||||
db.updateLocations(id, "locations")
|
||||
val saved = requireNotNull(db.getBookById(id))
|
||||
assertEquals(0.75, saved.readingProgress, 0.0)
|
||||
assertEquals("epubcfi(/6/4)", saved.lastCfi)
|
||||
assertEquals(75, saved.currentPage)
|
||||
assertArrayEquals(byteArrayOf(1, 2, 3), saved.coverImage)
|
||||
assertEquals("locations", saved.locations)
|
||||
}
|
||||
|
||||
@Test fun remoteIdsAreUniqueOnlyWithinTheirSource() = isolated { db ->
|
||||
val a = Book(title = "A", remoteId = "42", catalogSource = "https://one.test/opds")
|
||||
val b = a.copy(title = "B", catalogSource = "https://two.test/opds")
|
||||
val idA = db.saveBook(a)
|
||||
val idB = db.saveBook(b)
|
||||
assertEquals(idA, db.getBookByRemoteId(a.catalogSource!!, "42")?.id)
|
||||
assertEquals(idB, db.getBookByRemoteId(b.catalogSource!!, "42")?.id)
|
||||
assertNull(db.getBookByRemoteId("https://three.test", "42"))
|
||||
try { db.saveBook(a); fail("Duplicate source/ID must be rejected") }
|
||||
catch (_: android.database.sqlite.SQLiteConstraintException) { }
|
||||
db.saveBook(a.copy(catalogSource = null))
|
||||
db.saveBook(a.copy(catalogSource = null)) // unclassified legacy rows remain intact
|
||||
}
|
||||
|
||||
private fun isolated(block: (AppDatabaseHelper) -> Unit) {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val name = "audit-${UUID.randomUUID()}.db"
|
||||
val db = AppDatabaseHelper(context, name)
|
||||
try { block(db) } finally { db.close(); context.deleteDatabase(name) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.aletheia.app.data
|
||||
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.aletheia.app.model.QBooksBook
|
||||
import java.io.File
|
||||
import java.net.ServerSocket
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.concurrent.thread
|
||||
import kotlinx.coroutines.*
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class DownloadRegressionTest {
|
||||
@Test fun twoScreensShareOneDownloadAndObserverCancellationDoesNotStopImport() = runBlocking {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
val name = "download-${UUID.randomUUID()}.db"
|
||||
val db = AppDatabaseHelper(context, name)
|
||||
val folder = File(context.cacheDir, UUID.randomUUID().toString()).apply { mkdirs() }
|
||||
val server = ServerSocket(0)
|
||||
val received = CountDownLatch(1)
|
||||
val respond = CountDownLatch(1)
|
||||
val requests = AtomicInteger()
|
||||
val body = """<?xml version="1.0" encoding="UTF-8"?><FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0"><description><title-info><book-title>Тест загрузки</book-title><author><first-name>Иван</first-name></author></title-info></description><body><section><p>Текст</p></section></body></FictionBook>""".toByteArray()
|
||||
val serving = thread {
|
||||
server.accept().use { socket ->
|
||||
val input = socket.getInputStream().bufferedReader()
|
||||
while (!input.readLine().isNullOrEmpty()) { }
|
||||
requests.incrementAndGet()
|
||||
received.countDown()
|
||||
check(respond.await(10, TimeUnit.SECONDS))
|
||||
socket.getOutputStream().apply {
|
||||
write("HTTP/1.1 200 OK\r\nContent-Length: ${body.size}\r\nConnection: close\r\n\r\n".toByteArray())
|
||||
write(body); flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
try {
|
||||
val settings = SettingsRepository(context, db)
|
||||
val source = "http://127.0.0.1:${server.localPort}"
|
||||
settings.setString(SettingsRepository.KEY_QBOOKS_URL, source)
|
||||
val parser = BookParserService(File(folder, "books"), folder) { android.util.Base64.decode(it, 0) }
|
||||
val repo = BookRepository(db, parser, settings)
|
||||
val downloads = BookDownloads(scope, QBooksService(settings, CatalogCache(context)), repo, folder)
|
||||
val book = QBooksBook("42", "Тест", "Иван", "fb2", "$source/book.fb2", catalogSource = source)
|
||||
val first = downloads.start(book)
|
||||
assertTrue(received.await(10, TimeUnit.SECONDS))
|
||||
val second = downloads.start(book)
|
||||
assertSame(first, second)
|
||||
val observer = launch { first.await() }
|
||||
observer.cancelAndJoin()
|
||||
respond.countDown()
|
||||
val imported = withTimeout(10000) { second.await() }
|
||||
assertEquals(1, requests.get())
|
||||
assertEquals(1, db.getAllBooks().size)
|
||||
assertEquals(imported.id, downloads.states.value[downloads.key(book)]?.bookId)
|
||||
assertEquals("Тест загрузки", imported.title)
|
||||
} finally {
|
||||
respond.countDown(); server.close(); serving.join(1000); scope.cancel()
|
||||
db.close(); context.deleteDatabase(name); folder.deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.aletheia.app.data
|
||||
|
||||
import android.view.View
|
||||
import androidx.test.core.app.ActivityScenario
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.aletheia.app.AletheiaApplication
|
||||
import com.aletheia.app.R
|
||||
import com.aletheia.app.model.Book
|
||||
import com.aletheia.app.ui.main.MainActivity
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class LibraryUiRegressionTest {
|
||||
@Test fun emptyFinishedFilterKeepsControlsAndResetRestoresBooks() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val app = instrumentation.targetContext.applicationContext as AletheiaApplication
|
||||
val id = app.databaseHelper.saveBook(Book(title = "Непрочитанная тестовая книга"))
|
||||
try {
|
||||
ActivityScenario.launch(MainActivity::class.java).use { scenario ->
|
||||
scenario.onActivity { it.selectTab(R.id.nav_my_books) }
|
||||
awaitUi(scenario) { it.findViewById<View>(R.id.library_filter_finished)?.isShown == true }
|
||||
scenario.onActivity { it.findViewById<View>(R.id.library_filter_finished).performClick() }
|
||||
awaitUi(scenario) { it.findViewById<View>(R.id.empty_search_card)?.isShown == true }
|
||||
scenario.onActivity {
|
||||
assertTrue(it.findViewById<View>(R.id.library_filter_all).isShown)
|
||||
it.findViewById<View>(R.id.clear_library_search_button).performClick()
|
||||
}
|
||||
awaitUi(scenario) { it.findViewById<View>(R.id.books_recycler)?.isShown == true }
|
||||
scenario.onActivity { assertFalse(it.findViewById<View>(R.id.empty_search_card).isShown) }
|
||||
}
|
||||
} finally { app.databaseHelper.getBookById(id)?.let(app.databaseHelper::deleteBook) }
|
||||
}
|
||||
|
||||
private fun awaitUi(scenario: ActivityScenario<MainActivity>, predicate: (MainActivity) -> Boolean) {
|
||||
repeat(100) {
|
||||
var ready = false
|
||||
scenario.onActivity { ready = predicate(it) }
|
||||
if (ready) return
|
||||
Thread.sleep(100)
|
||||
}
|
||||
fail("UI state did not arrive within 10 seconds")
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
|
||||
<application
|
||||
@@ -28,6 +30,15 @@
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<service
|
||||
android:name=".audiobook.AudioPlaybackService"
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.media3.session.MediaSessionService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".audiobook.AudioBookGenerationService"
|
||||
android:exported="false"
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.aletheia.app.ui.reader.ReaderStateCoordinator
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import xyz.kusoft.argusupdater.ArgusUpdateManager
|
||||
|
||||
@@ -47,6 +48,12 @@ class AletheiaApplication : Application() {
|
||||
|
||||
lateinit var appUpdateManager: ArgusUpdateManager
|
||||
private set
|
||||
lateinit var bookDownloads: com.aletheia.app.data.BookDownloads
|
||||
private set
|
||||
lateinit var catalogFavorites: com.aletheia.app.data.CatalogFavorites
|
||||
private set
|
||||
lateinit var audioJobCleanup: com.aletheia.app.audiobook.AudioJobCleanup
|
||||
private set
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
@@ -57,9 +64,20 @@ class AletheiaApplication : Application() {
|
||||
settingsRepository = SettingsRepository(this, databaseHelper)
|
||||
bookParserService = BookParserService(this)
|
||||
bookRepository = BookRepository(databaseHelper, bookParserService, settingsRepository)
|
||||
audioBookRepository = AudioBookRepository(this, databaseHelper)
|
||||
audioJobCleanup = com.aletheia.app.audiobook.AudioJobCleanup(this, applicationScope) {
|
||||
com.aletheia.app.audiobook.RemoteAudioBookClient(this)
|
||||
}
|
||||
audioBookRepository = AudioBookRepository(this, databaseHelper, audioJobCleanup)
|
||||
applicationScope.launch {
|
||||
audioBookRepository.getAll().filter { it.status == com.aletheia.app.model.AudioBook.Status.READY }
|
||||
.mapNotNull { it.remoteJobId }.forEach(audioJobCleanup::enqueue)
|
||||
audioJobCleanup.flush()
|
||||
}
|
||||
audioJobCleanup.flush()
|
||||
catalogCache = CatalogCache(this)
|
||||
qBooksService = QBooksService(settingsRepository, catalogCache)
|
||||
bookDownloads = com.aletheia.app.data.BookDownloads(applicationScope, qBooksService, bookRepository, cacheDir)
|
||||
catalogFavorites = com.aletheia.app.data.CatalogFavorites(this, catalogCache)
|
||||
appUpdateManager = ArgusUpdateManager(this)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ class AudioBookGenerationService : Service() {
|
||||
private val jobs = ConcurrentHashMap<Long, Job>()
|
||||
private val userCancellations = ConcurrentHashMap.newKeySet<Long>()
|
||||
private val app by lazy { application as AletheiaApplication }
|
||||
private val client by lazy { RemoteAudioBookClient() }
|
||||
private val client by lazy { RemoteAudioBookClient(this) }
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
@@ -76,6 +76,7 @@ class AudioBookGenerationService : Service() {
|
||||
try {
|
||||
var remoteJobId = record.remoteJobId
|
||||
if (remoteJobId.isNullOrBlank()) {
|
||||
client.checkAvailable()
|
||||
record = saveProgress(
|
||||
record.copy(
|
||||
status = AudioBook.Status.GENERATING,
|
||||
@@ -87,19 +88,25 @@ class AudioBookGenerationService : Service() {
|
||||
)
|
||||
val created = client.create(source, book.title, book.author)
|
||||
remoteJobId = created.id
|
||||
record = saveProgress(record.copy(remoteJobId = remoteJobId))
|
||||
record = record.copy(remoteJobId = remoteJobId)
|
||||
record = saveProgress(record)
|
||||
}
|
||||
|
||||
var consecutiveFailures = 0
|
||||
while (true) {
|
||||
val remote = try {
|
||||
client.status(remoteJobId)
|
||||
} catch (error: IOException) {
|
||||
consecutiveFailures = (consecutiveFailures + 1).coerceAtMost(4)
|
||||
if (!AudioRetryPolicy.shouldRetry(error, consecutiveFailures)) throw error
|
||||
record = saveProgress(
|
||||
record.copy(currentChapter = "Нет связи с сервером, повтор через 15 секунд…")
|
||||
record.copy(status = AudioBook.Status.GENERATING, errorMessage = null,
|
||||
currentChapter = "Нет связи с сервером. Ожидаем подключения…")
|
||||
)
|
||||
delay(RETRY_DELAY_MS)
|
||||
delay((RETRY_DELAY_MS * consecutiveFailures).coerceAtMost(60000L))
|
||||
continue
|
||||
}
|
||||
consecutiveFailures = 0
|
||||
|
||||
record = saveProgress(
|
||||
record.copy(
|
||||
@@ -108,7 +115,8 @@ class AudioBookGenerationService : Service() {
|
||||
totalCharacters = remote.totalCharacters,
|
||||
durationMs = remote.durationMs,
|
||||
chaptersJson = remote.chaptersJson,
|
||||
currentChapter = remote.currentChapter ?: stageLabel(remote.stage),
|
||||
currentChapter = if (remote.stage == "synthesizing")
|
||||
remote.currentChapter ?: stageLabel(remote.stage) else stageLabel(remote.stage),
|
||||
errorMessage = null
|
||||
)
|
||||
)
|
||||
@@ -127,24 +135,39 @@ class AudioBookGenerationService : Service() {
|
||||
|
||||
partial.delete()
|
||||
record = saveProgress(record.copy(currentChapter = "Скачивание готового M4A…"))
|
||||
client.download(remoteJobId, partial)
|
||||
while (true) {
|
||||
try {
|
||||
client.download(remoteJobId, partial)
|
||||
break
|
||||
} catch (error: IOException) {
|
||||
if (!AudioRetryPolicy.shouldRetry(error, 1)) throw error
|
||||
partial.delete()
|
||||
record = saveProgress(record.copy(currentChapter = "Скачивание прервано. Ожидаем подключения…"))
|
||||
delay(RETRY_DELAY_MS)
|
||||
record = saveProgress(record.copy(currentChapter = "Скачивание готового M4A…"))
|
||||
}
|
||||
}
|
||||
output.delete()
|
||||
check(partial.renameTo(output)) { "Не удалось сохранить готовый M4A-файл" }
|
||||
record = saveProgress(
|
||||
record.copy(
|
||||
filePath = output.absolutePath,
|
||||
remoteJobId = null,
|
||||
status = AudioBook.Status.READY,
|
||||
processedCharacters = record.totalCharacters,
|
||||
currentChapter = null
|
||||
)
|
||||
)
|
||||
runCatching { client.cancelOrDelete(remoteJobId) }
|
||||
.onFailure { Log.w(TAG, "Remote job cleanup failed for id=$remoteJobId", it) }
|
||||
app.audioJobCleanup.enqueue(remoteJobId)
|
||||
app.audioJobCleanup.flush()
|
||||
notifyComplete(record)
|
||||
} catch (cancelled: CancellationException) {
|
||||
if (app.audioBookRepository.getById(audioBookId) == null) {
|
||||
partial.delete()
|
||||
output.delete()
|
||||
}
|
||||
if (userCancellations.remove(audioBookId)) {
|
||||
record.remoteJobId?.let { runCatching { client.cancelOrDelete(it) } }
|
||||
record.remoteJobId?.let(app.audioJobCleanup::enqueue)
|
||||
app.audioJobCleanup.flush()
|
||||
partial.delete()
|
||||
if (app.audioBookRepository.getById(audioBookId) != null) {
|
||||
saveProgress(record.copy(status = AudioBook.Status.CANCELLED, currentChapter = null))
|
||||
@@ -168,7 +191,8 @@ class AudioBookGenerationService : Service() {
|
||||
}
|
||||
scope.launch {
|
||||
val record = app.audioBookRepository.getById(audioBookId) ?: return@launch
|
||||
record.remoteJobId?.let { runCatching { client.cancelOrDelete(it) } }
|
||||
record.remoteJobId?.let(app.audioJobCleanup::enqueue)
|
||||
app.audioJobCleanup.flush()
|
||||
app.audioBookRepository.partialOutputFile(audioBookId).delete()
|
||||
if (app.audioBookRepository.getById(audioBookId) != null) {
|
||||
saveProgress(record.copy(status = AudioBook.Status.CANCELLED, currentChapter = null))
|
||||
@@ -177,13 +201,19 @@ class AudioBookGenerationService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveProgress(audioBook: AudioBook): AudioBook =
|
||||
app.audioBookRepository.save(audioBook).also(::broadcastProgress)
|
||||
private fun saveProgress(audioBook: AudioBook): AudioBook {
|
||||
if (app.audioBookRepository.getById(audioBook.id) == null) {
|
||||
audioBook.remoteJobId?.let(app.audioJobCleanup::enqueue)
|
||||
app.audioJobCleanup.flush()
|
||||
throw CancellationException("Задание удалено")
|
||||
}
|
||||
return app.audioBookRepository.save(audioBook).also(::broadcastProgress)
|
||||
}
|
||||
|
||||
private fun stageLabel(stage: String): String = when (stage) {
|
||||
"queued" -> "В очереди на CMP-сервере…"
|
||||
"extracting" -> "Подготовка текста книги…"
|
||||
"loading_model" -> "Загрузка мужского голоса Qwen…"
|
||||
"loading_model" -> "Подготовка голоса VoiceStudio…"
|
||||
"synthesizing" -> "Создание аудиокниги…"
|
||||
"encoding" -> "Сборка итогового M4A…"
|
||||
"ready" -> "Аудиокнига готова"
|
||||
@@ -196,6 +226,7 @@ class AudioBookGenerationService : Service() {
|
||||
}
|
||||
|
||||
private fun fail(record: AudioBook, message: String) {
|
||||
if (app.audioBookRepository.getById(record.id) == null) return
|
||||
val failed = saveProgress(
|
||||
record.copy(status = AudioBook.Status.FAILED, currentChapter = null, errorMessage = message)
|
||||
)
|
||||
@@ -232,7 +263,7 @@ class AudioBookGenerationService : Service() {
|
||||
private fun notification(id: Long, progress: Int, detail: String, ongoing: Boolean) =
|
||||
NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_reader_v2_voice)
|
||||
.setContentTitle("Создание аудиокниги · Qwen, мужской голос")
|
||||
.setContentTitle("Создание аудиокниги · VoiceStudio")
|
||||
.setContentText(detail)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setOngoing(ongoing)
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.aletheia.app.audiobook
|
||||
|
||||
import android.content.Context
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.AtomicFile
|
||||
import java.io.File
|
||||
import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
class AudioDeviceCredentials(context: Context) {
|
||||
private val file = AtomicFile(File(context.noBackupFilesDir, "audiobook-device.bin"))
|
||||
private fun key(): SecretKey {
|
||||
val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
(store.getKey(ALIAS, null) as? SecretKey)?.let { return it }
|
||||
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").apply {
|
||||
init(KeyGenParameterSpec.Builder(ALIAS, KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT)
|
||||
.setBlockModes(KeyProperties.BLOCK_MODE_GCM).setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE).build())
|
||||
}.generateKey()
|
||||
}
|
||||
fun token(): String = synchronized(LOCK) {
|
||||
if (!file.baseFile.isFile) return@synchronized ""
|
||||
runCatching {
|
||||
val bytes = file.readFully()
|
||||
require(bytes.size > 28)
|
||||
Cipher.getInstance("AES/GCM/NoPadding").run {
|
||||
init(Cipher.DECRYPT_MODE, key(), GCMParameterSpec(128, bytes.copyOfRange(0, 12)))
|
||||
String(doFinal(bytes.copyOfRange(12, bytes.size)), Charsets.UTF_8)
|
||||
}
|
||||
}.getOrDefault("")
|
||||
}
|
||||
fun save(token: String) = synchronized(LOCK) {
|
||||
require(token.length in 32..256)
|
||||
val cipher = Cipher.getInstance("AES/GCM/NoPadding").apply { init(Cipher.ENCRYPT_MODE, key()) }
|
||||
val bytes = cipher.iv + cipher.doFinal(token.toByteArray(Charsets.UTF_8))
|
||||
val output = file.startWrite()
|
||||
try { output.write(bytes); file.finishWrite(output) }
|
||||
catch (error: Exception) { file.failWrite(output); throw error }
|
||||
}
|
||||
companion object {
|
||||
private const val ALIAS = "aletheia.audio.device.v1"
|
||||
private val LOCK = Any()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.aletheia.app.audiobook
|
||||
|
||||
class AudioHttpException(val status: Int) : java.io.IOException(when (status) {
|
||||
401, 403 -> "Нет доступа к аудиосервису. Подключите устройство в настройках."
|
||||
404 -> "Задание больше не существует на сервере. Создайте аудиокнигу повторно."
|
||||
429 -> "Сервер занят. Повторите позже."
|
||||
else -> "Сервер аудиокниг вернул HTTP $status"
|
||||
})
|
||||
|
||||
object AudioRetryPolicy {
|
||||
fun shouldRetry(error: java.io.IOException, failures: Int): Boolean =
|
||||
(error !is AudioHttpException || error.status in listOf(408, 429) || error.status in 500..599)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.aletheia.app.audiobook
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Durable outbox: deleting a local row never loses the remote cancellation request. */
|
||||
class AudioJobCleanup(context: Context, private val scope: CoroutineScope, private val client: () -> RemoteAudioBookClient) {
|
||||
private val prefs = context.getSharedPreferences("audio_cleanup", Context.MODE_PRIVATE)
|
||||
private var worker: Job? = null
|
||||
@Synchronized fun enqueue(id: String) {
|
||||
val ids = prefs.getStringSet("pending", emptySet()).orEmpty().toMutableSet()
|
||||
ids.add(id)
|
||||
check(prefs.edit().putStringSet("pending", ids).commit()) { "Не удалось сохранить отмену задания" }
|
||||
}
|
||||
@Synchronized private fun pending() = prefs.getStringSet("pending", emptySet()).orEmpty().toSet()
|
||||
@Synchronized private fun remove(id: String) {
|
||||
prefs.edit().putStringSet("pending", pending() - id).commit()
|
||||
}
|
||||
@Synchronized fun flush() {
|
||||
if (worker?.isActive == true) return
|
||||
worker = scope.launch {
|
||||
var failures = 0
|
||||
while (pending().isNotEmpty()) {
|
||||
var failed = false
|
||||
for (id in pending()) {
|
||||
try { if (client().cancelOrDelete(id)) remove(id) else failed = true }
|
||||
catch (error: Exception) {
|
||||
if (error is kotlinx.coroutines.CancellationException) throw error
|
||||
failed = true
|
||||
}
|
||||
}
|
||||
if (failed) {
|
||||
failures = (failures + 1).coerceAtMost(4)
|
||||
delay((15000L * failures).coerceAtMost(60000))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package com.aletheia.app.audiobook
|
||||
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.session.MediaSession
|
||||
import androidx.media3.session.MediaSessionService
|
||||
import androidx.media3.session.SessionCommand
|
||||
import androidx.media3.session.SessionResult
|
||||
import androidx.media3.session.SessionError
|
||||
import com.aletheia.app.AletheiaApplication
|
||||
import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
class AudioPlaybackService : MediaSessionService() {
|
||||
private lateinit var player: ExoPlayer
|
||||
private var session: MediaSession? = null
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var sleepDeadline = 0L
|
||||
private var lastSave = 0L
|
||||
private val app get() = application as AletheiaApplication
|
||||
private val ticker = object : Runnable {
|
||||
override fun run() {
|
||||
if (sleepDeadline > 0 && SystemClock.elapsedRealtime() >= sleepDeadline) {
|
||||
player.pause()
|
||||
setSleepTimer(0)
|
||||
}
|
||||
if (SystemClock.elapsedRealtime() - lastSave >= 5000) persistPosition()
|
||||
handler.postDelayed(this, 500)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
player = ExoPlayer.Builder(this).build().apply {
|
||||
setAudioAttributes(AudioAttributes.Builder().setUsage(C.USAGE_MEDIA)
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_SPEECH).build(), true)
|
||||
setHandleAudioBecomingNoisy(true)
|
||||
setWakeMode(C.WAKE_MODE_LOCAL)
|
||||
addListener(object : Player.Listener {
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) { persistPosition() }
|
||||
override fun onPositionDiscontinuity(oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, reason: Int) {
|
||||
if (oldPosition.mediaItem?.mediaId != newPosition.mediaItem?.mediaId) {
|
||||
oldPosition.mediaItem?.mediaId?.toLongOrNull()?.let { id ->
|
||||
app.applicationScope.launch { app.audioBookRepository.savePosition(id, oldPosition.positionMs) }
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
session = MediaSession.Builder(this, player).setCallback(object : MediaSession.Callback {
|
||||
override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult {
|
||||
val defaults = super.onConnect(session, controller)
|
||||
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
|
||||
.setAvailableSessionCommands(defaults.availableSessionCommands.buildUpon()
|
||||
.add(SessionCommand(SLEEP_COMMAND, Bundle.EMPTY)).build()).build()
|
||||
}
|
||||
override fun onCustomCommand(session: MediaSession, controller: MediaSession.ControllerInfo,
|
||||
customCommand: SessionCommand, args: Bundle): ListenableFuture<SessionResult> {
|
||||
if (controller.packageName != packageName || customCommand.customAction != SLEEP_COMMAND)
|
||||
return Futures.immediateFuture(SessionResult(SessionError.ERROR_NOT_SUPPORTED))
|
||||
setSleepTimer(args.getInt("minutes").coerceIn(0, 120))
|
||||
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
|
||||
}
|
||||
}).build()
|
||||
handler.post(ticker)
|
||||
}
|
||||
|
||||
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession? =
|
||||
if (controllerInfo.packageName == packageName || controllerInfo.isTrusted) session else null
|
||||
|
||||
private fun setSleepTimer(minutes: Int) {
|
||||
sleepDeadline = if (minutes == 0) 0 else SystemClock.elapsedRealtime() + minutes * 60000L
|
||||
session?.setSessionExtras(Bundle().apply { putLong(SLEEP_DEADLINE, sleepDeadline) })
|
||||
}
|
||||
|
||||
private fun persistPosition() {
|
||||
if (!::player.isInitialized) return
|
||||
val id = player.currentMediaItem?.mediaId?.toLongOrNull() ?: return
|
||||
val position = player.currentPosition.coerceAtLeast(0)
|
||||
lastSave = SystemClock.elapsedRealtime()
|
||||
app.applicationScope.launch { app.audioBookRepository.savePosition(id, position) }
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
persistPosition()
|
||||
session?.release()
|
||||
player.release()
|
||||
session = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val SLEEP_COMMAND = "aletheia.sleep"
|
||||
const val SLEEP_DEADLINE = "sleep.deadline.elapsed"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package com.aletheia.app.audiobook
|
||||
|
||||
object PlaybackMath {
|
||||
fun position(duration: Long, slider: Int): Long = duration.coerceAtLeast(0) / 1000 * slider.coerceIn(0, 1000) +
|
||||
duration.coerceAtLeast(0) % 1000 * slider.coerceIn(0, 1000) / 1000
|
||||
fun slider(position: Long, duration: Long): Int =
|
||||
if (duration <= 0) 0 else (position.coerceIn(0, duration).toDouble() / duration * 1000).toInt().coerceIn(0, 1000)
|
||||
}
|
||||
@@ -12,9 +12,36 @@ import java.util.UUID
|
||||
import org.json.JSONObject
|
||||
|
||||
class RemoteAudioBookClient(
|
||||
private val baseUrl: String = BuildConfig.AUDIOBOOK_API_BASE_URL,
|
||||
private val token: String = BuildConfig.AUDIOBOOK_API_TOKEN
|
||||
context: android.content.Context,
|
||||
private val baseUrl: String = BuildConfig.AUDIOBOOK_API_BASE_URL
|
||||
) {
|
||||
private val credentials = AudioDeviceCredentials(context)
|
||||
fun checkAvailable() {
|
||||
check(credentials.token().isNotBlank()) { "Подключите аудиосервис в настройках приложения" }
|
||||
val connection = open("health").apply {
|
||||
connectTimeout = 8_000
|
||||
readTimeout = 8_000
|
||||
}
|
||||
try {
|
||||
check(JSONObject(connection.readSuccessfulBody()).optString("status") == "ok") {
|
||||
"CMP пока не готов к изготовлению аудиокниг"
|
||||
}
|
||||
} finally { connection.disconnect() }
|
||||
}
|
||||
fun pair(code: String, label: String) {
|
||||
val connection = URL(baseUrl.trimEnd('/') + "/v1/devices/enroll").openConnection() as HttpURLConnection
|
||||
try {
|
||||
connection.connectTimeout = CONNECT_TIMEOUT_MS
|
||||
connection.readTimeout = READ_TIMEOUT_MS
|
||||
connection.requestMethod = "POST"
|
||||
connection.doOutput = true
|
||||
connection.setRequestProperty("Content-Type", "application/json")
|
||||
connection.outputStream.use { it.write(JSONObject().put("code", code.trim()).put("label", label).toString().toByteArray(Charsets.UTF_8)) }
|
||||
if (connection.responseCode !in 200..299) throw IOException("Код подключения недействителен, истёк или сервер недоступен")
|
||||
val result = JSONObject(connection.inputStream.bufferedReader().use { it.readText() })
|
||||
credentials.save(result.getString("token"))
|
||||
} finally { connection.disconnect() }
|
||||
}
|
||||
fun create(source: File, title: String, author: String): RemoteJob {
|
||||
require(source.isFile) { "Файл исходной книги не найден" }
|
||||
val boundary = "aletheia-${UUID.randomUUID()}"
|
||||
@@ -56,9 +83,10 @@ class RemoteAudioBookClient(
|
||||
}
|
||||
try {
|
||||
if (connection.responseCode !in 200..299) {
|
||||
throw IOException(connection.errorDescription())
|
||||
throw AudioHttpException(connection.responseCode)
|
||||
}
|
||||
destination.parentFile?.mkdirs()
|
||||
val expectedLength = connection.contentLengthLong
|
||||
connection.inputStream.buffered(256 * 1024).use { input ->
|
||||
FileOutputStream(destination).buffered(256 * 1024).use { output ->
|
||||
input.copyTo(output, 256 * 1024)
|
||||
@@ -67,26 +95,36 @@ class RemoteAudioBookClient(
|
||||
check(destination.isFile && destination.length() > 0L) {
|
||||
"Сервер вернул пустой M4A-файл"
|
||||
}
|
||||
if (expectedLength >= 0 && destination.length() != expectedLength) {
|
||||
throw IOException("Аудиофайл получен не полностью. Скачивание будет повторено")
|
||||
}
|
||||
java.io.RandomAccessFile(destination, "rw").use { it.fd.sync() }
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelOrDelete(jobId: String) {
|
||||
fun cancelOrDelete(jobId: String): Boolean {
|
||||
val connection = open("v1/audiobooks/$jobId").apply { requestMethod = "DELETE" }
|
||||
try {
|
||||
val code = connection.responseCode
|
||||
if (code !in 200..299 && code != HttpURLConnection.HTTP_NOT_FOUND) {
|
||||
throw IOException(connection.errorDescription())
|
||||
throw AudioHttpException(code)
|
||||
}
|
||||
(if (code in 200..299) connection.inputStream else connection.errorStream)?.close()
|
||||
if (code == HttpURLConnection.HTTP_NOT_FOUND) {
|
||||
connection.errorStream?.close()
|
||||
return true
|
||||
}
|
||||
val body = connection.inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() }
|
||||
return JSONObject(body).optString("status") == "deleted"
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun open(path: String): HttpURLConnection {
|
||||
check(token.isNotBlank()) { "Ключ сервера аудиокниг не добавлен в сборку" }
|
||||
val token = credentials.token()
|
||||
check(token.isNotBlank()) { "Подключите аудиосервис в настройках приложения" }
|
||||
return (URL(baseUrl.trimEnd('/') + "/" + path).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = CONNECT_TIMEOUT_MS
|
||||
readTimeout = READ_TIMEOUT_MS
|
||||
@@ -98,7 +136,7 @@ class RemoteAudioBookClient(
|
||||
|
||||
private fun HttpURLConnection.readSuccessfulBody(): String {
|
||||
val code = responseCode
|
||||
if (code !in 200..299) throw IOException(errorDescription())
|
||||
if (code !in 200..299) throw AudioHttpException(code)
|
||||
return inputStream.bufferedReader(StandardCharsets.UTF_8).use { it.readText() }
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import com.aletheia.app.model.ReadingBookmark
|
||||
import com.aletheia.app.model.ReadingNote
|
||||
import com.aletheia.app.model.ReadingProgress
|
||||
|
||||
class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_NAME, null, DATABASE_VERSION) {
|
||||
class AppDatabaseHelper(context: Context, databaseName: String = DATABASE_NAME) : SQLiteOpenHelper(context, databaseName, null, DATABASE_VERSION) {
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
db.execSQL(
|
||||
@@ -32,7 +32,8 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
||||
last_read INTEGER NOT NULL,
|
||||
total_pages INTEGER NOT NULL DEFAULT 0,
|
||||
current_page INTEGER NOT NULL DEFAULT 0,
|
||||
remote_id TEXT
|
||||
remote_id TEXT,
|
||||
catalog_source TEXT
|
||||
)
|
||||
""".trimIndent()
|
||||
)
|
||||
@@ -63,6 +64,7 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
||||
createBookmarksTable(db)
|
||||
createNotesTable(db)
|
||||
createAudioBooksTable(db)
|
||||
createCatalogIndex(db)
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
@@ -94,6 +96,12 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
||||
if (oldVersion < 8 && !hasColumn(db, "audiobooks", "remote_job_id")) {
|
||||
db.execSQL("ALTER TABLE audiobooks ADD COLUMN remote_job_id TEXT")
|
||||
}
|
||||
if (oldVersion < 9) {
|
||||
db.execSQL("ALTER TABLE books ADD COLUMN catalog_source TEXT")
|
||||
// Historical rows have no trustworthy origin. Preserve them unlinked rather than
|
||||
// guessing a server and associating a different book with the same remote ID.
|
||||
createCatalogIndex(db)
|
||||
}
|
||||
}
|
||||
|
||||
fun getAllBooks(): List<Book> = readableDatabase.query(
|
||||
@@ -162,6 +170,48 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
||||
if (cursor.moveToFirst()) cursor.getString(0) else null
|
||||
}
|
||||
|
||||
fun getBookByRemoteId(source: String, remoteId: String): Book? = readableDatabase.query(
|
||||
"books", null, "catalog_source = ? AND remote_id = ?", arrayOf(source, remoteId),
|
||||
null, null, null, "1"
|
||||
).use { if (it.moveToFirst()) cursorToBook(it) else null }
|
||||
|
||||
fun updateMetadata(book: Book) {
|
||||
writableDatabase.update("books", ContentValues().apply {
|
||||
put("title", book.title)
|
||||
put("author", book.author)
|
||||
put("cover_image", book.coverImage)
|
||||
}, "id = ?", arrayOf(book.id.toString()))
|
||||
}
|
||||
|
||||
fun updateLocations(bookId: Long, locations: String) {
|
||||
writableDatabase.update("books", ContentValues().apply { put("locations", locations) },
|
||||
"id = ?", arrayOf(bookId.toString()))
|
||||
}
|
||||
|
||||
fun updateAudioPosition(id: Long, position: Long) {
|
||||
writableDatabase.update("audiobooks", ContentValues().apply {
|
||||
put("position_ms", position.coerceAtLeast(0))
|
||||
}, "id = ?", arrayOf(id.toString()))
|
||||
}
|
||||
|
||||
fun updateReadingState(bookId: Long, progress: Double, cfi: String?, chapter: String?, page: Int, total: Int) {
|
||||
val db = writableDatabase
|
||||
db.beginTransaction()
|
||||
try {
|
||||
val changed = db.update("books", ContentValues().apply {
|
||||
put("reading_progress", progress)
|
||||
put("last_cfi", cfi)
|
||||
put("last_chapter", chapter)
|
||||
put("current_page", page)
|
||||
put("total_pages", total)
|
||||
put("last_read", System.currentTimeMillis())
|
||||
}, "id = ?", arrayOf(bookId.toString()))
|
||||
if (changed > 0) saveProgress(ReadingProgress(bookId = bookId, cfi = cfi,
|
||||
progress = progress, currentPage = page, chapterTitle = chapter))
|
||||
db.setTransactionSuccessful()
|
||||
} finally { db.endTransaction() }
|
||||
}
|
||||
|
||||
fun getAllAudioBooks(): List<AudioBook> = readableDatabase.query(
|
||||
"audiobooks", null, null, null, null, null, "updated_at DESC"
|
||||
).use { cursor ->
|
||||
@@ -181,6 +231,7 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
||||
return if (audioBook.id == 0L) {
|
||||
writableDatabase.insertWithOnConflict("audiobooks", null, values, SQLiteDatabase.CONFLICT_REPLACE)
|
||||
} else {
|
||||
values.remove("position_ms") // Playback owns this field independently of generation/metadata.
|
||||
writableDatabase.update("audiobooks", values, "id = ?", arrayOf(audioBook.id.toString()))
|
||||
audioBook.id
|
||||
}
|
||||
@@ -403,6 +454,7 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
||||
put("total_pages", book.totalPages)
|
||||
put("current_page", book.currentPage)
|
||||
put("remote_id", book.remoteId)
|
||||
put("catalog_source", book.catalogSource)
|
||||
}
|
||||
|
||||
private fun audioBookToValues(audioBook: AudioBook) = ContentValues().apply {
|
||||
@@ -439,7 +491,8 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
||||
lastRead = cursor.getLong(cursor.getColumnIndexOrThrow("last_read")),
|
||||
totalPages = cursor.getInt(cursor.getColumnIndexOrThrow("total_pages")),
|
||||
currentPage = cursor.getInt(cursor.getColumnIndexOrThrow("current_page")),
|
||||
remoteId = cursor.getString(cursor.getColumnIndexOrThrow("remote_id"))
|
||||
remoteId = cursor.getString(cursor.getColumnIndexOrThrow("remote_id")),
|
||||
catalogSource = cursor.getString(cursor.getColumnIndexOrThrow("catalog_source"))
|
||||
)
|
||||
|
||||
private fun cursorToAudioBook(cursor: Cursor) = AudioBook(
|
||||
@@ -590,9 +643,13 @@ class AppDatabaseHelper(context: Context) : SQLiteOpenHelper(context, DATABASE_N
|
||||
return false
|
||||
}
|
||||
|
||||
private fun createCatalogIndex(db: SQLiteDatabase) {
|
||||
db.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS idx_books_catalog ON books(catalog_source, remote_id) WHERE catalog_source IS NOT NULL AND remote_id IS NOT NULL")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val DATABASE_NAME = "aletheia.db3"
|
||||
private const val DATABASE_VERSION = 8
|
||||
private const val DATABASE_VERSION = 9
|
||||
private const val MAX_READING_HISTORY_ENTRIES = 200
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,19 +7,23 @@ import java.io.File
|
||||
|
||||
class AudioBookRepository(
|
||||
context: Context,
|
||||
private val databaseHelper: AppDatabaseHelper
|
||||
private val databaseHelper: AppDatabaseHelper,
|
||||
private val cleanup: com.aletheia.app.audiobook.AudioJobCleanup
|
||||
) {
|
||||
private val outputDirectory = File(context.cacheDir, "audiobooks").apply { mkdirs() }
|
||||
private val outputDirectory = File(context.filesDir, "Audiobooks").apply { mkdirs() }
|
||||
private val oldDirectory = File(context.cacheDir, "audiobooks")
|
||||
|
||||
fun getAll(): List<AudioBook> = databaseHelper.getAllAudioBooks().map(::reconcileFile)
|
||||
@Synchronized fun getAll(): List<AudioBook> = databaseHelper.getAllAudioBooks().map(::reconcileFile)
|
||||
|
||||
fun getById(id: Long): AudioBook? = databaseHelper.getAudioBookById(id)?.let(::reconcileFile)
|
||||
@Synchronized fun getById(id: Long): AudioBook? = databaseHelper.getAudioBookById(id)?.let(::reconcileFile)
|
||||
|
||||
fun getBySourceBookId(bookId: Long): AudioBook? =
|
||||
@Synchronized fun getBySourceBookId(bookId: Long): AudioBook? =
|
||||
databaseHelper.getAudioBookBySourceBookId(bookId)?.let(::reconcileFile)
|
||||
|
||||
fun createOrReset(book: Book): AudioBook {
|
||||
@Synchronized fun createOrReset(book: Book): AudioBook {
|
||||
val existing = databaseHelper.getAudioBookBySourceBookId(book.id)
|
||||
existing?.remoteJobId?.let(cleanup::enqueue)
|
||||
cleanup.flush()
|
||||
existing?.filePath?.takeIf(String::isNotBlank)?.let { File(it).delete() }
|
||||
val now = System.currentTimeMillis()
|
||||
val audioBook = AudioBook(
|
||||
@@ -33,32 +37,43 @@ class AudioBookRepository(
|
||||
updatedAt = now
|
||||
)
|
||||
val id = databaseHelper.saveAudioBook(audioBook)
|
||||
databaseHelper.updateAudioPosition(id, 0)
|
||||
return requireNotNull(databaseHelper.getAudioBookById(id))
|
||||
}
|
||||
|
||||
fun save(audioBook: AudioBook): AudioBook {
|
||||
@Synchronized fun save(audioBook: AudioBook): AudioBook {
|
||||
if (audioBook.id != 0L && databaseHelper.getAudioBookById(audioBook.id) == null) {
|
||||
audioBook.remoteJobId?.let(cleanup::enqueue)
|
||||
cleanup.flush()
|
||||
throw kotlinx.coroutines.CancellationException("Задание удалено")
|
||||
}
|
||||
val id = databaseHelper.saveAudioBook(audioBook.copy(updatedAt = System.currentTimeMillis()))
|
||||
return requireNotNull(databaseHelper.getAudioBookById(id))
|
||||
}
|
||||
|
||||
fun savePosition(id: Long, positionMs: Long) {
|
||||
val current = databaseHelper.getAudioBookById(id) ?: return
|
||||
databaseHelper.saveAudioBook(
|
||||
current.copy(positionMs = positionMs.coerceAtLeast(0), updatedAt = System.currentTimeMillis())
|
||||
)
|
||||
databaseHelper.updateAudioPosition(id, positionMs)
|
||||
}
|
||||
|
||||
fun outputFile(audioBookId: Long): File = File(outputDirectory, "audiobook-$audioBookId.m4a")
|
||||
|
||||
fun partialOutputFile(audioBookId: Long): File = File(outputDirectory, "audiobook-$audioBookId.partial.m4a")
|
||||
|
||||
fun delete(audioBook: AudioBook) {
|
||||
@Synchronized fun delete(audioBook: AudioBook) {
|
||||
databaseHelper.getAudioBookById(audioBook.id)?.remoteJobId?.let(cleanup::enqueue)
|
||||
audioBook.filePath.takeIf(String::isNotBlank)?.let { File(it).delete() }
|
||||
partialOutputFile(audioBook.id).delete()
|
||||
databaseHelper.deleteAudioBook(audioBook.id)
|
||||
cleanup.flush()
|
||||
}
|
||||
|
||||
private fun reconcileFile(audioBook: AudioBook): AudioBook {
|
||||
val oldFile = File(audioBook.filePath)
|
||||
if (audioBook.status == AudioBook.Status.READY && oldFile.isFile && oldFile.parentFile == oldDirectory) {
|
||||
val destination = outputFile(audioBook.id)
|
||||
// Atomic rename within the same private volume; never delete the old copy on failure.
|
||||
if (oldFile.renameTo(destination)) return save(audioBook.copy(filePath = destination.absolutePath))
|
||||
}
|
||||
if (audioBook.status != AudioBook.Status.READY || File(audioBook.filePath).isFile) {
|
||||
return audioBook
|
||||
}
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.aletheia.app.data
|
||||
|
||||
import com.aletheia.app.model.Book
|
||||
import com.aletheia.app.model.QBooksBook
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
||||
data class DownloadState(val progress: Int = 0, val running: Boolean = false,
|
||||
val bookId: Long? = null, val error: String? = null)
|
||||
|
||||
/** Application-owned work: navigating away never cancels a download. */
|
||||
class BookDownloads(private val scope: CoroutineScope, private val service: QBooksService,
|
||||
private val repository: BookRepository, private val cache: File) {
|
||||
private val mutableStates = MutableStateFlow<Map<String, DownloadState>>(emptyMap())
|
||||
val states = mutableStates.asStateFlow()
|
||||
private val jobs = mutableMapOf<String, Deferred<Book>>()
|
||||
|
||||
fun key(book: QBooksBook): String = "${source(book)}\n${book.id}"
|
||||
private fun source(book: QBooksBook) = book.catalogSource.ifBlank { repository.catalogSource() }
|
||||
private fun publish(key: String, value: DownloadState) { mutableStates.update { it + (key to value) } }
|
||||
|
||||
@Synchronized
|
||||
fun start(book: QBooksBook): Deferred<Book> {
|
||||
val key = key(book)
|
||||
jobs[key]?.takeIf { !it.isCompleted }?.let { return it }
|
||||
val source = source(book)
|
||||
val job = scope.async(start = CoroutineStart.LAZY) {
|
||||
publish(key, DownloadState(running = true))
|
||||
try {
|
||||
val local = repository.getBookByRemoteId(book.id, source) ?: run {
|
||||
check(source == repository.catalogSource()) { "Каталог изменился. Откройте книгу в исходном каталоге." }
|
||||
val temp = when (val result = service.downloadBook(book, cache) {
|
||||
publish(key, DownloadState(progress = it, running = true))
|
||||
}) {
|
||||
is Result.Success -> result.value
|
||||
is Result.Failure -> throw java.io.IOException(result.message)
|
||||
}
|
||||
try {
|
||||
currentCoroutineContext().ensureActive()
|
||||
val name = book.title.replace(Regex("[\\\\/:*?\"<>|]"), "_") + "." + book.format.lowercase()
|
||||
repository.importDownloadedBook(temp, name, book.id, book.coverImage, source)
|
||||
} finally { temp.delete() }
|
||||
}
|
||||
publish(key, DownloadState(progress = 100, bookId = local.id))
|
||||
local
|
||||
} catch (cancelled: CancellationException) {
|
||||
publish(key, DownloadState(error = "Загрузка отменена"))
|
||||
throw cancelled
|
||||
} catch (error: Exception) {
|
||||
publish(key, DownloadState(error = error.message ?: "Не удалось скачать книгу"))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
jobs[key] = job
|
||||
job.invokeOnCompletion { synchronized(this) { if (jobs[key] === job) jobs.remove(key) } }
|
||||
job.start()
|
||||
return job
|
||||
}
|
||||
|
||||
fun refresh(books: List<QBooksBook>) = scope.launch {
|
||||
books.forEach { book ->
|
||||
val key = key(book)
|
||||
val local = repository.getBookByRemoteId(book.id, source(book))
|
||||
mutableStates.update { states ->
|
||||
if (states[key]?.running == true) states else states + (key to
|
||||
(local?.let { DownloadState(progress = 100, bookId = it.id) }
|
||||
?: states[key]?.takeIf { it.bookId == null } ?: DownloadState()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,8 +112,7 @@ class BookParserService private constructor(
|
||||
|
||||
private fun parseFb2(book: Book): Book {
|
||||
return try {
|
||||
val xml = File(book.filePath).readText()
|
||||
val document = parseXml(ByteArrayInputStream(xml.toByteArray()))
|
||||
val document = File(book.filePath).inputStream().use(::parseXml)
|
||||
val title = document.getElementsByTagNameNS("*", "book-title").item(0)?.textContent?.trim().orEmpty()
|
||||
val authorNode = document.getElementsByTagNameNS("*", "author").item(0) as? Element
|
||||
val firstName = authorNode?.getElementsByTagNameNS("*", "first-name")?.item(0)?.textContent?.trim().orEmpty()
|
||||
|
||||
@@ -27,8 +27,10 @@ class BookRepository(
|
||||
|
||||
fun getBookById(id: Long): Book? = databaseHelper.getBookById(id)
|
||||
|
||||
fun getBookByRemoteId(remoteId: String): Book? =
|
||||
databaseHelper.getAllBooks().firstOrNull { it.remoteId == remoteId }
|
||||
fun catalogSource(): String = settingsRepository.getCatalogUrl().trimEnd('/')
|
||||
|
||||
fun getBookByRemoteId(remoteId: String, source: String = catalogSource()): Book? =
|
||||
databaseHelper.getBookByRemoteId(source, remoteId)
|
||||
|
||||
fun importBook(uri: Uri): Book {
|
||||
val book = parserService.parseAndStoreBookFromUri(uri)
|
||||
@@ -36,9 +38,11 @@ class BookRepository(
|
||||
return requireNotNull(databaseHelper.getBookById(id))
|
||||
}
|
||||
|
||||
fun importDownloadedBook(file: File, originalFileName: String, remoteId: String?, coverImage: ByteArray?): Book {
|
||||
@Synchronized
|
||||
fun importDownloadedBook(file: File, originalFileName: String, remoteId: String?, coverImage: ByteArray?, source: String = catalogSource()): Book {
|
||||
remoteId?.let { getBookByRemoteId(it, source)?.let { existing -> return existing } }
|
||||
val parsedBook = parserService.parseAndStoreBook(file, originalFileName)
|
||||
val book = parsedBook.copy(remoteId = remoteId, coverImage = coverImage ?: parsedBook.coverImage)
|
||||
val book = parsedBook.copy(remoteId = remoteId, catalogSource = source, coverImage = coverImage ?: parsedBook.coverImage)
|
||||
val id = databaseHelper.saveBook(book)
|
||||
return requireNotNull(databaseHelper.getBookById(id))
|
||||
}
|
||||
@@ -57,12 +61,11 @@ class BookRepository(
|
||||
}
|
||||
|
||||
fun updateBook(book: Book) {
|
||||
databaseHelper.saveBook(book)
|
||||
databaseHelper.updateMetadata(book)
|
||||
}
|
||||
|
||||
fun saveLocations(bookId: Long, locations: String) {
|
||||
val book = getBookById(bookId) ?: return
|
||||
updateBook(book.copy(locations = locations))
|
||||
databaseHelper.updateLocations(bookId, locations)
|
||||
}
|
||||
|
||||
fun saveProgress(
|
||||
@@ -73,25 +76,7 @@ class BookRepository(
|
||||
currentPage: Int,
|
||||
totalPages: Int
|
||||
) {
|
||||
val book = getBookById(bookId) ?: return
|
||||
val updated = book.copy(
|
||||
readingProgress = progress,
|
||||
lastCfi = cfi,
|
||||
lastChapter = chapter,
|
||||
currentPage = currentPage,
|
||||
totalPages = totalPages,
|
||||
lastRead = System.currentTimeMillis()
|
||||
)
|
||||
updateBook(updated)
|
||||
databaseHelper.saveProgress(
|
||||
ReadingProgress(
|
||||
bookId = bookId,
|
||||
cfi = cfi,
|
||||
progress = progress,
|
||||
currentPage = currentPage,
|
||||
chapterTitle = chapter
|
||||
)
|
||||
)
|
||||
databaseHelper.updateReadingState(bookId, progress, cfi, chapter, currentPage, totalPages)
|
||||
}
|
||||
|
||||
fun getBookmarks(bookId: Long): List<ReadingBookmark> =
|
||||
|
||||
@@ -17,6 +17,9 @@ class CatalogCache(context: Context) {
|
||||
private val coversDirectory = File(context.cacheDir, "catalog-v1/covers").apply { mkdirs() }
|
||||
private val lock = Any()
|
||||
|
||||
fun encode(book: QBooksBook): String = book.toJson().toString()
|
||||
fun decode(json: String): QBooksBook? = runCatching { JSONObject(json).toBook() }.getOrNull()
|
||||
|
||||
data class CachedPage(
|
||||
val page: CatalogPage,
|
||||
val savedAtMillis: Long
|
||||
@@ -120,6 +123,7 @@ class CatalogCache(context: Context) {
|
||||
|
||||
private fun QBooksBook.toJson(): JSONObject = JSONObject().apply {
|
||||
put("id", id)
|
||||
put("catalogSource", catalogSource)
|
||||
put("title", title)
|
||||
put("author", author)
|
||||
put("format", format)
|
||||
@@ -139,6 +143,7 @@ class CatalogCache(context: Context) {
|
||||
if (id.isBlank() || title.isBlank()) return null
|
||||
return QBooksBook(
|
||||
id = id,
|
||||
catalogSource = optString("catalogSource"),
|
||||
title = title,
|
||||
author = optString("author").trim().ifBlank { "Неизвестный автор" },
|
||||
format = optString("format").trim().ifBlank { "epub" },
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.aletheia.app.data
|
||||
|
||||
import android.content.Context
|
||||
import com.aletheia.app.model.QBooksBook
|
||||
import org.json.JSONObject
|
||||
|
||||
/** Metadata lives in preferences, independently of the evictable catalog cache. */
|
||||
class CatalogFavorites(context: Context, private val codec: CatalogCache) {
|
||||
private val prefs = context.getSharedPreferences("catalog_favorites", Context.MODE_PRIVATE)
|
||||
private fun key(book: QBooksBook) = book.catalogSource + "\n" + book.id
|
||||
private fun records() = runCatching { JSONObject(prefs.getString("records-v2", "{}")!!) }.getOrDefault(JSONObject())
|
||||
|
||||
@Synchronized fun contains(book: QBooksBook): Boolean = records().has(key(book)) ||
|
||||
(book.catalogSource == "legacy" && book.id in prefs.getStringSet("book_ids", emptySet()).orEmpty())
|
||||
|
||||
@Synchronized fun toggle(book: QBooksBook) {
|
||||
if (book.catalogSource == "legacy") {
|
||||
prefs.edit().putStringSet("book_ids", prefs.getStringSet("book_ids", emptySet()).orEmpty() - book.id).apply()
|
||||
return
|
||||
}
|
||||
val records = records()
|
||||
if (records.has(key(book))) records.remove(key(book)) else records.put(key(book), codec.encode(book))
|
||||
prefs.edit().putString("records-v2", records.toString()).apply()
|
||||
}
|
||||
|
||||
@Synchronized fun all(): List<QBooksBook> {
|
||||
val records = records()
|
||||
val known = records.keys().asSequence().mapNotNull { codec.decode(records.getString(it)) }.toList()
|
||||
val legacy = prefs.getStringSet("book_ids", emptySet()).orEmpty().map { id ->
|
||||
QBooksBook(id, "Сохранённая книга: $id", "Источник старой записи неизвестен", "epub", "",
|
||||
description = "Предыдущая версия сохранила только ID. Найдите книгу в исходном каталоге и добавьте в избранное заново.", catalogSource = "legacy")
|
||||
}
|
||||
return known + legacy
|
||||
}
|
||||
|
||||
// Only globally-addressed legacy IDs can be matched safely. Numeric IDs from an
|
||||
// unknown server remain visible as unclassified entries, never assigned by guessing.
|
||||
@Synchronized fun recoverLegacy(books: List<QBooksBook>) {
|
||||
val legacy = prefs.getStringSet("book_ids", emptySet()).orEmpty().toMutableSet()
|
||||
if (legacy.isEmpty()) return
|
||||
val records = records()
|
||||
books.filter { book -> book.id in legacy && runCatching {
|
||||
val id = java.net.URI(book.id)
|
||||
id.scheme in listOf("http", "https") && id.host != null &&
|
||||
id.host.equals(java.net.URI(book.downloadUrl).host, ignoreCase = true)
|
||||
}.getOrDefault(false) }.forEach {
|
||||
records.put(key(it), codec.encode(it))
|
||||
legacy.remove(it.id)
|
||||
}
|
||||
prefs.edit().putString("records-v2", records.toString()).putStringSet("book_ids", legacy).apply()
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import com.aletheia.app.model.Book
|
||||
import com.aletheia.app.model.QBooksBook
|
||||
import com.aletheia.app.model.CatalogPage
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
@@ -56,6 +57,12 @@ class QBooksService(
|
||||
this.password = normalizedPassword
|
||||
}
|
||||
|
||||
suspend fun navigation(url: String? = null): List<OpdsNavigationEntry> = withContext(Dispatchers.IO) {
|
||||
configureFromSettings()
|
||||
if (detectBackend() != Backend.OPDS) return@withContext emptyList()
|
||||
fetchOpdsNavigation(url ?: requireBaseUrl())
|
||||
}
|
||||
|
||||
suspend fun testConnection(url: String, username: String?, password: String?): Result<Unit> = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
configure(url, username, password)
|
||||
@@ -88,7 +95,8 @@ class QBooksService(
|
||||
): CatalogCache.CachedPage? = withContext(Dispatchers.IO) {
|
||||
configureFromSettings()
|
||||
catalogCache.readPage(catalogCacheKey(searchQuery, page, pageUrl))?.let { cached ->
|
||||
cached.copy(page = cached.page.copy(books = cached.page.books.take(pageSize.coerceAtLeast(1))))
|
||||
cached.copy(page = cached.page.copy(books = cached.page.books.take(pageSize.coerceAtLeast(1))
|
||||
.map { it.copy(catalogSource = settingsRepository.getCatalogUrl().trimEnd('/')) }))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +104,8 @@ class QBooksService(
|
||||
book: QBooksBook,
|
||||
includeDetails: Boolean = false
|
||||
): QBooksBook = withContext(Dispatchers.IO) {
|
||||
if (book.catalogSource.isNotBlank() && book.catalogSource != settingsRepository.getCatalogUrl().trimEnd('/'))
|
||||
return@withContext book
|
||||
configureFromSettings()
|
||||
val cacheKey = bookCacheKey(book.id)
|
||||
var enriched = catalogCache.readBook(cacheKey)?.let { cached -> book.mergeMissing(cached) } ?: book
|
||||
@@ -126,6 +136,7 @@ class QBooksService(
|
||||
}
|
||||
|
||||
suspend fun enrichLocalBook(book: Book): Book = withContext(Dispatchers.IO) {
|
||||
if (book.catalogSource != null && book.catalogSource != settingsRepository.getCatalogUrl().trimEnd('/')) return@withContext book
|
||||
if (!book.needsCatalogMetadata()) return@withContext book
|
||||
configureFromSettings()
|
||||
val synthetic = QBooksBook(
|
||||
@@ -157,6 +168,7 @@ class QBooksService(
|
||||
runCatching {
|
||||
configureFromSettings()
|
||||
val query = searchQuery.trim()
|
||||
val source = settingsRepository.getCatalogUrl().trimEnd('/')
|
||||
val catalogPage = when (detectBackend()) {
|
||||
Backend.QBOOKS -> {
|
||||
val books = getBooksFromQBooks(query, page, maxOf(pageSize, DEFAULT_PAGE_SIZE))
|
||||
@@ -169,7 +181,7 @@ class QBooksService(
|
||||
Backend.UNKNOWN -> error(
|
||||
"Сервер не поддерживает книжный каталог. Проверьте адрес каталога."
|
||||
)
|
||||
}
|
||||
}.let { it.copy(books = it.books.map { book -> book.copy(catalogSource = source) }) }
|
||||
runCatching {
|
||||
catalogCache.writePage(catalogCacheKey(query, page, pageUrl), catalogPage)
|
||||
catalogPage.books.forEach { book ->
|
||||
@@ -190,6 +202,9 @@ class QBooksService(
|
||||
var destination: File? = null
|
||||
runCatching {
|
||||
configureFromSettings()
|
||||
check(book.catalogSource.isBlank() || book.catalogSource == requireBaseUrl()) {
|
||||
"Каталог изменился. Откройте книгу в исходном каталоге."
|
||||
}
|
||||
if (!booksDirectory.exists()) {
|
||||
booksDirectory.mkdirs()
|
||||
}
|
||||
@@ -209,6 +224,7 @@ class QBooksService(
|
||||
FileOutputStream(target).use { output ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
while (true) {
|
||||
kotlinx.coroutines.currentCoroutineContext().ensureActive()
|
||||
val read = input.read(buffer)
|
||||
if (read == -1) break
|
||||
output.write(buffer, 0, read)
|
||||
@@ -225,6 +241,7 @@ class QBooksService(
|
||||
Result.Success(target)
|
||||
}.getOrElse { throwable ->
|
||||
destination?.takeIf(File::exists)?.delete()
|
||||
if (throwable is kotlinx.coroutines.CancellationException) throw throwable
|
||||
Result.Failure(qBooksConnectionError("Не удалось скачать книгу.", throwable), throwable)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,8 +19,8 @@ data class AudioBook(
|
||||
val updatedAt: Long = System.currentTimeMillis()
|
||||
) {
|
||||
val progressPercent: Int
|
||||
get() = if (totalCharacters <= 0) 0 else
|
||||
((processedCharacters * 100L) / totalCharacters).toInt().coerceIn(0, 100)
|
||||
get() = if (status == Status.READY) 100 else if (totalCharacters <= 0) 0 else
|
||||
((processedCharacters.toDouble() / totalCharacters) * 100).toInt().coerceIn(0, 99)
|
||||
|
||||
enum class Status(val databaseValue: String) {
|
||||
QUEUED("queued"), GENERATING("generating"), READY("ready"),
|
||||
|
||||
@@ -16,7 +16,8 @@ data class Book(
|
||||
val lastRead: Long = System.currentTimeMillis(),
|
||||
val totalPages: Int = 0,
|
||||
val currentPage: Int = 0,
|
||||
val remoteId: String? = null
|
||||
val remoteId: String? = null,
|
||||
val catalogSource: String? = null
|
||||
) {
|
||||
val progressText: String
|
||||
get() = "${(readingProgress * 100).toInt()}%"
|
||||
|
||||
@@ -12,5 +12,6 @@ data class QBooksBook(
|
||||
val description: String? = null,
|
||||
val language: String? = null,
|
||||
val publisher: String? = null,
|
||||
val published: String? = null
|
||||
val published: String? = null,
|
||||
val catalogSource: String = ""
|
||||
) : java.io.Serializable
|
||||
|
||||
@@ -3,7 +3,18 @@ package com.aletheia.app.ui.audiobook
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.AudioAttributes
|
||||
import android.media.MediaPlayer
|
||||
import android.content.ComponentName
|
||||
import android.os.SystemClock
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MediaMetadata
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.session.MediaController
|
||||
import androidx.media3.session.SessionToken
|
||||
import androidx.media3.session.SessionCommand
|
||||
import com.aletheia.app.audiobook.AudioPlaybackService
|
||||
import com.aletheia.app.audiobook.PlaybackMath
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
@@ -26,11 +37,13 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
|
||||
@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class)
|
||||
class AudioBookPlayerActivity : AppCompatActivity() {
|
||||
private lateinit var binding: ActivityAudioBookPlayerBinding
|
||||
private val app by lazy { application as AletheiaApplication }
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var mediaPlayer: MediaPlayer? = null
|
||||
private var mediaPlayer: MediaController? = null
|
||||
private var controllerFuture: ListenableFuture<MediaController>? = null
|
||||
private var audioBook: AudioBook? = null
|
||||
private var chapters: List<Chapter> = emptyList()
|
||||
private var prepared = false
|
||||
@@ -67,12 +80,17 @@ class AudioBookPlayerActivity : AppCompatActivity() {
|
||||
|
||||
override fun onDestroy() {
|
||||
handler.removeCallbacksAndMessages(null)
|
||||
mediaPlayer?.release()
|
||||
controllerFuture?.let(MediaController::releaseFuture)
|
||||
mediaPlayer = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun setupControls() {
|
||||
binding.playerMoreButton.contentDescription = "Действия с аудиокнигой"
|
||||
binding.playerRewindButton.contentDescription = "Назад на 15 секунд"
|
||||
binding.playerForwardButton.contentDescription = "Вперёд на 30 секунд"
|
||||
binding.playerPlayButton.contentDescription = "Воспроизвести"
|
||||
binding.playerSleepButton.contentDescription = "Установить таймер сна"
|
||||
binding.playerBackButton.setOnClickListener { finish() }
|
||||
binding.playerPlayButton.setOnClickListener { togglePlayback() }
|
||||
binding.playerRewindButton.setOnClickListener { seekBy(-15_000) }
|
||||
@@ -100,7 +118,7 @@ class AudioBookPlayerActivity : AppCompatActivity() {
|
||||
override fun onStartTrackingTouch(seekBar: SeekBar?) { userSeeking = true }
|
||||
override fun onStopTrackingTouch(seekBar: SeekBar?) {
|
||||
val duration = mediaPlayer?.duration?.takeIf { it > 0 } ?: 0
|
||||
mediaPlayer?.seekTo(duration * (seekBar?.progress ?: 0) / 1000)
|
||||
mediaPlayer?.seekTo(PlaybackMath.position(duration.toLong(), seekBar?.progress ?: 0))
|
||||
userSeeking = false
|
||||
updateProgress()
|
||||
}
|
||||
@@ -131,37 +149,45 @@ class AudioBookPlayerActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun preparePlayer(audio: AudioBook) {
|
||||
mediaPlayer = MediaPlayer().apply {
|
||||
setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
)
|
||||
setDataSource(audio.filePath)
|
||||
setOnPreparedListener { player ->
|
||||
prepared = true
|
||||
player.seekTo(audio.positionMs.coerceAtMost(player.duration.toLong()).toInt())
|
||||
applySpeed()
|
||||
val future = MediaController.Builder(this, SessionToken(this,
|
||||
ComponentName(this, AudioPlaybackService::class.java))).buildAsync()
|
||||
controllerFuture = future
|
||||
future.addListener({
|
||||
if (isFinishing || isDestroyed) return@addListener
|
||||
try {
|
||||
val controller = future.get()
|
||||
mediaPlayer = controller
|
||||
controller.addListener(object : Player.Listener {
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
prepared = player.duration > 0
|
||||
updateProgress()
|
||||
}
|
||||
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
|
||||
Toast.makeText(this@AudioBookPlayerActivity, "Не удалось воспроизвести аудиокнигу", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
})
|
||||
if (controller.currentMediaItem?.mediaId != audio.id.toString()) {
|
||||
controller.setMediaItem(MediaItem.Builder().setMediaId(audio.id.toString())
|
||||
.setUri(android.net.Uri.fromFile(File(audio.filePath)))
|
||||
.setMediaMetadata(MediaMetadata.Builder().setTitle(audio.title).setArtist(audio.author).build()).build(),
|
||||
audio.positionMs)
|
||||
controller.prepare()
|
||||
}
|
||||
prepared = controller.duration > 0
|
||||
updateProgress()
|
||||
} catch (error: Exception) {
|
||||
Toast.makeText(this, "Не удалось подключить плеер", Toast.LENGTH_LONG).show()
|
||||
}
|
||||
setOnCompletionListener {
|
||||
binding.playerPlayButton.text = "▶"
|
||||
updateProgress()
|
||||
savePosition()
|
||||
}
|
||||
setOnErrorListener { _, _, _ ->
|
||||
Toast.makeText(this@AudioBookPlayerActivity, "Не удалось воспроизвести M4A-файл", Toast.LENGTH_LONG).show()
|
||||
true
|
||||
}
|
||||
prepareAsync()
|
||||
}
|
||||
}, ContextCompat.getMainExecutor(this))
|
||||
}
|
||||
|
||||
private fun togglePlayback() {
|
||||
val player = mediaPlayer ?: return
|
||||
if (!prepared) return
|
||||
if (player.isPlaying) player.pause() else player.start()
|
||||
if (player.isPlaying) player.pause() else {
|
||||
if (player.playbackState == Player.STATE_ENDED) player.seekTo(0)
|
||||
player.play()
|
||||
}
|
||||
binding.playerPlayButton.text = if (player.isPlaying) "Ⅱ" else "▶"
|
||||
}
|
||||
|
||||
@@ -181,7 +207,7 @@ class AudioBookPlayerActivity : AppCompatActivity() {
|
||||
val speed = SPEEDS[speedIndex]
|
||||
binding.playerSpeedButton.text = "${speed.toDisplay()}x"
|
||||
if (prepared) runCatching {
|
||||
mediaPlayer?.playbackParams = mediaPlayer!!.playbackParams.setSpeed(speed)
|
||||
mediaPlayer?.setPlaybackSpeed(speed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,8 +217,8 @@ class AudioBookPlayerActivity : AppCompatActivity() {
|
||||
MaterialAlertDialogBuilder(this)
|
||||
.setTitle("Таймер сна")
|
||||
.setItems(labels) { _, index ->
|
||||
sleepAtMs = minutes[index].takeIf { it > 0 }?.let { System.currentTimeMillis() + it * 60_000L }
|
||||
binding.playerSleepButton.alpha = if (sleepAtMs == null) 1f else 0.45f
|
||||
mediaPlayer?.sendCustomCommand(SessionCommand(AudioPlaybackService.SLEEP_COMMAND, Bundle.EMPTY),
|
||||
Bundle().apply { putInt("minutes", minutes[index]) })
|
||||
}
|
||||
.show()
|
||||
}
|
||||
@@ -200,17 +226,17 @@ class AudioBookPlayerActivity : AppCompatActivity() {
|
||||
private fun updateProgress() {
|
||||
val player = mediaPlayer ?: return
|
||||
if (!prepared) return
|
||||
sleepAtMs?.let { deadline ->
|
||||
if (System.currentTimeMillis() >= deadline) {
|
||||
if (player.isPlaying) player.pause()
|
||||
binding.playerPlayButton.text = "▶"
|
||||
sleepAtMs = null
|
||||
binding.playerSleepButton.alpha = 1f
|
||||
}
|
||||
}
|
||||
val deadline = player.sessionExtras.getLong(AudioPlaybackService.SLEEP_DEADLINE)
|
||||
val remaining = if (deadline == 0L) 0L else (deadline - SystemClock.elapsedRealtime()).coerceAtLeast(0)
|
||||
binding.playerSleepButton.text = if (remaining == 0L) "◴" else "${(remaining + 59999) / 60000}м"
|
||||
binding.playerSleepButton.contentDescription = if (remaining == 0L) "Установить таймер сна" else "Таймер сна: ${formatTime(remaining)}"
|
||||
binding.playerPlayButton.text = if (player.isPlaying) "Ⅱ" else "▶"
|
||||
binding.playerPlayButton.contentDescription = if (player.isPlaying) "Пауза" else "Воспроизвести"
|
||||
binding.playerSpeedButton.text = "${player.playbackParameters.speed.toDisplay()}x"
|
||||
binding.playerSpeedButton.contentDescription = "Скорость воспроизведения ${player.playbackParameters.speed.toDisplay()}"
|
||||
val position = player.currentPosition.coerceAtLeast(0)
|
||||
val duration = player.duration.coerceAtLeast(1)
|
||||
if (!userSeeking) binding.playerSeekBar.progress = position * 1000 / duration
|
||||
if (!userSeeking) binding.playerSeekBar.progress = PlaybackMath.slider(position, duration)
|
||||
binding.playerElapsed.text = formatTime(position.toLong())
|
||||
binding.playerRemaining.text = "-${formatTime((duration - position).toLong())}"
|
||||
binding.playerRemainingSummary.text = remainingSummary((duration - position).toLong())
|
||||
@@ -218,9 +244,7 @@ class AudioBookPlayerActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun savePosition() {
|
||||
val id = audioBook?.id ?: return
|
||||
val position = mediaPlayer?.currentPosition?.toLong() ?: return
|
||||
app.applicationScope.launch { app.audioBookRepository.savePosition(id, position) }
|
||||
// The playback service owns persistence, including while this screen is stopped.
|
||||
}
|
||||
|
||||
private fun parseChapters(json: String): List<Chapter> = runCatching {
|
||||
|
||||
@@ -49,6 +49,7 @@ class BookshelfFragment : Fragment() {
|
||||
private val binding get() = _binding!!
|
||||
|
||||
private val app by lazy { requireActivity().application as AletheiaApplication }
|
||||
private lateinit var audioBooksAdapter: BooksAdapter
|
||||
private lateinit var booksAdapter: BooksAdapter
|
||||
private lateinit var qBooksShelfAdapter: QBooksShelfAdapter
|
||||
private var currentBooks: List<Book> = emptyList()
|
||||
@@ -59,6 +60,9 @@ class BookshelfFragment : Fragment() {
|
||||
private val metadataJobs = mutableListOf<Job>()
|
||||
private var loadBooksJob: Job? = null
|
||||
private var libraryFilter = LibraryFilter.ALL
|
||||
private var generationDialog: androidx.appcompat.app.AlertDialog? = null
|
||||
private var generationDialogId: Long? = null
|
||||
private val startingAudioBooks = mutableSetOf<Long>()
|
||||
|
||||
private val notificationPermissionLauncher = registerForActivityResult(
|
||||
ActivityResultContracts.RequestPermission()
|
||||
@@ -98,6 +102,7 @@ class BookshelfFragment : Fragment() {
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
pendingAudioExportId = savedInstanceState?.getLong("audio.export.id") ?: 0L
|
||||
|
||||
booksAdapter = BooksAdapter(
|
||||
onBookClicked = ::openBook,
|
||||
@@ -110,6 +115,16 @@ class BookshelfFragment : Fragment() {
|
||||
binding.booksRecycler.layoutManager = LinearLayoutManager(requireContext(), RecyclerView.HORIZONTAL, false)
|
||||
binding.booksRecycler.addItemDecoration(BookshelfRailDecoration(requireContext(), dp(12), dp(8), dp(8)))
|
||||
binding.booksRecycler.adapter = booksAdapter
|
||||
audioBooksAdapter = BooksAdapter(
|
||||
onBookClicked = ::openBook,
|
||||
onAudioBookClicked = ::openAudioBook,
|
||||
onAddBookClicked = ::launchImport,
|
||||
onAudioBookActionsClicked = ::showAudioBookActions,
|
||||
includeAddItem = false
|
||||
)
|
||||
binding.audioBooksRecycler.layoutManager = LinearLayoutManager(requireContext(), RecyclerView.HORIZONTAL, false)
|
||||
binding.audioBooksRecycler.addItemDecoration(BookshelfRailDecoration(requireContext(), dp(12), dp(8), dp(8)))
|
||||
binding.audioBooksRecycler.adapter = audioBooksAdapter
|
||||
|
||||
qBooksShelfAdapter = QBooksShelfAdapter(
|
||||
onBookClicked = ::openQBooksBookPreview,
|
||||
@@ -145,7 +160,10 @@ class BookshelfFragment : Fragment() {
|
||||
continueReadingBook?.let(::openBook)
|
||||
}
|
||||
binding.clearLibrarySearchButton.setOnClickListener {
|
||||
libraryFilter = LibraryFilter.ALL
|
||||
binding.libraryFilterGroup.check(R.id.library_filter_all)
|
||||
binding.booksSearchInput.setText("")
|
||||
renderFilteredBooks()
|
||||
}
|
||||
binding.libraryFilterGroup.setOnCheckedStateChangeListener { _, checkedIds ->
|
||||
libraryFilter = when (checkedIds.firstOrNull()) {
|
||||
@@ -167,6 +185,7 @@ class BookshelfFragment : Fragment() {
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
resumeAudioBooks()
|
||||
loadBooks()
|
||||
}
|
||||
|
||||
@@ -187,11 +206,15 @@ class BookshelfFragment : Fragment() {
|
||||
override fun onHiddenChanged(hidden: Boolean) {
|
||||
super.onHiddenChanged(hidden)
|
||||
if (!hidden && _binding != null) {
|
||||
resumeAudioBooks()
|
||||
loadBooks()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
generationDialog?.dismiss()
|
||||
generationDialog = null
|
||||
generationDialogId = null
|
||||
loadBooksJob?.cancel()
|
||||
loadBooksJob = null
|
||||
metadataJobs.forEach { it.cancel() }
|
||||
@@ -200,6 +223,16 @@ class BookshelfFragment : Fragment() {
|
||||
_binding = null
|
||||
}
|
||||
|
||||
private fun resumeAudioBooks() {
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val records = withContext(Dispatchers.IO) { app.audioBookRepository.getAll() }
|
||||
records.filter {
|
||||
it.status == AudioBook.Status.QUEUED || it.status == AudioBook.Status.GENERATING ||
|
||||
(it.status == AudioBook.Status.FAILED && !it.remoteJobId.isNullOrBlank())
|
||||
}.forEach { AudioBookGenerationService.start(requireContext(), it.id) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadBooks() {
|
||||
loadBooksJob?.cancel()
|
||||
loadBooksJob = viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||
@@ -214,6 +247,7 @@ class BookshelfFragment : Fragment() {
|
||||
result.onSuccess { (books, audioBooks) ->
|
||||
currentBooks = books
|
||||
currentAudioBooks = audioBooks
|
||||
audioBooks.firstOrNull { it.id == generationDialogId }?.let(::updateGenerationDialog)
|
||||
renderFilteredBooks()
|
||||
enrichMissingMetadata(books.take(METADATA_PREFETCH_LIMIT))
|
||||
}.onFailure { exception ->
|
||||
@@ -244,8 +278,9 @@ class BookshelfFragment : Fragment() {
|
||||
visibleBooks = filterBooks(currentBooks, query)
|
||||
visibleAudioBooks = filterAudioBooks(currentAudioBooks, query)
|
||||
val covers = currentBooks.associate { it.id to it.coverImage }
|
||||
booksAdapter.submitContent(
|
||||
visibleBooks,
|
||||
booksAdapter.submitList(visibleBooks)
|
||||
audioBooksAdapter.submitContent(
|
||||
emptyList(),
|
||||
visibleAudioBooks.map { it to covers[it.sourceBookId] }
|
||||
)
|
||||
renderLibraryState(
|
||||
@@ -274,11 +309,12 @@ class BookshelfFragment : Fragment() {
|
||||
|
||||
val isLibraryEmpty = allBooks.isEmpty()
|
||||
val isSearchEmpty = !isLibraryEmpty && displayedBooks.isEmpty() && displayedAudioBooks.isEmpty()
|
||||
val hasVisibleBooks = displayedBooks.isNotEmpty() || displayedAudioBooks.isNotEmpty()
|
||||
val hasVisibleBooks = displayedBooks.isNotEmpty()
|
||||
binding.emptyLibraryCard.visibility = if (isLibraryEmpty) View.VISIBLE else View.GONE
|
||||
binding.emptySearchCard.visibility = if (isSearchEmpty) View.VISIBLE else View.GONE
|
||||
binding.bookshelfPanel.visibility = if (hasVisibleBooks) View.VISIBLE else View.GONE
|
||||
binding.bookshelfPanel.visibility = if (!isLibraryEmpty) View.VISIBLE else View.GONE
|
||||
binding.booksRecycler.visibility = if (hasVisibleBooks) View.VISIBLE else View.GONE
|
||||
binding.audioBookshelfPanel.visibility = if (displayedAudioBooks.isNotEmpty()) View.VISIBLE else View.GONE
|
||||
binding.continueReadingCard.visibility =
|
||||
if (continueReadingBook != null) View.VISIBLE else View.GONE
|
||||
|
||||
@@ -448,6 +484,36 @@ class BookshelfFragment : Fragment() {
|
||||
}
|
||||
}
|
||||
|
||||
private var pendingAudioExportId = 0L
|
||||
private val exportAudioLauncher = registerForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.CreateDocument("audio/mp4")
|
||||
) { uri ->
|
||||
if (uri != null) {
|
||||
val id = pendingAudioExportId
|
||||
val context = requireContext().applicationContext
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
val audio = requireNotNull(app.audioBookRepository.getById(id))
|
||||
context.contentResolver.openOutputStream(uri).use { output ->
|
||||
requireNotNull(output)
|
||||
java.io.File(audio.filePath).inputStream().use { it.copyTo(output) }
|
||||
}
|
||||
}
|
||||
android.widget.Toast.makeText(context, "Аудиокнига сохранена", android.widget.Toast.LENGTH_SHORT).show()
|
||||
} catch (error: Exception) {
|
||||
if (error is kotlinx.coroutines.CancellationException) throw error
|
||||
showMessage("Не удалось сохранить аудиокнигу: ${error.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
outState.putLong("audio.export.id", pendingAudioExportId)
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
private fun showAudioBookActions(anchor: View, audioBook: AudioBook) {
|
||||
PopupMenu(requireContext(), anchor).apply {
|
||||
when (audioBook.status) {
|
||||
@@ -460,6 +526,13 @@ class BookshelfFragment : Fragment() {
|
||||
true
|
||||
}
|
||||
}
|
||||
if (audioBook.status == AudioBook.Status.READY) {
|
||||
menu.add("Сохранить M4A…").setOnMenuItemClickListener {
|
||||
pendingAudioExportId = audioBook.id
|
||||
exportAudioLauncher.launch(audioBook.title.replace(Regex("[\\\\/:*?\"<>|]"), "_") + ".m4a")
|
||||
true
|
||||
}
|
||||
}
|
||||
menu.add("Удалить аудиокнигу").setOnMenuItemClickListener {
|
||||
confirmDeleteAudioBook(audioBook)
|
||||
true
|
||||
@@ -476,8 +549,8 @@ class BookshelfFragment : Fragment() {
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("Создать аудиокнигу?")
|
||||
.setMessage(
|
||||
warning + "Книга будет отправлена на ваш CUDA-сервер и озвучена мужским голосом Qwen. " +
|
||||
"Приложение покажет прогресс и скачает готовый M4A в свой кэш. Создание может занять продолжительное время."
|
||||
warning + "Книга будет отправлена на CMP и озвучена мужским голосом VoiceStudio. " +
|
||||
"Приложение покажет прогресс и сохранит готовый M4A в библиотеке. Создание может занять продолжительное время."
|
||||
)
|
||||
.setPositiveButton("Начать") { _, _ -> startAudioBookGeneration(book) }
|
||||
.setNegativeButton(R.string.action_cancel, null)
|
||||
@@ -485,32 +558,67 @@ class BookshelfFragment : Fragment() {
|
||||
}
|
||||
|
||||
private fun startAudioBookGeneration(book: Book) {
|
||||
if (!startingAudioBooks.add(book.id)) return
|
||||
val context = requireContext().applicationContext
|
||||
if (Build.VERSION.SDK_INT >= 33 &&
|
||||
ContextCompat.checkSelfPermission(requireContext(), Manifest.permission.POST_NOTIFICATIONS) !=
|
||||
android.content.pm.PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
viewLifecycleOwner.lifecycleScope.launch(Dispatchers.IO) {
|
||||
val record = app.audioBookRepository.createOrReset(book)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (_binding == null) return@withContext
|
||||
AudioBookGenerationService.start(requireContext(), record.id)
|
||||
loadBooks()
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
val record = withContext(Dispatchers.IO) {
|
||||
com.aletheia.app.audiobook.RemoteAudioBookClient(context).checkAvailable()
|
||||
app.audioBookRepository.createOrReset(book).also {
|
||||
AudioBookGenerationService.start(context, it.id)
|
||||
}
|
||||
}
|
||||
if (_binding != null) {
|
||||
showGenerationProgress(record)
|
||||
loadBooks()
|
||||
}
|
||||
} catch (cancelled: kotlinx.coroutines.CancellationException) {
|
||||
throw cancelled
|
||||
} catch (error: Exception) {
|
||||
if (_binding != null) showMessage("Не удалось начать изготовление: ${error.readableMessage()}")
|
||||
} finally {
|
||||
startingAudioBooks.remove(book.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showGenerationProgress(audioBook: AudioBook) {
|
||||
val chapter = audioBook.currentChapter?.let { "\nСейчас: $it" }.orEmpty()
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
generationDialog?.dismiss()
|
||||
generationDialogId = audioBook.id
|
||||
generationDialog = MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle("Создание аудиокниги · ${audioBook.progressPercent}%")
|
||||
.setMessage("Обработано ${audioBook.processedCharacters} из ${audioBook.totalCharacters} символов.$chapter")
|
||||
.setMessage("Подключение к CMP…")
|
||||
.setPositiveButton("Продолжить в фоне", null)
|
||||
.setNegativeButton("Отменить") { _, _ ->
|
||||
AudioBookGenerationService.cancel(requireContext(), audioBook.id)
|
||||
}
|
||||
.show()
|
||||
generationDialog?.setOnDismissListener {
|
||||
generationDialog = null
|
||||
generationDialogId = null
|
||||
}
|
||||
updateGenerationDialog(audioBook)
|
||||
}
|
||||
|
||||
private fun updateGenerationDialog(audioBook: AudioBook) {
|
||||
val finished = audioBook.status in setOf(AudioBook.Status.READY, AudioBook.Status.FAILED, AudioBook.Status.CANCELLED)
|
||||
generationDialog?.apply {
|
||||
setTitle(if (audioBook.status == AudioBook.Status.READY) "Аудиокнига готова" else "Создание аудиокниги · ${audioBook.progressPercent}%")
|
||||
setMessage(when (audioBook.status) {
|
||||
AudioBook.Status.READY -> "Готовая аудиокнига сохранена в библиотеке."
|
||||
AudioBook.Status.FAILED -> audioBook.errorMessage ?: "Ошибка изготовления"
|
||||
AudioBook.Status.CANCELLED -> "Изготовление отменено"
|
||||
else -> "Обработано ${audioBook.processedCharacters} из ${audioBook.totalCharacters} символов.\n${audioBook.currentChapter ?: "Подключение к CMP…"}"
|
||||
})
|
||||
getButton(android.content.DialogInterface.BUTTON_NEGATIVE)?.visibility = if (finished) View.GONE else View.VISIBLE
|
||||
getButton(android.content.DialogInterface.BUTTON_POSITIVE)?.text = if (finished) "Закрыть" else "Продолжить в фоне"
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirmDeleteAudioBook(audioBook: AudioBook) {
|
||||
|
||||
@@ -64,9 +64,10 @@ class HomeFragment : Fragment() {
|
||||
binding.homeSearch.setOnClickListener { openSearch() }
|
||||
binding.catalogAll.setOnClickListener { openSearch() }
|
||||
binding.downloadedAll.setOnClickListener { openMyBooks() }
|
||||
listOf(binding.chipGenres, binding.chipRecommendations, binding.chipBooks, binding.chipAuthors).forEach { chip ->
|
||||
chip.setOnClickListener { openSearch() }
|
||||
}
|
||||
binding.chipGenres.setOnClickListener { openCatalogMode("genres") }
|
||||
binding.chipRecommendations.setOnClickListener { openCatalogMode("selection") }
|
||||
binding.chipBooks.setOnClickListener { openCatalogMode("new") }
|
||||
binding.chipAuthors.setOnClickListener { openCatalogMode("authors") }
|
||||
binding.homeBannerFirst.setOnClickListener { openBanner(0) }
|
||||
binding.homeBannerSecond.setOnClickListener { openBanner(1) }
|
||||
binding.homeBannerThird.setOnClickListener { openBanner(2) }
|
||||
@@ -215,6 +216,11 @@ class HomeFragment : Fragment() {
|
||||
startActivity(BookDetailActivity.createIntent(requireContext(), book))
|
||||
}
|
||||
|
||||
private fun openCatalogMode(mode: String) {
|
||||
parentFragmentManager.setFragmentResult("catalog-mode", Bundle().apply { putString("mode", mode) })
|
||||
openSearch()
|
||||
}
|
||||
|
||||
private fun openSearch() {
|
||||
(activity as? MainActivity)?.selectTab(R.id.nav_search)
|
||||
}
|
||||
|
||||
@@ -49,6 +49,20 @@ class BookDetailActivity : AppCompatActivity() {
|
||||
binding.backButton.setOnClickListener { finish() }
|
||||
binding.shareButton.setOnClickListener { shareBook() }
|
||||
binding.favoriteButton.setOnClickListener { toggleFavorite() }
|
||||
book = book.copy(catalogSource = book.catalogSource.ifBlank { app.bookRepository.catalogSource() })
|
||||
app.catalogFavorites.recoverLegacy(listOf(book))
|
||||
lifecycleScope.launch {
|
||||
app.bookDownloads.states.collect { states ->
|
||||
val state = states[app.bookDownloads.key(book)]
|
||||
isDownloading = state?.running == true
|
||||
if (isDownloading) {
|
||||
binding.primaryAction.isEnabled = false
|
||||
binding.primaryAction.text = getString(R.string.book_detail_downloading, state?.progress ?: 0)
|
||||
binding.downloadProgress.visibility = View.VISIBLE
|
||||
binding.downloadProgress.progress = state?.progress ?: 0
|
||||
} else loadState()
|
||||
}
|
||||
}
|
||||
renderBook()
|
||||
loadState()
|
||||
loadMetadataAndCover()
|
||||
@@ -118,7 +132,7 @@ class BookDetailActivity : AppCompatActivity() {
|
||||
|
||||
private fun loadState() {
|
||||
lifecycleScope.launch {
|
||||
localBook = withContext(Dispatchers.IO) { app.bookRepository.getBookByRemoteId(book.id) }
|
||||
localBook = withContext(Dispatchers.IO) { app.bookRepository.getBookByRemoteId(book.id, book.catalogSource) }
|
||||
updatePrimaryAction()
|
||||
}
|
||||
}
|
||||
@@ -127,7 +141,7 @@ class BookDetailActivity : AppCompatActivity() {
|
||||
if (isDownloading) return
|
||||
val downloaded = localBook
|
||||
binding.downloadProgress.visibility = View.GONE
|
||||
binding.primaryAction.isEnabled = true
|
||||
binding.primaryAction.isEnabled = book.catalogSource != "legacy"
|
||||
binding.primaryAction.text = getString(
|
||||
if (downloaded == null) R.string.book_detail_download else R.string.book_detail_read
|
||||
)
|
||||
@@ -139,54 +153,17 @@ class BookDetailActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun downloadBook() {
|
||||
if (isDownloading) return
|
||||
isDownloading = true
|
||||
binding.primaryAction.isEnabled = false
|
||||
binding.downloadProgress.visibility = View.VISIBLE
|
||||
binding.downloadProgress.progress = 0
|
||||
binding.primaryAction.text = getString(R.string.book_detail_downloading, 0)
|
||||
|
||||
lifecycleScope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
app.bookRepository.getBookByRemoteId(book.id)?.let { return@runCatching it }
|
||||
val progress: (Int) -> Unit = { value ->
|
||||
runOnUiThread {
|
||||
binding.downloadProgress.progress = value
|
||||
binding.primaryAction.text = getString(R.string.book_detail_downloading, value)
|
||||
}
|
||||
}
|
||||
val temp = when (val download = app.qBooksService.downloadBook(
|
||||
book,
|
||||
app.cacheDir,
|
||||
progress
|
||||
)) {
|
||||
is Result.Success -> download.value
|
||||
is Result.Failure -> error(download.message)
|
||||
}
|
||||
try {
|
||||
app.bookRepository.importDownloadedBook(
|
||||
file = temp,
|
||||
originalFileName = safeFileName(book),
|
||||
remoteId = book.id,
|
||||
coverImage = book.coverImage
|
||||
)
|
||||
} finally {
|
||||
temp.takeIf(File::exists)?.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
isDownloading = false
|
||||
result.onSuccess {
|
||||
localBook = it
|
||||
try {
|
||||
localBook = app.bookDownloads.start(book).await()
|
||||
isDownloading = false
|
||||
updatePrimaryAction()
|
||||
}.onFailure {
|
||||
} catch (error: Exception) {
|
||||
if (error is kotlinx.coroutines.CancellationException) throw error
|
||||
isDownloading = false
|
||||
updatePrimaryAction()
|
||||
MaterialAlertDialogBuilder(this@BookDetailActivity)
|
||||
.setTitle(R.string.dialog_error_title)
|
||||
.setMessage(getString(R.string.dialog_download_book_failed, it.message ?: it.javaClass.simpleName))
|
||||
.setPositiveButton(R.string.action_ok, null)
|
||||
.show()
|
||||
MaterialAlertDialogBuilder(this@BookDetailActivity).setTitle(R.string.dialog_error_title)
|
||||
.setMessage(error.message).setPositiveButton(R.string.action_ok, null).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -198,7 +175,7 @@ class BookDetailActivity : AppCompatActivity() {
|
||||
private fun shareBook() {
|
||||
lifecycleScope.launch {
|
||||
val downloadedBook = withContext(Dispatchers.IO) {
|
||||
app.bookRepository.getBookByRemoteId(book.id)
|
||||
app.bookRepository.getBookByRemoteId(book.id, book.catalogSource)
|
||||
}
|
||||
runCatching {
|
||||
if (downloadedBook == null) {
|
||||
@@ -217,22 +194,15 @@ class BookDetailActivity : AppCompatActivity() {
|
||||
}
|
||||
|
||||
private fun toggleFavorite() {
|
||||
val prefs = getSharedPreferences(FAVORITES_PREFS, MODE_PRIVATE)
|
||||
val favorites = prefs.getStringSet(FAVORITES_KEY, emptySet()).orEmpty().toMutableSet()
|
||||
if (!favorites.add(book.id)) favorites.remove(book.id)
|
||||
prefs.edit().putStringSet(FAVORITES_KEY, favorites).apply()
|
||||
app.catalogFavorites.toggle(book)
|
||||
renderFavorite()
|
||||
}
|
||||
|
||||
private fun renderFavorite() {
|
||||
val selected = getSharedPreferences(FAVORITES_PREFS, MODE_PRIVATE)
|
||||
.getStringSet(FAVORITES_KEY, emptySet())
|
||||
.orEmpty()
|
||||
.contains(book.id)
|
||||
val selected = app.catalogFavorites.contains(book)
|
||||
binding.favoriteButton.text = if (selected) "♥" else "♡"
|
||||
binding.favoriteButton.contentDescription = getString(
|
||||
if (selected) R.string.book_detail_favorite_remove else R.string.book_detail_favorite_add
|
||||
)
|
||||
if (selected) R.string.book_detail_favorite_remove else R.string.book_detail_favorite_add)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
|
||||
@@ -15,6 +15,15 @@ class QBooksCatalogAdapter(
|
||||
) : RecyclerView.Adapter<QBooksCatalogAdapter.QBooksBookViewHolder>() {
|
||||
|
||||
private val items = mutableListOf<QBooksBook>()
|
||||
private var downloads = emptyMap<String, com.aletheia.app.data.DownloadState>()
|
||||
fun setDownloads(states: Map<String, com.aletheia.app.data.DownloadState>) {
|
||||
val old = downloads
|
||||
downloads = states
|
||||
items.forEachIndexed { index, book ->
|
||||
val key = book.catalogSource + "\n" + book.id
|
||||
if (old[key] != states[key]) notifyItemChanged(index)
|
||||
}
|
||||
}
|
||||
|
||||
fun submitList(books: List<QBooksBook>) {
|
||||
val oldItems = items.toList()
|
||||
@@ -22,7 +31,7 @@ class QBooksCatalogAdapter(
|
||||
override fun getOldListSize(): Int = oldItems.size
|
||||
override fun getNewListSize(): Int = books.size
|
||||
override fun areItemsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
|
||||
oldItems[oldItemPosition].id == books[newItemPosition].id
|
||||
oldItems[oldItemPosition].identity() == books[newItemPosition].identity()
|
||||
|
||||
override fun areContentsTheSame(oldItemPosition: Int, newItemPosition: Int): Boolean =
|
||||
oldItems[oldItemPosition].hasSameVisibleContent(books[newItemPosition])
|
||||
@@ -34,9 +43,9 @@ class QBooksCatalogAdapter(
|
||||
}
|
||||
|
||||
fun appendDistinct(books: List<QBooksBook>) {
|
||||
val existingIds = items.map { it.id }.toHashSet()
|
||||
val existingIds = items.map { it.identity() }.toHashSet()
|
||||
val startIndex = items.size
|
||||
val newItems = books.filter { existingIds.add(it.id) }
|
||||
val newItems = books.filter { existingIds.add(it.identity()) }
|
||||
if (newItems.isEmpty()) {
|
||||
return
|
||||
}
|
||||
@@ -48,7 +57,7 @@ class QBooksCatalogAdapter(
|
||||
fun appendOrUpdate(books: List<QBooksBook>) {
|
||||
val merged = items.toMutableList()
|
||||
books.forEach { book ->
|
||||
val index = merged.indexOfFirst { it.id == book.id }
|
||||
val index = merged.indexOfFirst { it.identity() == book.identity() }
|
||||
if (index >= 0) merged[index] = book else merged += book
|
||||
}
|
||||
submitList(merged)
|
||||
@@ -64,7 +73,7 @@ class QBooksCatalogAdapter(
|
||||
}
|
||||
|
||||
fun updateBook(book: QBooksBook) {
|
||||
val index = items.indexOfFirst { it.id == book.id }
|
||||
val index = items.indexOfFirst { it.identity() == book.identity() }
|
||||
if (index < 0) return
|
||||
items[index] = book
|
||||
notifyItemChanged(index)
|
||||
@@ -101,11 +110,24 @@ class QBooksCatalogAdapter(
|
||||
R.string.a11y_download_qbooks_book,
|
||||
book.title
|
||||
)
|
||||
val state = downloads[book.catalogSource + "\n" + book.id]
|
||||
binding.downloadButton.isEnabled = state?.running != true && book.catalogSource != "legacy"
|
||||
binding.downloadProgress.visibility = if (state?.running == true) android.view.View.VISIBLE else android.view.View.GONE
|
||||
binding.downloadProgress.progress = state?.progress ?: 0
|
||||
binding.downloadButton.setIconResource(if (state?.bookId != null) R.drawable.ic_nav_reader else R.drawable.ic_download)
|
||||
binding.downloadButton.contentDescription = when {
|
||||
state?.running == true -> "Загрузка ${book.title}: ${state.progress}%"
|
||||
state?.bookId != null -> "Читать ${book.title}"
|
||||
state?.error != null -> "Повторить загрузку ${book.title}: ${state.error}"
|
||||
else -> binding.downloadButton.contentDescription
|
||||
}
|
||||
binding.root.setOnClickListener { onBookClicked(book) }
|
||||
binding.downloadButton.setOnClickListener { onDownloadClicked(book) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun QBooksBook.identity() = catalogSource + "\n" + id
|
||||
|
||||
private fun QBooksBook.hasSameVisibleContent(other: QBooksBook): Boolean =
|
||||
title == other.title &&
|
||||
author == other.author &&
|
||||
|
||||
@@ -42,8 +42,11 @@ class QBooksLibraryFragment : Fragment() {
|
||||
private var searchJob: Job? = null
|
||||
private var loadJob: Job? = null
|
||||
private val coverJobs = mutableListOf<Job>()
|
||||
private val activeDownloads = mutableSetOf<String>()
|
||||
private var catalogMode = "all"
|
||||
private var browseUrl: String? = null
|
||||
private var requestGeneration = 0
|
||||
private var navigationJob: Job? = null
|
||||
private var configuredSource: String? = null
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, state: Bundle?): View {
|
||||
_binding = FragmentQbooksLibraryBinding.inflate(inflater, container, false)
|
||||
@@ -82,6 +85,24 @@ class QBooksLibraryFragment : Fragment() {
|
||||
binding.refreshCatalogButton.setOnClickListener { loadBooks(reset = true, forceRefresh = true) }
|
||||
binding.swipeRefresh.setOnRefreshListener { loadBooks(reset = true, forceRefresh = true) }
|
||||
|
||||
catalogMode = state?.getString("catalog.mode") ?: "all"
|
||||
browseUrl = state?.getString("catalog.url")
|
||||
binding.catalogModeButton.text = state?.getString("catalog.label") ?: "Все книги"
|
||||
binding.catalogModeButton.setOnClickListener {
|
||||
val modes = arrayOf("all", "genres", "authors", "new", "selection", "favorites")
|
||||
MaterialAlertDialogBuilder(requireContext()).setTitle("Раздел каталога")
|
||||
.setItems(arrayOf("Все книги", "Жанры", "Авторы", "Новинки", "Подборка из каталога", "Избранное")) { _, index ->
|
||||
selectMode(modes[index])
|
||||
}.show()
|
||||
}
|
||||
parentFragmentManager.setFragmentResultListener("catalog-mode", viewLifecycleOwner) { _, result ->
|
||||
selectMode(result.getString("mode") ?: "all")
|
||||
}
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
app.bookDownloads.states.collect { states ->
|
||||
if (_binding != null) booksAdapter.setDownloads(states)
|
||||
}
|
||||
}
|
||||
val genreChips = mapOf(
|
||||
binding.genreFantasy to R.string.genre_fantasy,
|
||||
binding.genreDetective to R.string.genre_detective,
|
||||
@@ -91,24 +112,99 @@ class QBooksLibraryFragment : Fragment() {
|
||||
binding.genreScience to R.string.genre_science
|
||||
)
|
||||
genreChips.forEach { (chip, title) ->
|
||||
chip.setOnClickListener { binding.searchInput.setText(getString(title)) }
|
||||
chip.setOnClickListener { selectMode("genres", getString(title)) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
initializeCatalog()
|
||||
app.bookDownloads.refresh(booksAdapter.currentItems())
|
||||
if (catalogMode == "favorites") loadBooks(reset = true)
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
searchJob?.cancel()
|
||||
loadJob?.cancel()
|
||||
navigationJob?.cancel()
|
||||
coverJobs.forEach { it.cancel() }
|
||||
coverJobs.clear()
|
||||
_binding = null
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
outState.putString("catalog.mode", catalogMode)
|
||||
outState.putString("catalog.url", browseUrl)
|
||||
outState.putString("catalog.label", binding.catalogModeButton.text.toString())
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
private fun selectMode(mode: String, genre: String? = null) {
|
||||
navigationJob?.cancel()
|
||||
requestGeneration++
|
||||
loadJob?.cancel()
|
||||
coverJobs.forEach { it.cancel() }
|
||||
isLoading = false
|
||||
catalogMode = mode
|
||||
browseUrl = null
|
||||
binding.searchInput.setText("")
|
||||
searchJob?.cancel()
|
||||
binding.catalogModeButton.text = when (mode) {
|
||||
"genres" -> "Жанры"; "authors" -> "Авторы"; "new" -> "Новинки"
|
||||
"selection" -> "Подборка из каталога"; "favorites" -> "Избранное"; else -> "Все книги"
|
||||
}
|
||||
binding.searchInput.hint = if (mode == "authors") "Имя или фамилия автора" else getString(R.string.label_search_qbooks)
|
||||
if (mode in listOf("genres", "authors", "new")) {
|
||||
navigationJob = viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
val entries = app.qBooksService.navigation()
|
||||
val pattern = when (mode) { "genres" -> Regex("жанр|genre", RegexOption.IGNORE_CASE)
|
||||
"authors" -> Regex("автор|author", RegexOption.IGNORE_CASE)
|
||||
else -> Regex("нов|new|recent", RegexOption.IGNORE_CASE) }
|
||||
val entry = entries.firstOrNull { pattern.containsMatchIn(it.title) }
|
||||
if (entry != null) openSection(entry.title, entry.url, genre)
|
||||
else {
|
||||
loadBooks(reset = true)
|
||||
com.google.android.material.snackbar.Snackbar.make(binding.root,
|
||||
"Каталог не предоставляет этот раздел. Доступен поиск по книгам.", 5000).show()
|
||||
}
|
||||
} catch (error: Exception) {
|
||||
if (error is kotlinx.coroutines.CancellationException) throw error
|
||||
showMessage("Не удалось загрузить раздел: ${error.message}")
|
||||
}
|
||||
}
|
||||
} else loadBooks(reset = true)
|
||||
}
|
||||
|
||||
private suspend fun openSection(title: String, url: String, genre: String? = null) {
|
||||
val page = app.qBooksService.getCatalogPage(pageUrl = url)
|
||||
if (_binding == null) return
|
||||
if (page is Result.Success && page.value.books.isNotEmpty()) {
|
||||
browseUrl = url
|
||||
binding.catalogModeButton.text = title
|
||||
loadBooks(reset = true)
|
||||
} else {
|
||||
val entries = app.qBooksService.navigation(url)
|
||||
if (_binding == null) return
|
||||
if (entries.isEmpty()) { showMessage("В разделе пока нет книг"); return }
|
||||
val selectedGenre = genre?.let { wanted -> entries.firstOrNull {
|
||||
it.title.contains(wanted, true) || wanted.contains(it.title, true)
|
||||
} }
|
||||
if (selectedGenre != null) { openSection(selectedGenre.title, selectedGenre.url); return }
|
||||
MaterialAlertDialogBuilder(requireContext()).setTitle(title)
|
||||
.setItems(entries.map { it.title }.toTypedArray()) { _, index ->
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try { openSection(entries[index].title, entries[index].url) }
|
||||
catch (error: Exception) {
|
||||
if (error is kotlinx.coroutines.CancellationException) throw error
|
||||
showMessage("Не удалось открыть раздел: ${error.message}")
|
||||
}
|
||||
}
|
||||
}.setNegativeButton(R.string.action_cancel, null).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializeCatalog() {
|
||||
var url = app.settingsRepository.getCatalogUrl()
|
||||
var validation = QBooksUrlPolicy.validate(url)
|
||||
@@ -123,13 +219,21 @@ class QBooksLibraryFragment : Fragment() {
|
||||
return
|
||||
}
|
||||
app.qBooksService.configure(url, null, null)
|
||||
val changed = configuredSource != null && configuredSource != url
|
||||
configuredSource = url
|
||||
if (changed) {
|
||||
navigationJob?.cancel()
|
||||
browseUrl = null
|
||||
catalogMode = "all"
|
||||
binding.catalogModeButton.text = "Все книги"
|
||||
}
|
||||
isConfigured = true
|
||||
binding.notConfiguredCard.visibility = View.GONE
|
||||
binding.errorCard.visibility = View.GONE
|
||||
binding.searchCard.visibility = View.VISIBLE
|
||||
binding.qbooksActionsRow.visibility = View.VISIBLE
|
||||
binding.swipeRefresh.visibility = View.VISIBLE
|
||||
if (booksAdapter.itemCount == 0) loadBooks(reset = true)
|
||||
if (changed || booksAdapter.itemCount == 0) loadBooks(reset = true)
|
||||
}
|
||||
|
||||
private fun loadBooks(reset: Boolean, forceRefresh: Boolean = false) {
|
||||
@@ -151,8 +255,20 @@ class QBooksLibraryFragment : Fragment() {
|
||||
|
||||
val generation = requestGeneration
|
||||
val pageToLoad = currentPage
|
||||
val pageUrl = if (reset) null else nextPageUrl
|
||||
val pageUrl = if (reset) browseUrl else nextPageUrl
|
||||
val query = currentSearchQuery()
|
||||
if (catalogMode == "favorites") {
|
||||
val favorites = app.catalogFavorites.all().filter {
|
||||
query.isBlank() || it.title.contains(query, true) || it.author.contains(query, true)
|
||||
}
|
||||
booksAdapter.submitList(favorites)
|
||||
app.bookDownloads.refresh(favorites)
|
||||
hasMoreBooks = false
|
||||
isLoading = false
|
||||
binding.swipeRefresh.isRefreshing = false
|
||||
renderLoaded()
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
loadJob = viewLifecycleOwner.lifecycleScope.launch {
|
||||
val cached = app.qBooksService.getCachedCatalogPage(query, pageToLoad, PAGE_SIZE, pageUrl)
|
||||
@@ -195,7 +311,14 @@ class QBooksLibraryFragment : Fragment() {
|
||||
pageIndex: Int,
|
||||
generation: Int
|
||||
) {
|
||||
if (reset) booksAdapter.submitList(page.books) else booksAdapter.appendOrUpdate(page.books)
|
||||
val visible = if (catalogMode == "selection") page.books.sortedBy {
|
||||
(it.id + (System.currentTimeMillis() / 86400000L)).hashCode()
|
||||
} else if (catalogMode == "authors" && currentSearchQuery().isNotBlank()) page.books.filter {
|
||||
it.author.contains(currentSearchQuery(), ignoreCase = true)
|
||||
} else page.books
|
||||
if (reset) booksAdapter.submitList(visible) else booksAdapter.appendOrUpdate(visible)
|
||||
app.catalogFavorites.recoverLegacy(page.books)
|
||||
app.bookDownloads.refresh(visible)
|
||||
loadMetadataAndCovers(page.books, generation)
|
||||
nextPageUrl = page.nextPageUrl
|
||||
hasMoreBooks = !page.nextPageUrl.isNullOrBlank()
|
||||
@@ -253,48 +376,18 @@ class QBooksLibraryFragment : Fragment() {
|
||||
}
|
||||
|
||||
private fun downloadBook(book: QBooksBook) {
|
||||
if (!activeDownloads.add(book.id)) return
|
||||
setDownloadStatus(getString(R.string.qbooks_download_status, book.title), true)
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
app.bookRepository.getBookByRemoteId(book.id)?.let { return@runCatching it }
|
||||
val progress: (Int) -> Unit = { value ->
|
||||
view?.post { setDownloadStatus(getString(R.string.qbooks_download_progress, value.toDouble()), true) }
|
||||
}
|
||||
val temp = when (val download = app.qBooksService.downloadBook(
|
||||
book,
|
||||
app.cacheDir,
|
||||
progress
|
||||
)) {
|
||||
is Result.Success -> download.value
|
||||
is Result.Failure -> error(download.message)
|
||||
}
|
||||
try {
|
||||
app.bookRepository.importDownloadedBook(
|
||||
temp,
|
||||
buildDownloadFileName(book),
|
||||
book.id,
|
||||
book.coverImage
|
||||
)
|
||||
} finally {
|
||||
temp.takeIf(File::exists)?.delete()
|
||||
}
|
||||
}
|
||||
val existing = withContext(Dispatchers.IO) {
|
||||
app.bookRepository.getBookByRemoteId(book.id, book.catalogSource.ifBlank { app.bookRepository.catalogSource() })
|
||||
}
|
||||
activeDownloads.remove(book.id)
|
||||
if (_binding == null) return@launch
|
||||
result.onSuccess { downloaded ->
|
||||
setDownloadStatus(getString(R.string.qbooks_download_complete), false)
|
||||
MaterialAlertDialogBuilder(requireContext())
|
||||
.setTitle(R.string.dialog_ready_title)
|
||||
.setMessage(getString(R.string.dialog_book_added_message, book.title))
|
||||
.setPositiveButton(R.string.action_read) { _, _ -> openDownloadedBook(downloaded) }
|
||||
.setNegativeButton(R.string.action_ok, null)
|
||||
.show()
|
||||
}.onFailure {
|
||||
setDownloadStatus("", false)
|
||||
showMessage(getString(R.string.dialog_download_book_failed, it.message ?: it.javaClass.simpleName))
|
||||
if (existing != null) { openDownloadedBook(existing); return@launch }
|
||||
try {
|
||||
val downloaded = app.bookDownloads.start(book).await()
|
||||
com.google.android.material.snackbar.Snackbar.make(binding.root, R.string.qbooks_download_complete, 6000)
|
||||
.setAction(R.string.action_read) { openDownloadedBook(downloaded) }.show()
|
||||
} catch (error: Exception) {
|
||||
if (error is kotlinx.coroutines.CancellationException) throw error
|
||||
if (_binding != null) showMessage(error.message ?: "Не удалось скачать книгу")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -903,21 +903,8 @@ class ReaderActivity : AppCompatActivity() {
|
||||
binding.colorPinkButton.isSelected = pendingHighlightColor == COLOR_PINK
|
||||
}
|
||||
|
||||
private fun shareSelection() {
|
||||
val selection = currentSelection ?: return
|
||||
val intent = Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, selection.text)
|
||||
}
|
||||
startActivity(Intent.createChooser(intent, "Поделиться цитатой"))
|
||||
}
|
||||
|
||||
private fun copySelection() {
|
||||
val selection = currentSelection ?: return
|
||||
val clipboard = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||
clipboard.setPrimaryClip(ClipData.newPlainText("Цитата", selection.text))
|
||||
Toast.makeText(this, "Цитата скопирована", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
private fun shareSelection() { currentSelection?.let { selectionActions.share(it.text) } }
|
||||
private fun copySelection() { currentSelection?.let { selectionActions.copy(it.text) } }
|
||||
|
||||
private fun openNoteEditor() {
|
||||
val selection = currentSelection ?: return
|
||||
@@ -1350,42 +1337,10 @@ class ReaderActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun persistPreferences(snapshot: ReaderPreferences) {
|
||||
withContext(Dispatchers.IO) {
|
||||
app.readerPersistenceMutex.withLock {
|
||||
snapshot.persist(app.settingsRepository)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun persistPosition(snapshot: ReaderPosition, book: Book) {
|
||||
withContext(Dispatchers.IO) {
|
||||
app.readerPersistenceMutex.withLock {
|
||||
val effectiveSnapshot = app.readerStateCoordinator.latest(book.id)?.toReaderPosition()
|
||||
?: snapshot
|
||||
val signature = listOf(
|
||||
book.id,
|
||||
effectiveSnapshot.progress,
|
||||
effectiveSnapshot.locator,
|
||||
effectiveSnapshot.chapter,
|
||||
effectiveSnapshot.currentPage,
|
||||
effectiveSnapshot.totalPages
|
||||
).joinToString("|")
|
||||
if (signature == app.readerStateCoordinator.lastPersistedSignature(book.id)) {
|
||||
return@withLock
|
||||
}
|
||||
app.bookRepository.saveProgress(
|
||||
bookId = book.id,
|
||||
progress = effectiveSnapshot.progress,
|
||||
cfi = effectiveSnapshot.locator,
|
||||
chapter = effectiveSnapshot.chapter,
|
||||
currentPage = effectiveSnapshot.currentPage,
|
||||
totalPages = effectiveSnapshot.totalPages
|
||||
)
|
||||
app.readerStateCoordinator.markPersisted(book.id, signature)
|
||||
}
|
||||
}
|
||||
}
|
||||
private val persistence by lazy { ReaderPersistence(app) }
|
||||
private val selectionActions by lazy { ReaderSelectionActions(this) }
|
||||
private suspend fun persistPreferences(snapshot: ReaderPreferences) = persistence.preferences(snapshot)
|
||||
private suspend fun persistPosition(snapshot: ReaderPosition, book: Book) = persistence.position(book.id, snapshot)
|
||||
|
||||
private fun persistStateThenApplyOrientation() {
|
||||
val oldSettingsJob = settingsSaveJob
|
||||
@@ -1639,16 +1594,6 @@ class ReaderActivity : AppCompatActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private data class ReaderPosition(
|
||||
val progress: Double = 0.0,
|
||||
val locator: String? = null,
|
||||
val chapter: String? = null,
|
||||
val currentPage: Int = 1,
|
||||
val totalPages: Int = 1,
|
||||
val chapterCurrentPage: Int = 1,
|
||||
val chapterTotalPages: Int = 1
|
||||
)
|
||||
|
||||
private data class Insets(
|
||||
val left: Int = 0,
|
||||
val top: Int = 0,
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.aletheia.app.ui.reader
|
||||
|
||||
import com.aletheia.app.AletheiaApplication
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
internal data class ReaderPosition(
|
||||
val progress: Double = 0.0, val locator: String? = null, val chapter: String? = null,
|
||||
val currentPage: Int = 1, val totalPages: Int = 1,
|
||||
val chapterCurrentPage: Int = 1, val chapterTotalPages: Int = 1
|
||||
)
|
||||
|
||||
/** Persistence owns serialization and always resolves the newest session revision. */
|
||||
internal class ReaderPersistence(private val app: AletheiaApplication) {
|
||||
suspend fun preferences(snapshot: ReaderPreferences) = withContext(Dispatchers.IO) {
|
||||
app.readerPersistenceMutex.withLock { snapshot.persist(app.settingsRepository) }
|
||||
}
|
||||
suspend fun position(bookId: Long, fallback: ReaderPosition) = withContext(Dispatchers.IO) {
|
||||
app.readerPersistenceMutex.withLock {
|
||||
val latest = app.readerStateCoordinator.latest(bookId)
|
||||
val current = latest?.let { ReaderPosition(it.progress, it.locator, it.chapter,
|
||||
it.currentPage, it.totalPages, it.chapterCurrentPage, it.chapterTotalPages) } ?: fallback
|
||||
val signature = listOf(bookId, current.progress, current.locator, current.chapter,
|
||||
current.currentPage, current.totalPages).joinToString("|")
|
||||
if (signature != app.readerStateCoordinator.lastPersistedSignature(bookId)) {
|
||||
app.bookRepository.saveProgress(bookId, current.progress, current.locator, current.chapter,
|
||||
current.currentPage, current.totalPages)
|
||||
app.readerStateCoordinator.markPersisted(bookId, signature)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.aletheia.app.ui.reader
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ClipData
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.widget.Toast
|
||||
|
||||
internal class ReaderSelectionActions(private val activity: Activity) {
|
||||
fun share(text: String) = activity.startActivity(Intent.createChooser(Intent(Intent.ACTION_SEND).apply {
|
||||
type = "text/plain"
|
||||
putExtra(Intent.EXTRA_TEXT, text)
|
||||
}, "Поделиться цитатой"))
|
||||
fun copy(text: String) {
|
||||
(activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager)
|
||||
.setPrimaryClip(ClipData.newPlainText("Цитата", text))
|
||||
Toast.makeText(activity, "Цитата скопирована", Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,7 @@ class SettingsFragment : Fragment() {
|
||||
binding.brightnessValueText.text = getString(R.string.reader_brightness_value, value.toDouble())
|
||||
}
|
||||
|
||||
binding.audioPairButton.setOnClickListener { pairAudioService() }
|
||||
binding.testConnectionButton.setOnClickListener { testConnection() }
|
||||
binding.saveSettingsButton.setOnClickListener { saveSettings(showNotification = true) }
|
||||
binding.checkUpdatesButton.setOnClickListener { checkForUpdates() }
|
||||
@@ -110,6 +111,34 @@ class SettingsFragment : Fragment() {
|
||||
super.onDestroyView()
|
||||
}
|
||||
|
||||
private fun pairAudioService() {
|
||||
val input = com.google.android.material.textfield.TextInputEditText(requireContext()).apply {
|
||||
hint = "Одноразовый код подключения"
|
||||
inputType = android.text.InputType.TYPE_CLASS_TEXT or android.text.InputType.TYPE_TEXT_VARIATION_PASSWORD
|
||||
setPadding(32, 24, 32, 24)
|
||||
}
|
||||
MaterialAlertDialogBuilder(requireContext()).setTitle(R.string.audio_pair)
|
||||
.setMessage("Введите одноразовый код от владельца аудиосервиса. Это устройство получит отдельный доступ.")
|
||||
.setView(input).setNegativeButton(R.string.action_cancel, null)
|
||||
.setPositiveButton("Подключить") { _, _ ->
|
||||
val code = input.text?.toString().orEmpty()
|
||||
val context = requireContext().applicationContext
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
try {
|
||||
withContext(Dispatchers.IO) {
|
||||
com.aletheia.app.audiobook.RemoteAudioBookClient(context).pair(code, android.os.Build.MODEL)
|
||||
app.audioJobCleanup.flush()
|
||||
}
|
||||
binding.audioPairButton.text = "Аудиосервис подключён · подключить заново"
|
||||
} catch (error: Exception) {
|
||||
if (error is kotlinx.coroutines.CancellationException) throw error
|
||||
MaterialAlertDialogBuilder(requireContext()).setTitle("Не удалось подключиться")
|
||||
.setMessage(error.message).setPositiveButton(R.string.action_ok, null).show()
|
||||
}
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
private fun loadSettings() {
|
||||
val settings = app.settingsRepository
|
||||
val storedTheme = settings.getString(SettingsRepository.KEY_THEME, "sepia")
|
||||
|
||||
@@ -181,7 +181,7 @@
|
||||
<ImageButton
|
||||
android:id="@+id/reader_back_button"
|
||||
android:layout_width="56dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/reader_v2_back"
|
||||
android:padding="17dp"
|
||||
@@ -377,7 +377,7 @@
|
||||
<ImageButton
|
||||
android:id="@+id/settings_back_button"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/reader_v2_back"
|
||||
android:padding="13dp"
|
||||
@@ -397,7 +397,7 @@
|
||||
<ImageButton
|
||||
android:id="@+id/settings_close_button"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_height="48dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/reader_v2_close"
|
||||
android:padding="13dp"
|
||||
@@ -509,7 +509,7 @@
|
||||
android:layout_marginEnd="2dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_reader_v2_theme_light"
|
||||
android:minWidth="0dp"
|
||||
android:minWidth="48dp"
|
||||
android:fontFamily="sans"
|
||||
android:maxLines="1"
|
||||
android:singleLine="true"
|
||||
@@ -535,7 +535,7 @@
|
||||
android:layout_marginEnd="2dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_reader_v2_theme_sepia"
|
||||
android:minWidth="0dp"
|
||||
android:minWidth="48dp"
|
||||
android:fontFamily="sans"
|
||||
android:maxLines="1"
|
||||
android:singleLine="true"
|
||||
@@ -560,7 +560,7 @@
|
||||
android:layout_marginStart="2dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_reader_v2_theme_dark"
|
||||
android:minWidth="0dp"
|
||||
android:minWidth="48dp"
|
||||
android:fontFamily="sans"
|
||||
android:maxLines="1"
|
||||
android:singleLine="true"
|
||||
@@ -1335,7 +1335,7 @@
|
||||
android:background="@drawable/bg_reader_v2_theme_light"
|
||||
android:fontFamily="sans"
|
||||
android:maxLines="1"
|
||||
android:minWidth="0dp"
|
||||
android:minWidth="48dp"
|
||||
android:singleLine="true"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingEnd="8dp"
|
||||
@@ -1361,7 +1361,7 @@
|
||||
android:background="@drawable/bg_reader_v2_theme_sepia"
|
||||
android:fontFamily="sans"
|
||||
android:maxLines="1"
|
||||
android:minWidth="0dp"
|
||||
android:minWidth="48dp"
|
||||
android:singleLine="true"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingEnd="8dp"
|
||||
@@ -1386,7 +1386,7 @@
|
||||
android:background="@drawable/bg_reader_v2_theme_dark"
|
||||
android:fontFamily="sans"
|
||||
android:maxLines="1"
|
||||
android:minWidth="0dp"
|
||||
android:minWidth="48dp"
|
||||
android:singleLine="true"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingEnd="8dp"
|
||||
@@ -1418,7 +1418,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="52dp"
|
||||
android:background="@drawable/bg_reader_v2_mode_selectable"
|
||||
android:minWidth="0dp"
|
||||
android:minWidth="48dp"
|
||||
android:text="@string/reader_v2_page_turn_tap_swipe"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/reader_v2_text"
|
||||
@@ -1431,7 +1431,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="52dp"
|
||||
android:background="@drawable/bg_reader_v2_mode_selectable"
|
||||
android:minWidth="0dp"
|
||||
android:minWidth="48dp"
|
||||
android:text="@string/reader_v2_page_turn_swipe"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/reader_v2_text"
|
||||
@@ -1444,7 +1444,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="52dp"
|
||||
android:background="@drawable/bg_reader_v2_mode_selectable"
|
||||
android:minWidth="0dp"
|
||||
android:minWidth="48dp"
|
||||
android:text="@string/reader_v2_page_turn_tap"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/reader_v2_text"
|
||||
@@ -1517,7 +1517,7 @@
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_reader_v2_tab"
|
||||
android:minWidth="0dp"
|
||||
android:minWidth="48dp"
|
||||
android:text="@string/reader_v2_tab_toc"
|
||||
android:textColor="@color/reader_v2_text"
|
||||
android:textSize="12sp"
|
||||
@@ -1530,7 +1530,7 @@
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_reader_v2_tab"
|
||||
android:minWidth="0dp"
|
||||
android:minWidth="48dp"
|
||||
android:text="@string/reader_v2_tab_bookmarks"
|
||||
android:textColor="@color/reader_v2_secondary_on_light"
|
||||
android:textSize="12sp"
|
||||
@@ -1543,7 +1543,7 @@
|
||||
android:layout_height="match_parent"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_reader_v2_tab"
|
||||
android:minWidth="0dp"
|
||||
android:minWidth="48dp"
|
||||
android:text="@string/reader_v2_tab_quotes"
|
||||
android:textColor="@color/reader_v2_secondary_on_light"
|
||||
android:textSize="12sp"
|
||||
@@ -1553,7 +1553,7 @@
|
||||
<ImageButton
|
||||
android:id="@+id/contents_close_button"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_height="58dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/reader_v2_close"
|
||||
android:padding="13dp"
|
||||
@@ -1703,7 +1703,7 @@
|
||||
<ImageButton
|
||||
android:id="@+id/selection_close_button"
|
||||
android:layout_width="52dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_height="52dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/reader_v2_close"
|
||||
android:padding="14dp"
|
||||
@@ -1715,7 +1715,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:gravity="start|center_vertical"
|
||||
android:minWidth="0dp"
|
||||
android:minWidth="48dp"
|
||||
android:paddingStart="20dp"
|
||||
android:text="@string/reader_v2_share"
|
||||
android:textAllCaps="false"
|
||||
@@ -1738,7 +1738,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:gravity="start|center_vertical"
|
||||
android:minWidth="0dp"
|
||||
android:minWidth="48dp"
|
||||
android:paddingStart="20dp"
|
||||
android:text="@string/reader_v2_copy"
|
||||
android:textAllCaps="false"
|
||||
@@ -1761,7 +1761,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:gravity="start|center_vertical"
|
||||
android:minWidth="0dp"
|
||||
android:minWidth="48dp"
|
||||
android:paddingStart="20dp"
|
||||
android:text="@string/reader_v2_add_note"
|
||||
android:textAllCaps="false"
|
||||
@@ -1858,7 +1858,7 @@
|
||||
android:id="@+id/save_quote_button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:minWidth="0dp"
|
||||
android:minWidth="48dp"
|
||||
android:text="@string/reader_v2_save_quote"
|
||||
android:textColor="@color/reader_v2_text"
|
||||
android:textSize="16sp"
|
||||
@@ -1967,7 +1967,7 @@
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginEnd="6dp"
|
||||
android:layout_weight="1"
|
||||
android:minWidth="0dp"
|
||||
android:minWidth="48dp"
|
||||
android:text="@string/reader_v2_cancel"
|
||||
android:textColor="@color/reader_v2_primary"
|
||||
app:backgroundTint="@color/reader_v2_separator"
|
||||
@@ -1979,7 +1979,7 @@
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginStart="6dp"
|
||||
android:layout_weight="1"
|
||||
android:minWidth="0dp"
|
||||
android:minWidth="48dp"
|
||||
android:text="@string/reader_v2_save"
|
||||
android:textColor="@color/reader_v2_canvas"
|
||||
app:backgroundTint="@color/reader_v2_accent"
|
||||
|
||||
@@ -153,6 +153,40 @@
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/audio_bookshelf_panel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:visibility="gone"
|
||||
app:cardBackgroundColor="@color/white"
|
||||
app:cardCornerRadius="20dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeColor="@color/border_color"
|
||||
app:strokeWidth="1dp">
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="14dp">
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="@font/open_sans_semibold"
|
||||
android:text="@string/library_audiobooks_section"
|
||||
android:textColor="@color/ink_color"
|
||||
android:textSize="20sp" />
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/audio_books_recycler"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:clipToPadding="false"
|
||||
android:contentDescription="@string/library_audiobooks_section"
|
||||
android:scrollbars="none" />
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/continue_reading_card"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -77,6 +77,15 @@
|
||||
android:textColor="@color/ink_color"
|
||||
android:textSize="18sp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/catalog_mode_button"
|
||||
style="@style/Widget.Aletheia.Button.Secondary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginHorizontal="16dp"
|
||||
android:minHeight="48dp"
|
||||
android:text="@string/catalog_mode" />
|
||||
|
||||
<HorizontalScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
||||
@@ -31,6 +31,15 @@
|
||||
android:lineSpacingExtra="2dp"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/audio_pair_button"
|
||||
style="@style/Widget.Aletheia.Button.Secondary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="48dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/audio_pair" />
|
||||
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/settings_overview_card"
|
||||
android:layout_width="match_parent"
|
||||
|
||||
@@ -67,16 +67,24 @@
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/download_progress"
|
||||
style="?android:attr/progressBarStyleHorizontal"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="8dp"
|
||||
android:visibility="gone"
|
||||
android:max="100" />
|
||||
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/download_button"
|
||||
style="@style/Widget.Aletheia.Button.Primary"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:insetTop="0dp"
|
||||
android:insetBottom="0dp"
|
||||
android:minWidth="40dp"
|
||||
android:minHeight="40dp"
|
||||
android:minWidth="48dp"
|
||||
android:minHeight="48dp"
|
||||
android:padding="8dp"
|
||||
app:icon="@drawable/ic_download"
|
||||
app:iconGravity="textStart"
|
||||
|
||||
@@ -339,4 +339,7 @@
|
||||
<string name="book_detail_favorite_remove">Убрать из избранного</string>
|
||||
<string name="share_book_chooser_title">Поделиться книгой</string>
|
||||
<string name="share_book_link_missing">Для этой книги нет доступной ссылки.</string>
|
||||
<string name="catalog_mode">Все книги · выбрать раздел</string>
|
||||
<string name="audio_pair">Подключить аудиосервис</string>
|
||||
<string name="library_audiobooks_section">Аудиокниги</string>
|
||||
</resources>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<exclude
|
||||
domain="file"
|
||||
path="Books" />
|
||||
<exclude domain="file" path="Audiobooks" />
|
||||
<exclude
|
||||
domain="sharedpref"
|
||||
path="." />
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
<exclude
|
||||
domain="file"
|
||||
path="Books" />
|
||||
<exclude domain="file" path="Audiobooks" />
|
||||
<exclude
|
||||
domain="sharedpref"
|
||||
path="." />
|
||||
@@ -19,6 +20,7 @@
|
||||
<exclude
|
||||
domain="file"
|
||||
path="Books" />
|
||||
<exclude domain="file" path="Audiobooks" />
|
||||
<exclude
|
||||
domain="sharedpref"
|
||||
path="." />
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.aletheia.app.data
|
||||
|
||||
import com.aletheia.app.audiobook.PlaybackMath
|
||||
import com.aletheia.app.audiobook.AudioHttpException
|
||||
import com.aletheia.app.audiobook.AudioRetryPolicy
|
||||
import java.io.File
|
||||
import java.nio.charset.Charset
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
|
||||
class AuditRegressionTest {
|
||||
@Test fun audiobookProgressCompletesOnlyAfterDownload() {
|
||||
val audio = com.aletheia.app.model.AudioBook(sourceBookId = 1, title = "Test", author = "",
|
||||
status = com.aletheia.app.model.AudioBook.Status.GENERATING, totalCharacters = 100, processedCharacters = 100)
|
||||
assertEquals(99, audio.progressPercent)
|
||||
assertEquals(100, audio.copy(status = com.aletheia.app.model.AudioBook.Status.READY).progressPercent)
|
||||
assertEquals(50, audio.copy(processedCharacters = 50).progressPercent)
|
||||
}
|
||||
@Test fun longAudiobookSeekRemainsMonotonicAndReachesEnd() {
|
||||
for (hours in listOf(1, 2, 10, 20, 100)) {
|
||||
val duration = hours * 3600000L + 731
|
||||
var previous = -1L
|
||||
for (slider in 0..1000) {
|
||||
val position = PlaybackMath.position(duration, slider)
|
||||
assertTrue(position in 0..duration)
|
||||
assertTrue(position >= previous)
|
||||
assertTrue(kotlin.math.abs(PlaybackMath.slider(position, duration) - slider) <= 1)
|
||||
previous = position
|
||||
}
|
||||
assertEquals(duration, previous)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun permanentHttpErrorsStopAndTemporaryFailuresRemainRetryable() {
|
||||
for (status in listOf(400, 401, 403, 404, 413))
|
||||
assertFalse(AudioRetryPolicy.shouldRetry(AudioHttpException(status), 1))
|
||||
for (status in listOf(408, 429, 500, 502, 503)) {
|
||||
assertTrue(AudioRetryPolicy.shouldRetry(AudioHttpException(status), 1))
|
||||
assertTrue(AudioRetryPolicy.shouldRetry(AudioHttpException(status), 1000))
|
||||
}
|
||||
assertTrue(AudioRetryPolicy.shouldRetry(java.io.IOException("Network lost"), 1000))
|
||||
}
|
||||
|
||||
@Test fun fb2XmlDeclarationControlsCyrillicDecoding() {
|
||||
for (encoding in listOf("windows-1251", "UTF-16", "UTF-8")) {
|
||||
val root = java.nio.file.Files.createTempDirectory("aletheia-encoding").toFile()
|
||||
try {
|
||||
val file = File(root, "input.fb2")
|
||||
val xml = """<?xml version="1.0" encoding="$encoding"?>
|
||||
<FictionBook xmlns="http://www.gribuser.ru/xml/fictionbook/2.0">
|
||||
<description><title-info><book-title>Мир и чтение</book-title>
|
||||
<author><first-name>Лев</first-name><last-name>Толстой</last-name></author>
|
||||
</title-info></description><body><section><p>Текст</p></section></body></FictionBook>""".trimIndent()
|
||||
file.writeBytes(xml.toByteArray(Charset.forName(encoding)))
|
||||
val parser = BookParserService(File(root, "Books"), File(root, "Cache")) {
|
||||
java.util.Base64.getMimeDecoder().decode(it)
|
||||
}
|
||||
val book = parser.parseAndStoreBook(file, "input.fb2")
|
||||
assertEquals(encoding, "Мир и чтение", book.title)
|
||||
assertEquals("Лев Толстой", book.author)
|
||||
} finally { root.deleteRecursively() }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user