From 64040d5961016e55d296c9147b96d9c5e4fdf52b Mon Sep 17 00:00:00 2001 From: alimu Date: Sat, 14 Mar 2026 16:34:02 +0400 Subject: [PATCH] feat(android): add image message sending --- android-client/app/build.gradle.kts | 1 + .../client/data/local/ChatDatabase.kt | 13 +- .../data/local/ChatHistoryRepository.kt | 12 +- .../client/data/local/ChatMessageEntity.kt | 6 +- .../client/data/protocol/ProtocolModels.kt | 23 + .../com/onlinemsg/client/ui/ChatScreen.kt | 394 +++++++++++++++++- .../onlinemsg/client/ui/ChatSessionManager.kt | 320 +++++++++++++- .../com/onlinemsg/client/ui/ChatUiState.kt | 11 +- .../com/onlinemsg/client/ui/ChatViewModel.kt | 2 + .../client/util/ImageMessageProcessor.kt | 289 +++++++++++++ .../onlinemsg/client/util/LanguageManager.kt | 56 +++ 11 files changed, 1112 insertions(+), 15 deletions(-) create mode 100644 android-client/app/src/main/java/com/onlinemsg/client/util/ImageMessageProcessor.kt diff --git a/android-client/app/build.gradle.kts b/android-client/app/build.gradle.kts index a670901..edb5d85 100644 --- a/android-client/app/build.gradle.kts +++ b/android-client/app/build.gradle.kts @@ -87,6 +87,7 @@ dependencies { implementation("androidx.datastore:datastore-preferences:1.1.1") implementation("androidx.room:room-runtime:2.6.1") implementation("androidx.room:room-ktx:2.6.1") + implementation("androidx.exifinterface:exifinterface:1.3.7") ksp("androidx.room:room-compiler:2.6.1") implementation("com.squareup.okhttp3:okhttp:4.12.0") diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatDatabase.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatDatabase.kt index 7c5e1a5..2d70723 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatDatabase.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatDatabase.kt @@ -9,7 +9,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase @Database( entities = [ChatMessageEntity::class], - version = 3, + version = 4, exportSchema = false ) abstract class ChatDatabase : RoomDatabase() { @@ -35,6 +35,15 @@ abstract class ChatDatabase : RoomDatabase() { } } + private val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE chat_messages ADD COLUMN imageBase64 TEXT NOT NULL DEFAULT ''") + db.execSQL("ALTER TABLE chat_messages ADD COLUMN imageMimeType TEXT NOT NULL DEFAULT ''") + db.execSQL("ALTER TABLE chat_messages ADD COLUMN imageWidth INTEGER NOT NULL DEFAULT 0") + db.execSQL("ALTER TABLE chat_messages ADD COLUMN imageHeight INTEGER NOT NULL DEFAULT 0") + } + } + fun getInstance(context: Context): ChatDatabase { return instance ?: synchronized(this) { instance ?: Room.databaseBuilder( @@ -42,7 +51,7 @@ abstract class ChatDatabase : RoomDatabase() { ChatDatabase::class.java, DB_NAME ) - .addMigrations(MIGRATION_1_2, MIGRATION_2_3) + .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) .build().also { db -> instance = db } diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatHistoryRepository.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatHistoryRepository.kt index c176815..c0c20e0 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatHistoryRepository.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatHistoryRepository.kt @@ -46,7 +46,11 @@ private fun UiMessage.toEntity(serverKey: String): ChatMessageEntity { timestampMillis = timestampMillis, contentType = contentType.name, audioBase64 = audioBase64, - audioDurationMillis = audioDurationMillis + audioDurationMillis = audioDurationMillis, + imageBase64 = imageBase64, + imageMimeType = imageMimeType, + imageWidth = imageWidth, + imageHeight = imageHeight ) } @@ -65,7 +69,11 @@ private fun ChatMessageEntity.toUiMessageOrNull(): UiMessage? { timestampMillis = timestampMillis, contentType = parsedContentType, audioBase64 = audioBase64, - audioDurationMillis = audioDurationMillis + audioDurationMillis = audioDurationMillis, + imageBase64 = imageBase64, + imageMimeType = imageMimeType, + imageWidth = imageWidth, + imageHeight = imageHeight ) } diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageEntity.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageEntity.kt index 2cc8c11..190f81c 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageEntity.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/local/ChatMessageEntity.kt @@ -15,5 +15,9 @@ data class ChatMessageEntity( val timestampMillis: Long, val contentType: String, val audioBase64: String, - val audioDurationMillis: Long + val audioDurationMillis: Long, + val imageBase64: String, + val imageMimeType: String, + val imageWidth: Int, + val imageHeight: Int ) diff --git a/android-client/app/src/main/java/com/onlinemsg/client/data/protocol/ProtocolModels.kt b/android-client/app/src/main/java/com/onlinemsg/client/data/protocol/ProtocolModels.kt index e872053..782cc35 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/data/protocol/ProtocolModels.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/data/protocol/ProtocolModels.kt @@ -60,6 +60,29 @@ data class AudioChunkPayloadDto( @SerialName("data") val data: String ) +@Serializable +data class ImagePayloadDto( + @SerialName("version") val version: Int = 1, + @SerialName("encoding") val encoding: String = "base64", + @SerialName("mimeType") val mimeType: String = "image/jpeg", + @SerialName("width") val width: Int = 0, + @SerialName("height") val height: Int = 0, + @SerialName("data") val data: String +) + +@Serializable +data class ImageChunkPayloadDto( + @SerialName("version") val version: Int = 1, + @SerialName("encoding") val encoding: String = "base64", + @SerialName("mimeType") val mimeType: String = "image/jpeg", + @SerialName("messageId") val messageId: String, + @SerialName("index") val index: Int, + @SerialName("total") val total: Int, + @SerialName("width") val width: Int = 0, + @SerialName("height") val height: Int = 0, + @SerialName("data") val data: String +) + fun JsonElement?.asPayloadText(): String { if (this == null || this is JsonNull) return "" return if (this is JsonPrimitive && this.isString) { diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt index 84fe8bf..e345040 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatScreen.kt @@ -5,12 +5,18 @@ import android.app.NotificationManager import android.content.Context import android.content.Intent import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.BitmapFactory import android.media.AudioAttributes import android.media.MediaPlayer +import android.net.Uri import android.os.Build import android.provider.Settings import android.util.Base64 import android.view.MotionEvent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.PickVisualMediaRequest +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -21,9 +27,11 @@ import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets @@ -50,12 +58,15 @@ import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Send +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.BrokenImage import androidx.compose.material.icons.rounded.ContentCopy import androidx.compose.material.icons.rounded.Forum import androidx.compose.material.icons.rounded.Keyboard import androidx.compose.material.icons.rounded.Settings import androidx.compose.material.icons.rounded.Language import androidx.compose.material.icons.rounded.MusicNote +import androidx.compose.material.icons.rounded.PhotoLibrary import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.Stop import androidx.compose.material.icons.rounded.KeyboardVoice @@ -84,16 +95,23 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalFocusManager @@ -111,6 +129,8 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import com.onlinemsg.client.ui.theme.OnlineMsgTheme import java.time.Instant import java.time.ZoneId @@ -120,7 +140,9 @@ import com.onlinemsg.client.ui.theme.themeOptions import com.onlinemsg.client.util.AudioRecorder import com.onlinemsg.client.util.LanguageManager import com.onlinemsg.client.util.NotificationSoundCatalog +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext /** @@ -230,6 +252,7 @@ fun OnlineMsgApp(viewModel: ChatViewModel = viewModel()) { onDraftChange = viewModel::updateDraft, onSend = viewModel::sendMessage, onSendAudio = viewModel::sendAudioMessage, + onSendImage = viewModel::sendImageMessage, onCopyMessage = { content -> clipboard.setText(AnnotatedString(content)) viewModel.onMessageCopied() @@ -334,6 +357,7 @@ private fun ChatTab( onDraftChange: (String) -> Unit, onSend: () -> Unit, onSendAudio: (String, Long) -> Unit, + onSendImage: (Uri) -> Unit, onCopyMessage: (String) -> Unit ) { val context = LocalContext.current @@ -341,14 +365,24 @@ private fun ChatTab( val audioRecorder = remember(context) { AudioRecorder(context) } val audioPlayer = remember(context) { AudioMessagePlayer(context) } var inputMode by rememberSaveable { mutableStateOf(ChatInputMode.TEXT) } + var attachmentPanelExpanded by rememberSaveable { mutableStateOf(false) } var isRecording by remember { mutableStateOf(false) } var cancelOnRelease by remember { mutableStateOf(false) } var pressDownRawY by remember { mutableStateOf(0f) } var audioHint by remember { mutableStateOf("") } var audioHintVersion by remember { mutableStateOf(0L) } var playingMessageId by remember { mutableStateOf(null) } + var previewingImageMessage by remember { mutableStateOf(null) } var recordingStartedAtMillis by remember { mutableStateOf(0L) } var recordingElapsedMillis by remember { mutableStateOf(0L) } + val imagePickerLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.PickVisualMedia() + ) { uri -> + attachmentPanelExpanded = false + if (uri != null) { + onSendImage(uri) + } + } val recordingPulse = rememberInfiniteTransition(label = "recordingPulse") val recordingPulseScale by recordingPulse.animateFloat( initialValue = 0.9f, @@ -378,6 +412,9 @@ private fun ChatTab( val canHoldToRecord = state.status == ConnectionStatus.READY && !state.sending && (!state.directMode || state.targetKey.trim().isNotBlank()) + val canPickImage = state.status == ConnectionStatus.READY && + !state.sending && + (!state.directMode || state.targetKey.trim().isNotBlank()) fun hasRecordPermission(): Boolean { return ContextCompat.checkSelfPermission( @@ -455,6 +492,12 @@ private fun ChatTab( } } + LaunchedEffect(inputMode) { + if (inputMode != ChatInputMode.TEXT) { + attachmentPanelExpanded = false + } + } + DisposableEffect(Unit) { onDispose { audioRecorder.release() @@ -610,6 +653,7 @@ private fun ChatTab( } playingMessageId = nextPlaying }, + onOpenImage = { previewingImageMessage = message }, isPlaying = playingMessageId == message.id, currentLanguage = state.language ) @@ -619,6 +663,48 @@ private fun ChatTab( Spacer(modifier = Modifier.height(8.dp)) + AnimatedVisibility( + visible = inputMode == ChatInputMode.TEXT && attachmentPanelExpanded, + enter = fadeIn(animationSpec = tween(150)) + slideInVertically( + animationSpec = tween(220), + initialOffsetY = { it / 3 } + ), + exit = fadeOut(animationSpec = tween(120)) + slideOutVertically( + animationSpec = tween(180), + targetOffsetY = { it / 4 } + ) + ) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ), + shape = RoundedCornerShape(18.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + AttachmentActionButton( + icon = Icons.Rounded.PhotoLibrary, + label = t("chat.image_entry"), + description = t("chat.image_gallery"), + enabled = canPickImage, + onClick = { + imagePickerLauncher.launch( + PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly) + ) + } + ) + } + } + Spacer(modifier = Modifier.height(8.dp)) + } + } + if (inputMode == ChatInputMode.TEXT) { Row( modifier = Modifier.fillMaxWidth(), @@ -626,9 +712,12 @@ private fun ChatTab( horizontalArrangement = Arrangement.spacedBy(8.dp) ) { IconButton( - onClick = { inputMode = ChatInputMode.AUDIO }, + onClick = { + attachmentPanelExpanded = false + inputMode = ChatInputMode.AUDIO + }, modifier = Modifier - .size(56.dp) + .size(48.dp) .background( color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(14.dp) @@ -655,10 +744,27 @@ private fun ChatTab( ) ) + IconButton( + onClick = { attachmentPanelExpanded = !attachmentPanelExpanded }, + enabled = attachmentPanelExpanded || canPickImage, + modifier = Modifier + .size(48.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(14.dp) + ) + ) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = t("chat.image_entry"), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Button( onClick = onSend, enabled = state.canSend, - modifier = Modifier.size(56.dp), + modifier = Modifier.size(48.dp), contentPadding = PaddingValues(0.dp) ) { Icon( @@ -696,10 +802,11 @@ private fun ChatTab( if (isRecording) { finishRecording(send = false) } + attachmentPanelExpanded = false inputMode = ChatInputMode.TEXT }, modifier = Modifier - .size(56.dp) + .size(48.dp) .background( color = MaterialTheme.colorScheme.surfaceVariant, shape = RoundedCornerShape(14.dp) @@ -807,6 +914,14 @@ private fun ChatTab( } } } + + previewingImageMessage?.let { message -> + ImagePreviewDialog( + message = message, + currentLanguage = state.language, + onDismiss = { previewingImageMessage = null } + ) + } } /** @@ -817,6 +932,7 @@ private fun MessageItem( message: UiMessage, onCopy: () -> Unit, onPlayAudio: () -> Unit, + onOpenImage: () -> Unit, isPlaying: Boolean, currentLanguage: String ) { @@ -938,6 +1054,15 @@ private fun MessageItem( isPlaying = isPlaying, currentLanguage = currentLanguage ) + } else if (message.contentType == MessageContentType.IMAGE && + message.imageBase64.isNotBlank() + ) { + ImageMessageBody( + message = message, + bubbleTextColor = bubbleTextColor, + onOpenImage = onOpenImage, + currentLanguage = currentLanguage + ) } else { Text( text = message.content, @@ -948,7 +1073,9 @@ private fun MessageItem( // 时间戳和复制按钮 Row( - modifier = if (message.contentType == MessageContentType.AUDIO) { + modifier = if (message.contentType == MessageContentType.AUDIO || + message.contentType == MessageContentType.IMAGE + ) { Modifier.align(Alignment.End) } else { Modifier.fillMaxWidth() @@ -1076,6 +1203,261 @@ private fun AudioMessageBody( } } +@Composable +private fun ImageMessageBody( + message: UiMessage, + bubbleTextColor: Color, + onOpenImage: () -> Unit, + currentLanguage: String +) { + val imageBitmap by rememberDecodedImageBitmap( + base64 = message.imageBase64, + maxDimensionPx = MESSAGE_IMAGE_PREVIEW_EDGE_PX + ) + val previewLabel = LanguageManager.getString("chat.image_preview", currentLanguage) + val fallbackLabel = LanguageManager.getString("chat.image_unavailable", currentLanguage) + val rawRatio = when { + message.imageWidth > 0 && message.imageHeight > 0 -> + message.imageWidth.toFloat() / message.imageHeight.toFloat() + imageBitmap != null && imageBitmap!!.height > 0 -> + imageBitmap!!.width.toFloat() / imageBitmap!!.height.toFloat() + else -> 1f + } + val bubbleRatio = rawRatio.coerceIn(0.68f, 1.45f) + + Box( + modifier = Modifier + .widthIn(min = 140.dp, max = 220.dp) + .clip(RoundedCornerShape(16.dp)) + .clickable(onClick = onOpenImage) + ) { + if (imageBitmap != null) { + Image( + bitmap = imageBitmap!!, + contentDescription = previewLabel, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(bubbleRatio), + contentScale = ContentScale.Crop + ) + } else { + Column( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(1f) + .background( + color = bubbleTextColor.copy(alpha = 0.12f), + shape = RoundedCornerShape(16.dp) + ) + .padding(16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + imageVector = Icons.Rounded.BrokenImage, + contentDescription = null, + tint = bubbleTextColor.copy(alpha = 0.8f), + modifier = Modifier.size(26.dp) + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = fallbackLabel, + style = MaterialTheme.typography.labelMedium, + color = bubbleTextColor.copy(alpha = 0.78f) + ) + } + } + + if (message.imageWidth > 0 && message.imageHeight > 0) { + Text( + text = "${message.imageWidth}×${message.imageHeight}", + style = MaterialTheme.typography.labelSmall, + color = Color.White, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(8.dp) + .background( + color = Color.Black.copy(alpha = 0.42f), + shape = RoundedCornerShape(999.dp) + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } +} + +@Composable +private fun AttachmentActionButton( + icon: ImageVector, + label: String, + description: String, + enabled: Boolean, + onClick: () -> Unit +) { + Card( + modifier = Modifier + .width(92.dp) + .graphicsLayer { + alpha = if (enabled) 1f else 0.55f + } + .clickable(enabled = enabled, onClick = onClick), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + shape = RoundedCornerShape(16.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 10.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Box( + modifier = Modifier + .size(42.dp) + .background( + color = MaterialTheme.colorScheme.secondaryContainer, + shape = RoundedCornerShape(14.dp) + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = icon, + contentDescription = description, + tint = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier.size(22.dp) + ) + } + Text( + text = label, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = description, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } + } +} + +@Composable +private fun ImagePreviewDialog( + message: UiMessage, + currentLanguage: String, + onDismiss: () -> Unit +) { + val imageBitmap by rememberDecodedImageBitmap( + base64 = message.imageBase64, + maxDimensionPx = MESSAGE_IMAGE_FULL_EDGE_PX + ) + val previewLabel = LanguageManager.getString("chat.image_preview", currentLanguage) + val fallbackLabel = LanguageManager.getString("chat.image_unavailable", currentLanguage) + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false) + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.92f)) + .clickable(onClick = onDismiss) + .padding(20.dp), + contentAlignment = Alignment.Center + ) { + if (imageBitmap != null) { + Image( + bitmap = imageBitmap!!, + contentDescription = previewLabel, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(20.dp)), + contentScale = ContentScale.Fit + ) + } else { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(10.dp) + ) { + Icon( + imageVector = Icons.Rounded.BrokenImage, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(32.dp) + ) + Text( + text = fallbackLabel, + color = Color.White, + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } +} + +@Composable +private fun rememberDecodedImageBitmap( + base64: String, + maxDimensionPx: Int +): State { + return produceState(initialValue = null, base64, maxDimensionPx) { + value = if (base64.isBlank()) { + null + } else { + withContext(Dispatchers.Default) { + decodeMessageImageBitmap(base64, maxDimensionPx)?.asImageBitmap() + } + } + } +} + +private fun decodeMessageImageBitmap(base64: String, maxDimensionPx: Int): Bitmap? { + val imageBytes = runCatching { + Base64.decode(base64, Base64.DEFAULT) + }.getOrNull() ?: return null + + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + + val options = BitmapFactory.Options().apply { + inSampleSize = calculateBitmapSampleSize(bounds.outWidth, bounds.outHeight, maxDimensionPx) + inPreferredConfig = Bitmap.Config.ARGB_8888 + } + val bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size, options) ?: return null + val longEdge = maxOf(bitmap.width, bitmap.height) + if (longEdge <= maxDimensionPx) return bitmap + + val scale = maxDimensionPx.toFloat() / longEdge.toFloat() + val targetWidth = (bitmap.width * scale).toInt().coerceAtLeast(1) + val targetHeight = (bitmap.height * scale).toInt().coerceAtLeast(1) + return Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true) +} + +private fun calculateBitmapSampleSize( + width: Int, + height: Int, + maxDimensionPx: Int +): Int { + var sampleSize = 1 + var sampledWidth = width + var sampledHeight = height + while (maxOf(sampledWidth, sampledHeight) > maxDimensionPx * 2) { + sampleSize *= 2 + sampledWidth = width / sampleSize + sampledHeight = height / sampleSize + } + return sampleSize.coerceAtLeast(1) +} + /** * 设置选项卡界面,包含个人设置、服务器管理、身份安全、语言、主题和诊断信息。 * @param modifier 修饰符 @@ -1668,6 +2050,8 @@ private fun localizedStatusHintText(raw: String, language: String): String { private const val AUDIO_CANCEL_TRIGGER_PX = 120f private const val MIN_AUDIO_DURATION_MS = 350L +private const val MESSAGE_IMAGE_PREVIEW_EDGE_PX = 960 +private const val MESSAGE_IMAGE_FULL_EDGE_PX = 2048 /** * 将时间戳格式化为本地时间的小时:分钟(如 "14:30")。 diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt index 2723f0a..64bb91c 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatSessionManager.kt @@ -27,10 +27,13 @@ import com.onlinemsg.client.data.protocol.AudioChunkPayloadDto import com.onlinemsg.client.data.protocol.AuthPayloadDto import com.onlinemsg.client.data.protocol.EnvelopeDto import com.onlinemsg.client.data.protocol.HelloDataDto +import com.onlinemsg.client.data.protocol.ImageChunkPayloadDto +import com.onlinemsg.client.data.protocol.ImagePayloadDto import com.onlinemsg.client.data.protocol.SignedPayloadDto import com.onlinemsg.client.data.protocol.asPayloadText import com.onlinemsg.client.data.protocol.asStringOrNull import com.onlinemsg.client.service.ChatForegroundService +import com.onlinemsg.client.util.ImageMessageProcessor import com.onlinemsg.client.util.LanguageManager import com.onlinemsg.client.util.NotificationSoundCatalog import kotlinx.coroutines.CoroutineScope @@ -112,6 +115,7 @@ object ChatSessionManager { private var keepAliveRequested = false // 是否应保活(前台服务标志) private var notificationIdSeed = 2000 private val incomingAudioChunkBuffers = mutableMapOf() + private val incomingImageChunkBuffers = mutableMapOf() // WebSocket 事件监听器 private val socketListener = object : OnlineMsgSocketClient.Listener { @@ -605,7 +609,7 @@ object ChatSessionManager { displayNameSyncJob?.join() val safeDuration = durationMillis.coerceAtLeast(0L) val normalized = audioBase64.trim() - val chunks = splitAudioBase64(normalized, AUDIO_CHUNK_BASE64_SIZE) + val chunks = splitPayloadBase64(normalized, AUDIO_CHUNK_BASE64_SIZE) if (chunks.size > MAX_AUDIO_CHUNK_COUNT) { _uiState.update { it.copy( @@ -662,6 +666,95 @@ object ChatSessionManager { } } + /** + * 发送图片消息(选择本地图库图片后压缩并分片发送)。 + */ + fun sendImageMessage(imageUri: Uri) { + val current = _uiState.value + if (current.status != ConnectionStatus.READY || current.sending) return + val route = resolveOutgoingRoute(current) ?: return + + scope.launch { + _uiState.update { it.copy(sending = true) } + displayNameSyncJob?.join() + + runCatching { + withContext(Dispatchers.IO) { + ImageMessageProcessor.prepareForMessage( + contentResolver = app.contentResolver, + uri = imageUri, + maxLongEdgePx = MAX_IMAGE_EDGE_PX, + maxEncodedBytes = MAX_IMAGE_PAYLOAD_BYTES + ) + } + }.mapCatching { prepared -> + val normalized = prepared.base64.trim() + val chunks = splitPayloadBase64(normalized, IMAGE_CHUNK_BASE64_SIZE) + if (chunks.size > MAX_IMAGE_CHUNK_COUNT) { + throw ImageChunkLimitExceededException + } + + if (chunks.size == 1) { + val taggedPayload = IMAGE_MESSAGE_PREFIX + json.encodeToString( + ImagePayloadDto( + mimeType = prepared.mimeType, + width = prepared.width, + height = prepared.height, + data = normalized + ) + ) + sendSignedPayload(route = route, payloadText = taggedPayload) + } else { + val messageId = UUID.randomUUID().toString() + chunks.forEachIndexed { index, chunk -> + val taggedPayload = IMAGE_CHUNK_MESSAGE_PREFIX + json.encodeToString( + ImageChunkPayloadDto( + mimeType = prepared.mimeType, + messageId = messageId, + index = index, + total = chunks.size, + width = prepared.width, + height = prepared.height, + data = chunk + ) + ) + sendSignedPayload(route = route, payloadText = taggedPayload) + } + } + prepared + }.onSuccess { prepared -> + addOutgoingImageMessage( + subtitle = route.subtitle, + channel = route.channel, + imageBase64 = prepared.base64.trim(), + imageMimeType = prepared.mimeType, + imageWidth = prepared.width, + imageHeight = prepared.height + ) + _uiState.update { it.copy(sending = false) } + }.onFailure { error -> + val failureText = localizedImageSendError(error) + if (error is ImageChunkLimitExceededException) { + _uiState.update { + it.copy( + sending = false, + statusHint = t("session.hint.image_chunk_over_limit") + ) + } + addSystemMessage(t("session.msg.image_chunk_canceled")) + } else { + _uiState.update { + it.copy( + sending = false, + statusHint = tf("session.hint.image_send_failed", failureText) + ) + } + addSystemMessage(tf("session.msg.image_send_failed", failureText)) + } + } + } + } + /** * 消息复制成功后的回调,显示“已复制”提示。 */ @@ -926,6 +1019,29 @@ object ChatSessionManager { "broadcast" -> { val sender = message.key?.takeIf { it.isNotBlank() } ?: t("session.sender.anonymous") val payloadText = message.data.asPayloadText() + val imageChunk = parseImageChunkPayload(payloadText) + if (imageChunk != null) { + ingestIncomingImageChunk( + sender = sender, + subtitle = "", + channel = MessageChannel.BROADCAST, + chunk = imageChunk + ) + return + } + val image = parseImagePayload(payloadText) + if (image != null) { + addIncomingImageMessage( + sender = sender, + subtitle = "", + imageBase64 = image.data, + imageMimeType = image.mimeType, + imageWidth = image.width, + imageHeight = image.height, + channel = MessageChannel.BROADCAST + ) + return + } val audioChunk = parseAudioChunkPayload(payloadText) if (audioChunk != null) { ingestIncomingAudioChunk( @@ -961,6 +1077,29 @@ object ChatSessionManager { val subtitle = sourceKey.takeIf { it.isNotBlank() } ?.let { tf("session.subtitle.from_key", summarizeKey(it)) } .orEmpty() + val imageChunk = parseImageChunkPayload(payloadText) + if (imageChunk != null) { + ingestIncomingImageChunk( + sender = t("session.sender.private_message"), + subtitle = subtitle, + channel = MessageChannel.PRIVATE, + chunk = imageChunk + ) + return + } + val image = parseImagePayload(payloadText) + if (image != null) { + addIncomingImageMessage( + sender = t("session.sender.private_message"), + subtitle = subtitle, + imageBase64 = image.data, + imageMimeType = image.mimeType, + imageWidth = image.width, + imageHeight = image.height, + channel = MessageChannel.PRIVATE + ) + return + } val audioChunk = parseAudioChunkPayload(payloadText) if (audioChunk != null) { ingestIncomingAudioChunk( @@ -1155,6 +1294,35 @@ object ChatSessionManager { ) } + private fun addIncomingImageMessage( + sender: String, + subtitle: String, + imageBase64: String, + imageMimeType: String, + imageWidth: Int, + imageHeight: Int, + channel: MessageChannel + ) { + showIncomingNotification( + title = sender, + body = t("session.notification.new_image_message") + ) + appendMessage( + UiMessage( + role = MessageRole.INCOMING, + sender = sender, + subtitle = subtitle, + content = t("session.message.image"), + channel = channel, + contentType = MessageContentType.IMAGE, + imageBase64 = imageBase64, + imageMimeType = imageMimeType, + imageWidth = imageWidth.coerceAtLeast(0), + imageHeight = imageHeight.coerceAtLeast(0) + ) + ) + } + /** * 添加一条发出的消息。 * @param content 消息内容 @@ -1197,6 +1365,30 @@ object ChatSessionManager { ) } + private fun addOutgoingImageMessage( + subtitle: String, + channel: MessageChannel, + imageBase64: String, + imageMimeType: String, + imageWidth: Int, + imageHeight: Int + ) { + appendMessage( + UiMessage( + role = MessageRole.OUTGOING, + sender = t("session.sender.me"), + subtitle = subtitle, + content = t("session.message.image"), + channel = channel, + contentType = MessageContentType.IMAGE, + imageBase64 = imageBase64, + imageMimeType = imageMimeType, + imageWidth = imageWidth.coerceAtLeast(0), + imageHeight = imageHeight.coerceAtLeast(0) + ) + ) + } + private fun resolveOutgoingRoute(state: ChatUiState): OutgoingRoute? { val key = if (state.directMode) state.targetKey.trim() else "" if (state.directMode && key.isBlank()) { @@ -1281,6 +1473,35 @@ object ChatSessionManager { } } + private fun parseImagePayload(payloadText: String): ImagePayloadDto? { + if (!payloadText.startsWith(IMAGE_MESSAGE_PREFIX)) return null + val encoded = payloadText.removePrefix(IMAGE_MESSAGE_PREFIX).trim() + if (encoded.isBlank()) return null + return runCatching { + json.decodeFromString(encoded) + }.getOrNull()?.takeIf { dto -> + dto.encoding.equals("base64", ignoreCase = true) && + dto.mimeType.startsWith("image/", ignoreCase = true) && + dto.data.isNotBlank() + } + } + + private fun parseImageChunkPayload(payloadText: String): ImageChunkPayloadDto? { + if (!payloadText.startsWith(IMAGE_CHUNK_MESSAGE_PREFIX)) return null + val encoded = payloadText.removePrefix(IMAGE_CHUNK_MESSAGE_PREFIX).trim() + if (encoded.isBlank()) return null + return runCatching { + json.decodeFromString(encoded) + }.getOrNull()?.takeIf { dto -> + dto.encoding.equals("base64", ignoreCase = true) && + dto.mimeType.startsWith("image/", ignoreCase = true) && + dto.messageId.isNotBlank() && + dto.total in 1..MAX_IMAGE_CHUNK_COUNT && + dto.index in 0 until dto.total && + dto.data.isNotBlank() + } + } + private fun ingestIncomingAudioChunk( sender: String, subtitle: String, @@ -1331,6 +1552,60 @@ object ChatSessionManager { ) } + private fun ingestIncomingImageChunk( + sender: String, + subtitle: String, + channel: MessageChannel, + chunk: ImageChunkPayloadDto + ) { + val now = System.currentTimeMillis() + purgeExpiredImageChunkBuffers(now) + val bufferKey = "${channel.name}:${sender}:${chunk.messageId}" + val buffer = incomingImageChunkBuffers[bufferKey] + val active = if (buffer == null || buffer.total != chunk.total) { + IncomingImageChunkBuffer( + sender = sender, + subtitle = subtitle, + channel = channel, + total = chunk.total, + mimeType = chunk.mimeType, + width = chunk.width.coerceAtLeast(0), + height = chunk.height.coerceAtLeast(0), + createdAtMillis = now, + chunks = MutableList(chunk.total) { null } + ).also { created -> + incomingImageChunkBuffers[bufferKey] = created + } + } else { + if (buffer.sender != sender || buffer.channel != channel) { + return + } + buffer + } + + active.chunks[chunk.index] = chunk.data + val completed = active.chunks.all { !it.isNullOrBlank() } + if (!completed) return + + incomingImageChunkBuffers.remove(bufferKey) + val merged = buildString { + active.chunks.forEach { part -> + append(part.orEmpty()) + } + } + if (merged.isBlank()) return + + addIncomingImageMessage( + sender = active.sender, + subtitle = active.subtitle, + imageBase64 = merged, + imageMimeType = active.mimeType, + imageWidth = active.width, + imageHeight = active.height, + channel = active.channel + ) + } + private fun purgeExpiredAudioChunkBuffers(nowMillis: Long) { if (incomingAudioChunkBuffers.isEmpty()) return val expiredKeys = incomingAudioChunkBuffers @@ -1341,7 +1616,17 @@ object ChatSessionManager { } } - private fun splitAudioBase64(base64: String, chunkSize: Int): List { + private fun purgeExpiredImageChunkBuffers(nowMillis: Long) { + if (incomingImageChunkBuffers.isEmpty()) return + val expiredKeys = incomingImageChunkBuffers + .filterValues { nowMillis - it.createdAtMillis >= IMAGE_CHUNK_BUFFER_TTL_MS } + .keys + expiredKeys.forEach { key -> + incomingImageChunkBuffers.remove(key) + } + } + + private fun splitPayloadBase64(base64: String, chunkSize: Int): List { if (base64.isEmpty() || chunkSize <= 0) return emptyList() if (base64.length <= chunkSize) return listOf(base64) val chunks = ArrayList((base64.length + chunkSize - 1) / chunkSize) @@ -1717,6 +2002,16 @@ object ChatSessionManager { return notificationIdSeed } + private fun localizedImageSendError(error: Throwable): String { + return when (error) { + is ImageChunkLimitExceededException -> t("session.error.image_too_large") + is ImageMessageProcessor.PrepareException.InvalidImage -> t("session.error.image_invalid") + is ImageMessageProcessor.PrepareException.DecodeFailed -> t("session.error.image_decode_failed") + is ImageMessageProcessor.PrepareException.TooLarge -> t("session.error.image_too_large") + else -> error.message ?: t("common.unknown") + } + } + private data class OutgoingRoute( val type: String, val key: String, @@ -1734,6 +2029,20 @@ object ChatSessionManager { val chunks: MutableList ) + private data class IncomingImageChunkBuffer( + val sender: String, + val subtitle: String, + val channel: MessageChannel, + val total: Int, + val mimeType: String, + val width: Int, + val height: Int, + val createdAtMillis: Long, + val chunks: MutableList + ) + + private object ImageChunkLimitExceededException : IllegalStateException("image_chunk_limit_exceeded") + // 常量定义 private const val HELLO_TIMEOUT_MS = 12_000L private const val AUTH_TIMEOUT_MS = 20_000L @@ -1742,8 +2051,15 @@ object ChatSessionManager { private const val SYSTEM_MESSAGE_TTL_MS = 1_000L private const val AUDIO_MESSAGE_PREFIX = "[[OMS_AUDIO_V1]]" private const val AUDIO_CHUNK_MESSAGE_PREFIX = "[[OMS_AUDIO_CHUNK_V1]]" + private const val IMAGE_MESSAGE_PREFIX = "[[OMS_IMAGE_V1]]" + private const val IMAGE_CHUNK_MESSAGE_PREFIX = "[[OMS_IMAGE_CHUNK_V1]]" private const val AUDIO_CHUNK_BASE64_SIZE = 20_000 private const val MAX_AUDIO_CHUNK_COUNT = 30 private const val AUDIO_CHUNK_BUFFER_TTL_MS = 180_000L + private const val IMAGE_CHUNK_BASE64_SIZE = 20_000 + private const val MAX_IMAGE_CHUNK_COUNT = 24 + private const val IMAGE_CHUNK_BUFFER_TTL_MS = 180_000L + private const val MAX_IMAGE_EDGE_PX = 1440 + private const val MAX_IMAGE_PAYLOAD_BYTES = IMAGE_CHUNK_BASE64_SIZE * MAX_IMAGE_CHUNK_COUNT * 3 / 4 private const val MAX_OUTBOUND_MESSAGE_BYTES = 60 * 1024 } diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt index abfe61c..481ce96 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatUiState.kt @@ -32,11 +32,12 @@ enum class MessageChannel { } /** - * 消息内容类型(文本/音频)。 + * 消息内容类型(文本/音频/图片)。 */ enum class MessageContentType { TEXT, - AUDIO + AUDIO, + IMAGE } /** @@ -59,7 +60,11 @@ data class UiMessage( val timestampMillis: Long = System.currentTimeMillis(), val contentType: MessageContentType = MessageContentType.TEXT, val audioBase64: String = "", - val audioDurationMillis: Long = 0L + val audioDurationMillis: Long = 0L, + val imageBase64: String = "", + val imageMimeType: String = "", + val imageWidth: Int = 0, + val imageHeight: Int = 0 ) /** diff --git a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt index a0ce6e5..81dc4d9 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/ui/ChatViewModel.kt @@ -1,6 +1,7 @@ package com.onlinemsg.client.ui import android.app.Application +import android.net.Uri import androidx.lifecycle.AndroidViewModel /** @@ -33,6 +34,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { fun sendMessage() = ChatSessionManager.sendMessage() fun sendAudioMessage(audioBase64: String, durationMillis: Long) = ChatSessionManager.sendAudioMessage(audioBase64, durationMillis) + fun sendImageMessage(uri: Uri) = ChatSessionManager.sendImageMessage(uri) fun onMessageCopied() = ChatSessionManager.onMessageCopied() fun updateTheme(themeId: String) = ChatSessionManager.updateTheme(themeId) diff --git a/android-client/app/src/main/java/com/onlinemsg/client/util/ImageMessageProcessor.kt b/android-client/app/src/main/java/com/onlinemsg/client/util/ImageMessageProcessor.kt new file mode 100644 index 0000000..a04530c --- /dev/null +++ b/android-client/app/src/main/java/com/onlinemsg/client/util/ImageMessageProcessor.kt @@ -0,0 +1,289 @@ +package com.onlinemsg.client.util + +import android.content.ContentResolver +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.ImageDecoder +import android.graphics.Matrix +import android.net.Uri +import android.os.Build +import android.util.Base64 +import androidx.exifinterface.media.ExifInterface +import java.io.ByteArrayOutputStream +import kotlin.math.max +import kotlin.math.roundToInt + +object ImageMessageProcessor { + + data class PreparedImageMessage( + val base64: String, + val mimeType: String, + val width: Int, + val height: Int + ) + + sealed class PrepareException(message: String) : IllegalArgumentException(message) { + object InvalidImage : PrepareException("invalid_image") + object DecodeFailed : PrepareException("decode_failed") + object TooLarge : PrepareException("too_large") + } + + fun prepareForMessage( + contentResolver: ContentResolver, + uri: Uri, + maxLongEdgePx: Int, + maxEncodedBytes: Int + ): PreparedImageMessage { + require(maxLongEdgePx > 0) { "maxLongEdgePx must be > 0" } + require(maxEncodedBytes > 0) { "maxEncodedBytes must be > 0" } + + val decoded = decodeBitmapCompat(contentResolver, uri, maxLongEdgePx) + val oriented = if (decoded.orientationHandled) { + decoded.bitmap + } else { + applyExifOrientation(contentResolver, uri, decoded.bitmap) + } + val normalized = scaleBitmapToLongEdge(oriented, maxLongEdgePx) + val jpegReady = flattenTransparencyForJpeg(normalized) + val compressed = compressWithinLimit(jpegReady, maxEncodedBytes) + + return PreparedImageMessage( + base64 = Base64.encodeToString(compressed, Base64.NO_WRAP), + mimeType = "image/jpeg", + width = jpegReady.width, + height = jpegReady.height + ) + } + + private fun decodeBitmapCompat( + contentResolver: ContentResolver, + uri: Uri, + maxLongEdgePx: Int + ): DecodedBitmap { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + decodeBitmapWithImageDecoder(contentResolver, uri, maxLongEdgePx)?.let { bitmap -> + return DecodedBitmap(bitmap = bitmap, orientationHandled = true) + } + } + + decodeBitmapWithFileDescriptor(contentResolver, uri, maxLongEdgePx)?.let { bitmap -> + return DecodedBitmap(bitmap = bitmap, orientationHandled = false) + } + + decodeBitmapWithInputStream(contentResolver, uri, maxLongEdgePx)?.let { bitmap -> + return DecodedBitmap(bitmap = bitmap, orientationHandled = false) + } + + throw PrepareException.DecodeFailed + } + + private fun decodeBitmapWithImageDecoder( + contentResolver: ContentResolver, + uri: Uri, + maxLongEdgePx: Int + ): Bitmap? { + return runCatching { + val source = ImageDecoder.createSource(contentResolver, uri) + ImageDecoder.decodeBitmap(source) { decoder, info, _ -> + val sourceWidth = info.size.width.coerceAtLeast(1) + val sourceHeight = info.size.height.coerceAtLeast(1) + val sampleSize = calculateInSampleSize( + width = sourceWidth, + height = sourceHeight, + maxLongEdgePx = maxLongEdgePx, + maxPixels = MAX_DECODE_PIXELS + ) + decoder.allocator = ImageDecoder.ALLOCATOR_SOFTWARE + decoder.isMutableRequired = true + decoder.setTargetSize( + (sourceWidth / sampleSize).coerceAtLeast(1), + (sourceHeight / sampleSize).coerceAtLeast(1) + ) + } + }.getOrNull() + } + + private fun decodeBitmapWithFileDescriptor( + contentResolver: ContentResolver, + uri: Uri, + maxLongEdgePx: Int + ): Bitmap? { + val bounds = contentResolver.openFileDescriptor(uri, "r")?.use { descriptor -> + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFileDescriptor(descriptor.fileDescriptor, null, options) + options + } + if (bounds == null || bounds.outWidth <= 0 || bounds.outHeight <= 0) { + return null + } + + return contentResolver.openFileDescriptor(uri, "r")?.use { descriptor -> + val options = BitmapFactory.Options().apply { + inSampleSize = calculateInSampleSize( + width = bounds.outWidth, + height = bounds.outHeight, + maxLongEdgePx = maxLongEdgePx, + maxPixels = MAX_DECODE_PIXELS + ) + inPreferredConfig = Bitmap.Config.ARGB_8888 + } + BitmapFactory.decodeFileDescriptor(descriptor.fileDescriptor, null, options) + } + } + + private fun decodeBitmapWithInputStream( + contentResolver: ContentResolver, + uri: Uri, + maxLongEdgePx: Int + ): Bitmap? { + val bounds = contentResolver.openInputStream(uri)?.use { stream -> + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeStream(stream, null, options) + options + } + if (bounds == null || bounds.outWidth <= 0 || bounds.outHeight <= 0) { + return null + } + + return contentResolver.openInputStream(uri)?.use { stream -> + val options = BitmapFactory.Options().apply { + inSampleSize = calculateInSampleSize( + width = bounds.outWidth, + height = bounds.outHeight, + maxLongEdgePx = maxLongEdgePx, + maxPixels = MAX_DECODE_PIXELS + ) + inPreferredConfig = Bitmap.Config.ARGB_8888 + } + BitmapFactory.decodeStream(stream, null, options) + } + } + + private fun applyExifOrientation( + contentResolver: ContentResolver, + uri: Uri, + bitmap: Bitmap + ): Bitmap { + val orientation = contentResolver.openInputStream(uri)?.use { stream -> + runCatching { ExifInterface(stream).getAttributeInt( + ExifInterface.TAG_ORIENTATION, + ExifInterface.ORIENTATION_NORMAL + ) }.getOrDefault(ExifInterface.ORIENTATION_NORMAL) + } ?: ExifInterface.ORIENTATION_NORMAL + + if (orientation == ExifInterface.ORIENTATION_NORMAL || + orientation == ExifInterface.ORIENTATION_UNDEFINED + ) { + return bitmap + } + + val matrix = Matrix().apply { + when (orientation) { + ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> postScale(-1f, 1f) + ExifInterface.ORIENTATION_ROTATE_180 -> postRotate(180f) + ExifInterface.ORIENTATION_FLIP_VERTICAL -> { + postRotate(180f) + postScale(-1f, 1f) + } + ExifInterface.ORIENTATION_TRANSPOSE -> { + postRotate(90f) + postScale(-1f, 1f) + } + ExifInterface.ORIENTATION_ROTATE_90 -> postRotate(90f) + ExifInterface.ORIENTATION_TRANSVERSE -> { + postRotate(-90f) + postScale(-1f, 1f) + } + ExifInterface.ORIENTATION_ROTATE_270 -> postRotate(270f) + } + } + return runCatching { + Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) + }.getOrElse { bitmap } + } + + private fun scaleBitmapToLongEdge(bitmap: Bitmap, maxLongEdgePx: Int): Bitmap { + val currentLongEdge = max(bitmap.width, bitmap.height) + if (currentLongEdge <= maxLongEdgePx) return bitmap + val scale = maxLongEdgePx.toFloat() / currentLongEdge.toFloat() + val targetWidth = (bitmap.width * scale).roundToInt().coerceAtLeast(1) + val targetHeight = (bitmap.height * scale).roundToInt().coerceAtLeast(1) + return Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true) + } + + private fun flattenTransparencyForJpeg(bitmap: Bitmap): Bitmap { + if (!bitmap.hasAlpha()) return bitmap + val flattened = Bitmap.createBitmap(bitmap.width, bitmap.height, Bitmap.Config.ARGB_8888) + val canvas = Canvas(flattened) + canvas.drawColor(Color.WHITE) + canvas.drawBitmap(bitmap, 0f, 0f, null) + return flattened + } + + private fun compressWithinLimit(bitmap: Bitmap, maxEncodedBytes: Int): ByteArray { + var workingBitmap = bitmap + var quality = INITIAL_JPEG_QUALITY + var compressed = compressBitmap(workingBitmap, quality) + while (compressed.size > maxEncodedBytes && quality > MIN_JPEG_QUALITY) { + quality -= JPEG_QUALITY_STEP + compressed = compressBitmap(workingBitmap, quality) + } + + while (compressed.size > maxEncodedBytes && max(workingBitmap.width, workingBitmap.height) > MIN_LONG_EDGE_PX) { + val nextLongEdge = (max(workingBitmap.width, workingBitmap.height) * SCALE_DOWN_RATIO) + .roundToInt() + .coerceAtLeast(MIN_LONG_EDGE_PX) + workingBitmap = scaleBitmapToLongEdge(workingBitmap, nextLongEdge) + quality = maxOf(MIN_JPEG_QUALITY, minOf(quality, INITIAL_JPEG_QUALITY - JPEG_QUALITY_STEP)) + compressed = compressBitmap(workingBitmap, quality) + } + + if (compressed.size > maxEncodedBytes) { + throw PrepareException.TooLarge + } + return compressed + } + + private fun compressBitmap(bitmap: Bitmap, quality: Int): ByteArray { + val output = ByteArrayOutputStream() + val success = bitmap.compress(Bitmap.CompressFormat.JPEG, quality, output) + if (!success) { + throw PrepareException.DecodeFailed + } + return output.toByteArray() + } + + private fun calculateInSampleSize( + width: Int, + height: Int, + maxLongEdgePx: Int, + maxPixels: Int + ): Int { + var sample = 1 + var sampledWidth = width + var sampledHeight = height + while ( + max(sampledWidth, sampledHeight) > maxLongEdgePx * 2 || + sampledWidth.toLong() * sampledHeight.toLong() > maxPixels + ) { + sample *= 2 + sampledWidth = width / sample + sampledHeight = height / sample + } + return sample.coerceAtLeast(1) + } + + private const val MAX_DECODE_PIXELS = 4_200_000 + private const val MIN_LONG_EDGE_PX = 480 + private const val INITIAL_JPEG_QUALITY = 86 + private const val MIN_JPEG_QUALITY = 58 + private const val JPEG_QUALITY_STEP = 8 + private const val SCALE_DOWN_RATIO = 0.85f + + private data class DecodedBitmap( + val bitmap: Bitmap, + val orientationHandled: Boolean + ) +} diff --git a/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt b/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt index c14bbf5..80bccad 100644 --- a/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt +++ b/android-client/app/src/main/java/com/onlinemsg/client/util/LanguageManager.kt @@ -58,6 +58,10 @@ object LanguageManager { "chat.input_placeholder" to "輸入訊息", "chat.send" to "傳送", "chat.sending" to "傳送中", + "chat.image_entry" to "圖片", + "chat.image_gallery" to "從圖庫選擇", + "chat.image_preview" to "查看圖片", + "chat.image_unavailable" to "圖片無法顯示", "chat.empty_hint" to "連線後即可聊天。預設為廣播,切換到私訊後可填寫目標公鑰。", "chat.mode_text" to "文字", "chat.mode_audio" to "語音", @@ -162,6 +166,8 @@ object LanguageManager { "session.hint.connection_closed" to "连接已关闭", "session.hint.audio_chunk_over_limit" to "语音过长,超过可发送分片上限", "session.hint.audio_send_failed" to "语音发送失败:%s", + "session.hint.image_chunk_over_limit" to "图片过大,超过可发送分片上限", + "session.hint.image_send_failed" to "图片发送失败:%s", "session.hint.handshake_data_received" to "已收到握手数据,正在解析...", "session.hint.handshake_incomplete_response" to "握手失败:服务端响应不完整", "session.hint.handshake_unexpected_message" to "握手失败:收到非预期消息", @@ -191,6 +197,8 @@ object LanguageManager { "session.msg.send_failed" to "发送失败:%s", "session.msg.audio_chunk_canceled" to "语音过长,已取消发送", "session.msg.audio_send_failed" to "语音发送失败:%s", + "session.msg.image_chunk_canceled" to "图片过大,已取消发送", + "session.msg.image_send_failed" to "图片发送失败:%s", "session.msg.handshake_unexpected_type" to "握手阶段收到非预期消息类型:%s", "session.msg.handshake_parse_failed" to "握手包解析失败:%s", "session.msg.decryption_failed" to "收到无法解密的消息", @@ -208,11 +216,16 @@ object LanguageManager { "session.msg.handshake_timeout_with_url" to "握手超时:未收到服务端 publickey 首包(当前地址:%s)", "session.error.message_too_large" to "消息体过大(%dB),请缩短消息内容后重试", "session.error.connection_unavailable" to "连接不可用", + "session.error.image_invalid" to "选择的文件不是有效图片", + "session.error.image_decode_failed" to "图片处理失败,请换一张再试", + "session.error.image_too_large" to "图片压缩后仍然过大,请换一张更小的图片", "session.notification.channel_name" to "OnlineMsg 消息提醒", "session.notification.channel_desc" to "收到服务器新消息时提醒", "session.notification.new_message" to "收到一条新消息", "session.notification.new_voice_message" to "收到一条语音消息", + "session.notification.new_image_message" to "收到一张图片", "session.message.voice" to "语音消息", + "session.message.image" to "图片消息", "session.subtitle.from_key" to "来自 %s", "session.subtitle.private_to_key" to "私聊 %s", "chat.broadcast" to "广播", @@ -221,6 +234,10 @@ object LanguageManager { "chat.input_placeholder" to "输入消息", "chat.send" to "发送", "chat.sending" to "发送中", + "chat.image_entry" to "图片", + "chat.image_gallery" to "从图库选择", + "chat.image_preview" to "查看图片", + "chat.image_unavailable" to "图片无法显示", "chat.empty_hint" to "连接后即可聊天。默认广播,切换到私聊后可填写目标公钥。", "chat.mode_text" to "文字", "chat.mode_audio" to "语音", @@ -327,6 +344,8 @@ object LanguageManager { "session.hint.connection_closed" to "Connection closed", "session.hint.audio_chunk_over_limit" to "Voice message too long, chunk limit exceeded", "session.hint.audio_send_failed" to "Voice send failed: %s", + "session.hint.image_chunk_over_limit" to "Image is too large and exceeds the chunk limit", + "session.hint.image_send_failed" to "Image send failed: %s", "session.hint.handshake_data_received" to "Handshake data received, parsing...", "session.hint.handshake_incomplete_response" to "Handshake failed: incomplete server response", "session.hint.handshake_unexpected_message" to "Handshake failed: unexpected message", @@ -356,6 +375,8 @@ object LanguageManager { "session.msg.send_failed" to "Send failed: %s", "session.msg.audio_chunk_canceled" to "Voice message too long, canceled", "session.msg.audio_send_failed" to "Voice send failed: %s", + "session.msg.image_chunk_canceled" to "Image is too large, sending canceled", + "session.msg.image_send_failed" to "Image send failed: %s", "session.msg.handshake_unexpected_type" to "Unexpected message type during handshake: %s", "session.msg.handshake_parse_failed" to "Handshake packet parse failed: %s", "session.msg.decryption_failed" to "Received undecryptable message", @@ -373,11 +394,16 @@ object LanguageManager { "session.msg.handshake_timeout_with_url" to "Handshake timeout: no server publickey packet (url: %s)", "session.error.message_too_large" to "Message too large (%dB), please shorten and retry", "session.error.connection_unavailable" to "Connection unavailable", + "session.error.image_invalid" to "The selected file is not a valid image", + "session.error.image_decode_failed" to "Image processing failed, try another image", + "session.error.image_too_large" to "Image is still too large after compression, try a smaller one", "session.notification.channel_name" to "OnlineMsg Notifications", "session.notification.channel_desc" to "Notify when new server messages arrive", "session.notification.new_message" to "New message received", "session.notification.new_voice_message" to "New voice message received", + "session.notification.new_image_message" to "New image received", "session.message.voice" to "Voice message", + "session.message.image" to "Image", "session.subtitle.from_key" to "From %s", "session.subtitle.private_to_key" to "Private %s", "chat.broadcast" to "Broadcast", @@ -386,6 +412,10 @@ object LanguageManager { "chat.input_placeholder" to "Type a message", "chat.send" to "Send", "chat.sending" to "Sending", + "chat.image_entry" to "Image", + "chat.image_gallery" to "Choose from gallery", + "chat.image_preview" to "Preview image", + "chat.image_unavailable" to "Image unavailable", "chat.empty_hint" to "Connect to start chatting. Default is broadcast.", "chat.mode_text" to "Text", "chat.mode_audio" to "Voice", @@ -492,6 +522,8 @@ object LanguageManager { "session.hint.connection_closed" to "接続を閉じました", "session.hint.audio_chunk_over_limit" to "音声が長すぎて分割上限を超えました", "session.hint.audio_send_failed" to "音声送信失敗:%s", + "session.hint.image_chunk_over_limit" to "画像が大きすぎて分割上限を超えました", + "session.hint.image_send_failed" to "画像送信失敗:%s", "session.hint.handshake_data_received" to "握手データ受信、解析中...", "session.hint.handshake_incomplete_response" to "握手失敗:サーバー応答が不完全です", "session.hint.handshake_unexpected_message" to "握手失敗:予期しないメッセージ", @@ -521,6 +553,8 @@ object LanguageManager { "session.msg.send_failed" to "送信失敗:%s", "session.msg.audio_chunk_canceled" to "音声が長すぎるため送信を中止しました", "session.msg.audio_send_failed" to "音声送信失敗:%s", + "session.msg.image_chunk_canceled" to "画像が大きすぎるため送信を中止しました", + "session.msg.image_send_failed" to "画像送信失敗:%s", "session.msg.handshake_unexpected_type" to "握手中に予期しないメッセージ種別:%s", "session.msg.handshake_parse_failed" to "握手パケット解析失敗:%s", "session.msg.decryption_failed" to "復号できないメッセージを受信しました", @@ -538,11 +572,16 @@ object LanguageManager { "session.msg.handshake_timeout_with_url" to "握手タイムアウト:server publickey 初回パケット未受信(URL: %s)", "session.error.message_too_large" to "メッセージが大きすぎます(%dB)。短くして再試行してください", "session.error.connection_unavailable" to "接続不可", + "session.error.image_invalid" to "選択したファイルは有効な画像ではありません", + "session.error.image_decode_failed" to "画像の処理に失敗しました。別の画像でお試しください", + "session.error.image_too_large" to "圧縮後も画像が大きすぎます。より小さい画像を選んでください", "session.notification.channel_name" to "OnlineMsg 通知", "session.notification.channel_desc" to "サーバー新着メッセージを通知", "session.notification.new_message" to "新着メッセージ", "session.notification.new_voice_message" to "新着音声メッセージ", + "session.notification.new_image_message" to "新着画像", "session.message.voice" to "音声メッセージ", + "session.message.image" to "画像メッセージ", "session.subtitle.from_key" to "%s から", "session.subtitle.private_to_key" to "個人 %s", "chat.broadcast" to "全体", @@ -551,6 +590,10 @@ object LanguageManager { "chat.input_placeholder" to "メッセージを入力", "chat.send" to "送信", "chat.sending" to "送信中", + "chat.image_entry" to "画像", + "chat.image_gallery" to "ギャラリーから選択", + "chat.image_preview" to "画像を表示", + "chat.image_unavailable" to "画像を表示できません", "chat.empty_hint" to "接続するとチャットを開始できます。", "chat.mode_text" to "テキスト", "chat.mode_audio" to "音声", @@ -657,6 +700,8 @@ object LanguageManager { "session.hint.connection_closed" to "연결이 종료되었습니다", "session.hint.audio_chunk_over_limit" to "음성이 너무 길어 분할 상한을 초과했습니다", "session.hint.audio_send_failed" to "음성 전송 실패: %s", + "session.hint.image_chunk_over_limit" to "이미지가 너무 커서 분할 상한을 초과했습니다", + "session.hint.image_send_failed" to "이미지 전송 실패: %s", "session.hint.handshake_data_received" to "핸드셰이크 데이터 수신, 파싱 중...", "session.hint.handshake_incomplete_response" to "핸드셰이크 실패: 서버 응답이 불완전합니다", "session.hint.handshake_unexpected_message" to "핸드셰이크 실패: 예상치 못한 메시지", @@ -686,6 +731,8 @@ object LanguageManager { "session.msg.send_failed" to "전송 실패: %s", "session.msg.audio_chunk_canceled" to "음성이 너무 길어 전송이 취소되었습니다", "session.msg.audio_send_failed" to "음성 전송 실패: %s", + "session.msg.image_chunk_canceled" to "이미지가 너무 커서 전송이 취소되었습니다", + "session.msg.image_send_failed" to "이미지 전송 실패: %s", "session.msg.handshake_unexpected_type" to "핸드셰이크 중 예상치 못한 메시지 유형: %s", "session.msg.handshake_parse_failed" to "핸드셰이크 패킷 파싱 실패: %s", "session.msg.decryption_failed" to "복호화할 수 없는 메시지를 받았습니다", @@ -703,11 +750,16 @@ object LanguageManager { "session.msg.handshake_timeout_with_url" to "핸드셰이크 시간 초과: 서버 publickey 첫 패킷 미수신 (URL: %s)", "session.error.message_too_large" to "메시지가 너무 큽니다 (%dB). 줄여서 다시 시도하세요", "session.error.connection_unavailable" to "연결 불가", + "session.error.image_invalid" to "선택한 파일이 올바른 이미지가 아닙니다", + "session.error.image_decode_failed" to "이미지 처리에 실패했습니다. 다른 이미지를 시도하세요", + "session.error.image_too_large" to "압축 후에도 이미지가 너무 큽니다. 더 작은 이미지를 선택하세요", "session.notification.channel_name" to "OnlineMsg 알림", "session.notification.channel_desc" to "서버 새 메시지 수신 시 알림", "session.notification.new_message" to "새 메시지 수신", "session.notification.new_voice_message" to "새 음성 메시지 수신", + "session.notification.new_image_message" to "새 이미지 수신", "session.message.voice" to "음성 메시지", + "session.message.image" to "이미지 메시지", "session.subtitle.from_key" to "%s 에서", "session.subtitle.private_to_key" to "비공개 %s", "chat.broadcast" to "브로드캐스트", @@ -716,6 +768,10 @@ object LanguageManager { "chat.input_placeholder" to "메시지 입력", "chat.send" to "전송", "chat.sending" to "전송 중", + "chat.image_entry" to "이미지", + "chat.image_gallery" to "갤러리에서 선택", + "chat.image_preview" to "이미지 보기", + "chat.image_unavailable" to "이미지를 표시할 수 없습니다", "chat.empty_hint" to "연결 후 채팅이 가능합니다. 기본은 브로드캐스트이며, 비공개 채팅으로 전환 후 대상 공개키를 입력할 수 있습니다.", "chat.mode_text" to "텍스트", "chat.mode_audio" to "음성",