22 changed files with 1061 additions and 121 deletions
@ -0,0 +1,303 @@ |
|||
package com.yunqiinnovation.azure_speech |
|||
|
|||
import android.app.ActivityManager |
|||
import android.content.Context |
|||
import android.media.AudioAttributes |
|||
import android.media.AudioFocusRequest |
|||
import android.media.AudioManager |
|||
import android.media.AudioPlaybackConfiguration |
|||
import android.os.Build |
|||
import android.os.Handler |
|||
import android.os.Process |
|||
import android.os.SystemClock |
|||
import android.util.Log |
|||
import android.view.KeyEvent |
|||
import io.flutter.plugin.common.MethodChannel |
|||
|
|||
/** |
|||
* 设备按键唤醒的对话,在 Android 上自己申请音频焦点。 |
|||
* |
|||
* audio_session 的 setActive 有两个对不上的地方: |
|||
* 1. 进程里已经有一份 AudioFocusRequest 时,它直接返回 true,不再向系统申请; |
|||
* 2. 它申请的是 GAIN_TRANSIENT + USAGE_MEDIA,华为/ColorOS 上音乐 App 可以继续播, |
|||
* 只压低音量,甚至完全不理会。 |
|||
* |
|||
* 华为 ALN-AL80 上独占焦点能拿到,但 300ms 内 isMusicActive 仍为 true, |
|||
* 音乐 App 不暂停。中途放弃再改抢 ASSISTANT 会给对方一次 LOSS, |
|||
* 结束时再 abandon 它也不自动续播。 |
|||
* |
|||
* 所以整段对话只持有一次 MEDIA 独占焦点。进来时**别的应用**正在播,才暂停, |
|||
* 结束时再补一次播放键。系统本来没在播,就不抢焦点、也不发播放键—— |
|||
* 交还焦点或 MEDIA_PLAY 都会把华为音乐从暂停状态拉起来。 |
|||
*/ |
|||
object DeviceDialogFocus { |
|||
private const val TAG = "EmaiFocus" |
|||
|
|||
/** AudioPlaybackConfiguration.PLAYER_STATE_STARTED,公开 SDK 不导出这个常量。 */ |
|||
private const val PLAYER_STATE_STARTED = 2 |
|||
|
|||
private var audioManager: AudioManager? = null |
|||
private var request: AudioFocusRequest? = null |
|||
private var pausedExternal = false |
|||
|
|||
private val listener = AudioManager.OnAudioFocusChangeListener { change -> |
|||
val music = try { |
|||
audioManager?.isMusicActive |
|||
} catch (_: Throwable) { |
|||
null |
|||
} |
|||
Log.i(TAG, "焦点变化=${focusName(change)} isMusicActive=$music") |
|||
} |
|||
|
|||
fun hold(context: Context, handler: Handler, result: MethodChannel.Result) { |
|||
val am = try { |
|||
manager(context) |
|||
} catch (e: Throwable) { |
|||
Log.e(TAG, "拿不到 AudioManager: ${e.message}") |
|||
result.success(mapOf("error" to (e.message ?: "no AudioManager"))) |
|||
return |
|||
} |
|||
val before = music(am) |
|||
val external = externalStarted(am) |
|||
val mode = try { |
|||
am.mode |
|||
} catch (_: Throwable) { |
|||
-1 |
|||
} |
|||
val importance = importance() |
|||
pausedExternal = false |
|||
if (!external) { |
|||
Log.i( |
|||
TAG, |
|||
"系统没在播音乐,不抢焦点、不发媒体键 isMusicActive=$before " + |
|||
"importance=$importance ${Build.BRAND}/${Build.MODEL}" |
|||
) |
|||
result.success( |
|||
mapOf( |
|||
"gain" to "NONE", |
|||
"usage" to "MEDIA", |
|||
"result" to "SKIPPED", |
|||
"mediaPause" to false, |
|||
"externalPlaying" to false, |
|||
"musicBefore" to before, |
|||
"importance" to importance, |
|||
) |
|||
) |
|||
return |
|||
} |
|||
abandon(am, "重抢前") |
|||
val outcome = request(am, AudioAttributes.USAGE_MEDIA, "MEDIA") |
|||
mediaKey(am, KeyEvent.KEYCODE_MEDIA_PAUSE) |
|||
pausedExternal = true |
|||
val after = music(am) |
|||
Log.i( |
|||
TAG, |
|||
"申请 EXCLUSIVE/MEDIA 结果=${outcome.name} 媒体暂停键=$pausedExternal " + |
|||
"mode=$mode importance=$importance 音乐 $before → $after " + |
|||
"sdk=${Build.VERSION.SDK_INT} ${Build.BRAND}/${Build.MODEL}" |
|||
) |
|||
result.success( |
|||
mapOf( |
|||
"gain" to "EXCLUSIVE", |
|||
"usage" to "MEDIA", |
|||
"result" to outcome.name, |
|||
"mediaPause" to pausedExternal, |
|||
"musicBefore" to before, |
|||
"musicAfter" to after, |
|||
"mode" to mode, |
|||
"importance" to importance, |
|||
"brand" to Build.BRAND, |
|||
"model" to Build.MODEL, |
|||
"sdk" to Build.VERSION.SDK_INT, |
|||
) |
|||
) |
|||
handler.postDelayed({ |
|||
Log.i(TAG, "300ms 复查 音乐=${music(am)} 已发暂停键=$pausedExternal") |
|||
}, 300) |
|||
} |
|||
|
|||
fun release(context: Context): Map<String, Any?> { |
|||
val am = manager(context) |
|||
val before = music(am) |
|||
val had = request != null |
|||
val shouldResume = pausedExternal |
|||
if (!shouldResume) { |
|||
Log.i(TAG, "进来时系统没在播,交还不发播放键 isMusicActive=$before") |
|||
return mapOf( |
|||
"had" to had, |
|||
"mediaPlay" to false, |
|||
"musicBefore" to before, |
|||
"musicAfter" to before, |
|||
) |
|||
} |
|||
abandon(am, "交还") |
|||
pausedExternal = false |
|||
val afterAbandon = music(am) |
|||
mediaKey(am, KeyEvent.KEYCODE_MEDIA_PLAY) |
|||
val sentPlay = true |
|||
val after = music(am) |
|||
Log.i( |
|||
TAG, |
|||
"交还 had=$had 播放键=$sentPlay 音乐 $before → $afterAbandon → $after" |
|||
) |
|||
return mapOf( |
|||
"had" to had, |
|||
"mediaPlay" to sentPlay, |
|||
"musicBefore" to before, |
|||
"musicAfter" to after, |
|||
) |
|||
} |
|||
|
|||
private fun manager(context: Context): AudioManager { |
|||
val existing = audioManager |
|||
if (existing != null) return existing |
|||
val created = context.applicationContext |
|||
.getSystemService(Context.AUDIO_SERVICE) as AudioManager |
|||
audioManager = created |
|||
return created |
|||
} |
|||
|
|||
private fun request(am: AudioManager, usage: Int, usageName: String): RequestOutcome { |
|||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { |
|||
@Suppress("DEPRECATION") |
|||
val status = am.requestAudioFocus( |
|||
listener, |
|||
AudioManager.STREAM_MUSIC, |
|||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE, |
|||
) |
|||
val outcome = RequestOutcome(status) |
|||
Log.i(TAG, "legacy 申请 EXCLUSIVE/$usageName 结果=${outcome.name}") |
|||
return outcome |
|||
} |
|||
val attrs = AudioAttributes.Builder() |
|||
.setUsage(usage) |
|||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) |
|||
.build() |
|||
val req = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE) |
|||
.setAudioAttributes(attrs) |
|||
.setAcceptsDelayedFocusGain(false) |
|||
.setWillPauseWhenDucked(true) |
|||
.setOnAudioFocusChangeListener(listener) |
|||
.build() |
|||
val status = am.requestAudioFocus(req) |
|||
if (status == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { |
|||
request = req |
|||
} else { |
|||
request = null |
|||
Log.w(TAG, "申请 EXCLUSIVE/$usageName 失败 结果=${RequestOutcome(status).name}") |
|||
} |
|||
return RequestOutcome(status) |
|||
} |
|||
|
|||
private fun abandon(am: AudioManager, why: String) { |
|||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { |
|||
val prev = request |
|||
request = null |
|||
if (prev == null) return |
|||
try { |
|||
val status = am.abandonAudioFocusRequest(prev) |
|||
Log.i(TAG, "$why abandon=${RequestOutcome(status).name}") |
|||
} catch (e: Throwable) { |
|||
Log.w(TAG, "$why abandon 异常: ${e.message}") |
|||
} |
|||
} else { |
|||
@Suppress("DEPRECATION") |
|||
try { |
|||
am.abandonAudioFocus(listener) |
|||
} catch (e: Throwable) { |
|||
Log.w(TAG, "$why abandon legacy 异常: ${e.message}") |
|||
} |
|||
} |
|||
} |
|||
|
|||
private fun mediaKey(am: AudioManager, keyCode: Int) { |
|||
val now = SystemClock.uptimeMillis() |
|||
try { |
|||
am.dispatchMediaKeyEvent(KeyEvent(now, now, KeyEvent.ACTION_DOWN, keyCode, 0)) |
|||
am.dispatchMediaKeyEvent(KeyEvent(now, now, KeyEvent.ACTION_UP, keyCode, 0)) |
|||
Log.i(TAG, "媒体键 ${if (keyCode == KeyEvent.KEYCODE_MEDIA_PAUSE) "PAUSE" else "PLAY"}") |
|||
} catch (e: Throwable) { |
|||
Log.w(TAG, "媒体键失败 key=$keyCode: ${e.message}") |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 别的应用有一条正在出声的媒体播放。自己的 AudioTrack 不算,暂停着的也不算。 |
|||
* |
|||
* 必须同时满足「播放器状态是 STARTED」和「isMusicActive」。只看状态会把 |
|||
* 暂停着、音轨还留在列表里的播放器算成在播,结束时一交还焦点,华为就把 |
|||
* 它拉起来。只看 isMusicActive 又会被我们自己后启动的静音保活污染。 |
|||
* |
|||
* getClientUid / getPlayerState 在公开 android.jar 里是隐藏接口,编译期调不到, |
|||
* 运行时再反射。读不到就退回 isMusicActive:调用方要在自己的音轨起来之前问。 |
|||
*/ |
|||
private fun externalStarted(am: AudioManager): Boolean { |
|||
val audible = music(am) |
|||
if (!audible) { |
|||
Log.i(TAG, "isMusicActive=false,没有正在出声的播放") |
|||
return false |
|||
} |
|||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return true |
|||
return try { |
|||
val mine = Process.myUid() |
|||
val uidOf = AudioPlaybackConfiguration::class.java.getMethod("getClientUid") |
|||
val stateOf = AudioPlaybackConfiguration::class.java.getMethod("getPlayerState") |
|||
val rows = am.activePlaybackConfigurations.map { cfg -> |
|||
Triple( |
|||
uidOf.invoke(cfg) as Int, |
|||
stateOf.invoke(cfg) as Int, |
|||
cfg.audioAttributes.usage, |
|||
) |
|||
} |
|||
val started = rows.any { |
|||
it.first != mine && |
|||
it.second == PLAYER_STATE_STARTED && |
|||
it.third == AudioAttributes.USAGE_MEDIA |
|||
} |
|||
Log.i( |
|||
TAG, |
|||
"外部播放器 " + rows.joinToString { |
|||
"uid=${it.first}/state=${it.second}/usage=${it.third}" |
|||
} + " mediaStarted=$started audible=$audible" |
|||
) |
|||
started |
|||
} catch (e: Throwable) { |
|||
Log.w(TAG, "读播放器列表失败,退回 isMusicActive=$audible: ${e.message}") |
|||
audible |
|||
} |
|||
} |
|||
|
|||
private fun music(am: AudioManager): Boolean = try { |
|||
am.isMusicActive |
|||
} catch (_: Throwable) { |
|||
false |
|||
} |
|||
|
|||
private fun importance(): Int = try { |
|||
val info = ActivityManager.RunningAppProcessInfo() |
|||
ActivityManager.getMyMemoryState(info) |
|||
info.importance |
|||
} catch (_: Throwable) { |
|||
-1 |
|||
} |
|||
|
|||
private fun focusName(code: Int): String = when (code) { |
|||
AudioManager.AUDIOFOCUS_GAIN -> "GAIN" |
|||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT -> "GAIN_TRANSIENT" |
|||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE -> "GAIN_TRANSIENT_EXCLUSIVE" |
|||
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK -> "GAIN_TRANSIENT_MAY_DUCK" |
|||
AudioManager.AUDIOFOCUS_LOSS -> "LOSS" |
|||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> "LOSS_TRANSIENT" |
|||
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> "LOSS_TRANSIENT_CAN_DUCK" |
|||
else -> "UNKNOWN($code)" |
|||
} |
|||
|
|||
private class RequestOutcome(val code: Int) { |
|||
val name: String = when (code) { |
|||
AudioManager.AUDIOFOCUS_REQUEST_GRANTED -> "GRANTED" |
|||
AudioManager.AUDIOFOCUS_REQUEST_FAILED -> "FAILED" |
|||
AudioManager.AUDIOFOCUS_REQUEST_DELAYED -> "DELAYED" |
|||
else -> "UNKNOWN($code)" |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,20 @@ |
|||
import 'package:audio_session/audio_session.dart'; |
|||
import 'package:eaimar/data/utils/emai_audio_session.dart'; |
|||
import 'package:flutter_test/flutter_test.dart'; |
|||
|
|||
void main() { |
|||
test('设备对话的放音会话独占,且不带 playback 上非法的 A2DP 选项', () { |
|||
final config = EmaiAudioSession.playbackOnlyConfigForTest(); |
|||
final options = config.avAudioSessionCategoryOptions!; |
|||
|
|||
expect(config.avAudioSessionCategory, AVAudioSessionCategory.playback); |
|||
expect(config.avAudioSessionMode, AVAudioSessionMode.defaultMode); |
|||
expect(options.contains(AVAudioSessionCategoryOptions.mixWithOthers), isFalse); |
|||
expect(options.contains(AVAudioSessionCategoryOptions.allowBluetoothA2dp), isFalse); |
|||
expect(options.contains(AVAudioSessionCategoryOptions.defaultToSpeaker), isFalse); |
|||
expect(config.androidAudioFocusGainType, AndroidAudioFocusGainType.gainTransient); |
|||
expect(config.androidAudioAttributes!.usage, AndroidAudioUsage.media); |
|||
expect(config.androidAudioAttributes!.contentType, AndroidAudioContentType.speech); |
|||
expect(config.androidWillPauseWhenDucked, isTrue); |
|||
}); |
|||
} |
|||
Loading…
Reference in new issue