diff --git a/README.md b/README.md
index fe07870..2aebbbd 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@
-Самостоятельный 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` не изменён; использованный клиентский код и библиотеки сохраняют свои авторские сведения и лицензии.
@@ -18,6 +18,21 @@
Сквозное шифрование исходного клиента сохранено в коде как отдельная функция. Обычные сообщения сервер принимает и хранит в читаемом виде; 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
В [Аргусе](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`.
-Рабочий 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; альтернативная иконка сохранена.
@@ -60,7 +75,7 @@ APK `artifacts/ikar-1.0.4+5.apk` подписан прежним ключом И
Внешний диск: `/mnt/data` (`/dev/sdb1`, ext4). Корень новой установки: `/mnt/data/ikar_server`.
-- `current` — ссылка на действующий выпуск в `releases/20261007-komet`;
+- `current` — ссылка на действующий выпуск в `releases/20261008-followups`;
- `shared/data` — SQLite, вложения, данные устройств и публикация APK;
- `shared/server.env`, `shared/turn.env` — закрытые настройки, доступ 0600;
- `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 временно недоступен.
-Для звонков через разные 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-соединение; это не доказывает работу через любую мобильную сеть.
## Сборка
diff --git a/android/.gitignore b/android/.gitignore
index cf547f7..f5c0e67 100644
--- a/android/.gitignore
+++ b/android/.gitignore
@@ -1,5 +1,6 @@
gradle-wrapper.jar
/.gradle
+/.kotlin/
/captures/
/gradlew
/gradlew.bat
diff --git a/android/app/src/main/kotlin/ru/qlyra/app/MainActivity.kt b/android/app/src/main/kotlin/ru/qlyra/app/MainActivity.kt
index 23e1d31..7a5838a 100644
--- a/android/app/src/main/kotlin/ru/qlyra/app/MainActivity.kt
+++ b/android/app/src/main/kotlin/ru/qlyra/app/MainActivity.kt
@@ -77,8 +77,6 @@ class MainActivity : AudioServiceFragmentActivity() {
private var pendingCall: Map? = null
private var pendingChat: Long = 0L
- private var pendingShare: Map? = null
- private var pendingShareTask: java.util.concurrent.Future?>? = null
private val shareExecutor = java.util.concurrent.Executors.newSingleThreadExecutor()
private val shareHandler = Handler(Looper.getMainLooper())
@@ -507,26 +505,25 @@ class MainActivity : AudioServiceFragmentActivity() {
"ru.ikar.app/share",
).setMethodCallHandler { call, result ->
when (call.method) {
- "consumeInitialShare" -> {
+ "consumeInitialShare", "acknowledgeShare", "releaseShare" -> {
stashShare(intent, emit = false)
- val ready = pendingShare
- val task = pendingShareTask
- if (ready != null) {
- pendingShare = null
- result.success(ready)
- } else if (task != null) {
- pendingShareTask = null
- shareExecutor.execute {
- val payload = try {
- task.get()
- } catch (e: Exception) {
- Log.w(SHARE_TAG, "materialize failed: $e")
- null
+ shareExecutor.execute {
+ try {
+ val value = when (call.method) {
+ "consumeInitialShare" -> ShareQueue.consume(applicationContext)
+ "acknowledgeShare" -> {
+ ShareQueue.acknowledge(applicationContext, call.argument("id")!!)
+ null
+ }
+ else -> {
+ ShareQueue.release(applicationContext, call.argument("id")!!)
+ 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" -> {
@@ -658,23 +655,14 @@ class MainActivity : AudioServiceFragmentActivity() {
intent.action = Intent.ACTION_MAIN
intent.removeExtra(Intent.EXTRA_STREAM)
intent.removeExtra(Intent.EXTRA_TEXT)
- val task = shareExecutor.submit?> {
- ShareIntake.materialize(applicationContext, snapshot)
- }
- if (!emit) {
- pendingShareTask = task
- return
- }
shareExecutor.execute {
- val payload = try {
- task.get()
+ try {
+ val payload = ShareIntake.materialize(applicationContext, snapshot)
+ ?: return@execute
+ ShareQueue.enqueue(applicationContext, payload)
+ if (emit) shareHandler.post { ShareIntake.sink?.success(true) }
} catch (e: Exception) {
- Log.w(SHARE_TAG, "materialize failed: $e")
- null
- } ?: return@execute
- shareHandler.post {
- val sink = ShareIntake.sink
- if (sink != null) sink.success(payload) else pendingShare = payload
+ Log.w(SHARE_TAG, "cannot persist share: $e")
}
}
}
diff --git a/android/app/src/main/kotlin/ru/qlyra/app/ShareIntake.kt b/android/app/src/main/kotlin/ru/qlyra/app/ShareIntake.kt
index 3eadeef..ef826ed 100644
--- a/android/app/src/main/kotlin/ru/qlyra/app/ShareIntake.kt
+++ b/android/app/src/main/kotlin/ru/qlyra/app/ShareIntake.kt
@@ -60,7 +60,7 @@ object ShareIntake {
fun clearCache(context: Context, paths: List) {
try {
- val dir = File(context.cacheDir, CACHE_DIR).canonicalFile
+ val dir = File(context.filesDir, CACHE_DIR).canonicalFile
if (!dir.isDirectory) return
for (path in paths) {
val file = File(path).canonicalFile
@@ -96,7 +96,7 @@ object ShareIntake {
val displayName = queryDisplayName(resolver, uri) ?: fallbackName(uri, mime)
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)}")
resolver.openInputStream(uri).use { input ->
if (input == null) return null
diff --git a/android/app/src/main/kotlin/ru/qlyra/app/ShareQueue.kt b/android/app/src/main/kotlin/ru/qlyra/app/ShareQueue.kt
new file mode 100644
index 0000000..5059849
--- /dev/null
+++ b/android/app/src/main/kotlin/ru/qlyra/app/ShareQueue.kt
@@ -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) {
+ 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? {
+ 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 = 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
+ }
+ }
+}
diff --git a/ikar_server/src/Ikar.Server.Tests/MessageLookupTests.cs b/ikar_server/src/Ikar.Server.Tests/MessageLookupTests.cs
new file mode 100644
index 0000000..a6099fc
--- /dev/null
+++ b/ikar_server/src/Ikar.Server.Tests/MessageLookupTests.cs
@@ -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(await controller.Dispatch(71, request, CancellationToken.None));
+ var payload = JsonSerializer.SerializeToElement(Assert.IsType(action.Value).Payload);
+ Assert.Equal("Old reply target", payload.GetProperty("message").GetProperty("text").GetString());
+ Assert.IsType(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(await controller.Dispatch(71, request, CancellationToken.None));
+ }
+}
diff --git a/ikar_server/src/Ikar.Server.Tests/QlyraCompatibilityControllerTests.cs b/ikar_server/src/Ikar.Server.Tests/QlyraCompatibilityControllerTests.cs
index d445f85..29b5ff7 100644
--- a/ikar_server/src/Ikar.Server.Tests/QlyraCompatibilityControllerTests.cs
+++ b/ikar_server/src/Ikar.Server.Tests/QlyraCompatibilityControllerTests.cs
@@ -28,7 +28,7 @@ public sealed partial class QlyraCompatibilityControllerTests
int[] opcodes =
[
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,
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
diff --git a/ikar_server/src/Ikar.Server/Controllers/QlyraCompatibilityController.cs b/ikar_server/src/Ikar.Server/Controllers/QlyraCompatibilityController.cs
index ffcb319..c874544 100644
--- a/ikar_server/src/Ikar.Server/Controllers/QlyraCompatibilityController.cs
+++ b/ikar_server/src/Ikar.Server/Controllers/QlyraCompatibilityController.cs
@@ -74,6 +74,7 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase
43 => await RemoveProfilePhotoAsync(opcode, cancellationToken),
48 => await GetChatInfoAsync(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),
51 => await GetChatMediaAsync(opcode, request.Payload, cancellationToken),
52 => await DeleteChatAsync(opcode, request.Payload, cancellationToken),
@@ -1738,6 +1739,13 @@ VideoPreviewService? videoPreviewService = null) : ControllerBase
var visibleMessages = chat.Messages
.Where(message => message.DeletedAt is null && !hiddenMessageIds.Contains(message.Id))
.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 (ReadInt(payload, "postId") is int clientPostId)
{
diff --git a/lib/backend/modules/messages.dart b/lib/backend/modules/messages.dart
index b3a7ea6..ce50aa7 100644
--- a/lib/backend/modules/messages.dart
+++ b/lib/backend/modules/messages.dart
@@ -201,7 +201,8 @@ class TranscriptionCache {
} else {
_expanded.remove(messageId);
}
- for (final listener in _listeners[messageId]?.toList() ?? []) {
+ for (final listener
+ in _listeners[messageId]?.toList() ?? []) {
listener();
}
}
@@ -789,6 +790,23 @@ class MessagesModule {
}
// #***! история с сервера
+ Future 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> fetchHistory(
int accountId,
int chatId, {
diff --git a/lib/backend/modules/pending_message_deletions.dart b/lib/backend/modules/pending_message_deletions.dart
new file mode 100644
index 0000000..2c6ef06
--- /dev/null
+++ b/lib/backend/modules/pending_message_deletions.dart
@@ -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> _pending = {};
+
+ bool contains(int accountId, int chatId, String id) =>
+ _pending[(accountId, chatId)]?.contains(id) ?? false;
+
+ void hold(int accountId, int chatId, Iterable ids) {
+ _pending.putIfAbsent((accountId, chatId), () => {}).addAll(ids);
+ notifyListeners();
+ }
+
+ void release(int accountId, int chatId, Iterable ids) {
+ final pending = _pending[(accountId, chatId)];
+ if (pending == null) return;
+ pending.removeAll(ids);
+ if (pending.isEmpty) _pending.remove((accountId, chatId));
+ notifyListeners();
+ }
+}
diff --git a/lib/core/calls/call_session.dart b/lib/core/calls/call_session.dart
index 208ad3a..6c5882a 100644
--- a/lib/core/calls/call_session.dart
+++ b/lib/core/calls/call_session.dart
@@ -2,7 +2,7 @@ import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart'
- show TargetPlatform, defaultTargetPlatform;
+ show TargetPlatform, defaultTargetPlatform, visibleForTesting;
import 'package:flutter_webrtc/flutter_webrtc.dart';
import '../config/app_camera.dart';
@@ -127,6 +127,8 @@ class CallSession {
static const Duration _maxReconnectDelay = Duration(seconds: 20);
bool get isReconnecting => _reconnecting;
+ String? _failureReason;
+ String? get failureReason => _failureReason;
Timer? _levelTimer;
final Map _speakHold = {};
@@ -238,7 +240,11 @@ class CallSession {
: DateTime.now().difference(_activeSince!).inSeconds;
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();
_current = s;
_state.add(s);
@@ -360,6 +366,7 @@ class CallSession {
_reconnecting = false;
if (!_ended) {
logger.w('[call] reconnect gave up');
+ _failureReason = 'Не удалось восстановить соединение для звонка';
_end();
}
}
@@ -368,6 +375,8 @@ class CallSession {
if (_ended || _iceRestarting || _topology == 'SERVER') return;
if (_iceRestarts >= _maxIceRestarts) {
logger.w('[call] ice restart budget exhausted, ending call');
+ _failureReason =
+ 'Не удалось установить соединение для звонка. Проверьте сеть';
_end();
return;
}
@@ -398,10 +407,15 @@ class CallSession {
_probeChannel = null;
await _closeSfuChannels();
- try {
- await _pc?.close();
- } catch (_) {}
+ final previousPc = _pc;
_pc = null;
+ previousPc?.onIceCandidate = null;
+ previousPc?.onTrack = null;
+ previousPc?.onConnectionState = null;
+ previousPc?.onIceConnectionState = null;
+ try {
+ await previousPc?.close();
+ } catch (_) {}
_audioSender = null;
_videoSender = null;
@@ -409,6 +423,7 @@ class CallSession {
_remoteDescSet = false;
_pendingCandidates.clear();
_accepted = false;
+ _gotConnection = false;
_mediaConnected = false;
_sfuSessionId = null;
await _clearParticipantStreams();
@@ -785,6 +800,9 @@ class CallSession {
}
Future _onConnection(Map msg) async {
+ if (_ended) return;
+ // A wake command or reconnect can repeat the handshake notification.
+ if (_gotConnection) return;
_gotConnection = true;
logger.i('[call] connection notification received');
final convParams = msg['conversationParams'];
@@ -808,8 +826,13 @@ class CallSession {
}
final pc = await _createPc(ice);
+ if (_ended) {
+ await pc.close();
+ return;
+ }
_pc = pc;
await _addLocalMedia(pc);
+ if (_ended || !identical(_pc, pc)) return;
await pc.addTransceiver(
kind: RTCRtpMediaType.RTCRtpMediaTypeVideo,
@@ -846,12 +869,13 @@ class CallSession {
final done = _gatherDone;
if (done != null && !done.isCompleted) done.complete();
};
- pc.onTrack = (event) => unawaited(_onRemoteTrack(event));
+ pc.onTrack = (event) => _runCallTask(() => _onRemoteTrack(event));
pc.onDataChannel = (channel) {
if (!_qlyraProbeEnabled) return;
_bindProbeChannel(channel, ask: false);
};
pc.onIceConnectionState = (s) {
+ if (_ended || !identical(_pc, pc)) return;
logger.i('[call] ice $s');
if (s != RTCIceConnectionState.RTCIceConnectionStateFailed) return;
if (_topology != 'SERVER' || _ended) return;
@@ -865,6 +889,7 @@ class CallSession {
);
};
pc.onConnectionState = (s) {
+ if (_ended || !identical(_pc, pc)) return;
logger.i('[call] pc $s');
final connected =
s == RTCPeerConnectionState.RTCPeerConnectionStateConnected;
@@ -876,9 +901,9 @@ class CallSession {
if (role == CallRole.joiner || _topology == 'SERVER') {
_setState(CallSessionState.active);
}
- unawaited(applyAudioRoute());
- unawaited(_resolvePath());
- unawaited(_collectReceivers());
+ _runCallTask(applyAudioRoute);
+ _runCallTask(_resolvePath);
+ _runCallTask(_collectReceivers);
}
}
if (_topology == 'SERVER') return;
@@ -905,15 +930,22 @@ class CallSession {
await _resetMicRoute();
}
await _selectMicInsideEngine();
- _localStream = await navigator.mediaDevices.getUserMedia({
+ if (_ended || !identical(_pc, pc)) return;
+ final stream = await navigator.mediaDevices.getUserMedia({
'audio': AudioDevices.micConstraints(
_micDeviceId,
monitorCapture: _monitorCapture,
),
'video': _wantVideo,
});
- for (final track in _localStream!.getTracks()) {
- final sender = await pc.addTrack(track, _localStream!);
+ if (_ended || !identical(_pc, pc)) {
+ 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;
}
_applyAudioTracks();
@@ -1136,7 +1168,7 @@ class CallSession {
final id = _participantIdFrom(key.split(':').first);
if (id != null) _slotParticipant[slot] = id;
});
- unawaited(_rebindSlotTracks());
+ _runCallTask(_rebindSlotTracks);
}
Future _rebindSlotTracks() async {
@@ -1829,6 +1861,7 @@ class CallSession {
}
Future _onRemoteTrack(RTCTrackEvent event) async {
+ if (_ended) return;
logger.t(
'[call] remote track: ${event.track.kind} id=${event.track.id} '
'streams=${event.streams.length}',
@@ -1836,7 +1869,9 @@ class CallSession {
await _bindParticipantTrack(event.track);
if (event.streams.isNotEmpty) {
_remoteStreamRef = event.streams.first;
- _remoteStream.add(event.streams.first);
+ if (!_ended && !_remoteStream.isClosed) {
+ _remoteStream.add(event.streams.first);
+ }
} else {
await _collectReceivers();
}
@@ -1887,7 +1922,7 @@ class CallSession {
await stream.addTrack(track);
} catch (_) {}
}
- _remoteStream.add(stream);
+ if (!_ended && !_remoteStream.isClosed) _remoteStream.add(stream);
}
Future _logSenders() async {
@@ -1971,7 +2006,7 @@ class CallSession {
Future _onTransmittedData(Map msg) async {
final pc = _pc;
- if (pc == null) return;
+ if (pc == null || _ended) return;
final data = msg['data'];
if (data is! Map) return;
@@ -2075,7 +2110,35 @@ class CallSession {
}
}
+ void _runCallTask(Future 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) {
+ if (_ended) return;
final line = candidate.candidate;
if (line != null && line.contains(' typ relay')) {
final done = _gatherDone;
@@ -2083,15 +2146,24 @@ class CallSession {
}
if (_topology == 'SERVER') return;
final peerId = _peerId;
- if (peerId == null || candidate.candidate == null) return;
- _signaling?.transmitCandidate(
- participantId: peerId,
- participantType: _peerType,
- deviceIdx: _peerDeviceIdx,
- candidate: candidate.candidate!,
- sdpMid: candidate.sdpMid ?? '0',
- sdpMLineIndex: candidate.sdpMLineIndex ?? 0,
- );
+ final signaling = _signaling;
+ if (peerId == null || line == null || signaling == null) return;
+ _runCallTask(() async {
+ try {
+ await signaling.transmitCandidate(
+ participantId: peerId,
+ participantType: _peerType,
+ 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 accept({bool activate = true}) async {
@@ -2316,35 +2388,63 @@ class CallSession {
if (_ended) return;
_ended = true;
_setState(CallSessionState.ended);
- _dispose();
+ unawaited(
+ _dispose().catchError((Object error, StackTrace stack) {
+ logger.w('[call] cleanup failed', error: error, stackTrace: stack);
+ }),
+ );
}
Future _dispose() async {
_levelTimer?.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 {
await _probeChannel?.close();
} catch (_) {}
_probeChannel = null;
- await _closeSfuChannels();
- for (final track in _localStream?.getTracks() ?? []) {
- await track.stop();
+ try {
+ await _closeSfuChannels();
+ } catch (e) {
+ logger.w('[call] channel cleanup: $e');
}
- await _localStream?.dispose();
+ await _disposeStream(_localStream);
+ _localStream = null;
await _disposeMicStream();
- await PulseAudio.closeBridge();
+ try {
+ await PulseAudio.closeBridge();
+ } catch (e) {
+ logger.w('[call] audio cleanup: $e');
+ }
await _disposeStream(_cameraStream);
await _disposeStream(_screenStream);
_cameraStream = null;
_screenStream = null;
- await _pc?.close();
+ try {
+ await pc?.close();
+ } catch (e) {
+ logger.w('[call] peer cleanup: $e');
+ }
if (_ownRemoteStream) {
try {
await _remoteStreamRef?.dispose();
} catch (_) {}
}
await _clearParticipantStreams();
- await _signaling?.close();
+ try {
+ await _signaling?.close();
+ } catch (e) {
+ logger.w('[call] signaling cleanup: $e');
+ }
if (!_participantStreamUpdates.isClosed) {
await _participantStreamUpdates.close();
}
diff --git a/lib/core/calls/ws2_signaling.dart b/lib/core/calls/ws2_signaling.dart
index e9d9ec3..41b2a5b 100644
--- a/lib/core/calls/ws2_signaling.dart
+++ b/lib/core/calls/ws2_signaling.dart
@@ -167,6 +167,7 @@ class Ws2Signaling {
}
void _onDone(Object? error) {
+ _call = null;
for (final waiter in _requests.values) {
if (!waiter.isCompleted)
waiter.completeError(error ?? StateError('Звонок завершён'));
@@ -184,22 +185,24 @@ class Ws2Signaling {
Duration timeout = const Duration(seconds: 15),
}) async {
final call = _call;
- if (call == null) {
+ if (call == null ||
+ _closed.isCompleted ||
+ call.readyState != WebSocket.open) {
return Future.error(StateError('ws2 не подключён'));
}
try {
final sequence = ++_sequence;
final waiter = Completer>();
_requests[sequence] = waiter;
- call.add(
- jsonEncode({
- 'type': 'request',
- 'command': command,
- 'sequence': sequence,
- ...extra,
- }),
- );
try {
+ call.add(
+ jsonEncode({
+ 'type': 'request',
+ 'command': command,
+ 'sequence': sequence,
+ ...extra,
+ }),
+ );
return await waiter.future.timeout(timeout);
} finally {
_requests.remove(sequence);
diff --git a/lib/core/config/app_bottom_navigation_style.dart b/lib/core/config/app_bottom_navigation_style.dart
new file mode 100644
index 0000000..59dc1b1
--- /dev/null
+++ b/lib/core/config/app_bottom_navigation_style.dart
@@ -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(
+ prefKey: prefKey,
+ defaultValue: BottomNavigationStyle.floating,
+ encode: (value) => value.name,
+ decode: (value) => enumFromName(
+ BottomNavigationStyle.values,
+ value,
+ BottomNavigationStyle.floating,
+ ),
+ );
+
+ static ValueNotifier get current => _setting.current;
+
+ static Future load() => _setting.load();
+
+ static Future save(BottomNavigationStyle value) => _setting.save(value);
+}
diff --git a/lib/core/share/share_inbox.dart b/lib/core/share/share_inbox.dart
new file mode 100644
index 0000000..c3a36cb
--- /dev/null
+++ b/lib/core/share/share_inbox.dart
@@ -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 Function() consume;
+ final Future Function(String) acknowledge;
+ final Future Function(String) release;
+ final void Function(SharedPayload) onShare;
+ String? _activeId;
+ bool _completed = false;
+ bool _acknowledged = false;
+ Future Function()? _waitForRelease;
+ bool _requested = false;
+ Future? _operation;
+
+ Future check() {
+ _requested = true;
+ return _operation ??= _drain().whenComplete(() => _operation = null);
+ }
+
+ Future complete({Future Function()? waitForRelease}) {
+ if (_activeId == null) return Future.value();
+ _completed = true;
+ _waitForRelease ??= waitForRelease;
+ return check();
+ }
+
+ Future _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);
+ }
+ }
+}
diff --git a/lib/core/share/share_intent_bridge.dart b/lib/core/share/share_intent_bridge.dart
index af54a47..d58a721 100644
--- a/lib/core/share/share_intent_bridge.dart
+++ b/lib/core/share/share_intent_bridge.dart
@@ -10,6 +10,7 @@ import '../../main.dart';
import '../../models/shared_payload.dart';
import '../utils/logger.dart';
import 'shared_file_uploads.dart';
+import 'share_inbox.dart';
class ShareIntentBridge {
ShareIntentBridge._();
@@ -26,6 +27,13 @@ class ShareIntentBridge {
SharedPayload? _pending;
int _retriesLeft = 0;
Timer? _retry;
+ late final _inbox = ShareInbox(
+ consume: () => _method.invokeMethod('consumeInitialShare'),
+ acknowledge: (id) =>
+ _method.invokeMethod('acknowledgeShare', {'id': id}),
+ release: (id) => _method.invokeMethod('releaseShare', {'id': id}),
+ onShare: _receivePayload,
+ );
bool get _native {
try {
@@ -55,7 +63,7 @@ class ShareIntentBridge {
Future checkInitialShare() async {
if (!_native) return;
try {
- _onEvent(await _method.invokeMethod('consumeInitialShare'));
+ await _inbox.check();
} catch (e) {
logger.w('ShareIntentBridge.checkInitialShare: $e');
}
@@ -73,8 +81,10 @@ class ShareIntentBridge {
}
void _onEvent(Object? event) {
- final payload = SharedPayload.fromMap(event);
- if (payload == null) return;
+ unawaited(checkInitialShare());
+ }
+
+ void _receivePayload(SharedPayload payload) {
logger.i(
'Поделиться: получено ${payload.files.length} файлов'
'${payload.text != null ? ' и текст' : ''}',
@@ -91,7 +101,7 @@ class ShareIntentBridge {
final context = QlyraApp.navigatorKey.currentContext;
if (!_ready || context == null || api.state != SessionState.online) {
if (_retriesLeft <= 0) {
- _pending = null;
+ // Keep the native record and payload until a session becomes ready.
return;
}
_retriesLeft--;
@@ -108,9 +118,17 @@ class ShareIntentBridge {
pushSwipeable(
context,
(_) => ChatListScreen(sharePayload: payload),
- ).whenComplete(() {
+ ).whenComplete(() async {
_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();
}),
);
diff --git a/lib/frontend/screens/calls/call_screen.dart b/lib/frontend/screens/calls/call_screen.dart
index b7a1fe9..928991e 100644
--- a/lib/frontend/screens/calls/call_screen.dart
+++ b/lib/frontend/screens/calls/call_screen.dart
@@ -322,7 +322,11 @@ class _CallScreenState extends State with TickerProviderStateMixin {
void _onState(CallSessionState state) {
if (!mounted) return;
setState(() => _state = state);
- if (state == CallSessionState.ended) _close();
+ if (state == CallSessionState.ended) {
+ final failure = _session?.failureReason;
+ if (failure != null) showCustomNotification(context, failure);
+ _close();
+ }
}
Future _accept() async {
diff --git a/lib/frontend/screens/chats/chat/chat_controller.dart b/lib/frontend/screens/chats/chat/chat_controller.dart
index 42e4a8e..0dfecd1 100644
--- a/lib/frontend/screens/chats/chat/chat_controller.dart
+++ b/lib/frontend/screens/chats/chat/chat_controller.dart
@@ -202,12 +202,13 @@ class ChatController extends ChangeNotifier {
Future loadMessageWindow({
required String targetId,
required int targetTime,
+ bool Function()? stillWanted,
}) async {
if (myId == 0 || targetTime <= 0) return false;
final onlyVisible = !QlyraSettings.viewDeleted.value;
var window = await loadWindowFromDb(targetTime, onlyVisible);
- if (!isMounted()) return false;
+ if (!isMounted() || stillWanted?.call() == false) return false;
if (!window.any((m) => m.id == targetId)) {
final fetched = await messagesModule.fetchHistory(
@@ -217,12 +218,12 @@ class ChatController extends ChangeNotifier {
forward: jumpWindowAfter,
backward: jumpWindowBefore + 1,
);
- if (!isMounted()) return false;
+ if (!isMounted() || stillWanted?.call() == false) return false;
if (fetched.isNotEmpty && QlyraSettings.viewDeleted.value) {
await chats.reconcileDeletedFromFetch(myId, chatId, fetched);
}
window = await loadWindowFromDb(targetTime, onlyVisible);
- if (!isMounted()) return false;
+ if (!isMounted() || stillWanted?.call() == false) return false;
}
if (window.isEmpty) return false;
diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart
index 67a806e..2ddeba0 100644
--- a/lib/frontend/screens/chats/chat_list_screen.dart
+++ b/lib/frontend/screens/chats/chat_list_screen.dart
@@ -31,6 +31,8 @@ import '../../widgets/sheet_helpers.dart';
import '../../widgets/swipe_route.dart';
import '../../widgets/toast_placement.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/visible_page_tickers.dart';
import '../../widgets/informer_banner_tile.dart';
@@ -2472,92 +2474,123 @@ class _ChatListScreenState extends State
return best;
}
- return AnimatedPositioned(
- duration: const Duration(milliseconds: 300),
- curve: Curves.easeOutCubic,
- left: 8,
- right: 8,
- bottom: _isSelectionMode ? -100 : bottomInset + 10.0,
- child: RepaintBoundary(
- child: GestureDetector(
- behavior: HitTestBehavior.opaque,
- onHorizontalDragStart: (_) {
- if (_isSelectionMode) return;
- _navPageAnimController.stop();
- _navPageAnimController.value = 1.0;
- _navDragDx.value = 0;
- setState(() {
- _navDragging = true;
- _navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex);
- });
- },
- onHorizontalDragUpdate: (details) {
- if (!_navDragging) return;
- _navDragDx.value += details.delta.dx;
- },
- onHorizontalDragEnd: (_) {
- if (!_navDragging) return;
- final left = (_navDragBaseLeft + _navDragDx.value).clamp(
- minBubbleLeft,
- maxBubbleLeft,
- );
- final next = indexForBubbleLeft(left);
- _navDragDx.value = 0;
- setState(() {
- _currentNavIndex = next;
- _navDragging = false;
- });
- activeNavTab.value = next;
- if (next == 0) _scheduleInformerPresentation();
- },
- onHorizontalDragCancel: () {
- if (!_navDragging) return;
- _navDragDx.value = 0;
- setState(() {
- _navDragging = false;
- });
- },
- child: ValueListenableBuilder(
- valueListenable: _navDragDx,
- builder: (context, navDragDx, _) {
- final position = _navDragging
- ? ((_navDragBaseLeft + navDragDx).clamp(
- 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);
+ return ValueListenableBuilder(
+ valueListenable: AppBottomNavigationStyle.current,
+ builder: (context, style, _) {
+ if (style == BottomNavigationStyle.compact) {
+ return Positioned(
+ left: 0,
+ right: 0,
+ bottom: _isSelectionMode ? -150 : 0,
+ child: CompactBottomNavigationBar(
+ 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),
+ ],
+ currentIndex: _currentNavIndex,
+ onTap: _onNavTabSelected,
+ onItemLongPress: (index, position) {
+ if (index == 3) _openAccountSwitcher(position);
+ },
+ ),
+ );
+ }
+ return AnimatedPositioned(
+ duration: const Duration(milliseconds: 300),
+ curve: Curves.easeOutCubic,
+ left: 8,
+ right: 8,
+ bottom: _isSelectionMode ? -100 : bottomInset + 10.0,
+ child: RepaintBoundary(
+ child: GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onHorizontalDragStart: (_) {
+ if (_isSelectionMode) return;
+ _navPageAnimController.stop();
+ _navPageAnimController.value = 1.0;
+ _navDragDx.value = 0;
+ setState(() {
+ _navDragging = true;
+ _navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex);
+ });
+ },
+ onHorizontalDragUpdate: (details) {
+ if (!_navDragging) return;
+ _navDragDx.value += details.delta.dx;
+ },
+ onHorizontalDragEnd: (_) {
+ if (!_navDragging) return;
+ final left = (_navDragBaseLeft + _navDragDx.value).clamp(
+ minBubbleLeft,
+ maxBubbleLeft,
+ );
+ final next = indexForBubbleLeft(left);
+ _navDragDx.value = 0;
+ setState(() {
+ _currentNavIndex = next;
+ _navDragging = false;
+ });
+ activeNavTab.value = next;
+ if (next == 0) _scheduleInformerPresentation();
+ },
+ onHorizontalDragCancel: () {
+ if (!_navDragging) return;
+ _navDragDx.value = 0;
+ setState(() {
+ _navDragging = false;
+ });
+ },
+ child: ValueListenableBuilder(
+ valueListenable: _navDragDx,
+ builder: (context, navDragDx, _) {
+ final position = _navDragging
+ ? ((_navDragBaseLeft + navDragDx).clamp(
+ 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);
+ },
+ );
},
- );
- },
+ ),
+ ),
),
- ),
- ),
+ );
+ },
);
}
diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart
index 635c8f8..0af1363 100644
--- a/lib/frontend/screens/chats/chat_screen.dart
+++ b/lib/frontend/screens/chats/chat_screen.dart
@@ -1,3 +1,4 @@
+import '../../../backend/modules/pending_message_deletions.dart';
import 'package:ikar/frontend/widgets/toast_placement.dart';
import 'package:ikar/frontend/widgets/hint_bubble.dart';
import 'chat/view/message_row_widgets.dart' show SelectableMessageRow;
@@ -916,6 +917,7 @@ class _ChatScreenState extends State
unawaited(_loadPeerKind());
unawaited(_loadWallpaper());
unawaited(_loadReactionSettings());
+ PendingMessageDeletions.instance.addListener(_pendingRemovalsChanged);
_forwardedSub = ForwardSender.delivered.listen(_onForwardedHere);
_departedSub = chats.departedChats.listen((id) {
if (!mounted || id != widget.chatId) return;
@@ -2016,11 +2018,17 @@ class _ChatScreenState extends State
return true;
}
- Future _loadMessageWindow(String messageId, int targetTime) async {
+ Future _loadMessageWindow(
+ String messageId,
+ int targetTime, {
+ bool Function()? stillWanted,
+ }) async {
if (targetTime <= 0) {
await _walkHistoryBack(
- reached: () => _messages.any((m) => m.id == messageId),
- maxPages: 10,
+ reached: () =>
+ stillWanted?.call() == false ||
+ _messages.any((m) => m.id == messageId),
+ maxPages: 100,
);
return;
}
@@ -2030,6 +2038,7 @@ class _ChatScreenState extends State
await _chatController.loadMessageWindow(
targetId: messageId,
targetTime: targetTime,
+ stillWanted: stillWanted,
);
} finally {
_historyAutoloadSuppressCount--;
@@ -2424,6 +2433,7 @@ class _ChatScreenState extends State
_detachUploadStatus();
_pushSub?.cancel();
_reactionUpdatesSub?.cancel();
+ PendingMessageDeletions.instance.removeListener(_pendingRemovalsChanged);
_forwardedSub?.cancel();
_departedSub?.cancel();
_messageEventSub?.cancel();
@@ -2762,39 +2772,70 @@ class _ChatScreenState extends State
if (forEveryone == null || !mounted) return;
_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 messages, {
+ required bool forEveryone,
+ }) {
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(
context,
- 'Удаление сообщений через 5 секунд',
- onUndo: () {},
+ messages.length == 1 ? 'Сообщение удалено' : 'Сообщения удалены',
+ onUndo: restore,
onCommit: () async {
- if ((await AppDatabase.loadActiveProfile())?.id != accountId) return;
- final ok = await messagesModule.deleteMessages(
- widget.chatId,
- serverMsgs.map((m) => m.id).toList(),
- forEveryone: forEveryone,
- );
- if (ok) {
- for (final message in serverMsgs) {
- await AppDatabase.deleteMessage(
- accountId,
- widget.chatId,
- message.id,
+ try {
+ if ((await AppDatabase.loadActiveProfile())?.id != accountId) return;
+ final ok = await messagesModule.deleteMessages(
+ chatId,
+ ids,
+ forEveryone: forEveryone,
+ );
+ if (!ok) {
+ if (overlay.mounted) {
+ showCustomNotificationOnOverlay(
+ overlay,
+ 'Не удалось удалить сообщения',
+ );
+ }
+ 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);
- await chats.reconcileLastMessage(accountId, widget.chatId);
+ } finally {
+ 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
}
Future _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 canForEveryone = (isMe || _canDeleteOthers) && !isLocalOnly;
@@ -3251,30 +3297,7 @@ class _ChatScreenState extends State
final forEveryone = await _showDeleteMessageDialog(canForEveryone);
if (forEveryone == null || !mounted) return;
- final accountId = _myId;
- 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);
- },
- );
+ _removeMessagesWithUndo([message], forEveryone: forEveryone);
}
void _startDeleteAnimation(String messageId) {
@@ -4915,18 +4938,45 @@ class _ChatScreenState extends State
);
}
- void _jumpToMessage(String messageId, {String? fromId}) {
- final index = _messages.indexWhere((m) => m.id == messageId);
- if (index == -1) {
- showCustomNotification(context, 'Сообщение не загружено');
- return;
- }
+ int _messageNavigationToken = 0;
+ Future _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 (!_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 ctx = key.currentContext;
- if (ctx != null) {
+ if (ctx != null && ctx.mounted) {
Scrollable.ensureVisible(
ctx,
duration: const Duration(milliseconds: 300),
@@ -4934,7 +4984,12 @@ class _ChatScreenState extends State
alignment: 0.4,
);
} else {
- unawaited(_scrollToMessagePrecise(messageId, alignment: 0.4));
+ await _scrollToMessagePrecise(
+ messageId,
+ alignment: 0.4,
+ stillWanted: current,
+ );
+ if (!mounted || !current()) return;
}
_highlightTimer?.cancel();
@@ -4986,14 +5041,20 @@ class _ChatScreenState extends State
}
Future _runGoToMessage(String id, int targetTime) async {
+ final token = ++_messageNavigationToken;
+ final epoch = _userGestureEpoch;
+ bool current() =>
+ mounted &&
+ token == _messageNavigationToken &&
+ epoch == _userGestureEpoch;
await WidgetsBinding.instance.endOfFrame;
- if (!mounted) return;
+ if (!current()) return;
if (!_messages.any((m) => m.id == id)) {
- await _loadMessageWindow(id, targetTime);
- if (!mounted) return;
+ await _loadMessageWindow(id, targetTime, stillWanted: current);
+ if (!current()) return;
await WidgetsBinding.instance.endOfFrame;
- if (!mounted) return;
+ if (!current()) return;
}
if (!_messages.any((m) => m.id == id)) {
@@ -5009,8 +5070,8 @@ class _ChatScreenState extends State
if (_highlightMessageId.value == id) _highlightMessageId.value = null;
});
- await _scrollToMessagePrecise(id);
- _finishTargetNavigation();
+ await _scrollToMessagePrecise(id, stillWanted: current);
+ if (token == _messageNavigationToken && mounted) _finishTargetNavigation();
}
({int min, int max})? _laidOutMessageRange(List items) {
@@ -5034,6 +5095,7 @@ class _ChatScreenState extends State
Future _scrollToMessagePrecise(
String id, {
double alignment = 0.32,
+ bool Function()? stillWanted,
}) async {
if (!mounted || !_scrollController.hasClients) return;
if (_messages.indexWhere((m) => m.id == id) == -1) return;
@@ -5050,7 +5112,7 @@ class _ChatScreenState extends State
var stable = 0;
for (var iter = 0; iter < 120; iter++) {
if (!mounted || !_scrollController.hasClients) return;
- if (_userGestureEpoch != epoch) return;
+ if (_userGestureEpoch != epoch || stillWanted?.call() == false) return;
final listObj = _listKey.currentContext?.findRenderObject();
final boxObj = _keyForMessage(id).currentContext?.findRenderObject();
final p = _scrollController.position;
@@ -5382,6 +5444,13 @@ class _ChatScreenState extends State
for (int i = 0; i < visible; i++) {
final msg = _messages[i];
+ if (PendingMessageDeletions.instance.contains(
+ _myId,
+ widget.chatId,
+ msg.id,
+ )) {
+ continue;
+ }
final msgDate = DateTime.fromMillisecondsSinceEpoch(msg.time);
final dayMillis = DateTime(
msgDate.year,
diff --git a/lib/frontend/screens/profile/appearance_screen.dart b/lib/frontend/screens/profile/appearance_screen.dart
index 32acc22..6f4eb36 100644
--- a/lib/frontend/screens/profile/appearance_screen.dart
+++ b/lib/frontend/screens/profile/appearance_screen.dart
@@ -21,6 +21,7 @@ import '../../../l10n/app_localizations.dart';
import '../../../main.dart';
import '../../widgets/liquid_glass.dart';
import '../../widgets/settings_card.dart';
+import '../../widgets/bottom_navigation_style_card.dart';
import '../../../core/config/app_shape.dart';
import '../../../core/utils/seed_scheme.dart';
@@ -124,6 +125,8 @@ class _AppearanceScreenState extends State {
const SizedBox(height: 12),
const _VisualStyleCard(),
const SizedBox(height: 12),
+ const BottomNavigationStyleCard(),
+ const SizedBox(height: 12),
const _ChatChromeCard(),
const SizedBox(height: 12),
const _ComposerBarCard(),
diff --git a/lib/frontend/widgets/attachment/bubbles/file_bubble.dart b/lib/frontend/widgets/attachment/bubbles/file_bubble.dart
index 7fd2ce3..23f1927 100644
--- a/lib/frontend/widgets/attachment/bubbles/file_bubble.dart
+++ b/lib/frontend/widgets/attachment/bubbles/file_bubble.dart
@@ -27,21 +27,52 @@ import '../../share_unopenable_file.dart';
import '../../upload_progress_ring.dart';
import 'bubble_context.dart';
-class FileBubble extends StatelessWidget {
- static const double _previewWidth = 240;
- static const double _previewHeight = 160;
+typedef FileBubbleOpener =
+ Future Function(
+ String cacheName,
+ Future Function() resolveUrl, {
+ void Function(double)? onProgress,
+ void Function()? onReady,
+ DownloadMetadata? download,
+ bool downloadOnlyIfMissing,
+ });
+class FileBubble extends StatefulWidget {
final BubbleContext ctx;
final FileAttachment file;
final bool fill;
+ final FileBubbleOpener openFile;
const FileBubble({
super.key,
required this.ctx,
required this.file,
this.fill = false,
+ this.openFile = openCachedFile,
});
+ @override
+ State createState() => _FileBubbleState();
+}
+
+class _FileBubbleState extends State {
+ 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 _openOnce(Future Function() open) async {
+ if (_opening || ctx.uploadProgress != null) return;
+ setState(() => _opening = true);
+ try {
+ await open();
+ } finally {
+ if (mounted) setState(() => _opening = false);
+ }
+ }
+
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
@@ -124,7 +155,11 @@ class FileBubble extends StatelessWidget {
builder: (context, progress, _) => Text(
progress != null
? '${(progress * 100).round()}% · $sizeStr'
- : sizeStr,
+ : '${_opening
+ ? 'Открытие…'
+ : MediaCache.presence(cacheName).value
+ ? 'Открыть'
+ : 'Скачать'} · $sizeStr',
style: TextStyle(
color: ctx.dim,
fontSize: 12,
@@ -144,7 +179,7 @@ class FileBubble extends StatelessWidget {
: ctx.cs.primary;
Widget circle(Widget child, VoidCallback? onTap) {
return GestureDetector(
- onTap: onTap,
+ onTap: _opening ? null : onTap,
child: Container(
width: 34,
height: 34,
@@ -159,13 +194,15 @@ class FileBubble extends StatelessWidget {
);
}
- if (progress != null) {
+ if (progress != null || _opening) {
return circle(
Padding(
padding: const EdgeInsets.all(8),
child: CircularProgressIndicator(
strokeWidth: 2,
- value: progress > 0 ? progress : null,
+ value: progress != null && progress > 0
+ ? progress
+ : null,
color: iconColor,
),
),
@@ -389,7 +426,7 @@ class FileBubble extends StatelessWidget {
);
}
- Future _openInViewer(
+ Future _openInViewerInner(
BuildContext context,
String name,
String cacheName,
@@ -498,6 +535,22 @@ class FileBubble extends StatelessWidget {
BuildContext context,
FileAttachment file,
String name,
+ ) => _openOnce(() => _downloadFileInner(context, file, name));
+ Future _playAudioFile(
+ BuildContext context,
+ FileAttachment file,
+ String name,
+ ) => _openOnce(() => _playAudioFileInner(context, file, name));
+ Future _openInViewer(
+ BuildContext context,
+ String name,
+ String cacheName,
+ ) => _openOnce(() => _openInViewerInner(context, name, cacheName));
+
+ Future _downloadFileInner(
+ BuildContext context,
+ FileAttachment file,
+ String name,
) async {
final fileId = file.fileId;
if (fileId == null) {
@@ -514,7 +567,7 @@ class FileBubble extends StatelessWidget {
final kind = downloadKindForName(name);
MediaDownloadProgress.set(cacheName, 0);
- final result = await openCachedFile(
+ final result = await widget.openFile(
cacheName,
_fileUrl,
downloadOnlyIfMissing: true,
@@ -553,7 +606,7 @@ class FileBubble extends StatelessWidget {
}
}
- Future _playAudioFile(
+ Future _playAudioFileInner(
BuildContext context,
FileAttachment file,
String name,
diff --git a/lib/frontend/widgets/bottom_navigation_style_card.dart b/lib/frontend/widgets/bottom_navigation_style_card.dart
new file mode 100644
index 0000000..fccf133
--- /dev/null
+++ b/lib/frontend/widgets/bottom_navigation_style_card.dart
@@ -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(
+ valueListenable: AppBottomNavigationStyle.current,
+ builder: (context, current, _) =>
+ SegmentedButton(
+ 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);
+ },
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/frontend/widgets/compact_bottom_navigation_bar.dart b/lib/frontend/widgets/compact_bottom_navigation_bar.dart
new file mode 100644
index 0000000..9d75d15
--- /dev/null
+++ b/lib/frontend/widgets/compact_bottom_navigation_bar.dart
@@ -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 items;
+ final int currentIndex;
+ final ValueChanged 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? 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,
+ ),
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb
index 9eff517..028ee3b 100644
--- a/lib/l10n/app_en.arb
+++ b/lib/l10n/app_en.arb
@@ -3309,5 +3309,9 @@
"avatarEditorSetPhoto": "Set photo",
"avatarEditorDraw": "Draw",
"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"
}
diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart
index 7fe6309..98c9b7d 100644
--- a/lib/l10n/app_localizations.dart
+++ b/lib/l10n/app_localizations.dart
@@ -11641,6 +11641,30 @@ abstract class AppLocalizations {
/// In en, this message translates to:
/// **'If the photo you need isn\'t in the gallery'**
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
diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart
index 6d078a3..d210121 100644
--- a/lib/l10n/app_localizations_en.dart
+++ b/lib/l10n/app_localizations_en.dart
@@ -6554,4 +6554,17 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get avatarPickerFilesSubtitle =>
'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';
}
diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart
index ff561b2..5998d25 100644
--- a/lib/l10n/app_localizations_ru.dart
+++ b/lib/l10n/app_localizations_ru.dart
@@ -6603,4 +6603,17 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get avatarPickerFilesSubtitle => 'Если нужного фото нет в галерее';
+
+ @override
+ String get appearanceBottomNavigationTitle => 'Нижняя панель';
+
+ @override
+ String get appearanceBottomNavigationSubtitle =>
+ 'Выберите удобный вид навигации';
+
+ @override
+ String get appearanceBottomNavigationFloating => 'Плавающая';
+
+ @override
+ String get appearanceBottomNavigationCompact => 'Компактная';
}
diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb
index 07ca507..e42fd5d 100644
--- a/lib/l10n/app_ru.arb
+++ b/lib/l10n/app_ru.arb
@@ -2110,5 +2110,9 @@
"avatarEditorSetPhoto": "Установить фото",
"avatarEditorDraw": "Рисовать",
"avatarPickerFilesTitle": "Выбрать фото из файлов",
- "avatarPickerFilesSubtitle": "Если нужного фото нет в галерее"
+ "avatarPickerFilesSubtitle": "Если нужного фото нет в галерее",
+ "appearanceBottomNavigationTitle": "Нижняя панель",
+ "appearanceBottomNavigationSubtitle": "Выберите удобный вид навигации",
+ "appearanceBottomNavigationFloating": "Плавающая",
+ "appearanceBottomNavigationCompact": "Компактная"
}
diff --git a/lib/main.dart b/lib/main.dart
index 0539ec9..bd71a5a 100644
--- a/lib/main.dart
+++ b/lib/main.dart
@@ -63,6 +63,7 @@ import 'core/config/app_chat_chrome.dart';
import 'core/config/app_composer_background.dart';
import 'core/config/app_composer_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/storage/chat_wallpaper_store.dart';
import 'core/utils/wallpaper_seed.dart';
@@ -255,6 +256,7 @@ void main(List args) async {
final composerStyleFuture = AppComposerStyle.load();
final composerBackgroundFuture = AppComposerBackground.load();
final navPillStyleFuture = AppNavPillStyle.load();
+ await AppBottomNavigationStyle.load();
final wallpaperTintFuture = AppWallpaperTint.load();
final themeScheduleFuture = AppThemeSchedule.load();
final messageActionsFuture = AppMessageActionsStyle.load();
diff --git a/pubspec.yaml b/pubspec.yaml
index aa15350..b4b48f9 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -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
# 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.
-version: 1.0.6+7
+version: 1.0.7+8
environment:
sdk: ^3.10.4
diff --git a/test/call_network_failure_test.dart b/test/call_network_failure_test.dart
new file mode 100644
index 0000000..6443aa7
--- /dev/null
+++ b/test/call_network_failure_test.dart
@@ -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 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 hangup({String reason = 'HUNGUP'}) async {}
+ @override
+ Future 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 = [];
+ runZonedGuarded(() {
+ session.receiveLocalCandidateForTesting(
+ RTCIceCandidate('candidate:mobile', '0', 0),
+ );
+ }, (error, stack) => errors.add(error));
+ await Future.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.delayed(Duration.zero);
+ expect(reconnecting, isTrue);
+ expect(signaling.sent, 1);
+ expect(errors, isEmpty);
+ expect(session.currentState, CallSessionState.ended);
+ await Future.delayed(const Duration(milliseconds: 1100));
+ },
+ );
+}
diff --git a/test/compact_bottom_navigation_test.dart b/test/compact_bottom_navigation_test.dart
new file mode 100644
index 0000000..c922045
--- /dev/null
+++ b/test/compact_bottom_navigation_test.dart
@@ -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,
+ );
+ });
+}
diff --git a/test/file_bubble_opening_test.dart b/test/file_bubble_opening_test.dart
new file mode 100644
index 0000000..f954e32
--- /dev/null
+++ b/test/file_bubble_opening_test.dart
@@ -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();
+ 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();
+ 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);
+ },
+ );
+}
diff --git a/test/pending_message_deletions_test.dart b/test/pending_message_deletions_test.dart
new file mode 100644
index 0000000..4e7bbd9
--- /dev/null
+++ b/test/pending_message_deletions_test.dart
@@ -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();
+ },
+ );
+}
diff --git a/test/share_inbox_test.dart b/test/share_inbox_test.dart
new file mode 100644
index 0000000..7a48051
--- /dev/null
+++ b/test/share_inbox_test.dart
@@ -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 = [];
+ final acknowledged = [];
+ final released = [];
+ final upload = Completer();
+ 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.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 = [];
+ final shown = [];
+ 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();
+ },
+ );
+}
diff --git a/tool/live_ikar_smoke.dart b/tool/live_ikar_smoke.dart
index 8a7dc4b..372db29 100644
--- a/tool/live_ikar_smoke.dart
+++ b/tool/live_ikar_smoke.dart
@@ -130,6 +130,21 @@ Future main(List args) async {
'Nonmember could read chat: ${forbidden['payload']}',
);
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 richPayload = {
'chatId': chatId,
diff --git a/tool/public_turn_smoke.dart b/tool/public_turn_smoke.dart
index 8e69b40..fd92a7a 100644
--- a/tool/public_turn_smoke.dart
+++ b/tool/public_turn_smoke.dart
@@ -4,7 +4,7 @@ import 'dart:io';
import 'dart:math';
import 'dart:typed_data';
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;
List attr(int type, List value) {
@@ -36,7 +36,7 @@ class Frame {
}
class Turn {
- Turn(this.socket) {
+ Turn(Socket socket) : write = socket.add, stop = socket.destroy {
socket.listen(
(chunk) {
buffer.addAll(chunk);
@@ -51,7 +51,26 @@ class Turn {
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) write;
+ final void Function() stop;
final frames = StreamController .broadcast();
final buffer = [];
late String username, password, realm, nonce;
@@ -63,7 +82,9 @@ class Turn {
..setUint32(4, 0x2112a442);
final bytes = header.buffer.asUint8List();
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];
if (!auth) return Uint8List.fromList(prefix);
final key = md5.convert(utf8.encode('$username:$realm:$password')).bytes;
@@ -84,7 +105,7 @@ class Turn {
final response = frames.stream
.firstWhere((f) => f.transaction == transaction)
.timeout(const Duration(seconds: 10));
- socket.add(wire);
+ write(wire);
return response;
}
@@ -94,7 +115,9 @@ class Turn {
..setUint16(2, address.$2 ^ 0x2112);
final ip = InternetAddress(address.$1).rawAddress;
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());
}
@@ -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))]),
);
Future receive() => frames.stream
.firstWhere((f) => f.type == 0x17)
.timeout(const Duration(seconds: 10))
.then((f) => utf8.decode(f.attributes[0x13]!));
- void close() => socket.destroy();
+ void close() => stop();
}
Future main(List args) async {
+ final udp = args.contains('--udp');
+ final hosts = args.where((arg) => !arg.startsWith('--')).toList();
final devices = [];
final relays = [];
WebSocket? signaling;
try {
- for (var i = 0; i < 2; i++)
+ for (var i = 0; i < 2; i++) {
devices.add(
await live.Device.create(
Uri.parse('https://ikar.kusoft.xyz'),
@@ -154,6 +179,7 @@ Future main(List args) async {
'IKAR TURN test',
),
);
+ }
final call = await devices[0].ok(78, {
'calleeIds': [devices[1].id],
'isVideo': false,
@@ -164,18 +190,26 @@ Future main(List args) async {
);
final hello = jsonDecode(await signaling.first as String);
final turn = hello['conversationParams']['turn'];
- final address = args.isNotEmpty
- ? args.first
+ final address = hosts.isNotEmpty
+ ? hosts.first
: RegExp(
r'^turn:([^:?/]+)',
).firstMatch((turn['urls'] as List).first)!.group(1)!;
for (var i = 0; i < 2; i++) {
- final relay = Turn(
- await Socket.connect(
- address,
- 3478,
- ).timeout(const Duration(seconds: 10)),
- );
+ final relay = udp
+ ? Turn.datagram(
+ await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0),
+ (await InternetAddress.lookup(
+ address,
+ type: InternetAddressType.IPv4,
+ )).first,
+ )
+ : Turn(
+ await Socket.connect(
+ address,
+ 3478,
+ ).timeout(const Duration(seconds: 10)),
+ );
relays.add(relay);
await relay.allocate(turn['username'], turn['credential']);
}
@@ -196,22 +230,22 @@ Future main(List args) async {
);
final report = {
'passed': true,
- 'transport': 'TCP',
+ 'transport': udp ? 'UDP' : 'TCP',
'serverAddress': address,
- 'externalAccess': args.isEmpty,
+ 'externalAccess': hosts.isEmpty,
'relayPorts': [a.relay.$2, b.relay.$2],
'bidirectionalData': true,
'client': 'Windows',
'timestamp': DateTime.now().toUtc().toIso8601String(),
};
await File(
- args.isEmpty
- ? 'artifacts/public-turn-verification.json'
- : 'artifacts/local-turn-verification.json',
+ 'artifacts/${hosts.isEmpty ? 'public' : 'local'}-turn-verification${udp ? '-udp' : ''}.json',
).writeAsString(const JsonEncoder.withIndent(' ').convert(report));
stdout.writeln(jsonEncode(report));
} finally {
- for (final relay in relays) relay.close();
+ for (final relay in relays) {
+ relay.close();
+ }
await signaling?.close();
for (final d in devices) {
await d.ok(199, {});