diff --git a/app/src/main/java/com/rosetta/messenger/IncomingCallActivity.kt b/app/src/main/java/com/rosetta/messenger/IncomingCallActivity.kt index 5812534..f445622 100644 --- a/app/src/main/java/com/rosetta/messenger/IncomingCallActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/IncomingCallActivity.kt @@ -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}") diff --git a/app/src/main/java/com/rosetta/messenger/data/ForwardManager.kt b/app/src/main/java/com/rosetta/messenger/data/ForwardManager.kt index 14bdf24..21284f5 100644 --- a/app/src/main/java/com/rosetta/messenger/data/ForwardManager.kt +++ b/app/src/main/java/com/rosetta/messenger/data/ForwardManager.kt @@ -30,7 +30,8 @@ object ForwardManager { val senderPublicKey: String, // publicKey отправителя сообщения val originalChatPublicKey: String, // publicKey чата откуда пересылается val senderName: String = "", // Имя отправителя для атрибуции - val attachments: List = emptyList() + val attachments: List = emptyList(), + val chachaKeyPlain: String = "" // Hex plainKeyAndNonce оригинального сообщения ) // Сообщения для пересылки diff --git a/app/src/main/java/com/rosetta/messenger/network/CallForegroundService.kt b/app/src/main/java/com/rosetta/messenger/network/CallForegroundService.kt index c949134..bad2da0 100644 --- a/app/src/main/java/com/rosetta/messenger/network/CallForegroundService.kt +++ b/app/src/main/java/com/rosetta/messenger/network/CallForegroundService.kt @@ -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) diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index 0f831df..b487533 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -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] } diff --git a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt index 194af02..666514e 100644 --- a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt +++ b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt @@ -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 diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt index 14da96a..da24ef8 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt @@ -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>(emptyList()) } + var createdAccount by remember { mutableStateOf(null) } // Use last logged account or fallback to first account var selectedAccountId by remember { mutableStateOf( @@ -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, diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SeedPhraseScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SeedPhraseScreen.kt index dac0590..2e93ad4 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/SeedPhraseScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SeedPhraseScreen.kt @@ -203,7 +203,7 @@ fun SeedPhraseScreen( shape = RoundedCornerShape(14.dp) ) { Text( - text = "Continue", + text = "I Saved It", fontSize = 17.sp, fontWeight = FontWeight.SemiBold ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt index c314f8b..a5efbea 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt @@ -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(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}" diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SetProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SetProfileScreen.kt new file mode 100644 index 0000000..2e37f6b --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SetProfileScreen.kt @@ -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(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 + ) +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 9af4799..5c70100 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -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) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index e0096cf..9925835 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -196,7 +196,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val isOutgoing: Boolean, val publicKey: String = "", // publicKey отправителя цитируемого сообщения val senderName: String = "", // Имя отправителя для атрибуции forward - val attachments: List = emptyList() // Для показа превью + val attachments: List = emptyList(), // Для показа превью + val chachaKeyPlainHex: String = "" // Hex plainKeyAndNonce для forward ) private val _replyMessages = MutableStateFlow>(emptyList()) val replyMessages: StateFlow> = _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 - // � Используем 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 = 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() - 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() diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index 122ef5b..886ac8f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -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 { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index fabb405..40f2ee4 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -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 } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt index 6835d66..73bf365 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ImageViewerScreen.kt @@ -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 ) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt index 5c7f431..da6df62 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/input/ChatDetailInput.kt @@ -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( diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/models/ChatDetailModels.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/models/ChatDetailModels.kt index 1275de6..9654524 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/models/ChatDetailModels.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/models/ChatDetailModels.kt @@ -26,7 +26,9 @@ data class ReplyData( val forwardedFromName: String = "", // Имя оригинального отправителя для атрибуции val attachments: List = 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 = emptyList(), // Multiple forwarded messages (desktop parity) val attachments: List = emptyList(), val chachaKey: String = "", // Для расшифровки attachments + val chachaKeyPlainHex: String = "", // Hex plainKeyAndNonce для forward val senderPublicKey: String = "", val senderName: String = "" ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/SafetyScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/SafetyScreen.kt index 5c08bb8..8f02050 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/SafetyScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/SafetyScreen.kt @@ -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 )