@file:Suppress("DEPRECATION") package io.legato.kazusa.service import android.annotation.SuppressLint import android.app.PendingIntent import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.graphics.Bitmap import android.graphics.BitmapFactory import android.media.AudioManager import android.net.wifi.WifiManager import android.os.Bundle import android.os.PowerManager import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat import android.telephony.PhoneStateListener import android.telephony.TelephonyManager import androidx.annotation.CallSuper import androidx.core.app.NotificationCompat import androidx.lifecycle.lifecycleScope import androidx.media.AudioFocusRequestCompat import androidx.media.AudioManagerCompat import io.legato.kazusa.R import io.legato.kazusa.base.BaseService import io.legato.kazusa.constant.AppConst import io.legato.kazusa.constant.AppLog import io.legato.kazusa.constant.AppPattern import io.legato.kazusa.constant.EventBus import io.legato.kazusa.constant.IntentAction import io.legato.kazusa.constant.NotificationId import io.legato.kazusa.constant.PreferKey import io.legato.kazusa.constant.Status import io.legato.kazusa.help.MediaHelp import io.legato.kazusa.help.config.AppConfig import io.legato.kazusa.help.coroutine.Coroutine import io.legato.kazusa.help.glide.ImageLoader import io.legato.kazusa.lib.permission.Permissions import io.legato.kazusa.lib.permission.PermissionsCompat import io.legato.kazusa.model.ReadAloud import io.legato.kazusa.model.ReadBook import io.legato.kazusa.receiver.MediaButtonReceiver import io.legato.kazusa.ui.book.read.ReadBookActivity import io.legato.kazusa.ui.book.read.page.entities.TextChapter import io.legato.kazusa.utils.LogUtils import io.legato.kazusa.utils.activityPendingIntent import io.legato.kazusa.utils.getPrefBoolean import io.legato.kazusa.utils.observeEvent import io.legato.kazusa.utils.observeSharedPreferences import io.legato.kazusa.utils.postEvent import io.legato.kazusa.utils.toastOnUi import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import splitties.init.appCtx import splitties.systemservices.audioManager import splitties.systemservices.notificationManager import splitties.systemservices.powerManager import splitties.systemservices.telephonyManager import splitties.systemservices.wifiManager /** * 朗读服务 */ abstract class BaseReadAloudService : BaseService(), AudioManager.OnAudioFocusChangeListener { companion object { @JvmStatic var isRun = false private set @JvmStatic var pause = true private set @JvmStatic var timeMinute: Int = 0 private set fun isPlay(): Boolean { return isRun && !pause } private const val TAG = "BaseReadAloudService" } private val useWakeLock = appCtx.getPrefBoolean(PreferKey.readAloudWakeLock, false) private val wakeLock by lazy { powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "legado:ReadAloudService") .apply { this.setReferenceCounted(false) } } private val wifiLock by lazy { @Suppress("DEPRECATION") wifiManager?.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "legado:AudioPlayService") ?.apply { setReferenceCounted(false) } } private val mFocusRequest: AudioFocusRequestCompat by lazy { MediaHelp.buildAudioFocusRequestCompat(this) } private val mediaSessionCompat: MediaSessionCompat by lazy { MediaSessionCompat(this, "readAloud") } private val phoneStateListener by lazy { ReadAloudPhoneStateListener() } internal var contentList = emptyList() internal var nowSpeak: Int = 0 internal var readAloudNumber: Int = 0 internal var textChapter: TextChapter? = null internal var pageIndex = 0 private var needResumeOnAudioFocusGain = false private var needResumeOnCallStateIdle = false private var registeredPhoneStateListener = false private var dsJob: Job? = null private var upNotificationJob: Coroutine<*>? = null private var cover: Bitmap = BitmapFactory.decodeResource(appCtx.resources, R.drawable.ic_launcher) var pageChanged = false private var toLast = false var paragraphStartPos = 0 var readAloudByPage = false private set private val broadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { if (AudioManager.ACTION_AUDIO_BECOMING_NOISY == intent.action) { pauseReadAloud() } } } @SuppressLint("WakelockTimeout") override fun onCreate() { super.onCreate() isRun = true pause = false observeLiveBus() initMediaSession() initBroadcastReceiver() initPhoneStateListener() upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING) setTimer(AppConfig.ttsTimer) if (AppConfig.ttsTimer > 0) { toastOnUi("朗读定时 ${AppConfig.ttsTimer} 分钟") } execute { ImageLoader .loadBitmap(this@BaseReadAloudService, ReadBook.book?.getDisplayCover()) .submit() .get() }.onSuccess { if (it.width > 16 && it.height > 16) { cover = it upReadAloudNotification() } } } fun observeLiveBus() { observeEvent(EventBus.READ_ALOUD_PLAY) { val play = it.getBoolean("play") val pageIndex = it.getInt("pageIndex") val startPos = it.getInt("startPos") newReadAloud(play, pageIndex, startPos) } observeSharedPreferences { _, key -> when (key) { PreferKey.ignoreAudioFocus, PreferKey.pauseReadAloudWhilePhoneCalls -> { initPhoneStateListener() } } } } override fun onDestroy() { super.onDestroy() if (useWakeLock) { wakeLock.release() wifiLock?.release() } isRun = false pause = true abandonFocus() unregisterReceiver(broadcastReceiver) postEvent(EventBus.ALOUD_STATE, Status.STOP) notificationManager.cancel(NotificationId.ReadAloudService) upMediaSessionPlaybackState(PlaybackStateCompat.STATE_STOPPED) mediaSessionCompat.release() ReadBook.uploadProgress() unregisterPhoneStateListener(phoneStateListener) upNotificationJob?.invokeOnCompletion { notificationManager.cancel(NotificationId.ReadAloudService) } } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { when (intent?.action) { IntentAction.play -> newReadAloud( intent.getBooleanExtra("play", true), intent.getIntExtra("pageIndex", ReadBook.durPageIndex), intent.getIntExtra("startPos", 0) ) IntentAction.pause -> pauseReadAloud() IntentAction.resume -> resumeReadAloud() IntentAction.upTtsSpeechRate -> upSpeechRate(true) IntentAction.prevParagraph -> prevP() IntentAction.nextParagraph -> nextP() IntentAction.prev -> prevChapter() IntentAction.next -> nextChapter() IntentAction.addTimer -> addTimer() IntentAction.setTimer -> setTimer(intent.getIntExtra("minute", 0)) IntentAction.stop -> stopSelf() } return super.onStartCommand(intent, flags, startId) } private fun newReadAloud(play: Boolean, pageIndex: Int, startPos: Int) { execute(executeContext = IO) { this@BaseReadAloudService.pageIndex = pageIndex textChapter = ReadBook.curTextChapter val textChapter = textChapter ?: return@execute if (!textChapter.isCompleted) { return@execute } readAloudNumber = textChapter.getReadLength(pageIndex) + startPos readAloudByPage = getPrefBoolean(PreferKey.readAloudByPage) contentList = textChapter.getNeedReadAloud(0, readAloudByPage, 0) .split("\n") .filter { it.isNotEmpty() } var pos = startPos val page = textChapter.getPage(pageIndex)!! if (pos > 0) { for (paragraph in page.paragraphs) { val tmp = pos - paragraph.length - 1 if (tmp < 0) break pos = tmp } } nowSpeak = textChapter.getParagraphNum(readAloudNumber + 1, readAloudByPage) - 1 if (!readAloudByPage && startPos == 0 && !toLast) { pos = page.chapterPosition - textChapter.paragraphs[nowSpeak].chapterPosition } if (toLast) { toLast = false readAloudNumber = textChapter.getLastParagraphPosition() nowSpeak = contentList.lastIndex if (page.paragraphs.size == 1) { pos = page.chapterPosition - textChapter.paragraphs[nowSpeak].chapterPosition } } paragraphStartPos = pos launch(Main) { if (play) play() else pageChanged = true } }.onError { AppLog.put("启动朗读出错\n${it.localizedMessage}", it, true) } } @SuppressLint("WakelockTimeout") open fun play() { if (useWakeLock) { wakeLock.acquire() wifiLock?.acquire() } isRun = true pause = false needResumeOnAudioFocusGain = false needResumeOnCallStateIdle = false upReadAloudNotification() postEvent(EventBus.ALOUD_STATE, Status.PLAY) } abstract fun playStop() @CallSuper open fun pauseReadAloud(abandonFocus: Boolean = true) { if (useWakeLock) { wakeLock.release() wifiLock?.release() } pause = true if (abandonFocus) { abandonFocus() } upReadAloudNotification() upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PAUSED) postEvent(EventBus.ALOUD_STATE, Status.PAUSE) ReadBook.uploadProgress() doDs() } @SuppressLint("WakelockTimeout") @CallSuper open fun resumeReadAloud() { pause = false needResumeOnAudioFocusGain = false needResumeOnCallStateIdle = false upReadAloudNotification() upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING) postEvent(EventBus.ALOUD_STATE, Status.PLAY) } abstract fun upSpeechRate(reset: Boolean = false) fun upTtsProgress(progress: Int) { postEvent(EventBus.TTS_PROGRESS, progress) } private fun prevP() { if (nowSpeak > 0) { playStop() do { nowSpeak-- readAloudNumber -= contentList[nowSpeak].length + 1 + paragraphStartPos paragraphStartPos = 0 } while (contentList[nowSpeak].matches(AppPattern.notReadAloudRegex)) textChapter?.let { if (readAloudByPage) { val paragraphs = it.getParagraphs(true) if (!paragraphs[nowSpeak].isParagraphEnd) readAloudNumber++ } if (readAloudNumber < it.getReadLength(pageIndex)) { pageIndex-- ReadBook.moveToPrevPage() } } upTtsProgress(readAloudNumber + 1) play() } else { toLast = true ReadBook.moveToPrevChapter(true) } } private fun nextP() { if (nowSpeak < contentList.size - 1) { playStop() readAloudNumber += contentList[nowSpeak].length.plus(1) - paragraphStartPos paragraphStartPos = 0 nowSpeak++ textChapter?.let { if (readAloudByPage) { val paragraphs = it.getParagraphs(true) if (!paragraphs[nowSpeak].isParagraphEnd) readAloudNumber-- } if (pageIndex + 1 < it.pageSize && readAloudNumber >= it.getReadLength(pageIndex + 1) ) { pageIndex++ ReadBook.moveToNextPage() } } upTtsProgress(readAloudNumber + 1) play() } else { nextChapter() } } private fun setTimer(minute: Int) { timeMinute = minute doDs() } private fun addTimer() { if (timeMinute == 180) { timeMinute = 0 } else { timeMinute += 10 if (timeMinute > 180) timeMinute = 180 } doDs() } /** * 定时 */ @Synchronized private fun doDs() { postEvent(EventBus.READ_ALOUD_DS, timeMinute) upReadAloudNotification() dsJob?.cancel() dsJob = lifecycleScope.launch { while (isActive) { delay(60000) if (!pause) { if (timeMinute >= 0) { timeMinute-- } if (timeMinute == 0) { ReadAloud.stop(this@BaseReadAloudService) postEvent(EventBus.READ_ALOUD_DS, timeMinute) break } } postEvent(EventBus.READ_ALOUD_DS, timeMinute) upReadAloudNotification() } } } /** * 请求音频焦点 * @return 音频焦点 */ fun requestFocus(): Boolean { if (AppConfig.ignoreAudioFocus) { return true } val requestFocus = MediaHelp.requestFocus(mFocusRequest) if (!requestFocus) { pauseReadAloud(false) toastOnUi("未获取到音频焦点") } return requestFocus } /** * 放弃音频焦点 */ private fun abandonFocus() { AudioManagerCompat.abandonAudioFocusRequest(audioManager, mFocusRequest) } /** * 更新媒体状态 */ private fun upMediaSessionPlaybackState(state: Int) { mediaSessionCompat.setPlaybackState( PlaybackStateCompat.Builder() .setActions(MediaHelp.MEDIA_SESSION_ACTIONS) .setState(state, nowSpeak.toLong(), 1f) // 为系统媒体控件添加定时按钮 // .addCustomAction( // PlaybackStateCompat.CustomAction.Builder( // "ACTION_ADD_TIMER", // getString(R.string.set_timer), // R.drawable.ic_time_add_24dp // ).build() // ) .build() ) } /** * 初始化MediaSession, 注册多媒体按钮 */ /** * 初始化MediaSession, 注册多媒体按钮 */ @SuppressLint("UnspecifiedImmutableFlag") private fun initMediaSession() { if (getPrefBoolean("systemMediaControlCompatibilityChange")) { mediaSessionCompat.setCallback(object : MediaSessionCompat.Callback() { override fun onPlay() { resumeReadAloud() } override fun onPause() { pauseReadAloud() } override fun onSkipToNext() { if (getPrefBoolean("mediaButtonPerNext", false)) { nextChapter() } else { nextP() } } override fun onSkipToPrevious() { if (getPrefBoolean("mediaButtonPerNext", false)) { prevChapter() } else { prevP() } } override fun onStop() { stopSelf() } override fun onCustomAction(action: String, extras: Bundle?) { if (action == "ACTION_ADD_TIMER") addTimer() } override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { return MediaButtonReceiver.handleIntent( this@BaseReadAloudService, mediaButtonEvent ) } }) } else { mediaSessionCompat.setCallback(object : MediaSessionCompat.Callback() { override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean { return MediaButtonReceiver.handleIntent( this@BaseReadAloudService, mediaButtonEvent ) } }) } } /** * 注册多媒体按钮监听 */ private fun initBroadcastReceiver() { val intentFilter = IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY) registerReceiver(broadcastReceiver, intentFilter) } /** * 音频焦点变化 */ override fun onAudioFocusChange(focusChange: Int) { if (AppConfig.ignoreAudioFocus) { AppLog.put("忽略音频焦点处理(TTS)") return } when (focusChange) { AudioManager.AUDIOFOCUS_GAIN -> { if (needResumeOnAudioFocusGain) { AppLog.put("音频焦点获得,继续朗读") resumeReadAloud() } else { AppLog.put("音频焦点获得") } } AudioManager.AUDIOFOCUS_LOSS -> { AppLog.put("音频焦点丢失,暂停朗读") pauseReadAloud() } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { AppLog.put("音频焦点暂时丢失并会很快再次获得,暂停朗读") if (!pause) { needResumeOnAudioFocusGain = true pauseReadAloud(false) } } AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> { // 短暂丢失焦点,这种情况是被其他应用申请了短暂的焦点希望其他声音能压低音量(或者关闭声音)凸显这个声音(比如短信提示音), AppLog.put("音频焦点短暂丢失,不做处理") } } } private fun upReadAloudNotification() { upNotificationJob = execute { try { val notification = createNotification() notificationManager.notify(NotificationId.ReadAloudService, notification.build()) } catch (e: Exception) { AppLog.put("创建朗读通知出错,${e.localizedMessage}", e, true) } } } private fun choiceMediaStyle(): androidx.media.app.NotificationCompat.MediaStyle { val mediaStyle = androidx.media.app.NotificationCompat.MediaStyle() .setShowActionsInCompactView(1, 2, 4) if (getPrefBoolean("systemMediaControlCompatibilityChange")) { //fix #4090 android 14 can not show play control in lock screen mediaStyle.setMediaSession(mediaSessionCompat.sessionToken) } return mediaStyle } private fun createNotification(): NotificationCompat.Builder { var nTitle: String = when { pause -> getString(R.string.read_aloud_pause) timeMinute > 0 -> getString( R.string.read_aloud_timer, timeMinute ) else -> getString(R.string.read_aloud_t) } nTitle += ": ${ReadBook.book?.name}" var nSubtitle = ReadBook.curTextChapter?.title if (nSubtitle.isNullOrBlank()) nSubtitle = getString(R.string.read_aloud_s) val builder = NotificationCompat .Builder(this, AppConst.channelIdReadAloud) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) .setCategory(NotificationCompat.CATEGORY_TRANSPORT) .setSmallIcon(R.drawable.ic_volume_up) .setSubText(getString(R.string.read_aloud)) .setOngoing(true) .setOnlyAlertOnce(true) .setContentTitle(nTitle) .setContentText(nSubtitle) .setContentIntent( activityPendingIntent("activity") ) .setVibrate(null) .setSound(null) .setLights(0, 0, 0) builder.setLargeIcon(cover) // 按钮定义:上一章、播放、停止、下一章、定时 builder.addAction( R.drawable.ic_skip_previous, getString(R.string.previous_chapter), aloudServicePendingIntent(IntentAction.prev) ) if (pause) { builder.addAction( R.drawable.ic_play, getString(R.string.resume), aloudServicePendingIntent(IntentAction.resume) ) } else { builder.addAction( R.drawable.ic_pause, getString(R.string.pause), aloudServicePendingIntent(IntentAction.pause) ) } builder.addAction( R.drawable.ic_stop_black_24dp, getString(R.string.stop), aloudServicePendingIntent(IntentAction.stop) ) builder.addAction( R.drawable.ic_skip_next, getString(R.string.next_chapter), aloudServicePendingIntent(IntentAction.next) ) builder.addAction( R.drawable.ic_time_add_24dp, getString(R.string.set_timer), aloudServicePendingIntent(IntentAction.addTimer) ) builder.setStyle(choiceMediaStyle()) return builder } /** * 更新通知 */ override fun startForegroundNotification() { execute { try { val notification = createNotification() startForeground(NotificationId.ReadAloudService, notification.build()) } catch (e: Exception) { AppLog.put("创建朗读通知出错,${e.localizedMessage}", e, true) //创建通知出错不结束服务就会崩溃,服务必须绑定通知 stopSelf() } } } abstract fun aloudServicePendingIntent(actionStr: String): PendingIntent? open fun prevChapter() { toLast = false ReadBook.moveToPrevChapter(true, toLast = false) } open fun nextChapter() { ReadBook.upReadTime() AppLog.putDebug("${ReadBook.curTextChapter?.chapter?.title} 朗读结束跳转下一章并朗读") if (!ReadBook.moveToNextChapter(true)) { stopSelf() } } private fun initPhoneStateListener() { val needRegister = AppConfig.ignoreAudioFocus && AppConfig.pauseReadAloudWhilePhoneCalls if (needRegister && registeredPhoneStateListener) { return } if (needRegister) { registerPhoneStateListener(phoneStateListener) } else { unregisterPhoneStateListener(phoneStateListener) } } private fun unregisterPhoneStateListener(l: PhoneStateListener) { if (registeredPhoneStateListener) { withReadPhoneStatePermission { telephonyManager.listen(l, PhoneStateListener.LISTEN_NONE) registeredPhoneStateListener = false } } } private fun registerPhoneStateListener(l: PhoneStateListener) { withReadPhoneStatePermission { telephonyManager.listen(l, PhoneStateListener.LISTEN_CALL_STATE) registeredPhoneStateListener = true } } private fun withReadPhoneStatePermission(block: () -> Unit) { try { block.invoke() } catch (_: SecurityException) { PermissionsCompat.Builder() .addPermissions(Permissions.READ_PHONE_STATE) .rationale(R.string.read_aloud_read_phone_state_permission_rationale) .onGranted { try { block.invoke() } catch (_: SecurityException) { LogUtils.d(TAG, "Grant read phone state permission fail.") } } .request() } } @Suppress("OVERRIDE_DEPRECATION") inner class ReadAloudPhoneStateListener : PhoneStateListener() { override fun onCallStateChanged(state: Int, phoneNumber: String?) { super.onCallStateChanged(state, phoneNumber) when (state) { TelephonyManager.CALL_STATE_IDLE -> { if (needResumeOnCallStateIdle) { AppLog.put("来电结束,继续朗读") resumeReadAloud() } else { AppLog.put("来电结束") } } TelephonyManager.CALL_STATE_RINGING -> { if (!pause) { AppLog.put("来电响铃,暂停朗读") needResumeOnCallStateIdle = true pauseReadAloud() } else { AppLog.put("来电响铃") } } TelephonyManager.CALL_STATE_OFFHOOK -> { AppLog.put("来电接听,不做处理") } } } } }