Retain ringtone resources and retry cleanup before replacement

This commit is contained in:
evgeny committed 2026-10-28 14:43:27 +01:00
1 parent 6abedd8177
commit 94b9786d91
2 files changed
+78 -43

No files matched your search

@@ -83,7 +83,6 @@ object IncomingCallManager {
checkMain()
val previous = active
active = null
if (previous != null) ringtone.stop()
cleanupStopped(forceNotification = previous != null)
if (previous != null) {
LogManager.addLog("INFO", "InCall", "ringing stopped callId=${previous.callId} radio=${previous.radio} " +
@@ -93,6 +92,7 @@ object IncomingCallManager {
/** Только без active: не теряет ссылки при отказе и не повторяет уже подтверждённую очистку. */
private fun cleanupStopped(forceNotification: Boolean) {
ringtone.stop()
releaseWakeLock()
for ((token, owner) in pendingIntents.toList()) {
try {
@@ -30,15 +30,20 @@ import com.utun.chat.R
*
* API 28+ : звук через Ringtone (RingtoneManager), громкость — «громкость звонка».
* API 26–27: MediaPlayer(STREAM_RING, loop) только после получения audio focus.
* Потеря focus гасит звук до следующего play, сохраняя вибрацию; stop освобождает оба эффекта.
* Потеря focus запрашивает остановку звука до следующего play, сохраняя вибрацию.
* Stop сразу отключает callbacks/таймер; ссылки очищаются только после успешных stop/release/cancel/abandon.
* Повтор stop или начало play повторяет незавершённую очистку. Новый звук/вибрация не заменяет неочищенного владельца.
* Ошибка очистки звука/focus не блокирует вибрацию и наоборот. Таймера повторной очистки нет.
*/
class RingtonePlayer {
private var ringtone: Ringtone? = null
private var mediaPlayer: MediaPlayer? = null
private var vibrator: Vibrator? = null
private var focusRequest: AudioFocusRequest? = null
private var focusManager: AudioManager? = null
private var ringtone: Ringtone? = null // Текущий либо ожидающий подтверждения stop плеер API 28+.
private var mediaPlayer: MediaPlayer? = null // Текущий либо ожидающий подтверждения release плеер API 26–27.
private var vibrator: Vibrator? = null // Сохраняется при отказе cancel, даже после снятия таймера.
private var focusRequest: AudioFocusRequest? = null // Сохраняется до AUDIOFOCUS_REQUEST_GRANTED от abandon.
private var focusManager: AudioManager? = null // Исходный менеджер соответствующего focusRequest.
private var soundActive = false // При очистке callbacks выключены, хотя ссылки ещё могут оставаться.
private var vibrating = false // Отдельно от сохранённого сервиса: false запрещает новые импульсы.
private val attributes = AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION).build()
@@ -47,6 +52,7 @@ class RingtonePlayer {
private val vibrateHandler = Handler(Looper.getMainLooper())
private val vibrateRunnable = object : Runnable {
override fun run() {
if (!vibrating) return
val v = vibrator ?: return
try {
v.vibrate(VibrationEffect.createWaveform(longArrayOf(0, 1000), -1), attributes)
@@ -55,13 +61,14 @@ class RingtonePlayer {
stopVibrate()
}
} catch (e: Exception) {
LogManager.addLog("WARN", "Ringtone", "vibration pulse failed: ${e.message}")
LogManager.addLog("WARN", "Ringtone", "vibration pulse failed: $e")
stopVibrate()
}
}
}
/** На main заменить прежнее оповещение мелодией resourceId и вибрацией согласно режиму ОС. */
/** На main очистить прежнее оповещение, затем запустить resourceId/вибрацию согласно режиму ОС.
* Пока очистка звука/focus либо вибратора не подтверждена, соответствующий новый эффект пропускается с WARN. */
fun play(@RawRes resourceId: Int = R.raw.ringtone) {
stop()
val ctx = ChatApplication.instance
@@ -71,7 +78,7 @@ class RingtonePlayer {
am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
ringerMode = am.ringerMode
} catch (e: Exception) {
LogManager.addLog("WARN", "Ringtone", "ringer mode unavailable: ${e.message}")
LogManager.addLog("WARN", "Ringtone", "ringer mode unavailable: $e")
return
}
if (ringerMode !in AudioManager.RINGER_MODE_SILENT..AudioManager.RINGER_MODE_NORMAL) {
@@ -89,53 +96,62 @@ class RingtonePlayer {
}
private fun playSound(@RawRes resourceId: Int, am: AudioManager) {
if (ringtone != null || mediaPlayer != null || focusRequest != null) {
LogManager.addLog("WARN", "Ringtone", "sound skipped: cleanup pending resource=$resourceId " +
"ringtone=${ringtone != null} player=${mediaPlayer != null} focus=${focusRequest != null}")
return
}
val ctx = ChatApplication.instance
soundActive = true
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val uri = Uri.parse("android.resource://${ctx.packageName}/$resourceId")
val r = RingtoneManager.getRingtone(ctx, uri)
if (r == null) {
LogManager.addLog("ERROR", "Ringtone", "getRingtone returned null resource=$resourceId")
stopSound()
return
}
ringtone = r
r.audioAttributes = attributes
r.isLooping = true
r.play()
LogManager.addLog("INFO", "Ringtone", "sound started (Ringtone, STREAM_RING)")
LogManager.addLog("INFO", "Ringtone", "sound started (Ringtone, STREAM_RING) resource=$resourceId player=${System.identityHashCode(r)}")
} else {
if (!requestAudioFocus(am)) return
if (!requestAudioFocus(am)) { stopSound(); return }
val mp = MediaPlayer.create(ctx, resourceId, attributes, AudioManager.AUDIO_SESSION_ID_GENERATE)
if (mp == null) {
LogManager.addLog("ERROR", "Ringtone", "MediaPlayer.create returned null resource=$resourceId")
abandonAudioFocus()
stopSound()
return
}
mediaPlayer = mp
mp.setOnErrorListener { failed, what, extra ->
if (mediaPlayer === failed) {
if (soundActive && mediaPlayer === failed) {
LogManager.addLog("ERROR", "Ringtone", "playback error what=$what extra=$extra")
stopSound()
}
true
}
mp.setOnCompletionListener { completed ->
if (mediaPlayer === completed) {
if (soundActive && mediaPlayer === completed) {
LogManager.addLog("WARN", "Ringtone", "loop unexpectedly completed")
stopSound()
}
}
mp.isLooping = true
mp.start()
LogManager.addLog("INFO", "Ringtone", "sound started (MediaPlayer fallback, STREAM_RING)")
LogManager.addLog("INFO", "Ringtone", "sound started (MediaPlayer fallback, STREAM_RING) " +
"resource=$resourceId player=${System.identityHashCode(mp)}")
}
} catch (e: Exception) {
LogManager.addLog("ERROR", "Ringtone", "sound failed resource=$resourceId: ${e.message}")
LogManager.addLog("ERROR", "Ringtone", "sound failed resource=$resourceId: $e")
stopSound()
}
}
/** На main остановить звук, focus и таймер вибрации. Повторный вызов не обращается к старым ресурсам. */
/** На main отключить callbacks/таймер и независимо очистить звук, focus и вибратор.
* Ошибка сохраняет владельца для следующего stop/play; успешно очищенный ресурс повторно не вызывается. */
fun stop() {
check(Looper.myLooper() == Looper.getMainLooper()) {
LogManager.addLog("ERROR", "Ringtone", "operation requires main thread")
@@ -144,19 +160,27 @@ class RingtonePlayer {
val active = ringtone != null || mediaPlayer != null || vibrator != null || focusRequest != null
stopSound()
stopVibrate()
if (active) LogManager.addLog("INFO", "Ringtone", "stopped")
if (active) LogManager.addLog("INFO", "Ringtone", "stop requested sound_pending=${ringtone != null || mediaPlayer != null} " +
"focus_pending=${focusRequest != null} vibration_pending=${vibrator != null}")
}
private fun stopSound() {
soundActive = false
val r = ringtone
ringtone = null
try { r?.stop() } catch (e: Exception) {
LogManager.addLog("WARN", "Ringtone", "ringtone stop failed: ${e.message}")
if (r != null) try {
r.stop()
ringtone = null
LogManager.addLog("DEBUG", "Ringtone", "ringtone stopped player=${System.identityHashCode(r)}")
} catch (e: Exception) {
LogManager.addLog("WARN", "Ringtone", "ringtone stop failed player=${System.identityHashCode(r)}; retained for retry: $e")
}
val mp = mediaPlayer
mediaPlayer = null
try { mp?.release() } catch (e: Exception) {
LogManager.addLog("WARN", "Ringtone", "player release failed: ${e.message}")
if (mp != null) try {
mp.release()
mediaPlayer = null
LogManager.addLog("DEBUG", "Ringtone", "player released player=${System.identityHashCode(mp)}")
} catch (e: Exception) {
LogManager.addLog("WARN", "Ringtone", "player release failed player=${System.identityHashCode(mp)}; retained for retry: $e")
}
abandonAudioFocus()
}
@@ -164,6 +188,10 @@ class RingtonePlayer {
/* ── вибрация (решение принято в play(): вибрируем всегда, кроме SILENT) ── */
private fun startVibrate() {
if (vibrator != null) {
LogManager.addLog("WARN", "Ringtone", "vibration skipped: cleanup pending vibrator=${System.identityHashCode(vibrator)}")
return
}
try {
val v = ChatApplication.instance.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
if (!v.hasVibrator()) {
@@ -171,22 +199,26 @@ class RingtonePlayer {
return
}
vibrator = v
vibrating = true
vibrateRunnable.run()
if (vibrator === v) LogManager.addLog("INFO", "Ringtone", "vibration started")
if (vibrating) LogManager.addLog("INFO", "Ringtone", "vibration started vibrator=${System.identityHashCode(v)}")
} catch (e: Exception) {
LogManager.addLog("WARN", "Ringtone", "vibrator unavailable: ${e.message}")
LogManager.addLog("WARN", "Ringtone", "vibrator unavailable: $e")
stopVibrate()
}
}
private fun stopVibrate() {
val v = vibrator
vibrator = null
vibrating = false
vibrateHandler.removeCallbacks(vibrateRunnable)
try { v?.cancel() } catch (e: Exception) {
LogManager.addLog("WARN", "Ringtone", "vibration cancel failed: ${e.message}")
val v = vibrator ?: return
try {
v.cancel()
vibrator = null
LogManager.addLog("INFO", "Ringtone", "vibration stopped vibrator=${System.identityHashCode(v)}")
} catch (e: Exception) {
LogManager.addLog("WARN", "Ringtone", "vibration cancel failed vibrator=${System.identityHashCode(v)}; retained for retry: $e")
}
if (v != null) LogManager.addLog("INFO", "Ringtone", "vibration stopped")
}
/* ── audio focus (fallback API 26–27) ── */
@@ -195,8 +227,8 @@ class RingtonePlayer {
lateinit var req: AudioFocusRequest
req = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT).setAudioAttributes(attributes)
.setOnAudioFocusChangeListener({ change ->
if (focusRequest === req && change < 0) {
LogManager.addLog("INFO", "Ringtone", "audio focus lost change=$change; sound stopped")
if (soundActive && focusRequest === req && change < 0) {
LogManager.addLog("INFO", "Ringtone", "audio focus lost change=$change; stopping sound")
stopSound()
}
}, vibrateHandler).build()
@@ -205,24 +237,27 @@ class RingtonePlayer {
val result = am.requestAudioFocus(req)
if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
LogManager.addLog("WARN", "Ringtone", "audio focus denied result=$result; sound suppressed")
abandonAudioFocus()
return false
}
LogManager.addLog("DEBUG", "Ringtone", "audio focus granted")
LogManager.addLog("DEBUG", "Ringtone", "audio focus granted request=${System.identityHashCode(req)}")
return true
}
private fun abandonAudioFocus() {
val req = focusRequest ?: return
val am = focusManager
focusRequest = null
focusManager = null
try {
val result = am?.abandonAudioFocusRequest(req)
if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED)
LogManager.addLog("WARN", "Ringtone", "abandon focus rejected result=$result")
val am = requireNotNull(focusManager) { "audio focus manager missing" }
val result = am.abandonAudioFocusRequest(req)
if (result != AudioManager.AUDIOFOCUS_REQUEST_GRANTED) {
LogManager.addLog("WARN", "Ringtone", "abandon focus rejected result=$result " +
"request=${System.identityHashCode(req)}; retained for retry")
return
}
focusRequest = null
focusManager = null
LogManager.addLog("DEBUG", "Ringtone", "audio focus abandoned request=${System.identityHashCode(req)}")
} catch (e: Exception) {
LogManager.addLog("WARN", "Ringtone", "abandon focus failed: ${e.message}")
LogManager.addLog("WARN", "Ringtone", "abandon focus failed request=${System.identityHashCode(req)}; retained for retry: $e")
}
}
}