From cedbd204c2880f759e42ab0b797644e1c3581fde Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Sat, 18 Apr 2026 18:11:21 +0500 Subject: [PATCH] =?UTF-8?q?=D0=90=D1=80=D1=85=D0=B8=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D1=83=D1=80=D0=BD=D1=8B=D0=B9=20=D1=80=D0=B5=D1=84=D0=B0?= =?UTF-8?q?=D0=BA=D1=82=D0=BE=D1=80=D0=B8=D0=BD=D0=B3:=20=D0=B5=D0=B4?= =?UTF-8?q?=D0=B8=D0=BD=D1=8B=D0=B9=20SessionStore/SessionReducer,=20Hilt?= =?UTF-8?q?=20DI=20=D0=B8=20=D0=B4=D0=B5=D0=BA=D0=BE=D0=BC=D0=BF=D0=BE?= =?UTF-8?q?=D0=B7=D0=B8=D1=86=D0=B8=D1=8F=20ProtocolManager?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 10 + app/src/main/AndroidManifest.xml | 15 + .../rosetta/messenger/IncomingCallActivity.kt | 7 +- .../com/rosetta/messenger/MainActivity.kt | 298 +++++++++++++++--- .../rosetta/messenger/RosettaApplication.kt | 23 ++ .../rosetta/messenger/data/GroupRepository.kt | 24 +- .../messenger/data/MessageRepository.kt | 25 +- .../com/rosetta/messenger/di/AppContainer.kt | 285 +++++++++++++++++ .../com/rosetta/messenger/di/UiEntryPoint.kt | 28 ++ .../network/CallForegroundService.kt | 9 +- .../rosetta/messenger/network/CallManager.kt | 21 +- .../messenger/network/ProtocolManager.kt | 62 +++- .../connection/ConnectionOrchestrator.kt | 12 +- .../connection/OwnProfileSyncService.kt | 13 +- .../push/RosettaFirebaseMessagingService.kt | 37 ++- .../session/AppSessionCoordinator.kt | 57 +--- .../messenger/session/SessionAction.kt | 19 ++ .../messenger/session/SessionReducer.kt | 27 ++ .../rosetta/messenger/session/SessionStore.kt | 51 +++ .../com/rosetta/messenger/ui/auth/AuthFlow.kt | 9 +- .../messenger/ui/auth/AuthProtocolSync.kt | 21 +- .../messenger/ui/auth/DeviceConfirmScreen.kt | 10 +- .../messenger/ui/auth/SetPasswordScreen.kt | 10 +- .../messenger/ui/auth/SetProfileScreen.kt | 19 +- .../rosetta/messenger/ui/auth/UnlockScreen.kt | 11 +- .../messenger/ui/chats/ChatDetailScreen.kt | 22 +- .../messenger/ui/chats/ChatViewModel.kt | 85 ++--- .../messenger/ui/chats/ChatsListScreen.kt | 32 +- .../messenger/ui/chats/ChatsListViewModel.kt | 26 +- .../ui/chats/ConnectionLogsScreen.kt | 18 +- .../messenger/ui/chats/GroupInfoScreen.kt | 29 +- .../messenger/ui/chats/GroupSetupScreen.kt | 14 +- .../messenger/ui/chats/RequestsListScreen.kt | 9 +- .../messenger/ui/chats/SearchScreen.kt | 15 +- .../ui/chats/SearchUsersViewModel.kt | 16 +- .../chats/components/ChatDetailComponents.kt | 5 +- .../ui/chats/components/InAppCameraScreen.kt | 7 +- .../messenger/ui/settings/AppIconScreen.kt | 4 +- .../ui/settings/NotificationsScreen.kt | 5 +- .../ui/settings/OtherProfileScreen.kt | 6 +- .../messenger/ui/settings/ProfileViewModel.kt | 11 +- build.gradle.kts | 1 + 42 files changed, 1073 insertions(+), 335 deletions(-) create mode 100644 app/src/main/java/com/rosetta/messenger/di/AppContainer.kt create mode 100644 app/src/main/java/com/rosetta/messenger/di/UiEntryPoint.kt create mode 100644 app/src/main/java/com/rosetta/messenger/session/SessionAction.kt create mode 100644 app/src/main/java/com/rosetta/messenger/session/SessionReducer.kt create mode 100644 app/src/main/java/com/rosetta/messenger/session/SessionStore.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c6f28bf..ae03992 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("com.android.application") id("org.jetbrains.kotlin.android") id("kotlin-kapt") + id("com.google.dagger.hilt.android") id("com.google.gms.google-services") } @@ -119,6 +120,10 @@ android { } } +kapt { + correctErrorTypes = true +} + dependencies { implementation("androidx.core:core-ktx:1.12.0") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2") @@ -182,6 +187,11 @@ dependencies { implementation("androidx.room:room-ktx:2.6.1") kapt("androidx.room:room-compiler:2.6.1") + // Hilt DI + implementation("com.google.dagger:hilt-android:2.51.1") + kapt("com.google.dagger:hilt-compiler:2.51.1") + implementation("androidx.hilt:hilt-navigation-compose:1.2.0") + // Biometric authentication implementation("androidx.biometric:biometric:1.1.0") diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 34ab15f..6639dde 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -61,6 +61,21 @@ + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/rosetta/messenger/IncomingCallActivity.kt b/app/src/main/java/com/rosetta/messenger/IncomingCallActivity.kt index f445622..efb90f9 100644 --- a/app/src/main/java/com/rosetta/messenger/IncomingCallActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/IncomingCallActivity.kt @@ -16,14 +16,19 @@ import com.rosetta.messenger.network.CallPhase import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.chats.calls.CallOverlay import com.rosetta.messenger.ui.theme.RosettaAndroidTheme +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject /** * Лёгкая Activity для показа входящего звонка на lock screen. * Показывается поверх экрана блокировки, без auth/splash. * При Accept → переходит в MainActivity. При Decline → закрывается. */ +@AndroidEntryPoint class IncomingCallActivity : ComponentActivity() { + @Inject lateinit var accountManager: AccountManager + companion object { private const val TAG = "IncomingCallActivity" } @@ -119,7 +124,7 @@ class IncomingCallActivity : ComponentActivity() { } val avatarRepository = remember { - val accountKey = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty() + val accountKey = accountManager.getLastLoggedPublicKey().orEmpty() if (accountKey.isNotBlank()) { val db = RosettaDatabase.getDatabase(applicationContext) AvatarRepository( diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index bd480bf..6e2fc9d 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -7,6 +7,7 @@ import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle +import android.provider.OpenableColumns import android.provider.Settings import android.view.WindowManager import android.widget.Toast @@ -47,20 +48,24 @@ import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.EncryptedAccount import com.rosetta.messenger.data.PreferencesManager +import com.rosetta.messenger.data.MessageRepository +import com.rosetta.messenger.data.GroupRepository import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.data.RecentSearchesManager import com.rosetta.messenger.data.isPlaceholderAccountName import com.rosetta.messenger.data.resolveAccountDisplayName import com.rosetta.messenger.database.RosettaDatabase +import com.rosetta.messenger.di.IdentityGateway +import com.rosetta.messenger.di.ProtocolGateway +import com.rosetta.messenger.di.SessionCoordinator import com.rosetta.messenger.network.CallActionResult import com.rosetta.messenger.network.CallPhase import com.rosetta.messenger.network.CallManager -import com.rosetta.messenger.network.ProtocolManager +import com.rosetta.messenger.network.AttachmentType +import com.rosetta.messenger.network.MessageAttachment import com.rosetta.messenger.network.ProtocolState import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.repository.AvatarRepository -import com.rosetta.messenger.session.AppSessionCoordinator -import com.rosetta.messenger.session.IdentityStore import com.rosetta.messenger.session.SessionState import com.rosetta.messenger.ui.auth.AccountInfo import com.rosetta.messenger.ui.auth.AuthFlow @@ -97,15 +102,32 @@ import com.rosetta.messenger.ui.settings.UpdatesScreen import com.rosetta.messenger.ui.splash.SplashScreen import com.rosetta.messenger.ui.theme.RosettaAndroidTheme import java.text.SimpleDateFormat +import java.io.File import java.util.Date import java.util.Locale +import java.util.UUID import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject +import dagger.hilt.android.AndroidEntryPoint +@AndroidEntryPoint class MainActivity : FragmentActivity() { - private lateinit var preferencesManager: PreferencesManager - private lateinit var accountManager: AccountManager + @Inject lateinit var preferencesManager: PreferencesManager + @Inject lateinit var accountManager: AccountManager + @Inject lateinit var messageRepository: MessageRepository + @Inject lateinit var groupRepository: GroupRepository + @Inject lateinit var protocolGateway: ProtocolGateway + @Inject lateinit var sessionCoordinator: SessionCoordinator + @Inject lateinit var identityGateway: IdentityGateway + + private data class SharedPayload( + val text: String = "", + val streamUris: List = emptyList() + ) + private var pendingSharedPayload by mutableStateOf(null) // Флаг: Activity открыта для ответа на звонок с lock screen — пропускаем auth // mutableStateOf чтобы Compose реагировал на изменение (избежать race condition) @@ -151,13 +173,12 @@ class MainActivity : FragmentActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() handleCallLockScreen(intent) + pendingSharedPayload = extractSharedPayload(intent) - preferencesManager = PreferencesManager(this) - accountManager = AccountManager(this) RecentSearchesManager.init(this) // 🔥 Инициализируем ProtocolManager для обработки онлайн статусов - ProtocolManager.initialize(this) + protocolGateway.initialize(this) CallManager.initialize(this) com.rosetta.messenger.ui.chats.components.AttachmentDownloadDebugLogger.init(this) @@ -237,13 +258,13 @@ class MainActivity : FragmentActivity() { else -> true } val isLoggedIn by accountManager.isLoggedIn.collectAsState(initial = null) - val protocolState by ProtocolManager.state.collectAsState() + val protocolState by protocolGateway.state.collectAsState() var showSplash by remember { mutableStateOf(true) } var showOnboarding by remember { mutableStateOf(true) } var hasExistingAccount by remember { mutableStateOf(null) } var currentAccount by remember { mutableStateOf(getCachedSessionAccount()) } - val identityState by IdentityStore.state.collectAsState() - val sessionState by AppSessionCoordinator.sessionState.collectAsState() + val identityState by identityGateway.state.collectAsState() + val sessionState by sessionCoordinator.sessionState.collectAsState() var accountInfoList by remember { mutableStateOf>(emptyList()) } var startCreateAccountFlow by remember { mutableStateOf(false) } var preservedMainNavStack by remember { mutableStateOf>(emptyList()) } @@ -251,7 +272,7 @@ class MainActivity : FragmentActivity() { // Check for existing accounts and build AccountInfo list LaunchedEffect(Unit) { - AppSessionCoordinator.syncFromCachedAccount(currentAccount) + sessionCoordinator.syncFromCachedAccount(currentAccount) val accounts = accountManager.getAllAccounts() hasExistingAccount = accounts.isNotEmpty() val infos = accounts.map { it.toAccountInfo() } @@ -302,10 +323,50 @@ class MainActivity : FragmentActivity() { LaunchedEffect(currentAccount, isLoggedIn) { val account = currentAccount when { - account != null -> AppSessionCoordinator.markReady(account, reason = "main_activity_state") + account != null -> sessionCoordinator.markReady(account, reason = "main_activity_state") isLoggedIn == true -> - AppSessionCoordinator.markAuthInProgress(reason = "main_activity_logged_in_no_account") - isLoggedIn == false -> AppSessionCoordinator.markLoggedOut(reason = "main_activity_logged_out") + sessionCoordinator.markAuthInProgress(reason = "main_activity_logged_in_no_account") + isLoggedIn == false -> sessionCoordinator.markLoggedOut(reason = "main_activity_logged_out") + } + } + + LaunchedEffect(pendingSharedPayload, currentAccount?.publicKey, sessionState) { + val payload = pendingSharedPayload ?: return@LaunchedEffect + val account = + currentAccount ?: (sessionState as? SessionState.Ready)?.account ?: return@LaunchedEffect + + runCatching { + val attachments = + withContext(Dispatchers.IO) { + payload.streamUris.mapNotNull { sourceUri -> + buildSharedAttachment(sourceUri) + } + } + val messageText = payload.text.trim() + if (messageText.isBlank() && attachments.isEmpty()) { + return@runCatching + } + + messageRepository.initialize(account.publicKey, account.privateKey) + messageRepository.sendMessage( + toPublicKey = account.publicKey, + text = messageText, + attachments = attachments + ) + }.onSuccess { + pendingSharedPayload = null + val hadFiles = payload.streamUris.isNotEmpty() + Toast.makeText( + this@MainActivity, + if (hadFiles) { + "Shared content saved to Saved Messages" + } else { + "Shared text saved to Saved Messages" + }, + Toast.LENGTH_SHORT + ).show() + }.onFailure { error -> + android.util.Log.e(TAG, "Failed to import shared payload", error) } } @@ -450,6 +511,7 @@ class MainActivity : FragmentActivity() { hasExistingAccount = screen == "auth_unlock", accounts = accountInfoList, accountManager = accountManager, + sessionCoordinator = sessionCoordinator, startInCreateMode = startCreateAccountFlow, onAuthComplete = { account -> startCreateAccountFlow = false @@ -481,11 +543,11 @@ class MainActivity : FragmentActivity() { currentAccount = normalizedAccount cacheSessionAccount(normalizedAccount) normalizedAccount?.let { - AppSessionCoordinator.markReady( + sessionCoordinator.markReady( account = it, reason = "auth_complete" ) - } ?: AppSessionCoordinator.markAuthInProgress( + } ?: sessionCoordinator.markAuthInProgress( reason = "auth_complete_no_account" ) hasExistingAccount = true @@ -526,11 +588,10 @@ class MainActivity : FragmentActivity() { // lag currentAccount = null clearCachedSessionAccount() - AppSessionCoordinator.markLoggedOut( + sessionCoordinator.markLoggedOut( reason = "auth_flow_logout" ) - com.rosetta.messenger.network.ProtocolManager - .disconnect() + protocolGateway.disconnect() scope.launch { accountManager.logout() } @@ -556,6 +617,11 @@ class MainActivity : FragmentActivity() { } else { emptyList() }, + accountManager = accountManager, + preferencesManager = preferencesManager, + groupRepository = groupRepository, + protocolGateway = protocolGateway, + identityGateway = identityGateway, onNavStackChanged = { stack -> if (activeAccountKey.isNotBlank()) { preservedMainNavAccountKey = activeAccountKey @@ -581,11 +647,10 @@ class MainActivity : FragmentActivity() { // lag currentAccount = null clearCachedSessionAccount() - AppSessionCoordinator.markLoggedOut( + sessionCoordinator.markLoggedOut( reason = "main_logout" ) - com.rosetta.messenger.network.ProtocolManager - .disconnect() + protocolGateway.disconnect() scope.launch { accountManager.logout() } @@ -609,7 +674,7 @@ class MainActivity : FragmentActivity() { // 5. Delete account from Room DB database.accountDao().deleteAccount(publicKey) // 6. Disconnect protocol - com.rosetta.messenger.network.ProtocolManager.disconnect() + protocolGateway.disconnect() // 7. Delete account from AccountManager DataStore (removes from accounts list + clears login) accountManager.deleteAccount(publicKey) // 8. Refresh accounts list @@ -619,7 +684,7 @@ class MainActivity : FragmentActivity() { // 8. Navigate away last currentAccount = null clearCachedSessionAccount() - AppSessionCoordinator.markLoggedOut( + sessionCoordinator.markLoggedOut( reason = "delete_current_account" ) } catch (e: Exception) { @@ -649,7 +714,7 @@ class MainActivity : FragmentActivity() { database.accountDao().deleteAccount(targetPublicKey) // 6. Disconnect protocol only if deleting currently open account if (currentAccount?.publicKey == targetPublicKey) { - com.rosetta.messenger.network.ProtocolManager.disconnect() + protocolGateway.disconnect() } // 7. Delete account from AccountManager DataStore accountManager.deleteAccount(targetPublicKey) @@ -663,7 +728,7 @@ class MainActivity : FragmentActivity() { preservedMainNavAccountKey = "" currentAccount = null clearCachedSessionAccount() - AppSessionCoordinator.markLoggedOut( + sessionCoordinator.markLoggedOut( reason = "delete_sidebar_current_account" ) } @@ -683,10 +748,10 @@ class MainActivity : FragmentActivity() { // Switch to another account: logout current, then show unlock. currentAccount = null clearCachedSessionAccount() - AppSessionCoordinator.markLoggedOut( + sessionCoordinator.markLoggedOut( reason = "switch_account" ) - com.rosetta.messenger.network.ProtocolManager.disconnect() + protocolGateway.disconnect() scope.launch { accountManager.logout() } @@ -697,10 +762,10 @@ class MainActivity : FragmentActivity() { preservedMainNavAccountKey = "" currentAccount = null clearCachedSessionAccount() - AppSessionCoordinator.markLoggedOut( + sessionCoordinator.markLoggedOut( reason = "add_account" ) - com.rosetta.messenger.network.ProtocolManager.disconnect() + protocolGateway.disconnect() scope.launch { accountManager.logout() } @@ -715,10 +780,10 @@ class MainActivity : FragmentActivity() { preservedMainNavAccountKey = "" currentAccount = null clearCachedSessionAccount() - AppSessionCoordinator.markLoggedOut( + sessionCoordinator.markLoggedOut( reason = "device_confirm_exit" ) - ProtocolManager.disconnect() + protocolGateway.disconnect() scope.launch { accountManager.logout() } @@ -734,7 +799,134 @@ class MainActivity : FragmentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) + setIntent(intent) handleCallLockScreen(intent) + pendingSharedPayload = extractSharedPayload(intent) + } + + private fun extractSharedPayload(intent: Intent?): SharedPayload? { + if (intent == null) return null + val action = intent.action ?: return null + if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) return null + + val sharedText = + ( + intent.getStringExtra(Intent.EXTRA_TEXT) + ?: intent.getCharSequenceExtra(Intent.EXTRA_TEXT)?.toString() + ?: intent.getStringExtra(Intent.EXTRA_SUBJECT) + ) + ?.trim() + .orEmpty() + + val sharedUris = + when (action) { + Intent.ACTION_SEND -> { + listOfNotNull(getParcelableUriExtra(intent, Intent.EXTRA_STREAM)) + } + Intent.ACTION_SEND_MULTIPLE -> { + getParcelableUriListExtra(intent, Intent.EXTRA_STREAM) + } + else -> emptyList() + } + + if (sharedText.isBlank() && sharedUris.isEmpty()) return null + return SharedPayload(text = sharedText, streamUris = sharedUris) + } + + private fun getParcelableUriExtra(intent: Intent, key: String): Uri? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(key, Uri::class.java) + } else { + @Suppress("DEPRECATION") + intent.getParcelableExtra(key) as? Uri + } + } + + private fun getParcelableUriListExtra(intent: Intent, key: String): List { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableArrayListExtra(key, Uri::class.java)?.filterNotNull().orEmpty() + } else { + @Suppress("DEPRECATION") + intent.getParcelableArrayListExtra(key)?.filterNotNull().orEmpty() + } + } + + private fun buildSharedAttachment(sourceUri: Uri): MessageAttachment? { + val cachedUri = copySharedUriToCache(sourceUri) ?: return null + val mimeType = contentResolver.getType(sourceUri).orEmpty().lowercase(Locale.ROOT) + val fileName = queryDisplayName(sourceUri) ?: cachedUri.lastPathSegment ?: "shared_file" + val fileSize = querySize(sourceUri) ?: runCatching { File(cachedUri.path ?: "").length() }.getOrNull() ?: 0L + val type = if (mimeType.startsWith("image/")) AttachmentType.IMAGE else AttachmentType.FILE + val preview = + if (type == AttachmentType.FILE) { + "${fileSize.coerceAtLeast(0L)}::$fileName" + } else { + "" + } + + return MessageAttachment( + id = UUID.randomUUID().toString().replace("-", "").take(32), + blob = "", + type = type, + preview = preview, + localUri = cachedUri.toString() + ) + } + + private fun copySharedUriToCache(sourceUri: Uri): Uri? { + return runCatching { + val sourceName = queryDisplayName(sourceUri).orEmpty() + val extension = + sourceName.substringAfterLast('.', "").trim().takeIf { it.isNotBlank() } + val targetDir = File(cacheDir, "shared_import").apply { mkdirs() } + val targetFileName = + buildString { + append("shared_") + append(System.currentTimeMillis()) + append('_') + append((1000..9999).random()) + if (extension != null) { + append('.') + append(extension) + } + } + val targetFile = File(targetDir, targetFileName) + + contentResolver.openInputStream(sourceUri)?.use { input -> + targetFile.outputStream().use { output -> + input.copyTo(output) + } + } ?: return null + + Uri.fromFile(targetFile) + }.getOrNull() + } + + private fun queryDisplayName(uri: Uri): String? { + return runCatching { + contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null) + ?.use { cursor -> + if (cursor.moveToFirst()) { + cursor.getString(0)?.trim()?.ifBlank { null } + } else { + null + } + } + }.getOrNull() + } + + private fun querySize(uri: Uri): Long? { + return runCatching { + contentResolver.query(uri, arrayOf(OpenableColumns.SIZE), null, null, null) + ?.use { cursor -> + if (cursor.moveToFirst()) { + val size = cursor.getLong(0) + if (size >= 0L) size else null + } else { + null + } + } + }.getOrNull() } private var callIntentResetJob: kotlinx.coroutines.Job? = null @@ -788,7 +980,7 @@ class MainActivity : FragmentActivity() { // 🔔 Сбрасываем все уведомления из шторки при открытии приложения (getSystemService(NOTIFICATION_SERVICE) as android.app.NotificationManager).cancelAll() // ⚡ На возврате в приложение пробуем мгновенный reconnect без ожидания backoff. - ProtocolManager.reconnectNowIfNeeded("activity_onResume") + protocolGateway.reconnectNowIfNeeded("activity_onResume") } override fun onPause() { @@ -822,9 +1014,9 @@ class MainActivity : FragmentActivity() { // Сохраняем токен локально saveFcmToken(token) addFcmLog("💾 Токен сохранен локально") - if (ProtocolManager.isAuthenticated()) { + if (protocolGateway.isAuthenticated()) { runCatching { - ProtocolManager.subscribePushTokenIfAvailable( + protocolGateway.subscribePushTokenIfAvailable( forceToken = token ) } @@ -926,6 +1118,11 @@ fun MainScreen( account: DecryptedAccount? = null, initialNavStack: List = emptyList(), onNavStackChanged: (List) -> Unit = {}, + accountManager: AccountManager, + preferencesManager: PreferencesManager, + groupRepository: GroupRepository, + protocolGateway: ProtocolGateway, + identityGateway: IdentityGateway, isDarkTheme: Boolean = true, themeMode: String = "dark", onToggleTheme: () -> Unit = {}, @@ -955,7 +1152,7 @@ fun MainScreen( // Following desktop version pattern: username is stored locally and loaded on app start var accountUsername by remember { mutableStateOf("") } var accountVerified by remember(accountPublicKey) { mutableIntStateOf(0) } - val identitySnapshot by IdentityStore.state.collectAsState() + val identitySnapshot by identityGateway.state.collectAsState() var reloadTrigger by remember { mutableIntStateOf(0) } // Load username AND name from AccountManager (persisted in DataStore) @@ -1031,11 +1228,11 @@ fun MainScreen( return@resolve null } - ProtocolManager.getCachedUserByUsername(usernameQuery)?.let { cached -> + protocolGateway.getCachedUserByUsername(usernameQuery)?.let { cached -> if (cached.publicKey.isNotBlank()) return@resolve cached } - val results = ProtocolManager.searchUsers(usernameQuery) + val results = protocolGateway.searchUsers(usernameQuery) results.firstOrNull { it.publicKey.isNotBlank() && it.username.trim().trimStart('@') @@ -1143,7 +1340,7 @@ fun MainScreen( val normalizedPublicKey = accountPublicKey.trim() val normalizedPrivateKey = accountPrivateKey.trim() if (normalizedPublicKey.isBlank() || normalizedPrivateKey.isBlank()) return@LaunchedEffect - ProtocolManager.initializeAccount(normalizedPublicKey, normalizedPrivateKey) + protocolGateway.initializeAccount(normalizedPublicKey, normalizedPrivateKey) } LaunchedEffect(callUiState.isVisible) { @@ -1197,13 +1394,12 @@ fun MainScreen( } suspend fun refreshAccountIdentityState(accountKey: String) { - val accountManager = AccountManager(context) val encryptedAccount = accountManager.getAccount(accountKey) val identityOwn = identitySnapshot.profile?.takeIf { it.publicKey.equals(accountKey, ignoreCase = true) } - val cachedOwn = ProtocolManager.getCachedUserInfo(accountKey) + val cachedOwn = protocolGateway.getCachedUserInfo(accountKey) val persistedName = encryptedAccount?.name?.trim().orEmpty() val persistedUsername = encryptedAccount?.username?.trim().orEmpty() @@ -1237,7 +1433,7 @@ fun MainScreen( accountUsername = finalUsername accountVerified = identityOwn?.verified ?: cachedOwn?.verified ?: 0 accountName = resolveAccountDisplayName(accountKey, preferredName, finalUsername) - IdentityStore.updateOwnProfile( + identityGateway.updateOwnProfile( publicKey = accountKey, displayName = accountName, username = accountUsername, @@ -1271,10 +1467,10 @@ fun MainScreen( } // Состояние протокола для передачи в SearchScreen - val protocolState by ProtocolManager.state.collectAsState() + val protocolState by protocolGateway.state.collectAsState() // Реактивно обновляем username/name когда сервер отвечает на fetchOwnProfile() - val ownProfileUpdated by ProtocolManager.ownProfileUpdated.collectAsState() + val ownProfileUpdated by protocolGateway.ownProfileUpdated.collectAsState() LaunchedEffect(ownProfileUpdated, accountPublicKey) { if (ownProfileUpdated > 0L && accountPublicKey.isNotBlank()) { refreshAccountIdentityState(accountPublicKey) @@ -1440,7 +1636,7 @@ fun MainScreen( androidx.lifecycle.viewmodel.compose.viewModel() // Appearance: background blur color preference - val prefsManager = remember { com.rosetta.messenger.data.PreferencesManager(context) } + val prefsManager = preferencesManager val backgroundBlurColorId by prefsManager .backgroundBlurColorIdForAccount(accountPublicKey) @@ -1669,7 +1865,6 @@ fun MainScreen( // Verify password by trying to decrypt the private key try { val publicKey = account?.publicKey ?: return@BackupScreen null - val accountManager = AccountManager(context) val encryptedAccount = accountManager.getAccount(publicKey) if (encryptedAccount != null) { @@ -2075,7 +2270,6 @@ fun MainScreen( val biometricPrefs = remember { com.rosetta.messenger.biometric.BiometricPreferences(context) } - val biometricAccountManager = remember { AccountManager(context) } val activity = context as? FragmentActivity val isFingerprintSupported = remember { biometricManager.isFingerprintHardwareAvailable() @@ -2099,7 +2293,7 @@ fun MainScreen( // Verify password against the real account before saving mainScreenScope.launch { - val account = biometricAccountManager.getAccount(accountPublicKey) + val account = accountManager.getAccount(accountPublicKey) if (account == null) { onError("Account not found") return@launch @@ -2149,7 +2343,7 @@ fun MainScreen( when (result.type) { com.rosetta.messenger.ui.qr.QrResultType.PROFILE -> { mainScreenScope.launch { - val users = com.rosetta.messenger.network.ProtocolManager.searchUsers(result.payload, 5000) + val users = protocolGateway.searchUsers(result.payload, 5000) val user = users.firstOrNull() if (user != null) { pushScreen(Screen.OtherProfile(user)) @@ -2164,8 +2358,8 @@ fun MainScreen( } com.rosetta.messenger.ui.qr.QrResultType.GROUP -> { mainScreenScope.launch { - val groupRepo = com.rosetta.messenger.data.GroupRepository.getInstance(context) - val joinResult = groupRepo.joinGroup(accountPublicKey, accountPrivateKey, result.payload) + val joinResult = + groupRepository.joinGroup(accountPublicKey, accountPrivateKey, result.payload) if (joinResult.success && !joinResult.dialogPublicKey.isNullOrBlank()) { val groupUser = com.rosetta.messenger.network.SearchUser( publicKey = joinResult.dialogPublicKey, diff --git a/app/src/main/java/com/rosetta/messenger/RosettaApplication.kt b/app/src/main/java/com/rosetta/messenger/RosettaApplication.kt index d146805..9f21509 100644 --- a/app/src/main/java/com/rosetta/messenger/RosettaApplication.kt +++ b/app/src/main/java/com/rosetta/messenger/RosettaApplication.kt @@ -2,15 +2,27 @@ package com.rosetta.messenger import android.app.Application import com.airbnb.lottie.L +import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.DraftManager +import com.rosetta.messenger.data.GroupRepository +import com.rosetta.messenger.data.MessageRepository +import com.rosetta.messenger.network.CallManager +import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.TransportManager import com.rosetta.messenger.update.UpdateManager import com.rosetta.messenger.utils.CrashReportManager +import dagger.hilt.android.HiltAndroidApp +import javax.inject.Inject /** * Application класс для инициализации глобальных компонентов приложения */ +@HiltAndroidApp class RosettaApplication : Application() { + + @Inject lateinit var messageRepository: MessageRepository + @Inject lateinit var groupRepository: GroupRepository + @Inject lateinit var accountManager: AccountManager companion object { private const val TAG = "RosettaApplication" @@ -33,6 +45,17 @@ class RosettaApplication : Application() { // Инициализируем менеджер обновлений (SDU) UpdateManager.init(this) + + // DI bootstrap for protocol internals (removes singleton-factory lookups in ProtocolManager). + ProtocolManager.bindDependencies( + messageRepository = messageRepository, + groupRepository = groupRepository, + accountManager = accountManager + ) + CallManager.bindDependencies( + messageRepository = messageRepository, + accountManager = accountManager + ) } diff --git a/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt b/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt index a0581d7..62c8137 100644 --- a/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt @@ -15,16 +15,22 @@ import com.rosetta.messenger.network.PacketGroupInviteInfo import com.rosetta.messenger.network.PacketGroupJoin import com.rosetta.messenger.network.PacketGroupLeave import com.rosetta.messenger.network.ProtocolManager +import dagger.hilt.android.qualifiers.ApplicationContext import java.security.SecureRandom import java.util.UUID import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withTimeoutOrNull import kotlin.coroutines.resume -class GroupRepository private constructor(context: Context) { +@Singleton +class GroupRepository @Inject constructor( + @ApplicationContext context: Context, + private val messageRepository: MessageRepository +) { - private val appContext = context.applicationContext private val db = RosettaDatabase.getDatabase(context.applicationContext) private val groupDao = db.groupDao() private val messageDao = db.messageDao() @@ -38,15 +44,6 @@ class GroupRepository private constructor(context: Context) { private const val GROUP_INVITE_PASSWORD = "rosetta_group" private const val GROUP_WAIT_TIMEOUT_MS = 15_000L private const val GROUP_CREATED_MARKER = "\$a=Group created" - - @Volatile - private var INSTANCE: GroupRepository? = null - - fun getInstance(context: Context): GroupRepository { - return INSTANCE ?: synchronized(this) { - INSTANCE ?: GroupRepository(context).also { INSTANCE = it } - } - } } data class ParsedGroupInvite( @@ -479,9 +476,8 @@ class GroupRepository private constructor(context: Context) { dialogPublicKey: String ) { try { - val messages = MessageRepository.getInstance(appContext) - messages.initialize(accountPublicKey, accountPrivateKey) - messages.sendMessage( + messageRepository.initialize(accountPublicKey, accountPrivateKey) + messageRepository.sendMessage( toPublicKey = dialogPublicKey, text = GROUP_CREATED_MARKER ) diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index 27a7a67..9d5cb01 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -8,8 +8,11 @@ import com.rosetta.messenger.network.* import com.rosetta.messenger.utils.AttachmentFileManager import com.rosetta.messenger.utils.AvatarFileManager import com.rosetta.messenger.utils.MessageLogger +import dagger.hilt.android.qualifiers.ApplicationContext import java.util.Locale import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton import kotlinx.coroutines.* import kotlinx.coroutines.flow.* import org.json.JSONArray @@ -43,7 +46,10 @@ data class Dialog( ) /** Repository для работы с сообщениями Оптимизированная версия с кэшированием и Optimistic UI */ -class MessageRepository private constructor(private val context: Context) { +@Singleton +class MessageRepository @Inject constructor( + @ApplicationContext private val context: Context +) { private val database = RosettaDatabase.getDatabase(context) private val messageDao = database.messageDao() @@ -96,8 +102,6 @@ class MessageRepository private constructor(private val context: Context) { private var currentPrivateKey: String? = null companion object { - @Volatile private var INSTANCE: MessageRepository? = null - /** Desktop parity: MESSAGE_MAX_TIME_TO_DELEVERED_S = 80 (seconds) */ private const val MESSAGE_MAX_TIME_TO_DELIVERED_MS = 80_000L @@ -135,16 +139,6 @@ class MessageRepository private constructor(private val context: Context) { /** Очистка кэша (вызывается при logout) */ fun clearProcessedCache() = processedMessageIds.clear() - fun getInstance(context: Context): MessageRepository { - return INSTANCE - ?: synchronized(this) { - INSTANCE - ?: MessageRepository(context.applicationContext).also { - INSTANCE = it - } - } - } - /** * Генерация уникального messageId 🔥 ИСПРАВЛЕНО: Используем UUID вместо детерминированного * хэша, чтобы избежать коллизий при одинаковом timestamp (при спаме сообщениями) @@ -1785,6 +1779,7 @@ class MessageRepository private constructor(private val context: Context) { put("preview", attachment.preview) put("width", attachment.width) put("height", attachment.height) + put("localUri", attachment.localUri) put("transportTag", attachment.transportTag) put("transportServer", attachment.transportServer) } @@ -2029,6 +2024,7 @@ class MessageRepository private constructor(private val context: Context) { jsonObj.put("preview", attachment.preview) jsonObj.put("width", attachment.width) jsonObj.put("height", attachment.height) + jsonObj.put("localUri", attachment.localUri) jsonObj.put("transportTag", attachment.transportTag) jsonObj.put("transportServer", attachment.transportServer) } else { @@ -2039,6 +2035,7 @@ class MessageRepository private constructor(private val context: Context) { jsonObj.put("preview", attachment.preview) jsonObj.put("width", attachment.width) jsonObj.put("height", attachment.height) + jsonObj.put("localUri", attachment.localUri) jsonObj.put("transportTag", attachment.transportTag) jsonObj.put("transportServer", attachment.transportServer) } @@ -2050,6 +2047,7 @@ class MessageRepository private constructor(private val context: Context) { jsonObj.put("preview", attachment.preview) jsonObj.put("width", attachment.width) jsonObj.put("height", attachment.height) + jsonObj.put("localUri", attachment.localUri) jsonObj.put("transportTag", attachment.transportTag) jsonObj.put("transportServer", attachment.transportServer) } @@ -2061,6 +2059,7 @@ class MessageRepository private constructor(private val context: Context) { jsonObj.put("preview", attachment.preview) jsonObj.put("width", attachment.width) jsonObj.put("height", attachment.height) + jsonObj.put("localUri", attachment.localUri) jsonObj.put("transportTag", attachment.transportTag) jsonObj.put("transportServer", attachment.transportServer) } diff --git a/app/src/main/java/com/rosetta/messenger/di/AppContainer.kt b/app/src/main/java/com/rosetta/messenger/di/AppContainer.kt new file mode 100644 index 0000000..6b648e5 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/di/AppContainer.kt @@ -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 + val syncInProgress: StateFlow + val pendingDeviceVerification: StateFlow + val typingUsers: StateFlow> + val typingUsersByDialogSnapshot: StateFlow>> + val debugLogs: StateFlow> + val ownProfileUpdated: StateFlow + + 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 + 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 +} + +interface SessionCoordinator { + val sessionState: StateFlow + + 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 + + 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 = ProtocolManager.state + override val syncInProgress: StateFlow = ProtocolManager.syncInProgress + override val pendingDeviceVerification: StateFlow = ProtocolManager.pendingDeviceVerification + override val typingUsers: StateFlow> = ProtocolManager.typingUsers + override val typingUsersByDialogSnapshot: StateFlow>> = + ProtocolManager.typingUsersByDialogSnapshot + override val debugLogs: StateFlow> = ProtocolManager.debugLogs + override val ownProfileUpdated: StateFlow = 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 = 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 = + ProtocolManager.searchUsers(query, timeoutMs) +} + +@Singleton +class SessionCoordinatorImpl @Inject constructor( + private val accountManager: AccountManager, + private val protocolGateway: ProtocolGateway +) : SessionCoordinator { + override val sessionState: StateFlow = 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 = 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 +} diff --git a/app/src/main/java/com/rosetta/messenger/di/UiEntryPoint.kt b/app/src/main/java/com/rosetta/messenger/di/UiEntryPoint.kt new file mode 100644 index 0000000..9410ff6 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/di/UiEntryPoint.kt @@ -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) +} diff --git a/app/src/main/java/com/rosetta/messenger/network/CallForegroundService.kt b/app/src/main/java/com/rosetta/messenger/network/CallForegroundService.kt index bad2da0..3ea2ae9 100644 --- a/app/src/main/java/com/rosetta/messenger/network/CallForegroundService.kt +++ b/app/src/main/java/com/rosetta/messenger/network/CallForegroundService.kt @@ -21,8 +21,11 @@ import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.IconCompat import com.rosetta.messenger.MainActivity import com.rosetta.messenger.R +import com.rosetta.messenger.data.PreferencesManager import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.utils.AvatarFileManager +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -34,8 +37,11 @@ import kotlinx.coroutines.runBlocking * Keeps call alive while app goes to background. * Uses Notification.CallStyle on Android 12+ and NotificationCompat fallback on older APIs. */ +@AndroidEntryPoint class CallForegroundService : Service() { + @Inject lateinit var preferencesManager: PreferencesManager + private data class Snapshot( val phase: CallPhase, val displayName: String, @@ -469,8 +475,7 @@ class CallForegroundService : Service() { // Проверяем настройку val avatarEnabled = runCatching { runBlocking(Dispatchers.IO) { - com.rosetta.messenger.data.PreferencesManager(applicationContext) - .notificationAvatarEnabled.first() + preferencesManager.notificationAvatarEnabled.first() } }.getOrDefault(true) if (!avatarEnabled) return null diff --git a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt index 97fbc6b..abdbd05 100644 --- a/app/src/main/java/com/rosetta/messenger/network/CallManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/CallManager.kt @@ -4,6 +4,7 @@ import android.content.Context import android.media.AudioManager import android.util.Log import com.rosetta.messenger.BuildConfig +import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.MessageRepository import java.security.MessageDigest import java.security.SecureRandom @@ -111,6 +112,8 @@ object CallManager { @Volatile private var initialized = false private var appContext: Context? = null + private var messageRepository: MessageRepository? = null + private var accountManager: AccountManager? = null private var ownPublicKey: String = "" private var role: CallRole? = null @@ -213,6 +216,14 @@ object CallManager { ProtocolManager.requestIceServers() } + fun bindDependencies( + messageRepository: MessageRepository, + accountManager: AccountManager + ) { + this.messageRepository = messageRepository + this.accountManager = accountManager + } + fun bindAccount(publicKey: String) { ownPublicKey = publicKey.trim() } @@ -318,7 +329,7 @@ object CallManager { // Если ownPublicKey пустой (push разбудил но аккаунт не привязан) — попробуем привязать if (ownPublicKey.isBlank()) { - val lastPk = appContext?.let { com.rosetta.messenger.data.AccountManager(it).getLastLoggedPublicKey() }.orEmpty() + val lastPk = accountManager?.getLastLoggedPublicKey().orEmpty() if (lastPk.isNotBlank()) { bindAccount(lastPk) breadcrumb("acceptIncomingCall: auto-bind account pk=${lastPk.take(8)}…") @@ -1042,7 +1053,6 @@ object CallManager { private fun emitCallAttachmentIfNeeded(snapshot: CallUiState) { val peerPublicKey = snapshot.peerPublicKey.trim() - val context = appContext ?: return if (peerPublicKey.isBlank()) return val durationSec = snapshot.durationSec.coerceAtLeast(0) @@ -1061,9 +1071,14 @@ object CallManager { scope.launch { runCatching { + val repository = messageRepository + if (repository == null) { + breadcrumb("CALL ATTACHMENT: MessageRepository not bound") + return@runCatching + } if (capturedRole == CallRole.CALLER) { // CALLER: send call attachment as a message (peer will receive it) - MessageRepository.getInstance(context).sendMessage( + repository.sendMessage( toPublicKey = peerPublicKey, text = "", attachments = listOf(callAttachment) diff --git a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt index 53096da..127a8bf 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -65,6 +65,7 @@ object ProtocolManager { @Volatile private var protocol: Protocol? = null private var messageRepository: MessageRepository? = null private var groupRepository: GroupRepository? = null + private var accountManager: AccountManager? = null private var appContext: Context? = null private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val protocolInstanceLock = Any() @@ -100,10 +101,22 @@ object ProtocolManager { waitForNetworkAndReconnect = ::waitForNetworkAndReconnect, stopWaitingForNetwork = { reason -> stopWaitingForNetwork(reason) }, getProtocol = ::getProtocol, - appContextProvider = { appContext }, + persistHandshakeCredentials = { publicKey, privateHash -> + accountManager?.setLastLoggedPublicKey(publicKey) + accountManager?.setLastLoggedPrivateKeyHash(privateHash) + }, buildHandshakeDevice = ::buildHandshakeDevice ) - private val ownProfileSyncService = OwnProfileSyncService(::isPlaceholderAccountName) + private val ownProfileSyncService = + OwnProfileSyncService( + isPlaceholderAccountName = ::isPlaceholderAccountName, + updateAccountName = { publicKey, name -> + accountManager?.updateAccountName(publicKey, name) + }, + updateAccountUsername = { publicKey, username -> + accountManager?.updateAccountUsername(publicKey, username) + } + ) private val packetRouter by lazy { PacketRouter( sendSearchPacket = { packet -> send(packet) }, @@ -239,10 +252,12 @@ object ProtocolManager { ) setSyncInProgress(false) clearTypingState() - if (messageRepository == null) { - appContext?.let { messageRepository = MessageRepository.getInstance(it) } + val repository = messageRepository + if (repository == null) { + addLog("❌ initializeAccount aborted: MessageRepository is not bound") + return } - messageRepository?.initialize(normalizedPublicKey, normalizedPrivateKey) + repository.initialize(normalizedPublicKey, normalizedPrivateKey) val sameAccount = bootstrapContext.accountPublicKey.equals(normalizedPublicKey, ignoreCase = true) @@ -601,14 +616,39 @@ object ProtocolManager { 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 */ fun initialize(context: Context) { appContext = context.applicationContext - messageRepository = MessageRepository.getInstance(context) - groupRepository = GroupRepository.getInstance(context) + if (messageRepository == null || groupRepository == null || accountManager == null) { + addLog("⚠️ initialize called before dependencies were bound via DI") + } ensureConnectionSupervisor() if (!packetHandlersRegistered) { setupPacketHandlers() @@ -854,7 +894,6 @@ object ProtocolManager { val ownProfileResolved = ownProfileSyncService.applyOwnProfileFromSearch( - appContext = appContext, ownPublicKey = ownPublicKey, user = user ) @@ -1537,8 +1576,11 @@ object ProtocolManager { preferredPublicKey: String? = null, reason: String = "background_restore" ): Boolean { - val context = appContext ?: return false - val accountManager = AccountManager(context) + val accountManager = accountManager + if (accountManager == null) { + addLog("⚠️ restoreAuthFromStoredCredentials skipped: AccountManager is not bound") + return false + } val publicKey = preferredPublicKey?.trim().orEmpty().ifBlank { accountManager.getLastLoggedPublicKey().orEmpty() diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/ConnectionOrchestrator.kt b/app/src/main/java/com/rosetta/messenger/network/connection/ConnectionOrchestrator.kt index a5e9f86..563452a 100644 --- a/app/src/main/java/com/rosetta/messenger/network/connection/ConnectionOrchestrator.kt +++ b/app/src/main/java/com/rosetta/messenger/network/connection/ConnectionOrchestrator.kt @@ -1,7 +1,5 @@ package com.rosetta.messenger.network.connection -import android.content.Context -import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.network.HandshakeDevice import com.rosetta.messenger.network.Protocol @@ -10,7 +8,7 @@ class ConnectionOrchestrator( private val waitForNetworkAndReconnect: (String) -> Unit, private val stopWaitingForNetwork: (String) -> Unit, private val getProtocol: () -> Protocol, - private val appContextProvider: () -> Context?, + private val persistHandshakeCredentials: (publicKey: String, privateHash: String) -> Unit, private val buildHandshakeDevice: () -> HandshakeDevice ) { fun handleConnect(reason: String) { @@ -32,13 +30,7 @@ class ConnectionOrchestrator( } fun handleAuthenticate(publicKey: String, privateHash: String) { - appContextProvider()?.let { context -> - runCatching { - val accountManager = AccountManager(context) - accountManager.setLastLoggedPublicKey(publicKey) - accountManager.setLastLoggedPrivateKeyHash(privateHash) - } - } + runCatching { persistHandshakeCredentials(publicKey, privateHash) } val device = buildHandshakeDevice() getProtocol().startHandshake(publicKey, privateHash, device) } diff --git a/app/src/main/java/com/rosetta/messenger/network/connection/OwnProfileSyncService.kt b/app/src/main/java/com/rosetta/messenger/network/connection/OwnProfileSyncService.kt index add8d95..1a3df94 100644 --- a/app/src/main/java/com/rosetta/messenger/network/connection/OwnProfileSyncService.kt +++ b/app/src/main/java/com/rosetta/messenger/network/connection/OwnProfileSyncService.kt @@ -1,7 +1,5 @@ package com.rosetta.messenger.network.connection -import android.content.Context -import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.network.PacketSearch import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.session.IdentityStore @@ -10,7 +8,9 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow class OwnProfileSyncService( - private val isPlaceholderAccountName: (String?) -> Boolean + private val isPlaceholderAccountName: (String?) -> Boolean, + private val updateAccountName: suspend (publicKey: String, name: String) -> Unit, + private val updateAccountUsername: suspend (publicKey: String, username: String) -> Unit ) { private val _ownProfileUpdated = MutableStateFlow(0L) val ownProfileUpdated: StateFlow = _ownProfileUpdated.asStateFlow() @@ -20,20 +20,17 @@ class OwnProfileSyncService( } suspend fun applyOwnProfileFromSearch( - appContext: Context?, ownPublicKey: String, user: SearchUser ): Boolean { if (ownPublicKey.isBlank()) return false if (!user.publicKey.equals(ownPublicKey, ignoreCase = true)) return false - val context = appContext ?: return true - val accountManager = AccountManager(context) if (user.title.isNotBlank() && !isPlaceholderAccountName(user.title)) { - accountManager.updateAccountName(ownPublicKey, user.title) + updateAccountName(ownPublicKey, user.title) } if (user.username.isNotBlank()) { - accountManager.updateAccountUsername(ownPublicKey, user.username) + updateAccountUsername(ownPublicKey, user.username) } IdentityStore.updateOwnProfile(user, reason = "protocol_search_own_profile") _ownProfileUpdated.value = System.currentTimeMillis() diff --git a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt index d39a1f8..5f0bfeb 100644 --- a/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt +++ b/app/src/main/java/com/rosetta/messenger/push/RosettaFirebaseMessagingService.kt @@ -19,12 +19,14 @@ import com.rosetta.messenger.R import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.PreferencesManager import com.rosetta.messenger.database.RosettaDatabase +import com.rosetta.messenger.di.ProtocolGateway import com.rosetta.messenger.network.CallForegroundService import com.rosetta.messenger.network.CallManager import com.rosetta.messenger.network.CallPhase import com.rosetta.messenger.network.CallUiState -import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.utils.AvatarFileManager +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -40,8 +42,13 @@ import java.util.Locale * - Получение push-уведомлений о новых сообщениях * - Отображение уведомлений */ +@AndroidEntryPoint class RosettaFirebaseMessagingService : FirebaseMessagingService() { + @Inject lateinit var accountManager: AccountManager + @Inject lateinit var preferencesManager: PreferencesManager + @Inject lateinit var protocolGateway: ProtocolGateway + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) companion object { @@ -121,8 +128,8 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { // Best-effort: если соединение уже авторизовано — сразу обновляем подписку на push. // Используем единую точку отправки в ProtocolManager (с дедупликацией). - if (ProtocolManager.isAuthenticated()) { - runCatching { ProtocolManager.subscribePushTokenIfAvailable(forceToken = token) } + if (protocolGateway.isAuthenticated()) { + runCatching { protocolGateway.subscribePushTokenIfAvailable(forceToken = token) } } } @@ -148,7 +155,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { if (!hasDataContent && !hasNotificationContent) { Log.d(TAG, "Silent/empty push ignored (iOS wake-up push)") // Still trigger reconnect if WebSocket is disconnected - com.rosetta.messenger.network.ProtocolManager.reconnectNowIfNeeded("silent_push") + protocolGateway.reconnectNowIfNeeded("silent_push") return } @@ -514,18 +521,18 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { /** Пробуждаем сетевой слой по high-priority push, чтобы быстрее получить сигнальные пакеты */ private fun wakeProtocolFromPush(reason: String) { runCatching { - val account = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty() - ProtocolManager.initialize(applicationContext) + val account = accountManager.getLastLoggedPublicKey().orEmpty() + protocolGateway.initialize(applicationContext) CallManager.initialize(applicationContext) if (account.isNotBlank()) { CallManager.bindAccount(account) } - val restored = ProtocolManager.restoreAuthFromStoredCredentials( + val restored = protocolGateway.restoreAuthFromStoredCredentials( preferredPublicKey = account, reason = "push_$reason" ) pushCallLog("wakeProtocolFromPush: authRestore=$restored account=${account.take(8)}…") - ProtocolManager.reconnectNowIfNeeded("push_$reason") + protocolGateway.reconnectNowIfNeeded("push_$reason") }.onFailure { error -> Log.w(TAG, "wakeProtocolFromPush failed: ${error.message}") } @@ -560,7 +567,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { private fun areNotificationsEnabled(): Boolean { return runCatching { runBlocking(Dispatchers.IO) { - PreferencesManager(applicationContext).notificationsEnabled.first() + preferencesManager.notificationsEnabled.first() } }.getOrDefault(true) } @@ -583,7 +590,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { parsedDialogKey: String?, parsedSenderKey: String? ): Set { - val currentAccount = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty().trim() + val currentAccount = accountManager.getLastLoggedPublicKey().orEmpty().trim() val candidates = linkedSetOf() fun addCandidate(raw: String?) { @@ -726,7 +733,7 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { private fun isAvatarInNotificationsEnabled(): Boolean { return runCatching { runBlocking(Dispatchers.IO) { - PreferencesManager(applicationContext).notificationAvatarEnabled.first() + preferencesManager.notificationAvatarEnabled.first() } }.getOrDefault(true) } @@ -735,12 +742,10 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { private fun isDialogMuted(senderPublicKey: String): Boolean { if (senderPublicKey.isBlank()) return false return runCatching { - val accountManager = AccountManager(applicationContext) val currentAccount = accountManager.getLastLoggedPublicKey().orEmpty() runBlocking(Dispatchers.IO) { - val preferences = PreferencesManager(applicationContext) buildDialogKeyVariants(senderPublicKey).any { key -> - preferences.isChatMuted(currentAccount, key) + preferencesManager.isChatMuted(currentAccount, key) } } }.getOrDefault(false) @@ -750,10 +755,10 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() { private fun resolveNameForKey(publicKey: String?): String? { if (publicKey.isNullOrBlank()) return null // 1. In-memory cache - ProtocolManager.getCachedUserName(publicKey)?.let { return it } + protocolGateway.getCachedUserName(publicKey)?.let { return it } // 2. DB dialogs table return runCatching { - val account = AccountManager(applicationContext).getLastLoggedPublicKey().orEmpty() + val account = accountManager.getLastLoggedPublicKey().orEmpty() if (account.isBlank()) return null val db = RosettaDatabase.getDatabase(applicationContext) val dialog = runBlocking(Dispatchers.IO) { diff --git a/app/src/main/java/com/rosetta/messenger/session/AppSessionCoordinator.kt b/app/src/main/java/com/rosetta/messenger/session/AppSessionCoordinator.kt index a7001f7..288335d 100644 --- a/app/src/main/java/com/rosetta/messenger/session/AppSessionCoordinator.kt +++ b/app/src/main/java/com/rosetta/messenger/session/AppSessionCoordinator.kt @@ -1,11 +1,7 @@ package com.rosetta.messenger.session -import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.DecryptedAccount -import com.rosetta.messenger.network.ProtocolManager -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow sealed interface SessionState { data object LoggedOut : SessionState @@ -24,55 +20,30 @@ sealed interface SessionState { * UI should rely on this state instead of scattering account checks. */ object AppSessionCoordinator { - private val _sessionState = MutableStateFlow(SessionState.LoggedOut) - val sessionState: StateFlow = _sessionState.asStateFlow() + val sessionState: StateFlow = SessionStore.state + + fun dispatch(action: SessionAction) { + SessionStore.dispatch(action) + } fun markLoggedOut(reason: String = "") { - _sessionState.value = SessionState.LoggedOut - IdentityStore.markLoggedOut(reason = reason) + dispatch(SessionAction.LoggedOut(reason = reason)) } fun markAuthInProgress(publicKey: String? = null, reason: String = "") { - _sessionState.value = SessionState.AuthInProgress(publicKey = publicKey, reason = reason) - IdentityStore.markAuthInProgress(publicKey = publicKey, reason = reason) + dispatch( + SessionAction.AuthInProgress( + publicKey = publicKey, + reason = reason + ) + ) } fun markReady(account: DecryptedAccount, reason: String = "") { - _sessionState.value = SessionState.Ready(account = account, reason = reason) - IdentityStore.setAccount(account = account, reason = reason) + dispatch(SessionAction.Ready(account = account, reason = reason)) } fun syncFromCachedAccount(account: DecryptedAccount?) { - if (account == null) { - if (_sessionState.value is SessionState.Ready) { - _sessionState.value = SessionState.LoggedOut - } - IdentityStore.markLoggedOut(reason = "cached_account_cleared") - return - } - _sessionState.value = SessionState.Ready(account = account, reason = "cached") - IdentityStore.setAccount(account = account, reason = "cached") - } - - /** - * Unified bootstrap used by registration and unlock flows. - * Keeps protocol/account initialization sequence in one place. - */ - suspend fun bootstrapAuthenticatedSession( - accountManager: AccountManager, - account: DecryptedAccount, - reason: String - ) { - markAuthInProgress(publicKey = account.publicKey, reason = reason) - - // Initialize storage-bound account context before handshake completes - // to avoid early sync/message race conditions. - ProtocolManager.initializeAccount(account.publicKey, account.privateKey) - ProtocolManager.connect() - ProtocolManager.authenticate(account.publicKey, account.privateKeyHash) - ProtocolManager.reconnectNowIfNeeded("session_bootstrap_$reason") - - accountManager.setCurrentAccount(account.publicKey) - markReady(account, reason = reason) + dispatch(SessionAction.SyncFromCachedAccount(account = account)) } } diff --git a/app/src/main/java/com/rosetta/messenger/session/SessionAction.kt b/app/src/main/java/com/rosetta/messenger/session/SessionAction.kt new file mode 100644 index 0000000..0b18171 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/session/SessionAction.kt @@ -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 +} diff --git a/app/src/main/java/com/rosetta/messenger/session/SessionReducer.kt b/app/src/main/java/com/rosetta/messenger/session/SessionReducer.kt new file mode 100644 index 0000000..4463b45 --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/session/SessionReducer.kt @@ -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") + } + } + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/session/SessionStore.kt b/app/src/main/java/com/rosetta/messenger/session/SessionStore.kt new file mode 100644 index 0000000..a57adff --- /dev/null +++ b/app/src/main/java/com/rosetta/messenger/session/SessionStore.kt @@ -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.LoggedOut) + val state: StateFlow = _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") + } + } + } + } +} diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt index 215c2b8..60e6062 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/AuthFlow.kt @@ -8,7 +8,7 @@ import androidx.compose.ui.platform.LocalView import androidx.core.view.WindowCompat import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.AccountManager -import com.rosetta.messenger.session.AppSessionCoordinator +import com.rosetta.messenger.di.SessionCoordinator enum class AuthScreen { SELECT_ACCOUNT, @@ -28,6 +28,7 @@ fun AuthFlow( hasExistingAccount: Boolean, accounts: List = emptyList(), accountManager: AccountManager, + sessionCoordinator: SessionCoordinator, startInCreateMode: Boolean = false, onAuthComplete: (DecryptedAccount?) -> Unit, onLogout: () -> Unit = {} @@ -64,7 +65,7 @@ fun AuthFlow( var isImportMode by remember { mutableStateOf(false) } LaunchedEffect(currentScreen, selectedAccountId) { - AppSessionCoordinator.markAuthInProgress( + sessionCoordinator.markAuthInProgress( publicKey = selectedAccountId, reason = "auth_flow_${currentScreen.name.lowercase()}" ) @@ -177,6 +178,8 @@ fun AuthFlow( seedPhrase = seedPhrase, isDarkTheme = isDarkTheme, isImportMode = isImportMode, + accountManager = accountManager, + sessionCoordinator = sessionCoordinator, onBack = { if (isImportMode) { currentScreen = AuthScreen.IMPORT_SEED @@ -236,6 +239,8 @@ fun AuthFlow( UnlockScreen( isDarkTheme = isDarkTheme, selectedAccountId = selectedAccountId, + accountManager = accountManager, + sessionCoordinator = sessionCoordinator, onUnlocked = { account -> onAuthComplete(account) }, onSwitchAccount = { // Navigate to create new account screen diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/AuthProtocolSync.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/AuthProtocolSync.kt index 805a591..8facff4 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/AuthProtocolSync.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/AuthProtocolSync.kt @@ -1,28 +1,33 @@ package com.rosetta.messenger.ui.auth -import com.rosetta.messenger.network.ProtocolManager +import com.rosetta.messenger.di.ProtocolGateway import com.rosetta.messenger.network.ProtocolState import kotlinx.coroutines.flow.first import kotlinx.coroutines.withTimeoutOrNull -internal fun startAuthHandshakeFast(publicKey: String, privateKeyHash: String) { +internal fun startAuthHandshakeFast( + protocolGateway: ProtocolGateway, + publicKey: String, + privateKeyHash: String +) { // Desktop parity: start connection+handshake immediately, without artificial waits. - ProtocolManager.connect() - ProtocolManager.authenticate(publicKey, privateKeyHash) - ProtocolManager.reconnectNowIfNeeded("auth_fast_start") + protocolGateway.connect() + protocolGateway.authenticate(publicKey, privateKeyHash) + protocolGateway.reconnectNowIfNeeded("auth_fast_start") } internal suspend fun awaitAuthHandshakeState( + protocolGateway: ProtocolGateway, publicKey: String, privateKeyHash: String, attempts: Int = 2, timeoutMs: Long = 25_000L ): ProtocolState? { repeat(attempts) { attempt -> - startAuthHandshakeFast(publicKey, privateKeyHash) + startAuthHandshakeFast(protocolGateway, publicKey, privateKeyHash) val state = withTimeoutOrNull(timeoutMs) { - ProtocolManager.state.first { + protocolGateway.state.first { it == ProtocolState.AUTHENTICATED || it == ProtocolState.DEVICE_VERIFICATION_REQUIRED } @@ -30,7 +35,7 @@ internal suspend fun awaitAuthHandshakeState( if (state != null) { return state } - ProtocolManager.reconnectNowIfNeeded("auth_handshake_retry_${attempt + 1}") + protocolGateway.reconnectNowIfNeeded("auth_handshake_retry_${attempt + 1}") } return null } diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/DeviceConfirmScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/DeviceConfirmScreen.kt index d82920a..0bddd8d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/DeviceConfirmScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/DeviceConfirmScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.SideEffect import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -52,10 +53,10 @@ import com.airbnb.lottie.compose.LottieConstants import com.airbnb.lottie.compose.animateLottieCompositionAsState import com.airbnb.lottie.compose.rememberLottieComposition import com.rosetta.messenger.R +import com.rosetta.messenger.di.UiDependencyAccess import com.rosetta.messenger.network.DeviceResolveSolution import com.rosetta.messenger.network.Packet import com.rosetta.messenger.network.PacketDeviceResolve -import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.ui.onboarding.PrimaryBlue import compose.icons.TablerIcons import compose.icons.tablericons.DeviceMobile @@ -66,6 +67,9 @@ fun DeviceConfirmScreen( isDarkTheme: Boolean, onExit: () -> Unit ) { + val context = LocalContext.current + val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() } val view = LocalView.current if (!view.isInEditMode) { SideEffect { @@ -131,9 +135,9 @@ fun DeviceConfirmScreen( scope.launch { onExitState() } } } - ProtocolManager.waitPacket(0x18, callback) + protocolGateway.waitPacket(0x18, callback) onDispose { - ProtocolManager.unwaitPacket(0x18, callback) + protocolGateway.unwaitPacket(0x18, callback) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt index 3ec341a..50548d3 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SetPasswordScreen.kt @@ -15,7 +15,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalView import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction @@ -29,7 +28,7 @@ import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.EncryptedAccount -import com.rosetta.messenger.session.AppSessionCoordinator +import com.rosetta.messenger.di.SessionCoordinator import com.rosetta.messenger.ui.onboarding.PrimaryBlue import kotlinx.coroutines.launch @@ -39,6 +38,8 @@ fun SetPasswordScreen( seedPhrase: List, isDarkTheme: Boolean, isImportMode: Boolean = false, + accountManager: AccountManager, + sessionCoordinator: SessionCoordinator, onBack: () -> Unit, onAccountCreated: (DecryptedAccount) -> Unit ) { @@ -47,8 +48,6 @@ fun SetPasswordScreen( val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF8E8E93) val fieldBackground = if (isDarkTheme) Color(0xFF2C2C2E) else Color(0xFFF2F2F7) - val context = LocalContext.current - val accountManager = remember { AccountManager(context) } val scope = rememberCoroutineScope() var password by remember { mutableStateOf("") } @@ -316,8 +315,7 @@ fun SetPasswordScreen( privateKeyHash = privateKeyHash, name = truncatedKey ) - AppSessionCoordinator.bootstrapAuthenticatedSession( - accountManager = accountManager, + sessionCoordinator.bootstrapAuthenticatedSession( account = decryptedAccount, reason = "set_password" ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/SetProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/SetProfileScreen.kt index 722499f..abf79c3 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/SetProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/SetProfileScreen.kt @@ -27,10 +27,9 @@ import androidx.compose.ui.unit.sp import coil.compose.AsyncImage import coil.request.ImageRequest import com.rosetta.messenger.crypto.CryptoManager -import com.rosetta.messenger.data.AccountManager import com.rosetta.messenger.data.DecryptedAccount +import com.rosetta.messenger.di.UiDependencyAccess import com.rosetta.messenger.network.PacketUserInfo -import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.ui.icons.TelegramIcons import com.rosetta.messenger.ui.settings.ProfilePhotoPicker import com.rosetta.messenger.utils.AvatarFileManager @@ -75,6 +74,9 @@ fun SetProfileScreen( onSkip: () -> Unit ) { val context = LocalContext.current + val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() } + val accountManager = remember(uiDeps) { uiDeps.accountManager() } val scope = rememberCoroutineScope() var name by remember { mutableStateOf("") } @@ -104,7 +106,7 @@ fun SetProfileScreen( isCheckingUsername = true delay(600) // debounce try { - val results = ProtocolManager.searchUsers(trimmed, 3000) + val results = protocolGateway.searchUsers(trimmed, 3000) val taken = results.any { it.username.equals(trimmed, ignoreCase = true) } usernameAvailable = !taken } catch (_: Exception) { @@ -402,14 +404,13 @@ fun SetProfileScreen( try { // Wait for server connection (up to 8s) val connected = withTimeoutOrNull(8000) { - while (!ProtocolManager.isAuthenticated()) { + while (!protocolGateway.isAuthenticated()) { delay(300) } true } ?: false // Save name and username locally first - val accountManager = AccountManager(context) if (name.trim().isNotEmpty()) { accountManager.updateAccountName(account.publicKey, name.trim()) } @@ -417,7 +418,7 @@ fun SetProfileScreen( accountManager.updateAccountUsername(account.publicKey, username.trim()) } // Trigger UI refresh in MainActivity - ProtocolManager.notifyOwnProfileUpdated() + protocolGateway.notifyOwnProfileUpdated() // Send name and username to server if (connected && (name.trim().isNotEmpty() || username.trim().isNotEmpty())) { @@ -425,16 +426,16 @@ fun SetProfileScreen( packet.title = name.trim() packet.username = username.trim() packet.privateKey = account.privateKeyHash - ProtocolManager.send(packet) + protocolGateway.send(packet) delay(1500) // Повторяем для надёжности - if (ProtocolManager.isAuthenticated()) { + if (protocolGateway.isAuthenticated()) { val packet2 = PacketUserInfo() packet2.title = name.trim() packet2.username = username.trim() packet2.privateKey = account.privateKeyHash - ProtocolManager.send(packet2) + protocolGateway.send(packet2) delay(500) } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt index 9c4152e..0b8d776 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/auth/UnlockScreen.kt @@ -45,8 +45,8 @@ import com.rosetta.messenger.data.DecryptedAccount import com.rosetta.messenger.data.EncryptedAccount import com.rosetta.messenger.data.resolveAccountDisplayName import com.rosetta.messenger.database.RosettaDatabase +import com.rosetta.messenger.di.SessionCoordinator import com.rosetta.messenger.repository.AvatarRepository -import com.rosetta.messenger.session.AppSessionCoordinator import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.chats.getAvatarColor import com.rosetta.messenger.ui.chats.getAvatarText @@ -69,6 +69,7 @@ private suspend fun performUnlock( selectedAccount: AccountItem?, password: String, accountManager: AccountManager, + sessionCoordinator: SessionCoordinator, onUnlocking: (Boolean) -> Unit, onError: (String) -> Unit, onSuccess: (DecryptedAccount) -> Unit @@ -117,8 +118,7 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword( name = selectedAccount.name ) - AppSessionCoordinator.bootstrapAuthenticatedSession( - accountManager = accountManager, + sessionCoordinator.bootstrapAuthenticatedSession( account = decryptedAccount, reason = "unlock" ) @@ -134,6 +134,8 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword( fun UnlockScreen( isDarkTheme: Boolean, selectedAccountId: String? = null, + accountManager: AccountManager, + sessionCoordinator: SessionCoordinator, onUnlocked: (DecryptedAccount) -> Unit, onSwitchAccount: () -> Unit = {}, onRecover: () -> Unit = {} @@ -163,7 +165,6 @@ fun UnlockScreen( val context = LocalContext.current val activity = context as? FragmentActivity - val accountManager = remember { AccountManager(context) } val biometricManager = remember { BiometricAuthManager(context) } val biometricPrefs = remember { BiometricPreferences(context) } val scope = rememberCoroutineScope() @@ -262,6 +263,7 @@ fun UnlockScreen( selectedAccount = selectedAccount, password = decryptedPassword, accountManager = accountManager, + sessionCoordinator = sessionCoordinator, onUnlocking = { isUnlocking = it }, onError = { error = it }, onSuccess = { decryptedAccount -> @@ -607,6 +609,7 @@ fun UnlockScreen( selectedAccount = selectedAccount, password = password, accountManager = accountManager, + sessionCoordinator = sessionCoordinator, onUnlocking = { isUnlocking = it }, onError = { error = it }, onSuccess = { decryptedAccount -> diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 396f015..edff05a 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -93,6 +93,7 @@ import com.airbnb.lottie.compose.LottieConstants import com.airbnb.lottie.compose.animateLottieCompositionAsState import com.airbnb.lottie.compose.rememberLottieComposition import com.rosetta.messenger.R +import com.rosetta.messenger.di.UiDependencyAccess import com.rosetta.messenger.data.ForwardManager import com.rosetta.messenger.data.GroupRepository import com.rosetta.messenger.data.MessageRepository @@ -100,7 +101,6 @@ import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.CallManager import com.rosetta.messenger.network.CallPhase -import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.ui.chats.calls.CallTopBanner import com.rosetta.messenger.repository.AvatarRepository @@ -342,6 +342,10 @@ fun ChatDetailScreen( ) { val viewModel: ChatViewModel = viewModel(key = "chat_${user.publicKey}") val context = LocalContext.current + val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() } + val preferencesManager = remember(uiDeps) { uiDeps.preferencesManager() } + val messageRepository = remember(uiDeps) { uiDeps.messageRepository() } val hasNativeNavigationBar = remember(context) { com.rosetta.messenger.ui.utils.NavigationModeUtils.hasNativeNavigationBar(context) } @@ -354,7 +358,6 @@ fun ChatDetailScreen( val hapticFeedback = LocalHapticFeedback.current // 🔇 Mute state — read from PreferencesManager - val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) } val isChatMuted = remember { mutableStateOf(false) } LaunchedEffect(currentUserPublicKey, user.publicKey) { if (currentUserPublicKey.isNotBlank()) { @@ -522,7 +525,7 @@ fun ChatDetailScreen( if (normalizedPublicKey.isBlank()) return@LaunchedEffect val cachedVerified = - ProtocolManager.getCachedUserInfo(normalizedPublicKey)?.verified ?: 0 + protocolGateway.getCachedUserInfo(normalizedPublicKey)?.verified ?: 0 if (cachedVerified > chatHeaderVerified) { chatHeaderVerified = cachedVerified } @@ -733,7 +736,7 @@ fun ChatDetailScreen( // 📨 Forward: список диалогов для выбора (загружаем из базы) val chatsListViewModel: ChatsListViewModel = viewModel() val dialogsList by chatsListViewModel.dialogs.collectAsState() - val groupRepository = remember { GroupRepository.getInstance(context) } + val groupRepository = remember(uiDeps) { uiDeps.groupRepository() } val groupMembersCacheKey = remember(user.publicKey, currentUserPublicKey) { "${currentUserPublicKey.trim()}::${user.publicKey.trim()}" @@ -4251,10 +4254,7 @@ fun ChatDetailScreen( scope.launch { try { if (isLeaveGroupDialog) { - GroupRepository - .getInstance( - context - ) + groupRepository .leaveGroup( currentUserPublicKey, user.publicKey @@ -4271,11 +4271,7 @@ fun ChatDetailScreen( } // 🗑️ Очищаем ВСЕ кэши сообщений - com.rosetta.messenger.data - .MessageRepository - .getInstance( - context - ) + messageRepository .clearDialogCache( user.publicKey ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt index 3088c73..c663c84 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt @@ -12,6 +12,8 @@ import androidx.lifecycle.viewModelScope import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.crypto.MessageCrypto import com.rosetta.messenger.data.ForwardManager +import com.rosetta.messenger.di.ProtocolGateway +import com.rosetta.messenger.di.UiDependencyAccess import com.rosetta.messenger.database.MessageEntity import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.* @@ -121,16 +123,17 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { private val searchIndexDao = database.messageSearchIndexDao() private val groupDao = database.groupDao() private val pinnedMessageDao = database.pinnedMessageDao() + private val uiDeps = UiDependencyAccess.get(application) + private val protocolGateway: ProtocolGateway = uiDeps.protocolGateway() // MessageRepository для подписки на события новых сообщений - private val messageRepository = - com.rosetta.messenger.data.MessageRepository.getInstance(application) + private val messageRepository = uiDeps.messageRepository() private val sendTextMessageUseCase = - SendTextMessageUseCase(sendWithRetry = { packet -> ProtocolManager.sendMessageWithRetry(packet) }) + SendTextMessageUseCase(sendWithRetry = { packet -> protocolGateway.sendMessageWithRetry(packet) }) private val sendMediaMessageUseCase = - SendMediaMessageUseCase(sendWithRetry = { packet -> ProtocolManager.sendMessageWithRetry(packet) }) + SendMediaMessageUseCase(sendWithRetry = { packet -> protocolGateway.sendMessageWithRetry(packet) }) private val sendForwardUseCase = - SendForwardUseCase(sendWithRetry = { packet -> ProtocolManager.sendMessageWithRetry(packet) }) + SendForwardUseCase(sendWithRetry = { packet -> protocolGateway.sendMessageWithRetry(packet) }) // 🔥 Кэш расшифрованных сообщений (messageId -> plainText) private val decryptionCache = ConcurrentHashMap() @@ -722,7 +725,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { typingSnapshotJob?.cancel() typingSnapshotJob = viewModelScope.launch { - ProtocolManager.typingUsersByDialogSnapshot.collect { snapshot -> + protocolGateway.typingUsersByDialogSnapshot.collect { snapshot -> val currentDialog = opponentKey?.trim().orEmpty() val currentAccount = myPublicKey?.trim().orEmpty() @@ -923,12 +926,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } private fun logPhotoPipeline(messageId: String, message: String) { - ProtocolManager.addLog("📸 IMG ${shortPhotoId(messageId)} | $message") + protocolGateway.addLog("📸 IMG ${shortPhotoId(messageId)} | $message") } private fun logPhotoPipelineError(messageId: String, stage: String, throwable: Throwable) { val reason = throwable.message ?: "unknown" - ProtocolManager.addLog( + protocolGateway.addLog( "❌ IMG ${shortPhotoId(messageId)} | $stage failed: ${throwable.javaClass.simpleName}: $reason" ) } @@ -1046,7 +1049,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { if (!isGroupDialogKey(normalizedPublicKey)) { groupKeyCache.remove(normalizedPublicKey) } - ProtocolManager.addLog("🔧 SEND_CONTEXT opponent=${shortSendKey(normalizedPublicKey)}") + protocolGateway.addLog("🔧 SEND_CONTEXT opponent=${shortSendKey(normalizedPublicKey)}") triggerPendingTextSendIfReady("send_context_bound") } @@ -1774,7 +1777,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { return nameFromMessages } - val cachedInfo = ProtocolManager.getCachedUserInfo(normalizedPublicKey) + val cachedInfo = protocolGateway.getCachedUserInfo(normalizedPublicKey) val protocolName = cachedInfo?.title?.trim().orEmpty().ifBlank { cachedInfo?.username?.trim().orEmpty() } if (isUsableSenderName(protocolName, normalizedPublicKey)) { @@ -1808,7 +1811,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } } - val cached = ProtocolManager.getCachedUserInfo(normalizedPublicKey) + val cached = protocolGateway.getCachedUserInfo(normalizedPublicKey) val protocolName = cached?.title?.trim().orEmpty() .ifBlank { cached?.username?.trim().orEmpty() } if (isUsableSenderName(protocolName, normalizedPublicKey)) { @@ -1842,7 +1845,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { viewModelScope.launch(Dispatchers.IO) { try { - val resolved = ProtocolManager.resolveUserInfo(normalizedPublicKey, timeoutMs = 5000L) + val resolved = protocolGateway.resolveUserInfo(normalizedPublicKey, timeoutMs = 5000L) val name = resolved?.title?.trim().orEmpty() .ifBlank { resolved?.username?.trim().orEmpty() } if (!isUsableSenderName(name, normalizedPublicKey)) { @@ -2568,12 +2571,12 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } catch (_: Exception) { null } // 2. Try ProtocolManager cache (previously resolved) - val cachedName = dbName ?: ProtocolManager.getCachedUserName(fwdPublicKey) + val cachedName = dbName ?: protocolGateway.getCachedUserName(fwdPublicKey) // 3. Server resolve via PacketSearch (like desktop useUserInformation) val serverName = if (cachedName == null) { try { - ProtocolManager.resolveUserName(fwdPublicKey, 3000) + protocolGateway.resolveUserName(fwdPublicKey, 3000) } catch (_: Exception) { null } } else null @@ -2750,11 +2753,11 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { fwdDialog?.opponentTitle?.ifEmpty { fwdDialog.opponentUsername }?.ifEmpty { null } } catch (_: Exception) { null } // 2. Try ProtocolManager cache - val cachedName = dbName ?: ProtocolManager.getCachedUserName(replyPublicKey) + val cachedName = dbName ?: protocolGateway.getCachedUserName(replyPublicKey) // 3. Server resolve via PacketSearch (like desktop useUserInformation) val serverName = if (cachedName == null) { try { - ProtocolManager.resolveUserName(replyPublicKey, 3000) + protocolGateway.resolveUserName(replyPublicKey, 3000) } catch (_: Exception) { null } } else null cachedName ?: serverName ?: "User" @@ -3388,14 +3391,14 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { /** 🛑 Отменить исходящую отправку фото во время загрузки */ fun cancelOutgoingImageUpload(messageId: String, attachmentId: String) { - ProtocolManager.addLog( + protocolGateway.addLog( "🛑 IMG cancel requested: msg=${messageId.take(8)}, att=${attachmentId.take(12)}" ) outgoingImageUploadJobs.remove(messageId)?.cancel( CancellationException("User cancelled image upload") ) TransportManager.cancelUpload(attachmentId) - ProtocolManager.resolveOutgoingRetry(messageId) + protocolGateway.resolveOutgoingRetry(messageId) deleteMessage(messageId) } @@ -3470,7 +3473,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } catch (_: Exception) {} // 2. Try ProtocolManager cache - val cached = ProtocolManager.getCachedUserInfo(publicKey) + val cached = protocolGateway.getCachedUserInfo(publicKey) if (cached != null) { return SearchUser( title = cached.title, @@ -3483,7 +3486,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // 3. Server resolve try { - val resolved = ProtocolManager.resolveUserInfo(publicKey, 3000) + val resolved = protocolGateway.resolveUserInfo(publicKey, 3000) if (resolved != null) { return SearchUser( title = resolved.title, @@ -3532,10 +3535,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } // 2) In-memory protocol cache. - ProtocolManager.getCachedUserByUsername(normalized)?.let { return it } + protocolGateway.getCachedUserByUsername(normalized)?.let { return it } // 3) Server search fallback. - val results = ProtocolManager.searchUsers(normalized, timeoutMs) + val results = protocolGateway.searchUsers(normalized, timeoutMs) if (results.isEmpty()) return null return results.firstOrNull { @@ -3604,7 +3607,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { sender: String?, hasPrivateKey: Boolean ) { - ProtocolManager.addLog( + protocolGateway.addLog( "⚠️ SEND_BLOCKED reason=$reason textLen=$textLength hasReply=$hasReply recipient=${shortSendKey(recipient)} sender=${shortSendKey(sender)} hasPriv=$hasPrivateKey isSending=$isSending" ) } @@ -3618,7 +3621,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { if (repositoryPublicKey.isNotEmpty() && repositoryPrivateKey.isNotEmpty()) { setUserKeys(repositoryPublicKey, repositoryPrivateKey) - ProtocolManager.addLog( + protocolGateway.addLog( "🔄 SEND_RECOVERY restored_keys pk=${shortSendKey(repositoryPublicKey)}" ) } @@ -3633,7 +3636,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { if (pendingSendRecoveryJob?.isActive == true) return - ProtocolManager.addLog("⏳ SEND_RECOVERY queued reason=$reason") + protocolGateway.addLog("⏳ SEND_RECOVERY queued reason=$reason") pendingSendRecoveryJob = viewModelScope.launch { repeat(10) { attempt -> @@ -3644,7 +3647,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } if (pendingTextSendRequested) { - ProtocolManager.addLog("⚠️ SEND_RECOVERY timeout reason=$pendingTextSendReason") + protocolGateway.addLog("⚠️ SEND_RECOVERY timeout reason=$pendingTextSendReason") } pendingTextSendRequested = false pendingTextSendReason = "" @@ -3668,7 +3671,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val keysReady = !myPublicKey.isNullOrBlank() && !myPrivateKey.isNullOrBlank() if (!recipientReady || !keysReady || isSending) return - ProtocolManager.addLog("🚀 SEND_RECOVERY flush trigger=$trigger") + protocolGateway.addLog("🚀 SEND_RECOVERY flush trigger=$trigger") pendingTextSendRequested = false pendingTextSendReason = "" pendingSendRecoveryJob?.cancel() @@ -4385,7 +4388,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val context = getApplication() if (recipient == null || sender == null || privateKey == null) { - ProtocolManager.addLog( + protocolGateway.addLog( "❌ IMG send aborted: missing keys (recipient=${recipient != null}, sender=${sender != null}, private=${privateKey != null})" ) return @@ -4986,13 +4989,13 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { val groupDebugId = UUID.randomUUID().toString().replace("-", "").take(8) if (recipient == null || sender == null || privateKey == null) { - ProtocolManager.addLog( + protocolGateway.addLog( "❌ IMG-GROUP $groupDebugId | aborted: missing keys (recipient=${recipient != null}, sender=${sender != null}, private=${privateKey != null})" ) return } if (isSending) { - ProtocolManager.addLog("⚠️ IMG-GROUP $groupDebugId | skipped: another send in progress") + protocolGateway.addLog("⚠️ IMG-GROUP $groupDebugId | skipped: another send in progress") return } @@ -5028,7 +5031,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { ) _inputText.value = "" - ProtocolManager.addLog( + protocolGateway.addLog( "📸 IMG-GROUP $groupDebugId | prepare start: count=${imageUris.size}, captionLen=${caption.trim().length}" ) @@ -5073,7 +5076,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { opponentPublicKey = recipient ) } catch (_: Exception) { - ProtocolManager.addLog("⚠️ IMG-GROUP $groupDebugId | optimistic DB save skipped (non-fatal)") + protocolGateway.addLog("⚠️ IMG-GROUP $groupDebugId | optimistic DB save skipped (non-fatal)") } val preparedImages = @@ -5089,7 +5092,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { uri ) ?: run { - ProtocolManager.addLog( + protocolGateway.addLog( "❌ IMG-GROUP $groupDebugId | item#$index base64 conversion failed" ) throw IllegalStateException( @@ -5101,7 +5104,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { context, uri ) - ProtocolManager.addLog( + protocolGateway.addLog( "📸 IMG-GROUP $groupDebugId | item#$index prepared: ${width}x$height, base64Len=${imageBase64.length}, blurhashLen=${blurhash.length}" ) index to @@ -5114,7 +5117,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { } if (preparedImages.isEmpty()) { - ProtocolManager.addLog( + protocolGateway.addLog( "❌ IMG-GROUP $groupDebugId | no prepared images, send canceled" ) updateMessageStatusInDb(messageId, DeliveryStatus.ERROR.value) @@ -5122,7 +5125,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { isSending = false return@launch } - ProtocolManager.addLog( + protocolGateway.addLog( "📸 IMG-GROUP $groupDebugId | prepare done: ready=${preparedImages.size}" ) @@ -6653,7 +6656,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { dialogDao.markIHaveSent(account, opponent) } - ProtocolManager.addLog( + protocolGateway.addLog( "🛠️ DIALOG_FALLBACK upserted opponent=${opponent.take(12)} ts=$fallbackTimestamp hasContent=1" ) } @@ -6920,7 +6923,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { toPublicKey = opponent } - ProtocolManager.send(packet) + protocolGateway.send(packet) } catch (e: Exception) {} } } @@ -6964,7 +6967,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { toPublicKey = opponent // Кому отправляем уведомление (собеседник) } - ProtocolManager.send(packet) + protocolGateway.send(packet) // ✅ Обновляем timestamp ПОСЛЕ успешной отправки lastReadMessageTimestamp = incomingTs MessageLogger.logReadReceiptSent(opponent) @@ -6973,7 +6976,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { // 🔄 Retry через 2с (desktop буферизует через WebSocket, мы ретраим вручную) try { kotlinx.coroutines.delay(2000) - ProtocolManager.send( + protocolGateway.send( PacketRead().apply { this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey) fromPublicKey = sender @@ -7058,7 +7061,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) { addPublicKey(opponent) } - ProtocolManager.send(packet) + protocolGateway.send(packet) } catch (e: Exception) {} } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt index 31d327d..025a7f2 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt @@ -68,11 +68,12 @@ import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.data.RecentSearchesManager import com.rosetta.messenger.data.isPlaceholderAccountName import com.rosetta.messenger.data.resolveAccountDisplayName +import com.rosetta.messenger.di.ProtocolGateway +import com.rosetta.messenger.di.UiDependencyAccess import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.CallPhase import com.rosetta.messenger.network.CallUiState import com.rosetta.messenger.network.DeviceEntry -import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolState import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.chats.calls.CallsHistoryScreen @@ -246,10 +247,10 @@ private fun shortPublicKey(value: String): String { return "${trimmed.take(6)}...${trimmed.takeLast(4)}" } -private fun resolveTypingDisplayName(publicKey: String): String { +private fun resolveTypingDisplayName(protocolGateway: ProtocolGateway, publicKey: String): String { val normalized = publicKey.trim() if (normalized.isBlank()) return "" - val cached = ProtocolManager.getCachedUserInfo(normalized) + val cached = protocolGateway.getCachedUserInfo(normalized) val resolvedName = cached?.title?.trim().orEmpty().ifBlank { cached?.username?.trim().orEmpty() } return if (resolvedName.isNotBlank()) resolvedName else shortPublicKey(normalized) @@ -324,6 +325,10 @@ fun ChatsListScreen( val view = androidx.compose.ui.platform.LocalView.current val context = androidx.compose.ui.platform.LocalContext.current + val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() } + val accountManager = remember(uiDeps) { uiDeps.accountManager() } + val preferencesManager = remember(uiDeps) { uiDeps.preferencesManager() } val focusManager = androidx.compose.ui.platform.LocalFocusManager.current val drawerState = rememberDrawerState(initialValue = DrawerValue.Closed) val scope = rememberCoroutineScope() @@ -456,10 +461,10 @@ fun ChatsListScreen( remember(isDarkTheme) { if (isDarkTheme) Color(0xFF8E8E8E) else Color(0xFF8E8E93) } // Protocol connection state - val protocolState by ProtocolManager.state.collectAsState() - val syncInProgress by ProtocolManager.syncInProgress.collectAsState() - val ownProfileUpdated by ProtocolManager.ownProfileUpdated.collectAsState() - val pendingDeviceVerification by ProtocolManager.pendingDeviceVerification.collectAsState() + val protocolState by protocolGateway.state.collectAsState() + val syncInProgress by protocolGateway.syncInProgress.collectAsState() + val ownProfileUpdated by protocolGateway.ownProfileUpdated.collectAsState() + val pendingDeviceVerification by protocolGateway.pendingDeviceVerification.collectAsState() // 📥 Active FILE downloads tracking (account-scoped, excludes photo downloads) val currentAccountKey = remember(accountPublicKey) { accountPublicKey.trim() } @@ -490,8 +495,8 @@ fun ChatsListScreen( val hasActiveDownloads = activeFileDownloads.isNotEmpty() // �🔥 Пользователи, которые сейчас печатают - val typingUsers by ProtocolManager.typingUsers.collectAsState() - val typingUsersByDialogSnapshot by ProtocolManager.typingUsersByDialogSnapshot.collectAsState() + val typingUsers by protocolGateway.typingUsers.collectAsState() + val typingUsersByDialogSnapshot by protocolGateway.typingUsersByDialogSnapshot.collectAsState() val playingVoiceAttachmentId by VoicePlaybackCoordinator.playingAttachmentId.collectAsState() val playingVoiceDialogKey by VoicePlaybackCoordinator.playingDialogKey.collectAsState() val isVoicePlaybackRunning by VoicePlaybackCoordinator.isPlaying.collectAsState() @@ -622,7 +627,6 @@ fun ChatsListScreen( // 👥 Load all accounts for sidebar (current account always first) var allAccounts by remember { mutableStateOf>(emptyList()) } LaunchedEffect(accountPublicKey, ownProfileUpdated) { - val accountManager = AccountManager(context) var accounts = accountManager.getAllAccounts() val preferredPublicKey = accountPublicKey.trim().ifBlank { @@ -630,7 +634,7 @@ fun ChatsListScreen( } if (preferredPublicKey.isNotBlank()) { - val cachedOwn = ProtocolManager.getCachedUserInfo(preferredPublicKey) + val cachedOwn = protocolGateway.getCachedUserInfo(preferredPublicKey) val cachedTitle = cachedOwn?.title?.trim().orEmpty() val cachedUsername = cachedOwn?.username?.trim().orEmpty() val existing = @@ -711,7 +715,6 @@ fun ChatsListScreen( val isSelectionMode = selectedChatKeys.isNotEmpty() val hapticFeedback = LocalHapticFeedback.current var showSelectionMenu by remember { mutableStateOf(false) } - val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) } val mutedChats by preferencesManager.mutedChatsForAccount(effectiveCurrentPublicKey) .collectAsState(initial = emptySet()) @@ -2730,6 +2733,7 @@ fun ChatsListScreen( } else { val baseName = resolveTypingDisplayName( + protocolGateway, typingSenderPublicKey ) if (baseName.isBlank()) { @@ -3237,12 +3241,12 @@ fun ChatsListScreen( if (request != null) { when (request.second) { DeviceResolveAction.ACCEPT -> { - ProtocolManager.acceptDevice( + protocolGateway.acceptDevice( request.first.deviceId ) } DeviceResolveAction.DECLINE -> { - ProtocolManager.declineDevice( + protocolGateway.declineDevice( request.first.deviceId ) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt index 8352429..0bbe17f 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListViewModel.kt @@ -8,11 +8,12 @@ import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.data.DraftManager import com.rosetta.messenger.data.GroupRepository import com.rosetta.messenger.data.MessageRepository +import com.rosetta.messenger.di.ProtocolGateway +import com.rosetta.messenger.di.UiDependencyAccess import com.rosetta.messenger.database.BlacklistEntity import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.PacketOnlineSubscribe import com.rosetta.messenger.network.PacketSearch -import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.SearchUser import java.text.SimpleDateFormat import java.util.Date @@ -74,7 +75,10 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio private val database = RosettaDatabase.getDatabase(application) private val dialogDao = database.dialogDao() - private val groupRepository = GroupRepository.getInstance(application) + private val uiDeps = UiDependencyAccess.get(application) + private val protocolGateway: ProtocolGateway = uiDeps.protocolGateway() + private val messageRepository: MessageRepository = uiDeps.messageRepository() + private val groupRepository: GroupRepository = uiDeps.groupRepository() private var currentAccount: String = "" private var currentPrivateKey: String? = null @@ -215,7 +219,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio } if (!dialogName.isNullOrBlank()) return dialogName - val cached = ProtocolManager.getCachedUserName(publicKey).orEmpty().trim() + val cached = protocolGateway.getCachedUserName(publicKey).orEmpty().trim() if (cached.isNotBlank() && cached != publicKey) { return cached } @@ -478,7 +482,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio .getDialogsFlow(publicKey) .flowOn(Dispatchers.IO) // 🚀 Flow работает на IO .debounce(100) // 🚀 Батчим быстрые обновления (N сообщений → 1 обработка) - .combine(ProtocolManager.syncInProgress) { dialogsList, syncing -> + .combine(protocolGateway.syncInProgress) { dialogsList, syncing -> dialogsList to syncing } .mapLatest { (dialogsList, syncing) -> @@ -529,7 +533,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio .getRequestsFlow(publicKey) .flowOn(Dispatchers.IO) .debounce(100) // 🚀 Батчим быстрые обновления - .combine(ProtocolManager.syncInProgress) { requestsList, syncing -> + .combine(protocolGateway.syncInProgress) { requestsList, syncing -> requestsList to syncing } .mapLatest { (requestsList, syncing) -> @@ -553,7 +557,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio dialogDao .getRequestsCountFlow(publicKey) .flowOn(Dispatchers.IO) - .combine(ProtocolManager.syncInProgress) { count, syncing -> + .combine(protocolGateway.syncInProgress) { count, syncing -> if (syncing) 0 else count } .distinctUntilChanged() // 🔥 ИСПРАВЛЕНИЕ: Игнорируем дублирующиеся значения @@ -577,7 +581,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio // dialogs that still have empty titles. launch { var wasSyncing = false - ProtocolManager.syncInProgress.collect { syncing -> + protocolGateway.syncInProgress.collect { syncing -> if (wasSyncing && !syncing) { requestedUserInfoKeys.clear() } @@ -634,7 +638,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio newKeys.forEach { key -> addPublicKey(key) } } - ProtocolManager.send(packet) + protocolGateway.send(packet) } catch (e: Exception) {} } } @@ -980,7 +984,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio } // 🗑️ 1. Очищаем ВСЕ кэши сообщений - MessageRepository.getInstance(getApplication()).clearDialogCache(opponentKey) + messageRepository.clearDialogCache(opponentKey) // 🗑️ Очищаем кэш ChatViewModel ChatViewModel.clearCacheForOpponent(opponentKey) @@ -1029,7 +1033,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio _requestsCount.value = _requests.value.size dialogsUiCache.remove(groupPublicKey) requestsUiCache.remove(groupPublicKey) - MessageRepository.getInstance(getApplication()).clearDialogCache(groupPublicKey) + messageRepository.clearDialogCache(groupPublicKey) ChatViewModel.clearCacheForOpponent(groupPublicKey) } left @@ -1104,7 +1108,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio this.privateKey = privateKeyHash this.search = publicKey } - ProtocolManager.send(packet) + protocolGateway.send(packet) } catch (e: Exception) {} } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ConnectionLogsScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ConnectionLogsScreen.kt index d78bbc5..2b18470 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ConnectionLogsScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ConnectionLogsScreen.kt @@ -11,11 +11,12 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.rosetta.messenger.network.ProtocolManager +import com.rosetta.messenger.di.UiDependencyAccess import com.rosetta.messenger.network.ProtocolState import compose.icons.TablerIcons import compose.icons.tablericons.* @@ -28,9 +29,12 @@ fun ConnectionLogsScreen( isDarkTheme: Boolean, onBack: () -> Unit ) { - val logs by ProtocolManager.debugLogs.collectAsState() - val protocolState by ProtocolManager.getProtocol().state.collectAsState() - val syncInProgress by ProtocolManager.syncInProgress.collectAsState() + val context = LocalContext.current + val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() } + val logs by protocolGateway.debugLogs.collectAsState() + val protocolState by protocolGateway.state.collectAsState() + val syncInProgress by protocolGateway.syncInProgress.collectAsState() val bgColor = if (isDarkTheme) Color(0xFF0E0E0E) else Color(0xFFF5F5F5) val cardColor = if (isDarkTheme) Color(0xFF1A1A1A) else Color.White @@ -41,9 +45,9 @@ fun ConnectionLogsScreen( val scope = rememberCoroutineScope() DisposableEffect(Unit) { - ProtocolManager.enableUILogs(true) + protocolGateway.enableUILogs(true) onDispose { - ProtocolManager.enableUILogs(false) + protocolGateway.enableUILogs(false) } } @@ -85,7 +89,7 @@ fun ConnectionLogsScreen( modifier = Modifier.weight(1f) ) - IconButton(onClick = { ProtocolManager.clearLogs() }) { + IconButton(onClick = { protocolGateway.clearLogs() }) { Icon( imageVector = TablerIcons.Trash, contentDescription = "Clear logs", diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt index 78fba8c..d1f239a 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt @@ -121,6 +121,8 @@ import com.rosetta.messenger.data.ForwardManager import com.rosetta.messenger.data.GroupRepository import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.data.PreferencesManager +import com.rosetta.messenger.di.ProtocolGateway +import com.rosetta.messenger.di.UiDependencyAccess import com.rosetta.messenger.database.MessageEntity import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.AttachmentType @@ -128,7 +130,6 @@ import com.rosetta.messenger.network.MessageAttachment import com.rosetta.messenger.network.OnlineState import com.rosetta.messenger.network.PacketOnlineState import com.rosetta.messenger.network.PacketOnlineSubscribe -import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.chats.components.ImageAttachment @@ -323,6 +324,11 @@ fun GroupInfoScreen( onSwipeBackEnabledChanged: (Boolean) -> Unit = {} ) { val context = androidx.compose.ui.platform.LocalContext.current + val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() } + val messageRepository = remember(uiDeps) { uiDeps.messageRepository() } + val preferencesManager = remember(uiDeps) { uiDeps.preferencesManager() } + val groupRepository = remember(uiDeps) { uiDeps.groupRepository() } val view = LocalView.current val focusManager = LocalFocusManager.current val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current @@ -376,9 +382,6 @@ fun GroupInfoScreen( } } - val groupRepository = remember { GroupRepository.getInstance(context) } - val messageRepository = remember { MessageRepository.getInstance(context) } - val preferencesManager = remember { PreferencesManager(context) } val database = remember { RosettaDatabase.getDatabase(context) } val groupDao = remember { database.groupDao() } val messageDao = remember { database.messageDao() } @@ -475,6 +478,7 @@ fun GroupInfoScreen( val memberSnapshot = memberInfoByKey.toMap() value = withContext(Dispatchers.Default) { buildGroupMediaItems( + protocolGateway = protocolGateway, messages = groupMessages, privateKey = currentUserPrivateKey, currentUserPublicKey = currentUserPublicKey, @@ -603,11 +607,11 @@ fun GroupInfoScreen( val resolvedUsers = withContext(Dispatchers.IO) { val resolvedMap = LinkedHashMap() members.forEach { memberKey -> - val cached = ProtocolManager.getCachedUserInfo(memberKey) + val cached = protocolGateway.getCachedUserInfo(memberKey) if (cached != null) { resolvedMap[memberKey] = cached } else { - ProtocolManager.resolveUserInfo(memberKey, timeoutMs = 2500L)?.let { resolvedUser -> + protocolGateway.resolveUserInfo(memberKey, timeoutMs = 2500L)?.let { resolvedUser -> resolvedMap[memberKey] = resolvedUser } } @@ -645,7 +649,7 @@ fun GroupInfoScreen( val resolvedUsers = withContext(Dispatchers.IO) { val resolvedMap = LinkedHashMap() cached.members.forEach { memberKey -> - ProtocolManager.getCachedUserInfo(memberKey)?.let { resolved -> + protocolGateway.getCachedUserInfo(memberKey)?.let { resolved -> resolvedMap[memberKey] = resolved } } @@ -685,9 +689,9 @@ fun GroupInfoScreen( } DisposableEffect(dialogPublicKey) { - ProtocolManager.waitPacket(0x05, onlinePacketHandler) + protocolGateway.waitPacket(0x05, onlinePacketHandler) onDispose { - ProtocolManager.unwaitPacket(0x05, onlinePacketHandler) + protocolGateway.unwaitPacket(0x05, onlinePacketHandler) } } @@ -705,7 +709,7 @@ fun GroupInfoScreen( this.privateKey = privateKeyHash keysToSubscribe.forEach { addPublicKey(it) } } - ProtocolManager.send(packet) + protocolGateway.send(packet) } } catch (_: Exception) {} } @@ -2357,6 +2361,7 @@ private fun parseAttachmentsForGroupInfo(attachmentsJson: String): List @@ -2364,13 +2369,14 @@ private fun resolveGroupSenderName( if (senderPublicKey.isBlank()) return "Unknown" if (senderPublicKey == currentUserPublicKey) return "You" - val info = memberInfoByKey[senderPublicKey] ?: ProtocolManager.getCachedUserInfo(senderPublicKey) + val info = memberInfoByKey[senderPublicKey] ?: protocolGateway.getCachedUserInfo(senderPublicKey) return info?.title?.takeIf { it.isNotBlank() } ?: info?.username?.takeIf { it.isNotBlank() } ?: shortPublicKey(senderPublicKey) } private fun buildGroupMediaItems( + protocolGateway: ProtocolGateway, messages: List, privateKey: String, currentUserPublicKey: String, @@ -2389,6 +2395,7 @@ private fun buildGroupMediaItems( ?: if (message.fromMe == 1) currentUserPublicKey else "" val senderCacheKey = senderKey.ifBlank { currentUserPublicKey } val senderName = resolveGroupSenderName( + protocolGateway = protocolGateway, senderPublicKey = senderCacheKey, currentUserPublicKey = currentUserPublicKey, memberInfoByKey = memberInfoByKey diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt index ba6541f..a07a0b5 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt @@ -77,6 +77,7 @@ import androidx.core.view.WindowInsetsCompat import coil.compose.AsyncImage import coil.request.ImageRequest import com.rosetta.messenger.R +import com.rosetta.messenger.di.UiDependencyAccess import com.rosetta.messenger.data.GroupRepository import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.database.DialogDao @@ -122,6 +123,9 @@ fun GroupSetupScreen( ) { val scope = rememberCoroutineScope() val context = LocalContext.current + val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val messageRepository = remember(uiDeps) { uiDeps.messageRepository() } + val groupRepository = remember(uiDeps) { uiDeps.groupRepository() } val view = LocalView.current val focusManager = LocalFocusManager.current val keyboardController = LocalSoftwareKeyboardController.current @@ -255,7 +259,7 @@ fun GroupSetupScreen( } suspend fun createGroup() = - GroupRepository.getInstance(context).createGroup( + groupRepository.createGroup( accountPublicKey = accountPublicKey, accountPrivateKey = accountPrivateKey, title = title.trim(), @@ -1091,23 +1095,21 @@ fun GroupSetupScreen( // Send invite to all selected members if (selectedMembers.isNotEmpty()) { withContext(Dispatchers.IO) { - val groupRepo = GroupRepository.getInstance(context) - val groupKey = groupRepo.getGroupKey( + val groupKey = groupRepository.getGroupKey( accountPublicKey, accountPrivateKey, result.dialogPublicKey ) if (!groupKey.isNullOrBlank()) { - val invite = groupRepo.constructInviteString( + val invite = groupRepository.constructInviteString( groupId = result.dialogPublicKey, title = result.title.ifBlank { title.trim() }, encryptKey = groupKey, description = description.trim() ) if (invite.isNotBlank()) { - val msgRepo = MessageRepository.getInstance(context) selectedMembers.forEach { member -> runCatching { - msgRepo.sendMessage( + messageRepository.sendMessage( toPublicKey = member.publicKey, text = invite ) diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt index cafe142..8f628a1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/RequestsListScreen.kt @@ -14,12 +14,14 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.sp -import com.rosetta.messenger.network.ProtocolManager +import com.rosetta.messenger.di.UiDependencyAccess import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.onboarding.PrimaryBlue @@ -39,8 +41,11 @@ fun RequestsListScreen( onUserSelect: (SearchUser) -> Unit, avatarRepository: AvatarRepository? = null ) { + val context = LocalContext.current + val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val protocolGateway = remember(uiDeps) { uiDeps.protocolGateway() } val chatsState by chatsViewModel.chatsState.collectAsState() - val syncInProgress by ProtocolManager.syncInProgress.collectAsState() + val syncInProgress by protocolGateway.syncInProgress.collectAsState() val requests = if (syncInProgress) emptyList() else chatsState.requests val blockedUsers by chatsViewModel.blockedUsers.collectAsState() val scope = rememberCoroutineScope() diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt index 134e9c5..c9b01cf 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt @@ -57,7 +57,7 @@ import androidx.compose.ui.unit.sp import androidx.lifecycle.viewmodel.compose.viewModel import com.airbnb.lottie.compose.* import com.rosetta.messenger.R -import com.rosetta.messenger.data.AccountManager +import com.rosetta.messenger.di.UiDependencyAccess import com.rosetta.messenger.data.RecentSearchesManager import com.rosetta.messenger.data.isPlaceholderAccountName import com.rosetta.messenger.network.ProtocolState @@ -104,6 +104,8 @@ fun SearchScreen( ) { // Context и View для мгновенного закрытия клавиатуры val context = LocalContext.current + val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val accountManager = remember(uiDeps) { uiDeps.accountManager() } val view = LocalView.current val focusManager = LocalFocusManager.current if (!view.isInEditMode) { @@ -173,7 +175,7 @@ fun SearchScreen( return@LaunchedEffect } - val account = AccountManager(context).getAccount(currentUserPublicKey) + val account = accountManager.getAccount(currentUserPublicKey) ownAccountName = account?.name?.trim().orEmpty() ownAccountUsername = account?.username?.trim().orEmpty() } @@ -993,6 +995,8 @@ private fun MessagesTabContent( onUserSelect: (SearchUser) -> Unit ) { val context = LocalContext.current + val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val messageRepository = remember(uiDeps) { uiDeps.messageRepository() } var results by remember { mutableStateOf>(emptyList()) } var isSearching by remember { mutableStateOf(false) } val dividerColor = remember(isDarkTheme) { @@ -1021,8 +1025,7 @@ private fun MessagesTabContent( withContext(Dispatchers.IO) { try { val db = RosettaDatabase.getDatabase(context) - val repo = com.rosetta.messenger.data.MessageRepository.getInstance(context) - val privateKey = repo.getCurrentPrivateKey().orEmpty() + val privateKey = messageRepository.getCurrentPrivateKey().orEmpty() if (privateKey.isBlank()) { isSearching = false return@withContext @@ -1481,11 +1484,13 @@ private fun MediaTabContent( onOpenImageViewer: (images: List, initialIndex: Int, privateKey: String) -> Unit = { _, _, _ -> } ) { val context = LocalContext.current + val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val messageRepository = remember(uiDeps) { uiDeps.messageRepository() } var mediaItems by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(true) } val privateKey = remember { - com.rosetta.messenger.data.MessageRepository.getInstance(context).getCurrentPrivateKey().orEmpty() + messageRepository.getCurrentPrivateKey().orEmpty() } val viewerImages = remember(mediaItems) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchUsersViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchUsersViewModel.kt index 8b220bf..ba81dd2 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchUsersViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchUsersViewModel.kt @@ -1,9 +1,11 @@ package com.rosetta.messenger.ui.chats -import androidx.lifecycle.ViewModel +import android.app.Application +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope +import com.rosetta.messenger.di.ProtocolGateway +import com.rosetta.messenger.di.UiDependencyAccess import com.rosetta.messenger.network.PacketSearch -import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.SearchUser import java.text.SimpleDateFormat import java.util.Date @@ -20,7 +22,9 @@ import kotlinx.coroutines.launch * ViewModel для поиска пользователей через протокол * Работает аналогично SearchBar в React Native приложении */ -class SearchUsersViewModel : ViewModel() { +class SearchUsersViewModel(application: Application) : AndroidViewModel(application) { + private val protocolGateway: ProtocolGateway = + UiDependencyAccess.get(application).protocolGateway() // Состояние поиска private val _searchQuery = MutableStateFlow("") @@ -47,7 +51,7 @@ class SearchUsersViewModel : ViewModel() { init { packetFlowJob = viewModelScope.launch { - ProtocolManager.packetFlow(0x03).collectLatest { packet -> + protocolGateway.packetFlow(0x03).collectLatest { packet -> val searchPacket = packet as? PacketSearch ?: return@collectLatest logSearch( "📥 PacketSearch response: search='${searchPacket.search}', users=${searchPacket.users.size}" @@ -118,7 +122,7 @@ class SearchUsersViewModel : ViewModel() { } val effectivePrivateHash = - privateKeyHash.ifBlank { ProtocolManager.getProtocol().getPrivateHash().orEmpty() } + privateKeyHash.ifBlank { protocolGateway.getPrivateHash().orEmpty() } if (effectivePrivateHash.isBlank()) { _isSearching.value = false logSearch("❌ Skip send: private hash is empty") @@ -131,7 +135,7 @@ class SearchUsersViewModel : ViewModel() { this.search = normalizedQuery } - ProtocolManager.sendPacket(packetSearch) + protocolGateway.sendPacket(packetSearch) logSearch("📤 PacketSearch sent: '$normalizedQuery'") } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 60de3b1..dd33f28 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -63,7 +63,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.PopupProperties -import com.rosetta.messenger.data.GroupRepository +import com.rosetta.messenger.di.UiDependencyAccess import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.GroupStatus import com.rosetta.messenger.network.MessageAttachment @@ -1693,7 +1693,8 @@ private fun GroupInviteInlineCard( ) { val context = LocalContext.current val scope = rememberCoroutineScope() - val groupRepository = remember { GroupRepository.getInstance(context) } + val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val groupRepository = remember(uiDeps) { uiDeps.groupRepository() } val normalizedInvite = remember(inviteText) { inviteText.trim() } val parsedInvite = remember(normalizedInvite) { groupRepository.parseInviteString(normalizedInvite) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt index 35ebd6c..f67a64a 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/InAppCameraScreen.kt @@ -72,7 +72,7 @@ import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.LinearEasing import androidx.compose.ui.graphics.graphicsLayer -import com.rosetta.messenger.data.PreferencesManager +import com.rosetta.messenger.di.UiDependencyAccess /** * 📷 In-App Camera Screen - как в Telegram @@ -91,9 +91,8 @@ fun InAppCameraScreen( val view = LocalView.current val keyboardController = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current - val preferencesManager = remember(context.applicationContext) { - PreferencesManager(context.applicationContext) - } + val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val preferencesManager = remember(uiDeps) { uiDeps.preferencesManager() } // Camera state var lensFacing by remember { mutableStateOf(CameraSelector.LENS_FACING_BACK) } diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/AppIconScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/AppIconScreen.kt index 0555844..37b5cd4 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/AppIconScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/AppIconScreen.kt @@ -29,6 +29,7 @@ import compose.icons.TablerIcons import compose.icons.tablericons.ChevronLeft import com.rosetta.messenger.R import com.rosetta.messenger.data.PreferencesManager +import com.rosetta.messenger.di.UiDependencyAccess import com.rosetta.messenger.ui.onboarding.PrimaryBlue import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @@ -56,7 +57,8 @@ fun AppIconScreen( ) { val context = LocalContext.current val scope = rememberCoroutineScope() - val prefs = remember { PreferencesManager(context) } + val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val prefs = remember(uiDeps) { uiDeps.preferencesManager() } var currentIcon by remember { mutableStateOf("default") } LaunchedEffect(Unit) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/NotificationsScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/NotificationsScreen.kt index 8b4c73c..c00a9a8 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/NotificationsScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/NotificationsScreen.kt @@ -27,7 +27,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import com.rosetta.messenger.data.PreferencesManager +import com.rosetta.messenger.di.UiDependencyAccess import com.rosetta.messenger.ui.icons.TelegramIcons import compose.icons.TablerIcons import compose.icons.tablericons.ChevronLeft @@ -40,7 +40,8 @@ fun NotificationsScreen( onBack: () -> Unit ) { val context = LocalContext.current - val preferencesManager = remember { PreferencesManager(context) } + val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val preferencesManager = remember(uiDeps) { uiDeps.preferencesManager() } val notificationsEnabled by preferencesManager.notificationsEnabled.collectAsState(initial = true) val avatarInNotifications by preferencesManager.notificationAvatarEnabled.collectAsState(initial = true) val scope = rememberCoroutineScope() diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt index 95f1de4..37502e1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/OtherProfileScreen.kt @@ -85,6 +85,7 @@ import com.rosetta.messenger.R import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.crypto.MessageCrypto import com.rosetta.messenger.data.MessageRepository +import com.rosetta.messenger.di.UiDependencyAccess import com.rosetta.messenger.database.MessageEntity import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.network.AttachmentType @@ -255,7 +256,8 @@ fun OtherProfileScreen( val coroutineScope = rememberCoroutineScope() // 🔕 Mute state - val preferencesManager = remember { com.rosetta.messenger.data.PreferencesManager(context) } + val uiDeps = remember(context) { UiDependencyAccess.get(context) } + val preferencesManager = remember(uiDeps) { uiDeps.preferencesManager() } var notificationsEnabled by remember { mutableStateOf(true) } // 🔥 Загружаем статус блокировки при открытии экрана @@ -356,7 +358,7 @@ fun OtherProfileScreen( } // �🟢 Наблюдаем за онлайн статусом пользователя в реальном времени - val messageRepository = remember { MessageRepository.getInstance(context) } + val messageRepository = remember(uiDeps) { uiDeps.messageRepository() } val onlineStatus by messageRepository .observeUserOnlineStatus(user.publicKey) diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileViewModel.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileViewModel.kt index f257c6c..7776482 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileViewModel.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ProfileViewModel.kt @@ -4,10 +4,11 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.rosetta.messenger.data.AccountManager +import com.rosetta.messenger.di.ProtocolGateway +import com.rosetta.messenger.di.UiDependencyAccess import com.rosetta.messenger.data.EncryptedAccount import com.rosetta.messenger.network.PacketResult import com.rosetta.messenger.network.PacketUserInfo -import com.rosetta.messenger.network.ProtocolManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collectLatest @@ -24,7 +25,9 @@ data class ProfileState( class ProfileViewModel(application: Application) : AndroidViewModel(application) { - private val accountManager = AccountManager(application) + private val uiDeps = UiDependencyAccess.get(application) + private val accountManager: AccountManager = uiDeps.accountManager() + private val protocolGateway: ProtocolGateway = uiDeps.protocolGateway() private val _state = MutableStateFlow(ProfileState()) val state: StateFlow = _state @@ -34,7 +37,7 @@ class ProfileViewModel(application: Application) : AndroidViewModel(application) init { packetFlowJob = viewModelScope.launch { - ProtocolManager.packetFlow(0x02).collectLatest { packet -> + protocolGateway.packetFlow(0x02).collectLatest { packet -> val result = packet as? PacketResult ?: return@collectLatest handlePacketResult(result) } @@ -98,7 +101,7 @@ class ProfileViewModel(application: Application) : AndroidViewModel(application) // CRITICAL: Log full packet data for debugging addLog("Sending packet to server...") - ProtocolManager.send(packet) + protocolGateway.send(packet) addLog("Packet sent successfully") addLog("Waiting for PacketResult (0x02) from server...") diff --git a/build.gradle.kts b/build.gradle.kts index 8026e37..c569c63 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,6 +4,7 @@ plugins { id("org.jetbrains.kotlin.android") version "1.9.20" apply false id("com.google.devtools.ksp") version "1.9.20-1.0.14" apply false id("com.google.gms.google-services") version "4.4.0" apply false + id("com.google.dagger.hilt.android") version "2.51.1" apply false } tasks.register("clean", Delete::class) {