feat(android): add image message sending

codex/alimu
alimu 1 week ago
parent 4e2993e772
commit 151a90ea53

@ -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")

@ -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
}

@ -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
)
}

@ -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
)

@ -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) {

@ -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<String?>(null) }
var previewingImageMessage by remember { mutableStateOf<UiMessage?>(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<ImageBitmap?> {
return produceState<ImageBitmap?>(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"

@ -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<String, IncomingAudioChunkBuffer>()
private val incomingImageChunkBuffers = mutableMapOf<String, IncomingImageChunkBuffer>()
// 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<ImagePayloadDto>(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<ImageChunkPayloadDto>(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<String> {
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<String> {
if (base64.isEmpty() || chunkSize <= 0) return emptyList()
if (base64.length <= chunkSize) return listOf(base64)
val chunks = ArrayList<String>((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<String?>
)
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<String?>
)
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
}

@ -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
)
/**

@ -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)

@ -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
)
}

@ -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 "음성",

Loading…
Cancel
Save