From ab9145c77a7fcedcf11a12332c822b332a92c47b Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Thu, 16 Apr 2026 03:35:37 +0500 Subject: [PATCH 1/2] =?UTF-8?q?=D0=98=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20race=20=D0=B8=D0=BD=D0=B8=D1=86=D0=B8=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20=D0=B0=D0=BA=D0=BA?= =?UTF-8?q?=D0=B0=D1=83=D0=BD=D1=82=D0=B0=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5?= =?UTF-8?q?=20device=20verification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/rosetta/messenger/MainActivity.kt | 100 +++++++++++++++--- .../com/rosetta/messenger/network/Protocol.kt | 31 ++++++ .../messenger/network/ProtocolManager.kt | 21 +++- .../messenger/ui/auth/SetPasswordScreen.kt | 4 + .../rosetta/messenger/ui/auth/UnlockScreen.kt | 4 + 5 files changed, 144 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/rosetta/messenger/MainActivity.kt b/app/src/main/java/com/rosetta/messenger/MainActivity.kt index 5a101c6..c8fe74f 100644 --- a/app/src/main/java/com/rosetta/messenger/MainActivity.kt +++ b/app/src/main/java/com/rosetta/messenger/MainActivity.kt @@ -225,7 +225,27 @@ class MainActivity : FragmentActivity() { LaunchedEffect(Unit) { val accounts = accountManager.getAllAccounts() hasExistingAccount = accounts.isNotEmpty() - accountInfoList = accounts.map { it.toAccountInfo() } + val infos = accounts.map { it.toAccountInfo() } + accountInfoList = infos + + // Reconcile process-cached account name with persisted profile data. + currentAccount?.let { cached -> + val persisted = infos.firstOrNull { + it.publicKey.equals(cached.publicKey, ignoreCase = true) + } + val persistedUsername = persisted?.username?.trim().orEmpty().ifBlank { null } + val normalizedCachedName = + resolveAccountDisplayName( + cached.publicKey, + persisted?.name ?: cached.name, + persistedUsername + ) + if (normalizedCachedName != cached.name) { + val updated = cached.copy(name = normalizedCachedName) + currentAccount = updated + cacheSessionAccount(updated) + } + } } // Wait for initial load @@ -305,15 +325,29 @@ class MainActivity : FragmentActivity() { onAuthComplete = { account -> startCreateAccountFlow = false val normalizedAccount = - account?.let { + account?.let { decrypted -> + val persisted = + accountInfoList.firstOrNull { + it.publicKey.equals( + decrypted.publicKey, + ignoreCase = true + ) + } + val persistedUsername = + persisted?.username + ?.trim() + .orEmpty() + .ifBlank { null } val normalizedName = resolveAccountDisplayName( - it.publicKey, - it.name, - null + decrypted.publicKey, + persisted?.name + ?: decrypted.name, + persistedUsername ) - if (it.name == normalizedName) it - else it.copy(name = normalizedName) + if (decrypted.name == normalizedName) + decrypted + else decrypted.copy(name = normalizedName) } currentAccount = normalizedAccount cacheSessionAccount(normalizedAccount) @@ -321,6 +355,14 @@ class MainActivity : FragmentActivity() { // Save as last logged account normalizedAccount?.let { accountManager.setLastLoggedPublicKey(it.publicKey) + // Initialize protocol/message account context + // immediately after auth completion to avoid + // packet processing race before MainScreen + // composition. + ProtocolManager.initializeAccount( + it.publicKey, + it.privateKey + ) } // Первый запуск после регистрации: @@ -354,6 +396,27 @@ class MainActivity : FragmentActivity() { runCatching { accountManager.setCurrentAccount(it.publicKey) } + + // Force-refresh account title from persisted + // profile (name/username) to avoid temporary + // public-key alias in UI after login. + val persisted = accountManager.getAccount(it.publicKey) + val persistedUsername = + persisted?.username + ?.trim() + .orEmpty() + .ifBlank { null } + val refreshedName = + resolveAccountDisplayName( + it.publicKey, + persisted?.name ?: it.name, + persistedUsername + ) + if (refreshedName != it.name) { + val updated = it.copy(name = refreshedName) + currentAccount = updated + cacheSessionAccount(updated) + } } val accounts = accountManager.getAllAccounts() accountInfoList = accounts.map { it.toAccountInfo() } @@ -367,9 +430,9 @@ class MainActivity : FragmentActivity() { // lag currentAccount = null clearCachedSessionAccount() + com.rosetta.messenger.network.ProtocolManager + .disconnect() scope.launch { - com.rosetta.messenger.network.ProtocolManager - .disconnect() accountManager.logout() } } @@ -416,9 +479,9 @@ class MainActivity : FragmentActivity() { // lag currentAccount = null clearCachedSessionAccount() + com.rosetta.messenger.network.ProtocolManager + .disconnect() scope.launch { - com.rosetta.messenger.network.ProtocolManager - .disconnect() accountManager.logout() } }, @@ -509,8 +572,8 @@ class MainActivity : FragmentActivity() { // Switch to another account: logout current, then show unlock. currentAccount = null clearCachedSessionAccount() + com.rosetta.messenger.network.ProtocolManager.disconnect() scope.launch { - com.rosetta.messenger.network.ProtocolManager.disconnect() accountManager.logout() } }, @@ -520,8 +583,8 @@ class MainActivity : FragmentActivity() { preservedMainNavAccountKey = "" currentAccount = null clearCachedSessionAccount() + com.rosetta.messenger.network.ProtocolManager.disconnect() scope.launch { - com.rosetta.messenger.network.ProtocolManager.disconnect() accountManager.logout() } } @@ -535,8 +598,8 @@ class MainActivity : FragmentActivity() { preservedMainNavAccountKey = "" currentAccount = null clearCachedSessionAccount() + ProtocolManager.disconnect() scope.launch { - ProtocolManager.disconnect() accountManager.logout() } } @@ -941,6 +1004,15 @@ fun MainScreen( CallManager.bindAccount(accountPublicKey) } + // Global account binding for protocol/message repository. + // Keeps init independent from ChatsList composition timing. + LaunchedEffect(accountPublicKey, accountPrivateKey) { + val normalizedPublicKey = accountPublicKey.trim() + val normalizedPrivateKey = accountPrivateKey.trim() + if (normalizedPublicKey.isBlank() || normalizedPrivateKey.isBlank()) return@LaunchedEffect + ProtocolManager.initializeAccount(normalizedPublicKey, normalizedPrivateKey) + } + LaunchedEffect(callUiState.isVisible) { if (callUiState.isVisible) { isCallOverlayExpanded = true diff --git a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt index 65943f4..e7d8f4d 100644 --- a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt +++ b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt @@ -301,6 +301,32 @@ class Protocol( startHeartbeat(packet.heartbeatInterval) } } + + // Device verification resolution from primary device. + // Desktop typically continues after next handshake response; here we also + // add a safety re-handshake trigger on ACCEPT to avoid being stuck in + // DEVICE_VERIFICATION_REQUIRED if server doesn't immediately push 0x00. + waitPacket(0x18) { packet -> + val resolve = packet as? PacketDeviceResolve ?: return@waitPacket + when (resolve.solution) { + DeviceResolveSolution.ACCEPT -> { + log("✅ DEVICE VERIFICATION ACCEPTED (deviceId=${shortKey(resolve.deviceId, 12)})") + if (_state.value == ProtocolState.DEVICE_VERIFICATION_REQUIRED) { + setState(ProtocolState.CONNECTED, "Device verification accepted") + val publicKey = lastPublicKey + val privateHash = lastPrivateHash + if (!publicKey.isNullOrBlank() && !privateHash.isNullOrBlank()) { + startHandshake(publicKey, privateHash, lastDevice) + } else { + log("⚠️ ACCEPT received but credentials are missing, waiting for reconnect") + } + } + } + DeviceResolveSolution.DECLINE -> { + log("⛔ DEVICE VERIFICATION DECLINED (deviceId=${shortKey(resolve.deviceId, 12)})") + } + } + } } /** @@ -847,6 +873,11 @@ class Protocol( "⚡ FAST RECONNECT CHECK: state=$currentState, hasCredentials=$hasCredentials, isConnecting=$isConnecting, reason=$reason" ) + if (isManuallyClosed) { + log("⚡ FAST RECONNECT SKIP: manually closed, reason=$reason") + return + } + if (!hasCredentials) return if (currentState == ProtocolState.CONNECTING && isConnecting) { 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 c665119..2f2d427 100644 --- a/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt +++ b/app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt @@ -293,11 +293,28 @@ object ProtocolManager { * Должен вызываться после авторизации пользователя */ fun initializeAccount(publicKey: String, privateKey: String) { + val normalizedPublicKey = publicKey.trim() + val normalizedPrivateKey = privateKey.trim() + if (normalizedPublicKey.isBlank() || normalizedPrivateKey.isBlank()) { + addLog("⚠️ initializeAccount skipped: missing account credentials") + return + } + + addLog( + "🔐 initializeAccount pk=${shortKeyForLog(normalizedPublicKey)} keyLen=${normalizedPrivateKey.length} state=${getProtocol().state.value}" + ) setSyncInProgress(false) clearTypingState() - messageRepository?.initialize(publicKey, privateKey) - if (resyncRequiredAfterAccountInit || protocol?.isAuthenticated() == true) { + messageRepository?.initialize(normalizedPublicKey, normalizedPrivateKey) + + val shouldResync = resyncRequiredAfterAccountInit || protocol?.isAuthenticated() == true + if (shouldResync) { + // Late account init may happen while an old sync request flag is still set. + // Force a fresh synchronize request to recover dropped inbound packets. resyncRequiredAfterAccountInit = false + syncRequestInFlight = false + clearSyncRequestTimeout() + addLog("🔄 Account initialized (${shortKeyForLog(normalizedPublicKey)}) -> force sync") requestSynchronize() } // Send "Rosetta Updates" message on version change (like desktop useUpdateMessage) 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 0cec146..2d0459d 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 @@ -29,6 +29,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.network.ProtocolManager import com.rosetta.messenger.ui.onboarding.PrimaryBlue import kotlinx.coroutines.launch @@ -308,6 +309,9 @@ fun SetPasswordScreen( ) accountManager.saveAccount(account) val privateKeyHash = CryptoManager.generatePrivateKeyHash(keyPair.privateKey) + // Initialize repository/account context before handshake completes to avoid + // "Sync postponed until account is initialized" race on first login. + ProtocolManager.initializeAccount(keyPair.publicKey, keyPair.privateKey) startAuthHandshakeFast(keyPair.publicKey, privateKeyHash) accountManager.setCurrentAccount(keyPair.publicKey) val decryptedAccount = DecryptedAccount( 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 f9b4c87..b7365f4 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,6 +45,7 @@ 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.network.ProtocolManager import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.chats.getAvatarColor @@ -116,6 +117,9 @@ val decryptedPrivateKey = CryptoManager.decryptWithPassword( name = selectedAccount.name ) + // Initialize repository/account context before handshake completes to avoid + // "Sync postponed until account is initialized" race. + ProtocolManager.initializeAccount(account.publicKey, decryptedPrivateKey) startAuthHandshakeFast(account.publicKey, privateKeyHash) accountManager.setCurrentAccount(account.publicKey) From 2066eb9f0386900e0b83dcc98458e5d098c15792 Mon Sep 17 00:00:00 2001 From: k1ngsterr1 Date: Thu, 16 Apr 2026 23:00:07 +0500 Subject: [PATCH 2/2] =?UTF-8?q?=D0=9A=D1=80=D0=B8=D1=82=D0=B8=D1=87=D0=B5?= =?UTF-8?q?=D1=81=D0=BA=D0=B8=D0=B9=20=D1=84=D0=B8=D0=BA=D1=81=20=D0=BE?= =?UTF-8?q?=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=BF=D0=BE=D1=81?= =?UTF-8?q?=D0=BB=D0=B5=20=D0=B2=D0=B5=D1=80=D0=B8=D1=84=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D0=B8=20=D1=83=D1=81=D1=82=D1=80=D0=BE=D0=B9=D1=81?= =?UTF-8?q?=D1=82=D0=B2=D0=B0=20=D0=B8=20=D1=80=D0=B5=D0=BB=D0=B8=D0=B7=20?= =?UTF-8?q?1.5.2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 +- .../rosetta/messenger/data/ReleaseNotes.kt | 37 ++++++----- .../com/rosetta/messenger/network/Protocol.kt | 63 +++++++++++++++---- 3 files changed, 74 insertions(+), 30 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 81e9a6c..eae802f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -23,8 +23,8 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown" // ═══════════════════════════════════════════════════════════ // Rosetta versioning — bump here on each release // ═══════════════════════════════════════════════════════════ -val rosettaVersionName = "1.5.1" -val rosettaVersionCode = 53 // Increment on each release +val rosettaVersionName = "1.5.2" +val rosettaVersionCode = 54 // Increment on each release val customWebRtcAar = file("libs/libwebrtc-custom.aar") android { diff --git a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt index 392ffda..eeaba7d 100644 --- a/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt +++ b/app/src/main/java/com/rosetta/messenger/data/ReleaseNotes.kt @@ -17,22 +17,27 @@ object ReleaseNotes { val RELEASE_NOTICE = """ Update v$VERSION_PLACEHOLDER - - Полностью переработан UX записи голосовых: удержание для записи, отправка по отпусканию, Slide to cancel - - Пересобрана панель записи ГС в Telegram-style с новым layout, волнами и анимациями - - Добавлена и доработана анимация удаления ГС (корзина), устранены рывки и визуальные артефакты - - Исправлены зависания/ANR при записи и отмене голосовых (race-condition, stuck-состояния, watchdog-сценарии) - - Исправлены скачки и наложения input-панели во время записи (включая Type message/overlay конфликты) - - Добавлены улучшения плеера голосовых: мини-плеер, интеграция в чат, корректная работа скоростей - - В чат-листе улучшено отображение и поведение активного воспроизведения голосовых - - Добавлена и отшлифована система выделения текста: handles, magnifier, toolbar (Copy/Select All), haptic - - Исправлены координаты и стабильность выделения текста в сложных сценариях - - Исправлена обработка reply в группах с Desktop (fallback на hex-ключ для reply blob) - - Оптимизированы тяжелые UI-сценарии: prewarm для circular reveal, ускорена анимация онбординга - - Улучшены миниатюры медиа через BlurHash и стабильность загрузки вложений - - Доработан экран звонков и related UI (включая пустой экран с Lottie-анимацией) - - Доработаны элементы профиля и сайдбара (включая обновления аккаунт-блока и действий) - - Добавлена смена иконки приложения (калькулятор, погода, заметки) через настройки - - Выполнен большой пакет фиксов по чатам/звонкам/коннекту и визуальному паритету с Telegram + - Перемотка голосовых полностью переработана в Telegram-style: drag по waveform и точный seek по отпусканию + - Устранены конфликты жестов у ГС: tap/drag/scrub больше не конфликтуют со swipe-to-reply и swipe-back + - Голосовой плеер доработан: стабильный scrub в паузе, корректный keepPaused, более надежный прогресс + - Добавлена очередь ГС внутри диалога с автопереходом к следующему голосовому по хронологии + - Улучшена совместимость payload голосовых (hex/base64 decode fallback) и восстановление аудиофайла из кэша + - Исправлено позиционирование и clipping кнопки записи в input-панели + - Добавлен haptic при старте записи и обновлены иконки записи voice/video + - В сайдбаре ограничен список аккаунтов (до 3) для более чистого Telegram-like layout + - Исправлен transition emoji -> keyboard: убран «пустой» зазор при закрытии emoji-панели + - В selection header чата добавлена кнопка Pin/Unpin для выбранного сообщения + - В Forward-пикере всегда показывается Saved Messages (даже если self-диалог ещё не создан) + - Переработаны media-permissions в attach/media picker: корректный permanently denied flow с переходом в Settings + - Улучшена инициализация аккаунта после login/unlock/create-account, устранён race «Sync postponed until account is initialized» + - Доработана синхронизация профиля аккаунта (name/username/verified), включая замену placeholder-имён + - Исправлен критичный баг отправки после верификации нового устройства на втором девайсе + - Исправлен reconnect overflow: устранена отрицательная задержка (-2147483648000ms) и дубли disconnect/reconnect + - Улучшена обработка device verification (ACCEPT/DECLINE) и reconnect-логика протокола + - Звонки: добавлен proximity manager (экран гаснет возле уха), добавлен WAKE_LOCK, учтён speaker on/off + - Звонки: рингтон теперь учитывает системный ringer mode (silent/vibrate), снижены ложные звуковые срабатывания + - Убраны дубли CALL-attachments у callee: источник call-события теперь единый (каноничный от caller) + - Групповые сообщения: fallback для plaintext-пакетов без group key и расширенная диагностика decrypt-ошибок """.trimIndent() fun getNotice(version: String): String = diff --git a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt index e7d8f4d..eeeea78 100644 --- a/app/src/main/java/com/rosetta/messenger/network/Protocol.kt +++ b/app/src/main/java/com/rosetta/messenger/network/Protocol.kt @@ -311,14 +311,35 @@ class Protocol( when (resolve.solution) { DeviceResolveSolution.ACCEPT -> { log("✅ DEVICE VERIFICATION ACCEPTED (deviceId=${shortKey(resolve.deviceId, 12)})") - if (_state.value == ProtocolState.DEVICE_VERIFICATION_REQUIRED) { + val stateAtAccept = _state.value + if (stateAtAccept == ProtocolState.AUTHENTICATED) { + log("✅ ACCEPT ignored: already authenticated") + return@waitPacket + } + + if (stateAtAccept == ProtocolState.DEVICE_VERIFICATION_REQUIRED) { setState(ProtocolState.CONNECTED, "Device verification accepted") - val publicKey = lastPublicKey - val privateHash = lastPrivateHash - if (!publicKey.isNullOrBlank() && !privateHash.isNullOrBlank()) { + } + + val publicKey = lastPublicKey + val privateHash = lastPrivateHash + if (publicKey.isNullOrBlank() || privateHash.isNullOrBlank()) { + log("⚠️ ACCEPT received but credentials are missing, waiting for reconnect") + return@waitPacket + } + + when (_state.value) { + ProtocolState.DISCONNECTED -> { + log("🔄 ACCEPT while disconnected -> reconnecting") + connect() + } + + ProtocolState.CONNECTING -> { + log("⏳ ACCEPT while connecting -> waiting for onOpen auto-handshake") + } + + else -> { startHandshake(publicKey, privateHash, lastDevice) - } else { - log("⚠️ ACCEPT received but credentials are missing, waiting for reconnect") } } } @@ -644,7 +665,14 @@ class Protocol( val currentState = _state.value val socket = webSocket val socketReady = socket != null - val authReady = handshakeComplete && currentState == ProtocolState.AUTHENTICATED + val authReady = currentState == ProtocolState.AUTHENTICATED + if (authReady && !handshakeComplete) { + // Defensive self-heal: + // AUTHENTICATED state must imply completed handshake. + // If these flags diverge, message sending can be stuck in queue forever. + log("⚠️ AUTHENTICATED with handshakeComplete=false -> self-heal handshakeComplete=true") + handshakeComplete = true + } val preAuthAllowedPacket = packet is PacketSignalPeer || packet is PacketWebRTC || packet is PacketIceServers val preAuthReady = @@ -772,6 +800,13 @@ class Protocol( private fun handleDisconnect() { val previousState = _state.value log("🔌 DISCONNECT HANDLER: previousState=$previousState, manuallyClosed=$isManuallyClosed, reconnectAttempts=$reconnectAttempts, isConnecting=$isConnecting") + + // Duplicate callbacks are possible (e.g. heartbeat failure + onFailure/onClosed). + // If we are already disconnected and a reconnect is pending, avoid scheduling another one. + if (previousState == ProtocolState.DISCONNECTED && reconnectJob?.isActive == true) { + log("⚠️ DISCONNECT DUPLICATE: reconnect already scheduled, skipping") + return + } // КРИТИЧНО: если уже идет подключение, не делаем ничего if (isConnecting) { @@ -799,12 +834,16 @@ class Protocol( // КРИТИЧНО: отменяем предыдущий reconnect job если есть reconnectJob?.cancel() - // Экспоненциальная задержка: 1s, 2s, 4s, 8s, максимум 30s - val delayMs = minOf(1000L * (1 shl minOf(reconnectAttempts - 1, 4)), 30000L) - log("🔄 SCHEDULING RECONNECT: attempt #$reconnectAttempts, delay=${delayMs}ms") + // Экспоненциальная задержка: 1s, 2s, 4s, 8s, 16s, максимум 30s. + // IMPORTANT: reconnectAttempts may be 0 right after AUTHENTICATED reset. + // Using (1 shl -1) causes overflow (seen in logs as -2147483648000ms). + val nextAttemptNumber = (reconnectAttempts + 1).coerceAtLeast(1) + val exponent = (nextAttemptNumber - 1).coerceIn(0, 4) + val delayMs = minOf(1000L * (1L shl exponent), 30000L) + log("🔄 SCHEDULING RECONNECT: attempt #$nextAttemptNumber, delay=${delayMs}ms") - if (reconnectAttempts > 20) { - log("⚠️ WARNING: Too many reconnect attempts ($reconnectAttempts), may be stuck in loop") + if (nextAttemptNumber > 20) { + log("⚠️ WARNING: Too many reconnect attempts ($nextAttemptNumber), may be stuck in loop") } reconnectJob = scope.launch {