From a2ec12aa086b3f927d990720ab742f8fced29e02 Mon Sep 17 00:00:00 2001 From: talh4tr Date: Mon, 28 Sep 2026 16:12:37 +0300 Subject: [PATCH 1/5] =?UTF-8?q?Kasa=20meta=20dosyas=C4=B1n=C4=B1=20silmede?= =?UTF-8?q?n=20yenile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Meta yazılırken mevcut dosya, geçici dosya hedefe taşınmadan önce siliniyordu. Önce doğrudan replacement dene; desteklenmezse mevcut fallback yoluna geç. --- .../kotlin/dev/stade/security/FileVaultTest.kt | 18 ++++++++++++++++++ .../kotlin/dev/stade/security/FileVault.kt | 1 + 2 files changed, 19 insertions(+) diff --git a/composeApp/src/desktopTest/kotlin/dev/stade/security/FileVaultTest.kt b/composeApp/src/desktopTest/kotlin/dev/stade/security/FileVaultTest.kt index 530e78a..5cbb596 100644 --- a/composeApp/src/desktopTest/kotlin/dev/stade/security/FileVaultTest.kt +++ b/composeApp/src/desktopTest/kotlin/dev/stade/security/FileVaultTest.kt @@ -8,6 +8,24 @@ import kotlin.test.assertTrue class FileVaultTest { + @Test + fun rewritesVaultMetaWithoutLeavingTemporaryFile() { + val root = Files.createTempDirectory("stade-vault-test") + try { + val vault = FileVault(root.toFile()) + vault.setup("1234") + vault.setScrambleKeypadEnabled(true) + + assertFalse(root.resolve("stade.vault.tmp").toFile().exists()) + + val reopened = FileVault(root.toFile()) + assertTrue(reopened.unlock("1234") is UnlockOutcome.Success) + assertTrue(reopened.isScrambleKeypadEnabled()) + } finally { + root.toFile().deleteRecursively() + } + } + @Test fun flushAndCloseEncryptsThenRemovesPlaintextDatabase() { val root = Files.createTempDirectory("stade-vault-test") diff --git a/composeApp/src/jvmCommonMain/kotlin/dev/stade/security/FileVault.kt b/composeApp/src/jvmCommonMain/kotlin/dev/stade/security/FileVault.kt index 090d08a..a1a0ed4 100644 --- a/composeApp/src/jvmCommonMain/kotlin/dev/stade/security/FileVault.kt +++ b/composeApp/src/jvmCommonMain/kotlin/dev/stade/security/FileVault.kt @@ -492,6 +492,7 @@ class FileVault(private val rootDir: File) : Vault { buf.put(meta.duressVerifierCipher) val tmp = File(metaFile.parentFile, metaFile.name + ".tmp") tmp.writeBytes(out) + if (tmp.renameTo(metaFile)) return if (metaFile.exists()) metaFile.delete() if (!tmp.renameTo(metaFile)) { tmp.copyTo(metaFile, overwrite = true) From e0d0a5f8f98355e2b3275542e7ad09dc7065e0d6 Mon Sep 17 00:00:00 2001 From: noydra Date: Mon, 28 Sep 2026 17:36:24 +0300 Subject: [PATCH 2/5] Put restored files in place without removing the old one first Restoring a backup deleted each existing file before writing its replacement, so an interruption at that moment could leave neither the old account nor the restored one. The replacement is now moved into place in a single step, falling back to the previous approach only where the system will not replace a file that way. Restoring onto a device that already held an account had no test of its own, which is the only situation where these files are overwritten, so one was added along with a check that no partial files are left behind. --- .../dev/stade/backup/StadeBackupTest.kt | 36 +++++++++++++++++++ .../kotlin/dev/stade/backup/StadeBackup.kt | 1 + 2 files changed, 37 insertions(+) diff --git a/composeApp/src/desktopTest/kotlin/dev/stade/backup/StadeBackupTest.kt b/composeApp/src/desktopTest/kotlin/dev/stade/backup/StadeBackupTest.kt index 5053168..fe81a3c 100644 --- a/composeApp/src/desktopTest/kotlin/dev/stade/backup/StadeBackupTest.kt +++ b/composeApp/src/desktopTest/kotlin/dev/stade/backup/StadeBackupTest.kt @@ -80,6 +80,42 @@ class StadeBackupTest { assertContentEquals(f.onion, File(f.targets.onionKeyPath).readBytes()) } + @Test + fun restoringOverAPopulatedInstallReplacesEveryFile() { + val f = Fixture() + f.seed() + val archive = f.export("a-long-enough-passphrase") + + File(f.targets.vaultMetaPath).writeBytes(Random(41).nextBytes(180)) + File(f.targets.encryptedDbPath).writeBytes(Random(42).nextBytes(9_000)) + File(f.targets.onionKeyPath).writeBytes("ED25519-V3:SOMETHINGELSE==".encodeToByteArray()) + + val outcome = f.restore(archive, "a-long-enough-passphrase") + assertTrue(outcome is RestoreOutcome.Restored, "expected a restore, got $outcome") + + assertContentEquals(f.meta, File(f.targets.vaultMetaPath).readBytes()) + assertContentEquals(f.db, File(f.targets.encryptedDbPath).readBytes()) + assertContentEquals(f.onion, File(f.targets.onionKeyPath).readBytes()) + } + + @Test + fun aRestoreLeavesNoHalfWrittenFilesBehind() { + val f = Fixture() + f.seed() + val archive = f.export("a-long-enough-passphrase") + f.clearInstall() + + assertTrue(f.restore(archive, "a-long-enough-passphrase") is RestoreOutcome.Restored) + + val leftovers = File(f.targets.vaultMetaPath).parentFile + .walkTopDown() + .filter { it.isFile && it.name.endsWith(".restore") } + .toList() + assertTrue(leftovers.isEmpty(), "restore temporaries were left behind: $leftovers") + assertTrue(File(f.targets.vaultMetaPath).isFile) + assertTrue(File(f.targets.encryptedDbPath).isFile) + } + @Test fun theStalePlaintextDatabaseIsRemovedOnRestore() { val f = Fixture() diff --git a/composeApp/src/jvmCommonMain/kotlin/dev/stade/backup/StadeBackup.kt b/composeApp/src/jvmCommonMain/kotlin/dev/stade/backup/StadeBackup.kt index e23baff..8cbd197 100644 --- a/composeApp/src/jvmCommonMain/kotlin/dev/stade/backup/StadeBackup.kt +++ b/composeApp/src/jvmCommonMain/kotlin/dev/stade/backup/StadeBackup.kt @@ -127,6 +127,7 @@ object StadeBackup { } private fun promote(tmp: File, dest: File): Boolean { + if (tmp.renameTo(dest)) return true if (dest.exists() && !dest.delete()) return false if (tmp.renameTo(dest)) return true return runCatching { From 4fc41db8865c91d098c2d90c25aada36168284d9 Mon Sep 17 00:00:00 2001 From: noydra Date: Mon, 28 Sep 2026 17:36:35 +0300 Subject: [PATCH 3/5] Bring sticker packs in from other messaging apps Stickers can now be brought over from elsewhere rather than made one at a time. A sticker pack file keeps its own name and arrives as a group, and loose sticker images can be picked several at a time. On a phone, sharing a sticker to Stade from another app works too, and a sticker shared while the app is locked is still waiting once it is unlocked. Stickers now belong to packs, so a whole pack can be removed in one go, and the collection is grouped by where each sticker came from. Anything already in the collection is recognised and skipped, so bringing the same pack in twice adds nothing, and a short summary afterwards says what arrived, what was already there, and what could not be read. Moving stickers are also handled properly now. Stade only recognised one kind of moving picture, so the moving stickers these apps actually use stopped on their first frame. They now play. --- .../src/androidMain/AndroidManifest.xml | 5 + .../kotlin/dev/stade/MainActivity.kt | 43 +++++ .../kotlin/dev/stade/StadeApplication.kt | 11 ++ .../dev/stade/ui/StickerImportIo.android.kt | 71 ++++++++ .../ui/components/AnimatedImage.android.kt | 2 +- .../kotlin/dev/stade/AppContainer.kt | 19 ++ .../kotlin/dev/stade/BootContext.kt | 3 + .../kotlin/dev/stade/sticker/Sticker.kt | 12 +- .../kotlin/dev/stade/sticker/StickerImport.kt | 132 ++++++++++++++ .../dev/stade/sticker/StickerManager.kt | 107 +++++++++++- .../kotlin/dev/stade/ui/StadeApp.kt | 15 ++ .../kotlin/dev/stade/ui/StickerImportIo.kt | 17 ++ .../dev/stade/ui/components/AnimatedImage.kt | 3 + .../stade/ui/components/EmojiStickerDrawer.kt | 145 ++++++++++++++-- .../components/StickerImportResultDialog.kt | 57 ++++++ .../kotlin/dev/stade/ui/i18n/AppStrings.kt | 10 ++ .../kotlin/dev/stade/ui/i18n/ArabicStrings.kt | 10 ++ .../dev/stade/ui/i18n/EnglishStrings.kt | 10 ++ .../kotlin/dev/stade/ui/i18n/KoreanStrings.kt | 10 ++ .../dev/stade/ui/i18n/PersianStrings.kt | 10 ++ .../dev/stade/ui/i18n/TurkishStrings.kt | 10 ++ .../kotlin/dev/stade/ui/screens/ChatScreen.kt | 24 ++- .../dev/stade/ui/screens/GroupChatScreen.kt | 24 ++- .../dev/stade/ui/screens/StadiumScreen.kt | 24 ++- .../sqldelight/dev/stade/db/StadeDb.sq | 39 ++++- .../dev/stade/ui/StickerImportIo.desktop.kt | 45 +++++ .../ui/components/AnimatedImage.desktop.kt | 2 +- .../sticker/StickerImportEndToEndTest.kt | 163 ++++++++++++++++++ .../dev/stade/sticker/StickerImportTest.kt | 154 +++++++++++++++++ .../dev/stade/sticker/StickerArchive.kt | 91 ++++++++++ i18n/ar.json | 10 ++ i18n/en.json | 10 ++ i18n/fa.json | 10 ++ i18n/ko.json | 10 ++ i18n/tr.json | 10 ++ 35 files changed, 1295 insertions(+), 23 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/dev/stade/ui/StickerImportIo.android.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/stade/sticker/StickerImport.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/stade/ui/StickerImportIo.kt create mode 100644 composeApp/src/commonMain/kotlin/dev/stade/ui/components/StickerImportResultDialog.kt create mode 100644 composeApp/src/desktopMain/kotlin/dev/stade/ui/StickerImportIo.desktop.kt create mode 100644 composeApp/src/desktopTest/kotlin/dev/stade/sticker/StickerImportEndToEndTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/dev/stade/sticker/StickerImportTest.kt create mode 100644 composeApp/src/jvmCommonMain/kotlin/dev/stade/sticker/StickerArchive.kt diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index 7bf1805..f689ba5 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -57,6 +57,11 @@ + + + + + intent.data Intent.ACTION_SEND -> extractStreamUri(intent) @@ -95,10 +100,48 @@ class MainActivity : FragmentActivity() { } if (!text.isNullOrBlank() && text.startsWith("STADE2-")) { app.handleOpenInviteIntent(text) + } else { + handleSharedStickers(listOf(uri)) } } } + private fun handleSharedStickers(uris: List) { + if (uris.isEmpty()) return + val app = application as StadeApplication + lifecycleScope.launch(Dispatchers.IO) { + val files = uris.mapNotNull { readSharedSticker(it) } + if (files.isNotEmpty()) app.handleStickerFilesIntent(files) + } + } + + private fun readSharedSticker(uri: Uri): dev.stade.ui.PickedStickerFile? = runCatching { + val name = contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { cursor -> if (cursor.moveToFirst()) cursor.getString(0) else null } + ?: uri.lastPathSegment ?: "sticker" + val bytes = contentResolver.openInputStream(uri)?.use { input -> + val out = ByteArrayOutputStream() + val buffer = ByteArray(64 * 1024) + while (true) { + val read = input.read(buffer) + if (read < 0) break + if (out.size() + read > dev.stade.sticker.MAX_IMPORT_ARCHIVE_BYTES) return@use null + out.write(buffer, 0, read) + } + out.toByteArray() + } ?: return null + if (!dev.stade.sticker.looksLikeSticker(bytes) && !dev.stade.sticker.isZipArchive(bytes)) return null + dev.stade.ui.PickedStickerFile(name, bytes) + }.getOrNull() + + @Suppress("DEPRECATION") + private fun extractStreamUris(intent: Intent): List = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java) + } else { + intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM) + }.orEmpty() + @Suppress("DEPRECATION") private fun extractStreamUri(intent: Intent): Uri? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { diff --git a/composeApp/src/androidMain/kotlin/dev/stade/StadeApplication.kt b/composeApp/src/androidMain/kotlin/dev/stade/StadeApplication.kt index e0f8fb1..754cdda 100644 --- a/composeApp/src/androidMain/kotlin/dev/stade/StadeApplication.kt +++ b/composeApp/src/androidMain/kotlin/dev/stade/StadeApplication.kt @@ -31,6 +31,7 @@ class StadeApplication : Application() { private var pendingGroupAtBoot: String? = null private var pendingGoHomeAtBoot: Boolean = false private var pendingInviteAtBoot: String? = null + private var pendingStickersAtBoot: List = emptyList() fun handleOpenChatIntent(contactId: String) { val c = activeContainer @@ -42,6 +43,12 @@ class StadeApplication : Application() { if (c != null) c.pendingInvite.value = inviteText else pendingInviteAtBoot = inviteText } + fun handleStickerFilesIntent(files: List) { + if (files.isEmpty()) return + val c = activeContainer + if (c != null) c.pendingStickerImport.value = files else pendingStickersAtBoot = files + } + fun handleOpenStadiumIntent(stadiumId: String) { val c = activeContainer if (c != null) c.pendingOpenStadium.value = stadiumId else pendingStadiumAtBoot = stadiumId @@ -98,6 +105,10 @@ class StadeApplication : Application() { } pendingInviteAtBoot?.let { c.pendingInvite.value = it } pendingInviteAtBoot = null + if (pendingStickersAtBoot.isNotEmpty()) { + c.pendingStickerImport.value = pendingStickersAtBoot + pendingStickersAtBoot = emptyList() + } } ) var startedCount = 0 diff --git a/composeApp/src/androidMain/kotlin/dev/stade/ui/StickerImportIo.android.kt b/composeApp/src/androidMain/kotlin/dev/stade/ui/StickerImportIo.android.kt new file mode 100644 index 0000000..0b510ec --- /dev/null +++ b/composeApp/src/androidMain/kotlin/dev/stade/ui/StickerImportIo.android.kt @@ -0,0 +1,71 @@ +package dev.stade.ui + +import android.net.Uri +import android.provider.OpenableColumns +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalContext +import dev.stade.sticker.MAX_IMPORT_ARCHIVE_BYTES +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +actual class StickerImportLauncher(private val onPick: () -> Unit) { + actual fun pickFiles() = onPick() +} + +@Composable +actual fun rememberStickerImportLauncher( + onPicked: (List) -> Unit +): StickerImportLauncher { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val callback by rememberUpdatedState(onPicked) + + val open = rememberLauncherForActivityResult( + ActivityResultContracts.OpenMultipleDocuments() + ) { uris -> + if (uris.isNullOrEmpty()) { + callback(emptyList()) + return@rememberLauncherForActivityResult + } + scope.launch { + val files = withContext(Dispatchers.IO) { + uris.mapNotNull { uri -> readPicked(context.contentResolver, uri) } + } + callback(files) + } + } + + return remember(open) { + StickerImportLauncher { open.launch(arrayOf("*/*")) } + } +} + +private fun readPicked( + resolver: android.content.ContentResolver, + uri: Uri +): PickedStickerFile? = runCatching { + val name = resolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else null + } ?: uri.lastPathSegment ?: "sticker" + + val bytes = resolver.openInputStream(uri)?.use { input -> + val out = java.io.ByteArrayOutputStream() + val buffer = ByteArray(64 * 1024) + while (true) { + val read = input.read(buffer) + if (read < 0) break + if (out.size() + read > MAX_IMPORT_ARCHIVE_BYTES) return@use null + out.write(buffer, 0, read) + } + out.toByteArray() + } ?: return null + + PickedStickerFile(name, bytes) +}.getOrNull() diff --git a/composeApp/src/androidMain/kotlin/dev/stade/ui/components/AnimatedImage.android.kt b/composeApp/src/androidMain/kotlin/dev/stade/ui/components/AnimatedImage.android.kt index b305d6d..aa44d7d 100644 --- a/composeApp/src/androidMain/kotlin/dev/stade/ui/components/AnimatedImage.android.kt +++ b/composeApp/src/androidMain/kotlin/dev/stade/ui/components/AnimatedImage.android.kt @@ -27,7 +27,7 @@ actual fun AnimatedImage( contentScale: ContentScale ) { val playable = remember(bytes) { - isGifBytes(bytes) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P + isAnimatedBytes(bytes) && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P } if (!playable) { StaticImageBytes(bytes, contentDescription, modifier, contentScale) diff --git a/composeApp/src/commonMain/kotlin/dev/stade/AppContainer.kt b/composeApp/src/commonMain/kotlin/dev/stade/AppContainer.kt index 6ac5bdc..bf5b319 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/AppContainer.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/AppContainer.kt @@ -142,6 +142,21 @@ class AppContainer( runCatching { createdDriver.execute(null, "CREATE TABLE IF NOT EXISTS Sticker (id TEXT NOT NULL PRIMARY KEY, ownerId TEXT NOT NULL, imageBytes BLOB NOT NULL, createdAt INTEGER NOT NULL)", 0) } runCatching { createdDriver.execute(null, "CREATE INDEX IF NOT EXISTS idxSticker ON Sticker(ownerId)", 0) } } + runCatching { + createdDriver.executeQuery(null, "SELECT packId, contentHash FROM Sticker LIMIT 0", + { _: SqlCursor -> QueryResult.Value(Unit) }, 0) + }.onFailure { + runCatching { createdDriver.execute(null, "ALTER TABLE Sticker ADD COLUMN packId TEXT", 0) } + runCatching { createdDriver.execute(null, "ALTER TABLE Sticker ADD COLUMN contentHash TEXT NOT NULL DEFAULT ''", 0) } + runCatching { createdDriver.execute(null, "CREATE INDEX IF NOT EXISTS idxStickerPackRef ON Sticker(packId)", 0) } + } + runCatching { + createdDriver.executeQuery(null, "SELECT id FROM StickerPack LIMIT 0", + { _: SqlCursor -> QueryResult.Value(Unit) }, 0) + }.onFailure { + runCatching { createdDriver.execute(null, "CREATE TABLE IF NOT EXISTS StickerPack (id TEXT NOT NULL PRIMARY KEY, ownerId TEXT NOT NULL, title TEXT NOT NULL, author TEXT NOT NULL DEFAULT '', origin TEXT NOT NULL DEFAULT '', createdAt INTEGER NOT NULL)", 0) } + runCatching { createdDriver.execute(null, "CREATE INDEX IF NOT EXISTS idxStickerPack ON StickerPack(ownerId)", 0) } + } runCatching { createdDriver.executeQuery(null, "SELECT sessionId FROM VanishSession LIMIT 0", { _: SqlCursor -> QueryResult.Value(Unit) }, 0) @@ -248,6 +263,8 @@ class AppContainer( val pendingGoHome = MutableStateFlow(false) + val pendingStickerImport = MutableStateFlow>(emptyList()) + val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Default + backgroundFailures) @Volatile var isClosed = false @@ -259,6 +276,7 @@ class AppContainer( pendingInvite.value = null pendingOpenChat.value = null pendingGoHome.value = false + pendingStickerImport.value = emptyList() activeContactId = null runCatching { db.stadeDbQueries.transaction { @@ -278,6 +296,7 @@ class AppContainer( db.stadeDbQueries.wipeKeyValue() db.stadeDbQueries.wipeProcessedEnvelope() db.stadeDbQueries.wipeStickers() + db.stadeDbQueries.wipeStickerPacks() db.stadeDbQueries.wipeReactions() db.stadeDbQueries.wipeVanishSessions() db.stadeDbQueries.wipeScheduledMessages() diff --git a/composeApp/src/commonMain/kotlin/dev/stade/BootContext.kt b/composeApp/src/commonMain/kotlin/dev/stade/BootContext.kt index 25a2069..039e01a 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/BootContext.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/BootContext.kt @@ -71,5 +71,8 @@ class BootContext( from.pendingOpenStadium.value?.let { to.pendingOpenStadium.value = it } from.pendingOpenGroup.value?.let { to.pendingOpenGroup.value = it } if (from.pendingGoHome.value) to.pendingGoHome.value = true + if (from.pendingStickerImport.value.isNotEmpty()) { + to.pendingStickerImport.value = from.pendingStickerImport.value + } } } diff --git a/composeApp/src/commonMain/kotlin/dev/stade/sticker/Sticker.kt b/composeApp/src/commonMain/kotlin/dev/stade/sticker/Sticker.kt index 9ba7ccf..01f9a1e 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/sticker/Sticker.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/sticker/Sticker.kt @@ -4,8 +4,18 @@ data class Sticker( val id: String, val ownerId: String, val imageBytes: ByteArray, - val createdAt: Long + val createdAt: Long, + val packId: String? = null ) { override fun equals(other: Any?): Boolean = other is Sticker && other.id == id override fun hashCode(): Int = id.hashCode() } + +data class StickerPack( + val id: String, + val ownerId: String, + val title: String, + val author: String, + val origin: String, + val createdAt: Long +) diff --git a/composeApp/src/commonMain/kotlin/dev/stade/sticker/StickerImport.kt b/composeApp/src/commonMain/kotlin/dev/stade/sticker/StickerImport.kt new file mode 100644 index 0000000..6ca16d0 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/stade/sticker/StickerImport.kt @@ -0,0 +1,132 @@ +package dev.stade.sticker + +const val MAX_IMPORT_ENTRIES = 120 +const val MAX_IMPORT_ENTRY_BYTES = 2 * 1024 * 1024 +const val MAX_IMPORT_ARCHIVE_BYTES = 64L * 1024 * 1024 + +class ImportedImage(val name: String, val bytes: ByteArray) + +class ImportedPack( + val title: String, + val author: String, + val images: List +) + +enum class ImportRejection { NotReadable, Empty, TooLarge } + +sealed interface ImportSourceOutcome { + data class Pack(val pack: ImportedPack) : ImportSourceOutcome + data class Rejected(val reason: ImportRejection) : ImportSourceOutcome +} + +class ImportSummary( + val packTitle: String, + val added: Int, + val duplicates: Int, + val unreadable: Int +) { + val isEmpty: Boolean get() = added == 0 +} + +fun isZipArchive(bytes: ByteArray): Boolean = + bytes.size >= 4 && + bytes[0] == 0x50.toByte() && bytes[1] == 0x4B.toByte() && + (bytes[2] == 0x03.toByte() || bytes[2] == 0x05.toByte() || bytes[2] == 0x07.toByte()) + +fun isWebpBytes(bytes: ByteArray): Boolean { + if (bytes.size < 12) return false + val riff = bytes.copyOfRange(0, 4).decodeToString() + val webp = bytes.copyOfRange(8, 12).decodeToString() + return riff == "RIFF" && webp == "WEBP" +} + +fun isAnimatedWebpBytes(bytes: ByteArray): Boolean { + if (!isWebpBytes(bytes)) return false + if (bytes.size < 21) return false + val fourth = bytes.copyOfRange(12, 16).decodeToString() + if (fourth != "VP8X") return false + return (bytes[20].toInt() and 0x02) != 0 +} + +fun isPngBytes(bytes: ByteArray): Boolean = + bytes.size >= 8 && + bytes[0] == 0x89.toByte() && bytes[1] == 0x50.toByte() && + bytes[2] == 0x4E.toByte() && bytes[3] == 0x47.toByte() + +fun isJpegBytes(bytes: ByteArray): Boolean = + bytes.size >= 3 && + bytes[0] == 0xFF.toByte() && bytes[1] == 0xD8.toByte() && bytes[2] == 0xFF.toByte() + +fun looksLikeSticker(bytes: ByteArray): Boolean = + isWebpBytes(bytes) || isPngBytes(bytes) || isJpegBytes(bytes) || + dev.stade.ui.components.isGifBytes(bytes) + +fun keepsItsOwnFrames(bytes: ByteArray): Boolean = + dev.stade.ui.components.isGifBytes(bytes) || isAnimatedWebpBytes(bytes) + +fun packTitleFromFileName(fileName: String): String { + val stripped = fileName.substringAfterLast('/').substringBeforeLast('.') + val cleaned = stripped.replace('_', ' ').replace('-', ' ').trim() + return cleaned.ifBlank { "Stickers" } +} + +fun normalizeStickerBytes(bytes: ByteArray): ByteArray? { + if (bytes.isEmpty() || bytes.size > MAX_IMPORT_ENTRY_BYTES) return null + if (!looksLikeSticker(bytes)) return null + if (keepsItsOwnFrames(bytes)) return bytes + val encoded = runCatching { dev.stade.media.encodeStickerPng(bytes) }.getOrNull() ?: return null + return if (isPngBytes(encoded) && encoded.isNotEmpty()) encoded else null +} + +expect fun readStickerArchive(fileName: String, bytes: ByteArray): ImportSourceOutcome + +class ImportResult( + val packs: List, + val added: Int, + val duplicates: Int, + val unreadable: Int +) + +object StickerImporter { + + fun importFiles( + manager: StickerManager, + ownerId: String, + files: List, + normalize: (ByteArray) -> ByteArray? = ::normalizeStickerBytes + ): ImportResult { + val packs = mutableListOf() + var added = 0 + var duplicates = 0 + var unreadable = 0 + val loose = mutableListOf() + + for (file in files) { + if (isZipArchive(file.bytes)) { + when (val outcome = readStickerArchive(file.name, file.bytes)) { + is ImportSourceOutcome.Pack -> { + val summary = manager.importPack(ownerId, outcome.pack, normalize) + if (summary.added > 0) packs.add(summary.packTitle) + added += summary.added + duplicates += summary.duplicates + unreadable += summary.unreadable + } + is ImportSourceOutcome.Rejected -> unreadable++ + } + } else if (looksLikeSticker(file.bytes)) { + loose.add(ImportedImage(file.name, file.bytes)) + } else { + unreadable++ + } + } + + if (loose.isNotEmpty()) { + val summary = manager.importLoose(ownerId, loose, normalize) + added += summary.added + duplicates += summary.duplicates + unreadable += summary.unreadable + } + + return ImportResult(packs, added, duplicates, unreadable) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/stade/sticker/StickerManager.kt b/composeApp/src/commonMain/kotlin/dev/stade/sticker/StickerManager.kt index 2b96bd6..91a6130 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/sticker/StickerManager.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/sticker/StickerManager.kt @@ -29,6 +29,111 @@ class StickerManager(private val db: StadeDb, private val crypto: CryptoApi) { db.stadeDbQueries.deleteSticker(id) } + fun observePacks(ownerId: String): Flow> = + db.stadeDbQueries.selectStickerPacks(ownerId) + .asFlow() + .mapToList(Dispatchers.Default) + .map { rows -> rows.map { it.toDomain() } } + + fun deletePack(packId: String) { + db.stadeDbQueries.transaction { + db.stadeDbQueries.deleteStickersInPack(packId) + db.stadeDbQueries.deleteStickerPack(packId) + } + } + + fun importPack(ownerId: String, pack: ImportedPack, normalize: (ByteArray) -> ByteArray?): ImportSummary { + val packId = Encoding.toHex(crypto.randomBytes(16)) + val now = Clock.System.now().toEpochMilliseconds() + var added = 0 + var duplicates = 0 + var unreadable = 0 + val seen = mutableSetOf() + + val prepared = pack.images.mapNotNull { image -> + val normalized = runCatching { normalize(image.bytes) }.getOrNull() + if (normalized == null || normalized.isEmpty()) { + unreadable++ + return@mapNotNull null + } + val hash = Encoding.toHex(crypto.hash(normalized)) + if (!seen.add(hash)) { + duplicates++ + return@mapNotNull null + } + if (db.stadeDbQueries.stickerHashCount(ownerId, hash).executeAsOne() > 0L) { + duplicates++ + return@mapNotNull null + } + normalized to hash + } + + if (prepared.isEmpty()) { + return ImportSummary(pack.title, 0, duplicates, unreadable) + } + + db.stadeDbQueries.transaction { + db.stadeDbQueries.insertStickerPack(packId, ownerId, pack.title, pack.author, ORIGIN_FILE, now) + prepared.forEachIndexed { index, (bytes, hash) -> + val id = Encoding.toHex(crypto.randomBytes(16)) + db.stadeDbQueries.insertPackSticker(id, ownerId, bytes, now + index, packId, hash) + added++ + } + } + return ImportSummary(pack.title, added, duplicates, unreadable) + } + + fun importLoose(ownerId: String, images: List, normalize: (ByteArray) -> ByteArray?): ImportSummary { + val now = Clock.System.now().toEpochMilliseconds() + var added = 0 + var duplicates = 0 + var unreadable = 0 + val seen = mutableSetOf() + + val prepared = images.mapNotNull { image -> + val normalized = runCatching { normalize(image.bytes) }.getOrNull() + if (normalized == null || normalized.isEmpty()) { + unreadable++ + return@mapNotNull null + } + val hash = Encoding.toHex(crypto.hash(normalized)) + if (!seen.add(hash)) { + duplicates++ + return@mapNotNull null + } + if (db.stadeDbQueries.stickerHashCount(ownerId, hash).executeAsOne() > 0L) { + duplicates++ + return@mapNotNull null + } + normalized to hash + } + + if (prepared.isNotEmpty()) { + db.stadeDbQueries.transaction { + prepared.forEachIndexed { index, (bytes, hash) -> + val id = Encoding.toHex(crypto.randomBytes(16)) + db.stadeDbQueries.insertPackSticker(id, ownerId, bytes, now + index, null, hash) + added++ + } + } + } + return ImportSummary("", added, duplicates, unreadable) + } + private fun dev.stade.db.Sticker.toDomain(): Sticker = - Sticker(id = id, ownerId = ownerId, imageBytes = imageBytes, createdAt = createdAt) + Sticker(id = id, ownerId = ownerId, imageBytes = imageBytes, createdAt = createdAt, packId = packId) + + private fun dev.stade.db.StickerPack.toDomain(): StickerPack = + StickerPack( + id = id, + ownerId = ownerId, + title = title, + author = author, + origin = origin, + createdAt = createdAt + ) + + companion object { + const val ORIGIN_FILE = "file" + } } diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/StadeApp.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/StadeApp.kt index 18ab979..32302f7 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/ui/StadeApp.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/StadeApp.kt @@ -857,6 +857,21 @@ private fun UnlockedApp( } } + val pendingStickers by container.pendingStickerImport.collectAsState() + var stickerImportResult by remember { mutableStateOf(null) } + LaunchedEffect(pendingStickers, identity?.id) { + val files = pendingStickers + val who = identity + if (files.isEmpty() || who == null) return@LaunchedEffect + container.pendingStickerImport.value = emptyList() + stickerImportResult = withContext(Dispatchers.Default) { + dev.stade.sticker.StickerImporter.importFiles(container.stickers, who.id, files) + } + } + stickerImportResult?.let { outcome -> + dev.stade.ui.components.StickerImportResultDialog(outcome) { stickerImportResult = null } + } + val pending = pendingInvite val owner = identity if (pending != null && owner != null && diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/StickerImportIo.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/StickerImportIo.kt new file mode 100644 index 0000000..1e026f9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/StickerImportIo.kt @@ -0,0 +1,17 @@ +package dev.stade.ui + +import androidx.compose.runtime.Composable +import dev.stade.sticker.ImportedImage + +class PickedStickerFile(val name: String, val bytes: ByteArray) + +expect class StickerImportLauncher { + fun pickFiles() +} + +@Composable +expect fun rememberStickerImportLauncher( + onPicked: (List) -> Unit +): StickerImportLauncher + +fun PickedStickerFile.asImportedImage(): ImportedImage = ImportedImage(name, bytes) diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/components/AnimatedImage.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/components/AnimatedImage.kt index 929d8c8..b04a195 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/ui/components/AnimatedImage.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/components/AnimatedImage.kt @@ -23,6 +23,9 @@ fun isGifBytes(bytes: ByteArray): Boolean { return header == "GIF87a" || header == "GIF89a" } +fun isAnimatedBytes(bytes: ByteArray): Boolean = + isGifBytes(bytes) || dev.stade.sticker.isAnimatedWebpBytes(bytes) + @Composable expect fun AnimatedImage( bytes: ByteArray, diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/components/EmojiStickerDrawer.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/components/EmojiStickerDrawer.kt index b316a66..0d0fafa 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/ui/components/EmojiStickerDrawer.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/components/EmojiStickerDrawer.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize @@ -17,13 +18,17 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.FileDownload import androidx.compose.material.icons.filled.Add import androidx.compose.material3.AlertDialog import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.IconButton import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Tab @@ -40,7 +45,9 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import dev.stade.sticker.StickerPack import dev.stade.sticker.Sticker import dev.stade.emoji.CustomEmojiCatalog import dev.stade.ui.i18n.LocalStrings @@ -57,16 +64,20 @@ private enum class EmojiStickerTab { Emoji, Stickers } @Composable fun EmojiStickerPanel( stickers: List, + packs: List, onDismiss: () -> Unit, onSend: (ByteArray) -> Unit, onCreateSticker: () -> Unit, - onDeleteSticker: (String) -> Unit + onImportStickers: () -> Unit, + onDeleteSticker: (String) -> Unit, + onDeletePack: (String) -> Unit ) { val strings = LocalStrings.current val scope = rememberCoroutineScope() val environment = rememberResourceEnvironment() var tab by remember { mutableStateOf(EmojiStickerTab.Emoji) } var pendingDeleteId by remember { mutableStateOf(null) } + var pendingDeletePackId by remember { mutableStateOf(null) } Column(modifier = Modifier.fillMaxSize()) { TabRow(selectedTabIndex = tab.ordinal) { @@ -139,8 +150,31 @@ fun EmojiStickerPanel( Spacer(Modifier.width(6.dp)) Text(strings.createStickerAction) } + Spacer(Modifier.height(8.dp)) + TextButton(onClick = onImportStickers) { + Icon( + Icons.Default.FileDownload, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(6.dp)) + Text(strings.importStickersAction) + } + Spacer(Modifier.height(6.dp)) + Text( + strings.importStickersHint, + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelSmall, + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 32.dp) + ) } } else { + val loose = remember(stickers) { stickers.filter { it.packId == null } } + val grouped = remember(stickers, packs) { + packs.map { pack -> pack to stickers.filter { it.packId == pack.id } } + .filter { it.second.isNotEmpty() } + } LazyVerticalGrid( columns = GridCells.Fixed(4), contentPadding = PaddingValues(12.dp), @@ -163,26 +197,63 @@ fun EmojiStickerPanel( ) } } - items(stickers, key = { it.id }) { sticker -> + item(key = "import") { Box( modifier = Modifier .padding(4.dp) .aspectRatio(1f) - .pointerInput(sticker.id) { - detectTapGestures( - onTap = { - onSend(sticker.imageBytes) - onDismiss() - }, - onLongPress = { pendingDeleteId = sticker.id } - ) - }, + .clip(RoundedCornerShape(14.dp)) + .border(1.5.dp, MaterialTheme.colorScheme.outlineVariant, RoundedCornerShape(14.dp)) + .clickable(onClick = onImportStickers), contentAlignment = Alignment.Center ) { - AnimatedImage( - bytes = sticker.imageBytes, - contentDescription = null, - modifier = Modifier.fillMaxSize() + Icon( + Icons.Default.FileDownload, + contentDescription = strings.importStickersAction, + tint = MaterialTheme.colorScheme.primary + ) + } + } + items(loose, key = { it.id }) { sticker -> + StickerCell( + sticker = sticker, + onSend = { onSend(sticker.imageBytes); onDismiss() }, + onLongPress = { pendingDeleteId = sticker.id } + ) + } + grouped.forEach { (pack, packStickers) -> + item( + key = "pack_${pack.id}", + span = { GridItemSpan(maxLineSpan) } + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 10.dp, bottom = 2.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + pack.title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.weight(1f) + ) + IconButton( + onClick = { pendingDeletePackId = pack.id }, + modifier = Modifier.size(28.dp) + ) { + Icon( + Icons.Default.Close, + contentDescription = strings.stickerPackDeleteAction, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(16.dp) + ) + } + } + } + items(packStickers, key = { it.id }) { sticker -> + StickerCell( + sticker = sticker, + onSend = { onSend(sticker.imageBytes); onDismiss() }, + onLongPress = { pendingDeleteId = sticker.id } ) } } @@ -193,6 +264,24 @@ fun EmojiStickerPanel( } + val deletePackId = pendingDeletePackId + if (deletePackId != null) { + AlertDialog( + onDismissRequest = { pendingDeletePackId = null }, + title = { Text(strings.stickerPackDeleteTitle) }, + text = { Text(strings.stickerPackDeleteBody) }, + confirmButton = { + TextButton(onClick = { + onDeletePack(deletePackId) + pendingDeletePackId = null + }) { Text(strings.delete, color = MaterialTheme.colorScheme.error) } + }, + dismissButton = { + TextButton(onClick = { pendingDeletePackId = null }) { Text(strings.cancel) } + } + ) + } + val deleteId = pendingDeleteId if (deleteId != null) { AlertDialog( @@ -221,3 +310,29 @@ private fun DrawerEmptyState(text: String, modifier: Modifier = Modifier) { Text(text, color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium) } } + +@Composable +private fun StickerCell( + sticker: Sticker, + onSend: () -> Unit, + onLongPress: () -> Unit +) { + Box( + modifier = Modifier + .padding(4.dp) + .aspectRatio(1f) + .pointerInput(sticker.id) { + detectTapGestures( + onTap = { onSend() }, + onLongPress = { onLongPress() } + ) + }, + contentAlignment = Alignment.Center + ) { + AnimatedImage( + bytes = sticker.imageBytes, + contentDescription = null, + modifier = Modifier.fillMaxSize() + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/components/StickerImportResultDialog.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/components/StickerImportResultDialog.kt new file mode 100644 index 0000000..2146d3d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/components/StickerImportResultDialog.kt @@ -0,0 +1,57 @@ +package dev.stade.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import dev.stade.sticker.ImportResult +import dev.stade.ui.i18n.LocalStrings + +@Composable +fun StickerImportResultDialog(result: ImportResult, onDismiss: () -> Unit) { + val strings = LocalStrings.current + AlertDialog( + onDismissRequest = onDismiss, + title = { + Text( + if (result.added > 0) strings.stickersImported(result.added) + else strings.stickersImportedNone + ) + }, + text = { + Column { + if (result.packs.isNotEmpty()) { + Text( + result.packs.joinToString(", "), + style = MaterialTheme.typography.bodyMedium + ) + } + if (result.duplicates > 0) { + Spacer(Modifier.height(6.dp)) + Text( + strings.stickersImportDuplicates(result.duplicates), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + if (result.unreadable > 0) { + Spacer(Modifier.height(6.dp)) + Text( + strings.stickersImportUnreadable(result.unreadable), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { Text(strings.understood) } + } + ) +} diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/AppStrings.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/AppStrings.kt index 8bb6306..3d1d4a1 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/AppStrings.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/AppStrings.kt @@ -430,6 +430,16 @@ abstract class AppStrings { abstract val saveStickerAction: String abstract val stickerCreationFailed: String abstract val stickerGifTooLarge: String + abstract val importStickersAction: String + abstract val importStickersHint: String + abstract fun stickersImported(count: Int): String + abstract val stickersImportedNone: String + abstract fun stickersImportDuplicates(count: Int): String + abstract fun stickersImportUnreadable(count: Int): String + abstract val stickerPackDeleteAction: String + abstract val stickerPackDeleteTitle: String + abstract val stickerPackDeleteBody: String + abstract val stickerPackLoose: String abstract val backupSection: String abstract val backupExportTitle: String abstract val backupExportSubtitle: String diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/ArabicStrings.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/ArabicStrings.kt index b1cfc8d..d686822 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/ArabicStrings.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/ArabicStrings.kt @@ -475,6 +475,16 @@ object ArabicStrings : AppStrings() { override val saveStickerAction = "حفظ الملصق" override val stickerCreationFailed = "تعذّر إنشاء الملصق" override val stickerGifTooLarge = "صورة GIF كبيرة جدًا. جرّب واحدة أقل من 1.8 ميغابايت." + override val importStickersAction = "استيراد ملصقات" + override val importStickersHint = "أحضر ملف حزمة ملصقات، أو اختر صور ملصقات محفوظة لديك" + override fun stickersImported(count: Int) = "أُضيف ${count} ملصقًا" + override val stickersImportedNone = "لا جديد لإضافته" + override fun stickersImportDuplicates(count: Int) = "${count} منها كان في مجموعتك بالفعل" + override fun stickersImportUnreadable(count: Int) = "${count} منها تعذّرت قراءته" + override val stickerPackDeleteAction = "إزالة الحزمة" + override val stickerPackDeleteTitle = "إزالة هذه الحزمة؟" + override val stickerPackDeleteBody = "ستُزال كل ملصقات هذه الحزمة من مجموعتك." + override val stickerPackLoose = "ملصقاتي" override val backupSection = "النسخ الاحتياطي" override val backupExportTitle = "تصدير نسخة احتياطية" override val backupExportSubtitle = "احفظ نسخة مشفّرة من هذا الحساب" diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/EnglishStrings.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/EnglishStrings.kt index 19f33bb..63d3bb1 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/EnglishStrings.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/EnglishStrings.kt @@ -492,6 +492,16 @@ object EnglishStrings : AppStrings() { override val saveStickerAction = "Save sticker" override val stickerCreationFailed = "Couldn't create sticker" override val stickerGifTooLarge = "That GIF is too large. Try one under 1.8 MB." + override val importStickersAction = "Import stickers" + override val importStickersHint = "Bring in a sticker pack file, or pick sticker images you have saved" + override fun stickersImported(count: Int) = "Added ${count} stickers" + override val stickersImportedNone = "Nothing new to add" + override fun stickersImportDuplicates(count: Int) = "${count} were already in your collection" + override fun stickersImportUnreadable(count: Int) = "${count} could not be read" + override val stickerPackDeleteAction = "Remove pack" + override val stickerPackDeleteTitle = "Remove this pack?" + override val stickerPackDeleteBody = "Every sticker in this pack will be removed from your collection." + override val stickerPackLoose = "My stickers" override val backupSection = "Backup" override val backupExportTitle = "Export a backup" override val backupExportSubtitle = "Save an encrypted copy of this account" diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/KoreanStrings.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/KoreanStrings.kt index c97fe4f..2367e50 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/KoreanStrings.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/KoreanStrings.kt @@ -475,6 +475,16 @@ object KoreanStrings : AppStrings() { override val saveStickerAction = "스티커 저장" override val stickerCreationFailed = "스티커를 만들지 못했습니다" override val stickerGifTooLarge = "이 GIF는 너무 큽니다. 1.8MB 미만인 파일을 사용하세요." + override val importStickersAction = "스티커 가져오기" + override val importStickersHint = "스티커 팩 파일을 가져오거나 저장해 둔 스티커 이미지를 선택하세요" + override fun stickersImported(count: Int) = "스티커 ${count}개를 추가했습니다" + override val stickersImportedNone = "새로 추가할 것이 없습니다" + override fun stickersImportDuplicates(count: Int) = "${count}개는 이미 보관함에 있습니다" + override fun stickersImportUnreadable(count: Int) = "${count}개는 읽을 수 없습니다" + override val stickerPackDeleteAction = "팩 삭제" + override val stickerPackDeleteTitle = "이 팩을 삭제할까요?" + override val stickerPackDeleteBody = "이 팩의 모든 스티커가 보관함에서 삭제됩니다." + override val stickerPackLoose = "내 스티커" override val backupSection = "백업" override val backupExportTitle = "백업 내보내기" override val backupExportSubtitle = "이 계정의 암호화된 사본을 저장합니다" diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/PersianStrings.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/PersianStrings.kt index 52dfa93..d38c595 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/PersianStrings.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/PersianStrings.kt @@ -475,6 +475,16 @@ object PersianStrings : AppStrings() { override val saveStickerAction = "ذخیرهٔ استیکر" override val stickerCreationFailed = "استیکر ساخته نشد" override val stickerGifTooLarge = "این GIF خیلی بزرگ است. یکی کمتر از ۱٫۸ مگابایت امتحان کنید." + override val importStickersAction = "درون‌ریزی استیکر" + override val importStickersHint = "یک فایل بستهٔ استیکر بیاورید یا تصویرهای استیکری را که ذخیره کرده‌اید انتخاب کنید" + override fun stickersImported(count: Int) = "${count} استیکر افزوده شد" + override val stickersImportedNone = "چیز تازه‌ای برای افزودن نیست" + override fun stickersImportDuplicates(count: Int) = "${count} مورد از پیش در مجموعهٔ شما بود" + override fun stickersImportUnreadable(count: Int) = "${count} مورد خوانده نشد" + override val stickerPackDeleteAction = "حذف بسته" + override val stickerPackDeleteTitle = "این بسته حذف شود؟" + override val stickerPackDeleteBody = "همهٔ استیکرهای این بسته از مجموعهٔ شما حذف می‌شوند." + override val stickerPackLoose = "استیکرهای من" override val backupSection = "پشتیبان" override val backupExportTitle = "خروجی گرفتن از پشتیبان" override val backupExportSubtitle = "ذخیرهٔ یک نسخهٔ رمزگذاری‌شده از این حساب" diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/TurkishStrings.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/TurkishStrings.kt index 363550c..8803281 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/TurkishStrings.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/TurkishStrings.kt @@ -491,6 +491,16 @@ object TurkishStrings : AppStrings() { override val saveStickerAction = "Çıkartmayı kaydet" override val stickerCreationFailed = "Çıkartma oluşturulamadı" override val stickerGifTooLarge = "Bu GIF çok büyük. 1,8 MB altında bir tane dene." + override val importStickersAction = "Çıkartma içe aktar" + override val importStickersHint = "Bir çıkartma paketi dosyası getirin ya da kayıtlı çıkartma görsellerinizi seçin" + override fun stickersImported(count: Int) = "${count} çıkartma eklendi" + override val stickersImportedNone = "Eklenecek yeni bir şey yok" + override fun stickersImportDuplicates(count: Int) = "${count} tanesi zaten koleksiyonunuzdaydı" + override fun stickersImportUnreadable(count: Int) = "${count} tanesi okunamadı" + override val stickerPackDeleteAction = "Paketi kaldır" + override val stickerPackDeleteTitle = "Bu paket kaldırılsın mı?" + override val stickerPackDeleteBody = "Bu paketteki her çıkartma koleksiyonunuzdan kaldırılacak." + override val stickerPackLoose = "Çıkartmalarım" override val backupSection = "Yedekleme" override val backupExportTitle = "Yedek dışa aktar" override val backupExportSubtitle = "Bu hesabın şifreli bir kopyasını kaydet" diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/ChatScreen.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/ChatScreen.kt index b90e34a..4c52927 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/ChatScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/ChatScreen.kt @@ -218,6 +218,10 @@ import dev.stade.ui.components.formatVoiceDuration import dev.stade.ui.components.maskAddress import dev.stade.ui.copyImageToClipboard import dev.stade.ui.decodeToImageBitmap +import dev.stade.sticker.ImportResult +import dev.stade.sticker.StickerImporter +import dev.stade.ui.rememberStickerImportLauncher +import dev.stade.ui.components.StickerImportResultDialog import dev.stade.ui.i18n.LocalStrings import dev.stade.ui.rememberMediaPickerLauncher import dev.stade.ui.saveImageToGallery @@ -291,7 +295,22 @@ fun ChatScreen( val starredIds by remember(owner.id) { container.starredMessages.observeStarredIds(owner.id) } .collectAsState(initial = remember(owner.id) { container.starredMessages.starredIds(owner.id) }) var showStickerMaker by remember { mutableStateOf(false) } + var stickerImportResult by remember { mutableStateOf(null) } val stickers by remember(owner.id) { container.stickers.observeStickers(owner.id) }.collectAsState(initial = emptyList()) + val stickerPacks by remember(owner.id) { container.stickers.observePacks(owner.id) }.collectAsState(initial = emptyList()) + val stickerScope = rememberCoroutineScope() + val stickerImport = rememberStickerImportLauncher { picked -> + if (picked.isNotEmpty()) { + stickerScope.launch { + stickerImportResult = withContext(Dispatchers.Default) { + StickerImporter.importFiles(container.stickers, owner.id, picked) + } + } + } + } + stickerImportResult?.let { outcome -> + StickerImportResultDialog(outcome) { stickerImportResult = null } + } var showScheduleDialog by remember(contactId) { mutableStateOf(false) } var showScheduledList by remember(contactId) { mutableStateOf(false) } @@ -1414,6 +1433,7 @@ fun ChatScreen( } else if (showEmojiDrawer && emojiContact != null) { EmojiStickerPanel( stickers = stickers, + packs = stickerPacks, onDismiss = { showEmojiDrawer = false }, onSend = { bytes -> scope.launch { container.chat.sendSticker(owner, emojiContact, bytes) } @@ -1422,7 +1442,9 @@ fun ChatScreen( showEmojiDrawer = false showStickerMaker = true }, - onDeleteSticker = { id -> container.stickers.delete(id) } + onDeleteSticker = { id -> container.stickers.delete(id) }, + onImportStickers = { stickerImport.pickFiles() }, + onDeletePack = { id -> container.stickers.deletePack(id) } ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/GroupChatScreen.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/GroupChatScreen.kt index 5352495..495f778 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/GroupChatScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/GroupChatScreen.kt @@ -183,6 +183,10 @@ import dev.stade.ui.components.formatChatTime import dev.stade.ui.components.formatVoiceDuration import dev.stade.ui.copyImageToClipboard import dev.stade.ui.decodeToImageBitmap +import dev.stade.sticker.ImportResult +import dev.stade.sticker.StickerImporter +import dev.stade.ui.rememberStickerImportLauncher +import dev.stade.ui.components.StickerImportResultDialog import dev.stade.ui.i18n.LocalStrings import dev.stade.ui.rememberMediaPickerLauncher import dev.stade.ui.saveImageToGallery @@ -280,7 +284,22 @@ fun GroupChatScreen( val starredIds by remember(owner.id) { container.starredMessages.observeStarredIds(owner.id) } .collectAsState(initial = remember(owner.id) { container.starredMessages.starredIds(owner.id) }) var showStickerMaker by remember { mutableStateOf(false) } + var stickerImportResult by remember { mutableStateOf(null) } val stickers by remember(owner.id) { container.stickers.observeStickers(owner.id) }.collectAsState(initial = emptyList()) + val stickerPacks by remember(owner.id) { container.stickers.observePacks(owner.id) }.collectAsState(initial = emptyList()) + val stickerScope = rememberCoroutineScope() + val stickerImport = rememberStickerImportLauncher { picked -> + if (picked.isNotEmpty()) { + stickerScope.launch { + stickerImportResult = withContext(Dispatchers.Default) { + StickerImporter.importFiles(container.stickers, owner.id, picked) + } + } + } + } + stickerImportResult?.let { outcome -> + StickerImportResultDialog(outcome) { stickerImportResult = null } + } var selectedMessageIds by remember(groupId) { mutableStateOf>(emptySet()) } val inSelectionMode by remember { derivedStateOf { selectedMessageIds.isNotEmpty() } } @@ -1223,6 +1242,7 @@ fun GroupChatScreen( } else if (showEmojiDrawer) { EmojiStickerPanel( stickers = stickers, + packs = stickerPacks, onDismiss = { showEmojiDrawer = false }, onSend = { bytes -> scope.launch { container.groupChat.sendSticker(owner, groupId, bytes) } @@ -1231,7 +1251,9 @@ fun GroupChatScreen( showEmojiDrawer = false showStickerMaker = true }, - onDeleteSticker = { id -> container.stickers.delete(id) } + onDeleteSticker = { id -> container.stickers.delete(id) }, + onImportStickers = { stickerImport.pickFiles() }, + onDeletePack = { id -> container.stickers.deletePack(id) } ) } } diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/StadiumScreen.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/StadiumScreen.kt index 44d4b75..5203442 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/StadiumScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/StadiumScreen.kt @@ -134,6 +134,10 @@ import dev.stade.ui.components.onSecondaryClick import dev.stade.chat.StarScope import androidx.compose.material.icons.filled.Star import androidx.compose.material.icons.filled.StarBorder +import dev.stade.sticker.ImportResult +import dev.stade.sticker.StickerImporter +import dev.stade.ui.rememberStickerImportLauncher +import dev.stade.ui.components.StickerImportResultDialog import dev.stade.ui.i18n.LocalStrings import dev.stade.ui.PlatformBackHandler import dev.stade.ui.components.Avatar @@ -215,7 +219,22 @@ fun StadiumScreen( val starredIds by remember(owner.id) { container.starredMessages.observeStarredIds(owner.id) } .collectAsState(initial = remember(owner.id) { container.starredMessages.starredIds(owner.id) }) var showStickerMaker by remember { mutableStateOf(false) } + var stickerImportResult by remember { mutableStateOf(null) } val stickers by remember(owner.id) { container.stickers.observeStickers(owner.id) }.collectAsState(initial = emptyList()) + val stickerPacks by remember(owner.id) { container.stickers.observePacks(owner.id) }.collectAsState(initial = emptyList()) + val stickerScope = rememberCoroutineScope() + val stickerImport = rememberStickerImportLauncher { picked -> + if (picked.isNotEmpty()) { + stickerScope.launch { + stickerImportResult = withContext(Dispatchers.Default) { + StickerImporter.importFiles(container.stickers, owner.id, picked) + } + } + } + } + stickerImportResult?.let { outcome -> + StickerImportResultDialog(outcome) { stickerImportResult = null } + } var leaving by remember { mutableStateOf(false) } var selectedMessageIds by remember(stadiumId) { mutableStateOf>(emptySet()) } @@ -785,6 +804,7 @@ fun StadiumScreen( } else if (showEmojiDrawer && emojiStadium != null) { EmojiStickerPanel( stickers = stickers, + packs = stickerPacks, onDismiss = { showEmojiDrawer = false }, onSend = { bytes -> scope.launch { container.stadiumChat.postSticker(owner, emojiStadium, bytes) } @@ -793,7 +813,9 @@ fun StadiumScreen( showEmojiDrawer = false showStickerMaker = true }, - onDeleteSticker = { id -> container.stickers.delete(id) } + onDeleteSticker = { id -> container.stickers.delete(id) }, + onImportStickers = { stickerImport.pickFiles() }, + onDeletePack = { id -> container.stickers.deletePack(id) } ) } } diff --git a/composeApp/src/commonMain/sqldelight/dev/stade/db/StadeDb.sq b/composeApp/src/commonMain/sqldelight/dev/stade/db/StadeDb.sq index 31db9a2..7c13d92 100644 --- a/composeApp/src/commonMain/sqldelight/dev/stade/db/StadeDb.sq +++ b/composeApp/src/commonMain/sqldelight/dev/stade/db/StadeDb.sq @@ -573,23 +573,60 @@ CREATE TABLE Sticker ( id TEXT NOT NULL PRIMARY KEY, ownerId TEXT NOT NULL, imageBytes BLOB NOT NULL, - createdAt INTEGER NOT NULL + createdAt INTEGER NOT NULL, + packId TEXT, + contentHash TEXT NOT NULL DEFAULT '' ); CREATE INDEX idxSticker ON Sticker(ownerId); +CREATE INDEX idxStickerPackRef ON Sticker(packId); + +CREATE TABLE StickerPack ( + id TEXT NOT NULL PRIMARY KEY, + ownerId TEXT NOT NULL, + title TEXT NOT NULL, + author TEXT NOT NULL DEFAULT '', + origin TEXT NOT NULL DEFAULT '', + createdAt INTEGER NOT NULL +); + +CREATE INDEX idxStickerPack ON StickerPack(ownerId); + insertSticker: INSERT INTO Sticker(id, ownerId, imageBytes, createdAt) VALUES (?, ?, ?, ?); +insertPackSticker: +INSERT INTO Sticker(id, ownerId, imageBytes, createdAt, packId, contentHash) +VALUES (?, ?, ?, ?, ?, ?); + selectStickers: SELECT * FROM Sticker WHERE ownerId = ? ORDER BY createdAt DESC; +stickerHashCount: +SELECT COUNT(*) FROM Sticker WHERE ownerId = ? AND contentHash = ? AND contentHash != ''; + deleteSticker: DELETE FROM Sticker WHERE id = ?; wipeStickers: DELETE FROM Sticker; +insertStickerPack: +INSERT INTO StickerPack(id, ownerId, title, author, origin, createdAt) VALUES (?, ?, ?, ?, ?, ?); + +selectStickerPacks: +SELECT * FROM StickerPack WHERE ownerId = ? ORDER BY createdAt DESC; + +deleteStickerPack: +DELETE FROM StickerPack WHERE id = ?; + +deleteStickersInPack: +DELETE FROM Sticker WHERE packId = ?; + +wipeStickerPacks: +DELETE FROM StickerPack; + CREATE TABLE VanishSession ( sessionId TEXT NOT NULL PRIMARY KEY, contactId TEXT NOT NULL, diff --git a/composeApp/src/desktopMain/kotlin/dev/stade/ui/StickerImportIo.desktop.kt b/composeApp/src/desktopMain/kotlin/dev/stade/ui/StickerImportIo.desktop.kt new file mode 100644 index 0000000..bebbb02 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/dev/stade/ui/StickerImportIo.desktop.kt @@ -0,0 +1,45 @@ +package dev.stade.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +import dev.stade.sticker.MAX_IMPORT_ARCHIVE_BYTES +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.awt.FileDialog +import java.awt.Frame +import java.io.File + +actual class StickerImportLauncher(private val onPick: () -> Unit) { + actual fun pickFiles() = onPick() +} + +@Composable +actual fun rememberStickerImportLauncher( + onPicked: (List) -> Unit +): StickerImportLauncher { + val scope = rememberCoroutineScope() + val callback by rememberUpdatedState(onPicked) + + return remember { + StickerImportLauncher { + scope.launch { + val files = withContext(Dispatchers.IO) { + val dialog = FileDialog(null as Frame?, "Stade", FileDialog.LOAD) + dialog.isMultipleMode = true + dialog.isVisible = true + dialog.files.orEmpty().mapNotNull { readPicked(it) } + } + callback(files) + } + } + } +} + +private fun readPicked(file: File): PickedStickerFile? = runCatching { + if (!file.isFile || file.length() > MAX_IMPORT_ARCHIVE_BYTES) return null + PickedStickerFile(file.name, file.readBytes()) +}.getOrNull() diff --git a/composeApp/src/desktopMain/kotlin/dev/stade/ui/components/AnimatedImage.desktop.kt b/composeApp/src/desktopMain/kotlin/dev/stade/ui/components/AnimatedImage.desktop.kt index e143846..669bee3 100644 --- a/composeApp/src/desktopMain/kotlin/dev/stade/ui/components/AnimatedImage.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/dev/stade/ui/components/AnimatedImage.desktop.kt @@ -55,7 +55,7 @@ actual fun AnimatedImage( modifier: Modifier, contentScale: ContentScale ) { - if (!isGifBytes(bytes)) { + if (!isAnimatedBytes(bytes)) { StaticImageBytes(bytes, contentDescription, modifier, contentScale) return } diff --git a/composeApp/src/desktopTest/kotlin/dev/stade/sticker/StickerImportEndToEndTest.kt b/composeApp/src/desktopTest/kotlin/dev/stade/sticker/StickerImportEndToEndTest.kt new file mode 100644 index 0000000..153c656 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/dev/stade/sticker/StickerImportEndToEndTest.kt @@ -0,0 +1,163 @@ +package dev.stade.sticker + +import dev.stade.db.DriverFactory +import dev.stade.db.StadeDb +import dev.stade.crypto.platformCrypto +import dev.stade.ui.PickedStickerFile +import java.awt.Color +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream +import java.io.File +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import javax.imageio.ImageIO +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +private fun png(size: Int, shade: Int): ByteArray { + val image = BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB) + val g = image.createGraphics() + g.color = Color(shade, shade % 200, 255 - shade) + g.fillRect(0, 0, size, size) + g.dispose() + val out = ByteArrayOutputStream() + ImageIO.write(image, "png", out) + return out.toByteArray() +} + +private fun zipOf(vararg entries: Pair): ByteArray { + val out = ByteArrayOutputStream() + ZipOutputStream(out).use { zip -> + entries.forEach { (name, bytes) -> + zip.putNextEntry(ZipEntry(name)) + zip.write(bytes) + zip.closeEntry() + } + } + return out.toByteArray() +} + +private fun freshManager(): Pair { + val path = File( + System.getProperty("java.io.tmpdir"), + "stade-stickers-${Random.nextLong()}/stade.db" + ).absolutePath + val driver = DriverFactory().create(path) + val db = StadeDb(driver) + return StickerManager(db, platformCrypto()) to db +} + +class StickerImportEndToEndTest { + + private val owner = "owner-1" + + @Test + fun aPackFileLandsInTheCollectionAsAPack() { + val (manager, db) = freshManager() + val archive = zipOf( + "identifier" to "Holiday".encodeToByteArray(), + "tray.png" to png(96, 10), + "a.png" to png(200, 20), + "b.png" to png(200, 30) + ) + val result = StickerImporter.importFiles( + manager, owner, listOf(PickedStickerFile("holiday.wastickers", archive)) + ) + assertEquals(2, result.added) + assertEquals(listOf("Holiday"), result.packs) + + val packs = db.stadeDbQueries.selectStickerPacks(owner).executeAsList() + assertEquals(1, packs.size) + assertEquals("Holiday", packs[0].title) + val stickers = db.stadeDbQueries.selectStickers(owner).executeAsList() + assertEquals(2, stickers.size) + assertTrue(stickers.all { it.packId == packs[0].id }) + assertTrue(stickers.all { it.contentHash.isNotBlank() }) + } + + @Test + fun theSameStickerIsNeverStoredTwice() { + val (manager, db) = freshManager() + val shared = png(200, 40) + val first = StickerImporter.importFiles( + manager, owner, listOf(PickedStickerFile("one.png", shared)) + ) + assertEquals(1, first.added) + + val again = StickerImporter.importFiles( + manager, owner, listOf(PickedStickerFile("one-copy.png", shared)) + ) + assertEquals(0, again.added) + assertEquals(1, again.duplicates) + assertEquals(1, db.stadeDbQueries.selectStickers(owner).executeAsList().size) + } + + @Test + fun duplicatesInsideOnePackAreCollapsed() { + val (manager, db) = freshManager() + val same = png(200, 55) + val archive = zipOf("a.png" to same, "b.png" to same, "c.png" to png(200, 65)) + val result = StickerImporter.importFiles( + manager, owner, listOf(PickedStickerFile("dupes.wastickers", archive)) + ) + assertEquals(2, result.added) + assertEquals(1, result.duplicates) + assertEquals(2, db.stadeDbQueries.selectStickers(owner).executeAsList().size) + } + + @Test + fun looseImagesArriveWithoutAPack() { + val (manager, db) = freshManager() + val result = StickerImporter.importFiles( + manager, owner, + listOf(PickedStickerFile("x.png", png(180, 70)), PickedStickerFile("y.png", png(180, 80))) + ) + assertEquals(2, result.added) + assertTrue(result.packs.isEmpty()) + assertTrue(db.stadeDbQueries.selectStickers(owner).executeAsList().all { it.packId == null }) + assertTrue(db.stadeDbQueries.selectStickerPacks(owner).executeAsList().isEmpty()) + } + + @Test + fun unreadableFilesAreCountedAndSkipped() { + val (manager, db) = freshManager() + val result = StickerImporter.importFiles( + manager, owner, + listOf( + PickedStickerFile("notes.txt", "just text".encodeToByteArray()), + PickedStickerFile("good.png", png(150, 90)) + ) + ) + assertEquals(1, result.added) + assertEquals(1, result.unreadable) + assertEquals(1, db.stadeDbQueries.selectStickers(owner).executeAsList().size) + } + + @Test + fun removingAPackTakesItsStickersWithIt() { + val (manager, db) = freshManager() + val archive = zipOf("a.png" to png(200, 100), "b.png" to png(200, 110)) + StickerImporter.importFiles(manager, owner, listOf(PickedStickerFile("p.wastickers", archive))) + StickerImporter.importFiles(manager, owner, listOf(PickedStickerFile("loose.png", png(200, 120)))) + + val pack = db.stadeDbQueries.selectStickerPacks(owner).executeAsList().single() + manager.deletePack(pack.id) + + assertTrue(db.stadeDbQueries.selectStickerPacks(owner).executeAsList().isEmpty()) + val left = db.stadeDbQueries.selectStickers(owner).executeAsList() + assertEquals(1, left.size, "only the loose sticker should survive") + assertEquals(null, left[0].packId) + } + + @Test + fun oneUsersStickersDoNotBlockAnother() { + val (manager, db) = freshManager() + val shared = png(200, 130) + StickerImporter.importFiles(manager, owner, listOf(PickedStickerFile("a.png", shared))) + val other = StickerImporter.importFiles(manager, "owner-2", listOf(PickedStickerFile("a.png", shared))) + assertEquals(1, other.added, "a different account must get its own copy") + assertEquals(1, db.stadeDbQueries.selectStickers("owner-2").executeAsList().size) + } +} diff --git a/composeApp/src/desktopTest/kotlin/dev/stade/sticker/StickerImportTest.kt b/composeApp/src/desktopTest/kotlin/dev/stade/sticker/StickerImportTest.kt new file mode 100644 index 0000000..73f5922 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/dev/stade/sticker/StickerImportTest.kt @@ -0,0 +1,154 @@ +package dev.stade.sticker + +import java.awt.Color +import java.awt.image.BufferedImage +import java.io.ByteArrayOutputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipOutputStream +import javax.imageio.ImageIO +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +private fun png(size: Int, shade: Int): ByteArray { + val image = BufferedImage(size, size, BufferedImage.TYPE_INT_ARGB) + val g = image.createGraphics() + g.color = Color(shade, shade, shade) + g.fillRect(0, 0, size, size) + g.dispose() + val out = ByteArrayOutputStream() + ImageIO.write(image, "png", out) + return out.toByteArray() +} + +private fun gif(): ByteArray = "GIF89a".encodeToByteArray() + ByteArray(32) + +private fun webpHeader(animated: Boolean): ByteArray { + val out = ByteArray(32) + "RIFF".encodeToByteArray().copyInto(out, 0) + "WEBP".encodeToByteArray().copyInto(out, 8) + "VP8X".encodeToByteArray().copyInto(out, 12) + if (animated) out[20] = 0x02 + return out +} + +private fun zipOf(vararg entries: Pair): ByteArray { + val out = ByteArrayOutputStream() + ZipOutputStream(out).use { zip -> + entries.forEach { (name, bytes) -> + zip.putNextEntry(ZipEntry(name)) + zip.write(bytes) + zip.closeEntry() + } + } + return out.toByteArray() +} + +class StickerImportTest { + + @Test + fun formatsAreRecognisedByTheirHeaders() { + assertTrue(isPngBytes(png(8, 10))) + assertTrue(isWebpBytes(webpHeader(animated = false))) + assertTrue(isAnimatedWebpBytes(webpHeader(animated = true))) + assertFalse(isAnimatedWebpBytes(webpHeader(animated = false))) + assertFalse(isWebpBytes("not an image".encodeToByteArray())) + assertTrue(isZipArchive(zipOf("a.png" to png(8, 20)))) + assertFalse(isZipArchive(png(8, 20))) + } + + @Test + fun animatedArtworkKeepsItsOwnFramesAndStillArtIsReEncoded() { + assertTrue(keepsItsOwnFrames(gif())) + assertTrue(keepsItsOwnFrames(webpHeader(animated = true))) + assertFalse(keepsItsOwnFrames(png(8, 30))) + + val still = png(600, 40) + val normalized = normalizeStickerBytes(still) + assertTrue(normalized != null && isPngBytes(normalized)) + + val animated = gif() + assertEquals(animated.toList(), normalizeStickerBytes(animated)?.toList()) + } + + @Test + fun junkAndOversizedFilesAreRefused() { + assertEquals(null, normalizeStickerBytes("hello".encodeToByteArray())) + assertEquals(null, normalizeStickerBytes(ByteArray(0))) + assertEquals(null, normalizeStickerBytes(ByteArray(MAX_IMPORT_ENTRY_BYTES + 1))) + } + + @Test + fun aWaStickersArchiveYieldsItsPackAndTitle() { + val archive = zipOf( + "identifier" to "Cat Pack".encodeToByteArray(), + "author" to "Someone".encodeToByteArray(), + "tray.png" to png(96, 50), + "01.png" to png(200, 60), + "02.png" to png(200, 70) + ) + val outcome = StickerArchive.read("cats.wastickers", archive) + assertTrue(outcome is ImportSourceOutcome.Pack, "expected a pack, got $outcome") + val pack = (outcome as ImportSourceOutcome.Pack).pack + assertEquals("Cat Pack", pack.title) + assertEquals("Someone", pack.author) + assertEquals(2, pack.images.size, "the tray icon must not become a sticker") + } + + @Test + fun aPackWithoutMetadataIsNamedAfterItsFile() { + val archive = zipOf("a.png" to png(120, 80), "b.png" to png(120, 90)) + val outcome = StickerArchive.read("my_holiday-pack.wastickers", archive) + val pack = (outcome as ImportSourceOutcome.Pack).pack + assertEquals("my holiday pack", pack.title) + assertEquals(2, pack.images.size) + } + + @Test + fun aJsonManifestSuppliesTheNameAndPublisher() { + val manifest = """{"identifier":"x","name":"Blue Set","publisher":"Studio"}""" + val archive = zipOf( + "contents.json" to manifest.encodeToByteArray(), + "one.png" to png(100, 110) + ) + val pack = (StickerArchive.read("set.wastickers", archive) as ImportSourceOutcome.Pack).pack + assertEquals("Blue Set", pack.title) + assertEquals("Studio", pack.author) + } + + @Test + fun archivesWithoutAnyArtworkAreRejected() { + val archive = zipOf("readme.txt" to "nothing here".encodeToByteArray()) + assertEquals( + ImportSourceOutcome.Rejected(ImportRejection.Empty), + StickerArchive.read("empty.wastickers", archive) + ) + } + + @Test + fun somethingThatIsNotAnArchiveIsRejected() { + assertEquals( + ImportSourceOutcome.Rejected(ImportRejection.NotReadable), + StickerArchive.read("photo.png", png(64, 120)) + ) + } + + @Test + fun anEnormousArchiveIsRefusedBeforeItIsOpened() { + val huge = ByteArray((MAX_IMPORT_ARCHIVE_BYTES + 1).toInt()) { 0 } + assertEquals( + ImportSourceOutcome.Rejected(ImportRejection.TooLarge), + StickerArchive.read("huge.wastickers", huge) + ) + } + + @Test + fun anArchiveNeverYieldsMoreThanTheEntryLimit() { + val entries = (0..MAX_IMPORT_ENTRIES + 20) + .map { "s$it.png" to png(40, it % 200) } + .toTypedArray() + val pack = (StickerArchive.read("big.wastickers", zipOf(*entries)) as ImportSourceOutcome.Pack).pack + assertTrue(pack.images.size <= MAX_IMPORT_ENTRIES, "got ${pack.images.size}") + } +} diff --git a/composeApp/src/jvmCommonMain/kotlin/dev/stade/sticker/StickerArchive.kt b/composeApp/src/jvmCommonMain/kotlin/dev/stade/sticker/StickerArchive.kt new file mode 100644 index 0000000..4cc484c --- /dev/null +++ b/composeApp/src/jvmCommonMain/kotlin/dev/stade/sticker/StickerArchive.kt @@ -0,0 +1,91 @@ +package dev.stade.sticker + +import java.io.ByteArrayInputStream +import java.util.zip.ZipInputStream + +private const val TRAY_MARKER = "tray" + +object StickerArchive { + + fun read(fileName: String, bytes: ByteArray): ImportSourceOutcome { + if (bytes.size > MAX_IMPORT_ARCHIVE_BYTES) { + return ImportSourceOutcome.Rejected(ImportRejection.TooLarge) + } + if (!isZipArchive(bytes)) return ImportSourceOutcome.Rejected(ImportRejection.NotReadable) + + val images = mutableListOf() + var title = "" + var author = "" + + val read = runCatching { + ZipInputStream(ByteArrayInputStream(bytes)).use { zip -> + while (true) { + val entry = zip.nextEntry ?: break + if (entry.isDirectory) continue + val name = entry.name.substringAfterLast('/') + if (name.isBlank() || name.startsWith(".")) continue + if (images.size >= MAX_IMPORT_ENTRIES) break + + val content = readCapped(zip, MAX_IMPORT_ENTRY_BYTES) ?: continue + when { + name.equals("identifier", true) || name.equals("title", true) -> + title = content.decodeToString().trim() + name.equals("author", true) -> + author = content.decodeToString().trim() + name.endsWith(".json", true) -> parseManifest(content)?.let { meta -> + if (title.isBlank()) title = meta.first + if (author.isBlank()) author = meta.second + } + looksLikeSticker(content) -> { + if (!name.substringBeforeLast('.').equals(TRAY_MARKER, true)) { + images.add(ImportedImage(name, content)) + } + } + } + } + } + } + if (read.isFailure) return ImportSourceOutcome.Rejected(ImportRejection.NotReadable) + if (images.isEmpty()) return ImportSourceOutcome.Rejected(ImportRejection.Empty) + + return ImportSourceOutcome.Pack( + ImportedPack( + title = title.ifBlank { packTitleFromFileName(fileName) }, + author = author, + images = images + ) + ) + } + + private fun readCapped(zip: ZipInputStream, cap: Int): ByteArray? { + val out = java.io.ByteArrayOutputStream() + val buffer = ByteArray(16 * 1024) + while (true) { + val n = zip.read(buffer) + if (n < 0) break + if (out.size() + n > cap) return null + out.write(buffer, 0, n) + } + return out.toByteArray() + } + + private fun parseManifest(content: ByteArray): Pair? = runCatching { + val text = content.decodeToString() + val name = firstField(text, listOf("name", "title", "identifier")) + val publisher = firstField(text, listOf("publisher", "author")) + if (name == null && publisher == null) null + else (name.orEmpty().trim() to publisher.orEmpty().trim()) + }.getOrNull() + + private fun firstField(text: String, keys: List): String? { + for (key in keys) { + val match = Regex("\"" + key + "\"\\s*:\\s*\"([^\"]*)\"", RegexOption.IGNORE_CASE).find(text) + val value = match?.groupValues?.get(1)?.trim() + if (!value.isNullOrBlank()) return value + } + return null + } +} + +actual fun readStickerArchive(fileName: String, bytes: ByteArray): ImportSourceOutcome = + StickerArchive.read(fileName, bytes) diff --git a/i18n/ar.json b/i18n/ar.json index e0b12a4..c7f77ab 100644 --- a/i18n/ar.json +++ b/i18n/ar.json @@ -410,6 +410,16 @@ "saveStickerAction": "حفظ الملصق", "stickerCreationFailed": "تعذّر إنشاء الملصق", "stickerGifTooLarge": "صورة GIF كبيرة جدًا. جرّب واحدة أقل من 1.8 ميغابايت.", + "importStickersAction": "استيراد ملصقات", + "importStickersHint": "أحضر ملف حزمة ملصقات، أو اختر صور ملصقات محفوظة لديك", + "stickersImported": "أُضيف {count} ملصقًا", + "stickersImportedNone": "لا جديد لإضافته", + "stickersImportDuplicates": "{count} منها كان في مجموعتك بالفعل", + "stickersImportUnreadable": "{count} منها تعذّرت قراءته", + "stickerPackDeleteAction": "إزالة الحزمة", + "stickerPackDeleteTitle": "إزالة هذه الحزمة؟", + "stickerPackDeleteBody": "ستُزال كل ملصقات هذه الحزمة من مجموعتك.", + "stickerPackLoose": "ملصقاتي", "backupSection": "النسخ الاحتياطي", "backupExportTitle": "تصدير نسخة احتياطية", "backupExportSubtitle": "احفظ نسخة مشفّرة من هذا الحساب", diff --git a/i18n/en.json b/i18n/en.json index 1578789..92cd644 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -410,6 +410,16 @@ "saveStickerAction": "Save sticker", "stickerCreationFailed": "Couldn't create sticker", "stickerGifTooLarge": "That GIF is too large. Try one under 1.8 MB.", + "importStickersAction": "Import stickers", + "importStickersHint": "Bring in a sticker pack file, or pick sticker images you have saved", + "stickersImported": "Added {count} stickers", + "stickersImportedNone": "Nothing new to add", + "stickersImportDuplicates": "{count} were already in your collection", + "stickersImportUnreadable": "{count} could not be read", + "stickerPackDeleteAction": "Remove pack", + "stickerPackDeleteTitle": "Remove this pack?", + "stickerPackDeleteBody": "Every sticker in this pack will be removed from your collection.", + "stickerPackLoose": "My stickers", "backupSection": "Backup", "backupExportTitle": "Export a backup", "backupExportSubtitle": "Save an encrypted copy of this account", diff --git a/i18n/fa.json b/i18n/fa.json index 9417de9..bbaa919 100644 --- a/i18n/fa.json +++ b/i18n/fa.json @@ -410,6 +410,16 @@ "saveStickerAction": "ذخیرهٔ استیکر", "stickerCreationFailed": "استیکر ساخته نشد", "stickerGifTooLarge": "این GIF خیلی بزرگ است. یکی کمتر از ۱٫۸ مگابایت امتحان کنید.", + "importStickersAction": "درون‌ریزی استیکر", + "importStickersHint": "یک فایل بستهٔ استیکر بیاورید یا تصویرهای استیکری را که ذخیره کرده‌اید انتخاب کنید", + "stickersImported": "{count} استیکر افزوده شد", + "stickersImportedNone": "چیز تازه‌ای برای افزودن نیست", + "stickersImportDuplicates": "{count} مورد از پیش در مجموعهٔ شما بود", + "stickersImportUnreadable": "{count} مورد خوانده نشد", + "stickerPackDeleteAction": "حذف بسته", + "stickerPackDeleteTitle": "این بسته حذف شود؟", + "stickerPackDeleteBody": "همهٔ استیکرهای این بسته از مجموعهٔ شما حذف می‌شوند.", + "stickerPackLoose": "استیکرهای من", "backupSection": "پشتیبان", "backupExportTitle": "خروجی گرفتن از پشتیبان", "backupExportSubtitle": "ذخیرهٔ یک نسخهٔ رمزگذاری‌شده از این حساب", diff --git a/i18n/ko.json b/i18n/ko.json index 8c6c563..00ba739 100644 --- a/i18n/ko.json +++ b/i18n/ko.json @@ -410,6 +410,16 @@ "saveStickerAction": "스티커 저장", "stickerCreationFailed": "스티커를 만들지 못했습니다", "stickerGifTooLarge": "이 GIF는 너무 큽니다. 1.8MB 미만인 파일을 사용하세요.", + "importStickersAction": "스티커 가져오기", + "importStickersHint": "스티커 팩 파일을 가져오거나 저장해 둔 스티커 이미지를 선택하세요", + "stickersImported": "스티커 {count}개를 추가했습니다", + "stickersImportedNone": "새로 추가할 것이 없습니다", + "stickersImportDuplicates": "{count}개는 이미 보관함에 있습니다", + "stickersImportUnreadable": "{count}개는 읽을 수 없습니다", + "stickerPackDeleteAction": "팩 삭제", + "stickerPackDeleteTitle": "이 팩을 삭제할까요?", + "stickerPackDeleteBody": "이 팩의 모든 스티커가 보관함에서 삭제됩니다.", + "stickerPackLoose": "내 스티커", "backupSection": "백업", "backupExportTitle": "백업 내보내기", "backupExportSubtitle": "이 계정의 암호화된 사본을 저장합니다", diff --git a/i18n/tr.json b/i18n/tr.json index 8d2ec6e..ccbd556 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -410,6 +410,16 @@ "saveStickerAction": "Çıkartmayı kaydet", "stickerCreationFailed": "Çıkartma oluşturulamadı", "stickerGifTooLarge": "Bu GIF çok büyük. 1,8 MB altında bir tane dene.", + "importStickersAction": "Çıkartma içe aktar", + "importStickersHint": "Bir çıkartma paketi dosyası getirin ya da kayıtlı çıkartma görsellerinizi seçin", + "stickersImported": "{count} çıkartma eklendi", + "stickersImportedNone": "Eklenecek yeni bir şey yok", + "stickersImportDuplicates": "{count} tanesi zaten koleksiyonunuzdaydı", + "stickersImportUnreadable": "{count} tanesi okunamadı", + "stickerPackDeleteAction": "Paketi kaldır", + "stickerPackDeleteTitle": "Bu paket kaldırılsın mı?", + "stickerPackDeleteBody": "Bu paketteki her çıkartma koleksiyonunuzdan kaldırılacak.", + "stickerPackLoose": "Çıkartmalarım", "backupSection": "Yedekleme", "backupExportTitle": "Yedek dışa aktar", "backupExportSubtitle": "Bu hesabın şifreli bir kopyasını kaydet", From cab5aef1e6c361a12dc5835eba5a1cb5e8c455d4 Mon Sep 17 00:00:00 2001 From: noydra Date: Mon, 28 Sep 2026 18:02:07 +0300 Subject: [PATCH 4/5] Slide the sticker panel open and let its button lead back The emoji and sound panels appeared and vanished in one frame. They now rise into place, and their contents fade in as they arrive. When the keyboard is already up the panel takes over its exact height at once, so the two never fight each other on the way past and nothing jumps. The button that opens the panel now turns into a keyboard while the panel is open, so the same button leads back to typing instead of leaving people hunting for the text field. It turns over rather than fading, to read as one button changing its mind. --- .../stade/ui/components/ChatComposerBar.kt | 75 +++++++++++++++++-- .../dev/stade/ui/components/KeyboardPanel.kt | 45 ++++++++++- .../kotlin/dev/stade/ui/i18n/AppStrings.kt | 1 + .../kotlin/dev/stade/ui/i18n/ArabicStrings.kt | 1 + .../dev/stade/ui/i18n/EnglishStrings.kt | 1 + .../kotlin/dev/stade/ui/i18n/KoreanStrings.kt | 1 + .../dev/stade/ui/i18n/PersianStrings.kt | 1 + .../dev/stade/ui/i18n/TurkishStrings.kt | 1 + .../kotlin/dev/stade/ui/screens/ChatScreen.kt | 7 +- .../dev/stade/ui/screens/GroupChatScreen.kt | 7 +- .../dev/stade/ui/screens/StadiumScreen.kt | 7 +- i18n/ar.json | 1 + i18n/en.json | 1 + i18n/fa.json | 1 + i18n/ko.json | 1 + i18n/tr.json | 1 + 16 files changed, 138 insertions(+), 14 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/components/ChatComposerBar.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/components/ChatComposerBar.kt index d9ea57c..74b5696 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/ui/components/ChatComposerBar.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/components/ChatComposerBar.kt @@ -32,6 +32,10 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.filled.Keyboard +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.foundation.text.BasicTextField import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send @@ -106,6 +110,8 @@ data class ChatComposerReplyPreview(val senderLabel: String, val snippet: String private enum class VoiceSendMode { MIC, STOP, SEND } +private const val ICON_SWAP_MS = 220 + @OptIn(ExperimentalFoundationApi::class) @Composable fun ChatComposerBar( @@ -129,12 +135,16 @@ fun ChatComposerBar( onCancelRecording: () -> Unit = {}, recordingElapsedMs: Long = 0L, onInputFocused: () -> Unit = {}, - onOpenEmojiPicker: () -> Unit = {} + onOpenEmojiPicker: () -> Unit = {}, + onCloseEmojiPicker: () -> Unit = {}, + drawerOpen: Boolean = false ) { val strings = LocalStrings.current var plusOpen by remember { mutableStateOf(false) } val canSend = draft.text.isNotBlank() || pendingImages.isNotEmpty() || pendingVideo != null || pendingVoiceClip != null val interactionSource = remember { MutableInteractionSource() } + val composerFocus = remember { FocusRequester() } + val softKeyboard = LocalSoftwareKeyboardController.current val haptic = LocalHapticFeedback.current var cancelDragPx by remember { mutableStateOf(0f) } val cancelThresholdPx = with(LocalDensity.current) { CANCEL_SLIDE_DISTANCE.toPx() } @@ -356,6 +366,7 @@ fun ChatComposerBar( modifier = Modifier .weight(1f) .height(54.dp) + .focusRequester(composerFocus) .onPreviewKeyEvent { keyEvent -> if (keyEvent.type == KeyEventType.KeyDown && keyEvent.key == Key.Enter) { if (keyEvent.isShiftPressed) { @@ -386,15 +397,18 @@ fun ChatComposerBar( verticalAlignment = Alignment.CenterVertically ) { IconButton( - onClick = onOpenEmojiPicker, + onClick = { + if (drawerOpen) { + onCloseEmojiPicker() + runCatching { composerFocus.requestFocus() } + softKeyboard?.show() + } else { + onOpenEmojiPicker() + } + }, modifier = Modifier.size(40.dp) ) { - Icon( - Icons.Default.InsertEmoticon, - contentDescription = strings.emojiPickerAction, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(24.dp) - ) + EmojiKeyboardToggleIcon(showKeyboard = drawerOpen) } Box(modifier = Modifier.weight(1f)) { if (draft.text.isEmpty()) { @@ -639,3 +653,48 @@ private fun RecordingStrip( ) } } + +@Composable +private fun EmojiKeyboardToggleIcon(showKeyboard: Boolean) { + val strings = LocalStrings.current + val swap by animateFloatAsState( + targetValue = if (showKeyboard) 1f else 0f, + animationSpec = tween(ICON_SWAP_MS, easing = FastOutSlowInEasing), + label = "emojiKeyboardSwap" + ) + val tint = MaterialTheme.colorScheme.primary + Box(contentAlignment = Alignment.Center) { + if (swap < 0.999f) { + Icon( + Icons.Default.InsertEmoticon, + contentDescription = if (showKeyboard) null else strings.emojiPickerAction, + tint = tint, + modifier = Modifier + .size(24.dp) + .graphicsLayer { + alpha = 1f - swap + rotationY = swap * -90f + val shrink = 1f - 0.25f * swap + scaleX = shrink + scaleY = shrink + } + ) + } + if (swap > 0.001f) { + Icon( + Icons.Default.Keyboard, + contentDescription = if (showKeyboard) strings.showKeyboardAction else null, + tint = tint, + modifier = Modifier + .size(24.dp) + .graphicsLayer { + alpha = swap + rotationY = (1f - swap) * 90f + val grow = 0.75f + 0.25f * swap + scaleX = grow + scaleY = grow + } + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/components/KeyboardPanel.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/components/KeyboardPanel.kt index f733e53..99741f2 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/ui/components/KeyboardPanel.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/components/KeyboardPanel.kt @@ -1,5 +1,11 @@ package dev.stade.ui.components +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.ui.graphics.graphicsLayer +import kotlin.math.roundToInt import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -32,6 +38,11 @@ import kotlinx.coroutines.flow.distinctUntilChanged private val FALLBACK_PANEL_HEIGHT = 280.dp private val MIN_REAL_KEYBOARD = 140.dp private const val KEYBOARD_SETTLE_MS = 160L +private const val PANEL_SLIDE_MS = 210 +private const val PANEL_REVEAL_MS = 170 +private const val PANEL_HIDE_MS = 110 +private val PANEL_REVEAL_LIFT = 18.dp +private val PanelSlideEasing = FastOutSlowInEasing private val sharedPanelHeight = mutableStateOf( loadPanelHeightDp().takeIf { it >= MIN_REAL_KEYBOARD.value.toInt() }?.dp ?: FALLBACK_PANEL_HEIGHT @@ -77,16 +88,44 @@ fun BottomInsetPanel( content: @Composable () -> Unit ) { val density = LocalDensity.current - val panelPx = if (visible) with(density) { state.height.roundToPx() } else 0 + val targetPx = with(density) { state.height.roundToPx() }.toFloat() + val keyboardPx = WindowInsets.ime.getBottom(density) + + val panelHeight = remember { Animatable(if (visible) targetPx else 0f) } + val reveal = remember { Animatable(if (visible) 1f else 0f) } + + LaunchedEffect(visible, targetPx) { + val goal = if (visible) targetPx else 0f + if (keyboardPx > 0) { + panelHeight.snapTo(goal) + } else { + panelHeight.animateTo(goal, tween(PANEL_SLIDE_MS, easing = PanelSlideEasing)) + } + } + + LaunchedEffect(visible) { + reveal.animateTo( + if (visible) 1f else 0f, + tween(if (visible) PANEL_REVEAL_MS else PANEL_HIDE_MS, easing = LinearOutSlowInEasing) + ) + } + + val panelPx = panelHeight.value.roundToInt() val panelInsets = remember(panelPx) { WindowInsets(bottom = panelPx) } Box( modifier = Modifier .fillMaxWidth() .windowInsetsBottomHeight(WindowInsets.ime.union(panelInsets)) ) { - if (visible) { + if (panelPx > 0 || reveal.value > 0.01f) { + val shown = reveal.value Surface( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + alpha = shown + translationY = (1f - shown) * PANEL_REVEAL_LIFT.toPx() + }, color = MaterialTheme.colorScheme.surface, tonalElevation = 1.dp ) { diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/AppStrings.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/AppStrings.kt index 3d1d4a1..c4a53f7 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/AppStrings.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/AppStrings.kt @@ -415,6 +415,7 @@ abstract class AppStrings { abstract val videoOpenFailed: String abstract val emojiPickerAction: String + abstract val showKeyboardAction: String abstract val stickerMessage: String abstract val emojiTabLabel: String abstract val stickersTabLabel: String diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/ArabicStrings.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/ArabicStrings.kt index d686822..6842951 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/ArabicStrings.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/ArabicStrings.kt @@ -460,6 +460,7 @@ object ArabicStrings : AppStrings() { override val videoOpenFailed = "تعذّر فتح مشغّل فيديو لهذا الملف" override val emojiPickerAction = "الرموز والملصقات" + override val showKeyboardAction = "إظهار لوحة المفاتيح" override val stickerMessage = "🏷️ ملصق" override val emojiTabLabel = "رموز" override val stickersTabLabel = "ملصقات" diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/EnglishStrings.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/EnglishStrings.kt index 63d3bb1..f05c536 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/EnglishStrings.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/EnglishStrings.kt @@ -477,6 +477,7 @@ object EnglishStrings : AppStrings() { override val videoOpenFailed = "Couldn't open a video player for this file" override val emojiPickerAction = "Emoji and stickers" + override val showKeyboardAction = "Show keyboard" override val stickerMessage = "🏷️ Sticker" override val emojiTabLabel = "Emoji" override val stickersTabLabel = "Stickers" diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/KoreanStrings.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/KoreanStrings.kt index 2367e50..0be3cb9 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/KoreanStrings.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/KoreanStrings.kt @@ -460,6 +460,7 @@ object KoreanStrings : AppStrings() { override val videoOpenFailed = "이 파일을 재생할 동영상 플레이어를 열지 못했습니다" override val emojiPickerAction = "이모지 및 스티커" + override val showKeyboardAction = "키보드 표시" override val stickerMessage = "🏷️ 스티커" override val emojiTabLabel = "이모지" override val stickersTabLabel = "스티커" diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/PersianStrings.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/PersianStrings.kt index d38c595..9291bb3 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/PersianStrings.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/PersianStrings.kt @@ -460,6 +460,7 @@ object PersianStrings : AppStrings() { override val videoOpenFailed = "پخش‌کننده‌ای برای این فایل باز نشد" override val emojiPickerAction = "ایموجی و استیکر" + override val showKeyboardAction = "نمایش صفحه‌کلید" override val stickerMessage = "🏷️ استیکر" override val emojiTabLabel = "ایموجی" override val stickersTabLabel = "استیکرها" diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/TurkishStrings.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/TurkishStrings.kt index 8803281..c475c66 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/TurkishStrings.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/i18n/TurkishStrings.kt @@ -476,6 +476,7 @@ object TurkishStrings : AppStrings() { override val videoOpenFailed = "Bu dosya için video oynatıcı açılamadı" override val emojiPickerAction = "Emoji ve çıkartmalar" + override val showKeyboardAction = "Klavyeyi göster" override val stickerMessage = "🏷️ Çıkartma" override val emojiTabLabel = "Emoji" override val stickersTabLabel = "Çıkartmalar" diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/ChatScreen.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/ChatScreen.kt index 4c52927..08c7fca 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/ChatScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/ChatScreen.kt @@ -1409,7 +1409,12 @@ fun ChatScreen( keyboardController?.hide() padOpen = false showEmojiDrawer = true - } + }, + onCloseEmojiPicker = { + showEmojiDrawer = false + padOpen = false + }, + drawerOpen = showEmojiDrawer || padOpen ) val padContact = contact diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/GroupChatScreen.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/GroupChatScreen.kt index 495f778..70d42ad 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/GroupChatScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/GroupChatScreen.kt @@ -1220,7 +1220,12 @@ fun GroupChatScreen( keyboardController?.hide() padOpen = false showEmojiDrawer = true - } + }, + onCloseEmojiPicker = { + showEmojiDrawer = false + padOpen = false + }, + drawerOpen = showEmojiDrawer || padOpen ) BottomInsetPanel(visible = anyPanelOpen, state = panelState) { diff --git a/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/StadiumScreen.kt b/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/StadiumScreen.kt index 5203442..00e920d 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/StadiumScreen.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/ui/screens/StadiumScreen.kt @@ -780,7 +780,12 @@ fun StadiumScreen( keyboardController?.hide() padOpen = false showEmojiDrawer = true - } + }, + onCloseEmojiPicker = { + showEmojiDrawer = false + padOpen = false + }, + drawerOpen = showEmojiDrawer || padOpen ) val padStadium = current diff --git a/i18n/ar.json b/i18n/ar.json index c7f77ab..e401ac9 100644 --- a/i18n/ar.json +++ b/i18n/ar.json @@ -395,6 +395,7 @@ "vlcNotFoundHint": "ثبّت VLC لتشغيل الفيديو داخل التطبيق", "videoOpenFailed": "تعذّر فتح مشغّل فيديو لهذا الملف", "emojiPickerAction": "الرموز والملصقات", + "showKeyboardAction": "إظهار لوحة المفاتيح", "stickerMessage": "🏷️ ملصق", "emojiTabLabel": "رموز", "stickersTabLabel": "ملصقات", diff --git a/i18n/en.json b/i18n/en.json index 92cd644..e3ee6c4 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -395,6 +395,7 @@ "vlcNotFoundHint": "Install VLC to play videos in-app", "videoOpenFailed": "Couldn't open a video player for this file", "emojiPickerAction": "Emoji and stickers", + "showKeyboardAction": "Show keyboard", "stickerMessage": "🏷️ Sticker", "emojiTabLabel": "Emoji", "stickersTabLabel": "Stickers", diff --git a/i18n/fa.json b/i18n/fa.json index bbaa919..853760f 100644 --- a/i18n/fa.json +++ b/i18n/fa.json @@ -395,6 +395,7 @@ "vlcNotFoundHint": "برای پخش ویدیو در برنامه، VLC را نصب کنید", "videoOpenFailed": "پخش‌کننده‌ای برای این فایل باز نشد", "emojiPickerAction": "ایموجی و استیکر", + "showKeyboardAction": "نمایش صفحه‌کلید", "stickerMessage": "🏷️ استیکر", "emojiTabLabel": "ایموجی", "stickersTabLabel": "استیکرها", diff --git a/i18n/ko.json b/i18n/ko.json index 00ba739..32a779a 100644 --- a/i18n/ko.json +++ b/i18n/ko.json @@ -395,6 +395,7 @@ "vlcNotFoundHint": "앱에서 동영상을 재생하려면 VLC를 설치하세요", "videoOpenFailed": "이 파일을 재생할 동영상 플레이어를 열지 못했습니다", "emojiPickerAction": "이모지 및 스티커", + "showKeyboardAction": "키보드 표시", "stickerMessage": "🏷️ 스티커", "emojiTabLabel": "이모지", "stickersTabLabel": "스티커", diff --git a/i18n/tr.json b/i18n/tr.json index ccbd556..1dfc504 100644 --- a/i18n/tr.json +++ b/i18n/tr.json @@ -395,6 +395,7 @@ "vlcNotFoundHint": "Uygulama içinde oynatmak için VLC kurun", "videoOpenFailed": "Bu dosya için video oynatıcı açılamadı", "emojiPickerAction": "Emoji ve çıkartmalar", + "showKeyboardAction": "Klavyeyi göster", "stickerMessage": "🏷️ Çıkartma", "emojiTabLabel": "Emoji", "stickersTabLabel": "Çıkartmalar", From 3487e70c005393e33dfaa8184c62470567128a06 Mon Sep 17 00:00:00 2001 From: noydra Date: Mon, 28 Sep 2026 18:36:34 +0300 Subject: [PATCH 5/5] Bump the version to 0.3.4 --- composeApp/build.gradle.kts | 4 ++-- composeApp/src/commonMain/kotlin/dev/stade/AppVersion.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 21499ba..23e6858 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -3,8 +3,8 @@ import java.util.Properties import java.net.URI import java.security.MessageDigest -val appVersion = "0.3.3" -val appVersionCode = 27 +val appVersion = "0.3.4" +val appVersionCode = 28 val appVersionSource = file("src/commonMain/kotlin/dev/stade/AppVersion.kt") val declaredAppVersion = Regex("""APP_VERSION\s*=\s*"([^"]+)"""") diff --git a/composeApp/src/commonMain/kotlin/dev/stade/AppVersion.kt b/composeApp/src/commonMain/kotlin/dev/stade/AppVersion.kt index 06f5ab3..905cb90 100644 --- a/composeApp/src/commonMain/kotlin/dev/stade/AppVersion.kt +++ b/composeApp/src/commonMain/kotlin/dev/stade/AppVersion.kt @@ -1,3 +1,3 @@ package dev.stade -const val APP_VERSION = "0.3.3" +const val APP_VERSION = "0.3.4"