Compare commits

...

29 Commits

Author SHA1 Message Date
c9fa12a690 Возврат Stream к новому серверному формату 2026-03-27 14:43:15 +05:00
ec541a2c0c Откат Stream к legacy-формату для совместимости с текущим сервером 2026-03-27 14:29:49 +05:00
454402938c Port Stream implementation from desktop/dev 2026-03-27 11:52:40 +05:00
83f6b49ba3 Release 1.3.2: bump version and update release notes 2026-03-27 03:15:10 +05:00
b663450db5 Работающие звонки 2026-03-27 03:12:04 +05:00
9cca071bd8 android: save all pending changes 2026-03-26 21:37:31 +05:00
0af4e6587e android: expand native e2ee diagnostics 2026-03-26 21:27:17 +05:00
31db795c56 Фикс оптимизации 2026-03-26 13:18:16 +05:00
9202204094 Фикс error parsing: 1 frame = 1 packet и safe handshake fallback 2026-03-26 13:17:33 +05:00
03282eb478 Стабилизация sync и логов: heartbeat антиспам + Connection Logs через rosettadev2 2026-03-26 13:17:33 +05:00
3fffbd0392 Промежуточный коммит со звонками 2026-03-26 00:31:35 +05:00
bc7efbfbd9 add license 2026-03-25 22:21:00 +05:00
eea650face WIP: стабилизация звонков и E2EE + инструменты сборки WebRTC 2026-03-25 22:20:24 +05:00
530047c5d0 Попытка обновления шифрования звонков и работа над UI 2026-03-25 01:47:12 +05:00
419101a4a9 Проработан UI звонков и частичная реализация 2026-03-23 18:25:25 +05:00
9778e3b196 Реализованы звонки в диалоге и полный permission flow Android 2026-03-23 10:56:52 +05:00
4664aa9482 Синхронизированы пакеты звонков с desktop/wss 2026-03-23 03:01:54 +05:00
ebb95905b5 PacketRead parity: корректные read-статусы и update release notes 1.3.0
Some checks failed
Android Kernel Build / build (push) Failing after 10m41s
2026-03-22 21:25:04 +05:00
f915333a44 Синхронизация 1.3.0: parity с desktop/server и стабилизация sync-цикла 2026-03-22 19:47:23 +05:00
69c0c377d1 Добавлено кэширование Android SDK и Gradle wrapper для ускорения сборки 2026-03-22 17:01:27 +05:00
30fbc41245 Добавил lint { checkReleaseBuilds = false; abortOnError = false } в build.gradle.kts
Some checks failed
Android Kernel Build / build (push) Failing after 6m30s
2026-03-22 16:19:33 +05:00
677a5f2ab2 Добавлен комментарий в MainActivity.kt
Some checks failed
Android Kernel Build / build (push) Failing after 18m34s
2026-03-22 15:40:38 +05:00
db55225d84 Релиз 1.2.9: fullscreen фото edge-to-edge без черных бордеров
Some checks failed
Android Kernel Build / build (push) Failing after 9m58s
2026-03-22 02:21:03 +05:00
7a188a2dbc Релиз 1.2.8: emoji iOS, fullscreen фото, сохранение в галерею и UI-фиксы
Some checks failed
Android Kernel Build / build (push) Has been cancelled
2026-03-22 02:00:21 +05:00
a3973b616e Merge branch 'master' into dev
# Conflicts:
#	app/src/main/java/com/rosetta/messenger/crypto/MessageCrypto.kt
2026-03-21 21:59:01 +05:00
3a595c02b3 убран коммент
Some checks failed
Android Kernel Build / build (push) Failing after 14m42s
2026-03-21 21:55:58 +05:00
8e743e710a v1.2.7: поиск сообщений, скелетон, анимация перехода и правка бейджа Requests 2026-03-21 21:53:30 +05:00
8fdbfb4e5f Большой пакет: поиск по сообщениям, клики по тэгам, темы обоев и UX-фиксы
Что вошло:\n- Добавлен полноценный Messages-tab в SearchScreen: поиск по тексту сообщений по всей базе, батчевый проход, параллельная дешифровка, кеш расшифровки, подсветка совпадений, сниппеты и быстрый переход в нужный диалог.\n- В Chats-tab добавлены алиасы для Saved Messages (saved/saved messages/избранное/сохраненные и др.), чтобы чат открывался по текстовому поиску даже без точного username/public key.\n- Для search-бэкенда расширен DAO: getAllMessagesPaged() для постраничного обхода сообщений аккаунта.\n- Исправлена логика клика по @тэгам в сообщениях:\n  - переход теперь ведет сразу в чат пользователя (а не в профиль);\n  - добавлен fallback-резолв username -> user через локальный диалог, кеш протокола и PacketSearch;\n  - добавлен DAO getDialogByUsername() (регистронезависимо и с игнором @).\n- Усилена обработка PacketSearch в ProtocolManager:\n  - добавлена очередь ожидания pendingSearchQueries;\n  - нормализация query (без @, lowercase);\n  - устойчивый матч ответов сервера (raw/normalized/by username);\n  - добавлены методы getCachedUserByUsername() и searchUsers().\n- Исправлен конфликт тачей между ClickableSpan и bubble-menu:\n  - в AppleEmojiText/AppleEmojiTextView добавлен callback начала тапа по span;\n  - улучшен hit-test по span (включая пограничные offset/layout fallback);\n  - suppress performClick на span-тапах;\n  - в MessageBubble добавлен тайм-guard, чтобы tap по span не открывал context menu.\n- Стабилизирован verified-бейдж в заголовке чата: агрегируется из переданного user, кеша протокола, локальной БД и серверного resolve; отображается консистентно в личных чатах.\n- Улучшен пустой экран Saved Messages при обоях: добавлена аккуратная подложка/бордер и выровненный текст, чтобы контент оставался читабельным на любом фоне.\n- Реализована автосвязка обоев между светлой/темной темами:\n  - добавлены pairGroup и mapToTheme/resolveWallpaperForTheme в ThemeWallpapers;\n  - добавлены отдельные prefs-ключи для light/dark wallpaper;\n  - MainActivity теперь автоматически подбирает и сохраняет обои под активную тему и сохраняет выбор по теме.\n- Биометрия: если на устройстве нет hardware fingerprint, экран включения биометрии не показывается (и доступность возвращает NotAvailable).\n- Небольшие UI-фиксы: поправлено позиционирование галочки в сайдбаре.\n- Техдолг: удалена неиспользуемая зависимость jsoup из build.gradle.
2026-03-21 21:53:30 +05:00
d90554aa9f commit
All checks were successful
Android Kernel Build / build (push) Successful in 27m13s
2026-03-20 23:29:33 +05:00
64 changed files with 9057 additions and 3626 deletions

View File

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

View File

