From 6df060ecb1df60847d3a3bcb17724ab055b432c6 Mon Sep 17 00:00:00 2001 From: regeloapp-coder Date: Mon, 26 Oct 2026 11:13:54 +0100 Subject: [PATCH] Stabilize core app flows for 1.3.1 Fix editor autosave flushing, chapter creation errors, toggle accessibility labels, profile search cancellation, progress sync retries, and bounded document import. Bump the app version and expand CI to run lint and build variants. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/android.yml | 2 +- README.md | 11 +- app/build.gradle.kts | 4 +- .../hu/regelo/app/data/LibraryRepository.kt | 44 +++++-- .../java/hu/regelo/app/data/SyncWorker.kt | 32 ++++- .../regelo/app/importer/DocumentImporter.kt | 30 ++++- .../regelo/app/ui/screens/BookDetailScreen.kt | 10 +- .../regelo/app/ui/screens/BookEditorScreen.kt | 123 +++++++++++++++--- .../regelo/app/ui/screens/CommunityScreens.kt | 20 +-- .../app/ui/screens/DebouncedBookSaver.kt | 80 ++++++++++++ .../app/ui/screens/ProfileSearchState.kt | 51 ++++++++ .../hu/regelo/app/BookDetailSemanticsTest.kt | 20 +++ .../hu/regelo/app/DebouncedBookSaverTest.kt | 86 ++++++++++++ .../test/java/hu/regelo/app/ImporterTest.kt | 42 ++++++ .../hu/regelo/app/ProfileSearchStateTest.kt | 96 ++++++++++++++ .../java/hu/regelo/app/data/SyncWorkerTest.kt | 58 +++++++++ 16 files changed, 653 insertions(+), 56 deletions(-) create mode 100644 app/src/main/java/hu/regelo/app/ui/screens/DebouncedBookSaver.kt create mode 100644 app/src/main/java/hu/regelo/app/ui/screens/ProfileSearchState.kt create mode 100644 app/src/test/java/hu/regelo/app/BookDetailSemanticsTest.kt create mode 100644 app/src/test/java/hu/regelo/app/DebouncedBookSaverTest.kt create mode 100644 app/src/test/java/hu/regelo/app/ProfileSearchStateTest.kt create mode 100644 app/src/test/java/hu/regelo/app/data/SyncWorkerTest.kt diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 6ea8d73..22ead79 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -27,4 +27,4 @@ jobs: uses: gradle/actions/setup-gradle@v4 - name: Run unit tests - run: bash ./gradlew test + run: bash ./gradlew test lintDebug assembleDebug assembleRelease diff --git a/README.md b/README.md index 17f6991..e34be49 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Regélő – Android alkalmazás (v1.3.0) +# Regélő – Android alkalmazás (v1.3.1) Magyar írói és olvasói platform (Wattpad / Inkitt jellegű), Kotlin + Jetpack Compose. @@ -23,7 +23,7 @@ Google Playre aláírt Android App Bundle-t (AAB) tölts fel, ne fejlesztői deb 4. Készítsd el az AAB-t, ellenőrizd a Play Console belső tesztcsatornáján, majd csak sikeres ellenőrzés után indítsd a szélesebb körű kiadást. 5. Minden új feltöltés előtt növeld az `app/build.gradle.kts` fájlban a `versionCode` értékét. Jelenlegi értéke - `130`; ez azonos vagy kisebb értékkel nem tölthető fel frissítésként. + `131`; ez azonos vagy kisebb értékkel nem tölthető fel frissítésként. ### Parancssoros/CI aláírás Biztonságos build/CI titokként add meg mind a négy környezeti változót: @@ -66,6 +66,13 @@ hogy egy véletlen törlés ne okozzon gondot. Hivatalos e-mail: regeloapp@gmail.com · Hivatalos profil az appban: @regelohivatalos (moderátor, hírei minden felhasználónak mennek). ## Változások +- **1.3.1 – Stabilitási és használhatósági javítások** – A könyvadatlap jelölő- és kedvelőgombjai az aktuális + állapotnak megfelelő, képernyőolvasóval is értelmezhető műveletet mondanak; a könyvszerkesztő a függőben lévő + metaadat-módosításokat navigáláskor is kiírja, a fejezet létrehozási hibákat pedig jelzi, navigálni csak siker + esetén navigál. A címzettkeresés rövid vagy megszakított keresés után sem hagyja betöltésben a képernyőt. + Az olvasási előrehaladás sikertelen feltöltése WorkManager-újrapróbálást eredményez; a fájlimport a 40 MB-os + határt olvasás közben érvényesíti, nem a teljes fájl memóriába töltése után. Regressziós tesztek fedik le e + változtatások kritikus útvonalait. - **v1.4 backend-kiegészítés** – A könyv befejezésének mentése szerveroldali, idempotens művelet, amely a budapesti naptári évet és az adatbázisban szereplő publikált/befejezett állapotot használja. A rate limit párhuzamos kéréseknél is atomikusan érvényesül. A kliens nem ment tokent titkosítatlanul, diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 356cc2a..bc32383 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -26,8 +26,8 @@ android { applicationId = "hu.regelo.app" minSdk = 26 targetSdk = 37 - versionCode = 130 - versionName = "1.3.0" + versionCode = 131 + versionName = "1.3.1" buildConfigField("String", "SUPABASE_URL", "\"https://pqmfezkvaslqedalwwnn.supabase.co\"") // Nyilvános (publishable) kulcs – a sorszintű jogosultságok (RLS) védik az adatokat. diff --git a/app/src/main/java/hu/regelo/app/data/LibraryRepository.kt b/app/src/main/java/hu/regelo/app/data/LibraryRepository.kt index 80d0f64..91126d9 100644 --- a/app/src/main/java/hu/regelo/app/data/LibraryRepository.kt +++ b/app/src/main/java/hu/regelo/app/data/LibraryRepository.kt @@ -7,6 +7,7 @@ import hu.regelo.app.util.newId import hu.regelo.app.util.nowMs import io.github.jan.supabase.postgrest.from import io.github.jan.supabase.postgrest.query.Order +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import kotlinx.serialization.json.JsonArray import kotlinx.serialization.json.JsonObject @@ -31,6 +32,12 @@ data class ReadingList( /** A „Folytatás” sor eleme; [newParts] > 0, ha a végére ért olvasó óta új rész jelent meg. */ data class ContinueItem(val card: BookCard, val progress: LocalProgress, val newParts: Int = 0) +enum class ProgressSyncResult { + SYNCED, + NO_PENDING_CHANGES, + NO_AUTHENTICATED_USER, +} + @kotlinx.serialization.Serializable private data class ChapterPublishRow( val id: String, @@ -52,26 +59,34 @@ class LibraryRepository { AppGraph.appScope.launch { val p = LocalProgress(bookId, chapterIndex, percent.coerceIn(0, 100), paragraphIndex, nowMs(), dirty = true, chapterId = chapterId) progressDao.upsert(p) - pushProgress(p) + try { + pushProgress(p) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + // The local dirty row is retried by the background sync. + } } } - private suspend fun pushProgress(p: LocalProgress) { - val uid = AppGraph.auth.userId ?: return - if (!AppGraph.network.currentlyOnline()) return - runCatching { - AppGraph.supabase.from("reader_progress").upsert( - ReaderProgressDto(uid, p.bookId, p.chapterIndex, p.percent, false, p.updatedAt, p.chapterId) - ) { onConflict = "user_id,book_id" } - val latest = progressDao.get(p.bookId) - if (latest != null && latest.updatedAt == p.updatedAt) progressDao.upsert(latest.copy(dirty = false)) - } + private suspend fun pushProgress(p: LocalProgress): Boolean { + val uid = AppGraph.auth.userId ?: return false + if (!AppGraph.network.currentlyOnline()) return false + AppGraph.supabase.from("reader_progress").upsert( + ReaderProgressDto(uid, p.bookId, p.chapterIndex, p.percent, false, p.updatedAt, p.chapterId) + ) { onConflict = "user_id,book_id" } + val latest = progressDao.get(p.bookId) + if (latest != null && latest.updatedAt == p.updatedAt) progressDao.upsert(latest.copy(dirty = false)) + return true } /** Pozíciók összefésülése: mindig a legutóbbi (bármelyik eszközön) nyer. */ - suspend fun syncProgress() { - val uid = AppGraph.auth.userId ?: return - progressDao.dirty().forEach { pushProgress(it) } + suspend fun syncProgress(): ProgressSyncResult { + val uid = AppGraph.auth.userId ?: return ProgressSyncResult.NO_AUTHENTICATED_USER + val dirty = progressDao.dirty() + dirty.forEach { + check(pushProgress(it)) { "Az olvasási pozíció feltöltése nem sikerült." } + } val remote = AppGraph.supabase.from("reader_progress").select { filter { eq("user_id", uid) } }.decodeList() @@ -86,6 +101,7 @@ class LibraryRepository { progressDao.upsert(LocalProgress(r.bookId, r.chapterIndex, r.percent, paragraph, r.updatedAt, dirty = false, chapterId = r.chapterId)) } } + return if (dirty.isEmpty()) ProgressSyncResult.NO_PENDING_CHANGES else ProgressSyncResult.SYNCED } suspend fun removeFromContinue(bookId: String) { diff --git a/app/src/main/java/hu/regelo/app/data/SyncWorker.kt b/app/src/main/java/hu/regelo/app/data/SyncWorker.kt index bbf0405..fa6d8bd 100644 --- a/app/src/main/java/hu/regelo/app/data/SyncWorker.kt +++ b/app/src/main/java/hu/regelo/app/data/SyncWorker.kt @@ -4,8 +4,32 @@ import android.content.Context import androidx.work.CoroutineWorker import androidx.work.WorkerParameters import hu.regelo.app.AppGraph +import kotlinx.coroutines.CancellationException import kotlin.time.Duration.Companion.seconds +internal enum class SyncDecision { SUCCESS, RETRY, FAILURE } + +internal suspend fun performSync( + attemptCount: Int, + writingSync: suspend () -> Boolean, + progressSync: suspend () -> Unit, +): SyncDecision { + val writingSucceeded = writingSync() + val progressSucceeded = try { + progressSync() + true + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + false + } + return when { + writingSucceeded && progressSucceeded -> SyncDecision.SUCCESS + attemptCount < 5 -> SyncDecision.RETRY + else -> SyncDecision.FAILURE + } +} + /** Háttér-szinkron: akkor is lefut, ha az app közben bezárult (netkapcsolat esetén). */ class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) { override suspend fun doWork(): Result { @@ -15,8 +39,10 @@ class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(c kotlinx.coroutines.delay(3.seconds) if (AppGraph.auth.userId == null) return Result.success() } - val ok = AppGraph.writing.syncNow() - runCatching { AppGraph.library.syncProgress() } - return if (ok) Result.success() else if (runAttemptCount < 5) Result.retry() else Result.failure() + return when (performSync(runAttemptCount, AppGraph.writing::syncNow) { AppGraph.library.syncProgress() }) { + SyncDecision.SUCCESS -> Result.success() + SyncDecision.RETRY -> Result.retry() + SyncDecision.FAILURE -> Result.failure() + } } } diff --git a/app/src/main/java/hu/regelo/app/importer/DocumentImporter.kt b/app/src/main/java/hu/regelo/app/importer/DocumentImporter.kt index bdb448b..e2fcbcb 100644 --- a/app/src/main/java/hu/regelo/app/importer/DocumentImporter.kt +++ b/app/src/main/java/hu/regelo/app/importer/DocumentImporter.kt @@ -9,6 +9,8 @@ import com.tom_roush.pdfbox.text.PDFTextStripper import org.w3c.dom.Element import org.w3c.dom.Node import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.InputStream import java.nio.charset.Charset import java.util.zip.ZipInputStream import javax.xml.parsers.DocumentBuilderFactory @@ -18,6 +20,8 @@ import javax.xml.parsers.DocumentBuilderFactory * Eredmény: fejezetek listája (cím + szöveg Regélő-formátumban). */ object DocumentImporter { + internal const val MAX_INPUT_BYTES = 40 * 1024 * 1024 + data class Result(val fileName: String, val title: String, val chapters: List, val warning: String? = null) fun fileName(context: Context, uri: Uri): String { @@ -30,16 +34,20 @@ object DocumentImporter { /** @param splitIntoChapters false esetén egyetlen fejezetként jön vissza (külön fejezet feltöltése). */ fun import(context: Context, uri: Uri, splitIntoChapters: Boolean): Result { val name = fileName(context, uri) - val bytes = context.contentResolver.openInputStream(uri)?.use { it.readBytes() } - ?: error("A fájl nem olvasható.") - if (bytes.size > 40 * 1024 * 1024) error("A fájl túl nagy (legfeljebb 40 MB).") + val input = context.contentResolver.openInputStream(uri) ?: error("A fájl nem olvasható.") + return input.use { importStream(context, name, it, splitIntoChapters) } + } + + internal fun importStream(context: Context?, name: String, input: InputStream, splitIntoChapters: Boolean): Result { + val bytes = readBounded(input) val ext = name.substringAfterLast('.', "").lowercase() val baseTitle = name.substringBeforeLast('.').replace('_', ' ').trim() val result: Result = when { ext == "docx" || isZipWith(bytes, "word/document.xml") -> fromDocx(name, baseTitle, bytes, splitIntoChapters) ext == "epub" || isZipWith(bytes, "META-INF/container.xml") -> fromEpub(name, baseTitle, bytes, splitIntoChapters) - ext == "pdf" || (bytes.size > 4 && String(bytes, 0, 4, Charsets.ISO_8859_1) == "%PDF") -> fromPdf(context, name, baseTitle, bytes, splitIntoChapters) + ext == "pdf" || (bytes.size > 4 && String(bytes, 0, 4, Charsets.ISO_8859_1) == "%PDF") -> + fromPdf(requireNotNull(context) { "PDF importálásához alkalmazáskörnyezet szükséges." }, name, baseTitle, bytes, splitIntoChapters) ext == "doc" -> fromDoc(name, baseTitle, bytes, splitIntoChapters) else -> fromText(name, baseTitle, decodeText(bytes), splitIntoChapters) } @@ -47,6 +55,20 @@ object DocumentImporter { return result } + internal fun readBounded(input: InputStream): ByteArray { + val output = ByteArrayOutputStream() + val buffer = ByteArray(64 * 1024) + var total = 0 + while (true) { + val count = input.read(buffer, 0, minOf(buffer.size, MAX_INPUT_BYTES + 1 - total)) + if (count < 0) break + total += count + if (total > MAX_INPUT_BYTES) error("A fájl túl nagy (legfeljebb 40 MB).") + output.write(buffer, 0, count) + } + return output.toByteArray() + } + private fun fromText(name: String, title: String, text: String, split: Boolean): Result { val chapters = if (split) ChapterSplitter.split(text) else listOf(ChapterSplitter.Chapter(title, ChapterSplitter.cleanBody(text))) return Result(name, title, chapters) diff --git a/app/src/main/java/hu/regelo/app/ui/screens/BookDetailScreen.kt b/app/src/main/java/hu/regelo/app/ui/screens/BookDetailScreen.kt index 843906a..6d82925 100644 --- a/app/src/main/java/hu/regelo/app/ui/screens/BookDetailScreen.kt +++ b/app/src/main/java/hu/regelo/app/ui/screens/BookDetailScreen.kt @@ -124,6 +124,12 @@ private data class DetailData( val isOfficial: Boolean get() = officialSlug != null } +internal fun bookmarkToggleDescription(saved: Boolean): String = + if (saved) "Eltávolítás a könyvtárból" else "Hozzáadás a könyvtárhoz" + +internal fun likeToggleDescription(liked: Boolean): String = + if (liked) "Kedvelés visszavonása" else "Kedvelés" + @Composable fun BookDetailScreen(nav: Navigator, bookId: String) { val snackbar = LocalSnackbar.current @@ -254,13 +260,13 @@ fun BookDetailScreen(nav: Navigator, bookId: String) { if (!d.saved) AppGraph.books.cacheBook(b) loader.update { it.copy(saved = !d.saved) } } - }) { Icon(if (d.saved) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, "Könyvtárba", tint = MaterialTheme.colorScheme.secondary) } + }) { Icon(if (d.saved) Icons.Default.Bookmark else Icons.Default.BookmarkBorder, bookmarkToggleDescription(d.saved), tint = MaterialTheme.colorScheme.secondary) } IconButton(onClick = { scope.runAction(snackbar) { AppGraph.social.setBookLike(b.id, !d.liked) loader.update { it.copy(liked = !d.liked, likeCount = it.likeCount + if (d.liked) -1 else 1) } } - }) { Icon(if (d.liked) Icons.Default.Favorite else Icons.Default.FavoriteBorder, "Kedvelés", tint = MaterialTheme.colorScheme.secondary) } + }) { Icon(if (d.liked) Icons.Default.Favorite else Icons.Default.FavoriteBorder, likeToggleDescription(d.liked), tint = MaterialTheme.colorScheme.secondary) } } val downloaded = localBook?.downloaded == true IconButton(onClick = { diff --git a/app/src/main/java/hu/regelo/app/ui/screens/BookEditorScreen.kt b/app/src/main/java/hu/regelo/app/ui/screens/BookEditorScreen.kt index 4388056..5fe6be3 100644 --- a/app/src/main/java/hu/regelo/app/ui/screens/BookEditorScreen.kt +++ b/app/src/main/java/hu/regelo/app/ui/screens/BookEditorScreen.kt @@ -64,7 +64,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.DisposableEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -98,16 +100,74 @@ import hu.regelo.app.util.readingTimeLabel import hu.regelo.app.util.relativeTime import hu.regelo.app.ui.components.notify import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.delay +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.time.LocalDate -import kotlin.time.Duration.Companion.milliseconds + +private data class BookEditorMetadata( + val title: String, + val penName: String, + val genre: String, + val shortDescription: String, + val isCompleted: Boolean, + val ageRating: String, + val contentWarnings: String, + val themeTags: String, + val publishStatus: String, + val scheduledDate: String?, + val seriesId: String?, + val volumeNumber: Int, +) { + fun applyTo(book: LocalBook): LocalBook = book.copy( + title = title, + penName = penName, + genre = genre, + shortDescription = shortDescription, + isCompleted = isCompleted, + ageRating = ageRating, + contentWarnings = contentWarnings, + themeTags = themeTags, + publishStatus = publishStatus, + scheduledDate = scheduledDate, + seriesId = seriesId, + volumeNumber = volumeNumber, + ) + + companion object { + fun from(book: LocalBook) = BookEditorMetadata( + title = book.title, + penName = book.penName, + genre = book.genre, + shortDescription = book.shortDescription, + isCompleted = book.isCompleted, + ageRating = book.ageRating, + contentWarnings = book.contentWarnings, + themeTags = book.themeTags, + publishStatus = book.publishStatus, + scheduledDate = book.scheduledDate, + seriesId = book.seriesId, + volumeNumber = book.volumeNumber, + ) + } +} @Composable fun BookEditorScreen(nav: Navigator, bookId: String) { val snackbar = LocalSnackbar.current val scope = rememberCoroutineScope() + val bookSaver = remember(bookId) { + DebouncedBookSaver( + scope = AppGraph.appScope, + delayMillis = 700, + save = { metadata: BookEditorMetadata -> + AppGraph.writing.book(bookId)?.let { current -> + AppGraph.writing.saveBook(metadata.applyTo(current)) + } + }, + onError = { hu.regelo.app.util.ErrorReporter.report(it, "BookEditorScreen.saveBook") }, + ) + } val context = LocalContext.current val stored by AppGraph.writing.observeBook(bookId).collectAsState(initial = null) val chapters by AppGraph.writing.chaptersOf(bookId).collectAsState(initial = emptyList()) @@ -128,20 +188,39 @@ fun BookEditorScreen(nav: Navigator, bookId: String) { if (draft == null) { draft = s; tagsText = s.themeTags.splitList().joinToString(", ") } } LaunchedEffect(Unit) { series = runCatching { AppGraph.writing.mySeries() }.orReport("BookEditorScreen", emptyList()) } - // A beállítások automatikus mentése (gépelés után kis késleltetéssel). - LaunchedEffect(draft, tagsText) { - val d = draft ?: return@LaunchedEffect - val s = stored ?: return@LaunchedEffect - delay(700.milliseconds) - val tags = tagsText.split(',', '#').map { it.trim().lowercase() }.filter { it.isNotEmpty() }.distinct().take(10).joinList() - // Csak a szerkeszthető mezőket visszük át a tárolt könyvre: a közben szinkronizált adatok - // (borító, megtekintések, letöltés stb.) így nem íródnak felül a megnyitáskori állapottal. - val toSave = s.copy( - title = d.title, penName = d.penName, genre = d.genre, shortDescription = d.shortDescription, - isCompleted = d.isCompleted, ageRating = d.ageRating, contentWarnings = d.contentWarnings, themeTags = tags, - publishStatus = d.publishStatus, scheduledDate = d.scheduledDate, seriesId = d.seriesId, volumeNumber = d.volumeNumber, - ) - if (toSave != s) AppGraph.writing.saveBook(toSave) + val pendingBookSave: BookEditorMetadata? = remember(draft, tagsText, stored) { + val d = draft + val s = stored + if (d == null || s == null) null else { + val tags = tagsText.split(',', '#').map { it.trim().lowercase() }.filter { it.isNotEmpty() }.distinct().take(10).joinList() + BookEditorMetadata( + title = d.title, + penName = d.penName, + genre = d.genre, + shortDescription = d.shortDescription, + isCompleted = d.isCompleted, + ageRating = d.ageRating, + contentWarnings = d.contentWarnings, + themeTags = tags, + publishStatus = d.publishStatus, + scheduledDate = d.scheduledDate, + seriesId = d.seriesId, + volumeNumber = d.volumeNumber, + ).takeIf { it != BookEditorMetadata.from(s) } + } + } + val latestPendingBookSave by rememberUpdatedState(pendingBookSave) + LaunchedEffect(pendingBookSave) { + pendingBookSave?.let(bookSaver::submit) + } + DisposableEffect(bookSaver) { + onDispose { + val latest = latestPendingBookSave + AppGraph.appScope.launch { + latest?.let(bookSaver::submit) + bookSaver.flush() + } + } } // Ha a szerver időközben megjelentette az időzített könyvet, a szerkesztő is ezt mutassa (és ne írja vissza). LaunchedEffect(stored?.publishStatus) { @@ -241,8 +320,16 @@ fun BookEditorScreen(nav: Navigator, bookId: String) { Row(Modifier.padding(16.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)) { Button(onClick = { scope.launch { - val c = AppGraph.writing.createChapter(bookId, "${chapters.size + 1}. fejezet", "") - nav.push(Route.ChapterEditor(bookId, c.id)) + try { + val c = AppGraph.writing.createChapter(bookId, "${chapters.size + 1}. fejezet", "") + nav.push(Route.ChapterEditor(bookId, c.id)) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + snackbar.showSnackbar(e.friendlyMessage().also { + hu.regelo.app.util.ErrorReporter.report(e, "BookEditorScreen.createChapter") + }) + } } }) { Icon(Icons.Default.Add, null); Spacer(Modifier.width(6.dp)); Text("Új fejezet") } OutlinedButton(onClick = { nav.push(Route.Import(bookId)) }) { Icon(Icons.Default.UploadFile, null); Spacer(Modifier.width(6.dp)); Text("Fájlból") } diff --git a/app/src/main/java/hu/regelo/app/ui/screens/CommunityScreens.kt b/app/src/main/java/hu/regelo/app/ui/screens/CommunityScreens.kt index 8905a03..7b2325a 100644 --- a/app/src/main/java/hu/regelo/app/ui/screens/CommunityScreens.kt +++ b/app/src/main/java/hu/regelo/app/ui/screens/CommunityScreens.kt @@ -90,6 +90,7 @@ import hu.regelo.app.data.Conversation import hu.regelo.app.data.MessageDto import hu.regelo.app.data.NotificationItem import hu.regelo.app.data.ProfileDto +import hu.regelo.app.util.ErrorReporter import hu.regelo.app.ui.components.Avatar import hu.regelo.app.ui.components.BackTopBar import hu.regelo.app.ui.components.ConfirmDialog @@ -508,14 +509,13 @@ private fun MessageBubble(m: UiMessage, mine: Boolean, onRetry: () -> Unit) { @Composable fun NewMessageScreen(nav: Navigator) { var query by remember { mutableStateOf("") } - var results by remember { mutableStateOf>(emptyList()) } - var searching by remember { mutableStateOf(false) } + val profileSearch = remember { ProfileSearchState() } LaunchedEffect(query) { - kotlinx.coroutines.delay(350.milliseconds) - if (query.trim().length < 2) { results = emptyList(); return@LaunchedEffect } - searching = true - results = runCatching { AppGraph.profiles.search(query) }.orReport("CommunityScreens", emptyList()).filter { it.userId != AppGraph.auth.userId } - searching = false + profileSearch.update( + query = query, + search = { AppGraph.profiles.search(it).filter { profile -> profile.userId != AppGraph.auth.userId } }, + onError = { ErrorReporter.report(it, "CommunityScreens") }, + ) } Column(Modifier.fillMaxSize()) { BackTopBar("Új üzenet", onBack = { nav.back() }) @@ -524,12 +524,12 @@ fun NewMessageScreen(nav: Navigator) { leadingIcon = { Icon(Icons.Default.Search, null) }, singleLine = true, modifier = Modifier.fillMaxWidth().padding(16.dp), ) - if (searching) LoadingBox(Modifier.height(80.dp)) + if (profileSearch.searching) LoadingBox(Modifier.height(80.dp)) LazyColumn { - items(results, key = { it.userId }) { p -> + items(profileSearch.results, key = { it.userId }) { p -> PersonRow(p, onClick = { nav.replace(Route.Conversation(p.userId)) }, trailing = { Icon(Icons.AutoMirrored.Filled.Send, null, tint = MaterialTheme.colorScheme.onSurfaceVariant) }) } - if (!searching && query.trim().length >= 2 && results.isEmpty()) item { + if (!profileSearch.searching && query.trim().length >= 2 && profileSearch.results.isEmpty()) item { EmptyState(Icons.Default.Search, "Nincs ilyen felhasználó", "Próbáld a nevét vagy a @felhasználónevét, ékezet nélkül is mehet.") } } diff --git a/app/src/main/java/hu/regelo/app/ui/screens/DebouncedBookSaver.kt b/app/src/main/java/hu/regelo/app/ui/screens/DebouncedBookSaver.kt new file mode 100644 index 0000000..9ac3a84 --- /dev/null +++ b/app/src/main/java/hu/regelo/app/ui/screens/DebouncedBookSaver.kt @@ -0,0 +1,80 @@ +package hu.regelo.app.ui.screens + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext + +internal class DebouncedBookSaver( + private val scope: CoroutineScope, + private val delayMillis: Long, + private val save: suspend (T) -> Unit, + private val isEquivalent: (T, T) -> Boolean = { first, second -> first == second }, + private val onError: (Throwable) -> Unit = {}, +) { + private val lock = Any() + private val saveMutex = Mutex() + private var pending: T? = null + private var inFlight: T? = null + private var lastSaved: T? = null + private var scheduled: Job? = null + + fun submit(value: T) { + synchronized(lock) { + if (pending?.let { isEquivalent(value, it) } == true) return + if (inFlight?.let { isEquivalent(value, it) } == true || + (inFlight == null && lastSaved?.let { isEquivalent(value, it) } == true) + ) { + pending = null + scheduled?.cancel() + scheduled = null + return + } + pending = value + scheduled?.cancel() + scheduled = scope.launch { + delay(delayMillis) + withContext(NonCancellable) { flushPending() } + } + } + } + + suspend fun flush() { + val job = synchronized(lock) { + scheduled.also { scheduled = null } + } + job?.cancel() + withContext(NonCancellable) { flushPending() } + } + + private suspend fun flushPending() = saveMutex.withLock { + val value = synchronized(lock) { + pending.also { + pending = null + inFlight = it + } + } ?: return + + try { + save(value) + synchronized(lock) { lastSaved = value } + } catch (error: CancellationException) { + synchronized(lock) { + if (pending == null) pending = value + } + throw error + } catch (error: Exception) { + synchronized(lock) { + if (pending == null) pending = value + } + onError(error) + } finally { + synchronized(lock) { inFlight = null } + } + } +} diff --git a/app/src/main/java/hu/regelo/app/ui/screens/ProfileSearchState.kt b/app/src/main/java/hu/regelo/app/ui/screens/ProfileSearchState.kt new file mode 100644 index 0000000..8ca1a84 --- /dev/null +++ b/app/src/main/java/hu/regelo/app/ui/screens/ProfileSearchState.kt @@ -0,0 +1,51 @@ +package hu.regelo.app.ui.screens + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive + +internal class ProfileSearchState(private val debounceMillis: Long = 350) { + var results by mutableStateOf>(emptyList()) + private set + + var searching by mutableStateOf(false) + private set + + private var generation = 0 + + suspend fun update( + query: String, + search: suspend (String) -> List, + onError: (Exception) -> Unit, + ) { + val request = ++generation + if (query.trim().length < 2) { + results = emptyList() + searching = false + return + } + + try { + delay(debounceMillis) + if (request != generation) return + + searching = true + val found = try { + search(query) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + onError(error) + emptyList() + } + currentCoroutineContext().ensureActive() + if (request == generation) results = found + } finally { + if (request == generation) searching = false + } + } +} diff --git a/app/src/test/java/hu/regelo/app/BookDetailSemanticsTest.kt b/app/src/test/java/hu/regelo/app/BookDetailSemanticsTest.kt new file mode 100644 index 0000000..ddb22b4 --- /dev/null +++ b/app/src/test/java/hu/regelo/app/BookDetailSemanticsTest.kt @@ -0,0 +1,20 @@ +package hu.regelo.app + +import hu.regelo.app.ui.screens.bookmarkToggleDescription +import hu.regelo.app.ui.screens.likeToggleDescription +import org.junit.Assert.assertEquals +import org.junit.Test + +class BookDetailSemanticsTest { + @Test + fun bookmarkDescriptionAnnouncesTheAvailableAction() { + assertEquals("Hozzáadás a könyvtárhoz", bookmarkToggleDescription(saved = false)) + assertEquals("Eltávolítás a könyvtárból", bookmarkToggleDescription(saved = true)) + } + + @Test + fun likeDescriptionAnnouncesTheAvailableAction() { + assertEquals("Kedvelés", likeToggleDescription(liked = false)) + assertEquals("Kedvelés visszavonása", likeToggleDescription(liked = true)) + } +} diff --git a/app/src/test/java/hu/regelo/app/DebouncedBookSaverTest.kt b/app/src/test/java/hu/regelo/app/DebouncedBookSaverTest.kt new file mode 100644 index 0000000..599c5f7 --- /dev/null +++ b/app/src/test/java/hu/regelo/app/DebouncedBookSaverTest.kt @@ -0,0 +1,86 @@ +package hu.regelo.app + +import hu.regelo.app.ui.screens.DebouncedBookSaver +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.Assert.assertEquals +import org.junit.Test + +class DebouncedBookSaverTest { + @Test + fun flushSavesOnlyTheLatestPendingValueOnce() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val saved = mutableListOf() + val saver = DebouncedBookSaver(scope, 10_000, { value -> saved += value }) + + saver.submit("first") + saver.submit("latest") + saver.flush() + delay(30) + + assertEquals(listOf("latest"), saved) + scope.cancel() + } + + @Test + fun flushWaitsForAnInFlightSaveBeforeWritingLatestValue() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val firstStarted = CompletableDeferred() + val releaseFirst = CompletableDeferred() + val saved = mutableListOf() + val saver = DebouncedBookSaver(scope, 10_000, { value -> + saved += value + if (value == "first") { + firstStarted.complete(Unit) + releaseFirst.await() + } + }) + + saver.submit("first") + val firstFlush = launch { saver.flush() } + withTimeout(1_000) { firstStarted.await() } + saver.submit("latest") + val latestFlush = launch { saver.flush() } + + releaseFirst.complete(Unit) + firstFlush.join() + latestFlush.join() + + assertEquals(listOf("first", "latest"), saved) + scope.cancel() + } + + @Test + fun returningToTheInFlightValueCancelsAStalePendingValue() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + val firstStarted = CompletableDeferred() + val releaseFirst = CompletableDeferred() + val saved = mutableListOf() + val saver = DebouncedBookSaver(scope, 10_000, { value -> + saved += value + if (value == "first") { + firstStarted.complete(Unit) + releaseFirst.await() + } + }) + + saver.submit("first") + val firstFlush = launch { saver.flush() } + withTimeout(1_000) { firstStarted.await() } + saver.submit("stale") + saver.submit("first") + releaseFirst.complete(Unit) + firstFlush.join() + saver.flush() + + assertEquals(listOf("first"), saved) + scope.cancel() + } +} diff --git a/app/src/test/java/hu/regelo/app/ImporterTest.kt b/app/src/test/java/hu/regelo/app/ImporterTest.kt index dbe23f8..39b4539 100644 --- a/app/src/test/java/hu/regelo/app/ImporterTest.kt +++ b/app/src/test/java/hu/regelo/app/ImporterTest.kt @@ -8,6 +8,7 @@ import hu.regelo.app.util.readingMinutes import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +import java.io.InputStream class ImporterTest { @Test @@ -51,6 +52,47 @@ class ImporterTest { assertEquals("Ez *dőlt* és **félkövér**.\nMásodik bekezdés & vége", body) } + @Test + fun oversizedInputIsRejectedAfterReadingOnlyLimitPlusOne() { + val source = object : InputStream() { + var bytesRead = 0 + + override fun read(): Int { + bytesRead++ + return 'x'.code + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + val count = minOf(length, 50 * 1024 * 1024 - bytesRead) + if (count == 0) return -1 + java.util.Arrays.fill(buffer, offset, offset + count, 'x'.code.toByte()) + bytesRead += count + return count + } + } + + try { + DocumentImporter.importStream(null, "oversized.txt", source, splitIntoChapters = false) + throw AssertionError("Oversized input was accepted.") + } catch (e: IllegalStateException) { + assertTrue(e.message!!.contains("40 MB")) + } + assertEquals(DocumentImporter.MAX_INPUT_BYTES + 1, source.bytesRead) + } + + @Test + fun normalTextInputParses() { + val parsed = DocumentImporter.importStream( + null, + "normal.txt", + "Egy rendes bekezdés a történetből.".byteInputStream(), + splitIntoChapters = false, + ) + + assertEquals("normal", parsed.title) + assertEquals("Egy rendes bekezdés a történetből.", parsed.chapters.single().content) + } + @Test fun wordCountAndReadingTime() { assertEquals(5, countWords("Ez egy **rövid** _mondat_, igen.")) diff --git a/app/src/test/java/hu/regelo/app/ProfileSearchStateTest.kt b/app/src/test/java/hu/regelo/app/ProfileSearchStateTest.kt new file mode 100644 index 0000000..4cc46af --- /dev/null +++ b/app/src/test/java/hu/regelo/app/ProfileSearchStateTest.kt @@ -0,0 +1,96 @@ +package hu.regelo.app + +import hu.regelo.app.ui.screens.ProfileSearchState +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProfileSearchStateTest { + @Test + fun shortQueryClearsResultsAndLoadingImmediately() = runBlocking { + val state = ProfileSearchState(debounceMillis = 0) + state.update("ab", { listOf("match") }, {}) + assertEquals(listOf("match"), state.results) + val started = CompletableDeferred() + val activeSearch = launch { + state.update("abc", { + started.complete(Unit) + awaitCancellation() + }, {}) + } + started.await() + assertTrue(state.searching) + + state.update("a", { error("short queries must not search") }, {}) + + assertFalse(state.searching) + assertEquals(emptyList(), state.results) + activeSearch.cancelAndJoin() + } + + @Test + fun cancellationOfCurrentSearchClearsLoading() = runBlocking { + val state = ProfileSearchState(debounceMillis = 0) + val started = CompletableDeferred() + val activeSearch = launch { + state.update("ab", { + started.complete(Unit) + awaitCancellation() + }, {}) + } + started.await() + assertTrue(state.searching) + + activeSearch.cancelAndJoin() + + assertFalse(state.searching) + } + + @Test + fun failedSearchClearsLoadingAndReturnsEmptyResults() = runBlocking { + val state = ProfileSearchState(debounceMillis = 0) + var reported: Exception? = null + + state.update("ab", { throw IllegalStateException("offline") }, { reported = it }) + + assertFalse(state.searching) + assertEquals(emptyList(), state.results) + assertTrue(reported is IllegalStateException) + } + + @Test + fun cancelledOlderSearchCannotOverwriteNewerResultsOrLoading() = runBlocking { + val state = ProfileSearchState(debounceMillis = 0) + val started = CompletableDeferred() + val release = CompletableDeferred() + val oldSearch = launch { + state.update("old", { + withContext(NonCancellable) { + started.complete(Unit) + release.await() + } + listOf("stale") + }, {}) + } + started.await() + oldSearch.cancel() + + state.update("new", { listOf("current") }, {}) + assertFalse(state.searching) + assertEquals(listOf("current"), state.results) + + release.complete(Unit) + oldSearch.cancelAndJoin() + + assertFalse(state.searching) + assertEquals(listOf("current"), state.results) + } +} diff --git a/app/src/test/java/hu/regelo/app/data/SyncWorkerTest.kt b/app/src/test/java/hu/regelo/app/data/SyncWorkerTest.kt new file mode 100644 index 0000000..a6a0324 --- /dev/null +++ b/app/src/test/java/hu/regelo/app/data/SyncWorkerTest.kt @@ -0,0 +1,58 @@ +package hu.regelo.app.data + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SyncWorkerTest { + @Test + fun progressUploadFailureRetriesEvenWhenWritingSyncSucceeds() { + var writingSyncRan = false + val decision = kotlinx.coroutines.runBlocking { + performSync( + attemptCount = 0, + writingSync = { + writingSyncRan = true + true + }, + progressSync = { error("Progress upload failed.") }, + ) + } + + assertTrue(writingSyncRan) + assertEquals(SyncDecision.RETRY, decision) + } + + @Test + fun writingFailureDoesNotSkipProgressSync() { + var progressSyncRan = false + val decision = kotlinx.coroutines.runBlocking { + performSync( + attemptCount = 0, + writingSync = { false }, + progressSync = { progressSyncRan = true }, + ) + } + + assertTrue(progressSyncRan) + assertEquals(SyncDecision.RETRY, decision) + } + + @Test + fun completedSyncAndNoPendingProgressAreSuccessful() { + val decision = kotlinx.coroutines.runBlocking { + performSync(attemptCount = 0, writingSync = { true }, progressSync = { }) + } + + assertEquals(SyncDecision.SUCCESS, decision) + } + + @Test + fun unsuccessfulSyncFailsAfterRetryLimit() { + val decision = kotlinx.coroutines.runBlocking { + performSync(attemptCount = 5, writingSync = { true }, progressSync = { error("Upload failed.") }) + } + + assertEquals(SyncDecision.FAILURE, decision) + } +}