mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 11:05:04 +03:00
Clear cache on logout
Signed-off-by: denis0001-dev <denis0001.dev@ya.ru>
This commit is contained in:
+8
@@ -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.resolveInstanceId
|
||||
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.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.PublicChatProfileCache
|
||||
import ru.fromchat.api.local.download.streamEncryptedFileToDisk
|
||||
import ru.fromchat.api.local.send.scheduleOutboxProcessing
|
||||
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.config.ServerConfig
|
||||
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.time.Duration.Companion.milliseconds
|
||||
|
||||
@@ -236,13 +233,10 @@ object ApiClient {
|
||||
val path = response.call.request.url.encodedPath
|
||||
val isCredentialCheck = path.endsWith("/login") || path.endsWith("/register")
|
||||
if (!isCredentialCheck) {
|
||||
token = null
|
||||
user = null
|
||||
clearSuspensionState()
|
||||
onAuthError?.let {
|
||||
MainScope().launch {
|
||||
it()
|
||||
}
|
||||
MainScope().launch {
|
||||
runCatching { WebSocketManager.disconnect() }
|
||||
runCatching { clearLocalSession() }
|
||||
onAuthError?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -370,6 +364,7 @@ object ApiClient {
|
||||
}
|
||||
Settings.lastKnownServerInstanceId = id
|
||||
CacheContext.setActiveInstance(id, user?.id)
|
||||
PublicChatProfileSync.ensureStarted()
|
||||
scheduleOutboxProcessing(id)
|
||||
}
|
||||
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? {
|
||||
val dir = encryptedDownloadsDir() ?: return null
|
||||
return "$dir/paused_keys.txt"
|
||||
@@ -1383,10 +1389,7 @@ object ApiClient {
|
||||
*/
|
||||
suspend fun clearLocalSession() {
|
||||
val instanceId = runCatching { CacheContext.activeInstanceId.value.trim() }.getOrDefault("")
|
||||
if (instanceId.isNotEmpty()) {
|
||||
runCatching { cancelOutboxProcessing(instanceId) }
|
||||
runCatching { MessageRepository.purgeAllPendingForInstance() }
|
||||
}
|
||||
runCatching { clearAccountCacheOnLogout(instanceId) }
|
||||
val uid = user?.id
|
||||
secureSettings.remove("auth_token")
|
||||
settings.remove("user_info")
|
||||
@@ -1399,10 +1402,6 @@ object ApiClient {
|
||||
uid?.let { UpdateSyncManager.clearPersistedSeqForUser(it) }
|
||||
UpdateSyncManager.resetInMemoryOnLogout()
|
||||
runCatching { IdentityKeyManager.clearLocalKeys() }
|
||||
runCatching { ProfileCache.clear() }
|
||||
runCatching { DmPanelCache.clearAll() }
|
||||
runCatching { PublicChatProfileCache.clear() }
|
||||
runCatching { PublicChatPanelCache.clear() }
|
||||
runCatching { CacheContext.clearActive() }
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package ru.fromchat.api
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
@@ -22,6 +23,7 @@ object PublicChatProfileSync {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private var started = false
|
||||
private var syncJob: Job? = null
|
||||
|
||||
fun ensureStarted() {
|
||||
if (started) return
|
||||
@@ -31,12 +33,18 @@ object PublicChatProfileSync {
|
||||
scope.launch { refreshFromNetworkIfNeeded() }
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
syncJob = scope.launch {
|
||||
runCatching { PublicChatProfileCache.hydrateFromDisk() }
|
||||
syncUntilLoaded()
|
||||
}
|
||||
}
|
||||
|
||||
fun resetOnLogout() {
|
||||
syncJob?.cancel()
|
||||
syncJob = null
|
||||
started = false
|
||||
}
|
||||
|
||||
suspend fun refreshFromNetwork(): PublicChatProfile {
|
||||
if (ApiClient.token.isNullOrEmpty()) {
|
||||
return PublicChatProfileCache.profile
|
||||
|
||||
+6
@@ -420,4 +420,10 @@ object DecryptedFileCache {
|
||||
|
||||
private fun sanitizeKeyPart(value: String): String =
|
||||
value.replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
||||
|
||||
suspend fun clearMemoryCache() {
|
||||
cacheMutex.withLock {
|
||||
memoryCache.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -501,4 +501,10 @@ object DecryptedImageCache {
|
||||
private fun invalidatePath(path: String) {
|
||||
runCatching { PlatformFileSystem.delete(path) }
|
||||
}
|
||||
|
||||
suspend fun clearMemoryCache() {
|
||||
cacheMutex.withLock {
|
||||
memoryCache.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -8,6 +8,9 @@ expect suspend fun wipeFromChatCacheDirectory()
|
||||
/** Removes decrypted attachment blobs and partial download state outside `fromchat/`. */
|
||||
expect suspend fun wipeAttachmentCacheDirectories()
|
||||
|
||||
/** Deletes `cacheDir/fromchat/instances/<instanceId>/` auxiliary files (export index, pending saves). */
|
||||
expect suspend fun wipeInstanceAuxiliaryCacheDirectory(instanceId: String)
|
||||
|
||||
suspend fun wipeAllOnDiskAttachmentCaches() {
|
||||
wipeFromChatCacheDirectory()
|
||||
wipeAttachmentCacheDirectories()
|
||||
|
||||
+18
-3
@@ -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()
|
||||
if (base.isEmpty()) return null
|
||||
val instanceId = runCatching { CacheContext.requireActiveInstanceId() }.getOrNull() ?: "default"
|
||||
val safe = instanceId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
||||
val safe = instanceId.trim().replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
||||
if (safe.isEmpty()) return null
|
||||
val dir = "$base/fromchat/instances/$safe"
|
||||
PlatformFileSystem.ensureDirectory(dir)
|
||||
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> {
|
||||
val path = indexPath() ?: 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.withContext
|
||||
import ru.fromchat.api.local.db.store.MessageDatabaseProvider
|
||||
import ru.fromchat.api.local.send.cancelOutboxProcessing
|
||||
import ru.fromchat.api.local.db.store.PublicChatProfileCache
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.PublicChatProfileSync
|
||||
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.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.utils.PublicChatPanelCache
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
val instanceId = runCatching { CacheContext.activeInstanceId.value.trim() }.getOrDefault("")
|
||||
@@ -25,7 +38,39 @@ suspend fun wipeLocalCacheOnDisk() {
|
||||
}
|
||||
wipeFromChatCacheDirectory()
|
||||
wipeAttachmentCacheDirectories()
|
||||
PublicChatProfileCache.clear()
|
||||
PublicChatPanelCache.clear()
|
||||
DmPanelCache.clearAll()
|
||||
clearInMemoryAccountCaches()
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
@@ -9,6 +9,7 @@ import ru.fromchat.api.instance.configKey
|
||||
import ru.fromchat.api.local.db.store.PublicChatProfileCache
|
||||
import ru.fromchat.ui.chat.utils.PublicChatPanelCache
|
||||
import ru.fromchat.ui.chat.panels.dm.DmPanelCache
|
||||
import ru.fromchat.config.Settings
|
||||
import kotlin.time.Clock
|
||||
|
||||
object InstanceRegistryStore {
|
||||
@@ -111,4 +112,12 @@ object InstanceRegistryStore {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun clearServerBindingForCurrentConfig() {
|
||||
withContext(Dispatchers.Default) {
|
||||
MessageDatabaseProvider.withDatabaseRecover {
|
||||
db.messageDatabaseQueries.deleteServerBinding(Settings.serverConfig.configKey())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-7
@@ -91,10 +91,10 @@ object MessageCacheStore {
|
||||
}
|
||||
|
||||
suspend fun loadPublicMessages(): List<Message> =
|
||||
loadMessages(conversationIdForPublic())
|
||||
ProfileCache.enrichPublicMessagesForDisplay(loadMessages(conversationIdForPublic()))
|
||||
|
||||
suspend fun loadRecentPublicMessages(limit: Long): List<Message> =
|
||||
loadRecentMessages(conversationIdForPublic(), limit)
|
||||
ProfileCache.enrichPublicMessagesForDisplay(loadRecentMessages(conversationIdForPublic(), limit))
|
||||
|
||||
fun loadRecentPublicMessagesImmediate(instanceId: String, limit: Long = 128): List<Message> {
|
||||
if (instanceId.isBlank()) return emptyList()
|
||||
@@ -105,11 +105,13 @@ object MessageCacheStore {
|
||||
.map { row: DbMessage -> row.toAppMessage() }
|
||||
.reversed()
|
||||
val withoutSuperseded = dropSupersededOptimisticMessages(raw, ApiClient.user?.id)
|
||||
return sortMessagesForChatDisplay(
|
||||
validatedOrEmpty(
|
||||
convId,
|
||||
dedupeMessagesByClientId(
|
||||
enrichQueuedOutboundUi(withoutSuperseded, convId),
|
||||
return ProfileCache.enrichPublicMessagesForDisplay(
|
||||
sortMessagesForChatDisplay(
|
||||
validatedOrEmpty(
|
||||
convId,
|
||||
dedupeMessagesByClientId(
|
||||
enrichQueuedOutboundUi(withoutSuperseded, convId),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -150,6 +152,7 @@ object MessageCacheStore {
|
||||
}
|
||||
|
||||
suspend fun replacePublicMessages(messages: List<Message>) {
|
||||
ProfileCache.mergePreviewFromPublicMessages(messages)
|
||||
conversationIdForPublic().let {
|
||||
replaceMessages(
|
||||
it,
|
||||
@@ -225,6 +228,7 @@ object MessageCacheStore {
|
||||
}
|
||||
|
||||
suspend fun upsertPublicMessage(message: Message) {
|
||||
ProfileCache.mergePreviewFromPublicMessage(message)
|
||||
upsertSingle(conversationIdForPublic(), message)
|
||||
}
|
||||
|
||||
@@ -280,6 +284,7 @@ object MessageCacheStore {
|
||||
}
|
||||
|
||||
suspend fun confirmPublicMessage(clientMessageId: String, confirmed: Message) {
|
||||
ProfileCache.mergePreviewFromPublicMessage(confirmed)
|
||||
confirmMessage(conversationIdForPublic(), clientMessageId, confirmed)
|
||||
}
|
||||
|
||||
|
||||
@@ -149,4 +149,8 @@ object MessageRepository {
|
||||
MessageCacheStore.pruneEmptyConversations()
|
||||
|
||||
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.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.schema.messages.Message
|
||||
import ru.fromchat.api.schema.user.User
|
||||
import ru.fromchat.api.schema.user.profile.UserProfile
|
||||
@@ -182,7 +183,8 @@ object ProfileCache {
|
||||
val existing = get(uid)
|
||||
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 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) {
|
||||
ioScope.launch {
|
||||
persistMutex.withLock {
|
||||
|
||||
+7
@@ -369,4 +369,11 @@ object AttachmentDownloadNotifier {
|
||||
val id = keys.firstOrNull() ?: return DownloadProgressThrottle()
|
||||
return progressThrottleByKey.getOrPut(id) { DownloadProgressThrottle() }
|
||||
}
|
||||
|
||||
fun resetOnLogout() {
|
||||
_progressPercentByKey.value = emptyMap()
|
||||
_failedKeys.value = emptySet()
|
||||
_cancelledKeys.value = emptySet()
|
||||
progressThrottleByKey.clear()
|
||||
}
|
||||
}
|
||||
|
||||
+7
@@ -202,6 +202,13 @@ object AttachmentDownloadScheduler {
|
||||
}.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) {
|
||||
|
||||
+18
-3
@@ -142,16 +142,31 @@ object DownloadedFileRegistry {
|
||||
private fun sanitizeKeyPart(value: String): String =
|
||||
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()
|
||||
if (base.isEmpty()) return null
|
||||
val instanceId = runCatching { CacheContext.requireActiveInstanceId() }.getOrNull() ?: "default"
|
||||
val safe = instanceId.replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
||||
val safe = instanceId.trim().replace(Regex("[^a-zA-Z0-9._-]"), "_")
|
||||
if (safe.isEmpty()) return null
|
||||
val dir = "$base/fromchat/instances/$safe"
|
||||
PlatformFileSystem.ensureDirectory(dir)
|
||||
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> {
|
||||
val path = indexPath() ?: 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.UpdateSyncManager
|
||||
import ru.fromchat.api.calls.CallStore
|
||||
import ru.fromchat.api.instance.bootstrapSessionInstance
|
||||
import ru.fromchat.api.instance.bootstrapSessionOnStartup
|
||||
import ru.fromchat.api.instance.logoutIfInstanceUnsupported
|
||||
import ru.fromchat.api.instance.scheduleSessionInstanceNetworkRefresh
|
||||
import ru.fromchat.api.local.WebSocketManager
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
import ru.fromchat.api.local.cache.ensureFromChatCacheGeneration
|
||||
@@ -427,6 +429,16 @@ fun App(
|
||||
composable("auth") {
|
||||
AuthScreen(
|
||||
onAuthSuccess = {
|
||||
MainScope().launch {
|
||||
runCatching {
|
||||
bootstrapSessionInstance(
|
||||
hasToken = true,
|
||||
forceNetwork = false,
|
||||
)
|
||||
}
|
||||
PublicChatProfileSync.ensureStarted()
|
||||
scheduleSessionInstanceNetworkRefresh()
|
||||
}
|
||||
WebSocketManager.connect(forceRestart = true)
|
||||
navController.navigateAndWipeBackStack("chat")
|
||||
},
|
||||
|
||||
@@ -12,6 +12,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.pr0gramm3r101.utils.crypto.deriveAuthSecret
|
||||
import io.ktor.client.call.body
|
||||
@@ -22,6 +23,8 @@ import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.ApiClient
|
||||
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.probeServer
|
||||
import ru.fromchat.api.schema.core.ErrorResponse
|
||||
@@ -98,6 +101,8 @@ private suspend fun fullLogin(
|
||||
password: String,
|
||||
request: suspend () -> LoginResponse,
|
||||
) {
|
||||
val previousInstanceId = runCatching { CacheContext.activeInstanceId.value.trim() }.getOrDefault("")
|
||||
runCatching { clearAccountCacheOnLogout(previousInstanceId) }
|
||||
ApiClient.clearMemorySession()
|
||||
|
||||
val response = request()
|
||||
@@ -257,6 +262,31 @@ fun AuthScreen(
|
||||
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(
|
||||
flowState = flowState,
|
||||
pages = listOf(
|
||||
|
||||
@@ -434,6 +434,7 @@ fun ChatInput(
|
||||
enabled = !isReadOnly,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.align(Alignment.Bottom),
|
||||
textStyle = inputTextStyle,
|
||||
singleLine = false,
|
||||
@@ -452,6 +453,7 @@ fun ChatInput(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = ChatInputIconSlotSize)
|
||||
.animateContentSize()
|
||||
.align(Alignment.BottomStart),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
@@ -469,9 +471,7 @@ fun ChatInput(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
Box(modifier = Modifier.animateContentSize()) {
|
||||
innerTextField()
|
||||
}
|
||||
innerTextField()
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -3,9 +3,11 @@ package ru.fromchat.ui.chat
|
||||
import androidx.compose.runtime.Composable
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import ru.fromchat.Res
|
||||
import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.db.store.ProfileCache
|
||||
import ru.fromchat.api.schema.messages.Message
|
||||
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.user_fallback
|
||||
|
||||
@@ -38,3 +40,27 @@ fun messageDisplayUsername(message: Message, currentUserId: Int?): String {
|
||||
}
|
||||
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 ru.fromchat.Res
|
||||
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.isQueuedOutbound
|
||||
import ru.fromchat.api.schema.messages.Message
|
||||
@@ -123,6 +125,11 @@ fun MessageItem(
|
||||
val editedSuffix = stringResource(Res.string.message_edited_suffix)
|
||||
val sendFailedLabel = stringResource(Res.string.message_send_failed)
|
||||
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 replyRef = message.reply_to
|
||||
|
||||
@@ -184,8 +191,8 @@ fun MessageItem(
|
||||
}
|
||||
) {
|
||||
Avatar(
|
||||
profilePictureUrl = message.profile_picture,
|
||||
displayName = message.username,
|
||||
profilePictureUrl = avatarPictureUrl,
|
||||
displayName = avatarDisplayName,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
|
||||
+34
-4
@@ -12,6 +12,7 @@ import ru.fromchat.api.ApiClient
|
||||
import ru.fromchat.api.local.cache.CacheContext
|
||||
import ru.fromchat.api.local.cache.DecryptedImageCache
|
||||
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.MessageRepository
|
||||
import ru.fromchat.api.local.messages.GENERAL_PUBLIC_GROUP_ID
|
||||
@@ -63,6 +64,25 @@ class PublicChatPanel(
|
||||
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
|
||||
get() = true
|
||||
|
||||
@@ -187,7 +207,9 @@ class PublicChatPanel(
|
||||
}
|
||||
|
||||
private suspend fun ingestIncomingPublicMessage(newMsg: Message) {
|
||||
addMessage(newMsg)
|
||||
ProfileCache.mergePreviewFromPublicMessage(newMsg)
|
||||
val displayMessage = ProfileCache.enrichPublicMessageForDisplay(newMsg)
|
||||
addMessage(displayMessage)
|
||||
withContext(Dispatchers.Default) {
|
||||
MessageCacheStore.upsertPublicMessage(newMsg)
|
||||
}
|
||||
@@ -252,17 +274,24 @@ class PublicChatPanel(
|
||||
val response = responseResult.getOrNull()
|
||||
|
||||
if (response != null && response.messages.isNotEmpty()) {
|
||||
ProfileCache.mergePreviewFromPublicMessages(response.messages)
|
||||
withContext(Dispatchers.Main) {
|
||||
val shown = _state.messages
|
||||
if (shown.isNotEmpty() && !publicHistoryDiffersForUi(shown, response.messages)) {
|
||||
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.isLoading) setLoading(false)
|
||||
} else {
|
||||
batchStateUpdates {
|
||||
val merged = mergeNetworkHistoryWithShown(shown, response.messages)
|
||||
clearMessages()
|
||||
addMessages(merged)
|
||||
addMessages(
|
||||
ProfileCache.enrichPublicMessagesForDisplay(merged),
|
||||
)
|
||||
setHasMoreMessages(false) // TODO: Implement has_more from API
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -315,10 +344,11 @@ class PublicChatPanel(
|
||||
ApiClient.getMessages(limit = 50, beforeId = oldestMessage.id)
|
||||
}
|
||||
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 ->
|
||||
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.chats_selected_count
|
||||
import ru.fromchat.config.ServerConfig
|
||||
import ru.fromchat.public_chat
|
||||
import ru.fromchat.search_title
|
||||
import ru.fromchat.status_connecting
|
||||
import ru.fromchat.status_updating
|
||||
@@ -517,7 +518,7 @@ fun ChatsTab(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(serverConfig, activeInstanceId) {
|
||||
LaunchedEffect(serverConfig, activeInstanceId, connectionStatus) {
|
||||
if (activeInstanceId.isBlank()) return@LaunchedEffect
|
||||
|
||||
runCatching {
|
||||
@@ -547,7 +548,9 @@ fun ChatsTab(
|
||||
val selectedCountTitle = stringResource(Res.string.chats_selected_count, selectedCount)
|
||||
val suspendBannerTitle = stringResource(Res.string.suspend_chat_banner_message)
|
||||
val suspendDefaultReason = stringResource(Res.string.suspended_default_reason)
|
||||
val publicChatFallbackTitle = stringResource(Res.string.public_chat)
|
||||
val publicChatTitle = publicChatProfile?.title?.takeIf { it.isNotBlank() }
|
||||
?: publicChatFallbackTitle.takeIf { activeInstanceId.isNotBlank() }
|
||||
val publicChatLink = publicChatProfile?.let { "https://fromchat.ru/chats/${it.id}" }
|
||||
val deleteConfirmTitle = stringResource(Res.string.chat_delete_confirm_title)
|
||||
val deleteConfirmBody = stringResource(Res.string.chat_delete_confirm_body)
|
||||
|
||||
+17
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user