diff --git a/.gitmodules b/.gitmodules index c6c8d4eb..4efe2e93 100644 --- a/.gitmodules +++ b/.gitmodules @@ -5,3 +5,6 @@ [submodule "pretixscan/libpretixui-repo"] path = pretixscan/libpretixui-repo url = https://github.com/pretix/libpretixui-android.git +[submodule "pretixscan/libpretixnfc-repo"] + path = pretixscan/libpretixnfc-repo + url = https://github.com/pretix/libpretixnfc.git diff --git a/pretixscan/app/build.gradle b/pretixscan/app/build.gradle index baaeda3f..c2858241 100644 --- a/pretixscan/app/build.gradle +++ b/pretixscan/app/build.gradle @@ -172,6 +172,17 @@ dependencies { implementation(project(':libpretixsync')) { transitive = false } + + // libpretixnfc + implementation(project(':libpretixnfc')) { + transitive = true + } + + // libpretixnfc-android + implementation(project(':libpretixnfc-android')) { + transitive = true + } + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" } diff --git a/pretixscan/app/src/main/AndroidManifest.xml b/pretixscan/app/src/main/AndroidManifest.xml index 44c0140a..30e95ffb 100644 --- a/pretixscan/app/src/main/AndroidManifest.xml +++ b/pretixscan/app/src/main/AndroidManifest.xml @@ -102,6 +102,8 @@ android:exported="false" android:theme="@style/AppTheme.NoActionBar" /> + + Unit)? = null + private var nfcHandler: NfcHandler? = null + companion object { const val PERMISSIONS_REQUEST_CAMERA = 1337 } @@ -206,9 +218,15 @@ class MainActivity : AppCompatActivity(), ReloadableActivity, ScannerView.Result } lastScanTime = System.currentTimeMillis() lastScanCode = result + lastScanSourceType = ReusableMediaType.BARCODE lastIgnoreUnpaid = false lastScanResult = null - handleScan(result, null, !conf.unpaidAsk) + handleScan( + result, + lastScanSourceType.serverName, + null, + !conf.unpaidAsk + ) } }) @@ -271,6 +289,8 @@ class MainActivity : AppCompatActivity(), ReloadableActivity, ScannerView.Result } view_data.configDetails.set(confdetails.trim()) view_data.isOffline.set(conf.offlineMode) + + reloadNfcHandler() } private fun setSearchFilter(f: String) { @@ -290,10 +310,16 @@ class MainActivity : AppCompatActivity(), ReloadableActivity, ScannerView.Result override fun onSearchResultClicked(res: TicketCheckProvider.SearchResult) { lastScanTime = System.currentTimeMillis() lastScanCode = res.secret!! + lastScanSourceType = ReusableMediaType.BARCODE lastScanResult = null lastIgnoreUnpaid = false hideSearchCard() - handleScan(res.secret!!, null, !conf.unpaidAsk) + handleScan( + res.secret!!, + lastScanSourceType.serverName, + null, + !conf.unpaidAsk + ) } }) runOnUiThread { @@ -565,7 +591,9 @@ class MainActivity : AppCompatActivity(), ReloadableActivity, ScannerView.Result Build.VERSION.RELEASE, "pretixSCAN Android", BuildConfig.VERSION_NAME, - null, + try { + conf.keyStore.getOrCreateRsaPubKey("device")?.toString(Charset.defaultCharset()) + } catch (e: NotImplementedError) { null }, null, (application as PretixScan).connectivityHelper ) @@ -966,6 +994,7 @@ class MainActivity : AppCompatActivity(), ReloadableActivity, ScannerView.Result override fun onPause() { handler.removeCallbacks(syncRunnable) (application as PretixScan).connectivityHelper.removeListener(this) + nfcHandler?.stop() super.onPause() if (conf.useCamera) { binding.scannerView.stopCamera() @@ -984,7 +1013,12 @@ class MainActivity : AppCompatActivity(), ReloadableActivity, ScannerView.Result super.onStop() } - fun handleScan(raw_result: String, answers: MutableList?, ignore_unpaid: Boolean = false) { + fun handleScan( + raw_result: String, + source_type: String, + answers: MutableList?, + ignore_unpaid: Boolean = false + ) { if (conf.kioskMode && conf.requiresPin("settings") && conf.verifyPin(raw_result)) { supportActionBar?.show() return @@ -1032,7 +1066,7 @@ class MainActivity : AppCompatActivity(), ReloadableActivity, ScannerView.Result val provider = (application as PretixScan).getCheckProvider(conf) val startedAt = System.currentTimeMillis() try { - checkResult = provider.check(conf.eventSelectionToMap(), result, "barcode", answers, ignore_unpaid, conf.printBadges, when (conf.scanType) { + checkResult = provider.check(conf.eventSelectionToMap(), result, source_type, answers, ignore_unpaid, conf.printBadges, when (conf.scanType) { "exit" -> TicketCheckProvider.CheckInType.EXIT else -> TicketCheckProvider.CheckInType.ENTRY }, allowQuestions = !conf.ignoreQuestions) @@ -1061,9 +1095,13 @@ class MainActivity : AppCompatActivity(), ReloadableActivity, ScannerView.Result } } - fun showQuestionsDialog(res: TicketCheckProvider.CheckResult, secret: String, ignore_unpaid: Boolean, - values: Map?, isResumed: Boolean, - retryHandler: ((String, MutableList, Boolean) -> Unit)): QuestionsDialogInterface { + fun showQuestionsDialog(res: TicketCheckProvider.CheckResult, + secret: String, + sourceType: ReusableMediaType, + ignore_unpaid: Boolean, + values: Map?, + isResumed: Boolean, + retryHandler: ((String, ReusableMediaType, MutableList, Boolean) -> Unit)): QuestionsDialogInterface { val questions = res.requiredAnswers!!.map { it.question.toModel() } for (q in questions) { @@ -1105,7 +1143,7 @@ class MainActivity : AppCompatActivity(), ReloadableActivity, ScannerView.Result values_, null, null, - { answers -> retryHandler(secret, answers, ignore_unpaid) }, + { answers -> retryHandler(secret, sourceType, answers, ignore_unpaid) }, null, attendeeName, attendeeDOB, @@ -1160,9 +1198,14 @@ class MainActivity : AppCompatActivity(), ReloadableActivity, ScannerView.Result startHidingTimer() if (result.type == TicketCheckProvider.CheckResult.Type.ANSWERS_REQUIRED) { view_data.resultState.set(DIALOG) - dialog = showQuestionsDialog(result, lastScanCode, ignore_unpaid, null, false) { secret, answers, ignore_unpaid -> + dialog = showQuestionsDialog(result, lastScanCode, lastScanSourceType, ignore_unpaid, null, false) { secret, sourceType, answers, ignore_unpaid -> stopHidingTimer() - handleScan(secret, answers, ignore_unpaid) + handleScan( + secret, + sourceType.serverName, + answers, + ignore_unpaid + ) } dialog!!.setOnCancelListener(DialogInterface.OnCancelListener { hideCard() }) view_data.setLed(this, view_data.resultState.get()!!, true) @@ -1170,9 +1213,14 @@ class MainActivity : AppCompatActivity(), ReloadableActivity, ScannerView.Result } if (result.type == TicketCheckProvider.CheckResult.Type.UNPAID && result.isCheckinAllowed) { view_data.resultState.set(DIALOG) - dialog = showUnpaidDialog(this, result, lastScanCode, answers) { secret, answers, ignore_unpaid -> + dialog = showUnpaidDialog(this, result, lastScanCode, lastScanSourceType, answers) { secret, sourceType, answers, ignore_unpaid -> stopHidingTimer() - handleScan(secret, answers, ignore_unpaid) + handleScan( + secret, + sourceType.serverName, + answers, + ignore_unpaid + ) } dialog!!.setOnCancelListener(DialogInterface.OnCancelListener { hideCard() }) view_data.setLed(this, view_data.resultState.get()!!, true) @@ -1375,9 +1423,15 @@ class MainActivity : AppCompatActivity(), ReloadableActivity, ScannerView.Result } lastScanTime = System.currentTimeMillis() lastScanCode = s + lastScanSourceType = ReusableMediaType.BARCODE lastScanResult = null lastIgnoreUnpaid = false - handleScan(s, null, !conf.unpaidAsk) + handleScan( + s, + lastScanSourceType.serverName, + null, + !conf.unpaidAsk + ) } override fun dispatchKeyEvent(event: KeyEvent): Boolean { @@ -1391,9 +1445,15 @@ class MainActivity : AppCompatActivity(), ReloadableActivity, ScannerView.Result } lastScanTime = System.currentTimeMillis() lastScanCode = keyboardBuffer + lastScanSourceType = ReusableMediaType.BARCODE lastScanResult = null lastIgnoreUnpaid = false - handleScan(keyboardBuffer, null, !conf.unpaidAsk) + handleScan( + keyboardBuffer, + lastScanSourceType.serverName, + null, + !conf.unpaidAsk + ) keyboardBuffer = "" true } @@ -1557,6 +1617,7 @@ class MainActivity : AppCompatActivity(), ReloadableActivity, ScannerView.Result view_data.resultState.set(DIALOG) lastScanCode = savedInstanceState.getString("lastScanCode", null) + lastScanSourceType = ReusableMediaType.entries.firstOrNull { it.serverName == savedInstanceState.getString("lastScanType", ReusableMediaType.BARCODE.serverName) } ?: ReusableMediaType.BARCODE lastIgnoreUnpaid = savedInstanceState.getBoolean("ignore_unpaid") lastScanResult = om.readValue(savedInstanceState.getString("result"), TicketCheckProvider.CheckResult::class.java) @@ -1574,9 +1635,14 @@ class MainActivity : AppCompatActivity(), ReloadableActivity, ScannerView.Result } } - dialog = showQuestionsDialog(lastScanResult!!, lastScanCode, lastIgnoreUnpaid, values, true) { secret, answers, ignore_unpaid -> + dialog = showQuestionsDialog(lastScanResult!!, lastScanCode, lastScanSourceType, lastIgnoreUnpaid, values, true) { secret, sourceType, answers, ignore_unpaid -> stopHidingTimer() - handleScan(secret, answers, ignore_unpaid) + handleScan( + secret, + sourceType.serverName, + answers, + ignore_unpaid + ) } dialog!!.onRestoreInstanceState(answers) dialog!!.setOnCancelListener(DialogInterface.OnCancelListener { hideCard() }) @@ -1601,9 +1667,102 @@ class MainActivity : AppCompatActivity(), ReloadableActivity, ScannerView.Result outState.putString("result_state", "DIALOG") outState.putString("lastScanCode", lastScanCode) + outState.putString("lastScanType", lastScanSourceType.serverName) outState.putBoolean("ignore_unpaid", lastIgnoreUnpaid) outState.putBundle("answers", dialog!!.onSaveInstanceState()) outState.putString("result", om.writeValueAsString(lastScanResult)) } } + + override fun chipReadSuccessfully(identifier: String, mediaType: ReusableMediaType) { + if (identifier.startsWith("08")) { + runOnUiThread { + showLoadingCard() + displayScanResult( + TicketCheckProvider.CheckResult(TicketCheckProvider.CheckResult.Type.ERROR, getString(R.string.nfc_random_uid), false), + null + ) + } + return + } + + lastScanTime = System.currentTimeMillis() + lastScanCode = identifier + lastScanSourceType = mediaType + lastScanResult = null + lastIgnoreUnpaid = false + + handleScan( + identifier, + mediaType.serverName, + null, + !conf.unpaidAsk + ) + } + + override fun chipReadError(error: ChipReadError, identifier: String?) { + val error = when (error) { + ChipReadError.IO_ERROR -> getString(R.string.nfc_read_error) + ChipReadError.UNKNOWN_CHIP_TYPE -> getString(R.string.nfc_unknown_chip_type) + ChipReadError.FOREIGN_CHIP -> getString(R.string.nfc_foreign_chip) + ChipReadError.EMPTY_CHIP -> getString(R.string.nfc_empty_chip) + else -> error.toString() + } + + runOnUiThread { + showLoadingCard() + displayScanResult( + TicketCheckProvider.CheckResult(TicketCheckProvider.CheckResult.Type.ERROR, error, false), + null + ) + } + } + + private fun reloadNfcHandler() { + if (conf.synchronizedEvents.isNotEmpty()) { + val eventSlug = conf.synchronizedEvents.first() + val settingsManager = SettingsManager(application) + val useRandomIdForNewTags = settingsManager.getBySlug(eventSlug)?.json?.optBoolean("reusable_media_type_nfc_mf0aes_random_uid", false) ?: false + val activeMediaTypes = getActiveMediaTypes( + settingsManager, + eventSlug + ) + if (!activeMediaTypes.any { it.isNfcBased }) { + if (nfcHandler?.isRunning() == true) { + nfcHandler?.stop() + } + return + } + if (nfcHandler?.isRunning() != true || activeMediaTypes.toSet() != nfcHandler?.getMediaTypes()?.toSet()) { + nfcHandler?.stop() + val keySets = (this.applicationContext as PretixScan).db.mediumKeySetQueries.selectAll() + .executeAsList() + .map { it.toModel() } + .map { + Mf0aesKeySet( + it.publicId, + it.organizer == conf.organizerSlug && it.active, + conf.keyStore.decryptRsa( + "device", + eu.pretix.libpretixsync.utils.codec.binary.Base64.decodeBase64(it.uidKey.toByteArray(Charset.defaultCharset())) + ), + conf.keyStore.decryptRsa( + "device", + eu.pretix.libpretixsync.utils.codec.binary.Base64.decodeBase64(it.diversificationKey.toByteArray(Charset.defaultCharset())) + ), + ) + } + + nfcHandler = getNfcHandler(this, keySets, useRandomIdForNewTags, nfcReaderType = conf.nfcReaderType) + nfcHandler!!.setOnChipReadListener(this) + try { + nfcHandler!!.start(activeMediaTypes) + } catch (_: NfcUnsupported) { + // silently ignore, else users on non-nfc-devices are warned all the time + } catch (_: NfcDisabled) { + // silently ignore, we may want to show an unobtrusive warning in the future + } + } + } + } } diff --git a/pretixscan/app/src/main/java/eu/pretix/pretixscan/droid/ui/UnpaidOrderDialogHelper.kt b/pretixscan/app/src/main/java/eu/pretix/pretixscan/droid/ui/UnpaidOrderDialogHelper.kt index 183a9498..dd7ac07b 100644 --- a/pretixscan/app/src/main/java/eu/pretix/pretixscan/droid/ui/UnpaidOrderDialogHelper.kt +++ b/pretixscan/app/src/main/java/eu/pretix/pretixscan/droid/ui/UnpaidOrderDialogHelper.kt @@ -1,25 +1,25 @@ package eu.pretix.pretixscan.droid.ui import android.app.Activity -import android.app.Dialog import android.content.DialogInterface import android.content.Intent import android.view.WindowManager import androidx.appcompat.app.AlertDialog import eu.pretix.libpretixsync.check.TicketCheckProvider import eu.pretix.libpretixsync.db.Answer +import eu.pretix.libpretixsync.db.ReusableMediaType import eu.pretix.libpretixui.android.questions.QuestionsDialogInterface import eu.pretix.pretixscan.droid.R -class UnpaidDialog(ctx: Activity, val secret: String, val answers: MutableList?, - val retryHandler: ((String, MutableList?, Boolean) -> Unit)) : AlertDialog(ctx), QuestionsDialogInterface { +class UnpaidDialog(ctx: Activity, val secret: String, val sourceType: ReusableMediaType, val answers: MutableList?, + val retryHandler: ((String, ReusableMediaType, MutableList?, Boolean) -> Unit)) : AlertDialog(ctx), QuestionsDialogInterface { init { setTitle(R.string.dialog_unpaid_title) setMessage(ctx.getString(R.string.dialog_unpaid_text)) setButton(DialogInterface.BUTTON_POSITIVE, ctx.getString(R.string.dialog_unpaid_retry)) { p0, p1 -> dismiss() - retryHandler(secret, answers, true) + retryHandler(secret, sourceType, answers, true) } setButton(DialogInterface.BUTTON_NEGATIVE, ctx.getString(eu.pretix.libpretixui.android.R.string.cancel)) { p0, p1 -> cancel() @@ -31,10 +31,13 @@ class UnpaidDialog(ctx: Activity, val secret: String, val answers: MutableList?, - retryHandler: ((String, MutableList?, Boolean) -> Unit)): QuestionsDialogInterface { - val dialog = UnpaidDialog(ctx, secret, answers, retryHandler) +fun showUnpaidDialog(ctx: Activity, + res: TicketCheckProvider.CheckResult, + secret: String, + sourceType: ReusableMediaType, + answers: MutableList?, + retryHandler: ((String, ReusableMediaType, MutableList?, Boolean) -> Unit)): QuestionsDialogInterface { + val dialog = UnpaidDialog(ctx, secret, sourceType, answers, retryHandler) dialog.show() return dialog } diff --git a/pretixscan/app/src/main/java/eu/pretix/pretixscan/utils/Settings.kt b/pretixscan/app/src/main/java/eu/pretix/pretixscan/utils/Settings.kt new file mode 100644 index 00000000..a40c65e9 --- /dev/null +++ b/pretixscan/app/src/main/java/eu/pretix/pretixscan/utils/Settings.kt @@ -0,0 +1,13 @@ +package eu.pretix.pretixscan.utils + +import android.app.Application +import eu.pretix.libpretixsync.models.Settings +import eu.pretix.libpretixsync.models.db.toModel +import eu.pretix.libpretixsync.utils.SettingsManager +import eu.pretix.pretixscan.droid.PretixScan + +class SettingsManager(private val application: Application): SettingsManager { + override fun getBySlug(eventSlug: String): Settings? { + return (application as PretixScan).db.settingsQueries.selectBySlug(eventSlug).executeAsOneOrNull()?.toModel() + } +} \ No newline at end of file diff --git a/pretixscan/libpretixnfc-repo b/pretixscan/libpretixnfc-repo new file mode 160000 index 00000000..d2a04c79 --- /dev/null +++ b/pretixscan/libpretixnfc-repo @@ -0,0 +1 @@ +Subproject commit d2a04c79ecb6d8b7abf4b8f1a59a609b879b0c39 diff --git a/pretixscan/libpretixsync-repo b/pretixscan/libpretixsync-repo index c4011dc8..1edc026e 160000 --- a/pretixscan/libpretixsync-repo +++ b/pretixscan/libpretixsync-repo @@ -1 +1 @@ -Subproject commit c4011dc8c6604a50664bd64ddeec395d745cb589 +Subproject commit 1edc026ebe40b4c68b7cc5f5f5383883b933f231 diff --git a/pretixscan/libpretixui-repo b/pretixscan/libpretixui-repo index 4136af04..8bb21189 160000 --- a/pretixscan/libpretixui-repo +++ b/pretixscan/libpretixui-repo @@ -1 +1 @@ -Subproject commit 4136af0492dff9610b724d3b718ed3189132aa39 +Subproject commit 8bb211896fd68e3888d187ce6c60f0b982a9317e diff --git a/pretixscan/settings.gradle b/pretixscan/settings.gradle index ffa3386a..b9a52074 100644 --- a/pretixscan/settings.gradle +++ b/pretixscan/settings.gradle @@ -12,9 +12,11 @@ plugins { id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0' } -include ':app', ':libpretixsync', ':libpretixui-android', ':android-libserenegiantcommon', ':android-libusbcameracommon', ':android-libuvccamera' +include ':app', ':libpretixsync', ':libpretixui-android', ':libpretixnfc', ':libpretixnfc-android', ':android-libserenegiantcommon', ':android-libusbcameracommon', ':android-libuvccamera' project(':libpretixsync').projectDir = new File('libpretixsync-repo/libpretixsync') project(':libpretixui-android').projectDir = new File('libpretixui-repo/libpretixui-android') +project(':libpretixnfc').projectDir = new File('libpretixnfc-repo/libpretixnfc') +project(':libpretixnfc-android').projectDir = new File('libpretixnfc-repo/libpretixnfc-android') project(':android-libserenegiantcommon').projectDir = new File('libpretixui-repo/android-libserenegiantcommon') project(':android-libusbcameracommon').projectDir = new File('libpretixui-repo/android-libusbcameracommon') project(':android-libuvccamera').projectDir = new File('libpretixui-repo/android-libuvccamera')