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
+14
View File
@@ -18,6 +18,20 @@
</InsightsFilterSettings> </InsightsFilterSettings>
</value> </value>
</entry> </entry>
<entry key="Android vitals">
<value>
<InsightsFilterSettings>
<option name="connection">
<ConnectionSetting>
<option name="appId" value="com.yaros.RadioUrl" />
</ConnectionSetting>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="SEVEN_DAYS" />
<option name="visibilityType" value="ALL" />
</InsightsFilterSettings>
</value>
</entry>
<entry key="Firebase Crashlytics"> <entry key="Firebase Crashlytics">
<value> <value>
<InsightsFilterSettings> <InsightsFilterSettings>
+644 -192
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
<component name="ProjectDictionaryState">
<dictionary name="project">
<words>
<w>Supabase</w>
</words>
</dictionary>
</component>
@@ -75,7 +75,25 @@ class MainActivity : AppCompatActivity() {
navView.setupWithNavController(navController) navView.setupWithNavController(navController)
PreferencesHelper.registerPreferenceChangeListener(sharedPreferenceChangeListener) 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 (если доступен) // Инициализация Firebase Messaging (если доступен)
initializeFirebaseMessaging() initializeFirebaseMessaging()
@@ -132,12 +150,28 @@ class MainActivity : AppCompatActivity() {
) )
} }
/**
* ВАРИАНТ A (опционально): безопасный явный запуск сервиса как
* foreground service — например, для авто-возобновления последней
* станции сразу при холодном старте приложения.
*
* Безопасно вызывать: PlayerService.onStartCommand() гарантированно
* вызывает startForeground() с placeholder-уведомлением немедленно
* после получения этого intent'а, поэтому таймаут системы не сработает
* независимо от состояния ExoPlayer.
*
* По умолчанию НЕ вызывается из onCreate() (см. Вариант B выше) —
* раскомментируйте вызов в onCreate(), если нужно такое поведение.
*/
@UnstableApi @UnstableApi
private fun startPlayerService() { @Suppress("unused")
if (!isServiceRunning(PlayerService::class.java)) { private fun resumeLastStationIfNeeded() {
val serviceIntent = Intent(this, PlayerService::class.java) if (isServiceRunning(PlayerService::class.java)) return
ContextCompat.startForegroundService(this, serviceIntent)
val serviceIntent = Intent(this, PlayerService::class.java).apply {
action = Keys.ACTION_START
} }
ContextCompat.startForegroundService(this, serviceIntent)
} }
private fun isServiceRunning(serviceClass: Class<*>): Boolean { private fun isServiceRunning(serviceClass: Class<*>): Boolean {
@@ -163,11 +197,11 @@ class MainActivity : AppCompatActivity() {
} }
private val sharedPreferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> private val sharedPreferenceChangeListener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
when (key) { when (key) {
Keys.PREF_THEME_SELECTION -> { Keys.PREF_THEME_SELECTION -> {
AppThemeHelper.setTheme(PreferencesHelper.loadThemeSelection()) AppThemeHelper.setTheme(PreferencesHelper.loadThemeSelection())
}
} }
} }
}
} }
@@ -1,14 +1,11 @@
package com.yaros.RadioUrl package com.yaros.RadioUrl
import android.app.ActivityManager import android.app.ActivityManager
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.graphics.Bitmap
import android.media.AudioManager import android.media.AudioManager
import android.net.Uri import android.net.Uri
import android.os.BatteryManager import android.os.BatteryManager
@@ -17,9 +14,6 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.Looper 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.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
import androidx.media3.common.MediaItem import androidx.media3.common.MediaItem
@@ -35,13 +29,11 @@ import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.SeekParameters import androidx.media3.exoplayer.SeekParameters
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.DefaultMediaNotificationProvider
import androidx.media3.session.MediaSession import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService import androidx.media3.session.MediaSessionService
import androidx.media3.session.SessionCommand import androidx.media3.session.SessionCommand
import androidx.media3.session.SessionResult 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.Futures
import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.ListenableFuture
import com.yaros.RadioUrl.helpers.AudioHelper 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.NetworkMonitor
import com.yaros.RadioUrl.helpers.PreferencesHelper import com.yaros.RadioUrl.helpers.PreferencesHelper
import com.yaros.RadioUrl.helpers.VolumeSettingsHelper import com.yaros.RadioUrl.helpers.VolumeSettingsHelper
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import timber.log.Timber 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 @UnstableApi
class PlayerService : MediaSessionService(), Player.Listener { class PlayerService : MediaSessionService(), Player.Listener {
private var exoPlayer: ExoPlayer? = null private var exoPlayer: ExoPlayer? = null
private lateinit var mediaSession: MediaSession private lateinit var mediaSession: MediaSession
private lateinit var notificationManager: NotificationManager private lateinit var notificationManager: NotificationManager
private lateinit var audioManager: AudioManager
private lateinit var networkMonitor: NetworkMonitor 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() { inner class LocalBinder : Binder() {
val service: PlayerService val service: PlayerService
@@ -127,23 +80,18 @@ class PlayerService : MediaSessionService(), Player.Listener {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
initComponents() initComponents()
setupPlayer()
// Немедленно запускаем foreground service с временным уведомлением setupMediaSession()
// чтобы избежать RemoteServiceException setupMediaNotificationProvider()
startForeground(NOTIFICATION_ID, createInitialNotification())
isForegroundService = true
setupPlayer() // Синхронная инициализация
setupMediaSession() // Должна быть после setupPlayer()
setupNotificationManager() // Должна быть после setupMediaSession()
setupNetworkMonitor() setupNetworkMonitor()
// Намеренно НЕ вызываем startForeground() вручную здесь.
// MediaSessionService сам переведёт сервис в foreground, как только
// начнётся подготовка/воспроизведение медиа (см. playRadio()).
} }
private fun initComponents() { private fun initComponents() {
audioManager = getSystemService(Context.AUDIO_SERVICE) as AudioManager
notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
networkMonitor = NetworkMonitor(this) networkMonitor = NetworkMonitor(this)
createNotificationChannel()
} }
private fun createDataSourceFactory(): DataSource.Factory { private fun createDataSourceFactory(): DataSource.Factory {
@@ -152,7 +100,6 @@ class PlayerService : MediaSessionService(), Player.Listener {
.setUserAgent("SoundWaveRadio/1.0") .setUserAgent("SoundWaveRadio/1.0")
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag("PlayerService").e("Error creating data source factory: ${e.message}") Timber.tag("PlayerService").e("Error creating data source factory: ${e.message}")
// Fallback to default
OkHttpDataSource.Factory(NetworkHelper.client) OkHttpDataSource.Factory(NetworkHelper.client)
.setUserAgent("SoundWaveRadio/1.0") .setUserAgent("SoundWaveRadio/1.0")
} }
@@ -189,7 +136,7 @@ class PlayerService : MediaSessionService(), Player.Listener {
.setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC)
.setUsage(C.USAGE_MEDIA) .setUsage(C.USAGE_MEDIA)
.build(), .build(),
false // НЕ обрабатываем Audio Focus автоматически - делаем это вручную true // handleAudioFocus = true — фокусом управляет сам ExoPlayer
) )
playWhenReady = true playWhenReady = true
setSeekParameters(SeekParameters.CLOSEST_SYNC) setSeekParameters(SeekParameters.CLOSEST_SYNC)
@@ -212,64 +159,25 @@ class PlayerService : MediaSessionService(), Player.Listener {
.build() .build()
} }
private fun setupNotificationManager() { /**
playerNotificationManager = PlayerNotificationManager.Builder( * Настраивает встроенный провайдер уведомлений MediaSessionService.
this, * Он сам создаст notification channel, сам построит MediaStyle-уведомление,
NOTIFICATION_ID, * сам подгрузит artwork по MediaMetadata.artworkUri и сам вызовет
NOTIFICATION_CHANNEL_ID * 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() { private fun setupNetworkMonitor() {
networkMonitor.addListener(object : NetworkMonitor.Listener { networkMonitor.addListener(object : NetworkMonitor.Listener {
override fun onNetworkAvailable() { override fun onNetworkAvailable() {
// Post to the main thread
Handler(Looper.getMainLooper()).post { Handler(Looper.getMainLooper()).post {
exoPlayer?.prepare() exoPlayer?.prepare()
exoPlayer?.play() exoPlayer?.play()
@@ -277,7 +185,6 @@ class PlayerService : MediaSessionService(), Player.Listener {
} }
override fun onNetworkLost() { override fun onNetworkLost() {
// Post to the main thread
Handler(Looper.getMainLooper()).post { Handler(Looper.getMainLooper()).post {
exoPlayer?.pause() exoPlayer?.pause()
} }
@@ -286,7 +193,6 @@ class PlayerService : MediaSessionService(), Player.Listener {
networkMonitor.start() networkMonitor.start()
} }
private fun getSessionActivity(): PendingIntent { private fun getSessionActivity(): PendingIntent {
val intent = Intent(this, MainActivity::class.java).apply { val intent = Intent(this, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) 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 { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId) super.onStartCommand(intent, flags, startId)
// Убеждаемся, что сервис в foreground режиме при каждом вызове startForegroundService()
if (!isForegroundService) {
startForeground(NOTIFICATION_ID, createInitialNotification())
isForegroundService = true
}
intent?.let { intent?.let {
when (it.action) { when (it.action) {
Keys.ACTION_PLAY_STREAM -> { Keys.ACTION_PLAY_STREAM -> {
@@ -337,7 +237,7 @@ class PlayerService : MediaSessionService(), Player.Listener {
if (!streamUri.isNullOrBlank()) { if (!streamUri.isNullOrBlank()) {
val artUri = if (!stationImage.isNullOrBlank()) { val artUri = if (!stationImage.isNullOrBlank()) {
android.net.Uri.parse(stationImage) Uri.parse(stationImage)
} else { } else {
null null
} }
@@ -363,7 +263,6 @@ class PlayerService : MediaSessionService(), Player.Listener {
super.onTaskRemoved(rootIntent) super.onTaskRemoved(rootIntent)
Timber.tag("PlayerService").i("Task removed, isPlaying: ${exoPlayer?.isPlaying}") Timber.tag("PlayerService").i("Task removed, isPlaying: ${exoPlayer?.isPlaying}")
// Останавливаем сервис только если не воспроизводится
if (exoPlayer?.isPlaying != true) { if (exoPlayer?.isPlaying != true) {
Timber.tag("PlayerService").i("Stopping service - not playing") Timber.tag("PlayerService").i("Stopping service - not playing")
stopServiceGracefully() stopServiceGracefully()
@@ -373,7 +272,7 @@ class PlayerService : MediaSessionService(), Player.Listener {
} }
override fun onDestroy() { override fun onDestroy() {
releaseResources() releaseResources()
super.onDestroy() super.onDestroy()
} }
@@ -383,22 +282,11 @@ class PlayerService : MediaSessionService(), Player.Listener {
retryAttempts = 0 retryAttempts = 0
networkMonitor.stop() networkMonitor.stop()
// Освобождаем Audio Focus
if (hasAudioFocus) {
audioManager.abandonAudioFocus(audioFocusRequest)
hasAudioFocus = false
Timber.tag("PlayerService").i("Audio focus abandoned")
}
if (::mediaSession.isInitialized) { if (::mediaSession.isInitialized) {
mediaSession.release() mediaSession.release()
} }
exoPlayer?.release() exoPlayer?.release()
exoPlayer = null exoPlayer = null
if (::playerNotificationManager.isInitialized) {
playerNotificationManager.setPlayer(null)
}
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag("PlayerService").e("Error releasing resources: ${e.message}") Timber.tag("PlayerService").e("Error releasing resources: ${e.message}")
} }
@@ -433,27 +321,17 @@ class PlayerService : MediaSessionService(), Player.Listener {
args: Bundle args: Bundle
): ListenableFuture<SessionResult> { ): ListenableFuture<SessionResult> {
when (customCommand.customAction) { when (customCommand.customAction) {
"play" -> { "play" -> {
if (isAppInForeground()) { if (isAppInForeground()) {
// Запрашиваем Audio Focus перед воспроизведением // Аудиофокус запросит сам ExoPlayer при вызове play()
val result = audioManager.requestAudioFocus( exoPlayer?.play()
audioFocusRequest, Timber.tag("PlayerService").i("Play command received")
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")
}
} else { } else {
Timber.tag("PlayerService").w("Невозможно воспроизвести из фонового режима") Timber.tag("PlayerService").w("Невозможно воспроизвести из фонового режима")
} }
} }
"pause" -> handlePauseCommand() "pause" -> exoPlayer?.pause()
"stop" -> stopServiceGracefully() "stop" -> stopServiceGracefully()
Keys.CMD_REQUEST_METADATA_HISTORY -> { Keys.CMD_REQUEST_METADATA_HISTORY -> {
val metadataHistory = PreferencesHelper.loadMetadataHistory() val metadataHistory = PreferencesHelper.loadMetadataHistory()
val resultBundle = Bundle().apply { val resultBundle = Bundle().apply {
@@ -464,39 +342,11 @@ class PlayerService : MediaSessionService(), Player.Listener {
} }
return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) 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) { override fun onIsPlayingChanged(isPlaying: Boolean) {
super.onIsPlayingChanged(isPlaying) super.onIsPlayingChanged(isPlaying)
Timber.tag("PlayerService").i("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) { override fun onPlaybackStateChanged(playbackState: Int) {
@@ -513,14 +363,7 @@ class PlayerService : MediaSessionService(), Player.Listener {
} }
Player.STATE_IDLE -> { Player.STATE_IDLE -> {
Timber.tag("PlayerService").i("Playback state: IDLE") Timber.tag("PlayerService").i("Playback state: IDLE")
// Останавливаем сервис если плеер не должен воспроизводить
if (exoPlayer?.playWhenReady == false) { if (exoPlayer?.playWhenReady == false) {
// Освобождаем Audio Focus
if (hasAudioFocus) {
audioManager.abandonAudioFocus(audioFocusRequest)
hasAudioFocus = false
Timber.tag("PlayerService").i("Audio focus abandoned on idle")
}
stopServiceGracefully() stopServiceGracefully()
} }
} }
@@ -577,14 +420,11 @@ class PlayerService : MediaSessionService(), Player.Listener {
if (metadataString.isNotEmpty()) { if (metadataString.isNotEmpty()) {
Timber.tag("PlayerService").i("Metadata received: $metadataString") Timber.tag("PlayerService").i("Metadata received: $metadataString")
// Сохраняем метаданные в историю
val metadataHistory = PreferencesHelper.loadMetadataHistory() val metadataHistory = PreferencesHelper.loadMetadataHistory()
// Добавляем только если это новые метаданные
if (metadataHistory.isEmpty() || metadataHistory.last() != metadataString) { if (metadataHistory.isEmpty() || metadataHistory.last() != metadataString) {
metadataHistory.add(metadataString) metadataHistory.add(metadataString)
// Ограничиваем размер истории
while (metadataHistory.size > Keys.DEFAULT_SIZE_OF_METADATA_HISTORY) { while (metadataHistory.size > Keys.DEFAULT_SIZE_OF_METADATA_HISTORY) {
metadataHistory.removeAt(0) metadataHistory.removeAt(0)
} }
@@ -607,35 +447,6 @@ class PlayerService : MediaSessionService(), Player.Listener {
exoPlayer?.playWhenReady = true 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) { fun playRadio(url: String, title: String, artist: String, artUri: Uri?, stationUuid: String? = null) {
try { try {
Timber.tag("PlayerService").i("=== playRadio called ===") Timber.tag("PlayerService").i("=== playRadio called ===")
@@ -647,7 +458,6 @@ class PlayerService : MediaSessionService(), Player.Listener {
} }
exoPlayer?.apply { exoPlayer?.apply {
// Применяем индивидуальную громкость ДО начала воспроизведения
val targetVolume = if (!stationUuid.isNullOrBlank()) { val targetVolume = if (!stationUuid.isNullOrBlank()) {
val stationVolume = VolumeSettingsHelper.getVolume(this@PlayerService, stationUuid) val stationVolume = VolumeSettingsHelper.getVolume(this@PlayerService, stationUuid)
Timber.tag("PlayerService").i("Station has custom volume: $stationVolume (${(stationVolume * 100).toInt()}%)") Timber.tag("PlayerService").i("Station has custom volume: $stationVolume (${(stationVolume * 100).toInt()}%)")
@@ -657,15 +467,9 @@ class PlayerService : MediaSessionService(), Player.Listener {
1.0f 1.0f
} }
// Устанавливаем громкость перед подготовкой
volume = targetVolume volume = targetVolume
Timber.tag("PlayerService").i("Volume set to: $targetVolume before playback") Timber.tag("PlayerService").i("Volume set to: $targetVolume before playback")
// Сбрасываем флаги Audio Focus для нового воспроизведения
resumeOnFocusGain = false
playbackDelayed = false
// Останавливаем текущее воспроизведение
stop() stop()
clearMediaItems() clearMediaItems()
@@ -682,11 +486,10 @@ class PlayerService : MediaSessionService(), Player.Listener {
setMediaItem(mediaItem) setMediaItem(mediaItem)
// Подтверждаем, что громкость установлена // prepare()+play() — ExoPlayer сам запросит аудиофокус перед стартом
Timber.tag("PlayerService").i("Current player volume before prepare: $volume") // благодаря handleAudioFocus=true из setupPlayer()
prepare()
// Запрашиваем Audio Focus ПЕРЕД prepare и play play()
requestAudioFocusAndPlay()
} ?: run { } ?: run {
Timber.tag("PlayerService").e("ExoPlayer is null, cannot play radio") 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() { private fun stopServiceGracefully() {
Timber.tag("PlayerService").i("Stopping service gracefully") 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() exoPlayer?.stop()
stopForeground(true)
stopSelf() stopSelf()
} }
@@ -773,7 +526,7 @@ class PlayerService : MediaSessionService(), Player.Listener {
val packageName = packageName val packageName = packageName
return appProcesses.any { process -> return appProcesses.any { process ->
process.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND && process.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND &&
process.processName == packageName process.processName == packageName
} }
} }
@@ -832,52 +585,9 @@ class PlayerService : MediaSessionService(), Player.Listener {
}, delayMillis) }, 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 { companion object {
const val NOTIFICATION_ID = 2031 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 MIN_BUFFER_MS = 5000
private const val MAX_BUFFER_MS = 10000 private const val MAX_BUFFER_MS = 10000
private const val PLAYBACK_BUFFER_MS = 2000 private const val PLAYBACK_BUFFER_MS = 2000
@@ -12,6 +12,7 @@ import com.yaros.RadioUrl.helpers.PreferencesHelper
import com.yaros.RadioUrl.helpers.PreferencesHelper.initPreferences import com.yaros.RadioUrl.helpers.PreferencesHelper.initPreferences
import dagger.hilt.android.HiltAndroidApp import dagger.hilt.android.HiltAndroidApp
import android.app.Application import android.app.Application
import com.yaros.RadioUrl.helpers.CacheManager
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.ExecutionException import java.util.concurrent.ExecutionException
import java.util.concurrent.Executors import java.util.concurrent.Executors
@@ -33,6 +34,7 @@ class URLRadio : Application() {
// Инициализация Firebase с runtime-детекцией // Инициализация Firebase с runtime-детекцией
initFirebaseWithFallback() initFirebaseWithFallback()
initPreferences() initPreferences()
CacheManager.init(this)
AppThemeHelper.setTheme(PreferencesHelper.loadThemeSelection()) AppThemeHelper.setTheme(PreferencesHelper.loadThemeSelection())
adManager = AdManager(this) adManager = AdManager(this)
ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleObserver) ProcessLifecycleOwner.get().lifecycle.addObserver(lifecycleObserver)
@@ -1,11 +1,11 @@
package com.yaros.RadioUrl.core.supabase package com.yaros.RadioUrl.core.supabase
import android.content.Context import android.content.Context
import android.util.Log
import com.yaros.RadioUrl.core.APIInterface.data.Category import com.yaros.RadioUrl.core.APIInterface.data.Category
import com.yaros.RadioUrl.core.APIInterface.data.NewsItem import com.yaros.RadioUrl.core.APIInterface.data.NewsItem
import com.yaros.RadioUrl.data.Station as DataStation import com.yaros.RadioUrl.data.Station as DataStation
import com.yaros.RadioUrl.helpers.CacheManager import com.yaros.RadioUrl.helpers.CacheManager
import timber.log.Timber
/** /**
* Адаптер для совместимости с существующим ApiRepository * Адаптер для совместимости с существующим ApiRepository
@@ -23,9 +23,13 @@ class SupabaseApiRepository(private val context: Context? = null) {
// Добавляем метод getHomeData для совместимости // Добавляем метод getHomeData для совместимости
suspend fun getHomeData(): NetworkResult<HomeResponse> { suspend fun getHomeData(): NetworkResult<HomeResponse> {
return try { return try {
// Получаем последние радиостанции как "избранные" val cachedStations = CacheManager.getCachedStations()
val stationsResult = supabaseRepository.getAllStations() 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) { if (stationsResult.isSuccess) {
val stations = stationsResult.getOrNull()?.map { apiStation -> val stations = stationsResult.getOrNull()?.map { apiStation ->
DataStation( DataStation(
@@ -42,13 +46,13 @@ class SupabaseApiRepository(private val context: Context? = null) {
) )
} ?: emptyList() } ?: emptyList()
// Возвращаем HomeResponse с пустыми новостями и станциями CacheManager.cacheStations(stations)
NetworkResult.Success(HomeResponse(news = emptyList(), favorites = stations)) NetworkResult.Success(HomeResponse(news = emptyList(), favorites = stations))
} else { } else {
NetworkResult.Error(stationsResult.exceptionOrNull()?.message ?: "Unknown error") NetworkResult.Error(stationsResult.exceptionOrNull()?.message ?: "Unknown error")
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("SupabaseApiRepository", "Error getting home data", e) Timber.e(e, "Error getting home data")
NetworkResult.Error(e.message ?: "Unknown error") NetworkResult.Error(e.message ?: "Unknown error")
} }
} }
@@ -58,7 +62,7 @@ class SupabaseApiRepository(private val context: Context? = null) {
// Проверяем кеш // Проверяем кеш
val cachedCategories = CacheManager.getCachedCategories() val cachedCategories = CacheManager.getCachedCategories()
if (cachedCategories != null) { if (cachedCategories != null) {
Log.d("SupabaseApiRepository", "Returning cached categories") Timber.tag("SupabaseApiRepository").d("Returning cached categories")
return NetworkResult.Success(cachedCategories) return NetworkResult.Success(cachedCategories)
} }
@@ -73,7 +77,7 @@ class SupabaseApiRepository(private val context: Context? = null) {
NetworkResult.Error(result.exceptionOrNull()?.message ?: "Unknown error") NetworkResult.Error(result.exceptionOrNull()?.message ?: "Unknown error")
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("SupabaseApiRepository", "Error getting categories", e) Timber.e(e, "Error getting categories")
NetworkResult.Error(e.message ?: "Unknown error") NetworkResult.Error(e.message ?: "Unknown error")
} }
} }
@@ -83,7 +87,7 @@ class SupabaseApiRepository(private val context: Context? = null) {
// Проверяем кеш // Проверяем кеш
val cachedStations = CacheManager.getCachedStations() val cachedStations = CacheManager.getCachedStations()
if (cachedStations != null) { if (cachedStations != null) {
Log.d("SupabaseApiRepository", "Returning cached stations") Timber.tag("SupabaseApiRepository").d("Returning cached stations")
return NetworkResult.Success(cachedStations) return NetworkResult.Success(cachedStations)
} }
@@ -112,7 +116,7 @@ class SupabaseApiRepository(private val context: Context? = null) {
NetworkResult.Error(result.exceptionOrNull()?.message ?: "Unknown error") NetworkResult.Error(result.exceptionOrNull()?.message ?: "Unknown error")
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("SupabaseApiRepository", "Error getting recent radio", e) Timber.e(e, "Error getting recent radio")
NetworkResult.Error(e.message ?: "Unknown error") NetworkResult.Error(e.message ?: "Unknown error")
} }
} }
@@ -122,7 +126,7 @@ class SupabaseApiRepository(private val context: Context? = null) {
// Проверяем кеш для этой категории // Проверяем кеш для этой категории
val cachedStations = CacheManager.getCachedCategoryStations(categoryId) val cachedStations = CacheManager.getCachedCategoryStations(categoryId)
if (cachedStations != null) { 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) return NetworkResult.Success(cachedStations)
} }
@@ -150,7 +154,7 @@ class SupabaseApiRepository(private val context: Context? = null) {
NetworkResult.Error(stationsResult.exceptionOrNull()?.message ?: "Unknown error") NetworkResult.Error(stationsResult.exceptionOrNull()?.message ?: "Unknown error")
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("SupabaseApiRepository", "Error getting category detail", e) Timber.e(e, "Error getting category detail")
NetworkResult.Error(e.message ?: "Unknown error") NetworkResult.Error(e.message ?: "Unknown error")
} }
} }
@@ -178,7 +182,7 @@ class SupabaseApiRepository(private val context: Context? = null) {
NetworkResult.Error(result.exceptionOrNull()?.message ?: "Unknown error") NetworkResult.Error(result.exceptionOrNull()?.message ?: "Unknown error")
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("SupabaseApiRepository", "Error searching", e) Timber.e(e, "Error searching")
NetworkResult.Error(e.message ?: "Unknown error") NetworkResult.Error(e.message ?: "Unknown error")
} }
} }
@@ -211,7 +215,7 @@ class SupabaseApiRepository(private val context: Context? = null) {
NetworkResult.Error(updateResult.exceptionOrNull()?.message ?: "Unknown error") NetworkResult.Error(updateResult.exceptionOrNull()?.message ?: "Unknown error")
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e("SupabaseApiRepository", "Error updating station views", e) Timber.e(e, "Error updating station views")
NetworkResult.Error(e.message ?: "Unknown error") NetworkResult.Error(e.message ?: "Unknown error")
} }
} }
@@ -2,21 +2,43 @@ package com.yaros.RadioUrl.helpers
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import androidx.preference.PreferenceManager
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.JsonParseException
import com.google.gson.reflect.TypeToken import com.google.gson.reflect.TypeToken
import com.yaros.RadioUrl.core.APIInterface.data.Category import com.yaros.RadioUrl.core.APIInterface.data.Category
import com.yaros.RadioUrl.data.Station import com.yaros.RadioUrl.data.Station
import timber.log.Timber 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 { object CacheManager {
private const val TAG = "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 // Ключи для SharedPreferences
private const val PREF_CACHE_ENABLED = "cache_enabled" private const val PREF_CACHE_ENABLED = "cache_enabled"
private const val PREF_CACHE_MAX_SIZE = "cache_max_size_mb" 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_" private const val PREF_CATEGORY_STATIONS_TIME_PREFIX = "category_stations_time_"
// Время жизни кеша (по умолчанию 1 час) // Время жизни кеша (по умолчанию 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 const val CACHE_SIZE_SMALL = 10
@@ -36,264 +60,288 @@ object CacheManager {
const val CACHE_SIZE_LARGE = 50 const val CACHE_SIZE_LARGE = 50
const val CACHE_SIZE_XLARGE = 100 const val CACHE_SIZE_XLARGE = 100
private lateinit var sharedPreferences: SharedPreferences @Volatile
private var sharedPreferences: SharedPreferences? = null
private val gson = Gson() 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) { 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 { fun isCachingEnabled(): Boolean {
return sharedPreferences.getBoolean(PREF_CACHE_ENABLED, true) return prefs()?.getBoolean(PREF_CACHE_ENABLED, true) ?: true
} }
/**
* Включить кеширование
*/
fun enableCaching() { fun enableCaching() {
sharedPreferences.edit().putBoolean(PREF_CACHE_ENABLED, true).apply() prefs()?.edit()?.putBoolean(PREF_CACHE_ENABLED, true)?.apply()
} }
/**
* Отключить кеширование
*/
fun disableCaching() { fun disableCaching() {
sharedPreferences.edit().putBoolean(PREF_CACHE_ENABLED, false).apply() prefs()?.edit()?.putBoolean(PREF_CACHE_ENABLED, false)?.apply()
clearCache()
} }
/**
* Получить максимальный размер кеша в МБ
*/
fun getMaxCacheSize(): Int { 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) { 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>) { fun cacheStations(stations: List<Station>) {
if (!isCachingEnabled()) return writeEntry(PREF_STATIONS_CACHE, PREF_STATIONS_CACHE_TIME, stations, "stations")
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")
}
} }
/**
* Получить список всех радиостанций из кеша
*/
fun getCachedStations(): List<Station>? { fun getCachedStations(): List<Station>? {
if (!isCachingEnabled()) return null return readEntry(PREF_STATIONS_CACHE, PREF_STATIONS_CACHE_TIME, allowStale = false)
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
}
} }
/** /** То же самое, но если кеш протух — вернёт его всё равно (для fallback при ошибке сети). */
* Сохранить список категорий в кеш fun getCachedStationsOrStale(): List<Station>? {
*/ return readEntry(PREF_STATIONS_CACHE, PREF_STATIONS_CACHE_TIME, allowStale = true)
}
// ---------------------------------------------------------------------
// Categories
// ---------------------------------------------------------------------
fun cacheCategories(categories: List<Category>) { fun cacheCategories(categories: List<Category>) {
if (!isCachingEnabled()) return writeEntry(PREF_CATEGORIES_CACHE, PREF_CATEGORIES_CACHE_TIME, categories, "categories")
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")
}
} }
/**
* Получить список категорий из кеша
*/
fun getCachedCategories(): List<Category>? { fun getCachedCategories(): List<Category>? {
if (!isCachingEnabled()) return null return readEntry(PREF_CATEGORIES_CACHE, PREF_CATEGORIES_CACHE_TIME, allowStale = false)
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
}
} }
/** 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>) { 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 if (!isCachingEnabled()) return
val p = prefs() ?: return
try { try {
val json = gson.toJson(stations) val json = gson.toJson(data)
val currentTime = System.currentTimeMillis() val timestamp = System.currentTimeMillis()
p.edit()
sharedPreferences.edit() .putString(dataKey, json)
.putString("$PREF_CATEGORY_STATIONS_PREFIX$categoryId", json) .putLong(timeKey, timestamp)
.putLong("$PREF_CATEGORY_STATIONS_TIME_PREFIX$categoryId", currentTime)
.apply() .apply()
memoryCache[dataKey] = MemoryEntry(json, timestamp)
Timber.tag(TAG).d("Cached ${stations.size} stations for category $categoryId") Timber.tag(TAG).d("Cached ${data.size} $logLabel")
enforceMaxCacheSize()
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag(TAG).e(e, "Error caching category stations") Timber.tag(TAG).e(e, "Error caching $logLabel")
} }
} }
/** private inline fun <reified T> readEntry(
* Получить список радиостанций категории из кеша dataKey: String,
*/ timeKey: String,
fun getCachedCategoryStations(categoryId: Int): List<Station>? { allowStale: Boolean
): List<T>? {
if (!isCachingEnabled()) return null if (!isCachingEnabled()) return null
val p = prefs() ?: return null
try { val now = System.currentTimeMillis()
val cacheTime = sharedPreferences.getLong("$PREF_CATEGORY_STATIONS_TIME_PREFIX$categoryId", 0)
val currentTime = System.currentTimeMillis()
// Проверяем, не истек ли срок действия кеша // Сначала пробуем in-memory слой — не трогаем диск лишний раз
if (currentTime - cacheTime > CACHE_EXPIRATION_TIME) { memoryCache[dataKey]?.let { mem ->
Timber.tag(TAG).d("Category $categoryId stations cache expired") 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 return null
} }
val json = sharedPreferences.getString("$PREF_CATEGORY_STATIONS_PREFIX$categoryId", null) ?: return null val json = p.getString(dataKey, null) ?: return null
val type = object : TypeToken<List<Station>>() {}.type memoryCache[dataKey] = MemoryEntry(json, cacheTime)
val stations: List<Station> = gson.fromJson(json, type) parseOrEvict(dataKey, json)
Timber.tag(TAG).d("Retrieved ${stations.size} stations for category $categoryId from cache")
return stations
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag(TAG).e(e, "Error retrieving cached category stations") Timber.tag(TAG).e(e, "Error retrieving cache for $dataKey")
return null 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
fun clearCache() { gson.fromJson<List<T>>(json, type)
try { } catch (e: JsonParseException) {
val editor = sharedPreferences.edit() // Повреждённый или несовместимый 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)
editor.remove(PREF_STATIONS_CACHE_TIME) editor.remove(PREF_STATIONS_CACHE_TIME)
// Удаляем кеш категорий
editor.remove(PREF_CATEGORIES_CACHE) editor.remove(PREF_CATEGORIES_CACHE)
editor.remove(PREF_CATEGORIES_CACHE_TIME) editor.remove(PREF_CATEGORIES_CACHE_TIME)
// Удаляем кеш станций по категориям p.all.keys.forEach { key ->
val allKeys = sharedPreferences.all.keys
allKeys.forEach { key ->
if (key.startsWith(PREF_CATEGORY_STATIONS_PREFIX) || 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.remove(key)
} }
} }
editor.apply() editor.apply()
memoryCache.clear()
Timber.tag(TAG).d("Cache cleared") Timber.tag(TAG).d("Cache cleared")
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag(TAG).e(e, "Error clearing cache") Timber.tag(TAG).e(e, "Error clearing cache")
} }
} }
/** /** Приблизительный размер кеша в байтах */
* Получить размер кеша в байтах (приблизительно)
*/
fun getCacheSize(): Long { fun getCacheSize(): Long {
try { val p = prefs() ?: return 0L
return try {
var totalSize = 0L 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 }
sharedPreferences.getString(PREF_STATIONS_CACHE, null)?.let { p.all.keys.forEach { key ->
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 ->
if (key.startsWith(PREF_CATEGORY_STATIONS_PREFIX)) { if (key.startsWith(PREF_CATEGORY_STATIONS_PREFIX)) {
sharedPreferences.getString(key, null)?.let { p.getString(key, null)?.let { totalSize += it.length * 2L }
totalSize += it.length * 2
}
} }
} }
totalSize
return totalSize
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag(TAG).e(e, "Error calculating cache size") Timber.tag(TAG).e(e, "Error calculating cache size")
return 0L 0L
} }
} }
fun getCacheSizeMB(): Double = getCacheSize() / (1024.0 * 1024.0)
/** /**
* Получить размер кеша в МБ * Если кеш превысил заданный пользователем лимит (например, на устройствах
* с малым объёмом памяти/хранилища) — сбрасываем самые "тяжёлые" данные
* по категориям (они менее критичны, чем общий список станций).
*/ */
fun getCacheSizeMB(): Double { private fun enforceMaxCacheSize() {
return getCacheSize() / (1024.0 * 1024.0) 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()
} }
} }