@@ -23,8 +23,9 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// Rosetta versioning — bump here on each release // Rosetta versioning — bump here on each release
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
val rosettaVersionName = "1.2.7" val rosettaVersionName = "1.3.2"
val rosettaVersionCode = 29 // Increment on each release val rosettaVersionCode = 34 // Increment on each release
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
android { android {
namespace = "com.rosetta.messenger" namespace = "com.rosetta.messenger"
@@ -43,6 +44,19 @@ android {
// Optimize Lottie animations // Optimize Lottie animations
manifestPlaceholders["enableLottieOptimizations"] = "true" 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 { signingConfigs {
@@ -84,6 +98,10 @@ android {
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" } resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
jniLibs { useLegacyPackaging = true } jniLibs { useLegacyPackaging = true }
} }
lint {
checkReleaseBuilds = false
abortOnError = false
}
applicationVariants.all { applicationVariants.all {
outputs.all { outputs.all {
@@ -165,6 +183,14 @@ dependencies {
implementation("androidx.camera:camera-lifecycle:1.3.1") implementation("androidx.camera:camera-lifecycle:1.3.1")
implementation("androidx.camera:camera-view: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 // Baseline Profiles for startup performance
implementation("androidx.profileinstaller:profileinstaller:1.3.1") implementation("androidx.profileinstaller:profileinstaller:1.3.1")

3158
app/libs/LICENSE.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,11 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.USE_BIOMETRIC" /> <uses-permission android:name="android.permission.USE_BIOMETRIC" />
<uses-permission android:name="android.permission.CAMERA" /> <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.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.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" /> <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />

View 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
View 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
View 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 */

File diff suppressed because it is too large Load Diff

View 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_

View File

@@ -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_

View File

@@ -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_

View 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_

View 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_

View File

@@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -15,7 +15,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
/** /**
* 🚀 Telegram-style: Fixed Height Box + Fade In/Out * 🚀 Telegram-style: Fixed Height Box + Fade In/Out
@@ -109,20 +109,4 @@ fun AnimatedKeyboardTransition(
content() content()
} }
} }
}
/**
* Алиас для обратной совместимости
*/
@Composable
fun SimpleAnimatedKeyboardTransition(
coordinator: KeyboardTransitionCoordinator,
showEmojiPicker: Boolean,
content: @Composable () -> Unit
) {
AnimatedKeyboardTransition(
coordinator = coordinator,
showEmojiPicker = showEmojiPicker,
content = content
)
} }

View File

@@ -4,7 +4,7 @@ import android.os.Handler
import android.os.Looper import android.os.Looper
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
@@ -46,9 +46,6 @@ class KeyboardTransitionCoordinator {
var currentState by mutableStateOf(TransitionState.IDLE) var currentState by mutableStateOf(TransitionState.IDLE)
private set private set
var transitionProgress by mutableFloatStateOf(0f)
private set
// ============ Высоты ============ // ============ Высоты ============
var keyboardHeight by mutableStateOf(0.dp) var keyboardHeight by mutableStateOf(0.dp)
@@ -68,9 +65,6 @@ class KeyboardTransitionCoordinator {
// Используется для отключения imePadding пока Box виден // Используется для отключения imePadding пока Box виден
var isEmojiBoxVisible by mutableStateOf(false) var isEmojiBoxVisible by mutableStateOf(false)
// 🔥 Коллбэк для показа emoji (сохраняем для вызова после закрытия клавиатуры)
private var pendingShowEmojiCallback: (() -> Unit)? = null
// 📊 Для умного логирования (не каждый фрейм) // 📊 Для умного логирования (не каждый фрейм)
private var lastLogTime = 0L private var lastLogTime = 0L
private var lastLoggedHeight = -1f private var lastLoggedHeight = -1f
@@ -108,8 +102,6 @@ class KeyboardTransitionCoordinator {
currentState = TransitionState.IDLE currentState = TransitionState.IDLE
isTransitioning = false isTransitioning = false
// Очищаем pending callback - больше не нужен
pendingShowEmojiCallback = null
} }
// ============ Главный метод: Emoji → Keyboard ============ // ============ Главный метод: Emoji → Keyboard ============
@@ -119,11 +111,6 @@ class KeyboardTransitionCoordinator {
* плавно скрыть emoji. * плавно скрыть emoji.
*/ */
fun requestShowKeyboard(showKeyboard: () -> Unit, hideEmoji: () -> Unit) { fun requestShowKeyboard(showKeyboard: () -> Unit, hideEmoji: () -> Unit) {
// 🔥 Отменяем pending emoji callback если он есть (предотвращаем конфликт)
if (pendingShowEmojiCallback != null) {
pendingShowEmojiCallback = null
}
currentState = TransitionState.EMOJI_TO_KEYBOARD currentState = TransitionState.EMOJI_TO_KEYBOARD
isTransitioning = true isTransitioning = true
@@ -260,13 +247,6 @@ class KeyboardTransitionCoordinator {
// 🔥 УБРАН pending callback - теперь emoji показывается сразу в requestShowEmoji() // 🔥 УБРАН pending callback - теперь emoji показывается сразу в requestShowEmoji()
} }
/** Обновить высоту emoji панели. */
fun updateEmojiHeight(height: Dp) {
if (height > 0.dp && height != emojiHeight) {
emojiHeight = height
}
}
/** /**
* Синхронизировать высоты (emoji = keyboard). * Синхронизировать высоты (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'а. */ /** Composable для создания и запоминания coordinator'а. */

View File

@@ -1,9 +1,10 @@
package com.rosetta.messenger package com.rosetta.messenger
// commit
import android.Manifest import android.Manifest
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
@@ -32,6 +33,8 @@ import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.data.RecentSearchesManager import com.rosetta.messenger.data.RecentSearchesManager
import com.rosetta.messenger.data.resolveAccountDisplayName import com.rosetta.messenger.data.resolveAccountDisplayName
import com.rosetta.messenger.database.RosettaDatabase import com.rosetta.messenger.database.RosettaDatabase
import com.rosetta.messenger.network.CallActionResult
import com.rosetta.messenger.network.CallManager
import com.rosetta.messenger.network.ProtocolManager import com.rosetta.messenger.network.ProtocolManager
import com.rosetta.messenger.network.ProtocolState import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
@@ -46,6 +49,7 @@ import com.rosetta.messenger.ui.chats.GroupInfoScreen
import com.rosetta.messenger.ui.chats.GroupSetupScreen import com.rosetta.messenger.ui.chats.GroupSetupScreen
import com.rosetta.messenger.ui.chats.RequestsListScreen import com.rosetta.messenger.ui.chats.RequestsListScreen
import com.rosetta.messenger.ui.chats.SearchScreen import com.rosetta.messenger.ui.chats.SearchScreen
import com.rosetta.messenger.ui.chats.calls.CallOverlay
import com.rosetta.messenger.ui.components.OptimizedEmojiCache import com.rosetta.messenger.ui.components.OptimizedEmojiCache
import com.rosetta.messenger.ui.components.SwipeBackBackgroundEffect import com.rosetta.messenger.ui.components.SwipeBackBackgroundEffect
import com.rosetta.messenger.ui.components.SwipeBackContainer import com.rosetta.messenger.ui.components.SwipeBackContainer
@@ -116,6 +120,7 @@ class MainActivity : FragmentActivity() {
// 🔥 Инициализируем ProtocolManager для обработки онлайн статусов // 🔥 Инициализируем ProtocolManager для обработки онлайн статусов
ProtocolManager.initialize(this) ProtocolManager.initialize(this)
CallManager.initialize(this)
// 🔔 Инициализируем Firebase для push-уведомлений // 🔔 Инициализируем Firebase для push-уведомлений
initializeFirebase() initializeFirebase()
@@ -581,6 +586,177 @@ fun MainScreen(
// Load username AND name from AccountManager (persisted in DataStore) // Load username AND name from AccountManager (persisted in DataStore)
val context = LocalContext.current val context = LocalContext.current
val callScope = rememberCoroutineScope()
val callUiState by CallManager.state.collectAsState()
var pendingOutgoingCall by remember { mutableStateOf<SearchUser?>(null) }
var pendingIncomingAccept by remember { mutableStateOf(false) }
var callPermissionsRequestedOnce by remember { mutableStateOf(false) }
val mandatoryCallPermissions = remember {
listOf(Manifest.permission.RECORD_AUDIO)
}
val optionalCallPermissions = remember {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf(Manifest.permission.BLUETOOTH_CONNECT)
} else {
emptyList()
}
}
val permissionsToRequest = remember(mandatoryCallPermissions, optionalCallPermissions) {
mandatoryCallPermissions + optionalCallPermissions
}
val hasMandatoryCallPermissions: () -> Boolean =
remember(context, mandatoryCallPermissions) {
{
mandatoryCallPermissions.all { permission ->
ContextCompat.checkSelfPermission(context, permission) ==
PackageManager.PERMISSION_GRANTED
}
}
}
val hasOptionalCallPermissions: () -> Boolean =
remember(context, optionalCallPermissions) {
{
optionalCallPermissions.all { permission ->
ContextCompat.checkSelfPermission(context, permission) ==
PackageManager.PERMISSION_GRANTED
}
}
}
val showCallError: (CallActionResult) -> Unit = { result ->
val message =
when (result) {
CallActionResult.STARTED -> ""
CallActionResult.ALREADY_IN_CALL -> "Сначала заверши текущий звонок"
CallActionResult.NOT_AUTHENTICATED -> "Нет подключения к серверу"
CallActionResult.ACCOUNT_NOT_BOUND -> "Аккаунт еще не инициализирован"
CallActionResult.INVALID_TARGET -> "Не удалось определить пользователя для звонка"
CallActionResult.NOT_INCOMING -> "Входящий звонок не найден"
}
if (message.isNotBlank()) {
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
}
}
val resolveCallableUser: suspend (SearchUser) -> SearchUser? = resolve@{ user ->
val publicKey = user.publicKey.trim()
if (publicKey.isNotBlank()) {
return@resolve user.copy(publicKey = publicKey)
}
val usernameQuery = user.username.trim().trimStart('@')
if (usernameQuery.isBlank()) {
return@resolve null
}
ProtocolManager.getCachedUserByUsername(usernameQuery)?.let { cached ->
if (cached.publicKey.isNotBlank()) return@resolve cached
}
val results = ProtocolManager.searchUsers(usernameQuery)
results.firstOrNull {
it.publicKey.isNotBlank() &&
it.username.trim().trimStart('@')
.equals(usernameQuery, ignoreCase = true)
}?.let { return@resolve it }
return@resolve results.firstOrNull { it.publicKey.isNotBlank() }
}
val startOutgoingCallSafely: (SearchUser) -> Unit = { user ->
callScope.launch {
val resolved = resolveCallableUser(user)
if (resolved == null) {
showCallError(CallActionResult.INVALID_TARGET)
return@launch
}
val result = CallManager.startOutgoingCall(resolved)
if (result != CallActionResult.STARTED) {
showCallError(result)
}
}
}
val acceptIncomingCallSafely: () -> Unit = {
val result = CallManager.acceptIncomingCall()
if (result != CallActionResult.STARTED) {
showCallError(result)
}
}
val callPermissionLauncher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestMultiplePermissions()
) { grantedMap ->
callPermissionsRequestedOnce = true
val micGranted =
grantedMap[Manifest.permission.RECORD_AUDIO] == true ||
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
val bluetoothGranted =
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
true
} else {
grantedMap[Manifest.permission.BLUETOOTH_CONNECT] == true ||
ContextCompat.checkSelfPermission(
context,
Manifest.permission.BLUETOOTH_CONNECT
) == PackageManager.PERMISSION_GRANTED
}
if (!micGranted) {
Toast.makeText(
context,
"Для звонков нужен доступ к микрофону",
Toast.LENGTH_SHORT
).show()
} else {
pendingOutgoingCall?.let { startOutgoingCallSafely(it) }
if (pendingIncomingAccept) {
acceptIncomingCallSafely()
}
if (!bluetoothGranted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
Toast.makeText(
context,
"Bluetooth недоступен: гарнитура может не работать",
Toast.LENGTH_SHORT
).show()
}
}
pendingOutgoingCall = null
pendingIncomingAccept = false
}
val startCallWithPermission: (SearchUser) -> Unit = { user ->
val shouldRequestPermissions =
!hasMandatoryCallPermissions() ||
(!callPermissionsRequestedOnce && !hasOptionalCallPermissions())
if (!shouldRequestPermissions) {
startOutgoingCallSafely(user)
} else {
pendingOutgoingCall = user
callPermissionLauncher.launch(permissionsToRequest.toTypedArray())
}
}
val acceptCallWithPermission: () -> Unit = {
val shouldRequestPermissions =
!hasMandatoryCallPermissions() ||
(!callPermissionsRequestedOnce && !hasOptionalCallPermissions())
if (!shouldRequestPermissions) {
acceptIncomingCallSafely()
} else {
pendingIncomingAccept = true
callPermissionLauncher.launch(permissionsToRequest.toTypedArray())
}
}
LaunchedEffect(accountPublicKey) {
CallManager.bindAccount(accountPublicKey)
}
LaunchedEffect(accountPublicKey, reloadTrigger) { LaunchedEffect(accountPublicKey, reloadTrigger) {
if (accountPublicKey.isNotBlank()) { if (accountPublicKey.isNotBlank()) {
val accountManager = AccountManager(context) val accountManager = AccountManager(context)
@@ -1075,6 +1251,9 @@ fun MainScreen(
currentUserUsername = accountUsername, currentUserUsername = accountUsername,
totalUnreadFromOthers = totalUnreadFromOthers, totalUnreadFromOthers = totalUnreadFromOthers,
onBack = { popChatAndChildren() }, onBack = { popChatAndChildren() },
onCallClick = { callableUser ->
startCallWithPermission(callableUser)
},
onUserProfileClick = { user -> onUserProfileClick = { user ->
if (isCurrentAccountUser(user)) { if (isCurrentAccountUser(user)) {
// Свой профиль из чата открываем поверх текущего чата, // Свой профиль из чата открываем поверх текущего чата,
@@ -1200,6 +1379,11 @@ fun MainScreen(
}, },
onNavigateToCrashLogs = { onNavigateToCrashLogs = {
navStack = navStack.filterNot { it is Screen.Search } + Screen.CrashLogs navStack = navStack.filterNot { it is Screen.Search } + Screen.CrashLogs
},
onNavigateToConnectionLogs = {
navStack =
navStack.filterNot { it is Screen.Search } +
Screen.ConnectionLogs
} }
) )
} }
@@ -1369,5 +1553,16 @@ fun MainScreen(
} }
) )
} }
CallOverlay(
state = callUiState,
isDarkTheme = isDarkTheme,
avatarRepository = avatarRepository,
onAccept = { acceptCallWithPermission() },
onDecline = { CallManager.declineIncomingCall() },
onEnd = { CallManager.endCall() },
onToggleMute = { CallManager.toggleMute() },
onToggleSpeaker = { CallManager.toggleSpeaker() }
)
} }
} }

View File

@@ -1592,7 +1592,6 @@ object MessageCrypto {
// Reset bounds to default after first continuation // Reset bounds to default after first continuation
lowerBoundary = 0x80 lowerBoundary = 0x80
upperBoundary = 0xBF upperBoundary = 0xBF
// test
if (bytesSeen == bytesNeeded) { if (bytesSeen == bytesNeeded) {
// Sequence complete — emit code point // Sequence complete — emit code point
if (codePoint <= 0xFFFF) { if (codePoint <= 0xFFFF) {

View File

@@ -477,15 +477,18 @@ class MessageRepository private constructor(private val context: Context) {
scope.launch { scope.launch {
val startTime = System.currentTimeMillis() val startTime = System.currentTimeMillis()
try { try {
// Шифрование // Шифрование (пропускаем для пустого текста — напр. CALL-сообщения)
val encryptResult = MessageCrypto.encryptForSending(text.trim(), toPublicKey) val hasContent = text.trim().isNotEmpty()
val encryptedContent = encryptResult.ciphertext val encryptResult = if (hasContent) MessageCrypto.encryptForSending(text.trim(), toPublicKey) else null
val encryptedKey = encryptResult.encryptedKey val encryptedContent = encryptResult?.ciphertext ?: ""
val encryptedKey = encryptResult?.encryptedKey ?: ""
val aesChachaKey = val aesChachaKey =
CryptoManager.encryptWithPassword( if (encryptResult != null) {
String(encryptResult.plainKeyAndNonce, Charsets.ISO_8859_1), CryptoManager.encryptWithPassword(
privateKey String(encryptResult.plainKeyAndNonce, Charsets.ISO_8859_1),
) privateKey
)
} else ""
// 📝 LOG: Шифрование успешно // 📝 LOG: Шифрование успешно
MessageLogger.logEncryptionSuccess( MessageLogger.logEncryptionSuccess(
@@ -686,13 +689,6 @@ class MessageRepository private constructor(private val context: Context) {
return true return true
} }
// 🔥 ВТОРОЙ УРОВЕНЬ ЗАЩИТЫ: Проверка в БД (для сообщений сохранённых в предыдущих сессиях)
val isDuplicate = messageDao.messageExists(account, messageId)
MessageLogger.logDuplicateCheck(messageId, isDuplicate)
if (isDuplicate) {
return true
}
val dialogOpponentKey = val dialogOpponentKey =
when { when {
isGroupMessage -> packet.toPublicKey isGroupMessage -> packet.toPublicKey
@@ -701,6 +697,33 @@ class MessageRepository private constructor(private val context: Context) {
} }
val dialogKey = getDialogKey(dialogOpponentKey) 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 { try {
val groupKey = val groupKey =
if (isGroupMessage) { if (isGroupMessage) {
@@ -743,15 +766,26 @@ 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 = val plainText =
if (isGroupMessage) { if (isAttachmentOnly) {
""
} else if (isGroupMessage) {
CryptoManager.decryptWithPassword(packet.content, groupKey!!) CryptoManager.decryptWithPassword(packet.content, groupKey!!)
?: throw IllegalStateException("Failed to decrypt group payload") ?: throw IllegalStateException("Failed to decrypt group payload")
} else if (plainKeyAndNonce != null) { } else if (plainKeyAndNonce != null) {
MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce) MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce)
} else { } else {
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey) try {
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
} catch (e: Exception) {
// Fallback: если дешифровка не удалась (напр. CALL с encrypted empty content)
android.util.Log.w("MessageRepository", "Decryption fallback for ${messageId.take(8)}: ${e.message}")
""
}
} }
// 📝 LOG: Расшифровка успешна // 📝 LOG: Расшифровка успешна
@@ -851,11 +885,10 @@ class MessageRepository private constructor(private val context: Context) {
unreadCount = dialog?.unreadCount ?: 0 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)) { if (!isGroupDialogKey(dialogOpponentKey)) {
requestedUserInfoKeys.remove(dialogOpponentKey)
requestUserInfo(dialogOpponentKey) requestUserInfo(dialogOpponentKey)
} else { } else {
applyGroupDisplayNameToDialog(account, dialogOpponentKey) applyGroupDisplayNameToDialog(account, dialogOpponentKey)
@@ -955,20 +988,24 @@ class MessageRepository private constructor(private val context: Context) {
val readCount = val readCount =
messageCache[dialogKey]?.value?.count { messageCache[dialogKey]?.value?.count {
it.isFromMe && it.isFromMe && !it.isRead
!it.isRead &&
(it.deliveryStatus == DeliveryStatus.DELIVERED ||
it.deliveryStatus == DeliveryStatus.READ)
} ?: 0 } ?: 0
messageCache[dialogKey]?.let { flow -> messageCache[dialogKey]?.let { flow ->
flow.value = flow.value =
flow.value.map { msg -> flow.value.map { msg ->
if (msg.isFromMe && if (msg.isFromMe && !msg.isRead) {
!msg.isRead && msg.copy(
(msg.deliveryStatus == DeliveryStatus.DELIVERED || isRead = true,
msg.deliveryStatus == DeliveryStatus.READ) deliveryStatus =
) { if (
msg.copy(isRead = true, deliveryStatus = DeliveryStatus.READ) msg.deliveryStatus == DeliveryStatus.DELIVERED ||
msg.deliveryStatus == DeliveryStatus.READ
) {
DeliveryStatus.READ
} else {
msg.deliveryStatus
}
)
} else { } else {
msg msg
} }
@@ -1006,20 +1043,24 @@ class MessageRepository private constructor(private val context: Context) {
val readCount = val readCount =
messageCache[dialogKey]?.value?.count { messageCache[dialogKey]?.value?.count {
it.isFromMe && it.isFromMe && !it.isRead
!it.isRead &&
(it.deliveryStatus == DeliveryStatus.DELIVERED ||
it.deliveryStatus == DeliveryStatus.READ)
} ?: 0 } ?: 0
messageCache[dialogKey]?.let { flow -> messageCache[dialogKey]?.let { flow ->
flow.value = flow.value =
flow.value.map { msg -> flow.value.map { msg ->
if (msg.isFromMe && if (msg.isFromMe && !msg.isRead) {
!msg.isRead && msg.copy(
(msg.deliveryStatus == DeliveryStatus.DELIVERED || isRead = true,
msg.deliveryStatus == DeliveryStatus.READ) deliveryStatus =
) { if (
msg.copy(isRead = true, deliveryStatus = DeliveryStatus.READ) msg.deliveryStatus == DeliveryStatus.DELIVERED ||
msg.deliveryStatus == DeliveryStatus.READ
) {
DeliveryStatus.READ
} else {
msg.deliveryStatus
}
)
} else { } else {
msg msg
} }

View File

@@ -17,30 +17,10 @@ object ReleaseNotes {
val RELEASE_NOTICE = """ val RELEASE_NOTICE = """
Update v$VERSION_PLACEHOLDER Update v$VERSION_PLACEHOLDER
Поиск Защищенные звонки и диагностика E2EE
- Добавлена вкладка Messages в поиске: поиск по тексту сообщений по всем чатам - Обновлен custom WebRTC для Android и исправлена совместимость аудио E2EE с Desktop
- Реализованы быстрые сниппеты с подсветкой найденного текста и переходом в нужный чат - Улучшены diagnostics для шифрования звонков (детализация ENC/DEC в crash reports)
- Добавлены алиасы для Saved Messages в поиске (saved / saved messages / избранное и др.) - В Crash Reports добавлена кнопка копирования полного лога одним действием
Тэги и навигация
- Исправлены клики по @тэгам в сообщениях: теперь открывается чат пользователя
- Добавлен устойчивый резолв @username (локальный диалог -> кэш -> сервер)
- Устранен конфликт клика по тэгу с контекстным меню пузырька
Чаты и UI
- Улучшен пустой экран Saved Messages на обоях: добавлена подложка и повышена читаемость
- Стабилизировано отображение verified-бейджа в хедере личного чата
- Подправлено положение галочки в сайдбаре
- В тёмной теме цвет цифры в бейдже Requests возле бургер-меню приведен к цвету шапки
Темы и обои
- Добавлены пары обоев для светлой и темной темы
- Обои теперь автоматически синхронизируются при переключении темы
- Выбор обоев сохраняется отдельно для light/dark
Безопасность и система
- Если устройство не поддерживает отпечаток пальца, биометрия больше не предлагается
- Удалена неиспользуемая зависимость jsoup
""".trimIndent() """.trimIndent()
fun getNotice(version: String): String = fun getNotice(version: String): String =

View File

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

View File

@@ -7,9 +7,11 @@ enum class AttachmentType(val value: Int) {
IMAGE(0), // Изображение IMAGE(0), // Изображение
MESSAGES(1), // Reply (цитата сообщения) MESSAGES(1), // Reply (цитата сообщения)
FILE(2), // Файл FILE(2), // Файл
AVATAR(3); // Аватар пользователя AVATAR(3), // Аватар пользователя
CALL(4), // Событие звонка (пропущен/принят/завершен)
UNKNOWN(-1); // Неизвестный тип
companion object { companion object {
fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: IMAGE fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: UNKNOWN
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,132 @@
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 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 (sound == CallSound.RINGTONE) {
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) {}
}
}

View File

@@ -6,7 +6,8 @@ enum class HandshakeState(val value: Int) {
companion object { companion object {
fun fromValue(value: Int): HandshakeState { 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
} }
} }
} }

View File

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

View File

@@ -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
}
}

View File

@@ -0,0 +1,70 @@
package com.rosetta.messenger.network
enum class SignalType(val value: Int) {
CALL(0),
KEY_EXCHANGE(1),
ACTIVE_CALL(2),
END_CALL(3),
CREATE_ROOM(4),
END_CALL_BECAUSE_PEER_DISCONNECTED(5),
END_CALL_BECAUSE_BUSY(6);
companion object {
fun fromValue(value: Int): SignalType =
entries.firstOrNull { it.value == value }
?: 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 roomId: String = ""
override fun getPacketId(): Int = 0x1A
override fun receive(stream: Stream) {
signalType = SignalType.fromValue(stream.readInt8())
if (
signalType == SignalType.END_CALL_BECAUSE_BUSY ||
signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED
) {
return
}
src = stream.readString()
dst = stream.readString()
if (signalType == SignalType.KEY_EXCHANGE) {
sharedPublic = stream.readString()
}
if (signalType == SignalType.CREATE_ROOM) {
roomId = stream.readString()
}
}
override fun send(): Stream {
val stream = Stream()
stream.writeInt16(getPacketId())
stream.writeInt8(signalType.value)
if (
signalType == SignalType.END_CALL_BECAUSE_BUSY ||
signalType == SignalType.END_CALL_BECAUSE_PEER_DISCONNECTED
) {
return stream
}
stream.writeString(src)
stream.writeString(dst)
if (signalType == SignalType.KEY_EXCHANGE) {
stream.writeString(sharedPublic)
}
if (signalType == SignalType.CREATE_ROOM) {
stream.writeString(roomId)
}
return stream
}
}

View File

@@ -0,0 +1,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
}
}

View File

@@ -32,7 +32,10 @@ class Protocol(
private const val TAG = "RosettaProtocol" private const val TAG = "RosettaProtocol"
private const val RECONNECT_INTERVAL = 5000L // 5 seconds (как в Архиве) private const val RECONNECT_INTERVAL = 5000L // 5 seconds (как в Архиве)
private const val HANDSHAKE_TIMEOUT = 10000L // 10 seconds private const val HANDSHAKE_TIMEOUT = 10000L // 10 seconds
private const val MIN_PACKET_ID_BITS = 18 // Stream.readInt16() = 2 * readInt8() (9 bits each) private const val MIN_PACKET_ID_BITS = 16 // Stream.readInt16() reads exactly 16 bits
private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L
private const val HEARTBEAT_OK_LOG_THROTTLE_MS = 30_000L
} }
private fun log(message: String) { private fun log(message: String) {
@@ -112,6 +115,9 @@ class Protocol(
// Heartbeat // Heartbeat
private var heartbeatJob: Job? = null private var heartbeatJob: Job? = null
@Volatile private var heartbeatPeriodMs: Long = 0L
@Volatile private var lastHeartbeatOkLogAtMs: Long = 0L
@Volatile private var heartbeatOkSuppressedCount: Int = 0
// Supported packets // Supported packets
private val supportedPackets = mapOf( private val supportedPackets = mapOf(
@@ -127,6 +133,7 @@ class Protocol(
0x09 to { PacketDeviceNew() }, 0x09 to { PacketDeviceNew() },
0x0A to { PacketRequestUpdate() }, 0x0A to { PacketRequestUpdate() },
0x0B to { PacketTyping() }, 0x0B to { PacketTyping() },
0x10 to { PacketPushNotification() },
0x11 to { PacketCreateGroup() }, 0x11 to { PacketCreateGroup() },
0x12 to { PacketGroupInfo() }, 0x12 to { PacketGroupInfo() },
0x13 to { PacketGroupInviteInfo() }, 0x13 to { PacketGroupInviteInfo() },
@@ -136,7 +143,10 @@ class Protocol(
0x0F to { PacketRequestTransport() }, 0x0F to { PacketRequestTransport() },
0x17 to { PacketDeviceList() }, 0x17 to { PacketDeviceList() },
0x18 to { PacketDeviceResolve() }, 0x18 to { PacketDeviceResolve() },
0x19 to { PacketSync() } 0x19 to { PacketSync() },
0x1A to { PacketSignalPeer() },
0x1B to { PacketWebRTC() },
0x1C to { PacketIceServers() }
) )
init { init {
@@ -175,11 +185,24 @@ class Protocol(
* Как в Архиве - отправляем text "heartbeat" СРАЗУ и потом с интервалом * Как в Архиве - отправляем text "heartbeat" СРАЗУ и потом с интервалом
*/ */
private fun startHeartbeat(intervalSeconds: Int) { private fun startHeartbeat(intervalSeconds: Int) {
val normalizedServerIntervalSec =
if (intervalSeconds > 0) intervalSeconds else DEFAULT_HEARTBEAT_INTERVAL_SECONDS
// Отправляем чаще - каждые 1/3 интервала, но с нижним лимитом чтобы исключить tight-loop.
val intervalMs =
((normalizedServerIntervalSec * 1000L) / 3).coerceAtLeast(MIN_HEARTBEAT_SEND_INTERVAL_MS)
if (heartbeatJob?.isActive == true && heartbeatPeriodMs == intervalMs) {
return
}
heartbeatJob?.cancel() heartbeatJob?.cancel()
heartbeatPeriodMs = intervalMs
// Отправляем чаще - каждые 1/3 интервала (чтобы не терять соединение) lastHeartbeatOkLogAtMs = 0L
val intervalMs = (intervalSeconds * 1000L) / 3 heartbeatOkSuppressedCount = 0
log("💓 HEARTBEAT START: server=${intervalSeconds}s, sending=${intervalMs/1000}s, state=${_state.value}") log(
"💓 HEARTBEAT START: server=${intervalSeconds}s(normalized=${normalizedServerIntervalSec}s), " +
"sending=${intervalMs / 1000}s, state=${_state.value}"
)
heartbeatJob = scope.launch { heartbeatJob = scope.launch {
// ⚡ СРАЗУ отправляем первый heartbeat (как в Архиве) // ⚡ СРАЗУ отправляем первый heartbeat (как в Архиве)
@@ -206,7 +229,17 @@ class Protocol(
) { ) {
val sent = webSocket?.send("heartbeat") ?: false val sent = webSocket?.send("heartbeat") ?: false
if (sent) { if (sent) {
log("💓 Heartbeat OK (socket=$socketAlive, state=$currentState)") val now = System.currentTimeMillis()
if (now - lastHeartbeatOkLogAtMs >= HEARTBEAT_OK_LOG_THROTTLE_MS) {
val suppressed = heartbeatOkSuppressedCount
heartbeatOkSuppressedCount = 0
lastHeartbeatOkLogAtMs = now
val suffix =
if (suppressed > 0) ", +$suppressed suppressed" else ""
log("💓 Heartbeat OK (socket=$socketAlive, state=$currentState$suffix)")
} else {
heartbeatOkSuppressedCount++
}
} else { } else {
log("💔 HEARTBEAT FAILED: socket=$socketAlive, state=$currentState, manuallyClosed=$isManuallyClosed") log("💔 HEARTBEAT FAILED: socket=$socketAlive, state=$currentState, manuallyClosed=$isManuallyClosed")
// Триггерим reconnect если heartbeat не прошёл // Триггерим reconnect если heartbeat не прошёл
@@ -502,52 +535,40 @@ class Protocol(
log("📥 Received ${data.size} bytes: $hexDump${if (data.size > 50) "..." else ""}") log("📥 Received ${data.size} bytes: $hexDump${if (data.size > 50) "..." else ""}")
val stream = Stream(data) val stream = Stream(data)
var parsedPackets = 0 if (stream.getRemainingBits() < MIN_PACKET_ID_BITS) {
log("⚠️ Frame too short to contain packet ID (${stream.getRemainingBits()} bits)")
return
}
// Desktop/server parity: one WebSocket frame contains one packet.
val packetId = stream.readInt16()
log("📥 Packet ID: $packetId")
while (stream.getRemainingBits() >= MIN_PACKET_ID_BITS) { val packetFactory = supportedPackets[packetId]
val packetStartBits = stream.getReadPointerBits() if (packetFactory == null) {
val packetId = stream.readInt16() log("⚠️ Unknown packet ID: $packetId")
return
log("📥 Packet ID: $packetId")
val packetFactory = supportedPackets[packetId]
if (packetFactory == null) {
log("⚠️ Unknown packet ID: $packetId, stopping frame parse")
break
}
val packet = packetFactory()
try {
packet.receive(stream)
} catch (e: Exception) {
log("❌ Error parsing packet $packetId: ${e.message}")
e.printStackTrace()
break
}
// Notify waiters
val waitersCount = packetWaiters[packetId]?.size ?: 0
log("📥 Notifying $waitersCount waiter(s) for packet $packetId")
packetWaiters[packetId]?.forEach { callback ->
try {
callback(packet)
} catch (e: Exception) {
log("❌ Error in packet handler: ${e.message}")
e.printStackTrace()
}
}
parsedPackets++
val consumedBits = stream.getReadPointerBits() - packetStartBits
if (consumedBits <= 0) {
log("⚠️ Packet parser made no progress for packet $packetId, stopping frame parse")
break
}
} }
if (parsedPackets > 1) { val packet = packetFactory()
log("📦 Parsed $parsedPackets packets from single WebSocket frame") try {
packet.receive(stream)
} catch (e: Exception) {
log("❌ Error parsing packet $packetId: ${e.message}")
e.printStackTrace()
return
}
// Notify waiters
val waitersCount = packetWaiters[packetId]?.size ?: 0
log("📥 Notifying $waitersCount waiter(s) for packet $packetId")
packetWaiters[packetId]?.forEach { callback ->
try {
callback(packet)
} catch (e: Exception) {
log("❌ Error in packet handler: ${e.message}")
e.printStackTrace()
}
} }
} catch (e: Exception) { } catch (e: Exception) {
log("❌ Error parsing packet: ${e.message}") log("❌ Error parsing packet: ${e.message}")
@@ -569,6 +590,7 @@ class Protocol(
handshakeComplete = false handshakeComplete = false
handshakeJob?.cancel() handshakeJob?.cancel()
heartbeatJob?.cancel() heartbeatJob?.cancel()
heartbeatPeriodMs = 0L
// Автоматический reconnect с защитой от бесконечных попыток // Автоматический reconnect с защитой от бесконечных попыток
if (!isManuallyClosed) { if (!isManuallyClosed) {
@@ -624,6 +646,7 @@ class Protocol(
reconnectJob = null reconnectJob = null
handshakeJob?.cancel() handshakeJob?.cancel()
heartbeatJob?.cancel() heartbeatJob?.cancel()
heartbeatPeriodMs = 0L
webSocket?.close(1000, "User disconnected") webSocket?.close(1000, "User disconnected")
webSocket = null webSocket = null
_state.value = ProtocolState.DISCONNECTED _state.value = ProtocolState.DISCONNECTED

View File

@@ -6,6 +6,7 @@ import com.rosetta.messenger.data.AccountManager
import com.rosetta.messenger.data.GroupRepository import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.data.MessageRepository import com.rosetta.messenger.data.MessageRepository
import com.rosetta.messenger.data.isPlaceholderAccountName import com.rosetta.messenger.data.isPlaceholderAccountName
import com.rosetta.messenger.utils.MessageLogger
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@@ -27,9 +28,14 @@ import kotlin.coroutines.resume
object ProtocolManager { object ProtocolManager {
private const val TAG = "ProtocolManager" private const val TAG = "ProtocolManager"
private const val MANUAL_SYNC_BACKTRACK_MS = 120_000L private const val MANUAL_SYNC_BACKTRACK_MS = 120_000L
private const val SYNC_REQUEST_TIMEOUT_MS = 12_000L
private const val MAX_DEBUG_LOGS = 600 private const val MAX_DEBUG_LOGS = 600
private const val DEBUG_LOG_FLUSH_DELAY_MS = 60L private const val DEBUG_LOG_FLUSH_DELAY_MS = 60L
private const val HEARTBEAT_OK_LOG_MIN_INTERVAL_MS = 5_000L
private const val TYPING_INDICATOR_TIMEOUT_MS = 3_000L private const val TYPING_INDICATOR_TIMEOUT_MS = 3_000L
private const val PACKET_SIGNAL_PEER = 0x1A
private const val PACKET_WEB_RTC = 0x1B
private const val PACKET_ICE_SERVERS = 0x1C
// Desktop parity: use the same primary WebSocket endpoint as desktop client. // Desktop parity: use the same primary WebSocket endpoint as desktop client.
private const val SERVER_ADDRESS = "wss://wss.rosetta.im" private const val SERVER_ADDRESS = "wss://wss.rosetta.im"
@@ -45,6 +51,7 @@ object ProtocolManager {
@Volatile private var packetHandlersRegistered = false @Volatile private var packetHandlersRegistered = false
@Volatile private var stateMonitoringStarted = false @Volatile private var stateMonitoringStarted = false
@Volatile private var syncRequestInFlight = false @Volatile private var syncRequestInFlight = false
@Volatile private var syncRequestTimeoutJob: Job? = null
// Guard: prevent duplicate FCM token subscribe within a single session // Guard: prevent duplicate FCM token subscribe within a single session
@Volatile @Volatile
@@ -56,6 +63,8 @@ object ProtocolManager {
private val debugLogsLock = Any() private val debugLogsLock = Any()
@Volatile private var debugFlushJob: Job? = null @Volatile private var debugFlushJob: Job? = null
private val debugFlushPending = AtomicBoolean(false) private val debugFlushPending = AtomicBoolean(false)
@Volatile private var lastHeartbeatOkLogAtMs: Long = 0L
@Volatile private var suppressedHeartbeatOkLogs: Int = 0
// Typing status // Typing status
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet()) private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
@@ -87,8 +96,8 @@ object ProtocolManager {
private fun normalizeSearchQuery(value: String): String = private fun normalizeSearchQuery(value: String): String =
value.trim().removePrefix("@").lowercase(Locale.ROOT) value.trim().removePrefix("@").lowercase(Locale.ROOT)
// UI logs are enabled by default; updates are throttled and bounded by MAX_DEBUG_LOGS. // Keep heavy protocol/message UI logs disabled by default.
private var uiLogsEnabled = true private var uiLogsEnabled = false
private var lastProtocolState: ProtocolState? = null private var lastProtocolState: ProtocolState? = null
@Volatile private var syncBatchInProgress = false @Volatile private var syncBatchInProgress = false
private val _syncInProgress = MutableStateFlow(false) private val _syncInProgress = MutableStateFlow(false)
@@ -126,9 +135,23 @@ object ProtocolManager {
fun addLog(message: String) { fun addLog(message: String) {
if (!uiLogsEnabled) return if (!uiLogsEnabled) return
var normalizedMessage = message
val isHeartbeatOk = normalizedMessage.startsWith("💓 Heartbeat OK")
if (isHeartbeatOk) {
val now = System.currentTimeMillis()
if (now - lastHeartbeatOkLogAtMs < HEARTBEAT_OK_LOG_MIN_INTERVAL_MS) {
suppressedHeartbeatOkLogs++
return
}
if (suppressedHeartbeatOkLogs > 0) {
normalizedMessage = "$normalizedMessage (+${suppressedHeartbeatOkLogs} skipped)"
suppressedHeartbeatOkLogs = 0
}
lastHeartbeatOkLogAtMs = now
}
val timestamp = val timestamp =
java.text.SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date()) java.text.SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
val line = "[$timestamp] $message" val line = "[$timestamp] $normalizedMessage"
synchronized(debugLogsLock) { synchronized(debugLogsLock) {
if (debugLogsBuffer.size >= MAX_DEBUG_LOGS) { if (debugLogsBuffer.size >= MAX_DEBUG_LOGS) {
debugLogsBuffer.removeFirst() debugLogsBuffer.removeFirst()
@@ -140,6 +163,7 @@ object ProtocolManager {
fun enableUILogs(enabled: Boolean) { fun enableUILogs(enabled: Boolean) {
uiLogsEnabled = enabled uiLogsEnabled = enabled
MessageLogger.setEnabled(enabled)
if (enabled) { if (enabled) {
val snapshot = synchronized(debugLogsLock) { debugLogsBuffer.toList() } val snapshot = synchronized(debugLogsLock) { debugLogsBuffer.toList() }
_debugLogs.value = snapshot _debugLogs.value = snapshot
@@ -152,6 +176,8 @@ object ProtocolManager {
synchronized(debugLogsLock) { synchronized(debugLogsLock) {
debugLogsBuffer.clear() debugLogsBuffer.clear()
} }
suppressedHeartbeatOkLogs = 0
lastHeartbeatOkLogAtMs = 0L
_debugLogs.value = emptyList() _debugLogs.value = emptyList()
} }
@@ -210,6 +236,7 @@ object ProtocolManager {
} }
if (newState != ProtocolState.AUTHENTICATED && newState != ProtocolState.HANDSHAKING) { if (newState != ProtocolState.AUTHENTICATED && newState != ProtocolState.HANDSHAKING) {
syncRequestInFlight = false syncRequestInFlight = false
clearSyncRequestTimeout()
setSyncInProgress(false) setSyncInProgress(false)
// Connection/session dropped: force re-subscribe on next AUTHENTICATED. // Connection/session dropped: force re-subscribe on next AUTHENTICATED.
lastSubscribedToken = null lastSubscribedToken = null
@@ -678,6 +705,7 @@ object ProtocolManager {
private fun finishSyncCycle(reason: String) { private fun finishSyncCycle(reason: String) {
syncRequestInFlight = false syncRequestInFlight = false
clearSyncRequestTimeout()
inboundProcessingFailures.set(0) inboundProcessingFailures.set(0)
addLog(reason) addLog(reason)
setSyncInProgress(false) setSyncInProgress(false)
@@ -734,6 +762,7 @@ object ProtocolManager {
val repository = messageRepository val repository = messageRepository
if (repository == null || !repository.isInitialized()) { if (repository == null || !repository.isInitialized()) {
syncRequestInFlight = false syncRequestInFlight = false
clearSyncRequestTimeout()
requireResyncAfterAccountInit("⏳ Sync postponed until account is initialized") requireResyncAfterAccountInit("⏳ Sync postponed until account is initialized")
return@launch return@launch
} }
@@ -744,6 +773,7 @@ object ProtocolManager {
repositoryAccount != protocolAccount repositoryAccount != protocolAccount
) { ) {
syncRequestInFlight = false syncRequestInFlight = false
clearSyncRequestTimeout()
requireResyncAfterAccountInit( requireResyncAfterAccountInit(
"⏳ Sync postponed: repository bound to another account" "⏳ Sync postponed: repository bound to another account"
) )
@@ -757,6 +787,7 @@ object ProtocolManager {
private fun sendSynchronize(timestamp: Long) { private fun sendSynchronize(timestamp: Long) {
syncRequestInFlight = true syncRequestInFlight = true
scheduleSyncRequestTimeout(timestamp)
val packet = PacketSync().apply { val packet = PacketSync().apply {
status = SyncStatus.NOT_NEEDED status = SyncStatus.NOT_NEEDED
this.timestamp = timestamp this.timestamp = timestamp
@@ -777,6 +808,7 @@ object ProtocolManager {
*/ */
private fun handleSyncPacket(packet: PacketSync) { private fun handleSyncPacket(packet: PacketSync) {
syncRequestInFlight = false syncRequestInFlight = false
clearSyncRequestTimeout()
when (packet.status) { when (packet.status) {
SyncStatus.BATCH_START -> { SyncStatus.BATCH_START -> {
addLog("🔄 SYNC BATCH_START — incoming message batch") addLog("🔄 SYNC BATCH_START — incoming message batch")
@@ -826,6 +858,24 @@ object ProtocolManager {
} }
} }
private fun scheduleSyncRequestTimeout(cursor: Long) {
syncRequestTimeoutJob?.cancel()
syncRequestTimeoutJob = scope.launch {
delay(SYNC_REQUEST_TIMEOUT_MS)
if (!syncRequestInFlight || !isAuthenticated()) return@launch
syncRequestInFlight = false
addLog(
"⏱️ SYNC response timeout for cursor=$cursor, retrying request"
)
requestSynchronize()
}
}
private fun clearSyncRequestTimeout() {
syncRequestTimeoutJob?.cancel()
syncRequestTimeoutJob = null
}
/** /**
* Retry messages stuck in WAITING status on reconnect. * Retry messages stuck in WAITING status on reconnect.
* Desktop has in-memory _packetQueue that flushes on handshake, but desktop apps are * Desktop has in-memory _packetQueue that flushes on handshake, but desktop apps are
@@ -1255,6 +1305,94 @@ object ProtocolManager {
fun sendPacket(packet: Packet) { fun sendPacket(packet: Packet) {
send(packet) send(packet)
} }
/**
* Send call signaling packet (0x1A).
*/
fun sendCallSignal(
signalType: SignalType,
src: String = "",
dst: String = "",
sharedPublic: String = "",
roomId: String = ""
) {
send(
PacketSignalPeer().apply {
this.signalType = signalType
this.src = src
this.dst = dst
this.sharedPublic = sharedPublic
this.roomId = roomId
}
)
}
/**
* Send WebRTC signaling packet (0x1B).
*/
fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) {
send(
PacketWebRTC().apply {
this.signalType = signalType
this.sdpOrCandidate = sdpOrCandidate
}
)
}
/**
* Request ICE servers from server (0x1C).
*/
fun requestIceServers() {
send(PacketIceServers())
}
/**
* Typed subscribe for call signaling packets (0x1A).
* Returns wrapper callback for subsequent unwait.
*/
fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit {
val wrapper: (Packet) -> Unit = { packet ->
(packet as? PacketSignalPeer)?.let(callback)
}
waitPacket(PACKET_SIGNAL_PEER, wrapper)
return wrapper
}
fun unwaitCallSignal(callback: (Packet) -> Unit) {
unwaitPacket(PACKET_SIGNAL_PEER, callback)
}
/**
* Typed subscribe for WebRTC packets (0x1B).
* Returns wrapper callback for subsequent unwait.
*/
fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit {
val wrapper: (Packet) -> Unit = { packet ->
(packet as? PacketWebRTC)?.let(callback)
}
waitPacket(PACKET_WEB_RTC, wrapper)
return wrapper
}
fun unwaitWebRtcSignal(callback: (Packet) -> Unit) {
unwaitPacket(PACKET_WEB_RTC, callback)
}
/**
* Typed subscribe for ICE servers packet (0x1C).
* Returns wrapper callback for subsequent unwait.
*/
fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit {
val wrapper: (Packet) -> Unit = { packet ->
(packet as? PacketIceServers)?.let(callback)
}
waitPacket(PACKET_ICE_SERVERS, wrapper)
return wrapper
}
fun unwaitIceServers(callback: (Packet) -> Unit) {
unwaitPacket(PACKET_ICE_SERVERS, callback)
}
/** /**
* Register packet handler * Register packet handler
@@ -1325,6 +1463,7 @@ object ProtocolManager {
_devices.value = emptyList() _devices.value = emptyList()
_pendingDeviceVerification.value = null _pendingDeviceVerification.value = null
syncRequestInFlight = false syncRequestInFlight = false
clearSyncRequestTimeout()
setSyncInProgress(false) setSyncInProgress(false)
resyncRequiredAfterAccountInit = false resyncRequiredAfterAccountInit = false
lastSubscribedToken = null // reset so token is re-sent on next connect lastSubscribedToken = null // reset so token is re-sent on next connect
@@ -1341,6 +1480,7 @@ object ProtocolManager {
_devices.value = emptyList() _devices.value = emptyList()
_pendingDeviceVerification.value = null _pendingDeviceVerification.value = null
syncRequestInFlight = false syncRequestInFlight = false
clearSyncRequestTimeout()
setSyncInProgress(false) setSyncInProgress(false)
resyncRequiredAfterAccountInit = false resyncRequiredAfterAccountInit = false
scope.cancel() scope.cancel()

View File

@@ -1,163 +1,332 @@
package com.rosetta.messenger.network package com.rosetta.messenger.network
/** /**
* Binary stream for protocol packets * Binary stream for protocol packets.
* Matches the React Native implementation exactly * Ported from desktop/dev stream.ts implementation.
*/ */
class Stream(stream: ByteArray = ByteArray(0)) { class Stream(stream: ByteArray = ByteArray(0)) {
private var _stream = mutableListOf<Int>() private var stream: ByteArray
private var _readPointer = 0 private var readPointer = 0 // bits
private var _writePointer = 0 private var writePointer = 0 // bits
init { init {
_stream = stream.map { it.toInt() and 0xFF }.toMutableList() if (stream.isEmpty()) {
this.stream = ByteArray(0)
} else {
this.stream = stream.copyOf()
this.writePointer = this.stream.size shl 3
}
} }
fun getStream(): ByteArray { fun getStream(): ByteArray {
return _stream.map { it.toByte() }.toByteArray() return stream.copyOf(length())
} }
fun getReadPointerBits(): Int = _readPointer fun setStream(stream: ByteArray = ByteArray(0)) {
if (stream.isEmpty()) {
fun getTotalBits(): Int = _stream.size * 8 this.stream = ByteArray(0)
this.readPointer = 0
fun getRemainingBits(): Int = getTotalBits() - _readPointer this.writePointer = 0
return
fun hasRemainingBits(): Boolean = _readPointer < getTotalBits()
fun setStream(stream: ByteArray) {
_stream = stream.map { it.toInt() and 0xFF }.toMutableList()
_readPointer = 0
}
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++
} }
this.stream = stream.copyOf()
this.readPointer = 0
this.writePointer = this.stream.size shl 3
} }
fun readInt8(): Int { fun getBuffer(): ByteArray = getStream()
var value = 0
val negationBit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1 fun isEmpty(): Boolean = writePointer == 0
_readPointer++
fun length(): Int = (writePointer + 7) shr 3
for (i in 0 until 8) {
val bit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1 fun getReadPointerBits(): Int = readPointer
value = value or (bit shl (7 - i))
_readPointer++ fun getTotalBits(): Int = writePointer
}
fun getRemainingBits(): Int = writePointer - readPointer
return if (negationBit == 1) -value else value
} fun hasRemainingBits(): Boolean = readPointer < writePointer
fun writeBit(value: Int) { fun writeBit(value: Int) {
val bit = value and 1 writeBits((value and 1).toULong(), 1)
ensureCapacity(_writePointer shr 3)
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (bit shl (7 - (_writePointer and 7)))
_writePointer++
} }
fun readBit(): Int { fun readBit(): Int = readBits(1).toInt()
val bit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
_readPointer++
return bit
}
fun writeBoolean(value: Boolean) { fun writeBoolean(value: Boolean) {
writeBit(if (value) 1 else 0) writeBit(if (value) 1 else 0)
} }
fun readBoolean(): Boolean { fun readBoolean(): Boolean = readBit() == 1
return readBit() == 1
fun writeByte(value: Int) {
writeUInt8(value and 0xFF)
} }
fun readByte(): Int {
val value = readUInt8()
return if (value >= 0x80) value - 0x100 else value
}
fun writeUInt8(value: Int) {
val v = value and 0xFF
if ((writePointer and 7) == 0) {
reserveBits(8)
stream[writePointer shr 3] = v.toByte()
writePointer += 8
return
}
writeBits(v.toULong(), 8)
}
fun readUInt8(): Int {
if (remainingBits() < 8L) {
throw IllegalStateException("Not enough bits to read UInt8")
}
if ((readPointer and 7) == 0) {
val value = stream[readPointer shr 3].toInt() and 0xFF
readPointer += 8
return value
}
return readBits(8).toInt()
}
fun writeInt8(value: Int) {
writeUInt8(value)
}
fun readInt8(): Int {
val value = readUInt8()
return if (value >= 0x80) value - 0x100 else value
}
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) { fun writeInt16(value: Int) {
writeInt8(value shr 8) writeUInt16(value)
writeInt8(value and 0xFF)
} }
fun readInt16(): Int { fun readInt16(): Int {
val high = readInt8() shl 8 val value = readUInt16()
return high or readInt8() return if (value >= 0x8000) value - 0x10000 else value
} }
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()
val b2 = readUInt8().toLong()
val b3 = readUInt8().toLong()
val b4 = readUInt8().toLong()
return ((b1 shl 24) or (b2 shl 16) or (b3 shl 8) or b4) and 0xFFFF_FFFFL
}
fun writeInt32(value: Int) { fun writeInt32(value: Int) {
writeInt16(value shr 16) writeUInt32(value.toLong() and 0xFFFF_FFFFL)
writeInt16(value and 0xFFFF)
} }
fun readInt32(): Int { fun readInt32(): Int = readUInt32().toInt()
val high = readInt16() shl 16
return high or readInt16() fun writeUInt64(value: ULong) {
writeUInt8(((value shr 56) and 0xFFu).toInt())
writeUInt8(((value shr 48) and 0xFFu).toInt())
writeUInt8(((value shr 40) and 0xFFu).toInt())
writeUInt8(((value shr 32) and 0xFFu).toInt())
writeUInt8(((value shr 24) and 0xFFu).toInt())
writeUInt8(((value shr 16) and 0xFFu).toInt())
writeUInt8(((value shr 8) and 0xFFu).toInt())
writeUInt8((value and 0xFFu).toInt())
} }
fun writeInt64(value: Long) { fun readUInt64(): ULong {
val high = (value shr 32).toInt() val high = readUInt32().toULong()
val low = (value and 0xFFFFFFFF).toInt() val low = readUInt32().toULong()
writeInt32(high)
writeInt32(low)
}
fun readInt64(): Long {
val high = readInt32().toLong()
val low = (readInt32().toLong() and 0xFFFFFFFFL)
return (high shl 32) or low return (high shl 32) or low
} }
fun writeString(value: String) { fun writeInt64(value: Long) {
writeInt32(value.length) writeUInt64(value.toULong())
for (char in value) { }
writeInt16(char.code)
fun readInt64(): Long = readUInt64().toLong()
fun writeFloat32(value: Float) {
val bits = value.toRawBits().toLong() and 0xFFFF_FFFFL
writeUInt32(bits)
}
fun readFloat32(): Float {
val bits = readUInt32().toInt()
return Float.fromBits(bits)
}
fun writeString(value: String?) {
val str = value ?: ""
writeUInt32(str.length.toLong())
if (str.isEmpty()) return
reserveBits(str.length.toLong() * 16L)
for (i in str.indices) {
writeUInt16(str[i].code and 0xFFFF)
} }
} }
fun readString(): String { fun readString(): String {
val length = readInt32() val len = readUInt32()
// Desktop parity + safety: don't trust malformed string length. if (len > Int.MAX_VALUE.toLong()) {
val bytesAvailable = _stream.size - (_readPointer shr 3) throw IllegalStateException("String length too large: $len")
if (length < 0 || (length.toLong() * 2L) > bytesAvailable.toLong()) {
android.util.Log.w(
"RosettaStream",
"readString invalid length=$length, bytesAvailable=$bytesAvailable, readPointer=$_readPointer"
)
return ""
} }
val sb = StringBuilder()
for (i in 0 until length) { val requiredBits = len * 16L
sb.append(readInt16().toChar()) if (requiredBits > remainingBits()) {
throw IllegalStateException("Not enough bits to read string")
} }
return sb.toString()
val chars = CharArray(len.toInt())
for (i in chars.indices) {
chars[i] = readUInt16().toChar()
}
return String(chars)
} }
fun writeBytes(value: ByteArray) { fun writeBytes(value: ByteArray?) {
writeInt32(value.size) val bytes = value ?: ByteArray(0)
for (byte in value) { writeUInt32(bytes.size.toLong())
writeInt8(byte.toInt()) if (bytes.isEmpty()) return
reserveBits(bytes.size.toLong() * 8L)
if ((writePointer and 7) == 0) {
val byteIndex = writePointer shr 3
ensureCapacity(byteIndex + bytes.size - 1)
System.arraycopy(bytes, 0, stream, byteIndex, bytes.size)
writePointer += bytes.size shl 3
return
}
for (b in bytes) {
writeUInt8(b.toInt() and 0xFF)
} }
} }
fun readBytes(): ByteArray { fun readBytes(): ByteArray {
val length = readInt32() val len = readUInt32()
val bytes = ByteArray(length) if (len == 0L) return ByteArray(0)
for (i in 0 until length) { if (len > Int.MAX_VALUE.toLong()) return ByteArray(0)
bytes[i] = readInt8().toByte()
val requiredBits = len * 8L
if (requiredBits > remainingBits()) {
return ByteArray(0)
} }
return bytes
val out = ByteArray(len.toInt())
if ((readPointer and 7) == 0) {
val byteIndex = readPointer shr 3
System.arraycopy(stream, byteIndex, out, 0, out.size)
readPointer += out.size shl 3
return out
}
for (i in out.indices) {
out[i] = readUInt8().toByte()
}
return out
} }
private fun ensureCapacity(index: Int) { private fun remainingBits(): Long = (writePointer - readPointer).toLong()
while (_stream.size <= index) {
_stream.add(0) private fun writeBits(value: ULong, bits: Int) {
if (bits <= 0) return
reserveBits(bits.toLong())
for (i in bits - 1 downTo 0) {
val bit = ((value shr i) and 1u).toInt()
val byteIndex = writePointer shr 3
val shift = 7 - (writePointer and 7)
if (bit == 1) {
stream[byteIndex] = (stream[byteIndex].toInt() or (1 shl shift)).toByte()
} else {
stream[byteIndex] = (stream[byteIndex].toInt() and (1 shl shift).inv()).toByte()
}
writePointer++
} }
} }
private fun readBits(bits: Int): ULong {
if (bits <= 0) return 0u
if (remainingBits() < bits.toLong()) {
throw IllegalStateException("Not enough bits to read")
}
var value = 0uL
repeat(bits) {
val bit = (stream[readPointer shr 3].toInt() ushr (7 - (readPointer and 7))) and 1
value = (value shl 1) or bit.toULong()
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(index: Int) {
val requiredSize = index + 1
if (requiredSize <= stream.size) return
var newSize = if (stream.isEmpty()) 32 else stream.size
while (newSize < requiredSize) {
if (newSize > (Int.MAX_VALUE shr 1)) {
newSize = requiredSize
break
}
newSize = newSize shl 1
}
val next = ByteArray(newSize)
System.arraycopy(stream, 0, next, 0, stream.size)
stream = next
}
} }

View File

@@ -0,0 +1,107 @@
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 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 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 nativeCreateDecryptor(key: ByteArray): Long
@JvmStatic private external fun nativeReleaseDecryptor(ptr: Long)
@JvmStatic private external fun nativeInstallCrashHandler(path: String)
@JvmStatic external fun nativeOpenDiagFile(path: String)
@JvmStatic external fun nativeCloseDiagFile()
}

View File

@@ -1,274 +0,0 @@
package com.rosetta.messenger.providers
import android.content.Context
import androidx.compose.runtime.*
import com.rosetta.messenger.crypto.CryptoManager
import com.rosetta.messenger.database.DatabaseService
import com.rosetta.messenger.database.DecryptedAccountData
import com.rosetta.messenger.network.ProtocolManager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
* Auth state management - matches React Native architecture
*/
sealed class AuthStatus {
object Loading : AuthStatus()
object Unauthenticated : AuthStatus()
data class Authenticated(val account: DecryptedAccountData) : AuthStatus()
data class Locked(val publicKey: String) : AuthStatus()
}
data class AuthStateData(
val status: AuthStatus = AuthStatus.Loading,
val hasExistingAccounts: Boolean = false,
val availableAccounts: List<String> = emptyList()
)
class AuthStateManager(
private val context: Context,
private val scope: CoroutineScope
) {
private val databaseService = DatabaseService.getInstance(context)
private val _state = MutableStateFlow(AuthStateData())
val state: StateFlow<AuthStateData> = _state.asStateFlow()
private var currentDecryptedAccount: DecryptedAccountData? = null
// 🚀 ОПТИМИЗАЦИЯ: Кэш списка аккаунтов для UI
private var accountsCache: List<String>? = null
private var lastAccountsLoadTime = 0L
private val accountsCacheTTL = 5000L // 5 секунд
companion object {
private const val TAG = "AuthStateManager"
}
init {
scope.launch {
loadAccounts()
checkAuthStatus()
}
}
private suspend fun loadAccounts() = withContext(Dispatchers.IO) {
try {
// 🚀 ОПТИМИЗАЦИЯ: Используем кэш если он свежий
val currentTime = System.currentTimeMillis()
if (accountsCache != null && (currentTime - lastAccountsLoadTime) < accountsCacheTTL) {
_state.update { it.copy(
hasExistingAccounts = accountsCache!!.isNotEmpty(),
availableAccounts = accountsCache!!
)}
return@withContext
}
val accounts = databaseService.getAllEncryptedAccounts()
val hasAccounts = accounts.isNotEmpty()
val accountKeys = accounts.map { it.publicKey }
// Обновляем кэш
accountsCache = accountKeys
lastAccountsLoadTime = currentTime
_state.update { it.copy(
hasExistingAccounts = hasAccounts,
availableAccounts = accountKeys
)}
} catch (e: Exception) {
}
}
private suspend fun checkAuthStatus() {
try {
val hasAccounts = databaseService.hasAccounts()
if (!hasAccounts) {
_state.update { it.copy(
status = AuthStatus.Unauthenticated
)}
} else {
_state.update { it.copy(
status = AuthStatus.Unauthenticated
)}
}
} catch (e: Exception) {
_state.update { it.copy(
status = AuthStatus.Unauthenticated
)}
}
}
/**
* Create new account from seed phrase
* Matches createAccountFromSeedPhrase from React Native
* 🚀 ОПТИМИЗАЦИЯ: Dispatchers.Default для CPU-интенсивной криптографии
*/
suspend fun createAccount(
seedPhrase: List<String>,
password: String
): Result<DecryptedAccountData> = withContext(Dispatchers.Default) {
try {
// Step 1: Generate key pair from seed phrase (using BIP39)
val keyPair = CryptoManager.generateKeyPairFromSeed(seedPhrase)
// Step 2: Generate private key hash for protocol
val privateKeyHash = CryptoManager.generatePrivateKeyHash(keyPair.privateKey)
// Step 3: Encrypt private key with password
val encryptedPrivateKey = CryptoManager.encryptWithPassword(
keyPair.privateKey, password
)
// Step 4: Encrypt seed phrase with password
val encryptedSeedPhrase = CryptoManager.encryptWithPassword(
seedPhrase.joinToString(" "), password
)
// Step 5: Save to database
val saved = withContext(Dispatchers.IO) {
databaseService.saveEncryptedAccount(
publicKey = keyPair.publicKey,
privateKeyEncrypted = encryptedPrivateKey,
seedPhraseEncrypted = encryptedSeedPhrase
)
}
if (!saved) {
return@withContext Result.failure(Exception("Failed to save account to database"))
}
// Step 6: Create decrypted account object
val decryptedAccount = DecryptedAccountData(
publicKey = keyPair.publicKey,
privateKey = keyPair.privateKey,
privateKeyHash = privateKeyHash,
seedPhrase = seedPhrase
)
// Step 7: Update state and reload accounts
currentDecryptedAccount = decryptedAccount
_state.update { it.copy(
status = AuthStatus.Authenticated(decryptedAccount)
)}
loadAccounts()
// Initialize MessageRepository BEFORE connecting/authenticating
// so incoming messages from server are stored under the correct account
ProtocolManager.initializeAccount(keyPair.publicKey, keyPair.privateKey)
// Step 8: Connect and authenticate with protocol
ProtocolManager.connect()
ProtocolManager.authenticate(keyPair.publicKey, privateKeyHash)
ProtocolManager.reconnectNowIfNeeded("auth_state_create")
Result.success(decryptedAccount)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Unlock account with password
* Matches loginWithPassword from React Native
*/
suspend fun unlock(
publicKey: String,
password: String
): Result<DecryptedAccountData> = withContext(Dispatchers.Default) {
try {
// Decrypt account from database
val decryptedAccount = withContext(Dispatchers.IO) {
databaseService.decryptAccount(publicKey, password)
}
if (decryptedAccount == null) {
return@withContext Result.failure(Exception("Invalid password or account not found"))
}
// Update last used timestamp
withContext(Dispatchers.IO) {
databaseService.updateLastUsed(publicKey)
}
// Update state
currentDecryptedAccount = decryptedAccount
_state.update { it.copy(
status = AuthStatus.Authenticated(decryptedAccount)
)}
// Initialize MessageRepository BEFORE connecting/authenticating
// so incoming messages from server are stored under the correct account
ProtocolManager.initializeAccount(decryptedAccount.publicKey, decryptedAccount.privateKey)
// Connect and authenticate with protocol
ProtocolManager.connect()
ProtocolManager.authenticate(decryptedAccount.publicKey, decryptedAccount.privateKeyHash)
ProtocolManager.reconnectNowIfNeeded("auth_state_unlock")
Result.success(decryptedAccount)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Logout - clears decrypted account from memory
*/
fun logout() {
currentDecryptedAccount = null
_state.update { it.copy(
status = AuthStatus.Unauthenticated
)}
}
/**
* Delete account from database
*/
suspend fun deleteAccount(publicKey: String): Result<Unit> = withContext(Dispatchers.IO) {
try {
val success = databaseService.deleteAccount(publicKey)
if (!success) {
return@withContext Result.failure(Exception("Failed to delete account"))
}
// If deleting current account, logout
if (currentDecryptedAccount?.publicKey == publicKey) {
withContext(Dispatchers.Main) {
logout()
}
}
loadAccounts()
Result.success(Unit)
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* Get current decrypted account (if authenticated)
*/
fun getCurrentAccount(): DecryptedAccountData? = currentDecryptedAccount
}
@Composable
fun rememberAuthState(context: Context): AuthStateManager {
val scope = rememberCoroutineScope()
return remember(context) {
AuthStateManager(context, scope)
}
}
@Composable
fun ProvideAuthState(
authState: AuthStateManager,
content: @Composable (AuthStateData) -> Unit
) {
val state by authState.state.collectAsState()
content(state)
}

View File

@@ -284,6 +284,7 @@ fun ChatDetailScreen(
user: SearchUser, user: SearchUser,
onBack: () -> Unit, onBack: () -> Unit,
onNavigateToChat: (SearchUser) -> Unit, onNavigateToChat: (SearchUser) -> Unit,
onCallClick: (SearchUser) -> Unit = {},
onUserProfileClick: (SearchUser) -> Unit = {}, onUserProfileClick: (SearchUser) -> Unit = {},
onGroupInfoClick: (SearchUser) -> Unit = {}, onGroupInfoClick: (SearchUser) -> Unit = {},
currentUserPublicKey: String, currentUserPublicKey: String,
@@ -1873,8 +1874,7 @@ fun ChatDetailScreen(
!isSystemAccount !isSystemAccount
) { ) {
IconButton( IconButton(
onClick = { /* TODO: Voice call */ onClick = { onCallClick(user) }
}
) { ) {
Icon( Icon(
Icons.Default Icons.Default

View File

@@ -2063,6 +2063,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
message.attachments.any { it.type == AttachmentType.IMAGE } -> "Photo" message.attachments.any { it.type == AttachmentType.IMAGE } -> "Photo"
message.attachments.any { it.type == AttachmentType.FILE } -> "File" message.attachments.any { it.type == AttachmentType.FILE } -> "File"
message.attachments.any { it.type == AttachmentType.AVATAR } -> "Avatar" message.attachments.any { it.type == AttachmentType.AVATAR } -> "Avatar"
message.attachments.any { it.type == AttachmentType.CALL } -> "Call"
message.forwardedMessages.isNotEmpty() -> "Forwarded message" message.forwardedMessages.isNotEmpty() -> "Forwarded message"
message.replyData != null -> "Reply" message.replyData != null -> "Reply"
else -> "Pinned message" else -> "Pinned message"

View File

@@ -4367,6 +4367,8 @@ fun DialogItemContent(
"File" -> "File" "File" -> "File"
dialog.lastMessageAttachmentType == dialog.lastMessageAttachmentType ==
"Avatar" -> "Avatar" "Avatar" -> "Avatar"
dialog.lastMessageAttachmentType ==
"Call" -> "Call"
dialog.lastMessageAttachmentType == dialog.lastMessageAttachmentType ==
"Forwarded" -> "Forwarded message" "Forwarded" -> "Forwarded message"
dialog.lastMessage.isEmpty() -> dialog.lastMessage.isEmpty() ->

View File

@@ -603,6 +603,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
} }
2 -> "File" // AttachmentType.FILE = 2 2 -> "File" // AttachmentType.FILE = 2
3 -> "Avatar" // AttachmentType.AVATAR = 3 3 -> "Avatar" // AttachmentType.AVATAR = 3
4 -> "Call" // AttachmentType.CALL = 4
else -> null else -> null
} }
} catch (e: Exception) { } catch (e: Exception) {

View File

@@ -40,9 +40,16 @@ fun ConnectionLogsScreen(
val listState = rememberLazyListState() val listState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
DisposableEffect(Unit) {
ProtocolManager.enableUILogs(true)
onDispose {
ProtocolManager.enableUILogs(false)
}
}
LaunchedEffect(logs.size) { LaunchedEffect(logs.size) {
if (logs.isNotEmpty()) { if (logs.isNotEmpty()) {
listState.animateScrollToItem(logs.size - 1) listState.scrollToItem(logs.size - 1)
} }
} }
@@ -89,7 +96,7 @@ fun ConnectionLogsScreen(
IconButton(onClick = { IconButton(onClick = {
scope.launch { scope.launch {
if (logs.isNotEmpty()) listState.animateScrollToItem(logs.size - 1) if (logs.isNotEmpty()) listState.scrollToItem(logs.size - 1)
} }
}) { }) {
Icon( Icon(

View File

@@ -404,6 +404,7 @@ private fun ForwardDialogItem(
dialog.lastMessageAttachmentType == "Photo" -> "Photo" dialog.lastMessageAttachmentType == "Photo" -> "Photo"
dialog.lastMessageAttachmentType == "File" -> "File" dialog.lastMessageAttachmentType == "File" -> "File"
dialog.lastMessageAttachmentType == "Avatar" -> "Avatar" dialog.lastMessageAttachmentType == "Avatar" -> "Avatar"
dialog.lastMessageAttachmentType == "Call" -> "Call"
dialog.lastMessageAttachmentType == "Forwarded" -> "Forwarded message" dialog.lastMessageAttachmentType == "Forwarded" -> "Forwarded message"
dialog.lastMessage.isNotEmpty() -> dialog.lastMessage dialog.lastMessage.isNotEmpty() -> dialog.lastMessage
else -> "No messages" else -> "No messages"

View File

@@ -101,7 +101,8 @@ fun SearchScreen(
protocolState: ProtocolState, protocolState: ProtocolState,
onBackClick: () -> Unit, onBackClick: () -> Unit,
onUserSelect: (SearchUser) -> Unit, onUserSelect: (SearchUser) -> Unit,
onNavigateToCrashLogs: () -> Unit = {} onNavigateToCrashLogs: () -> Unit = {},
onNavigateToConnectionLogs: () -> Unit = {}
) { ) {
// Context и View для мгновенного закрытия клавиатуры // Context и View для мгновенного закрытия клавиатуры
val context = LocalContext.current val context = LocalContext.current
@@ -150,6 +151,11 @@ fun SearchScreen(
if (searchQuery.trim().equals("rosettadev1", ignoreCase = true)) { if (searchQuery.trim().equals("rosettadev1", ignoreCase = true)) {
searchViewModel.clearSearchQuery() searchViewModel.clearSearchQuery()
onNavigateToCrashLogs() onNavigateToCrashLogs()
return@LaunchedEffect
}
if (searchQuery.trim().equals("rosettadev2", ignoreCase = true)) {
searchViewModel.clearSearchQuery()
onNavigateToConnectionLogs()
} }
} }

View File

@@ -0,0 +1,548 @@
package com.rosetta.messenger.ui.chats.calls
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Call
import androidx.compose.material.icons.filled.CallEnd
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.MicOff
import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material.icons.filled.VideocamOff
import androidx.compose.material.icons.filled.VolumeOff
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.QrCode2
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.network.CallPhase
import com.rosetta.messenger.network.CallUiState
import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.AvatarImage
// ── Telegram-style dark gradient colors ──────────────────────────
private val GradientTop = Color(0xFF1A1A2E)
private val GradientMid = Color(0xFF16213E)
private val GradientBottom = Color(0xFF0F3460)
private val AcceptGreen = Color(0xFF4CC764)
private val DeclineRed = Color(0xFFE74C3C)
private val ButtonBg = Color.White.copy(alpha = 0.15f)
private val ButtonBgActive = Color.White.copy(alpha = 0.30f)
private val RingColor1 = Color.White.copy(alpha = 0.06f)
private val RingColor2 = Color.White.copy(alpha = 0.10f)
private val RingColor3 = Color.White.copy(alpha = 0.04f)
// ── Main Call Screen ─────────────────────────────────────────────
@Composable
fun CallOverlay(
state: CallUiState,
isDarkTheme: Boolean,
avatarRepository: AvatarRepository? = null,
onAccept: () -> Unit,
onDecline: () -> Unit,
onEnd: () -> Unit,
onToggleMute: () -> Unit,
onToggleSpeaker: () -> Unit
) {
val view = LocalView.current
LaunchedEffect(state.isVisible) {
if (state.isVisible && !view.isInEditMode) {
val window = (view.context as android.app.Activity).window
window.statusBarColor = android.graphics.Color.TRANSPARENT
window.navigationBarColor = android.graphics.Color.TRANSPARENT
val ctrl = androidx.core.view.WindowCompat.getInsetsController(window, view)
ctrl.isAppearanceLightStatusBars = false
ctrl.isAppearanceLightNavigationBars = false
}
}
AnimatedVisibility(
visible = state.isVisible,
enter = fadeIn(tween(300)),
exit = fadeOut(tween(200))
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(
Brush.verticalGradient(listOf(GradientTop, GradientMid, GradientBottom))
)
) {
// ── Top bar: "Encrypted" left + QR icon right ──
if (state.phase == CallPhase.ACTIVE || state.phase == CallPhase.CONNECTING) {
Row(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.TopCenter)
.statusBarsPadding()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "\uD83D\uDD12 Encrypted",
color = Color.White.copy(alpha = 0.4f),
fontSize = 13.sp,
)
// QR grid icon — tap to show popover
if (state.keyCast.isNotBlank()) {
EncryptionKeyButton(keyHex = state.keyCast)
}
}
}
// ── Center content: rings + avatar + name + status ──
Column(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.TopCenter)
.statusBarsPadding()
.padding(top = 100.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
// Avatar with rings
CallAvatar(
peerPublicKey = state.peerPublicKey,
displayName = state.displayName,
avatarRepository = avatarRepository,
isDarkTheme = isDarkTheme,
showRings = state.phase != CallPhase.IDLE
)
Spacer(modifier = Modifier.height(24.dp))
// Name
Text(
text = state.displayName,
color = Color.White,
fontSize = 26.sp,
fontWeight = FontWeight.SemiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(horizontal = 48.dp)
)
Spacer(modifier = Modifier.height(6.dp))
// Status with animated dots
val showDots = state.phase == CallPhase.OUTGOING ||
state.phase == CallPhase.CONNECTING ||
state.phase == CallPhase.INCOMING
if (showDots) {
AnimatedDotsText(
baseText = when (state.phase) {
CallPhase.OUTGOING -> state.statusText.ifBlank { "Requesting" }
CallPhase.CONNECTING -> state.statusText.ifBlank { "Connecting" }
CallPhase.INCOMING -> "Ringing"
else -> ""
},
color = Color.White.copy(alpha = 0.6f)
)
} else if (state.phase == CallPhase.ACTIVE) {
Text(
text = formatCallDuration(state.durationSec),
color = Color.White.copy(alpha = 0.6f),
fontSize = 15.sp
)
}
}
// ── Bottom buttons ──
Column(
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.navigationBarsPadding()
.padding(bottom = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
AnimatedContent(
targetState = state.phase,
transitionSpec = {
(fadeIn(tween(200)) + slideInVertically { it / 3 }) togetherWith
(fadeOut(tween(150)) + slideOutVertically { it / 3 })
},
label = "btns"
) { phase ->
when (phase) {
CallPhase.INCOMING -> IncomingButtons(onAccept, onDecline)
CallPhase.ACTIVE -> ActiveButtons(state, onToggleMute, onToggleSpeaker, onEnd)
CallPhase.OUTGOING, CallPhase.CONNECTING -> OutgoingButtons(state, onToggleSpeaker, onToggleMute, onEnd)
CallPhase.IDLE -> Spacer(Modifier.height(1.dp))
}
}
}
}
}
}
// ── Avatar with concentric rings ─────────────────────────────────
@Composable
private fun CallAvatar(
peerPublicKey: String,
displayName: String,
avatarRepository: AvatarRepository?,
isDarkTheme: Boolean,
showRings: Boolean
) {
val avatarSize = 130.dp
val ringPadding = 50.dp
val totalSize = avatarSize + ringPadding * 2
val infiniteTransition = rememberInfiniteTransition(label = "rings")
val ringScale by infiniteTransition.animateFloat(
1f, 1.08f,
infiniteRepeatable(tween(3000, easing = EaseInOut), RepeatMode.Reverse),
label = "ringScale"
)
val ringAlpha by animateFloatAsState(
if (showRings) 1f else 0f, tween(400), label = "ringAlpha"
)
Box(modifier = Modifier.size(totalSize), contentAlignment = Alignment.Center) {
// Concentric rings (like Telegram)
if (ringAlpha > 0f) {
// Outer ring
Box(
modifier = Modifier
.size(avatarSize + 44.dp)
.scale(ringScale)
.graphicsLayer { alpha = ringAlpha }
.clip(CircleShape)
.background(RingColor3)
)
// Middle ring
Box(
modifier = Modifier
.size(avatarSize + 28.dp)
.scale(ringScale * 0.98f)
.graphicsLayer { alpha = ringAlpha }
.clip(CircleShape)
.background(RingColor1)
)
// Inner ring
Box(
modifier = Modifier
.size(avatarSize + 14.dp)
.scale(ringScale * 0.96f)
.graphicsLayer { alpha = ringAlpha }
.clip(CircleShape)
.background(RingColor2)
)
}
// Avatar
AvatarImage(
publicKey = peerPublicKey,
avatarRepository = avatarRepository,
size = avatarSize,
isDarkTheme = isDarkTheme,
showOnlineIndicator = false,
isOnline = false,
displayName = displayName
)
}
}
// ── Incoming: Accept + Decline ───────────────────────────────────
@Composable
private fun IncomingButtons(onAccept: () -> Unit, onDecline: () -> Unit) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 60.dp),
horizontalArrangement = Arrangement.SpaceBetween
) {
CallButton(DeclineRed, "Decline", Icons.Default.CallEnd, onClick = onDecline)
CallButton(AcceptGreen, "Accept", Icons.Default.Call, onClick = onAccept, showPulse = true)
}
}
// ── Outgoing/Connecting: Speaker + Mute + End Call ───────────────
@Composable
private fun OutgoingButtons(
state: CallUiState, onSpeaker: () -> Unit, onMute: () -> Unit, onEnd: () -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
CallButton(
if (state.isSpeakerOn) ButtonBgActive else ButtonBg, "Speaker",
if (state.isSpeakerOn) Icons.Default.VolumeUp else Icons.Default.VolumeOff,
onClick = onSpeaker
)
CallButton(
if (state.isMuted) ButtonBgActive else ButtonBg, "Mute",
if (state.isMuted) Icons.Default.MicOff else Icons.Default.Mic,
onClick = onMute
)
CallButton(DeclineRed, "End Call", Icons.Default.CallEnd, onClick = onEnd)
}
}
// ── Active: Speaker + Video + Mute + End Call ────────────────────
@Composable
private fun ActiveButtons(
state: CallUiState, onMute: () -> Unit, onSpeaker: () -> Unit, onEnd: () -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
CallButton(
if (state.isSpeakerOn) ButtonBgActive else ButtonBg, "Speaker",
if (state.isSpeakerOn) Icons.Default.VolumeUp else Icons.Default.VolumeOff,
onClick = onSpeaker
)
CallButton(
if (state.isMuted) ButtonBgActive else ButtonBg, "Mute",
if (state.isMuted) Icons.Default.MicOff else Icons.Default.Mic,
onClick = onMute
)
CallButton(DeclineRed, "End Call", Icons.Default.CallEnd, onClick = onEnd)
}
}
// ── Reusable round button with icon + label ──────────────────────
@Composable
private fun CallButton(
color: Color,
label: String,
icon: ImageVector,
size: Dp = 60.dp,
showPulse: Boolean = false,
onClick: () -> Unit
) {
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val btnScale by animateFloatAsState(
if (isPressed) 0.88f else 1f,
spring(dampingRatio = 0.5f, stiffness = 800f), label = "s"
)
val inf = rememberInfiniteTransition(label = "p_$label")
val pulseScale by inf.animateFloat(
1f, 1.35f,
infiniteRepeatable(tween(1200, easing = EaseOut), RepeatMode.Restart), label = "ps"
)
val pulseAlpha by inf.animateFloat(
0.4f, 0f,
infiniteRepeatable(tween(1200, easing = EaseOut), RepeatMode.Restart), label = "pa"
)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.width(72.dp)
) {
Box(contentAlignment = Alignment.Center) {
if (showPulse) {
Box(
Modifier.size(size).scale(pulseScale)
.graphicsLayer { alpha = pulseAlpha }
.clip(CircleShape).background(color.copy(alpha = 0.4f))
)
}
Box(
Modifier.size(size).scale(btnScale).clip(CircleShape).background(color)
.clickable(interactionSource, indication = null, onClick = onClick),
contentAlignment = Alignment.Center
) {
Icon(icon, label, tint = Color.White, modifier = Modifier.size(26.dp))
}
}
Spacer(Modifier.height(8.dp))
Text(
label, color = Color.White.copy(alpha = 0.7f), fontSize = 11.sp,
maxLines = 1, textAlign = TextAlign.Center
)
}
}
// ── Animated dots (Canvas circles with staggered scale) ──────────
@Composable
private fun AnimatedDotsText(baseText: String, color: Color) {
val inf = rememberInfiniteTransition(label = "dots")
val d0 by inf.animateFloat(0f, 1f, infiniteRepeatable(tween(800, easing = LinearEasing)), label = "d0")
val d1 by inf.animateFloat(0f, 1f, infiniteRepeatable(tween(800, delayMillis = 150, easing = LinearEasing)), label = "d1")
val d2 by inf.animateFloat(0f, 1f, infiniteRepeatable(tween(800, delayMillis = 300, easing = LinearEasing)), label = "d2")
val density = LocalDensity.current
val dotR = with(density) { 2.dp.toPx() }
val spacing = with(density) { 6.dp.toPx() }
Row(verticalAlignment = Alignment.CenterVertically) {
Text(baseText, color = color, fontSize = 15.sp)
Spacer(Modifier.width(5.dp))
Canvas(Modifier.size(width = 22.dp, height = 14.dp)) {
val cy = size.height / 2
listOf(d0, d1, d2).forEachIndexed { i, p ->
val s = if (p < 0.4f) {
val t = p / 0.4f; t * t * (3f - 2f * t)
} else {
val t = (p - 0.4f) / 0.6f; 1f - t * t * (3f - 2f * t)
}
drawCircle(
color.copy(alpha = 0.4f + 0.6f * s),
radius = dotR * (0.5f + 0.5f * s),
center = Offset(dotR + i * spacing, cy)
)
}
}
}
}
// ── Helpers ──────────────────────────────────────────────────────
private fun formatCallDuration(seconds: Int): String {
val s = seconds.coerceAtLeast(0)
val h = s / 3600; val m = (s % 3600) / 60; val sec = s % 60
return if (h > 0) "%d:%02d:%02d".format(h, m, sec) else "%02d:%02d".format(m, sec)
}
/**
* QR icon in top-right corner — tap to show encryption key dropdown.
* 1:1 match with Desktop's IconQrcode + Popover.
*/
@Composable
private fun EncryptionKeyButton(keyHex: String) {
var showPopup by remember { mutableStateOf(false) }
Box {
// QR code icon (matches Desktop IconQrcode size={24} color="white")
Icon(
imageVector = Icons.Default.QrCode2,
contentDescription = "Encryption key",
tint = Color.White,
modifier = Modifier
.size(24.dp)
.clickable { showPopup = !showPopup }
)
// Dropdown popover (matches Desktop Popover width={300} withArrow)
androidx.compose.material3.DropdownMenu(
expanded = showPopup,
onDismissRequest = { showPopup = false },
modifier = Modifier
.widthIn(max = 300.dp)
.background(Color(0xFF1E293B)),
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Text(
text = "This call is secured by 256 bit end-to-end encryption. " +
"Only you and the recipient can read or listen to the content of this call.",
color = Color.White.copy(alpha = 0.6f),
fontSize = 11.sp,
lineHeight = 15.sp,
modifier = Modifier.weight(1f)
)
Canvas(modifier = Modifier.size(80.dp)) {
drawKeyGrid(keyHex, size.width, this)
}
}
Spacer(modifier = Modifier.height(8.dp))
val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current
var copied by remember { mutableStateOf(false) }
Row(
modifier = Modifier
.fillMaxWidth()
.clip(androidx.compose.foundation.shape.RoundedCornerShape(8.dp))
.background(Color.White.copy(alpha = 0.08f))
.clickable {
clipboardManager.setText(androidx.compose.ui.text.AnnotatedString(keyHex))
copied = true
}
.padding(horizontal = 10.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
Icon(
imageVector = if (copied) Icons.Default.Check else Icons.Default.ContentCopy,
contentDescription = "Copy key",
tint = Color.White.copy(alpha = 0.5f),
modifier = Modifier.size(14.dp)
)
Text(
text = if (copied) "Copied!" else keyHex.take(16) + "..." + keyHex.takeLast(8),
color = Color.White.copy(alpha = 0.4f),
fontSize = 10.sp,
maxLines = 1,
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace
)
}
}
}
}
}
/** Palette matching Desktop's Mantine theme.colors.blue[1..5] */
private val KeyGridPalette = listOf(
Color(0xFFDBE4FF), // blue[1]
Color(0xFFBAC8FF), // blue[2]
Color(0xFF91A7FF), // blue[3]
Color(0xFF748FFC), // blue[4]
Color(0xFF5C7CFA), // blue[5]
)
/**
* Draw 8x8 color grid — same algorithm as Desktop KeyImage.tsx:
* each character's charCode % palette.size determines the color.
*/
private fun drawKeyGrid(keyHex: String, totalSize: Float, scope: androidx.compose.ui.graphics.drawscope.DrawScope) {
val cells = 8
val cellSize = totalSize / cells
for (i in 0 until cells * cells) {
val color = if (i < keyHex.length) {
KeyGridPalette[keyHex[i].code % KeyGridPalette.size]
} else {
KeyGridPalette[0]
}
val row = i / cells
val col = i % cells
scope.drawRect(
color = color,
topLeft = Offset(col * cellSize, row * cellSize),
size = androidx.compose.ui.geometry.Size(cellSize, cellSize)
)
}
}

View File

@@ -19,6 +19,7 @@ import androidx.compose.animation.core.tween
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
@@ -525,6 +526,15 @@ fun MessageAttachments(
messageStatus = messageStatus messageStatus = messageStatus
) )
} }
AttachmentType.CALL -> {
CallAttachment(
attachment = attachment,
isOutgoing = isOutgoing,
isDarkTheme = isDarkTheme,
timestamp = timestamp,
messageStatus = messageStatus
)
}
else -> { else -> {
/* MESSAGES обрабатываются отдельно */ /* MESSAGES обрабатываются отдельно */
} }
@@ -1546,6 +1556,197 @@ fun ImageAttachment(
} }
} }
private data class DesktopCallUi(
val title: String,
val subtitle: String,
val isError: Boolean
)
private fun parseCallDurationSeconds(preview: String): Int {
if (preview.isBlank()) return 0
val tail = preview.substringAfterLast("::").trim()
tail.toIntOrNull()?.let { return it.coerceAtLeast(0) }
val durationRegex = Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*(\\d+)", RegexOption.IGNORE_CASE)
durationRegex.find(preview)?.groupValues?.getOrNull(1)?.toIntOrNull()?.let {
return it.coerceAtLeast(0)
}
return preview.trim().toIntOrNull()?.coerceAtLeast(0) ?: 0
}
private fun formatDesktopCallDuration(durationSec: Int): String {
val minutes = durationSec / 60
val seconds = durationSec % 60
return "$minutes:${seconds.toString().padStart(2, '0')}"
}
private fun resolveDesktopCallUi(preview: String, isOutgoing: Boolean): DesktopCallUi {
val durationSec = parseCallDurationSeconds(preview)
val isError = durationSec == 0
val title =
if (isError) {
if (isOutgoing) "Rejected call" else "Missed call"
} else {
if (isOutgoing) "Outgoing call" else "Incoming call"
}
val subtitle =
if (isError) {
"Call was not answered or was rejected"
} else {
formatDesktopCallDuration(durationSec)
}
return DesktopCallUi(title = title, subtitle = subtitle, isError = isError)
}
/** Call attachment bubble */
@Composable
fun CallAttachment(
attachment: MessageAttachment,
isOutgoing: Boolean,
isDarkTheme: Boolean,
timestamp: java.util.Date,
messageStatus: MessageStatus = MessageStatus.READ
) {
val callUi = remember(attachment.preview, isOutgoing) {
resolveDesktopCallUi(attachment.preview, isOutgoing)
}
val containerShape = RoundedCornerShape(10.dp)
val containerBackground =
if (isOutgoing) {
Color.White.copy(alpha = 0.12f)
} else {
if (isDarkTheme) Color(0xFF1F2733) else Color(0xFFF3F8FF)
}
val containerBorder =
if (isOutgoing) {
Color.White.copy(alpha = 0.2f)
} else {
if (isDarkTheme) Color(0xFF33435A) else Color(0xFFD8E5F4)
}
val iconBackground = if (callUi.isError) Color(0xFFE55A5A) else PrimaryBlue
val iconVector =
when {
callUi.isError -> Icons.Default.Close
isOutgoing -> Icons.Default.CallMade
else -> Icons.Default.CallReceived
}
Box(
modifier =
Modifier
.padding(vertical = 4.dp)
.widthIn(min = 200.dp)
.heightIn(min = 60.dp)
.clip(containerShape)
.background(containerBackground)
.border(width = 1.dp, color = containerBorder, shape = containerShape)
.padding(horizontal = 10.dp, vertical = 8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier =
Modifier.size(40.dp)
.clip(CircleShape)
.background(iconBackground),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = iconVector,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(20.dp)
)
}
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = callUi.title,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = if (isOutgoing) Color.White else if (isDarkTheme) Color.White else Color.Black,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = callUi.subtitle,
fontSize = 12.sp,
color =
if (callUi.isError) {
Color(0xFFE55A5A)
} else if (isOutgoing) {
Color.White.copy(alpha = 0.72f)
} else {
if (isDarkTheme) Color(0xFF8EC9FF) else PrimaryBlue
},
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (isOutgoing) {
Spacer(modifier = Modifier.width(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = android.text.format.DateFormat.format("HH:mm", timestamp).toString(),
fontSize = 11.sp,
color = Color.White.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.width(4.dp))
when (messageStatus) {
MessageStatus.SENDING -> {
Icon(
painter = TelegramIcons.Clock,
contentDescription = null,
tint = Color.White.copy(alpha = 0.7f),
modifier = Modifier.size(14.dp)
)
}
MessageStatus.SENT, MessageStatus.DELIVERED -> {
Icon(
painter = TelegramIcons.Done,
contentDescription = null,
tint = Color.White.copy(alpha = 0.8f),
modifier = Modifier.size(14.dp)
)
}
MessageStatus.READ -> {
Box(modifier = Modifier.height(14.dp)) {
Icon(
painter = TelegramIcons.Done,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(14.dp)
)
Icon(
painter = TelegramIcons.Done,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(14.dp).offset(x = 4.dp)
)
}
}
MessageStatus.ERROR -> {
Icon(
imageVector = Icons.Default.Error,
contentDescription = null,
tint = Color(0xFFE53935),
modifier = Modifier.size(14.dp)
)
}
}
}
}
}
}
}
/** File attachment - Telegram style */ /** File attachment - Telegram style */
@Composable @Composable
fun FileAttachment( fun FileAttachment(

View File

@@ -2235,6 +2235,7 @@ fun ReplyBubble(
} else if (!hasImage) { } else if (!hasImage) {
val displayText = when { val displayText = when {
replyData.attachments.any { it.type == AttachmentType.FILE } -> "File" replyData.attachments.any { it.type == AttachmentType.FILE } -> "File"
replyData.attachments.any { it.type == AttachmentType.CALL } -> "Call"
else -> "..." else -> "..."
} }
AppleEmojiText( AppleEmojiText(

View File

@@ -1,258 +0,0 @@
package com.rosetta.messenger.ui.chats.components
import android.view.HapticFeedbackConstants
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import compose.icons.TablerIcons
import compose.icons.tablericons.Bug
import com.rosetta.messenger.ui.icons.TelegramIcons
import kotlinx.coroutines.launch
/**
* 🐛 BottomSheet для отображения debug логов протокола
*
* Показывает логи отправки/получения сообщений для дебага.
* Использует ProtocolManager.debugLogs как источник данных.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DebugLogsBottomSheet(
logs: List<String>,
isDarkTheme: Boolean,
onDismiss: () -> Unit,
onClearLogs: () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false)
val scope = rememberCoroutineScope()
val view = LocalView.current
val listState = rememberLazyListState()
// Colors
val backgroundColor = if (isDarkTheme) Color(0xFF1C1C1E) else Color.White
val textColor = if (isDarkTheme) Color.White else Color.Black
val secondaryTextColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF666666)
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFE8E8E8)
// Haptic feedback при открытии
LaunchedEffect(Unit) {
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
}
// Авто-скролл вниз при новых логах
LaunchedEffect(logs.size) {
if (logs.isNotEmpty()) {
listState.animateScrollToItem(logs.size - 1)
}
}
// Плавное затемнение статус бара
DisposableEffect(Unit) {
if (!view.isInEditMode) {
val window = (view.context as? android.app.Activity)?.window
val originalStatusBarColor = window?.statusBarColor ?: 0
val scrimColor = android.graphics.Color.argb(153, 0, 0, 0)
val fadeInAnimator = android.animation.ValueAnimator.ofArgb(originalStatusBarColor, scrimColor).apply {
duration = 200
addUpdateListener { animator ->
window?.statusBarColor = animator.animatedValue as Int
}
}
fadeInAnimator.start()
onDispose {
val fadeOutAnimator = android.animation.ValueAnimator.ofArgb(scrimColor, originalStatusBarColor).apply {
duration = 150
addUpdateListener { animator ->
window?.statusBarColor = animator.animatedValue as Int
}
}
fadeOutAnimator.start()
}
} else {
onDispose { }
}
}
fun dismissWithAnimation() {
scope.launch {
sheetState.hide()
onDismiss()
}
}
ModalBottomSheet(
onDismissRequest = { dismissWithAnimation() },
sheetState = sheetState,
containerColor = backgroundColor,
scrimColor = Color.Black.copy(alpha = 0.6f),
dragHandle = {
Column(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.width(36.dp)
.height(5.dp)
.clip(RoundedCornerShape(2.5.dp))
.background(if (isDarkTheme) Color(0xFF4A4A4A) else Color(0xFFD1D1D6))
)
Spacer(modifier = Modifier.height(16.dp))
}
},
shape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp),
modifier = Modifier.statusBarsPadding()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Иконка и заголовок
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
TablerIcons.Bug,
contentDescription = null,
tint = PrimaryBlue,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = "Debug Logs",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
color = textColor
)
Text(
text = "${logs.size} log entries",
fontSize = 14.sp,
color = secondaryTextColor
)
}
}
// Кнопки
Row {
IconButton(onClick = onClearLogs) {
Icon(
painter = TelegramIcons.Delete,
contentDescription = "Clear logs",
tint = secondaryTextColor.copy(alpha = 0.6f),
modifier = Modifier.size(22.dp)
)
}
IconButton(onClick = { dismissWithAnimation() }) {
Icon(
Icons.Default.Close,
contentDescription = "Close",
tint = secondaryTextColor.copy(alpha = 0.6f),
modifier = Modifier.size(22.dp)
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
Divider(color = dividerColor, thickness = 0.5.dp)
// Контент
if (logs.isEmpty()) {
// Empty state
Box(
modifier = Modifier
.fillMaxWidth()
.height(200.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "No logs yet.\nLogs will appear here during messaging.",
fontSize = 14.sp,
color = secondaryTextColor,
textAlign = TextAlign.Center
)
}
} else {
// Список логов
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 300.dp, max = 500.dp)
.padding(horizontal = 8.dp, vertical = 8.dp)
) {
items(logs) { log ->
DebugLogItem(log = log, isDarkTheme = isDarkTheme)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
}
}
/**
* Элемент лога с цветовой кодировкой
*/
@Composable
private fun DebugLogItem(
log: String,
isDarkTheme: Boolean
) {
val textColor = if (isDarkTheme) Color.White else Color.Black
val successColor = Color(0xFF34C759)
val errorColor = Color(0xFFFF3B30)
val purpleColor = Color(0xFFAF52DE)
val heartbeatColor = Color(0xFFFF9500)
val messageColor = PrimaryBlue
// Определяем цвет по содержимому лога
val logColor = when {
log.contains("") || log.contains("SUCCESS") -> successColor
log.contains("") || log.contains("ERROR") || log.contains("FAILED") -> errorColor
log.contains("🔄") || log.contains("STATE") -> purpleColor
log.contains("💓") || log.contains("💔") -> heartbeatColor
log.contains("📥") || log.contains("📤") || log.contains("📨") -> messageColor
else -> textColor.copy(alpha = 0.85f)
}
Text(
text = log,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
color = logColor,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 2.dp, horizontal = 8.dp)
)
}

View File

@@ -1,7 +1,12 @@
package com.rosetta.messenger.ui.chats.components package com.rosetta.messenger.ui.chats.components
import android.content.Context import android.content.Context
import android.content.ContentValues
import android.graphics.Bitmap import android.graphics.Bitmap
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import android.widget.Toast
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
@@ -55,6 +60,7 @@ import compose.icons.tablericons.*
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.IOException
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.* import java.util.*
import kotlin.math.abs import kotlin.math.abs
@@ -215,6 +221,8 @@ fun ImageViewerScreen(
// UI visibility state // UI visibility state
var showControls by remember { mutableStateOf(true) } var showControls by remember { mutableStateOf(true) }
var showKebabMenu by remember { mutableStateOf(false) }
var isSavingToGallery by remember { mutableStateOf(false) }
var isTapNavigationInProgress by remember { mutableStateOf(false) } var isTapNavigationInProgress by remember { mutableStateOf(false) }
val edgeTapFadeAlpha = remember { Animatable(1f) } val edgeTapFadeAlpha = remember { Animatable(1f) }
val imageBitmapCache = val imageBitmapCache =
@@ -526,6 +534,77 @@ fun ImageViewerScreen(
tint = Color.White tint = Color.White
) )
} }
// Kebab menu (save image)
Box(modifier = Modifier.align(Alignment.CenterEnd)) {
IconButton(
onClick = { showKebabMenu = true },
enabled = currentImage != null && !isSavingToGallery
) {
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "More",
tint = Color.White
)
}
DropdownMenu(
expanded = showKebabMenu,
onDismissRequest = { showKebabMenu = false }
) {
DropdownMenuItem(
text = {
Text(
text = if (isSavingToGallery) "Saving..." else "Save to Gallery"
)
},
enabled = !isSavingToGallery,
onClick = {
val imageToSave = currentImage ?: return@DropdownMenuItem
showKebabMenu = false
if (isSavingToGallery) return@DropdownMenuItem
scope.launch {
isSavingToGallery = true
try {
val cachedBitmap = getCachedBitmap(imageToSave.attachmentId)
val bitmapToSave =
cachedBitmap ?: withContext(Dispatchers.IO) {
loadBitmapForViewerImage(context, imageToSave, privateKey)
}
if (bitmapToSave != null && cachedBitmap == null) {
cacheBitmap(imageToSave.attachmentId, bitmapToSave)
}
val saved =
if (bitmapToSave != null) {
withContext(Dispatchers.IO) {
saveBitmapToGallery(context, bitmapToSave, imageToSave)
}
} else {
false
}
Toast.makeText(
context,
if (saved) "Saved to gallery" else "Failed to save image",
Toast.LENGTH_SHORT
).show()
} catch (_: Exception) {
Toast.makeText(
context,
"Failed to save image",
Toast.LENGTH_SHORT
).show()
} finally {
isSavingToGallery = false
}
}
}
)
}
}
// Title and date // Title and date
Column( Column(
@@ -972,6 +1051,55 @@ private suspend fun loadBitmapForViewerImage(
} }
} }
private fun saveBitmapToGallery(
context: Context,
bitmap: Bitmap,
image: ViewableImage
): Boolean {
val resolver = context.contentResolver
val fileName = buildGalleryFileName(image)
val values =
ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
put(
MediaStore.Images.Media.RELATIVE_PATH,
"${Environment.DIRECTORY_PICTURES}/Rosetta"
)
put(MediaStore.Images.Media.IS_PENDING, 1)
}
}
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: return false
return try {
resolver.openOutputStream(uri)?.use { output ->
if (!bitmap.compress(Bitmap.CompressFormat.JPEG, 95, output)) {
throw IOException("Bitmap compression failed")
}
} ?: throw IOException("Cannot open output stream")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val finalizeValues =
ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }
resolver.update(uri, finalizeValues, null, null)
}
true
} catch (_: Exception) {
runCatching { resolver.delete(uri, null, null) }
false
}
}
private fun buildGalleryFileName(image: ViewableImage): String {
val formatter = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
val timePart = formatter.format(image.timestamp)
val idPart = image.attachmentId.take(8)
return "Rosetta_${timePart}_$idPart.jpg"
}
/** /**
* Безопасное декодирование base64 в Bitmap * Безопасное декодирование base64 в Bitmap
*/ */

View File

@@ -2,6 +2,7 @@ package com.rosetta.messenger.ui.components
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.icu.text.BreakIterator
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.Paint import android.graphics.Paint
@@ -34,8 +35,163 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import java.util.Locale
import java.util.concurrent.ConcurrentHashMap
import java.util.regex.Pattern import java.util.regex.Pattern
private data class EmojiRenderMatch(
val start: Int,
val end: Int,
val unified: String
)
private object AppleEmojiAssetResolver {
@Volatile
private var availableEmojiAssets: Set<String>? = null
private val unifiedResolveCache = ConcurrentHashMap<String, String>()
private val unresolvedUnifiedCache = ConcurrentHashMap.newKeySet<String>()
fun normalizeUnifiedCode(code: String): String =
code.trim().lowercase(Locale.ROOT).replace('_', '-')
fun resolveUnifiedFromCode(context: Context, code: String): String? {
val normalized = normalizeUnifiedCode(code)
if (normalized.isBlank()) return null
return resolveUnified(context, normalized)
}
fun collectUnicodeMatches(
context: Context,
text: String,
occupiedRanges: List<IntRange> = emptyList()
): List<EmojiRenderMatch> {
if (text.isEmpty()) return emptyList()
val iterator = BreakIterator.getCharacterInstance(Locale.ROOT)
iterator.setText(text)
val matches = mutableListOf<EmojiRenderMatch>()
var start = iterator.first()
var end = iterator.next()
while (end != BreakIterator.DONE) {
if (start < end && !isOverlapping(start, end, occupiedRanges)) {
val cluster = text.substring(start, end)
val unified = resolveUnifiedFromCluster(context, cluster)
if (!unified.isNullOrEmpty()) {
matches.add(
EmojiRenderMatch(
start = start,
end = end,
unified = unified
)
)
}
}
start = end
end = iterator.next()
}
return matches
}
private fun resolveUnifiedFromCluster(context: Context, cluster: String): String? {
if (cluster.isBlank()) return null
val codePoints = cluster.codePoints().toArray()
if (codePoints.isEmpty()) return null
val rawUnified = codePoints.joinToString("-") { codePoint ->
String.format(Locale.ROOT, "%04x", codePoint)
}
return resolveUnified(context, rawUnified)
}
private fun resolveUnified(context: Context, rawUnified: String): String? {
val key = rawUnified.lowercase(Locale.ROOT)
unifiedResolveCache[key]?.let { return it }
if (unresolvedUnifiedCache.contains(key)) return null
val resolved = buildUnifiedCandidates(key).firstOrNull { candidate ->
hasEmojiAsset(context, candidate)
}
if (resolved != null) {
unifiedResolveCache[key] = resolved
} else {
unresolvedUnifiedCache.add(key)
}
return resolved
}
private fun hasEmojiAsset(context: Context, unified: String): Boolean =
getAvailableEmojiAssets(context).contains(unified.lowercase(Locale.ROOT))
private fun getAvailableEmojiAssets(context: Context): Set<String> {
availableEmojiAssets?.let { return it }
synchronized(this) {
availableEmojiAssets?.let { return it }
val loaded =
context.assets
.list("emoji")
?.asSequence()
?.filter { it.endsWith(".png", ignoreCase = true) }
?.map { it.removeSuffix(".png").lowercase(Locale.ROOT) }
?.toSet()
?: emptySet()
availableEmojiAssets = loaded
return loaded
}
}
private fun buildUnifiedCandidates(rawUnified: String): List<String> {
val parts = rawUnified.split('-').map { it.trim() }.filter { it.isNotEmpty() }
if (parts.isEmpty()) return emptyList()
val candidates = LinkedHashSet<String>()
fun addCandidate(partList: List<String>) {
if (partList.isNotEmpty()) {
candidates.add(partList.joinToString("-"))
}
}
addCandidate(parts)
val withoutTextVs = parts.filterNot { it == "fe0e" }
addCandidate(withoutTextVs)
val withoutAnyVs = withoutTextVs.filterNot { it == "fe0f" }
addCandidate(withoutAnyVs)
if (parts.any { it == "fe0e" }) {
addCandidate(parts.map { if (it == "fe0e") "fe0f" else it })
}
val keycapNoVs = withoutAnyVs.toMutableList()
val keycapIndex = keycapNoVs.indexOf("20e3")
if (keycapIndex > 0 && keycapNoVs.getOrNull(keycapIndex - 1) != "fe0f") {
keycapNoVs.add(keycapIndex, "fe0f")
addCandidate(keycapNoVs)
}
if (!parts.contains("fe0f")) {
if (withoutAnyVs.size == 1) {
addCandidate(listOf(withoutAnyVs.first(), "fe0f"))
}
if (withoutAnyVs.size > 1 && withoutAnyVs[0] != "200d" && withoutAnyVs[1] != "200d") {
val withVsAfterFirst = withoutAnyVs.toMutableList().apply { add(1, "fe0f") }
addCandidate(withVsAfterFirst)
}
}
return candidates.toList()
}
private fun isOverlapping(start: Int, end: Int, occupiedRanges: List<IntRange>): Boolean =
occupiedRanges.any { range ->
start < (range.last + 1) && end > range.first
}
}
private class TelegramLikeEmojiSpan( private class TelegramLikeEmojiSpan(
emojiDrawable: Drawable, emojiDrawable: Drawable,
private var sourceFontMetrics: Paint.FontMetricsInt? private var sourceFontMetrics: Paint.FontMetricsInt?
@@ -204,31 +360,29 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
val cursorPosition = selectionStart val cursorPosition = selectionStart
// 🔥 Собираем все позиции эмодзи (и Unicode, и :emoji_code:) // 🔥 Собираем все позиции эмодзи (и Unicode, и :emoji_code:)
data class EmojiMatch(val start: Int, val end: Int, val unified: String, val isCodeFormat: Boolean) data class EmojiMatch(val start: Int, val end: Int, val unified: String)
val emojiMatches = mutableListOf<EmojiMatch>() val emojiMatches = mutableListOf<EmojiMatch>()
// 1. Ищем :emoji_XXXX: формат // 1. Ищем :emoji_XXXX: формат
val codeMatcher = EMOJI_CODE_PATTERN.matcher(textStr) val codeMatcher = EMOJI_CODE_PATTERN.matcher(textStr)
while (codeMatcher.find()) { while (codeMatcher.find()) {
val unified = codeMatcher.group(1) ?: continue val rawCode = codeMatcher.group(1) ?: continue
emojiMatches.add(EmojiMatch(codeMatcher.start(), codeMatcher.end(), unified, true)) val unified =
AppleEmojiAssetResolver.resolveUnifiedFromCode(context, rawCode)
?: AppleEmojiAssetResolver.normalizeUnifiedCode(rawCode)
emojiMatches.add(EmojiMatch(codeMatcher.start(), codeMatcher.end(), unified))
} }
// 2. Ищем реальные Unicode эмодзи // 2. Ищем реальные Unicode эмодзи (графемные кластеры, включая ZWJ/flags/skin tones)
val matcher = EMOJI_PATTERN.matcher(textStr) val occupiedRanges = emojiMatches.map { it.start until it.end }
while (matcher.find()) { val unicodeMatches =
val emoji = matcher.group() AppleEmojiAssetResolver.collectUnicodeMatches(
val start = matcher.start() context = context,
val end = matcher.end() text = textStr,
occupiedRanges = occupiedRanges
// Проверяем что этот диапазон не перекрывается с :emoji_XXXX: )
val overlaps = emojiMatches.any { unicodeMatches.forEach { match ->
(start >= it.start && start < it.end) || emojiMatches.add(EmojiMatch(match.start, match.end, match.unified))
(end > it.start && end <= it.end)
}
if (!overlaps) {
emojiMatches.add(EmojiMatch(start, end, emojiToUnified(emoji), false))
}
} }
// 3. Обрабатываем все найденные эмодзи // 3. Обрабатываем все найденные эмодзи
@@ -287,19 +441,6 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
} }
} }
private fun emojiToUnified(emoji: String): String {
val codePoints = emoji.codePoints().toArray()
if (codePoints.isEmpty()) return ""
val unifiedParts = ArrayList<String>(codePoints.size)
for (codePoint in codePoints) {
if (codePoint != 0xFE0F) {
unifiedParts.add(String.format("%04x", codePoint))
}
}
return unifiedParts.joinToString("-")
}
} }
/** /**
@@ -662,29 +803,48 @@ class AppleEmojiTextView @JvmOverloads constructor(
val spannable = SpannableStringBuilder(text) val spannable = SpannableStringBuilder(text)
// Собираем все замены (чтобы не сбить индексы) // Собираем все замены (чтобы не сбить индексы)
data class EmojiMatch(val start: Int, val end: Int, val unified: String) data class EmojiMatch(
val start: Int,
val end: Int,
val unified: String,
val isCodeFormat: Boolean
)
val emojiMatches = mutableListOf<EmojiMatch>() val emojiMatches = mutableListOf<EmojiMatch>()
// 1. Ищем :emoji_XXXX: формат // 1. Ищем :emoji_XXXX: формат
val codeMatcher = EMOJI_CODE_PATTERN.matcher(text) val codeMatcher = EMOJI_CODE_PATTERN.matcher(text)
while (codeMatcher.find()) { while (codeMatcher.find()) {
val unified = codeMatcher.group(1) ?: continue val rawCode = codeMatcher.group(1) ?: continue
emojiMatches.add(EmojiMatch(codeMatcher.start(), codeMatcher.end(), unified)) val unified =
AppleEmojiAssetResolver.resolveUnifiedFromCode(context, rawCode)
?: AppleEmojiAssetResolver.normalizeUnifiedCode(rawCode)
emojiMatches.add(
EmojiMatch(
start = codeMatcher.start(),
end = codeMatcher.end(),
unified = unified,
isCodeFormat = true
)
)
} }
// 2. Ищем реальные Unicode эмодзи // 2. Ищем реальные Unicode эмодзи (включая составные iOS-кластеры)
val unicodeMatcher = EMOJI_PATTERN.matcher(text) val occupiedRanges = emojiMatches.map { it.start until it.end }
while (unicodeMatcher.find()) { val unicodeMatches =
val emoji = unicodeMatcher.group() AppleEmojiAssetResolver.collectUnicodeMatches(
val unified = emojiToUnified(emoji) context = context,
// Проверяем что этот диапазон не перекрывается с :emoji_XXXX: text = text,
val overlaps = emojiMatches.any { occupiedRanges = occupiedRanges
(unicodeMatcher.start() >= it.start && unicodeMatcher.start() < it.end) || )
(unicodeMatcher.end() > it.start && unicodeMatcher.end() <= it.end) unicodeMatches.forEach { match ->
} emojiMatches.add(
if (!overlaps) { EmojiMatch(
emojiMatches.add(EmojiMatch(unicodeMatcher.start(), unicodeMatcher.end(), unified)) start = match.start,
} end = match.end,
unified = match.unified,
isCodeFormat = false
)
)
} }
// 3. Сортируем по позиции в обратном порядке (чтобы не сбить индексы при замене) // 3. Сортируем по позиции в обратном порядке (чтобы не сбить индексы при замене)
@@ -699,8 +859,7 @@ class AppleEmojiTextView @JvmOverloads constructor(
// Для :emoji_XXXX: заменяем весь текст на пробел + span // Для :emoji_XXXX: заменяем весь текст на пробел + span
// Для Unicode эмодзи оставляем символ как есть // Для Unicode эмодзи оставляем символ как есть
if (match.end - match.start > 10) { if (match.isCodeFormat) {
// Это :emoji_XXXX: формат - заменяем на один символ
spannable.replace(match.start, match.end, "\u200B") // Zero-width space spannable.replace(match.start, match.end, "\u200B") // Zero-width space
spannable.setSpan(span, match.start, match.start + 1, spannable.setSpan(span, match.start, match.start + 1,
android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
@@ -709,7 +868,7 @@ class AppleEmojiTextView @JvmOverloads constructor(
spannable.setSpan(span, match.start, match.end, spannable.setSpan(span, match.start, match.end,
android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
} }
} else if (match.end - match.start > 10) { } else if (match.isCodeFormat) {
// 🔥 Fallback: если PNG не найден, конвертируем :emoji_XXXX: в Unicode эмодзи // 🔥 Fallback: если PNG не найден, конвертируем :emoji_XXXX: в Unicode эмодзи
val unicodeEmoji = unifiedToEmoji(match.unified) val unicodeEmoji = unifiedToEmoji(match.unified)
if (unicodeEmoji != null) { if (unicodeEmoji != null) {
@@ -867,20 +1026,6 @@ class AppleEmojiTextView @JvmOverloads constructor(
} }
} }
private fun emojiToUnified(emoji: String): String {
val codePoints = emoji.codePoints().toArray()
if (codePoints.isEmpty()) return ""
val unifiedParts = ArrayList<String>(codePoints.size)
for (codePoint in codePoints) {
if (codePoint != 0xFE0F) {
unifiedParts.add(String.format("%04x", codePoint))
}
}
return unifiedParts.joinToString("-")
}
/** /**
* 🔥 Конвертирует unified код (1f600) в Unicode эмодзи (😀) * 🔥 Конвертирует unified код (1f600) в Unicode эмодзи (😀)
*/ */

View File

@@ -3,7 +3,7 @@ package com.rosetta.messenger.ui.components.metaball
import android.graphics.ColorMatrixColorFilter import android.graphics.ColorMatrixColorFilter
import android.graphics.Path import android.graphics.Path
import android.graphics.RectF import android.graphics.RectF
import android.util.Log
import android.graphics.RenderEffect import android.graphics.RenderEffect
import android.graphics.Shader import android.graphics.Shader
import android.os.Build import android.os.Build
@@ -11,13 +11,10 @@ import android.view.Gravity
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.compose.foundation.Canvas import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
@@ -25,13 +22,11 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -48,10 +43,10 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlin.math.abs import kotlin.math.abs
import kotlin.math.max import kotlin.math.max
import kotlin.math.min import kotlin.math.min
@@ -410,17 +405,8 @@ fun ProfileMetaballOverlay(
NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view) NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view)
} }
// Only log in explicit debug mode to keep production scroll clean.
val debugLogsEnabled = MetaballDebug.forceMode != null || MetaballDebug.forceNoNotch
LaunchedEffect(debugLogsEnabled, notchInfo, screenWidthPx, statusBarHeightPx, headerHeightPx) {
if (debugLogsEnabled) {
Log.d("ProfileMetaball", "NotchInfo: gravity=${notchInfo?.gravity}, isCircle=${notchInfo?.isLikelyCircle}, bounds=${notchInfo?.bounds}, raw=${notchInfo?.rawPath}")
Log.d("ProfileMetaball", "Screen: width=${screenWidthPx}px, statusBar=${statusBarHeightPx}px, header=${headerHeightPx}px")
}
}
val hasCenteredNotch = remember(notchInfo, screenWidthPx) { val hasCenteredNotch = remember(notchInfo, screenWidthPx) {
!MetaballDebug.forceNoNotch && isCenteredTopCutout(notchInfo, screenWidthPx) isCenteredTopCutout(notchInfo, screenWidthPx)
} }
// Calculate notch radius based on real device notch (like Telegram's ProfileGooeyView) // Calculate notch radius based on real device notch (like Telegram's ProfileGooeyView)
@@ -900,7 +886,7 @@ fun ProfileMetaballOverlayCpu(
NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view) NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view)
} }
val hasRealNotch = remember(notchInfo, screenWidthPx) { val hasRealNotch = remember(notchInfo, screenWidthPx) {
!MetaballDebug.forceNoNotch && isCenteredTopCutout(notchInfo, screenWidthPx) isCenteredTopCutout(notchInfo, screenWidthPx)
} }
val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() } val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() }
@@ -1162,153 +1148,6 @@ fun ProfileMetaballOverlayCpu(
} }
} }
/**
* DEBUG: Temporary toggle to force a specific rendering path.
* Set forceMode to test different paths on your device:
* - null: auto-detect (default production behavior)
* - "gpu": force GPU path (requires API 31+)
* - "cpu": force CPU bitmap path
* - "compat": force compat/noop path
*
* Set forceNoNotch = true to simulate no-notch device (black bar fallback).
*
* TODO: Remove before release!
*/
object MetaballDebug {
var forceMode: String? = null // "gpu", "cpu", "compat", or null
var forceNoNotch: Boolean = false // true = pretend no notch exists
}
/**
* DEBUG: Floating panel with buttons to switch metaball rendering path.
* Place inside a Box (e.g. profile header) — it aligns to bottom-center.
* TODO: Remove before release!
*/
@Composable
fun MetaballDebugPanel(modifier: Modifier = Modifier) {
var currentMode by remember { mutableStateOf(MetaballDebug.forceMode) }
var noNotch by remember { mutableStateOf(MetaballDebug.forceNoNotch) }
val context = LocalContext.current
val perfClass = remember { DevicePerformanceClass.get(context) }
Column(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.background(
ComposeColor.Black.copy(alpha = 0.75f),
RoundedCornerShape(12.dp)
)
.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Title
Text(
text = "Metaball Debug | API ${Build.VERSION.SDK_INT} | $perfClass",
color = ComposeColor.White,
fontSize = 11.sp,
fontWeight = FontWeight.Bold
)
// Mode buttons row
Row(
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth()
) {
val modes = listOf(null to "Auto", "gpu" to "GPU", "cpu" to "CPU", "compat" to "Compat")
modes.forEach { (mode, label) ->
val isSelected = currentMode == mode
Box(
modifier = Modifier
.weight(1f)
.clip(RoundedCornerShape(8.dp))
.background(
if (isSelected) ComposeColor(0xFF4CAF50) else ComposeColor.White.copy(alpha = 0.15f)
)
.border(
width = 1.dp,
color = if (isSelected) ComposeColor(0xFF4CAF50) else ComposeColor.White.copy(alpha = 0.3f),
shape = RoundedCornerShape(8.dp)
)
.clickable {
MetaballDebug.forceMode = mode
currentMode = mode
}
.padding(vertical = 8.dp),
contentAlignment = Alignment.Center
) {
Text(
text = label,
color = ComposeColor.White,
fontSize = 12.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
)
}
}
}
// No-notch toggle
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = "Force no-notch (black bar)",
color = ComposeColor.White,
fontSize = 12.sp
)
Switch(
checked = noNotch,
onCheckedChange = {
MetaballDebug.forceNoNotch = it
noNotch = it
},
colors = SwitchDefaults.colors(
checkedThumbColor = ComposeColor(0xFF4CAF50),
checkedTrackColor = ComposeColor(0xFF4CAF50).copy(alpha = 0.5f)
)
)
}
// Current active path info
val activePath = when (currentMode) {
"gpu" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) "GPU (forced)" else "GPU needs API 31!"
"cpu" -> "CPU (forced)"
"compat" -> "Compat (forced)"
else -> when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> "GPU (auto)"
else -> "CPU (auto)"
}
}
Text(
text = "Active: $activePath" + if (noNotch) " + no-notch" else "",
color = ComposeColor(0xFF4CAF50),
fontSize = 11.sp,
fontWeight = FontWeight.Medium
)
// Notch detection info
val view = LocalView.current
val notchRes = remember { NotchInfoUtils.getInfo(context) }
val notchCutout = remember(view) { NotchInfoUtils.getInfoFromCutout(view) }
val notchSource = when {
notchRes != null -> "resource"
notchCutout != null -> "DisplayCutout"
else -> "NONE"
}
val activeNotch = notchRes ?: notchCutout
Text(
text = "Notch: $notchSource" +
if (activeNotch != null) " | ${activeNotch.bounds.width().toInt()}x${activeNotch.bounds.height().toInt()}" +
" circle=${activeNotch.isLikelyCircle}" else " (black bar fallback!)",
color = if (activeNotch != null) ComposeColor(0xFF4CAF50) else ComposeColor(0xFFFF5722),
fontSize = 10.sp
)
}
}
/** /**
* Auto-selecting wrapper — 3-tier architecture matching Telegram's ProfileGooeyView: * Auto-selecting wrapper — 3-tier architecture matching Telegram's ProfileGooeyView:
* 1. GPU path (Android 12+, average+ performance): RenderEffect blur + ColorMatrixColorFilter * 1. GPU path (Android 12+, average+ performance): RenderEffect blur + ColorMatrixColorFilter
@@ -1329,36 +1168,9 @@ fun ProfileMetaballEffect(
val context = LocalContext.current val context = LocalContext.current
val performanceClass = remember { DevicePerformanceClass.get(context) } val performanceClass = remember { DevicePerformanceClass.get(context) }
// Debug: log which path is selected
val selectedPath = when (MetaballDebug.forceMode) {
"gpu" -> "GPU (forced)"
"cpu" -> "CPU (forced)"
"compat" -> "Compat (forced)"
else -> when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> "GPU (auto)"
else -> "CPU (auto)"
}
}
val debugLogsEnabled = MetaballDebug.forceMode != null || MetaballDebug.forceNoNotch
LaunchedEffect(selectedPath, debugLogsEnabled, performanceClass) {
if (debugLogsEnabled) {
Log.d("MetaballDebug", "Rendering path: $selectedPath, forceNoNotch: ${MetaballDebug.forceNoNotch}, perf: $performanceClass, API: ${Build.VERSION.SDK_INT}")
}
}
// Resolve actual mode // Resolve actual mode
val useGpu = when (MetaballDebug.forceMode) { val useGpu = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
"gpu" -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S // still need API 31 val useCpu = !useGpu
"cpu" -> false
"compat" -> false
else -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
}
val useCpu = when (MetaballDebug.forceMode) {
"gpu" -> false
"cpu" -> true
"compat" -> false
else -> !useGpu
}
when { when {
useGpu -> { useGpu -> {

View File

@@ -1,5 +1,6 @@
package com.rosetta.messenger.ui.crashlogs package com.rosetta.messenger.ui.crashlogs
import android.widget.Toast
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
@@ -8,13 +9,16 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.BugReport import androidx.compose.material.icons.filled.BugReport
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Share import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@@ -263,6 +267,8 @@ private fun CrashDetailScreen(
onDelete: () -> Unit onDelete: () -> Unit
) { ) {
var showDeleteDialog by remember { mutableStateOf(false) } var showDeleteDialog by remember { mutableStateOf(false) }
val clipboardManager = LocalClipboardManager.current
val context = LocalContext.current
Scaffold( Scaffold(
topBar = { topBar = {
@@ -274,6 +280,14 @@ private fun CrashDetailScreen(
} }
}, },
actions = { actions = {
IconButton(
onClick = {
clipboardManager.setText(AnnotatedString(crashReport.content))
Toast.makeText(context, "Full log copied", Toast.LENGTH_SHORT).show()
}
) {
Icon(Icons.Default.ContentCopy, contentDescription = "Copy Full Log")
}
IconButton(onClick = { /* TODO: Share */ }) { IconButton(onClick = { /* TODO: Share */ }) {
Icon(Icons.Default.Share, contentDescription = "Share") Icon(Icons.Default.Share, contentDescription = "Share")
} }

View File

@@ -1939,6 +1939,13 @@ private fun CollapsingOtherProfileHeader(
overlayColors = com.rosetta.messenger.ui.settings.BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId), overlayColors = com.rosetta.messenger.ui.settings.BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId),
isDarkTheme = isDarkTheme isDarkTheme = isDarkTheme
) )
// Slightly deepen avatar blur in other profile so text/icons stay readable.
Box(
modifier =
Modifier.matchParentSize().background(
Color.Black.copy(alpha = if (isDarkTheme) 0.12f else 0.04f)
)
)
} }
} }

View File

@@ -44,8 +44,8 @@ class CrashReportManager private constructor(private val context: Context) : Thr
fun getCrashReports(context: Context): List<CrashReport> { fun getCrashReports(context: Context): List<CrashReport> {
val crashDir = File(context.filesDir, CRASH_DIR) val crashDir = File(context.filesDir, CRASH_DIR)
if (!crashDir.exists()) return emptyList() if (!crashDir.exists()) return emptyList()
return crashDir.listFiles() val reports = crashDir.listFiles()
?.filter { it.extension == "txt" } ?.filter { it.extension == "txt" }
?.sortedByDescending { it.lastModified() } ?.sortedByDescending { it.lastModified() }
?.map { file -> ?.map { file ->
@@ -54,7 +54,21 @@ class CrashReportManager private constructor(private val context: Context) : Thr
timestamp = file.lastModified(), timestamp = file.lastModified(),
content = file.readText() content = file.readText()
) )
} ?: emptyList() }?.toMutableList() ?: mutableListOf()
// Include native crash report if present
val nativeCrash = File(crashDir, "native_crash.txt")
if (nativeCrash.exists() && nativeCrash.length() > 0) {
reports.add(0, CrashReport(
fileName = "native_crash.txt",
timestamp = nativeCrash.lastModified(),
content = nativeCrash.readText()
))
// Delete after reading so it doesn't show up again
nativeCrash.delete()
}
return reports
} }
/** /**

View File

@@ -13,10 +13,13 @@ import com.rosetta.messenger.network.ProtocolManager
*/ */
object MessageLogger { object MessageLogger {
private const val TAG = "RosettaMsg" private const val TAG = "RosettaMsg"
// Всегда включён — вывод идёт только в ProtocolManager.addLog() (in-memory UI), @Volatile
// не в logcat, безопасно для release private var isEnabled: Boolean = false
private val isEnabled: Boolean = true
fun setEnabled(enabled: Boolean) {
isEnabled = enabled
}
/** /**
* Добавить лог в UI (Debug Logs в чате) * Добавить лог в UI (Debug Logs в чате)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -0,0 +1,82 @@
# Custom WebRTC for Rosetta Android (Audio E2EE Timestamp)
This setup builds a custom `libwebrtc.aar` for Android and patches audio E2EE so
`FrameEncryptor/FrameDecryptor` receive non-empty `additional_data` with RTP timestamp bytes.
## Why
Stock `io.github.webrtc-sdk:android:125.6422.07` can call audio frame encryptor with empty
`additional_data` (`ad=0`), so nonce derivation based on timestamp is unavailable.
Desktop uses frame timestamp for nonce. This patch aligns Android with that approach by passing
an 8-byte big-endian timestamp payload in `additional_data` (absolute RTP timestamp,
including sender start offset):
- bytes `0..3` = `0`
- bytes `4..7` = RTP timestamp (big-endian)
## Files
- `build_custom_webrtc.sh` — reproducible build script
- `patches/0001-audio-e2ee-pass-rtp-timestamp-as-additional-data.patch` — WebRTC patch
- `patches/0002-android-build-on-mac-host.patch` — allows Android target build on macOS host
- `patches/0003-macos-host-java-ijar.patch` — enables host tools (`ijar`/`jdk`) on macOS
- `patches/0004-macos-linker-missing-L-dirs.patch` — skips invalid host `-L...` paths for lld
- `patches/0005-macos-server-utils-socket.patch` — handles macOS socket errno in Android Java compile helper
## Build
Recommended on Linux (macOS is supported via additional patches in this folder).
Bootstrap `depot_tools` first:
```bash
cd /path/to/rosetta-android/tools/webrtc-custom
./bootstrap_depot_tools.sh
```
Then run:
```bash
cd /path/to/rosetta-android/tools/webrtc-custom
./build_custom_webrtc.sh
```
Optional env vars:
- `WEBRTC_ROOT` — checkout root (default: `$HOME/webrtc_android`)
- `WEBRTC_SRC` — direct path to `src/`
- `WEBRTC_BRANCH` — default `branch-heads/6422`
- `WEBRTC_TAG` — use a specific tag/commit instead of branch
- `OUT_AAR` — output AAR path (default: `app/libs/libwebrtc-custom.aar`)
- `SYNC_JOBS``gclient sync` jobs (default: `1`, safer for googlesource limits)
- `SYNC_RETRIES` — sync retry attempts (default: `8`)
- `SYNC_RETRY_BASE_SEC` — base retry delay in seconds (default: `20`)
- `MAC_ANDROID_NDK_ROOT` — local Android NDK path on macOS (default: `~/Library/Android/sdk/ndk/27.1.12297006`)
## Troubleshooting (HTTP 429 / RESOURCE_EXHAUSTED)
If build fails with:
- `The requested URL returned error: 429`
- `RESOURCE_EXHAUSTED`
- `Short term server-time rate limit exceeded`
run with conservative sync settings:
```bash
SYNC_JOBS=1 SYNC_RETRIES=12 SYNC_RETRY_BASE_SEC=30 ./build_custom_webrtc.sh
```
The script now retries `fetch`, `git fetch`, and `gclient sync` with backoff.
## Integration in app
`app/build.gradle.kts` already prefers local `app/libs/libwebrtc-custom.aar` if present.
If file exists, Maven WebRTC dependency is not used.
## Maintenance policy
- Keep patch small and isolated to `audio/channel_send.cc` + `audio/channel_receive.cc`.
- Pin WebRTC branch/tag for releases.
- Rebuild AAR on version bumps and verify `e2ee_diag.txt` shows `ad=8` (or non-zero).

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
DEPOT_TOOLS_DIR="${DEPOT_TOOLS_DIR:-$HOME/depot_tools}"
if [[ ! -d "${DEPOT_TOOLS_DIR}/.git" ]]; then
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git "${DEPOT_TOOLS_DIR}"
fi
echo
echo "depot_tools ready: ${DEPOT_TOOLS_DIR}"
echo "Add to PATH in your shell profile:"
echo " export PATH=\"${DEPOT_TOOLS_DIR}:\$PATH\""

View File

@@ -0,0 +1,202 @@
#!/usr/bin/env bash
set -euo pipefail
# Reproducible custom WebRTC AAR build for Rosetta Android.
# Requirements:
# - Linux machine
# - depot_tools in PATH
# - python3, git
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROSETTA_ANDROID_DIR="$(cd "${SCRIPT_DIR}/../.." && pwd)"
PATCH_FILES=(
"${SCRIPT_DIR}/patches/0001-audio-e2ee-pass-rtp-timestamp-as-additional-data.patch"
"${SCRIPT_DIR}/patches/0002-android-build-on-mac-host.patch"
"${SCRIPT_DIR}/patches/0003-macos-host-java-ijar.patch"
"${SCRIPT_DIR}/patches/0004-macos-linker-missing-L-dirs.patch"
"${SCRIPT_DIR}/patches/0005-macos-server-utils-socket.patch"
)
# Default target: WebRTC M125 family used by app dependency 125.6422.07.
WEBRTC_BRANCH="${WEBRTC_BRANCH:-branch-heads/6422}"
WEBRTC_TAG="${WEBRTC_TAG:-}"
# Source checkout root (contains src/)
WEBRTC_ROOT="${WEBRTC_ROOT:-$HOME/webrtc_android}"
WEBRTC_SRC="${WEBRTC_SRC:-${WEBRTC_ROOT}/src}"
# Output AAR consumed by app/build.gradle.kts.
OUT_AAR="${OUT_AAR:-${ROSETTA_ANDROID_DIR}/app/libs/libwebrtc-custom.aar}"
# Sync tuning to survive chromium.googlesource short-term 429 limits.
SYNC_JOBS="${SYNC_JOBS:-1}"
SYNC_RETRIES="${SYNC_RETRIES:-8}"
SYNC_RETRY_BASE_SEC="${SYNC_RETRY_BASE_SEC:-20}"
# Architectures used by the app.
ARCHS=("armeabi-v7a" "arm64-v8a" "x86_64")
echo "[webrtc-custom] root: ${WEBRTC_ROOT}"
echo "[webrtc-custom] src: ${WEBRTC_SRC}"
echo "[webrtc-custom] out: ${OUT_AAR}"
echo "[webrtc-custom] sync jobs: ${SYNC_JOBS}, retries: ${SYNC_RETRIES}"
# Keep depot_tools from auto-updating during long runs.
export DEPOT_TOOLS_UPDATE=0
retry_cmd() {
local max_attempts="$1"
shift
local attempt=1
local backoff="${SYNC_RETRY_BASE_SEC}"
while true; do
if "$@"; then
return 0
fi
if (( attempt >= max_attempts )); then
return 1
fi
echo "[webrtc-custom] attempt ${attempt}/${max_attempts} failed, retrying in ${backoff}s: $*"
sleep "${backoff}"
backoff=$(( backoff * 2 ))
if (( backoff > 300 )); then
backoff=300
fi
attempt=$(( attempt + 1 ))
done
}
sync_with_retry() {
local attempt=1
while true; do
# Heal known broken checkout state after interrupted/failed gclient runs.
if [[ -d "${WEBRTC_SRC}/third_party/libjpeg_turbo/.git" ]]; then
git -C "${WEBRTC_SRC}/third_party/libjpeg_turbo" reset --hard >/dev/null 2>&1 || true
git -C "${WEBRTC_SRC}/third_party/libjpeg_turbo" clean -fd >/dev/null 2>&1 || true
fi
if [[ -d "${WEBRTC_ROOT}/_bad_scm/src/third_party" ]]; then
find "${WEBRTC_ROOT}/_bad_scm/src/third_party" -maxdepth 1 -type d -name 'libjpeg_turbo*' -exec rm -rf {} + >/dev/null 2>&1 || true
fi
if gclient sync -D --jobs "${SYNC_JOBS}"; then
return 0
fi
if (( attempt >= SYNC_RETRIES )); then
echo "[webrtc-custom] ERROR: gclient sync failed after ${SYNC_RETRIES} attempts"
echo "[webrtc-custom] Tip: wait 10-15 min and rerun with lower burst:"
echo "[webrtc-custom] SYNC_JOBS=1 SYNC_RETRIES=12 ./build_custom_webrtc.sh"
return 1
fi
local wait_sec=$(( SYNC_RETRY_BASE_SEC * attempt ))
if (( wait_sec > 300 )); then
wait_sec=300
fi
echo "[webrtc-custom] gclient sync failed (attempt ${attempt}/${SYNC_RETRIES}), sleeping ${wait_sec}s..."
sleep "${wait_sec}"
attempt=$(( attempt + 1 ))
done
}
if ! command -v fetch >/dev/null 2>&1; then
echo "[webrtc-custom] ERROR: depot_tools 'fetch' not found in PATH"
exit 1
fi
if [[ ! -d "${WEBRTC_SRC}/.git" ]]; then
echo "[webrtc-custom] checkout not found, fetching webrtc_android..."
mkdir -p "${WEBRTC_ROOT}"
pushd "${WEBRTC_ROOT}" >/dev/null
retry_cmd "${SYNC_RETRIES}" fetch --nohooks --no-history webrtc_android
sync_with_retry
popd >/dev/null
fi
pushd "${WEBRTC_SRC}" >/dev/null
echo "[webrtc-custom] syncing source..."
retry_cmd "${SYNC_RETRIES}" git fetch --all --tags
if [[ -n "${WEBRTC_TAG}" ]]; then
retry_cmd "${SYNC_RETRIES}" git checkout "${WEBRTC_TAG}"
else
if git show-ref --verify --quiet "refs/remotes/origin/${WEBRTC_BRANCH}"; then
retry_cmd "${SYNC_RETRIES}" git checkout -B "${WEBRTC_BRANCH}" "origin/${WEBRTC_BRANCH}"
else
retry_cmd "${SYNC_RETRIES}" git checkout "${WEBRTC_BRANCH}"
fi
if git rev-parse --abbrev-ref --symbolic-full-name '@{u}' >/dev/null 2>&1; then
retry_cmd "${SYNC_RETRIES}" git pull --ff-only
else
echo "[webrtc-custom] no upstream for current branch, skipping git pull"
fi
fi
sync_with_retry
echo "[webrtc-custom] applying Rosetta patch..."
git reset --hard
for patch in "${PATCH_FILES[@]}"; do
echo "[webrtc-custom] apply $(basename "${patch}")"
git apply --check "${patch}"
git apply "${patch}"
done
# macOS host tweaks:
# - point third_party/jdk/current to local JDK
# - use locally installed Android NDK (darwin toolchain)
if [[ "$(uname -s)" == "Darwin" ]]; then
if [[ -z "${JAVA_HOME:-}" ]]; then
JAVA_HOME="$(/usr/libexec/java_home 2>/dev/null || true)"
fi
if [[ -z "${JAVA_HOME:-}" || ! -d "${JAVA_HOME}" ]]; then
echo "[webrtc-custom] ERROR: JAVA_HOME not found on macOS"
exit 1
fi
JAVA_HOME_CANDIDATE="${JAVA_HOME}"
if [[ ! -f "${JAVA_HOME_CANDIDATE}/conf/logging.properties" ]] && [[ -d "${JAVA_HOME_CANDIDATE}/libexec/openjdk.jdk/Contents/Home" ]]; then
JAVA_HOME_CANDIDATE="${JAVA_HOME_CANDIDATE}/libexec/openjdk.jdk/Contents/Home"
fi
if [[ ! -f "${JAVA_HOME_CANDIDATE}/conf/logging.properties" ]]; then
echo "[webrtc-custom] ERROR: invalid JAVA_HOME (conf/logging.properties not found): ${JAVA_HOME}"
exit 1
fi
JAVA_HOME="${JAVA_HOME_CANDIDATE}"
ln -sfn "${JAVA_HOME}" "${WEBRTC_SRC}/third_party/jdk/current"
echo "[webrtc-custom] macOS JDK linked: ${WEBRTC_SRC}/third_party/jdk/current -> ${JAVA_HOME}"
fi
mkdir -p "$(dirname "${OUT_AAR}")"
echo "[webrtc-custom] building AAR (this can take a while)..."
GN_ARGS=(
is_debug=false
is_component_build=false
rtc_include_tests=false
rtc_build_examples=false
)
if [[ "$(uname -s)" == "Darwin" ]]; then
MAC_ANDROID_NDK_ROOT="${MAC_ANDROID_NDK_ROOT:-$HOME/Library/Android/sdk/ndk/27.1.12297006}"
if [[ ! -d "${MAC_ANDROID_NDK_ROOT}" ]]; then
echo "[webrtc-custom] ERROR: Android NDK not found at ${MAC_ANDROID_NDK_ROOT}"
echo "[webrtc-custom] Set MAC_ANDROID_NDK_ROOT to your local NDK path."
exit 1
fi
GN_ARGS+=("android_ndk_root=\"${MAC_ANDROID_NDK_ROOT}\"")
GN_ARGS+=("android_ndk_version=\"27.1.12297006\"")
echo "[webrtc-custom] macOS Android NDK: ${MAC_ANDROID_NDK_ROOT}"
fi
python3 tools_webrtc/android/build_aar.py \
--build-dir out_rosetta_aar \
--output "${OUT_AAR}" \
--arch "${ARCHS[@]}" \
--extra-gn-args "${GN_ARGS[@]}"
echo "[webrtc-custom] done"
echo "[webrtc-custom] AAR: ${OUT_AAR}"
popd >/dev/null

View File

@@ -0,0 +1,56 @@
diff --git a/audio/channel_receive.cc b/audio/channel_receive.cc
index 17cf859ed8..b9d9ab14c8 100644
--- a/audio/channel_receive.cc
+++ b/audio/channel_receive.cc
@@ -693,10 +693,20 @@ void ChannelReceive::ReceivePacket(const uint8_t* packet,
const std::vector<uint32_t> csrcs(header.arrOfCSRCs,
header.arrOfCSRCs + header.numCSRCs);
+ const uint8_t additional_data_bytes[8] = {
+ 0,
+ 0,
+ 0,
+ 0,
+ static_cast<uint8_t>((header.timestamp >> 24) & 0xff),
+ static_cast<uint8_t>((header.timestamp >> 16) & 0xff),
+ static_cast<uint8_t>((header.timestamp >> 8) & 0xff),
+ static_cast<uint8_t>(header.timestamp & 0xff),
+ };
const FrameDecryptorInterface::Result decrypt_result =
frame_decryptor_->Decrypt(
cricket::MEDIA_TYPE_AUDIO, csrcs,
- /*additional_data=*/nullptr,
+ /*additional_data=*/additional_data_bytes,
rtc::ArrayView<const uint8_t>(payload, payload_data_length),
decrypted_audio_payload);
diff --git a/audio/channel_send.cc b/audio/channel_send.cc
index 4a2700177b..7ebb501704 100644
--- a/audio/channel_send.cc
+++ b/audio/channel_send.cc
@@ -320,10 +320,23 @@ int32_t ChannelSend::SendRtpAudio(AudioFrameType frameType,
// Encrypt the audio payload into the buffer.
size_t bytes_written = 0;
+ const uint32_t additional_data_timestamp =
+ rtp_timestamp_without_offset + rtp_rtcp_->StartTimestamp();
+ const uint8_t additional_data_bytes[8] = {
+ 0,
+ 0,
+ 0,
+ 0,
+ static_cast<uint8_t>((additional_data_timestamp >> 24) & 0xff),
+ static_cast<uint8_t>((additional_data_timestamp >> 16) & 0xff),
+ static_cast<uint8_t>((additional_data_timestamp >> 8) & 0xff),
+ static_cast<uint8_t>(additional_data_timestamp & 0xff),
+ };
+
int encrypt_status = frame_encryptor_->Encrypt(
cricket::MEDIA_TYPE_AUDIO, rtp_rtcp_->SSRC(),
- /*additional_data=*/nullptr, payload, encrypted_audio_payload,
- &bytes_written);
+ /*additional_data=*/additional_data_bytes, payload,
+ encrypted_audio_payload, &bytes_written);
if (encrypt_status != 0) {
RTC_DLOG(LS_ERROR)
<< "Channel::SendData() failed encrypt audio payload: "

View File

@@ -0,0 +1,27 @@
diff --git a/build/config/BUILDCONFIG.gn b/build/config/BUILDCONFIG.gn
index 26fad5adf..7a614f334 100644
--- a/build/config/BUILDCONFIG.gn
+++ b/build/config/BUILDCONFIG.gn
@@ -239,7 +239,8 @@ if (host_toolchain == "") {
_default_toolchain = ""
if (target_os == "android") {
- assert(host_os == "linux", "Android builds are only supported on Linux.")
+ assert(host_os == "linux" || host_os == "mac",
+ "Android builds are only supported on Linux/macOS.")
_default_toolchain = "//build/toolchain/android:android_clang_$target_cpu"
} else if (target_os == "chromeos" || target_os == "linux") {
# See comments in build/toolchain/cros/BUILD.gn about board compiles.
diff --git a/build/config/android/config.gni b/build/config/android/config.gni
index 427739d70..6a5ab0594 100644
--- a/build/config/android/config.gni
+++ b/build/config/android/config.gni
@@ -327,7 +327,7 @@ if (is_android || is_chromeos) {
# Defines the name the Android build gives to the current host CPU
# architecture, which is different than the names GN uses.
- if (host_cpu == "x64") {
+ if (host_cpu == "x64" || host_cpu == "arm64") {
android_host_arch = "x86_64"
} else if (host_cpu == "x86") {
android_host_arch = "x86"

View File

@@ -0,0 +1,34 @@
diff --git a/third_party/ijar/BUILD.gn b/third_party/ijar/BUILD.gn
index 8dc9fe21cf8..49c50e6636f 100644
--- a/third_party/ijar/BUILD.gn
+++ b/third_party/ijar/BUILD.gn
@@ -4,7 +4,7 @@
# A tool that removes all non-interface-specific parts from a .jar file.
-if (is_linux || is_chromeos) {
+if (is_linux || is_chromeos || is_mac) {
config("ijar_compiler_flags") {
if (is_clang) {
cflags = [
diff --git a/third_party/jdk/BUILD.gn b/third_party/jdk/BUILD.gn
index e003eef94d7..ec49922942b 100644
--- a/third_party/jdk/BUILD.gn
+++ b/third_party/jdk/BUILD.gn
@@ -3,10 +3,12 @@
# found in the LICENSE file.
config("jdk") {
- include_dirs = [
- "current/include",
- "current/include/linux",
- ]
+ include_dirs = [ "current/include" ]
+ if (host_os == "mac") {
+ include_dirs += [ "current/include/darwin" ]
+ } else {
+ include_dirs += [ "current/include/linux" ]
+ }
}
group("java_data") {

View File

@@ -0,0 +1,102 @@
diff --git a/build/toolchain/apple/linker_driver.py b/build/toolchain/apple/linker_driver.py
index 0632230cf..798442534 100755
--- a/build/toolchain/apple/linker_driver.py
+++ b/build/toolchain/apple/linker_driver.py
@@ -7,6 +7,7 @@
import os
import os.path
import re
+import shlex
import shutil
import subprocess
import sys
@@ -113,6 +114,53 @@ class LinkerDriver(object):
# The temporary directory for intermediate LTO object files. If it
# exists, it will clean itself up on script exit.
self._object_path_lto = None
+ self._temp_rsp_files = []
+
+ def _sanitize_rsp_arg(self, arg):
+ if not arg.startswith('@'):
+ return arg
+ rsp_path = arg[1:]
+ if not os.path.isfile(rsp_path):
+ return arg
+
+ try:
+ with open(rsp_path, 'r', encoding='utf-8') as f:
+ rsp_content = f.read()
+ except OSError:
+ return arg
+
+ tokens = shlex.split(rsp_content, posix=True)
+ sanitized = []
+ changed = False
+ i = 0
+ while i < len(tokens):
+ tok = tokens[i]
+ if tok == '-L' and i + 1 < len(tokens):
+ lib_dir = tokens[i + 1]
+ if not os.path.isdir(lib_dir):
+ changed = True
+ i += 2
+ continue
+ elif tok.startswith('-L') and len(tok) > 2:
+ lib_dir = tok[2:]
+ if not os.path.isdir(lib_dir):
+ changed = True
+ i += 1
+ continue
+ sanitized.append(tok)
+ i += 1
+
+ if not changed:
+ return arg
+
+ fd, temp_path = tempfile.mkstemp(prefix='linker_driver_', suffix='.rsp')
+ os.close(fd)
+ with open(temp_path, 'w', encoding='utf-8') as f:
+ for tok in sanitized:
+ f.write(tok)
+ f.write('\n')
+ self._temp_rsp_files.append(temp_path)
+ return '@' + temp_path
def run(self):
"""Runs the linker driver, separating out the main compiler driver's
@@ -135,11 +183,25 @@ class LinkerDriver(object):
assert driver_action[0] not in linker_driver_actions
linker_driver_actions[driver_action[0]] = driver_action[1]
else:
+ if arg.startswith('@'):
+ arg = self._sanitize_rsp_arg(arg)
# TODO(crbug.com/1446796): On Apple, the linker command line
# produced by rustc for LTO includes these arguments, but the
# Apple linker doesn't accept them.
# Upstream bug: https://github.com/rust-lang/rust/issues/60059
BAD_RUSTC_ARGS = '-Wl,-plugin-opt=O[0-9],-plugin-opt=mcpu=.*'
+ if arg == '-Wl,-fatal_warnings':
+ # Some host link steps on Apple Silicon produce benign
+ # warnings from injected search paths (e.g. /usr/local/lib
+ # missing). Don't fail the whole build on those warnings.
+ continue
+ if arg.startswith('-L') and len(arg) > 2:
+ # Some environments inject non-existent library search
+ # paths (e.g. /usr/local/lib on Apple Silicon). lld treats
+ # them as hard errors, so skip missing -L entries.
+ lib_dir = arg[2:]
+ if not os.path.isdir(lib_dir):
+ continue
if not re.match(BAD_RUSTC_ARGS, arg):
compiler_driver_args.append(arg)
@@ -185,6 +247,9 @@ class LinkerDriver(object):
# Re-report the original failure.
raise
+ finally:
+ for path in self._temp_rsp_files:
+ _remove_path(path)
def _get_linker_output(self):
"""Returns the value of the output argument to the linker."""

View File

@@ -0,0 +1,15 @@
diff --git a/build/android/gyp/util/server_utils.py b/build/android/gyp/util/server_utils.py
index 6d5ed79d3..c05b57529 100644
--- a/build/android/gyp/util/server_utils.py
+++ b/build/android/gyp/util/server_utils.py
@@ -36,7 +36,9 @@ def MaybeRunCommand(name, argv, stamp_file, force):
except socket.error as e:
# [Errno 111] Connection refused. Either the server has not been started
# or the server is not currently accepting new connections.
- if e.errno == 111:
+ # [Errno 2] Abstract Unix sockets are unsupported on macOS, so treat
+ # this the same way (build server unavailable).
+ if e.errno in (111, 2):
if force:
raise RuntimeError(
'\n\nBuild server is not running and '