Группы: добавлен выбор участников при создании + авто-приглашение. 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.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.compose.runtime.* 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.CallForegroundService
import com.rosetta.messenger.network.CallManager import com.rosetta.messenger.network.CallManager
import com.rosetta.messenger.network.CallPhase 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.chats.calls.CallOverlay
import com.rosetta.messenger.ui.theme.RosettaAndroidTheme import com.rosetta.messenger.ui.theme.RosettaAndroidTheme
@@ -115,10 +118,23 @@ class IncomingCallActivity : ComponentActivity() {
callState 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) { RosettaAndroidTheme(darkTheme = true) {
CallOverlay( CallOverlay(
state = displayState, state = displayState,
isDarkTheme = true, isDarkTheme = true,
avatarRepository = avatarRepository,
isExpanded = true, isExpanded = true,
onAccept = { onAccept = {
callLog("onAccept tapped, phase=${callState.phase}") callLog("onAccept tapped, phase=${callState.phase}")

View File

@@ -30,7 +30,8 @@ object ForwardManager {
val senderPublicKey: String, // publicKey отправителя сообщения val senderPublicKey: String, // publicKey отправителя сообщения
val originalChatPublicKey: String, // publicKey чата откуда пересылается val originalChatPublicKey: String, // publicKey чата откуда пересылается
val senderName: String = "", // Имя отправителя для атрибуции 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 phase: CallPhase,
val displayName: String, val displayName: String,
val statusText: String, val statusText: String,
val durationSec: Int val durationSec: Int,
val peerPublicKey: String = ""
) )
override fun onBind(intent: Intent?): IBinder? = null override fun onBind(intent: Intent?): IBinder? = null
@@ -145,7 +146,8 @@ class CallForegroundService : Service() {
phase = state.phase, phase = state.phase,
displayName = state.displayName, displayName = state.displayName,
statusText = state.statusText, statusText = state.statusText,
durationSec = state.durationSec durationSec = state.durationSec,
peerPublicKey = state.peerPublicKey
) )
} }
@@ -158,11 +160,15 @@ class CallForegroundService : Service() {
.ifBlank { "Unknown" } .ifBlank { "Unknown" }
val statusText = payloadIntent.getStringExtra(EXTRA_STATUS_TEXT).orEmpty().ifBlank { state.statusText } val statusText = payloadIntent.getStringExtra(EXTRA_STATUS_TEXT).orEmpty().ifBlank { state.statusText }
val durationSec = payloadIntent.getIntExtra(EXTRA_DURATION_SEC, state.durationSec) val durationSec = payloadIntent.getIntExtra(EXTRA_DURATION_SEC, state.durationSec)
val peerPublicKey = payloadIntent.getStringExtra(EXTRA_PEER_PUBLIC_KEY)
.orEmpty()
.ifBlank { state.peerPublicKey }
return Snapshot( return Snapshot(
phase = phase, phase = phase,
displayName = displayName, displayName = displayName,
statusText = statusText, statusText = statusText,
durationSec = durationSec.coerceAtLeast(0) durationSec = durationSec.coerceAtLeast(0),
peerPublicKey = peerPublicKey
) )
} }
@@ -246,7 +252,7 @@ class CallForegroundService : Service() {
CallPhase.IDLE -> "Call ended" CallPhase.IDLE -> "Call ended"
} }
val contentText = snapshot.statusText.ifBlank { defaultStatus } 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) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val personBuilder = Person.Builder().setName(snapshot.displayName).setImportant(true) 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_DISPLAY_NAME = "extra_display_name"
private const val EXTRA_STATUS_TEXT = "extra_status_text" private const val EXTRA_STATUS_TEXT = "extra_status_text"
private const val EXTRA_DURATION_SEC = "extra_duration_sec" 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" const val EXTRA_OPEN_CALL_FROM_NOTIFICATION = "extra_open_call_from_notification"
fun syncWithCallState(context: Context, state: CallUiState) { fun syncWithCallState(context: Context, state: CallUiState) {
@@ -439,6 +446,7 @@ class CallForegroundService : Service() {
.putExtra(EXTRA_DISPLAY_NAME, state.displayName) .putExtra(EXTRA_DISPLAY_NAME, state.displayName)
.putExtra(EXTRA_STATUS_TEXT, state.statusText) .putExtra(EXTRA_STATUS_TEXT, state.statusText)
.putExtra(EXTRA_DURATION_SEC, state.durationSec) .putExtra(EXTRA_DURATION_SEC, state.durationSec)
.putExtra(EXTRA_PEER_PUBLIC_KEY, state.peerPublicKey)
runCatching { ContextCompat.startForegroundService(appContext, intent) } runCatching { ContextCompat.startForegroundService(appContext, intent) }
.onFailure { error -> .onFailure { error ->
@@ -471,8 +479,9 @@ class CallForegroundService : Service() {
val entity = runBlocking(Dispatchers.IO) { val entity = runBlocking(Dispatchers.IO) {
db.avatarDao().getLatestAvatarByKeys(listOf(publicKey)) db.avatarDao().getLatestAvatarByKeys(listOf(publicKey))
} ?: return null } ?: return null
val base64 = AvatarFileManager.readAvatar(applicationContext, entity.avatar) val rawBase64 = AvatarFileManager.readAvatar(applicationContext, entity.avatar)
?: return null ?: return null
val base64 = if (rawBase64.contains(",")) rawBase64.substringAfter(",") else rawBase64
val bytes = Base64.decode(base64, Base64.DEFAULT) val bytes = Base64.decode(base64, Base64.DEFAULT)
val original = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null val original = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null
toCircleBitmap(original) toCircleBitmap(original)

View File

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

View File

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

View File

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

View File

@@ -203,7 +203,7 @@ fun SeedPhraseScreen(
shape = RoundedCornerShape(14.dp) shape = RoundedCornerShape(14.dp)
) { ) {
Text( Text(
text = "Continue", text = "I Saved It",
fontSize = 17.sp, fontSize = 17.sp,
fontWeight = FontWeight.SemiBold 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.crypto.CryptoManager
import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.DecryptedAccount
@@ -76,6 +79,11 @@ fun SetPasswordScreen(
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
var visible by remember { mutableStateOf(false) } 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 // Track keyboard visibility
val view = LocalView.current val view = LocalView.current
if (!view.isInEditMode) { 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)) Spacer(modifier = Modifier.height(16.dp))
// Create Account Button // Create Account Button
@@ -544,6 +590,17 @@ fun SetPasswordScreen(
name = truncatedKey name = truncatedKey
) )
// Save biometric preference
if (biometricEnabled && biometricAvailable) {
try {
biometricPrefs.enableBiometric()
biometricPrefs.saveEncryptedPassword(
keyPair.publicKey,
CryptoManager.encryptWithPassword(password, keyPair.publicKey)
)
} catch (_: Exception) {}
}
onAccountCreated(decryptedAccount) onAccountCreated(decryptedAccount)
} catch (e: Exception) { } catch (e: Exception) {
error = "Failed to create account: ${e.message}" 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 != it.type !=
AttachmentType AttachmentType
.MESSAGES .MESSAGES
} },
chachaKeyPlain = fwd.chachaKeyPlainHex
) )
} }
} else { } else {
@@ -2593,7 +2594,8 @@ fun ChatDetailScreen(
it.type != it.type !=
AttachmentType AttachmentType
.MESSAGES .MESSAGES
} },
chachaKeyPlain = msg.chachaKeyPlainHex
)) ))
} }
} }
@@ -2707,7 +2709,8 @@ fun ChatDetailScreen(
.filter { .filter {
it.type != AttachmentType.MESSAGES 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" } }, senderName = fwd.forwardedFromName.ifEmpty { fwd.senderName.ifEmpty { "User" } },
attachments = fwd.attachments attachments = fwd.attachments
.filter { it.type != AttachmentType.MESSAGES } .filter { it.type != AttachmentType.MESSAGES }
.map { it.copy(localUri = "") } .map { it.copy(localUri = "") },
chachaKeyPlain = fwd.chachaKeyPlainHex
) )
} }
} else { } else {
@@ -3487,7 +3491,8 @@ fun ChatDetailScreen(
else user.title.ifEmpty { user.username.ifEmpty { "User" } }, else user.title.ifEmpty { user.username.ifEmpty { "User" } },
attachments = msg.attachments attachments = msg.attachments
.filter { it.type != AttachmentType.MESSAGES } .filter { it.type != AttachmentType.MESSAGES }
.map { it.copy(localUri = "") } .map { it.copy(localUri = "") },
chachaKeyPlain = msg.chachaKeyPlainHex
)) ))
} }
ForwardManager.setForwardMessages(forwardMessages, showPicker = false) ForwardManager.setForwardMessages(forwardMessages, showPicker = false)

