611 lines
22 KiB
Kotlin
611 lines
22 KiB
Kotlin
package jp.juggler.subwaytooter
|
||
|
||
import android.annotation.SuppressLint
|
||
import android.content.*
|
||
import android.media.Ringtone
|
||
import android.media.RingtoneManager
|
||
import android.net.Uri
|
||
import android.os.Handler
|
||
import android.os.SystemClock
|
||
import android.speech.tts.TextToSpeech
|
||
import android.speech.tts.Voice
|
||
import android.text.Spannable
|
||
import jp.juggler.subwaytooter.api.entity.TootStatus
|
||
import jp.juggler.subwaytooter.column.Column
|
||
import jp.juggler.subwaytooter.column.ColumnEncoder
|
||
import jp.juggler.subwaytooter.column.getBackgroundImageDir
|
||
import jp.juggler.subwaytooter.column.onMuteUpdated
|
||
import jp.juggler.subwaytooter.span.MyClickableSpan
|
||
import jp.juggler.subwaytooter.streaming.StreamManager
|
||
import jp.juggler.subwaytooter.table.HighlightWord
|
||
import jp.juggler.subwaytooter.table.MutedApp
|
||
import jp.juggler.subwaytooter.table.MutedWord
|
||
import jp.juggler.subwaytooter.table.SavedAccount
|
||
import jp.juggler.subwaytooter.util.NetworkStateTracker
|
||
import jp.juggler.subwaytooter.util.PostAttachment
|
||
import jp.juggler.util.*
|
||
import org.apache.commons.io.IOUtils
|
||
import java.io.ByteArrayOutputStream
|
||
import java.io.File
|
||
import java.io.FileNotFoundException
|
||
import java.lang.ref.WeakReference
|
||
import java.util.*
|
||
import kotlin.math.max
|
||
|
||
enum class DedupMode {
|
||
None,
|
||
RecentExpire,
|
||
Recent,
|
||
}
|
||
|
||
class DedupItem(
|
||
val text: String,
|
||
var time: Long = SystemClock.elapsedRealtime()
|
||
)
|
||
|
||
class AppState(
|
||
internal val context: Context,
|
||
internal val handler: Handler,
|
||
internal val pref: SharedPreferences
|
||
) {
|
||
|
||
companion object {
|
||
|
||
internal val log = LogCategory("AppState")
|
||
|
||
private const val FILE_COLUMN_LIST = "column_list"
|
||
|
||
private const val TTS_STATUS_NONE = 0
|
||
private const val TTS_STATUS_INITIALIZING = 1
|
||
private const val TTS_STATUS_INITIALIZED = 2
|
||
|
||
private const val tts_speak_wait_expire = 1000L * 100
|
||
private val random = Random()
|
||
|
||
private val reSpaces = "[\\s ]+".asciiPattern()
|
||
|
||
private var utteranceIdSeed = 0
|
||
|
||
internal fun saveColumnList(context: Context, fileName: String, array: JsonArray) {
|
||
synchronized(log) {
|
||
try {
|
||
val tmpName =
|
||
"tmpColumnList.${System.currentTimeMillis()}.${Thread.currentThread().id}"
|
||
val tmpFile = context.getFileStreamPath(tmpName)
|
||
try {
|
||
// write to tmp file
|
||
context.openFileOutput(tmpName, Context.MODE_PRIVATE).use { os ->
|
||
os.write(array.toString().encodeUTF8())
|
||
}
|
||
// rename
|
||
val outFile = context.getFileStreamPath(fileName)
|
||
if (!tmpFile.renameTo(outFile)) {
|
||
error("saveColumnList: rename failed!")
|
||
} else {
|
||
log.d("saveColumnList: rename ok: $outFile")
|
||
}
|
||
Unit
|
||
} finally {
|
||
tmpFile.delete() // ignore return value
|
||
}
|
||
} catch (ex: Throwable) {
|
||
log.trace(ex)
|
||
context.showToast(ex, "saveColumnList failed.")
|
||
}
|
||
}
|
||
}
|
||
|
||
internal fun loadColumnList(context: Context, fileName: String): JsonArray? {
|
||
synchronized(log) {
|
||
try {
|
||
context.openFileInput(fileName).use { inData ->
|
||
val bao = ByteArrayOutputStream(inData.available())
|
||
IOUtils.copy(inData, bao)
|
||
return bao.toByteArray().decodeUTF8().decodeJsonArray()
|
||
}
|
||
} catch (ignored: FileNotFoundException) {
|
||
} catch (ex: Throwable) {
|
||
log.trace(ex)
|
||
context.showToast(ex, "loadColumnList failed.")
|
||
}
|
||
|
||
return null
|
||
}
|
||
}
|
||
|
||
private fun getStatusText(status: TootStatus?): Spannable? {
|
||
return when {
|
||
status == null -> null
|
||
status.decoded_spoiler_text.isNotEmpty() -> status.decoded_spoiler_text
|
||
status.decoded_content.isNotEmpty() -> status.decoded_content
|
||
else -> null
|
||
}
|
||
}
|
||
}
|
||
|
||
internal val density: Float
|
||
|
||
internal val streamManager: StreamManager
|
||
|
||
internal var mediaThumbHeight: Int = 0
|
||
|
||
private val _columnList = ArrayList<Column>()
|
||
|
||
// make shallow copy
|
||
val columnList: List<Column>
|
||
get() = synchronized(_columnList) { ArrayList(_columnList) }
|
||
|
||
val columnCount: Int
|
||
get() = synchronized(_columnList) { _columnList.size }
|
||
|
||
fun column(i: Int) =
|
||
synchronized(_columnList) { _columnList.elementAtOrNull(i) }
|
||
|
||
fun columnIndex(column: Column?) =
|
||
synchronized(_columnList) { _columnList.indexOf(column).takeIf { it != -1 } }
|
||
|
||
fun editColumnList(save: Boolean = true, block: (ArrayList<Column>) -> Unit) {
|
||
synchronized(_columnList) {
|
||
block(_columnList)
|
||
if (save) saveColumnList()
|
||
}
|
||
}
|
||
|
||
private val mapBusyFav = HashSet<String>()
|
||
private val mapBusyBookmark = HashSet<String>()
|
||
private val mapBusyBoost = HashSet<String>()
|
||
internal var attachmentList: ArrayList<PostAttachment>? = null
|
||
|
||
private var willSpeechEnabled: Boolean = false
|
||
private var tts: TextToSpeech? = null
|
||
private var ttsStatus = TTS_STATUS_NONE
|
||
private var ttsSpeakStart = 0L
|
||
private var ttsSpeakEnd = 0L
|
||
|
||
private val voiceList = ArrayList<Voice>()
|
||
|
||
private val ttsQueue = LinkedList<String>()
|
||
|
||
private val duplicationCheck = LinkedList<DedupItem>()
|
||
|
||
private var lastRingtone: WeakReference<Ringtone>? = null
|
||
|
||
private var lastSound: Long = 0
|
||
|
||
val networkTracker: NetworkStateTracker
|
||
|
||
// initからプロパティにアクセスする場合、そのプロパティはinitより上で定義されていないとダメっぽい
|
||
// そしてその他のメソッドからval プロパティにアクセスする場合、そのプロパティはメソッドより上で初期化されていないとダメっぽい
|
||
|
||
init {
|
||
|
||
this.density = context.resources.displayMetrics.density
|
||
this.streamManager = StreamManager(this)
|
||
this.networkTracker = NetworkStateTracker(context) {
|
||
App1.custom_emoji_cache.onNetworkChanged()
|
||
App1.custom_emoji_lister.onNetworkChanged()
|
||
}
|
||
}
|
||
|
||
//////////////////////////////////////////////////////
|
||
// TextToSpeech
|
||
|
||
private val isTextToSpeechRequired: Boolean
|
||
get() = columnList.any { it.enableSpeech } || HighlightWord.hasTextToSpeechHighlightWord()
|
||
|
||
private val ttsReceiver = object : BroadcastReceiver() {
|
||
override fun onReceive(context: Context, intent: Intent?) {
|
||
if (intent != null) {
|
||
if (TextToSpeech.ACTION_TTS_QUEUE_PROCESSING_COMPLETED == intent.action) {
|
||
log.d("tts_receiver: speech completed.")
|
||
ttsSpeakEnd = SystemClock.elapsedRealtime()
|
||
handler.post(procFlushSpeechQueue)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
private val procFlushSpeechQueue = object : Runnable {
|
||
override fun run() {
|
||
try {
|
||
handler.removeCallbacks(this)
|
||
|
||
val queue_count = ttsQueue.size
|
||
if (queue_count <= 0) {
|
||
return
|
||
}
|
||
|
||
val tts = this@AppState.tts
|
||
if (tts == null) {
|
||
log.d("proc_flushSpeechQueue: tts is null")
|
||
return
|
||
}
|
||
|
||
val now = SystemClock.elapsedRealtime()
|
||
|
||
if (ttsSpeakStart >= max(1L, ttsSpeakEnd)) {
|
||
// まだ終了イベントを受け取っていない
|
||
val expire_remain = tts_speak_wait_expire + ttsSpeakStart - now
|
||
if (expire_remain <= 0) {
|
||
log.d("proc_flushSpeechQueue: tts_speak wait expired.")
|
||
restartTTS()
|
||
} else {
|
||
log.d(
|
||
"proc_flushSpeechQueue: tts is speaking. queue_count=$queue_count, expire_remain=${
|
||
expire_remain.div(1000f).toString("%.3f")
|
||
}"
|
||
)
|
||
handler.postDelayed(this, expire_remain)
|
||
return
|
||
}
|
||
return
|
||
}
|
||
|
||
val sv = ttsQueue.removeFirst()
|
||
log.d("proc_flushSpeechQueue: speak $sv")
|
||
|
||
val voice_count = voiceList.size
|
||
if (voice_count > 0) {
|
||
val n = random.nextInt(voice_count)
|
||
tts.voice = voiceList[n]
|
||
}
|
||
|
||
ttsSpeakStart = now
|
||
tts.speak(
|
||
sv,
|
||
TextToSpeech.QUEUE_ADD,
|
||
null, // Bundle params
|
||
(++utteranceIdSeed).toString() // String utteranceId
|
||
)
|
||
} catch (ex: Throwable) {
|
||
log.trace(ex)
|
||
log.e(ex, "proc_flushSpeechQueue catch exception.")
|
||
restartTTS()
|
||
}
|
||
}
|
||
|
||
fun restartTTS() {
|
||
log.d("restart TextToSpeech")
|
||
tts?.shutdown()
|
||
tts = null
|
||
ttsStatus = TTS_STATUS_NONE
|
||
enableSpeech()
|
||
}
|
||
}
|
||
|
||
internal fun encodeColumnList() =
|
||
columnList.mapIndexedNotNull { index, column ->
|
||
try {
|
||
val dst = JsonObject()
|
||
ColumnEncoder.encode(column, dst, index)
|
||
dst
|
||
} catch (ex: JsonException) {
|
||
log.trace(ex)
|
||
null
|
||
}
|
||
}.toJsonArray()
|
||
|
||
internal fun saveColumnList(bEnableSpeech: Boolean = true) {
|
||
val array = encodeColumnList()
|
||
saveColumnList(context, FILE_COLUMN_LIST, array)
|
||
if (bEnableSpeech) enableSpeech()
|
||
}
|
||
|
||
fun loadColumnList() {
|
||
val list = loadColumnList(context, FILE_COLUMN_LIST)
|
||
?.objectList()
|
||
?.mapNotNull { src ->
|
||
try {
|
||
Column(this, src)
|
||
} catch (ex: Throwable) {
|
||
log.trace(ex)
|
||
null
|
||
}
|
||
}
|
||
if (list != null) editColumnList(save = false) { it.addAll(list) }
|
||
|
||
// ミュートデータのロード
|
||
TootStatus.muted_app = MutedApp.nameSet
|
||
TootStatus.muted_word = MutedWord.nameSet
|
||
|
||
// 背景フォルダの掃除
|
||
try {
|
||
val backgroundImageDir = getBackgroundImageDir(context)
|
||
backgroundImageDir.list()?.forEach { name ->
|
||
val file = File(backgroundImageDir, name)
|
||
if (file.isFile) {
|
||
val delm = name.indexOf(':')
|
||
val id = if (delm != -1) name.substring(0, delm) else name
|
||
val column = ColumnEncoder.findColumnById(id)
|
||
if (column == null) file.delete()
|
||
}
|
||
}
|
||
} catch (ex: Throwable) {
|
||
// クラッシュレポートによると状態が悪いとダメらしい
|
||
// java.lang.IllegalStateException
|
||
log.trace(ex)
|
||
}
|
||
}
|
||
|
||
fun isBusyFav(account: SavedAccount, status: TootStatus): Boolean {
|
||
val key = account.acct.ascii + ":" + status.busyKey
|
||
return mapBusyFav.contains(key)
|
||
}
|
||
|
||
fun setBusyFav(account: SavedAccount, status: TootStatus) {
|
||
val key = account.acct.ascii + ":" + status.busyKey
|
||
mapBusyFav.add(key)
|
||
}
|
||
|
||
fun resetBusyFav(account: SavedAccount, status: TootStatus) {
|
||
val key = account.acct.ascii + ":" + status.busyKey
|
||
mapBusyFav.remove(key)
|
||
}
|
||
|
||
fun isBusyBookmark(account: SavedAccount, status: TootStatus): Boolean {
|
||
val key = account.acct.ascii + ":" + status.busyKey
|
||
return mapBusyBookmark.contains(key)
|
||
}
|
||
|
||
fun setBusyBookmark(account: SavedAccount, status: TootStatus) {
|
||
val key = account.acct.ascii + ":" + status.busyKey
|
||
mapBusyBookmark.add(key)
|
||
}
|
||
|
||
fun resetBusyBookmark(account: SavedAccount, status: TootStatus) {
|
||
val key = account.acct.ascii + ":" + status.busyKey
|
||
mapBusyBookmark.remove(key)
|
||
}
|
||
|
||
fun isBusyBoost(account: SavedAccount, status: TootStatus): Boolean {
|
||
val key = account.acct.ascii + ":" + status.busyKey
|
||
return mapBusyBoost.contains(key)
|
||
}
|
||
|
||
fun setBusyBoost(account: SavedAccount, status: TootStatus) {
|
||
val key = account.acct.ascii + ":" + status.busyKey
|
||
mapBusyBoost.add(key)
|
||
}
|
||
|
||
fun resetBusyBoost(account: SavedAccount, status: TootStatus) {
|
||
val key = account.acct.ascii + ":" + status.busyKey
|
||
mapBusyBoost.remove(key)
|
||
}
|
||
|
||
@SuppressLint("StaticFieldLeak")
|
||
fun enableSpeech() {
|
||
this.willSpeechEnabled = isTextToSpeechRequired
|
||
|
||
if (willSpeechEnabled && tts == null && ttsStatus == TTS_STATUS_NONE) {
|
||
ttsStatus = TTS_STATUS_INITIALIZING
|
||
context.showToast(false, R.string.text_to_speech_initializing)
|
||
log.d("initializing TextToSpeech…")
|
||
|
||
launchIO {
|
||
|
||
var tmpTts: TextToSpeech? = null
|
||
|
||
val ttsInitListener: TextToSpeech.OnInitListener =
|
||
TextToSpeech.OnInitListener { status ->
|
||
|
||
val tts = tmpTts
|
||
if (tts == null || TextToSpeech.SUCCESS != status) {
|
||
context.showToast(
|
||
false,
|
||
R.string.text_to_speech_initialize_failed,
|
||
status
|
||
)
|
||
log.d("speech initialize failed. status=$status")
|
||
return@OnInitListener
|
||
}
|
||
|
||
runOnMainLooper {
|
||
if (!willSpeechEnabled) {
|
||
context.showToast(false, R.string.text_to_speech_shutdown)
|
||
log.d("shutdown TextToSpeech…")
|
||
tts.shutdown()
|
||
} else {
|
||
this@AppState.tts = tts
|
||
ttsStatus = TTS_STATUS_INITIALIZED
|
||
ttsSpeakStart = 0L
|
||
ttsSpeakEnd = 0L
|
||
|
||
voiceList.clear()
|
||
try {
|
||
val voiceSet = try {
|
||
tts.voices
|
||
// may raise NullPointerException is tts has no collection
|
||
} catch (ignored: Throwable) {
|
||
null
|
||
}
|
||
if (voiceSet == null || voiceSet.isEmpty()) {
|
||
log.d("TextToSpeech.getVoices returns null or empty set.")
|
||
} else {
|
||
val lang = defaultLocale(context).toLanguageTag()
|
||
for (v in voiceSet) {
|
||
log.d("Voice ${v.name} ${v.locale.toLanguageTag()} $lang")
|
||
if (lang != v.locale.toLanguageTag()) continue
|
||
voiceList.add(v)
|
||
}
|
||
}
|
||
} catch (ex: Throwable) {
|
||
log.trace(ex)
|
||
log.e(ex, "TextToSpeech.getVoices raises exception.")
|
||
}
|
||
|
||
handler.post(procFlushSpeechQueue)
|
||
|
||
context.registerReceiver(
|
||
ttsReceiver,
|
||
IntentFilter(TextToSpeech.ACTION_TTS_QUEUE_PROCESSING_COMPLETED)
|
||
)
|
||
|
||
// tts.setOnUtteranceProgressListener( new UtteranceProgressListener() {
|
||
// @Override public void onStart( String utteranceId ){
|
||
// warning.d( "UtteranceProgressListener.onStart id=%s", utteranceId );
|
||
// }
|
||
//
|
||
// @Override public void onDone( String utteranceId ){
|
||
// warning.d( "UtteranceProgressListener.onDone id=%s", utteranceId );
|
||
// handler.post( proc_flushSpeechQueue );
|
||
// }
|
||
//
|
||
// @Override public void onError( String utteranceId ){
|
||
// warning.d( "UtteranceProgressListener.onError id=%s", utteranceId );
|
||
// handler.post( proc_flushSpeechQueue );
|
||
// }
|
||
// } );
|
||
}
|
||
}
|
||
}
|
||
|
||
tmpTts = TextToSpeech(context, ttsInitListener)
|
||
}
|
||
return
|
||
}
|
||
|
||
if (!willSpeechEnabled && tts != null) {
|
||
context.showToast(false, R.string.text_to_speech_shutdown)
|
||
log.d("shutdown TextToSpeech…")
|
||
tts?.shutdown()
|
||
tts = null
|
||
ttsStatus = TTS_STATUS_NONE
|
||
}
|
||
}
|
||
|
||
internal fun addSpeech(status: TootStatus) {
|
||
|
||
if (tts == null) return
|
||
|
||
val text = getStatusText(status)
|
||
if (text == null || text.length == 0) return
|
||
|
||
val spanList = text.getSpans(0, text.length, MyClickableSpan::class.java)
|
||
if (spanList == null || spanList.isEmpty()) {
|
||
addSpeech(text.toString())
|
||
return
|
||
}
|
||
Arrays.sort(spanList) { a, b ->
|
||
val aStart = text.getSpanStart(a)
|
||
val bStart = text.getSpanStart(b)
|
||
aStart - bStart
|
||
}
|
||
val strText = text.toString()
|
||
val sb = StringBuilder()
|
||
var lastEnd = 0
|
||
var hasUrl = false
|
||
for (span in spanList) {
|
||
val start = text.getSpanStart(span)
|
||
val end = text.getSpanEnd(span)
|
||
//
|
||
if (start > lastEnd) {
|
||
sb.append(strText.substring(lastEnd, start))
|
||
}
|
||
lastEnd = end
|
||
//
|
||
val spanText = strText.substring(start, end)
|
||
if (spanText.isNotEmpty()) {
|
||
val c = spanText[0]
|
||
if (c == '#' || c == '@') {
|
||
// #hashtag や @user はそのまま読み上げる
|
||
sb.append(spanText)
|
||
} else {
|
||
// それ以外はURL省略
|
||
hasUrl = true
|
||
sb.append(" ")
|
||
}
|
||
}
|
||
}
|
||
val textEnd = strText.length
|
||
if (textEnd > lastEnd) {
|
||
sb.append(strText.substring(lastEnd, textEnd))
|
||
}
|
||
if (hasUrl) {
|
||
sb.append(context.getString(R.string.url_omitted))
|
||
}
|
||
addSpeech(sb.toString())
|
||
}
|
||
|
||
internal fun addSpeech(text: String, dedupMode: DedupMode = DedupMode.Recent) {
|
||
|
||
if (tts == null) return
|
||
|
||
val sv = reSpaces.matcher(text).replaceAll(" ").trim { it <= ' ' }
|
||
if (sv.isEmpty()) return
|
||
|
||
if (dedupMode != DedupMode.None) {
|
||
synchronized(this) {
|
||
val check = duplicationCheck.find { it.text.equals(sv, ignoreCase = true) }
|
||
if (check == null) {
|
||
duplicationCheck.addLast(DedupItem(sv))
|
||
if (duplicationCheck.size > 60) duplicationCheck.removeFirst()
|
||
} else {
|
||
val now = SystemClock.elapsedRealtime()
|
||
val delta = now - check.time
|
||
// 古い項目が残っていることがあるので、check.timeの更新は必須
|
||
check.time = now
|
||
|
||
if (dedupMode == DedupMode.Recent) return
|
||
if (dedupMode == DedupMode.RecentExpire && delta < 5000L) return
|
||
}
|
||
}
|
||
}
|
||
|
||
ttsQueue.add(sv)
|
||
if (ttsQueue.size > 30) ttsQueue.removeFirst()
|
||
|
||
handler.post(procFlushSpeechQueue)
|
||
}
|
||
|
||
private fun stopLastRingtone() {
|
||
val r = lastRingtone?.get()
|
||
if (r != null) {
|
||
try {
|
||
r.stop()
|
||
} catch (ex: Throwable) {
|
||
log.trace(ex)
|
||
} finally {
|
||
lastRingtone = null
|
||
}
|
||
}
|
||
}
|
||
|
||
internal fun sound(item: HighlightWord) {
|
||
// 短時間に何度もならないようにする
|
||
val now = SystemClock.elapsedRealtime()
|
||
if (now - lastSound < 500L) return
|
||
lastSound = now
|
||
|
||
stopLastRingtone()
|
||
|
||
if (item.sound_type == HighlightWord.SOUND_TYPE_NONE) return
|
||
|
||
fun Uri?.tryRingtone(): Boolean {
|
||
try {
|
||
if (this != null) {
|
||
RingtoneManager.getRingtone(context, this)?.let { ringTone ->
|
||
lastRingtone = WeakReference(ringTone)
|
||
ringTone.play()
|
||
return true
|
||
}
|
||
}
|
||
} catch (ex: Throwable) {
|
||
log.trace(ex)
|
||
}
|
||
return false
|
||
}
|
||
|
||
if (item.sound_type == HighlightWord.SOUND_TYPE_CUSTOM && item.sound_uri.mayUri()
|
||
.tryRingtone()
|
||
) return
|
||
|
||
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION).tryRingtone()
|
||
}
|
||
|
||
fun onMuteUpdated() {
|
||
TootStatus.muted_app = MutedApp.nameSet
|
||
TootStatus.muted_word = MutedWord.nameSet
|
||
columnList.forEach { it.onMuteUpdated() }
|
||
}
|
||
}
|