Release IKAR 1.0.7: port navigation and sharing improvements, harden calls
Tests / test (push) Canceled after 0s
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
Tests / test (push) Canceled after 0s
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<img src="assets/ikar_icon.png" width="96" alt="Иконка ИКАР">
|
<img src="assets/ikar_icon.png" width="96" alt="Иконка ИКАР">
|
||||||
|
|
||||||
Самостоятельный Android-мессенджер с сервером `ikar_server`. Имя приложения — **ИКАР**, пакет Android — `ru.ikar.app`, версия исходников и опубликованного выпуска — `1.0.4+5`, вариант сборки — `ikar`.
|
Самостоятельный Android-мессенджер с сервером `ikar_server`. Имя приложения — **ИКАР**, пакет Android — `ru.ikar.app`, версия исходников и опубликованного выпуска — `1.0.7+8`, вариант сборки — `ikar`.
|
||||||
|
|
||||||
Клиент находится в `C:\Repos\IKAR`, сервер — в `ikar_server/`. Основная иконка содержит рисунок Икара без надписей. Исходный каталог `C:\Repos\Qlyra` не изменён; использованный клиентский код и библиотеки сохраняют свои авторские сведения и лицензии.
|
Клиент находится в `C:\Repos\IKAR`, сервер — в `ikar_server/`. Основная иконка содержит рисунок Икара без надписей. Исходный каталог `C:\Repos\Qlyra` не изменён; использованный клиентский код и библиотеки сохраняют свои авторские сведения и лицензии.
|
||||||
|
|
||||||
@@ -18,6 +18,21 @@
|
|||||||
|
|
||||||
Сквозное шифрование исходного клиента сохранено в коде как отдельная функция. Обычные сообщения сервер принимает и хранит в читаемом виде; HTTPS обеспечивает шифрование транспорта. Проверки не доказывают работу всех режимов сквозного шифрования и звонков с большим количеством участников.
|
Сквозное шифрование исходного клиента сохранено в коде как отдельная функция. Обычные сообщения сервер принимает и хранит в читаемом виде; HTTPS обеспечивает шифрование транспорта. Проверки не доказывают работу всех режимов сквозного шифрования и звонков с большим количеством участников.
|
||||||
|
|
||||||
|
## Выпуск 1.0.7
|
||||||
|
|
||||||
|
В [Аргусе](https://argus.kusoft.xyz/#app=ikar) опубликована [ИКАР 1.0.7, сборка 8](https://argus.kusoft.xyz/api/apps/ikar/releases/c3a069b6-1f5f-409a-a67e-59e6e0345ded/download). Пакет `ru.ikar.app` и подпись предыдущего выпуска сохранены; обновление доступно также через собственный сервер ИКАР.
|
||||||
|
|
||||||
|
- Ответ на старое сообщение загружает нужное окно истории; новая навигация отменяет устаревший переход. Сервер проверяет участие в чате и видимость сообщения при запросе по ID.
|
||||||
|
- Входящее «Поделиться» сохраняется в очереди Android. Следующий материал открывается после закрытия текущего экрана; файлы освобождаются после завершения использующих их загрузок.
|
||||||
|
- Карточка файла показывает открытие и блокирует повторное нажатие до завершения действия.
|
||||||
|
- Удаляемые сообщения скрываются сразу, доступны отмена и восстановление при ошибке сервера. Очередь удаления разделена по аккаунтам и чатам.
|
||||||
|
- В настройках оформления доступна компактная нижняя панель с индикатором непрочитанных чатов; прежняя плавающая панель остаётся вариантом по умолчанию.
|
||||||
|
- Ошибка отправки ICE-кандидата при обрыве сети обрабатывается и запускает переподключение. Завершение звонка отключает события старого соединения и безопасно освобождает ресурсы.
|
||||||
|
|
||||||
|
Проверены 1206 тестов клиента (3 пропущены), 163 теста сервера и 16 сценариев рабочего HTTPS API. Передача данных через публичный TURN подтверждена по TCP и UDP после настройки проброса портов. Звонок через мобильную сеть на физическом устройстве в этом выпуске не проверялся.
|
||||||
|
|
||||||
|
APK содержит актуальные Flutter-библиотеки всех трёх ABI и прежнюю подпись ИКАР. SHA-256: `db9bad659f93a9ddca0d484193f25c7430989e36385a2e0fd763976d873ee96a`. Сервер развёрнут в `releases/20261008-followups` с резервной копией базы и вложений; соседние сервисы сохранены. Проверки: `artifacts/apk-verification-1.0.7.json`, `artifacts/server-deployment-1.0.7.json`, `artifacts/argus-publication-1.0.7.json`, `artifacts/full-tests-1.0.7-fixed.log`.
|
||||||
|
|
||||||
## Выпуск 1.0.4
|
## Выпуск 1.0.4
|
||||||
|
|
||||||
В [Аргусе](https://argus.kusoft.xyz/#app=ikar) опубликована [ИКАР 1.0.4, сборка 5](https://argus.kusoft.xyz/api/apps/ikar/releases/24f6ba10-f133-443e-9635-e38e54ce7852/download). Выпуск включает новую главную иконку, собственную тему, изменения жестов и видеоредактора, действия с расшифрованным текстом, работающую с сервером невидимость и меню расшифровки голосовых сообщений. Серверное исправление аватарок уже развёрнуто.
|
В [Аргусе](https://argus.kusoft.xyz/#app=ikar) опубликована [ИКАР 1.0.4, сборка 5](https://argus.kusoft.xyz/api/apps/ikar/releases/24f6ba10-f133-443e-9635-e38e54ce7852/download). Выпуск включает новую главную иконку, собственную тему, изменения жестов и видеоредактора, действия с расшифрованным текстом, работающую с сервером невидимость и меню расшифровки голосовых сообщений. Серверное исправление аватарок уже развёрнуто.
|
||||||
@@ -50,7 +65,7 @@ APK `artifacts/ikar-1.0.4+5.apk` подписан прежним ключом И
|
|||||||
|
|
||||||
Проверки: полный клиентский прогон — 1189 прошли, 3 пропущены; сервер — 150 прошли, без пропусков. Анализ `lib` и `test` не выявил ошибок и предупреждений, остались 42 информационных замечания стиля. Проверены выбор качества видео, сохранение темы, доступ к расшифрованному тексту, жесты, переключение невидимости, переподключение и сохранение публичной активности при обновлении схемы. Локальные отчёты: `artifacts/komet-full-flutter-tests.log`, `artifacts/komet-server-tests.log`, `artifacts/komet-implementation-analyze.log`.
|
Проверки: полный клиентский прогон — 1189 прошли, 3 пропущены; сервер — 150 прошли, без пропусков. Анализ `lib` и `test` не выявил ошибок и предупреждений, остались 42 информационных замечания стиля. Проверены выбор качества видео, сохранение темы, доступ к расшифрованному тексту, жесты, переключение невидимости, переподключение и сохранение публичной активности при обновлении схемы. Локальные отчёты: `artifacts/komet-full-flutter-tests.log`, `artifacts/komet-server-tests.log`, `artifacts/komet-implementation-analyze.log`.
|
||||||
|
|
||||||
Рабочий API обновлён до `releases/20261007-komet`. Проверены совпадение SHA-256 серверной сборки, целостность рабочей базы и резервной копии, новая колонка публичной активности, `/health`, отсутствие перезапусков API и сохранность соседних контейнеров. Резервная копия перед обновлением: `/mnt/data/ikar_server/backups/ikar-20261007T191617Z.tar.gz`; предыдущий выпуск сохранён для отката. Через публичный HTTPS прошли 15 общих проверок API, 8 проверок невидимости и 5 проверок видео. Они охватывают регистрацию, доставку и историю, права доступа, файлы, речь на Pi, группы, каналы, реакции, опросы, отложенную отправку и сигнализацию звонков. Невидимая сессия получает сообщения и приглашения звонка, не меняя публичное время активности. Отчёты: `artifacts/deployment-komet.json`, `artifacts/ghost-live-verification.json`, `artifacts/live-smoke.json`, `artifacts/video-live-verification.json`.
|
Рабочий API обновлён до `releases/20261008-followups`. Проверены совпадение SHA-256 серверной сборки, целостность рабочей базы и резервной копии, новая колонка публичной активности, `/health`, отсутствие перезапусков API и сохранность соседних контейнеров. Резервная копия перед обновлением: `/mnt/data/ikar_server/backups/ikar-20261007T191617Z.tar.gz`; предыдущий выпуск сохранён для отката. Через публичный HTTPS прошли 15 общих проверок API, 8 проверок невидимости и 5 проверок видео. Они охватывают регистрацию, доставку и историю, права доступа, файлы, речь на Pi, группы, каналы, реакции, опросы, отложенную отправку и сигнализацию звонков. Невидимая сессия получает сообщения и приглашения звонка, не меняя публичное время активности. Отчёты: `artifacts/deployment-komet.json`, `artifacts/ghost-live-verification.json`, `artifacts/live-smoke.json`, `artifacts/video-live-verification.json`.
|
||||||
|
|
||||||
Главная иконка содержит только рисунок Икара без надписей. Размер рисунка — около 90% диаметра круглой области; адаптивный слой учитывает расширение Android с 72 до 108 dp. Исходный рисунок — `assets/ikar_mark.png`, основные ресурсы — `assets/ikar_icon.png` и `assets/ikar.png`. Пересоздание: `dart run tool/make_ikar_icons.dart`, затем `dart run flutter_launcher_icons`. Ресурсы основной иконки обновлены для Android, iOS, Windows и macOS; альтернативная иконка сохранена.
|
Главная иконка содержит только рисунок Икара без надписей. Размер рисунка — около 90% диаметра круглой области; адаптивный слой учитывает расширение Android с 72 до 108 dp. Исходный рисунок — `assets/ikar_mark.png`, основные ресурсы — `assets/ikar_icon.png` и `assets/ikar.png`. Пересоздание: `dart run tool/make_ikar_icons.dart`, затем `dart run flutter_launcher_icons`. Ресурсы основной иконки обновлены для Android, iOS, Windows и macOS; альтернативная иконка сохранена.
|
||||||
|
|
||||||
@@ -60,7 +75,7 @@ APK `artifacts/ikar-1.0.4+5.apk` подписан прежним ключом И
|
|||||||
|
|
||||||
Внешний диск: `/mnt/data` (`/dev/sdb1`, ext4). Корень новой установки: `/mnt/data/ikar_server`.
|
Внешний диск: `/mnt/data` (`/dev/sdb1`, ext4). Корень новой установки: `/mnt/data/ikar_server`.
|
||||||
|
|
||||||
- `current` — ссылка на действующий выпуск в `releases/20261007-komet`;
|
- `current` — ссылка на действующий выпуск в `releases/20261008-followups`;
|
||||||
- `shared/data` — SQLite, вложения, данные устройств и публикация APK;
|
- `shared/data` — SQLite, вложения, данные устройств и публикация APK;
|
||||||
- `shared/server.env`, `shared/turn.env` — закрытые настройки, доступ 0600;
|
- `shared/server.env`, `shared/turn.env` — закрытые настройки, доступ 0600;
|
||||||
- `backups` — ежедневные согласованные копии SQLite и вложений, хранение 14 дней;
|
- `backups` — ежедневные согласованные копии SQLite и вложений, хранение 14 дней;
|
||||||
@@ -78,7 +93,7 @@ sudo systemctl start ikar_server-backup.service
|
|||||||
|
|
||||||
Адрес сервера — `https://ikar.kusoft.xyz`. На проверке 7 октября 2026 года DNS-запись разрешалась в `195.216.241.165`. APK также умеет подключаться к этому IP с сохранением проверки сертификата имени `ikar.kusoft.xyz`, если DNS временно недоступен.
|
Адрес сервера — `https://ikar.kusoft.xyz`. На проверке 7 октября 2026 года DNS-запись разрешалась в `195.216.241.165`. APK также умеет подключаться к этому IP с сохранением проверки сертификата имени `ikar.kusoft.xyz`, если DNS временно недоступен.
|
||||||
|
|
||||||
Для звонков через разные NAT нужен проброс на роутере: 3478 TCP/UDP и 49160–49200 UDP на `192.168.0.185`. При проверке TURN работал через локальный адрес Pi; запросы на внешний адрес не получили ответа TURN. Публичная ретрансляция пока не подтверждена. Звонок с аудио/видео в эмуляторе прошёл через прямое WebRTC-соединение; это не доказывает работу через любую мобильную сеть.
|
Для звонков через разные NAT нужен проброс на роутере: 3478 TCP/UDP и 49160–49200 UDP на `192.168.0.185`. После настройки проброса 8 октября 2026 года подтверждена двусторонняя передача данных через TURN по TCP и UDP с обращением к публичному адресу сервера. Отчёты: `artifacts/public-turn-verification.json` и `artifacts/public-turn-verification-udp.json`. Звонок с аудио/видео в эмуляторе прошёл через прямое WebRTC-соединение; это не доказывает работу через любую мобильную сеть.
|
||||||
|
|
||||||
## Сборка
|
## Сборка
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
gradle-wrapper.jar
|
gradle-wrapper.jar
|
||||||
/.gradle
|
/.gradle
|
||||||
|
/.kotlin/
|
||||||
/captures/
|
/captures/
|
||||||
/gradlew
|
/gradlew
|
||||||
/gradlew.bat
|
/gradlew.bat
|
||||||
|
|||||||
@@ -77,8 +77,6 @@ class MainActivity : AudioServiceFragmentActivity() {
|
|||||||
|
|
||||||
private var pendingCall: Map<String, Any?>? = null
|
private var pendingCall: Map<String, Any?>? = null
|
||||||
private var pendingChat: Long = 0L
|
private var pendingChat: Long = 0L
|
||||||
private var pendingShare: Map<String, Any?>? = null
|
|
||||||
private var pendingShareTask: java.util.concurrent.Future<Map<String, Any?>?>? = null
|
|
||||||
private val shareExecutor = java.util.concurrent.Executors.newSingleThreadExecutor()
|
private val shareExecutor = java.util.concurrent.Executors.newSingleThreadExecutor()
|
||||||
private val shareHandler = Handler(Looper.getMainLooper())
|
private val shareHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
@@ -507,26 +505,25 @@ class MainActivity : AudioServiceFragmentActivity() {
|
|||||||
"ru.ikar.app/share",
|
"ru.ikar.app/share",
|
||||||
).setMethodCallHandler { call, result ->
|
).setMethodCallHandler { call, result ->
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
"consumeInitialShare" -> {
|
"consumeInitialShare", "acknowledgeShare", "releaseShare" -> {
|
||||||
stashShare(intent, emit = false)
|
stashShare(intent, emit = false)
|
||||||
val ready = pendingShare
|
shareExecutor.execute {
|
||||||
val task = pendingShareTask
|
try {
|
||||||
if (ready != null) {
|
val value = when (call.method) {
|
||||||
pendingShare = null
|
"consumeInitialShare" -> ShareQueue.consume(applicationContext)
|
||||||
result.success(ready)
|
"acknowledgeShare" -> {
|
||||||
} else if (task != null) {
|
ShareQueue.acknowledge(applicationContext, call.argument<String>("id")!!)
|
||||||
pendingShareTask = null
|
null
|
||||||
shareExecutor.execute {
|
}
|
||||||
val payload = try {
|
else -> {
|
||||||
task.get()
|
ShareQueue.release(applicationContext, call.argument<String>("id")!!)
|
||||||
} catch (e: Exception) {
|
null
|
||||||
Log.w(SHARE_TAG, "materialize failed: $e")
|
}
|
||||||
null
|
|
||||||
}
|
}
|
||||||
shareHandler.post { result.success(payload) }
|
shareHandler.post { result.success(value) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
shareHandler.post { result.error("SHARE_QUEUE", e.message, null) }
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
result.success(null)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"clearCache" -> {
|
"clearCache" -> {
|
||||||
@@ -658,23 +655,14 @@ class MainActivity : AudioServiceFragmentActivity() {
|
|||||||
intent.action = Intent.ACTION_MAIN
|
intent.action = Intent.ACTION_MAIN
|
||||||
intent.removeExtra(Intent.EXTRA_STREAM)
|
intent.removeExtra(Intent.EXTRA_STREAM)
|
||||||
intent.removeExtra(Intent.EXTRA_TEXT)
|
intent.removeExtra(Intent.EXTRA_TEXT)
|
||||||
val task = shareExecutor.submit<Map<String, Any?>?> {
|
|
||||||
ShareIntake.materialize(applicationContext, snapshot)
|
|
||||||
}
|
|
||||||
if (!emit) {
|
|
||||||
pendingShareTask = task
|
|
||||||
return
|
|
||||||
}
|
|
||||||
shareExecutor.execute {
|
shareExecutor.execute {
|
||||||
val payload = try {
|
try {
|
||||||
task.get()
|
val payload = ShareIntake.materialize(applicationContext, snapshot)
|
||||||
|
?: return@execute
|
||||||
|
ShareQueue.enqueue(applicationContext, payload)
|
||||||
|
if (emit) shareHandler.post { ShareIntake.sink?.success(true) }
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(SHARE_TAG, "materialize failed: $e")
|
Log.w(SHARE_TAG, "cannot persist share: $e")
|
||||||
null
|
|
||||||
} ?: return@execute
|
|
||||||
shareHandler.post {
|
|
||||||
val sink = ShareIntake.sink
|
|
||||||
if (sink != null) sink.success(payload) else pendingShare = payload
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ object ShareIntake {
|
|||||||
|
|
||||||
fun clearCache(context: Context, paths: List<String>) {
|
fun clearCache(context: Context, paths: List<String>) {
|
||||||
try {
|
try {
|
||||||
val dir = File(context.cacheDir, CACHE_DIR).canonicalFile
|
val dir = File(context.filesDir, CACHE_DIR).canonicalFile
|
||||||
if (!dir.isDirectory) return
|
if (!dir.isDirectory) return
|
||||||
for (path in paths) {
|
for (path in paths) {
|
||||||
val file = File(path).canonicalFile
|
val file = File(path).canonicalFile
|
||||||
@@ -96,7 +96,7 @@ object ShareIntake {
|
|||||||
val displayName = queryDisplayName(resolver, uri) ?: fallbackName(uri, mime)
|
val displayName = queryDisplayName(resolver, uri) ?: fallbackName(uri, mime)
|
||||||
|
|
||||||
return try {
|
return try {
|
||||||
val dir = File(context.cacheDir, CACHE_DIR).apply { mkdirs() }
|
val dir = File(context.filesDir, CACHE_DIR).apply { mkdirs() }
|
||||||
val target = File(dir, "${System.currentTimeMillis()}_${seq.incrementAndGet()}_${sanitize(displayName)}")
|
val target = File(dir, "${System.currentTimeMillis()}_${seq.incrementAndGet()}_${sanitize(displayName)}")
|
||||||
resolver.openInputStream(uri).use { input ->
|
resolver.openInputStream(uri).use { input ->
|
||||||
if (input == null) return null
|
if (input == null) return null
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package ru.ikar.app
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/** Durable intake; all methods are called on MainActivity's share executor. */
|
||||||
|
object ShareQueue {
|
||||||
|
private fun directory(context: Context) = File(context.filesDir, "share_queue").apply { mkdirs() }
|
||||||
|
private fun record(context: Context, id: String): File {
|
||||||
|
require(id.matches(Regex("[0-9a-f-]{36}")))
|
||||||
|
return File(directory(context), "$id.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun enqueue(context: Context, payload: Map<String, Any?>) {
|
||||||
|
val id = UUID.randomUUID().toString()
|
||||||
|
val target = record(context, id)
|
||||||
|
val json = JSONObject(payload).put("id", id).put("receivedAt", System.currentTimeMillis())
|
||||||
|
val staged = File(target.path + ".next")
|
||||||
|
staged.writeText(json.toString())
|
||||||
|
check(staged.renameTo(target)) { "Cannot persist shared content" }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun consume(context: Context): Map<String, Any?>? {
|
||||||
|
val entries = directory(context).listFiles()?.filter { it.extension == "json" }
|
||||||
|
?.sortedBy { it.lastModified() } ?: return null
|
||||||
|
for (entry in entries) {
|
||||||
|
val json = try { JSONObject(entry.readText()) } catch (_: Exception) { continue }
|
||||||
|
if (json.optBoolean("acknowledged")) {
|
||||||
|
release(context, json.getString("id"))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return decode(json)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun acknowledge(context: Context, id: String) {
|
||||||
|
val target = record(context, id)
|
||||||
|
if (!target.exists()) return
|
||||||
|
val json = JSONObject(target.readText()).put("acknowledged", true)
|
||||||
|
val staged = File(target.path + ".next")
|
||||||
|
staged.writeText(json.toString())
|
||||||
|
check(staged.renameTo(target))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun release(context: Context, id: String) {
|
||||||
|
val target = record(context, id)
|
||||||
|
if (!target.exists()) return
|
||||||
|
val json = JSONObject(target.readText())
|
||||||
|
val files = json.optJSONArray("files")
|
||||||
|
val paths = (0 until (files?.length() ?: 0)).mapNotNull {
|
||||||
|
files?.optJSONObject(it)?.optString("path")
|
||||||
|
}
|
||||||
|
ShareIntake.clearCache(context, paths)
|
||||||
|
check(target.delete())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decode(json: JSONObject): Map<String, Any?> = json.keys().asSequence().associateWith { key ->
|
||||||
|
when (val value = json.get(key)) {
|
||||||
|
JSONObject.NULL -> null
|
||||||
|
is org.json.JSONArray -> (0 until value.length()).map { decode(value.getJSONObject(it)) }
|
||||||
|
else -> value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Ikar.Server.Data.Entities;
|
||||||
|
using Ikar.Server.Controllers;
|
||||||
|
using Ikar.Server.Infrastructure;
|
||||||
|
using Ikar.Shared;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Ikar.Server.Tests;
|
||||||
|
|
||||||
|
public sealed partial class QlyraCompatibilityControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task MessageLookup_RespectsMembershipChatAndHiddenMessages()
|
||||||
|
{
|
||||||
|
await using var database = new SqliteTestDatabase();
|
||||||
|
await using var db = await database.CreateContextAsync();
|
||||||
|
var owner = new User { Username = "lookup", NormalizedUsername = "LOOKUP", DisplayName = "Lookup", PasswordHash = "test" };
|
||||||
|
var outsider = new User { Username = "outsider", NormalizedUsername = "OUTSIDER", DisplayName = "Other", PasswordHash = "test" };
|
||||||
|
var chat = new Chat { Type = ChatType.Group, Title = "Lookup", CreatedBy = owner };
|
||||||
|
chat.Members.Add(new ChatMember { Chat = chat, User = owner, IsOwner = true, Role = ChatMemberRole.Owner });
|
||||||
|
var message = new Message { Chat = chat, Sender = owner, Text = "Old reply target" };
|
||||||
|
chat.Messages.Add(message);
|
||||||
|
db.Users.Add(outsider);
|
||||||
|
db.Chats.Add(chat);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var mapper = new QlyraClientIdMapper(db);
|
||||||
|
var chatId = await mapper.GetOrCreateAsync("chat", chat.Id, CancellationToken.None);
|
||||||
|
var messageId = await mapper.GetOrCreateAsync("message", message.Id, CancellationToken.None);
|
||||||
|
using var document = JsonDocument.Parse($"{{\"chatId\":{chatId},\"messageId\":{messageId}}}");
|
||||||
|
var request = new QlyraCompatibilityRequest(document.RootElement.Clone());
|
||||||
|
var controller = CreateController(db, owner.Id);
|
||||||
|
var action = Assert.IsType<OkObjectResult>(await controller.Dispatch(71, request, CancellationToken.None));
|
||||||
|
var payload = JsonSerializer.SerializeToElement(Assert.IsType<QlyraCompatibilityResponse>(action.Value).Payload);
|
||||||
|
Assert.Equal("Old reply target", payload.GetProperty("message").GetProperty("text").GetString());
|
||||||
|
Assert.IsType<NotFoundObjectResult>(await CreateController(db, outsider.Id).Dispatch(71, request, CancellationToken.None));
|
||||||
|
db.MessageHiddenForUsers.Add(new MessageHiddenForUser { UserId = owner.Id, MessageId = message.Id });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
Assert.IsType<NotFoundObjectResult>(await controller.Dispatch(71, request, CancellationToken.None));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ public sealed partial class QlyraCompatibilityControllerTests
|
|||||||
int[] opcodes =
|
int[] opcodes =
|
||||||
[
|
[
|
||||||
1, 8, 16, 17, 18, 19, 20, 21, 22, 23, 26, 27, 28, 29, 32, 34, 35, 36, 39, 41, 43, 46,
|
1, 8, 16, 17, 18, 19, 20, 21, 22, 23, 26, 27, 28, 29, 32, 34, 35, 36, 39, 41, 43, 46,
|
||||||
48, 49, 50, 51, 52, 53, 54, 55, 57, 58, 59, 60, 64, 65, 66, 67, 68, 73, 75, 76, 77, 78,
|
48, 49, 50, 51, 52, 53, 54, 55, 57, 58, 59, 60, 64, 65, 66, 67, 68, 71, 73, 75, 76, 77, 78,
|
||||||
79, 80, 82, 83, 84, 87, 88, 89, 91, 96, 97, 104, 107, 108, 109, 110, 111, 112,
|
79, 80, 82, 83, 84, 87, 88, 89, 91, 96, 97, 104, 107, 108, 109, 110, 111, 112,
|
||||||
113, 115, 118, 145, 161, 162, 164, 166, 178, 179, 181, 198, 202, 208, 210, 214, 215,
|
113, 115, 118, 145, 161, 162, 164, 166, 178, 179, 181, 198, 202, 208, 210, 214, 215,
|
||||||
259, 272, 273, 274, 275, 276, 290, 302, 304, 306
|
259, 272, 273, 274, 275, 276, 290, 302, 304, 306
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase
|
|||||||
43 => await RemoveProfilePhotoAsync(opcode, cancellationToken),
|
43 => await RemoveProfilePhotoAsync(opcode, cancellationToken),
|
||||||
48 => await GetChatInfoAsync(opcode, request.Payload, cancellationToken),
|
48 => await GetChatInfoAsync(opcode, request.Payload, cancellationToken),
|
||||||
49 => await GetHistoryAsync(opcode, request.Payload, cancellationToken),
|
49 => await GetHistoryAsync(opcode, request.Payload, cancellationToken),
|
||||||
|
71 => await GetHistoryAsync(opcode, request.Payload, cancellationToken),
|
||||||
50 => await MarkReadAsync(opcode, request.Payload, cancellationToken),
|
50 => await MarkReadAsync(opcode, request.Payload, cancellationToken),
|
||||||
51 => await GetChatMediaAsync(opcode, request.Payload, cancellationToken),
|
51 => await GetChatMediaAsync(opcode, request.Payload, cancellationToken),
|
||||||
52 => await DeleteChatAsync(opcode, request.Payload, cancellationToken),
|
52 => await DeleteChatAsync(opcode, request.Payload, cancellationToken),
|
||||||
@@ -1738,6 +1739,13 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase
|
|||||||
var visibleMessages = chat.Messages
|
var visibleMessages = chat.Messages
|
||||||
.Where(message => message.DeletedAt is null && !hiddenMessageIds.Contains(message.Id))
|
.Where(message => message.DeletedAt is null && !hiddenMessageIds.Contains(message.Id))
|
||||||
.Where(message => delayed ? message.ScheduledForUnixMs != null && message.SenderId == user.Id : message.ScheduledForUnixMs == null);
|
.Where(message => delayed ? message.ScheduledForUnixMs != null && message.SenderId == user.Id : message.ScheduledForUnixMs == null);
|
||||||
|
if (opcode == 71)
|
||||||
|
{
|
||||||
|
var selected = await ResolveMessageAsync(chat.Id, ReadInt(payload, "messageId"), cancellationToken);
|
||||||
|
if (selected is null || !visibleMessages.Any(message => message.Id == selected.Id))
|
||||||
|
return NotFound(QlyraCompatibilityResponse.Error(opcode, "MESSAGE_NOT_FOUND", "Сообщение не найдено."));
|
||||||
|
return Ok(QlyraCompatibilityResponse.Ok(opcode, new { message = await BuildMessageAsync(selected, cancellationToken) }));
|
||||||
|
}
|
||||||
if (delayed) anchor = DateTimeOffset.MaxValue;
|
if (delayed) anchor = DateTimeOffset.MaxValue;
|
||||||
if (ReadInt(payload, "postId") is int clientPostId)
|
if (ReadInt(payload, "postId") is int clientPostId)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -201,7 +201,8 @@ class TranscriptionCache {
|
|||||||
} else {
|
} else {
|
||||||
_expanded.remove(messageId);
|
_expanded.remove(messageId);
|
||||||
}
|
}
|
||||||
for (final listener in _listeners[messageId]?.toList() ?? <VoidCallback>[]) {
|
for (final listener
|
||||||
|
in _listeners[messageId]?.toList() ?? <VoidCallback>[]) {
|
||||||
listener();
|
listener();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -789,6 +790,23 @@ class MessagesModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// #***! история с сервера
|
// #***! история с сервера
|
||||||
|
Future<CachedMessage?> fetchMessage(
|
||||||
|
int accountId,
|
||||||
|
int chatId,
|
||||||
|
String id,
|
||||||
|
) async {
|
||||||
|
final messageId = int.tryParse(id);
|
||||||
|
if (messageId == null) return null;
|
||||||
|
final response = await _api.sendRequest(Opcode.msgGet, {
|
||||||
|
'chatId': chatId,
|
||||||
|
'messageId': messageId,
|
||||||
|
});
|
||||||
|
if (!response.isOk || response.payload is! Map) return null;
|
||||||
|
final raw = (response.payload as Map)['message'];
|
||||||
|
if (raw is! Map) return null;
|
||||||
|
return _parseMessage(raw, accountId, chatId);
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<CachedMessage>> fetchHistory(
|
Future<List<CachedMessage>> fetchHistory(
|
||||||
int accountId,
|
int accountId,
|
||||||
int chatId, {
|
int chatId, {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// Pending UI removals are scoped to an account as well as a chat.
|
||||||
|
class PendingMessageDeletions extends ChangeNotifier {
|
||||||
|
PendingMessageDeletions();
|
||||||
|
static final instance = PendingMessageDeletions();
|
||||||
|
final Map<(int, int), Set<String>> _pending = {};
|
||||||
|
|
||||||
|
bool contains(int accountId, int chatId, String id) =>
|
||||||
|
_pending[(accountId, chatId)]?.contains(id) ?? false;
|
||||||
|
|
||||||
|
void hold(int accountId, int chatId, Iterable<String> ids) {
|
||||||
|
_pending.putIfAbsent((accountId, chatId), () => {}).addAll(ids);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void release(int accountId, int chatId, Iterable<String> ids) {
|
||||||
|
final pending = _pending[(accountId, chatId)];
|
||||||
|
if (pending == null) return;
|
||||||
|
pending.removeAll(ids);
|
||||||
|
if (pending.isEmpty) _pending.remove((accountId, chatId));
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart'
|
import 'package:flutter/foundation.dart'
|
||||||
show TargetPlatform, defaultTargetPlatform;
|
show TargetPlatform, defaultTargetPlatform, visibleForTesting;
|
||||||
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||||
|
|
||||||
import '../config/app_camera.dart';
|
import '../config/app_camera.dart';
|
||||||
@@ -127,6 +127,8 @@ class CallSession {
|
|||||||
static const Duration _maxReconnectDelay = Duration(seconds: 20);
|
static const Duration _maxReconnectDelay = Duration(seconds: 20);
|
||||||
|
|
||||||
bool get isReconnecting => _reconnecting;
|
bool get isReconnecting => _reconnecting;
|
||||||
|
String? _failureReason;
|
||||||
|
String? get failureReason => _failureReason;
|
||||||
|
|
||||||
Timer? _levelTimer;
|
Timer? _levelTimer;
|
||||||
final Map<int, int> _speakHold = {};
|
final Map<int, int> _speakHold = {};
|
||||||
@@ -238,7 +240,11 @@ class CallSession {
|
|||||||
: DateTime.now().difference(_activeSince!).inSeconds;
|
: DateTime.now().difference(_activeSince!).inSeconds;
|
||||||
|
|
||||||
void _setState(CallSessionState s) {
|
void _setState(CallSessionState s) {
|
||||||
if (_current == s || _current == CallSessionState.ended) return;
|
if (_state.isClosed ||
|
||||||
|
_current == s ||
|
||||||
|
_current == CallSessionState.ended) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (s == CallSessionState.active) _activeSince ??= DateTime.now();
|
if (s == CallSessionState.active) _activeSince ??= DateTime.now();
|
||||||
_current = s;
|
_current = s;
|
||||||
_state.add(s);
|
_state.add(s);
|
||||||
@@ -360,6 +366,7 @@ class CallSession {
|
|||||||
_reconnecting = false;
|
_reconnecting = false;
|
||||||
if (!_ended) {
|
if (!_ended) {
|
||||||
logger.w('[call] reconnect gave up');
|
logger.w('[call] reconnect gave up');
|
||||||
|
_failureReason = 'Не удалось восстановить соединение для звонка';
|
||||||
_end();
|
_end();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -368,6 +375,8 @@ class CallSession {
|
|||||||
if (_ended || _iceRestarting || _topology == 'SERVER') return;
|
if (_ended || _iceRestarting || _topology == 'SERVER') return;
|
||||||
if (_iceRestarts >= _maxIceRestarts) {
|
if (_iceRestarts >= _maxIceRestarts) {
|
||||||
logger.w('[call] ice restart budget exhausted, ending call');
|
logger.w('[call] ice restart budget exhausted, ending call');
|
||||||
|
_failureReason =
|
||||||
|
'Не удалось установить соединение для звонка. Проверьте сеть';
|
||||||
_end();
|
_end();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -398,10 +407,15 @@ class CallSession {
|
|||||||
_probeChannel = null;
|
_probeChannel = null;
|
||||||
await _closeSfuChannels();
|
await _closeSfuChannels();
|
||||||
|
|
||||||
try {
|
final previousPc = _pc;
|
||||||
await _pc?.close();
|
|
||||||
} catch (_) {}
|
|
||||||
_pc = null;
|
_pc = null;
|
||||||
|
previousPc?.onIceCandidate = null;
|
||||||
|
previousPc?.onTrack = null;
|
||||||
|
previousPc?.onConnectionState = null;
|
||||||
|
previousPc?.onIceConnectionState = null;
|
||||||
|
try {
|
||||||
|
await previousPc?.close();
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
_audioSender = null;
|
_audioSender = null;
|
||||||
_videoSender = null;
|
_videoSender = null;
|
||||||
@@ -409,6 +423,7 @@ class CallSession {
|
|||||||
_remoteDescSet = false;
|
_remoteDescSet = false;
|
||||||
_pendingCandidates.clear();
|
_pendingCandidates.clear();
|
||||||
_accepted = false;
|
_accepted = false;
|
||||||
|
_gotConnection = false;
|
||||||
_mediaConnected = false;
|
_mediaConnected = false;
|
||||||
_sfuSessionId = null;
|
_sfuSessionId = null;
|
||||||
await _clearParticipantStreams();
|
await _clearParticipantStreams();
|
||||||
@@ -785,6 +800,9 @@ class CallSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onConnection(Map<String, dynamic> msg) async {
|
Future<void> _onConnection(Map<String, dynamic> msg) async {
|
||||||
|
if (_ended) return;
|
||||||
|
// A wake command or reconnect can repeat the handshake notification.
|
||||||
|
if (_gotConnection) return;
|
||||||
_gotConnection = true;
|
_gotConnection = true;
|
||||||
logger.i('[call] connection notification received');
|
logger.i('[call] connection notification received');
|
||||||
final convParams = msg['conversationParams'];
|
final convParams = msg['conversationParams'];
|
||||||
@@ -808,8 +826,13 @@ class CallSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final pc = await _createPc(ice);
|
final pc = await _createPc(ice);
|
||||||
|
if (_ended) {
|
||||||
|
await pc.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
_pc = pc;
|
_pc = pc;
|
||||||
await _addLocalMedia(pc);
|
await _addLocalMedia(pc);
|
||||||
|
if (_ended || !identical(_pc, pc)) return;
|
||||||
|
|
||||||
await pc.addTransceiver(
|
await pc.addTransceiver(
|
||||||
kind: RTCRtpMediaType.RTCRtpMediaTypeVideo,
|
kind: RTCRtpMediaType.RTCRtpMediaTypeVideo,
|
||||||
@@ -846,12 +869,13 @@ class CallSession {
|
|||||||
final done = _gatherDone;
|
final done = _gatherDone;
|
||||||
if (done != null && !done.isCompleted) done.complete();
|
if (done != null && !done.isCompleted) done.complete();
|
||||||
};
|
};
|
||||||
pc.onTrack = (event) => unawaited(_onRemoteTrack(event));
|
pc.onTrack = (event) => _runCallTask(() => _onRemoteTrack(event));
|
||||||
pc.onDataChannel = (channel) {
|
pc.onDataChannel = (channel) {
|
||||||
if (!_qlyraProbeEnabled) return;
|
if (!_qlyraProbeEnabled) return;
|
||||||
_bindProbeChannel(channel, ask: false);
|
_bindProbeChannel(channel, ask: false);
|
||||||
};
|
};
|
||||||
pc.onIceConnectionState = (s) {
|
pc.onIceConnectionState = (s) {
|
||||||
|
if (_ended || !identical(_pc, pc)) return;
|
||||||
logger.i('[call] ice $s');
|
logger.i('[call] ice $s');
|
||||||
if (s != RTCIceConnectionState.RTCIceConnectionStateFailed) return;
|
if (s != RTCIceConnectionState.RTCIceConnectionStateFailed) return;
|
||||||
if (_topology != 'SERVER' || _ended) return;
|
if (_topology != 'SERVER' || _ended) return;
|
||||||
@@ -865,6 +889,7 @@ class CallSession {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
pc.onConnectionState = (s) {
|
pc.onConnectionState = (s) {
|
||||||
|
if (_ended || !identical(_pc, pc)) return;
|
||||||
logger.i('[call] pc $s');
|
logger.i('[call] pc $s');
|
||||||
final connected =
|
final connected =
|
||||||
s == RTCPeerConnectionState.RTCPeerConnectionStateConnected;
|
s == RTCPeerConnectionState.RTCPeerConnectionStateConnected;
|
||||||
@@ -876,9 +901,9 @@ class CallSession {
|
|||||||
if (role == CallRole.joiner || _topology == 'SERVER') {
|
if (role == CallRole.joiner || _topology == 'SERVER') {
|
||||||
_setState(CallSessionState.active);
|
_setState(CallSessionState.active);
|
||||||
}
|
}
|
||||||
unawaited(applyAudioRoute());
|
_runCallTask(applyAudioRoute);
|
||||||
unawaited(_resolvePath());
|
_runCallTask(_resolvePath);
|
||||||
unawaited(_collectReceivers());
|
_runCallTask(_collectReceivers);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (_topology == 'SERVER') return;
|
if (_topology == 'SERVER') return;
|
||||||
@@ -905,15 +930,22 @@ class CallSession {
|
|||||||
await _resetMicRoute();
|
await _resetMicRoute();
|
||||||
}
|
}
|
||||||
await _selectMicInsideEngine();
|
await _selectMicInsideEngine();
|
||||||
_localStream = await navigator.mediaDevices.getUserMedia({
|
if (_ended || !identical(_pc, pc)) return;
|
||||||
|
final stream = await navigator.mediaDevices.getUserMedia({
|
||||||
'audio': AudioDevices.micConstraints(
|
'audio': AudioDevices.micConstraints(
|
||||||
_micDeviceId,
|
_micDeviceId,
|
||||||
monitorCapture: _monitorCapture,
|
monitorCapture: _monitorCapture,
|
||||||
),
|
),
|
||||||
'video': _wantVideo,
|
'video': _wantVideo,
|
||||||
});
|
});
|
||||||
for (final track in _localStream!.getTracks()) {
|
if (_ended || !identical(_pc, pc)) {
|
||||||
final sender = await pc.addTrack(track, _localStream!);
|
await _disposeStream(stream);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_localStream = stream;
|
||||||
|
for (final track in stream.getTracks()) {
|
||||||
|
if (_ended || !identical(_pc, pc)) return;
|
||||||
|
final sender = await pc.addTrack(track, stream);
|
||||||
if (track.kind == 'audio') _audioSender = sender;
|
if (track.kind == 'audio') _audioSender = sender;
|
||||||
}
|
}
|
||||||
_applyAudioTracks();
|
_applyAudioTracks();
|
||||||
@@ -1136,7 +1168,7 @@ class CallSession {
|
|||||||
final id = _participantIdFrom(key.split(':').first);
|
final id = _participantIdFrom(key.split(':').first);
|
||||||
if (id != null) _slotParticipant[slot] = id;
|
if (id != null) _slotParticipant[slot] = id;
|
||||||
});
|
});
|
||||||
unawaited(_rebindSlotTracks());
|
_runCallTask(_rebindSlotTracks);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _rebindSlotTracks() async {
|
Future<void> _rebindSlotTracks() async {
|
||||||
@@ -1829,6 +1861,7 @@ class CallSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onRemoteTrack(RTCTrackEvent event) async {
|
Future<void> _onRemoteTrack(RTCTrackEvent event) async {
|
||||||
|
if (_ended) return;
|
||||||
logger.t(
|
logger.t(
|
||||||
'[call] remote track: ${event.track.kind} id=${event.track.id} '
|
'[call] remote track: ${event.track.kind} id=${event.track.id} '
|
||||||
'streams=${event.streams.length}',
|
'streams=${event.streams.length}',
|
||||||
@@ -1836,7 +1869,9 @@ class CallSession {
|
|||||||
await _bindParticipantTrack(event.track);
|
await _bindParticipantTrack(event.track);
|
||||||
if (event.streams.isNotEmpty) {
|
if (event.streams.isNotEmpty) {
|
||||||
_remoteStreamRef = event.streams.first;
|
_remoteStreamRef = event.streams.first;
|
||||||
_remoteStream.add(event.streams.first);
|
if (!_ended && !_remoteStream.isClosed) {
|
||||||
|
_remoteStream.add(event.streams.first);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
await _collectReceivers();
|
await _collectReceivers();
|
||||||
}
|
}
|
||||||
@@ -1887,7 +1922,7 @@ class CallSession {
|
|||||||
await stream.addTrack(track);
|
await stream.addTrack(track);
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
_remoteStream.add(stream);
|
if (!_ended && !_remoteStream.isClosed) _remoteStream.add(stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _logSenders() async {
|
Future<void> _logSenders() async {
|
||||||
@@ -1971,7 +2006,7 @@ class CallSession {
|
|||||||
|
|
||||||
Future<void> _onTransmittedData(Map<String, dynamic> msg) async {
|
Future<void> _onTransmittedData(Map<String, dynamic> msg) async {
|
||||||
final pc = _pc;
|
final pc = _pc;
|
||||||
if (pc == null) return;
|
if (pc == null || _ended) return;
|
||||||
|
|
||||||
final data = msg['data'];
|
final data = msg['data'];
|
||||||
if (data is! Map) return;
|
if (data is! Map) return;
|
||||||
@@ -2075,7 +2110,35 @@ class CallSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _runCallTask(Future<void> Function() operation) {
|
||||||
|
if (_ended) return;
|
||||||
|
unawaited(() async {
|
||||||
|
try {
|
||||||
|
await operation();
|
||||||
|
} catch (error, stack) {
|
||||||
|
if (!_ended) {
|
||||||
|
logger.w(
|
||||||
|
'[call] asynchronous operation failed',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}());
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
void bindCandidateSignalingForTesting(Ws2Signaling signaling, int peerId) {
|
||||||
|
_signaling = signaling;
|
||||||
|
_peerId = peerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
void receiveLocalCandidateForTesting(RTCIceCandidate candidate) =>
|
||||||
|
_onLocalCandidate(candidate);
|
||||||
|
|
||||||
void _onLocalCandidate(RTCIceCandidate candidate) {
|
void _onLocalCandidate(RTCIceCandidate candidate) {
|
||||||
|
if (_ended) return;
|
||||||
final line = candidate.candidate;
|
final line = candidate.candidate;
|
||||||
if (line != null && line.contains(' typ relay')) {
|
if (line != null && line.contains(' typ relay')) {
|
||||||
final done = _gatherDone;
|
final done = _gatherDone;
|
||||||
@@ -2083,15 +2146,24 @@ class CallSession {
|
|||||||
}
|
}
|
||||||
if (_topology == 'SERVER') return;
|
if (_topology == 'SERVER') return;
|
||||||
final peerId = _peerId;
|
final peerId = _peerId;
|
||||||
if (peerId == null || candidate.candidate == null) return;
|
final signaling = _signaling;
|
||||||
_signaling?.transmitCandidate(
|
if (peerId == null || line == null || signaling == null) return;
|
||||||
participantId: peerId,
|
_runCallTask(() async {
|
||||||
participantType: _peerType,
|
try {
|
||||||
deviceIdx: _peerDeviceIdx,
|
await signaling.transmitCandidate(
|
||||||
candidate: candidate.candidate!,
|
participantId: peerId,
|
||||||
sdpMid: candidate.sdpMid ?? '0',
|
participantType: _peerType,
|
||||||
sdpMLineIndex: candidate.sdpMLineIndex ?? 0,
|
deviceIdx: _peerDeviceIdx,
|
||||||
);
|
candidate: line,
|
||||||
|
sdpMid: candidate.sdpMid ?? '0',
|
||||||
|
sdpMLineIndex: candidate.sdpMLineIndex ?? 0,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (_ended || !identical(_signaling, signaling)) return;
|
||||||
|
logger.w('[call] ICE signaling interrupted: $error');
|
||||||
|
_onSignalingLost();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> accept({bool activate = true}) async {
|
Future<void> accept({bool activate = true}) async {
|
||||||
@@ -2316,35 +2388,63 @@ class CallSession {
|
|||||||
if (_ended) return;
|
if (_ended) return;
|
||||||
_ended = true;
|
_ended = true;
|
||||||
_setState(CallSessionState.ended);
|
_setState(CallSessionState.ended);
|
||||||
_dispose();
|
unawaited(
|
||||||
|
_dispose().catchError((Object error, StackTrace stack) {
|
||||||
|
logger.w('[call] cleanup failed', error: error, stackTrace: stack);
|
||||||
|
}),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _dispose() async {
|
Future<void> _dispose() async {
|
||||||
_levelTimer?.cancel();
|
_levelTimer?.cancel();
|
||||||
_videoStatsTimer?.cancel();
|
_videoStatsTimer?.cancel();
|
||||||
|
final pc = _pc;
|
||||||
|
_pc = null;
|
||||||
|
if (pc != null) {
|
||||||
|
pc.onIceCandidate = null;
|
||||||
|
pc.onTrack = null;
|
||||||
|
pc.onConnectionState = null;
|
||||||
|
pc.onIceConnectionState = null;
|
||||||
|
pc.onIceGatheringState = null;
|
||||||
|
pc.onDataChannel = null;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await _probeChannel?.close();
|
await _probeChannel?.close();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
_probeChannel = null;
|
_probeChannel = null;
|
||||||
await _closeSfuChannels();
|
try {
|
||||||
for (final track in _localStream?.getTracks() ?? <MediaStreamTrack>[]) {
|
await _closeSfuChannels();
|
||||||
await track.stop();
|
} catch (e) {
|
||||||
|
logger.w('[call] channel cleanup: $e');
|
||||||
}
|
}
|
||||||
await _localStream?.dispose();
|
await _disposeStream(_localStream);
|
||||||
|
_localStream = null;
|
||||||
await _disposeMicStream();
|
await _disposeMicStream();
|
||||||
await PulseAudio.closeBridge();
|
try {
|
||||||
|
await PulseAudio.closeBridge();
|
||||||
|
} catch (e) {
|
||||||
|
logger.w('[call] audio cleanup: $e');
|
||||||
|
}
|
||||||
await _disposeStream(_cameraStream);
|
await _disposeStream(_cameraStream);
|
||||||
await _disposeStream(_screenStream);
|
await _disposeStream(_screenStream);
|
||||||
_cameraStream = null;
|
_cameraStream = null;
|
||||||
_screenStream = null;
|
_screenStream = null;
|
||||||
await _pc?.close();
|
try {
|
||||||
|
await pc?.close();
|
||||||
|
} catch (e) {
|
||||||
|
logger.w('[call] peer cleanup: $e');
|
||||||
|
}
|
||||||
if (_ownRemoteStream) {
|
if (_ownRemoteStream) {
|
||||||
try {
|
try {
|
||||||
await _remoteStreamRef?.dispose();
|
await _remoteStreamRef?.dispose();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
await _clearParticipantStreams();
|
await _clearParticipantStreams();
|
||||||
await _signaling?.close();
|
try {
|
||||||
|
await _signaling?.close();
|
||||||
|
} catch (e) {
|
||||||
|
logger.w('[call] signaling cleanup: $e');
|
||||||
|
}
|
||||||
if (!_participantStreamUpdates.isClosed) {
|
if (!_participantStreamUpdates.isClosed) {
|
||||||
await _participantStreamUpdates.close();
|
await _participantStreamUpdates.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,6 +167,7 @@ class Ws2Signaling {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onDone(Object? error) {
|
void _onDone(Object? error) {
|
||||||
|
_call = null;
|
||||||
for (final waiter in _requests.values) {
|
for (final waiter in _requests.values) {
|
||||||
if (!waiter.isCompleted)
|
if (!waiter.isCompleted)
|
||||||
waiter.completeError(error ?? StateError('Звонок завершён'));
|
waiter.completeError(error ?? StateError('Звонок завершён'));
|
||||||
@@ -184,22 +185,24 @@ class Ws2Signaling {
|
|||||||
Duration timeout = const Duration(seconds: 15),
|
Duration timeout = const Duration(seconds: 15),
|
||||||
}) async {
|
}) async {
|
||||||
final call = _call;
|
final call = _call;
|
||||||
if (call == null) {
|
if (call == null ||
|
||||||
|
_closed.isCompleted ||
|
||||||
|
call.readyState != WebSocket.open) {
|
||||||
return Future.error(StateError('ws2 не подключён'));
|
return Future.error(StateError('ws2 не подключён'));
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
final sequence = ++_sequence;
|
final sequence = ++_sequence;
|
||||||
final waiter = Completer<Map<String, dynamic>>();
|
final waiter = Completer<Map<String, dynamic>>();
|
||||||
_requests[sequence] = waiter;
|
_requests[sequence] = waiter;
|
||||||
call.add(
|
|
||||||
jsonEncode({
|
|
||||||
'type': 'request',
|
|
||||||
'command': command,
|
|
||||||
'sequence': sequence,
|
|
||||||
...extra,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
|
call.add(
|
||||||
|
jsonEncode({
|
||||||
|
'type': 'request',
|
||||||
|
'command': command,
|
||||||
|
'sequence': sequence,
|
||||||
|
...extra,
|
||||||
|
}),
|
||||||
|
);
|
||||||
return await waiter.future.timeout(timeout);
|
return await waiter.future.timeout(timeout);
|
||||||
} finally {
|
} finally {
|
||||||
_requests.remove(sequence);
|
_requests.remove(sequence);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'persisted_setting.dart';
|
||||||
|
|
||||||
|
enum BottomNavigationStyle { floating, compact }
|
||||||
|
|
||||||
|
class AppBottomNavigationStyle {
|
||||||
|
static const prefKey = 'app_bottom_navigation_style';
|
||||||
|
|
||||||
|
static final _setting = PersistedEnum<BottomNavigationStyle>(
|
||||||
|
prefKey: prefKey,
|
||||||
|
defaultValue: BottomNavigationStyle.floating,
|
||||||
|
encode: (value) => value.name,
|
||||||
|
decode: (value) => enumFromName(
|
||||||
|
BottomNavigationStyle.values,
|
||||||
|
value,
|
||||||
|
BottomNavigationStyle.floating,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
static ValueNotifier<BottomNavigationStyle> get current => _setting.current;
|
||||||
|
|
||||||
|
static Future<BottomNavigationStyle> load() => _setting.load();
|
||||||
|
|
||||||
|
static Future<void> save(BottomNavigationStyle value) => _setting.save(value);
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import '../../models/shared_payload.dart';
|
||||||
|
|
||||||
|
/// Only one native share is presented at a time. A record is acknowledged
|
||||||
|
/// after its screen closes, and released after its uploads stop using files.
|
||||||
|
class ShareInbox {
|
||||||
|
ShareInbox({
|
||||||
|
required this.consume,
|
||||||
|
required this.acknowledge,
|
||||||
|
required this.release,
|
||||||
|
required this.onShare,
|
||||||
|
});
|
||||||
|
final Future<Object?> Function() consume;
|
||||||
|
final Future<void> Function(String) acknowledge;
|
||||||
|
final Future<void> Function(String) release;
|
||||||
|
final void Function(SharedPayload) onShare;
|
||||||
|
String? _activeId;
|
||||||
|
bool _completed = false;
|
||||||
|
bool _acknowledged = false;
|
||||||
|
Future<void> Function()? _waitForRelease;
|
||||||
|
bool _requested = false;
|
||||||
|
Future<void>? _operation;
|
||||||
|
|
||||||
|
Future<void> check() {
|
||||||
|
_requested = true;
|
||||||
|
return _operation ??= _drain().whenComplete(() => _operation = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> complete({Future<void> Function()? waitForRelease}) {
|
||||||
|
if (_activeId == null) return Future.value();
|
||||||
|
_completed = true;
|
||||||
|
_waitForRelease ??= waitForRelease;
|
||||||
|
return check();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _drain() async {
|
||||||
|
while (_requested) {
|
||||||
|
_requested = false;
|
||||||
|
final activeId = _activeId;
|
||||||
|
if (activeId != null) {
|
||||||
|
if (!_completed) return;
|
||||||
|
if (!_acknowledged) {
|
||||||
|
await acknowledge(activeId);
|
||||||
|
_acknowledged = true;
|
||||||
|
}
|
||||||
|
await _waitForRelease?.call();
|
||||||
|
await release(activeId);
|
||||||
|
_activeId = null;
|
||||||
|
_completed = _acknowledged = false;
|
||||||
|
_waitForRelease = null;
|
||||||
|
}
|
||||||
|
final raw = await consume();
|
||||||
|
if (raw is! Map) continue;
|
||||||
|
final id = raw['id'];
|
||||||
|
if (id is! String || id.isEmpty) continue;
|
||||||
|
final payload = SharedPayload.fromMap(raw);
|
||||||
|
if (payload == null) {
|
||||||
|
await acknowledge(id);
|
||||||
|
await release(id);
|
||||||
|
_requested = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
_activeId = id;
|
||||||
|
onShare(payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import '../../main.dart';
|
|||||||
import '../../models/shared_payload.dart';
|
import '../../models/shared_payload.dart';
|
||||||
import '../utils/logger.dart';
|
import '../utils/logger.dart';
|
||||||
import 'shared_file_uploads.dart';
|
import 'shared_file_uploads.dart';
|
||||||
|
import 'share_inbox.dart';
|
||||||
|
|
||||||
class ShareIntentBridge {
|
class ShareIntentBridge {
|
||||||
ShareIntentBridge._();
|
ShareIntentBridge._();
|
||||||
@@ -26,6 +27,13 @@ class ShareIntentBridge {
|
|||||||
SharedPayload? _pending;
|
SharedPayload? _pending;
|
||||||
int _retriesLeft = 0;
|
int _retriesLeft = 0;
|
||||||
Timer? _retry;
|
Timer? _retry;
|
||||||
|
late final _inbox = ShareInbox(
|
||||||
|
consume: () => _method.invokeMethod<dynamic>('consumeInitialShare'),
|
||||||
|
acknowledge: (id) =>
|
||||||
|
_method.invokeMethod<void>('acknowledgeShare', {'id': id}),
|
||||||
|
release: (id) => _method.invokeMethod<void>('releaseShare', {'id': id}),
|
||||||
|
onShare: _receivePayload,
|
||||||
|
);
|
||||||
|
|
||||||
bool get _native {
|
bool get _native {
|
||||||
try {
|
try {
|
||||||
@@ -55,7 +63,7 @@ class ShareIntentBridge {
|
|||||||
Future<void> checkInitialShare() async {
|
Future<void> checkInitialShare() async {
|
||||||
if (!_native) return;
|
if (!_native) return;
|
||||||
try {
|
try {
|
||||||
_onEvent(await _method.invokeMethod<dynamic>('consumeInitialShare'));
|
await _inbox.check();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.w('ShareIntentBridge.checkInitialShare: $e');
|
logger.w('ShareIntentBridge.checkInitialShare: $e');
|
||||||
}
|
}
|
||||||
@@ -73,8 +81,10 @@ class ShareIntentBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _onEvent(Object? event) {
|
void _onEvent(Object? event) {
|
||||||
final payload = SharedPayload.fromMap(event);
|
unawaited(checkInitialShare());
|
||||||
if (payload == null) return;
|
}
|
||||||
|
|
||||||
|
void _receivePayload(SharedPayload payload) {
|
||||||
logger.i(
|
logger.i(
|
||||||
'Поделиться: получено ${payload.files.length} файлов'
|
'Поделиться: получено ${payload.files.length} файлов'
|
||||||
'${payload.text != null ? ' и текст' : ''}',
|
'${payload.text != null ? ' и текст' : ''}',
|
||||||
@@ -91,7 +101,7 @@ class ShareIntentBridge {
|
|||||||
final context = QlyraApp.navigatorKey.currentContext;
|
final context = QlyraApp.navigatorKey.currentContext;
|
||||||
if (!_ready || context == null || api.state != SessionState.online) {
|
if (!_ready || context == null || api.state != SessionState.online) {
|
||||||
if (_retriesLeft <= 0) {
|
if (_retriesLeft <= 0) {
|
||||||
_pending = null;
|
// Keep the native record and payload until a session becomes ready.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_retriesLeft--;
|
_retriesLeft--;
|
||||||
@@ -108,9 +118,17 @@ class ShareIntentBridge {
|
|||||||
pushSwipeable<void>(
|
pushSwipeable<void>(
|
||||||
context,
|
context,
|
||||||
(_) => ChatListScreen(sharePayload: payload),
|
(_) => ChatListScreen(sharePayload: payload),
|
||||||
).whenComplete(() {
|
).whenComplete(() async {
|
||||||
_presenting = false;
|
_presenting = false;
|
||||||
unawaited(clearCache(payload));
|
try {
|
||||||
|
await _inbox.complete(
|
||||||
|
waitForRelease: () => SharedFileUploads.instance.waitUntilIdle(
|
||||||
|
payload.files.map((file) => file.path).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
logger.w('ShareIntentBridge.complete: $e');
|
||||||
|
}
|
||||||
_flushPending();
|
_flushPending();
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -322,7 +322,11 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
|
|||||||
void _onState(CallSessionState state) {
|
void _onState(CallSessionState state) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _state = state);
|
setState(() => _state = state);
|
||||||
if (state == CallSessionState.ended) _close();
|
if (state == CallSessionState.ended) {
|
||||||
|
final failure = _session?.failureReason;
|
||||||
|
if (failure != null) showCustomNotification(context, failure);
|
||||||
|
_close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _accept() async {
|
Future<void> _accept() async {
|
||||||
|
|||||||
@@ -202,12 +202,13 @@ class ChatController extends ChangeNotifier {
|
|||||||
Future<bool> loadMessageWindow({
|
Future<bool> loadMessageWindow({
|
||||||
required String targetId,
|
required String targetId,
|
||||||
required int targetTime,
|
required int targetTime,
|
||||||
|
bool Function()? stillWanted,
|
||||||
}) async {
|
}) async {
|
||||||
if (myId == 0 || targetTime <= 0) return false;
|
if (myId == 0 || targetTime <= 0) return false;
|
||||||
final onlyVisible = !QlyraSettings.viewDeleted.value;
|
final onlyVisible = !QlyraSettings.viewDeleted.value;
|
||||||
|
|
||||||
var window = await loadWindowFromDb(targetTime, onlyVisible);
|
var window = await loadWindowFromDb(targetTime, onlyVisible);
|
||||||
if (!isMounted()) return false;
|
if (!isMounted() || stillWanted?.call() == false) return false;
|
||||||
|
|
||||||
if (!window.any((m) => m.id == targetId)) {
|
if (!window.any((m) => m.id == targetId)) {
|
||||||
final fetched = await messagesModule.fetchHistory(
|
final fetched = await messagesModule.fetchHistory(
|
||||||
@@ -217,12 +218,12 @@ class ChatController extends ChangeNotifier {
|
|||||||
forward: jumpWindowAfter,
|
forward: jumpWindowAfter,
|
||||||
backward: jumpWindowBefore + 1,
|
backward: jumpWindowBefore + 1,
|
||||||
);
|
);
|
||||||
if (!isMounted()) return false;
|
if (!isMounted() || stillWanted?.call() == false) return false;
|
||||||
if (fetched.isNotEmpty && QlyraSettings.viewDeleted.value) {
|
if (fetched.isNotEmpty && QlyraSettings.viewDeleted.value) {
|
||||||
await chats.reconcileDeletedFromFetch(myId, chatId, fetched);
|
await chats.reconcileDeletedFromFetch(myId, chatId, fetched);
|
||||||
}
|
}
|
||||||
window = await loadWindowFromDb(targetTime, onlyVisible);
|
window = await loadWindowFromDb(targetTime, onlyVisible);
|
||||||
if (!isMounted()) return false;
|
if (!isMounted() || stillWanted?.call() == false) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.isEmpty) return false;
|
if (window.isEmpty) return false;
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import '../../widgets/sheet_helpers.dart';
|
|||||||
import '../../widgets/swipe_route.dart';
|
import '../../widgets/swipe_route.dart';
|
||||||
import '../../widgets/toast_placement.dart';
|
import '../../widgets/toast_placement.dart';
|
||||||
import '../../widgets/sliding_pill_nav.dart';
|
import '../../widgets/sliding_pill_nav.dart';
|
||||||
|
import '../../widgets/compact_bottom_navigation_bar.dart';
|
||||||
|
import '../../../core/config/app_bottom_navigation_style.dart';
|
||||||
import '../../widgets/springy_tap.dart';
|
import '../../widgets/springy_tap.dart';
|
||||||
import '../../widgets/visible_page_tickers.dart';
|
import '../../widgets/visible_page_tickers.dart';
|
||||||
import '../../widgets/informer_banner_tile.dart';
|
import '../../widgets/informer_banner_tile.dart';
|
||||||
@@ -2472,92 +2474,123 @@ class _ChatListScreenState extends State<ChatListScreen>
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
return AnimatedPositioned(
|
return ValueListenableBuilder<BottomNavigationStyle>(
|
||||||
duration: const Duration(milliseconds: 300),
|
valueListenable: AppBottomNavigationStyle.current,
|
||||||
curve: Curves.easeOutCubic,
|
builder: (context, style, _) {
|
||||||
left: 8,
|
if (style == BottomNavigationStyle.compact) {
|
||||||
right: 8,
|
return Positioned(
|
||||||
bottom: _isSelectionMode ? -100 : bottomInset + 10.0,
|
left: 0,
|
||||||
child: RepaintBoundary(
|
right: 0,
|
||||||
child: GestureDetector(
|
bottom: _isSelectionMode ? -150 : 0,
|
||||||
behavior: HitTestBehavior.opaque,
|
child: CompactBottomNavigationBar(
|
||||||
onHorizontalDragStart: (_) {
|
items: [
|
||||||
if (_isSelectionMode) return;
|
PillNavItem(
|
||||||
_navPageAnimController.stop();
|
icon: _chatsNavItems(
|
||||||
_navPageAnimController.value = 1.0;
|
AppLocalizations.of(context)!,
|
||||||
_navDragDx.value = 0;
|
).first.icon,
|
||||||
setState(() {
|
label: _chatsNavItems(
|
||||||
_navDragging = true;
|
AppLocalizations.of(context)!,
|
||||||
_navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex);
|
).first.label,
|
||||||
});
|
unreadCount: countUnreadChats(_folderSourceChats),
|
||||||
},
|
),
|
||||||
onHorizontalDragUpdate: (details) {
|
..._chatsNavItems(AppLocalizations.of(context)!).skip(1),
|
||||||
if (!_navDragging) return;
|
],
|
||||||
_navDragDx.value += details.delta.dx;
|
currentIndex: _currentNavIndex,
|
||||||
},
|
onTap: _onNavTabSelected,
|
||||||
onHorizontalDragEnd: (_) {
|
onItemLongPress: (index, position) {
|
||||||
if (!_navDragging) return;
|
if (index == 3) _openAccountSwitcher(position);
|
||||||
final left = (_navDragBaseLeft + _navDragDx.value).clamp(
|
},
|
||||||
minBubbleLeft,
|
),
|
||||||
maxBubbleLeft,
|
);
|
||||||
);
|
}
|
||||||
final next = indexForBubbleLeft(left);
|
return AnimatedPositioned(
|
||||||
_navDragDx.value = 0;
|
duration: const Duration(milliseconds: 300),
|
||||||
setState(() {
|
curve: Curves.easeOutCubic,
|
||||||
_currentNavIndex = next;
|
left: 8,
|
||||||
_navDragging = false;
|
right: 8,
|
||||||
});
|
bottom: _isSelectionMode ? -100 : bottomInset + 10.0,
|
||||||
activeNavTab.value = next;
|
child: RepaintBoundary(
|
||||||
if (next == 0) _scheduleInformerPresentation();
|
child: GestureDetector(
|
||||||
},
|
behavior: HitTestBehavior.opaque,
|
||||||
onHorizontalDragCancel: () {
|
onHorizontalDragStart: (_) {
|
||||||
if (!_navDragging) return;
|
if (_isSelectionMode) return;
|
||||||
_navDragDx.value = 0;
|
_navPageAnimController.stop();
|
||||||
setState(() {
|
_navPageAnimController.value = 1.0;
|
||||||
_navDragging = false;
|
_navDragDx.value = 0;
|
||||||
});
|
setState(() {
|
||||||
},
|
_navDragging = true;
|
||||||
child: ValueListenableBuilder<double>(
|
_navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex);
|
||||||
valueListenable: _navDragDx,
|
});
|
||||||
builder: (context, navDragDx, _) {
|
},
|
||||||
final position = _navDragging
|
onHorizontalDragUpdate: (details) {
|
||||||
? ((_navDragBaseLeft + navDragDx).clamp(
|
if (!_navDragging) return;
|
||||||
minBubbleLeft,
|
_navDragDx.value += details.delta.dx;
|
||||||
maxBubbleLeft,
|
},
|
||||||
) -
|
onHorizontalDragEnd: (_) {
|
||||||
4) /
|
if (!_navDragging) return;
|
||||||
inactiveWidth
|
final left = (_navDragBaseLeft + _navDragDx.value).clamp(
|
||||||
: _currentNavIndex.toDouble();
|
minBubbleLeft,
|
||||||
return SlidingPillNav(
|
maxBubbleLeft,
|
||||||
items: [
|
);
|
||||||
PillNavItem(
|
final next = indexForBubbleLeft(left);
|
||||||
icon: _chatsNavItems(
|
_navDragDx.value = 0;
|
||||||
AppLocalizations.of(context)!,
|
setState(() {
|
||||||
).first.icon,
|
_currentNavIndex = next;
|
||||||
label: _chatsNavItems(
|
_navDragging = false;
|
||||||
AppLocalizations.of(context)!,
|
});
|
||||||
).first.label,
|
activeNavTab.value = next;
|
||||||
unreadCount: countUnreadChats(_folderSourceChats),
|
if (next == 0) _scheduleInformerPresentation();
|
||||||
),
|
},
|
||||||
..._chatsNavItems(AppLocalizations.of(context)!).skip(1),
|
onHorizontalDragCancel: () {
|
||||||
],
|
if (!_navDragging) return;
|
||||||
position: position,
|
_navDragDx.value = 0;
|
||||||
animationDuration: _navDragging
|
setState(() {
|
||||||
? Duration.zero
|
_navDragging = false;
|
||||||
: const Duration(milliseconds: 350),
|
});
|
||||||
geometry: geometry,
|
},
|
||||||
iconSize: 20,
|
child: ValueListenableBuilder<double>(
|
||||||
labelGap: 4,
|
valueListenable: _navDragDx,
|
||||||
backdropKey: _frostBackdrop,
|
builder: (context, navDragDx, _) {
|
||||||
onTap: _onNavTabSelected,
|
final position = _navDragging
|
||||||
onItemLongPress: (index, pos) {
|
? ((_navDragBaseLeft + navDragDx).clamp(
|
||||||
if (index == 3) _openAccountSwitcher(pos);
|
minBubbleLeft,
|
||||||
|
maxBubbleLeft,
|
||||||
|
) -
|
||||||
|
4) /
|
||||||
|
inactiveWidth
|
||||||
|
: _currentNavIndex.toDouble();
|
||||||
|
return SlidingPillNav(
|
||||||
|
items: [
|
||||||
|
PillNavItem(
|
||||||
|
icon: _chatsNavItems(
|
||||||
|
AppLocalizations.of(context)!,
|
||||||
|
).first.icon,
|
||||||
|
label: _chatsNavItems(
|
||||||
|
AppLocalizations.of(context)!,
|
||||||
|
).first.label,
|
||||||
|
unreadCount: countUnreadChats(_folderSourceChats),
|
||||||
|
),
|
||||||
|
..._chatsNavItems(AppLocalizations.of(context)!).skip(1),
|
||||||
|
],
|
||||||
|
position: position,
|
||||||
|
animationDuration: _navDragging
|
||||||
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 350),
|
||||||
|
geometry: geometry,
|
||||||
|
iconSize: 20,
|
||||||
|
labelGap: 4,
|
||||||
|
backdropKey: _frostBackdrop,
|
||||||
|
onTap: _onNavTabSelected,
|
||||||
|
onItemLongPress: (index, pos) {
|
||||||
|
if (index == 3) _openAccountSwitcher(pos);
|
||||||
|
},
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
),
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import '../../../backend/modules/pending_message_deletions.dart';
|
||||||
import 'package:ikar/frontend/widgets/toast_placement.dart';
|
import 'package:ikar/frontend/widgets/toast_placement.dart';
|
||||||
import 'package:ikar/frontend/widgets/hint_bubble.dart';
|
import 'package:ikar/frontend/widgets/hint_bubble.dart';
|
||||||
import 'chat/view/message_row_widgets.dart' show SelectableMessageRow;
|
import 'chat/view/message_row_widgets.dart' show SelectableMessageRow;
|
||||||
@@ -916,6 +917,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
unawaited(_loadPeerKind());
|
unawaited(_loadPeerKind());
|
||||||
unawaited(_loadWallpaper());
|
unawaited(_loadWallpaper());
|
||||||
unawaited(_loadReactionSettings());
|
unawaited(_loadReactionSettings());
|
||||||
|
PendingMessageDeletions.instance.addListener(_pendingRemovalsChanged);
|
||||||
_forwardedSub = ForwardSender.delivered.listen(_onForwardedHere);
|
_forwardedSub = ForwardSender.delivered.listen(_onForwardedHere);
|
||||||
_departedSub = chats.departedChats.listen((id) {
|
_departedSub = chats.departedChats.listen((id) {
|
||||||
if (!mounted || id != widget.chatId) return;
|
if (!mounted || id != widget.chatId) return;
|
||||||
@@ -2016,11 +2018,17 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadMessageWindow(String messageId, int targetTime) async {
|
Future<void> _loadMessageWindow(
|
||||||
|
String messageId,
|
||||||
|
int targetTime, {
|
||||||
|
bool Function()? stillWanted,
|
||||||
|
}) async {
|
||||||
if (targetTime <= 0) {
|
if (targetTime <= 0) {
|
||||||
await _walkHistoryBack(
|
await _walkHistoryBack(
|
||||||
reached: () => _messages.any((m) => m.id == messageId),
|
reached: () =>
|
||||||
maxPages: 10,
|
stillWanted?.call() == false ||
|
||||||
|
_messages.any((m) => m.id == messageId),
|
||||||
|
maxPages: 100,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2030,6 +2038,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
await _chatController.loadMessageWindow(
|
await _chatController.loadMessageWindow(
|
||||||
targetId: messageId,
|
targetId: messageId,
|
||||||
targetTime: targetTime,
|
targetTime: targetTime,
|
||||||
|
stillWanted: stillWanted,
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
_historyAutoloadSuppressCount--;
|
_historyAutoloadSuppressCount--;
|
||||||
@@ -2424,6 +2433,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
_detachUploadStatus();
|
_detachUploadStatus();
|
||||||
_pushSub?.cancel();
|
_pushSub?.cancel();
|
||||||
_reactionUpdatesSub?.cancel();
|
_reactionUpdatesSub?.cancel();
|
||||||
|
PendingMessageDeletions.instance.removeListener(_pendingRemovalsChanged);
|
||||||
_forwardedSub?.cancel();
|
_forwardedSub?.cancel();
|
||||||
_departedSub?.cancel();
|
_departedSub?.cancel();
|
||||||
_messageEventSub?.cancel();
|
_messageEventSub?.cancel();
|
||||||
@@ -2762,39 +2772,70 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
if (forEveryone == null || !mounted) return;
|
if (forEveryone == null || !mounted) return;
|
||||||
|
|
||||||
_clearSelection();
|
_clearSelection();
|
||||||
|
for (final message in msgs.where((m) => m.id.startsWith('temp_'))) {
|
||||||
|
_startDeleteAnimation(message.id);
|
||||||
|
}
|
||||||
|
_removeMessagesWithUndo(serverMsgs, forEveryone: forEveryone);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pendingRemovalsChanged() {
|
||||||
|
if (mounted) _bumpMessages();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _removeMessagesWithUndo(
|
||||||
|
List<CachedMessage> messages, {
|
||||||
|
required bool forEveryone,
|
||||||
|
}) {
|
||||||
final accountId = _myId;
|
final accountId = _myId;
|
||||||
|
final chatId = widget.chatId;
|
||||||
|
final ids = messages.map((message) => message.id).toList();
|
||||||
|
final pending = PendingMessageDeletions.instance;
|
||||||
|
if (ids.any((id) => pending.contains(accountId, chatId, id))) return;
|
||||||
|
pending.hold(accountId, chatId, ids);
|
||||||
|
final overlay = Overlay.of(context, rootOverlay: true);
|
||||||
|
void restore() => pending.release(accountId, chatId, ids);
|
||||||
showUndoNotification(
|
showUndoNotification(
|
||||||
context,
|
context,
|
||||||
'Удаление сообщений через 5 секунд',
|
messages.length == 1 ? 'Сообщение удалено' : 'Сообщения удалены',
|
||||||
onUndo: () {},
|
onUndo: restore,
|
||||||
onCommit: () async {
|
onCommit: () async {
|
||||||
if ((await AppDatabase.loadActiveProfile())?.id != accountId) return;
|
try {
|
||||||
final ok = await messagesModule.deleteMessages(
|
if ((await AppDatabase.loadActiveProfile())?.id != accountId) return;
|
||||||
widget.chatId,
|
final ok = await messagesModule.deleteMessages(
|
||||||
serverMsgs.map((m) => m.id).toList(),
|
chatId,
|
||||||
forEveryone: forEveryone,
|
ids,
|
||||||
);
|
forEveryone: forEveryone,
|
||||||
if (ok) {
|
);
|
||||||
for (final message in serverMsgs) {
|
if (!ok) {
|
||||||
await AppDatabase.deleteMessage(
|
if (overlay.mounted) {
|
||||||
accountId,
|
showCustomNotificationOnOverlay(
|
||||||
widget.chatId,
|
overlay,
|
||||||
message.id,
|
'Не удалось удалить сообщения',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (final id in ids) {
|
||||||
|
await AppDatabase.deleteMessage(accountId, chatId, id);
|
||||||
|
}
|
||||||
|
MessageSessionCache.remove(accountId, chatId);
|
||||||
|
await chats.reconcileLastMessage(accountId, chatId);
|
||||||
|
if (mounted && _myId == accountId) {
|
||||||
|
_messages.removeWhere((message) => ids.contains(message.id));
|
||||||
|
for (final id in ids) {
|
||||||
|
_reactionNotifiers.remove(id)?.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (overlay.mounted) {
|
||||||
|
showCustomNotificationOnOverlay(
|
||||||
|
overlay,
|
||||||
|
'Не удалось удалить сообщения',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
MessageSessionCache.remove(accountId, widget.chatId);
|
} finally {
|
||||||
await chats.reconcileLastMessage(accountId, widget.chatId);
|
restore();
|
||||||
}
|
}
|
||||||
if (!mounted) return;
|
|
||||||
if (!ok) {
|
|
||||||
Haptics.error();
|
|
||||||
showCustomNotification(context, 'Не удалось удалить сообщения');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (final m in msgs) {
|
|
||||||
_startDeleteAnimation(m.id);
|
|
||||||
}
|
|
||||||
_clearSelection();
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -3240,6 +3281,11 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _confirmDeleteMessage(String messageId, bool isMe) async {
|
Future<void> _confirmDeleteMessage(String messageId, bool isMe) async {
|
||||||
|
final messageIndex = _messages.indexWhere(
|
||||||
|
(message) => message.id == messageId,
|
||||||
|
);
|
||||||
|
if (messageIndex == -1) return;
|
||||||
|
final message = _messages[messageIndex];
|
||||||
final isLocalOnly = messageId.startsWith('temp_');
|
final isLocalOnly = messageId.startsWith('temp_');
|
||||||
final canForEveryone = (isMe || _canDeleteOthers) && !isLocalOnly;
|
final canForEveryone = (isMe || _canDeleteOthers) && !isLocalOnly;
|
||||||
|
|
||||||
@@ -3251,30 +3297,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
final forEveryone = await _showDeleteMessageDialog(canForEveryone);
|
final forEveryone = await _showDeleteMessageDialog(canForEveryone);
|
||||||
if (forEveryone == null || !mounted) return;
|
if (forEveryone == null || !mounted) return;
|
||||||
|
|
||||||
final accountId = _myId;
|
_removeMessagesWithUndo([message], forEveryone: forEveryone);
|
||||||
showUndoNotification(
|
|
||||||
context,
|
|
||||||
'Удаление сообщения через 5 секунд',
|
|
||||||
onUndo: () {},
|
|
||||||
onCommit: () async {
|
|
||||||
if ((await AppDatabase.loadActiveProfile())?.id != accountId) return;
|
|
||||||
final ok = await messagesModule.deleteMessages(widget.chatId, [
|
|
||||||
messageId,
|
|
||||||
], forEveryone: forEveryone);
|
|
||||||
if (ok) {
|
|
||||||
await AppDatabase.deleteMessage(accountId, widget.chatId, messageId);
|
|
||||||
MessageSessionCache.remove(accountId, widget.chatId);
|
|
||||||
await chats.reconcileLastMessage(accountId, widget.chatId);
|
|
||||||
}
|
|
||||||
if (!mounted) return;
|
|
||||||
if (!ok) {
|
|
||||||
Haptics.error();
|
|
||||||
showCustomNotification(context, 'Не удалось удалить сообщение');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_startDeleteAnimation(messageId);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startDeleteAnimation(String messageId) {
|
void _startDeleteAnimation(String messageId) {
|
||||||
@@ -4915,18 +4938,45 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _jumpToMessage(String messageId, {String? fromId}) {
|
int _messageNavigationToken = 0;
|
||||||
final index = _messages.indexWhere((m) => m.id == messageId);
|
|
||||||
if (index == -1) {
|
|
||||||
showCustomNotification(context, 'Сообщение не загружено');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
Future<void> _jumpToMessage(String messageId, {String? fromId}) async {
|
||||||
|
final token = ++_messageNavigationToken;
|
||||||
|
final gesture = _userGestureEpoch;
|
||||||
|
bool current() =>
|
||||||
|
mounted &&
|
||||||
|
token == _messageNavigationToken &&
|
||||||
|
gesture == _userGestureEpoch;
|
||||||
if (fromId != null) _pushReturnAnchor(fromId);
|
if (fromId != null) _pushReturnAnchor(fromId);
|
||||||
|
if (!_messages.any((message) => message.id == messageId)) {
|
||||||
|
try {
|
||||||
|
final target = await messagesModule.fetchMessage(
|
||||||
|
_myId,
|
||||||
|
widget.chatId,
|
||||||
|
messageId,
|
||||||
|
);
|
||||||
|
if (!mounted || !current()) return;
|
||||||
|
if (target == null) {
|
||||||
|
showCustomNotification(context, 'Сообщение не найдено');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _loadMessageWindow(messageId, target.time, stillWanted: current);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted && current()) {
|
||||||
|
showCustomNotification(context, 'Не удалось загрузить сообщение');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!mounted || !current()) return;
|
||||||
|
if (!_messages.any((message) => message.id == messageId)) {
|
||||||
|
showCustomNotification(context, 'Сообщение не найдено');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final key = _keyForMessage(messageId);
|
final key = _keyForMessage(messageId);
|
||||||
final ctx = key.currentContext;
|
final ctx = key.currentContext;
|
||||||
if (ctx != null) {
|
if (ctx != null && ctx.mounted) {
|
||||||
Scrollable.ensureVisible(
|
Scrollable.ensureVisible(
|
||||||
ctx,
|
ctx,
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
@@ -4934,7 +4984,12 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
alignment: 0.4,
|
alignment: 0.4,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
unawaited(_scrollToMessagePrecise(messageId, alignment: 0.4));
|
await _scrollToMessagePrecise(
|
||||||
|
messageId,
|
||||||
|
alignment: 0.4,
|
||||||
|
stillWanted: current,
|
||||||
|
);
|
||||||
|
if (!mounted || !current()) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_highlightTimer?.cancel();
|
_highlightTimer?.cancel();
|
||||||
@@ -4986,14 +5041,20 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _runGoToMessage(String id, int targetTime) async {
|
Future<void> _runGoToMessage(String id, int targetTime) async {
|
||||||
|
final token = ++_messageNavigationToken;
|
||||||
|
final epoch = _userGestureEpoch;
|
||||||
|
bool current() =>
|
||||||
|
mounted &&
|
||||||
|
token == _messageNavigationToken &&
|
||||||
|
epoch == _userGestureEpoch;
|
||||||
await WidgetsBinding.instance.endOfFrame;
|
await WidgetsBinding.instance.endOfFrame;
|
||||||
if (!mounted) return;
|
if (!current()) return;
|
||||||
|
|
||||||
if (!_messages.any((m) => m.id == id)) {
|
if (!_messages.any((m) => m.id == id)) {
|
||||||
await _loadMessageWindow(id, targetTime);
|
await _loadMessageWindow(id, targetTime, stillWanted: current);
|
||||||
if (!mounted) return;
|
if (!current()) return;
|
||||||
await WidgetsBinding.instance.endOfFrame;
|
await WidgetsBinding.instance.endOfFrame;
|
||||||
if (!mounted) return;
|
if (!current()) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_messages.any((m) => m.id == id)) {
|
if (!_messages.any((m) => m.id == id)) {
|
||||||
@@ -5009,8 +5070,8 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
if (_highlightMessageId.value == id) _highlightMessageId.value = null;
|
if (_highlightMessageId.value == id) _highlightMessageId.value = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
await _scrollToMessagePrecise(id);
|
await _scrollToMessagePrecise(id, stillWanted: current);
|
||||||
_finishTargetNavigation();
|
if (token == _messageNavigationToken && mounted) _finishTargetNavigation();
|
||||||
}
|
}
|
||||||
|
|
||||||
({int min, int max})? _laidOutMessageRange(List<Object> items) {
|
({int min, int max})? _laidOutMessageRange(List<Object> items) {
|
||||||
@@ -5034,6 +5095,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
Future<void> _scrollToMessagePrecise(
|
Future<void> _scrollToMessagePrecise(
|
||||||
String id, {
|
String id, {
|
||||||
double alignment = 0.32,
|
double alignment = 0.32,
|
||||||
|
bool Function()? stillWanted,
|
||||||
}) async {
|
}) async {
|
||||||
if (!mounted || !_scrollController.hasClients) return;
|
if (!mounted || !_scrollController.hasClients) return;
|
||||||
if (_messages.indexWhere((m) => m.id == id) == -1) return;
|
if (_messages.indexWhere((m) => m.id == id) == -1) return;
|
||||||
@@ -5050,7 +5112,7 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
var stable = 0;
|
var stable = 0;
|
||||||
for (var iter = 0; iter < 120; iter++) {
|
for (var iter = 0; iter < 120; iter++) {
|
||||||
if (!mounted || !_scrollController.hasClients) return;
|
if (!mounted || !_scrollController.hasClients) return;
|
||||||
if (_userGestureEpoch != epoch) return;
|
if (_userGestureEpoch != epoch || stillWanted?.call() == false) return;
|
||||||
final listObj = _listKey.currentContext?.findRenderObject();
|
final listObj = _listKey.currentContext?.findRenderObject();
|
||||||
final boxObj = _keyForMessage(id).currentContext?.findRenderObject();
|
final boxObj = _keyForMessage(id).currentContext?.findRenderObject();
|
||||||
final p = _scrollController.position;
|
final p = _scrollController.position;
|
||||||
@@ -5382,6 +5444,13 @@ class _ChatScreenState extends State<ChatScreen>
|
|||||||
|
|
||||||
for (int i = 0; i < visible; i++) {
|
for (int i = 0; i < visible; i++) {
|
||||||
final msg = _messages[i];
|
final msg = _messages[i];
|
||||||
|
if (PendingMessageDeletions.instance.contains(
|
||||||
|
_myId,
|
||||||
|
widget.chatId,
|
||||||
|
msg.id,
|
||||||
|
)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
final msgDate = DateTime.fromMillisecondsSinceEpoch(msg.time);
|
final msgDate = DateTime.fromMillisecondsSinceEpoch(msg.time);
|
||||||
final dayMillis = DateTime(
|
final dayMillis = DateTime(
|
||||||
msgDate.year,
|
msgDate.year,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import '../../../l10n/app_localizations.dart';
|
|||||||
import '../../../main.dart';
|
import '../../../main.dart';
|
||||||
import '../../widgets/liquid_glass.dart';
|
import '../../widgets/liquid_glass.dart';
|
||||||
import '../../widgets/settings_card.dart';
|
import '../../widgets/settings_card.dart';
|
||||||
|
import '../../widgets/bottom_navigation_style_card.dart';
|
||||||
import '../../../core/config/app_shape.dart';
|
import '../../../core/config/app_shape.dart';
|
||||||
import '../../../core/utils/seed_scheme.dart';
|
import '../../../core/utils/seed_scheme.dart';
|
||||||
|
|
||||||
@@ -124,6 +125,8 @@ class _AppearanceScreenState extends State<AppearanceScreen> {
|
|||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const _VisualStyleCard(),
|
const _VisualStyleCard(),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
|
const BottomNavigationStyleCard(),
|
||||||
|
const SizedBox(height: 12),
|
||||||
const _ChatChromeCard(),
|
const _ChatChromeCard(),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
const _ComposerBarCard(),
|
const _ComposerBarCard(),
|
||||||
|
|||||||
@@ -27,21 +27,52 @@ import '../../share_unopenable_file.dart';
|
|||||||
import '../../upload_progress_ring.dart';
|
import '../../upload_progress_ring.dart';
|
||||||
import 'bubble_context.dart';
|
import 'bubble_context.dart';
|
||||||
|
|
||||||
class FileBubble extends StatelessWidget {
|
typedef FileBubbleOpener =
|
||||||
static const double _previewWidth = 240;
|
Future<FileDownloadResult> Function(
|
||||||
static const double _previewHeight = 160;
|
String cacheName,
|
||||||
|
Future<String?> Function() resolveUrl, {
|
||||||
|
void Function(double)? onProgress,
|
||||||
|
void Function()? onReady,
|
||||||
|
DownloadMetadata? download,
|
||||||
|
bool downloadOnlyIfMissing,
|
||||||
|
});
|
||||||
|
|
||||||
|
class FileBubble extends StatefulWidget {
|
||||||
final BubbleContext ctx;
|
final BubbleContext ctx;
|
||||||
final FileAttachment file;
|
final FileAttachment file;
|
||||||
final bool fill;
|
final bool fill;
|
||||||
|
final FileBubbleOpener openFile;
|
||||||
|
|
||||||
const FileBubble({
|
const FileBubble({
|
||||||
super.key,
|
super.key,
|
||||||
required this.ctx,
|
required this.ctx,
|
||||||
required this.file,
|
required this.file,
|
||||||
this.fill = false,
|
this.fill = false,
|
||||||
|
this.openFile = openCachedFile,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FileBubble> createState() => _FileBubbleState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FileBubbleState extends State<FileBubble> {
|
||||||
|
static const double _previewWidth = 240;
|
||||||
|
static const double _previewHeight = 160;
|
||||||
|
BubbleContext get ctx => widget.ctx;
|
||||||
|
FileAttachment get file => widget.file;
|
||||||
|
bool get fill => widget.fill;
|
||||||
|
bool _opening = false;
|
||||||
|
|
||||||
|
Future<void> _openOnce(Future<void> Function() open) async {
|
||||||
|
if (_opening || ctx.uploadProgress != null) return;
|
||||||
|
setState(() => _opening = true);
|
||||||
|
try {
|
||||||
|
await open();
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _opening = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
@@ -124,7 +155,11 @@ class FileBubble extends StatelessWidget {
|
|||||||
builder: (context, progress, _) => Text(
|
builder: (context, progress, _) => Text(
|
||||||
progress != null
|
progress != null
|
||||||
? '${(progress * 100).round()}% · $sizeStr'
|
? '${(progress * 100).round()}% · $sizeStr'
|
||||||
: sizeStr,
|
: '${_opening
|
||||||
|
? 'Открытие…'
|
||||||
|
: MediaCache.presence(cacheName).value
|
||||||
|
? 'Открыть'
|
||||||
|
: 'Скачать'} · $sizeStr',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: ctx.dim,
|
color: ctx.dim,
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
@@ -144,7 +179,7 @@ class FileBubble extends StatelessWidget {
|
|||||||
: ctx.cs.primary;
|
: ctx.cs.primary;
|
||||||
Widget circle(Widget child, VoidCallback? onTap) {
|
Widget circle(Widget child, VoidCallback? onTap) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: onTap,
|
onTap: _opening ? null : onTap,
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 34,
|
width: 34,
|
||||||
height: 34,
|
height: 34,
|
||||||
@@ -159,13 +194,15 @@ class FileBubble extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (progress != null) {
|
if (progress != null || _opening) {
|
||||||
return circle(
|
return circle(
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
value: progress > 0 ? progress : null,
|
value: progress != null && progress > 0
|
||||||
|
? progress
|
||||||
|
: null,
|
||||||
color: iconColor,
|
color: iconColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -389,7 +426,7 @@ class FileBubble extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openInViewer(
|
Future<void> _openInViewerInner(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
String name,
|
String name,
|
||||||
String cacheName,
|
String cacheName,
|
||||||
@@ -498,6 +535,22 @@ class FileBubble extends StatelessWidget {
|
|||||||
BuildContext context,
|
BuildContext context,
|
||||||
FileAttachment file,
|
FileAttachment file,
|
||||||
String name,
|
String name,
|
||||||
|
) => _openOnce(() => _downloadFileInner(context, file, name));
|
||||||
|
Future<void> _playAudioFile(
|
||||||
|
BuildContext context,
|
||||||
|
FileAttachment file,
|
||||||
|
String name,
|
||||||
|
) => _openOnce(() => _playAudioFileInner(context, file, name));
|
||||||
|
Future<void> _openInViewer(
|
||||||
|
BuildContext context,
|
||||||
|
String name,
|
||||||
|
String cacheName,
|
||||||
|
) => _openOnce(() => _openInViewerInner(context, name, cacheName));
|
||||||
|
|
||||||
|
Future<void> _downloadFileInner(
|
||||||
|
BuildContext context,
|
||||||
|
FileAttachment file,
|
||||||
|
String name,
|
||||||
) async {
|
) async {
|
||||||
final fileId = file.fileId;
|
final fileId = file.fileId;
|
||||||
if (fileId == null) {
|
if (fileId == null) {
|
||||||
@@ -514,7 +567,7 @@ class FileBubble extends StatelessWidget {
|
|||||||
final kind = downloadKindForName(name);
|
final kind = downloadKindForName(name);
|
||||||
|
|
||||||
MediaDownloadProgress.set(cacheName, 0);
|
MediaDownloadProgress.set(cacheName, 0);
|
||||||
final result = await openCachedFile(
|
final result = await widget.openFile(
|
||||||
cacheName,
|
cacheName,
|
||||||
_fileUrl,
|
_fileUrl,
|
||||||
downloadOnlyIfMissing: true,
|
downloadOnlyIfMissing: true,
|
||||||
@@ -553,7 +606,7 @@ class FileBubble extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _playAudioFile(
|
Future<void> _playAudioFileInner(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
FileAttachment file,
|
FileAttachment file,
|
||||||
String name,
|
String name,
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../core/config/app_bottom_navigation_style.dart';
|
||||||
|
import '../../core/utils/haptics.dart';
|
||||||
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import 'settings_card.dart';
|
||||||
|
|
||||||
|
class BottomNavigationStyleCard extends StatelessWidget {
|
||||||
|
const BottomNavigationStyleCard({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
return SettingsPanel(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
l10n.appearanceBottomNavigationTitle,
|
||||||
|
style: TextStyle(
|
||||||
|
color: cs.onSurface,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
l10n.appearanceBottomNavigationSubtitle,
|
||||||
|
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ValueListenableBuilder<BottomNavigationStyle>(
|
||||||
|
valueListenable: AppBottomNavigationStyle.current,
|
||||||
|
builder: (context, current, _) =>
|
||||||
|
SegmentedButton<BottomNavigationStyle>(
|
||||||
|
showSelectedIcon: false,
|
||||||
|
segments: [
|
||||||
|
ButtonSegment(
|
||||||
|
value: BottomNavigationStyle.floating,
|
||||||
|
label: Text(l10n.appearanceBottomNavigationFloating),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: BottomNavigationStyle.compact,
|
||||||
|
label: Text(l10n.appearanceBottomNavigationCompact),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {current},
|
||||||
|
onSelectionChanged: (selection) {
|
||||||
|
Haptics.selection();
|
||||||
|
AppBottomNavigationStyle.save(selection.first);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'sliding_pill_nav.dart';
|
||||||
|
|
||||||
|
class CompactBottomNavigationBar extends StatelessWidget {
|
||||||
|
const CompactBottomNavigationBar({
|
||||||
|
super.key,
|
||||||
|
required this.items,
|
||||||
|
required this.currentIndex,
|
||||||
|
required this.onTap,
|
||||||
|
this.onItemLongPress,
|
||||||
|
});
|
||||||
|
|
||||||
|
final List<PillNavItem> items;
|
||||||
|
final int currentIndex;
|
||||||
|
final ValueChanged<int> onTap;
|
||||||
|
final void Function(int index, Offset position)? onItemLongPress;
|
||||||
|
|
||||||
|
static double contentHeight(BuildContext context) =>
|
||||||
|
math.max(50, 34 + MediaQuery.textScalerOf(context).scale(10) * 1.2);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (MediaQuery.viewInsetsOf(context).bottom > 0) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
return DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: cs.surface,
|
||||||
|
border: Border(top: BorderSide(color: cs.outlineVariant, width: 0.5)),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: SizedBox(
|
||||||
|
height: contentHeight(context),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
for (var index = 0; index < items.length; index++)
|
||||||
|
Expanded(
|
||||||
|
child: _CompactTab(
|
||||||
|
item: items[index],
|
||||||
|
selected: index == currentIndex,
|
||||||
|
onTap: () => onTap(index),
|
||||||
|
onLongPress:
|
||||||
|
items[index].longPressable && onItemLongPress != null
|
||||||
|
? (position) => onItemLongPress!(index, position)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CompactTab extends StatefulWidget {
|
||||||
|
const _CompactTab({
|
||||||
|
required this.item,
|
||||||
|
required this.selected,
|
||||||
|
required this.onTap,
|
||||||
|
this.onLongPress,
|
||||||
|
});
|
||||||
|
|
||||||
|
final PillNavItem item;
|
||||||
|
final bool selected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final ValueChanged<Offset>? onLongPress;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_CompactTab> createState() => _CompactTabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CompactTabState extends State<_CompactTab> {
|
||||||
|
bool _pressed = false;
|
||||||
|
|
||||||
|
void _setPressed(bool value) {
|
||||||
|
if (_pressed != value) setState(() => _pressed = value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final cs = Theme.of(context).colorScheme;
|
||||||
|
final color = widget.selected ? cs.primary : cs.onSurfaceVariant;
|
||||||
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
selected: widget.selected,
|
||||||
|
label: widget.item.label,
|
||||||
|
onTap: widget.onTap,
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
excludeFromSemantics: true,
|
||||||
|
onTapDown: (_) => _setPressed(true),
|
||||||
|
onTapUp: (_) => _setPressed(false),
|
||||||
|
onTapCancel: () => _setPressed(false),
|
||||||
|
onTap: widget.onTap,
|
||||||
|
onLongPressStart: widget.onLongPress == null
|
||||||
|
? null
|
||||||
|
: (details) {
|
||||||
|
_setPressed(false);
|
||||||
|
widget.onLongPress!(details.globalPosition);
|
||||||
|
},
|
||||||
|
child: ExcludeSemantics(
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: _pressed ? 0.55 : 1,
|
||||||
|
duration: const Duration(milliseconds: 100),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Badge(
|
||||||
|
isLabelVisible: widget.item.unreadCount > 0,
|
||||||
|
label: Text(
|
||||||
|
widget.item.unreadCount > 99
|
||||||
|
? '99+'
|
||||||
|
: '${widget.item.unreadCount}',
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
widget.item.icon,
|
||||||
|
color: color,
|
||||||
|
size: 24,
|
||||||
|
fill: widget.selected ? 1 : 0,
|
||||||
|
weight: widget.selected ? 500 : 400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
widget.item.label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: TextStyle(
|
||||||
|
color: color,
|
||||||
|
fontSize: 10,
|
||||||
|
height: 1.2,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+5
-1
@@ -3309,5 +3309,9 @@
|
|||||||
"avatarEditorSetPhoto": "Set photo",
|
"avatarEditorSetPhoto": "Set photo",
|
||||||
"avatarEditorDraw": "Draw",
|
"avatarEditorDraw": "Draw",
|
||||||
"avatarPickerFilesTitle": "Pick a photo from files",
|
"avatarPickerFilesTitle": "Pick a photo from files",
|
||||||
"avatarPickerFilesSubtitle": "If the photo you need isn't in the gallery"
|
"avatarPickerFilesSubtitle": "If the photo you need isn't in the gallery",
|
||||||
|
"appearanceBottomNavigationTitle": "Bottom navigation",
|
||||||
|
"appearanceBottomNavigationSubtitle": "Choose your navigation layout",
|
||||||
|
"appearanceBottomNavigationFloating": "Floating",
|
||||||
|
"appearanceBottomNavigationCompact": "Compact"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11641,6 +11641,30 @@ abstract class AppLocalizations {
|
|||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'If the photo you need isn\'t in the gallery'**
|
/// **'If the photo you need isn\'t in the gallery'**
|
||||||
String get avatarPickerFilesSubtitle;
|
String get avatarPickerFilesSubtitle;
|
||||||
|
|
||||||
|
/// No description provided for @appearanceBottomNavigationTitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Bottom navigation'**
|
||||||
|
String get appearanceBottomNavigationTitle;
|
||||||
|
|
||||||
|
/// No description provided for @appearanceBottomNavigationSubtitle.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Choose your navigation layout'**
|
||||||
|
String get appearanceBottomNavigationSubtitle;
|
||||||
|
|
||||||
|
/// No description provided for @appearanceBottomNavigationFloating.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Floating'**
|
||||||
|
String get appearanceBottomNavigationFloating;
|
||||||
|
|
||||||
|
/// No description provided for @appearanceBottomNavigationCompact.
|
||||||
|
///
|
||||||
|
/// In en, this message translates to:
|
||||||
|
/// **'Compact'**
|
||||||
|
String get appearanceBottomNavigationCompact;
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AppLocalizationsDelegate
|
class _AppLocalizationsDelegate
|
||||||
|
|||||||
@@ -6554,4 +6554,17 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get avatarPickerFilesSubtitle =>
|
String get avatarPickerFilesSubtitle =>
|
||||||
'If the photo you need isn\'t in the gallery';
|
'If the photo you need isn\'t in the gallery';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appearanceBottomNavigationTitle => 'Bottom navigation';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appearanceBottomNavigationSubtitle =>
|
||||||
|
'Choose your navigation layout';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appearanceBottomNavigationFloating => 'Floating';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appearanceBottomNavigationCompact => 'Compact';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6603,4 +6603,17 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
String get avatarPickerFilesSubtitle => 'Если нужного фото нет в галерее';
|
String get avatarPickerFilesSubtitle => 'Если нужного фото нет в галерее';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appearanceBottomNavigationTitle => 'Нижняя панель';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appearanceBottomNavigationSubtitle =>
|
||||||
|
'Выберите удобный вид навигации';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appearanceBottomNavigationFloating => 'Плавающая';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get appearanceBottomNavigationCompact => 'Компактная';
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-1
@@ -2110,5 +2110,9 @@
|
|||||||
"avatarEditorSetPhoto": "Установить фото",
|
"avatarEditorSetPhoto": "Установить фото",
|
||||||
"avatarEditorDraw": "Рисовать",
|
"avatarEditorDraw": "Рисовать",
|
||||||
"avatarPickerFilesTitle": "Выбрать фото из файлов",
|
"avatarPickerFilesTitle": "Выбрать фото из файлов",
|
||||||
"avatarPickerFilesSubtitle": "Если нужного фото нет в галерее"
|
"avatarPickerFilesSubtitle": "Если нужного фото нет в галерее",
|
||||||
|
"appearanceBottomNavigationTitle": "Нижняя панель",
|
||||||
|
"appearanceBottomNavigationSubtitle": "Выберите удобный вид навигации",
|
||||||
|
"appearanceBottomNavigationFloating": "Плавающая",
|
||||||
|
"appearanceBottomNavigationCompact": "Компактная"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import 'core/config/app_chat_chrome.dart';
|
|||||||
import 'core/config/app_composer_background.dart';
|
import 'core/config/app_composer_background.dart';
|
||||||
import 'core/config/app_composer_style.dart';
|
import 'core/config/app_composer_style.dart';
|
||||||
import 'core/config/app_nav_pill_style.dart';
|
import 'core/config/app_nav_pill_style.dart';
|
||||||
|
import 'core/config/app_bottom_navigation_style.dart';
|
||||||
import 'core/config/app_wallpaper_tint.dart';
|
import 'core/config/app_wallpaper_tint.dart';
|
||||||
import 'core/storage/chat_wallpaper_store.dart';
|
import 'core/storage/chat_wallpaper_store.dart';
|
||||||
import 'core/utils/wallpaper_seed.dart';
|
import 'core/utils/wallpaper_seed.dart';
|
||||||
@@ -255,6 +256,7 @@ void main(List<String> args) async {
|
|||||||
final composerStyleFuture = AppComposerStyle.load();
|
final composerStyleFuture = AppComposerStyle.load();
|
||||||
final composerBackgroundFuture = AppComposerBackground.load();
|
final composerBackgroundFuture = AppComposerBackground.load();
|
||||||
final navPillStyleFuture = AppNavPillStyle.load();
|
final navPillStyleFuture = AppNavPillStyle.load();
|
||||||
|
await AppBottomNavigationStyle.load();
|
||||||
final wallpaperTintFuture = AppWallpaperTint.load();
|
final wallpaperTintFuture = AppWallpaperTint.load();
|
||||||
final themeScheduleFuture = AppThemeSchedule.load();
|
final themeScheduleFuture = AppThemeSchedule.load();
|
||||||
final messageActionsFuture = AppMessageActionsStyle.load();
|
final messageActionsFuture = AppMessageActionsStyle.load();
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.6+7
|
version: 1.0.7+8
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.10.4
|
sdk: ^3.10.4
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:flutter_webrtc/flutter_webrtc.dart';
|
||||||
|
import 'package:ikar/core/calls/call_session.dart';
|
||||||
|
import 'package:ikar/core/calls/ws2_signaling.dart';
|
||||||
|
|
||||||
|
final config = Ws2Config(
|
||||||
|
userId: 1,
|
||||||
|
uri: Uri(scheme: 'wss', host: 'calls.invalid'),
|
||||||
|
);
|
||||||
|
|
||||||
|
class BrokenSignaling extends Ws2Signaling {
|
||||||
|
BrokenSignaling() : super(config);
|
||||||
|
int sent = 0;
|
||||||
|
@override
|
||||||
|
Future<void> transmitCandidate({
|
||||||
|
required int participantId,
|
||||||
|
required String candidate,
|
||||||
|
required String sdpMid,
|
||||||
|
required int sdpMLineIndex,
|
||||||
|
String participantType = 'USER',
|
||||||
|
int deviceIdx = 0,
|
||||||
|
}) async {
|
||||||
|
sent++;
|
||||||
|
throw const SocketException('mobile network disconnected');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> hangup({String reason = 'HUNGUP'}) async {}
|
||||||
|
@override
|
||||||
|
Future<void> close() async {}
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
test(
|
||||||
|
'ICE send failure is handled and late candidates are ignored after hangup',
|
||||||
|
() async {
|
||||||
|
final signaling = BrokenSignaling();
|
||||||
|
final session = CallSession(ws2Config: config, role: CallRole.caller);
|
||||||
|
session.bindCandidateSignalingForTesting(signaling, 2);
|
||||||
|
final errors = <Object>[];
|
||||||
|
runZonedGuarded(() {
|
||||||
|
session.receiveLocalCandidateForTesting(
|
||||||
|
RTCIceCandidate('candidate:mobile', '0', 0),
|
||||||
|
);
|
||||||
|
}, (error, stack) => errors.add(error));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
final reconnecting = session.isReconnecting;
|
||||||
|
await session.hangup();
|
||||||
|
runZonedGuarded(() {
|
||||||
|
session.receiveLocalCandidateForTesting(
|
||||||
|
RTCIceCandidate('candidate:late', '0', 0),
|
||||||
|
);
|
||||||
|
}, (error, stack) => errors.add(error));
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(reconnecting, isTrue);
|
||||||
|
expect(signaling.sent, 1);
|
||||||
|
expect(errors, isEmpty);
|
||||||
|
expect(session.currentState, CallSessionState.ended);
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 1100));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:ikar/core/config/app_bottom_navigation_style.dart';
|
||||||
|
import 'package:ikar/frontend/widgets/compact_bottom_navigation_bar.dart';
|
||||||
|
import 'package:ikar/frontend/widgets/bottom_navigation_style_card.dart';
|
||||||
|
import 'package:ikar/frontend/widgets/sliding_pill_nav.dart';
|
||||||
|
import 'package:ikar/l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
setUp(() {
|
||||||
|
SharedPreferences.setMockInitialValues({});
|
||||||
|
AppBottomNavigationStyle.current.value = BottomNavigationStyle.floating;
|
||||||
|
});
|
||||||
|
testWidgets(
|
||||||
|
'compact tabs retain badge, selection and account long press at large text size',
|
||||||
|
(tester) async {
|
||||||
|
var selected = -1;
|
||||||
|
var held = -1;
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: MediaQuery(
|
||||||
|
data: const MediaQueryData(
|
||||||
|
size: Size(320, 640),
|
||||||
|
textScaler: TextScaler.linear(2),
|
||||||
|
),
|
||||||
|
child: Scaffold(
|
||||||
|
bottomNavigationBar: CompactBottomNavigationBar(
|
||||||
|
items: const [
|
||||||
|
PillNavItem(
|
||||||
|
icon: Icons.chat,
|
||||||
|
label: 'Чаты',
|
||||||
|
unreadCount: 120,
|
||||||
|
),
|
||||||
|
PillNavItem(icon: Icons.call, label: 'Звонки'),
|
||||||
|
PillNavItem(icon: Icons.people, label: 'Контакты'),
|
||||||
|
PillNavItem(
|
||||||
|
icon: Icons.settings,
|
||||||
|
label: 'Настройки',
|
||||||
|
longPressable: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
currentIndex: 0,
|
||||||
|
onTap: (index) => selected = index,
|
||||||
|
onItemLongPress: (index, _) => held = index,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(find.text('99+'), findsOneWidget);
|
||||||
|
await tester.tap(find.byIcon(Icons.call));
|
||||||
|
expect(selected, 1);
|
||||||
|
await tester.longPress(find.byIcon(Icons.settings));
|
||||||
|
expect(held, 3);
|
||||||
|
expect(tester.takeException(), isNull);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
testWidgets('appearance setting persists and restores compact layout', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
|
await tester.pumpWidget(
|
||||||
|
const MaterialApp(
|
||||||
|
locale: Locale('ru'),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(body: BottomNavigationStyleCard()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('Компактная'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(
|
||||||
|
AppBottomNavigationStyle.current.value,
|
||||||
|
BottomNavigationStyle.compact,
|
||||||
|
);
|
||||||
|
AppBottomNavigationStyle.current.value = BottomNavigationStyle.floating;
|
||||||
|
await AppBottomNavigationStyle.load();
|
||||||
|
expect(
|
||||||
|
AppBottomNavigationStyle.current.value,
|
||||||
|
BottomNavigationStyle.compact,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ikar/backend/modules/messages.dart';
|
||||||
|
import 'package:ikar/core/utils/download_progress.dart';
|
||||||
|
import 'package:ikar/core/utils/file_download.dart';
|
||||||
|
import 'package:ikar/frontend/widgets/attachment/bubbles/bubble_context.dart';
|
||||||
|
import 'package:ikar/frontend/widgets/attachment/bubbles/file_bubble.dart';
|
||||||
|
import 'package:ikar/l10n/app_localizations.dart';
|
||||||
|
import 'package:ikar/models/attachment.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
testWidgets(
|
||||||
|
'whole file card cannot open twice while external opening is pending',
|
||||||
|
(tester) async {
|
||||||
|
var opens = 0;
|
||||||
|
var ready = Completer<FileDownloadResult>();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
locale: const Locale('ru'),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: Scaffold(
|
||||||
|
body: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
const file = FileAttachment(
|
||||||
|
fileId: 765,
|
||||||
|
name: 'report.pdf',
|
||||||
|
size: 1024,
|
||||||
|
);
|
||||||
|
return FileBubble(
|
||||||
|
file: file,
|
||||||
|
ctx: BubbleContext(
|
||||||
|
context: context,
|
||||||
|
cs: Theme.of(context).colorScheme,
|
||||||
|
text: Colors.black,
|
||||||
|
shape: BubbleShape.singleMiddle,
|
||||||
|
contentType: MessageType.attachment,
|
||||||
|
hasPhotoWithCaption: false,
|
||||||
|
hasMultiplePhotosNoCaption: false,
|
||||||
|
message: CachedMessage(
|
||||||
|
id: '1',
|
||||||
|
accountId: 1,
|
||||||
|
chatId: 2,
|
||||||
|
senderId: 3,
|
||||||
|
time: 1000,
|
||||||
|
status: 'sent',
|
||||||
|
),
|
||||||
|
isMe: false,
|
||||||
|
myId: 1,
|
||||||
|
chatType: 'DIALOG',
|
||||||
|
),
|
||||||
|
openFile:
|
||||||
|
(
|
||||||
|
name,
|
||||||
|
url, {
|
||||||
|
onProgress,
|
||||||
|
onReady,
|
||||||
|
download,
|
||||||
|
downloadOnlyIfMissing = false,
|
||||||
|
}) {
|
||||||
|
opens++;
|
||||||
|
onReady?.call();
|
||||||
|
return ready.future;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('report.pdf'));
|
||||||
|
await tester.pump();
|
||||||
|
await tester.tap(find.text('report.pdf'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(opens, 1);
|
||||||
|
expect(find.textContaining('Открытие'), findsOneWidget);
|
||||||
|
ready.complete(const FileDownloadResult(ok: true));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
ready = Completer<FileDownloadResult>();
|
||||||
|
await tester.tap(find.text('report.pdf'));
|
||||||
|
expect(opens, 2);
|
||||||
|
ready.complete(const FileDownloadResult(ok: true));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
MediaDownloadProgress.set('765_report.pdf', null);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ikar/backend/modules/pending_message_deletions.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test(
|
||||||
|
'undo retains other pending removals and isolates accounts and chats',
|
||||||
|
() {
|
||||||
|
final pending = PendingMessageDeletions();
|
||||||
|
pending.hold(1, 2, ['a', 'b']);
|
||||||
|
pending.hold(1, 2, ['c']);
|
||||||
|
pending.hold(3, 2, ['a']);
|
||||||
|
pending.release(1, 2, ['a', 'b']);
|
||||||
|
expect(pending.contains(1, 2, 'a'), isFalse);
|
||||||
|
expect(pending.contains(1, 2, 'c'), isTrue);
|
||||||
|
expect(pending.contains(3, 2, 'a'), isTrue);
|
||||||
|
expect(pending.contains(1, 4, 'c'), isFalse);
|
||||||
|
pending.dispose();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:ikar/core/share/share_inbox.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test(
|
||||||
|
'concurrent events present each share once and wait for file users',
|
||||||
|
() async {
|
||||||
|
final records = [
|
||||||
|
for (var i = 0; i < 3; i++) {'id': '$i', 'text': 'share $i'},
|
||||||
|
];
|
||||||
|
final shown = <String>[];
|
||||||
|
final acknowledged = <String>[];
|
||||||
|
final released = <String>[];
|
||||||
|
final upload = Completer<void>();
|
||||||
|
final inbox = ShareInbox(
|
||||||
|
consume: () async => records.isEmpty ? null : records.removeAt(0),
|
||||||
|
acknowledge: (id) async => acknowledged.add(id),
|
||||||
|
release: (id) async => released.add(id),
|
||||||
|
onShare: (payload) => shown.add(payload.text!),
|
||||||
|
);
|
||||||
|
await Future.wait([inbox.check(), inbox.check(), inbox.check()]);
|
||||||
|
expect(shown, ['share 0']);
|
||||||
|
final completing = inbox.complete(waitForRelease: () => upload.future);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
expect(acknowledged, ['0']);
|
||||||
|
expect(released, isEmpty);
|
||||||
|
expect(shown, ['share 0']);
|
||||||
|
upload.complete();
|
||||||
|
await completing;
|
||||||
|
expect(released, ['0']);
|
||||||
|
expect(shown, ['share 0', 'share 1']);
|
||||||
|
await inbox.complete();
|
||||||
|
await inbox.complete();
|
||||||
|
expect(shown, ['share 0', 'share 1', 'share 2']);
|
||||||
|
expect(released, ['0', '1', '2']);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'ack failure keeps active record and retries without presenting twice',
|
||||||
|
() async {
|
||||||
|
var failures = 1;
|
||||||
|
var shown = 0;
|
||||||
|
var consumed = false;
|
||||||
|
final inbox = ShareInbox(
|
||||||
|
consume: () async {
|
||||||
|
if (consumed) return null;
|
||||||
|
consumed = true;
|
||||||
|
return {'id': 'a', 'text': 'text'};
|
||||||
|
},
|
||||||
|
acknowledge: (_) async {
|
||||||
|
if (failures-- > 0) throw StateError('disk');
|
||||||
|
},
|
||||||
|
release: (_) async {},
|
||||||
|
onShare: (_) => shown++,
|
||||||
|
);
|
||||||
|
await inbox.check();
|
||||||
|
await expectLater(inbox.complete(), throwsStateError);
|
||||||
|
await inbox.check();
|
||||||
|
expect(shown, 1);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test(
|
||||||
|
'invalid record is discarded and next valid record is presented',
|
||||||
|
() async {
|
||||||
|
final records = [
|
||||||
|
{'id': 'bad'},
|
||||||
|
{'id': 'good', 'text': 'ok'},
|
||||||
|
];
|
||||||
|
final acknowledged = <String>[];
|
||||||
|
final shown = <String>[];
|
||||||
|
final inbox = ShareInbox(
|
||||||
|
consume: () async => records.isEmpty ? null : records.removeAt(0),
|
||||||
|
acknowledge: (id) async => acknowledged.add(id),
|
||||||
|
release: (_) async {},
|
||||||
|
onShare: (payload) => shown.add(payload.text!),
|
||||||
|
);
|
||||||
|
await inbox.check();
|
||||||
|
expect(acknowledged, ['bad']);
|
||||||
|
expect(shown, ['ok']);
|
||||||
|
await inbox.complete();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -130,6 +130,21 @@ Future<void> main(List<String> args) async {
|
|||||||
'Nonmember could read chat: ${forbidden['payload']}',
|
'Nonmember could read chat: ${forbidden['payload']}',
|
||||||
);
|
);
|
||||||
checks.add('persistent history and nonmember isolation');
|
checks.add('persistent history and nonmember isolation');
|
||||||
|
final lookup = await b.ok(71, {
|
||||||
|
'chatId': chatId,
|
||||||
|
'messageId': message['id'],
|
||||||
|
});
|
||||||
|
check(
|
||||||
|
lookup['message']['id'] == message['id'] &&
|
||||||
|
lookup['message']['text'] == 'IKAR live delivery',
|
||||||
|
'Message lookup failed',
|
||||||
|
);
|
||||||
|
final privateLookup = await outsider.transport.request(71, {
|
||||||
|
'chatId': chatId,
|
||||||
|
'messageId': message['id'],
|
||||||
|
});
|
||||||
|
check(privateLookup['cmd'] == 3, 'Nonmember could look up a message');
|
||||||
|
checks.add('reply lookup and nonmember isolation');
|
||||||
final retryCid = DateTime.now().microsecondsSinceEpoch;
|
final retryCid = DateTime.now().microsecondsSinceEpoch;
|
||||||
final richPayload = {
|
final richPayload = {
|
||||||
'chatId': chatId,
|
'chatId': chatId,
|
||||||
|
|||||||
+57
-23
@@ -4,7 +4,7 @@ import 'dart:io';
|
|||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import 'dart:typed_data';
|
import 'dart:typed_data';
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import '../lib/core/transport/ikar_network.dart';
|
import 'package:ikar/core/transport/ikar_network.dart';
|
||||||
import 'live_ikar_smoke.dart' as live;
|
import 'live_ikar_smoke.dart' as live;
|
||||||
|
|
||||||
List<int> attr(int type, List<int> value) {
|
List<int> attr(int type, List<int> value) {
|
||||||
@@ -36,7 +36,7 @@ class Frame {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class Turn {
|
class Turn {
|
||||||
Turn(this.socket) {
|
Turn(Socket socket) : write = socket.add, stop = socket.destroy {
|
||||||
socket.listen(
|
socket.listen(
|
||||||
(chunk) {
|
(chunk) {
|
||||||
buffer.addAll(chunk);
|
buffer.addAll(chunk);
|
||||||
@@ -51,7 +51,26 @@ class Turn {
|
|||||||
onDone: frames.close,
|
onDone: frames.close,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final Socket socket;
|
Turn.datagram(RawDatagramSocket socket, InternetAddress address)
|
||||||
|
: write = ((bytes) => socket.send(bytes, address, 3478)),
|
||||||
|
stop = socket.close {
|
||||||
|
socket.listen(
|
||||||
|
(event) {
|
||||||
|
if (event != RawSocketEvent.read) return;
|
||||||
|
Datagram? packet;
|
||||||
|
while ((packet = socket.receive()) != null) {
|
||||||
|
if (packet!.address.address == address.address &&
|
||||||
|
packet.port == 3478) {
|
||||||
|
frames.add(Frame(packet.data));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: frames.addError,
|
||||||
|
onDone: frames.close,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final void Function(List<int>) write;
|
||||||
|
final void Function() stop;
|
||||||
final frames = StreamController<Frame>.broadcast();
|
final frames = StreamController<Frame>.broadcast();
|
||||||
final buffer = <int>[];
|
final buffer = <int>[];
|
||||||
late String username, password, realm, nonce;
|
late String username, password, realm, nonce;
|
||||||
@@ -63,7 +82,9 @@ class Turn {
|
|||||||
..setUint32(4, 0x2112a442);
|
..setUint32(4, 0x2112a442);
|
||||||
final bytes = header.buffer.asUint8List();
|
final bytes = header.buffer.asUint8List();
|
||||||
final random = Random.secure();
|
final random = Random.secure();
|
||||||
for (var i = 8; i < 20; i++) bytes[i] = random.nextInt(256);
|
for (var i = 8; i < 20; i++) {
|
||||||
|
bytes[i] = random.nextInt(256);
|
||||||
|
}
|
||||||
final prefix = [...bytes, ...body];
|
final prefix = [...bytes, ...body];
|
||||||
if (!auth) return Uint8List.fromList(prefix);
|
if (!auth) return Uint8List.fromList(prefix);
|
||||||
final key = md5.convert(utf8.encode('$username:$realm:$password')).bytes;
|
final key = md5.convert(utf8.encode('$username:$realm:$password')).bytes;
|
||||||
@@ -84,7 +105,7 @@ class Turn {
|
|||||||
final response = frames.stream
|
final response = frames.stream
|
||||||
.firstWhere((f) => f.transaction == transaction)
|
.firstWhere((f) => f.transaction == transaction)
|
||||||
.timeout(const Duration(seconds: 10));
|
.timeout(const Duration(seconds: 10));
|
||||||
socket.add(wire);
|
write(wire);
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +115,9 @@ class Turn {
|
|||||||
..setUint16(2, address.$2 ^ 0x2112);
|
..setUint16(2, address.$2 ^ 0x2112);
|
||||||
final ip = InternetAddress(address.$1).rawAddress;
|
final ip = InternetAddress(address.$1).rawAddress;
|
||||||
final magic = [0x21, 0x12, 0xa4, 0x42];
|
final magic = [0x21, 0x12, 0xa4, 0x42];
|
||||||
for (var i = 0; i < 4; i++) value.setUint8(4 + i, ip[i] ^ magic[i]);
|
for (var i = 0; i < 4; i++) {
|
||||||
|
value.setUint8(4 + i, ip[i] ^ magic[i]);
|
||||||
|
}
|
||||||
return attr(0x12, value.buffer.asUint8List());
|
return attr(0x12, value.buffer.asUint8List());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,22 +153,24 @@ class Turn {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void send((String, int) address, String message) => socket.add(
|
void send((String, int) address, String message) => write(
|
||||||
encode(0x16, [...peer(address), ...attr(0x13, utf8.encode(message))]),
|
encode(0x16, [...peer(address), ...attr(0x13, utf8.encode(message))]),
|
||||||
);
|
);
|
||||||
Future<String> receive() => frames.stream
|
Future<String> receive() => frames.stream
|
||||||
.firstWhere((f) => f.type == 0x17)
|
.firstWhere((f) => f.type == 0x17)
|
||||||
.timeout(const Duration(seconds: 10))
|
.timeout(const Duration(seconds: 10))
|
||||||
.then((f) => utf8.decode(f.attributes[0x13]!));
|
.then((f) => utf8.decode(f.attributes[0x13]!));
|
||||||
void close() => socket.destroy();
|
void close() => stop();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> main(List<String> args) async {
|
Future<void> main(List<String> args) async {
|
||||||
|
final udp = args.contains('--udp');
|
||||||
|
final hosts = args.where((arg) => !arg.startsWith('--')).toList();
|
||||||
final devices = <live.Device>[];
|
final devices = <live.Device>[];
|
||||||
final relays = <Turn>[];
|
final relays = <Turn>[];
|
||||||
WebSocket? signaling;
|
WebSocket? signaling;
|
||||||
try {
|
try {
|
||||||
for (var i = 0; i < 2; i++)
|
for (var i = 0; i < 2; i++) {
|
||||||
devices.add(
|
devices.add(
|
||||||
await live.Device.create(
|
await live.Device.create(
|
||||||
Uri.parse('https://ikar.kusoft.xyz'),
|
Uri.parse('https://ikar.kusoft.xyz'),
|
||||||
@@ -154,6 +179,7 @@ Future<void> main(List<String> args) async {
|
|||||||
'IKAR TURN test',
|
'IKAR TURN test',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
final call = await devices[0].ok(78, {
|
final call = await devices[0].ok(78, {
|
||||||
'calleeIds': [devices[1].id],
|
'calleeIds': [devices[1].id],
|
||||||
'isVideo': false,
|
'isVideo': false,
|
||||||
@@ -164,18 +190,26 @@ Future<void> main(List<String> args) async {
|
|||||||
);
|
);
|
||||||
final hello = jsonDecode(await signaling.first as String);
|
final hello = jsonDecode(await signaling.first as String);
|
||||||
final turn = hello['conversationParams']['turn'];
|
final turn = hello['conversationParams']['turn'];
|
||||||
final address = args.isNotEmpty
|
final address = hosts.isNotEmpty
|
||||||
? args.first
|
? hosts.first
|
||||||
: RegExp(
|
: RegExp(
|
||||||
r'^turn:([^:?/]+)',
|
r'^turn:([^:?/]+)',
|
||||||
).firstMatch((turn['urls'] as List).first)!.group(1)!;
|
).firstMatch((turn['urls'] as List).first)!.group(1)!;
|
||||||
for (var i = 0; i < 2; i++) {
|
for (var i = 0; i < 2; i++) {
|
||||||
final relay = Turn(
|
final relay = udp
|
||||||
await Socket.connect(
|
? Turn.datagram(
|
||||||
address,
|
await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0),
|
||||||
3478,
|
(await InternetAddress.lookup(
|
||||||
).timeout(const Duration(seconds: 10)),
|
address,
|
||||||
);
|
type: InternetAddressType.IPv4,
|
||||||
|
)).first,
|
||||||
|
)
|
||||||
|
: Turn(
|
||||||
|
await Socket.connect(
|
||||||
|
address,
|
||||||
|
3478,
|
||||||
|
).timeout(const Duration(seconds: 10)),
|
||||||
|
);
|
||||||
relays.add(relay);
|
relays.add(relay);
|
||||||
await relay.allocate(turn['username'], turn['credential']);
|
await relay.allocate(turn['username'], turn['credential']);
|
||||||
}
|
}
|
||||||
@@ -196,22 +230,22 @@ Future<void> main(List<String> args) async {
|
|||||||
);
|
);
|
||||||
final report = {
|
final report = {
|
||||||
'passed': true,
|
'passed': true,
|
||||||
'transport': 'TCP',
|
'transport': udp ? 'UDP' : 'TCP',
|
||||||
'serverAddress': address,
|
'serverAddress': address,
|
||||||
'externalAccess': args.isEmpty,
|
'externalAccess': hosts.isEmpty,
|
||||||
'relayPorts': [a.relay.$2, b.relay.$2],
|
'relayPorts': [a.relay.$2, b.relay.$2],
|
||||||
'bidirectionalData': true,
|
'bidirectionalData': true,
|
||||||
'client': 'Windows',
|
'client': 'Windows',
|
||||||
'timestamp': DateTime.now().toUtc().toIso8601String(),
|
'timestamp': DateTime.now().toUtc().toIso8601String(),
|
||||||
};
|
};
|
||||||
await File(
|
await File(
|
||||||
args.isEmpty
|
'artifacts/${hosts.isEmpty ? 'public' : 'local'}-turn-verification${udp ? '-udp' : ''}.json',
|
||||||
? 'artifacts/public-turn-verification.json'
|
|
||||||
: 'artifacts/local-turn-verification.json',
|
|
||||||
).writeAsString(const JsonEncoder.withIndent(' ').convert(report));
|
).writeAsString(const JsonEncoder.withIndent(' ').convert(report));
|
||||||
stdout.writeln(jsonEncode(report));
|
stdout.writeln(jsonEncode(report));
|
||||||
} finally {
|
} finally {
|
||||||
for (final relay in relays) relay.close();
|
for (final relay in relays) {
|
||||||
|
relay.close();
|
||||||
|
}
|
||||||
await signaling?.close();
|
await signaling?.close();
|
||||||
for (final d in devices) {
|
for (final d in devices) {
|
||||||
await d.ok(199, {});
|
await d.ok(199, {});
|
||||||
|
|||||||
Reference in New Issue
Block a user