347 lines
14 KiB
Kotlin
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()
|
|
}
|
|
} |