diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallManager.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallManager.kt index 6297ae96..6dc1d707 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallManager.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/IncomingCallManager.kt @@ -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 { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RingtonePlayer.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RingtonePlayer.kt index 765978b7..33496804 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RingtonePlayer.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RingtonePlayer.kt @@ -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") } } }