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

471 lines
17 KiB
Kotlin

package jp.juggler.subwaytooter
import android.app.SearchManager
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.os.SystemClock
import android.text.Spanned
import android.text.style.BackgroundColorSpan
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.core.widget.addTextChangedListener
import androidx.lifecycle.lifecycleScope
import jp.juggler.subwaytooter.api.entity.TootAccount
import jp.juggler.subwaytooter.api.entity.TootStatus
import jp.juggler.subwaytooter.databinding.ActTextBinding
import jp.juggler.subwaytooter.dialog.pickAccount
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.table.daoMutedWord
import jp.juggler.subwaytooter.table.daoSavedAccount
import jp.juggler.subwaytooter.util.CustomShare
import jp.juggler.subwaytooter.util.CustomShareTarget
import jp.juggler.subwaytooter.util.TootTextEncoder
import jp.juggler.subwaytooter.util.copyToClipboard
import jp.juggler.util.*
import jp.juggler.util.coroutine.AppDispatchers
import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.coroutine.launchMain
import jp.juggler.util.data.*
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.showToast
import jp.juggler.util.ui.*
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class ActText : AppCompatActivity() {
companion object {
internal val log = LogCategory("ActText")
// internal const val RESULT_SEARCH_MSP = RESULT_FIRST_USER + 1
// internal const val RESULT_SEARCH_TS = RESULT_FIRST_USER + 2
internal const val RESULT_SEARCH_NOTESTOCK = RESULT_FIRST_USER + 3
internal const val EXTRA_TEXT = "text"
internal const val EXTRA_CONTENT_START = "content_start"
internal const val EXTRA_CONTENT_END = "content_end"
internal const val EXTRA_ACCOUNT_DB_ID = "account_db_id"
fun createIntent(
activity: ActMain,
accessInfo: SavedAccount,
status: TootStatus,
) = Intent(activity, ActText::class.java).apply {
putExtra(EXTRA_ACCOUNT_DB_ID, accessInfo.db_id)
TootTextEncoder.encodeStatus(this, activity, accessInfo, status)
}
fun createIntent(
activity: ActMain,
accessInfo: SavedAccount,
who: TootAccount,
) = Intent(activity, ActText::class.java).apply {
putExtra(EXTRA_ACCOUNT_DB_ID, accessInfo.db_id)
TootTextEncoder.encodeAccount(this, activity, accessInfo, who)
}
}
private class SearchResultSpan(color: Int) : BackgroundColorSpan(color)
private class SearchResult(
val items: List<IntRange> = emptyList(),
val hasMore: Boolean = false,
val error: String? = null,
) {
val size = items.size
fun findNext(curPos: Int, allowEqual: Boolean = false): IntRange? {
var start = 0
var end = items.size
while (end > start) {
val mid = (end + start) shr 1
val item = items[mid]
if (curPos in item) return when {
allowEqual -> item
else -> items.elementAtOrNull(mid + 1)
}
items.elementAtOrNull(mid - 1)?.let { prev ->
if (curPos in prev.last + 1 until item.first) return item
}
when {
curPos > item.first -> start = mid + 1
else -> end = mid
}
}
return null
}
fun findPrev(curPos: Int, allowEqual: Boolean = false): IntRange? {
var start = 0
var end = items.size
while (end > start) {
val mid = (end + start) shr 1
val item = items[mid]
if (curPos in item) return when {
allowEqual -> item
else -> items.elementAtOrNull(mid - 1)
}
items.elementAtOrNull(mid + 1)?.let { next ->
if (curPos in item.last + 1 until next.first) return item
}
when {
curPos > item.first -> start = mid + 1
else -> end = mid
}
}
return null
}
fun index(curPos: Int): Int? {
var start = 0
var end = items.size
while (end > start) {
val mid = (end + start) shr 1
val item = items[mid]
when {
curPos in item -> return mid
curPos > item.first -> start = mid + 1
else -> end = mid
}
}
return null
}
}
private val views by lazy {
ActTextBinding.inflate(layoutInflater)
}
private val searchTextChannel = Channel<Long>(capacity = Channel.CONFLATED)
private var account: SavedAccount? = null
private var searchResult = SearchResult()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
App1.setActivityTheme(this)
setContentView(views.root)
setSupportActionBar(views.toolbar)
setNavigationBack(views.toolbar)
fixHorizontalMargin(views.etText)
views.etSearch.addTextChangedListener { postSearchText() }
views.btnSearchClear.setOnClickListener { views.etSearch.setText("") }
views.btnSearchPrev.setOnClickListener { searchPrev() }
views.btnSearchNext.setOnClickListener { searchNext() }
views.btnToggleRegex.setOnCheckedChangeListener { _, _ -> postSearchText() }
lifecycleScope.launch {
while (true) {
try {
searchTextChannel.receive()
searchTextImpl()
} catch (ex: Throwable) {
if (ex is CancellationException) break
log.e(ex, "searchTextChannel failed.")
}
}
}
launchAndShowError {
account = intent.long(EXTRA_ACCOUNT_DB_ID)
?.let { daoSavedAccount.loadAccount(it) }
if (savedInstanceState == null) {
showSearchResult(null)
val sv = intent.string(EXTRA_TEXT) ?: ""
val contentStart = intent.int(EXTRA_CONTENT_START) ?: 0
val contentEnd = intent.int(EXTRA_CONTENT_END) ?: sv.length
views.etText.setText(sv)
// Android 9 以降ではフォーカスがないとsetSelectionできない
if (Build.VERSION.SDK_INT >= 28) {
views.etText.requestFocus()
views.etText.hideKeyboard()
}
views.etText.setSelection(contentStart, contentEnd)
}
}
}
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
postSearchText()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.act_text, menu)
super.onCreateOptionsMenu(menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
when (item.itemId) {
R.id.miCopy -> selectionOrAll.copyToClipboard(this)
R.id.miSearch -> search()
R.id.miSend -> send()
R.id.miMuteWord -> muteWord()
R.id.miSearchNotestock -> searchToot(RESULT_SEARCH_NOTESTOCK)
R.id.miKeywordFilter -> keywordFilter()
R.id.miHighlight -> highlight()
// MSP検索ボタン -> searchToot(RESULT_SEARCH_MSP)
// R.id.btnSearchTS -> searchToot(RESULT_SEARCH_TS)
R.id.miTranslate -> CustomShare.invokeText(
CustomShareTarget.Translate,
this,
selectionOrAll,
)
else -> return super.onOptionsItemSelected(item)
}
return true
}
/**
* 選択範囲、またはテキスト全体
*/
private val selectionOrAll: String
get() {
val et = views.etText
val s = et.selectionStart
val e = et.selectionEnd
val text = et.text.toString()
return when (s) {
e -> text
else -> text.substring(s, e)
}
}
private fun send() {
selectionOrAll.trim().notEmpty()?.let {
try {
val intent = Intent()
intent.action = Intent.ACTION_SEND
intent.type = "text/plain"
intent.putExtra(Intent.EXTRA_TEXT, it)
startActivity(intent)
} catch (ex: Throwable) {
log.e(ex, "send failed.")
showToast(ex, "send failed.")
}
}
}
private fun search() {
selectionOrAll.trim().notEmpty()?.also {
try {
val intent = Intent(Intent.ACTION_WEB_SEARCH)
intent.putExtra(SearchManager.QUERY, it)
if (intent.resolveActivity(packageManager) != null) {
startActivity(intent)
}
} catch (ex: Throwable) {
log.e(ex, "search failed.")
showToast(ex, "search failed.")
}
}
}
private fun searchToot(@Suppress("SameParameterValue") resultCode: Int) {
selectionOrAll.trim().notEmpty()?.let {
try {
val data = Intent()
data.putExtra(Intent.EXTRA_TEXT, it)
setResult(resultCode, data)
finish()
} catch (ex: Throwable) {
log.e(ex, "searchToot failed.")
showToast(ex, "searchToot failed.")
}
}
}
private fun muteWord() {
launchAndShowError {
selectionOrAll.trim().notEmpty()?.let {
daoMutedWord.save(it)
App1.getAppState(this@ActText).onMuteUpdated()
showToast(false, R.string.word_was_muted)
}
}
}
private fun keywordFilter() {
selectionOrAll.trim().notEmpty()?.let { text ->
val account = this.account
if (account?.isPseudo == false && account.isMastodon) {
ActKeywordFilter.open(this, account, initialPhrase = text)
} else {
launchMain {
pickAccount(
bAllowPseudo = false,
bAllowMisskey = false,
bAllowMastodon = true,
bAuto = false,
)?.let {
ActKeywordFilter.open(this@ActText, it, initialPhrase = text)
}
}
}
}
}
private fun highlight() {
selectionOrAll.trim().notEmpty()?.let {
startActivity(ActHighlightWordEdit.createIntent(this, it))
}
}
private fun postSearchText() {
lifecycleScope.launch {
try {
searchTextChannel.send(SystemClock.elapsedRealtime())
} catch (ex: Throwable) {
log.e(ex, "postSearchText failed.")
}
}
}
private suspend fun searchTextImpl() {
val keyword = views.etSearch.text?.toString() ?: ""
val content = views.etText.text?.toString() ?: ""
val useRegex = views.btnToggleRegex.isChecked
this.searchResult = withContext(AppDispatchers.IO) {
try {
val limit = 1000
var hasMore = false
val items = buildList {
when {
// 空欄
keyword.isEmpty() -> Unit
// 正規表現
useRegex -> {
val re = keyword.toRegex(RegexOption.IGNORE_CASE)
var nextStart = 0
while (nextStart < content.length) {
val mr = re.find(content, startIndex = nextStart) ?: break
if (size >= limit) {
hasMore = true
break
}
add(mr.range)
nextStart = mr.range.last + 1
}
}
// 生テキスト
else -> {
var nextStart = 0
while (nextStart < content.length) {
val pos = content.indexOf(
keyword,
startIndex = nextStart,
ignoreCase = true
)
if (pos == -1) break
if (size >= limit) {
hasMore = true
break
}
val end = pos + keyword.length
add(pos until end)
nextStart = end
}
}
}
}
SearchResult(items = items, hasMore = hasMore)
} catch (ex: Throwable) {
log.e(ex, "search error.")
SearchResult(error = ex.message)
}
}
showSearchResult {
searchResult.findNext(views.etText.selectionStart, allowEqual = true)
?: searchResult.items.firstOrNull()
}
}
private fun searchNext() {
showSearchResult {
searchResult.findNext(views.etText.selectionStart, allowEqual = false)
?: searchResult.items.firstOrNull()
}
}
private fun searchPrev() {
showSearchResult {
searchResult.findPrev(views.etText.selectionStart, allowEqual = false)
?: searchResult.items.lastOrNull()
}
}
private fun showSearchResult(newPosFinder: (() -> IntRange?)?) {
val newPos: IntRange? = try {
newPosFinder?.invoke()
} catch (ex: Throwable) {
log.e(ex, "newPosFinder failed.")
null
}
val hasKeyword = !views.etSearch.text.isNullOrEmpty()
views.btnSearchClear.isEnabledAlpha = hasKeyword
views.llSearchResult.vg(hasKeyword)?.let {
views.btnSearchPrev.isEnabledAlpha = searchResult.size > 1
views.btnSearchNext.isEnabledAlpha = searchResult.size > 1
val idx = newPos?.let {
val end = views.etText.text?.length ?: 0
views.etText.setSelection(
it.first.clip(0, end),
(it.last + 1).clip(0, end),
)
searchResult.index(it.first)
}
views.tvSearchCount.text = getString(
R.string.search_result,
idx?.plus(1) ?: 0,
searchResult.size,
if (searchResult.hasMore) "+" else ""
)
}
val error = searchResult.error
views.tvSearchError.vg(hasKeyword && !error.isNullOrBlank())
?.text = error
views.etText.text?.let { e ->
for (span in e.getSpans(0, e.length, SearchResultSpan::class.java)) {
try {
e.removeSpan(span)
} catch (ignored: Throwable) {
}
}
for (pos in searchResult.items) {
val attrId = when (newPos) {
pos -> R.attr.colorSearchFormBackground
else -> R.attr.colorButtonBgCw
}
e.setSpan(
SearchResultSpan(attrColor(attrId)),
pos.first,
pos.last + 1,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
)
}
}
}
}