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/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/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" 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/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/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/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/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..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 @@ -430,6 +431,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..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 = "ملصقات" @@ -475,6 +476,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..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" @@ -492,6 +493,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..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 = "스티커" @@ -475,6 +476,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..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 = "استیکرها" @@ -475,6 +476,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..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" @@ -491,6 +492,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..08c7fca 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) } @@ -1390,7 +1409,12 @@ fun ChatScreen( keyboardController?.hide() padOpen = false showEmojiDrawer = true - } + }, + onCloseEmojiPicker = { + showEmojiDrawer = false + padOpen = false + }, + drawerOpen = showEmojiDrawer || padOpen ) val padContact = contact @@ -1414,6 +1438,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 +1447,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..70d42ad 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() } } @@ -1201,7 +1220,12 @@ fun GroupChatScreen( keyboardController?.hide() padOpen = false showEmojiDrawer = true - } + }, + onCloseEmojiPicker = { + showEmojiDrawer = false + padOpen = false + }, + drawerOpen = showEmojiDrawer || padOpen ) BottomInsetPanel(visible = anyPanelOpen, state = panelState) { @@ -1223,6 +1247,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 +1256,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..00e920d 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()) } @@ -761,7 +780,12 @@ fun StadiumScreen( keyboardController?.hide() padOpen = false showEmojiDrawer = true - } + }, + onCloseEmojiPicker = { + showEmojiDrawer = false + padOpen = false + }, + drawerOpen = showEmojiDrawer || padOpen ) val padStadium = current @@ -785,6 +809,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 +818,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/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/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/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/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 { 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) 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..e401ac9 100644 --- a/i18n/ar.json +++ b/i18n/ar.json @@ -395,6 +395,7 @@ "vlcNotFoundHint": "ثبّت VLC لتشغيل الفيديو داخل التطبيق", "videoOpenFailed": "تعذّر فتح مشغّل فيديو لهذا الملف", "emojiPickerAction": "الرموز والملصقات", + "showKeyboardAction": "إظهار لوحة المفاتيح", "stickerMessage": "🏷️ ملصق", "emojiTabLabel": "رموز", "stickersTabLabel": "ملصقات", @@ -410,6 +411,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..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", @@ -410,6 +411,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..853760f 100644 --- a/i18n/fa.json +++ b/i18n/fa.json @@ -395,6 +395,7 @@ "vlcNotFoundHint": "برای پخش ویدیو در برنامه، VLC را نصب کنید", "videoOpenFailed": "پخش‌کننده‌ای برای این فایل باز نشد", "emojiPickerAction": "ایموجی و استیکر", + "showKeyboardAction": "نمایش صفحه‌کلید", "stickerMessage": "🏷️ استیکر", "emojiTabLabel": "ایموجی", "stickersTabLabel": "استیکرها", @@ -410,6 +411,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..32a779a 100644 --- a/i18n/ko.json +++ b/i18n/ko.json @@ -395,6 +395,7 @@ "vlcNotFoundHint": "앱에서 동영상을 재생하려면 VLC를 설치하세요", "videoOpenFailed": "이 파일을 재생할 동영상 플레이어를 열지 못했습니다", "emojiPickerAction": "이모지 및 스티커", + "showKeyboardAction": "키보드 표시", "stickerMessage": "🏷️ 스티커", "emojiTabLabel": "이모지", "stickersTabLabel": "스티커", @@ -410,6 +411,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..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", @@ -410,6 +411,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",