Compare commits
5 Commits
db55225d84
...
ebb95905b5
| Author | SHA1 | Date | |
|---|---|---|---|
| ebb95905b5 | |||
| f915333a44 | |||
| 69c0c377d1 | |||
| 30fbc41245 | |||
| 677a5f2ab2 |
@@ -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: |
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
package com.rosetta.messenger
|
||||
|
||||
// commit
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user