Группы: добавлен выбор участников при создании + авто-приглашение. Forward: убран ре-аплоад картинок, добавлен chacha_key_plain для кросс-платформы. Онбординг: экран профиля (имя + username + аватар), биометрия на экране пароля, убран экран подтверждения фразы. Звонки: аватарки в уведомлениях и на экране входящего. Reply: исправлена расшифровка фото (chachaKey оригинала). Уведомления: фикс декодирования аватарки (base64 prefix). UI: подсказка эмодзи в стиле Telegram, стрелка на Safety, сепараторы участников.

This commit is contained in:
2026-04-07 23:29:37 +05:00
parent ecac56773a
commit 9fafa52483
17 changed files with 741 additions and 154 deletions

View File

@@ -8,9 +8,12 @@ import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.runtime.*
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.CallForegroundService
import com.rosetta.messenger.network.CallManager
import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.calls.CallOverlay
import com.rosetta.messenger.ui.theme.RosettaAndroidTheme
@@ -115,10 +118,23 @@ class IncomingCallActivity : ComponentActivity() {
callState
}
val avatarRepository = remember {
val accountKey = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty()
if (accountKey.isNotBlank()) {
val db = RosettaDatabase.getDatabase(applicationContext)
AvatarRepository(
context = applicationContext,
avatarDao = db.avatarDao(),
currentPublicKey = accountKey
)
} else null
}
RosettaAndroidTheme(darkTheme = true) {
CallOverlay(
state = displayState,
isDarkTheme = true,
avatarRepository = avatarRepository,
isExpanded = true,
onAccept = {
callLog("onAccept tapped, phase=${callState.phase}")

View File

@@ -30,7 +30,8 @@ object ForwardManager {
val senderPublicKey: String, // publicKey отправителя сообщения
val originalChatPublicKey: String, // publicKey чата откуда пересылается
val senderName: String = "", // Имя отправителя для атрибуции
val attachments: List<MessageAttachment> = emptyList()
val attachments: List<MessageAttachment> = emptyList(),
val chachaKeyPlain: String = "" // Hex plainKeyAndNonce оригинального сообщения
)
// Сообщения для пересылки

View File

@@ -40,7 +40,8 @@ class CallForegroundService : Service() {
val phase: CallPhase,
val displayName: String,
val statusText: String,
val durationSec: Int
val durationSec: Int,
val peerPublicKey: String = ""
)
override fun onBind(intent: Intent?): IBinder? = null
@@ -145,7 +146,8 @@ class CallForegroundService : Service() {
phase = state.phase,
displayName = state.displayName,
statusText = state.statusText,
durationSec = state.durationSec
durationSec = state.durationSec,
peerPublicKey = state.peerPublicKey
)
}
@@ -158,11 +160,15 @@ class CallForegroundService : Service() {
.ifBlank { "Unknown" }
val statusText = payloadIntent.getStringExtra(EXTRA_STATUS_TEXT).orEmpty().ifBlank { state.statusText }
val durationSec = payloadIntent.getIntExtra(EXTRA_DURATION_SEC, state.durationSec)
val peerPublicKey = payloadIntent.getStringExtra(EXTRA_PEER_PUBLIC_KEY)
.orEmpty()
.ifBlank { state.peerPublicKey }
return Snapshot(
phase = phase,
displayName = displayName,
statusText = statusText,
durationSec = durationSec.coerceAtLeast(0)
durationSec = durationSec.coerceAtLeast(0),
peerPublicKey = peerPublicKey
)
}
@@ -246,7 +252,7 @@ class CallForegroundService : Service() {
CallPhase.IDLE -> "Call ended"
}
val contentText = snapshot.statusText.ifBlank { defaultStatus }
val avatarBitmap = loadAvatarBitmap(CallManager.state.value.peerPublicKey)
val avatarBitmap = loadAvatarBitmap(snapshot.peerPublicKey)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val personBuilder = Person.Builder().setName(snapshot.displayName).setImportant(true)
@@ -420,6 +426,7 @@ class CallForegroundService : Service() {
private const val EXTRA_DISPLAY_NAME = "extra_display_name"
private const val EXTRA_STATUS_TEXT = "extra_status_text"
private const val EXTRA_DURATION_SEC = "extra_duration_sec"
private const val EXTRA_PEER_PUBLIC_KEY = "extra_peer_public_key"
const val EXTRA_OPEN_CALL_FROM_NOTIFICATION = "extra_open_call_from_notification"
fun syncWithCallState(context: Context, state: CallUiState) {
@@ -439,6 +446,7 @@ class CallForegroundService : Service() {
.putExtra(EXTRA_DISPLAY_NAME, state.displayName)
.putExtra(EXTRA_STATUS_TEXT, state.statusText)
.putExtra(EXTRA_DURATION_SEC, state.durationSec)
.putExtra(EXTRA_PEER_PUBLIC_KEY, state.peerPublicKey)
runCatching { ContextCompat.startForegroundService(appContext, intent) }
.onFailure { error ->
@@ -471,8 +479,9 @@ class CallForegroundService : Service() {
val entity = runBlocking(Dispatchers.IO) {
db.avatarDao().getLatestAvatarByKeys(listOf(publicKey))
} ?: return null
val base64 = AvatarFileManager.readAvatar(applicationContext, entity.avatar)
val rawBase64 = AvatarFileManager.readAvatar(applicationContext, entity.avatar)
?: return null
val base64 = if (rawBase64.contains(",")) rawBase64.substringAfter(",") else rawBase64
val bytes = Base64.decode(base64, Base64.DEFAULT)
val original = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null
toCircleBitmap(original)

View File

@@ -1299,6 +1299,10 @@ object ProtocolManager {
/**
* 🔍 Get full cached user info (no network request)
*/
fun notifyOwnProfileUpdated() {
_ownProfileUpdated.value = System.currentTimeMillis()
}
fun getCachedUserInfo(publicKey: String): SearchUser? {
return userInfoCache[publicKey]
}

View File

@@ -756,8 +756,9 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
val entity = runBlocking(Dispatchers.IO) {
db.avatarDao().getLatestAvatarByKeys(listOf(publicKey))
} ?: return null
val base64 = AvatarFileManager.readAvatar(applicationContext, entity.avatar)
val rawBase64 = AvatarFileManager.readAvatar(applicationContext, entity.avatar)
?: return null
val base64 = if (rawBase64.contains(",")) rawBase64.substringAfter(",") else rawBase64
val bytes = Base64.decode(base64, Base64.DEFAULT)
val original = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null
// Делаем круглый bitmap для notification

View File

@@ -15,6 +15,7 @@ enum class AuthScreen {
SEED_PHRASE,
CONFIRM_SEED,
SET_PASSWORD,
SET_PROFILE,
IMPORT_SEED,
UNLOCK
}
@@ -50,6 +51,7 @@ fun AuthFlow(
)
}
var seedPhrase by remember { mutableStateOf<List<String>>(emptyList()) }
var createdAccount by remember { mutableStateOf<DecryptedAccount?>(null) }
// Use last logged account or fallback to first account
var selectedAccountId by remember {
mutableStateOf<String?>(
@@ -82,12 +84,15 @@ fun AuthFlow(
} else if (hasExistingAccount) {
currentScreen = AuthScreen.UNLOCK
} else {
currentScreen = AuthScreen.CONFIRM_SEED
currentScreen = AuthScreen.SEED_PHRASE
}
}
AuthScreen.SET_PROFILE -> {
// Skip profile setup — complete auth
onAuthComplete(createdAccount)
}
AuthScreen.IMPORT_SEED -> {
if (isImportMode && hasExistingAccount) {
// Came from UnlockScreen recover — go back to unlock
currentScreen = AuthScreen.UNLOCK
isImportMode = false
} else {
@@ -146,18 +151,14 @@ fun AuthFlow(
onBack = { currentScreen = AuthScreen.WELCOME },
onConfirm = { words ->
seedPhrase = words
currentScreen = AuthScreen.CONFIRM_SEED
currentScreen = AuthScreen.SET_PASSWORD
}
)
}
AuthScreen.CONFIRM_SEED -> {
ConfirmSeedPhraseScreen(
seedPhrase = seedPhrase,
isDarkTheme = isDarkTheme,
onBack = { currentScreen = AuthScreen.SEED_PHRASE },
onConfirmed = { currentScreen = AuthScreen.SET_PASSWORD }
)
// Skipped — go directly from SEED_PHRASE to SET_PASSWORD
LaunchedEffect(Unit) { currentScreen = AuthScreen.SET_PASSWORD }
}
AuthScreen.SET_PASSWORD -> {
@@ -165,19 +166,35 @@ fun AuthFlow(
seedPhrase = seedPhrase,
isDarkTheme = isDarkTheme,
isImportMode = isImportMode,
onBack = {
onBack = {
if (isImportMode) {
currentScreen = AuthScreen.IMPORT_SEED
} else if (hasExistingAccount) {
currentScreen = AuthScreen.UNLOCK
} else {
currentScreen = AuthScreen.CONFIRM_SEED
currentScreen = AuthScreen.SEED_PHRASE
}
},
onAccountCreated = { account -> onAuthComplete(account) }
onAccountCreated = { account ->
if (isImportMode) {
onAuthComplete(account)
} else {
createdAccount = account
currentScreen = AuthScreen.SET_PROFILE
}
}
)
}
AuthScreen.SET_PROFILE -> {
SetProfileScreen(
isDarkTheme = isDarkTheme,
account = createdAccount,
onComplete = { onAuthComplete(createdAccount) },
onSkip = { onAuthComplete(createdAccount) }
)
}
AuthScreen.IMPORT_SEED -> {
ImportSeedPhraseScreen(
isDarkTheme = isDarkTheme,

View File

@@ -203,7 +203,7 @@ fun SeedPhraseScreen(
shape = RoundedCornerShape(14.dp)
) {
Text(
text = "Continue",
text = "I Saved It",
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold
)

View File

@@ -25,6 +25,9 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.biometric.BiometricAuthManager
import com.rosetta.messenger.biometric.BiometricAvailability
import com.rosetta.messenger.biometric.BiometricPreferences
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount
@@ -76,6 +79,11 @@ fun SetPasswordScreen(
var error by remember { mutableStateOf<String?>(null) }
var visible by remember { mutableStateOf(false) }
val biometricManager = remember { BiometricAuthManager(context) }
val biometricPrefs = remember { BiometricPreferences(context) }
val biometricAvailable = remember { biometricManager.isBiometricAvailable() is BiometricAvailability.Available }
var biometricEnabled by remember { mutableStateOf(biometricAvailable) }
// Track keyboard visibility
val view = LocalView.current
if (!view.isInEditMode) {
@@ -475,6 +483,44 @@ fun SetPasswordScreen(
}
}
// Biometric toggle
if (biometricAvailable && !isKeyboardVisible) {
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(cardColor)
.clickable { biometricEnabled = !biometricEnabled }
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = TablerIcons.Fingerprint,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "Use Biometrics",
fontSize = 15.sp,
color = textColor,
modifier = Modifier.weight(1f)
)
Switch(
checked = biometricEnabled,
onCheckedChange = { biometricEnabled = it },
colors = SwitchDefaults.colors(
checkedThumbColor = Color.White,
checkedTrackColor = PrimaryBlue,
uncheckedThumbColor = Color.White,
uncheckedTrackColor = secondaryTextColor.copy(alpha = 0.3f)
)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Create Account Button
@@ -544,6 +590,17 @@ fun SetPasswordScreen(
name = truncatedKey
)
// Save biometric preference
if (biometricEnabled && biometricAvailable) {
try {
biometricPrefs.enableBiometric()
biometricPrefs.saveEncryptedPassword(
keyPair.publicKey,
CryptoManager.encryptWithPassword(password, keyPair.publicKey)
)
} catch (_: Exception) {}
}
onAccountCreated(decryptedAccount)
} catch (e: Exception) {
error = "Failed to create account: ${e.message}"

View File

@@ -0,0 +1,470 @@
package com.rosetta.messenger.ui.auth
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.*
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.network.PacketUserInfo
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.ui.icons.TelegramIcons
import com.rosetta.messenger.ui.settings.ProfilePhotoPicker
import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.utils.ImageCropHelper
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.database.RosettaDatabase
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
private val PrimaryBlue = Color(0xFF248AE6)
private val ErrorRed = Color(0xFFFF3B30)
private const val NAME_MAX_LENGTH = 40
private const val USERNAME_MIN_LENGTH = 5
private const val USERNAME_MAX_LENGTH = 32
private val NAME_ALLOWED_REGEX = Regex("^[\\p{L}\\p{N} ._'-]+$")
private val USERNAME_ALLOWED_REGEX = Regex("^[A-Za-z0-9_]+$")
private fun validateName(name: String): String? {
if (name.isBlank()) return "Name can't be empty"
if (name.length > NAME_MAX_LENGTH) return "Name is too long (max $NAME_MAX_LENGTH)"
if (!NAME_ALLOWED_REGEX.matches(name)) return "Only letters, numbers, spaces, . _ - ' are allowed"
return null
}
private fun validateUsername(username: String): String? {
if (username.isBlank()) return null // optional
if (username.length < USERNAME_MIN_LENGTH) return "Username must be at least $USERNAME_MIN_LENGTH characters"
if (username.length > USERNAME_MAX_LENGTH) return "Username is too long (max $USERNAME_MAX_LENGTH)"
if (!USERNAME_ALLOWED_REGEX.matches(username)) return "Use only letters, numbers, and underscore"
return null
}
@Composable
fun SetProfileScreen(
isDarkTheme: Boolean,
account: DecryptedAccount?,
onComplete: () -> Unit,
onSkip: () -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var name by remember { mutableStateOf("") }
var username by remember { mutableStateOf("") }
var nameTouched by remember { mutableStateOf(false) }
var usernameTouched by remember { mutableStateOf(false) }
var avatarUri by remember { mutableStateOf<String?>(null) }
var showPhotoPicker by remember { mutableStateOf(false) }
var isSaving by remember { mutableStateOf(false) }
var visible by remember { mutableStateOf(false) }
val nameError = if (nameTouched) validateName(name.trim()) else null
val usernameError = if (usernameTouched) validateUsername(username.trim()) else null
val isFormValid = validateName(name.trim()) == null && validateUsername(username.trim()) == null
LaunchedEffect(Unit) { visible = true }
val cropLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
val croppedUri = ImageCropHelper.getCroppedImageUri(result)
if (croppedUri != null) {
avatarUri = croppedUri.toString()
}
}
val backgroundColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color(0xFFF8F8FF)
val cardColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
val textColor = if (isDarkTheme) Color.White else Color(0xFF1A1A1A)
val secondaryText = Color(0xFF8E8E93)
val avatarBg = if (isDarkTheme) Color(0xFF333336) else PrimaryBlue
Box(
modifier = Modifier
.fillMaxSize()
.background(backgroundColor)
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 24.dp)
.statusBarsPadding()
.navigationBarsPadding(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(16.dp))
// Skip button
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
TextButton(onClick = onSkip) {
Text(
text = "Skip",
color = PrimaryBlue,
fontSize = 16.sp,
fontWeight = FontWeight.Medium
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Avatar
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400)) + scaleIn(tween(400), initialScale = 0.8f)
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(100.dp)
.clip(CircleShape)
.background(avatarBg)
.clickable { showPhotoPicker = true }
) {
if (avatarUri != null) {
AsyncImage(
model = ImageRequest.Builder(context)
.data(Uri.parse(avatarUri))
.crossfade(true)
.build(),
contentDescription = "Avatar",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
} else {
Icon(
painter = TelegramIcons.Camera,
contentDescription = "Set photo",
tint = Color.White,
modifier = Modifier.size(36.dp)
)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = { showPhotoPicker = true }) {
Text(
text = "Set Photo",
color = PrimaryBlue,
fontSize = 14.sp
)
}
Spacer(modifier = Modifier.height(24.dp))
// Title
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 150))
) {
Text(
text = "What's your name?",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
color = textColor,
textAlign = TextAlign.Center
)
}
Spacer(modifier = Modifier.height(8.dp))
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 250))
) {
Text(
text = "Your friends can find you by this name. It will be displayed in chats, groups, and your profile.",
fontSize = 15.sp,
color = secondaryText,
textAlign = TextAlign.Center,
lineHeight = 20.sp
)
}
Spacer(modifier = Modifier.height(28.dp))
// Name & Username inputs
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 350))
) {
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
// Name field
Column {
TextField(
value = name,
onValueChange = {
name = it.take(NAME_MAX_LENGTH)
nameTouched = true
},
placeholder = {
Text("Your name", color = secondaryText)
},
isError = nameError != null,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp)),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedTextColor = textColor,
unfocusedTextColor = textColor,
focusedContainerColor = cardColor,
unfocusedContainerColor = cardColor,
errorContainerColor = cardColor,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
cursorColor = PrimaryBlue
)
)
if (nameError != null) {
Text(
text = nameError,
fontSize = 12.sp,
color = ErrorRed,
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Username field
Column {
TextField(
value = username,
onValueChange = { raw ->
username = raw.take(USERNAME_MAX_LENGTH)
.lowercase()
.filter { it.isLetterOrDigit() || it == '_' }
usernameTouched = true
},
placeholder = {
Text("Username", color = secondaryText)
},
prefix = {
Text("@", color = secondaryText, fontSize = 16.sp)
},
isError = usernameError != null,
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(14.dp)),
singleLine = true,
colors = TextFieldDefaults.colors(
focusedTextColor = textColor,
unfocusedTextColor = textColor,
focusedContainerColor = cardColor,
unfocusedContainerColor = cardColor,
errorContainerColor = cardColor,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
cursorColor = PrimaryBlue
)
)
if (usernameError != null) {
Text(
text = usernameError,
fontSize = 12.sp,
color = ErrorRed,
modifier = Modifier.padding(start = 16.dp, top = 4.dp)
)
} else {
Text(
text = "Username is optional. People can use it to find you without sharing your key.",
fontSize = 12.sp,
color = secondaryText.copy(alpha = 0.7f),
modifier = Modifier.padding(start = 16.dp, top = 4.dp),
lineHeight = 16.sp
)
}
}
}
}
Spacer(modifier = Modifier.height(32.dp))
// Continue button
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 450))
) {
Button(
onClick = {
if (account == null) {
onComplete()
return@Button
}
isSaving = true
scope.launch {
try {
// Wait for server connection (up to 8s)
val connected = withTimeoutOrNull(8000) {
while (!ProtocolManager.isAuthenticated()) {
delay(300)
}
true
} ?: false
// Save name and username locally first
val accountManager = AccountManager(context)
if (name.trim().isNotEmpty()) {
accountManager.updateAccountName(account.publicKey, name.trim())
}
if (username.trim().isNotEmpty()) {
accountManager.updateAccountUsername(account.publicKey, username.trim())
}
// Trigger UI refresh in MainActivity
ProtocolManager.notifyOwnProfileUpdated()
// Send name and username to server
if (connected && (name.trim().isNotEmpty() || username.trim().isNotEmpty())) {
val packet = PacketUserInfo()
packet.title = name.trim()
packet.username = username.trim()
packet.privateKey = account.privateKeyHash
ProtocolManager.send(packet)
delay(1500)
// Повторяем для надёжности
if (ProtocolManager.isAuthenticated()) {
val packet2 = PacketUserInfo()
packet2.title = name.trim()
packet2.username = username.trim()
packet2.privateKey = account.privateKeyHash
ProtocolManager.send(packet2)
delay(500)
}
}
// Save avatar
if (avatarUri != null) {
withContext(Dispatchers.IO) {
val uri = Uri.parse(avatarUri)
val rawBytes = context.contentResolver
.openInputStream(uri)?.use { it.readBytes() }
if (rawBytes != null && rawBytes.isNotEmpty()) {
val preparedBase64 = AvatarFileManager
.imagePrepareForNetworkTransfer(context, rawBytes)
if (preparedBase64.isNotBlank()) {
val db = RosettaDatabase.getDatabase(context)
val avatarRepo = AvatarRepository(
context = context,
avatarDao = db.avatarDao(),
currentPublicKey = account.publicKey
)
avatarRepo.saveAvatar(
fromPublicKey = account.publicKey,
base64Image = "data:image/png;base64,$preparedBase64"
)
}
}
}
}
onComplete()
} catch (_: Exception) {
onComplete()
}
}
},
enabled = isFormValid && !isSaving,
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
colors = ButtonDefaults.buttonColors(
containerColor = PrimaryBlue,
contentColor = Color.White,
disabledContainerColor = PrimaryBlue.copy(alpha = 0.5f),
disabledContentColor = Color.White.copy(alpha = 0.5f)
),
shape = RoundedCornerShape(14.dp)
) {
if (isSaving) {
CircularProgressIndicator(
modifier = Modifier.size(22.dp),
color = Color.White,
strokeWidth = 2.dp
)
} else {
Text(
text = "Continue",
fontSize = 17.sp,
fontWeight = FontWeight.SemiBold
)
}
}
}
Spacer(modifier = Modifier.weight(1f))
// Examples of where name appears
AnimatedVisibility(
visible = visible,
enter = fadeIn(tween(400, delayMillis = 550))
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Your name will appear in:",
fontSize = 13.sp,
color = secondaryText,
fontWeight = FontWeight.Medium
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "Chat list • Messages • Groups • Profile",
fontSize = 13.sp,
color = secondaryText.copy(alpha = 0.7f)
)
}
}
}
}
ProfilePhotoPicker(
isVisible = showPhotoPicker,
onDismiss = { showPhotoPicker = false },
onPhotoSelected = { uri ->
showPhotoPicker = false
val cropIntent = ImageCropHelper.createCropIntent(context, uri, isDarkTheme)
cropLauncher.launch(cropIntent)
},
isDarkTheme = isDarkTheme
)
}

