Архитектурный рефакторинг: единый 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("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
id("kotlin-kapt") id("kotlin-kapt")
id("com.google.dagger.hilt.android")
id("com.google.gms.google-services") id("com.google.gms.google-services")
} }
@@ -119,6 +120,10 @@ android {
} }
} }
kapt {
correctErrorTypes = true
}
dependencies { dependencies {
implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.core:core-ktx:1.12.0")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
@@ -182,6 +187,11 @@ dependencies {
implementation("androidx.room:room-ktx:2.6.1") implementation("androidx.room:room-ktx:2.6.1")
kapt("androidx.room:room-compiler: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 // Biometric authentication
implementation("androidx.biometric:biometric:1.1.0") implementation("androidx.biometric:biometric:1.1.0")

View File

@@ -61,6 +61,21 @@
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="rosetta.im" /> <data android:scheme="https" android:host="rosetta.im" />
</intent-filter> </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> </activity>
<!-- App Icon Aliases: only one enabled at a time --> <!-- 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.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.calls.CallOverlay import com.rosetta.messenger.ui.chats.calls.CallOverlay
import com.rosetta.messenger.ui.theme.RosettaAndroidTheme import com.rosetta.messenger.ui.theme.RosettaAndroidTheme
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
/** /**
* Лёгкая Activity для показа входящего звонка на lock screen. * Лёгкая Activity для показа входящего звонка на lock screen.
* Показывается поверх экрана блокировки, без auth/splash. * Показывается поверх экрана блокировки, без auth/splash.
* При Accept → переходит в MainActivity. При Decline → закрывается. * При Accept → переходит в MainActivity. При Decline → закрывается.
*/ */
@AndroidEntryPoint
class IncomingCallActivity : ComponentActivity() { class IncomingCallActivity : ComponentActivity() {
@Inject lateinit var accountManager: AccountManager
companion object { companion object {
private const val TAG = "IncomingCallActivity" private const val TAG = "IncomingCallActivity"
} }
@@ -119,7 +124,7 @@ class IncomingCallActivity : ComponentActivity() {
} }
val avatarRepository = remember { val avatarRepository = remember {
val accountKey = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty() val accountKey = accountManager.getLastLoggedPublicKey().orEmpty()
if (accountKey.isNotBlank()) { if (accountKey.isNotBlank()) {
val db = RosettaDatabase.getDatabase(applicationContext) val db = RosettaDatabase.getDatabase(applicationContext)
AvatarRepository( AvatarRepository(

View File

@@ -7,6 +7,7 @@ import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.OpenableColumns
import android.provider.Settings import android.provider.Settings
import android.view.WindowManager import android.view.WindowManager
import android.widget.Toast 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.DecryptedAccount
import com.rosetta.messenger.data.EncryptedAccount import com.rosetta.messenger.data.EncryptedAccount
import com.rosetta.messenger.data.PreferencesManager 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.crypto.CryptoManager
import com.rosetta.messenger.data.RecentSearchesManager import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.data.isPlaceholderAccountName import com.rosetta.messenger.data.isPlaceholderAccountName
import com.rosetta.messenger.data.resolveAccountDisplayName import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.database.RosettaDatabase 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.CallActionResult
import com.rosetta.messenger.network.CallPhase import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.CallManager 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.ProtocolState
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository 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.session.SessionState
import com.rosetta.messenger.ui.auth.AccountInfo import com.rosetta.messenger.ui.auth.AccountInfo
import com.rosetta.messenger.ui.auth.AuthFlow 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.splash.SplashScreen
import com.rosetta.messenger.ui.theme.RosettaAndroidTheme import com.rosetta.messenger.ui.theme.RosettaAndroidTheme
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.io.File
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
import java.util.UUID
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import javax.inject.Inject
import dagger.hilt.android.AndroidEntryPoint
@AndroidEntryPoint
class MainActivity : FragmentActivity() { class MainActivity : FragmentActivity() {
private lateinit var preferencesManager: PreferencesManager @Inject lateinit var preferencesManager: PreferencesManager
private lateinit var accountManager: AccountManager @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 // Флаг: Activity открыта для ответа на звонок с lock screen — пропускаем auth
// mutableStateOf чтобы Compose реагировал на изменение (избежать race condition) // mutableStateOf чтобы Compose реагировал на изменение (избежать race condition)
@@ -151,13 +173,12 @@ class MainActivity : FragmentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() enableEdgeToEdge()
handleCallLockScreen(intent) handleCallLockScreen(intent)
pendingSharedPayload = extractSharedPayload(intent)
preferencesManager = PreferencesManager(this)
accountManager = AccountManager(this)
RecentSearchesManager.init(this) RecentSearchesManager.init(this)
// 🔥 Инициализируем ProtocolManager для обработки онлайн статусов // 🔥 Инициализируем ProtocolManager для обработки онлайн статусов
ProtocolManager.initialize(this) protocolGateway.initialize(this)
CallManager.initialize(this) CallManager.initialize(this)
com.rosetta.messenger.ui.chats.components.AttachmentDownloadDebugLogger.init(this) com.rosetta.messenger.ui.chats.components.AttachmentDownloadDebugLogger.init(this)
@@ -237,13 +258,13 @@ class MainActivity : FragmentActivity() {
else -> true else -> true
} }
val isLoggedIn by accountManager.isLoggedIn.collectAsState(initial = null) 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 showSplash by remember { mutableStateOf(true) }
var showOnboarding by remember { mutableStateOf(true) } var showOnboarding by remember { mutableStateOf(true) }
var hasExistingAccount by remember { mutableStateOf<Boolean?>(null) } var hasExistingAccount by remember { mutableStateOf<Boolean?>(null) }
var currentAccount by remember { mutableStateOf(getCachedSessionAccount()) } var currentAccount by remember { mutableStateOf(getCachedSessionAccount()) }
val identityState by IdentityStore.state.collectAsState() val identityState by identityGateway.state.collectAsState()
val sessionState by AppSessionCoordinator.sessionState.collectAsState() val sessionState by sessionCoordinator.sessionState.collectAsState()
var accountInfoList by remember { mutableStateOf<List<AccountInfo>>(emptyList()) } var accountInfoList by remember { mutableStateOf<List<AccountInfo>>(emptyList()) }
var startCreateAccountFlow by remember { mutableStateOf(false) } var startCreateAccountFlow by remember { mutableStateOf(false) }
var preservedMainNavStack by remember { mutableStateOf<List<Screen>>(emptyList()) } var preservedMainNavStack by remember { mutableStateOf<List<Screen>>(emptyList()) }
@@ -251,7 +272,7 @@ class MainActivity : FragmentActivity() {
// Check for existing accounts and build AccountInfo list // Check for existing accounts and build AccountInfo list
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
AppSessionCoordinator.syncFromCachedAccount(currentAccount) sessionCoordinator.syncFromCachedAccount(currentAccount)
val accounts = accountManager.getAllAccounts() val accounts = accountManager.getAllAccounts()
hasExistingAccount = accounts.isNotEmpty() hasExistingAccount = accounts.isNotEmpty()
val infos = accounts.map { it.toAccountInfo() } val infos = accounts.map { it.toAccountInfo() }
@@ -302,10 +323,50 @@ class MainActivity : FragmentActivity() {
LaunchedEffect(currentAccount, isLoggedIn) { LaunchedEffect(currentAccount, isLoggedIn) {
val account = currentAccount val account = currentAccount
when { when {
account != null -> AppSessionCoordinator.markReady(account, reason = "main_activity_state") account != null -> sessionCoordinator.markReady(account, reason = "main_activity_state")
isLoggedIn == true -> isLoggedIn == true ->
AppSessionCoordinator.markAuthInProgress(reason = "main_activity_logged_in_no_account") sessionCoordinator.markAuthInProgress(reason = "main_activity_logged_in_no_account")
isLoggedIn == false -> AppSessionCoordinator.markLoggedOut(reason = "main_activity_logged_out") 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", hasExistingAccount = screen == "auth_unlock",
accounts = accountInfoList, accounts = accountInfoList,
accountManager = accountManager, accountManager = accountManager,
sessionCoordinator = sessionCoordinator,
startInCreateMode = startCreateAccountFlow, startInCreateMode = startCreateAccountFlow,
onAuthComplete = { account -> onAuthComplete = { account ->
startCreateAccountFlow = false startCreateAccountFlow = false
@@ -481,11 +543,11 @@ class MainActivity : FragmentActivity() {
currentAccount = normalizedAccount currentAccount = normalizedAccount
cacheSessionAccount(normalizedAccount) cacheSessionAccount(normalizedAccount)
normalizedAccount?.let { normalizedAccount?.let {
AppSessionCoordinator.markReady( sessionCoordinator.markReady(
account = it, account = it,
reason = "auth_complete" reason = "auth_complete"
) )
} ?: AppSessionCoordinator.markAuthInProgress( } ?: sessionCoordinator.markAuthInProgress(
reason = "auth_complete_no_account" reason = "auth_complete_no_account"
) )
hasExistingAccount = true hasExistingAccount = true
@@ -526,11 +588,10 @@ class MainActivity : FragmentActivity() {
// lag // lag
currentAccount = null currentAccount = null
clearCachedSessionAccount() clearCachedSessionAccount()
AppSessionCoordinator.markLoggedOut( sessionCoordinator.markLoggedOut(
reason = "auth_flow_logout" reason = "auth_flow_logout"
) )
com.rosetta.messenger.network.ProtocolManager protocolGateway.disconnect()
.disconnect()
scope.launch { scope.launch {
accountManager.logout() accountManager.logout()
} }
@@ -556,6 +617,11 @@ class MainActivity : FragmentActivity() {
} else { } else {
emptyList() emptyList()
}, },
accountManager = accountManager,
preferencesManager = preferencesManager,
groupRepository = groupRepository,
protocolGateway = protocolGateway,
identityGateway = identityGateway,
onNavStackChanged = { stack -> onNavStackChanged = { stack ->
if (activeAccountKey.isNotBlank()) { if (activeAccountKey.isNotBlank()) {
preservedMainNavAccountKey = activeAccountKey preservedMainNavAccountKey = activeAccountKey
@@ -581,11 +647,10 @@ class MainActivity : FragmentActivity() {
// lag // lag
currentAccount = null currentAccount = null
clearCachedSessionAccount() clearCachedSessionAccount()
AppSessionCoordinator.markLoggedOut( sessionCoordinator.markLoggedOut(
reason = "main_logout" reason = "main_logout"
) )
com.rosetta.messenger.network.ProtocolManager protocolGateway.disconnect()
.disconnect()
scope.launch { scope.launch {
accountManager.logout() accountManager.logout()
} }
@@ -609,7 +674,7 @@ class MainActivity : FragmentActivity() {
// 5. Delete account from Room DB // 5. Delete account from Room DB
database.accountDao().deleteAccount(publicKey) database.accountDao().deleteAccount(publicKey)
// 6. Disconnect protocol // 6. Disconnect protocol
com.rosetta.messenger.network.ProtocolManager.disconnect() protocolGateway.disconnect()
// 7. Delete account from AccountManager DataStore (removes from accounts list + clears login) // 7. Delete account from AccountManager DataStore (removes from accounts list + clears login)
accountManager.deleteAccount(publicKey) accountManager.deleteAccount(publicKey)
// 8. Refresh accounts list // 8. Refresh accounts list
@@ -619,7 +684,7 @@ class MainActivity : FragmentActivity() {
// 8. Navigate away last // 8. Navigate away last
currentAccount = null currentAccount = null
clearCachedSessionAccount() clearCachedSessionAccount()
AppSessionCoordinator.markLoggedOut( sessionCoordinator.markLoggedOut(
reason = "delete_current_account" reason = "delete_current_account"
) )
} catch (e: Exception) { } catch (e: Exception) {
@@ -649,7 +714,7 @@ class MainActivity : FragmentActivity() {
database.accountDao().deleteAccount(targetPublicKey) database.accountDao().deleteAccount(targetPublicKey)
// 6. Disconnect protocol only if deleting currently open account // 6. Disconnect protocol only if deleting currently open account
if (currentAccount?.publicKey == targetPublicKey) { if (currentAccount?.publicKey == targetPublicKey) {
com.rosetta.messenger.network.ProtocolManager.disconnect() protocolGateway.disconnect()
} }
// 7. Delete account from AccountManager DataStore // 7. Delete account from AccountManager DataStore
accountManager.deleteAccount(targetPublicKey) accountManager.deleteAccount(targetPublicKey)
@@ -663,7 +728,7 @@ class MainActivity : FragmentActivity() {
preservedMainNavAccountKey = "" preservedMainNavAccountKey = ""
currentAccount = null currentAccount = null
clearCachedSessionAccount() clearCachedSessionAccount()
AppSessionCoordinator.markLoggedOut( sessionCoordinator.markLoggedOut(
reason = "delete_sidebar_current_account" reason = "delete_sidebar_current_account"
) )
} }
@@ -683,10 +748,10 @@ class MainActivity : FragmentActivity() {
// Switch to another account: logout current, then show unlock. // Switch to another account: logout current, then show unlock.
currentAccount = null currentAccount = null
clearCachedSessionAccount() clearCachedSessionAccount()
AppSessionCoordinator.markLoggedOut( sessionCoordinator.markLoggedOut(
reason = "switch_account" reason = "switch_account"
) )
com.rosetta.messenger.network.ProtocolManager.disconnect() protocolGateway.disconnect()
scope.launch { scope.launch {
accountManager.logout() accountManager.logout()
} }
@@ -697,10 +762,10 @@ class MainActivity : FragmentActivity() {
preservedMainNavAccountKey = "" preservedMainNavAccountKey = ""
currentAccount = null currentAccount = null
clearCachedSessionAccount() clearCachedSessionAccount()
AppSessionCoordinator.markLoggedOut( sessionCoordinator.markLoggedOut(
reason = "add_account" reason = "add_account"
) )
com.rosetta.messenger.network.ProtocolManager.disconnect() protocolGateway.disconnect()
scope.launch { scope.launch {
accountManager.logout() accountManager.logout()
} }
@@ -715,10 +780,10 @@ class MainActivity : FragmentActivity() {
preservedMainNavAccountKey = "" preservedMainNavAccountKey = ""
currentAccount = null currentAccount = null
clearCachedSessionAccount() clearCachedSessionAccount()
AppSessionCoordinator.markLoggedOut( sessionCoordinator.markLoggedOut(
reason = "device_confirm_exit" reason = "device_confirm_exit"
) )
ProtocolManager.disconnect() protocolGateway.disconnect()
scope.launch { scope.launch {
accountManager.logout() accountManager.logout()
} }
@@ -734,7 +799,134 @@ class MainActivity : FragmentActivity() {
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) super.onNewIntent(intent)
setIntent(intent)
handleCallLockScreen(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 private var callIntentResetJob: kotlinx.coroutines.Job? = null
@@ -788,7 +980,7 @@ class MainActivity : FragmentActivity() {
// 🔔 Сбрасываем все уведомления из шторки при открытии приложения // 🔔 Сбрасываем все уведомления из шторки при открытии приложения
(getSystemService(NOTIFICATION_SERVICE) as android.app.NotificationManager).cancelAll() (getSystemService(NOTIFICATION_SERVICE) as android.app.NotificationManager).cancelAll()
// ⚡ На возврате в приложение пробуем мгновенный reconnect без ожидания backoff. // ⚡ На возврате в приложение пробуем мгновенный reconnect без ожидания backoff.
ProtocolManager.reconnectNowIfNeeded("activity_onResume") protocolGateway.reconnectNowIfNeeded("activity_onResume")
} }
override fun onPause() { override fun onPause() {
@@ -822,9 +1014,9 @@ class MainActivity : FragmentActivity() {
// Сохраняем токен локально // Сохраняем токен локально
saveFcmToken(token) saveFcmToken(token)
addFcmLog("💾 Токен сохранен локально") addFcmLog("💾 Токен сохранен локально")
if (ProtocolManager.isAuthenticated()) { if (protocolGateway.isAuthenticated()) {
runCatching { runCatching {
ProtocolManager.subscribePushTokenIfAvailable( protocolGateway.subscribePushTokenIfAvailable(
forceToken = token forceToken = token
) )
} }
@@ -926,6 +1118,11 @@ fun MainScreen(
account: DecryptedAccount? = null, account: DecryptedAccount? = null,
initialNavStack: List<Screen> = emptyList(), initialNavStack: List<Screen> = emptyList(),
onNavStackChanged: (List<Screen>) -> Unit = {}, onNavStackChanged: (List<Screen>) -> Unit = {},
accountManager: AccountManager,
preferencesManager: PreferencesManager,
groupRepository: GroupRepository,
protocolGateway: ProtocolGateway,
identityGateway: IdentityGateway,
isDarkTheme: Boolean = true, isDarkTheme: Boolean = true,
themeMode: String = "dark", themeMode: String = "dark",
onToggleTheme: () -> Unit = {}, onToggleTheme: () -> Unit = {},
@@ -955,7 +1152,7 @@ fun MainScreen(
// Following desktop version pattern: username is stored locally and loaded on app start // Following desktop version pattern: username is stored locally and loaded on app start
var accountUsername by remember { mutableStateOf("") } var accountUsername by remember { mutableStateOf("") }
var accountVerified by remember(accountPublicKey) { mutableIntStateOf(0) } 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) } var reloadTrigger by remember { mutableIntStateOf(0) }
// Load username AND name from AccountManager (persisted in DataStore) // Load username AND name from AccountManager (persisted in DataStore)
@@ -1031,11 +1228,11 @@ fun MainScreen(
return@resolve null return@resolve null
} }
ProtocolManager.getCachedUserByUsername(usernameQuery)?.let { cached -> protocolGateway.getCachedUserByUsername(usernameQuery)?.let { cached ->
if (cached.publicKey.isNotBlank()) return@resolve cached if (cached.publicKey.isNotBlank()) return@resolve cached
} }
val results = ProtocolManager.searchUsers(usernameQuery) val results = protocolGateway.searchUsers(usernameQuery)
results.firstOrNull { results.firstOrNull {
it.publicKey.isNotBlank() && it.publicKey.isNotBlank() &&
it.username.trim().trimStart('@') it.username.trim().trimStart('@')
@@ -1143,7 +1340,7 @@ fun MainScreen(
val normalizedPublicKey = accountPublicKey.trim() val normalizedPublicKey = accountPublicKey.trim()
val normalizedPrivateKey = accountPrivateKey.trim() val normalizedPrivateKey = accountPrivateKey.trim()
if (normalizedPublicKey.isBlank() || normalizedPrivateKey.isBlank()) return@LaunchedEffect if (normalizedPublicKey.isBlank() || normalizedPrivateKey.isBlank()) return@LaunchedEffect
ProtocolManager.initializeAccount(normalizedPublicKey, normalizedPrivateKey) protocolGateway.initializeAccount(normalizedPublicKey, normalizedPrivateKey)
} }
LaunchedEffect(callUiState.isVisible) { LaunchedEffect(callUiState.isVisible) {
@@ -1197,13 +1394,12 @@ fun MainScreen(
} }
suspend fun refreshAccountIdentityState(accountKey: String) { suspend fun refreshAccountIdentityState(accountKey: String) {
val accountManager = AccountManager(context)
val encryptedAccount = accountManager.getAccount(accountKey) val encryptedAccount = accountManager.getAccount(accountKey)
val identityOwn = val identityOwn =
identitySnapshot.profile?.takeIf { identitySnapshot.profile?.takeIf {
it.publicKey.equals(accountKey, ignoreCase = true) it.publicKey.equals(accountKey, ignoreCase = true)
} }
val cachedOwn = ProtocolManager.getCachedUserInfo(accountKey) val cachedOwn = protocolGateway.getCachedUserInfo(accountKey)
val persistedName = encryptedAccount?.name?.trim().orEmpty() val persistedName = encryptedAccount?.name?.trim().orEmpty()
val persistedUsername = encryptedAccount?.username?.trim().orEmpty() val persistedUsername = encryptedAccount?.username?.trim().orEmpty()
@@ -1237,7 +1433,7 @@ fun MainScreen(
accountUsername = finalUsername accountUsername = finalUsername
accountVerified = identityOwn?.verified ?: cachedOwn?.verified ?: 0 accountVerified = identityOwn?.verified ?: cachedOwn?.verified ?: 0
accountName = resolveAccountDisplayName(accountKey, preferredName, finalUsername) accountName = resolveAccountDisplayName(accountKey, preferredName, finalUsername)
IdentityStore.updateOwnProfile( identityGateway.updateOwnProfile(
publicKey = accountKey, publicKey = accountKey,
displayName = accountName, displayName = accountName,
username = accountUsername, username = accountUsername,
@@ -1271,10 +1467,10 @@ fun MainScreen(
} }
// Состояние протокола для передачи в SearchScreen // Состояние протокола для передачи в SearchScreen
val protocolState by ProtocolManager.state.collectAsState() val protocolState by protocolGateway.state.collectAsState()
// Реактивно обновляем username/name когда сервер отвечает на fetchOwnProfile() // Реактивно обновляем username/name когда сервер отвечает на fetchOwnProfile()
val ownProfileUpdated by ProtocolManager.ownProfileUpdated.collectAsState() val ownProfileUpdated by protocolGateway.ownProfileUpdated.collectAsState()
LaunchedEffect(ownProfileUpdated, accountPublicKey) { LaunchedEffect(ownProfileUpdated, accountPublicKey) {
if (ownProfileUpdated > 0L && accountPublicKey.isNotBlank()) { if (ownProfileUpdated > 0L && accountPublicKey.isNotBlank()) {
refreshAccountIdentityState(accountPublicKey) refreshAccountIdentityState(accountPublicKey)
@@ -1440,7 +1636,7 @@ fun MainScreen(
androidx.lifecycle.viewmodel.compose.viewModel() androidx.lifecycle.viewmodel.compose.viewModel()
// Appearance: background blur color preference // Appearance: background blur color preference
val prefsManager = remember { com.rosetta.messenger.data.PreferencesManager(context) } val prefsManager = preferencesManager
val backgroundBlurColorId by val backgroundBlurColorId by
prefsManager prefsManager
.backgroundBlurColorIdForAccount(accountPublicKey) .backgroundBlurColorIdForAccount(accountPublicKey)
@@ -1669,7 +1865,6 @@ fun MainScreen(
// Verify password by trying to decrypt the private key // Verify password by trying to decrypt the private key
try { try {
val publicKey = account?.publicKey ?: return@BackupScreen null val publicKey = account?.publicKey ?: return@BackupScreen null
val accountManager = AccountManager(context)
val encryptedAccount = accountManager.getAccount(publicKey) val encryptedAccount = accountManager.getAccount(publicKey)
if (encryptedAccount != null) { if (encryptedAccount != null) {
@@ -2075,7 +2270,6 @@ fun MainScreen(
val biometricPrefs = remember { val biometricPrefs = remember {
com.rosetta.messenger.biometric.BiometricPreferences(context) com.rosetta.messenger.biometric.BiometricPreferences(context)
} }
val biometricAccountManager = remember { AccountManager(context) }
val activity = context as? FragmentActivity val activity = context as? FragmentActivity
val isFingerprintSupported = remember { val isFingerprintSupported = remember {
biometricManager.isFingerprintHardwareAvailable() biometricManager.isFingerprintHardwareAvailable()
@@ -2099,7 +2293,7 @@ fun MainScreen(
// Verify password against the real account before saving // Verify password against the real account before saving
mainScreenScope.launch { mainScreenScope.launch {
val account = biometricAccountManager.getAccount(accountPublicKey) val account = accountManager.getAccount(accountPublicKey)
if (account == null) { if (account == null) {
onError("Account not found") onError("Account not found")
return@launch return@launch
@@ -2149,7 +2343,7 @@ fun MainScreen(
when (result.type) { when (result.type) {
com.rosetta.messenger.ui.qr.QrResultType.PROFILE -> { com.rosetta.messenger.ui.qr.QrResultType.PROFILE -> {
mainScreenScope.launch { mainScreenScope.launch {
val users = com.rosetta.messenger.network.ProtocolManager.searchUsers(result.payload, 5000) val users = protocolGateway.searchUsers(result.payload, 5000)
val user = users.firstOrNull() val user = users.firstOrNull()
if (user != null) { if (user != null) {
pushScreen(Screen.OtherProfile(user)) pushScreen(Screen.OtherProfile(user))
@@ -2164,8 +2358,8 @@ fun MainScreen(
} }
com.rosetta.messenger.ui.qr.QrResultType.GROUP -> { com.rosetta.messenger.ui.qr.QrResultType.GROUP -> {
mainScreenScope.launch { mainScreenScope.launch {
val groupRepo = com.rosetta.messenger.data.GroupRepository.getInstance(context) val joinResult =
val joinResult = groupRepo.joinGroup(accountPublicKey, accountPrivateKey, result.payload) groupRepository.joinGroup(accountPublicKey, accountPrivateKey, result.payload)
if (joinResult.success && !joinResult.dialogPublicKey.isNullOrBlank()) { if (joinResult.success && !joinResult.dialogPublicKey.isNullOrBlank()) {
val groupUser = com.rosetta.messenger.network.SearchUser( val groupUser = com.rosetta.messenger.network.SearchUser(
publicKey = joinResult.dialogPublicKey, publicKey = joinResult.dialogPublicKey,

View File

@@ -2,15 +2,27 @@ package com.rosetta.messenger
import android.app.Application import android.app.Application
import com.airbnb.lottie.L import com.airbnb.lottie.L
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DraftManager 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.network.TransportManager
import com.rosetta.messenger.update.UpdateManager import com.rosetta.messenger.update.UpdateManager
import com.rosetta.messenger.utils.CrashReportManager import com.rosetta.messenger.utils.CrashReportManager
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject
/** /**
* Application класс для инициализации глобальных компонентов приложения * Application класс для инициализации глобальных компонентов приложения
*/ */
@HiltAndroidApp
class RosettaApplication : Application() { class RosettaApplication : Application() {
@Inject lateinit var messageRepository: MessageRepository
@Inject lateinit var groupRepository: GroupRepository
@Inject lateinit var accountManager: AccountManager
companion object { companion object {
private const val TAG = "RosettaApplication" private const val TAG = "RosettaApplication"
@@ -33,6 +45,17 @@ class RosettaApplication : Application() {
// Инициализируем менеджер обновлений (SDU) // Инициализируем менеджер обновлений (SDU)
UpdateManager.init(this) 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.PacketGroupJoin
import com.rosetta.messenger.network.PacketGroupLeave import com.rosetta.messenger.network.PacketGroupLeave
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolManager
import dagger.hilt.android.qualifiers.ApplicationContext
import java.security.SecureRandom import java.security.SecureRandom
import java.util.UUID import java.util.UUID
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.resume 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 db = RosettaDatabase.getDatabase(context.applicationContext)
private val groupDao = db.groupDao() private val groupDao = db.groupDao()
private val messageDao = db.messageDao() 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_INVITE_PASSWORD = "rosetta_group"
private const val GROUP_WAIT_TIMEOUT_MS = 15_000L private const val GROUP_WAIT_TIMEOUT_MS = 15_000L
private const val GROUP_CREATED_MARKER = "\$a=Group created" 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( data class ParsedGroupInvite(
@@ -479,9 +476,8 @@ class GroupRepository private constructor(context: Context) {
dialogPublicKey: String dialogPublicKey: String
) { ) {
try { try {
val messages = MessageRepository.getInstance(appContext) messageRepository.initialize(accountPublicKey, accountPrivateKey)
messages.initialize(accountPublicKey, accountPrivateKey) messageRepository.sendMessage(
messages.sendMessage(
toPublicKey = dialogPublicKey, toPublicKey = dialogPublicKey,
text = GROUP_CREATED_MARKER 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.AttachmentFileManager
import com.rosetta.messenger.utils.AvatarFileManager import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.utils.MessageLogger import com.rosetta.messenger.utils.MessageLogger
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.Locale import java.util.Locale
import java.util.UUID import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import org.json.JSONArray import org.json.JSONArray
@@ -43,7 +46,10 @@ data class Dialog(
) )
/** Repository для работы с сообщениями Оптимизированная версия с кэшированием и Optimistic UI */ /** 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 database = RosettaDatabase.getDatabase(context)
private val messageDao = database.messageDao() private val messageDao = database.messageDao()
@@ -96,8 +102,6 @@ class MessageRepository private constructor(private val context: Context) {
private var currentPrivateKey: String? = null private var currentPrivateKey: String? = null
companion object { companion object {
@Volatile private var INSTANCE: MessageRepository? = null
/** Desktop parity: MESSAGE_MAX_TIME_TO_DELEVERED_S = 80 (seconds) */ /** Desktop parity: MESSAGE_MAX_TIME_TO_DELEVERED_S = 80 (seconds) */
private const val MESSAGE_MAX_TIME_TO_DELIVERED_MS = 80_000L private const val MESSAGE_MAX_TIME_TO_DELIVERED_MS = 80_000L
@@ -135,16 +139,6 @@ class MessageRepository private constructor(private val context: Context) {
/** Очистка кэша (вызывается при logout) */ /** Очистка кэша (вызывается при logout) */
fun clearProcessedCache() = processedMessageIds.clear() fun clearProcessedCache() = processedMessageIds.clear()
fun getInstance(context: Context): MessageRepository {
return INSTANCE
?: synchronized(this) {
INSTANCE
?: MessageRepository(context.applicationContext).also {
INSTANCE = it
}
}
}
/** /**
* Генерация уникального messageId 🔥 ИСПРАВЛЕНО: Используем UUID вместо детерминированного * Генерация уникального messageId 🔥 ИСПРАВЛЕНО: Используем UUID вместо детерминированного
* хэша, чтобы избежать коллизий при одинаковом timestamp (при спаме сообщениями) * хэша, чтобы избежать коллизий при одинаковом timestamp (при спаме сообщениями)
@@ -1785,6 +1779,7 @@ class MessageRepository private constructor(private val context: Context) {
put("preview", attachment.preview) put("preview", attachment.preview)
put("width", attachment.width) put("width", attachment.width)
put("height", attachment.height) put("height", attachment.height)
put("localUri", attachment.localUri)
put("transportTag", attachment.transportTag) put("transportTag", attachment.transportTag)
put("transportServer", attachment.transportServer) put("transportServer", attachment.transportServer)
} }
@@ -2029,6 +2024,7 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview) jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width) jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height) jsonObj.put("height", attachment.height)
jsonObj.put("localUri", attachment.localUri)
jsonObj.put("transportTag", attachment.transportTag) jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer) jsonObj.put("transportServer", attachment.transportServer)
} else { } else {
@@ -2039,6 +2035,7 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview) jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width) jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height) jsonObj.put("height", attachment.height)
jsonObj.put("localUri", attachment.localUri)
jsonObj.put("transportTag", attachment.transportTag) jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer) jsonObj.put("transportServer", attachment.transportServer)
} }
@@ -2050,6 +2047,7 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview) jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width) jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height) jsonObj.put("height", attachment.height)
jsonObj.put("localUri", attachment.localUri)
jsonObj.put("transportTag", attachment.transportTag) jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer) jsonObj.put("transportServer", attachment.transportServer)
} }
@@ -2061,6 +2059,7 @@ class MessageRepository private constructor(private val context: Context) {
jsonObj.put("preview", attachment.preview) jsonObj.put("preview", attachment.preview)
jsonObj.put("width", attachment.width) jsonObj.put("width", attachment.width)
jsonObj.put("height", attachment.height) jsonObj.put("height", attachment.height)
jsonObj.put("localUri", attachment.localUri)
jsonObj.put("transportTag", attachment.transportTag) jsonObj.put("transportTag", attachment.transportTag)
jsonObj.put("transportServer", attachment.transportServer) 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 androidx.core.graphics.drawable.IconCompat
import com.rosetta.messenger.MainActivity import com.rosetta.messenger.MainActivity
import com.rosetta.messenger.R import com.rosetta.messenger.R
import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.utils.AvatarFileManager import com.rosetta.messenger.utils.AvatarFileManager
import dagger.hilt.android.AndroidEntryPoint
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -34,8 +37,11 @@ import kotlinx.coroutines.runBlocking
* Keeps call alive while app goes to background. * Keeps call alive while app goes to background.
* Uses Notification.CallStyle on Android 12+ and NotificationCompat fallback on older APIs. * Uses Notification.CallStyle on Android 12+ and NotificationCompat fallback on older APIs.
*/ */
@AndroidEntryPoint
class CallForegroundService : Service() { class CallForegroundService : Service() {
@Inject lateinit var preferencesManager: PreferencesManager
private data class Snapshot( private data class Snapshot(
val phase: CallPhase, val phase: CallPhase,
val displayName: String, val displayName: String,
@@ -469,8 +475,7 @@ class CallForegroundService : Service() {
// Проверяем настройку // Проверяем настройку
val avatarEnabled = runCatching { val avatarEnabled = runCatching {
runBlocking(Dispatchers.IO) { runBlocking(Dispatchers.IO) {
com.rosetta.messenger.data.PreferencesManager(applicationContext) preferencesManager.notificationAvatarEnabled.first()
.notificationAvatarEnabled.first()
} }
}.getOrDefault(true) }.getOrDefault(true)
if (!avatarEnabled) return null if (!avatarEnabled) return null

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,7 @@
package com.rosetta.messenger.session package com.rosetta.messenger.session
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount 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.StateFlow
import kotlinx.coroutines.flow.asStateFlow
sealed interface SessionState { sealed interface SessionState {
data object LoggedOut : SessionState data object LoggedOut : SessionState
@@ -24,55 +20,30 @@ sealed interface SessionState {
* UI should rely on this state instead of scattering account checks. * UI should rely on this state instead of scattering account checks.
*/ */
object AppSessionCoordinator { object AppSessionCoordinator {
private val _sessionState = MutableStateFlow<SessionState>(SessionState.LoggedOut) val sessionState: StateFlow<SessionState> = SessionStore.state
val sessionState: StateFlow<SessionState> = _sessionState.asStateFlow()
fun dispatch(action: SessionAction) {
SessionStore.dispatch(action)
}
fun markLoggedOut(reason: String = "") { fun markLoggedOut(reason: String = "") {
_sessionState.value = SessionState.LoggedOut dispatch(SessionAction.LoggedOut(reason = reason))
IdentityStore.markLoggedOut(reason = reason)
} }
fun markAuthInProgress(publicKey: String? = null, reason: String = "") { fun markAuthInProgress(publicKey: String? = null, reason: String = "") {
_sessionState.value = SessionState.AuthInProgress(publicKey = publicKey, reason = reason) dispatch(
IdentityStore.markAuthInProgress(publicKey = publicKey, reason = reason) SessionAction.AuthInProgress(
publicKey = publicKey,
reason = reason
)
)
} }
fun markReady(account: DecryptedAccount, reason: String = "") { fun markReady(account: DecryptedAccount, reason: String = "") {
_sessionState.value = SessionState.Ready(account = account, reason = reason) dispatch(SessionAction.Ready(account = account, reason = reason))
IdentityStore.setAccount(account = account, reason = reason)
} }
fun syncFromCachedAccount(account: DecryptedAccount?) { fun syncFromCachedAccount(account: DecryptedAccount?) {
if (account == null) { dispatch(SessionAction.SyncFromCachedAccount(account = account))
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)
} }
} }

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

View File

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

View File

@@ -37,6 +37,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition import com.airbnb.lottie.compose.rememberLottieComposition
import com.rosetta.messenger.R import com.rosetta.messenger.R
import com.rosetta.messenger.di.UiDependencyAccess
import com.rosetta.messenger.network.DeviceResolveSolution import com.rosetta.messenger.network.DeviceResolveSolution
import com.rosetta.messenger.network.Packet import com.rosetta.messenger.network.Packet
import com.rosetta.messenger.network.PacketDeviceResolve import com.rosetta.messenger.network.PacketDeviceResolve
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import compose.icons.TablerIcons import compose.icons.TablerIcons
import compose.icons.tablericons.DeviceMobile import compose.icons.tablericons.DeviceMobile
@@ -66,6 +67,9 @@ fun DeviceConfirmScreen(
isDarkTheme: Boolean, isDarkTheme: Boolean,
onExit: () -> Unit onExit: () -> Unit
) { ) {
val context = LocalContext.current
val uiDeps = remember(context) { UiDependencyAccess.get(context) }
val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() }
val view = LocalView.current val view = LocalView.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
SideEffect { SideEffect {
@@ -131,9 +135,9 @@ fun DeviceConfirmScreen(
scope.launch { onExitState() } scope.launch { onExitState() }
} }
} }
ProtocolManager.waitPacket(0x18, callback) protocolGateway.waitPacket(0x18, callback)
onDispose { 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.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction 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.AccountManager
import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.data.EncryptedAccount 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 com.rosetta.messenger.ui.onboarding.PrimaryBlue
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -39,6 +38,8 @@ fun SetPasswordScreen(
seedPhrase: List<String>, seedPhrase: List<String>,
isDarkTheme: Boolean, isDarkTheme: Boolean,
isImportMode: Boolean = false, isImportMode: Boolean = false,
accountManager: AccountManager,
sessionCoordinator: SessionCoordinator,
onBack: () -> Unit, onBack: () -> Unit,
onAccountCreated: (DecryptedAccount) -> Unit onAccountCreated: (DecryptedAccount) -> Unit
) { ) {
@@ -47,8 +48,6 @@ fun SetPasswordScreen(
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93) val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93)
val fieldBackground = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7) val fieldBackground = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7)
val context = LocalContext.current
val accountManager = remember { AccountManager(context) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
var password by remember { mutableStateOf("") } var password by remember { mutableStateOf("") }
@@ -316,8 +315,7 @@ fun SetPasswordScreen(
privateKeyHash = privateKeyHash, privateKeyHash = privateKeyHash,
name = truncatedKey name = truncatedKey
) )
AppSessionCoordinator.bootstrapAuthenticatedSession( sessionCoordinator.bootstrapAuthenticatedSession(
accountManager = accountManager,
account = decryptedAccount, account = decryptedAccount,
reason = "set_password" reason = "set_password"
) )

View File

@@ -27,10 +27,9 @@ import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import coil.request.ImageRequest import coil.request.ImageRequest
import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.DecryptedAccount
import com.rosetta.messenger.di.UiDependencyAccess
import com.rosetta.messenger.network.PacketUserInfo import com.rosetta.messenger.network.PacketUserInfo
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.ui.icons.TelegramIcons import com.rosetta.messenger.ui.icons.TelegramIcons
import com.rosetta.messenger.ui.settings.ProfilePhotoPicker import com.rosetta.messenger.ui.settings.ProfilePhotoPicker
import com.rosetta.messenger.utils.AvatarFileManager import com.rosetta.messenger.utils.AvatarFileManager
@@ -75,6 +74,9 @@ fun SetProfileScreen(
onSkip: () -> Unit onSkip: () -> Unit
) { ) {
val context = LocalContext.current 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() val scope = rememberCoroutineScope()
var name by remember { mutableStateOf("") } var name by remember { mutableStateOf("") }
@@ -104,7 +106,7 @@ fun SetProfileScreen(
isCheckingUsername = true isCheckingUsername = true
delay(600) // debounce delay(600) // debounce
try { try {
val results = ProtocolManager.searchUsers(trimmed, 3000) val results = protocolGateway.searchUsers(trimmed, 3000)
val taken = results.any { it.username.equals(trimmed, ignoreCase = true) } val taken = results.any { it.username.equals(trimmed, ignoreCase = true) }
usernameAvailable = !taken usernameAvailable = !taken
} catch (_: Exception) { } catch (_: Exception) {
@@ -402,14 +404,13 @@ fun SetProfileScreen(
try { try {
// Wait for server connection (up to 8s) // Wait for server connection (up to 8s)
val connected = withTimeoutOrNull(8000) { val connected = withTimeoutOrNull(8000) {
while (!ProtocolManager.isAuthenticated()) { while (!protocolGateway.isAuthenticated()) {
delay(300) delay(300)
} }
true true
} ?: false } ?: false
// Save name and username locally first // Save name and username locally first
val accountManager = AccountManager(context)
if (name.trim().isNotEmpty()) { if (name.trim().isNotEmpty()) {
accountManager.updateAccountName(account.publicKey, name.trim()) accountManager.updateAccountName(account.publicKey, name.trim())
} }
@@ -417,7 +418,7 @@ fun SetProfileScreen(
accountManager.updateAccountUsername(account.publicKey, username.trim()) accountManager.updateAccountUsername(account.publicKey, username.trim())
} }
// Trigger UI refresh in MainActivity // Trigger UI refresh in MainActivity
ProtocolManager.notifyOwnProfileUpdated() protocolGateway.notifyOwnProfileUpdated()
// Send name and username to server // Send name and username to server
if (connected && (name.trim().isNotEmpty() || username.trim().isNotEmpty())) { if (connected && (name.trim().isNotEmpty() || username.trim().isNotEmpty())) {
@@ -425,16 +426,16 @@ fun SetProfileScreen(
packet.title = name.trim() packet.title = name.trim()
packet.username = username.trim() packet.username = username.trim()
packet.privateKey = account.privateKeyHash packet.privateKey = account.privateKeyHash
ProtocolManager.send(packet) protocolGateway.send(packet)
delay(1500) delay(1500)
// Повторяем для надёжности // Повторяем для надёжности
if (ProtocolManager.isAuthenticated()) { if (protocolGateway.isAuthenticated()) {
val packet2 = PacketUserInfo() val packet2 = PacketUserInfo()
packet2.title = name.trim() packet2.title = name.trim()
packet2.username = username.trim() packet2.username = username.trim()
packet2.privateKey = account.privateKeyHash packet2.privateKey = account.privateKeyHash
ProtocolManager.send(packet2) protocolGateway.send(packet2)
delay(500) 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.EncryptedAccount
import com.rosetta.messenger.data.resolveAccountDisplayName import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.di.SessionCoordinator
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.session.AppSessionCoordinator
import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.chats.getAvatarColor import com.rosetta.messenger.ui.chats.getAvatarColor
import com.rosetta.messenger.ui.chats.getAvatarText import com.rosetta.messenger.ui.chats.getAvatarText
@@ -69,6 +69,7 @@ private suspend fun performUnlock(
selectedAccount: AccountItem?, selectedAccount: AccountItem?,
password: String, password: String,
accountManager: AccountManager, accountManager: AccountManager,
sessionCoordinator: SessionCoordinator,
onUnlocking: (Boolean) -> Unit, onUnlocking: (Boolean) -> Unit,
onError: (String) -> Unit, onError: (String) -> Unit,
onSuccess: (DecryptedAccount) -> Unit onSuccess: (DecryptedAccount) -> Unit
@@ -117,8 +118,7 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword(
name = selectedAccount.name name = selectedAccount.name
) )
AppSessionCoordinator.bootstrapAuthenticatedSession( sessionCoordinator.bootstrapAuthenticatedSession(
accountManager = accountManager,
account = decryptedAccount, account = decryptedAccount,
reason = "unlock" reason = "unlock"
) )
@@ -134,6 +134,8 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword(
fun UnlockScreen( fun UnlockScreen(
isDarkTheme: Boolean, isDarkTheme: Boolean,
selectedAccountId: String? = null, selectedAccountId: String? = null,
accountManager: AccountManager,
sessionCoordinator: SessionCoordinator,
onUnlocked: (DecryptedAccount) -> Unit, onUnlocked: (DecryptedAccount) -> Unit,
onSwitchAccount: () -> Unit = {}, onSwitchAccount: () -> Unit = {},
onRecover: () -> Unit = {} onRecover: () -> Unit = {}
@@ -163,7 +165,6 @@ fun UnlockScreen(
val context = LocalContext.current val context = LocalContext.current
val activity = context as? FragmentActivity val activity = context as? FragmentActivity
val accountManager = remember { AccountManager(context) }
val biometricManager = remember { BiometricAuthManager(context) } val biometricManager = remember { BiometricAuthManager(context) }
val biometricPrefs = remember { BiometricPreferences(context) } val biometricPrefs = remember { BiometricPreferences(context) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -262,6 +263,7 @@ fun UnlockScreen(
selectedAccount = selectedAccount, selectedAccount = selectedAccount,
password = decryptedPassword, password = decryptedPassword,
accountManager = accountManager, accountManager = accountManager,
sessionCoordinator = sessionCoordinator,
onUnlocking = { isUnlocking = it }, onUnlocking = { isUnlocking = it },
onError = { error = it }, onError = { error = it },
onSuccess = { decryptedAccount -> onSuccess = { decryptedAccount ->
@@ -607,6 +609,7 @@ fun UnlockScreen(
selectedAccount = selectedAccount, selectedAccount = selectedAccount,
password = password, password = password,
accountManager = accountManager, accountManager = accountManager,
sessionCoordinator = sessionCoordinator,
onUnlocking = { isUnlocking = it }, onUnlocking = { isUnlocking = it },
onError = { error = it }, onError = { error = it },
onSuccess = { decryptedAccount -> 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.animateLottieCompositionAsState
import com.airbnb.lottie.compose.rememberLottieComposition import com.airbnb.lottie.compose.rememberLottieComposition
import com.rosetta.messenger.R import com.rosetta.messenger.R
import com.rosetta.messenger.di.UiDependencyAccess
import com.rosetta.messenger.data.ForwardManager import com.rosetta.messenger.data.ForwardManager
import com.rosetta.messenger.data.GroupRepository import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository 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.AttachmentType
import com.rosetta.messenger.network.CallManager import com.rosetta.messenger.network.CallManager
import com.rosetta.messenger.network.CallPhase import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.ui.chats.calls.CallTopBanner import com.rosetta.messenger.ui.chats.calls.CallTopBanner
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
@@ -342,6 +342,10 @@ fun ChatDetailScreen(
) { ) {
val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}") val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}")
val context = LocalContext.current 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) { val hasNativeNavigationBar = remember(context) {
com.rosetta.messenger.ui.utils.NavigationModeUtils.hasNativeNavigationBar(context) com.rosetta.messenger.ui.utils.NavigationModeUtils.hasNativeNavigationBar(context)
} }
@@ -354,7 +358,6 @@ fun ChatDetailScreen(
val hapticFeedback = LocalHapticFeedback.current val hapticFeedback = LocalHapticFeedback.current
// 🔇 Mute state — read from PreferencesManager // 🔇 Mute state — read from PreferencesManager
val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) }
val isChatMuted = remember { mutableStateOf(false) } val isChatMuted = remember { mutableStateOf(false) }
LaunchedEffect(currentUserPublicKey, user.publicKey) { LaunchedEffect(currentUserPublicKey, user.publicKey) {
if (currentUserPublicKey.isNotBlank()) { if (currentUserPublicKey.isNotBlank()) {
@@ -522,7 +525,7 @@ fun ChatDetailScreen(
if (normalizedPublicKey.isBlank()) return@LaunchedEffect if (normalizedPublicKey.isBlank()) return@LaunchedEffect
val cachedVerified = val cachedVerified =
ProtocolManager.getCachedUserInfo(normalizedPublicKey)?.verified ?: 0 protocolGateway.getCachedUserInfo(normalizedPublicKey)?.verified ?: 0
if (cachedVerified > chatHeaderVerified) { if (cachedVerified > chatHeaderVerified) {
chatHeaderVerified = cachedVerified chatHeaderVerified = cachedVerified
} }
@@ -733,7 +736,7 @@ fun ChatDetailScreen(
// 📨 Forward: список диалогов для выбора (загружаем из базы) // 📨 Forward: список диалогов для выбора (загружаем из базы)
val chatsListViewModel: ChatsListViewModel = viewModel() val chatsListViewModel: ChatsListViewModel = viewModel()
val dialogsList by chatsListViewModel.dialogs.collectAsState() val dialogsList by chatsListViewModel.dialogs.collectAsState()
val groupRepository = remember { GroupRepository.getInstance(context) } val groupRepository = remember(uiDeps) { uiDeps.groupRepository() }
val groupMembersCacheKey = val groupMembersCacheKey =
remember(user.publicKey, currentUserPublicKey) { remember(user.publicKey, currentUserPublicKey) {
"${currentUserPublicKey.trim()}::${user.publicKey.trim()}" "${currentUserPublicKey.trim()}::${user.publicKey.trim()}"
@@ -4251,10 +4254,7 @@ fun ChatDetailScreen(
scope.launch { scope.launch {
try { try {
if (isLeaveGroupDialog) { if (isLeaveGroupDialog) {
GroupRepository groupRepository
.getInstance(
context
)
.leaveGroup( .leaveGroup(
currentUserPublicKey, currentUserPublicKey,
user.publicKey user.publicKey
@@ -4271,11 +4271,7 @@ fun ChatDetailScreen(
} }
// 🗑️ Очищаем ВСЕ кэши сообщений // 🗑️ Очищаем ВСЕ кэши сообщений
com.rosetta.messenger.data messageRepository
.MessageRepository
.getInstance(
context
)
.clearDialogCache( .clearDialogCache(
user.publicKey user.publicKey
) )

View File

@@ -12,6 +12,8 @@ import androidx.lifecycle.viewModelScope
import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.data.ForwardManager 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.MessageEntity
import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.* import com.rosetta.messenger.network.*
@@ -121,16 +123,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
private val searchIndexDao = database.messageSearchIndexDao() private val searchIndexDao = database.messageSearchIndexDao()
private val groupDao = database.groupDao() private val groupDao = database.groupDao()
private val pinnedMessageDao = database.pinnedMessageDao() private val pinnedMessageDao = database.pinnedMessageDao()
private val uiDeps = UiDependencyAccess.get(application)
private val protocolGateway: ProtocolGateway = uiDeps.protocolGateway()
// MessageRepository для подписки на события новых сообщений // MessageRepository для подписки на события новых сообщений
private val messageRepository = private val messageRepository = uiDeps.messageRepository()
com.rosetta.messenger.data.MessageRepository.getInstance(application)
private val sendTextMessageUseCase = private val sendTextMessageUseCase =
SendTextMessageUseCase(sendWithRetry = { packet -> ProtocolManager.sendMessageWithRetry(packet) }) SendTextMessageUseCase(sendWithRetry = { packet -> protocolGateway.sendMessageWithRetry(packet) })
private val sendMediaMessageUseCase = private val sendMediaMessageUseCase =
SendMediaMessageUseCase(sendWithRetry = { packet -> ProtocolManager.sendMessageWithRetry(packet) }) SendMediaMessageUseCase(sendWithRetry = { packet -> protocolGateway.sendMessageWithRetry(packet) })
private val sendForwardUseCase = private val sendForwardUseCase =
SendForwardUseCase(sendWithRetry = { packet -> ProtocolManager.sendMessageWithRetry(packet) }) SendForwardUseCase(sendWithRetry = { packet -> protocolGateway.sendMessageWithRetry(packet) })
// 🔥 Кэш расшифрованных сообщений (messageId -> plainText) // 🔥 Кэш расшифрованных сообщений (messageId -> plainText)
private val decryptionCache = ConcurrentHashMap<String, String>() private val decryptionCache = ConcurrentHashMap<String, String>()
@@ -722,7 +725,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
typingSnapshotJob?.cancel() typingSnapshotJob?.cancel()
typingSnapshotJob = typingSnapshotJob =
viewModelScope.launch { viewModelScope.launch {
ProtocolManager.typingUsersByDialogSnapshot.collect { snapshot -> protocolGateway.typingUsersByDialogSnapshot.collect { snapshot ->
val currentDialog = opponentKey?.trim().orEmpty() val currentDialog = opponentKey?.trim().orEmpty()
val currentAccount = myPublicKey?.trim().orEmpty() val currentAccount = myPublicKey?.trim().orEmpty()
@@ -923,12 +926,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
private fun logPhotoPipeline(messageId: String, message: String) { 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) { private fun logPhotoPipelineError(messageId: String, stage: String, throwable: Throwable) {
val reason = throwable.message ?: "unknown" val reason = throwable.message ?: "unknown"
ProtocolManager.addLog( protocolGateway.addLog(
"❌ IMG ${shortPhotoId(messageId)} | $stage failed: ${throwable.javaClass.simpleName}: $reason" "❌ IMG ${shortPhotoId(messageId)} | $stage failed: ${throwable.javaClass.simpleName}: $reason"
) )
} }
@@ -1046,7 +1049,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
if (!isGroupDialogKey(normalizedPublicKey)) { if (!isGroupDialogKey(normalizedPublicKey)) {
groupKeyCache.remove(normalizedPublicKey) groupKeyCache.remove(normalizedPublicKey)
} }
ProtocolManager.addLog("🔧 SEND_CONTEXT opponent=${shortSendKey(normalizedPublicKey)}") protocolGateway.addLog("🔧 SEND_CONTEXT opponent=${shortSendKey(normalizedPublicKey)}")
triggerPendingTextSendIfReady("send_context_bound") triggerPendingTextSendIfReady("send_context_bound")
} }
@@ -1774,7 +1777,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
return nameFromMessages return nameFromMessages
} }
val cachedInfo = ProtocolManager.getCachedUserInfo(normalizedPublicKey) val cachedInfo = protocolGateway.getCachedUserInfo(normalizedPublicKey)
val protocolName = val protocolName =
cachedInfo?.title?.trim().orEmpty().ifBlank { cachedInfo?.username?.trim().orEmpty() } cachedInfo?.title?.trim().orEmpty().ifBlank { cachedInfo?.username?.trim().orEmpty() }
if (isUsableSenderName(protocolName, normalizedPublicKey)) { 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() val protocolName = cached?.title?.trim().orEmpty()
.ifBlank { cached?.username?.trim().orEmpty() } .ifBlank { cached?.username?.trim().orEmpty() }
if (isUsableSenderName(protocolName, normalizedPublicKey)) { if (isUsableSenderName(protocolName, normalizedPublicKey)) {
@@ -1842,7 +1845,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
try { try {
val resolved = ProtocolManager.resolveUserInfo(normalizedPublicKey, timeoutMs = 5000L) val resolved = protocolGateway.resolveUserInfo(normalizedPublicKey, timeoutMs = 5000L)
val name = resolved?.title?.trim().orEmpty() val name = resolved?.title?.trim().orEmpty()
.ifBlank { resolved?.username?.trim().orEmpty() } .ifBlank { resolved?.username?.trim().orEmpty() }
if (!isUsableSenderName(name, normalizedPublicKey)) { if (!isUsableSenderName(name, normalizedPublicKey)) {
@@ -2568,12 +2571,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} catch (_: Exception) { null } } catch (_: Exception) { null }
// 2. Try ProtocolManager cache (previously resolved) // 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) // 3. Server resolve via PacketSearch (like desktop useUserInformation)
val serverName = if (cachedName == null) { val serverName = if (cachedName == null) {
try { try {
ProtocolManager.resolveUserName(fwdPublicKey, 3000) protocolGateway.resolveUserName(fwdPublicKey, 3000)
} catch (_: Exception) { null } } catch (_: Exception) { null }
} else null } else null
@@ -2750,11 +2753,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
fwdDialog?.opponentTitle?.ifEmpty { fwdDialog.opponentUsername }?.ifEmpty { null } fwdDialog?.opponentTitle?.ifEmpty { fwdDialog.opponentUsername }?.ifEmpty { null }
} catch (_: Exception) { null } } catch (_: Exception) { null }
// 2. Try ProtocolManager cache // 2. Try ProtocolManager cache
val cachedName = dbName ?: ProtocolManager.getCachedUserName(replyPublicKey) val cachedName = dbName ?: protocolGateway.getCachedUserName(replyPublicKey)
// 3. Server resolve via PacketSearch (like desktop useUserInformation) // 3. Server resolve via PacketSearch (like desktop useUserInformation)
val serverName = if (cachedName == null) { val serverName = if (cachedName == null) {
try { try {
ProtocolManager.resolveUserName(replyPublicKey, 3000) protocolGateway.resolveUserName(replyPublicKey, 3000)
} catch (_: Exception) { null } } catch (_: Exception) { null }
} else null } else null
cachedName ?: serverName ?: "User" cachedName ?: serverName ?: "User"
@@ -3388,14 +3391,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
/** 🛑 Отменить исходящую отправку фото во время загрузки */ /** 🛑 Отменить исходящую отправку фото во время загрузки */
fun cancelOutgoingImageUpload(messageId: String, attachmentId: String) { fun cancelOutgoingImageUpload(messageId: String, attachmentId: String) {
ProtocolManager.addLog( protocolGateway.addLog(
"🛑 IMG cancel requested: msg=${messageId.take(8)}, att=${attachmentId.take(12)}" "🛑 IMG cancel requested: msg=${messageId.take(8)}, att=${attachmentId.take(12)}"
) )
outgoingImageUploadJobs.remove(messageId)?.cancel( outgoingImageUploadJobs.remove(messageId)?.cancel(
CancellationException("User cancelled image upload") CancellationException("User cancelled image upload")
) )
TransportManager.cancelUpload(attachmentId) TransportManager.cancelUpload(attachmentId)
ProtocolManager.resolveOutgoingRetry(messageId) protocolGateway.resolveOutgoingRetry(messageId)
deleteMessage(messageId) deleteMessage(messageId)
} }
@@ -3470,7 +3473,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} catch (_: Exception) {} } catch (_: Exception) {}
// 2. Try ProtocolManager cache // 2. Try ProtocolManager cache
val cached = ProtocolManager.getCachedUserInfo(publicKey) val cached = protocolGateway.getCachedUserInfo(publicKey)
if (cached != null) { if (cached != null) {
return SearchUser( return SearchUser(
title = cached.title, title = cached.title,
@@ -3483,7 +3486,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 3. Server resolve // 3. Server resolve
try { try {
val resolved = ProtocolManager.resolveUserInfo(publicKey, 3000) val resolved = protocolGateway.resolveUserInfo(publicKey, 3000)
if (resolved != null) { if (resolved != null) {
return SearchUser( return SearchUser(
title = resolved.title, title = resolved.title,
@@ -3532,10 +3535,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
// 2) In-memory protocol cache. // 2) In-memory protocol cache.
ProtocolManager.getCachedUserByUsername(normalized)?.let { return it } protocolGateway.getCachedUserByUsername(normalized)?.let { return it }
// 3) Server search fallback. // 3) Server search fallback.
val results = ProtocolManager.searchUsers(normalized, timeoutMs) val results = protocolGateway.searchUsers(normalized, timeoutMs)
if (results.isEmpty()) return null if (results.isEmpty()) return null
return results.firstOrNull { return results.firstOrNull {
@@ -3604,7 +3607,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
sender: String?, sender: String?,
hasPrivateKey: Boolean hasPrivateKey: Boolean
) { ) {
ProtocolManager.addLog( protocolGateway.addLog(
"⚠️ SEND_BLOCKED reason=$reason textLen=$textLength hasReply=$hasReply recipient=${shortSendKey(recipient)} sender=${shortSendKey(sender)} hasPriv=$hasPrivateKey isSending=$isSending" "⚠️ 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()) { if (repositoryPublicKey.isNotEmpty() && repositoryPrivateKey.isNotEmpty()) {
setUserKeys(repositoryPublicKey, repositoryPrivateKey) setUserKeys(repositoryPublicKey, repositoryPrivateKey)
ProtocolManager.addLog( protocolGateway.addLog(
"🔄 SEND_RECOVERY restored_keys pk=${shortSendKey(repositoryPublicKey)}" "🔄 SEND_RECOVERY restored_keys pk=${shortSendKey(repositoryPublicKey)}"
) )
} }
@@ -3633,7 +3636,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
if (pendingSendRecoveryJob?.isActive == true) return if (pendingSendRecoveryJob?.isActive == true) return
ProtocolManager.addLog("⏳ SEND_RECOVERY queued reason=$reason") protocolGateway.addLog("⏳ SEND_RECOVERY queued reason=$reason")
pendingSendRecoveryJob = pendingSendRecoveryJob =
viewModelScope.launch { viewModelScope.launch {
repeat(10) { attempt -> repeat(10) { attempt ->
@@ -3644,7 +3647,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
if (pendingTextSendRequested) { if (pendingTextSendRequested) {
ProtocolManager.addLog("⚠️ SEND_RECOVERY timeout reason=$pendingTextSendReason") protocolGateway.addLog("⚠️ SEND_RECOVERY timeout reason=$pendingTextSendReason")
} }
pendingTextSendRequested = false pendingTextSendRequested = false
pendingTextSendReason = "" pendingTextSendReason = ""
@@ -3668,7 +3671,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val keysReady = !myPublicKey.isNullOrBlank() && !myPrivateKey.isNullOrBlank() val keysReady = !myPublicKey.isNullOrBlank() && !myPrivateKey.isNullOrBlank()
if (!recipientReady || !keysReady || isSending) return if (!recipientReady || !keysReady || isSending) return
ProtocolManager.addLog("🚀 SEND_RECOVERY flush trigger=$trigger") protocolGateway.addLog("🚀 SEND_RECOVERY flush trigger=$trigger")
pendingTextSendRequested = false pendingTextSendRequested = false
pendingTextSendReason = "" pendingTextSendReason = ""
pendingSendRecoveryJob?.cancel() pendingSendRecoveryJob?.cancel()
@@ -4385,7 +4388,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val context = getApplication<Application>() val context = getApplication<Application>()
if (recipient == null || sender == null || privateKey == null) { 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})" "❌ IMG send aborted: missing keys (recipient=${recipient != null}, sender=${sender != null}, private=${privateKey != null})"
) )
return return
@@ -4986,13 +4989,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val groupDebugId = UUID.randomUUID().toString().replace("-", "").take(8) val groupDebugId = UUID.randomUUID().toString().replace("-", "").take(8)
if (recipient == null || sender == null || privateKey == null) { 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})" "❌ IMG-GROUP $groupDebugId | aborted: missing keys (recipient=${recipient != null}, sender=${sender != null}, private=${privateKey != null})"
) )
return return
} }
if (isSending) { if (isSending) {
ProtocolManager.addLog("⚠️ IMG-GROUP $groupDebugId | skipped: another send in progress") protocolGateway.addLog("⚠️ IMG-GROUP $groupDebugId | skipped: another send in progress")
return return
} }
@@ -5028,7 +5031,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
) )
_inputText.value = "" _inputText.value = ""
ProtocolManager.addLog( protocolGateway.addLog(
"📸 IMG-GROUP $groupDebugId | prepare start: count=${imageUris.size}, captionLen=${caption.trim().length}" "📸 IMG-GROUP $groupDebugId | prepare start: count=${imageUris.size}, captionLen=${caption.trim().length}"
) )
@@ -5073,7 +5076,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
opponentPublicKey = recipient opponentPublicKey = recipient
) )
} catch (_: Exception) { } 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 = val preparedImages =
@@ -5089,7 +5092,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
uri uri
) )
?: run { ?: run {
ProtocolManager.addLog( protocolGateway.addLog(
"❌ IMG-GROUP $groupDebugId | item#$index base64 conversion failed" "❌ IMG-GROUP $groupDebugId | item#$index base64 conversion failed"
) )
throw IllegalStateException( throw IllegalStateException(
@@ -5101,7 +5104,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
context, context,
uri uri
) )
ProtocolManager.addLog( protocolGateway.addLog(
"📸 IMG-GROUP $groupDebugId | item#$index prepared: ${width}x$height, base64Len=${imageBase64.length}, blurhashLen=${blurhash.length}" "📸 IMG-GROUP $groupDebugId | item#$index prepared: ${width}x$height, base64Len=${imageBase64.length}, blurhashLen=${blurhash.length}"
) )
index to index to
@@ -5114,7 +5117,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
} }
if (preparedImages.isEmpty()) { if (preparedImages.isEmpty()) {
ProtocolManager.addLog( protocolGateway.addLog(
"❌ IMG-GROUP $groupDebugId | no prepared images, send canceled" "❌ IMG-GROUP $groupDebugId | no prepared images, send canceled"
) )
updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value) updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value)
@@ -5122,7 +5125,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
isSending = false isSending = false
return@launch return@launch
} }
ProtocolManager.addLog( protocolGateway.addLog(
"📸 IMG-GROUP $groupDebugId | prepare done: ready=${preparedImages.size}" "📸 IMG-GROUP $groupDebugId | prepare done: ready=${preparedImages.size}"
) )
@@ -6653,7 +6656,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
dialogDao.markIHaveSent(account, opponent) dialogDao.markIHaveSent(account, opponent)
} }
ProtocolManager.addLog( protocolGateway.addLog(
"🛠️ DIALOG_FALLBACK upserted opponent=${opponent.take(12)} ts=$fallbackTimestamp hasContent=1" "🛠️ DIALOG_FALLBACK upserted opponent=${opponent.take(12)} ts=$fallbackTimestamp hasContent=1"
) )
} }
@@ -6920,7 +6923,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
toPublicKey = opponent toPublicKey = opponent
} }
ProtocolManager.send(packet) protocolGateway.send(packet)
} catch (e: Exception) {} } catch (e: Exception) {}
} }
} }
@@ -6964,7 +6967,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
toPublicKey = opponent // Кому отправляем уведомление (собеседник) toPublicKey = opponent // Кому отправляем уведомление (собеседник)
} }
ProtocolManager.send(packet) protocolGateway.send(packet)
// ✅ Обновляем timestamp ПОСЛЕ успешной отправки // ✅ Обновляем timestamp ПОСЛЕ успешной отправки
lastReadMessageTimestamp = incomingTs lastReadMessageTimestamp = incomingTs
MessageLogger.logReadReceiptSent(opponent) MessageLogger.logReadReceiptSent(opponent)
@@ -6973,7 +6976,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
// 🔄 Retry через 2с (desktop буферизует через WebSocket, мы ретраим вручную) // 🔄 Retry через 2с (desktop буферизует через WebSocket, мы ретраим вручную)
try { try {
kotlinx.coroutines.delay(2000) kotlinx.coroutines.delay(2000)
ProtocolManager.send( protocolGateway.send(
PacketRead().apply { PacketRead().apply {
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey) this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
fromPublicKey = sender fromPublicKey = sender
@@ -7058,7 +7061,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
addPublicKey(opponent) addPublicKey(opponent)
} }
ProtocolManager.send(packet) protocolGateway.send(packet)
} catch (e: Exception) {} } 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.RecentSearchesManager
import com.rosetta.messenger.data.isPlaceholderAccountName import com.rosetta.messenger.data.isPlaceholderAccountName
import com.rosetta.messenger.data.resolveAccountDisplayName 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.database.RosettaDatabase
import com.rosetta.messenger.network.CallPhase import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.CallUiState import com.rosetta.messenger.network.CallUiState
import com.rosetta.messenger.network.DeviceEntry import com.rosetta.messenger.network.DeviceEntry
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.calls.CallsHistoryScreen 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)}" 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() val normalized = publicKey.trim()
if (normalized.isBlank()) return "" if (normalized.isBlank()) return ""
val cached = ProtocolManager.getCachedUserInfo(normalized) val cached = protocolGateway.getCachedUserInfo(normalized)
val resolvedName = val resolvedName =
cached?.title?.trim().orEmpty().ifBlank { cached?.username?.trim().orEmpty() } cached?.title?.trim().orEmpty().ifBlank { cached?.username?.trim().orEmpty() }
return if (resolvedName.isNotBlank()) resolvedName else shortPublicKey(normalized) return if (resolvedName.isNotBlank()) resolvedName else shortPublicKey(normalized)
@@ -324,6 +325,10 @@ fun ChatsListScreen(
val view = androidx.compose.ui.platform.LocalView.current val view = androidx.compose.ui.platform.LocalView.current
val context = androidx.compose.ui.platform.LocalContext.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 focusManager = androidx.compose.ui.platform.LocalFocusManager.current
val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -456,10 +461,10 @@ fun ChatsListScreen(
remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E8E) else Color(0xFF8E8E93) } remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E8E) else Color(0xFF8E8E93) }
// Protocol connection state // Protocol connection state
val protocolState by ProtocolManager.state.collectAsState() val protocolState by protocolGateway.state.collectAsState()
val syncInProgress by ProtocolManager.syncInProgress.collectAsState() val syncInProgress by protocolGateway.syncInProgress.collectAsState()
val ownProfileUpdated by ProtocolManager.ownProfileUpdated.collectAsState() val ownProfileUpdated by protocolGateway.ownProfileUpdated.collectAsState()
val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState() val pendingDeviceVerification by protocolGateway.pendingDeviceVerification.collectAsState()
// 📥 Active FILE downloads tracking (account-scoped, excludes photo downloads) // 📥 Active FILE downloads tracking (account-scoped, excludes photo downloads)
val currentAccountKey = remember(accountPublicKey) { accountPublicKey.trim() } val currentAccountKey = remember(accountPublicKey) { accountPublicKey.trim() }
@@ -490,8 +495,8 @@ fun ChatsListScreen(
val hasActiveDownloads = activeFileDownloads.isNotEmpty() val hasActiveDownloads = activeFileDownloads.isNotEmpty()
// <20>🔥 Пользователи, которые сейчас печатают // <20>🔥 Пользователи, которые сейчас печатают
val typingUsers by ProtocolManager.typingUsers.collectAsState() val typingUsers by protocolGateway.typingUsers.collectAsState()
val typingUsersByDialogSnapshot by ProtocolManager.typingUsersByDialogSnapshot.collectAsState() val typingUsersByDialogSnapshot by protocolGateway.typingUsersByDialogSnapshot.collectAsState()
val playingVoiceAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState() val playingVoiceAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState()
val playingVoiceDialogKey by VoicePlaybackCoordinator.playingDialogKey.collectAsState() val playingVoiceDialogKey by VoicePlaybackCoordinator.playingDialogKey.collectAsState()
val isVoicePlaybackRunning by VoicePlaybackCoordinator.isPlaying.collectAsState() val isVoicePlaybackRunning by VoicePlaybackCoordinator.isPlaying.collectAsState()
@@ -622,7 +627,6 @@ fun ChatsListScreen(
// 👥 Load all accounts for sidebar (current account always first) // 👥 Load all accounts for sidebar (current account always first)
var allAccounts by remember { mutableStateOf<List<EncryptedAccount>>(emptyList()) } var allAccounts by remember { mutableStateOf<List<EncryptedAccount>>(emptyList()) }
LaunchedEffect(accountPublicKey, ownProfileUpdated) { LaunchedEffect(accountPublicKey, ownProfileUpdated) {
val accountManager = AccountManager(context)
var accounts = accountManager.getAllAccounts() var accounts = accountManager.getAllAccounts()
val preferredPublicKey = val preferredPublicKey =
accountPublicKey.trim().ifBlank { accountPublicKey.trim().ifBlank {
@@ -630,7 +634,7 @@ fun ChatsListScreen(
} }
if (preferredPublicKey.isNotBlank()) { if (preferredPublicKey.isNotBlank()) {
val cachedOwn = ProtocolManager.getCachedUserInfo(preferredPublicKey) val cachedOwn = protocolGateway.getCachedUserInfo(preferredPublicKey)
val cachedTitle = cachedOwn?.title?.trim().orEmpty() val cachedTitle = cachedOwn?.title?.trim().orEmpty()
val cachedUsername = cachedOwn?.username?.trim().orEmpty() val cachedUsername = cachedOwn?.username?.trim().orEmpty()
val existing = val existing =
@@ -711,7 +715,6 @@ fun ChatsListScreen(
val isSelectionMode = selectedChatKeys.isNotEmpty() val isSelectionMode = selectedChatKeys.isNotEmpty()
val hapticFeedback = LocalHapticFeedback.current val hapticFeedback = LocalHapticFeedback.current
var showSelectionMenu by remember { mutableStateOf(false) } var showSelectionMenu by remember { mutableStateOf(false) }
val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) }
val mutedChats by preferencesManager.mutedChatsForAccount(effectiveCurrentPublicKey) val mutedChats by preferencesManager.mutedChatsForAccount(effectiveCurrentPublicKey)
.collectAsState(initial = emptySet()) .collectAsState(initial = emptySet())
@@ -2730,6 +2733,7 @@ fun ChatsListScreen(
} else { } else {
val baseName = val baseName =
resolveTypingDisplayName( resolveTypingDisplayName(
protocolGateway,
typingSenderPublicKey typingSenderPublicKey
) )
if (baseName.isBlank()) { if (baseName.isBlank()) {
@@ -3237,12 +3241,12 @@ fun ChatsListScreen(
if (request != null) { if (request != null) {
when (request.second) { when (request.second) {
DeviceResolveAction.ACCEPT -> { DeviceResolveAction.ACCEPT -> {
ProtocolManager.acceptDevice( protocolGateway.acceptDevice(
request.first.deviceId request.first.deviceId
) )
} }
DeviceResolveAction.DECLINE -> { DeviceResolveAction.DECLINE -> {
ProtocolManager.declineDevice( protocolGateway.declineDevice(
request.first.deviceId 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.DraftManager
import com.rosetta.messenger.data.GroupRepository import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository 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.BlacklistEntity
import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.PacketOnlineSubscribe import com.rosetta.messenger.network.PacketOnlineSubscribe
import com.rosetta.messenger.network.PacketSearch import com.rosetta.messenger.network.PacketSearch
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
@@ -74,7 +75,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
private val database = RosettaDatabase.getDatabase(application) private val database = RosettaDatabase.getDatabase(application)
private val dialogDao = database.dialogDao() 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 currentAccount: String = ""
private var currentPrivateKey: String? = null private var currentPrivateKey: String? = null
@@ -215,7 +219,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
} }
if (!dialogName.isNullOrBlank()) return dialogName if (!dialogName.isNullOrBlank()) return dialogName
val cached = ProtocolManager.getCachedUserName(publicKey).orEmpty().trim() val cached = protocolGateway.getCachedUserName(publicKey).orEmpty().trim()
if (cached.isNotBlank() && cached != publicKey) { if (cached.isNotBlank() && cached != publicKey) {
return cached return cached
} }
@@ -478,7 +482,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
.getDialogsFlow(publicKey) .getDialogsFlow(publicKey)
.flowOn(Dispatchers.IO) // 🚀 Flow работает на IO .flowOn(Dispatchers.IO) // 🚀 Flow работает на IO
.debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка) .debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка)
.combine(ProtocolManager.syncInProgress) { dialogsList, syncing -> .combine(protocolGateway.syncInProgress) { dialogsList, syncing ->
dialogsList to syncing dialogsList to syncing
} }
.mapLatest { (dialogsList, syncing) -> .mapLatest { (dialogsList, syncing) ->
@@ -529,7 +533,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
.getRequestsFlow(publicKey) .getRequestsFlow(publicKey)
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.debounce(100) // 🚀 Батчим быстрые обновления .debounce(100) // 🚀 Батчим быстрые обновления
.combine(ProtocolManager.syncInProgress) { requestsList, syncing -> .combine(protocolGateway.syncInProgress) { requestsList, syncing ->
requestsList to syncing requestsList to syncing
} }
.mapLatest { (requestsList, syncing) -> .mapLatest { (requestsList, syncing) ->
@@ -553,7 +557,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
dialogDao dialogDao
.getRequestsCountFlow(publicKey) .getRequestsCountFlow(publicKey)
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.combine(ProtocolManager.syncInProgress) { count, syncing -> .combine(protocolGateway.syncInProgress) { count, syncing ->
if (syncing) 0 else count if (syncing) 0 else count
} }
.distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения
@@ -577,7 +581,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// dialogs that still have empty titles. // dialogs that still have empty titles.
launch { launch {
var wasSyncing = false var wasSyncing = false
ProtocolManager.syncInProgress.collect { syncing -> protocolGateway.syncInProgress.collect { syncing ->
if (wasSyncing && !syncing) { if (wasSyncing && !syncing) {
requestedUserInfoKeys.clear() requestedUserInfoKeys.clear()
} }
@@ -634,7 +638,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
newKeys.forEach { key -> addPublicKey(key) } newKeys.forEach { key -> addPublicKey(key) }
} }
ProtocolManager.send(packet) protocolGateway.send(packet)
} catch (e: Exception) {} } catch (e: Exception) {}
} }
} }
@@ -980,7 +984,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
} }
// 🗑️ 1. Очищаем ВСЕ кэши сообщений // 🗑️ 1. Очищаем ВСЕ кэши сообщений
MessageRepository.getInstance(getApplication()).clearDialogCache(opponentKey) messageRepository.clearDialogCache(opponentKey)
// 🗑️ Очищаем кэш ChatViewModel // 🗑️ Очищаем кэш ChatViewModel
ChatViewModel.clearCacheForOpponent(opponentKey) ChatViewModel.clearCacheForOpponent(opponentKey)
@@ -1029,7 +1033,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
_requestsCount.value = _requests.value.size _requestsCount.value = _requests.value.size
dialogsUiCache.remove(groupPublicKey) dialogsUiCache.remove(groupPublicKey)
requestsUiCache.remove(groupPublicKey) requestsUiCache.remove(groupPublicKey)
MessageRepository.getInstance(getApplication()).clearDialogCache(groupPublicKey) messageRepository.clearDialogCache(groupPublicKey)
ChatViewModel.clearCacheForOpponent(groupPublicKey) ChatViewModel.clearCacheForOpponent(groupPublicKey)
} }
left left
@@ -1104,7 +1108,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
this.privateKey = privateKeyHash this.privateKey = privateKeyHash
this.search = publicKey this.search = publicKey
} }
ProtocolManager.send(packet) protocolGateway.send(packet)
} catch (e: Exception) {} } catch (e: Exception) {}
} }
} }

View File

@@ -11,11 +11,12 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color 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.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.di.UiDependencyAccess
import com.rosetta.messenger.network.ProtocolState import com.rosetta.messenger.network.ProtocolState
import compose.icons.TablerIcons import compose.icons.TablerIcons
import compose.icons.tablericons.* import compose.icons.tablericons.*
@@ -28,9 +29,12 @@ fun ConnectionLogsScreen(
isDarkTheme: Boolean, isDarkTheme: Boolean,
onBack: () -> Unit onBack: () -> Unit
) { ) {
val logs by ProtocolManager.debugLogs.collectAsState() val context = LocalContext.current
val protocolState by ProtocolManager.getProtocol().state.collectAsState() val uiDeps = remember(context) { UiDependencyAccess.get(context) }
val syncInProgress by ProtocolManager.syncInProgress.collectAsState() 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 bgColor = if (isDarkTheme) Color(0xFF0E0E0E) else Color(0xFFF5F5F5)
val cardColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color.White val cardColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color.White
@@ -41,9 +45,9 @@ fun ConnectionLogsScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
DisposableEffect(Unit) { DisposableEffect(Unit) {
ProtocolManager.enableUILogs(true) protocolGateway.enableUILogs(true)
onDispose { onDispose {
ProtocolManager.enableUILogs(false) protocolGateway.enableUILogs(false)
} }
} }
@@ -85,7 +89,7 @@ fun ConnectionLogsScreen(
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
IconButton(onClick = { ProtocolManager.clearLogs() }) { IconButton(onClick = { protocolGateway.clearLogs() }) {
Icon( Icon(
imageVector = TablerIcons.Trash, imageVector = TablerIcons.Trash,
contentDescription = "Clear logs", 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.GroupRepository
import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.PreferencesManager 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.MessageEntity
import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.AttachmentType 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.OnlineState
import com.rosetta.messenger.network.PacketOnlineState import com.rosetta.messenger.network.PacketOnlineState
import com.rosetta.messenger.network.PacketOnlineSubscribe import com.rosetta.messenger.network.PacketOnlineSubscribe
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.chats.components.ImageAttachment import com.rosetta.messenger.ui.chats.components.ImageAttachment
@@ -323,6 +324,11 @@ fun GroupInfoScreen(
onSwipeBackEnabledChanged: (Boolean) -> Unit = {} onSwipeBackEnabledChanged: (Boolean) -> Unit = {}
) { ) {
val context = androidx.compose.ui.platform.LocalContext.current 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 view = LocalView.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.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 database = remember { RosettaDatabase.getDatabase(context) }
val groupDao = remember { database.groupDao() } val groupDao = remember { database.groupDao() }
val messageDao = remember { database.messageDao() } val messageDao = remember { database.messageDao() }
@@ -475,6 +478,7 @@ fun GroupInfoScreen(
val memberSnapshot = memberInfoByKey.toMap() val memberSnapshot = memberInfoByKey.toMap()
value = withContext(Dispatchers.Default) { value = withContext(Dispatchers.Default) {
buildGroupMediaItems( buildGroupMediaItems(
protocolGateway = protocolGateway,
messages = groupMessages, messages = groupMessages,
privateKey = currentUserPrivateKey, privateKey = currentUserPrivateKey,
currentUserPublicKey = currentUserPublicKey, currentUserPublicKey = currentUserPublicKey,
@@ -603,11 +607,11 @@ fun GroupInfoScreen(
val resolvedUsers = withContext(Dispatchers.IO) { val resolvedUsers = withContext(Dispatchers.IO) {
val resolvedMap = LinkedHashMap<String, SearchUser>() val resolvedMap = LinkedHashMap<String, SearchUser>()
members.forEach { memberKey -> members.forEach { memberKey ->
val cached = ProtocolManager.getCachedUserInfo(memberKey) val cached = protocolGateway.getCachedUserInfo(memberKey)
if (cached != null) { if (cached != null) {
resolvedMap[memberKey] = cached resolvedMap[memberKey] = cached
} else { } else {
ProtocolManager.resolveUserInfo(memberKey, timeoutMs = 2500L)?.let { resolvedUser -> protocolGateway.resolveUserInfo(memberKey, timeoutMs = 2500L)?.let { resolvedUser ->
resolvedMap[memberKey] = resolvedUser resolvedMap[memberKey] = resolvedUser
} }
} }
@@ -645,7 +649,7 @@ fun GroupInfoScreen(
val resolvedUsers = withContext(Dispatchers.IO) { val resolvedUsers = withContext(Dispatchers.IO) {
val resolvedMap = LinkedHashMap<String, SearchUser>() val resolvedMap = LinkedHashMap<String, SearchUser>()
cached.members.forEach { memberKey -> cached.members.forEach { memberKey ->
ProtocolManager.getCachedUserInfo(memberKey)?.let { resolved -> protocolGateway.getCachedUserInfo(memberKey)?.let { resolved ->
resolvedMap[memberKey] = resolved resolvedMap[memberKey] = resolved
} }
} }
@@ -685,9 +689,9 @@ fun GroupInfoScreen(
} }
DisposableEffect(dialogPublicKey) { DisposableEffect(dialogPublicKey) {
ProtocolManager.waitPacket(0x05, onlinePacketHandler) protocolGateway.waitPacket(0x05, onlinePacketHandler)
onDispose { onDispose {
ProtocolManager.unwaitPacket(0x05, onlinePacketHandler) protocolGateway.unwaitPacket(0x05, onlinePacketHandler)
} }
} }
@@ -705,7 +709,7 @@ fun GroupInfoScreen(
this.privateKey = privateKeyHash this.privateKey = privateKeyHash
keysToSubscribe.forEach { addPublicKey(it) } keysToSubscribe.forEach { addPublicKey(it) }
} }
ProtocolManager.send(packet) protocolGateway.send(packet)
} }
} catch (_: Exception) {} } catch (_: Exception) {}
} }
@@ -2357,6 +2361,7 @@ private fun parseAttachmentsForGroupInfo(attachmentsJson: String): List<MessageA
} }
private fun resolveGroupSenderName( private fun resolveGroupSenderName(
protocolGateway: ProtocolGateway,
senderPublicKey: String, senderPublicKey: String,
currentUserPublicKey: String, currentUserPublicKey: String,
memberInfoByKey: Map<String, SearchUser> memberInfoByKey: Map<String, SearchUser>
@@ -2364,13 +2369,14 @@ private fun resolveGroupSenderName(
if (senderPublicKey.isBlank()) return "Unknown" if (senderPublicKey.isBlank()) return "Unknown"
if (senderPublicKey == currentUserPublicKey) return "You" 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() } return info?.title?.takeIf { it.isNotBlank() }
?: info?.username?.takeIf { it.isNotBlank() } ?: info?.username?.takeIf { it.isNotBlank() }
?: shortPublicKey(senderPublicKey) ?: shortPublicKey(senderPublicKey)
} }
private fun buildGroupMediaItems( private fun buildGroupMediaItems(
protocolGateway: ProtocolGateway,
messages: List<MessageEntity>, messages: List<MessageEntity>,
privateKey: String, privateKey: String,
currentUserPublicKey: String, currentUserPublicKey: String,
@@ -2389,6 +2395,7 @@ private fun buildGroupMediaItems(
?: if (message.fromMe == 1) currentUserPublicKey else "" ?: if (message.fromMe == 1) currentUserPublicKey else ""
val senderCacheKey = senderKey.ifBlank { currentUserPublicKey } val senderCacheKey = senderKey.ifBlank { currentUserPublicKey }
val senderName = resolveGroupSenderName( val senderName = resolveGroupSenderName(
protocolGateway = protocolGateway,
senderPublicKey = senderCacheKey, senderPublicKey = senderCacheKey,
currentUserPublicKey = currentUserPublicKey, currentUserPublicKey = currentUserPublicKey,
memberInfoByKey = memberInfoByKey memberInfoByKey = memberInfoByKey

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,11 @@
package com.rosetta.messenger.ui.chats package com.rosetta.messenger.ui.chats
import androidx.lifecycle.ViewModel import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope 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.PacketSearch
import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
@@ -20,7 +22,9 @@ import kotlinx.coroutines.launch
* ViewModel для поиска пользователей через протокол * ViewModel для поиска пользователей через протокол
* Работает аналогично SearchBar в React Native приложении * Работает аналогично SearchBar в React Native приложении
*/ */
class SearchUsersViewModel : ViewModel() { class SearchUsersViewModel(application: Application) : AndroidViewModel(application) {
private val protocolGateway: ProtocolGateway =
UiDependencyAccess.get(application).protocolGateway()
// Состояние поиска // Состояние поиска
private val _searchQuery = MutableStateFlow("") private val _searchQuery = MutableStateFlow("")
@@ -47,7 +51,7 @@ class SearchUsersViewModel : ViewModel() {
init { init {
packetFlowJob = packetFlowJob =
viewModelScope.launch { viewModelScope.launch {
ProtocolManager.packetFlow(0x03).collectLatest { packet -> protocolGateway.packetFlow(0x03).collectLatest { packet ->
val searchPacket = packet as? PacketSearch ?: return@collectLatest val searchPacket = packet as? PacketSearch ?: return@collectLatest
logSearch( logSearch(
"📥 PacketSearch response: search='${searchPacket.search}', users=${searchPacket.users.size}" "📥 PacketSearch response: search='${searchPacket.search}', users=${searchPacket.users.size}"
@@ -118,7 +122,7 @@ class SearchUsersViewModel : ViewModel() {
} }
val effectivePrivateHash = val effectivePrivateHash =
privateKeyHash.ifBlank { ProtocolManager.getProtocol().getPrivateHash().orEmpty() } privateKeyHash.ifBlank { protocolGateway.getPrivateHash().orEmpty() }
if (effectivePrivateHash.isBlank()) { if (effectivePrivateHash.isBlank()) {
_isSearching.value = false _isSearching.value = false
logSearch("❌ Skip send: private hash is empty") logSearch("❌ Skip send: private hash is empty")
@@ -131,7 +135,7 @@ class SearchUsersViewModel : ViewModel() {
this.search = normalizedQuery this.search = normalizedQuery
} }
ProtocolManager.sendPacket(packetSearch) protocolGateway.sendPacket(packetSearch)
logSearch("📤 PacketSearch sent: '$normalizedQuery'") 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.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.PopupProperties 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.AttachmentType
import com.rosetta.messenger.network.GroupStatus import com.rosetta.messenger.network.GroupStatus
import com.rosetta.messenger.network.MessageAttachment import com.rosetta.messenger.network.MessageAttachment
@@ -1693,7 +1693,8 @@ private fun GroupInviteInlineCard(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() 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 normalizedInvite = remember(inviteText) { inviteText.trim() }
val parsedInvite = remember(normalizedInvite) { groupRepository.parseInviteString(normalizedInvite) } 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.FastOutSlowInEasing
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import com.rosetta.messenger.data.PreferencesManager import com.rosetta.messenger.di.UiDependencyAccess
/** /**
* 📷 In-App Camera Screen - как в Telegram * 📷 In-App Camera Screen - как в Telegram
@@ -91,9 +91,8 @@ fun InAppCameraScreen(
val view = LocalView.current val view = LocalView.current
val keyboardController = LocalSoftwareKeyboardController.current val keyboardController = LocalSoftwareKeyboardController.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val preferencesManager = remember(context.applicationContext) { val uiDeps = remember(context) { UiDependencyAccess.get(context) }
PreferencesManager(context.applicationContext) val preferencesManager = remember(uiDeps) { uiDeps.preferencesManager() }
}
// Camera state // Camera state
var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) } 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 compose.icons.tablericons.ChevronLeft
import com.rosetta.messenger.R import com.rosetta.messenger.R
import com.rosetta.messenger.data.PreferencesManager import com.rosetta.messenger.data.PreferencesManager
import com.rosetta.messenger.di.UiDependencyAccess
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -56,7 +57,8 @@ fun AppIconScreen(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val scope = rememberCoroutineScope() 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") } var currentIcon by remember { mutableStateOf("default") }
LaunchedEffect(Unit) { 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.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.rosetta.messenger.data.PreferencesManager import com.rosetta.messenger.di.UiDependencyAccess
import com.rosetta.messenger.ui.icons.TelegramIcons import com.rosetta.messenger.ui.icons.TelegramIcons
import compose.icons.TablerIcons import compose.icons.TablerIcons
import compose.icons.tablericons.ChevronLeft import compose.icons.tablericons.ChevronLeft
@@ -40,7 +40,8 @@ fun NotificationsScreen(
onBack: () -> Unit onBack: () -> Unit
) { ) {
val context = LocalContext.current 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 notificationsEnabled by preferencesManager.notificationsEnabled.collectAsState(initial = true)
val avatarInNotifications by preferencesManager.notificationAvatarEnabled.collectAsState(initial = true) val avatarInNotifications by preferencesManager.notificationAvatarEnabled.collectAsState(initial = true)
val scope = rememberCoroutineScope() 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.CryptoManager
import com.rosetta.messenger.crypto.MessageCrypto import com.rosetta.messenger.crypto.MessageCrypto
import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.di.UiDependencyAccess
import com.rosetta.messenger.database.MessageEntity import com.rosetta.messenger.database.MessageEntity
import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.AttachmentType
@@ -255,7 +256,8 @@ fun OtherProfileScreen(
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
// 🔕 Mute state // 🔕 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) } var notificationsEnabled by remember { mutableStateOf(true) }
// 🔥 Загружаем статус блокировки при открытии экрана // 🔥 Загружаем статус блокировки при открытии экрана
@@ -356,7 +358,7 @@ fun OtherProfileScreen(
} }
// <20>🟢 Наблюдаем за онлайн статусом пользователя в реальном времени // <20>🟢 Наблюдаем за онлайн статусом пользователя в реальном времени
val messageRepository = remember { MessageRepository.getInstance(context) } val messageRepository = remember(uiDeps) { uiDeps.messageRepository() }
val onlineStatus by val onlineStatus by
messageRepository messageRepository
.observeUserOnlineStatus(user.publicKey) .observeUserOnlineStatus(user.publicKey)

View File

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