From 6a5920ff034b76f312edd74eec53bdb37d33f9c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9A=D1=83=D1=80=D0=BD=D0=B0=D1=82=20=D0=90=D0=BD=D0=B4?= =?UTF-8?q?=D1=80=D0=B5=D0=B9?= Date: Fri, 10 Jul 2026 15:45:34 +0300 Subject: [PATCH] Improve IKAR reliability, security, and Android UX --- .env.example | 9 +- Ikar.slnx | 3 +- IkarClassic.sln | 73 ++---- docker-compose.rpi5.yml | 48 ++++ scripts/backup-ikar-data.sh | 34 +++ scripts/restore-ikar-data.sh | 44 ++++ src/Ikar.AndroidKotlin/app/build.gradle.kts | 8 +- src/Ikar.AndroidKotlin/app/proguard-rules.pro | 18 +- .../app/src/main/AndroidManifest.xml | 5 +- .../com/seven/ikar/kotlin/app/AppContainer.kt | 2 +- .../kotlin/core/local/IkarLocalDatabase.kt | 10 +- .../kotlin/core/local/TransferQueueEntity.kt | 8 + .../kotlin/core/model/ClientMessageIds.kt | 16 ++ .../seven/ikar/kotlin/core/model/Models.kt | 6 +- .../ikar/kotlin/core/network/ApiClient.kt | 28 ++- .../core/push/NotificationActionReceiver.kt | 35 +++ .../push/PushNotificationDisplayService.kt | 29 ++- .../kotlin/core/realtime/RealtimeService.kt | 4 + .../core/security/AndroidKeystoreCipher.kt | 61 +++++ .../ikar/kotlin/core/session/SessionStore.kt | 37 ++- .../core/settings/AndroidAppSettings.kt | 27 ++- .../ikar/kotlin/feature/chat/ChatScreen.kt | 81 +++++-- .../ikar/kotlin/feature/chat/ChatViewModel.kt | 125 ++++++++-- .../ikar/kotlin/feature/chats/ChatsScreen.kt | 56 +++-- .../kotlin/feature/chats/ChatsViewModel.kt | 12 +- .../ikar/kotlin/feature/login/LoginScreen.kt | 33 +-- .../kotlin/feature/login/LoginViewModel.kt | 5 +- .../feature/transfer/TextMessageWorker.kt | 62 +++++ .../transfer/TransferQueueRepository.kt | 69 ++++++ .../ikar/kotlin/ui/AuthenticatedAsyncImage.kt | 7 +- .../com/seven/ikar/kotlin/ui/IkarChrome.kt | 8 +- .../app/src/main/res/values-en/strings.xml | 17 ++ .../app/src/main/res/values/strings.xml | 17 ++ .../kotlin/core/model/ClientMessageIdsTest.kt | 21 ++ .../AuthEmailControllerTests.cs | 6 +- .../ChannelMembersControllerTests.cs | 6 +- .../ReliabilityAndSecurityTests.cs | 166 +++++++++++++ src/Ikar.Server/Controllers/AuthController.cs | 87 ++----- .../Controllers/BotApiController.cs | 3 + .../Controllers/ChatsController.cs | 58 +++-- src/Ikar.Server/Data/Entities/Entities.cs | 16 ++ src/Ikar.Server/Data/IkarDbContext.cs | 19 ++ src/Ikar.Server/Ikar.Server.csproj | 8 +- .../Auth/EmailAuthChallengeStore.cs | 92 +++++++- .../Infrastructure/Bots/BotCreatorService.cs | 1 + .../Calls/CallSessionCleanupService.cs | 40 ++++ .../Infrastructure/Calls/CallSessionStore.cs | 23 ++ .../Infrastructure/Calls/WebRtcOptions.cs | 4 + src/Ikar.Server/Infrastructure/DtoMapper.cs | 3 +- .../Push/PushDispatchBackgroundService.cs | 74 +++--- .../Infrastructure/Push/PushDispatchQueue.cs | 221 +++++++++++++++++- .../Push/PushNotificationDispatcher.cs | 2 + .../Storage/AttachmentContentInspector.cs | 30 +++ .../Storage/AttachmentStorageService.cs | 6 +- .../Storage/DatabaseSchemaBootstrapper.cs | 53 +++++ src/Ikar.Server/Program.cs | 74 +++++- src/Ikar.Server/appsettings.json | 2 + src/Ikar.Shared/MessageContracts.cs | 6 +- 58 files changed, 1698 insertions(+), 320 deletions(-) create mode 100644 scripts/backup-ikar-data.sh create mode 100644 scripts/restore-ikar-data.sh create mode 100644 src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/model/ClientMessageIds.kt create mode 100644 src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/security/AndroidKeystoreCipher.kt create mode 100644 src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/transfer/TextMessageWorker.kt create mode 100644 src/Ikar.AndroidKotlin/app/src/main/res/values-en/strings.xml create mode 100644 src/Ikar.AndroidKotlin/app/src/main/res/values/strings.xml create mode 100644 src/Ikar.AndroidKotlin/app/src/test/java/com/seven/ikar/kotlin/core/model/ClientMessageIdsTest.kt create mode 100644 src/Ikar.Server.Tests/ReliabilityAndSecurityTests.cs create mode 100644 src/Ikar.Server/Infrastructure/Calls/CallSessionCleanupService.cs create mode 100644 src/Ikar.Server/Infrastructure/Storage/AttachmentContentInspector.cs diff --git a/.env.example b/.env.example index c1a6ec3..87043fb 100644 --- a/.env.example +++ b/.env.example @@ -1 +1,8 @@ -IKAR_DATA_PATH=/media/myDrive/ikar-data +IKAR_DATA_PATH=/mnt/data/ikar-data +IKAR_BACKUP_PATH=/mnt/data/ikar-backups +TURN_HOST=ikar.kusoft.xyz +TURN_REALM=ikar.kusoft.xyz +TURN_EXTERNAL_IP=195.216.241.165 +TURN_PRIVATE_IP=192.168.0.185 +TURN_USERNAME=ikar +TURN_PASSWORD=replace-with-a-long-random-secret diff --git a/Ikar.slnx b/Ikar.slnx index 22f6f38..d6b7d6a 100644 --- a/Ikar.slnx +++ b/Ikar.slnx @@ -1,7 +1,6 @@ - - + diff --git a/IkarClassic.sln b/IkarClassic.sln index b98e987..d8a6cdf 100644 --- a/IkarClassic.sln +++ b/IkarClassic.sln @@ -9,13 +9,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ikar.Shared", "src\Ikar.Sha EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ikar.Server", "src\Ikar.Server\Ikar.Server.csproj", "{631B782D-44B2-4680-BC8C-1A0A948A1945}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ikar.Client", "src\Ikar.Client\Ikar.Client.csproj", "{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ikar.WinUI", "src\Ikar.WinUI\Ikar.WinUI.csproj", "{2900F762-FB89-44B7-AF69-D4908EEC50B2}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ikar.CodexBridge", "src\Ikar.CodexBridge\Ikar.CodexBridge.csproj", "{57EC4F75-199D-4FFB-A658-D455AF1F41CF}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ikar.CodexBridge.Tests", "src\Ikar.CodexBridge.Tests\Ikar.CodexBridge.Tests.csproj", "{318E90D6-56ED-491E-8454-22CB84B6FDD3}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ikar.Server.Tests", "src\Ikar.Server.Tests\Ikar.Server.Tests.csproj", "{94F7D186-7CAA-4840-9C64-AC4792A1A6C0}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -51,54 +45,18 @@ Global {631B782D-44B2-4680-BC8C-1A0A948A1945}.Release|x64.Build.0 = Release|Any CPU {631B782D-44B2-4680-BC8C-1A0A948A1945}.Release|x86.ActiveCfg = Release|Any CPU {631B782D-44B2-4680-BC8C-1A0A948A1945}.Release|x86.Build.0 = Release|Any CPU - {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|x64.ActiveCfg = Debug|Any CPU - {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|x64.Build.0 = Debug|Any CPU - {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|x86.ActiveCfg = Debug|Any CPU - {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|x86.Build.0 = Debug|Any CPU - {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|Any CPU.Build.0 = Release|Any CPU - {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|x64.ActiveCfg = Release|Any CPU - {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|x64.Build.0 = Release|Any CPU - {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|x86.ActiveCfg = Release|Any CPU - {E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|x86.Build.0 = Release|Any CPU - {2900F762-FB89-44B7-AF69-D4908EEC50B2}.Debug|Any CPU.ActiveCfg = Debug|x86 - {2900F762-FB89-44B7-AF69-D4908EEC50B2}.Debug|Any CPU.Build.0 = Debug|x86 - {2900F762-FB89-44B7-AF69-D4908EEC50B2}.Debug|x64.ActiveCfg = Debug|x64 - {2900F762-FB89-44B7-AF69-D4908EEC50B2}.Debug|x64.Build.0 = Debug|x64 - {2900F762-FB89-44B7-AF69-D4908EEC50B2}.Debug|x86.ActiveCfg = Debug|x86 - {2900F762-FB89-44B7-AF69-D4908EEC50B2}.Debug|x86.Build.0 = Debug|x86 - {2900F762-FB89-44B7-AF69-D4908EEC50B2}.Release|Any CPU.ActiveCfg = Release|x86 - {2900F762-FB89-44B7-AF69-D4908EEC50B2}.Release|Any CPU.Build.0 = Release|x86 - {2900F762-FB89-44B7-AF69-D4908EEC50B2}.Release|x64.ActiveCfg = Release|x64 - {2900F762-FB89-44B7-AF69-D4908EEC50B2}.Release|x64.Build.0 = Release|x64 - {2900F762-FB89-44B7-AF69-D4908EEC50B2}.Release|x86.ActiveCfg = Release|x86 - {2900F762-FB89-44B7-AF69-D4908EEC50B2}.Release|x86.Build.0 = Release|x86 - {57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Debug|Any CPU.Build.0 = Debug|Any CPU - {57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Debug|x64.ActiveCfg = Debug|Any CPU - {57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Debug|x64.Build.0 = Debug|Any CPU - {57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Debug|x86.ActiveCfg = Debug|Any CPU - {57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Debug|x86.Build.0 = Debug|Any CPU - {57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Release|Any CPU.ActiveCfg = Release|Any CPU - {57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Release|Any CPU.Build.0 = Release|Any CPU - {57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Release|x64.ActiveCfg = Release|Any CPU - {57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Release|x64.Build.0 = Release|Any CPU - {57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Release|x86.ActiveCfg = Release|Any CPU - {57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Release|x86.Build.0 = Release|Any CPU - {318E90D6-56ED-491E-8454-22CB84B6FDD3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {318E90D6-56ED-491E-8454-22CB84B6FDD3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {318E90D6-56ED-491E-8454-22CB84B6FDD3}.Debug|x64.ActiveCfg = Debug|Any CPU - {318E90D6-56ED-491E-8454-22CB84B6FDD3}.Debug|x64.Build.0 = Debug|Any CPU - {318E90D6-56ED-491E-8454-22CB84B6FDD3}.Debug|x86.ActiveCfg = Debug|Any CPU - {318E90D6-56ED-491E-8454-22CB84B6FDD3}.Debug|x86.Build.0 = Debug|Any CPU - {318E90D6-56ED-491E-8454-22CB84B6FDD3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {318E90D6-56ED-491E-8454-22CB84B6FDD3}.Release|Any CPU.Build.0 = Release|Any CPU - {318E90D6-56ED-491E-8454-22CB84B6FDD3}.Release|x64.ActiveCfg = Release|Any CPU - {318E90D6-56ED-491E-8454-22CB84B6FDD3}.Release|x64.Build.0 = Release|Any CPU - {318E90D6-56ED-491E-8454-22CB84B6FDD3}.Release|x86.ActiveCfg = Release|Any CPU - {318E90D6-56ED-491E-8454-22CB84B6FDD3}.Release|x86.Build.0 = Release|Any CPU + {94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Debug|x64.ActiveCfg = Debug|Any CPU + {94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Debug|x64.Build.0 = Debug|Any CPU + {94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Debug|x86.ActiveCfg = Debug|Any CPU + {94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Debug|x86.Build.0 = Debug|Any CPU + {94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Release|Any CPU.Build.0 = Release|Any CPU + {94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Release|x64.ActiveCfg = Release|Any CPU + {94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Release|x64.Build.0 = Release|Any CPU + {94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Release|x86.ActiveCfg = Release|Any CPU + {94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -106,9 +64,6 @@ Global GlobalSection(NestedProjects) = preSolution {2FBB67AD-D250-4C69-AEA9-D0F7A18D623B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} {631B782D-44B2-4680-BC8C-1A0A948A1945} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} - {E5B4217E-FCA8-4944-99A6-12D07E7B5D60} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} - {2900F762-FB89-44B7-AF69-D4908EEC50B2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} - {57EC4F75-199D-4FFB-A658-D455AF1F41CF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} - {318E90D6-56ED-491E-8454-22CB84B6FDD3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {94F7D186-7CAA-4840-9C64-AC4792A1A6C0} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} EndGlobalSection EndGlobal diff --git a/docker-compose.rpi5.yml b/docker-compose.rpi5.yml index 004fcd7..be8aaab 100644 --- a/docker-compose.rpi5.yml +++ b/docker-compose.rpi5.yml @@ -7,9 +7,57 @@ services: context: . dockerfile: Dockerfile.server restart: unless-stopped + stop_grace_period: 30s + user: "${IKAR_UID:-1000}:${IKAR_GID:-1000}" env_file: - .env.server ports: - "127.0.0.1:5099:5099" volumes: - ${IKAR_DATA_PATH:?IKAR_DATA_PATH must be set in .env}:/app/Data + - ${IKAR_BACKUP_PATH:-/mnt/data/ikar-backups}:/backups:ro + environment: + Backup__Directory: /backups + Backup__RequireFreshBackup: "true" + WebRtc__IceServers__0__Urls__0: stun:stun.l.google.com:19302 + WebRtc__IceServers__1__Urls__0: turn:${TURN_HOST:-ikar.kusoft.xyz}:3478?transport=udp + WebRtc__IceServers__1__Urls__1: turn:${TURN_HOST:-ikar.kusoft.xyz}:3478?transport=tcp + WebRtc__IceServers__1__Username: ${TURN_USERNAME:?TURN_USERNAME must be set in .env} + WebRtc__IceServers__1__Credential: ${TURN_PASSWORD:?TURN_PASSWORD must be set in .env} + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + mem_limit: 768m + cpus: 2.0 + + ikar-turn: + container_name: ikar-turn + image: coturn/coturn:4.14.0-r0 + platform: linux/arm64 + restart: unless-stopped + network_mode: host + command: + - --no-cli + - --fingerprint + - --lt-cred-mech + - --realm=${TURN_REALM:-ikar.kusoft.xyz} + - --user=${TURN_USERNAME:?TURN_USERNAME must be set in .env}:${TURN_PASSWORD:?TURN_PASSWORD must be set in .env} + - --listening-ip=${TURN_PRIVATE_IP:?TURN_PRIVATE_IP must be set in .env} + - --relay-ip=${TURN_PRIVATE_IP:?TURN_PRIVATE_IP must be set in .env} + - --external-ip=${TURN_EXTERNAL_IP:?TURN_EXTERNAL_IP must be set in .env}/${TURN_PRIVATE_IP:?TURN_PRIVATE_IP must be set in .env} + - --listening-port=3478 + - --min-port=49160 + - --max-port=49200 + - --no-tls + - --no-dtls + - --no-multicast-peers + - --log-file=stdout + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + mem_limit: 256m + cpus: 1.0 diff --git a/scripts/backup-ikar-data.sh b/scripts/backup-ikar-data.sh new file mode 100644 index 0000000..0f0d5d2 --- /dev/null +++ b/scripts/backup-ikar-data.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +data_dir="${IKAR_DATA_PATH:-/mnt/data/ikar-data}" +backup_dir="${IKAR_BACKUP_PATH:-/mnt/data/ikar-backups}" +retention_days="${IKAR_BACKUP_RETENTION_DAYS:-14}" +timestamp="$(date -u +%Y%m%dT%H%M%SZ)" +work_dir="${backup_dir}/.ikar-${timestamp}.tmp" +archive="${backup_dir}/ikar-${timestamp}.tar.gz" + +test -f "${data_dir}/ikar.db" +mkdir -p "${backup_dir}" +rm -rf "${work_dir}" +mkdir -p "${work_dir}/Data" + +sqlite3 "${data_dir}/ikar.db" ".timeout 10000" ".backup '${work_dir}/Data/ikar.db'" +test "$(sqlite3 "${work_dir}/Data/ikar.db" 'PRAGMA integrity_check;')" = "ok" + +for directory in Attachments AppUpdates secrets; do + if test -d "${data_dir}/${directory}"; then + rsync -a "${data_dir}/${directory}/" "${work_dir}/Data/${directory}/" + fi +done + +tar -C "${work_dir}" -czf "${archive}.partial" Data +gzip -t "${archive}.partial" +mv "${archive}.partial" "${archive}" +sha256sum "${archive}" > "${archive}.sha256" +rm -rf "${work_dir}" + +find "${backup_dir}" -maxdepth 1 -type f -name 'ikar-*.tar.gz' -mtime "+${retention_days}" -delete +find "${backup_dir}" -maxdepth 1 -type f -name 'ikar-*.tar.gz.sha256' -mtime "+${retention_days}" -delete + +printf '%s\n' "${archive}" diff --git a/scripts/restore-ikar-data.sh b/scripts/restore-ikar-data.sh new file mode 100644 index 0000000..bc98614 --- /dev/null +++ b/scripts/restore-ikar-data.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +verify_only=false +if test "$#" -eq 2 && test "$2" = "--verify-only"; then + archive="$1" + staging_dir="$(mktemp -d "$(dirname "${archive}")/.ikar-verify-XXXXXX")" + verify_only=true +elif test "$#" -eq 3 && test "$3" = "--confirm-restore"; then + archive="$1" + target_dir="$2" + mkdir -p "$(dirname "${target_dir}")" + staging_dir="$(mktemp -d "${target_dir}.restore-XXXXXX")" +else + echo "Usage: $0 --verify-only" >&2 + echo " or: $0 --confirm-restore" >&2 + exit 64 +fi + +cleanup() { + rm -rf -- "${staging_dir}" +} +trap cleanup EXIT + +test -f "${archive}" +test -f "${archive}.sha256" +(cd "$(dirname "${archive}")" && sha256sum -c "$(basename "${archive}.sha256")") +tar -C "${staging_dir}" -xzf "${archive}" +test -f "${staging_dir}/Data/ikar.db" +test "$(sqlite3 "${staging_dir}/Data/ikar.db" 'PRAGMA integrity_check;')" = "ok" + +if test "${verify_only}" = true; then + echo "Backup verification completed successfully." + exit 0 +fi + +if test -e "${target_dir}"; then + mv "${target_dir}" "${target_dir}.before-restore-$(date -u +%Y%m%dT%H%M%SZ)" +fi +mv "${staging_dir}/Data" "${target_dir}" +rmdir "${staging_dir}" +trap - EXIT + +echo "Restore completed. Start the Ikar server and verify /health before removing the pre-restore directory." diff --git a/src/Ikar.AndroidKotlin/app/build.gradle.kts b/src/Ikar.AndroidKotlin/app/build.gradle.kts index 427668f..7c79e56 100644 --- a/src/Ikar.AndroidKotlin/app/build.gradle.kts +++ b/src/Ikar.AndroidKotlin/app/build.gradle.kts @@ -37,8 +37,8 @@ android { applicationId = "com.seven.ikar.kotlin" minSdk = 24 targetSdk = 36 - versionCode = 209 - versionName = "3.96" + versionCode = 211 + versionName = "3.98" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true @@ -57,7 +57,8 @@ android { buildTypes { release { - isMinifyEnabled = false + isMinifyEnabled = true + isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" @@ -123,4 +124,5 @@ dependencies { implementation("androidx.compose.material:material-icons-extended") debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-test-manifest") + testImplementation("junit:junit:4.13.2") } diff --git a/src/Ikar.AndroidKotlin/app/proguard-rules.pro b/src/Ikar.AndroidKotlin/app/proguard-rules.pro index bce7e92..11b56f6 100644 --- a/src/Ikar.AndroidKotlin/app/proguard-rules.pro +++ b/src/Ikar.AndroidKotlin/app/proguard-rules.pro @@ -1 +1,17 @@ -# Intentionally empty for the first migration pass. +# SignalR creates its wire DTOs through reflection. +-keep class com.seven.ikar.kotlin.core.realtime.** { *; } + +# Firebase and WorkManager instantiate these Android components by class name. +-keep class com.seven.ikar.kotlin.core.push.IkarFirebaseMessagingService { *; } +-keep class com.seven.ikar.kotlin.feature.transfer.**Worker { public (...); } + +# Keep Kotlin serialization-generated serializers and their companions. +-keepclassmembers class com.seven.ikar.kotlin.core.model.** { + *** Companion; +} +-keepclasseswithmembers,includedescriptorclasses class * { + kotlinx.serialization.KSerializer serializer(...); +} + +# SLF4J uses this optional binding only when an implementation is packaged. +-dontwarn org.slf4j.impl.StaticLoggerBinder diff --git a/src/Ikar.AndroidKotlin/app/src/main/AndroidManifest.xml b/src/Ikar.AndroidKotlin/app/src/main/AndroidManifest.xml index 5ef3fc1..6b1b619 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/AndroidManifest.xml +++ b/src/Ikar.AndroidKotlin/app/src/main/AndroidManifest.xml @@ -16,9 +16,10 @@ diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/app/AppContainer.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/app/AppContainer.kt index 21d789c..eea9efd 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/app/AppContainer.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/app/AppContainer.kt @@ -61,7 +61,7 @@ class AppContainer(context: Context) { } private val loggingInterceptor = HttpLoggingInterceptor().apply { - level = HttpLoggingInterceptor.Level.BASIC + level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC else HttpLoggingInterceptor.Level.NONE } val pushSettings = AndroidPushSettings(appContext) diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/IkarLocalDatabase.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/IkarLocalDatabase.kt index 418f408..c1e1e63 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/IkarLocalDatabase.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/IkarLocalDatabase.kt @@ -9,7 +9,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase @Database( entities = [CachedChatMessageEntity::class, CachedChatEntity::class, TransferQueueEntity::class], - version = 4, + version = 5, exportSchema = false ) abstract class IkarLocalDatabase : RoomDatabase() { @@ -79,6 +79,12 @@ abstract class IkarLocalDatabase : RoomDatabase() { } } + private val Migration4To5 = object : Migration(4, 5) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE transfer_queue ADD COLUMN clientMessageId TEXT") + } + } + fun getInstance(context: Context): IkarLocalDatabase = instance ?: synchronized(this) { instance ?: Room.databaseBuilder( @@ -86,7 +92,7 @@ abstract class IkarLocalDatabase : RoomDatabase() { IkarLocalDatabase::class.java, "ikar-local.db" ) - .addMigrations(Migration1To2, Migration2To3, Migration3To4) + .addMigrations(Migration1To2, Migration2To3, Migration3To4, Migration4To5) .build() .also { instance = it } } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/TransferQueueEntity.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/TransferQueueEntity.kt index e38cd9f..ad1dab0 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/TransferQueueEntity.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/local/TransferQueueEntity.kt @@ -17,6 +17,7 @@ data class TransferQueueEntity( val chatId: String?, val text: String?, val replyToMessageId: String?, + val clientMessageId: String?, val attachmentId: String?, val storyVisibility: String?, val hasProtectedContent: Boolean, @@ -40,6 +41,12 @@ interface TransferQueueDao { @Query("SELECT * FROM transfer_queue WHERE groupId = :groupId ORDER BY sortOrder ASC") suspend fun getGroup(groupId: String): List + @Query("SELECT * FROM transfer_queue WHERE groupId = :groupId ORDER BY sortOrder ASC") + fun observeGroup(groupId: String): Flow> + + @Query("SELECT groupId FROM transfer_queue WHERE attachmentId = :attachmentId AND type = 'download' AND status IN ('queued', 'running') ORDER BY createdAtMillis DESC LIMIT 1") + suspend fun findActiveDownloadGroup(attachmentId: String): String? + @Query("SELECT * FROM transfer_queue WHERE chatId = :chatId AND status IN ('queued', 'running', 'failed') ORDER BY createdAtMillis ASC, sortOrder ASC") fun observeActiveForChat(chatId: String): Flow> @@ -60,6 +67,7 @@ interface TransferQueueDao { } object TransferQueueTypes { + const val TextMessage = "text_message" const val Upload = "upload" const val StoryUpload = "story_upload" const val Download = "download" diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/model/ClientMessageIds.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/model/ClientMessageIds.kt new file mode 100644 index 0000000..2f1b0cd --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/model/ClientMessageIds.kt @@ -0,0 +1,16 @@ +package com.seven.ikar.kotlin.core.model + +import java.util.UUID + +object ClientMessageIds { + const val LocalPrefix = "local-" + + fun newClientId(): String = UUID.randomUUID().toString() + + fun localId(clientMessageId: String): String = "$LocalPrefix$clientMessageId" + + fun clientIdFromLocalId(localMessageId: String): String? = + localMessageId.takeIf { it.startsWith(LocalPrefix) } + ?.removePrefix(LocalPrefix) + ?.takeIf(String::isNotBlank) +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/model/Models.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/model/Models.kt index 6c16fe0..cec9ecc 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/model/Models.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/model/Models.kt @@ -252,7 +252,8 @@ data class MessageDto( val viewCount: Int? = null, val storyReply: StoryReplyDto? = null, val discussionMessageId: String? = null, - val commentCount: Int? = null + val commentCount: Int? = null, + val clientMessageId: String? = null ) @Serializable @@ -608,7 +609,8 @@ data class SendMessageRequest( val topicId: String? = null, val hasProtectedContent: Boolean? = null, val ttlSeconds: Int? = null, - val storyReplyStoryId: String? = null + val storyReplyStoryId: String? = null, + val clientMessageId: String? = null ) @Serializable diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/network/ApiClient.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/network/ApiClient.kt index c9026d6..89de2d1 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/network/ApiClient.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/network/ApiClient.kt @@ -648,7 +648,8 @@ class ApiClient( topicId: String? = null, hasProtectedContent: Boolean? = null, ttlSeconds: Int? = null, - storyReplyStoryId: String? = null + storyReplyStoryId: String? = null, + clientMessageId: String? = null ): MessageDto = executeJsonRequest( Request.Builder() @@ -662,7 +663,8 @@ class ApiClient( topicId = topicId, hasProtectedContent = hasProtectedContent, ttlSeconds = ttlSeconds, - storyReplyStoryId = storyReplyStoryId + storyReplyStoryId = storyReplyStoryId, + clientMessageId = clientMessageId ) ).toRequestBody(JSON) ) @@ -886,10 +888,12 @@ class ApiClient( ?: attachment.fileSizeBytes.takeIf { it >= 0L } ?: -1L targetFile.parentFile?.mkdirs() + val partialFile = File(targetFile.parentFile ?: File("."), "${targetFile.name}.part") + partialFile.delete() + var bytesRead = 0L body.byteStream().use { input -> - targetFile.outputStream().use { output -> + partialFile.outputStream().use { output -> val buffer = ByteArray(DEFAULT_BUFFER_SIZE) - var bytesRead = 0L while (true) { val read = input.read(buffer) if (read < 0) { @@ -900,9 +904,21 @@ class ApiClient( onProgress?.invoke(bytesRead, totalBytes) } } + + if (totalBytes > 0L && bytesRead != totalBytes) { + throw IOException("Incomplete attachment download.") + } } - DownloadedAttachmentFile( + if (targetFile.exists() && !targetFile.delete()) { + throw IOException("Could not replace cached attachment.") + } + if (!partialFile.renameTo(targetFile)) { + partialFile.copyTo(targetFile, overwrite = true) + partialFile.delete() + } + + DownloadedAttachmentFile( fileName = attachment.fileName, contentType = body.contentType()?.toString() ?: response.header("Content-Type") @@ -912,6 +928,8 @@ class ApiClient( ) } } catch (error: IOException) { + val partialFile = File(targetFile.parentFile ?: File("."), "${targetFile.name}.part") + partialFile.delete() throw ApiException(-1, networkErrorMessage()) } } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/NotificationActionReceiver.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/NotificationActionReceiver.kt index 8e74108..1a555d9 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/NotificationActionReceiver.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/NotificationActionReceiver.kt @@ -3,10 +3,12 @@ package com.seven.ikar.kotlin.core.push import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import androidx.core.app.RemoteInput import com.seven.ikar.kotlin.app.IkarNativeApp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import java.util.UUID class NotificationActionReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { @@ -24,11 +26,42 @@ class NotificationActionReceiver : BroadcastReceiver() { val action = intent.action.orEmpty() when (action) { NotificationAction.MarkReadAction -> handleMarkRead(context, intent) + NotificationAction.InlineReplyAction -> handleInlineReply(context, intent) NotificationAction.AcceptIncomingCallAction -> handleAcceptIncomingCall(context, intent) NotificationAction.DeclineIncomingCallAction -> handleDeclineIncomingCall(context, intent) } } + private suspend fun handleInlineReply(context: Context, intent: Intent) { + val chatId = intent.getStringExtra(PushDataKeys.ChatId).orEmpty() + val notificationId = intent.getIntExtra(NotificationAction.NotificationIdExtra, Int.MIN_VALUE) + val text = RemoteInput.getResultsFromIntent(intent) + ?.getCharSequence(NotificationAction.InlineReplyResultKey) + ?.toString() + ?.trim() + .orEmpty() + if (chatId.isBlank() || text.isBlank()) { + return + } + + val app = context.applicationContext as? IkarNativeApp ?: return + runCatching { + app.container.executeAuthorized { session -> + app.container.apiClient.sendMessage( + chatId = chatId, + text = text, + accessToken = session.accessToken, + clientMessageId = UUID.randomUUID().toString() + ) + } + }.onSuccess { + app.container.realtimeService.notifyChatsChangedLocally() + if (notificationId != Int.MIN_VALUE) { + ChatNotificationDismissalManager.cancelNotification(context, chatId, notificationId) + } + } + } + private suspend fun handleMarkRead(context: Context, intent: Intent) { val chatId = intent.getStringExtra(PushDataKeys.ChatId).orEmpty() val messageId = intent.getStringExtra(PushDataKeys.MessageId).orEmpty() @@ -92,7 +125,9 @@ class NotificationActionReceiver : BroadcastReceiver() { object NotificationAction { const val MarkReadAction = "com.seven.ikar.kotlin.NOTIFICATION_MARK_READ" + const val InlineReplyAction = "com.seven.ikar.kotlin.NOTIFICATION_INLINE_REPLY" const val AcceptIncomingCallAction = "com.seven.ikar.kotlin.NOTIFICATION_ACCEPT_INCOMING_CALL" const val DeclineIncomingCallAction = "com.seven.ikar.kotlin.NOTIFICATION_DECLINE_INCOMING_CALL" const val NotificationIdExtra = "notificationId" + const val InlineReplyResultKey = "inlineReplyText" } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/PushNotificationDisplayService.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/PushNotificationDisplayService.kt index 07fea94..c2f406c 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/PushNotificationDisplayService.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/push/PushNotificationDisplayService.kt @@ -11,6 +11,7 @@ import android.os.Vibrator import android.os.VibratorManager import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.core.app.RemoteInput import androidx.core.content.ContextCompat import com.seven.ikar.kotlin.BuildConfig import com.seven.ikar.kotlin.MainActivity @@ -218,11 +219,20 @@ object PushNotificationDisplayService { } val pendingIntent = PendingIntent.getActivity(context, notificationId, intent, flags) - val replyPendingIntent = PendingIntent.getActivity( + val replyFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + } else { + PendingIntent.FLAG_UPDATE_CURRENT + } + val replyPendingIntent = PendingIntent.getBroadcast( context, notificationId + 1, - intent, - flags + Intent(context, NotificationActionReceiver::class.java).apply { + action = NotificationAction.InlineReplyAction + putExtra(PushDataKeys.ChatId, data[PushDataKeys.ChatId]) + putExtra(NotificationAction.NotificationIdExtra, notificationId) + }, + replyFlags ) val markReadPendingIntent = PendingIntent.getBroadcast( context, @@ -292,8 +302,19 @@ object PushNotificationDisplayService { !data[PushDataKeys.ChatId].isNullOrBlank() && !data[PushDataKeys.MessageId].isNullOrBlank() ) { + val remoteInput = RemoteInput.Builder(NotificationAction.InlineReplyResultKey) + .setLabel("Ответ") + .build() + val replyAction = NotificationCompat.Action.Builder( + android.R.drawable.ic_menu_send, + "Ответить", + replyPendingIntent + ) + .addRemoteInput(remoteInput) + .setAllowGeneratedReplies(true) + .build() notificationBuilder - .addAction(android.R.drawable.ic_menu_send, "Ответить", replyPendingIntent) + .addAction(replyAction) .addAction(android.R.drawable.ic_menu_view, "Отметить как прочитанное", markReadPendingIntent) } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/realtime/RealtimeService.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/realtime/RealtimeService.kt index d858a84..d2082df 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/realtime/RealtimeService.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/realtime/RealtimeService.kt @@ -53,6 +53,9 @@ class RealtimeService( private val _stage = MutableStateFlow(RealtimeConnectionStage.Disconnected) val stage: StateFlow = _stage + private val _connectionRestored = MutableSharedFlow(extraBufferCapacity = 1) + val connectionRestored: SharedFlow = _connectionRestored + private val _messagesCreated = MutableSharedFlow(extraBufferCapacity = 32) val messagesCreated: SharedFlow = _messagesCreated @@ -209,6 +212,7 @@ class RealtimeService( newConnection.start().blockingAwait() }.onSuccess { _stage.value = RealtimeConnectionStage.Connected + _connectionRestored.tryEmit(Unit) }.onFailure { if (connection == newConnection) { connection = null diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/security/AndroidKeystoreCipher.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/security/AndroidKeystoreCipher.kt new file mode 100644 index 0000000..3806448 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/security/AndroidKeystoreCipher.kt @@ -0,0 +1,61 @@ +package com.seven.ikar.kotlin.core.security + +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import java.security.KeyStore +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.SecretKey +import javax.crypto.spec.GCMParameterSpec + +class AndroidKeystoreCipher(private val keyAlias: String) { + fun encrypt(value: String): String { + val cipher = Cipher.getInstance(Transformation) + cipher.init(Cipher.ENCRYPT_MODE, loadOrCreateKey()) + val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8)) + return listOf( + Version, + Base64.encodeToString(cipher.iv, Base64.NO_WRAP), + Base64.encodeToString(encrypted, Base64.NO_WRAP) + ).joinToString(Separator) + } + + fun decrypt(value: String): String? = runCatching { + val parts = value.split(Separator, limit = 3) + require(parts.size == 3 && parts[0] == Version) + val iv = Base64.decode(parts[1], Base64.NO_WRAP) + val encrypted = Base64.decode(parts[2], Base64.NO_WRAP) + val cipher = Cipher.getInstance(Transformation) + cipher.init(Cipher.DECRYPT_MODE, loadOrCreateKey(), GCMParameterSpec(GcmTagBits, iv)) + cipher.doFinal(encrypted).toString(Charsets.UTF_8) + }.getOrNull() + + private fun loadOrCreateKey(): SecretKey { + val keyStore = KeyStore.getInstance(AndroidKeyStore).apply { load(null) } + (keyStore.getKey(keyAlias, null) as? SecretKey)?.let { return it } + + return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, AndroidKeyStore).run { + init( + KeyGenParameterSpec.Builder( + keyAlias, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ) + .setBlockModes(KeyProperties.BLOCK_MODE_GCM) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) + .setRandomizedEncryptionRequired(true) + .setKeySize(256) + .build() + ) + generateKey() + } + } + + private companion object { + const val AndroidKeyStore = "AndroidKeyStore" + const val Transformation = "AES/GCM/NoPadding" + const val GcmTagBits = 128 + const val Version = "v1" + const val Separator = ":" + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/session/SessionStore.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/session/SessionStore.kt index 37d207f..859e5df 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/session/SessionStore.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/session/SessionStore.kt @@ -3,28 +3,53 @@ package com.seven.ikar.kotlin.core.session import android.content.Context import android.content.SharedPreferences import com.seven.ikar.kotlin.core.model.AuthSessionDto +import com.seven.ikar.kotlin.core.security.AndroidKeystoreCipher import kotlinx.serialization.json.Json class SessionStore(context: Context, private val json: Json) { private val preferences: SharedPreferences = context.getSharedPreferences("ikar_kotlin_session", Context.MODE_PRIVATE) + private val cipher = AndroidKeystoreCipher("ikar.session.v1") fun read(): AuthSessionDto? { - val raw = preferences.getString(KEY_SESSION, null) ?: return null - return runCatching { json.decodeFromString(raw) }.getOrNull() + val encrypted = preferences.getString(KEY_ENCRYPTED_SESSION, null) + if (encrypted != null) { + val raw = cipher.decrypt(encrypted) ?: run { + clear() + return null + } + return decode(raw) + } + + val legacy = preferences.getString(KEY_LEGACY_SESSION, null) ?: return null + val session = decode(legacy) ?: run { + clear() + return null + } + save(session) + return session } fun save(session: AuthSessionDto) { + val encrypted = cipher.encrypt(json.encodeToString(AuthSessionDto.serializer(), session)) preferences.edit() - .putString(KEY_SESSION, json.encodeToString(AuthSessionDto.serializer(), session)) - .apply() + .putString(KEY_ENCRYPTED_SESSION, encrypted) + .remove(KEY_LEGACY_SESSION) + .commit() } fun clear() { - preferences.edit().remove(KEY_SESSION).apply() + preferences.edit() + .remove(KEY_ENCRYPTED_SESSION) + .remove(KEY_LEGACY_SESSION) + .commit() } + private fun decode(raw: String): AuthSessionDto? = + runCatching { json.decodeFromString(raw) }.getOrNull() + companion object { - private const val KEY_SESSION = "session" + private const val KEY_ENCRYPTED_SESSION = "encrypted_session" + private const val KEY_LEGACY_SESSION = "session" } } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/settings/AndroidAppSettings.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/settings/AndroidAppSettings.kt index 7fcf622..4630cb2 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/settings/AndroidAppSettings.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/core/settings/AndroidAppSettings.kt @@ -1,6 +1,7 @@ package com.seven.ikar.kotlin.core.settings import android.content.Context +import com.seven.ikar.kotlin.core.security.AndroidKeystoreCipher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -46,6 +47,7 @@ data class AppSettingsSnapshot( class AndroidAppSettings(context: Context) { private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE) + private val secretCipher = AndroidKeystoreCipher("ikar.settings.v1") private val _snapshot = MutableStateFlow(loadSnapshot()) val snapshot: StateFlow = _snapshot.asStateFlow() @@ -68,7 +70,7 @@ class AndroidAppSettings(context: Context) { proxyType = getEnum(KEY_PROXY_TYPE, AppProxyType.Socks5).supportedProxyType(), proxyHost = preferences.getString(KEY_PROXY_HOST, "").orEmpty(), proxyPort = preferences.getInt(KEY_PROXY_PORT, 1080).coerceIn(1, 65_535), - proxySecret = preferences.getString(KEY_PROXY_SECRET, "").orEmpty() + proxySecret = readProxySecret() ) fun update(transform: (AppSettingsSnapshot) -> AppSettingsSnapshot) { @@ -99,7 +101,11 @@ class AndroidAppSettings(context: Context) { putString(KEY_PROXY_TYPE, sanitized.proxyType.name) putString(KEY_PROXY_HOST, sanitized.proxyHost) putInt(KEY_PROXY_PORT, sanitized.proxyPort) - putString(KEY_PROXY_SECRET, sanitized.proxySecret) + putString( + KEY_ENCRYPTED_PROXY_SECRET, + sanitized.proxySecret.takeIf(String::isNotEmpty)?.let(secretCipher::encrypt) + ) + remove(KEY_PROXY_SECRET) apply() } _snapshot.value = sanitized @@ -117,6 +123,22 @@ class AndroidAppSettings(context: Context) { ?: defaultValue } + private fun readProxySecret(): String { + val encrypted = preferences.getString(KEY_ENCRYPTED_PROXY_SECRET, null) + if (encrypted != null) { + return secretCipher.decrypt(encrypted).orEmpty() + } + + val legacy = preferences.getString(KEY_PROXY_SECRET, "").orEmpty() + if (legacy.isNotEmpty()) { + preferences.edit() + .putString(KEY_ENCRYPTED_PROXY_SECRET, secretCipher.encrypt(legacy)) + .remove(KEY_PROXY_SECRET) + .commit() + } + return legacy + } + private fun AppProxyType.supportedProxyType(): AppProxyType = when (this) { AppProxyType.MtProto -> AppProxyType.Socks5 else -> this @@ -141,5 +163,6 @@ class AndroidAppSettings(context: Context) { private const val KEY_PROXY_HOST = "proxy_host" private const val KEY_PROXY_PORT = "proxy_port" private const val KEY_PROXY_SECRET = "proxy_secret" + private const val KEY_ENCRYPTED_PROXY_SECRET = "encrypted_proxy_secret" } } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatScreen.kt index ee4a35f..3100ea6 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatScreen.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.gestures.detectTransformGestures import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column @@ -3681,20 +3682,73 @@ private fun InlineMessageImage( onClick: () -> Unit, onLongPress: () -> Unit ) { - AuthenticatedAsyncImage( + ChatPhotoImage( imageUrl = imageUrl, accessToken = "", contentDescription = "", - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 120.dp, max = 420.dp) - .clip(RoundedCornerShape(16.dp)) - .combinedClickable(onClick = onClick, onLongClick = onLongPress) - .background(Color(0xFFF7FAFD)), - contentScale = ContentScale.Fit + onClick = onClick, + onLongPress = onLongPress ) } +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun ChatPhotoImage( + imageUrl: String?, + accessToken: String?, + contentDescription: String, + modifier: Modifier = Modifier, + onClick: () -> Unit, + onLongPress: () -> Unit +) { + var imageAspectRatio by remember(imageUrl) { mutableStateOf(null) } + + BoxWithConstraints( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center + ) { + val maxPhotoHeight = 420.dp + val knownRatio = imageAspectRatio + val imageModifier = if (knownRatio != null) { + val safeRatio = knownRatio.coerceIn(0.12f, 8f) + val targetWidth = if (maxWidth / safeRatio > maxPhotoHeight) { + maxPhotoHeight * safeRatio + } else { + maxWidth + } + Modifier + .width(targetWidth) + .aspectRatio(safeRatio) + } else { + Modifier + .fillMaxWidth() + .height(220.dp) + } + + Box( + modifier = imageModifier + .clip(RoundedCornerShape(16.dp)) + .combinedClickable(onClick = onClick, onLongClick = onLongPress) + ) { + AuthenticatedAsyncImage( + imageUrl = imageUrl, + accessToken = accessToken, + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + onSuccess = { state -> + val drawable = state.result.drawable + val intrinsicWidth = drawable.intrinsicWidth + val intrinsicHeight = drawable.intrinsicHeight + if (intrinsicWidth > 0 && intrinsicHeight > 0) { + imageAspectRatio = intrinsicWidth.toFloat() / intrinsicHeight.toFloat() + } + } + ) + } + } +} + @OptIn(ExperimentalFoundationApi::class) @Composable private fun InlineMessageAudio( @@ -3888,17 +3942,12 @@ private fun AttachmentCard( ) { when { attachment.isImageAttachment() -> { - AuthenticatedAsyncImage( + ChatPhotoImage( imageUrl = attachmentUrl, accessToken = accessToken, contentDescription = attachment.fileName, - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 120.dp, max = 420.dp) - .clip(RoundedCornerShape(16.dp)) - .combinedClickable(onClick = onClick, onLongClick = onLongPress) - .background(Color(0xFFF7FAFD)), - contentScale = ContentScale.Fit + onClick = onClick, + onLongPress = onLongPress ) } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatViewModel.kt index ab7eb24..dbaf980 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatViewModel.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chat/ChatViewModel.kt @@ -18,6 +18,7 @@ import com.seven.ikar.kotlin.core.model.BotCommandDto import com.seven.ikar.kotlin.core.model.CallMediaType import com.seven.ikar.kotlin.core.model.ChatDetailsDto import com.seven.ikar.kotlin.core.model.ChatType +import com.seven.ikar.kotlin.core.model.ClientMessageIds import com.seven.ikar.kotlin.core.model.MessageDeliveryState import com.seven.ikar.kotlin.core.model.MessageDto import com.seven.ikar.kotlin.core.model.MessageReplyDto @@ -26,6 +27,8 @@ import com.seven.ikar.kotlin.core.model.UserSummaryDto import com.seven.ikar.kotlin.core.network.ServerConfig import com.seven.ikar.kotlin.core.network.UploadFileSource import com.seven.ikar.kotlin.core.local.TransferQueueEntity +import com.seven.ikar.kotlin.core.local.TransferQueueStatus +import com.seven.ikar.kotlin.core.local.TransferQueueTypes import com.seven.ikar.kotlin.feature.call.AudioCallSnapshot import com.seven.ikar.kotlin.feature.call.AudioCallStage import com.seven.ikar.kotlin.feature.transfer.TransferForegroundNotification @@ -164,7 +167,6 @@ private val DefaultCodexBridgeBotCommands = listOf( private val BotThreadOptionRegex = Regex("""^(?\d+)\.\s+(?.+?)(?<selected>\s+\[выбран])?$""") private val ChatTimestampFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm", Locale("ru", "RU")) -private const val LocalMessageIdPrefix = "local-" class ChatViewModel( private val container: AppContainer, @@ -703,14 +705,14 @@ class ChatViewModel( } draftText.isNotBlank() -> { - container.executeAuthorized { session -> - container.apiClient.sendMessage( - chatId = chatId, - text = draftText, - accessToken = session.accessToken, - replyToMessageId = replyToMessageId - ) - } + val clientMessageId = optimisticMessage?.clientMessageId ?: UUID.randomUUID().toString() + container.transferQueueRepository.enqueueTextMessage( + chatId = chatId, + text = draftText, + replyToMessageId = replyToMessageId, + clientMessageId = clientMessageId + ) + null } else -> null @@ -1315,10 +1317,10 @@ class ChatViewModel( val cachedFile = cachedAttachmentFiles[attachment.id] ?.localPath ?.let(::File) - ?.takeIf { it.exists() } + ?.takeIf { it.isCompleteCacheFileFor(attachment) } ?: container.attachmentFileService .createAttachmentCacheFile(attachment.id, attachment.fileName) - .takeIf { it.exists() } + .takeIf { it.isCompleteCacheFileFor(attachment) } ?.also { file -> cachedAttachmentFiles[attachment.id] = LocalAttachmentFile( fileName = attachment.fileName, @@ -1419,8 +1421,9 @@ class ChatViewModel( private fun buildOptimisticMessage(state: ChatUiState, text: String): MessageDto? { val session = state.session ?: container.sessionStore.read() ?: return null + val clientMessageId = ClientMessageIds.newClientId() return MessageDto( - id = "$LocalMessageIdPrefix${UUID.randomUUID()}", + id = ClientMessageIds.localId(clientMessageId), chatId = chatId, chatType = state.chatType, sender = session.user, @@ -1431,13 +1434,17 @@ class ChatViewModel( replyTo = state.replyTo, postedAsChannel = state.chatType == ChatType.Channel, displayAuthorName = if (state.chatType == ChatType.Channel) state.title else session.user.displayName, - viewCount = if (state.chatType == ChatType.Channel) 0 else null + viewCount = if (state.chatType == ChatType.Channel) 0 else null, + clientMessageId = clientMessageId ) } private fun applyChat(chat: ChatDetailsDto, session: AuthSessionDto) { val previousState = _uiState.value - val localMessages = previousState.messages.filter { it.isLocalMessage() } + val receivedClientMessageIds = chat.messages.mapNotNullTo(mutableSetOf(), MessageDto::clientMessageId) + val localMessages = previousState.messages.filter { message -> + message.isLocalMessage() && message.clientMessageId !in receivedClientMessageIds + } val orderedMessages = (chat.messages + localMessages) .distinctBy { it.id } .sortedBy { it.sentAt } @@ -1530,6 +1537,11 @@ class ChatViewModel( refresh() } } + viewModelScope.launch { + container.realtimeService.connectionRestored.collect { + refresh() + } + } viewModelScope.launch { container.realtimeService.typingChanged.collect(::handleTypingChanged) } @@ -1659,7 +1671,25 @@ class ChatViewModel( private fun attachTransferQueueState() { viewModelScope.launch { container.transferQueueRepository.observeActiveForChat(chatId).collect { items -> - _uiState.value = _uiState.value.copy(activeTransfers = items.toTransferQueueUiState()) + val currentState = _uiState.value + val textItems = items.filter { it.type == TransferQueueTypes.TextMessage } + val activeTextLocalIds = textItems.mapNotNullTo(mutableSetOf()) { item -> + item.clientMessageId?.let(ClientMessageIds::localId) + } + val failedTextLocalIds = textItems + .filter { it.status == TransferQueueStatus.Failed } + .mapNotNullTo(mutableSetOf()) { item -> + item.clientMessageId?.let(ClientMessageIds::localId) + } + _uiState.value = currentState.copy( + activeTransfers = items + .filterNot { it.type == TransferQueueTypes.TextMessage } + .toTransferQueueUiState(), + sendingLocalMessageIds = currentState.sendingLocalMessageIds + + (activeTextLocalIds - failedTextLocalIds), + failedLocalMessageIds = (currentState.failedLocalMessageIds - activeTextLocalIds) + + failedTextLocalIds + ) } } } @@ -1670,10 +1700,16 @@ class ChatViewModel( } private suspend fun ensureAttachmentDownloaded(attachment: AttachmentDto): LocalAttachmentFile { - cachedAttachmentFiles[attachment.id]?.let { return it } + cachedAttachmentFiles[attachment.id] + ?.takeIf { File(it.localPath).isCompleteCacheFileFor(attachment) } + ?.let { return it } + ?: cachedAttachmentFiles.remove(attachment.id) val target = container.attachmentFileService.createAttachmentCacheFile(attachment.id, attachment.fileName) - if (target.exists()) { + if (target.exists() && !target.isCompleteCacheFileFor(attachment)) { + target.delete() + } + if (target.isCompleteCacheFileFor(attachment)) { val cached = LocalAttachmentFile( fileName = attachment.fileName, contentType = attachment.contentType, @@ -1686,8 +1722,20 @@ class ChatViewModel( } if (attachment.fileSizeBytes >= TransferForegroundNotification.LargeTransferThresholdBytes) { - container.transferQueueRepository.enqueueDownload(chatId, attachment) - throw IllegalStateException("Large download queued. Try opening it again when the transfer completes.") + val groupId = container.transferQueueRepository.enqueueDownload(chatId, attachment) + container.transferQueueRepository.awaitCompletion(groupId) + if (!target.isCompleteCacheFileFor(attachment)) { + throw IllegalStateException("Downloaded attachment is incomplete.") + } + return LocalAttachmentFile( + fileName = attachment.fileName, + contentType = attachment.contentType, + fileSizeBytes = target.length(), + localPath = target.absolutePath + ).also { cached -> + cachedAttachmentFiles[attachment.id] = cached + notifyAttachmentCacheChanged() + } } return container.executeAuthorized { session -> @@ -1727,8 +1775,15 @@ class ChatViewModel( .filterNot { it.hasProtectedContent || it.isDeleted() } .flatMap { it.attachments.asSequence() } .filter { attachment -> shouldAutoDownload(attachment, settings, isUnmetered) } - .filterNot { attachment -> cachedAttachmentFiles.containsKey(attachment.id) } - .filterNot { attachment -> container.attachmentFileService.createAttachmentCacheFile(attachment.id, attachment.fileName).exists() } + .filterNot { attachment -> + cachedAttachmentFiles[attachment.id] + ?.let { File(it.localPath).isCompleteCacheFileFor(attachment) } == true + } + .filterNot { attachment -> + container.attachmentFileService + .createAttachmentCacheFile(attachment.id, attachment.fileName) + .isCompleteCacheFileFor(attachment) + } .take(8) .forEach { attachment -> viewModelScope.launch { @@ -1760,6 +1815,20 @@ class ChatViewModel( message.hasProtectedContent && message.attachments.any { it.id == attachment.id } } + private fun File.isCompleteCacheFileFor(attachment: AttachmentDto): Boolean { + if (!isFile) { + return false + } + + val actualSize = length() + if (actualSize <= 0L) { + return false + } + + val expectedSize = attachment.fileSizeBytes + return expectedSize <= 0L || actualSize == expectedSize + } + private fun toggleVoiceAttachmentPlayback(attachment: AttachmentDto) { viewModelScope.launch { runCatching { @@ -1881,13 +1950,19 @@ class ChatViewModel( } private fun findMatchingPendingLocalMessageId(state: ChatUiState, serverMessage: MessageDto): String? { - if (serverMessage.isLocalMessage() || state.sendingLocalMessageIds.isEmpty()) { + if (serverMessage.isLocalMessage()) { return null } + serverMessage.clientMessageId?.let { clientMessageId -> + state.messages.firstOrNull { localMessage -> + localMessage.isLocalMessage() && localMessage.clientMessageId == clientMessageId + }?.let { return it.id } + } + return state.messages.firstOrNull { localMessage -> - state.sendingLocalMessageIds.contains(localMessage.id) && - localMessage.sender.id == serverMessage.sender.id && + localMessage.isLocalMessage() && + localMessage.sender.id == serverMessage.sender.id && localMessage.text == serverMessage.text && localMessage.attachments.isEmpty() && serverMessage.attachments.isEmpty() && @@ -2097,7 +2172,7 @@ internal fun MessageDto.canManage(currentUserId: String?, canPostAsChannel: Bool internal fun MessageDto.canDelete(currentUserId: String?): Boolean = currentUserId != null && deletedAt == null -private fun MessageDto.isLocalMessage(): Boolean = id.startsWith(LocalMessageIdPrefix) +private fun MessageDto.isLocalMessage(): Boolean = id.startsWith(ClientMessageIds.LocalPrefix) private fun searchLoadedMessageIds(query: String, messages: List<MessageDto>): List<String> { val normalizedQuery = query.trim() diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chats/ChatsScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chats/ChatsScreen.kt index dc969c8..28e9879 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chats/ChatsScreen.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chats/ChatsScreen.kt @@ -73,6 +73,7 @@ import com.seven.ikar.kotlin.core.model.ChatSummaryDto import com.seven.ikar.kotlin.core.model.ChatType import com.seven.ikar.kotlin.core.model.StoryDto import com.seven.ikar.kotlin.core.network.ServerConfig +import com.seven.ikar.kotlin.core.realtime.RealtimeConnectionStage import com.seven.ikar.kotlin.core.settings.ChatListLayoutOption import com.seven.ikar.kotlin.core.settings.ChatSettingsSnapshot import com.seven.ikar.kotlin.core.settings.ChatSwipeLeftActionOption @@ -125,7 +126,7 @@ fun ChatsScreen( onFilterChanged: (ChatListFilter) -> Unit, onQueryChanged: (String) -> Unit ) { - val isRealtimeConnected = !state.isLoading || state.chats.isNotEmpty() + val isRealtimeConnected = state.realtimeStage == RealtimeConnectionStage.Connected val previewMaxLines = if (chatSettings.chatListLayout == ChatListLayoutOption.ThreeLine) 2 else 1 val chatListState = rememberLazyListState() var isStoriesHeaderVisible by remember { mutableStateOf(false) } @@ -188,8 +189,8 @@ fun ChatsScreen( .nestedScroll(storyHeaderNestedScrollConnection) ) { Column( - modifier = Modifier.padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 12.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + modifier = Modifier.padding(start = 14.dp, top = 8.dp, end = 14.dp, bottom = 6.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) ) { when { state.isSelectionMode && state.isShareMode -> { @@ -247,8 +248,11 @@ fun ChatsScreen( ) } - TextButton(onClick = onGlobalSearchClick) { - Text("Глобальный поиск: сообщения, медиа, файлы и ссылки") + TextButton( + onClick = onGlobalSearchClick, + contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp) + ) { + Text("Поиск по сообщениям и файлам", style = MaterialTheme.typography.labelMedium) } state.pendingShare?.let { pendingShare -> @@ -327,7 +331,7 @@ fun ChatsScreen( } } } - item { Box(modifier = Modifier.padding(bottom = 132.dp)) } + item { Box(modifier = Modifier.padding(bottom = 104.dp)) } } if (!state.isSelectionMode && !state.isShareMode) { @@ -336,7 +340,7 @@ fun ChatsScreen( contentDescription = "Новое сообщение", modifier = Modifier .align(Alignment.BottomEnd) - .padding(end = 20.dp, bottom = 126.dp), + .padding(end = 16.dp, bottom = 98.dp), onClick = onDiscoverClick ) } @@ -347,7 +351,7 @@ fun ChatsScreen( selectedTab = IkarMainMenuTab.Chats, modifier = Modifier .align(Alignment.BottomCenter) - .padding(horizontal = 8.dp, vertical = 18.dp), + .padding(horizontal = 8.dp, vertical = 8.dp), onChatsClick = {}, onContactsClick = onContactsClick, onSettingsClick = onSettingsClick, @@ -412,7 +416,7 @@ private fun ChatsBrandHeader( onStoriesClick: () -> Unit ) { Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { StoriesClusterButton( @@ -424,12 +428,16 @@ private fun ChatsBrandHeader( Column(verticalArrangement = Arrangement.spacedBy(0.dp)) { Text( text = if (isRealtimeConnected) "ИКАР" else "Соединение...", - style = MaterialTheme.typography.displayMedium.copy( - letterSpacing = if (isRealtimeConnected) 3.sp else 0.sp + style = MaterialTheme.typography.titleLarge.copy( + letterSpacing = if (isRealtimeConnected) 2.sp else 0.sp ), color = TelegramBlue ) - Text("Чаты", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted) + Text( + if (isRealtimeConnected) "Чаты" else "Повторное подключение", + style = MaterialTheme.typography.labelSmall, + color = TelegramInkMuted + ) } } } @@ -939,7 +947,11 @@ private fun SwipeableChatListRow( verticalAlignment = Alignment.CenterVertically ) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { - Icon(Icons.Outlined.PushPin, contentDescription = null, tint = TelegramBlue) + Icon( + Icons.Outlined.PushPin, + contentDescription = if (chatItem.chat.isPinned) "Открепить чат" else "Закрепить чат", + tint = TelegramBlue + ) Text( if (chatItem.chat.isPinned) "Открепить" else "Закрепить", style = MaterialTheme.typography.labelLarge, @@ -958,7 +970,11 @@ private fun SwipeableChatListRow( style = MaterialTheme.typography.labelLarge, color = TelegramBlue ) - Icon(leftSwipeIcon, contentDescription = null, tint = TelegramBlue) + Icon( + leftSwipeIcon, + contentDescription = chatItem.chat.leftSwipeActionLabel(swipeLeftAction), + tint = TelegramBlue + ) } } } @@ -1009,18 +1025,18 @@ private fun ChatListRow( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 2.dp) + .padding(horizontal = 6.dp, vertical = 1.dp) .clip(MaterialTheme.shapes.large) .background(rowBackground) .combinedClickable(onClick = onClick, onLongClick = onLongClick) - .padding(horizontal = 8.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.spacedBy(14.dp), + .padding(horizontal = 8.dp, vertical = 7.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically ) { AvatarView( imageUrl = avatarUrl, title = chatItem.resolvedTitle, - size = 56.dp + size = 48.dp ) Column( @@ -1035,7 +1051,7 @@ private fun ChatListRow( Text( text = chatItem.resolvedTitle, modifier = Modifier.weight(1f), - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis ) @@ -1088,7 +1104,7 @@ private fun ChatListRow( ) } } - IkarRowDivider(startIndent = 86.dp) + IkarRowDivider(startIndent = 72.dp) } } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chats/ChatsViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chats/ChatsViewModel.kt index 87a361a..ca37a67 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chats/ChatsViewModel.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/chats/ChatsViewModel.kt @@ -13,6 +13,7 @@ import com.seven.ikar.kotlin.core.model.ChatStateRequest import com.seven.ikar.kotlin.core.model.ChatSummaryDto import com.seven.ikar.kotlin.core.model.ChatType import com.seven.ikar.kotlin.core.model.StoryDto +import com.seven.ikar.kotlin.core.realtime.RealtimeConnectionStage import com.seven.ikar.kotlin.feature.share.PendingExternalShare import com.seven.ikar.kotlin.ui.formatLastActivity import kotlinx.coroutines.Dispatchers @@ -69,7 +70,8 @@ data class ChatsUiState( val showDeleteForEveryoneOption: Boolean = false, val deleteForEveryoneLabel: String = DefaultDeleteForEveryoneLabel, val isDeletingSelected: Boolean = false, - val selectionStartedFromSwipeDelete: Boolean = false + val selectionStartedFromSwipeDelete: Boolean = false, + val realtimeStage: RealtimeConnectionStage = RealtimeConnectionStage.Disconnected ) { val isShareMode: Boolean get() = pendingShare != null @@ -582,6 +584,14 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() { } private fun attachRealtime() { + viewModelScope.launch { + container.realtimeService.stage.collect { stage -> + _uiState.value = _uiState.value.copy(realtimeStage = stage) + } + } + viewModelScope.launch { + container.realtimeService.connectionRestored.collect { scheduleRefresh() } + } viewModelScope.launch { container.realtimeService.chatCreated.collect { scheduleRefresh() } } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/login/LoginScreen.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/login/LoginScreen.kt index 26dceae..001cff8 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/login/LoginScreen.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/login/LoginScreen.kt @@ -29,6 +29,8 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp +import androidx.compose.ui.res.stringResource +import com.seven.ikar.kotlin.R @Composable fun LoginScreen( @@ -88,13 +90,17 @@ fun LoginScreen( verticalArrangement = Arrangement.spacedBy(4.dp) ) { Text( - text = state.pageTitle, + text = stringResource( + if (state.isAwaitingCode) R.string.login_verify_email_title else R.string.login_title + ), style = MaterialTheme.typography.headlineSmall, color = Color.White, fontWeight = FontWeight.SemiBold ) Text( - text = state.subtitle, + text = stringResource( + if (state.isAwaitingCode) R.string.login_code_subtitle else R.string.login_subtitle + ), style = MaterialTheme.typography.bodyMedium, color = Color(0xFFDDEEFF) ) @@ -111,7 +117,7 @@ fun LoginScreen( verticalArrangement = Arrangement.spacedBy(16.dp) ) { Text( - text = "АККАУНТ", + text = stringResource(R.string.login_account_section), style = MaterialTheme.typography.labelLarge, color = Color(0xFF5A8EB2), fontWeight = FontWeight.SemiBold @@ -122,7 +128,7 @@ fun LoginScreen( value = state.email, onValueChange = onEmailChanged, modifier = Modifier.fillMaxWidth(), - label = { Text("Электронная почта") }, + label = { Text(stringResource(R.string.login_email)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), singleLine = true ) @@ -131,7 +137,7 @@ fun LoginScreen( value = state.phoneNumber, onValueChange = onPhoneNumberChanged, modifier = Modifier.fillMaxWidth(), - label = { Text("Номер телефона") }, + label = { Text(stringResource(R.string.login_phone)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), singleLine = true ) @@ -147,7 +153,7 @@ fun LoginScreen( modifier = Modifier.padding(horizontal = 8.dp) ) } else { - Text("Получить код") + Text(stringResource(R.string.login_request_code)) } } } @@ -164,12 +170,12 @@ fun LoginScreen( verticalArrangement = Arrangement.spacedBy(10.dp) ) { Text( - text = "Подтверждение email", + text = stringResource(R.string.login_verify_email_title), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold ) Text( - text = "Код отправлен на указанную электронную почту. Номер телефона будет привязан к аккаунту.", + text = stringResource(R.string.login_code_explanation), style = MaterialTheme.typography.bodyMedium, color = Color(0xFF617A8F) ) @@ -178,7 +184,7 @@ fun LoginScreen( value = state.verificationCode, onValueChange = onVerificationCodeChanged, modifier = Modifier.fillMaxWidth(), - label = { Text("Код подтверждения") }, + label = { Text(stringResource(R.string.login_code)) }, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), singleLine = true ) @@ -188,7 +194,7 @@ fun LoginScreen( value = state.displayName, onValueChange = onDisplayNameChanged, modifier = Modifier.fillMaxWidth(), - label = { Text("Ваше имя") }, + label = { Text(stringResource(R.string.login_display_name)) }, singleLine = true ) } @@ -196,8 +202,7 @@ fun LoginScreen( Button( onClick = onVerifyCodeClick, enabled = !state.isLoading && - state.verificationCode.isNotBlank() && - (!state.isNewAccount || state.displayName.isNotBlank()), + state.verificationCode.isNotBlank(), contentPadding = PaddingValues(horizontal = 20.dp, vertical = 14.dp) ) { if (state.isLoading) { @@ -206,7 +211,7 @@ fun LoginScreen( modifier = Modifier.padding(horizontal = 8.dp) ) } else { - Text("Продолжить") + Text(stringResource(R.string.login_continue)) } } @@ -219,7 +224,7 @@ fun LoginScreen( ), contentPadding = PaddingValues(horizontal = 0.dp, vertical = 12.dp) ) { - Text("Изменить email или телефон") + Text(stringResource(R.string.login_change_identity)) } } } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/login/LoginViewModel.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/login/LoginViewModel.kt index f3ba592..d5814a0 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/login/LoginViewModel.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/login/LoginViewModel.kt @@ -38,7 +38,7 @@ data class LoginUiState( get() = isAwaitingCode val showDisplayName: Boolean - get() = isAwaitingCode && isNewAccount + get() = isAwaitingCode } class LoginViewModel(private val container: AppContainer) : ViewModel() { @@ -103,8 +103,7 @@ class LoginViewModel(private val container: AppContainer) : ViewModel() { state.challengeId.isBlank() || state.email.isBlank() || state.phoneNumber.isBlank() || - state.verificationCode.isBlank() || - (state.isNewAccount && state.displayName.isBlank()) + state.verificationCode.isBlank() ) { return } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/transfer/TextMessageWorker.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/transfer/TextMessageWorker.kt new file mode 100644 index 0000000..b981152 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/transfer/TextMessageWorker.kt @@ -0,0 +1,62 @@ +package com.seven.ikar.kotlin.feature.transfer + +import android.content.Context +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.seven.ikar.kotlin.app.IkarNativeApp +import com.seven.ikar.kotlin.core.local.TransferQueueStatus +import com.seven.ikar.kotlin.core.local.TransferQueueTypes + +class TextMessageWorker( + appContext: Context, + params: WorkerParameters +) : CoroutineWorker(appContext, params) { + override suspend fun doWork(): Result { + val groupId = inputData.getString(KeyGroupId) ?: return Result.failure() + val container = (applicationContext as IkarNativeApp).container + val dao = container.localDatabase.transferQueueDao() + val item = dao.getGroup(groupId).firstOrNull() ?: return Result.success() + if (item.type != TransferQueueTypes.TextMessage) { + return Result.failure() + } + + val chatId = item.chatId ?: return Result.failure() + val text = item.text?.takeIf(String::isNotBlank) ?: return Result.failure() + val clientMessageId = item.clientMessageId ?: groupId + dao.updateGroupStatus(groupId, TransferQueueStatus.Running, 25, null, System.currentTimeMillis()) + + return runCatching { + container.executeAuthorized { session -> + val message = container.apiClient.sendMessage( + chatId = chatId, + text = text, + accessToken = session.accessToken, + replyToMessageId = item.replyToMessageId, + clientMessageId = clientMessageId + ) + container.chatMessageCache.upsertMessages(session.user.id, chatId, listOf(message)) + } + }.fold( + onSuccess = { + dao.updateGroupStatus(groupId, TransferQueueStatus.Completed, 100, null, System.currentTimeMillis()) + container.realtimeService.notifyChatsChangedLocally() + Result.success() + }, + onFailure = { error -> + dao.updateGroupStatus( + groupId, + TransferQueueStatus.Failed, + 0, + error.message, + System.currentTimeMillis() + ) + if (runAttemptCount < MaxAutomaticRetries) Result.retry() else Result.failure() + } + ) + } + + companion object { + const val KeyGroupId = "group_id" + private const val MaxAutomaticRetries = 5 + } +} diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/transfer/TransferQueueRepository.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/transfer/TransferQueueRepository.kt index 15aa63f..f781417 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/transfer/TransferQueueRepository.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/feature/transfer/TransferQueueRepository.kt @@ -15,6 +15,7 @@ import com.seven.ikar.kotlin.core.model.AttachmentDto import com.seven.ikar.kotlin.core.model.StoryVisibility import com.seven.ikar.kotlin.feature.chat.LocalAttachmentFile import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first import java.util.UUID class TransferQueueRepository( @@ -27,6 +28,46 @@ class TransferQueueRepository( fun observeActiveStoryUploads(): Flow<List<TransferQueueEntity>> = dao.observeActiveStoryUploads() + suspend fun enqueueTextMessage( + chatId: String, + text: String, + replyToMessageId: String?, + clientMessageId: String + ): String { + require(text.isNotBlank()) { "Message text is required." } + + val now = System.currentTimeMillis() + dao.upsert( + listOf( + TransferQueueEntity( + id = UUID.randomUUID().toString(), + groupId = clientMessageId, + type = TransferQueueTypes.TextMessage, + status = TransferQueueStatus.Queued, + chatId = chatId, + text = text, + replyToMessageId = replyToMessageId, + clientMessageId = clientMessageId, + attachmentId = null, + storyVisibility = null, + hasProtectedContent = false, + ttlSeconds = null, + fileName = "", + contentType = "text/plain", + fileSizeBytes = 0, + localPath = "", + sortOrder = 0, + progressPercent = 0, + errorMessage = null, + createdAtMillis = now, + updatedAtMillis = now + ) + ) + ) + scheduler.enqueueTextMessage(clientMessageId) + return clientMessageId + } + suspend fun enqueueUpload( chatId: String, text: String?, @@ -47,6 +88,7 @@ class TransferQueueRepository( chatId = chatId, text = text?.takeIf { it.isNotBlank() }, replyToMessageId = replyToMessageId, + clientMessageId = null, attachmentId = null, storyVisibility = null, hasProtectedContent = false, @@ -88,6 +130,7 @@ class TransferQueueRepository( chatId = null, text = text?.takeIf { it.isNotBlank() }, replyToMessageId = null, + clientMessageId = null, attachmentId = null, storyVisibility = visibility.name, hasProtectedContent = hasProtectedContent, @@ -109,6 +152,7 @@ class TransferQueueRepository( } suspend fun enqueueDownload(chatId: String, attachment: AttachmentDto): String { + dao.findActiveDownloadGroup(attachment.id)?.let { return it } val groupId = UUID.randomUUID().toString() val now = System.currentTimeMillis() dao.upsert( @@ -121,6 +165,7 @@ class TransferQueueRepository( chatId = chatId, text = null, replyToMessageId = null, + clientMessageId = null, attachmentId = attachment.id, storyVisibility = null, hasProtectedContent = false, @@ -141,10 +186,22 @@ class TransferQueueRepository( return groupId } + suspend fun awaitCompletion(groupId: String): List<TransferQueueEntity> = + dao.observeGroup(groupId).first { items -> + items.isNotEmpty() && items.all { item -> + item.status == TransferQueueStatus.Completed || item.status == TransferQueueStatus.Failed + } + }.also { items -> + items.firstOrNull { it.status == TransferQueueStatus.Failed }?.let { failed -> + throw IllegalStateException(failed.errorMessage ?: "Transfer failed.") + } + } + suspend fun retry(groupId: String) { val type = dao.getGroup(groupId).firstOrNull()?.type dao.updateGroupStatus(groupId, TransferQueueStatus.Queued, 0, null, System.currentTimeMillis()) when (type) { + TransferQueueTypes.TextMessage -> scheduler.enqueueTextMessage(groupId) TransferQueueTypes.Download -> scheduler.enqueueDownload(groupId) TransferQueueTypes.StoryUpload -> scheduler.enqueueStoryUpload(groupId) else -> scheduler.enqueueUpload(groupId) @@ -172,6 +229,15 @@ class TransferWorkScheduler(context: Context) { workManager.enqueueUniqueWork(uploadWorkName(groupId), ExistingWorkPolicy.REPLACE, request) } + fun enqueueTextMessage(groupId: String) { + val request = OneTimeWorkRequestBuilder<TextMessageWorker>() + .setInputData(workDataOf(TextMessageWorker.KeyGroupId to groupId)) + .setConstraints(networkConstraints) + .build() + + workManager.enqueueUniqueWork(textMessageWorkName(groupId), ExistingWorkPolicy.KEEP, request) + } + fun enqueueStoryUpload(groupId: String) { val request = OneTimeWorkRequestBuilder<AttachmentUploadWorker>() .setInputData(workDataOf(AttachmentUploadWorker.KeyGroupId to groupId)) @@ -191,6 +257,7 @@ class TransferWorkScheduler(context: Context) { } fun cancel(groupId: String) { + workManager.cancelUniqueWork(textMessageWorkName(groupId)) workManager.cancelUniqueWork(uploadWorkName(groupId)) workManager.cancelUniqueWork(storyUploadWorkName(groupId)) workManager.cancelUniqueWork(downloadWorkName(groupId)) @@ -198,6 +265,8 @@ class TransferWorkScheduler(context: Context) { private fun uploadWorkName(groupId: String): String = "upload-$groupId" + private fun textMessageWorkName(groupId: String): String = "text-message-$groupId" + private fun storyUploadWorkName(groupId: String): String = "story-upload-$groupId" private fun downloadWorkName(groupId: String): String = "download-$groupId" diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/AuthenticatedAsyncImage.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/AuthenticatedAsyncImage.kt index 41d8a13..81d0eaa 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/AuthenticatedAsyncImage.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/AuthenticatedAsyncImage.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext import coil.compose.AsyncImage +import coil.compose.AsyncImagePainter import coil.request.CachePolicy import coil.request.ImageRequest import java.security.MessageDigest @@ -16,7 +17,8 @@ fun AuthenticatedAsyncImage( accessToken: String? = null, contentDescription: String, modifier: Modifier = Modifier, - contentScale: ContentScale = ContentScale.Crop + contentScale: ContentScale = ContentScale.Crop, + onSuccess: ((AsyncImagePainter.State.Success) -> Unit)? = null ) { if (imageUrl.isNullOrBlank()) { return @@ -45,7 +47,8 @@ fun AuthenticatedAsyncImage( model = request, contentDescription = contentDescription, modifier = modifier, - contentScale = contentScale + contentScale = contentScale, + onSuccess = onSuccess ) } diff --git a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/IkarChrome.kt b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/IkarChrome.kt index 2ca6ad9..6b16c99 100644 --- a/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/IkarChrome.kt +++ b/src/Ikar.AndroidKotlin/app/src/main/java/com/seven/ikar/kotlin/ui/IkarChrome.kt @@ -524,8 +524,8 @@ fun IkarFloatingMainMenu( Row( modifier = Modifier .border(1.dp, TelegramFloatingBorder, RoundedCornerShape(32.dp)) - .padding(horizontal = 12.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp) + .padding(horizontal = 8.dp, vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) ) { val unreadChatsCount = LocalUnreadChatsCount.current IkarFloatingMenuItem( @@ -573,9 +573,9 @@ private fun IkarFloatingMenuItem( .clip(shape) .clickable(onClick = onClick) .background(backgroundColor) - .padding(horizontal = 10.dp, vertical = 8.dp), + .padding(horizontal = 8.dp, vertical = 5.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(4.dp) + verticalArrangement = Arrangement.spacedBy(2.dp) ) { Box( modifier = Modifier.size(28.dp), diff --git a/src/Ikar.AndroidKotlin/app/src/main/res/values-en/strings.xml b/src/Ikar.AndroidKotlin/app/src/main/res/values-en/strings.xml new file mode 100644 index 0000000..84925a6 --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/res/values-en/strings.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="app_name">Ikar</string> + <string name="login_title">Sign in to IKAR</string> + <string name="login_verify_email_title">Verify your email</string> + <string name="login_subtitle">Enter your email and phone number. The code will only be sent by email.</string> + <string name="login_code_subtitle">Enter the code sent to your email address.</string> + <string name="login_account_section">ACCOUNT</string> + <string name="login_email">Email</string> + <string name="login_phone">Phone number</string> + <string name="login_request_code">Send code</string> + <string name="login_code">Verification code</string> + <string name="login_display_name">Your name (for a new account)</string> + <string name="login_continue">Continue</string> + <string name="login_change_identity">Change email or phone</string> + <string name="login_code_explanation">The code was sent by email. Your phone number will be linked to the account.</string> +</resources> diff --git a/src/Ikar.AndroidKotlin/app/src/main/res/values/strings.xml b/src/Ikar.AndroidKotlin/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..28d48bb --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/main/res/values/strings.xml @@ -0,0 +1,17 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <string name="app_name">Икар</string> + <string name="login_title">Вход в ИКАР</string> + <string name="login_verify_email_title">Подтвердите email</string> + <string name="login_subtitle">Введите электронную почту и номер телефона. Код придёт только на email.</string> + <string name="login_code_subtitle">Введите код, отправленный на указанную электронную почту.</string> + <string name="login_account_section">АККАУНТ</string> + <string name="login_email">Электронная почта</string> + <string name="login_phone">Номер телефона</string> + <string name="login_request_code">Получить код</string> + <string name="login_code">Код подтверждения</string> + <string name="login_display_name">Ваше имя (для нового аккаунта)</string> + <string name="login_continue">Продолжить</string> + <string name="login_change_identity">Изменить email или телефон</string> + <string name="login_code_explanation">Код отправлен на указанную электронную почту. Номер телефона будет привязан к аккаунту.</string> +</resources> diff --git a/src/Ikar.AndroidKotlin/app/src/test/java/com/seven/ikar/kotlin/core/model/ClientMessageIdsTest.kt b/src/Ikar.AndroidKotlin/app/src/test/java/com/seven/ikar/kotlin/core/model/ClientMessageIdsTest.kt new file mode 100644 index 0000000..e9c3bba --- /dev/null +++ b/src/Ikar.AndroidKotlin/app/src/test/java/com/seven/ikar/kotlin/core/model/ClientMessageIdsTest.kt @@ -0,0 +1,21 @@ +package com.seven.ikar.kotlin.core.model + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class ClientMessageIdsTest { + @Test + fun localIdRoundTripsClientId() { + val clientId = ClientMessageIds.newClientId() + assertEquals(clientId, ClientMessageIds.clientIdFromLocalId(ClientMessageIds.localId(clientId))) + } + + @Test + fun generatedIdsAreUniqueAndNonLocalValuesAreRejected() { + assertNotEquals(ClientMessageIds.newClientId(), ClientMessageIds.newClientId()) + assertNull(ClientMessageIds.clientIdFromLocalId("server-message-id")) + assertNull(ClientMessageIds.clientIdFromLocalId(ClientMessageIds.LocalPrefix)) + } +} diff --git a/src/Ikar.Server.Tests/AuthEmailControllerTests.cs b/src/Ikar.Server.Tests/AuthEmailControllerTests.cs index 5602e23..f57bd88 100644 --- a/src/Ikar.Server.Tests/AuthEmailControllerTests.cs +++ b/src/Ikar.Server.Tests/AuthEmailControllerTests.cs @@ -152,7 +152,7 @@ public sealed class AuthEmailControllerTests } [Fact] - public async Task RequestPhoneCode_NewPhone_DoesNotRegister() + public async Task RequestPhoneCode_IsDisabled() { await using var database = new SqliteTestDatabase(); await using var dbContext = await database.CreateContextAsync(); @@ -162,7 +162,8 @@ public sealed class AuthEmailControllerTests new RequestPhoneCodeRequest("+10000009999"), CancellationToken.None); - Assert.IsType<NotFoundObjectResult>(result.Result); + var response = Assert.IsType<ObjectResult>(result.Result); + Assert.Equal(StatusCodes.Status410Gone, response.StatusCode); } private static AuthController CreateController( @@ -174,7 +175,6 @@ public sealed class AuthEmailControllerTests dbContext, new TokenService(dbContext, Options.Create(new JwtOptions())), new PresenceTracker(), - new PhoneAuthChallengeStore(), emailChallengeStore, emailCodeSender); controller.ControllerContext = new ControllerContext diff --git a/src/Ikar.Server.Tests/ChannelMembersControllerTests.cs b/src/Ikar.Server.Tests/ChannelMembersControllerTests.cs index 0d8bc99..0ef23f3 100644 --- a/src/Ikar.Server.Tests/ChannelMembersControllerTests.cs +++ b/src/Ikar.Server.Tests/ChannelMembersControllerTests.cs @@ -519,7 +519,7 @@ public sealed class ChannelMembersControllerTests new UploadAttachmentRequest { Text = "Photo post", - File = CreateFormFile([1, 2, 3], "photo.jpg", "image/jpeg") + File = CreateFormFile([0xFF, 0xD8, 0xFF, 0xE0], "photo.jpg", "image/jpeg") }, CancellationToken.None); @@ -570,8 +570,8 @@ public sealed class ChannelMembersControllerTests Text = "Gallery", Files = [ - CreateFormFile([1, 2, 3], "first.jpg", "image/jpeg"), - CreateFormFile([4, 5, 6], "second.jpg", "image/jpeg") + CreateFormFile([0xFF, 0xD8, 0xFF, 0xE0], "first.jpg", "image/jpeg"), + CreateFormFile([0xFF, 0xD8, 0xFF, 0xE1], "second.jpg", "image/jpeg") ] }, CancellationToken.None); diff --git a/src/Ikar.Server.Tests/ReliabilityAndSecurityTests.cs b/src/Ikar.Server.Tests/ReliabilityAndSecurityTests.cs new file mode 100644 index 0000000..eebbdeb --- /dev/null +++ b/src/Ikar.Server.Tests/ReliabilityAndSecurityTests.cs @@ -0,0 +1,166 @@ +using Ikar.Server.Data.Entities; +using Ikar.Server.Infrastructure.Auth; +using Ikar.Server.Infrastructure.Calls; +using Ikar.Server.Infrastructure.Push; +using Ikar.Server.Infrastructure.Storage; +using Ikar.Shared; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Ikar.Server.Tests; + +public sealed class ReliabilityAndSecurityTests +{ + [Fact] + public void EmailChallenge_EnforcesResendDelayAndAttemptLimit() + { + var store = new EmailAuthChallengeStore(); + var challenge = store.Create("user@example.com", "10000000000", isRegistered: true); + + var rateLimit = Assert.Throws<AuthChallengeRateLimitException>(() => + store.Create("user@example.com", "10000000000", isRegistered: true)); + Assert.True(rateLimit.RetryAfter > TimeSpan.Zero); + + for (var attempt = 0; attempt < 5; attempt++) + { + Assert.Null(store.Consume(challenge.Id, challenge.NormalizedEmail, challenge.NormalizedPhoneNumber, "000000")); + } + + Assert.Null(store.Consume(challenge.Id, challenge.NormalizedEmail, challenge.NormalizedPhoneNumber, challenge.Code)); + } + + [Fact] + public void AttachmentInspector_UsesFileSignatureInsteadOfClaimedMimeType() + { + using var fakeImageStream = new MemoryStream("not-an-image"u8.ToArray()); + var fakeImage = new FormFile(fakeImageStream, 0, fakeImageStream.Length, "file", "photo.jpg") + { + Headers = new HeaderDictionary(), + ContentType = "image/jpeg" + }; + Assert.Equal("application/octet-stream", AttachmentContentInspector.Inspect(fakeImage).ContentType); + + using var jpegStream = new MemoryStream([0xFF, 0xD8, 0xFF, 0xE0]); + var jpeg = new FormFile(jpegStream, 0, jpegStream.Length, "file", "photo.bin") + { + Headers = new HeaderDictionary(), + ContentType = "application/octet-stream" + }; + var inspectedJpeg = AttachmentContentInspector.Inspect(jpeg); + Assert.Equal("image/jpeg", inspectedJpeg.ContentType); + Assert.True(inspectedJpeg.IsImage); + } + + [Fact] + public void CallStore_ExpiresUnansweredCalls() + { + var store = new CallSessionStore(); + Assert.True(store.TryCreate( + Guid.NewGuid(), + Guid.NewGuid(), + Guid.NewGuid(), + CallMediaType.Audio, + out var created, + out _)); + + var expired = store.DrainExpired( + created!.CreatedAt.AddSeconds(61), + TimeSpan.FromSeconds(60), + TimeSpan.FromHours(8)); + + var ended = Assert.Single(expired); + Assert.Equal("no_answer", ended.EndReason); + Assert.Equal(CallSessionState.Ended, ended.State); + } + + [Fact] + public async Task SendMessage_ReusingClientMessageId_ReturnsOriginalMessage() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var user = new User + { + Username = "reliable_sender", + NormalizedUsername = "RELIABLE_SENDER", + DisplayName = "Reliable Sender", + PasswordHash = "test" + }; + var chat = new Chat + { + Type = ChatType.Group, + Title = "Idempotency", + CreatedBy = user, + CreatedById = user.Id + }; + chat.Members.Add(new ChatMember + { + Chat = chat, + ChatId = chat.Id, + User = user, + UserId = user.Id, + IsOwner = true, + Role = ChatMemberRole.Owner + }); + dbContext.AddRange(user, chat); + await dbContext.SaveChangesAsync(); + + var controller = ControllerTestFixture.CreateChatsController(dbContext, user.Id); + var clientMessageId = Guid.NewGuid(); + var request = new SendMessageRequest("Send once", ClientMessageId: clientMessageId); + var first = ControllerTestFixture.ValueOf(await controller.SendMessage(chat.Id, request, CancellationToken.None)); + var second = ControllerTestFixture.ValueOf(await controller.SendMessage(chat.Id, request, CancellationToken.None)); + + Assert.Equal(first.Id, second.Id); + Assert.Equal(clientMessageId, second.ClientMessageId); + Assert.Equal(1, await dbContext.Messages.CountAsync()); + } + + [Fact] + public async Task PushOutbox_IsCommittedWithTheMessageAndDeduplicated() + { + await using var database = new SqliteTestDatabase(); + await using var dbContext = await database.CreateContextAsync(); + var user = new User + { + Username = "outbox_sender", + NormalizedUsername = "OUTBOX_SENDER", + DisplayName = "Outbox Sender", + PasswordHash = "test" + }; + var chat = new Chat + { + Type = ChatType.Group, + Title = "Outbox", + CreatedBy = user, + CreatedById = user.Id + }; + chat.Members.Add(new ChatMember + { + Chat = chat, + ChatId = chat.Id, + User = user, + UserId = user.Id, + IsOwner = true, + Role = ChatMemberRole.Owner + }); + var message = new Message + { + Chat = chat, + ChatId = chat.Id, + Sender = user, + SenderId = user.Id, + Text = "Persist with outbox" + }; + dbContext.Messages.Add(message); + + using var services = new ServiceCollection().BuildServiceProvider(); + var queue = new PushDispatchQueue(services.GetRequiredService<IServiceScopeFactory>()); + queue.StageMessages(dbContext, message, message); + await dbContext.SaveChangesAsync(); + + var outbox = Assert.Single(await dbContext.PushDispatchOutbox.AsNoTracking().ToListAsync()); + Assert.Equal(message.Id, outbox.MessageId); + Assert.Equal($"message:{message.Id:D}", outbox.DeduplicationKey); + } +} diff --git a/src/Ikar.Server/Controllers/AuthController.cs b/src/Ikar.Server/Controllers/AuthController.cs index bef487d..55339d2 100644 --- a/src/Ikar.Server/Controllers/AuthController.cs +++ b/src/Ikar.Server/Controllers/AuthController.cs @@ -16,7 +16,6 @@ public sealed class AuthController( IkarDbContext dbContext, TokenService tokenService, PresenceTracker presenceTracker, - PhoneAuthChallengeStore phoneAuthChallengeStore, EmailAuthChallengeStore emailAuthChallengeStore, IEmailCodeSender emailCodeSender) : ControllerBase { @@ -135,77 +134,26 @@ public sealed class AuthController( [HttpPost("request-code")] [AllowAnonymous] - public async Task<ActionResult<PhoneCodeChallengeDto>> RequestPhoneCode( + public Task<ActionResult<PhoneCodeChallengeDto>> RequestPhoneCode( RequestPhoneCodeRequest request, CancellationToken cancellationToken) { - var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(request.PhoneNumber); - if (normalizedPhoneNumber is null) - { - return ValidationProblem("Valid phone number is required."); - } - - var user = await dbContext.Users - .AsNoTracking() - .SingleOrDefaultAsync(x => x.NormalizedPhoneNumber == normalizedPhoneNumber, cancellationToken); - if (user is null) - { - return NotFound("Phone code login is available only for existing accounts. Register by email."); - } - - if (user?.IsBlocked == true) - { - return StatusCode(StatusCodes.Status423Locked, "User is blocked."); - } - - var challenge = phoneAuthChallengeStore.Create(normalizedPhoneNumber, isRegistered: true); - return Ok(new PhoneCodeChallengeDto( - challenge.Id, - challenge.ExpiresAt, - challenge.Code, - challenge.IsRegistered)); + return Task.FromResult<ActionResult<PhoneCodeChallengeDto>>( + StatusCode( + StatusCodes.Status410Gone, + new { error = "Phone-code authentication is disabled. Request a code by email." })); } [HttpPost("verify-code")] [AllowAnonymous] - public async Task<ActionResult<AuthSessionDto>> VerifyPhoneCode( + public Task<ActionResult<AuthSessionDto>> VerifyPhoneCode( VerifyPhoneCodeRequest request, CancellationToken cancellationToken) { - var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(request.PhoneNumber); - if (normalizedPhoneNumber is null) - { - return ValidationProblem("Valid phone number is required."); - } - - var challenge = phoneAuthChallengeStore.Consume( - request.ChallengeId?.Trim() ?? string.Empty, - normalizedPhoneNumber, - request.Code?.Trim() ?? string.Empty); - if (challenge is null) - { - return Unauthorized("Invalid or expired verification code."); - } - - var user = await dbContext.Users.SingleOrDefaultAsync( - x => x.NormalizedPhoneNumber == normalizedPhoneNumber, - cancellationToken); - - if (user is null) - { - return NotFound("Phone code login is available only for existing accounts. Register by email."); - } - - if (user.IsBlocked) - { - return StatusCode(StatusCodes.Status423Locked, "User is blocked."); - } - - var session = await tokenService.CreateSessionAsync( - user, - ClientVersionRequestReader.Read(Request), - cancellationToken); - return Ok(session with { User = user.ToDto(presenceTracker) }); + return Task.FromResult<ActionResult<AuthSessionDto>>( + StatusCode( + StatusCodes.Status410Gone, + new { error = "Phone-code authentication is disabled. Verify a code sent by email." })); } [HttpPost("request-email-code")] @@ -247,7 +195,18 @@ public sealed class AuthController( return StatusCode(StatusCodes.Status423Locked, "User is blocked."); } - var challenge = emailAuthChallengeStore.Create(normalizedEmail, normalizedPhoneNumber, user is not null); + EmailAuthChallengeStore.EmailAuthChallenge challenge; + try + { + challenge = emailAuthChallengeStore.Create(normalizedEmail, normalizedPhoneNumber, user is not null); + } + catch (AuthChallengeRateLimitException ex) + { + Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(ex.RetryAfter.TotalSeconds)).ToString(); + return StatusCode( + StatusCodes.Status429TooManyRequests, + new { error = "Please wait before requesting another email code." }); + } try { await emailCodeSender.SendLoginCodeAsync(email, challenge.Code, challenge.ExpiresAt, cancellationToken); @@ -261,7 +220,7 @@ public sealed class AuthController( return Ok(new EmailCodeChallengeDto( challenge.Id, challenge.ExpiresAt, - challenge.IsRegistered)); + IsRegistered: false)); } [HttpPost("verify-email-code")] diff --git a/src/Ikar.Server/Controllers/BotApiController.cs b/src/Ikar.Server/Controllers/BotApiController.cs index 1439de0..12713cd 100644 --- a/src/Ikar.Server/Controllers/BotApiController.cs +++ b/src/Ikar.Server/Controllers/BotApiController.cs @@ -225,6 +225,7 @@ public sealed class BotApiController( message.DiscussionMessageId = discussionRootMessage.Id; } + pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage); await dbContext.SaveChangesAsync(cancellationToken); await NotifyMessageCreatedAsync(chat, message, cancellationToken); @@ -377,6 +378,7 @@ public sealed class BotApiController( message.DiscussionMessageId = discussionRootMessage.Id; } + pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage); await dbContext.SaveChangesAsync(cancellationToken); } catch @@ -516,6 +518,7 @@ public sealed class BotApiController( message.DiscussionMessageId = discussionRootMessage.Id; } + pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage); await dbContext.SaveChangesAsync(cancellationToken); } catch diff --git a/src/Ikar.Server/Controllers/ChatsController.cs b/src/Ikar.Server/Controllers/ChatsController.cs index 4db6279..c593e2a 100644 --- a/src/Ikar.Server/Controllers/ChatsController.cs +++ b/src/Ikar.Server/Controllers/ChatsController.cs @@ -1842,6 +1842,38 @@ public sealed class ChatsController( return StatusCode(StatusCodes.Status403Forbidden, "Only channel owners can publish."); } + if (request.ClientMessageId is not null) + { + var existingMessage = await dbContext.Messages + .AsNoTracking() + .SingleOrDefaultAsync( + message => message.SenderId == userId && message.ClientMessageId == request.ClientMessageId, + cancellationToken); + if (existingMessage is not null) + { + if (existingMessage.ChatId != chatId) + { + return Conflict("Client message id is already used in another chat."); + } + + var loadedExistingMessage = await LoadMessageForMemberAsync( + chatId, + existingMessage.Id, + userId, + cancellationToken); + if (loadedExistingMessage is null) + { + return NotFound(); + } + + // Re-enqueueing is safe because the durable outbox deduplicates by message id. + // This repairs the narrow case where the original HTTP request committed the + // message but was interrupted before the push job could be persisted. + await pushDispatchQueue.EnqueueAsync(chatId, existingMessage.Id, cancellationToken); + return Ok(loadedExistingMessage.ToDto(presenceTracker, userId)); + } + } + var storyReply = request.StoryReplyStoryId is null ? null : await LoadVisibleStoryForReplyAsync(request.StoryReplyStoryId.Value, userId, cancellationToken); @@ -1897,6 +1929,7 @@ public sealed class ChatsController( Chat = chat, SenderId = userId, Sender = sender, + ClientMessageId = request.ClientMessageId, PostedAsChannel = ShouldPostAsChannel(chat), AuthorSignature = ResolveChannelAuthorSignature(chat, sender), Text = text, @@ -1928,6 +1961,7 @@ public sealed class ChatsController( message.DiscussionMessageId = discussionRootMessage.Id; } + pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage); await dbContext.SaveChangesAsync(cancellationToken); var loadedMessage = await LoadMessageForMemberAsync(chatId, message.Id, userId, cancellationToken) @@ -2052,6 +2086,7 @@ public sealed class ChatsController( forwardedMessage.DiscussionMessageId = discussionRootMessage.Id; } + pushDispatchQueue.StageMessages(dbContext, forwardedMessage, discussionRootMessage); await dbContext.SaveChangesAsync(cancellationToken); } catch @@ -2176,6 +2211,10 @@ public sealed class ChatsController( shouldNotifyReaction = message.SenderId != userId; } + if (shouldNotifyReaction) + { + pushDispatchQueue.StageReaction(dbContext, chatId, messageId, userId, emoji); + } await dbContext.SaveChangesAsync(cancellationToken); var updatedMessage = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken) @@ -2314,6 +2353,7 @@ public sealed class ChatsController( message.DiscussionMessageId = discussionRootMessage.Id; } + pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage); await dbContext.SaveChangesAsync(cancellationToken); } catch @@ -2488,6 +2528,7 @@ public sealed class ChatsController( message.DiscussionMessageId = discussionRootMessage.Id; } + pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage); await dbContext.SaveChangesAsync(cancellationToken); } catch @@ -4425,21 +4466,8 @@ public sealed class ChatsController( private static bool IsMediaAlbumFile(IFormFile file) { - var normalizedContentType = file.ContentType - ?.Split(';', 2, StringSplitOptions.TrimEntries) - .FirstOrDefault() - ?.Trim() - .ToLowerInvariant(); - if (!string.IsNullOrWhiteSpace(normalizedContentType) && - (normalizedContentType.StartsWith("image/", StringComparison.Ordinal) || - normalizedContentType.StartsWith("video/", StringComparison.Ordinal))) - { - return true; - } - - var extension = Path.GetExtension(file.FileName).ToLowerInvariant(); - return extension is ".jpg" or ".jpeg" or ".png" or ".gif" or ".webp" or ".bmp" or - ".mp4" or ".m4v" or ".mov" or ".webm" or ".mkv"; + var contentInfo = AttachmentContentInspector.Inspect(file); + return contentInfo.IsImage || contentInfo.IsVideo; } private static string? ResolveChannelAuthorSignature(Chat chat, User sender) diff --git a/src/Ikar.Server/Data/Entities/Entities.cs b/src/Ikar.Server/Data/Entities/Entities.cs index 06c48be..a430349 100644 --- a/src/Ikar.Server/Data/Entities/Entities.cs +++ b/src/Ikar.Server/Data/Entities/Entities.cs @@ -163,6 +163,7 @@ public sealed class Message public Chat Chat { get; set; } = null!; public Guid SenderId { get; set; } public User Sender { get; set; } = null!; + public Guid? ClientMessageId { get; set; } public string Text { get; set; } = string.Empty; public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset? EditedAt { get; set; } @@ -229,6 +230,21 @@ public sealed class MessageView public DateTimeOffset ViewedAt { get; set; } = DateTimeOffset.UtcNow; } +public sealed class PushDispatchOutboxItem +{ + public long Id { get; set; } + public string DeduplicationKey { get; set; } = string.Empty; + public Guid ChatId { get; set; } + public Guid MessageId { get; set; } + public int Kind { get; set; } + public Guid? ActorUserId { get; set; } + public string? ReactionEmoji { get; set; } + public int AttemptCount { get; set; } + public DateTimeOffset NextAttemptAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public string? LastError { get; set; } +} + public sealed class PushDevice { public Guid Id { get; set; } = Guid.NewGuid(); diff --git a/src/Ikar.Server/Data/IkarDbContext.cs b/src/Ikar.Server/Data/IkarDbContext.cs index 84cac05..6b2068f 100644 --- a/src/Ikar.Server/Data/IkarDbContext.cs +++ b/src/Ikar.Server/Data/IkarDbContext.cs @@ -29,6 +29,7 @@ public sealed class IkarDbContext(DbContextOptions<IkarDbContext> options) : DbC public DbSet<MessageReaction> MessageReactions => Set<MessageReaction>(); public DbSet<MessageView> MessageViews => Set<MessageView>(); public DbSet<PushDevice> PushDevices => Set<PushDevice>(); + public DbSet<PushDispatchOutboxItem> PushDispatchOutbox => Set<PushDispatchOutboxItem>(); public DbSet<Bot> Bots => Set<Bot>(); public DbSet<BotCommand> BotCommands => Set<BotCommand>(); public DbSet<BotUpdate> BotUpdates => Set<BotUpdate>(); @@ -44,6 +45,7 @@ public sealed class IkarDbContext(DbContextOptions<IkarDbContext> options) : DbC ConfigureChats(modelBuilder); ConfigureMessages(modelBuilder); ConfigurePushDevices(modelBuilder); + ConfigurePushDispatchOutbox(modelBuilder); ConfigureBots(modelBuilder); ConfigureModeration(modelBuilder); ConfigureStories(modelBuilder); @@ -246,6 +248,9 @@ public sealed class IkarDbContext(DbContextOptions<IkarDbContext> options) : DbC entity.HasKey(x => x.Id); entity.HasIndex(x => new { x.ChatId, x.SentAt }); entity.HasIndex(x => new { x.ChatId, x.SenderId, x.SentAt }); + entity.HasIndex(x => new { x.SenderId, x.ClientMessageId }) + .IsUnique() + .HasFilter("\"ClientMessageId\" IS NOT NULL"); entity.HasIndex(x => x.SenderId); entity.HasIndex(x => x.ReplyToMessageId); entity.HasIndex(x => x.TopicId); @@ -424,6 +429,20 @@ public sealed class IkarDbContext(DbContextOptions<IkarDbContext> options) : DbC }); } + private static void ConfigurePushDispatchOutbox(ModelBuilder modelBuilder) + { + modelBuilder.Entity<PushDispatchOutboxItem>(entity => + { + entity.ToTable("PushDispatchOutbox"); + entity.HasKey(x => x.Id); + entity.Property(x => x.Id).ValueGeneratedOnAdd(); + entity.Property(x => x.DeduplicationKey).IsRequired(); + entity.HasIndex(x => x.DeduplicationKey).IsUnique(); + entity.HasIndex(x => new { x.NextAttemptAt, x.Id }); + entity.HasIndex(x => new { x.MessageId, x.Kind }); + }); + } + private static void ConfigureBots(ModelBuilder modelBuilder) { modelBuilder.Entity<Bot>(entity => diff --git a/src/Ikar.Server/Ikar.Server.csproj b/src/Ikar.Server/Ikar.Server.csproj index ce2e83b..37080c4 100644 --- a/src/Ikar.Server/Ikar.Server.csproj +++ b/src/Ikar.Server/Ikar.Server.csproj @@ -7,9 +7,11 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.4" /> - <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.4" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.4" /> + <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" /> + <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" /> + <PackageReference Include="Microsoft.OpenApi" Version="2.7.5" /> + <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" /> </ItemGroup> <ItemGroup> diff --git a/src/Ikar.Server/Infrastructure/Auth/EmailAuthChallengeStore.cs b/src/Ikar.Server/Infrastructure/Auth/EmailAuthChallengeStore.cs index 5761448..99337bf 100644 --- a/src/Ikar.Server/Infrastructure/Auth/EmailAuthChallengeStore.cs +++ b/src/Ikar.Server/Infrastructure/Auth/EmailAuthChallengeStore.cs @@ -6,11 +6,17 @@ namespace Ikar.Server.Infrastructure.Auth; public sealed class EmailAuthChallengeStore { private static readonly TimeSpan ChallengeLifetime = TimeSpan.FromMinutes(10); + private static readonly TimeSpan MinimumRequestInterval = TimeSpan.FromSeconds(60); + private static readonly TimeSpan RequestWindow = TimeSpan.FromMinutes(15); + private const int MaxRequestsPerWindow = 5; + private const int MaxVerificationAttempts = 5; private readonly ConcurrentDictionary<string, EmailAuthChallenge> _challenges = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary<string, RequestBucket> _requestBuckets = new(StringComparer.Ordinal); public EmailAuthChallenge Create(string normalizedEmail, string normalizedPhoneNumber, bool isRegistered) { CleanupExpired(); + EnforceRequestLimit($"{normalizedEmail}|{normalizedPhoneNumber}"); var challenge = new EmailAuthChallenge( Guid.NewGuid().ToString("N"), @@ -39,12 +45,21 @@ public sealed class EmailAuthChallengeStore if (!string.Equals(challenge.NormalizedEmail, normalizedEmail, StringComparison.Ordinal) || !string.Equals(challenge.NormalizedPhoneNumber, normalizedPhoneNumber, StringComparison.Ordinal) || - !string.Equals(challenge.Code, code, StringComparison.Ordinal) || challenge.ExpiresAt <= DateTimeOffset.UtcNow) { return null; } + if (!string.Equals(challenge.Code, code, StringComparison.Ordinal)) + { + if (challenge.RegisterFailedAttempt() >= MaxVerificationAttempts) + { + _challenges.TryRemove(challenge.Id, out _); + } + + return null; + } + _challenges.TryRemove(challenge.Id, out _); return challenge; } @@ -64,13 +79,74 @@ public sealed class EmailAuthChallengeStore _challenges.TryRemove(pair.Key, out _); } } + + foreach (var pair in _requestBuckets) + { + if (now - pair.Value.WindowStartedAt >= RequestWindow) + { + _requestBuckets.TryRemove(pair.Key, out _); + } + } } - public sealed record EmailAuthChallenge( - string Id, - string NormalizedEmail, - string NormalizedPhoneNumber, - string Code, - DateTimeOffset ExpiresAt, - bool IsRegistered); + private void EnforceRequestLimit(string key) + { + var now = DateTimeOffset.UtcNow; + var bucket = _requestBuckets.GetOrAdd(key, _ => new RequestBucket(now)); + lock (bucket) + { + if (now - bucket.WindowStartedAt >= RequestWindow) + { + bucket.WindowStartedAt = now; + bucket.LastRequestedAt = DateTimeOffset.MinValue; + bucket.Count = 0; + } + + var sinceLastRequest = now - bucket.LastRequestedAt; + if (bucket.LastRequestedAt != DateTimeOffset.MinValue && sinceLastRequest < MinimumRequestInterval) + { + throw new AuthChallengeRateLimitException(MinimumRequestInterval - sinceLastRequest); + } + + if (bucket.Count >= MaxRequestsPerWindow) + { + throw new AuthChallengeRateLimitException(RequestWindow - (now - bucket.WindowStartedAt)); + } + + bucket.LastRequestedAt = now; + bucket.Count++; + } + } + + public sealed class EmailAuthChallenge( + string id, + string normalizedEmail, + string normalizedPhoneNumber, + string code, + DateTimeOffset expiresAt, + bool isRegistered) + { + private int _failedAttempts; + + public string Id { get; } = id; + public string NormalizedEmail { get; } = normalizedEmail; + public string NormalizedPhoneNumber { get; } = normalizedPhoneNumber; + public string Code { get; } = code; + public DateTimeOffset ExpiresAt { get; } = expiresAt; + public bool IsRegistered { get; } = isRegistered; + + public int RegisterFailedAttempt() => Interlocked.Increment(ref _failedAttempts); + } + + private sealed class RequestBucket(DateTimeOffset windowStartedAt) + { + public DateTimeOffset WindowStartedAt { get; set; } = windowStartedAt; + public DateTimeOffset LastRequestedAt { get; set; } = DateTimeOffset.MinValue; + public int Count { get; set; } + } +} + +public sealed class AuthChallengeRateLimitException(TimeSpan retryAfter) : Exception("Authentication challenge rate limit exceeded.") +{ + public TimeSpan RetryAfter { get; } = retryAfter; } diff --git a/src/Ikar.Server/Infrastructure/Bots/BotCreatorService.cs b/src/Ikar.Server/Infrastructure/Bots/BotCreatorService.cs index 2de1541..dbd48b7 100644 --- a/src/Ikar.Server/Infrastructure/Bots/BotCreatorService.cs +++ b/src/Ikar.Server/Infrastructure/Bots/BotCreatorService.cs @@ -376,6 +376,7 @@ public sealed class BotCreatorService( chat.LastActivityAt = now; dbContext.Messages.Add(reply); + pushDispatchQueue.StageMessages(dbContext, reply); await dbContext.SaveChangesAsync(cancellationToken); var loadedReply = await dbContext.Messages diff --git a/src/Ikar.Server/Infrastructure/Calls/CallSessionCleanupService.cs b/src/Ikar.Server/Infrastructure/Calls/CallSessionCleanupService.cs new file mode 100644 index 0000000..6d3aa95 --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Calls/CallSessionCleanupService.cs @@ -0,0 +1,40 @@ +using Ikar.Server.Infrastructure.Hubs; +using Ikar.Shared; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Options; + +namespace Ikar.Server.Infrastructure.Calls; + +public sealed class CallSessionCleanupService( + CallSessionStore store, + IHubContext<MessengerHub> hubContext, + IOptions<WebRtcOptions> options, + ILogger<CallSessionCleanupService> logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5)); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + var expired = store.DrainExpired( + DateTimeOffset.UtcNow, + TimeSpan.FromSeconds(Math.Clamp(options.Value.RingingTimeoutSeconds, 15, 300)), + TimeSpan.FromHours(Math.Clamp(options.Value.MaximumCallDurationHours, 1, 24))); + foreach (var session in expired) + { + var payload = new CallEndedDto( + session.CallId, + session.ChatId, + session.EndReason, + session.MediaType); + await Task.WhenAll( + hubContext.Clients.User(session.CallerId.ToString()).SendAsync("CallEnded", payload, stoppingToken), + hubContext.Clients.User(session.CalleeId.ToString()).SendAsync("CallEnded", payload, stoppingToken)); + logger.LogInformation( + "Expired call session {CallId} with reason {Reason}.", + session.CallId, + session.EndReason); + } + } + } +} diff --git a/src/Ikar.Server/Infrastructure/Calls/CallSessionStore.cs b/src/Ikar.Server/Infrastructure/Calls/CallSessionStore.cs index 78c6e6a..74aab43 100644 --- a/src/Ikar.Server/Infrastructure/Calls/CallSessionStore.cs +++ b/src/Ikar.Server/Infrastructure/Calls/CallSessionStore.cs @@ -135,6 +135,29 @@ public sealed class CallSessionStore } } + public IReadOnlyList<ActiveCallSession> DrainExpired( + DateTimeOffset now, + TimeSpan ringingTimeout, + TimeSpan maximumConnectedDuration) + { + lock (_sync) + { + var expired = _sessions.Values + .Where(stored => + stored.State == CallSessionState.Ringing && stored.CreatedAt.Add(ringingTimeout) <= now || + stored.State == CallSessionState.Connected && + stored.ConnectedAt is not null && + stored.ConnectedAt.Value.Add(maximumConnectedDuration) <= now) + .ToList(); + foreach (var stored in expired) + { + RemoveSession(stored, stored.State == CallSessionState.Ringing ? "no_answer" : "timeout"); + } + + return expired.Select(stored => stored.ToSnapshot()).ToList(); + } + } + private bool TryTerminate(Guid callId, Guid userId, string reason, bool requireParticipant, out ActiveCallSession? session, out string error) { lock (_sync) diff --git a/src/Ikar.Server/Infrastructure/Calls/WebRtcOptions.cs b/src/Ikar.Server/Infrastructure/Calls/WebRtcOptions.cs index f432fca..e1419f3 100644 --- a/src/Ikar.Server/Infrastructure/Calls/WebRtcOptions.cs +++ b/src/Ikar.Server/Infrastructure/Calls/WebRtcOptions.cs @@ -14,6 +14,10 @@ public sealed class WebRtcOptions } ]; + public int RingingTimeoutSeconds { get; set; } = 60; + + public int MaximumCallDurationHours { get; set; } = 8; + public IReadOnlyList<CallIceServerDto> ToDto() => IceServers .Where(server => server.Urls.Count > 0) diff --git a/src/Ikar.Server/Infrastructure/DtoMapper.cs b/src/Ikar.Server/Infrastructure/DtoMapper.cs index 521663e..d0038e5 100644 --- a/src/Ikar.Server/Infrastructure/DtoMapper.cs +++ b/src/Ikar.Server/Infrastructure/DtoMapper.cs @@ -130,7 +130,8 @@ public static class DtoMapper viewCount, BuildStoryReplyDto(message), message.DiscussionMessageId, - commentCount); + commentCount, + message.ClientMessageId); } public static AttachmentDto ToDto(this MessageAttachment attachment, Guid chatId) => diff --git a/src/Ikar.Server/Infrastructure/Push/PushDispatchBackgroundService.cs b/src/Ikar.Server/Infrastructure/Push/PushDispatchBackgroundService.cs index 9c3b71f..b4b9905 100644 --- a/src/Ikar.Server/Infrastructure/Push/PushDispatchBackgroundService.cs +++ b/src/Ikar.Server/Infrastructure/Push/PushDispatchBackgroundService.cs @@ -9,26 +9,52 @@ public sealed class PushDispatchBackgroundService( ILogger<PushDispatchBackgroundService> logger) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (!queue.IsDurable) + { + await ProcessFallbackQueueAsync(stoppingToken); + return; + } + + while (!stoppingToken.IsCancellationRequested) + { + var batch = await queue.LoadDueBatchAsync(stoppingToken); + if (batch.Count == 0) + { + await queue.WaitForWorkAsync(stoppingToken); + continue; + } + + foreach (var workItem in batch) + { + try + { + await ProcessAsync(workItem, stoppingToken); + await queue.CompleteAsync(workItem.OutboxId!.Value, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + logger.LogWarning( + ex, + "Background push dispatch failed for chat {ChatId}, message {MessageId}, kind {Kind}.", + workItem.ChatId, + workItem.MessageId, + workItem.Kind); + await queue.RescheduleAsync(workItem.OutboxId!.Value, ex, stoppingToken); + } + } + } + } + + private async Task ProcessFallbackQueueAsync(CancellationToken stoppingToken) { await foreach (var workItem in queue.ReadAllAsync(stoppingToken)) { - try - { - await ProcessAsync(workItem, stoppingToken); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - break; - } - catch (Exception ex) - { - logger.LogWarning( - ex, - "Background push dispatch failed for chat {ChatId}, message {MessageId}, kind {Kind}.", - workItem.ChatId, - workItem.MessageId, - workItem.Kind); - } + await ProcessAsync(workItem, stoppingToken); } } @@ -43,7 +69,6 @@ public sealed class PushDispatchBackgroundService( .SingleOrDefaultAsync(x => x.Id == workItem.ChatId, cancellationToken); if (chat is null) { - logger.LogDebug("Skipping push dispatch because chat {ChatId} was not found.", workItem.ChatId); return; } @@ -51,15 +76,10 @@ public sealed class PushDispatchBackgroundService( .Include(x => x.Sender) .Include(x => x.Attachments) .SingleOrDefaultAsync( - x => x.Id == workItem.MessageId && - x.ChatId == workItem.ChatId, + x => x.Id == workItem.MessageId && x.ChatId == workItem.ChatId, cancellationToken); if (message is null) { - logger.LogDebug( - "Skipping push dispatch because message {MessageId} for chat {ChatId} was not found.", - workItem.MessageId, - workItem.ChatId); return; } @@ -67,9 +87,6 @@ public sealed class PushDispatchBackgroundService( { if (workItem.ActorUserId is null || string.IsNullOrWhiteSpace(workItem.ReactionEmoji)) { - logger.LogDebug( - "Skipping reaction push dispatch for message {MessageId} because actor or emoji is missing.", - workItem.MessageId); return; } @@ -77,9 +94,6 @@ public sealed class PushDispatchBackgroundService( .SingleOrDefaultAsync(x => x.Id == workItem.ActorUserId.Value, cancellationToken); if (reactor is null) { - logger.LogDebug( - "Skipping reaction push dispatch because reactor {UserId} was not found.", - workItem.ActorUserId.Value); return; } diff --git a/src/Ikar.Server/Infrastructure/Push/PushDispatchQueue.cs b/src/Ikar.Server/Infrastructure/Push/PushDispatchQueue.cs index 1dc286e..6399afd 100644 --- a/src/Ikar.Server/Infrastructure/Push/PushDispatchQueue.cs +++ b/src/Ikar.Server/Infrastructure/Push/PushDispatchQueue.cs @@ -1,4 +1,7 @@ using System.Threading.Channels; +using Ikar.Server.Data; +using Ikar.Server.Data.Entities; +using Microsoft.EntityFrameworkCore; namespace Ikar.Server.Infrastructure.Push; @@ -13,19 +16,72 @@ public sealed record PushDispatchWorkItem( Guid MessageId, PushDispatchKind Kind = PushDispatchKind.ChatMessage, Guid? ActorUserId = null, - string? ReactionEmoji = null); + string? ReactionEmoji = null, + long? OutboxId = null); -public sealed class PushDispatchQueue +public sealed class PushDispatchQueue(IServiceScopeFactory? serviceScopeFactory = null) { - private readonly Channel<PushDispatchWorkItem> _channel = Channel.CreateUnbounded<PushDispatchWorkItem>( - new UnboundedChannelOptions + private const int BatchSize = 50; + private readonly Channel<PushDispatchWorkItem> _fallbackChannel = + Channel.CreateUnbounded<PushDispatchWorkItem>(new UnboundedChannelOptions { SingleReader = true, SingleWriter = false }); + private readonly Channel<bool> _wakeSignals = Channel.CreateBounded<bool>( + new BoundedChannelOptions(1) + { + SingleReader = true, + SingleWriter = false, + FullMode = BoundedChannelFullMode.DropOldest + }); + + public bool IsDurable => serviceScopeFactory is not null; + + public void StageMessages(IkarDbContext dbContext, params Message?[] messages) + { + if (!IsDurable) + { + return; + } + + foreach (var message in messages.Where(message => message is not null).Cast<Message>()) + { + StageWorkItem( + dbContext, + new PushDispatchWorkItem(message.ChatId, message.Id), + $"message:{message.Id:D}"); + } + } + + public void StageReaction( + IkarDbContext dbContext, + Guid chatId, + Guid messageId, + Guid reactorUserId, + string emoji) + { + if (!IsDurable) + { + return; + } + + StageWorkItem( + dbContext, + new PushDispatchWorkItem( + chatId, + messageId, + PushDispatchKind.MessageReaction, + reactorUserId, + emoji), + $"reaction:{messageId:D}:{reactorUserId:D}:{emoji}"); + } public ValueTask EnqueueAsync(Guid chatId, Guid messageId, CancellationToken cancellationToken = default) => - _channel.Writer.WriteAsync(new PushDispatchWorkItem(chatId, messageId), cancellationToken); + EnqueueWorkItemAsync( + new PushDispatchWorkItem(chatId, messageId), + $"message:{messageId:D}", + cancellationToken); public ValueTask EnqueueReactionAsync( Guid chatId, @@ -33,15 +89,166 @@ public sealed class PushDispatchQueue Guid reactorUserId, string emoji, CancellationToken cancellationToken = default) => - _channel.Writer.WriteAsync( + EnqueueWorkItemAsync( new PushDispatchWorkItem( chatId, messageId, PushDispatchKind.MessageReaction, reactorUserId, emoji), + $"reaction:{messageId:D}:{reactorUserId:D}:{emoji}", cancellationToken); public IAsyncEnumerable<PushDispatchWorkItem> ReadAllAsync(CancellationToken cancellationToken) => - _channel.Reader.ReadAllAsync(cancellationToken); + _fallbackChannel.Reader.ReadAllAsync(cancellationToken); + + public async Task<IReadOnlyList<PushDispatchWorkItem>> LoadDueBatchAsync(CancellationToken cancellationToken) + { + if (serviceScopeFactory is null) + { + return []; + } + + await using var scope = serviceScopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService<IkarDbContext>(); + var now = DateTimeOffset.UtcNow; + return await dbContext.PushDispatchOutbox + .AsNoTracking() + .Where(item => item.NextAttemptAt <= now) + .OrderBy(item => item.Id) + .Take(BatchSize) + .Select(item => new PushDispatchWorkItem( + item.ChatId, + item.MessageId, + (PushDispatchKind)item.Kind, + item.ActorUserId, + item.ReactionEmoji, + item.Id)) + .ToListAsync(cancellationToken); + } + + public async Task CompleteAsync(long outboxId, CancellationToken cancellationToken) + { + if (serviceScopeFactory is null) + { + return; + } + + await using var scope = serviceScopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService<IkarDbContext>(); + await dbContext.PushDispatchOutbox + .Where(item => item.Id == outboxId) + .ExecuteDeleteAsync(cancellationToken); + } + + public async Task RescheduleAsync(long outboxId, Exception error, CancellationToken cancellationToken) + { + if (serviceScopeFactory is null) + { + return; + } + + await using var scope = serviceScopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService<IkarDbContext>(); + var item = await dbContext.PushDispatchOutbox.SingleOrDefaultAsync(x => x.Id == outboxId, cancellationToken); + if (item is null) + { + return; + } + + item.AttemptCount++; + var delaySeconds = Math.Min(900, 5 * (1 << Math.Min(item.AttemptCount, 8))); + item.NextAttemptAt = DateTimeOffset.UtcNow.AddSeconds(delaySeconds); + item.LastError = error.Message[..Math.Min(error.Message.Length, 1000)]; + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task<int> CountPendingAsync(CancellationToken cancellationToken = default) + { + if (serviceScopeFactory is null) + { + return 0; + } + + await using var scope = serviceScopeFactory.CreateAsyncScope(); + return await scope.ServiceProvider.GetRequiredService<IkarDbContext>() + .PushDispatchOutbox.CountAsync(cancellationToken); + } + + public async Task WaitForWorkAsync(CancellationToken cancellationToken) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(5)); + try + { + await _wakeSignals.Reader.ReadAsync(timeout.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + } + } + + private async ValueTask EnqueueWorkItemAsync( + PushDispatchWorkItem workItem, + string deduplicationKey, + CancellationToken cancellationToken) + { + if (serviceScopeFactory is null) + { + await _fallbackChannel.Writer.WriteAsync(workItem, cancellationToken); + return; + } + + await using var scope = serviceScopeFactory.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService<IkarDbContext>(); + dbContext.PushDispatchOutbox.Add(new PushDispatchOutboxItem + { + DeduplicationKey = deduplicationKey, + ChatId = workItem.ChatId, + MessageId = workItem.MessageId, + Kind = (int)workItem.Kind, + ActorUserId = workItem.ActorUserId, + ReactionEmoji = workItem.ReactionEmoji + }); + try + { + await dbContext.SaveChangesAsync(cancellationToken); + } + catch (DbUpdateException) + { + dbContext.ChangeTracker.Clear(); + if (await dbContext.PushDispatchOutbox + .AsNoTracking() + .AnyAsync(item => item.DeduplicationKey == deduplicationKey, cancellationToken)) + { + _wakeSignals.Writer.TryWrite(true); + return; + } + + throw; + } + + _wakeSignals.Writer.TryWrite(true); + } + + private static void StageWorkItem( + IkarDbContext dbContext, + PushDispatchWorkItem workItem, + string deduplicationKey) + { + if (dbContext.PushDispatchOutbox.Local.Any(item => item.DeduplicationKey == deduplicationKey)) + { + return; + } + + dbContext.PushDispatchOutbox.Add(new PushDispatchOutboxItem + { + DeduplicationKey = deduplicationKey, + ChatId = workItem.ChatId, + MessageId = workItem.MessageId, + Kind = (int)workItem.Kind, + ActorUserId = workItem.ActorUserId, + ReactionEmoji = workItem.ReactionEmoji + }); + } } diff --git a/src/Ikar.Server/Infrastructure/Push/PushNotificationDispatcher.cs b/src/Ikar.Server/Infrastructure/Push/PushNotificationDispatcher.cs index b21a614..b5437fe 100644 --- a/src/Ikar.Server/Infrastructure/Push/PushNotificationDispatcher.cs +++ b/src/Ikar.Server/Infrastructure/Push/PushNotificationDispatcher.cs @@ -67,6 +67,7 @@ public sealed class PushNotificationDispatcher( catch (Exception ex) { logger.LogWarning(ex, "Push dispatch failed for message {MessageId}.", message.Id); + throw; } } @@ -123,6 +124,7 @@ public sealed class PushNotificationDispatcher( ex, "Push dispatch failed for reaction to message {MessageId}.", message.Id); + throw; } } diff --git a/src/Ikar.Server/Infrastructure/Storage/AttachmentContentInspector.cs b/src/Ikar.Server/Infrastructure/Storage/AttachmentContentInspector.cs new file mode 100644 index 0000000..7ffa835 --- /dev/null +++ b/src/Ikar.Server/Infrastructure/Storage/AttachmentContentInspector.cs @@ -0,0 +1,30 @@ +namespace Ikar.Server.Infrastructure.Storage; + +public sealed record AttachmentContentInfo(string ContentType, bool IsImage, bool IsVideo); + +public static class AttachmentContentInspector +{ + public static AttachmentContentInfo Inspect(IFormFile file) + { + Span<byte> header = stackalloc byte[16]; + using var stream = file.OpenReadStream(); + var read = stream.Read(header); + var bytes = header[..read]; + + if (StartsWith(bytes, [0xFF, 0xD8, 0xFF])) return new("image/jpeg", true, false); + if (StartsWith(bytes, [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])) return new("image/png", true, false); + if (StartsWith(bytes, "GIF87a"u8) || StartsWith(bytes, "GIF89a"u8)) return new("image/gif", true, false); + if (StartsWith(bytes, "BM"u8)) return new("image/bmp", true, false); + if (read >= 12 && StartsWith(bytes, "RIFF"u8) && bytes[8..12].SequenceEqual("WEBP"u8)) return new("image/webp", true, false); + if (read >= 8 && bytes[4..8].SequenceEqual("ftyp"u8)) return new("video/mp4", false, true); + if (StartsWith(bytes, [0x1A, 0x45, 0xDF, 0xA3])) return new("video/webm", false, true); + if (StartsWith(bytes, "OggS"u8)) return new("audio/ogg", false, false); + if (StartsWith(bytes, "%PDF-"u8)) return new("application/pdf", false, false); + if (StartsWith(bytes, [0x50, 0x4B, 0x03, 0x04])) return new("application/zip", false, false); + + return new("application/octet-stream", false, false); + } + + private static bool StartsWith(ReadOnlySpan<byte> bytes, ReadOnlySpan<byte> signature) => + bytes.Length >= signature.Length && bytes[..signature.Length].SequenceEqual(signature); +} diff --git a/src/Ikar.Server/Infrastructure/Storage/AttachmentStorageService.cs b/src/Ikar.Server/Infrastructure/Storage/AttachmentStorageService.cs index 24fd40a..d425ea0 100644 --- a/src/Ikar.Server/Infrastructure/Storage/AttachmentStorageService.cs +++ b/src/Ikar.Server/Infrastructure/Storage/AttachmentStorageService.cs @@ -18,6 +18,7 @@ public sealed class AttachmentStorageService(IWebHostEnvironment environment) } var originalFileName = NormalizeOriginalFileName(file.FileName, "attachment"); + var contentInfo = AttachmentContentInspector.Inspect(file); var (attachmentId, physicalPath) = CreateTargetPath(originalFileName); await using (var source = file.OpenReadStream()) @@ -31,7 +32,7 @@ public sealed class AttachmentStorageService(IWebHostEnvironment environment) Id = attachmentId, MessageId = messageId, OriginalFileName = originalFileName, - ContentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType, + ContentType = contentInfo.ContentType, FileSizeBytes = file.Length, StoragePath = physicalPath, SortOrder = sortOrder, @@ -51,6 +52,7 @@ public sealed class AttachmentStorageService(IWebHostEnvironment environment) } var originalFileName = NormalizeOriginalFileName(file.FileName, "story-attachment"); + var contentInfo = AttachmentContentInspector.Inspect(file); var (attachmentId, physicalPath) = CreateTargetPath(originalFileName); await using (var source = file.OpenReadStream()) @@ -64,7 +66,7 @@ public sealed class AttachmentStorageService(IWebHostEnvironment environment) Id = attachmentId, StoryId = storyId, OriginalFileName = originalFileName, - ContentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType, + ContentType = contentInfo.ContentType, FileSizeBytes = file.Length, StoragePath = physicalPath, SortOrder = sortOrder, diff --git a/src/Ikar.Server/Infrastructure/Storage/DatabaseSchemaBootstrapper.cs b/src/Ikar.Server/Infrastructure/Storage/DatabaseSchemaBootstrapper.cs index e7b41d9..25646a7 100644 --- a/src/Ikar.Server/Infrastructure/Storage/DatabaseSchemaBootstrapper.cs +++ b/src/Ikar.Server/Infrastructure/Storage/DatabaseSchemaBootstrapper.cs @@ -11,6 +11,7 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext) await EnableWriteAheadLoggingAsync(cancellationToken); await EnsureMessageAttachmentsTableAsync(cancellationToken); await EnsurePushDevicesTableAsync(cancellationToken); + await EnsurePushDispatchOutboxTableAsync(cancellationToken); await EnsureUserPrivacySettingsTableAsync(cancellationToken); await EnsureChatInviteLinksTableAsync(cancellationToken); await EnsureChatJoinRequestsTableAsync(cancellationToken); @@ -67,6 +68,8 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext) await EnsureColumnAsync("Messages", "StoryReplyAttachmentKind", "TEXT NULL", cancellationToken); await EnsureColumnAsync("Messages", "DiscussionMessageId", "TEXT NULL", cancellationToken); await EnsureColumnAsync("Messages", "CommentCount", "INTEGER NOT NULL DEFAULT 0", cancellationToken); + await EnsureColumnAsync("Messages", "ClientMessageId", "TEXT NULL", cancellationToken); + await EnsureMessagesClientMessageIdIndexAsync(cancellationToken); await EnsureLegacyChannelPostsMarkedAsync(cancellationToken); await EnsureColumnAsync("MessageAttachments", "SortOrder", "INTEGER NOT NULL DEFAULT 0", cancellationToken); await EnsureColumnAsync("MessageAttachments", "Kind", "INTEGER NOT NULL DEFAULT 1", cancellationToken); @@ -729,6 +732,56 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext) cancellationToken); } + private async Task EnsurePushDispatchOutboxTableAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE TABLE IF NOT EXISTS "PushDispatchOutbox" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_PushDispatchOutbox" PRIMARY KEY AUTOINCREMENT, + "DeduplicationKey" TEXT NOT NULL, + "ChatId" TEXT NOT NULL, + "MessageId" TEXT NOT NULL, + "Kind" INTEGER NOT NULL, + "ActorUserId" TEXT NULL, + "ReactionEmoji" TEXT NULL, + "AttemptCount" INTEGER NOT NULL DEFAULT 0, + "NextAttemptAt" TEXT NOT NULL, + "CreatedAt" TEXT NOT NULL, + "LastError" TEXT NULL + ); + """, + cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_PushDispatchOutbox_DeduplicationKey" + ON "PushDispatchOutbox" ("DeduplicationKey"); + """, + cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_PushDispatchOutbox_NextAttemptAt_Id" + ON "PushDispatchOutbox" ("NextAttemptAt", "Id"); + """, + cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE INDEX IF NOT EXISTS "IX_PushDispatchOutbox_MessageId_Kind" + ON "PushDispatchOutbox" ("MessageId", "Kind"); + """, + cancellationToken); + } + + private async Task EnsureMessagesClientMessageIdIndexAsync(CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync( + """ + CREATE UNIQUE INDEX IF NOT EXISTS "IX_Messages_SenderId_ClientMessageId" + ON "Messages" ("SenderId", "ClientMessageId") + WHERE "ClientMessageId" IS NOT NULL; + """, + cancellationToken); + } + private async Task EnsureColumnAsync( string tableName, string columnName, diff --git a/src/Ikar.Server/Program.cs b/src/Ikar.Server/Program.cs index 99f7023..2a74c0a 100644 --- a/src/Ikar.Server/Program.cs +++ b/src/Ikar.Server/Program.cs @@ -3,6 +3,7 @@ using System.Security.Claims; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Ikar.Server.Data; @@ -35,8 +36,15 @@ builder.Services.AddEndpointsApiExplorer(); builder.Services.AddHttpClient(); builder.Services.AddDbContext<IkarDbContext>(options => - options.UseSqlite(builder.Configuration.GetConnectionString("IkarDb") ?? - "Data Source=Data/ikar.db")); +{ + var configuredConnectionString = builder.Configuration.GetConnectionString("IkarDb") ?? + "Data Source=Data/ikar.db"; + var connectionString = new SqliteConnectionStringBuilder(configuredConnectionString) + { + DefaultTimeout = 30 + }.ToString(); + options.UseSqlite(connectionString, sqliteOptions => sqliteOptions.CommandTimeout(30)); +}); builder.Services.Configure<ForwardedHeadersOptions>(options => { @@ -165,10 +173,10 @@ builder.Services.AddAuthorization(options => }); builder.Services.AddSingleton<PresenceTracker>(); builder.Services.AddSingleton<CallSessionStore>(); +builder.Services.AddHostedService<CallSessionCleanupService>(); builder.Services.AddSingleton(new ServerRuntimeState(DateTimeOffset.UtcNow)); builder.Services.AddScoped<TokenService>(); builder.Services.AddSingleton<BotTokenService>(); -builder.Services.AddSingleton<PhoneAuthChallengeStore>(); builder.Services.AddSingleton<EmailAuthChallengeStore>(); builder.Services.AddScoped<IEmailCodeSender, SmtpEmailCodeSender>(); builder.Services.AddScoped<DatabaseSeeder>(); @@ -228,6 +236,13 @@ app.Use(async (context, next) => app.UseForwardedHeaders(); app.UseStaticFiles(); app.Use(async (context, next) => +{ + context.Response.Headers["X-Content-Type-Options"] = "nosniff"; + context.Response.Headers["Referrer-Policy"] = "no-referrer"; + context.Response.Headers["X-Frame-Options"] = "DENY"; + await next(); +}); +app.Use(async (context, next) => { if (!ShouldEnforceAndroidClientVersion(context.Request)) { @@ -269,17 +284,62 @@ app.UseMiddleware<ApiRateLimitMiddleware>(); app.UseAuthorization(); app.MapControllers(); -app.MapGet("/health", async (IkarDbContext dbContext, CancellationToken cancellationToken) => +app.MapGet("/health", async ( + IkarDbContext dbContext, + IWebHostEnvironment environment, + IConfiguration configuration, + PushDispatchQueue pushQueue, + CancellationToken cancellationToken) => { var canConnect = await dbContext.Database.CanConnectAsync(cancellationToken); - return canConnect - ? Results.Ok(new { status = "ok" }) - : Results.StatusCode(StatusCodes.Status503ServiceUnavailable); + var dataPath = Path.Combine(environment.ContentRootPath, "Data"); + var freeDiskBytes = ResolveAvailableDiskSpace(dataPath); + var diskHealthy = freeDiskBytes >= 512L * 1024 * 1024; + var pendingPushCount = await pushQueue.CountPendingAsync(cancellationToken); + + var backupDirectory = configuration["Backup:Directory"]; + DateTimeOffset? latestBackupAt = null; + if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory)) + { + latestBackupAt = Directory.EnumerateFiles(backupDirectory, "ikar-*.tar.gz") + .Select(path => new DateTimeOffset(File.GetLastWriteTimeUtc(path), TimeSpan.Zero)) + .OrderByDescending(value => value) + .FirstOrDefault(); + } + + var backupRequired = configuration.GetValue("Backup:RequireFreshBackup", false); + var backupHealthy = !backupRequired || + latestBackupAt is not null && latestBackupAt >= DateTimeOffset.UtcNow.AddHours(-36); + var healthy = canConnect && diskHealthy && backupHealthy; + var payload = new + { + status = healthy ? "ok" : "degraded", + database = canConnect ? "ok" : "unavailable", + disk = diskHealthy ? "ok" : "low", + freeDiskBytes, + backup = backupHealthy ? "ok" : "stale", + latestBackupAt, + pendingPushCount + }; + return Results.Json( + payload, + statusCode: healthy ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable); }); app.MapHub<MessengerHub>("/hubs/messenger"); app.Run(); +static long ResolveAvailableDiskSpace(string path) +{ + var fullPath = Path.GetFullPath(path); + var drive = DriveInfo.GetDrives() + .Where(candidate => fullPath.StartsWith(candidate.Name, StringComparison.Ordinal)) + .OrderByDescending(candidate => candidate.Name.Length) + .FirstOrDefault(); + + return drive?.AvailableFreeSpace ?? new DriveInfo(Path.GetPathRoot(fullPath)!).AvailableFreeSpace; +} + static bool ShouldEnforceAndroidClientVersion(HttpRequest request) { if (request.Path.StartsWithSegments("/health", StringComparison.OrdinalIgnoreCase) || diff --git a/src/Ikar.Server/appsettings.json b/src/Ikar.Server/appsettings.json index 7af62ed..cd2d6f9 100644 --- a/src/Ikar.Server/appsettings.json +++ b/src/Ikar.Server/appsettings.json @@ -15,6 +15,8 @@ "ServiceAccountJson": "" }, "WebRtc": { + "RingingTimeoutSeconds": 60, + "MaximumCallDurationHours": 8, "IceServers": [ { "Urls": [ diff --git a/src/Ikar.Shared/MessageContracts.cs b/src/Ikar.Shared/MessageContracts.cs index b5e4b67..163c42b 100644 --- a/src/Ikar.Shared/MessageContracts.cs +++ b/src/Ikar.Shared/MessageContracts.cs @@ -58,7 +58,8 @@ public sealed record MessageDto( int? ViewCount = null, StoryReplyDto? StoryReply = null, Guid? DiscussionMessageId = null, - int? CommentCount = null); + int? CommentCount = null, + Guid? ClientMessageId = null); public sealed record SendMessageRequest( string Text, @@ -66,7 +67,8 @@ public sealed record SendMessageRequest( Guid? TopicId = null, bool? HasProtectedContent = null, int? TtlSeconds = null, - Guid? StoryReplyStoryId = null); + Guid? StoryReplyStoryId = null, + Guid? ClientMessageId = null); public sealed record UpdateMessageRequest(string Text);