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