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

310 lines
12 KiB
Kotlin

package jp.juggler.subwaytooter.dialog
import android.app.Dialog
import android.view.WindowManager
import android.view.inputmethod.EditorInfo
import android.widget.ArrayAdapter
import android.widget.Filter
import android.widget.TextView
import androidx.annotation.StringRes
import androidx.appcompat.app.AppCompatActivity
import androidx.core.widget.addTextChangedListener
import jp.juggler.subwaytooter.R
import jp.juggler.subwaytooter.api.entity.Host
import jp.juggler.subwaytooter.api.entity.TootInstance
import jp.juggler.subwaytooter.api.getApiHostFromWebFinger
import jp.juggler.subwaytooter.api.runApiTask2
import jp.juggler.subwaytooter.databinding.DlgAccountAddBinding
import jp.juggler.subwaytooter.databinding.LvAuthTypeBinding
import jp.juggler.subwaytooter.util.DecodeOptions
import jp.juggler.subwaytooter.util.LinkHelper
import jp.juggler.util.coroutine.AppDispatchers
import jp.juggler.util.coroutine.launchAndShowError
import jp.juggler.util.coroutine.launchMain
import jp.juggler.util.data.notBlank
import jp.juggler.util.data.notEmpty
import jp.juggler.util.log.LogCategory
import jp.juggler.util.log.showToast
import jp.juggler.util.ui.ProgressDialogEx
import jp.juggler.util.ui.attrColor
import jp.juggler.util.ui.dismissSafe
import jp.juggler.util.ui.hideKeyboard
import jp.juggler.util.ui.invisible
import jp.juggler.util.ui.isEnabledAlpha
import jp.juggler.util.ui.vg
import jp.juggler.util.ui.visible
import jp.juggler.util.ui.visibleOrInvisible
import kotlinx.coroutines.withContext
import org.jetbrains.anko.textColor
import org.jetbrains.anko.textResource
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.IDN
class LoginForm(
val activity: AppCompatActivity,
val onClickOk: (
dialog: Dialog,
apiHost: Host,
serverInfo: TootInstance?,
action: Action,
) -> Unit,
) {
companion object {
private val log = LogCategory("LoginForm")
@Suppress("RegExpSimplifiable")
val reBadChars = """([^\p{L}\p{N}A-Za-z0-9:;._-]+)""".toRegex()
fun AppCompatActivity.showLoginForm(
onClickOk: (
dialog: Dialog,
apiHost: Host,
serverInfo: TootInstance?,
action: Action,
) -> Unit,
) = LoginForm(this, onClickOk)
}
enum class Action(
@StringRes val idName: Int,
@StringRes val idDesc: Int,
) {
Login(R.string.existing_account, R.string.existing_account_desc),
Pseudo(R.string.pseudo_account, R.string.pseudo_account_desc),
// Create(2, R.string.create_account, R.string.create_account_desc),
Token(R.string.input_access_token, R.string.input_access_token_desc),
}
// 実行時キャストのためGenericsを含まない型を定義する
private class StringArrayList : ArrayList<String>()
val views = DlgAccountAddBinding.inflate(activity.layoutInflater)
val dialog = Dialog(activity)
private var targetServer: Host? = null
private var targetServerInfo: TootInstance? = null
init {
for (a in Action.entries) {
val subViews =
LvAuthTypeBinding.inflate(activity.layoutInflater, views.llPageAuthType, true)
subViews.btnAuthType.textResource = a.idName
subViews.tvDesc.textResource = a.idDesc
subViews.btnAuthType.setOnClickListener { onAuthTypeSelect(a) }
}
views.btnPrev.setOnClickListener { showPage(0) }
views.btnNext.setOnClickListener { nextPage() }
views.btnCancel.setOnClickListener { dialog.cancel() }
views.etInstance.setOnEditorActionListener(TextView.OnEditorActionListener { _, actionId, _ ->
if (actionId == EditorInfo.IME_ACTION_DONE) {
nextPage()
return@OnEditorActionListener true
}
false
})
views.etInstance.addTextChangedListener { validateAndShow() }
showPage(0)
validateAndShow()
dialog.setContentView(views.root)
dialog.window?.setLayout(
WindowManager.LayoutParams.MATCH_PARENT,
WindowManager.LayoutParams.WRAP_CONTENT
)
dialog.show()
initServerNameList()
}
private fun initServerNameList() {
val progress = ProgressDialogEx(activity)
progress.setMessageEx(activity.getString(R.string.autocomplete_list_loading))
progress.show()
launchMain {
try {
val instanceList = HashSet<String>().apply {
try {
withContext(AppDispatchers.IO) {
activity.resources.openRawResource(R.raw.server_list).use { inStream ->
val br = BufferedReader(InputStreamReader(inStream, "UTF-8"))
while (true) {
(br.readLine() ?: break)
.trim { it <= ' ' }
.notEmpty()
?.lowercase()
?.let {
add(it)
add(IDN.toASCII(it, IDN.ALLOW_UNASSIGNED))
add(IDN.toUnicode(it, IDN.ALLOW_UNASSIGNED))
}
}
}
}
} catch (ex: Throwable) {
log.e(ex, "can't load server list.")
}
}.toList().sorted()
val adapter = object : ArrayAdapter<String>(
activity, R.layout.lv_spinner_dropdown, ArrayList()
) {
override fun getFilter(): Filter = nameFilter
val nameFilter: Filter = object : Filter() {
override fun convertResultToString(value: Any) =
value as String
override fun performFiltering(constraint: CharSequence?) =
FilterResults().also { result ->
constraint?.notEmpty()?.toString()?.lowercase()?.let { key ->
// suggestions リストは毎回生成する必要がある。publishResultsと同時にアクセスされる場合がある
val suggestions = StringArrayList()
for (s in instanceList) {
if (s.contains(key)) {
suggestions.add(s)
if (suggestions.size >= 20) break
}
}
result.values = suggestions
result.count = suggestions.size
}
}
override fun publishResults(
constraint: CharSequence?,
results: FilterResults?,
) {
clear()
(results?.values as? StringArrayList)?.let { addAll(it) }
notifyDataSetChanged()
}
}
}
adapter.setDropDownViewResource(R.layout.lv_spinner_dropdown)
views.etInstance.setAdapter<ArrayAdapter<String>>(adapter)
} catch (ex: Throwable) {
activity.showToast(ex, "initServerNameList failed.")
} finally {
progress.dismissSafe()
}
}
}
// return validated name. else null
private fun validateAndShow(): String? {
fun showError(s: String) {
views.btnNext.isEnabledAlpha = false
views.tvError.visible().text = s
}
val s = views.etInstance.text.toString().trim()
if (s.isEmpty()) {
showError(activity.getString(R.string.instance_not_specified))
return null
}
// コピペミスに合わせたガイド
arrayOf(
"http://",
"https://",
).forEach {
if (s.contains(it)) {
showError(activity.getString(R.string.server_host_name_cant_contains_it, it))
return null
}
}
if (s.contains("/") || s.contains("@")) {
showError(activity.getString(R.string.instance_not_need_slash))
return null
}
reBadChars.findAll(s).joinToString("") { it.value }.notEmpty()?.let {
showError(activity.getString(R.string.server_host_name_cant_contains_it, it))
return null
}
views.tvError.invisible()
views.btnNext.isEnabledAlpha = true
return s
}
private fun showPage(n: Int) {
views.etInstance.dismissDropDown()
views.etInstance.hideKeyboard()
views.llPageServerHost.vg(n == 0)
views.llPageAuthType.vg(n == 1)
val canBack = n != 0
views.btnPrev.vg(canBack)
val canNext = n == 0
views.btnNext.visibleOrInvisible(canNext)
views.tvHeader.textResource = when (n) {
0 -> R.string.server_host_name
else -> R.string.authentication_select
}
}
private fun nextPage() {
activity.run {
launchAndShowError {
var host = Host.parse(validateAndShow() ?: return@launchAndShowError)
var error: String? = null
val tootInstance = runApiTask2(host) { client ->
try {
// ユーザの入力がホスト名かドメイン名かは分からない。
// WebFingerでホストを調べる
client.getApiHostFromWebFinger(host)?.let {
if (it != host) {
host = it
client.apiHost = it
}
}
// サーバ情報を読む
TootInstance.getExOrThrow(client, forceUpdate = true)
} catch (ex: Throwable) {
error = ex.message
null
}
}
if (isDestroyed || isFinishing) return@launchAndShowError
targetServer = host
targetServerInfo = tootInstance
views.tvServerHost.text = tootInstance?.apDomain?.pretty ?: host.pretty
views.tvServerDesc.run {
when (tootInstance) {
null -> {
textColor = attrColor(R.attr.colorRegexFilterError)
text = error
}
else -> {
textColor = attrColor(R.attr.colorTextContent)
text = (tootInstance.description.notBlank()
?: tootInstance.descriptionOld.notBlank()
?: "(empty server description)"
).let {
DecodeOptions(
applicationContext,
LinkHelper.create(tootInstance),
forceHtml = true,
short = true,
).decodeHTML(it)
}.replace("""\n[\s\n]+""".toRegex(), "\n")
.trim()
}
}
}
showPage(1)
}
}
}
private fun onAuthTypeSelect(action: Action) {
targetServer?.let { onClickOk(dialog, it, targetServerInfo, action) }
}
}