Группы: добавлен выбор участников при создании + авто-приглашение. Forward: убран ре-аплоад картинок, добавлен chacha_key_plain для кросс-платформы. Онбординг: экран профиля (имя + username + аватар), биометрия на экране пароля, убран экран подтверждения фразы. Звонки: аватарки в уведомлениях и на экране входящего. Reply: исправлена расшифровка фото (chachaKey оригинала). Уведомления: фикс декодирования аватарки (base64 prefix). UI: подсказка эмодзи в стиле Telegram, стрелка на Safety, сепараторы участников.
This commit is contained in:
@@ -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}")
|
||||
|
||||
@@ -30,7 +30,8 @@ object ForwardManager {
|
||||
val senderPublicKey: String, // publicKey отправителя сообщения
|
||||
val originalChatPublicKey: String, // publicKey чата откуда пересылается
|
||||
val senderName: String = "", // Имя отправителя для атрибуции
|
||||
val attachments: List<MessageAttachment> = emptyList()
|
||||
val attachments: List<MessageAttachment> = emptyList(),
|
||||
val chachaKeyPlain: String = "" // Hex plainKeyAndNonce оригинального сообщения
|
||||
)
|
||||
|
||||
// Сообщения для пересылки
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,6 +15,7 @@ enum class AuthScreen {
|
||||
SEED_PHRASE,
|
||||
CONFIRM_SEED,
|
||||
SET_PASSWORD,
|
||||
SET_PROFILE,
|
||||
IMPORT_SEED,
|
||||
UNLOCK
|
||||
}
|
||||
@@ -50,6 +51,7 @@ fun AuthFlow(
|
||||
)
|
||||
}
|
||||
var seedPhrase by remember { mutableStateOf<List<String>>(emptyList()) }
|
||||
var createdAccount by remember { mutableStateOf<DecryptedAccount?>(null) }
|
||||
// Use last logged account or fallback to first account
|
||||
var selectedAccountId by remember {
|
||||
mutableStateOf<String?>(
|
||||
@@ -82,12 +84,15 @@ fun AuthFlow(
|
||||
} else if (hasExistingAccount) {
|
||||
currentScreen = AuthScreen.UNLOCK
|
||||
} else {
|
||||
currentScreen = AuthScreen.CONFIRM_SEED
|
||||
currentScreen = AuthScreen.SEED_PHRASE
|
||||
}
|
||||
}
|
||||
AuthScreen.SET_PROFILE -> {
|
||||
// Skip profile setup — complete auth
|
||||
onAuthComplete(createdAccount)
|
||||
}
|
||||
AuthScreen.IMPORT_SEED -> {
|
||||
if (isImportMode && hasExistingAccount) {
|
||||
// Came from UnlockScreen recover — go back to unlock
|
||||
currentScreen = AuthScreen.UNLOCK
|
||||
isImportMode = false
|
||||
} else {
|
||||
@@ -146,18 +151,14 @@ fun AuthFlow(
|
||||
onBack = { currentScreen = AuthScreen.WELCOME },
|
||||
onConfirm = { words ->
|
||||
seedPhrase = words
|
||||
currentScreen = AuthScreen.CONFIRM_SEED
|
||||
currentScreen = AuthScreen.SET_PASSWORD
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
AuthScreen.CONFIRM_SEED -> {
|
||||
ConfirmSeedPhraseScreen(
|
||||
seedPhrase = seedPhrase,
|
||||
isDarkTheme = isDarkTheme,
|
||||
onBack = { currentScreen = AuthScreen.SEED_PHRASE },
|
||||
onConfirmed = { currentScreen = AuthScreen.SET_PASSWORD }
|
||||
)
|
||||
// Skipped — go directly from SEED_PHRASE to SET_PASSWORD
|
||||
LaunchedEffect(Unit) { currentScreen = AuthScreen.SET_PASSWORD }
|
||||
}
|
||||
|
||||
AuthScreen.SET_PASSWORD -> {
|
||||
@@ -165,19 +166,35 @@ fun AuthFlow(
|
||||
seedPhrase = seedPhrase,
|
||||
isDarkTheme = isDarkTheme,
|
||||
isImportMode = isImportMode,
|
||||
onBack = {
|
||||
onBack = {
|
||||
if (isImportMode) {
|
||||
currentScreen = AuthScreen.IMPORT_SEED
|
||||
} else if (hasExistingAccount) {
|
||||
currentScreen = AuthScreen.UNLOCK
|
||||
} else {
|
||||
currentScreen = AuthScreen.CONFIRM_SEED
|
||||
currentScreen = AuthScreen.SEED_PHRASE
|
||||
}
|
||||
},
|
||||
onAccountCreated = { account -> onAuthComplete(account) }
|
||||
onAccountCreated = { account ->
|
||||
if (isImportMode) {
|
||||
onAuthComplete(account)
|
||||
} else {
|
||||
createdAccount = account
|
||||
currentScreen = AuthScreen.SET_PROFILE
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
AuthScreen.SET_PROFILE -> {
|
||||
SetProfileScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
account = createdAccount,
|
||||
onComplete = { onAuthComplete(createdAccount) },
|
||||
onSkip = { onAuthComplete(createdAccount) }
|
||||
)
|
||||
}
|
||||
|
||||
AuthScreen.IMPORT_SEED -> {
|
||||
ImportSeedPhraseScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
|
||||
@@ -203,7 +203,7 @@ fun SeedPhraseScreen(
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Continue",
|
||||
text = "I Saved It",
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
||||
@@ -25,6 +25,9 @@ import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.rosetta.messenger.biometric.BiometricAuthManager
|
||||
import com.rosetta.messenger.biometric.BiometricAvailability
|
||||
import com.rosetta.messenger.biometric.BiometricPreferences
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.data.AccountManager
|
||||
import com.rosetta.messenger.data.DecryptedAccount
|
||||
@@ -76,6 +79,11 @@ fun SetPasswordScreen(
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
var visible by remember { mutableStateOf(false) }
|
||||
|
||||
val biometricManager = remember { BiometricAuthManager(context) }
|
||||
val biometricPrefs = remember { BiometricPreferences(context) }
|
||||
val biometricAvailable = remember { biometricManager.isBiometricAvailable() is BiometricAvailability.Available }
|
||||
var biometricEnabled by remember { mutableStateOf(biometricAvailable) }
|
||||
|
||||
// Track keyboard visibility
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
@@ -475,6 +483,44 @@ fun SetPasswordScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// Biometric toggle
|
||||
if (biometricAvailable && !isKeyboardVisible) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(cardColor)
|
||||
.clickable { biometricEnabled = !biometricEnabled }
|
||||
.padding(horizontal = 16.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = TablerIcons.Fingerprint,
|
||||
contentDescription = null,
|
||||
tint = PrimaryBlue,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text(
|
||||
text = "Use Biometrics",
|
||||
fontSize = 15.sp,
|
||||
color = textColor,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Switch(
|
||||
checked = biometricEnabled,
|
||||
onCheckedChange = { biometricEnabled = it },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = Color.White,
|
||||
checkedTrackColor = PrimaryBlue,
|
||||
uncheckedThumbColor = Color.White,
|
||||
uncheckedTrackColor = secondaryTextColor.copy(alpha = 0.3f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Create Account Button
|
||||
@@ -544,6 +590,17 @@ fun SetPasswordScreen(
|
||||
name = truncatedKey
|
||||
)
|
||||
|
||||
// Save biometric preference
|
||||
if (biometricEnabled && biometricAvailable) {
|
||||
try {
|
||||
biometricPrefs.enableBiometric()
|
||||
biometricPrefs.saveEncryptedPassword(
|
||||
keyPair.publicKey,
|
||||
CryptoManager.encryptWithPassword(password, keyPair.publicKey)
|
||||
)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
onAccountCreated(decryptedAccount)
|
||||
} catch (e: Exception) {
|
||||
error = "Failed to create account: ${e.message}"
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -196,7 +196,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val isOutgoing: Boolean,
|
||||
val publicKey: String = "", // publicKey отправителя цитируемого сообщения
|
||||
val senderName: String = "", // Имя отправителя для атрибуции forward
|
||||
val attachments: List<MessageAttachment> = emptyList() // Для показа превью
|
||||
val attachments: List<MessageAttachment> = emptyList(), // Для показа превью
|
||||
val chachaKeyPlainHex: String = "" // Hex plainKeyAndNonce для forward
|
||||
)
|
||||
private val _replyMessages = MutableStateFlow<List<ReplyMessage>>(emptyList())
|
||||
val replyMessages: StateFlow<List<ReplyMessage>> = _replyMessages.asStateFlow()
|
||||
@@ -1403,6 +1404,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
requestGroupSenderNameIfNeeded(senderKey)
|
||||
}
|
||||
|
||||
val chachaKeyPlainHex = plainKeyAndNonce?.joinToString("") { "%02x".format(it) } ?: ""
|
||||
|
||||
return ChatMessage(
|
||||
id = entity.messageId,
|
||||
text = displayText,
|
||||
@@ -1413,6 +1416,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
forwardedMessages = forwardedMessages,
|
||||
attachments = finalAttachments,
|
||||
chachaKey = entity.chachaKey,
|
||||
chachaKeyPlainHex = chachaKeyPlainHex,
|
||||
senderPublicKey = senderKey,
|
||||
senderName = senderName
|
||||
)
|
||||
@@ -2000,6 +2004,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val fwdMessageId = fwdMsg.optString("message_id", "")
|
||||
val fwdTimestamp = fwdMsg.optLong("timestamp", 0L)
|
||||
val fwdSenderName = fwdMsg.optString("senderName", "")
|
||||
val fwdChachaKeyPlain = fwdMsg.optString("chacha_key_plain", "")
|
||||
val fwdIsFromMe = fwdPublicKey == myPublicKey
|
||||
|
||||
// Parse attachments from this forwarded message
|
||||
@@ -2086,7 +2091,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
forwardedFromName = senderDisplayName,
|
||||
attachments = fwdAttachments,
|
||||
senderPublicKey = fwdPublicKey.ifEmpty { if (fwdIsFromMe) myPublicKey ?: "" else opponentKey ?: "" },
|
||||
recipientPrivateKey = myPrivateKey ?: ""
|
||||
recipientPrivateKey = myPrivateKey ?: "",
|
||||
chachaKeyPlainHex = fwdChachaKeyPlain
|
||||
))
|
||||
}
|
||||
return ParsedReplyResult(
|
||||
@@ -2102,6 +2108,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val replyMessageIdFromJson = replyMessage.optString("message_id", "")
|
||||
val replyTimestamp = replyMessage.optLong("timestamp", 0L)
|
||||
val senderNameFromJson = replyMessage.optString("senderName", "")
|
||||
val chachaKeyPlainFromJson = replyMessage.optString("chacha_key_plain", "")
|
||||
|
||||
// 🔥 Detect forward: explicit flag OR publicKey belongs to a third party
|
||||
// Desktop doesn't send "forwarded" flag, but if publicKey differs from
|
||||
@@ -2193,22 +2200,24 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// Определяем, кто автор цитируемого сообщения
|
||||
val isReplyFromMe = replyPublicKey == myPublicKey
|
||||
|
||||
// <EFBFBD> Используем attachments из JSON если есть, иначе загружаем из БД
|
||||
// Загружаем оригинальное сообщение из БД для attachments и chachaKey
|
||||
val originalMessage = try {
|
||||
messageDao.findMessageByContent(
|
||||
account = account,
|
||||
dialogKey = dialogKey,
|
||||
fromPublicKey = replyPublicKey,
|
||||
timestampFrom = replyTimestamp - 5000,
|
||||
timestampTo = replyTimestamp + 5000
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
|
||||
val originalChachaKey = originalMessage?.chachaKey ?: ""
|
||||
|
||||
val originalAttachments =
|
||||
if (replyAttachmentsFromJson.isNotEmpty()) {
|
||||
// Используем attachments из JSON reply
|
||||
replyAttachmentsFromJson
|
||||
} else {
|
||||
// Fallback: загружаем из БД
|
||||
try {
|
||||
val originalMessage =
|
||||
messageDao.findMessageByContent(
|
||||
account = account,
|
||||
dialogKey = dialogKey,
|
||||
fromPublicKey = replyPublicKey,
|
||||
timestampFrom = replyTimestamp - 5000,
|
||||
timestampTo = replyTimestamp + 5000
|
||||
)
|
||||
if (originalMessage != null &&
|
||||
originalMessage.attachments.isNotEmpty()
|
||||
) {
|
||||
@@ -2266,7 +2275,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
if (isReplyFromMe) myPublicKey ?: ""
|
||||
else opponentKey ?: ""
|
||||
},
|
||||
recipientPrivateKey = myPrivateKey ?: ""
|
||||
recipientPrivateKey = myPrivateKey ?: "",
|
||||
chachaKey = originalChachaKey,
|
||||
chachaKeyPlainHex = chachaKeyPlainFromJson
|
||||
)
|
||||
|
||||
// 🔥 If this is a forwarded message (from third party), return as forwardedMessages list
|
||||
@@ -2427,13 +2438,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
text = msg.text,
|
||||
timestamp = msg.timestamp.time,
|
||||
isOutgoing = msg.isOutgoing,
|
||||
// В группах senderPublicKey содержит автора сообщения, а opponent = ключ группы.
|
||||
// Для desktop parity в reply JSON нужен именно ключ автора.
|
||||
publicKey = resolvedPublicKey,
|
||||
senderName = msg.senderName,
|
||||
attachments =
|
||||
msg.attachments
|
||||
.filter { it.type != AttachmentType.MESSAGES }
|
||||
.filter { it.type != AttachmentType.MESSAGES },
|
||||
chachaKeyPlainHex = msg.chachaKeyPlainHex
|
||||
)
|
||||
}
|
||||
_isForwardMode.value = false
|
||||
@@ -3215,34 +3225,27 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val replyAttachmentId = "reply_${timestamp}"
|
||||
|
||||
fun buildForwardReplyJson(
|
||||
forwardedIdMap: Map<String, MessageAttachment> = emptyMap(),
|
||||
includeLocalUri: Boolean
|
||||
): JSONArray {
|
||||
val replyJsonArray = JSONArray()
|
||||
forwardMessages.forEach { fm ->
|
||||
val attachmentsArray = JSONArray()
|
||||
fm.attachments.forEach { att ->
|
||||
val fwdInfo = forwardedIdMap[att.id]
|
||||
val attId = fwdInfo?.id ?: att.id
|
||||
val attPreview = fwdInfo?.preview ?: att.preview
|
||||
val attTransportTag = fwdInfo?.transportTag ?: att.transportTag
|
||||
val attTransportServer = fwdInfo?.transportServer ?: att.transportServer
|
||||
|
||||
attachmentsArray.put(
|
||||
JSONObject().apply {
|
||||
put("id", attId)
|
||||
put("id", att.id)
|
||||
put("type", att.type.value)
|
||||
put("preview", attPreview)
|
||||
put("preview", att.preview)
|
||||
put("width", att.width)
|
||||
put("height", att.height)
|
||||
put("blob", if (att.type == AttachmentType.MESSAGES) att.blob else "")
|
||||
put("transportTag", attTransportTag)
|
||||
put("transportServer", attTransportServer)
|
||||
put("blob", "")
|
||||
put("transportTag", att.transportTag)
|
||||
put("transportServer", att.transportServer)
|
||||
put(
|
||||
"transport",
|
||||
JSONObject().apply {
|
||||
put("transport_tag", attTransportTag)
|
||||
put("transport_server", attTransportServer)
|
||||
put("transport_tag", att.transportTag)
|
||||
put("transport_server", att.transportServer)
|
||||
}
|
||||
)
|
||||
if (includeLocalUri && att.localUri.isNotEmpty()) {
|
||||
@@ -3260,6 +3263,9 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
put("attachments", attachmentsArray)
|
||||
put("forwarded", true)
|
||||
put("senderName", fm.senderName)
|
||||
if (fm.chachaKeyPlain.isNotEmpty()) {
|
||||
put("chacha_key_plain", fm.chachaKeyPlain)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -3349,63 +3355,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
// 📸 Forward: загружаем IMAGE на CDN и пересобираем MESSAGES blob с новыми ID/tag
|
||||
// Map: originalAttId -> updated attachment metadata.
|
||||
val forwardedAttMap = mutableMapOf<String, MessageAttachment>()
|
||||
var fwdIdx = 0
|
||||
for (fm in forwardMessages) {
|
||||
for (att in fm.attachments) {
|
||||
if (att.type == AttachmentType.IMAGE) {
|
||||
try {
|
||||
val imageBlob = AttachmentFileManager.readAttachment(
|
||||
context = context,
|
||||
attachmentId = att.id,
|
||||
publicKey = fm.senderPublicKey,
|
||||
privateKey = privateKey
|
||||
)
|
||||
if (imageBlob != null) {
|
||||
val encryptedBlob = encryptAttachmentPayload(imageBlob, encryptionContext)
|
||||
val newAttId = "fwd_${timestamp}_${fwdIdx++}"
|
||||
|
||||
var uploadTag = ""
|
||||
if (!isSavedMessages) {
|
||||
uploadTag = TransportManager.uploadFile(newAttId, encryptedBlob)
|
||||
}
|
||||
|
||||
val blurhash = att.preview.substringAfter("::", att.preview)
|
||||
val transportServer =
|
||||
if (uploadTag.isNotEmpty()) {
|
||||
TransportManager.getTransportServer().orEmpty()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
forwardedAttMap[att.id] =
|
||||
att.copy(
|
||||
id = newAttId,
|
||||
preview = blurhash,
|
||||
blob = "",
|
||||
transportTag = uploadTag,
|
||||
transportServer = transportServer
|
||||
)
|
||||
|
||||
// Сохраняем локально с новым ID
|
||||
// publicKey = fm.senderPublicKey чтобы совпадал с JSON для parseReplyFromAttachments
|
||||
AttachmentFileManager.saveAttachment(
|
||||
context = context,
|
||||
blob = imageBlob,
|
||||
attachmentId = newAttId,
|
||||
publicKey = fm.senderPublicKey,
|
||||
privateKey = privateKey
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop/iOS parity: передаём оригинальные transport tags + chacha_key_plain,
|
||||
// без перезагрузки картинок на CDN
|
||||
val replyBlobPlaintext =
|
||||
buildForwardReplyJson(
|
||||
forwardedIdMap = forwardedAttMap,
|
||||
includeLocalUri = false
|
||||
)
|
||||
.toString()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -26,7 +26,9 @@ data class ReplyData(
|
||||
val forwardedFromName: String = "", // Имя оригинального отправителя для атрибуции
|
||||
val attachments: List<MessageAttachment> = emptyList(), // 🖼️ Для превью фото в reply
|
||||
val senderPublicKey: String = "", // Для расшифровки attachments в reply
|
||||
val recipientPrivateKey: String = "" // Для расшифровки attachments в reply
|
||||
val recipientPrivateKey: String = "", // Для расшифровки attachments в reply
|
||||
val chachaKey: String = "", // Ключ оригинального сообщения для расшифровки attachments
|
||||
val chachaKeyPlainHex: String = "" // Hex plainKeyAndNonce оригинального сообщения (для cross-platform forward)
|
||||
)
|
||||
|
||||
/** Legacy message model (for compatibility) */
|
||||
@@ -41,6 +43,7 @@ data class ChatMessage(
|
||||
val forwardedMessages: List<ReplyData> = emptyList(), // Multiple forwarded messages (desktop parity)
|
||||
val attachments: List<MessageAttachment> = emptyList(),
|
||||
val chachaKey: String = "", // Для расшифровки attachments
|
||||
val chachaKeyPlainHex: String = "", // Hex plainKeyAndNonce для forward
|
||||
val senderPublicKey: String = "",
|
||||
val senderName: String = ""
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user