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

482 lines
17 KiB
Kotlin

package jp.juggler.subwaytooter
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.widget.ArrayAdapter
import android.widget.CheckBox
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.children
import jp.juggler.subwaytooter.api.ApiPath
import jp.juggler.subwaytooter.api.TootApiResult
import jp.juggler.subwaytooter.api.auth.AuthRepo
import jp.juggler.subwaytooter.api.entity.*
import jp.juggler.subwaytooter.api.runApiTask
import jp.juggler.subwaytooter.column.ColumnType
import jp.juggler.subwaytooter.databinding.ActKeywordFilterBinding
import jp.juggler.subwaytooter.databinding.LvKeywordFilterBinding
import jp.juggler.subwaytooter.table.SavedAccount
import jp.juggler.subwaytooter.table.daoAcctColor
import jp.juggler.subwaytooter.table.daoSavedAccount
import jp.juggler.util.backPressed
import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.coroutine.launchMain
import jp.juggler.util.data.*
import jp.juggler.util.int
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.showToast
import jp.juggler.util.long
import jp.juggler.util.network.toPostRequestBuilder
import jp.juggler.util.network.toPut
import jp.juggler.util.network.toRequestBody
import jp.juggler.util.string
import jp.juggler.util.ui.setNavigationBack
class ActKeywordFilter : AppCompatActivity() {
companion object {
private val log = LogCategory("ActKeywordFilter")
private const val EXTRA_ACCOUNT_DB_ID = "account_db_id"
private const val EXTRA_FILTER_ID = "filter_id"
private const val EXTRA_INITIAL_PHRASE = "initial_phrase"
private const val STATE_EXPIRE_SPINNER = "expire_spinner"
private const val STATE_EXPIRE_AT = "expire_at"
private const val STATE_KEYWORDS = "keywords"
private const val STATE_DELETE_IDS = "deleteIds"
fun open(
activity: Activity,
ai: SavedAccount,
filterId: EntityId? = null,
initialPhrase: String? = null,
) {
val intent = Intent(activity, ActKeywordFilter::class.java)
intent.putExtra(EXTRA_ACCOUNT_DB_ID, ai.db_id)
filterId?.putTo(intent, EXTRA_FILTER_ID)
initialPhrase?.notEmpty()?.let { intent.putExtra(EXTRA_INITIAL_PHRASE, it) }
activity.startActivity(intent)
}
private val expire_duration_list = intArrayOf(
-1, // dont change
0, // unlimited
1800,
3600,
3600 * 6,
3600 * 12,
86400,
86400 * 7
)
}
private lateinit var account: SavedAccount
private val views by lazy {
ActKeywordFilterBinding.inflate(layoutInflater)
}
// private lateinit var tvAccount: TextView
// private lateinit var etPhrase: EditText
// private lateinit var cbContextHome: CheckBox
// private lateinit var cbContextNotification: CheckBox
// private lateinit var cbContextPublic: CheckBox
// private lateinit var cbContextThread: CheckBox
// private lateinit var cbContextProfile: CheckBox
//
// private lateinit var cbFilterIrreversible: CheckBox
// private lateinit var cbFilterWordMatch: CheckBox
// private lateinit var tvExpire: TextView
// private lateinit var spExpire: Spinner
private var loading = false
private var density: Float = 1f
private var filterId: EntityId? = null
private var filterExpire: Long = 0L
private val deleteIds = HashSet<String>()
val authRepo by lazy {
AuthRepo(this)
}
///////////////////////////////////////////////////
override fun onCreate(savedInstanceState: Bundle?) {
backPressed { confirmBack() }
super.onCreate(savedInstanceState)
App1.setActivityTheme(this)
initUI()
launchAndShowError {
// filter ID の有無はUIに影響するのでinitUIより先に初期化する
filterId = EntityId.entityId(intent, EXTRA_FILTER_ID)
val a = intent.long(EXTRA_ACCOUNT_DB_ID)
?.let { daoSavedAccount.loadAccount(it) }
if (a == null) {
finish()
return@launchAndShowError
}
account = a
showAccount()
if (savedInstanceState == null) {
if (filterId != null) {
startLoading()
} else {
views.spExpire.setSelection(1)
val initialText = intent.string(EXTRA_INITIAL_PHRASE)?.trim() ?: ""
views.etTitle.setText(initialText)
addKeywordArea(TootFilterKeyword(keyword = initialText))
}
} else {
savedInstanceState.getStringArrayList(STATE_DELETE_IDS)
?.let { deleteIds.addAll(it) }
savedInstanceState.getStringArrayList(STATE_KEYWORDS)
?.mapNotNull { it?.decodeJsonObject() }
?.forEach {
try {
addKeywordArea(TootFilterKeyword(it))
} catch (ex: Throwable) {
log.e(ex, "can't decode TootFilterKeyword")
}
}
savedInstanceState.int(STATE_EXPIRE_SPINNER)
?.let { views.spExpire.setSelection(it) }
savedInstanceState.long(STATE_EXPIRE_AT)
?.let { filterExpire = it }
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
if (!loading) {
outState.putInt(STATE_EXPIRE_SPINNER, views.spExpire.selectedItemPosition)
outState.putLong(STATE_EXPIRE_AT, filterExpire)
outState.putStringArrayList(STATE_DELETE_IDS, ArrayList<String>(deleteIds))
views.llKeywords.children
.mapNotNull { (it.tag as? VhKeyword)?.encodeJson()?.toString() }
.toList()
.let { outState.putStringArrayList(STATE_KEYWORDS, ArrayList<String>(it)) }
}
}
private fun initUI() {
setContentView(views.root)
setSupportActionBar(views.toolbar)
setNavigationBack(views.toolbar)
fixHorizontalMargin(views.llContent)
this.density = resources.displayMetrics.density
title = getString(
when (filterId) {
null -> R.string.keyword_filter_new
else -> R.string.keyword_filter_edit
}
)
views.btnSave.setOnClickListener { save() }
views.btnAddKeyword.setOnClickListener {
val ti = TootInstance.getCached(account)
when {
ti == null ->
showToast(true, "can't get server information")
!ti.versionGE(TootInstance.VERSION_4_0_0) && views.llKeywords.childCount >= 1 ->
showToast(true, "before mastodon 4.0, allowed 1 keyword per 1 filter.")
else -> addKeywordArea(TootFilterKeyword(keyword = ""))
}
}
val captionList = arrayOf(
getString(R.string.dont_change),
getString(R.string.filter_expire_unlimited),
getString(R.string.filter_expire_30min),
getString(R.string.filter_expire_1hour),
getString(R.string.filter_expire_6hour),
getString(R.string.filter_expire_12hour),
getString(R.string.filter_expire_1day),
getString(R.string.filter_expire_1week)
)
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, captionList)
adapter.setDropDownViewResource(R.layout.lv_spinner_dropdown)
views.spExpire.adapter = adapter
}
private fun confirmBack() {
AlertDialog.Builder(this)
.setMessage(R.string.keyword_filter_quit_waring)
.setPositiveButton(R.string.ok) { _, _ -> finish() }
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun showAccount() {
views.tvAccount.text = daoAcctColor.getNicknameWithColor(account.acct)
}
private fun startLoading() {
loading = true
launchMain {
var resultFilter: TootFilter? = null
runApiTask(account) { client ->
// try v2
var result = client.request("${ApiPath.PATH_FILTERS_V2}/$filterId")
result?.jsonObject?.let {
try {
resultFilter = TootFilter(it)
return@runApiTask result
} catch (ex: Throwable) {
log.e(ex, "parse error.")
}
}
if (result?.response?.code == 404) {
// try v1
result = client.request("${ApiPath.PATH_FILTERS_V1}/$filterId")
result?.jsonObject?.let {
try {
resultFilter = TootFilter(it)
return@runApiTask result
} catch (ex: Throwable) {
log.e(ex, "parse error.")
}
}
}
result
}?.let { result ->
loading = false
when (val filter = resultFilter) {
null -> {
showToast(true, result.error ?: "?")
finish()
}
else -> onLoadComplete(filter)
}
}
// キャンセル時はloadingはtrueのまま
}
}
private fun onLoadComplete(filter: TootFilter) {
loading = false
filterExpire = filter.time_expires_at
setContextChecked(filter, views.cbContextHome, TootFilterContext.Home)
setContextChecked(filter, views.cbContextNotification, TootFilterContext.Notifications)
setContextChecked(filter, views.cbContextPublic, TootFilterContext.Public)
setContextChecked(filter, views.cbContextThread, TootFilterContext.Thread)
setContextChecked(filter, views.cbContextProfile, TootFilterContext.Account)
views.rgAction.check(if (filter.hide) views.rbHide.id else views.rbWarn.id)
if (filter.keywords.isEmpty()) {
filter.keywords = listOf(TootFilterKeyword(keyword = ""))
}
filter.keywords.forEach { addKeywordArea(it) }
views.etTitle.setText(
filter.title.notEmpty() ?: filter.keywords.firstOrNull()?.keyword ?: ""
)
views.tvExpire.text = if (filter.time_expires_at == 0L) {
getString(R.string.filter_expire_unlimited)
} else {
TootStatus.formatTime(this, filter.time_expires_at, false)
}
}
private fun setContextChecked(filter: TootFilter, cb: CheckBox, fc: TootFilterContext) {
cb.isChecked = filter.hasContext(fc)
}
private fun save() {
if (loading) return
val vhList = views.llKeywords.children.mapNotNull { it.tag as? VhKeyword }.toList()
if (vhList.isEmpty() || vhList.any { it.keyword.isEmpty() }) {
showToast(true, R.string.filter_keyword_empty)
return
}
val title = views.etTitle.text.toString().trim()
if (title.isEmpty()) {
showToast(true, R.string.filter_title_empty)
return
}
launchMain {
var result = saveV2(vhList, title)
if (result?.response?.code == 404) {
result = saveV1(vhList)
}
result ?: return@launchMain // cancelled
val error = result.error
if (error != null) {
showToast(true, result.error)
} else {
val appState = App1.prepare(applicationContext, "ActKeywordFilter.save()")
for (column in appState.columnList) {
if (column.type == ColumnType.KEYWORD_FILTER && column.accessInfo == account) {
column.filterReloadRequired = true
}
}
finish()
}
}
}
private fun filterParamBase() = buildJsonObject {
fun JsonArray.putContextChecked(cb: CheckBox, fc: TootFilterContext) {
if (cb.isChecked) add(fc.apiName)
}
put("context", JsonArray().apply {
putContextChecked(views.cbContextHome, TootFilterContext.Home)
putContextChecked(views.cbContextNotification, TootFilterContext.Notifications)
putContextChecked(views.cbContextPublic, TootFilterContext.Public)
putContextChecked(views.cbContextThread, TootFilterContext.Thread)
putContextChecked(views.cbContextProfile, TootFilterContext.Account)
})
when (val seconds = expire_duration_list
.elementAtOrNull(views.spExpire.selectedItemPosition)
?: -1
) {
// dont change
-1 -> Unit
// unlimited
0 -> when {
// already unlimited. don't change.
filterExpire <= 0L -> Unit
// XXX: currently there is no way to remove expires from existing filter.
else -> put("expires_in", Int.MAX_VALUE)
}
// set seconds
else -> put("expires_in", seconds)
}
}
private suspend fun saveV1(vhList: List<VhKeyword>): TootApiResult? {
if (vhList.size != 1) return TootApiResult("V1 API allow only 1 keyword.")
val params = filterParamBase().apply {
put("irreversible", views.rgAction.checkedRadioButtonId == views.rbHide.id)
val vh = vhList.first()
put("phrase", vh.keyword)
put("whole_word", vh.wholeWord)
}
return runApiTask(account) { client ->
if (filterId == null) {
client.request(
ApiPath.PATH_FILTERS_V1,
params.toPostRequestBuilder()
)
} else {
client.request(
"${ApiPath.PATH_FILTERS_V1}/$filterId",
params.toRequestBody().toPut()
)
}
}
}
private suspend fun saveV2(vhList: List<VhKeyword>, title: String): TootApiResult? {
val params = filterParamBase().apply {
put("title", title)
put(
"filter_action",
if (views.rbHide.isChecked) "hide" else "warn"
)
put("keywords_attributes", buildJsonArray {
vhList.forEach { vh ->
add(buildJsonObject {
put("keyword", vh.keyword)
put("whole_word", vh.wholeWord)
vh.id?.let { put("id", it) }
})
}
deleteIds.forEach { id ->
add(buildJsonObject {
put("id", id)
put("_destroy", id)
})
}
})
}
return runApiTask(account) { client ->
if (filterId == null) {
client.request(
ApiPath.PATH_FILTERS_V2,
params.toPostRequestBuilder()
)
} else {
client.request(
"${ApiPath.PATH_FILTERS_V2}/$filterId",
params.toRequestBody().toPut()
)
}
}
}
private fun addKeywordArea(keyword: TootFilterKeyword) {
views.llKeywords.addView(VhKeyword(fk = keyword).views.root)
}
private fun deleteKeywordArea(vh: VhKeyword) {
views.llKeywords.children.find { it.tag == vh }
?.let { views.llKeywords.removeView(it) }
vh.id?.let { deleteIds.add(it) }
}
private inner class VhKeyword(
val fk: TootFilterKeyword,
val views: LvKeywordFilterBinding = LvKeywordFilterBinding.inflate(layoutInflater),
) {
init {
views.root.tag = this
views.etKeyword.setText(fk.keyword.trim())
views.cbFilterWordMatch.isChecked = fk.whole_word
views.btnDelete.setOnClickListener {
deleteKeywordArea(this)
}
}
// onSaveInstanceや保存時に呼ばれる
fun encodeJson() =
fk.encodeNewParam(newKeyword = keyword, newWholeWord = wholeWord)
val keyword: String
get() = views.etKeyword.text.toString().trim()
val wholeWord: Boolean
get() = views.cbFilterWordMatch.isChecked
val id: String?
get() = fk.id?.toString()?.notEmpty()
}
}