Improve IKAR reliability, security, and Android UX

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