Compare commits
29 Commits
ce16802ac3
...
new-server
| Author | SHA1 | Date | |
|---|---|---|---|
| c9fa12a690 | |||
| ec541a2c0c | |||
| 454402938c | |||
| 83f6b49ba3 | |||
| b663450db5 | |||
| 9cca071bd8 | |||
| 0af4e6587e | |||
| 31db795c56 | |||
| 9202204094 | |||
| 03282eb478 | |||
| 3fffbd0392 | |||
| bc7efbfbd9 | |||
| eea650face | |||
| 530047c5d0 | |||
| 419101a4a9 | |||
| 9778e3b196 | |||
| 4664aa9482 | |||
| ebb95905b5 | |||
| f915333a44 | |||
| 69c0c377d1 | |||
| 30fbc41245 | |||
| 677a5f2ab2 | |||
| db55225d84 | |||
| 7a188a2dbc | |||
| a3973b616e | |||
| 3a595c02b3 | |||
| 8e743e710a | |||
| 8fdbfb4e5f | |||
| d90554aa9f |
@@ -41,6 +41,12 @@ jobs:
|
||||
export JAVA_HOME="$JAVA_DIR"
|
||||
echo "JAVA_HOME set to $JAVA_HOME"
|
||||
|
||||
- name: Cache Android SDK
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/android-sdk
|
||||
key: android-sdk-34
|
||||
|
||||
- name: Install Android SDK
|
||||
run: |
|
||||
export ANDROID_HOME="$HOME/android-sdk"
|
||||
@@ -65,6 +71,14 @@ jobs:
|
||||
echo "ANDROID_HOME=$ANDROID_HOME" >> $GITHUB_ENV
|
||||
echo "ANDROID_SDK_ROOT=$ANDROID_HOME" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache Gradle wrapper
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: |
|
||||
~/.gradle/wrapper/dists
|
||||
~/.gradle/caches
|
||||
key: gradle-wrapper-8.14.3
|
||||
|
||||
- name: Restore debug keystore
|
||||
run: |
|
||||
mkdir -p ~/.android
|
||||
@@ -76,10 +90,28 @@ jobs:
|
||||
- name: Setup Gradle wrapper
|
||||
run: |
|
||||
chmod +x ./gradlew
|
||||
./gradlew --version
|
||||
GRADLE_VERSION="8.14.3"
|
||||
GRADLE_DIST_DIR="$HOME/.gradle/wrapper/dists/gradle-${GRADLE_VERSION}-bin"
|
||||
|
||||
# Проверяем — если Gradle уже распакован в кэше, пропускаем скачивание
|
||||
if find "$GRADLE_DIST_DIR" -name "gradle-${GRADLE_VERSION}" -type d 2>/dev/null | grep -q .; then
|
||||
echo "Gradle ${GRADLE_VERSION} found in cache, skipping download"
|
||||
else
|
||||
echo "Gradle not found in cache, downloading..."
|
||||
mkdir -p /opt/gradle-download
|
||||
curl -fL --retry 3 --retry-delay 5 \
|
||||
"https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" \
|
||||
-o "/opt/gradle-download/gradle-${GRADLE_VERSION}-bin.zip"
|
||||
mkdir -p /opt/gradle
|
||||
unzip -q "/opt/gradle-download/gradle-${GRADLE_VERSION}-bin.zip" -d /opt/gradle
|
||||
export PATH="/opt/gradle/gradle-${GRADLE_VERSION}/bin:$PATH"
|
||||
echo "PATH=/opt/gradle/gradle-${GRADLE_VERSION}/bin:$PATH" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
./gradlew --no-daemon --version
|
||||
|
||||
- name: Build Release APK
|
||||
run: ./gradlew assembleRelease
|
||||
run: ./gradlew --no-daemon assembleRelease
|
||||
|
||||
- name: Check if APK exists
|
||||
run: |
|
||||
|
||||
@@ -23,8 +23,9 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// Rosetta versioning — bump here on each release
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
val rosettaVersionName = "1.2.7"
|
||||
val rosettaVersionCode = 29 // Increment on each release
|
||||
val rosettaVersionName = "1.3.2"
|
||||
val rosettaVersionCode = 34 // Increment on each release
|
||||
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
|
||||
|
||||
android {
|
||||
namespace = "com.rosetta.messenger"
|
||||
@@ -43,6 +44,19 @@ android {
|
||||
|
||||
// Optimize Lottie animations
|
||||
manifestPlaceholders["enableLottieOptimizations"] = "true"
|
||||
|
||||
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
|
||||
|
||||
externalNativeBuild {
|
||||
cmake { cppFlags("-std=c++17") }
|
||||
}
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path = file("src/main/cpp/CMakeLists.txt")
|
||||
version = "3.22.1"
|
||||
}
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
@@ -84,6 +98,10 @@ android {
|
||||
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
|
||||
jniLibs { useLegacyPackaging = true }
|
||||
}
|
||||
lint {
|
||||
checkReleaseBuilds = false
|
||||
abortOnError = false
|
||||
}
|
||||
|
||||
applicationVariants.all {
|
||||
outputs.all {
|
||||
@@ -165,6 +183,14 @@ dependencies {
|
||||
implementation("androidx.camera:camera-lifecycle:1.3.1")
|
||||
implementation("androidx.camera:camera-view:1.3.1")
|
||||
|
||||
// WebRTC for voice calls.
|
||||
// If app/libs/libwebrtc-custom.aar exists, prefer it (custom E2EE-enabled build).
|
||||
if (customWebRtcAar.exists()) {
|
||||
implementation(files(customWebRtcAar))
|
||||
} else {
|
||||
implementation("io.github.webrtc-sdk:android:125.6422.07")
|
||||
}
|
||||
|
||||
// Baseline Profiles for startup performance
|
||||
implementation("androidx.profileinstaller:profileinstaller:1.3.1")
|
||||
|
||||
|
||||
3158
app/libs/LICENSE.md
Normal file
3158
app/libs/LICENSE.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,11 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
|
||||
|
||||
33
app/src/main/cpp/CMakeLists.txt
Normal file
33
app/src/main/cpp/CMakeLists.txt
Normal file
@@ -0,0 +1,33 @@
|
||||
cmake_minimum_required(VERSION 3.22.1)
|
||||
project(rosetta_e2ee LANGUAGES C CXX)
|
||||
|
||||
set(CMAKE_C_STANDARD 11)
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
|
||||
add_library(rosetta_e2ee SHARED
|
||||
crypto.c
|
||||
rosetta_e2ee.cpp
|
||||
)
|
||||
|
||||
target_include_directories(rosetta_e2ee PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
|
||||
# Hide all C++ symbols to avoid ODR clashes with WebRTC's .so
|
||||
set_target_properties(rosetta_e2ee PROPERTIES
|
||||
CXX_VISIBILITY_PRESET hidden
|
||||
C_VISIBILITY_PRESET hidden
|
||||
)
|
||||
|
||||
# Match WebRTC SDK build flags:
|
||||
# -fno-rtti -fno-exceptions — standard WebRTC flags
|
||||
# -fexperimental-relative-c++-abi-vtables — WebRTC uses relative vtables
|
||||
# (32-bit offsets instead of 64-bit absolute pointers in vtable).
|
||||
# Without this, setFrameEncryptor crashes with SIGSEGV because WebRTC
|
||||
# reads our 64-bit pointers as 32-bit offsets.
|
||||
target_compile_options(rosetta_e2ee PRIVATE
|
||||
-fno-rtti
|
||||
-fno-exceptions
|
||||
-fexperimental-relative-c++-abi-vtables
|
||||
)
|
||||
|
||||
find_library(log-lib log)
|
||||
target_link_libraries(rosetta_e2ee ${log-lib})
|
||||
248
app/src/main/cpp/crypto.c
Normal file
248
app/src/main/cpp/crypto.c
Normal file
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Minimal crypto primitives for Rosetta E2EE:
|
||||
* - HSalsa20 (for nacl.box.before() compatible key exchange)
|
||||
* - HChaCha20 + ChaCha20-IETF → XChaCha20 (for frame encryption)
|
||||
*
|
||||
* Based on the public-domain algorithms by D.J. Bernstein.
|
||||
*/
|
||||
|
||||
#include "crypto.h"
|
||||
#include <string.h>
|
||||
|
||||
/* ── helpers ─────────────────────────────────────────────────── */
|
||||
|
||||
#define ROTL32(v, n) (((v) << (n)) | ((v) >> (32 - (n))))
|
||||
|
||||
static uint32_t load32_le(const uint8_t *p) {
|
||||
return (uint32_t)p[0]
|
||||
| (uint32_t)p[1] << 8
|
||||
| (uint32_t)p[2] << 16
|
||||
| (uint32_t)p[3] << 24;
|
||||
}
|
||||
|
||||
static void store32_le(uint8_t *p, uint32_t v) {
|
||||
p[0] = (uint8_t)(v);
|
||||
p[1] = (uint8_t)(v >> 8);
|
||||
p[2] = (uint8_t)(v >> 16);
|
||||
p[3] = (uint8_t)(v >> 24);
|
||||
}
|
||||
|
||||
/* "expand 32-byte k" as four little-endian uint32 */
|
||||
static const uint32_t SIGMA[4] = {
|
||||
0x61707865u, 0x3320646eu, 0x79622d32u, 0x6b206574u
|
||||
};
|
||||
|
||||
/* ── HSalsa20 (Salsa20 family) ──────────────────────────────── */
|
||||
|
||||
void rosetta_hsalsa20(uint8_t out[32],
|
||||
const uint8_t inp[16],
|
||||
const uint8_t key[32])
|
||||
{
|
||||
uint32_t x[16];
|
||||
x[ 0] = SIGMA[0];
|
||||
x[ 1] = load32_le(key + 0);
|
||||
x[ 2] = load32_le(key + 4);
|
||||
x[ 3] = load32_le(key + 8);
|
||||
x[ 4] = load32_le(key + 12);
|
||||
x[ 5] = SIGMA[1];
|
||||
x[ 6] = load32_le(inp + 0);
|
||||
x[ 7] = load32_le(inp + 4);
|
||||
x[ 8] = load32_le(inp + 8);
|
||||
x[ 9] = load32_le(inp + 12);
|
||||
x[10] = SIGMA[2];
|
||||
x[11] = load32_le(key + 16);
|
||||
x[12] = load32_le(key + 20);
|
||||
x[13] = load32_le(key + 24);
|
||||
x[14] = load32_le(key + 28);
|
||||
x[15] = SIGMA[3];
|
||||
|
||||
for (int i = 0; i < 20; i += 2) {
|
||||
/* column round */
|
||||
x[ 4] ^= ROTL32(x[ 0] + x[12], 7);
|
||||
x[ 8] ^= ROTL32(x[ 4] + x[ 0], 9);
|
||||
x[12] ^= ROTL32(x[ 8] + x[ 4], 13);
|
||||
x[ 0] ^= ROTL32(x[12] + x[ 8], 18);
|
||||
x[ 9] ^= ROTL32(x[ 5] + x[ 1], 7);
|
||||
x[13] ^= ROTL32(x[ 9] + x[ 5], 9);
|
||||
x[ 1] ^= ROTL32(x[13] + x[ 9], 13);
|
||||
x[ 5] ^= ROTL32(x[ 1] + x[13], 18);
|
||||
x[14] ^= ROTL32(x[10] + x[ 6], 7);
|
||||
x[ 2] ^= ROTL32(x[14] + x[10], 9);
|
||||
x[ 6] ^= ROTL32(x[ 2] + x[14], 13);
|
||||
x[10] ^= ROTL32(x[ 6] + x[ 2], 18);
|
||||
x[ 3] ^= ROTL32(x[15] + x[11], 7);
|
||||
x[ 7] ^= ROTL32(x[ 3] + x[15], 9);
|
||||
x[11] ^= ROTL32(x[ 7] + x[ 3], 13);
|
||||
x[15] ^= ROTL32(x[11] + x[ 7], 18);
|
||||
/* row round */
|
||||
x[ 1] ^= ROTL32(x[ 0] + x[ 3], 7);
|
||||
x[ 2] ^= ROTL32(x[ 1] + x[ 0], 9);
|
||||
x[ 3] ^= ROTL32(x[ 2] + x[ 1], 13);
|
||||
x[ 0] ^= ROTL32(x[ 3] + x[ 2], 18);
|
||||
x[ 6] ^= ROTL32(x[ 5] + x[ 4], 7);
|
||||
x[ 7] ^= ROTL32(x[ 6] + x[ 5], 9);
|
||||
x[ 4] ^= ROTL32(x[ 7] + x[ 6], 13);
|
||||
x[ 5] ^= ROTL32(x[ 4] + x[ 7], 18);
|
||||
x[11] ^= ROTL32(x[10] + x[ 9], 7);
|
||||
x[ 8] ^= ROTL32(x[11] + x[10], 9);
|
||||
x[ 9] ^= ROTL32(x[ 8] + x[11], 13);
|
||||
x[10] ^= ROTL32(x[ 9] + x[ 8], 18);
|
||||
x[12] ^= ROTL32(x[15] + x[14], 7);
|
||||
x[13] ^= ROTL32(x[12] + x[15], 9);
|
||||
x[14] ^= ROTL32(x[13] + x[12], 13);
|
||||
x[15] ^= ROTL32(x[14] + x[13], 18);
|
||||
}
|
||||
|
||||
/* output words: 0, 5, 10, 15, 6, 7, 8, 9 */
|
||||
store32_le(out + 0, x[ 0]);
|
||||
store32_le(out + 4, x[ 5]);
|
||||
store32_le(out + 8, x[10]);
|
||||
store32_le(out + 12, x[15]);
|
||||
store32_le(out + 16, x[ 6]);
|
||||
store32_le(out + 20, x[ 7]);
|
||||
store32_le(out + 24, x[ 8]);
|
||||
store32_le(out + 28, x[ 9]);
|
||||
}
|
||||
|
||||
/* ── HChaCha20 (ChaCha20 family) ────────────────────────────── */
|
||||
|
||||
static void hchacha20(uint8_t out[32],
|
||||
const uint8_t inp[16],
|
||||
const uint8_t key[32])
|
||||
{
|
||||
uint32_t x[16];
|
||||
x[ 0] = SIGMA[0];
|
||||
x[ 1] = SIGMA[1];
|
||||
x[ 2] = SIGMA[2];
|
||||
x[ 3] = SIGMA[3];
|
||||
x[ 4] = load32_le(key + 0);
|
||||
x[ 5] = load32_le(key + 4);
|
||||
x[ 6] = load32_le(key + 8);
|
||||
x[ 7] = load32_le(key + 12);
|
||||
x[ 8] = load32_le(key + 16);
|
||||
x[ 9] = load32_le(key + 20);
|
||||
x[10] = load32_le(key + 24);
|
||||
x[11] = load32_le(key + 28);
|
||||
x[12] = load32_le(inp + 0);
|
||||
x[13] = load32_le(inp + 4);
|
||||
x[14] = load32_le(inp + 8);
|
||||
x[15] = load32_le(inp + 12);
|
||||
|
||||
for (int i = 0; i < 20; i += 2) {
|
||||
/* column round */
|
||||
#define QR(a, b, c, d) \
|
||||
a += b; d ^= a; d = ROTL32(d, 16); \
|
||||
c += d; b ^= c; b = ROTL32(b, 12); \
|
||||
a += b; d ^= a; d = ROTL32(d, 8); \
|
||||
c += d; b ^= c; b = ROTL32(b, 7);
|
||||
|
||||
QR(x[0], x[4], x[8], x[12]);
|
||||
QR(x[1], x[5], x[9], x[13]);
|
||||
QR(x[2], x[6], x[10], x[14]);
|
||||
QR(x[3], x[7], x[11], x[15]);
|
||||
/* diagonal round */
|
||||
QR(x[0], x[5], x[10], x[15]);
|
||||
QR(x[1], x[6], x[11], x[12]);
|
||||
QR(x[2], x[7], x[8], x[13]);
|
||||
QR(x[3], x[4], x[9], x[14]);
|
||||
|
||||
#undef QR
|
||||
}
|
||||
|
||||
/* output words: 0, 1, 2, 3, 12, 13, 14, 15 */
|
||||
store32_le(out + 0, x[ 0]);
|
||||
store32_le(out + 4, x[ 1]);
|
||||
store32_le(out + 8, x[ 2]);
|
||||
store32_le(out + 12, x[ 3]);
|
||||
store32_le(out + 16, x[12]);
|
||||
store32_le(out + 20, x[13]);
|
||||
store32_le(out + 24, x[14]);
|
||||
store32_le(out + 28, x[15]);
|
||||
}
|
||||
|
||||
/* ── ChaCha20-IETF (RFC 8439) ───────────────────────────────── */
|
||||
|
||||
static void chacha20_block(uint8_t out[64],
|
||||
const uint8_t key[32],
|
||||
uint32_t counter,
|
||||
const uint8_t nonce[12])
|
||||
{
|
||||
uint32_t s[16], x[16];
|
||||
s[ 0] = SIGMA[0];
|
||||
s[ 1] = SIGMA[1];
|
||||
s[ 2] = SIGMA[2];
|
||||
s[ 3] = SIGMA[3];
|
||||
for (int i = 0; i < 8; i++) s[4 + i] = load32_le(key + i * 4);
|
||||
s[12] = counter;
|
||||
s[13] = load32_le(nonce + 0);
|
||||
s[14] = load32_le(nonce + 4);
|
||||
s[15] = load32_le(nonce + 8);
|
||||
|
||||
memcpy(x, s, sizeof(x));
|
||||
|
||||
for (int i = 0; i < 20; i += 2) {
|
||||
#define QR(a, b, c, d) \
|
||||
a += b; d ^= a; d = ROTL32(d, 16); \
|
||||
c += d; b ^= c; b = ROTL32(b, 12); \
|
||||
a += b; d ^= a; d = ROTL32(d, 8); \
|
||||
c += d; b ^= c; b = ROTL32(b, 7);
|
||||
|
||||
QR(x[0], x[4], x[8], x[12]);
|
||||
QR(x[1], x[5], x[9], x[13]);
|
||||
QR(x[2], x[6], x[10], x[14]);
|
||||
QR(x[3], x[7], x[11], x[15]);
|
||||
QR(x[0], x[5], x[10], x[15]);
|
||||
QR(x[1], x[6], x[11], x[12]);
|
||||
QR(x[2], x[7], x[8], x[13]);
|
||||
QR(x[3], x[4], x[9], x[14]);
|
||||
|
||||
#undef QR
|
||||
}
|
||||
|
||||
for (int i = 0; i < 16; i++) store32_le(out + i * 4, x[i] + s[i]);
|
||||
}
|
||||
|
||||
static void chacha20_ietf_xor(uint8_t *out,
|
||||
const uint8_t *in,
|
||||
size_t len,
|
||||
const uint8_t nonce[12],
|
||||
const uint8_t key[32],
|
||||
uint32_t initial_counter)
|
||||
{
|
||||
uint8_t block[64];
|
||||
uint32_t ctr = initial_counter;
|
||||
size_t off = 0;
|
||||
|
||||
while (off < len) {
|
||||
chacha20_block(block, key, ctr++, nonce);
|
||||
size_t chunk = len - off;
|
||||
if (chunk > 64) chunk = 64;
|
||||
for (size_t i = 0; i < chunk; i++) {
|
||||
out[off + i] = in[off + i] ^ block[i];
|
||||
}
|
||||
off += chunk;
|
||||
}
|
||||
|
||||
memset(block, 0, sizeof(block));
|
||||
}
|
||||
|
||||
/* ── XChaCha20 XOR ───────────────────────────────────────────── */
|
||||
|
||||
void rosetta_xchacha20_xor(uint8_t *out,
|
||||
const uint8_t *in,
|
||||
size_t len,
|
||||
const uint8_t nonce[24],
|
||||
const uint8_t key[32])
|
||||
{
|
||||
/* Step 1: derive sub-key with HChaCha20(key, nonce[0..15]) */
|
||||
uint8_t subkey[32];
|
||||
hchacha20(subkey, nonce, key);
|
||||
|
||||
/* Step 2: ChaCha20-IETF with sub-key and nonce' = [0,0,0,0, nonce[16..23]] */
|
||||
uint8_t sub_nonce[12] = {0};
|
||||
memcpy(sub_nonce + 4, nonce + 16, 8);
|
||||
|
||||
chacha20_ietf_xor(out, in, len, sub_nonce, subkey, 0);
|
||||
|
||||
memset(subkey, 0, sizeof(subkey));
|
||||
}
|
||||
33
app/src/main/cpp/crypto.h
Normal file
33
app/src/main/cpp/crypto.h
Normal file
@@ -0,0 +1,33 @@
|
||||
#ifndef ROSETTA_CRYPTO_H
|
||||
#define ROSETTA_CRYPTO_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/**
|
||||
* HSalsa20 core — used by nacl.box.before() to derive shared key.
|
||||
* out: 32 bytes, inp: 16 bytes (nonce, zeros for box.before), key: 32 bytes
|
||||
*/
|
||||
void rosetta_hsalsa20(uint8_t out[32],
|
||||
const uint8_t inp[16],
|
||||
const uint8_t key[32]);
|
||||
|
||||
/**
|
||||
* XChaCha20 XOR (encrypt = decrypt, symmetric stream cipher).
|
||||
* out and in may overlap. nonce: 24 bytes, key: 32 bytes.
|
||||
*/
|
||||
void rosetta_xchacha20_xor(uint8_t *out,
|
||||
const uint8_t *in,
|
||||
size_t len,
|
||||
const uint8_t nonce[24],
|
||||
const uint8_t key[32]);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif /* ROSETTA_CRYPTO_H */
|
||||
1124
app/src/main/cpp/rosetta_e2ee.cpp
Normal file
1124
app/src/main/cpp/rosetta_e2ee.cpp
Normal file
File diff suppressed because it is too large
Load Diff
24
app/src/main/cpp/webrtc/api/array_view.h
Normal file
24
app/src/main/cpp/webrtc/api/array_view.h
Normal file
@@ -0,0 +1,24 @@
|
||||
// Minimal stub matching WebRTC M125 api/array_view.h
|
||||
#ifndef API_ARRAY_VIEW_H_
|
||||
#define API_ARRAY_VIEW_H_
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
namespace rtc {
|
||||
|
||||
template <typename T>
|
||||
class ArrayView final {
|
||||
public:
|
||||
constexpr ArrayView() noexcept : ptr_(nullptr), size_(0) {}
|
||||
constexpr ArrayView(T* ptr, size_t size) noexcept : ptr_(ptr), size_(size) {}
|
||||
constexpr T* data() const { return ptr_; }
|
||||
constexpr size_t size() const { return size_; }
|
||||
|
||||
private:
|
||||
T* ptr_;
|
||||
size_t size_;
|
||||
};
|
||||
|
||||
} // namespace rtc
|
||||
|
||||
#endif // API_ARRAY_VIEW_H_
|
||||
@@ -0,0 +1,42 @@
|
||||
// Minimal stub matching WebRTC M125 api/crypto/frame_decryptor_interface.h
|
||||
#ifndef API_CRYPTO_FRAME_DECRYPTOR_INTERFACE_H_
|
||||
#define API_CRYPTO_FRAME_DECRYPTOR_INTERFACE_H_
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
#include "webrtc/rtc_base/ref_count.h"
|
||||
#include "webrtc/api/array_view.h"
|
||||
#include "webrtc/api/media_types.h"
|
||||
|
||||
namespace webrtc {
|
||||
|
||||
class FrameDecryptorInterface : public rtc::RefCountInterface {
|
||||
public:
|
||||
struct Result {
|
||||
enum class Status { kOk = 0, kRecoverable, kFailedToDecrypt };
|
||||
|
||||
Result(Status s, size_t bw) : status(s), bytes_written(bw) {}
|
||||
bool IsOk() const { return status == Status::kOk; }
|
||||
|
||||
Status status;
|
||||
size_t bytes_written;
|
||||
};
|
||||
|
||||
virtual Result Decrypt(cricket::MediaType media_type,
|
||||
const std::vector<uint32_t>& csrcs,
|
||||
rtc::ArrayView<const uint8_t> additional_data,
|
||||
rtc::ArrayView<const uint8_t> encrypted_frame,
|
||||
rtc::ArrayView<uint8_t> frame) = 0;
|
||||
|
||||
virtual size_t GetMaxPlaintextByteSize(cricket::MediaType media_type,
|
||||
size_t encrypted_frame_size) = 0;
|
||||
|
||||
protected:
|
||||
~FrameDecryptorInterface() override {}
|
||||
};
|
||||
|
||||
} // namespace webrtc
|
||||
|
||||
#endif // API_CRYPTO_FRAME_DECRYPTOR_INTERFACE_H_
|
||||
@@ -0,0 +1,32 @@
|
||||
// Minimal stub matching WebRTC M125 api/crypto/frame_encryptor_interface.h
|
||||
#ifndef API_CRYPTO_FRAME_ENCRYPTOR_INTERFACE_H_
|
||||
#define API_CRYPTO_FRAME_ENCRYPTOR_INTERFACE_H_
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
|
||||
#include "webrtc/rtc_base/ref_count.h"
|
||||
#include "webrtc/api/array_view.h"
|
||||
#include "webrtc/api/media_types.h"
|
||||
|
||||
namespace webrtc {
|
||||
|
||||
class FrameEncryptorInterface : public rtc::RefCountInterface {
|
||||
public:
|
||||
virtual int Encrypt(cricket::MediaType media_type,
|
||||
uint32_t ssrc,
|
||||
rtc::ArrayView<const uint8_t> additional_data,
|
||||
rtc::ArrayView<const uint8_t> frame,
|
||||
rtc::ArrayView<uint8_t> encrypted_frame,
|
||||
size_t* bytes_written) = 0;
|
||||
|
||||
virtual size_t GetMaxCiphertextByteSize(cricket::MediaType media_type,
|
||||
size_t frame_size) = 0;
|
||||
|
||||
protected:
|
||||
~FrameEncryptorInterface() override {}
|
||||
};
|
||||
|
||||
} // namespace webrtc
|
||||
|
||||
#endif // API_CRYPTO_FRAME_ENCRYPTOR_INTERFACE_H_
|
||||
14
app/src/main/cpp/webrtc/api/media_types.h
Normal file
14
app/src/main/cpp/webrtc/api/media_types.h
Normal file
@@ -0,0 +1,14 @@
|
||||
// Minimal stub matching WebRTC M125 api/media_types.h
|
||||
#ifndef API_MEDIA_TYPES_H_
|
||||
#define API_MEDIA_TYPES_H_
|
||||
|
||||
namespace cricket {
|
||||
enum MediaType {
|
||||
MEDIA_TYPE_AUDIO,
|
||||
MEDIA_TYPE_VIDEO,
|
||||
MEDIA_TYPE_DATA,
|
||||
MEDIA_TYPE_UNSUPPORTED
|
||||
};
|
||||
} // namespace cricket
|
||||
|
||||
#endif // API_MEDIA_TYPES_H_
|
||||
21
app/src/main/cpp/webrtc/rtc_base/ref_count.h
Normal file
21
app/src/main/cpp/webrtc/rtc_base/ref_count.h
Normal file
@@ -0,0 +1,21 @@
|
||||
// Minimal stub matching WebRTC M125 rtc_base/ref_count.h
|
||||
#ifndef RTC_BASE_REF_COUNT_H_
|
||||
#define RTC_BASE_REF_COUNT_H_
|
||||
|
||||
namespace rtc {
|
||||
|
||||
enum class RefCountReleaseStatus { kDroppedLastRef, kOtherRefsRemained };
|
||||
|
||||
// Must match the EXACT virtual layout of the real rtc::RefCountInterface.
|
||||
class RefCountInterface {
|
||||
public:
|
||||
virtual void AddRef() const = 0;
|
||||
virtual RefCountReleaseStatus Release() const = 0;
|
||||
|
||||
protected:
|
||||
virtual ~RefCountInterface() {}
|
||||
};
|
||||
|
||||
} // namespace rtc
|
||||
|
||||
#endif // RTC_BASE_REF_COUNT_H_
|
||||
@@ -7,7 +7,7 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -15,7 +15,7 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
|
||||
/**
|
||||
* 🚀 Telegram-style: Fixed Height Box + Fade In/Out
|
||||
@@ -109,20 +109,4 @@ fun AnimatedKeyboardTransition(
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Алиас для обратной совместимости
|
||||
*/
|
||||
@Composable
|
||||
fun SimpleAnimatedKeyboardTransition(
|
||||
coordinator: KeyboardTransitionCoordinator,
|
||||
showEmojiPicker: Boolean,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
AnimatedKeyboardTransition(
|
||||
coordinator = coordinator,
|
||||
showEmojiPicker = showEmojiPicker,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -46,9 +46,6 @@ class KeyboardTransitionCoordinator {
|
||||
var currentState by mutableStateOf(TransitionState.IDLE)
|
||||
private set
|
||||
|
||||
var transitionProgress by mutableFloatStateOf(0f)
|
||||
private set
|
||||
|
||||
// ============ Высоты ============
|
||||
|
||||
var keyboardHeight by mutableStateOf(0.dp)
|
||||
@@ -68,9 +65,6 @@ class KeyboardTransitionCoordinator {
|
||||
// Используется для отключения imePadding пока Box виден
|
||||
var isEmojiBoxVisible by mutableStateOf(false)
|
||||
|
||||
// 🔥 Коллбэк для показа emoji (сохраняем для вызова после закрытия клавиатуры)
|
||||
private var pendingShowEmojiCallback: (() -> Unit)? = null
|
||||
|
||||
// 📊 Для умного логирования (не каждый фрейм)
|
||||
private var lastLogTime = 0L
|
||||
private var lastLoggedHeight = -1f
|
||||
@@ -108,8 +102,6 @@ class KeyboardTransitionCoordinator {
|
||||
currentState = TransitionState.IDLE
|
||||
isTransitioning = false
|
||||
|
||||
// Очищаем pending callback - больше не нужен
|
||||
pendingShowEmojiCallback = null
|
||||
}
|
||||
|
||||
// ============ Главный метод: Emoji → Keyboard ============
|
||||
@@ -119,11 +111,6 @@ class KeyboardTransitionCoordinator {
|
||||
* плавно скрыть emoji.
|
||||
*/
|
||||
fun requestShowKeyboard(showKeyboard: () -> Unit, hideEmoji: () -> Unit) {
|
||||
// 🔥 Отменяем pending emoji callback если он есть (предотвращаем конфликт)
|
||||
if (pendingShowEmojiCallback != null) {
|
||||
pendingShowEmojiCallback = null
|
||||
}
|
||||
|
||||
currentState = TransitionState.EMOJI_TO_KEYBOARD
|
||||
isTransitioning = true
|
||||
|
||||
@@ -260,13 +247,6 @@ class KeyboardTransitionCoordinator {
|
||||
// 🔥 УБРАН pending callback - теперь emoji показывается сразу в requestShowEmoji()
|
||||
}
|
||||
|
||||
/** Обновить высоту emoji панели. */
|
||||
fun updateEmojiHeight(height: Dp) {
|
||||
if (height > 0.dp && height != emojiHeight) {
|
||||
emojiHeight = height
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Синхронизировать высоты (emoji = keyboard).
|
||||
*
|
||||
@@ -292,35 +272,6 @@ class KeyboardTransitionCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить текущую высоту для резервирования места. Telegram паттерн: всегда резервировать
|
||||
* максимум из двух.
|
||||
*/
|
||||
fun getReservedHeight(): Dp {
|
||||
return when {
|
||||
isKeyboardVisible -> keyboardHeight
|
||||
isEmojiVisible -> emojiHeight
|
||||
isTransitioning -> maxOf(keyboardHeight, emojiHeight)
|
||||
else -> 0.dp
|
||||
}
|
||||
}
|
||||
|
||||
/** Проверка, можно ли начать новый переход. */
|
||||
fun canStartTransition(): Boolean {
|
||||
return !isTransitioning
|
||||
}
|
||||
|
||||
/** Сброс состояния (для отладки). */
|
||||
fun reset() {
|
||||
currentState = TransitionState.IDLE
|
||||
isTransitioning = false
|
||||
isKeyboardVisible = false
|
||||
isEmojiVisible = false
|
||||
transitionProgress = 0f
|
||||
}
|
||||
|
||||
/** Логирование текущего состояния. */
|
||||
fun logState() {}
|
||||
}
|
||||
|
||||
/** Composable для создания и запоминания coordinator'а. */
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package com.rosetta.messenger
|
||||
|
||||
// commit
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
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.resolveAccountDisplayName
|
||||
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.ProtocolState
|
||||
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.RequestsListScreen
|
||||
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.SwipeBackBackgroundEffect
|
||||
import com.rosetta.messenger.ui.components.SwipeBackContainer
|
||||
@@ -116,6 +120,7 @@ class MainActivity : FragmentActivity() {
|
||||
|
||||
// 🔥 Инициализируем ProtocolManager для обработки онлайн статусов
|
||||
ProtocolManager.initialize(this)
|
||||
CallManager.initialize(this)
|
||||
|
||||
// 🔔 Инициализируем Firebase для push-уведомлений
|
||||
initializeFirebase()
|
||||
@@ -581,6 +586,177 @@ fun MainScreen(
|
||||
|
||||
// Load username AND name from AccountManager (persisted in DataStore)
|
||||
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) {
|
||||
if (accountPublicKey.isNotBlank()) {
|
||||
val accountManager = AccountManager(context)
|
||||
@@ -1075,6 +1251,9 @@ fun MainScreen(
|
||||
currentUserUsername = accountUsername,
|
||||
totalUnreadFromOthers = totalUnreadFromOthers,
|
||||
onBack = { popChatAndChildren() },
|
||||
onCallClick = { callableUser ->
|
||||
startCallWithPermission(callableUser)
|
||||
},
|
||||
onUserProfileClick = { user ->
|
||||
if (isCurrentAccountUser(user)) {
|
||||
// Свой профиль из чата открываем поверх текущего чата,
|
||||
@@ -1200,6 +1379,11 @@ fun MainScreen(
|
||||
},
|
||||
onNavigateToCrashLogs = {
|
||||
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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1592,7 +1592,6 @@ object MessageCrypto {
|
||||
// Reset bounds to default after first continuation
|
||||
lowerBoundary = 0x80
|
||||
upperBoundary = 0xBF
|
||||
// test
|
||||
if (bytesSeen == bytesNeeded) {
|
||||
// Sequence complete — emit code point
|
||||
if (codePoint <= 0xFFFF) {
|
||||
|
||||
@@ -477,15 +477,18 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
scope.launch {
|
||||
val startTime = System.currentTimeMillis()
|
||||
try {
|
||||
// Шифрование
|
||||
val encryptResult = MessageCrypto.encryptForSending(text.trim(), toPublicKey)
|
||||
val encryptedContent = encryptResult.ciphertext
|
||||
val encryptedKey = encryptResult.encryptedKey
|
||||
// Шифрование (пропускаем для пустого текста — напр. CALL-сообщения)
|
||||
val hasContent = text.trim().isNotEmpty()
|
||||
val encryptResult = if (hasContent) MessageCrypto.encryptForSending(text.trim(), toPublicKey) else null
|
||||
val encryptedContent = encryptResult?.ciphertext ?: ""
|
||||
val encryptedKey = encryptResult?.encryptedKey ?: ""
|
||||
val aesChachaKey =
|
||||
CryptoManager.encryptWithPassword(
|
||||
String(encryptResult.plainKeyAndNonce, Charsets.ISO_8859_1),
|
||||
privateKey
|
||||
)
|
||||
if (encryptResult != null) {
|
||||
CryptoManager.encryptWithPassword(
|
||||
String(encryptResult.plainKeyAndNonce, Charsets.ISO_8859_1),
|
||||
privateKey
|
||||
)
|
||||
} else ""
|
||||
|
||||
// 📝 LOG: Шифрование успешно
|
||||
MessageLogger.logEncryptionSuccess(
|
||||
@@ -686,13 +689,6 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 🔥 ВТОРОЙ УРОВЕНЬ ЗАЩИТЫ: Проверка в БД (для сообщений сохранённых в предыдущих сессиях)
|
||||
val isDuplicate = messageDao.messageExists(account, messageId)
|
||||
MessageLogger.logDuplicateCheck(messageId, isDuplicate)
|
||||
if (isDuplicate) {
|
||||
return true
|
||||
}
|
||||
|
||||
val dialogOpponentKey =
|
||||
when {
|
||||
isGroupMessage -> packet.toPublicKey
|
||||
@@ -701,6 +697,33 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
}
|
||||
val dialogKey = getDialogKey(dialogOpponentKey)
|
||||
|
||||
// 🔥 ВТОРОЙ УРОВЕНЬ ЗАЩИТЫ: Проверка в БД (для сообщений сохранённых в предыдущих сессиях)
|
||||
val isDuplicate = messageDao.messageExists(account, messageId)
|
||||
MessageLogger.logDuplicateCheck(messageId, isDuplicate)
|
||||
if (isDuplicate) {
|
||||
// Desktop/server parity:
|
||||
// own messages that arrive via sync must be treated as delivered.
|
||||
// If a local optimistic row already exists (WAITING/ERROR), normalize it.
|
||||
if (isOwnMessage) {
|
||||
messageDao.updateDeliveryStatus(account, messageId, DeliveryStatus.DELIVERED.value)
|
||||
messageCache[dialogKey]?.let { flow ->
|
||||
flow.value =
|
||||
flow.value.map { msg ->
|
||||
if (msg.messageId == messageId) {
|
||||
msg.copy(deliveryStatus = DeliveryStatus.DELIVERED)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
}
|
||||
}
|
||||
_deliveryStatusEvents.tryEmit(
|
||||
DeliveryStatusUpdate(dialogKey, messageId, DeliveryStatus.DELIVERED)
|
||||
)
|
||||
dialogDao.updateDialogFromMessages(account, dialogOpponentKey)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
val groupKey =
|
||||
if (isGroupMessage) {
|
||||
@@ -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 =
|
||||
if (isGroupMessage) {
|
||||
if (isAttachmentOnly) {
|
||||
""
|
||||
} else if (isGroupMessage) {
|
||||
CryptoManager.decryptWithPassword(packet.content, groupKey!!)
|
||||
?: throw IllegalStateException("Failed to decrypt group payload")
|
||||
} else if (plainKeyAndNonce != null) {
|
||||
MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce)
|
||||
} else {
|
||||
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
|
||||
try {
|
||||
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
|
||||
} catch (e: Exception) {
|
||||
// Fallback: если дешифровка не удалась (напр. CALL с encrypted empty content)
|
||||
android.util.Log.w("MessageRepository", "Decryption fallback for ${messageId.take(8)}: ${e.message}")
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
// 📝 LOG: Расшифровка успешна
|
||||
@@ -851,11 +885,10 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
unreadCount = dialog?.unreadCount ?: 0
|
||||
)
|
||||
|
||||
// 🔥 Запрашиваем информацию о пользователе для отображения имени вместо ключа
|
||||
// Desktop parity: always re-fetch on incoming message so renamed contacts
|
||||
// get their new name/username updated in the chat list.
|
||||
// 🔥 Запрашиваем информацию о пользователе для отображения имени вместо ключа.
|
||||
// Важно: не форсим повторный запрос на каждый входящий пакет — это создает
|
||||
// шторм PacketSearch во время sync и заметно тормозит обработку.
|
||||
if (!isGroupDialogKey(dialogOpponentKey)) {
|
||||
requestedUserInfoKeys.remove(dialogOpponentKey)
|
||||
requestUserInfo(dialogOpponentKey)
|
||||
} else {
|
||||
applyGroupDisplayNameToDialog(account, dialogOpponentKey)
|
||||
@@ -955,20 +988,24 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
|
||||
val readCount =
|
||||
messageCache[dialogKey]?.value?.count {
|
||||
it.isFromMe &&
|
||||
!it.isRead &&
|
||||
(it.deliveryStatus == DeliveryStatus.DELIVERED ||
|
||||
it.deliveryStatus == DeliveryStatus.READ)
|
||||
it.isFromMe && !it.isRead
|
||||
} ?: 0
|
||||
messageCache[dialogKey]?.let { flow ->
|
||||
flow.value =
|
||||
flow.value.map { msg ->
|
||||
if (msg.isFromMe &&
|
||||
!msg.isRead &&
|
||||
(msg.deliveryStatus == DeliveryStatus.DELIVERED ||
|
||||
msg.deliveryStatus == DeliveryStatus.READ)
|
||||
) {
|
||||
msg.copy(isRead = true, deliveryStatus = DeliveryStatus.READ)
|
||||
if (msg.isFromMe && !msg.isRead) {
|
||||
msg.copy(
|
||||
isRead = true,
|
||||
deliveryStatus =
|
||||
if (
|
||||
msg.deliveryStatus == DeliveryStatus.DELIVERED ||
|
||||
msg.deliveryStatus == DeliveryStatus.READ
|
||||
) {
|
||||
DeliveryStatus.READ
|
||||
} else {
|
||||
msg.deliveryStatus
|
||||
}
|
||||
)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
@@ -1006,20 +1043,24 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
|
||||
val readCount =
|
||||
messageCache[dialogKey]?.value?.count {
|
||||
it.isFromMe &&
|
||||
!it.isRead &&
|
||||
(it.deliveryStatus == DeliveryStatus.DELIVERED ||
|
||||
it.deliveryStatus == DeliveryStatus.READ)
|
||||
it.isFromMe && !it.isRead
|
||||
} ?: 0
|
||||
messageCache[dialogKey]?.let { flow ->
|
||||
flow.value =
|
||||
flow.value.map { msg ->
|
||||
if (msg.isFromMe &&
|
||||
!msg.isRead &&
|
||||
(msg.deliveryStatus == DeliveryStatus.DELIVERED ||
|
||||
msg.deliveryStatus == DeliveryStatus.READ)
|
||||
) {
|
||||
msg.copy(isRead = true, deliveryStatus = DeliveryStatus.READ)
|
||||
if (msg.isFromMe && !msg.isRead) {
|
||||
msg.copy(
|
||||
isRead = true,
|
||||
deliveryStatus =
|
||||
if (
|
||||
msg.deliveryStatus == DeliveryStatus.DELIVERED ||
|
||||
msg.deliveryStatus == DeliveryStatus.READ
|
||||
) {
|
||||
DeliveryStatus.READ
|
||||
} else {
|
||||
msg.deliveryStatus
|
||||
}
|
||||
)
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
|
||||
@@ -17,30 +17,10 @@ object ReleaseNotes {
|
||||
val RELEASE_NOTICE = """
|
||||
Update v$VERSION_PLACEHOLDER
|
||||
|
||||
Поиск
|
||||
- Добавлена вкладка Messages в поиске: поиск по тексту сообщений по всем чатам
|
||||
- Реализованы быстрые сниппеты с подсветкой найденного текста и переходом в нужный чат
|
||||
- Добавлены алиасы для Saved Messages в поиске (saved / saved messages / избранное и др.)
|
||||
|
||||
Тэги и навигация
|
||||
- Исправлены клики по @тэгам в сообщениях: теперь открывается чат пользователя
|
||||
- Добавлен устойчивый резолв @username (локальный диалог -> кэш -> сервер)
|
||||
- Устранен конфликт клика по тэгу с контекстным меню пузырька
|
||||
|
||||
Чаты и UI
|
||||
- Улучшен пустой экран Saved Messages на обоях: добавлена подложка и повышена читаемость
|
||||
- Стабилизировано отображение verified-бейджа в хедере личного чата
|
||||
- Подправлено положение галочки в сайдбаре
|
||||
- В тёмной теме цвет цифры в бейдже Requests возле бургер-меню приведен к цвету шапки
|
||||
|
||||
Темы и обои
|
||||
- Добавлены пары обоев для светлой и темной темы
|
||||
- Обои теперь автоматически синхронизируются при переключении темы
|
||||
- Выбор обоев сохраняется отдельно для light/dark
|
||||
|
||||
Безопасность и система
|
||||
- Если устройство не поддерживает отпечаток пальца, биометрия больше не предлагается
|
||||
- Удалена неиспользуемая зависимость jsoup
|
||||
Защищенные звонки и диагностика E2EE
|
||||
- Обновлен custom WebRTC для Android и исправлена совместимость аудио E2EE с Desktop
|
||||
- Улучшены diagnostics для шифрования звонков (детализация ENC/DEC в crash reports)
|
||||
- В Crash Reports добавлена кнопка копирования полного лога одним действием
|
||||
""".trimIndent()
|
||||
|
||||
fun getNotice(version: String): String =
|
||||
|
||||
@@ -403,7 +403,7 @@ interface MessageDao {
|
||||
WHERE account = :account
|
||||
AND to_public_key = :opponent
|
||||
AND from_me = 1
|
||||
AND delivered IN (1, 3)
|
||||
AND read != 1
|
||||
"""
|
||||
)
|
||||
suspend fun markAllAsRead(account: String, opponent: String): Int
|
||||
|
||||
@@ -7,9 +7,11 @@ enum class AttachmentType(val value: Int) {
|
||||
IMAGE(0), // Изображение
|
||||
MESSAGES(1), // Reply (цитата сообщения)
|
||||
FILE(2), // Файл
|
||||
AVATAR(3); // Аватар пользователя
|
||||
AVATAR(3), // Аватар пользователя
|
||||
CALL(4), // Событие звонка (пропущен/принят/завершен)
|
||||
UNKNOWN(-1); // Неизвестный тип
|
||||
|
||||
companion object {
|
||||
fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: IMAGE
|
||||
fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: UNKNOWN
|
||||
}
|
||||
}
|
||||
|
||||
1371
app/src/main/java/com/rosetta/messenger/network/CallManager.kt
Normal file
1371
app/src/main/java/com/rosetta/messenger/network/CallManager.kt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,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) {}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,8 @@ enum class HandshakeState(val value: Int) {
|
||||
|
||||
companion object {
|
||||
fun fromValue(value: Int): HandshakeState {
|
||||
return entries.firstOrNull { it.value == value } ?: COMPLETED
|
||||
// Fail-safe: unknown value must not auto-authenticate.
|
||||
return entries.firstOrNull { it.value == value } ?: NEED_DEVICE_VERIFICATION
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
data class IceServer(
|
||||
val url: String,
|
||||
val username: String,
|
||||
val credential: String,
|
||||
val transport: String
|
||||
)
|
||||
|
||||
/**
|
||||
* ICE servers packet (ID: 0x1C / 28).
|
||||
* Wire format mirrors desktop packet.ice.servers.ts.
|
||||
*/
|
||||
class PacketIceServers : Packet() {
|
||||
var iceServers: List<IceServer> = emptyList()
|
||||
|
||||
override fun getPacketId(): Int = 0x1C
|
||||
|
||||
override fun receive(stream: Stream) {
|
||||
val count = stream.readInt16()
|
||||
val servers = ArrayList<IceServer>(count.coerceAtLeast(0))
|
||||
for (i in 0 until count) {
|
||||
servers.add(
|
||||
IceServer(
|
||||
url = stream.readString(),
|
||||
username = stream.readString(),
|
||||
credential = stream.readString(),
|
||||
transport = stream.readString()
|
||||
)
|
||||
)
|
||||
}
|
||||
iceServers = servers
|
||||
}
|
||||
|
||||
override fun send(): Stream {
|
||||
val stream = Stream()
|
||||
stream.writeInt16(getPacketId())
|
||||
stream.writeInt16(iceServers.size)
|
||||
for (server in iceServers) {
|
||||
stream.writeString(server.url)
|
||||
stream.writeString(server.username)
|
||||
stream.writeString(server.credential)
|
||||
stream.writeString(server.transport)
|
||||
}
|
||||
return stream
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
/**
|
||||
* Push Token packet (ID: 0x0A) - DEPRECATED
|
||||
* Старый формат, заменен на PacketPushNotification (0x10)
|
||||
*/
|
||||
class PacketPushToken : Packet() {
|
||||
var privateKey: String = ""
|
||||
var publicKey: String = ""
|
||||
var pushToken: String = ""
|
||||
var platform: String = "android" // "android" или "ios"
|
||||
|
||||
override fun getPacketId(): Int = 0x0A
|
||||
|
||||
override fun receive(stream: Stream) {
|
||||
privateKey = stream.readString()
|
||||
publicKey = stream.readString()
|
||||
pushToken = stream.readString()
|
||||
platform = stream.readString()
|
||||
}
|
||||
|
||||
override fun send(): Stream {
|
||||
val stream = Stream()
|
||||
stream.writeInt16(getPacketId())
|
||||
stream.writeString(privateKey)
|
||||
stream.writeString(publicKey)
|
||||
stream.writeString(pushToken)
|
||||
stream.writeString(platform)
|
||||
return stream
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,10 @@ class Protocol(
|
||||
private const val TAG = "RosettaProtocol"
|
||||
private const val RECONNECT_INTERVAL = 5000L // 5 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) {
|
||||
@@ -112,6 +115,9 @@ class Protocol(
|
||||
|
||||
// Heartbeat
|
||||
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
|
||||
private val supportedPackets = mapOf(
|
||||
@@ -127,6 +133,7 @@ class Protocol(
|
||||
0x09 to { PacketDeviceNew() },
|
||||
0x0A to { PacketRequestUpdate() },
|
||||
0x0B to { PacketTyping() },
|
||||
0x10 to { PacketPushNotification() },
|
||||
0x11 to { PacketCreateGroup() },
|
||||
0x12 to { PacketGroupInfo() },
|
||||
0x13 to { PacketGroupInviteInfo() },
|
||||
@@ -136,7 +143,10 @@ class Protocol(
|
||||
0x0F to { PacketRequestTransport() },
|
||||
0x17 to { PacketDeviceList() },
|
||||
0x18 to { PacketDeviceResolve() },
|
||||
0x19 to { PacketSync() }
|
||||
0x19 to { PacketSync() },
|
||||
0x1A to { PacketSignalPeer() },
|
||||
0x1B to { PacketWebRTC() },
|
||||
0x1C to { PacketIceServers() }
|
||||
)
|
||||
|
||||
init {
|
||||
@@ -175,11 +185,24 @@ class Protocol(
|
||||
* Как в Архиве - отправляем text "heartbeat" СРАЗУ и потом с интервалом
|
||||
*/
|
||||
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()
|
||||
|
||||
// Отправляем чаще - каждые 1/3 интервала (чтобы не терять соединение)
|
||||
val intervalMs = (intervalSeconds * 1000L) / 3
|
||||
log("💓 HEARTBEAT START: server=${intervalSeconds}s, sending=${intervalMs/1000}s, state=${_state.value}")
|
||||
heartbeatPeriodMs = intervalMs
|
||||
lastHeartbeatOkLogAtMs = 0L
|
||||
heartbeatOkSuppressedCount = 0
|
||||
log(
|
||||
"💓 HEARTBEAT START: server=${intervalSeconds}s(normalized=${normalizedServerIntervalSec}s), " +
|
||||
"sending=${intervalMs / 1000}s, state=${_state.value}"
|
||||
)
|
||||
|
||||
heartbeatJob = scope.launch {
|
||||
// ⚡ СРАЗУ отправляем первый heartbeat (как в Архиве)
|
||||
@@ -206,7 +229,17 @@ class Protocol(
|
||||
) {
|
||||
val sent = webSocket?.send("heartbeat") ?: false
|
||||
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 {
|
||||
log("💔 HEARTBEAT FAILED: socket=$socketAlive, state=$currentState, manuallyClosed=$isManuallyClosed")
|
||||
// Триггерим reconnect если heartbeat не прошёл
|
||||
@@ -502,52 +535,40 @@ class Protocol(
|
||||
log("📥 Received ${data.size} bytes: $hexDump${if (data.size > 50) "..." else ""}")
|
||||
|
||||
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 packetStartBits = stream.getReadPointerBits()
|
||||
val packetId = stream.readInt16()
|
||||
|
||||
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
|
||||
}
|
||||
val packetFactory = supportedPackets[packetId]
|
||||
if (packetFactory == null) {
|
||||
log("⚠️ Unknown packet ID: $packetId")
|
||||
return
|
||||
}
|
||||
|
||||
if (parsedPackets > 1) {
|
||||
log("📦 Parsed $parsedPackets packets from single WebSocket frame")
|
||||
val packet = packetFactory()
|
||||
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) {
|
||||
log("❌ Error parsing packet: ${e.message}")
|
||||
@@ -569,6 +590,7 @@ class Protocol(
|
||||
handshakeComplete = false
|
||||
handshakeJob?.cancel()
|
||||
heartbeatJob?.cancel()
|
||||
heartbeatPeriodMs = 0L
|
||||
|
||||
// Автоматический reconnect с защитой от бесконечных попыток
|
||||
if (!isManuallyClosed) {
|
||||
@@ -624,6 +646,7 @@ class Protocol(
|
||||
reconnectJob = null
|
||||
handshakeJob?.cancel()
|
||||
heartbeatJob?.cancel()
|
||||
heartbeatPeriodMs = 0L
|
||||
webSocket?.close(1000, "User disconnected")
|
||||
webSocket = null
|
||||
_state.value = ProtocolState.DISCONNECTED
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.rosetta.messenger.data.AccountManager
|
||||
import com.rosetta.messenger.data.GroupRepository
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.data.isPlaceholderAccountName
|
||||
import com.rosetta.messenger.utils.MessageLogger
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -27,9 +28,14 @@ import kotlin.coroutines.resume
|
||||
object ProtocolManager {
|
||||
private const val TAG = "ProtocolManager"
|
||||
private const val MANUAL_SYNC_BACKTRACK_MS = 120_000L
|
||||
private const val SYNC_REQUEST_TIMEOUT_MS = 12_000L
|
||||
private const val MAX_DEBUG_LOGS = 600
|
||||
private const val DEBUG_LOG_FLUSH_DELAY_MS = 60L
|
||||
private const val HEARTBEAT_OK_LOG_MIN_INTERVAL_MS = 5_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.
|
||||
private const val SERVER_ADDRESS = "wss://wss.rosetta.im"
|
||||
@@ -45,6 +51,7 @@ object ProtocolManager {
|
||||
@Volatile private var packetHandlersRegistered = false
|
||||
@Volatile private var stateMonitoringStarted = false
|
||||
@Volatile private var syncRequestInFlight = false
|
||||
@Volatile private var syncRequestTimeoutJob: Job? = null
|
||||
|
||||
// Guard: prevent duplicate FCM token subscribe within a single session
|
||||
@Volatile
|
||||
@@ -56,6 +63,8 @@ object ProtocolManager {
|
||||
private val debugLogsLock = Any()
|
||||
@Volatile private var debugFlushJob: Job? = null
|
||||
private val debugFlushPending = AtomicBoolean(false)
|
||||
@Volatile private var lastHeartbeatOkLogAtMs: Long = 0L
|
||||
@Volatile private var suppressedHeartbeatOkLogs: Int = 0
|
||||
|
||||
// Typing status
|
||||
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
|
||||
@@ -87,8 +96,8 @@ object ProtocolManager {
|
||||
private fun normalizeSearchQuery(value: String): String =
|
||||
value.trim().removePrefix("@").lowercase(Locale.ROOT)
|
||||
|
||||
// UI logs are enabled by default; updates are throttled and bounded by MAX_DEBUG_LOGS.
|
||||
private var uiLogsEnabled = true
|
||||
// Keep heavy protocol/message UI logs disabled by default.
|
||||
private var uiLogsEnabled = false
|
||||
private var lastProtocolState: ProtocolState? = null
|
||||
@Volatile private var syncBatchInProgress = false
|
||||
private val _syncInProgress = MutableStateFlow(false)
|
||||
@@ -126,9 +135,23 @@ object ProtocolManager {
|
||||
|
||||
fun addLog(message: String) {
|
||||
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 =
|
||||
java.text.SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
|
||||
val line = "[$timestamp] $message"
|
||||
val line = "[$timestamp] $normalizedMessage"
|
||||
synchronized(debugLogsLock) {
|
||||
if (debugLogsBuffer.size >= MAX_DEBUG_LOGS) {
|
||||
debugLogsBuffer.removeFirst()
|
||||
@@ -140,6 +163,7 @@ object ProtocolManager {
|
||||
|
||||
fun enableUILogs(enabled: Boolean) {
|
||||
uiLogsEnabled = enabled
|
||||
MessageLogger.setEnabled(enabled)
|
||||
if (enabled) {
|
||||
val snapshot = synchronized(debugLogsLock) { debugLogsBuffer.toList() }
|
||||
_debugLogs.value = snapshot
|
||||
@@ -152,6 +176,8 @@ object ProtocolManager {
|
||||
synchronized(debugLogsLock) {
|
||||
debugLogsBuffer.clear()
|
||||
}
|
||||
suppressedHeartbeatOkLogs = 0
|
||||
lastHeartbeatOkLogAtMs = 0L
|
||||
_debugLogs.value = emptyList()
|
||||
}
|
||||
|
||||
@@ -210,6 +236,7 @@ object ProtocolManager {
|
||||
}
|
||||
if (newState != ProtocolState.AUTHENTICATED && newState != ProtocolState.HANDSHAKING) {
|
||||
syncRequestInFlight = false
|
||||
clearSyncRequestTimeout()
|
||||
setSyncInProgress(false)
|
||||
// Connection/session dropped: force re-subscribe on next AUTHENTICATED.
|
||||
lastSubscribedToken = null
|
||||
@@ -678,6 +705,7 @@ object ProtocolManager {
|
||||
|
||||
private fun finishSyncCycle(reason: String) {
|
||||
syncRequestInFlight = false
|
||||
clearSyncRequestTimeout()
|
||||
inboundProcessingFailures.set(0)
|
||||
addLog(reason)
|
||||
setSyncInProgress(false)
|
||||
@@ -734,6 +762,7 @@ object ProtocolManager {
|
||||
val repository = messageRepository
|
||||
if (repository == null || !repository.isInitialized()) {
|
||||
syncRequestInFlight = false
|
||||
clearSyncRequestTimeout()
|
||||
requireResyncAfterAccountInit("⏳ Sync postponed until account is initialized")
|
||||
return@launch
|
||||
}
|
||||
@@ -744,6 +773,7 @@ object ProtocolManager {
|
||||
repositoryAccount != protocolAccount
|
||||
) {
|
||||
syncRequestInFlight = false
|
||||
clearSyncRequestTimeout()
|
||||
requireResyncAfterAccountInit(
|
||||
"⏳ Sync postponed: repository bound to another account"
|
||||
)
|
||||
@@ -757,6 +787,7 @@ object ProtocolManager {
|
||||
|
||||
private fun sendSynchronize(timestamp: Long) {
|
||||
syncRequestInFlight = true
|
||||
scheduleSyncRequestTimeout(timestamp)
|
||||
val packet = PacketSync().apply {
|
||||
status = SyncStatus.NOT_NEEDED
|
||||
this.timestamp = timestamp
|
||||
@@ -777,6 +808,7 @@ object ProtocolManager {
|
||||
*/
|
||||
private fun handleSyncPacket(packet: PacketSync) {
|
||||
syncRequestInFlight = false
|
||||
clearSyncRequestTimeout()
|
||||
when (packet.status) {
|
||||
SyncStatus.BATCH_START -> {
|
||||
addLog("🔄 SYNC BATCH_START — incoming message batch")
|
||||
@@ -826,6 +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.
|
||||
* Desktop has in-memory _packetQueue that flushes on handshake, but desktop apps are
|
||||
@@ -1255,6 +1305,94 @@ object ProtocolManager {
|
||||
fun sendPacket(packet: 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
|
||||
@@ -1325,6 +1463,7 @@ object ProtocolManager {
|
||||
_devices.value = emptyList()
|
||||
_pendingDeviceVerification.value = null
|
||||
syncRequestInFlight = false
|
||||
clearSyncRequestTimeout()
|
||||
setSyncInProgress(false)
|
||||
resyncRequiredAfterAccountInit = false
|
||||
lastSubscribedToken = null // reset so token is re-sent on next connect
|
||||
@@ -1341,6 +1480,7 @@ object ProtocolManager {
|
||||
_devices.value = emptyList()
|
||||
_pendingDeviceVerification.value = null
|
||||
syncRequestInFlight = false
|
||||
clearSyncRequestTimeout()
|
||||
setSyncInProgress(false)
|
||||
resyncRequiredAfterAccountInit = false
|
||||
scope.cancel()
|
||||
|
||||
@@ -1,163 +1,332 @@
|
||||
package com.rosetta.messenger.network
|
||||
|
||||
/**
|
||||
* Binary stream for protocol packets
|
||||
* Matches the React Native implementation exactly
|
||||
* Binary stream for protocol packets.
|
||||
* Ported from desktop/dev stream.ts implementation.
|
||||
*/
|
||||
class Stream(stream: ByteArray = ByteArray(0)) {
|
||||
private var _stream = mutableListOf<Int>()
|
||||
private var _readPointer = 0
|
||||
private var _writePointer = 0
|
||||
|
||||
private var stream: ByteArray
|
||||
private var readPointer = 0 // bits
|
||||
private var writePointer = 0 // bits
|
||||
|
||||
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 {
|
||||
return _stream.map { it.toByte() }.toByteArray()
|
||||
return stream.copyOf(length())
|
||||
}
|
||||
|
||||
fun getReadPointerBits(): Int = _readPointer
|
||||
|
||||
fun getTotalBits(): Int = _stream.size * 8
|
||||
|
||||
fun getRemainingBits(): Int = getTotalBits() - _readPointer
|
||||
|
||||
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++
|
||||
fun setStream(stream: ByteArray = ByteArray(0)) {
|
||||
if (stream.isEmpty()) {
|
||||
this.stream = ByteArray(0)
|
||||
this.readPointer = 0
|
||||
this.writePointer = 0
|
||||
return
|
||||
}
|
||||
this.stream = stream.copyOf()
|
||||
this.readPointer = 0
|
||||
this.writePointer = this.stream.size shl 3
|
||||
}
|
||||
|
||||
fun readInt8(): Int {
|
||||
var value = 0
|
||||
val negationBit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
|
||||
_readPointer++
|
||||
|
||||
for (i in 0 until 8) {
|
||||
val bit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
|
||||
value = value or (bit shl (7 - i))
|
||||
_readPointer++
|
||||
}
|
||||
|
||||
return if (negationBit == 1) -value else value
|
||||
}
|
||||
|
||||
|
||||
fun getBuffer(): ByteArray = getStream()
|
||||
|
||||
fun isEmpty(): Boolean = writePointer == 0
|
||||
|
||||
fun length(): Int = (writePointer + 7) shr 3
|
||||
|
||||
fun getReadPointerBits(): Int = readPointer
|
||||
|
||||
fun getTotalBits(): Int = writePointer
|
||||
|
||||
fun getRemainingBits(): Int = writePointer - readPointer
|
||||
|
||||
fun hasRemainingBits(): Boolean = readPointer < writePointer
|
||||
|
||||
fun writeBit(value: Int) {
|
||||
val bit = value and 1
|
||||
ensureCapacity(_writePointer shr 3)
|
||||
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (bit shl (7 - (_writePointer and 7)))
|
||||
_writePointer++
|
||||
writeBits((value and 1).toULong(), 1)
|
||||
}
|
||||
|
||||
fun readBit(): Int {
|
||||
val bit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
|
||||
_readPointer++
|
||||
return bit
|
||||
}
|
||||
|
||||
|
||||
fun readBit(): Int = readBits(1).toInt()
|
||||
|
||||
fun writeBoolean(value: Boolean) {
|
||||
writeBit(if (value) 1 else 0)
|
||||
}
|
||||
|
||||
fun readBoolean(): Boolean {
|
||||
return readBit() == 1
|
||||
|
||||
fun readBoolean(): Boolean = readBit() == 1
|
||||
|
||||
fun writeByte(value: 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) {
|
||||
writeInt8(value shr 8)
|
||||
writeInt8(value and 0xFF)
|
||||
writeUInt16(value)
|
||||
}
|
||||
|
||||
|
||||
fun readInt16(): Int {
|
||||
val high = readInt8() shl 8
|
||||
return high or readInt8()
|
||||
val value = readUInt16()
|
||||
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) {
|
||||
writeInt16(value shr 16)
|
||||
writeInt16(value and 0xFFFF)
|
||||
writeUInt32(value.toLong() and 0xFFFF_FFFFL)
|
||||
}
|
||||
|
||||
fun readInt32(): Int {
|
||||
val high = readInt16() shl 16
|
||||
return high or readInt16()
|
||||
|
||||
fun readInt32(): Int = readUInt32().toInt()
|
||||
|
||||
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) {
|
||||
val high = (value shr 32).toInt()
|
||||
val low = (value and 0xFFFFFFFF).toInt()
|
||||
writeInt32(high)
|
||||
writeInt32(low)
|
||||
}
|
||||
|
||||
fun readInt64(): Long {
|
||||
val high = readInt32().toLong()
|
||||
val low = (readInt32().toLong() and 0xFFFFFFFFL)
|
||||
|
||||
fun readUInt64(): ULong {
|
||||
val high = readUInt32().toULong()
|
||||
val low = readUInt32().toULong()
|
||||
return (high shl 32) or low
|
||||
}
|
||||
|
||||
fun writeString(value: String) {
|
||||
writeInt32(value.length)
|
||||
for (char in value) {
|
||||
writeInt16(char.code)
|
||||
|
||||
fun writeInt64(value: Long) {
|
||||
writeUInt64(value.toULong())
|
||||
}
|
||||
|
||||
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 {
|
||||
val length = readInt32()
|
||||
// Desktop parity + safety: don't trust malformed string length.
|
||||
val bytesAvailable = _stream.size - (_readPointer shr 3)
|
||||
if (length < 0 || (length.toLong() * 2L) > bytesAvailable.toLong()) {
|
||||
android.util.Log.w(
|
||||
"RosettaStream",
|
||||
"readString invalid length=$length, bytesAvailable=$bytesAvailable, readPointer=$_readPointer"
|
||||
)
|
||||
return ""
|
||||
val len = readUInt32()
|
||||
if (len > Int.MAX_VALUE.toLong()) {
|
||||
throw IllegalStateException("String length too large: $len")
|
||||
}
|
||||
val sb = StringBuilder()
|
||||
for (i in 0 until length) {
|
||||
sb.append(readInt16().toChar())
|
||||
|
||||
val requiredBits = len * 16L
|
||||
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) {
|
||||
writeInt32(value.size)
|
||||
for (byte in value) {
|
||||
writeInt8(byte.toInt())
|
||||
|
||||
fun writeBytes(value: ByteArray?) {
|
||||
val bytes = value ?: ByteArray(0)
|
||||
writeUInt32(bytes.size.toLong())
|
||||
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 {
|
||||
val length = readInt32()
|
||||
val bytes = ByteArray(length)
|
||||
for (i in 0 until length) {
|
||||
bytes[i] = readInt8().toByte()
|
||||
val len = readUInt32()
|
||||
if (len == 0L) return ByteArray(0)
|
||||
if (len > Int.MAX_VALUE.toLong()) return ByteArray(0)
|
||||
|
||||
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) {
|
||||
while (_stream.size <= index) {
|
||||
_stream.add(0)
|
||||
|
||||
private fun remainingBits(): Long = (writePointer - readPointer).toLong()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
107
app/src/main/java/com/rosetta/messenger/network/XChaCha20E2EE.kt
Normal file
107
app/src/main/java/com/rosetta/messenger/network/XChaCha20E2EE.kt
Normal 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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -284,6 +284,7 @@ fun ChatDetailScreen(
|
||||
user: SearchUser,
|
||||
onBack: () -> Unit,
|
||||
onNavigateToChat: (SearchUser) -> Unit,
|
||||
onCallClick: (SearchUser) -> Unit = {},
|
||||
onUserProfileClick: (SearchUser) -> Unit = {},
|
||||
onGroupInfoClick: (SearchUser) -> Unit = {},
|
||||
currentUserPublicKey: String,
|
||||
@@ -1873,8 +1874,7 @@ fun ChatDetailScreen(
|
||||
!isSystemAccount
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { /* TODO: Voice call */
|
||||
}
|
||||
onClick = { onCallClick(user) }
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default
|
||||
|
||||
@@ -2063,6 +2063,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
message.attachments.any { it.type == AttachmentType.IMAGE } -> "Photo"
|
||||
message.attachments.any { it.type == AttachmentType.FILE } -> "File"
|
||||
message.attachments.any { it.type == AttachmentType.AVATAR } -> "Avatar"
|
||||
message.attachments.any { it.type == AttachmentType.CALL } -> "Call"
|
||||
message.forwardedMessages.isNotEmpty() -> "Forwarded message"
|
||||
message.replyData != null -> "Reply"
|
||||
else -> "Pinned message"
|
||||
|
||||
@@ -4367,6 +4367,8 @@ fun DialogItemContent(
|
||||
"File" -> "File"
|
||||
dialog.lastMessageAttachmentType ==
|
||||
"Avatar" -> "Avatar"
|
||||
dialog.lastMessageAttachmentType ==
|
||||
"Call" -> "Call"
|
||||
dialog.lastMessageAttachmentType ==
|
||||
"Forwarded" -> "Forwarded message"
|
||||
dialog.lastMessage.isEmpty() ->
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -603,6 +603,7 @@ class ChatsListViewModel(application: Application) : AndroidViewModel(applicatio
|
||||
}
|
||||
2 -> "File" // AttachmentType.FILE = 2
|
||||
3 -> "Avatar" // AttachmentType.AVATAR = 3
|
||||
4 -> "Call" // AttachmentType.CALL = 4
|
||||
else -> null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -40,9 +40,16 @@ fun ConnectionLogsScreen(
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
ProtocolManager.enableUILogs(true)
|
||||
onDispose {
|
||||
ProtocolManager.enableUILogs(false)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(logs.size) {
|
||||
if (logs.isNotEmpty()) {
|
||||
listState.animateScrollToItem(logs.size - 1)
|
||||
listState.scrollToItem(logs.size - 1)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +96,7 @@ fun ConnectionLogsScreen(
|
||||
|
||||
IconButton(onClick = {
|
||||
scope.launch {
|
||||
if (logs.isNotEmpty()) listState.animateScrollToItem(logs.size - 1)
|
||||
if (logs.isNotEmpty()) listState.scrollToItem(logs.size - 1)
|
||||
}
|
||||
}) {
|
||||
Icon(
|
||||
|
||||
@@ -404,6 +404,7 @@ private fun ForwardDialogItem(
|
||||
dialog.lastMessageAttachmentType == "Photo" -> "Photo"
|
||||
dialog.lastMessageAttachmentType == "File" -> "File"
|
||||
dialog.lastMessageAttachmentType == "Avatar" -> "Avatar"
|
||||
dialog.lastMessageAttachmentType == "Call" -> "Call"
|
||||
dialog.lastMessageAttachmentType == "Forwarded" -> "Forwarded message"
|
||||
dialog.lastMessage.isNotEmpty() -> dialog.lastMessage
|
||||
else -> "No messages"
|
||||
|
||||
@@ -101,7 +101,8 @@ fun SearchScreen(
|
||||
protocolState: ProtocolState,
|
||||
onBackClick: () -> Unit,
|
||||
onUserSelect: (SearchUser) -> Unit,
|
||||
onNavigateToCrashLogs: () -> Unit = {}
|
||||
onNavigateToCrashLogs: () -> Unit = {},
|
||||
onNavigateToConnectionLogs: () -> Unit = {}
|
||||
) {
|
||||
// Context и View для мгновенного закрытия клавиатуры
|
||||
val context = LocalContext.current
|
||||
@@ -150,6 +151,11 @@ fun SearchScreen(
|
||||
if (searchQuery.trim().equals("rosettadev1", ignoreCase = true)) {
|
||||
searchViewModel.clearSearchQuery()
|
||||
onNavigateToCrashLogs()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
if (searchQuery.trim().equals("rosettadev2", ignoreCase = true)) {
|
||||
searchViewModel.clearSearchQuery()
|
||||
onNavigateToConnectionLogs()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
@@ -525,6 +526,15 @@ fun MessageAttachments(
|
||||
messageStatus = messageStatus
|
||||
)
|
||||
}
|
||||
AttachmentType.CALL -> {
|
||||
CallAttachment(
|
||||
attachment = attachment,
|
||||
isOutgoing = isOutgoing,
|
||||
isDarkTheme = isDarkTheme,
|
||||
timestamp = timestamp,
|
||||
messageStatus = messageStatus
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
/* 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 */
|
||||
@Composable
|
||||
fun FileAttachment(
|
||||
|
||||
@@ -2235,6 +2235,7 @@ fun ReplyBubble(
|
||||
} else if (!hasImage) {
|
||||
val displayText = when {
|
||||
replyData.attachments.any { it.type == AttachmentType.FILE } -> "File"
|
||||
replyData.attachments.any { it.type == AttachmentType.CALL } -> "Call"
|
||||
else -> "..."
|
||||
}
|
||||
AppleEmojiText(
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
package com.rosetta.messenger.ui.chats.components
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContentValues
|
||||
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.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
@@ -55,6 +60,7 @@ import compose.icons.tablericons.*
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.IOException
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.math.abs
|
||||
@@ -215,6 +221,8 @@ fun ImageViewerScreen(
|
||||
|
||||
// UI visibility state
|
||||
var showControls by remember { mutableStateOf(true) }
|
||||
var showKebabMenu by remember { mutableStateOf(false) }
|
||||
var isSavingToGallery by remember { mutableStateOf(false) }
|
||||
var isTapNavigationInProgress by remember { mutableStateOf(false) }
|
||||
val edgeTapFadeAlpha = remember { Animatable(1f) }
|
||||
val imageBitmapCache =
|
||||
@@ -526,6 +534,77 @@ fun ImageViewerScreen(
|
||||
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
|
||||
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
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.rosetta.messenger.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.icu.text.BreakIterator
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Paint
|
||||
@@ -34,8 +35,163 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
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(
|
||||
emojiDrawable: Drawable,
|
||||
private var sourceFontMetrics: Paint.FontMetricsInt?
|
||||
@@ -204,31 +360,29 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
|
||||
val cursorPosition = selectionStart
|
||||
|
||||
// 🔥 Собираем все позиции эмодзи (и 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>()
|
||||
|
||||
// 1. Ищем :emoji_XXXX: формат
|
||||
val codeMatcher = EMOJI_CODE_PATTERN.matcher(textStr)
|
||||
while (codeMatcher.find()) {
|
||||
val unified = codeMatcher.group(1) ?: continue
|
||||
emojiMatches.add(EmojiMatch(codeMatcher.start(), codeMatcher.end(), unified, true))
|
||||
val rawCode = codeMatcher.group(1) ?: continue
|
||||
val unified =
|
||||
AppleEmojiAssetResolver.resolveUnifiedFromCode(context, rawCode)
|
||||
?: AppleEmojiAssetResolver.normalizeUnifiedCode(rawCode)
|
||||
emojiMatches.add(EmojiMatch(codeMatcher.start(), codeMatcher.end(), unified))
|
||||
}
|
||||
|
||||
// 2. Ищем реальные Unicode эмодзи
|
||||
val matcher = EMOJI_PATTERN.matcher(textStr)
|
||||
while (matcher.find()) {
|
||||
val emoji = matcher.group()
|
||||
val start = matcher.start()
|
||||
val end = matcher.end()
|
||||
|
||||
// Проверяем что этот диапазон не перекрывается с :emoji_XXXX:
|
||||
val overlaps = emojiMatches.any {
|
||||
(start >= it.start && start < it.end) ||
|
||||
(end > it.start && end <= it.end)
|
||||
}
|
||||
if (!overlaps) {
|
||||
emojiMatches.add(EmojiMatch(start, end, emojiToUnified(emoji), false))
|
||||
}
|
||||
// 2. Ищем реальные Unicode эмодзи (графемные кластеры, включая ZWJ/flags/skin tones)
|
||||
val occupiedRanges = emojiMatches.map { it.start until it.end }
|
||||
val unicodeMatches =
|
||||
AppleEmojiAssetResolver.collectUnicodeMatches(
|
||||
context = context,
|
||||
text = textStr,
|
||||
occupiedRanges = occupiedRanges
|
||||
)
|
||||
unicodeMatches.forEach { match ->
|
||||
emojiMatches.add(EmojiMatch(match.start, match.end, match.unified))
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// Собираем все замены (чтобы не сбить индексы)
|
||||
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>()
|
||||
|
||||
// 1. Ищем :emoji_XXXX: формат
|
||||
val codeMatcher = EMOJI_CODE_PATTERN.matcher(text)
|
||||
while (codeMatcher.find()) {
|
||||
val unified = codeMatcher.group(1) ?: continue
|
||||
emojiMatches.add(EmojiMatch(codeMatcher.start(), codeMatcher.end(), unified))
|
||||
val rawCode = codeMatcher.group(1) ?: continue
|
||||
val unified =
|
||||
AppleEmojiAssetResolver.resolveUnifiedFromCode(context, rawCode)
|
||||
?: AppleEmojiAssetResolver.normalizeUnifiedCode(rawCode)
|
||||
emojiMatches.add(
|
||||
EmojiMatch(
|
||||
start = codeMatcher.start(),
|
||||
end = codeMatcher.end(),
|
||||
unified = unified,
|
||||
isCodeFormat = true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 2. Ищем реальные Unicode эмодзи
|
||||
val unicodeMatcher = EMOJI_PATTERN.matcher(text)
|
||||
while (unicodeMatcher.find()) {
|
||||
val emoji = unicodeMatcher.group()
|
||||
val unified = emojiToUnified(emoji)
|
||||
// Проверяем что этот диапазон не перекрывается с :emoji_XXXX:
|
||||
val overlaps = emojiMatches.any {
|
||||
(unicodeMatcher.start() >= it.start && unicodeMatcher.start() < it.end) ||
|
||||
(unicodeMatcher.end() > it.start && unicodeMatcher.end() <= it.end)
|
||||
}
|
||||
if (!overlaps) {
|
||||
emojiMatches.add(EmojiMatch(unicodeMatcher.start(), unicodeMatcher.end(), unified))
|
||||
}
|
||||
// 2. Ищем реальные Unicode эмодзи (включая составные iOS-кластеры)
|
||||
val occupiedRanges = emojiMatches.map { it.start until it.end }
|
||||
val unicodeMatches =
|
||||
AppleEmojiAssetResolver.collectUnicodeMatches(
|
||||
context = context,
|
||||
text = text,
|
||||
occupiedRanges = occupiedRanges
|
||||
)
|
||||
unicodeMatches.forEach { match ->
|
||||
emojiMatches.add(
|
||||
EmojiMatch(
|
||||
start = match.start,
|
||||
end = match.end,
|
||||
unified = match.unified,
|
||||
isCodeFormat = false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// 3. Сортируем по позиции в обратном порядке (чтобы не сбить индексы при замене)
|
||||
@@ -699,8 +859,7 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
|
||||
// Для :emoji_XXXX: заменяем весь текст на пробел + span
|
||||
// Для Unicode эмодзи оставляем символ как есть
|
||||
if (match.end - match.start > 10) {
|
||||
// Это :emoji_XXXX: формат - заменяем на один символ
|
||||
if (match.isCodeFormat) {
|
||||
spannable.replace(match.start, match.end, "\u200B") // Zero-width space
|
||||
spannable.setSpan(span, match.start, match.start + 1,
|
||||
android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
@@ -709,7 +868,7 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
spannable.setSpan(span, match.start, match.end,
|
||||
android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
}
|
||||
} else if (match.end - match.start > 10) {
|
||||
} else if (match.isCodeFormat) {
|
||||
// 🔥 Fallback: если PNG не найден, конвертируем :emoji_XXXX: в Unicode эмодзи
|
||||
val unicodeEmoji = unifiedToEmoji(match.unified)
|
||||
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 эмодзи (😀)
|
||||
*/
|
||||
|
||||
@@ -3,7 +3,7 @@ package com.rosetta.messenger.ui.components.metaball
|
||||
import android.graphics.ColorMatrixColorFilter
|
||||
import android.graphics.Path
|
||||
import android.graphics.RectF
|
||||
import android.util.Log
|
||||
|
||||
import android.graphics.RenderEffect
|
||||
import android.graphics.Shader
|
||||
import android.os.Build
|
||||
@@ -11,13 +11,10 @@ import android.view.Gravity
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.compose.foundation.Canvas
|
||||
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.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
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.width
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
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.LocalDensity
|
||||
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.sp
|
||||
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
@@ -410,17 +405,8 @@ fun ProfileMetaballOverlay(
|
||||
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) {
|
||||
!MetaballDebug.forceNoNotch && isCenteredTopCutout(notchInfo, screenWidthPx)
|
||||
isCenteredTopCutout(notchInfo, screenWidthPx)
|
||||
}
|
||||
|
||||
// Calculate notch radius based on real device notch (like Telegram's ProfileGooeyView)
|
||||
@@ -900,7 +886,7 @@ fun ProfileMetaballOverlayCpu(
|
||||
NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view)
|
||||
}
|
||||
val hasRealNotch = remember(notchInfo, screenWidthPx) {
|
||||
!MetaballDebug.forceNoNotch && isCenteredTopCutout(notchInfo, screenWidthPx)
|
||||
isCenteredTopCutout(notchInfo, screenWidthPx)
|
||||
}
|
||||
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:
|
||||
* 1. GPU path (Android 12+, average+ performance): RenderEffect blur + ColorMatrixColorFilter
|
||||
@@ -1329,36 +1168,9 @@ fun ProfileMetaballEffect(
|
||||
val context = LocalContext.current
|
||||
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
|
||||
val useGpu = when (MetaballDebug.forceMode) {
|
||||
"gpu" -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S // still need API 31
|
||||
"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
|
||||
}
|
||||
val useGpu = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||
val useCpu = !useGpu
|
||||
|
||||
when {
|
||||
useGpu -> {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.rosetta.messenger.ui.crashlogs
|
||||
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
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.filled.ArrowBack
|
||||
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.Share
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.platform.LocalClipboardManager
|
||||
import androidx.compose.ui.Modifier
|
||||
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.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -263,6 +267,8 @@ private fun CrashDetailScreen(
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
val context = LocalContext.current
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
@@ -274,6 +280,14 @@ private fun CrashDetailScreen(
|
||||
}
|
||||
},
|
||||
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 */ }) {
|
||||
Icon(Icons.Default.Share, contentDescription = "Share")
|
||||
}
|
||||
|
||||
@@ -1939,6 +1939,13 @@ private fun CollapsingOtherProfileHeader(
|
||||
overlayColors = com.rosetta.messenger.ui.settings.BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId),
|
||||
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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ class CrashReportManager private constructor(private val context: Context) : Thr
|
||||
fun getCrashReports(context: Context): List<CrashReport> {
|
||||
val crashDir = File(context.filesDir, CRASH_DIR)
|
||||
if (!crashDir.exists()) return emptyList()
|
||||
|
||||
return crashDir.listFiles()
|
||||
|
||||
val reports = crashDir.listFiles()
|
||||
?.filter { it.extension == "txt" }
|
||||
?.sortedByDescending { it.lastModified() }
|
||||
?.map { file ->
|
||||
@@ -54,7 +54,21 @@ class CrashReportManager private constructor(private val context: Context) : Thr
|
||||
timestamp = file.lastModified(),
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -13,10 +13,13 @@ import com.rosetta.messenger.network.ProtocolManager
|
||||
*/
|
||||
object MessageLogger {
|
||||
private const val TAG = "RosettaMsg"
|
||||
|
||||
// Всегда включён — вывод идёт только в ProtocolManager.addLog() (in-memory UI),
|
||||
// не в logcat, безопасно для release
|
||||
private val isEnabled: Boolean = true
|
||||
|
||||
@Volatile
|
||||
private var isEnabled: Boolean = false
|
||||
|
||||
fun setEnabled(enabled: Boolean) {
|
||||
isEnabled = enabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавить лог в UI (Debug Logs в чате)
|
||||
|
||||
BIN
app/src/main/res/raw/call_calling.mp3
Normal file
BIN
app/src/main/res/raw/call_calling.mp3
Normal file
Binary file not shown.
BIN
app/src/main/res/raw/call_connected.mp3
Normal file
BIN
app/src/main/res/raw/call_connected.mp3
Normal file
Binary file not shown.
BIN
app/src/main/res/raw/call_end.mp3
Normal file
BIN
app/src/main/res/raw/call_end.mp3
Normal file
Binary file not shown.
BIN
app/src/main/res/raw/call_ringtone.mp3
Normal file
BIN
app/src/main/res/raw/call_ringtone.mp3
Normal file
Binary file not shown.
82
tools/webrtc-custom/README.md
Normal file
82
tools/webrtc-custom/README.md
Normal 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).
|
||||
13
tools/webrtc-custom/bootstrap_depot_tools.sh
Executable file
13
tools/webrtc-custom/bootstrap_depot_tools.sh
Executable 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\""
|
||||
202
tools/webrtc-custom/build_custom_webrtc.sh
Executable file
202
tools/webrtc-custom/build_custom_webrtc.sh
Executable 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
|
||||
@@ -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: "
|
||||
@@ -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"
|
||||
34
tools/webrtc-custom/patches/0003-macos-host-java-ijar.patch
Normal file
34
tools/webrtc-custom/patches/0003-macos-host-java-ijar.patch
Normal 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") {
|
||||
@@ -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."""
|
||||
@@ -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 '
|
||||
Reference in New Issue
Block a user