1.9.9.8 уведомление и кеширование

This commit is contained in:
2026-07-03 13:50:48 +03:00
parent 271fcfe54f
commit 0a05413a31
8 changed files with 1017 additions and 746 deletions
@@ -75,7 +75,25 @@ class MainActivity : AppCompatActivity() {
navView.setupWithNavController(navController)
PreferencesHelper.registerPreferenceChangeListener(sharedPreferenceChangeListener)
startPlayerService()
// ИСПРАВЛЕНИЕ КРАША ForegroundServiceDidNotStartInTimeException:
// Раньше здесь безусловно вызывался startPlayerService(), который
// поднимал PlayerService как foreground service на КАЖДЫЙ запуск
// приложения — даже если пользователь ещё ничего не проигрывал.
// PlayerService при этом не звал startForeground() вовремя (плеер
// оставался в IDLE), и система убивала процесс по таймауту.
//
// Теперь сервис НЕ стартуется принудительно отсюда. PlayerFragment
// сам поднимет его лениво через MediaController.Builder(...).buildAsync()
// (это bindService, а не startForegroundService — таймаут не применяется).
// startForeground() внутри сервиса вызывается автоматически, когда
// реально начинается воспроизведение.
//
// Если вам нужно авто-возобновление последней станции сразу при
// старте приложения — раскомментируйте вызов ниже. Он безопасен,
// так как PlayerService.onStartCommand() теперь гарантированно
// вызывает startForeground() немедленно при получении ACTION_START.
// resumeLastStationIfNeeded()
// Инициализация Firebase Messaging (если доступен)
initializeFirebaseMessaging()
@@ -132,12 +150,28 @@ class MainActivity : AppCompatActivity() {
)
}
/**
* ВАРИАНТ A (опционально): безопасный явный запуск сервиса как
* foreground service — например, для авто-возобновления последней
* станции сразу при холодном старте приложения.
*
* Безопасно вызывать: PlayerService.onStartCommand() гарантированно
* вызывает startForeground() с placeholder-уведомлением немедленно
* после получения этого intent'а, поэтому таймаут системы не сработает
* независимо от состояния ExoPlayer.
*
* По умолчанию НЕ вызывается из onCreate() (см. Вариант B выше) —
* раскомментируйте вызов в onCreate(), если нужно такое поведение.
*/
@UnstableApi
private fun startPlayerService() {
if (!isServiceRunning(PlayerService::class.java)) {
val serviceIntent = Intent(this, PlayerService::class.java)
ContextCompat.startForegroundService(this, serviceIntent)
@Suppress("unused")
private fun resumeLastStationIfNeeded() {
if (isServiceRunning(PlayerService::class.java)) return
val serviceIntent = Intent(this, PlayerService::class.java).apply {
action = Keys.ACTION_START
}
ContextCompat.startForegroundService(this, serviceIntent)
}
private fun isServiceRunning(serviceClass: Class<*>): Boolean {
@@ -163,11 +197,11 @@ class MainActivity : AppCompatActivity() {
}
private val sharedPreferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
when (key) {
Keys.PREF_THEME_SELECTION -> {
AppThemeHelper.setTheme(PreferencesHelper.loadThemeSelection())
}
when (key) {
Keys.PREF_THEME_SELECTION -> {
AppThemeHelper.setTheme(PreferencesHelper.loadThemeSelection())
}
}
}
}
}
@@ -1,14 +1,11 @@
package com.yaros.RadioUrl
import android.app.ActivityManager
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.graphics.Bitmap
import android.media.AudioManager
import android.net.Uri
import android.os.BatteryManager
@@ -17,9 +14,6 @@ import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import androidx.core.app.NotificationCompat
import androidx.media3.*
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
@@ -35,13 +29,11 @@ import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.SeekParameters
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult
import androidx.media3.ui.PlayerNotificationManager
import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
import com.yaros.RadioUrl.helpers.AudioHelper
@@ -49,75 +41,36 @@ import com.yaros.RadioUrl.helpers.NetworkHelper
import com.yaros.RadioUrl.helpers.NetworkMonitor
import com.yaros.RadioUrl.helpers.PreferencesHelper
import com.yaros.RadioUrl.helpers.VolumeSettingsHelper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import timber.log.Timber
/**
* ВАЖНЫЕ АРХИТЕКТУРНЫЕ ИЗМЕНЕНИЯ ПО СРАВНЕНИЮ С ПРЕДЫДУЩЕЙ ВЕРСИЕЙ:
*
* 1. Убран PlayerNotificationManager и вся связанная с ним рефлексия для привязки
* MediaSession token к уведомлению. MediaSessionService имеет встроенный
* MediaNotification.Provider, который сам создаёт уведомление, сам вызывает
* startForeground() и корректно регистрирует сессию в системе (шторка,
* блокировка экрана, Android Auto, Wear OS). Смешивание старого
* PlayerNotificationManager с MediaSessionService — известная причина того,
* что система (особенно на новых версиях Android) не распознаёт активное
* воспроизведение.
*
* 2. Аудиофокус больше не обрабатывается вручную. setAudioAttributes(..., true)
* включает handleAudioFocus у ExoPlayer — теперь ExoPlayer сам запрашивает
* и отдаёт фокус ПЕРЕД любым player.play(), независимо от того, откуда
* пришла команда: из нашего кода, из системного уведомления, с Bluetooth-
* гарнитуры, из Android Auto или от Ассистента. Раньше ручной запрос фокуса
* происходил только внутри playRadio() / кастомной SessionCommand("play"),
* а стандартные системные команды play/pause идут через MediaSession
* напрямую на плеер, минуя onCustomCommand — поэтому фокус для них никогда
* не запрашивался.
*/
@UnstableApi
class PlayerService : MediaSessionService(), Player.Listener {
private var exoPlayer: ExoPlayer? = null
private lateinit var mediaSession: MediaSession
private lateinit var notificationManager: NotificationManager
private lateinit var audioManager: AudioManager
private lateinit var networkMonitor: NetworkMonitor
private var isForegroundService = false
private lateinit var playerNotificationManager: PlayerNotificationManager
private var hasAudioFocus = false
private var playbackDelayed = false
private var resumeOnFocusGain = false
private val audioFocusRequest = AudioManager.OnAudioFocusChangeListener { focusChange ->
Timber.tag("PlayerService").i("Audio focus changed: $focusChange")
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
Timber.tag("PlayerService").i("Audio focus gained")
hasAudioFocus = true
exoPlayer?.let { player ->
// Восстанавливаем громкость если была уменьшена (ducking)
if (player.volume < 1.0f) {
player.volume = 1.0f
Timber.tag("PlayerService").i("Volume restored to 100%")
}
// Если воспроизведение было отложено - запускаем
if (playbackDelayed) {
Timber.tag("PlayerService").i("Starting delayed playback")
player.play()
playbackDelayed = false
}
// Если нужно возобновить после временной паузы
else if (resumeOnFocusGain) {
Timber.tag("PlayerService").i("Resuming playback after transient loss")
player.play()
resumeOnFocusGain = false
}
}
}
AudioManager.AUDIOFOCUS_LOSS -> {
Timber.tag("PlayerService").i("Audio focus lost permanently")
hasAudioFocus = false
resumeOnFocusGain = false
playbackDelayed = false
exoPlayer?.pause()
// Останавливаем сервис при постоянной потере фокуса
stopServiceGracefully()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
Timber.tag("PlayerService").i("Audio focus lost temporarily")
hasAudioFocus = false
resumeOnFocusGain = exoPlayer?.isPlaying == true
exoPlayer?.pause()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
Timber.tag("PlayerService").i("Audio focus lost, can duck")
// Уменьшаем громкость вместо паузы
exoPlayer?.volume = 0.2f
}
}
}
inner class LocalBinder : Binder() {
val service: PlayerService
@@ -127,23 +80,18 @@ class PlayerService : MediaSessionService(), Player.Listener {
override fun onCreate() {
super.onCreate()
initComponents()
// Немедленно запускаем foreground service с временным уведомлением
// чтобы избежать RemoteServiceException
startForeground(NOTIFICATION_ID, createInitialNotification())
isForegroundService = true
setupPlayer() // Синхронная инициализация
setupMediaSession() // Должна быть после setupPlayer()
setupNotificationManager() // Должна быть после setupMediaSession()
setupPlayer()
setupMediaSession()
setupMediaNotificationProvider()
setupNetworkMonitor()
// Намеренно НЕ вызываем startForeground() вручную здесь.
// MediaSessionService сам переведёт сервис в foreground, как только
// начнётся подготовка/воспроизведение медиа (см. playRadio()).
}
private fun initComponents() {
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
networkMonitor = NetworkMonitor(this)
createNotificationChannel()
}
private fun createDataSourceFactory(): DataSource.Factory {
@@ -152,7 +100,6 @@ class PlayerService : MediaSessionService(), Player.Listener {
.setUserAgent("SoundWaveRadio/1.0")
} catch (e: Exception) {
Timber.tag("PlayerService").e("Error creating data source factory: ${e.message}")
// Fallback to default
OkHttpDataSource.Factory(NetworkHelper.client)
.setUserAgent("SoundWaveRadio/1.0")
}
@@ -189,7 +136,7 @@ class PlayerService : MediaSessionService(), Player.Listener {
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.setUsage(C.USAGE_MEDIA)
.build(),
false // НЕ обрабатываем Audio Focus автоматически - делаем это вручную
true // handleAudioFocus = true — фокусом управляет сам ExoPlayer
)
playWhenReady = true
setSeekParameters(SeekParameters.CLOSEST_SYNC)
@@ -212,64 +159,25 @@ class PlayerService : MediaSessionService(), Player.Listener {
.build()
}
private fun setupNotificationManager() {
playerNotificationManager = PlayerNotificationManager.Builder(
this,
NOTIFICATION_ID,
NOTIFICATION_CHANNEL_ID
/**
* Настраивает встроенный провайдер уведомлений MediaSessionService.
* Он сам создаст notification channel, сам построит MediaStyle-уведомление,
* сам подгрузит artwork по MediaMetadata.artworkUri и сам вызовет
* startForeground()/stopForeground() в нужные моменты жизненного цикла.
*/
private fun setupMediaNotificationProvider() {
setMediaNotificationProvider(
DefaultMediaNotificationProvider.Builder(this)
.setChannelId(NOTIFICATION_CHANNEL_ID)
.setChannelName(R.string.notification_info)
.setNotificationId(NOTIFICATION_ID)
.build()
)
.setMediaDescriptionAdapter(NotificationMediaAdapter())
.setChannelNameResourceId(R.string.notification_info)
.setChannelDescriptionResourceId(R.string.default_notification_channel_name)
.setNotificationListener(object : PlayerNotificationManager.NotificationListener {
override fun onNotificationPosted(
notificationId: Int,
notification: Notification,
ongoing: Boolean
) {
// Обновляем уведомление, если сервис уже в foreground режиме
if (ongoing && isForegroundService) {
startForeground(notificationId, notification)
} else if (!ongoing) {
stopForeground(false)
isForegroundService = false
}
}
override fun onNotificationCancelled(notificationId: Int, dismissedByUser: Boolean) {
stopSelf()
}
})
.build()
.apply {
setPlayer(exoPlayer)
setColorized(true)
setPriority(NotificationCompat.PRIORITY_LOW)
}
}
private val notificationListener = object : PlayerNotificationManager.NotificationListener {
override fun onNotificationPosted(
notificationId: Int,
notification: Notification,
ongoing: Boolean
) {
if (ongoing && !isForegroundService) {
startForeground(notificationId, notification)
isForegroundService = true
}
}
override fun onNotificationCancelled(notificationId: Int, dismissedByUser: Boolean) {
stopSelf()
}
}
private fun setupNetworkMonitor() {
networkMonitor.addListener(object : NetworkMonitor.Listener {
override fun onNetworkAvailable() {
// Post to the main thread
Handler(Looper.getMainLooper()).post {
exoPlayer?.prepare()
exoPlayer?.play()
@@ -277,7 +185,6 @@ class PlayerService : MediaSessionService(), Player.Listener {
}
override fun onNetworkLost() {
// Post to the main thread
Handler(Looper.getMainLooper()).post {
exoPlayer?.pause()
}
@@ -286,7 +193,6 @@ class PlayerService : MediaSessionService(), Player.Listener {
networkMonitor.start()
}
private fun getSessionActivity(): PendingIntent {
val intent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
@@ -319,12 +225,6 @@ class PlayerService : MediaSessionService(), Player.Listener {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
// Убеждаемся, что сервис в foreground режиме при каждом вызове startForegroundService()
if (!isForegroundService) {
startForeground(NOTIFICATION_ID, createInitialNotification())
isForegroundService = true
}
intent?.let {
when (it.action) {
Keys.ACTION_PLAY_STREAM -> {
@@ -337,7 +237,7 @@ class PlayerService : MediaSessionService(), Player.Listener {
if (!streamUri.isNullOrBlank()) {
val artUri = if (!stationImage.isNullOrBlank()) {
android.net.Uri.parse(stationImage)
Uri.parse(stationImage)
} else {
null
}
@@ -363,7 +263,6 @@ class PlayerService : MediaSessionService(), Player.Listener {
super.onTaskRemoved(rootIntent)
Timber.tag("PlayerService").i("Task removed, isPlaying: ${exoPlayer?.isPlaying}")
// Останавливаем сервис только если не воспроизводится
if (exoPlayer?.isPlaying != true) {
Timber.tag("PlayerService").i("Stopping service - not playing")
stopServiceGracefully()
@@ -373,7 +272,7 @@ class PlayerService : MediaSessionService(), Player.Listener {
}
override fun onDestroy() {
releaseResources()
releaseResources()
super.onDestroy()
}
@@ -383,22 +282,11 @@ class PlayerService : MediaSessionService(), Player.Listener {
retryAttempts = 0
networkMonitor.stop()
// Освобождаем Audio Focus
if (hasAudioFocus) {
audioManager.abandonAudioFocus(audioFocusRequest)
hasAudioFocus = false
Timber.tag("PlayerService").i("Audio focus abandoned")
}
if (::mediaSession.isInitialized) {
mediaSession.release()
}
exoPlayer?.release()
exoPlayer = null
if (::playerNotificationManager.isInitialized) {
playerNotificationManager.setPlayer(null)
}
} catch (e: Exception) {
Timber.tag("PlayerService").e("Error releasing resources: ${e.message}")
}
@@ -433,27 +321,17 @@ class PlayerService : MediaSessionService(), Player.Listener {
args: Bundle
): ListenableFuture<SessionResult> {
when (customCommand.customAction) {
"play" -> {
"play" -> {
if (isAppInForeground()) {
// Запрашиваем Audio Focus перед воспроизведением
val result = audioManager.requestAudioFocus(
audioFocusRequest,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN
)
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
hasAudioFocus = true
exoPlayer?.play()
Timber.tag("PlayerService").i("Play command: Audio focus granted, playing")
} else {
Timber.tag("PlayerService").w("Play command: Audio focus denied")
}
// Аудиофокус запросит сам ExoPlayer при вызове play()
exoPlayer?.play()
Timber.tag("PlayerService").i("Play command received")
} else {
Timber.tag("PlayerService").w("Невозможно воспроизвести из фонового режима")
}
}
"pause" -> handlePauseCommand()
"stop" -> stopServiceGracefully()
"pause" -> exoPlayer?.pause()
"stop" -> stopServiceGracefully()
Keys.CMD_REQUEST_METADATA_HISTORY -> {
val metadataHistory = PreferencesHelper.loadMetadataHistory()
val resultBundle = Bundle().apply {
@@ -464,39 +342,11 @@ class PlayerService : MediaSessionService(), Player.Listener {
}
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
private fun handlePauseCommand() {
exoPlayer?.pause()
// Освобождаем Audio Focus при паузе
if (hasAudioFocus) {
audioManager.abandonAudioFocus(audioFocusRequest)
hasAudioFocus = false
Timber.tag("PlayerService").i("Audio focus abandoned on pause")
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_DETACH)
} else {
@Suppress("DEPRECATION")
stopForeground(false)
}
isForegroundService = false
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying)
Timber.tag("PlayerService").i("onIsPlayingChanged: $isPlaying")
if (!isPlaying) {
// Когда воспроизведение останавливается, освобождаем Audio Focus
if (hasAudioFocus && exoPlayer?.playWhenReady == false) {
audioManager.abandonAudioFocus(audioFocusRequest)
hasAudioFocus = false
Timber.tag("PlayerService").i("Audio focus abandoned - playback stopped")
}
}
}
override fun onPlaybackStateChanged(playbackState: Int) {
@@ -513,14 +363,7 @@ class PlayerService : MediaSessionService(), Player.Listener {
}
Player.STATE_IDLE -> {
Timber.tag("PlayerService").i("Playback state: IDLE")
// Останавливаем сервис если плеер не должен воспроизводить
if (exoPlayer?.playWhenReady == false) {
// Освобождаем Audio Focus
if (hasAudioFocus) {
audioManager.abandonAudioFocus(audioFocusRequest)
hasAudioFocus = false
Timber.tag("PlayerService").i("Audio focus abandoned on idle")
}
stopServiceGracefully()
}
}
@@ -577,14 +420,11 @@ class PlayerService : MediaSessionService(), Player.Listener {
if (metadataString.isNotEmpty()) {
Timber.tag("PlayerService").i("Metadata received: $metadataString")
// Сохраняем метаданные в историю
val metadataHistory = PreferencesHelper.loadMetadataHistory()
// Добавляем только если это новые метаданные
if (metadataHistory.isEmpty() || metadataHistory.last() != metadataString) {
metadataHistory.add(metadataString)
// Ограничиваем размер истории
while (metadataHistory.size > Keys.DEFAULT_SIZE_OF_METADATA_HISTORY) {
metadataHistory.removeAt(0)
}
@@ -607,35 +447,6 @@ class PlayerService : MediaSessionService(), Player.Listener {
exoPlayer?.playWhenReady = true
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
NOTIFICATION_CHANNEL_ID,
getString(R.string.notification_info),
NotificationManager.IMPORTANCE_LOW
).apply {
description = getString(R.string.default_notification_channel_name)
setShowBadge(false)
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
enableLights(false)
enableVibration(false)
setSound(null, null)
}
notificationManager.createNotificationChannel(channel)
}
}
private fun createInitialNotification(): Notification {
return NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(R.string.notification_info))
.setSmallIcon(R.drawable.ic_launcher_foreground)
.setContentIntent(getSessionActivity())
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.build()
}
fun playRadio(url: String, title: String, artist: String, artUri: Uri?, stationUuid: String? = null) {
try {
Timber.tag("PlayerService").i("=== playRadio called ===")
@@ -647,7 +458,6 @@ class PlayerService : MediaSessionService(), Player.Listener {
}
exoPlayer?.apply {
// Применяем индивидуальную громкость ДО начала воспроизведения
val targetVolume = if (!stationUuid.isNullOrBlank()) {
val stationVolume = VolumeSettingsHelper.getVolume(this@PlayerService, stationUuid)
Timber.tag("PlayerService").i("Station has custom volume: $stationVolume (${(stationVolume * 100).toInt()}%)")
@@ -657,15 +467,9 @@ class PlayerService : MediaSessionService(), Player.Listener {
1.0f
}
// Устанавливаем громкость перед подготовкой
volume = targetVolume
Timber.tag("PlayerService").i("Volume set to: $targetVolume before playback")
// Сбрасываем флаги Audio Focus для нового воспроизведения
resumeOnFocusGain = false
playbackDelayed = false
// Останавливаем текущее воспроизведение
stop()
clearMediaItems()
@@ -682,11 +486,10 @@ class PlayerService : MediaSessionService(), Player.Listener {
setMediaItem(mediaItem)
// Подтверждаем, что громкость установлена
Timber.tag("PlayerService").i("Current player volume before prepare: $volume")
// Запрашиваем Audio Focus ПЕРЕД prepare и play
requestAudioFocusAndPlay()
// prepare()+play() — ExoPlayer сам запросит аудиофокус перед стартом
// благодаря handleAudioFocus=true из setupPlayer()
prepare()
play()
} ?: run {
Timber.tag("PlayerService").e("ExoPlayer is null, cannot play radio")
}
@@ -700,59 +503,9 @@ class PlayerService : MediaSessionService(), Player.Listener {
}
}
private fun requestAudioFocusAndPlay() {
Timber.tag("PlayerService").i("Requesting audio focus...")
val result = audioManager.requestAudioFocus(
audioFocusRequest,
AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN
)
Timber.tag("PlayerService").i("Audio focus request result: $result")
when (result) {
AudioManager.AUDIOFOCUS_REQUEST_GRANTED -> {
Timber.tag("PlayerService").i("Audio focus GRANTED - starting playback")
hasAudioFocus = true
playbackDelayed = false
// Теперь можем безопасно начать воспроизведение
exoPlayer?.let { player ->
player.prepare()
player.play()
Timber.tag("PlayerService").i("Playback started with audio focus")
}
}
AudioManager.AUDIOFOCUS_REQUEST_FAILED -> {
Timber.tag("PlayerService").w("Audio focus FAILED - cannot play")
hasAudioFocus = false
playbackDelayed = false
Toast.makeText(this, getString(R.string.notification_play), Toast.LENGTH_SHORT).show()
}
AudioManager.AUDIOFOCUS_REQUEST_DELAYED -> {
Timber.tag("PlayerService").i("Audio focus DELAYED - will play when granted")
hasAudioFocus = false
playbackDelayed = true
// prepare() вызовем сейчас, play() будет вызван в onAudioFocusChange
exoPlayer?.prepare()
}
}
}
private fun stopServiceGracefully() {
Timber.tag("PlayerService").i("Stopping service gracefully")
// Освобождаем Audio Focus перед остановкой
if (hasAudioFocus) {
audioManager.abandonAudioFocus(audioFocusRequest)
hasAudioFocus = false
Timber.tag("PlayerService").i("Audio focus abandoned on stop")
}
exoPlayer?.stop()
stopForeground(true)
stopSelf()
}
@@ -773,7 +526,7 @@ class PlayerService : MediaSessionService(), Player.Listener {
val packageName = packageName
return appProcesses.any { process ->
process.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND &&
process.processName == packageName
process.processName == packageName
}
}
@@ -832,55 +585,12 @@ class PlayerService : MediaSessionService(), Player.Listener {
}, delayMillis)
}
private inner class NotificationMediaAdapter : PlayerNotificationManager.MediaDescriptionAdapter {
override fun getCurrentContentTitle(player: Player): CharSequence {
return player.mediaMetadata.title ?: getString(R.string.app_name)
}
override fun createCurrentContentIntent(player: Player): PendingIntent? {
return getSessionActivity()
}
override fun getCurrentContentText(player: Player): CharSequence? {
return player.mediaMetadata.artist ?: getString(R.string.sample_text_station_metadata)
}
override fun getCurrentLargeIcon(
player: Player,
callback: PlayerNotificationManager.BitmapCallback
): Bitmap? {
val imageUri = player.mediaMetadata.artworkUri
return if (imageUri != null) {
loadBitmapAsync(imageUri.toString(), callback)
null
} else {
null
}
}
private fun loadBitmapAsync(url: String, callback: PlayerNotificationManager.BitmapCallback) {
CoroutineScope(Dispatchers.IO).launch {
try {
val bitmap = Glide.with(this@PlayerService)
.asBitmap()
.load(url)
.apply(RequestOptions().timeout(10000))
.submit()
.get()
callback.onBitmap(bitmap)
} catch (e: Exception) {
Timber.tag("Player").v("Playback error: notification")
}
}
}
}
companion object {
const val NOTIFICATION_ID = 2031
const val NOTIFICATION_CHANNEL_ID = "Проигрование"
const val NOTIFICATION_CHANNEL_ID = "playback_channel"
private const val MIN_BUFFER_MS = 5000
private const val MAX_BUFFER_MS = 10000
private const val PLAYBACK_BUFFER_MS = 2000
private const val REBUFFER_MS = 5000
}
}
}
@@ -12,6 +12,7 @@ import com.yaros.RadioUrl.helpers.PreferencesHelper
import com.yaros.RadioUrl.helpers.PreferencesHelper.initPreferences
import dagger.hilt.android.HiltAndroidApp
import android.app.Application
import com.yaros.RadioUrl.helpers.CacheManager
import timber.log.Timber
import java.util.concurrent.ExecutionException
import java.util.concurrent.Executors
@@ -33,6 +34,7 @@ class URLRadio : Application() {
// Инициализация Firebase с runtime-детекцией
initFirebaseWithFallback()
initPreferences()
CacheManager.init(this)
AppThemeHelper.setTheme(PreferencesHelper.loadThemeSelection())
adManager = AdManager(this)
ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleObserver)
@@ -1,11 +1,11 @@
package com.yaros.RadioUrl.core.supabase
import android.content.Context
import android.util.Log
import com.yaros.RadioUrl.core.APIInterface.data.Category
import com.yaros.RadioUrl.core.APIInterface.data.NewsItem
import com.yaros.RadioUrl.data.Station as DataStation
import com.yaros.RadioUrl.helpers.CacheManager
import timber.log.Timber
/**
* Адаптер для совместимости с существующим ApiRepository
@@ -23,9 +23,13 @@ class SupabaseApiRepository(private val context: Context? = null) {
// Добавляем метод getHomeData для совместимости
suspend fun getHomeData(): NetworkResult<HomeResponse> {
return try {
// Получаем последние радиостанции как "избранные"
val stationsResult = supabaseRepository.getAllStations()
val cachedStations = CacheManager.getCachedStations()
if (cachedStations != null) {
Timber.tag("SupabaseApiRepository").d("Returning cached home stations")
return NetworkResult.Success(HomeResponse(news = emptyList(), favorites = cachedStations))
}
val stationsResult = supabaseRepository.getAllStations()
if (stationsResult.isSuccess) {
val stations = stationsResult.getOrNull()?.map { apiStation ->
DataStation(
@@ -42,13 +46,13 @@ class SupabaseApiRepository(private val context: Context? = null) {
)
} ?: emptyList()
// Возвращаем HomeResponse с пустыми новостями и станциями
CacheManager.cacheStations(stations)
NetworkResult.Success(HomeResponse(news = emptyList(), favorites = stations))
} else {
NetworkResult.Error(stationsResult.exceptionOrNull()?.message ?: "Unknown error")
}
} catch (e: Exception) {
Log.e("SupabaseApiRepository", "Error getting home data", e)
Timber.e(e, "Error getting home data")
NetworkResult.Error(e.message ?: "Unknown error")
}
}
@@ -58,7 +62,7 @@ class SupabaseApiRepository(private val context: Context? = null) {
// Проверяем кеш
val cachedCategories = CacheManager.getCachedCategories()
if (cachedCategories != null) {
Log.d("SupabaseApiRepository", "Returning cached categories")
Timber.tag("SupabaseApiRepository").d("Returning cached categories")
return NetworkResult.Success(cachedCategories)
}
@@ -73,7 +77,7 @@ class SupabaseApiRepository(private val context: Context? = null) {
NetworkResult.Error(result.exceptionOrNull()?.message ?: "Unknown error")
}
} catch (e: Exception) {
Log.e("SupabaseApiRepository", "Error getting categories", e)
Timber.e(e, "Error getting categories")
NetworkResult.Error(e.message ?: "Unknown error")
}
}
@@ -83,7 +87,7 @@ class SupabaseApiRepository(private val context: Context? = null) {
// Проверяем кеш
val cachedStations = CacheManager.getCachedStations()
if (cachedStations != null) {
Log.d("SupabaseApiRepository", "Returning cached stations")
Timber.tag("SupabaseApiRepository").d("Returning cached stations")
return NetworkResult.Success(cachedStations)
}
@@ -112,7 +116,7 @@ class SupabaseApiRepository(private val context: Context? = null) {
NetworkResult.Error(result.exceptionOrNull()?.message ?: "Unknown error")
}
} catch (e: Exception) {
Log.e("SupabaseApiRepository", "Error getting recent radio", e)
Timber.e(e, "Error getting recent radio")
NetworkResult.Error(e.message ?: "Unknown error")
}
}
@@ -122,7 +126,7 @@ class SupabaseApiRepository(private val context: Context? = null) {
// Проверяем кеш для этой категории
val cachedStations = CacheManager.getCachedCategoryStations(categoryId)
if (cachedStations != null) {
Log.d("SupabaseApiRepository", "Returning cached stations for category $categoryId")
Timber.tag("SupabaseApiRepository").d("Returning cached stations for category $categoryId")
return NetworkResult.Success(cachedStations)
}
@@ -150,7 +154,7 @@ class SupabaseApiRepository(private val context: Context? = null) {
NetworkResult.Error(stationsResult.exceptionOrNull()?.message ?: "Unknown error")
}
} catch (e: Exception) {
Log.e("SupabaseApiRepository", "Error getting category detail", e)
Timber.e(e, "Error getting category detail")
NetworkResult.Error(e.message ?: "Unknown error")
}
}
@@ -178,7 +182,7 @@ class SupabaseApiRepository(private val context: Context? = null) {
NetworkResult.Error(result.exceptionOrNull()?.message ?: "Unknown error")
}
} catch (e: Exception) {
Log.e("SupabaseApiRepository", "Error searching", e)
Timber.e(e, "Error searching")
NetworkResult.Error(e.message ?: "Unknown error")
}
}
@@ -211,7 +215,7 @@ class SupabaseApiRepository(private val context: Context? = null) {
NetworkResult.Error(updateResult.exceptionOrNull()?.message ?: "Unknown error")
}
} catch (e: Exception) {
Log.e("SupabaseApiRepository", "Error updating station views", e)
Timber.e(e, "Error updating station views")
NetworkResult.Error(e.message ?: "Unknown error")
}
}
@@ -2,21 +2,43 @@ package com.yaros.RadioUrl.helpers
import android.content.Context
import android.content.SharedPreferences
import androidx.preference.PreferenceManager
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.TimeUnit
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"
@@ -28,7 +50,9 @@ object CacheManager {
private const val PREF_CATEGORY_STATIONS_TIME_PREFIX = "category_stations_time_"
// Время жизни кеша (по умолчанию 1 час)
private const val CACHE_EXPIRATION_TIME = 60 * 60 * 1000L // 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
@@ -36,264 +60,288 @@ object CacheManager {
const val CACHE_SIZE_LARGE = 50
const val CACHE_SIZE_XLARGE = 100
private lateinit var sharedPreferences: SharedPreferences
@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) {
sharedPreferences = PreferenceManager.getDefaultSharedPreferences(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 sharedPreferences.getBoolean(PREF_CACHE_ENABLED, true)
return prefs()?.getBoolean(PREF_CACHE_ENABLED, true) ?: true
}
/**
* Включить кеширование
*/
fun enableCaching() {
sharedPreferences.edit().putBoolean(PREF_CACHE_ENABLED, true).apply()
prefs()?.edit()?.putBoolean(PREF_CACHE_ENABLED, true)?.apply()
}
/**
* Отключить кеширование
*/
fun disableCaching() {
sharedPreferences.edit().putBoolean(PREF_CACHE_ENABLED, false).apply()
prefs()?.edit()?.putBoolean(PREF_CACHE_ENABLED, false)?.apply()
clearCache()
}
/**
* Получить максимальный размер кеша в МБ
*/
fun getMaxCacheSize(): Int {
return sharedPreferences.getInt(PREF_CACHE_MAX_SIZE, CACHE_SIZE_MEDIUM)
return prefs()?.getInt(PREF_CACHE_MAX_SIZE, CACHE_SIZE_MEDIUM) ?: CACHE_SIZE_MEDIUM
}
/**
* Установить максимальный размер кеша в МБ
*/
fun setMaxCacheSize(sizeMb: Int) {
sharedPreferences.edit().putInt(PREF_CACHE_MAX_SIZE, sizeMb).apply()
prefs()?.edit()?.putInt(PREF_CACHE_MAX_SIZE, sizeMb)?.apply()
enforceMaxCacheSize()
}
/**
* Сохранить список всех радиостанций в кеш
*/
// ---------------------------------------------------------------------
// Station list (все станции)
// ---------------------------------------------------------------------
fun cacheStations(stations: List<Station>) {
if (!isCachingEnabled()) return
try {
val json = gson.toJson(stations)
val currentTime = System.currentTimeMillis()
sharedPreferences.edit()
.putString(PREF_STATIONS_CACHE, json)
.putLong(PREF_STATIONS_CACHE_TIME, currentTime)
.apply()
Timber.tag(TAG).d("Cached ${stations.size} stations")
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Error caching stations")
}
writeEntry(PREF_STATIONS_CACHE, PREF_STATIONS_CACHE_TIME, stations, "stations")
}
/**
* Получить список всех радиостанций из кеша
*/
fun getCachedStations(): List<Station>? {
if (!isCachingEnabled()) return null
try {
val cacheTime = sharedPreferences.getLong(PREF_STATIONS_CACHE_TIME, 0)
val currentTime = System.currentTimeMillis()
// Проверяем, не истек ли срок действия кеша
if (currentTime - cacheTime > CACHE_EXPIRATION_TIME) {
Timber.tag(TAG).d("Stations cache expired")
return null
}
val json = sharedPreferences.getString(PREF_STATIONS_CACHE, null) ?: return null
val type = object : TypeToken<List<Station>>() {}.type
val stations: List<Station> = gson.fromJson(json, type)
Timber.tag(TAG).d("Retrieved ${stations.size} stations from cache")
return stations
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Error retrieving cached stations")
return null
}
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>) {
if (!isCachingEnabled()) return
try {
val json = gson.toJson(categories)
val currentTime = System.currentTimeMillis()
sharedPreferences.edit()
.putString(PREF_CATEGORIES_CACHE, json)
.putLong(PREF_CATEGORIES_CACHE_TIME, currentTime)
.apply()
Timber.tag(TAG).d("Cached ${categories.size} categories")
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Error caching categories")
}
writeEntry(PREF_CATEGORIES_CACHE, PREF_CATEGORIES_CACHE_TIME, categories, "categories")
}
/**
* Получить список категорий из кеша
*/
fun getCachedCategories(): List<Category>? {
if (!isCachingEnabled()) return null
try {
val cacheTime = sharedPreferences.getLong(PREF_CATEGORIES_CACHE_TIME, 0)
val currentTime = System.currentTimeMillis()
// Проверяем, не истек ли срок действия кеша
if (currentTime - cacheTime > CACHE_EXPIRATION_TIME) {
Timber.tag(TAG).d("Categories cache expired")
return null
}
val json = sharedPreferences.getString(PREF_CATEGORIES_CACHE, null) ?: return null
val type = object : TypeToken<List<Category>>() {}.type
val categories: List<Category> = gson.fromJson(json, type)
Timber.tag(TAG).d("Retrieved ${categories.size} categories from cache")
return categories
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Error retrieving cached categories")
return null
}
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(stations)
val currentTime = System.currentTimeMillis()
sharedPreferences.edit()
.putString("$PREF_CATEGORY_STATIONS_PREFIX$categoryId", json)
.putLong("$PREF_CATEGORY_STATIONS_TIME_PREFIX$categoryId", currentTime)
val json = gson.toJson(data)
val timestamp = System.currentTimeMillis()
p.edit()
.putString(dataKey, json)
.putLong(timeKey, timestamp)
.apply()
Timber.tag(TAG).d("Cached ${stations.size} stations for category $categoryId")
memoryCache[dataKey] = MemoryEntry(json, timestamp)
Timber.tag(TAG).d("Cached ${data.size} $logLabel")
enforceMaxCacheSize()
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Error caching category stations")
Timber.tag(TAG).e(e, "Error caching $logLabel")
}
}
/**
* Получить список радиостанций категории из кеша
*/
fun getCachedCategoryStations(categoryId: Int): List<Station>? {
private inline fun <reified T> readEntry(
dataKey: String,
timeKey: String,
allowStale: Boolean
): List<T>? {
if (!isCachingEnabled()) return null
val p = prefs() ?: return null
try {
val cacheTime = sharedPreferences.getLong("$PREF_CATEGORY_STATIONS_TIME_PREFIX$categoryId", 0)
val currentTime = System.currentTimeMillis()
val now = System.currentTimeMillis()
// Проверяем, не истек ли срок действия кеша
if (currentTime - cacheTime > CACHE_EXPIRATION_TIME) {
Timber.tag(TAG).d("Category $categoryId stations cache expired")
// Сначала пробуем 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 = sharedPreferences.getString("$PREF_CATEGORY_STATIONS_PREFIX$categoryId", null) ?: return null
val type = object : TypeToken<List<Station>>() {}.type
val stations: List<Station> = gson.fromJson(json, type)
Timber.tag(TAG).d("Retrieved ${stations.size} stations for category $categoryId from cache")
return stations
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 cached category stations")
return null
Timber.tag(TAG).e(e, "Error retrieving cache for $dataKey")
null
}
}
/**
* Очистить весь кеш
*/
fun clearCache() {
try {
val editor = sharedPreferences.edit()
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)
// Удаляем кеш станций по категориям
val allKeys = sharedPreferences.all.keys
allKeys.forEach { key ->
p.all.keys.forEach { key ->
if (key.startsWith(PREF_CATEGORY_STATIONS_PREFIX) ||
key.startsWith(PREF_CATEGORY_STATIONS_TIME_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 {
try {
val p = prefs() ?: return 0L
return try {
var totalSize = 0L
// Размер кеша станций
sharedPreferences.getString(PREF_STATIONS_CACHE, null)?.let {
totalSize += it.length * 2 // UTF-16, 2 байта на символ
}
// Размер кеша категорий
sharedPreferences.getString(PREF_CATEGORIES_CACHE, null)?.let {
totalSize += it.length * 2
}
// Размер кеша станций по категориям
val allKeys = sharedPreferences.all.keys
allKeys.forEach { key ->
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)) {
sharedPreferences.getString(key, null)?.let {
totalSize += it.length * 2
}
p.getString(key, null)?.let { totalSize += it.length * 2L }
}
}
return totalSize
totalSize
} catch (e: Exception) {
Timber.tag(TAG).e(e, "Error calculating cache size")
return 0L
0L
}
}
fun getCacheSizeMB(): Double = getCacheSize() / (1024.0 * 1024.0)
/**
* Получить размер кеша в МБ
* Если кеш превысил заданный пользователем лимит (например, на устройствах
* с малым объёмом памяти/хранилища) — сбрасываем самые "тяжёлые" данные
* по категориям (они менее критичны, чем общий список станций).
*/
fun getCacheSizeMB(): Double {
return 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()
}
}
}