Rename
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Reference in new issue
Block a user