View File

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

View File

@@ -2940,9 +2940,11 @@ internal suspend fun downloadAndDecryptImage(
cacheKey: String, cacheKey: String,
context: android.content.Context, context: android.content.Context,
senderPublicKey: String, senderPublicKey: String,
recipientPrivateKey: String recipientPrivateKey: String,
chachaKeyPlainHex: String = ""
): Bitmap? { ): 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) { return withContext(Dispatchers.IO) {
val idShort = shortDebugId(attachmentId) val idShort = shortDebugId(attachmentId)
@@ -2957,7 +2959,13 @@ internal suspend fun downloadAndDecryptImage(
) )
val decrypted: String? = 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 val groupPassword = decodeGroupPassword(chachaKey, privateKey) ?: return@withContext null
CryptoManager.decryptWithPassword(encryptedContent, groupPassword) CryptoManager.decryptWithPassword(encryptedContent, groupPassword)
} else { } else {

View File

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

View File

@@ -121,7 +121,8 @@ data class ViewableImage(
val timestamp: Date, val timestamp: Date,
val width: Int = 0, val width: Int = 0,
val height: 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}" "Viewer CDN download OK: id=$idShort, bytes=${encryptedContent.length}"
) )
val decrypted = 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 = val groupPassword =
CryptoManager.decryptWithPassword( CryptoManager.decryptWithPassword(
image.chachaKey.removePrefix("group:"), image.chachaKey.removePrefix("group:"),
@@ -1150,17 +1160,19 @@ fun extractImagesFromMessages(
val replySenderKey = message.replyData.senderPublicKey.ifEmpty { val replySenderKey = message.replyData.senderPublicKey.ifEmpty {
if (message.replyData.isFromMe) currentPublicKey else opponentPublicKey if (message.replyData.isFromMe) currentPublicKey else opponentPublicKey
} }
val replyChachaKey = message.replyData.chachaKey.ifEmpty { message.chachaKey }
ViewableImage( ViewableImage(
attachmentId = attachment.id, attachmentId = attachment.id,
preview = attachment.preview, preview = attachment.preview,
blob = attachment.blob, blob = attachment.blob,
chachaKey = message.chachaKey, chachaKey = replyChachaKey,
senderPublicKey = replySenderKey, senderPublicKey = replySenderKey,
senderName = message.replyData.senderName, senderName = message.replyData.senderName,
timestamp = message.timestamp, timestamp = message.timestamp,
width = attachment.width, width = attachment.width,
height = attachment.height, height = attachment.height,
caption = message.replyData.text caption = message.replyData.text,
chachaKeyPlainHex = message.replyData.chachaKeyPlainHex
) )
} ?: emptyList() } ?: emptyList()
@@ -1172,17 +1184,19 @@ fun extractImagesFromMessages(
val fwdSenderKey = fwd.senderPublicKey.ifEmpty { val fwdSenderKey = fwd.senderPublicKey.ifEmpty {
if (fwd.isFromMe) currentPublicKey else opponentPublicKey if (fwd.isFromMe) currentPublicKey else opponentPublicKey
} }
val fwdChachaKey = fwd.chachaKey.ifEmpty { message.chachaKey }
ViewableImage( ViewableImage(
attachmentId = attachment.id, attachmentId = attachment.id,
preview = attachment.preview, preview = attachment.preview,
blob = attachment.blob, blob = attachment.blob,
chachaKey = message.chachaKey, chachaKey = fwdChachaKey,
senderPublicKey = fwdSenderKey, senderPublicKey = fwdSenderKey,
senderName = fwd.senderName, senderName = fwd.senderName,
timestamp = message.timestamp, timestamp = message.timestamp,
width = attachment.width, width = attachment.width,
height = attachment.height, 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.ExperimentalComposeUiApi
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer 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.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
@@ -755,67 +758,88 @@ fun MessageInputBar(
androidx.compose.animation.AnimatedVisibility( androidx.compose.animation.AnimatedVisibility(
visible = shouldShowEmojiSuggestions, visible = shouldShowEmojiSuggestions,
enter = fadeIn(animationSpec = tween(120)) + 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)) + 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 modifier = Modifier
.align(Alignment.TopStart) .align(Alignment.TopStart)
.offset(y = (-46).dp) .offset(y = (-52).dp)
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp) .padding(horizontal = 8.dp)
.zIndex(4f) .zIndex(4f)
) { ) {
val barBackground = if (isDarkTheme) Color(0xFF303A48).copy(alpha = 0.97f) else Color(0xFFE9EEF6) val barBackground = if (isDarkTheme) Color(0xFF2B2B2F) else Color.White
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)
Surface( Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
color = barBackground, color = barBackground,
shape = RoundedCornerShape(16.dp), shape = RoundedCornerShape(20.dp),
border = androidx.compose.foundation.BorderStroke(1.dp, barBorder), shadowElevation = 8.dp,
shadowElevation = if (isDarkTheme) 6.dp else 3.dp,
tonalElevation = 0.dp tonalElevation = 0.dp
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(38.dp) .padding(horizontal = 6.dp, vertical = 6.dp),
.padding(horizontal = 8.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Smile icon — opens emoji keyboard
Box( Box(
modifier = Modifier modifier = Modifier
.size(26.dp) .size(44.dp)
.clip(CircleShape) .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 contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
painter = TelegramIcons.Smile, painter = TelegramIcons.Smile,
contentDescription = null, contentDescription = null,
tint = if (isDarkTheme) Color.White.copy(alpha = 0.92f) else Color(0xFF31415A), tint = if (isDarkTheme) Color.White.copy(alpha = 0.5f) else Color(0xFF9E9EA6),
modifier = Modifier.size(15.dp) modifier = Modifier.size(22.dp)
) )
} }
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(2.dp))
// Emoji row
LazyRow( LazyRow(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
horizontalArrangement = Arrangement.spacedBy(9.dp), horizontalArrangement = Arrangement.spacedBy(0.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
items(emojiSuggestions, key = { it }) { emoji -> 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( Box(
modifier = Modifier modifier = Modifier
.clip(RoundedCornerShape(8.dp)) .size(44.dp)
.clickable( .graphicsLayer {
interactionSource = remember { MutableInteractionSource() }, scaleX = scale
indication = null scaleY = scale
) { onSelectEmojiSuggestion(emoji) } }
.padding(horizontal = 1.dp), .clip(RoundedCornerShape(12.dp))
.pointerInput(emoji) {
detectTapGestures(
onPress = {
pressed = true
tryAwaitRelease()
pressed = false
},
onTap = { onSelectEmojiSuggestion(emoji) }
)
},
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
AppleEmojiText( AppleEmojiText(

View File

@@ -26,7 +26,9 @@ data class ReplyData(
val forwardedFromName: String = "", // Имя оригинального отправителя для атрибуции val forwardedFromName: String = "", // Имя оригинального отправителя для атрибуции
val attachments: List<MessageAttachment> = emptyList(), // 🖼️ Для превью фото в reply val attachments: List<MessageAttachment> = emptyList(), // 🖼️ Для превью фото в reply
val senderPublicKey: String = "", // Для расшифровки attachments в 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) */ /** Legacy message model (for compatibility) */
@@ -41,6 +43,7 @@ data class ChatMessage(
val forwardedMessages: List<ReplyData> = emptyList(), // Multiple forwarded messages (desktop parity) val forwardedMessages: List<ReplyData> = emptyList(), // Multiple forwarded messages (desktop parity)
val attachments: List<MessageAttachment> = emptyList(), val attachments: List<MessageAttachment> = emptyList(),
val chachaKey: String = "", // Для расшифровки attachments val chachaKey: String = "", // Для расшифровки attachments
val chachaKeyPlainHex: String = "", // Hex plainKeyAndNonce для forward
val senderPublicKey: String = "", val senderPublicKey: String = "",
val senderName: 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.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ChevronRight import androidx.compose.material.icons.filled.ChevronRight
import compose.icons.TablerIcons
import compose.icons.tablericons.ChevronLeft
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -82,7 +83,7 @@ fun SafetyScreen(
) { ) {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon( Icon(
imageVector = Icons.Filled.ArrowBack, imageVector = TablerIcons.ChevronLeft,
contentDescription = "Back", contentDescription = "Back",
tint = textColor tint = textColor
) )