Clear cache on logout

Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
2026-07-05 22:04:55 +03:00
Unverified
parent 5a28f0c756
commit 27ff5e2e4a
23 changed files with 353 additions and 52 deletions
@@ -19,3 +19,11 @@ actual suspend fun wipeAttachmentCacheDirectories() {
} }
} }
} }
actual suspend fun wipeInstanceAuxiliaryCacheDirectory(instanceId: String) {
withContext(Dispatchers.IO) {
val safe = instanceId.trim().replace(Regex("[^a-zA-Z0-9._-]"), "_")
if (safe.isEmpty()) return@withContext
File(UtilsLibrary.context.cacheDir, "fromchat/instances/$safe").deleteRecursively()
}
}
@@ -52,12 +52,11 @@ import ru.fromchat.api.instance.InstanceIdGuard
import ru.fromchat.api.instance.InstanceIdResolveResult import ru.fromchat.api.instance.InstanceIdResolveResult
import ru.fromchat.api.instance.resolveInstanceId import ru.fromchat.api.instance.resolveInstanceId
import ru.fromchat.api.local.WebSocketManager import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.send.cancelOutboxProcessing
import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.cache.readOutboundFileBytes import ru.fromchat.api.local.cache.readOutboundFileBytes
import ru.fromchat.api.local.db.clearAccountCacheOnLogout
import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.api.local.download.streamEncryptedFileToDisk import ru.fromchat.api.local.download.streamEncryptedFileToDisk
import ru.fromchat.api.local.send.scheduleOutboxProcessing import ru.fromchat.api.local.send.scheduleOutboxProcessing
import ru.fromchat.api.schema.calls.CallSignalingLiveKitControl import ru.fromchat.api.schema.calls.CallSignalingLiveKitControl
@@ -115,8 +114,6 @@ import ru.fromchat.api.schema.websocket.types.DmTypingData
import ru.fromchat.api.schema.websocket.types.SubscribeStatusData import ru.fromchat.api.schema.websocket.types.SubscribeStatusData
import ru.fromchat.config.ServerConfig import ru.fromchat.config.ServerConfig
import ru.fromchat.config.Settings import ru.fromchat.config.Settings
import ru.fromchat.ui.chat.panels.dm.DmPanelCache
import ru.fromchat.ui.chat.utils.PublicChatPanelCache
import kotlin.concurrent.Volatile import kotlin.concurrent.Volatile
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@@ -236,13 +233,10 @@ object ApiClient {
val path = response.call.request.url.encodedPath val path = response.call.request.url.encodedPath
val isCredentialCheck = path.endsWith("/login") || path.endsWith("/register") val isCredentialCheck = path.endsWith("/login") || path.endsWith("/register")
if (!isCredentialCheck) { if (!isCredentialCheck) {
token = null MainScope().launch {
user = null runCatching { WebSocketManager.disconnect() }
clearSuspensionState() runCatching { clearLocalSession() }
onAuthError?.let { onAuthError?.invoke()
MainScope().launch {
it()
}
} }
} }
} }
@@ -370,6 +364,7 @@ object ApiClient {
} }
Settings.lastKnownServerInstanceId = id Settings.lastKnownServerInstanceId = id
CacheContext.setActiveInstance(id, user?.id) CacheContext.setActiveInstance(id, user?.id)
PublicChatProfileSync.ensureStarted()
scheduleOutboxProcessing(id) scheduleOutboxProcessing(id)
} }
else -> Unit else -> Unit
@@ -1150,6 +1145,17 @@ object ApiClient {
} }
} }
/** Clears in-memory and on-disk partial download state on logout. */
suspend fun clearAllDownloadCachesOnLogout() {
val keys = partialDownloadMetaCache.keys.toList()
keys.forEach { clearPartialEncryptedDownload(it) }
partialDownloadMetaCache.clear()
pausedDownloadIndexCache = emptySet()
pausedDownloadIndexPath()?.let { path ->
runCatching { PlatformFileSystem.delete(path) }
}
}
private fun pausedDownloadIndexPath(): String? { private fun pausedDownloadIndexPath(): String? {
val dir = encryptedDownloadsDir() ?: return null val dir = encryptedDownloadsDir() ?: return null
return "$dir/paused_keys.txt" return "$dir/paused_keys.txt"
@@ -1383,10 +1389,7 @@ object ApiClient {
*/ */
suspend fun clearLocalSession() { suspend fun clearLocalSession() {
val instanceId = runCatching { CacheContext.activeInstanceId.value.trim() }.getOrDefault("") val instanceId = runCatching { CacheContext.activeInstanceId.value.trim() }.getOrDefault("")
if (instanceId.isNotEmpty()) { runCatching { clearAccountCacheOnLogout(instanceId) }
runCatching { cancelOutboxProcessing(instanceId) }
runCatching { MessageRepository.purgeAllPendingForInstance() }
}
val uid = user?.id val uid = user?.id
secureSettings.remove("auth_token") secureSettings.remove("auth_token")
settings.remove("user_info") settings.remove("user_info")
@@ -1399,10 +1402,6 @@ object ApiClient {
uid?.let { UpdateSyncManager.clearPersistedSeqForUser(it) } uid?.let { UpdateSyncManager.clearPersistedSeqForUser(it) }
UpdateSyncManager.resetInMemoryOnLogout() UpdateSyncManager.resetInMemoryOnLogout()
runCatching { IdentityKeyManager.clearLocalKeys() } runCatching { IdentityKeyManager.clearLocalKeys() }
runCatching { ProfileCache.clear() }
runCatching { DmPanelCache.clearAll() }
runCatching { PublicChatProfileCache.clear() }
runCatching { PublicChatPanelCache.clear() }
runCatching { CacheContext.clearActive() } runCatching { CacheContext.clearActive() }
} }
@@ -2,6 +2,7 @@ package ru.fromchat.api
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -22,6 +23,7 @@ object PublicChatProfileSync {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var started = false private var started = false
private var syncJob: Job? = null
fun ensureStarted() { fun ensureStarted() {
if (started) return if (started) return
@@ -31,12 +33,18 @@ object PublicChatProfileSync {
scope.launch { refreshFromNetworkIfNeeded() } scope.launch { refreshFromNetworkIfNeeded() }
} }
scope.launch { syncJob = scope.launch {
runCatching { PublicChatProfileCache.hydrateFromDisk() } runCatching { PublicChatProfileCache.hydrateFromDisk() }
syncUntilLoaded() syncUntilLoaded()
} }
} }
fun resetOnLogout() {
syncJob?.cancel()
syncJob = null
started = false
}
suspend fun refreshFromNetwork(): PublicChatProfile { suspend fun refreshFromNetwork(): PublicChatProfile {
if (ApiClient.token.isNullOrEmpty()) { if (ApiClient.token.isNullOrEmpty()) {
return PublicChatProfileCache.profile return PublicChatProfileCache.profile
@@ -420,4 +420,10 @@ object DecryptedFileCache {
private fun sanitizeKeyPart(value: String): String = private fun sanitizeKeyPart(value: String): String =
value.replace(Regex("[^a-zA-Z0-9._-]"), "_") value.replace(Regex("[^a-zA-Z0-9._-]"), "_")
suspend fun clearMemoryCache() {
cacheMutex.withLock {
memoryCache.clear()
}
}
} }
@@ -501,4 +501,10 @@ object DecryptedImageCache {
private fun invalidatePath(path: String) { private fun invalidatePath(path: String) {
runCatching { PlatformFileSystem.delete(path) } runCatching { PlatformFileSystem.delete(path) }
} }
suspend fun clearMemoryCache() {
cacheMutex.withLock {
memoryCache.clear()
}
}
} }
@@ -8,6 +8,9 @@ expect suspend fun wipeFromChatCacheDirectory()
/** Removes decrypted attachment blobs and partial download state outside `fromchat/`. */ /** Removes decrypted attachment blobs and partial download state outside `fromchat/`. */
expect suspend fun wipeAttachmentCacheDirectories() expect suspend fun wipeAttachmentCacheDirectories()
/** Deletes `cacheDir/fromchat/instances/<instanceId>/` auxiliary files (export index, pending saves). */
expect suspend fun wipeInstanceAuxiliaryCacheDirectory(instanceId: String)
suspend fun wipeAllOnDiskAttachmentCaches() { suspend fun wipeAllOnDiskAttachmentCaches() {
wipeFromChatCacheDirectory() wipeFromChatCacheDirectory()
wipeAttachmentCacheDirectories() wipeAttachmentCacheDirectories()
@@ -80,16 +80,31 @@ object PendingFileSaveRegistry {
} }
} }
private fun indexPath(): String? { private fun indexPath(): String? = instanceIndexPath(
runCatching { CacheContext.requireActiveInstanceId() }.getOrNull() ?: "default",
)
private fun instanceIndexPath(instanceId: String): String? {
val base = PlatformFileSystem.getAppCacheDirectory() val base = PlatformFileSystem.getAppCacheDirectory()
if (base.isEmpty()) return null if (base.isEmpty()) return null
val instanceId = runCatching { CacheContext.requireActiveInstanceId() }.getOrNull() ?: "default" val safe = instanceId.trim().replace(Regex("[^a-zA-Z0-9._-]"), "_")
val safe = instanceId.replace(Regex("[^a-zA-Z0-9._-]"), "_") if (safe.isEmpty()) return null
val dir = "$base/fromchat/instances/$safe" val dir = "$base/fromchat/instances/$safe"
PlatformFileSystem.ensureDirectory(dir) PlatformFileSystem.ensureDirectory(dir)
return "$dir/$INDEX_FILE" return "$dir/$INDEX_FILE"
} }
suspend fun clearForInstance(instanceId: String) {
val path = instanceIndexPath(instanceId) ?: return
mutex.withLock {
memory.clear()
diskLoaded = false
}
withContext(Dispatchers.Default) {
runCatching { PlatformFileSystem.delete(path) }
}
}
private suspend fun readIndexFromDisk(): List<PendingFileSaveEntry> { private suspend fun readIndexFromDisk(): List<PendingFileSaveEntry> {
val path = indexPath() ?: return emptyList() val path = indexPath() ?: return emptyList()
if (!PlatformFileSystem.exists(path)) return emptyList() if (!PlatformFileSystem.exists(path)) return emptyList()
@@ -2,18 +2,31 @@ package ru.fromchat.api.local.db
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.fromchat.api.local.db.store.MessageDatabaseProvider import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.send.cancelOutboxProcessing import ru.fromchat.api.PublicChatProfileSync
import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.cache.DecryptedFileCache
import ru.fromchat.api.local.cache.DecryptedImageCache
import ru.fromchat.api.local.cache.PendingFileSaveRegistry
import ru.fromchat.api.local.cache.wipeAttachmentCacheDirectories import ru.fromchat.api.local.cache.wipeAttachmentCacheDirectories
import ru.fromchat.api.local.cache.wipeFromChatCacheDirectory import ru.fromchat.api.local.cache.wipeFromChatCacheDirectory
import ru.fromchat.api.local.cache.wipeInstanceAuxiliaryCacheDirectory
import ru.fromchat.api.local.db.store.InstanceRegistryStore
import ru.fromchat.api.local.db.store.MessageDatabaseProvider
import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.api.local.download.AttachmentDownloadNotifier
import ru.fromchat.api.local.download.AttachmentDownloadScheduler
import ru.fromchat.api.local.download.DownloadedFileRegistry
import ru.fromchat.api.local.download.LocalDecodedImageCache
import ru.fromchat.api.local.send.cancelOutboxProcessing
import ru.fromchat.ui.chat.panels.dm.DmPanelCache import ru.fromchat.ui.chat.panels.dm.DmPanelCache
import ru.fromchat.ui.chat.utils.PublicChatPanelCache import ru.fromchat.ui.chat.utils.PublicChatPanelCache
/** /**
* Drops the on-disk FromChat cache tree and reopens SQLite on next access. * Drops the on-disk FromChat cache tree and reopens SQLite on next access.
* Call [writeFromChatCacheGeneration] after this when wiping from settings. * Call [ru.fromchat.api.local.cache.writeFromChatCacheGeneration] after this when wiping from settings.
*/ */
suspend fun wipeLocalCacheOnDisk() { suspend fun wipeLocalCacheOnDisk() {
val instanceId = runCatching { CacheContext.activeInstanceId.value.trim() }.getOrDefault("") val instanceId = runCatching { CacheContext.activeInstanceId.value.trim() }.getOrDefault("")
@@ -25,7 +38,39 @@ suspend fun wipeLocalCacheOnDisk() {
} }
wipeFromChatCacheDirectory() wipeFromChatCacheDirectory()
wipeAttachmentCacheDirectories() wipeAttachmentCacheDirectories()
PublicChatProfileCache.clear() clearInMemoryAccountCaches()
PublicChatPanelCache.clear() }
DmPanelCache.clearAll()
/**
* Clears all per-account cache for the active server instance on logout.
* Preserves other server-instance partitions in SQLite for multi-server use.
*/
suspend fun clearAccountCacheOnLogout(instanceId: String) {
val id = instanceId.trim()
PublicChatProfileSync.resetOnLogout()
if (id.isNotEmpty()) {
cancelOutboxProcessing(id)
runCatching { MessageRepository.purgeAllPendingForInstance() }
runCatching { AttachmentDownloadScheduler.cancelAllOnLogout() }
runCatching { InstanceRegistryStore.purgePartition(id) }
runCatching { InstanceRegistryStore.clearServerBindingForCurrentConfig() }
runCatching { wipeInstanceAuxiliaryCacheDirectory(id) }
runCatching { DownloadedFileRegistry.clearForInstance(id) }
runCatching { PendingFileSaveRegistry.clearForInstance(id) }
}
runCatching { ApiClient.clearAllDownloadCachesOnLogout() }
wipeAttachmentCacheDirectories()
clearInMemoryAccountCaches()
}
private suspend fun clearInMemoryAccountCaches() {
runCatching { ProfileCache.clear() }
runCatching { PublicChatProfileCache.clear() }
runCatching { DmPanelCache.clearAll() }
PublicChatPanelCache.clear()
AttachmentDownloadNotifier.resetOnLogout()
DecryptedImageCache.clearMemoryCache()
DecryptedFileCache.clearMemoryCache()
MessageRepository.resetListPreviewStringsOnLogout()
LocalDecodedImageCache.evictPrefix("img_")
} }
@@ -9,6 +9,7 @@ import ru.fromchat.api.instance.configKey
import ru.fromchat.api.local.db.store.PublicChatProfileCache import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.ui.chat.utils.PublicChatPanelCache import ru.fromchat.ui.chat.utils.PublicChatPanelCache
import ru.fromchat.ui.chat.panels.dm.DmPanelCache import ru.fromchat.ui.chat.panels.dm.DmPanelCache
import ru.fromchat.config.Settings
import kotlin.time.Clock import kotlin.time.Clock
object InstanceRegistryStore { object InstanceRegistryStore {
@@ -111,4 +112,12 @@ object InstanceRegistryStore {
} }
} }
} }
suspend fun clearServerBindingForCurrentConfig() {
withContext(Dispatchers.Default) {
MessageDatabaseProvider.withDatabaseRecover {
db.messageDatabaseQueries.deleteServerBinding(Settings.serverConfig.configKey())
}
}
}
} }
@@ -91,10 +91,10 @@ object MessageCacheStore {
} }
suspend fun loadPublicMessages(): List<Message> = suspend fun loadPublicMessages(): List<Message> =
loadMessages(conversationIdForPublic()) ProfileCache.enrichPublicMessagesForDisplay(loadMessages(conversationIdForPublic()))
suspend fun loadRecentPublicMessages(limit: Long): List<Message> = suspend fun loadRecentPublicMessages(limit: Long): List<Message> =
loadRecentMessages(conversationIdForPublic(), limit) ProfileCache.enrichPublicMessagesForDisplay(loadRecentMessages(conversationIdForPublic(), limit))
fun loadRecentPublicMessagesImmediate(instanceId: String, limit: Long = 128): List<Message> { fun loadRecentPublicMessagesImmediate(instanceId: String, limit: Long = 128): List<Message> {
if (instanceId.isBlank()) return emptyList() if (instanceId.isBlank()) return emptyList()
@@ -105,11 +105,13 @@ object MessageCacheStore {
.map { row: DbMessage -> row.toAppMessage() } .map { row: DbMessage -> row.toAppMessage() }
.reversed() .reversed()
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id) val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
return sortMessagesForChatDisplay( return ProfileCache.enrichPublicMessagesForDisplay(
validatedOrEmpty( sortMessagesForChatDisplay(
convId, validatedOrEmpty(
dedupeMessagesByClientId( convId,
enrichQueuedOutboundUi(withoutSuperseded, convId), dedupeMessagesByClientId(
enrichQueuedOutboundUi(withoutSuperseded, convId),
),
), ),
), ),
) )
@@ -150,6 +152,7 @@ object MessageCacheStore {
} }
suspend fun replacePublicMessages(messages: List<Message>) { suspend fun replacePublicMessages(messages: List<Message>) {
ProfileCache.mergePreviewFromPublicMessages(messages)
conversationIdForPublic().let { conversationIdForPublic().let {
replaceMessages( replaceMessages(
it, it,
@@ -225,6 +228,7 @@ object MessageCacheStore {
} }
suspend fun upsertPublicMessage(message: Message) { suspend fun upsertPublicMessage(message: Message) {
ProfileCache.mergePreviewFromPublicMessage(message)
upsertSingle(conversationIdForPublic(), message) upsertSingle(conversationIdForPublic(), message)
} }
@@ -280,6 +284,7 @@ object MessageCacheStore {
} }
suspend fun confirmPublicMessage(clientMessageId: String, confirmed: Message) { suspend fun confirmPublicMessage(clientMessageId: String, confirmed: Message) {
ProfileCache.mergePreviewFromPublicMessage(confirmed)
confirmMessage(conversationIdForPublic(), clientMessageId, confirmed) confirmMessage(conversationIdForPublic(), clientMessageId, confirmed)
} }
@@ -149,4 +149,8 @@ object MessageRepository {
MessageCacheStore.pruneEmptyConversations() MessageCacheStore.pruneEmptyConversations()
suspend fun clearAllCache() = MessageCacheStore.clearAll() suspend fun clearAllCache() = MessageCacheStore.clearAll()
fun resetListPreviewStringsOnLogout() {
MessageCacheStore.listPreviewStrings = null
}
} }
@@ -6,6 +6,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import ru.fromchat.api.ApiClient
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.schema.user.User import ru.fromchat.api.schema.user.User
import ru.fromchat.api.schema.user.profile.UserProfile import ru.fromchat.api.schema.user.profile.UserProfile
@@ -182,7 +183,8 @@ object ProfileCache {
val existing = get(uid) val existing = get(uid)
if (existing != null && !existing.isClientPreviewOnly) return if (existing != null && !existing.isClientPreviewOnly) return
val uname = message.username.ifBlank { existing?.username ?: return } val uname = message.username.trim().ifBlank { existing?.username?.trim().orEmpty() }
if (uname.isBlank()) return
val display = existing?.displayName?.takeIf { it.isNotBlank() } ?: uname val display = existing?.displayName?.takeIf { it.isNotBlank() } ?: uname
val pic = message.profile_picture?.takeIf { it.isNotBlank() } ?: existing?.profilePicture val pic = message.profile_picture?.takeIf { it.isNotBlank() } ?: existing?.profilePicture
@@ -206,6 +208,43 @@ object ProfileCache {
) )
} }
fun mergePreviewFromPublicMessages(messages: Iterable<Message>) {
messages.forEach(::mergePreviewFromPublicMessage)
}
/**
* Fills blank sender fields on a public-chat [Message] from [ProfileCache] or the current user.
*/
fun enrichPublicMessageForDisplay(
message: Message,
currentUserId: Int? = ApiClient.user?.id,
): Message {
val self = currentUserId
if (self != null && message.user_id == self) {
val user = ApiClient.user
return message.copy(
username = message.username.trim().ifBlank { user?.username.orEmpty() },
profile_picture = message.profile_picture?.takeIf { it.isNotBlank() }
?: user?.profile_picture,
)
}
val profile = get(message.user_id)
return message.copy(
username = message.username.trim().ifBlank {
profile?.visibleUsername(self).orEmpty()
},
profile_picture = message.profile_picture?.takeIf { it.isNotBlank() }
?: profile?.profilePicture,
verified = message.verified ?: profile?.verified,
verificationStatus = message.verificationStatus ?: profile?.verificationStatus,
)
}
fun enrichPublicMessagesForDisplay(
messages: List<Message>,
currentUserId: Int? = ApiClient.user?.id,
): List<Message> = messages.map { enrichPublicMessageForDisplay(it, currentUserId) }
fun onActiveInstanceChanged(instanceId: String) { fun onActiveInstanceChanged(instanceId: String) {
ioScope.launch { ioScope.launch {
persistMutex.withLock { persistMutex.withLock {
@@ -369,4 +369,11 @@ object AttachmentDownloadNotifier {
val id = keys.firstOrNull() ?: return DownloadProgressThrottle() val id = keys.firstOrNull() ?: return DownloadProgressThrottle()
return progressThrottleByKey.getOrPut(id) { DownloadProgressThrottle() } return progressThrottleByKey.getOrPut(id) { DownloadProgressThrottle() }
} }
fun resetOnLogout() {
_progressPercentByKey.value = emptyMap()
_failedKeys.value = emptySet()
_cancelledKeys.value = emptySet()
progressThrottleByKey.clear()
}
} }
@@ -202,6 +202,13 @@ object AttachmentDownloadScheduler {
}.thenBy { it.enqueuedAt }, }.thenBy { it.enqueuedAt },
) )
} }
suspend fun cancelAllOnLogout() {
val keys = mutex.withLock {
(activeJobs.keys + waiting.map { it.storageKey } + keyToDeferred.keys).toSet()
}
keys.forEach { cancel(it) }
}
} }
internal fun checkAttachmentDownloadActive(storageKey: String) { internal fun checkAttachmentDownloadActive(storageKey: String) {
@@ -142,16 +142,31 @@ object DownloadedFileRegistry {
private fun sanitizeKeyPart(value: String): String = private fun sanitizeKeyPart(value: String): String =
value.replace(Regex("[^a-zA-Z0-9._-]"), "_") value.replace(Regex("[^a-zA-Z0-9._-]"), "_")
private fun indexPath(): String? { private fun indexPath(): String? = instanceIndexPath(
runCatching { CacheContext.requireActiveInstanceId() }.getOrNull() ?: "default",
)
private fun instanceIndexPath(instanceId: String): String? {
val base = PlatformFileSystem.getAppCacheDirectory() val base = PlatformFileSystem.getAppCacheDirectory()
if (base.isEmpty()) return null if (base.isEmpty()) return null
val instanceId = runCatching { CacheContext.requireActiveInstanceId() }.getOrNull() ?: "default" val safe = instanceId.trim().replace(Regex("[^a-zA-Z0-9._-]"), "_")
val safe = instanceId.replace(Regex("[^a-zA-Z0-9._-]"), "_") if (safe.isEmpty()) return null
val dir = "$base/fromchat/instances/$safe" val dir = "$base/fromchat/instances/$safe"
PlatformFileSystem.ensureDirectory(dir) PlatformFileSystem.ensureDirectory(dir)
return "$dir/$INDEX_FILE" return "$dir/$INDEX_FILE"
} }
suspend fun clearForInstance(instanceId: String) {
val path = instanceIndexPath(instanceId) ?: return
mutex.withLock {
memory.clear()
diskIndexLoaded = false
}
withContext(Dispatchers.Default) {
runCatching { PlatformFileSystem.delete(path) }
}
}
private suspend fun readIndexFromDisk(): Map<String, String> { private suspend fun readIndexFromDisk(): Map<String, String> {
val path = indexPath() ?: return emptyMap() val path = indexPath() ?: return emptyMap()
if (!PlatformFileSystem.exists(path)) return emptyMap() if (!PlatformFileSystem.exists(path)) return emptyMap()
@@ -59,8 +59,10 @@ import ru.fromchat.api.DeferredStartupNetwork
import ru.fromchat.api.PublicChatProfileSync import ru.fromchat.api.PublicChatProfileSync
import ru.fromchat.api.UpdateSyncManager import ru.fromchat.api.UpdateSyncManager
import ru.fromchat.api.calls.CallStore import ru.fromchat.api.calls.CallStore
import ru.fromchat.api.instance.bootstrapSessionInstance
import ru.fromchat.api.instance.bootstrapSessionOnStartup import ru.fromchat.api.instance.bootstrapSessionOnStartup
import ru.fromchat.api.instance.logoutIfInstanceUnsupported import ru.fromchat.api.instance.logoutIfInstanceUnsupported
import ru.fromchat.api.instance.scheduleSessionInstanceNetworkRefresh
import ru.fromchat.api.local.WebSocketManager import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.cache.ensureFromChatCacheGeneration import ru.fromchat.api.local.cache.ensureFromChatCacheGeneration
@@ -427,6 +429,16 @@ fun App(
composable("auth") { composable("auth") {
AuthScreen( AuthScreen(
onAuthSuccess = { onAuthSuccess = {
MainScope().launch {
runCatching {
bootstrapSessionInstance(
hasToken = true,
forceNetwork = false,
)
}
PublicChatProfileSync.ensureStarted()
scheduleSessionInstanceNetworkRefresh()
}
WebSocketManager.connect(forceRestart = true) WebSocketManager.connect(forceRestart = true)
navController.navigateAndWipeBackStack("chat") navController.navigateAndWipeBackStack("chat")
}, },
@@ -12,6 +12,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.pr0gramm3r101.utils.crypto.deriveAuthSecret import com.pr0gramm3r101.utils.crypto.deriveAuthSecret
import io.ktor.client.call.body import io.ktor.client.call.body
@@ -22,6 +23,8 @@ import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.ApiClient import ru.fromchat.api.ApiClient
import ru.fromchat.api.crypto.IdentityKeyManager import ru.fromchat.api.crypto.IdentityKeyManager
import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.db.clearAccountCacheOnLogout
import ru.fromchat.api.instance.ServerProbeResult import ru.fromchat.api.instance.ServerProbeResult
import ru.fromchat.api.instance.probeServer import ru.fromchat.api.instance.probeServer
import ru.fromchat.api.schema.core.ErrorResponse import ru.fromchat.api.schema.core.ErrorResponse
@@ -98,6 +101,8 @@ private suspend fun fullLogin(
password: String, password: String,
request: suspend () -> LoginResponse, request: suspend () -> LoginResponse,
) { ) {
val previousInstanceId = runCatching { CacheContext.activeInstanceId.value.trim() }.getOrDefault("")
runCatching { clearAccountCacheOnLogout(previousInstanceId) }
ApiClient.clearMemorySession() ApiClient.clearMemorySession()
val response = request() val response = request()
@@ -257,6 +262,31 @@ fun AuthScreen(
bio = "" bio = ""
} }
LaunchedEffect(flowState.pagerState) {
var settledPage = flowState.pagerState.currentPage
snapshotFlow { flowState.pagerState.currentPage }
.collect { page ->
if (page < settledPage) {
when (page) {
AuthFlowStep.Username.ordinal -> {
password = ""
confirmPassword = ""
}
AuthFlowStep.Password.ordinal -> {
password = ""
confirmPassword = ""
}
AuthFlowStep.ConfirmPassword.ordinal -> {
confirmPassword = ""
}
}
}
settledPage = page
}
}
ExpressiveStepFlowScaffold( ExpressiveStepFlowScaffold(
flowState = flowState, flowState = flowState,
pages = listOf( pages = listOf(
@@ -434,6 +434,7 @@ fun ChatInput(
enabled = !isReadOnly, enabled = !isReadOnly,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxWidth()
.align(Alignment.Bottom), .align(Alignment.Bottom),
textStyle = inputTextStyle, textStyle = inputTextStyle,
singleLine = false, singleLine = false,
@@ -452,6 +453,7 @@ fun ChatInput(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.defaultMinSize(minHeight = ChatInputIconSlotSize) .defaultMinSize(minHeight = ChatInputIconSlotSize)
.animateContentSize()
.align(Alignment.BottomStart), .align(Alignment.BottomStart),
contentAlignment = Alignment.CenterStart, contentAlignment = Alignment.CenterStart,
) { ) {
@@ -469,9 +471,7 @@ fun ChatInput(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} }
Box(modifier = Modifier.animateContentSize()) { innerTextField()
innerTextField()
}
} }
} }
}, },
@@ -3,9 +3,11 @@ package ru.fromchat.ui.chat
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.db.store.ProfileCache import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
import ru.fromchat.api.local.db.store.visibleUsername import ru.fromchat.api.local.db.store.visibleUsername
import ru.fromchat.api.local.db.store.visibleDisplayName
import ru.fromchat.message_sender_you import ru.fromchat.message_sender_you
import ru.fromchat.user_fallback import ru.fromchat.user_fallback
@@ -38,3 +40,27 @@ fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
} }
return message.username return message.username
} }
fun messageSenderProfilePicture(
message: Message,
currentUserId: Int? = ApiClient.user?.id,
): String? {
if (currentUserId != null && message.user_id == currentUserId) {
return message.profile_picture?.takeIf { it.isNotBlank() }
?: ApiClient.user?.profile_picture?.takeIf { it.isNotBlank() }
}
return message.profile_picture?.takeIf { it.isNotBlank() }
?: ProfileCache.get(message.user_id)?.profilePicture?.takeIf { it.isNotBlank() }
}
fun messageSenderAvatarLabel(
message: Message,
currentUserId: Int? = ApiClient.user?.id,
): String {
if (currentUserId != null && message.user_id == currentUserId) {
return ApiClient.user?.username?.takeIf { it.isNotBlank() }.orEmpty()
}
return message.username.trim().ifBlank {
ProfileCache.get(message.user_id)?.visibleDisplayName(currentUserId).orEmpty()
}
}
@@ -56,6 +56,8 @@ import com.pr0gramm3r101.utils.conditional
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res import ru.fromchat.Res
import ru.fromchat.api.local.cache.DecryptedImageCache import ru.fromchat.api.local.cache.DecryptedImageCache
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.visibleUsername
import ru.fromchat.api.local.messages.formatMessageTimeLocal import ru.fromchat.api.local.messages.formatMessageTimeLocal
import ru.fromchat.api.local.messages.isQueuedOutbound import ru.fromchat.api.local.messages.isQueuedOutbound
import ru.fromchat.api.schema.messages.Message import ru.fromchat.api.schema.messages.Message
@@ -123,6 +125,11 @@ fun MessageItem(
val editedSuffix = stringResource(Res.string.message_edited_suffix) val editedSuffix = stringResource(Res.string.message_edited_suffix)
val sendFailedLabel = stringResource(Res.string.message_send_failed) val sendFailedLabel = stringResource(Res.string.message_send_failed)
val displayUsername = messageDisplayUsername(message, currentUserId) val displayUsername = messageDisplayUsername(message, currentUserId)
val senderProfile = ProfileCache.get(message.user_id)
val avatarPictureUrl = senderProfile?.profilePicture?.takeIf { it.isNotBlank() }
?: message.profile_picture
val avatarDisplayName = senderProfile?.visibleUsername(currentUserId)?.takeIf { it.isNotBlank() }
?: message.username
val senderVerificationStatus = resolveVerificationStatus(message.user_id, message) val senderVerificationStatus = resolveVerificationStatus(message.user_id, message)
val replyRef = message.reply_to val replyRef = message.reply_to
@@ -184,8 +191,8 @@ fun MessageItem(
} }
) { ) {
Avatar( Avatar(
profilePictureUrl = message.profile_picture, profilePictureUrl = avatarPictureUrl,
displayName = message.username, displayName = avatarDisplayName,
modifier = Modifier.size(32.dp) modifier = Modifier.size(32.dp)
) )
} }
@@ -12,6 +12,7 @@ import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.cache.CacheContext import ru.fromchat.api.local.cache.CacheContext
import ru.fromchat.api.local.cache.DecryptedImageCache import ru.fromchat.api.local.cache.DecryptedImageCache
import ru.fromchat.api.local.db.store.MessageCacheStore import ru.fromchat.api.local.db.store.MessageCacheStore
import ru.fromchat.api.local.db.store.ProfileCache
import ru.fromchat.api.local.db.store.PublicChatProfileCache import ru.fromchat.api.local.db.store.PublicChatProfileCache
import ru.fromchat.api.local.db.store.MessageRepository import ru.fromchat.api.local.db.store.MessageRepository
import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID
@@ -63,6 +64,25 @@ class PublicChatPanel(
return false return false
} }
private fun mergePublicSenderFieldsFromNetwork(
shown: List<Message>,
fromNetwork: List<Message>,
): List<Message> {
val byId = fromNetwork.associateBy { it.id }
return shown.map { message ->
val fresh = byId[message.id] ?: return@map message
ProfileCache.mergePreviewFromPublicMessage(fresh)
ProfileCache.enrichPublicMessageForDisplay(
message.copy(
username = fresh.username,
profile_picture = fresh.profile_picture,
verified = fresh.verified,
verificationStatus = fresh.verificationStatus,
),
)
}
}
override val supportsNavigateToSenderProfile: Boolean override val supportsNavigateToSenderProfile: Boolean
get() = true get() = true
@@ -187,7 +207,9 @@ class PublicChatPanel(
} }
private suspend fun ingestIncomingPublicMessage(newMsg: Message) { private suspend fun ingestIncomingPublicMessage(newMsg: Message) {
addMessage(newMsg) ProfileCache.mergePreviewFromPublicMessage(newMsg)
val displayMessage = ProfileCache.enrichPublicMessageForDisplay(newMsg)
addMessage(displayMessage)
withContext(Dispatchers.Default) { withContext(Dispatchers.Default) {
MessageCacheStore.upsertPublicMessage(newMsg) MessageCacheStore.upsertPublicMessage(newMsg)
} }
@@ -252,17 +274,24 @@ class PublicChatPanel(
val response = responseResult.getOrNull() val response = responseResult.getOrNull()
if (response != null && response.messages.isNotEmpty()) { if (response != null && response.messages.isNotEmpty()) {
ProfileCache.mergePreviewFromPublicMessages(response.messages)
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
val shown = _state.messages val shown = _state.messages
if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, response.messages)) { if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, response.messages)) {
Logger.d("PublicChatPanel", "Network history matches UI; skip clear/re-add") Logger.d("PublicChatPanel", "Network history matches UI; skip clear/re-add")
val withSenders = mergePublicSenderFieldsFromNetwork(shown, response.messages)
if (withSenders != shown) {
updateState { it.copy(messages = sortMessagesForChatDisplay(withSenders)) }
}
if (_state.hasMoreMessages) setHasMoreMessages(false) if (_state.hasMoreMessages) setHasMoreMessages(false)
if (_state.isLoading) setLoading(false) if (_state.isLoading) setLoading(false)
} else { } else {
batchStateUpdates { batchStateUpdates {
val merged = mergeNetworkHistoryWithShown(shown, response.messages) val merged = mergeNetworkHistoryWithShown(shown, response.messages)
clearMessages() clearMessages()
addMessages(merged) addMessages(
ProfileCache.enrichPublicMessagesForDisplay(merged),
)
setHasMoreMessages(false) // TODO: Implement has_more from API setHasMoreMessages(false) // TODO: Implement has_more from API
setLoading(false) setLoading(false)
} }
@@ -315,10 +344,11 @@ class PublicChatPanel(
ApiClient.getMessages(limit = 50, beforeId = oldestMessage.id) ApiClient.getMessages(limit = 50, beforeId = oldestMessage.id)
} }
if (response.messages.isNotEmpty()) { if (response.messages.isNotEmpty()) {
// Prepend older messages (they come in reverse chronological order) ProfileCache.mergePreviewFromPublicMessages(response.messages)
val older = ProfileCache.enrichPublicMessagesForDisplay(response.messages.reversed())
updateState { currentState -> updateState { currentState ->
currentState.copy( currentState.copy(
messages = response.messages.reversed() + currentState.messages messages = older + currentState.messages
) )
} }
} }
@@ -108,6 +108,7 @@ import ru.fromchat.chat_preview_image
import ru.fromchat.chat_preview_image_emoji import ru.fromchat.chat_preview_image_emoji
import ru.fromchat.chats_selected_count import ru.fromchat.chats_selected_count
import ru.fromchat.config.ServerConfig import ru.fromchat.config.ServerConfig
import ru.fromchat.public_chat
import ru.fromchat.search_title import ru.fromchat.search_title
import ru.fromchat.status_connecting import ru.fromchat.status_connecting
import ru.fromchat.status_updating import ru.fromchat.status_updating
@@ -517,7 +518,7 @@ fun ChatsTab(
} }
} }
LaunchedEffect(serverConfig, activeInstanceId) { LaunchedEffect(serverConfig, activeInstanceId, connectionStatus) {
if (activeInstanceId.isBlank()) return@LaunchedEffect if (activeInstanceId.isBlank()) return@LaunchedEffect
runCatching { runCatching {
@@ -547,7 +548,9 @@ fun ChatsTab(
val selectedCountTitle = stringResource(Res.string.chats_selected_count, selectedCount) val selectedCountTitle = stringResource(Res.string.chats_selected_count, selectedCount)
val suspendBannerTitle = stringResource(Res.string.suspend_chat_banner_message) val suspendBannerTitle = stringResource(Res.string.suspend_chat_banner_message)
val suspendDefaultReason = stringResource(Res.string.suspended_default_reason) val suspendDefaultReason = stringResource(Res.string.suspended_default_reason)
val publicChatFallbackTitle = stringResource(Res.string.public_chat)
val publicChatTitle = publicChatProfile?.title?.takeIf { it.isNotBlank() } val publicChatTitle = publicChatProfile?.title?.takeIf { it.isNotBlank() }
?: publicChatFallbackTitle.takeIf { activeInstanceId.isNotBlank() }
val publicChatLink = publicChatProfile?.let { "https://fromchat.ru/chats/${it.id}" } val publicChatLink = publicChatProfile?.let { "https://fromchat.ru/chats/${it.id}" }
val deleteConfirmTitle = stringResource(Res.string.chat_delete_confirm_title) val deleteConfirmTitle = stringResource(Res.string.chat_delete_confirm_title)
val deleteConfirmBody = stringResource(Res.string.chat_delete_confirm_body) val deleteConfirmBody = stringResource(Res.string.chat_delete_confirm_body)
@@ -38,3 +38,20 @@ actual suspend fun wipeAttachmentCacheDirectories() {
} }
} }
} }
@OptIn(ExperimentalForeignApi::class)
actual suspend fun wipeInstanceAuxiliaryCacheDirectory(instanceId: String) {
withContext(Dispatchers.Default) {
val safe = instanceId.trim().replace(Regex("[^a-zA-Z0-9._-]"), "_")
if (safe.isEmpty()) return@withContext
val url = NSFileManager.defaultManager.URLForDirectory(
directory = NSCachesDirectory,
inDomain = NSUserDomainMask,
appropriateForURL = null,
create = false,
error = null,
) ?: return@withContext
val base = url.path ?: return@withContext
NSFileManager.defaultManager.removeItemAtPath("$base/fromchat/instances/$safe", error = null)
}
}