SubwayTooter-Android-App/app/src/main/java/jp/juggler/subwaytooter/AppState.kt

603 lines
22 KiB
Kotlin
Raw Normal View History

package jp.juggler.subwaytooter
import android.annotation.SuppressLint
2018-12-01 00:02:18 +01:00
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
2018-12-01 00:02:18 +01:00
import jp.juggler.subwaytooter.api.entity.TootStatus
2021-06-28 09:09:00 +02:00
import jp.juggler.subwaytooter.column.Column
import jp.juggler.subwaytooter.column.ColumnEncoder
import jp.juggler.subwaytooter.column.getBackgroundImageDir
import jp.juggler.subwaytooter.column.onMuteUpdated
2018-12-01 00:02:18 +01:00
import jp.juggler.subwaytooter.span.MyClickableSpan
2020-12-21 03:13:03 +01:00
import jp.juggler.subwaytooter.streaming.StreamManager
import jp.juggler.subwaytooter.table.*
2018-12-01 00:02:18 +01:00
import jp.juggler.subwaytooter.util.NetworkStateTracker
import jp.juggler.subwaytooter.util.PostAttachment
import jp.juggler.util.*
import jp.juggler.util.coroutine.launchIO
import jp.juggler.util.coroutine.runOnMainLooper
import jp.juggler.util.data.*
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.showToast
2018-12-01 00:02:18 +01:00
import java.io.File
import java.io.FileNotFoundException
import java.lang.ref.WeakReference
2018-12-01 00:02:18 +01:00
import java.util.*
import kotlin.math.max
enum class DedupMode {
2020-12-21 03:13:03 +01:00
None,
RecentExpire,
Recent,
}
class DedupItem(
2021-06-13 13:48:48 +02:00
val text: String,
2022-03-13 13:05:54 +01:00
var time: Long = SystemClock.elapsedRealtime(),
)
class AppState(
2021-06-13 13:48:48 +02:00
internal val context: Context,
internal val handler: Handler,
) {
2020-12-21 03:13:03 +01:00
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")
}
2021-06-28 09:09:00 +02:00
Unit
2020-12-21 03:13:03 +01:00
} finally {
tmpFile.delete() // ignore return value
}
} catch (ex: Throwable) {
log.e(ex, "saveColumnList failed.")
2020-12-21 03:13:03 +01:00
context.showToast(ex, "saveColumnList failed.")
}
}
}
internal fun loadColumnList(context: Context, fileName: String): JsonArray? {
synchronized(log) {
try {
2022-03-13 13:05:54 +01:00
return context.openFileInput(fileName).use { inData ->
inData.readBytes().decodeUTF8().decodeJsonArray()
2020-12-21 03:13:03 +01:00
}
} catch (ignored: FileNotFoundException) {
} catch (ex: Throwable) {
log.e(ex, "loadColumnList failed.")
2020-12-21 03:13:03 +01:00
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
2020-12-21 03:13:03 +01:00
private val _columnList = ArrayList<Column>()
2021-06-13 13:48:48 +02:00
// make shallow copy
val columnList: List<Column>
get() = synchronized(_columnList) { ArrayList(_columnList) }
2020-12-21 03:13:03 +01:00
val columnCount: Int
get() = synchronized(_columnList) { _columnList.size }
fun column(i: Int) =
synchronized(_columnList) { _columnList.elementAtOrNull(i) }
fun columnIndex(column: Column?) =
2021-06-13 13:48:48 +02:00
synchronized(_columnList) { _columnList.indexOf(column).takeIf { it != -1 } }
2020-12-21 03:13:03 +01:00
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
2020-12-21 03:13:03 +01:00
private var willSpeechEnabled: Boolean = false
private var tts: TextToSpeech? = null
private var ttsStatus = TTS_STATUS_NONE
private var ttsSpeakStart = 0L
private var ttsSpeakEnd = 0L
2020-12-21 03:13:03 +01:00
private val voiceList = ArrayList<Voice>()
2020-12-21 03:13:03 +01:00
private val ttsQueue = LinkedList<String>()
2020-12-21 03:13:03 +01:00
private val duplicationCheck = LinkedList<DedupItem>()
2020-12-21 03:13:03 +01:00
private var lastRingtone: WeakReference<Ringtone>? = null
2020-12-21 03:13:03 +01:00
private var lastSound: Long = 0
2020-12-21 03:13:03 +01:00
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 } || daoHighlightWord.hasTextToSpeechHighlightWord()
2020-12-21 03:13:03 +01:00
private val ttsReceiver = object : BroadcastReceiver() {
2020-12-21 03:13:03 +01:00
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)
2020-12-21 03:13:03 +01:00
}
}
}
}
private val procFlushSpeechQueue = object : Runnable {
2020-12-21 03:13:03 +01:00
override fun run() {
try {
handler.removeCallbacks(this)
val queue_count = ttsQueue.size
2020-12-21 03:13:03 +01:00
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)) {
2020-12-21 03:13:03 +01:00
// まだ終了イベントを受け取っていない
val expire_remain = tts_speak_wait_expire + ttsSpeakStart - now
2020-12-21 03:13:03 +01:00
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=${
"%.3f".format(expire_remain.div(1000f))
2021-06-13 13:48:48 +02:00
}"
)
2020-12-21 03:13:03 +01:00
handler.postDelayed(this, expire_remain)
return
}
return
}
val sv = ttsQueue.removeFirst()
log.d("proc_flushSpeechQueue: speak $sv")
2020-12-21 03:13:03 +01:00
val voice_count = voiceList.size
2020-12-21 03:13:03 +01:00
if (voice_count > 0) {
val n = random.nextInt(voice_count)
tts.voice = voiceList[n]
2020-12-21 03:13:03 +01:00
}
ttsSpeakStart = now
2020-12-21 03:13:03 +01:00
tts.speak(
2021-06-13 13:48:48 +02:00
sv,
TextToSpeech.QUEUE_ADD,
null, // Bundle params
(++utteranceIdSeed).toString() // String utteranceId
)
2020-12-21 03:13:03 +01:00
} catch (ex: Throwable) {
log.e(ex, "proc_flushSpeechQueue catch exception.")
restartTTS()
}
}
fun restartTTS() {
log.d("restart TextToSpeech")
tts?.shutdown()
tts = null
ttsStatus = TTS_STATUS_NONE
2020-12-21 03:13:03 +01:00
enableSpeech()
}
}
internal fun encodeColumnList() =
columnList.mapIndexedNotNull { index, column ->
try {
val dst = JsonObject()
2021-06-13 13:48:48 +02:00
ColumnEncoder.encode(column, dst, index)
2020-12-21 03:13:03 +01:00
dst
} catch (ex: JsonException) {
log.e(ex, "encodeColumnList: encode failed at $index.")
2020-12-21 03:13:03 +01:00
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()
?.mapIndexedNotNull { index, src ->
2020-12-21 03:13:03 +01:00
try {
Column(this, src)
} catch (ex: Throwable) {
log.e(ex, "loadColumnList: decode column failed at $index")
2020-12-21 03:13:03 +01:00
null
}
}
if (list != null) editColumnList(save = false) { it.addAll(list) }
// ミュートデータのロード
TootStatus.muted_app = daoMutedApp.nameSet()
TootStatus.muted_word = daoMutedWord.nameSet()
2020-12-21 03:13:03 +01:00
// 背景フォルダの掃除
try {
2021-05-17 16:13:04 +02:00
val backgroundImageDir = getBackgroundImageDir(context)
2020-12-21 03:13:03 +01:00
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
2021-05-17 16:13:04 +02:00
val column = ColumnEncoder.findColumnById(id)
2020-12-21 03:13:03 +01:00
if (column == null) file.delete()
}
}
} catch (ex: Throwable) {
// クラッシュレポートによると状態が悪いとダメらしい
// java.lang.IllegalStateException
log.e(ex, "loadColumnList failed.")
2020-12-21 03:13:03 +01:00
}
}
fun isBusyFav(account: SavedAccount, status: TootStatus): Boolean {
val key = account.acct.ascii + ":" + status.busyKey
return mapBusyFav.contains(key)
2020-12-21 03:13:03 +01:00
}
fun setBusyFav(account: SavedAccount, status: TootStatus) {
val key = account.acct.ascii + ":" + status.busyKey
mapBusyFav.add(key)
2020-12-21 03:13:03 +01:00
}
fun resetBusyFav(account: SavedAccount, status: TootStatus) {
val key = account.acct.ascii + ":" + status.busyKey
mapBusyFav.remove(key)
2020-12-21 03:13:03 +01:00
}
fun isBusyBookmark(account: SavedAccount, status: TootStatus): Boolean {
val key = account.acct.ascii + ":" + status.busyKey
return mapBusyBookmark.contains(key)
2020-12-21 03:13:03 +01:00
}
fun setBusyBookmark(account: SavedAccount, status: TootStatus) {
val key = account.acct.ascii + ":" + status.busyKey
mapBusyBookmark.add(key)
2020-12-21 03:13:03 +01:00
}
fun resetBusyBookmark(account: SavedAccount, status: TootStatus) {
val key = account.acct.ascii + ":" + status.busyKey
mapBusyBookmark.remove(key)
2020-12-21 03:13:03 +01:00
}
fun isBusyBoost(account: SavedAccount, status: TootStatus): Boolean {
val key = account.acct.ascii + ":" + status.busyKey
return mapBusyBoost.contains(key)
2020-12-21 03:13:03 +01:00
}
fun setBusyBoost(account: SavedAccount, status: TootStatus) {
val key = account.acct.ascii + ":" + status.busyKey
mapBusyBoost.add(key)
2020-12-21 03:13:03 +01:00
}
fun resetBusyBoost(account: SavedAccount, status: TootStatus) {
val key = account.acct.ascii + ":" + status.busyKey
mapBusyBoost.remove(key)
2020-12-21 03:13:03 +01:00
}
@SuppressLint("StaticFieldLeak")
fun enableSpeech() {
this.willSpeechEnabled = isTextToSpeechRequired
if (willSpeechEnabled && tts == null && ttsStatus == TTS_STATUS_NONE) {
ttsStatus = TTS_STATUS_INITIALIZING
2020-12-21 03:13:03 +01:00
context.showToast(false, R.string.text_to_speech_initializing)
log.d("initializing TextToSpeech…")
2021-06-13 13:48:48 +02:00
launchIO {
2020-12-21 03:13:03 +01:00
var tmpTts: TextToSpeech? = null
2020-12-21 03:13:03 +01:00
val ttsInitListener: TextToSpeech.OnInitListener =
2020-12-21 03:13:03 +01:00
TextToSpeech.OnInitListener { status ->
val tts = tmpTts
2020-12-21 03:13:03 +01:00
if (tts == null || TextToSpeech.SUCCESS != status) {
context.showToast(
2021-06-13 13:48:48 +02:00
false,
R.string.text_to_speech_initialize_failed,
status
)
log.d("speech initialize failed. status=$status")
2020-12-21 03:13:03 +01:00
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
2020-12-21 03:13:03 +01:00
voiceList.clear()
2020-12-21 03:13:03 +01:00
try {
val voiceSet = try {
2020-12-21 03:13:03 +01:00
tts.voices
// may raise NullPointerException is tts has no collection
} catch (ignored: Throwable) {
null
}
if (voiceSet == null || voiceSet.isEmpty()) {
2020-12-21 03:13:03 +01:00
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")
2021-06-13 13:48:48 +02:00
if (lang != v.locale.toLanguageTag()) continue
voiceList.add(v)
2020-12-21 03:13:03 +01:00
}
}
} catch (ex: Throwable) {
log.e(ex, "TextToSpeech.getVoices raises exception.")
}
handler.post(procFlushSpeechQueue)
2020-12-21 03:13:03 +01:00
context.registerReceiver(
ttsReceiver,
2021-06-13 13:48:48 +02:00
IntentFilter(TextToSpeech.ACTION_TTS_QUEUE_PROCESSING_COMPLETED)
)
2020-12-21 03:13:03 +01:00
// 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)
2020-12-21 03:13:03 +01:00
}
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
2020-12-21 03:13:03 +01:00
}
}
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()) {
2020-12-21 03:13:03 +01:00
addSpeech(text.toString())
return
}
Arrays.sort(spanList) { a, b ->
val aStart = text.getSpanStart(a)
val bStart = text.getSpanStart(b)
aStart - bStart
2020-12-21 03:13:03 +01:00
}
val strText = text.toString()
2020-12-21 03:13:03 +01:00
val sb = StringBuilder()
var lastEnd = 0
var hasUrl = false
for (span in spanList) {
2020-12-21 03:13:03 +01:00
val start = text.getSpanStart(span)
val end = text.getSpanEnd(span)
//
if (start > lastEnd) {
sb.append(strText.substring(lastEnd, start))
2020-12-21 03:13:03 +01:00
}
lastEnd = end
2020-12-21 03:13:03 +01:00
//
val spanText = strText.substring(start, end)
if (spanText.isNotEmpty()) {
val c = spanText[0]
2020-12-21 03:13:03 +01:00
if (c == '#' || c == '@') {
// #hashtag や @user はそのまま読み上げる
sb.append(spanText)
2020-12-21 03:13:03 +01:00
} else {
// それ以外はURL省略
hasUrl = true
2020-12-21 03:13:03 +01:00
sb.append(" ")
}
}
}
val textEnd = strText.length
if (textEnd > lastEnd) {
sb.append(strText.substring(lastEnd, textEnd))
2020-12-21 03:13:03 +01:00
}
if (hasUrl) {
2020-12-21 03:13:03 +01:00
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) }
2020-12-21 03:13:03 +01:00
if (check == null) {
duplicationCheck.addLast(DedupItem(sv))
if (duplicationCheck.size > 60) duplicationCheck.removeFirst()
2020-12-21 03:13:03 +01:00
} 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()
2020-12-21 03:13:03 +01:00
handler.post(procFlushSpeechQueue)
2020-12-21 03:13:03 +01:00
}
private fun stopLastRingtone() {
lastRingtone?.get()?.let { r ->
2020-12-21 03:13:03 +01:00
try {
r.stop()
} catch (ex: Throwable) {
log.e(ex, "stopLastRingtone failed.")
2020-12-21 03:13:03 +01:00
} finally {
lastRingtone = null
2020-12-21 03:13:03 +01:00
}
}
}
internal fun sound(item: HighlightWord) {
// 短時間に何度もならないようにする
val now = SystemClock.elapsedRealtime()
if (now - lastSound < 500L) return
lastSound = now
2020-12-21 03:13:03 +01:00
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)
2020-12-21 03:13:03 +01:00
ringTone.play()
return true
}
}
} catch (ex: Throwable) {
log.e(ex, "tryRingtone failed.")
2020-12-21 03:13:03 +01:00
}
return false
}
if (item.sound_type == HighlightWord.SOUND_TYPE_CUSTOM && item.sound_uri.mayUri()
2021-06-13 13:48:48 +02:00
.tryRingtone()
) return
2020-12-21 03:13:03 +01:00
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION).tryRingtone()
}
fun onMuteUpdated() {
TootStatus.muted_app = daoMutedApp.nameSet()
TootStatus.muted_word = daoMutedWord.nameSet()
2020-12-21 03:13:03 +01:00
columnList.forEach { it.onMuteUpdated() }
}
}