From 317cfdae388f71ebba0ebbacfa54f487e5556cec Mon Sep 17 00:00:00 2001 From: sevenhill Date: Thu, 8 Oct 2026 20:38:54 +0300 Subject: [PATCH] Release IKAR 1.0.7: port navigation and sharing improvements, harden calls --- README.md | 23 +- android/.gitignore | 1 + .../main/kotlin/ru/qlyra/app/MainActivity.kt | 56 ++--- .../main/kotlin/ru/qlyra/app/ShareIntake.kt | 4 +- .../main/kotlin/ru/qlyra/app/ShareQueue.kt | 67 ++++++ .../Ikar.Server.Tests/MessageLookupTests.cs | 40 ++++ .../QlyraCompatibilityControllerTests.cs | 2 +- .../QlyraCompatibilityController.cs | 8 + lib/backend/modules/messages.dart | 20 +- .../modules/pending_message_deletions.dart | 24 ++ lib/core/calls/call_session.dart | 166 +++++++++++--- lib/core/calls/ws2_signaling.dart | 21 +- .../config/app_bottom_navigation_style.dart | 26 +++ lib/core/share/share_inbox.dart | 66 ++++++ lib/core/share/share_intent_bridge.dart | 30 ++- lib/frontend/screens/calls/call_screen.dart | 6 +- .../screens/chats/chat/chat_controller.dart | 7 +- .../screens/chats/chat_list_screen.dart | 201 ++++++++++------- lib/frontend/screens/chats/chat_screen.dart | 205 ++++++++++++------ .../screens/profile/appearance_screen.dart | 3 + .../attachment/bubbles/file_bubble.dart | 73 ++++++- .../widgets/bottom_navigation_style_card.dart | 60 +++++ .../compact_bottom_navigation_bar.dart | 152 +++++++++++++ lib/l10n/app_en.arb | 6 +- lib/l10n/app_localizations.dart | 24 ++ lib/l10n/app_localizations_en.dart | 13 ++ lib/l10n/app_localizations_ru.dart | 13 ++ lib/l10n/app_ru.arb | 6 +- lib/main.dart | 2 + pubspec.yaml | 2 +- test/call_network_failure_test.dart | 65 ++++++ test/compact_bottom_navigation_test.dart | 84 +++++++ test/file_bubble_opening_test.dart | 88 ++++++++ test/pending_message_deletions_test.dart | 20 ++ test/share_inbox_test.dart | 86 ++++++++ tool/live_ikar_smoke.dart | 15 ++ tool/public_turn_smoke.dart | 80 +++++-- 37 files changed, 1483 insertions(+), 282 deletions(-) create mode 100644 android/app/src/main/kotlin/ru/qlyra/app/ShareQueue.kt create mode 100644 ikar_server/src/Ikar.Server.Tests/MessageLookupTests.cs create mode 100644 lib/backend/modules/pending_message_deletions.dart create mode 100644 lib/core/config/app_bottom_navigation_style.dart create mode 100644 lib/core/share/share_inbox.dart create mode 100644 lib/frontend/widgets/bottom_navigation_style_card.dart create mode 100644 lib/frontend/widgets/compact_bottom_navigation_bar.dart create mode 100644 test/call_network_failure_test.dart create mode 100644 test/compact_bottom_navigation_test.dart create mode 100644 test/file_bubble_opening_test.dart create mode 100644 test/pending_message_deletions_test.dart create mode 100644 test/share_inbox_test.dart 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, {});