471 lines
17 KiB
Kotlin
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,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|