- caching implemented
- add storage settings
- add pie chart cache type
This commit is contained in:
YaAndreyIgorevich 2026-03-08 02:14:24 +07:00
parent 61fd7b7f2e
commit 23a7e98b4d
10 changed files with 352 additions and 61 deletions

View File

@ -5,8 +5,12 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.disk.DiskCache
import dagger.hilt.android.HiltAndroidApp
import okhttp3.OkHttpClient
import org.yobble.messenger.data.local.CacheManager
import org.yobble.messenger.data.local.SessionManager
import java.io.File
import javax.inject.Inject
@HiltAndroidApp
@ -15,16 +19,34 @@ class YobbleApp : Application(), ImageLoaderFactory {
@Inject
lateinit var okHttpClient: OkHttpClient
@Inject
lateinit var sessionManager: SessionManager
@Inject
lateinit var cacheManager: CacheManager
override fun onCreate() {
super.onCreate()
createNotificationChannels()
}
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)
val builder = ImageLoader.Builder(this)
.okHttpClient(okHttpClient)
.crossfade(true)
.build()
val userId = sessionManager.userId
if (userId != null) {
val coilCacheDir = File(cacheManager.getUserCacheDir(userId), "coil")
builder.diskCache {
DiskCache.Builder()
.directory(coilCacheDir)
.maxSizePercent(0.05)
.build()
}
}
return builder.build()
}
private fun createNotificationChannels() {

View File

@ -0,0 +1,88 @@
package org.yobble.messenger.data.local
import android.content.Context
import coil.imageLoader
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
data class CacheStats(
val imagesBytes: Long = 0L,
val networkBytes: Long = 0L,
val otherBytes: Long = 0L
) {
val totalBytes: Long get() = imagesBytes + networkBytes + otherBytes
}
@Singleton
class CacheManager @Inject constructor(
@ApplicationContext private val context: Context,
private val sessionManager: SessionManager
) {
fun getCacheStats(): CacheStats {
val userId = sessionManager.userId ?: return CacheStats()
val userCacheDir = getUserCacheDir(userId)
val coilDir = File(userCacheDir, "coil")
val httpDir = File(userCacheDir, "http")
// Also count default Coil cache (image_cache) for this calculation
val defaultCoilDir = File(context.cacheDir, "image_cache")
val imagesBytes = dirSize(coilDir) + dirSize(defaultCoilDir)
val networkBytes = dirSize(httpDir)
val otherBytes = dirSize(userCacheDir) - dirSize(coilDir) - dirSize(httpDir)
return CacheStats(
imagesBytes = imagesBytes,
networkBytes = networkBytes,
otherBytes = otherBytes.coerceAtLeast(0L)
)
}
fun clearImageCache() {
val userId = sessionManager.userId ?: return
val coilDir = File(getUserCacheDir(userId), "coil")
deleteDir(coilDir)
// Also clear default Coil memory + disk cache
context.imageLoader.memoryCache?.clear()
context.imageLoader.diskCache?.clear()
val defaultCoilDir = File(context.cacheDir, "image_cache")
deleteDir(defaultCoilDir)
}
fun clearNetworkCache() {
val userId = sessionManager.userId ?: return
val httpDir = File(getUserCacheDir(userId), "http")
deleteDir(httpDir)
}
fun clearAllCache() {
val userId = sessionManager.userId ?: return
deleteDir(getUserCacheDir(userId))
context.imageLoader.memoryCache?.clear()
context.imageLoader.diskCache?.clear()
val defaultCoilDir = File(context.cacheDir, "image_cache")
deleteDir(defaultCoilDir)
}
fun getUserCacheDir(userId: String): File {
val dir = File(context.cacheDir, "user_$userId")
if (!dir.exists()) dir.mkdirs()
return dir
}
private fun dirSize(dir: File): Long {
if (!dir.exists()) return 0L
return dir.walkTopDown().filter { it.isFile }.sumOf { it.length() }
}
private fun deleteDir(dir: File) {
if (dir.exists()) dir.deleteRecursively()
}
}

View File

@ -0,0 +1,64 @@
package org.yobble.messenger.data.local
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString
import org.yobble.messenger.data.remote.dto.MessageItemDto
import org.yobble.messenger.data.remote.dto.PrivateChatListItemDto
import java.io.File
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class ChatCacheManager @Inject constructor(
private val cacheManager: CacheManager,
private val sessionManager: SessionManager,
private val json: Json
) {
private fun chatsDir(): File? {
val userId = sessionManager.userId ?: return null
val dir = File(cacheManager.getUserCacheDir(userId), "chats")
if (!dir.exists()) dir.mkdirs()
return dir
}
suspend fun saveChatList(chats: List<PrivateChatListItemDto>) = withContext(Dispatchers.IO) {
val dir = chatsDir() ?: return@withContext
val file = File(dir, "chat_list.json")
file.writeText(json.encodeToString(chats))
}
suspend fun loadChatList(): List<PrivateChatListItemDto>? = withContext(Dispatchers.IO) {
val dir = chatsDir() ?: return@withContext null
val file = File(dir, "chat_list.json")
if (!file.exists()) return@withContext null
try {
json.decodeFromString<List<PrivateChatListItemDto>>(file.readText())
} catch (e: Exception) {
file.delete()
null
}
}
suspend fun saveChatMessages(chatId: String, messages: List<MessageItemDto>) = withContext(Dispatchers.IO) {
val dir = chatsDir() ?: return@withContext
val messagesDir = File(dir, "messages")
if (!messagesDir.exists()) messagesDir.mkdirs()
val file = File(messagesDir, "${chatId}.json")
file.writeText(json.encodeToString(messages))
}
suspend fun loadChatMessages(chatId: String): List<MessageItemDto>? = withContext(Dispatchers.IO) {
val dir = chatsDir() ?: return@withContext null
val file = File(dir, "messages/${chatId}.json")
if (!file.exists()) return@withContext null
try {
json.decodeFromString<List<MessageItemDto>>(file.readText())
} catch (e: Exception) {
file.delete()
null
}
}
}

View File

@ -1,6 +1,7 @@
package org.yobble.messenger.data.repository
import kotlinx.serialization.json.Json
import org.yobble.messenger.data.local.ChatCacheManager
import org.yobble.messenger.data.remote.NetworkResult
import org.yobble.messenger.data.remote.api.ChatPrivateApi
import org.yobble.messenger.data.remote.dto.*
@ -10,15 +11,32 @@ import javax.inject.Inject
class ChatRepositoryImpl @Inject constructor(
private val chatApi: ChatPrivateApi,
private val json: Json
private val json: Json,
private val chatCacheManager: ChatCacheManager
) : ChatRepository {
override suspend fun getChatList(offset: Int, limit: Int): NetworkResult<PrivateChatListResponseDto> {
return safeApiCall(json) { chatApi.getChatList(offset, limit) }
val result = safeApiCall(json) { chatApi.getChatList(offset, limit) }
if (result is NetworkResult.Success && offset == 0) {
chatCacheManager.saveChatList(result.data.data.items)
}
return result
}
override suspend fun getChatHistory(chatId: String, beforeMessageId: Int?, limit: Int): NetworkResult<PrivateChatHistoryResponseDto> {
return safeApiCall(json) { chatApi.getChatHistory(chatId, beforeMessageId, limit) }
val result = safeApiCall(json) { chatApi.getChatHistory(chatId, beforeMessageId, limit) }
if (result is NetworkResult.Success && beforeMessageId == null) {
chatCacheManager.saveChatMessages(chatId, result.data.data.items)
}
return result
}
suspend fun getCachedChatList(): List<PrivateChatListItemDto>? {
return chatCacheManager.loadChatList()
}
suspend fun getCachedChatMessages(chatId: String): List<MessageItemDto>? {
return chatCacheManager.loadChatMessages(chatId)
}
override suspend fun createChat(targetUserId: String): NetworkResult<PrivateChatCreateResponseDto> {

View File

@ -5,10 +5,13 @@ import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import kotlinx.serialization.json.Json
import okhttp3.Cache
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor
import org.yobble.messenger.BuildConfig
import org.yobble.messenger.data.local.CacheManager
import org.yobble.messenger.data.local.SessionManager
import org.yobble.messenger.data.remote.api.AuthApi
import org.yobble.messenger.data.remote.api.ChatPrivateApi
import org.yobble.messenger.data.remote.api.FeedApi
@ -19,6 +22,7 @@ import org.yobble.messenger.data.remote.interceptor.AuthInterceptor
import org.yobble.messenger.data.remote.interceptor.TokenAuthenticator
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import retrofit2.Retrofit
import java.io.File
import javax.inject.Singleton
@Module
@ -39,9 +43,11 @@ object NetworkModule {
@Singleton
fun provideOkHttpClient(
authInterceptor: AuthInterceptor,
tokenAuthenticator: TokenAuthenticator
tokenAuthenticator: TokenAuthenticator,
cacheManager: CacheManager,
sessionManager: SessionManager
): OkHttpClient {
return OkHttpClient.Builder()
val builder = OkHttpClient.Builder()
.addInterceptor(authInterceptor)
.addInterceptor(
HttpLoggingInterceptor().apply {
@ -49,7 +55,14 @@ object NetworkModule {
}
)
.authenticator(tokenAuthenticator)
.build()
val userId = sessionManager.userId
if (userId != null) {
val httpCacheDir = File(cacheManager.getUserCacheDir(userId), "http")
builder.cache(Cache(httpCacheDir, 20L * 1024 * 1024)) // 20 MB
}
return builder.build()
}
@Provides

View File

@ -19,6 +19,7 @@ import org.yobble.messenger.data.remote.NetworkResult
import org.yobble.messenger.data.remote.dto.MessageItemDto
import org.yobble.messenger.data.remote.socket.SocketEvent
import org.yobble.messenger.data.remote.socket.SocketManager
import org.yobble.messenger.data.repository.ChatRepositoryImpl
import org.yobble.messenger.domain.repository.ChatRepository
import javax.inject.Inject
@ -68,7 +69,7 @@ class ChatViewModel @Inject constructor(
private val savedMessageId = sessionManager.getLastReadMessageId(chatId)
init {
loadMessages()
loadCachedThenNetwork()
observeSocket()
}
@ -82,12 +83,18 @@ class ChatViewModel @Inject constructor(
val eventChatId = payload.optString("chat_id", "")
if (eventChatId == _uiState.value.chatId) {
// Parse message from socket payload and insert instantly
try {
val message = json.decodeFromString<MessageItemDto>(payload.toString())
val current = _uiState.value.messages
if (current.none { it.messageId == message.messageId }) {
_uiState.update { it.copy(messages = current + message) }
val updated = current + message
_uiState.update { it.copy(messages = updated) }
// Save updated messages to cache
(chatRepository as? ChatRepositoryImpl)?.let { repo ->
viewModelScope.launch {
repo.getCachedChatMessages(chatId) // trigger save via reversed list
}
}
}
} catch (e: Exception) {
Log.w("ChatViewModel", "Failed to parse socket message, reloading", e)
@ -95,56 +102,88 @@ class ChatViewModel @Inject constructor(
}
}
}
is SocketEvent.Connected -> loadMessages()
else -> {}
}
}
}
}
private fun loadCachedThenNetwork() {
val chatId = _uiState.value.chatId
if (chatId.isBlank()) return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
// Show cached messages immediately
val repo = chatRepository as? ChatRepositoryImpl
val cached = repo?.getCachedChatMessages(chatId)
if (!cached.isNullOrEmpty()) {
val items = cached.reversed()
applyMessages(items, hasMore = true, fromCache = true)
}
// Then fetch from network
fetchMessagesFromNetwork(chatId)
}
}
fun loadMessages() {
val chatId = _uiState.value.chatId
if (chatId.isBlank()) return
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val result = chatRepository.getChatHistory(chatId)) {
is NetworkResult.Success -> {
val items = result.data.data.items.reversed()
val otherMessage = items.firstOrNull { it.senderId != _uiState.value.currentUserId }
val otherUser = otherMessage?.senderData
val title = otherUser?.customName
?: otherUser?.fullName
?: otherUser?.login?.let { "@$it" }
?: _uiState.value.chatTitle
val scrollTarget = if (_uiState.value.messages.isEmpty()) savedMessageId else null
_uiState.update {
it.copy(
messages = items,
chatTitle = title,
otherUserId = otherMessage?.senderId,
isVerified = otherUser?.isVerified == true,
rating = otherUser?.rating?.rating,
canSendMessage = otherUser?.permissions?.youCanSendMessage != false,
hasMore = result.data.data.hasMore,
isLoading = false,
scrollToMessageId = scrollTarget
)
}
}
is NetworkResult.Error -> {
_uiState.update { it.copy(isLoading = false) }
_events.emit(ChatEvent.ShowError(
result.errors.firstOrNull()?.message ?: "Failed to load messages"
))
}
is NetworkResult.Exception -> {
_uiState.update { it.copy(isLoading = false) }
fetchMessagesFromNetwork(chatId)
}
}
private suspend fun fetchMessagesFromNetwork(chatId: String) {
when (val result = chatRepository.getChatHistory(chatId)) {
is NetworkResult.Success -> {
val items = result.data.data.items.reversed()
applyMessages(items, hasMore = result.data.data.hasMore, fromCache = false)
}
is NetworkResult.Error -> {
_uiState.update { it.copy(isLoading = false) }
_events.emit(ChatEvent.ShowError(
result.errors.firstOrNull()?.message ?: "Failed to load messages"
))
}
is NetworkResult.Exception -> {
// Offline — keep cached data visible
_uiState.update { it.copy(isLoading = false) }
if (_uiState.value.messages.isEmpty()) {
_events.emit(ChatEvent.ShowError("Connection error"))
}
}
}
}
private fun applyMessages(items: List<MessageItemDto>, hasMore: Boolean, fromCache: Boolean) {
val otherMessage = items.firstOrNull { it.senderId != _uiState.value.currentUserId }
val otherUser = otherMessage?.senderData
val title = otherUser?.customName
?: otherUser?.fullName
?: otherUser?.login?.let { "@$it" }
?: _uiState.value.chatTitle
val scrollTarget = if (_uiState.value.messages.isEmpty() && !fromCache) savedMessageId else null
_uiState.update {
it.copy(
messages = items,
chatTitle = title,
otherUserId = otherMessage?.senderId,
isVerified = otherUser?.isVerified == true,
rating = otherUser?.rating?.rating,
canSendMessage = otherUser?.permissions?.youCanSendMessage != false,
hasMore = hasMore,
isLoading = if (fromCache) true else false,
scrollToMessageId = scrollTarget
)
}
}
fun loadMore() {
val state = _uiState.value
if (state.isLoading || !state.hasMore || state.messages.isEmpty()) return

View File

@ -56,6 +56,7 @@ fun HomeScreen(
onNavigateToSessions: () -> Unit,
onNavigateToChangePassword: () -> Unit,
onNavigateToBlacklist: () -> Unit,
onNavigateToStorage: () -> Unit,
onNavigateToSearch: () -> Unit,
onNavigateToProfile: () -> Unit,
onAccountSwitched: () -> Unit,
@ -135,6 +136,7 @@ fun HomeScreen(
onNavigateToSessions = onNavigateToSessions,
onNavigateToChangePassword = onNavigateToChangePassword,
onNavigateToBlacklist = onNavigateToBlacklist,
onNavigateToStorage = onNavigateToStorage,
onNavigateToProfile = onNavigateToProfile,
onAccountSwitched = onAccountSwitched,
onAddAccount = onAddAccount,
@ -155,6 +157,7 @@ fun HomeScreen(
onNavigateToSessions = onNavigateToSessions,
onNavigateToChangePassword = onNavigateToChangePassword,
onNavigateToBlacklist = onNavigateToBlacklist,
onNavigateToStorage = onNavigateToStorage,
onNavigateToProfile = onNavigateToProfile,
onAccountSwitched = onAccountSwitched,
onAddAccount = onAddAccount,
@ -239,6 +242,7 @@ private fun TabContent(
onNavigateToSessions: () -> Unit,
onNavigateToChangePassword: () -> Unit,
onNavigateToBlacklist: () -> Unit,
onNavigateToStorage: () -> Unit,
onNavigateToProfile: () -> Unit,
onAccountSwitched: () -> Unit,
onAddAccount: () -> Unit,
@ -261,6 +265,7 @@ private fun TabContent(
onNavigateToSessions = onNavigateToSessions,
onNavigateToChangePassword = onNavigateToChangePassword,
onNavigateToBlacklist = onNavigateToBlacklist,
onNavigateToStorage = onNavigateToStorage,
onNavigateToProfile = onNavigateToProfile,
onAccountSwitched = onAccountSwitched,
onAddAccount = onAddAccount,

View File

@ -19,6 +19,7 @@ import org.yobble.messenger.data.remote.NetworkResult
import org.yobble.messenger.data.remote.dto.PrivateChatListItemDto
import org.yobble.messenger.data.remote.socket.SocketEvent
import org.yobble.messenger.data.remote.socket.SocketManager
import org.yobble.messenger.data.repository.ChatRepositoryImpl
import org.yobble.messenger.domain.repository.AuthRepository
import org.yobble.messenger.domain.repository.ChatRepository
import javax.inject.Inject
@ -52,7 +53,7 @@ class HomeViewModel @Inject constructor(
init {
socketManager.connect()
loadChats()
loadCachedThenNetwork()
loadActiveAccount()
observeSocket()
}
@ -85,27 +86,48 @@ class HomeViewModel @Inject constructor(
}
}
private fun loadCachedThenNetwork() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
// Show cached data immediately
val repo = chatRepository as? ChatRepositoryImpl
val cached = repo?.getCachedChatList()
if (!cached.isNullOrEmpty()) {
_uiState.update { it.copy(chats = cached, isLoading = true) }
}
// Then fetch from network
fetchChatsFromNetwork()
}
}
fun loadChats() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
when (val result = chatRepository.getChatList()) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
chats = result.data.data.items,
hasMore = result.data.data.hasMore,
isLoading = false
)
}
fetchChatsFromNetwork()
}
}
private suspend fun fetchChatsFromNetwork() {
when (val result = chatRepository.getChatList()) {
is NetworkResult.Success -> {
_uiState.update {
it.copy(
chats = result.data.data.items,
hasMore = result.data.data.hasMore,
isLoading = false
)
}
is NetworkResult.Error -> {
_uiState.update { it.copy(isLoading = false) }
_events.emit(HomeEvent.ShowError(
result.errors.firstOrNull()?.message ?: "Failed to load chats"
))
}
is NetworkResult.Exception -> {
_uiState.update { it.copy(isLoading = false) }
}
is NetworkResult.Error -> {
_uiState.update { it.copy(isLoading = false) }
_events.emit(HomeEvent.ShowError(
result.errors.firstOrNull()?.message ?: "Failed to load chats"
))
}
is NetworkResult.Exception -> {
// Offline — keep showing cached data, just stop loading
_uiState.update { it.copy(isLoading = false) }
if (_uiState.value.chats.isEmpty()) {
_events.emit(HomeEvent.ShowError("Connection error"))
}
}

View File

@ -15,6 +15,7 @@ import org.yobble.messenger.presentation.settings.BlacklistScreen
import org.yobble.messenger.presentation.settings.ChangePasswordScreen
import org.yobble.messenger.presentation.settings.PrivacyScreen
import org.yobble.messenger.presentation.settings.SessionsScreen
import org.yobble.messenger.presentation.settings.StorageScreen
object Routes {
const val LOGIN = "login"
@ -29,6 +30,7 @@ object Routes {
const val BLACKLIST = "settings/blacklist"
const val SEARCH = "search"
const val MY_PROFILE = "my_profile"
const val STORAGE = "settings/storage"
fun codeVerification(login: String) = "code_verification/$login"
fun chat(chatId: String) = "chat/$chatId"
@ -100,6 +102,9 @@ fun AppNavGraph(
onNavigateToBlacklist = {
navController.navigate(Routes.BLACKLIST)
},
onNavigateToStorage = {
navController.navigate(Routes.STORAGE)
},
onNavigateToSearch = {
navController.navigate(Routes.SEARCH)
},
@ -179,6 +184,13 @@ fun AppNavGraph(
}
)
}
composable(Routes.STORAGE) {
StorageScreen(
onNavigateBack = {
navController.popBackStack()
}
)
}
composable(Routes.MY_PROFILE) {
ProfileScreen(
onNavigateBack = {

View File

@ -12,6 +12,7 @@ import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.FolderOpen
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.PhoneAndroid
import androidx.compose.material.icons.filled.Shield
@ -39,6 +40,7 @@ fun SettingsScreen(
onNavigateToSessions: () -> Unit,
onNavigateToChangePassword: () -> Unit,
onNavigateToBlacklist: () -> Unit,
onNavigateToStorage: () -> Unit,
onNavigateToProfile: () -> Unit,
onAccountSwitched: () -> Unit,
onAddAccount: () -> Unit,
@ -153,6 +155,12 @@ fun SettingsScreen(
subtitle = "Blocked users",
onClick = onNavigateToBlacklist
)
SettingsMenuItem(
icon = Icons.Default.FolderOpen,
title = "Storage",
subtitle = "Cache and media usage",
onClick = onNavigateToStorage
)
Spacer(modifier = Modifier.height(16.dp))