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() private val map_busy_fav = HashSet() private val map_busy_boost = HashSet() internal var attachment_list : ArrayList? = 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() private val tts_queue = LinkedList() private val duplication_check = LinkedList() private var last_ringtone : WeakReference? = 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() { 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() } } }