Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 30 additions & 14 deletions app/src/main/java/hu/regelo/app/data/LibraryRepository.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand All @@ -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<ReaderProgressDto>()
Expand All @@ -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) {
Expand Down
32 changes: 29 additions & 3 deletions app/src/main/java/hu/regelo/app/data/SyncWorker.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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()
}
}
}
30 changes: 26 additions & 4 deletions app/src/main/java/hu/regelo/app/importer/DocumentImporter.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<ChapterSplitter.Chapter>, val warning: String? = null)

fun fileName(context: Context, uri: Uri): String {
Expand All @@ -30,23 +34,41 @@ 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)
}
if (result.chapters.all { it.content.isBlank() }) error("Nem találtam szöveget a fájlban.")
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)
Expand Down
10 changes: 8 additions & 2 deletions app/src/main/java/hu/regelo/app/ui/screens/BookDetailScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down
Loading
Loading