feat: improve library and audiobook workflow

This commit is contained in:
Курнат Андрей
2026-09-25 06:17:13 +03:00
parent a4d139009f
commit fadcf1da22
70 changed files with 2683 additions and 733 deletions
+5 -10
View File
@@ -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")
}
}
+11
View File
@@ -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")
+23 -23
View File
@@ -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"
+12 -4
View File
@@ -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"
+3
View File
@@ -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>
+1
View File
@@ -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() }
}
}
}