forked from github/szkolny
[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:
parent
e2ad3758e0
commit
e629e03b33
@ -36,4 +36,8 @@ class QrScannerDialog(
|
||||
}
|
||||
root.startCamera()
|
||||
}
|
||||
|
||||
override fun onDismiss() {
|
||||
root.stopCamera()
|
||||
}
|
||||
}
|
||||
|
@ -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,70 +90,24 @@ 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
|
||||
}
|
||||
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
|
||||
val b = when (credential) {
|
||||
is FormField -> buildFormField(credential)
|
||||
is FormCheckbox -> buildFormCheckbox(credential)
|
||||
else -> continue
|
||||
}
|
||||
this.b.formContainer.addView(b.root)
|
||||
credentials[credential] = b
|
||||
}
|
||||
|
||||
activity.lastError?.let { error ->
|
||||
@ -156,61 +133,168 @@ class LoginFormFragment : Fragment(), CoroutineScope {
|
||||
}
|
||||
|
||||
b.loginButton.onClick {
|
||||
val payload = Bundle(
|
||||
"loginType" to loginType,
|
||||
"loginMode" to loginMode
|
||||
)
|
||||
|
||||
if (App.debugMode && b.fakeLogin.isChecked) {
|
||||
payload.putBoolean("fakeLogin", true)
|
||||
}
|
||||
|
||||
payload.putBundle("webRealmData", platformRealmData?.toBundle())
|
||||
|
||||
var hasErrors = false
|
||||
credentials.forEach { (credential, b) ->
|
||||
if (credential is LoginInfo.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)
|
||||
text = text.uppercase()
|
||||
if (credential.caseMode == LoginInfo.FormField.CaseMode.LOWER_CASE)
|
||||
text = text.lowercase()
|
||||
|
||||
credential.stripTextRegex?.let {
|
||||
text = text.replace(it.toRegex(), "")
|
||||
}
|
||||
|
||||
b.textEdit.setText(text)
|
||||
|
||||
if (credential.isRequired && text.isBlank()) {
|
||||
b.textLayout.error = app.getString(credential.emptyText)
|
||||
hasErrors = true
|
||||
return@forEach
|
||||
}
|
||||
|
||||
if (!text.matches(credential.validationRegex.toRegex())) {
|
||||
b.textLayout.error = app.getString(credential.invalidText)
|
||||
hasErrors = true
|
||||
return@forEach
|
||||
}
|
||||
|
||||
payload.putString(credential.keyName, text)
|
||||
arguments?.putString(credential.keyName, text)
|
||||
}
|
||||
if (credential is LoginInfo.FormCheckbox && b is LoginFormCheckboxItemBinding) {
|
||||
val checked = b.checkbox.isChecked
|
||||
payload.putBoolean(credential.keyName, checked)
|
||||
arguments?.putBoolean(credential.keyName, checked)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasErrors)
|
||||
return@onClick
|
||||
|
||||
nav.navigate(R.id.loginProgressFragment, payload, activity.navOptions)
|
||||
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
|
||||
)
|
||||
|
||||
if (App.debugMode && b.fakeLogin.isChecked) {
|
||||
payload.putBoolean("fakeLogin", true)
|
||||
}
|
||||
|
||||
payload.putBundle("webRealmData", platformRealmData?.toBundle())
|
||||
|
||||
var hasErrors = false
|
||||
credentials.forEach { (credential, b) ->
|
||||
if (credential is FormField && b is LoginFormFieldItemBinding) {
|
||||
var text = b.textEdit.text?.toString() ?: return@forEach
|
||||
if (!credential.hideText)
|
||||
text = text.trim()
|
||||
|
||||
if (credential.caseMode == FormField.CaseMode.UPPER_CASE)
|
||||
text = text.uppercase()
|
||||
if (credential.caseMode == FormField.CaseMode.LOWER_CASE)
|
||||
text = text.uppercase()
|
||||
|
||||
credential.stripTextRegex?.let {
|
||||
text = text.replace(it.toRegex(), "")
|
||||
}
|
||||
|
||||
b.textEdit.setText(text)
|
||||
|
||||
if (credential.isRequired && text.isBlank()) {
|
||||
b.textLayout.error = app.getString(credential.emptyText)
|
||||
hasErrors = true
|
||||
return@forEach
|
||||
}
|
||||
|
||||
if (!text.matches(credential.validationRegex.toRegex())) {
|
||||
b.textLayout.error = app.getString(credential.invalidText)
|
||||
hasErrors = true
|
||||
return@forEach
|
||||
}
|
||||
|
||||
payload.putString(credential.keyName, text)
|
||||
arguments?.putString(credential.keyName, text)
|
||||
}
|
||||
if (credential is FormCheckbox && b is LoginFormCheckboxItemBinding) {
|
||||
val checked = b.checkbox.isChecked
|
||||
payload.putBoolean(credential.keyName, checked)
|
||||
arguments?.putBoolean(credential.keyName, checked)
|
||||
}
|
||||
}
|
||||
|
||||
if (hasErrors)
|
||||
return
|
||||
|
||||
nav.navigate(R.id.loginProgressFragment, payload, activity.navOptions)
|
||||
}
|
||||
}
|
||||
|
@ -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 }
|
||||
}
|
||||
|
@ -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"
|
||||
}
|
@ -0,0 +1,7 @@
|
||||
package pl.szczodrzynski.edziennik.ui.login.qr
|
||||
|
||||
interface LoginQrDecoder {
|
||||
|
||||
fun decode(value: String): Map<String, String>?
|
||||
fun focusFieldName(): String?
|
||||
}
|
@ -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"
|
||||
}
|
@ -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>
|
||||
|
Loading…
Reference in New Issue
Block a user