[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() root.startCamera()
} }
override fun onDismiss() {
root.stopCamera()
}
} }

View File

@ -10,20 +10,28 @@ import android.text.InputType
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.Toast
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.core.widget.addTextChangedListener import androidx.core.widget.addTextChangedListener
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.viewbinding.ViewBinding
import com.google.android.material.textfield.TextInputLayout import com.google.android.material.textfield.TextInputLayout
import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.typeface.library.community.material.CommunityMaterial
import com.mikepenz.iconics.utils.sizeDp import com.mikepenz.iconics.utils.sizeDp
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job 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.LoginFormCheckboxItemBinding
import pl.szczodrzynski.edziennik.databinding.LoginFormFieldItemBinding import pl.szczodrzynski.edziennik.databinding.LoginFormFieldItemBinding
import pl.szczodrzynski.edziennik.databinding.LoginFormFragmentBinding import pl.szczodrzynski.edziennik.databinding.LoginFormFragmentBinding
import pl.szczodrzynski.edziennik.ext.* 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 pl.szczodrzynski.navlib.colorAttr
import java.util.* import java.util.*
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
@ -31,8 +39,10 @@ import kotlin.coroutines.CoroutineContext
class LoginFormFragment : Fragment(), CoroutineScope { class LoginFormFragment : Fragment(), CoroutineScope {
companion object { companion object {
private const val TAG = "LoginFormFragment" private const val TAG = "LoginFormFragment"
// eggs // eggs
var wantEggs = false var wantEggs = false
var isEggs = false
} }
private lateinit var app: App private lateinit var app: App
@ -44,9 +54,23 @@ class LoginFormFragment : Fragment(), CoroutineScope {
override val coroutineContext: CoroutineContext override val coroutineContext: CoroutineContext
get() = job + Dispatchers.Main 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 activity = (getActivity() as LoginActivity?) ?: return null
context ?: return null context ?: return null
app = activity.application as App app = activity.application as App
@ -54,7 +78,6 @@ class LoginFormFragment : Fragment(), CoroutineScope {
return b.root return b.root
} }
@SuppressLint("ResourceType")
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
if (!isAdded) return if (!isAdded) return
b.backButton.onClick { nav.navigateUp() } b.backButton.onClick { nav.navigateUp() }
@ -67,70 +90,24 @@ class LoginFormFragment : Fragment(), CoroutineScope {
val loginMode = arguments?.getInt("loginMode") ?: return val loginMode = arguments?.getInt("loginMode") ?: return
val mode = register.loginModes.firstOrNull { it.loginMode == 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.title.setText(R.string.login_form_title_format, app.getString(register.registerName))
b.subTitle.text = platformName ?: app.getString(mode.name) b.subTitle.text = platformName ?: app.getString(mode.name)
b.text.text = platformGuideText ?: app.getString(mode.guideText) b.text.text = platformGuideText ?: app.getString(mode.guideText)
val credentials = mutableMapOf<LoginInfo.BaseCredential, Any>() // eggs
isEggs = register.internalName == "podlasie"
for (credential in mode.credentials) { for (credential in mode.credentials) {
if (platformFormFields?.contains(credential.keyName) == false) if (platformFormFields?.contains(credential.keyName) == false)
continue continue
if (credential is LoginInfo.FormField) { val b = when (credential) {
val b = LoginFormFieldItemBinding.inflate(layoutInflater) is FormField -> buildFormField(credential)
b.textLayout.hint = app.getString(credential.name) is FormCheckbox -> buildFormCheckbox(credential)
if (credential.isNumber) { else -> continue
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
} }
this.b.formContainer.addView(b.root)
credentials[credential] = b
} }
activity.lastError?.let { error -> activity.lastError?.let { error ->
@ -156,61 +133,168 @@ class LoginFormFragment : Fragment(), CoroutineScope {
} }
b.loginButton.onClick { b.loginButton.onClick {
val payload = Bundle( login(loginType, loginMode)
"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)
} }
} }
@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)
}
} }

View File

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