diff --git a/README.md b/README.md index 16236fa..17f6991 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Magyar írói és olvasói platform (Wattpad / Inkitt jellegű), Kotlin + Jetpac - Csomagnév: `hu.regelo.app` - Backend: Supabase (Postgres + RLS, Auth, Storage, Realtime), projekt: `pqmfezkvaslqedalwwnn` (Frankfurt) - Push: Firebase Cloud Messaging (`regelo` projekt) + `send-community-push` Edge Function -- Offline-first: minden írás azonnal a telefonra mentődik (Room), a felhőbe a háttérben szinkronizál (WorkManager) +- Offline-first: minden írás azonnal a telefonon, SQLCipherrel titkosított Room-adatbázisba mentődik, a felhőbe a háttérben szinkronizál (WorkManager) ## Fordítás Android Studio › Open › ez a mappa › (Gradle sync) › Build › Build App Bundle(s) / APK(s) › Build APK(s). @@ -48,6 +48,9 @@ a szerveroldali hitelesítés és RLS-szabályok adják. Service-role kulcsot ti - Az alkalmazás API 37-et céloz. A `versionCode` frissítése és az aláírt bundle ellenőrzése még szükséges a kiadáshoz. - A completion RPC-t használó kliens kiadása előtt a cél Supabase-környezeten legyen alkalmazva a `supabase/2026-10-26_app_v1_4.sql` migráció. +- A helyi Room-adatbázis SQLCipherrel titkosított. Meglévő telepítésen az indulási migráció WAL-ellenőrzőpontot + készít, külön titkosított példányon konvertál, ellenőrzi, majd atomi fájlcserével vált; hiba esetén az eredeti + adatbázis marad érintetlen. A véletlen adatvesztés elkerülésére ne töröld az alkalmazás adatait vagy Keystore-kulcsát. ## Adatbázis A `supabase/` mappában lévő SQL fájlok idempotensek (többször is futtathatók) a Supabase SQL Editorban. @@ -67,7 +70,11 @@ Hivatalos e-mail: regeloapp@gmail.com · Hivatalos profil az appban: @regelohiva 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, az adat-export a teljes, szerveroldali GDPR-exportot használja, és a Room frissítés nem törli csendben - az ismeretlen sémájú helyi adatokat. + az ismeretlen sémájú helyi adatokat. A helyi Room-adatbázis SQLCipherrel titkosított; a kulcs Keystore-ban + védett AES-GCM burkolással tárolódik, sérült vagy hiányzó kulcs esetén az alkalmazás az adatok törlése helyett + hibával leáll. A korábbi sima SQLite-fájlt ellenőrzött, atomi migráció alakítja át. +- **Profilon kezelt írások** – A saját művek és piszkozatok, import, sorozatok, export, statisztika és törlés + a Profil fülön érhető el; az Írás fül a napi célra és írói kihívásokra összpontosít. - **1.3.0** – Hivatalos kiadványok, adatvédelem és biztonság: a Regélő négy, folyamatosan bővülő hivatalos „könyve” (Hírek, Kihívások, Heti válogatás, Kávéház) – az új részeket a szerver automatikusan teszi közzé (hírből, kihívásból, heti válogatásból, beszélgetésindítóból); „A Regélőtől” sáv a Könyvtárban diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bd68aaa..356cc2a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -93,6 +93,8 @@ dependencies { implementation(libs.androidx.lifecycle.process) implementation(libs.androidx.room.runtime) implementation(libs.androidx.room.ktx) + implementation(libs.androidx.sqlite) + implementation(libs.zetetic.sqlcipher) ksp(libs.androidx.room.compiler) implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.work.runtime.ktx) diff --git a/app/src/main/java/hu/regelo/app/data/EncryptedLocalDatabase.kt b/app/src/main/java/hu/regelo/app/data/EncryptedLocalDatabase.kt new file mode 100644 index 0000000..22b133a --- /dev/null +++ b/app/src/main/java/hu/regelo/app/data/EncryptedLocalDatabase.kt @@ -0,0 +1,186 @@ +package hu.regelo.app.data + +import android.content.Context +import android.database.sqlite.SQLiteDatabase as PlatformSQLiteDatabase +import android.os.Build +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.system.Os +import android.util.Base64 +import androidx.core.content.edit +import net.zetetic.database.sqlcipher.SQLiteDatabase +import net.zetetic.database.sqlcipher.SupportOpenHelperFactory +import java.io.File +import java.security.KeyStore +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +internal object EncryptedLocalDatabase { + private const val DATABASE_NAME = "regelo-v1.db" + private const val PREFS_NAME = "regelo_local_database_key" + private const val PREFS_KEY = "wrapped_key_v1" + private const val KEYSTORE_PROVIDER = "AndroidKeyStore" + private const val KEY_ALIAS = "regelo_local_database_wrapping_key" + private const val TRANSFORMATION = "AES/GCM/NoPadding" + private const val SQLITE_HEADER = "SQLite format 3\u0000" + + @Synchronized + fun openHelperFactory(context: Context): SupportOpenHelperFactory { + System.loadLibrary("sqlcipher") + val database = context.getDatabasePath(DATABASE_NAME) + val hasPlaintextDatabase = database.isFile && hasSqliteHeader(database) + val passphrase = getOrCreatePassphrase(context, hasPlaintextDatabase) + if (hasPlaintextDatabase) encryptExistingDatabase(database, passphrase) + else if (database.exists()) verifyEncryptedDatabase(database, passphrase) + return SupportOpenHelperFactory(passphrase) + } + + internal fun hasSqliteHeader(file: File): Boolean { + if (!file.isFile || file.length() < SQLITE_HEADER.length) return false + val header = ByteArray(SQLITE_HEADER.length) + return file.inputStream().use { input -> + var offset = 0 + while (offset < header.size) { + val read = input.read(header, offset, header.size - offset) + if (read < 0) return@use false + offset += read + } + header.contentEquals(SQLITE_HEADER.toByteArray(Charsets.US_ASCII)) + } + } + + private fun getOrCreatePassphrase(context: Context, hasPlaintextDatabase: Boolean): ByteArray { + val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val stored = prefs.getString(PREFS_KEY, null) + if (stored != null) return decrypt(context, stored) + + val database = context.getDatabasePath(DATABASE_NAME) + check(!database.exists() || hasPlaintextDatabase) { + "A helyi adatbázis titkosítókulcsa nem érhető el; az adatokat nem töröltük." + } + + val passphrase = ByteArray(32).also(SecureRandom()::nextBytes) + val encoded = encrypt(context, passphrase) + check(prefs.edit().putString(PREFS_KEY, encoded).commit()) { + "A helyi adatbázis titkosítókulcsát nem sikerült biztonságosan menteni." + } + return passphrase + } + + private fun encryptExistingDatabase(database: File, passphrase: ByteArray) { + val temporary = File(database.parentFile, "${database.name}.encrypted.tmp") + check(temporary.delete() || !temporary.exists()) { + "A titkosított adatbázis ideiglenes fájlja nem törölhető." + } + + val plainDb = PlatformSQLiteDatabase.openDatabase( + database.absolutePath, + null, + PlatformSQLiteDatabase.OPEN_READWRITE, + ) + try { + plainDb.rawQuery("PRAGMA wal_checkpoint(TRUNCATE)", null).use { cursor -> + check(!cursor.moveToFirst() || cursor.getInt(0) == 0) { + "Az olvasási/írási napló ellenőrzőpontja nem fejeződött be." + } + } + plainDb.rawQuery("PRAGMA journal_mode=DELETE", null).use { cursor -> + check(cursor.moveToFirst() && cursor.getString(0).equals("delete", ignoreCase = true)) { + "Az adatbázis naplózási módját nem sikerült biztonságosan előkészíteni." + } + } + } finally { + plainDb.close() + } + listOf("-wal", "-shm").forEach { suffix -> + val sidecar = File(database.absolutePath + suffix) + check(!sidecar.exists() || sidecar.delete()) { + "Az adatbázis naplófájlja nem törölhető biztonságosan." + } + } + + try { + database.copyTo(temporary, overwrite = true) + val migrationDb = SQLiteDatabase.openDatabase( + temporary.absolutePath, + null, + SQLiteDatabase.OPEN_READWRITE, + ) + try { + verifyReadable(migrationDb) + migrationDb.changePassword(passphrase) + verifyReadable(migrationDb) + } finally { + migrationDb.close() + } + verifyEncryptedDatabase(temporary, passphrase) + Os.rename(temporary.absolutePath, database.absolutePath) + verifyEncryptedDatabase(database, passphrase) + } catch (e: Exception) { + temporary.delete() + throw IllegalStateException( + "A helyi adatbázis titkosítása nem sikerült; az eredeti adatokat megőriztük.", + e, + ) + } + } + + private fun verifyEncryptedDatabase(database: File, passphrase: ByteArray) { + val encryptedDb = SQLiteDatabase.openDatabase( + database.absolutePath, + passphrase, + null, + SQLiteDatabase.OPEN_READONLY, + null, + ) + try { + verifyReadable(encryptedDb) + } finally { + encryptedDb.close() + } + } + + private fun verifyReadable(database: SQLiteDatabase) { + database.rawQuery("SELECT count(*) FROM sqlite_master", null).use { cursor -> + check(cursor.moveToFirst()) { "A helyi adatbázis ellenőrzése sikertelen." } + } + } + + private fun encrypt(context: Context, passphrase: ByteArray): String { + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.ENCRYPT_MODE, wrappingKey(context, create = true)) + val ciphertext = cipher.doFinal(passphrase) + return Base64.encodeToString(cipher.iv, Base64.NO_WRAP) + ":" + + Base64.encodeToString(ciphertext, Base64.NO_WRAP) + } + + private fun decrypt(context: Context, value: String): ByteArray { + val parts = value.split(':', limit = 2) + check(parts.size == 2) { "A helyi adatbázis titkosítókulcsa sérült." } + val iv = Base64.decode(parts[0], Base64.NO_WRAP) + val ciphertext = Base64.decode(parts[1], Base64.NO_WRAP) + val cipher = Cipher.getInstance(TRANSFORMATION) + cipher.init(Cipher.DECRYPT_MODE, wrappingKey(context, create = false), GCMParameterSpec(128, iv)) + return cipher.doFinal(ciphertext) + } + + private fun wrappingKey(context: Context, create: Boolean): SecretKey { + val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) } + (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it } + check(create) { "A helyi adatbázis Keystore-kulcsa nem érhető el." } + + val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER) + val builder = KeyGenParameterSpec.Builder( + KEY_ALIAS, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT, + ).setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setKeySize(256) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) builder.setUnlockedDeviceRequired(true) + generator.init(builder.build()) + return generator.generateKey() + } +} diff --git a/app/src/main/java/hu/regelo/app/data/Local.kt b/app/src/main/java/hu/regelo/app/data/Local.kt index 1a4176f..a6dfb72 100644 --- a/app/src/main/java/hu/regelo/app/data/Local.kt +++ b/app/src/main/java/hu/regelo/app/data/Local.kt @@ -261,6 +261,7 @@ abstract class RegeloDatabase : RoomDatabase() { Room.databaseBuilder(context, RegeloDatabase::class.java, "regelo-v1.db") // Kézi migráció: ismeretlen sémaváltásnál inkább hibázzunk, mint hogy töröljük a piszkozatokat. .addMigrations(MIGRATION_1_2) + .openHelperFactory(EncryptedLocalDatabase.openHelperFactory(context.applicationContext)) .build() } } diff --git a/app/src/main/java/hu/regelo/app/ui/screens/ProfileScreens.kt b/app/src/main/java/hu/regelo/app/ui/screens/ProfileScreens.kt index 9850bfd..7873861 100644 --- a/app/src/main/java/hu/regelo/app/ui/screens/ProfileScreens.kt +++ b/app/src/main/java/hu/regelo/app/ui/screens/ProfileScreens.kt @@ -67,6 +67,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -165,7 +166,11 @@ private fun ProfileContent(nav: Navigator, userId: String, isMe: Boolean) { var tip by remember { mutableStateOf(false) } var postNews by remember { mutableStateOf(false) } var deleteNews by remember { mutableStateOf(null) } + var newBook by remember { mutableStateOf(false) } + var deleteBook by remember { mutableStateOf(null) } + var exportBook by remember { mutableStateOf(null) } val myProfile by AppGraph.profiles.me.collectAsState() + val myBooks by AppGraph.writing.myBooks().collectAsState(initial = emptyList()) val loader = rememberLoader(userId, if (isMe) myProfile else null) { loadProfile(userId) } Column(Modifier.fillMaxSize()) { @@ -265,32 +270,73 @@ private fun ProfileContent(nav: Navigator, userId: String, isMe: Boolean) { AnnouncementCard(hu.regelo.app.data.AnnouncementItem(a, p), nav, onDelete = if (isMe) ({ deleteNews = a.id }) else null) } } + if (isMe) { + item(key = "my-writing-tools") { + Column(Modifier.fillMaxWidth().padding(top = 12.dp)) { + SectionTitle("Saját történetek és piszkozatok (${myBooks.size})") + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button(onClick = { newBook = true }, modifier = Modifier.weight(1f)) { + Icon(Icons.Default.Add, null) + Spacer(Modifier.width(6.dp)) + Text("Új történet", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + OutlinedButton(onClick = { nav.push(Route.Import(null)) }, modifier = Modifier.weight(1f)) { + Text("Importálás", maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } + OutlinedButton( + onClick = { nav.push(Route.SeriesManager) }, + modifier = Modifier.padding(start = 16.dp, top = 4.dp), + ) { Text("Sorozataim kezelése") } + if (myBooks.isEmpty()) { + Text( + "Itt kezelheted a piszkozataidat és publikált műveidet.", + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + items(myBooks, key = { "my-work-${it.id}" }) { book -> + MyBookCard( + book, + onOpen = { nav.push(Route.BookEditor(book.id)) }, + onPreview = { nav.push(Route.Reader(book.id)) }, + onStats = { nav.push(Route.Stats(book.id)) }, + onWorld = { nav.push(Route.World(book.id)) }, + onExport = { exportBook = book }, + onDelete = { deleteBook = book }, + ) + } + } // A hivatalos kiadványok külön sorban, a „Művei” előtt (a Regélő hivatalos profilján). val officialCards = d.books.filter { it.book.id in officialBooks } val ownCards = d.books.filter { it.book.id !in officialBooks } - if (officialCards.isNotEmpty()) { - item { SectionTitle("Hivatalos kiadványok") } - item { - LazyRow(contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(14.dp)) { - items(officialCards, key = { "o-" + it.book.id }) { c -> - Column(Modifier.width(112.dp)) { - BookTile(c, onClick = { nav.push(Route.BookDetail(c.book.id)) }) - Text("Utoljára bővült: ${relativeTime(c.book.updatedAt)}", style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2) + if (!isMe) { + if (officialCards.isNotEmpty()) { + item { SectionTitle("Hivatalos kiadványok") } + item { + LazyRow(contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(14.dp)) { + items(officialCards, key = { "o-" + it.book.id }) { c -> + Column(Modifier.width(112.dp)) { + BookTile(c, onClick = { nav.push(Route.BookDetail(c.book.id)) }) + Text("Utoljára bővült: ${relativeTime(c.book.updatedAt)}", style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2) + } } } } } - } - if (officialCards.isEmpty() || ownCards.isNotEmpty()) item { SectionTitle(if (isMe) "Publikált műveim" else "Művei") } - if (officialCards.isNotEmpty() && ownCards.isEmpty()) { - // A hivatalos fióknak nincs külön saját műve: nem írunk ki üres „Művei” részt. - } else if (ownCards.isEmpty()) item { - Text(if (isMe) "Még nincs publikált történeted. Az Írás fülön kezdhetsz bele." else "Még nincs publikált története.", - modifier = Modifier.padding(horizontal = 20.dp), color = MaterialTheme.colorScheme.onSurfaceVariant) - } else item { - LazyRow(contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(14.dp)) { - items(ownCards, key = { it.book.id }) { c -> BookTile(c, onClick = { nav.push(Route.BookDetail(c.book.id)) }) } + if (officialCards.isEmpty() || ownCards.isNotEmpty()) item { SectionTitle("Művei") } + if (officialCards.isEmpty() && ownCards.isEmpty()) item { + Text("Még nincs publikált története.", modifier = Modifier.padding(horizontal = 20.dp), color = MaterialTheme.colorScheme.onSurfaceVariant) + } else if (ownCards.isNotEmpty()) item { + LazyRow(contentPadding = PaddingValues(horizontal = 16.dp), horizontalArrangement = Arrangement.spacedBy(14.dp)) { + items(ownCards, key = { it.book.id }) { c -> BookTile(c, onClick = { nav.push(Route.BookDetail(c.book.id)) }) } + } } } if (d.lists.isNotEmpty()) { @@ -306,6 +352,33 @@ private fun ProfileContent(nav: Navigator, userId: String, isMe: Boolean) { } } if (tip) TipDialog(p.displayName, p.paypalMe) { tip = false } + if (newBook) NewBookDialog(onDismiss = { newBook = false }) { title, genre, description -> + scope.launch { + try { + val book = AppGraph.writing.createBook(title, genre, description) + val firstChapter = AppGraph.writing.chapterList(book.id).firstOrNull() + if (firstChapter != null) nav.push(Route.ChapterEditor(book.id, firstChapter.id)) + else nav.push(Route.BookEditor(book.id)) + } catch (e: Exception) { + snackbar.showSnackbar(e.friendlyMessage().also { + hu.regelo.app.util.ErrorReporter.report(e, "ProfileScreens.createBook") + }) + } + } + } + exportBook?.let { book -> ExportDialog(book.id, book.title, onDismiss = { exportBook = null }) } + deleteBook?.let { book -> + ConfirmDialog( + title = "Történet törlése", + text = "„${book.title}” és minden fejezete véglegesen törlődik a telefonról és a felhőből. Ez nem vonható vissza.", + confirm = "Végleges törlés", + destructive = true, + onConfirm = { + scope.runAction(snackbar, "Történet törölve.") { AppGraph.writing.deleteBook(book.id) } + }, + onDismiss = { deleteBook = null }, + ) + } if (postNews) PostNewsDialog(null, "", onDismiss = { postNews = false }, onPosted = { loader.reload() }) deleteNews?.let { id -> ConfirmDialog("Hír törlése", "Törlöd ezt a hírt? Az Újdonságok közül is eltűnik.", "Törlés", destructive = true, diff --git a/app/src/main/java/hu/regelo/app/ui/screens/WriteScreen.kt b/app/src/main/java/hu/regelo/app/ui/screens/WriteScreen.kt index 7bd6e2d..7589619 100644 --- a/app/src/main/java/hu/regelo/app/ui/screens/WriteScreen.kt +++ b/app/src/main/java/hu/regelo/app/ui/screens/WriteScreen.kt @@ -19,8 +19,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.MenuBook -import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.AutoStories import androidx.compose.material.icons.filled.BarChart import androidx.compose.material.icons.filled.CloudDone import androidx.compose.material.icons.filled.CloudOff @@ -31,20 +29,17 @@ import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.SaveAlt import androidx.compose.material.icons.filled.Sync -import androidx.compose.material.icons.filled.UploadFile import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -71,15 +66,11 @@ import hu.regelo.app.data.Genres import hu.regelo.app.data.LocalBook import hu.regelo.app.data.PublishStatus import hu.regelo.app.ui.components.BookCover -import hu.regelo.app.ui.components.ConfirmDialog -import hu.regelo.app.ui.components.EmptyState import hu.regelo.app.ui.components.LocalSnackbar import hu.regelo.app.ui.components.Pill -import hu.regelo.app.ui.components.runAction import hu.regelo.app.ui.nav.Navigator import hu.regelo.app.ui.nav.Route import hu.regelo.app.util.formatIsoDay -import hu.regelo.app.util.friendlyMessage import hu.regelo.app.util.relativeTime import kotlinx.coroutines.launch @@ -87,132 +78,88 @@ import kotlinx.coroutines.launch fun WriteScreen(nav: Navigator) { var openChallenges by remember { mutableStateOf>(emptyList()) } LaunchedEffect(Unit) { openChallenges = runCatching { AppGraph.community.openForSubmission() }.getOrDefault(emptyList()) } - val snackbar = LocalSnackbar.current val scope = rememberCoroutineScope() - val books by AppGraph.writing.myBooks().collectAsState(initial = emptyList()) val sync by AppGraph.writing.syncState.collectAsState() val todayWords by AppGraph.writing.todayWords().collectAsState(initial = 0) val settings by AppGraph.settings.settings.collectAsState(initial = AppSettings()) - var newBook by remember { mutableStateOf(false) } var goalDialog by remember { mutableStateOf(false) } - var deleteTarget by remember { mutableStateOf(null) } - var exportTarget by remember { mutableStateOf(null) } - Box(Modifier.fillMaxSize()) { - Column(Modifier.fillMaxSize()) { - TopAppBar( - title = { Text("Írás", style = MaterialTheme.typography.headlineSmall) }, - actions = { - SyncBadge(sync.syncing, sync.pending, sync.offline, sync.error) { AppGraph.writing.requestSync(immediate = true) } - IconButton(onClick = { nav.push(Route.Import(null)) }) { Icon(Icons.Default.UploadFile, "Dokumentum importálása") } - IconButton(onClick = { nav.push(Route.SeriesManager) }) { Icon(Icons.Default.AutoStories, "Sorozataim") } - }, - colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.background), - ) - LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 96.dp)) { - // Most nevezhető írói kihívás felhívása - openChallenges.firstOrNull()?.let { ch -> - item { - Card( - onClick = { nav.push(Route.ChallengeDetail(ch.id)) }, - modifier = Modifier.fillMaxWidth().padding(16.dp, 4.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), - ) { - Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Default.EmojiEvents, null, tint = MaterialTheme.colorScheme.onPrimaryContainer) - Spacer(Modifier.width(12.dp)) - Column(Modifier.weight(1f)) { - Text("Írói kihívás: ${ch.title}", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onPrimaryContainer) - Text(phaseLine(ch, hu.regelo.app.data.ChallengePhase.SUBMISSION) + " – nevezz be egy műveddel!", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onPrimaryContainer) - } - } - } + Column(Modifier.fillMaxSize()) { + TopAppBar( + title = { Text("Írás", style = MaterialTheme.typography.headlineSmall) }, + actions = { + SyncBadge(sync.syncing, sync.pending, sync.offline, sync.error) { AppGraph.writing.requestSync(immediate = true) } + }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = MaterialTheme.colorScheme.background), + ) + LazyColumn(Modifier.fillMaxSize(), contentPadding = PaddingValues(bottom = 96.dp)) { + item { + Card( + onClick = { nav.select(hu.regelo.app.ui.nav.Tab.ME) }, + modifier = Modifier.fillMaxWidth().padding(16.dp, 8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer), + ) { + Column(Modifier.padding(16.dp)) { + Text("Saját történetek és piszkozatok", style = MaterialTheme.typography.titleMedium) + Spacer(Modifier.height(4.dp)) + Text( + "A műveid, importálás, sorozatok és szerkesztési eszközök a Profil oldalon találhatók.", + style = MaterialTheme.typography.bodyMedium, + ) } } + } + openChallenges.firstOrNull()?.let { ch -> item { Card( - onClick = { goalDialog = true }, + onClick = { nav.push(Route.ChallengeDetail(ch.id)) }, modifier = Modifier.fillMaxWidth().padding(16.dp, 4.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), ) { - Column(Modifier.padding(16.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text("Mai írás", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) - Text("$todayWords / ${settings.dailyGoal} szó", style = MaterialTheme.typography.labelLarge) + Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.EmojiEvents, null, tint = MaterialTheme.colorScheme.onPrimaryContainer) + Spacer(Modifier.width(12.dp)) + Column(Modifier.weight(1f)) { + Text("Írói kihívás: ${ch.title}", style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onPrimaryContainer) + Text( + phaseLine(ch, hu.regelo.app.data.ChallengePhase.SUBMISSION) + " – nevezz be egy műveddel!", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) } - Spacer(Modifier.height(8.dp)) - LinearProgressIndicator( - progress = { (todayWords.toFloat() / settings.dailyGoal.coerceAtLeast(1)).coerceIn(0f, 1f) }, - modifier = Modifier.fillMaxWidth().height(6.dp), - color = MaterialTheme.colorScheme.secondary, - ) - Spacer(Modifier.height(6.dp)) - Text( - if (todayWords >= settings.dailyGoal) "Megvan a mai cél – szép munka! ✨" else "Koppints a napi cél módosításához.", - style = MaterialTheme.typography.bodySmall, - ) } } } - if (books.isEmpty()) { - item { - EmptyState( - Icons.AutoMirrored.Filled.MenuBook, "Kezdd el az első történeted", - "Írhatsz itt a telefonon, vagy feltölthetsz egy kész kéziratot (DOCX, EPUB, PDF, TXT) – automatikusan fejezetekre bontjuk.", - ) { - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Button(onClick = { newBook = true }) { Text("Új történet") } - OutlinedButton(onClick = { nav.push(Route.Import(null)) }) { Text("Importálás") } - } + } + item { + Card( + onClick = { goalDialog = true }, + modifier = Modifier.fillMaxWidth().padding(16.dp, 4.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer), + ) { + Column(Modifier.padding(16.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Mai írás", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + Text("$todayWords / ${settings.dailyGoal} szó", style = MaterialTheme.typography.labelLarge) } - } - } else { - item { Text("Történeteim (${books.size})", style = MaterialTheme.typography.titleLarge, modifier = Modifier.padding(16.dp, 16.dp, 16.dp, 4.dp)) } - items(books, key = { it.id }) { b -> - MyBookCard( - b, - onOpen = { nav.push(Route.BookEditor(b.id)) }, - onPreview = { nav.push(Route.Reader(b.id)) }, - onStats = { nav.push(Route.Stats(b.id)) }, - onWorld = { nav.push(Route.World(b.id)) }, - onExport = { exportTarget = b }, - onDelete = { deleteTarget = b }, + Spacer(Modifier.height(8.dp)) + LinearProgressIndicator( + progress = { (todayWords.toFloat() / settings.dailyGoal.coerceAtLeast(1)).coerceIn(0f, 1f) }, + modifier = Modifier.fillMaxWidth().height(6.dp), + color = MaterialTheme.colorScheme.secondary, + ) + Spacer(Modifier.height(6.dp)) + Text( + if (todayWords >= settings.dailyGoal) "Megvan a mai cél – szép munka! ✨" else "Koppints a napi cél módosításához.", + style = MaterialTheme.typography.bodySmall, ) } } } } - ExtendedFloatingActionButton( - onClick = { newBook = true }, - icon = { Icon(Icons.Default.Add, null) }, - text = { Text("Új történet") }, - containerColor = MaterialTheme.colorScheme.secondary, - contentColor = MaterialTheme.colorScheme.onSecondary, - modifier = Modifier.align(Alignment.BottomEnd).padding(20.dp), - ) } - if (newBook) NewBookDialog(onDismiss = { newBook = false }) { title, genre, description -> - scope.launch { - try { - val b = AppGraph.writing.createBook(title, genre, description) - val firstChapter = AppGraph.writing.chapterList(b.id).firstOrNull() - if (firstChapter != null) nav.push(Route.ChapterEditor(b.id, firstChapter.id)) - else nav.push(Route.BookEditor(b.id)) - } catch (e: Exception) { snackbar.showSnackbar(e.friendlyMessage()) } - } - } if (goalDialog) GoalDialog(settings.dailyGoal, onDismiss = { goalDialog = false }) { g -> scope.launch { AppGraph.settings.setDailyGoal(g) } } - exportTarget?.let { b -> ExportDialog(b.id, b.title, onDismiss = { exportTarget = null }) } - deleteTarget?.let { b -> - ConfirmDialog( - title = "Törlöd a történetet?", - text = "„${b.title}” és minden fejezete véglegesen törlődik – a telefonról és a felhőből is. Ez nem vonható vissza.", - confirm = "Végleges törlés", destructive = true, - onConfirm = { scope.runAction(snackbar, "Történet törölve.") { AppGraph.writing.deleteBook(b.id) } }, - onDismiss = { deleteTarget = null }, - ) - } } @Composable @@ -242,7 +189,7 @@ fun SyncBadge(syncing: Boolean, pending: Int, offline: Boolean, error: String?, } @Composable -private fun MyBookCard(b: LocalBook, onOpen: () -> Unit, onPreview: () -> Unit, onStats: () -> Unit, onWorld: () -> Unit, onExport: () -> Unit, onDelete: () -> Unit) { +internal fun MyBookCard(b: LocalBook, onOpen: () -> Unit, onPreview: () -> Unit, onStats: () -> Unit, onWorld: () -> Unit, onExport: () -> Unit, onDelete: () -> Unit) { var menu by remember { mutableStateOf(false) } Card( onClick = onOpen, @@ -286,7 +233,7 @@ private fun MyBookCard(b: LocalBook, onOpen: () -> Unit, onPreview: () -> Unit, } @Composable -private fun NewBookDialog(onDismiss: () -> Unit, onCreate: (String, String, String) -> Unit) { +internal fun NewBookDialog(onDismiss: () -> Unit, onCreate: (String, String, String) -> Unit) { var title by remember { mutableStateOf("") } var genre by remember { mutableStateOf("") } var description by remember { mutableStateOf("") } diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml index 7e1bcca..bc962ef 100644 --- a/app/src/main/res/xml/backup_rules.xml +++ b/app/src/main/res/xml/backup_rules.xml @@ -1,5 +1,5 @@ - diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml index ca99311..fb449dc 100644 --- a/app/src/main/res/xml/data_extraction_rules.xml +++ b/app/src/main/res/xml/data_extraction_rules.xml @@ -1,5 +1,5 @@ - diff --git a/app/src/test/java/hu/regelo/app/EncryptedLocalDatabaseTest.kt b/app/src/test/java/hu/regelo/app/EncryptedLocalDatabaseTest.kt new file mode 100644 index 0000000..5cf9772 --- /dev/null +++ b/app/src/test/java/hu/regelo/app/EncryptedLocalDatabaseTest.kt @@ -0,0 +1,26 @@ +package hu.regelo.app + +import hu.regelo.app.data.EncryptedLocalDatabase +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class EncryptedLocalDatabaseTest { + @Test + fun detectsPlaintextSqliteHeaderOnly() { + val database = File.createTempFile("regelo-db-header", ".db") + try { + database.writeBytes("SQLite format 3\u0000".toByteArray(Charsets.US_ASCII) + ByteArray(32)) + assertTrue(EncryptedLocalDatabase.hasSqliteHeader(database)) + + database.writeBytes(ByteArray(48)) + assertFalse(EncryptedLocalDatabase.hasSqliteHeader(database)) + + database.writeBytes("SQLite".toByteArray(Charsets.US_ASCII)) + assertFalse(EncryptedLocalDatabase.hasSqliteHeader(database)) + } finally { + database.delete() + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 636c34a..2dc5f2c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,6 +19,8 @@ pdfbox = "2.0.27.0" junit = "4.13.2" exifinterface = "1.4.2" fragment = "1.9.1" +sqlite = "2.7.0" +sqlcipher = "4.19.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -36,6 +38,8 @@ androidx-lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-p androidx-room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } androidx-room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +androidx-sqlite = { group = "androidx.sqlite", name = "sqlite", version.ref = "sqlite" } +zetetic-sqlcipher = { group = "net.zetetic", name = "sqlcipher-android", version.ref = "sqlcipher" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "coroutines" }