View File

@@ -2561,7 +2561,8 @@ fun ChatDetailScreen(
it.type !=
AttachmentType
.MESSAGES
}
},
chachaKeyPlain = fwd.chachaKeyPlainHex
)
}
} else {
@@ -2593,7 +2594,8 @@ fun ChatDetailScreen(
it.type !=
AttachmentType
.MESSAGES
}
},
chachaKeyPlain = msg.chachaKeyPlainHex
))
}
}
@@ -2707,7 +2709,8 @@ fun ChatDetailScreen(
.filter {
it.type != AttachmentType.MESSAGES
}
.map { it.copy(localUri = "") }
.map { it.copy(localUri = "") },
chachaKeyPlain = msg.chachaKeyPlainHex
)
}
@@ -3472,7 +3475,8 @@ fun ChatDetailScreen(
senderName = fwd.forwardedFromName.ifEmpty { fwd.senderName.ifEmpty { "User" } },
attachments = fwd.attachments
.filter { it.type != AttachmentType.MESSAGES }
.map { it.copy(localUri = "") }
.map { it.copy(localUri = "") },
chachaKeyPlain = fwd.chachaKeyPlainHex
)
}
} else {
@@ -3487,7 +3491,8 @@ fun ChatDetailScreen(
else user.title.ifEmpty { user.username.ifEmpty { "User" } },
attachments = msg.attachments
.filter { it.type != AttachmentType.MESSAGES }
.map { it.copy(localUri = "") }
.map { it.copy(localUri = "") },
chachaKeyPlain = msg.chachaKeyPlainHex
))
}
ForwardManager.setForwardMessages(forwardMessages, showPicker = false)

