1.9.9.8 уведомление и кеширование
This commit is contained in:
Generated
+14
@@ -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>
|
||||||
|
|||||||
Generated
+641
-189
File diff suppressed because it is too large
Load Diff
Generated
+7
@@ -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 {
|
||||||
|
|||||||
@@ -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() в нужные моменты жизненного цикла.
|
||||||
)
|
*/
|
||||||
.setMediaDescriptionAdapter(NotificationMediaAdapter())
|
private fun setupMediaNotificationProvider() {
|
||||||
.setChannelNameResourceId(R.string.notification_info)
|
setMediaNotificationProvider(
|
||||||
.setChannelDescriptionResourceId(R.string.default_notification_channel_name)
|
DefaultMediaNotificationProvider.Builder(this)
|
||||||
.setNotificationListener(object : PlayerNotificationManager.NotificationListener {
|
.setChannelId(NOTIFICATION_CHANNEL_ID)
|
||||||
override fun onNotificationPosted(
|
.setChannelName(R.string.notification_info)
|
||||||
notificationId: Int,
|
.setNotificationId(NOTIFICATION_ID)
|
||||||
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()
|
.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()
|
||||||
@@ -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}")
|
||||||
}
|
}
|
||||||
@@ -435,24 +323,14 @@ class PlayerService : MediaSessionService(), Player.Listener {
|
|||||||
when (customCommand.customAction) {
|
when (customCommand.customAction) {
|
||||||
"play" -> {
|
"play" -> {
|
||||||
if (isAppInForeground()) {
|
if (isAppInForeground()) {
|
||||||
// Запрашиваем Audio Focus перед воспроизведением
|
// Аудиофокус запросит сам ExoPlayer при вызове play()
|
||||||
val result = audioManager.requestAudioFocus(
|
|
||||||
audioFocusRequest,
|
|
||||||
AudioManager.STREAM_MUSIC,
|
|
||||||
AudioManager.AUDIOFOCUS_GAIN
|
|
||||||
)
|
|
||||||
if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
|
|
||||||
hasAudioFocus = true
|
|
||||||
exoPlayer?.play()
|
exoPlayer?.play()
|
||||||
Timber.tag("PlayerService").i("Play command: Audio focus granted, playing")
|
Timber.tag("PlayerService").i("Play command received")
|
||||||
} 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()
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
/** То же самое, но если кеш протух — вернёт его всё равно (для fallback при ошибке сети). */
|
||||||
val type = object : TypeToken<List<Station>>() {}.type
|
fun getCachedStationsOrStale(): List<Station>? {
|
||||||
val stations: List<Station> = gson.fromJson(json, type)
|
return readEntry(PREF_STATIONS_CACHE, PREF_STATIONS_CACHE_TIME, allowStale = true)
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ---------------------------------------------------------------------
|
||||||
* Сохранить список категорий в кеш
|
// 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
|
fun getCachedCategoriesOrStale(): List<Category>? {
|
||||||
val type = object : TypeToken<List<Category>>() {}.type
|
return readEntry(PREF_CATEGORIES_CACHE, PREF_CATEGORIES_CACHE_TIME, allowStale = true)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// ---------------------------------------------------------------------
|
||||||
* Сохранить список радиостанций категории в кеш
|
// Stations by category
|
||||||
*/
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
fun cacheCategoryStations(categoryId: Int, stations: List<Station>) {
|
fun cacheCategoryStations(categoryId: Int, stations: List<Station>) {
|
||||||
if (!isCachingEnabled()) return
|
writeEntry(
|
||||||
|
"$PREF_CATEGORY_STATIONS_PREFIX$categoryId",
|
||||||
try {
|
"$PREF_CATEGORY_STATIONS_TIME_PREFIX$categoryId",
|
||||||
val json = gson.toJson(stations)
|
stations,
|
||||||
val currentTime = System.currentTimeMillis()
|
"category $categoryId stations"
|
||||||
|
)
|
||||||
sharedPreferences.edit()
|
|
||||||
.putString("$PREF_CATEGORY_STATIONS_PREFIX$categoryId", json)
|
|
||||||
.putLong("$PREF_CATEGORY_STATIONS_TIME_PREFIX$categoryId", currentTime)
|
|
||||||
.apply()
|
|
||||||
|
|
||||||
Timber.tag(TAG).d("Cached ${stations.size} stations for category $categoryId")
|
|
||||||
} catch (e: Exception) {
|
|
||||||
Timber.tag(TAG).e(e, "Error caching category stations")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Получить список радиостанций категории из кеша
|
|
||||||
*/
|
|
||||||
fun getCachedCategoryStations(categoryId: Int): List<Station>? {
|
fun getCachedCategoryStations(categoryId: Int): List<Station>? {
|
||||||
if (!isCachingEnabled()) return null
|
return readEntry(
|
||||||
|
"$PREF_CATEGORY_STATIONS_PREFIX$categoryId",
|
||||||
try {
|
"$PREF_CATEGORY_STATIONS_TIME_PREFIX$categoryId",
|
||||||
val cacheTime = sharedPreferences.getLong("$PREF_CATEGORY_STATIONS_TIME_PREFIX$categoryId", 0)
|
allowStale = false
|
||||||
val currentTime = System.currentTimeMillis()
|
)
|
||||||
|
|
||||||
// Проверяем, не истек ли срок действия кеша
|
|
||||||
if (currentTime - cacheTime > CACHE_EXPIRATION_TIME) {
|
|
||||||
Timber.tag(TAG).d("Category $categoryId stations cache expired")
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val json = sharedPreferences.getString("$PREF_CATEGORY_STATIONS_PREFIX$categoryId", null) ?: return null
|
fun getCachedCategoryStationsOrStale(categoryId: Int): List<Station>? {
|
||||||
val type = object : TypeToken<List<Station>>() {}.type
|
return readEntry(
|
||||||
val stations: List<Station> = gson.fromJson(json, type)
|
"$PREF_CATEGORY_STATIONS_PREFIX$categoryId",
|
||||||
|
"$PREF_CATEGORY_STATIONS_TIME_PREFIX$categoryId",
|
||||||
|
allowStale = true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
Timber.tag(TAG).d("Retrieved ${stations.size} stations for category $categoryId from cache")
|
// ---------------------------------------------------------------------
|
||||||
return stations
|
// Общая реализация чтения/записи
|
||||||
|
// ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
private inline fun <reified T> writeEntry(
|
||||||
|
dataKey: String,
|
||||||
|
timeKey: String,
|
||||||
|
data: List<T>,
|
||||||
|
logLabel: String
|
||||||
|
) {
|
||||||
|
if (!isCachingEnabled()) return
|
||||||
|
val p = prefs() ?: return
|
||||||
|
try {
|
||||||
|
val json = gson.toJson(data)
|
||||||
|
val timestamp = System.currentTimeMillis()
|
||||||
|
p.edit()
|
||||||
|
.putString(dataKey, json)
|
||||||
|
.putLong(timeKey, timestamp)
|
||||||
|
.apply()
|
||||||
|
memoryCache[dataKey] = MemoryEntry(json, timestamp)
|
||||||
|
Timber.tag(TAG).d("Cached ${data.size} $logLabel")
|
||||||
|
enforceMaxCacheSize()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.tag(TAG).e(e, "Error retrieving cached category stations")
|
Timber.tag(TAG).e(e, "Error caching $logLabel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inline fun <reified T> readEntry(
|
||||||
|
dataKey: String,
|
||||||
|
timeKey: String,
|
||||||
|
allowStale: Boolean
|
||||||
|
): List<T>? {
|
||||||
|
if (!isCachingEnabled()) return null
|
||||||
|
val p = prefs() ?: return null
|
||||||
|
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
|
||||||
|
// Сначала пробуем in-memory слой — не трогаем диск лишний раз
|
||||||
|
memoryCache[dataKey]?.let { mem ->
|
||||||
|
val age = now - mem.timestamp
|
||||||
|
if (age <= CACHE_EXPIRATION_TIME || (allowStale && age <= STALE_FALLBACK_WINDOW)) {
|
||||||
|
return parseOrEvict(dataKey, mem.json)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val cacheTime = p.getLong(timeKey, 0)
|
||||||
|
val age = now - cacheTime
|
||||||
|
val withinFreshWindow = age <= CACHE_EXPIRATION_TIME
|
||||||
|
val withinStaleWindow = allowStale && age <= STALE_FALLBACK_WINDOW
|
||||||
|
|
||||||
|
if (!withinFreshWindow && !withinStaleWindow) {
|
||||||
|
Timber.tag(TAG).d("Cache for $dataKey expired (age=${age}ms)")
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val json = p.getString(dataKey, null) ?: return null
|
||||||
|
memoryCache[dataKey] = MemoryEntry(json, cacheTime)
|
||||||
|
parseOrEvict(dataKey, json)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag(TAG).e(e, "Error retrieving cache for $dataKey")
|
||||||
|
null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private inline fun <reified T> parseOrEvict(dataKey: String, json: String): List<T>? {
|
||||||
* Очистить весь кеш
|
return try {
|
||||||
*/
|
val type = TypeToken.getParameterized(List::class.java, T::class.java).type
|
||||||
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user