diff --git a/.gitignore b/.gitignore
index b0c8c2d..a3f49aa 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,9 @@
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# Android / Gradle
+.kotlin/
+signing/*.jks
+signing/*-info.txt
.gradle/
build/
**/build/
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 5492c98..2682b54 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -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")
diff --git a/app/src/androidTest/java/com/aletheia/app/data/AudioRegressionTest.kt b/app/src/androidTest/java/com/aletheia/app/data/AudioRegressionTest.kt
new file mode 100644
index 0000000..86262d9
--- /dev/null
+++ b/app/src/androidTest/java/com/aletheia/app/data/AudioRegressionTest.kt
@@ -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")
+ }
+}
diff --git a/app/src/androidTest/java/com/aletheia/app/data/DatabaseRegressionTest.kt b/app/src/androidTest/java/com/aletheia/app/data/DatabaseRegressionTest.kt
new file mode 100644
index 0000000..3c4a976
--- /dev/null
+++ b/app/src/androidTest/java/com/aletheia/app/data/DatabaseRegressionTest.kt
@@ -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) }
+ }
+}
diff --git a/app/src/androidTest/java/com/aletheia/app/data/DownloadRegressionTest.kt b/app/src/androidTest/java/com/aletheia/app/data/DownloadRegressionTest.kt
new file mode 100644
index 0000000..522917a
--- /dev/null
+++ b/app/src/androidTest/java/com/aletheia/app/data/DownloadRegressionTest.kt
@@ -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 = """Тест загрузкиИван""".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()
+ }
+ }
+}
diff --git a/app/src/androidTest/java/com/aletheia/app/data/LibraryUiRegressionTest.kt b/app/src/androidTest/java/com/aletheia/app/data/LibraryUiRegressionTest.kt
new file mode 100644
index 0000000..5caddba
--- /dev/null
+++ b/app/src/androidTest/java/com/aletheia/app/data/LibraryUiRegressionTest.kt
@@ -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(R.id.library_filter_finished)?.isShown == true }
+ scenario.onActivity { it.findViewById(R.id.library_filter_finished).performClick() }
+ awaitUi(scenario) { it.findViewById(R.id.empty_search_card)?.isShown == true }
+ scenario.onActivity {
+ assertTrue(it.findViewById(R.id.library_filter_all).isShown)
+ it.findViewById(R.id.clear_library_search_button).performClick()
+ }
+ awaitUi(scenario) { it.findViewById(R.id.books_recycler)?.isShown == true }
+ scenario.onActivity { assertFalse(it.findViewById(R.id.empty_search_card).isShown) }
+ }
+ } finally { app.databaseHelper.getBookById(id)?.let(app.databaseHelper::deleteBook) }
+ }
+
+ private fun awaitUi(scenario: ActivityScenario, 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")
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index bc2edd9..2f504cb 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -5,6 +5,8 @@
+
+
+
+
+
+
+
+
()
private val userCancellations = ConcurrentHashMap.newKeySet()
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)
diff --git a/app/src/main/java/com/aletheia/app/audiobook/AudioDeviceCredentials.kt b/app/src/main/java/com/aletheia/app/audiobook/AudioDeviceCredentials.kt
new file mode 100644
index 0000000..1087291
--- /dev/null
+++ b/app/src/main/java/com/aletheia/app/audiobook/AudioDeviceCredentials.kt
@@ -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()
+ }
+}
diff --git a/app/src/main/java/com/aletheia/app/audiobook/AudioHttpException.kt b/app/src/main/java/com/aletheia/app/audiobook/AudioHttpException.kt
new file mode 100644
index 0000000..69add5d
--- /dev/null
+++ b/app/src/main/java/com/aletheia/app/audiobook/AudioHttpException.kt
@@ -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)
+}
diff --git a/app/src/main/java/com/aletheia/app/audiobook/AudioJobCleanup.kt b/app/src/main/java/com/aletheia/app/audiobook/AudioJobCleanup.kt
new file mode 100644
index 0000000..2eac914
--- /dev/null
+++ b/app/src/main/java/com/aletheia/app/audiobook/AudioJobCleanup.kt
@@ -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))
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/com/aletheia/app/audiobook/AudioPlaybackService.kt b/app/src/main/java/com/aletheia/app/audiobook/AudioPlaybackService.kt
new file mode 100644
index 0000000..559866f
--- /dev/null
+++ b/app/src/main/java/com/aletheia/app/audiobook/AudioPlaybackService.kt
@@ -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 {
+ 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"
+ }
+}
diff --git a/app/src/main/java/com/aletheia/app/audiobook/PlaybackMath.kt b/app/src/main/java/com/aletheia/app/audiobook/PlaybackMath.kt
new file mode 100644
index 0000000..e335125
--- /dev/null
+++ b/app/src/main/java/com/aletheia/app/audiobook/PlaybackMath.kt
@@ -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)
+}
diff --git a/app/src/main/java/com/aletheia/app/audiobook/RemoteAudioBookClient.kt b/app/src/main/java/com/aletheia/app/audiobook/RemoteAudioBookClient.kt
index 2398bb7..29d09ef 100644
--- a/app/src/main/java/com/aletheia/app/audiobook/RemoteAudioBookClient.kt
+++ b/app/src/main/java/com/aletheia/app/audiobook/RemoteAudioBookClient.kt
@@ -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() }
}
diff --git a/app/src/main/java/com/aletheia/app/data/AppDatabaseHelper.kt b/app/src/main/java/com/aletheia/app/data/AppDatabaseHelper.kt
index bca2bda..ec90d83 100644
--- a/app/src/main/java/com/aletheia/app/data/AppDatabaseHelper.kt
+++ b/app/src/main/java/com/aletheia/app/data/AppDatabaseHelper.kt
@@ -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 = 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 = 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
}
}
diff --git a/app/src/main/java/com/aletheia/app/data/AudioBookRepository.kt b/app/src/main/java/com/aletheia/app/data/AudioBookRepository.kt
index 7c4b8ca..b282044 100644
--- a/app/src/main/java/com/aletheia/app/data/AudioBookRepository.kt
+++ b/app/src/main/java/com/aletheia/app/data/AudioBookRepository.kt
@@ -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 = databaseHelper.getAllAudioBooks().map(::reconcileFile)
+ @Synchronized fun getAll(): List = 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
}
diff --git a/app/src/main/java/com/aletheia/app/data/BookDownloads.kt b/app/src/main/java/com/aletheia/app/data/BookDownloads.kt
new file mode 100644
index 0000000..9b7d0f3
--- /dev/null
+++ b/app/src/main/java/com/aletheia/app/data/BookDownloads.kt
@@ -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