package com.yaros.RadioUrl.helpers import android.content.Context import android.content.SharedPreferences import com.google.gson.Gson import com.google.gson.JsonParseException import com.google.gson.reflect.TypeToken import com.yaros.RadioUrl.core.APIInterface.data.Category import com.yaros.RadioUrl.data.Station import timber.log.Timber import java.util.concurrent.ConcurrentHashMap /** * Менеджер кеширования для радиостанций и категорий. * * Особенности реализации: * - Собственный файл SharedPreferences (не дефолтный) — не разрастает * и не тормозит основной PreferenceManager-файл настроек на слабых устройствах. * - In-memory слой поверх диска — повторные обращения в рамках одной сессии * не парсят JSON заново. * - Версионирование формата кеша — при смене полей модели Station/Category * старый кеш не роняет приложение, а просто сбрасывается. * - Защита от повреждённого JSON (частая причина краша на реальных * устройствах при OOM/kill процесса во время записи). * - "Stale-if-error" — при сетевой ошибке можно отдать протухший кеш, * чтобы пользователь не видел пустой экран. * - Мягкая деградация: если init() ещё не вызван, кеш не крашит приложение, * а просто временно не работает (и логирует это). */ object CacheManager { private const val TAG = "CacheManager" // Отдельный файл, а не дефолтные PreferenceManager-настройки private const val PREFS_FILE_NAME = "swr_cache_manager" // Версия формата кеша. Увеличивайте при изменении полей Station/Category, // чтобы старые несовместимые данные автоматически сбрасывались. private const val CACHE_FORMAT_VERSION = 1 private const val PREF_CACHE_FORMAT_VERSION = "cache_format_version" // Ключи для SharedPreferences private const val PREF_CACHE_ENABLED = "cache_enabled" private const val PREF_CACHE_MAX_SIZE = "cache_max_size_mb" private const val PREF_STATIONS_CACHE = "stations_cache" private const val PREF_STATIONS_CACHE_TIME = "stations_cache_time" private const val PREF_CATEGORIES_CACHE = "categories_cache" private const val PREF_CATEGORIES_CACHE_TIME = "categories_cache_time" private const val PREF_CATEGORY_STATIONS_PREFIX = "category_stations_" private const val PREF_CATEGORY_STATIONS_TIME_PREFIX = "category_stations_time_" // Время жизни кеша (по умолчанию 1 час) private const val CACHE_EXPIRATION_TIME = 60 * 60 * 1000L // Насколько дольше можно отдавать протухший кеш как fallback при ошибке сети private const val STALE_FALLBACK_WINDOW = 24 * 60 * 60 * 1000L // 24 часа // Размеры кеша в МБ const val CACHE_SIZE_SMALL = 10 const val CACHE_SIZE_MEDIUM = 25 const val CACHE_SIZE_LARGE = 50 const val CACHE_SIZE_XLARGE = 100 @Volatile private var sharedPreferences: SharedPreferences? = null private val gson = Gson() // Простой in-memory слой: избавляет от повторного чтения/парсинга JSON // на слабых устройствах в рамках одной "живой" сессии процесса. private data class MemoryEntry(val json: String, val timestamp: Long) private val memoryCache = ConcurrentHashMap() fun init(context: Context) { if (sharedPreferences == null) { synchronized(this) { if (sharedPreferences == null) { val prefs = context.applicationContext .getSharedPreferences(PREFS_FILE_NAME, Context.MODE_PRIVATE) sharedPreferences = prefs migrateFormatIfNeeded(prefs) } } } } /** * Если версия формата кеша изменилась (например, обновили модель Station * новым полем без дефолта, и старый Gson.fromJson упадёт) — сбрасываем кеш * целиком одним махом вместо падения на первом же чтении. */ private fun migrateFormatIfNeeded(prefs: SharedPreferences) { val storedVersion = prefs.getInt(PREF_CACHE_FORMAT_VERSION, -1) if (storedVersion != CACHE_FORMAT_VERSION) { Timber.tag(TAG).i("Cache format changed ($storedVersion -> $CACHE_FORMAT_VERSION), clearing cache") prefs.edit() .clear() .putInt(PREF_CACHE_FORMAT_VERSION, CACHE_FORMAT_VERSION) .apply() } } private fun prefs(): SharedPreferences? { val p = sharedPreferences if (p == null) { Timber.tag(TAG).w("CacheManager used before init() — caching disabled for this call") } return p } /** Проверка, включено ли кеширование */ fun isCachingEnabled(): Boolean { return prefs()?.getBoolean(PREF_CACHE_ENABLED, true) ?: true } fun enableCaching() { prefs()?.edit()?.putBoolean(PREF_CACHE_ENABLED, true)?.apply() } fun disableCaching() { prefs()?.edit()?.putBoolean(PREF_CACHE_ENABLED, false)?.apply() clearCache() } fun getMaxCacheSize(): Int { return prefs()?.getInt(PREF_CACHE_MAX_SIZE, CACHE_SIZE_MEDIUM) ?: CACHE_SIZE_MEDIUM } fun setMaxCacheSize(sizeMb: Int) { prefs()?.edit()?.putInt(PREF_CACHE_MAX_SIZE, sizeMb)?.apply() enforceMaxCacheSize() } // --------------------------------------------------------------------- // Station list (все станции) // --------------------------------------------------------------------- fun cacheStations(stations: List) { writeEntry(PREF_STATIONS_CACHE, PREF_STATIONS_CACHE_TIME, stations, "stations") } fun getCachedStations(): List? { return readEntry(PREF_STATIONS_CACHE, PREF_STATIONS_CACHE_TIME, allowStale = false) } /** То же самое, но если кеш протух — вернёт его всё равно (для fallback при ошибке сети). */ fun getCachedStationsOrStale(): List? { return readEntry(PREF_STATIONS_CACHE, PREF_STATIONS_CACHE_TIME, allowStale = true) } // --------------------------------------------------------------------- // Categories // --------------------------------------------------------------------- fun cacheCategories(categories: List) { writeEntry(PREF_CATEGORIES_CACHE, PREF_CATEGORIES_CACHE_TIME, categories, "categories") } fun getCachedCategories(): List? { return readEntry(PREF_CATEGORIES_CACHE, PREF_CATEGORIES_CACHE_TIME, allowStale = false) } fun getCachedCategoriesOrStale(): List? { return readEntry(PREF_CATEGORIES_CACHE, PREF_CATEGORIES_CACHE_TIME, allowStale = true) } // --------------------------------------------------------------------- // Stations by category // --------------------------------------------------------------------- fun cacheCategoryStations(categoryId: Int, stations: List) { writeEntry( "$PREF_CATEGORY_STATIONS_PREFIX$categoryId", "$PREF_CATEGORY_STATIONS_TIME_PREFIX$categoryId", stations, "category $categoryId stations" ) } fun getCachedCategoryStations(categoryId: Int): List? { return readEntry( "$PREF_CATEGORY_STATIONS_PREFIX$categoryId", "$PREF_CATEGORY_STATIONS_TIME_PREFIX$categoryId", allowStale = false ) } fun getCachedCategoryStationsOrStale(categoryId: Int): List? { return readEntry( "$PREF_CATEGORY_STATIONS_PREFIX$categoryId", "$PREF_CATEGORY_STATIONS_TIME_PREFIX$categoryId", allowStale = true ) } // --------------------------------------------------------------------- // Общая реализация чтения/записи // --------------------------------------------------------------------- private inline fun writeEntry( dataKey: String, timeKey: String, data: List, logLabel: String ) { if (!isCachingEnabled()) return val p = prefs() ?: return try { val json = gson.toJson(data) val timestamp = System.currentTimeMillis() p.edit() .putString(dataKey, json) .putLong(timeKey, timestamp) .apply() memoryCache[dataKey] = MemoryEntry(json, timestamp) Timber.tag(TAG).d("Cached ${data.size} $logLabel") enforceMaxCacheSize() } catch (e: Exception) { Timber.tag(TAG).e(e, "Error caching $logLabel") } } private inline fun readEntry( dataKey: String, timeKey: String, allowStale: Boolean ): List? { if (!isCachingEnabled()) return null val p = prefs() ?: return null val now = System.currentTimeMillis() // Сначала пробуем in-memory слой — не трогаем диск лишний раз memoryCache[dataKey]?.let { mem -> val age = now - mem.timestamp if (age <= CACHE_EXPIRATION_TIME || (allowStale && age <= STALE_FALLBACK_WINDOW)) { return parseOrEvict(dataKey, mem.json) } } return try { val cacheTime = p.getLong(timeKey, 0) val age = now - cacheTime val withinFreshWindow = age <= CACHE_EXPIRATION_TIME val withinStaleWindow = allowStale && age <= STALE_FALLBACK_WINDOW if (!withinFreshWindow && !withinStaleWindow) { Timber.tag(TAG).d("Cache for $dataKey expired (age=${age}ms)") return null } val json = p.getString(dataKey, null) ?: return null memoryCache[dataKey] = MemoryEntry(json, cacheTime) parseOrEvict(dataKey, json) } catch (e: Exception) { Timber.tag(TAG).e(e, "Error retrieving cache for $dataKey") null } } private inline fun parseOrEvict(dataKey: String, json: String): List? { return try { val type = TypeToken.getParameterized(List::class.java, T::class.java).type gson.fromJson>(json, type) } catch (e: JsonParseException) { // Повреждённый или несовместимый JSON — не роняем вызывающий код, // а тихо чистим битую запись, чтобы она не мешала при следующем чтении. Timber.tag(TAG).e(e, "Corrupted cache entry for $dataKey, evicting") evictEntry(dataKey) null } } private fun evictEntry(dataKey: String) { memoryCache.remove(dataKey) prefs()?.edit()?.remove(dataKey)?.apply() } // --------------------------------------------------------------------- // Обслуживание кеша // --------------------------------------------------------------------- /** Очистить весь кеш (кроме флагов enabled/max size/версии формата) */ fun clearCache() { val p = prefs() ?: return try { val editor = p.edit() editor.remove(PREF_STATIONS_CACHE) editor.remove(PREF_STATIONS_CACHE_TIME) editor.remove(PREF_CATEGORIES_CACHE) editor.remove(PREF_CATEGORIES_CACHE_TIME) p.all.keys.forEach { key -> if (key.startsWith(PREF_CATEGORY_STATIONS_PREFIX) || key.startsWith(PREF_CATEGORY_STATIONS_TIME_PREFIX) ) { editor.remove(key) } } editor.apply() memoryCache.clear() Timber.tag(TAG).d("Cache cleared") } catch (e: Exception) { Timber.tag(TAG).e(e, "Error clearing cache") } } /** Приблизительный размер кеша в байтах */ fun getCacheSize(): Long { val p = prefs() ?: return 0L return try { var totalSize = 0L p.getString(PREF_STATIONS_CACHE, null)?.let { totalSize += it.length * 2L } p.getString(PREF_CATEGORIES_CACHE, null)?.let { totalSize += it.length * 2L } p.all.keys.forEach { key -> if (key.startsWith(PREF_CATEGORY_STATIONS_PREFIX)) { p.getString(key, null)?.let { totalSize += it.length * 2L } } } totalSize } catch (e: Exception) { Timber.tag(TAG).e(e, "Error calculating cache size") 0L } } fun getCacheSizeMB(): Double = getCacheSize() / (1024.0 * 1024.0) /** * Если кеш превысил заданный пользователем лимит (например, на устройствах * с малым объёмом памяти/хранилища) — сбрасываем самые "тяжёлые" данные * по категориям (они менее критичны, чем общий список станций). */ private fun enforceMaxCacheSize() { val maxBytes = getMaxCacheSize() * 1024L * 1024L if (getCacheSize() <= maxBytes) return val p = prefs() ?: return Timber.tag(TAG).w("Cache exceeds max size, trimming per-category entries") val categoryKeys = p.all.keys.filter { it.startsWith(PREF_CATEGORY_STATIONS_PREFIX) } val editor = p.edit() categoryKeys.forEach { key -> editor.remove(key) editor.remove(key.replace(PREF_CATEGORY_STATIONS_PREFIX, PREF_CATEGORY_STATIONS_TIME_PREFIX)) memoryCache.remove(key) } editor.apply() } }