Compare commits
32 Commits
b2558653b7
...
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 | |||
| ce16802ac3 | |||
| 9d3e5bcb10 | |||
| d90554aa9f | |||
| c929685e04 |
@@ -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.5"
|
||||
val rosettaVersionCode = 27 // 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 {
|
||||
@@ -129,9 +147,6 @@ dependencies {
|
||||
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||
implementation("io.coil-kt:coil-gif:2.5.0") // For animated WebP/GIF support
|
||||
|
||||
// Jsoup for HTML parsing (Link Preview OG tags)
|
||||
implementation("org.jsoup:jsoup:1.17.2")
|
||||
|
||||
// uCrop for image cropping
|
||||
implementation("com.github.yalantis:ucrop:2.2.8")
|
||||
|
||||
@@ -168,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
|
||||
@@ -58,6 +62,7 @@ import com.rosetta.messenger.ui.settings.OtherProfileScreen
|
||||
import com.rosetta.messenger.ui.settings.ProfileScreen
|
||||
import com.rosetta.messenger.ui.settings.SafetyScreen
|
||||
import com.rosetta.messenger.ui.settings.ThemeScreen
|
||||
import com.rosetta.messenger.ui.settings.ThemeWallpapers
|
||||
import com.rosetta.messenger.ui.settings.UpdatesScreen
|
||||
import com.rosetta.messenger.ui.splash.SplashScreen
|
||||
import com.rosetta.messenger.ui.theme.RosettaAndroidTheme
|
||||
@@ -115,6 +120,7 @@ class MainActivity : FragmentActivity() {
|
||||
|
||||
// 🔥 Инициализируем ProtocolManager для обработки онлайн статусов
|
||||
ProtocolManager.initialize(this)
|
||||
CallManager.initialize(this)
|
||||
|
||||
// 🔔 Инициализируем Firebase для push-уведомлений
|
||||
initializeFirebase()
|
||||
@@ -580,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)
|
||||
@@ -743,6 +920,8 @@ fun MainScreen(
|
||||
.backgroundBlurColorIdForAccount(accountPublicKey)
|
||||
.collectAsState(initial = "avatar")
|
||||
val chatWallpaperId by prefsManager.chatWallpaperId.collectAsState(initial = "")
|
||||
val chatWallpaperIdLight by prefsManager.chatWallpaperIdLight.collectAsState(initial = "")
|
||||
val chatWallpaperIdDark by prefsManager.chatWallpaperIdDark.collectAsState(initial = "")
|
||||
val pinnedChats by prefsManager.pinnedChats.collectAsState(initial = emptySet())
|
||||
|
||||
// AvatarRepository для работы с аватарами
|
||||
@@ -763,6 +942,29 @@ fun MainScreen(
|
||||
// Coroutine scope for profile updates
|
||||
val mainScreenScope = rememberCoroutineScope()
|
||||
|
||||
LaunchedEffect(isDarkTheme, chatWallpaperId, chatWallpaperIdLight, chatWallpaperIdDark) {
|
||||
val targetWallpaperId =
|
||||
ThemeWallpapers.resolveWallpaperForTheme(
|
||||
currentWallpaperId = chatWallpaperId,
|
||||
isDarkTheme = isDarkTheme,
|
||||
darkThemeWallpaperId = chatWallpaperIdDark,
|
||||
lightThemeWallpaperId = chatWallpaperIdLight
|
||||
)
|
||||
|
||||
if (targetWallpaperId != chatWallpaperId) {
|
||||
prefsManager.setChatWallpaperId(targetWallpaperId)
|
||||
}
|
||||
|
||||
val currentThemeStored =
|
||||
if (isDarkTheme) chatWallpaperIdDark else chatWallpaperIdLight
|
||||
if (currentThemeStored != targetWallpaperId) {
|
||||
prefsManager.setChatWallpaperIdForTheme(
|
||||
isDarkTheme = isDarkTheme,
|
||||
value = targetWallpaperId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 🔥 Простая навигация с swipe back
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Base layer - chats list (всегда видимый, чтобы его было видно при свайпе)
|
||||
@@ -971,7 +1173,13 @@ fun MainScreen(
|
||||
onBack = { navStack = navStack.filterNot { it is Screen.Theme } },
|
||||
onThemeModeChange = onThemeModeChange,
|
||||
onWallpaperChange = { wallpaperId ->
|
||||
mainScreenScope.launch { prefsManager.setChatWallpaperId(wallpaperId) }
|
||||
mainScreenScope.launch {
|
||||
prefsManager.setChatWallpaperIdForTheme(
|
||||
isDarkTheme = isDarkTheme,
|
||||
value = wallpaperId
|
||||
)
|
||||
prefsManager.setChatWallpaperId(wallpaperId)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1043,6 +1251,9 @@ fun MainScreen(
|
||||
currentUserUsername = accountUsername,
|
||||
totalUnreadFromOthers = totalUnreadFromOthers,
|
||||
onBack = { popChatAndChildren() },
|
||||
onCallClick = { callableUser ->
|
||||
startCallWithPermission(callableUser)
|
||||
},
|
||||
onUserProfileClick = { user ->
|
||||
if (isCurrentAccountUser(user)) {
|
||||
// Свой профиль из чата открываем поверх текущего чата,
|
||||
@@ -1168,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
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1280,6 +1496,16 @@ fun MainScreen(
|
||||
}
|
||||
val biometricAccountManager = remember { AccountManager(context) }
|
||||
val activity = context as? FragmentActivity
|
||||
val isFingerprintSupported = remember {
|
||||
biometricManager.isFingerprintHardwareAvailable()
|
||||
}
|
||||
|
||||
if (!isFingerprintSupported) {
|
||||
LaunchedEffect(Unit) {
|
||||
navStack = navStack.filterNot { it is Screen.Biometric }
|
||||
}
|
||||
return@SwipeBackContainer
|
||||
}
|
||||
|
||||
BiometricEnableScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
@@ -1327,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() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.rosetta.messenger.biometric
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||
@@ -52,7 +53,15 @@ class BiometricAuthManager(private val context: Context) {
|
||||
* Проверяет доступность STRONG биометрической аутентификации
|
||||
* BIOMETRIC_STRONG требует Class 3 биометрию (отпечаток/лицо с криптографической привязкой)
|
||||
*/
|
||||
fun isFingerprintHardwareAvailable(): Boolean {
|
||||
return context.packageManager.hasSystemFeature(PackageManager.FEATURE_FINGERPRINT)
|
||||
}
|
||||
|
||||
fun isBiometricAvailable(): BiometricAvailability {
|
||||
if (!isFingerprintHardwareAvailable()) {
|
||||
return BiometricAvailability.NotAvailable("Отпечаток пальца не поддерживается")
|
||||
}
|
||||
|
||||
val biometricManager = BiometricManager.from(context)
|
||||
|
||||
return when (biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG)) {
|
||||
|
||||
@@ -1592,7 +1592,6 @@ object MessageCrypto {
|
||||
// Reset bounds to default after first continuation
|
||||
lowerBoundary = 0x80
|
||||
upperBoundary = 0xBF
|
||||
|
||||
if (bytesSeen == bytesNeeded) {
|
||||
// Sequence complete — emit code point
|
||||
if (codePoint <= 0xFFFF) {
|
||||
|
||||
@@ -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: Расшифровка успешна
|
||||
@@ -806,14 +840,10 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
packet.chachaKey
|
||||
}
|
||||
|
||||
val isSelfDialog = packet.toPublicKey.trim() == account
|
||||
// Для исходящих сообщений статус доставки меняется ТОЛЬКО по PacketDelivery.
|
||||
val initialDeliveredStatus =
|
||||
if (isOwnMessage && !isSelfDialog) {
|
||||
DeliveryStatus.WAITING.value
|
||||
} else {
|
||||
DeliveryStatus.DELIVERED.value
|
||||
}
|
||||
// Desktop parity (useSynchronize.ts):
|
||||
// own messages received via PacketMessage sync are inserted as DELIVERED immediately.
|
||||
// WAITING is used only for messages created locally on this device before PacketDelivery.
|
||||
val initialDeliveredStatus = DeliveryStatus.DELIVERED.value
|
||||
|
||||
// Создаем entity для кэша и возможной вставки
|
||||
val entity =
|
||||
@@ -855,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)
|
||||
@@ -959,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
|
||||
}
|
||||
@@ -1010,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
|
||||
}
|
||||
@@ -1131,6 +1168,23 @@ class MessageRepository private constructor(private val context: Context) {
|
||||
val privateKey = currentPrivateKey ?: return
|
||||
val now = System.currentTimeMillis()
|
||||
|
||||
// Desktop parity recovery:
|
||||
// historically, own synced direct messages ("sync:*" chacha_key) could be saved as WAITING/ERROR
|
||||
// on Android and then incorrectly shown with failed status.
|
||||
// Desktop stores them as DELIVERED from the beginning.
|
||||
val syncedOpponentsWithWrongStatus =
|
||||
messageDao.getSyncedOwnMessageOpponentsWithNonDeliveredStatus(account)
|
||||
val normalizedSyncedCount = messageDao.markSyncedOwnMessagesAsDelivered(account)
|
||||
if (normalizedSyncedCount > 0) {
|
||||
syncedOpponentsWithWrongStatus.forEach { opponentKey ->
|
||||
runCatching { dialogDao.updateDialogFromMessages(account, opponentKey) }
|
||||
}
|
||||
android.util.Log.i(
|
||||
"MessageRepository",
|
||||
"✅ Normalized $normalizedSyncedCount synced own messages to DELIVERED"
|
||||
)
|
||||
}
|
||||
|
||||
// Mark expired messages as ERROR (older than 80 seconds)
|
||||
val expiredCount = messageDao.markExpiredWaitingAsError(account, now - MESSAGE_MAX_TIME_TO_DELIVERED_MS)
|
||||
if (expiredCount > 0) {
|
||||
|
||||
@@ -28,6 +28,8 @@ class PreferencesManager(private val context: Context) {
|
||||
val IS_DARK_THEME = booleanPreferencesKey("is_dark_theme")
|
||||
val THEME_MODE = stringPreferencesKey("theme_mode") // "light", "dark", "auto"
|
||||
val CHAT_WALLPAPER_ID = stringPreferencesKey("chat_wallpaper_id") // empty = no wallpaper
|
||||
val CHAT_WALLPAPER_ID_LIGHT = stringPreferencesKey("chat_wallpaper_id_light")
|
||||
val CHAT_WALLPAPER_ID_DARK = stringPreferencesKey("chat_wallpaper_id_dark")
|
||||
|
||||
// Notifications
|
||||
val NOTIFICATIONS_ENABLED = booleanPreferencesKey("notifications_enabled")
|
||||
@@ -104,10 +106,21 @@ class PreferencesManager(private val context: Context) {
|
||||
val chatWallpaperId: Flow<String> =
|
||||
context.dataStore.data.map { preferences -> preferences[CHAT_WALLPAPER_ID] ?: "" }
|
||||
|
||||
val chatWallpaperIdLight: Flow<String> =
|
||||
context.dataStore.data.map { preferences -> preferences[CHAT_WALLPAPER_ID_LIGHT] ?: "" }
|
||||
|
||||
val chatWallpaperIdDark: Flow<String> =
|
||||
context.dataStore.data.map { preferences -> preferences[CHAT_WALLPAPER_ID_DARK] ?: "" }
|
||||
|
||||
suspend fun setChatWallpaperId(value: String) {
|
||||
context.dataStore.edit { preferences -> preferences[CHAT_WALLPAPER_ID] = value }
|
||||
}
|
||||
|
||||
suspend fun setChatWallpaperIdForTheme(isDarkTheme: Boolean, value: String) {
|
||||
val key = if (isDarkTheme) CHAT_WALLPAPER_ID_DARK else CHAT_WALLPAPER_ID_LIGHT
|
||||
context.dataStore.edit { preferences -> preferences[key] = value }
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
// 🔔 NOTIFICATIONS
|
||||
// ═════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -17,17 +17,10 @@ object ReleaseNotes {
|
||||
val RELEASE_NOTICE = """
|
||||
Update v$VERSION_PLACEHOLDER
|
||||
|
||||
Что обновлено после версии 1.2.4
|
||||
|
||||
Статусы отправки и прочтения
|
||||
- Исправлены ложные двойные галочки у фото и медиа-сообщений до фактической доставки
|
||||
- Исходящие сообщения теперь остаются в ожидании до реального PacketDelivery с сервера
|
||||
- PacketRead больше не переводит недоставленные сообщения в «прочитано»
|
||||
- Синхронизация статусов между БД, кэшем и UI стала стабильнее в личных чатах и группах
|
||||
|
||||
Надёжность отображения
|
||||
- Убраны ложные переходы статусов при быстрых событиях синхронизации
|
||||
- Логика read receipts приведена к более корректному серверному подтверждению
|
||||
Защищенные звонки и диагностика 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
|
||||
@@ -482,6 +482,35 @@ interface MessageDao {
|
||||
)
|
||||
suspend fun markExpiredWaitingAsError(account: String, maxTimestamp: Long): Int
|
||||
|
||||
/**
|
||||
* Desktop parity recovery:
|
||||
* own direct messages synced from another device are stored with chacha_key "sync:*"
|
||||
* and should always be DELIVERED (never WAITING/ERROR).
|
||||
*/
|
||||
@Query(
|
||||
"""
|
||||
UPDATE messages
|
||||
SET delivered = 1
|
||||
WHERE account = :account
|
||||
AND from_me = 1
|
||||
AND delivered != 1
|
||||
AND chacha_key LIKE 'sync:%'
|
||||
"""
|
||||
)
|
||||
suspend fun markSyncedOwnMessagesAsDelivered(account: String): Int
|
||||
|
||||
@Query(
|
||||
"""
|
||||
SELECT DISTINCT to_public_key
|
||||
FROM messages
|
||||
WHERE account = :account
|
||||
AND from_me = 1
|
||||
AND delivered != 1
|
||||
AND chacha_key LIKE 'sync:%'
|
||||
"""
|
||||
)
|
||||
suspend fun getSyncedOwnMessageOpponentsWithNonDeliveredStatus(account: String): List<String>
|
||||
|
||||
/**
|
||||
* Update delivery status AND timestamp on delivery confirmation.
|
||||
* Desktop parity: useDialogFiber.ts sets timestamp = Date.now() on PacketDelivery.
|
||||
@@ -529,6 +558,18 @@ interface MessageDao {
|
||||
"""
|
||||
)
|
||||
suspend fun getMessagesWithFiles(account: String, limit: Int, offset: Int): List<MessageEntity>
|
||||
|
||||
/** Все сообщения аккаунта (для поиска по тексту), отсортированные по дате */
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM messages
|
||||
WHERE account = :account
|
||||
AND plain_message != ''
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
"""
|
||||
)
|
||||
suspend fun getAllMessagesPaged(account: String, limit: Int, offset: Int): List<MessageEntity>
|
||||
}
|
||||
|
||||
/** DAO для работы с диалогами */
|
||||
@@ -629,6 +670,18 @@ interface DialogDao {
|
||||
@Query("SELECT * FROM dialogs WHERE account = :account AND opponent_key = :opponentKey LIMIT 1")
|
||||
suspend fun getDialog(account: String, opponentKey: String): DialogEntity?
|
||||
|
||||
/** Найти direct-диалог по username собеседника (без учета регистра и '@'). */
|
||||
@Query(
|
||||
"""
|
||||
SELECT * FROM dialogs
|
||||
WHERE account = :account
|
||||
AND opponent_key NOT LIKE '#group:%'
|
||||
AND LOWER(REPLACE(TRIM(opponent_username), '@', '')) = LOWER(REPLACE(TRIM(:username), '@', ''))
|
||||
LIMIT 1
|
||||
"""
|
||||
)
|
||||
suspend fun getDialogByUsername(account: String, username: String): DialogEntity?
|
||||
|
||||
/** Обновить последнее сообщение */
|
||||
@Query(
|
||||
"""
|
||||
|
||||
@@ -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())
|
||||
@@ -80,9 +89,15 @@ object ProtocolManager {
|
||||
private val userInfoCache = ConcurrentHashMap<String, SearchUser>()
|
||||
// Pending resolves: publicKey → list of continuations waiting for the result
|
||||
private val pendingResolves = ConcurrentHashMap<String, MutableList<kotlinx.coroutines.CancellableContinuation<SearchUser?>>>()
|
||||
// Pending search requests: query(username/publicKey fragment) → waiting continuations
|
||||
private val pendingSearchQueries =
|
||||
ConcurrentHashMap<String, MutableList<kotlinx.coroutines.CancellableContinuation<List<SearchUser>>>>()
|
||||
|
||||
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)
|
||||
@@ -120,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()
|
||||
@@ -134,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
|
||||
@@ -146,6 +176,8 @@ object ProtocolManager {
|
||||
synchronized(debugLogsLock) {
|
||||
debugLogsBuffer.clear()
|
||||
}
|
||||
suppressedHeartbeatOkLogs = 0
|
||||
lastHeartbeatOkLogAtMs = 0L
|
||||
_debugLogs.value = emptyList()
|
||||
}
|
||||
|
||||
@@ -204,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
|
||||
@@ -473,6 +506,56 @@ object ProtocolManager {
|
||||
try { cont.resume(null) } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
// Resume pending username/query searches.
|
||||
// Server may return query in different case/format, so match robustly.
|
||||
if (searchPacket.search.isNotEmpty()) {
|
||||
val rawQuery = searchPacket.search.trim()
|
||||
val normalizedQuery = normalizeSearchQuery(rawQuery)
|
||||
val continuations = LinkedHashSet<kotlinx.coroutines.CancellableContinuation<List<SearchUser>>>()
|
||||
|
||||
fun collectByKey(key: String) {
|
||||
if (key.isEmpty()) return
|
||||
pendingSearchQueries.remove(key)?.let { continuations.addAll(it) }
|
||||
}
|
||||
|
||||
collectByKey(rawQuery)
|
||||
if (normalizedQuery.isNotEmpty() && normalizedQuery != rawQuery) {
|
||||
collectByKey(normalizedQuery)
|
||||
}
|
||||
|
||||
if (continuations.isEmpty()) {
|
||||
val matchedByQuery =
|
||||
pendingSearchQueries.keys.firstOrNull { pendingKey ->
|
||||
pendingKey.equals(rawQuery, ignoreCase = true) ||
|
||||
normalizeSearchQuery(pendingKey) == normalizedQuery
|
||||
}
|
||||
if (matchedByQuery != null) collectByKey(matchedByQuery)
|
||||
}
|
||||
|
||||
if (continuations.isEmpty() && searchPacket.users.isNotEmpty()) {
|
||||
val responseUsernames =
|
||||
searchPacket.users
|
||||
.map { normalizeSearchQuery(it.username) }
|
||||
.filter { it.isNotEmpty() }
|
||||
.toSet()
|
||||
if (responseUsernames.isNotEmpty()) {
|
||||
val matchedByUsers =
|
||||
pendingSearchQueries.keys.firstOrNull { pendingKey ->
|
||||
val normalizedPending = normalizeSearchQuery(pendingKey)
|
||||
normalizedPending.isNotEmpty() &&
|
||||
responseUsernames.contains(normalizedPending)
|
||||
}
|
||||
if (matchedByUsers != null) collectByKey(matchedByUsers)
|
||||
}
|
||||
}
|
||||
|
||||
continuations.forEach { cont ->
|
||||
try {
|
||||
cont.resume(searchPacket.users)
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 🚀 Обработчик транспортного сервера (0x0F)
|
||||
@@ -622,6 +705,7 @@ object ProtocolManager {
|
||||
|
||||
private fun finishSyncCycle(reason: String) {
|
||||
syncRequestInFlight = false
|
||||
clearSyncRequestTimeout()
|
||||
inboundProcessingFailures.set(0)
|
||||
addLog(reason)
|
||||
setSyncInProgress(false)
|
||||
@@ -678,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
|
||||
}
|
||||
@@ -688,6 +773,7 @@ object ProtocolManager {
|
||||
repositoryAccount != protocolAccount
|
||||
) {
|
||||
syncRequestInFlight = false
|
||||
clearSyncRequestTimeout()
|
||||
requireResyncAfterAccountInit(
|
||||
"⏳ Sync postponed: repository bound to another account"
|
||||
)
|
||||
@@ -701,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
|
||||
@@ -721,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")
|
||||
@@ -770,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
|
||||
@@ -973,6 +1079,18 @@ object ProtocolManager {
|
||||
return userInfoCache[publicKey]
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔍 Get cached user by username (no network request).
|
||||
* Username compare is case-insensitive and ignores '@'.
|
||||
*/
|
||||
fun getCachedUserByUsername(username: String): SearchUser? {
|
||||
val normalizedUsername = normalizeSearchQuery(username)
|
||||
if (normalizedUsername.isEmpty()) return null
|
||||
return userInfoCache.values.firstOrNull { cached ->
|
||||
normalizeSearchQuery(cached.username) == normalizedUsername
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔍 Resolve publicKey → full SearchUser (with server request if needed)
|
||||
*/
|
||||
@@ -1009,6 +1127,52 @@ object ProtocolManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔍 Search users by query (usually username without '@').
|
||||
* Returns raw PacketSearch users list for the exact query.
|
||||
*/
|
||||
suspend fun searchUsers(query: String, timeoutMs: Long = 3000): List<SearchUser> {
|
||||
val normalizedQuery = normalizeSearchQuery(query)
|
||||
if (normalizedQuery.isEmpty()) return emptyList()
|
||||
|
||||
val privateHash = try { getProtocol().getPrivateHash() } catch (_: Exception) { null }
|
||||
?: return emptyList()
|
||||
|
||||
val cachedMatches =
|
||||
userInfoCache.values.filter { cached ->
|
||||
normalizeSearchQuery(cached.username) == normalizedQuery && cached.publicKey.isNotBlank()
|
||||
}
|
||||
if (cachedMatches.isNotEmpty()) {
|
||||
return cachedMatches.distinctBy { it.publicKey }
|
||||
}
|
||||
|
||||
return try {
|
||||
withTimeout(timeoutMs) {
|
||||
suspendCancellableCoroutine { cont ->
|
||||
pendingSearchQueries
|
||||
.getOrPut(normalizedQuery) { mutableListOf() }
|
||||
.add(cont)
|
||||
|
||||
cont.invokeOnCancellation {
|
||||
pendingSearchQueries[normalizedQuery]?.remove(cont)
|
||||
if (pendingSearchQueries[normalizedQuery]?.isEmpty() == true) {
|
||||
pendingSearchQueries.remove(normalizedQuery)
|
||||
}
|
||||
}
|
||||
|
||||
val packet = PacketSearch().apply {
|
||||
this.privateKey = privateHash
|
||||
this.search = normalizedQuery
|
||||
}
|
||||
send(packet)
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
pendingSearchQueries.remove(normalizedQuery)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept a pending device login request.
|
||||
*/
|
||||
@@ -1141,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
|
||||
@@ -1211,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
|
||||
@@ -1227,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)
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
@@ -70,6 +71,7 @@ import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.compose.ui.res.painterResource
|
||||
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.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -90,6 +92,7 @@ import com.rosetta.messenger.data.GroupRepository
|
||||
import com.rosetta.messenger.data.MessageRepository
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
import com.rosetta.messenger.ui.chats.attach.ChatAttachAlert
|
||||
@@ -281,6 +284,7 @@ fun ChatDetailScreen(
|
||||
user: SearchUser,
|
||||
onBack: () -> Unit,
|
||||
onNavigateToChat: (SearchUser) -> Unit,
|
||||
onCallClick: (SearchUser) -> Unit = {},
|
||||
onUserProfileClick: (SearchUser) -> Unit = {},
|
||||
onGroupInfoClick: (SearchUser) -> Unit = {},
|
||||
currentUserPublicKey: String,
|
||||
@@ -411,6 +415,57 @@ fun ChatDetailScreen(
|
||||
val chatTitle =
|
||||
if (isSavedMessages) "Saved Messages"
|
||||
else user.title.ifEmpty { user.publicKey.take(10) }
|
||||
var chatHeaderVerified by
|
||||
remember(user.publicKey, user.verified) {
|
||||
mutableIntStateOf(user.verified.coerceAtLeast(0))
|
||||
}
|
||||
|
||||
LaunchedEffect(
|
||||
user.publicKey,
|
||||
user.verified,
|
||||
currentUserPublicKey,
|
||||
isSavedMessages,
|
||||
isGroupChat
|
||||
) {
|
||||
chatHeaderVerified = user.verified.coerceAtLeast(0)
|
||||
|
||||
if (isSavedMessages || isGroupChat || currentUserPublicKey.isBlank()) {
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
val normalizedPublicKey = user.publicKey.trim()
|
||||
if (normalizedPublicKey.isBlank()) return@LaunchedEffect
|
||||
|
||||
val cachedVerified =
|
||||
ProtocolManager.getCachedUserInfo(normalizedPublicKey)?.verified ?: 0
|
||||
if (cachedVerified > chatHeaderVerified) {
|
||||
chatHeaderVerified = cachedVerified
|
||||
}
|
||||
|
||||
val localVerified =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
database
|
||||
.dialogDao()
|
||||
.getDialog(currentUserPublicKey, normalizedPublicKey)
|
||||
?.verified ?: 0
|
||||
}
|
||||
.getOrDefault(0)
|
||||
}
|
||||
if (localVerified > chatHeaderVerified) {
|
||||
chatHeaderVerified = localVerified
|
||||
}
|
||||
|
||||
val resolvedVerified =
|
||||
runCatching {
|
||||
viewModel.resolveUserForProfile(normalizedPublicKey)?.verified
|
||||
?: 0
|
||||
}
|
||||
.getOrDefault(0)
|
||||
if (resolvedVerified > chatHeaderVerified) {
|
||||
chatHeaderVerified = resolvedVerified
|
||||
}
|
||||
}
|
||||
|
||||
val openDialogInfo: () -> Unit = {
|
||||
hideInputOverlays()
|
||||
@@ -1323,8 +1378,35 @@ fun ChatDetailScreen(
|
||||
isDarkTheme
|
||||
)
|
||||
|
||||
// Smooth transition when switching to another dialog from in-message mentions/tags.
|
||||
// SwipeBackContainer stays mounted, so we animate content change locally.
|
||||
var runDialogSwitchEnterAnimation by remember(user.publicKey) { mutableStateOf(false) }
|
||||
LaunchedEffect(user.publicKey) {
|
||||
runDialogSwitchEnterAnimation = false
|
||||
withFrameNanos { }
|
||||
runDialogSwitchEnterAnimation = true
|
||||
}
|
||||
val dialogSwitchAlpha by
|
||||
animateFloatAsState(
|
||||
targetValue = if (runDialogSwitchEnterAnimation) 1f else 0.9f,
|
||||
animationSpec = tween(durationMillis = 240),
|
||||
label = "dialogSwitchAlpha"
|
||||
)
|
||||
val dialogSwitchOffsetX by
|
||||
animateDpAsState(
|
||||
targetValue = if (runDialogSwitchEnterAnimation) 0.dp else 24.dp,
|
||||
animationSpec = tween(durationMillis = 240),
|
||||
label = "dialogSwitchOffsetX"
|
||||
)
|
||||
|
||||
// 🚀 Весь контент (swipe-back обрабатывается в SwipeBackContainer)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxSize().graphicsLayer {
|
||||
alpha = dialogSwitchAlpha
|
||||
translationX = with(density) { dialogSwitchOffsetX.toPx() }
|
||||
}
|
||||
) {
|
||||
// Telegram-style solid header background (без blur)
|
||||
val headerBackground = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF228BE6)
|
||||
|
||||
@@ -1725,7 +1807,7 @@ fun ChatDetailScreen(
|
||||
)
|
||||
if (!isSavedMessages &&
|
||||
!isGroupChat &&
|
||||
(user.verified >
|
||||
(chatHeaderVerified >
|
||||
0 || isRosettaOfficial)
|
||||
) {
|
||||
Spacer(
|
||||
@@ -1736,7 +1818,7 @@ fun ChatDetailScreen(
|
||||
)
|
||||
VerifiedBadge(
|
||||
verified =
|
||||
if (user.verified > 0) user.verified else 1,
|
||||
if (chatHeaderVerified > 0) chatHeaderVerified else 1,
|
||||
size =
|
||||
16,
|
||||
isDarkTheme =
|
||||
@@ -1792,8 +1874,7 @@ fun ChatDetailScreen(
|
||||
!isSystemAccount
|
||||
) {
|
||||
IconButton(
|
||||
onClick = { /* TODO: Voice call */
|
||||
}
|
||||
onClick = { onCallClick(user) }
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default
|
||||
@@ -2526,102 +2607,184 @@ fun ChatDetailScreen(
|
||||
verticalArrangement =
|
||||
Arrangement.Center
|
||||
) {
|
||||
if (isSavedMessages) {
|
||||
val composition by
|
||||
rememberLottieComposition(
|
||||
LottieCompositionSpec
|
||||
.RawRes(
|
||||
R.raw.saved
|
||||
)
|
||||
val showSavedMessagesBackdrop =
|
||||
isSavedMessages &&
|
||||
hasChatWallpaper
|
||||
val savedMessagesBackdropShape =
|
||||
RoundedCornerShape(
|
||||
22.dp
|
||||
)
|
||||
val savedMessagesBackdropColor =
|
||||
if (isDarkTheme)
|
||||
Color(
|
||||
0xB3212121
|
||||
)
|
||||
val progress by
|
||||
animateLottieCompositionAsState(
|
||||
else
|
||||
Color(
|
||||
0xB32A2A2A
|
||||
)
|
||||
val contentModifier =
|
||||
if (
|
||||
showSavedMessagesBackdrop
|
||||
) {
|
||||
Modifier
|
||||
.widthIn(
|
||||
max =
|
||||
340.dp
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
color =
|
||||
savedMessagesBackdropColor,
|
||||
shape =
|
||||
savedMessagesBackdropShape
|
||||
)
|
||||
.border(
|
||||
width =
|
||||
1.dp,
|
||||
color =
|
||||
Color.White
|
||||
.copy(
|
||||
alpha =
|
||||
0.12f
|
||||
),
|
||||
shape =
|
||||
savedMessagesBackdropShape
|
||||
)
|
||||
.padding(
|
||||
horizontal =
|
||||
22.dp,
|
||||
vertical =
|
||||
18.dp
|
||||
)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier =
|
||||
contentModifier,
|
||||
horizontalAlignment =
|
||||
Alignment
|
||||
.CenterHorizontally
|
||||
) {
|
||||
val emptyStateTextAlign =
|
||||
if (isSavedMessages)
|
||||
TextAlign.Center
|
||||
else
|
||||
TextAlign.Start
|
||||
val emptyStateTextModifier =
|
||||
if (isSavedMessages)
|
||||
Modifier.fillMaxWidth()
|
||||
else
|
||||
Modifier
|
||||
if (isSavedMessages) {
|
||||
val composition by
|
||||
rememberLottieComposition(
|
||||
LottieCompositionSpec
|
||||
.RawRes(
|
||||
R.raw.saved
|
||||
)
|
||||
)
|
||||
val progress by
|
||||
animateLottieCompositionAsState(
|
||||
composition =
|
||||
composition,
|
||||
iterations =
|
||||
LottieConstants
|
||||
.IterateForever
|
||||
)
|
||||
LottieAnimation(
|
||||
composition =
|
||||
composition,
|
||||
iterations =
|
||||
LottieConstants
|
||||
.IterateForever
|
||||
progress = {
|
||||
progress
|
||||
},
|
||||
modifier =
|
||||
Modifier.size(
|
||||
120.dp
|
||||
)
|
||||
)
|
||||
LottieAnimation(
|
||||
composition =
|
||||
composition,
|
||||
progress = {
|
||||
progress
|
||||
},
|
||||
} else {
|
||||
val composition by
|
||||
rememberLottieComposition(
|
||||
LottieCompositionSpec
|
||||
.RawRes(
|
||||
R.raw.speech
|
||||
)
|
||||
)
|
||||
val progress by
|
||||
animateLottieCompositionAsState(
|
||||
composition =
|
||||
composition,
|
||||
iterations =
|
||||
LottieConstants
|
||||
.IterateForever
|
||||
)
|
||||
LottieAnimation(
|
||||
composition =
|
||||
composition,
|
||||
progress = {
|
||||
progress
|
||||
},
|
||||
modifier =
|
||||
Modifier.size(
|
||||
120.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
Spacer(
|
||||
modifier =
|
||||
Modifier.size(
|
||||
120.dp
|
||||
Modifier.height(
|
||||
16.dp
|
||||
)
|
||||
)
|
||||
} else {
|
||||
val composition by
|
||||
rememberLottieComposition(
|
||||
LottieCompositionSpec
|
||||
.RawRes(
|
||||
R.raw.speech
|
||||
)
|
||||
)
|
||||
val progress by
|
||||
animateLottieCompositionAsState(
|
||||
composition =
|
||||
composition,
|
||||
iterations =
|
||||
LottieConstants
|
||||
.IterateForever
|
||||
)
|
||||
LottieAnimation(
|
||||
composition =
|
||||
composition,
|
||||
progress = {
|
||||
progress
|
||||
},
|
||||
modifier =
|
||||
Modifier.size(
|
||||
120.dp
|
||||
Text(
|
||||
text =
|
||||
if (isSavedMessages
|
||||
)
|
||||
"Save messages here for quick access"
|
||||
else
|
||||
"No messages yet",
|
||||
fontSize =
|
||||
16.sp,
|
||||
color =
|
||||
dateHeaderTextColor,
|
||||
fontWeight =
|
||||
FontWeight
|
||||
.Medium,
|
||||
textAlign =
|
||||
emptyStateTextAlign,
|
||||
modifier =
|
||||
emptyStateTextModifier
|
||||
)
|
||||
Spacer(
|
||||
modifier =
|
||||
Modifier.height(
|
||||
8.dp
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
if (isSavedMessages
|
||||
)
|
||||
"Forward messages here or send notes to yourself"
|
||||
else
|
||||
"Send a message to start the conversation",
|
||||
fontSize =
|
||||
14.sp,
|
||||
color =
|
||||
dateHeaderTextColor
|
||||
.copy(
|
||||
alpha =
|
||||
0.7f
|
||||
),
|
||||
textAlign =
|
||||
emptyStateTextAlign,
|
||||
modifier =
|
||||
emptyStateTextModifier
|
||||
)
|
||||
}
|
||||
Spacer(
|
||||
modifier =
|
||||
Modifier.height(
|
||||
16.dp
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
if (isSavedMessages
|
||||
)
|
||||
"Save messages here for quick access"
|
||||
else
|
||||
"No messages yet",
|
||||
fontSize = 16.sp,
|
||||
color =
|
||||
dateHeaderTextColor,
|
||||
fontWeight =
|
||||
FontWeight
|
||||
.Medium
|
||||
)
|
||||
Spacer(
|
||||
modifier =
|
||||
Modifier.height(
|
||||
8.dp
|
||||
)
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
if (isSavedMessages
|
||||
)
|
||||
"Forward messages here or send notes to yourself"
|
||||
else
|
||||
"Send a message to start the conversation",
|
||||
fontSize = 14.sp,
|
||||
color =
|
||||
dateHeaderTextColor
|
||||
.copy(
|
||||
alpha =
|
||||
0.7f
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
// Есть сообщения
|
||||
@@ -2981,6 +3144,8 @@ fun ChatDetailScreen(
|
||||
val normalizedUsername =
|
||||
username.trim().trimStart('@').lowercase(Locale.ROOT)
|
||||
if (normalizedUsername.isBlank()) return@MessageBubble
|
||||
// Mention tap should not trigger bubble context-menu tap.
|
||||
suppressTapAfterLongPress(selectionKey)
|
||||
scope.launch {
|
||||
val normalizedCurrentUsername =
|
||||
currentUserUsername.trim().trimStart('@').lowercase(Locale.ROOT)
|
||||
@@ -3006,12 +3171,21 @@ fun ChatDetailScreen(
|
||||
}
|
||||
}
|
||||
|
||||
if (targetPublicKey.isBlank()) return@launch
|
||||
if (targetPublicKey.isBlank()) {
|
||||
val resolvedByUsername =
|
||||
viewModel.resolveUserByUsername(normalizedUsername)
|
||||
if (resolvedByUsername != null) {
|
||||
showContextMenu = false
|
||||
contextMenuMessage = null
|
||||
onNavigateToChat(resolvedByUsername)
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (targetPublicKey.equals(currentUserPublicKey.trim(), ignoreCase = true)) {
|
||||
showContextMenu = false
|
||||
contextMenuMessage = null
|
||||
onUserProfileClick(
|
||||
onNavigateToChat(
|
||||
SearchUser(
|
||||
title = currentUserName.ifBlank { "You" },
|
||||
username = currentUserUsername.trim().trimStart('@'),
|
||||
@@ -3027,7 +3201,7 @@ fun ChatDetailScreen(
|
||||
if (resolvedUser != null) {
|
||||
showContextMenu = false
|
||||
contextMenuMessage = null
|
||||
onUserProfileClick(resolvedUser)
|
||||
onNavigateToChat(resolvedUser)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.rosetta.messenger.utils.AttachmentFileManager
|
||||
import com.rosetta.messenger.utils.MessageLogger
|
||||
import com.rosetta.messenger.utils.MessageThrottleManager
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.*
|
||||
@@ -2062,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"
|
||||
@@ -2290,6 +2292,44 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve @username to SearchUser using PacketSearch.
|
||||
* Used for clickable mentions inside chat messages.
|
||||
*/
|
||||
suspend fun resolveUserByUsername(username: String, timeoutMs: Long = 3000): SearchUser? {
|
||||
val normalized = username.trim().trimStart('@').lowercase(Locale.ROOT)
|
||||
if (normalized.isBlank()) return null
|
||||
|
||||
// 1) Local DB first: in private chats this gives instant/stable navigation.
|
||||
val account = myPublicKey?.trim().orEmpty()
|
||||
if (account.isNotBlank()) {
|
||||
val localDialog =
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching { dialogDao.getDialogByUsername(account, normalized) }.getOrNull()
|
||||
}
|
||||
if (localDialog != null && localDialog.opponentKey.isNotBlank()) {
|
||||
return SearchUser(
|
||||
title = localDialog.opponentTitle.ifBlank { normalized },
|
||||
username = localDialog.opponentUsername,
|
||||
publicKey = localDialog.opponentKey,
|
||||
verified = localDialog.verified,
|
||||
online = localDialog.isOnline
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 2) In-memory protocol cache.
|
||||
ProtocolManager.getCachedUserByUsername(normalized)?.let { return it }
|
||||
|
||||
// 3) Server search fallback.
|
||||
val results = ProtocolManager.searchUsers(normalized, timeoutMs)
|
||||
if (results.isEmpty()) return null
|
||||
|
||||
return results.firstOrNull {
|
||||
it.username.trim().trimStart('@').lowercase(Locale.ROOT) == normalized
|
||||
} ?: results.firstOrNull()
|
||||
}
|
||||
|
||||
/** 🔥 Повторить отправку сообщения (для ошибки) */
|
||||
fun retryMessage(message: ChatMessage) {
|
||||
// Удаляем старое сообщение
|
||||
|
||||
@@ -1061,7 +1061,16 @@ fun ChatsListScreen(
|
||||
painter = TelegramIcons.Done,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(8.dp)
|
||||
modifier =
|
||||
Modifier.size(
|
||||
8.dp
|
||||
)
|
||||
.offset(
|
||||
x =
|
||||
0.3.dp,
|
||||
y =
|
||||
0.dp
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1603,6 +1612,8 @@ fun ChatsListScreen(
|
||||
}
|
||||
}
|
||||
val badgeBg = Color.White
|
||||
val badgeTextColor =
|
||||
if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFF228BE6)
|
||||
val badgeShape = RoundedCornerShape(50)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
@@ -1621,7 +1632,7 @@ fun ChatsListScreen(
|
||||
text = badgeText,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color(0xFF228BE6),
|
||||
color = badgeTextColor,
|
||||
lineHeight = 10.sp,
|
||||
modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp)
|
||||
)
|
||||
@@ -4327,7 +4338,7 @@ fun DialogItemContent(
|
||||
if (showTyping) {
|
||||
TypingIndicatorSmall()
|
||||
} else if (!dialog.draftText.isNullOrEmpty()) {
|
||||
Row {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Draft: ",
|
||||
fontSize = 14.sp,
|
||||
@@ -4343,7 +4354,8 @@ fun DialogItemContent(
|
||||
fontWeight = FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
overflow = android.text.TextUtils.TruncateAt.END,
|
||||
enableLinks = false
|
||||
enableLinks = false,
|
||||
minHeightMultiplier = 1f
|
||||
)
|
||||
}
|
||||
} else {
|
||||
@@ -4355,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"
|
||||
|
||||
@@ -63,6 +63,9 @@ import com.rosetta.messenger.network.ProtocolState
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue as AppPrimaryBlue
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
@@ -70,6 +73,11 @@ import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import com.rosetta.messenger.crypto.CryptoManager
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.withStyle
|
||||
|
||||
// Primary Blue color
|
||||
private val PrimaryBlue = Color(0xFF54A9EB)
|
||||
@@ -77,6 +85,7 @@ private val PrimaryBlue = Color(0xFF54A9EB)
|
||||
/** Вкладки поиска как в Telegram */
|
||||
private enum class SearchTab(val title: String) {
|
||||
CHATS("Chats"),
|
||||
MESSAGES("Messages"),
|
||||
MEDIA("Media"),
|
||||
DOWNLOADS("Downloads"),
|
||||
FILES("Files")
|
||||
@@ -92,7 +101,8 @@ fun SearchScreen(
|
||||
protocolState: ProtocolState,
|
||||
onBackClick: () -> Unit,
|
||||
onUserSelect: (SearchUser) -> Unit,
|
||||
onNavigateToCrashLogs: () -> Unit = {}
|
||||
onNavigateToCrashLogs: () -> Unit = {},
|
||||
onNavigateToConnectionLogs: () -> Unit = {}
|
||||
) {
|
||||
// Context и View для мгновенного закрытия клавиатуры
|
||||
val context = LocalContext.current
|
||||
@@ -141,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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,6 +397,18 @@ fun SearchScreen(
|
||||
onUserSelect = onUserSelect
|
||||
)
|
||||
}
|
||||
SearchTab.MESSAGES -> {
|
||||
MessagesTabContent(
|
||||
searchQuery = searchQuery,
|
||||
currentUserPublicKey = currentUserPublicKey,
|
||||
isDarkTheme = isDarkTheme,
|
||||
textColor = textColor,
|
||||
secondaryTextColor = secondaryTextColor,
|
||||
avatarRepository = avatarRepository,
|
||||
searchLottieComposition = searchLottieComposition,
|
||||
onUserSelect = onUserSelect
|
||||
)
|
||||
}
|
||||
SearchTab.MEDIA -> {
|
||||
MediaTabContent(
|
||||
currentUserPublicKey = currentUserPublicKey,
|
||||
@@ -639,14 +666,35 @@ private fun ChatsTabContent(
|
||||
} else {
|
||||
// ═══ Search results ═══
|
||||
val normalizedQuery = searchQuery.trim().removePrefix("@").lowercase()
|
||||
val compactQuery = normalizedQuery.replace(Regex("\\s+"), " ").trim()
|
||||
val normalizedPublicKey = currentUserPublicKey.lowercase()
|
||||
val normalizedUsername = ownAccountUsername.removePrefix("@").trim().lowercase()
|
||||
val normalizedName = ownAccountName.trim().lowercase()
|
||||
val hasValidOwnName =
|
||||
ownAccountName.isNotBlank() && !isPlaceholderAccountName(ownAccountName)
|
||||
val savedMessagesAliases =
|
||||
listOf(
|
||||
"saved",
|
||||
"saved message",
|
||||
"saved messages",
|
||||
"savedmessages",
|
||||
"bookmarks",
|
||||
"bookmark",
|
||||
"избранное",
|
||||
"сохраненное",
|
||||
"сохранённое",
|
||||
"сохраненные",
|
||||
"сохранённые"
|
||||
)
|
||||
val isSavedAliasSearch =
|
||||
compactQuery.length >= 3 &&
|
||||
savedMessagesAliases.any { alias ->
|
||||
alias.startsWith(compactQuery) || compactQuery.startsWith(alias)
|
||||
}
|
||||
val isSavedMessagesSearch =
|
||||
normalizedQuery.isNotEmpty() &&
|
||||
(normalizedPublicKey == normalizedQuery ||
|
||||
(isSavedAliasSearch ||
|
||||
normalizedPublicKey == normalizedQuery ||
|
||||
normalizedPublicKey.startsWith(normalizedQuery) ||
|
||||
normalizedPublicKey.take(8) == normalizedQuery ||
|
||||
normalizedPublicKey.takeLast(8) == normalizedQuery ||
|
||||
@@ -916,6 +964,503 @@ private fun SearchSkeleton(isDarkTheme: Boolean) {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 💬 MESSAGES TAB — search through decrypted message text
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
/** A single message search result */
|
||||
private data class MessageSearchResult(
|
||||
val messageId: String,
|
||||
val dialogKey: String,
|
||||
val opponentKey: String,
|
||||
val opponentTitle: String,
|
||||
val opponentUsername: String,
|
||||
val plainText: String,
|
||||
val timestamp: Long,
|
||||
val fromMe: Boolean,
|
||||
val verified: Int = 0
|
||||
)
|
||||
|
||||
/**
|
||||
* Optimized message search: loads messages in batches, decrypts plainMessage
|
||||
* fields in parallel (Semaphore-limited), filters client-side, and caches
|
||||
* decrypted text to avoid re-decryption on subsequent queries.
|
||||
*/
|
||||
@Composable
|
||||
private fun MessagesTabContent(
|
||||
searchQuery: String,
|
||||
currentUserPublicKey: String,
|
||||
isDarkTheme: Boolean,
|
||||
textColor: Color,
|
||||
secondaryTextColor: Color,
|
||||
avatarRepository: AvatarRepository?,
|
||||
searchLottieComposition: com.airbnb.lottie.LottieComposition?,
|
||||
onUserSelect: (SearchUser) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var results by remember { mutableStateOf<List<MessageSearchResult>>(emptyList()) }
|
||||
var isSearching by remember { mutableStateOf(false) }
|
||||
val dividerColor = remember(isDarkTheme) {
|
||||
if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8)
|
||||
}
|
||||
|
||||
// Persistent decryption cache: messageId → plaintext (survives re-queries)
|
||||
val decryptCache = remember { ConcurrentHashMap<String, String>(512) }
|
||||
// Cache for dialog metadata: opponentKey → (title, username, verified)
|
||||
val dialogCache = remember { ConcurrentHashMap<String, Triple<String, String, Int>>() }
|
||||
|
||||
val dateFormat = remember { SimpleDateFormat("dd MMM, HH:mm", Locale.getDefault()) }
|
||||
|
||||
// Debounced search: waits 600ms after typing stops, then searches
|
||||
LaunchedEffect(searchQuery, currentUserPublicKey) {
|
||||
results = emptyList()
|
||||
if (searchQuery.length < 2 || currentUserPublicKey.isBlank()) {
|
||||
isSearching = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
isSearching = true
|
||||
// Debounce
|
||||
kotlinx.coroutines.delay(600)
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val db = RosettaDatabase.getDatabase(context)
|
||||
val repo = com.rosetta.messenger.data.MessageRepository.getInstance(context)
|
||||
val privateKey = repo.getCurrentPrivateKey().orEmpty()
|
||||
if (privateKey.isBlank()) {
|
||||
isSearching = false
|
||||
return@withContext
|
||||
}
|
||||
|
||||
// Pre-warm PBKDF2 cache for this password
|
||||
CryptoManager.getPbkdf2Key(privateKey)
|
||||
|
||||
// Load dialog metadata once
|
||||
if (dialogCache.isEmpty()) {
|
||||
val dialogs = db.dialogDao().getDialogsPaged(currentUserPublicKey, 500, 0)
|
||||
for (d in dialogs) {
|
||||
dialogCache[d.opponentKey] = Triple(d.opponentTitle, d.opponentUsername, d.verified)
|
||||
}
|
||||
}
|
||||
|
||||
val queryLower = searchQuery.trim().lowercase()
|
||||
val matched = mutableListOf<MessageSearchResult>()
|
||||
val semaphore = Semaphore(4)
|
||||
val batchSize = 200
|
||||
var offset = 0
|
||||
val maxMessages = 5000 // Safety cap
|
||||
val maxResults = 50 // Don't return more than 50 matches
|
||||
|
||||
while (offset < maxMessages && matched.size < maxResults) {
|
||||
val batch = db.messageDao().getAllMessagesPaged(
|
||||
currentUserPublicKey, batchSize, offset
|
||||
)
|
||||
if (batch.isEmpty()) break
|
||||
|
||||
// Decrypt in parallel, filter by query
|
||||
val batchResults = kotlinx.coroutines.coroutineScope {
|
||||
batch.chunked(20).flatMap { chunk ->
|
||||
chunk.map { msg ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
val cached = decryptCache[msg.messageId]
|
||||
val plain = if (cached != null) {
|
||||
cached
|
||||
} else {
|
||||
val decrypted = try {
|
||||
CryptoManager.decryptWithPassword(
|
||||
msg.plainMessage, privateKey
|
||||
)
|
||||
} catch (_: Exception) { null }
|
||||
if (!decrypted.isNullOrBlank()) {
|
||||
decryptCache[msg.messageId] = decrypted
|
||||
}
|
||||
decrypted
|
||||
}
|
||||
|
||||
if (!plain.isNullOrBlank() && plain.lowercase().contains(queryLower)) {
|
||||
val opponent = if (msg.fromMe == 1) msg.toPublicKey else msg.fromPublicKey
|
||||
val normalized = opponent.trim()
|
||||
val meta = dialogCache[normalized]
|
||||
MessageSearchResult(
|
||||
messageId = msg.messageId,
|
||||
dialogKey = msg.dialogKey,
|
||||
opponentKey = normalized,
|
||||
opponentTitle = meta?.first.orEmpty(),
|
||||
opponentUsername = meta?.second.orEmpty(),
|
||||
plainText = plain,
|
||||
timestamp = msg.timestamp,
|
||||
fromMe = msg.fromMe == 1,
|
||||
verified = meta?.third ?: 0
|
||||
)
|
||||
} else null
|
||||
}
|
||||
}
|
||||
}.awaitAll().filterNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
matched.addAll(batchResults)
|
||||
offset += batchSize
|
||||
}
|
||||
|
||||
results = matched.take(maxResults)
|
||||
} catch (_: Exception) {
|
||||
results = emptyList()
|
||||
}
|
||||
isSearching = false
|
||||
}
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
when {
|
||||
searchQuery.length < 2 -> {
|
||||
// Idle state — prompt to type
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
if (searchLottieComposition != null) {
|
||||
val progress by animateLottieCompositionAsState(
|
||||
composition = searchLottieComposition,
|
||||
iterations = 1,
|
||||
isPlaying = true,
|
||||
restartOnPlay = false
|
||||
)
|
||||
LottieAnimation(
|
||||
composition = searchLottieComposition,
|
||||
progress = { progress },
|
||||
modifier = Modifier.size(100.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "Search in messages",
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = textColor
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = "Type at least 2 characters",
|
||||
fontSize = 14.sp,
|
||||
color = secondaryTextColor
|
||||
)
|
||||
}
|
||||
}
|
||||
isSearching -> {
|
||||
MessageSearchSkeleton(isDarkTheme = isDarkTheme)
|
||||
}
|
||||
results.isEmpty() -> {
|
||||
// No results
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.imePadding(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.search_files_filled),
|
||||
contentDescription = null,
|
||||
tint = secondaryTextColor.copy(alpha = 0.4f),
|
||||
modifier = Modifier.size(48.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
text = "No messages found",
|
||||
fontSize = 17.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = textColor.copy(alpha = 0.8f)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Try a different search term",
|
||||
fontSize = 14.sp,
|
||||
color = secondaryTextColor
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(vertical = 4.dp)
|
||||
) {
|
||||
items(results, key = { it.messageId }) { result ->
|
||||
MessageSearchResultItem(
|
||||
result = result,
|
||||
searchQuery = searchQuery,
|
||||
dateFormat = dateFormat,
|
||||
currentUserPublicKey = currentUserPublicKey,
|
||||
isDarkTheme = isDarkTheme,
|
||||
textColor = textColor,
|
||||
secondaryTextColor = secondaryTextColor,
|
||||
avatarRepository = avatarRepository,
|
||||
onClick = {
|
||||
val user = SearchUser(
|
||||
title = result.opponentTitle,
|
||||
username = result.opponentUsername,
|
||||
publicKey = result.opponentKey,
|
||||
verified = result.verified,
|
||||
online = 0
|
||||
)
|
||||
onUserSelect(user)
|
||||
}
|
||||
)
|
||||
Divider(
|
||||
color = dividerColor,
|
||||
thickness = 0.5.dp,
|
||||
modifier = Modifier.padding(start = 76.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageSearchSkeleton(isDarkTheme: Boolean) {
|
||||
val shimmerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8)
|
||||
val highlightColor = if (isDarkTheme) Color(0xFF3A3A3A) else Color(0xFFF5F5F5)
|
||||
val dividerColor = if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8)
|
||||
|
||||
val transition = rememberInfiniteTransition(label = "message_search_shimmer")
|
||||
val translateAnim by transition.animateFloat(
|
||||
initialValue = 0f,
|
||||
targetValue = 1000f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 1200, easing = LinearEasing),
|
||||
repeatMode = RepeatMode.Restart
|
||||
),
|
||||
label = "message_search_shimmer_translate"
|
||||
)
|
||||
|
||||
val shimmerBrush = androidx.compose.ui.graphics.Brush.linearGradient(
|
||||
colors = listOf(shimmerColor, highlightColor, shimmerColor),
|
||||
start = androidx.compose.ui.geometry.Offset(translateAnim - 200f, 0f),
|
||||
end = androidx.compose.ui.geometry.Offset(translateAnim, 0f)
|
||||
)
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().imePadding(),
|
||||
contentPadding = PaddingValues(vertical = 4.dp)
|
||||
) {
|
||||
items(8) { index ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(shimmerBrush)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth(
|
||||
fraction = if (index % 2 == 0) 0.48f else 0.62f
|
||||
)
|
||||
.height(14.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(shimmerBrush)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.width(if (index % 2 == 0) 58.dp else 50.dp)
|
||||
.height(12.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(shimmerBrush)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth(
|
||||
fraction = if (index % 2 == 0) 0.86f else 0.74f
|
||||
)
|
||||
.height(12.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(shimmerBrush)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.fillMaxWidth(
|
||||
fraction = if (index % 2 == 0) 0.67f else 0.79f
|
||||
)
|
||||
.height(12.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(shimmerBrush)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider(
|
||||
color = dividerColor,
|
||||
thickness = 0.5.dp,
|
||||
modifier = Modifier.padding(start = 76.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MessageSearchResultItem(
|
||||
result: MessageSearchResult,
|
||||
searchQuery: String,
|
||||
dateFormat: SimpleDateFormat,
|
||||
currentUserPublicKey: String,
|
||||
isDarkTheme: Boolean,
|
||||
textColor: Color,
|
||||
secondaryTextColor: Color,
|
||||
avatarRepository: AvatarRepository?,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val isGroup = result.opponentKey.startsWith("#group:") || result.opponentKey.startsWith("group:")
|
||||
val displayName = when {
|
||||
result.opponentKey == currentUserPublicKey -> "Saved Messages"
|
||||
result.opponentTitle.isNotBlank() -> result.opponentTitle
|
||||
result.opponentUsername.isNotBlank() -> result.opponentUsername
|
||||
else -> result.opponentKey.take(8) + "..."
|
||||
}
|
||||
|
||||
// Build highlighted snippet: show text around the match
|
||||
val snippet = remember(result.plainText, searchQuery) {
|
||||
buildMessageSnippet(result.plainText, searchQuery)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AvatarImage(
|
||||
publicKey = result.opponentKey,
|
||||
avatarRepository = avatarRepository,
|
||||
size = 48.dp,
|
||||
isDarkTheme = isDarkTheme,
|
||||
showOnlineIndicator = false,
|
||||
isOnline = false,
|
||||
displayName = displayName
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
// Top line: name + date
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.weight(1f, fill = false)
|
||||
) {
|
||||
Text(
|
||||
text = displayName,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = textColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (result.verified != 0) {
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
VerifiedBadge(verified = result.verified, size = 16, isDarkTheme = isDarkTheme)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = dateFormat.format(Date(result.timestamp)),
|
||||
fontSize = 13.sp,
|
||||
color = secondaryTextColor,
|
||||
maxLines = 1
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
|
||||
// Bottom line: message snippet with highlighted match
|
||||
val annotated = remember(snippet, searchQuery) {
|
||||
buildHighlightedText(snippet, searchQuery, secondaryTextColor, PrimaryBlue)
|
||||
}
|
||||
Text(
|
||||
text = annotated,
|
||||
fontSize = 14.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Extract a snippet around the first match, ~80 chars context */
|
||||
private fun buildMessageSnippet(text: String, query: String): String {
|
||||
val lower = text.lowercase()
|
||||
val queryLower = query.trim().lowercase()
|
||||
val idx = lower.indexOf(queryLower)
|
||||
if (idx < 0) return text.take(100)
|
||||
|
||||
val start = (idx - 30).coerceAtLeast(0)
|
||||
val end = (idx + queryLower.length + 50).coerceAtMost(text.length)
|
||||
val prefix = if (start > 0) "..." else ""
|
||||
val suffix = if (end < text.length) "..." else ""
|
||||
return prefix + text.substring(start, end).replace('\n', ' ') + suffix
|
||||
}
|
||||
|
||||
/** Build AnnotatedString with the query highlighted in blue */
|
||||
private fun buildHighlightedText(
|
||||
text: String,
|
||||
query: String,
|
||||
baseColor: Color,
|
||||
highlightColor: Color
|
||||
) = buildAnnotatedString {
|
||||
val lower = text.lowercase()
|
||||
val queryLower = query.trim().lowercase()
|
||||
var cursor = 0
|
||||
|
||||
while (cursor < text.length) {
|
||||
val matchIdx = lower.indexOf(queryLower, cursor)
|
||||
if (matchIdx < 0) {
|
||||
withStyle(SpanStyle(color = baseColor)) {
|
||||
append(text.substring(cursor))
|
||||
}
|
||||
break
|
||||
}
|
||||
if (matchIdx > cursor) {
|
||||
withStyle(SpanStyle(color = baseColor)) {
|
||||
append(text.substring(cursor, matchIdx))
|
||||
}
|
||||
}
|
||||
withStyle(SpanStyle(color = highlightColor, fontWeight = FontWeight.SemiBold)) {
|
||||
append(text.substring(matchIdx, matchIdx + queryLower.length))
|
||||
}
|
||||
cursor = matchIdx + queryLower.length
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🖼️ MEDIA TAB — grid of images from all chats
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -376,6 +376,10 @@ fun MessageBubble(
|
||||
} else {
|
||||
null
|
||||
}
|
||||
var suppressBubbleTapUntilMs by remember(message.id) { mutableLongStateOf(0L) }
|
||||
val suppressBubbleTapFromSpan: () -> Unit = {
|
||||
suppressBubbleTapUntilMs = System.currentTimeMillis() + 450L
|
||||
}
|
||||
|
||||
val timeColor =
|
||||
remember(message.isOutgoing, isDarkTheme) {
|
||||
@@ -781,7 +785,12 @@ fun MessageBubble(
|
||||
remember {
|
||||
MutableInteractionSource()
|
||||
},
|
||||
onClick = onClick,
|
||||
onClick = {
|
||||
if (System.currentTimeMillis() <= suppressBubbleTapUntilMs) {
|
||||
return@combinedClickable
|
||||
}
|
||||
onClick()
|
||||
},
|
||||
onLongClick = onLongClick
|
||||
)
|
||||
.then(
|
||||
@@ -894,7 +903,8 @@ fun MessageBubble(
|
||||
linksEnabled = linksEnabled,
|
||||
onImageClick = onImageClick,
|
||||
onForwardedSenderClick = onForwardedSenderClick,
|
||||
onMentionClick = onMentionClick
|
||||
onMentionClick = onMentionClick,
|
||||
onTextSpanPressStart = suppressBubbleTapFromSpan
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
@@ -987,6 +997,8 @@ fun MessageBubble(
|
||||
true,
|
||||
onMentionClick =
|
||||
mentionClickHandler,
|
||||
onClickableSpanPressStart =
|
||||
suppressBubbleTapFromSpan,
|
||||
onClick =
|
||||
textClickHandler,
|
||||
onLongClick =
|
||||
@@ -1077,6 +1089,8 @@ fun MessageBubble(
|
||||
enableLinks = linksEnabled,
|
||||
enableMentions = true,
|
||||
onMentionClick = mentionClickHandler,
|
||||
onClickableSpanPressStart =
|
||||
suppressBubbleTapFromSpan,
|
||||
onClick = textClickHandler,
|
||||
onLongClick =
|
||||
onLongClick // 🔥
|
||||
@@ -1179,6 +1193,8 @@ fun MessageBubble(
|
||||
enableLinks = linksEnabled,
|
||||
enableMentions = true,
|
||||
onMentionClick = mentionClickHandler,
|
||||
onClickableSpanPressStart =
|
||||
suppressBubbleTapFromSpan,
|
||||
onClick = textClickHandler,
|
||||
onLongClick =
|
||||
onLongClick // 🔥
|
||||
@@ -2219,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(
|
||||
@@ -2314,7 +2331,8 @@ fun ForwardedMessagesBubble(
|
||||
linksEnabled: Boolean = true,
|
||||
onImageClick: (attachmentId: String, bounds: ImageSourceBounds?) -> Unit = { _, _ -> },
|
||||
onForwardedSenderClick: (senderPublicKey: String) -> Unit = {},
|
||||
onMentionClick: (username: String) -> Unit = {}
|
||||
onMentionClick: (username: String) -> Unit = {},
|
||||
onTextSpanPressStart: (() -> Unit)? = null
|
||||
) {
|
||||
val backgroundColor =
|
||||
if (isOutgoing) Color.Black.copy(alpha = 0.1f)
|
||||
@@ -2425,7 +2443,8 @@ fun ForwardedMessagesBubble(
|
||||
overflow = android.text.TextUtils.TruncateAt.END,
|
||||
enableLinks = linksEnabled,
|
||||
enableMentions = true,
|
||||
onMentionClick = if (linksEnabled) onMentionClick else null
|
||||
onMentionClick = if (linksEnabled) onMentionClick else null,
|
||||
onClickableSpanPressStart = onTextSpanPressStart
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
@@ -36,6 +37,7 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.compose.ui.window.Popup
|
||||
import androidx.compose.ui.window.PopupProperties
|
||||
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
|
||||
@@ -135,6 +137,8 @@ fun MessageInputBar(
|
||||
val density = LocalDensity.current
|
||||
|
||||
var editTextView by remember { mutableStateOf<com.rosetta.messenger.ui.components.AppleEmojiEditTextView?>(null) }
|
||||
var selectionStart by remember { mutableIntStateOf(value.length) }
|
||||
var selectionEnd by remember { mutableIntStateOf(value.length) }
|
||||
|
||||
// Auto-focus when reply panel opens
|
||||
LaunchedEffect(hasReply, editTextView) {
|
||||
@@ -269,6 +273,25 @@ fun MessageInputBar(
|
||||
}
|
||||
}
|
||||
|
||||
val emojiWordMatch =
|
||||
remember(value, selectionStart, selectionEnd) {
|
||||
EmojiKeywordSuggester.findWordAtCursor(value, selectionStart, selectionEnd)
|
||||
}
|
||||
|
||||
val emojiSuggestions =
|
||||
remember(emojiWordMatch) {
|
||||
emojiWordMatch?.let { EmojiKeywordSuggester.suggest(it) }.orEmpty()
|
||||
}
|
||||
|
||||
val shouldShowEmojiSuggestions =
|
||||
remember(emojiSuggestions, mentionSuggestions, isGroupChat, suppressKeyboard, showEmojiPicker) {
|
||||
emojiSuggestions.isNotEmpty() &&
|
||||
mentionSuggestions.isEmpty() &&
|
||||
!(isGroupChat && shouldShowMentionSuggestions) &&
|
||||
!showEmojiPicker &&
|
||||
!suppressKeyboard
|
||||
}
|
||||
|
||||
// Close keyboard when user is blocked
|
||||
LaunchedEffect(isBlocked) {
|
||||
if (isBlocked) {
|
||||
@@ -357,6 +380,14 @@ fun MessageInputBar(
|
||||
editTextView?.requestFocus()
|
||||
}
|
||||
|
||||
fun onSelectEmojiSuggestion(emoji: String) {
|
||||
val updated = EmojiKeywordSuggester.applySuggestion(value, selectionStart, selectionEnd, emoji)
|
||||
if (updated != value) {
|
||||
onValueChange(updated)
|
||||
}
|
||||
editTextView?.requestFocus()
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().graphicsLayer { clip = false }) {
|
||||
if (isBlocked) {
|
||||
// BLOCKED CHAT FOOTER
|
||||
@@ -695,14 +726,101 @@ fun MessageInputBar(
|
||||
}
|
||||
}
|
||||
|
||||
// INPUT ROW
|
||||
Row(
|
||||
// INPUT ROW + Telegram-like flat emoji suggestion strip overlay
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
.graphicsLayer { clip = false }
|
||||
) {
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = shouldShowEmojiSuggestions,
|
||||
enter = fadeIn(animationSpec = tween(120)) +
|
||||
slideInVertically(animationSpec = tween(120), initialOffsetY = { it / 2 }),
|
||||
exit = fadeOut(animationSpec = tween(90)) +
|
||||
slideOutVertically(animationSpec = tween(90), targetOffsetY = { it / 3 }),
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopStart)
|
||||
.offset(y = (-46).dp)
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp)
|
||||
.zIndex(4f)
|
||||
) {
|
||||
val barBackground = if (isDarkTheme) Color(0xFF303A48).copy(alpha = 0.97f) else Color(0xFFE9EEF6)
|
||||
val barBorder = if (isDarkTheme) Color.White.copy(alpha = 0.12f) else Color.Black.copy(alpha = 0.1f)
|
||||
val leadingIconBg =
|
||||
if (isDarkTheme) Color.White.copy(alpha = 0.1f) else Color.Black.copy(alpha = 0.06f)
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = barBackground,
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, barBorder),
|
||||
shadowElevation = if (isDarkTheme) 6.dp else 3.dp,
|
||||
tonalElevation = 0.dp
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(38.dp)
|
||||
.padding(horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(26.dp)
|
||||
.clip(CircleShape)
|
||||
.background(leadingIconBg),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
painter = TelegramIcons.Smile,
|
||||
contentDescription = null,
|
||||
tint = if (isDarkTheme) Color.White.copy(alpha = 0.92f) else Color(0xFF31415A),
|
||||
modifier = Modifier.size(15.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
LazyRow(
|
||||
modifier = Modifier.weight(1f),
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
items(emojiSuggestions, key = { it }) { emoji ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
indication = null
|
||||
) { onSelectEmojiSuggestion(emoji) }
|
||||
.padding(horizontal = 1.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AppleEmojiText(
|
||||
text = emoji,
|
||||
fontSize = 26.sp,
|
||||
color = if (isDarkTheme) Color.White else Color.Black,
|
||||
maxLines = 1,
|
||||
enableLinks = false,
|
||||
minHeightMultiplier = 1f,
|
||||
onClick = { onSelectEmojiSuggestion(emoji) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 48.dp)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
IconButton(
|
||||
onClick = onAttachClick,
|
||||
modifier = Modifier.size(40.dp)
|
||||
@@ -740,6 +858,10 @@ fun MessageInputBar(
|
||||
if (hasFocus && showEmojiPicker) {
|
||||
onToggleEmojiPicker(false)
|
||||
}
|
||||
},
|
||||
onSelectionChanged = { start, end ->
|
||||
selectionStart = start
|
||||
selectionEnd = end
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -779,6 +901,7 @@ fun MessageInputBar(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.rosetta.messenger.ui.chats.input
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
data class EmojiWordMatch(
|
||||
val normalizedWord: String,
|
||||
val start: Int,
|
||||
val end: Int
|
||||
)
|
||||
|
||||
object EmojiKeywordSuggester {
|
||||
|
||||
private const val MIN_WORD_LENGTH = 2
|
||||
private const val MAX_WORD_LENGTH = 32
|
||||
|
||||
// test
|
||||
|
||||
private val keywordToEmoji: Map<String, List<String>> =
|
||||
linkedMapOf(
|
||||
"спасибо" to listOf("🙏", "😊", "❤️", "🤝"),
|
||||
"спс" to listOf("🙏", "😊"),
|
||||
"благодарю" to listOf("🙏", "🤝"),
|
||||
"пожалуйста" to listOf("🙏", "🙂"),
|
||||
"привет" to listOf("👋", "🙂", "😊"),
|
||||
"здравствуйте" to listOf("👋", "🙂"),
|
||||
"пока" to listOf("👋", "🫡"),
|
||||
"доброеутро" to listOf("☀️", "🌤️", "🙂"),
|
||||
"добрыйдень" to listOf("☀️", "🙂"),
|
||||
"добрыйвечер" to listOf("🌇", "🙂"),
|
||||
"ок" to listOf("👌", "👍"),
|
||||
"хорошо" to listOf("👌", "👍", "✅"),
|
||||
"круто" to listOf("🔥", "😎", "🤘"),
|
||||
"класс" to listOf("🔥", "😎", "✨"),
|
||||
"да" to listOf("✅", "👍"),
|
||||
"нет" to listOf("❌", "🙅"),
|
||||
"люблю" to listOf("❤️", "🥰", "😘"),
|
||||
"любовь" to listOf("❤️", "😍", "🥰"),
|
||||
"скучаю" to listOf("🥺", "❤️"),
|
||||
"смешно" to listOf("😂", "🤣", "😄"),
|
||||
"лол" to listOf("😂", "🤣"),
|
||||
"печаль" to listOf("😢", "🥺", "💔"),
|
||||
"грустно" to listOf("😢", "🥺"),
|
||||
"злюсь" to listOf("😡", "🤬"),
|
||||
"злой" to listOf("😡", "🤬"),
|
||||
"sorry" to listOf("🙏", "😔"),
|
||||
"thanks" to listOf("🙏", "😊", "❤️"),
|
||||
"thankyou" to listOf("🙏", "😊"),
|
||||
"hello" to listOf("👋", "🙂", "😊"),
|
||||
"hi" to listOf("👋", "🙂"),
|
||||
"bye" to listOf("👋", "🫡"),
|
||||
"goodmorning" to listOf("☀️", "🌤️", "🙂"),
|
||||
"goodevening" to listOf("🌇", "🙂"),
|
||||
"okey" to listOf("👌", "👍"),
|
||||
"okay" to listOf("👌", "👍"),
|
||||
"yes" to listOf("✅", "👍"),
|
||||
"no" to listOf("❌", "🙅"),
|
||||
"love" to listOf("❤️", "🥰", "😍"),
|
||||
"missyou" to listOf("🥺", "❤️"),
|
||||
"funny" to listOf("😂", "🤣", "😄"),
|
||||
"sad" to listOf("😢", "🥺", "💔"),
|
||||
"angry" to listOf("😡", "🤬"),
|
||||
"spasibo" to listOf("🙏", "😊", "❤️"),
|
||||
"privet" to listOf("👋", "🙂", "😊"),
|
||||
"poka" to listOf("👋", "🫡"),
|
||||
"lyublyu" to listOf("❤️", "🥰", "😘")
|
||||
)
|
||||
|
||||
fun findWordAtCursor(
|
||||
text: String,
|
||||
selectionStart: Int,
|
||||
selectionEnd: Int
|
||||
): EmojiWordMatch? {
|
||||
if (selectionStart != selectionEnd) return null
|
||||
if (text.isEmpty()) return null
|
||||
|
||||
val cursor = selectionEnd.coerceIn(0, text.length)
|
||||
var wordStart = cursor
|
||||
var wordEnd = cursor
|
||||
|
||||
while (wordStart > 0 && isWordChar(text[wordStart - 1])) {
|
||||
wordStart--
|
||||
}
|
||||
while (wordEnd < text.length && isWordChar(text[wordEnd])) {
|
||||
wordEnd++
|
||||
}
|
||||
|
||||
if (wordStart == wordEnd) return null
|
||||
|
||||
val rawWord = text.substring(wordStart, wordEnd)
|
||||
val normalized = normalizeWord(rawWord)
|
||||
if (normalized.length !in MIN_WORD_LENGTH..MAX_WORD_LENGTH) return null
|
||||
|
||||
return EmojiWordMatch(
|
||||
normalizedWord = normalized,
|
||||
start = wordStart,
|
||||
end = wordEnd
|
||||
)
|
||||
}
|
||||
|
||||
fun suggest(match: EmojiWordMatch, maxCount: Int = 8): List<String> {
|
||||
if (maxCount <= 0) return emptyList()
|
||||
|
||||
val exact = keywordToEmoji[match.normalizedWord].orEmpty()
|
||||
val prefix =
|
||||
keywordToEmoji
|
||||
.asSequence()
|
||||
.filter { (keyword, _) ->
|
||||
keyword != match.normalizedWord && keyword.startsWith(match.normalizedWord)
|
||||
}
|
||||
.sortedBy { (keyword, _) -> keyword.length }
|
||||
.flatMap { (_, emoji) -> emoji.asSequence() }
|
||||
.toList()
|
||||
|
||||
return buildList {
|
||||
val dedupe = LinkedHashSet<String>()
|
||||
(exact + prefix).forEach { emoji ->
|
||||
if (dedupe.add(emoji)) {
|
||||
add(emoji)
|
||||
if (size >= maxCount) return@buildList
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun applySuggestion(
|
||||
text: String,
|
||||
selectionStart: Int,
|
||||
selectionEnd: Int,
|
||||
emoji: String
|
||||
): String {
|
||||
val match = findWordAtCursor(text, selectionStart, selectionEnd) ?: return text
|
||||
val prefix = text.substring(0, match.start)
|
||||
val suffix = text.substring(match.end)
|
||||
|
||||
val shouldAddSpace =
|
||||
suffix.isEmpty() || (!suffix.first().isWhitespace() && suffix.first() !in setOf(',', '.', '!', '?', ':', ';'))
|
||||
val trailing = if (shouldAddSpace) " " else ""
|
||||
|
||||
return prefix + emoji + trailing + suffix
|
||||
}
|
||||
|
||||
private fun isWordChar(char: Char): Boolean {
|
||||
return char.isLetterOrDigit() || char == '_'
|
||||
}
|
||||
|
||||
private fun normalizeWord(word: String): String {
|
||||
return buildString(word.length) {
|
||||
word.forEach { char ->
|
||||
if (isWordChar(char)) append(char.lowercaseChar())
|
||||
}
|
||||
}.lowercase(Locale.ROOT)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -10,6 +11,7 @@ import android.graphics.drawable.Drawable
|
||||
import android.net.Uri
|
||||
import android.text.Editable
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import android.text.TextPaint
|
||||
import android.text.TextWatcher
|
||||
import android.text.method.LinkMovementMethod
|
||||
@@ -33,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?
|
||||
@@ -83,6 +240,7 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
|
||||
) : EditText(context, attrs, defStyleAttr) {
|
||||
|
||||
var onTextChange: ((String) -> Unit)? = null
|
||||
var onSelectionRangeChange: ((Int, Int) -> Unit)? = null
|
||||
/** Called when the view's measured height changes (for multiline expansion animation). */
|
||||
var onHeightChanged: ((oldHeight: Int, newHeight: Int) -> Unit)? = null
|
||||
private var isUpdating = false
|
||||
@@ -173,6 +331,11 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
|
||||
lastMeasuredHeight = h
|
||||
}
|
||||
|
||||
override fun onSelectionChanged(selStart: Int, selEnd: Int) {
|
||||
super.onSelectionChanged(selStart, selEnd)
|
||||
onSelectionRangeChange?.invoke(selStart, selEnd)
|
||||
}
|
||||
|
||||
fun setTextWithEmojis(newText: String) {
|
||||
if (newText == text.toString()) return
|
||||
isUpdating = true
|
||||
@@ -197,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. Обрабатываем все найденные эмодзи
|
||||
@@ -280,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("-")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -310,7 +458,8 @@ fun AppleEmojiTextField(
|
||||
onViewCreated: ((AppleEmojiEditTextView) -> Unit)? = null,
|
||||
requestFocus: Boolean = false,
|
||||
onFocusChanged: ((Boolean) -> Unit)? = null,
|
||||
onHeightChanged: ((oldHeight: Int, newHeight: Int) -> Unit)? = null
|
||||
onHeightChanged: ((oldHeight: Int, newHeight: Int) -> Unit)? = null,
|
||||
onSelectionChanged: ((Int, Int) -> Unit)? = null
|
||||
) {
|
||||
// Храним ссылку на view для управления фокусом
|
||||
var editTextView by remember { mutableStateOf<AppleEmojiEditTextView?>(null) }
|
||||
@@ -333,6 +482,7 @@ fun AppleEmojiTextField(
|
||||
setHint(hint)
|
||||
setTextSize(textSize)
|
||||
onTextChange = onValueChange
|
||||
onSelectionRangeChange = onSelectionChanged
|
||||
this.onHeightChanged = onHeightChanged
|
||||
// Убираем все возможные фоны у EditText
|
||||
background = null
|
||||
@@ -351,6 +501,7 @@ fun AppleEmojiTextField(
|
||||
// Always update the callback to prevent stale lambda references
|
||||
// after recomposition (e.g., after sending a photo)
|
||||
view.onTextChange = onValueChange
|
||||
view.onSelectionRangeChange = onSelectionChanged
|
||||
|
||||
if (view.text.toString() != value) {
|
||||
view.setTextWithEmojis(value)
|
||||
@@ -386,6 +537,7 @@ fun AppleEmojiText(
|
||||
enableLinks: Boolean = true, // 🔥 Включить кликабельные ссылки
|
||||
enableMentions: Boolean = false,
|
||||
onMentionClick: ((String) -> Unit)? = null,
|
||||
onClickableSpanPressStart: (() -> Unit)? = null,
|
||||
onClick: (() -> Unit)? = null, // 🔥 Обычный tap (selection mode в MessageBubble)
|
||||
onLongClick: (() -> Unit)? = null, // 🔥 Callback для long press (selection в MessageBubble)
|
||||
minHeightMultiplier: Float = 1.5f
|
||||
@@ -430,8 +582,16 @@ fun AppleEmojiText(
|
||||
setMentionColor(mentionColor.toArgb())
|
||||
enableMentionHighlight(enableMentions)
|
||||
setOnMentionClickListener(onMentionClick)
|
||||
// 🔥 Поддержка обычного tap (например, для selection mode)
|
||||
setOnClickListener(onClick?.let { click -> View.OnClickListener { click.invoke() } })
|
||||
setOnClickableSpanPressStartListener(onClickableSpanPressStart)
|
||||
// In link/mention mode, keep span clicks exclusive to avoid parent bubble menu tap.
|
||||
val canUseTextViewClick = !enableLinks
|
||||
setOnClickListener(
|
||||
if (canUseTextViewClick && onClick != null) {
|
||||
View.OnClickListener { onClick.invoke() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
update = { view ->
|
||||
@@ -455,8 +615,16 @@ fun AppleEmojiText(
|
||||
view.setMentionColor(mentionColor.toArgb())
|
||||
view.enableMentionHighlight(enableMentions)
|
||||
view.setOnMentionClickListener(onMentionClick)
|
||||
// 🔥 Обновляем tap callback, чтобы не было stale lambda
|
||||
view.setOnClickListener(onClick?.let { click -> View.OnClickListener { click.invoke() } })
|
||||
view.setOnClickableSpanPressStartListener(onClickableSpanPressStart)
|
||||
// In link/mention mode, keep span clicks exclusive to avoid parent bubble menu tap.
|
||||
val canUseTextViewClick = !enableLinks
|
||||
view.setOnClickListener(
|
||||
if (canUseTextViewClick && onClick != null) {
|
||||
View.OnClickListener { onClick.invoke() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
},
|
||||
modifier = modifier
|
||||
)
|
||||
@@ -499,14 +667,19 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
private var mentionColorValue: Int = 0xFF54A9EB.toInt()
|
||||
private var mentionsEnabled: Boolean = false
|
||||
private var mentionClickCallback: ((String) -> Unit)? = null
|
||||
private var clickableSpanPressStartCallback: (() -> Unit)? = null
|
||||
|
||||
// 🔥 Long press callback для selection в MessageBubble
|
||||
var onLongClickCallback: (() -> Unit)? = null
|
||||
private var downOnClickableSpan: Boolean = false
|
||||
private var suppressPerformClickOnce: Boolean = false
|
||||
|
||||
// 🔥 GestureDetector для обработки long press поверх LinkMovementMethod
|
||||
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
|
||||
override fun onLongPress(e: MotionEvent) {
|
||||
onLongClickCallback?.invoke()
|
||||
if (!downOnClickableSpan) {
|
||||
onLongClickCallback?.invoke()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -520,12 +693,64 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
* GestureDetector обрабатывает long press, затем передаем событие parent для ссылок
|
||||
*/
|
||||
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
|
||||
when (event.actionMasked) {
|
||||
MotionEvent.ACTION_DOWN -> {
|
||||
downOnClickableSpan = isTouchOnClickableSpan(event)
|
||||
suppressPerformClickOnce = downOnClickableSpan
|
||||
if (downOnClickableSpan) {
|
||||
clickableSpanPressStartCallback?.invoke()
|
||||
parent?.requestDisallowInterceptTouchEvent(true)
|
||||
}
|
||||
}
|
||||
MotionEvent.ACTION_CANCEL,
|
||||
MotionEvent.ACTION_UP -> {
|
||||
downOnClickableSpan = false
|
||||
parent?.requestDisallowInterceptTouchEvent(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Позволяем GestureDetector обработать событие (для long press)
|
||||
gestureDetector.onTouchEvent(event)
|
||||
// Передаем событие дальше для обработки ссылок
|
||||
return super.dispatchTouchEvent(event)
|
||||
}
|
||||
|
||||
override fun performClick(): Boolean {
|
||||
if (suppressPerformClickOnce) {
|
||||
suppressPerformClickOnce = false
|
||||
return true
|
||||
}
|
||||
return super.performClick()
|
||||
}
|
||||
|
||||
private fun isTouchOnClickableSpan(event: MotionEvent): Boolean {
|
||||
val currentText = text as? Spanned ?: return false
|
||||
val hasClickableAtOffset: (Int) -> Boolean = { offset ->
|
||||
if (offset < 0 || offset > currentText.length) {
|
||||
false
|
||||
} else {
|
||||
val start = (offset - 1).coerceAtLeast(0)
|
||||
val end = (offset + 1).coerceAtMost(currentText.length)
|
||||
currentText.getSpans(start, end, ClickableSpan::class.java).isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
val directOffset = runCatching { getOffsetForPosition(event.x, event.y) }.getOrNull()
|
||||
if (directOffset != null && hasClickableAtOffset(directOffset)) {
|
||||
return true
|
||||
}
|
||||
|
||||
val textLayout = layout ?: return false
|
||||
val x = (event.x - totalPaddingLeft + scrollX).toInt()
|
||||
val y = (event.y - totalPaddingTop + scrollY).toInt()
|
||||
if (x < 0 || y < 0 || x > textLayout.width || y > textLayout.height) return false
|
||||
|
||||
val line = textLayout.getLineForVertical(y)
|
||||
val horizontal = x.toFloat().coerceIn(textLayout.getLineLeft(line), textLayout.getLineRight(line))
|
||||
val layoutOffset = textLayout.getOffsetForHorizontal(line, horizontal)
|
||||
return hasClickableAtOffset(layoutOffset)
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 Установить цвет для ссылок
|
||||
*/
|
||||
@@ -547,6 +772,10 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
||||
updateMovementMethod()
|
||||
}
|
||||
|
||||
fun setOnClickableSpanPressStartListener(listener: (() -> Unit)?) {
|
||||
clickableSpanPressStartCallback = listener
|
||||
}
|
||||
|
||||
/**
|
||||
* 🔥 Включить/выключить кликабельные ссылки
|
||||
* @param enable - включить ссылки
|
||||
@@ -574,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. Сортируем по позиции в обратном порядке (чтобы не сбить индексы при замене)
|
||||
@@ -611,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)
|
||||
@@ -621,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) {
|
||||
@@ -779,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)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ data class ThemeWallpaper(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val preferredTheme: WallpaperTheme,
|
||||
val pairGroup: String,
|
||||
@DrawableRes val drawableRes: Int
|
||||
)
|
||||
|
||||
@@ -23,77 +24,119 @@ object ThemeWallpapers {
|
||||
id = "back_3",
|
||||
name = "Wallpaper 1",
|
||||
preferredTheme = WallpaperTheme.DARK,
|
||||
pairGroup = "pair_1",
|
||||
drawableRes = R.drawable.wallpaper_back_3
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_4",
|
||||
name = "Wallpaper 2",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
pairGroup = "pair_1",
|
||||
drawableRes = R.drawable.wallpaper_back_4
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_5",
|
||||
name = "Wallpaper 3",
|
||||
preferredTheme = WallpaperTheme.DARK,
|
||||
pairGroup = "pair_2",
|
||||
drawableRes = R.drawable.wallpaper_back_5
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_6",
|
||||
name = "Wallpaper 4",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
pairGroup = "pair_2",
|
||||
drawableRes = R.drawable.wallpaper_back_6
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_7",
|
||||
name = "Wallpaper 5",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
pairGroup = "pair_2",
|
||||
drawableRes = R.drawable.wallpaper_back_7
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_8",
|
||||
name = "Wallpaper 6",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
pairGroup = "pair_3",
|
||||
drawableRes = R.drawable.wallpaper_back_8
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_9",
|
||||
name = "Wallpaper 7",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
pairGroup = "pair_1",
|
||||
drawableRes = R.drawable.wallpaper_back_9
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_10",
|
||||
name = "Wallpaper 8",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
pairGroup = "pair_4",
|
||||
drawableRes = R.drawable.wallpaper_back_10
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_11",
|
||||
name = "Wallpaper 9",
|
||||
preferredTheme = WallpaperTheme.DARK,
|
||||
pairGroup = "pair_3",
|
||||
drawableRes = R.drawable.wallpaper_back_11
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_1",
|
||||
name = "Wallpaper 10",
|
||||
preferredTheme = WallpaperTheme.LIGHT,
|
||||
pairGroup = "pair_3",
|
||||
drawableRes = R.drawable.wallpaper_back_1
|
||||
),
|
||||
ThemeWallpaper(
|
||||
id = "back_2",
|
||||
name = "Wallpaper 11",
|
||||
preferredTheme = WallpaperTheme.DARK,
|
||||
pairGroup = "pair_4",
|
||||
drawableRes = R.drawable.wallpaper_back_2
|
||||
)
|
||||
)
|
||||
|
||||
fun findById(id: String): ThemeWallpaper? = all.firstOrNull { it.id == id }
|
||||
private val byId: Map<String, ThemeWallpaper> = all.associateBy { it.id }
|
||||
private val byPairGroup: Map<String, List<ThemeWallpaper>> = all.groupBy { it.pairGroup }
|
||||
|
||||
fun findById(id: String): ThemeWallpaper? = byId[id]
|
||||
|
||||
fun forTheme(isDarkTheme: Boolean): List<ThemeWallpaper> {
|
||||
val targetTheme = if (isDarkTheme) WallpaperTheme.DARK else WallpaperTheme.LIGHT
|
||||
return all.filter { it.preferredTheme == targetTheme }
|
||||
}
|
||||
|
||||
fun mapToTheme(wallpaperId: String, isDarkTheme: Boolean): String {
|
||||
if (wallpaperId.isBlank()) return ""
|
||||
|
||||
val targetTheme = if (isDarkTheme) WallpaperTheme.DARK else WallpaperTheme.LIGHT
|
||||
val wallpaper = findById(wallpaperId) ?: return wallpaperId
|
||||
if (wallpaper.preferredTheme == targetTheme) return wallpaperId
|
||||
|
||||
val fromPair =
|
||||
byPairGroup[wallpaper.pairGroup]
|
||||
?.firstOrNull { it.preferredTheme == targetTheme }
|
||||
?.id
|
||||
if (!fromPair.isNullOrBlank()) return fromPair
|
||||
|
||||
return all.firstOrNull { it.preferredTheme == targetTheme }?.id ?: wallpaperId
|
||||
}
|
||||
|
||||
fun resolveWallpaperForTheme(
|
||||
currentWallpaperId: String,
|
||||
isDarkTheme: Boolean,
|
||||
darkThemeWallpaperId: String,
|
||||
lightThemeWallpaperId: String
|
||||
): String {
|
||||
val savedForTargetTheme = if (isDarkTheme) darkThemeWallpaperId else lightThemeWallpaperId
|
||||
val mappedSaved = mapToTheme(savedForTargetTheme, isDarkTheme)
|
||||
if (mappedSaved.isNotBlank()) return mappedSaved
|
||||
return mapToTheme(currentWallpaperId, isDarkTheme)
|
||||
}
|
||||
|
||||
@DrawableRes
|
||||
fun drawableResOrNull(id: String): Int? = findById(id)?.drawableRes
|
||||
}
|
||||
|
||||
@@ -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