Improve IKAR reliability, security, and Android UX
This commit is contained in:
+8
-1
@@ -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
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
<Folder Name="/src/">
|
<Folder Name="/src/">
|
||||||
<Project Path="src/Ikar.Client/Ikar.Client.csproj" />
|
<Project Path="src/Ikar.Server.Tests/Ikar.Server.Tests.csproj" />
|
||||||
<Project Path="src/Ikar.CodexBridge/Ikar.CodexBridge.csproj" />
|
|
||||||
<Project Path="src/Ikar.Server/Ikar.Server.csproj" />
|
<Project Path="src/Ikar.Server/Ikar.Server.csproj" />
|
||||||
<Project Path="src/Ikar.Shared/Ikar.Shared.csproj" />
|
<Project Path="src/Ikar.Shared/Ikar.Shared.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
|||||||
+14
-59
@@ -9,13 +9,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ikar.Shared", "src\Ikar.Sha
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ikar.Server", "src\Ikar.Server\Ikar.Server.csproj", "{631B782D-44B2-4680-BC8C-1A0A948A1945}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ikar.Server", "src\Ikar.Server\Ikar.Server.csproj", "{631B782D-44B2-4680-BC8C-1A0A948A1945}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ikar.Client", "src\Ikar.Client\Ikar.Client.csproj", "{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ikar.Server.Tests", "src\Ikar.Server.Tests\Ikar.Server.Tests.csproj", "{94F7D186-7CAA-4840-9C64-AC4792A1A6C0}"
|
||||||
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}"
|
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
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|x64.Build.0 = Release|Any CPU
|
||||||
{631B782D-44B2-4680-BC8C-1A0A948A1945}.Release|x86.ActiveCfg = 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
|
{631B782D-44B2-4680-BC8C-1A0A948A1945}.Release|x86.Build.0 = Release|Any CPU
|
||||||
{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|x64.ActiveCfg = Debug|Any CPU
|
{94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||||
{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|x64.Build.0 = Debug|Any CPU
|
{94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||||
{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|x86.ActiveCfg = Debug|Any CPU
|
{94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||||
{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|x86.Build.0 = Debug|Any CPU
|
{94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||||
{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|Any CPU.Build.0 = Release|Any CPU
|
{94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|x64.ActiveCfg = Release|Any CPU
|
{94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||||
{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|x64.Build.0 = Release|Any CPU
|
{94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Release|x64.Build.0 = Release|Any CPU
|
||||||
{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|x86.ActiveCfg = Release|Any CPU
|
{94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||||
{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|x86.Build.0 = Release|Any CPU
|
{94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.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
|
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -106,9 +64,6 @@ Global
|
|||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{2FBB67AD-D250-4C69-AEA9-D0F7A18D623B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
{2FBB67AD-D250-4C69-AEA9-D0F7A18D623B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||||
{631B782D-44B2-4680-BC8C-1A0A948A1945} = {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}
|
{94F7D186-7CAA-4840-9C64-AC4792A1A6C0} = {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}
|
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
|||||||
@@ -7,9 +7,57 @@ services:
|
|||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.server
|
dockerfile: Dockerfile.server
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
stop_grace_period: 30s
|
||||||
|
user: "${IKAR_UID:-1000}:${IKAR_GID:-1000}"
|
||||||
env_file:
|
env_file:
|
||||||
- .env.server
|
- .env.server
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:5099:5099"
|
- "127.0.0.1:5099:5099"
|
||||||
volumes:
|
volumes:
|
||||||
- ${IKAR_DATA_PATH:?IKAR_DATA_PATH must be set in .env}:/app/Data
|
- ${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
|
||||||
|
|||||||
@@ -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}"
|
||||||
@@ -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 <backup.tar.gz> --verify-only" >&2
|
||||||
|
echo " or: $0 <backup.tar.gz> <target-data-dir> --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."
|
||||||
@@ -37,8 +37,8 @@ android {
|
|||||||
applicationId = "com.seven.ikar.kotlin"
|
applicationId = "com.seven.ikar.kotlin"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 209
|
versionCode = 211
|
||||||
versionName = "3.96"
|
versionName = "3.98"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables.useSupportLibrary = true
|
vectorDrawables.useSupportLibrary = true
|
||||||
@@ -57,7 +57,8 @@ android {
|
|||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
"proguard-rules.pro"
|
"proguard-rules.pro"
|
||||||
@@ -123,4 +124,5 @@ dependencies {
|
|||||||
implementation("androidx.compose.material:material-icons-extended")
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-1
@@ -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 <init>(...); }
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|||||||
@@ -16,9 +16,10 @@
|
|||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".app.IkarNativeApp"
|
android:name=".app.IkarNativeApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="false"
|
||||||
|
android:fullBackupContent="false"
|
||||||
android:icon="@drawable/appicon"
|
android:icon="@drawable/appicon"
|
||||||
android:label="Икар"
|
android:label="@string/app_name"
|
||||||
android:roundIcon="@drawable/appicon"
|
android:roundIcon="@drawable/appicon"
|
||||||
android:supportsRtl="true"
|
android:supportsRtl="true"
|
||||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ class AppContainer(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private val loggingInterceptor = HttpLoggingInterceptor().apply {
|
private val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||||
level = HttpLoggingInterceptor.Level.BASIC
|
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC else HttpLoggingInterceptor.Level.NONE
|
||||||
}
|
}
|
||||||
|
|
||||||
val pushSettings = AndroidPushSettings(appContext)
|
val pushSettings = AndroidPushSettings(appContext)
|
||||||
|
|||||||
+8
-2
@@ -9,7 +9,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
|||||||
|
|
||||||
@Database(
|
@Database(
|
||||||
entities = [CachedChatMessageEntity::class, CachedChatEntity::class, TransferQueueEntity::class],
|
entities = [CachedChatMessageEntity::class, CachedChatEntity::class, TransferQueueEntity::class],
|
||||||
version = 4,
|
version = 5,
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
abstract class IkarLocalDatabase : RoomDatabase() {
|
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 =
|
fun getInstance(context: Context): IkarLocalDatabase =
|
||||||
instance ?: synchronized(this) {
|
instance ?: synchronized(this) {
|
||||||
instance ?: Room.databaseBuilder(
|
instance ?: Room.databaseBuilder(
|
||||||
@@ -86,7 +92,7 @@ abstract class IkarLocalDatabase : RoomDatabase() {
|
|||||||
IkarLocalDatabase::class.java,
|
IkarLocalDatabase::class.java,
|
||||||
"ikar-local.db"
|
"ikar-local.db"
|
||||||
)
|
)
|
||||||
.addMigrations(Migration1To2, Migration2To3, Migration3To4)
|
.addMigrations(Migration1To2, Migration2To3, Migration3To4, Migration4To5)
|
||||||
.build()
|
.build()
|
||||||
.also { instance = it }
|
.also { instance = it }
|
||||||
}
|
}
|
||||||
|
|||||||
+8
@@ -17,6 +17,7 @@ data class TransferQueueEntity(
|
|||||||
val chatId: String?,
|
val chatId: String?,
|
||||||
val text: String?,
|
val text: String?,
|
||||||
val replyToMessageId: String?,
|
val replyToMessageId: String?,
|
||||||
|
val clientMessageId: String?,
|
||||||
val attachmentId: String?,
|
val attachmentId: String?,
|
||||||
val storyVisibility: String?,
|
val storyVisibility: String?,
|
||||||
val hasProtectedContent: Boolean,
|
val hasProtectedContent: Boolean,
|
||||||
@@ -40,6 +41,12 @@ interface TransferQueueDao {
|
|||||||
@Query("SELECT * FROM transfer_queue WHERE groupId = :groupId ORDER BY sortOrder ASC")
|
@Query("SELECT * FROM transfer_queue WHERE groupId = :groupId ORDER BY sortOrder ASC")
|
||||||
suspend fun getGroup(groupId: String): List<TransferQueueEntity>
|
suspend fun getGroup(groupId: String): List<TransferQueueEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM transfer_queue WHERE groupId = :groupId ORDER BY sortOrder ASC")
|
||||||
|
fun observeGroup(groupId: String): Flow<List<TransferQueueEntity>>
|
||||||
|
|
||||||
|
@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")
|
@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<List<TransferQueueEntity>>
|
fun observeActiveForChat(chatId: String): Flow<List<TransferQueueEntity>>
|
||||||
|
|
||||||
@@ -60,6 +67,7 @@ interface TransferQueueDao {
|
|||||||
}
|
}
|
||||||
|
|
||||||
object TransferQueueTypes {
|
object TransferQueueTypes {
|
||||||
|
const val TextMessage = "text_message"
|
||||||
const val Upload = "upload"
|
const val Upload = "upload"
|
||||||
const val StoryUpload = "story_upload"
|
const val StoryUpload = "story_upload"
|
||||||
const val Download = "download"
|
const val Download = "download"
|
||||||
|
|||||||
+16
@@ -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)
|
||||||
|
}
|
||||||
@@ -252,7 +252,8 @@ data class MessageDto(
|
|||||||
val viewCount: Int? = null,
|
val viewCount: Int? = null,
|
||||||
val storyReply: StoryReplyDto? = null,
|
val storyReply: StoryReplyDto? = null,
|
||||||
val discussionMessageId: String? = null,
|
val discussionMessageId: String? = null,
|
||||||
val commentCount: Int? = null
|
val commentCount: Int? = null,
|
||||||
|
val clientMessageId: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -608,7 +609,8 @@ data class SendMessageRequest(
|
|||||||
val topicId: String? = null,
|
val topicId: String? = null,
|
||||||
val hasProtectedContent: Boolean? = null,
|
val hasProtectedContent: Boolean? = null,
|
||||||
val ttlSeconds: Int? = null,
|
val ttlSeconds: Int? = null,
|
||||||
val storyReplyStoryId: String? = null
|
val storyReplyStoryId: String? = null,
|
||||||
|
val clientMessageId: String? = null
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|||||||
+23
-5
@@ -648,7 +648,8 @@ class ApiClient(
|
|||||||
topicId: String? = null,
|
topicId: String? = null,
|
||||||
hasProtectedContent: Boolean? = null,
|
hasProtectedContent: Boolean? = null,
|
||||||
ttlSeconds: Int? = null,
|
ttlSeconds: Int? = null,
|
||||||
storyReplyStoryId: String? = null
|
storyReplyStoryId: String? = null,
|
||||||
|
clientMessageId: String? = null
|
||||||
): MessageDto =
|
): MessageDto =
|
||||||
executeJsonRequest(
|
executeJsonRequest(
|
||||||
Request.Builder()
|
Request.Builder()
|
||||||
@@ -662,7 +663,8 @@ class ApiClient(
|
|||||||
topicId = topicId,
|
topicId = topicId,
|
||||||
hasProtectedContent = hasProtectedContent,
|
hasProtectedContent = hasProtectedContent,
|
||||||
ttlSeconds = ttlSeconds,
|
ttlSeconds = ttlSeconds,
|
||||||
storyReplyStoryId = storyReplyStoryId
|
storyReplyStoryId = storyReplyStoryId,
|
||||||
|
clientMessageId = clientMessageId
|
||||||
)
|
)
|
||||||
).toRequestBody(JSON)
|
).toRequestBody(JSON)
|
||||||
)
|
)
|
||||||
@@ -886,10 +888,12 @@ class ApiClient(
|
|||||||
?: attachment.fileSizeBytes.takeIf { it >= 0L }
|
?: attachment.fileSizeBytes.takeIf { it >= 0L }
|
||||||
?: -1L
|
?: -1L
|
||||||
targetFile.parentFile?.mkdirs()
|
targetFile.parentFile?.mkdirs()
|
||||||
|
val partialFile = File(targetFile.parentFile ?: File("."), "${targetFile.name}.part")
|
||||||
|
partialFile.delete()
|
||||||
|
var bytesRead = 0L
|
||||||
body.byteStream().use { input ->
|
body.byteStream().use { input ->
|
||||||
targetFile.outputStream().use { output ->
|
partialFile.outputStream().use { output ->
|
||||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||||
var bytesRead = 0L
|
|
||||||
while (true) {
|
while (true) {
|
||||||
val read = input.read(buffer)
|
val read = input.read(buffer)
|
||||||
if (read < 0) {
|
if (read < 0) {
|
||||||
@@ -900,9 +904,21 @@ class ApiClient(
|
|||||||
onProgress?.invoke(bytesRead, totalBytes)
|
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,
|
fileName = attachment.fileName,
|
||||||
contentType = body.contentType()?.toString()
|
contentType = body.contentType()?.toString()
|
||||||
?: response.header("Content-Type")
|
?: response.header("Content-Type")
|
||||||
@@ -912,6 +928,8 @@ class ApiClient(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (error: IOException) {
|
} catch (error: IOException) {
|
||||||
|
val partialFile = File(targetFile.parentFile ?: File("."), "${targetFile.name}.part")
|
||||||
|
partialFile.delete()
|
||||||
throw ApiException(-1, networkErrorMessage())
|
throw ApiException(-1, networkErrorMessage())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
@@ -3,10 +3,12 @@ package com.seven.ikar.kotlin.core.push
|
|||||||
import android.content.BroadcastReceiver
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import androidx.core.app.RemoteInput
|
||||||
import com.seven.ikar.kotlin.app.IkarNativeApp
|
import com.seven.ikar.kotlin.app.IkarNativeApp
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
class NotificationActionReceiver : BroadcastReceiver() {
|
class NotificationActionReceiver : BroadcastReceiver() {
|
||||||
override fun onReceive(context: Context, intent: Intent) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
@@ -24,11 +26,42 @@ class NotificationActionReceiver : BroadcastReceiver() {
|
|||||||
val action = intent.action.orEmpty()
|
val action = intent.action.orEmpty()
|
||||||
when (action) {
|
when (action) {
|
||||||
NotificationAction.MarkReadAction -> handleMarkRead(context, intent)
|
NotificationAction.MarkReadAction -> handleMarkRead(context, intent)
|
||||||
|
NotificationAction.InlineReplyAction -> handleInlineReply(context, intent)
|
||||||
NotificationAction.AcceptIncomingCallAction -> handleAcceptIncomingCall(context, intent)
|
NotificationAction.AcceptIncomingCallAction -> handleAcceptIncomingCall(context, intent)
|
||||||
NotificationAction.DeclineIncomingCallAction -> handleDeclineIncomingCall(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) {
|
private suspend fun handleMarkRead(context: Context, intent: Intent) {
|
||||||
val chatId = intent.getStringExtra(PushDataKeys.ChatId).orEmpty()
|
val chatId = intent.getStringExtra(PushDataKeys.ChatId).orEmpty()
|
||||||
val messageId = intent.getStringExtra(PushDataKeys.MessageId).orEmpty()
|
val messageId = intent.getStringExtra(PushDataKeys.MessageId).orEmpty()
|
||||||
@@ -92,7 +125,9 @@ class NotificationActionReceiver : BroadcastReceiver() {
|
|||||||
|
|
||||||
object NotificationAction {
|
object NotificationAction {
|
||||||
const val MarkReadAction = "com.seven.ikar.kotlin.NOTIFICATION_MARK_READ"
|
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 AcceptIncomingCallAction = "com.seven.ikar.kotlin.NOTIFICATION_ACCEPT_INCOMING_CALL"
|
||||||
const val DeclineIncomingCallAction = "com.seven.ikar.kotlin.NOTIFICATION_DECLINE_INCOMING_CALL"
|
const val DeclineIncomingCallAction = "com.seven.ikar.kotlin.NOTIFICATION_DECLINE_INCOMING_CALL"
|
||||||
const val NotificationIdExtra = "notificationId"
|
const val NotificationIdExtra = "notificationId"
|
||||||
|
const val InlineReplyResultKey = "inlineReplyText"
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-4
@@ -11,6 +11,7 @@ import android.os.Vibrator
|
|||||||
import android.os.VibratorManager
|
import android.os.VibratorManager
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
import androidx.core.app.NotificationManagerCompat
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import androidx.core.app.RemoteInput
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.seven.ikar.kotlin.BuildConfig
|
import com.seven.ikar.kotlin.BuildConfig
|
||||||
import com.seven.ikar.kotlin.MainActivity
|
import com.seven.ikar.kotlin.MainActivity
|
||||||
@@ -218,11 +219,20 @@ object PushNotificationDisplayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val pendingIntent = PendingIntent.getActivity(context, notificationId, intent, flags)
|
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,
|
context,
|
||||||
notificationId + 1,
|
notificationId + 1,
|
||||||
intent,
|
Intent(context, NotificationActionReceiver::class.java).apply {
|
||||||
flags
|
action = NotificationAction.InlineReplyAction
|
||||||
|
putExtra(PushDataKeys.ChatId, data[PushDataKeys.ChatId])
|
||||||
|
putExtra(NotificationAction.NotificationIdExtra, notificationId)
|
||||||
|
},
|
||||||
|
replyFlags
|
||||||
)
|
)
|
||||||
val markReadPendingIntent = PendingIntent.getBroadcast(
|
val markReadPendingIntent = PendingIntent.getBroadcast(
|
||||||
context,
|
context,
|
||||||
@@ -292,8 +302,19 @@ object PushNotificationDisplayService {
|
|||||||
!data[PushDataKeys.ChatId].isNullOrBlank() &&
|
!data[PushDataKeys.ChatId].isNullOrBlank() &&
|
||||||
!data[PushDataKeys.MessageId].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
|
notificationBuilder
|
||||||
.addAction(android.R.drawable.ic_menu_send, "Ответить", replyPendingIntent)
|
.addAction(replyAction)
|
||||||
.addAction(android.R.drawable.ic_menu_view, "Отметить как прочитанное", markReadPendingIntent)
|
.addAction(android.R.drawable.ic_menu_view, "Отметить как прочитанное", markReadPendingIntent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
@@ -53,6 +53,9 @@ class RealtimeService(
|
|||||||
private val _stage = MutableStateFlow(RealtimeConnectionStage.Disconnected)
|
private val _stage = MutableStateFlow(RealtimeConnectionStage.Disconnected)
|
||||||
val stage: StateFlow<RealtimeConnectionStage> = _stage
|
val stage: StateFlow<RealtimeConnectionStage> = _stage
|
||||||
|
|
||||||
|
private val _connectionRestored = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||||
|
val connectionRestored: SharedFlow<Unit> = _connectionRestored
|
||||||
|
|
||||||
private val _messagesCreated = MutableSharedFlow<MessageDto>(extraBufferCapacity = 32)
|
private val _messagesCreated = MutableSharedFlow<MessageDto>(extraBufferCapacity = 32)
|
||||||
val messagesCreated: SharedFlow<MessageDto> = _messagesCreated
|
val messagesCreated: SharedFlow<MessageDto> = _messagesCreated
|
||||||
|
|
||||||
@@ -209,6 +212,7 @@ class RealtimeService(
|
|||||||
newConnection.start().blockingAwait()
|
newConnection.start().blockingAwait()
|
||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
_stage.value = RealtimeConnectionStage.Connected
|
_stage.value = RealtimeConnectionStage.Connected
|
||||||
|
_connectionRestored.tryEmit(Unit)
|
||||||
}.onFailure {
|
}.onFailure {
|
||||||
if (connection == newConnection) {
|
if (connection == newConnection) {
|
||||||
connection = null
|
connection = null
|
||||||
|
|||||||
+61
@@ -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 = ":"
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
-6
@@ -3,28 +3,53 @@ package com.seven.ikar.kotlin.core.session
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.SharedPreferences
|
import android.content.SharedPreferences
|
||||||
import com.seven.ikar.kotlin.core.model.AuthSessionDto
|
import com.seven.ikar.kotlin.core.model.AuthSessionDto
|
||||||
|
import com.seven.ikar.kotlin.core.security.AndroidKeystoreCipher
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
|
||||||
class SessionStore(context: Context, private val json: Json) {
|
class SessionStore(context: Context, private val json: Json) {
|
||||||
private val preferences: SharedPreferences =
|
private val preferences: SharedPreferences =
|
||||||
context.getSharedPreferences("ikar_kotlin_session", Context.MODE_PRIVATE)
|
context.getSharedPreferences("ikar_kotlin_session", Context.MODE_PRIVATE)
|
||||||
|
private val cipher = AndroidKeystoreCipher("ikar.session.v1")
|
||||||
|
|
||||||
fun read(): AuthSessionDto? {
|
fun read(): AuthSessionDto? {
|
||||||
val raw = preferences.getString(KEY_SESSION, null) ?: return null
|
val encrypted = preferences.getString(KEY_ENCRYPTED_SESSION, null)
|
||||||
return runCatching { json.decodeFromString<AuthSessionDto>(raw) }.getOrNull()
|
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) {
|
fun save(session: AuthSessionDto) {
|
||||||
|
val encrypted = cipher.encrypt(json.encodeToString(AuthSessionDto.serializer(), session))
|
||||||
preferences.edit()
|
preferences.edit()
|
||||||
.putString(KEY_SESSION, json.encodeToString(AuthSessionDto.serializer(), session))
|
.putString(KEY_ENCRYPTED_SESSION, encrypted)
|
||||||
.apply()
|
.remove(KEY_LEGACY_SESSION)
|
||||||
|
.commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear() {
|
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<AuthSessionDto>(raw) }.getOrNull()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val KEY_SESSION = "session"
|
private const val KEY_ENCRYPTED_SESSION = "encrypted_session"
|
||||||
|
private const val KEY_LEGACY_SESSION = "session"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+25
-2
@@ -1,6 +1,7 @@
|
|||||||
package com.seven.ikar.kotlin.core.settings
|
package com.seven.ikar.kotlin.core.settings
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import com.seven.ikar.kotlin.core.security.AndroidKeystoreCipher
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@@ -46,6 +47,7 @@ data class AppSettingsSnapshot(
|
|||||||
|
|
||||||
class AndroidAppSettings(context: Context) {
|
class AndroidAppSettings(context: Context) {
|
||||||
private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||||
|
private val secretCipher = AndroidKeystoreCipher("ikar.settings.v1")
|
||||||
private val _snapshot = MutableStateFlow(loadSnapshot())
|
private val _snapshot = MutableStateFlow(loadSnapshot())
|
||||||
val snapshot: StateFlow<AppSettingsSnapshot> = _snapshot.asStateFlow()
|
val snapshot: StateFlow<AppSettingsSnapshot> = _snapshot.asStateFlow()
|
||||||
|
|
||||||
@@ -68,7 +70,7 @@ class AndroidAppSettings(context: Context) {
|
|||||||
proxyType = getEnum(KEY_PROXY_TYPE, AppProxyType.Socks5).supportedProxyType(),
|
proxyType = getEnum(KEY_PROXY_TYPE, AppProxyType.Socks5).supportedProxyType(),
|
||||||
proxyHost = preferences.getString(KEY_PROXY_HOST, "").orEmpty(),
|
proxyHost = preferences.getString(KEY_PROXY_HOST, "").orEmpty(),
|
||||||
proxyPort = preferences.getInt(KEY_PROXY_PORT, 1080).coerceIn(1, 65_535),
|
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) {
|
fun update(transform: (AppSettingsSnapshot) -> AppSettingsSnapshot) {
|
||||||
@@ -99,7 +101,11 @@ class AndroidAppSettings(context: Context) {
|
|||||||
putString(KEY_PROXY_TYPE, sanitized.proxyType.name)
|
putString(KEY_PROXY_TYPE, sanitized.proxyType.name)
|
||||||
putString(KEY_PROXY_HOST, sanitized.proxyHost)
|
putString(KEY_PROXY_HOST, sanitized.proxyHost)
|
||||||
putInt(KEY_PROXY_PORT, sanitized.proxyPort)
|
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()
|
apply()
|
||||||
}
|
}
|
||||||
_snapshot.value = sanitized
|
_snapshot.value = sanitized
|
||||||
@@ -117,6 +123,22 @@ class AndroidAppSettings(context: Context) {
|
|||||||
?: defaultValue
|
?: 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) {
|
private fun AppProxyType.supportedProxyType(): AppProxyType = when (this) {
|
||||||
AppProxyType.MtProto -> AppProxyType.Socks5
|
AppProxyType.MtProto -> AppProxyType.Socks5
|
||||||
else -> this
|
else -> this
|
||||||
@@ -141,5 +163,6 @@ class AndroidAppSettings(context: Context) {
|
|||||||
private const val KEY_PROXY_HOST = "proxy_host"
|
private const val KEY_PROXY_HOST = "proxy_host"
|
||||||
private const val KEY_PROXY_PORT = "proxy_port"
|
private const val KEY_PROXY_PORT = "proxy_port"
|
||||||
private const val KEY_PROXY_SECRET = "proxy_secret"
|
private const val KEY_PROXY_SECRET = "proxy_secret"
|
||||||
|
private const val KEY_ENCRYPTED_PROXY_SECRET = "encrypted_proxy_secret"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+65
-16
@@ -25,6 +25,7 @@ import androidx.compose.foundation.gestures.detectTapGestures
|
|||||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
import androidx.compose.foundation.horizontalScroll
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -3681,20 +3682,73 @@ private fun InlineMessageImage(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongPress: () -> Unit
|
onLongPress: () -> Unit
|
||||||
) {
|
) {
|
||||||
AuthenticatedAsyncImage(
|
ChatPhotoImage(
|
||||||
imageUrl = imageUrl,
|
imageUrl = imageUrl,
|
||||||
accessToken = "",
|
accessToken = "",
|
||||||
contentDescription = "",
|
contentDescription = "",
|
||||||
modifier = Modifier
|
onClick = onClick,
|
||||||
.fillMaxWidth()
|
onLongPress = onLongPress
|
||||||
.heightIn(min = 120.dp, max = 420.dp)
|
|
||||||
.clip(RoundedCornerShape(16.dp))
|
|
||||||
.combinedClickable(onClick = onClick, onLongClick = onLongPress)
|
|
||||||
.background(Color(0xFFF7FAFD)),
|
|
||||||
contentScale = ContentScale.Fit
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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<Float?>(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)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun InlineMessageAudio(
|
private fun InlineMessageAudio(
|
||||||
@@ -3888,17 +3942,12 @@ private fun AttachmentCard(
|
|||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
attachment.isImageAttachment() -> {
|
attachment.isImageAttachment() -> {
|
||||||
AuthenticatedAsyncImage(
|
ChatPhotoImage(
|
||||||
imageUrl = attachmentUrl,
|
imageUrl = attachmentUrl,
|
||||||
accessToken = accessToken,
|
accessToken = accessToken,
|
||||||
contentDescription = attachment.fileName,
|
contentDescription = attachment.fileName,
|
||||||
modifier = Modifier
|
onClick = onClick,
|
||||||
.fillMaxWidth()
|
onLongPress = onLongPress
|
||||||
.heightIn(min = 120.dp, max = 420.dp)
|
|
||||||
.clip(RoundedCornerShape(16.dp))
|
|
||||||
.combinedClickable(onClick = onClick, onLongClick = onLongPress)
|
|
||||||
.background(Color(0xFFF7FAFD)),
|
|
||||||
contentScale = ContentScale.Fit
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+100
-25
@@ -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.CallMediaType
|
||||||
import com.seven.ikar.kotlin.core.model.ChatDetailsDto
|
import com.seven.ikar.kotlin.core.model.ChatDetailsDto
|
||||||
import com.seven.ikar.kotlin.core.model.ChatType
|
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.MessageDeliveryState
|
||||||
import com.seven.ikar.kotlin.core.model.MessageDto
|
import com.seven.ikar.kotlin.core.model.MessageDto
|
||||||
import com.seven.ikar.kotlin.core.model.MessageReplyDto
|
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.ServerConfig
|
||||||
import com.seven.ikar.kotlin.core.network.UploadFileSource
|
import com.seven.ikar.kotlin.core.network.UploadFileSource
|
||||||
import com.seven.ikar.kotlin.core.local.TransferQueueEntity
|
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.AudioCallSnapshot
|
||||||
import com.seven.ikar.kotlin.feature.call.AudioCallStage
|
import com.seven.ikar.kotlin.feature.call.AudioCallStage
|
||||||
import com.seven.ikar.kotlin.feature.transfer.TransferForegroundNotification
|
import com.seven.ikar.kotlin.feature.transfer.TransferForegroundNotification
|
||||||
@@ -164,7 +167,6 @@ private val DefaultCodexBridgeBotCommands = listOf(
|
|||||||
|
|
||||||
private val BotThreadOptionRegex = Regex("""^(?<index>\d+)\.\s+(?<title>.+?)(?<selected>\s+\[выбран])?$""")
|
private val BotThreadOptionRegex = Regex("""^(?<index>\d+)\.\s+(?<title>.+?)(?<selected>\s+\[выбран])?$""")
|
||||||
private val ChatTimestampFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm", Locale("ru", "RU"))
|
private val ChatTimestampFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm", Locale("ru", "RU"))
|
||||||
private const val LocalMessageIdPrefix = "local-"
|
|
||||||
|
|
||||||
class ChatViewModel(
|
class ChatViewModel(
|
||||||
private val container: AppContainer,
|
private val container: AppContainer,
|
||||||
@@ -703,14 +705,14 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
draftText.isNotBlank() -> {
|
draftText.isNotBlank() -> {
|
||||||
container.executeAuthorized { session ->
|
val clientMessageId = optimisticMessage?.clientMessageId ?: UUID.randomUUID().toString()
|
||||||
container.apiClient.sendMessage(
|
container.transferQueueRepository.enqueueTextMessage(
|
||||||
chatId = chatId,
|
chatId = chatId,
|
||||||
text = draftText,
|
text = draftText,
|
||||||
accessToken = session.accessToken,
|
replyToMessageId = replyToMessageId,
|
||||||
replyToMessageId = replyToMessageId
|
clientMessageId = clientMessageId
|
||||||
)
|
)
|
||||||
}
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> null
|
else -> null
|
||||||
@@ -1315,10 +1317,10 @@ class ChatViewModel(
|
|||||||
val cachedFile = cachedAttachmentFiles[attachment.id]
|
val cachedFile = cachedAttachmentFiles[attachment.id]
|
||||||
?.localPath
|
?.localPath
|
||||||
?.let(::File)
|
?.let(::File)
|
||||||
?.takeIf { it.exists() }
|
?.takeIf { it.isCompleteCacheFileFor(attachment) }
|
||||||
?: container.attachmentFileService
|
?: container.attachmentFileService
|
||||||
.createAttachmentCacheFile(attachment.id, attachment.fileName)
|
.createAttachmentCacheFile(attachment.id, attachment.fileName)
|
||||||
.takeIf { it.exists() }
|
.takeIf { it.isCompleteCacheFileFor(attachment) }
|
||||||
?.also { file ->
|
?.also { file ->
|
||||||
cachedAttachmentFiles[attachment.id] = LocalAttachmentFile(
|
cachedAttachmentFiles[attachment.id] = LocalAttachmentFile(
|
||||||
fileName = attachment.fileName,
|
fileName = attachment.fileName,
|
||||||
@@ -1419,8 +1421,9 @@ class ChatViewModel(
|
|||||||
|
|
||||||
private fun buildOptimisticMessage(state: ChatUiState, text: String): MessageDto? {
|
private fun buildOptimisticMessage(state: ChatUiState, text: String): MessageDto? {
|
||||||
val session = state.session ?: container.sessionStore.read() ?: return null
|
val session = state.session ?: container.sessionStore.read() ?: return null
|
||||||
|
val clientMessageId = ClientMessageIds.newClientId()
|
||||||
return MessageDto(
|
return MessageDto(
|
||||||
id = "$LocalMessageIdPrefix${UUID.randomUUID()}",
|
id = ClientMessageIds.localId(clientMessageId),
|
||||||
chatId = chatId,
|
chatId = chatId,
|
||||||
chatType = state.chatType,
|
chatType = state.chatType,
|
||||||
sender = session.user,
|
sender = session.user,
|
||||||
@@ -1431,13 +1434,17 @@ class ChatViewModel(
|
|||||||
replyTo = state.replyTo,
|
replyTo = state.replyTo,
|
||||||
postedAsChannel = state.chatType == ChatType.Channel,
|
postedAsChannel = state.chatType == ChatType.Channel,
|
||||||
displayAuthorName = if (state.chatType == ChatType.Channel) state.title else session.user.displayName,
|
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) {
|
private fun applyChat(chat: ChatDetailsDto, session: AuthSessionDto) {
|
||||||
val previousState = _uiState.value
|
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)
|
val orderedMessages = (chat.messages + localMessages)
|
||||||
.distinctBy { it.id }
|
.distinctBy { it.id }
|
||||||
.sortedBy { it.sentAt }
|
.sortedBy { it.sentAt }
|
||||||
@@ -1530,6 +1537,11 @@ class ChatViewModel(
|
|||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
viewModelScope.launch {
|
||||||
|
container.realtimeService.connectionRestored.collect {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
container.realtimeService.typingChanged.collect(::handleTypingChanged)
|
container.realtimeService.typingChanged.collect(::handleTypingChanged)
|
||||||
}
|
}
|
||||||
@@ -1659,7 +1671,25 @@ class ChatViewModel(
|
|||||||
private fun attachTransferQueueState() {
|
private fun attachTransferQueueState() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
container.transferQueueRepository.observeActiveForChat(chatId).collect { items ->
|
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 {
|
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)
|
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(
|
val cached = LocalAttachmentFile(
|
||||||
fileName = attachment.fileName,
|
fileName = attachment.fileName,
|
||||||
contentType = attachment.contentType,
|
contentType = attachment.contentType,
|
||||||
@@ -1686,8 +1722,20 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (attachment.fileSizeBytes >= TransferForegroundNotification.LargeTransferThresholdBytes) {
|
if (attachment.fileSizeBytes >= TransferForegroundNotification.LargeTransferThresholdBytes) {
|
||||||
container.transferQueueRepository.enqueueDownload(chatId, attachment)
|
val groupId = container.transferQueueRepository.enqueueDownload(chatId, attachment)
|
||||||
throw IllegalStateException("Large download queued. Try opening it again when the transfer completes.")
|
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 ->
|
return container.executeAuthorized { session ->
|
||||||
@@ -1727,8 +1775,15 @@ class ChatViewModel(
|
|||||||
.filterNot { it.hasProtectedContent || it.isDeleted() }
|
.filterNot { it.hasProtectedContent || it.isDeleted() }
|
||||||
.flatMap { it.attachments.asSequence() }
|
.flatMap { it.attachments.asSequence() }
|
||||||
.filter { attachment -> shouldAutoDownload(attachment, settings, isUnmetered) }
|
.filter { attachment -> shouldAutoDownload(attachment, settings, isUnmetered) }
|
||||||
.filterNot { attachment -> cachedAttachmentFiles.containsKey(attachment.id) }
|
.filterNot { attachment ->
|
||||||
.filterNot { attachment -> container.attachmentFileService.createAttachmentCacheFile(attachment.id, attachment.fileName).exists() }
|
cachedAttachmentFiles[attachment.id]
|
||||||
|
?.let { File(it.localPath).isCompleteCacheFileFor(attachment) } == true
|
||||||
|
}
|
||||||
|
.filterNot { attachment ->
|
||||||
|
container.attachmentFileService
|
||||||
|
.createAttachmentCacheFile(attachment.id, attachment.fileName)
|
||||||
|
.isCompleteCacheFileFor(attachment)
|
||||||
|
}
|
||||||
.take(8)
|
.take(8)
|
||||||
.forEach { attachment ->
|
.forEach { attachment ->
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -1760,6 +1815,20 @@ class ChatViewModel(
|
|||||||
message.hasProtectedContent && message.attachments.any { it.id == attachment.id }
|
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) {
|
private fun toggleVoiceAttachmentPlayback(attachment: AttachmentDto) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -1881,13 +1950,19 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun findMatchingPendingLocalMessageId(state: ChatUiState, serverMessage: MessageDto): String? {
|
private fun findMatchingPendingLocalMessageId(state: ChatUiState, serverMessage: MessageDto): String? {
|
||||||
if (serverMessage.isLocalMessage() || state.sendingLocalMessageIds.isEmpty()) {
|
if (serverMessage.isLocalMessage()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
serverMessage.clientMessageId?.let { clientMessageId ->
|
||||||
|
state.messages.firstOrNull { localMessage ->
|
||||||
|
localMessage.isLocalMessage() && localMessage.clientMessageId == clientMessageId
|
||||||
|
}?.let { return it.id }
|
||||||
|
}
|
||||||
|
|
||||||
return state.messages.firstOrNull { localMessage ->
|
return state.messages.firstOrNull { localMessage ->
|
||||||
state.sendingLocalMessageIds.contains(localMessage.id) &&
|
localMessage.isLocalMessage() &&
|
||||||
localMessage.sender.id == serverMessage.sender.id &&
|
localMessage.sender.id == serverMessage.sender.id &&
|
||||||
localMessage.text == serverMessage.text &&
|
localMessage.text == serverMessage.text &&
|
||||||
localMessage.attachments.isEmpty() &&
|
localMessage.attachments.isEmpty() &&
|
||||||
serverMessage.attachments.isEmpty() &&
|
serverMessage.attachments.isEmpty() &&
|
||||||
@@ -2097,7 +2172,7 @@ internal fun MessageDto.canManage(currentUserId: String?, canPostAsChannel: Bool
|
|||||||
internal fun MessageDto.canDelete(currentUserId: String?): Boolean =
|
internal fun MessageDto.canDelete(currentUserId: String?): Boolean =
|
||||||
currentUserId != null && deletedAt == null
|
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> {
|
private fun searchLoadedMessageIds(query: String, messages: List<MessageDto>): List<String> {
|
||||||
val normalizedQuery = query.trim()
|
val normalizedQuery = query.trim()
|
||||||
|
|||||||
+36
-20
@@ -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.ChatType
|
||||||
import com.seven.ikar.kotlin.core.model.StoryDto
|
import com.seven.ikar.kotlin.core.model.StoryDto
|
||||||
import com.seven.ikar.kotlin.core.network.ServerConfig
|
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.ChatListLayoutOption
|
||||||
import com.seven.ikar.kotlin.core.settings.ChatSettingsSnapshot
|
import com.seven.ikar.kotlin.core.settings.ChatSettingsSnapshot
|
||||||
import com.seven.ikar.kotlin.core.settings.ChatSwipeLeftActionOption
|
import com.seven.ikar.kotlin.core.settings.ChatSwipeLeftActionOption
|
||||||
@@ -125,7 +126,7 @@ fun ChatsScreen(
|
|||||||
onFilterChanged: (ChatListFilter) -> Unit,
|
onFilterChanged: (ChatListFilter) -> Unit,
|
||||||
onQueryChanged: (String) -> 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 previewMaxLines = if (chatSettings.chatListLayout == ChatListLayoutOption.ThreeLine) 2 else 1
|
||||||
val chatListState = rememberLazyListState()
|
val chatListState = rememberLazyListState()
|
||||||
var isStoriesHeaderVisible by remember { mutableStateOf(false) }
|
var isStoriesHeaderVisible by remember { mutableStateOf(false) }
|
||||||
@@ -188,8 +189,8 @@ fun ChatsScreen(
|
|||||||
.nestedScroll(storyHeaderNestedScrollConnection)
|
.nestedScroll(storyHeaderNestedScrollConnection)
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 12.dp),
|
modifier = Modifier.padding(start = 14.dp, top = 8.dp, end = 14.dp, bottom = 6.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
state.isSelectionMode && state.isShareMode -> {
|
state.isSelectionMode && state.isShareMode -> {
|
||||||
@@ -247,8 +248,11 @@ fun ChatsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
TextButton(onClick = onGlobalSearchClick) {
|
TextButton(
|
||||||
Text("Глобальный поиск: сообщения, медиа, файлы и ссылки")
|
onClick = onGlobalSearchClick,
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp)
|
||||||
|
) {
|
||||||
|
Text("Поиск по сообщениям и файлам", style = MaterialTheme.typography.labelMedium)
|
||||||
}
|
}
|
||||||
|
|
||||||
state.pendingShare?.let { pendingShare ->
|
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) {
|
if (!state.isSelectionMode && !state.isShareMode) {
|
||||||
@@ -336,7 +340,7 @@ fun ChatsScreen(
|
|||||||
contentDescription = "Новое сообщение",
|
contentDescription = "Новое сообщение",
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomEnd)
|
.align(Alignment.BottomEnd)
|
||||||
.padding(end = 20.dp, bottom = 126.dp),
|
.padding(end = 16.dp, bottom = 98.dp),
|
||||||
onClick = onDiscoverClick
|
onClick = onDiscoverClick
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -347,7 +351,7 @@ fun ChatsScreen(
|
|||||||
selectedTab = IkarMainMenuTab.Chats,
|
selectedTab = IkarMainMenuTab.Chats,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
.padding(horizontal = 8.dp, vertical = 18.dp),
|
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||||
onChatsClick = {},
|
onChatsClick = {},
|
||||||
onContactsClick = onContactsClick,
|
onContactsClick = onContactsClick,
|
||||||
onSettingsClick = onSettingsClick,
|
onSettingsClick = onSettingsClick,
|
||||||
@@ -412,7 +416,7 @@ private fun ChatsBrandHeader(
|
|||||||
onStoriesClick: () -> Unit
|
onStoriesClick: () -> Unit
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
StoriesClusterButton(
|
StoriesClusterButton(
|
||||||
@@ -424,12 +428,16 @@ private fun ChatsBrandHeader(
|
|||||||
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
|
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text = if (isRealtimeConnected) "ИКАР" else "Соединение...",
|
text = if (isRealtimeConnected) "ИКАР" else "Соединение...",
|
||||||
style = MaterialTheme.typography.displayMedium.copy(
|
style = MaterialTheme.typography.titleLarge.copy(
|
||||||
letterSpacing = if (isRealtimeConnected) 3.sp else 0.sp
|
letterSpacing = if (isRealtimeConnected) 2.sp else 0.sp
|
||||||
),
|
),
|
||||||
color = TelegramBlue
|
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
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), 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(
|
Text(
|
||||||
if (chatItem.chat.isPinned) "Открепить" else "Закрепить",
|
if (chatItem.chat.isPinned) "Открепить" else "Закрепить",
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
@@ -958,7 +970,11 @@ private fun SwipeableChatListRow(
|
|||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = TelegramBlue
|
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(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
.padding(horizontal = 6.dp, vertical = 1.dp)
|
||||||
.clip(MaterialTheme.shapes.large)
|
.clip(MaterialTheme.shapes.large)
|
||||||
.background(rowBackground)
|
.background(rowBackground)
|
||||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||||
.padding(horizontal = 8.dp, vertical = 10.dp),
|
.padding(horizontal = 8.dp, vertical = 7.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
AvatarView(
|
AvatarView(
|
||||||
imageUrl = avatarUrl,
|
imageUrl = avatarUrl,
|
||||||
title = chatItem.resolvedTitle,
|
title = chatItem.resolvedTitle,
|
||||||
size = 56.dp
|
size = 48.dp
|
||||||
)
|
)
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
@@ -1035,7 +1051,7 @@ private fun ChatListRow(
|
|||||||
Text(
|
Text(
|
||||||
text = chatItem.resolvedTitle,
|
text = chatItem.resolvedTitle,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
)
|
)
|
||||||
@@ -1088,7 +1104,7 @@ private fun ChatListRow(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
IkarRowDivider(startIndent = 86.dp)
|
IkarRowDivider(startIndent = 72.dp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -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.ChatSummaryDto
|
||||||
import com.seven.ikar.kotlin.core.model.ChatType
|
import com.seven.ikar.kotlin.core.model.ChatType
|
||||||
import com.seven.ikar.kotlin.core.model.StoryDto
|
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.feature.share.PendingExternalShare
|
||||||
import com.seven.ikar.kotlin.ui.formatLastActivity
|
import com.seven.ikar.kotlin.ui.formatLastActivity
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -69,7 +70,8 @@ data class ChatsUiState(
|
|||||||
val showDeleteForEveryoneOption: Boolean = false,
|
val showDeleteForEveryoneOption: Boolean = false,
|
||||||
val deleteForEveryoneLabel: String = DefaultDeleteForEveryoneLabel,
|
val deleteForEveryoneLabel: String = DefaultDeleteForEveryoneLabel,
|
||||||
val isDeletingSelected: Boolean = false,
|
val isDeletingSelected: Boolean = false,
|
||||||
val selectionStartedFromSwipeDelete: Boolean = false
|
val selectionStartedFromSwipeDelete: Boolean = false,
|
||||||
|
val realtimeStage: RealtimeConnectionStage = RealtimeConnectionStage.Disconnected
|
||||||
) {
|
) {
|
||||||
val isShareMode: Boolean
|
val isShareMode: Boolean
|
||||||
get() = pendingShare != null
|
get() = pendingShare != null
|
||||||
@@ -582,6 +584,14 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun attachRealtime() {
|
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 {
|
viewModelScope.launch {
|
||||||
container.realtimeService.chatCreated.collect { scheduleRefresh() }
|
container.realtimeService.chatCreated.collect { scheduleRefresh() }
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-14
@@ -29,6 +29,8 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.seven.ikar.kotlin.R
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginScreen(
|
fun LoginScreen(
|
||||||
@@ -88,13 +90,17 @@ fun LoginScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = state.pageTitle,
|
text = stringResource(
|
||||||
|
if (state.isAwaitingCode) R.string.login_verify_email_title else R.string.login_title
|
||||||
|
),
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = state.subtitle,
|
text = stringResource(
|
||||||
|
if (state.isAwaitingCode) R.string.login_code_subtitle else R.string.login_subtitle
|
||||||
|
),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = Color(0xFFDDEEFF)
|
color = Color(0xFFDDEEFF)
|
||||||
)
|
)
|
||||||
@@ -111,7 +117,7 @@ fun LoginScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "АККАУНТ",
|
text = stringResource(R.string.login_account_section),
|
||||||
style = MaterialTheme.typography.labelLarge,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = Color(0xFF5A8EB2),
|
color = Color(0xFF5A8EB2),
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold
|
||||||
@@ -122,7 +128,7 @@ fun LoginScreen(
|
|||||||
value = state.email,
|
value = state.email,
|
||||||
onValueChange = onEmailChanged,
|
onValueChange = onEmailChanged,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
label = { Text("Электронная почта") },
|
label = { Text(stringResource(R.string.login_email)) },
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||||
singleLine = true
|
singleLine = true
|
||||||
)
|
)
|
||||||
@@ -131,7 +137,7 @@ fun LoginScreen(
|
|||||||
value = state.phoneNumber,
|
value = state.phoneNumber,
|
||||||
onValueChange = onPhoneNumberChanged,
|
onValueChange = onPhoneNumberChanged,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
label = { Text("Номер телефона") },
|
label = { Text(stringResource(R.string.login_phone)) },
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||||
singleLine = true
|
singleLine = true
|
||||||
)
|
)
|
||||||
@@ -147,7 +153,7 @@ fun LoginScreen(
|
|||||||
modifier = Modifier.padding(horizontal = 8.dp)
|
modifier = Modifier.padding(horizontal = 8.dp)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text("Получить код")
|
Text(stringResource(R.string.login_request_code))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,12 +170,12 @@ fun LoginScreen(
|
|||||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Подтверждение email",
|
text = stringResource(R.string.login_verify_email_title),
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.SemiBold
|
fontWeight = FontWeight.SemiBold
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
text = "Код отправлен на указанную электронную почту. Номер телефона будет привязан к аккаунту.",
|
text = stringResource(R.string.login_code_explanation),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = Color(0xFF617A8F)
|
color = Color(0xFF617A8F)
|
||||||
)
|
)
|
||||||
@@ -178,7 +184,7 @@ fun LoginScreen(
|
|||||||
value = state.verificationCode,
|
value = state.verificationCode,
|
||||||
onValueChange = onVerificationCodeChanged,
|
onValueChange = onVerificationCodeChanged,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
label = { Text("Код подтверждения") },
|
label = { Text(stringResource(R.string.login_code)) },
|
||||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
singleLine = true
|
singleLine = true
|
||||||
)
|
)
|
||||||
@@ -188,7 +194,7 @@ fun LoginScreen(
|
|||||||
value = state.displayName,
|
value = state.displayName,
|
||||||
onValueChange = onDisplayNameChanged,
|
onValueChange = onDisplayNameChanged,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
label = { Text("Ваше имя") },
|
label = { Text(stringResource(R.string.login_display_name)) },
|
||||||
singleLine = true
|
singleLine = true
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -196,8 +202,7 @@ fun LoginScreen(
|
|||||||
Button(
|
Button(
|
||||||
onClick = onVerifyCodeClick,
|
onClick = onVerifyCodeClick,
|
||||||
enabled = !state.isLoading &&
|
enabled = !state.isLoading &&
|
||||||
state.verificationCode.isNotBlank() &&
|
state.verificationCode.isNotBlank(),
|
||||||
(!state.isNewAccount || state.displayName.isNotBlank()),
|
|
||||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 14.dp)
|
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 14.dp)
|
||||||
) {
|
) {
|
||||||
if (state.isLoading) {
|
if (state.isLoading) {
|
||||||
@@ -206,7 +211,7 @@ fun LoginScreen(
|
|||||||
modifier = Modifier.padding(horizontal = 8.dp)
|
modifier = Modifier.padding(horizontal = 8.dp)
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
Text("Продолжить")
|
Text(stringResource(R.string.login_continue))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +224,7 @@ fun LoginScreen(
|
|||||||
),
|
),
|
||||||
contentPadding = PaddingValues(horizontal = 0.dp, vertical = 12.dp)
|
contentPadding = PaddingValues(horizontal = 0.dp, vertical = 12.dp)
|
||||||
) {
|
) {
|
||||||
Text("Изменить email или телефон")
|
Text(stringResource(R.string.login_change_identity))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-3
@@ -38,7 +38,7 @@ data class LoginUiState(
|
|||||||
get() = isAwaitingCode
|
get() = isAwaitingCode
|
||||||
|
|
||||||
val showDisplayName: Boolean
|
val showDisplayName: Boolean
|
||||||
get() = isAwaitingCode && isNewAccount
|
get() = isAwaitingCode
|
||||||
}
|
}
|
||||||
|
|
||||||
class LoginViewModel(private val container: AppContainer) : ViewModel() {
|
class LoginViewModel(private val container: AppContainer) : ViewModel() {
|
||||||
@@ -103,8 +103,7 @@ class LoginViewModel(private val container: AppContainer) : ViewModel() {
|
|||||||
state.challengeId.isBlank() ||
|
state.challengeId.isBlank() ||
|
||||||
state.email.isBlank() ||
|
state.email.isBlank() ||
|
||||||
state.phoneNumber.isBlank() ||
|
state.phoneNumber.isBlank() ||
|
||||||
state.verificationCode.isBlank() ||
|
state.verificationCode.isBlank()
|
||||||
(state.isNewAccount && state.displayName.isBlank())
|
|
||||||
) {
|
) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+62
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+69
@@ -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.core.model.StoryVisibility
|
||||||
import com.seven.ikar.kotlin.feature.chat.LocalAttachmentFile
|
import com.seven.ikar.kotlin.feature.chat.LocalAttachmentFile
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
class TransferQueueRepository(
|
class TransferQueueRepository(
|
||||||
@@ -27,6 +28,46 @@ class TransferQueueRepository(
|
|||||||
fun observeActiveStoryUploads(): Flow<List<TransferQueueEntity>> =
|
fun observeActiveStoryUploads(): Flow<List<TransferQueueEntity>> =
|
||||||
dao.observeActiveStoryUploads()
|
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(
|
suspend fun enqueueUpload(
|
||||||
chatId: String,
|
chatId: String,
|
||||||
text: String?,
|
text: String?,
|
||||||
@@ -47,6 +88,7 @@ class TransferQueueRepository(
|
|||||||
chatId = chatId,
|
chatId = chatId,
|
||||||
text = text?.takeIf { it.isNotBlank() },
|
text = text?.takeIf { it.isNotBlank() },
|
||||||
replyToMessageId = replyToMessageId,
|
replyToMessageId = replyToMessageId,
|
||||||
|
clientMessageId = null,
|
||||||
attachmentId = null,
|
attachmentId = null,
|
||||||
storyVisibility = null,
|
storyVisibility = null,
|
||||||
hasProtectedContent = false,
|
hasProtectedContent = false,
|
||||||
@@ -88,6 +130,7 @@ class TransferQueueRepository(
|
|||||||
chatId = null,
|
chatId = null,
|
||||||
text = text?.takeIf { it.isNotBlank() },
|
text = text?.takeIf { it.isNotBlank() },
|
||||||
replyToMessageId = null,
|
replyToMessageId = null,
|
||||||
|
clientMessageId = null,
|
||||||
attachmentId = null,
|
attachmentId = null,
|
||||||
storyVisibility = visibility.name,
|
storyVisibility = visibility.name,
|
||||||
hasProtectedContent = hasProtectedContent,
|
hasProtectedContent = hasProtectedContent,
|
||||||
@@ -109,6 +152,7 @@ class TransferQueueRepository(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun enqueueDownload(chatId: String, attachment: AttachmentDto): String {
|
suspend fun enqueueDownload(chatId: String, attachment: AttachmentDto): String {
|
||||||
|
dao.findActiveDownloadGroup(attachment.id)?.let { return it }
|
||||||
val groupId = UUID.randomUUID().toString()
|
val groupId = UUID.randomUUID().toString()
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
dao.upsert(
|
dao.upsert(
|
||||||
@@ -121,6 +165,7 @@ class TransferQueueRepository(
|
|||||||
chatId = chatId,
|
chatId = chatId,
|
||||||
text = null,
|
text = null,
|
||||||
replyToMessageId = null,
|
replyToMessageId = null,
|
||||||
|
clientMessageId = null,
|
||||||
attachmentId = attachment.id,
|
attachmentId = attachment.id,
|
||||||
storyVisibility = null,
|
storyVisibility = null,
|
||||||
hasProtectedContent = false,
|
hasProtectedContent = false,
|
||||||
@@ -141,10 +186,22 @@ class TransferQueueRepository(
|
|||||||
return groupId
|
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) {
|
suspend fun retry(groupId: String) {
|
||||||
val type = dao.getGroup(groupId).firstOrNull()?.type
|
val type = dao.getGroup(groupId).firstOrNull()?.type
|
||||||
dao.updateGroupStatus(groupId, TransferQueueStatus.Queued, 0, null, System.currentTimeMillis())
|
dao.updateGroupStatus(groupId, TransferQueueStatus.Queued, 0, null, System.currentTimeMillis())
|
||||||
when (type) {
|
when (type) {
|
||||||
|
TransferQueueTypes.TextMessage -> scheduler.enqueueTextMessage(groupId)
|
||||||
TransferQueueTypes.Download -> scheduler.enqueueDownload(groupId)
|
TransferQueueTypes.Download -> scheduler.enqueueDownload(groupId)
|
||||||
TransferQueueTypes.StoryUpload -> scheduler.enqueueStoryUpload(groupId)
|
TransferQueueTypes.StoryUpload -> scheduler.enqueueStoryUpload(groupId)
|
||||||
else -> scheduler.enqueueUpload(groupId)
|
else -> scheduler.enqueueUpload(groupId)
|
||||||
@@ -172,6 +229,15 @@ class TransferWorkScheduler(context: Context) {
|
|||||||
workManager.enqueueUniqueWork(uploadWorkName(groupId), ExistingWorkPolicy.REPLACE, request)
|
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) {
|
fun enqueueStoryUpload(groupId: String) {
|
||||||
val request = OneTimeWorkRequestBuilder<AttachmentUploadWorker>()
|
val request = OneTimeWorkRequestBuilder<AttachmentUploadWorker>()
|
||||||
.setInputData(workDataOf(AttachmentUploadWorker.KeyGroupId to groupId))
|
.setInputData(workDataOf(AttachmentUploadWorker.KeyGroupId to groupId))
|
||||||
@@ -191,6 +257,7 @@ class TransferWorkScheduler(context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun cancel(groupId: String) {
|
fun cancel(groupId: String) {
|
||||||
|
workManager.cancelUniqueWork(textMessageWorkName(groupId))
|
||||||
workManager.cancelUniqueWork(uploadWorkName(groupId))
|
workManager.cancelUniqueWork(uploadWorkName(groupId))
|
||||||
workManager.cancelUniqueWork(storyUploadWorkName(groupId))
|
workManager.cancelUniqueWork(storyUploadWorkName(groupId))
|
||||||
workManager.cancelUniqueWork(downloadWorkName(groupId))
|
workManager.cancelUniqueWork(downloadWorkName(groupId))
|
||||||
@@ -198,6 +265,8 @@ class TransferWorkScheduler(context: Context) {
|
|||||||
|
|
||||||
private fun uploadWorkName(groupId: String): String = "upload-$groupId"
|
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 storyUploadWorkName(groupId: String): String = "story-upload-$groupId"
|
||||||
|
|
||||||
private fun downloadWorkName(groupId: String): String = "download-$groupId"
|
private fun downloadWorkName(groupId: String): String = "download-$groupId"
|
||||||
|
|||||||
+5
-2
@@ -6,6 +6,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import coil.compose.AsyncImage
|
import coil.compose.AsyncImage
|
||||||
|
import coil.compose.AsyncImagePainter
|
||||||
import coil.request.CachePolicy
|
import coil.request.CachePolicy
|
||||||
import coil.request.ImageRequest
|
import coil.request.ImageRequest
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
@@ -16,7 +17,8 @@ fun AuthenticatedAsyncImage(
|
|||||||
accessToken: String? = null,
|
accessToken: String? = null,
|
||||||
contentDescription: String,
|
contentDescription: String,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
contentScale: ContentScale = ContentScale.Crop
|
contentScale: ContentScale = ContentScale.Crop,
|
||||||
|
onSuccess: ((AsyncImagePainter.State.Success) -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
if (imageUrl.isNullOrBlank()) {
|
if (imageUrl.isNullOrBlank()) {
|
||||||
return
|
return
|
||||||
@@ -45,7 +47,8 @@ fun AuthenticatedAsyncImage(
|
|||||||
model = request,
|
model = request,
|
||||||
contentDescription = contentDescription,
|
contentDescription = contentDescription,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
contentScale = contentScale
|
contentScale = contentScale,
|
||||||
|
onSuccess = onSuccess
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -524,8 +524,8 @@ fun IkarFloatingMainMenu(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.border(1.dp, TelegramFloatingBorder, RoundedCornerShape(32.dp))
|
.border(1.dp, TelegramFloatingBorder, RoundedCornerShape(32.dp))
|
||||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
) {
|
) {
|
||||||
val unreadChatsCount = LocalUnreadChatsCount.current
|
val unreadChatsCount = LocalUnreadChatsCount.current
|
||||||
IkarFloatingMenuItem(
|
IkarFloatingMenuItem(
|
||||||
@@ -573,9 +573,9 @@ private fun IkarFloatingMenuItem(
|
|||||||
.clip(shape)
|
.clip(shape)
|
||||||
.clickable(onClick = onClick)
|
.clickable(onClick = onClick)
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
.padding(horizontal = 8.dp, vertical = 5.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||||
) {
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.size(28.dp),
|
modifier = Modifier.size(28.dp),
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
+21
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -152,7 +152,7 @@ public sealed class AuthEmailControllerTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RequestPhoneCode_NewPhone_DoesNotRegister()
|
public async Task RequestPhoneCode_IsDisabled()
|
||||||
{
|
{
|
||||||
await using var database = new SqliteTestDatabase();
|
await using var database = new SqliteTestDatabase();
|
||||||
await using var dbContext = await database.CreateContextAsync();
|
await using var dbContext = await database.CreateContextAsync();
|
||||||
@@ -162,7 +162,8 @@ public sealed class AuthEmailControllerTests
|
|||||||
new RequestPhoneCodeRequest("+10000009999"),
|
new RequestPhoneCodeRequest("+10000009999"),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
Assert.IsType<NotFoundObjectResult>(result.Result);
|
var response = Assert.IsType<ObjectResult>(result.Result);
|
||||||
|
Assert.Equal(StatusCodes.Status410Gone, response.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AuthController CreateController(
|
private static AuthController CreateController(
|
||||||
@@ -174,7 +175,6 @@ public sealed class AuthEmailControllerTests
|
|||||||
dbContext,
|
dbContext,
|
||||||
new TokenService(dbContext, Options.Create(new JwtOptions())),
|
new TokenService(dbContext, Options.Create(new JwtOptions())),
|
||||||
new PresenceTracker(),
|
new PresenceTracker(),
|
||||||
new PhoneAuthChallengeStore(),
|
|
||||||
emailChallengeStore,
|
emailChallengeStore,
|
||||||
emailCodeSender);
|
emailCodeSender);
|
||||||
controller.ControllerContext = new ControllerContext
|
controller.ControllerContext = new ControllerContext
|
||||||
|
|||||||
@@ -519,7 +519,7 @@ public sealed class ChannelMembersControllerTests
|
|||||||
new UploadAttachmentRequest
|
new UploadAttachmentRequest
|
||||||
{
|
{
|
||||||
Text = "Photo post",
|
Text = "Photo post",
|
||||||
File = CreateFormFile([1, 2, 3], "photo.jpg", "image/jpeg")
|
File = CreateFormFile([0xFF, 0xD8, 0xFF, 0xE0], "photo.jpg", "image/jpeg")
|
||||||
},
|
},
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
@@ -570,8 +570,8 @@ public sealed class ChannelMembersControllerTests
|
|||||||
Text = "Gallery",
|
Text = "Gallery",
|
||||||
Files =
|
Files =
|
||||||
[
|
[
|
||||||
CreateFormFile([1, 2, 3], "first.jpg", "image/jpeg"),
|
CreateFormFile([0xFF, 0xD8, 0xFF, 0xE0], "first.jpg", "image/jpeg"),
|
||||||
CreateFormFile([4, 5, 6], "second.jpg", "image/jpeg")
|
CreateFormFile([0xFF, 0xD8, 0xFF, 0xE1], "second.jpg", "image/jpeg")
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,6 @@ public sealed class AuthController(
|
|||||||
IkarDbContext dbContext,
|
IkarDbContext dbContext,
|
||||||
TokenService tokenService,
|
TokenService tokenService,
|
||||||
PresenceTracker presenceTracker,
|
PresenceTracker presenceTracker,
|
||||||
PhoneAuthChallengeStore phoneAuthChallengeStore,
|
|
||||||
EmailAuthChallengeStore emailAuthChallengeStore,
|
EmailAuthChallengeStore emailAuthChallengeStore,
|
||||||
IEmailCodeSender emailCodeSender) : ControllerBase
|
IEmailCodeSender emailCodeSender) : ControllerBase
|
||||||
{
|
{
|
||||||
@@ -135,77 +134,26 @@ public sealed class AuthController(
|
|||||||
|
|
||||||
[HttpPost("request-code")]
|
[HttpPost("request-code")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public async Task<ActionResult<PhoneCodeChallengeDto>> RequestPhoneCode(
|
public Task<ActionResult<PhoneCodeChallengeDto>> RequestPhoneCode(
|
||||||
RequestPhoneCodeRequest request,
|
RequestPhoneCodeRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(request.PhoneNumber);
|
return Task.FromResult<ActionResult<PhoneCodeChallengeDto>>(
|
||||||
if (normalizedPhoneNumber is null)
|
StatusCode(
|
||||||
{
|
StatusCodes.Status410Gone,
|
||||||
return ValidationProblem("Valid phone number is required.");
|
new { error = "Phone-code authentication is disabled. Request a code by email." }));
|
||||||
}
|
|
||||||
|
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("verify-code")]
|
[HttpPost("verify-code")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
public async Task<ActionResult<AuthSessionDto>> VerifyPhoneCode(
|
public Task<ActionResult<AuthSessionDto>> VerifyPhoneCode(
|
||||||
VerifyPhoneCodeRequest request,
|
VerifyPhoneCodeRequest request,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(request.PhoneNumber);
|
return Task.FromResult<ActionResult<AuthSessionDto>>(
|
||||||
if (normalizedPhoneNumber is null)
|
StatusCode(
|
||||||
{
|
StatusCodes.Status410Gone,
|
||||||
return ValidationProblem("Valid phone number is required.");
|
new { error = "Phone-code authentication is disabled. Verify a code sent by email." }));
|
||||||
}
|
|
||||||
|
|
||||||
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) });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("request-email-code")]
|
[HttpPost("request-email-code")]
|
||||||
@@ -247,7 +195,18 @@ public sealed class AuthController(
|
|||||||
return StatusCode(StatusCodes.Status423Locked, "User is blocked.");
|
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
|
try
|
||||||
{
|
{
|
||||||
await emailCodeSender.SendLoginCodeAsync(email, challenge.Code, challenge.ExpiresAt, cancellationToken);
|
await emailCodeSender.SendLoginCodeAsync(email, challenge.Code, challenge.ExpiresAt, cancellationToken);
|
||||||
@@ -261,7 +220,7 @@ public sealed class AuthController(
|
|||||||
return Ok(new EmailCodeChallengeDto(
|
return Ok(new EmailCodeChallengeDto(
|
||||||
challenge.Id,
|
challenge.Id,
|
||||||
challenge.ExpiresAt,
|
challenge.ExpiresAt,
|
||||||
challenge.IsRegistered));
|
IsRegistered: false));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("verify-email-code")]
|
[HttpPost("verify-email-code")]
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ public sealed class BotApiController(
|
|||||||
message.DiscussionMessageId = discussionRootMessage.Id;
|
message.DiscussionMessageId = discussionRootMessage.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage);
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
await NotifyMessageCreatedAsync(chat, message, cancellationToken);
|
await NotifyMessageCreatedAsync(chat, message, cancellationToken);
|
||||||
@@ -377,6 +378,7 @@ public sealed class BotApiController(
|
|||||||
message.DiscussionMessageId = discussionRootMessage.Id;
|
message.DiscussionMessageId = discussionRootMessage.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage);
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -516,6 +518,7 @@ public sealed class BotApiController(
|
|||||||
message.DiscussionMessageId = discussionRootMessage.Id;
|
message.DiscussionMessageId = discussionRootMessage.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage);
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -1842,6 +1842,38 @@ public sealed class ChatsController(
|
|||||||
return StatusCode(StatusCodes.Status403Forbidden, "Only channel owners can publish.");
|
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
|
var storyReply = request.StoryReplyStoryId is null
|
||||||
? null
|
? null
|
||||||
: await LoadVisibleStoryForReplyAsync(request.StoryReplyStoryId.Value, userId, cancellationToken);
|
: await LoadVisibleStoryForReplyAsync(request.StoryReplyStoryId.Value, userId, cancellationToken);
|
||||||
@@ -1897,6 +1929,7 @@ public sealed class ChatsController(
|
|||||||
Chat = chat,
|
Chat = chat,
|
||||||
SenderId = userId,
|
SenderId = userId,
|
||||||
Sender = sender,
|
Sender = sender,
|
||||||
|
ClientMessageId = request.ClientMessageId,
|
||||||
PostedAsChannel = ShouldPostAsChannel(chat),
|
PostedAsChannel = ShouldPostAsChannel(chat),
|
||||||
AuthorSignature = ResolveChannelAuthorSignature(chat, sender),
|
AuthorSignature = ResolveChannelAuthorSignature(chat, sender),
|
||||||
Text = text,
|
Text = text,
|
||||||
@@ -1928,6 +1961,7 @@ public sealed class ChatsController(
|
|||||||
message.DiscussionMessageId = discussionRootMessage.Id;
|
message.DiscussionMessageId = discussionRootMessage.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage);
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
var loadedMessage = await LoadMessageForMemberAsync(chatId, message.Id, userId, cancellationToken)
|
var loadedMessage = await LoadMessageForMemberAsync(chatId, message.Id, userId, cancellationToken)
|
||||||
@@ -2052,6 +2086,7 @@ public sealed class ChatsController(
|
|||||||
forwardedMessage.DiscussionMessageId = discussionRootMessage.Id;
|
forwardedMessage.DiscussionMessageId = discussionRootMessage.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pushDispatchQueue.StageMessages(dbContext, forwardedMessage, discussionRootMessage);
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -2176,6 +2211,10 @@ public sealed class ChatsController(
|
|||||||
shouldNotifyReaction = message.SenderId != userId;
|
shouldNotifyReaction = message.SenderId != userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (shouldNotifyReaction)
|
||||||
|
{
|
||||||
|
pushDispatchQueue.StageReaction(dbContext, chatId, messageId, userId, emoji);
|
||||||
|
}
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
var updatedMessage = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken)
|
var updatedMessage = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken)
|
||||||
@@ -2314,6 +2353,7 @@ public sealed class ChatsController(
|
|||||||
message.DiscussionMessageId = discussionRootMessage.Id;
|
message.DiscussionMessageId = discussionRootMessage.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage);
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -2488,6 +2528,7 @@ public sealed class ChatsController(
|
|||||||
message.DiscussionMessageId = discussionRootMessage.Id;
|
message.DiscussionMessageId = discussionRootMessage.Id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage);
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -4425,21 +4466,8 @@ public sealed class ChatsController(
|
|||||||
|
|
||||||
private static bool IsMediaAlbumFile(IFormFile file)
|
private static bool IsMediaAlbumFile(IFormFile file)
|
||||||
{
|
{
|
||||||
var normalizedContentType = file.ContentType
|
var contentInfo = AttachmentContentInspector.Inspect(file);
|
||||||
?.Split(';', 2, StringSplitOptions.TrimEntries)
|
return contentInfo.IsImage || contentInfo.IsVideo;
|
||||||
.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";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? ResolveChannelAuthorSignature(Chat chat, User sender)
|
private static string? ResolveChannelAuthorSignature(Chat chat, User sender)
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ public sealed class Message
|
|||||||
public Chat Chat { get; set; } = null!;
|
public Chat Chat { get; set; } = null!;
|
||||||
public Guid SenderId { get; set; }
|
public Guid SenderId { get; set; }
|
||||||
public User Sender { get; set; } = null!;
|
public User Sender { get; set; } = null!;
|
||||||
|
public Guid? ClientMessageId { get; set; }
|
||||||
public string Text { get; set; } = string.Empty;
|
public string Text { get; set; } = string.Empty;
|
||||||
public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
public DateTimeOffset? EditedAt { get; set; }
|
public DateTimeOffset? EditedAt { get; set; }
|
||||||
@@ -229,6 +230,21 @@ public sealed class MessageView
|
|||||||
public DateTimeOffset ViewedAt { get; set; } = DateTimeOffset.UtcNow;
|
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 sealed class PushDevice
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public sealed class IkarDbContext(DbContextOptions<IkarDbContext> options) : DbC
|
|||||||
public DbSet<MessageReaction> MessageReactions => Set<MessageReaction>();
|
public DbSet<MessageReaction> MessageReactions => Set<MessageReaction>();
|
||||||
public DbSet<MessageView> MessageViews => Set<MessageView>();
|
public DbSet<MessageView> MessageViews => Set<MessageView>();
|
||||||
public DbSet<PushDevice> PushDevices => Set<PushDevice>();
|
public DbSet<PushDevice> PushDevices => Set<PushDevice>();
|
||||||
|
public DbSet<PushDispatchOutboxItem> PushDispatchOutbox => Set<PushDispatchOutboxItem>();
|
||||||
public DbSet<Bot> Bots => Set<Bot>();
|
public DbSet<Bot> Bots => Set<Bot>();
|
||||||
public DbSet<BotCommand> BotCommands => Set<BotCommand>();
|
public DbSet<BotCommand> BotCommands => Set<BotCommand>();
|
||||||
public DbSet<BotUpdate> BotUpdates => Set<BotUpdate>();
|
public DbSet<BotUpdate> BotUpdates => Set<BotUpdate>();
|
||||||
@@ -44,6 +45,7 @@ public sealed class IkarDbContext(DbContextOptions<IkarDbContext> options) : DbC
|
|||||||
ConfigureChats(modelBuilder);
|
ConfigureChats(modelBuilder);
|
||||||
ConfigureMessages(modelBuilder);
|
ConfigureMessages(modelBuilder);
|
||||||
ConfigurePushDevices(modelBuilder);
|
ConfigurePushDevices(modelBuilder);
|
||||||
|
ConfigurePushDispatchOutbox(modelBuilder);
|
||||||
ConfigureBots(modelBuilder);
|
ConfigureBots(modelBuilder);
|
||||||
ConfigureModeration(modelBuilder);
|
ConfigureModeration(modelBuilder);
|
||||||
ConfigureStories(modelBuilder);
|
ConfigureStories(modelBuilder);
|
||||||
@@ -246,6 +248,9 @@ public sealed class IkarDbContext(DbContextOptions<IkarDbContext> options) : DbC
|
|||||||
entity.HasKey(x => x.Id);
|
entity.HasKey(x => x.Id);
|
||||||
entity.HasIndex(x => new { x.ChatId, x.SentAt });
|
entity.HasIndex(x => new { x.ChatId, x.SentAt });
|
||||||
entity.HasIndex(x => new { x.ChatId, x.SenderId, 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.SenderId);
|
||||||
entity.HasIndex(x => x.ReplyToMessageId);
|
entity.HasIndex(x => x.ReplyToMessageId);
|
||||||
entity.HasIndex(x => x.TopicId);
|
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)
|
private static void ConfigureBots(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
modelBuilder.Entity<Bot>(entity =>
|
modelBuilder.Entity<Bot>(entity =>
|
||||||
|
|||||||
@@ -7,9 +7,11 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.4" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.4" />
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.4" />
|
<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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -6,11 +6,17 @@ namespace Ikar.Server.Infrastructure.Auth;
|
|||||||
public sealed class EmailAuthChallengeStore
|
public sealed class EmailAuthChallengeStore
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan ChallengeLifetime = TimeSpan.FromMinutes(10);
|
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, EmailAuthChallenge> _challenges = new(StringComparer.Ordinal);
|
||||||
|
private readonly ConcurrentDictionary<string, RequestBucket> _requestBuckets = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
public EmailAuthChallenge Create(string normalizedEmail, string normalizedPhoneNumber, bool isRegistered)
|
public EmailAuthChallenge Create(string normalizedEmail, string normalizedPhoneNumber, bool isRegistered)
|
||||||
{
|
{
|
||||||
CleanupExpired();
|
CleanupExpired();
|
||||||
|
EnforceRequestLimit($"{normalizedEmail}|{normalizedPhoneNumber}");
|
||||||
|
|
||||||
var challenge = new EmailAuthChallenge(
|
var challenge = new EmailAuthChallenge(
|
||||||
Guid.NewGuid().ToString("N"),
|
Guid.NewGuid().ToString("N"),
|
||||||
@@ -39,12 +45,21 @@ public sealed class EmailAuthChallengeStore
|
|||||||
|
|
||||||
if (!string.Equals(challenge.NormalizedEmail, normalizedEmail, StringComparison.Ordinal) ||
|
if (!string.Equals(challenge.NormalizedEmail, normalizedEmail, StringComparison.Ordinal) ||
|
||||||
!string.Equals(challenge.NormalizedPhoneNumber, normalizedPhoneNumber, StringComparison.Ordinal) ||
|
!string.Equals(challenge.NormalizedPhoneNumber, normalizedPhoneNumber, StringComparison.Ordinal) ||
|
||||||
!string.Equals(challenge.Code, code, StringComparison.Ordinal) ||
|
|
||||||
challenge.ExpiresAt <= DateTimeOffset.UtcNow)
|
challenge.ExpiresAt <= DateTimeOffset.UtcNow)
|
||||||
{
|
{
|
||||||
return null;
|
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 _);
|
_challenges.TryRemove(challenge.Id, out _);
|
||||||
return challenge;
|
return challenge;
|
||||||
}
|
}
|
||||||
@@ -64,13 +79,74 @@ public sealed class EmailAuthChallengeStore
|
|||||||
_challenges.TryRemove(pair.Key, out _);
|
_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(
|
private void EnforceRequestLimit(string key)
|
||||||
string Id,
|
{
|
||||||
string NormalizedEmail,
|
var now = DateTimeOffset.UtcNow;
|
||||||
string NormalizedPhoneNumber,
|
var bucket = _requestBuckets.GetOrAdd(key, _ => new RequestBucket(now));
|
||||||
string Code,
|
lock (bucket)
|
||||||
DateTimeOffset ExpiresAt,
|
{
|
||||||
bool IsRegistered);
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -376,6 +376,7 @@ public sealed class BotCreatorService(
|
|||||||
|
|
||||||
chat.LastActivityAt = now;
|
chat.LastActivityAt = now;
|
||||||
dbContext.Messages.Add(reply);
|
dbContext.Messages.Add(reply);
|
||||||
|
pushDispatchQueue.StageMessages(dbContext, reply);
|
||||||
await dbContext.SaveChangesAsync(cancellationToken);
|
await dbContext.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
var loadedReply = await dbContext.Messages
|
var loadedReply = await dbContext.Messages
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
private bool TryTerminate(Guid callId, Guid userId, string reason, bool requireParticipant, out ActiveCallSession? session, out string error)
|
||||||
{
|
{
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ public sealed class WebRtcOptions
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
public int RingingTimeoutSeconds { get; set; } = 60;
|
||||||
|
|
||||||
|
public int MaximumCallDurationHours { get; set; } = 8;
|
||||||
|
|
||||||
public IReadOnlyList<CallIceServerDto> ToDto() =>
|
public IReadOnlyList<CallIceServerDto> ToDto() =>
|
||||||
IceServers
|
IceServers
|
||||||
.Where(server => server.Urls.Count > 0)
|
.Where(server => server.Urls.Count > 0)
|
||||||
|
|||||||
@@ -130,7 +130,8 @@ public static class DtoMapper
|
|||||||
viewCount,
|
viewCount,
|
||||||
BuildStoryReplyDto(message),
|
BuildStoryReplyDto(message),
|
||||||
message.DiscussionMessageId,
|
message.DiscussionMessageId,
|
||||||
commentCount);
|
commentCount,
|
||||||
|
message.ClientMessageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static AttachmentDto ToDto(this MessageAttachment attachment, Guid chatId) =>
|
public static AttachmentDto ToDto(this MessageAttachment attachment, Guid chatId) =>
|
||||||
|
|||||||
@@ -9,26 +9,52 @@ public sealed class PushDispatchBackgroundService(
|
|||||||
ILogger<PushDispatchBackgroundService> logger) : BackgroundService
|
ILogger<PushDispatchBackgroundService> logger) : BackgroundService
|
||||||
{
|
{
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
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))
|
await foreach (var workItem in queue.ReadAllAsync(stoppingToken))
|
||||||
{
|
{
|
||||||
try
|
await ProcessAsync(workItem, stoppingToken);
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +69,6 @@ public sealed class PushDispatchBackgroundService(
|
|||||||
.SingleOrDefaultAsync(x => x.Id == workItem.ChatId, cancellationToken);
|
.SingleOrDefaultAsync(x => x.Id == workItem.ChatId, cancellationToken);
|
||||||
if (chat is null)
|
if (chat is null)
|
||||||
{
|
{
|
||||||
logger.LogDebug("Skipping push dispatch because chat {ChatId} was not found.", workItem.ChatId);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,15 +76,10 @@ public sealed class PushDispatchBackgroundService(
|
|||||||
.Include(x => x.Sender)
|
.Include(x => x.Sender)
|
||||||
.Include(x => x.Attachments)
|
.Include(x => x.Attachments)
|
||||||
.SingleOrDefaultAsync(
|
.SingleOrDefaultAsync(
|
||||||
x => x.Id == workItem.MessageId &&
|
x => x.Id == workItem.MessageId && x.ChatId == workItem.ChatId,
|
||||||
x.ChatId == workItem.ChatId,
|
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (message is null)
|
if (message is null)
|
||||||
{
|
{
|
||||||
logger.LogDebug(
|
|
||||||
"Skipping push dispatch because message {MessageId} for chat {ChatId} was not found.",
|
|
||||||
workItem.MessageId,
|
|
||||||
workItem.ChatId);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,9 +87,6 @@ public sealed class PushDispatchBackgroundService(
|
|||||||
{
|
{
|
||||||
if (workItem.ActorUserId is null || string.IsNullOrWhiteSpace(workItem.ReactionEmoji))
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,9 +94,6 @@ public sealed class PushDispatchBackgroundService(
|
|||||||
.SingleOrDefaultAsync(x => x.Id == workItem.ActorUserId.Value, cancellationToken);
|
.SingleOrDefaultAsync(x => x.Id == workItem.ActorUserId.Value, cancellationToken);
|
||||||
if (reactor is null)
|
if (reactor is null)
|
||||||
{
|
{
|
||||||
logger.LogDebug(
|
|
||||||
"Skipping reaction push dispatch because reactor {UserId} was not found.",
|
|
||||||
workItem.ActorUserId.Value);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
|
using Ikar.Server.Data;
|
||||||
|
using Ikar.Server.Data.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Ikar.Server.Infrastructure.Push;
|
namespace Ikar.Server.Infrastructure.Push;
|
||||||
|
|
||||||
@@ -13,19 +16,72 @@ public sealed record PushDispatchWorkItem(
|
|||||||
Guid MessageId,
|
Guid MessageId,
|
||||||
PushDispatchKind Kind = PushDispatchKind.ChatMessage,
|
PushDispatchKind Kind = PushDispatchKind.ChatMessage,
|
||||||
Guid? ActorUserId = null,
|
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>(
|
private const int BatchSize = 50;
|
||||||
new UnboundedChannelOptions
|
private readonly Channel<PushDispatchWorkItem> _fallbackChannel =
|
||||||
|
Channel.CreateUnbounded<PushDispatchWorkItem>(new UnboundedChannelOptions
|
||||||
{
|
{
|
||||||
SingleReader = true,
|
SingleReader = true,
|
||||||
SingleWriter = false
|
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) =>
|
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(
|
public ValueTask EnqueueReactionAsync(
|
||||||
Guid chatId,
|
Guid chatId,
|
||||||
@@ -33,15 +89,166 @@ public sealed class PushDispatchQueue
|
|||||||
Guid reactorUserId,
|
Guid reactorUserId,
|
||||||
string emoji,
|
string emoji,
|
||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default) =>
|
||||||
_channel.Writer.WriteAsync(
|
EnqueueWorkItemAsync(
|
||||||
new PushDispatchWorkItem(
|
new PushDispatchWorkItem(
|
||||||
chatId,
|
chatId,
|
||||||
messageId,
|
messageId,
|
||||||
PushDispatchKind.MessageReaction,
|
PushDispatchKind.MessageReaction,
|
||||||
reactorUserId,
|
reactorUserId,
|
||||||
emoji),
|
emoji),
|
||||||
|
$"reaction:{messageId:D}:{reactorUserId:D}:{emoji}",
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
public IAsyncEnumerable<PushDispatchWorkItem> ReadAllAsync(CancellationToken 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
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ public sealed class PushNotificationDispatcher(
|
|||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogWarning(ex, "Push dispatch failed for message {MessageId}.", message.Id);
|
logger.LogWarning(ex, "Push dispatch failed for message {MessageId}.", message.Id);
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,6 +124,7 @@ public sealed class PushNotificationDispatcher(
|
|||||||
ex,
|
ex,
|
||||||
"Push dispatch failed for reaction to message {MessageId}.",
|
"Push dispatch failed for reaction to message {MessageId}.",
|
||||||
message.Id);
|
message.Id);
|
||||||
|
throw;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ public sealed class AttachmentStorageService(IWebHostEnvironment environment)
|
|||||||
}
|
}
|
||||||
|
|
||||||
var originalFileName = NormalizeOriginalFileName(file.FileName, "attachment");
|
var originalFileName = NormalizeOriginalFileName(file.FileName, "attachment");
|
||||||
|
var contentInfo = AttachmentContentInspector.Inspect(file);
|
||||||
var (attachmentId, physicalPath) = CreateTargetPath(originalFileName);
|
var (attachmentId, physicalPath) = CreateTargetPath(originalFileName);
|
||||||
|
|
||||||
await using (var source = file.OpenReadStream())
|
await using (var source = file.OpenReadStream())
|
||||||
@@ -31,7 +32,7 @@ public sealed class AttachmentStorageService(IWebHostEnvironment environment)
|
|||||||
Id = attachmentId,
|
Id = attachmentId,
|
||||||
MessageId = messageId,
|
MessageId = messageId,
|
||||||
OriginalFileName = originalFileName,
|
OriginalFileName = originalFileName,
|
||||||
ContentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType,
|
ContentType = contentInfo.ContentType,
|
||||||
FileSizeBytes = file.Length,
|
FileSizeBytes = file.Length,
|
||||||
StoragePath = physicalPath,
|
StoragePath = physicalPath,
|
||||||
SortOrder = sortOrder,
|
SortOrder = sortOrder,
|
||||||
@@ -51,6 +52,7 @@ public sealed class AttachmentStorageService(IWebHostEnvironment environment)
|
|||||||
}
|
}
|
||||||
|
|
||||||
var originalFileName = NormalizeOriginalFileName(file.FileName, "story-attachment");
|
var originalFileName = NormalizeOriginalFileName(file.FileName, "story-attachment");
|
||||||
|
var contentInfo = AttachmentContentInspector.Inspect(file);
|
||||||
var (attachmentId, physicalPath) = CreateTargetPath(originalFileName);
|
var (attachmentId, physicalPath) = CreateTargetPath(originalFileName);
|
||||||
|
|
||||||
await using (var source = file.OpenReadStream())
|
await using (var source = file.OpenReadStream())
|
||||||
@@ -64,7 +66,7 @@ public sealed class AttachmentStorageService(IWebHostEnvironment environment)
|
|||||||
Id = attachmentId,
|
Id = attachmentId,
|
||||||
StoryId = storyId,
|
StoryId = storyId,
|
||||||
OriginalFileName = originalFileName,
|
OriginalFileName = originalFileName,
|
||||||
ContentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType,
|
ContentType = contentInfo.ContentType,
|
||||||
FileSizeBytes = file.Length,
|
FileSizeBytes = file.Length,
|
||||||
StoragePath = physicalPath,
|
StoragePath = physicalPath,
|
||||||
SortOrder = sortOrder,
|
SortOrder = sortOrder,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext)
|
|||||||
await EnableWriteAheadLoggingAsync(cancellationToken);
|
await EnableWriteAheadLoggingAsync(cancellationToken);
|
||||||
await EnsureMessageAttachmentsTableAsync(cancellationToken);
|
await EnsureMessageAttachmentsTableAsync(cancellationToken);
|
||||||
await EnsurePushDevicesTableAsync(cancellationToken);
|
await EnsurePushDevicesTableAsync(cancellationToken);
|
||||||
|
await EnsurePushDispatchOutboxTableAsync(cancellationToken);
|
||||||
await EnsureUserPrivacySettingsTableAsync(cancellationToken);
|
await EnsureUserPrivacySettingsTableAsync(cancellationToken);
|
||||||
await EnsureChatInviteLinksTableAsync(cancellationToken);
|
await EnsureChatInviteLinksTableAsync(cancellationToken);
|
||||||
await EnsureChatJoinRequestsTableAsync(cancellationToken);
|
await EnsureChatJoinRequestsTableAsync(cancellationToken);
|
||||||
@@ -67,6 +68,8 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext)
|
|||||||
await EnsureColumnAsync("Messages", "StoryReplyAttachmentKind", "TEXT NULL", cancellationToken);
|
await EnsureColumnAsync("Messages", "StoryReplyAttachmentKind", "TEXT NULL", cancellationToken);
|
||||||
await EnsureColumnAsync("Messages", "DiscussionMessageId", "TEXT NULL", cancellationToken);
|
await EnsureColumnAsync("Messages", "DiscussionMessageId", "TEXT NULL", cancellationToken);
|
||||||
await EnsureColumnAsync("Messages", "CommentCount", "INTEGER NOT NULL DEFAULT 0", 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 EnsureLegacyChannelPostsMarkedAsync(cancellationToken);
|
||||||
await EnsureColumnAsync("MessageAttachments", "SortOrder", "INTEGER NOT NULL DEFAULT 0", cancellationToken);
|
await EnsureColumnAsync("MessageAttachments", "SortOrder", "INTEGER NOT NULL DEFAULT 0", cancellationToken);
|
||||||
await EnsureColumnAsync("MessageAttachments", "Kind", "INTEGER NOT NULL DEFAULT 1", cancellationToken);
|
await EnsureColumnAsync("MessageAttachments", "Kind", "INTEGER NOT NULL DEFAULT 1", cancellationToken);
|
||||||
@@ -729,6 +732,56 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext)
|
|||||||
cancellationToken);
|
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(
|
private async Task EnsureColumnAsync(
|
||||||
string tableName,
|
string tableName,
|
||||||
string columnName,
|
string columnName,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Security.Claims;
|
|||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
using Ikar.Server.Data;
|
using Ikar.Server.Data;
|
||||||
@@ -35,8 +36,15 @@ builder.Services.AddEndpointsApiExplorer();
|
|||||||
builder.Services.AddHttpClient();
|
builder.Services.AddHttpClient();
|
||||||
|
|
||||||
builder.Services.AddDbContext<IkarDbContext>(options =>
|
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 =>
|
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||||
{
|
{
|
||||||
@@ -165,10 +173,10 @@ builder.Services.AddAuthorization(options =>
|
|||||||
});
|
});
|
||||||
builder.Services.AddSingleton<PresenceTracker>();
|
builder.Services.AddSingleton<PresenceTracker>();
|
||||||
builder.Services.AddSingleton<CallSessionStore>();
|
builder.Services.AddSingleton<CallSessionStore>();
|
||||||
|
builder.Services.AddHostedService<CallSessionCleanupService>();
|
||||||
builder.Services.AddSingleton(new ServerRuntimeState(DateTimeOffset.UtcNow));
|
builder.Services.AddSingleton(new ServerRuntimeState(DateTimeOffset.UtcNow));
|
||||||
builder.Services.AddScoped<TokenService>();
|
builder.Services.AddScoped<TokenService>();
|
||||||
builder.Services.AddSingleton<BotTokenService>();
|
builder.Services.AddSingleton<BotTokenService>();
|
||||||
builder.Services.AddSingleton<PhoneAuthChallengeStore>();
|
|
||||||
builder.Services.AddSingleton<EmailAuthChallengeStore>();
|
builder.Services.AddSingleton<EmailAuthChallengeStore>();
|
||||||
builder.Services.AddScoped<IEmailCodeSender, SmtpEmailCodeSender>();
|
builder.Services.AddScoped<IEmailCodeSender, SmtpEmailCodeSender>();
|
||||||
builder.Services.AddScoped<DatabaseSeeder>();
|
builder.Services.AddScoped<DatabaseSeeder>();
|
||||||
@@ -228,6 +236,13 @@ app.Use(async (context, next) =>
|
|||||||
app.UseForwardedHeaders();
|
app.UseForwardedHeaders();
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
app.Use(async (context, next) =>
|
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))
|
if (!ShouldEnforceAndroidClientVersion(context.Request))
|
||||||
{
|
{
|
||||||
@@ -269,17 +284,62 @@ app.UseMiddleware<ApiRateLimitMiddleware>();
|
|||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.MapControllers();
|
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);
|
var canConnect = await dbContext.Database.CanConnectAsync(cancellationToken);
|
||||||
return canConnect
|
var dataPath = Path.Combine(environment.ContentRootPath, "Data");
|
||||||
? Results.Ok(new { status = "ok" })
|
var freeDiskBytes = ResolveAvailableDiskSpace(dataPath);
|
||||||
: Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
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.MapHub<MessengerHub>("/hubs/messenger");
|
||||||
|
|
||||||
app.Run();
|
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)
|
static bool ShouldEnforceAndroidClientVersion(HttpRequest request)
|
||||||
{
|
{
|
||||||
if (request.Path.StartsWithSegments("/health", StringComparison.OrdinalIgnoreCase) ||
|
if (request.Path.StartsWithSegments("/health", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
|||||||
@@ -15,6 +15,8 @@
|
|||||||
"ServiceAccountJson": ""
|
"ServiceAccountJson": ""
|
||||||
},
|
},
|
||||||
"WebRtc": {
|
"WebRtc": {
|
||||||
|
"RingingTimeoutSeconds": 60,
|
||||||
|
"MaximumCallDurationHours": 8,
|
||||||
"IceServers": [
|
"IceServers": [
|
||||||
{
|
{
|
||||||
"Urls": [
|
"Urls": [
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ public sealed record MessageDto(
|
|||||||
int? ViewCount = null,
|
int? ViewCount = null,
|
||||||
StoryReplyDto? StoryReply = null,
|
StoryReplyDto? StoryReply = null,
|
||||||
Guid? DiscussionMessageId = null,
|
Guid? DiscussionMessageId = null,
|
||||||
int? CommentCount = null);
|
int? CommentCount = null,
|
||||||
|
Guid? ClientMessageId = null);
|
||||||
|
|
||||||
public sealed record SendMessageRequest(
|
public sealed record SendMessageRequest(
|
||||||
string Text,
|
string Text,
|
||||||
@@ -66,7 +67,8 @@ public sealed record SendMessageRequest(
|
|||||||
Guid? TopicId = null,
|
Guid? TopicId = null,
|
||||||
bool? HasProtectedContent = null,
|
bool? HasProtectedContent = null,
|
||||||
int? TtlSeconds = null,
|
int? TtlSeconds = null,
|
||||||
Guid? StoryReplyStoryId = null);
|
Guid? StoryReplyStoryId = null,
|
||||||
|
Guid? ClientMessageId = null);
|
||||||
|
|
||||||
public sealed record UpdateMessageRequest(string Text);
|
public sealed record UpdateMessageRequest(string Text);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user