2018-01-04 19:52:25 +01:00
|
|
|
package jp.juggler.subwaytooter
|
|
|
|
|
|
|
|
import android.app.SearchManager
|
|
|
|
import android.content.Intent
|
2019-04-02 23:50:05 +02:00
|
|
|
import android.os.Build
|
2018-01-04 19:52:25 +01:00
|
|
|
import android.os.Bundle
|
2023-05-04 12:13:13 +02:00
|
|
|
import android.os.Handler
|
|
|
|
import android.os.Looper
|
|
|
|
import android.os.SystemClock
|
|
|
|
import android.text.Spanned
|
|
|
|
import android.text.style.BackgroundColorSpan
|
2021-05-22 11:07:23 +02:00
|
|
|
import android.view.Menu
|
|
|
|
import android.view.MenuItem
|
2019-04-02 23:50:05 +02:00
|
|
|
import androidx.appcompat.app.AppCompatActivity
|
2023-05-04 12:13:13 +02:00
|
|
|
import androidx.core.widget.addTextChangedListener
|
|
|
|
import androidx.lifecycle.lifecycleScope
|
2019-06-25 22:28:02 +02:00
|
|
|
import jp.juggler.subwaytooter.api.entity.TootAccount
|
|
|
|
import jp.juggler.subwaytooter.api.entity.TootStatus
|
2023-01-14 21:37:23 +01:00
|
|
|
import jp.juggler.subwaytooter.databinding.ActTextBinding
|
2021-05-27 04:15:59 +02:00
|
|
|
import jp.juggler.subwaytooter.dialog.pickAccount
|
2018-01-04 19:52:25 +01:00
|
|
|
import jp.juggler.subwaytooter.table.SavedAccount
|
2023-02-04 21:52:26 +01:00
|
|
|
import jp.juggler.subwaytooter.table.daoMutedWord
|
|
|
|
import jp.juggler.subwaytooter.table.daoSavedAccount
|
2021-05-22 00:03:16 +02:00
|
|
|
import jp.juggler.subwaytooter.util.CustomShare
|
|
|
|
import jp.juggler.subwaytooter.util.CustomShareTarget
|
2019-06-25 22:28:02 +02:00
|
|
|
import jp.juggler.subwaytooter.util.TootTextEncoder
|
2023-01-13 13:22:25 +01:00
|
|
|
import jp.juggler.subwaytooter.util.copyToClipboard
|
2021-05-22 00:03:16 +02:00
|
|
|
import jp.juggler.util.*
|
2023-05-04 12:13:13 +02:00
|
|
|
import jp.juggler.util.coroutine.AppDispatchers
|
2023-02-04 21:52:26 +01:00
|
|
|
import jp.juggler.util.coroutine.launchAndShowError
|
2023-01-13 13:22:25 +01:00
|
|
|
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.*
|
2023-05-04 12:13:13 +02:00
|
|
|
import kotlinx.coroutines.CancellationException
|
|
|
|
import kotlinx.coroutines.channels.Channel
|
|
|
|
import kotlinx.coroutines.launch
|
|
|
|
import kotlinx.coroutines.withContext
|
2018-01-04 19:52:25 +01:00
|
|
|
|
2021-05-22 11:07:23 +02:00
|
|
|
class ActText : AppCompatActivity() {
|
|
|
|
|
|
|
|
companion object {
|
|
|
|
|
|
|
|
internal val log = LogCategory("ActText")
|
|
|
|
|
2022-12-27 07:16:02 +01:00
|
|
|
// internal const val RESULT_SEARCH_MSP = RESULT_FIRST_USER + 1
|
2022-12-26 16:04:32 +01:00
|
|
|
// internal const val RESULT_SEARCH_TS = RESULT_FIRST_USER + 2
|
2021-05-22 11:07:23 +02:00
|
|
|
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,
|
2021-06-20 15:12:25 +02:00
|
|
|
accessInfo: SavedAccount,
|
|
|
|
status: TootStatus,
|
2021-05-22 11:07:23 +02:00
|
|
|
) = Intent(activity, ActText::class.java).apply {
|
2021-06-20 15:12:25 +02:00
|
|
|
putExtra(EXTRA_ACCOUNT_DB_ID, accessInfo.db_id)
|
|
|
|
TootTextEncoder.encodeStatus(this, activity, accessInfo, status)
|
2021-05-22 11:07:23 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
fun createIntent(
|
|
|
|
activity: ActMain,
|
2021-06-20 15:12:25 +02:00
|
|
|
accessInfo: SavedAccount,
|
|
|
|
who: TootAccount,
|
2021-05-22 11:07:23 +02:00
|
|
|
) = Intent(activity, ActText::class.java).apply {
|
2021-06-20 15:12:25 +02:00
|
|
|
putExtra(EXTRA_ACCOUNT_DB_ID, accessInfo.db_id)
|
|
|
|
TootTextEncoder.encodeAccount(this, activity, accessInfo, who)
|
2021-05-22 11:07:23 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-05-05 05:12:49 +02:00
|
|
|
class SearchResultSpan(color: Int) : BackgroundColorSpan(color)
|
2023-05-04 12:13:13 +02:00
|
|
|
|
2021-05-22 11:07:23 +02:00
|
|
|
private var account: SavedAccount? = null
|
2023-01-14 21:37:23 +01:00
|
|
|
|
|
|
|
private val views by lazy {
|
|
|
|
ActTextBinding.inflate(layoutInflater)
|
|
|
|
}
|
2021-05-22 11:07:23 +02:00
|
|
|
|
|
|
|
private val selection: String
|
|
|
|
get() {
|
2023-01-14 21:37:23 +01:00
|
|
|
val et = views.etText
|
|
|
|
val s = et.selectionStart
|
|
|
|
val e = et.selectionEnd
|
|
|
|
val text = et.text.toString()
|
2021-05-22 11:07:23 +02:00
|
|
|
return if (s == e) {
|
|
|
|
text
|
|
|
|
} else {
|
|
|
|
text.substring(s, e)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-05-04 12:13:13 +02:00
|
|
|
private val searchTextChannel = Channel<Long>(capacity = Channel.CONFLATED)
|
2021-05-22 11:07:23 +02:00
|
|
|
|
2023-05-04 12:13:13 +02:00
|
|
|
private var searchResult: List<Int> = emptyList()
|
|
|
|
private var searchKeywordLength = 0
|
2021-05-22 11:07:23 +02:00
|
|
|
|
2023-05-04 12:13:13 +02:00
|
|
|
private val handler = Handler(Looper.getMainLooper())
|
2021-05-22 11:07:23 +02:00
|
|
|
|
|
|
|
override fun onCreate(savedInstanceState: Bundle?) {
|
|
|
|
super.onCreate(savedInstanceState)
|
2023-01-14 21:37:23 +01:00
|
|
|
App1.setActivityTheme(this)
|
2021-05-22 11:07:23 +02:00
|
|
|
|
2023-05-04 12:13:13 +02:00
|
|
|
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() }
|
|
|
|
|
|
|
|
lifecycleScope.launch {
|
|
|
|
while (true) {
|
|
|
|
try {
|
|
|
|
searchTextChannel.receive()
|
|
|
|
searchTextImpl()
|
|
|
|
} catch (ex: Throwable) {
|
|
|
|
if (ex is CancellationException) break
|
|
|
|
log.e(ex, "searchTextChannel failed.")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2021-05-22 11:07:23 +02:00
|
|
|
|
2023-02-04 21:52:26 +01:00
|
|
|
launchAndShowError {
|
|
|
|
account = intent.long(EXTRA_ACCOUNT_DB_ID)
|
|
|
|
?.let { daoSavedAccount.loadAccount(it) }
|
2021-05-22 11:07:23 +02:00
|
|
|
|
2023-02-04 21:52:26 +01:00
|
|
|
if (savedInstanceState == null) {
|
2023-05-04 12:13:13 +02:00
|
|
|
views.llSearchResult.gone()
|
|
|
|
|
2023-02-04 21:52:26 +01:00
|
|
|
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()
|
|
|
|
}
|
2021-05-22 11:07:23 +02:00
|
|
|
|
2023-02-04 21:52:26 +01:00
|
|
|
views.etText.setSelection(contentStart, contentEnd)
|
|
|
|
}
|
2021-05-22 11:07:23 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2023-05-04 12:13:13 +02:00
|
|
|
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 -> selection.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,
|
|
|
|
selection,
|
|
|
|
)
|
|
|
|
|
|
|
|
else -> return super.onOptionsItemSelected(item)
|
|
|
|
}
|
|
|
|
return true
|
2021-05-22 11:07:23 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
private fun send() {
|
|
|
|
selection.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) {
|
2022-12-27 03:54:52 +01:00
|
|
|
log.e(ex, "send failed.")
|
2021-05-22 11:07:23 +02:00
|
|
|
showToast(ex, "send failed.")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private fun search() {
|
2021-06-27 12:05:04 +02:00
|
|
|
selection.trim().notEmpty()?.also {
|
2021-05-22 11:07:23 +02:00
|
|
|
try {
|
|
|
|
val intent = Intent(Intent.ACTION_WEB_SEARCH)
|
|
|
|
intent.putExtra(SearchManager.QUERY, it)
|
|
|
|
if (intent.resolveActivity(packageManager) != null) {
|
|
|
|
startActivity(intent)
|
|
|
|
}
|
|
|
|
} catch (ex: Throwable) {
|
2022-12-27 03:54:52 +01:00
|
|
|
log.e(ex, "search failed.")
|
2021-05-22 11:07:23 +02:00
|
|
|
showToast(ex, "search failed.")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private fun searchToot(@Suppress("SameParameterValue") resultCode: Int) {
|
|
|
|
selection.trim().notEmpty()?.let {
|
|
|
|
try {
|
|
|
|
val data = Intent()
|
|
|
|
data.putExtra(Intent.EXTRA_TEXT, it)
|
|
|
|
setResult(resultCode, data)
|
|
|
|
finish()
|
|
|
|
} catch (ex: Throwable) {
|
2022-12-27 03:54:52 +01:00
|
|
|
log.e(ex, "searchToot failed.")
|
|
|
|
showToast(ex, "searchToot failed.")
|
2021-05-22 11:07:23 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private fun muteWord() {
|
2023-02-04 21:52:26 +01:00
|
|
|
launchAndShowError {
|
|
|
|
selection.trim().notEmpty()?.let {
|
|
|
|
daoMutedWord.save(it)
|
|
|
|
App1.getAppState(this@ActText).onMuteUpdated()
|
2021-05-22 11:07:23 +02:00
|
|
|
showToast(false, R.string.word_was_muted)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private fun keywordFilter() {
|
2021-05-27 04:15:59 +02:00
|
|
|
selection.trim().notEmpty()?.let { text ->
|
2021-05-22 11:07:23 +02:00
|
|
|
val account = this.account
|
2021-05-27 04:15:59 +02:00
|
|
|
if (account?.isPseudo == false && account.isMastodon) {
|
2021-06-20 15:12:25 +02:00
|
|
|
ActKeywordFilter.open(this, account, initialPhrase = text)
|
2021-05-27 04:15:59 +02:00
|
|
|
} else {
|
|
|
|
launchMain {
|
|
|
|
pickAccount(
|
|
|
|
bAllowPseudo = false,
|
|
|
|
bAllowMisskey = false,
|
|
|
|
bAllowMastodon = true,
|
|
|
|
bAuto = false,
|
|
|
|
)?.let {
|
2021-06-20 15:12:25 +02:00
|
|
|
ActKeywordFilter.open(this@ActText, it, initialPhrase = text)
|
2021-05-27 04:15:59 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2021-05-22 11:07:23 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private fun highlight() {
|
|
|
|
selection.trim().notEmpty()?.let {
|
|
|
|
startActivity(ActHighlightWordEdit.createIntent(this, it))
|
|
|
|
}
|
|
|
|
}
|
2023-05-04 12:13:13 +02:00
|
|
|
|
|
|
|
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 searchResult: List<Int> = withContext(AppDispatchers.IO) {
|
|
|
|
if (keyword.isEmpty()) {
|
|
|
|
emptyList()
|
|
|
|
} else {
|
|
|
|
buildList {
|
|
|
|
var nextStart = 0
|
|
|
|
while (nextStart < content.length) {
|
|
|
|
val pos = content.indexOf(
|
|
|
|
keyword,
|
|
|
|
startIndex = nextStart,
|
|
|
|
ignoreCase = true
|
|
|
|
)
|
|
|
|
if (pos == -1) break
|
|
|
|
add(pos)
|
|
|
|
nextStart = pos + keyword.length
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
this.searchResult = searchResult
|
|
|
|
this.searchKeywordLength = keyword.length
|
|
|
|
|
|
|
|
views.btnSearchClear.isEnabledAlpha = keyword.isNotEmpty()
|
|
|
|
views.btnSearchPrev.isEnabledAlpha = searchResult.isNotEmpty()
|
|
|
|
views.btnSearchNext.isEnabledAlpha = searchResult.isNotEmpty()
|
|
|
|
when {
|
|
|
|
searchResult.isEmpty() -> {
|
|
|
|
views.llSearchResult.gone()
|
|
|
|
searchHighlight(null)
|
|
|
|
}
|
|
|
|
|
|
|
|
else -> searchNext(byTextUpdate=true)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private fun searchNext(byTextUpdate: Boolean=false) {
|
|
|
|
try {
|
|
|
|
val curPos = views.etText.selectionStart
|
|
|
|
val newPos = when {
|
|
|
|
byTextUpdate -> searchResult.find { it >= curPos }
|
|
|
|
else -> searchResult.find { it > curPos }
|
|
|
|
} ?: searchResult.firstOrNull()
|
|
|
|
searchJump(newPos)
|
|
|
|
} catch (ex: Throwable) {
|
|
|
|
log.e(ex, "searchNext failed.")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private fun searchPrev() {
|
|
|
|
try {
|
|
|
|
val curPos = views.etText.selectionStart.takeIf { it >= 0 }
|
|
|
|
?: views.etText.text?.length
|
|
|
|
?: return
|
|
|
|
val newPos = searchResult.findLast { it < curPos }
|
|
|
|
?: searchResult.lastOrNull()
|
|
|
|
searchJump(newPos)
|
|
|
|
} catch (ex: Throwable) {
|
|
|
|
log.e(ex, "searchPrev failed.")
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private fun searchJump(newPos: Int?) {
|
|
|
|
val idx = when (newPos) {
|
|
|
|
null -> null
|
|
|
|
else -> {
|
|
|
|
val end = views.etText.text?.length ?: 0
|
|
|
|
views.etText.setSelection(
|
|
|
|
newPos.clip(0, end),
|
|
|
|
(newPos + searchKeywordLength).clip(0, end),
|
|
|
|
)
|
|
|
|
searchResult.indexOf(newPos).takeIf { it >= 0 }?.plus(1)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
views.llSearchResult.visible()
|
|
|
|
views.tvSearchCount.text = getString(
|
|
|
|
R.string.search_result,
|
|
|
|
idx?.toString() ?: "?",
|
|
|
|
searchResult.size
|
|
|
|
)
|
|
|
|
searchHighlight(newPos)
|
|
|
|
}
|
|
|
|
|
|
|
|
private fun searchHighlight(newPos: Int?) {
|
|
|
|
views.etText.text?.let { e ->
|
2023-05-05 05:12:49 +02:00
|
|
|
for (span in e.getSpans(0, e.length, SearchResultSpan::class.java)) {
|
2023-05-04 12:13:13 +02:00
|
|
|
try {
|
|
|
|
e.removeSpan(span)
|
|
|
|
} catch (ignored: Throwable) {
|
|
|
|
}
|
|
|
|
}
|
|
|
|
for (pos in searchResult) {
|
|
|
|
val attrId = when (newPos) {
|
|
|
|
pos -> R.attr.colorSearchFormBackground
|
|
|
|
else -> R.attr.colorButtonBgCw
|
|
|
|
}
|
|
|
|
e.setSpan(
|
2023-05-05 05:12:49 +02:00
|
|
|
SearchResultSpan(attrColor(attrId)),
|
2023-05-04 12:13:13 +02:00
|
|
|
pos,
|
|
|
|
pos + searchKeywordLength,
|
|
|
|
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
|
|
|
|
)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2018-01-04 19:52:25 +01:00
|
|
|
}
|