From 58abfe32bad4c2a3bf4efb71c8d238c640c8a3e2 Mon Sep 17 00:00:00 2001 From: Martin Gross Date: Tue, 17 Mar 2026 14:17:27 +0100 Subject: [PATCH 01/13] Split reusable components of pretix' NFC usage across libpretixnfc, libpretixsync and libpretixui --- libpretixnfc-android/build.gradle | 82 ++++++ libpretixnfc-android/gradlew | 249 ++++++++++++++++++ libpretixnfc-android/gradlew.bat | 92 +++++++ libpretixnfc-android/libs/acssmc-1.1.5.jar | Bin 0 -> 56673 bytes libpretixnfc-android/settings.gradle | 20 ++ .../src/main/AndroidManifest.xml | 5 + .../android/hardware/AcsNfcHandler.kt | 244 +++++++++++++++++ .../hardware/AndroidNativeNfcHandler.kt | 180 +++++++++++++ .../android/hardware/NfcHandler.kt | 60 +++++ .../android/hardware/acs/AcsReaderService.kt | 235 +++++++++++++++++ .../android/platform/AndroidKeyStore.kt | 130 +++++++++ .../src/main/res/drawable/ic_nfc_24dp.xml | 5 + .../main/res/drawable/ic_nfc_white_24dp.xml | 5 + .../src/main/res/values-de/strings.xml | 11 + .../src/main/res/values/strings.xml | 11 + .../platform/HardwareBackedKeyStore.kt | 9 + 16 files changed, 1338 insertions(+) create mode 100644 libpretixnfc-android/build.gradle create mode 100755 libpretixnfc-android/gradlew create mode 100644 libpretixnfc-android/gradlew.bat create mode 100644 libpretixnfc-android/libs/acssmc-1.1.5.jar create mode 100644 libpretixnfc-android/settings.gradle create mode 100644 libpretixnfc-android/src/main/AndroidManifest.xml create mode 100644 libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AcsNfcHandler.kt create mode 100644 libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AndroidNativeNfcHandler.kt create mode 100644 libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/NfcHandler.kt create mode 100644 libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/acs/AcsReaderService.kt create mode 100644 libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/platform/AndroidKeyStore.kt create mode 100644 libpretixnfc-android/src/main/res/drawable/ic_nfc_24dp.xml create mode 100644 libpretixnfc-android/src/main/res/drawable/ic_nfc_white_24dp.xml create mode 100644 libpretixnfc-android/src/main/res/values-de/strings.xml create mode 100644 libpretixnfc-android/src/main/res/values/strings.xml create mode 100644 libpretixnfc/src/main/java/eu/pretix/libpretixnfc/platform/HardwareBackedKeyStore.kt diff --git a/libpretixnfc-android/build.gradle b/libpretixnfc-android/build.gradle new file mode 100644 index 0000000..c2bd6a9 --- /dev/null +++ b/libpretixnfc-android/build.gradle @@ -0,0 +1,82 @@ +// NOTE: update versions in settings.gradle! +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' + id 'org.jetbrains.kotlin.kapt' +} + +android { + namespace 'eu.pretix.libpretinfc.android' + + compileSdkVersion 33 + + defaultConfig { + minSdkVersion 21 + targetSdkVersion 33 + multiDexEnabled true + + vectorDrawables { + useSupportLibrary = true + } + } + compileOptions { + coreLibraryDesugaringEnabled true + } + kotlin { + jvmToolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } + } + java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } + } + buildFeatures { + buildConfig = true + dataBinding = true + viewBinding = true + } +} + +repositories { + mavenCentral() + maven { url 'https://jitpack.io' } +} + +dependencies { + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' + + def kotlin_version = "1.9.23" // update in settings.gradle too + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation 'com.neovisionaries:nv-i18n:1.27' + implementation 'joda-time:joda-time:2.10.10' + implementation 'com.github.ialokim:android-phone-field:0.2.3' + implementation 'androidx.core:core-ktx:1.10.0' + implementation 'androidx.appcompat:appcompat:1.4.2' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.lifecycle:lifecycle-common:2.6.0' + implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' + implementation 'com.google.zxing:core:3.5.3' + implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" + implementation 'com.google.android.material:material:1.6.1' + implementation 'com.github.pretix:json-logic-kotlin:1.0.0' + + implementation(files('libs/acssmc-1.1.5.jar')) + + implementation(project(':libpretixnfc')) { + transitive = false + } + implementation(project(':libpretixsync')) { + transitive = false + } + implementation(project(':libpretixui-android')) { + transitive = true + } + implementation 'io.sentry:sentry-android:8.18.0' + implementation 'com.madgag.spongycastle:prov:1.58.0.0' + + implementation 'com.github.bumptech.glide:glide:4.12.0' + kapt 'com.github.bumptech.glide:compiler:4.12.0' +} + diff --git a/libpretixnfc-android/gradlew b/libpretixnfc-android/gradlew new file mode 100755 index 0000000..1aa94a4 --- /dev/null +++ b/libpretixnfc-android/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/libpretixnfc-android/gradlew.bat b/libpretixnfc-android/gradlew.bat new file mode 100644 index 0000000..25da30d --- /dev/null +++ b/libpretixnfc-android/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/libpretixnfc-android/libs/acssmc-1.1.5.jar b/libpretixnfc-android/libs/acssmc-1.1.5.jar new file mode 100644 index 0000000000000000000000000000000000000000..c817966de84ad3ee0c7042e73ae4b13b31001157 GIT binary patch literal 56673 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 P1@I#$h(M^dG5-1o*=m_} literal 0 HcmV?d00001 diff --git a/libpretixnfc-android/settings.gradle b/libpretixnfc-android/settings.gradle new file mode 100644 index 0000000..d680e64 --- /dev/null +++ b/libpretixnfc-android/settings.gradle @@ -0,0 +1,20 @@ +pluginManagement { + repositories { + gradlePluginPortal() + google() + maven { + url 'https://plugins.gradle.org/m2/' + } + } +} + +// workaround: plugin definition with version here, so a standalone build is possible +// but building it inside the multi module project doesn't exit with "unknown version already on classpath" error +plugins { + id 'com.android.library' version '8.4.2' apply false + id 'org.jetbrains.kotlin.android' version '1.9.23' apply false + id 'org.jetbrains.kotlin.kapt' version '1.9.23' apply false +} + +rootProject.name = 'eu.pretix.libpretixnfc-android' + diff --git a/libpretixnfc-android/src/main/AndroidManifest.xml b/libpretixnfc-android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b63df76 --- /dev/null +++ b/libpretixnfc-android/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AcsNfcHandler.kt b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AcsNfcHandler.kt new file mode 100644 index 0000000..f60583c --- /dev/null +++ b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AcsNfcHandler.kt @@ -0,0 +1,244 @@ +package eu.pretix.libpretixnfc.android.hardware + +import Mf0aesKeySet +import android.app.Activity +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.IBinder +import android.util.Log +import com.acs.smartcard.Reader +import com.acs.smartcard.ReaderException +import eu.pretix.libpretinfc.android.BuildConfig +import eu.pretix.libpretixnfc.communication.AbstractNfcA +import eu.pretix.libpretixnfc.communication.ChipReadError +import eu.pretix.libpretixnfc.communication.NfcChipReadError +import eu.pretix.libpretixnfc.communication.NfcIOError +import eu.pretix.libpretixsync.db.ReusableMediaType +import eu.pretix.libpretixnfc.android.hardware.acs.AcsReaderService +import eu.pretix.libpretixui.android.utils.doAsyncSentry +import io.sentry.Sentry +import java.io.IOException + + +private var lastTagId: String = "" +private var lastTagTime: Long = 0 + +class AcsNfcHandler( + private val ctx: Activity, + private val keySets: List, + private val useRandomIdForNewTags: Boolean, + private val mode: NfcHandlerMode = NfcHandlerMode.DEFAULT +) : NfcHandler, (Reader, Int) -> Unit { + private var mediaTypes: List? = null + private var running = false + private var readerService: AcsReaderService? = null + private var chipReadListener: NfcHandler.OnChipReadListener? = null + + val supportedTypes = listOf(ReusableMediaType.NFC_UID, ReusableMediaType.NFC_MF0AES) + + companion object { + val TAG = "AcsNfcHandler" + } + + private val readerConnection = object : ServiceConnection { + override fun onServiceConnected(className: ComponentName, service: IBinder) { + val binder = service as AcsReaderService.LocalBinder + readerService = binder.getService() + readerService!!.cardHandler = this@AcsNfcHandler + } + + override fun onServiceDisconnected(arg0: ComponentName) { + readerService = null + } + } + + override fun start(mediaTypes: List) { + if (!supportedTypes.any { mediaTypes.contains(it) }) { + return + } + + this.mediaTypes = mediaTypes + Log.i(TAG, "start (${mediaTypes.joinToString(", ")}) @$ctx") + + running = true + + Intent(ctx, AcsReaderService::class.java).also { intent -> + ctx.bindService(intent, readerConnection, Context.BIND_AUTO_CREATE) + } + } + + override fun getMediaTypes(): List? { + return mediaTypes + } + + override fun setOnChipReadListener(listener: NfcHandler.OnChipReadListener?) { + chipReadListener = listener + } + + override fun stop() { + running = false + readerService?.cardHandler = null + Log.i(TAG, "stop @$ctx") + } + + override fun isRunning(): Boolean { + return running + } + + private fun readUid(reader: Reader, slotNum: Int): String { + val command = + byteArrayOf(0xFF.toByte(), 0xCA.toByte(), 0x00, 0x00, 0x00) // Read UID + val responseBuffer = ByteArray(300) + val responseLength = reader!!.transmit( + slotNum, + command, + command.size, + responseBuffer, + responseBuffer.size + ) + val response = responseBuffer.copyOfRange(0, responseLength - 2) + val hexId = response.joinToString("") { "%02x".format(it).uppercase() } + return hexId + } + + fun beep(reader: Reader) { + val duration = 10 // * 10ms + // Set buzzer to only buzz on cart insertion, not removal + val command = + byteArrayOf(0xE0.toByte(), 0x00, 0x00, 0x28, 0x01, duration.toByte()) + val responseBuffer = ByteArray(300) + if (BuildConfig.DEBUG) { + Log.i(TAG, "beep") + } + reader.control( + 0, + Reader.IOCTL_CCID_ESCAPE, + command, + command.size, + responseBuffer, + responseBuffer.size + ) + } + + override fun invoke(reader: Reader, slotNum: Int) { + val nfca = AcsNfcA(reader, slotNum) + nfca.connect() + + val hexId = readUid(reader, slotNum) + if (hexId == lastTagId && lastTagTime > System.currentTimeMillis() - 2000) { + // Debounce duplicate reads + Log.i(TAG, "debounced @$ctx") + return + } + + try { + if (mediaTypes?.contains(ReusableMediaType.NFC_UID) == true) { + Log.i(TAG, "found tag with id $hexId") + + lastTagId = hexId + lastTagTime = System.currentTimeMillis() + + ctx.runOnUiThread { + chipReadListener?.chipReadSuccessfully(hexId, ReusableMediaType.NFC_UID) + } + beep(reader) + } else if (mediaTypes?.contains(ReusableMediaType.NFC_MF0AES) == true) { + doAsyncSentry { + val identifier = try { + val nfca = AcsNfcA(reader, slotNum) + processMf0aes(keySets, mode, useRandomIdForNewTags, nfca) + } catch (e: NfcChipReadError) { + ctx.runOnUiThread { + chipReadListener?.chipReadError(e.errorType, hexId) + } + beep(reader) + return@doAsyncSentry + } catch (e: NfcIOError) { + e.printStackTrace() + ctx.runOnUiThread { + chipReadListener?.chipReadError(ChipReadError.IO_ERROR, hexId) + } + beep(reader) + return@doAsyncSentry + } catch (e: IOException) { + e.printStackTrace() + ctx.runOnUiThread { + chipReadListener?.chipReadError(ChipReadError.IO_ERROR, hexId) + } + beep(reader) + return@doAsyncSentry + } catch (e: Exception) { + e.printStackTrace() + Sentry.captureException(e) + ctx.runOnUiThread { + chipReadListener?.chipReadError( + ChipReadError.UNKNOWN_ERROR, + hexId + ) + } + beep(reader) + return@doAsyncSentry + } + ctx.runOnUiThread { + if (mode == NfcHandlerMode.DEFAULT) { + lastTagId = hexId + lastTagTime = System.currentTimeMillis() + } + + chipReadListener?.chipReadSuccessfully( + identifier, + ReusableMediaType.NFC_MF0AES + ) + } + beep(reader) + } + } + + + } catch (e: ReaderException) { + e.printStackTrace() + } + } + + class AcsNfcA(val reader: Reader, val slotNum: Int) : AbstractNfcA { + var connected = false + + override fun connect() { + if (!connected) { + if (BuildConfig.DEBUG) { + Log.i("NfcTest", "reader.power()") + } + reader.power(slotNum, Reader.CARD_WARM_RESET) + if (BuildConfig.DEBUG) { + Log.i("NfcTest", "reader.setProtocol()") + } + reader.setProtocol(slotNum, Reader.PROTOCOL_T0 or Reader.PROTOCOL_T1) + connected = true + } + } + + @OptIn(ExperimentalStdlibApi::class) + override fun transceive(data: ByteArray): ByteArray { + check(data.size <= 255) + val command = byteArrayOf(0xFF.toByte(), 0x00, 0x00, 0x00, data.size.toByte()) + data + val responseBuffer = ByteArray(300) + if (BuildConfig.DEBUG) { + Log.i("NfcTest", "reader.transmit(${command.toHexString()})") + } + val responseLength = reader.transmit( + slotNum, + command, + command.size, + responseBuffer, + responseBuffer.size + ) + val response = responseBuffer.copyOfRange(0, responseLength - 2) + return response + } + + override fun close() { + } + } +} diff --git a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AndroidNativeNfcHandler.kt b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AndroidNativeNfcHandler.kt new file mode 100644 index 0000000..8db948d --- /dev/null +++ b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AndroidNativeNfcHandler.kt @@ -0,0 +1,180 @@ +package eu.pretix.libpretixnfc.android.hardware + +import Mf0aesKeySet +import android.app.Activity +import android.content.Context +import android.nfc.NfcAdapter +import android.nfc.NfcAdapter.FLAG_READER_NFC_A +import android.nfc.NfcAdapter.FLAG_READER_SKIP_NDEF_CHECK +import android.nfc.Tag +import android.nfc.tech.NfcA +import android.os.Bundle +import android.os.Vibrator +import android.util.Log +import eu.pretix.libpretinfc.android.BuildConfig +import eu.pretix.libpretixnfc.communication.AbstractNfcA +import eu.pretix.libpretixnfc.communication.ChipReadError +import eu.pretix.libpretixnfc.communication.NfcChipReadError +import eu.pretix.libpretixnfc.communication.NfcIOError +import eu.pretix.libpretixnfc.toHexString +import eu.pretix.libpretixsync.db.ReusableMediaType +import eu.pretix.libpretixui.android.utils.doAsyncSentry +import io.sentry.Sentry +import java.io.IOException + + +private var lastTagId: String = "" +private var lastTagTime: Long = 0 + + +enum class NfcHandlerMode { + DEFAULT, + ENCODE +} + + +class AndroidNativeNfcHandler( + private val ctx: Activity, + private val keySets: List, + private val useRandomIdForNewTags: Boolean, + private val mode: NfcHandlerMode = NfcHandlerMode.DEFAULT +) : NfcHandler, NfcAdapter.ReaderCallback { + private var chipReadListener: NfcHandler.OnChipReadListener? = null + private var nfcAdapter: NfcAdapter? = NfcAdapter.getDefaultAdapter(ctx) + private var mediaTypes: List? = null + private val buzzer = + ctx.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator? + private var running = false + + val supportedTypes = listOf(ReusableMediaType.NFC_UID, ReusableMediaType.NFC_MF0AES) + + companion object { + val TAG = "AndroidNativeNfcHandler" + } + + override fun start(mediaTypes: List) { + if (!supportedTypes.any { mediaTypes.contains(it) }) { + return + } + + this.mediaTypes = mediaTypes + Log.i(TAG, "start (${mediaTypes.joinToString(", ")}) @$ctx") + if (nfcAdapter == null) { + throw NfcUnsupported() + } + if (!nfcAdapter!!.isEnabled) { + throw NfcDisabled() + } + + nfcAdapter!!.enableReaderMode( + ctx, + this, + FLAG_READER_SKIP_NDEF_CHECK or FLAG_READER_NFC_A, + Bundle().apply { + putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 500) + } + ) + running = true + } + + override fun getMediaTypes(): List? { + return mediaTypes + } + + override fun stop() { + nfcAdapter?.disableReaderMode(ctx) + running = false + Log.i(TAG, "stop @$ctx") + } + + override fun isRunning(): Boolean { + return running + } + + override fun setOnChipReadListener(listener: NfcHandler.OnChipReadListener?) { + chipReadListener = listener + } + + override fun onTagDiscovered(tag: Tag) { + Log.i(TAG, "onTagDiscovered @$ctx") + if (tag.hexId() == lastTagId && lastTagTime > System.currentTimeMillis() - 2000) { + // Debounce duplicate reads + Log.i(TAG, "debounced @$ctx") + return + } + + lastTagId = tag.hexId() + lastTagTime = System.currentTimeMillis() + + if (mediaTypes?.contains(ReusableMediaType.NFC_UID) == true) { + ctx.runOnUiThread { + buzzer?.vibrate(125) + chipReadListener?.chipReadSuccessfully(tag.hexId(), ReusableMediaType.NFC_UID) + } + } else if (mediaTypes?.contains(ReusableMediaType.NFC_MF0AES) == true) { + doAsyncSentry { + val identifier = try { + val nfca = AndroidNfcA(tag) + processMf0aes(keySets, mode, useRandomIdForNewTags, nfca) + } catch (e: NfcChipReadError) { + ctx.runOnUiThread { + buzzer?.vibrate(125) + chipReadListener?.chipReadError(e.errorType, tag.hexId()) + } + return@doAsyncSentry + } catch (e: NfcIOError) { + e.printStackTrace() + ctx.runOnUiThread { + buzzer?.vibrate(125) + chipReadListener?.chipReadError(ChipReadError.IO_ERROR, tag.hexId()) + } + return@doAsyncSentry + } catch (e: IOException) { + e.printStackTrace() + ctx.runOnUiThread { + buzzer?.vibrate(125) + chipReadListener?.chipReadError(ChipReadError.IO_ERROR, tag.hexId()) + } + return@doAsyncSentry + } catch (e: Exception) { + e.printStackTrace() + Sentry.captureException(e) + ctx.runOnUiThread { + buzzer?.vibrate(125) + chipReadListener?.chipReadError(ChipReadError.UNKNOWN_ERROR, tag.hexId()) + } + return@doAsyncSentry + } + ctx.runOnUiThread { + buzzer?.vibrate(125) + chipReadListener?.chipReadSuccessfully(identifier, ReusableMediaType.NFC_MF0AES) + } + } + } + } + + class AndroidNfcA(val tag: Tag) : AbstractNfcA { + val raw = NfcA.get(tag) ?: throw NfcChipReadError(ChipReadError.UNKNOWN_CHIP_TYPE) + + override fun connect() { + raw.connect() + } + + override fun transceive(data: ByteArray): ByteArray { + if (BuildConfig.DEBUG) { + Log.i("NFC TRANSMISSION", "=> " + data.toHexString(true)) + } + val reply = raw.transceive(data) + if (BuildConfig.DEBUG) { + Log.i("NFC TRANSMISSION", "<= " + reply.toHexString(true)) + } + return reply + } + + override fun close() { + raw.close() + } + } +} + +fun Tag.hexId(): String = id.joinToString("") { "%02x".format(it).uppercase() } diff --git a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/NfcHandler.kt b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/NfcHandler.kt new file mode 100644 index 0000000..b68184c --- /dev/null +++ b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/NfcHandler.kt @@ -0,0 +1,60 @@ +package eu.pretix.libpretixnfc.android.hardware + +import Mf0aesKeySet +import PretixMf0aes +import android.app.Activity +import eu.pretix.libpretinfc.android.BuildConfig +import eu.pretix.libpretixnfc.communication.AbstractNfcA + +import eu.pretix.libpretixnfc.communication.ChipReadError +import eu.pretix.libpretixsync.db.ReusableMediaType +import java.lang.RuntimeException + +class NfcUnsupported : Exception() +class NfcDisabled : Exception() + +interface NfcHandler { + interface OnChipReadListener { + fun chipReadSuccessfully(identifier: String, mediaType: ReusableMediaType) + fun chipReadError(error: ChipReadError, identifier: String?) + } + + /** + * Start listening for chips. Should e.g. be called in Activity.onResume(). + */ + @Throws(NfcDisabled::class, NfcUnsupported::class) + fun start(mediaTypes: List) + + /** + * Stop listening for chips. Should e.g. be called in Activity.onPause(). + */ + fun stop() + + fun isRunning(): Boolean + + fun getMediaTypes(): List? + + /** + * Set the callback to be fired whenever a chip is read. + */ + fun setOnChipReadListener(listener: OnChipReadListener?) +} + + +fun getNfcHandler(activity: Activity, keySets: List, useRandomIdForNewTags: Boolean, mode: NfcHandlerMode = NfcHandlerMode.DEFAULT, nfcReaderType: String): NfcHandler { + return when (nfcReaderType) { + "acs" -> return AcsNfcHandler(activity, keySets, useRandomIdForNewTags, mode) + "native" -> return AndroidNativeNfcHandler(activity, keySets, useRandomIdForNewTags, mode) + else -> throw RuntimeException("Unknown NFC reader type ${nfcReaderType}") + } +} + +fun processMf0aes(keySets: List, mode: NfcHandlerMode, useRandomIdForNewTags: Boolean = false, nfca: AbstractNfcA): String { + return PretixMf0aes( + keySets, + useRandomIdForNewTags, + BuildConfig.DEBUG, + ).process( + nfca, + encodeWith = if (mode == NfcHandlerMode.ENCODE) keySets.firstOrNull { it.canEncode } else null) +} diff --git a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/acs/AcsReaderService.kt b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/acs/AcsReaderService.kt new file mode 100644 index 0000000..0ce3887 --- /dev/null +++ b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/acs/AcsReaderService.kt @@ -0,0 +1,235 @@ +package eu.pretix.libpretixnfc.android.hardware.acs + +import android.annotation.SuppressLint +import android.app.* +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.hardware.usb.UsbDevice +import android.hardware.usb.UsbManager +import android.os.Binder +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.PendingIntentCompat +import androidx.core.content.ContextCompat +import androidx.core.content.IntentCompat +import com.acs.smartcard.Reader +import eu.pretix.libpretinfc.android.R +import eu.pretix.libpretixnfc.android.hardware.AcsNfcHandler +import eu.pretix.libpretixui.android.utils.doAsyncSentry + +class AcsReaderService : Service() { + /* + Tested with ACR1252U + + API docs: https://www.acs.com.hk/download-manual/6402/API-ACR1252U-1.17.pdf + */ + + private val binder = LocalBinder() + val notificationChannel = "pretixscan:AcsReaderService" + val description = "ACS reader Service" + val notificationId = 40182724 + public var cardHandler: ((Reader, Int) -> Unit)? = null + private var reader: Reader? = null + val VENDOR_ID = 0x072f + + /** + * Class used for the client Binder. Because we know this service always + * runs in the same process as its clients, we don't need to deal with IPC. + */ + inner class LocalBinder : Binder() { + // Return this instance of LocalService so clients can call public methods + fun getService(): AcsReaderService = this@AcsReaderService + } + + private fun readerConfig() { + // Disable auto buzzer + val command = + byteArrayOf(0xE0.toByte(), 0x00, 0x00, 0x21, 0x01, 0b01100111.toByte()) + val responseBuffer = ByteArray(300) + reader!!.control( + 0, + Reader.IOCTL_CCID_ESCAPE, + command, + command.size, + responseBuffer, + responseBuffer.size, + ) + } + + private fun setup() { + val manager = getSystemService(USB_SERVICE) as UsbManager + if (reader == null) { + reader = Reader(manager) + var firstStateChange = true + + reader!!.setOnStateChangeListener { slotNum, prevState, currState -> + Log.i( + AcsNfcHandler.Companion.TAG, + "onStateChange slot=$slotNum prevState=$prevState currState=$currState", + ) + if (firstStateChange) { + firstStateChange = false + doAsyncSentry { + readerConfig() + } + } + if (slotNum == 0 && prevState == Reader.CARD_ABSENT && currState == Reader.CARD_PRESENT) { + doAsyncSentry { + cardHandler?.invoke(reader!!, slotNum) + } + } + } + + findAndConnectDevice() + + val usbFilter = IntentFilter() + usbFilter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) + usbFilter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED) + + ContextCompat.registerReceiver( + this, + object : BroadcastReceiver() { + override fun onReceive( + p0: Context, + intent: Intent, + ) { + val device = IntentCompat.getParcelableExtra(intent, "device", UsbDevice::class.java) ?: return + if (intent.action == UsbManager.ACTION_USB_DEVICE_ATTACHED && device.vendorId == VENDOR_ID) { + Log.i( + AcsNfcHandler.Companion.TAG, + "device attached", + ) + connectDevice(device) + } + } + }, + usbFilter, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + } + } + + private fun findAndConnectDevice() { + val manager = getSystemService(USB_SERVICE) as UsbManager + for (dev in manager.deviceList.values) { + if (dev.vendorId == VENDOR_ID) { + connectDevice(dev) + return + } + } + } + + @SuppressLint("UnspecifiedImmutableFlag") + private fun connectDevice(dev: UsbDevice) { + val manager = getSystemService(USB_SERVICE) as UsbManager + val intent = Intent("eu.pretix.pretixscan.droid.hardware.nfc.USB_PERMISSION") + intent.setPackage(packageName) + val permissionIntent = PendingIntentCompat.getBroadcast( + this, + 0, + intent, + 0, + true + ) + + val filter = IntentFilter("eu.pretix.pretixscan.droid.hardware.nfc.USB_PERMISSION") + ContextCompat.registerReceiver( + this, + object : BroadcastReceiver() { + override fun onReceive( + ctx: Context, + intent: Intent, + ) { + val device = IntentCompat.getParcelableExtra(intent, "device", UsbDevice::class.java) + if (!intent.getBooleanExtra("permission", false)) { + // show error + } else if (device == null) { + // show error + } else { + reader!!.open(device) + } + } + }, + filter, + ContextCompat.RECEIVER_EXPORTED, + ) + manager.requestPermission(dev, permissionIntent) + } + + override fun onBind(intent: Intent): IBinder { + setup() + return binder + } + + override fun onUnbind(intent: Intent?): Boolean { + return true + } + + private fun _startForeground() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = + NotificationChannel( + notificationChannel, + description, + NotificationManager.IMPORTANCE_LOW, + ) + channel.setSound(null, null) + channel.enableVibration(false) + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + + //val startBaseActivity = Intent(this, MainActivity::class.java) + val startBaseActivity = Intent(this, this::class.java) + startBaseActivity.action = Intent.ACTION_MAIN + startBaseActivity.addCategory(Intent.CATEGORY_LAUNCHER) + startBaseActivity.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + val keepAliveNotification: Notification = + NotificationCompat.Builder(this, notificationChannel) + .setContentTitle(getString(R.string.nfc_connected_notification)) + .setSmallIcon(R.drawable.ic_nfc_white_24dp) + .setContentIntent( + PendingIntent.getActivity( + this, + 0, + startBaseActivity, + if (Build.VERSION.SDK_INT >= 23) { + PendingIntent.FLAG_IMMUTABLE + } else { + 0 + }, + ), + ) + .build() + this.startForeground(notificationId, keepAliveNotification) + } + + override fun onStartCommand( + intent: Intent?, + flags: Int, + startId: Int, + ): Int { + _startForeground() + return START_NOT_STICKY + } + + override fun onRebind(intent: Intent?) { + setup() + super.onRebind(intent) + } + + override fun onCreate() { + super.onCreate() + _startForeground() + } + + override fun onDestroy() { + reader?.close() + reader = null + super.onDestroy() + } +} diff --git a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/platform/AndroidKeyStore.kt b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/platform/AndroidKeyStore.kt new file mode 100644 index 0000000..8c4fe46 --- /dev/null +++ b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/platform/AndroidKeyStore.kt @@ -0,0 +1,130 @@ +package eu.pretix.libpretixnfc.android.platform + +import android.content.Context +import android.os.Build +import android.security.KeyStoreParameter +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.security.keystore.KeyProtection +import eu.pretix.libpretixnfc.platform.HardwareBackedKeyStore +import org.spongycastle.util.io.pem.PemObject +import org.spongycastle.util.io.pem.PemWriter +import java.io.StringWriter +import java.nio.charset.Charset +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.PrivateKey +import java.util.concurrent.locks.ReentrantLock +import javax.crypto.Cipher +import javax.crypto.Mac +import javax.crypto.SecretKey +import kotlin.concurrent.withLock + + +val keyLock = ReentrantLock() + +class AndroidKeyStore(val ctx: Context) : HardwareBackedKeyStore { + override fun importHmacKey(keyName: String, keyValue: ByteArray) { + val key = object : SecretKey { + override fun getAlgorithm(): String { + return "HmacSHA512" + } + + override fun getFormat(): String { + return "RAW" + } + + override fun getEncoded(): ByteArray { + return keyValue + } + } + + val keyStore = KeyStore.getInstance("AndroidKeyStore") + keyStore.load(null) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + keyStore.setEntry( + keyName, + KeyStore.SecretKeyEntry(key), + KeyProtection.Builder(KeyProperties.PURPOSE_SIGN).build() + ) + } else { + keyStore.setEntry( + keyName, + KeyStore.SecretKeyEntry(key), + KeyStoreParameter.Builder(ctx).build(), + ) + } + } + + override fun hmacSHA256(keyName: String, message: ByteArray): ByteArray { + val keyStore = KeyStore.getInstance("AndroidKeyStore") + keyStore.load(null) + // Key imported, obtain a reference to it. + val keyStoreKey = keyStore.getKey(keyName, null) + // The original key can now be discarded. + + val mac = Mac.getInstance("HmacSHA512") + mac.init(keyStoreKey) + mac.update(message) + return mac.doFinal() + } + + override fun hasHmacKey(keyName: String): Boolean { + val keyStore = KeyStore.getInstance("AndroidKeyStore") + keyStore.load(null) + return keyStore.isKeyEntry(keyName) + } + + override fun getOrCreateRsaPubKey(keyName: String): ByteArray? { + return keyLock.withLock { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + return null + } + val keyStore = KeyStore.getInstance("AndroidKeyStore") + keyStore.load(null) + val pubkey = if (keyStore.isKeyEntry(keyName)) { + keyStore.getCertificate(keyName).publicKey + } else { + val keyPair = + KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, "AndroidKeyStore") + .apply { + val parameterSpec = KeyGenParameterSpec.Builder( + keyName, + KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT + ).run { + setBlockModes(KeyProperties.BLOCK_MODE_ECB) + setDigests( + KeyProperties.DIGEST_SHA1, + KeyProperties.DIGEST_SHA256, + KeyProperties.DIGEST_SHA512 + ) + setEncryptionPaddings( + KeyProperties.ENCRYPTION_PADDING_RSA_OAEP, + KeyProperties.ENCRYPTION_PADDING_RSA_PKCS1 + ) + setKeySize(2048) + setUserAuthenticationRequired(false) + build() + } + initialize(parameterSpec) + }.genKeyPair() + keyPair.public + } + val writer = StringWriter() + val pemWriter = PemWriter(writer) + pemWriter.writeObject(PemObject("PUBLIC KEY", pubkey.encoded)) + pemWriter.flush() + pemWriter.close() + return@withLock writer.toString().toByteArray(Charset.defaultCharset()) + } + } + + override fun decryptRsa(keyName: String, ciphertext: ByteArray): ByteArray { + val keyStore = KeyStore.getInstance("AndroidKeyStore") + keyStore.load(null) + val privateKey = keyStore.getKey(keyName, null) as PrivateKey? + val cipher: Cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding") + cipher.init(Cipher.DECRYPT_MODE, privateKey) + return cipher.doFinal(ciphertext) + } +} \ No newline at end of file diff --git a/libpretixnfc-android/src/main/res/drawable/ic_nfc_24dp.xml b/libpretixnfc-android/src/main/res/drawable/ic_nfc_24dp.xml new file mode 100644 index 0000000..8f4ecad --- /dev/null +++ b/libpretixnfc-android/src/main/res/drawable/ic_nfc_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/libpretixnfc-android/src/main/res/drawable/ic_nfc_white_24dp.xml b/libpretixnfc-android/src/main/res/drawable/ic_nfc_white_24dp.xml new file mode 100644 index 0000000..ab3d9d1 --- /dev/null +++ b/libpretixnfc-android/src/main/res/drawable/ic_nfc_white_24dp.xml @@ -0,0 +1,5 @@ + + + diff --git a/libpretixnfc-android/src/main/res/values-de/strings.xml b/libpretixnfc-android/src/main/res/values-de/strings.xml new file mode 100644 index 0000000..c17d3bf --- /dev/null +++ b/libpretixnfc-android/src/main/res/values-de/strings.xml @@ -0,0 +1,11 @@ + + NFC-Reader-Service läuft + Dieses Medium hat eine zufällige UID und kann nicht benutzt werden. + Der NFC-Chip konnte nicht gelesen werden. Probieren Sie, ihn länger am Gerät zu halten. + Diese Art von NFC-Chip wird nicht unterstützt. + Dieser NFC-Chip ist von einem anderen System kodiert. + Dieser NFC-Chip ist noch nicht kodiert. + NFC ist auf diesem Gerät gerade deaktiviert. + NFC wird auf diesem Gerät nicht unterstützt. + Prüfe Medium… + \ No newline at end of file diff --git a/libpretixnfc-android/src/main/res/values/strings.xml b/libpretixnfc-android/src/main/res/values/strings.xml new file mode 100644 index 0000000..2857e6c --- /dev/null +++ b/libpretixnfc-android/src/main/res/values/strings.xml @@ -0,0 +1,11 @@ + + NFC reader service running + This medium has a random ID and can\'t be used. + The NFC chip could not be read. Try keeping it close to the device for a longer time. + This type of NFC chip is not supported. + This NFC chip is encoded by a different system. + This NFC chip is not yet encoded. + NFC is currently disabled on this device. + NFC is not supported on this device. + Checking medium… + \ No newline at end of file diff --git a/libpretixnfc/src/main/java/eu/pretix/libpretixnfc/platform/HardwareBackedKeyStore.kt b/libpretixnfc/src/main/java/eu/pretix/libpretixnfc/platform/HardwareBackedKeyStore.kt new file mode 100644 index 0000000..8486728 --- /dev/null +++ b/libpretixnfc/src/main/java/eu/pretix/libpretixnfc/platform/HardwareBackedKeyStore.kt @@ -0,0 +1,9 @@ +package eu.pretix.libpretixnfc.platform + +interface HardwareBackedKeyStore { + fun hasHmacKey(keyName: String): Boolean + fun importHmacKey(keyName: String, keyValue: ByteArray) + fun hmacSHA256(keyName: String, message: ByteArray): ByteArray + fun getOrCreateRsaPubKey(keyName: String): ByteArray? + fun decryptRsa(keyName: String, ciphertext: ByteArray): ByteArray +} \ No newline at end of file From ca8f77753bdaf5f122e4b0ab7306ba3348841700 Mon Sep 17 00:00:00 2001 From: Martin Gross Date: Tue, 17 Mar 2026 16:05:17 +0100 Subject: [PATCH 02/13] Add ES, FR, JA, NL translations --- .../src/main/res/values-es/strings.xml | 11 +++++++++++ .../src/main/res/values-fr/strings.xml | 12 ++++++++++++ .../src/main/res/values-ja/strings.xml | 5 +++++ .../src/main/res/values-nl/strings.xml | 11 +++++++++++ 4 files changed, 39 insertions(+) create mode 100644 libpretixnfc-android/src/main/res/values-es/strings.xml create mode 100644 libpretixnfc-android/src/main/res/values-fr/strings.xml create mode 100644 libpretixnfc-android/src/main/res/values-ja/strings.xml create mode 100644 libpretixnfc-android/src/main/res/values-nl/strings.xml diff --git a/libpretixnfc-android/src/main/res/values-es/strings.xml b/libpretixnfc-android/src/main/res/values-es/strings.xml new file mode 100644 index 0000000..d2da22d --- /dev/null +++ b/libpretixnfc-android/src/main/res/values-es/strings.xml @@ -0,0 +1,11 @@ + + Este chip NFC está cifrado por un mecanismo aparte. + Este chip NFC todavía no está cifrado. + NFC está deshabilitado en este momento en este dispositivo. + Este dispositivo no admite NFC. + Comprobando el medio… + El soporte tiene un identificador aleatorio y no se puede utilizar. + No se ha podido leer el chip NFC. Intente mantenerlo cerca del dispositivo durante más tiempo. + Este tipo de chip NFC no es compatible. + Servicio de lector NFC en ejecución + diff --git a/libpretixnfc-android/src/main/res/values-fr/strings.xml b/libpretixnfc-android/src/main/res/values-fr/strings.xml new file mode 100644 index 0000000..cc4f507 --- /dev/null +++ b/libpretixnfc-android/src/main/res/values-fr/strings.xml @@ -0,0 +1,12 @@ + + + Cette puce NFC est chiffrée avec un système différent. + Cette puce NFC n\\\'est pas encore chiffrée. + La fonction NFC est actuellement désactivée sur cet appareil. + La technologie NFC n\\\'est pas prise en charge par cet appareil. + Vérification du support… + Service de lecture NFC en cours d\\\'exécution + Ce support a un identifiant aléatoire et ne peut pas être utilisé. + La puce NFC n\\\'a pas pu être lue. Essayez de le maintenir plus longtemps à proximité de l\\\'appareil. + Ce type de puce NFC n\\\'est pas pris en charge. + \ No newline at end of file diff --git a/libpretixnfc-android/src/main/res/values-ja/strings.xml b/libpretixnfc-android/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000..35580fb --- /dev/null +++ b/libpretixnfc-android/src/main/res/values-ja/strings.xml @@ -0,0 +1,5 @@ + + + このデバイスでは NFC はサポートされていません。 + メディアを確認中… + \ No newline at end of file diff --git a/libpretixnfc-android/src/main/res/values-nl/strings.xml b/libpretixnfc-android/src/main/res/values-nl/strings.xml new file mode 100644 index 0000000..50acc71 --- /dev/null +++ b/libpretixnfc-android/src/main/res/values-nl/strings.xml @@ -0,0 +1,11 @@ + + NFC-lezerservice actief + Dit medium heeft een willekeurig ID en kan niet worden gebruikt. + De NFC-chip kon niet uitgelezen worden. Probeer de chip langer en dicht tegen het apparaat te houden. + Deze NFC-chip is gecodeerd door een ander systeem. + De NFC-chip is nog niet gecodeerd. + NFC is momenteel uitgeschakeld op dit apparaat. + NFC is niet ondersteund op dit apparaat. + Controleren van het medium… + Dit type van NFC-chip is niet ondersteund. + \ No newline at end of file From f8b4c3b1a0871c6766a6e0b1c57dd8e90178ea2f Mon Sep 17 00:00:00 2001 From: Martin Gross Date: Tue, 17 Mar 2026 16:37:51 +0100 Subject: [PATCH 03/13] gradle: Dependency-Cleanup --- libpretixnfc-android/build.gradle | 22 +--------------------- libpretixnfc-android/settings.gradle | 1 - 2 files changed, 1 insertion(+), 22 deletions(-) diff --git a/libpretixnfc-android/build.gradle b/libpretixnfc-android/build.gradle index c2bd6a9..1c7dec7 100644 --- a/libpretixnfc-android/build.gradle +++ b/libpretixnfc-android/build.gradle @@ -19,9 +19,6 @@ android { useSupportLibrary = true } } - compileOptions { - coreLibraryDesugaringEnabled true - } kotlin { jvmToolchain { languageVersion.set(JavaLanguageVersion.of(17)) @@ -45,22 +42,9 @@ repositories { } dependencies { - coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5' - def kotlin_version = "1.9.23" // update in settings.gradle too implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" - implementation 'com.neovisionaries:nv-i18n:1.27' - implementation 'joda-time:joda-time:2.10.10' - implementation 'com.github.ialokim:android-phone-field:0.2.3' implementation 'androidx.core:core-ktx:1.10.0' - implementation 'androidx.appcompat:appcompat:1.4.2' - implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - implementation 'androidx.lifecycle:lifecycle-common:2.6.0' - implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' - implementation 'com.google.zxing:core:3.5.3' - implementation "androidx.swiperefreshlayout:swiperefreshlayout:1.1.0" - implementation 'com.google.android.material:material:1.6.1' - implementation 'com.github.pretix:json-logic-kotlin:1.0.0' implementation(files('libs/acssmc-1.1.5.jar')) @@ -75,8 +59,4 @@ dependencies { } implementation 'io.sentry:sentry-android:8.18.0' implementation 'com.madgag.spongycastle:prov:1.58.0.0' - - implementation 'com.github.bumptech.glide:glide:4.12.0' - kapt 'com.github.bumptech.glide:compiler:4.12.0' -} - +} \ No newline at end of file diff --git a/libpretixnfc-android/settings.gradle b/libpretixnfc-android/settings.gradle index d680e64..8c22ba8 100644 --- a/libpretixnfc-android/settings.gradle +++ b/libpretixnfc-android/settings.gradle @@ -13,7 +13,6 @@ pluginManagement { plugins { id 'com.android.library' version '8.4.2' apply false id 'org.jetbrains.kotlin.android' version '1.9.23' apply false - id 'org.jetbrains.kotlin.kapt' version '1.9.23' apply false } rootProject.name = 'eu.pretix.libpretixnfc-android' From c4badfb55f27c9cd954df205fd04cf67e255500b Mon Sep 17 00:00:00 2001 From: Martin Gross Date: Tue, 17 Mar 2026 16:48:38 +0100 Subject: [PATCH 04/13] Bump compileSdk/targetSdk to 36 --- libpretixnfc-android/build.gradle | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/libpretixnfc-android/build.gradle b/libpretixnfc-android/build.gradle index 1c7dec7..7f7a9ce 100644 --- a/libpretixnfc-android/build.gradle +++ b/libpretixnfc-android/build.gradle @@ -8,12 +8,12 @@ plugins { android { namespace 'eu.pretix.libpretinfc.android' - compileSdkVersion 33 + compileSdk = 36 defaultConfig { - minSdkVersion 21 - targetSdkVersion 33 - multiDexEnabled true + minSdk = 21 + targetSdk = 36 + multiDexEnabled = true vectorDrawables { useSupportLibrary = true From 5fdd85b4a60bddae6effae9745ff934ae51f60c5 Mon Sep 17 00:00:00 2001 From: Martin Gross Date: Wed, 25 Mar 2026 13:13:41 +0100 Subject: [PATCH 05/13] Fix spelling in namespace Co-authored-by: robbi5 --- libpretixnfc-android/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libpretixnfc-android/build.gradle b/libpretixnfc-android/build.gradle index 7f7a9ce..26a08b5 100644 --- a/libpretixnfc-android/build.gradle +++ b/libpretixnfc-android/build.gradle @@ -6,7 +6,7 @@ plugins { } android { - namespace 'eu.pretix.libpretinfc.android' + namespace 'eu.pretix.libpretixnfc.android' compileSdk = 36 From 2d710b84875c42aa44516ce2a29c6d6eb6c692c8 Mon Sep 17 00:00:00 2001 From: Martin Gross Date: Mon, 30 Mar 2026 15:39:39 +0200 Subject: [PATCH 06/13] Fix more imports after namespace correction --- .../eu/pretix/libpretixnfc/android/hardware/AcsNfcHandler.kt | 2 +- .../libpretixnfc/android/hardware/AndroidNativeNfcHandler.kt | 2 +- .../java/eu/pretix/libpretixnfc/android/hardware/NfcHandler.kt | 2 +- .../libpretixnfc/android/hardware/acs/AcsReaderService.kt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AcsNfcHandler.kt b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AcsNfcHandler.kt index f60583c..ae94edb 100644 --- a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AcsNfcHandler.kt +++ b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AcsNfcHandler.kt @@ -10,7 +10,7 @@ import android.os.IBinder import android.util.Log import com.acs.smartcard.Reader import com.acs.smartcard.ReaderException -import eu.pretix.libpretinfc.android.BuildConfig +import eu.pretix.libpretixnfc.android.BuildConfig import eu.pretix.libpretixnfc.communication.AbstractNfcA import eu.pretix.libpretixnfc.communication.ChipReadError import eu.pretix.libpretixnfc.communication.NfcChipReadError diff --git a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AndroidNativeNfcHandler.kt b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AndroidNativeNfcHandler.kt index 8db948d..48cefc3 100644 --- a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AndroidNativeNfcHandler.kt +++ b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AndroidNativeNfcHandler.kt @@ -11,7 +11,7 @@ import android.nfc.tech.NfcA import android.os.Bundle import android.os.Vibrator import android.util.Log -import eu.pretix.libpretinfc.android.BuildConfig +import eu.pretix.libpretixnfc.android.BuildConfig import eu.pretix.libpretixnfc.communication.AbstractNfcA import eu.pretix.libpretixnfc.communication.ChipReadError import eu.pretix.libpretixnfc.communication.NfcChipReadError diff --git a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/NfcHandler.kt b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/NfcHandler.kt index b68184c..1246642 100644 --- a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/NfcHandler.kt +++ b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/NfcHandler.kt @@ -3,7 +3,7 @@ package eu.pretix.libpretixnfc.android.hardware import Mf0aesKeySet import PretixMf0aes import android.app.Activity -import eu.pretix.libpretinfc.android.BuildConfig +import eu.pretix.libpretixnfc.android.BuildConfig import eu.pretix.libpretixnfc.communication.AbstractNfcA import eu.pretix.libpretixnfc.communication.ChipReadError diff --git a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/acs/AcsReaderService.kt b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/acs/AcsReaderService.kt index 0ce3887..a8f4bac 100644 --- a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/acs/AcsReaderService.kt +++ b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/acs/AcsReaderService.kt @@ -17,7 +17,7 @@ import androidx.core.app.PendingIntentCompat import androidx.core.content.ContextCompat import androidx.core.content.IntentCompat import com.acs.smartcard.Reader -import eu.pretix.libpretinfc.android.R +import eu.pretix.libpretixnfc.android.R import eu.pretix.libpretixnfc.android.hardware.AcsNfcHandler import eu.pretix.libpretixui.android.utils.doAsyncSentry From 6756e267f28b7bb577c2b7e8e7163b801d78fb64 Mon Sep 17 00:00:00 2001 From: Maximilian Richt Date: Mon, 30 Mar 2026 19:50:46 +0200 Subject: [PATCH 07/13] Move a transitional version of doAsyncSentry into libpretixnfc-android --- .../eu/pretix/libpretixnfc/android/Utils.kt | 45 +++++++++++++++++++ .../android/hardware/AcsNfcHandler.kt | 2 +- .../hardware/AndroidNativeNfcHandler.kt | 2 +- .../android/hardware/acs/AcsReaderService.kt | 2 +- 4 files changed, 48 insertions(+), 3 deletions(-) create mode 100644 libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/Utils.kt diff --git a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/Utils.kt b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/Utils.kt new file mode 100644 index 0000000..b9f254b --- /dev/null +++ b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/Utils.kt @@ -0,0 +1,45 @@ +package eu.pretix.libpretixnfc.android + +import io.sentry.Sentry +import java.lang.ref.WeakReference +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.Future +import kotlin.system.exitProcess + +val crashLogger: (Throwable) -> Unit = { throwable: Throwable -> + throwable.printStackTrace() + if (BuildConfig.DEBUG) { + exitProcess(1) + } else { + Sentry.captureException(throwable) + } +} + +class AsyncContext(val weakRef: WeakReference) + +fun T.doAsyncSentry( + exceptionHandler: ((Throwable) -> Unit)? = crashLogger, + task: AsyncContext.() -> Unit +): Future { + val context = AsyncContext(WeakReference(this)) + return BackgroundExecutor.submit { + return@submit try { + context.task() + } catch (thr: Throwable) { + val result = exceptionHandler?.invoke(thr) + if (result != null) { + result + } else { + Unit + } + } + } +} + +internal object BackgroundExecutor { + var executor: ExecutorService = + Executors.newScheduledThreadPool(2 * Runtime.getRuntime().availableProcessors()) + + fun submit(task: () -> T): Future = executor.submit(task) +} \ No newline at end of file diff --git a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AcsNfcHandler.kt b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AcsNfcHandler.kt index ae94edb..a4069cc 100644 --- a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AcsNfcHandler.kt +++ b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AcsNfcHandler.kt @@ -17,7 +17,7 @@ import eu.pretix.libpretixnfc.communication.NfcChipReadError import eu.pretix.libpretixnfc.communication.NfcIOError import eu.pretix.libpretixsync.db.ReusableMediaType import eu.pretix.libpretixnfc.android.hardware.acs.AcsReaderService -import eu.pretix.libpretixui.android.utils.doAsyncSentry +import eu.pretix.libpretixnfc.android.doAsyncSentry import io.sentry.Sentry import java.io.IOException diff --git a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AndroidNativeNfcHandler.kt b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AndroidNativeNfcHandler.kt index 48cefc3..19a0874 100644 --- a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AndroidNativeNfcHandler.kt +++ b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AndroidNativeNfcHandler.kt @@ -18,7 +18,7 @@ import eu.pretix.libpretixnfc.communication.NfcChipReadError import eu.pretix.libpretixnfc.communication.NfcIOError import eu.pretix.libpretixnfc.toHexString import eu.pretix.libpretixsync.db.ReusableMediaType -import eu.pretix.libpretixui.android.utils.doAsyncSentry +import eu.pretix.libpretixnfc.android.doAsyncSentry import io.sentry.Sentry import java.io.IOException diff --git a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/acs/AcsReaderService.kt b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/acs/AcsReaderService.kt index a8f4bac..cc6dd5f 100644 --- a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/acs/AcsReaderService.kt +++ b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/acs/AcsReaderService.kt @@ -19,7 +19,7 @@ import androidx.core.content.IntentCompat import com.acs.smartcard.Reader import eu.pretix.libpretixnfc.android.R import eu.pretix.libpretixnfc.android.hardware.AcsNfcHandler -import eu.pretix.libpretixui.android.utils.doAsyncSentry +import eu.pretix.libpretixnfc.android.doAsyncSentry class AcsReaderService : Service() { /* From a1234d6e4a684f093532a1f7f1d3a9ab7ce32c7c Mon Sep 17 00:00:00 2001 From: Maximilian Richt Date: Wed, 1 Apr 2026 13:04:40 +0200 Subject: [PATCH 08/13] We want to use the dependencies of libpretixnfc in libpretixnfc-android, else bouncycastle is missing --- libpretixnfc-android/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libpretixnfc-android/build.gradle b/libpretixnfc-android/build.gradle index 26a08b5..4a14442 100644 --- a/libpretixnfc-android/build.gradle +++ b/libpretixnfc-android/build.gradle @@ -49,7 +49,7 @@ dependencies { implementation(files('libs/acssmc-1.1.5.jar')) implementation(project(':libpretixnfc')) { - transitive = false + transitive = true } implementation(project(':libpretixsync')) { transitive = false From 67cd7c321673d18b4d160c66e78ac266172dc5b5 Mon Sep 17 00:00:00 2001 From: Maximilian Richt Date: Wed, 1 Apr 2026 14:27:02 +0200 Subject: [PATCH 09/13] cleanup --- libpretixnfc-android/build.gradle | 5 ----- .../eu/pretix/libpretixnfc/android/hardware/NfcHandler.kt | 4 ++-- .../libpretixnfc/commands/nxp/mf0aes/AuthenticationHelper.kt | 1 - 3 files changed, 2 insertions(+), 8 deletions(-) diff --git a/libpretixnfc-android/build.gradle b/libpretixnfc-android/build.gradle index 4a14442..c62b85a 100644 --- a/libpretixnfc-android/build.gradle +++ b/libpretixnfc-android/build.gradle @@ -31,8 +31,6 @@ android { } buildFeatures { buildConfig = true - dataBinding = true - viewBinding = true } } @@ -54,9 +52,6 @@ dependencies { implementation(project(':libpretixsync')) { transitive = false } - implementation(project(':libpretixui-android')) { - transitive = true - } implementation 'io.sentry:sentry-android:8.18.0' implementation 'com.madgag.spongycastle:prov:1.58.0.0' } \ No newline at end of file diff --git a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/NfcHandler.kt b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/NfcHandler.kt index 1246642..a7b1e7c 100644 --- a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/NfcHandler.kt +++ b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/NfcHandler.kt @@ -43,8 +43,8 @@ interface NfcHandler { fun getNfcHandler(activity: Activity, keySets: List, useRandomIdForNewTags: Boolean, mode: NfcHandlerMode = NfcHandlerMode.DEFAULT, nfcReaderType: String): NfcHandler { return when (nfcReaderType) { - "acs" -> return AcsNfcHandler(activity, keySets, useRandomIdForNewTags, mode) - "native" -> return AndroidNativeNfcHandler(activity, keySets, useRandomIdForNewTags, mode) + "acs" -> AcsNfcHandler(activity, keySets, useRandomIdForNewTags, mode) + "native" -> AndroidNativeNfcHandler(activity, keySets, useRandomIdForNewTags, mode) else -> throw RuntimeException("Unknown NFC reader type ${nfcReaderType}") } } diff --git a/libpretixnfc/src/main/java/eu/pretix/libpretixnfc/commands/nxp/mf0aes/AuthenticationHelper.kt b/libpretixnfc/src/main/java/eu/pretix/libpretixnfc/commands/nxp/mf0aes/AuthenticationHelper.kt index caa3170..7324d40 100644 --- a/libpretixnfc/src/main/java/eu/pretix/libpretixnfc/commands/nxp/mf0aes/AuthenticationHelper.kt +++ b/libpretixnfc/src/main/java/eu/pretix/libpretixnfc/commands/nxp/mf0aes/AuthenticationHelper.kt @@ -4,7 +4,6 @@ import eu.pretix.libpretixnfc.commands.nxp.ReadPages import eu.pretix.libpretixnfc.communication.AbstractNfcA import eu.pretix.libpretixnfc.communication.NfcIOError import eu.pretix.libpretixnfc.rotateLeft -import eu.pretix.libpretixnfc.toHexString import org.bouncycastle.jce.provider.BouncyCastleProvider import java.security.SecureRandom import javax.crypto.Cipher From fd5cd82f13005c9099dd03e5157fc56d9387e018 Mon Sep 17 00:00:00 2001 From: Maximilian Richt Date: Wed, 1 Apr 2026 14:27:56 +0200 Subject: [PATCH 10/13] Use kotlin coroutines instead of legacy anko async launcher --- libpretixnfc-android/build.gradle | 2 + .../eu/pretix/libpretixnfc/android/Utils.kt | 45 +++++++------------ .../android/hardware/AcsNfcHandler.kt | 29 +++++++----- .../hardware/AndroidNativeNfcHandler.kt | 29 +++++++----- .../android/hardware/acs/AcsReaderService.kt | 12 +++-- 5 files changed, 62 insertions(+), 55 deletions(-) diff --git a/libpretixnfc-android/build.gradle b/libpretixnfc-android/build.gradle index c62b85a..c4af2d3 100644 --- a/libpretixnfc-android/build.gradle +++ b/libpretixnfc-android/build.gradle @@ -43,6 +43,8 @@ dependencies { def kotlin_version = "1.9.23" // update in settings.gradle too implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" implementation 'androidx.core:core-ktx:1.10.0' + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3" implementation(files('libs/acssmc-1.1.5.jar')) diff --git a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/Utils.kt b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/Utils.kt index b9f254b..cf4d524 100644 --- a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/Utils.kt +++ b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/Utils.kt @@ -1,13 +1,16 @@ package eu.pretix.libpretixnfc.android import io.sentry.Sentry -import java.lang.ref.WeakReference -import java.util.concurrent.ExecutorService -import java.util.concurrent.Executors -import java.util.concurrent.Future import kotlin.system.exitProcess +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlin.coroutines.CoroutineContext -val crashLogger: (Throwable) -> Unit = { throwable: Throwable -> +val crashLogger: (CoroutineContext, Throwable) -> Unit = { _, throwable: Throwable -> throwable.printStackTrace() if (BuildConfig.DEBUG) { exitProcess(1) @@ -16,30 +19,12 @@ val crashLogger: (Throwable) -> Unit = { throwable: Throwable -> } } -class AsyncContext(val weakRef: WeakReference) - -fun T.doAsyncSentry( - exceptionHandler: ((Throwable) -> Unit)? = crashLogger, - task: AsyncContext.() -> Unit -): Future { - val context = AsyncContext(WeakReference(this)) - return BackgroundExecutor.submit { - return@submit try { - context.task() - } catch (thr: Throwable) { - val result = exceptionHandler?.invoke(thr) - if (result != null) { - result - } else { - Unit - } - } +fun CoroutineScope.launchWithSentry( + dispatcher: CoroutineDispatcher = Dispatchers.IO, + exceptionHandler: ((CoroutineContext, Throwable) -> Unit) = crashLogger, + task: suspend () -> Unit +): Job { + return launch(dispatcher + CoroutineExceptionHandler(exceptionHandler)) { + task() } -} - -internal object BackgroundExecutor { - var executor: ExecutorService = - Executors.newScheduledThreadPool(2 * Runtime.getRuntime().availableProcessors()) - - fun submit(task: () -> T): Future = executor.submit(task) } \ No newline at end of file diff --git a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AcsNfcHandler.kt b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AcsNfcHandler.kt index a4069cc..cf715c7 100644 --- a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AcsNfcHandler.kt +++ b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AcsNfcHandler.kt @@ -17,8 +17,13 @@ import eu.pretix.libpretixnfc.communication.NfcChipReadError import eu.pretix.libpretixnfc.communication.NfcIOError import eu.pretix.libpretixsync.db.ReusableMediaType import eu.pretix.libpretixnfc.android.hardware.acs.AcsReaderService -import eu.pretix.libpretixnfc.android.doAsyncSentry +import eu.pretix.libpretixnfc.android.launchWithSentry import io.sentry.Sentry +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.withContext import java.io.IOException @@ -35,6 +40,7 @@ class AcsNfcHandler( private var running = false private var readerService: AcsReaderService? = null private var chipReadListener: NfcHandler.OnChipReadListener? = null + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) val supportedTypes = listOf(ReusableMediaType.NFC_UID, ReusableMediaType.NFC_MF0AES) @@ -80,6 +86,7 @@ class AcsNfcHandler( override fun stop() { running = false readerService?.cardHandler = null + scope.cancel() Log.i(TAG, "stop @$ctx") } @@ -145,43 +152,43 @@ class AcsNfcHandler( } beep(reader) } else if (mediaTypes?.contains(ReusableMediaType.NFC_MF0AES) == true) { - doAsyncSentry { + scope.launchWithSentry { val identifier = try { val nfca = AcsNfcA(reader, slotNum) processMf0aes(keySets, mode, useRandomIdForNewTags, nfca) } catch (e: NfcChipReadError) { - ctx.runOnUiThread { + withContext(Dispatchers.Main) { chipReadListener?.chipReadError(e.errorType, hexId) } beep(reader) - return@doAsyncSentry + return@launchWithSentry } catch (e: NfcIOError) { e.printStackTrace() - ctx.runOnUiThread { + withContext(Dispatchers.Main) { chipReadListener?.chipReadError(ChipReadError.IO_ERROR, hexId) } beep(reader) - return@doAsyncSentry + return@launchWithSentry } catch (e: IOException) { e.printStackTrace() - ctx.runOnUiThread { + withContext(Dispatchers.Main) { chipReadListener?.chipReadError(ChipReadError.IO_ERROR, hexId) } beep(reader) - return@doAsyncSentry + return@launchWithSentry } catch (e: Exception) { e.printStackTrace() Sentry.captureException(e) - ctx.runOnUiThread { + withContext(Dispatchers.Main) { chipReadListener?.chipReadError( ChipReadError.UNKNOWN_ERROR, hexId ) } beep(reader) - return@doAsyncSentry + return@launchWithSentry } - ctx.runOnUiThread { + withContext(Dispatchers.Main) { if (mode == NfcHandlerMode.DEFAULT) { lastTagId = hexId lastTagTime = System.currentTimeMillis() diff --git a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AndroidNativeNfcHandler.kt b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AndroidNativeNfcHandler.kt index 19a0874..106a1b0 100644 --- a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AndroidNativeNfcHandler.kt +++ b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AndroidNativeNfcHandler.kt @@ -12,14 +12,19 @@ import android.os.Bundle import android.os.Vibrator import android.util.Log import eu.pretix.libpretixnfc.android.BuildConfig +import eu.pretix.libpretixnfc.android.launchWithSentry import eu.pretix.libpretixnfc.communication.AbstractNfcA import eu.pretix.libpretixnfc.communication.ChipReadError import eu.pretix.libpretixnfc.communication.NfcChipReadError import eu.pretix.libpretixnfc.communication.NfcIOError import eu.pretix.libpretixnfc.toHexString import eu.pretix.libpretixsync.db.ReusableMediaType -import eu.pretix.libpretixnfc.android.doAsyncSentry import io.sentry.Sentry +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.withContext import java.io.IOException @@ -45,6 +50,7 @@ class AndroidNativeNfcHandler( private val buzzer = ctx.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator? private var running = false + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) val supportedTypes = listOf(ReusableMediaType.NFC_UID, ReusableMediaType.NFC_MF0AES) @@ -84,6 +90,7 @@ class AndroidNativeNfcHandler( override fun stop() { nfcAdapter?.disableReaderMode(ctx) running = false + scope.cancel() Log.i(TAG, "stop @$ctx") } @@ -112,40 +119,40 @@ class AndroidNativeNfcHandler( chipReadListener?.chipReadSuccessfully(tag.hexId(), ReusableMediaType.NFC_UID) } } else if (mediaTypes?.contains(ReusableMediaType.NFC_MF0AES) == true) { - doAsyncSentry { + scope.launchWithSentry { val identifier = try { val nfca = AndroidNfcA(tag) processMf0aes(keySets, mode, useRandomIdForNewTags, nfca) } catch (e: NfcChipReadError) { - ctx.runOnUiThread { + withContext(Dispatchers.Main) { buzzer?.vibrate(125) chipReadListener?.chipReadError(e.errorType, tag.hexId()) } - return@doAsyncSentry + return@launchWithSentry } catch (e: NfcIOError) { e.printStackTrace() - ctx.runOnUiThread { + withContext(Dispatchers.Main) { buzzer?.vibrate(125) chipReadListener?.chipReadError(ChipReadError.IO_ERROR, tag.hexId()) } - return@doAsyncSentry + return@launchWithSentry } catch (e: IOException) { e.printStackTrace() - ctx.runOnUiThread { + withContext(Dispatchers.Main) { buzzer?.vibrate(125) chipReadListener?.chipReadError(ChipReadError.IO_ERROR, tag.hexId()) } - return@doAsyncSentry + return@launchWithSentry } catch (e: Exception) { e.printStackTrace() Sentry.captureException(e) - ctx.runOnUiThread { + withContext(Dispatchers.Main) { buzzer?.vibrate(125) chipReadListener?.chipReadError(ChipReadError.UNKNOWN_ERROR, tag.hexId()) } - return@doAsyncSentry + return@launchWithSentry } - ctx.runOnUiThread { + withContext(Dispatchers.Main) { buzzer?.vibrate(125) chipReadListener?.chipReadSuccessfully(identifier, ReusableMediaType.NFC_MF0AES) } diff --git a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/acs/AcsReaderService.kt b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/acs/AcsReaderService.kt index cc6dd5f..f29b74c 100644 --- a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/acs/AcsReaderService.kt +++ b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/acs/AcsReaderService.kt @@ -19,7 +19,11 @@ import androidx.core.content.IntentCompat import com.acs.smartcard.Reader import eu.pretix.libpretixnfc.android.R import eu.pretix.libpretixnfc.android.hardware.AcsNfcHandler -import eu.pretix.libpretixnfc.android.doAsyncSentry +import eu.pretix.libpretixnfc.android.launchWithSentry +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel class AcsReaderService : Service() { /* @@ -35,6 +39,7 @@ class AcsReaderService : Service() { public var cardHandler: ((Reader, Int) -> Unit)? = null private var reader: Reader? = null val VENDOR_ID = 0x072f + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) /** * Class used for the client Binder. Because we know this service always @@ -73,12 +78,12 @@ class AcsReaderService : Service() { ) if (firstStateChange) { firstStateChange = false - doAsyncSentry { + scope.launchWithSentry { readerConfig() } } if (slotNum == 0 && prevState == Reader.CARD_ABSENT && currState == Reader.CARD_PRESENT) { - doAsyncSentry { + scope.launchWithSentry { cardHandler?.invoke(reader!!, slotNum) } } @@ -230,6 +235,7 @@ class AcsReaderService : Service() { override fun onDestroy() { reader?.close() reader = null + scope.cancel() super.onDestroy() } } From dc965e4a4a4f34e2f61c494d99b7a97ff2c5dd03 Mon Sep 17 00:00:00 2001 From: Maximilian Richt Date: Thu, 2 Apr 2026 10:30:14 +0200 Subject: [PATCH 11/13] Ignore tag out of date SecurityException when finally trying to close the tag connection --- .../java/eu/pretix/libpretixnfc/highlevel/PretixMf0aes.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/libpretixnfc/src/main/java/eu/pretix/libpretixnfc/highlevel/PretixMf0aes.kt b/libpretixnfc/src/main/java/eu/pretix/libpretixnfc/highlevel/PretixMf0aes.kt index bfc1cb6..fdde57e 100644 --- a/libpretixnfc/src/main/java/eu/pretix/libpretixnfc/highlevel/PretixMf0aes.kt +++ b/libpretixnfc/src/main/java/eu/pretix/libpretixnfc/highlevel/PretixMf0aes.kt @@ -53,7 +53,11 @@ class PretixMf0aes(val keySets: List, val useRandomIdForNewTags: B } throw NfcChipReadError(ChipReadError.FOREIGN_CHIP) } finally { - nfca.close() + try { + nfca.close() + } catch (_: SecurityException) { + // ignore "tag is out of date" + } } } From cff4315a768e64e68bff0b366d40b9d36eea6046 Mon Sep 17 00:00:00 2001 From: Maximilian Richt Date: Thu, 2 Apr 2026 10:30:56 +0200 Subject: [PATCH 12/13] Increase android native nfc reader presence check, so we have more time to actually communicate with the tag --- .../libpretixnfc/android/hardware/AndroidNativeNfcHandler.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AndroidNativeNfcHandler.kt b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AndroidNativeNfcHandler.kt index 106a1b0..ca8269f 100644 --- a/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AndroidNativeNfcHandler.kt +++ b/libpretixnfc-android/src/main/java/eu/pretix/libpretixnfc/android/hardware/AndroidNativeNfcHandler.kt @@ -77,7 +77,7 @@ class AndroidNativeNfcHandler( this, FLAG_READER_SKIP_NDEF_CHECK or FLAG_READER_NFC_A, Bundle().apply { - putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 500) + putInt(NfcAdapter.EXTRA_READER_PRESENCE_CHECK_DELAY, 1200) } ) running = true From c1c1c661d505ca919e8dba414c94b1511fe64f2a Mon Sep 17 00:00:00 2001 From: Maximilian Richt Date: Thu, 2 Apr 2026 11:13:41 +0200 Subject: [PATCH 13/13] Extract instance creation of AESCMAC and BouncyCastleProvider, these are very expensive to do everytime --- .../commands/nxp/mf0aes/AuthenticationHelper.kt | 6 +++--- .../cryptography/An10922KeyDiversification.kt | 9 ++++++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/libpretixnfc/src/main/java/eu/pretix/libpretixnfc/commands/nxp/mf0aes/AuthenticationHelper.kt b/libpretixnfc/src/main/java/eu/pretix/libpretixnfc/commands/nxp/mf0aes/AuthenticationHelper.kt index 7324d40..cd41034 100644 --- a/libpretixnfc/src/main/java/eu/pretix/libpretixnfc/commands/nxp/mf0aes/AuthenticationHelper.kt +++ b/libpretixnfc/src/main/java/eu/pretix/libpretixnfc/commands/nxp/mf0aes/AuthenticationHelper.kt @@ -38,11 +38,11 @@ private val defaultRndGenerator = { length: Int -> b } +val staticAesCMac: Mac = Mac.getInstance("AESCMAC", BouncyCastleProvider()) fun aesCmac(secret: ByteArray, plaintext: ByteArray): ByteArray { val secretKey = SecretKeySpec(secret, 0, 16, "AES") - val mac = Mac.getInstance("AESCMAC", BouncyCastleProvider()) - mac.init(secretKey) - return mac.doFinal(plaintext) + staticAesCMac.init(secretKey) + return staticAesCMac.doFinal(plaintext) } fun truncateMac(mac: ByteArray): ByteArray { diff --git a/libpretixnfc/src/main/java/eu/pretix/libpretixnfc/cryptography/An10922KeyDiversification.kt b/libpretixnfc/src/main/java/eu/pretix/libpretixnfc/cryptography/An10922KeyDiversification.kt index 7a1d9aa..04bec98 100644 --- a/libpretixnfc/src/main/java/eu/pretix/libpretixnfc/cryptography/An10922KeyDiversification.kt +++ b/libpretixnfc/src/main/java/eu/pretix/libpretixnfc/cryptography/An10922KeyDiversification.kt @@ -7,6 +7,10 @@ import javax.crypto.spec.SecretKeySpec class An10922KeyDiversification { // https://www.nxp.com/docs/en/application-note/AN10922.pdf + companion object { + val staticAesCMac: Mac = Mac.getInstance("AESCMAC", BouncyCastleProvider()) + } + fun generateDiversifiedKeyAES128(masterKey: ByteArray, uid: ByteArray, applicationId: ByteArray, systemId: ByteArray): ByteArray { check(masterKey.size == 16) @@ -23,9 +27,8 @@ class An10922KeyDiversification { }*/ val secretKey = SecretKeySpec(masterKey, 0, 16, "AES") - val mac = Mac.getInstance("AESCMAC", BouncyCastleProvider()) - mac.init(secretKey) - val cmac = mac.doFinal(diversificationInput) + staticAesCMac.init(secretKey) + val cmac = staticAesCMac.doFinal(diversificationInput) return cmac } } \ No newline at end of file