[UI/Login] Implement login QR code scanning. (#100)

* Enable Vulcan QR login [WiP]

* [UI/Login] Implement login QR scanning.

Co-authored-by: Kuba Szczodrzyński <kuba@szczodrzynski.pl>
This commit is contained in:
Antoni Czaplicki 2021-10-23 17:23:42 +02:00 committed by GitHub
parent e2ad3758e0
commit e629e03b33
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 267 additions and 116 deletions

View File

@ -36,4 +36,8 @@ class QrScannerDialog(
}
root.startCamera()
}
override fun onDismiss() {
root.stopCamera()
}
}

View File

@ -10,20 +10,28 @@ import android.text.InputType
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.view.isVisible
import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment
import androidx.viewbinding.ViewBinding
import com.google.android.material.textfield.TextInputLayout
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import pl.szczodrzynski.edziennik.*
import pl.szczodrzynski.edziennik.App
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.databinding.LoginFormCheckboxItemBinding
import pl.szczodrzynski.edziennik.databinding.LoginFormFieldItemBinding
import pl.szczodrzynski.edziennik.databinding.LoginFormFragmentBinding
import pl.szczodrzynski.edziennik.ext.*
import pl.szczodrzynski.edziennik.ui.dialogs.QrScannerDialog
import pl.szczodrzynski.edziennik.ui.login.LoginInfo.BaseCredential
import pl.szczodrzynski.edziennik.ui.login.LoginInfo.FormCheckbox
import pl.szczodrzynski.edziennik.ui.login.LoginInfo.FormField
import pl.szczodrzynski.navlib.colorAttr
import java.util.*
import kotlin.coroutines.CoroutineContext
@ -31,8 +39,10 @@ import kotlin.coroutines.CoroutineContext
class LoginFormFragment : Fragment(), CoroutineScope {
companion object {
private const val TAG = "LoginFormFragment"
// eggs
var wantEggs = false
var isEggs = false
}
private lateinit var app: App
@ -44,9 +54,23 @@ class LoginFormFragment : Fragment(), CoroutineScope {
override val coroutineContext: CoroutineContext
get() = job + Dispatchers.Main
// local/private variables go here
private val credentials = mutableMapOf<BaseCredential, ViewBinding>()
private val platformName
get() = arguments?.getString("platformName")
private val platformGuideText
get() = arguments?.getString("platformGuideText")
private val platformDescription
get() = arguments?.getString("platformDescription")
private val platformFormFields
get() = arguments?.getString("platformFormFields")?.split(";")
private val platformRealmData
get() = arguments?.getString("platformRealmData")?.toJsonObject()
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View? {
activity = (getActivity() as LoginActivity?) ?: return null
context ?: return null
app = activity.application as App
@ -54,7 +78,6 @@ class LoginFormFragment : Fragment(), CoroutineScope {
return b.root
}
@SuppressLint("ResourceType")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
if (!isAdded) return
b.backButton.onClick { nav.navigateUp() }
@ -67,71 +90,25 @@ class LoginFormFragment : Fragment(), CoroutineScope {
val loginMode = arguments?.getInt("loginMode") ?: return
val mode = register.loginModes.firstOrNull { it.loginMode == loginMode } ?: return
val platformName = arguments?.getString("platformName")
val platformGuideText = arguments?.getString("platformGuideText")
val platformDescription = arguments?.getString("platformDescription")
val platformFormFields = arguments?.getString("platformFormFields")?.split(";")
val platformRealmData = arguments?.getString("platformRealmData")?.toJsonObject()
b.title.setText(R.string.login_form_title_format, app.getString(register.registerName))
b.subTitle.text = platformName ?: app.getString(mode.name)
b.text.text = platformGuideText ?: app.getString(mode.guideText)
val credentials = mutableMapOf<LoginInfo.BaseCredential, Any>()
// eggs
isEggs = register.internalName == "podlasie"
for (credential in mode.credentials) {
if (platformFormFields?.contains(credential.keyName) == false)
continue
if (credential is LoginInfo.FormField) {
val b = LoginFormFieldItemBinding.inflate(layoutInflater)
b.textLayout.hint = app.getString(credential.name)
if (credential.isNumber) {
b.textEdit.inputType = InputType.TYPE_CLASS_NUMBER
val b = when (credential) {
is FormField -> buildFormField(credential)
is FormCheckbox -> buildFormCheckbox(credential)
else -> continue
}
if (credential.hideText) {
b.textEdit.inputType = InputType.TYPE_TEXT_VARIATION_PASSWORD
b.textLayout.endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
}
b.textEdit.addTextChangedListener {
b.textLayout.error = null
}
if (credential.prefix != null)
b.textLayout.prefixText = app.getString(credential.prefix)
if (credential.suffix != null)
b.textLayout.suffixText = app.getString(credential.suffix)
b.textEdit.id = credential.name
b.textEdit.setText(arguments?.getString(credential.keyName) ?: "")
b.textLayout.startIconDrawable = IconicsDrawable(activity).apply {
icon = credential.icon
sizeDp = 24
colorAttr(activity, R.attr.colorOnBackground)
}
this.b.formContainer.addView(b.root)
credentials[credential] = b
}
if (credential is LoginInfo.FormCheckbox) {
val b = LoginFormCheckboxItemBinding.inflate(layoutInflater)
b.checkbox.text = app.getString(credential.name)
b.checkbox.onChange { _, isChecked ->
b.errorText.text = null
// eggs
if (register.internalName == "podlasie") {
wantEggs = !isChecked
}
}
if (arguments?.containsKey(credential.keyName) == true) {
b.checkbox.isChecked = arguments?.getBoolean(credential.keyName) == true
}
this.b.formContainer.addView(b.root)
credentials[credential] = b
}
}
activity.lastError?.let { error ->
activity.lastError = null
@ -156,6 +133,114 @@ class LoginFormFragment : Fragment(), CoroutineScope {
}
b.loginButton.onClick {
login(loginType, loginMode)
}
}
@Suppress("UNCHECKED_CAST")
private fun <C : BaseCredential, B : ViewBinding> getCredential(keyName: String): Pair<C, B>? {
val (credential, binding) = credentials.entries.firstOrNull {
it.key.keyName == keyName
} ?: return null
val c = credential as? C ?: return null
val b = binding as? B ?: return null
return c to b
}
@SuppressLint("ResourceType")
private fun buildFormField(credential: FormField): LoginFormFieldItemBinding {
val b = LoginFormFieldItemBinding.inflate(layoutInflater)
if (credential.isNumber) {
b.textEdit.inputType = InputType.TYPE_CLASS_NUMBER
}
if (credential.qrDecoderClass != null) {
b.textLayout.endIconDrawable = IconicsDrawable(activity).apply {
icon = CommunityMaterial.Icon3.cmd_qrcode
sizeDp = 24
colorAttr(activity, R.attr.colorOnBackground)
}
b.textLayout.endIconMode = TextInputLayout.END_ICON_CUSTOM
b.textLayout.setEndIconOnClickListener {
scanQrCode(credential)
}
}
if (credential.hideText) {
b.textEdit.inputType = InputType.TYPE_TEXT_VARIATION_PASSWORD
b.textLayout.endIconMode = TextInputLayout.END_ICON_PASSWORD_TOGGLE
}
b.textEdit.addTextChangedListener {
b.textLayout.error = null
}
b.textEdit.id = credential.name
b.textEdit.setText(arguments?.getString(credential.keyName))
b.textLayout.hint = credential.name.resolveString(app)
b.textLayout.prefixText = credential.prefix?.resolveString(app)
b.textLayout.suffixText = credential.suffix?.resolveString(app)
b.textLayout.tag = credential
b.textLayout.startIconDrawable = IconicsDrawable(activity).apply {
icon = credential.icon
sizeDp = 24
colorAttr(activity, R.attr.colorOnBackground)
}
return b
}
private fun buildFormCheckbox(credential: FormCheckbox): LoginFormCheckboxItemBinding {
val b = LoginFormCheckboxItemBinding.inflate(layoutInflater)
b.checkbox.onChange { _, isChecked ->
b.errorText.text = null
// eggs
if (isEggs) {
wantEggs = !isChecked
}
}
if (arguments?.containsKey(credential.keyName) == true) {
b.checkbox.isChecked = arguments?.getBoolean(credential.keyName) == true
}
b.checkbox.tag = credential
b.checkbox.text = credential.name.resolveString(app)
return b
}
private fun scanQrCode(credential: FormField) {
val qrDecoderClass = credential.qrDecoderClass ?: return
app.permissionManager.requestCameraPermission(activity, R.string.permissions_qr_scanner) {
QrScannerDialog(activity, onCodeScanned = { code ->
val decoder = qrDecoderClass.newInstance()
val values = decoder.decode(code)
if (values == null) {
Toast.makeText(activity, R.string.login_qr_decoding_error, Toast.LENGTH_SHORT).show()
return@QrScannerDialog
}
values.forEach { (keyName, fieldText) ->
val (_, b) = getCredential<FormField, LoginFormFieldItemBinding>(keyName)
?: return@forEach
b.textEdit.setText(fieldText)
}
decoder.focusFieldName()?.let { keyName ->
val (_, b) = getCredential<FormField, LoginFormFieldItemBinding>(keyName)
?: return@let
b.textEdit.requestFocus()
}
}).show()
}
}
private fun login(loginType: Int, loginMode: Int) {
val payload = Bundle(
"loginType" to loginType,
"loginMode" to loginMode
@ -169,15 +254,15 @@ class LoginFormFragment : Fragment(), CoroutineScope {
var hasErrors = false
credentials.forEach { (credential, b) ->
if (credential is LoginInfo.FormField && b is LoginFormFieldItemBinding) {
if (credential is FormField && b is LoginFormFieldItemBinding) {
var text = b.textEdit.text?.toString() ?: return@forEach
if (!credential.hideText)
text = text.trim()
if (credential.caseMode == LoginInfo.FormField.CaseMode.UPPER_CASE)
if (credential.caseMode == FormField.CaseMode.UPPER_CASE)
text = text.uppercase()
if (credential.caseMode == FormField.CaseMode.LOWER_CASE)
text = text.uppercase()
if (credential.caseMode == LoginInfo.FormField.CaseMode.LOWER_CASE)
text = text.lowercase()
credential.stripTextRegex?.let {
text = text.replace(it.toRegex(), "")
@ -200,7 +285,7 @@ class LoginFormFragment : Fragment(), CoroutineScope {
payload.putString(credential.keyName, text)
arguments?.putString(credential.keyName, text)
}
if (credential is LoginInfo.FormCheckbox && b is LoginFormCheckboxItemBinding) {
if (credential is FormCheckbox && b is LoginFormCheckboxItemBinding) {
val checked = b.checkbox.isChecked
payload.putBoolean(credential.keyName, checked)
arguments?.putBoolean(credential.keyName, checked)
@ -208,9 +293,8 @@ class LoginFormFragment : Fragment(), CoroutineScope {
}
if (hasErrors)
return@onClick
return
nav.navigate(R.id.loginProgressFragment, payload, activity.navOptions)
}
}
}

View File

@ -11,6 +11,9 @@ import com.mikepenz.iconics.typeface.library.community.material.CommunityMateria
import pl.szczodrzynski.edziennik.R
import pl.szczodrzynski.edziennik.data.api.*
import pl.szczodrzynski.edziennik.ui.grades.models.ExpandableItemModel
import pl.szczodrzynski.edziennik.ui.login.qr.LoginLibrusQrDecoder
import pl.szczodrzynski.edziennik.ui.login.qr.LoginQrDecoder
import pl.szczodrzynski.edziennik.ui.login.qr.LoginVulcanQrDecoder
import pl.szczodrzynski.fslogin.realm.RealmData
object LoginInfo {
@ -105,7 +108,8 @@ object LoginInfo {
errorCodes = mapOf(),
isRequired = true,
validationRegex = "[A-Z0-9_]+",
caseMode = FormField.CaseMode.UPPER_CASE
caseMode = FormField.CaseMode.UPPER_CASE,
qrDecoderClass = LoginLibrusQrDecoder::class.java
),
FormField(
keyName = "accountPin",
@ -152,7 +156,8 @@ object LoginInfo {
),
isRequired = true,
validationRegex = "[A-Z0-9]{5,12}",
caseMode = FormField.CaseMode.UPPER_CASE
caseMode = FormField.CaseMode.UPPER_CASE,
qrDecoderClass = LoginVulcanQrDecoder::class.java
),
FormField(
keyName = "symbol",
@ -409,7 +414,8 @@ object LoginInfo {
val caseMode: CaseMode = CaseMode.UNCHANGED,
val hideText: Boolean = false,
val isNumber: Boolean = false,
val stripTextRegex: String? = null
val stripTextRegex: String? = null,
val qrDecoderClass: Class<out LoginQrDecoder>? = null,
) : BaseCredential(keyName, name, errorCodes) {
enum class CaseMode { UNCHANGED, UPPER_CASE, LOWER_CASE }
}

View File

@ -0,0 +1,20 @@
/*
* Copyright (c) Kuba Szczodrzyński 2021-10-23.
*/
package pl.szczodrzynski.edziennik.ui.login.qr
class LoginLibrusQrDecoder : LoginQrDecoder {
private val regex = "[A-Z0-9_]+".toRegex()
override fun decode(value: String): Map<String, String>? {
if (!regex.matches(value) || value.length > 10)
return null
return mapOf(
"accountCode" to value,
)
}
override fun focusFieldName() = "accountPin"
}

View File

@ -0,0 +1,7 @@
package pl.szczodrzynski.edziennik.ui.login.qr
interface LoginQrDecoder {
fun decode(value: String): Map<String, String>?
fun focusFieldName(): String?
}

View File

@ -0,0 +1,29 @@
/*
* Copyright (c) Kuba Szczodrzyński 2021-10-23.
*/
package pl.szczodrzynski.edziennik.ui.login.qr
import pl.szczodrzynski.edziennik.ext.get
import pl.szczodrzynski.edziennik.utils.Utils
class LoginVulcanQrDecoder : LoginQrDecoder {
private val regex = "CERT#https?://.+?/([A-z]+)/mobile-api#([A-z0-9]+)#ENDCERT".toRegex()
override fun decode(value: String): Map<String, String>? {
val data = try {
Utils.VulcanQrEncryptionUtils.decode(value)
} catch (e: Exception) {
return null
}
val match = regex.find(data) ?: return null
return mapOf(
"deviceToken" to match[2],
"symbol" to match[1],
)
}
override fun focusFieldName() = "devicePin"
}

View File

@ -1496,4 +1496,5 @@
<string name="login_mobidziennik_server_prefix">https://</string>
<string name="login_mobidziennik_server_suffix">.mobidziennik.pl/</string>
<string name="styled_text_dialog_title">Edytuj tekst</string>
<string name="login_qr_decoding_error">Kod QR nie wygląda na prawidłowy</string>
</resources>