From e629e03b335520fd5eef64c49fa4c5e8ac4da17f Mon Sep 17 00:00:00 2001 From: Antoni Czaplicki <56671347+Antoni-Czaplicki@users.noreply.github.com> Date: Sat, 23 Oct 2021 17:23:42 +0200 Subject: [PATCH] [UI/Login] Implement login QR code scanning. (#100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Enable Vulcan QR login [WiP] * [UI/Login] Implement login QR scanning. Co-authored-by: Kuba Szczodrzyński --- .../edziennik/ui/dialogs/QrScannerDialog.kt | 4 + .../edziennik/ui/login/LoginFormFragment.kt | 310 +++++++++++------- .../edziennik/ui/login/LoginInfo.kt | 12 +- .../ui/login/qr/LoginLibrusQrDecoder.kt | 20 ++ .../edziennik/ui/login/qr/LoginQrDecoder.kt | 7 + .../ui/login/qr/LoginVulcanQrDecoder.kt | 29 ++ app/src/main/res/values/strings.xml | 1 + 7 files changed, 267 insertions(+), 116 deletions(-) create mode 100644 app/src/main/java/pl/szczodrzynski/edziennik/ui/login/qr/LoginLibrusQrDecoder.kt create mode 100644 app/src/main/java/pl/szczodrzynski/edziennik/ui/login/qr/LoginQrDecoder.kt create mode 100644 app/src/main/java/pl/szczodrzynski/edziennik/ui/login/qr/LoginVulcanQrDecoder.kt diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/ui/dialogs/QrScannerDialog.kt b/app/src/main/java/pl/szczodrzynski/edziennik/ui/dialogs/QrScannerDialog.kt index c97419fb..1d0c7707 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/ui/dialogs/QrScannerDialog.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/ui/dialogs/QrScannerDialog.kt @@ -36,4 +36,8 @@ class QrScannerDialog( } root.startCamera() } + + override fun onDismiss() { + root.stopCamera() + } } diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/ui/login/LoginFormFragment.kt b/app/src/main/java/pl/szczodrzynski/edziennik/ui/login/LoginFormFragment.kt index 3cbea439..eec21db2 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/ui/login/LoginFormFragment.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/ui/login/LoginFormFragment.kt @@ -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() + 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() + // 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 getCredential(keyName: String): Pair? { + 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(keyName) + ?: return@forEach + b.textEdit.setText(fieldText) + } + + decoder.focusFieldName()?.let { keyName -> + val (_, b) = getCredential(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) + } } diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/ui/login/LoginInfo.kt b/app/src/main/java/pl/szczodrzynski/edziennik/ui/login/LoginInfo.kt index c9a15659..0c47d2b7 100644 --- a/app/src/main/java/pl/szczodrzynski/edziennik/ui/login/LoginInfo.kt +++ b/app/src/main/java/pl/szczodrzynski/edziennik/ui/login/LoginInfo.kt @@ -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? = null, ) : BaseCredential(keyName, name, errorCodes) { enum class CaseMode { UNCHANGED, UPPER_CASE, LOWER_CASE } } diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/ui/login/qr/LoginLibrusQrDecoder.kt b/app/src/main/java/pl/szczodrzynski/edziennik/ui/login/qr/LoginLibrusQrDecoder.kt new file mode 100644 index 00000000..ed059858 --- /dev/null +++ b/app/src/main/java/pl/szczodrzynski/edziennik/ui/login/qr/LoginLibrusQrDecoder.kt @@ -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? { + if (!regex.matches(value) || value.length > 10) + return null + return mapOf( + "accountCode" to value, + ) + } + + override fun focusFieldName() = "accountPin" +} diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/ui/login/qr/LoginQrDecoder.kt b/app/src/main/java/pl/szczodrzynski/edziennik/ui/login/qr/LoginQrDecoder.kt new file mode 100644 index 00000000..0f7ac50e --- /dev/null +++ b/app/src/main/java/pl/szczodrzynski/edziennik/ui/login/qr/LoginQrDecoder.kt @@ -0,0 +1,7 @@ +package pl.szczodrzynski.edziennik.ui.login.qr + +interface LoginQrDecoder { + + fun decode(value: String): Map? + fun focusFieldName(): String? +} diff --git a/app/src/main/java/pl/szczodrzynski/edziennik/ui/login/qr/LoginVulcanQrDecoder.kt b/app/src/main/java/pl/szczodrzynski/edziennik/ui/login/qr/LoginVulcanQrDecoder.kt new file mode 100644 index 00000000..73530348 --- /dev/null +++ b/app/src/main/java/pl/szczodrzynski/edziennik/ui/login/qr/LoginVulcanQrDecoder.kt @@ -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? { + 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" +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fae23749..f64df860 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1496,4 +1496,5 @@ https:// .mobidziennik.pl/ Edytuj tekst + Kod QR nie wygląda na prawidłowy