Compare commits

...

5 Commits

Author SHA1 Message Date
ebb95905b5 PacketRead parity: корректные read-статусы и update release notes 1.3.0
Some checks failed
Android Kernel Build / build (push) Failing after 10m41s
2026-03-22 21:25:04 +05:00
f915333a44 Синхронизация 1.3.0: parity с desktop/server и стабилизация sync-цикла 2026-03-22 19:47:23 +05:00
69c0c377d1 Добавлено кэширование Android SDK и Gradle wrapper для ускорения сборки 2026-03-22 17:01:27 +05:00
30fbc41245 Добавил lint { checkReleaseBuilds = false; abortOnError = false } в build.gradle.kts
Some checks failed
Android Kernel Build / build (push) Failing after 6m30s
2026-03-22 16:19:33 +05:00
677a5f2ab2 Добавлен комментарий в MainActivity.kt
Some checks failed
Android Kernel Build / build (push) Failing after 18m34s
2026-03-22 15:40:38 +05:00
11 changed files with 288 additions and 38 deletions

View File

@@ -41,6 +41,12 @@ jobs:
export JAVA_HOME="$JAVA_DIR"
echo "JAVA_HOME set to $JAVA_HOME"
- name: Cache Android SDK
uses: actions/cache@v3
with:
path: ~/android-sdk
key: android-sdk-34
- name: Install Android SDK
run: |
export ANDROID_HOME="$HOME/android-sdk"
@@ -65,6 +71,14 @@ jobs:
echo "ANDROID_HOME=$ANDROID_HOME" >> $GITHUB_ENV
echo "ANDROID_SDK_ROOT=$ANDROID_HOME" >> $GITHUB_ENV
- name: Cache Gradle wrapper
uses: actions/cache@v3
with:
path: |
~/.gradle/wrapper/dists
~/.gradle/caches
key: gradle-wrapper-8.14.3
- name: Restore debug keystore
run: |
mkdir -p ~/.android
@@ -76,10 +90,28 @@ jobs:
- name: Setup Gradle wrapper
run: |
chmod +x ./gradlew
./gradlew --version
GRADLE_VERSION="8.14.3"
GRADLE_DIST_DIR="$HOME/.gradle/wrapper/dists/gradle-${GRADLE_VERSION}-bin"
# Проверяем — если Gradle уже распакован в кэше, пропускаем скачивание
if find "$GRADLE_DIST_DIR" -name "gradle-${GRADLE_VERSION}" -type d 2>/dev/null | grep -q .; then
echo "Gradle ${GRADLE_VERSION} found in cache, skipping download"
else
echo "Gradle not found in cache, downloading..."
mkdir -p /opt/gradle-download
curl -fL --retry 3 --retry-delay 5 \
"https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" \
-o "/opt/gradle-download/gradle-${GRADLE_VERSION}-bin.zip"
mkdir -p /opt/gradle
unzip -q "/opt/gradle-download/gradle-${GRADLE_VERSION}-bin.zip" -d /opt/gradle
export PATH="/opt/gradle/gradle-${GRADLE_VERSION}/bin:$PATH"
echo "PATH=/opt/gradle/gradle-${GRADLE_VERSION}/bin:$PATH" >> $GITHUB_ENV
fi
./gradlew --no-daemon --version
- name: Build Release APK
run: ./gradlew assembleRelease
run: ./gradlew --no-daemon assembleRelease
- name: Check if APK exists
run: |

View File

