310 lines
12 KiB
Kotlin
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) }
|
|
}
|
|
}
|