Files
SoundWaveRadio/app/src/main/java/com/yaros/RadioUrl/helpers/CacheManager.kt
T

347 lines
14 KiB
Kotlin

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<String, MemoryEntry>()
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<Station>) {
writeEntry(PREF_STATIONS_CACHE, PREF_STATIONS_CACHE_TIME, stations, "stations")
}
fun getCachedStations(): List<Station>? {
return readEntry(PREF_STATIONS_CACHE, PREF_STATIONS_CACHE_TIME, allowStale = false)
}
/** То же самое, но если кеш протух — вернёт его всё равно (для fallback при ошибке сети). */
fun getCachedStationsOrStale(): List<Station>? {
return readEntry(PREF_STATIONS_CACHE, PREF_STATIONS_CACHE_TIME, allowStale = true)
}
// ---------------------------------------------------------------------
// Categories
// ---------------------------------------------------------------------
fun cacheCategories(categories: List<Category>) {
writeEntry(PREF_CATEGORIES_CACHE, PREF_CATEGORIES_CACHE_TIME, categories, "categories")
}
fun getCachedCategories(): List<Category>? {
return readEntry(PREF_CATEGORIES_CACHE, PREF_CATEGORIES_CACHE_TIME, allowStale = false)
}
fun getCachedCategoriesOrStale(): List<Category>? {
return readEntry(PREF_CATEGORIES_CACHE, PREF_CATEGORIES_CACHE_TIME, allowStale = true)
}
// ---------------------------------------------------------------------
// Stations by category
// ---------------------------------------------------------------------
fun cacheCategoryStations(categoryId: Int, stations: List<Station>) {
writeEntry(
"$PREF_CATEGORY_STATIONS_PREFIX$categoryId",
"$PREF_CATEGORY_STATIONS_TIME_PREFIX$categoryId",
stations,
"category $categoryId stations"
)
}
fun getCachedCategoryStations(categoryId: Int): List<Station>? {
return readEntry(
"$PREF_CATEGORY_STATIONS_PREFIX$categoryId",
"$PREF_CATEGORY_STATIONS_TIME_PREFIX$categoryId",
allowStale = false
)
}
fun getCachedCategoryStationsOrStale(categoryId: Int): List<Station>? {
return readEntry(
"$PREF_CATEGORY_STATIONS_PREFIX$categoryId",
"$PREF_CATEGORY_STATIONS_TIME_PREFIX$categoryId",
allowStale = true
)
}
// ---------------------------------------------------------------------
// Общая реализация чтения/записи
// ---------------------------------------------------------------------
private inline fun <reified T> writeEntry(
dataKey: String,
timeKey: String,
data: List<T>,
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 <reified T> readEntry(
dataKey: String,
timeKey: String,
allowStale: Boolean
): List<T>? {
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 <reified T> parseOrEvict(dataKey: String, json: String): List<T>? {
return try {
val type = TypeToken.getParameterized(List::class.java, T::class.java).type
gson.fromJson<List<T>>(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()
}
}