Архитектурный рефакторинг: единый SessionStore/SessionReducer, Hilt DI и декомпозиция ProtocolManager

This commit is contained in:
2026-04-18 18:11:21 +05:00
parent 660ba12c8c
commit cedbd204c2
42 changed files with 1073 additions and 335 deletions

View File

@@ -2,6 +2,7 @@ plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("kotlin-kapt")
id("com.google.dagger.hilt.android")
id("com.google.gms.google-services")
}
@@ -119,6 +120,10 @@ android {
}
}
kapt {
correctErrorTypes = true
}
dependencies {
implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
@@ -182,6 +187,11 @@ dependencies {
implementation("androidx.room:room-ktx:2.6.1")
kapt("androidx.room:room-compiler:2.6.1")
// Hilt DI
implementation("com.google.dagger:hilt-android:2.51.1")
kapt("com.google.dagger:hilt-compiler:2.51.1")
implementation("androidx.hilt:hilt-navigation-compose:1.2.0")
// Biometric authentication
implementation("androidx.biometric:biometric:1.1.0")

View File

@@ -61,6 +61,21 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="rosetta.im" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
<!-- App Icon Aliases: only one enabled at a time -->

View File

@@ -16,14 +16,19 @@ 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
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
/**
* Лёгкая Activity для показа входящего звонка на lock screen.
* Показывается поверх экрана блокировки, без auth/splash.
* При Accept → переходит в MainActivity. При Decline → закрывается.
*/
@AndroidEntryPoint
class IncomingCallActivity : ComponentActivity() {
@Inject lateinit var accountManager: AccountManager
companion object {
private const val TAG = "IncomingCallActivity"
}
@@ -119,7 +124,7 @@ class IncomingCallActivity : ComponentActivity() {
}
val avatarRepository = remember {
val accountKey = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty()
val accountKey = accountManager.getLastLoggedPublicKey().orEmpty()
if (accountKey.isNotBlank()) {
val db = RosettaDatabase.getDatabase(applicationContext)
AvatarRepository(

View File

@@ -7,6 +7,7 @@ import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.OpenableColumns
import android.provider.Settings
import android.view.WindowManager
import android.widget.Toast
@@ -47,20 +48,24 @@ import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.data.isPlaceholderAccountName
import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.di.IdentityGateway
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.di.SessionCoordinator
import com.rosetta.messenger.network.CallActionResult
import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.CallManager
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.MessageAttachment
import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.session.AppSessionCoordinator
import com.rosetta.messenger.session.IdentityStore
import com.rosetta.messenger.session.SessionState
import com.rosetta.messenger.ui.auth.AccountInfo
import com.rosetta.messenger.ui.auth.AuthFlow
@@ -97,15 +102,32 @@ import com.rosetta.messenger.ui.settings.UpdatesScreen
import com.rosetta.messenger.ui.splash.SplashScreen
import com.rosetta.messenger.ui.theme.RosettaAndroidTheme
import java.text.SimpleDateFormat
import java.io.File
import java.util.Date
import java.util.Locale
import java.util.UUID
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : FragmentActivity() {
private lateinit var preferencesManager: PreferencesManager
private lateinit var accountManager: AccountManager
@Inject lateinit var preferencesManager: PreferencesManager
@Inject lateinit var accountManager: AccountManager
@Inject lateinit var messageRepository: MessageRepository
@Inject lateinit var groupRepository: GroupRepository
@Inject lateinit var protocolGateway: ProtocolGateway
@Inject lateinit var sessionCoordinator: SessionCoordinator
@Inject lateinit var identityGateway: IdentityGateway
private data class SharedPayload(
val text: String = "",
val streamUris: List<Uri> = emptyList()
)
private var pendingSharedPayload by mutableStateOf<SharedPayload?>(null)
// Флаг: Activity открыта для ответа на звонок с lock screen — пропускаем auth
// mutableStateOf чтобы Compose реагировал на изменение (избежать race condition)
@@ -151,13 +173,12 @@ class MainActivity : FragmentActivity() {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
handleCallLockScreen(intent)
pendingSharedPayload = extractSharedPayload(intent)
preferencesManager = PreferencesManager(this)
accountManager = AccountManager(this)
RecentSearchesManager.init(this)
// 🔥 Инициализируем ProtocolManager для обработки онлайн статусов
ProtocolManager.initialize(this)
protocolGateway.initialize(this)
CallManager.initialize(this)
com.rosetta.messenger.ui.chats.components.AttachmentDownloadDebugLogger.init(this)
@@ -237,13 +258,13 @@ class MainActivity : FragmentActivity() {
else -> true
}
val isLoggedIn by accountManager.isLoggedIn.collectAsState(initial = null)
val protocolState by ProtocolManager.state.collectAsState()
val protocolState by protocolGateway.state.collectAsState()
var showSplash by remember { mutableStateOf(true) }
var showOnboarding by remember { mutableStateOf(true) }
var hasExistingAccount by remember { mutableStateOf<Boolean?>(null) }
var currentAccount by remember { mutableStateOf(getCachedSessionAccount()) }
val identityState by IdentityStore.state.collectAsState()
val sessionState by AppSessionCoordinator.sessionState.collectAsState()
val identityState by identityGateway.state.collectAsState()
val sessionState by sessionCoordinator.sessionState.collectAsState()
var accountInfoList by remember { mutableStateOf<List<AccountInfo>>(emptyList()) }
var startCreateAccountFlow by remember { mutableStateOf(false) }
var preservedMainNavStack by remember { mutableStateOf<List<Screen>>(emptyList()) }
@@ -251,7 +272,7 @@ class MainActivity : FragmentActivity() {
// Check for existing accounts and build AccountInfo list
LaunchedEffect(Unit) {
AppSessionCoordinator.syncFromCachedAccount(currentAccount)
sessionCoordinator.syncFromCachedAccount(currentAccount)
val accounts = accountManager.getAllAccounts()
hasExistingAccount = accounts.isNotEmpty()
val infos = accounts.map { it.toAccountInfo() }
@@ -302,10 +323,50 @@ class MainActivity : FragmentActivity() {
LaunchedEffect(currentAccount, isLoggedIn) {
val account = currentAccount
when {
account != null -> AppSessionCoordinator.markReady(account, reason = "main_activity_state")
account != null -> sessionCoordinator.markReady(account, reason = "main_activity_state")
isLoggedIn == true ->
AppSessionCoordinator.markAuthInProgress(reason = "main_activity_logged_in_no_account")
isLoggedIn == false -> AppSessionCoordinator.markLoggedOut(reason = "main_activity_logged_out")
sessionCoordinator.markAuthInProgress(reason = "main_activity_logged_in_no_account")
isLoggedIn == false -> sessionCoordinator.markLoggedOut(reason = "main_activity_logged_out")
}
}
LaunchedEffect(pendingSharedPayload, currentAccount?.publicKey, sessionState) {
val payload = pendingSharedPayload ?: return@LaunchedEffect
val account =
currentAccount ?: (sessionState as? SessionState.Ready)?.account ?: return@LaunchedEffect
runCatching {
val attachments =
withContext(Dispatchers.IO) {
payload.streamUris.mapNotNull { sourceUri ->
buildSharedAttachment(sourceUri)
}
}
val messageText = payload.text.trim()
if (messageText.isBlank() && attachments.isEmpty()) {
return@runCatching
}
messageRepository.initialize(account.publicKey, account.privateKey)
messageRepository.sendMessage(
toPublicKey = account.publicKey,
text = messageText,
attachments = attachments
)
}.onSuccess {
pendingSharedPayload = null
val hadFiles = payload.streamUris.isNotEmpty()
Toast.makeText(
this@MainActivity,
if (hadFiles) {
"Shared content saved to Saved Messages"
} else {
"Shared text saved to Saved Messages"
},
Toast.LENGTH_SHORT
).show()
}.onFailure { error ->
android.util.Log.e(TAG, "Failed to import shared payload", error)
}
}
@@ -450,6 +511,7 @@ class MainActivity : FragmentActivity() {
hasExistingAccount = screen == "auth_unlock",
accounts = accountInfoList,
accountManager = accountManager,
sessionCoordinator = sessionCoordinator,
startInCreateMode = startCreateAccountFlow,
onAuthComplete = { account ->
startCreateAccountFlow = false
@@ -481,11 +543,11 @@ class MainActivity : FragmentActivity() {
currentAccount = normalizedAccount
cacheSessionAccount(normalizedAccount)
normalizedAccount?.let {
AppSessionCoordinator.markReady(
sessionCoordinator.markReady(
account = it,
reason = "auth_complete"
)
} ?: AppSessionCoordinator.markAuthInProgress(
} ?: sessionCoordinator.markAuthInProgress(
reason = "auth_complete_no_account"
)
hasExistingAccount = true
@@ -526,11 +588,10 @@ class MainActivity : FragmentActivity() {
// lag
currentAccount = null
clearCachedSessionAccount()
AppSessionCoordinator.markLoggedOut(
sessionCoordinator.markLoggedOut(
reason = "auth_flow_logout"
)
com.rosetta.messenger.network.ProtocolManager
.disconnect()
protocolGateway.disconnect()
scope.launch {
accountManager.logout()
}
@@ -556,6 +617,11 @@ class MainActivity : FragmentActivity() {
} else {
emptyList()
},
accountManager = accountManager,
preferencesManager = preferencesManager,
groupRepository = groupRepository,
protocolGateway = protocolGateway,
identityGateway = identityGateway,
onNavStackChanged = { stack ->
if (activeAccountKey.isNotBlank()) {
preservedMainNavAccountKey = activeAccountKey
@@ -581,11 +647,10 @@ class MainActivity : FragmentActivity() {
// lag
currentAccount = null
clearCachedSessionAccount()
AppSessionCoordinator.markLoggedOut(
sessionCoordinator.markLoggedOut(
reason = "main_logout"
)
com.rosetta.messenger.network.ProtocolManager
.disconnect()
protocolGateway.disconnect()
scope.launch {
accountManager.logout()
}
@@ -609,7 +674,7 @@ class MainActivity : FragmentActivity() {
// 5. Delete account from Room DB
database.accountDao().deleteAccount(publicKey)
// 6. Disconnect protocol
com.rosetta.messenger.network.ProtocolManager.disconnect()
protocolGateway.disconnect()
// 7. Delete account from AccountManager DataStore (removes from accounts list + clears login)
accountManager.deleteAccount(publicKey)
// 8. Refresh accounts list
@@ -619,7 +684,7 @@ class MainActivity : FragmentActivity() {
// 8. Navigate away last
currentAccount = null
clearCachedSessionAccount()
AppSessionCoordinator.markLoggedOut(
sessionCoordinator.markLoggedOut(
reason = "delete_current_account"
)
} catch (e: Exception) {
@@ -649,7 +714,7 @@ class MainActivity : FragmentActivity() {
database.accountDao().deleteAccount(targetPublicKey)
// 6. Disconnect protocol only if deleting currently open account
if (currentAccount?.publicKey == targetPublicKey) {
com.rosetta.messenger.network.ProtocolManager.disconnect()
protocolGateway.disconnect()
}
// 7. Delete account from AccountManager DataStore
accountManager.deleteAccount(targetPublicKey)
@@ -663,7 +728,7 @@ class MainActivity : FragmentActivity() {
preservedMainNavAccountKey = ""
currentAccount = null
clearCachedSessionAccount()
AppSessionCoordinator.markLoggedOut(
sessionCoordinator.markLoggedOut(
reason = "delete_sidebar_current_account"
)
}
@@ -683,10 +748,10 @@ class MainActivity : FragmentActivity() {
// Switch to another account: logout current, then show unlock.
currentAccount = null
clearCachedSessionAccount()
AppSessionCoordinator.markLoggedOut(
sessionCoordinator.markLoggedOut(
reason = "switch_account"
)
com.rosetta.messenger.network.ProtocolManager.disconnect()
protocolGateway.disconnect()
scope.launch {
accountManager.logout()
}
@@ -697,10 +762,10 @@ class MainActivity : FragmentActivity() {
preservedMainNavAccountKey = ""
currentAccount = null
clearCachedSessionAccount()
AppSessionCoordinator.markLoggedOut(
sessionCoordinator.markLoggedOut(
reason = "add_account"
)
com.rosetta.messenger.network.ProtocolManager.disconnect()
protocolGateway.disconnect()
scope.launch {
accountManager.logout()
}
@@ -715,10 +780,10 @@ class MainActivity : FragmentActivity() {
preservedMainNavAccountKey = ""
currentAccount = null
clearCachedSessionAccount()
AppSessionCoordinator.markLoggedOut(
sessionCoordinator.markLoggedOut(
reason = "device_confirm_exit"
)
ProtocolManager.disconnect()
protocolGateway.disconnect()
scope.launch {
accountManager.logout()
}
@@ -734,7 +799,134 @@ class MainActivity : FragmentActivity() {
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
setIntent(intent)
handleCallLockScreen(intent)
pendingSharedPayload = extractSharedPayload(intent)
}
private fun extractSharedPayload(intent: Intent?): SharedPayload? {
if (intent == null) return null
val action = intent.action ?: return null
if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return null
val sharedText =
(
intent.getStringExtra(Intent.EXTRA_TEXT)
?: intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString()
?: intent.getStringExtra(Intent.EXTRA_SUBJECT)
)
?.trim()
.orEmpty()
val sharedUris =
when (action) {
Intent.ACTION_SEND -> {
listOfNotNull(getParcelableUriExtra(intent, Intent.EXTRA_STREAM))
}
Intent.ACTION_SEND_MULTIPLE -> {
getParcelableUriListExtra(intent, Intent.EXTRA_STREAM)
}
else -> emptyList()
}
if (sharedText.isBlank() && sharedUris.isEmpty()) return null
return SharedPayload(text = sharedText, streamUris = sharedUris)
}
private fun getParcelableUriExtra(intent: Intent, key: String): Uri? {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(key, Uri::class.java)
} else {
@Suppress("DEPRECATION")
intent.getParcelableExtra(key) as? Uri
}
}
private fun getParcelableUriListExtra(intent: Intent, key: String): List<Uri> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableArrayListExtra(key, Uri::class.java)?.filterNotNull().orEmpty()
} else {
@Suppress("DEPRECATION")
intent.getParcelableArrayListExtra<Uri>(key)?.filterNotNull().orEmpty()
}
}
private fun buildSharedAttachment(sourceUri: Uri): MessageAttachment? {
val cachedUri = copySharedUriToCache(sourceUri) ?: return null
val mimeType = contentResolver.getType(sourceUri).orEmpty().lowercase(Locale.ROOT)
val fileName = queryDisplayName(sourceUri) ?: cachedUri.lastPathSegment ?: "shared_file"
val fileSize = querySize(sourceUri) ?: runCatching { File(cachedUri.path ?: "").length() }.getOrNull() ?: 0L
val type = if (mimeType.startsWith("image/")) AttachmentType.IMAGE else AttachmentType.FILE
val preview =
if (type == AttachmentType.FILE) {
"${fileSize.coerceAtLeast(0L)}::$fileName"
} else {
""
}
return MessageAttachment(
id = UUID.randomUUID().toString().replace("-", "").take(32),
blob = "",
type = type,
preview = preview,
localUri = cachedUri.toString()
)
}
private fun copySharedUriToCache(sourceUri: Uri): Uri? {
return runCatching {
val sourceName = queryDisplayName(sourceUri).orEmpty()
val extension =
sourceName.substringAfterLast('.', "").trim().takeIf { it.isNotBlank() }
val targetDir = File(cacheDir, "shared_import").apply { mkdirs() }
val targetFileName =
buildString {
append("shared_")
append(System.currentTimeMillis())
append('_')
append((1000..9999).random())
if (extension != null) {
append('.')
append(extension)
}
}
val targetFile = File(targetDir, targetFileName)
contentResolver.openInputStream(sourceUri)?.use { input ->
targetFile.outputStream().use { output ->
input.copyTo(output)
}
} ?: return null
Uri.fromFile(targetFile)
}.getOrNull()
}
private fun queryDisplayName(uri: Uri): String? {
return runCatching {
contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)
?.use { cursor ->
if (cursor.moveToFirst()) {
cursor.getString(0)?.trim()?.ifBlank { null }
} else {
null
}
}
}.getOrNull()
}
private fun querySize(uri: Uri): Long? {
return runCatching {
contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null)
?.use { cursor ->
if (cursor.moveToFirst()) {
val size = cursor.getLong(0)
if (size >= 0L) size else null
} else {
null
}
}
}.getOrNull()
}
private var callIntentResetJob: kotlinx.coroutines.Job? = null
@@ -788,7 +980,7 @@ class MainActivity : FragmentActivity() {
// 🔔 Сбрасываем все уведомления из шторки при открытии приложения
(getSystemService(NOTIFICATION_SERVICE) as android.app.NotificationManager).cancelAll()
// ⚡ На возврате в приложение пробуем мгновенный reconnect без ожидания backoff.
ProtocolManager.reconnectNowIfNeeded("activity_onResume")
protocolGateway.reconnectNowIfNeeded("activity_onResume")
}
override fun onPause() {
@@ -822,9 +1014,9 @@ class MainActivity : FragmentActivity() {
// Сохраняем токен локально
saveFcmToken(token)
addFcmLog("💾 Токен сохранен локально")
if (ProtocolManager.isAuthenticated()) {
if (protocolGateway.isAuthenticated()) {
runCatching {
ProtocolManager.subscribePushTokenIfAvailable(
protocolGateway.subscribePushTokenIfAvailable(
forceToken = token
)
}
@@ -926,6 +1118,11 @@ fun MainScreen(
account: DecryptedAccount? = null,
initialNavStack: List<Screen> = emptyList(),
onNavStackChanged: (List<Screen>) -> Unit = {},
accountManager: AccountManager,
preferencesManager: PreferencesManager,
groupRepository: GroupRepository,
protocolGateway: ProtocolGateway,
identityGateway: IdentityGateway,
isDarkTheme: Boolean = true,
themeMode: String = "dark",
onToggleTheme: () -> Unit = {},
@@ -955,7 +1152,7 @@ fun MainScreen(
// Following desktop version pattern: username is stored locally and loaded on app start
var accountUsername by remember { mutableStateOf("") }
var accountVerified by remember(accountPublicKey) { mutableIntStateOf(0) }
val identitySnapshot by IdentityStore.state.collectAsState()
val identitySnapshot by identityGateway.state.collectAsState()
var reloadTrigger by remember { mutableIntStateOf(0) }
// Load username AND name from AccountManager (persisted in DataStore)
@@ -1031,11 +1228,11 @@ fun MainScreen(
return@resolve null
}
ProtocolManager.getCachedUserByUsername(usernameQuery)?.let { cached ->
protocolGateway.getCachedUserByUsername(usernameQuery)?.let { cached ->
if (cached.publicKey.isNotBlank()) return@resolve cached
}
val results = ProtocolManager.searchUsers(usernameQuery)
val results = protocolGateway.searchUsers(usernameQuery)
results.firstOrNull {
it.publicKey.isNotBlank() &&
it.username.trim().trimStart('@')
@@ -1143,7 +1340,7 @@ fun MainScreen(
val normalizedPublicKey = accountPublicKey.trim()
val normalizedPrivateKey = accountPrivateKey.trim()
if (normalizedPublicKey.isBlank() || normalizedPrivateKey.isBlank()) return@LaunchedEffect
ProtocolManager.initializeAccount(normalizedPublicKey, normalizedPrivateKey)
protocolGateway.initializeAccount(normalizedPublicKey, normalizedPrivateKey)
}
LaunchedEffect(callUiState.isVisible) {
@@ -1197,13 +1394,12 @@ fun MainScreen(
}
suspend fun refreshAccountIdentityState(accountKey: String) {
val accountManager = AccountManager(context)
val encryptedAccount = accountManager.getAccount(accountKey)
val identityOwn =
identitySnapshot.profile?.takeIf {
it.publicKey.equals(accountKey, ignoreCase = true)
}
val cachedOwn = ProtocolManager.getCachedUserInfo(accountKey)
val cachedOwn = protocolGateway.getCachedUserInfo(accountKey)
val persistedName = encryptedAccount?.name?.trim().orEmpty()
val persistedUsername = encryptedAccount?.username?.trim().orEmpty()
@@ -1237,7 +1433,7 @@ fun MainScreen(
accountUsername = finalUsername
accountVerified = identityOwn?.verified ?: cachedOwn?.verified ?: 0
accountName = resolveAccountDisplayName(accountKey, preferredName, finalUsername)
IdentityStore.updateOwnProfile(
identityGateway.updateOwnProfile(
publicKey = accountKey,
displayName = accountName,
username = accountUsername,
@@ -1271,10 +1467,10 @@ fun MainScreen(
}
// Состояние протокола для передачи в SearchScreen
val protocolState by ProtocolManager.state.collectAsState()
val protocolState by protocolGateway.state.collectAsState()
// Реактивно обновляем username/name когда сервер отвечает на fetchOwnProfile()
val ownProfileUpdated by ProtocolManager.ownProfileUpdated.collectAsState()
val ownProfileUpdated by protocolGateway.ownProfileUpdated.collectAsState()
LaunchedEffect(ownProfileUpdated, accountPublicKey) {
if (ownProfileUpdated > 0L && accountPublicKey.isNotBlank()) {
refreshAccountIdentityState(accountPublicKey)
@@ -1440,7 +1636,7 @@ fun MainScreen(
androidx.lifecycle.viewmodel.compose.viewModel()
// Appearance: background blur color preference
val prefsManager = remember { com.rosetta.messenger.data.PreferencesManager(context) }
val prefsManager = preferencesManager
val backgroundBlurColorId by
prefsManager
.backgroundBlurColorIdForAccount(accountPublicKey)
@@ -1669,7 +1865,6 @@ fun MainScreen(
// Verify password by trying to decrypt the private key
try {
val publicKey = account?.publicKey ?: return@BackupScreen null
val accountManager = AccountManager(context)
val encryptedAccount = accountManager.getAccount(publicKey)
if (encryptedAccount != null) {
@@ -2075,7 +2270,6 @@ fun MainScreen(
val biometricPrefs = remember {
com.rosetta.messenger.biometric.BiometricPreferences(context)
}
val biometricAccountManager = remember { AccountManager(context) }
val activity = context as? FragmentActivity
val isFingerprintSupported = remember {
biometricManager.isFingerprintHardwareAvailable()
@@ -2099,7 +2293,7 @@ fun MainScreen(
// Verify password against the real account before saving
mainScreenScope.launch {
val account = biometricAccountManager.getAccount(accountPublicKey)
val account = accountManager.getAccount(accountPublicKey)
if (account == null) {
onError("Account not found")
return@launch
@@ -2149,7 +2343,7 @@ fun MainScreen(
when (result.type) {
com.rosetta.messenger.ui.qr.QrResultType.PROFILE -> {
mainScreenScope.launch {
val users = com.rosetta.messenger.network.ProtocolManager.searchUsers(result.payload, 5000)
val users = protocolGateway.searchUsers(result.payload, 5000)
val user = users.firstOrNull()
if (user != null) {
pushScreen(Screen.OtherProfile(user))
@@ -2164,8 +2358,8 @@ fun MainScreen(
}
com.rosetta.messenger.ui.qr.QrResultType.GROUP -> {
mainScreenScope.launch {
val groupRepo = com.rosetta.messenger.data.GroupRepository.getInstance(context)
val joinResult = groupRepo.joinGroup(accountPublicKey, accountPrivateKey, result.payload)
val joinResult =
groupRepository.joinGroup(accountPublicKey, accountPrivateKey, result.payload)
if (joinResult.success && !joinResult.dialogPublicKey.isNullOrBlank()) {
val groupUser = com.rosetta.messenger.network.SearchUser(
publicKey = joinResult.dialogPublicKey,

View File

@@ -2,16 +2,28 @@ package com.rosetta.messenger
import android.app.Application
import com.airbnb.lottie.L
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DraftManager
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.network.CallManager
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.TransportManager
import com.rosetta.messenger.update.UpdateManager
import com.rosetta.messenger.utils.CrashReportManager
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
/**
* Application класс для инициализации глобальных компонентов приложения
*/
@HiltAndroidApp
class RosettaApplication : Application() {
@Inject lateinit var messageRepository: MessageRepository
@Inject lateinit var groupRepository: GroupRepository
@Inject lateinit var accountManager: AccountManager
companion object {
private const val TAG = "RosettaApplication"
}
@@ -34,6 +46,17 @@ class RosettaApplication : Application() {
// Инициализируем менеджер обновлений (SDU)
UpdateManager.init(this)
// DI bootstrap for protocol internals (removes singleton-factory lookups in ProtocolManager).
ProtocolManager.bindDependencies(
messageRepository = messageRepository,
groupRepository = groupRepository,
accountManager = accountManager
)
CallManager.bindDependencies(
messageRepository = messageRepository,
accountManager = accountManager
)
}
/**

View File

@@ -15,16 +15,22 @@ import com.rosetta.messenger.network.PacketGroupInviteInfo
import com.rosetta.messenger.network.PacketGroupJoin
import com.rosetta.messenger.network.PacketGroupLeave
import com.rosetta.messenger.network.ProtocolManager
import dagger.hilt.android.qualifiers.ApplicationContext
import java.security.SecureRandom
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.resume
class GroupRepository private constructor(context: Context) {
@Singleton
class GroupRepository @Inject constructor(
@ApplicationContext context: Context,
private val messageRepository: MessageRepository
) {
private val appContext = context.applicationContext
private val db = RosettaDatabase.getDatabase(context.applicationContext)
private val groupDao = db.groupDao()
private val messageDao = db.messageDao()
@@ -38,15 +44,6 @@ class GroupRepository private constructor(context: Context) {
private const val GROUP_INVITE_PASSWORD = "rosetta_group"
private const val GROUP_WAIT_TIMEOUT_MS = 15_000L
private const val GROUP_CREATED_MARKER = "\$a=Group created"
@Volatile
private var INSTANCE: GroupRepository? = null
fun getInstance(context: Context): GroupRepository {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: GroupRepository(context).also { INSTANCE = it }
}
}
}
data class ParsedGroupInvite(
@@ -479,9 +476,8 @@ class GroupRepository private constructor(context: Context) {
dialogPublicKey: String
) {
try {
val messages = MessageRepository.getInstance(appContext)
messages.initialize(accountPublicKey, accountPrivateKey)
messages.sendMessage(
messageRepository.initialize(accountPublicKey, accountPrivateKey)
messageRepository.sendMessage(
toPublicKey = dialogPublicKey,
text = GROUP_CREATED_MARKER
)

View File

@@ -8,8 +8,11 @@ import com.rosetta.messenger.network.*
import com.rosetta.messenger.utils.AttachmentFileManager
import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.utils.MessageLogger
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.Locale
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import org.json.JSONArray
@@ -43,7 +46,10 @@ data class Dialog(
)
/** Repository для работы с сообщениями Оптимизированная версия с кэшированием и Optimistic UI */
class MessageRepository private constructor(private val context: Context) {
@Singleton
class MessageRepository @Inject constructor(
@ApplicationContext private val context: Context
) {
private val database = RosettaDatabase.getDatabase(context)
private val messageDao = database.messageDao()
@@ -96,8 +102,6 @@ class MessageRepository private constructor(private val context: Context) {
private var currentPrivateKey: String? = null
companion object {
@Volatile private var INSTANCE: MessageRepository? = null
/** Desktop parity: MESSAGE_MAX_TIME_TO_DELEVERED_S = 80 (seconds) */
private const val MESSAGE_MAX_TIME_TO_DELIVERED_MS = 80_000L
@@ -135,16 +139,6 @@ class MessageRepository private constructor(private val context: Context) {
/** Очистка кэша (вызывается при logout) */
fun clearProcessedCache() = processedMessageIds.clear()
fun getInstance(context: Context): MessageRepository {
return INSTANCE
?: synchronized(this) {
INSTANCE
?: MessageRepository(context.applicationContext).also {
INSTANCE = it
}
}
}
/**
* Генерация уникального messageId 🔥 ИСПРАВЛЕНО: Используем UUID вместо детерминированного
* хэша, чтобы избежать коллизий при одинаковом timestamp (при спаме сообщениями)
@@ -1785,6 +1779,7 @@ class MessageRepository private constructor(private val context: Context) {
put("preview", attachment.preview)
put("width", attachment.width)
put("height", attachment.height)
put("localUri", attachment.localUri)
put("transportTag", attachment.transportTag)
put("transportServer", attachment.transportServer)
}
@@ -2029,6 +2024,7 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height)
jsonObj.put("localUri", attachment.localUri)
jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer)
} else {
@@ -2039,6 +2035,7 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height)
jsonObj.put("localUri", attachment.localUri)
jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer)
}
@@ -2050,6 +2047,7 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height)
jsonObj.put("localUri", attachment.localUri)
jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer)
}
@@ -2061,6 +2059,7 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height)
jsonObj.put("localUri", attachment.localUri)
jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer)
}

View File

@@ -0,0 +1,285 @@
package com.rosetta.messenger.di
import android.content.Context
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.network.DeviceEntry
import com.rosetta.messenger.network.Packet
import com.rosetta.messenger.network.PacketMessage
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.session.SessionAction
import com.rosetta.messenger.session.IdentityStateSnapshot
import com.rosetta.messenger.session.IdentityStore
import com.rosetta.messenger.session.SessionState
import com.rosetta.messenger.session.SessionStore
import dagger.Binds
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
interface ProtocolGateway {
val state: StateFlow<ProtocolState>
val syncInProgress: StateFlow<Boolean>
val pendingDeviceVerification: StateFlow<DeviceEntry?>
val typingUsers: StateFlow<Set<String>>
val typingUsersByDialogSnapshot: StateFlow<Map<String, Set<String>>>
val debugLogs: StateFlow<List<String>>
val ownProfileUpdated: StateFlow<Long>
fun initialize(context: Context)
fun initializeAccount(publicKey: String, privateKey: String)
fun connect()
fun authenticate(publicKey: String, privateHash: String)
fun reconnectNowIfNeeded(reason: String)
fun disconnect()
fun isAuthenticated(): Boolean
fun getPrivateHash(): String?
fun subscribePushTokenIfAvailable(forceToken: String? = null)
fun addLog(message: String)
fun enableUILogs(enabled: Boolean)
fun clearLogs()
fun resolveOutgoingRetry(messageId: String)
fun getCachedUserByUsername(username: String): SearchUser?
fun getCachedUserName(publicKey: String): String?
fun getCachedUserInfo(publicKey: String): SearchUser?
fun acceptDevice(deviceId: String)
fun declineDevice(deviceId: String)
fun send(packet: Packet)
fun sendPacket(packet: Packet)
fun sendMessageWithRetry(packet: PacketMessage)
fun waitPacket(packetId: Int, callback: (Packet) -> Unit)
fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit)
fun packetFlow(packetId: Int): SharedFlow<Packet>
fun notifyOwnProfileUpdated()
fun restoreAuthFromStoredCredentials(
preferredPublicKey: String? = null,
reason: String = "background_restore"
): Boolean
suspend fun resolveUserName(publicKey: String, timeoutMs: Long = 3000): String?
suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long = 3000): SearchUser?
suspend fun searchUsers(query: String, timeoutMs: Long = 3000): List<SearchUser>
}
interface SessionCoordinator {
val sessionState: StateFlow<SessionState>
fun dispatch(action: SessionAction)
fun markLoggedOut(reason: String = "") =
dispatch(SessionAction.LoggedOut(reason = reason))
fun markAuthInProgress(publicKey: String? = null, reason: String = "") =
dispatch(
SessionAction.AuthInProgress(
publicKey = publicKey,
reason = reason
)
)
fun markReady(account: DecryptedAccount, reason: String = "") =
dispatch(SessionAction.Ready(account = account, reason = reason))
fun syncFromCachedAccount(account: DecryptedAccount?) =
dispatch(SessionAction.SyncFromCachedAccount(account = account))
suspend fun bootstrapAuthenticatedSession(account: DecryptedAccount, reason: String)
}
interface IdentityGateway {
val state: StateFlow<IdentityStateSnapshot>
fun updateOwnProfile(
publicKey: String,
displayName: String? = null,
username: String? = null,
verified: Int? = null,
resolved: Boolean = true,
reason: String = ""
)
}
@Singleton
class ProtocolGatewayImpl @Inject constructor(
private val messageRepository: MessageRepository,
private val groupRepository: GroupRepository,
private val accountManager: AccountManager
) : ProtocolGateway {
init {
ProtocolManager.bindDependencies(
messageRepository = messageRepository,
groupRepository = groupRepository,
accountManager = accountManager
)
}
override val state: StateFlow<ProtocolState> = ProtocolManager.state
override val syncInProgress: StateFlow<Boolean> = ProtocolManager.syncInProgress
override val pendingDeviceVerification: StateFlow<DeviceEntry?> = ProtocolManager.pendingDeviceVerification
override val typingUsers: StateFlow<Set<String>> = ProtocolManager.typingUsers
override val typingUsersByDialogSnapshot: StateFlow<Map<String, Set<String>>> =
ProtocolManager.typingUsersByDialogSnapshot
override val debugLogs: StateFlow<List<String>> = ProtocolManager.debugLogs
override val ownProfileUpdated: StateFlow<Long> = ProtocolManager.ownProfileUpdated
override fun initialize(context: Context) {
ProtocolManager.bindDependencies(
messageRepository = messageRepository,
groupRepository = groupRepository,
accountManager = accountManager
)
ProtocolManager.initialize(context)
}
override fun initializeAccount(publicKey: String, privateKey: String) =
ProtocolManager.initializeAccount(publicKey, privateKey)
override fun connect() = ProtocolManager.connect()
override fun authenticate(publicKey: String, privateHash: String) =
ProtocolManager.authenticate(publicKey, privateHash)
override fun reconnectNowIfNeeded(reason: String) = ProtocolManager.reconnectNowIfNeeded(reason)
override fun disconnect() = ProtocolManager.disconnect()
override fun isAuthenticated(): Boolean = ProtocolManager.isAuthenticated()
override fun getPrivateHash(): String? =
runCatching { ProtocolManager.getProtocol().getPrivateHash() }.getOrNull()
override fun subscribePushTokenIfAvailable(forceToken: String?) =
ProtocolManager.subscribePushTokenIfAvailable(forceToken)
override fun addLog(message: String) = ProtocolManager.addLog(message)
override fun enableUILogs(enabled: Boolean) = ProtocolManager.enableUILogs(enabled)
override fun clearLogs() = ProtocolManager.clearLogs()
override fun resolveOutgoingRetry(messageId: String) = ProtocolManager.resolveOutgoingRetry(messageId)
override fun getCachedUserByUsername(username: String): SearchUser? =
ProtocolManager.getCachedUserByUsername(username)
override fun getCachedUserName(publicKey: String): String? =
ProtocolManager.getCachedUserName(publicKey)
override fun getCachedUserInfo(publicKey: String): SearchUser? =
ProtocolManager.getCachedUserInfo(publicKey)
override fun acceptDevice(deviceId: String) = ProtocolManager.acceptDevice(deviceId)
override fun declineDevice(deviceId: String) = ProtocolManager.declineDevice(deviceId)
override fun send(packet: Packet) = ProtocolManager.send(packet)
override fun sendPacket(packet: Packet) = ProtocolManager.sendPacket(packet)
override fun sendMessageWithRetry(packet: PacketMessage) = ProtocolManager.sendMessageWithRetry(packet)
override fun waitPacket(packetId: Int, callback: (Packet) -> Unit) =
ProtocolManager.waitPacket(packetId, callback)
override fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) =
ProtocolManager.unwaitPacket(packetId, callback)
override fun packetFlow(packetId: Int): SharedFlow<Packet> = ProtocolManager.packetFlow(packetId)
override fun notifyOwnProfileUpdated() = ProtocolManager.notifyOwnProfileUpdated()
override fun restoreAuthFromStoredCredentials(
preferredPublicKey: String?,
reason: String
): Boolean = ProtocolManager.restoreAuthFromStoredCredentials(preferredPublicKey, reason)
override suspend fun resolveUserName(publicKey: String, timeoutMs: Long): String? =
ProtocolManager.resolveUserName(publicKey, timeoutMs)
override suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long): SearchUser? =
ProtocolManager.resolveUserInfo(publicKey, timeoutMs)
override suspend fun searchUsers(query: String, timeoutMs: Long): List<SearchUser> =
ProtocolManager.searchUsers(query, timeoutMs)
}
@Singleton
class SessionCoordinatorImpl @Inject constructor(
private val accountManager: AccountManager,
private val protocolGateway: ProtocolGateway
) : SessionCoordinator {
override val sessionState: StateFlow<SessionState> = SessionStore.state
override fun dispatch(action: SessionAction) {
SessionStore.dispatch(action)
}
override suspend fun bootstrapAuthenticatedSession(account: DecryptedAccount, reason: String) {
dispatch(SessionAction.AuthInProgress(publicKey = account.publicKey, reason = reason))
protocolGateway.initializeAccount(account.publicKey, account.privateKey)
protocolGateway.connect()
protocolGateway.authenticate(account.publicKey, account.privateKeyHash)
protocolGateway.reconnectNowIfNeeded("session_bootstrap_$reason")
accountManager.setCurrentAccount(account.publicKey)
dispatch(SessionAction.Ready(account = account, reason = reason))
}
}
@Singleton
class IdentityGatewayImpl @Inject constructor() : IdentityGateway {
override val state: StateFlow<IdentityStateSnapshot> = IdentityStore.state
override fun updateOwnProfile(
publicKey: String,
displayName: String?,
username: String?,
verified: Int?,
resolved: Boolean,
reason: String
) {
IdentityStore.updateOwnProfile(
publicKey = publicKey,
displayName = displayName,
username = username,
verified = verified,
resolved = resolved,
reason = reason
)
}
}
@Module
@InstallIn(SingletonComponent::class)
object AppDataModule {
@Provides
@Singleton
fun provideAccountManager(@ApplicationContext context: Context): AccountManager =
AccountManager(context)
@Provides
@Singleton
fun providePreferencesManager(@ApplicationContext context: Context): PreferencesManager =
PreferencesManager(context)
}
@Module
@InstallIn(SingletonComponent::class)
abstract class AppGatewayModule {
@Binds
@Singleton
abstract fun bindProtocolGateway(impl: ProtocolGatewayImpl): ProtocolGateway
@Binds
@Singleton
abstract fun bindSessionCoordinator(impl: SessionCoordinatorImpl): SessionCoordinator
@Binds
@Singleton
abstract fun bindIdentityGateway(impl: IdentityGatewayImpl): IdentityGateway
}

View File

@@ -0,0 +1,28 @@
package com.rosetta.messenger.di
import android.content.Context
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.PreferencesManager
import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.android.EntryPointAccessors
import dagger.hilt.components.SingletonComponent
@EntryPoint
@InstallIn(SingletonComponent::class)
interface UiEntryPoint {
fun protocolGateway(): ProtocolGateway
fun sessionCoordinator(): SessionCoordinator
fun identityGateway(): IdentityGateway
fun accountManager(): AccountManager
fun preferencesManager(): PreferencesManager
fun messageRepository(): MessageRepository
fun groupRepository(): GroupRepository
}
object UiDependencyAccess {
fun get(context: Context): UiEntryPoint =
EntryPointAccessors.fromApplication(context.applicationContext, UiEntryPoint::class.java)
}

View File

@@ -21,8 +21,11 @@ import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.IconCompat
import com.rosetta.messenger.MainActivity
import com.rosetta.messenger.R
import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.utils.AvatarFileManager
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
@@ -34,8 +37,11 @@ import kotlinx.coroutines.runBlocking
* Keeps call alive while app goes to background.
* Uses Notification.CallStyle on Android 12+ and NotificationCompat fallback on older APIs.
*/
@AndroidEntryPoint
class CallForegroundService : Service() {
@Inject lateinit var preferencesManager: PreferencesManager
private data class Snapshot(
val phase: CallPhase,
val displayName: String,
@@ -469,8 +475,7 @@ class CallForegroundService : Service() {
// Проверяем настройку
val avatarEnabled = runCatching {
runBlocking(Dispatchers.IO) {
com.rosetta.messenger.data.PreferencesManager(applicationContext)
.notificationAvatarEnabled.first()
preferencesManager.notificationAvatarEnabled.first()
}
}.getOrDefault(true)
if (!avatarEnabled) return null

View File

@@ -4,6 +4,7 @@ import android.content.Context
import android.media.AudioManager
import android.util.Log
import com.rosetta.messenger.BuildConfig
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.MessageRepository
import java.security.MessageDigest
import java.security.SecureRandom
@@ -111,6 +112,8 @@ object CallManager {
@Volatile
private var initialized = false
private var appContext: Context? = null
private var messageRepository: MessageRepository? = null
private var accountManager: AccountManager? = null
private var ownPublicKey: String = ""
private var role: CallRole? = null
@@ -213,6 +216,14 @@ object CallManager {
ProtocolManager.requestIceServers()
}
fun bindDependencies(
messageRepository: MessageRepository,
accountManager: AccountManager
) {
this.messageRepository = messageRepository
this.accountManager = accountManager
}
fun bindAccount(publicKey: String) {
ownPublicKey = publicKey.trim()
}
@@ -318,7 +329,7 @@ object CallManager {
// Если ownPublicKey пустой (push разбудил но аккаунт не привязан) — попробуем привязать
if (ownPublicKey.isBlank()) {
val lastPk = appContext?.let { com.rosetta.messenger.data.AccountManager(it).getLastLoggedPublicKey() }.orEmpty()
val lastPk = accountManager?.getLastLoggedPublicKey().orEmpty()
if (lastPk.isNotBlank()) {
bindAccount(lastPk)
breadcrumb("acceptIncomingCall: auto-bind account pk=${lastPk.take(8)}")
@@ -1042,7 +1053,6 @@ object CallManager {
private fun emitCallAttachmentIfNeeded(snapshot: CallUiState) {
val peerPublicKey = snapshot.peerPublicKey.trim()
val context = appContext ?: return
if (peerPublicKey.isBlank()) return
val durationSec = snapshot.durationSec.coerceAtLeast(0)
@@ -1061,9 +1071,14 @@ object CallManager {
scope.launch {
runCatching {
val repository = messageRepository
if (repository == null) {
breadcrumb("CALL ATTACHMENT: MessageRepository not bound")
return@runCatching
}
if (capturedRole == CallRole.CALLER) {
// CALLER: send call attachment as a message (peer will receive it)
MessageRepository.getInstance(context).sendMessage(
repository.sendMessage(
toPublicKey = peerPublicKey,
text = "",
attachments = listOf(callAttachment)

View File

@@ -65,6 +65,7 @@ object ProtocolManager {
@Volatile private var protocol: Protocol? = null
private var messageRepository: MessageRepository? = null
private var groupRepository: GroupRepository? = null
private var accountManager: AccountManager? = null
private var appContext: Context? = null
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val protocolInstanceLock = Any()
@@ -100,10 +101,22 @@ object ProtocolManager {
waitForNetworkAndReconnect = ::waitForNetworkAndReconnect,
stopWaitingForNetwork = { reason -> stopWaitingForNetwork(reason) },
getProtocol = ::getProtocol,
appContextProvider = { appContext },
persistHandshakeCredentials = { publicKey, privateHash ->
accountManager?.setLastLoggedPublicKey(publicKey)
accountManager?.setLastLoggedPrivateKeyHash(privateHash)
},
buildHandshakeDevice = ::buildHandshakeDevice
)
private val ownProfileSyncService = OwnProfileSyncService(::isPlaceholderAccountName)
private val ownProfileSyncService =
OwnProfileSyncService(
isPlaceholderAccountName = ::isPlaceholderAccountName,
updateAccountName = { publicKey, name ->
accountManager?.updateAccountName(publicKey, name)
},
updateAccountUsername = { publicKey, username ->
accountManager?.updateAccountUsername(publicKey, username)
}
)
private val packetRouter by lazy {
PacketRouter(
sendSearchPacket = { packet -> send(packet) },
@@ -239,10 +252,12 @@ object ProtocolManager {
)
setSyncInProgress(false)
clearTypingState()
if (messageRepository == null) {
appContext?.let { messageRepository = MessageRepository.getInstance(it) }
val repository = messageRepository
if (repository == null) {
addLog("❌ initializeAccount aborted: MessageRepository is not bound")
return
}
messageRepository?.initialize(normalizedPublicKey, normalizedPrivateKey)
repository.initialize(normalizedPublicKey, normalizedPrivateKey)
val sameAccount =
bootstrapContext.accountPublicKey.equals(normalizedPublicKey, ignoreCase = true)
@@ -602,13 +617,38 @@ object ProtocolManager {
}
}
/**
* Inject process-wide dependencies from DI container.
*/
fun bindDependencies(
messageRepository: MessageRepository,
groupRepository: GroupRepository,
accountManager: AccountManager
) {
this.messageRepository = messageRepository
this.groupRepository = groupRepository
this.accountManager = accountManager
}
/**
* Backward-compatible alias kept while migrating call sites.
*/
fun bindRepositories(
messageRepository: MessageRepository,
groupRepository: GroupRepository
) {
this.messageRepository = messageRepository
this.groupRepository = groupRepository
}
/**
* Инициализация с контекстом для доступа к MessageRepository
*/
fun initialize(context: Context) {
appContext = context.applicationContext
messageRepository = MessageRepository.getInstance(context)
groupRepository = GroupRepository.getInstance(context)
if (messageRepository == null || groupRepository == null || accountManager == null) {
addLog("⚠️ initialize called before dependencies were bound via DI")
}
ensureConnectionSupervisor()
if (!packetHandlersRegistered) {
setupPacketHandlers()
@@ -854,7 +894,6 @@ object ProtocolManager {
val ownProfileResolved =
ownProfileSyncService.applyOwnProfileFromSearch(
appContext = appContext,
ownPublicKey = ownPublicKey,
user = user
)
@@ -1537,8 +1576,11 @@ object ProtocolManager {
preferredPublicKey: String? = null,
reason: String = "background_restore"
): Boolean {
val context = appContext ?: return false
val accountManager = AccountManager(context)
val accountManager = accountManager
if (accountManager == null) {
addLog("⚠️ restoreAuthFromStoredCredentials skipped: AccountManager is not bound")
return false
}
val publicKey =
preferredPublicKey?.trim().orEmpty().ifBlank {
accountManager.getLastLoggedPublicKey().orEmpty()

View File

@@ -1,7 +1,5 @@
package com.rosetta.messenger.network.connection
import android.content.Context
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.network.HandshakeDevice
import com.rosetta.messenger.network.Protocol
@@ -10,7 +8,7 @@ class ConnectionOrchestrator(
private val waitForNetworkAndReconnect: (String) -> Unit,
private val stopWaitingForNetwork: (String) -> Unit,
private val getProtocol: () -> Protocol,
private val appContextProvider: () -> Context?,
private val persistHandshakeCredentials: (publicKey: String, privateHash: String) -> Unit,
private val buildHandshakeDevice: () -> HandshakeDevice
) {
fun handleConnect(reason: String) {
@@ -32,13 +30,7 @@ class ConnectionOrchestrator(
}
fun handleAuthenticate(publicKey: String, privateHash: String) {
appContextProvider()?.let { context ->
runCatching {
val accountManager = AccountManager(context)
accountManager.setLastLoggedPublicKey(publicKey)
accountManager.setLastLoggedPrivateKeyHash(privateHash)
}
}
runCatching { persistHandshakeCredentials(publicKey, privateHash) }
val device = buildHandshakeDevice()
getProtocol().startHandshake(publicKey, privateHash, device)
}

View File

@@ -1,7 +1,5 @@
package com.rosetta.messenger.network.connection
import android.content.Context
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.network.PacketSearch
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.session.IdentityStore
@@ -10,7 +8,9 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class OwnProfileSyncService(
private val isPlaceholderAccountName: (String?) -> Boolean
private val isPlaceholderAccountName: (String?) -> Boolean,
private val updateAccountName: suspend (publicKey: String, name: String) -> Unit,
private val updateAccountUsername: suspend (publicKey: String, username: String) -> Unit
) {
private val _ownProfileUpdated = MutableStateFlow(0L)
val ownProfileUpdated: StateFlow<Long> = _ownProfileUpdated.asStateFlow()
@@ -20,20 +20,17 @@ class OwnProfileSyncService(
}
suspend fun applyOwnProfileFromSearch(
appContext: Context?,
ownPublicKey: String,
user: SearchUser
): Boolean {
if (ownPublicKey.isBlank()) return false
if (!user.publicKey.equals(ownPublicKey, ignoreCase = true)) return false
val context = appContext ?: return true
val accountManager = AccountManager(context)
if (user.title.isNotBlank() && !isPlaceholderAccountName(user.title)) {
accountManager.updateAccountName(ownPublicKey, user.title)
updateAccountName(ownPublicKey, user.title)
}
if (user.username.isNotBlank()) {
accountManager.updateAccountUsername(ownPublicKey, user.username)
updateAccountUsername(ownPublicKey, user.username)
}
IdentityStore.updateOwnProfile(user, reason = "protocol_search_own_profile")
_ownProfileUpdated.value = System.currentTimeMillis()

View File

@@ -19,12 +19,14 @@ import com.rosetta.messenger.R
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.network.CallForegroundService
import com.rosetta.messenger.network.CallManager
import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.CallUiState
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.utils.AvatarFileManager
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -40,8 +42,13 @@ import java.util.Locale
* - Получение push-уведомлений о новых сообщениях
* - Отображение уведомлений
*/
@AndroidEntryPoint
class RosettaFirebaseMessagingService : FirebaseMessagingService() {
@Inject lateinit var accountManager: AccountManager
@Inject lateinit var preferencesManager: PreferencesManager
@Inject lateinit var protocolGateway: ProtocolGateway
private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
companion object {
@@ -121,8 +128,8 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
// Best-effort: если соединение уже авторизовано — сразу обновляем подписку на push.
// Используем единую точку отправки в ProtocolManager (с дедупликацией).
if (ProtocolManager.isAuthenticated()) {
runCatching { ProtocolManager.subscribePushTokenIfAvailable(forceToken = token) }
if (protocolGateway.isAuthenticated()) {
runCatching { protocolGateway.subscribePushTokenIfAvailable(forceToken = token) }
}
}
@@ -148,7 +155,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
if (!hasDataContent && !hasNotificationContent) {
Log.d(TAG, "Silent/empty push ignored (iOS wake-up push)")
// Still trigger reconnect if WebSocket is disconnected
com.rosetta.messenger.network.ProtocolManager.reconnectNowIfNeeded("silent_push")
protocolGateway.reconnectNowIfNeeded("silent_push")
return
}
@@ -514,18 +521,18 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
/** Пробуждаем сетевой слой по high-priority push, чтобы быстрее получить сигнальные пакеты */
private fun wakeProtocolFromPush(reason: String) {
runCatching {
val account = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty()
ProtocolManager.initialize(applicationContext)
val account = accountManager.getLastLoggedPublicKey().orEmpty()
protocolGateway.initialize(applicationContext)
CallManager.initialize(applicationContext)
if (account.isNotBlank()) {
CallManager.bindAccount(account)
}
val restored = ProtocolManager.restoreAuthFromStoredCredentials(
val restored = protocolGateway.restoreAuthFromStoredCredentials(
preferredPublicKey = account,
reason = "push_$reason"
)
pushCallLog("wakeProtocolFromPush: authRestore=$restored account=${account.take(8)}")
ProtocolManager.reconnectNowIfNeeded("push_$reason")
protocolGateway.reconnectNowIfNeeded("push_$reason")
}.onFailure { error ->
Log.w(TAG, "wakeProtocolFromPush failed: ${error.message}")
}
@@ -560,7 +567,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
private fun areNotificationsEnabled(): Boolean {
return runCatching {
runBlocking(Dispatchers.IO) {
PreferencesManager(applicationContext).notificationsEnabled.first()
preferencesManager.notificationsEnabled.first()
}
}.getOrDefault(true)
}
@@ -583,7 +590,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
parsedDialogKey: String?,
parsedSenderKey: String?
): Set<String> {
val currentAccount = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty().trim()
val currentAccount = accountManager.getLastLoggedPublicKey().orEmpty().trim()
val candidates = linkedSetOf<String>()
fun addCandidate(raw: String?) {
@@ -726,7 +733,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
private fun isAvatarInNotificationsEnabled(): Boolean {
return runCatching {
runBlocking(Dispatchers.IO) {
PreferencesManager(applicationContext).notificationAvatarEnabled.first()
preferencesManager.notificationAvatarEnabled.first()
}
}.getOrDefault(true)
}
@@ -735,12 +742,10 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
private fun isDialogMuted(senderPublicKey: String): Boolean {
if (senderPublicKey.isBlank()) return false
return runCatching {
val accountManager = AccountManager(applicationContext)
val currentAccount = accountManager.getLastLoggedPublicKey().orEmpty()
runBlocking(Dispatchers.IO) {
val preferences = PreferencesManager(applicationContext)
buildDialogKeyVariants(senderPublicKey).any { key ->
preferences.isChatMuted(currentAccount, key)
preferencesManager.isChatMuted(currentAccount, key)
}
}
}.getOrDefault(false)
@@ -750,10 +755,10 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
private fun resolveNameForKey(publicKey: String?): String? {
if (publicKey.isNullOrBlank()) return null
// 1. In-memory cache
ProtocolManager.getCachedUserName(publicKey)?.let { return it }
protocolGateway.getCachedUserName(publicKey)?.let { return it }
// 2. DB dialogs table
return runCatching {
val account = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty()
val account = accountManager.getLastLoggedPublicKey().orEmpty()
if (account.isBlank()) return null
val db = RosettaDatabase.getDatabase(applicationContext)
val dialog = runBlocking(Dispatchers.IO) {

View File

@@ -1,11 +1,7 @@
package com.rosetta.messenger.session
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.network.ProtocolManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
sealed interface SessionState {
data object LoggedOut : SessionState
@@ -24,55 +20,30 @@ sealed interface SessionState {
* UI should rely on this state instead of scattering account checks.
*/
object AppSessionCoordinator {
private val _sessionState = MutableStateFlow<SessionState>(SessionState.LoggedOut)
val sessionState: StateFlow<SessionState> = _sessionState.asStateFlow()
val sessionState: StateFlow<SessionState> = SessionStore.state
fun dispatch(action: SessionAction) {
SessionStore.dispatch(action)
}
fun markLoggedOut(reason: String = "") {
_sessionState.value = SessionState.LoggedOut
IdentityStore.markLoggedOut(reason = reason)
dispatch(SessionAction.LoggedOut(reason = reason))
}
fun markAuthInProgress(publicKey: String? = null, reason: String = "") {
_sessionState.value = SessionState.AuthInProgress(publicKey = publicKey, reason = reason)
IdentityStore.markAuthInProgress(publicKey = publicKey, reason = reason)
dispatch(
SessionAction.AuthInProgress(
publicKey = publicKey,
reason = reason
)
)
}
fun markReady(account: DecryptedAccount, reason: String = "") {
_sessionState.value = SessionState.Ready(account = account, reason = reason)
IdentityStore.setAccount(account = account, reason = reason)
dispatch(SessionAction.Ready(account = account, reason = reason))
}
fun syncFromCachedAccount(account: DecryptedAccount?) {
if (account == null) {
if (_sessionState.value is SessionState.Ready) {
_sessionState.value = SessionState.LoggedOut
}
IdentityStore.markLoggedOut(reason = "cached_account_cleared")
return
}
_sessionState.value = SessionState.Ready(account = account, reason = "cached")
IdentityStore.setAccount(account = account, reason = "cached")
}
/**
* Unified bootstrap used by registration and unlock flows.
* Keeps protocol/account initialization sequence in one place.
*/
suspend fun bootstrapAuthenticatedSession(
accountManager: AccountManager,
account: DecryptedAccount,
reason: String
) {
markAuthInProgress(publicKey = account.publicKey, reason = reason)
// Initialize storage-bound account context before handshake completes
// to avoid early sync/message race conditions.
ProtocolManager.initializeAccount(account.publicKey, account.privateKey)
ProtocolManager.connect()
ProtocolManager.authenticate(account.publicKey, account.privateKeyHash)
ProtocolManager.reconnectNowIfNeeded("session_bootstrap_$reason")
accountManager.setCurrentAccount(account.publicKey)
markReady(account, reason = reason)
dispatch(SessionAction.SyncFromCachedAccount(account = account))
}
}

View File

@@ -0,0 +1,19 @@
package com.rosetta.messenger.session
import com.rosetta.messenger.data.DecryptedAccount
sealed interface SessionAction {
data class LoggedOut(val reason: String = "") : SessionAction
data class AuthInProgress(
val publicKey: String? = null,
val reason: String = ""
) : SessionAction
data class Ready(
val account: DecryptedAccount,
val reason: String = ""
) : SessionAction
data class SyncFromCachedAccount(val account: DecryptedAccount?) : SessionAction
}

View File

@@ -0,0 +1,27 @@
package com.rosetta.messenger.session
object SessionReducer {
fun reduce(current: SessionState, action: SessionAction): SessionState {
return when (action) {
is SessionAction.LoggedOut -> SessionState.LoggedOut
is SessionAction.AuthInProgress ->
SessionState.AuthInProgress(
publicKey = action.publicKey?.trim().orEmpty().ifBlank { null },
reason = action.reason
)
is SessionAction.Ready ->
SessionState.Ready(
account = action.account,
reason = action.reason
)
is SessionAction.SyncFromCachedAccount -> {
val account = action.account
if (account == null) {
if (current is SessionState.Ready) SessionState.LoggedOut else current
} else {
SessionState.Ready(account = account, reason = "cached")
}
}
}
}
}

View File

@@ -0,0 +1,51 @@
package com.rosetta.messenger.session
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Single runtime source of truth for session lifecycle state.
* State transitions are produced only by SessionReducer.
*/
object SessionStore {
private val _state = MutableStateFlow<SessionState>(SessionState.LoggedOut)
val state: StateFlow<SessionState> = _state.asStateFlow()
private val lock = Any()
fun dispatch(action: SessionAction) {
synchronized(lock) {
_state.value = SessionReducer.reduce(_state.value, action)
}
syncIdentity(action)
}
private fun syncIdentity(action: SessionAction) {
when (action) {
is SessionAction.LoggedOut -> {
IdentityStore.markLoggedOut(reason = action.reason)
}
is SessionAction.AuthInProgress -> {
IdentityStore.markAuthInProgress(
publicKey = action.publicKey,
reason = action.reason
)
}
is SessionAction.Ready -> {
IdentityStore.setAccount(
account = action.account,
reason = action.reason
)
}
is SessionAction.SyncFromCachedAccount -> {
val account = action.account
if (account == null) {
IdentityStore.markLoggedOut(reason = "cached_account_cleared")
} else {
IdentityStore.setAccount(account = account, reason = "cached")
}
}
}
}
}

View File

@@ -8,7 +8,7 @@ import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.session.AppSessionCoordinator
import com.rosetta.messenger.di.SessionCoordinator
enum class AuthScreen {
SELECT_ACCOUNT,
@@ -28,6 +28,7 @@ fun AuthFlow(
hasExistingAccount: Boolean,
accounts: List<AccountInfo> = emptyList(),
accountManager: AccountManager,
sessionCoordinator: SessionCoordinator,
startInCreateMode: Boolean = false,
onAuthComplete: (DecryptedAccount?) -> Unit,
onLogout: () -> Unit = {}
@@ -64,7 +65,7 @@ fun AuthFlow(
var isImportMode by remember { mutableStateOf(false) }
LaunchedEffect(currentScreen, selectedAccountId) {
AppSessionCoordinator.markAuthInProgress(
sessionCoordinator.markAuthInProgress(
publicKey = selectedAccountId,
reason = "auth_flow_${currentScreen.name.lowercase()}"
)
@@ -177,6 +178,8 @@ fun AuthFlow(
seedPhrase = seedPhrase,
isDarkTheme = isDarkTheme,
isImportMode = isImportMode,
accountManager = accountManager,
sessionCoordinator = sessionCoordinator,
onBack = {
if (isImportMode) {
currentScreen = AuthScreen.IMPORT_SEED
@@ -236,6 +239,8 @@ fun AuthFlow(
UnlockScreen(
isDarkTheme = isDarkTheme,
selectedAccountId = selectedAccountId,
accountManager = accountManager,
sessionCoordinator = sessionCoordinator,
onUnlocked = { account -> onAuthComplete(account) },
onSwitchAccount = {
// Navigate to create new account screen

View File

@@ -1,28 +1,33 @@
package com.rosetta.messenger.ui.auth
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.network.ProtocolState
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withTimeoutOrNull
internal fun startAuthHandshakeFast(publicKey: String, privateKeyHash: String) {
internal fun startAuthHandshakeFast(
protocolGateway: ProtocolGateway,
publicKey: String,
privateKeyHash: String
) {
// Desktop parity: start connection+handshake immediately, without artificial waits.
ProtocolManager.connect()
ProtocolManager.authenticate(publicKey, privateKeyHash)
ProtocolManager.reconnectNowIfNeeded("auth_fast_start")
protocolGateway.connect()
protocolGateway.authenticate(publicKey, privateKeyHash)
protocolGateway.reconnectNowIfNeeded("auth_fast_start")
}
internal suspend fun awaitAuthHandshakeState(
protocolGateway: ProtocolGateway,
publicKey: String,
privateKeyHash: String,
attempts: Int = 2,
timeoutMs: Long = 25_000L
): ProtocolState? {
repeat(attempts) { attempt ->
startAuthHandshakeFast(publicKey, privateKeyHash)
startAuthHandshakeFast(protocolGateway, publicKey, privateKeyHash)
val state = withTimeoutOrNull(timeoutMs) {
ProtocolManager.state.first {
protocolGateway.state.first {
it == ProtocolState.AUTHENTICATED ||
it == ProtocolState.DEVICE_VERIFICATION_REQUIRED
}
@@ -30,7 +35,7 @@ internal suspend fun awaitAuthHandshakeState(
if (state != null) {
return state
}
ProtocolManager.reconnectNowIfNeeded("auth_handshake_retry_${attempt + 1}")
protocolGateway.reconnectNowIfNeeded("auth_handshake_retry_${attempt + 1}")
}
return null
}

View File

@@ -37,6 +37,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -52,10 +53,10 @@ import com.airbnb.lottie.compose.LottieConstants
import com.airbnb.lottie.compose.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition
import com.rosetta.messenger.R
import com.rosetta.messenger.di.UiDependencyAccess
import com.rosetta.messenger.network.DeviceResolveSolution
import com.rosetta.messenger.network.Packet
import com.rosetta.messenger.network.PacketDeviceResolve
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import compose.icons.TablerIcons
import compose.icons.tablericons.DeviceMobile
@@ -66,6 +67,9 @@ fun DeviceConfirmScreen(
isDarkTheme: Boolean,
onExit: () -> Unit
) {
val context = LocalContext.current
val uiDeps = remember(context) { UiDependencyAccess.get(context) }
val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() }
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
@@ -131,9 +135,9 @@ fun DeviceConfirmScreen(
scope.launch { onExitState() }
}
}
ProtocolManager.waitPacket(0x18, callback)
protocolGateway.waitPacket(0x18, callback)
onDispose {
ProtocolManager.unwaitPacket(0x18, callback)
protocolGateway.unwaitPacket(0x18, callback)
}
}

View File

@@ -15,7 +15,6 @@ 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.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
@@ -29,7 +28,7 @@ import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.session.AppSessionCoordinator
import com.rosetta.messenger.di.SessionCoordinator
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import kotlinx.coroutines.launch
@@ -39,6 +38,8 @@ fun SetPasswordScreen(
seedPhrase: List<String>,
isDarkTheme: Boolean,
isImportMode: Boolean = false,
accountManager: AccountManager,
sessionCoordinator: SessionCoordinator,
onBack: () -> Unit,
onAccountCreated: (DecryptedAccount) -> Unit
) {
@@ -47,8 +48,6 @@ fun SetPasswordScreen(
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
val fieldBackground = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
val context = LocalContext.current
val accountManager = remember { AccountManager(context) }
val scope = rememberCoroutineScope()
var password by remember { mutableStateOf("") }
@@ -316,8 +315,7 @@ fun SetPasswordScreen(
privateKeyHash = privateKeyHash,
name = truncatedKey
)
AppSessionCoordinator.bootstrapAuthenticatedSession(
accountManager = accountManager,
sessionCoordinator.bootstrapAuthenticatedSession(
account = decryptedAccount,
reason = "set_password"
)

View File

@@ -27,10 +27,9 @@ 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.di.UiDependencyAccess
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
@@ -75,6 +74,9 @@ fun SetProfileScreen(
onSkip: () -> Unit
) {
val context = LocalContext.current
val uiDeps = remember(context) { UiDependencyAccess.get(context) }
val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() }
val accountManager = remember(uiDeps) { uiDeps.accountManager() }
val scope = rememberCoroutineScope()
var name by remember { mutableStateOf("") }
@@ -104,7 +106,7 @@ fun SetProfileScreen(
isCheckingUsername = true
delay(600) // debounce
try {
val results = ProtocolManager.searchUsers(trimmed, 3000)
val results = protocolGateway.searchUsers(trimmed, 3000)
val taken = results.any { it.username.equals(trimmed, ignoreCase = true) }
usernameAvailable = !taken
} catch (_: Exception) {
@@ -402,14 +404,13 @@ fun SetProfileScreen(
try {
// Wait for server connection (up to 8s)
val connected = withTimeoutOrNull(8000) {
while (!ProtocolManager.isAuthenticated()) {
while (!protocolGateway.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())
}
@@ -417,7 +418,7 @@ fun SetProfileScreen(
accountManager.updateAccountUsername(account.publicKey, username.trim())
}
// Trigger UI refresh in MainActivity
ProtocolManager.notifyOwnProfileUpdated()
protocolGateway.notifyOwnProfileUpdated()
// Send name and username to server
if (connected && (name.trim().isNotEmpty() || username.trim().isNotEmpty())) {
@@ -425,16 +426,16 @@ fun SetProfileScreen(
packet.title = name.trim()
packet.username = username.trim()
packet.privateKey = account.privateKeyHash
ProtocolManager.send(packet)
protocolGateway.send(packet)
delay(1500)
// Повторяем для надёжности
if (ProtocolManager.isAuthenticated()) {
if (protocolGateway.isAuthenticated()) {
val packet2 = PacketUserInfo()
packet2.title = name.trim()
packet2.username = username.trim()
packet2.privateKey = account.privateKeyHash
ProtocolManager.send(packet2)
protocolGateway.send(packet2)
delay(500)
}
}

View File

@@ -45,8 +45,8 @@ import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.di.SessionCoordinator
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.session.AppSessionCoordinator
import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.chats.getAvatarColor
import com.rosetta.messenger.ui.chats.getAvatarText
@@ -69,6 +69,7 @@ private suspend fun performUnlock(
selectedAccount: AccountItem?,
password: String,
accountManager: AccountManager,
sessionCoordinator: SessionCoordinator,
onUnlocking: (Boolean) -> Unit,
onError: (String) -> Unit,
onSuccess: (DecryptedAccount) -> Unit
@@ -117,8 +118,7 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword(
name = selectedAccount.name
)
AppSessionCoordinator.bootstrapAuthenticatedSession(
accountManager = accountManager,
sessionCoordinator.bootstrapAuthenticatedSession(
account = decryptedAccount,
reason = "unlock"
)
@@ -134,6 +134,8 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword(
fun UnlockScreen(
isDarkTheme: Boolean,
selectedAccountId: String? = null,
accountManager: AccountManager,
sessionCoordinator: SessionCoordinator,
onUnlocked: (DecryptedAccount) -> Unit,
onSwitchAccount: () -> Unit = {},
onRecover: () -> Unit = {}
@@ -163,7 +165,6 @@ fun UnlockScreen(
val context = LocalContext.current
val activity = context as? FragmentActivity
val accountManager = remember { AccountManager(context) }
val biometricManager = remember { BiometricAuthManager(context) }
val biometricPrefs = remember { BiometricPreferences(context) }
val scope = rememberCoroutineScope()
@@ -262,6 +263,7 @@ fun UnlockScreen(
selectedAccount = selectedAccount,
password = decryptedPassword,
accountManager = accountManager,
sessionCoordinator = sessionCoordinator,
onUnlocking = { isUnlocking = it },
onError = { error = it },
onSuccess = { decryptedAccount ->
@@ -607,6 +609,7 @@ fun UnlockScreen(
selectedAccount = selectedAccount,
password = password,
accountManager = accountManager,
sessionCoordinator = sessionCoordinator,
onUnlocking = { isUnlocking = it },
onError = { error = it },
onSuccess = { decryptedAccount ->

View File

@@ -93,6 +93,7 @@ import com.airbnb.lottie.compose.LottieConstants
import com.airbnb.lottie.compose.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition
import com.rosetta.messenger.R
import com.rosetta.messenger.di.UiDependencyAccess
import com.rosetta.messenger.data.ForwardManager
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository
@@ -100,7 +101,6 @@ import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.CallManager
import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.ui.chats.calls.CallTopBanner
import com.rosetta.messenger.repository.AvatarRepository
@@ -342,6 +342,10 @@ fun ChatDetailScreen(
) {
val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}")
val context = LocalContext.current
val uiDeps = remember(context) { UiDependencyAccess.get(context) }
val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() }
val preferencesManager = remember(uiDeps) { uiDeps.preferencesManager() }
val messageRepository = remember(uiDeps) { uiDeps.messageRepository() }
val hasNativeNavigationBar = remember(context) {
com.rosetta.messenger.ui.utils.NavigationModeUtils.hasNativeNavigationBar(context)
}
@@ -354,7 +358,6 @@ fun ChatDetailScreen(
val hapticFeedback = LocalHapticFeedback.current
// 🔇 Mute state — read from PreferencesManager
val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) }
val isChatMuted = remember { mutableStateOf(false) }
LaunchedEffect(currentUserPublicKey, user.publicKey) {
if (currentUserPublicKey.isNotBlank()) {
@@ -522,7 +525,7 @@ fun ChatDetailScreen(
if (normalizedPublicKey.isBlank()) return@LaunchedEffect
val cachedVerified =
ProtocolManager.getCachedUserInfo(normalizedPublicKey)?.verified ?: 0
protocolGateway.getCachedUserInfo(normalizedPublicKey)?.verified ?: 0
if (cachedVerified > chatHeaderVerified) {
chatHeaderVerified = cachedVerified
}
@@ -733,7 +736,7 @@ fun ChatDetailScreen(
// 📨 Forward: список диалогов для выбора (загружаем из базы)
val chatsListViewModel: ChatsListViewModel = viewModel()
val dialogsList by chatsListViewModel.dialogs.collectAsState()
val groupRepository = remember { GroupRepository.getInstance(context) }
val groupRepository = remember(uiDeps) { uiDeps.groupRepository() }
val groupMembersCacheKey =
remember(user.publicKey, currentUserPublicKey) {
"${currentUserPublicKey.trim()}::${user.publicKey.trim()}"
@@ -4251,10 +4254,7 @@ fun ChatDetailScreen(
scope.launch {
try {
if (isLeaveGroupDialog) {
GroupRepository
.getInstance(
context
)
groupRepository
.leaveGroup(
currentUserPublicKey,
user.publicKey
@@ -4271,11 +4271,7 @@ fun ChatDetailScreen(
}
// 🗑️ Очищаем ВСЕ кэши сообщений
com.rosetta.messenger.data
.MessageRepository
.getInstance(
context
)
messageRepository
.clearDialogCache(
user.publicKey
)

View File

@@ -12,6 +12,8 @@ import androidx.lifecycle.viewModelScope
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.data.ForwardManager
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.di.UiDependencyAccess
import com.rosetta.messenger.database.MessageEntity
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.*
@@ -121,16 +123,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val searchIndexDao = database.messageSearchIndexDao()
private val groupDao = database.groupDao()
private val pinnedMessageDao = database.pinnedMessageDao()
private val uiDeps = UiDependencyAccess.get(application)
private val protocolGateway: ProtocolGateway = uiDeps.protocolGateway()
// MessageRepository для подписки на события новых сообщений
private val messageRepository =
com.rosetta.messenger.data.MessageRepository.getInstance(application)
private val messageRepository = uiDeps.messageRepository()
private val sendTextMessageUseCase =
SendTextMessageUseCase(sendWithRetry = { packet -> ProtocolManager.sendMessageWithRetry(packet) })
SendTextMessageUseCase(sendWithRetry = { packet -> protocolGateway.sendMessageWithRetry(packet) })
private val sendMediaMessageUseCase =
SendMediaMessageUseCase(sendWithRetry = { packet -> ProtocolManager.sendMessageWithRetry(packet) })
SendMediaMessageUseCase(sendWithRetry = { packet -> protocolGateway.sendMessageWithRetry(packet) })
private val sendForwardUseCase =
SendForwardUseCase(sendWithRetry = { packet -> ProtocolManager.sendMessageWithRetry(packet) })
SendForwardUseCase(sendWithRetry = { packet -> protocolGateway.sendMessageWithRetry(packet) })
// 🔥 Кэш расшифрованных сообщений (messageId -> plainText)
private val decryptionCache = ConcurrentHashMap<String, String>()
@@ -722,7 +725,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
typingSnapshotJob?.cancel()
typingSnapshotJob =
viewModelScope.launch {
ProtocolManager.typingUsersByDialogSnapshot.collect { snapshot ->
protocolGateway.typingUsersByDialogSnapshot.collect { snapshot ->
val currentDialog = opponentKey?.trim().orEmpty()
val currentAccount = myPublicKey?.trim().orEmpty()
@@ -923,12 +926,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
private fun logPhotoPipeline(messageId: String, message: String) {
ProtocolManager.addLog("📸 IMG ${shortPhotoId(messageId)} | $message")
protocolGateway.addLog("📸 IMG ${shortPhotoId(messageId)} | $message")
}
private fun logPhotoPipelineError(messageId: String, stage: String, throwable: Throwable) {
val reason = throwable.message ?: "unknown"
ProtocolManager.addLog(
protocolGateway.addLog(
"❌ IMG ${shortPhotoId(messageId)} | $stage failed: ${throwable.javaClass.simpleName}: $reason"
)
}
@@ -1046,7 +1049,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
if (!isGroupDialogKey(normalizedPublicKey)) {
groupKeyCache.remove(normalizedPublicKey)
}
ProtocolManager.addLog("🔧 SEND_CONTEXT opponent=${shortSendKey(normalizedPublicKey)}")
protocolGateway.addLog("🔧 SEND_CONTEXT opponent=${shortSendKey(normalizedPublicKey)}")
triggerPendingTextSendIfReady("send_context_bound")
}
@@ -1774,7 +1777,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
return nameFromMessages
}
val cachedInfo = ProtocolManager.getCachedUserInfo(normalizedPublicKey)
val cachedInfo = protocolGateway.getCachedUserInfo(normalizedPublicKey)
val protocolName =
cachedInfo?.title?.trim().orEmpty().ifBlank { cachedInfo?.username?.trim().orEmpty() }
if (isUsableSenderName(protocolName, normalizedPublicKey)) {
@@ -1808,7 +1811,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
}
val cached = ProtocolManager.getCachedUserInfo(normalizedPublicKey)
val cached = protocolGateway.getCachedUserInfo(normalizedPublicKey)
val protocolName = cached?.title?.trim().orEmpty()
.ifBlank { cached?.username?.trim().orEmpty() }
if (isUsableSenderName(protocolName, normalizedPublicKey)) {
@@ -1842,7 +1845,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
viewModelScope.launch(Dispatchers.IO) {
try {
val resolved = ProtocolManager.resolveUserInfo(normalizedPublicKey, timeoutMs = 5000L)
val resolved = protocolGateway.resolveUserInfo(normalizedPublicKey, timeoutMs = 5000L)
val name = resolved?.title?.trim().orEmpty()
.ifBlank { resolved?.username?.trim().orEmpty() }
if (!isUsableSenderName(name, normalizedPublicKey)) {
@@ -2568,12 +2571,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} catch (_: Exception) { null }
// 2. Try ProtocolManager cache (previously resolved)
val cachedName = dbName ?: ProtocolManager.getCachedUserName(fwdPublicKey)
val cachedName = dbName ?: protocolGateway.getCachedUserName(fwdPublicKey)
// 3. Server resolve via PacketSearch (like desktop useUserInformation)
val serverName = if (cachedName == null) {
try {
ProtocolManager.resolveUserName(fwdPublicKey, 3000)
protocolGateway.resolveUserName(fwdPublicKey, 3000)
} catch (_: Exception) { null }
} else null
@@ -2750,11 +2753,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
fwdDialog?.opponentTitle?.ifEmpty { fwdDialog.opponentUsername }?.ifEmpty { null }
} catch (_: Exception) { null }
// 2. Try ProtocolManager cache
val cachedName = dbName ?: ProtocolManager.getCachedUserName(replyPublicKey)
val cachedName = dbName ?: protocolGateway.getCachedUserName(replyPublicKey)
// 3. Server resolve via PacketSearch (like desktop useUserInformation)
val serverName = if (cachedName == null) {
try {
ProtocolManager.resolveUserName(replyPublicKey, 3000)
protocolGateway.resolveUserName(replyPublicKey, 3000)
} catch (_: Exception) { null }
} else null
cachedName ?: serverName ?: "User"
@@ -3388,14 +3391,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
/** 🛑 Отменить исходящую отправку фото во время загрузки */
fun cancelOutgoingImageUpload(messageId: String, attachmentId: String) {
ProtocolManager.addLog(
protocolGateway.addLog(
"🛑 IMG cancel requested: msg=${messageId.take(8)}, att=${attachmentId.take(12)}"
)
outgoingImageUploadJobs.remove(messageId)?.cancel(
CancellationException("User cancelled image upload")
)
TransportManager.cancelUpload(attachmentId)
ProtocolManager.resolveOutgoingRetry(messageId)
protocolGateway.resolveOutgoingRetry(messageId)
deleteMessage(messageId)
}
@@ -3470,7 +3473,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} catch (_: Exception) {}
// 2. Try ProtocolManager cache
val cached = ProtocolManager.getCachedUserInfo(publicKey)
val cached = protocolGateway.getCachedUserInfo(publicKey)
if (cached != null) {
return SearchUser(
title = cached.title,
@@ -3483,7 +3486,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 3. Server resolve
try {
val resolved = ProtocolManager.resolveUserInfo(publicKey, 3000)
val resolved = protocolGateway.resolveUserInfo(publicKey, 3000)
if (resolved != null) {
return SearchUser(
title = resolved.title,
@@ -3532,10 +3535,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
// 2) In-memory protocol cache.
ProtocolManager.getCachedUserByUsername(normalized)?.let { return it }
protocolGateway.getCachedUserByUsername(normalized)?.let { return it }
// 3) Server search fallback.
val results = ProtocolManager.searchUsers(normalized, timeoutMs)
val results = protocolGateway.searchUsers(normalized, timeoutMs)
if (results.isEmpty()) return null
return results.firstOrNull {
@@ -3604,7 +3607,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
sender: String?,
hasPrivateKey: Boolean
) {
ProtocolManager.addLog(
protocolGateway.addLog(
"⚠️ SEND_BLOCKED reason=$reason textLen=$textLength hasReply=$hasReply recipient=${shortSendKey(recipient)} sender=${shortSendKey(sender)} hasPriv=$hasPrivateKey isSending=$isSending"
)
}
@@ -3618,7 +3621,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
if (repositoryPublicKey.isNotEmpty() && repositoryPrivateKey.isNotEmpty()) {
setUserKeys(repositoryPublicKey, repositoryPrivateKey)
ProtocolManager.addLog(
protocolGateway.addLog(
"🔄 SEND_RECOVERY restored_keys pk=${shortSendKey(repositoryPublicKey)}"
)
}
@@ -3633,7 +3636,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
if (pendingSendRecoveryJob?.isActive == true) return
ProtocolManager.addLog("⏳ SEND_RECOVERY queued reason=$reason")
protocolGateway.addLog("⏳ SEND_RECOVERY queued reason=$reason")
pendingSendRecoveryJob =
viewModelScope.launch {
repeat(10) { attempt ->
@@ -3644,7 +3647,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
if (pendingTextSendRequested) {
ProtocolManager.addLog("⚠️ SEND_RECOVERY timeout reason=$pendingTextSendReason")
protocolGateway.addLog("⚠️ SEND_RECOVERY timeout reason=$pendingTextSendReason")
}
pendingTextSendRequested = false
pendingTextSendReason = ""
@@ -3668,7 +3671,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val keysReady = !myPublicKey.isNullOrBlank() && !myPrivateKey.isNullOrBlank()
if (!recipientReady || !keysReady || isSending) return
ProtocolManager.addLog("🚀 SEND_RECOVERY flush trigger=$trigger")
protocolGateway.addLog("🚀 SEND_RECOVERY flush trigger=$trigger")
pendingTextSendRequested = false
pendingTextSendReason = ""
pendingSendRecoveryJob?.cancel()
@@ -4385,7 +4388,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val context = getApplication<Application>()
if (recipient == null || sender == null || privateKey == null) {
ProtocolManager.addLog(
protocolGateway.addLog(
"❌ IMG send aborted: missing keys (recipient=${recipient != null}, sender=${sender != null}, private=${privateKey != null})"
)
return
@@ -4986,13 +4989,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val groupDebugId = UUID.randomUUID().toString().replace("-", "").take(8)
if (recipient == null || sender == null || privateKey == null) {
ProtocolManager.addLog(
protocolGateway.addLog(
"❌ IMG-GROUP $groupDebugId | aborted: missing keys (recipient=${recipient != null}, sender=${sender != null}, private=${privateKey != null})"
)
return
}
if (isSending) {
ProtocolManager.addLog("⚠️ IMG-GROUP $groupDebugId | skipped: another send in progress")
protocolGateway.addLog("⚠️ IMG-GROUP $groupDebugId | skipped: another send in progress")
return
}
@@ -5028,7 +5031,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
)
_inputText.value = ""
ProtocolManager.addLog(
protocolGateway.addLog(
"📸 IMG-GROUP $groupDebugId | prepare start: count=${imageUris.size}, captionLen=${caption.trim().length}"
)
@@ -5073,7 +5076,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
opponentPublicKey = recipient
)
} catch (_: Exception) {
ProtocolManager.addLog("⚠️ IMG-GROUP $groupDebugId | optimistic DB save skipped (non-fatal)")
protocolGateway.addLog("⚠️ IMG-GROUP $groupDebugId | optimistic DB save skipped (non-fatal)")
}
val preparedImages =
@@ -5089,7 +5092,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
uri
)
?: run {
ProtocolManager.addLog(
protocolGateway.addLog(
"❌ IMG-GROUP $groupDebugId | item#$index base64 conversion failed"
)
throw IllegalStateException(
@@ -5101,7 +5104,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
context,
uri
)
ProtocolManager.addLog(
protocolGateway.addLog(
"📸 IMG-GROUP $groupDebugId | item#$index prepared: ${width}x$height, base64Len=${imageBase64.length}, blurhashLen=${blurhash.length}"
)
index to
@@ -5114,7 +5117,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
}
if (preparedImages.isEmpty()) {
ProtocolManager.addLog(
protocolGateway.addLog(
"❌ IMG-GROUP $groupDebugId | no prepared images, send canceled"
)
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
@@ -5122,7 +5125,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
isSending = false
return@launch
}
ProtocolManager.addLog(
protocolGateway.addLog(
"📸 IMG-GROUP $groupDebugId | prepare done: ready=${preparedImages.size}"
)
@@ -6653,7 +6656,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
dialogDao.markIHaveSent(account, opponent)
}
ProtocolManager.addLog(
protocolGateway.addLog(
"🛠️ DIALOG_FALLBACK upserted opponent=${opponent.take(12)} ts=$fallbackTimestamp hasContent=1"
)
}
@@ -6920,7 +6923,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
toPublicKey = opponent
}
ProtocolManager.send(packet)
protocolGateway.send(packet)
} catch (e: Exception) {}
}
}
@@ -6964,7 +6967,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
toPublicKey = opponent // Кому отправляем уведомление (собеседник)
}
ProtocolManager.send(packet)
protocolGateway.send(packet)
// ✅ Обновляем timestamp ПОСЛЕ успешной отправки
lastReadMessageTimestamp = incomingTs
MessageLogger.logReadReceiptSent(opponent)
@@ -6973,7 +6976,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🔄 Retry через 2с (desktop буферизует через WebSocket, мы ретраим вручную)
try {
kotlinx.coroutines.delay(2000)
ProtocolManager.send(
protocolGateway.send(
PacketRead().apply {
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
fromPublicKey = sender
@@ -7058,7 +7061,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
addPublicKey(opponent)
}
ProtocolManager.send(packet)
protocolGateway.send(packet)
} catch (e: Exception) {}
}
}

View File

@@ -68,11 +68,12 @@ import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.data.isPlaceholderAccountName
import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.di.UiDependencyAccess
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.CallUiState
import com.rosetta.messenger.network.DeviceEntry
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.calls.CallsHistoryScreen
@@ -246,10 +247,10 @@ private fun shortPublicKey(value: String): String {
return "${trimmed.take(6)}...${trimmed.takeLast(4)}"
}
private fun resolveTypingDisplayName(publicKey: String): String {
private fun resolveTypingDisplayName(protocolGateway: ProtocolGateway, publicKey: String): String {
val normalized = publicKey.trim()
if (normalized.isBlank()) return ""
val cached = ProtocolManager.getCachedUserInfo(normalized)
val cached = protocolGateway.getCachedUserInfo(normalized)
val resolvedName =
cached?.title?.trim().orEmpty().ifBlank { cached?.username?.trim().orEmpty() }
return if (resolvedName.isNotBlank()) resolvedName else shortPublicKey(normalized)
@@ -324,6 +325,10 @@ fun ChatsListScreen(
val view = androidx.compose.ui.platform.LocalView.current
val context = androidx.compose.ui.platform.LocalContext.current
val uiDeps = remember(context) { UiDependencyAccess.get(context) }
val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() }
val accountManager = remember(uiDeps) { uiDeps.accountManager() }
val preferencesManager = remember(uiDeps) { uiDeps.preferencesManager() }
val focusManager = androidx.compose.ui.platform.LocalFocusManager.current
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope()
@@ -456,10 +461,10 @@ fun ChatsListScreen(
remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E8E) else Color(0xFF8E8E93) }
// Protocol connection state
val protocolState by ProtocolManager.state.collectAsState()
val syncInProgress by ProtocolManager.syncInProgress.collectAsState()
val ownProfileUpdated by ProtocolManager.ownProfileUpdated.collectAsState()
val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState()
val protocolState by protocolGateway.state.collectAsState()
val syncInProgress by protocolGateway.syncInProgress.collectAsState()
val ownProfileUpdated by protocolGateway.ownProfileUpdated.collectAsState()
val pendingDeviceVerification by protocolGateway.pendingDeviceVerification.collectAsState()
// 📥 Active FILE downloads tracking (account-scoped, excludes photo downloads)
val currentAccountKey = remember(accountPublicKey) { accountPublicKey.trim() }
@@ -490,8 +495,8 @@ fun ChatsListScreen(
val hasActiveDownloads = activeFileDownloads.isNotEmpty()
// <20>🔥 Пользователи, которые сейчас печатают
val typingUsers by ProtocolManager.typingUsers.collectAsState()
val typingUsersByDialogSnapshot by ProtocolManager.typingUsersByDialogSnapshot.collectAsState()
val typingUsers by protocolGateway.typingUsers.collectAsState()
val typingUsersByDialogSnapshot by protocolGateway.typingUsersByDialogSnapshot.collectAsState()
val playingVoiceAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
val playingVoiceDialogKey by VoicePlaybackCoordinator.playingDialogKey.collectAsState()
val isVoicePlaybackRunning by VoicePlaybackCoordinator.isPlaying.collectAsState()
@@ -622,7 +627,6 @@ fun ChatsListScreen(
// 👥 Load all accounts for sidebar (current account always first)
var allAccounts by remember { mutableStateOf<List<EncryptedAccount>>(emptyList()) }
LaunchedEffect(accountPublicKey, ownProfileUpdated) {
val accountManager = AccountManager(context)
var accounts = accountManager.getAllAccounts()
val preferredPublicKey =
accountPublicKey.trim().ifBlank {
@@ -630,7 +634,7 @@ fun ChatsListScreen(
}
if (preferredPublicKey.isNotBlank()) {
val cachedOwn = ProtocolManager.getCachedUserInfo(preferredPublicKey)
val cachedOwn = protocolGateway.getCachedUserInfo(preferredPublicKey)
val cachedTitle = cachedOwn?.title?.trim().orEmpty()
val cachedUsername = cachedOwn?.username?.trim().orEmpty()
val existing =
@@ -711,7 +715,6 @@ fun ChatsListScreen(
val isSelectionMode = selectedChatKeys.isNotEmpty()
val hapticFeedback = LocalHapticFeedback.current
var showSelectionMenu by remember { mutableStateOf(false) }
val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) }
val mutedChats by preferencesManager.mutedChatsForAccount(effectiveCurrentPublicKey)
.collectAsState(initial = emptySet())
@@ -2730,6 +2733,7 @@ fun ChatsListScreen(
} else {
val baseName =
resolveTypingDisplayName(
protocolGateway,
typingSenderPublicKey
)
if (baseName.isBlank()) {
@@ -3237,12 +3241,12 @@ fun ChatsListScreen(
if (request != null) {
when (request.second) {
DeviceResolveAction.ACCEPT -> {
ProtocolManager.acceptDevice(
protocolGateway.acceptDevice(
request.first.deviceId
)
}
DeviceResolveAction.DECLINE -> {
ProtocolManager.declineDevice(
protocolGateway.declineDevice(
request.first.deviceId
)
}

View File

@@ -8,11 +8,12 @@ import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.DraftManager
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.di.UiDependencyAccess
import com.rosetta.messenger.database.BlacklistEntity
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.PacketOnlineSubscribe
import com.rosetta.messenger.network.PacketSearch
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser
import java.text.SimpleDateFormat
import java.util.Date
@@ -74,7 +75,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
private val database = RosettaDatabase.getDatabase(application)
private val dialogDao = database.dialogDao()
private val groupRepository = GroupRepository.getInstance(application)
private val uiDeps = UiDependencyAccess.get(application)
private val protocolGateway: ProtocolGateway = uiDeps.protocolGateway()
private val messageRepository: MessageRepository = uiDeps.messageRepository()
private val groupRepository: GroupRepository = uiDeps.groupRepository()
private var currentAccount: String = ""
private var currentPrivateKey: String? = null
@@ -215,7 +219,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
}
if (!dialogName.isNullOrBlank()) return dialogName
val cached = ProtocolManager.getCachedUserName(publicKey).orEmpty().trim()
val cached = protocolGateway.getCachedUserName(publicKey).orEmpty().trim()
if (cached.isNotBlank() && cached != publicKey) {
return cached
}
@@ -478,7 +482,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
.getDialogsFlow(publicKey)
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
.debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка)
.combine(ProtocolManager.syncInProgress) { dialogsList, syncing ->
.combine(protocolGateway.syncInProgress) { dialogsList, syncing ->
dialogsList to syncing
}
.mapLatest { (dialogsList, syncing) ->
@@ -529,7 +533,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
.getRequestsFlow(publicKey)
.flowOn(Dispatchers.IO)
.debounce(100) // 🚀 Батчим быстрые обновления
.combine(ProtocolManager.syncInProgress) { requestsList, syncing ->
.combine(protocolGateway.syncInProgress) { requestsList, syncing ->
requestsList to syncing
}
.mapLatest { (requestsList, syncing) ->
@@ -553,7 +557,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
dialogDao
.getRequestsCountFlow(publicKey)
.flowOn(Dispatchers.IO)
.combine(ProtocolManager.syncInProgress) { count, syncing ->
.combine(protocolGateway.syncInProgress) { count, syncing ->
if (syncing) 0 else count
}
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения
@@ -577,7 +581,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// dialogs that still have empty titles.
launch {
var wasSyncing = false
ProtocolManager.syncInProgress.collect { syncing ->
protocolGateway.syncInProgress.collect { syncing ->
if (wasSyncing && !syncing) {
requestedUserInfoKeys.clear()
}
@@ -634,7 +638,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
newKeys.forEach { key -> addPublicKey(key) }
}
ProtocolManager.send(packet)
protocolGateway.send(packet)
} catch (e: Exception) {}
}
}
@@ -980,7 +984,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
}
// 🗑️ 1. Очищаем ВСЕ кэши сообщений
MessageRepository.getInstance(getApplication()).clearDialogCache(opponentKey)
messageRepository.clearDialogCache(opponentKey)
// 🗑️ Очищаем кэш ChatViewModel
ChatViewModel.clearCacheForOpponent(opponentKey)
@@ -1029,7 +1033,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
_requestsCount.value = _requests.value.size
dialogsUiCache.remove(groupPublicKey)
requestsUiCache.remove(groupPublicKey)
MessageRepository.getInstance(getApplication()).clearDialogCache(groupPublicKey)
messageRepository.clearDialogCache(groupPublicKey)
ChatViewModel.clearCacheForOpponent(groupPublicKey)
}
left
@@ -1104,7 +1108,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
this.privateKey = privateKeyHash
this.search = publicKey
}
ProtocolManager.send(packet)
protocolGateway.send(packet)
} catch (e: Exception) {}
}
}

View File

@@ -11,11 +11,12 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.di.UiDependencyAccess
import com.rosetta.messenger.network.ProtocolState
import compose.icons.TablerIcons
import compose.icons.tablericons.*
@@ -28,9 +29,12 @@ fun ConnectionLogsScreen(
isDarkTheme: Boolean,
onBack: () -> Unit
) {
val logs by ProtocolManager.debugLogs.collectAsState()
val protocolState by ProtocolManager.getProtocol().state.collectAsState()
val syncInProgress by ProtocolManager.syncInProgress.collectAsState()
val context = LocalContext.current
val uiDeps = remember(context) { UiDependencyAccess.get(context) }
val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() }
val logs by protocolGateway.debugLogs.collectAsState()
val protocolState by protocolGateway.state.collectAsState()
val syncInProgress by protocolGateway.syncInProgress.collectAsState()
val bgColor = if (isDarkTheme) Color(0xFF0E0E0E) else Color(0xFFF5F5F5)
val cardColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color.White
@@ -41,9 +45,9 @@ fun ConnectionLogsScreen(
val scope = rememberCoroutineScope()
DisposableEffect(Unit) {
ProtocolManager.enableUILogs(true)
protocolGateway.enableUILogs(true)
onDispose {
ProtocolManager.enableUILogs(false)
protocolGateway.enableUILogs(false)
}
}
@@ -85,7 +89,7 @@ fun ConnectionLogsScreen(
modifier = Modifier.weight(1f)
)
IconButton(onClick = { ProtocolManager.clearLogs() }) {
IconButton(onClick = { protocolGateway.clearLogs() }) {
Icon(
imageVector = TablerIcons.Trash,
contentDescription = "Clear logs",

View File

@@ -121,6 +121,8 @@ import com.rosetta.messenger.data.ForwardManager
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.di.UiDependencyAccess
import com.rosetta.messenger.database.MessageEntity
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.AttachmentType
@@ -128,7 +130,6 @@ import com.rosetta.messenger.network.MessageAttachment
import com.rosetta.messenger.network.OnlineState
import com.rosetta.messenger.network.PacketOnlineState
import com.rosetta.messenger.network.PacketOnlineSubscribe
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.components.ImageAttachment
@@ -323,6 +324,11 @@ fun GroupInfoScreen(
onSwipeBackEnabledChanged: (Boolean) -> Unit = {}
) {
val context = androidx.compose.ui.platform.LocalContext.current
val uiDeps = remember(context) { UiDependencyAccess.get(context) }
val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() }
val messageRepository = remember(uiDeps) { uiDeps.messageRepository() }
val preferencesManager = remember(uiDeps) { uiDeps.preferencesManager() }
val groupRepository = remember(uiDeps) { uiDeps.groupRepository() }
val view = LocalView.current
val focusManager = LocalFocusManager.current
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
@@ -376,9 +382,6 @@ fun GroupInfoScreen(
}
}
val groupRepository = remember { GroupRepository.getInstance(context) }
val messageRepository = remember { MessageRepository.getInstance(context) }
val preferencesManager = remember { PreferencesManager(context) }
val database = remember { RosettaDatabase.getDatabase(context) }
val groupDao = remember { database.groupDao() }
val messageDao = remember { database.messageDao() }
@@ -475,6 +478,7 @@ fun GroupInfoScreen(
val memberSnapshot = memberInfoByKey.toMap()
value = withContext(Dispatchers.Default) {
buildGroupMediaItems(
protocolGateway = protocolGateway,
messages = groupMessages,
privateKey = currentUserPrivateKey,
currentUserPublicKey = currentUserPublicKey,
@@ -603,11 +607,11 @@ fun GroupInfoScreen(
val resolvedUsers = withContext(Dispatchers.IO) {
val resolvedMap = LinkedHashMap<String, SearchUser>()
members.forEach { memberKey ->
val cached = ProtocolManager.getCachedUserInfo(memberKey)
val cached = protocolGateway.getCachedUserInfo(memberKey)
if (cached != null) {
resolvedMap[memberKey] = cached
} else {
ProtocolManager.resolveUserInfo(memberKey, timeoutMs = 2500L)?.let { resolvedUser ->
protocolGateway.resolveUserInfo(memberKey, timeoutMs = 2500L)?.let { resolvedUser ->
resolvedMap[memberKey] = resolvedUser
}
}
@@ -645,7 +649,7 @@ fun GroupInfoScreen(
val resolvedUsers = withContext(Dispatchers.IO) {
val resolvedMap = LinkedHashMap<String, SearchUser>()
cached.members.forEach { memberKey ->
ProtocolManager.getCachedUserInfo(memberKey)?.let { resolved ->
protocolGateway.getCachedUserInfo(memberKey)?.let { resolved ->
resolvedMap[memberKey] = resolved
}
}
@@ -685,9 +689,9 @@ fun GroupInfoScreen(
}
DisposableEffect(dialogPublicKey) {
ProtocolManager.waitPacket(0x05, onlinePacketHandler)
protocolGateway.waitPacket(0x05, onlinePacketHandler)
onDispose {
ProtocolManager.unwaitPacket(0x05, onlinePacketHandler)
protocolGateway.unwaitPacket(0x05, onlinePacketHandler)
}
}
@@ -705,7 +709,7 @@ fun GroupInfoScreen(
this.privateKey = privateKeyHash
keysToSubscribe.forEach { addPublicKey(it) }
}
ProtocolManager.send(packet)
protocolGateway.send(packet)
}
} catch (_: Exception) {}
}
@@ -2357,6 +2361,7 @@ private fun parseAttachmentsForGroupInfo(attachmentsJson: String): List<MessageA
}
private fun resolveGroupSenderName(
protocolGateway: ProtocolGateway,
senderPublicKey: String,
currentUserPublicKey: String,
memberInfoByKey: Map<String, SearchUser>
@@ -2364,13 +2369,14 @@ private fun resolveGroupSenderName(
if (senderPublicKey.isBlank()) return "Unknown"
if (senderPublicKey == currentUserPublicKey) return "You"
val info = memberInfoByKey[senderPublicKey] ?: ProtocolManager.getCachedUserInfo(senderPublicKey)
val info = memberInfoByKey[senderPublicKey] ?: protocolGateway.getCachedUserInfo(senderPublicKey)
return info?.title?.takeIf { it.isNotBlank() }
?: info?.username?.takeIf { it.isNotBlank() }
?: shortPublicKey(senderPublicKey)
}
private fun buildGroupMediaItems(
protocolGateway: ProtocolGateway,
messages: List<MessageEntity>,
privateKey: String,
currentUserPublicKey: String,
@@ -2389,6 +2395,7 @@ private fun buildGroupMediaItems(
?: if (message.fromMe == 1) currentUserPublicKey else ""
val senderCacheKey = senderKey.ifBlank { currentUserPublicKey }
val senderName = resolveGroupSenderName(
protocolGateway = protocolGateway,
senderPublicKey = senderCacheKey,
currentUserPublicKey = currentUserPublicKey,
memberInfoByKey = memberInfoByKey

View File

@@ -77,6 +77,7 @@ import androidx.core.view.WindowInsetsCompat
import coil.compose.AsyncImage
import coil.request.ImageRequest
import com.rosetta.messenger.R
import com.rosetta.messenger.di.UiDependencyAccess
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.database.DialogDao
@@ -122,6 +123,9 @@ fun GroupSetupScreen(
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val uiDeps = remember(context) { UiDependencyAccess.get(context) }
val messageRepository = remember(uiDeps) { uiDeps.messageRepository() }
val groupRepository = remember(uiDeps) { uiDeps.groupRepository() }
val view = LocalView.current
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
@@ -255,7 +259,7 @@ fun GroupSetupScreen(
}
suspend fun createGroup() =
GroupRepository.getInstance(context).createGroup(
groupRepository.createGroup(
accountPublicKey = accountPublicKey,
accountPrivateKey = accountPrivateKey,
title = title.trim(),
@@ -1091,23 +1095,21 @@ fun GroupSetupScreen(
// Send invite to all selected members
if (selectedMembers.isNotEmpty()) {
withContext(Dispatchers.IO) {
val groupRepo = GroupRepository.getInstance(context)
val groupKey = groupRepo.getGroupKey(
val groupKey = groupRepository.getGroupKey(
accountPublicKey, accountPrivateKey,
result.dialogPublicKey
)
if (!groupKey.isNullOrBlank()) {
val invite = groupRepo.constructInviteString(
val invite = groupRepository.constructInviteString(
groupId = result.dialogPublicKey,
title = result.title.ifBlank { title.trim() },
encryptKey = groupKey,
description = description.trim()
)
if (invite.isNotBlank()) {
val msgRepo = MessageRepository.getInstance(context)
selectedMembers.forEach { member ->
runCatching {
msgRepo.sendMessage(
messageRepository.sendMessage(
toPublicKey = member.publicKey,
text = invite
)

View File

@@ -14,12 +14,14 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.di.UiDependencyAccess
import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
@@ -39,8 +41,11 @@ fun RequestsListScreen(
onUserSelect: (SearchUser) -> Unit,
avatarRepository: AvatarRepository? = null
) {
val context = LocalContext.current
val uiDeps = remember(context) { UiDependencyAccess.get(context) }
val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() }
val chatsState by chatsViewModel.chatsState.collectAsState()
val syncInProgress by ProtocolManager.syncInProgress.collectAsState()
val syncInProgress by protocolGateway.syncInProgress.collectAsState()
val requests = if (syncInProgress) emptyList() else chatsState.requests
val blockedUsers by chatsViewModel.blockedUsers.collectAsState()
val scope = rememberCoroutineScope()

View File

@@ -57,7 +57,7 @@ import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.airbnb.lottie.compose.*
import com.rosetta.messenger.R
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.di.UiDependencyAccess
import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.data.isPlaceholderAccountName
import com.rosetta.messenger.network.ProtocolState
@@ -104,6 +104,8 @@ fun SearchScreen(
) {
// Context и View для мгновенного закрытия клавиатуры
val context = LocalContext.current
val uiDeps = remember(context) { UiDependencyAccess.get(context) }
val accountManager = remember(uiDeps) { uiDeps.accountManager() }
val view = LocalView.current
val focusManager = LocalFocusManager.current
if (!view.isInEditMode) {
@@ -173,7 +175,7 @@ fun SearchScreen(
return@LaunchedEffect
}
val account = AccountManager(context).getAccount(currentUserPublicKey)
val account = accountManager.getAccount(currentUserPublicKey)
ownAccountName = account?.name?.trim().orEmpty()
ownAccountUsername = account?.username?.trim().orEmpty()
}
@@ -993,6 +995,8 @@ private fun MessagesTabContent(
onUserSelect: (SearchUser) -> Unit
) {
val context = LocalContext.current
val uiDeps = remember(context) { UiDependencyAccess.get(context) }
val messageRepository = remember(uiDeps) { uiDeps.messageRepository() }
var results by remember { mutableStateOf<List<MessageSearchResult>>(emptyList()) }
var isSearching by remember { mutableStateOf(false) }
val dividerColor = remember(isDarkTheme) {
@@ -1021,8 +1025,7 @@ private fun MessagesTabContent(
withContext(Dispatchers.IO) {
try {
val db = RosettaDatabase.getDatabase(context)
val repo = com.rosetta.messenger.data.MessageRepository.getInstance(context)
val privateKey = repo.getCurrentPrivateKey().orEmpty()
val privateKey = messageRepository.getCurrentPrivateKey().orEmpty()
if (privateKey.isBlank()) {
isSearching = false
return@withContext
@@ -1481,11 +1484,13 @@ private fun MediaTabContent(
onOpenImageViewer: (images: List<com.rosetta.messenger.ui.chats.components.ViewableImage>, initialIndex: Int, privateKey: String) -> Unit = { _, _, _ -> }
) {
val context = LocalContext.current
val uiDeps = remember(context) { UiDependencyAccess.get(context) }
val messageRepository = remember(uiDeps) { uiDeps.messageRepository() }
var mediaItems by remember { mutableStateOf<List<MediaItem>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
val privateKey = remember {
com.rosetta.messenger.data.MessageRepository.getInstance(context).getCurrentPrivateKey().orEmpty()
messageRepository.getCurrentPrivateKey().orEmpty()
}
val viewerImages = remember(mediaItems) {

View File

@@ -1,9 +1,11 @@
package com.rosetta.messenger.ui.chats
import androidx.lifecycle.ViewModel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.di.UiDependencyAccess
import com.rosetta.messenger.network.PacketSearch
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser
import java.text.SimpleDateFormat
import java.util.Date
@@ -20,7 +22,9 @@ import kotlinx.coroutines.launch
* ViewModel для поиска пользователей через протокол
* Работает аналогично SearchBar в React Native приложении
*/
class SearchUsersViewModel : ViewModel() {
class SearchUsersViewModel(application: Application) : AndroidViewModel(application) {
private val protocolGateway: ProtocolGateway =
UiDependencyAccess.get(application).protocolGateway()
// Состояние поиска
private val _searchQuery = MutableStateFlow("")
@@ -47,7 +51,7 @@ class SearchUsersViewModel : ViewModel() {
init {
packetFlowJob =
viewModelScope.launch {
ProtocolManager.packetFlow(0x03).collectLatest { packet ->
protocolGateway.packetFlow(0x03).collectLatest { packet ->
val searchPacket = packet as? PacketSearch ?: return@collectLatest
logSearch(
"📥 PacketSearch response: search='${searchPacket.search}', users=${searchPacket.users.size}"
@@ -118,7 +122,7 @@ class SearchUsersViewModel : ViewModel() {
}
val effectivePrivateHash =
privateKeyHash.ifBlank { ProtocolManager.getProtocol().getPrivateHash().orEmpty() }
privateKeyHash.ifBlank { protocolGateway.getPrivateHash().orEmpty() }
if (effectivePrivateHash.isBlank()) {
_isSearching.value = false
logSearch("❌ Skip send: private hash is empty")
@@ -131,7 +135,7 @@ class SearchUsersViewModel : ViewModel() {
this.search = normalizedQuery
}
ProtocolManager.sendPacket(packetSearch)
protocolGateway.sendPacket(packetSearch)
logSearch("📤 PacketSearch sent: '$normalizedQuery'")
}
}

View File

@@ -63,7 +63,7 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.PopupProperties
import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.di.UiDependencyAccess
import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.GroupStatus
import com.rosetta.messenger.network.MessageAttachment
@@ -1693,7 +1693,8 @@ private fun GroupInviteInlineCard(
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val groupRepository = remember { GroupRepository.getInstance(context) }
val uiDeps = remember(context) { UiDependencyAccess.get(context) }
val groupRepository = remember(uiDeps) { uiDeps.groupRepository() }
val normalizedInvite = remember(inviteText) { inviteText.trim() }
val parsedInvite = remember(normalizedInvite) { groupRepository.parseInviteString(normalizedInvite) }

View File

@@ -72,7 +72,7 @@ import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing
import androidx.compose.ui.graphics.graphicsLayer
import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.di.UiDependencyAccess
/**
* 📷 In-App Camera Screen - как в Telegram
@@ -91,9 +91,8 @@ fun InAppCameraScreen(
val view = LocalView.current
val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current
val preferencesManager = remember(context.applicationContext) {
PreferencesManager(context.applicationContext)
}
val uiDeps = remember(context) { UiDependencyAccess.get(context) }
val preferencesManager = remember(uiDeps) { uiDeps.preferencesManager() }
// Camera state
var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) }

View File

@@ -29,6 +29,7 @@ import compose.icons.TablerIcons
import compose.icons.tablericons.ChevronLeft
import com.rosetta.messenger.R
import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.di.UiDependencyAccess
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
@@ -56,7 +57,8 @@ fun AppIconScreen(
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val prefs = remember { PreferencesManager(context) }
val uiDeps = remember(context) { UiDependencyAccess.get(context) }
val prefs = remember(uiDeps) { uiDeps.preferencesManager() }
var currentIcon by remember { mutableStateOf("default") }
LaunchedEffect(Unit) {

View File

@@ -27,7 +27,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.di.UiDependencyAccess
import com.rosetta.messenger.ui.icons.TelegramIcons
import compose.icons.TablerIcons
import compose.icons.tablericons.ChevronLeft
@@ -40,7 +40,8 @@ fun NotificationsScreen(
onBack: () -> Unit
) {
val context = LocalContext.current
val preferencesManager = remember { PreferencesManager(context) }
val uiDeps = remember(context) { UiDependencyAccess.get(context) }
val preferencesManager = remember(uiDeps) { uiDeps.preferencesManager() }
val notificationsEnabled by preferencesManager.notificationsEnabled.collectAsState(initial = true)
val avatarInNotifications by preferencesManager.notificationAvatarEnabled.collectAsState(initial = true)
val scope = rememberCoroutineScope()

View File

@@ -85,6 +85,7 @@ import com.rosetta.messenger.R
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.di.UiDependencyAccess
import com.rosetta.messenger.database.MessageEntity
import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.AttachmentType
@@ -255,7 +256,8 @@ fun OtherProfileScreen(
val coroutineScope = rememberCoroutineScope()
// 🔕 Mute state
val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) }
val uiDeps = remember(context) { UiDependencyAccess.get(context) }
val preferencesManager = remember(uiDeps) { uiDeps.preferencesManager() }
var notificationsEnabled by remember { mutableStateOf(true) }
// 🔥 Загружаем статус блокировки при открытии экрана
@@ -356,7 +358,7 @@ fun OtherProfileScreen(
}
// <20>🟢 Наблюдаем за онлайн статусом пользователя в реальном времени
val messageRepository = remember { MessageRepository.getInstance(context) }
val messageRepository = remember(uiDeps) { uiDeps.messageRepository() }
val onlineStatus by
messageRepository
.observeUserOnlineStatus(user.publicKey)

View File

@@ -4,10 +4,11 @@ import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.di.ProtocolGateway
import com.rosetta.messenger.di.UiDependencyAccess
import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.network.PacketResult
import com.rosetta.messenger.network.PacketUserInfo
import com.rosetta.messenger.network.ProtocolManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.collectLatest
@@ -24,7 +25,9 @@ data class ProfileState(
class ProfileViewModel(application: Application) : AndroidViewModel(application) {
private val accountManager = AccountManager(application)
private val uiDeps = UiDependencyAccess.get(application)
private val accountManager: AccountManager = uiDeps.accountManager()
private val protocolGateway: ProtocolGateway = uiDeps.protocolGateway()
private val _state = MutableStateFlow(ProfileState())
val state: StateFlow<ProfileState> = _state
@@ -34,7 +37,7 @@ class ProfileViewModel(application: Application) : AndroidViewModel(application)
init {
packetFlowJob =
viewModelScope.launch {
ProtocolManager.packetFlow(0x02).collectLatest { packet ->
protocolGateway.packetFlow(0x02).collectLatest { packet ->
val result = packet as? PacketResult ?: return@collectLatest
handlePacketResult(result)
}
@@ -98,7 +101,7 @@ class ProfileViewModel(application: Application) : AndroidViewModel(application)
// CRITICAL: Log full packet data for debugging
addLog("Sending packet to server...")
ProtocolManager.send(packet)
protocolGateway.send(packet)
addLog("Packet sent successfully")
addLog("Waiting for PacketResult (0x02) from server...")

View File

@@ -4,6 +4,7 @@ plugins {
id("org.jetbrains.kotlin.android") version "1.9.20" apply false
id("com.google.devtools.ksp") version "1.9.20-1.0.14" apply false
id("com.google.gms.google-services") version "4.4.0" apply false
id("com.google.dagger.hilt.android") version "2.51.1" apply false
}
tasks.register("clean", Delete::class) {