@@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.2.9"
val rosettaVersionCode = 31 // Increment on each release
val rosettaVersionName = "1.3.0"
val rosettaVersionCode = 32 // Increment on each release
android {
namespace = "com.rosetta.messenger"
@@ -84,6 +84,10 @@ android {
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
jniLibs { useLegacyPackaging = true }
}
lint {
checkReleaseBuilds = false
abortOnError = false
}
applicationVariants.all {
outputs.all {

View File

@@ -1,5 +1,5 @@
package com.rosetta.messenger
// commit
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build

View File

@@ -686,13 +686,6 @@ class MessageRepository private constructor(private val context: Context) {
return true
}
// 🔥 ВТОРОЙ УРОВЕНЬ ЗАЩИТЫ: Проверка в БД (для сообщений сохранённых в предыдущих сессиях)
val isDuplicate = messageDao.messageExists(account, messageId)
MessageLogger.logDuplicateCheck(messageId, isDuplicate)
if (isDuplicate) {
return true
}
val dialogOpponentKey =
when {
isGroupMessage -> packet.toPublicKey
@@ -701,6 +694,33 @@ class MessageRepository private constructor(private val context: Context) {
}
val dialogKey = getDialogKey(dialogOpponentKey)
// 🔥 ВТОРОЙ УРОВЕНЬ ЗАЩИТЫ: Проверка в БД (для сообщений сохранённых в предыдущих сессиях)
val isDuplicate = messageDao.messageExists(account, messageId)
MessageLogger.logDuplicateCheck(messageId, isDuplicate)
if (isDuplicate) {
// Desktop/server parity:
// own messages that arrive via sync must be treated as delivered.
// If a local optimistic row already exists (WAITING/ERROR), normalize it.
if (isOwnMessage) {
messageDao.updateDeliveryStatus(account, messageId, DeliveryStatus.DELIVERED.value)
messageCache[dialogKey]?.let { flow ->
flow.value =
flow.value.map { msg ->
if (msg.messageId == messageId) {
msg.copy(deliveryStatus = DeliveryStatus.DELIVERED)
} else {
msg
}
}
}
_deliveryStatusEvents.tryEmit(
DeliveryStatusUpdate(dialogKey, messageId, DeliveryStatus.DELIVERED)
)
dialogDao.updateDialogFromMessages(account, dialogOpponentKey)
}
return true
}
try {
val groupKey =
if (isGroupMessage) {
@@ -955,20 +975,24 @@ class MessageRepository private constructor(private val context: Context) {
val readCount =
messageCache[dialogKey]?.value?.count {
it.isFromMe &&
!it.isRead &&
(it.deliveryStatus == DeliveryStatus.DELIVERED ||
it.deliveryStatus == DeliveryStatus.READ)
it.isFromMe && !it.isRead
} ?: 0
messageCache[dialogKey]?.let { flow ->
flow.value =
flow.value.map { msg ->
if (msg.isFromMe &&
!msg.isRead &&
(msg.deliveryStatus == DeliveryStatus.DELIVERED ||
msg.deliveryStatus == DeliveryStatus.READ)
) {
msg.copy(isRead = true, deliveryStatus = DeliveryStatus.READ)
if (msg.isFromMe && !msg.isRead) {
msg.copy(
isRead = true,
deliveryStatus =
if (
msg.deliveryStatus == DeliveryStatus.DELIVERED ||
msg.deliveryStatus == DeliveryStatus.READ
) {
DeliveryStatus.READ
} else {
msg.deliveryStatus
}
)
} else {
msg
}
@@ -1006,20 +1030,24 @@ class MessageRepository private constructor(private val context: Context) {
val readCount =
messageCache[dialogKey]?.value?.count {
it.isFromMe &&
!it.isRead &&
(it.deliveryStatus == DeliveryStatus.DELIVERED ||
it.deliveryStatus == DeliveryStatus.READ)
it.isFromMe && !it.isRead
} ?: 0
messageCache[dialogKey]?.let { flow ->
flow.value =
flow.value.map { msg ->
if (msg.isFromMe &&
!msg.isRead &&
(msg.deliveryStatus == DeliveryStatus.DELIVERED ||
msg.deliveryStatus == DeliveryStatus.READ)
) {
msg.copy(isRead = true, deliveryStatus = DeliveryStatus.READ)
if (msg.isFromMe && !msg.isRead) {
msg.copy(
isRead = true,
deliveryStatus =
if (
msg.deliveryStatus == DeliveryStatus.DELIVERED ||
msg.deliveryStatus == DeliveryStatus.READ
) {
DeliveryStatus.READ
} else {
msg.deliveryStatus
}
)
} else {
msg
}

View File

@@ -17,10 +17,12 @@ object ReleaseNotes {
val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER
Полноэкранный просмотр фото
- Убраны лишние искусственные отступы в fullscreen viewer
- Фото (включая большие скриншоты) снова открываются edge-to-edge, как в Telegram
- Исправлены большие чёрные бордеры вокруг изображения при открытии
Синхронизация 1 в 1 с desktop/server
- Выровнен сетевой контракт пакетов как в desktop: добавлена поддержка 0x10 (push), 0x1A (signal), 0x1B (webrtc), 0x1C (ice)
- Исправлена нормализация дубликатов своих сообщений из sync: локальные WAITING/ERROR теперь автоматически переходят в DELIVERED
- Добавлен watchdog для sync-запроса: если ответ на PacketSync завис, запрос перезапускается автоматически
- Повышена стабильность цикла BATCH_START/BATCH_END/NOT_NEEDED при reconnect
- Исправлена обработка PacketRead: read-статусы теперь ставятся как в desktop/wss, включая сценарии когда read приходит раньше delivery
""".trimIndent()
fun getNotice(version: String): String =

View File

@@ -403,7 +403,7 @@ interface MessageDao {
WHERE account = :account
AND to_public_key = :opponent
AND from_me = 1
AND delivered IN (1, 3)
AND read != 1
"""
)
suspend fun markAllAsRead(account: String, opponent: String): Int

View File

@@ -0,0 +1,47 @@
package com.rosetta.messenger.network
data class IceServer(
val url: String,
val username: String,
val credential: String,
val transport: String
)
/**
* ICE servers packet (ID: 0x1C / 28).
* Wire format mirrors desktop packet.ice.servers.ts.
*/
class PacketIceServers : Packet() {
var iceServers: List<IceServer> = emptyList()
override fun getPacketId(): Int = 0x1C
override fun receive(stream: Stream) {
val count = stream.readInt16()
val servers = ArrayList<IceServer>(count.coerceAtLeast(0))
for (i in 0 until count) {
servers.add(
IceServer(
url = stream.readString(),
username = stream.readString(),
credential = stream.readString(),
transport = stream.readString()
)
)
}
iceServers = servers
}
override fun send(): Stream {
val stream = Stream()
stream.writeInt16(getPacketId())
stream.writeInt16(iceServers.size)
for (server in iceServers) {
stream.writeString(server.url)
stream.writeString(server.username)
stream.writeString(server.credential)
stream.writeString(server.transport)
}
return stream
}
}

View File

@@ -0,0 +1,69 @@
package com.rosetta.messenger.network
enum class SignalType(val value: Int) {
CALL(0),
KEY_EXCHANGE(1),
ACTIVE_CALL(2),
END_CALL(3),
CREATE_ROOM(4),
END_CALL_BECAUSE_PEER_DISCONNECTED(5),
END_CALL_BECAUSE_BUSY(6);
companion object {
fun fromValue(value: Int): SignalType =
entries.firstOrNull { it.value == value } ?: CALL
}
}
/**
* Signaling packet (ID: 0x1A / 26).
* Wire format mirrors desktop packet.signal.peer.ts.
*/
class PacketSignalPeer : Packet() {
var src: String = ""
var dst: String = ""
var sharedPublic: String = ""
var signalType: SignalType = SignalType.CALL
var roomId: String = ""
override fun getPacketId(): Int = 0x1A
override fun receive(stream: Stream) {
signalType = SignalType.fromValue(stream.readInt8())
if (
signalType == SignalType.END_CALL_BECAUSE_BUSY ||
signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED
) {
return
}
src = stream.readString()
dst = stream.readString()
if (signalType == SignalType.KEY_EXCHANGE) {
sharedPublic = stream.readString()
}
if (signalType == SignalType.CREATE_ROOM) {
roomId = stream.readString()
}
}
override fun send(): Stream {
val stream = Stream()
stream.writeInt16(getPacketId())
stream.writeInt8(signalType.value)
if (
signalType == SignalType.END_CALL_BECAUSE_BUSY ||
signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED
) {
return stream
}
stream.writeString(src)
stream.writeString(dst)
if (signalType == SignalType.KEY_EXCHANGE) {
stream.writeString(sharedPublic)
}
if (signalType == SignalType.CREATE_ROOM) {
stream.writeString(roomId)
}
return stream
}
}

View File

@@ -0,0 +1,36 @@
package com.rosetta.messenger.network
enum class WebRTCSignalType(val value: Int) {
OFFER(0),
ANSWER(1),
ICE_CANDIDATE(2);
companion object {
fun fromValue(value: Int): WebRTCSignalType =
entries.firstOrNull { it.value == value } ?: OFFER
}
}
/**
* WebRTC exchange packet (ID: 0x1B / 27).
* Wire format mirrors desktop packet.webrtc.ts.
*/
class PacketWebRTC : Packet() {
var signalType: WebRTCSignalType = WebRTCSignalType.OFFER
var sdpOrCandidate: String = ""
override fun getPacketId(): Int = 0x1B
override fun receive(stream: Stream) {
signalType = WebRTCSignalType.fromValue(stream.readInt8())
sdpOrCandidate = stream.readString()
}
override fun send(): Stream {
val stream = Stream()
stream.writeInt16(getPacketId())
stream.writeInt8(signalType.value)
stream.writeString(sdpOrCandidate)
return stream
}
}

View File

@@ -127,6 +127,7 @@ class Protocol(
0x09 to { PacketDeviceNew() },
0x0A to { PacketRequestUpdate() },
0x0B to { PacketTyping() },
0x10 to { PacketPushNotification() },
0x11 to { PacketCreateGroup() },
0x12 to { PacketGroupInfo() },
0x13 to { PacketGroupInviteInfo() },
@@ -136,7 +137,10 @@ class Protocol(
0x0F to { PacketRequestTransport() },
0x17 to { PacketDeviceList() },
0x18 to { PacketDeviceResolve() },
0x19 to { PacketSync() }
0x19 to { PacketSync() },
0x1A to { PacketSignalPeer() },
0x1B to { PacketWebRTC() },
0x1C to { PacketIceServers() }
)
init {

View File

@@ -27,6 +27,7 @@ import kotlin.coroutines.resume
object ProtocolManager {
private const val TAG = "ProtocolManager"
private const val MANUAL_SYNC_BACKTRACK_MS = 120_000L
private const val SYNC_REQUEST_TIMEOUT_MS = 12_000L
private const val MAX_DEBUG_LOGS = 600
private const val DEBUG_LOG_FLUSH_DELAY_MS = 60L
private const val TYPING_INDICATOR_TIMEOUT_MS = 3_000L
@@ -45,6 +46,7 @@ object ProtocolManager {
@Volatile private var packetHandlersRegistered = false
@Volatile private var stateMonitoringStarted = false
@Volatile private var syncRequestInFlight = false
@Volatile private var syncRequestTimeoutJob: Job? = null
// Guard: prevent duplicate FCM token subscribe within a single session
@Volatile
@@ -210,6 +212,7 @@ object ProtocolManager {
}
if (newState != ProtocolState.AUTHENTICATED && newState != ProtocolState.HANDSHAKING) {
syncRequestInFlight = false
clearSyncRequestTimeout()
setSyncInProgress(false)
// Connection/session dropped: force re-subscribe on next AUTHENTICATED.
lastSubscribedToken = null
@@ -678,6 +681,7 @@ object ProtocolManager {
private fun finishSyncCycle(reason: String) {
syncRequestInFlight = false
clearSyncRequestTimeout()
inboundProcessingFailures.set(0)
addLog(reason)
setSyncInProgress(false)
@@ -734,6 +738,7 @@ object ProtocolManager {
val repository = messageRepository
if (repository == null || !repository.isInitialized()) {
syncRequestInFlight = false
clearSyncRequestTimeout()
requireResyncAfterAccountInit("⏳ Sync postponed until account is initialized")
return@launch
}
@@ -744,6 +749,7 @@ object ProtocolManager {
repositoryAccount != protocolAccount
) {
syncRequestInFlight = false
clearSyncRequestTimeout()
requireResyncAfterAccountInit(
"⏳ Sync postponed: repository bound to another account"
)
@@ -757,6 +763,7 @@ object ProtocolManager {
private fun sendSynchronize(timestamp: Long) {
syncRequestInFlight = true
scheduleSyncRequestTimeout(timestamp)
val packet = PacketSync().apply {
status = SyncStatus.NOT_NEEDED
this.timestamp = timestamp
@@ -777,6 +784,7 @@ object ProtocolManager {
*/
private fun handleSyncPacket(packet: PacketSync) {
syncRequestInFlight = false
clearSyncRequestTimeout()
when (packet.status) {
SyncStatus.BATCH_START -> {
addLog("🔄 SYNC BATCH_START — incoming message batch")
@@ -826,6 +834,24 @@ object ProtocolManager {
}
}
private fun scheduleSyncRequestTimeout(cursor: Long) {
syncRequestTimeoutJob?.cancel()
syncRequestTimeoutJob = scope.launch {
delay(SYNC_REQUEST_TIMEOUT_MS)
if (!syncRequestInFlight || !isAuthenticated()) return@launch
syncRequestInFlight = false
addLog(
"⏱️ SYNC response timeout for cursor=$cursor, retrying request"
)
requestSynchronize()
}
}
private fun clearSyncRequestTimeout() {
syncRequestTimeoutJob?.cancel()
syncRequestTimeoutJob = null
}
/**
* Retry messages stuck in WAITING status on reconnect.
* Desktop has in-memory _packetQueue that flushes on handshake, but desktop apps are
@@ -1325,6 +1351,7 @@ object ProtocolManager {
_devices.value = emptyList()
_pendingDeviceVerification.value = null
syncRequestInFlight = false
clearSyncRequestTimeout()
setSyncInProgress(false)
resyncRequiredAfterAccountInit = false
lastSubscribedToken = null // reset so token is re-sent on next connect
@@ -1341,6 +1368,7 @@ object ProtocolManager {
_devices.value = emptyList()
_pendingDeviceVerification.value = null
syncRequestInFlight = false
clearSyncRequestTimeout()
setSyncInProgress(false)
resyncRequiredAfterAccountInit = false
scope.cancel()