Промежуточный результат для 1.0.4 версии

This commit is contained in:
2026-02-22 08:54:46 +05:00
parent 3aa18fa9ac
commit 5b9b3f83f7
37 changed files with 5643 additions and 928 deletions

View File

@@ -4,7 +4,11 @@ private const val PLACEHOLDER_ACCOUNT_NAME = "Account"
fun resolveAccountDisplayName(publicKey: String, name: String?, username: String?): String {
val normalizedName = name?.trim().orEmpty()
if (normalizedName.isNotEmpty() && !normalizedName.equals(PLACEHOLDER_ACCOUNT_NAME, ignoreCase = true)) {
if (
normalizedName.isNotEmpty() &&
!normalizedName.equals(PLACEHOLDER_ACCOUNT_NAME, ignoreCase = true) &&
!looksLikePublicKeyAlias(normalizedName, publicKey)
) {
return normalizedName
}
@@ -24,5 +28,19 @@ fun resolveAccountDisplayName(publicKey: String, name: String?, username: String
}
}
private fun looksLikePublicKeyAlias(name: String, publicKey: String): Boolean {
if (publicKey.isBlank()) return false
if (name.equals(publicKey, ignoreCase = true)) return true
val compactKeyAlias =
if (publicKey.length > 12) "${publicKey.take(6)}...${publicKey.takeLast(4)}" else publicKey
if (name.equals(compactKeyAlias, ignoreCase = true)) return true
val normalized = name.lowercase()
val prefix = publicKey.take(6).lowercase()
val suffix = publicKey.takeLast(4).lowercase()
return normalized.startsWith(prefix) && normalized.endsWith(suffix) && normalized.contains("...")
}
fun isPlaceholderAccountName(name: String?): Boolean =
name?.trim().orEmpty().equals(PLACEHOLDER_ACCOUNT_NAME, ignoreCase = true)

View File

@@ -0,0 +1,104 @@
package com.rosetta.messenger.data
import android.content.Context
import android.content.SharedPreferences
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* 📝 Менеджер черновиков сообщений (как в Telegram)
* Хранит текст из инпута для каждого диалога.
* При возврате в список чатов показывает "Draft: текст" красным цветом.
*
* Использует SharedPreferences для персистентности между перезапусками.
* Ключ: "draft_{account}_{opponentKey}" -> текст черновика
*/
object DraftManager {
private const val PREFS_NAME = "rosetta_drafts"
private const val KEY_PREFIX = "draft_"
private var prefs: SharedPreferences? = null
// 🔥 Реактивный Map: opponentKey -> draftText
// Обновляется при каждом изменении черновика для мгновенного обновления UI списка чатов
private val _drafts = MutableStateFlow<Map<String, String>>(emptyMap())
val drafts: StateFlow<Map<String, String>> = _drafts.asStateFlow()
private var currentAccount: String = ""
/** Инициализация с контекстом приложения */
fun init(context: Context) {
if (prefs == null) {
prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
}
}
/** Установить текущий аккаунт и загрузить его черновики */
fun setAccount(account: String) {
if (currentAccount == account) return
currentAccount = account
loadDraftsFromPrefs()
}
/** Сохранить черновик для диалога */
fun saveDraft(opponentKey: String, text: String) {
if (currentAccount.isEmpty()) return
val trimmed = text.trim()
val currentDrafts = _drafts.value.toMutableMap()
if (trimmed.isEmpty()) {
// Удаляем черновик если текст пустой
currentDrafts.remove(opponentKey)
prefs?.edit()?.remove(prefKey(opponentKey))?.apply()
} else {
currentDrafts[opponentKey] = trimmed
prefs?.edit()?.putString(prefKey(opponentKey), trimmed)?.apply()
}
_drafts.value = currentDrafts
}
/** Получить черновик для диалога */
fun getDraft(opponentKey: String): String? {
return _drafts.value[opponentKey]
}
/** Очистить черновик для диалога (после отправки сообщения) */
fun clearDraft(opponentKey: String) {
if (currentAccount.isEmpty()) return
val currentDrafts = _drafts.value.toMutableMap()
currentDrafts.remove(opponentKey)
_drafts.value = currentDrafts
prefs?.edit()?.remove(prefKey(opponentKey))?.apply()
}
/** Очистить все черновики (при смене аккаунта / логауте) */
fun clearAll() {
_drafts.value = emptyMap()
}
/** Загрузить черновики текущего аккаунта из SharedPreferences */
private fun loadDraftsFromPrefs() {
val allPrefs = prefs?.all ?: return
val prefix = "${KEY_PREFIX}${currentAccount}_"
val loaded = mutableMapOf<String, String>()
allPrefs.forEach { (key, value) ->
if (key.startsWith(prefix) && value is String && value.isNotEmpty()) {
val opponentKey = key.removePrefix(prefix)
loaded[opponentKey] = value
}
}
_drafts.value = loaded
}
private fun prefKey(opponentKey: String): String {
return "${KEY_PREFIX}${currentAccount}_${opponentKey}"
}
}

View File

@@ -100,6 +100,15 @@ class MessageRepository private constructor(private val context: Context) {
const val SYSTEM_SAFE_TITLE = "Safe"
const val SYSTEM_SAFE_USERNAME = "safe"
const val SYSTEM_UPDATES_PUBLIC_KEY = "0x000000000000000000000000000000000000000001"
const val SYSTEM_UPDATES_TITLE = "Rosetta Updates"
const val SYSTEM_UPDATES_USERNAME = "updates"
/** All system account public keys */
val SYSTEM_ACCOUNT_KEYS = setOf(SYSTEM_SAFE_PUBLIC_KEY, SYSTEM_UPDATES_PUBLIC_KEY)
fun isSystemAccount(publicKey: String): Boolean = publicKey in SYSTEM_ACCOUNT_KEYS
// 🔥 In-memory кэш обработанных messageId для предотвращения дубликатов
// LRU кэш с ограничением 1000 элементов - защита от race conditions
private val processedMessageIds =
@@ -221,6 +230,80 @@ class MessageRepository private constructor(private val context: Context) {
_newMessageEvents.tryEmit(dialogKey)
}
/** Send a system message from "Rosetta Updates" account */
suspend fun addUpdateSystemMessage(messageText: String) {
val account = currentAccount ?: return
val privateKey = currentPrivateKey ?: return
val encryptedPlainMessage =
try {
CryptoManager.encryptWithPassword(messageText, privateKey)
} catch (_: Exception) {
return
}
val messageId = UUID.randomUUID().toString().replace("-", "").take(32)
val timestamp = System.currentTimeMillis()
val dialogKey = getDialogKey(SYSTEM_UPDATES_PUBLIC_KEY)
val inserted =
messageDao.insertMessage(
MessageEntity(
account = account,
fromPublicKey = SYSTEM_UPDATES_PUBLIC_KEY,
toPublicKey = account,
content = "",
timestamp = timestamp,
chachaKey = "",
read = 0,
fromMe = 0,
delivered = DeliveryStatus.DELIVERED.value,
messageId = messageId,
plainMessage = encryptedPlainMessage,
attachments = "[]",
dialogKey = dialogKey
)
)
if (inserted == -1L) return
val existing = dialogDao.getDialog(account, SYSTEM_UPDATES_PUBLIC_KEY)
dialogDao.insertDialog(
DialogEntity(
id = existing?.id ?: 0,
account = account,
opponentKey = SYSTEM_UPDATES_PUBLIC_KEY,
opponentTitle = existing?.opponentTitle?.ifBlank { SYSTEM_UPDATES_TITLE } ?: SYSTEM_UPDATES_TITLE,
opponentUsername =
existing?.opponentUsername?.ifBlank { SYSTEM_UPDATES_USERNAME }
?: SYSTEM_UPDATES_USERNAME,
isOnline = existing?.isOnline ?: 0,
lastSeen = existing?.lastSeen ?: 0,
verified = maxOf(existing?.verified ?: 0, 1),
iHaveSent = 1
)
)
dialogDao.updateDialogFromMessages(account, SYSTEM_UPDATES_PUBLIC_KEY)
_newMessageEvents.tryEmit(dialogKey)
}
/**
* Check if app version changed and send update message from "Rosetta Updates"
* Called after account initialization, like desktop useUpdateMessage hook
*/
suspend fun checkAndSendVersionUpdateMessage() {
val account = currentAccount ?: return
val prefs = context.getSharedPreferences("rosetta_system_${account}", Context.MODE_PRIVATE)
val lastNoticeVersion = prefs.getString("lastNoticeVersion", "") ?: ""
val currentVersion = com.rosetta.messenger.BuildConfig.VERSION_NAME
if (lastNoticeVersion != currentVersion) {
addUpdateSystemMessage(ReleaseNotes.getNotice(currentVersion))
prefs.edit().putString("lastNoticeVersion", currentVersion).apply()
}
}
/** Инициализация с текущим аккаунтом */
fun initialize(publicKey: String, privateKey: String) {
val start = System.currentTimeMillis()

View File

@@ -40,6 +40,7 @@ class PreferencesManager(private val context: Context) {
val AUTO_DOWNLOAD_PHOTOS = booleanPreferencesKey("auto_download_photos")
val AUTO_DOWNLOAD_VIDEOS = booleanPreferencesKey("auto_download_videos")
val AUTO_DOWNLOAD_FILES = booleanPreferencesKey("auto_download_files")
val CAMERA_FLASH_MODE = intPreferencesKey("camera_flash_mode") // 0=off, 1=auto, 2=on
// Privacy
val SHOW_ONLINE_STATUS = booleanPreferencesKey("show_online_status")
@@ -158,6 +159,11 @@ class PreferencesManager(private val context: Context) {
val autoDownloadFiles: Flow<Boolean> =
context.dataStore.data.map { preferences -> preferences[AUTO_DOWNLOAD_FILES] ?: false }
val cameraFlashMode: Flow<Int> =
context.dataStore.data.map { preferences ->
normalizeCameraFlashMode(preferences[CAMERA_FLASH_MODE])
}
suspend fun setMessageTextSize(value: Int) {
context.dataStore.edit { preferences -> preferences[MESSAGE_TEXT_SIZE] = value }
}
@@ -178,6 +184,23 @@ class PreferencesManager(private val context: Context) {
context.dataStore.edit { preferences -> preferences[AUTO_DOWNLOAD_FILES] = value }
}
suspend fun getCameraFlashMode(): Int {
return normalizeCameraFlashMode(context.dataStore.data.first()[CAMERA_FLASH_MODE])
}
suspend fun setCameraFlashMode(value: Int) {
context.dataStore.edit { preferences ->
preferences[CAMERA_FLASH_MODE] = normalizeCameraFlashMode(value)
}
}
private fun normalizeCameraFlashMode(value: Int?): Int {
return when (value) {
0, 1, 2 -> value
else -> 1
}
}
// ═════════════════════════════════════════════════════════════
// 🔐 PRIVACY
// ═════════════════════════════════════════════════════════════

View File

@@ -0,0 +1,31 @@
package com.rosetta.messenger.data
/**
* Release notes for "Rosetta Updates" system messages.
*
* When releasing a new version, update [RELEASE_NOTICE] below.
* The text will be sent once to each user after they update the app.
*/
object ReleaseNotes {
/**
* Current release notice shown to users after update.
* [VERSION_PLACEHOLDER] will be replaced with the actual version from BuildConfig.
*/
const val VERSION_PLACEHOLDER = "{version}"
val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER
- New attachment panel with tabs (photos, files, avatar)
- Message drafts — text is saved when leaving a chat
- Circular reveal animation for theme switching
- Photo albums in media picker
- Camera flash mode is now saved between sessions
- Swipe-back gesture fixes
- UI and performance improvements
""".trimIndent()
fun getNotice(version: String): String =
RELEASE_NOTICE.replace(VERSION_PLACEHOLDER, version)
}