Compare commits
213 Commits
fbae1283ca
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| b32d8ed061 | |||
| 5e6d66b762 | |||
| 15bca1ec34 | |||
| aa0fa3fdb1 | |||
| cedbd204c2 | |||
| 660ba12c8c | |||
| 7f4684082e | |||
| 1a57d8f4d0 | |||
| 1cf645ea3f | |||
| 17f37b06ec | |||
| d008485a9d | |||
| 95ec00547c | |||
| edd0e73de9 | |||
| 7199e174f1 | |||
| 7521b9a11b | |||
| 484c02c867 | |||
| 53e2119feb | |||
| 664f9fd7ae | |||
| 103ae134a5 | |||
| 2066eb9f03 | |||
| 2fc652cacb | |||
| 6242e3c34f | |||
| 0c150a3113 | |||
| ab9145c77a | |||
| 45134665b3 | |||
| 38ae9bca66 | |||
| 0d21769399 | |||
| 060d0cbd12 | |||
| 4396611355 | |||
| ce7f913de7 | |||
| cb920b490d | |||
| b1fc623f5e | |||
| ad08af7f0c | |||
| 9fe5f35923 | |||
| 78925dd61d | |||
| 1ac3d93f74 | |||
| 6ad24974e0 | |||
| e825a1ef30 | |||
| 7fcf1195e1 | |||
| a10482b794 | |||
| 419761e34d | |||
| 988896c080 | |||
| b57e48fe20 | |||
| 5c02ff6fd3 | |||
| 7630aa6874 | |||
| afebbf6acb | |||
| aa3cc76646 | |||
| 8dac52c2eb | |||
| 946ba7838c | |||
| 78fbe0b3c8 | |||
| b13cdb7ea1 | |||
| b6055c98a5 | |||
| 3e3f501b9b | |||
| 620200ca44 | |||
| 47a6e20834 | |||
| fad8bfb1d1 | |||
| 5e5c4c11ac | |||
| 8d8b02a3ec | |||
| 6124a52c84 | |||
| 3485cb458f | |||
| 0dd3255cfe | |||
| accf34f233 | |||
| 30327fade2 | |||
| e5ff42ce1d | |||
| 06f43b9d4e | |||
| 655cc10a3e | |||
| d02f03516c | |||
| d94b3ec37a | |||
| 73d3b2baf6 | |||
| 66cc21fc29 | |||
| 3bef589274 | |||
| 7f79e4e0be | |||
| ae78e4a162 | |||
| 325073fc09 | |||
| 8bfbba3159 | |||
| 0427e2ba17 | |||
| 1e259f52ee | |||
| 299c84cb89 | |||
| 14d7fc6eb1 | |||
| 9fafa52483 | |||
| ecac56773a | |||
| 43422bb131 | |||
| b81b38f40d | |||
| 19508090a5 | |||
| 6d14881fa2 | |||
| 081bdb6d30 | |||
| ead84a8a53 | |||
| 152106eda1 | |||
| b8c5529b29 | |||
| 9d04ec07e8 | |||
| 9e14724ae2 | |||
| 2bb3281ccf | |||
| 7d4b9a8fc4 | |||
| 6886a6cef1 | |||
| a9be1282c6 | |||
| 3217aeaeeb | |||
| c90136f563 | |||
| 8bc1f15bdd | |||
| 876c1ab4df | |||
| 803fda9abe | |||
| 7beb722c65 | |||
| 89ad59b1f8 | |||
| fe1a7fed3d | |||
| 480fc9a1d0 | |||
| 0558a57942 | |||
| 566e1f6c2e | |||
| 676c205666 | |||
| b9ac7791f6 | |||
| 20bef53869 | |||
| 2ff1383b13 | |||
| 727b902df7 | |||
| 89259b2a46 | |||
| ce6bc985be | |||
| ff854e919e | |||
| 434ccef30c | |||
| 26f4597c3b | |||
| fa1288479f | |||
| 46b1b3a6f1 | |||
| aa40f5287c | |||
| b271917594 | |||
| 4cfa9f1d48 | |||
| 20c6696fdf | |||
| 3eac17d9a8 | |||
| 84aad5f094 | |||
| e7efe0856c | |||
| 93a2de315a | |||
| c3e97eee56 | |||
| 39b0b0e107 | |||
| 51f76b5073 | |||
| c9fa12a690 | |||
| ec541a2c0c | |||
| 454402938c | |||
| 0fc637b42a | |||
| 83f6b49ba3 | |||
| b663450db5 | |||
| 9cca071bd8 | |||
| 0af4e6587e | |||
| 31db795c56 | |||
| 9202204094 | |||
| 03282eb478 | |||
| 59addf4373 | |||
| 3953d93207 | |||
| de958e10a1 | |||
| 3fffbd0392 | |||
| bc7efbfbd9 | |||
| eea650face | |||
| 530047c5d0 | |||
| 419101a4a9 | |||
| 9778e3b196 | |||
| 4664aa9482 | |||
| ebb95905b5 | |||
| f915333a44 | |||
| 69c0c377d1 | |||
| 30fbc41245 | |||
| 677a5f2ab2 | |||
| db55225d84 | |||
| 7a188a2dbc | |||
| a3973b616e | |||
| 3a595c02b3 | |||
| 8e743e710a | |||
| 8fdbfb4e5f | |||
| ce16802ac3 | |||
| 9d3e5bcb10 | |||
| d90554aa9f | |||
| c929685e04 | |||
| b2558653b7 | |||
| 58455cf32a | |||
| e5a68439f8 | |||
| b85c553507 | |||
| 9afbbae5c9 | |||
| 4440016d5f | |||
| 0353f845a5 | |||
| 004b54ec7c | |||
| 5ecb2a8db4 | |||
| f34e520d03 | |||
| 1ba173be54 | |||
| d41674ff78 | |||
| bd6e033ed3 | |||
| 72a2cf1b70 | |||
| 2cf64e80eb | |||
| 2602084764 | |||
| 420ea6e560 | |||
| 53946e2e6e | |||
| 4d4130fefd | |||
| 09df7586e7 | |||
| 13b61cf720 | |||
| 4640b0128f | |||
| 5a754f6643 | |||
| c6e9acdac6 | |||
| 6e14213b5c | |||
| af4a3a5f27 | |||
| 9a411ac473 | |||
| 75d0f4726b | |||
| 581a44b270 | |||
| cd325bea87 | |||
| b918b45603 | |||
| 5e66437239 | |||
| 670093c8fe | |||
| c2198b624d | |||
| 807309a812 | |||
| 479fdd0074 | |||
| b1d4458484 | |||
| b5398e2f1d | |||
| bae665f89d | |||
| a5ec0595ad | |||
| d78fb184c6 | |||
| ddd98a8065 | |||
| d9c54b2d05 | |||
| 494b459e39 | |||
| 8c2e30b4d8 | |||
| 0aa34e75c9 | |||
| 43bcfdff1b | |||
| 982dfc5dff |
@@ -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-ndk26
|
||||
|
||||
- name: Install Android SDK
|
||||
run: |
|
||||
export ANDROID_HOME="$HOME/android-sdk"
|
||||
@@ -61,9 +67,20 @@ jobs:
|
||||
"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
|
||||
"platforms;android-34" \
|
||||
"build-tools;34.0.0" \
|
||||
"platform-tools"
|
||||
"platform-tools" \
|
||||
"ndk;25.1.8937393" \
|
||||
"cmake;3.22.1"
|
||||
echo "ANDROID_HOME=$ANDROID_HOME" >> $GITHUB_ENV
|
||||
echo "ANDROID_SDK_ROOT=$ANDROID_HOME" >> $GITHUB_ENV
|
||||
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/25.1.8937393" >> $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: |
|
||||
@@ -76,10 +93,35 @@ 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: Configure local.properties
|
||||
run: |
|
||||
echo "sdk.dir=$ANDROID_HOME" > local.properties
|
||||
echo "ndk.dir=$ANDROID_HOME/ndk/25.1.8937393" >> local.properties
|
||||
echo "cmake.dir=$ANDROID_HOME/cmake/3.22.1" >> local.properties
|
||||
cat local.properties
|
||||
|
||||
- name: Build Release APK
|
||||
run: ./gradlew assembleRelease
|
||||
run: ./gradlew --no-daemon -Dorg.gradle.jvmargs="-Xmx2g" assembleRelease
|
||||
|
||||
- name: Check if APK exists
|
||||
run: |
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
# Built application files
|
||||
*.apk
|
||||
*.aar
|
||||
# *.aar — кастомный WebRTC разрешён в app/libs/
|
||||
!app/libs/*.aar
|
||||
*.ap_
|
||||
*.aab
|
||||
|
||||
|
||||
593
Architecture.md
Normal file
593
Architecture.md
Normal file
@@ -0,0 +1,593 @@
|
||||
# Rosetta Android — Architecture
|
||||
|
||||
> Документ отражает текущее состояние `rosetta-android` (ветка `dev`) по коду на 2026-04-19.
|
||||
|
||||
## 1. Архитектурный профиль
|
||||
|
||||
Приложение сейчас устроено как layered + service-oriented архитектура:
|
||||
- UI: `MainActivity` + Compose-экраны + ViewModel.
|
||||
- Chat feature orchestration: `ChatViewModel` (host-state) + feature-facade VM + coordinators.
|
||||
- DI: Hilt (`@HiltAndroidApp`, `@AndroidEntryPoint`, модули в `di/AppContainer.kt`).
|
||||
- Runtime orchestration: `ProtocolGateway`/`ProtocolRuntime` -> `RuntimeComposition` (+ legacy facade `ProtocolManager`), `CallManager`, `TransportManager`, `UpdateManager`.
|
||||
- Session/Identity runtime state: `SessionStore`, `SessionReducer`, `IdentityStore`.
|
||||
- Domain сценарии отправки чата: `domain/chats/usecase/*` (text/media/forward/voice/typing/read-receipt/attachments/upload).
|
||||
- Data: `MessageRepository`, `GroupRepository`, `AccountManager`, `PreferencesManager`.
|
||||
- Persistence: Room (`RosettaDatabase`) + DataStore/SharedPreferences.
|
||||
|
||||
Основная runtime-логика сети вынесена в `RuntimeComposition`, а DI-вход в runtime идет напрямую через `ProtocolRuntime`.
|
||||
`ProtocolManager` переведен в минимальный legacy compatibility facade поверх `ProtocolRuntimeAccess`.
|
||||
DI-вход в network core идет через `ProtocolRuntime` (Hilt singleton).
|
||||
|
||||
---
|
||||
|
||||
## 2. Слои и границы
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph ENTRY["Android Entry Points"]
|
||||
E1["RosettaApplication"]
|
||||
E2["MainActivity"]
|
||||
E3["RosettaFirebaseMessagingService"]
|
||||
E4["IncomingCallActivity / CallForegroundService"]
|
||||
end
|
||||
|
||||
subgraph DI["Hilt Singleton Graph"]
|
||||
D1["ProtocolGateway -> ProtocolRuntime"]
|
||||
D2["SessionCoordinator"]
|
||||
D3["IdentityGateway"]
|
||||
D4["AccountManager / PreferencesManager"]
|
||||
D5["MessageRepository / GroupRepository"]
|
||||
end
|
||||
|
||||
subgraph CHAT_UI["Chat UI Orchestration"]
|
||||
C1["ChatDetailScreen / ChatsListScreen"]
|
||||
C2["ChatViewModel (host-state)"]
|
||||
C3["Feature VM: Messages/Voice/Attachments/Typing"]
|
||||
C4["Coordinators: Messages/Forward/Attachments"]
|
||||
end
|
||||
|
||||
subgraph CHAT_DOMAIN["Chat Domain UseCases"]
|
||||
U1["SendText / SendMedia / SendForward"]
|
||||
U2["SendVoice / SendTyping / SendReadReceipt"]
|
||||
U3["CreateAttachment / EncryptAndUpload / VideoCircle"]
|
||||
end
|
||||
|
||||
subgraph SESSION["Session / Identity Runtime"]
|
||||
S1["SessionStore / SessionReducer"]
|
||||
S2["IdentityStore / AppSessionCoordinator"]
|
||||
end
|
||||
|
||||
subgraph NET["Network Runtime"]
|
||||
N0["ProtocolRuntime"]
|
||||
N1["RuntimeComposition (wiring only)"]
|
||||
N2["RuntimeConnectionControlFacade"]
|
||||
N3["RuntimeDirectoryFacade"]
|
||||
N4["RuntimePacketIoFacade"]
|
||||
N5["Assemblies: Transport / Messaging / State / Routing"]
|
||||
N6["ProtocolInstanceManager -> Protocol"]
|
||||
N7["ProtocolManager (legacy compat)"]
|
||||
end
|
||||
|
||||
subgraph DATA["Data + Persistence"]
|
||||
R1["MessageRepository / GroupRepository"]
|
||||
R2["Room: RosettaDatabase"]
|
||||
end
|
||||
|
||||
ENTRY --> DI
|
||||
DI --> SESSION
|
||||
DI --> DATA
|
||||
DI --> CHAT_UI
|
||||
DI --> N0
|
||||
CHAT_UI --> CHAT_DOMAIN
|
||||
CHAT_UI --> R1
|
||||
CHAT_DOMAIN --> D1
|
||||
D1 --> N0
|
||||
N0 --> N1
|
||||
N1 --> N2
|
||||
N1 --> N3
|
||||
N1 --> N4
|
||||
N1 --> N5
|
||||
N5 --> N6
|
||||
N7 --> N0
|
||||
SESSION --> N0
|
||||
R1 --> N0
|
||||
R1 --> R2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. DI и composition root
|
||||
|
||||
### 3.1 Hilt
|
||||
- `RosettaApplication` помечен `@HiltAndroidApp`.
|
||||
- Entry points уровня Android-компонентов: `MainActivity`, `IncomingCallActivity`, `CallForegroundService`, `RosettaFirebaseMessagingService`.
|
||||
- Основные модули:
|
||||
- `AppDataModule`: `AccountManager`, `PreferencesManager`.
|
||||
- `AppGatewayModule`: биндинги `ProtocolGateway`, `SessionCoordinator`, `IdentityGateway`, `ProtocolClient`.
|
||||
- `ProtocolGateway` теперь биндится напрямую на `ProtocolRuntime` (без отдельного `ProtocolGatewayImpl` proxy-класса).
|
||||
- `ProtocolClientImpl` остается узким техническим adapter-слоем для repository (`send/sendWithRetry/addLog/wait/unwait`) и делегирует в `ProtocolRuntime` через `Provider<ProtocolRuntime>`.
|
||||
|
||||
### 3.2 UI bridge для composable-слоя
|
||||
UI-композаблы больше не получают runtime-зависимости через `UiEntryPoint`/`EntryPointAccessors`.
|
||||
`UiDependencyAccess.get(...)` из `ui/*` удален (DoD: 0 вхождений).
|
||||
|
||||
Для non-Hilt `object`-ов (`CallManager`, `TransportManager`, `UpdateManager`, utils)
|
||||
используется `ProtocolRuntimeAccess` + `ProtocolRuntimePort`:
|
||||
- runtime ставится в `RosettaApplication` через `ProtocolRuntimeAccess.install(protocolRuntime)`;
|
||||
- доступ до install запрещен (fail-fast), чтобы не было тихого отката в legacy facade.
|
||||
|
||||
### 3.3 Разрыв DI-cycle (Hilt)
|
||||
После перехода на `ProtocolRuntime` был закрыт цикл зависимостей:
|
||||
`MessageRepository -> ProtocolClient -> ProtocolRuntime -> MessageRepository`.
|
||||
|
||||
Текущее решение:
|
||||
- `ProtocolClientImpl` получает `Provider<ProtocolRuntime>` (ленивая резолюция).
|
||||
- `ProtocolRuntime` остается singleton-композицией для `MessageRepository/GroupRepository/AccountManager`.
|
||||
- На `assembleDebug/assembleRelease` больше нет `Dagger/DependencyCycle`.
|
||||
|
||||
---
|
||||
|
||||
## 4. Session lifecycle: единый source of truth
|
||||
|
||||
### 4.1 Модель состояния
|
||||
`SessionState`:
|
||||
- `LoggedOut`
|
||||
- `AuthInProgress(publicKey?, reason)`
|
||||
- `Ready(account, reason)`
|
||||
|
||||
### 4.2 Модель событий
|
||||
`SessionAction`:
|
||||
- `LoggedOut`
|
||||
- `AuthInProgress`
|
||||
- `Ready`
|
||||
- `SyncFromCachedAccount`
|
||||
|
||||
### 4.3 Контур изменения состояния
|
||||
- Только `SessionStore` владеет `MutableStateFlow<SessionState>`.
|
||||
- Только `SessionReducer` вычисляет next-state.
|
||||
- `SessionCoordinator`/`AppSessionCoordinator` больше не мутируют состояние напрямую, а делают `dispatch(action)`.
|
||||
- `SessionStore.dispatch(...)` синхронно обновляет `IdentityStore` для консистентности account/profile/auth-runtime.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["AuthFlow / MainActivity / Unlock / SetPassword"] --> B["SessionCoordinator.dispatch(action)"]
|
||||
B --> C["SessionStore.dispatch(action)"]
|
||||
C --> D["SessionReducer.reduce(current, action)"]
|
||||
D --> E["StateFlow<SessionState>"]
|
||||
C --> F["IdentityStore sync"]
|
||||
```
|
||||
|
||||
### 4.4 State machine
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> LoggedOut
|
||||
LoggedOut --> AuthInProgress: dispatch(AuthInProgress)
|
||||
AuthInProgress --> Ready: dispatch(Ready)
|
||||
AuthInProgress --> LoggedOut: dispatch(LoggedOut)
|
||||
Ready --> LoggedOut: dispatch(LoggedOut)
|
||||
Ready --> Ready: dispatch(SyncFromCachedAccount(account))
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Network orchestration после декомпозиции
|
||||
|
||||
`ProtocolRuntime` — DI-фасад runtime слоя и реализация `ProtocolGateway`/`ProtocolRuntimePort`.
|
||||
`RuntimeComposition` — composition-root runtime слоя (сборка service graph + orchestration wiring) и делегирует отдельные зоны ответственности:
|
||||
- Публичные runtime API proxy-методы (connect/auth/directory/packet I/O) убраны из `RuntimeComposition`; публичный runtime surface теперь удерживается в `ProtocolRuntime` + `Runtime*Facade`.
|
||||
- `RuntimeTransportAssembly`: отдельный assembly-блок transport/network wiring (`NetworkReconnectWatcher`, `NetworkConnectivityFacade`, `ProtocolInstanceManager`, `PacketSubscriptionRegistry/Facade`).
|
||||
- `RuntimeMessagingAssembly`: отдельный assembly-блок packet/message/sync wiring (`PacketRouter`, `OutgoingMessagePipelineService`, `PresenceTypingService`, `SyncCoordinator`, `CallSignalBridge`, `InboundPacketHandlerRegistrar`).
|
||||
- `RuntimeStateAssembly`: отдельный assembly-блок connection-state wiring (`ReadyPacketGate`, `BootstrapCoordinator`, `RuntimeLifecycleStateMachine`, `OwnProfileFallbackTimerService`, `ProtocolLifecycleStateStoreImpl`).
|
||||
- `RuntimeRoutingAssembly`: отдельный assembly-блок event-routing wiring (`ConnectionEventRouter` + `ProtocolConnectionSupervisor` как единый orchestration-шаг).
|
||||
- `RuntimeConnectionControlFacade`: high-level connection/session control API (`initialize*`, `connect/reconnect/sync/auth`, `disconnect/destroy`, auth/connect checks).
|
||||
- `RuntimeDirectoryFacade`: directory/device/typing API (`resolve/search user`, cached user lookup, own-profile signal, device accept/decline, typing snapshot by dialog).
|
||||
- `RuntimePacketIoFacade`: packet I/O API (`send/sendWithRetry/resolveRetry`, call/webrtc/ice bridge, `wait/unwait/packetFlow`).
|
||||
- `ProtocolInstanceManager`: singleton lifecycle `Protocol` (create/state/lastError/disconnect/destroy/isAuthenticated/isConnected).
|
||||
- `RuntimeLifecycleStateMachine`: runtime lifecycle state (`ConnectionLifecycleState` + `ConnectionBootstrapContext`) и пересчет transition-логики через `BootstrapCoordinator`.
|
||||
- `RuntimeInitializationCoordinator`: one-time bootstrap runtime (`initialize`, регистрация packet handlers, старт state monitoring, проверка bound DI dependencies).
|
||||
- `ProtocolLifecycleStateStoreImpl`: отдельное lifecycle-state хранилище (`bootstrapContext`, `sessionGeneration`, last-subscribed-token clear hooks, own-profile fallback timer hooks).
|
||||
- `OwnProfileFallbackTimerService`: управление таймером own-profile fallback (`schedule/cancel`) с генерацией timeout-события.
|
||||
- `AuthRestoreService`: восстановление auth-handshake credentials из локального кеша аккаунта (`preferredPublicKey`/fallback + validation + authenticate trigger).
|
||||
- `RuntimeShutdownCoordinator`: централизованный graceful runtime shutdown (`stop watcher`, `destroy subscriptions/protocol`, `clear runtime state/services`, `cancel scope`).
|
||||
- `ConnectionEventRouter`: маршрутизация `ConnectionEvent` к соответствующим coordinator/service handlers без `when(event)` внутри core.
|
||||
- `NetworkConnectivityFacade`: единая обертка network-availability/wait/stop policy поверх `NetworkReconnectWatcher`.
|
||||
- `ConnectionOrchestrator`: connect/reconnect/authenticate + network-aware поведение.
|
||||
- `ProtocolLifecycleCoordinator`: lifecycle/auth/bootstrap transitions (`ProtocolStateChanged`, `SyncCompleted`, own-profile resolved/fallback).
|
||||
- `ProtocolAccountSessionCoordinator`: account-bound transitions (`InitializeAccount`, `Disconnect`) и reset account/session state.
|
||||
- `ReadyPacketDispatchCoordinator`: обработка `SendPacket` через ready-gate (`bypass/enqueue/flush trigger + reconnect policy`).
|
||||
- `ProtocolPostAuthBootstrapCoordinator`: post-auth orchestration (`canRun/tryRun bootstrap`, own profile fetch, push subscribe, post-sync retry/missing-user-info).
|
||||
- `BootstrapCoordinator`: пересчет lifecycle (`AUTHENTICATED`/`BOOTSTRAPPING`/`READY`) и работа с `ReadyPacketGate`.
|
||||
- `SyncCoordinator`: sync state machine (request/timeout, BATCH_START/BATCH_END/NOT_NEEDED, foreground/manual sync).
|
||||
- `PresenceTypingService`: in-memory typing presence с TTL и snapshot `StateFlow`.
|
||||
- `PacketRouter`: user/search cache + resolve/search continuation routing.
|
||||
- `OwnProfileSyncService`: применение собственного профиля из search и синхронизация `IdentityStore`.
|
||||
- `RetryQueueService`: retry очереди отправки `PacketMessage`.
|
||||
- `AuthBootstrapCoordinator`: session-aware post-auth bootstrap (transport/update/profile/sync/push).
|
||||
- `NetworkReconnectWatcher`: единый watcher ожидания сети и fast-reconnect триггеры.
|
||||
- `DeviceVerificationService`: состояние списка устройств + pending verification + resolve packets.
|
||||
- `DeviceRuntimeService`: device-id/handshake device + device verification orchestration.
|
||||
- `CallSignalBridge`: call/webrtc/ice signal send+subscribe bridge.
|
||||
- `PacketSubscriptionFacade`: thin bridge `waitPacket/unwaitPacket/packetFlow` API поверх `PacketSubscriptionRegistry`.
|
||||
- `PacketSubscriptionRegistry`: централизованные подписки на пакеты и fan-out.
|
||||
- `InboundPacketHandlerRegistrar`: централизованная регистрация inbound packet handlers (`0x03/0x05/0x06/0x07/0x08/0x09/0x0B/0x0F/0x14/0x17/0x19`) и делегирование в sync/repository/device/typing/profile сервисы.
|
||||
- `InboundTaskQueueService`: sequential inbound task queue (`enqueue` + `whenTasksFinish`) для Desktop parity (`dialogQueue` semantics).
|
||||
- `OutgoingMessagePipelineService`: отправка `PacketMessage` с retry/error policy.
|
||||
- `ProtocolDebugLogService`: буферизация UI-логов, throttle flush и персистентный protocol trace.
|
||||
|
||||
На hot-path `ProtocolRuntime` берет runtime API (`RuntimeConnectionControlFacade`/`RuntimeDirectoryFacade`/`RuntimePacketIoFacade`) напрямую из `RuntimeComposition`, поэтому лишний proxy-hop через публичные методы composition не используется.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
PR["ProtocolRuntime (ProtocolGateway impl)"] --> RC["RuntimeComposition"]
|
||||
RC --> RCC["RuntimeConnectionControlFacade"]
|
||||
RC --> RDF["RuntimeDirectoryFacade"]
|
||||
RC --> RPF["RuntimePacketIoFacade"]
|
||||
|
||||
RC --> RTA["RuntimeTransportAssembly"]
|
||||
RC --> RMA["RuntimeMessagingAssembly"]
|
||||
RC --> RSA["RuntimeStateAssembly"]
|
||||
RC --> RRA["RuntimeRoutingAssembly"]
|
||||
|
||||
RTA --> PIM["ProtocolInstanceManager"]
|
||||
RTA --> PSF["PacketSubscriptionFacade"]
|
||||
RTA --> NCF["NetworkConnectivityFacade"]
|
||||
|
||||
RMA --> SC["SyncCoordinator"]
|
||||
RMA --> PROUTER["PacketRouter"]
|
||||
RMA --> OMPS["OutgoingMessagePipelineService"]
|
||||
RMA --> CSB["CallSignalBridge"]
|
||||
RMA --> IPR["InboundPacketHandlerRegistrar"]
|
||||
|
||||
RSA --> RLSM["RuntimeLifecycleStateMachine"]
|
||||
RSA --> BC["BootstrapCoordinator"]
|
||||
RSA --> RPG["ReadyPacketGate"]
|
||||
RSA --> PLSS["ProtocolLifecycleStateStoreImpl"]
|
||||
|
||||
RRA --> SUP["ProtocolConnectionSupervisor"]
|
||||
RRA --> CER["ConnectionEventRouter"]
|
||||
|
||||
CER --> CO["ConnectionOrchestrator"]
|
||||
CER --> PLC["ProtocolLifecycleCoordinator"]
|
||||
CER --> PAC["ProtocolAccountSessionCoordinator"]
|
||||
CER --> RPDC["ReadyPacketDispatchCoordinator"]
|
||||
|
||||
PIM --> P["Protocol (WebSocket + packet codec)"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Централизация packet-subscriptions
|
||||
|
||||
Проблема дублирующихся low-level подписок закрыта через `PacketSubscriptionRegistry`:
|
||||
- На каждый `packetId` создается один bus и один bridge на `Protocol.waitPacket(...)`.
|
||||
- Дальше packet fan-out идет в:
|
||||
- callback API (`waitPacket/unwaitPacket`),
|
||||
- `SharedFlow` (`packetFlow(packetId)`).
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Feature as Feature/Service
|
||||
participant PR as ProtocolRuntime
|
||||
participant RPF as RuntimePacketIoFacade
|
||||
participant PSF as PacketSubscriptionFacade
|
||||
participant REG as PacketSubscriptionRegistry
|
||||
participant P as Protocol
|
||||
|
||||
Feature->>PR: waitPacket(0x03, callback)
|
||||
PR->>RPF: waitPacket(0x03, callback)
|
||||
RPF->>PSF: waitPacket(0x03, callback)
|
||||
PSF->>REG: addCallback(0x03, callback)
|
||||
REG->>P: waitPacket(0x03, protocolBridge) [once per packetId]
|
||||
|
||||
P-->>REG: Packet(0x03)
|
||||
REG-->>Feature: callback(packet)
|
||||
REG-->>Feature: packetFlow(0x03).emit(packet)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Чат-модуль: декомпозиция и message pipeline
|
||||
|
||||
### 7.1 Domain слой для сценариев отправки
|
||||
|
||||
Use-case слой вынесен из UI-пакета в `domain/chats/usecase`:
|
||||
- `SendTextMessageUseCase`
|
||||
- `SendMediaMessageUseCase`
|
||||
- `SendForwardUseCase`
|
||||
- `SendVoiceMessageUseCase`
|
||||
- `SendTypingIndicatorUseCase`
|
||||
- `SendReadReceiptUseCase`
|
||||
- `CreateFileAttachmentUseCase`
|
||||
- `CreateAvatarAttachmentUseCase`
|
||||
- `CreateVideoCircleAttachmentUseCase`
|
||||
- `EncryptAndUploadAttachmentUseCase`
|
||||
|
||||
Роли use-case слоя:
|
||||
- `SendTextMessageUseCase`/`SendMediaMessageUseCase`: сборка `PacketMessage` + dispatch через `ProtocolGateway` (с учетом `isSavedMessages`).
|
||||
- `SendForwardUseCase`: сборка forward-reply JSON, сборка forward attachment и dispatch.
|
||||
- `SendVoiceMessageUseCase`/`SendTypingIndicatorUseCase`: normalization/decision логика (preview waveform, throttle/guard).
|
||||
- `SendReadReceiptUseCase`: отдельный сценарий отправки `PacketRead`.
|
||||
- `Create*AttachmentUseCase`: типобезопасная сборка attachment-моделей.
|
||||
- `EncryptAndUploadAttachmentUseCase`: общий шаг `encrypt + upload` с возвратом `transportTag/transportServer`.
|
||||
|
||||
Текущий поток отправки:
|
||||
1. Feature VM/Coordinator через `ChatViewModel`-host формирует command + encryption context.
|
||||
2. UseCase строит payload/decision (`PacketMessage` или typed decision model).
|
||||
3. `ProtocolGateway.sendMessageWithRetry(...)` уводит пакет в network runtime.
|
||||
4. `RuntimeComposition` (через `ProtocolRuntime`) регистрирует пакет в `RetryQueueService` и отправляет в сеть.
|
||||
5. До `READY` пакет буферизуется через `ReadyPacketGate`, затем flush.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
FVM["Feature ViewModel"] --> CVM["ChatViewModel (host)"]
|
||||
CVM --> COORD["Messages/Forward/Attachments Coordinator"]
|
||||
CVM --> UC["domain/chats/usecase/*"]
|
||||
COORD --> UC
|
||||
UC --> GW["ProtocolGateway.send / sendMessageWithRetry"]
|
||||
GW --> PR["ProtocolRuntime"]
|
||||
PR --> RPF["RuntimePacketIoFacade"]
|
||||
RPF --> OMP["OutgoingMessagePipelineService"]
|
||||
OMP --> RQ["RetryQueueService"]
|
||||
OMP --> RR["RuntimeRoutingAssembly"]
|
||||
RR --> RG["ReadyPacketGate / ReadyPacketDispatchCoordinator"]
|
||||
RG --> P["Protocol.sendPacket"]
|
||||
```
|
||||
|
||||
### 7.2 Декомпозиция ChatViewModel (host + feature/coordinator слой)
|
||||
|
||||
Для UI-слоя введены feature-facade viewmodel-классы:
|
||||
- `MessagesViewModel`
|
||||
- `VoiceRecordingViewModel`
|
||||
- `AttachmentsViewModel`
|
||||
- `TypingViewModel`
|
||||
|
||||
Они живут в `ui/chats/ChatFeatureViewModels.kt` и компонуются внутри `ChatViewModel`.
|
||||
Текущий статус:
|
||||
- `VoiceRecordingViewModel` содержит реальный send-pipeline голосовых сообщений.
|
||||
- `TypingViewModel` содержит реальную отправку typing indicator (throttle + packet send).
|
||||
- `MessagesViewModel` содержит orchestration-level entrypoint (`sendMessage`, `retryMessage`), а core text send pipeline вынесен в `MessagesCoordinator` (pending recovery/throttle + reply/forward packet assembly).
|
||||
- `ForwardCoordinator` вынесен из `ChatViewModel`: `sendForwardDirectly` + forward rewrite/re-upload helper-ветка (включая payload resolve из cache/download).
|
||||
- `AttachmentsCoordinator` вынесен из `ChatViewModel`: `updateOptimisticImageMessage`, `sendImageMessageInternal`, `sendVideoCircleMessageInternal` + local cache/update (`localUri` cleanup после отправки).
|
||||
- `AttachmentsFeatureCoordinator` вынесен из `AttachmentsViewModel`: high-level media orchestration для `sendImageGroup*`, `sendFileMessage`, `sendVideoCircleFromUri`, `sendAvatarMessage`.
|
||||
- `AttachmentsViewModel` теперь концентрируется на facade-методах и `sendImageFromUri`/`sendImageMessage`, делегируя крупные media-ветки в coordinator-слой.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
CD["ChatDetailScreen"] --> MVM["MessagesViewModel"]
|
||||
CD --> TVM["TypingViewModel"]
|
||||
CD --> VVM["VoiceRecordingViewModel"]
|
||||
CD --> AVM["AttachmentsViewModel"]
|
||||
MVM --> CVM["ChatViewModel (host-state)"]
|
||||
TVM --> CVM
|
||||
VVM --> CVM
|
||||
AVM --> CVM
|
||||
CVM --> MCO["MessagesCoordinator"]
|
||||
CVM --> FCO["ForwardCoordinator"]
|
||||
CVM --> ACO["AttachmentsCoordinator"]
|
||||
AVM --> AFCO["AttachmentsFeatureCoordinator"]
|
||||
CVM --> U["domain/chats/usecase/*"]
|
||||
MCO --> U
|
||||
FCO --> U
|
||||
ACO --> U
|
||||
AFCO --> U
|
||||
```
|
||||
|
||||
Важно: после вынесения `MessagesCoordinator`, `ForwardCoordinator` и `AttachmentsCoordinator` `ChatViewModel` выступает как host-state и bridge для feature/coordinator подсистем.
|
||||
|
||||
### 7.3 Декомпозиция ChatsListScreen
|
||||
|
||||
Из `ChatsListScreen.kt` вынесены отдельные composable-секции:
|
||||
- `ChatItem` -> `ChatsListChatItem.kt`
|
||||
- `RequestsSection` -> `ChatsListRequestsSection.kt`
|
||||
- `DrawerContent` -> `ChatsListDrawerContent.kt`
|
||||
|
||||
Результат:
|
||||
- основной файл экрана меньше и проще для навигации;
|
||||
- повторно используемые куски UI имеют явные file boundaries;
|
||||
- дальнейший рефакторинг drawer/request/chat list можно делать независимо.
|
||||
|
||||
---
|
||||
|
||||
## 8. Auth/bootstrap: фактический runtime flow
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as Auth UI (SetPassword/Unlock)
|
||||
participant SC as SessionCoordinatorImpl
|
||||
participant SS as SessionStore
|
||||
participant PG as ProtocolGateway
|
||||
participant PR as ProtocolRuntime
|
||||
participant RCC as RuntimeConnectionControlFacade
|
||||
participant RRA as RuntimeRoutingAssembly
|
||||
participant RSA as RuntimeStateAssembly
|
||||
participant AM as AccountManager
|
||||
|
||||
UI->>SC: bootstrapAuthenticatedSession(account, reason)
|
||||
SC->>SS: dispatch(AuthInProgress)
|
||||
SC->>PG: initializeAccount(public, private)
|
||||
SC->>PG: connect()
|
||||
SC->>PG: authenticate(public, privateHash)
|
||||
SC->>PG: reconnectNowIfNeeded(...)
|
||||
SC->>AM: setCurrentAccount(public)
|
||||
SC->>SS: dispatch(Ready)
|
||||
|
||||
PG->>PR: runtime API calls
|
||||
PR->>RCC: connection/auth commands
|
||||
RCC->>RRA: post(ConnectionEvent.*)
|
||||
RRA-->>RRA: Supervisor + Router route events
|
||||
RRA-->>RSA: apply lifecycle transitions
|
||||
RSA-->>RSA: AUTHENTICATED -> BOOTSTRAPPING -> READY
|
||||
```
|
||||
|
||||
Важно: `SessionState.Ready` (app-session готова) и `connectionLifecycleState = READY` (сеть готова) — это разные state-модели.
|
||||
|
||||
---
|
||||
|
||||
## 9. Состояния соединения (network lifecycle)
|
||||
|
||||
`RuntimeComposition.connectionLifecycleState`:
|
||||
- `DISCONNECTED`
|
||||
- `CONNECTING`
|
||||
- `HANDSHAKING`
|
||||
- `AUTHENTICATED`
|
||||
- `BOOTSTRAPPING`
|
||||
- `READY`
|
||||
- `DEVICE_VERIFICATION_REQUIRED`
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> DISCONNECTED
|
||||
DISCONNECTED --> CONNECTING
|
||||
CONNECTING --> HANDSHAKING
|
||||
HANDSHAKING --> DEVICE_VERIFICATION_REQUIRED
|
||||
HANDSHAKING --> AUTHENTICATED
|
||||
AUTHENTICATED --> BOOTSTRAPPING
|
||||
BOOTSTRAPPING --> READY
|
||||
READY --> HANDSHAKING
|
||||
AUTHENTICATED --> DISCONNECTED
|
||||
BOOTSTRAPPING --> DISCONNECTED
|
||||
READY --> DISCONNECTED
|
||||
DEVICE_VERIFICATION_REQUIRED --> CONNECTING
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Ключевые файлы новой архитектуры
|
||||
|
||||
- `app/src/main/java/com/rosetta/messenger/di/AppContainer.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/ProtocolRuntime.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/RuntimeComposition.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/RuntimeTransportAssembly.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/RuntimeMessagingAssembly.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/RuntimeStateAssembly.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/RuntimeRoutingAssembly.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/RuntimeConnectionControlFacade.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/RuntimeDirectoryFacade.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/RuntimePacketIoFacade.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/ProtocolClient.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/ProtocolRuntimeAccess.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/session/AppSessionCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/session/SessionStore.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/session/SessionReducer.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/session/SessionAction.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/session/IdentityStore.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/ProtocolManager.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/PacketSubscriptionRegistry.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/ProtocolConnectionModels.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/ProtocolConnectionSupervisor.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/ReadyPacketGate.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/ConnectionOrchestrator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/ProtocolInstanceManager.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/RuntimeLifecycleStateMachine.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/RuntimeInitializationCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/ProtocolLifecycleStateStoreImpl.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/OwnProfileFallbackTimerService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/AuthRestoreService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/RuntimeShutdownCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/ConnectionEventRouter.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/NetworkConnectivityFacade.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/PacketSubscriptionFacade.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/ProtocolLifecycleCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/ProtocolAccountSessionCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/ReadyPacketDispatchCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/ProtocolPostAuthBootstrapCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/BootstrapCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/SyncCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/PresenceTypingService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/AuthBootstrapCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/NetworkReconnectWatcher.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/DeviceVerificationService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/DeviceRuntimeService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/CallSignalBridge.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/InboundPacketHandlerRegistrar.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/InboundTaskQueueService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/OutgoingMessagePipelineService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/ProtocolDebugLogService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/PacketRouter.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/OwnProfileSyncService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/network/connection/RetryQueueService.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatViewModel.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatFeatureViewModels.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/MessagesCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ForwardCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/AttachmentsCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/AttachmentsFeatureCoordinator.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/OutgoingEncryptionContext.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/OutgoingSendContext.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListScreen.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListChatItem.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListRequestsSection.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListDrawerContent.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListDrawerSections.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/ui/chats/ChatsListRequestsScreen.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendTextMessageUseCase.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendMediaMessageUseCase.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendForwardUseCase.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendVoiceMessageUseCase.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendTypingIndicatorUseCase.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/SendReadReceiptUseCase.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/CreateAttachmentUseCases.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/EncryptAndUploadAttachmentUseCase.kt`
|
||||
- `app/src/main/java/com/rosetta/messenger/domain/chats/usecase/VideoCircleMediaUseCases.kt`
|
||||
|
||||
---
|
||||
|
||||
## 11. Что осталось как технический долг
|
||||
|
||||
Актуальные открытые хвосты:
|
||||
- `RuntimeComposition` остается composition-root (около 501 строки): публичные proxy-методы уже убраны, но внутри все еще смешаны wiring и часть helper-логики (`setupStateMonitoring`, event-bridge, log helpers). Следующий шаг: вынести эти helper-блоки в отдельные adapters/services.
|
||||
- `ProtocolRuntime` + `ProtocolRuntimePort` все еще имеют широкий API surface (connection + directory + packet IO + call signaling + debug). Нужен audit и сужение публичных контрактов по use-case группам.
|
||||
- `ChatViewModel` остается очень крупным host-классом (около 4391 строки) с большим bridge/proxy surface к feature/coordinator/use-case слоям.
|
||||
- `AttachmentsFeatureCoordinator` остается крупным (около 761 строки): high-level media сценарии стоит резать на более узкие upload/transform/packet-assembly сервисы.
|
||||
- Тестовое покрытие архитектурно-критичных слоев недостаточно: `app/src/test` = 7, `app/src/androidTest` = 1; не покрыты runtime-routing/lifecycle компоненты (`RuntimeRoutingAssembly`, `ConnectionEventRouter`, `ProtocolLifecycle*`, `ReadyPacketDispatchCoordinator`) и chat coordinators (`Messages/Forward/Attachments*`).
|
||||
- В runtime все еще несколько точек входа (`ProtocolRuntime`, `ProtocolRuntimeAccess`, `ProtocolManager` legacy), что повышает cognitive load; целевой шаг — дальнейшее сокращение legacy/static call-sites.
|
||||
|
||||
Уже закрыто и больше не считается техдолгом:
|
||||
- `UiDependencyAccess.get(...)` удален из `ui/*`.
|
||||
- `UiEntryPoint`/`EntryPointAccessors` убраны из UI-экранов (явная передача зависимостей через `MainActivity`/`ViewModel`).
|
||||
- DI-cycle `MessageRepository -> ProtocolClient -> ProtocolRuntime -> MessageRepository` закрыт через `Provider<ProtocolRuntime>`.
|
||||
- `ProtocolManager` переведен в минимальный legacy compatibility API (тонкие прокси к `ProtocolRuntimeAccess`).
|
||||
|
||||
---
|
||||
|
||||
## 12. Guardrails против переусложнения
|
||||
|
||||
Чтобы декомпозиция не превращалась в «архитектуру ради архитектуры», применяются следующие правила:
|
||||
|
||||
1. Лимит глубины runtime-цепочки вызова: не более 3 логических слоев после DI-entry (`ProtocolRuntime -> Runtime*Facade -> service`; `RuntimeComposition` остается composition-root/wiring-слоем, а не обязательным proxy-hop).
|
||||
2. Новый слой/класс допускается только если он дает измеримый выигрыш:
|
||||
- убирает минимум 80-120 строк связанной orchestration-логики из текущего класса, или
|
||||
- убирает минимум 2 внешние зависимости из текущего класса.
|
||||
3. Каждый шаг рефакторинга считается завершенным только после: `compileDebugKotlin` + минимум одного smoke-сценария по затронутому флоу + обновления `Architecture.md`.
|
||||
4. Если после выноса сложность чтения/изменения не снизилась (по факту код не стал проще), такой вынос считается кандидатом на откат/консолидацию.
|
||||
5. Для event-driven runtime-chain (`ProtocolConnectionSupervisor` + `ConnectionEventRouter`) эти два элемента считаются одним orchestration-этапом при анализе hop-depth.
|
||||
6. `ProtocolClientImpl` трактуется как инфраструктурный DI-adapter и учитывается отдельно от business-flow hop budget.
|
||||
|
||||
---
|
||||
|
||||
## 13. Плюсы и минусы текущей архитектуры
|
||||
|
||||
### 13.1 Плюсы
|
||||
- Четко выделены слои: UI, domain use-cases, network runtime, session/identity, data/persistence.
|
||||
- DI через Hilt и `ProtocolGateway`/`SessionCoordinator` снижает прямую связанность между UI и transport/runtime.
|
||||
- Убраны `UiEntryPoint`/`EntryPointAccessors` из UI-экранов, что улучшило явность зависимостей.
|
||||
- Закрыт критичный DI-cycle `MessageRepository -> ProtocolClient -> ProtocolRuntime -> MessageRepository` через `Provider<ProtocolRuntime>`.
|
||||
- Network runtime декомпозирован на отдельные сервисы/coordinator-ы с более узкими зонами ответственности.
|
||||
- Сокращен DI runtime path: `ProtocolGateway` биндится напрямую на `ProtocolRuntime`, runtime работает напрямую с `RuntimeComposition`.
|
||||
- Централизован packet subscription fan-out (`PacketSubscriptionRegistry` + `PacketSubscriptionFacade`), что снижает риск дублирующих low-level подписок.
|
||||
- В chat-модуле выделен domain use-case слой и вынесены крупные сценарии в coordinators.
|
||||
|
||||
### 13.2 Минусы
|
||||
- `RuntimeComposition` и `ChatViewModel` остаются очень крупными hotspot-классами и концентрируют много связей.
|
||||
- Runtime API-слой пока широкий: много proxy-методов усложняют контроль границ и эволюцию surface API.
|
||||
- В части chat/media orchestration (`AttachmentsFeatureCoordinator`, `MessagesCoordinator`, `ForwardCoordinator`) сохраняются большие high-level сценарии.
|
||||
- Мало unit/integration тестов на архитектурно-критичные runtime/chat orchestration компоненты.
|
||||
- В проекте остаются несколько точек доступа к runtime (`ProtocolRuntime`, `ProtocolRuntimePort`, `ProtocolManager` legacy), что повышает cognitive load для новых разработчиков.
|
||||
- Стоимость входа в кодовую базу выросла: для трассировки одного бизнес-флоу нужно проходить больше слоев, чем раньше.
|
||||
|
||||
### 13.3 Итог оценки
|
||||
- Текущая архитектура стала заметно лучше по управляемости зависимостей и изоляции ответственности.
|
||||
- Главные риски сместились из “монолитного класса” в “размер composition/API surface и недотестированность orchestration”.
|
||||
- При соблюдении guardrails (секция 12) и фокусе на тестах/дальнейшей локальной декомпозиции архитектура остается управляемой и не уходит в избыточную сложность.
|
||||
@@ -1,5 +1,59 @@
|
||||
# Release Notes
|
||||
|
||||
## 1.4.2
|
||||
|
||||
### Звонки
|
||||
- Полноэкранный incoming call через ForegroundService — кнопки Accept/Decline, будит экран, работает когда приложение свёрнуто или убито (и из push, и из WebSocket).
|
||||
- Синхронизация ForegroundService с фазами звонка — notification обновляется при INCOMING → CONNECTING → ACTIVE → IDLE.
|
||||
- Защита от CREATE_ROOM без ключей шифрования — сброс сессии если звонок принят на другом устройстве.
|
||||
- Корректное освобождение PeerConnection (`dispose()`) при завершении звонка — фикс зависания ICE портов ~30 сек.
|
||||
|
||||
### E2EE диагностика
|
||||
- Диагностический файл E2EE включён для всех билдов (был только debug).
|
||||
- Периодический health-лог E2EE с счётчиками фреймов enc/dec из нативного кода.
|
||||
- Уменьшен спам scan receivers — логирование только при изменении состояния.
|
||||
- Нативные методы `FrameCount()` / `BadStreak()` для мониторинга шифрования в реальном времени.
|
||||
|
||||
### Push-уведомления
|
||||
- Добавлены `tokenType` и `deviceId` в пакет push-подписки (совместимость с новым сервером).
|
||||
- Сохранение FCM токена в crash_reports для просмотра через rosettadev1.
|
||||
|
||||
### CI/CD
|
||||
- Установка NDK и CMake в CI для сборки нативного модуля `rosetta_e2ee.so`.
|
||||
|
||||
## 1.3.4
|
||||
|
||||
### Звонки и UI
|
||||
- Реализован Telegram-style фон звонка в приложении: full-screen звонок теперь можно свернуть в закрепленную верхнюю плашку в чат-листе.
|
||||
- Плашка звонка перенесена внутрь `ChatsListScreen` и ведет обратно в экран звонка по нажатию.
|
||||
- Обновлен UI звонка: иконка сворачивания в стиле Telegram, улучшено поведение call overlay.
|
||||
- Исправлено автоматическое скрытие клавиатуры при открытии экрана звонка.
|
||||
|
||||
### Поиск в диалоге
|
||||
- В kebab-меню каждого чата добавлен пункт `Search`.
|
||||
- Добавлен встроенный поиск сообщений внутри текущего диалога (через локальный индекс `message_search_index` и `dialog_key`).
|
||||
- Добавлена навигация по результатам (`prev/next`) со скроллом и подсветкой найденного сообщения.
|
||||
|
||||
## 1.3.3
|
||||
|
||||
### E2EE, чаты и производительность
|
||||
- В release-сборке отключена frame-диагностика E2EE (детальный frame dump теперь только в debug).
|
||||
- В `ChatsListScreen` убран двойной `collectAsState(chatsState)` и вынесены route-блоки в подкомпоненты (`CallsRouteContent`, `RequestsRouteContent`, общий `SwipeBackContainer`).
|
||||
- Добавлена денормализация `primary_attachment_type` в таблице `messages` + индекс `(account, primary_attachment_type, timestamp)`.
|
||||
- Обновлена миграция БД `14 -> 15`: добавление колонки, индекс и backfill значения типа вложения для уже сохраненных сообщений.
|
||||
- Поисковые и call-history запросы переведены на `primary_attachment_type` с fallback на legacy `attachments LIKE` для старых записей.
|
||||
|
||||
## 1.2.3
|
||||
|
||||
### Групповые чаты и медиа
|
||||
- Исправлено отображение групповых баблов: логика стеков и аватаров приведена ближе к desktop-версии.
|
||||
- Исправлено позиционирование аватарки в группе: аватар и имя теперь отображаются на одном сообщении (без «разъезда»).
|
||||
- Исправлена обрезка имени отправителя в медиа-баблах группового чата.
|
||||
- Исправлено растяжение и кривые пропорции фото в forwarded/media-пузырях.
|
||||
|
||||
### Sidebar
|
||||
- Убрана лишняя рамка (border) вокруг аватарки в сайдбаре.
|
||||
|
||||
## 1.2.1
|
||||
|
||||
### Синхронизация Android ↔ iOS
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -23,8 +24,9 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Rosetta versioning — bump here on each release
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val rosettaVersionName = "1.2.1"
|
||||
val rosettaVersionCode = 23 // Increment on each release
|
||||
val rosettaVersionName = "1.5.4"
|
||||
val rosettaVersionCode = 56 // Increment on each release
|
||||
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
|
||||
|
||||
android {
|
||||
namespace = "com.rosetta.messenger"
|
||||
@@ -43,6 +45,19 @@ android {
|
||||
|
||||
// Optimize Lottie animations
|
||||
manifestPlaceholders["enableLottieOptimizations"] = "true"
|
||||
|
||||
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
|
||||
|
||||
externalNativeBuild {
|
||||
cmake { cppFlags("-std=c++17") }
|
||||
}
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path = file("src/main/cpp/CMakeLists.txt")
|
||||
version = "3.22.1"
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@@ -69,6 +84,14 @@ android {
|
||||
// Enable baseline profiles in debug builds too for testing
|
||||
// Remove this in production
|
||||
}
|
||||
create("benchmark") {
|
||||
initWith(getByName("release"))
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
matchingFallbacks += listOf("release")
|
||||
isDebuggable = false
|
||||
isMinifyEnabled = false
|
||||
isShrinkResources = false
|
||||
}
|
||||
}
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_11
|
||||
@@ -84,6 +107,10 @@ android {
|
||||
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
|
||||
jniLibs { useLegacyPackaging = true }
|
||||
}
|
||||
lint {
|
||||
checkReleaseBuilds = false
|
||||
abortOnError = false
|
||||
}
|
||||
|
||||
applicationVariants.all {
|
||||
outputs.all {
|
||||
@@ -93,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")
|
||||
@@ -129,9 +160,6 @@ dependencies {
|
||||
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||
implementation("io.coil-kt:coil-gif:2.5.0") // For animated WebP/GIF support
|
||||
|
||||
// Jsoup for HTML parsing (Link Preview OG tags)
|
||||
implementation("org.jsoup:jsoup:1.17.2")
|
||||
|
||||
// uCrop for image cropping
|
||||
implementation("com.github.yalantis:ucrop:2.2.8")
|
||||
|
||||
@@ -159,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")
|
||||
|
||||
@@ -168,14 +201,26 @@ dependencies {
|
||||
implementation("androidx.camera:camera-lifecycle:1.3.1")
|
||||
implementation("androidx.camera:camera-view:1.3.1")
|
||||
|
||||
// WebRTC for voice calls.
|
||||
// If app/libs/libwebrtc-custom.aar exists, prefer it (custom E2EE-enabled build).
|
||||
if (customWebRtcAar.exists()) {
|
||||
implementation(files(customWebRtcAar))
|
||||
} else {
|
||||
implementation("io.github.webrtc-sdk:android:125.6422.07")
|
||||
}
|
||||
|
||||
// Baseline Profiles for startup performance
|
||||
implementation("androidx.profileinstaller:profileinstaller:1.3.1")
|
||||
implementation("androidx.profileinstaller:profileinstaller:1.4.1")
|
||||
|
||||
// Firebase Cloud Messaging
|
||||
implementation(platform("com.google.firebase:firebase-bom:32.7.0"))
|
||||
implementation("com.google.firebase:firebase-messaging-ktx")
|
||||
implementation("com.google.firebase:firebase-analytics-ktx")
|
||||
|
||||
// QR Code generation (ZXing) + scanning (ML Kit)
|
||||
implementation("com.google.zxing:core:3.5.3")
|
||||
implementation("com.google.mlkit:barcode-scanning:17.3.0")
|
||||
|
||||
// Testing dependencies
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("io.mockk:mockk:1.13.8")
|
||||
|
||||
3158
app/libs/LICENSE.md
Normal file
3158
app/libs/LICENSE.md
Normal file
File diff suppressed because it is too large
Load Diff
BIN
app/libs/libwebrtc-custom.aar
Normal file
BIN
app/libs/libwebrtc-custom.aar
Normal file
Binary file not shown.
@@ -7,6 +7,18 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT" />
|
||||
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
|
||||
@@ -15,10 +27,8 @@
|
||||
|
||||
<application
|
||||
android:name=".RosettaApplication"
|
||||
android:allowBackup="true"
|
||||
android:allowBackup="false"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
android:fullBackupContent="@xml/backup_rules"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
@@ -36,13 +46,104 @@
|
||||
android:configChanges="keyboard|keyboardHidden|orientation|screenSize|uiMode|smallestScreenSize|screenLayout"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:screenOrientation="portrait">
|
||||
<!-- LAUNCHER intent-filter moved to activity-alias entries for icon switching -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="rosetta" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" android:host="rosetta.im" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.SEND_MULTIPLE" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="*/*" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
|
||||
<!-- App Icon Aliases: only one enabled at a time -->
|
||||
<activity-alias
|
||||
android:name=".MainActivityDefault"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:enabled="true"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:label="@string/app_name">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".MainActivityCalculator"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_calc"
|
||||
android:roundIcon="@mipmap/ic_launcher_calc"
|
||||
android:label="Calculator">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".MainActivityWeather"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_weather"
|
||||
android:roundIcon="@mipmap/ic_launcher_weather"
|
||||
android:label="Weather">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity-alias
|
||||
android:name=".MainActivityNotes"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:enabled="false"
|
||||
android:exported="true"
|
||||
android:icon="@mipmap/ic_launcher_notes"
|
||||
android:roundIcon="@mipmap/ic_launcher_notes"
|
||||
android:label="Notes">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
|
||||
<activity
|
||||
android:name=".IncomingCallActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.RosettaAndroid"
|
||||
android:launchMode="singleTask"
|
||||
android:showWhenLocked="true"
|
||||
android:turnScreenOn="true"
|
||||
android:screenOrientation="portrait"
|
||||
android:excludeFromRecents="true"
|
||||
android:taskAffinity="com.rosetta.messenger.call" />
|
||||
|
||||
<!-- FileProvider for camera images -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
@@ -62,6 +163,11 @@
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<service
|
||||
android:name=".network.CallForegroundService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="microphone|mediaPlayback|phoneCall" />
|
||||
|
||||
<!-- Firebase notification icon (optional, for better looking notifications) -->
|
||||
<meta-data
|
||||
|
||||
33
app/src/main/cpp/CMakeLists.txt
Normal file
33
app/src/main/cpp/CMakeLists.txt
Normal file
@@ -0,0 +1,33 @@
|
||||
cmake_minimum_required(VERSION 3.22.1)
|
||||
project(rosetta_e2ee LANGUAGES C CXX)
|
||||
|
||||
set(CMAKE_C_STANDARD 11)
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
|
||||
add_library(rosetta_e2ee SHARED
|
||||
crypto.c
|
||||
rosetta_e2ee.cpp
|
||||
)
|
||||
|
||||
target_include_directories(rosetta_e2ee PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
|
||||
# Hide all C++ symbols to avoid ODR clashes with WebRTC's .so
|
||||
set_target_properties(rosetta_e2ee PROPERTIES
|
||||
CXX_VISIBILITY_PRESET hidden
|
||||
C_VISIBILITY_PRESET hidden
|
||||
)
|
||||
|
||||
# Match WebRTC SDK build flags:
|
||||
# -fno-rtti -fno-exceptions — standard WebRTC flags
|
||||
# -fexperimental-relative-c++-abi-vtables — WebRTC uses relative vtables
|
||||
# (32-bit offsets instead of 64-bit absolute pointers in vtable).
|
||||
# Without this, setFrameEncryptor crashes with SIGSEGV because WebRTC
|
||||
# reads our 64-bit pointers as 32-bit offsets.
|
||||
target_compile_options(rosetta_e2ee PRIVATE
|
||||
-fno-rtti
|
||||
-fno-exceptions
|
||||
-fexperimental-relative-c++-abi-vtables
|
||||
)
|
||||
|
||||
find_library(log-lib log)
|
||||
target_link_libraries(rosetta_e2ee ${log-lib})
|
||||
248
app/src/main/cpp/crypto.c
Normal file
248
app/src/main/cpp/crypto.c
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Minimal crypto primitives for Rosetta E2EE:
|
||||
* - HSalsa20 (for nacl.box.before() compatible key exchange)
|
||||
* - HChaCha20 + ChaCha20-IETF → XChaCha20 (for frame encryption)
|
||||
*
|
||||
* Based on the public-domain algorithms by D.J. Bernstein.
|
||||
*/
|
||||
|
||||
#include "crypto.h"
|
||||
#include <string.h>
|
||||
|
||||
/* ── helpers ─────────────────────────────────────────────────── */
|
||||
|
||||
#define ROTL32(v, n) (((v) << (n)) | ((v) >> (32 - (n))))
|
||||
|
||||
static uint32_t load32_le(const uint8_t *p) {
|
||||
return (uint32_t)p[0]
|
||||
| (uint32_t)p[1] << 8
|
||||
| (uint32_t)p[2] << 16
|
||||
| (uint32_t)p[3] << 24;
|
||||
}
|
||||
|
||||
static void store32_le(uint8_t *p, uint32_t v) {
|
||||
p[0] = (uint8_t)(v);
|
||||
p[1] = (uint8_t)(v >> 8);
|
||||
p[2] = (uint8_t)(v >> 16);
|
||||
p[3] = (uint8_t)(v >> 24);
|
||||
}
|
||||
|
||||
/* "expand 32-byte k" as four little-endian uint32 */
|
||||
static const uint32_t SIGMA[4] = {
|
||||
0x61707865u, 0x3320646eu, 0x79622d32u, 0x6b206574u
|
||||
};
|
||||
|
||||
/* ── HSalsa20 (Salsa20 family) ──────────────────────────────── */
|
||||
|
||||
void rosetta_hsalsa20(uint8_t out[32],
|
||||
const uint8_t inp[16],
|
||||
const uint8_t key[32])
|
||||
{
|
||||
uint32_t x[16];
|
||||
x[ 0] = SIGMA[0];
|
||||
x[ 1] = load32_le(key + 0);
|
||||
x[ 2] = load32_le(key + 4);
|
||||
x[ 3] = load32_le(key + 8);
|
||||
x[ 4] = load32_le(key + 12);
|
||||
x[ 5] = SIGMA[1];
|
||||
x[ 6] = load32_le(inp + 0);
|
||||
x[ 7] = load32_le(inp + 4);
|
||||
x[ 8] = load32_le(inp + 8);
|
||||
x[ 9] = load32_le(inp + 12);
|
||||
x[10] = SIGMA[2];
|
||||
x[11] = load32_le(key + 16);
|
||||
x[12] = load32_le(key + 20);
|
||||
x[13] = load32_le(key + 24);
|
||||
x[14] = load32_le(key + 28);
|
||||
x[15] = SIGMA[3];
|
||||
|
||||
for (int i = 0; i < 20; i += 2) {
|
||||
/* column round */
|
||||
x[ 4] ^= ROTL32(x[ 0] + x[12], 7);
|
||||
x[ 8] ^= ROTL32(x[ 4] + x[ 0], 9);
|
||||
x[12] ^= ROTL32(x[ 8] + x[ 4], 13);
|
||||
x[ 0] ^= ROTL32(x[12] + x[ 8], 18);
|
||||
x[ 9] ^= ROTL32(x[ 5] + x[ 1], 7);
|
||||
x[13] ^= ROTL32(x[ 9] + x[ 5], 9);
|
||||
x[ 1] ^= ROTL32(x[13] + x[ 9], 13);
|
||||
x[ 5] ^= ROTL32(x[ 1] + x[13], 18);
|
||||
x[14] ^= ROTL32(x[10] + x[ 6], 7);
|
||||
x[ 2] ^= ROTL32(x[14] + x[10], 9);
|
||||
x[ 6] ^= ROTL32(x[ 2] + x[14], 13);
|
||||
x[10] ^= ROTL32(x[ 6] + x[ 2], 18);
|
||||
x[ 3] ^= ROTL32(x[15] + x[11], 7);
|
||||
x[ 7] ^= ROTL32(x[ 3] + x[15], 9);
|
||||
x[11] ^= ROTL32(x[ 7] + x[ 3], 13);
|
||||
x[15] ^= ROTL32(x[11] + x[ 7], 18);
|
||||
/* row round */
|
||||
x[ 1] ^= ROTL32(x[ 0] + x[ 3], 7);
|
||||
x[ 2] ^= ROTL32(x[ 1] + x[ 0], 9);
|
||||
x[ 3] ^= ROTL32(x[ 2] + x[ 1], 13);
|
||||
x[ 0] ^= ROTL32(x[ 3] + x[ 2], 18);
|
||||
x[ 6] ^= ROTL32(x[ 5] + x[ 4], 7);
|
||||
x[ 7] ^= ROTL32(x[ 6] + x[ 5], 9);
|
||||
x[ 4] ^= ROTL32(x[ 7] + x[ 6], 13);
|
||||
x[ 5] ^= ROTL32(x[ 4] + x[ 7], 18);
|
||||
x[11] ^= ROTL32(x[10] + x[ 9], 7);
|
||||
x[ 8] ^= ROTL32(x[11] + x[10], 9);
|
||||
x[ 9] ^= ROTL32(x[ 8] + x[11], 13);
|
||||
x[10] ^= ROTL32(x[ 9] + x[ 8], 18);
|
||||
x[12] ^= ROTL32(x[15] + x[14], 7);
|
||||
x[13] ^= ROTL32(x[12] + x[15], 9);
|
||||
x[14] ^= ROTL32(x[13] + x[12], 13);
|
||||
x[15] ^= ROTL32(x[14] + x[13], 18);
|
||||
}
|
||||
|
||||
/* output words: 0, 5, 10, 15, 6, 7, 8, 9 */
|
||||
store32_le(out + 0, x[ 0]);
|
||||
store32_le(out + 4, x[ 5]);
|
||||
store32_le(out + 8, x[10]);
|
||||
store32_le(out + 12, x[15]);
|
||||
store32_le(out + 16, x[ 6]);
|
||||
store32_le(out + 20, x[ 7]);
|
||||
store32_le(out + 24, x[ 8]);
|
||||
store32_le(out + 28, x[ 9]);
|
||||
}
|
||||
|
||||
/* ── HChaCha20 (ChaCha20 family) ────────────────────────────── */
|
||||
|
||||
static void hchacha20(uint8_t out[32],
|
||||
const uint8_t inp[16],
|
||||
const uint8_t key[32])
|
||||
{
|
||||
uint32_t x[16];
|
||||
x[ 0] = SIGMA[0];
|
||||
x[ 1] = SIGMA[1];
|
||||
x[ 2] = SIGMA[2];
|
||||
x[ 3] = SIGMA[3];
|
||||
x[ 4] = load32_le(key + 0);
|
||||
x[ 5] = load32_le(key + 4);
|
||||
x[ 6] = load32_le(key + 8);
|
||||
x[ 7] = load32_le(key + 12);
|
||||
x[ 8] = load32_le(key + 16);
|
||||
x[ 9] = load32_le(key + 20);
|
||||
x[10] = load32_le(key + 24);
|
||||
x[11] = load32_le(key + 28);
|
||||
x[12] = load32_le(inp + 0);
|
||||
x[13] = load32_le(inp + 4);
|
||||
x[14] = load32_le(inp + 8);
|
||||
x[15] = load32_le(inp + 12);
|
||||
|
||||
for (int i = 0; i < 20; i += 2) {
|
||||
/* column round */
|
||||
#define QR(a, b, c, d) \
|
||||
a += b; d ^= a; d = ROTL32(d, 16); \
|
||||
c += d; b ^= c; b = ROTL32(b, 12); \
|
||||
a += b; d ^= a; d = ROTL32(d, 8); \
|
||||
c += d; b ^= c; b = ROTL32(b, 7);
|
||||
|
||||
QR(x[0], x[4], x[8], x[12]);
|
||||
QR(x[1], x[5], x[9], x[13]);
|
||||
QR(x[2], x[6], x[10], x[14]);
|
||||
QR(x[3], x[7], x[11], x[15]);
|
||||
/* diagonal round */
|
||||
QR(x[0], x[5], x[10], x[15]);
|
||||
QR(x[1], x[6], x[11], x[12]);
|
||||
QR(x[2], x[7], x[8], x[13]);
|
||||
QR(x[3], x[4], x[9], x[14]);
|
||||
|
||||
#undef QR
|
||||
}
|
||||
|
||||
/* output words: 0, 1, 2, 3, 12, 13, 14, 15 */
|
||||
store32_le(out + 0, x[ 0]);
|
||||
store32_le(out + 4, x[ 1]);
|
||||
store32_le(out + 8, x[ 2]);
|
||||
store32_le(out + 12, x[ 3]);
|
||||
store32_le(out + 16, x[12]);
|
||||
store32_le(out + 20, x[13]);
|
||||
store32_le(out + 24, x[14]);
|
||||
store32_le(out + 28, x[15]);
|
||||
}
|
||||
|
||||
/* ── ChaCha20-IETF (RFC 8439) ───────────────────────────────── */
|
||||
|
||||
static void chacha20_block(uint8_t out[64],
|
||||
const uint8_t key[32],
|
||||
uint32_t counter,
|
||||
const uint8_t nonce[12])
|
||||
{
|
||||
uint32_t s[16], x[16];
|
||||
s[ 0] = SIGMA[0];
|
||||
s[ 1] = SIGMA[1];
|
||||
s[ 2] = SIGMA[2];
|
||||
s[ 3] = SIGMA[3];
|
||||
for (int i = 0; i < 8; i++) s[4 + i] = load32_le(key + i * 4);
|
||||
s[12] = counter;
|
||||
s[13] = load32_le(nonce + 0);
|
||||
s[14] = load32_le(nonce + 4);
|
||||
s[15] = load32_le(nonce + 8);
|
||||
|
||||
memcpy(x, s, sizeof(x));
|
||||
|
||||
for (int i = 0; i < 20; i += 2) {
|
||||
#define QR(a, b, c, d) \
|
||||
a += b; d ^= a; d = ROTL32(d, 16); \
|
||||
c += d; b ^= c; b = ROTL32(b, 12); \
|
||||
a += b; d ^= a; d = ROTL32(d, 8); \
|
||||
c += d; b ^= c; b = ROTL32(b, 7);
|
||||
|
||||
QR(x[0], x[4], x[8], x[12]);
|
||||
QR(x[1], x[5], x[9], x[13]);
|
||||
QR(x[2], x[6], x[10], x[14]);
|
||||
QR(x[3], x[7], x[11], x[15]);
|
||||
QR(x[0], x[5], x[10], x[15]);
|
||||
QR(x[1], x[6], x[11], x[12]);
|
||||
QR(x[2], x[7], x[8], x[13]);
|
||||
QR(x[3], x[4], x[9], x[14]);
|
||||
|
||||
#undef QR
|
||||
}
|
||||
|
||||
for (int i = 0; i < 16; i++) store32_le(out + i * 4, x[i] + s[i]);
|
||||
}
|
||||
|
||||
static void chacha20_ietf_xor(uint8_t *out,
|
||||
const uint8_t *in,
|
||||
size_t len,
|
||||
const uint8_t nonce[12],
|
||||
const uint8_t key[32],
|
||||
uint32_t initial_counter)
|
||||
{
|
||||
uint8_t block[64];
|
||||
uint32_t ctr = initial_counter;
|
||||
size_t off = 0;
|
||||
|
||||
while (off < len) {
|
||||
chacha20_block(block, key, ctr++, nonce);
|
||||
size_t chunk = len - off;
|
||||
if (chunk > 64) chunk = 64;
|
||||
for (size_t i = 0; i < chunk; i++) {
|
||||
out[off + i] = in[off + i] ^ block[i];
|
||||
}
|
||||
off += chunk;
|
||||
}
|
||||
|
||||
memset(block, 0, sizeof(block));
|
||||
}
|
||||
|
||||
/* ── XChaCha20 XOR ───────────────────────────────────────────── */
|
||||
|
||||
void rosetta_xchacha20_xor(uint8_t *out,
|
||||
const uint8_t *in,
|
||||
size_t len,
|
||||
const uint8_t nonce[24],
|
||||
const uint8_t key[32])
|
||||
{
|
||||
/* Step 1: derive sub-key with HChaCha20(key, nonce[0..15]) */
|
||||
uint8_t subkey[32];
|
||||
hchacha20(subkey, nonce, key);
|
||||
|
||||
/* Step 2: ChaCha20-IETF with sub-key and nonce' = [0,0,0,0, nonce[16..23]] */
|
||||
uint8_t sub_nonce[12] = {0};
|
||||
memcpy(sub_nonce + 4, nonce + 16, 8);
|
||||
|
||||
chacha20_ietf_xor(out, in, len, sub_nonce, subkey, 0);
|
||||
|
||||
memset(subkey, 0, sizeof(subkey));
|
||||
}
|
||||
33
app/src/main/cpp/crypto.h
Normal file
33
app/src/main/cpp/crypto.h
Normal file
@@ -0,0 +1,33 @@
|
||||
#ifndef ROSETTA_CRYPTO_H
|
||||
#define ROSETTA_CRYPTO_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* HSalsa20 core — used by nacl.box.before() to derive shared key.
|
||||
* out: 32 bytes, inp: 16 bytes (nonce, zeros for box.before), key: 32 bytes
|
||||
*/
|
||||
void rosetta_hsalsa20(uint8_t out[32],
|
||||
const uint8_t inp[16],
|
||||
const uint8_t key[32]);
|
||||
|
||||
/**
|
||||
* XChaCha20 XOR (encrypt = decrypt, symmetric stream cipher).
|
||||
* out and in may overlap. nonce: 24 bytes, key: 32 bytes.
|
||||
*/
|
||||
void rosetta_xchacha20_xor(uint8_t *out,
|
||||
const uint8_t *in,
|
||||
size_t len,
|
||||
const uint8_t nonce[24],
|
||||
const uint8_t key[32]);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* ROSETTA_CRYPTO_H */
|
||||
1156
app/src/main/cpp/rosetta_e2ee.cpp
Normal file
1156
app/src/main/cpp/rosetta_e2ee.cpp
Normal file
File diff suppressed because it is too large
Load Diff
24
app/src/main/cpp/webrtc/api/array_view.h
Normal file
24
app/src/main/cpp/webrtc/api/array_view.h
Normal file
@@ -0,0 +1,24 @@
|
||||
// Minimal stub matching WebRTC M125 api/array_view.h
|
||||
#ifndef API_ARRAY_VIEW_H_
|
||||
#define API_ARRAY_VIEW_H_
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
namespace rtc {
|
||||
|
||||
template <typename T>
|
||||
class ArrayView final {
|
||||
public:
|
||||
constexpr ArrayView() noexcept : ptr_(nullptr), size_(0) {}
|
||||
constexpr ArrayView(T* ptr, size_t size) noexcept : ptr_(ptr), size_(size) {}
|
||||
constexpr T* data() const { return ptr_; }
|
||||
constexpr size_t size() const { return size_; }
|
||||
|
||||
private:
|
||||
T* ptr_;
|
||||
size_t size_;
|
||||
};
|
||||
|
||||
} // namespace rtc
|
||||
|
||||
#endif // API_ARRAY_VIEW_H_
|
||||
@@ -0,0 +1,42 @@
|
||||
// Minimal stub matching WebRTC M125 api/crypto/frame_decryptor_interface.h
|
||||
#ifndef API_CRYPTO_FRAME_DECRYPTOR_INTERFACE_H_
|
||||
#define API_CRYPTO_FRAME_DECRYPTOR_INTERFACE_H_
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "webrtc/rtc_base/ref_count.h"
|
||||
#include "webrtc/api/array_view.h"
|
||||
#include "webrtc/api/media_types.h"
|
||||
|
||||
namespace webrtc {
|
||||
|
||||
class FrameDecryptorInterface : public rtc::RefCountInterface {
|
||||
public:
|
||||
struct Result {
|
||||
enum class Status { kOk = 0, kRecoverable, kFailedToDecrypt };
|
||||
|
||||
Result(Status s, size_t bw) : status(s), bytes_written(bw) {}
|
||||
bool IsOk() const { return status == Status::kOk; }
|
||||
|
||||
Status status;
|
||||
size_t bytes_written;
|
||||
};
|
||||
|
||||
virtual Result Decrypt(cricket::MediaType media_type,
|
||||
const std::vector<uint32_t>& csrcs,
|
||||
rtc::ArrayView<const uint8_t> additional_data,
|
||||
rtc::ArrayView<const uint8_t> encrypted_frame,
|
||||
rtc::ArrayView<uint8_t> frame) = 0;
|
||||
|
||||
virtual size_t GetMaxPlaintextByteSize(cricket::MediaType media_type,
|
||||
size_t encrypted_frame_size) = 0;
|
||||
|
||||
protected:
|
||||
~FrameDecryptorInterface() override {}
|
||||
};
|
||||
|
||||
} // namespace webrtc
|
||||
|
||||
#endif // API_CRYPTO_FRAME_DECRYPTOR_INTERFACE_H_
|
||||
@@ -0,0 +1,32 @@
|
||||
// Minimal stub matching WebRTC M125 api/crypto/frame_encryptor_interface.h
|
||||
#ifndef API_CRYPTO_FRAME_ENCRYPTOR_INTERFACE_H_
|
||||
#define API_CRYPTO_FRAME_ENCRYPTOR_INTERFACE_H_
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#include "webrtc/rtc_base/ref_count.h"
|
||||
#include "webrtc/api/array_view.h"
|
||||
#include "webrtc/api/media_types.h"
|
||||
|
||||
namespace webrtc {
|
||||
|
||||
class FrameEncryptorInterface : public rtc::RefCountInterface {
|
||||
public:
|
||||
virtual int Encrypt(cricket::MediaType media_type,
|
||||
uint32_t ssrc,
|
||||
rtc::ArrayView<const uint8_t> additional_data,
|
||||
rtc::ArrayView<const uint8_t> frame,
|
||||
rtc::ArrayView<uint8_t> encrypted_frame,
|
||||
size_t* bytes_written) = 0;
|
||||
|
||||
virtual size_t GetMaxCiphertextByteSize(cricket::MediaType media_type,
|
||||
size_t frame_size) = 0;
|
||||
|
||||
protected:
|
||||
~FrameEncryptorInterface() override {}
|
||||
};
|
||||
|
||||
} // namespace webrtc
|
||||
|
||||
#endif // API_CRYPTO_FRAME_ENCRYPTOR_INTERFACE_H_
|
||||
14
app/src/main/cpp/webrtc/api/media_types.h
Normal file
14
app/src/main/cpp/webrtc/api/media_types.h
Normal file
@@ -0,0 +1,14 @@
|
||||
// Minimal stub matching WebRTC M125 api/media_types.h
|
||||
#ifndef API_MEDIA_TYPES_H_
|
||||
#define API_MEDIA_TYPES_H_
|
||||
|
||||
namespace cricket {
|
||||
enum MediaType {
|
||||
MEDIA_TYPE_AUDIO,
|
||||
MEDIA_TYPE_VIDEO,
|
||||
MEDIA_TYPE_DATA,
|
||||
MEDIA_TYPE_UNSUPPORTED
|
||||
};
|
||||
} // namespace cricket
|
||||
|
||||
#endif // API_MEDIA_TYPES_H_
|
||||
21
app/src/main/cpp/webrtc/rtc_base/ref_count.h
Normal file
21
app/src/main/cpp/webrtc/rtc_base/ref_count.h
Normal file
@@ -0,0 +1,21 @@
|
||||
// Minimal stub matching WebRTC M125 rtc_base/ref_count.h
|
||||
#ifndef RTC_BASE_REF_COUNT_H_
|
||||
#define RTC_BASE_REF_COUNT_H_
|
||||
|
||||
namespace rtc {
|
||||
|
||||
enum class RefCountReleaseStatus { kDroppedLastRef, kOtherRefsRemained };
|
||||
|
||||
// Must match the EXACT virtual layout of the real rtc::RefCountInterface.
|
||||
class RefCountInterface {
|
||||
public:
|
||||
virtual void AddRef() const = 0;
|
||||
virtual RefCountReleaseStatus Release() const = 0;
|
||||
|
||||
protected:
|
||||
virtual ~RefCountInterface() {}
|
||||
};
|
||||
|
||||
} // namespace rtc
|
||||
|
||||
#endif // RTC_BASE_REF_COUNT_H_
|
||||
@@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -15,7 +15,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
|
||||
/**
|
||||
* 🚀 Telegram-style: Fixed Height Box + Fade In/Out
|
||||
@@ -53,8 +53,17 @@ fun AnimatedKeyboardTransition(
|
||||
wasEmojiShown = true
|
||||
}
|
||||
if (!showEmojiPicker && wasEmojiShown && !isTransitioningToKeyboard) {
|
||||
// Emoji закрылся после того как был открыт = переход emoji→keyboard
|
||||
isTransitioningToKeyboard = true
|
||||
// Keep reserved space only if keyboard is actually opening.
|
||||
// For back-swipe/back-press close there is no keyboard open request,
|
||||
// so we must drop the emoji box immediately to avoid an empty gap.
|
||||
val keyboardIsComing =
|
||||
coordinator.currentState == KeyboardTransitionCoordinator.TransitionState.EMOJI_TO_KEYBOARD ||
|
||||
coordinator.isKeyboardVisible ||
|
||||
coordinator.keyboardHeight > 0.dp
|
||||
isTransitioningToKeyboard = keyboardIsComing
|
||||
if (!keyboardIsComing) {
|
||||
wasEmojiShown = false
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 КЛЮЧЕВОЕ: Сбрасываем флаг когда клавиатура достигла высоты близкой к emoji
|
||||
@@ -63,6 +72,19 @@ fun AnimatedKeyboardTransition(
|
||||
isTransitioningToKeyboard = false
|
||||
wasEmojiShown = false
|
||||
}
|
||||
|
||||
// Failsafe for interrupted gesture/back navigation: if keyboard never started opening,
|
||||
// don't keep an invisible fixed-height box.
|
||||
if (
|
||||
isTransitioningToKeyboard &&
|
||||
!showEmojiPicker &&
|
||||
coordinator.currentState != KeyboardTransitionCoordinator.TransitionState.EMOJI_TO_KEYBOARD &&
|
||||
!coordinator.isKeyboardVisible &&
|
||||
coordinator.keyboardHeight == 0.dp
|
||||
) {
|
||||
isTransitioningToKeyboard = false
|
||||
wasEmojiShown = false
|
||||
}
|
||||
|
||||
// 🎯 Целевая прозрачность
|
||||
val targetAlpha = if (showEmojiPicker) 1f else 0f
|
||||
@@ -110,19 +132,3 @@ fun AnimatedKeyboardTransition(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Алиас для обратной совместимости
|
||||
*/
|
||||
@Composable
|
||||
fun SimpleAnimatedKeyboardTransition(
|
||||
coordinator: KeyboardTransitionCoordinator,
|
||||
showEmojiPicker: Boolean,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
AnimatedKeyboardTransition(
|
||||
coordinator = coordinator,
|
||||
showEmojiPicker = showEmojiPicker,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -46,9 +46,6 @@ class KeyboardTransitionCoordinator {
|
||||
var currentState by mutableStateOf(TransitionState.IDLE)
|
||||
private set
|
||||
|
||||
var transitionProgress by mutableFloatStateOf(0f)
|
||||
private set
|
||||
|
||||
// ============ Высоты ============
|
||||
|
||||
var keyboardHeight by mutableStateOf(0.dp)
|
||||
@@ -68,9 +65,6 @@ class KeyboardTransitionCoordinator {
|
||||
// Используется для отключения imePadding пока Box виден
|
||||
var isEmojiBoxVisible by mutableStateOf(false)
|
||||
|
||||
// 🔥 Коллбэк для показа emoji (сохраняем для вызова после закрытия клавиатуры)
|
||||
private var pendingShowEmojiCallback: (() -> Unit)? = null
|
||||
|
||||
// 📊 Для умного логирования (не каждый фрейм)
|
||||
private var lastLogTime = 0L
|
||||
private var lastLoggedHeight = -1f
|
||||
@@ -108,8 +102,6 @@ class KeyboardTransitionCoordinator {
|
||||
currentState = TransitionState.IDLE
|
||||
isTransitioning = false
|
||||
|
||||
// Очищаем pending callback - больше не нужен
|
||||
pendingShowEmojiCallback = null
|
||||
}
|
||||
|
||||
// ============ Главный метод: Emoji → Keyboard ============
|
||||
@@ -119,11 +111,6 @@ class KeyboardTransitionCoordinator {
|
||||
* плавно скрыть emoji.
|
||||
*/
|
||||
fun requestShowKeyboard(showKeyboard: () -> Unit, hideEmoji: () -> Unit) {
|
||||
// 🔥 Отменяем pending emoji callback если он есть (предотвращаем конфликт)
|
||||
if (pendingShowEmojiCallback != null) {
|
||||
pendingShowEmojiCallback = null
|
||||
}
|
||||
|
||||
currentState = TransitionState.EMOJI_TO_KEYBOARD
|
||||
isTransitioning = true
|
||||
|
||||
@@ -260,13 +247,6 @@ class KeyboardTransitionCoordinator {
|
||||
// 🔥 УБРАН pending callback - теперь emoji показывается сразу в requestShowEmoji()
|
||||
}
|
||||
|
||||
/** Обновить высоту emoji панели. */
|
||||
fun updateEmojiHeight(height: Dp) {
|
||||
if (height > 0.dp && height != emojiHeight) {
|
||||
emojiHeight = height
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Синхронизировать высоты (emoji = keyboard).
|
||||
*
|
||||
@@ -292,35 +272,6 @@ class KeyboardTransitionCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить текущую высоту для резервирования места. Telegram паттерн: всегда резервировать
|
||||
* максимум из двух.
|
||||
*/
|
||||
fun getReservedHeight(): Dp {
|
||||
return when {
|
||||
isKeyboardVisible -> keyboardHeight
|
||||
isEmojiVisible -> emojiHeight
|
||||
isTransitioning -> maxOf(keyboardHeight, emojiHeight)
|
||||
else -> 0.dp
|
||||
}
|
||||
}
|
||||
|
||||
/** Проверка, можно ли начать новый переход. */
|
||||
fun canStartTransition(): Boolean {
|
||||
return !isTransitioning
|
||||
}
|
||||
|
||||
/** Сброс состояния (для отладки). */
|
||||
fun reset() {
|
||||
currentState = TransitionState.IDLE
|
||||
isTransitioning = false
|
||||
isKeyboardVisible = false
|
||||
isEmojiVisible = false
|
||||
transitionProgress = 0f
|
||||
}
|
||||
|
||||
/** Логирование текущего состояния. */
|
||||
fun logState() {}
|
||||
}
|
||||
|
||||
/** Composable для создания и запоминания coordinator'а. */
|
||||
|
||||
196
app/src/main/java/com/rosetta/messenger/IncomingCallActivity.kt
Normal file
196
app/src/main/java/com/rosetta/messenger/IncomingCallActivity.kt
Normal file
@@ -0,0 +1,196 @@
|
||||
package com.rosetta.messenger
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.runtime.*
|
||||
import com.rosetta.messenger.data.AccountManager
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.CallForegroundService
|
||||
import com.rosetta.messenger.network.CallManager
|
||||
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"
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
try {
|
||||
super.onCreate(savedInstanceState)
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "super.onCreate CRASHED", e)
|
||||
callLog("super.onCreate CRASHED: ${e.message}")
|
||||
finish()
|
||||
return
|
||||
}
|
||||
callLog("onCreate START")
|
||||
|
||||
// Показываем поверх lock screen и включаем экран
|
||||
callLog("setting lock screen flags, SDK=${Build.VERSION.SDK_INT}")
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
|
||||
setShowWhenLocked(true)
|
||||
setTurnScreenOn(true)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
window.addFlags(
|
||||
WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
|
||||
WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or
|
||||
WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
|
||||
)
|
||||
}
|
||||
|
||||
// Важно: не снимаем keyguard автоматически.
|
||||
// Экран звонка может отображаться поверх lockscreen, но разблокировку делает только пользователь.
|
||||
|
||||
try {
|
||||
CallManager.initialize(applicationContext)
|
||||
callLog("CallManager initialized, phase=${CallManager.state.value.phase}")
|
||||
} catch (e: Throwable) {
|
||||
callLog("CallManager.initialize CRASHED: ${e.message}")
|
||||
Log.e(TAG, "CallManager init failed", e)
|
||||
}
|
||||
|
||||
callLog("calling setContent")
|
||||
setContent {
|
||||
val callState by CallManager.state.collectAsState()
|
||||
|
||||
// Ждём до 10 сек пока WebSocket доставит сигнал (CallManager перейдёт из IDLE)
|
||||
var wasIncoming by remember { mutableStateOf(false) }
|
||||
var lastPeerIdentity by remember { mutableStateOf(Triple("", "", "")) }
|
||||
|
||||
LaunchedEffect(callState.phase) {
|
||||
callLog("phase changed: ${callState.phase}")
|
||||
if (callState.phase == CallPhase.INCOMING) wasIncoming = true
|
||||
// Закрываем только когда звонок завершился
|
||||
if (callState.phase == CallPhase.IDLE && wasIncoming) {
|
||||
callLog("IDLE after INCOMING → finish()")
|
||||
finish()
|
||||
}
|
||||
// НЕ закрываемся при CONNECTING/ACTIVE — остаёмся на экране звонка
|
||||
// IncomingCallActivity показывает полный CallOverlay, не нужно переходить в MainActivity
|
||||
}
|
||||
|
||||
LaunchedEffect(callState.peerPublicKey, callState.peerTitle, callState.peerUsername) {
|
||||
val hasIdentity =
|
||||
callState.peerPublicKey.isNotBlank() ||
|
||||
callState.peerTitle.isNotBlank() ||
|
||||
callState.peerUsername.isNotBlank()
|
||||
if (hasIdentity) {
|
||||
lastPeerIdentity =
|
||||
Triple(callState.peerPublicKey, callState.peerTitle, callState.peerUsername)
|
||||
}
|
||||
}
|
||||
|
||||
// Показываем INCOMING в IDLE только до первого реального входящего состояния.
|
||||
// Иначе после Decline/END на мгновение мелькает "Unknown".
|
||||
val shouldShowProvisionalIncoming =
|
||||
callState.phase == CallPhase.IDLE &&
|
||||
!wasIncoming &&
|
||||
(callState.peerPublicKey.isNotBlank() ||
|
||||
callState.peerTitle.isNotBlank() ||
|
||||
callState.peerUsername.isNotBlank())
|
||||
|
||||
val displayState = if (shouldShowProvisionalIncoming) {
|
||||
callState.copy(phase = CallPhase.INCOMING, statusText = "Incoming call...")
|
||||
} else if (callState.phase == CallPhase.IDLE && wasIncoming) {
|
||||
// Во время закрытия Activity сохраняем последнее известное имя/peer, чтобы не мигал Unknown.
|
||||
callState.copy(
|
||||
peerPublicKey = lastPeerIdentity.first,
|
||||
peerTitle = lastPeerIdentity.second,
|
||||
peerUsername = lastPeerIdentity.third
|
||||
)
|
||||
} else {
|
||||
callState
|
||||
}
|
||||
|
||||
val avatarRepository = remember {
|
||||
val accountKey = accountManager.getLastLoggedPublicKey().orEmpty()
|
||||
if (accountKey.isNotBlank()) {
|
||||
val db = RosettaDatabase.getDatabase(applicationContext)
|
||||
AvatarRepository(
|
||||
context = applicationContext,
|
||||
avatarDao = db.avatarDao(),
|
||||
currentPublicKey = accountKey
|
||||
)
|
||||
} else null
|
||||
}
|
||||
|
||||
RosettaAndroidTheme(darkTheme = true) {
|
||||
CallOverlay(
|
||||
state = displayState,
|
||||
isDarkTheme = true,
|
||||
avatarRepository = avatarRepository,
|
||||
isExpanded = true,
|
||||
onAccept = {
|
||||
callLog("onAccept tapped, phase=${callState.phase}")
|
||||
if (callState.phase == CallPhase.INCOMING) {
|
||||
val result = CallManager.acceptIncomingCall()
|
||||
callLog("acceptIncomingCall result=$result")
|
||||
// Остаёмся на IncomingCallActivity — она покажет CONNECTING → ACTIVE
|
||||
} else {
|
||||
callLog("onAccept: phase=${callState.phase}, trying accept anyway")
|
||||
CallManager.acceptIncomingCall()
|
||||
}
|
||||
},
|
||||
onDecline = {
|
||||
callLog("onDecline tapped")
|
||||
CallManager.declineIncomingCall()
|
||||
finish()
|
||||
},
|
||||
onEnd = {
|
||||
callLog("onEnd tapped")
|
||||
CallManager.endCall()
|
||||
finish()
|
||||
},
|
||||
onToggleMute = { CallManager.toggleMute() },
|
||||
onToggleSpeaker = { CallManager.toggleSpeaker() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openMainActivity() {
|
||||
callLog("openMainActivity")
|
||||
val intent = Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or
|
||||
Intent.FLAG_ACTIVITY_SINGLE_TOP or
|
||||
Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtra(CallForegroundService.EXTRA_OPEN_CALL_FROM_NOTIFICATION, true)
|
||||
}
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
private fun callLog(msg: String) {
|
||||
Log.d(TAG, msg)
|
||||
try {
|
||||
val ctx = applicationContext ?: return
|
||||
val dir = java.io.File(ctx.filesDir, "crash_reports")
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
val f = java.io.File(dir, "call_notification_log.txt")
|
||||
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date())
|
||||
f.appendText("$ts [IncomingCallActivity] $msg\n")
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "callLog write failed: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,15 +2,29 @@ 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.ProtocolRuntime
|
||||
import com.rosetta.messenger.network.ProtocolRuntimeAccess
|
||||
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
|
||||
@Inject lateinit var protocolRuntime: ProtocolRuntime
|
||||
|
||||
companion object {
|
||||
private const val TAG = "RosettaApplication"
|
||||
@@ -24,6 +38,9 @@ class RosettaApplication : Application() {
|
||||
|
||||
// Инициализируем crash reporter
|
||||
initCrashReporting()
|
||||
|
||||
// Install instance-based protocol runtime for non-Hilt singleton objects.
|
||||
ProtocolRuntimeAccess.install(protocolRuntime)
|
||||
|
||||
// Инициализируем менеджер черновиков
|
||||
DraftManager.init(this)
|
||||
@@ -33,6 +50,11 @@ class RosettaApplication : Application() {
|
||||
|
||||
// Инициализируем менеджер обновлений (SDU)
|
||||
UpdateManager.init(this)
|
||||
|
||||
CallManager.bindDependencies(
|
||||
messageRepository = messageRepository,
|
||||
accountManager = accountManager
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.rosetta.messenger.biometric
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||
@@ -52,7 +53,15 @@ class BiometricAuthManager(private val context: Context) {
|
||||
* Проверяет доступность STRONG биометрической аутентификации
|
||||
* BIOMETRIC_STRONG требует Class 3 биометрию (отпечаток/лицо с криптографической привязкой)
|
||||
*/
|
||||
fun isFingerprintHardwareAvailable(): Boolean {
|
||||
return context.packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)
|
||||
}
|
||||
|
||||
fun isBiometricAvailable(): BiometricAvailability {
|
||||
if (!isFingerprintHardwareAvailable()) {
|
||||
return BiometricAvailability.NotAvailable("Отпечаток пальца не поддерживается")
|
||||
}
|
||||
|
||||
val biometricManager = BiometricManager.from(context)
|
||||
|
||||
return when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) {
|
||||
|
||||
@@ -14,50 +14,36 @@ import kotlinx.coroutines.withContext
|
||||
/**
|
||||
* Безопасное хранилище настроек биометрической аутентификации
|
||||
* Использует EncryptedSharedPreferences с MasterKey из Android Keystore
|
||||
*
|
||||
* Уровни защиты:
|
||||
* - AES256_GCM для шифрования значений
|
||||
* - AES256_SIV для шифрования ключей
|
||||
* - MasterKey хранится в Android Keystore (TEE/StrongBox)
|
||||
*
|
||||
* Биометрия привязана к конкретному аккаунту (per-account), не глобальная.
|
||||
*/
|
||||
class BiometricPreferences(private val context: Context) {
|
||||
|
||||
|
||||
companion object {
|
||||
private const val TAG = "BiometricPreferences"
|
||||
private const val PREFS_FILE_NAME = "rosetta_secure_biometric_prefs"
|
||||
private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled"
|
||||
private const val KEY_BIOMETRIC_ENABLED_PREFIX = "biometric_enabled_"
|
||||
private const val ENCRYPTED_PASSWORD_PREFIX = "encrypted_password_"
|
||||
// Shared between all BiometricPreferences instances so UI in different screens
|
||||
// receives updates immediately (ProfileScreen <-> BiometricEnableScreen).
|
||||
// Legacy key (global) — for migration
|
||||
private const val KEY_BIOMETRIC_ENABLED_LEGACY = "biometric_enabled"
|
||||
// Shared state for reactive UI updates
|
||||
private val biometricEnabledState = MutableStateFlow(false)
|
||||
}
|
||||
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val _isBiometricEnabled = biometricEnabledState
|
||||
|
||||
|
||||
private val encryptedPrefs: SharedPreferences by lazy {
|
||||
createEncryptedPreferences()
|
||||
}
|
||||
|
||||
init {
|
||||
// Загружаем начальное значение
|
||||
try {
|
||||
_isBiometricEnabled.value = encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED, false)
|
||||
} catch (e: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создает EncryptedSharedPreferences с максимальной защитой
|
||||
*/
|
||||
|
||||
private fun createEncryptedPreferences(): SharedPreferences {
|
||||
try {
|
||||
// Создаем MasterKey с максимальной защитой
|
||||
val masterKey = MasterKey.Builder(appContext)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.setUserAuthenticationRequired(false) // Биометрия проверяется отдельно в BiometricAuthManager
|
||||
.setUserAuthenticationRequired(false)
|
||||
.build()
|
||||
|
||||
|
||||
return EncryptedSharedPreferences.create(
|
||||
appContext,
|
||||
PREFS_FILE_NAME,
|
||||
@@ -66,77 +52,93 @@ class BiometricPreferences(private val context: Context) {
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Fallback на обычные SharedPreferences в случае ошибки (не должно произойти)
|
||||
return appContext.getSharedPreferences(PREFS_FILE_NAME + "_fallback", Context.MODE_PRIVATE)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Включена ли биометрическая аутентификация
|
||||
*/
|
||||
val isBiometricEnabled: Flow<Boolean> = _isBiometricEnabled.asStateFlow()
|
||||
|
||||
/**
|
||||
* Включить биометрическую аутентификацию
|
||||
* Загрузить состояние биометрии для конкретного аккаунта
|
||||
*/
|
||||
fun loadForAccount(publicKey: String) {
|
||||
try {
|
||||
val key = "$KEY_BIOMETRIC_ENABLED_PREFIX$publicKey"
|
||||
val perAccount = encryptedPrefs.getBoolean(key, false)
|
||||
// Migration: если per-account нет, проверяем legacy глобальный ключ
|
||||
if (!perAccount && encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED_LEGACY, false)) {
|
||||
// Мигрируем: копируем глобальное значение в per-account
|
||||
encryptedPrefs.edit().putBoolean(key, true).apply()
|
||||
_isBiometricEnabled.value = true
|
||||
} else {
|
||||
_isBiometricEnabled.value = perAccount
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_isBiometricEnabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Включить биометрическую аутентификацию для аккаунта
|
||||
*/
|
||||
suspend fun enableBiometric(publicKey: String) = withContext(Dispatchers.IO) {
|
||||
val key = "$KEY_BIOMETRIC_ENABLED_PREFIX$publicKey"
|
||||
encryptedPrefs.edit().putBoolean(key, true).commit()
|
||||
_isBiometricEnabled.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Отключить биометрическую аутентификацию для аккаунта
|
||||
*/
|
||||
suspend fun disableBiometric(publicKey: String) = withContext(Dispatchers.IO) {
|
||||
val key = "$KEY_BIOMETRIC_ENABLED_PREFIX$publicKey"
|
||||
encryptedPrefs.edit().putBoolean(key, false).commit()
|
||||
_isBiometricEnabled.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить включена ли биометрия для аккаунта (синхронно)
|
||||
*/
|
||||
fun isBiometricEnabledForAccount(publicKey: String): Boolean {
|
||||
return try {
|
||||
encryptedPrefs.getBoolean("$KEY_BIOMETRIC_ENABLED_PREFIX$publicKey", false)
|
||||
} catch (_: Exception) { false }
|
||||
}
|
||||
|
||||
// --- Legacy compat: old callers without publicKey ---
|
||||
|
||||
@Deprecated("Use enableBiometric(publicKey) instead")
|
||||
suspend fun enableBiometric() = withContext(Dispatchers.IO) {
|
||||
val success = encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, true).commit()
|
||||
if (!success) {
|
||||
Log.w(TAG, "Failed to persist biometric enabled state")
|
||||
}
|
||||
_isBiometricEnabled.value = encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED, false)
|
||||
encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED_LEGACY, true).commit()
|
||||
_isBiometricEnabled.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Отключить биометрическую аутентификацию
|
||||
*/
|
||||
@Deprecated("Use disableBiometric(publicKey) instead")
|
||||
suspend fun disableBiometric() = withContext(Dispatchers.IO) {
|
||||
val success = encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, false).commit()
|
||||
if (!success) {
|
||||
Log.w(TAG, "Failed to persist biometric disabled state")
|
||||
}
|
||||
_isBiometricEnabled.value = encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED, false)
|
||||
encryptedPrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED_LEGACY, false).commit()
|
||||
_isBiometricEnabled.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохранить зашифрованный пароль для аккаунта
|
||||
* Пароль уже зашифрован BiometricAuthManager, здесь добавляется второй слой шифрования
|
||||
*/
|
||||
suspend fun saveEncryptedPassword(publicKey: String, encryptedPassword: String) = withContext(Dispatchers.IO) {
|
||||
val key = "$ENCRYPTED_PASSWORD_PREFIX$publicKey"
|
||||
encryptedPrefs.edit().putString(key, encryptedPassword).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить зашифрованный пароль для аккаунта
|
||||
*/
|
||||
suspend fun getEncryptedPassword(publicKey: String): String? = withContext(Dispatchers.IO) {
|
||||
val key = "$ENCRYPTED_PASSWORD_PREFIX$publicKey"
|
||||
encryptedPrefs.getString(key, null)
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить зашифрованный пароль для аккаунта
|
||||
*/
|
||||
suspend fun removeEncryptedPassword(publicKey: String) = withContext(Dispatchers.IO) {
|
||||
val key = "$ENCRYPTED_PASSWORD_PREFIX$publicKey"
|
||||
encryptedPrefs.edit().remove(key).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Удалить все биометрические данные
|
||||
*/
|
||||
suspend fun clearAll() = withContext(Dispatchers.IO) {
|
||||
val success = encryptedPrefs.edit().clear().commit()
|
||||
if (!success) {
|
||||
Log.w(TAG, "Failed to clear biometric preferences")
|
||||
}
|
||||
_isBiometricEnabled.value = encryptedPrefs.getBoolean(KEY_BIOMETRIC_ENABLED, false)
|
||||
encryptedPrefs.edit().clear().commit()
|
||||
_isBiometricEnabled.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Проверить, есть ли сохраненный зашифрованный пароль для аккаунта
|
||||
*/
|
||||
suspend fun hasEncryptedPassword(publicKey: String): Boolean {
|
||||
return getEncryptedPassword(publicKey) != null
|
||||
}
|
||||
|
||||
@@ -45,6 +45,10 @@ object CryptoManager {
|
||||
// ConcurrentHashMap вместо synchronized LinkedHashMap — убирает контention при параллельной
|
||||
// расшифровке
|
||||
private const val DECRYPTION_CACHE_SIZE = 2000
|
||||
// Не кэшируем большие payload (вложения), чтобы избежать OOM на конкатенации cache key
|
||||
// и хранения гигантских plaintext в памяти.
|
||||
private const val MAX_CACHEABLE_ENCRYPTED_CHARS = 64 * 1024
|
||||
private const val MAX_CACHEABLE_DECRYPTED_CHARS = 64 * 1024
|
||||
private val decryptionCache = ConcurrentHashMap<String, String>(DECRYPTION_CACHE_SIZE, 0.75f, 4)
|
||||
|
||||
init {
|
||||
@@ -298,17 +302,21 @@ object CryptoManager {
|
||||
* 🚀 ОПТИМИЗАЦИЯ: Кэширование PBKDF2 ключа и расшифрованных сообщений
|
||||
*/
|
||||
fun decryptWithPassword(encryptedData: String, password: String): String? {
|
||||
val useCache = encryptedData.length <= MAX_CACHEABLE_ENCRYPTED_CHARS
|
||||
val cacheKey = if (useCache) "$password:$encryptedData" else null
|
||||
|
||||
// 🚀 ОПТИМИЗАЦИЯ: Lock-free проверка кэша (ConcurrentHashMap)
|
||||
val cacheKey = "$password:$encryptedData"
|
||||
decryptionCache[cacheKey]?.let {
|
||||
return it
|
||||
if (cacheKey != null) {
|
||||
decryptionCache[cacheKey]?.let {
|
||||
return it
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
val result = decryptWithPasswordInternal(encryptedData, password)
|
||||
|
||||
// 🚀 Сохраняем в кэш (lock-free)
|
||||
if (result != null) {
|
||||
if (cacheKey != null && result != null && result.length <= MAX_CACHEABLE_DECRYPTED_CHARS) {
|
||||
// Ограничиваем размер кэша
|
||||
if (decryptionCache.size >= DECRYPTION_CACHE_SIZE) {
|
||||
// Удаляем ~10% самых старых записей
|
||||
|
||||
@@ -1327,15 +1327,17 @@ object MessageCrypto {
|
||||
/**
|
||||
* Собираем пароль-кандидаты для полной desktop совместимости:
|
||||
* - full key+nonce (56 bytes) и legacy key-only (32 bytes)
|
||||
* - hex password (актуальный desktop формат для attachments)
|
||||
* - Buffer polyfill UTF-8 decode (desktop runtime parity: window.Buffer from "buffer")
|
||||
* - WHATWG/Node UTF-8 decode
|
||||
* - JVM UTF-8 / Latin1 fallback
|
||||
*/
|
||||
private fun buildAttachmentPasswordCandidates(chachaKeyPlain: ByteArray): List<String> {
|
||||
val candidates = LinkedHashSet<String>(12)
|
||||
val candidates = LinkedHashSet<String>(16)
|
||||
|
||||
fun addVariants(bytes: ByteArray) {
|
||||
if (bytes.isEmpty()) return
|
||||
candidates.add(bytes.joinToString("") { "%02x".format(it) })
|
||||
candidates.add(bytesToBufferPolyfillUtf8String(bytes))
|
||||
candidates.add(bytesToJsUtf8String(bytes))
|
||||
candidates.add(String(bytes, Charsets.UTF_8))
|
||||
@@ -1592,7 +1594,6 @@ object MessageCrypto {
|
||||
// Reset bounds to default after first continuation
|
||||
lowerBoundary = 0x80
|
||||
upperBoundary = 0xBF
|
||||
|
||||
if (bytesSeen == bytesNeeded) {
|
||||
// Sequence complete — emit code point
|
||||
if (codePoint <= 0xFFFF) {
|
||||
|
||||
@@ -21,6 +21,7 @@ class AccountManager(private val context: Context) {
|
||||
private val IS_LOGGED_IN = booleanPreferencesKey("is_logged_in")
|
||||
private const val PREFS_NAME = "rosetta_account_prefs"
|
||||
private const val KEY_LAST_LOGGED = "last_logged_public_key"
|
||||
private const val KEY_LAST_LOGGED_PRIVATE_HASH = "last_logged_private_hash"
|
||||
}
|
||||
|
||||
// Use SharedPreferences for last logged account - more reliable for immediate reads
|
||||
@@ -43,13 +44,19 @@ class AccountManager(private val context: Context) {
|
||||
val publicKey = sharedPrefs.getString(KEY_LAST_LOGGED, null)
|
||||
return publicKey
|
||||
}
|
||||
|
||||
fun getLastLoggedPrivateKeyHash(): String? {
|
||||
return sharedPrefs.getString(KEY_LAST_LOGGED_PRIVATE_HASH, null)
|
||||
}
|
||||
|
||||
// Synchronous write to SharedPreferences
|
||||
fun setLastLoggedPublicKey(publicKey: String) {
|
||||
val success = sharedPrefs.edit().putString(KEY_LAST_LOGGED, publicKey).commit() // commit() is synchronous
|
||||
|
||||
// Verify immediately
|
||||
val saved = sharedPrefs.getString(KEY_LAST_LOGGED, null)
|
||||
sharedPrefs.edit().putString(KEY_LAST_LOGGED, publicKey).commit() // commit() is synchronous
|
||||
}
|
||||
|
||||
fun setLastLoggedPrivateKeyHash(privateKeyHash: String) {
|
||||
if (privateKeyHash.isBlank()) return
|
||||
sharedPrefs.edit().putString(KEY_LAST_LOGGED_PRIVATE_HASH, privateKeyHash).apply()
|
||||
}
|
||||
|
||||
suspend fun saveAccount(account: EncryptedAccount) {
|
||||
@@ -98,6 +105,7 @@ class AccountManager(private val context: Context) {
|
||||
context.accountDataStore.edit { preferences ->
|
||||
preferences[IS_LOGGED_IN] = false
|
||||
}
|
||||
sharedPrefs.edit().remove(KEY_LAST_LOGGED_PRIVATE_HASH).apply()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -140,12 +148,21 @@ class AccountManager(private val context: Context) {
|
||||
// Clear SharedPreferences if this was the last logged account
|
||||
val lastLogged = sharedPrefs.getString(KEY_LAST_LOGGED, null)
|
||||
if (lastLogged == publicKey) {
|
||||
sharedPrefs.edit().remove(KEY_LAST_LOGGED).commit()
|
||||
sharedPrefs
|
||||
.edit()
|
||||
.remove(KEY_LAST_LOGGED)
|
||||
.remove(KEY_LAST_LOGGED_PRIVATE_HASH)
|
||||
.commit()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearAll() {
|
||||
context.accountDataStore.edit { it.clear() }
|
||||
sharedPrefs
|
||||
.edit()
|
||||
.remove(KEY_LAST_LOGGED)
|
||||
.remove(KEY_LAST_LOGGED_PRIVATE_HASH)
|
||||
.apply()
|
||||
}
|
||||
|
||||
private fun serializeAccounts(accounts: List<EncryptedAccount>): String {
|
||||
|
||||
@@ -46,19 +46,24 @@ object DraftManager {
|
||||
fun saveDraft(opponentKey: String, text: String) {
|
||||
if (currentAccount.isEmpty()) return
|
||||
|
||||
val trimmed = text.trim()
|
||||
val currentDrafts = _drafts.value.toMutableMap()
|
||||
val hasContent = text.any { !it.isWhitespace() }
|
||||
val existing = _drafts.value[opponentKey]
|
||||
|
||||
if (trimmed.isEmpty()) {
|
||||
if (!hasContent) {
|
||||
if (existing == null) return
|
||||
val currentDrafts = _drafts.value.toMutableMap()
|
||||
// Удаляем черновик если текст пустой
|
||||
currentDrafts.remove(opponentKey)
|
||||
prefs?.edit()?.remove(prefKey(opponentKey))?.apply()
|
||||
_drafts.value = currentDrafts
|
||||
} else {
|
||||
currentDrafts[opponentKey] = trimmed
|
||||
prefs?.edit()?.putString(prefKey(opponentKey), trimmed)?.apply()
|
||||
// Ничего не делаем, если текст не изменился — это частый путь при больших вставках.
|
||||
if (existing == text) return
|
||||
val currentDrafts = _drafts.value.toMutableMap()
|
||||
currentDrafts[opponentKey] = text
|
||||
prefs?.edit()?.putString(prefKey(opponentKey), text)?.apply()
|
||||
_drafts.value = currentDrafts
|
||||
}
|
||||
|
||||
_drafts.value = currentDrafts
|
||||
}
|
||||
|
||||
/** Получить черновик для диалога */
|
||||
|
||||
@@ -30,7 +30,8 @@ object ForwardManager {
|
||||
val senderPublicKey: String, // publicKey отправителя сообщения
|
||||
val originalChatPublicKey: String, // publicKey чата откуда пересылается
|
||||
val senderName: String = "", // Имя отправителя для атрибуции
|
||||
val attachments: List<MessageAttachment> = emptyList()
|
||||
val attachments: List<MessageAttachment> = emptyList(),
|
||||
val chachaKeyPlain: String = "" // Hex plainKeyAndNonce оригинального сообщения
|
||||
)
|
||||
|
||||
// Сообщения для пересылки
|
||||
|
||||
@@ -14,17 +14,24 @@ import com.rosetta.messenger.network.PacketGroupInfo
|
||||
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 com.rosetta.messenger.network.ProtocolClient
|
||||
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 protocolClient: ProtocolClient
|
||||
) {
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val db = RosettaDatabase.getDatabase(context.applicationContext)
|
||||
private val groupDao = db.groupDao()
|
||||
private val messageDao = db.messageDao()
|
||||
@@ -38,15 +45,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(
|
||||
@@ -155,7 +153,7 @@ class GroupRepository private constructor(context: Context) {
|
||||
this.groupId = groupId
|
||||
this.members = emptyList()
|
||||
}
|
||||
ProtocolManager.send(packet)
|
||||
protocolClient.send(packet)
|
||||
|
||||
val response = awaitPacketOnce<PacketGroupInfo>(
|
||||
packetId = 0x12,
|
||||
@@ -189,7 +187,7 @@ class GroupRepository private constructor(context: Context) {
|
||||
this.membersCount = 0
|
||||
this.groupStatus = GroupStatus.NOT_JOINED
|
||||
}
|
||||
ProtocolManager.send(packet)
|
||||
protocolClient.send(packet)
|
||||
|
||||
val response = awaitPacketOnce<PacketGroupInviteInfo>(
|
||||
packetId = 0x13,
|
||||
@@ -217,7 +215,7 @@ class GroupRepository private constructor(context: Context) {
|
||||
}
|
||||
|
||||
val createPacket = PacketCreateGroup()
|
||||
ProtocolManager.send(createPacket)
|
||||
protocolClient.send(createPacket)
|
||||
|
||||
val response = awaitPacketOnce<PacketCreateGroup>(
|
||||
packetId = 0x11,
|
||||
@@ -268,7 +266,7 @@ class GroupRepository private constructor(context: Context) {
|
||||
groupString = encodedGroupStringForServer
|
||||
groupStatus = GroupStatus.NOT_JOINED
|
||||
}
|
||||
ProtocolManager.send(packet)
|
||||
protocolClient.send(packet)
|
||||
|
||||
val response = awaitPacketOnce<PacketGroupJoin>(
|
||||
packetId = 0x14,
|
||||
@@ -376,7 +374,7 @@ class GroupRepository private constructor(context: Context) {
|
||||
val packet = PacketGroupLeave().apply {
|
||||
this.groupId = groupId
|
||||
}
|
||||
ProtocolManager.send(packet)
|
||||
protocolClient.send(packet)
|
||||
|
||||
val response = awaitPacketOnce<PacketGroupLeave>(
|
||||
packetId = 0x15,
|
||||
@@ -402,7 +400,7 @@ class GroupRepository private constructor(context: Context) {
|
||||
this.groupId = groupId
|
||||
this.publicKey = targetPublicKey
|
||||
}
|
||||
ProtocolManager.send(packet)
|
||||
protocolClient.send(packet)
|
||||
|
||||
val response = awaitPacketOnce<PacketGroupBan>(
|
||||
packetId = 0x16,
|
||||
@@ -456,6 +454,7 @@ class GroupRepository private constructor(context: Context) {
|
||||
messageId = UUID.randomUUID().toString().replace("-", "").take(32),
|
||||
plainMessage = encryptedPlainMessage,
|
||||
attachments = "[]",
|
||||
primaryAttachmentType = -1,
|
||||
dialogKey = dialogPublicKey
|
||||
)
|
||||
)
|
||||
@@ -478,9 +477,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
|
||||
)
|
||||
@@ -511,13 +509,13 @@ class GroupRepository private constructor(context: Context) {
|
||||
callback = { packet ->
|
||||
val typedPacket = packet as? T
|
||||
if (typedPacket != null && predicate(typedPacket)) {
|
||||
ProtocolManager.unwaitPacket(packetId, callback)
|
||||
protocolClient.unwaitPacket(packetId, callback)
|
||||
continuation.resume(typedPacket)
|
||||
}
|
||||
}
|
||||
ProtocolManager.waitPacket(packetId, callback)
|
||||
protocolClient.waitPacket(packetId, callback)
|
||||
continuation.invokeOnCancellation {
|
||||
ProtocolManager.unwaitPacket(packetId, callback)
|
||||
protocolClient.unwaitPacket(packetId, callback)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.rosetta.messenger.data
|
||||
|
||||
import android.content.Context
|
||||
import com.rosetta.messenger.BuildConfig
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.crypto.MessageCrypto
|
||||
import com.rosetta.messenger.database.*
|
||||
@@ -8,7 +9,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
|
||||
@@ -29,7 +34,6 @@ data class Message(
|
||||
val replyToMessageId: String? = null
|
||||
)
|
||||
|
||||
/** UI модель диалога */
|
||||
data class Dialog(
|
||||
val opponentKey: String,
|
||||
val opponentTitle: String,
|
||||
@@ -43,7 +47,11 @@ 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 protocolClient: ProtocolClient
|
||||
) {
|
||||
|
||||
private val database = RosettaDatabase.getDatabase(context)
|
||||
private val messageDao = database.messageDao()
|
||||
@@ -51,6 +59,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
private val avatarDao = database.avatarDao()
|
||||
private val syncTimeDao = database.syncTimeDao()
|
||||
private val groupDao = database.groupDao()
|
||||
private val searchIndexDao = database.messageSearchIndexDao()
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
@@ -95,8 +104,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
|
||||
|
||||
@@ -134,16 +141,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 (при спаме сообщениями)
|
||||
@@ -207,12 +204,32 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
messageId = messageId,
|
||||
plainMessage = encryptedPlainMessage,
|
||||
attachments = "[]",
|
||||
primaryAttachmentType = -1,
|
||||
dialogKey = dialogKey
|
||||
)
|
||||
)
|
||||
|
||||
if (inserted == -1L) return
|
||||
|
||||
val insertedMessage =
|
||||
MessageEntity(
|
||||
account = account,
|
||||
fromPublicKey = SYSTEM_SAFE_PUBLIC_KEY,
|
||||
toPublicKey = account,
|
||||
content = "",
|
||||
timestamp = timestamp,
|
||||
chachaKey = "",
|
||||
read = 0,
|
||||
fromMe = 0,
|
||||
delivered = DeliveryStatus.DELIVERED.value,
|
||||
messageId = messageId,
|
||||
plainMessage = encryptedPlainMessage,
|
||||
attachments = "[]",
|
||||
primaryAttachmentType = -1,
|
||||
dialogKey = dialogKey
|
||||
)
|
||||
upsertSearchIndex(account, insertedMessage, messageText)
|
||||
|
||||
val existing = dialogDao.getDialog(account, SYSTEM_SAFE_PUBLIC_KEY)
|
||||
dialogDao.insertDialog(
|
||||
DialogEntity(
|
||||
@@ -223,6 +240,13 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
opponentUsername =
|
||||
existing?.opponentUsername?.ifBlank { SYSTEM_SAFE_USERNAME }
|
||||
?: SYSTEM_SAFE_USERNAME,
|
||||
lastMessage = encryptedPlainMessage,
|
||||
lastMessageTimestamp = maxOf(existing?.lastMessageTimestamp ?: 0L, timestamp),
|
||||
hasContent = 1,
|
||||
lastMessageFromMe = 0,
|
||||
lastMessageDelivered = DeliveryStatus.DELIVERED.value,
|
||||
lastMessageRead = 0,
|
||||
lastMessageAttachments = "[]",
|
||||
isOnline = existing?.isOnline ?: 0,
|
||||
lastSeen = existing?.lastSeen ?: 0,
|
||||
verified = maxOf(existing?.verified ?: 0, 1),
|
||||
@@ -243,7 +267,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
try {
|
||||
CryptoManager.encryptWithPassword(messageText, privateKey)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ReleaseNotes", "❌ encryptWithPassword failed", e)
|
||||
if (BuildConfig.DEBUG) android.util.Log.e("ReleaseNotes", "❌ encryptWithPassword failed", e)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -266,12 +290,32 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
messageId = messageId,
|
||||
plainMessage = encryptedPlainMessage,
|
||||
attachments = "[]",
|
||||
primaryAttachmentType = -1,
|
||||
dialogKey = dialogKey
|
||||
)
|
||||
)
|
||||
|
||||
if (inserted == -1L) return null
|
||||
|
||||
val insertedMessage =
|
||||
MessageEntity(
|
||||
account = account,
|
||||
fromPublicKey = SYSTEM_UPDATES_PUBLIC_KEY,
|
||||
toPublicKey = account,
|
||||
content = "",
|
||||
timestamp = timestamp,
|
||||
chachaKey = "",
|
||||
read = 0,
|
||||
fromMe = 0,
|
||||
delivered = DeliveryStatus.DELIVERED.value,
|
||||
messageId = messageId,
|
||||
plainMessage = encryptedPlainMessage,
|
||||
attachments = "[]",
|
||||
primaryAttachmentType = -1,
|
||||
dialogKey = dialogKey
|
||||
)
|
||||
upsertSearchIndex(account, insertedMessage, messageText)
|
||||
|
||||
val existing = dialogDao.getDialog(account, SYSTEM_UPDATES_PUBLIC_KEY)
|
||||
dialogDao.insertDialog(
|
||||
DialogEntity(
|
||||
@@ -282,6 +326,13 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
opponentUsername =
|
||||
existing?.opponentUsername?.ifBlank { SYSTEM_UPDATES_USERNAME }
|
||||
?: SYSTEM_UPDATES_USERNAME,
|
||||
lastMessage = encryptedPlainMessage,
|
||||
lastMessageTimestamp = maxOf(existing?.lastMessageTimestamp ?: 0L, timestamp),
|
||||
hasContent = 1,
|
||||
lastMessageFromMe = 0,
|
||||
lastMessageDelivered = DeliveryStatus.DELIVERED.value,
|
||||
lastMessageRead = 0,
|
||||
lastMessageAttachments = "[]",
|
||||
isOnline = existing?.isOnline ?: 0,
|
||||
lastSeen = existing?.lastSeen ?: 0,
|
||||
verified = maxOf(existing?.verified ?: 0, 1),
|
||||
@@ -301,12 +352,12 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
suspend fun checkAndSendVersionUpdateMessage() {
|
||||
val account = currentAccount
|
||||
if (account == null) {
|
||||
android.util.Log.w("ReleaseNotes", "❌ currentAccount is null, skipping update message")
|
||||
if (BuildConfig.DEBUG) android.util.Log.w("ReleaseNotes", "❌ currentAccount is null, skipping update message")
|
||||
return
|
||||
}
|
||||
val privateKey = currentPrivateKey
|
||||
if (privateKey == null) {
|
||||
android.util.Log.w("ReleaseNotes", "❌ currentPrivateKey is null, skipping update message")
|
||||
if (BuildConfig.DEBUG) android.util.Log.w("ReleaseNotes", "❌ currentPrivateKey is null, skipping update message")
|
||||
return
|
||||
}
|
||||
val prefs = context.getSharedPreferences("rosetta_system_${account}", Context.MODE_PRIVATE)
|
||||
@@ -314,7 +365,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
val currentVersion = com.rosetta.messenger.BuildConfig.VERSION_NAME
|
||||
val currentKey = "${currentVersion}_${ReleaseNotes.noticeHash}"
|
||||
|
||||
android.util.Log.d("ReleaseNotes", "checkUpdate: version=$currentVersion, lastKey=$lastNoticeKey, currentKey=$currentKey, match=${lastNoticeKey == currentKey}")
|
||||
if (BuildConfig.DEBUG) android.util.Log.d("ReleaseNotes", "checkUpdate: version=$currentVersion, lastKey=$lastNoticeKey, currentKey=$currentKey, match=${lastNoticeKey == currentKey}")
|
||||
|
||||
if (lastNoticeKey != currentKey) {
|
||||
// Delete the previous message for this version (if any)
|
||||
@@ -325,15 +376,15 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
val messageId = addUpdateSystemMessage(ReleaseNotes.getNotice(currentVersion))
|
||||
android.util.Log.d("ReleaseNotes", "addUpdateSystemMessage result: messageId=$messageId")
|
||||
if (BuildConfig.DEBUG) android.util.Log.d("ReleaseNotes", "addUpdateSystemMessage result: messageId=$messageId")
|
||||
if (messageId != null) {
|
||||
prefs.edit()
|
||||
.putString("lastNoticeKey", currentKey)
|
||||
.putString("lastNoticeMessageId_$currentVersion", messageId)
|
||||
.apply()
|
||||
android.util.Log.d("ReleaseNotes", "✅ Update message saved successfully")
|
||||
if (BuildConfig.DEBUG) android.util.Log.d("ReleaseNotes", "✅ Update message saved successfully")
|
||||
} else {
|
||||
android.util.Log.e("ReleaseNotes", "❌ Failed to create update message")
|
||||
if (BuildConfig.DEBUG) android.util.Log.e("ReleaseNotes", "❌ Failed to create update message")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -406,6 +457,18 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
return if (raw < 1_000_000_000_000L) raw * 1000L else raw
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize incoming message timestamp for chat ordering:
|
||||
* 1) accept both seconds and milliseconds;
|
||||
* 2) never allow a message timestamp from the future on this device.
|
||||
*/
|
||||
private fun normalizeIncomingPacketTimestamp(rawTimestamp: Long, receivedAtMs: Long): Long {
|
||||
val normalizedRaw =
|
||||
if (rawTimestamp in 1..999_999_999_999L) rawTimestamp * 1000L else rawTimestamp
|
||||
if (normalizedRaw <= 0L) return receivedAtMs
|
||||
return minOf(normalizedRaw, receivedAtMs)
|
||||
}
|
||||
|
||||
/** Получить поток сообщений для диалога */
|
||||
fun getMessagesFlow(opponentKey: String): StateFlow<List<Message>> {
|
||||
val dialogKey = getDialogKey(opponentKey)
|
||||
@@ -477,15 +540,18 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
scope.launch {
|
||||
val startTime = System.currentTimeMillis()
|
||||
try {
|
||||
// Шифрование
|
||||
val encryptResult = MessageCrypto.encryptForSending(text.trim(), toPublicKey)
|
||||
val encryptedContent = encryptResult.ciphertext
|
||||
val encryptedKey = encryptResult.encryptedKey
|
||||
// Шифрование (пропускаем для пустого текста — напр. CALL-сообщения)
|
||||
val hasContent = text.trim().isNotEmpty()
|
||||
val encryptResult = if (hasContent) MessageCrypto.encryptForSending(text.trim(), toPublicKey) else null
|
||||
val encryptedContent = encryptResult?.ciphertext ?: ""
|
||||
val encryptedKey = encryptResult?.encryptedKey ?: ""
|
||||
val aesChachaKey =
|
||||
CryptoManager.encryptWithPassword(
|
||||
String(encryptResult.plainKeyAndNonce, Charsets.ISO_8859_1),
|
||||
privateKey
|
||||
)
|
||||
if (encryptResult != null) {
|
||||
CryptoManager.encryptWithPassword(
|
||||
String(encryptResult.plainKeyAndNonce, Charsets.ISO_8859_1),
|
||||
privateKey
|
||||
)
|
||||
} else ""
|
||||
|
||||
// 📝 LOG: Шифрование успешно
|
||||
MessageLogger.logEncryptionSuccess(
|
||||
@@ -525,10 +591,13 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
messageId = messageId,
|
||||
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
|
||||
attachments = attachmentsJson,
|
||||
primaryAttachmentType =
|
||||
resolvePrimaryAttachmentType(attachments),
|
||||
replyToMessageId = replyToMessageId,
|
||||
dialogKey = dialogKey
|
||||
)
|
||||
messageDao.insertMessage(entity)
|
||||
upsertSearchIndex(account, entity, text.trim())
|
||||
|
||||
// 📝 LOG: Сохранено в БД
|
||||
MessageLogger.logDbSave(messageId, dialogKey, isNew = true)
|
||||
@@ -539,6 +608,12 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
// 🔥 КРИТИЧНО: Обновляем диалог через updateDialogFromMessages
|
||||
dialogDao.updateDialogFromMessages(account, toPublicKey)
|
||||
|
||||
// Notify listeners (ChatViewModel) that a new message was persisted
|
||||
// so the chat UI reloads from DB. Without this, messages produced by
|
||||
// non-input flows (e.g. CallManager's missed-call attachment) only
|
||||
// appear after the user re-enters the chat.
|
||||
_newMessageEvents.tryEmit(dialogKey)
|
||||
|
||||
// 📁 Для saved messages - гарантируем создание/обновление dialog
|
||||
if (isSavedMessages) {
|
||||
val existing = dialogDao.getDialog(account, account)
|
||||
@@ -556,6 +631,17 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
lastSeen = existing?.lastSeen ?: 0,
|
||||
verified = existing?.verified ?: 0,
|
||||
iHaveSent = 1,
|
||||
hasContent =
|
||||
if (
|
||||
encryptedPlainMessage.isNotBlank() ||
|
||||
attachments.isNotEmpty()
|
||||
) {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
},
|
||||
lastMessageAttachmentType = resolvePrimaryAttachmentType(attachments),
|
||||
lastSenderKey = account,
|
||||
lastMessageFromMe = 1,
|
||||
lastMessageDelivered = 1,
|
||||
lastMessageRead = 1,
|
||||
@@ -603,7 +689,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
MessageLogger.logPacketSend(messageId, toPublicKey, timestamp)
|
||||
|
||||
// iOS parity: send with automatic retry (4s interval, 3 attempts, 80s timeout)
|
||||
ProtocolManager.sendMessageWithRetry(packet)
|
||||
protocolClient.sendMessageWithRetry(packet)
|
||||
|
||||
// 📝 LOG: Успешная отправка
|
||||
MessageLogger.logSendSuccess(messageId, System.currentTimeMillis() - startTime)
|
||||
@@ -652,6 +738,13 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
|
||||
val isOwnMessage = packet.fromPublicKey == account
|
||||
val isGroupMessage = isGroupDialogKey(packet.toPublicKey)
|
||||
val normalizedPacketTimestamp =
|
||||
normalizeIncomingPacketTimestamp(packet.timestamp, startTime)
|
||||
if (normalizedPacketTimestamp != packet.timestamp) {
|
||||
MessageLogger.debug(
|
||||
"📥 TIMESTAMP normalized: raw=${packet.timestamp} -> local=$normalizedPacketTimestamp"
|
||||
)
|
||||
}
|
||||
|
||||
// 🔥 Проверяем, не заблокирован ли отправитель
|
||||
if (!isOwnMessage && !isGroupDialogKey(packet.fromPublicKey)) {
|
||||
@@ -686,13 +779,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 +787,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) {
|
||||
@@ -716,11 +829,19 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
if (isGroupMessage && groupKey.isNullOrBlank()) {
|
||||
MessageLogger.debug(
|
||||
"📥 GROUP DROP: key not found for ${packet.toPublicKey.take(20)}..."
|
||||
val requiresGroupKey =
|
||||
(packet.content.isNotBlank() && isProbablyEncryptedPayload(packet.content)) ||
|
||||
packet.attachments.any { it.blob.isNotBlank() }
|
||||
if (requiresGroupKey) {
|
||||
MessageLogger.debug(
|
||||
"📥 GROUP DROP: key not found for ${packet.toPublicKey.take(20)}..."
|
||||
)
|
||||
processedMessageIds.remove(messageId)
|
||||
return false
|
||||
}
|
||||
protocolClient.addLog(
|
||||
"⚠️ GROUP fallback without key: ${messageId.take(8)}..., contentLikelyPlain=true"
|
||||
)
|
||||
processedMessageIds.remove(messageId)
|
||||
return false
|
||||
}
|
||||
|
||||
val plainKeyAndNonce =
|
||||
@@ -732,7 +853,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
if (!isGroupMessage && isOwnMessage && packet.aesChachaKey.isNotBlank() && plainKeyAndNonce == null) {
|
||||
ProtocolManager.addLog(
|
||||
protocolClient.addLog(
|
||||
"⚠️ OWN SYNC: failed to decrypt aesChachaKey for ${messageId.take(8)}..."
|
||||
)
|
||||
}
|
||||
@@ -743,28 +864,43 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
)
|
||||
}
|
||||
|
||||
// Расшифровываем
|
||||
// Расшифровываем (CALL и attachment-only сообщения могут иметь пустой или
|
||||
// зашифрованный пустой content — обрабатываем оба случая безопасно)
|
||||
val isAttachmentOnly = packet.content.isBlank() ||
|
||||
(packet.attachments.isNotEmpty() && packet.chachaKey.isBlank())
|
||||
val plainText =
|
||||
if (isGroupMessage) {
|
||||
CryptoManager.decryptWithPassword(packet.content, groupKey!!)
|
||||
?: throw IllegalStateException("Failed to decrypt group payload")
|
||||
if (isAttachmentOnly) {
|
||||
""
|
||||
} else if (isGroupMessage) {
|
||||
val decryptedGroupPayload =
|
||||
groupKey?.let { decryptWithGroupKeyCompat(packet.content, it) }
|
||||
decryptedGroupPayload ?: safePlainMessageFallback(packet.content)
|
||||
} else if (plainKeyAndNonce != null) {
|
||||
MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce)
|
||||
} else {
|
||||
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
|
||||
try {
|
||||
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
|
||||
} catch (e: Exception) {
|
||||
// Fallback: если дешифровка не удалась (напр. CALL с encrypted empty content)
|
||||
if (BuildConfig.DEBUG) android.util.Log.w("MessageRepository", "Decryption fallback for ${messageId.take(8)}: ${e.message}")
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
val normalizedIncomingAttachments =
|
||||
normalizeIncomingAttachments(packet.attachments, plainText)
|
||||
|
||||
// 📝 LOG: Расшифровка успешна
|
||||
MessageLogger.logDecryptionSuccess(
|
||||
messageId = messageId,
|
||||
plainTextLength = plainText.length,
|
||||
attachmentsCount = packet.attachments.size
|
||||
attachmentsCount = normalizedIncomingAttachments.size
|
||||
)
|
||||
|
||||
// Сериализуем attachments в JSON с расшифровкой MESSAGES blob
|
||||
val attachmentsJson =
|
||||
serializeAttachmentsWithDecryption(
|
||||
packet.attachments,
|
||||
normalizedIncomingAttachments,
|
||||
packet.chachaKey,
|
||||
privateKey,
|
||||
plainKeyAndNonce,
|
||||
@@ -773,7 +909,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
|
||||
// 🖼️ Обрабатываем IMAGE attachments - сохраняем в файл (как в desktop)
|
||||
processImageAttachments(
|
||||
packet.attachments,
|
||||
normalizedIncomingAttachments,
|
||||
packet.chachaKey,
|
||||
privateKey,
|
||||
plainKeyAndNonce,
|
||||
@@ -786,7 +922,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
val avatarOwnerKey =
|
||||
if (isGroupMessage) toGroupDialogPublicKey(packet.toPublicKey) else packet.fromPublicKey
|
||||
processAvatarAttachments(
|
||||
packet.attachments,
|
||||
normalizedIncomingAttachments,
|
||||
avatarOwnerKey,
|
||||
packet.chachaKey,
|
||||
privateKey,
|
||||
@@ -806,6 +942,11 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
packet.chachaKey
|
||||
}
|
||||
|
||||
// Desktop parity (useSynchronize.ts):
|
||||
// own messages received via PacketMessage sync are inserted as DELIVERED immediately.
|
||||
// WAITING is used only for messages created locally on this device before PacketDelivery.
|
||||
val initialDeliveredStatus = DeliveryStatus.DELIVERED.value
|
||||
|
||||
// Создаем entity для кэша и возможной вставки
|
||||
val entity =
|
||||
MessageEntity(
|
||||
@@ -813,14 +954,16 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
fromPublicKey = packet.fromPublicKey,
|
||||
toPublicKey = packet.toPublicKey,
|
||||
content = packet.content,
|
||||
timestamp = packet.timestamp,
|
||||
timestamp = normalizedPacketTimestamp,
|
||||
chachaKey = storedChachaKey,
|
||||
read = 0,
|
||||
fromMe = if (isOwnMessage) 1 else 0,
|
||||
delivered = DeliveryStatus.DELIVERED.value,
|
||||
delivered = initialDeliveredStatus,
|
||||
messageId = messageId, // 🔥 Используем сгенерированный messageId!
|
||||
plainMessage = encryptedPlainMessage, // 🔒 Зашифрованный текст
|
||||
attachments = attachmentsJson,
|
||||
primaryAttachmentType =
|
||||
resolvePrimaryAttachmentType(normalizedIncomingAttachments),
|
||||
dialogKey = dialogKey
|
||||
)
|
||||
|
||||
@@ -830,6 +973,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
if (!stillExists) {
|
||||
// Сохраняем в БД только если сообщения нет
|
||||
messageDao.insertMessage(entity)
|
||||
upsertSearchIndex(account, entity, plainText)
|
||||
MessageLogger.logDbSave(messageId, dialogKey, isNew = true)
|
||||
} else {
|
||||
MessageLogger.logDbSave(messageId, dialogKey, isNew = false)
|
||||
@@ -846,11 +990,10 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
unreadCount = dialog?.unreadCount ?: 0
|
||||
)
|
||||
|
||||
// 🔥 Запрашиваем информацию о пользователе для отображения имени вместо ключа
|
||||
// Desktop parity: always re-fetch on incoming message so renamed contacts
|
||||
// get their new name/username updated in the chat list.
|
||||
// 🔥 Запрашиваем информацию о пользователе для отображения имени вместо ключа.
|
||||
// Важно: не форсим повторный запрос на каждый входящий пакет — это создает
|
||||
// шторм PacketSearch во время sync и заметно тормозит обработку.
|
||||
if (!isGroupDialogKey(dialogOpponentKey)) {
|
||||
requestedUserInfoKeys.remove(dialogOpponentKey)
|
||||
requestUserInfo(dialogOpponentKey)
|
||||
} else {
|
||||
applyGroupDisplayNameToDialog(account, dialogOpponentKey)
|
||||
@@ -879,8 +1022,10 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
} catch (e: Exception) {
|
||||
// 📝 LOG: Ошибка обработки
|
||||
MessageLogger.logDecryptionError(messageId, e)
|
||||
ProtocolManager.addLog(
|
||||
"❌ Incoming message dropped: ${messageId.take(8)}..., own=$isOwnMessage, reason=${e.javaClass.simpleName}"
|
||||
protocolClient.addLog(
|
||||
"❌ Incoming message dropped: ${messageId.take(8)}..., own=$isOwnMessage, " +
|
||||
"group=$isGroupMessage, chachaLen=${packet.chachaKey.length}, " +
|
||||
"aesLen=${packet.aesChachaKey.length}, reason=${e.javaClass.simpleName}:${e.message ?: "<no-message>"}"
|
||||
)
|
||||
// Разрешаем повторную обработку через re-sync, если пакет не удалось сохранить.
|
||||
processedMessageIds.remove(messageId)
|
||||
@@ -893,15 +1038,12 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
suspend fun handleDelivery(packet: PacketDelivery) {
|
||||
val account = currentAccount ?: return
|
||||
|
||||
// 📝 LOG: Получено подтверждение доставки
|
||||
MessageLogger.logDeliveryStatus(
|
||||
messageId = packet.messageId,
|
||||
toPublicKey = packet.toPublicKey,
|
||||
status = "DELIVERED"
|
||||
)
|
||||
|
||||
// Desktop parity: update both delivery status AND timestamp on delivery confirmation.
|
||||
// Desktop sets timestamp = Date.now() when PacketDelivery arrives (useSynchronize.ts).
|
||||
val deliveryTimestamp = System.currentTimeMillis()
|
||||
messageDao.updateDeliveryStatusAndTimestamp(
|
||||
account, packet.messageId, DeliveryStatus.DELIVERED.value, deliveryTimestamp
|
||||
@@ -926,6 +1068,50 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
dialogDao.updateDialogFromMessages(account, packet.toPublicKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an incoming call event locally (for CALLEE side).
|
||||
* Creates a message as if received from the peer, with CALL attachment.
|
||||
*/
|
||||
suspend fun saveIncomingCallEvent(fromPublicKey: String, durationSec: Int) {
|
||||
val account = currentAccount ?: return
|
||||
val privateKey = currentPrivateKey ?: return
|
||||
val messageId = java.util.UUID.randomUUID().toString().replace("-", "").take(32)
|
||||
val timestamp = System.currentTimeMillis()
|
||||
val dialogKey = getDialogKey(fromPublicKey)
|
||||
|
||||
val attId = java.util.UUID.randomUUID().toString().replace("-", "").take(16)
|
||||
val attachmentsJson = org.json.JSONArray().apply {
|
||||
put(org.json.JSONObject().apply {
|
||||
put("id", attId)
|
||||
put("type", com.rosetta.messenger.network.AttachmentType.CALL.value)
|
||||
put("preview", durationSec.toString())
|
||||
put("blob", "")
|
||||
})
|
||||
}.toString()
|
||||
|
||||
val encryptedPlainMessage = com.rosetta.messenger.crypto.CryptoManager.encryptWithPassword("", privateKey)
|
||||
|
||||
val entity = com.rosetta.messenger.database.MessageEntity(
|
||||
account = account,
|
||||
fromPublicKey = fromPublicKey,
|
||||
toPublicKey = account,
|
||||
content = "",
|
||||
timestamp = timestamp,
|
||||
chachaKey = "",
|
||||
read = 1,
|
||||
fromMe = 0,
|
||||
delivered = com.rosetta.messenger.network.DeliveryStatus.DELIVERED.value,
|
||||
messageId = messageId,
|
||||
plainMessage = encryptedPlainMessage,
|
||||
attachments = attachmentsJson,
|
||||
primaryAttachmentType = com.rosetta.messenger.network.AttachmentType.CALL.value,
|
||||
dialogKey = dialogKey
|
||||
)
|
||||
messageDao.insertMessage(entity)
|
||||
dialogDao.updateDialogFromMessages(account, fromPublicKey)
|
||||
_newMessageEvents.tryEmit(dialogKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Обработка прочтения В Desktop PacketRead сообщает что собеседник прочитал наши сообщения
|
||||
* fromPublicKey - кто прочитал (собеседник)
|
||||
@@ -946,18 +1132,38 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
// Desktop parity (group): from=groupMember, to=groupId -> mark own group messages as read.
|
||||
if (!isOwnReadSync && isGroupDialogKey(toPublicKey)) {
|
||||
val dialogKey = getDialogKey(toPublicKey)
|
||||
messageDao.markAllAsRead(account, toPublicKey)
|
||||
val updatedRows = messageDao.markAllAsRead(account, toPublicKey)
|
||||
|
||||
val readCount = messageCache[dialogKey]?.value?.count { it.isFromMe && !it.isRead } ?: 0
|
||||
val readCount =
|
||||
messageCache[dialogKey]?.value?.count {
|
||||
it.isFromMe && !it.isRead
|
||||
} ?: 0
|
||||
messageCache[dialogKey]?.let { flow ->
|
||||
flow.value =
|
||||
flow.value.map { msg ->
|
||||
if (msg.isFromMe && !msg.isRead) msg.copy(isRead = true) else msg
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ))
|
||||
MessageLogger.logReadStatus(fromPublicKey = toPublicKey, messagesCount = readCount)
|
||||
if (updatedRows > 0) {
|
||||
_deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ))
|
||||
}
|
||||
MessageLogger.logReadStatus(fromPublicKey = toPublicKey, messagesCount = minOf(readCount, updatedRows))
|
||||
dialogDao.updateDialogFromMessages(account, toPublicKey)
|
||||
return
|
||||
}
|
||||
@@ -981,20 +1187,40 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
// Opponent read our outgoing messages.
|
||||
messageDao.markAllAsRead(account, opponentKey)
|
||||
val updatedRows = messageDao.markAllAsRead(account, opponentKey)
|
||||
|
||||
val readCount = messageCache[dialogKey]?.value?.count { it.isFromMe && !it.isRead } ?: 0
|
||||
val readCount =
|
||||
messageCache[dialogKey]?.value?.count {
|
||||
it.isFromMe && !it.isRead
|
||||
} ?: 0
|
||||
messageCache[dialogKey]?.let { flow ->
|
||||
flow.value =
|
||||
flow.value.map { msg ->
|
||||
if (msg.isFromMe && !msg.isRead) msg.copy(isRead = true) else msg
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Notify current dialog UI: all outgoing messages are now read.
|
||||
_deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ))
|
||||
// Notify current dialog UI only when there are real DB read updates.
|
||||
if (updatedRows > 0) {
|
||||
_deliveryStatusEvents.tryEmit(DeliveryStatusUpdate(dialogKey, "", DeliveryStatus.READ))
|
||||
}
|
||||
|
||||
MessageLogger.logReadStatus(fromPublicKey = opponentKey, messagesCount = readCount)
|
||||
MessageLogger.logReadStatus(fromPublicKey = opponentKey, messagesCount = minOf(readCount, updatedRows))
|
||||
dialogDao.updateDialogFromMessages(account, opponentKey)
|
||||
}
|
||||
|
||||
@@ -1036,7 +1262,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
this.toPublicKey = toPublicKey
|
||||
this.privateKey = CryptoManager.generatePrivateKeyHash(privateKey)
|
||||
}
|
||||
ProtocolManager.send(packet)
|
||||
protocolClient.send(packet)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1090,17 +1316,34 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
val privateKey = currentPrivateKey ?: return
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
// Desktop parity recovery:
|
||||
// historically, own synced direct messages ("sync:*" chacha_key) could be saved as WAITING/ERROR
|
||||
// on Android and then incorrectly shown with failed status.
|
||||
// Desktop stores them as DELIVERED from the beginning.
|
||||
val syncedOpponentsWithWrongStatus =
|
||||
messageDao.getSyncedOwnMessageOpponentsWithNonDeliveredStatus(account)
|
||||
val normalizedSyncedCount = messageDao.markSyncedOwnMessagesAsDelivered(account)
|
||||
if (normalizedSyncedCount > 0) {
|
||||
syncedOpponentsWithWrongStatus.forEach { opponentKey ->
|
||||
runCatching { dialogDao.updateDialogFromMessages(account, opponentKey) }
|
||||
}
|
||||
if (BuildConfig.DEBUG) android.util.Log.i(
|
||||
"MessageRepository",
|
||||
"✅ Normalized $normalizedSyncedCount synced own messages to DELIVERED"
|
||||
)
|
||||
}
|
||||
|
||||
// Mark expired messages as ERROR (older than 80 seconds)
|
||||
val expiredCount = messageDao.markExpiredWaitingAsError(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS)
|
||||
if (expiredCount > 0) {
|
||||
android.util.Log.w("MessageRepository", "⚠️ Marked $expiredCount expired WAITING messages as ERROR")
|
||||
if (BuildConfig.DEBUG) android.util.Log.w("MessageRepository", "⚠️ Marked $expiredCount expired WAITING messages as ERROR")
|
||||
}
|
||||
|
||||
// Get remaining WAITING messages (younger than 80s)
|
||||
val waitingMessages = messageDao.getWaitingMessages(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS)
|
||||
if (waitingMessages.isEmpty()) return
|
||||
|
||||
android.util.Log.i("MessageRepository", "🔄 Retrying ${waitingMessages.size} WAITING messages")
|
||||
if (BuildConfig.DEBUG) android.util.Log.i("MessageRepository", "🔄 Retrying ${waitingMessages.size} WAITING messages")
|
||||
|
||||
for (entity in waitingMessages) {
|
||||
// Skip saved messages (should not happen, but guard)
|
||||
@@ -1124,7 +1367,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
privateKey
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.w("MessageRepository", "⚠️ Cannot regenerate aesChachaKey for ${entity.messageId.take(8)}, sending without it")
|
||||
if (BuildConfig.DEBUG) android.util.Log.w("MessageRepository", "⚠️ Cannot regenerate aesChachaKey for ${entity.messageId.take(8)}, sending without it")
|
||||
""
|
||||
}
|
||||
}
|
||||
@@ -1150,10 +1393,10 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
// iOS parity: use retry mechanism for reconnect-resent messages too
|
||||
ProtocolManager.sendMessageWithRetry(packet)
|
||||
android.util.Log.d("MessageRepository", "🔄 Resent WAITING message: ${entity.messageId.take(8)}")
|
||||
protocolClient.sendMessageWithRetry(packet)
|
||||
if (BuildConfig.DEBUG) android.util.Log.d("MessageRepository", "🔄 Resent WAITING message: ${entity.messageId.take(8)}")
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("MessageRepository", "❌ Failed to retry message ${entity.messageId.take(8)}: ${e.message}")
|
||||
if (BuildConfig.DEBUG) android.util.Log.e("MessageRepository", "❌ Failed to retry message ${entity.messageId.take(8)}: ${e.message}")
|
||||
// Mark as ERROR if retry fails
|
||||
messageDao.updateDeliveryStatus(account, entity.messageId, DeliveryStatus.ERROR.value)
|
||||
val dialogKey = getDialogKey(entity.toPublicKey)
|
||||
@@ -1254,7 +1497,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Public API for ProtocolManager to update delivery status (e.g., marking as ERROR on retry timeout).
|
||||
* Runtime API to update delivery status (e.g., marking as ERROR on retry timeout).
|
||||
*/
|
||||
suspend fun updateMessageDeliveryStatus(dialogKey: String, messageId: String, status: DeliveryStatus) {
|
||||
val account = currentAccount ?: return
|
||||
@@ -1310,7 +1553,8 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
opponentKey = opponentKey,
|
||||
lastMessage = encryptedLastMessage,
|
||||
lastMessageTimestamp = timestamp,
|
||||
unreadCount = unreadCount
|
||||
unreadCount = unreadCount,
|
||||
hasContent = if (encryptedLastMessage.isNotBlank()) 1 else 0
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -1414,7 +1658,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
this.privateKey = privateKeyHash
|
||||
this.search = dialog.opponentKey
|
||||
}
|
||||
ProtocolManager.send(packet)
|
||||
protocolClient.send(packet)
|
||||
// Small delay to avoid flooding the server with search requests
|
||||
kotlinx.coroutines.delay(50)
|
||||
}
|
||||
@@ -1451,7 +1695,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
this.privateKey = privateKeyHash
|
||||
this.search = publicKey
|
||||
}
|
||||
ProtocolManager.send(packet)
|
||||
protocolClient.send(packet)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1537,12 +1781,84 @@ 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)
|
||||
}
|
||||
jsonArray.put(jsonObj)
|
||||
}
|
||||
return jsonArray.toString()
|
||||
}
|
||||
|
||||
private fun resolvePrimaryAttachmentType(attachments: List<MessageAttachment>): Int {
|
||||
if (attachments.isEmpty()) return -1
|
||||
return attachments.first().type.value
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop иногда присылает attachment звонка с некорректным type при поврежденном/пограничном
|
||||
* пакете (в UI это превращается в пустой пузырь). Для attachment-only сообщения мягко
|
||||
* нормализуем такой кейс к CALL.
|
||||
*/
|
||||
private fun normalizeIncomingAttachments(
|
||||
attachments: List<MessageAttachment>,
|
||||
plainText: String
|
||||
): List<MessageAttachment> {
|
||||
if (attachments.isEmpty() || plainText.isNotBlank() || attachments.size != 1) {
|
||||
return attachments
|
||||
}
|
||||
|
||||
val first = attachments.first()
|
||||
if (!isLikelyCallAttachment(first, plainText)) {
|
||||
return attachments
|
||||
}
|
||||
|
||||
return when (first.type) {
|
||||
AttachmentType.CALL -> attachments
|
||||
else -> {
|
||||
MessageLogger.debug(
|
||||
"📥 ATTACHMENT FIXUP: coerced ${first.type} -> CALL for ${first.id.take(8)}..."
|
||||
)
|
||||
listOf(first.copy(type = AttachmentType.CALL))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isLikelyCallAttachment(attachment: MessageAttachment, plainText: String): Boolean {
|
||||
if (plainText.isNotBlank()) return false
|
||||
if (attachment.blob.isNotBlank()) return false
|
||||
if (attachment.width > 0 || attachment.height > 0) return false
|
||||
|
||||
val preview = attachment.preview.trim()
|
||||
if (preview.isEmpty()) return true
|
||||
|
||||
val tail = preview.substringAfterLast("::", preview).trim()
|
||||
if (tail.toIntOrNull() != null) return true
|
||||
|
||||
return Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*\\d+", RegexOption.IGNORE_CASE)
|
||||
.containsMatchIn(preview)
|
||||
}
|
||||
|
||||
private suspend fun upsertSearchIndex(account: String, entity: MessageEntity, plainText: String) {
|
||||
val opponentKey =
|
||||
if (entity.fromMe == 1) entity.toPublicKey.trim() else entity.fromPublicKey.trim()
|
||||
val normalized = plainText.lowercase(Locale.ROOT)
|
||||
searchIndexDao.upsert(
|
||||
listOf(
|
||||
MessageSearchIndexEntity(
|
||||
account = account,
|
||||
messageId = entity.messageId,
|
||||
dialogKey = entity.dialogKey,
|
||||
opponentKey = opponentKey,
|
||||
timestamp = entity.timestamp,
|
||||
fromMe = entity.fromMe,
|
||||
plainText = plainText,
|
||||
plainTextNormalized = normalized
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 📸 Обработка AVATAR attachments - сохранение аватара отправителя в кэш Как в desktop: при
|
||||
* получении attachment с типом AVATAR - сохраняем в avatar_cache
|
||||
@@ -1564,7 +1880,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
// 1. Расшифровываем blob с ChaCha ключом сообщения
|
||||
val decryptedBlob =
|
||||
if (groupKey != null) {
|
||||
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
|
||||
decryptWithGroupKeyCompat(attachment.blob, groupKey)
|
||||
} else {
|
||||
plainKeyAndNonce?.let {
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
||||
@@ -1621,7 +1937,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
// 1. Расшифровываем blob с ChaCha ключом сообщения
|
||||
val decryptedBlob =
|
||||
if (groupKey != null) {
|
||||
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
|
||||
decryptWithGroupKeyCompat(attachment.blob, groupKey)
|
||||
} else {
|
||||
plainKeyAndNonce?.let {
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
||||
@@ -1685,7 +2001,7 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
// 1. Расшифровываем с ChaCha ключом сообщения
|
||||
val decryptedBlob =
|
||||
if (groupKey != null) {
|
||||
CryptoManager.decryptWithPassword(attachment.blob, groupKey)
|
||||
decryptWithGroupKeyCompat(attachment.blob, groupKey)
|
||||
} else {
|
||||
plainKeyAndNonce?.let {
|
||||
MessageCrypto.decryptAttachmentBlobWithPlainKey(attachment.blob, it)
|
||||
@@ -1710,6 +2026,9 @@ 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 {
|
||||
// Fallback - пустой blob для IMAGE/FILE
|
||||
jsonObj.put("id", attachment.id)
|
||||
@@ -1718,6 +2037,9 @@ 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)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Fallback - пустой blob
|
||||
@@ -1727,6 +2049,9 @@ 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 {
|
||||
// Для IMAGE/FILE - НЕ сохраняем blob (пустой)
|
||||
@@ -1736,10 +2061,35 @@ 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)
|
||||
}
|
||||
|
||||
jsonArray.put(jsonObj)
|
||||
}
|
||||
return jsonArray.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop parity for group attachment blobs:
|
||||
* old payloads may be encrypted with raw group key, new payloads with hex(groupKey bytes).
|
||||
*/
|
||||
private fun decryptWithGroupKeyCompat(encryptedBlob: String, groupKey: String): String? {
|
||||
if (encryptedBlob.isBlank() || groupKey.isBlank()) return null
|
||||
|
||||
val rawAttempt = runCatching {
|
||||
CryptoManager.decryptWithPassword(encryptedBlob, groupKey)
|
||||
}.getOrNull()
|
||||
if (rawAttempt != null) return rawAttempt
|
||||
|
||||
val hexKey =
|
||||
groupKey.toByteArray(Charsets.ISO_8859_1)
|
||||
.joinToString("") { "%02x".format(it.toInt() and 0xff) }
|
||||
if (hexKey == groupKey) return null
|
||||
|
||||
return runCatching {
|
||||
CryptoManager.decryptWithPassword(encryptedBlob, hexKey)
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,12 +28,15 @@ class PreferencesManager(private val context: Context) {
|
||||
val IS_DARK_THEME = booleanPreferencesKey("is_dark_theme")
|
||||
val THEME_MODE = stringPreferencesKey("theme_mode") // "light", "dark", "auto"
|
||||
val CHAT_WALLPAPER_ID = stringPreferencesKey("chat_wallpaper_id") // empty = no wallpaper
|
||||
val CHAT_WALLPAPER_ID_LIGHT = stringPreferencesKey("chat_wallpaper_id_light")
|
||||
val CHAT_WALLPAPER_ID_DARK = stringPreferencesKey("chat_wallpaper_id_dark")
|
||||
|
||||
// Notifications
|
||||
val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
|
||||
val NOTIFICATION_SOUND_ENABLED = booleanPreferencesKey("notification_sound_enabled")
|
||||
val NOTIFICATION_VIBRATE_ENABLED = booleanPreferencesKey("notification_vibrate_enabled")
|
||||
val NOTIFICATION_PREVIEW_ENABLED = booleanPreferencesKey("notification_preview_enabled")
|
||||
val NOTIFICATION_AVATAR_ENABLED = booleanPreferencesKey("notification_avatar_enabled")
|
||||
|
||||
// Chat Settings
|
||||
val MESSAGE_TEXT_SIZE = intPreferencesKey("message_text_size") // 0=small, 1=medium, 2=large
|
||||
@@ -55,6 +58,9 @@ class PreferencesManager(private val context: Context) {
|
||||
val BACKGROUND_BLUR_COLOR_ID =
|
||||
stringPreferencesKey("background_blur_color_id") // id from BackgroundBlurPresets
|
||||
|
||||
// App Icon disguise: "default", "calculator", "weather", "notes"
|
||||
val APP_ICON = stringPreferencesKey("app_icon")
|
||||
|
||||
// Pinned Chats (max 3)
|
||||
val PINNED_CHATS = stringSetPreferencesKey("pinned_chats") // Set of opponent public keys
|
||||
|
||||
@@ -104,10 +110,21 @@ class PreferencesManager(private val context: Context) {
|
||||
val chatWallpaperId: Flow<String> =
|
||||
context.dataStore.data.map { preferences -> preferences[CHAT_WALLPAPER_ID] ?: "" }
|
||||
|
||||
val chatWallpaperIdLight: Flow<String> =
|
||||
context.dataStore.data.map { preferences -> preferences[CHAT_WALLPAPER_ID_LIGHT] ?: "" }
|
||||
|
||||
val chatWallpaperIdDark: Flow<String> =
|
||||
context.dataStore.data.map { preferences -> preferences[CHAT_WALLPAPER_ID_DARK] ?: "" }
|
||||
|
||||
suspend fun setChatWallpaperId(value: String) {
|
||||
context.dataStore.edit { preferences -> preferences[CHAT_WALLPAPER_ID] = value }
|
||||
}
|
||||
|
||||
suspend fun setChatWallpaperIdForTheme(isDarkTheme: Boolean, value: String) {
|
||||
val key = if (isDarkTheme) CHAT_WALLPAPER_ID_DARK else CHAT_WALLPAPER_ID_LIGHT
|
||||
context.dataStore.edit { preferences -> preferences[key] = value }
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
// 🔔 NOTIFICATIONS
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
@@ -130,6 +147,11 @@ class PreferencesManager(private val context: Context) {
|
||||
preferences[NOTIFICATION_PREVIEW_ENABLED] ?: true
|
||||
}
|
||||
|
||||
val notificationAvatarEnabled: Flow<Boolean> =
|
||||
context.dataStore.data.map { preferences ->
|
||||
preferences[NOTIFICATION_AVATAR_ENABLED] ?: true
|
||||
}
|
||||
|
||||
suspend fun setNotificationsEnabled(value: Boolean) {
|
||||
context.dataStore.edit { preferences -> preferences[NOTIFICATIONS_ENABLED] = value }
|
||||
}
|
||||
@@ -146,6 +168,10 @@ class PreferencesManager(private val context: Context) {
|
||||
context.dataStore.edit { preferences -> preferences[NOTIFICATION_PREVIEW_ENABLED] = value }
|
||||
}
|
||||
|
||||
suspend fun setNotificationAvatarEnabled(value: Boolean) {
|
||||
context.dataStore.edit { preferences -> preferences[NOTIFICATION_AVATAR_ENABLED] = value }
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
// 💬 CHAT SETTINGS
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
@@ -310,6 +336,19 @@ class PreferencesManager(private val context: Context) {
|
||||
return wasPinned
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
// 🎨 APP ICON
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
|
||||
val appIcon: Flow<String> =
|
||||
context.dataStore.data.map { preferences ->
|
||||
preferences[APP_ICON] ?: "default"
|
||||
}
|
||||
|
||||
suspend fun setAppIcon(value: String) {
|
||||
context.dataStore.edit { preferences -> preferences[APP_ICON] = value }
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
// 🔕 MUTED CHATS
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -17,14 +17,11 @@ object ReleaseNotes {
|
||||
val RELEASE_NOTICE = """
|
||||
Update v$VERSION_PLACEHOLDER
|
||||
|
||||
Синхронизация Android ↔ iOS
|
||||
- Исправлена проблема: сообщения зависали на «часиках» при одновременном использовании Android и iOS
|
||||
- Добавлен механизм автоматического повтора отправки: 3 попытки с интервалом 4 сек, таймаут 80 сек
|
||||
- Исправлена нормализация sync-курсора для корректной синхронизации между устройствами
|
||||
|
||||
Интерфейс
|
||||
- Дата «today/yesterday» и пустой стейт чата теперь белые при тёмных обоях или тёмной теме
|
||||
- Исправлена обрезка имени отправителя в групповых чатах
|
||||
- Исправлена перемотка голосовых: waveform продолжается с текущей позиции после seek, без перерисовки с нуля
|
||||
- Стабилизирован вход и переподключение после подтверждения или отклонения верификации на другом устройстве
|
||||
- Исправлена отправка сообщений и синхронизация после повторного запроса входа
|
||||
- Восстановлена совместимость старых вложений и голосовых между версиями приложения
|
||||
- Улучшен запрос Full Screen Intent для звонков на Android 14+
|
||||
""".trimIndent()
|
||||
|
||||
fun getNotice(version: String): String =
|
||||
|
||||
@@ -81,7 +81,9 @@ data class LastMessageStatus(
|
||||
[
|
||||
Index(value = ["account", "from_public_key", "to_public_key", "timestamp"]),
|
||||
Index(value = ["account", "message_id"], unique = true),
|
||||
Index(value = ["account", "dialog_key", "timestamp"])]
|
||||
Index(value = ["account", "dialog_key", "timestamp"]),
|
||||
Index(value = ["account", "timestamp"]),
|
||||
Index(value = ["account", "primary_attachment_type", "timestamp"])]
|
||||
)
|
||||
data class MessageEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
@@ -99,18 +101,47 @@ data class MessageEntity(
|
||||
@ColumnInfo(name = "plain_message")
|
||||
val plainMessage: String, // 🔒 Зашифрованный текст (encryptWithPassword) для хранения в БД
|
||||
@ColumnInfo(name = "attachments") val attachments: String = "[]", // JSON массив вложений
|
||||
@ColumnInfo(name = "primary_attachment_type", defaultValue = "-1")
|
||||
val primaryAttachmentType: Int = -1, // Денормализованный тип 1-го вложения (-1 если нет)
|
||||
@ColumnInfo(name = "reply_to_message_id")
|
||||
val replyToMessageId: String? = null, // ID цитируемого сообщения
|
||||
@ColumnInfo(name = "dialog_key") val dialogKey: String // Ключ диалога для быстрой выборки
|
||||
)
|
||||
|
||||
/** Локальный денормализованный индекс для поиска по сообщениям без повторной дешифровки. */
|
||||
@Entity(
|
||||
tableName = "message_search_index",
|
||||
primaryKeys = ["account", "message_id"],
|
||||
indices =
|
||||
[
|
||||
Index(value = ["account", "timestamp"]),
|
||||
Index(value = ["account", "opponent_key", "timestamp"])]
|
||||
)
|
||||
data class MessageSearchIndexEntity(
|
||||
@ColumnInfo(name = "account") val account: String,
|
||||
@ColumnInfo(name = "message_id") val messageId: String,
|
||||
@ColumnInfo(name = "dialog_key") val dialogKey: String,
|
||||
@ColumnInfo(name = "opponent_key") val opponentKey: String,
|
||||
@ColumnInfo(name = "timestamp") val timestamp: Long,
|
||||
@ColumnInfo(name = "from_me") val fromMe: Int = 0,
|
||||
@ColumnInfo(name = "plain_text") val plainText: String,
|
||||
@ColumnInfo(name = "plain_text_normalized") val plainTextNormalized: String
|
||||
)
|
||||
|
||||
/** Entity для диалогов (кэш последнего сообщения) */
|
||||
@Entity(
|
||||
tableName = "dialogs",
|
||||
indices =
|
||||
[
|
||||
Index(value = ["account", "opponent_key"], unique = true),
|
||||
Index(value = ["account", "last_message_timestamp"])]
|
||||
Index(value = ["account", "last_message_timestamp"]),
|
||||
Index(
|
||||
value =
|
||||
[
|
||||
"account",
|
||||
"i_have_sent",
|
||||
"has_content",
|
||||
"last_message_timestamp"])]
|
||||
)
|
||||
data class DialogEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
@@ -129,6 +160,12 @@ data class DialogEntity(
|
||||
@ColumnInfo(name = "verified") val verified: Int = 0, // Верифицирован
|
||||
@ColumnInfo(name = "i_have_sent", defaultValue = "0")
|
||||
val iHaveSent: Int = 0, // Отправлял ли я сообщения в этот диалог (0/1)
|
||||
@ColumnInfo(name = "has_content", defaultValue = "0")
|
||||
val hasContent: Int = 0, // Есть ли контент в диалоге (0/1)
|
||||
@ColumnInfo(name = "last_message_attachment_type", defaultValue = "-1")
|
||||
val lastMessageAttachmentType: Int = -1, // Денормализованный тип вложения последнего сообщения
|
||||
@ColumnInfo(name = "last_sender_key", defaultValue = "''")
|
||||
val lastSenderKey: String = "", // Для групп: публичный ключ последнего отправителя
|
||||
@ColumnInfo(name = "last_message_from_me", defaultValue = "0")
|
||||
val lastMessageFromMe: Int = 0, // Последнее сообщение от меня (0/1)
|
||||
@ColumnInfo(name = "last_message_delivered", defaultValue = "0")
|
||||
@@ -174,6 +211,16 @@ interface GroupDao {
|
||||
suspend fun deleteAllByAccount(account: String): Int
|
||||
}
|
||||
|
||||
/** Строка истории звонков (messages + данные собеседника из dialogs) */
|
||||
data class CallHistoryRow(
|
||||
@Embedded val message: MessageEntity,
|
||||
@ColumnInfo(name = "peer_key") val peerKey: String,
|
||||
@ColumnInfo(name = "peer_title") val peerTitle: String?,
|
||||
@ColumnInfo(name = "peer_username") val peerUsername: String?,
|
||||
@ColumnInfo(name = "peer_verified") val peerVerified: Int?,
|
||||
@ColumnInfo(name = "peer_online") val peerOnline: Int?
|
||||
)
|
||||
|
||||
/** DAO для работы с сообщениями */
|
||||
@Dao
|
||||
interface MessageDao {
|
||||
@@ -191,7 +238,7 @@ interface MessageDao {
|
||||
"""
|
||||
SELECT * FROM messages
|
||||
WHERE account = :account AND dialog_key = :dialogKey
|
||||
ORDER BY timestamp DESC, message_id DESC
|
||||
ORDER BY timestamp DESC, id DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
@@ -213,7 +260,7 @@ interface MessageDao {
|
||||
WHERE account = :account
|
||||
AND from_public_key = :account
|
||||
AND to_public_key = :account
|
||||
ORDER BY timestamp DESC, message_id DESC
|
||||
ORDER BY timestamp DESC, id DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
@@ -239,7 +286,7 @@ interface MessageDao {
|
||||
"""
|
||||
SELECT * FROM messages
|
||||
WHERE account = :account AND dialog_key = :dialogKey
|
||||
ORDER BY timestamp ASC, message_id ASC
|
||||
ORDER BY timestamp ASC, id ASC
|
||||
"""
|
||||
)
|
||||
fun getMessagesFlow(account: String, dialogKey: String): Flow<List<MessageEntity>>
|
||||
@@ -272,7 +319,7 @@ interface MessageDao {
|
||||
"""
|
||||
SELECT * FROM messages
|
||||
WHERE account = :account AND dialog_key = :dialogKey
|
||||
ORDER BY timestamp DESC, message_id DESC
|
||||
ORDER BY timestamp DESC, id DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
)
|
||||
@@ -331,7 +378,7 @@ interface MessageDao {
|
||||
AND dialog_key = :dialogKey
|
||||
AND from_public_key = :fromPublicKey
|
||||
AND timestamp BETWEEN :timestampFrom AND :timestampTo
|
||||
ORDER BY timestamp ASC, message_id ASC
|
||||
ORDER BY timestamp ASC, id ASC
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
@@ -393,6 +440,10 @@ interface MessageDao {
|
||||
)
|
||||
suspend fun messageExists(account: String, messageId: String): Boolean
|
||||
|
||||
/** Найти сообщение по ID */
|
||||
@Query("SELECT * FROM messages WHERE account = :account AND message_id = :messageId LIMIT 1")
|
||||
suspend fun findMessageById(account: String, messageId: String): MessageEntity?
|
||||
|
||||
/**
|
||||
* Отметить все исходящие сообщения к собеседнику как прочитанные Используется когда приходит
|
||||
* PacketRead от собеседника.
|
||||
@@ -400,10 +451,13 @@ interface MessageDao {
|
||||
@Query(
|
||||
"""
|
||||
UPDATE messages SET read = 1
|
||||
WHERE account = :account AND to_public_key = :opponent AND from_me = 1
|
||||
WHERE account = :account
|
||||
AND to_public_key = :opponent
|
||||
AND from_me = 1
|
||||
AND read != 1
|
||||
"""
|
||||
)
|
||||
suspend fun markAllAsRead(account: String, opponent: String)
|
||||
suspend fun markAllAsRead(account: String, opponent: String): Int
|
||||
|
||||
/** 🔥 DEBUG: Получить последнее сообщение в диалоге для отладки */
|
||||
@Query(
|
||||
@@ -412,7 +466,7 @@ interface MessageDao {
|
||||
WHERE account = :account
|
||||
AND ((from_public_key = :opponent AND to_public_key = :account)
|
||||
OR (from_public_key = :account AND to_public_key = :opponent))
|
||||
ORDER BY timestamp DESC, message_id DESC LIMIT 1
|
||||
ORDER BY timestamp DESC, id DESC LIMIT 1
|
||||
"""
|
||||
)
|
||||
suspend fun getLastMessageDebug(account: String, opponent: String): MessageEntity?
|
||||
@@ -427,7 +481,7 @@ interface MessageDao {
|
||||
WHERE account = :account
|
||||
AND ((from_public_key = :opponent AND to_public_key = :account)
|
||||
OR (from_public_key = :account AND to_public_key = :opponent))
|
||||
ORDER BY timestamp DESC, message_id DESC LIMIT 1
|
||||
ORDER BY timestamp DESC, id DESC LIMIT 1
|
||||
"""
|
||||
)
|
||||
suspend fun getLastMessageStatus(account: String, opponent: String): LastMessageStatus?
|
||||
@@ -442,7 +496,7 @@ interface MessageDao {
|
||||
WHERE account = :account
|
||||
AND ((from_public_key = :opponent AND to_public_key = :account)
|
||||
OR (from_public_key = :account AND to_public_key = :opponent))
|
||||
ORDER BY timestamp DESC, message_id DESC LIMIT 1
|
||||
ORDER BY timestamp DESC, id DESC LIMIT 1
|
||||
"""
|
||||
)
|
||||
suspend fun getLastMessageAttachments(account: String, opponent: String): String?
|
||||
@@ -458,6 +512,7 @@ interface MessageDao {
|
||||
WHERE account = :account
|
||||
AND from_me = 1
|
||||
AND delivered = 0
|
||||
AND (attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]')
|
||||
AND timestamp >= :minTimestamp
|
||||
ORDER BY timestamp ASC
|
||||
"""
|
||||
@@ -474,11 +529,41 @@ interface MessageDao {
|
||||
WHERE account = :account
|
||||
AND from_me = 1
|
||||
AND delivered = 0
|
||||
AND (attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]')
|
||||
AND timestamp < :maxTimestamp
|
||||
"""
|
||||
)
|
||||
suspend fun markExpiredWaitingAsError(account: String, maxTimestamp: Long): Int
|
||||
|
||||
/**
|
||||
* Desktop parity recovery:
|
||||
* own direct messages synced from another device are stored with chacha_key "sync:*"
|
||||
* and should always be DELIVERED (never WAITING/ERROR).
|
||||
*/
|
||||
@Query(
|
||||
"""
|
||||
UPDATE messages
|
||||
SET delivered = 1
|
||||
WHERE account = :account
|
||||
AND from_me = 1
|
||||
AND delivered != 1
|
||||
AND chacha_key LIKE 'sync:%'
|
||||
"""
|
||||
)
|
||||
suspend fun markSyncedOwnMessagesAsDelivered(account: String): Int
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT DISTINCT to_public_key
|
||||
FROM messages
|
||||
WHERE account = :account
|
||||
AND from_me = 1
|
||||
AND delivered != 1
|
||||
AND chacha_key LIKE 'sync:%'
|
||||
"""
|
||||
)
|
||||
suspend fun getSyncedOwnMessageOpponentsWithNonDeliveredStatus(account: String): List<String>
|
||||
|
||||
/**
|
||||
* Update delivery status AND timestamp on delivery confirmation.
|
||||
* Desktop parity: useDialogFiber.ts sets timestamp = Date.now() on PacketDelivery.
|
||||
@@ -503,8 +588,7 @@ interface MessageDao {
|
||||
"""
|
||||
SELECT * FROM messages
|
||||
WHERE account = :account
|
||||
AND attachments != '[]'
|
||||
AND attachments LIKE '%"type":0%'
|
||||
AND primary_attachment_type = 0
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
@@ -519,13 +603,139 @@ interface MessageDao {
|
||||
"""
|
||||
SELECT * FROM messages
|
||||
WHERE account = :account
|
||||
AND attachments != '[]'
|
||||
AND attachments LIKE '%"type":2%'
|
||||
AND primary_attachment_type = 2
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
suspend fun getMessagesWithFiles(account: String, limit: Int, offset: Int): List<MessageEntity>
|
||||
|
||||
/**
|
||||
* 📞 История звонков на основе CALL attachments (type: 4)
|
||||
* LEFT JOIN на dialogs нужен для имени/username/verified без дополнительных запросов.
|
||||
*/
|
||||
@Query(
|
||||
"""
|
||||
SELECT
|
||||
m.*,
|
||||
CASE
|
||||
WHEN m.from_me = 1 THEN m.to_public_key
|
||||
ELSE m.from_public_key
|
||||
END AS peer_key,
|
||||
d.opponent_title AS peer_title,
|
||||
d.opponent_username AS peer_username,
|
||||
d.verified AS peer_verified,
|
||||
d.is_online AS peer_online
|
||||
FROM messages m
|
||||
LEFT JOIN dialogs d
|
||||
ON d.account = m.account
|
||||
AND d.opponent_key = CASE
|
||||
WHEN m.from_me = 1 THEN m.to_public_key
|
||||
ELSE m.from_public_key
|
||||
END
|
||||
WHERE m.account = :account
|
||||
AND m.primary_attachment_type = 4
|
||||
ORDER BY m.timestamp DESC, m.id DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
)
|
||||
fun getCallHistoryFlow(account: String, limit: Int = 300): Flow<List<CallHistoryRow>>
|
||||
|
||||
/** Пиры, у которых есть call attachments (нужно для пересчета dialogs после удаления). */
|
||||
@Query(
|
||||
"""
|
||||
SELECT DISTINCT
|
||||
CASE
|
||||
WHEN from_me = 1 THEN to_public_key
|
||||
ELSE from_public_key
|
||||
END AS peer_key
|
||||
FROM messages
|
||||
WHERE account = :account
|
||||
AND primary_attachment_type = 4
|
||||
"""
|
||||
)
|
||||
suspend fun getCallHistoryPeers(account: String): List<String>
|
||||
|
||||
/** Удалить все call events из messages для аккаунта. */
|
||||
@Query(
|
||||
"""
|
||||
DELETE FROM messages
|
||||
WHERE account = :account
|
||||
AND primary_attachment_type = 4
|
||||
"""
|
||||
)
|
||||
suspend fun deleteAllCallMessages(account: String): Int
|
||||
|
||||
/** Все сообщения аккаунта (для поиска по тексту), отсортированные по дате */
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM messages
|
||||
WHERE account = :account
|
||||
AND plain_message != ''
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
suspend fun getAllMessagesPaged(account: String, limit: Int, offset: Int): List<MessageEntity>
|
||||
}
|
||||
|
||||
@Dao
|
||||
interface MessageSearchIndexDao {
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(items: List<MessageSearchIndexEntity>)
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM message_search_index
|
||||
WHERE account = :account
|
||||
AND plain_text_normalized LIKE '%' || :queryNormalized || '%'
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
suspend fun search(
|
||||
account: String,
|
||||
queryNormalized: String,
|
||||
limit: Int,
|
||||
offset: Int = 0
|
||||
): List<MessageSearchIndexEntity>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM message_search_index
|
||||
WHERE account = :account
|
||||
AND dialog_key = :dialogKey
|
||||
AND plain_text_normalized LIKE '%' || :queryNormalized || '%'
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
suspend fun searchInDialog(
|
||||
account: String,
|
||||
dialogKey: String,
|
||||
queryNormalized: String,
|
||||
limit: Int,
|
||||
offset: Int = 0
|
||||
): List<MessageSearchIndexEntity>
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT m.* FROM messages m
|
||||
LEFT JOIN message_search_index s
|
||||
ON s.account = m.account
|
||||
AND s.message_id = m.message_id
|
||||
WHERE m.account = :account
|
||||
AND m.plain_message != ''
|
||||
AND s.message_id IS NULL
|
||||
ORDER BY m.timestamp DESC
|
||||
LIMIT :limit
|
||||
"""
|
||||
)
|
||||
suspend fun getUnindexedMessages(account: String, limit: Int): List<MessageEntity>
|
||||
|
||||
@Query("DELETE FROM message_search_index WHERE account = :account")
|
||||
suspend fun deleteByAccount(account: String): Int
|
||||
}
|
||||
|
||||
/** DAO для работы с диалогами */
|
||||
@@ -549,7 +759,7 @@ interface DialogDao {
|
||||
OR opponent_key = '0x000000000000000000000000000000000000000001'
|
||||
OR opponent_key = '0x000000000000000000000000000000000000000002'
|
||||
)
|
||||
AND (last_message != '' OR last_message_attachments != '[]')
|
||||
AND has_content = 1
|
||||
ORDER BY last_message_timestamp DESC
|
||||
LIMIT 30
|
||||
"""
|
||||
@@ -566,7 +776,7 @@ interface DialogDao {
|
||||
OR opponent_key = '0x000000000000000000000000000000000000000001'
|
||||
OR opponent_key = '0x000000000000000000000000000000000000000002'
|
||||
)
|
||||
AND (last_message != '' OR last_message_attachments != '[]')
|
||||
AND has_content = 1
|
||||
ORDER BY last_message_timestamp DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
@@ -584,7 +794,7 @@ interface DialogDao {
|
||||
AND i_have_sent = 0
|
||||
AND opponent_key != '0x000000000000000000000000000000000000000001'
|
||||
AND opponent_key != '0x000000000000000000000000000000000000000002'
|
||||
AND (last_message != '' OR last_message_attachments != '[]')
|
||||
AND has_content = 1
|
||||
ORDER BY last_message_timestamp DESC
|
||||
LIMIT 30
|
||||
"""
|
||||
@@ -599,7 +809,7 @@ interface DialogDao {
|
||||
AND i_have_sent = 0
|
||||
AND opponent_key != '0x000000000000000000000000000000000000000001'
|
||||
AND opponent_key != '0x000000000000000000000000000000000000000002'
|
||||
AND (last_message != '' OR last_message_attachments != '[]')
|
||||
AND has_content = 1
|
||||
"""
|
||||
)
|
||||
fun getRequestsCountFlow(account: String): Flow<Int>
|
||||
@@ -612,7 +822,7 @@ interface DialogDao {
|
||||
@Query("""
|
||||
SELECT * FROM dialogs
|
||||
WHERE account = :account
|
||||
AND (last_message != '' OR last_message_attachments != '[]')
|
||||
AND has_content = 1
|
||||
AND opponent_key NOT LIKE '#group:%'
|
||||
AND (
|
||||
opponent_title = ''
|
||||
@@ -626,12 +836,25 @@ interface DialogDao {
|
||||
@Query("SELECT * FROM dialogs WHERE account = :account AND opponent_key = :opponentKey LIMIT 1")
|
||||
suspend fun getDialog(account: String, opponentKey: String): DialogEntity?
|
||||
|
||||
/** Найти direct-диалог по username собеседника (без учета регистра и '@'). */
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM dialogs
|
||||
WHERE account = :account
|
||||
AND opponent_key NOT LIKE '#group:%'
|
||||
AND LOWER(REPLACE(TRIM(opponent_username), '@', '')) = LOWER(REPLACE(TRIM(:username), '@', ''))
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
suspend fun getDialogByUsername(account: String, username: String): DialogEntity?
|
||||
|
||||
/** Обновить последнее сообщение */
|
||||
@Query(
|
||||
"""
|
||||
UPDATE dialogs SET
|
||||
last_message = :lastMessage,
|
||||
last_message_timestamp = :timestamp
|
||||
last_message_timestamp = :timestamp,
|
||||
has_content = CASE WHEN TRIM(:lastMessage) != '' THEN 1 ELSE has_content END
|
||||
WHERE account = :account AND opponent_key = :opponentKey
|
||||
"""
|
||||
)
|
||||
@@ -761,7 +984,7 @@ interface DialogDao {
|
||||
"""
|
||||
SELECT * FROM messages
|
||||
WHERE account = :account AND dialog_key = :dialogKey
|
||||
ORDER BY timestamp DESC, message_id DESC LIMIT 1
|
||||
ORDER BY timestamp DESC, id DESC LIMIT 1
|
||||
"""
|
||||
)
|
||||
suspend fun getLastMessageByDialogKey(account: String, dialogKey: String): MessageEntity?
|
||||
@@ -860,6 +1083,16 @@ interface DialogDao {
|
||||
val hasSent = hasSentByDialogKey(account, dialogKey)
|
||||
|
||||
// 5. Один INSERT OR REPLACE с вычисленными данными
|
||||
val hasContent =
|
||||
if (
|
||||
lastMsg.plainMessage.isNotBlank() ||
|
||||
(lastMsg.attachments.isNotBlank() &&
|
||||
lastMsg.attachments.trim() != "[]")
|
||||
) {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
insertDialog(
|
||||
DialogEntity(
|
||||
id = existing?.id ?: 0,
|
||||
@@ -875,6 +1108,9 @@ interface DialogDao {
|
||||
verified = existing?.verified ?: 0,
|
||||
// Desktop parity: request flag is always derived from message history.
|
||||
iHaveSent = if (hasSent || isSystemDialog) 1 else 0,
|
||||
hasContent = hasContent,
|
||||
lastMessageAttachmentType = lastMsg.primaryAttachmentType,
|
||||
lastSenderKey = lastMsg.fromPublicKey,
|
||||
lastMessageFromMe = lastMsg.fromMe,
|
||||
lastMessageDelivered = if (lastMsg.fromMe == 1) lastMsg.delivered else 0,
|
||||
lastMessageRead = if (lastMsg.fromMe == 1) lastMsg.read else 0,
|
||||
@@ -894,6 +1130,16 @@ interface DialogDao {
|
||||
|
||||
val lastMsg = getLastMessageByDialogKey(account, dialogKey) ?: return
|
||||
val existing = getDialog(account, account)
|
||||
val hasContent =
|
||||
if (
|
||||
lastMsg.plainMessage.isNotBlank() ||
|
||||
(lastMsg.attachments.isNotBlank() &&
|
||||
lastMsg.attachments.trim() != "[]")
|
||||
) {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
|
||||
insertDialog(
|
||||
DialogEntity(
|
||||
@@ -909,6 +1155,9 @@ interface DialogDao {
|
||||
lastSeen = existing?.lastSeen ?: 0,
|
||||
verified = existing?.verified ?: 0,
|
||||
iHaveSent = 1,
|
||||
hasContent = hasContent,
|
||||
lastMessageAttachmentType = lastMsg.primaryAttachmentType,
|
||||
lastSenderKey = lastMsg.fromPublicKey,
|
||||
lastMessageFromMe = 1,
|
||||
lastMessageDelivered = 1,
|
||||
lastMessageRead = 1,
|
||||
|
||||
@@ -12,13 +12,14 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
[
|
||||
EncryptedAccountEntity::class,
|
||||
MessageEntity::class,
|
||||
MessageSearchIndexEntity::class,
|
||||
DialogEntity::class,
|
||||
BlacklistEntity::class,
|
||||
AvatarCacheEntity::class,
|
||||
AccountSyncTimeEntity::class,
|
||||
GroupEntity::class,
|
||||
PinnedMessageEntity::class],
|
||||
version = 14,
|
||||
version = 17,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class RosettaDatabase : RoomDatabase() {
|
||||
@@ -30,6 +31,7 @@ abstract class RosettaDatabase : RoomDatabase() {
|
||||
abstract fun syncTimeDao(): SyncTimeDao
|
||||
abstract fun groupDao(): GroupDao
|
||||
abstract fun pinnedMessageDao(): PinnedMessageDao
|
||||
abstract fun messageSearchIndexDao(): MessageSearchIndexDao
|
||||
|
||||
companion object {
|
||||
@Volatile private var INSTANCE: RosettaDatabase? = null
|
||||
@@ -202,6 +204,154 @@ abstract class RosettaDatabase : RoomDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🧱 МИГРАЦИЯ 14->15: Денормализованный тип вложения для ускорения фильтров (media/files/calls)
|
||||
*/
|
||||
private val MIGRATION_14_15 =
|
||||
object : Migration(14, 15) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL(
|
||||
"ALTER TABLE messages ADD COLUMN primary_attachment_type INTEGER NOT NULL DEFAULT -1"
|
||||
)
|
||||
database.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS index_messages_account_primary_attachment_type_timestamp ON messages (account, primary_attachment_type, timestamp)"
|
||||
)
|
||||
// Best-effort backfill для уже сохраненных сообщений.
|
||||
database.execSQL(
|
||||
"""
|
||||
UPDATE messages
|
||||
SET primary_attachment_type = CASE
|
||||
WHEN attachments IS NULL OR TRIM(attachments) = '' OR TRIM(attachments) = '[]' THEN -1
|
||||
WHEN attachments LIKE '%"type":0%' OR attachments LIKE '%"type": 0%' THEN 0
|
||||
WHEN attachments LIKE '%"type":1%' OR attachments LIKE '%"type": 1%' THEN 1
|
||||
WHEN attachments LIKE '%"type":2%' OR attachments LIKE '%"type": 2%' THEN 2
|
||||
WHEN attachments LIKE '%"type":3%' OR attachments LIKE '%"type": 3%' THEN 3
|
||||
WHEN attachments LIKE '%"type":4%' OR attachments LIKE '%"type": 4%' THEN 4
|
||||
ELSE -1
|
||||
END
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🧱 МИГРАЦИЯ 15->16: Денормализованный has_content для быстрых выборок dialogs/requests
|
||||
*/
|
||||
private val MIGRATION_15_16 =
|
||||
object : Migration(15, 16) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL(
|
||||
"ALTER TABLE dialogs ADD COLUMN has_content INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
database.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS index_dialogs_account_i_have_sent_has_content_last_message_timestamp ON dialogs (account, i_have_sent, has_content, last_message_timestamp)"
|
||||
)
|
||||
database.execSQL(
|
||||
"""
|
||||
UPDATE dialogs
|
||||
SET has_content = CASE
|
||||
WHEN TRIM(last_message) != '' THEN 1
|
||||
WHEN last_message_attachments IS NOT NULL
|
||||
AND TRIM(last_message_attachments) != ''
|
||||
AND TRIM(last_message_attachments) != '[]' THEN 1
|
||||
ELSE 0
|
||||
END
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🧱 МИГРАЦИЯ 16->17:
|
||||
* - dialogs: last_message_attachment_type + last_sender_key
|
||||
* - messages: индекс (account, timestamp)
|
||||
* - локальный message_search_index для поиска без повторной дешифровки
|
||||
*/
|
||||
private val MIGRATION_16_17 =
|
||||
object : Migration(16, 17) {
|
||||
override fun migrate(database: SupportSQLiteDatabase) {
|
||||
database.execSQL(
|
||||
"ALTER TABLE dialogs ADD COLUMN last_message_attachment_type INTEGER NOT NULL DEFAULT -1"
|
||||
)
|
||||
database.execSQL(
|
||||
"ALTER TABLE dialogs ADD COLUMN last_sender_key TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
database.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS index_messages_account_timestamp ON messages (account, timestamp)"
|
||||
)
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS message_search_index (
|
||||
account TEXT NOT NULL,
|
||||
message_id TEXT NOT NULL,
|
||||
dialog_key TEXT NOT NULL,
|
||||
opponent_key TEXT NOT NULL,
|
||||
timestamp INTEGER NOT NULL,
|
||||
from_me INTEGER NOT NULL DEFAULT 0,
|
||||
plain_text TEXT NOT NULL,
|
||||
plain_text_normalized TEXT NOT NULL,
|
||||
PRIMARY KEY(account, message_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
database.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS index_message_search_index_account_timestamp ON message_search_index (account, timestamp)"
|
||||
)
|
||||
database.execSQL(
|
||||
"CREATE INDEX IF NOT EXISTS index_message_search_index_account_opponent_key_timestamp ON message_search_index (account, opponent_key, timestamp)"
|
||||
)
|
||||
database.execSQL(
|
||||
"""
|
||||
UPDATE dialogs
|
||||
SET last_message_attachment_type = CASE
|
||||
WHEN last_message_attachments IS NULL
|
||||
OR TRIM(last_message_attachments) = ''
|
||||
OR TRIM(last_message_attachments) = '[]' THEN -1
|
||||
WHEN last_message_attachments LIKE '%"type":0%' OR last_message_attachments LIKE '%"type": 0%' THEN 0
|
||||
WHEN last_message_attachments LIKE '%"type":1%' OR last_message_attachments LIKE '%"type": 1%' THEN 1
|
||||
WHEN last_message_attachments LIKE '%"type":2%' OR last_message_attachments LIKE '%"type": 2%' THEN 2
|
||||
WHEN last_message_attachments LIKE '%"type":3%' OR last_message_attachments LIKE '%"type": 3%' THEN 3
|
||||
WHEN last_message_attachments LIKE '%"type":4%' OR last_message_attachments LIKE '%"type": 4%' THEN 4
|
||||
ELSE -1
|
||||
END
|
||||
"""
|
||||
)
|
||||
database.execSQL(
|
||||
"""
|
||||
UPDATE dialogs
|
||||
SET last_sender_key = COALESCE(
|
||||
(
|
||||
SELECT m.from_public_key
|
||||
FROM messages m
|
||||
WHERE m.account = dialogs.account
|
||||
AND m.dialog_key = CASE
|
||||
WHEN dialogs.opponent_key = dialogs.account THEN dialogs.account
|
||||
WHEN LOWER(dialogs.opponent_key) LIKE '#group:%' OR LOWER(dialogs.opponent_key) LIKE 'group:%'
|
||||
THEN dialogs.opponent_key
|
||||
WHEN dialogs.account < dialogs.opponent_key
|
||||
THEN dialogs.account || ':' || dialogs.opponent_key
|
||||
ELSE dialogs.opponent_key || ':' || dialogs.account
|
||||
END
|
||||
ORDER BY m.timestamp DESC, m.id DESC
|
||||
LIMIT 1
|
||||
),
|
||||
''
|
||||
)
|
||||
"""
|
||||
)
|
||||
database.execSQL(
|
||||
"""
|
||||
CREATE TRIGGER IF NOT EXISTS trg_message_search_index_delete
|
||||
AFTER DELETE ON messages
|
||||
BEGIN
|
||||
DELETE FROM message_search_index
|
||||
WHERE account = OLD.account AND message_id = OLD.message_id;
|
||||
END
|
||||
"""
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun getDatabase(context: Context): RosettaDatabase {
|
||||
return INSTANCE
|
||||
?: synchronized(this) {
|
||||
@@ -224,7 +374,10 @@ abstract class RosettaDatabase : RoomDatabase() {
|
||||
MIGRATION_10_11,
|
||||
MIGRATION_11_12,
|
||||
MIGRATION_12_13,
|
||||
MIGRATION_13_14
|
||||
MIGRATION_13_14,
|
||||
MIGRATION_14_15,
|
||||
MIGRATION_15_16,
|
||||
MIGRATION_16_17
|
||||
)
|
||||
.fallbackToDestructiveMigration() // Для разработки - только
|
||||
// если миграция не
|
||||
|
||||
199
app/src/main/java/com/rosetta/messenger/di/AppContainer.kt
Normal file
199
app/src/main/java/com/rosetta/messenger/di/AppContainer.kt
Normal file
@@ -0,0 +1,199 @@
|
||||
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.PreferencesManager
|
||||
import com.rosetta.messenger.network.DeviceEntry
|
||||
import com.rosetta.messenger.network.Packet
|
||||
import com.rosetta.messenger.network.PacketMessage
|
||||
import com.rosetta.messenger.network.ProtocolClient
|
||||
import com.rosetta.messenger.network.ProtocolRuntime
|
||||
import com.rosetta.messenger.network.ProtocolRuntimePort
|
||||
import com.rosetta.messenger.network.RuntimeComposition
|
||||
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 javax.inject.Provider
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
interface ProtocolGateway : ProtocolRuntimePort {
|
||||
val syncInProgress: StateFlow<Boolean>
|
||||
val pendingDeviceVerification: StateFlow<DeviceEntry?>
|
||||
val typingUsers: StateFlow<Set<String>>
|
||||
val typingUsersByDialogSnapshot: StateFlow<Map<String, Set<String>>>
|
||||
val ownProfileUpdated: StateFlow<Long>
|
||||
|
||||
fun initialize(context: Context)
|
||||
fun initializeAccount(publicKey: String, privateKey: String)
|
||||
fun connect()
|
||||
fun authenticate(publicKey: String, privateHash: String)
|
||||
fun disconnect()
|
||||
fun getPrivateHash(): String?
|
||||
fun subscribePushTokenIfAvailable(forceToken: String? = null)
|
||||
fun enableUILogs(enabled: Boolean)
|
||||
fun clearLogs()
|
||||
fun resolveOutgoingRetry(messageId: String)
|
||||
fun getCachedUserByUsername(username: String): SearchUser?
|
||||
fun getCachedUserName(publicKey: String): String?
|
||||
fun acceptDevice(deviceId: String)
|
||||
fun declineDevice(deviceId: String)
|
||||
fun sendMessageWithRetry(packet: PacketMessage)
|
||||
fun packetFlow(packetId: Int): SharedFlow<Packet>
|
||||
fun notifyOwnProfileUpdated()
|
||||
suspend fun resolveUserName(publicKey: String, timeoutMs: Long = 3000): String?
|
||||
suspend fun searchUsers(query: String, timeoutMs: Long = 3000): List<SearchUser>
|
||||
}
|
||||
|
||||
interface SessionCoordinator {
|
||||
val sessionState: StateFlow<SessionState>
|
||||
|
||||
fun dispatch(action: SessionAction)
|
||||
fun markLoggedOut(reason: String = "") =
|
||||
dispatch(SessionAction.LoggedOut(reason = reason))
|
||||
fun markAuthInProgress(publicKey: String? = null, reason: String = "") =
|
||||
dispatch(
|
||||
SessionAction.AuthInProgress(
|
||||
publicKey = publicKey,
|
||||
reason = reason
|
||||
)
|
||||
)
|
||||
fun markReady(account: DecryptedAccount, reason: String = "") =
|
||||
dispatch(SessionAction.Ready(account = account, reason = reason))
|
||||
fun syncFromCachedAccount(account: DecryptedAccount?) =
|
||||
dispatch(SessionAction.SyncFromCachedAccount(account = account))
|
||||
suspend fun bootstrapAuthenticatedSession(account: DecryptedAccount, reason: String)
|
||||
}
|
||||
|
||||
interface IdentityGateway {
|
||||
val state: StateFlow<IdentityStateSnapshot>
|
||||
|
||||
fun updateOwnProfile(
|
||||
publicKey: String,
|
||||
displayName: String? = null,
|
||||
username: String? = null,
|
||||
verified: Int? = null,
|
||||
resolved: Boolean = true,
|
||||
reason: String = ""
|
||||
)
|
||||
}
|
||||
|
||||
@Singleton
|
||||
/**
|
||||
* Thin infrastructure adapter for repositories.
|
||||
*
|
||||
* This bridge is intentionally excluded from business-flow hop-depth accounting and exists
|
||||
* to keep lazy runtime access (`Provider<ProtocolRuntime>`) and avoid DI cycles.
|
||||
*/
|
||||
class ProtocolClientImpl @Inject constructor(
|
||||
private val runtimeProvider: Provider<ProtocolRuntime>
|
||||
) : ProtocolClient {
|
||||
override fun send(packet: Packet) = runtimeProvider.get().send(packet)
|
||||
|
||||
override fun sendMessageWithRetry(packet: PacketMessage) =
|
||||
runtimeProvider.get().sendMessageWithRetry(packet)
|
||||
|
||||
override fun addLog(message: String) = runtimeProvider.get().addLog(message)
|
||||
|
||||
override fun waitPacket(packetId: Int, callback: (Packet) -> Unit) =
|
||||
runtimeProvider.get().waitPacket(packetId, callback)
|
||||
|
||||
override fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) =
|
||||
runtimeProvider.get().unwaitPacket(packetId, callback)
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class SessionCoordinatorImpl @Inject constructor(
|
||||
private val accountManager: AccountManager,
|
||||
private val protocolGateway: ProtocolGateway
|
||||
) : SessionCoordinator {
|
||||
override val sessionState: StateFlow<SessionState> = SessionStore.state
|
||||
|
||||
override fun dispatch(action: SessionAction) {
|
||||
SessionStore.dispatch(action)
|
||||
}
|
||||
|
||||
override suspend fun bootstrapAuthenticatedSession(account: DecryptedAccount, reason: String) {
|
||||
dispatch(SessionAction.AuthInProgress(publicKey = account.publicKey, reason = reason))
|
||||
protocolGateway.initializeAccount(account.publicKey, account.privateKey)
|
||||
protocolGateway.connect()
|
||||
protocolGateway.authenticate(account.publicKey, account.privateKeyHash)
|
||||
protocolGateway.reconnectNowIfNeeded("session_bootstrap_$reason")
|
||||
accountManager.setCurrentAccount(account.publicKey)
|
||||
dispatch(SessionAction.Ready(account = account, reason = reason))
|
||||
}
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class IdentityGatewayImpl @Inject constructor() : IdentityGateway {
|
||||
override val state: StateFlow<IdentityStateSnapshot> = IdentityStore.state
|
||||
|
||||
override fun updateOwnProfile(
|
||||
publicKey: String,
|
||||
displayName: String?,
|
||||
username: String?,
|
||||
verified: Int?,
|
||||
resolved: Boolean,
|
||||
reason: String
|
||||
) {
|
||||
IdentityStore.updateOwnProfile(
|
||||
publicKey = publicKey,
|
||||
displayName = displayName,
|
||||
username = username,
|
||||
verified = verified,
|
||||
resolved = resolved,
|
||||
reason = reason
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppDataModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAccountManager(@ApplicationContext context: Context): AccountManager =
|
||||
AccountManager(context)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun providePreferencesManager(@ApplicationContext context: Context): PreferencesManager =
|
||||
PreferencesManager(context)
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRuntimeComposition(): RuntimeComposition = RuntimeComposition()
|
||||
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class AppGatewayModule {
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindProtocolGateway(runtime: ProtocolRuntime): ProtocolGateway
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindSessionCoordinator(impl: SessionCoordinatorImpl): SessionCoordinator
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindIdentityGateway(impl: IdentityGatewayImpl): IdentityGateway
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindProtocolClient(impl: ProtocolClientImpl): ProtocolClient
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.rosetta.messenger.domain.chats.usecase
|
||||
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
import com.rosetta.messenger.network.MessageAttachment
|
||||
import javax.inject.Inject
|
||||
|
||||
data class CreateFileAttachmentCommand(
|
||||
val attachmentId: String,
|
||||
val preview: String,
|
||||
val blob: String = "",
|
||||
val transportTag: String = "",
|
||||
val transportServer: String = ""
|
||||
)
|
||||
|
||||
class CreateFileAttachmentUseCase @Inject constructor() {
|
||||
operator fun invoke(command: CreateFileAttachmentCommand): MessageAttachment =
|
||||
MessageAttachment(
|
||||
id = command.attachmentId,
|
||||
blob = command.blob,
|
||||
type = AttachmentType.FILE,
|
||||
preview = command.preview,
|
||||
transportTag = command.transportTag,
|
||||
transportServer = command.transportServer
|
||||
)
|
||||
}
|
||||
|
||||
data class CreateAvatarAttachmentCommand(
|
||||
val attachmentId: String,
|
||||
val preview: String,
|
||||
val blob: String = "",
|
||||
val transportTag: String = "",
|
||||
val transportServer: String = ""
|
||||
)
|
||||
|
||||
class CreateAvatarAttachmentUseCase @Inject constructor() {
|
||||
operator fun invoke(command: CreateAvatarAttachmentCommand): MessageAttachment =
|
||||
MessageAttachment(
|
||||
id = command.attachmentId,
|
||||
blob = command.blob,
|
||||
type = AttachmentType.AVATAR,
|
||||
preview = command.preview,
|
||||
transportTag = command.transportTag,
|
||||
transportServer = command.transportServer
|
||||
)
|
||||
}
|
||||
|
||||
data class CreateVideoCircleAttachmentCommand(
|
||||
val attachmentId: String,
|
||||
val preview: String,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val blob: String = "",
|
||||
val localUri: String = "",
|
||||
val transportTag: String = "",
|
||||
val transportServer: String = ""
|
||||
)
|
||||
|
||||
class CreateVideoCircleAttachmentUseCase @Inject constructor() {
|
||||
operator fun invoke(command: CreateVideoCircleAttachmentCommand): MessageAttachment =
|
||||
MessageAttachment(
|
||||
id = command.attachmentId,
|
||||
blob = command.blob,
|
||||
type = AttachmentType.VIDEO_CIRCLE,
|
||||
preview = command.preview,
|
||||
width = command.width,
|
||||
height = command.height,
|
||||
localUri = command.localUri,
|
||||
transportTag = command.transportTag,
|
||||
transportServer = command.transportServer
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.rosetta.messenger.domain.chats.usecase
|
||||
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.network.TransportManager
|
||||
import javax.inject.Inject
|
||||
|
||||
data class EncryptAndUploadAttachmentCommand(
|
||||
val payload: String,
|
||||
val attachmentPassword: String,
|
||||
val attachmentId: String,
|
||||
val isSavedMessages: Boolean
|
||||
)
|
||||
|
||||
data class EncryptAndUploadAttachmentResult(
|
||||
val encryptedBlob: String,
|
||||
val transportTag: String,
|
||||
val transportServer: String
|
||||
)
|
||||
|
||||
class EncryptAndUploadAttachmentUseCase @Inject constructor() {
|
||||
suspend operator fun invoke(command: EncryptAndUploadAttachmentCommand): EncryptAndUploadAttachmentResult {
|
||||
val encryptedBlob = CryptoManager.encryptWithPassword(command.payload, command.attachmentPassword)
|
||||
if (command.isSavedMessages) {
|
||||
return EncryptAndUploadAttachmentResult(
|
||||
encryptedBlob = encryptedBlob,
|
||||
transportTag = "",
|
||||
transportServer = ""
|
||||
)
|
||||
}
|
||||
|
||||
val uploadTag = TransportManager.uploadFile(command.attachmentId, encryptedBlob)
|
||||
val transportServer =
|
||||
if (uploadTag.isNotEmpty()) {
|
||||
TransportManager.getTransportServer().orEmpty()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
|
||||
return EncryptAndUploadAttachmentResult(
|
||||
encryptedBlob = encryptedBlob,
|
||||
transportTag = uploadTag,
|
||||
transportServer = transportServer
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.rosetta.messenger.domain.chats.usecase
|
||||
|
||||
import com.rosetta.messenger.di.ProtocolGateway
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
import com.rosetta.messenger.network.MessageAttachment
|
||||
import com.rosetta.messenger.network.PacketMessage
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import javax.inject.Inject
|
||||
|
||||
data class ForwardPayloadMessage(
|
||||
val messageId: String,
|
||||
val senderPublicKey: String,
|
||||
val senderName: String,
|
||||
val text: String,
|
||||
val timestamp: Long,
|
||||
val chachaKeyPlain: String,
|
||||
val attachments: List<MessageAttachment>
|
||||
)
|
||||
|
||||
class SendForwardUseCase @Inject constructor(
|
||||
private val protocolGateway: ProtocolGateway
|
||||
) {
|
||||
fun buildForwardReplyJson(
|
||||
messages: List<ForwardPayloadMessage>,
|
||||
rewrittenAttachments: Map<String, MessageAttachment>,
|
||||
rewrittenMessageIds: Set<String>,
|
||||
outgoingForwardPlainKeyHex: String,
|
||||
includeLocalUri: Boolean,
|
||||
rewriteKey: (messageId: String, attachmentId: String) -> String
|
||||
): JSONArray {
|
||||
val replyJsonArray = JSONArray()
|
||||
messages.forEach { message ->
|
||||
val attachmentsArray = JSONArray()
|
||||
message.attachments.forEach { attachment ->
|
||||
val rewritten =
|
||||
rewrittenAttachments[rewriteKey(message.messageId, attachment.id)]
|
||||
val effectiveAttachment = rewritten ?: attachment
|
||||
attachmentsArray.put(
|
||||
JSONObject().apply {
|
||||
put("id", effectiveAttachment.id)
|
||||
put("type", effectiveAttachment.type.value)
|
||||
put("preview", effectiveAttachment.preview)
|
||||
put("width", effectiveAttachment.width)
|
||||
put("height", effectiveAttachment.height)
|
||||
put("blob", "")
|
||||
put("transportTag", effectiveAttachment.transportTag)
|
||||
put("transportServer", effectiveAttachment.transportServer)
|
||||
put(
|
||||
"transport",
|
||||
JSONObject().apply {
|
||||
put("transport_tag", effectiveAttachment.transportTag)
|
||||
put("transport_server", effectiveAttachment.transportServer)
|
||||
}
|
||||
)
|
||||
if (includeLocalUri && effectiveAttachment.localUri.isNotEmpty()) {
|
||||
put("localUri", effectiveAttachment.localUri)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
val effectiveForwardPlainKey =
|
||||
if (message.messageId in rewrittenMessageIds && outgoingForwardPlainKeyHex.isNotEmpty()) {
|
||||
outgoingForwardPlainKeyHex
|
||||
} else {
|
||||
message.chachaKeyPlain
|
||||
}
|
||||
|
||||
replyJsonArray.put(
|
||||
JSONObject().apply {
|
||||
put("message_id", message.messageId)
|
||||
put("publicKey", message.senderPublicKey)
|
||||
put("message", message.text)
|
||||
put("timestamp", message.timestamp)
|
||||
put("attachments", attachmentsArray)
|
||||
put("forwarded", true)
|
||||
put("senderName", message.senderName)
|
||||
if (effectiveForwardPlainKey.isNotEmpty()) {
|
||||
put("chacha_key_plain", effectiveForwardPlainKey)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
return replyJsonArray
|
||||
}
|
||||
|
||||
fun buildForwardAttachment(
|
||||
replyAttachmentId: String,
|
||||
encryptedReplyBlob: String
|
||||
): MessageAttachment =
|
||||
MessageAttachment(
|
||||
id = replyAttachmentId,
|
||||
blob = encryptedReplyBlob,
|
||||
type = AttachmentType.MESSAGES,
|
||||
preview = ""
|
||||
)
|
||||
|
||||
fun dispatch(packet: PacketMessage, isSavedMessages: Boolean) {
|
||||
if (!isSavedMessages) {
|
||||
protocolGateway.sendMessageWithRetry(packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.rosetta.messenger.domain.chats.usecase
|
||||
|
||||
import com.rosetta.messenger.di.ProtocolGateway
|
||||
import com.rosetta.messenger.network.MessageAttachment
|
||||
import com.rosetta.messenger.network.PacketMessage
|
||||
import javax.inject.Inject
|
||||
|
||||
data class SendMediaMessageCommand(
|
||||
val fromPublicKey: String,
|
||||
val toPublicKey: String,
|
||||
val encryptedContent: String,
|
||||
val encryptedKey: String,
|
||||
val aesChachaKey: String,
|
||||
val privateKeyHash: String,
|
||||
val messageId: String,
|
||||
val timestamp: Long,
|
||||
val mediaAttachments: List<MessageAttachment>,
|
||||
val isSavedMessages: Boolean
|
||||
)
|
||||
|
||||
class SendMediaMessageUseCase @Inject constructor(
|
||||
private val protocolGateway: ProtocolGateway
|
||||
) {
|
||||
operator fun invoke(command: SendMediaMessageCommand): PacketMessage {
|
||||
val packet =
|
||||
PacketMessage().apply {
|
||||
fromPublicKey = command.fromPublicKey
|
||||
toPublicKey = command.toPublicKey
|
||||
content = command.encryptedContent
|
||||
chachaKey = command.encryptedKey
|
||||
aesChachaKey = command.aesChachaKey
|
||||
privateKey = command.privateKeyHash
|
||||
messageId = command.messageId
|
||||
timestamp = command.timestamp
|
||||
attachments = command.mediaAttachments
|
||||
}
|
||||
|
||||
if (!command.isSavedMessages) {
|
||||
protocolGateway.sendMessageWithRetry(packet)
|
||||
}
|
||||
return packet
|
||||
}
|
||||
|
||||
fun dispatch(packet: PacketMessage, isSavedMessages: Boolean) {
|
||||
if (!isSavedMessages) {
|
||||
protocolGateway.sendMessageWithRetry(packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.rosetta.messenger.domain.chats.usecase
|
||||
|
||||
import com.rosetta.messenger.di.ProtocolGateway
|
||||
import com.rosetta.messenger.network.PacketRead
|
||||
import javax.inject.Inject
|
||||
|
||||
data class SendReadReceiptCommand(
|
||||
val privateKeyHash: String,
|
||||
val fromPublicKey: String,
|
||||
val toPublicKey: String
|
||||
)
|
||||
|
||||
class SendReadReceiptUseCase @Inject constructor(
|
||||
private val protocolGateway: ProtocolGateway
|
||||
) {
|
||||
operator fun invoke(command: SendReadReceiptCommand) {
|
||||
val packet =
|
||||
PacketRead().apply {
|
||||
privateKey = command.privateKeyHash
|
||||
fromPublicKey = command.fromPublicKey
|
||||
toPublicKey = command.toPublicKey
|
||||
}
|
||||
protocolGateway.send(packet)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.rosetta.messenger.domain.chats.usecase
|
||||
|
||||
import com.rosetta.messenger.di.ProtocolGateway
|
||||
import com.rosetta.messenger.network.MessageAttachment
|
||||
import com.rosetta.messenger.network.PacketMessage
|
||||
import javax.inject.Inject
|
||||
|
||||
data class SendTextMessageCommand(
|
||||
val fromPublicKey: String,
|
||||
val toPublicKey: String,
|
||||
val encryptedContent: String,
|
||||
val encryptedKey: String,
|
||||
val aesChachaKey: String,
|
||||
val privateKeyHash: String,
|
||||
val messageId: String,
|
||||
val timestamp: Long,
|
||||
val attachments: List<MessageAttachment> = emptyList(),
|
||||
val isSavedMessages: Boolean
|
||||
)
|
||||
|
||||
class SendTextMessageUseCase @Inject constructor(
|
||||
private val protocolGateway: ProtocolGateway
|
||||
) {
|
||||
operator fun invoke(command: SendTextMessageCommand): PacketMessage {
|
||||
val packet =
|
||||
PacketMessage().apply {
|
||||
fromPublicKey = command.fromPublicKey
|
||||
toPublicKey = command.toPublicKey
|
||||
content = command.encryptedContent
|
||||
chachaKey = command.encryptedKey
|
||||
aesChachaKey = command.aesChachaKey
|
||||
privateKey = command.privateKeyHash
|
||||
messageId = command.messageId
|
||||
timestamp = command.timestamp
|
||||
attachments = command.attachments
|
||||
}
|
||||
|
||||
if (!command.isSavedMessages) {
|
||||
protocolGateway.sendMessageWithRetry(packet)
|
||||
}
|
||||
return packet
|
||||
}
|
||||
|
||||
fun dispatch(packet: PacketMessage, isSavedMessages: Boolean) {
|
||||
if (!isSavedMessages) {
|
||||
protocolGateway.sendMessageWithRetry(packet)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.rosetta.messenger.domain.chats.usecase
|
||||
|
||||
import javax.inject.Inject
|
||||
|
||||
data class SendTypingIndicatorCommand(
|
||||
val nowMs: Long,
|
||||
val lastSentMs: Long,
|
||||
val throttleMs: Long,
|
||||
val opponentPublicKey: String?,
|
||||
val senderPublicKey: String?,
|
||||
val isGroupDialog: Boolean,
|
||||
val isOpponentOnline: Boolean
|
||||
)
|
||||
|
||||
data class SendTypingIndicatorDecision(
|
||||
val shouldSend: Boolean,
|
||||
val nextLastSentMs: Long
|
||||
)
|
||||
|
||||
class SendTypingIndicatorUseCase @Inject constructor() {
|
||||
operator fun invoke(command: SendTypingIndicatorCommand): SendTypingIndicatorDecision {
|
||||
if (command.nowMs - command.lastSentMs < command.throttleMs) {
|
||||
return SendTypingIndicatorDecision(shouldSend = false, nextLastSentMs = command.lastSentMs)
|
||||
}
|
||||
|
||||
val opponent = command.opponentPublicKey?.trim().orEmpty()
|
||||
val sender = command.senderPublicKey?.trim().orEmpty()
|
||||
if (opponent.isBlank() || sender.isBlank()) {
|
||||
return SendTypingIndicatorDecision(shouldSend = false, nextLastSentMs = command.lastSentMs)
|
||||
}
|
||||
|
||||
if (opponent.equals(sender, ignoreCase = true)) {
|
||||
return SendTypingIndicatorDecision(shouldSend = false, nextLastSentMs = command.lastSentMs)
|
||||
}
|
||||
|
||||
if (!command.isGroupDialog && !command.isOpponentOnline) {
|
||||
return SendTypingIndicatorDecision(shouldSend = false, nextLastSentMs = command.lastSentMs)
|
||||
}
|
||||
|
||||
return SendTypingIndicatorDecision(shouldSend = true, nextLastSentMs = command.nowMs)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.rosetta.messenger.domain.chats.usecase
|
||||
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
|
||||
data class SendVoiceMessageCommand(
|
||||
val voiceHex: String,
|
||||
val durationSec: Int,
|
||||
val waves: List<Float>,
|
||||
val maxWaveCount: Int = 120
|
||||
)
|
||||
|
||||
data class VoiceMessagePayload(
|
||||
val normalizedVoiceHex: String,
|
||||
val durationSec: Int,
|
||||
val normalizedWaves: List<Float>,
|
||||
val preview: String
|
||||
)
|
||||
|
||||
class SendVoiceMessageUseCase @Inject constructor() {
|
||||
operator fun invoke(command: SendVoiceMessageCommand): VoiceMessagePayload? {
|
||||
val normalizedVoiceHex = command.voiceHex.trim()
|
||||
if (normalizedVoiceHex.isEmpty()) return null
|
||||
|
||||
val normalizedDuration = command.durationSec.coerceAtLeast(1)
|
||||
val normalizedWaves =
|
||||
command.waves
|
||||
.asSequence()
|
||||
.map { it.coerceIn(0f, 1f) }
|
||||
.take(command.maxWaveCount)
|
||||
.toList()
|
||||
val wavesPreview = normalizedWaves.joinToString(",") { String.format(Locale.US, "%.3f", it) }
|
||||
val preview = "$normalizedDuration::$wavesPreview"
|
||||
|
||||
return VoiceMessagePayload(
|
||||
normalizedVoiceHex = normalizedVoiceHex,
|
||||
durationSec = normalizedDuration,
|
||||
normalizedWaves = normalizedWaves,
|
||||
preview = preview
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.rosetta.messenger.domain.chats.usecase
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.net.Uri
|
||||
import android.webkit.MimeTypeMap
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class ResolveVideoCircleMetaCommand(
|
||||
val context: Context,
|
||||
val videoUri: Uri
|
||||
)
|
||||
|
||||
data class VideoCircleMeta(
|
||||
val durationSec: Int,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val mimeType: String
|
||||
)
|
||||
|
||||
class ResolveVideoCircleMetaUseCase @Inject constructor() {
|
||||
operator fun invoke(command: ResolveVideoCircleMetaCommand): VideoCircleMeta {
|
||||
var durationSec = 1
|
||||
var width = 0
|
||||
var height = 0
|
||||
|
||||
val mimeType =
|
||||
command.context.contentResolver.getType(command.videoUri)?.trim().orEmpty().ifBlank {
|
||||
val ext =
|
||||
MimeTypeMap.getFileExtensionFromUrl(command.videoUri.toString())
|
||||
?.lowercase(Locale.ROOT)
|
||||
.orEmpty()
|
||||
MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: "video/mp4"
|
||||
}
|
||||
|
||||
runCatching {
|
||||
val retriever = MediaMetadataRetriever()
|
||||
retriever.setDataSource(command.context, command.videoUri)
|
||||
val durationMs =
|
||||
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)
|
||||
?.toLongOrNull()
|
||||
?: 0L
|
||||
val rawWidth =
|
||||
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)
|
||||
?.toIntOrNull()
|
||||
?: 0
|
||||
val rawHeight =
|
||||
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)
|
||||
?.toIntOrNull()
|
||||
?: 0
|
||||
val rotation =
|
||||
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)
|
||||
?.toIntOrNull()
|
||||
?: 0
|
||||
retriever.release()
|
||||
|
||||
durationSec = ((durationMs + 999L) / 1000L).toInt().coerceAtLeast(1)
|
||||
val rotated = rotation == 90 || rotation == 270
|
||||
width = if (rotated) rawHeight else rawWidth
|
||||
height = if (rotated) rawWidth else rawHeight
|
||||
}
|
||||
|
||||
return VideoCircleMeta(
|
||||
durationSec = durationSec,
|
||||
width = width.coerceAtLeast(0),
|
||||
height = height.coerceAtLeast(0),
|
||||
mimeType = mimeType
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class EncodeVideoUriToHexCommand(
|
||||
val context: Context,
|
||||
val videoUri: Uri
|
||||
)
|
||||
|
||||
class EncodeVideoUriToHexUseCase @Inject constructor() {
|
||||
suspend operator fun invoke(command: EncodeVideoUriToHexCommand): String? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
command.context.contentResolver.openInputStream(command.videoUri)?.use { stream ->
|
||||
val bytes = stream.readBytes()
|
||||
if (bytes.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
bytesToHex(bytes)
|
||||
}
|
||||
}
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private fun bytesToHex(bytes: ByteArray): String {
|
||||
val hexChars = "0123456789abcdef".toCharArray()
|
||||
val output = CharArray(bytes.size * 2)
|
||||
var index = 0
|
||||
bytes.forEach { byte ->
|
||||
val value = byte.toInt() and 0xFF
|
||||
output[index++] = hexChars[value ushr 4]
|
||||
output[index++] = hexChars[value and 0x0F]
|
||||
}
|
||||
return String(output)
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,13 @@ enum class AttachmentType(val value: Int) {
|
||||
IMAGE(0), // Изображение
|
||||
MESSAGES(1), // Reply (цитата сообщения)
|
||||
FILE(2), // Файл
|
||||
AVATAR(3); // Аватар пользователя
|
||||
AVATAR(3), // Аватар пользователя
|
||||
CALL(4), // Событие звонка (пропущен/принят/завершен)
|
||||
VOICE(5), // Голосовое сообщение
|
||||
VIDEO_CIRCLE(6), // Видео-кружок (video note)
|
||||
UNKNOWN(-1); // Неизвестный тип
|
||||
|
||||
companion object {
|
||||
fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: IMAGE
|
||||
fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Person
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.drawable.IconCompat
|
||||
import com.rosetta.messenger.MainActivity
|
||||
import com.rosetta.messenger.R
|
||||
import com.rosetta.messenger.data.PreferencesManager
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.utils.AvatarFileManager
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
/**
|
||||
* Keeps call alive while app goes to background.
|
||||
* Uses Notification.CallStyle on Android 12+ and NotificationCompat fallback on older APIs.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class CallForegroundService : Service() {
|
||||
|
||||
@Inject lateinit var preferencesManager: PreferencesManager
|
||||
|
||||
private data class Snapshot(
|
||||
val phase: CallPhase,
|
||||
val displayName: String,
|
||||
val statusText: String,
|
||||
val durationSec: Int,
|
||||
val peerPublicKey: String = ""
|
||||
)
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
val action = intent?.action ?: ACTION_SYNC
|
||||
CallManager.initialize(applicationContext)
|
||||
val phaseNow = CallManager.state.value.phase
|
||||
notifLog("onStartCommand action=$action phase=$phaseNow")
|
||||
|
||||
when (action) {
|
||||
ACTION_STOP -> {
|
||||
if (phaseNow == CallPhase.IDLE) {
|
||||
notifLog("ACTION_STOP → stopSelf")
|
||||
safeStopForeground()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
// Может прилететь поздний STOP от прошлой сессии, не глушим живой звонок.
|
||||
notifLog("ACTION_STOP ignored: phase=$phaseNow")
|
||||
}
|
||||
ACTION_END -> {
|
||||
notifLog("ACTION_END → endCall")
|
||||
CallManager.endCall()
|
||||
safeStopForeground()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
ACTION_DECLINE -> {
|
||||
val phase = CallManager.state.value.phase
|
||||
notifLog("ACTION_DECLINE phase=$phase")
|
||||
if (phase == CallPhase.INCOMING) {
|
||||
CallManager.declineIncomingCall()
|
||||
} else {
|
||||
CallManager.endCall()
|
||||
}
|
||||
safeStopForeground()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
ACTION_ACCEPT -> {
|
||||
notifLog("ACTION_ACCEPT → acceptIncomingCall phase=${CallManager.state.value.phase}")
|
||||
// Если push пришёл раньше WebSocket — CallManager ещё в IDLE.
|
||||
// Ждём до 5 сек пока реальный CALL сигнал придёт по WebSocket.
|
||||
CoroutineScope(Dispatchers.Main).launch {
|
||||
var accepted = false
|
||||
for (i in 1..50) { // 50 * 100ms = 5 sec
|
||||
val phase = CallManager.state.value.phase
|
||||
if (phase == CallPhase.INCOMING) {
|
||||
val result = CallManager.acceptIncomingCall()
|
||||
notifLog("ACTION_ACCEPT attempt #$i result=$result")
|
||||
if (result == CallActionResult.STARTED) {
|
||||
openCallUi()
|
||||
notifLog("ACTION_ACCEPT → openCallUi()")
|
||||
accepted = true
|
||||
}
|
||||
break
|
||||
} else if (phase != CallPhase.IDLE) {
|
||||
notifLog("ACTION_ACCEPT phase=$phase (not INCOMING/IDLE), opening UI")
|
||||
openCallUi()
|
||||
accepted = true
|
||||
break
|
||||
}
|
||||
delay(100)
|
||||
}
|
||||
if (!accepted) {
|
||||
notifLog("ACTION_ACCEPT: timed out waiting for INCOMING, phase=${CallManager.state.value.phase}")
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
|
||||
val snapshot = extractSnapshot(intent)
|
||||
notifLog("snapshot: phase=${snapshot.phase} name=${snapshot.displayName} status=${snapshot.statusText}")
|
||||
if (snapshot.phase == CallPhase.IDLE) {
|
||||
notifLog("phase=IDLE → stopSelf")
|
||||
stopForegroundCompat()
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
ensureNotificationChannel()
|
||||
val notification = buildNotification(snapshot)
|
||||
val hasFullScreen = snapshot.phase == CallPhase.INCOMING
|
||||
notifLog("buildNotification OK, hasFullScreenIntent=$hasFullScreen, starting foreground")
|
||||
startForegroundCompat(notification, snapshot.phase)
|
||||
notifLog("startForeground OK, phase=${snapshot.phase}")
|
||||
|
||||
// Проверяем canUseFullScreenIntent на Android 14+
|
||||
if (Build.VERSION.SDK_INT >= 34) {
|
||||
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
|
||||
val canFsi = nm.canUseFullScreenIntent()
|
||||
notifLog("Android 14+: canUseFullScreenIntent=$canFsi")
|
||||
}
|
||||
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
private fun extractSnapshot(intent: Intent?): Snapshot {
|
||||
val state = CallManager.state.value
|
||||
val payloadIntent = intent
|
||||
if (payloadIntent == null || !payloadIntent.hasExtra(EXTRA_PHASE)) {
|
||||
return Snapshot(
|
||||
phase = state.phase,
|
||||
displayName = state.displayName,
|
||||
statusText = state.statusText,
|
||||
durationSec = state.durationSec,
|
||||
peerPublicKey = state.peerPublicKey
|
||||
)
|
||||
}
|
||||
|
||||
val rawPhase = payloadIntent.getStringExtra(EXTRA_PHASE).orEmpty()
|
||||
val phase = runCatching { CallPhase.valueOf(rawPhase) }.getOrElse { state.phase }
|
||||
val displayName =
|
||||
payloadIntent.getStringExtra(EXTRA_DISPLAY_NAME)
|
||||
.orEmpty()
|
||||
.ifBlank { state.displayName }
|
||||
.ifBlank { "Unknown" }
|
||||
val statusText = payloadIntent.getStringExtra(EXTRA_STATUS_TEXT).orEmpty().ifBlank { state.statusText }
|
||||
val durationSec = payloadIntent.getIntExtra(EXTRA_DURATION_SEC, state.durationSec)
|
||||
val peerPublicKey = payloadIntent.getStringExtra(EXTRA_PEER_PUBLIC_KEY)
|
||||
.orEmpty()
|
||||
.ifBlank { state.peerPublicKey }
|
||||
return Snapshot(
|
||||
phase = phase,
|
||||
displayName = displayName,
|
||||
statusText = statusText,
|
||||
durationSec = durationSec.coerceAtLeast(0),
|
||||
peerPublicKey = peerPublicKey
|
||||
)
|
||||
}
|
||||
|
||||
private fun ensureNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
|
||||
val channel =
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Calls",
|
||||
NotificationManager.IMPORTANCE_MAX
|
||||
).apply {
|
||||
description = "Incoming and ongoing calls"
|
||||
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||
setShowBadge(false)
|
||||
enableVibration(true)
|
||||
vibrationPattern = longArrayOf(0, 1000, 500, 1000)
|
||||
setBypassDnd(true)
|
||||
}
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun buildNotification(snapshot: Snapshot): Notification {
|
||||
// При INCOMING — нажатие открывает IncomingCallActivity (полноэкранный звонок)
|
||||
// При остальных фазах — открывает MainActivity
|
||||
val contentActivity = if (snapshot.phase == CallPhase.INCOMING) {
|
||||
com.rosetta.messenger.IncomingCallActivity::class.java
|
||||
} else {
|
||||
MainActivity::class.java
|
||||
}
|
||||
val openAppPendingIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
REQUEST_OPEN_APP,
|
||||
Intent(this, contentActivity).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
if (contentActivity == MainActivity::class.java) {
|
||||
putExtra(EXTRA_OPEN_CALL_FROM_NOTIFICATION, true)
|
||||
}
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
val endCallPendingIntent = PendingIntent.getService(
|
||||
this,
|
||||
REQUEST_END_CALL,
|
||||
Intent(this, CallForegroundService::class.java).setAction(ACTION_END),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
val declinePendingIntent = PendingIntent.getService(
|
||||
this,
|
||||
REQUEST_DECLINE_CALL,
|
||||
Intent(this, CallForegroundService::class.java).setAction(ACTION_DECLINE),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
val answerPendingIntent = PendingIntent.getService(
|
||||
this,
|
||||
REQUEST_ACCEPT_CALL,
|
||||
Intent(this, CallForegroundService::class.java).setAction(ACTION_ACCEPT),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
// fullScreenIntent открывает лёгкую IncomingCallActivity поверх lock screen
|
||||
val fullScreenPendingIntent = if (snapshot.phase == CallPhase.INCOMING) {
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
REQUEST_FULL_SCREEN,
|
||||
Intent(this, com.rosetta.messenger.IncomingCallActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
} else null
|
||||
|
||||
val defaultStatus =
|
||||
when (snapshot.phase) {
|
||||
CallPhase.INCOMING -> "Incoming call"
|
||||
CallPhase.OUTGOING -> "Calling"
|
||||
CallPhase.CONNECTING -> "Connecting"
|
||||
CallPhase.ACTIVE -> "Call in progress"
|
||||
CallPhase.IDLE -> "Call ended"
|
||||
}
|
||||
val contentText = snapshot.statusText.ifBlank { defaultStatus }
|
||||
val avatarBitmap = loadAvatarBitmap(snapshot.peerPublicKey)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val personBuilder = Person.Builder().setName(snapshot.displayName).setImportant(true)
|
||||
if (avatarBitmap != null) {
|
||||
personBuilder.setIcon(Icon.createWithBitmap(avatarBitmap))
|
||||
}
|
||||
val person = personBuilder.build()
|
||||
val style =
|
||||
if (snapshot.phase == CallPhase.INCOMING) {
|
||||
Notification.CallStyle.forIncomingCall(
|
||||
person,
|
||||
declinePendingIntent,
|
||||
answerPendingIntent
|
||||
)
|
||||
} else {
|
||||
Notification.CallStyle.forOngoingCall(person, endCallPendingIntent)
|
||||
}
|
||||
|
||||
return Notification.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(snapshot.displayName)
|
||||
.setContentText(contentText)
|
||||
.setContentIntent(openAppPendingIntent)
|
||||
.setOngoing(true)
|
||||
.setCategory(Notification.CATEGORY_CALL)
|
||||
.setVisibility(Notification.VISIBILITY_PUBLIC)
|
||||
.setStyle(style)
|
||||
.setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.apply {
|
||||
if (fullScreenPendingIntent != null) {
|
||||
setFullScreenIntent(fullScreenPendingIntent, true)
|
||||
}
|
||||
}
|
||||
.apply {
|
||||
if (snapshot.phase == CallPhase.ACTIVE) {
|
||||
setUsesChronometer(true)
|
||||
setWhen(System.currentTimeMillis() - snapshot.durationSec * 1000L)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle(snapshot.displayName)
|
||||
.apply { if (avatarBitmap != null) setLargeIcon(avatarBitmap) }
|
||||
.setContentText(contentText)
|
||||
.setContentIntent(openAppPendingIntent)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setOngoing(true)
|
||||
.apply {
|
||||
if (fullScreenPendingIntent != null) {
|
||||
setFullScreenIntent(fullScreenPendingIntent, true)
|
||||
}
|
||||
}
|
||||
.apply {
|
||||
if (snapshot.phase == CallPhase.INCOMING) {
|
||||
addAction(android.R.drawable.ic_menu_call, "Answer", answerPendingIntent)
|
||||
addAction(android.R.drawable.ic_menu_close_clear_cancel, "Decline", declinePendingIntent)
|
||||
} else {
|
||||
addAction(android.R.drawable.ic_menu_close_clear_cancel, "End", endCallPendingIntent)
|
||||
}
|
||||
}
|
||||
.apply {
|
||||
if (snapshot.phase == CallPhase.ACTIVE) {
|
||||
setUsesChronometer(true)
|
||||
setWhen(System.currentTimeMillis() - snapshot.durationSec * 1000L)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun startForegroundCompat(notification: Notification, phase: CallPhase) {
|
||||
val started =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
val preferredType =
|
||||
when (phase) {
|
||||
CallPhase.INCOMING -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
|
||||
CallPhase.OUTGOING,
|
||||
CallPhase.CONNECTING,
|
||||
CallPhase.ACTIVE -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE or ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
|
||||
CallPhase.IDLE -> ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
|
||||
}
|
||||
startForegroundTyped(notification, preferredType) ||
|
||||
startForegroundTyped(notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) ||
|
||||
startForegroundUntyped(notification)
|
||||
} else {
|
||||
startForegroundUntyped(notification)
|
||||
}
|
||||
|
||||
if (!started) {
|
||||
Log.e(TAG, "Failed to start foreground service safely; stopping service")
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startForegroundTyped(notification: Notification, type: Int): Boolean {
|
||||
return try {
|
||||
startForeground(NOTIFICATION_ID, notification, type)
|
||||
notifLog("startForeground OK type=$type")
|
||||
true
|
||||
} catch (error: Throwable) {
|
||||
notifLog("startForeground FAILED type=$type: ${error.message}")
|
||||
Log.w(TAG, "Typed startForeground failed (type=$type): ${error.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun startForegroundUntyped(notification: Notification): Boolean {
|
||||
return try {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
notifLog("startForeground (untyped) OK")
|
||||
true
|
||||
} catch (error: Throwable) {
|
||||
notifLog("startForeground (untyped) FAILED: ${error.message}")
|
||||
Log.w(TAG, "Untyped startForeground failed: ${error.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun stopForegroundCompat() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
} else {
|
||||
stopForeground(true)
|
||||
}
|
||||
}
|
||||
|
||||
/** Безопасная остановка: startForeground → stopForeground → stopSelf.
|
||||
* Предотвращает ForegroundServiceDidNotStartInTimeException. */
|
||||
private fun safeStopForeground() {
|
||||
ensureNotificationChannel()
|
||||
try {
|
||||
startForeground(NOTIFICATION_ID, buildPlaceholderNotification())
|
||||
} catch (_: Throwable) {}
|
||||
stopForegroundCompat()
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun buildPlaceholderNotification(): Notification {
|
||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_notification)
|
||||
.setContentTitle("Rosetta")
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setSilent(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "CallForegroundService"
|
||||
private const val CHANNEL_ID = "rosetta_calls"
|
||||
private const val NOTIFICATION_ID = 9010
|
||||
private const val REQUEST_OPEN_APP = 9011
|
||||
private const val REQUEST_END_CALL = 9012
|
||||
private const val REQUEST_DECLINE_CALL = 9013
|
||||
private const val REQUEST_ACCEPT_CALL = 9014
|
||||
private const val REQUEST_FULL_SCREEN = 9015
|
||||
private const val NOTIF_LOG_FILE = "call_notification_log.txt"
|
||||
|
||||
private const val ACTION_SYNC = "com.rosetta.messenger.call.ACTION_SYNC"
|
||||
private const val ACTION_END = "com.rosetta.messenger.call.ACTION_END"
|
||||
private const val ACTION_DECLINE = "com.rosetta.messenger.call.ACTION_DECLINE"
|
||||
private const val ACTION_ACCEPT = "com.rosetta.messenger.call.ACTION_ACCEPT"
|
||||
private const val ACTION_STOP = "com.rosetta.messenger.call.ACTION_STOP"
|
||||
|
||||
private const val EXTRA_PHASE = "extra_phase"
|
||||
private const val EXTRA_DISPLAY_NAME = "extra_display_name"
|
||||
private const val EXTRA_STATUS_TEXT = "extra_status_text"
|
||||
private const val EXTRA_DURATION_SEC = "extra_duration_sec"
|
||||
private const val EXTRA_PEER_PUBLIC_KEY = "extra_peer_public_key"
|
||||
const val EXTRA_OPEN_CALL_FROM_NOTIFICATION = "extra_open_call_from_notification"
|
||||
|
||||
fun syncWithCallState(context: Context, state: CallUiState) {
|
||||
val appContext = context.applicationContext
|
||||
if (state.phase == CallPhase.IDLE) {
|
||||
// Используем ACTION_STOP вместо stopService — он вызовет safeStopForeground
|
||||
val stopIntent = Intent(appContext, CallForegroundService::class.java).setAction(ACTION_STOP)
|
||||
runCatching { appContext.startService(stopIntent) }
|
||||
.onFailure { appContext.stopService(Intent(appContext, CallForegroundService::class.java)) }
|
||||
return
|
||||
}
|
||||
|
||||
val intent =
|
||||
Intent(appContext, CallForegroundService::class.java)
|
||||
.setAction(ACTION_SYNC)
|
||||
.putExtra(EXTRA_PHASE, state.phase.name)
|
||||
.putExtra(EXTRA_DISPLAY_NAME, state.displayName)
|
||||
.putExtra(EXTRA_STATUS_TEXT, state.statusText)
|
||||
.putExtra(EXTRA_DURATION_SEC, state.durationSec)
|
||||
.putExtra(EXTRA_PEER_PUBLIC_KEY, state.peerPublicKey)
|
||||
|
||||
runCatching { ContextCompat.startForegroundService(appContext, intent) }
|
||||
.onFailure { error ->
|
||||
Log.w(TAG, "Failed to start foreground service: ${error.message}")
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
val appContext = context.applicationContext
|
||||
val intent = Intent(appContext, CallForegroundService::class.java).setAction(ACTION_STOP)
|
||||
runCatching { appContext.startService(intent) }
|
||||
.onFailure {
|
||||
appContext.stopService(Intent(appContext, CallForegroundService::class.java))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAvatarBitmap(publicKey: String): Bitmap? {
|
||||
if (publicKey.isBlank()) return null
|
||||
// Проверяем настройку
|
||||
val avatarEnabled = runCatching {
|
||||
runBlocking(Dispatchers.IO) {
|
||||
preferencesManager.notificationAvatarEnabled.first()
|
||||
}
|
||||
}.getOrDefault(true)
|
||||
if (!avatarEnabled) return null
|
||||
return runCatching {
|
||||
val db = RosettaDatabase.getDatabase(applicationContext)
|
||||
val entity = runBlocking(Dispatchers.IO) {
|
||||
db.avatarDao().getLatestAvatarByKeys(listOf(publicKey))
|
||||
} ?: return null
|
||||
val rawBase64 = AvatarFileManager.readAvatar(applicationContext, entity.avatar)
|
||||
?: return null
|
||||
val base64 = if (rawBase64.contains(",")) rawBase64.substringAfter(",") else rawBase64
|
||||
val bytes = Base64.decode(base64, Base64.DEFAULT)
|
||||
val original = BitmapFactory.decodeByteArray(bytes, 0, bytes.size) ?: return null
|
||||
toCircleBitmap(original)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun toCircleBitmap(source: Bitmap): Bitmap {
|
||||
val size = minOf(source.width, source.height)
|
||||
val output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
|
||||
val canvas = android.graphics.Canvas(output)
|
||||
val paint = android.graphics.Paint().apply { isAntiAlias = true }
|
||||
val rect = android.graphics.Rect(0, 0, size, size)
|
||||
canvas.drawARGB(0, 0, 0, 0)
|
||||
canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
|
||||
paint.xfermode = android.graphics.PorterDuffXfermode(android.graphics.PorterDuff.Mode.SRC_IN)
|
||||
canvas.drawBitmap(source, rect, rect, paint)
|
||||
return output
|
||||
}
|
||||
|
||||
private fun openCallUi() {
|
||||
notifLog("openCallUi → MainActivity")
|
||||
val intent =
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||
putExtra(EXTRA_OPEN_CALL_FROM_NOTIFICATION, true)
|
||||
}
|
||||
runCatching { startActivity(intent) }
|
||||
.onSuccess { notifLog("openCallUi → started OK") }
|
||||
.onFailure { error ->
|
||||
notifLog("openCallUi FAILED: ${error.message}")
|
||||
Log.w(TAG, "Failed to open call UI: ${error.message}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun openIncomingCallUi() {
|
||||
notifLog("openIncomingCallUi → IncomingCallActivity")
|
||||
val intent =
|
||||
Intent(this, com.rosetta.messenger.IncomingCallActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP
|
||||
}
|
||||
runCatching { startActivity(intent) }
|
||||
.onSuccess { notifLog("openIncomingCallUi → started OK") }
|
||||
.onFailure { error ->
|
||||
notifLog("openIncomingCallUi FAILED: ${error.message}")
|
||||
Log.w(TAG, "Failed to open incoming call UI: ${error.message}")
|
||||
}
|
||||
}
|
||||
|
||||
/** Пишет лог в crash_reports/call_notification_log.txt — виден через rosettadev1 */
|
||||
private fun notifLog(msg: String) {
|
||||
Log.d(TAG, msg)
|
||||
try {
|
||||
val dir = java.io.File(applicationContext.filesDir, "crash_reports")
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
val f = java.io.File(dir, NOTIF_LOG_FILE)
|
||||
// Ограничиваем размер файла — перезаписываем если больше 100KB
|
||||
if (f.exists() && f.length() > 100_000) f.writeText("")
|
||||
val ts = java.text.SimpleDateFormat("HH:mm:ss.SSS", java.util.Locale.getDefault()).format(java.util.Date())
|
||||
f.appendText("$ts $msg\n")
|
||||
} catch (_: Throwable) {}
|
||||
}
|
||||
}
|
||||
1764
app/src/main/java/com/rosetta/messenger/network/CallManager.kt
Normal file
1764
app/src/main/java/com/rosetta/messenger/network/CallManager.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,140 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Controls proximity screen-off behavior during active calls.
|
||||
* Uses PROXIMITY_SCREEN_OFF_WAKE_LOCK to mimic phone-call UX.
|
||||
*/
|
||||
object CallProximityManager : SensorEventListener {
|
||||
|
||||
private const val TAG = "CallProximityManager"
|
||||
private const val WAKE_LOCK_TAG = "Rosetta:CallProximity"
|
||||
|
||||
private val lock = Any()
|
||||
|
||||
private var sensorManager: SensorManager? = null
|
||||
private var proximitySensor: Sensor? = null
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
|
||||
private var enabled: Boolean = false
|
||||
private var listenerRegistered: Boolean = false
|
||||
private var lastNearState: Boolean? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
synchronized(lock) {
|
||||
if (sensorManager != null) return
|
||||
val app = context.applicationContext
|
||||
|
||||
sensorManager = app.getSystemService(Context.SENSOR_SERVICE) as? SensorManager
|
||||
proximitySensor = sensorManager?.getDefaultSensor(Sensor.TYPE_PROXIMITY)
|
||||
|
||||
val powerManager = app.getSystemService(Context.POWER_SERVICE) as? PowerManager
|
||||
val wakeSupported =
|
||||
powerManager?.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK) == true
|
||||
wakeLock =
|
||||
if (wakeSupported) {
|
||||
powerManager
|
||||
?.newWakeLock(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK, WAKE_LOCK_TAG)
|
||||
?.apply { setReferenceCounted(false) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
Log.i(
|
||||
TAG,
|
||||
"initialize: sensor=${proximitySensor != null} wakeLockSupported=$wakeSupported"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setEnabled(context: Context, shouldEnable: Boolean) {
|
||||
initialize(context)
|
||||
synchronized(lock) {
|
||||
if (enabled == shouldEnable) return
|
||||
enabled = shouldEnable
|
||||
if (shouldEnable) {
|
||||
registerListenerLocked()
|
||||
} else {
|
||||
unregisterListenerLocked()
|
||||
releaseWakeLockLocked()
|
||||
lastNearState = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
synchronized(lock) {
|
||||
enabled = false
|
||||
unregisterListenerLocked()
|
||||
releaseWakeLockLocked()
|
||||
lastNearState = null
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent?) {
|
||||
val ev = event ?: return
|
||||
val near = isNear(ev)
|
||||
synchronized(lock) {
|
||||
if (!enabled) return
|
||||
if (lastNearState == near) return
|
||||
lastNearState = near
|
||||
if (near) {
|
||||
acquireWakeLockLocked()
|
||||
} else {
|
||||
releaseWakeLockLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) = Unit
|
||||
|
||||
private fun registerListenerLocked() {
|
||||
if (listenerRegistered) return
|
||||
val sm = sensorManager
|
||||
val sensor = proximitySensor
|
||||
if (sm == null || sensor == null) {
|
||||
Log.w(TAG, "register skipped: no proximity sensor")
|
||||
return
|
||||
}
|
||||
listenerRegistered = sm.registerListener(this, sensor, SensorManager.SENSOR_DELAY_NORMAL)
|
||||
Log.i(TAG, "registerListener: ok=$listenerRegistered")
|
||||
}
|
||||
|
||||
private fun unregisterListenerLocked() {
|
||||
if (!listenerRegistered) return
|
||||
runCatching { sensorManager?.unregisterListener(this) }
|
||||
listenerRegistered = false
|
||||
Log.i(TAG, "unregisterListener")
|
||||
}
|
||||
|
||||
private fun acquireWakeLockLocked() {
|
||||
val wl = wakeLock ?: return
|
||||
if (wl.isHeld) return
|
||||
runCatching { wl.acquire() }
|
||||
.onSuccess { Log.i(TAG, "wakeLock acquired (near)") }
|
||||
.onFailure { Log.w(TAG, "wakeLock acquire failed: ${it.message}") }
|
||||
}
|
||||
|
||||
private fun releaseWakeLockLocked() {
|
||||
val wl = wakeLock ?: return
|
||||
if (!wl.isHeld) return
|
||||
runCatching { wl.release() }
|
||||
.onSuccess { Log.i(TAG, "wakeLock released (far/disabled)") }
|
||||
.onFailure { Log.w(TAG, "wakeLock release failed: ${it.message}") }
|
||||
}
|
||||
|
||||
private fun isNear(event: SensorEvent): Boolean {
|
||||
val value = event.values.firstOrNull() ?: return false
|
||||
val maxRange = event.sensor.maximumRange
|
||||
// Treat as "near" if below max range and below a common 5cm threshold.
|
||||
return value < maxRange && value < 5f
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioManager
|
||||
import android.media.MediaPlayer
|
||||
import android.os.Build
|
||||
import android.os.VibrationEffect
|
||||
import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import android.util.Log
|
||||
import com.rosetta.messenger.R
|
||||
|
||||
/**
|
||||
* Manages call sounds (ringtone, calling, connected, end_call).
|
||||
* Matches desktop CallProvider.tsx sound behavior.
|
||||
*/
|
||||
object CallSoundManager {
|
||||
|
||||
private const val TAG = "CallSoundManager"
|
||||
|
||||
private var mediaPlayer: MediaPlayer? = null
|
||||
private var vibrator: Vibrator? = null
|
||||
private var currentSound: CallSound? = null
|
||||
|
||||
enum class CallSound {
|
||||
RINGTONE, // Incoming call — loops
|
||||
CALLING, // Outgoing call — loops
|
||||
CONNECTED, // Call connected — plays once
|
||||
END_CALL // Call ended — plays once
|
||||
}
|
||||
|
||||
fun initialize(context: Context) {
|
||||
vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
val vm = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager
|
||||
vm?.defaultVibrator
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
context.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a call sound. Stops any currently playing sound first.
|
||||
* RINGTONE and CALLING loop. CONNECTED and END_CALL play once.
|
||||
*/
|
||||
fun play(context: Context, sound: CallSound) {
|
||||
stop()
|
||||
currentSound = sound
|
||||
|
||||
val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as? AudioManager
|
||||
val ringerMode = audioManager?.ringerMode ?: AudioManager.RINGER_MODE_NORMAL
|
||||
val allowAudible = ringerMode == AudioManager.RINGER_MODE_NORMAL
|
||||
val allowVibration =
|
||||
sound == CallSound.RINGTONE &&
|
||||
(ringerMode == AudioManager.RINGER_MODE_NORMAL ||
|
||||
ringerMode == AudioManager.RINGER_MODE_VIBRATE)
|
||||
|
||||
if (!allowAudible) {
|
||||
if (allowVibration) {
|
||||
startVibration()
|
||||
}
|
||||
Log.i(TAG, "Skip audible $sound due to ringerMode=$ringerMode")
|
||||
return
|
||||
}
|
||||
|
||||
val resId = when (sound) {
|
||||
CallSound.RINGTONE -> R.raw.call_ringtone
|
||||
CallSound.CALLING -> R.raw.call_calling
|
||||
CallSound.CONNECTED -> R.raw.call_connected
|
||||
CallSound.END_CALL -> R.raw.call_end
|
||||
}
|
||||
|
||||
val loop = sound == CallSound.RINGTONE || sound == CallSound.CALLING
|
||||
|
||||
try {
|
||||
val player = MediaPlayer.create(context, resId)
|
||||
if (player == null) {
|
||||
Log.e(TAG, "Failed to create MediaPlayer for $sound")
|
||||
return
|
||||
}
|
||||
|
||||
player.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(
|
||||
if (sound == CallSound.RINGTONE)
|
||||
AudioAttributes.USAGE_NOTIFICATION_RINGTONE
|
||||
else
|
||||
AudioAttributes.USAGE_VOICE_COMMUNICATION_SIGNALLING
|
||||
)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
|
||||
.build()
|
||||
)
|
||||
|
||||
player.isLooping = loop
|
||||
player.setOnCompletionListener {
|
||||
if (!loop) {
|
||||
stop()
|
||||
}
|
||||
}
|
||||
player.start()
|
||||
mediaPlayer = player
|
||||
|
||||
// Vibrate for incoming calls
|
||||
if (allowVibration) {
|
||||
startVibration()
|
||||
}
|
||||
|
||||
Log.i(TAG, "Playing $sound (loop=$loop)")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error playing $sound", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop any currently playing sound and vibration.
|
||||
*/
|
||||
fun stop() {
|
||||
try {
|
||||
mediaPlayer?.let { player ->
|
||||
if (player.isPlaying) player.stop()
|
||||
player.release()
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
mediaPlayer = null
|
||||
currentSound = null
|
||||
stopVibration()
|
||||
}
|
||||
|
||||
private fun startVibration() {
|
||||
try {
|
||||
val v = vibrator ?: return
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val pattern = longArrayOf(0, 500, 300, 500, 300, 500, 1000)
|
||||
v.vibrate(VibrationEffect.createWaveform(pattern, 0))
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
v.vibrate(longArrayOf(0, 500, 300, 500, 300, 500, 1000), 0)
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
|
||||
private fun stopVibration() {
|
||||
try {
|
||||
vibrator?.cancel()
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import android.content.Context
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import com.rosetta.messenger.crypto.MessageCrypto
|
||||
import kotlinx.coroutines.*
|
||||
@@ -20,6 +19,7 @@ data class FileDownloadState(
|
||||
enum class FileDownloadStatus {
|
||||
QUEUED,
|
||||
DOWNLOADING,
|
||||
PAUSED,
|
||||
DECRYPTING,
|
||||
DONE,
|
||||
ERROR
|
||||
@@ -35,6 +35,29 @@ object FileDownloadManager {
|
||||
|
||||
/** Текущие Job'ы — чтобы не запускать повторно */
|
||||
private val jobs = mutableMapOf<String, Job>()
|
||||
/** Последние параметры скачивания — нужны для resume */
|
||||
private val requests = mutableMapOf<String, DownloadRequest>()
|
||||
/** Флаг, что cancel произошёл именно как user pause */
|
||||
private val pauseRequested = mutableSetOf<String>()
|
||||
/** Если пользователь очень быстро тапнул pause→resume, запускаем resume после фактической паузы */
|
||||
private val resumeAfterPause = mutableSetOf<String>()
|
||||
|
||||
private data class DownloadRequest(
|
||||
val attachmentId: String,
|
||||
val downloadTag: String,
|
||||
val transportServer: String,
|
||||
val chachaKey: String,
|
||||
val privateKey: String,
|
||||
val accountPublicKey: String,
|
||||
val fileName: String,
|
||||
val savedFile: File
|
||||
)
|
||||
|
||||
private fun encryptedPartFile(request: DownloadRequest): File {
|
||||
val parent = request.savedFile.parentFile ?: request.savedFile.absoluteFile.parentFile
|
||||
val safeId = request.attachmentId.take(32).replace(Regex("[^A-Za-z0-9._-]"), "_")
|
||||
return File(parent, ".dl_${safeId}.part")
|
||||
}
|
||||
|
||||
// ─── helpers ───
|
||||
|
||||
@@ -67,9 +90,16 @@ object FileDownloadManager {
|
||||
*/
|
||||
fun isDownloading(attachmentId: String): Boolean {
|
||||
val state = _downloads.value[attachmentId] ?: return false
|
||||
return state.status == FileDownloadStatus.DOWNLOADING || state.status == FileDownloadStatus.DECRYPTING
|
||||
return state.status == FileDownloadStatus.QUEUED ||
|
||||
state.status == FileDownloadStatus.DOWNLOADING ||
|
||||
state.status == FileDownloadStatus.DECRYPTING
|
||||
}
|
||||
|
||||
fun isPaused(attachmentId: String): Boolean =
|
||||
_downloads.value[attachmentId]?.status == FileDownloadStatus.PAUSED
|
||||
|
||||
fun stateOf(attachmentId: String): FileDownloadState? = _downloads.value[attachmentId]
|
||||
|
||||
/**
|
||||
* Возвращает Flow<FileDownloadState?> для конкретного attachment
|
||||
*/
|
||||
@@ -81,154 +111,272 @@ object FileDownloadManager {
|
||||
* Скачивание продолжается даже если пользователь вышел из чата.
|
||||
*/
|
||||
fun download(
|
||||
context: Context,
|
||||
attachmentId: String,
|
||||
downloadTag: String,
|
||||
transportServer: String = "",
|
||||
chachaKey: String,
|
||||
privateKey: String,
|
||||
accountPublicKey: String,
|
||||
fileName: String,
|
||||
savedFile: File
|
||||
) {
|
||||
// Уже в процессе?
|
||||
if (jobs[attachmentId]?.isActive == true) return
|
||||
val normalizedAccount = accountPublicKey.trim()
|
||||
val savedPath = savedFile.absolutePath
|
||||
val request = DownloadRequest(
|
||||
attachmentId = attachmentId,
|
||||
downloadTag = downloadTag,
|
||||
transportServer = transportServer.trim(),
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
accountPublicKey = accountPublicKey.trim(),
|
||||
fileName = fileName,
|
||||
savedFile = savedFile
|
||||
)
|
||||
requests[attachmentId] = request
|
||||
startDownload(request)
|
||||
}
|
||||
|
||||
update(attachmentId, fileName, FileDownloadStatus.QUEUED, 0f, normalizedAccount, savedPath)
|
||||
fun pause(attachmentId: String) {
|
||||
val current = _downloads.value[attachmentId] ?: return
|
||||
if (
|
||||
current.status == FileDownloadStatus.DONE ||
|
||||
current.status == FileDownloadStatus.ERROR
|
||||
) return
|
||||
|
||||
jobs[attachmentId] = scope.launch {
|
||||
try {
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
FileDownloadStatus.DOWNLOADING,
|
||||
0f,
|
||||
normalizedAccount,
|
||||
savedPath
|
||||
)
|
||||
pauseRequested.add(attachmentId)
|
||||
val pausedProgress = current.progress.coerceIn(0f, 0.98f)
|
||||
update(
|
||||
id = attachmentId,
|
||||
fileName = current.fileName,
|
||||
status = FileDownloadStatus.PAUSED,
|
||||
progress = pausedProgress,
|
||||
accountPublicKey = current.accountPublicKey,
|
||||
savedPath = current.savedPath
|
||||
)
|
||||
|
||||
// Запускаем polling прогресса из TransportManager
|
||||
val progressJob = launch {
|
||||
TransportManager.downloading.collect { list ->
|
||||
val entry = list.find { it.id == attachmentId }
|
||||
if (entry != null) {
|
||||
// CDN progress: 0-100 → 0f..0.8f (80% круга — скачивание)
|
||||
val p = (entry.progress / 100f) * 0.8f
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
FileDownloadStatus.DOWNLOADING,
|
||||
p,
|
||||
normalizedAccount,
|
||||
savedPath
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
TransportManager.cancelDownload(attachmentId)
|
||||
jobs[attachmentId]?.cancel()
|
||||
}
|
||||
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
if (isGroupStoredKey(chachaKey)) {
|
||||
downloadGroupFile(
|
||||
attachmentId = attachmentId,
|
||||
downloadTag = downloadTag,
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
fileName = fileName,
|
||||
savedFile = savedFile,
|
||||
accountPublicKey = normalizedAccount,
|
||||
savedPath = savedPath
|
||||
)
|
||||
} else {
|
||||
downloadDirectFile(
|
||||
attachmentId = attachmentId,
|
||||
downloadTag = downloadTag,
|
||||
chachaKey = chachaKey,
|
||||
privateKey = privateKey,
|
||||
fileName = fileName,
|
||||
savedFile = savedFile,
|
||||
accountPublicKey = normalizedAccount,
|
||||
savedPath = savedPath
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
progressJob.cancel()
|
||||
|
||||
if (success) {
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
FileDownloadStatus.DONE,
|
||||
1f,
|
||||
normalizedAccount,
|
||||
savedPath
|
||||
)
|
||||
} else {
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
FileDownloadStatus.ERROR,
|
||||
0f,
|
||||
normalizedAccount,
|
||||
savedPath
|
||||
)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
FileDownloadStatus.ERROR,
|
||||
0f,
|
||||
normalizedAccount,
|
||||
savedPath
|
||||
)
|
||||
} catch (_: OutOfMemoryError) {
|
||||
System.gc()
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
FileDownloadStatus.ERROR,
|
||||
0f,
|
||||
normalizedAccount,
|
||||
savedPath
|
||||
)
|
||||
} finally {
|
||||
jobs.remove(attachmentId)
|
||||
// Автоочистка через 5 секунд после завершения
|
||||
scope.launch {
|
||||
delay(5000)
|
||||
_downloads.update { it - attachmentId }
|
||||
}
|
||||
}
|
||||
fun resume(attachmentId: String) {
|
||||
val request = requests[attachmentId] ?: return
|
||||
if (jobs[attachmentId]?.isActive == true) {
|
||||
resumeAfterPause.add(attachmentId)
|
||||
return
|
||||
}
|
||||
pauseRequested.remove(attachmentId)
|
||||
startDownload(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Отменяет скачивание
|
||||
*/
|
||||
fun cancel(attachmentId: String) {
|
||||
requests[attachmentId]?.let { req ->
|
||||
encryptedPartFile(req).delete()
|
||||
}
|
||||
pauseRequested.remove(attachmentId)
|
||||
resumeAfterPause.remove(attachmentId)
|
||||
requests.remove(attachmentId)
|
||||
TransportManager.cancelDownload(attachmentId)
|
||||
jobs[attachmentId]?.cancel()
|
||||
jobs.remove(attachmentId)
|
||||
_downloads.update { it - attachmentId }
|
||||
}
|
||||
|
||||
private fun startDownload(request: DownloadRequest) {
|
||||
val attachmentId = request.attachmentId
|
||||
if (jobs[attachmentId]?.isActive == true) return
|
||||
|
||||
pauseRequested.remove(attachmentId)
|
||||
|
||||
val savedPath = request.savedFile.absolutePath
|
||||
val encryptedPart = encryptedPartFile(request)
|
||||
val resumeBase =
|
||||
(_downloads.value[attachmentId]
|
||||
?.takeIf { it.status == FileDownloadStatus.PAUSED }
|
||||
?.progress
|
||||
?: 0f).coerceIn(0f, 0.8f)
|
||||
|
||||
update(
|
||||
attachmentId,
|
||||
request.fileName,
|
||||
FileDownloadStatus.QUEUED,
|
||||
resumeBase,
|
||||
request.accountPublicKey,
|
||||
savedPath
|
||||
)
|
||||
|
||||
jobs[attachmentId] = scope.launch {
|
||||
var progressJob: Job? = null
|
||||
try {
|
||||
update(
|
||||
attachmentId,
|
||||
request.fileName,
|
||||
FileDownloadStatus.DOWNLOADING,
|
||||
resumeBase,
|
||||
request.accountPublicKey,
|
||||
savedPath
|
||||
)
|
||||
|
||||
// Запускаем polling прогресса из TransportManager.
|
||||
// Держим прогресс монотонным, чтобы он не дёргался вниз.
|
||||
progressJob = launch {
|
||||
TransportManager.downloading.collect { list ->
|
||||
val entry = list.find { it.id == attachmentId } ?: return@collect
|
||||
val rawCdn = (entry.progress / 100f) * 0.8f
|
||||
val current = _downloads.value[attachmentId]?.progress ?: 0f
|
||||
val stable = maxOf(current, rawCdn).coerceIn(0f, 0.8f)
|
||||
update(
|
||||
attachmentId,
|
||||
request.fileName,
|
||||
FileDownloadStatus.DOWNLOADING,
|
||||
stable,
|
||||
request.accountPublicKey,
|
||||
savedPath
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val success = withContext(Dispatchers.IO) {
|
||||
if (isGroupStoredKey(request.chachaKey)) {
|
||||
downloadGroupFile(
|
||||
attachmentId = attachmentId,
|
||||
downloadTag = request.downloadTag,
|
||||
transportServer = request.transportServer,
|
||||
chachaKey = request.chachaKey,
|
||||
privateKey = request.privateKey,
|
||||
fileName = request.fileName,
|
||||
savedFile = request.savedFile,
|
||||
encryptedPartFile = encryptedPart,
|
||||
accountPublicKey = request.accountPublicKey,
|
||||
savedPath = savedPath
|
||||
)
|
||||
} else {
|
||||
downloadDirectFile(
|
||||
attachmentId = attachmentId,
|
||||
downloadTag = request.downloadTag,
|
||||
transportServer = request.transportServer,
|
||||
chachaKey = request.chachaKey,
|
||||
privateKey = request.privateKey,
|
||||
fileName = request.fileName,
|
||||
savedFile = request.savedFile,
|
||||
encryptedPartFile = encryptedPart,
|
||||
accountPublicKey = request.accountPublicKey,
|
||||
savedPath = savedPath
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
update(
|
||||
attachmentId,
|
||||
request.fileName,
|
||||
FileDownloadStatus.DONE,
|
||||
1f,
|
||||
request.accountPublicKey,
|
||||
savedPath
|
||||
)
|
||||
encryptedPart.delete()
|
||||
requests.remove(attachmentId)
|
||||
} else {
|
||||
update(
|
||||
attachmentId,
|
||||
request.fileName,
|
||||
FileDownloadStatus.ERROR,
|
||||
0f,
|
||||
request.accountPublicKey,
|
||||
savedPath
|
||||
)
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
if (pauseRequested.remove(attachmentId)) {
|
||||
val current = _downloads.value[attachmentId]
|
||||
val pausedProgress = (current?.progress ?: resumeBase).coerceIn(0f, 0.98f)
|
||||
update(
|
||||
attachmentId,
|
||||
request.fileName,
|
||||
FileDownloadStatus.PAUSED,
|
||||
pausedProgress,
|
||||
request.accountPublicKey,
|
||||
savedPath
|
||||
)
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
update(
|
||||
attachmentId,
|
||||
request.fileName,
|
||||
FileDownloadStatus.ERROR,
|
||||
0f,
|
||||
request.accountPublicKey,
|
||||
savedPath
|
||||
)
|
||||
} catch (_: OutOfMemoryError) {
|
||||
System.gc()
|
||||
update(
|
||||
attachmentId,
|
||||
request.fileName,
|
||||
FileDownloadStatus.ERROR,
|
||||
0f,
|
||||
request.accountPublicKey,
|
||||
savedPath
|
||||
)
|
||||
} finally {
|
||||
progressJob?.cancel()
|
||||
jobs.remove(attachmentId)
|
||||
|
||||
if (resumeAfterPause.remove(attachmentId)) {
|
||||
scope.launch { startDownload(request) }
|
||||
}
|
||||
|
||||
// Автоочистка только терминальных состояний.
|
||||
val terminalStatus = _downloads.value[attachmentId]?.status
|
||||
if (
|
||||
terminalStatus == FileDownloadStatus.DONE ||
|
||||
terminalStatus == FileDownloadStatus.ERROR
|
||||
) {
|
||||
scope.launch {
|
||||
delay(5000)
|
||||
val current = _downloads.value[attachmentId]
|
||||
if (
|
||||
current?.status == FileDownloadStatus.DONE ||
|
||||
current?.status == FileDownloadStatus.ERROR
|
||||
) {
|
||||
_downloads.update { it - attachmentId }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── internal download logic (moved from FileAttachment) ───
|
||||
|
||||
private suspend fun downloadGroupFile(
|
||||
attachmentId: String,
|
||||
downloadTag: String,
|
||||
transportServer: String,
|
||||
chachaKey: String,
|
||||
privateKey: String,
|
||||
fileName: String,
|
||||
savedFile: File,
|
||||
encryptedPartFile: File,
|
||||
accountPublicKey: String,
|
||||
savedPath: String
|
||||
): Boolean {
|
||||
val encryptedContent = TransportManager.downloadFile(attachmentId, downloadTag)
|
||||
val resumeBytes = if (encryptedPartFile.exists()) encryptedPartFile.length() else 0L
|
||||
val encryptedFile =
|
||||
TransportManager.downloadFileRawResumable(
|
||||
id = attachmentId,
|
||||
tag = downloadTag,
|
||||
targetFile = encryptedPartFile,
|
||||
resumeFromBytes = resumeBytes,
|
||||
transportServer = transportServer
|
||||
)
|
||||
val encryptedContent = withContext(Dispatchers.IO) {
|
||||
encryptedFile.readText(Charsets.UTF_8)
|
||||
}
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
@@ -279,15 +427,24 @@ object FileDownloadManager {
|
||||
private suspend fun downloadDirectFile(
|
||||
attachmentId: String,
|
||||
downloadTag: String,
|
||||
transportServer: String,
|
||||
chachaKey: String,
|
||||
privateKey: String,
|
||||
fileName: String,
|
||||
savedFile: File,
|
||||
encryptedPartFile: File,
|
||||
accountPublicKey: String,
|
||||
savedPath: String
|
||||
): Boolean {
|
||||
// Streaming: скачиваем во temp file
|
||||
val tempFile = TransportManager.downloadFileRaw(attachmentId, downloadTag)
|
||||
val resumeBytes = if (encryptedPartFile.exists()) encryptedPartFile.length() else 0L
|
||||
val tempFile =
|
||||
TransportManager.downloadFileRawResumable(
|
||||
id = attachmentId,
|
||||
tag = downloadTag,
|
||||
targetFile = encryptedPartFile,
|
||||
resumeFromBytes = resumeBytes,
|
||||
transportServer = transportServer
|
||||
)
|
||||
update(
|
||||
attachmentId,
|
||||
fileName,
|
||||
@@ -316,7 +473,7 @@ object FileDownloadManager {
|
||||
savedFile
|
||||
)
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
encryptedPartFile.delete()
|
||||
}
|
||||
}
|
||||
update(
|
||||
@@ -339,12 +496,19 @@ object FileDownloadManager {
|
||||
savedPath: String
|
||||
) {
|
||||
_downloads.update { map ->
|
||||
val previous = map[id]
|
||||
val normalizedProgress =
|
||||
when (status) {
|
||||
FileDownloadStatus.DONE -> 1f
|
||||
FileDownloadStatus.ERROR -> progress.coerceIn(0f, 1f)
|
||||
else -> maxOf(progress, previous?.progress ?: 0f).coerceIn(0f, 1f)
|
||||
}
|
||||
map + (
|
||||
id to FileDownloadState(
|
||||
attachmentId = id,
|
||||
fileName = fileName,
|
||||
status = status,
|
||||
progress = progress,
|
||||
progress = normalizedProgress,
|
||||
accountPublicKey = accountPublicKey,
|
||||
savedPath = savedPath
|
||||
)
|
||||
|
||||
@@ -10,5 +10,9 @@ data class MessageAttachment(
|
||||
val preview: String = "", // Метаданные: "UUID::metadata" или "filesize::filename"
|
||||
val width: Int = 0,
|
||||
val height: Int = 0,
|
||||
val localUri: String = "" // 🚀 Локальный URI для мгновенного отображения (optimistic UI)
|
||||
val localUri: String = "", // 🚀 Локальный URI для мгновенного отображения (optimistic UI)
|
||||
val transportTag: String = "",
|
||||
val transportServer: String = "",
|
||||
val encodedFor: String = "",
|
||||
val encoder: String = ""
|
||||
)
|
||||
|
||||
@@ -6,7 +6,8 @@ enum class HandshakeState(val value: Int) {
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: Int): HandshakeState {
|
||||
return entries.firstOrNull { it.value == value } ?: COMPLETED
|
||||
// Fail-safe: unknown value must not auto-authenticate.
|
||||
return entries.firstOrNull { it.value == value } ?: NEED_DEVICE_VERIFICATION
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -15,29 +15,48 @@ class PacketMessage : Packet() {
|
||||
var aesChachaKey: String = "" // ChaCha key+nonce зашифрованный приватным ключом отправителя
|
||||
var attachments: List<MessageAttachment> = emptyList()
|
||||
|
||||
private data class ParsedPacketMessage(
|
||||
val fromPublicKey: String,
|
||||
val toPublicKey: String,
|
||||
val content: String,
|
||||
val chachaKey: String,
|
||||
val timestamp: Long,
|
||||
val privateKey: String,
|
||||
val messageId: String,
|
||||
val attachments: List<MessageAttachment>,
|
||||
val aesChachaKey: String
|
||||
)
|
||||
|
||||
override fun getPacketId(): Int = 0x06
|
||||
|
||||
override fun receive(stream: Stream) {
|
||||
fromPublicKey = stream.readString()
|
||||
toPublicKey = stream.readString()
|
||||
content = stream.readString()
|
||||
chachaKey = stream.readString()
|
||||
timestamp = stream.readInt64()
|
||||
privateKey = stream.readString()
|
||||
messageId = stream.readString()
|
||||
val startPointer = stream.getReadPointerBits()
|
||||
val parsed =
|
||||
listOf(4, 2, 0)
|
||||
.asSequence()
|
||||
.mapNotNull { attachmentMetaFieldCount ->
|
||||
stream.setReadPointerBits(startPointer)
|
||||
parseFromStream(stream, attachmentMetaFieldCount)
|
||||
?.takeIf { !stream.hasRemainingBits() }
|
||||
}
|
||||
.firstOrNull()
|
||||
?: run {
|
||||
stream.setReadPointerBits(startPointer)
|
||||
parseFromStream(stream, 2)
|
||||
?: throw IllegalStateException(
|
||||
"Failed to parse PacketMessage payload"
|
||||
)
|
||||
}
|
||||
|
||||
val attachmentCount = stream.readInt8()
|
||||
val attachmentsList = mutableListOf<MessageAttachment>()
|
||||
for (i in 0 until attachmentCount) {
|
||||
attachmentsList.add(MessageAttachment(
|
||||
id = stream.readString(),
|
||||
preview = stream.readString(),
|
||||
blob = stream.readString(),
|
||||
type = AttachmentType.fromInt(stream.readInt8())
|
||||
))
|
||||
}
|
||||
attachments = attachmentsList
|
||||
aesChachaKey = stream.readString()
|
||||
fromPublicKey = parsed.fromPublicKey
|
||||
toPublicKey = parsed.toPublicKey
|
||||
content = parsed.content
|
||||
chachaKey = parsed.chachaKey
|
||||
timestamp = parsed.timestamp
|
||||
privateKey = parsed.privateKey
|
||||
messageId = parsed.messageId
|
||||
attachments = parsed.attachments
|
||||
aesChachaKey = parsed.aesChachaKey
|
||||
}
|
||||
|
||||
override fun send(): Stream {
|
||||
@@ -57,9 +76,80 @@ class PacketMessage : Packet() {
|
||||
stream.writeString(attachment.preview)
|
||||
stream.writeString(attachment.blob)
|
||||
stream.writeInt8(attachment.type.value)
|
||||
stream.writeString(attachment.transportTag)
|
||||
stream.writeString(attachment.transportServer)
|
||||
}
|
||||
stream.writeString(aesChachaKey)
|
||||
|
||||
return stream
|
||||
}
|
||||
|
||||
private fun parseFromStream(
|
||||
parser: Stream,
|
||||
attachmentMetaFieldCount: Int
|
||||
): ParsedPacketMessage? {
|
||||
return runCatching {
|
||||
val parsedFromPublicKey = parser.readString()
|
||||
val parsedToPublicKey = parser.readString()
|
||||
val parsedContent = parser.readString()
|
||||
val parsedChachaKey = parser.readString()
|
||||
val parsedTimestamp = parser.readInt64()
|
||||
val parsedPrivateKey = parser.readString()
|
||||
val parsedMessageId = parser.readString()
|
||||
|
||||
val attachmentCount = parser.readInt8().coerceAtLeast(0)
|
||||
val parsedAttachments = ArrayList<MessageAttachment>(attachmentCount)
|
||||
repeat(attachmentCount) {
|
||||
val id = parser.readString()
|
||||
val preview = parser.readString()
|
||||
val blob = parser.readString()
|
||||
val type = AttachmentType.fromInt(parser.readInt8())
|
||||
|
||||
val transportTag: String
|
||||
val transportServer: String
|
||||
val encodedFor: String
|
||||
val encoder: String
|
||||
if (attachmentMetaFieldCount >= 2) {
|
||||
transportTag = parser.readString()
|
||||
transportServer = parser.readString()
|
||||
} else {
|
||||
transportTag = ""
|
||||
transportServer = ""
|
||||
}
|
||||
if (attachmentMetaFieldCount >= 4) {
|
||||
encodedFor = parser.readString()
|
||||
encoder = parser.readString()
|
||||
} else {
|
||||
encodedFor = ""
|
||||
encoder = ""
|
||||
}
|
||||
|
||||
parsedAttachments.add(
|
||||
MessageAttachment(
|
||||
id = id,
|
||||
preview = preview,
|
||||
blob = blob,
|
||||
type = type,
|
||||
transportTag = transportTag,
|
||||
transportServer = transportServer,
|
||||
encodedFor = encodedFor,
|
||||
encoder = encoder
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
val parsedAesChachaKey = parser.readString()
|
||||
ParsedPacketMessage(
|
||||
fromPublicKey = parsedFromPublicKey,
|
||||
toPublicKey = parsedToPublicKey,
|
||||
content = parsedContent,
|
||||
chachaKey = parsedChachaKey,
|
||||
timestamp = parsedTimestamp,
|
||||
privateKey = parsedPrivateKey,
|
||||
messageId = parsedMessageId,
|
||||
attachments = parsedAttachments,
|
||||
aesChachaKey = parsedAesChachaKey
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,24 @@ enum class PushNotificationAction(val value: Int) {
|
||||
UNSUBSCRIBE(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Token type for push notifications.
|
||||
*/
|
||||
enum class PushTokenType(val value: Int) {
|
||||
FCM(0),
|
||||
VOIP_APNS(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Push Notification packet (ID: 0x10)
|
||||
* Отправка FCM/APNS токена на сервер для push-уведомлений (новый формат)
|
||||
* Совместим с React Native версией
|
||||
* Отправка FCM/APNS токена на сервер для push-уведомлений
|
||||
* Передаёт tokenType (fcm/voip) и deviceId
|
||||
*/
|
||||
class PacketPushNotification : Packet() {
|
||||
var notificationsToken: String = ""
|
||||
var action: PushNotificationAction = PushNotificationAction.SUBSCRIBE
|
||||
var tokenType: PushTokenType = PushTokenType.FCM
|
||||
var deviceId: String = ""
|
||||
|
||||
override fun getPacketId(): Int = 0x10
|
||||
|
||||
@@ -25,6 +35,11 @@ class PacketPushNotification : Packet() {
|
||||
1 -> PushNotificationAction.UNSUBSCRIBE
|
||||
else -> PushNotificationAction.SUBSCRIBE
|
||||
}
|
||||
tokenType = when (stream.readInt8()) {
|
||||
1 -> PushTokenType.VOIP_APNS
|
||||
else -> PushTokenType.FCM
|
||||
}
|
||||
deviceId = stream.readString()
|
||||
}
|
||||
|
||||
override fun send(): Stream {
|
||||
@@ -32,6 +47,8 @@ class PacketPushNotification : Packet() {
|
||||
stream.writeInt16(getPacketId())
|
||||
stream.writeString(notificationsToken)
|
||||
stream.writeInt8(action.value)
|
||||
stream.writeInt8(tokenType.value)
|
||||
stream.writeString(deviceId)
|
||||
return stream
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
/**
|
||||
* Push Token packet (ID: 0x0A) - DEPRECATED
|
||||
* Старый формат, заменен на PacketPushNotification (0x10)
|
||||
*/
|
||||
class PacketPushToken : Packet() {
|
||||
var privateKey: String = ""
|
||||
var publicKey: String = ""
|
||||
var pushToken: String = ""
|
||||
var platform: String = "android" // "android" или "ios"
|
||||
|
||||
override fun getPacketId(): Int = 0x0A
|
||||
|
||||
override fun receive(stream: Stream) {
|
||||
privateKey = stream.readString()
|
||||
publicKey = stream.readString()
|
||||
pushToken = stream.readString()
|
||||
platform = stream.readString()
|
||||
}
|
||||
|
||||
override fun send(): Stream {
|
||||
val stream = Stream()
|
||||
stream.writeInt16(getPacketId())
|
||||
stream.writeString(privateKey)
|
||||
stream.writeString(publicKey)
|
||||
stream.writeString(pushToken)
|
||||
stream.writeString(platform)
|
||||
return stream
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
enum class SignalType(val value: Int) {
|
||||
CALL(0),
|
||||
KEY_EXCHANGE(1),
|
||||
ACTIVE_CALL(2),
|
||||
END_CALL(3),
|
||||
ACTIVE(4),
|
||||
END_CALL_BECAUSE_PEER_DISCONNECTED(5),
|
||||
END_CALL_BECAUSE_BUSY(6),
|
||||
ACCEPT(7),
|
||||
RINGING_TIMEOUT(8);
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: Int): SignalType =
|
||||
entries.firstOrNull { it.value == value }
|
||||
?: throw IllegalArgumentException("Unknown SignalType code: $value")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 callId: String = ""
|
||||
var joinToken: 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 ||
|
||||
signalType == SignalType.RINGING_TIMEOUT
|
||||
) {
|
||||
return
|
||||
}
|
||||
src = stream.readString()
|
||||
dst = stream.readString()
|
||||
if (signalType == SignalType.KEY_EXCHANGE) {
|
||||
sharedPublic = stream.readString()
|
||||
}
|
||||
if (
|
||||
signalType == SignalType.CALL ||
|
||||
signalType == SignalType.ACCEPT ||
|
||||
signalType == SignalType.END_CALL
|
||||
) {
|
||||
callId = stream.readString()
|
||||
joinToken = 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 ||
|
||||
signalType == SignalType.RINGING_TIMEOUT
|
||||
) {
|
||||
return stream
|
||||
}
|
||||
stream.writeString(src)
|
||||
stream.writeString(dst)
|
||||
if (signalType == SignalType.KEY_EXCHANGE) {
|
||||
stream.writeString(sharedPublic)
|
||||
}
|
||||
if (
|
||||
signalType == SignalType.CALL ||
|
||||
signalType == SignalType.ACCEPT ||
|
||||
signalType == SignalType.END_CALL
|
||||
) {
|
||||
stream.writeString(callId)
|
||||
stream.writeString(joinToken)
|
||||
}
|
||||
return stream
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Centralized packet subscription registry.
|
||||
*
|
||||
* Guarantees exactly one low-level Protocol.waitPacket subscription per packet id
|
||||
* and fans out packets to:
|
||||
* 1) legacy callback listeners (waitPacket/unwaitPacket API),
|
||||
* 2) SharedFlow collectors in network/UI layers.
|
||||
*/
|
||||
class PacketSubscriptionRegistry(
|
||||
private val protocolProvider: () -> Protocol,
|
||||
private val scope: CoroutineScope,
|
||||
private val addLog: (String) -> Unit
|
||||
) {
|
||||
|
||||
private data class PacketBus(
|
||||
val packetId: Int,
|
||||
val callbacks: CopyOnWriteArrayList<(Packet) -> Unit>,
|
||||
val sharedFlow: MutableSharedFlow<Packet>,
|
||||
val protocolBridge: (Packet) -> Unit
|
||||
)
|
||||
|
||||
private val buses = ConcurrentHashMap<Int, PacketBus>()
|
||||
|
||||
private fun ensureBus(packetId: Int): PacketBus {
|
||||
buses[packetId]?.let { return it }
|
||||
|
||||
val callbacks = CopyOnWriteArrayList<(Packet) -> Unit>()
|
||||
val sharedFlow =
|
||||
MutableSharedFlow<Packet>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 128,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
val bridge: (Packet) -> Unit = { packet ->
|
||||
if (!sharedFlow.tryEmit(packet)) {
|
||||
scope.launch { sharedFlow.emit(packet) }
|
||||
}
|
||||
callbacks.forEach { callback ->
|
||||
runCatching { callback(packet) }
|
||||
.onFailure { error ->
|
||||
addLog("❌ PacketSubscriptionRegistry callback error: ${error.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val created =
|
||||
PacketBus(
|
||||
packetId = packetId,
|
||||
callbacks = callbacks,
|
||||
sharedFlow = sharedFlow,
|
||||
protocolBridge = bridge
|
||||
)
|
||||
|
||||
val existing = buses.putIfAbsent(packetId, created)
|
||||
if (existing == null) {
|
||||
protocolProvider().waitPacket(packetId, bridge)
|
||||
addLog(
|
||||
"🧭 PacketSubscriptionRegistry attached id=0x${packetId.toString(16).uppercase()}"
|
||||
)
|
||||
return created
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
fun flow(packetId: Int): SharedFlow<Packet> = ensureBus(packetId).sharedFlow.asSharedFlow()
|
||||
|
||||
fun addCallback(packetId: Int, callback: (Packet) -> Unit) {
|
||||
val bus = ensureBus(packetId)
|
||||
if (bus.callbacks.contains(callback)) {
|
||||
addLog(
|
||||
"📝 registry waitPacket(0x${packetId.toString(16)}) skipped duplicate callback; callbacks=${bus.callbacks.size}"
|
||||
)
|
||||
return
|
||||
}
|
||||
bus.callbacks.add(callback)
|
||||
addLog(
|
||||
"📝 registry waitPacket(0x${packetId.toString(16)}) callback registered; callbacks=${bus.callbacks.size}"
|
||||
)
|
||||
}
|
||||
|
||||
fun removeCallback(packetId: Int, callback: (Packet) -> Unit) {
|
||||
val bus = buses[packetId] ?: return
|
||||
bus.callbacks.remove(callback)
|
||||
addLog(
|
||||
"📝 registry unwaitPacket(0x${packetId.toString(16)}) callback removed; callbacks=${bus.callbacks.size}"
|
||||
)
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
buses.forEach { (packetId, bus) ->
|
||||
runCatching {
|
||||
protocolProvider().unwaitPacket(packetId, bus.protocolBridge)
|
||||
}
|
||||
}
|
||||
buses.clear()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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 }
|
||||
?: throw IllegalArgumentException("Unknown WebRTCSignalType code: $value")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,15 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
/**
|
||||
* Infrastructure adapter contract used by repositories.
|
||||
*
|
||||
* Kept intentionally narrow and transport-oriented to avoid direct repository -> runtime wiring
|
||||
* while preserving lazy runtime resolution through Provider in DI.
|
||||
*/
|
||||
interface ProtocolClient {
|
||||
fun send(packet: Packet)
|
||||
fun sendMessageWithRetry(packet: PacketMessage)
|
||||
fun addLog(message: String)
|
||||
fun waitPacket(packetId: Int, callback: (Packet) -> Unit)
|
||||
fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit)
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
enum class ConnectionLifecycleState {
|
||||
DISCONNECTED,
|
||||
CONNECTING,
|
||||
HANDSHAKING,
|
||||
AUTHENTICATED,
|
||||
BOOTSTRAPPING,
|
||||
READY,
|
||||
DEVICE_VERIFICATION_REQUIRED
|
||||
}
|
||||
|
||||
sealed interface ConnectionEvent {
|
||||
data class InitializeAccount(val publicKey: String, val privateKey: String) : ConnectionEvent
|
||||
data class Connect(val reason: String) : ConnectionEvent
|
||||
data class FastReconnect(val reason: String) : ConnectionEvent
|
||||
data class Disconnect(val reason: String, val clearCredentials: Boolean) : ConnectionEvent
|
||||
data class Authenticate(val publicKey: String, val privateHash: String) : ConnectionEvent
|
||||
data class ProtocolStateChanged(val state: ProtocolState) : ConnectionEvent
|
||||
data class SendPacket(val packet: Packet) : ConnectionEvent
|
||||
data class SyncCompleted(val reason: String) : ConnectionEvent
|
||||
data class OwnProfileResolved(val publicKey: String) : ConnectionEvent
|
||||
data class OwnProfileFallbackTimeout(val sessionGeneration: Long) : ConnectionEvent
|
||||
}
|
||||
|
||||
data class ConnectionBootstrapContext(
|
||||
val accountPublicKey: String = "",
|
||||
val accountInitialized: Boolean = false,
|
||||
val protocolState: ProtocolState = ProtocolState.DISCONNECTED,
|
||||
val authenticated: Boolean = false,
|
||||
val syncCompleted: Boolean = false,
|
||||
val ownProfileResolved: Boolean = false
|
||||
)
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ProtocolConnectionSupervisor(
|
||||
private val scope: CoroutineScope,
|
||||
private val onEvent: suspend (ConnectionEvent) -> Unit,
|
||||
private val onError: (Throwable) -> Unit,
|
||||
private val addLog: (String) -> Unit
|
||||
) {
|
||||
private val eventChannel = Channel<ConnectionEvent>(Channel.UNLIMITED)
|
||||
private val lock = Any()
|
||||
|
||||
@Volatile private var job: Job? = null
|
||||
|
||||
fun start() {
|
||||
if (job?.isActive == true) return
|
||||
synchronized(lock) {
|
||||
if (job?.isActive == true) return
|
||||
job =
|
||||
scope.launch {
|
||||
for (event in eventChannel) {
|
||||
try {
|
||||
onEvent(event)
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
addLog("❌ ConnectionSupervisor event failed: ${e.message}")
|
||||
onError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
addLog("🧠 ConnectionSupervisor started")
|
||||
}
|
||||
}
|
||||
|
||||
fun post(event: ConnectionEvent) {
|
||||
start()
|
||||
val result = eventChannel.trySend(event)
|
||||
if (result.isFailure) {
|
||||
scope.launch { eventChannel.send(event) }
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,156 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import android.content.Context
|
||||
import com.rosetta.messenger.data.AccountManager
|
||||
import com.rosetta.messenger.data.GroupRepository
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.di.ProtocolGateway
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class ProtocolRuntime @Inject constructor(
|
||||
private val runtimeComposition: RuntimeComposition,
|
||||
private val messageRepository: MessageRepository,
|
||||
private val groupRepository: GroupRepository,
|
||||
private val accountManager: AccountManager
|
||||
) : ProtocolRuntimePort, ProtocolGateway {
|
||||
init {
|
||||
bindDependencies()
|
||||
}
|
||||
|
||||
private val connectionControlApi by lazy { runtimeComposition.connectionControlApi() }
|
||||
private val directoryApi by lazy { runtimeComposition.directoryApi() }
|
||||
private val packetIoApi by lazy { runtimeComposition.packetIoApi() }
|
||||
|
||||
override val state: StateFlow<ProtocolState> get() = runtimeComposition.state
|
||||
override val syncInProgress: StateFlow<Boolean> get() = runtimeComposition.syncInProgress
|
||||
override val pendingDeviceVerification: StateFlow<DeviceEntry?> get() =
|
||||
runtimeComposition.pendingDeviceVerification
|
||||
override val typingUsers: StateFlow<Set<String>> get() = runtimeComposition.typingUsers
|
||||
override val typingUsersByDialogSnapshot: StateFlow<Map<String, Set<String>>> get() =
|
||||
runtimeComposition.typingUsersByDialogSnapshot
|
||||
override val debugLogs: StateFlow<List<String>> get() = runtimeComposition.debugLogs
|
||||
override val ownProfileUpdated: StateFlow<Long> get() = runtimeComposition.ownProfileUpdated
|
||||
|
||||
override fun initialize(context: Context) {
|
||||
bindDependencies()
|
||||
connectionControlApi.initialize(context)
|
||||
}
|
||||
|
||||
override fun initializeAccount(publicKey: String, privateKey: String) =
|
||||
connectionControlApi.initializeAccount(publicKey, privateKey)
|
||||
|
||||
override fun connect() = connectionControlApi.connect()
|
||||
|
||||
override fun authenticate(publicKey: String, privateHash: String) =
|
||||
connectionControlApi.authenticate(publicKey, privateHash)
|
||||
|
||||
override fun reconnectNowIfNeeded(reason: String) =
|
||||
connectionControlApi.reconnectNowIfNeeded(reason)
|
||||
|
||||
override fun disconnect() = connectionControlApi.disconnect()
|
||||
|
||||
override fun isAuthenticated(): Boolean = connectionControlApi.isAuthenticated()
|
||||
|
||||
override fun getPrivateHash(): String? = connectionControlApi.getPrivateHashOrNull()
|
||||
|
||||
override fun subscribePushTokenIfAvailable(forceToken: String?) =
|
||||
connectionControlApi.subscribePushToken(forceToken)
|
||||
|
||||
override fun addLog(message: String) = runtimeComposition.addLog(message)
|
||||
|
||||
override fun enableUILogs(enabled: Boolean) = runtimeComposition.enableUILogs(enabled)
|
||||
|
||||
override fun clearLogs() = runtimeComposition.clearLogs()
|
||||
|
||||
override fun resolveOutgoingRetry(messageId: String) =
|
||||
packetIoApi.resolveOutgoingRetry(messageId)
|
||||
|
||||
override fun getCachedUserByUsername(username: String): SearchUser? =
|
||||
directoryApi.getCachedUserByUsername(username)
|
||||
|
||||
override fun getCachedUserName(publicKey: String): String? =
|
||||
directoryApi.getCachedUserName(publicKey)
|
||||
|
||||
override fun getCachedUserInfo(publicKey: String): SearchUser? =
|
||||
directoryApi.getCachedUserInfo(publicKey)
|
||||
|
||||
override fun acceptDevice(deviceId: String) = directoryApi.acceptDevice(deviceId)
|
||||
|
||||
override fun declineDevice(deviceId: String) = directoryApi.declineDevice(deviceId)
|
||||
|
||||
override fun send(packet: Packet) = packetIoApi.send(packet)
|
||||
|
||||
override fun sendPacket(packet: Packet) = packetIoApi.sendPacket(packet)
|
||||
|
||||
override fun sendMessageWithRetry(packet: PacketMessage) =
|
||||
packetIoApi.sendMessageWithRetry(packet)
|
||||
|
||||
override fun waitPacket(packetId: Int, callback: (Packet) -> Unit) =
|
||||
packetIoApi.waitPacket(packetId, callback)
|
||||
|
||||
override fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) =
|
||||
packetIoApi.unwaitPacket(packetId, callback)
|
||||
|
||||
override fun packetFlow(packetId: Int): SharedFlow<Packet> =
|
||||
packetIoApi.packetFlow(packetId)
|
||||
|
||||
override fun notifyOwnProfileUpdated() = directoryApi.notifyOwnProfileUpdated()
|
||||
|
||||
override fun restoreAuthFromStoredCredentials(
|
||||
preferredPublicKey: String?,
|
||||
reason: String
|
||||
): Boolean = connectionControlApi.restoreAuthFromStoredCredentials(preferredPublicKey, reason)
|
||||
|
||||
override suspend fun resolveUserName(publicKey: String, timeoutMs: Long): String? =
|
||||
directoryApi.resolveUserName(publicKey, timeoutMs)
|
||||
|
||||
override suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long): SearchUser? =
|
||||
directoryApi.resolveUserInfo(publicKey, timeoutMs)
|
||||
|
||||
override suspend fun searchUsers(query: String, timeoutMs: Long): List<SearchUser> =
|
||||
directoryApi.searchUsers(query, timeoutMs)
|
||||
|
||||
override fun requestIceServers() = packetIoApi.requestIceServers()
|
||||
|
||||
override fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit =
|
||||
packetIoApi.waitCallSignal(callback)
|
||||
|
||||
override fun unwaitCallSignal(callback: (Packet) -> Unit) =
|
||||
packetIoApi.unwaitCallSignal(callback)
|
||||
|
||||
override fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit =
|
||||
packetIoApi.waitWebRtcSignal(callback)
|
||||
|
||||
override fun unwaitWebRtcSignal(callback: (Packet) -> Unit) =
|
||||
packetIoApi.unwaitWebRtcSignal(callback)
|
||||
|
||||
override fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit =
|
||||
packetIoApi.waitIceServers(callback)
|
||||
|
||||
override fun unwaitIceServers(callback: (Packet) -> Unit) =
|
||||
packetIoApi.unwaitIceServers(callback)
|
||||
|
||||
override fun sendCallSignal(
|
||||
signalType: SignalType,
|
||||
src: String,
|
||||
dst: String,
|
||||
sharedPublic: String,
|
||||
callId: String,
|
||||
joinToken: String
|
||||
) = packetIoApi.sendCallSignal(signalType, src, dst, sharedPublic, callId, joinToken)
|
||||
|
||||
override fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) =
|
||||
packetIoApi.sendWebRtcSignal(signalType, sdpOrCandidate)
|
||||
|
||||
private fun bindDependencies() {
|
||||
runtimeComposition.bindDependencies(
|
||||
messageRepository = messageRepository,
|
||||
groupRepository = groupRepository,
|
||||
accountManager = accountManager
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Stable runtime port for layers that are not created by Hilt (object managers/services).
|
||||
*/
|
||||
interface ProtocolRuntimePort {
|
||||
val state: StateFlow<ProtocolState>
|
||||
val debugLogs: StateFlow<List<String>>
|
||||
|
||||
fun addLog(message: String)
|
||||
fun send(packet: Packet)
|
||||
fun sendPacket(packet: Packet)
|
||||
fun waitPacket(packetId: Int, callback: (Packet) -> Unit)
|
||||
fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit)
|
||||
|
||||
fun requestIceServers()
|
||||
fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit
|
||||
fun unwaitCallSignal(callback: (Packet) -> Unit)
|
||||
fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit
|
||||
fun unwaitWebRtcSignal(callback: (Packet) -> Unit)
|
||||
fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit
|
||||
fun unwaitIceServers(callback: (Packet) -> Unit)
|
||||
|
||||
fun getCachedUserInfo(publicKey: String): SearchUser?
|
||||
fun isAuthenticated(): Boolean
|
||||
fun restoreAuthFromStoredCredentials(
|
||||
preferredPublicKey: String? = null,
|
||||
reason: String = "background_restore"
|
||||
): Boolean
|
||||
fun reconnectNowIfNeeded(reason: String = "foreground_resume")
|
||||
fun sendCallSignal(
|
||||
signalType: SignalType,
|
||||
src: String = "",
|
||||
dst: String = "",
|
||||
sharedPublic: String = "",
|
||||
callId: String = "",
|
||||
joinToken: String = ""
|
||||
)
|
||||
fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String)
|
||||
suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long = 3000): SearchUser?
|
||||
}
|
||||
|
||||
object ProtocolRuntimeAccess {
|
||||
@Volatile private var runtime: ProtocolRuntimePort? = null
|
||||
|
||||
fun install(runtime: ProtocolRuntimePort) {
|
||||
this.runtime = runtime
|
||||
}
|
||||
|
||||
fun get(): ProtocolRuntimePort {
|
||||
return runtime
|
||||
?: error(
|
||||
"ProtocolRuntimeAccess is not installed. Install runtime in RosettaApplication.onCreate() before using singleton managers."
|
||||
)
|
||||
}
|
||||
|
||||
fun isInstalled(): Boolean = runtime != null
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
class ReadyPacketGate(
|
||||
private val maxSize: Int,
|
||||
private val ttlMs: Long
|
||||
) {
|
||||
private data class QueuedPacket(
|
||||
val packet: Packet,
|
||||
val accountPublicKey: String,
|
||||
val queuedAtMs: Long
|
||||
)
|
||||
|
||||
private val queue = ArrayDeque<QueuedPacket>()
|
||||
|
||||
fun clear(reason: String, addLog: (String) -> Unit) {
|
||||
val clearedCount =
|
||||
synchronized(queue) {
|
||||
val count = queue.size
|
||||
queue.clear()
|
||||
count
|
||||
}
|
||||
if (clearedCount > 0) {
|
||||
addLog("🧹 READY-GATE queue cleared: $clearedCount packet(s), reason=$reason")
|
||||
}
|
||||
}
|
||||
|
||||
fun enqueue(
|
||||
packet: Packet,
|
||||
accountPublicKey: String,
|
||||
state: ConnectionLifecycleState,
|
||||
shortKeyForLog: (String) -> String,
|
||||
addLog: (String) -> Unit
|
||||
) {
|
||||
val now = System.currentTimeMillis()
|
||||
val packetId = packet.getPacketId()
|
||||
synchronized(queue) {
|
||||
while (queue.isNotEmpty()) {
|
||||
val oldest = queue.first()
|
||||
if (now - oldest.queuedAtMs <= ttlMs) break
|
||||
queue.removeFirst()
|
||||
}
|
||||
while (queue.size >= maxSize) {
|
||||
queue.removeFirst()
|
||||
}
|
||||
queue.addLast(
|
||||
QueuedPacket(
|
||||
packet = packet,
|
||||
accountPublicKey = accountPublicKey,
|
||||
queuedAtMs = now
|
||||
)
|
||||
)
|
||||
}
|
||||
addLog(
|
||||
"📦 READY-GATE queued id=0x${packetId.toString(16)} state=$state account=${shortKeyForLog(accountPublicKey)}"
|
||||
)
|
||||
}
|
||||
|
||||
fun drainForAccount(
|
||||
activeAccountKey: String,
|
||||
reason: String,
|
||||
addLog: (String) -> Unit
|
||||
): List<Packet> {
|
||||
if (activeAccountKey.isBlank()) return emptyList()
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val packetsToSend = mutableListOf<Packet>()
|
||||
|
||||
synchronized(queue) {
|
||||
val iterator = queue.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
val queued = iterator.next()
|
||||
val isExpired = now - queued.queuedAtMs > ttlMs
|
||||
val accountMatches =
|
||||
queued.accountPublicKey.isBlank() ||
|
||||
queued.accountPublicKey.equals(activeAccountKey, ignoreCase = true)
|
||||
if (!isExpired && accountMatches) {
|
||||
packetsToSend += queued.packet
|
||||
}
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
|
||||
if (packetsToSend.isNotEmpty()) {
|
||||
addLog("📬 READY-GATE flush: ${packetsToSend.size} packet(s), reason=$reason")
|
||||
}
|
||||
return packetsToSend
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,501 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import android.content.Context
|
||||
import com.rosetta.messenger.data.AccountManager
|
||||
import com.rosetta.messenger.data.GroupRepository
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.data.isPlaceholderAccountName
|
||||
import com.rosetta.messenger.network.connection.AuthBootstrapCoordinator
|
||||
import com.rosetta.messenger.network.connection.AuthRestoreService
|
||||
import com.rosetta.messenger.network.connection.ProtocolAccountSessionCoordinator
|
||||
import com.rosetta.messenger.network.connection.ConnectionOrchestrator
|
||||
import com.rosetta.messenger.network.connection.DeviceRuntimeService
|
||||
import com.rosetta.messenger.network.connection.OwnProfileSyncService
|
||||
import com.rosetta.messenger.network.connection.ProtocolDebugLogService
|
||||
import com.rosetta.messenger.network.connection.ProtocolLifecycleCoordinator
|
||||
import com.rosetta.messenger.network.connection.ProtocolPostAuthBootstrapCoordinator
|
||||
import com.rosetta.messenger.network.connection.ReadyPacketDispatchCoordinator
|
||||
import com.rosetta.messenger.network.connection.RuntimeInitializationCoordinator
|
||||
import com.rosetta.messenger.network.connection.RuntimeShutdownCoordinator
|
||||
import com.rosetta.messenger.session.IdentityStore
|
||||
import com.rosetta.messenger.utils.MessageLogger
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import java.util.*
|
||||
|
||||
/**
|
||||
* Singleton manager for Protocol instance
|
||||
* Ensures single connection across the app
|
||||
*/
|
||||
class RuntimeComposition {
|
||||
private val TAG = "ProtocolRuntime"
|
||||
private val MANUAL_SYNC_BACKTRACK_MS = 120_000L
|
||||
private val SYNC_REQUEST_TIMEOUT_MS = 12_000L
|
||||
private val MAX_DEBUG_LOGS = 600
|
||||
private val DEBUG_LOG_FLUSH_DELAY_MS = 60L
|
||||
private val HEARTBEAT_OK_LOG_MIN_INTERVAL_MS = 5_000L
|
||||
private val TYPING_INDICATOR_TIMEOUT_MS = 3_000L
|
||||
private val PROTOCOL_TRACE_FILE_NAME = "protocol_wire_log.txt"
|
||||
private val PROTOCOL_TRACE_MAX_BYTES = 2_000_000L
|
||||
private val PROTOCOL_TRACE_KEEP_BYTES = 1_200_000
|
||||
private val NETWORK_WAIT_TIMEOUT_MS = 20_000L
|
||||
private val BOOTSTRAP_OWN_PROFILE_FALLBACK_MS = 2_500L
|
||||
private val READY_PACKET_QUEUE_MAX = 500
|
||||
private val READY_PACKET_QUEUE_TTL_MS = 120_000L
|
||||
|
||||
// Desktop parity: use the same primary WebSocket endpoint as desktop client.
|
||||
private val SERVER_ADDRESS = "wss://wss.rosetta.im"
|
||||
|
||||
private var messageRepository: MessageRepository? = null
|
||||
private var groupRepository: GroupRepository? = null
|
||||
private var accountManager: AccountManager? = null
|
||||
private var appContext: Context? = null
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private val transportAssembly =
|
||||
RuntimeTransportAssembly(
|
||||
scope = scope,
|
||||
networkWaitTimeoutMs = NETWORK_WAIT_TIMEOUT_MS,
|
||||
serverAddress = SERVER_ADDRESS,
|
||||
getAppContext = { appContext },
|
||||
addLog = ::addLog,
|
||||
onFastReconnectRequested = ::onFastReconnectRequested
|
||||
)
|
||||
private val networkConnectivityFacade = transportAssembly.networkConnectivityFacade
|
||||
private val protocolInstanceManager = transportAssembly.protocolInstanceManager
|
||||
private val packetSubscriptionFacade = transportAssembly.packetSubscriptionFacade
|
||||
// Guard: prevent duplicate FCM token subscribe within a single session
|
||||
@Volatile
|
||||
private var lastSubscribedToken: String? = null
|
||||
private val stateAssembly =
|
||||
RuntimeStateAssembly(
|
||||
scope = scope,
|
||||
readyPacketQueueMax = READY_PACKET_QUEUE_MAX,
|
||||
readyPacketQueueTtlMs = READY_PACKET_QUEUE_TTL_MS,
|
||||
ownProfileFallbackTimeoutMs = BOOTSTRAP_OWN_PROFILE_FALLBACK_MS,
|
||||
addLog = ::addLog,
|
||||
shortKeyForLog = ::shortKeyForLog,
|
||||
sendPacketDirect = { packet -> getProtocol().sendPacket(packet) },
|
||||
onOwnProfileFallbackTimeout = ::onOwnProfileFallbackTimeoutEvent,
|
||||
clearLastSubscribedTokenValue = { lastSubscribedToken = null }
|
||||
)
|
||||
private val bootstrapCoordinator get() = stateAssembly.bootstrapCoordinator
|
||||
private val lifecycleStateMachine get() = stateAssembly.lifecycleStateMachine
|
||||
private val lifecycleStateStore get() = stateAssembly.lifecycleStateStore
|
||||
private val deviceRuntimeService =
|
||||
DeviceRuntimeService(
|
||||
getAppContext = { appContext },
|
||||
sendPacket = ::send
|
||||
)
|
||||
private val connectionOrchestrator =
|
||||
ConnectionOrchestrator(
|
||||
hasActiveInternet = networkConnectivityFacade::hasActiveInternet,
|
||||
waitForNetworkAndReconnect = networkConnectivityFacade::waitForNetworkAndReconnect,
|
||||
stopWaitingForNetwork = { reason -> networkConnectivityFacade.stopWaitingForNetwork(reason) },
|
||||
getProtocol = ::getProtocol,
|
||||
persistHandshakeCredentials = { publicKey, privateHash ->
|
||||
accountManager?.setLastLoggedPublicKey(publicKey)
|
||||
accountManager?.setLastLoggedPrivateKeyHash(privateHash)
|
||||
},
|
||||
buildHandshakeDevice = deviceRuntimeService::buildHandshakeDevice
|
||||
)
|
||||
private val ownProfileSyncService =
|
||||
OwnProfileSyncService(
|
||||
isPlaceholderAccountName = ::isPlaceholderAccountName,
|
||||
updateAccountName = { publicKey, name ->
|
||||
accountManager?.updateAccountName(publicKey, name)
|
||||
},
|
||||
updateAccountUsername = { publicKey, username ->
|
||||
accountManager?.updateAccountUsername(publicKey, username)
|
||||
}
|
||||
)
|
||||
private val authBootstrapCoordinator =
|
||||
AuthBootstrapCoordinator(
|
||||
scope = scope,
|
||||
addLog = ::addLog
|
||||
)
|
||||
private val messagingAssembly by lazy {
|
||||
RuntimeMessagingAssembly(
|
||||
tag = TAG,
|
||||
scope = scope,
|
||||
typingIndicatorTimeoutMs = TYPING_INDICATOR_TIMEOUT_MS,
|
||||
syncRequestTimeoutMs = SYNC_REQUEST_TIMEOUT_MS,
|
||||
manualSyncBacktrackMs = MANUAL_SYNC_BACKTRACK_MS,
|
||||
deviceRuntimeService = deviceRuntimeService,
|
||||
ownProfileSyncService = ownProfileSyncService,
|
||||
isAuthenticated = ::isAuthenticated,
|
||||
getProtocolPublicKey = { getProtocol().getPublicKey().orEmpty() },
|
||||
getProtocolPrivateHash = {
|
||||
try {
|
||||
getProtocol().getPrivateHash()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
},
|
||||
getMessageRepository = { messageRepository },
|
||||
getGroupRepository = { groupRepository },
|
||||
sendSearchPacket = ::sendSearchPacketViaPacketIo,
|
||||
sendMessagePacket = ::sendMessagePacketViaPacketIo,
|
||||
sendSyncPacket = ::sendSyncPacketViaPacketIo,
|
||||
sendPacket = ::send,
|
||||
waitPacket = ::waitPacket,
|
||||
unwaitPacket = ::unwaitPacket,
|
||||
addLog = ::addLog,
|
||||
shortKeyForLog = ::shortKeyForLog,
|
||||
shortTextForLog = ::shortTextForLog,
|
||||
onSyncCompleted = ::finishSyncCycle,
|
||||
onInboundTaskQueued = ::onInboundTaskQueued,
|
||||
onInboundTaskFailure = ::markInboundProcessingFailure,
|
||||
resolveOutgoingRetry = ::resolveOutgoingRetry,
|
||||
isGroupDialogKey = ::isGroupDialogKey,
|
||||
setTransportServer = TransportManager::setTransportServer,
|
||||
onOwnProfileResolved = ::onOwnProfileResolvedEvent
|
||||
)
|
||||
}
|
||||
private val packetRouter get() = messagingAssembly.packetRouter
|
||||
private val outgoingMessagePipelineService get() = messagingAssembly.outgoingMessagePipelineService
|
||||
private val presenceTypingService get() = messagingAssembly.presenceTypingService
|
||||
private val inboundTaskQueueService get() = messagingAssembly.inboundTaskQueueService
|
||||
private val syncCoordinator get() = messagingAssembly.syncCoordinator
|
||||
private val callSignalBridge get() = messagingAssembly.callSignalBridge
|
||||
private val inboundPacketHandlerRegistrar get() = messagingAssembly.inboundPacketHandlerRegistrar
|
||||
|
||||
val connectionLifecycleState: StateFlow<ConnectionLifecycleState> =
|
||||
stateAssembly.connectionLifecycleState
|
||||
private val postAuthBootstrapCoordinator =
|
||||
ProtocolPostAuthBootstrapCoordinator(
|
||||
tag = TAG,
|
||||
scope = scope,
|
||||
authBootstrapCoordinator = authBootstrapCoordinator,
|
||||
syncCoordinator = syncCoordinator,
|
||||
ownProfileSyncService = ownProfileSyncService,
|
||||
deviceRuntimeService = deviceRuntimeService,
|
||||
getMessageRepository = { messageRepository },
|
||||
getAppContext = { appContext },
|
||||
getProtocolPublicKey = { getProtocol().getPublicKey() },
|
||||
getProtocolPrivateHash = { getProtocol().getPrivateHash() },
|
||||
sendPacket = ::send,
|
||||
requestTransportServer = TransportManager::requestTransportServer,
|
||||
requestUpdateServer = { com.rosetta.messenger.update.UpdateManager.requestSduServer() },
|
||||
addLog = ::addLog,
|
||||
shortKeyForLog = { value -> shortKeyForLog(value) },
|
||||
getLastSubscribedToken = { lastSubscribedToken },
|
||||
setLastSubscribedToken = { token -> lastSubscribedToken = token }
|
||||
)
|
||||
private val lifecycleCoordinator =
|
||||
ProtocolLifecycleCoordinator(
|
||||
stateStore = lifecycleStateStore,
|
||||
syncCoordinator = syncCoordinator,
|
||||
authBootstrapCoordinator = authBootstrapCoordinator,
|
||||
addLog = ::addLog,
|
||||
shortKeyForLog = { value -> shortKeyForLog(value) },
|
||||
stopWaitingForNetwork = { reason -> networkConnectivityFacade.stopWaitingForNetwork(reason) },
|
||||
cancelAllOutgoingRetries = ::cancelAllOutgoingRetries,
|
||||
recomputeConnectionLifecycleState = stateAssembly::recomputeConnectionLifecycleState,
|
||||
onAuthenticated = { postAuthBootstrapCoordinator.runPostAuthBootstrap("state_authenticated") },
|
||||
onSyncCompletedSideEffects = postAuthBootstrapCoordinator::handleSyncCompletedSideEffects,
|
||||
updateOwnProfileResolved = { publicKey, reason ->
|
||||
IdentityStore.updateOwnProfile(
|
||||
publicKey = publicKey,
|
||||
resolved = true,
|
||||
reason = reason
|
||||
)
|
||||
}
|
||||
)
|
||||
private val readyPacketDispatchCoordinator =
|
||||
ReadyPacketDispatchCoordinator(
|
||||
bootstrapCoordinator = bootstrapCoordinator,
|
||||
getConnectionLifecycleState = lifecycleStateMachine::currentState,
|
||||
resolveAccountPublicKey = {
|
||||
messageRepository?.getCurrentAccountKey()?.trim().orEmpty().ifBlank {
|
||||
lifecycleStateMachine.bootstrapContext.accountPublicKey
|
||||
}
|
||||
},
|
||||
sendPacketDirect = { packet -> getProtocol().sendPacket(packet) },
|
||||
isAuthenticated = ::isAuthenticated,
|
||||
hasActiveInternet = networkConnectivityFacade::hasActiveInternet,
|
||||
waitForNetworkAndReconnect = networkConnectivityFacade::waitForNetworkAndReconnect,
|
||||
reconnectNowIfNeeded = { reason -> getProtocol().reconnectNowIfNeeded(reason) }
|
||||
)
|
||||
private val accountSessionCoordinator =
|
||||
ProtocolAccountSessionCoordinator(
|
||||
stateStore = lifecycleStateStore,
|
||||
syncCoordinator = syncCoordinator,
|
||||
authBootstrapCoordinator = authBootstrapCoordinator,
|
||||
presenceTypingService = presenceTypingService,
|
||||
deviceRuntimeService = deviceRuntimeService,
|
||||
getMessageRepository = { messageRepository },
|
||||
getProtocolState = { state.value },
|
||||
isProtocolAuthenticated = ::isAuthenticated,
|
||||
addLog = ::addLog,
|
||||
shortKeyForLog = { value -> shortKeyForLog(value) },
|
||||
clearReadyPacketQueue = readyPacketDispatchCoordinator::clearReadyPacketQueue,
|
||||
recomputeConnectionLifecycleState = stateAssembly::recomputeConnectionLifecycleState,
|
||||
stopWaitingForNetwork = { reason -> networkConnectivityFacade.stopWaitingForNetwork(reason) },
|
||||
disconnectProtocol = protocolInstanceManager::disconnect,
|
||||
tryRunPostAuthBootstrap = postAuthBootstrapCoordinator::runPostAuthBootstrap,
|
||||
launchVersionUpdateCheck = {
|
||||
scope.launch {
|
||||
messageRepository?.checkAndSendVersionUpdateMessage()
|
||||
}
|
||||
}
|
||||
)
|
||||
private val initializationCoordinator =
|
||||
RuntimeInitializationCoordinator(
|
||||
ensureConnectionSupervisor = ::ensureConnectionSupervisor,
|
||||
setupPacketHandlers = ::setupPacketHandlers,
|
||||
setupStateMonitoring = ::setupStateMonitoring,
|
||||
setAppContext = { context -> appContext = context },
|
||||
hasBoundDependencies = {
|
||||
messageRepository != null && groupRepository != null && accountManager != null
|
||||
},
|
||||
addLog = ::addLog
|
||||
)
|
||||
private val authRestoreService =
|
||||
AuthRestoreService(
|
||||
getAccountManager = { accountManager },
|
||||
addLog = ::addLog,
|
||||
shortKeyForLog = ::shortKeyForLog,
|
||||
authenticate = ::authenticate
|
||||
)
|
||||
private val runtimeShutdownCoordinator =
|
||||
RuntimeShutdownCoordinator(
|
||||
stopWaitingForNetwork = { reason -> networkConnectivityFacade.stopWaitingForNetwork(reason) },
|
||||
destroyPacketSubscriptionRegistry = transportAssembly::destroyPacketSubscriptions,
|
||||
destroyProtocolInstance = protocolInstanceManager::destroy,
|
||||
clearMessageRepositoryInitialization = {
|
||||
messageRepository?.clearInitialization()
|
||||
},
|
||||
clearPresenceTyping = presenceTypingService::clear,
|
||||
clearDeviceRuntime = deviceRuntimeService::clear,
|
||||
resetSyncCoordinator = syncCoordinator::resetForDisconnect,
|
||||
resetAuthBootstrap = authBootstrapCoordinator::reset,
|
||||
cancelRuntimeScope = scope::cancel
|
||||
)
|
||||
private val routingAssembly by lazy {
|
||||
RuntimeRoutingAssembly(
|
||||
scope = scope,
|
||||
tag = TAG,
|
||||
addLog = ::addLog,
|
||||
handleInitializeAccount = accountSessionCoordinator::handleInitializeAccount,
|
||||
handleConnect = connectionOrchestrator::handleConnect,
|
||||
handleFastReconnect = connectionOrchestrator::handleFastReconnect,
|
||||
handleDisconnect = accountSessionCoordinator::handleDisconnect,
|
||||
handleAuthenticate = connectionOrchestrator::handleAuthenticate,
|
||||
handleProtocolStateChanged = lifecycleCoordinator::handleProtocolStateChanged,
|
||||
handleSendPacket = readyPacketDispatchCoordinator::handleSendPacket,
|
||||
handleSyncCompleted = lifecycleCoordinator::handleSyncCompleted,
|
||||
handleOwnProfileResolved = lifecycleCoordinator::handleOwnProfileResolved,
|
||||
handleOwnProfileFallbackTimeout = lifecycleCoordinator::handleOwnProfileFallbackTimeout
|
||||
)
|
||||
}
|
||||
private val connectionControlFacade by lazy {
|
||||
RuntimeConnectionControlFacade(
|
||||
postConnectionEvent = ::postConnectionEvent,
|
||||
initializationCoordinator = initializationCoordinator,
|
||||
syncCoordinator = syncCoordinator,
|
||||
authRestoreService = authRestoreService,
|
||||
runtimeShutdownCoordinator = runtimeShutdownCoordinator,
|
||||
protocolInstanceManager = protocolInstanceManager,
|
||||
subscribePushTokenIfAvailable = postAuthBootstrapCoordinator::subscribePushTokenIfAvailable
|
||||
)
|
||||
}
|
||||
private val directoryFacade by lazy {
|
||||
RuntimeDirectoryFacade(
|
||||
packetRouter = packetRouter,
|
||||
ownProfileSyncService = ownProfileSyncService,
|
||||
deviceRuntimeService = deviceRuntimeService,
|
||||
presenceTypingService = presenceTypingService
|
||||
)
|
||||
}
|
||||
private val packetIoFacade by lazy {
|
||||
RuntimePacketIoFacade(
|
||||
postConnectionEvent = ::postConnectionEvent,
|
||||
outgoingMessagePipelineServiceProvider = { outgoingMessagePipelineService },
|
||||
callSignalBridge = callSignalBridge,
|
||||
packetSubscriptionFacade = packetSubscriptionFacade
|
||||
)
|
||||
}
|
||||
|
||||
private val debugLogService =
|
||||
ProtocolDebugLogService(
|
||||
scope = scope,
|
||||
maxDebugLogs = MAX_DEBUG_LOGS,
|
||||
debugLogFlushDelayMs = DEBUG_LOG_FLUSH_DELAY_MS,
|
||||
heartbeatOkLogMinIntervalMs = HEARTBEAT_OK_LOG_MIN_INTERVAL_MS,
|
||||
protocolTraceFileName = PROTOCOL_TRACE_FILE_NAME,
|
||||
protocolTraceMaxBytes = PROTOCOL_TRACE_MAX_BYTES,
|
||||
protocolTraceKeepBytes = PROTOCOL_TRACE_KEEP_BYTES,
|
||||
appContextProvider = { appContext }
|
||||
)
|
||||
val debugLogs: StateFlow<List<String>> = debugLogService.debugLogs
|
||||
val typingUsers: StateFlow<Set<String>> = presenceTypingService.typingUsers
|
||||
val typingUsersByDialogSnapshot: StateFlow<Map<String, Set<String>>> =
|
||||
presenceTypingService.typingUsersByDialogSnapshot
|
||||
|
||||
val devices: StateFlow<List<DeviceEntry>> = deviceRuntimeService.devices
|
||||
val pendingDeviceVerification: StateFlow<DeviceEntry?> =
|
||||
deviceRuntimeService.pendingDeviceVerification
|
||||
|
||||
// Сигнал обновления own profile (username/name загружены с сервера)
|
||||
val ownProfileUpdated: StateFlow<Long> = ownProfileSyncService.ownProfileUpdated
|
||||
val syncInProgress: StateFlow<Boolean> = syncCoordinator.syncInProgress
|
||||
|
||||
fun connectionControlApi(): RuntimeConnectionControlFacade = connectionControlFacade
|
||||
|
||||
fun directoryApi(): RuntimeDirectoryFacade = directoryFacade
|
||||
|
||||
fun packetIoApi(): RuntimePacketIoFacade = packetIoFacade
|
||||
|
||||
private fun sendSearchPacketViaPacketIo(packet: PacketSearch) {
|
||||
packetIoFacade.send(packet)
|
||||
}
|
||||
|
||||
private fun sendMessagePacketViaPacketIo(packet: PacketMessage) {
|
||||
packetIoFacade.send(packet)
|
||||
}
|
||||
|
||||
private fun sendSyncPacketViaPacketIo(packet: PacketSync) {
|
||||
packetIoFacade.send(packet)
|
||||
}
|
||||
|
||||
private fun ensureConnectionSupervisor() {
|
||||
routingAssembly.start()
|
||||
}
|
||||
|
||||
private fun postConnectionEvent(event: ConnectionEvent) {
|
||||
routingAssembly.post(event)
|
||||
}
|
||||
|
||||
private fun onFastReconnectRequested(reason: String) {
|
||||
postConnectionEvent(ConnectionEvent.FastReconnect(reason))
|
||||
}
|
||||
|
||||
private fun onOwnProfileResolvedEvent(publicKey: String) {
|
||||
postConnectionEvent(ConnectionEvent.OwnProfileResolved(publicKey))
|
||||
}
|
||||
|
||||
private fun onOwnProfileFallbackTimeoutEvent(generation: Long) {
|
||||
postConnectionEvent(ConnectionEvent.OwnProfileFallbackTimeout(generation))
|
||||
}
|
||||
|
||||
fun addLog(message: String) {
|
||||
debugLogService.addLog(message)
|
||||
}
|
||||
|
||||
fun enableUILogs(enabled: Boolean) {
|
||||
debugLogService.enableUILogs(enabled)
|
||||
MessageLogger.setEnabled(enabled)
|
||||
}
|
||||
|
||||
fun clearLogs() {
|
||||
debugLogService.clearLogs()
|
||||
}
|
||||
|
||||
private fun markInboundProcessingFailure(reason: String, error: Throwable? = null) {
|
||||
syncCoordinator.markInboundProcessingFailure()
|
||||
if (error != null) {
|
||||
android.util.Log.e(TAG, reason, error)
|
||||
addLog("❌ $reason: ${error.message ?: error.javaClass.simpleName}")
|
||||
} else {
|
||||
android.util.Log.w(TAG, reason)
|
||||
addLog("⚠️ $reason")
|
||||
}
|
||||
}
|
||||
|
||||
private fun onInboundTaskQueued() {
|
||||
syncCoordinator.trackInboundTaskQueued()
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject process-wide dependencies from DI container.
|
||||
*/
|
||||
fun bindDependencies(
|
||||
messageRepository: MessageRepository,
|
||||
groupRepository: GroupRepository,
|
||||
accountManager: AccountManager
|
||||
) {
|
||||
this.messageRepository = messageRepository
|
||||
this.groupRepository = groupRepository
|
||||
this.accountManager = accountManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Backward-compatible alias kept while migrating call sites.
|
||||
*/
|
||||
fun bindRepositories(
|
||||
messageRepository: MessageRepository,
|
||||
groupRepository: GroupRepository
|
||||
) {
|
||||
this.messageRepository = messageRepository
|
||||
this.groupRepository = groupRepository
|
||||
}
|
||||
|
||||
private fun setupStateMonitoring() {
|
||||
scope.launch {
|
||||
state.collect { newState ->
|
||||
postConnectionEvent(ConnectionEvent.ProtocolStateChanged(newState))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupPacketHandlers() {
|
||||
inboundPacketHandlerRegistrar.register()
|
||||
}
|
||||
|
||||
private fun isGroupDialogKey(value: String): Boolean {
|
||||
val normalized = value.trim().lowercase(Locale.ROOT)
|
||||
return normalized.startsWith("#group:") || normalized.startsWith("group:")
|
||||
}
|
||||
|
||||
private fun finishSyncCycle(reason: String) {
|
||||
postConnectionEvent(ConnectionEvent.SyncCompleted(reason))
|
||||
}
|
||||
|
||||
private fun getProtocol(): Protocol {
|
||||
return protocolInstanceManager.getOrCreateProtocol()
|
||||
}
|
||||
|
||||
val state: StateFlow<ProtocolState>
|
||||
get() = protocolInstanceManager.state
|
||||
|
||||
private fun send(packet: Packet) {
|
||||
packetIoFacade.send(packet)
|
||||
}
|
||||
|
||||
private fun resolveOutgoingRetry(messageId: String) {
|
||||
packetIoFacade.resolveOutgoingRetry(messageId)
|
||||
}
|
||||
|
||||
private fun cancelAllOutgoingRetries() {
|
||||
packetIoFacade.clearOutgoingRetries()
|
||||
}
|
||||
|
||||
private fun authenticate(publicKey: String, privateHash: String) {
|
||||
postConnectionEvent(
|
||||
ConnectionEvent.Authenticate(publicKey = publicKey, privateHash = privateHash)
|
||||
)
|
||||
}
|
||||
|
||||
private fun waitPacket(packetId: Int, callback: (Packet) -> Unit) {
|
||||
packetIoFacade.waitPacket(packetId, callback)
|
||||
}
|
||||
|
||||
private fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) {
|
||||
packetIoFacade.unwaitPacket(packetId, callback)
|
||||
}
|
||||
|
||||
private fun shortKeyForLog(value: String, visible: Int = 8): String {
|
||||
val trimmed = value.trim()
|
||||
if (trimmed.isBlank()) return "<empty>"
|
||||
return if (trimmed.length <= visible) trimmed else "${trimmed.take(visible)}…"
|
||||
}
|
||||
|
||||
private fun shortTextForLog(value: String, limit: Int = 80): String {
|
||||
val normalized = value.replace('\n', ' ').replace('\r', ' ').trim()
|
||||
if (normalized.isBlank()) return "<empty>"
|
||||
return if (normalized.length <= limit) normalized else "${normalized.take(limit)}…"
|
||||
}
|
||||
|
||||
private fun isAuthenticated(): Boolean = connectionControlFacade.isAuthenticated()
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import android.content.Context
|
||||
import com.rosetta.messenger.network.connection.AuthRestoreService
|
||||
import com.rosetta.messenger.network.connection.ProtocolInstanceManager
|
||||
import com.rosetta.messenger.network.connection.RuntimeInitializationCoordinator
|
||||
import com.rosetta.messenger.network.connection.RuntimeShutdownCoordinator
|
||||
import com.rosetta.messenger.network.connection.SyncCoordinator
|
||||
|
||||
class RuntimeConnectionControlFacade(
|
||||
private val postConnectionEvent: (ConnectionEvent) -> Unit,
|
||||
private val initializationCoordinator: RuntimeInitializationCoordinator,
|
||||
private val syncCoordinator: SyncCoordinator,
|
||||
private val authRestoreService: AuthRestoreService,
|
||||
private val runtimeShutdownCoordinator: RuntimeShutdownCoordinator,
|
||||
private val protocolInstanceManager: ProtocolInstanceManager,
|
||||
private val subscribePushTokenIfAvailable: (String?) -> Unit
|
||||
) {
|
||||
fun initialize(context: Context) {
|
||||
initializationCoordinator.initialize(context)
|
||||
}
|
||||
|
||||
fun initializeAccount(publicKey: String, privateKey: String) {
|
||||
postConnectionEvent(
|
||||
ConnectionEvent.InitializeAccount(publicKey = publicKey, privateKey = privateKey)
|
||||
)
|
||||
}
|
||||
|
||||
fun connect() {
|
||||
postConnectionEvent(ConnectionEvent.Connect(reason = "api_connect"))
|
||||
}
|
||||
|
||||
fun reconnectNowIfNeeded(reason: String = "foreground_resume") {
|
||||
postConnectionEvent(ConnectionEvent.FastReconnect(reason = reason))
|
||||
}
|
||||
|
||||
fun syncOnForeground() {
|
||||
syncCoordinator.syncOnForeground()
|
||||
}
|
||||
|
||||
fun forceSynchronize(backtrackMs: Long) {
|
||||
if (!isAuthenticated()) {
|
||||
reconnectNowIfNeeded("manual_sync_button")
|
||||
return
|
||||
}
|
||||
syncCoordinator.forceSynchronize(backtrackMs)
|
||||
}
|
||||
|
||||
fun authenticate(publicKey: String, privateHash: String) {
|
||||
postConnectionEvent(
|
||||
ConnectionEvent.Authenticate(publicKey = publicKey, privateHash = privateHash)
|
||||
)
|
||||
}
|
||||
|
||||
fun restoreAuthFromStoredCredentials(
|
||||
preferredPublicKey: String? = null,
|
||||
reason: String = "background_restore"
|
||||
): Boolean {
|
||||
return authRestoreService.restoreAuthFromStoredCredentials(preferredPublicKey, reason)
|
||||
}
|
||||
|
||||
fun subscribePushToken(forceToken: String? = null) {
|
||||
subscribePushTokenIfAvailable(forceToken)
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
postConnectionEvent(
|
||||
ConnectionEvent.Disconnect(
|
||||
reason = "manual_disconnect",
|
||||
clearCredentials = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
runtimeShutdownCoordinator.destroy()
|
||||
}
|
||||
|
||||
fun isAuthenticated(): Boolean = protocolInstanceManager.isAuthenticated()
|
||||
|
||||
fun isConnected(): Boolean = protocolInstanceManager.isConnected()
|
||||
|
||||
fun getPrivateHashOrNull(): String? {
|
||||
return runCatching { protocolInstanceManager.getOrCreateProtocol().getPrivateHash() }.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import com.rosetta.messenger.network.connection.DeviceRuntimeService
|
||||
import com.rosetta.messenger.network.connection.OwnProfileSyncService
|
||||
import com.rosetta.messenger.network.connection.PacketRouter
|
||||
import com.rosetta.messenger.network.connection.PresenceTypingService
|
||||
|
||||
class RuntimeDirectoryFacade(
|
||||
private val packetRouter: PacketRouter,
|
||||
private val ownProfileSyncService: OwnProfileSyncService,
|
||||
private val deviceRuntimeService: DeviceRuntimeService,
|
||||
private val presenceTypingService: PresenceTypingService
|
||||
) {
|
||||
fun getTypingUsersForDialog(dialogKey: String): Set<String> {
|
||||
return presenceTypingService.getTypingUsersForDialog(dialogKey)
|
||||
}
|
||||
|
||||
suspend fun resolveUserName(publicKey: String, timeoutMs: Long = 3000): String? {
|
||||
return packetRouter.resolveUserName(publicKey = publicKey, timeoutMs = timeoutMs)
|
||||
}
|
||||
|
||||
fun getCachedUserName(publicKey: String): String? {
|
||||
return packetRouter.getCachedUserName(publicKey)
|
||||
}
|
||||
|
||||
fun notifyOwnProfileUpdated() {
|
||||
ownProfileSyncService.notifyOwnProfileUpdated()
|
||||
}
|
||||
|
||||
fun getCachedUserInfo(publicKey: String): SearchUser? {
|
||||
return packetRouter.getCachedUserInfo(publicKey)
|
||||
}
|
||||
|
||||
fun getCachedUserByUsername(username: String): SearchUser? {
|
||||
return packetRouter.getCachedUserByUsername(username)
|
||||
}
|
||||
|
||||
suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long = 3000): SearchUser? {
|
||||
return packetRouter.resolveUserInfo(publicKey = publicKey, timeoutMs = timeoutMs)
|
||||
}
|
||||
|
||||
suspend fun searchUsers(query: String, timeoutMs: Long = 3000): List<SearchUser> {
|
||||
return packetRouter.searchUsers(query = query, timeoutMs = timeoutMs)
|
||||
}
|
||||
|
||||
fun acceptDevice(deviceId: String) {
|
||||
deviceRuntimeService.acceptDevice(deviceId)
|
||||
}
|
||||
|
||||
fun declineDevice(deviceId: String) {
|
||||
deviceRuntimeService.declineDevice(deviceId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import com.rosetta.messenger.data.GroupRepository
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.network.connection.CallSignalBridge
|
||||
import com.rosetta.messenger.network.connection.DeviceRuntimeService
|
||||
import com.rosetta.messenger.network.connection.InboundPacketHandlerRegistrar
|
||||
import com.rosetta.messenger.network.connection.InboundTaskQueueService
|
||||
import com.rosetta.messenger.network.connection.OutgoingMessagePipelineService
|
||||
import com.rosetta.messenger.network.connection.OwnProfileSyncService
|
||||
import com.rosetta.messenger.network.connection.PacketRouter
|
||||
import com.rosetta.messenger.network.connection.PresenceTypingService
|
||||
import com.rosetta.messenger.network.connection.SyncCoordinator
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
internal class RuntimeMessagingAssembly(
|
||||
tag: String,
|
||||
scope: CoroutineScope,
|
||||
typingIndicatorTimeoutMs: Long,
|
||||
syncRequestTimeoutMs: Long,
|
||||
manualSyncBacktrackMs: Long,
|
||||
deviceRuntimeService: DeviceRuntimeService,
|
||||
ownProfileSyncService: OwnProfileSyncService,
|
||||
isAuthenticated: () -> Boolean,
|
||||
getProtocolPublicKey: () -> String,
|
||||
getProtocolPrivateHash: () -> String?,
|
||||
getMessageRepository: () -> MessageRepository?,
|
||||
getGroupRepository: () -> GroupRepository?,
|
||||
sendSearchPacket: (PacketSearch) -> Unit,
|
||||
sendMessagePacket: (PacketMessage) -> Unit,
|
||||
sendSyncPacket: (PacketSync) -> Unit,
|
||||
sendPacket: (Packet) -> Unit,
|
||||
waitPacket: (Int, (Packet) -> Unit) -> Unit,
|
||||
unwaitPacket: (Int, (Packet) -> Unit) -> Unit,
|
||||
addLog: (String) -> Unit,
|
||||
shortKeyForLog: (String, Int) -> String,
|
||||
shortTextForLog: (String, Int) -> String,
|
||||
onSyncCompleted: (String) -> Unit,
|
||||
onInboundTaskQueued: () -> Unit,
|
||||
onInboundTaskFailure: (String, Throwable?) -> Unit,
|
||||
resolveOutgoingRetry: (String) -> Unit,
|
||||
isGroupDialogKey: (String) -> Boolean,
|
||||
setTransportServer: (String) -> Unit,
|
||||
onOwnProfileResolved: (String) -> Unit
|
||||
) {
|
||||
val packetRouter =
|
||||
PacketRouter(
|
||||
sendSearchPacket = sendSearchPacket,
|
||||
privateHashProvider = getProtocolPrivateHash
|
||||
)
|
||||
|
||||
val outgoingMessagePipelineService =
|
||||
OutgoingMessagePipelineService(
|
||||
scope = scope,
|
||||
getRepository = getMessageRepository,
|
||||
sendPacket = sendMessagePacket,
|
||||
isAuthenticated = isAuthenticated,
|
||||
addLog = addLog
|
||||
)
|
||||
|
||||
val presenceTypingService =
|
||||
PresenceTypingService(
|
||||
scope = scope,
|
||||
typingIndicatorTimeoutMs = typingIndicatorTimeoutMs
|
||||
)
|
||||
|
||||
val inboundTaskQueueService =
|
||||
InboundTaskQueueService(
|
||||
scope = scope,
|
||||
onTaskQueued = onInboundTaskQueued,
|
||||
onTaskFailure = onInboundTaskFailure
|
||||
)
|
||||
|
||||
val syncCoordinator =
|
||||
SyncCoordinator(
|
||||
scope = scope,
|
||||
syncRequestTimeoutMs = syncRequestTimeoutMs,
|
||||
manualSyncBacktrackMs = manualSyncBacktrackMs,
|
||||
addLog = addLog,
|
||||
isAuthenticated = isAuthenticated,
|
||||
getRepository = getMessageRepository,
|
||||
getProtocolPublicKey = getProtocolPublicKey,
|
||||
sendPacket = sendSyncPacket,
|
||||
onSyncCompleted = onSyncCompleted,
|
||||
whenInboundTasksFinish = inboundTaskQueueService::whenTasksFinish
|
||||
)
|
||||
|
||||
val callSignalBridge =
|
||||
CallSignalBridge(
|
||||
sendPacket = sendPacket,
|
||||
waitPacket = waitPacket,
|
||||
unwaitPacket = unwaitPacket,
|
||||
addLog = addLog,
|
||||
shortKeyForLog = shortKeyForLog,
|
||||
shortTextForLog = shortTextForLog
|
||||
)
|
||||
|
||||
val inboundPacketHandlerRegistrar =
|
||||
InboundPacketHandlerRegistrar(
|
||||
tag = tag,
|
||||
scope = scope,
|
||||
syncCoordinator = syncCoordinator,
|
||||
presenceTypingService = presenceTypingService,
|
||||
deviceRuntimeService = deviceRuntimeService,
|
||||
packetRouter = packetRouter,
|
||||
ownProfileSyncService = ownProfileSyncService,
|
||||
waitPacket = waitPacket,
|
||||
launchInboundPacketTask = inboundTaskQueueService::enqueue,
|
||||
getMessageRepository = getMessageRepository,
|
||||
getGroupRepository = getGroupRepository,
|
||||
getProtocolPublicKey = { getProtocolPublicKey().trim().orEmpty() },
|
||||
addLog = addLog,
|
||||
markInboundProcessingFailure = onInboundTaskFailure,
|
||||
resolveOutgoingRetry = resolveOutgoingRetry,
|
||||
isGroupDialogKey = isGroupDialogKey,
|
||||
onOwnProfileResolved = onOwnProfileResolved,
|
||||
setTransportServer = setTransportServer
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import com.rosetta.messenger.network.connection.CallSignalBridge
|
||||
import com.rosetta.messenger.network.connection.OutgoingMessagePipelineService
|
||||
import com.rosetta.messenger.network.connection.PacketSubscriptionFacade
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
class RuntimePacketIoFacade(
|
||||
private val postConnectionEvent: (ConnectionEvent) -> Unit,
|
||||
private val outgoingMessagePipelineServiceProvider: () -> OutgoingMessagePipelineService,
|
||||
private val callSignalBridge: CallSignalBridge,
|
||||
private val packetSubscriptionFacade: PacketSubscriptionFacade
|
||||
) {
|
||||
fun send(packet: Packet) {
|
||||
postConnectionEvent(ConnectionEvent.SendPacket(packet))
|
||||
}
|
||||
|
||||
fun sendMessageWithRetry(packet: PacketMessage) {
|
||||
outgoingMessagePipelineServiceProvider().sendWithRetry(packet)
|
||||
}
|
||||
|
||||
fun resolveOutgoingRetry(messageId: String) {
|
||||
outgoingMessagePipelineServiceProvider().resolveOutgoingRetry(messageId)
|
||||
}
|
||||
|
||||
fun clearOutgoingRetries() {
|
||||
outgoingMessagePipelineServiceProvider().clearRetryQueue()
|
||||
}
|
||||
|
||||
fun sendPacket(packet: Packet) {
|
||||
send(packet)
|
||||
}
|
||||
|
||||
fun sendCallSignal(
|
||||
signalType: SignalType,
|
||||
src: String = "",
|
||||
dst: String = "",
|
||||
sharedPublic: String = "",
|
||||
callId: String = "",
|
||||
joinToken: String = ""
|
||||
) {
|
||||
callSignalBridge.sendCallSignal(signalType, src, dst, sharedPublic, callId, joinToken)
|
||||
}
|
||||
|
||||
fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) {
|
||||
callSignalBridge.sendWebRtcSignal(signalType, sdpOrCandidate)
|
||||
}
|
||||
|
||||
fun requestIceServers() {
|
||||
callSignalBridge.requestIceServers()
|
||||
}
|
||||
|
||||
fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit {
|
||||
return callSignalBridge.waitCallSignal(callback)
|
||||
}
|
||||
|
||||
fun unwaitCallSignal(callback: (Packet) -> Unit) {
|
||||
callSignalBridge.unwaitCallSignal(callback)
|
||||
}
|
||||
|
||||
fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit {
|
||||
return callSignalBridge.waitWebRtcSignal(callback)
|
||||
}
|
||||
|
||||
fun unwaitWebRtcSignal(callback: (Packet) -> Unit) {
|
||||
callSignalBridge.unwaitWebRtcSignal(callback)
|
||||
}
|
||||
|
||||
fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit {
|
||||
return callSignalBridge.waitIceServers(callback)
|
||||
}
|
||||
|
||||
fun unwaitIceServers(callback: (Packet) -> Unit) {
|
||||
callSignalBridge.unwaitIceServers(callback)
|
||||
}
|
||||
|
||||
fun waitPacket(packetId: Int, callback: (Packet) -> Unit) {
|
||||
packetSubscriptionFacade.waitPacket(packetId, callback)
|
||||
}
|
||||
|
||||
fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) {
|
||||
packetSubscriptionFacade.unwaitPacket(packetId, callback)
|
||||
}
|
||||
|
||||
fun packetFlow(packetId: Int): SharedFlow<Packet> {
|
||||
return packetSubscriptionFacade.packetFlow(packetId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import android.util.Log
|
||||
import com.rosetta.messenger.network.connection.ConnectionEventRouter
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
internal class RuntimeRoutingAssembly(
|
||||
scope: CoroutineScope,
|
||||
tag: String,
|
||||
addLog: (String) -> Unit,
|
||||
handleInitializeAccount: (publicKey: String, privateKey: String) -> Unit,
|
||||
handleConnect: (reason: String) -> Unit,
|
||||
handleFastReconnect: (reason: String) -> Unit,
|
||||
handleDisconnect: (reason: String, clearCredentials: Boolean) -> Unit,
|
||||
handleAuthenticate: (publicKey: String, privateHash: String) -> Unit,
|
||||
handleProtocolStateChanged: (state: ProtocolState) -> Unit,
|
||||
handleSendPacket: (packet: Packet) -> Unit,
|
||||
handleSyncCompleted: (reason: String) -> Unit,
|
||||
handleOwnProfileResolved: (publicKey: String) -> Unit,
|
||||
handleOwnProfileFallbackTimeout: (sessionGeneration: Long) -> Unit
|
||||
) {
|
||||
private val connectionEventRouter =
|
||||
ConnectionEventRouter(
|
||||
handleInitializeAccount = handleInitializeAccount,
|
||||
handleConnect = handleConnect,
|
||||
handleFastReconnect = handleFastReconnect,
|
||||
handleDisconnect = handleDisconnect,
|
||||
handleAuthenticate = handleAuthenticate,
|
||||
handleProtocolStateChanged = handleProtocolStateChanged,
|
||||
handleSendPacket = handleSendPacket,
|
||||
handleSyncCompleted = handleSyncCompleted,
|
||||
handleOwnProfileResolved = handleOwnProfileResolved,
|
||||
handleOwnProfileFallbackTimeout = handleOwnProfileFallbackTimeout
|
||||
)
|
||||
|
||||
private val connectionSupervisor =
|
||||
ProtocolConnectionSupervisor(
|
||||
scope = scope,
|
||||
onEvent = { event -> connectionEventRouter.route(event) },
|
||||
onError = { error -> Log.e(tag, "ConnectionSupervisor event failed", error) },
|
||||
addLog = addLog
|
||||
)
|
||||
|
||||
fun start() {
|
||||
connectionSupervisor.start()
|
||||
}
|
||||
|
||||
fun post(event: ConnectionEvent) {
|
||||
connectionSupervisor.post(event)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import com.rosetta.messenger.network.connection.BootstrapCoordinator
|
||||
import com.rosetta.messenger.network.connection.OwnProfileFallbackTimerService
|
||||
import com.rosetta.messenger.network.connection.ProtocolLifecycleStateStoreImpl
|
||||
import com.rosetta.messenger.network.connection.RuntimeLifecycleStateMachine
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
internal class RuntimeStateAssembly(
|
||||
scope: CoroutineScope,
|
||||
readyPacketQueueMax: Int,
|
||||
readyPacketQueueTtlMs: Long,
|
||||
ownProfileFallbackTimeoutMs: Long,
|
||||
addLog: (String) -> Unit,
|
||||
shortKeyForLog: (String) -> String,
|
||||
sendPacketDirect: (Packet) -> Unit,
|
||||
onOwnProfileFallbackTimeout: (Long) -> Unit,
|
||||
clearLastSubscribedTokenValue: () -> Unit
|
||||
) {
|
||||
private val readyPacketGate =
|
||||
ReadyPacketGate(
|
||||
maxSize = readyPacketQueueMax,
|
||||
ttlMs = readyPacketQueueTtlMs
|
||||
)
|
||||
|
||||
val bootstrapCoordinator =
|
||||
BootstrapCoordinator(
|
||||
readyPacketGate = readyPacketGate,
|
||||
addLog = addLog,
|
||||
shortKeyForLog = shortKeyForLog,
|
||||
sendPacketDirect = sendPacketDirect
|
||||
)
|
||||
|
||||
val lifecycleStateMachine =
|
||||
RuntimeLifecycleStateMachine(
|
||||
bootstrapCoordinator = bootstrapCoordinator,
|
||||
addLog = addLog
|
||||
)
|
||||
|
||||
private val ownProfileFallbackTimerService =
|
||||
OwnProfileFallbackTimerService(
|
||||
scope = scope,
|
||||
fallbackTimeoutMs = ownProfileFallbackTimeoutMs,
|
||||
onTimeout = onOwnProfileFallbackTimeout
|
||||
)
|
||||
|
||||
val lifecycleStateStore =
|
||||
ProtocolLifecycleStateStoreImpl(
|
||||
lifecycleStateMachine = lifecycleStateMachine,
|
||||
ownProfileFallbackTimerService = ownProfileFallbackTimerService,
|
||||
clearLastSubscribedTokenValue = clearLastSubscribedTokenValue
|
||||
)
|
||||
|
||||
val connectionLifecycleState: StateFlow<ConnectionLifecycleState> =
|
||||
lifecycleStateMachine.connectionLifecycleState
|
||||
|
||||
fun recomputeConnectionLifecycleState(reason: String) {
|
||||
lifecycleStateMachine.recompute(reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import android.content.Context
|
||||
import com.rosetta.messenger.network.connection.NetworkConnectivityFacade
|
||||
import com.rosetta.messenger.network.connection.NetworkReconnectWatcher
|
||||
import com.rosetta.messenger.network.connection.PacketSubscriptionFacade
|
||||
import com.rosetta.messenger.network.connection.ProtocolInstanceManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
internal class RuntimeTransportAssembly(
|
||||
scope: CoroutineScope,
|
||||
networkWaitTimeoutMs: Long,
|
||||
serverAddress: String,
|
||||
getAppContext: () -> Context?,
|
||||
addLog: (String) -> Unit,
|
||||
onFastReconnectRequested: (String) -> Unit
|
||||
) {
|
||||
private val networkReconnectWatcher =
|
||||
NetworkReconnectWatcher(
|
||||
scope = scope,
|
||||
networkWaitTimeoutMs = networkWaitTimeoutMs,
|
||||
addLog = addLog,
|
||||
onReconnectRequested = onFastReconnectRequested
|
||||
)
|
||||
|
||||
val networkConnectivityFacade =
|
||||
NetworkConnectivityFacade(
|
||||
networkReconnectWatcher = networkReconnectWatcher,
|
||||
getAppContext = getAppContext
|
||||
)
|
||||
|
||||
val protocolInstanceManager =
|
||||
ProtocolInstanceManager(
|
||||
serverAddress = serverAddress,
|
||||
addLog = addLog,
|
||||
isNetworkAvailable = networkConnectivityFacade::hasActiveInternet,
|
||||
onNetworkUnavailable = {
|
||||
networkConnectivityFacade.waitForNetworkAndReconnect("protocol_connect")
|
||||
}
|
||||
)
|
||||
|
||||
private val packetSubscriptionRegistry =
|
||||
PacketSubscriptionRegistry(
|
||||
protocolProvider = protocolInstanceManager::getOrCreateProtocol,
|
||||
scope = scope,
|
||||
addLog = addLog
|
||||
)
|
||||
|
||||
val packetSubscriptionFacade = PacketSubscriptionFacade(packetSubscriptionRegistry)
|
||||
|
||||
fun destroyPacketSubscriptions() {
|
||||
packetSubscriptionRegistry.destroy()
|
||||
}
|
||||
}
|
||||
@@ -1,163 +1,318 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
/**
|
||||
* Binary stream for protocol packets
|
||||
* Matches the React Native implementation exactly
|
||||
* Binary stream for protocol packets.
|
||||
*
|
||||
* Parity with desktop/server:
|
||||
* - signed: Int8/16/32/64 (two's complement)
|
||||
* - unsigned: UInt8/16/32/64
|
||||
* - String: length(UInt32) + chars(UInt16)
|
||||
* - byte[]: length(UInt32) + raw bytes
|
||||
*/
|
||||
class Stream(stream: ByteArray = ByteArray(0)) {
|
||||
private var _stream = mutableListOf<Int>()
|
||||
private var _readPointer = 0
|
||||
private var _writePointer = 0
|
||||
|
||||
init {
|
||||
_stream = stream.map { it.toInt() and 0xFF }.toMutableList()
|
||||
}
|
||||
|
||||
fun getStream(): ByteArray {
|
||||
return _stream.map { it.toByte() }.toByteArray()
|
||||
class Stream(initial: ByteArray = byteArrayOf()) {
|
||||
private var stream: ByteArray = initial.copyOf()
|
||||
private var readPointer: Int = 0 // bits
|
||||
private var writePointer: Int = stream.size shl 3 // bits
|
||||
|
||||
fun getStream(): ByteArray = stream.copyOf(length())
|
||||
|
||||
fun setStream(value: ByteArray) {
|
||||
stream = value.copyOf()
|
||||
readPointer = 0
|
||||
writePointer = stream.size shl 3
|
||||
}
|
||||
|
||||
fun getReadPointerBits(): Int = _readPointer
|
||||
fun getBuffer(): ByteArray = getStream()
|
||||
|
||||
fun getTotalBits(): Int = _stream.size * 8
|
||||
fun isEmpty(): Boolean = writePointer == 0
|
||||
|
||||
fun getRemainingBits(): Int = getTotalBits() - _readPointer
|
||||
fun length(): Int = (writePointer + 7) shr 3
|
||||
|
||||
fun hasRemainingBits(): Boolean = _readPointer < getTotalBits()
|
||||
|
||||
fun setStream(stream: ByteArray) {
|
||||
_stream = stream.map { it.toInt() and 0xFF }.toMutableList()
|
||||
_readPointer = 0
|
||||
fun getReadPointerBits(): Int = readPointer
|
||||
|
||||
fun setReadPointerBits(bits: Int) {
|
||||
readPointer = bits.coerceIn(0, writePointer)
|
||||
}
|
||||
|
||||
fun writeInt8(value: Int) {
|
||||
val negationBit = if (value < 0) 1 else 0
|
||||
val int8Value = Math.abs(value) and 0xFF
|
||||
|
||||
ensureCapacity(_writePointer shr 3)
|
||||
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (negationBit shl (7 - (_writePointer and 7)))
|
||||
_writePointer++
|
||||
|
||||
for (i in 0 until 8) {
|
||||
val bit = (int8Value shr (7 - i)) and 1
|
||||
ensureCapacity(_writePointer shr 3)
|
||||
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (bit shl (7 - (_writePointer and 7)))
|
||||
_writePointer++
|
||||
}
|
||||
}
|
||||
|
||||
fun readInt8(): Int {
|
||||
var value = 0
|
||||
val negationBit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
|
||||
_readPointer++
|
||||
|
||||
for (i in 0 until 8) {
|
||||
val bit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
|
||||
value = value or (bit shl (7 - i))
|
||||
_readPointer++
|
||||
}
|
||||
|
||||
return if (negationBit == 1) -value else value
|
||||
}
|
||||
|
||||
|
||||
fun getTotalBits(): Int = writePointer
|
||||
|
||||
fun getRemainingBits(): Int = (writePointer - readPointer).coerceAtLeast(0)
|
||||
|
||||
fun hasRemainingBits(): Boolean = readPointer < writePointer
|
||||
|
||||
fun writeBit(value: Int) {
|
||||
val bit = value and 1
|
||||
ensureCapacity(_writePointer shr 3)
|
||||
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (bit shl (7 - (_writePointer and 7)))
|
||||
_writePointer++
|
||||
writeBits((value and 1).toLong(), 1)
|
||||
}
|
||||
|
||||
fun readBit(): Int {
|
||||
val bit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
|
||||
_readPointer++
|
||||
return bit
|
||||
}
|
||||
|
||||
|
||||
fun readBit(): Int = readBits(1).toInt()
|
||||
|
||||
fun writeBoolean(value: Boolean) {
|
||||
writeBit(if (value) 1 else 0)
|
||||
}
|
||||
|
||||
fun readBoolean(): Boolean {
|
||||
return readBit() == 1
|
||||
|
||||
fun readBoolean(): Boolean = readBit() == 1
|
||||
|
||||
fun writeByte(value: Byte) {
|
||||
writeUInt8(value.toInt() and 0xFF)
|
||||
}
|
||||
|
||||
|
||||
fun readByte(): Byte = readUInt8().toByte()
|
||||
|
||||
fun writeUInt8(value: Int) {
|
||||
val v = value and 0xFF
|
||||
|
||||
// Fast path when byte-aligned.
|
||||
if ((writePointer and 7) == 0) {
|
||||
reserveBits(8)
|
||||
stream[writePointer shr 3] = v.toByte()
|
||||
writePointer += 8
|
||||
return
|
||||
}
|
||||
|
||||
writeBits(v.toLong(), 8)
|
||||
}
|
||||
|
||||
fun readUInt8(): Int {
|
||||
if (remainingBits() < 8L) {
|
||||
throw IllegalStateException("Not enough bits to read UInt8")
|
||||
}
|
||||
|
||||
// Fast path when byte-aligned.
|
||||
if ((readPointer and 7) == 0) {
|
||||
val value = stream[readPointer shr 3].toInt() and 0xFF
|
||||
readPointer += 8
|
||||
return value
|
||||
}
|
||||
|
||||
return readBits(8).toInt() and 0xFF
|
||||
}
|
||||
|
||||
fun writeInt8(value: Int) {
|
||||
writeUInt8(value)
|
||||
}
|
||||
|
||||
fun readInt8(): Int = readUInt8().toByte().toInt()
|
||||
|
||||
fun writeUInt16(value: Int) {
|
||||
val v = value and 0xFFFF
|
||||
writeUInt8((v ushr 8) and 0xFF)
|
||||
writeUInt8(v and 0xFF)
|
||||
}
|
||||
|
||||
fun readUInt16(): Int {
|
||||
val hi = readUInt8()
|
||||
val lo = readUInt8()
|
||||
return (hi shl 8) or lo
|
||||
}
|
||||
|
||||
fun writeInt16(value: Int) {
|
||||
writeInt8(value shr 8)
|
||||
writeInt8(value and 0xFF)
|
||||
writeUInt16(value)
|
||||
}
|
||||
|
||||
fun readInt16(): Int {
|
||||
val high = readInt8() shl 8
|
||||
return high or readInt8()
|
||||
|
||||
fun readInt16(): Int = readUInt16().toShort().toInt()
|
||||
|
||||
fun writeUInt32(value: Long) {
|
||||
if (value < 0L || value > 0xFFFF_FFFFL) {
|
||||
throw IllegalArgumentException("UInt32 out of range: $value")
|
||||
}
|
||||
|
||||
writeUInt8(((value ushr 24) and 0xFF).toInt())
|
||||
writeUInt8(((value ushr 16) and 0xFF).toInt())
|
||||
writeUInt8(((value ushr 8) and 0xFF).toInt())
|
||||
writeUInt8((value and 0xFF).toInt())
|
||||
}
|
||||
|
||||
|
||||
fun readUInt32(): Long {
|
||||
val b1 = readUInt8().toLong() and 0xFFL
|
||||
val b2 = readUInt8().toLong() and 0xFFL
|
||||
val b3 = readUInt8().toLong() and 0xFFL
|
||||
val b4 = readUInt8().toLong() and 0xFFL
|
||||
return (b1 shl 24) or (b2 shl 16) or (b3 shl 8) or b4
|
||||
}
|
||||
|
||||
fun writeInt32(value: Int) {
|
||||
writeInt16(value shr 16)
|
||||
writeInt16(value and 0xFFFF)
|
||||
writeUInt32(value.toLong() and 0xFFFF_FFFFL)
|
||||
}
|
||||
|
||||
fun readInt32(): Int {
|
||||
val high = readInt16() shl 16
|
||||
return high or readInt16()
|
||||
|
||||
fun readInt32(): Int = readUInt32().toInt()
|
||||
|
||||
/** Writes raw 64-bit pattern (UInt64 bit-pattern in Long). */
|
||||
fun writeUInt64(value: Long) {
|
||||
writeUInt8(((value ushr 56) and 0xFF).toInt())
|
||||
writeUInt8(((value ushr 48) and 0xFF).toInt())
|
||||
writeUInt8(((value ushr 40) and 0xFF).toInt())
|
||||
writeUInt8(((value ushr 32) and 0xFF).toInt())
|
||||
writeUInt8(((value ushr 24) and 0xFF).toInt())
|
||||
writeUInt8(((value ushr 16) and 0xFF).toInt())
|
||||
writeUInt8(((value ushr 8) and 0xFF).toInt())
|
||||
writeUInt8((value and 0xFF).toInt())
|
||||
}
|
||||
|
||||
fun writeInt64(value: Long) {
|
||||
val high = (value shr 32).toInt()
|
||||
val low = (value and 0xFFFFFFFF).toInt()
|
||||
writeInt32(high)
|
||||
writeInt32(low)
|
||||
}
|
||||
|
||||
fun readInt64(): Long {
|
||||
val high = readInt32().toLong()
|
||||
val low = (readInt32().toLong() and 0xFFFFFFFFL)
|
||||
|
||||
/** Reads raw 64-bit pattern (UInt64 bit-pattern in Long). */
|
||||
fun readUInt64(): Long {
|
||||
val high = readUInt32() and 0xFFFF_FFFFL
|
||||
val low = readUInt32() and 0xFFFF_FFFFL
|
||||
return (high shl 32) or low
|
||||
}
|
||||
|
||||
|
||||
fun writeInt64(value: Long) {
|
||||
writeUInt64(value)
|
||||
}
|
||||
|
||||
fun readInt64(): Long = readUInt64()
|
||||
|
||||
fun writeFloat32(value: Float) {
|
||||
writeInt32(java.lang.Float.floatToIntBits(value))
|
||||
}
|
||||
|
||||
fun readFloat32(): Float = java.lang.Float.intBitsToFloat(readInt32())
|
||||
|
||||
/** String: length(UInt32) + chars(UInt16). */
|
||||
fun writeString(value: String) {
|
||||
writeInt32(value.length)
|
||||
for (char in value) {
|
||||
writeInt16(char.code)
|
||||
writeUInt32(value.length.toLong())
|
||||
|
||||
if (value.isEmpty()) return
|
||||
|
||||
reserveBits(value.length.toLong() * 16L)
|
||||
for (i in value.indices) {
|
||||
writeUInt16(value[i].code and 0xFFFF)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun readString(): String {
|
||||
val length = readInt32()
|
||||
// Desktop parity + safety: don't trust malformed string length.
|
||||
val bytesAvailable = _stream.size - (_readPointer shr 3)
|
||||
if (length < 0 || (length.toLong() * 2L) > bytesAvailable.toLong()) {
|
||||
android.util.Log.w(
|
||||
"RosettaStream",
|
||||
"readString invalid length=$length, bytesAvailable=$bytesAvailable, readPointer=$_readPointer"
|
||||
)
|
||||
return ""
|
||||
val lenLong = readUInt32()
|
||||
if (lenLong > Int.MAX_VALUE.toLong()) {
|
||||
throw IllegalStateException("String length too large: $lenLong")
|
||||
}
|
||||
val sb = StringBuilder()
|
||||
for (i in 0 until length) {
|
||||
sb.append(readInt16().toChar())
|
||||
|
||||
val length = lenLong.toInt()
|
||||
val requiredBits = length.toLong() * 16L
|
||||
if (requiredBits > remainingBits()) {
|
||||
throw IllegalStateException("Not enough bits to read string")
|
||||
}
|
||||
|
||||
val sb = StringBuilder(length)
|
||||
repeat(length) {
|
||||
sb.append(readUInt16().toChar())
|
||||
}
|
||||
return sb.toString()
|
||||
}
|
||||
|
||||
|
||||
/** byte[]: length(UInt32) + payload. */
|
||||
fun writeBytes(value: ByteArray) {
|
||||
writeInt32(value.size)
|
||||
for (byte in value) {
|
||||
writeInt8(byte.toInt())
|
||||
writeUInt32(value.size.toLong())
|
||||
|
||||
if (value.isEmpty()) return
|
||||
|
||||
reserveBits(value.size.toLong() * 8L)
|
||||
|
||||
// Fast path when byte-aligned.
|
||||
if ((writePointer and 7) == 0) {
|
||||
val byteIndex = writePointer shr 3
|
||||
ensureCapacity(byteIndex + value.size - 1)
|
||||
System.arraycopy(value, 0, stream, byteIndex, value.size)
|
||||
writePointer += value.size shl 3
|
||||
return
|
||||
}
|
||||
|
||||
value.forEach { writeUInt8(it.toInt() and 0xFF) }
|
||||
}
|
||||
|
||||
|
||||
fun readBytes(): ByteArray {
|
||||
val length = readInt32()
|
||||
val bytes = ByteArray(length)
|
||||
val lenLong = readUInt32()
|
||||
if (lenLong == 0L) return byteArrayOf()
|
||||
if (lenLong > Int.MAX_VALUE.toLong()) {
|
||||
throw IllegalStateException("Byte array too large: $lenLong")
|
||||
}
|
||||
|
||||
val length = lenLong.toInt()
|
||||
val requiredBits = length.toLong() * 8L
|
||||
if (requiredBits > remainingBits()) {
|
||||
return byteArrayOf()
|
||||
}
|
||||
|
||||
val out = ByteArray(length)
|
||||
|
||||
// Fast path when byte-aligned.
|
||||
if ((readPointer and 7) == 0) {
|
||||
val byteIndex = readPointer shr 3
|
||||
System.arraycopy(stream, byteIndex, out, 0, length)
|
||||
readPointer += length shl 3
|
||||
return out
|
||||
}
|
||||
|
||||
for (i in 0 until length) {
|
||||
bytes[i] = readInt8().toByte()
|
||||
out[i] = readUInt8().toByte()
|
||||
}
|
||||
return bytes
|
||||
return out
|
||||
}
|
||||
|
||||
private fun ensureCapacity(index: Int) {
|
||||
while (_stream.size <= index) {
|
||||
_stream.add(0)
|
||||
|
||||
private fun remainingBits(): Long = (writePointer - readPointer).toLong()
|
||||
|
||||
private fun writeBits(value: Long, bits: Int) {
|
||||
if (bits <= 0) return
|
||||
|
||||
reserveBits(bits.toLong())
|
||||
|
||||
for (i in bits - 1 downTo 0) {
|
||||
val bit = ((value ushr i) and 1L).toInt()
|
||||
val byteIndex = writePointer shr 3
|
||||
val shift = 7 - (writePointer and 7)
|
||||
|
||||
stream[byteIndex] =
|
||||
if (bit == 1) {
|
||||
(stream[byteIndex].toInt() or (1 shl shift)).toByte()
|
||||
} else {
|
||||
(stream[byteIndex].toInt() and (1 shl shift).inv()).toByte()
|
||||
}
|
||||
|
||||
writePointer++
|
||||
}
|
||||
}
|
||||
|
||||
private fun readBits(bits: Int): Long {
|
||||
if (bits <= 0) return 0L
|
||||
if (remainingBits() < bits.toLong()) {
|
||||
throw IllegalStateException("Not enough bits to read")
|
||||
}
|
||||
|
||||
var value = 0L
|
||||
repeat(bits) {
|
||||
val bit =
|
||||
(stream[readPointer shr 3].toInt() ushr
|
||||
(7 - (readPointer and 7))) and
|
||||
1
|
||||
value = (value shl 1) or bit.toLong()
|
||||
readPointer++
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
private fun reserveBits(bitsToWrite: Long) {
|
||||
if (bitsToWrite <= 0L) return
|
||||
|
||||
val lastBitIndex = writePointer.toLong() + bitsToWrite - 1L
|
||||
if (lastBitIndex < 0L) {
|
||||
throw IllegalStateException("Bit index overflow")
|
||||
}
|
||||
|
||||
val byteIndex = lastBitIndex ushr 3
|
||||
if (byteIndex > Int.MAX_VALUE.toLong()) {
|
||||
throw IllegalStateException("Stream too large")
|
||||
}
|
||||
|
||||
ensureCapacity(byteIndex.toInt())
|
||||
}
|
||||
|
||||
private fun ensureCapacity(byteIndex: Int) {
|
||||
val requiredSize = byteIndex + 1
|
||||
if (requiredSize <= stream.size) return
|
||||
|
||||
var newSize = if (stream.isEmpty()) 32 else stream.size
|
||||
while (newSize < requiredSize) {
|
||||
newSize = if (newSize <= Int.MAX_VALUE / 2) newSize shl 1 else requiredSize
|
||||
}
|
||||
|
||||
stream = stream.copyOf(newSize)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import android.content.Context
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.resumeWithException
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/**
|
||||
* Состояние загрузки/скачивания файла
|
||||
@@ -38,9 +43,11 @@ object TransportManager {
|
||||
|
||||
private val _uploading = MutableStateFlow<List<TransportState>>(emptyList())
|
||||
val uploading: StateFlow<List<TransportState>> = _uploading.asStateFlow()
|
||||
private val activeUploadCalls = ConcurrentHashMap<String, Call>()
|
||||
|
||||
private val _downloading = MutableStateFlow<List<TransportState>>(emptyList())
|
||||
val downloading: StateFlow<List<TransportState>> = _downloading.asStateFlow()
|
||||
private val activeDownloadCalls = ConcurrentHashMap<String, Call>()
|
||||
|
||||
private val client = OkHttpClient.Builder()
|
||||
.connectTimeout(60, TimeUnit.SECONDS)
|
||||
@@ -72,7 +79,11 @@ object TransportManager {
|
||||
* Получить активный сервер для скачивания/загрузки.
|
||||
* Desktop parity: ждём сервер из PacketRequestTransport (0x0F), а не используем hardcoded CDN.
|
||||
*/
|
||||
private suspend fun getActiveServer(): String {
|
||||
private suspend fun getActiveServer(serverOverride: String? = null): String {
|
||||
val normalizedOverride = serverOverride?.trim()?.trimEnd('/').orEmpty()
|
||||
if (normalizedOverride.isNotEmpty()) {
|
||||
return normalizedOverride
|
||||
}
|
||||
transportServer?.let { return it }
|
||||
requestTransportServer()
|
||||
repeat(40) { // 10s total
|
||||
@@ -93,6 +104,8 @@ object TransportManager {
|
||||
repeat(MAX_RETRIES) { attempt ->
|
||||
try {
|
||||
return block()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
lastException = if (e is IOException) e else IOException("Download error: ${e.message}", e)
|
||||
if (attempt < MAX_RETRIES - 1) {
|
||||
@@ -108,7 +121,63 @@ object TransportManager {
|
||||
*/
|
||||
fun requestTransportServer() {
|
||||
val packet = PacketRequestTransport()
|
||||
ProtocolManager.sendPacket(packet)
|
||||
ProtocolRuntimeAccess.get().sendPacket(packet)
|
||||
}
|
||||
|
||||
/**
|
||||
* Принудительно отменяет активный HTTP call для скачивания attachment.
|
||||
* Нужен для pause/resume в file bubble.
|
||||
*/
|
||||
fun cancelDownload(id: String) {
|
||||
activeDownloadCalls.remove(id)?.cancel()
|
||||
_downloading.value = _downloading.value.filter { it.id != id }
|
||||
}
|
||||
|
||||
/**
|
||||
* Принудительно отменяет активный HTTP call для upload attachment.
|
||||
*/
|
||||
fun cancelUpload(id: String) {
|
||||
activeUploadCalls.remove(id)?.cancel()
|
||||
_uploading.value = _uploading.value.filter { it.id != id }
|
||||
}
|
||||
|
||||
private suspend fun awaitDownloadResponse(id: String, request: Request): Response =
|
||||
suspendCancellableCoroutine { cont ->
|
||||
val call = client.newCall(request)
|
||||
activeDownloadCalls[id] = call
|
||||
|
||||
cont.invokeOnCancellation {
|
||||
activeDownloadCalls.remove(id, call)
|
||||
call.cancel()
|
||||
}
|
||||
|
||||
call.enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
activeDownloadCalls.remove(id, call)
|
||||
if (call.isCanceled()) {
|
||||
cont.cancel(CancellationException("Download cancelled"))
|
||||
} else {
|
||||
cont.resumeWithException(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
activeDownloadCalls.remove(id, call)
|
||||
if (cont.isCancelled) {
|
||||
response.close()
|
||||
return
|
||||
}
|
||||
cont.resume(response)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun parseContentRangeTotal(value: String?): Long? {
|
||||
if (value.isNullOrBlank()) return null
|
||||
// Example: "bytes 100-999/12345"
|
||||
val totalPart = value.substringAfter('/').trim()
|
||||
if (totalPart.isEmpty() || totalPart == "*") return null
|
||||
return totalPart.toLongOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,7 +188,7 @@ object TransportManager {
|
||||
*/
|
||||
suspend fun uploadFile(id: String, content: String): String = withContext(Dispatchers.IO) {
|
||||
val server = getActiveServer()
|
||||
ProtocolManager.addLog("📤 Upload start: id=${id.take(8)}, server=$server")
|
||||
ProtocolRuntimeAccess.get().addLog("📤 Upload start: id=${id.take(8)}, server=$server")
|
||||
|
||||
// Добавляем в список загрузок
|
||||
_uploading.value = _uploading.value + TransportState(id, 0)
|
||||
@@ -163,13 +232,31 @@ object TransportManager {
|
||||
.post(requestBody)
|
||||
.build()
|
||||
|
||||
val response = suspendCoroutine<Response> { cont ->
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
val response = suspendCancellableCoroutine<Response> { cont ->
|
||||
val call = client.newCall(request)
|
||||
activeUploadCalls[id] = call
|
||||
|
||||
cont.invokeOnCancellation {
|
||||
activeUploadCalls.remove(id, call)
|
||||
call.cancel()
|
||||
}
|
||||
|
||||
call.enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
cont.resumeWithException(e)
|
||||
activeUploadCalls.remove(id, call)
|
||||
if (call.isCanceled()) {
|
||||
cont.cancel(CancellationException("Upload cancelled"))
|
||||
} else {
|
||||
cont.resumeWithException(e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
activeUploadCalls.remove(id, call)
|
||||
if (cont.isCancelled) {
|
||||
response.close()
|
||||
return
|
||||
}
|
||||
cont.resume(response)
|
||||
}
|
||||
})
|
||||
@@ -188,16 +275,20 @@ object TransportManager {
|
||||
if (it.id == id) it.copy(progress = 100) else it
|
||||
}
|
||||
|
||||
ProtocolManager.addLog("✅ Upload success: id=${id.take(8)}, tag=${tag.take(10)}")
|
||||
ProtocolRuntimeAccess.get().addLog("✅ Upload success: id=${id.take(8)}, tag=${tag.take(10)}")
|
||||
|
||||
tag
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
ProtocolRuntimeAccess.get().addLog("🛑 Upload cancelled: id=${id.take(8)}")
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
ProtocolManager.addLog(
|
||||
ProtocolRuntimeAccess.get().addLog(
|
||||
"❌ Upload failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
||||
)
|
||||
throw e
|
||||
} finally {
|
||||
activeUploadCalls.remove(id)?.cancel()
|
||||
// Удаляем из списка загрузок
|
||||
_uploading.value = _uploading.value.filter { it.id != id }
|
||||
}
|
||||
@@ -212,9 +303,13 @@ object TransportManager {
|
||||
* @param tag Tag файла на сервере
|
||||
* @return Содержимое файла
|
||||
*/
|
||||
suspend fun downloadFile(id: String, tag: String): String = withContext(Dispatchers.IO) {
|
||||
val server = getActiveServer()
|
||||
ProtocolManager.addLog("📥 Download start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server")
|
||||
suspend fun downloadFile(
|
||||
id: String,
|
||||
tag: String,
|
||||
transportServer: String? = null
|
||||
): String = withContext(Dispatchers.IO) {
|
||||
val server = getActiveServer(transportServer)
|
||||
ProtocolRuntimeAccess.get().addLog("📥 Download start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server")
|
||||
|
||||
// Добавляем в список скачиваний
|
||||
_downloading.value = _downloading.value + TransportState(id, 0)
|
||||
@@ -226,17 +321,7 @@ object TransportManager {
|
||||
.get()
|
||||
.build()
|
||||
|
||||
val response = suspendCoroutine<Response> { cont ->
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
cont.resumeWithException(e)
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
cont.resume(response)
|
||||
}
|
||||
})
|
||||
}
|
||||
val response = awaitDownloadResponse(id, request)
|
||||
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("Download failed: ${response.code}")
|
||||
@@ -251,7 +336,7 @@ object TransportManager {
|
||||
_downloading.value = _downloading.value.map {
|
||||
if (it.id == id) it.copy(progress = 100) else it
|
||||
}
|
||||
ProtocolManager.addLog("✅ Download OK (mem): id=${id.take(8)}, size=${content.length}")
|
||||
ProtocolRuntimeAccess.get().addLog("✅ Download OK (mem): id=${id.take(8)}, size=${content.length}")
|
||||
return@withRetry content
|
||||
}
|
||||
|
||||
@@ -298,18 +383,19 @@ object TransportManager {
|
||||
if (it.id == id) it.copy(progress = 100) else it
|
||||
}
|
||||
|
||||
ProtocolManager.addLog("✅ Download OK (stream): id=${id.take(8)}, size=$totalRead")
|
||||
ProtocolRuntimeAccess.get().addLog("✅ Download OK (stream): id=${id.take(8)}, size=$totalRead")
|
||||
content
|
||||
} finally {
|
||||
tempFile.delete()
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ProtocolManager.addLog(
|
||||
ProtocolRuntimeAccess.get().addLog(
|
||||
"❌ Download failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
||||
)
|
||||
throw e
|
||||
} finally {
|
||||
activeDownloadCalls.remove(id)?.cancel()
|
||||
// Удаляем из списка скачиваний
|
||||
_downloading.value = _downloading.value.filter { it.id != id }
|
||||
}
|
||||
@@ -337,85 +423,136 @@ object TransportManager {
|
||||
* @param tag Tag файла на сервере
|
||||
* @return Временный файл с зашифрованным содержимым
|
||||
*/
|
||||
suspend fun downloadFileRaw(id: String, tag: String): File = withContext(Dispatchers.IO) {
|
||||
val server = getActiveServer()
|
||||
ProtocolManager.addLog("📥 Download raw start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server")
|
||||
suspend fun downloadFileRaw(
|
||||
id: String,
|
||||
tag: String,
|
||||
transportServer: String? = null
|
||||
): File = withContext(Dispatchers.IO) {
|
||||
val cacheDir = appContext?.cacheDir ?: throw IOException("No app context")
|
||||
val tempFile = File(cacheDir, "dlr_${id.take(16)}_${System.currentTimeMillis()}.tmp")
|
||||
try {
|
||||
downloadFileRawResumable(
|
||||
id = id,
|
||||
tag = tag,
|
||||
targetFile = tempFile,
|
||||
resumeFromBytes = 0L,
|
||||
transportServer = transportServer
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
tempFile.delete()
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
_downloading.value = _downloading.value + TransportState(id, 0)
|
||||
/**
|
||||
* Resumable download with HTTP Range support.
|
||||
* If server supports range (206), continues from `targetFile.length()`.
|
||||
* If not, safely restarts from zero and rewrites target file.
|
||||
*/
|
||||
suspend fun downloadFileRawResumable(
|
||||
id: String,
|
||||
tag: String,
|
||||
targetFile: File,
|
||||
resumeFromBytes: Long = 0L,
|
||||
transportServer: String? = null
|
||||
): File = withContext(Dispatchers.IO) {
|
||||
val server = getActiveServer(transportServer)
|
||||
ProtocolRuntimeAccess.get().addLog(
|
||||
"📥 Download raw(resume) start: id=${id.take(8)}, tag=${tag.take(10)}, server=$server, resume=$resumeFromBytes"
|
||||
)
|
||||
|
||||
_downloading.value = _downloading.value.filter { it.id != id } + TransportState(id, 0)
|
||||
|
||||
try {
|
||||
withRetry {
|
||||
val request = Request.Builder()
|
||||
val existingBytes = if (targetFile.exists()) targetFile.length() else 0L
|
||||
val startOffset = maxOf(existingBytes, resumeFromBytes.coerceAtLeast(0L))
|
||||
.coerceAtMost(existingBytes)
|
||||
|
||||
val requestBuilder = Request.Builder()
|
||||
.url("$server/d/$tag")
|
||||
.get()
|
||||
.build()
|
||||
|
||||
val response = suspendCoroutine<Response> { cont ->
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
cont.resumeWithException(e)
|
||||
}
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
cont.resume(response)
|
||||
}
|
||||
})
|
||||
if (startOffset > 0L) {
|
||||
requestBuilder.addHeader("Range", "bytes=$startOffset-")
|
||||
}
|
||||
|
||||
val response = awaitDownloadResponse(id, requestBuilder.build())
|
||||
if (!response.isSuccessful) {
|
||||
throw IOException("Download failed: ${response.code}")
|
||||
}
|
||||
|
||||
val body = response.body ?: throw IOException("Empty response body")
|
||||
val contentLength = body.contentLength()
|
||||
val cacheDir = appContext?.cacheDir ?: throw IOException("No app context")
|
||||
val tempFile = File(cacheDir, "dlr_${id.take(16)}_${System.currentTimeMillis()}.tmp")
|
||||
val rangeAccepted = response.code == 206
|
||||
val writeFromOffset = if (rangeAccepted) startOffset else 0L
|
||||
val incomingLength = body.contentLength().coerceAtLeast(0L)
|
||||
val totalFromHeader = parseContentRangeTotal(response.header("Content-Range"))
|
||||
val totalBytes = when {
|
||||
totalFromHeader != null && totalFromHeader > 0L -> totalFromHeader
|
||||
incomingLength > 0L -> writeFromOffset + incomingLength
|
||||
else -> -1L
|
||||
}
|
||||
|
||||
try {
|
||||
var totalRead = 0L
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
if (writeFromOffset == 0L && targetFile.exists()) {
|
||||
targetFile.delete()
|
||||
}
|
||||
targetFile.parentFile?.mkdirs()
|
||||
|
||||
body.byteStream().use { inputStream ->
|
||||
tempFile.outputStream().use { outputStream ->
|
||||
while (true) {
|
||||
val bytesRead = inputStream.read(buffer)
|
||||
if (bytesRead == -1) break
|
||||
outputStream.write(buffer, 0, bytesRead)
|
||||
totalRead += bytesRead
|
||||
if (contentLength > 0) {
|
||||
val progress = ((totalRead * 100) / contentLength).toInt().coerceIn(0, 99)
|
||||
_downloading.value = _downloading.value.map {
|
||||
if (it.id == id) it.copy(progress = progress) else it
|
||||
}
|
||||
val append = writeFromOffset > 0L
|
||||
var totalRead = writeFromOffset
|
||||
val buffer = ByteArray(64 * 1024)
|
||||
|
||||
body.byteStream().use { inputStream ->
|
||||
java.io.FileOutputStream(targetFile, append).use { outputStream ->
|
||||
while (true) {
|
||||
coroutineContext.ensureActive()
|
||||
val bytesRead = try {
|
||||
inputStream.read(buffer)
|
||||
} catch (e: IOException) {
|
||||
if (!coroutineContext.isActive) {
|
||||
throw CancellationException("Download cancelled", e)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
if (bytesRead == -1) break
|
||||
|
||||
outputStream.write(buffer, 0, bytesRead)
|
||||
totalRead += bytesRead
|
||||
|
||||
if (totalBytes > 0L) {
|
||||
val progress =
|
||||
((totalRead * 100L) / totalBytes).toInt().coerceIn(0, 99)
|
||||
_downloading.value = _downloading.value.map {
|
||||
if (it.id == id) it.copy(progress = progress) else it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (contentLength > 0 && totalRead != contentLength) {
|
||||
tempFile.delete()
|
||||
throw IOException("Incomplete download: expected=$contentLength, got=$totalRead")
|
||||
}
|
||||
if (totalRead == 0L) {
|
||||
tempFile.delete()
|
||||
throw IOException("Empty download: 0 bytes received")
|
||||
}
|
||||
|
||||
_downloading.value = _downloading.value.map {
|
||||
if (it.id == id) it.copy(progress = 100) else it
|
||||
}
|
||||
ProtocolManager.addLog("✅ Download raw OK: id=${id.take(8)}, size=$totalRead")
|
||||
tempFile
|
||||
} catch (e: Exception) {
|
||||
tempFile.delete()
|
||||
throw e
|
||||
}
|
||||
|
||||
if (totalBytes > 0L && totalRead < totalBytes) {
|
||||
throw IOException(
|
||||
"Incomplete download: expected=$totalBytes, got=$totalRead"
|
||||
)
|
||||
}
|
||||
if (totalRead == 0L) {
|
||||
throw IOException("Empty download: 0 bytes received")
|
||||
}
|
||||
|
||||
_downloading.value = _downloading.value.map {
|
||||
if (it.id == id) it.copy(progress = 100) else it
|
||||
}
|
||||
ProtocolRuntimeAccess.get().addLog(
|
||||
"✅ Download raw(resume) OK: id=${id.take(8)}, size=$totalRead"
|
||||
)
|
||||
targetFile
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
ProtocolManager.addLog(
|
||||
"❌ Download raw failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
||||
ProtocolRuntimeAccess.get().addLog(
|
||||
"❌ Download raw(resume) failed: id=${id.take(8)}, reason=${e.javaClass.simpleName}: ${e.message ?: "unknown"}"
|
||||
)
|
||||
throw e
|
||||
} finally {
|
||||
activeDownloadCalls.remove(id)?.cancel()
|
||||
_downloading.value = _downloading.value.filter { it.id != id }
|
||||
}
|
||||
}
|
||||
|
||||
115
app/src/main/java/com/rosetta/messenger/network/XChaCha20E2EE.kt
Normal file
115
app/src/main/java/com/rosetta/messenger/network/XChaCha20E2EE.kt
Normal file
@@ -0,0 +1,115 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
import android.util.Log
|
||||
import org.webrtc.FrameDecryptor
|
||||
import org.webrtc.FrameEncryptor
|
||||
|
||||
/**
|
||||
* XChaCha20-based E2EE compatible with Rosetta Desktop.
|
||||
*
|
||||
* Desktop encrypts audio frames using XChaCha20 (libsodium) with a nonce
|
||||
* derived from the RTP timestamp. The shared key is computed as
|
||||
* nacl.box.before(peerPub, ownSecret) = HSalsa20(zeros, X25519(sk, pk)).
|
||||
*
|
||||
* This class provides:
|
||||
* - [hsalsa20] — applies HSalsa20 to a raw X25519 shared secret,
|
||||
* producing the same key as nacl.box.before().
|
||||
* - [Encryptor] / [Decryptor] — WebRTC FrameEncryptor / FrameDecryptor
|
||||
* that use XChaCha20 matching the Desktop implementation.
|
||||
*/
|
||||
object XChaCha20E2EE {
|
||||
|
||||
private const val TAG = "XChaCha20E2EE"
|
||||
|
||||
var nativeLoaded: Boolean = false
|
||||
private set
|
||||
|
||||
private var crashFilePath: String? = null
|
||||
|
||||
fun initWithContext(context: android.content.Context) {
|
||||
if (!nativeLoaded) return
|
||||
try {
|
||||
val dir = java.io.File(context.filesDir, "crash_reports")
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
val path = java.io.File(dir, "native_crash.txt").absolutePath
|
||||
crashFilePath = path
|
||||
nativeInstallCrashHandler(path)
|
||||
Log.i(TAG, "Native crash handler installed → $path")
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Failed to install native crash handler", e)
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
try {
|
||||
System.loadLibrary("rosetta_e2ee")
|
||||
nativeLoaded = true
|
||||
Log.i(TAG, "Native library loaded successfully")
|
||||
} catch (e: UnsatisfiedLinkError) {
|
||||
Log.e(TAG, "Failed to load native library rosetta_e2ee", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* HSalsa20(zeros_16, rawDhShared, sigma) — converts a raw X25519
|
||||
* shared secret into the NaCl box-before shared key.
|
||||
*/
|
||||
fun hsalsa20(rawDhShared: ByteArray): ByteArray {
|
||||
require(nativeLoaded) { "Native library not loaded" }
|
||||
require(rawDhShared.size >= 32) { "Raw DH shared secret must be >= 32 bytes" }
|
||||
return nativeHSalsa20(rawDhShared)
|
||||
}
|
||||
|
||||
/** WebRTC [FrameEncryptor] backed by native XChaCha20. */
|
||||
class Encryptor(key: ByteArray) : FrameEncryptor {
|
||||
private val nativePtr: Long
|
||||
|
||||
init {
|
||||
require(nativeLoaded) { "Native library not loaded" }
|
||||
nativePtr = nativeCreateEncryptor(key)
|
||||
Log.i(TAG, "Encryptor created, ptr=0x${nativePtr.toString(16)}")
|
||||
}
|
||||
|
||||
override fun getNativeFrameEncryptor(): Long = nativePtr
|
||||
|
||||
fun frameCount(): Int = if (nativePtr != 0L) nativeGetEncryptorFrameCount(nativePtr) else -1
|
||||
|
||||
fun dispose() {
|
||||
if (nativePtr != 0L) nativeReleaseEncryptor(nativePtr)
|
||||
}
|
||||
}
|
||||
|
||||
/** WebRTC [FrameDecryptor] backed by native XChaCha20. */
|
||||
class Decryptor(key: ByteArray) : FrameDecryptor {
|
||||
private val nativePtr: Long
|
||||
|
||||
init {
|
||||
require(nativeLoaded) { "Native library not loaded" }
|
||||
nativePtr = nativeCreateDecryptor(key)
|
||||
Log.i(TAG, "Decryptor created, ptr=0x${nativePtr.toString(16)}")
|
||||
}
|
||||
|
||||
override fun getNativeFrameDecryptor(): Long = nativePtr
|
||||
|
||||
fun frameCount(): Int = if (nativePtr != 0L) nativeGetDecryptorFrameCount(nativePtr) else -1
|
||||
fun badStreak(): Int = if (nativePtr != 0L) nativeGetDecryptorBadStreak(nativePtr) else -1
|
||||
|
||||
fun dispose() {
|
||||
if (nativePtr != 0L) nativeReleaseDecryptor(nativePtr)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── JNI ─────────────────────────────────────────────────── */
|
||||
|
||||
@JvmStatic private external fun nativeHSalsa20(rawDh: ByteArray): ByteArray
|
||||
@JvmStatic private external fun nativeCreateEncryptor(key: ByteArray): Long
|
||||
@JvmStatic private external fun nativeReleaseEncryptor(ptr: Long)
|
||||
@JvmStatic private external fun nativeGetEncryptorFrameCount(ptr: Long): Int
|
||||
@JvmStatic private external fun nativeCreateDecryptor(key: ByteArray): Long
|
||||
@JvmStatic private external fun nativeReleaseDecryptor(ptr: Long)
|
||||
@JvmStatic private external fun nativeGetDecryptorFrameCount(ptr: Long): Int
|
||||
@JvmStatic private external fun nativeGetDecryptorBadStreak(ptr: Long): Int
|
||||
@JvmStatic private external fun nativeInstallCrashHandler(path: String)
|
||||
@JvmStatic external fun nativeOpenDiagFile(path: String)
|
||||
@JvmStatic external fun nativeCloseDiagFile()
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
class AuthBootstrapCoordinator(
|
||||
private val scope: CoroutineScope,
|
||||
private val addLog: (String) -> Unit
|
||||
) {
|
||||
private val sessionCounter = AtomicLong(0L)
|
||||
private val mutex = Mutex()
|
||||
|
||||
@Volatile private var activeAuthenticatedSessionId = 0L
|
||||
@Volatile private var lastBootstrappedSessionId = 0L
|
||||
@Volatile private var deferredAuthBootstrap = false
|
||||
|
||||
fun onAuthenticatedSessionStarted(): Long {
|
||||
val sessionId = sessionCounter.incrementAndGet()
|
||||
activeAuthenticatedSessionId = sessionId
|
||||
deferredAuthBootstrap = false
|
||||
return sessionId
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
deferredAuthBootstrap = false
|
||||
activeAuthenticatedSessionId = 0L
|
||||
lastBootstrappedSessionId = 0L
|
||||
}
|
||||
|
||||
fun isBootstrapPending(): Boolean {
|
||||
return activeAuthenticatedSessionId > 0L &&
|
||||
lastBootstrappedSessionId != activeAuthenticatedSessionId
|
||||
}
|
||||
|
||||
fun tryRun(
|
||||
trigger: String,
|
||||
canRun: () -> Boolean,
|
||||
onDeferred: () -> Unit,
|
||||
runBootstrap: suspend () -> Unit
|
||||
) {
|
||||
val sessionId = activeAuthenticatedSessionId
|
||||
if (sessionId <= 0L) return
|
||||
|
||||
scope.launch {
|
||||
mutex.withLock {
|
||||
if (sessionId != activeAuthenticatedSessionId) return@withLock
|
||||
if (sessionId == lastBootstrappedSessionId) return@withLock
|
||||
if (!canRun()) {
|
||||
deferredAuthBootstrap = true
|
||||
onDeferred()
|
||||
return@withLock
|
||||
}
|
||||
|
||||
deferredAuthBootstrap = false
|
||||
addLog("🚀 AUTH bootstrap start session=$sessionId trigger=$trigger")
|
||||
runCatching { runBootstrap() }
|
||||
.onSuccess {
|
||||
lastBootstrappedSessionId = sessionId
|
||||
addLog("✅ AUTH bootstrap complete session=$sessionId trigger=$trigger")
|
||||
}
|
||||
.onFailure { error ->
|
||||
addLog(
|
||||
"❌ AUTH bootstrap failed session=$sessionId trigger=$trigger: ${error.message}"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import com.rosetta.messenger.data.AccountManager
|
||||
|
||||
class AuthRestoreService(
|
||||
private val getAccountManager: () -> AccountManager?,
|
||||
private val addLog: (String) -> Unit,
|
||||
private val shortKeyForLog: (String) -> String,
|
||||
private val authenticate: (publicKey: String, privateHash: String) -> Unit
|
||||
) {
|
||||
fun restoreAuthFromStoredCredentials(
|
||||
preferredPublicKey: String? = null,
|
||||
reason: String = "background_restore"
|
||||
): Boolean {
|
||||
val accountManager = getAccountManager()
|
||||
if (accountManager == null) {
|
||||
addLog("⚠️ restoreAuthFromStoredCredentials skipped: AccountManager is not bound")
|
||||
return false
|
||||
}
|
||||
val publicKey =
|
||||
preferredPublicKey?.trim().orEmpty().ifBlank {
|
||||
accountManager.getLastLoggedPublicKey().orEmpty()
|
||||
}
|
||||
val privateHash = accountManager.getLastLoggedPrivateKeyHash().orEmpty()
|
||||
if (publicKey.isBlank() || privateHash.isBlank()) {
|
||||
addLog(
|
||||
"⚠️ restoreAuthFromStoredCredentials skipped (pk=${publicKey.isNotBlank()} hash=${privateHash.isNotBlank()} reason=$reason)"
|
||||
)
|
||||
return false
|
||||
}
|
||||
addLog("🔐 Restoring auth from cache reason=$reason pk=${shortKeyForLog(publicKey)}")
|
||||
authenticate(publicKey, privateHash)
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import com.rosetta.messenger.network.*
|
||||
|
||||
class BootstrapCoordinator(
|
||||
private val readyPacketGate: ReadyPacketGate,
|
||||
private val addLog: (String) -> Unit,
|
||||
private val shortKeyForLog: (String) -> String,
|
||||
private val sendPacketDirect: (Packet) -> Unit
|
||||
) {
|
||||
fun protocolToLifecycleState(state: ProtocolState): ConnectionLifecycleState =
|
||||
when (state) {
|
||||
ProtocolState.DISCONNECTED -> ConnectionLifecycleState.DISCONNECTED
|
||||
ProtocolState.CONNECTING -> ConnectionLifecycleState.CONNECTING
|
||||
ProtocolState.CONNECTED, ProtocolState.HANDSHAKING -> ConnectionLifecycleState.HANDSHAKING
|
||||
ProtocolState.DEVICE_VERIFICATION_REQUIRED ->
|
||||
ConnectionLifecycleState.DEVICE_VERIFICATION_REQUIRED
|
||||
ProtocolState.AUTHENTICATED -> ConnectionLifecycleState.AUTHENTICATED
|
||||
}
|
||||
|
||||
fun packetCanBypassReadyGate(packet: Packet): Boolean =
|
||||
when (packet) {
|
||||
is PacketHandshake,
|
||||
is PacketSync,
|
||||
is PacketSearch,
|
||||
is PacketPushNotification,
|
||||
is PacketRequestTransport,
|
||||
is PacketRequestUpdate,
|
||||
is PacketSignalPeer,
|
||||
is PacketWebRTC,
|
||||
is PacketIceServers,
|
||||
is PacketDeviceResolve -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
fun recomputeLifecycleState(
|
||||
context: ConnectionBootstrapContext,
|
||||
currentState: ConnectionLifecycleState,
|
||||
reason: String,
|
||||
onStateChanged: (ConnectionLifecycleState, String) -> Unit
|
||||
): ConnectionLifecycleState {
|
||||
val nextState =
|
||||
if (context.authenticated) {
|
||||
if (context.accountInitialized && context.syncCompleted && context.ownProfileResolved) {
|
||||
ConnectionLifecycleState.READY
|
||||
} else {
|
||||
ConnectionLifecycleState.BOOTSTRAPPING
|
||||
}
|
||||
} else {
|
||||
protocolToLifecycleState(context.protocolState)
|
||||
}
|
||||
|
||||
if (currentState != nextState) {
|
||||
onStateChanged(nextState, reason)
|
||||
}
|
||||
|
||||
if (nextState == ConnectionLifecycleState.READY) {
|
||||
flushReadyPacketQueue(context.accountPublicKey, reason)
|
||||
}
|
||||
|
||||
return nextState
|
||||
}
|
||||
|
||||
fun clearReadyPacketQueue(reason: String) {
|
||||
readyPacketGate.clear(reason = reason, addLog = addLog)
|
||||
}
|
||||
|
||||
fun enqueueReadyPacket(
|
||||
packet: Packet,
|
||||
accountPublicKey: String,
|
||||
state: ConnectionLifecycleState
|
||||
) {
|
||||
readyPacketGate.enqueue(
|
||||
packet = packet,
|
||||
accountPublicKey = accountPublicKey,
|
||||
state = state,
|
||||
shortKeyForLog = shortKeyForLog,
|
||||
addLog = addLog
|
||||
)
|
||||
}
|
||||
|
||||
fun flushReadyPacketQueue(activeAccountKey: String, reason: String) {
|
||||
val packetsToSend =
|
||||
readyPacketGate.drainForAccount(
|
||||
activeAccountKey = activeAccountKey,
|
||||
reason = reason,
|
||||
addLog = addLog
|
||||
)
|
||||
if (packetsToSend.isEmpty()) return
|
||||
packetsToSend.forEach(sendPacketDirect)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import com.rosetta.messenger.network.Packet
|
||||
import com.rosetta.messenger.network.PacketIceServers
|
||||
import com.rosetta.messenger.network.PacketSignalPeer
|
||||
import com.rosetta.messenger.network.PacketWebRTC
|
||||
import com.rosetta.messenger.network.SignalType
|
||||
import com.rosetta.messenger.network.WebRTCSignalType
|
||||
|
||||
class CallSignalBridge(
|
||||
private val sendPacket: (Packet) -> Unit,
|
||||
private val waitPacket: (packetId: Int, callback: (Packet) -> Unit) -> Unit,
|
||||
private val unwaitPacket: (packetId: Int, callback: (Packet) -> Unit) -> Unit,
|
||||
private val addLog: (String) -> Unit,
|
||||
private val shortKeyForLog: (String, Int) -> String,
|
||||
private val shortTextForLog: (String, Int) -> String
|
||||
) {
|
||||
private companion object {
|
||||
const val PACKET_SIGNAL_PEER = 0x1A
|
||||
const val PACKET_WEB_RTC = 0x1B
|
||||
const val PACKET_ICE_SERVERS = 0x1C
|
||||
}
|
||||
|
||||
fun sendCallSignal(
|
||||
signalType: SignalType,
|
||||
src: String = "",
|
||||
dst: String = "",
|
||||
sharedPublic: String = "",
|
||||
callId: String = "",
|
||||
joinToken: String = ""
|
||||
) {
|
||||
addLog(
|
||||
"📡 CALL TX type=$signalType src=${shortKeyForLog(src, 8)} dst=${shortKeyForLog(dst, 8)} " +
|
||||
"sharedLen=${sharedPublic.length} callId=${shortKeyForLog(callId, 12)} join=${shortKeyForLog(joinToken, 12)}"
|
||||
)
|
||||
sendPacket(
|
||||
PacketSignalPeer().apply {
|
||||
this.signalType = signalType
|
||||
this.src = src
|
||||
this.dst = dst
|
||||
this.sharedPublic = sharedPublic
|
||||
this.callId = callId
|
||||
this.joinToken = joinToken
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) {
|
||||
addLog(
|
||||
"📡 WEBRTC TX type=$signalType sdpLen=${sdpOrCandidate.length} " +
|
||||
"preview='${shortTextForLog(sdpOrCandidate, 56)}'"
|
||||
)
|
||||
sendPacket(
|
||||
PacketWebRTC().apply {
|
||||
this.signalType = signalType
|
||||
this.sdpOrCandidate = sdpOrCandidate
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
fun requestIceServers() {
|
||||
addLog("📡 ICE TX request")
|
||||
sendPacket(PacketIceServers())
|
||||
}
|
||||
|
||||
fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit {
|
||||
val wrapper: (Packet) -> Unit = { packet ->
|
||||
(packet as? PacketSignalPeer)?.let {
|
||||
addLog(
|
||||
"📡 CALL RX type=${it.signalType} src=${shortKeyForLog(it.src, 8)} dst=${shortKeyForLog(it.dst, 8)} " +
|
||||
"sharedLen=${it.sharedPublic.length} callId=${shortKeyForLog(it.callId, 12)} join=${shortKeyForLog(it.joinToken, 12)}"
|
||||
)
|
||||
callback(it)
|
||||
}
|
||||
}
|
||||
waitPacket(PACKET_SIGNAL_PEER, wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
fun unwaitCallSignal(callback: (Packet) -> Unit) {
|
||||
unwaitPacket(PACKET_SIGNAL_PEER, callback)
|
||||
}
|
||||
|
||||
fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit {
|
||||
val wrapper: (Packet) -> Unit = { packet ->
|
||||
(packet as? PacketWebRTC)?.let {
|
||||
addLog(
|
||||
"📡 WEBRTC RX type=${it.signalType} sdpLen=${it.sdpOrCandidate.length} " +
|
||||
"preview='${shortTextForLog(it.sdpOrCandidate, 56)}'"
|
||||
)
|
||||
callback(it)
|
||||
}
|
||||
}
|
||||
waitPacket(PACKET_WEB_RTC, wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
fun unwaitWebRtcSignal(callback: (Packet) -> Unit) {
|
||||
unwaitPacket(PACKET_WEB_RTC, callback)
|
||||
}
|
||||
|
||||
fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit {
|
||||
val wrapper: (Packet) -> Unit = { packet ->
|
||||
(packet as? PacketIceServers)?.let {
|
||||
val firstUrl = it.iceServers.firstOrNull()?.url.orEmpty()
|
||||
addLog("📡 ICE RX count=${it.iceServers.size} first='${shortTextForLog(firstUrl, 56)}'")
|
||||
callback(it)
|
||||
}
|
||||
}
|
||||
waitPacket(PACKET_ICE_SERVERS, wrapper)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
fun unwaitIceServers(callback: (Packet) -> Unit) {
|
||||
unwaitPacket(PACKET_ICE_SERVERS, callback)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import com.rosetta.messenger.network.ConnectionEvent
|
||||
import com.rosetta.messenger.network.Packet
|
||||
import com.rosetta.messenger.network.ProtocolState
|
||||
|
||||
class ConnectionEventRouter(
|
||||
private val handleInitializeAccount: (publicKey: String, privateKey: String) -> Unit,
|
||||
private val handleConnect: (reason: String) -> Unit,
|
||||
private val handleFastReconnect: (reason: String) -> Unit,
|
||||
private val handleDisconnect: (reason: String, clearCredentials: Boolean) -> Unit,
|
||||
private val handleAuthenticate: (publicKey: String, privateHash: String) -> Unit,
|
||||
private val handleProtocolStateChanged: (state: ProtocolState) -> Unit,
|
||||
private val handleSendPacket: (packet: Packet) -> Unit,
|
||||
private val handleSyncCompleted: (reason: String) -> Unit,
|
||||
private val handleOwnProfileResolved: (publicKey: String) -> Unit,
|
||||
private val handleOwnProfileFallbackTimeout: (sessionGeneration: Long) -> Unit
|
||||
) {
|
||||
suspend fun route(event: ConnectionEvent) {
|
||||
when (event) {
|
||||
is ConnectionEvent.InitializeAccount -> {
|
||||
handleInitializeAccount(event.publicKey, event.privateKey)
|
||||
}
|
||||
is ConnectionEvent.Connect -> {
|
||||
handleConnect(event.reason)
|
||||
}
|
||||
is ConnectionEvent.FastReconnect -> {
|
||||
handleFastReconnect(event.reason)
|
||||
}
|
||||
is ConnectionEvent.Disconnect -> {
|
||||
handleDisconnect(event.reason, event.clearCredentials)
|
||||
}
|
||||
is ConnectionEvent.Authenticate -> {
|
||||
handleAuthenticate(event.publicKey, event.privateHash)
|
||||
}
|
||||
is ConnectionEvent.ProtocolStateChanged -> {
|
||||
handleProtocolStateChanged(event.state)
|
||||
}
|
||||
is ConnectionEvent.SendPacket -> {
|
||||
handleSendPacket(event.packet)
|
||||
}
|
||||
is ConnectionEvent.SyncCompleted -> {
|
||||
handleSyncCompleted(event.reason)
|
||||
}
|
||||
is ConnectionEvent.OwnProfileResolved -> {
|
||||
handleOwnProfileResolved(event.publicKey)
|
||||
}
|
||||
is ConnectionEvent.OwnProfileFallbackTimeout -> {
|
||||
handleOwnProfileFallbackTimeout(event.sessionGeneration)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import com.rosetta.messenger.network.HandshakeDevice
|
||||
import com.rosetta.messenger.network.Protocol
|
||||
|
||||
class ConnectionOrchestrator(
|
||||
private val hasActiveInternet: () -> Boolean,
|
||||
private val waitForNetworkAndReconnect: (String) -> Unit,
|
||||
private val stopWaitingForNetwork: (String) -> Unit,
|
||||
private val getProtocol: () -> Protocol,
|
||||
private val persistHandshakeCredentials: (publicKey: String, privateHash: String) -> Unit,
|
||||
private val buildHandshakeDevice: () -> HandshakeDevice
|
||||
) {
|
||||
fun handleConnect(reason: String) {
|
||||
if (!hasActiveInternet()) {
|
||||
waitForNetworkAndReconnect("connect:$reason")
|
||||
return
|
||||
}
|
||||
stopWaitingForNetwork("connect:$reason")
|
||||
getProtocol().connect()
|
||||
}
|
||||
|
||||
fun handleFastReconnect(reason: String) {
|
||||
if (!hasActiveInternet()) {
|
||||
waitForNetworkAndReconnect("reconnect:$reason")
|
||||
return
|
||||
}
|
||||
stopWaitingForNetwork("reconnect:$reason")
|
||||
getProtocol().reconnectNowIfNeeded(reason)
|
||||
}
|
||||
|
||||
fun handleAuthenticate(publicKey: String, privateHash: String) {
|
||||
runCatching { persistHandshakeCredentials(publicKey, privateHash) }
|
||||
val device = buildHandshakeDevice()
|
||||
getProtocol().startHandshake(publicKey, privateHash, device)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import com.rosetta.messenger.network.DeviceEntry
|
||||
import com.rosetta.messenger.network.DeviceResolveSolution
|
||||
import com.rosetta.messenger.network.HandshakeDevice
|
||||
import com.rosetta.messenger.network.Packet
|
||||
import com.rosetta.messenger.network.PacketDeviceList
|
||||
import java.security.SecureRandom
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class DeviceRuntimeService(
|
||||
private val getAppContext: () -> Context?,
|
||||
private val sendPacket: (Packet) -> Unit,
|
||||
private val devicePrefsName: String = "rosetta_protocol",
|
||||
private val deviceIdKey: String = "device_id",
|
||||
private val deviceIdLength: Int = 128
|
||||
) {
|
||||
private val verificationService = DeviceVerificationService()
|
||||
|
||||
val devices: StateFlow<List<DeviceEntry>> = verificationService.devices
|
||||
val pendingDeviceVerification: StateFlow<DeviceEntry?> =
|
||||
verificationService.pendingDeviceVerification
|
||||
|
||||
fun handleDeviceList(packet: PacketDeviceList) {
|
||||
verificationService.handleDeviceList(packet)
|
||||
}
|
||||
|
||||
fun acceptDevice(deviceId: String) {
|
||||
sendPacket(
|
||||
verificationService.buildResolvePacket(
|
||||
deviceId = deviceId,
|
||||
solution = DeviceResolveSolution.ACCEPT
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun declineDevice(deviceId: String) {
|
||||
sendPacket(
|
||||
verificationService.buildResolvePacket(
|
||||
deviceId = deviceId,
|
||||
solution = DeviceResolveSolution.DECLINE
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun resolvePushDeviceId(): String {
|
||||
return getAppContext()?.let(::getOrCreateDeviceId).orEmpty()
|
||||
}
|
||||
|
||||
fun buildHandshakeDevice(): HandshakeDevice {
|
||||
val context = getAppContext()
|
||||
val deviceId = if (context != null) getOrCreateDeviceId(context) else generateDeviceId()
|
||||
val manufacturer = Build.MANUFACTURER.orEmpty().trim()
|
||||
val model = Build.MODEL.orEmpty().trim()
|
||||
val name =
|
||||
listOf(manufacturer, model)
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
.joinToString(" ")
|
||||
.ifBlank { "Android Device" }
|
||||
val os = "Android ${Build.VERSION.RELEASE ?: "Unknown"}"
|
||||
|
||||
return HandshakeDevice(
|
||||
deviceId = deviceId,
|
||||
deviceName = name,
|
||||
deviceOs = os
|
||||
)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
verificationService.clear()
|
||||
}
|
||||
|
||||
private fun getOrCreateDeviceId(context: Context): String {
|
||||
val prefs = context.getSharedPreferences(devicePrefsName, Context.MODE_PRIVATE)
|
||||
val cached = prefs.getString(deviceIdKey, null)
|
||||
if (!cached.isNullOrBlank()) {
|
||||
return cached
|
||||
}
|
||||
val newId = generateDeviceId()
|
||||
prefs.edit().putString(deviceIdKey, newId).apply()
|
||||
return newId
|
||||
}
|
||||
|
||||
private fun generateDeviceId(): String {
|
||||
val chars = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
val random = SecureRandom()
|
||||
return buildString(deviceIdLength) {
|
||||
repeat(deviceIdLength) {
|
||||
append(chars[random.nextInt(chars.length)])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import com.rosetta.messenger.network.DeviceEntry
|
||||
import com.rosetta.messenger.network.DeviceResolveSolution
|
||||
import com.rosetta.messenger.network.DeviceVerifyState
|
||||
import com.rosetta.messenger.network.PacketDeviceList
|
||||
import com.rosetta.messenger.network.PacketDeviceResolve
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class DeviceVerificationService {
|
||||
private val _devices = MutableStateFlow<List<DeviceEntry>>(emptyList())
|
||||
val devices: StateFlow<List<DeviceEntry>> = _devices.asStateFlow()
|
||||
|
||||
private val _pendingDeviceVerification = MutableStateFlow<DeviceEntry?>(null)
|
||||
val pendingDeviceVerification: StateFlow<DeviceEntry?> = _pendingDeviceVerification.asStateFlow()
|
||||
|
||||
fun handleDeviceList(packet: PacketDeviceList) {
|
||||
val parsedDevices = packet.devices
|
||||
_devices.value = parsedDevices
|
||||
_pendingDeviceVerification.value =
|
||||
parsedDevices.firstOrNull { device ->
|
||||
device.deviceVerify == DeviceVerifyState.NOT_VERIFIED
|
||||
}
|
||||
}
|
||||
|
||||
fun buildResolvePacket(
|
||||
deviceId: String,
|
||||
solution: DeviceResolveSolution
|
||||
): PacketDeviceResolve {
|
||||
return PacketDeviceResolve().apply {
|
||||
this.deviceId = deviceId
|
||||
this.solution = solution
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
_devices.value = emptyList()
|
||||
_pendingDeviceVerification.value = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import android.util.Log
|
||||
import com.rosetta.messenger.data.GroupRepository
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.network.OnlineState
|
||||
import com.rosetta.messenger.network.Packet
|
||||
import com.rosetta.messenger.network.PacketDelivery
|
||||
import com.rosetta.messenger.network.PacketDeviceList
|
||||
import com.rosetta.messenger.network.PacketDeviceNew
|
||||
import com.rosetta.messenger.network.PacketGroupJoin
|
||||
import com.rosetta.messenger.network.PacketMessage
|
||||
import com.rosetta.messenger.network.PacketOnlineState
|
||||
import com.rosetta.messenger.network.PacketRead
|
||||
import com.rosetta.messenger.network.PacketRequestTransport
|
||||
import com.rosetta.messenger.network.PacketSearch
|
||||
import com.rosetta.messenger.network.PacketSync
|
||||
import com.rosetta.messenger.network.PacketTyping
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class InboundPacketHandlerRegistrar(
|
||||
private val tag: String,
|
||||
private val scope: CoroutineScope,
|
||||
private val syncCoordinator: SyncCoordinator,
|
||||
private val presenceTypingService: PresenceTypingService,
|
||||
private val deviceRuntimeService: DeviceRuntimeService,
|
||||
private val packetRouter: PacketRouter,
|
||||
private val ownProfileSyncService: OwnProfileSyncService,
|
||||
private val waitPacket: (Int, (Packet) -> Unit) -> Unit,
|
||||
private val launchInboundPacketTask: (suspend () -> Unit) -> Boolean,
|
||||
private val getMessageRepository: () -> MessageRepository?,
|
||||
private val getGroupRepository: () -> GroupRepository?,
|
||||
private val getProtocolPublicKey: () -> String,
|
||||
private val addLog: (String) -> Unit,
|
||||
private val markInboundProcessingFailure: (String, Throwable?) -> Unit,
|
||||
private val resolveOutgoingRetry: (String) -> Unit,
|
||||
private val isGroupDialogKey: (String) -> Boolean,
|
||||
private val onOwnProfileResolved: (String) -> Unit,
|
||||
private val setTransportServer: (String) -> Unit
|
||||
) {
|
||||
fun register() {
|
||||
registerIncomingMessageHandler()
|
||||
registerDeliveryHandler()
|
||||
registerReadHandler()
|
||||
registerDeviceLoginHandler()
|
||||
registerSyncHandler()
|
||||
registerGroupSyncHandler()
|
||||
registerOnlineStatusHandler()
|
||||
registerTypingHandler()
|
||||
registerDeviceListHandler()
|
||||
registerSearchHandler()
|
||||
registerTransportHandler()
|
||||
}
|
||||
|
||||
private fun registerIncomingMessageHandler() {
|
||||
waitPacket(0x06) { packet ->
|
||||
val messagePacket = packet as PacketMessage
|
||||
|
||||
launchInboundPacketTask {
|
||||
val repository = getMessageRepository()
|
||||
if (repository == null || !repository.isInitialized()) {
|
||||
syncCoordinator.requireResyncAfterAccountInit(
|
||||
"⏳ Incoming message before account init, scheduling re-sync"
|
||||
)
|
||||
markInboundProcessingFailure("Incoming packet skipped before account init", null)
|
||||
return@launchInboundPacketTask
|
||||
}
|
||||
val processed = repository.handleIncomingMessage(messagePacket)
|
||||
if (!processed) {
|
||||
markInboundProcessingFailure(
|
||||
"Message processing failed for ${messagePacket.messageId.take(8)}",
|
||||
null
|
||||
)
|
||||
return@launchInboundPacketTask
|
||||
}
|
||||
if (!syncCoordinator.isBatchInProgress()) {
|
||||
repository.updateLastSyncTimestamp(messagePacket.timestamp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerDeliveryHandler() {
|
||||
waitPacket(0x08) { packet ->
|
||||
val deliveryPacket = packet as PacketDelivery
|
||||
|
||||
launchInboundPacketTask {
|
||||
val repository = getMessageRepository()
|
||||
if (repository == null || !repository.isInitialized()) {
|
||||
syncCoordinator.requireResyncAfterAccountInit(
|
||||
"⏳ Delivery status before account init, scheduling re-sync"
|
||||
)
|
||||
markInboundProcessingFailure("Delivery packet skipped before account init", null)
|
||||
return@launchInboundPacketTask
|
||||
}
|
||||
try {
|
||||
repository.handleDelivery(deliveryPacket)
|
||||
resolveOutgoingRetry(deliveryPacket.messageId)
|
||||
} catch (e: Exception) {
|
||||
markInboundProcessingFailure("Delivery processing failed", e)
|
||||
return@launchInboundPacketTask
|
||||
}
|
||||
if (!syncCoordinator.isBatchInProgress()) {
|
||||
repository.updateLastSyncTimestamp(System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerReadHandler() {
|
||||
waitPacket(0x07) { packet ->
|
||||
val readPacket = packet as PacketRead
|
||||
|
||||
launchInboundPacketTask {
|
||||
val repository = getMessageRepository()
|
||||
if (repository == null || !repository.isInitialized()) {
|
||||
syncCoordinator.requireResyncAfterAccountInit(
|
||||
"⏳ Read status before account init, scheduling re-sync"
|
||||
)
|
||||
markInboundProcessingFailure("Read packet skipped before account init", null)
|
||||
return@launchInboundPacketTask
|
||||
}
|
||||
val ownKey = getProtocolPublicKey()
|
||||
if (ownKey.isBlank()) {
|
||||
syncCoordinator.requireResyncAfterAccountInit(
|
||||
"⏳ Read status before protocol account init, scheduling re-sync"
|
||||
)
|
||||
markInboundProcessingFailure(
|
||||
"Read packet skipped before protocol account init",
|
||||
null
|
||||
)
|
||||
return@launchInboundPacketTask
|
||||
}
|
||||
try {
|
||||
repository.handleRead(readPacket)
|
||||
} catch (e: Exception) {
|
||||
markInboundProcessingFailure("Read processing failed", e)
|
||||
return@launchInboundPacketTask
|
||||
}
|
||||
if (!syncCoordinator.isBatchInProgress()) {
|
||||
// Desktop parity:
|
||||
// own direct read sync (from=me,to=peer) does not advance sync cursor.
|
||||
val isOwnDirectReadSync =
|
||||
readPacket.fromPublicKey.trim() == ownKey &&
|
||||
!isGroupDialogKey(readPacket.toPublicKey)
|
||||
if (!isOwnDirectReadSync) {
|
||||
repository.updateLastSyncTimestamp(System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerDeviceLoginHandler() {
|
||||
waitPacket(0x09) { packet ->
|
||||
val devicePacket = packet as PacketDeviceNew
|
||||
|
||||
addLog(
|
||||
"🔐 DEVICE LOGIN ATTEMPT: ip=${devicePacket.ipAddress}, device=${devicePacket.device.deviceName}, os=${devicePacket.device.deviceOs}"
|
||||
)
|
||||
|
||||
launchInboundPacketTask {
|
||||
getMessageRepository()?.addDeviceLoginSystemMessage(
|
||||
ipAddress = devicePacket.ipAddress,
|
||||
deviceId = devicePacket.device.deviceId,
|
||||
deviceName = devicePacket.device.deviceName,
|
||||
deviceOs = devicePacket.device.deviceOs
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerSyncHandler() {
|
||||
waitPacket(0x19) { packet ->
|
||||
syncCoordinator.handleSyncPacket(packet as PacketSync)
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerGroupSyncHandler() {
|
||||
waitPacket(0x14) { packet ->
|
||||
val joinPacket = packet as PacketGroupJoin
|
||||
|
||||
launchInboundPacketTask {
|
||||
val repository = getMessageRepository()
|
||||
val groups = getGroupRepository()
|
||||
val account = repository?.getCurrentAccountKey()
|
||||
val privateKey = repository?.getCurrentPrivateKey()
|
||||
if (groups == null || account.isNullOrBlank() || privateKey.isNullOrBlank()) {
|
||||
return@launchInboundPacketTask
|
||||
}
|
||||
try {
|
||||
val result = groups.synchronizeJoinedGroup(
|
||||
accountPublicKey = account,
|
||||
accountPrivateKey = privateKey,
|
||||
packet = joinPacket
|
||||
)
|
||||
if (result?.success == true) {
|
||||
addLog("👥 GROUP synced: ${result.dialogPublicKey}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(tag, "Failed to sync group packet", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerOnlineStatusHandler() {
|
||||
waitPacket(0x05) { packet ->
|
||||
val onlinePacket = packet as PacketOnlineState
|
||||
|
||||
scope.launch {
|
||||
val repository = getMessageRepository()
|
||||
if (repository != null) {
|
||||
onlinePacket.publicKeysState.forEach { item ->
|
||||
val isOnline = item.state == OnlineState.ONLINE
|
||||
repository.updateOnlineStatus(item.publicKey, isOnline)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerTypingHandler() {
|
||||
waitPacket(0x0B) { packet ->
|
||||
presenceTypingService.handleTypingPacket(packet as PacketTyping) {
|
||||
getProtocolPublicKey().ifBlank {
|
||||
getMessageRepository()?.getCurrentAccountKey()?.trim().orEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerDeviceListHandler() {
|
||||
waitPacket(0x17) { packet ->
|
||||
deviceRuntimeService.handleDeviceList(packet as PacketDeviceList)
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerSearchHandler() {
|
||||
waitPacket(0x03) { packet ->
|
||||
val searchPacket = packet as PacketSearch
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val ownPublicKey =
|
||||
getProtocolPublicKey().ifBlank {
|
||||
getMessageRepository()?.getCurrentAccountKey()?.trim().orEmpty()
|
||||
}
|
||||
|
||||
packetRouter.onSearchPacket(searchPacket) { user ->
|
||||
val normalizedUserPublicKey = user.publicKey.trim()
|
||||
getMessageRepository()?.updateDialogUserInfo(
|
||||
normalizedUserPublicKey,
|
||||
user.title,
|
||||
user.username,
|
||||
user.verified
|
||||
)
|
||||
|
||||
val ownProfileResolved =
|
||||
ownProfileSyncService.applyOwnProfileFromSearch(
|
||||
ownPublicKey = ownPublicKey,
|
||||
user = user
|
||||
)
|
||||
if (ownProfileResolved) {
|
||||
onOwnProfileResolved(user.publicKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerTransportHandler() {
|
||||
waitPacket(0x0F) { packet ->
|
||||
val transportPacket = packet as PacketRequestTransport
|
||||
setTransportServer(transportPacket.transportServer)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class InboundTaskQueueService(
|
||||
private val scope: CoroutineScope,
|
||||
private val onTaskQueued: () -> Unit,
|
||||
private val onTaskFailure: (String, Throwable?) -> Unit
|
||||
) {
|
||||
private val inboundTaskChannel = Channel<suspend () -> Unit>(Channel.UNLIMITED)
|
||||
@Volatile private var inboundQueueDrainJob: Job? = null
|
||||
|
||||
fun enqueue(block: suspend () -> Unit): Boolean {
|
||||
ensureDrainRunning()
|
||||
onTaskQueued()
|
||||
val result = inboundTaskChannel.trySend(block)
|
||||
if (result.isFailure) {
|
||||
onTaskFailure("Failed to enqueue inbound task", result.exceptionOrNull())
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
suspend fun whenTasksFinish(): Boolean {
|
||||
val done = CompletableDeferred<Unit>()
|
||||
if (!enqueue { done.complete(Unit) }) {
|
||||
return false
|
||||
}
|
||||
done.await()
|
||||
return true
|
||||
}
|
||||
|
||||
private fun ensureDrainRunning() {
|
||||
if (inboundQueueDrainJob?.isActive == true) return
|
||||
inboundQueueDrainJob = scope.launch {
|
||||
for (task in inboundTaskChannel) {
|
||||
try {
|
||||
task()
|
||||
} catch (t: Throwable) {
|
||||
onTaskFailure("Dialog queue error", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import android.content.Context
|
||||
|
||||
class NetworkConnectivityFacade(
|
||||
private val networkReconnectWatcher: NetworkReconnectWatcher,
|
||||
private val getAppContext: () -> Context?
|
||||
) {
|
||||
fun hasActiveInternet(): Boolean {
|
||||
return networkReconnectWatcher.hasActiveInternet(getAppContext())
|
||||
}
|
||||
|
||||
fun stopWaitingForNetwork(reason: String? = null) {
|
||||
networkReconnectWatcher.stop(getAppContext(), reason)
|
||||
}
|
||||
|
||||
fun waitForNetworkAndReconnect(reason: String) {
|
||||
networkReconnectWatcher.waitForNetwork(getAppContext(), reason)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.Build
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class NetworkReconnectWatcher(
|
||||
private val scope: CoroutineScope,
|
||||
private val networkWaitTimeoutMs: Long,
|
||||
private val addLog: (String) -> Unit,
|
||||
private val onReconnectRequested: (String) -> Unit
|
||||
) {
|
||||
private val lock = Any()
|
||||
|
||||
@Volatile private var registered = false
|
||||
@Volatile private var callback: ConnectivityManager.NetworkCallback? = null
|
||||
@Volatile private var timeoutJob: Job? = null
|
||||
|
||||
fun hasActiveInternet(context: Context?): Boolean {
|
||||
if (context == null) return true
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
?: return true
|
||||
val network = cm.activeNetwork ?: return false
|
||||
val caps = cm.getNetworkCapabilities(network) ?: return false
|
||||
return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
}
|
||||
|
||||
fun stop(context: Context?, reason: String? = null) {
|
||||
if (context == null) return
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
?: return
|
||||
|
||||
val currentCallback = synchronized(lock) {
|
||||
val current = callback
|
||||
callback = null
|
||||
registered = false
|
||||
timeoutJob?.cancel()
|
||||
timeoutJob = null
|
||||
current
|
||||
}
|
||||
|
||||
if (currentCallback != null) {
|
||||
runCatching { cm.unregisterNetworkCallback(currentCallback) }
|
||||
if (!reason.isNullOrBlank()) {
|
||||
addLog("📡 NETWORK WATCH STOP: $reason")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun waitForNetwork(context: Context?, reason: String) {
|
||||
if (context == null) return
|
||||
if (hasActiveInternet(context)) {
|
||||
stop(context, "network already available")
|
||||
return
|
||||
}
|
||||
|
||||
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||
?: return
|
||||
|
||||
val alreadyRegistered = synchronized(lock) {
|
||||
if (registered) {
|
||||
true
|
||||
} else {
|
||||
val callback =
|
||||
object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
if (hasActiveInternet(context)) {
|
||||
addLog("📡 NETWORK AVAILABLE → reconnect")
|
||||
stop(context, "available")
|
||||
onReconnectRequested("network_available")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCapabilitiesChanged(
|
||||
network: Network,
|
||||
networkCapabilities: NetworkCapabilities
|
||||
) {
|
||||
if (networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) {
|
||||
addLog("📡 NETWORK CAPABILITIES READY → reconnect")
|
||||
stop(context, "capabilities_changed")
|
||||
onReconnectRequested("network_capabilities_changed")
|
||||
}
|
||||
}
|
||||
}
|
||||
this.callback = callback
|
||||
registered = true
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
if (alreadyRegistered) {
|
||||
addLog("📡 NETWORK WAIT already active (reason=$reason)")
|
||||
return
|
||||
}
|
||||
|
||||
addLog("📡 NETWORK WAIT start (reason=$reason)")
|
||||
runCatching {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
cm.registerDefaultNetworkCallback(callback!!)
|
||||
} else {
|
||||
val request =
|
||||
NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.build()
|
||||
cm.registerNetworkCallback(request, callback!!)
|
||||
}
|
||||
}.onFailure { error ->
|
||||
addLog("⚠️ NETWORK WAIT register failed: ${error.message}")
|
||||
stop(context, "register_failed")
|
||||
onReconnectRequested("network_wait_register_failed")
|
||||
}
|
||||
|
||||
timeoutJob?.cancel()
|
||||
timeoutJob =
|
||||
scope.launch {
|
||||
delay(networkWaitTimeoutMs)
|
||||
if (!hasActiveInternet(context)) {
|
||||
addLog("⏱️ NETWORK WAIT timeout (${networkWaitTimeoutMs}ms), reconnect fallback")
|
||||
stop(context, "timeout")
|
||||
onReconnectRequested("network_wait_timeout")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.network.DeliveryStatus
|
||||
import com.rosetta.messenger.network.PacketMessage
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
class OutgoingMessagePipelineService(
|
||||
scope: CoroutineScope,
|
||||
private val getRepository: () -> MessageRepository?,
|
||||
private val sendPacket: (PacketMessage) -> Unit,
|
||||
isAuthenticated: () -> Boolean,
|
||||
addLog: (String) -> Unit
|
||||
) {
|
||||
private val retryQueueService =
|
||||
RetryQueueService(
|
||||
scope = scope,
|
||||
sendPacket = sendPacket,
|
||||
isAuthenticated = isAuthenticated,
|
||||
addLog = addLog,
|
||||
markOutgoingAsError = ::markOutgoingAsError
|
||||
)
|
||||
|
||||
fun sendWithRetry(packet: PacketMessage) {
|
||||
sendPacket(packet)
|
||||
retryQueueService.register(packet)
|
||||
}
|
||||
|
||||
fun resolveOutgoingRetry(messageId: String) {
|
||||
retryQueueService.resolve(messageId)
|
||||
}
|
||||
|
||||
fun clearRetryQueue() {
|
||||
retryQueueService.clear()
|
||||
}
|
||||
|
||||
private suspend fun markOutgoingAsError(messageId: String, packet: PacketMessage) {
|
||||
val repository = getRepository() ?: return
|
||||
val opponentKey =
|
||||
if (packet.fromPublicKey == repository.getCurrentAccountKey()) {
|
||||
packet.toPublicKey
|
||||
} else {
|
||||
packet.fromPublicKey
|
||||
}
|
||||
val dialogKey = repository.getDialogKey(opponentKey)
|
||||
repository.updateMessageDeliveryStatus(dialogKey, messageId, DeliveryStatus.ERROR)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class OwnProfileFallbackTimerService(
|
||||
private val scope: CoroutineScope,
|
||||
private val fallbackTimeoutMs: Long,
|
||||
private val onTimeout: (Long) -> Unit
|
||||
) {
|
||||
@Volatile private var timeoutJob: Job? = null
|
||||
|
||||
fun schedule(sessionGeneration: Long) {
|
||||
cancel()
|
||||
timeoutJob =
|
||||
scope.launch {
|
||||
delay(fallbackTimeoutMs)
|
||||
onTimeout(sessionGeneration)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
timeoutJob?.cancel()
|
||||
timeoutJob = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import com.rosetta.messenger.network.PacketSearch
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import com.rosetta.messenger.session.IdentityStore
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class OwnProfileSyncService(
|
||||
private val isPlaceholderAccountName: (String?) -> Boolean,
|
||||
private val updateAccountName: suspend (publicKey: String, name: String) -> Unit,
|
||||
private val updateAccountUsername: suspend (publicKey: String, username: String) -> Unit
|
||||
) {
|
||||
private val _ownProfileUpdated = MutableStateFlow(0L)
|
||||
val ownProfileUpdated: StateFlow<Long> = _ownProfileUpdated.asStateFlow()
|
||||
|
||||
fun notifyOwnProfileUpdated() {
|
||||
_ownProfileUpdated.value = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
suspend fun applyOwnProfileFromSearch(
|
||||
ownPublicKey: String,
|
||||
user: SearchUser
|
||||
): Boolean {
|
||||
if (ownPublicKey.isBlank()) return false
|
||||
if (!user.publicKey.equals(ownPublicKey, ignoreCase = true)) return false
|
||||
|
||||
if (user.title.isNotBlank() && !isPlaceholderAccountName(user.title)) {
|
||||
updateAccountName(ownPublicKey, user.title)
|
||||
}
|
||||
if (user.username.isNotBlank()) {
|
||||
updateAccountUsername(ownPublicKey, user.username)
|
||||
}
|
||||
IdentityStore.updateOwnProfile(user, reason = "protocol_search_own_profile")
|
||||
_ownProfileUpdated.value = System.currentTimeMillis()
|
||||
return true
|
||||
}
|
||||
|
||||
fun buildOwnProfilePacket(publicKey: String?, privateHash: String?): PacketSearch? {
|
||||
val normalizedPublicKey = publicKey?.trim().orEmpty()
|
||||
val normalizedPrivateHash = privateHash?.trim().orEmpty()
|
||||
if (normalizedPublicKey.isEmpty() || normalizedPrivateHash.isEmpty()) return null
|
||||
|
||||
return PacketSearch().apply {
|
||||
this.privateKey = normalizedPrivateHash
|
||||
this.search = normalizedPublicKey
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import com.rosetta.messenger.network.PacketSearch
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.LinkedHashSet
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class PacketRouter(
|
||||
private val sendSearchPacket: (PacketSearch) -> Unit,
|
||||
private val privateHashProvider: () -> String?
|
||||
) {
|
||||
private val userInfoCache = ConcurrentHashMap<String, SearchUser>()
|
||||
private val pendingResolves =
|
||||
ConcurrentHashMap<String, MutableList<kotlinx.coroutines.CancellableContinuation<SearchUser?>>>()
|
||||
private val pendingSearchQueries =
|
||||
ConcurrentHashMap<String, MutableList<kotlinx.coroutines.CancellableContinuation<List<SearchUser>>>>()
|
||||
|
||||
private fun normalizeSearchQuery(value: String): String =
|
||||
value.trim().removePrefix("@").lowercase(Locale.ROOT)
|
||||
|
||||
suspend fun onSearchPacket(packet: PacketSearch, onUserDiscovered: suspend (SearchUser) -> Unit) {
|
||||
if (packet.users.isNotEmpty()) {
|
||||
packet.users.forEach { user ->
|
||||
val normalizedUserPublicKey = user.publicKey.trim()
|
||||
userInfoCache[normalizedUserPublicKey] = user
|
||||
|
||||
pendingResolves
|
||||
.keys
|
||||
.filter { it.equals(normalizedUserPublicKey, ignoreCase = true) }
|
||||
.forEach { key ->
|
||||
pendingResolves.remove(key)?.forEach { cont ->
|
||||
try {
|
||||
cont.resume(user)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
onUserDiscovered(user)
|
||||
}
|
||||
}
|
||||
|
||||
if (packet.search.isNotEmpty() && packet.users.none { it.publicKey == packet.search }) {
|
||||
pendingResolves.remove(packet.search)?.forEach { cont ->
|
||||
try {
|
||||
cont.resume(null)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
if (packet.search.isNotEmpty()) {
|
||||
val rawQuery = packet.search.trim()
|
||||
val normalizedQuery = normalizeSearchQuery(rawQuery)
|
||||
val continuations =
|
||||
LinkedHashSet<kotlinx.coroutines.CancellableContinuation<List<SearchUser>>>()
|
||||
|
||||
fun collectByKey(key: String) {
|
||||
if (key.isEmpty()) return
|
||||
pendingSearchQueries.remove(key)?.let { continuations.addAll(it) }
|
||||
}
|
||||
|
||||
collectByKey(rawQuery)
|
||||
if (normalizedQuery.isNotEmpty() && normalizedQuery != rawQuery) {
|
||||
collectByKey(normalizedQuery)
|
||||
}
|
||||
|
||||
if (continuations.isEmpty()) {
|
||||
val matchedByQuery =
|
||||
pendingSearchQueries.keys.firstOrNull { pendingKey ->
|
||||
pendingKey.equals(rawQuery, ignoreCase = true) ||
|
||||
normalizeSearchQuery(pendingKey) == normalizedQuery
|
||||
}
|
||||
if (matchedByQuery != null) collectByKey(matchedByQuery)
|
||||
}
|
||||
|
||||
if (continuations.isEmpty() && packet.users.isNotEmpty()) {
|
||||
val responseUsernames =
|
||||
packet.users
|
||||
.map { normalizeSearchQuery(it.username) }
|
||||
.filter { it.isNotEmpty() }
|
||||
.toSet()
|
||||
if (responseUsernames.isNotEmpty()) {
|
||||
val matchedByUsers =
|
||||
pendingSearchQueries.keys.firstOrNull { pendingKey ->
|
||||
val normalizedPending = normalizeSearchQuery(pendingKey)
|
||||
normalizedPending.isNotEmpty() &&
|
||||
responseUsernames.contains(normalizedPending)
|
||||
}
|
||||
if (matchedByUsers != null) collectByKey(matchedByUsers)
|
||||
}
|
||||
}
|
||||
|
||||
continuations.forEach { cont ->
|
||||
try {
|
||||
cont.resume(packet.users)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getCachedUserName(publicKey: String): String? {
|
||||
val cached = userInfoCache[publicKey] ?: return null
|
||||
return cached.title.ifEmpty { cached.username }.ifEmpty { null }
|
||||
}
|
||||
|
||||
fun getCachedUserInfo(publicKey: String): SearchUser? = userInfoCache[publicKey]
|
||||
|
||||
fun getCachedUserByUsername(username: String): SearchUser? {
|
||||
val normalizedUsername = normalizeSearchQuery(username)
|
||||
if (normalizedUsername.isEmpty()) return null
|
||||
return userInfoCache.values.firstOrNull { cached ->
|
||||
normalizeSearchQuery(cached.username) == normalizedUsername
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resolveUserName(publicKey: String, timeoutMs: Long = 3000): String? {
|
||||
if (publicKey.isEmpty()) return null
|
||||
|
||||
userInfoCache[publicKey]?.let { cached ->
|
||||
val name = cached.title.ifEmpty { cached.username }
|
||||
if (name.isNotEmpty()) return name
|
||||
}
|
||||
|
||||
val privateHash = privateHashProvider()?.takeIf { it.isNotBlank() } ?: return null
|
||||
|
||||
return try {
|
||||
withTimeout(timeoutMs) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
pendingResolves.getOrPut(publicKey) { mutableListOf() }.add(cont)
|
||||
|
||||
cont.invokeOnCancellation {
|
||||
pendingResolves[publicKey]?.remove(cont)
|
||||
if (pendingResolves[publicKey]?.isEmpty() == true) {
|
||||
pendingResolves.remove(publicKey)
|
||||
}
|
||||
}
|
||||
|
||||
val packet = PacketSearch().apply {
|
||||
this.privateKey = privateHash
|
||||
this.search = publicKey
|
||||
}
|
||||
sendSearchPacket(packet)
|
||||
}
|
||||
}?.let { user -> user.title.ifEmpty { user.username }.ifEmpty { null } }
|
||||
} catch (_: Exception) {
|
||||
pendingResolves.remove(publicKey)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun resolveUserInfo(publicKey: String, timeoutMs: Long = 3000): SearchUser? {
|
||||
if (publicKey.isEmpty()) return null
|
||||
|
||||
userInfoCache[publicKey]?.let { return it }
|
||||
val privateHash = privateHashProvider()?.takeIf { it.isNotBlank() } ?: return null
|
||||
|
||||
return try {
|
||||
withTimeout(timeoutMs) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
pendingResolves.getOrPut(publicKey) { mutableListOf() }.add(cont)
|
||||
cont.invokeOnCancellation {
|
||||
pendingResolves[publicKey]?.remove(cont)
|
||||
if (pendingResolves[publicKey]?.isEmpty() == true) {
|
||||
pendingResolves.remove(publicKey)
|
||||
}
|
||||
}
|
||||
val packet = PacketSearch().apply {
|
||||
this.privateKey = privateHash
|
||||
this.search = publicKey
|
||||
}
|
||||
sendSearchPacket(packet)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
pendingResolves.remove(publicKey)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun searchUsers(query: String, timeoutMs: Long = 3000): List<SearchUser> {
|
||||
val normalizedQuery = normalizeSearchQuery(query)
|
||||
if (normalizedQuery.isEmpty()) return emptyList()
|
||||
|
||||
val privateHash = privateHashProvider()?.takeIf { it.isNotBlank() } ?: return emptyList()
|
||||
|
||||
val cachedMatches =
|
||||
userInfoCache.values.filter { cached ->
|
||||
normalizeSearchQuery(cached.username) == normalizedQuery && cached.publicKey.isNotBlank()
|
||||
}
|
||||
if (cachedMatches.isNotEmpty()) {
|
||||
return cachedMatches.distinctBy { it.publicKey }
|
||||
}
|
||||
|
||||
return try {
|
||||
withTimeout(timeoutMs) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
pendingSearchQueries.getOrPut(normalizedQuery) { mutableListOf() }.add(cont)
|
||||
|
||||
cont.invokeOnCancellation {
|
||||
pendingSearchQueries[normalizedQuery]?.remove(cont)
|
||||
if (pendingSearchQueries[normalizedQuery]?.isEmpty() == true) {
|
||||
pendingSearchQueries.remove(normalizedQuery)
|
||||
}
|
||||
}
|
||||
|
||||
val packet = PacketSearch().apply {
|
||||
this.privateKey = privateHash
|
||||
this.search = normalizedQuery
|
||||
}
|
||||
sendSearchPacket(packet)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
pendingSearchQueries.remove(normalizedQuery)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import com.rosetta.messenger.network.Packet
|
||||
import com.rosetta.messenger.network.PacketSubscriptionRegistry
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
class PacketSubscriptionFacade(
|
||||
private val packetSubscriptionRegistry: PacketSubscriptionRegistry
|
||||
) {
|
||||
fun waitPacket(packetId: Int, callback: (Packet) -> Unit) {
|
||||
packetSubscriptionRegistry.addCallback(packetId, callback)
|
||||
}
|
||||
|
||||
fun unwaitPacket(packetId: Int, callback: (Packet) -> Unit) {
|
||||
packetSubscriptionRegistry.removeCallback(packetId, callback)
|
||||
}
|
||||
|
||||
fun packetFlow(packetId: Int): SharedFlow<Packet> {
|
||||
return packetSubscriptionRegistry.flow(packetId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import com.rosetta.messenger.network.PacketTyping
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class PresenceTypingService(
|
||||
private val scope: CoroutineScope,
|
||||
private val typingIndicatorTimeoutMs: Long
|
||||
) {
|
||||
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
|
||||
val typingUsers: StateFlow<Set<String>> = _typingUsers.asStateFlow()
|
||||
|
||||
private val _typingUsersByDialogSnapshot =
|
||||
MutableStateFlow<Map<String, Set<String>>>(emptyMap())
|
||||
val typingUsersByDialogSnapshot: StateFlow<Map<String, Set<String>>> =
|
||||
_typingUsersByDialogSnapshot.asStateFlow()
|
||||
|
||||
private val typingStateLock = Any()
|
||||
private val typingUsersByDialog = mutableMapOf<String, MutableSet<String>>()
|
||||
private val typingTimeoutJobs = ConcurrentHashMap<String, Job>()
|
||||
|
||||
fun getTypingUsersForDialog(dialogKey: String): Set<String> {
|
||||
val normalizedDialogKey =
|
||||
if (isGroupDialogKey(dialogKey)) {
|
||||
normalizeGroupDialogKey(dialogKey)
|
||||
} else {
|
||||
dialogKey.trim()
|
||||
}
|
||||
if (normalizedDialogKey.isBlank()) return emptySet()
|
||||
|
||||
synchronized(typingStateLock) {
|
||||
return typingUsersByDialog[normalizedDialogKey]?.toSet() ?: emptySet()
|
||||
}
|
||||
}
|
||||
|
||||
fun handleTypingPacket(
|
||||
packet: PacketTyping,
|
||||
ownPublicKeyProvider: () -> String
|
||||
) {
|
||||
val fromPublicKey = packet.fromPublicKey.trim()
|
||||
val toPublicKey = packet.toPublicKey.trim()
|
||||
if (fromPublicKey.isBlank() || toPublicKey.isBlank()) return
|
||||
|
||||
val ownPublicKey = ownPublicKeyProvider().trim()
|
||||
if (ownPublicKey.isNotBlank() && fromPublicKey.equals(ownPublicKey, ignoreCase = true)) {
|
||||
return
|
||||
}
|
||||
|
||||
val dialogKey =
|
||||
resolveTypingDialogKey(
|
||||
fromPublicKey = fromPublicKey,
|
||||
toPublicKey = toPublicKey,
|
||||
ownPublicKey = ownPublicKey
|
||||
) ?: return
|
||||
|
||||
rememberTypingEvent(dialogKey, fromPublicKey)
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
typingTimeoutJobs.values.forEach { it.cancel() }
|
||||
typingTimeoutJobs.clear()
|
||||
synchronized(typingStateLock) {
|
||||
typingUsersByDialog.clear()
|
||||
_typingUsers.value = emptySet()
|
||||
_typingUsersByDialogSnapshot.value = emptyMap()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isGroupDialogKey(value: String): Boolean {
|
||||
val normalized = value.trim().lowercase(Locale.ROOT)
|
||||
return normalized.startsWith("#group:") || normalized.startsWith("group:")
|
||||
}
|
||||
|
||||
private fun normalizeGroupDialogKey(value: String): String {
|
||||
val trimmed = value.trim()
|
||||
val normalized = trimmed.lowercase(Locale.ROOT)
|
||||
return when {
|
||||
normalized.startsWith("#group:") -> "#group:${trimmed.substringAfter(':').trim()}"
|
||||
normalized.startsWith("group:") -> "#group:${trimmed.substringAfter(':').trim()}"
|
||||
else -> trimmed
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveTypingDialogKey(
|
||||
fromPublicKey: String,
|
||||
toPublicKey: String,
|
||||
ownPublicKey: String
|
||||
): String? {
|
||||
return when {
|
||||
isGroupDialogKey(toPublicKey) -> normalizeGroupDialogKey(toPublicKey)
|
||||
ownPublicKey.isNotBlank() && toPublicKey.equals(ownPublicKey, ignoreCase = true) ->
|
||||
fromPublicKey.trim()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun makeTypingTimeoutKey(dialogKey: String, fromPublicKey: String): String {
|
||||
return "${dialogKey.lowercase(Locale.ROOT)}|${fromPublicKey.lowercase(Locale.ROOT)}"
|
||||
}
|
||||
|
||||
private fun rememberTypingEvent(dialogKey: String, fromPublicKey: String) {
|
||||
val normalizedDialogKey =
|
||||
if (isGroupDialogKey(dialogKey)) normalizeGroupDialogKey(dialogKey) else dialogKey.trim()
|
||||
val normalizedFrom = fromPublicKey.trim()
|
||||
if (normalizedDialogKey.isBlank() || normalizedFrom.isBlank()) return
|
||||
|
||||
synchronized(typingStateLock) {
|
||||
val users = typingUsersByDialog.getOrPut(normalizedDialogKey) { mutableSetOf() }
|
||||
users.add(normalizedFrom)
|
||||
_typingUsers.value = typingUsersByDialog.keys.toSet()
|
||||
_typingUsersByDialogSnapshot.value =
|
||||
typingUsersByDialog.mapValues { entry -> entry.value.toSet() }
|
||||
}
|
||||
|
||||
val timeoutKey = makeTypingTimeoutKey(normalizedDialogKey, normalizedFrom)
|
||||
typingTimeoutJobs.remove(timeoutKey)?.cancel()
|
||||
typingTimeoutJobs[timeoutKey] =
|
||||
scope.launch {
|
||||
delay(typingIndicatorTimeoutMs)
|
||||
synchronized(typingStateLock) {
|
||||
val users = typingUsersByDialog[normalizedDialogKey]
|
||||
users?.remove(normalizedFrom)
|
||||
if (users.isNullOrEmpty()) {
|
||||
typingUsersByDialog.remove(normalizedDialogKey)
|
||||
}
|
||||
_typingUsers.value = typingUsersByDialog.keys.toSet()
|
||||
_typingUsersByDialogSnapshot.value =
|
||||
typingUsersByDialog.mapValues { entry -> entry.value.toSet() }
|
||||
}
|
||||
typingTimeoutJobs.remove(timeoutKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.network.ConnectionBootstrapContext
|
||||
import com.rosetta.messenger.network.ProtocolState
|
||||
|
||||
class ProtocolAccountSessionCoordinator(
|
||||
private val stateStore: ProtocolLifecycleStateStore,
|
||||
private val syncCoordinator: SyncCoordinator,
|
||||
private val authBootstrapCoordinator: AuthBootstrapCoordinator,
|
||||
private val presenceTypingService: PresenceTypingService,
|
||||
private val deviceRuntimeService: DeviceRuntimeService,
|
||||
private val getMessageRepository: () -> MessageRepository?,
|
||||
private val getProtocolState: () -> ProtocolState,
|
||||
private val isProtocolAuthenticated: () -> Boolean,
|
||||
private val addLog: (String) -> Unit,
|
||||
private val shortKeyForLog: (String) -> String,
|
||||
private val clearReadyPacketQueue: (String) -> Unit,
|
||||
private val recomputeConnectionLifecycleState: (String) -> Unit,
|
||||
private val stopWaitingForNetwork: (String) -> Unit,
|
||||
private val disconnectProtocol: (clearCredentials: Boolean) -> Unit,
|
||||
private val tryRunPostAuthBootstrap: (String) -> Unit,
|
||||
private val launchVersionUpdateCheck: () -> Unit
|
||||
) {
|
||||
fun handleInitializeAccount(publicKey: String, privateKey: String) {
|
||||
val normalizedPublicKey = publicKey.trim()
|
||||
val normalizedPrivateKey = privateKey.trim()
|
||||
if (normalizedPublicKey.isBlank() || normalizedPrivateKey.isBlank()) {
|
||||
addLog("⚠️ initializeAccount skipped: missing account credentials")
|
||||
return
|
||||
}
|
||||
|
||||
val protocolState = getProtocolState()
|
||||
addLog(
|
||||
"🔐 initializeAccount pk=${shortKeyForLog(normalizedPublicKey)} keyLen=${normalizedPrivateKey.length} state=$protocolState"
|
||||
)
|
||||
syncCoordinator.markSyncInProgress(false)
|
||||
presenceTypingService.clear()
|
||||
|
||||
val repository = getMessageRepository()
|
||||
if (repository == null) {
|
||||
addLog("❌ initializeAccount aborted: MessageRepository is not bound")
|
||||
return
|
||||
}
|
||||
repository.initialize(normalizedPublicKey, normalizedPrivateKey)
|
||||
|
||||
val context = stateStore.bootstrapContext
|
||||
val sameAccount = context.accountPublicKey.equals(normalizedPublicKey, ignoreCase = true)
|
||||
if (!sameAccount) {
|
||||
clearReadyPacketQueue("account_switch")
|
||||
}
|
||||
|
||||
stateStore.bootstrapContext =
|
||||
context.copy(
|
||||
accountPublicKey = normalizedPublicKey,
|
||||
accountInitialized = true,
|
||||
syncCompleted = if (sameAccount) context.syncCompleted else false,
|
||||
ownProfileResolved = if (sameAccount) context.ownProfileResolved else false
|
||||
)
|
||||
recomputeConnectionLifecycleState("account_initialized")
|
||||
|
||||
val shouldResync = syncCoordinator.shouldResyncAfterAccountInit() || isProtocolAuthenticated()
|
||||
if (shouldResync) {
|
||||
syncCoordinator.clearResyncRequired()
|
||||
syncCoordinator.clearRequestState()
|
||||
addLog("🔄 Account initialized (${shortKeyForLog(normalizedPublicKey)}) -> force sync")
|
||||
syncCoordinator.requestSynchronize()
|
||||
}
|
||||
if (isProtocolAuthenticated() && authBootstrapCoordinator.isBootstrapPending()) {
|
||||
tryRunPostAuthBootstrap("account_initialized")
|
||||
}
|
||||
|
||||
launchVersionUpdateCheck()
|
||||
}
|
||||
|
||||
fun handleDisconnect(reason: String, clearCredentials: Boolean) {
|
||||
stopWaitingForNetwork(reason)
|
||||
disconnectProtocol(clearCredentials)
|
||||
getMessageRepository()?.clearInitialization()
|
||||
presenceTypingService.clear()
|
||||
deviceRuntimeService.clear()
|
||||
syncCoordinator.resetForDisconnect()
|
||||
stateStore.clearLastSubscribedToken()
|
||||
stateStore.cancelOwnProfileFallbackTimeout()
|
||||
authBootstrapCoordinator.reset()
|
||||
stateStore.bootstrapContext = ConnectionBootstrapContext()
|
||||
clearReadyPacketQueue("disconnect:$reason")
|
||||
recomputeConnectionLifecycleState("disconnect:$reason")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal class ProtocolDebugLogService(
|
||||
private val scope: CoroutineScope,
|
||||
private val maxDebugLogs: Int,
|
||||
private val debugLogFlushDelayMs: Long,
|
||||
private val heartbeatOkLogMinIntervalMs: Long,
|
||||
private val protocolTraceFileName: String,
|
||||
private val protocolTraceMaxBytes: Long,
|
||||
private val protocolTraceKeepBytes: Int,
|
||||
private val appContextProvider: () -> Context?
|
||||
) {
|
||||
private var uiLogsEnabled = false
|
||||
private val _debugLogs = MutableStateFlow<List<String>>(emptyList())
|
||||
val debugLogs: StateFlow<List<String>> = _debugLogs.asStateFlow()
|
||||
private val debugLogsBuffer = ArrayDeque<String>(maxDebugLogs)
|
||||
private val debugLogsLock = Any()
|
||||
private val protocolTraceLock = Any()
|
||||
@Volatile private var debugFlushJob: Job? = null
|
||||
private val debugFlushPending = AtomicBoolean(false)
|
||||
@Volatile private var lastHeartbeatOkLogAtMs: Long = 0L
|
||||
@Volatile private var suppressedHeartbeatOkLogs: Int = 0
|
||||
|
||||
fun addLog(message: String) {
|
||||
var normalizedMessage = message
|
||||
val isHeartbeatOk = normalizedMessage.startsWith("💓 Heartbeat OK")
|
||||
if (isHeartbeatOk) {
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastHeartbeatOkLogAtMs < heartbeatOkLogMinIntervalMs) {
|
||||
suppressedHeartbeatOkLogs++
|
||||
return
|
||||
}
|
||||
if (suppressedHeartbeatOkLogs > 0) {
|
||||
normalizedMessage = "$normalizedMessage (+${suppressedHeartbeatOkLogs} skipped)"
|
||||
suppressedHeartbeatOkLogs = 0
|
||||
}
|
||||
lastHeartbeatOkLogAtMs = now
|
||||
}
|
||||
|
||||
val timestamp =
|
||||
java.text.SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
|
||||
val line = "[$timestamp] $normalizedMessage"
|
||||
if (shouldPersistProtocolTrace(normalizedMessage)) {
|
||||
persistProtocolTraceLine(line)
|
||||
}
|
||||
if (!uiLogsEnabled) return
|
||||
|
||||
synchronized(debugLogsLock) {
|
||||
if (debugLogsBuffer.size >= maxDebugLogs) {
|
||||
debugLogsBuffer.removeFirst()
|
||||
}
|
||||
debugLogsBuffer.addLast(line)
|
||||
}
|
||||
flushDebugLogsThrottled()
|
||||
}
|
||||
|
||||
fun enableUILogs(enabled: Boolean) {
|
||||
uiLogsEnabled = enabled
|
||||
if (enabled) {
|
||||
val snapshot = synchronized(debugLogsLock) { debugLogsBuffer.toList() }
|
||||
_debugLogs.value = snapshot
|
||||
} else {
|
||||
_debugLogs.value = emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
fun clearLogs() {
|
||||
synchronized(debugLogsLock) {
|
||||
debugLogsBuffer.clear()
|
||||
}
|
||||
suppressedHeartbeatOkLogs = 0
|
||||
lastHeartbeatOkLogAtMs = 0L
|
||||
_debugLogs.value = emptyList()
|
||||
}
|
||||
|
||||
private fun shouldPersistProtocolTrace(message: String): Boolean {
|
||||
if (uiLogsEnabled) return true
|
||||
if (message.startsWith("❌") || message.startsWith("⚠️")) return true
|
||||
if (message.contains("STATE CHANGE")) return true
|
||||
if (message.contains("CONNECTION FULLY ESTABLISHED")) return true
|
||||
if (message.contains("HANDSHAKE COMPLETE")) return true
|
||||
if (message.contains("SYNC COMPLETE")) return true
|
||||
if (message.startsWith("🔌 CONNECT CALLED") || message.startsWith("🔌 Connecting to")) return true
|
||||
if (message.startsWith("✅ WebSocket OPEN")) return true
|
||||
if (message.startsWith("📡 NETWORK")) return true
|
||||
return false
|
||||
}
|
||||
|
||||
private fun persistProtocolTraceLine(line: String) {
|
||||
val context = appContextProvider() ?: return
|
||||
runCatching {
|
||||
val dir = File(context.filesDir, "crash_reports")
|
||||
if (!dir.exists()) dir.mkdirs()
|
||||
val traceFile = File(dir, protocolTraceFileName)
|
||||
synchronized(protocolTraceLock) {
|
||||
if (traceFile.exists() && traceFile.length() > protocolTraceMaxBytes) {
|
||||
val tail = runCatching {
|
||||
traceFile.readText(Charsets.UTF_8).takeLast(protocolTraceKeepBytes)
|
||||
}.getOrDefault("")
|
||||
traceFile.writeText("=== PROTOCOL TRACE ROTATED ===\n$tail\n", Charsets.UTF_8)
|
||||
}
|
||||
traceFile.appendText("$line\n", Charsets.UTF_8)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun flushDebugLogsThrottled() {
|
||||
debugFlushPending.set(true)
|
||||
if (debugFlushJob?.isActive == true) return
|
||||
debugFlushJob =
|
||||
scope.launch {
|
||||
while (debugFlushPending.getAndSet(false)) {
|
||||
delay(debugLogFlushDelayMs)
|
||||
val snapshot = synchronized(debugLogsLock) { debugLogsBuffer.toList() }
|
||||
_debugLogs.value = snapshot
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.rosetta.messenger.network.connection
|
||||
|
||||
import com.rosetta.messenger.network.Protocol
|
||||
import com.rosetta.messenger.network.ProtocolState
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
class ProtocolInstanceManager(
|
||||
private val serverAddress: String,
|
||||
private val addLog: (String) -> Unit,
|
||||
private val isNetworkAvailable: () -> Boolean,
|
||||
private val onNetworkUnavailable: () -> Unit
|
||||
) {
|
||||
@Volatile private var protocol: Protocol? = null
|
||||
private val protocolInstanceLock = Any()
|
||||
|
||||
fun getOrCreateProtocol(): Protocol {
|
||||
protocol?.let { return it }
|
||||
|
||||
synchronized(protocolInstanceLock) {
|
||||
protocol?.let { return it }
|
||||
|
||||
val created =
|
||||
Protocol(
|
||||
serverAddress = serverAddress,
|
||||
logger = { msg -> addLog(msg) },
|
||||
isNetworkAvailable = isNetworkAvailable,
|
||||
onNetworkUnavailable = onNetworkUnavailable
|
||||
)
|
||||
protocol = created
|
||||
addLog("🧩 Protocol singleton created: id=${System.identityHashCode(created)}")
|
||||
return created
|
||||
}
|
||||
}
|
||||
|
||||
val state: StateFlow<ProtocolState>
|
||||
get() = getOrCreateProtocol().state
|
||||
|
||||
val lastError: StateFlow<String?>
|
||||
get() = getOrCreateProtocol().lastError
|
||||
|
||||
fun disconnect(clearCredentials: Boolean) {
|
||||
protocol?.disconnect()
|
||||
if (clearCredentials) {
|
||||
protocol?.clearCredentials()
|
||||
}
|
||||
}
|
||||
|
||||
fun destroy() {
|
||||
synchronized(protocolInstanceLock) {
|
||||
protocol?.destroy()
|
||||
protocol = null
|
||||
}
|
||||
}
|
||||
|
||||
fun isAuthenticated(): Boolean = protocol?.isAuthenticated() ?: false
|
||||
|
||||
fun isConnected(): Boolean = protocol?.isConnected() ?: false
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user