Выпуск 1.1.7: слияние dev в master
All checks were successful
Android Kernel Build / build (push) Successful in 16h25m22s

This commit is contained in:
2026-03-11 23:03:48 +07:00
12 changed files with 67 additions and 80 deletions

View File

@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release // Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.1.6" val rosettaVersionName = "1.1.7"
val rosettaVersionCode = 18 // Increment on each release val rosettaVersionCode = 19 // Increment on each release
android { android {
namespace = "com.rosetta.messenger" namespace = "com.rosetta.messenger"

View File

@@ -32,6 +32,8 @@
android:exported="true" android:exported="true"
android:label="@string/app_name" android:label="@string/app_name"
android:theme="@style/Theme.RosettaAndroid" android:theme="@style/Theme.RosettaAndroid"
android:launchMode="singleTask"
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode|smallestScreenSize|screenLayout"
android:windowSoftInputMode="adjustResize" android:windowSoftInputMode="adjustResize"
android:screenOrientation="portrait"> android:screenOrientation="portrait">
<intent-filter> <intent-filter>

View File

@@ -458,15 +458,24 @@ class MainActivity : FragmentActivity() {
// Сохраняем токен локально // Сохраняем токен локально
saveFcmToken(token) saveFcmToken(token)
addFcmLog("💾 Токен сохранен локально") addFcmLog("💾 Токен сохранен локально")
if (ProtocolManager.isAuthenticated()) {
// Token will be sent by ProtocolManager.onAuthenticated() runCatching {
// when protocol reaches AUTHENTICATED state ProtocolManager.subscribePushTokenIfAvailable(
forceToken = token
)
}
.onSuccess {
addFcmLog("🔔 Push token отправлен на сервер сразу")
}
.onFailure { error ->
addFcmLog(
"❌ Ошибка отправки push token: ${error.message}"
)
}
}
} else { } else {
addFcmLog("⚠️ Токен пустой") addFcmLog("⚠️ Токен пустой")
} }
// Токен будет отправлен через ProtocolManager.subscribePushTokenIfAvailable()
// при достижении состояния AUTHENTICATED
} }
} catch (e: Exception) { } catch (e: Exception) {
addFcmLog("❌ Ошибка Firebase: ${e.message}") addFcmLog("❌ Ошибка Firebase: ${e.message}")

View File

@@ -17,18 +17,14 @@ object ReleaseNotes {
val RELEASE_NOTICE = """ val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER Update v$VERSION_PLACEHOLDER
Профили и аватарки Уведомления
- Добавлен полноэкранный просмотр аватарок в чужом профиле (включая системные аккаунты) - Исправлена регистрация push-токена после переподключений
- Исправлено отображение даты аватарки: устранён некорректный год (например, 58154) - Добавлен fallback для нестандартных payload, чтобы push-уведомления не терялись
- Улучшена отправка push-токена сразу после получения FCM токена
Сессии и вход
- Добавлен кэш сессии в памяти процесса: повторный пароль не запрашивается, пока процесс жив
- Кэш сессии корректно очищается при выходе, переключении и удалении аккаунта
Интерфейс Интерфейс
- Исправлено центрирование blur-фона у системных аватарок - Улучшено поведение сворачивания приложения в стиле Telegram
- Унифицировано определение темы для verified-галочек - Стабилизировано отображение нижней системной панели навигации
- В списке чатов verified-галочки сделаны синими в светлой теме (включая system light)
""".trimIndent() """.trimIndent()
fun getNotice(version: String): String = fun getNotice(version: String): String =

View File

@@ -682,6 +682,10 @@ interface DialogDao {
lastSeen: Long lastSeen: Long
) )
/** Сбросить online-флаг у всех диалогов аккаунта (защита от устаревшего кэша при старте) */
@Query("UPDATE dialogs SET is_online = 0 WHERE account = :account AND is_online != 0")
suspend fun clearOnlineStatuses(account: String)
/** Получить онлайн статус пользователя */ /** Получить онлайн статус пользователя */
@Query( @Query(
""" """

View File

@@ -187,11 +187,15 @@ object ProtocolManager {
getProtocol().state.collect { newState -> getProtocol().state.collect { newState ->
val previous = lastProtocolState val previous = lastProtocolState
if (newState == ProtocolState.AUTHENTICATED && previous != ProtocolState.AUTHENTICATED) { if (newState == ProtocolState.AUTHENTICATED && previous != ProtocolState.AUTHENTICATED) {
// New authenticated websocket session: always allow fresh push subscribe.
lastSubscribedToken = null
onAuthenticated() onAuthenticated()
} }
if (newState != ProtocolState.AUTHENTICATED && newState != ProtocolState.HANDSHAKING) { if (newState != ProtocolState.AUTHENTICATED && newState != ProtocolState.HANDSHAKING) {
syncRequestInFlight = false syncRequestInFlight = false
setSyncInProgress(false) setSyncInProgress(false)
// Connection/session dropped: force re-subscribe on next AUTHENTICATED.
lastSubscribedToken = null
} }
lastProtocolState = newState lastProtocolState = newState
} }
@@ -616,10 +620,6 @@ object ProtocolManager {
* [com.rosetta.messenger.push.RosettaFirebaseMessagingService.onNewToken] * [com.rosetta.messenger.push.RosettaFirebaseMessagingService.onNewToken]
* when Firebase rotates the token mid-session. * when Firebase rotates the token mid-session.
* *
* On each connect we send UNSUBSCRIBE first to clear any duplicate
* registrations that may have accumulated on the server, then SUBSCRIBE
* once — guaranteeing exactly one active push binding per device.
*
* @param forceToken if non-null, use this token instead of reading SharedPreferences * @param forceToken if non-null, use this token instead of reading SharedPreferences
* (used by onNewToken which already has the fresh token). * (used by onNewToken which already has the fresh token).
*/ */
@@ -638,23 +638,13 @@ object ProtocolManager {
return return
} }
// 1) UNSUBSCRIBE — clears ALL existing registrations for this token on the server.
// This removes duplicates that may have been created before the dedup fix.
val unsubPacket = PacketPushNotification().apply {
notificationsToken = token
action = PushNotificationAction.UNSUBSCRIBE
}
send(unsubPacket)
addLog("🔕 Push token UNSUBSCRIBE sent (clearing duplicates)")
// 2) SUBSCRIBE — register exactly once.
val subPacket = PacketPushNotification().apply { val subPacket = PacketPushNotification().apply {
notificationsToken = token notificationsToken = token
action = PushNotificationAction.SUBSCRIBE action = PushNotificationAction.SUBSCRIBE
} }
send(subPacket) send(subPacket)
lastSubscribedToken = token lastSubscribedToken = token
addLog("🔔 Push token SUBSCRIBE sent — single registration") addLog("🔔 Push token SUBSCRIBE sent")
} }
private fun requestSynchronize() { private fun requestSynchronize() {

View File

@@ -132,6 +132,19 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
handledMessageData = true handledMessageData = true
} }
} }
val looksLikeMessagePayload =
type.contains("message") ||
data.keys.any { key ->
val lower = key.lowercase(Locale.ROOT)
lower.contains("message") ||
lower.contains("text") ||
lower.contains("body")
}
if (!handledMessageData && !isReadEvent && looksLikeMessagePayload) {
showSimpleNotification(senderName, messagePreview)
handledMessageData = true
}
} }
// Обрабатываем notification payload (если есть). // Обрабатываем notification payload (если есть).
@@ -164,11 +177,6 @@ class RosettaFirebaseMessagingService : FirebaseMessagingService() {
} }
lastNotifTimestamps[dedupKey] = now lastNotifTimestamps[dedupKey] = now
Log.d(TAG, "\u2705 Showing notification for key=$dedupKey") Log.d(TAG, "\u2705 Showing notification for key=$dedupKey")
// Desktop parity: suppress notifications during sync (useDialogFiber.ts checks
// protocolState != ProtocolState.SYNCHRONIZATION before calling notify()).
if (ProtocolManager.syncInProgress.value) {
return
}
val senderKey = senderPublicKey?.trim().orEmpty() val senderKey = senderPublicKey?.trim().orEmpty()
if (senderKey.isNotEmpty() && isDialogMuted(senderKey)) { if (senderKey.isNotEmpty() && isDialogMuted(senderKey)) {
return return

View File

@@ -266,6 +266,12 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
// Запускаем все подписки в одном родительском Job для отмены при смене аккаунта // Запускаем все подписки в одном родительском Job для отмены при смене аккаунта
accountSubscriptionsJob = viewModelScope.launch { accountSubscriptionsJob = viewModelScope.launch {
// 🟢 Сбрасываем устаревшие online-флаги из прошлого сеанса.
// Актуальные статусы придут сразу после PacketOnlineSubscribe.
withContext(Dispatchers.IO) {
runCatching { dialogDao.clearOnlineStatuses(publicKey) }
}
// Подписываемся на обычные диалоги // Подписываемся на обычные диалоги
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
launch { launch {

View File

@@ -187,15 +187,9 @@ fun InAppCameraScreen(
window.statusBarColor = originalStatusBarColor window.statusBarColor = originalStatusBarColor
insetsController.isAppearanceLightStatusBars = originalLightStatusBars insetsController.isAppearanceLightStatusBars = originalLightStatusBars
// Navigation bar: восстанавливаем только если есть нативные кнопки window.navigationBarColor = originalNavigationBarColor
if (com.rosetta.messenger.ui.utils.NavigationModeUtils.hasNativeNavigationBar(context)) { insetsController.isAppearanceLightNavigationBars = originalLightNavigationBars
window.navigationBarColor = originalNavigationBarColor insetsController.show(androidx.core.view.WindowInsetsCompat.Type.navigationBars())
insetsController.isAppearanceLightNavigationBars = originalLightNavigationBars
} else {
insetsController.hide(androidx.core.view.WindowInsetsCompat.Type.navigationBars())
insetsController.systemBarsBehavior =
androidx.core.view.WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
} }
} }

View File

@@ -173,15 +173,10 @@ fun OnboardingScreen(
if (!view.isInEditMode) { if (!view.isInEditMode) {
val window = (view.context as android.app.Activity).window val window = (view.context as android.app.Activity).window
val insetsController = WindowCompat.getInsetsController(window, view) val insetsController = WindowCompat.getInsetsController(window, view)
if (NavigationModeUtils.hasNativeNavigationBar(view.context)) { window.navigationBarColor =
window.navigationBarColor = if (isDarkTheme) 0xFF1E1E1E.toInt() else 0xFFFFFFFF.toInt()
if (isDarkTheme) 0xFF1E1E1E.toInt() else 0xFFFFFFFF.toInt() insetsController.show(WindowInsetsCompat.Type.navigationBars())
} else { insetsController.isAppearanceLightNavigationBars = !isDarkTheme
// Жестовая навигация — прячем бар
insetsController.hide(WindowInsetsCompat.Type.navigationBars())
insetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
} }
} }

View File

@@ -55,25 +55,15 @@ object NavigationModeUtils {
} }
/** /**
* Показывает или прячет navigation bar в зависимости от типа навигации. * Показывает navigation bar на всех устройствах.
* - Кнопочная навигация → показываем бар
* - Жестовая навигация → прячем бар, свайп снизу временно покажет
*/ */
fun applyNavigationBarVisibility( fun applyNavigationBarVisibility(
insetsController: WindowInsetsControllerCompat, insetsController: WindowInsetsControllerCompat,
context: Context, context: Context,
isDarkTheme: Boolean isDarkTheme: Boolean
) { ) {
if (hasNativeNavigationBar(context)) { insetsController.show(WindowInsetsCompat.Type.navigationBars())
// Есть нативные кнопки — показываем навигационный бар insetsController.isAppearanceLightNavigationBars = !isDarkTheme
insetsController.show(WindowInsetsCompat.Type.navigationBars())
insetsController.isAppearanceLightNavigationBars = !isDarkTheme
} else {
// Жестовая навигация — прячем навигационный бар
insetsController.hide(WindowInsetsCompat.Type.navigationBars())
insetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
} }
} }

View File

@@ -6,7 +6,6 @@ import android.view.View
import android.view.Window import android.view.Window
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
data class SystemBarsState( data class SystemBarsState(
val statusBarColor: Int, val statusBarColor: Int,
@@ -48,16 +47,10 @@ object SystemBarsStyleUtils {
if (window == null || view == null) return if (window == null || view == null) return
val insetsController = WindowCompat.getInsetsController(window, view) val insetsController = WindowCompat.getInsetsController(window, view)
if (NavigationModeUtils.hasNativeNavigationBar(context)) { insetsController.show(WindowInsetsCompat.Type.navigationBars())
insetsController.show(WindowInsetsCompat.Type.navigationBars()) if (state != null) {
if (state != null) { window.navigationBarColor = state.navigationBarColor
window.navigationBarColor = state.navigationBarColor insetsController.isAppearanceLightNavigationBars = state.isLightNavigationBars
insetsController.isAppearanceLightNavigationBars = state.isLightNavigationBars
}
} else {
insetsController.hide(WindowInsetsCompat.Type.navigationBars())
insetsController.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
} }
} }