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

576 lines
16 KiB
Kotlin
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package jp.juggler.subwaytooter
import android.annotation.SuppressLint
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.media.Ringtone
import android.media.RingtoneManager
import android.net.Uri
import android.os.AsyncTask
import android.os.Handler
import android.os.SystemClock
import android.speech.tts.TextToSpeech
import android.speech.tts.Voice
import android.text.Spannable
import org.apache.commons.io.IOUtils
import org.json.JSONArray
import org.json.JSONException
import org.json.JSONObject
import java.io.ByteArrayOutputStream
import java.io.FileNotFoundException
import java.lang.ref.WeakReference
import java.util.ArrayList
import java.util.Arrays
import java.util.HashSet
import java.util.LinkedList
import java.util.Locale
import java.util.Random
import java.util.regex.Pattern
import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.table.HighlightWord
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.span.MyClickableSpan
import jp.juggler.subwaytooter.table.MutedApp
import jp.juggler.subwaytooter.table.MutedWord
import jp.juggler.subwaytooter.util.*
import java.io.File
class AppState(internal val context : Context, 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 = Pattern.compile("[\\s ]+")
private var utteranceIdSeed = 0
// データ保存用 および カラム一覧への伝達用
internal fun saveColumnList(context : Context, fileName : String, array : JSONArray) {
try {
context.openFileOutput(fileName, Context.MODE_PRIVATE).use { os ->
os.write(array.toString().encodeUTF8())
}
} catch(ex : Throwable) {
log.trace(ex)
showToast(context, ex, "saveColumnList failed.")
}
}
// データ保存用 および カラム一覧への伝達用
internal fun loadColumnList(context : Context, fileName : String) : JSONArray? {
try {
context.openFileInput(fileName).use { inData ->
val bao = ByteArrayOutputStream(inData.available())
IOUtils.copy(inData, bao)
return bao.toByteArray().decodeUTF8().toJsonArray()
}
} catch(ignored : FileNotFoundException) {
} catch(ex : Throwable) {
log.trace(ex)
showToast(context, 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 handler : Handler
internal val stream_reader : StreamReader
internal var media_thumb_height : Int = 0
val column_list = ArrayList<Column>()
private val map_busy_fav = HashSet<String>()
private val map_busy_boost = HashSet<String>()
internal var attachment_list : ArrayList<PostAttachment>? = null
private var willSpeechEnabled : Boolean = false
private var tts : TextToSpeech? = null
private var tts_status = TTS_STATUS_NONE
private var tts_speak_start = 0L
private var tts_speak_end = 0L
private val voice_list = ArrayList<Voice>()
private val tts_queue = LinkedList<String>()
private val duplication_check = LinkedList<String>()
private var last_ringtone : WeakReference<Ringtone>? = null
private var last_sound : Long = 0
val networkTracker : NetworkStateTracker
// initからプロパティにアクセスする場合、そのプロパティはinitより上で定義されていないとダメっぽい
// そしてその他のメソッドからval プロパティにアクセスする場合、そのプロパティはメソッドより上で初期化されていないとダメっぽい
init {
this.handler = Handler()
this.density = context.resources.displayMetrics.density
this.stream_reader = StreamReader(context, handler, pref)
this.networkTracker = NetworkStateTracker(context)
loadColumnList()
}
//////////////////////////////////////////////////////
// TextToSpeech
private val isTextToSpeechRequired : Boolean
get() {
var b = false
for(c in column_list) {
if(c.enable_speech) {
b = true
break
}
}
return b
}
private val tts_receiver = 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.")
tts_speak_end = SystemClock.elapsedRealtime()
handler.post(proc_flushSpeechQueue)
}
}
}
}
private val proc_flushSpeechQueue = object : Runnable {
override fun run() {
try {
handler.removeCallbacks(this)
val queue_count = tts_queue.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(tts_speak_start > 0L) {
if(tts_speak_start >= tts_speak_end) {
// まだ終了イベントを受け取っていない
val expire_remain = tts_speak_wait_expire + tts_speak_start - now
if(expire_remain <= 0) {
log.d("proc_flushSpeechQueue: tts_speak wait expired.")
restartTTS()
} else {
log.d(
"proc_flushSpeechQueue: tts is speaking. queue_count=%d, expire_remain=%.3f",
queue_count,
expire_remain / 1000f
)
handler.postDelayed(this, expire_remain)
return
}
return
}
}
val sv = tts_queue.removeFirst()
log.d("proc_flushSpeechQueue: speak %s", sv)
val voice_count = voice_list.size
if(voice_count > 0) {
val n = random.nextInt(voice_count)
tts.voice = voice_list[n]
}
tts_speak_start = now
tts.speak(
sv,
TextToSpeech.QUEUE_ADD,
null, // Bundle params
Integer.toString(++ utteranceIdSeed) // 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
tts_status = TTS_STATUS_NONE
enableSpeech()
}
}
internal fun encodeColumnList() : JSONArray {
val array = JSONArray()
var i = 0
val ie = column_list.size
while(i < ie) {
val column = column_list[i]
try {
val dst = JSONObject()
column.encodeJSON(dst, i)
array.put(dst)
} catch(ex : JSONException) {
log.trace(ex)
}
++ i
}
return array
}
internal fun saveColumnList(bEnableSpeech : Boolean = true) {
val array = encodeColumnList()
saveColumnList(context, FILE_COLUMN_LIST, array)
if(bEnableSpeech) enableSpeech()
}
private fun loadColumnList() {
val array = loadColumnList(context, FILE_COLUMN_LIST)
if(array != null) {
var i = 0
val ie = array.length()
while(i < ie) {
try {
val src = array.optJSONObject(i)
val col = Column(this, src)
column_list.add(col)
} catch(ex : Throwable) {
log.trace(ex)
}
++ i
}
}
enableSpeech()
// ミュートデータのロード
TootStatus.muted_app = MutedApp.nameSet
TootStatus.muted_word = MutedWord.nameSet
// 背景フォルダの掃除
try {
val backgroundImageDir = Column.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 = Column.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 + ":" + status.busyKey
return map_busy_fav.contains(key)
}
fun setBusyFav(account : SavedAccount, status : TootStatus) {
val key = account.acct + ":" + status.busyKey
map_busy_fav.add(key)
}
fun resetBusyFav(account : SavedAccount, status : TootStatus) {
val key = account.acct + ":" + status.busyKey
map_busy_fav.remove(key)
}
fun isBusyBoost(account : SavedAccount, status : TootStatus) : Boolean {
val key = account.acct + ":" + status.busyKey
return map_busy_boost.contains(key)
}
fun setBusyBoost(account : SavedAccount, status : TootStatus) {
val key = account.acct + ":" + status.busyKey
map_busy_boost.add(key)
}
fun resetBusyBoost(account : SavedAccount, status : TootStatus) {
val key = account.acct + ":" + status.busyKey
map_busy_boost.remove(key)
}
@SuppressLint("StaticFieldLeak")
private fun enableSpeech() {
this.willSpeechEnabled = isTextToSpeechRequired
if(willSpeechEnabled && tts == null && tts_status == TTS_STATUS_NONE) {
tts_status = TTS_STATUS_INITIALIZING
showToast(context, false, R.string.text_to_speech_initializing)
log.d("initializing TextToSpeech…")
object : AsyncTask<Void, Void, TextToSpeech?>() {
var tmp_tts : TextToSpeech? = null
override fun doInBackground(vararg params : Void) : TextToSpeech {
val tts = TextToSpeech(context, tts_init_listener)
this.tmp_tts = tts
return tts
}
val tts_init_listener : TextToSpeech.OnInitListener =
TextToSpeech.OnInitListener { status ->
val tts = this.tmp_tts
if(tts == null || TextToSpeech.SUCCESS != status) {
showToast(
context,
false,
R.string.text_to_speech_initialize_failed,
status
)
log.d("speech initialize failed. status=%s", status)
return@OnInitListener
}
runOnMainLooper {
if(! willSpeechEnabled) {
showToast(context, false, R.string.text_to_speech_shutdown)
log.d("shutdown TextToSpeech…")
tts.shutdown()
} else {
this@AppState.tts = tts
tts_status = TTS_STATUS_INITIALIZED
tts_speak_start = 0L
tts_speak_end = 0L
voice_list.clear()
try {
val voice_set = try {
tts.voices
// may raise NullPointerException is tts has no collection
} catch(ignored : Throwable) {
null
}
if(voice_set == null || voice_set.isEmpty()) {
log.d("TextToSpeech.getVoices returns null or empty set.")
} else {
val lang = Locale.getDefault().toLanguageTag()
for(v in voice_set) {
log.d(
"Voice %s %s %s",
v.name,
v.locale.toLanguageTag(),
lang
)
if(lang != v.locale.toLanguageTag()) {
continue
}
voice_list.add(v)
}
}
} catch(ex : Throwable) {
log.trace(ex)
log.e(ex, "TextToSpeech.getVoices raises exception.")
}
handler.post(proc_flushSpeechQueue)
context.registerReceiver(
tts_receiver,
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 );
// }
// } );
}
}
}
}.executeOnExecutor(App1.task_executor)
}
if(! willSpeechEnabled && tts != null) {
showToast(context, false, R.string.text_to_speech_shutdown)
log.d("shutdown TextToSpeech…")
tts?.shutdown()
tts = null
tts_status = TTS_STATUS_NONE
}
}
internal fun addSpeech(status : TootStatus) {
if(tts == null) return
val text = getStatusText(status)
if(text == null || text.length == 0) return
val span_list = text.getSpans(0, text.length, MyClickableSpan::class.java)
if(span_list == null || span_list.isEmpty()) {
addSpeech(text.toString())
return
}
Arrays.sort(span_list) { a, b ->
val a_start = text.getSpanStart(a)
val b_start = text.getSpanStart(b)
a_start - b_start
}
val str_text = text.toString()
val sb = StringBuilder()
var last_end = 0
var has_url = false
for(span in span_list) {
val start = text.getSpanStart(span)
val end = text.getSpanEnd(span)
//
if(start > last_end) {
sb.append(str_text.substring(last_end, start))
}
last_end = end
//
val span_text = str_text.substring(start, end)
if(span_text.isNotEmpty()) {
val c = span_text[0]
if(c == '#' || c == '@') {
// #hashtag や @user はそのまま読み上げる
sb.append(span_text)
} else {
// それ以外はURL省略
has_url = true
sb.append(" ")
}
}
}
val text_end = str_text.length
if(text_end > last_end) {
sb.append(str_text.substring(last_end, text_end))
}
if(has_url) {
sb.append(context.getString(R.string.url_omitted))
}
addSpeech(sb.toString())
}
private fun addSpeech(text : String) {
if(tts == null) return
val sv = reSpaces.matcher(text).replaceAll(" ").trim { it <= ' ' }
if(sv.isEmpty()) return
for(check in duplication_check) {
if(check == sv) return
}
duplication_check.addLast(sv)
if(duplication_check.size >= 60) {
duplication_check.removeFirst()
}
tts_queue.add(sv)
if(tts_queue.size > 30) tts_queue.removeFirst()
handler.post(proc_flushSpeechQueue)
}
private fun stopLastRingtone() {
val r = last_ringtone?.get()
if(r != null) {
try {
r.stop()
} catch(ex : Throwable) {
log.trace(ex)
} finally {
last_ringtone = null
}
}
}
internal fun sound(item : HighlightWord) {
// 短時間に何度もならないようにする
val now = SystemClock.elapsedRealtime()
if(now - last_sound < 500L) return
last_sound = 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 ->
last_ringtone = 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
for(column in column_list) {
column.onMuteUpdated()
}
}
}