View File

@@ -196,7 +196,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val isOutgoing: Boolean,
val publicKey: String = "", // publicKey отправителя цитируемого сообщения
val senderName: String = "", // Имя отправителя для атрибуции forward
val attachments: List<MessageAttachment> = emptyList() // Для показа превью
val attachments: List<MessageAttachment> = emptyList(), // Для показа превью
val chachaKeyPlainHex: String = "" // Hex plainKeyAndNonce для forward
)
private val _replyMessages = MutableStateFlow<List<ReplyMessage>>(emptyList())
val replyMessages: StateFlow<List<ReplyMessage>> = _replyMessages.asStateFlow()
@@ -1403,6 +1404,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
requestGroupSenderNameIfNeeded(senderKey)
}
val chachaKeyPlainHex = plainKeyAndNonce?.joinToString("") { "%02x".format(it) } ?: ""
return ChatMessage(
id = entity.messageId,
text = displayText,
@@ -1413,6 +1416,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
forwardedMessages = forwardedMessages,
attachments = finalAttachments,
chachaKey = entity.chachaKey,
chachaKeyPlainHex = chachaKeyPlainHex,
senderPublicKey = senderKey,
senderName = senderName
)
@@ -2000,6 +2004,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val fwdMessageId = fwdMsg.optString("message_id", "")
val fwdTimestamp = fwdMsg.optLong("timestamp", 0L)
val fwdSenderName = fwdMsg.optString("senderName", "")
val fwdChachaKeyPlain = fwdMsg.optString("chacha_key_plain", "")
val fwdIsFromMe = fwdPublicKey == myPublicKey
// Parse attachments from this forwarded message
@@ -2086,7 +2091,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
forwardedFromName = senderDisplayName,
attachments = fwdAttachments,
senderPublicKey = fwdPublicKey.ifEmpty { if (fwdIsFromMe) myPublicKey ?: "" else opponentKey ?: "" },
recipientPrivateKey = myPrivateKey ?: ""
recipientPrivateKey = myPrivateKey ?: "",
chachaKeyPlainHex = fwdChachaKeyPlain
))
}
return ParsedReplyResult(
@@ -2102,6 +2108,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val replyMessageIdFromJson = replyMessage.optString("message_id", "")
val replyTimestamp = replyMessage.optLong("timestamp", 0L)
val senderNameFromJson = replyMessage.optString("senderName", "")
val chachaKeyPlainFromJson = replyMessage.optString("chacha_key_plain", "")
// 🔥 Detect forward: explicit flag OR publicKey belongs to a third party
// Desktop doesn't send "forwarded" flag, but if publicKey differs from
@@ -2193,22 +2200,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// Определяем, кто автор цитируемого сообщения
val isReplyFromMe = replyPublicKey == myPublicKey
// <EFBFBD> Используем attachments из JSON если есть, иначе загружаем из БД
// Загружаем оригинальное сообщение из БД для attachments и chachaKey
val originalMessage = try {
messageDao.findMessageByContent(
account = account,
dialogKey = dialogKey,
fromPublicKey = replyPublicKey,
timestampFrom = replyTimestamp - 5000,
timestampTo = replyTimestamp + 5000
)
} catch (_: Exception) { null }
val originalChachaKey = originalMessage?.chachaKey ?: ""
val originalAttachments =
if (replyAttachmentsFromJson.isNotEmpty()) {
// Используем attachments из JSON reply
replyAttachmentsFromJson
} else {
// Fallback: загружаем из БД
try {
val originalMessage =
messageDao.findMessageByContent(
account = account,
dialogKey = dialogKey,
fromPublicKey = replyPublicKey,
timestampFrom = replyTimestamp - 5000,
timestampTo = replyTimestamp + 5000
)
if (originalMessage != null &&
originalMessage.attachments.isNotEmpty()
) {
@@ -2266,7 +2275,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
if (isReplyFromMe) myPublicKey ?: ""
else opponentKey ?: ""
},
recipientPrivateKey = myPrivateKey ?: ""
recipientPrivateKey = myPrivateKey ?: "",
chachaKey = originalChachaKey,
chachaKeyPlainHex = chachaKeyPlainFromJson
)
// 🔥 If this is a forwarded message (from third party), return as forwardedMessages list
@@ -2427,13 +2438,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
text = msg.text,
timestamp = msg.timestamp.time,
isOutgoing = msg.isOutgoing,
// В группах senderPublicKey содержит автора сообщения, а opponent = ключ группы.
// Для desktop parity в reply JSON нужен именно ключ автора.
publicKey = resolvedPublicKey,
senderName = msg.senderName,
attachments =
msg.attachments
.filter { it.type != AttachmentType.MESSAGES }
.filter { it.type != AttachmentType.MESSAGES },
chachaKeyPlainHex = msg.chachaKeyPlainHex
)
}
_isForwardMode.value = false
@@ -3215,34 +3225,27 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val replyAttachmentId = "reply_${timestamp}"
fun buildForwardReplyJson(
forwardedIdMap: Map<String, MessageAttachment> = emptyMap(),
includeLocalUri: Boolean
): JSONArray {
val replyJsonArray = JSONArray()
forwardMessages.forEach { fm ->
val attachmentsArray = JSONArray()
fm.attachments.forEach { att ->
val fwdInfo = forwardedIdMap[att.id]
val attId = fwdInfo?.id ?: att.id
val attPreview = fwdInfo?.preview ?: att.preview
val attTransportTag = fwdInfo?.transportTag ?: att.transportTag
val attTransportServer = fwdInfo?.transportServer ?: att.transportServer
attachmentsArray.put(
JSONObject().apply {
put("id", attId)
put("id", att.id)
put("type", att.type.value)
put("preview", attPreview)
put("preview", att.preview)
put("width", att.width)
put("height", att.height)
put("blob", if (att.type == AttachmentType.MESSAGES) att.blob else "")
put("transportTag", attTransportTag)
put("transportServer", attTransportServer)
put("blob", "")
put("transportTag", att.transportTag)
put("transportServer", att.transportServer)
put(
"transport",
JSONObject().apply {
put("transport_tag", attTransportTag)
put("transport_server", attTransportServer)
put("transport_tag", att.transportTag)
put("transport_server", att.transportServer)
}
)
if (includeLocalUri && att.localUri.isNotEmpty()) {
@@ -3260,6 +3263,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
put("attachments", attachmentsArray)
put("forwarded", true)
put("senderName", fm.senderName)
if (fm.chachaKeyPlain.isNotEmpty()) {
put("chacha_key_plain", fm.chachaKeyPlain)
}
}
)
}
@@ -3349,63 +3355,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
}
// 📸 Forward: загружаем IMAGE на CDN и пересобираем MESSAGES blob с новыми ID/tag
// Map: originalAttId -> updated attachment metadata.
val forwardedAttMap = mutableMapOf<String, MessageAttachment>()
var fwdIdx = 0
for (fm in forwardMessages) {
for (att in fm.attachments) {
if (att.type == AttachmentType.IMAGE) {
try {
val imageBlob = AttachmentFileManager.readAttachment(
context = context,
attachmentId = att.id,
publicKey = fm.senderPublicKey,
privateKey = privateKey
)
if (imageBlob != null) {
val encryptedBlob = encryptAttachmentPayload(imageBlob, encryptionContext)
val newAttId = "fwd_${timestamp}_${fwdIdx++}"
var uploadTag = ""
if (!isSavedMessages) {
uploadTag = TransportManager.uploadFile(newAttId, encryptedBlob)
}
val blurhash = att.preview.substringAfter("::", att.preview)
val transportServer =
if (uploadTag.isNotEmpty()) {
TransportManager.getTransportServer().orEmpty()
} else {
""
}
forwardedAttMap[att.id] =
att.copy(
id = newAttId,
preview = blurhash,
blob = "",
transportTag = uploadTag,
transportServer = transportServer
)
// Сохраняем локально с новым ID
// publicKey = fm.senderPublicKey чтобы совпадал с JSON для parseReplyFromAttachments
AttachmentFileManager.saveAttachment(
context = context,
blob = imageBlob,
attachmentId = newAttId,
publicKey = fm.senderPublicKey,
privateKey = privateKey
)
}
} catch (e: Exception) { }
}
}
}
// Desktop/iOS parity: передаём оригинальные transport tags + chacha_key_plain,
// без перезагрузки картинок на CDN
val replyBlobPlaintext =
buildForwardReplyJson(
forwardedIdMap = forwardedAttMap,
includeLocalUri = false
)
.toString()

View File

@@ -2940,9 +2940,11 @@ internal suspend fun downloadAndDecryptImage(
cacheKey: String,
context: android.content.Context,
senderPublicKey: String,
recipientPrivateKey: String
recipientPrivateKey: String,
chachaKeyPlainHex: String = ""
): Bitmap? {
if (downloadTag.isEmpty() || chachaKey.isEmpty() || privateKey.isEmpty()) return null
if (downloadTag.isEmpty() || privateKey.isEmpty()) return null
if (chachaKeyPlainHex.isEmpty() && chachaKey.isEmpty()) return null
return withContext(Dispatchers.IO) {
val idShort = shortDebugId(attachmentId)
@@ -2957,7 +2959,13 @@ internal suspend fun downloadAndDecryptImage(
)
val decrypted: String? =
if (isGroupStoredKey(chachaKey)) {
if (chachaKeyPlainHex.isNotEmpty()) {
// Desktop/iOS parity: используем готовый plainKeyAndNonce из chacha_key_plain
val plainKey = chachaKeyPlainHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
logPhotoDebug("Helper using chacha_key_plain: id=$idShort, keySize=${plainKey.size}")
MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, plainKey)
?: MessageCrypto.decryptReplyBlob(encryptedContent, plainKey).takeIf { it.isNotEmpty() }
} else if (isGroupStoredKey(chachaKey)) {
val groupPassword = decodeGroupPassword(chachaKey, privateKey) ?: return@withContext null
CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
} else {

View File

@@ -2196,10 +2196,11 @@ fun ReplyBubble(
// 6. CDN download — для форвардов, где фото загружено на CDN
if (imageBitmap == null && imageAttachment.preview.isNotEmpty()) {
val downloadTag = getDownloadTag(imageAttachment.preview)
val effectiveChachaKey = replyData.chachaKey.ifEmpty { chachaKey }
val bitmap = downloadAndDecryptImage(
attachmentId = imageAttachment.id,
downloadTag = downloadTag,
chachaKey = chachaKey,
chachaKey = effectiveChachaKey,
privateKey = privateKey,
cacheKey = "img_${imageAttachment.id}",
context = context,
@@ -2471,9 +2472,10 @@ fun ForwardedMessagesBubble(
attachment = imgAtt,
senderPublicKey = fwd.senderPublicKey,
recipientPrivateKey = fwd.recipientPrivateKey,
chachaKey = chachaKey,
chachaKey = fwd.chachaKey.ifEmpty { chachaKey },
privateKey = privateKey,
onImageClick = onImageClick
onImageClick = onImageClick,
chachaKeyPlainHex = fwd.chachaKeyPlainHex
)
}
}
@@ -2509,7 +2511,8 @@ private fun ForwardedImagePreview(
recipientPrivateKey: String,
chachaKey: String,
privateKey: String,
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit,
chachaKeyPlainHex: String = ""
) {
val context = androidx.compose.ui.platform.LocalContext.current
val configuration = androidx.compose.ui.platform.LocalConfiguration.current
@@ -2595,7 +2598,8 @@ private fun ForwardedImagePreview(
cacheKey = cacheKey,
context = context,
senderPublicKey = senderPublicKey,
recipientPrivateKey = recipientPrivateKey
recipientPrivateKey = recipientPrivateKey,
chachaKeyPlainHex = chachaKeyPlainHex
)
if (bitmap != null) imageBitmap = bitmap
}

View File

@@ -121,7 +121,8 @@ data class ViewableImage(
val timestamp: Date,
val width: Int = 0,
val height: Int = 0,
val caption: String = "" // Текст сообщения для отображения снизу
val caption: String = "",
val chachaKeyPlainHex: String = ""
)
/**
@@ -1012,7 +1013,16 @@ private suspend fun loadBitmapForViewerImage(
"Viewer CDN download OK: id=$idShort, bytes=${encryptedContent.length}"
)
val decrypted =
if (image.chachaKey.startsWith("group:")) {
if (image.chachaKeyPlainHex.isNotEmpty()) {
// Desktop/iOS parity: используем готовый plainKeyAndNonce
val plainKey = image.chachaKeyPlainHex.chunked(2).map { it.toInt(16).toByte() }.toByteArray()
AttachmentDownloadDebugLogger.log(
"Viewer using chacha_key_plain: id=$idShort, keySize=${plainKey.size}"
)
MessageCrypto.decryptAttachmentBlobWithPlainKey(encryptedContent, plainKey)
?: MessageCrypto.decryptReplyBlob(encryptedContent, plainKey).takeIf { it.isNotEmpty() }
?: return null
} else if (image.chachaKey.startsWith("group:")) {
val groupPassword =
CryptoManager.decryptWithPassword(
image.chachaKey.removePrefix("group:"),
@@ -1150,17 +1160,19 @@ fun extractImagesFromMessages(
val replySenderKey = message.replyData.senderPublicKey.ifEmpty {
if (message.replyData.isFromMe) currentPublicKey else opponentPublicKey
}
val replyChachaKey = message.replyData.chachaKey.ifEmpty { message.chachaKey }
ViewableImage(
attachmentId = attachment.id,
preview = attachment.preview,
blob = attachment.blob,
chachaKey = message.chachaKey,
chachaKey = replyChachaKey,
senderPublicKey = replySenderKey,
senderName = message.replyData.senderName,
timestamp = message.timestamp,
width = attachment.width,
height = attachment.height,
caption = message.replyData.text
caption = message.replyData.text,
chachaKeyPlainHex = message.replyData.chachaKeyPlainHex
)
} ?: emptyList()
@@ -1172,17 +1184,19 @@ fun extractImagesFromMessages(
val fwdSenderKey = fwd.senderPublicKey.ifEmpty {
if (fwd.isFromMe) currentPublicKey else opponentPublicKey
}
val fwdChachaKey = fwd.chachaKey.ifEmpty { message.chachaKey }
ViewableImage(
attachmentId = attachment.id,
preview = attachment.preview,
blob = attachment.blob,
chachaKey = message.chachaKey,
chachaKey = fwdChachaKey,
senderPublicKey = fwdSenderKey,
senderName = fwd.senderName,
timestamp = message.timestamp,
width = attachment.width,
height = attachment.height,
caption = fwd.text
caption = fwd.text,
chachaKeyPlainHex = fwd.chachaKeyPlainHex
)
}
}

View File

@@ -24,7 +24,10 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
@@ -755,67 +758,88 @@ fun MessageInputBar(
androidx.compose.animation.AnimatedVisibility(
visible = shouldShowEmojiSuggestions,
enter = fadeIn(animationSpec = tween(120)) +
slideInVertically(animationSpec = tween(120), initialOffsetY = { it / 2 }),
scaleIn(animationSpec = tween(150), initialScale = 0.92f,
transformOrigin = TransformOrigin(0.5f, 1f)),
exit = fadeOut(animationSpec = tween(90)) +
slideOutVertically(animationSpec = tween(90), targetOffsetY = { it / 3 }),
scaleOut(animationSpec = tween(90), targetScale = 0.92f,
transformOrigin = TransformOrigin(0.5f, 1f)),
modifier = Modifier
.align(Alignment.TopStart)
.offset(y = (-46).dp)
.offset(y = (-52).dp)
.fillMaxWidth()
.padding(horizontal = 12.dp)
.padding(horizontal = 8.dp)
.zIndex(4f)
) {
val barBackground = if (isDarkTheme) Color(0xFF303A48).copy(alpha = 0.97f) else Color(0xFFE9EEF6)
val barBorder = if (isDarkTheme) Color.White.copy(alpha = 0.12f) else Color.Black.copy(alpha = 0.1f)
val leadingIconBg =
if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.06f)
val barBackground = if (isDarkTheme) Color(0xFF2B2B2F) else Color.White
Surface(
modifier = Modifier.fillMaxWidth(),
color = barBackground,
shape = RoundedCornerShape(16.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, barBorder),
shadowElevation = if (isDarkTheme) 6.dp else 3.dp,
shape = RoundedCornerShape(20.dp),
shadowElevation = 8.dp,
tonalElevation = 0.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(38.dp)
.padding(horizontal = 8.dp),
.padding(horizontal = 6.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Smile icon — opens emoji keyboard
Box(
modifier = Modifier
.size(26.dp)
.size(44.dp)
.clip(CircleShape)
.background(leadingIconBg),
.background(
if (isDarkTheme) Color.White.copy(alpha = 0.08f)
else Color(0xFFF0F0F5)
)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { toggleEmojiPicker() },
contentAlignment = Alignment.Center
) {
Icon(
painter = TelegramIcons.Smile,
contentDescription = null,
tint = if (isDarkTheme) Color.White.copy(alpha = 0.92f) else Color(0xFF31415A),
modifier = Modifier.size(15.dp)
tint = if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color(0xFF9E9EA6),
modifier = Modifier.size(22.dp)
)
}
Spacer(modifier = Modifier.width(8.dp))
Spacer(modifier = Modifier.width(2.dp))
// Emoji row
LazyRow(
modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(9.dp),
horizontalArrangement = Arrangement.spacedBy(0.dp),
verticalAlignment = Alignment.CenterVertically
) {
items(emojiSuggestions, key = { it }) { emoji ->
var pressed by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (pressed) 0.78f else 1f,
animationSpec = tween(if (pressed) 80 else 200)
)
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) { onSelectEmojiSuggestion(emoji) }
.padding(horizontal = 1.dp),
.size(44.dp)
.graphicsLayer {
scaleX = scale
scaleY = scale
}
.clip(RoundedCornerShape(12.dp))
.pointerInput(emoji) {
detectTapGestures(
onPress = {
pressed = true
tryAwaitRelease()
pressed = false
},
onTap = { onSelectEmojiSuggestion(emoji) }
)
},
contentAlignment = Alignment.Center
) {
AppleEmojiText(

View File

@@ -26,7 +26,9 @@ data class ReplyData(
val forwardedFromName: String = "", // Имя оригинального отправителя для атрибуции
val attachments: List<MessageAttachment> = emptyList(), // 🖼️ Для превью фото в reply
val senderPublicKey: String = "", // Для расшифровки attachments в reply
val recipientPrivateKey: String = "" // Для расшифровки attachments в reply
val recipientPrivateKey: String = "", // Для расшифровки attachments в reply
val chachaKey: String = "", // Ключ оригинального сообщения для расшифровки attachments
val chachaKeyPlainHex: String = "" // Hex plainKeyAndNonce оригинального сообщения (для cross-platform forward)
)
/** Legacy message model (for compatibility) */
@@ -41,6 +43,7 @@ data class ChatMessage(
val forwardedMessages: List<ReplyData> = emptyList(), // Multiple forwarded messages (desktop parity)
val attachments: List<MessageAttachment> = emptyList(),
val chachaKey: String = "", // Для расшифровки attachments
val chachaKeyPlainHex: String = "", // Hex plainKeyAndNonce для forward
val senderPublicKey: String = "",
val senderName: String = ""
)

View File

@@ -11,8 +11,9 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ChevronRight
import compose.icons.TablerIcons
import compose.icons.tablericons.ChevronLeft
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
@@ -82,7 +83,7 @@ fun SafetyScreen(
) {
IconButton(onClick = onBack) {
Icon(
imageVector = Icons.Filled.ArrowBack,
imageVector = TablerIcons.ChevronLeft,
contentDescription = "Back",
tint = textColor
)