diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml
index d90a374..7865ed1 100644
--- a/.idea/appInsightsSettings.xml
+++ b/.idea/appInsightsSettings.xml
@@ -18,6 +18,20 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/.idea/caches/deviceStreaming.xml b/.idea/caches/deviceStreaming.xml
index df8a09b..ddfd7af 100644
--- a/.idea/caches/deviceStreaming.xml
+++ b/.idea/caches/deviceStreaming.xml
@@ -10,7 +10,7 @@
-
+
@@ -34,7 +34,7 @@
-
+
@@ -46,7 +46,7 @@
-
+
@@ -58,11 +58,71 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -76,17 +136,28 @@
-
-
-
-
-
+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -107,7 +178,7 @@
-
+
@@ -127,15 +198,27 @@
-
-
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -143,7 +226,7 @@
-
+
@@ -155,7 +238,7 @@
-
+
@@ -167,7 +250,7 @@
-
+
@@ -179,7 +262,7 @@
-
+
@@ -191,11 +274,47 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -203,11 +322,23 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -215,7 +346,7 @@
-
+
@@ -227,7 +358,7 @@
-
+
@@ -239,7 +370,7 @@
-
+
@@ -251,7 +382,7 @@
-
+
@@ -263,7 +394,7 @@
-
+
@@ -275,7 +406,31 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -287,7 +442,7 @@
-
+
@@ -328,6 +483,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
@@ -364,6 +531,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
@@ -371,15 +550,28 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -387,6 +579,11 @@
+
+
+
+
+
@@ -407,11 +604,35 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -440,7 +661,6 @@
-
@@ -448,17 +668,11 @@
-
-
-
-
-
-
@@ -466,11 +680,30 @@
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -479,7 +712,7 @@
-
+
@@ -491,7 +724,19 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -503,7 +748,19 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -536,7 +793,6 @@
-
@@ -544,17 +800,11 @@
-
-
-
-
-
-
@@ -562,11 +812,18 @@
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
@@ -575,7 +832,7 @@
-
+
@@ -616,6 +873,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
@@ -664,6 +933,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
@@ -688,6 +969,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
@@ -704,6 +997,7 @@
+
@@ -711,6 +1005,11 @@
+
+
+
+
+
@@ -719,7 +1018,7 @@
-
+
@@ -731,11 +1030,23 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -743,11 +1054,24 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -755,7 +1079,7 @@
-
+
@@ -767,11 +1091,23 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -817,11 +1153,52 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -942,6 +1319,30 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -970,6 +1371,7 @@
+
@@ -977,6 +1379,11 @@
+
+
+
+
+
@@ -985,7 +1392,7 @@
-
+
@@ -997,7 +1404,7 @@
-
+
@@ -1089,7 +1496,19 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1101,19 +1520,7 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
+
@@ -1121,6 +1528,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1154,6 +1573,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1166,6 +1597,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1178,6 +1621,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1199,7 +1702,7 @@
-
+
@@ -1211,7 +1714,7 @@
-
+
@@ -1228,6 +1731,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1256,6 +1771,7 @@
+
@@ -1263,6 +1779,11 @@
+
+
+
+
+
@@ -1281,23 +1802,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -1332,42 +1836,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
@@ -1387,42 +1855,19 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
-
-
+
+
-
+
@@ -1440,7 +1885,7 @@
-
+
@@ -1452,7 +1897,7 @@
-
+
@@ -1470,6 +1915,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1486,7 +1944,6 @@
-
@@ -1494,17 +1951,11 @@
-
-
-
-
-
-
@@ -1512,17 +1963,11 @@
-
-
-
-
-
-
@@ -1530,11 +1975,6 @@
-
-
-
-
-
@@ -1645,7 +2085,7 @@
-
+
@@ -1727,11 +2167,23 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml
new file mode 100644
index 0000000..a5c67c2
--- /dev/null
+++ b/.idea/dictionaries/project.xml
@@ -0,0 +1,7 @@
+
+
+
+ Supabase
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/yaros/RadioUrl/MainActivity.kt b/app/src/main/java/com/yaros/RadioUrl/MainActivity.kt
index b665408..2772242 100644
--- a/app/src/main/java/com/yaros/RadioUrl/MainActivity.kt
+++ b/app/src/main/java/com/yaros/RadioUrl/MainActivity.kt
@@ -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())
}
}
+ }
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/yaros/RadioUrl/PlayerService.kt b/app/src/main/java/com/yaros/RadioUrl/PlayerService.kt
index 25c77e2..2663d56 100644
--- a/app/src/main/java/com/yaros/RadioUrl/PlayerService.kt
+++ b/app/src/main/java/com/yaros/RadioUrl/PlayerService.kt
@@ -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 {
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
}
-}
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/yaros/RadioUrl/URLRadio.kt b/app/src/main/java/com/yaros/RadioUrl/URLRadio.kt
index 088565e..3d4cdb8 100644
--- a/app/src/main/java/com/yaros/RadioUrl/URLRadio.kt
+++ b/app/src/main/java/com/yaros/RadioUrl/URLRadio.kt
@@ -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)
diff --git a/app/src/main/java/com/yaros/RadioUrl/core/supabase/SupabaseApiRepository.kt b/app/src/main/java/com/yaros/RadioUrl/core/supabase/SupabaseApiRepository.kt
index 2591eac..7649fbf 100644
--- a/app/src/main/java/com/yaros/RadioUrl/core/supabase/SupabaseApiRepository.kt
+++ b/app/src/main/java/com/yaros/RadioUrl/core/supabase/SupabaseApiRepository.kt
@@ -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 {
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")
}
}
diff --git a/app/src/main/java/com/yaros/RadioUrl/helpers/CacheManager.kt b/app/src/main/java/com/yaros/RadioUrl/helpers/CacheManager.kt
index c9ba373..76c4725 100644
--- a/app/src/main/java/com/yaros/RadioUrl/helpers/CacheManager.kt
+++ b/app/src/main/java/com/yaros/RadioUrl/helpers/CacheManager.kt
@@ -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()
+
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) {
- 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? {
- 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>() {}.type
- val stations: List = 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? {
+ return readEntry(PREF_STATIONS_CACHE, PREF_STATIONS_CACHE_TIME, allowStale = true)
+ }
+
+ // ---------------------------------------------------------------------
+ // Categories
+ // ---------------------------------------------------------------------
+
fun cacheCategories(categories: List) {
- 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? {
- 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>() {}.type
- val categories: List = 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? {
+ return readEntry(PREF_CATEGORIES_CACHE, PREF_CATEGORIES_CACHE_TIME, allowStale = true)
+ }
+
+ // ---------------------------------------------------------------------
+ // Stations by category
+ // ---------------------------------------------------------------------
+
fun cacheCategoryStations(categoryId: Int, stations: List) {
+ writeEntry(
+ "$PREF_CATEGORY_STATIONS_PREFIX$categoryId",
+ "$PREF_CATEGORY_STATIONS_TIME_PREFIX$categoryId",
+ stations,
+ "category $categoryId stations"
+ )
+ }
+
+ fun getCachedCategoryStations(categoryId: Int): List? {
+ return readEntry(
+ "$PREF_CATEGORY_STATIONS_PREFIX$categoryId",
+ "$PREF_CATEGORY_STATIONS_TIME_PREFIX$categoryId",
+ allowStale = false
+ )
+ }
+
+ fun getCachedCategoryStationsOrStale(categoryId: Int): List? {
+ return readEntry(
+ "$PREF_CATEGORY_STATIONS_PREFIX$categoryId",
+ "$PREF_CATEGORY_STATIONS_TIME_PREFIX$categoryId",
+ allowStale = true
+ )
+ }
+
+ // ---------------------------------------------------------------------
+ // Общая реализация чтения/записи
+ // ---------------------------------------------------------------------
+
+ private inline fun writeEntry(
+ dataKey: String,
+ timeKey: String,
+ data: List,
+ logLabel: String
+ ) {
if (!isCachingEnabled()) return
-
+ val p = prefs() ?: return
try {
- val json = gson.toJson(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? {
+ private inline fun readEntry(
+ dataKey: String,
+ timeKey: String,
+ allowStale: Boolean
+ ): List? {
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>() {}.type
- val stations: List = 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 parseOrEvict(dataKey: String, json: String): List? {
+ return try {
+ val type = TypeToken.getParameterized(List::class.java, T::class.java).type
+ gson.fromJson>(json, type)
+ } catch (e: JsonParseException) {
+ // Повреждённый или несовместимый JSON — не роняем вызывающий код,
+ // а тихо чистим битую запись, чтобы она не мешала при следующем чтении.
+ Timber.tag(TAG).e(e, "Corrupted cache entry for $dataKey, evicting")
+ evictEntry(dataKey)
+ null
+ }
+ }
- // Удаляем кеш станций
+ private fun evictEntry(dataKey: String) {
+ memoryCache.remove(dataKey)
+ prefs()?.edit()?.remove(dataKey)?.apply()
+ }
+
+ // ---------------------------------------------------------------------
+ // Обслуживание кеша
+ // ---------------------------------------------------------------------
+
+ /** Очистить весь кеш (кроме флагов enabled/max size/версии формата) */
+ fun clearCache() {
+ val p = prefs() ?: return
+ try {
+ val editor = p.edit()
editor.remove(PREF_STATIONS_CACHE)
editor.remove(PREF_STATIONS_CACHE_TIME)
-
- // Удаляем кеш категорий
editor.remove(PREF_CATEGORIES_CACHE)
editor.remove(PREF_CATEGORIES_CACHE_TIME)
- // Удаляем кеш станций по категориям
- 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()
}
-}
+}
\ No newline at end of file