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>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/Ikar.Client/Ikar.Client.csproj" />
|
||||
<Project Path="src/Ikar.CodexBridge/Ikar.CodexBridge.csproj" />
|
||||
<Project Path="src/Ikar.Server.Tests/Ikar.Server.Tests.csproj" />
|
||||
<Project Path="src/Ikar.Server/Ikar.Server.csproj" />
|
||||
<Project Path="src/Ikar.Shared/Ikar.Shared.csproj" />
|
||||
</Folder>
|
||||
|
||||
+14
-59
@@ -9,13 +9,7 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ikar.Shared", "src\Ikar.Sha
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ikar.Server", "src\Ikar.Server\Ikar.Server.csproj", "{631B782D-44B2-4680-BC8C-1A0A948A1945}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ikar.Client", "src\Ikar.Client\Ikar.Client.csproj", "{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ikar.WinUI", "src\Ikar.WinUI\Ikar.WinUI.csproj", "{2900F762-FB89-44B7-AF69-D4908EEC50B2}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ikar.CodexBridge", "src\Ikar.CodexBridge\Ikar.CodexBridge.csproj", "{57EC4F75-199D-4FFB-A658-D455AF1F41CF}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ikar.CodexBridge.Tests", "src\Ikar.CodexBridge.Tests\Ikar.CodexBridge.Tests.csproj", "{318E90D6-56ED-491E-8454-22CB84B6FDD3}"
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ikar.Server.Tests", "src\Ikar.Server.Tests\Ikar.Server.Tests.csproj", "{94F7D186-7CAA-4840-9C64-AC4792A1A6C0}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
@@ -51,54 +45,18 @@ Global
|
||||
{631B782D-44B2-4680-BC8C-1A0A948A1945}.Release|x64.Build.0 = Release|Any CPU
|
||||
{631B782D-44B2-4680-BC8C-1A0A948A1945}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{631B782D-44B2-4680-BC8C-1A0A948A1945}.Release|x86.Build.0 = Release|Any CPU
|
||||
{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|x64.Build.0 = Release|Any CPU
|
||||
{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{E5B4217E-FCA8-4944-99A6-12D07E7B5D60}.Release|x86.Build.0 = Release|Any CPU
|
||||
{2900F762-FB89-44B7-AF69-D4908EEC50B2}.Debug|Any CPU.ActiveCfg = Debug|x86
|
||||
{2900F762-FB89-44B7-AF69-D4908EEC50B2}.Debug|Any CPU.Build.0 = Debug|x86
|
||||
{2900F762-FB89-44B7-AF69-D4908EEC50B2}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{2900F762-FB89-44B7-AF69-D4908EEC50B2}.Debug|x64.Build.0 = Debug|x64
|
||||
{2900F762-FB89-44B7-AF69-D4908EEC50B2}.Debug|x86.ActiveCfg = Debug|x86
|
||||
{2900F762-FB89-44B7-AF69-D4908EEC50B2}.Debug|x86.Build.0 = Debug|x86
|
||||
{2900F762-FB89-44B7-AF69-D4908EEC50B2}.Release|Any CPU.ActiveCfg = Release|x86
|
||||
{2900F762-FB89-44B7-AF69-D4908EEC50B2}.Release|Any CPU.Build.0 = Release|x86
|
||||
{2900F762-FB89-44B7-AF69-D4908EEC50B2}.Release|x64.ActiveCfg = Release|x64
|
||||
{2900F762-FB89-44B7-AF69-D4908EEC50B2}.Release|x64.Build.0 = Release|x64
|
||||
{2900F762-FB89-44B7-AF69-D4908EEC50B2}.Release|x86.ActiveCfg = Release|x86
|
||||
{2900F762-FB89-44B7-AF69-D4908EEC50B2}.Release|x86.Build.0 = Release|x86
|
||||
{57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Release|x64.Build.0 = Release|Any CPU
|
||||
{57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{57EC4F75-199D-4FFB-A658-D455AF1F41CF}.Release|x86.Build.0 = Release|Any CPU
|
||||
{318E90D6-56ED-491E-8454-22CB84B6FDD3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{318E90D6-56ED-491E-8454-22CB84B6FDD3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{318E90D6-56ED-491E-8454-22CB84B6FDD3}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{318E90D6-56ED-491E-8454-22CB84B6FDD3}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{318E90D6-56ED-491E-8454-22CB84B6FDD3}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{318E90D6-56ED-491E-8454-22CB84B6FDD3}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{318E90D6-56ED-491E-8454-22CB84B6FDD3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{318E90D6-56ED-491E-8454-22CB84B6FDD3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{318E90D6-56ED-491E-8454-22CB84B6FDD3}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{318E90D6-56ED-491E-8454-22CB84B6FDD3}.Release|x64.Build.0 = Release|Any CPU
|
||||
{318E90D6-56ED-491E-8454-22CB84B6FDD3}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{318E90D6-56ED-491E-8454-22CB84B6FDD3}.Release|x86.Build.0 = Release|Any CPU
|
||||
{94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Release|x64.Build.0 = Release|Any CPU
|
||||
{94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{94F7D186-7CAA-4840-9C64-AC4792A1A6C0}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -106,9 +64,6 @@ Global
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{2FBB67AD-D250-4C69-AEA9-D0F7A18D623B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{631B782D-44B2-4680-BC8C-1A0A948A1945} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{E5B4217E-FCA8-4944-99A6-12D07E7B5D60} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{2900F762-FB89-44B7-AF69-D4908EEC50B2} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{57EC4F75-199D-4FFB-A658-D455AF1F41CF} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{318E90D6-56ED-491E-8454-22CB84B6FDD3} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{94F7D186-7CAA-4840-9C64-AC4792A1A6C0} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -7,9 +7,57 @@ services:
|
||||
context: .
|
||||
dockerfile: Dockerfile.server
|
||||
restart: unless-stopped
|
||||
stop_grace_period: 30s
|
||||
user: "${IKAR_UID:-1000}:${IKAR_GID:-1000}"
|
||||
env_file:
|
||||
- .env.server
|
||||
ports:
|
||||
- "127.0.0.1:5099:5099"
|
||||
volumes:
|
||||
- ${IKAR_DATA_PATH:?IKAR_DATA_PATH must be set in .env}:/app/Data
|
||||
- ${IKAR_BACKUP_PATH:-/mnt/data/ikar-backups}:/backups:ro
|
||||
environment:
|
||||
Backup__Directory: /backups
|
||||
Backup__RequireFreshBackup: "true"
|
||||
WebRtc__IceServers__0__Urls__0: stun:stun.l.google.com:19302
|
||||
WebRtc__IceServers__1__Urls__0: turn:${TURN_HOST:-ikar.kusoft.xyz}:3478?transport=udp
|
||||
WebRtc__IceServers__1__Urls__1: turn:${TURN_HOST:-ikar.kusoft.xyz}:3478?transport=tcp
|
||||
WebRtc__IceServers__1__Username: ${TURN_USERNAME:?TURN_USERNAME must be set in .env}
|
||||
WebRtc__IceServers__1__Credential: ${TURN_PASSWORD:?TURN_PASSWORD must be set in .env}
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
mem_limit: 768m
|
||||
cpus: 2.0
|
||||
|
||||
ikar-turn:
|
||||
container_name: ikar-turn
|
||||
image: coturn/coturn:4.14.0-r0
|
||||
platform: linux/arm64
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
command:
|
||||
- --no-cli
|
||||
- --fingerprint
|
||||
- --lt-cred-mech
|
||||
- --realm=${TURN_REALM:-ikar.kusoft.xyz}
|
||||
- --user=${TURN_USERNAME:?TURN_USERNAME must be set in .env}:${TURN_PASSWORD:?TURN_PASSWORD must be set in .env}
|
||||
- --listening-ip=${TURN_PRIVATE_IP:?TURN_PRIVATE_IP must be set in .env}
|
||||
- --relay-ip=${TURN_PRIVATE_IP:?TURN_PRIVATE_IP must be set in .env}
|
||||
- --external-ip=${TURN_EXTERNAL_IP:?TURN_EXTERNAL_IP must be set in .env}/${TURN_PRIVATE_IP:?TURN_PRIVATE_IP must be set in .env}
|
||||
- --listening-port=3478
|
||||
- --min-port=49160
|
||||
- --max-port=49200
|
||||
- --no-tls
|
||||
- --no-dtls
|
||||
- --no-multicast-peers
|
||||
- --log-file=stdout
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
mem_limit: 256m
|
||||
cpus: 1.0
|
||||
|
||||
@@ -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"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 209
|
||||
versionName = "3.96"
|
||||
versionCode = 211
|
||||
versionName = "3.98"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
@@ -57,7 +57,8 @@ android {
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
isMinifyEnabled = true
|
||||
isShrinkResources = true
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro"
|
||||
@@ -123,4 +124,5 @@ dependencies {
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
debugImplementation("androidx.compose.ui:ui-test-manifest")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
}
|
||||
|
||||
+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
|
||||
android:name=".app.IkarNativeApp"
|
||||
android:allowBackup="true"
|
||||
android:allowBackup="false"
|
||||
android:fullBackupContent="false"
|
||||
android:icon="@drawable/appicon"
|
||||
android:label="Икар"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@drawable/appicon"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@android:style/Theme.Material.Light.NoActionBar">
|
||||
|
||||
@@ -61,7 +61,7 @@ class AppContainer(context: Context) {
|
||||
}
|
||||
|
||||
private val loggingInterceptor = HttpLoggingInterceptor().apply {
|
||||
level = HttpLoggingInterceptor.Level.BASIC
|
||||
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC else HttpLoggingInterceptor.Level.NONE
|
||||
}
|
||||
|
||||
val pushSettings = AndroidPushSettings(appContext)
|
||||
|
||||
+8
-2
@@ -9,7 +9,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
|
||||
|
||||
@Database(
|
||||
entities = [CachedChatMessageEntity::class, CachedChatEntity::class, TransferQueueEntity::class],
|
||||
version = 4,
|
||||
version = 5,
|
||||
exportSchema = false
|
||||
)
|
||||
abstract class IkarLocalDatabase : RoomDatabase() {
|
||||
@@ -79,6 +79,12 @@ abstract class IkarLocalDatabase : RoomDatabase() {
|
||||
}
|
||||
}
|
||||
|
||||
private val Migration4To5 = object : Migration(4, 5) {
|
||||
override fun migrate(db: SupportSQLiteDatabase) {
|
||||
db.execSQL("ALTER TABLE transfer_queue ADD COLUMN clientMessageId TEXT")
|
||||
}
|
||||
}
|
||||
|
||||
fun getInstance(context: Context): IkarLocalDatabase =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: Room.databaseBuilder(
|
||||
@@ -86,7 +92,7 @@ abstract class IkarLocalDatabase : RoomDatabase() {
|
||||
IkarLocalDatabase::class.java,
|
||||
"ikar-local.db"
|
||||
)
|
||||
.addMigrations(Migration1To2, Migration2To3, Migration3To4)
|
||||
.addMigrations(Migration1To2, Migration2To3, Migration3To4, Migration4To5)
|
||||
.build()
|
||||
.also { instance = it }
|
||||
}
|
||||
|
||||
+8
@@ -17,6 +17,7 @@ data class TransferQueueEntity(
|
||||
val chatId: String?,
|
||||
val text: String?,
|
||||
val replyToMessageId: String?,
|
||||
val clientMessageId: String?,
|
||||
val attachmentId: String?,
|
||||
val storyVisibility: String?,
|
||||
val hasProtectedContent: Boolean,
|
||||
@@ -40,6 +41,12 @@ interface TransferQueueDao {
|
||||
@Query("SELECT * FROM transfer_queue WHERE groupId = :groupId ORDER BY sortOrder ASC")
|
||||
suspend fun getGroup(groupId: String): List<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")
|
||||
fun observeActiveForChat(chatId: String): Flow<List<TransferQueueEntity>>
|
||||
|
||||
@@ -60,6 +67,7 @@ interface TransferQueueDao {
|
||||
}
|
||||
|
||||
object TransferQueueTypes {
|
||||
const val TextMessage = "text_message"
|
||||
const val Upload = "upload"
|
||||
const val StoryUpload = "story_upload"
|
||||
const val Download = "download"
|
||||
|
||||
+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 storyReply: StoryReplyDto? = null,
|
||||
val discussionMessageId: String? = null,
|
||||
val commentCount: Int? = null
|
||||
val commentCount: Int? = null,
|
||||
val clientMessageId: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -608,7 +609,8 @@ data class SendMessageRequest(
|
||||
val topicId: String? = null,
|
||||
val hasProtectedContent: Boolean? = null,
|
||||
val ttlSeconds: Int? = null,
|
||||
val storyReplyStoryId: String? = null
|
||||
val storyReplyStoryId: String? = null,
|
||||
val clientMessageId: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
+23
-5
@@ -648,7 +648,8 @@ class ApiClient(
|
||||
topicId: String? = null,
|
||||
hasProtectedContent: Boolean? = null,
|
||||
ttlSeconds: Int? = null,
|
||||
storyReplyStoryId: String? = null
|
||||
storyReplyStoryId: String? = null,
|
||||
clientMessageId: String? = null
|
||||
): MessageDto =
|
||||
executeJsonRequest(
|
||||
Request.Builder()
|
||||
@@ -662,7 +663,8 @@ class ApiClient(
|
||||
topicId = topicId,
|
||||
hasProtectedContent = hasProtectedContent,
|
||||
ttlSeconds = ttlSeconds,
|
||||
storyReplyStoryId = storyReplyStoryId
|
||||
storyReplyStoryId = storyReplyStoryId,
|
||||
clientMessageId = clientMessageId
|
||||
)
|
||||
).toRequestBody(JSON)
|
||||
)
|
||||
@@ -886,10 +888,12 @@ class ApiClient(
|
||||
?: attachment.fileSizeBytes.takeIf { it >= 0L }
|
||||
?: -1L
|
||||
targetFile.parentFile?.mkdirs()
|
||||
val partialFile = File(targetFile.parentFile ?: File("."), "${targetFile.name}.part")
|
||||
partialFile.delete()
|
||||
var bytesRead = 0L
|
||||
body.byteStream().use { input ->
|
||||
targetFile.outputStream().use { output ->
|
||||
partialFile.outputStream().use { output ->
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var bytesRead = 0L
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) {
|
||||
@@ -900,9 +904,21 @@ class ApiClient(
|
||||
onProgress?.invoke(bytesRead, totalBytes)
|
||||
}
|
||||
}
|
||||
|
||||
if (totalBytes > 0L && bytesRead != totalBytes) {
|
||||
throw IOException("Incomplete attachment download.")
|
||||
}
|
||||
}
|
||||
|
||||
DownloadedAttachmentFile(
|
||||
if (targetFile.exists() && !targetFile.delete()) {
|
||||
throw IOException("Could not replace cached attachment.")
|
||||
}
|
||||
if (!partialFile.renameTo(targetFile)) {
|
||||
partialFile.copyTo(targetFile, overwrite = true)
|
||||
partialFile.delete()
|
||||
}
|
||||
|
||||
DownloadedAttachmentFile(
|
||||
fileName = attachment.fileName,
|
||||
contentType = body.contentType()?.toString()
|
||||
?: response.header("Content-Type")
|
||||
@@ -912,6 +928,8 @@ class ApiClient(
|
||||
)
|
||||
}
|
||||
} catch (error: IOException) {
|
||||
val partialFile = File(targetFile.parentFile ?: File("."), "${targetFile.name}.part")
|
||||
partialFile.delete()
|
||||
throw ApiException(-1, networkErrorMessage())
|
||||
}
|
||||
}
|
||||
|
||||
+35
@@ -3,10 +3,12 @@ package com.seven.ikar.kotlin.core.push
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.RemoteInput
|
||||
import com.seven.ikar.kotlin.app.IkarNativeApp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.UUID
|
||||
|
||||
class NotificationActionReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
@@ -24,11 +26,42 @@ class NotificationActionReceiver : BroadcastReceiver() {
|
||||
val action = intent.action.orEmpty()
|
||||
when (action) {
|
||||
NotificationAction.MarkReadAction -> handleMarkRead(context, intent)
|
||||
NotificationAction.InlineReplyAction -> handleInlineReply(context, intent)
|
||||
NotificationAction.AcceptIncomingCallAction -> handleAcceptIncomingCall(context, intent)
|
||||
NotificationAction.DeclineIncomingCallAction -> handleDeclineIncomingCall(context, intent)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleInlineReply(context: Context, intent: Intent) {
|
||||
val chatId = intent.getStringExtra(PushDataKeys.ChatId).orEmpty()
|
||||
val notificationId = intent.getIntExtra(NotificationAction.NotificationIdExtra, Int.MIN_VALUE)
|
||||
val text = RemoteInput.getResultsFromIntent(intent)
|
||||
?.getCharSequence(NotificationAction.InlineReplyResultKey)
|
||||
?.toString()
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
if (chatId.isBlank() || text.isBlank()) {
|
||||
return
|
||||
}
|
||||
|
||||
val app = context.applicationContext as? IkarNativeApp ?: return
|
||||
runCatching {
|
||||
app.container.executeAuthorized { session ->
|
||||
app.container.apiClient.sendMessage(
|
||||
chatId = chatId,
|
||||
text = text,
|
||||
accessToken = session.accessToken,
|
||||
clientMessageId = UUID.randomUUID().toString()
|
||||
)
|
||||
}
|
||||
}.onSuccess {
|
||||
app.container.realtimeService.notifyChatsChangedLocally()
|
||||
if (notificationId != Int.MIN_VALUE) {
|
||||
ChatNotificationDismissalManager.cancelNotification(context, chatId, notificationId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun handleMarkRead(context: Context, intent: Intent) {
|
||||
val chatId = intent.getStringExtra(PushDataKeys.ChatId).orEmpty()
|
||||
val messageId = intent.getStringExtra(PushDataKeys.MessageId).orEmpty()
|
||||
@@ -92,7 +125,9 @@ class NotificationActionReceiver : BroadcastReceiver() {
|
||||
|
||||
object NotificationAction {
|
||||
const val MarkReadAction = "com.seven.ikar.kotlin.NOTIFICATION_MARK_READ"
|
||||
const val InlineReplyAction = "com.seven.ikar.kotlin.NOTIFICATION_INLINE_REPLY"
|
||||
const val AcceptIncomingCallAction = "com.seven.ikar.kotlin.NOTIFICATION_ACCEPT_INCOMING_CALL"
|
||||
const val DeclineIncomingCallAction = "com.seven.ikar.kotlin.NOTIFICATION_DECLINE_INCOMING_CALL"
|
||||
const val NotificationIdExtra = "notificationId"
|
||||
const val InlineReplyResultKey = "inlineReplyText"
|
||||
}
|
||||
|
||||
+25
-4
@@ -11,6 +11,7 @@ import android.os.Vibrator
|
||||
import android.os.VibratorManager
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.RemoteInput
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.seven.ikar.kotlin.BuildConfig
|
||||
import com.seven.ikar.kotlin.MainActivity
|
||||
@@ -218,11 +219,20 @@ object PushNotificationDisplayService {
|
||||
}
|
||||
|
||||
val pendingIntent = PendingIntent.getActivity(context, notificationId, intent, flags)
|
||||
val replyPendingIntent = PendingIntent.getActivity(
|
||||
val replyFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||
} else {
|
||||
PendingIntent.FLAG_UPDATE_CURRENT
|
||||
}
|
||||
val replyPendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
notificationId + 1,
|
||||
intent,
|
||||
flags
|
||||
Intent(context, NotificationActionReceiver::class.java).apply {
|
||||
action = NotificationAction.InlineReplyAction
|
||||
putExtra(PushDataKeys.ChatId, data[PushDataKeys.ChatId])
|
||||
putExtra(NotificationAction.NotificationIdExtra, notificationId)
|
||||
},
|
||||
replyFlags
|
||||
)
|
||||
val markReadPendingIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
@@ -292,8 +302,19 @@ object PushNotificationDisplayService {
|
||||
!data[PushDataKeys.ChatId].isNullOrBlank() &&
|
||||
!data[PushDataKeys.MessageId].isNullOrBlank()
|
||||
) {
|
||||
val remoteInput = RemoteInput.Builder(NotificationAction.InlineReplyResultKey)
|
||||
.setLabel("Ответ")
|
||||
.build()
|
||||
val replyAction = NotificationCompat.Action.Builder(
|
||||
android.R.drawable.ic_menu_send,
|
||||
"Ответить",
|
||||
replyPendingIntent
|
||||
)
|
||||
.addRemoteInput(remoteInput)
|
||||
.setAllowGeneratedReplies(true)
|
||||
.build()
|
||||
notificationBuilder
|
||||
.addAction(android.R.drawable.ic_menu_send, "Ответить", replyPendingIntent)
|
||||
.addAction(replyAction)
|
||||
.addAction(android.R.drawable.ic_menu_view, "Отметить как прочитанное", markReadPendingIntent)
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -53,6 +53,9 @@ class RealtimeService(
|
||||
private val _stage = MutableStateFlow(RealtimeConnectionStage.Disconnected)
|
||||
val stage: StateFlow<RealtimeConnectionStage> = _stage
|
||||
|
||||
private val _connectionRestored = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val connectionRestored: SharedFlow<Unit> = _connectionRestored
|
||||
|
||||
private val _messagesCreated = MutableSharedFlow<MessageDto>(extraBufferCapacity = 32)
|
||||
val messagesCreated: SharedFlow<MessageDto> = _messagesCreated
|
||||
|
||||
@@ -209,6 +212,7 @@ class RealtimeService(
|
||||
newConnection.start().blockingAwait()
|
||||
}.onSuccess {
|
||||
_stage.value = RealtimeConnectionStage.Connected
|
||||
_connectionRestored.tryEmit(Unit)
|
||||
}.onFailure {
|
||||
if (connection == newConnection) {
|
||||
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.SharedPreferences
|
||||
import com.seven.ikar.kotlin.core.model.AuthSessionDto
|
||||
import com.seven.ikar.kotlin.core.security.AndroidKeystoreCipher
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
class SessionStore(context: Context, private val json: Json) {
|
||||
private val preferences: SharedPreferences =
|
||||
context.getSharedPreferences("ikar_kotlin_session", Context.MODE_PRIVATE)
|
||||
private val cipher = AndroidKeystoreCipher("ikar.session.v1")
|
||||
|
||||
fun read(): AuthSessionDto? {
|
||||
val raw = preferences.getString(KEY_SESSION, null) ?: return null
|
||||
return runCatching { json.decodeFromString<AuthSessionDto>(raw) }.getOrNull()
|
||||
val encrypted = preferences.getString(KEY_ENCRYPTED_SESSION, null)
|
||||
if (encrypted != null) {
|
||||
val raw = cipher.decrypt(encrypted) ?: run {
|
||||
clear()
|
||||
return null
|
||||
}
|
||||
return decode(raw)
|
||||
}
|
||||
|
||||
val legacy = preferences.getString(KEY_LEGACY_SESSION, null) ?: return null
|
||||
val session = decode(legacy) ?: run {
|
||||
clear()
|
||||
return null
|
||||
}
|
||||
save(session)
|
||||
return session
|
||||
}
|
||||
|
||||
fun save(session: AuthSessionDto) {
|
||||
val encrypted = cipher.encrypt(json.encodeToString(AuthSessionDto.serializer(), session))
|
||||
preferences.edit()
|
||||
.putString(KEY_SESSION, json.encodeToString(AuthSessionDto.serializer(), session))
|
||||
.apply()
|
||||
.putString(KEY_ENCRYPTED_SESSION, encrypted)
|
||||
.remove(KEY_LEGACY_SESSION)
|
||||
.commit()
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
preferences.edit().remove(KEY_SESSION).apply()
|
||||
preferences.edit()
|
||||
.remove(KEY_ENCRYPTED_SESSION)
|
||||
.remove(KEY_LEGACY_SESSION)
|
||||
.commit()
|
||||
}
|
||||
|
||||
private fun decode(raw: String): AuthSessionDto? =
|
||||
runCatching { json.decodeFromString<AuthSessionDto>(raw) }.getOrNull()
|
||||
|
||||
companion object {
|
||||
private const val KEY_SESSION = "session"
|
||||
private const val KEY_ENCRYPTED_SESSION = "encrypted_session"
|
||||
private const val KEY_LEGACY_SESSION = "session"
|
||||
}
|
||||
}
|
||||
|
||||
+25
-2
@@ -1,6 +1,7 @@
|
||||
package com.seven.ikar.kotlin.core.settings
|
||||
|
||||
import android.content.Context
|
||||
import com.seven.ikar.kotlin.core.security.AndroidKeystoreCipher
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
@@ -46,6 +47,7 @@ data class AppSettingsSnapshot(
|
||||
|
||||
class AndroidAppSettings(context: Context) {
|
||||
private val preferences = context.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
|
||||
private val secretCipher = AndroidKeystoreCipher("ikar.settings.v1")
|
||||
private val _snapshot = MutableStateFlow(loadSnapshot())
|
||||
val snapshot: StateFlow<AppSettingsSnapshot> = _snapshot.asStateFlow()
|
||||
|
||||
@@ -68,7 +70,7 @@ class AndroidAppSettings(context: Context) {
|
||||
proxyType = getEnum(KEY_PROXY_TYPE, AppProxyType.Socks5).supportedProxyType(),
|
||||
proxyHost = preferences.getString(KEY_PROXY_HOST, "").orEmpty(),
|
||||
proxyPort = preferences.getInt(KEY_PROXY_PORT, 1080).coerceIn(1, 65_535),
|
||||
proxySecret = preferences.getString(KEY_PROXY_SECRET, "").orEmpty()
|
||||
proxySecret = readProxySecret()
|
||||
)
|
||||
|
||||
fun update(transform: (AppSettingsSnapshot) -> AppSettingsSnapshot) {
|
||||
@@ -99,7 +101,11 @@ class AndroidAppSettings(context: Context) {
|
||||
putString(KEY_PROXY_TYPE, sanitized.proxyType.name)
|
||||
putString(KEY_PROXY_HOST, sanitized.proxyHost)
|
||||
putInt(KEY_PROXY_PORT, sanitized.proxyPort)
|
||||
putString(KEY_PROXY_SECRET, sanitized.proxySecret)
|
||||
putString(
|
||||
KEY_ENCRYPTED_PROXY_SECRET,
|
||||
sanitized.proxySecret.takeIf(String::isNotEmpty)?.let(secretCipher::encrypt)
|
||||
)
|
||||
remove(KEY_PROXY_SECRET)
|
||||
apply()
|
||||
}
|
||||
_snapshot.value = sanitized
|
||||
@@ -117,6 +123,22 @@ class AndroidAppSettings(context: Context) {
|
||||
?: defaultValue
|
||||
}
|
||||
|
||||
private fun readProxySecret(): String {
|
||||
val encrypted = preferences.getString(KEY_ENCRYPTED_PROXY_SECRET, null)
|
||||
if (encrypted != null) {
|
||||
return secretCipher.decrypt(encrypted).orEmpty()
|
||||
}
|
||||
|
||||
val legacy = preferences.getString(KEY_PROXY_SECRET, "").orEmpty()
|
||||
if (legacy.isNotEmpty()) {
|
||||
preferences.edit()
|
||||
.putString(KEY_ENCRYPTED_PROXY_SECRET, secretCipher.encrypt(legacy))
|
||||
.remove(KEY_PROXY_SECRET)
|
||||
.commit()
|
||||
}
|
||||
return legacy
|
||||
}
|
||||
|
||||
private fun AppProxyType.supportedProxyType(): AppProxyType = when (this) {
|
||||
AppProxyType.MtProto -> AppProxyType.Socks5
|
||||
else -> this
|
||||
@@ -141,5 +163,6 @@ class AndroidAppSettings(context: Context) {
|
||||
private const val KEY_PROXY_HOST = "proxy_host"
|
||||
private const val KEY_PROXY_PORT = "proxy_port"
|
||||
private const val KEY_PROXY_SECRET = "proxy_secret"
|
||||
private const val KEY_ENCRYPTED_PROXY_SECRET = "encrypted_proxy_secret"
|
||||
}
|
||||
}
|
||||
|
||||
+65
-16
@@ -25,6 +25,7 @@ import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -3681,20 +3682,73 @@ private fun InlineMessageImage(
|
||||
onClick: () -> Unit,
|
||||
onLongPress: () -> Unit
|
||||
) {
|
||||
AuthenticatedAsyncImage(
|
||||
ChatPhotoImage(
|
||||
imageUrl = imageUrl,
|
||||
accessToken = "",
|
||||
contentDescription = "",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp, max = 420.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongPress)
|
||||
.background(Color(0xFFF7FAFD)),
|
||||
contentScale = ContentScale.Fit
|
||||
onClick = onClick,
|
||||
onLongPress = onLongPress
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ChatPhotoImage(
|
||||
imageUrl: String?,
|
||||
accessToken: String?,
|
||||
contentDescription: String,
|
||||
modifier: Modifier = Modifier,
|
||||
onClick: () -> Unit,
|
||||
onLongPress: () -> Unit
|
||||
) {
|
||||
var imageAspectRatio by remember(imageUrl) { mutableStateOf<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)
|
||||
@Composable
|
||||
private fun InlineMessageAudio(
|
||||
@@ -3888,17 +3942,12 @@ private fun AttachmentCard(
|
||||
) {
|
||||
when {
|
||||
attachment.isImageAttachment() -> {
|
||||
AuthenticatedAsyncImage(
|
||||
ChatPhotoImage(
|
||||
imageUrl = attachmentUrl,
|
||||
accessToken = accessToken,
|
||||
contentDescription = attachment.fileName,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 120.dp, max = 420.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongPress)
|
||||
.background(Color(0xFFF7FAFD)),
|
||||
contentScale = ContentScale.Fit
|
||||
onClick = onClick,
|
||||
onLongPress = onLongPress
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+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.ChatDetailsDto
|
||||
import com.seven.ikar.kotlin.core.model.ChatType
|
||||
import com.seven.ikar.kotlin.core.model.ClientMessageIds
|
||||
import com.seven.ikar.kotlin.core.model.MessageDeliveryState
|
||||
import com.seven.ikar.kotlin.core.model.MessageDto
|
||||
import com.seven.ikar.kotlin.core.model.MessageReplyDto
|
||||
@@ -26,6 +27,8 @@ import com.seven.ikar.kotlin.core.model.UserSummaryDto
|
||||
import com.seven.ikar.kotlin.core.network.ServerConfig
|
||||
import com.seven.ikar.kotlin.core.network.UploadFileSource
|
||||
import com.seven.ikar.kotlin.core.local.TransferQueueEntity
|
||||
import com.seven.ikar.kotlin.core.local.TransferQueueStatus
|
||||
import com.seven.ikar.kotlin.core.local.TransferQueueTypes
|
||||
import com.seven.ikar.kotlin.feature.call.AudioCallSnapshot
|
||||
import com.seven.ikar.kotlin.feature.call.AudioCallStage
|
||||
import com.seven.ikar.kotlin.feature.transfer.TransferForegroundNotification
|
||||
@@ -164,7 +167,6 @@ private val DefaultCodexBridgeBotCommands = listOf(
|
||||
|
||||
private val BotThreadOptionRegex = Regex("""^(?<index>\d+)\.\s+(?<title>.+?)(?<selected>\s+\[выбран])?$""")
|
||||
private val ChatTimestampFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm", Locale("ru", "RU"))
|
||||
private const val LocalMessageIdPrefix = "local-"
|
||||
|
||||
class ChatViewModel(
|
||||
private val container: AppContainer,
|
||||
@@ -703,14 +705,14 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
draftText.isNotBlank() -> {
|
||||
container.executeAuthorized { session ->
|
||||
container.apiClient.sendMessage(
|
||||
chatId = chatId,
|
||||
text = draftText,
|
||||
accessToken = session.accessToken,
|
||||
replyToMessageId = replyToMessageId
|
||||
)
|
||||
}
|
||||
val clientMessageId = optimisticMessage?.clientMessageId ?: UUID.randomUUID().toString()
|
||||
container.transferQueueRepository.enqueueTextMessage(
|
||||
chatId = chatId,
|
||||
text = draftText,
|
||||
replyToMessageId = replyToMessageId,
|
||||
clientMessageId = clientMessageId
|
||||
)
|
||||
null
|
||||
}
|
||||
|
||||
else -> null
|
||||
@@ -1315,10 +1317,10 @@ class ChatViewModel(
|
||||
val cachedFile = cachedAttachmentFiles[attachment.id]
|
||||
?.localPath
|
||||
?.let(::File)
|
||||
?.takeIf { it.exists() }
|
||||
?.takeIf { it.isCompleteCacheFileFor(attachment) }
|
||||
?: container.attachmentFileService
|
||||
.createAttachmentCacheFile(attachment.id, attachment.fileName)
|
||||
.takeIf { it.exists() }
|
||||
.takeIf { it.isCompleteCacheFileFor(attachment) }
|
||||
?.also { file ->
|
||||
cachedAttachmentFiles[attachment.id] = LocalAttachmentFile(
|
||||
fileName = attachment.fileName,
|
||||
@@ -1419,8 +1421,9 @@ class ChatViewModel(
|
||||
|
||||
private fun buildOptimisticMessage(state: ChatUiState, text: String): MessageDto? {
|
||||
val session = state.session ?: container.sessionStore.read() ?: return null
|
||||
val clientMessageId = ClientMessageIds.newClientId()
|
||||
return MessageDto(
|
||||
id = "$LocalMessageIdPrefix${UUID.randomUUID()}",
|
||||
id = ClientMessageIds.localId(clientMessageId),
|
||||
chatId = chatId,
|
||||
chatType = state.chatType,
|
||||
sender = session.user,
|
||||
@@ -1431,13 +1434,17 @@ class ChatViewModel(
|
||||
replyTo = state.replyTo,
|
||||
postedAsChannel = state.chatType == ChatType.Channel,
|
||||
displayAuthorName = if (state.chatType == ChatType.Channel) state.title else session.user.displayName,
|
||||
viewCount = if (state.chatType == ChatType.Channel) 0 else null
|
||||
viewCount = if (state.chatType == ChatType.Channel) 0 else null,
|
||||
clientMessageId = clientMessageId
|
||||
)
|
||||
}
|
||||
|
||||
private fun applyChat(chat: ChatDetailsDto, session: AuthSessionDto) {
|
||||
val previousState = _uiState.value
|
||||
val localMessages = previousState.messages.filter { it.isLocalMessage() }
|
||||
val receivedClientMessageIds = chat.messages.mapNotNullTo(mutableSetOf(), MessageDto::clientMessageId)
|
||||
val localMessages = previousState.messages.filter { message ->
|
||||
message.isLocalMessage() && message.clientMessageId !in receivedClientMessageIds
|
||||
}
|
||||
val orderedMessages = (chat.messages + localMessages)
|
||||
.distinctBy { it.id }
|
||||
.sortedBy { it.sentAt }
|
||||
@@ -1530,6 +1537,11 @@ class ChatViewModel(
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
container.realtimeService.connectionRestored.collect {
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
container.realtimeService.typingChanged.collect(::handleTypingChanged)
|
||||
}
|
||||
@@ -1659,7 +1671,25 @@ class ChatViewModel(
|
||||
private fun attachTransferQueueState() {
|
||||
viewModelScope.launch {
|
||||
container.transferQueueRepository.observeActiveForChat(chatId).collect { items ->
|
||||
_uiState.value = _uiState.value.copy(activeTransfers = items.toTransferQueueUiState())
|
||||
val currentState = _uiState.value
|
||||
val textItems = items.filter { it.type == TransferQueueTypes.TextMessage }
|
||||
val activeTextLocalIds = textItems.mapNotNullTo(mutableSetOf()) { item ->
|
||||
item.clientMessageId?.let(ClientMessageIds::localId)
|
||||
}
|
||||
val failedTextLocalIds = textItems
|
||||
.filter { it.status == TransferQueueStatus.Failed }
|
||||
.mapNotNullTo(mutableSetOf()) { item ->
|
||||
item.clientMessageId?.let(ClientMessageIds::localId)
|
||||
}
|
||||
_uiState.value = currentState.copy(
|
||||
activeTransfers = items
|
||||
.filterNot { it.type == TransferQueueTypes.TextMessage }
|
||||
.toTransferQueueUiState(),
|
||||
sendingLocalMessageIds = currentState.sendingLocalMessageIds +
|
||||
(activeTextLocalIds - failedTextLocalIds),
|
||||
failedLocalMessageIds = (currentState.failedLocalMessageIds - activeTextLocalIds) +
|
||||
failedTextLocalIds
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1670,10 +1700,16 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
private suspend fun ensureAttachmentDownloaded(attachment: AttachmentDto): LocalAttachmentFile {
|
||||
cachedAttachmentFiles[attachment.id]?.let { return it }
|
||||
cachedAttachmentFiles[attachment.id]
|
||||
?.takeIf { File(it.localPath).isCompleteCacheFileFor(attachment) }
|
||||
?.let { return it }
|
||||
?: cachedAttachmentFiles.remove(attachment.id)
|
||||
|
||||
val target = container.attachmentFileService.createAttachmentCacheFile(attachment.id, attachment.fileName)
|
||||
if (target.exists()) {
|
||||
if (target.exists() && !target.isCompleteCacheFileFor(attachment)) {
|
||||
target.delete()
|
||||
}
|
||||
if (target.isCompleteCacheFileFor(attachment)) {
|
||||
val cached = LocalAttachmentFile(
|
||||
fileName = attachment.fileName,
|
||||
contentType = attachment.contentType,
|
||||
@@ -1686,8 +1722,20 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
if (attachment.fileSizeBytes >= TransferForegroundNotification.LargeTransferThresholdBytes) {
|
||||
container.transferQueueRepository.enqueueDownload(chatId, attachment)
|
||||
throw IllegalStateException("Large download queued. Try opening it again when the transfer completes.")
|
||||
val groupId = container.transferQueueRepository.enqueueDownload(chatId, attachment)
|
||||
container.transferQueueRepository.awaitCompletion(groupId)
|
||||
if (!target.isCompleteCacheFileFor(attachment)) {
|
||||
throw IllegalStateException("Downloaded attachment is incomplete.")
|
||||
}
|
||||
return LocalAttachmentFile(
|
||||
fileName = attachment.fileName,
|
||||
contentType = attachment.contentType,
|
||||
fileSizeBytes = target.length(),
|
||||
localPath = target.absolutePath
|
||||
).also { cached ->
|
||||
cachedAttachmentFiles[attachment.id] = cached
|
||||
notifyAttachmentCacheChanged()
|
||||
}
|
||||
}
|
||||
|
||||
return container.executeAuthorized { session ->
|
||||
@@ -1727,8 +1775,15 @@ class ChatViewModel(
|
||||
.filterNot { it.hasProtectedContent || it.isDeleted() }
|
||||
.flatMap { it.attachments.asSequence() }
|
||||
.filter { attachment -> shouldAutoDownload(attachment, settings, isUnmetered) }
|
||||
.filterNot { attachment -> cachedAttachmentFiles.containsKey(attachment.id) }
|
||||
.filterNot { attachment -> container.attachmentFileService.createAttachmentCacheFile(attachment.id, attachment.fileName).exists() }
|
||||
.filterNot { attachment ->
|
||||
cachedAttachmentFiles[attachment.id]
|
||||
?.let { File(it.localPath).isCompleteCacheFileFor(attachment) } == true
|
||||
}
|
||||
.filterNot { attachment ->
|
||||
container.attachmentFileService
|
||||
.createAttachmentCacheFile(attachment.id, attachment.fileName)
|
||||
.isCompleteCacheFileFor(attachment)
|
||||
}
|
||||
.take(8)
|
||||
.forEach { attachment ->
|
||||
viewModelScope.launch {
|
||||
@@ -1760,6 +1815,20 @@ class ChatViewModel(
|
||||
message.hasProtectedContent && message.attachments.any { it.id == attachment.id }
|
||||
}
|
||||
|
||||
private fun File.isCompleteCacheFileFor(attachment: AttachmentDto): Boolean {
|
||||
if (!isFile) {
|
||||
return false
|
||||
}
|
||||
|
||||
val actualSize = length()
|
||||
if (actualSize <= 0L) {
|
||||
return false
|
||||
}
|
||||
|
||||
val expectedSize = attachment.fileSizeBytes
|
||||
return expectedSize <= 0L || actualSize == expectedSize
|
||||
}
|
||||
|
||||
private fun toggleVoiceAttachmentPlayback(attachment: AttachmentDto) {
|
||||
viewModelScope.launch {
|
||||
runCatching {
|
||||
@@ -1881,13 +1950,19 @@ class ChatViewModel(
|
||||
}
|
||||
|
||||
private fun findMatchingPendingLocalMessageId(state: ChatUiState, serverMessage: MessageDto): String? {
|
||||
if (serverMessage.isLocalMessage() || state.sendingLocalMessageIds.isEmpty()) {
|
||||
if (serverMessage.isLocalMessage()) {
|
||||
return null
|
||||
}
|
||||
|
||||
serverMessage.clientMessageId?.let { clientMessageId ->
|
||||
state.messages.firstOrNull { localMessage ->
|
||||
localMessage.isLocalMessage() && localMessage.clientMessageId == clientMessageId
|
||||
}?.let { return it.id }
|
||||
}
|
||||
|
||||
return state.messages.firstOrNull { localMessage ->
|
||||
state.sendingLocalMessageIds.contains(localMessage.id) &&
|
||||
localMessage.sender.id == serverMessage.sender.id &&
|
||||
localMessage.isLocalMessage() &&
|
||||
localMessage.sender.id == serverMessage.sender.id &&
|
||||
localMessage.text == serverMessage.text &&
|
||||
localMessage.attachments.isEmpty() &&
|
||||
serverMessage.attachments.isEmpty() &&
|
||||
@@ -2097,7 +2172,7 @@ internal fun MessageDto.canManage(currentUserId: String?, canPostAsChannel: Bool
|
||||
internal fun MessageDto.canDelete(currentUserId: String?): Boolean =
|
||||
currentUserId != null && deletedAt == null
|
||||
|
||||
private fun MessageDto.isLocalMessage(): Boolean = id.startsWith(LocalMessageIdPrefix)
|
||||
private fun MessageDto.isLocalMessage(): Boolean = id.startsWith(ClientMessageIds.LocalPrefix)
|
||||
|
||||
private fun searchLoadedMessageIds(query: String, messages: List<MessageDto>): List<String> {
|
||||
val normalizedQuery = query.trim()
|
||||
|
||||
+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.StoryDto
|
||||
import com.seven.ikar.kotlin.core.network.ServerConfig
|
||||
import com.seven.ikar.kotlin.core.realtime.RealtimeConnectionStage
|
||||
import com.seven.ikar.kotlin.core.settings.ChatListLayoutOption
|
||||
import com.seven.ikar.kotlin.core.settings.ChatSettingsSnapshot
|
||||
import com.seven.ikar.kotlin.core.settings.ChatSwipeLeftActionOption
|
||||
@@ -125,7 +126,7 @@ fun ChatsScreen(
|
||||
onFilterChanged: (ChatListFilter) -> Unit,
|
||||
onQueryChanged: (String) -> Unit
|
||||
) {
|
||||
val isRealtimeConnected = !state.isLoading || state.chats.isNotEmpty()
|
||||
val isRealtimeConnected = state.realtimeStage == RealtimeConnectionStage.Connected
|
||||
val previewMaxLines = if (chatSettings.chatListLayout == ChatListLayoutOption.ThreeLine) 2 else 1
|
||||
val chatListState = rememberLazyListState()
|
||||
var isStoriesHeaderVisible by remember { mutableStateOf(false) }
|
||||
@@ -188,8 +189,8 @@ fun ChatsScreen(
|
||||
.nestedScroll(storyHeaderNestedScrollConnection)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(start = 18.dp, top = 16.dp, end = 18.dp, bottom = 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||
modifier = Modifier.padding(start = 14.dp, top = 8.dp, end = 14.dp, bottom = 6.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
when {
|
||||
state.isSelectionMode && state.isShareMode -> {
|
||||
@@ -247,8 +248,11 @@ fun ChatsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
TextButton(onClick = onGlobalSearchClick) {
|
||||
Text("Глобальный поиск: сообщения, медиа, файлы и ссылки")
|
||||
TextButton(
|
||||
onClick = onGlobalSearchClick,
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 4.dp, vertical = 0.dp)
|
||||
) {
|
||||
Text("Поиск по сообщениям и файлам", style = MaterialTheme.typography.labelMedium)
|
||||
}
|
||||
|
||||
state.pendingShare?.let { pendingShare ->
|
||||
@@ -327,7 +331,7 @@ fun ChatsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
item { Box(modifier = Modifier.padding(bottom = 132.dp)) }
|
||||
item { Box(modifier = Modifier.padding(bottom = 104.dp)) }
|
||||
}
|
||||
|
||||
if (!state.isSelectionMode && !state.isShareMode) {
|
||||
@@ -336,7 +340,7 @@ fun ChatsScreen(
|
||||
contentDescription = "Новое сообщение",
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(end = 20.dp, bottom = 126.dp),
|
||||
.padding(end = 16.dp, bottom = 98.dp),
|
||||
onClick = onDiscoverClick
|
||||
)
|
||||
}
|
||||
@@ -347,7 +351,7 @@ fun ChatsScreen(
|
||||
selectedTab = IkarMainMenuTab.Chats,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(horizontal = 8.dp, vertical = 18.dp),
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
onChatsClick = {},
|
||||
onContactsClick = onContactsClick,
|
||||
onSettingsClick = onSettingsClick,
|
||||
@@ -412,7 +416,7 @@ private fun ChatsBrandHeader(
|
||||
onStoriesClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
StoriesClusterButton(
|
||||
@@ -424,12 +428,16 @@ private fun ChatsBrandHeader(
|
||||
Column(verticalArrangement = Arrangement.spacedBy(0.dp)) {
|
||||
Text(
|
||||
text = if (isRealtimeConnected) "ИКАР" else "Соединение...",
|
||||
style = MaterialTheme.typography.displayMedium.copy(
|
||||
letterSpacing = if (isRealtimeConnected) 3.sp else 0.sp
|
||||
style = MaterialTheme.typography.titleLarge.copy(
|
||||
letterSpacing = if (isRealtimeConnected) 2.sp else 0.sp
|
||||
),
|
||||
color = TelegramBlue
|
||||
)
|
||||
Text("Чаты", style = MaterialTheme.typography.bodySmall, color = TelegramInkMuted)
|
||||
Text(
|
||||
if (isRealtimeConnected) "Чаты" else "Повторное подключение",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = TelegramInkMuted
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -939,7 +947,11 @@ private fun SwipeableChatListRow(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(Icons.Outlined.PushPin, contentDescription = null, tint = TelegramBlue)
|
||||
Icon(
|
||||
Icons.Outlined.PushPin,
|
||||
contentDescription = if (chatItem.chat.isPinned) "Открепить чат" else "Закрепить чат",
|
||||
tint = TelegramBlue
|
||||
)
|
||||
Text(
|
||||
if (chatItem.chat.isPinned) "Открепить" else "Закрепить",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
@@ -958,7 +970,11 @@ private fun SwipeableChatListRow(
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = TelegramBlue
|
||||
)
|
||||
Icon(leftSwipeIcon, contentDescription = null, tint = TelegramBlue)
|
||||
Icon(
|
||||
leftSwipeIcon,
|
||||
contentDescription = chatItem.chat.leftSwipeActionLabel(swipeLeftAction),
|
||||
tint = TelegramBlue
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1009,18 +1025,18 @@ private fun ChatListRow(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp)
|
||||
.padding(horizontal = 6.dp, vertical = 1.dp)
|
||||
.clip(MaterialTheme.shapes.large)
|
||||
.background(rowBackground)
|
||||
.combinedClickable(onClick = onClick, onLongClick = onLongClick)
|
||||
.padding(horizontal = 8.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
.padding(horizontal = 8.dp, vertical = 7.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AvatarView(
|
||||
imageUrl = avatarUrl,
|
||||
title = chatItem.resolvedTitle,
|
||||
size = 56.dp
|
||||
size = 48.dp
|
||||
)
|
||||
|
||||
Column(
|
||||
@@ -1035,7 +1051,7 @@ private fun ChatListRow(
|
||||
Text(
|
||||
text = chatItem.resolvedTitle,
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
@@ -1088,7 +1104,7 @@ private fun ChatListRow(
|
||||
)
|
||||
}
|
||||
}
|
||||
IkarRowDivider(startIndent = 86.dp)
|
||||
IkarRowDivider(startIndent = 72.dp)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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.ChatType
|
||||
import com.seven.ikar.kotlin.core.model.StoryDto
|
||||
import com.seven.ikar.kotlin.core.realtime.RealtimeConnectionStage
|
||||
import com.seven.ikar.kotlin.feature.share.PendingExternalShare
|
||||
import com.seven.ikar.kotlin.ui.formatLastActivity
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -69,7 +70,8 @@ data class ChatsUiState(
|
||||
val showDeleteForEveryoneOption: Boolean = false,
|
||||
val deleteForEveryoneLabel: String = DefaultDeleteForEveryoneLabel,
|
||||
val isDeletingSelected: Boolean = false,
|
||||
val selectionStartedFromSwipeDelete: Boolean = false
|
||||
val selectionStartedFromSwipeDelete: Boolean = false,
|
||||
val realtimeStage: RealtimeConnectionStage = RealtimeConnectionStage.Disconnected
|
||||
) {
|
||||
val isShareMode: Boolean
|
||||
get() = pendingShare != null
|
||||
@@ -582,6 +584,14 @@ class ChatsViewModel(private val container: AppContainer) : ViewModel() {
|
||||
}
|
||||
|
||||
private fun attachRealtime() {
|
||||
viewModelScope.launch {
|
||||
container.realtimeService.stage.collect { stage ->
|
||||
_uiState.value = _uiState.value.copy(realtimeStage = stage)
|
||||
}
|
||||
}
|
||||
viewModelScope.launch {
|
||||
container.realtimeService.connectionRestored.collect { scheduleRefresh() }
|
||||
}
|
||||
viewModelScope.launch {
|
||||
container.realtimeService.chatCreated.collect { scheduleRefresh() }
|
||||
}
|
||||
|
||||
+19
-14
@@ -29,6 +29,8 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.seven.ikar.kotlin.R
|
||||
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
@@ -88,13 +90,17 @@ fun LoginScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
Text(
|
||||
text = state.pageTitle,
|
||||
text = stringResource(
|
||||
if (state.isAwaitingCode) R.string.login_verify_email_title else R.string.login_title
|
||||
),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = state.subtitle,
|
||||
text = stringResource(
|
||||
if (state.isAwaitingCode) R.string.login_code_subtitle else R.string.login_subtitle
|
||||
),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color(0xFFDDEEFF)
|
||||
)
|
||||
@@ -111,7 +117,7 @@ fun LoginScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "АККАУНТ",
|
||||
text = stringResource(R.string.login_account_section),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = Color(0xFF5A8EB2),
|
||||
fontWeight = FontWeight.SemiBold
|
||||
@@ -122,7 +128,7 @@ fun LoginScreen(
|
||||
value = state.email,
|
||||
onValueChange = onEmailChanged,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Электронная почта") },
|
||||
label = { Text(stringResource(R.string.login_email)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
|
||||
singleLine = true
|
||||
)
|
||||
@@ -131,7 +137,7 @@ fun LoginScreen(
|
||||
value = state.phoneNumber,
|
||||
onValueChange = onPhoneNumberChanged,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Номер телефона") },
|
||||
label = { Text(stringResource(R.string.login_phone)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone),
|
||||
singleLine = true
|
||||
)
|
||||
@@ -147,7 +153,7 @@ fun LoginScreen(
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
)
|
||||
} else {
|
||||
Text("Получить код")
|
||||
Text(stringResource(R.string.login_request_code))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,12 +170,12 @@ fun LoginScreen(
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
Text(
|
||||
text = "Подтверждение email",
|
||||
text = stringResource(R.string.login_verify_email_title),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = "Код отправлен на указанную электронную почту. Номер телефона будет привязан к аккаунту.",
|
||||
text = stringResource(R.string.login_code_explanation),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color(0xFF617A8F)
|
||||
)
|
||||
@@ -178,7 +184,7 @@ fun LoginScreen(
|
||||
value = state.verificationCode,
|
||||
onValueChange = onVerificationCodeChanged,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Код подтверждения") },
|
||||
label = { Text(stringResource(R.string.login_code)) },
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||
singleLine = true
|
||||
)
|
||||
@@ -188,7 +194,7 @@ fun LoginScreen(
|
||||
value = state.displayName,
|
||||
onValueChange = onDisplayNameChanged,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
label = { Text("Ваше имя") },
|
||||
label = { Text(stringResource(R.string.login_display_name)) },
|
||||
singleLine = true
|
||||
)
|
||||
}
|
||||
@@ -196,8 +202,7 @@ fun LoginScreen(
|
||||
Button(
|
||||
onClick = onVerifyCodeClick,
|
||||
enabled = !state.isLoading &&
|
||||
state.verificationCode.isNotBlank() &&
|
||||
(!state.isNewAccount || state.displayName.isNotBlank()),
|
||||
state.verificationCode.isNotBlank(),
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 14.dp)
|
||||
) {
|
||||
if (state.isLoading) {
|
||||
@@ -206,7 +211,7 @@ fun LoginScreen(
|
||||
modifier = Modifier.padding(horizontal = 8.dp)
|
||||
)
|
||||
} else {
|
||||
Text("Продолжить")
|
||||
Text(stringResource(R.string.login_continue))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -219,7 +224,7 @@ fun LoginScreen(
|
||||
),
|
||||
contentPadding = PaddingValues(horizontal = 0.dp, vertical = 12.dp)
|
||||
) {
|
||||
Text("Изменить email или телефон")
|
||||
Text(stringResource(R.string.login_change_identity))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -38,7 +38,7 @@ data class LoginUiState(
|
||||
get() = isAwaitingCode
|
||||
|
||||
val showDisplayName: Boolean
|
||||
get() = isAwaitingCode && isNewAccount
|
||||
get() = isAwaitingCode
|
||||
}
|
||||
|
||||
class LoginViewModel(private val container: AppContainer) : ViewModel() {
|
||||
@@ -103,8 +103,7 @@ class LoginViewModel(private val container: AppContainer) : ViewModel() {
|
||||
state.challengeId.isBlank() ||
|
||||
state.email.isBlank() ||
|
||||
state.phoneNumber.isBlank() ||
|
||||
state.verificationCode.isBlank() ||
|
||||
(state.isNewAccount && state.displayName.isBlank())
|
||||
state.verificationCode.isBlank()
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
+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.feature.chat.LocalAttachmentFile
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import java.util.UUID
|
||||
|
||||
class TransferQueueRepository(
|
||||
@@ -27,6 +28,46 @@ class TransferQueueRepository(
|
||||
fun observeActiveStoryUploads(): Flow<List<TransferQueueEntity>> =
|
||||
dao.observeActiveStoryUploads()
|
||||
|
||||
suspend fun enqueueTextMessage(
|
||||
chatId: String,
|
||||
text: String,
|
||||
replyToMessageId: String?,
|
||||
clientMessageId: String
|
||||
): String {
|
||||
require(text.isNotBlank()) { "Message text is required." }
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
dao.upsert(
|
||||
listOf(
|
||||
TransferQueueEntity(
|
||||
id = UUID.randomUUID().toString(),
|
||||
groupId = clientMessageId,
|
||||
type = TransferQueueTypes.TextMessage,
|
||||
status = TransferQueueStatus.Queued,
|
||||
chatId = chatId,
|
||||
text = text,
|
||||
replyToMessageId = replyToMessageId,
|
||||
clientMessageId = clientMessageId,
|
||||
attachmentId = null,
|
||||
storyVisibility = null,
|
||||
hasProtectedContent = false,
|
||||
ttlSeconds = null,
|
||||
fileName = "",
|
||||
contentType = "text/plain",
|
||||
fileSizeBytes = 0,
|
||||
localPath = "",
|
||||
sortOrder = 0,
|
||||
progressPercent = 0,
|
||||
errorMessage = null,
|
||||
createdAtMillis = now,
|
||||
updatedAtMillis = now
|
||||
)
|
||||
)
|
||||
)
|
||||
scheduler.enqueueTextMessage(clientMessageId)
|
||||
return clientMessageId
|
||||
}
|
||||
|
||||
suspend fun enqueueUpload(
|
||||
chatId: String,
|
||||
text: String?,
|
||||
@@ -47,6 +88,7 @@ class TransferQueueRepository(
|
||||
chatId = chatId,
|
||||
text = text?.takeIf { it.isNotBlank() },
|
||||
replyToMessageId = replyToMessageId,
|
||||
clientMessageId = null,
|
||||
attachmentId = null,
|
||||
storyVisibility = null,
|
||||
hasProtectedContent = false,
|
||||
@@ -88,6 +130,7 @@ class TransferQueueRepository(
|
||||
chatId = null,
|
||||
text = text?.takeIf { it.isNotBlank() },
|
||||
replyToMessageId = null,
|
||||
clientMessageId = null,
|
||||
attachmentId = null,
|
||||
storyVisibility = visibility.name,
|
||||
hasProtectedContent = hasProtectedContent,
|
||||
@@ -109,6 +152,7 @@ class TransferQueueRepository(
|
||||
}
|
||||
|
||||
suspend fun enqueueDownload(chatId: String, attachment: AttachmentDto): String {
|
||||
dao.findActiveDownloadGroup(attachment.id)?.let { return it }
|
||||
val groupId = UUID.randomUUID().toString()
|
||||
val now = System.currentTimeMillis()
|
||||
dao.upsert(
|
||||
@@ -121,6 +165,7 @@ class TransferQueueRepository(
|
||||
chatId = chatId,
|
||||
text = null,
|
||||
replyToMessageId = null,
|
||||
clientMessageId = null,
|
||||
attachmentId = attachment.id,
|
||||
storyVisibility = null,
|
||||
hasProtectedContent = false,
|
||||
@@ -141,10 +186,22 @@ class TransferQueueRepository(
|
||||
return groupId
|
||||
}
|
||||
|
||||
suspend fun awaitCompletion(groupId: String): List<TransferQueueEntity> =
|
||||
dao.observeGroup(groupId).first { items ->
|
||||
items.isNotEmpty() && items.all { item ->
|
||||
item.status == TransferQueueStatus.Completed || item.status == TransferQueueStatus.Failed
|
||||
}
|
||||
}.also { items ->
|
||||
items.firstOrNull { it.status == TransferQueueStatus.Failed }?.let { failed ->
|
||||
throw IllegalStateException(failed.errorMessage ?: "Transfer failed.")
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun retry(groupId: String) {
|
||||
val type = dao.getGroup(groupId).firstOrNull()?.type
|
||||
dao.updateGroupStatus(groupId, TransferQueueStatus.Queued, 0, null, System.currentTimeMillis())
|
||||
when (type) {
|
||||
TransferQueueTypes.TextMessage -> scheduler.enqueueTextMessage(groupId)
|
||||
TransferQueueTypes.Download -> scheduler.enqueueDownload(groupId)
|
||||
TransferQueueTypes.StoryUpload -> scheduler.enqueueStoryUpload(groupId)
|
||||
else -> scheduler.enqueueUpload(groupId)
|
||||
@@ -172,6 +229,15 @@ class TransferWorkScheduler(context: Context) {
|
||||
workManager.enqueueUniqueWork(uploadWorkName(groupId), ExistingWorkPolicy.REPLACE, request)
|
||||
}
|
||||
|
||||
fun enqueueTextMessage(groupId: String) {
|
||||
val request = OneTimeWorkRequestBuilder<TextMessageWorker>()
|
||||
.setInputData(workDataOf(TextMessageWorker.KeyGroupId to groupId))
|
||||
.setConstraints(networkConstraints)
|
||||
.build()
|
||||
|
||||
workManager.enqueueUniqueWork(textMessageWorkName(groupId), ExistingWorkPolicy.KEEP, request)
|
||||
}
|
||||
|
||||
fun enqueueStoryUpload(groupId: String) {
|
||||
val request = OneTimeWorkRequestBuilder<AttachmentUploadWorker>()
|
||||
.setInputData(workDataOf(AttachmentUploadWorker.KeyGroupId to groupId))
|
||||
@@ -191,6 +257,7 @@ class TransferWorkScheduler(context: Context) {
|
||||
}
|
||||
|
||||
fun cancel(groupId: String) {
|
||||
workManager.cancelUniqueWork(textMessageWorkName(groupId))
|
||||
workManager.cancelUniqueWork(uploadWorkName(groupId))
|
||||
workManager.cancelUniqueWork(storyUploadWorkName(groupId))
|
||||
workManager.cancelUniqueWork(downloadWorkName(groupId))
|
||||
@@ -198,6 +265,8 @@ class TransferWorkScheduler(context: Context) {
|
||||
|
||||
private fun uploadWorkName(groupId: String): String = "upload-$groupId"
|
||||
|
||||
private fun textMessageWorkName(groupId: String): String = "text-message-$groupId"
|
||||
|
||||
private fun storyUploadWorkName(groupId: String): String = "story-upload-$groupId"
|
||||
|
||||
private fun downloadWorkName(groupId: String): String = "download-$groupId"
|
||||
|
||||
+5
-2
@@ -6,6 +6,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import coil.compose.AsyncImage
|
||||
import coil.compose.AsyncImagePainter
|
||||
import coil.request.CachePolicy
|
||||
import coil.request.ImageRequest
|
||||
import java.security.MessageDigest
|
||||
@@ -16,7 +17,8 @@ fun AuthenticatedAsyncImage(
|
||||
accessToken: String? = null,
|
||||
contentDescription: String,
|
||||
modifier: Modifier = Modifier,
|
||||
contentScale: ContentScale = ContentScale.Crop
|
||||
contentScale: ContentScale = ContentScale.Crop,
|
||||
onSuccess: ((AsyncImagePainter.State.Success) -> Unit)? = null
|
||||
) {
|
||||
if (imageUrl.isNullOrBlank()) {
|
||||
return
|
||||
@@ -45,7 +47,8 @@ fun AuthenticatedAsyncImage(
|
||||
model = request,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
contentScale = contentScale
|
||||
contentScale = contentScale,
|
||||
onSuccess = onSuccess
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -524,8 +524,8 @@ fun IkarFloatingMainMenu(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.border(1.dp, TelegramFloatingBorder, RoundedCornerShape(32.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||
.padding(horizontal = 8.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
val unreadChatsCount = LocalUnreadChatsCount.current
|
||||
IkarFloatingMenuItem(
|
||||
@@ -573,9 +573,9 @@ private fun IkarFloatingMenuItem(
|
||||
.clip(shape)
|
||||
.clickable(onClick = onClick)
|
||||
.background(backgroundColor)
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
.padding(horizontal = 8.dp, vertical = 5.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.size(28.dp),
|
||||
|
||||
@@ -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]
|
||||
public async Task RequestPhoneCode_NewPhone_DoesNotRegister()
|
||||
public async Task RequestPhoneCode_IsDisabled()
|
||||
{
|
||||
await using var database = new SqliteTestDatabase();
|
||||
await using var dbContext = await database.CreateContextAsync();
|
||||
@@ -162,7 +162,8 @@ public sealed class AuthEmailControllerTests
|
||||
new RequestPhoneCodeRequest("+10000009999"),
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.IsType<NotFoundObjectResult>(result.Result);
|
||||
var response = Assert.IsType<ObjectResult>(result.Result);
|
||||
Assert.Equal(StatusCodes.Status410Gone, response.StatusCode);
|
||||
}
|
||||
|
||||
private static AuthController CreateController(
|
||||
@@ -174,7 +175,6 @@ public sealed class AuthEmailControllerTests
|
||||
dbContext,
|
||||
new TokenService(dbContext, Options.Create(new JwtOptions())),
|
||||
new PresenceTracker(),
|
||||
new PhoneAuthChallengeStore(),
|
||||
emailChallengeStore,
|
||||
emailCodeSender);
|
||||
controller.ControllerContext = new ControllerContext
|
||||
|
||||
@@ -519,7 +519,7 @@ public sealed class ChannelMembersControllerTests
|
||||
new UploadAttachmentRequest
|
||||
{
|
||||
Text = "Photo post",
|
||||
File = CreateFormFile([1, 2, 3], "photo.jpg", "image/jpeg")
|
||||
File = CreateFormFile([0xFF, 0xD8, 0xFF, 0xE0], "photo.jpg", "image/jpeg")
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
@@ -570,8 +570,8 @@ public sealed class ChannelMembersControllerTests
|
||||
Text = "Gallery",
|
||||
Files =
|
||||
[
|
||||
CreateFormFile([1, 2, 3], "first.jpg", "image/jpeg"),
|
||||
CreateFormFile([4, 5, 6], "second.jpg", "image/jpeg")
|
||||
CreateFormFile([0xFF, 0xD8, 0xFF, 0xE0], "first.jpg", "image/jpeg"),
|
||||
CreateFormFile([0xFF, 0xD8, 0xFF, 0xE1], "second.jpg", "image/jpeg")
|
||||
]
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
@@ -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,
|
||||
TokenService tokenService,
|
||||
PresenceTracker presenceTracker,
|
||||
PhoneAuthChallengeStore phoneAuthChallengeStore,
|
||||
EmailAuthChallengeStore emailAuthChallengeStore,
|
||||
IEmailCodeSender emailCodeSender) : ControllerBase
|
||||
{
|
||||
@@ -135,77 +134,26 @@ public sealed class AuthController(
|
||||
|
||||
[HttpPost("request-code")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<PhoneCodeChallengeDto>> RequestPhoneCode(
|
||||
public Task<ActionResult<PhoneCodeChallengeDto>> RequestPhoneCode(
|
||||
RequestPhoneCodeRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(request.PhoneNumber);
|
||||
if (normalizedPhoneNumber is null)
|
||||
{
|
||||
return ValidationProblem("Valid phone number is required.");
|
||||
}
|
||||
|
||||
var user = await dbContext.Users
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(x => x.NormalizedPhoneNumber == normalizedPhoneNumber, cancellationToken);
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound("Phone code login is available only for existing accounts. Register by email.");
|
||||
}
|
||||
|
||||
if (user?.IsBlocked == true)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status423Locked, "User is blocked.");
|
||||
}
|
||||
|
||||
var challenge = phoneAuthChallengeStore.Create(normalizedPhoneNumber, isRegistered: true);
|
||||
return Ok(new PhoneCodeChallengeDto(
|
||||
challenge.Id,
|
||||
challenge.ExpiresAt,
|
||||
challenge.Code,
|
||||
challenge.IsRegistered));
|
||||
return Task.FromResult<ActionResult<PhoneCodeChallengeDto>>(
|
||||
StatusCode(
|
||||
StatusCodes.Status410Gone,
|
||||
new { error = "Phone-code authentication is disabled. Request a code by email." }));
|
||||
}
|
||||
|
||||
[HttpPost("verify-code")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<AuthSessionDto>> VerifyPhoneCode(
|
||||
public Task<ActionResult<AuthSessionDto>> VerifyPhoneCode(
|
||||
VerifyPhoneCodeRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedPhoneNumber = PhoneNumberNormalizer.Normalize(request.PhoneNumber);
|
||||
if (normalizedPhoneNumber is null)
|
||||
{
|
||||
return ValidationProblem("Valid phone number is required.");
|
||||
}
|
||||
|
||||
var challenge = phoneAuthChallengeStore.Consume(
|
||||
request.ChallengeId?.Trim() ?? string.Empty,
|
||||
normalizedPhoneNumber,
|
||||
request.Code?.Trim() ?? string.Empty);
|
||||
if (challenge is null)
|
||||
{
|
||||
return Unauthorized("Invalid or expired verification code.");
|
||||
}
|
||||
|
||||
var user = await dbContext.Users.SingleOrDefaultAsync(
|
||||
x => x.NormalizedPhoneNumber == normalizedPhoneNumber,
|
||||
cancellationToken);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return NotFound("Phone code login is available only for existing accounts. Register by email.");
|
||||
}
|
||||
|
||||
if (user.IsBlocked)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status423Locked, "User is blocked.");
|
||||
}
|
||||
|
||||
var session = await tokenService.CreateSessionAsync(
|
||||
user,
|
||||
ClientVersionRequestReader.Read(Request),
|
||||
cancellationToken);
|
||||
return Ok(session with { User = user.ToDto(presenceTracker) });
|
||||
return Task.FromResult<ActionResult<AuthSessionDto>>(
|
||||
StatusCode(
|
||||
StatusCodes.Status410Gone,
|
||||
new { error = "Phone-code authentication is disabled. Verify a code sent by email." }));
|
||||
}
|
||||
|
||||
[HttpPost("request-email-code")]
|
||||
@@ -247,7 +195,18 @@ public sealed class AuthController(
|
||||
return StatusCode(StatusCodes.Status423Locked, "User is blocked.");
|
||||
}
|
||||
|
||||
var challenge = emailAuthChallengeStore.Create(normalizedEmail, normalizedPhoneNumber, user is not null);
|
||||
EmailAuthChallengeStore.EmailAuthChallenge challenge;
|
||||
try
|
||||
{
|
||||
challenge = emailAuthChallengeStore.Create(normalizedEmail, normalizedPhoneNumber, user is not null);
|
||||
}
|
||||
catch (AuthChallengeRateLimitException ex)
|
||||
{
|
||||
Response.Headers.RetryAfter = Math.Max(1, (int)Math.Ceiling(ex.RetryAfter.TotalSeconds)).ToString();
|
||||
return StatusCode(
|
||||
StatusCodes.Status429TooManyRequests,
|
||||
new { error = "Please wait before requesting another email code." });
|
||||
}
|
||||
try
|
||||
{
|
||||
await emailCodeSender.SendLoginCodeAsync(email, challenge.Code, challenge.ExpiresAt, cancellationToken);
|
||||
@@ -261,7 +220,7 @@ public sealed class AuthController(
|
||||
return Ok(new EmailCodeChallengeDto(
|
||||
challenge.Id,
|
||||
challenge.ExpiresAt,
|
||||
challenge.IsRegistered));
|
||||
IsRegistered: false));
|
||||
}
|
||||
|
||||
[HttpPost("verify-email-code")]
|
||||
|
||||
@@ -225,6 +225,7 @@ public sealed class BotApiController(
|
||||
message.DiscussionMessageId = discussionRootMessage.Id;
|
||||
}
|
||||
|
||||
pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await NotifyMessageCreatedAsync(chat, message, cancellationToken);
|
||||
@@ -377,6 +378,7 @@ public sealed class BotApiController(
|
||||
message.DiscussionMessageId = discussionRootMessage.Id;
|
||||
}
|
||||
|
||||
pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
@@ -516,6 +518,7 @@ public sealed class BotApiController(
|
||||
message.DiscussionMessageId = discussionRootMessage.Id;
|
||||
}
|
||||
|
||||
pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -1842,6 +1842,38 @@ public sealed class ChatsController(
|
||||
return StatusCode(StatusCodes.Status403Forbidden, "Only channel owners can publish.");
|
||||
}
|
||||
|
||||
if (request.ClientMessageId is not null)
|
||||
{
|
||||
var existingMessage = await dbContext.Messages
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(
|
||||
message => message.SenderId == userId && message.ClientMessageId == request.ClientMessageId,
|
||||
cancellationToken);
|
||||
if (existingMessage is not null)
|
||||
{
|
||||
if (existingMessage.ChatId != chatId)
|
||||
{
|
||||
return Conflict("Client message id is already used in another chat.");
|
||||
}
|
||||
|
||||
var loadedExistingMessage = await LoadMessageForMemberAsync(
|
||||
chatId,
|
||||
existingMessage.Id,
|
||||
userId,
|
||||
cancellationToken);
|
||||
if (loadedExistingMessage is null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
// Re-enqueueing is safe because the durable outbox deduplicates by message id.
|
||||
// This repairs the narrow case where the original HTTP request committed the
|
||||
// message but was interrupted before the push job could be persisted.
|
||||
await pushDispatchQueue.EnqueueAsync(chatId, existingMessage.Id, cancellationToken);
|
||||
return Ok(loadedExistingMessage.ToDto(presenceTracker, userId));
|
||||
}
|
||||
}
|
||||
|
||||
var storyReply = request.StoryReplyStoryId is null
|
||||
? null
|
||||
: await LoadVisibleStoryForReplyAsync(request.StoryReplyStoryId.Value, userId, cancellationToken);
|
||||
@@ -1897,6 +1929,7 @@ public sealed class ChatsController(
|
||||
Chat = chat,
|
||||
SenderId = userId,
|
||||
Sender = sender,
|
||||
ClientMessageId = request.ClientMessageId,
|
||||
PostedAsChannel = ShouldPostAsChannel(chat),
|
||||
AuthorSignature = ResolveChannelAuthorSignature(chat, sender),
|
||||
Text = text,
|
||||
@@ -1928,6 +1961,7 @@ public sealed class ChatsController(
|
||||
message.DiscussionMessageId = discussionRootMessage.Id;
|
||||
}
|
||||
|
||||
pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var loadedMessage = await LoadMessageForMemberAsync(chatId, message.Id, userId, cancellationToken)
|
||||
@@ -2052,6 +2086,7 @@ public sealed class ChatsController(
|
||||
forwardedMessage.DiscussionMessageId = discussionRootMessage.Id;
|
||||
}
|
||||
|
||||
pushDispatchQueue.StageMessages(dbContext, forwardedMessage, discussionRootMessage);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
@@ -2176,6 +2211,10 @@ public sealed class ChatsController(
|
||||
shouldNotifyReaction = message.SenderId != userId;
|
||||
}
|
||||
|
||||
if (shouldNotifyReaction)
|
||||
{
|
||||
pushDispatchQueue.StageReaction(dbContext, chatId, messageId, userId, emoji);
|
||||
}
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var updatedMessage = await LoadMessageForMemberAsync(chatId, messageId, userId, cancellationToken)
|
||||
@@ -2314,6 +2353,7 @@ public sealed class ChatsController(
|
||||
message.DiscussionMessageId = discussionRootMessage.Id;
|
||||
}
|
||||
|
||||
pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
@@ -2488,6 +2528,7 @@ public sealed class ChatsController(
|
||||
message.DiscussionMessageId = discussionRootMessage.Id;
|
||||
}
|
||||
|
||||
pushDispatchQueue.StageMessages(dbContext, message, discussionRootMessage);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
@@ -4425,21 +4466,8 @@ public sealed class ChatsController(
|
||||
|
||||
private static bool IsMediaAlbumFile(IFormFile file)
|
||||
{
|
||||
var normalizedContentType = file.ContentType
|
||||
?.Split(';', 2, StringSplitOptions.TrimEntries)
|
||||
.FirstOrDefault()
|
||||
?.Trim()
|
||||
.ToLowerInvariant();
|
||||
if (!string.IsNullOrWhiteSpace(normalizedContentType) &&
|
||||
(normalizedContentType.StartsWith("image/", StringComparison.Ordinal) ||
|
||||
normalizedContentType.StartsWith("video/", StringComparison.Ordinal)))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var extension = Path.GetExtension(file.FileName).ToLowerInvariant();
|
||||
return extension is ".jpg" or ".jpeg" or ".png" or ".gif" or ".webp" or ".bmp" or
|
||||
".mp4" or ".m4v" or ".mov" or ".webm" or ".mkv";
|
||||
var contentInfo = AttachmentContentInspector.Inspect(file);
|
||||
return contentInfo.IsImage || contentInfo.IsVideo;
|
||||
}
|
||||
|
||||
private static string? ResolveChannelAuthorSignature(Chat chat, User sender)
|
||||
|
||||
@@ -163,6 +163,7 @@ public sealed class Message
|
||||
public Chat Chat { get; set; } = null!;
|
||||
public Guid SenderId { get; set; }
|
||||
public User Sender { get; set; } = null!;
|
||||
public Guid? ClientMessageId { get; set; }
|
||||
public string Text { get; set; } = string.Empty;
|
||||
public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset? EditedAt { get; set; }
|
||||
@@ -229,6 +230,21 @@ public sealed class MessageView
|
||||
public DateTimeOffset ViewedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
public sealed class PushDispatchOutboxItem
|
||||
{
|
||||
public long Id { get; set; }
|
||||
public string DeduplicationKey { get; set; } = string.Empty;
|
||||
public Guid ChatId { get; set; }
|
||||
public Guid MessageId { get; set; }
|
||||
public int Kind { get; set; }
|
||||
public Guid? ActorUserId { get; set; }
|
||||
public string? ReactionEmoji { get; set; }
|
||||
public int AttemptCount { get; set; }
|
||||
public DateTimeOffset NextAttemptAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public string? LastError { get; set; }
|
||||
}
|
||||
|
||||
public sealed class PushDevice
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
@@ -29,6 +29,7 @@ public sealed class IkarDbContext(DbContextOptions<IkarDbContext> options) : DbC
|
||||
public DbSet<MessageReaction> MessageReactions => Set<MessageReaction>();
|
||||
public DbSet<MessageView> MessageViews => Set<MessageView>();
|
||||
public DbSet<PushDevice> PushDevices => Set<PushDevice>();
|
||||
public DbSet<PushDispatchOutboxItem> PushDispatchOutbox => Set<PushDispatchOutboxItem>();
|
||||
public DbSet<Bot> Bots => Set<Bot>();
|
||||
public DbSet<BotCommand> BotCommands => Set<BotCommand>();
|
||||
public DbSet<BotUpdate> BotUpdates => Set<BotUpdate>();
|
||||
@@ -44,6 +45,7 @@ public sealed class IkarDbContext(DbContextOptions<IkarDbContext> options) : DbC
|
||||
ConfigureChats(modelBuilder);
|
||||
ConfigureMessages(modelBuilder);
|
||||
ConfigurePushDevices(modelBuilder);
|
||||
ConfigurePushDispatchOutbox(modelBuilder);
|
||||
ConfigureBots(modelBuilder);
|
||||
ConfigureModeration(modelBuilder);
|
||||
ConfigureStories(modelBuilder);
|
||||
@@ -246,6 +248,9 @@ public sealed class IkarDbContext(DbContextOptions<IkarDbContext> options) : DbC
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.HasIndex(x => new { x.ChatId, x.SentAt });
|
||||
entity.HasIndex(x => new { x.ChatId, x.SenderId, x.SentAt });
|
||||
entity.HasIndex(x => new { x.SenderId, x.ClientMessageId })
|
||||
.IsUnique()
|
||||
.HasFilter("\"ClientMessageId\" IS NOT NULL");
|
||||
entity.HasIndex(x => x.SenderId);
|
||||
entity.HasIndex(x => x.ReplyToMessageId);
|
||||
entity.HasIndex(x => x.TopicId);
|
||||
@@ -424,6 +429,20 @@ public sealed class IkarDbContext(DbContextOptions<IkarDbContext> options) : DbC
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigurePushDispatchOutbox(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<PushDispatchOutboxItem>(entity =>
|
||||
{
|
||||
entity.ToTable("PushDispatchOutbox");
|
||||
entity.HasKey(x => x.Id);
|
||||
entity.Property(x => x.Id).ValueGeneratedOnAdd();
|
||||
entity.Property(x => x.DeduplicationKey).IsRequired();
|
||||
entity.HasIndex(x => x.DeduplicationKey).IsUnique();
|
||||
entity.HasIndex(x => new { x.NextAttemptAt, x.Id });
|
||||
entity.HasIndex(x => new { x.MessageId, x.Kind });
|
||||
});
|
||||
}
|
||||
|
||||
private static void ConfigureBots(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.Entity<Bot>(entity =>
|
||||
|
||||
@@ -7,9 +7,11 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.4" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.4" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.OpenApi" Version="2.7.5" />
|
||||
<PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" Version="3.0.3" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -6,11 +6,17 @@ namespace Ikar.Server.Infrastructure.Auth;
|
||||
public sealed class EmailAuthChallengeStore
|
||||
{
|
||||
private static readonly TimeSpan ChallengeLifetime = TimeSpan.FromMinutes(10);
|
||||
private static readonly TimeSpan MinimumRequestInterval = TimeSpan.FromSeconds(60);
|
||||
private static readonly TimeSpan RequestWindow = TimeSpan.FromMinutes(15);
|
||||
private const int MaxRequestsPerWindow = 5;
|
||||
private const int MaxVerificationAttempts = 5;
|
||||
private readonly ConcurrentDictionary<string, EmailAuthChallenge> _challenges = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, RequestBucket> _requestBuckets = new(StringComparer.Ordinal);
|
||||
|
||||
public EmailAuthChallenge Create(string normalizedEmail, string normalizedPhoneNumber, bool isRegistered)
|
||||
{
|
||||
CleanupExpired();
|
||||
EnforceRequestLimit($"{normalizedEmail}|{normalizedPhoneNumber}");
|
||||
|
||||
var challenge = new EmailAuthChallenge(
|
||||
Guid.NewGuid().ToString("N"),
|
||||
@@ -39,12 +45,21 @@ public sealed class EmailAuthChallengeStore
|
||||
|
||||
if (!string.Equals(challenge.NormalizedEmail, normalizedEmail, StringComparison.Ordinal) ||
|
||||
!string.Equals(challenge.NormalizedPhoneNumber, normalizedPhoneNumber, StringComparison.Ordinal) ||
|
||||
!string.Equals(challenge.Code, code, StringComparison.Ordinal) ||
|
||||
challenge.ExpiresAt <= DateTimeOffset.UtcNow)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!string.Equals(challenge.Code, code, StringComparison.Ordinal))
|
||||
{
|
||||
if (challenge.RegisterFailedAttempt() >= MaxVerificationAttempts)
|
||||
{
|
||||
_challenges.TryRemove(challenge.Id, out _);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
_challenges.TryRemove(challenge.Id, out _);
|
||||
return challenge;
|
||||
}
|
||||
@@ -64,13 +79,74 @@ public sealed class EmailAuthChallengeStore
|
||||
_challenges.TryRemove(pair.Key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var pair in _requestBuckets)
|
||||
{
|
||||
if (now - pair.Value.WindowStartedAt >= RequestWindow)
|
||||
{
|
||||
_requestBuckets.TryRemove(pair.Key, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record EmailAuthChallenge(
|
||||
string Id,
|
||||
string NormalizedEmail,
|
||||
string NormalizedPhoneNumber,
|
||||
string Code,
|
||||
DateTimeOffset ExpiresAt,
|
||||
bool IsRegistered);
|
||||
private void EnforceRequestLimit(string key)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var bucket = _requestBuckets.GetOrAdd(key, _ => new RequestBucket(now));
|
||||
lock (bucket)
|
||||
{
|
||||
if (now - bucket.WindowStartedAt >= RequestWindow)
|
||||
{
|
||||
bucket.WindowStartedAt = now;
|
||||
bucket.LastRequestedAt = DateTimeOffset.MinValue;
|
||||
bucket.Count = 0;
|
||||
}
|
||||
|
||||
var sinceLastRequest = now - bucket.LastRequestedAt;
|
||||
if (bucket.LastRequestedAt != DateTimeOffset.MinValue && sinceLastRequest < MinimumRequestInterval)
|
||||
{
|
||||
throw new AuthChallengeRateLimitException(MinimumRequestInterval - sinceLastRequest);
|
||||
}
|
||||
|
||||
if (bucket.Count >= MaxRequestsPerWindow)
|
||||
{
|
||||
throw new AuthChallengeRateLimitException(RequestWindow - (now - bucket.WindowStartedAt));
|
||||
}
|
||||
|
||||
bucket.LastRequestedAt = now;
|
||||
bucket.Count++;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EmailAuthChallenge(
|
||||
string id,
|
||||
string normalizedEmail,
|
||||
string normalizedPhoneNumber,
|
||||
string code,
|
||||
DateTimeOffset expiresAt,
|
||||
bool isRegistered)
|
||||
{
|
||||
private int _failedAttempts;
|
||||
|
||||
public string Id { get; } = id;
|
||||
public string NormalizedEmail { get; } = normalizedEmail;
|
||||
public string NormalizedPhoneNumber { get; } = normalizedPhoneNumber;
|
||||
public string Code { get; } = code;
|
||||
public DateTimeOffset ExpiresAt { get; } = expiresAt;
|
||||
public bool IsRegistered { get; } = isRegistered;
|
||||
|
||||
public int RegisterFailedAttempt() => Interlocked.Increment(ref _failedAttempts);
|
||||
}
|
||||
|
||||
private sealed class RequestBucket(DateTimeOffset windowStartedAt)
|
||||
{
|
||||
public DateTimeOffset WindowStartedAt { get; set; } = windowStartedAt;
|
||||
public DateTimeOffset LastRequestedAt { get; set; } = DateTimeOffset.MinValue;
|
||||
public int Count { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AuthChallengeRateLimitException(TimeSpan retryAfter) : Exception("Authentication challenge rate limit exceeded.")
|
||||
{
|
||||
public TimeSpan RetryAfter { get; } = retryAfter;
|
||||
}
|
||||
|
||||
@@ -376,6 +376,7 @@ public sealed class BotCreatorService(
|
||||
|
||||
chat.LastActivityAt = now;
|
||||
dbContext.Messages.Add(reply);
|
||||
pushDispatchQueue.StageMessages(dbContext, reply);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var loadedReply = await dbContext.Messages
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
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() =>
|
||||
IceServers
|
||||
.Where(server => server.Urls.Count > 0)
|
||||
|
||||
@@ -130,7 +130,8 @@ public static class DtoMapper
|
||||
viewCount,
|
||||
BuildStoryReplyDto(message),
|
||||
message.DiscussionMessageId,
|
||||
commentCount);
|
||||
commentCount,
|
||||
message.ClientMessageId);
|
||||
}
|
||||
|
||||
public static AttachmentDto ToDto(this MessageAttachment attachment, Guid chatId) =>
|
||||
|
||||
@@ -9,26 +9,52 @@ public sealed class PushDispatchBackgroundService(
|
||||
ILogger<PushDispatchBackgroundService> logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!queue.IsDurable)
|
||||
{
|
||||
await ProcessFallbackQueueAsync(stoppingToken);
|
||||
return;
|
||||
}
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var batch = await queue.LoadDueBatchAsync(stoppingToken);
|
||||
if (batch.Count == 0)
|
||||
{
|
||||
await queue.WaitForWorkAsync(stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var workItem in batch)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessAsync(workItem, stoppingToken);
|
||||
await queue.CompleteAsync(workItem.OutboxId!.Value, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"Background push dispatch failed for chat {ChatId}, message {MessageId}, kind {Kind}.",
|
||||
workItem.ChatId,
|
||||
workItem.MessageId,
|
||||
workItem.Kind);
|
||||
await queue.RescheduleAsync(workItem.OutboxId!.Value, ex, stoppingToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessFallbackQueueAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await foreach (var workItem in queue.ReadAllAsync(stoppingToken))
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessAsync(workItem, stoppingToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(
|
||||
ex,
|
||||
"Background push dispatch failed for chat {ChatId}, message {MessageId}, kind {Kind}.",
|
||||
workItem.ChatId,
|
||||
workItem.MessageId,
|
||||
workItem.Kind);
|
||||
}
|
||||
await ProcessAsync(workItem, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +69,6 @@ public sealed class PushDispatchBackgroundService(
|
||||
.SingleOrDefaultAsync(x => x.Id == workItem.ChatId, cancellationToken);
|
||||
if (chat is null)
|
||||
{
|
||||
logger.LogDebug("Skipping push dispatch because chat {ChatId} was not found.", workItem.ChatId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -51,15 +76,10 @@ public sealed class PushDispatchBackgroundService(
|
||||
.Include(x => x.Sender)
|
||||
.Include(x => x.Attachments)
|
||||
.SingleOrDefaultAsync(
|
||||
x => x.Id == workItem.MessageId &&
|
||||
x.ChatId == workItem.ChatId,
|
||||
x => x.Id == workItem.MessageId && x.ChatId == workItem.ChatId,
|
||||
cancellationToken);
|
||||
if (message is null)
|
||||
{
|
||||
logger.LogDebug(
|
||||
"Skipping push dispatch because message {MessageId} for chat {ChatId} was not found.",
|
||||
workItem.MessageId,
|
||||
workItem.ChatId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -67,9 +87,6 @@ public sealed class PushDispatchBackgroundService(
|
||||
{
|
||||
if (workItem.ActorUserId is null || string.IsNullOrWhiteSpace(workItem.ReactionEmoji))
|
||||
{
|
||||
logger.LogDebug(
|
||||
"Skipping reaction push dispatch for message {MessageId} because actor or emoji is missing.",
|
||||
workItem.MessageId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -77,9 +94,6 @@ public sealed class PushDispatchBackgroundService(
|
||||
.SingleOrDefaultAsync(x => x.Id == workItem.ActorUserId.Value, cancellationToken);
|
||||
if (reactor is null)
|
||||
{
|
||||
logger.LogDebug(
|
||||
"Skipping reaction push dispatch because reactor {UserId} was not found.",
|
||||
workItem.ActorUserId.Value);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Threading.Channels;
|
||||
using Ikar.Server.Data;
|
||||
using Ikar.Server.Data.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Ikar.Server.Infrastructure.Push;
|
||||
|
||||
@@ -13,19 +16,72 @@ public sealed record PushDispatchWorkItem(
|
||||
Guid MessageId,
|
||||
PushDispatchKind Kind = PushDispatchKind.ChatMessage,
|
||||
Guid? ActorUserId = null,
|
||||
string? ReactionEmoji = null);
|
||||
string? ReactionEmoji = null,
|
||||
long? OutboxId = null);
|
||||
|
||||
public sealed class PushDispatchQueue
|
||||
public sealed class PushDispatchQueue(IServiceScopeFactory? serviceScopeFactory = null)
|
||||
{
|
||||
private readonly Channel<PushDispatchWorkItem> _channel = Channel.CreateUnbounded<PushDispatchWorkItem>(
|
||||
new UnboundedChannelOptions
|
||||
private const int BatchSize = 50;
|
||||
private readonly Channel<PushDispatchWorkItem> _fallbackChannel =
|
||||
Channel.CreateUnbounded<PushDispatchWorkItem>(new UnboundedChannelOptions
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = false
|
||||
});
|
||||
private readonly Channel<bool> _wakeSignals = Channel.CreateBounded<bool>(
|
||||
new BoundedChannelOptions(1)
|
||||
{
|
||||
SingleReader = true,
|
||||
SingleWriter = false,
|
||||
FullMode = BoundedChannelFullMode.DropOldest
|
||||
});
|
||||
|
||||
public bool IsDurable => serviceScopeFactory is not null;
|
||||
|
||||
public void StageMessages(IkarDbContext dbContext, params Message?[] messages)
|
||||
{
|
||||
if (!IsDurable)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var message in messages.Where(message => message is not null).Cast<Message>())
|
||||
{
|
||||
StageWorkItem(
|
||||
dbContext,
|
||||
new PushDispatchWorkItem(message.ChatId, message.Id),
|
||||
$"message:{message.Id:D}");
|
||||
}
|
||||
}
|
||||
|
||||
public void StageReaction(
|
||||
IkarDbContext dbContext,
|
||||
Guid chatId,
|
||||
Guid messageId,
|
||||
Guid reactorUserId,
|
||||
string emoji)
|
||||
{
|
||||
if (!IsDurable)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
StageWorkItem(
|
||||
dbContext,
|
||||
new PushDispatchWorkItem(
|
||||
chatId,
|
||||
messageId,
|
||||
PushDispatchKind.MessageReaction,
|
||||
reactorUserId,
|
||||
emoji),
|
||||
$"reaction:{messageId:D}:{reactorUserId:D}:{emoji}");
|
||||
}
|
||||
|
||||
public ValueTask EnqueueAsync(Guid chatId, Guid messageId, CancellationToken cancellationToken = default) =>
|
||||
_channel.Writer.WriteAsync(new PushDispatchWorkItem(chatId, messageId), cancellationToken);
|
||||
EnqueueWorkItemAsync(
|
||||
new PushDispatchWorkItem(chatId, messageId),
|
||||
$"message:{messageId:D}",
|
||||
cancellationToken);
|
||||
|
||||
public ValueTask EnqueueReactionAsync(
|
||||
Guid chatId,
|
||||
@@ -33,15 +89,166 @@ public sealed class PushDispatchQueue
|
||||
Guid reactorUserId,
|
||||
string emoji,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
_channel.Writer.WriteAsync(
|
||||
EnqueueWorkItemAsync(
|
||||
new PushDispatchWorkItem(
|
||||
chatId,
|
||||
messageId,
|
||||
PushDispatchKind.MessageReaction,
|
||||
reactorUserId,
|
||||
emoji),
|
||||
$"reaction:{messageId:D}:{reactorUserId:D}:{emoji}",
|
||||
cancellationToken);
|
||||
|
||||
public IAsyncEnumerable<PushDispatchWorkItem> ReadAllAsync(CancellationToken cancellationToken) =>
|
||||
_channel.Reader.ReadAllAsync(cancellationToken);
|
||||
_fallbackChannel.Reader.ReadAllAsync(cancellationToken);
|
||||
|
||||
public async Task<IReadOnlyList<PushDispatchWorkItem>> LoadDueBatchAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (serviceScopeFactory is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
await using var scope = serviceScopeFactory.CreateAsyncScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<IkarDbContext>();
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
return await dbContext.PushDispatchOutbox
|
||||
.AsNoTracking()
|
||||
.Where(item => item.NextAttemptAt <= now)
|
||||
.OrderBy(item => item.Id)
|
||||
.Take(BatchSize)
|
||||
.Select(item => new PushDispatchWorkItem(
|
||||
item.ChatId,
|
||||
item.MessageId,
|
||||
(PushDispatchKind)item.Kind,
|
||||
item.ActorUserId,
|
||||
item.ReactionEmoji,
|
||||
item.Id))
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task CompleteAsync(long outboxId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (serviceScopeFactory is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var scope = serviceScopeFactory.CreateAsyncScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<IkarDbContext>();
|
||||
await dbContext.PushDispatchOutbox
|
||||
.Where(item => item.Id == outboxId)
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task RescheduleAsync(long outboxId, Exception error, CancellationToken cancellationToken)
|
||||
{
|
||||
if (serviceScopeFactory is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await using var scope = serviceScopeFactory.CreateAsyncScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<IkarDbContext>();
|
||||
var item = await dbContext.PushDispatchOutbox.SingleOrDefaultAsync(x => x.Id == outboxId, cancellationToken);
|
||||
if (item is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
item.AttemptCount++;
|
||||
var delaySeconds = Math.Min(900, 5 * (1 << Math.Min(item.AttemptCount, 8)));
|
||||
item.NextAttemptAt = DateTimeOffset.UtcNow.AddSeconds(delaySeconds);
|
||||
item.LastError = error.Message[..Math.Min(error.Message.Length, 1000)];
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<int> CountPendingAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (serviceScopeFactory is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
await using var scope = serviceScopeFactory.CreateAsyncScope();
|
||||
return await scope.ServiceProvider.GetRequiredService<IkarDbContext>()
|
||||
.PushDispatchOutbox.CountAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task WaitForWorkAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(TimeSpan.FromSeconds(5));
|
||||
try
|
||||
{
|
||||
await _wakeSignals.Reader.ReadAsync(timeout.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private async ValueTask EnqueueWorkItemAsync(
|
||||
PushDispatchWorkItem workItem,
|
||||
string deduplicationKey,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (serviceScopeFactory is null)
|
||||
{
|
||||
await _fallbackChannel.Writer.WriteAsync(workItem, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
await using var scope = serviceScopeFactory.CreateAsyncScope();
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<IkarDbContext>();
|
||||
dbContext.PushDispatchOutbox.Add(new PushDispatchOutboxItem
|
||||
{
|
||||
DeduplicationKey = deduplicationKey,
|
||||
ChatId = workItem.ChatId,
|
||||
MessageId = workItem.MessageId,
|
||||
Kind = (int)workItem.Kind,
|
||||
ActorUserId = workItem.ActorUserId,
|
||||
ReactionEmoji = workItem.ReactionEmoji
|
||||
});
|
||||
try
|
||||
{
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
dbContext.ChangeTracker.Clear();
|
||||
if (await dbContext.PushDispatchOutbox
|
||||
.AsNoTracking()
|
||||
.AnyAsync(item => item.DeduplicationKey == deduplicationKey, cancellationToken))
|
||||
{
|
||||
_wakeSignals.Writer.TryWrite(true);
|
||||
return;
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
|
||||
_wakeSignals.Writer.TryWrite(true);
|
||||
}
|
||||
|
||||
private static void StageWorkItem(
|
||||
IkarDbContext dbContext,
|
||||
PushDispatchWorkItem workItem,
|
||||
string deduplicationKey)
|
||||
{
|
||||
if (dbContext.PushDispatchOutbox.Local.Any(item => item.DeduplicationKey == deduplicationKey))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
dbContext.PushDispatchOutbox.Add(new PushDispatchOutboxItem
|
||||
{
|
||||
DeduplicationKey = deduplicationKey,
|
||||
ChatId = workItem.ChatId,
|
||||
MessageId = workItem.MessageId,
|
||||
Kind = (int)workItem.Kind,
|
||||
ActorUserId = workItem.ActorUserId,
|
||||
ReactionEmoji = workItem.ReactionEmoji
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ public sealed class PushNotificationDispatcher(
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Push dispatch failed for message {MessageId}.", message.Id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +124,7 @@ public sealed class PushNotificationDispatcher(
|
||||
ex,
|
||||
"Push dispatch failed for reaction to message {MessageId}.",
|
||||
message.Id);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 contentInfo = AttachmentContentInspector.Inspect(file);
|
||||
var (attachmentId, physicalPath) = CreateTargetPath(originalFileName);
|
||||
|
||||
await using (var source = file.OpenReadStream())
|
||||
@@ -31,7 +32,7 @@ public sealed class AttachmentStorageService(IWebHostEnvironment environment)
|
||||
Id = attachmentId,
|
||||
MessageId = messageId,
|
||||
OriginalFileName = originalFileName,
|
||||
ContentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType,
|
||||
ContentType = contentInfo.ContentType,
|
||||
FileSizeBytes = file.Length,
|
||||
StoragePath = physicalPath,
|
||||
SortOrder = sortOrder,
|
||||
@@ -51,6 +52,7 @@ public sealed class AttachmentStorageService(IWebHostEnvironment environment)
|
||||
}
|
||||
|
||||
var originalFileName = NormalizeOriginalFileName(file.FileName, "story-attachment");
|
||||
var contentInfo = AttachmentContentInspector.Inspect(file);
|
||||
var (attachmentId, physicalPath) = CreateTargetPath(originalFileName);
|
||||
|
||||
await using (var source = file.OpenReadStream())
|
||||
@@ -64,7 +66,7 @@ public sealed class AttachmentStorageService(IWebHostEnvironment environment)
|
||||
Id = attachmentId,
|
||||
StoryId = storyId,
|
||||
OriginalFileName = originalFileName,
|
||||
ContentType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType,
|
||||
ContentType = contentInfo.ContentType,
|
||||
FileSizeBytes = file.Length,
|
||||
StoragePath = physicalPath,
|
||||
SortOrder = sortOrder,
|
||||
|
||||
@@ -11,6 +11,7 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext)
|
||||
await EnableWriteAheadLoggingAsync(cancellationToken);
|
||||
await EnsureMessageAttachmentsTableAsync(cancellationToken);
|
||||
await EnsurePushDevicesTableAsync(cancellationToken);
|
||||
await EnsurePushDispatchOutboxTableAsync(cancellationToken);
|
||||
await EnsureUserPrivacySettingsTableAsync(cancellationToken);
|
||||
await EnsureChatInviteLinksTableAsync(cancellationToken);
|
||||
await EnsureChatJoinRequestsTableAsync(cancellationToken);
|
||||
@@ -67,6 +68,8 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext)
|
||||
await EnsureColumnAsync("Messages", "StoryReplyAttachmentKind", "TEXT NULL", cancellationToken);
|
||||
await EnsureColumnAsync("Messages", "DiscussionMessageId", "TEXT NULL", cancellationToken);
|
||||
await EnsureColumnAsync("Messages", "CommentCount", "INTEGER NOT NULL DEFAULT 0", cancellationToken);
|
||||
await EnsureColumnAsync("Messages", "ClientMessageId", "TEXT NULL", cancellationToken);
|
||||
await EnsureMessagesClientMessageIdIndexAsync(cancellationToken);
|
||||
await EnsureLegacyChannelPostsMarkedAsync(cancellationToken);
|
||||
await EnsureColumnAsync("MessageAttachments", "SortOrder", "INTEGER NOT NULL DEFAULT 0", cancellationToken);
|
||||
await EnsureColumnAsync("MessageAttachments", "Kind", "INTEGER NOT NULL DEFAULT 1", cancellationToken);
|
||||
@@ -729,6 +732,56 @@ public sealed class DatabaseSchemaBootstrapper(IkarDbContext dbContext)
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task EnsurePushDispatchOutboxTableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS "PushDispatchOutbox" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_PushDispatchOutbox" PRIMARY KEY AUTOINCREMENT,
|
||||
"DeduplicationKey" TEXT NOT NULL,
|
||||
"ChatId" TEXT NOT NULL,
|
||||
"MessageId" TEXT NOT NULL,
|
||||
"Kind" INTEGER NOT NULL,
|
||||
"ActorUserId" TEXT NULL,
|
||||
"ReactionEmoji" TEXT NULL,
|
||||
"AttemptCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"NextAttemptAt" TEXT NOT NULL,
|
||||
"CreatedAt" TEXT NOT NULL,
|
||||
"LastError" TEXT NULL
|
||||
);
|
||||
""",
|
||||
cancellationToken);
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IX_PushDispatchOutbox_DeduplicationKey"
|
||||
ON "PushDispatchOutbox" ("DeduplicationKey");
|
||||
""",
|
||||
cancellationToken);
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS "IX_PushDispatchOutbox_NextAttemptAt_Id"
|
||||
ON "PushDispatchOutbox" ("NextAttemptAt", "Id");
|
||||
""",
|
||||
cancellationToken);
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS "IX_PushDispatchOutbox_MessageId_Kind"
|
||||
ON "PushDispatchOutbox" ("MessageId", "Kind");
|
||||
""",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task EnsureMessagesClientMessageIdIndexAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await dbContext.Database.ExecuteSqlRawAsync(
|
||||
"""
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "IX_Messages_SenderId_ClientMessageId"
|
||||
ON "Messages" ("SenderId", "ClientMessageId")
|
||||
WHERE "ClientMessageId" IS NOT NULL;
|
||||
""",
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task EnsureColumnAsync(
|
||||
string tableName,
|
||||
string columnName,
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Security.Claims;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using Ikar.Server.Data;
|
||||
@@ -35,8 +36,15 @@ builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
builder.Services.AddDbContext<IkarDbContext>(options =>
|
||||
options.UseSqlite(builder.Configuration.GetConnectionString("IkarDb") ??
|
||||
"Data Source=Data/ikar.db"));
|
||||
{
|
||||
var configuredConnectionString = builder.Configuration.GetConnectionString("IkarDb") ??
|
||||
"Data Source=Data/ikar.db";
|
||||
var connectionString = new SqliteConnectionStringBuilder(configuredConnectionString)
|
||||
{
|
||||
DefaultTimeout = 30
|
||||
}.ToString();
|
||||
options.UseSqlite(connectionString, sqliteOptions => sqliteOptions.CommandTimeout(30));
|
||||
});
|
||||
|
||||
builder.Services.Configure<ForwardedHeadersOptions>(options =>
|
||||
{
|
||||
@@ -165,10 +173,10 @@ builder.Services.AddAuthorization(options =>
|
||||
});
|
||||
builder.Services.AddSingleton<PresenceTracker>();
|
||||
builder.Services.AddSingleton<CallSessionStore>();
|
||||
builder.Services.AddHostedService<CallSessionCleanupService>();
|
||||
builder.Services.AddSingleton(new ServerRuntimeState(DateTimeOffset.UtcNow));
|
||||
builder.Services.AddScoped<TokenService>();
|
||||
builder.Services.AddSingleton<BotTokenService>();
|
||||
builder.Services.AddSingleton<PhoneAuthChallengeStore>();
|
||||
builder.Services.AddSingleton<EmailAuthChallengeStore>();
|
||||
builder.Services.AddScoped<IEmailCodeSender, SmtpEmailCodeSender>();
|
||||
builder.Services.AddScoped<DatabaseSeeder>();
|
||||
@@ -228,6 +236,13 @@ app.Use(async (context, next) =>
|
||||
app.UseForwardedHeaders();
|
||||
app.UseStaticFiles();
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
context.Response.Headers["X-Content-Type-Options"] = "nosniff";
|
||||
context.Response.Headers["Referrer-Policy"] = "no-referrer";
|
||||
context.Response.Headers["X-Frame-Options"] = "DENY";
|
||||
await next();
|
||||
});
|
||||
app.Use(async (context, next) =>
|
||||
{
|
||||
if (!ShouldEnforceAndroidClientVersion(context.Request))
|
||||
{
|
||||
@@ -269,17 +284,62 @@ app.UseMiddleware<ApiRateLimitMiddleware>();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
app.MapGet("/health", async (IkarDbContext dbContext, CancellationToken cancellationToken) =>
|
||||
app.MapGet("/health", async (
|
||||
IkarDbContext dbContext,
|
||||
IWebHostEnvironment environment,
|
||||
IConfiguration configuration,
|
||||
PushDispatchQueue pushQueue,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var canConnect = await dbContext.Database.CanConnectAsync(cancellationToken);
|
||||
return canConnect
|
||||
? Results.Ok(new { status = "ok" })
|
||||
: Results.StatusCode(StatusCodes.Status503ServiceUnavailable);
|
||||
var dataPath = Path.Combine(environment.ContentRootPath, "Data");
|
||||
var freeDiskBytes = ResolveAvailableDiskSpace(dataPath);
|
||||
var diskHealthy = freeDiskBytes >= 512L * 1024 * 1024;
|
||||
var pendingPushCount = await pushQueue.CountPendingAsync(cancellationToken);
|
||||
|
||||
var backupDirectory = configuration["Backup:Directory"];
|
||||
DateTimeOffset? latestBackupAt = null;
|
||||
if (!string.IsNullOrWhiteSpace(backupDirectory) && Directory.Exists(backupDirectory))
|
||||
{
|
||||
latestBackupAt = Directory.EnumerateFiles(backupDirectory, "ikar-*.tar.gz")
|
||||
.Select(path => new DateTimeOffset(File.GetLastWriteTimeUtc(path), TimeSpan.Zero))
|
||||
.OrderByDescending(value => value)
|
||||
.FirstOrDefault();
|
||||
}
|
||||
|
||||
var backupRequired = configuration.GetValue("Backup:RequireFreshBackup", false);
|
||||
var backupHealthy = !backupRequired ||
|
||||
latestBackupAt is not null && latestBackupAt >= DateTimeOffset.UtcNow.AddHours(-36);
|
||||
var healthy = canConnect && diskHealthy && backupHealthy;
|
||||
var payload = new
|
||||
{
|
||||
status = healthy ? "ok" : "degraded",
|
||||
database = canConnect ? "ok" : "unavailable",
|
||||
disk = diskHealthy ? "ok" : "low",
|
||||
freeDiskBytes,
|
||||
backup = backupHealthy ? "ok" : "stale",
|
||||
latestBackupAt,
|
||||
pendingPushCount
|
||||
};
|
||||
return Results.Json(
|
||||
payload,
|
||||
statusCode: healthy ? StatusCodes.Status200OK : StatusCodes.Status503ServiceUnavailable);
|
||||
});
|
||||
app.MapHub<MessengerHub>("/hubs/messenger");
|
||||
|
||||
app.Run();
|
||||
|
||||
static long ResolveAvailableDiskSpace(string path)
|
||||
{
|
||||
var fullPath = Path.GetFullPath(path);
|
||||
var drive = DriveInfo.GetDrives()
|
||||
.Where(candidate => fullPath.StartsWith(candidate.Name, StringComparison.Ordinal))
|
||||
.OrderByDescending(candidate => candidate.Name.Length)
|
||||
.FirstOrDefault();
|
||||
|
||||
return drive?.AvailableFreeSpace ?? new DriveInfo(Path.GetPathRoot(fullPath)!).AvailableFreeSpace;
|
||||
}
|
||||
|
||||
static bool ShouldEnforceAndroidClientVersion(HttpRequest request)
|
||||
{
|
||||
if (request.Path.StartsWithSegments("/health", StringComparison.OrdinalIgnoreCase) ||
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
"ServiceAccountJson": ""
|
||||
},
|
||||
"WebRtc": {
|
||||
"RingingTimeoutSeconds": 60,
|
||||
"MaximumCallDurationHours": 8,
|
||||
"IceServers": [
|
||||
{
|
||||
"Urls": [
|
||||
|
||||
@@ -58,7 +58,8 @@ public sealed record MessageDto(
|
||||
int? ViewCount = null,
|
||||
StoryReplyDto? StoryReply = null,
|
||||
Guid? DiscussionMessageId = null,
|
||||
int? CommentCount = null);
|
||||
int? CommentCount = null,
|
||||
Guid? ClientMessageId = null);
|
||||
|
||||
public sealed record SendMessageRequest(
|
||||
string Text,
|
||||
@@ -66,7 +67,8 @@ public sealed record SendMessageRequest(
|
||||
Guid? TopicId = null,
|
||||
bool? HasProtectedContent = null,
|
||||
int? TtlSeconds = null,
|
||||
Guid? StoryReplyStoryId = null);
|
||||
Guid? StoryReplyStoryId = null,
|
||||
Guid? ClientMessageId = null);
|
||||
|
||||
public sealed record UpdateMessageRequest(string Text);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user