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) + } +}