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