Compare commits
29 Commits
ce16802ac3
...
new-server
| Author | SHA1 | Date | |
|---|---|---|---|
| c9fa12a690 | |||
| ec541a2c0c | |||
| 454402938c | |||
| 83f6b49ba3 | |||
| b663450db5 | |||
| 9cca071bd8 | |||
| 0af4e6587e | |||
| 31db795c56 | |||
| 9202204094 | |||
| 03282eb478 | |||
| 3fffbd0392 | |||
| bc7efbfbd9 | |||
| eea650face | |||
| 530047c5d0 | |||
| 419101a4a9 | |||
| 9778e3b196 | |||
| 4664aa9482 | |||
| ebb95905b5 | |||
| f915333a44 | |||
| 69c0c377d1 | |||
| 30fbc41245 | |||
| 677a5f2ab2 | |||
| db55225d84 | |||
| 7a188a2dbc | |||
| a3973b616e | |||
| 3a595c02b3 | |||
| 8e743e710a | |||
| 8fdbfb4e5f | |||
| d90554aa9f |
@@ -41,6 +41,12 @@ jobs:
|
|||||||
export JAVA_HOME="$JAVA_DIR"
|
export JAVA_HOME="$JAVA_DIR"
|
||||||
echo "JAVA_HOME set to $JAVA_HOME"
|
echo "JAVA_HOME set to $JAVA_HOME"
|
||||||
|
|
||||||
|
- name: Cache Android SDK
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: ~/android-sdk
|
||||||
|
key: android-sdk-34
|
||||||
|
|
||||||
- name: Install Android SDK
|
- name: Install Android SDK
|
||||||
run: |
|
run: |
|
||||||
export ANDROID_HOME="$HOME/android-sdk"
|
export ANDROID_HOME="$HOME/android-sdk"
|
||||||
@@ -65,6 +71,14 @@ jobs:
|
|||||||
echo "ANDROID_HOME=$ANDROID_HOME" >> $GITHUB_ENV
|
echo "ANDROID_HOME=$ANDROID_HOME" >> $GITHUB_ENV
|
||||||
echo "ANDROID_SDK_ROOT=$ANDROID_HOME" >> $GITHUB_ENV
|
echo "ANDROID_SDK_ROOT=$ANDROID_HOME" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Cache Gradle wrapper
|
||||||
|
uses: actions/cache@v3
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.gradle/wrapper/dists
|
||||||
|
~/.gradle/caches
|
||||||
|
key: gradle-wrapper-8.14.3
|
||||||
|
|
||||||
- name: Restore debug keystore
|
- name: Restore debug keystore
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.android
|
mkdir -p ~/.android
|
||||||
@@ -76,10 +90,28 @@ jobs:
|
|||||||
- name: Setup Gradle wrapper
|
- name: Setup Gradle wrapper
|
||||||
run: |
|
run: |
|
||||||
chmod +x ./gradlew
|
chmod +x ./gradlew
|
||||||
./gradlew --version
|
GRADLE_VERSION="8.14.3"
|
||||||
|
GRADLE_DIST_DIR="$HOME/.gradle/wrapper/dists/gradle-${GRADLE_VERSION}-bin"
|
||||||
|
|
||||||
|
# Проверяем — если Gradle уже распакован в кэше, пропускаем скачивание
|
||||||
|
if find "$GRADLE_DIST_DIR" -name "gradle-${GRADLE_VERSION}" -type d 2>/dev/null | grep -q .; then
|
||||||
|
echo "Gradle ${GRADLE_VERSION} found in cache, skipping download"
|
||||||
|
else
|
||||||
|
echo "Gradle not found in cache, downloading..."
|
||||||
|
mkdir -p /opt/gradle-download
|
||||||
|
curl -fL --retry 3 --retry-delay 5 \
|
||||||
|
"https://services.gradle.org/distributions/gradle-${GRADLE_VERSION}-bin.zip" \
|
||||||
|
-o "/opt/gradle-download/gradle-${GRADLE_VERSION}-bin.zip"
|
||||||
|
mkdir -p /opt/gradle
|
||||||
|
unzip -q "/opt/gradle-download/gradle-${GRADLE_VERSION}-bin.zip" -d /opt/gradle
|
||||||
|
export PATH="/opt/gradle/gradle-${GRADLE_VERSION}/bin:$PATH"
|
||||||
|
echo "PATH=/opt/gradle/gradle-${GRADLE_VERSION}/bin:$PATH" >> $GITHUB_ENV
|
||||||
|
fi
|
||||||
|
|
||||||
|
./gradlew --no-daemon --version
|
||||||
|
|
||||||
- name: Build Release APK
|
- name: Build Release APK
|
||||||
run: ./gradlew assembleRelease
|
run: ./gradlew --no-daemon assembleRelease
|
||||||
|
|
||||||
- name: Check if APK exists
|
- name: Check if APK exists
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ val gitShortSha = safeGitOutput("rev-parse", "--short", "HEAD") ?: "unknown"
|
|||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// Rosetta versioning — bump here on each release
|
// Rosetta versioning — bump here on each release
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
val rosettaVersionName = "1.2.7"
|
val rosettaVersionName = "1.3.2"
|
||||||
val rosettaVersionCode = 29 // Increment on each release
|
val rosettaVersionCode = 34 // Increment on each release
|
||||||
|
val customWebRtcAar = file("libs/libwebrtc-custom.aar")
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.rosetta.messenger"
|
namespace = "com.rosetta.messenger"
|
||||||
@@ -43,6 +44,19 @@ android {
|
|||||||
|
|
||||||
// Optimize Lottie animations
|
// Optimize Lottie animations
|
||||||
manifestPlaceholders["enableLottieOptimizations"] = "true"
|
manifestPlaceholders["enableLottieOptimizations"] = "true"
|
||||||
|
|
||||||
|
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a", "x86_64") }
|
||||||
|
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake { cppFlags("-std=c++17") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
externalNativeBuild {
|
||||||
|
cmake {
|
||||||
|
path = file("src/main/cpp/CMakeLists.txt")
|
||||||
|
version = "3.22.1"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -84,6 +98,10 @@ android {
|
|||||||
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
|
resources { excludes += "/META-INF/{AL2.0,LGPL2.1}" }
|
||||||
jniLibs { useLegacyPackaging = true }
|
jniLibs { useLegacyPackaging = true }
|
||||||
}
|
}
|
||||||
|
lint {
|
||||||
|
checkReleaseBuilds = false
|
||||||
|
abortOnError = false
|
||||||
|
}
|
||||||
|
|
||||||
applicationVariants.all {
|
applicationVariants.all {
|
||||||
outputs.all {
|
outputs.all {
|
||||||
@@ -165,6 +183,14 @@ dependencies {
|
|||||||
implementation("androidx.camera:camera-lifecycle:1.3.1")
|
implementation("androidx.camera:camera-lifecycle:1.3.1")
|
||||||
implementation("androidx.camera:camera-view:1.3.1")
|
implementation("androidx.camera:camera-view:1.3.1")
|
||||||
|
|
||||||
|
// WebRTC for voice calls.
|
||||||
|
// If app/libs/libwebrtc-custom.aar exists, prefer it (custom E2EE-enabled build).
|
||||||
|
if (customWebRtcAar.exists()) {
|
||||||
|
implementation(files(customWebRtcAar))
|
||||||
|
} else {
|
||||||
|
implementation("io.github.webrtc-sdk:android:125.6422.07")
|
||||||
|
}
|
||||||
|
|
||||||
// Baseline Profiles for startup performance
|
// Baseline Profiles for startup performance
|
||||||
implementation("androidx.profileinstaller:profileinstaller:1.3.1")
|
implementation("androidx.profileinstaller:profileinstaller:1.3.1")
|
||||||
|
|
||||||
|
|||||||
3158
app/libs/LICENSE.md
Normal file
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.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
|
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />
|
||||||
|
|||||||
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.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -15,7 +15,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🚀 Telegram-style: Fixed Height Box + Fade In/Out
|
* 🚀 Telegram-style: Fixed Height Box + Fade In/Out
|
||||||
@@ -110,19 +110,3 @@ fun AnimatedKeyboardTransition(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Алиас для обратной совместимости
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun SimpleAnimatedKeyboardTransition(
|
|
||||||
coordinator: KeyboardTransitionCoordinator,
|
|
||||||
showEmojiPicker: Boolean,
|
|
||||||
content: @Composable () -> Unit
|
|
||||||
) {
|
|
||||||
AnimatedKeyboardTransition(
|
|
||||||
coordinator = coordinator,
|
|
||||||
showEmojiPicker = showEmojiPicker,
|
|
||||||
content = content
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import android.os.Handler
|
|||||||
import android.os.Looper
|
import android.os.Looper
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -46,9 +46,6 @@ class KeyboardTransitionCoordinator {
|
|||||||
var currentState by mutableStateOf(TransitionState.IDLE)
|
var currentState by mutableStateOf(TransitionState.IDLE)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
var transitionProgress by mutableFloatStateOf(0f)
|
|
||||||
private set
|
|
||||||
|
|
||||||
// ============ Высоты ============
|
// ============ Высоты ============
|
||||||
|
|
||||||
var keyboardHeight by mutableStateOf(0.dp)
|
var keyboardHeight by mutableStateOf(0.dp)
|
||||||
@@ -68,9 +65,6 @@ class KeyboardTransitionCoordinator {
|
|||||||
// Используется для отключения imePadding пока Box виден
|
// Используется для отключения imePadding пока Box виден
|
||||||
var isEmojiBoxVisible by mutableStateOf(false)
|
var isEmojiBoxVisible by mutableStateOf(false)
|
||||||
|
|
||||||
// 🔥 Коллбэк для показа emoji (сохраняем для вызова после закрытия клавиатуры)
|
|
||||||
private var pendingShowEmojiCallback: (() -> Unit)? = null
|
|
||||||
|
|
||||||
// 📊 Для умного логирования (не каждый фрейм)
|
// 📊 Для умного логирования (не каждый фрейм)
|
||||||
private var lastLogTime = 0L
|
private var lastLogTime = 0L
|
||||||
private var lastLoggedHeight = -1f
|
private var lastLoggedHeight = -1f
|
||||||
@@ -108,8 +102,6 @@ class KeyboardTransitionCoordinator {
|
|||||||
currentState = TransitionState.IDLE
|
currentState = TransitionState.IDLE
|
||||||
isTransitioning = false
|
isTransitioning = false
|
||||||
|
|
||||||
// Очищаем pending callback - больше не нужен
|
|
||||||
pendingShowEmojiCallback = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Главный метод: Emoji → Keyboard ============
|
// ============ Главный метод: Emoji → Keyboard ============
|
||||||
@@ -119,11 +111,6 @@ class KeyboardTransitionCoordinator {
|
|||||||
* плавно скрыть emoji.
|
* плавно скрыть emoji.
|
||||||
*/
|
*/
|
||||||
fun requestShowKeyboard(showKeyboard: () -> Unit, hideEmoji: () -> Unit) {
|
fun requestShowKeyboard(showKeyboard: () -> Unit, hideEmoji: () -> Unit) {
|
||||||
// 🔥 Отменяем pending emoji callback если он есть (предотвращаем конфликт)
|
|
||||||
if (pendingShowEmojiCallback != null) {
|
|
||||||
pendingShowEmojiCallback = null
|
|
||||||
}
|
|
||||||
|
|
||||||
currentState = TransitionState.EMOJI_TO_KEYBOARD
|
currentState = TransitionState.EMOJI_TO_KEYBOARD
|
||||||
isTransitioning = true
|
isTransitioning = true
|
||||||
|
|
||||||
@@ -260,13 +247,6 @@ class KeyboardTransitionCoordinator {
|
|||||||
// 🔥 УБРАН pending callback - теперь emoji показывается сразу в requestShowEmoji()
|
// 🔥 УБРАН pending callback - теперь emoji показывается сразу в requestShowEmoji()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Обновить высоту emoji панели. */
|
|
||||||
fun updateEmojiHeight(height: Dp) {
|
|
||||||
if (height > 0.dp && height != emojiHeight) {
|
|
||||||
emojiHeight = height
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Синхронизировать высоты (emoji = keyboard).
|
* Синхронизировать высоты (emoji = keyboard).
|
||||||
*
|
*
|
||||||
@@ -292,35 +272,6 @@ class KeyboardTransitionCoordinator {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Получить текущую высоту для резервирования места. Telegram паттерн: всегда резервировать
|
|
||||||
* максимум из двух.
|
|
||||||
*/
|
|
||||||
fun getReservedHeight(): Dp {
|
|
||||||
return when {
|
|
||||||
isKeyboardVisible -> keyboardHeight
|
|
||||||
isEmojiVisible -> emojiHeight
|
|
||||||
isTransitioning -> maxOf(keyboardHeight, emojiHeight)
|
|
||||||
else -> 0.dp
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Проверка, можно ли начать новый переход. */
|
|
||||||
fun canStartTransition(): Boolean {
|
|
||||||
return !isTransitioning
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Сброс состояния (для отладки). */
|
|
||||||
fun reset() {
|
|
||||||
currentState = TransitionState.IDLE
|
|
||||||
isTransitioning = false
|
|
||||||
isKeyboardVisible = false
|
|
||||||
isEmojiVisible = false
|
|
||||||
transitionProgress = 0f
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Логирование текущего состояния. */
|
|
||||||
fun logState() {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Composable для создания и запоминания coordinator'а. */
|
/** Composable для создания и запоминания coordinator'а. */
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package com.rosetta.messenger
|
package com.rosetta.messenger
|
||||||
|
// commit
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
@@ -32,6 +33,8 @@ import com.rosetta.messenger.crypto.CryptoManager
|
|||||||
import com.rosetta.messenger.data.RecentSearchesManager
|
import com.rosetta.messenger.data.RecentSearchesManager
|
||||||
import com.rosetta.messenger.data.resolveAccountDisplayName
|
import com.rosetta.messenger.data.resolveAccountDisplayName
|
||||||
import com.rosetta.messenger.database.RosettaDatabase
|
import com.rosetta.messenger.database.RosettaDatabase
|
||||||
|
import com.rosetta.messenger.network.CallActionResult
|
||||||
|
import com.rosetta.messenger.network.CallManager
|
||||||
import com.rosetta.messenger.network.ProtocolManager
|
import com.rosetta.messenger.network.ProtocolManager
|
||||||
import com.rosetta.messenger.network.ProtocolState
|
import com.rosetta.messenger.network.ProtocolState
|
||||||
import com.rosetta.messenger.network.SearchUser
|
import com.rosetta.messenger.network.SearchUser
|
||||||
@@ -46,6 +49,7 @@ import com.rosetta.messenger.ui.chats.GroupInfoScreen
|
|||||||
import com.rosetta.messenger.ui.chats.GroupSetupScreen
|
import com.rosetta.messenger.ui.chats.GroupSetupScreen
|
||||||
import com.rosetta.messenger.ui.chats.RequestsListScreen
|
import com.rosetta.messenger.ui.chats.RequestsListScreen
|
||||||
import com.rosetta.messenger.ui.chats.SearchScreen
|
import com.rosetta.messenger.ui.chats.SearchScreen
|
||||||
|
import com.rosetta.messenger.ui.chats.calls.CallOverlay
|
||||||
import com.rosetta.messenger.ui.components.OptimizedEmojiCache
|
import com.rosetta.messenger.ui.components.OptimizedEmojiCache
|
||||||
import com.rosetta.messenger.ui.components.SwipeBackBackgroundEffect
|
import com.rosetta.messenger.ui.components.SwipeBackBackgroundEffect
|
||||||
import com.rosetta.messenger.ui.components.SwipeBackContainer
|
import com.rosetta.messenger.ui.components.SwipeBackContainer
|
||||||
@@ -116,6 +120,7 @@ class MainActivity : FragmentActivity() {
|
|||||||
|
|
||||||
// 🔥 Инициализируем ProtocolManager для обработки онлайн статусов
|
// 🔥 Инициализируем ProtocolManager для обработки онлайн статусов
|
||||||
ProtocolManager.initialize(this)
|
ProtocolManager.initialize(this)
|
||||||
|
CallManager.initialize(this)
|
||||||
|
|
||||||
// 🔔 Инициализируем Firebase для push-уведомлений
|
// 🔔 Инициализируем Firebase для push-уведомлений
|
||||||
initializeFirebase()
|
initializeFirebase()
|
||||||
@@ -581,6 +586,177 @@ fun MainScreen(
|
|||||||
|
|
||||||
// Load username AND name from AccountManager (persisted in DataStore)
|
// Load username AND name from AccountManager (persisted in DataStore)
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
|
val callScope = rememberCoroutineScope()
|
||||||
|
val callUiState by CallManager.state.collectAsState()
|
||||||
|
var pendingOutgoingCall by remember { mutableStateOf<SearchUser?>(null) }
|
||||||
|
var pendingIncomingAccept by remember { mutableStateOf(false) }
|
||||||
|
var callPermissionsRequestedOnce by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val mandatoryCallPermissions = remember {
|
||||||
|
listOf(Manifest.permission.RECORD_AUDIO)
|
||||||
|
}
|
||||||
|
val optionalCallPermissions = remember {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
listOf(Manifest.permission.BLUETOOTH_CONNECT)
|
||||||
|
} else {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val permissionsToRequest = remember(mandatoryCallPermissions, optionalCallPermissions) {
|
||||||
|
mandatoryCallPermissions + optionalCallPermissions
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasMandatoryCallPermissions: () -> Boolean =
|
||||||
|
remember(context, mandatoryCallPermissions) {
|
||||||
|
{
|
||||||
|
mandatoryCallPermissions.all { permission ->
|
||||||
|
ContextCompat.checkSelfPermission(context, permission) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val hasOptionalCallPermissions: () -> Boolean =
|
||||||
|
remember(context, optionalCallPermissions) {
|
||||||
|
{
|
||||||
|
optionalCallPermissions.all { permission ->
|
||||||
|
ContextCompat.checkSelfPermission(context, permission) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val showCallError: (CallActionResult) -> Unit = { result ->
|
||||||
|
val message =
|
||||||
|
when (result) {
|
||||||
|
CallActionResult.STARTED -> ""
|
||||||
|
CallActionResult.ALREADY_IN_CALL -> "Сначала заверши текущий звонок"
|
||||||
|
CallActionResult.NOT_AUTHENTICATED -> "Нет подключения к серверу"
|
||||||
|
CallActionResult.ACCOUNT_NOT_BOUND -> "Аккаунт еще не инициализирован"
|
||||||
|
CallActionResult.INVALID_TARGET -> "Не удалось определить пользователя для звонка"
|
||||||
|
CallActionResult.NOT_INCOMING -> "Входящий звонок не найден"
|
||||||
|
}
|
||||||
|
if (message.isNotBlank()) {
|
||||||
|
Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val resolveCallableUser: suspend (SearchUser) -> SearchUser? = resolve@{ user ->
|
||||||
|
val publicKey = user.publicKey.trim()
|
||||||
|
if (publicKey.isNotBlank()) {
|
||||||
|
return@resolve user.copy(publicKey = publicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
val usernameQuery = user.username.trim().trimStart('@')
|
||||||
|
if (usernameQuery.isBlank()) {
|
||||||
|
return@resolve null
|
||||||
|
}
|
||||||
|
|
||||||
|
ProtocolManager.getCachedUserByUsername(usernameQuery)?.let { cached ->
|
||||||
|
if (cached.publicKey.isNotBlank()) return@resolve cached
|
||||||
|
}
|
||||||
|
|
||||||
|
val results = ProtocolManager.searchUsers(usernameQuery)
|
||||||
|
results.firstOrNull {
|
||||||
|
it.publicKey.isNotBlank() &&
|
||||||
|
it.username.trim().trimStart('@')
|
||||||
|
.equals(usernameQuery, ignoreCase = true)
|
||||||
|
}?.let { return@resolve it }
|
||||||
|
|
||||||
|
return@resolve results.firstOrNull { it.publicKey.isNotBlank() }
|
||||||
|
}
|
||||||
|
|
||||||
|
val startOutgoingCallSafely: (SearchUser) -> Unit = { user ->
|
||||||
|
callScope.launch {
|
||||||
|
val resolved = resolveCallableUser(user)
|
||||||
|
if (resolved == null) {
|
||||||
|
showCallError(CallActionResult.INVALID_TARGET)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val result = CallManager.startOutgoingCall(resolved)
|
||||||
|
if (result != CallActionResult.STARTED) {
|
||||||
|
showCallError(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val acceptIncomingCallSafely: () -> Unit = {
|
||||||
|
val result = CallManager.acceptIncomingCall()
|
||||||
|
if (result != CallActionResult.STARTED) {
|
||||||
|
showCallError(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val callPermissionLauncher =
|
||||||
|
rememberLauncherForActivityResult(
|
||||||
|
contract = ActivityResultContracts.RequestMultiplePermissions()
|
||||||
|
) { grantedMap ->
|
||||||
|
callPermissionsRequestedOnce = true
|
||||||
|
val micGranted =
|
||||||
|
grantedMap[Manifest.permission.RECORD_AUDIO] == true ||
|
||||||
|
ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
|
||||||
|
val bluetoothGranted =
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
grantedMap[Manifest.permission.BLUETOOTH_CONNECT] == true ||
|
||||||
|
ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.BLUETOOTH_CONNECT
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!micGranted) {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
"Для звонков нужен доступ к микрофону",
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
} else {
|
||||||
|
pendingOutgoingCall?.let { startOutgoingCallSafely(it) }
|
||||||
|
if (pendingIncomingAccept) {
|
||||||
|
acceptIncomingCallSafely()
|
||||||
|
}
|
||||||
|
if (!bluetoothGranted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
"Bluetooth недоступен: гарнитура может не работать",
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pendingOutgoingCall = null
|
||||||
|
pendingIncomingAccept = false
|
||||||
|
}
|
||||||
|
|
||||||
|
val startCallWithPermission: (SearchUser) -> Unit = { user ->
|
||||||
|
val shouldRequestPermissions =
|
||||||
|
!hasMandatoryCallPermissions() ||
|
||||||
|
(!callPermissionsRequestedOnce && !hasOptionalCallPermissions())
|
||||||
|
if (!shouldRequestPermissions) {
|
||||||
|
startOutgoingCallSafely(user)
|
||||||
|
} else {
|
||||||
|
pendingOutgoingCall = user
|
||||||
|
callPermissionLauncher.launch(permissionsToRequest.toTypedArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val acceptCallWithPermission: () -> Unit = {
|
||||||
|
val shouldRequestPermissions =
|
||||||
|
!hasMandatoryCallPermissions() ||
|
||||||
|
(!callPermissionsRequestedOnce && !hasOptionalCallPermissions())
|
||||||
|
if (!shouldRequestPermissions) {
|
||||||
|
acceptIncomingCallSafely()
|
||||||
|
} else {
|
||||||
|
pendingIncomingAccept = true
|
||||||
|
callPermissionLauncher.launch(permissionsToRequest.toTypedArray())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(accountPublicKey) {
|
||||||
|
CallManager.bindAccount(accountPublicKey)
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(accountPublicKey, reloadTrigger) {
|
LaunchedEffect(accountPublicKey, reloadTrigger) {
|
||||||
if (accountPublicKey.isNotBlank()) {
|
if (accountPublicKey.isNotBlank()) {
|
||||||
val accountManager = AccountManager(context)
|
val accountManager = AccountManager(context)
|
||||||
@@ -1075,6 +1251,9 @@ fun MainScreen(
|
|||||||
currentUserUsername = accountUsername,
|
currentUserUsername = accountUsername,
|
||||||
totalUnreadFromOthers = totalUnreadFromOthers,
|
totalUnreadFromOthers = totalUnreadFromOthers,
|
||||||
onBack = { popChatAndChildren() },
|
onBack = { popChatAndChildren() },
|
||||||
|
onCallClick = { callableUser ->
|
||||||
|
startCallWithPermission(callableUser)
|
||||||
|
},
|
||||||
onUserProfileClick = { user ->
|
onUserProfileClick = { user ->
|
||||||
if (isCurrentAccountUser(user)) {
|
if (isCurrentAccountUser(user)) {
|
||||||
// Свой профиль из чата открываем поверх текущего чата,
|
// Свой профиль из чата открываем поверх текущего чата,
|
||||||
@@ -1200,6 +1379,11 @@ fun MainScreen(
|
|||||||
},
|
},
|
||||||
onNavigateToCrashLogs = {
|
onNavigateToCrashLogs = {
|
||||||
navStack = navStack.filterNot { it is Screen.Search } + Screen.CrashLogs
|
navStack = navStack.filterNot { it is Screen.Search } + Screen.CrashLogs
|
||||||
|
},
|
||||||
|
onNavigateToConnectionLogs = {
|
||||||
|
navStack =
|
||||||
|
navStack.filterNot { it is Screen.Search } +
|
||||||
|
Screen.ConnectionLogs
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1369,5 +1553,16 @@ fun MainScreen(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CallOverlay(
|
||||||
|
state = callUiState,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
avatarRepository = avatarRepository,
|
||||||
|
onAccept = { acceptCallWithPermission() },
|
||||||
|
onDecline = { CallManager.declineIncomingCall() },
|
||||||
|
onEnd = { CallManager.endCall() },
|
||||||
|
onToggleMute = { CallManager.toggleMute() },
|
||||||
|
onToggleSpeaker = { CallManager.toggleSpeaker() }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1592,7 +1592,6 @@ object MessageCrypto {
|
|||||||
// Reset bounds to default after first continuation
|
// Reset bounds to default after first continuation
|
||||||
lowerBoundary = 0x80
|
lowerBoundary = 0x80
|
||||||
upperBoundary = 0xBF
|
upperBoundary = 0xBF
|
||||||
// test
|
|
||||||
if (bytesSeen == bytesNeeded) {
|
if (bytesSeen == bytesNeeded) {
|
||||||
// Sequence complete — emit code point
|
// Sequence complete — emit code point
|
||||||
if (codePoint <= 0xFFFF) {
|
if (codePoint <= 0xFFFF) {
|
||||||
|
|||||||
@@ -477,15 +477,18 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
val startTime = System.currentTimeMillis()
|
val startTime = System.currentTimeMillis()
|
||||||
try {
|
try {
|
||||||
// Шифрование
|
// Шифрование (пропускаем для пустого текста — напр. CALL-сообщения)
|
||||||
val encryptResult = MessageCrypto.encryptForSending(text.trim(), toPublicKey)
|
val hasContent = text.trim().isNotEmpty()
|
||||||
val encryptedContent = encryptResult.ciphertext
|
val encryptResult = if (hasContent) MessageCrypto.encryptForSending(text.trim(), toPublicKey) else null
|
||||||
val encryptedKey = encryptResult.encryptedKey
|
val encryptedContent = encryptResult?.ciphertext ?: ""
|
||||||
|
val encryptedKey = encryptResult?.encryptedKey ?: ""
|
||||||
val aesChachaKey =
|
val aesChachaKey =
|
||||||
|
if (encryptResult != null) {
|
||||||
CryptoManager.encryptWithPassword(
|
CryptoManager.encryptWithPassword(
|
||||||
String(encryptResult.plainKeyAndNonce, Charsets.ISO_8859_1),
|
String(encryptResult.plainKeyAndNonce, Charsets.ISO_8859_1),
|
||||||
privateKey
|
privateKey
|
||||||
)
|
)
|
||||||
|
} else ""
|
||||||
|
|
||||||
// 📝 LOG: Шифрование успешно
|
// 📝 LOG: Шифрование успешно
|
||||||
MessageLogger.logEncryptionSuccess(
|
MessageLogger.logEncryptionSuccess(
|
||||||
@@ -686,13 +689,6 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 🔥 ВТОРОЙ УРОВЕНЬ ЗАЩИТЫ: Проверка в БД (для сообщений сохранённых в предыдущих сессиях)
|
|
||||||
val isDuplicate = messageDao.messageExists(account, messageId)
|
|
||||||
MessageLogger.logDuplicateCheck(messageId, isDuplicate)
|
|
||||||
if (isDuplicate) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
val dialogOpponentKey =
|
val dialogOpponentKey =
|
||||||
when {
|
when {
|
||||||
isGroupMessage -> packet.toPublicKey
|
isGroupMessage -> packet.toPublicKey
|
||||||
@@ -701,6 +697,33 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
}
|
}
|
||||||
val dialogKey = getDialogKey(dialogOpponentKey)
|
val dialogKey = getDialogKey(dialogOpponentKey)
|
||||||
|
|
||||||
|
// 🔥 ВТОРОЙ УРОВЕНЬ ЗАЩИТЫ: Проверка в БД (для сообщений сохранённых в предыдущих сессиях)
|
||||||
|
val isDuplicate = messageDao.messageExists(account, messageId)
|
||||||
|
MessageLogger.logDuplicateCheck(messageId, isDuplicate)
|
||||||
|
if (isDuplicate) {
|
||||||
|
// Desktop/server parity:
|
||||||
|
// own messages that arrive via sync must be treated as delivered.
|
||||||
|
// If a local optimistic row already exists (WAITING/ERROR), normalize it.
|
||||||
|
if (isOwnMessage) {
|
||||||
|
messageDao.updateDeliveryStatus(account, messageId, DeliveryStatus.DELIVERED.value)
|
||||||
|
messageCache[dialogKey]?.let { flow ->
|
||||||
|
flow.value =
|
||||||
|
flow.value.map { msg ->
|
||||||
|
if (msg.messageId == messageId) {
|
||||||
|
msg.copy(deliveryStatus = DeliveryStatus.DELIVERED)
|
||||||
|
} else {
|
||||||
|
msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_deliveryStatusEvents.tryEmit(
|
||||||
|
DeliveryStatusUpdate(dialogKey, messageId, DeliveryStatus.DELIVERED)
|
||||||
|
)
|
||||||
|
dialogDao.updateDialogFromMessages(account, dialogOpponentKey)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val groupKey =
|
val groupKey =
|
||||||
if (isGroupMessage) {
|
if (isGroupMessage) {
|
||||||
@@ -743,15 +766,26 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Расшифровываем
|
// Расшифровываем (CALL и attachment-only сообщения могут иметь пустой или
|
||||||
|
// зашифрованный пустой content — обрабатываем оба случая безопасно)
|
||||||
|
val isAttachmentOnly = packet.content.isBlank() ||
|
||||||
|
(packet.attachments.isNotEmpty() && packet.chachaKey.isBlank())
|
||||||
val plainText =
|
val plainText =
|
||||||
if (isGroupMessage) {
|
if (isAttachmentOnly) {
|
||||||
|
""
|
||||||
|
} else if (isGroupMessage) {
|
||||||
CryptoManager.decryptWithPassword(packet.content, groupKey!!)
|
CryptoManager.decryptWithPassword(packet.content, groupKey!!)
|
||||||
?: throw IllegalStateException("Failed to decrypt group payload")
|
?: throw IllegalStateException("Failed to decrypt group payload")
|
||||||
} else if (plainKeyAndNonce != null) {
|
} else if (plainKeyAndNonce != null) {
|
||||||
MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce)
|
MessageCrypto.decryptIncomingWithPlainKey(packet.content, plainKeyAndNonce)
|
||||||
} else {
|
} else {
|
||||||
|
try {
|
||||||
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
|
MessageCrypto.decryptIncoming(packet.content, packet.chachaKey, privateKey)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Fallback: если дешифровка не удалась (напр. CALL с encrypted empty content)
|
||||||
|
android.util.Log.w("MessageRepository", "Decryption fallback for ${messageId.take(8)}: ${e.message}")
|
||||||
|
""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 📝 LOG: Расшифровка успешна
|
// 📝 LOG: Расшифровка успешна
|
||||||
@@ -851,11 +885,10 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
unreadCount = dialog?.unreadCount ?: 0
|
unreadCount = dialog?.unreadCount ?: 0
|
||||||
)
|
)
|
||||||
|
|
||||||
// 🔥 Запрашиваем информацию о пользователе для отображения имени вместо ключа
|
// 🔥 Запрашиваем информацию о пользователе для отображения имени вместо ключа.
|
||||||
// Desktop parity: always re-fetch on incoming message so renamed contacts
|
// Важно: не форсим повторный запрос на каждый входящий пакет — это создает
|
||||||
// get their new name/username updated in the chat list.
|
// шторм PacketSearch во время sync и заметно тормозит обработку.
|
||||||
if (!isGroupDialogKey(dialogOpponentKey)) {
|
if (!isGroupDialogKey(dialogOpponentKey)) {
|
||||||
requestedUserInfoKeys.remove(dialogOpponentKey)
|
|
||||||
requestUserInfo(dialogOpponentKey)
|
requestUserInfo(dialogOpponentKey)
|
||||||
} else {
|
} else {
|
||||||
applyGroupDisplayNameToDialog(account, dialogOpponentKey)
|
applyGroupDisplayNameToDialog(account, dialogOpponentKey)
|
||||||
@@ -955,20 +988,24 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
|
|
||||||
val readCount =
|
val readCount =
|
||||||
messageCache[dialogKey]?.value?.count {
|
messageCache[dialogKey]?.value?.count {
|
||||||
it.isFromMe &&
|
it.isFromMe && !it.isRead
|
||||||
!it.isRead &&
|
|
||||||
(it.deliveryStatus == DeliveryStatus.DELIVERED ||
|
|
||||||
it.deliveryStatus == DeliveryStatus.READ)
|
|
||||||
} ?: 0
|
} ?: 0
|
||||||
messageCache[dialogKey]?.let { flow ->
|
messageCache[dialogKey]?.let { flow ->
|
||||||
flow.value =
|
flow.value =
|
||||||
flow.value.map { msg ->
|
flow.value.map { msg ->
|
||||||
if (msg.isFromMe &&
|
if (msg.isFromMe && !msg.isRead) {
|
||||||
!msg.isRead &&
|
msg.copy(
|
||||||
(msg.deliveryStatus == DeliveryStatus.DELIVERED ||
|
isRead = true,
|
||||||
msg.deliveryStatus == DeliveryStatus.READ)
|
deliveryStatus =
|
||||||
|
if (
|
||||||
|
msg.deliveryStatus == DeliveryStatus.DELIVERED ||
|
||||||
|
msg.deliveryStatus == DeliveryStatus.READ
|
||||||
) {
|
) {
|
||||||
msg.copy(isRead = true, deliveryStatus = DeliveryStatus.READ)
|
DeliveryStatus.READ
|
||||||
|
} else {
|
||||||
|
msg.deliveryStatus
|
||||||
|
}
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
msg
|
msg
|
||||||
}
|
}
|
||||||
@@ -1006,20 +1043,24 @@ class MessageRepository private constructor(private val context: Context) {
|
|||||||
|
|
||||||
val readCount =
|
val readCount =
|
||||||
messageCache[dialogKey]?.value?.count {
|
messageCache[dialogKey]?.value?.count {
|
||||||
it.isFromMe &&
|
it.isFromMe && !it.isRead
|
||||||
!it.isRead &&
|
|
||||||
(it.deliveryStatus == DeliveryStatus.DELIVERED ||
|
|
||||||
it.deliveryStatus == DeliveryStatus.READ)
|
|
||||||
} ?: 0
|
} ?: 0
|
||||||
messageCache[dialogKey]?.let { flow ->
|
messageCache[dialogKey]?.let { flow ->
|
||||||
flow.value =
|
flow.value =
|
||||||
flow.value.map { msg ->
|
flow.value.map { msg ->
|
||||||
if (msg.isFromMe &&
|
if (msg.isFromMe && !msg.isRead) {
|
||||||
!msg.isRead &&
|
msg.copy(
|
||||||
(msg.deliveryStatus == DeliveryStatus.DELIVERED ||
|
isRead = true,
|
||||||
msg.deliveryStatus == DeliveryStatus.READ)
|
deliveryStatus =
|
||||||
|
if (
|
||||||
|
msg.deliveryStatus == DeliveryStatus.DELIVERED ||
|
||||||
|
msg.deliveryStatus == DeliveryStatus.READ
|
||||||
) {
|
) {
|
||||||
msg.copy(isRead = true, deliveryStatus = DeliveryStatus.READ)
|
DeliveryStatus.READ
|
||||||
|
} else {
|
||||||
|
msg.deliveryStatus
|
||||||
|
}
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
msg
|
msg
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,30 +17,10 @@ object ReleaseNotes {
|
|||||||
val RELEASE_NOTICE = """
|
val RELEASE_NOTICE = """
|
||||||
Update v$VERSION_PLACEHOLDER
|
Update v$VERSION_PLACEHOLDER
|
||||||
|
|
||||||
Поиск
|
Защищенные звонки и диагностика E2EE
|
||||||
- Добавлена вкладка Messages в поиске: поиск по тексту сообщений по всем чатам
|
- Обновлен custom WebRTC для Android и исправлена совместимость аудио E2EE с Desktop
|
||||||
- Реализованы быстрые сниппеты с подсветкой найденного текста и переходом в нужный чат
|
- Улучшены diagnostics для шифрования звонков (детализация ENC/DEC в crash reports)
|
||||||
- Добавлены алиасы для Saved Messages в поиске (saved / saved messages / избранное и др.)
|
- В Crash Reports добавлена кнопка копирования полного лога одним действием
|
||||||
|
|
||||||
Тэги и навигация
|
|
||||||
- Исправлены клики по @тэгам в сообщениях: теперь открывается чат пользователя
|
|
||||||
- Добавлен устойчивый резолв @username (локальный диалог -> кэш -> сервер)
|
|
||||||
- Устранен конфликт клика по тэгу с контекстным меню пузырька
|
|
||||||
|
|
||||||
Чаты и UI
|
|
||||||
- Улучшен пустой экран Saved Messages на обоях: добавлена подложка и повышена читаемость
|
|
||||||
- Стабилизировано отображение verified-бейджа в хедере личного чата
|
|
||||||
- Подправлено положение галочки в сайдбаре
|
|
||||||
- В тёмной теме цвет цифры в бейдже Requests возле бургер-меню приведен к цвету шапки
|
|
||||||
|
|
||||||
Темы и обои
|
|
||||||
- Добавлены пары обоев для светлой и темной темы
|
|
||||||
- Обои теперь автоматически синхронизируются при переключении темы
|
|
||||||
- Выбор обоев сохраняется отдельно для light/dark
|
|
||||||
|
|
||||||
Безопасность и система
|
|
||||||
- Если устройство не поддерживает отпечаток пальца, биометрия больше не предлагается
|
|
||||||
- Удалена неиспользуемая зависимость jsoup
|
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
fun getNotice(version: String): String =
|
fun getNotice(version: String): String =
|
||||||
|
|||||||
@@ -403,7 +403,7 @@ interface MessageDao {
|
|||||||
WHERE account = :account
|
WHERE account = :account
|
||||||
AND to_public_key = :opponent
|
AND to_public_key = :opponent
|
||||||
AND from_me = 1
|
AND from_me = 1
|
||||||
AND delivered IN (1, 3)
|
AND read != 1
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
suspend fun markAllAsRead(account: String, opponent: String): Int
|
suspend fun markAllAsRead(account: String, opponent: String): Int
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ enum class AttachmentType(val value: Int) {
|
|||||||
IMAGE(0), // Изображение
|
IMAGE(0), // Изображение
|
||||||
MESSAGES(1), // Reply (цитата сообщения)
|
MESSAGES(1), // Reply (цитата сообщения)
|
||||||
FILE(2), // Файл
|
FILE(2), // Файл
|
||||||
AVATAR(3); // Аватар пользователя
|
AVATAR(3), // Аватар пользователя
|
||||||
|
CALL(4), // Событие звонка (пропущен/принят/завершен)
|
||||||
|
UNKNOWN(-1); // Неизвестный тип
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: IMAGE
|
fun fromInt(value: Int) = entries.firstOrNull { it.value == value } ?: UNKNOWN
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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 {
|
companion object {
|
||||||
fun fromValue(value: Int): HandshakeState {
|
fun fromValue(value: Int): HandshakeState {
|
||||||
return entries.firstOrNull { it.value == value } ?: COMPLETED
|
// Fail-safe: unknown value must not auto-authenticate.
|
||||||
|
return entries.firstOrNull { it.value == value } ?: NEED_DEVICE_VERIFICATION
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 TAG = "RosettaProtocol"
|
||||||
private const val RECONNECT_INTERVAL = 5000L // 5 seconds (как в Архиве)
|
private const val RECONNECT_INTERVAL = 5000L // 5 seconds (как в Архиве)
|
||||||
private const val HANDSHAKE_TIMEOUT = 10000L // 10 seconds
|
private const val HANDSHAKE_TIMEOUT = 10000L // 10 seconds
|
||||||
private const val MIN_PACKET_ID_BITS = 18 // Stream.readInt16() = 2 * readInt8() (9 bits each)
|
private const val MIN_PACKET_ID_BITS = 16 // Stream.readInt16() reads exactly 16 bits
|
||||||
|
private const val DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 15
|
||||||
|
private const val MIN_HEARTBEAT_SEND_INTERVAL_MS = 2_000L
|
||||||
|
private const val HEARTBEAT_OK_LOG_THROTTLE_MS = 30_000L
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun log(message: String) {
|
private fun log(message: String) {
|
||||||
@@ -112,6 +115,9 @@ class Protocol(
|
|||||||
|
|
||||||
// Heartbeat
|
// Heartbeat
|
||||||
private var heartbeatJob: Job? = null
|
private var heartbeatJob: Job? = null
|
||||||
|
@Volatile private var heartbeatPeriodMs: Long = 0L
|
||||||
|
@Volatile private var lastHeartbeatOkLogAtMs: Long = 0L
|
||||||
|
@Volatile private var heartbeatOkSuppressedCount: Int = 0
|
||||||
|
|
||||||
// Supported packets
|
// Supported packets
|
||||||
private val supportedPackets = mapOf(
|
private val supportedPackets = mapOf(
|
||||||
@@ -127,6 +133,7 @@ class Protocol(
|
|||||||
0x09 to { PacketDeviceNew() },
|
0x09 to { PacketDeviceNew() },
|
||||||
0x0A to { PacketRequestUpdate() },
|
0x0A to { PacketRequestUpdate() },
|
||||||
0x0B to { PacketTyping() },
|
0x0B to { PacketTyping() },
|
||||||
|
0x10 to { PacketPushNotification() },
|
||||||
0x11 to { PacketCreateGroup() },
|
0x11 to { PacketCreateGroup() },
|
||||||
0x12 to { PacketGroupInfo() },
|
0x12 to { PacketGroupInfo() },
|
||||||
0x13 to { PacketGroupInviteInfo() },
|
0x13 to { PacketGroupInviteInfo() },
|
||||||
@@ -136,7 +143,10 @@ class Protocol(
|
|||||||
0x0F to { PacketRequestTransport() },
|
0x0F to { PacketRequestTransport() },
|
||||||
0x17 to { PacketDeviceList() },
|
0x17 to { PacketDeviceList() },
|
||||||
0x18 to { PacketDeviceResolve() },
|
0x18 to { PacketDeviceResolve() },
|
||||||
0x19 to { PacketSync() }
|
0x19 to { PacketSync() },
|
||||||
|
0x1A to { PacketSignalPeer() },
|
||||||
|
0x1B to { PacketWebRTC() },
|
||||||
|
0x1C to { PacketIceServers() }
|
||||||
)
|
)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@@ -175,11 +185,24 @@ class Protocol(
|
|||||||
* Как в Архиве - отправляем text "heartbeat" СРАЗУ и потом с интервалом
|
* Как в Архиве - отправляем text "heartbeat" СРАЗУ и потом с интервалом
|
||||||
*/
|
*/
|
||||||
private fun startHeartbeat(intervalSeconds: Int) {
|
private fun startHeartbeat(intervalSeconds: Int) {
|
||||||
heartbeatJob?.cancel()
|
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)
|
||||||
|
|
||||||
// Отправляем чаще - каждые 1/3 интервала (чтобы не терять соединение)
|
if (heartbeatJob?.isActive == true && heartbeatPeriodMs == intervalMs) {
|
||||||
val intervalMs = (intervalSeconds * 1000L) / 3
|
return
|
||||||
log("💓 HEARTBEAT START: server=${intervalSeconds}s, sending=${intervalMs/1000}s, state=${_state.value}")
|
}
|
||||||
|
|
||||||
|
heartbeatJob?.cancel()
|
||||||
|
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 {
|
heartbeatJob = scope.launch {
|
||||||
// ⚡ СРАЗУ отправляем первый heartbeat (как в Архиве)
|
// ⚡ СРАЗУ отправляем первый heartbeat (как в Архиве)
|
||||||
@@ -206,7 +229,17 @@ class Protocol(
|
|||||||
) {
|
) {
|
||||||
val sent = webSocket?.send("heartbeat") ?: false
|
val sent = webSocket?.send("heartbeat") ?: false
|
||||||
if (sent) {
|
if (sent) {
|
||||||
log("💓 Heartbeat OK (socket=$socketAlive, state=$currentState)")
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastHeartbeatOkLogAtMs >= HEARTBEAT_OK_LOG_THROTTLE_MS) {
|
||||||
|
val suppressed = heartbeatOkSuppressedCount
|
||||||
|
heartbeatOkSuppressedCount = 0
|
||||||
|
lastHeartbeatOkLogAtMs = now
|
||||||
|
val suffix =
|
||||||
|
if (suppressed > 0) ", +$suppressed suppressed" else ""
|
||||||
|
log("💓 Heartbeat OK (socket=$socketAlive, state=$currentState$suffix)")
|
||||||
|
} else {
|
||||||
|
heartbeatOkSuppressedCount++
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
log("💔 HEARTBEAT FAILED: socket=$socketAlive, state=$currentState, manuallyClosed=$isManuallyClosed")
|
log("💔 HEARTBEAT FAILED: socket=$socketAlive, state=$currentState, manuallyClosed=$isManuallyClosed")
|
||||||
// Триггерим reconnect если heartbeat не прошёл
|
// Триггерим reconnect если heartbeat не прошёл
|
||||||
@@ -502,18 +535,18 @@ class Protocol(
|
|||||||
log("📥 Received ${data.size} bytes: $hexDump${if (data.size > 50) "..." else ""}")
|
log("📥 Received ${data.size} bytes: $hexDump${if (data.size > 50) "..." else ""}")
|
||||||
|
|
||||||
val stream = Stream(data)
|
val stream = Stream(data)
|
||||||
var parsedPackets = 0
|
if (stream.getRemainingBits() < MIN_PACKET_ID_BITS) {
|
||||||
|
log("⚠️ Frame too short to contain packet ID (${stream.getRemainingBits()} bits)")
|
||||||
while (stream.getRemainingBits() >= MIN_PACKET_ID_BITS) {
|
return
|
||||||
val packetStartBits = stream.getReadPointerBits()
|
}
|
||||||
|
// Desktop/server parity: one WebSocket frame contains one packet.
|
||||||
val packetId = stream.readInt16()
|
val packetId = stream.readInt16()
|
||||||
|
|
||||||
log("📥 Packet ID: $packetId")
|
log("📥 Packet ID: $packetId")
|
||||||
|
|
||||||
val packetFactory = supportedPackets[packetId]
|
val packetFactory = supportedPackets[packetId]
|
||||||
if (packetFactory == null) {
|
if (packetFactory == null) {
|
||||||
log("⚠️ Unknown packet ID: $packetId, stopping frame parse")
|
log("⚠️ Unknown packet ID: $packetId")
|
||||||
break
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val packet = packetFactory()
|
val packet = packetFactory()
|
||||||
@@ -522,7 +555,7 @@ class Protocol(
|
|||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log("❌ Error parsing packet $packetId: ${e.message}")
|
log("❌ Error parsing packet $packetId: ${e.message}")
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
break
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify waiters
|
// Notify waiters
|
||||||
@@ -537,18 +570,6 @@ class Protocol(
|
|||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
parsedPackets++
|
|
||||||
val consumedBits = stream.getReadPointerBits() - packetStartBits
|
|
||||||
if (consumedBits <= 0) {
|
|
||||||
log("⚠️ Packet parser made no progress for packet $packetId, stopping frame parse")
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsedPackets > 1) {
|
|
||||||
log("📦 Parsed $parsedPackets packets from single WebSocket frame")
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
log("❌ Error parsing packet: ${e.message}")
|
log("❌ Error parsing packet: ${e.message}")
|
||||||
e.printStackTrace()
|
e.printStackTrace()
|
||||||
@@ -569,6 +590,7 @@ class Protocol(
|
|||||||
handshakeComplete = false
|
handshakeComplete = false
|
||||||
handshakeJob?.cancel()
|
handshakeJob?.cancel()
|
||||||
heartbeatJob?.cancel()
|
heartbeatJob?.cancel()
|
||||||
|
heartbeatPeriodMs = 0L
|
||||||
|
|
||||||
// Автоматический reconnect с защитой от бесконечных попыток
|
// Автоматический reconnect с защитой от бесконечных попыток
|
||||||
if (!isManuallyClosed) {
|
if (!isManuallyClosed) {
|
||||||
@@ -624,6 +646,7 @@ class Protocol(
|
|||||||
reconnectJob = null
|
reconnectJob = null
|
||||||
handshakeJob?.cancel()
|
handshakeJob?.cancel()
|
||||||
heartbeatJob?.cancel()
|
heartbeatJob?.cancel()
|
||||||
|
heartbeatPeriodMs = 0L
|
||||||
webSocket?.close(1000, "User disconnected")
|
webSocket?.close(1000, "User disconnected")
|
||||||
webSocket = null
|
webSocket = null
|
||||||
_state.value = ProtocolState.DISCONNECTED
|
_state.value = ProtocolState.DISCONNECTED
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.rosetta.messenger.data.AccountManager
|
|||||||
import com.rosetta.messenger.data.GroupRepository
|
import com.rosetta.messenger.data.GroupRepository
|
||||||
import com.rosetta.messenger.data.MessageRepository
|
import com.rosetta.messenger.data.MessageRepository
|
||||||
import com.rosetta.messenger.data.isPlaceholderAccountName
|
import com.rosetta.messenger.data.isPlaceholderAccountName
|
||||||
|
import com.rosetta.messenger.utils.MessageLogger
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -27,9 +28,14 @@ import kotlin.coroutines.resume
|
|||||||
object ProtocolManager {
|
object ProtocolManager {
|
||||||
private const val TAG = "ProtocolManager"
|
private const val TAG = "ProtocolManager"
|
||||||
private const val MANUAL_SYNC_BACKTRACK_MS = 120_000L
|
private const val MANUAL_SYNC_BACKTRACK_MS = 120_000L
|
||||||
|
private const val SYNC_REQUEST_TIMEOUT_MS = 12_000L
|
||||||
private const val MAX_DEBUG_LOGS = 600
|
private const val MAX_DEBUG_LOGS = 600
|
||||||
private const val DEBUG_LOG_FLUSH_DELAY_MS = 60L
|
private const val DEBUG_LOG_FLUSH_DELAY_MS = 60L
|
||||||
|
private const val HEARTBEAT_OK_LOG_MIN_INTERVAL_MS = 5_000L
|
||||||
private const val TYPING_INDICATOR_TIMEOUT_MS = 3_000L
|
private const val TYPING_INDICATOR_TIMEOUT_MS = 3_000L
|
||||||
|
private const val PACKET_SIGNAL_PEER = 0x1A
|
||||||
|
private const val PACKET_WEB_RTC = 0x1B
|
||||||
|
private const val PACKET_ICE_SERVERS = 0x1C
|
||||||
|
|
||||||
// Desktop parity: use the same primary WebSocket endpoint as desktop client.
|
// Desktop parity: use the same primary WebSocket endpoint as desktop client.
|
||||||
private const val SERVER_ADDRESS = "wss://wss.rosetta.im"
|
private const val SERVER_ADDRESS = "wss://wss.rosetta.im"
|
||||||
@@ -45,6 +51,7 @@ object ProtocolManager {
|
|||||||
@Volatile private var packetHandlersRegistered = false
|
@Volatile private var packetHandlersRegistered = false
|
||||||
@Volatile private var stateMonitoringStarted = false
|
@Volatile private var stateMonitoringStarted = false
|
||||||
@Volatile private var syncRequestInFlight = false
|
@Volatile private var syncRequestInFlight = false
|
||||||
|
@Volatile private var syncRequestTimeoutJob: Job? = null
|
||||||
|
|
||||||
// Guard: prevent duplicate FCM token subscribe within a single session
|
// Guard: prevent duplicate FCM token subscribe within a single session
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -56,6 +63,8 @@ object ProtocolManager {
|
|||||||
private val debugLogsLock = Any()
|
private val debugLogsLock = Any()
|
||||||
@Volatile private var debugFlushJob: Job? = null
|
@Volatile private var debugFlushJob: Job? = null
|
||||||
private val debugFlushPending = AtomicBoolean(false)
|
private val debugFlushPending = AtomicBoolean(false)
|
||||||
|
@Volatile private var lastHeartbeatOkLogAtMs: Long = 0L
|
||||||
|
@Volatile private var suppressedHeartbeatOkLogs: Int = 0
|
||||||
|
|
||||||
// Typing status
|
// Typing status
|
||||||
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
|
private val _typingUsers = MutableStateFlow<Set<String>>(emptySet())
|
||||||
@@ -87,8 +96,8 @@ object ProtocolManager {
|
|||||||
private fun normalizeSearchQuery(value: String): String =
|
private fun normalizeSearchQuery(value: String): String =
|
||||||
value.trim().removePrefix("@").lowercase(Locale.ROOT)
|
value.trim().removePrefix("@").lowercase(Locale.ROOT)
|
||||||
|
|
||||||
// UI logs are enabled by default; updates are throttled and bounded by MAX_DEBUG_LOGS.
|
// Keep heavy protocol/message UI logs disabled by default.
|
||||||
private var uiLogsEnabled = true
|
private var uiLogsEnabled = false
|
||||||
private var lastProtocolState: ProtocolState? = null
|
private var lastProtocolState: ProtocolState? = null
|
||||||
@Volatile private var syncBatchInProgress = false
|
@Volatile private var syncBatchInProgress = false
|
||||||
private val _syncInProgress = MutableStateFlow(false)
|
private val _syncInProgress = MutableStateFlow(false)
|
||||||
@@ -126,9 +135,23 @@ object ProtocolManager {
|
|||||||
|
|
||||||
fun addLog(message: String) {
|
fun addLog(message: String) {
|
||||||
if (!uiLogsEnabled) return
|
if (!uiLogsEnabled) return
|
||||||
|
var normalizedMessage = message
|
||||||
|
val isHeartbeatOk = normalizedMessage.startsWith("💓 Heartbeat OK")
|
||||||
|
if (isHeartbeatOk) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastHeartbeatOkLogAtMs < HEARTBEAT_OK_LOG_MIN_INTERVAL_MS) {
|
||||||
|
suppressedHeartbeatOkLogs++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (suppressedHeartbeatOkLogs > 0) {
|
||||||
|
normalizedMessage = "$normalizedMessage (+${suppressedHeartbeatOkLogs} skipped)"
|
||||||
|
suppressedHeartbeatOkLogs = 0
|
||||||
|
}
|
||||||
|
lastHeartbeatOkLogAtMs = now
|
||||||
|
}
|
||||||
val timestamp =
|
val timestamp =
|
||||||
java.text.SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
|
java.text.SimpleDateFormat("HH:mm:ss.SSS", Locale.getDefault()).format(Date())
|
||||||
val line = "[$timestamp] $message"
|
val line = "[$timestamp] $normalizedMessage"
|
||||||
synchronized(debugLogsLock) {
|
synchronized(debugLogsLock) {
|
||||||
if (debugLogsBuffer.size >= MAX_DEBUG_LOGS) {
|
if (debugLogsBuffer.size >= MAX_DEBUG_LOGS) {
|
||||||
debugLogsBuffer.removeFirst()
|
debugLogsBuffer.removeFirst()
|
||||||
@@ -140,6 +163,7 @@ object ProtocolManager {
|
|||||||
|
|
||||||
fun enableUILogs(enabled: Boolean) {
|
fun enableUILogs(enabled: Boolean) {
|
||||||
uiLogsEnabled = enabled
|
uiLogsEnabled = enabled
|
||||||
|
MessageLogger.setEnabled(enabled)
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
val snapshot = synchronized(debugLogsLock) { debugLogsBuffer.toList() }
|
val snapshot = synchronized(debugLogsLock) { debugLogsBuffer.toList() }
|
||||||
_debugLogs.value = snapshot
|
_debugLogs.value = snapshot
|
||||||
@@ -152,6 +176,8 @@ object ProtocolManager {
|
|||||||
synchronized(debugLogsLock) {
|
synchronized(debugLogsLock) {
|
||||||
debugLogsBuffer.clear()
|
debugLogsBuffer.clear()
|
||||||
}
|
}
|
||||||
|
suppressedHeartbeatOkLogs = 0
|
||||||
|
lastHeartbeatOkLogAtMs = 0L
|
||||||
_debugLogs.value = emptyList()
|
_debugLogs.value = emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,6 +236,7 @@ object ProtocolManager {
|
|||||||
}
|
}
|
||||||
if (newState != ProtocolState.AUTHENTICATED && newState != ProtocolState.HANDSHAKING) {
|
if (newState != ProtocolState.AUTHENTICATED && newState != ProtocolState.HANDSHAKING) {
|
||||||
syncRequestInFlight = false
|
syncRequestInFlight = false
|
||||||
|
clearSyncRequestTimeout()
|
||||||
setSyncInProgress(false)
|
setSyncInProgress(false)
|
||||||
// Connection/session dropped: force re-subscribe on next AUTHENTICATED.
|
// Connection/session dropped: force re-subscribe on next AUTHENTICATED.
|
||||||
lastSubscribedToken = null
|
lastSubscribedToken = null
|
||||||
@@ -678,6 +705,7 @@ object ProtocolManager {
|
|||||||
|
|
||||||
private fun finishSyncCycle(reason: String) {
|
private fun finishSyncCycle(reason: String) {
|
||||||
syncRequestInFlight = false
|
syncRequestInFlight = false
|
||||||
|
clearSyncRequestTimeout()
|
||||||
inboundProcessingFailures.set(0)
|
inboundProcessingFailures.set(0)
|
||||||
addLog(reason)
|
addLog(reason)
|
||||||
setSyncInProgress(false)
|
setSyncInProgress(false)
|
||||||
@@ -734,6 +762,7 @@ object ProtocolManager {
|
|||||||
val repository = messageRepository
|
val repository = messageRepository
|
||||||
if (repository == null || !repository.isInitialized()) {
|
if (repository == null || !repository.isInitialized()) {
|
||||||
syncRequestInFlight = false
|
syncRequestInFlight = false
|
||||||
|
clearSyncRequestTimeout()
|
||||||
requireResyncAfterAccountInit("⏳ Sync postponed until account is initialized")
|
requireResyncAfterAccountInit("⏳ Sync postponed until account is initialized")
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
@@ -744,6 +773,7 @@ object ProtocolManager {
|
|||||||
repositoryAccount != protocolAccount
|
repositoryAccount != protocolAccount
|
||||||
) {
|
) {
|
||||||
syncRequestInFlight = false
|
syncRequestInFlight = false
|
||||||
|
clearSyncRequestTimeout()
|
||||||
requireResyncAfterAccountInit(
|
requireResyncAfterAccountInit(
|
||||||
"⏳ Sync postponed: repository bound to another account"
|
"⏳ Sync postponed: repository bound to another account"
|
||||||
)
|
)
|
||||||
@@ -757,6 +787,7 @@ object ProtocolManager {
|
|||||||
|
|
||||||
private fun sendSynchronize(timestamp: Long) {
|
private fun sendSynchronize(timestamp: Long) {
|
||||||
syncRequestInFlight = true
|
syncRequestInFlight = true
|
||||||
|
scheduleSyncRequestTimeout(timestamp)
|
||||||
val packet = PacketSync().apply {
|
val packet = PacketSync().apply {
|
||||||
status = SyncStatus.NOT_NEEDED
|
status = SyncStatus.NOT_NEEDED
|
||||||
this.timestamp = timestamp
|
this.timestamp = timestamp
|
||||||
@@ -777,6 +808,7 @@ object ProtocolManager {
|
|||||||
*/
|
*/
|
||||||
private fun handleSyncPacket(packet: PacketSync) {
|
private fun handleSyncPacket(packet: PacketSync) {
|
||||||
syncRequestInFlight = false
|
syncRequestInFlight = false
|
||||||
|
clearSyncRequestTimeout()
|
||||||
when (packet.status) {
|
when (packet.status) {
|
||||||
SyncStatus.BATCH_START -> {
|
SyncStatus.BATCH_START -> {
|
||||||
addLog("🔄 SYNC BATCH_START — incoming message batch")
|
addLog("🔄 SYNC BATCH_START — incoming message batch")
|
||||||
@@ -826,6 +858,24 @@ object ProtocolManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun scheduleSyncRequestTimeout(cursor: Long) {
|
||||||
|
syncRequestTimeoutJob?.cancel()
|
||||||
|
syncRequestTimeoutJob = scope.launch {
|
||||||
|
delay(SYNC_REQUEST_TIMEOUT_MS)
|
||||||
|
if (!syncRequestInFlight || !isAuthenticated()) return@launch
|
||||||
|
syncRequestInFlight = false
|
||||||
|
addLog(
|
||||||
|
"⏱️ SYNC response timeout for cursor=$cursor, retrying request"
|
||||||
|
)
|
||||||
|
requestSynchronize()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearSyncRequestTimeout() {
|
||||||
|
syncRequestTimeoutJob?.cancel()
|
||||||
|
syncRequestTimeoutJob = null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Retry messages stuck in WAITING status on reconnect.
|
* Retry messages stuck in WAITING status on reconnect.
|
||||||
* Desktop has in-memory _packetQueue that flushes on handshake, but desktop apps are
|
* Desktop has in-memory _packetQueue that flushes on handshake, but desktop apps are
|
||||||
@@ -1256,6 +1306,94 @@ object ProtocolManager {
|
|||||||
send(packet)
|
send(packet)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send call signaling packet (0x1A).
|
||||||
|
*/
|
||||||
|
fun sendCallSignal(
|
||||||
|
signalType: SignalType,
|
||||||
|
src: String = "",
|
||||||
|
dst: String = "",
|
||||||
|
sharedPublic: String = "",
|
||||||
|
roomId: String = ""
|
||||||
|
) {
|
||||||
|
send(
|
||||||
|
PacketSignalPeer().apply {
|
||||||
|
this.signalType = signalType
|
||||||
|
this.src = src
|
||||||
|
this.dst = dst
|
||||||
|
this.sharedPublic = sharedPublic
|
||||||
|
this.roomId = roomId
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send WebRTC signaling packet (0x1B).
|
||||||
|
*/
|
||||||
|
fun sendWebRtcSignal(signalType: WebRTCSignalType, sdpOrCandidate: String) {
|
||||||
|
send(
|
||||||
|
PacketWebRTC().apply {
|
||||||
|
this.signalType = signalType
|
||||||
|
this.sdpOrCandidate = sdpOrCandidate
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request ICE servers from server (0x1C).
|
||||||
|
*/
|
||||||
|
fun requestIceServers() {
|
||||||
|
send(PacketIceServers())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed subscribe for call signaling packets (0x1A).
|
||||||
|
* Returns wrapper callback for subsequent unwait.
|
||||||
|
*/
|
||||||
|
fun waitCallSignal(callback: (PacketSignalPeer) -> Unit): (Packet) -> Unit {
|
||||||
|
val wrapper: (Packet) -> Unit = { packet ->
|
||||||
|
(packet as? PacketSignalPeer)?.let(callback)
|
||||||
|
}
|
||||||
|
waitPacket(PACKET_SIGNAL_PEER, wrapper)
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unwaitCallSignal(callback: (Packet) -> Unit) {
|
||||||
|
unwaitPacket(PACKET_SIGNAL_PEER, callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed subscribe for WebRTC packets (0x1B).
|
||||||
|
* Returns wrapper callback for subsequent unwait.
|
||||||
|
*/
|
||||||
|
fun waitWebRtcSignal(callback: (PacketWebRTC) -> Unit): (Packet) -> Unit {
|
||||||
|
val wrapper: (Packet) -> Unit = { packet ->
|
||||||
|
(packet as? PacketWebRTC)?.let(callback)
|
||||||
|
}
|
||||||
|
waitPacket(PACKET_WEB_RTC, wrapper)
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unwaitWebRtcSignal(callback: (Packet) -> Unit) {
|
||||||
|
unwaitPacket(PACKET_WEB_RTC, callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Typed subscribe for ICE servers packet (0x1C).
|
||||||
|
* Returns wrapper callback for subsequent unwait.
|
||||||
|
*/
|
||||||
|
fun waitIceServers(callback: (PacketIceServers) -> Unit): (Packet) -> Unit {
|
||||||
|
val wrapper: (Packet) -> Unit = { packet ->
|
||||||
|
(packet as? PacketIceServers)?.let(callback)
|
||||||
|
}
|
||||||
|
waitPacket(PACKET_ICE_SERVERS, wrapper)
|
||||||
|
return wrapper
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unwaitIceServers(callback: (Packet) -> Unit) {
|
||||||
|
unwaitPacket(PACKET_ICE_SERVERS, callback)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Register packet handler
|
* Register packet handler
|
||||||
*/
|
*/
|
||||||
@@ -1325,6 +1463,7 @@ object ProtocolManager {
|
|||||||
_devices.value = emptyList()
|
_devices.value = emptyList()
|
||||||
_pendingDeviceVerification.value = null
|
_pendingDeviceVerification.value = null
|
||||||
syncRequestInFlight = false
|
syncRequestInFlight = false
|
||||||
|
clearSyncRequestTimeout()
|
||||||
setSyncInProgress(false)
|
setSyncInProgress(false)
|
||||||
resyncRequiredAfterAccountInit = false
|
resyncRequiredAfterAccountInit = false
|
||||||
lastSubscribedToken = null // reset so token is re-sent on next connect
|
lastSubscribedToken = null // reset so token is re-sent on next connect
|
||||||
@@ -1341,6 +1480,7 @@ object ProtocolManager {
|
|||||||
_devices.value = emptyList()
|
_devices.value = emptyList()
|
||||||
_pendingDeviceVerification.value = null
|
_pendingDeviceVerification.value = null
|
||||||
syncRequestInFlight = false
|
syncRequestInFlight = false
|
||||||
|
clearSyncRequestTimeout()
|
||||||
setSyncInProgress(false)
|
setSyncInProgress(false)
|
||||||
resyncRequiredAfterAccountInit = false
|
resyncRequiredAfterAccountInit = false
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
|
|||||||
@@ -1,163 +1,332 @@
|
|||||||
package com.rosetta.messenger.network
|
package com.rosetta.messenger.network
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Binary stream for protocol packets
|
* Binary stream for protocol packets.
|
||||||
* Matches the React Native implementation exactly
|
* Ported from desktop/dev stream.ts implementation.
|
||||||
*/
|
*/
|
||||||
class Stream(stream: ByteArray = ByteArray(0)) {
|
class Stream(stream: ByteArray = ByteArray(0)) {
|
||||||
private var _stream = mutableListOf<Int>()
|
private var stream: ByteArray
|
||||||
private var _readPointer = 0
|
private var readPointer = 0 // bits
|
||||||
private var _writePointer = 0
|
private var writePointer = 0 // bits
|
||||||
|
|
||||||
init {
|
init {
|
||||||
_stream = stream.map { it.toInt() and 0xFF }.toMutableList()
|
if (stream.isEmpty()) {
|
||||||
|
this.stream = ByteArray(0)
|
||||||
|
} else {
|
||||||
|
this.stream = stream.copyOf()
|
||||||
|
this.writePointer = this.stream.size shl 3
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getStream(): ByteArray {
|
fun getStream(): ByteArray {
|
||||||
return _stream.map { it.toByte() }.toByteArray()
|
return stream.copyOf(length())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getReadPointerBits(): Int = _readPointer
|
fun setStream(stream: ByteArray = ByteArray(0)) {
|
||||||
|
if (stream.isEmpty()) {
|
||||||
fun getTotalBits(): Int = _stream.size * 8
|
this.stream = ByteArray(0)
|
||||||
|
this.readPointer = 0
|
||||||
fun getRemainingBits(): Int = getTotalBits() - _readPointer
|
this.writePointer = 0
|
||||||
|
return
|
||||||
fun hasRemainingBits(): Boolean = _readPointer < getTotalBits()
|
}
|
||||||
|
this.stream = stream.copyOf()
|
||||||
fun setStream(stream: ByteArray) {
|
this.readPointer = 0
|
||||||
_stream = stream.map { it.toInt() and 0xFF }.toMutableList()
|
this.writePointer = this.stream.size shl 3
|
||||||
_readPointer = 0
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun writeInt8(value: Int) {
|
fun getBuffer(): ByteArray = getStream()
|
||||||
val negationBit = if (value < 0) 1 else 0
|
|
||||||
val int8Value = Math.abs(value) and 0xFF
|
|
||||||
|
|
||||||
ensureCapacity(_writePointer shr 3)
|
fun isEmpty(): Boolean = writePointer == 0
|
||||||
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (negationBit shl (7 - (_writePointer and 7)))
|
|
||||||
_writePointer++
|
|
||||||
|
|
||||||
for (i in 0 until 8) {
|
fun length(): Int = (writePointer + 7) shr 3
|
||||||
val bit = (int8Value shr (7 - i)) and 1
|
|
||||||
ensureCapacity(_writePointer shr 3)
|
|
||||||
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (bit shl (7 - (_writePointer and 7)))
|
|
||||||
_writePointer++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readInt8(): Int {
|
fun getReadPointerBits(): Int = readPointer
|
||||||
var value = 0
|
|
||||||
val negationBit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
|
|
||||||
_readPointer++
|
|
||||||
|
|
||||||
for (i in 0 until 8) {
|
fun getTotalBits(): Int = writePointer
|
||||||
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 getRemainingBits(): Int = writePointer - readPointer
|
||||||
}
|
|
||||||
|
fun hasRemainingBits(): Boolean = readPointer < writePointer
|
||||||
|
|
||||||
fun writeBit(value: Int) {
|
fun writeBit(value: Int) {
|
||||||
val bit = value and 1
|
writeBits((value and 1).toULong(), 1)
|
||||||
ensureCapacity(_writePointer shr 3)
|
|
||||||
_stream[_writePointer shr 3] = _stream[_writePointer shr 3] or (bit shl (7 - (_writePointer and 7)))
|
|
||||||
_writePointer++
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun readBit(): Int {
|
fun readBit(): Int = readBits(1).toInt()
|
||||||
val bit = (_stream[_readPointer shr 3] shr (7 - (_readPointer and 7))) and 1
|
|
||||||
_readPointer++
|
|
||||||
return bit
|
|
||||||
}
|
|
||||||
|
|
||||||
fun writeBoolean(value: Boolean) {
|
fun writeBoolean(value: Boolean) {
|
||||||
writeBit(if (value) 1 else 0)
|
writeBit(if (value) 1 else 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun readBoolean(): Boolean {
|
fun readBoolean(): Boolean = readBit() == 1
|
||||||
return readBit() == 1
|
|
||||||
|
fun writeByte(value: Int) {
|
||||||
|
writeUInt8(value and 0xFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readByte(): Int {
|
||||||
|
val value = readUInt8()
|
||||||
|
return if (value >= 0x80) value - 0x100 else value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeUInt8(value: Int) {
|
||||||
|
val v = value and 0xFF
|
||||||
|
|
||||||
|
if ((writePointer and 7) == 0) {
|
||||||
|
reserveBits(8)
|
||||||
|
stream[writePointer shr 3] = v.toByte()
|
||||||
|
writePointer += 8
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeBits(v.toULong(), 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readUInt8(): Int {
|
||||||
|
if (remainingBits() < 8L) {
|
||||||
|
throw IllegalStateException("Not enough bits to read UInt8")
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((readPointer and 7) == 0) {
|
||||||
|
val value = stream[readPointer shr 3].toInt() and 0xFF
|
||||||
|
readPointer += 8
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return readBits(8).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeInt8(value: Int) {
|
||||||
|
writeUInt8(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readInt8(): Int {
|
||||||
|
val value = readUInt8()
|
||||||
|
return if (value >= 0x80) value - 0x100 else value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeUInt16(value: Int) {
|
||||||
|
val v = value and 0xFFFF
|
||||||
|
writeUInt8((v ushr 8) and 0xFF)
|
||||||
|
writeUInt8(v and 0xFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readUInt16(): Int {
|
||||||
|
val hi = readUInt8()
|
||||||
|
val lo = readUInt8()
|
||||||
|
return (hi shl 8) or lo
|
||||||
}
|
}
|
||||||
|
|
||||||
fun writeInt16(value: Int) {
|
fun writeInt16(value: Int) {
|
||||||
writeInt8(value shr 8)
|
writeUInt16(value)
|
||||||
writeInt8(value and 0xFF)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun readInt16(): Int {
|
fun readInt16(): Int {
|
||||||
val high = readInt8() shl 8
|
val value = readUInt16()
|
||||||
return high or readInt8()
|
return if (value >= 0x8000) value - 0x10000 else value
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeUInt32(value: Long) {
|
||||||
|
if (value < 0L || value > 0xFFFF_FFFFL) {
|
||||||
|
throw IllegalArgumentException("UInt32 out of range: $value")
|
||||||
|
}
|
||||||
|
|
||||||
|
writeUInt8(((value ushr 24) and 0xFF).toInt())
|
||||||
|
writeUInt8(((value ushr 16) and 0xFF).toInt())
|
||||||
|
writeUInt8(((value ushr 8) and 0xFF).toInt())
|
||||||
|
writeUInt8((value and 0xFF).toInt())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readUInt32(): Long {
|
||||||
|
val b1 = readUInt8().toLong()
|
||||||
|
val b2 = readUInt8().toLong()
|
||||||
|
val b3 = readUInt8().toLong()
|
||||||
|
val b4 = readUInt8().toLong()
|
||||||
|
return ((b1 shl 24) or (b2 shl 16) or (b3 shl 8) or b4) and 0xFFFF_FFFFL
|
||||||
}
|
}
|
||||||
|
|
||||||
fun writeInt32(value: Int) {
|
fun writeInt32(value: Int) {
|
||||||
writeInt16(value shr 16)
|
writeUInt32(value.toLong() and 0xFFFF_FFFFL)
|
||||||
writeInt16(value and 0xFFFF)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun readInt32(): Int {
|
fun readInt32(): Int = readUInt32().toInt()
|
||||||
val high = readInt16() shl 16
|
|
||||||
return high or readInt16()
|
fun writeUInt64(value: ULong) {
|
||||||
|
writeUInt8(((value shr 56) and 0xFFu).toInt())
|
||||||
|
writeUInt8(((value shr 48) and 0xFFu).toInt())
|
||||||
|
writeUInt8(((value shr 40) and 0xFFu).toInt())
|
||||||
|
writeUInt8(((value shr 32) and 0xFFu).toInt())
|
||||||
|
writeUInt8(((value shr 24) and 0xFFu).toInt())
|
||||||
|
writeUInt8(((value shr 16) and 0xFFu).toInt())
|
||||||
|
writeUInt8(((value shr 8) and 0xFFu).toInt())
|
||||||
|
writeUInt8((value and 0xFFu).toInt())
|
||||||
}
|
}
|
||||||
|
|
||||||
fun writeInt64(value: Long) {
|
fun readUInt64(): ULong {
|
||||||
val high = (value shr 32).toInt()
|
val high = readUInt32().toULong()
|
||||||
val low = (value and 0xFFFFFFFF).toInt()
|
val low = readUInt32().toULong()
|
||||||
writeInt32(high)
|
|
||||||
writeInt32(low)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun readInt64(): Long {
|
|
||||||
val high = readInt32().toLong()
|
|
||||||
val low = (readInt32().toLong() and 0xFFFFFFFFL)
|
|
||||||
return (high shl 32) or low
|
return (high shl 32) or low
|
||||||
}
|
}
|
||||||
|
|
||||||
fun writeString(value: String) {
|
fun writeInt64(value: Long) {
|
||||||
writeInt32(value.length)
|
writeUInt64(value.toULong())
|
||||||
for (char in value) {
|
}
|
||||||
writeInt16(char.code)
|
|
||||||
|
fun readInt64(): Long = readUInt64().toLong()
|
||||||
|
|
||||||
|
fun writeFloat32(value: Float) {
|
||||||
|
val bits = value.toRawBits().toLong() and 0xFFFF_FFFFL
|
||||||
|
writeUInt32(bits)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun readFloat32(): Float {
|
||||||
|
val bits = readUInt32().toInt()
|
||||||
|
return Float.fromBits(bits)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun writeString(value: String?) {
|
||||||
|
val str = value ?: ""
|
||||||
|
writeUInt32(str.length.toLong())
|
||||||
|
|
||||||
|
if (str.isEmpty()) return
|
||||||
|
|
||||||
|
reserveBits(str.length.toLong() * 16L)
|
||||||
|
for (i in str.indices) {
|
||||||
|
writeUInt16(str[i].code and 0xFFFF)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun readString(): String {
|
fun readString(): String {
|
||||||
val length = readInt32()
|
val len = readUInt32()
|
||||||
// Desktop parity + safety: don't trust malformed string length.
|
if (len > Int.MAX_VALUE.toLong()) {
|
||||||
val bytesAvailable = _stream.size - (_readPointer shr 3)
|
throw IllegalStateException("String length too large: $len")
|
||||||
if (length < 0 || (length.toLong() * 2L) > bytesAvailable.toLong()) {
|
|
||||||
android.util.Log.w(
|
|
||||||
"RosettaStream",
|
|
||||||
"readString invalid length=$length, bytesAvailable=$bytesAvailable, readPointer=$_readPointer"
|
|
||||||
)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
val sb = StringBuilder()
|
|
||||||
for (i in 0 until length) {
|
|
||||||
sb.append(readInt16().toChar())
|
|
||||||
}
|
|
||||||
return sb.toString()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun writeBytes(value: ByteArray) {
|
val requiredBits = len * 16L
|
||||||
writeInt32(value.size)
|
if (requiredBits > remainingBits()) {
|
||||||
for (byte in value) {
|
throw IllegalStateException("Not enough bits to read string")
|
||||||
writeInt8(byte.toInt())
|
}
|
||||||
|
|
||||||
|
val chars = CharArray(len.toInt())
|
||||||
|
for (i in chars.indices) {
|
||||||
|
chars[i] = readUInt16().toChar()
|
||||||
|
}
|
||||||
|
return String(chars)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
fun readBytes(): ByteArray {
|
||||||
val length = readInt32()
|
val len = readUInt32()
|
||||||
val bytes = ByteArray(length)
|
if (len == 0L) return ByteArray(0)
|
||||||
for (i in 0 until length) {
|
if (len > Int.MAX_VALUE.toLong()) return ByteArray(0)
|
||||||
bytes[i] = readInt8().toByte()
|
|
||||||
|
val requiredBits = len * 8L
|
||||||
|
if (requiredBits > remainingBits()) {
|
||||||
|
return ByteArray(0)
|
||||||
}
|
}
|
||||||
return bytes
|
|
||||||
|
val out = ByteArray(len.toInt())
|
||||||
|
|
||||||
|
if ((readPointer and 7) == 0) {
|
||||||
|
val byteIndex = readPointer shr 3
|
||||||
|
System.arraycopy(stream, byteIndex, out, 0, out.size)
|
||||||
|
readPointer += out.size shl 3
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
for (i in out.indices) {
|
||||||
|
out[i] = readUInt8().toByte()
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun 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) {
|
private fun ensureCapacity(index: Int) {
|
||||||
while (_stream.size <= index) {
|
val requiredSize = index + 1
|
||||||
_stream.add(0)
|
if (requiredSize <= stream.size) return
|
||||||
|
|
||||||
|
var newSize = if (stream.isEmpty()) 32 else stream.size
|
||||||
|
while (newSize < requiredSize) {
|
||||||
|
if (newSize > (Int.MAX_VALUE shr 1)) {
|
||||||
|
newSize = requiredSize
|
||||||
|
break
|
||||||
}
|
}
|
||||||
|
newSize = newSize shl 1
|
||||||
|
}
|
||||||
|
|
||||||
|
val next = ByteArray(newSize)
|
||||||
|
System.arraycopy(stream, 0, next, 0, stream.size)
|
||||||
|
stream = next
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
107
app/src/main/java/com/rosetta/messenger/network/XChaCha20E2EE.kt
Normal file
107
app/src/main/java/com/rosetta/messenger/network/XChaCha20E2EE.kt
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
package com.rosetta.messenger.network
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import org.webrtc.FrameDecryptor
|
||||||
|
import org.webrtc.FrameEncryptor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* XChaCha20-based E2EE compatible with Rosetta Desktop.
|
||||||
|
*
|
||||||
|
* Desktop encrypts audio frames using XChaCha20 (libsodium) with a nonce
|
||||||
|
* derived from the RTP timestamp. The shared key is computed as
|
||||||
|
* nacl.box.before(peerPub, ownSecret) = HSalsa20(zeros, X25519(sk, pk)).
|
||||||
|
*
|
||||||
|
* This class provides:
|
||||||
|
* - [hsalsa20] — applies HSalsa20 to a raw X25519 shared secret,
|
||||||
|
* producing the same key as nacl.box.before().
|
||||||
|
* - [Encryptor] / [Decryptor] — WebRTC FrameEncryptor / FrameDecryptor
|
||||||
|
* that use XChaCha20 matching the Desktop implementation.
|
||||||
|
*/
|
||||||
|
object XChaCha20E2EE {
|
||||||
|
|
||||||
|
private const val TAG = "XChaCha20E2EE"
|
||||||
|
|
||||||
|
var nativeLoaded: Boolean = false
|
||||||
|
private set
|
||||||
|
|
||||||
|
private var crashFilePath: String? = null
|
||||||
|
|
||||||
|
fun initWithContext(context: android.content.Context) {
|
||||||
|
if (!nativeLoaded) return
|
||||||
|
try {
|
||||||
|
val dir = java.io.File(context.filesDir, "crash_reports")
|
||||||
|
if (!dir.exists()) dir.mkdirs()
|
||||||
|
val path = java.io.File(dir, "native_crash.txt").absolutePath
|
||||||
|
crashFilePath = path
|
||||||
|
nativeInstallCrashHandler(path)
|
||||||
|
Log.i(TAG, "Native crash handler installed → $path")
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.e(TAG, "Failed to install native crash handler", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
try {
|
||||||
|
System.loadLibrary("rosetta_e2ee")
|
||||||
|
nativeLoaded = true
|
||||||
|
Log.i(TAG, "Native library loaded successfully")
|
||||||
|
} catch (e: UnsatisfiedLinkError) {
|
||||||
|
Log.e(TAG, "Failed to load native library rosetta_e2ee", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HSalsa20(zeros_16, rawDhShared, sigma) — converts a raw X25519
|
||||||
|
* shared secret into the NaCl box-before shared key.
|
||||||
|
*/
|
||||||
|
fun hsalsa20(rawDhShared: ByteArray): ByteArray {
|
||||||
|
require(nativeLoaded) { "Native library not loaded" }
|
||||||
|
require(rawDhShared.size >= 32) { "Raw DH shared secret must be >= 32 bytes" }
|
||||||
|
return nativeHSalsa20(rawDhShared)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WebRTC [FrameEncryptor] backed by native XChaCha20. */
|
||||||
|
class Encryptor(key: ByteArray) : FrameEncryptor {
|
||||||
|
private val nativePtr: Long
|
||||||
|
|
||||||
|
init {
|
||||||
|
require(nativeLoaded) { "Native library not loaded" }
|
||||||
|
nativePtr = nativeCreateEncryptor(key)
|
||||||
|
Log.i(TAG, "Encryptor created, ptr=0x${nativePtr.toString(16)}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getNativeFrameEncryptor(): Long = nativePtr
|
||||||
|
|
||||||
|
fun dispose() {
|
||||||
|
if (nativePtr != 0L) nativeReleaseEncryptor(nativePtr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WebRTC [FrameDecryptor] backed by native XChaCha20. */
|
||||||
|
class Decryptor(key: ByteArray) : FrameDecryptor {
|
||||||
|
private val nativePtr: Long
|
||||||
|
|
||||||
|
init {
|
||||||
|
require(nativeLoaded) { "Native library not loaded" }
|
||||||
|
nativePtr = nativeCreateDecryptor(key)
|
||||||
|
Log.i(TAG, "Decryptor created, ptr=0x${nativePtr.toString(16)}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getNativeFrameDecryptor(): Long = nativePtr
|
||||||
|
|
||||||
|
fun dispose() {
|
||||||
|
if (nativePtr != 0L) nativeReleaseDecryptor(nativePtr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── JNI ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@JvmStatic private external fun nativeHSalsa20(rawDh: ByteArray): ByteArray
|
||||||
|
@JvmStatic private external fun nativeCreateEncryptor(key: ByteArray): Long
|
||||||
|
@JvmStatic private external fun nativeReleaseEncryptor(ptr: Long)
|
||||||
|
@JvmStatic private external fun nativeCreateDecryptor(key: ByteArray): Long
|
||||||
|
@JvmStatic private external fun nativeReleaseDecryptor(ptr: Long)
|
||||||
|
@JvmStatic private external fun nativeInstallCrashHandler(path: String)
|
||||||
|
@JvmStatic external fun nativeOpenDiagFile(path: String)
|
||||||
|
@JvmStatic external fun nativeCloseDiagFile()
|
||||||
|
}
|
||||||
@@ -1,274 +0,0 @@
|
|||||||
package com.rosetta.messenger.providers
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import com.rosetta.messenger.crypto.CryptoManager
|
|
||||||
import com.rosetta.messenger.database.DatabaseService
|
|
||||||
import com.rosetta.messenger.database.DecryptedAccountData
|
|
||||||
import com.rosetta.messenger.network.ProtocolManager
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.flow.*
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Auth state management - matches React Native architecture
|
|
||||||
*/
|
|
||||||
sealed class AuthStatus {
|
|
||||||
object Loading : AuthStatus()
|
|
||||||
object Unauthenticated : AuthStatus()
|
|
||||||
data class Authenticated(val account: DecryptedAccountData) : AuthStatus()
|
|
||||||
data class Locked(val publicKey: String) : AuthStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
data class AuthStateData(
|
|
||||||
val status: AuthStatus = AuthStatus.Loading,
|
|
||||||
val hasExistingAccounts: Boolean = false,
|
|
||||||
val availableAccounts: List<String> = emptyList()
|
|
||||||
)
|
|
||||||
|
|
||||||
class AuthStateManager(
|
|
||||||
private val context: Context,
|
|
||||||
private val scope: CoroutineScope
|
|
||||||
) {
|
|
||||||
private val databaseService = DatabaseService.getInstance(context)
|
|
||||||
|
|
||||||
private val _state = MutableStateFlow(AuthStateData())
|
|
||||||
val state: StateFlow<AuthStateData> = _state.asStateFlow()
|
|
||||||
|
|
||||||
private var currentDecryptedAccount: DecryptedAccountData? = null
|
|
||||||
|
|
||||||
// 🚀 ОПТИМИЗАЦИЯ: Кэш списка аккаунтов для UI
|
|
||||||
private var accountsCache: List<String>? = null
|
|
||||||
private var lastAccountsLoadTime = 0L
|
|
||||||
private val accountsCacheTTL = 5000L // 5 секунд
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
private const val TAG = "AuthStateManager"
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
scope.launch {
|
|
||||||
loadAccounts()
|
|
||||||
checkAuthStatus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun loadAccounts() = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
// 🚀 ОПТИМИЗАЦИЯ: Используем кэш если он свежий
|
|
||||||
val currentTime = System.currentTimeMillis()
|
|
||||||
if (accountsCache != null && (currentTime - lastAccountsLoadTime) < accountsCacheTTL) {
|
|
||||||
_state.update { it.copy(
|
|
||||||
hasExistingAccounts = accountsCache!!.isNotEmpty(),
|
|
||||||
availableAccounts = accountsCache!!
|
|
||||||
)}
|
|
||||||
return@withContext
|
|
||||||
}
|
|
||||||
|
|
||||||
val accounts = databaseService.getAllEncryptedAccounts()
|
|
||||||
val hasAccounts = accounts.isNotEmpty()
|
|
||||||
val accountKeys = accounts.map { it.publicKey }
|
|
||||||
|
|
||||||
// Обновляем кэш
|
|
||||||
accountsCache = accountKeys
|
|
||||||
lastAccountsLoadTime = currentTime
|
|
||||||
|
|
||||||
_state.update { it.copy(
|
|
||||||
hasExistingAccounts = hasAccounts,
|
|
||||||
availableAccounts = accountKeys
|
|
||||||
)}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun checkAuthStatus() {
|
|
||||||
try {
|
|
||||||
val hasAccounts = databaseService.hasAccounts()
|
|
||||||
if (!hasAccounts) {
|
|
||||||
_state.update { it.copy(
|
|
||||||
status = AuthStatus.Unauthenticated
|
|
||||||
)}
|
|
||||||
} else {
|
|
||||||
_state.update { it.copy(
|
|
||||||
status = AuthStatus.Unauthenticated
|
|
||||||
)}
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_state.update { it.copy(
|
|
||||||
status = AuthStatus.Unauthenticated
|
|
||||||
)}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create new account from seed phrase
|
|
||||||
* Matches createAccountFromSeedPhrase from React Native
|
|
||||||
* 🚀 ОПТИМИЗАЦИЯ: Dispatchers.Default для CPU-интенсивной криптографии
|
|
||||||
*/
|
|
||||||
suspend fun createAccount(
|
|
||||||
seedPhrase: List<String>,
|
|
||||||
password: String
|
|
||||||
): Result<DecryptedAccountData> = withContext(Dispatchers.Default) {
|
|
||||||
try {
|
|
||||||
// Step 1: Generate key pair from seed phrase (using BIP39)
|
|
||||||
val keyPair = CryptoManager.generateKeyPairFromSeed(seedPhrase)
|
|
||||||
|
|
||||||
// Step 2: Generate private key hash for protocol
|
|
||||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(keyPair.privateKey)
|
|
||||||
|
|
||||||
// Step 3: Encrypt private key with password
|
|
||||||
val encryptedPrivateKey = CryptoManager.encryptWithPassword(
|
|
||||||
keyPair.privateKey, password
|
|
||||||
)
|
|
||||||
|
|
||||||
// Step 4: Encrypt seed phrase with password
|
|
||||||
val encryptedSeedPhrase = CryptoManager.encryptWithPassword(
|
|
||||||
seedPhrase.joinToString(" "), password
|
|
||||||
)
|
|
||||||
|
|
||||||
// Step 5: Save to database
|
|
||||||
val saved = withContext(Dispatchers.IO) {
|
|
||||||
databaseService.saveEncryptedAccount(
|
|
||||||
publicKey = keyPair.publicKey,
|
|
||||||
privateKeyEncrypted = encryptedPrivateKey,
|
|
||||||
seedPhraseEncrypted = encryptedSeedPhrase
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!saved) {
|
|
||||||
return@withContext Result.failure(Exception("Failed to save account to database"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 6: Create decrypted account object
|
|
||||||
val decryptedAccount = DecryptedAccountData(
|
|
||||||
publicKey = keyPair.publicKey,
|
|
||||||
privateKey = keyPair.privateKey,
|
|
||||||
privateKeyHash = privateKeyHash,
|
|
||||||
seedPhrase = seedPhrase
|
|
||||||
)
|
|
||||||
|
|
||||||
// Step 7: Update state and reload accounts
|
|
||||||
currentDecryptedAccount = decryptedAccount
|
|
||||||
_state.update { it.copy(
|
|
||||||
status = AuthStatus.Authenticated(decryptedAccount)
|
|
||||||
)}
|
|
||||||
|
|
||||||
loadAccounts()
|
|
||||||
|
|
||||||
// Initialize MessageRepository BEFORE connecting/authenticating
|
|
||||||
// so incoming messages from server are stored under the correct account
|
|
||||||
ProtocolManager.initializeAccount(keyPair.publicKey, keyPair.privateKey)
|
|
||||||
|
|
||||||
// Step 8: Connect and authenticate with protocol
|
|
||||||
ProtocolManager.connect()
|
|
||||||
ProtocolManager.authenticate(keyPair.publicKey, privateKeyHash)
|
|
||||||
ProtocolManager.reconnectNowIfNeeded("auth_state_create")
|
|
||||||
|
|
||||||
Result.success(decryptedAccount)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.failure(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unlock account with password
|
|
||||||
* Matches loginWithPassword from React Native
|
|
||||||
*/
|
|
||||||
suspend fun unlock(
|
|
||||||
publicKey: String,
|
|
||||||
password: String
|
|
||||||
): Result<DecryptedAccountData> = withContext(Dispatchers.Default) {
|
|
||||||
try {
|
|
||||||
// Decrypt account from database
|
|
||||||
val decryptedAccount = withContext(Dispatchers.IO) {
|
|
||||||
databaseService.decryptAccount(publicKey, password)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (decryptedAccount == null) {
|
|
||||||
return@withContext Result.failure(Exception("Invalid password or account not found"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update last used timestamp
|
|
||||||
withContext(Dispatchers.IO) {
|
|
||||||
databaseService.updateLastUsed(publicKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update state
|
|
||||||
currentDecryptedAccount = decryptedAccount
|
|
||||||
_state.update { it.copy(
|
|
||||||
status = AuthStatus.Authenticated(decryptedAccount)
|
|
||||||
)}
|
|
||||||
|
|
||||||
// Initialize MessageRepository BEFORE connecting/authenticating
|
|
||||||
// so incoming messages from server are stored under the correct account
|
|
||||||
ProtocolManager.initializeAccount(decryptedAccount.publicKey, decryptedAccount.privateKey)
|
|
||||||
|
|
||||||
// Connect and authenticate with protocol
|
|
||||||
ProtocolManager.connect()
|
|
||||||
ProtocolManager.authenticate(decryptedAccount.publicKey, decryptedAccount.privateKeyHash)
|
|
||||||
ProtocolManager.reconnectNowIfNeeded("auth_state_unlock")
|
|
||||||
|
|
||||||
Result.success(decryptedAccount)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.failure(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Logout - clears decrypted account from memory
|
|
||||||
*/
|
|
||||||
fun logout() {
|
|
||||||
currentDecryptedAccount = null
|
|
||||||
_state.update { it.copy(
|
|
||||||
status = AuthStatus.Unauthenticated
|
|
||||||
)}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete account from database
|
|
||||||
*/
|
|
||||||
suspend fun deleteAccount(publicKey: String): Result<Unit> = withContext(Dispatchers.IO) {
|
|
||||||
try {
|
|
||||||
val success = databaseService.deleteAccount(publicKey)
|
|
||||||
if (!success) {
|
|
||||||
return@withContext Result.failure(Exception("Failed to delete account"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// If deleting current account, logout
|
|
||||||
if (currentDecryptedAccount?.publicKey == publicKey) {
|
|
||||||
withContext(Dispatchers.Main) {
|
|
||||||
logout()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadAccounts()
|
|
||||||
Result.success(Unit)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Result.failure(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current decrypted account (if authenticated)
|
|
||||||
*/
|
|
||||||
fun getCurrentAccount(): DecryptedAccountData? = currentDecryptedAccount
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun rememberAuthState(context: Context): AuthStateManager {
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
return remember(context) {
|
|
||||||
AuthStateManager(context, scope)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ProvideAuthState(
|
|
||||||
authState: AuthStateManager,
|
|
||||||
content: @Composable (AuthStateData) -> Unit
|
|
||||||
) {
|
|
||||||
val state by authState.state.collectAsState()
|
|
||||||
content(state)
|
|
||||||
}
|
|
||||||
@@ -284,6 +284,7 @@ fun ChatDetailScreen(
|
|||||||
user: SearchUser,
|
user: SearchUser,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onNavigateToChat: (SearchUser) -> Unit,
|
onNavigateToChat: (SearchUser) -> Unit,
|
||||||
|
onCallClick: (SearchUser) -> Unit = {},
|
||||||
onUserProfileClick: (SearchUser) -> Unit = {},
|
onUserProfileClick: (SearchUser) -> Unit = {},
|
||||||
onGroupInfoClick: (SearchUser) -> Unit = {},
|
onGroupInfoClick: (SearchUser) -> Unit = {},
|
||||||
currentUserPublicKey: String,
|
currentUserPublicKey: String,
|
||||||
@@ -1873,8 +1874,7 @@ fun ChatDetailScreen(
|
|||||||
!isSystemAccount
|
!isSystemAccount
|
||||||
) {
|
) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { /* TODO: Voice call */
|
onClick = { onCallClick(user) }
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
Icons.Default
|
Icons.Default
|
||||||
|
|||||||
@@ -2063,6 +2063,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
|||||||
message.attachments.any { it.type == AttachmentType.IMAGE } -> "Photo"
|
message.attachments.any { it.type == AttachmentType.IMAGE } -> "Photo"
|
||||||
message.attachments.any { it.type == AttachmentType.FILE } -> "File"
|
message.attachments.any { it.type == AttachmentType.FILE } -> "File"
|
||||||
message.attachments.any { it.type == AttachmentType.AVATAR } -> "Avatar"
|
message.attachments.any { it.type == AttachmentType.AVATAR } -> "Avatar"
|
||||||
|
message.attachments.any { it.type == AttachmentType.CALL } -> "Call"
|
||||||
message.forwardedMessages.isNotEmpty() -> "Forwarded message"
|
message.forwardedMessages.isNotEmpty() -> "Forwarded message"
|
||||||
message.replyData != null -> "Reply"
|
message.replyData != null -> "Reply"
|
||||||
else -> "Pinned message"
|
else -> "Pinned message"
|
||||||
|
|||||||
@@ -4367,6 +4367,8 @@ fun DialogItemContent(
|
|||||||
"File" -> "File"
|
"File" -> "File"
|
||||||
dialog.lastMessageAttachmentType ==
|
dialog.lastMessageAttachmentType ==
|
||||||
"Avatar" -> "Avatar"
|
"Avatar" -> "Avatar"
|
||||||
|
dialog.lastMessageAttachmentType ==
|
||||||
|
"Call" -> "Call"
|
||||||
dialog.lastMessageAttachmentType ==
|
dialog.lastMessageAttachmentType ==
|
||||||
"Forwarded" -> "Forwarded message"
|
"Forwarded" -> "Forwarded message"
|
||||||
dialog.lastMessage.isEmpty() ->
|
dialog.lastMessage.isEmpty() ->
|
||||||
|
|||||||
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
|
2 -> "File" // AttachmentType.FILE = 2
|
||||||
3 -> "Avatar" // AttachmentType.AVATAR = 3
|
3 -> "Avatar" // AttachmentType.AVATAR = 3
|
||||||
|
4 -> "Call" // AttachmentType.CALL = 4
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|||||||
@@ -40,9 +40,16 @@ fun ConnectionLogsScreen(
|
|||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
ProtocolManager.enableUILogs(true)
|
||||||
|
onDispose {
|
||||||
|
ProtocolManager.enableUILogs(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
LaunchedEffect(logs.size) {
|
LaunchedEffect(logs.size) {
|
||||||
if (logs.isNotEmpty()) {
|
if (logs.isNotEmpty()) {
|
||||||
listState.animateScrollToItem(logs.size - 1)
|
listState.scrollToItem(logs.size - 1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,7 +96,7 @@ fun ConnectionLogsScreen(
|
|||||||
|
|
||||||
IconButton(onClick = {
|
IconButton(onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (logs.isNotEmpty()) listState.animateScrollToItem(logs.size - 1)
|
if (logs.isNotEmpty()) listState.scrollToItem(logs.size - 1)
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|||||||
@@ -404,6 +404,7 @@ private fun ForwardDialogItem(
|
|||||||
dialog.lastMessageAttachmentType == "Photo" -> "Photo"
|
dialog.lastMessageAttachmentType == "Photo" -> "Photo"
|
||||||
dialog.lastMessageAttachmentType == "File" -> "File"
|
dialog.lastMessageAttachmentType == "File" -> "File"
|
||||||
dialog.lastMessageAttachmentType == "Avatar" -> "Avatar"
|
dialog.lastMessageAttachmentType == "Avatar" -> "Avatar"
|
||||||
|
dialog.lastMessageAttachmentType == "Call" -> "Call"
|
||||||
dialog.lastMessageAttachmentType == "Forwarded" -> "Forwarded message"
|
dialog.lastMessageAttachmentType == "Forwarded" -> "Forwarded message"
|
||||||
dialog.lastMessage.isNotEmpty() -> dialog.lastMessage
|
dialog.lastMessage.isNotEmpty() -> dialog.lastMessage
|
||||||
else -> "No messages"
|
else -> "No messages"
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ fun SearchScreen(
|
|||||||
protocolState: ProtocolState,
|
protocolState: ProtocolState,
|
||||||
onBackClick: () -> Unit,
|
onBackClick: () -> Unit,
|
||||||
onUserSelect: (SearchUser) -> Unit,
|
onUserSelect: (SearchUser) -> Unit,
|
||||||
onNavigateToCrashLogs: () -> Unit = {}
|
onNavigateToCrashLogs: () -> Unit = {},
|
||||||
|
onNavigateToConnectionLogs: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
// Context и View для мгновенного закрытия клавиатуры
|
// Context и View для мгновенного закрытия клавиатуры
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -150,6 +151,11 @@ fun SearchScreen(
|
|||||||
if (searchQuery.trim().equals("rosettadev1", ignoreCase = true)) {
|
if (searchQuery.trim().equals("rosettadev1", ignoreCase = true)) {
|
||||||
searchViewModel.clearSearchQuery()
|
searchViewModel.clearSearchQuery()
|
||||||
onNavigateToCrashLogs()
|
onNavigateToCrashLogs()
|
||||||
|
return@LaunchedEffect
|
||||||
|
}
|
||||||
|
if (searchQuery.trim().equals("rosettadev2", ignoreCase = true)) {
|
||||||
|
searchViewModel.clearSearchQuery()
|
||||||
|
onNavigateToConnectionLogs()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.Canvas
|
||||||
import androidx.compose.foundation.Image
|
import androidx.compose.foundation.Image
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
@@ -525,6 +526,15 @@ fun MessageAttachments(
|
|||||||
messageStatus = messageStatus
|
messageStatus = messageStatus
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
AttachmentType.CALL -> {
|
||||||
|
CallAttachment(
|
||||||
|
attachment = attachment,
|
||||||
|
isOutgoing = isOutgoing,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
timestamp = timestamp,
|
||||||
|
messageStatus = messageStatus
|
||||||
|
)
|
||||||
|
}
|
||||||
else -> {
|
else -> {
|
||||||
/* MESSAGES обрабатываются отдельно */
|
/* MESSAGES обрабатываются отдельно */
|
||||||
}
|
}
|
||||||
@@ -1546,6 +1556,197 @@ fun ImageAttachment(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private data class DesktopCallUi(
|
||||||
|
val title: String,
|
||||||
|
val subtitle: String,
|
||||||
|
val isError: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun parseCallDurationSeconds(preview: String): Int {
|
||||||
|
if (preview.isBlank()) return 0
|
||||||
|
|
||||||
|
val tail = preview.substringAfterLast("::").trim()
|
||||||
|
tail.toIntOrNull()?.let { return it.coerceAtLeast(0) }
|
||||||
|
|
||||||
|
val durationRegex = Regex("duration(?:Sec|Seconds)?\\s*[:=]\\s*(\\d+)", RegexOption.IGNORE_CASE)
|
||||||
|
durationRegex.find(preview)?.groupValues?.getOrNull(1)?.toIntOrNull()?.let {
|
||||||
|
return it.coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return preview.trim().toIntOrNull()?.coerceAtLeast(0) ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDesktopCallDuration(durationSec: Int): String {
|
||||||
|
val minutes = durationSec / 60
|
||||||
|
val seconds = durationSec % 60
|
||||||
|
return "$minutes:${seconds.toString().padStart(2, '0')}"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveDesktopCallUi(preview: String, isOutgoing: Boolean): DesktopCallUi {
|
||||||
|
val durationSec = parseCallDurationSeconds(preview)
|
||||||
|
val isError = durationSec == 0
|
||||||
|
val title =
|
||||||
|
if (isError) {
|
||||||
|
if (isOutgoing) "Rejected call" else "Missed call"
|
||||||
|
} else {
|
||||||
|
if (isOutgoing) "Outgoing call" else "Incoming call"
|
||||||
|
}
|
||||||
|
val subtitle =
|
||||||
|
if (isError) {
|
||||||
|
"Call was not answered or was rejected"
|
||||||
|
} else {
|
||||||
|
formatDesktopCallDuration(durationSec)
|
||||||
|
}
|
||||||
|
return DesktopCallUi(title = title, subtitle = subtitle, isError = isError)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Call attachment bubble */
|
||||||
|
@Composable
|
||||||
|
fun CallAttachment(
|
||||||
|
attachment: MessageAttachment,
|
||||||
|
isOutgoing: Boolean,
|
||||||
|
isDarkTheme: Boolean,
|
||||||
|
timestamp: java.util.Date,
|
||||||
|
messageStatus: MessageStatus = MessageStatus.READ
|
||||||
|
) {
|
||||||
|
val callUi = remember(attachment.preview, isOutgoing) {
|
||||||
|
resolveDesktopCallUi(attachment.preview, isOutgoing)
|
||||||
|
}
|
||||||
|
val containerShape = RoundedCornerShape(10.dp)
|
||||||
|
val containerBackground =
|
||||||
|
if (isOutgoing) {
|
||||||
|
Color.White.copy(alpha = 0.12f)
|
||||||
|
} else {
|
||||||
|
if (isDarkTheme) Color(0xFF1F2733) else Color(0xFFF3F8FF)
|
||||||
|
}
|
||||||
|
val containerBorder =
|
||||||
|
if (isOutgoing) {
|
||||||
|
Color.White.copy(alpha = 0.2f)
|
||||||
|
} else {
|
||||||
|
if (isDarkTheme) Color(0xFF33435A) else Color(0xFFD8E5F4)
|
||||||
|
}
|
||||||
|
val iconBackground = if (callUi.isError) Color(0xFFE55A5A) else PrimaryBlue
|
||||||
|
val iconVector =
|
||||||
|
when {
|
||||||
|
callUi.isError -> Icons.Default.Close
|
||||||
|
isOutgoing -> Icons.Default.CallMade
|
||||||
|
else -> Icons.Default.CallReceived
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.padding(vertical = 4.dp)
|
||||||
|
.widthIn(min = 200.dp)
|
||||||
|
.heightIn(min = 60.dp)
|
||||||
|
.clip(containerShape)
|
||||||
|
.background(containerBackground)
|
||||||
|
.border(width = 1.dp, color = containerBorder, shape = containerShape)
|
||||||
|
.padding(horizontal = 10.dp, vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(iconBackground),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = iconVector,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = callUi.title,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = if (isOutgoing) Color.White else if (isDarkTheme) Color.White else Color.Black,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = callUi.subtitle,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
color =
|
||||||
|
if (callUi.isError) {
|
||||||
|
Color(0xFFE55A5A)
|
||||||
|
} else if (isOutgoing) {
|
||||||
|
Color.White.copy(alpha = 0.72f)
|
||||||
|
} else {
|
||||||
|
if (isDarkTheme) Color(0xFF8EC9FF) else PrimaryBlue
|
||||||
|
},
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOutgoing) {
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(
|
||||||
|
text = android.text.format.DateFormat.format("HH:mm", timestamp).toString(),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = Color.White.copy(alpha = 0.7f)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
when (messageStatus) {
|
||||||
|
MessageStatus.SENDING -> {
|
||||||
|
Icon(
|
||||||
|
painter = TelegramIcons.Clock,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White.copy(alpha = 0.7f),
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
MessageStatus.SENT, MessageStatus.DELIVERED -> {
|
||||||
|
Icon(
|
||||||
|
painter = TelegramIcons.Done,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White.copy(alpha = 0.8f),
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
MessageStatus.READ -> {
|
||||||
|
Box(modifier = Modifier.height(14.dp)) {
|
||||||
|
Icon(
|
||||||
|
painter = TelegramIcons.Done,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
Icon(
|
||||||
|
painter = TelegramIcons.Done,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(14.dp).offset(x = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MessageStatus.ERROR -> {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Error,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color(0xFFE53935),
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** File attachment - Telegram style */
|
/** File attachment - Telegram style */
|
||||||
@Composable
|
@Composable
|
||||||
fun FileAttachment(
|
fun FileAttachment(
|
||||||
|
|||||||
@@ -2235,6 +2235,7 @@ fun ReplyBubble(
|
|||||||
} else if (!hasImage) {
|
} else if (!hasImage) {
|
||||||
val displayText = when {
|
val displayText = when {
|
||||||
replyData.attachments.any { it.type == AttachmentType.FILE } -> "File"
|
replyData.attachments.any { it.type == AttachmentType.FILE } -> "File"
|
||||||
|
replyData.attachments.any { it.type == AttachmentType.CALL } -> "Call"
|
||||||
else -> "..."
|
else -> "..."
|
||||||
}
|
}
|
||||||
AppleEmojiText(
|
AppleEmojiText(
|
||||||
|
|||||||
@@ -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
|
package com.rosetta.messenger.ui.chats.components
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.ContentValues
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.animation.*
|
import androidx.compose.animation.*
|
||||||
import androidx.compose.animation.core.*
|
import androidx.compose.animation.core.*
|
||||||
@@ -55,6 +60,7 @@ import compose.icons.tablericons.*
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.IOException
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
@@ -215,6 +221,8 @@ fun ImageViewerScreen(
|
|||||||
|
|
||||||
// UI visibility state
|
// UI visibility state
|
||||||
var showControls by remember { mutableStateOf(true) }
|
var showControls by remember { mutableStateOf(true) }
|
||||||
|
var showKebabMenu by remember { mutableStateOf(false) }
|
||||||
|
var isSavingToGallery by remember { mutableStateOf(false) }
|
||||||
var isTapNavigationInProgress by remember { mutableStateOf(false) }
|
var isTapNavigationInProgress by remember { mutableStateOf(false) }
|
||||||
val edgeTapFadeAlpha = remember { Animatable(1f) }
|
val edgeTapFadeAlpha = remember { Animatable(1f) }
|
||||||
val imageBitmapCache =
|
val imageBitmapCache =
|
||||||
@@ -527,6 +535,77 @@ fun ImageViewerScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kebab menu (save image)
|
||||||
|
Box(modifier = Modifier.align(Alignment.CenterEnd)) {
|
||||||
|
IconButton(
|
||||||
|
onClick = { showKebabMenu = true },
|
||||||
|
enabled = currentImage != null && !isSavingToGallery
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.MoreVert,
|
||||||
|
contentDescription = "More",
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showKebabMenu,
|
||||||
|
onDismissRequest = { showKebabMenu = false }
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = {
|
||||||
|
Text(
|
||||||
|
text = if (isSavingToGallery) "Saving..." else "Save to Gallery"
|
||||||
|
)
|
||||||
|
},
|
||||||
|
enabled = !isSavingToGallery,
|
||||||
|
onClick = {
|
||||||
|
val imageToSave = currentImage ?: return@DropdownMenuItem
|
||||||
|
showKebabMenu = false
|
||||||
|
if (isSavingToGallery) return@DropdownMenuItem
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
isSavingToGallery = true
|
||||||
|
try {
|
||||||
|
val cachedBitmap = getCachedBitmap(imageToSave.attachmentId)
|
||||||
|
val bitmapToSave =
|
||||||
|
cachedBitmap ?: withContext(Dispatchers.IO) {
|
||||||
|
loadBitmapForViewerImage(context, imageToSave, privateKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bitmapToSave != null && cachedBitmap == null) {
|
||||||
|
cacheBitmap(imageToSave.attachmentId, bitmapToSave)
|
||||||
|
}
|
||||||
|
|
||||||
|
val saved =
|
||||||
|
if (bitmapToSave != null) {
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
saveBitmapToGallery(context, bitmapToSave, imageToSave)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
if (saved) "Saved to gallery" else "Failed to save image",
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
Toast.makeText(
|
||||||
|
context,
|
||||||
|
"Failed to save image",
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show()
|
||||||
|
} finally {
|
||||||
|
isSavingToGallery = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Title and date
|
// Title and date
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -972,6 +1051,55 @@ private suspend fun loadBitmapForViewerImage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun saveBitmapToGallery(
|
||||||
|
context: Context,
|
||||||
|
bitmap: Bitmap,
|
||||||
|
image: ViewableImage
|
||||||
|
): Boolean {
|
||||||
|
val resolver = context.contentResolver
|
||||||
|
val fileName = buildGalleryFileName(image)
|
||||||
|
|
||||||
|
val values =
|
||||||
|
ContentValues().apply {
|
||||||
|
put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
|
||||||
|
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
put(
|
||||||
|
MediaStore.Images.Media.RELATIVE_PATH,
|
||||||
|
"${Environment.DIRECTORY_PICTURES}/Rosetta"
|
||||||
|
)
|
||||||
|
put(MediaStore.Images.Media.IS_PENDING, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val uri = resolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values) ?: return false
|
||||||
|
|
||||||
|
return try {
|
||||||
|
resolver.openOutputStream(uri)?.use { output ->
|
||||||
|
if (!bitmap.compress(Bitmap.CompressFormat.JPEG, 95, output)) {
|
||||||
|
throw IOException("Bitmap compression failed")
|
||||||
|
}
|
||||||
|
} ?: throw IOException("Cannot open output stream")
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
val finalizeValues =
|
||||||
|
ContentValues().apply { put(MediaStore.Images.Media.IS_PENDING, 0) }
|
||||||
|
resolver.update(uri, finalizeValues, null, null)
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} catch (_: Exception) {
|
||||||
|
runCatching { resolver.delete(uri, null, null) }
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildGalleryFileName(image: ViewableImage): String {
|
||||||
|
val formatter = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
|
||||||
|
val timePart = formatter.format(image.timestamp)
|
||||||
|
val idPart = image.attachmentId.take(8)
|
||||||
|
return "Rosetta_${timePart}_$idPart.jpg"
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Безопасное декодирование base64 в Bitmap
|
* Безопасное декодирование base64 в Bitmap
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.rosetta.messenger.ui.components
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.icu.text.BreakIterator
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.graphics.Paint
|
import android.graphics.Paint
|
||||||
@@ -34,8 +35,163 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.toArgb
|
import androidx.compose.ui.graphics.toArgb
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import java.util.regex.Pattern
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
|
private data class EmojiRenderMatch(
|
||||||
|
val start: Int,
|
||||||
|
val end: Int,
|
||||||
|
val unified: String
|
||||||
|
)
|
||||||
|
|
||||||
|
private object AppleEmojiAssetResolver {
|
||||||
|
@Volatile
|
||||||
|
private var availableEmojiAssets: Set<String>? = null
|
||||||
|
private val unifiedResolveCache = ConcurrentHashMap<String, String>()
|
||||||
|
private val unresolvedUnifiedCache = ConcurrentHashMap.newKeySet<String>()
|
||||||
|
|
||||||
|
fun normalizeUnifiedCode(code: String): String =
|
||||||
|
code.trim().lowercase(Locale.ROOT).replace('_', '-')
|
||||||
|
|
||||||
|
fun resolveUnifiedFromCode(context: Context, code: String): String? {
|
||||||
|
val normalized = normalizeUnifiedCode(code)
|
||||||
|
if (normalized.isBlank()) return null
|
||||||
|
return resolveUnified(context, normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun collectUnicodeMatches(
|
||||||
|
context: Context,
|
||||||
|
text: String,
|
||||||
|
occupiedRanges: List<IntRange> = emptyList()
|
||||||
|
): List<EmojiRenderMatch> {
|
||||||
|
if (text.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
val iterator = BreakIterator.getCharacterInstance(Locale.ROOT)
|
||||||
|
iterator.setText(text)
|
||||||
|
|
||||||
|
val matches = mutableListOf<EmojiRenderMatch>()
|
||||||
|
var start = iterator.first()
|
||||||
|
var end = iterator.next()
|
||||||
|
|
||||||
|
while (end != BreakIterator.DONE) {
|
||||||
|
if (start < end && !isOverlapping(start, end, occupiedRanges)) {
|
||||||
|
val cluster = text.substring(start, end)
|
||||||
|
val unified = resolveUnifiedFromCluster(context, cluster)
|
||||||
|
if (!unified.isNullOrEmpty()) {
|
||||||
|
matches.add(
|
||||||
|
EmojiRenderMatch(
|
||||||
|
start = start,
|
||||||
|
end = end,
|
||||||
|
unified = unified
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
start = end
|
||||||
|
end = iterator.next()
|
||||||
|
}
|
||||||
|
|
||||||
|
return matches
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveUnifiedFromCluster(context: Context, cluster: String): String? {
|
||||||
|
if (cluster.isBlank()) return null
|
||||||
|
val codePoints = cluster.codePoints().toArray()
|
||||||
|
if (codePoints.isEmpty()) return null
|
||||||
|
|
||||||
|
val rawUnified = codePoints.joinToString("-") { codePoint ->
|
||||||
|
String.format(Locale.ROOT, "%04x", codePoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolveUnified(context, rawUnified)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resolveUnified(context: Context, rawUnified: String): String? {
|
||||||
|
val key = rawUnified.lowercase(Locale.ROOT)
|
||||||
|
unifiedResolveCache[key]?.let { return it }
|
||||||
|
if (unresolvedUnifiedCache.contains(key)) return null
|
||||||
|
|
||||||
|
val resolved = buildUnifiedCandidates(key).firstOrNull { candidate ->
|
||||||
|
hasEmojiAsset(context, candidate)
|
||||||
|
}
|
||||||
|
if (resolved != null) {
|
||||||
|
unifiedResolveCache[key] = resolved
|
||||||
|
} else {
|
||||||
|
unresolvedUnifiedCache.add(key)
|
||||||
|
}
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasEmojiAsset(context: Context, unified: String): Boolean =
|
||||||
|
getAvailableEmojiAssets(context).contains(unified.lowercase(Locale.ROOT))
|
||||||
|
|
||||||
|
private fun getAvailableEmojiAssets(context: Context): Set<String> {
|
||||||
|
availableEmojiAssets?.let { return it }
|
||||||
|
synchronized(this) {
|
||||||
|
availableEmojiAssets?.let { return it }
|
||||||
|
val loaded =
|
||||||
|
context.assets
|
||||||
|
.list("emoji")
|
||||||
|
?.asSequence()
|
||||||
|
?.filter { it.endsWith(".png", ignoreCase = true) }
|
||||||
|
?.map { it.removeSuffix(".png").lowercase(Locale.ROOT) }
|
||||||
|
?.toSet()
|
||||||
|
?: emptySet()
|
||||||
|
availableEmojiAssets = loaded
|
||||||
|
return loaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildUnifiedCandidates(rawUnified: String): List<String> {
|
||||||
|
val parts = rawUnified.split('-').map { it.trim() }.filter { it.isNotEmpty() }
|
||||||
|
if (parts.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
val candidates = LinkedHashSet<String>()
|
||||||
|
fun addCandidate(partList: List<String>) {
|
||||||
|
if (partList.isNotEmpty()) {
|
||||||
|
candidates.add(partList.joinToString("-"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addCandidate(parts)
|
||||||
|
|
||||||
|
val withoutTextVs = parts.filterNot { it == "fe0e" }
|
||||||
|
addCandidate(withoutTextVs)
|
||||||
|
|
||||||
|
val withoutAnyVs = withoutTextVs.filterNot { it == "fe0f" }
|
||||||
|
addCandidate(withoutAnyVs)
|
||||||
|
|
||||||
|
if (parts.any { it == "fe0e" }) {
|
||||||
|
addCandidate(parts.map { if (it == "fe0e") "fe0f" else it })
|
||||||
|
}
|
||||||
|
|
||||||
|
val keycapNoVs = withoutAnyVs.toMutableList()
|
||||||
|
val keycapIndex = keycapNoVs.indexOf("20e3")
|
||||||
|
if (keycapIndex > 0 && keycapNoVs.getOrNull(keycapIndex - 1) != "fe0f") {
|
||||||
|
keycapNoVs.add(keycapIndex, "fe0f")
|
||||||
|
addCandidate(keycapNoVs)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!parts.contains("fe0f")) {
|
||||||
|
if (withoutAnyVs.size == 1) {
|
||||||
|
addCandidate(listOf(withoutAnyVs.first(), "fe0f"))
|
||||||
|
}
|
||||||
|
if (withoutAnyVs.size > 1 && withoutAnyVs[0] != "200d" && withoutAnyVs[1] != "200d") {
|
||||||
|
val withVsAfterFirst = withoutAnyVs.toMutableList().apply { add(1, "fe0f") }
|
||||||
|
addCandidate(withVsAfterFirst)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates.toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isOverlapping(start: Int, end: Int, occupiedRanges: List<IntRange>): Boolean =
|
||||||
|
occupiedRanges.any { range ->
|
||||||
|
start < (range.last + 1) && end > range.first
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private class TelegramLikeEmojiSpan(
|
private class TelegramLikeEmojiSpan(
|
||||||
emojiDrawable: Drawable,
|
emojiDrawable: Drawable,
|
||||||
private var sourceFontMetrics: Paint.FontMetricsInt?
|
private var sourceFontMetrics: Paint.FontMetricsInt?
|
||||||
@@ -204,31 +360,29 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
|
|||||||
val cursorPosition = selectionStart
|
val cursorPosition = selectionStart
|
||||||
|
|
||||||
// 🔥 Собираем все позиции эмодзи (и Unicode, и :emoji_code:)
|
// 🔥 Собираем все позиции эмодзи (и Unicode, и :emoji_code:)
|
||||||
data class EmojiMatch(val start: Int, val end: Int, val unified: String, val isCodeFormat: Boolean)
|
data class EmojiMatch(val start: Int, val end: Int, val unified: String)
|
||||||
val emojiMatches = mutableListOf<EmojiMatch>()
|
val emojiMatches = mutableListOf<EmojiMatch>()
|
||||||
|
|
||||||
// 1. Ищем :emoji_XXXX: формат
|
// 1. Ищем :emoji_XXXX: формат
|
||||||
val codeMatcher = EMOJI_CODE_PATTERN.matcher(textStr)
|
val codeMatcher = EMOJI_CODE_PATTERN.matcher(textStr)
|
||||||
while (codeMatcher.find()) {
|
while (codeMatcher.find()) {
|
||||||
val unified = codeMatcher.group(1) ?: continue
|
val rawCode = codeMatcher.group(1) ?: continue
|
||||||
emojiMatches.add(EmojiMatch(codeMatcher.start(), codeMatcher.end(), unified, true))
|
val unified =
|
||||||
|
AppleEmojiAssetResolver.resolveUnifiedFromCode(context, rawCode)
|
||||||
|
?: AppleEmojiAssetResolver.normalizeUnifiedCode(rawCode)
|
||||||
|
emojiMatches.add(EmojiMatch(codeMatcher.start(), codeMatcher.end(), unified))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Ищем реальные Unicode эмодзи
|
// 2. Ищем реальные Unicode эмодзи (графемные кластеры, включая ZWJ/flags/skin tones)
|
||||||
val matcher = EMOJI_PATTERN.matcher(textStr)
|
val occupiedRanges = emojiMatches.map { it.start until it.end }
|
||||||
while (matcher.find()) {
|
val unicodeMatches =
|
||||||
val emoji = matcher.group()
|
AppleEmojiAssetResolver.collectUnicodeMatches(
|
||||||
val start = matcher.start()
|
context = context,
|
||||||
val end = matcher.end()
|
text = textStr,
|
||||||
|
occupiedRanges = occupiedRanges
|
||||||
// Проверяем что этот диапазон не перекрывается с :emoji_XXXX:
|
)
|
||||||
val overlaps = emojiMatches.any {
|
unicodeMatches.forEach { match ->
|
||||||
(start >= it.start && start < it.end) ||
|
emojiMatches.add(EmojiMatch(match.start, match.end, match.unified))
|
||||||
(end > it.start && end <= it.end)
|
|
||||||
}
|
|
||||||
if (!overlaps) {
|
|
||||||
emojiMatches.add(EmojiMatch(start, end, emojiToUnified(emoji), false))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Обрабатываем все найденные эмодзи
|
// 3. Обрабатываем все найденные эмодзи
|
||||||
@@ -287,19 +441,6 @@ class AppleEmojiEditTextView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun emojiToUnified(emoji: String): String {
|
|
||||||
val codePoints = emoji.codePoints().toArray()
|
|
||||||
if (codePoints.isEmpty()) return ""
|
|
||||||
|
|
||||||
val unifiedParts = ArrayList<String>(codePoints.size)
|
|
||||||
for (codePoint in codePoints) {
|
|
||||||
if (codePoint != 0xFE0F) {
|
|
||||||
unifiedParts.add(String.format("%04x", codePoint))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return unifiedParts.joinToString("-")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -662,29 +803,48 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
|||||||
val spannable = SpannableStringBuilder(text)
|
val spannable = SpannableStringBuilder(text)
|
||||||
|
|
||||||
// Собираем все замены (чтобы не сбить индексы)
|
// Собираем все замены (чтобы не сбить индексы)
|
||||||
data class EmojiMatch(val start: Int, val end: Int, val unified: String)
|
data class EmojiMatch(
|
||||||
|
val start: Int,
|
||||||
|
val end: Int,
|
||||||
|
val unified: String,
|
||||||
|
val isCodeFormat: Boolean
|
||||||
|
)
|
||||||
val emojiMatches = mutableListOf<EmojiMatch>()
|
val emojiMatches = mutableListOf<EmojiMatch>()
|
||||||
|
|
||||||
// 1. Ищем :emoji_XXXX: формат
|
// 1. Ищем :emoji_XXXX: формат
|
||||||
val codeMatcher = EMOJI_CODE_PATTERN.matcher(text)
|
val codeMatcher = EMOJI_CODE_PATTERN.matcher(text)
|
||||||
while (codeMatcher.find()) {
|
while (codeMatcher.find()) {
|
||||||
val unified = codeMatcher.group(1) ?: continue
|
val rawCode = codeMatcher.group(1) ?: continue
|
||||||
emojiMatches.add(EmojiMatch(codeMatcher.start(), codeMatcher.end(), unified))
|
val unified =
|
||||||
|
AppleEmojiAssetResolver.resolveUnifiedFromCode(context, rawCode)
|
||||||
|
?: AppleEmojiAssetResolver.normalizeUnifiedCode(rawCode)
|
||||||
|
emojiMatches.add(
|
||||||
|
EmojiMatch(
|
||||||
|
start = codeMatcher.start(),
|
||||||
|
end = codeMatcher.end(),
|
||||||
|
unified = unified,
|
||||||
|
isCodeFormat = true
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Ищем реальные Unicode эмодзи
|
// 2. Ищем реальные Unicode эмодзи (включая составные iOS-кластеры)
|
||||||
val unicodeMatcher = EMOJI_PATTERN.matcher(text)
|
val occupiedRanges = emojiMatches.map { it.start until it.end }
|
||||||
while (unicodeMatcher.find()) {
|
val unicodeMatches =
|
||||||
val emoji = unicodeMatcher.group()
|
AppleEmojiAssetResolver.collectUnicodeMatches(
|
||||||
val unified = emojiToUnified(emoji)
|
context = context,
|
||||||
// Проверяем что этот диапазон не перекрывается с :emoji_XXXX:
|
text = text,
|
||||||
val overlaps = emojiMatches.any {
|
occupiedRanges = occupiedRanges
|
||||||
(unicodeMatcher.start() >= it.start && unicodeMatcher.start() < it.end) ||
|
)
|
||||||
(unicodeMatcher.end() > it.start && unicodeMatcher.end() <= it.end)
|
unicodeMatches.forEach { match ->
|
||||||
}
|
emojiMatches.add(
|
||||||
if (!overlaps) {
|
EmojiMatch(
|
||||||
emojiMatches.add(EmojiMatch(unicodeMatcher.start(), unicodeMatcher.end(), unified))
|
start = match.start,
|
||||||
}
|
end = match.end,
|
||||||
|
unified = match.unified,
|
||||||
|
isCodeFormat = false
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Сортируем по позиции в обратном порядке (чтобы не сбить индексы при замене)
|
// 3. Сортируем по позиции в обратном порядке (чтобы не сбить индексы при замене)
|
||||||
@@ -699,8 +859,7 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
|||||||
|
|
||||||
// Для :emoji_XXXX: заменяем весь текст на пробел + span
|
// Для :emoji_XXXX: заменяем весь текст на пробел + span
|
||||||
// Для Unicode эмодзи оставляем символ как есть
|
// Для Unicode эмодзи оставляем символ как есть
|
||||||
if (match.end - match.start > 10) {
|
if (match.isCodeFormat) {
|
||||||
// Это :emoji_XXXX: формат - заменяем на один символ
|
|
||||||
spannable.replace(match.start, match.end, "\u200B") // Zero-width space
|
spannable.replace(match.start, match.end, "\u200B") // Zero-width space
|
||||||
spannable.setSpan(span, match.start, match.start + 1,
|
spannable.setSpan(span, match.start, match.start + 1,
|
||||||
android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
@@ -709,7 +868,7 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
|||||||
spannable.setSpan(span, match.start, match.end,
|
spannable.setSpan(span, match.start, match.end,
|
||||||
android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
android.text.Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||||
}
|
}
|
||||||
} else if (match.end - match.start > 10) {
|
} else if (match.isCodeFormat) {
|
||||||
// 🔥 Fallback: если PNG не найден, конвертируем :emoji_XXXX: в Unicode эмодзи
|
// 🔥 Fallback: если PNG не найден, конвертируем :emoji_XXXX: в Unicode эмодзи
|
||||||
val unicodeEmoji = unifiedToEmoji(match.unified)
|
val unicodeEmoji = unifiedToEmoji(match.unified)
|
||||||
if (unicodeEmoji != null) {
|
if (unicodeEmoji != null) {
|
||||||
@@ -867,20 +1026,6 @@ class AppleEmojiTextView @JvmOverloads constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun emojiToUnified(emoji: String): String {
|
|
||||||
val codePoints = emoji.codePoints().toArray()
|
|
||||||
if (codePoints.isEmpty()) return ""
|
|
||||||
|
|
||||||
val unifiedParts = ArrayList<String>(codePoints.size)
|
|
||||||
for (codePoint in codePoints) {
|
|
||||||
if (codePoint != 0xFE0F) {
|
|
||||||
unifiedParts.add(String.format("%04x", codePoint))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return unifiedParts.joinToString("-")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 🔥 Конвертирует unified код (1f600) в Unicode эмодзи (😀)
|
* 🔥 Конвертирует unified код (1f600) в Unicode эмодзи (😀)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package com.rosetta.messenger.ui.components.metaball
|
|||||||
import android.graphics.ColorMatrixColorFilter
|
import android.graphics.ColorMatrixColorFilter
|
||||||
import android.graphics.Path
|
import android.graphics.Path
|
||||||
import android.graphics.RectF
|
import android.graphics.RectF
|
||||||
import android.util.Log
|
|
||||||
import android.graphics.RenderEffect
|
import android.graphics.RenderEffect
|
||||||
import android.graphics.Shader
|
import android.graphics.Shader
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
@@ -11,13 +11,10 @@ import android.view.Gravity
|
|||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.compose.foundation.Canvas
|
import androidx.compose.foundation.Canvas
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
@@ -25,13 +22,11 @@ import androidx.compose.foundation.layout.offset
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Switch
|
|
||||||
import androidx.compose.material3.SwitchDefaults
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
import androidx.compose.ui.graphics.RectangleShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -48,10 +43,10 @@ import androidx.compose.ui.platform.LocalConfiguration
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.platform.LocalView
|
import androidx.compose.ui.platform.LocalView
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.math.max
|
import kotlin.math.max
|
||||||
import kotlin.math.min
|
import kotlin.math.min
|
||||||
@@ -410,17 +405,8 @@ fun ProfileMetaballOverlay(
|
|||||||
NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view)
|
NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only log in explicit debug mode to keep production scroll clean.
|
|
||||||
val debugLogsEnabled = MetaballDebug.forceMode != null || MetaballDebug.forceNoNotch
|
|
||||||
LaunchedEffect(debugLogsEnabled, notchInfo, screenWidthPx, statusBarHeightPx, headerHeightPx) {
|
|
||||||
if (debugLogsEnabled) {
|
|
||||||
Log.d("ProfileMetaball", "NotchInfo: gravity=${notchInfo?.gravity}, isCircle=${notchInfo?.isLikelyCircle}, bounds=${notchInfo?.bounds}, raw=${notchInfo?.rawPath}")
|
|
||||||
Log.d("ProfileMetaball", "Screen: width=${screenWidthPx}px, statusBar=${statusBarHeightPx}px, header=${headerHeightPx}px")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val hasCenteredNotch = remember(notchInfo, screenWidthPx) {
|
val hasCenteredNotch = remember(notchInfo, screenWidthPx) {
|
||||||
!MetaballDebug.forceNoNotch && isCenteredTopCutout(notchInfo, screenWidthPx)
|
isCenteredTopCutout(notchInfo, screenWidthPx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate notch radius based on real device notch (like Telegram's ProfileGooeyView)
|
// Calculate notch radius based on real device notch (like Telegram's ProfileGooeyView)
|
||||||
@@ -900,7 +886,7 @@ fun ProfileMetaballOverlayCpu(
|
|||||||
NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view)
|
NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view)
|
||||||
}
|
}
|
||||||
val hasRealNotch = remember(notchInfo, screenWidthPx) {
|
val hasRealNotch = remember(notchInfo, screenWidthPx) {
|
||||||
!MetaballDebug.forceNoNotch && isCenteredTopCutout(notchInfo, screenWidthPx)
|
isCenteredTopCutout(notchInfo, screenWidthPx)
|
||||||
}
|
}
|
||||||
val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() }
|
val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() }
|
||||||
|
|
||||||
@@ -1162,153 +1148,6 @@ fun ProfileMetaballOverlayCpu(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* DEBUG: Temporary toggle to force a specific rendering path.
|
|
||||||
* Set forceMode to test different paths on your device:
|
|
||||||
* - null: auto-detect (default production behavior)
|
|
||||||
* - "gpu": force GPU path (requires API 31+)
|
|
||||||
* - "cpu": force CPU bitmap path
|
|
||||||
* - "compat": force compat/noop path
|
|
||||||
*
|
|
||||||
* Set forceNoNotch = true to simulate no-notch device (black bar fallback).
|
|
||||||
*
|
|
||||||
* TODO: Remove before release!
|
|
||||||
*/
|
|
||||||
object MetaballDebug {
|
|
||||||
var forceMode: String? = null // "gpu", "cpu", "compat", or null
|
|
||||||
var forceNoNotch: Boolean = false // true = pretend no notch exists
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DEBUG: Floating panel with buttons to switch metaball rendering path.
|
|
||||||
* Place inside a Box (e.g. profile header) — it aligns to bottom-center.
|
|
||||||
* TODO: Remove before release!
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun MetaballDebugPanel(modifier: Modifier = Modifier) {
|
|
||||||
var currentMode by remember { mutableStateOf(MetaballDebug.forceMode) }
|
|
||||||
var noNotch by remember { mutableStateOf(MetaballDebug.forceNoNotch) }
|
|
||||||
|
|
||||||
val context = LocalContext.current
|
|
||||||
val perfClass = remember { DevicePerformanceClass.get(context) }
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 12.dp)
|
|
||||||
.background(
|
|
||||||
ComposeColor.Black.copy(alpha = 0.75f),
|
|
||||||
RoundedCornerShape(12.dp)
|
|
||||||
)
|
|
||||||
.padding(12.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
// Title
|
|
||||||
Text(
|
|
||||||
text = "Metaball Debug | API ${Build.VERSION.SDK_INT} | $perfClass",
|
|
||||||
color = ComposeColor.White,
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
|
|
||||||
// Mode buttons row
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
val modes = listOf(null to "Auto", "gpu" to "GPU", "cpu" to "CPU", "compat" to "Compat")
|
|
||||||
modes.forEach { (mode, label) ->
|
|
||||||
val isSelected = currentMode == mode
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.clip(RoundedCornerShape(8.dp))
|
|
||||||
.background(
|
|
||||||
if (isSelected) ComposeColor(0xFF4CAF50) else ComposeColor.White.copy(alpha = 0.15f)
|
|
||||||
)
|
|
||||||
.border(
|
|
||||||
width = 1.dp,
|
|
||||||
color = if (isSelected) ComposeColor(0xFF4CAF50) else ComposeColor.White.copy(alpha = 0.3f),
|
|
||||||
shape = RoundedCornerShape(8.dp)
|
|
||||||
)
|
|
||||||
.clickable {
|
|
||||||
MetaballDebug.forceMode = mode
|
|
||||||
currentMode = mode
|
|
||||||
}
|
|
||||||
.padding(vertical = 8.dp),
|
|
||||||
contentAlignment = Alignment.Center
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
color = ComposeColor.White,
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No-notch toggle
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Force no-notch (black bar)",
|
|
||||||
color = ComposeColor.White,
|
|
||||||
fontSize = 12.sp
|
|
||||||
)
|
|
||||||
Switch(
|
|
||||||
checked = noNotch,
|
|
||||||
onCheckedChange = {
|
|
||||||
MetaballDebug.forceNoNotch = it
|
|
||||||
noNotch = it
|
|
||||||
},
|
|
||||||
colors = SwitchDefaults.colors(
|
|
||||||
checkedThumbColor = ComposeColor(0xFF4CAF50),
|
|
||||||
checkedTrackColor = ComposeColor(0xFF4CAF50).copy(alpha = 0.5f)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Current active path info
|
|
||||||
val activePath = when (currentMode) {
|
|
||||||
"gpu" -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) "GPU (forced)" else "GPU needs API 31!"
|
|
||||||
"cpu" -> "CPU (forced)"
|
|
||||||
"compat" -> "Compat (forced)"
|
|
||||||
else -> when {
|
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> "GPU (auto)"
|
|
||||||
else -> "CPU (auto)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = "Active: $activePath" + if (noNotch) " + no-notch" else "",
|
|
||||||
color = ComposeColor(0xFF4CAF50),
|
|
||||||
fontSize = 11.sp,
|
|
||||||
fontWeight = FontWeight.Medium
|
|
||||||
)
|
|
||||||
|
|
||||||
// Notch detection info
|
|
||||||
val view = LocalView.current
|
|
||||||
val notchRes = remember { NotchInfoUtils.getInfo(context) }
|
|
||||||
val notchCutout = remember(view) { NotchInfoUtils.getInfoFromCutout(view) }
|
|
||||||
val notchSource = when {
|
|
||||||
notchRes != null -> "resource"
|
|
||||||
notchCutout != null -> "DisplayCutout"
|
|
||||||
else -> "NONE"
|
|
||||||
}
|
|
||||||
val activeNotch = notchRes ?: notchCutout
|
|
||||||
Text(
|
|
||||||
text = "Notch: $notchSource" +
|
|
||||||
if (activeNotch != null) " | ${activeNotch.bounds.width().toInt()}x${activeNotch.bounds.height().toInt()}" +
|
|
||||||
" circle=${activeNotch.isLikelyCircle}" else " (black bar fallback!)",
|
|
||||||
color = if (activeNotch != null) ComposeColor(0xFF4CAF50) else ComposeColor(0xFFFF5722),
|
|
||||||
fontSize = 10.sp
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-selecting wrapper — 3-tier architecture matching Telegram's ProfileGooeyView:
|
* Auto-selecting wrapper — 3-tier architecture matching Telegram's ProfileGooeyView:
|
||||||
* 1. GPU path (Android 12+, average+ performance): RenderEffect blur + ColorMatrixColorFilter
|
* 1. GPU path (Android 12+, average+ performance): RenderEffect blur + ColorMatrixColorFilter
|
||||||
@@ -1329,36 +1168,9 @@ fun ProfileMetaballEffect(
|
|||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val performanceClass = remember { DevicePerformanceClass.get(context) }
|
val performanceClass = remember { DevicePerformanceClass.get(context) }
|
||||||
|
|
||||||
// Debug: log which path is selected
|
|
||||||
val selectedPath = when (MetaballDebug.forceMode) {
|
|
||||||
"gpu" -> "GPU (forced)"
|
|
||||||
"cpu" -> "CPU (forced)"
|
|
||||||
"compat" -> "Compat (forced)"
|
|
||||||
else -> when {
|
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> "GPU (auto)"
|
|
||||||
else -> "CPU (auto)"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val debugLogsEnabled = MetaballDebug.forceMode != null || MetaballDebug.forceNoNotch
|
|
||||||
LaunchedEffect(selectedPath, debugLogsEnabled, performanceClass) {
|
|
||||||
if (debugLogsEnabled) {
|
|
||||||
Log.d("MetaballDebug", "Rendering path: $selectedPath, forceNoNotch: ${MetaballDebug.forceNoNotch}, perf: $performanceClass, API: ${Build.VERSION.SDK_INT}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resolve actual mode
|
// Resolve actual mode
|
||||||
val useGpu = when (MetaballDebug.forceMode) {
|
val useGpu = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
||||||
"gpu" -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S // still need API 31
|
val useCpu = !useGpu
|
||||||
"cpu" -> false
|
|
||||||
"compat" -> false
|
|
||||||
else -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
|
|
||||||
}
|
|
||||||
val useCpu = when (MetaballDebug.forceMode) {
|
|
||||||
"gpu" -> false
|
|
||||||
"cpu" -> true
|
|
||||||
"compat" -> false
|
|
||||||
else -> !useGpu
|
|
||||||
}
|
|
||||||
|
|
||||||
when {
|
when {
|
||||||
useGpu -> {
|
useGpu -> {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.rosetta.messenger.ui.crashlogs
|
package com.rosetta.messenger.ui.crashlogs
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
@@ -8,13 +9,16 @@ import androidx.compose.foundation.lazy.items
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.ArrowBack
|
import androidx.compose.material.icons.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.BugReport
|
import androidx.compose.material.icons.filled.BugReport
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
import androidx.compose.material.icons.filled.Delete
|
import androidx.compose.material.icons.filled.Delete
|
||||||
import androidx.compose.material.icons.filled.Share
|
import androidx.compose.material.icons.filled.Share
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -263,6 +267,8 @@ private fun CrashDetailScreen(
|
|||||||
onDelete: () -> Unit
|
onDelete: () -> Unit
|
||||||
) {
|
) {
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -274,6 +280,14 @@ private fun CrashDetailScreen(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
clipboardManager.setText(AnnotatedString(crashReport.content))
|
||||||
|
Toast.makeText(context, "Full log copied", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.ContentCopy, contentDescription = "Copy Full Log")
|
||||||
|
}
|
||||||
IconButton(onClick = { /* TODO: Share */ }) {
|
IconButton(onClick = { /* TODO: Share */ }) {
|
||||||
Icon(Icons.Default.Share, contentDescription = "Share")
|
Icon(Icons.Default.Share, contentDescription = "Share")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1939,6 +1939,13 @@ private fun CollapsingOtherProfileHeader(
|
|||||||
overlayColors = com.rosetta.messenger.ui.settings.BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId),
|
overlayColors = com.rosetta.messenger.ui.settings.BackgroundBlurPresets.getOverlayColors(backgroundBlurColorId),
|
||||||
isDarkTheme = isDarkTheme
|
isDarkTheme = isDarkTheme
|
||||||
)
|
)
|
||||||
|
// Slightly deepen avatar blur in other profile so text/icons stay readable.
|
||||||
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier.matchParentSize().background(
|
||||||
|
Color.Black.copy(alpha = if (isDarkTheme) 0.12f else 0.04f)
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class CrashReportManager private constructor(private val context: Context) : Thr
|
|||||||
val crashDir = File(context.filesDir, CRASH_DIR)
|
val crashDir = File(context.filesDir, CRASH_DIR)
|
||||||
if (!crashDir.exists()) return emptyList()
|
if (!crashDir.exists()) return emptyList()
|
||||||
|
|
||||||
return crashDir.listFiles()
|
val reports = crashDir.listFiles()
|
||||||
?.filter { it.extension == "txt" }
|
?.filter { it.extension == "txt" }
|
||||||
?.sortedByDescending { it.lastModified() }
|
?.sortedByDescending { it.lastModified() }
|
||||||
?.map { file ->
|
?.map { file ->
|
||||||
@@ -54,7 +54,21 @@ class CrashReportManager private constructor(private val context: Context) : Thr
|
|||||||
timestamp = file.lastModified(),
|
timestamp = file.lastModified(),
|
||||||
content = file.readText()
|
content = file.readText()
|
||||||
)
|
)
|
||||||
} ?: emptyList()
|
}?.toMutableList() ?: mutableListOf()
|
||||||
|
|
||||||
|
// Include native crash report if present
|
||||||
|
val nativeCrash = File(crashDir, "native_crash.txt")
|
||||||
|
if (nativeCrash.exists() && nativeCrash.length() > 0) {
|
||||||
|
reports.add(0, CrashReport(
|
||||||
|
fileName = "native_crash.txt",
|
||||||
|
timestamp = nativeCrash.lastModified(),
|
||||||
|
content = nativeCrash.readText()
|
||||||
|
))
|
||||||
|
// Delete after reading so it doesn't show up again
|
||||||
|
nativeCrash.delete()
|
||||||
|
}
|
||||||
|
|
||||||
|
return reports
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ import com.rosetta.messenger.network.ProtocolManager
|
|||||||
object MessageLogger {
|
object MessageLogger {
|
||||||
private const val TAG = "RosettaMsg"
|
private const val TAG = "RosettaMsg"
|
||||||
|
|
||||||
// Всегда включён — вывод идёт только в ProtocolManager.addLog() (in-memory UI),
|
@Volatile
|
||||||
// не в logcat, безопасно для release
|
private var isEnabled: Boolean = false
|
||||||
private val isEnabled: Boolean = true
|
|
||||||
|
fun setEnabled(enabled: Boolean) {
|
||||||
|
isEnabled = enabled
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Добавить лог в UI (Debug Logs в чате)
|
* Добавить лог в UI (Debug Logs в чате)
|
||||||
|
|||||||
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