This commit is contained in:
HapeLee committed 2025-06-04 13:33:51 +08:00
1 parent b86d90021c
commit 6c0757c91b
972 files changed
+6885 -7321

No files matched your search

@@ -0,0 +1,659 @@
package io.legato.kazusa.service
import android.annotation.SuppressLint
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.WIFI_MODE_FULL_HIGH_PERF
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaSessionCompat
import android.support.v4.media.session.PlaybackStateCompat
import androidx.core.app.NotificationCompat
import androidx.lifecycle.lifecycleScope
import androidx.media.AudioFocusRequestCompat
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
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.EventBus
import io.legato.kazusa.constant.IntentAction
import io.legato.kazusa.constant.NotificationId
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.exoplayer.ExoPlayerHelper
import io.legato.kazusa.help.glide.ImageLoader
import io.legato.kazusa.model.AudioPlay
import io.legato.kazusa.model.analyzeRule.AnalyzeUrl
import io.legato.kazusa.model.analyzeRule.AnalyzeUrl.Companion.getMediaItem
import io.legato.kazusa.receiver.MediaButtonReceiver
import io.legato.kazusa.ui.book.audio.AudioPlayActivity
import io.legato.kazusa.utils.activityPendingIntent
import io.legato.kazusa.utils.broadcastPendingIntent
import io.legato.kazusa.utils.postEvent
import io.legato.kazusa.utils.printOnDebug
import io.legato.kazusa.utils.servicePendingIntent
import io.legato.kazusa.utils.toastOnUi
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.wifiManager
/**
* 音频播放服务
*/
class AudioPlayService : BaseService(),
AudioManager.OnAudioFocusChangeListener,
Player.Listener {
companion object {
@JvmStatic
var isRun = false
private set
@JvmStatic
var pause = true
private set
@JvmStatic
var timeMinute: Int = 0
var url: String = ""
private set
private const val MEDIA_SESSION_ACTIONS = (PlaybackStateCompat.ACTION_PLAY
or PlaybackStateCompat.ACTION_PAUSE
or PlaybackStateCompat.ACTION_PLAY_PAUSE
or PlaybackStateCompat.ACTION_SEEK_TO)
private const val APP_ACTION_STOP = "Stop"
private const val APP_ACTION_TIMER = "Timer"
}
private val useWakeLock = AppConfig.audioPlayUseWakeLock
private val wakeLock by lazy {
powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "legado:AudioPlayService")
.apply {
this.setReferenceCounted(false)
}
}
private val wifiLock by lazy {
@Suppress("DEPRECATION")
wifiManager?.createWifiLock(WIFI_MODE_FULL_HIGH_PERF, "legado:AudioPlayService")?.apply {
setReferenceCounted(false)
}
}
private val mFocusRequest: AudioFocusRequestCompat by lazy {
MediaHelp.buildAudioFocusRequestCompat(this)
}
private val exoPlayer: ExoPlayer by lazy {
ExoPlayerHelper.createHttpExoPlayer(this)
}
private var mediaSessionCompat: MediaSessionCompat? = null
private var broadcastReceiver: BroadcastReceiver? = null
private var needResumeOnAudioFocusGain = false
private var position = AudioPlay.book?.durChapterPos ?: 0
private var dsJob: Job? = null
private var upNotificationJob: Coroutine<*>? = null
private var upPlayProgressJob: Job? = null
private var playSpeed: Float = 1f
private var cover: Bitmap =
BitmapFactory.decodeResource(appCtx.resources, R.drawable.icon_read_book)
override fun onCreate() {
super.onCreate()
isRun = true
exoPlayer.addListener(this)
AudioPlay.registerService(this)
initMediaSession()
initBroadcastReceiver()
upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING)
doDs()
execute {
ImageLoader
.loadBitmap(this@AudioPlayService, AudioPlay.book?.getDisplayCover())
.submit()
.get()
}.onSuccess {
if (it.width > 16 && it.height > 16) {
cover = it
upMediaMetadata()
upAudioPlayNotification()
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
intent?.action?.let { action ->
when (action) {
IntentAction.play -> {
exoPlayer.stop()
upPlayProgressJob?.cancel()
pause = false
position = AudioPlay.book?.durChapterPos ?: 0
url = AudioPlay.durPlayUrl
play()
}
IntentAction.playNew -> {
exoPlayer.stop()
upPlayProgressJob?.cancel()
pause = false
position = 0
url = AudioPlay.durPlayUrl
play()
}
IntentAction.stopPlay -> {
exoPlayer.stop()
upPlayProgressJob?.cancel()
AudioPlay.status = Status.STOP
postEvent(EventBus.AUDIO_STATE, Status.STOP)
}
IntentAction.pause -> pause()
IntentAction.resume -> resume()
IntentAction.prev -> AudioPlay.prev()
IntentAction.next -> AudioPlay.next()
IntentAction.adjustSpeed -> upSpeed(intent.getFloatExtra("adjust", 1f))
IntentAction.addTimer -> addTimer()
IntentAction.setTimer -> setTimer(intent.getIntExtra("minute", 0))
IntentAction.adjustProgress -> {
adjustProgress(intent.getIntExtra("position", position))
}
IntentAction.stop -> stopSelf()
}
}
return super.onStartCommand(intent, flags, startId)
}
override fun onDestroy() {
super.onDestroy()
if (useWakeLock) {
wakeLock.release()
wifiLock?.release()
}
isRun = false
abandonFocus()
exoPlayer.release()
mediaSessionCompat?.release()
unregisterReceiver(broadcastReceiver)
upMediaSessionPlaybackState(PlaybackStateCompat.STATE_STOPPED)
AudioPlay.status = Status.STOP
postEvent(EventBus.AUDIO_STATE, Status.STOP)
AudioPlay.unregisterService()
upNotificationJob?.invokeOnCompletion {
notificationManager.cancel(NotificationId.AudioPlayService)
}
}
/**
* 播放音频
*/
@SuppressLint("WakelockTimeout")
private fun play() {
if (useWakeLock) {
wakeLock.acquire()
wifiLock?.acquire()
}
upAudioPlayNotification()
if (!requestFocus()) {
return
}
execute(context = Main) {
AudioPlay.status = Status.STOP
postEvent(EventBus.AUDIO_STATE, Status.STOP)
upPlayProgressJob?.cancel()
val analyzeUrl = AnalyzeUrl(
url,
source = AudioPlay.bookSource,
ruleData = AudioPlay.book,
chapter = AudioPlay.durChapter,
coroutineContext = coroutineContext
)
exoPlayer.setMediaItem(analyzeUrl.getMediaItem())
exoPlayer.playWhenReady = true
exoPlayer.seekTo(position.toLong())
exoPlayer.prepare()
}.onError {
AppLog.put("播放出错\n${it.localizedMessage}", it)
toastOnUi("$url ${it.localizedMessage}")
stopSelf()
}
}
/**
* 暂停播放
*/
private fun pause(abandonFocus: Boolean = true) {
if (useWakeLock) {
wakeLock.release()
wifiLock?.release()
}
try {
pause = true
if (abandonFocus) {
abandonFocus()
}
upPlayProgressJob?.cancel()
position = exoPlayer.currentPosition.toInt()
if (exoPlayer.isPlaying) exoPlayer.pause()
upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PAUSED)
AudioPlay.status = Status.PAUSE
postEvent(EventBus.AUDIO_STATE, Status.PAUSE)
upAudioPlayNotification()
} catch (e: Exception) {
e.printOnDebug()
}
}
/**
* 恢复播放
*/
@SuppressLint("WakelockTimeout")
private fun resume() {
if (useWakeLock) {
wakeLock.acquire()
wifiLock?.acquire()
}
try {
pause = false
if (url.isEmpty()) {
AudioPlay.loadOrUpPlayUrl()
return
}
if (!exoPlayer.isPlaying) {
exoPlayer.play()
}
upPlayProgress()
upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING)
AudioPlay.status = Status.PLAY
postEvent(EventBus.AUDIO_STATE, Status.PLAY)
upAudioPlayNotification()
} catch (e: Exception) {
e.printOnDebug()
stopSelf()
}
}
/**
* 调节进度
*/
private fun adjustProgress(position: Int) {
this.position = position
exoPlayer.seekTo(position.toLong())
}
/**
* 调节速度
*/
@SuppressLint(value = ["ObsoleteSdkInt"])
private fun upSpeed(adjust: Float) {
kotlin.runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
playSpeed += adjust
exoPlayer.setPlaybackSpeed(playSpeed)
postEvent(EventBus.AUDIO_SPEED, playSpeed)
}
}
}
/**
* 播放状态监控
*/
override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
when (playbackState) {
Player.STATE_IDLE -> {
// 空闲
}
Player.STATE_BUFFERING -> {
// 缓冲中
}
Player.STATE_READY -> {
// 准备好
AudioPlay.upLoading(false)
if (exoPlayer.playWhenReady) {
AudioPlay.status = Status.PLAY
postEvent(EventBus.AUDIO_STATE, Status.PLAY)
} else {
AudioPlay.status = Status.PAUSE
postEvent(EventBus.AUDIO_STATE, Status.PAUSE)
}
postEvent(EventBus.AUDIO_SIZE, exoPlayer.duration.toInt())
upMediaMetadata()
upPlayProgress()
AudioPlay.saveDurChapter(exoPlayer.duration)
}
Player.STATE_ENDED -> {
// 结束
upPlayProgressJob?.cancel()
AudioPlay.playPositionChanged(exoPlayer.duration.toInt())
AudioPlay.next()
}
}
upAudioPlayNotification()
}
private fun upMediaMetadata() {
val metadata = MediaMetadataCompat.Builder()
.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, cover)
.putText(MediaMetadataCompat.METADATA_KEY_TITLE, AudioPlay.durChapter?.title ?: "null")
.putText(MediaMetadataCompat.METADATA_KEY_ARTIST, AudioPlay.book?.name ?: "null")
.putText(MediaMetadataCompat.METADATA_KEY_ALBUM, AudioPlay.book?.author ?: "null")
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, exoPlayer.duration)
.build()
mediaSessionCompat?.setMetadata(metadata)
}
/**
* 播放错误事件
*/
override fun onPlayerError(error: PlaybackException) {
super.onPlayerError(error)
AudioPlay.status = Status.STOP
postEvent(EventBus.AUDIO_STATE, Status.STOP)
AudioPlay.upLoading(false)
val errorMsg = "音频播放出错\n${error.errorCodeName} ${error.errorCode}"
AppLog.put(errorMsg, error)
toastOnUi(errorMsg)
}
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()
}
/**
* 定时
*/
private fun doDs() {
postEvent(EventBus.AUDIO_DS, timeMinute)
upAudioPlayNotification()
dsJob?.cancel()
dsJob = lifecycleScope.launch {
while (isActive) {
delay(60000)
if (!pause) {
if (timeMinute >= 0) {
timeMinute--
}
if (timeMinute == 0) {
AudioPlay.stop()
postEvent(EventBus.AUDIO_DS, timeMinute)
break
}
}
postEvent(EventBus.AUDIO_DS, timeMinute)
upAudioPlayNotification()
}
}
}
/**
* 每隔1秒发送播放进度
*/
private fun upPlayProgress() {
upPlayProgressJob?.cancel()
upPlayProgressJob = lifecycleScope.launch {
while (isActive) {
//更新buffer位置
AudioPlay.playPositionChanged(exoPlayer.currentPosition.toInt())
postEvent(EventBus.AUDIO_BUFFER_PROGRESS, exoPlayer.bufferedPosition.toInt())
postEvent(EventBus.AUDIO_PROGRESS, AudioPlay.durChapterPos)
postEvent(EventBus.AUDIO_SIZE, exoPlayer.duration.toInt())
upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING)
delay(1000)
}
}
}
/**
* 更新媒体状态
*/
private fun upMediaSessionPlaybackState(state: Int) {
mediaSessionCompat?.setPlaybackState(
PlaybackStateCompat.Builder()
.setActions(MEDIA_SESSION_ACTIONS)
.setState(state, exoPlayer.currentPosition, 1f)
.setBufferedPosition(exoPlayer.bufferedPosition)
.addCustomAction(
APP_ACTION_STOP,
getString(R.string.stop),
R.drawable.ic_stop_black_24dp
)
.addCustomAction(
APP_ACTION_TIMER,
getString(R.string.set_timer),
R.drawable.ic_time_add_24dp
)
.build()
)
}
/**
* 初始化MediaSession, 注册多媒体按钮
*/
@SuppressLint("UnspecifiedImmutableFlag")
private fun initMediaSession() {
mediaSessionCompat = MediaSessionCompat(this, "readAloud")
mediaSessionCompat?.setCallback(object : MediaSessionCompat.Callback() {
override fun onSeekTo(pos: Long) {
position = pos.toInt()
exoPlayer.seekTo(pos)
}
override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
return MediaButtonReceiver.handleIntent(this@AudioPlayService, mediaButtonEvent)
}
override fun onPlay() = resume()
override fun onPause() = pause()
override fun onCustomAction(action: String?, extras: Bundle?) {
action ?: return
when (action) {
APP_ACTION_STOP -> stopSelf()
APP_ACTION_TIMER -> addTimer()
}
}
})
mediaSessionCompat?.setMediaButtonReceiver(
broadcastPendingIntent<MediaButtonReceiver>(Intent.ACTION_MEDIA_BUTTON)
)
mediaSessionCompat?.isActive = true
}
/**
* 断开耳机监听
*/
private fun initBroadcastReceiver() {
broadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (AudioManager.ACTION_AUDIO_BECOMING_NOISY == intent.action) {
pause()
}
}
}
val intentFilter = IntentFilter(AudioManager.ACTION_AUDIO_BECOMING_NOISY)
registerReceiver(broadcastReceiver, intentFilter)
}
/**
* 音频焦点变化
*/
override fun onAudioFocusChange(focusChange: Int) {
if (AppConfig.ignoreAudioFocus) {
AppLog.put("忽略音频焦点处理(有声)")
return
}
when (focusChange) {
AudioManager.AUDIOFOCUS_GAIN -> {
if (needResumeOnAudioFocusGain) {
AppLog.put("音频焦点获得,继续播放")
resume()
} else {
AppLog.put("音频焦点获得")
}
}
AudioManager.AUDIOFOCUS_LOSS -> {
AppLog.put("音频焦点丢失,暂停播放")
pause()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
AppLog.put("音频焦点暂时丢失并会很快再次获得,暂停播放")
if (!pause) {
needResumeOnAudioFocusGain = true
pause(false)
}
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> {
// 短暂丢失焦点,这种情况是被其他应用申请了短暂的焦点希望其他声音能压低音量(或者关闭声音)凸显这个声音(比如短信提示音),
AppLog.put("音频焦点短暂丢失,不做处理")
}
}
}
private fun createNotification(): NotificationCompat.Builder {
var nTitle: String = when {
pause -> getString(R.string.audio_pause)
timeMinute in 1..60 -> getString(
R.string.playing_timer,
timeMinute
)
else -> getString(R.string.audio_play_t)
}
nTitle += ": ${AudioPlay.book?.name}"
var nSubtitle = AudioPlay.durChapter?.title
if (nSubtitle.isNullOrEmpty()) {
nSubtitle = getString(R.string.audio_play_s)
}
val builder = NotificationCompat
.Builder(this@AudioPlayService, AppConst.channelIdReadAloud)
.setSmallIcon(R.drawable.ic_volume_up)
.setSubText(getString(R.string.audio))
.setOngoing(true)
.setOnlyAlertOnce(true)
.setContentTitle(nTitle)
.setContentText(nSubtitle)
.setContentIntent(
activityPendingIntent<AudioPlayActivity>("activity")
)
builder.setLargeIcon(cover)
if (pause) {
builder.addAction(
R.drawable.ic_play_24dp,
getString(R.string.resume),
servicePendingIntent<AudioPlayService>(IntentAction.resume)
)
} else {
builder.addAction(
R.drawable.ic_pause_24dp,
getString(R.string.pause),
servicePendingIntent<AudioPlayService>(IntentAction.pause)
)
}
builder.addAction(
R.drawable.ic_stop_black_24dp,
getString(R.string.stop),
servicePendingIntent<AudioPlayService>(IntentAction.stop)
)
builder.addAction(
R.drawable.ic_time_add_24dp,
getString(R.string.set_timer),
servicePendingIntent<AudioPlayService>(IntentAction.addTimer)
)
builder.setStyle(
androidx.media.app.NotificationCompat.MediaStyle()
.setShowActionsInCompactView(0, 1, 2)
.setMediaSession(mediaSessionCompat?.sessionToken)
)
builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
return builder
}
private fun upAudioPlayNotification() {
upNotificationJob = execute {
try {
val notification = createNotification()
notificationManager.notify(NotificationId.AudioPlayService, notification.build())
} catch (e: Exception) {
AppLog.put("创建音频播放通知出错,${e.localizedMessage}", e, true)
}
}
}
/**
* 更新通知
*/
override fun startForegroundNotification() {
execute {
try {
val notification = createNotification()
startForeground(NotificationId.AudioPlayService, notification.build())
} catch (e: Exception) {
AppLog.put("创建音频播放通知出错,${e.localizedMessage}", e, true)
//创建通知出错不结束服务就会崩溃,服务必须绑定通知
stopSelf()
}
}
}
/**
* 请求音频焦点
* @return 音频焦点
*/
private fun requestFocus(): Boolean {
if (AppConfig.ignoreAudioFocus) {
return true
}
return MediaHelp.requestFocus(mFocusRequest)
}
/**
* 放弃音频焦点
*/
private fun abandonFocus() {
@Suppress("DEPRECATION")
audioManager.abandonAudioFocus(this)
}
}
@@ -0,0 +1,711 @@
@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.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.broadcastPendingIntent
import io.legato.kazusa.utils.getPrefBoolean
import io.legato.kazusa.utils.isVivoDevice
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<String>()
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.icon_read_book)
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<Bundle>(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()
nowSpeak--
readAloudNumber -= contentList[nowSpeak].length + 1 + paragraphStartPos
paragraphStartPos = 0
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 (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)
.build()
)
}
/**
* 初始化MediaSession, 注册多媒体按钮
*/
@SuppressLint("UnspecifiedImmutableFlag")
private fun initMediaSession() {
mediaSessionCompat.setCallback(object : MediaSessionCompat.Callback() {
override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
return MediaButtonReceiver.handleIntent(this@BaseReadAloudService, mediaButtonEvent)
}
})
mediaSessionCompat.setMediaButtonReceiver(
broadcastPendingIntent<MediaButtonReceiver>(Intent.ACTION_MEDIA_BUTTON)
)
mediaSessionCompat.isActive = true
}
/**
* 注册多媒体按钮监听
*/
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 (isVivoDevice) {
//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<ReadBookActivity>("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_24dp,
getString(R.string.resume),
aloudServicePendingIntent(IntentAction.resume)
)
} else {
builder.addAction(
R.drawable.ic_pause_24dp,
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("来电接听,不做处理")
}
}
}
}
}
@@ -0,0 +1,204 @@
package io.legato.kazusa.service
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.lifecycle.lifecycleScope
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.EventBus
import io.legato.kazusa.constant.IntentAction
import io.legato.kazusa.constant.NotificationId
import io.legato.kazusa.data.appDb
import io.legato.kazusa.help.book.update
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.model.CacheBook
import io.legato.kazusa.model.webBook.WebBook
import io.legato.kazusa.ui.book.cache.CacheActivity
import io.legato.kazusa.utils.activityPendingIntent
import io.legato.kazusa.utils.postEvent
import io.legato.kazusa.utils.servicePendingIntent
import kotlinx.coroutines.Job
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import splitties.init.appCtx
import splitties.systemservices.notificationManager
import java.util.concurrent.Executors
import kotlin.math.min
/**
* 缓存书籍服务
*/
class CacheBookService : BaseService() {
companion object {
var isRun = false
private set
}
private val threadCount = AppConfig.threadCount
private var cachePool =
Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher()
private var downloadJob: Job? = null
private var notificationContent = appCtx.getString(R.string.service_starting)
private var mutex = Mutex()
private val notificationBuilder by lazy {
val builder = NotificationCompat.Builder(this, AppConst.channelIdDownload)
.setSmallIcon(R.drawable.ic_download)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setContentTitle(getString(R.string.offline_cache))
.setContentIntent(activityPendingIntent<CacheActivity>("cacheActivity"))
builder.addAction(
R.drawable.ic_stop_black_24dp,
getString(R.string.cancel),
servicePendingIntent<CacheBookService>(IntentAction.stop)
)
builder.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
}
override fun onCreate() {
super.onCreate()
isRun = true
CacheBook.clear()
lifecycleScope.launch {
while (isActive) {
delay(1000)
notificationContent = CacheBook.downloadSummary
upCacheBookNotification()
postEvent(EventBus.UP_DOWNLOAD, "")
}
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
intent?.action?.let { action ->
when (action) {
IntentAction.start -> addDownloadData(
intent.getStringExtra("bookUrl"),
intent.getIntExtra("start", 0),
intent.getIntExtra("end", 0)
)
IntentAction.remove -> {
val bookUrl = intent.getStringExtra("bookUrl")
CacheBook.cacheBookMap[bookUrl]?.stop()
CacheBook.cacheBookMap.remove(bookUrl)
}
IntentAction.stop -> stopSelf()
}
}
return super.onStartCommand(intent, flags, startId)
}
override fun onDestroy() {
isRun = false
cachePool.close()
CacheBook.close()
super.onDestroy()
postEvent(EventBus.UP_DOWNLOAD, "")
}
private fun addDownloadData(bookUrl: String?, start: Int, end: Int) {
bookUrl ?: return
execute {
val cacheBook = CacheBook.getOrCreate(bookUrl) ?: return@execute
val chapterCount = appDb.bookChapterDao.getChapterCount(bookUrl)
val book = cacheBook.book
if (chapterCount == 0) {
mutex.withLock {
val name = book.name
if (book.tocUrl.isEmpty()) {
kotlin.runCatching {
WebBook.getBookInfoAwait(cacheBook.bookSource, book)
}.onFailure {
val msg = "《$name》目录为空且加载详情页失败\n${it.localizedMessage}"
AppLog.put(msg, it, true)
return@execute
}
}
WebBook.getChapterListAwait(cacheBook.bookSource, book).onFailure {
if (book.totalChapterNum > 0) {
book.totalChapterNum = 0
book.update()
}
val msg = "《$name》目录为空且加载目录失败\n${it.localizedMessage}"
AppLog.put(msg, it, true)
return@execute
}.getOrNull()?.let { toc ->
appDb.bookChapterDao.insert(*toc.toTypedArray())
}
book.update()
}
}
val end2 = if (end < 0) {
book.lastChapterIndex
} else {
min(end, book.lastChapterIndex)
}
cacheBook.addDownload(start, end2)
notificationContent = CacheBook.downloadSummary
upCacheBookNotification()
}.onFinally {
if (downloadJob == null) {
download()
}
}
}
private fun removeDownload(bookUrl: String?) {
CacheBook.cacheBookMap[bookUrl]?.stop()
postEvent(EventBus.UP_DOWNLOAD, "")
if (downloadJob == null && CacheBook.isRun) {
download()
return
}
if (CacheBook.cacheBookMap.isEmpty()) {
stopSelf()
}
}
private fun download() {
downloadJob?.cancel()
downloadJob = lifecycleScope.launch(cachePool) {
while (isActive) {
if (!CacheBook.isRun) {
stopSelf()
return@launch
}
CacheBook.cacheBookMap.forEach {
val cacheBookModel = it.value
while (cacheBookModel.waitCount > 0) {
if (CacheBook.onDownloadCount < threadCount) {
cacheBookModel.download(this, cachePool)
} else {
delay(100)
}
}
}
delay(100)
}
}
}
private fun upCacheBookNotification() {
notificationBuilder.setContentText(notificationContent)
val notification = notificationBuilder.build()
notificationManager.notify(NotificationId.CacheBookService, notification)
}
/**
* 更新通知
*/
override fun startForegroundNotification() {
notificationBuilder.setContentText(notificationContent)
val notification = notificationBuilder.build()
startForeground(NotificationId.CacheBookService, notification)
}
}
@@ -0,0 +1,261 @@
package io.legato.kazusa.service
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.lifecycle.lifecycleScope
import com.script.ScriptException
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseService
import io.legato.kazusa.constant.AppConst
import io.legato.kazusa.constant.BookSourceType
import io.legato.kazusa.constant.EventBus
import io.legato.kazusa.constant.IntentAction
import io.legato.kazusa.constant.NotificationId
import io.legato.kazusa.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookSource
import io.legato.kazusa.exception.ContentEmptyException
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.exception.TocEmptyException
import io.legato.kazusa.help.IntentData
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.source.exploreKinds
import io.legato.kazusa.model.CheckSource
import io.legato.kazusa.model.Debug
import io.legato.kazusa.model.webBook.WebBook
import io.legato.kazusa.ui.book.source.manage.BookSourceActivity
import io.legato.kazusa.utils.activityPendingIntent
import io.legato.kazusa.utils.onEachParallel
import io.legato.kazusa.utils.postEvent
import io.legato.kazusa.utils.servicePendingIntent
import io.legato.kazusa.utils.toastOnUi
import kotlinx.coroutines.Job
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import org.mozilla.javascript.WrappedException
import splitties.init.appCtx
import splitties.systemservices.notificationManager
import java.util.concurrent.Executors
import kotlin.coroutines.coroutineContext
import kotlin.math.min
/**
* 校验书源
*/
class CheckSourceService : BaseService() {
private var threadCount = AppConfig.threadCount
private var searchCoroutine =
Executors.newFixedThreadPool(min(threadCount, AppConst.MAX_THREAD)).asCoroutineDispatcher()
private var notificationMsg = appCtx.getString(R.string.service_starting)
private var checkJob: Job? = null
private var originSize = 0
private var finishCount = 0
private val notificationBuilder by lazy {
NotificationCompat.Builder(this, AppConst.channelIdReadAloud)
.setSmallIcon(R.drawable.ic_network_check)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setContentTitle(getString(R.string.check_book_source))
.setContentIntent(
activityPendingIntent<BookSourceActivity>("activity")
)
.addAction(
R.drawable.ic_stop_black_24dp,
getString(R.string.cancel),
servicePendingIntent<CheckSourceService>(IntentAction.stop)
)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
IntentAction.start -> IntentData.get<List<String>>("checkSourceSelectedIds")?.let {
check(it)
}
IntentAction.resume -> upNotification()
IntentAction.stop -> stopSelf()
}
return super.onStartCommand(intent, flags, startId)
}
override fun onDestroy() {
super.onDestroy()
Debug.finishChecking()
searchCoroutine.close()
postEvent(EventBus.CHECK_SOURCE_DONE, 0)
notificationManager.cancel(NotificationId.CheckSourceService)
}
private fun check(ids: List<String>) {
if (checkJob?.isActive == true) {
toastOnUi("已有书源在校验,等完成后再试")
return
}
checkJob = lifecycleScope.launch(searchCoroutine) {
flow {
for (origin in ids) {
appDb.bookSourceDao.getBookSource(origin)?.let {
emit(it)
}
}
}.onStart {
originSize = ids.size
finishCount = 0
notificationMsg = getString(R.string.progress_show, "", 0, originSize)
upNotification()
}.onEachParallel(threadCount) {
checkSource(it)
}.onEach {
finishCount++
notificationMsg = getString(
R.string.progress_show,
it.bookSourceName,
finishCount,
originSize
)
upNotification()
appDb.bookSourceDao.update(it)
}.onCompletion {
stopSelf()
}.collect()
}
}
private suspend fun checkSource(source: BookSource) {
kotlin.runCatching {
withTimeout(CheckSource.timeout) {
doCheckSource(source)
}
}.onSuccess {
Debug.updateFinalMessage(source.bookSourceUrl, "校验成功")
}.onFailure {
coroutineContext.ensureActive()
when (it) {
is TimeoutCancellationException -> source.addGroup("校验超时")
is ScriptException, is WrappedException -> source.addGroup("js失效")
!is NoStackTraceException -> source.addGroup("网站失效")
}
source.addErrorComment(it)
Debug.updateFinalMessage(source.bookSourceUrl, "校验失败:${it.localizedMessage}")
}
source.respondTime = Debug.getRespondTime(source.bookSourceUrl)
}
private suspend fun doCheckSource(source: BookSource) {
Debug.startChecking(source)
source.removeInvalidGroups()
source.removeErrorComment()
//校验搜索书籍
if (CheckSource.checkSearch) {
val searchWord = source.getCheckKeyword(CheckSource.keyword)
if (!source.searchUrl.isNullOrBlank()) {
source.removeGroup("搜索链接规则为空")
val searchBooks = WebBook.searchBookAwait(source, searchWord)
if (searchBooks.isEmpty()) {
source.addGroup("搜索失效")
} else {
source.removeGroup("搜索失效")
checkBook(searchBooks.first().toBook(), source)
}
} else {
source.addGroup("搜索链接规则为空")
}
}
//校验发现书籍
if (CheckSource.checkDiscovery && !source.exploreUrl.isNullOrBlank()) {
val url = source.exploreKinds().firstOrNull {
!it.url.isNullOrBlank()
}?.url
if (url.isNullOrBlank()) {
source.addGroup("发现规则为空")
} else {
source.removeGroup("发现规则为空")
val exploreBooks = WebBook.exploreBookAwait(source, url)
if (exploreBooks.isEmpty()) {
source.addGroup("发现失效")
} else {
source.removeGroup("发现失效")
checkBook(exploreBooks.first().toBook(), source, false)
}
}
}
val finalCheckMessage = source.getInvalidGroupNames()
if (finalCheckMessage.isNotBlank()) {
throw NoStackTraceException(finalCheckMessage)
}
}
/**
*校验书源的详情目录正文
*/
private suspend fun checkBook(book: Book, source: BookSource, isSearchBook: Boolean = true) {
kotlin.runCatching {
if (!CheckSource.checkInfo) {
return
}
//校验详情
if (book.tocUrl.isBlank()) {
WebBook.getBookInfoAwait(source, book)
}
if (!CheckSource.checkCategory || source.bookSourceType == BookSourceType.file) {
return
}
//校验目录
val toc = WebBook.getChapterListAwait(source, book).getOrThrow().asSequence()
.filter { !(it.isVolume && it.url.startsWith(it.title)) }
.take(2)
.toList()
val nextChapterUrl = toc.getOrNull(1)?.url ?: toc.first().url
if (!CheckSource.checkContent) {
return
}
//校验正文
WebBook.getContentAwait(
bookSource = source,
book = book,
bookChapter = toc.first(),
nextChapterUrl = nextChapterUrl,
needSave = false
)
}.onFailure {
val bookType = if (isSearchBook) "搜索" else "发现"
when (it) {
is ContentEmptyException -> source.addGroup("${bookType}正文失效")
is TocEmptyException -> source.addGroup("${bookType}目录失效")
else -> throw it
}
}.onSuccess {
val bookType = if (isSearchBook) "搜索" else "发现"
source.removeGroup("${bookType}目录失效")
source.removeGroup("${bookType}正文失效")
}
}
private fun upNotification() {
notificationBuilder.setContentText(notificationMsg)
notificationBuilder.setProgress(originSize, finishCount, false)
postEvent(EventBus.CHECK_SOURCE, notificationMsg)
notificationManager.notify(NotificationId.CheckSourceService, notificationBuilder.build())
}
/**
* 更新通知
*/
override fun startForegroundNotification() {
notificationBuilder.setContentText(notificationMsg)
notificationBuilder.setProgress(originSize, finishCount, false)
postEvent(EventBus.CHECK_SOURCE, notificationMsg)
startForeground(NotificationId.CheckSourceService, notificationBuilder.build())
}
}
@@ -0,0 +1,279 @@
package io.legato.kazusa.service
import android.annotation.SuppressLint
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Environment
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
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.IntentAction
import io.legato.kazusa.constant.NotificationId
import io.legato.kazusa.utils.IntentType
import io.legato.kazusa.utils.openFileUri
import io.legato.kazusa.utils.servicePendingIntent
import io.legato.kazusa.utils.toastOnUi
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import splitties.init.appCtx
import splitties.systemservices.downloadManager
import splitties.systemservices.notificationManager
/**
* 下载文件
*/
class DownloadService : BaseService() {
private val groupKey = "${appCtx.packageName}.download"
private val downloads = hashMapOf<Long, DownloadInfo>()
private val completeDownloads = hashSetOf<Long>()
private var upStateJob: Job? = null
private val downloadReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
queryState()
}
}
@SuppressLint("UnspecifiedRegisterReceiverFlag")
override fun onCreate() {
super.onCreate()
ContextCompat.registerReceiver(
this,
downloadReceiver,
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
ContextCompat.RECEIVER_EXPORTED
)
}
override fun onDestroy() {
super.onDestroy()
unregisterReceiver(downloadReceiver)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
IntentAction.start -> startDownload(
intent.getStringExtra("url"),
intent.getStringExtra("fileName")
)
IntentAction.play -> {
val id = intent.getLongExtra("downloadId", 0)
if (completeDownloads.contains(id)) {
openDownload(id, downloads[id]?.fileName)
} else {
toastOnUi("未完成,下载的文件夹Download")
}
}
IntentAction.stop -> {
val downloadId = intent.getLongExtra("downloadId", 0)
removeDownload(downloadId)
}
}
return super.onStartCommand(intent, flags, startId)
}
/**
* 开始下载
*/
@Synchronized
private fun startDownload(url: String?, fileName: String?) {
if (url == null || fileName == null) {
if (downloads.isEmpty()) {
stopSelf()
}
return
}
if (downloads.values.any { it.url == url }) {
toastOnUi("已在下载列表")
return
}
kotlin.runCatching {
// 指定下载地址
val request = DownloadManager.Request(Uri.parse(url))
// 设置通知
request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)
// 设置下载文件保存的路径和文件名
request.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
// 添加一个下载任务
val downloadId = downloadManager.enqueue(request)
downloads[downloadId] =
DownloadInfo(url, fileName, NotificationId.Download + downloads.size)
queryState()
if (upStateJob == null) {
checkDownloadState()
}
}.onFailure {
it.printStackTrace()
val msg = when (it) {
is SecurityException -> "下载出错,没有存储权限"
else -> "下载出错,${it.localizedMessage}"
}
toastOnUi(msg)
AppLog.put(msg, it)
}
}
/**
* 取消下载
*/
@Synchronized
private fun removeDownload(downloadId: Long) {
if (!completeDownloads.contains(downloadId)) {
downloadManager.remove(downloadId)
}
downloads.remove(downloadId)
completeDownloads.remove(downloadId)
notificationManager.cancel(downloadId.toInt())
}
/**
* 下载成功
*/
@Synchronized
private fun successDownload(downloadId: Long) {
if (!completeDownloads.contains(downloadId)) {
completeDownloads.add(downloadId)
val fileName = downloads[downloadId]?.fileName
openDownload(downloadId, fileName)
}
}
private fun checkDownloadState() {
upStateJob?.cancel()
upStateJob = lifecycleScope.launch {
while (isActive) {
queryState()
delay(1000)
}
}
}
/**
* 查询下载进度
*/
@Synchronized
private fun queryState() {
if (downloads.isEmpty()) {
stopSelf()
return
}
val ids = downloads.keys
val query = DownloadManager.Query()
query.setFilterById(*ids.toLongArray())
downloadManager.query(query).use { cursor ->
if (cursor.moveToFirst()) {
val idIndex = cursor.getColumnIndex(DownloadManager.COLUMN_ID)
val progressIndex =
cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
val fileSizeIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
val statusIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
do {
val id = cursor.getLong(idIndex)
val progress = cursor.getInt(progressIndex)
val max = cursor.getInt(fileSizeIndex)
val status = when (cursor.getInt(statusIndex)) {
DownloadManager.STATUS_PAUSED -> getString(R.string.pause)
DownloadManager.STATUS_PENDING -> getString(R.string.wait_download)
DownloadManager.STATUS_RUNNING -> getString(R.string.downloading)
DownloadManager.STATUS_SUCCESSFUL -> {
successDownload(id)
getString(R.string.download_success)
}
DownloadManager.STATUS_FAILED -> getString(R.string.download_error)
else -> getString(R.string.unknown_state)
}
downloads[id]?.let { downloadInfo ->
upDownloadNotification(
id,
downloadInfo.notificationId,
"${downloadInfo.fileName} $status",
max,
progress,
downloadInfo.startTime
)
}
} while (cursor.moveToNext())
}
}
}
/**
* 打开下载文件
*/
private fun openDownload(downloadId: Long, fileName: String?) {
kotlin.runCatching {
downloadManager.getUriForDownloadedFile(downloadId)?.let { uri ->
val type = IntentType.from(fileName)
openFileUri(uri, type)
}
}.onFailure {
AppLog.put("打开下载文件${fileName}出错", it)
}
}
override fun startForegroundNotification() {
val notification = NotificationCompat.Builder(this, AppConst.channelIdDownload)
.setSmallIcon(R.drawable.ic_download)
.setSubText(getString(R.string.action_download))
.setGroup(groupKey)
.setGroupSummary(true)
.setOngoing(true)
.build()
startForeground(NotificationId.DownloadService, notification)
}
/**
* 更新通知
*/
private fun upDownloadNotification(
downloadId: Long,
notificationId: Int,
content: String,
max: Int,
progress: Int,
startTime: Long
) {
val notificationBuilder = NotificationCompat.Builder(this, AppConst.channelIdDownload)
.setSmallIcon(R.drawable.ic_download)
.setSubText(getString(R.string.action_download))
.setContentTitle(content)
.setOnlyAlertOnce(true)
.setContentIntent(
servicePendingIntent<DownloadService>(IntentAction.play, downloadId.toInt()) {
putExtra("downloadId", downloadId)
}
)
.setDeleteIntent(
servicePendingIntent<DownloadService>(IntentAction.stop, downloadId.toInt()) {
putExtra("downloadId", downloadId)
}
)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setGroup(groupKey)
.setWhen(startTime)
if (progress < max) {
notificationBuilder.setProgress(max, progress, false)
}
notificationManager.notify(notificationId, notificationBuilder.build())
}
private data class DownloadInfo(
val url: String,
val fileName: String,
val notificationId: Int,
val startTime: Long = System.currentTimeMillis()
)
}
@@ -0,0 +1,877 @@
package io.legato.kazusa.service
import android.annotation.SuppressLint
import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
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.data.appDb
import io.legato.kazusa.data.entities.Book
import io.legato.kazusa.data.entities.BookChapter
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.help.AppWebDav
import io.legato.kazusa.help.book.BookHelp
import io.legato.kazusa.help.book.ContentProcessor
import io.legato.kazusa.help.book.getExportFileName
import io.legato.kazusa.help.book.isLocalModified
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.model.ReadBook
import io.legato.kazusa.model.localBook.LocalBook
import io.legato.kazusa.ui.book.cache.CacheActivity
import io.legato.kazusa.utils.FileDoc
import io.legato.kazusa.utils.FileUtils
import io.legato.kazusa.utils.HtmlFormatter
import io.legato.kazusa.utils.MD5Utils
import io.legato.kazusa.utils.NetworkUtils
import io.legato.kazusa.utils.activityPendingIntent
import io.legato.kazusa.utils.cnCompare
import io.legato.kazusa.utils.createFileIfNotExist
import io.legato.kazusa.utils.delete
import io.legato.kazusa.utils.find
import io.legato.kazusa.utils.list
import io.legato.kazusa.utils.mapAsync
import io.legato.kazusa.utils.mapAsyncIndexed
import io.legato.kazusa.utils.openOutputStream
import io.legato.kazusa.utils.postEvent
import io.legato.kazusa.utils.servicePendingIntent
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.utils.writeFile
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.collectIndexed
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import me.ag2s.epublib.domain.Author
import me.ag2s.epublib.domain.Date
import me.ag2s.epublib.domain.EpubBook
import me.ag2s.epublib.domain.FileResourceProvider
import me.ag2s.epublib.domain.LazyResource
import me.ag2s.epublib.domain.LazyResourceProvider
import me.ag2s.epublib.domain.Metadata
import me.ag2s.epublib.domain.Resource
import me.ag2s.epublib.domain.TOCReference
import me.ag2s.epublib.epub.EpubWriter
import me.ag2s.epublib.epub.EpubWriterProcessor
import me.ag2s.epublib.util.ResourceUtil
import splitties.init.appCtx
import splitties.systemservices.notificationManager
import java.nio.charset.Charset
import java.util.concurrent.ConcurrentHashMap
import kotlin.coroutines.coroutineContext
import kotlin.math.min
/**
* 导出书籍服务
*/
class ExportBookService : BaseService() {
companion object {
val exportProgress = ConcurrentHashMap<String, Int>()
val exportMsg = ConcurrentHashMap<String, String>()
}
data class ExportConfig(
val path: String,
val type: String,
val epubSize: Int = 1,
val epubScope: String? = null
)
private val groupKey = "${appCtx.packageName}.exportBook"
private val waitExportBooks = linkedMapOf<String, ExportConfig>()
private var exportJob: Job? = null
private var notificationContentText = appCtx.getString(R.string.service_starting)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
IntentAction.start -> kotlin.runCatching {
val bookUrl = intent.getStringExtra("bookUrl")!!
if (!exportProgress.contains(bookUrl)) {
val exportConfig = ExportConfig(
path = intent.getStringExtra("exportPath")!!,
type = intent.getStringExtra("exportType")!!,
epubSize = intent.getIntExtra("epubSize", 1),
epubScope = intent.getStringExtra("epubScope")
)
waitExportBooks[bookUrl] = exportConfig
exportMsg[bookUrl] = getString(R.string.export_wait)
postEvent(EventBus.EXPORT_BOOK, bookUrl)
export()
}
}.onFailure {
toastOnUi(it.localizedMessage)
}
IntentAction.stop -> {
notificationManager.cancel(NotificationId.ExportBook)
stopSelf()
}
}
return super.onStartCommand(intent, flags, startId)
}
override fun onDestroy() {
super.onDestroy()
exportProgress.clear()
exportMsg.clear()
waitExportBooks.keys.forEach {
postEvent(EventBus.EXPORT_BOOK, it)
}
}
@SuppressLint("MissingPermission")
override fun startForegroundNotification() {
val notification = NotificationCompat.Builder(this, AppConst.channelIdDownload)
.setSmallIcon(R.drawable.ic_export)
.setSubText(getString(R.string.export_book))
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setGroup(groupKey)
.setGroupSummary(true)
startForeground(NotificationId.ExportBookService, notification.build())
}
private fun upExportNotification(finish: Boolean = false) {
val notification = NotificationCompat.Builder(this, AppConst.channelIdDownload)
.setSmallIcon(R.drawable.ic_export)
.setSubText(getString(R.string.export_book))
.setContentIntent(activityPendingIntent<CacheActivity>("cacheActivity"))
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentText(notificationContentText)
.setDeleteIntent(servicePendingIntent<ExportBookService>(IntentAction.stop))
.setGroup(groupKey)
.setOnlyAlertOnce(true)
if (!finish) {
notification.setOngoing(true)
notification.addAction(
R.drawable.ic_stop_black_24dp,
getString(R.string.cancel),
servicePendingIntent<ExportBookService>(IntentAction.stop)
)
}
notificationManager.notify(NotificationId.ExportBook, notification.build())
}
private fun export() {
if (exportJob?.isActive == true) {
return
}
exportJob = lifecycleScope.launch(IO) {
while (isActive) {
val (bookUrl, exportConfig) = waitExportBooks.entries.firstOrNull() ?: let {
notificationContentText = "导出完成"
upExportNotification(true)
return@launch
}
exportProgress[bookUrl] = 0
waitExportBooks.remove(bookUrl)
val book = appDb.bookDao.getBook(bookUrl)
try {
book ?: throw NoStackTraceException("获取${bookUrl}书籍出错")
refreshChapterList(book)
notificationContentText = getString(
R.string.export_book_notification_content,
book.name,
waitExportBooks.size
)
upExportNotification()
if (exportConfig.type == "epub") {
if (exportConfig.epubScope.isNullOrBlank()) {
exportEpub(exportConfig.path, book)
} else {
CustomExporter(
exportConfig.epubScope,
exportConfig.epubSize
).export(exportConfig.path, book)
}
} else {
exportTxt(exportConfig.path, book)
}
exportMsg[book.bookUrl] = getString(R.string.export_success)
} catch (e: Throwable) {
ensureActive()
exportMsg[bookUrl] = e.localizedMessage ?: "ERROR"
AppLog.put("导出书籍<${book?.name ?: bookUrl}>出错", e)
} finally {
exportProgress.remove(bookUrl)
postEvent(EventBus.EXPORT_BOOK, bookUrl)
}
}
}
}
private fun refreshChapterList(book: Book) {
if (!book.isLocalModified()) {
return
}
kotlin.runCatching {
LocalBook.getChapterList(book)
}.onSuccess {
appDb.bookChapterDao.delByBook(book.bookUrl)
appDb.bookChapterDao.insert(*it.toTypedArray())
appDb.bookDao.update(book)
ReadBook.onChapterListUpdated(book)
}
}
private data class SrcData(
val chapterTitle: String,
val index: Int,
val src: String
)
private suspend fun exportTxt(path: String, book: Book) {
exportMsg.remove(book.bookUrl)
postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
val fileDoc = FileDoc.fromDir(path)
exportTxt(fileDoc, book)
}
private suspend fun exportTxt(fileDoc: FileDoc, book: Book) {
val filename = book.getExportFileName("txt")
fileDoc.find(filename)?.delete()
val bookDoc = fileDoc.createFileIfNotExist(filename)
val charset = Charset.forName(AppConfig.exportCharset)
bookDoc.openOutputStream().getOrThrow().bufferedWriter(charset).use { bw ->
getAllContents(book) { text, srcList ->
bw.write(text)
srcList?.forEach {
val vFile = BookHelp.getImage(book, it.src)
if (vFile.exists()) {
fileDoc.createFileIfNotExist(
"${it.index}-${MD5Utils.md5Encode16(it.src)}.jpg",
subDirs = arrayOf(
"${book.name}_${book.author}",
"images",
it.chapterTitle
)
).writeFile(vFile)
}
}
}
}
if (AppConfig.exportToWebDav) {
// 导出到webdav
AppWebDav.exportWebDav(bookDoc.uri, filename)
}
}
private suspend fun getAllContents(
book: Book,
append: (text: String, srcList: ArrayList<SrcData>?) -> Unit
) = coroutineScope {
val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule()
val contentProcessor = ContentProcessor.get(book.name, book.origin)
val qy = "${book.name}\n${
getString(R.string.author_show, book.getRealAuthor())
}\n${
getString(
R.string.intro_show,
"\n" + HtmlFormatter.format(book.getDisplayIntro())
)
}"
append(qy, null)
val threads = if (AppConfig.parallelExportBook) {
AppConst.MAX_THREAD
} else {
1
}
flow {
appDb.bookChapterDao.getChapterList(book.bookUrl).forEach { chapter ->
emit(chapter)
}
}.mapAsync(threads) { chapter ->
getExportData(book, chapter, contentProcessor, useReplace)
}.collectIndexed { index, result ->
postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
exportProgress[book.bookUrl] = index
append.invoke(result.first, result.second)
}
}
private fun getExportData(
book: Book,
chapter: BookChapter,
contentProcessor: ContentProcessor,
useReplace: Boolean
): Pair<String, ArrayList<SrcData>?> {
val content = BookHelp.getContent(book, chapter)
val content1 = contentProcessor
.getContent(
book,
// 不导出vip标识
chapter.apply { isVip = false },
content ?: if (chapter.isVolume) "" else "null",
includeTitle = !AppConfig.exportNoChapterName,
useReplace = useReplace,
chineseConvert = false,
reSegment = false
).toString()
if (AppConfig.exportPictureFile) {
//txt导出图片文件
val srcList = arrayListOf<SrcData>()
content?.split("\n")?.forEachIndexed { index, text ->
val matcher = AppPattern.imgPattern.matcher(text)
while (matcher.find()) {
matcher.group(1)?.let {
val src = NetworkUtils.getAbsoluteURL(chapter.url, it)
srcList.add(SrcData(chapter.title, index, src))
}
}
}
return Pair("\n\n$content1", srcList)
} else {
return Pair("\n\n$content1", null)
}
}
/**
* 导出Epub
*/
private suspend fun exportEpub(path: String, book: Book) {
exportMsg.remove(book.bookUrl)
postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
val fileDoc = FileDoc.fromDir(path)
exportEpub(fileDoc, book)
}
private suspend fun exportEpub(fileDoc: FileDoc, book: Book) {
val filename = book.getExportFileName("epub")
fileDoc.find(filename)?.delete()
val epubBook = EpubBook()
epubBook.version = "2.0"
//set metadata
setEpubMetadata(book, epubBook)
//set cover
setCover(book, epubBook)
//set css
val contentModel = setAssets(fileDoc, book, epubBook)
//设置正文
setEpubContent(contentModel, book, epubBook)
val bookDoc = fileDoc.createFileIfNotExist(filename)
bookDoc.openOutputStream().getOrThrow().buffered().use { bookOs ->
EpubWriter().write(epubBook, bookOs)
}
if (AppConfig.exportToWebDav) {
// 导出到webdav
AppWebDav.exportWebDav(bookDoc.uri, filename)
}
}
private fun setAssets(doc: FileDoc, book: Book, epubBook: EpubBook): String {
val customPath = doc.find("Asset")
val contentModel = if (customPath == null) {//使用内置模板
setAssets(book, epubBook)
} else {//外部模板
setAssetsExternal(customPath, book, epubBook)
}
return contentModel
}
private fun setAssetsExternal(doc: FileDoc, book: Book, epubBook: EpubBook): String {
var contentModel = ""
doc.list()!!.forEach { folder ->
if (folder.isDir && folder.name == "Text") {
folder.list()!!.sortedWith { o1, o2 ->
o1.name.cnCompare(o2.name)
}.forEach loop@{ file ->
if (file.isDir) {
return@loop
}
when {
//正文模板
file.name.equals("chapter.html", true)
|| file.name.equals("chapter.xhtml", true) -> {
contentModel = file.readText()
}
//封面等其他模板
file.name.endsWith("html", true) -> {
epubBook.addSection(
FileUtils.getNameExcludeExtension(file.name),
ResourceUtil.createPublicResource(
book.name,
book.getRealAuthor(),
book.getDisplayIntro(),
book.kind,
book.wordCount,
file.readText(),
"${folder.name}/${file.name}"
)
)
}
//其他格式文件当做资源文件
else -> {
epubBook.resources.add(
Resource(
file.readBytes(),
"${folder.name}/${file.name}"
)
)
}
}
}
} else if (folder.isDir) {
//资源文件
folder.list()!!.forEach loop2@{
if (it.isDir) {
return@loop2
}
epubBook.resources.add(
Resource(
it.readBytes(),
"${folder.name}/${it.name}"
)
)
}
} else {//Asset下面的资源文件
epubBook.resources.add(
Resource(
folder.readBytes(),
folder.name
)
)
}
}
return contentModel
}
private fun setAssets(book: Book, epubBook: EpubBook): String {
epubBook.resources.add(
Resource(
appCtx.assets.open("epub/fonts.css").readBytes(),
"Styles/fonts.css"
)
)
epubBook.resources.add(
Resource(
appCtx.assets.open("epub/main.css").readBytes(),
"Styles/main.css"
)
)
epubBook.resources.add(
Resource(
appCtx.assets.open("epub/logo.png").readBytes(),
"Images/logo.png"
)
)
epubBook.addSection(
getString(R.string.img_cover),
ResourceUtil.createPublicResource(
book.name,
book.getRealAuthor(),
book.getDisplayIntro(),
book.kind,
book.wordCount,
String(appCtx.assets.open("epub/cover.html").readBytes()),
"Text/cover.html"
)
)
epubBook.addSection(
getString(R.string.book_intro),
ResourceUtil.createPublicResource(
book.name,
book.getRealAuthor(),
book.getDisplayIntro(),
book.kind,
book.wordCount,
String(appCtx.assets.open("epub/intro.html").readBytes()),
"Text/intro.html"
)
)
return String(appCtx.assets.open("epub/chapter.html").readBytes())
}
private fun setCover(book: Book, epubBook: EpubBook) {
kotlin.runCatching {
val file = Glide.with(this)
.asFile()
.load(book.getDisplayCover())
.submit()
.get()
val provider = LazyResourceProvider { _ ->
file.inputStream()
}
epubBook.coverImage = LazyResource(provider, "Images/cover.jpg")
}.onFailure {
AppLog.put("获取书籍封面出错\n${it.localizedMessage}", it)
}
}
private suspend fun setEpubContent(
contentModel: String,
book: Book,
epubBook: EpubBook
) = coroutineScope {
//正文
val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule()
val contentProcessor = ContentProcessor.get(book.name, book.origin)
val threads = if (AppConfig.parallelExportBook) {
AppConst.MAX_THREAD
} else {
1
}
var parentSection: TOCReference? = null
flow {
appDb.bookChapterDao.getChapterList(book.bookUrl).forEach { chapter ->
emit(chapter)
}
}.mapAsyncIndexed(threads) { index, chapter ->
val content = BookHelp.getContent(book, chapter)
val (contentFix, resources) = fixPic(
book,
content ?: if (chapter.isVolume) "" else "null",
chapter
)
// 不导出vip标识
chapter.isVip = false
val content1 = contentProcessor
.getContent(
book,
chapter,
contentFix,
includeTitle = false,
useReplace = useReplace,
chineseConvert = false,
reSegment = false
).toString()
val title = chapter.run {
// 不导出vip标识
isVip = false
getDisplayTitle(
contentProcessor.getTitleReplaceRules(),
useReplace = useReplace
)
}
val chapterResource = ResourceUtil.createChapterResource(
title.replace("\uD83D\uDD12", ""),
content1,
contentModel,
"Text/chapter_${index}.html"
)
ExportChapter(title, chapterResource, resources, chapter)
}.collectIndexed { index, exportChapter ->
postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
exportProgress[book.bookUrl] = index
val (title, chapterResource, resources, chapter) = exportChapter
epubBook.resources.addAll(resources)
if (chapter.isVolume) {
parentSection = epubBook.addSection(title, chapterResource)
} else if (parentSection == null) {
epubBook.addSection(title, chapterResource)
} else {
epubBook.addSection(parentSection, title, chapterResource)
}
}
}
data class ExportChapter(
val title: String,
val chapterResource: Resource,
val resources: ArrayList<Resource>,
val chapter: BookChapter
)
private fun fixPic(
book: Book,
content: String,
chapter: BookChapter
): Pair<String, ArrayList<Resource>> {
val data = StringBuilder("")
val resources = arrayListOf<Resource>()
content.split("\n").forEach { text ->
var text1 = text
val matcher = AppPattern.imgPattern.matcher(text)
while (matcher.find()) {
matcher.group(1)?.let {
val src = NetworkUtils.getAbsoluteURL(chapter.url, it)
val originalHref =
"${MD5Utils.md5Encode16(src)}.${BookHelp.getImageSuffix(src)}"
val href =
"Images/${MD5Utils.md5Encode16(src)}.${BookHelp.getImageSuffix(src)}"
val vFile = BookHelp.getImage(book, src)
val fp = FileResourceProvider(vFile.parent)
if (vFile.exists()) {
val img = LazyResource(fp, href, originalHref)
resources.add(img)
}
text1 = text1.replace(src, "../${href}")
}
}
data.append(text1).append("\n")
}
return data.toString() to resources
}
private fun setEpubMetadata(book: Book, epubBook: EpubBook) {
val metadata = Metadata()
metadata.titles.add(book.name)//书籍的名称
metadata.authors.add(Author(book.getRealAuthor()))//书籍的作者
metadata.language = "zh"//数据的语言
metadata.dates.add(Date())//数据的创建日期
metadata.publishers.add("Legado")//数据的创建者
metadata.descriptions.add(book.getDisplayIntro())//书籍的简介
//metadata.subjects.add("")//书籍的主题,在静读天下里面有使用这个分类书籍
epubBook.metadata = metadata
}
//////end of EPUB
//////start of custom exporter
/**
* 自定义Exporter
* @param scope 导出范围
* @param size epub 文件包含最大章节数
*/
inner class CustomExporter(scopeStr: String, private val size: Int) {
private var scope = parseScope(scopeStr)
/**
* 导出Epub
* @param path 导出的路径
* @param book 书籍
*/
suspend fun export(
path: String,
book: Book
) {
exportProgress[book.bookUrl] = 0
exportMsg.remove(book.bookUrl)
postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
val currentTimeMillis = System.currentTimeMillis()
val count = appDb.bookChapterDao.getChapterCount(book.bookUrl)
scope = scope.filter { it < count }.toHashSet()
val fileDoc = FileDoc.fromDir(path)
val (contentModel, epubList) = createEpubs(book, fileDoc)
var progressBar = 0.0
epubList.forEachIndexed { index, ep ->
val (filename, epubBook) = ep
//设置正文
setEpubContent(
contentModel,
book,
epubBook,
index
) { _, _ ->
// 将章节写入内存时更新进度条
postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
progressBar += book.totalChapterNum.toDouble() / scope.size / 2
exportProgress[book.bookUrl] = progressBar.toInt()
}
save2Drive(filename, epubBook, fileDoc) { total, _ ->
//写入硬盘时更新进度条
progressBar += book.totalChapterNum.toDouble() / epubList.size / total / 2
postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
exportProgress[book.bookUrl] = progressBar.toInt()
}
}
val elapsed = System.currentTimeMillis() - currentTimeMillis
AppLog.put("分割导出书籍 ${book.name} 一共耗时 $elapsed")
}
/**
* 设置epub正文
*
* @param contentModel 正文模板
* @param book 书籍
* @param epubBook 分割后的epub
* @param epubBookIndex 分割后的epub序号
*/
private suspend fun setEpubContent(
contentModel: String,
book: Book,
epubBook: EpubBook,
epubBookIndex: Int,
updateProgress: (chapterList: MutableList<BookChapter>, index: Int) -> Unit
) {
//正文
val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule()
val contentProcessor = ContentProcessor.get(book.name, book.origin)
var chapterList: MutableList<BookChapter> = ArrayList()
appDb.bookChapterDao.getChapterList(book.bookUrl).forEachIndexed { index, chapter ->
if (scope.contains(index)) {
chapterList.add(chapter)
}
if (scope.size == chapterList.size) {
return@forEachIndexed
}
}
// val totalChapterNum = book.totalChapterNum / scope.size
if (chapterList.isEmpty()) {
throw RuntimeException("书籍<${book.name}>(${epubBookIndex + 1})未找到章节信息")
}
chapterList = chapterList.subList(
epubBookIndex * size,
min(scope.size, (epubBookIndex + 1) * size)
)
chapterList.forEachIndexed { index, chapter ->
coroutineContext.ensureActive()
updateProgress(chapterList, index)
BookHelp.getContent(book, chapter).let { content ->
val (contentFix, resources) = fixPic(
book,
content ?: if (chapter.isVolume) "" else "null",
chapter
)
epubBook.resources.addAll(resources)
val content1 = contentProcessor
.getContent(
book,
chapter,
contentFix,
includeTitle = false,
useReplace = useReplace,
chineseConvert = false,
reSegment = false
).toString()
val title = chapter.run {
// 不导出vip标识
isVip = false
getDisplayTitle(
contentProcessor.getTitleReplaceRules(),
useReplace = useReplace
)
}
epubBook.addSection(
title,
ResourceUtil.createChapterResource(
title.replace("\uD83D\uDD12", ""),
content1,
contentModel,
"Text/chapter_${index}.html"
)
)
}
}
}
/**
* 创建多个epub 对象
*
* 分割epub时,一个书籍需要创建多个epub对象
* @param book 书籍
* @param fileDoc 导出文件夹文档
*
* @return <内容模板字符串, <epub文件名, epub对象>>
*/
private fun createEpubs(
book: Book,
fileDoc: FileDoc
): Pair<String, List<Pair<String, EpubBook>>> {
val paresNumOfEpub = paresNumOfEpub(scope.size, size)
val result: MutableList<Pair<String, EpubBook>> = ArrayList(paresNumOfEpub)
var contentModel = ""
for (i in 1..paresNumOfEpub) {
val filename = book.getExportFileName("epub", i)
fileDoc.find(filename)?.delete()
val epubBook = EpubBook()
epubBook.version = "2.0"
//set metadata
setEpubMetadata(book, epubBook)
//set cover
setCover(book, epubBook)
//set css
contentModel = setAssets(fileDoc, book, epubBook)
// add epubBook
result.add(Pair(filename, epubBook))
}
return Pair(contentModel, result)
}
/**
* 保存文件到 设备
*/
private suspend fun save2Drive(
filename: String,
epubBook: EpubBook,
fileDoc: FileDoc,
callback: (total: Int, progress: Int) -> Unit
) {
val bookDoc = fileDoc.createFileIfNotExist(filename)
bookDoc.openOutputStream().getOrThrow().buffered().use { bookOs ->
EpubWriter()
.setCallback(object : EpubWriterProcessor.Callback {
override fun onProgressing(total: Int, progress: Int) {
callback(total, progress)
}
})
.write(epubBook, bookOs)
}
if (AppConfig.exportToWebDav) {
// 导出到webdav
AppWebDav.exportWebDav(bookDoc.uri, filename)
}
}
/**
* 解析 分割epub后的数量
*
* @param total 章节总数
* @param size 每个epub文件包含多少章节
*/
private fun paresNumOfEpub(total: Int, size: Int): Int {
val i = total % size
var result = total / size
if (i > 0) {
result++
}
return result
}
/**
* 解析范围字符串
*
* @param scope 范围字符串
* @return 范围
*
* @since 2023/5/22
* @author Discut
*/
private fun parseScope(scope: String): Set<Int> {
val split = scope.split(",")
val result = linkedSetOf<Int>()
for (s in split) {
val v = s.split("-")
if (v.size != 2) {
result.add(s.toInt() - 1)
continue
}
val left = v[0].toInt()
val right = v[1].toInt()
if (left > right) {
AppLog.put("Error expression : $s; left > right")
continue
}
for (i in left..right)
result.add(i - 1)
}
return result
}
}
}
@@ -0,0 +1,594 @@
package io.legato.kazusa.service
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.net.Uri
import androidx.lifecycle.lifecycleScope
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.Timeline
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.cache.CacheDataSink
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.offline.DefaultDownloaderFactory
import androidx.media3.exoplayer.offline.DownloadRequest
import androidx.media3.exoplayer.offline.Downloader
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.upstream.DefaultLoadErrorHandlingPolicy
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import com.script.ScriptException
import io.legato.kazusa.R
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.AppPattern
import io.legato.kazusa.data.entities.HttpTTS
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.coroutine.Coroutine
import io.legato.kazusa.help.exoplayer.InputStreamDataSource
import io.legato.kazusa.help.http.okHttpClient
import io.legato.kazusa.model.ReadAloud
import io.legato.kazusa.model.ReadBook
import io.legato.kazusa.model.analyzeRule.AnalyzeUrl
import io.legato.kazusa.utils.FileUtils
import io.legato.kazusa.utils.MD5Utils
import io.legato.kazusa.utils.printOnDebug
import io.legato.kazusa.utils.servicePendingIntent
import io.legato.kazusa.utils.toastOnUi
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers.Main
import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.Response
import org.mozilla.javascript.WrappedException
import splitties.init.appCtx
import java.io.File
import java.io.InputStream
import java.net.ConnectException
import java.net.SocketTimeoutException
import kotlin.coroutines.coroutineContext
/**
* 在线朗读
*/
@SuppressLint("UnsafeOptInUsageError")
class HttpReadAloudService : BaseReadAloudService(),
Player.Listener {
private val exoPlayer: ExoPlayer by lazy {
ExoPlayer.Builder(this).build()
}
private val ttsFolderPath: String by lazy {
cacheDir.absolutePath + File.separator + "httpTTS" + File.separator
}
private val cache by lazy {
SimpleCache(
File(cacheDir, "httpTTS_cache"),
LeastRecentlyUsedCacheEvictor(128 * 1024 * 1024),
StandaloneDatabaseProvider(appCtx)
)
}
private val cacheDataSinkFactory by lazy {
CacheDataSink.Factory()
.setCache(cache)
}
private val loadErrorHandlingPolicy by lazy {
CustomLoadErrorHandlingPolicy()
}
private var speechRate: Int = AppConfig.speechRatePlay + 5
private var downloadTask: Coroutine<*>? = null
private var playIndexJob: Job? = null
private var downloadErrorNo: Int = 0
private var playErrorNo = 0
private val downloadTaskActiveLock = Mutex()
override fun onCreate() {
super.onCreate()
exoPlayer.addListener(this)
}
override fun onDestroy() {
super.onDestroy()
downloadTask?.cancel()
exoPlayer.release()
cache.release()
Coroutine.async {
removeCacheFile()
}
}
override fun play() {
pageChanged = false
exoPlayer.stop()
if (!requestFocus()) return
if (contentList.isEmpty()) {
AppLog.putDebug("朗读列表为空")
ReadBook.readAloud()
} else {
super.play()
if (AppConfig.streamReadAloudAudio) {
downloadAndPlayAudiosStream()
} else {
downloadAndPlayAudios()
}
}
}
override fun playStop() {
exoPlayer.stop()
playIndexJob?.cancel()
}
private fun updateNextPos() {
readAloudNumber += contentList[nowSpeak].length + 1 - paragraphStartPos
paragraphStartPos = 0
if (nowSpeak < contentList.lastIndex) {
nowSpeak++
} else {
nextChapter()
}
}
private fun downloadAndPlayAudios() {
exoPlayer.clearMediaItems()
downloadTask?.cancel()
downloadTask = execute {
downloadTaskActiveLock.withLock {
ensureActive()
val httpTts = ReadAloud.httpTTS ?: throw NoStackTraceException("tts is null")
contentList.forEachIndexed { index, content ->
ensureActive()
if (index < nowSpeak) return@forEachIndexed
var text = content
if (paragraphStartPos > 0 && index == nowSpeak) {
text = text.substring(paragraphStartPos)
}
val fileName = md5SpeakFileName(text)
val speakText = text.replace(AppPattern.notReadAloudRegex, "")
if (speakText.isEmpty()) {
AppLog.put("阅读段落内容为空,使用无声音频代替。\n朗读文本:$text")
createSilentSound(fileName)
} else if (!hasSpeakFile(fileName)) {
runCatching {
val inputStream = getSpeakStream(httpTts, speakText)
if (inputStream != null) {
createSpeakFile(fileName, inputStream)
} else {
createSilentSound(fileName)
}
}.onFailure {
when (it) {
is CancellationException -> Unit
else -> pauseReadAloud()
}
return@execute
}
}
val file = getSpeakFileAsMd5(fileName)
val mediaItem = MediaItem.fromUri(Uri.fromFile(file))
launch(Main) {
exoPlayer.addMediaItem(mediaItem)
}
}
preDownloadAudios(httpTts)
}
}.onError {
AppLog.put("朗读下载出错\n${it.localizedMessage}", it, true)
}
}
private suspend fun preDownloadAudios(httpTts: HttpTTS) {
val textChapter = ReadBook.nextTextChapter ?: return
val contentList = textChapter.getNeedReadAloud(0, readAloudByPage, 0, 1)
.splitToSequence("\n")
.filter { it.isNotEmpty() }
.take(10)
.toList()
contentList.forEach { content ->
coroutineContext.ensureActive()
val fileName = md5SpeakFileName(content)
val speakText = content.replace(AppPattern.notReadAloudRegex, "")
if (speakText.isEmpty()) {
createSilentSound(fileName)
} else if (!hasSpeakFile(fileName)) {
runCatching {
val inputStream = getSpeakStream(httpTts, speakText)
if (inputStream != null) {
createSpeakFile(fileName, inputStream)
} else {
createSilentSound(fileName)
}
}
}
}
}
private fun downloadAndPlayAudiosStream() {
exoPlayer.clearMediaItems()
downloadTask?.cancel()
downloadTask = execute {
downloadTaskActiveLock.withLock {
ensureActive()
val httpTts = ReadAloud.httpTTS ?: throw NoStackTraceException("tts is null")
val downloaderChannel = Channel<Downloader>()
launch {
for (downloader in downloaderChannel) {
downloader.download(null)
}
}
contentList.forEachIndexed { index, content ->
ensureActive()
if (index < nowSpeak) return@forEachIndexed
var text = content
if (paragraphStartPos > 0 && index == nowSpeak) {
text = text.substring(paragraphStartPos)
}
val speakText = text.replace(AppPattern.notReadAloudRegex, "")
if (speakText.isEmpty()) {
AppLog.put("阅读段落内容为空,使用无声音频代替。\n朗读文本:$speakText")
}
val fileName = md5SpeakFileName(text)
val dataSourceFactory = createDataSourceFactory(httpTts, speakText)
val downloader = createDownloader(dataSourceFactory, fileName)
downloaderChannel.send(downloader)
val mediaSource = createMediaSource(dataSourceFactory, fileName)
launch(Main) {
exoPlayer.addMediaSource(mediaSource)
}
}
preDownloadAudiosStream(httpTts, downloaderChannel)
}
}.onError {
AppLog.put("朗读下载出错\n${it.localizedMessage}", it, true)
}
}
private suspend fun preDownloadAudiosStream(
httpTts: HttpTTS,
downloaderChannel: Channel<Downloader>
) {
val textChapter = ReadBook.nextTextChapter ?: return
val contentList = textChapter.getNeedReadAloud(0, readAloudByPage, 0, 1)
.splitToSequence("\n")
.filter { it.isNotEmpty() }
.take(10)
.toList()
contentList.forEach { content ->
coroutineContext.ensureActive()
val fileName = md5SpeakFileName(content)
val speakText = content.replace(AppPattern.notReadAloudRegex, "")
val dataSourceFactory = createDataSourceFactory(httpTts, speakText)
val downloader = createDownloader(dataSourceFactory, fileName)
downloaderChannel.send(downloader)
}
}
private fun createDataSourceFactory(
httpTts: HttpTTS,
speakText: String
): CacheDataSource.Factory {
val upstreamFactory = DataSource.Factory {
InputStreamDataSource {
if (speakText.isEmpty()) {
null
} else {
kotlin.runCatching {
runBlocking(lifecycleScope.coroutineContext[Job]!!) {
getSpeakStream(httpTts, speakText)
}
}.onFailure {
when (it) {
is InterruptedException,
is CancellationException -> Unit
else -> pauseReadAloud()
}
}.getOrThrow()
} ?: resources.openRawResource(R.raw.silent_sound)
}
}
val factory = CacheDataSource.Factory()
.setCache(cache)
.setUpstreamDataSourceFactory(upstreamFactory)
.setCacheWriteDataSinkFactory(cacheDataSinkFactory)
return factory
}
private fun createDownloader(factory: CacheDataSource.Factory, fileName: String): Downloader {
val uri = Uri.parse(fileName)
val request = DownloadRequest.Builder(fileName, uri).build()
return DefaultDownloaderFactory(factory, okHttpClient.dispatcher.executorService)
.createDownloader(request)
}
private fun createMediaSource(factory: DataSource.Factory, fileName: String): MediaSource {
return DefaultMediaSourceFactory(this)
.setDataSourceFactory(factory)
.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy)
.createMediaSource(MediaItem.fromUri(fileName))
}
private suspend fun getSpeakStream(
httpTts: HttpTTS,
speakText: String
): InputStream? {
while (true) {
try {
val analyzeUrl = AnalyzeUrl(
httpTts.url,
speakText = speakText,
speakSpeed = speechRate,
source = httpTts,
readTimeout = 300 * 1000L,
coroutineContext = coroutineContext
)
var response = analyzeUrl.getResponseAwait()
coroutineContext.ensureActive()
val checkJs = httpTts.loginCheckJs
if (checkJs?.isNotBlank() == true) {
response = analyzeUrl.evalJS(checkJs, response) as Response
}
response.headers["Content-Type"]?.let { contentType ->
val contentType = contentType.substringBefore(";")
val ct = httpTts.contentType
if (contentType == "application/json" || contentType.startsWith("text/")) {
throw NoStackTraceException(response.body!!.string())
} else if (ct?.isNotBlank() == true) {
if (!contentType.matches(ct.toRegex())) {
throw NoStackTraceException(
"TTS服务器返回错误:" + response.body!!.string()
)
}
}
}
coroutineContext.ensureActive()
response.body!!.byteStream().let { stream ->
downloadErrorNo = 0
return stream
}
} catch (e: Exception) {
when (e) {
is CancellationException -> throw e
is ScriptException, is WrappedException -> {
AppLog.put("js错误\n${e.localizedMessage}", e, true)
e.printOnDebug()
throw e
}
is SocketTimeoutException, is ConnectException -> {
downloadErrorNo++
if (downloadErrorNo > 5) {
val msg = "tts超时或连接错误超过5次\n${e.localizedMessage}"
AppLog.put(msg, e, true)
throw e
}
}
else -> {
downloadErrorNo++
val msg = "tts下载错误\n${e.localizedMessage}"
AppLog.put(msg, e)
e.printOnDebug()
if (downloadErrorNo > 5) {
val msg1 = "TTS服务器连续5次错误,已暂停阅读。"
AppLog.put(msg1, e, true)
throw e
} else {
AppLog.put("TTS下载音频出错,使用无声音频代替。\n朗读文本:$speakText")
break
}
}
}
}
}
return null
}
private fun md5SpeakFileName(content: String): String {
return MD5Utils.md5Encode16(textChapter?.title ?: "") + "_" +
MD5Utils.md5Encode16("${ReadAloud.httpTTS?.url}-|-$speechRate-|-$content")
}
private fun createSilentSound(fileName: String) {
val file = createSpeakFile(fileName)
file.writeBytes(resources.openRawResource(R.raw.silent_sound).readBytes())
}
private fun hasSpeakFile(name: String): Boolean {
return FileUtils.exist("${ttsFolderPath}$name.mp3")
}
private fun getSpeakFileAsMd5(name: String): File {
return File("${ttsFolderPath}$name.mp3")
}
private fun createSpeakFile(name: String): File {
return FileUtils.createFileIfNotExist("${ttsFolderPath}$name.mp3")
}
private fun createSpeakFile(name: String, inputStream: InputStream) {
FileUtils.createFileIfNotExist("${ttsFolderPath}$name.mp3").outputStream().use { out ->
inputStream.use {
it.copyTo(out)
}
}
}
/**
* 移除缓存文件
*/
private fun removeCacheFile() {
val titleMd5 = MD5Utils.md5Encode16(textChapter?.title ?: "")
FileUtils.listDirsAndFiles(ttsFolderPath)?.forEach {
val isSilentSound = it.length() == 2160L
if ((!it.name.startsWith(titleMd5)
&& System.currentTimeMillis() - it.lastModified() > 600000)
|| isSilentSound
) {
FileUtils.delete(it.absolutePath)
}
}
}
override fun pauseReadAloud(abandonFocus: Boolean) {
super.pauseReadAloud(abandonFocus)
kotlin.runCatching {
playIndexJob?.cancel()
exoPlayer.pause()
}
}
override fun resumeReadAloud() {
super.resumeReadAloud()
kotlin.runCatching {
if (pageChanged) {
play()
} else {
exoPlayer.play()
upPlayPos()
}
}
}
private fun upPlayPos() {
playIndexJob?.cancel()
val textChapter = textChapter ?: return
playIndexJob = lifecycleScope.launch {
upTtsProgress(readAloudNumber + 1)
if (exoPlayer.duration <= 0) {
return@launch
}
val speakTextLength = contentList[nowSpeak].length
if (speakTextLength <= 0) {
return@launch
}
val sleep = exoPlayer.duration / speakTextLength
val start = speakTextLength * exoPlayer.currentPosition / exoPlayer.duration
for (i in start..contentList[nowSpeak].length) {
if (readAloudNumber + i > textChapter.getReadLength(pageIndex + 1)) {
pageIndex++
if (pageIndex < textChapter.pageSize) {
ReadBook.moveToNextPage()
upTtsProgress(readAloudNumber + i.toInt())
}
}
delay(sleep)
}
}
}
/**
* 更新朗读速度
*/
override fun upSpeechRate(reset: Boolean) {
downloadTask?.cancel()
exoPlayer.stop()
speechRate = AppConfig.speechRatePlay + 5
if (AppConfig.streamReadAloudAudio) {
downloadAndPlayAudiosStream()
} else {
downloadAndPlayAudios()
}
}
override fun onPlaybackStateChanged(playbackState: Int) {
super.onPlaybackStateChanged(playbackState)
when (playbackState) {
Player.STATE_IDLE -> {
// 空闲
}
Player.STATE_BUFFERING -> {
// 缓冲中
}
Player.STATE_READY -> {
// 准备好
if (pause) return
exoPlayer.play()
upPlayPos()
}
Player.STATE_ENDED -> {
// 结束
playErrorNo = 0
updateNextPos()
exoPlayer.stop()
exoPlayer.clearMediaItems()
}
}
}
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
when (reason) {
Player.TIMELINE_CHANGE_REASON_PLAYLIST_CHANGED -> {
if (!timeline.isEmpty && exoPlayer.playbackState == Player.STATE_IDLE) {
exoPlayer.prepare()
}
}
else -> {}
}
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED) return
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
playErrorNo = 0
}
updateNextPos()
upPlayPos()
}
override fun onPlayerError(error: PlaybackException) {
super.onPlayerError(error)
AppLog.put("朗读错误\n${contentList[nowSpeak]}", error)
deleteCurrentSpeakFile()
playErrorNo++
if (playErrorNo >= 5) {
toastOnUi("朗读连续5次错误, 最后一次错误代码(${error.localizedMessage})")
AppLog.put("朗读连续5次错误, 最后一次错误代码(${error.localizedMessage})", error)
pauseReadAloud()
} else {
if (exoPlayer.hasNextMediaItem()) {
exoPlayer.seekToNextMediaItem()
exoPlayer.prepare()
} else {
exoPlayer.clearMediaItems()
updateNextPos()
}
}
}
private fun deleteCurrentSpeakFile() {
if (AppConfig.streamReadAloudAudio) {
return
}
val mediaItem = exoPlayer.currentMediaItem ?: return
val filePath = mediaItem.localConfiguration!!.uri.path!!
File(filePath).delete()
}
override fun aloudServicePendingIntent(actionStr: String): PendingIntent? {
return servicePendingIntent<HttpReadAloudService>(actionStr)
}
inner class CustomLoadErrorHandlingPolicy : DefaultLoadErrorHandlingPolicy(0) {
override fun getRetryDelayMsFor(loadErrorInfo: LoadErrorHandlingPolicy.LoadErrorInfo): Long {
return C.TIME_UNSET
}
}
}
@@ -0,0 +1,8 @@
# android服务
* AudioPlayService 音频播放服务
* CheckSourceService 书源检测服务
* DownloadService 缓存服务
* HttpReadAloudService 在线朗读服务
* TTSReadAloudService tts朗读服务
* WebService web服务
@@ -0,0 +1,258 @@
package io.legato.kazusa.service
import android.app.PendingIntent
import android.speech.tts.TextToSpeech
import android.speech.tts.UtteranceProgressListener
import io.legato.kazusa.R
import io.legato.kazusa.constant.AppConst
import io.legato.kazusa.constant.AppLog
import io.legato.kazusa.constant.AppPattern
import io.legato.kazusa.exception.NoStackTraceException
import io.legato.kazusa.help.MediaHelp
import io.legato.kazusa.help.config.AppConfig
import io.legato.kazusa.help.coroutine.Coroutine
import io.legato.kazusa.lib.dialogs.SelectItem
import io.legato.kazusa.model.ReadAloud
import io.legato.kazusa.model.ReadBook
import io.legato.kazusa.utils.GSON
import io.legato.kazusa.utils.LogUtils
import io.legato.kazusa.utils.fromJsonObject
import io.legato.kazusa.utils.servicePendingIntent
import io.legato.kazusa.utils.toastOnUi
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
/**
* 本地朗读
*/
class TTSReadAloudService : BaseReadAloudService(), TextToSpeech.OnInitListener {
private var textToSpeech: TextToSpeech? = null
private var ttsInitFinish = false
private val ttsUtteranceListener = TTSUtteranceListener()
private var speakJob: Coroutine<*>? = null
private val TAG = "TTSReadAloudService"
override fun onCreate() {
super.onCreate()
initTts()
}
override fun onDestroy() {
super.onDestroy()
clearTTS()
}
@Synchronized
private fun initTts() {
ttsInitFinish = false
val engine = GSON.fromJsonObject<SelectItem<String>>(ReadAloud.ttsEngine).getOrNull()?.value
LogUtils.d(TAG, "initTts engine:$engine")
textToSpeech = if (engine.isNullOrBlank()) {
TextToSpeech(this, this)
} else {
TextToSpeech(this, this, engine)
}
upSpeechRate()
}
@Synchronized
fun clearTTS() {
textToSpeech?.runCatching {
stop()
shutdown()
}
textToSpeech = null
ttsInitFinish = false
}
override fun onInit(status: Int) {
if (status == TextToSpeech.SUCCESS) {
textToSpeech?.let {
it.setOnUtteranceProgressListener(ttsUtteranceListener)
ttsInitFinish = true
play()
}
} else {
toastOnUi(R.string.tts_init_failed)
}
}
@Synchronized
override fun play() {
if (!ttsInitFinish) return
if (!requestFocus()) return
if (contentList.isEmpty()) {
AppLog.putDebug("朗读列表为空")
ReadBook.readAloud()
return
}
super.play()
MediaHelp.playSilentSound(this@TTSReadAloudService)
speakJob?.cancel()
speakJob = execute {
LogUtils.d(TAG, "朗读列表大小 ${contentList.size}")
LogUtils.d(TAG, "朗读页数 ${textChapter?.pageSize}")
val tts = textToSpeech ?: throw NoStackTraceException("tts is null")
val contentList = contentList
var isAddedText = false
for (i in nowSpeak until contentList.size) {
ensureActive()
var text = contentList[i]
if (paragraphStartPos > 0 && i == nowSpeak) {
text = text.substring(paragraphStartPos)
}
if (text.matches(AppPattern.notReadAloudRegex)) {
continue
}
if (!isAddedText) {
val result = tts.runCatching {
speak(text, TextToSpeech.QUEUE_FLUSH, null, AppConst.APP_TAG + i)
}.getOrElse {
AppLog.put("tts出错\n${it.localizedMessage}", it, true)
TextToSpeech.ERROR
}
if (result == TextToSpeech.ERROR) {
AppLog.put("tts出错 尝试重新初始化")
clearTTS()
initTts()
return@execute
}
} else {
val result = tts.runCatching {
speak(text, TextToSpeech.QUEUE_ADD, null, AppConst.APP_TAG + i)
}.getOrElse {
AppLog.put("tts出错\n${it.localizedMessage}", it, true)
TextToSpeech.ERROR
}
if (result == TextToSpeech.ERROR) {
AppLog.put("tts朗读出错:$text")
}
}
isAddedText = true
}
LogUtils.d(TAG, "朗读内容添加完成")
if (!isAddedText) {
playStop()
delay(1000)
nextChapter()
}
}.onError {
AppLog.put("tts朗读出错\n${it.localizedMessage}", it, true)
}
}
override fun playStop() {
textToSpeech?.runCatching {
stop()
}
}
/**
* 更新朗读速度
*/
override fun upSpeechRate(reset: Boolean) {
if (AppConfig.ttsFlowSys) {
if (reset) {
clearTTS()
initTts()
}
} else {
val speechRate = (AppConfig.ttsSpeechRate + 5) / 10f
textToSpeech?.setSpeechRate(speechRate)
}
}
/**
* 暂停朗读
*/
override fun pauseReadAloud(abandonFocus: Boolean) {
super.pauseReadAloud(abandonFocus)
speakJob?.cancel()
textToSpeech?.runCatching {
stop()
}
}
/**
* 恢复朗读
*/
override fun resumeReadAloud() {
super.resumeReadAloud()
play()
}
/**
* 朗读监听
*/
private inner class TTSUtteranceListener : UtteranceProgressListener() {
private val TAG = "TTSUtteranceListener"
override fun onStart(s: String) {
LogUtils.d(TAG, "onStart nowSpeak:$nowSpeak pageIndex:$pageIndex utteranceId:$s")
textChapter?.let {
if (contentList[nowSpeak].matches(AppPattern.notReadAloudRegex)) {
nextParagraph()
}
if (readAloudNumber + 1 > it.getReadLength(pageIndex + 1)) {
pageIndex++
ReadBook.moveToNextPage()
}
upTtsProgress(readAloudNumber + 1)
}
}
override fun onDone(s: String) {
LogUtils.d(TAG, "onDone utteranceId:$s")
nextParagraph()
}
override fun onRangeStart(utteranceId: String?, start: Int, end: Int, frame: Int) {
super.onRangeStart(utteranceId, start, end, frame)
val msg =
"onRangeStart nowSpeak:$nowSpeak pageIndex:$pageIndex utteranceId:$utteranceId start:$start end:$end frame:$frame"
LogUtils.d(TAG, msg)
textChapter?.let {
if (readAloudNumber + start > it.getReadLength(pageIndex + 1)) {
pageIndex++
ReadBook.moveToNextPage()
upTtsProgress(readAloudNumber + start)
}
}
}
override fun onError(utteranceId: String?, errorCode: Int) {
LogUtils.d(
TAG,
"onError nowSpeak:$nowSpeak pageIndex:$pageIndex utteranceId:$utteranceId errorCode:$errorCode"
)
nextParagraph()
}
private fun nextParagraph() {
//跳过全标点段落
do {
readAloudNumber += contentList[nowSpeak].length + 1 - paragraphStartPos
paragraphStartPos = 0
nowSpeak++
if (nowSpeak >= contentList.size) {
nextChapter()
return
}
} while (contentList[nowSpeak].matches(AppPattern.notReadAloudRegex))
}
@Deprecated("Deprecated in Java")
override fun onError(s: String) {
LogUtils.d(TAG, "onError nowSpeak:$nowSpeak pageIndex:$pageIndex s:$s")
nextParagraph()
}
}
override fun aloudServicePendingIntent(actionStr: String): PendingIntent? {
return servicePendingIntent<TTSReadAloudService>(actionStr)
}
}
@@ -0,0 +1,230 @@
package io.legato.kazusa.service
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.net.wifi.WifiManager
import android.os.Build
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import io.legato.kazusa.R
import io.legato.kazusa.base.BaseService
import io.legato.kazusa.constant.AppConst
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.receiver.NetworkChangedListener
import io.legato.kazusa.utils.NetworkUtils
import io.legato.kazusa.utils.getPrefBoolean
import io.legato.kazusa.utils.getPrefInt
import io.legato.kazusa.utils.postEvent
import io.legato.kazusa.utils.printOnDebug
import io.legato.kazusa.utils.sendToClip
import io.legato.kazusa.utils.servicePendingIntent
import io.legato.kazusa.utils.startForegroundServiceCompat
import io.legato.kazusa.utils.startService
import io.legato.kazusa.utils.stopService
import io.legato.kazusa.utils.toastOnUi
import io.legato.kazusa.web.HttpServer
import io.legato.kazusa.web.WebSocketServer
import splitties.init.appCtx
import splitties.systemservices.powerManager
import splitties.systemservices.wifiManager
import java.io.IOException
class WebService : BaseService() {
companion object {
var isRun = false
var hostAddress = ""
fun start(context: Context) {
context.startService<WebService>()
}
fun startForeground(context: Context) {
val intent = Intent(context, WebService::class.java)
context.startForegroundServiceCompat(intent)
}
fun stop(context: Context) {
context.stopService<WebService>()
}
fun serve() {
appCtx.startService<WebService> {
action = "serve"
}
}
}
private val useWakeLock = appCtx.getPrefBoolean(PreferKey.webServiceWakeLock, false)
private val wakeLock: PowerManager.WakeLock by lazy {
powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "legado:webService")
.apply {
setReferenceCounted(false)
}
}
private val wifiLock by lazy {
@Suppress("DEPRECATION")
wifiManager?.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "legado:AudioPlayService")
?.apply {
setReferenceCounted(false)
}
}
private var httpServer: HttpServer? = null
private var webSocketServer: WebSocketServer? = null
private var notificationList = mutableListOf(appCtx.getString(R.string.service_starting))
private val networkChangedListener by lazy {
NetworkChangedListener(this)
}
@SuppressLint("WakelockTimeout")
override fun onCreate() {
super.onCreate()
if (useWakeLock) {
wakeLock.acquire()
wifiLock?.acquire()
}
isRun = true
upTile(true)
networkChangedListener.register()
networkChangedListener.onNetworkChanged = {
val addressList = NetworkUtils.getLocalIPAddress()
notificationList.clear()
if (addressList.any()) {
notificationList.addAll(addressList.map { address ->
getString(
R.string.http_ip,
address.hostAddress,
getPort()
)
})
hostAddress = notificationList.first()
} else {
hostAddress = getString(R.string.network_connection_unavailable)
notificationList.add(hostAddress)
}
startForegroundNotification()
postEvent(EventBus.WEB_SERVICE, hostAddress)
}
}
@SuppressLint("WakelockTimeout")
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
IntentAction.stop -> stopSelf()
"copyHostAddress" -> sendToClip(hostAddress)
"serve" -> if (useWakeLock) {
wakeLock.acquire()
wifiLock?.acquire()
}
else -> upWebServer()
}
return super.onStartCommand(intent, flags, startId)
}
override fun onDestroy() {
super.onDestroy()
if (useWakeLock) {
wakeLock.release()
wifiLock?.release()
}
networkChangedListener.unRegister()
isRun = false
if (httpServer?.isAlive == true) {
httpServer?.stop()
}
if (webSocketServer?.isAlive == true) {
webSocketServer?.stop()
}
postEvent(EventBus.WEB_SERVICE, "")
upTile(false)
}
private fun upWebServer() {
if (httpServer?.isAlive == true) {
httpServer?.stop()
}
if (webSocketServer?.isAlive == true) {
webSocketServer?.stop()
}
val addressList = NetworkUtils.getLocalIPAddress()
if (addressList.any()) {
val port = getPort()
httpServer = HttpServer(port)
webSocketServer = WebSocketServer(port + 1)
try {
httpServer?.start()
webSocketServer?.start(1000 * 30) // 通信超时设置
notificationList.clear()
notificationList.addAll(addressList.map { address ->
getString(
R.string.http_ip,
address.hostAddress,
getPort()
)
})
hostAddress = notificationList.first()
isRun = true
postEvent(EventBus.WEB_SERVICE, hostAddress)
startForegroundNotification()
} catch (e: IOException) {
toastOnUi(e.localizedMessage ?: "")
e.printOnDebug()
stopSelf()
}
} else {
toastOnUi("web service cant start, no ip address")
stopSelf()
}
}
private fun getPort(): Int {
var port = getPrefInt(PreferKey.webPort, 1122)
if (port > 65530 || port < 1024) {
port = 1122
}
return port
}
/**
* 更新通知
*/
override fun startForegroundNotification() {
val builder = NotificationCompat.Builder(this, AppConst.channelIdWeb)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setSmallIcon(R.drawable.ic_web_service_noti)
.setOngoing(true)
.setContentTitle(getString(R.string.web_service))
.setContentText(notificationList.joinToString("\n"))
.setContentIntent(
servicePendingIntent<WebService>("copyHostAddress")
)
builder.addAction(
R.drawable.ic_stop_black_24dp,
getString(R.string.cancel),
servicePendingIntent<WebService>(IntentAction.stop)
)
val notification = builder.build()
startForeground(NotificationId.WebService, notification)
}
@SuppressLint("ObsoleteSdkInt")
private fun upTile(active: Boolean) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
kotlin.runCatching {
startService<WebTileService> {
action = if (active) {
IntentAction.start
} else {
IntentAction.stop
}
}
}
}
}
}
@@ -0,0 +1,79 @@
package io.legato.kazusa.service
import android.app.Dialog
import android.app.ForegroundServiceStartNotAllowedException
import android.content.Intent
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import android.view.WindowManager.BadTokenException
import androidx.annotation.RequiresApi
import io.legato.kazusa.R
import io.legato.kazusa.constant.IntentAction
import io.legato.kazusa.utils.printOnDebug
/**
* web服务快捷开关
*/
@RequiresApi(Build.VERSION_CODES.N)
class WebTileService : TileService() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
try {
when (intent?.action) {
IntentAction.start -> qsTile?.run {
state = Tile.STATE_ACTIVE
updateTile()
}
IntentAction.stop -> qsTile?.run {
state = Tile.STATE_INACTIVE
updateTile()
}
}
} catch (e: Exception) {
e.printOnDebug()
}
return super.onStartCommand(intent, flags, startId)
}
override fun onStartListening() {
super.onStartListening()
qsTile?.run {
state = if (WebService.isRun) {
Tile.STATE_ACTIVE
} else {
Tile.STATE_INACTIVE
}
updateTile()
}
}
override fun onClick() {
super.onClick()
if (WebService.isRun) {
WebService.stop(this)
} else {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
val dialog = Dialog(this, R.style.AppTheme_Transparent)
dialog.setOnShowListener {
try {
WebService.startForeground(this)
} catch (e: ForegroundServiceStartNotAllowedException) {
e.printStackTrace()
}
dialog.dismiss()
}
try {
showDialog(dialog)
} catch (e: BadTokenException) {
e.printStackTrace()
}
} else {
WebService.start(this)
}
}
}
}