diff --git a/pretixscan/build.gradle.kts b/pretixscan/build.gradle.kts index b02ba896..ed62a82d 100644 --- a/pretixscan/build.gradle.kts +++ b/pretixscan/build.gradle.kts @@ -4,6 +4,8 @@ plugins { alias(libs.plugins.jetbrainsCompose) apply false alias(libs.plugins.compose.compiler) apply false alias(libs.plugins.kotlinMultiplatform) apply false + alias(libs.plugins.kotlinJvm) apply false + alias(libs.plugins.kotlinKapt) apply false alias(libs.plugins.google.protobuf) apply false alias(libs.plugins.app.cash.sqldelight) apply false alias(libs.plugins.osdetector) apply false diff --git a/pretixscan/composeApp/src/commonMain/composeResources/values-de/strings.xml b/pretixscan/composeApp/src/commonMain/composeResources/values-de/strings.xml index 10ee4287..d85a16a2 100644 --- a/pretixscan/composeApp/src/commonMain/composeResources/values-de/strings.xml +++ b/pretixscan/composeApp/src/commonMain/composeResources/values-de/strings.xml @@ -72,7 +72,7 @@ dd.MM.yyyy HH:mm:ss dd.MM. HH:mm - Zuerst gescannt: %s + Zuerst gescannt: %1$td.%1$tm.%1$tY %1$tH:%1$tM OK Legen Sie bitte im Abschnitt "Geräte" Ihres Veranstalterskonto im pretix-Backend ein neues Gerät an um mit der Einrichtung zu beginnen. Es wird Ihnen dann ein QR-Code zum scannen angezeigt. @@ -159,6 +159,11 @@ Ticket momentan nicht gültig Ticket gesperrt Ticket storniert + Ticket bereits in ein Medium umgetauscht + Wiederverwendbares Medium nicht gültig + Wiederverwendbares Medium existiert bereits + Dieses Ticket muss in ein wiederverwendbares Medium getauscht werden. Leider unterstützt diese Version von pretixSCAN dies noch nicht. + Ticket muss in ein Medium umgetauscht werden, dies ist jedoch Offline nicht möglich Eine Synchronisation läuft bereits im Hintergrund. Unbezahlte Bestellung Dieses Ticket gehört zu einer Bestellung die noch nicht bezahlt wurde. Möchten Sie den Teilnehmer trotzdem einchecken\? Die Bestellung wird dadurch NICHT auf bezahlt gestellt. @@ -228,8 +233,10 @@ Badge-Druck nicht verfügbar Es sind keine Drucker auf diesem System vorhanden. Bitte konfigurieren Sie einen und versuchen Sie es erneut. Der ausgewählte Drucker ist nicht verfügbar. Bitte prüfen Sie dass das Gerät online ist und mit dem System verbunden ist. + Es ist kein Badge-Drucker ausgewählt. Bitte wählen Sie in den Einstellungen einen Badge-Drucker aus. Kamera Bevorzugte Kamera Auto Keine Kamera verfügbar + Die Verbindung aus der vorherigen Version von pretixSCAN ist nicht mehr gültig. Bitte verbinden Sie dieses Gerät erneut mit einem neuen Token aus Ihrem pretix-Veranstalterkonto. diff --git a/pretixscan/composeApp/src/commonMain/composeResources/values/strings.xml b/pretixscan/composeApp/src/commonMain/composeResources/values/strings.xml index 982b53a8..97862f8b 100644 --- a/pretixscan/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/pretixscan/composeApp/src/commonMain/composeResources/values/strings.xml @@ -167,6 +167,11 @@ Ticket currently not valid Ticket blocked Order canceled + Ticket already exchanged + Reusable medium invalid + Reusable medium already exists + This ticket requires to be exchanged to a reusable medium. Sadly this version of pretixSCAN isn't able to do this yet. + This ticket needs to be exchanged, but this isn't possible while offline A synchronization is already running in the background. Unpaid order This ticket belongs to an order that has not yet been paid. Do you still want to check it in? This will not mark the order as paid. @@ -237,11 +242,11 @@ There are no available printers on the system. Please, configure at least one printer and try again. - The selected printer is currently unavailable. Please - check that the device is online and connected to the system. - + The selected printer is currently unavailable. Please check that the device is online and connected to the system. + No badge printer is selected. Please choose a badge printer in the settings. Camera Preferred camera Auto No available cameras + The connection from the previous version of pretixSCAN is no longer valid. Please connect this device again using a new token from your pretix organizer account. diff --git a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/app/scan/GlobalScanSetup.kt b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/app/scan/GlobalScanSetup.kt index 3e78ef48..adfccb29 100644 --- a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/app/scan/GlobalScanSetup.kt +++ b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/app/scan/GlobalScanSetup.kt @@ -10,3 +10,6 @@ expect fun GlobalScanSetup( stateFlow: StateFlow>, onHandleDirectScan: suspend (String) -> Unit ) + +@Composable +expect fun rememberDiscardGlobalScanInput(): () -> Unit diff --git a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/app/sync/SyncRootService.kt b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/app/sync/SyncRootService.kt index 9e055b9d..3242d3f0 100644 --- a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/app/sync/SyncRootService.kt +++ b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/app/sync/SyncRootService.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import eu.pretix.desktop.cache.AppCache import eu.pretix.desktop.cache.DataStoreConfigStore import eu.pretix.libpretixsync.models.db.toModel +import eu.pretix.libpretixsync.sync.SyncException import eu.pretix.libpretixsync.sync.SyncManager import kotlinx.coroutines.* import kotlinx.coroutines.flow.* @@ -100,17 +101,18 @@ class SyncRootService( log.info("performing sync for ${events.size} events") - // Initialize event sync states - _eventSyncStates.value = events.associate { - it.eventSlug to EventSyncState.Pending - } - - _syncState.value = SyncState.InProgress("Syncing ${events.size} event(s)...") val syncManager = GlobalContext.get().get() activeSyncManager = syncManager + var started = false val syncResult = try { syncManager.sync(force) { message -> + if (!started) { + started = true + _eventSyncStates.value = events.associate { + it.eventSlug to EventSyncState.Pending + } + } _syncState.value = SyncState.InProgress(message) events.forEach { event -> if (message.contains(event.eventSlug, ignoreCase = true) || @@ -130,6 +132,10 @@ class SyncRootService( throw syncResult.exception } + if (!syncResult.isDataUploaded && !syncResult.isDataDownloaded) { + return + } + // Mark all as success _eventSyncStates.value = events.associate { it.eventSlug to EventSyncState.Success @@ -137,9 +143,6 @@ class SyncRootService( _syncState.value = SyncState.Success(lastSync = nowMillis) logEventSummary() - appConfig.lastFailedSync = 0L - appConfig.lastSync = nowMillis - appConfig.lastDownload = nowMillis } catch (e: Exception) { if (cancellationRequested) { cancellationRequested = false @@ -162,8 +165,10 @@ class SyncRootService( } _syncState.value = SyncState.Error(e.localizedMessage ?: "Unknown error") - appConfig.lastFailedSync = nowMillis - appConfig.lastFailedSyncMsg = e.localizedMessage ?: "Unknown error" + if (e !is SyncException) { + appConfig.lastFailedSync = System.currentTimeMillis() + appConfig.lastFailedSyncMsg = e.localizedMessage ?: "Unknown error" + } } } @@ -213,8 +218,7 @@ class SyncRootService( suspend fun minimalSync(nowMillis: Long = System.currentTimeMillis()) { log.info("Running a minimal sync") val shouldResume = skipFutureSyncs() - cancellationRequested = true - activeSyncManager?.cancel() + cancelActiveSync() _minimumSyncState.value = SyncState.InProgress("") withContext(Dispatchers.IO) { @@ -256,10 +260,18 @@ class SyncRootService( } } + suspend fun runWithSyncStopped(action: () -> Unit) { + log.info("Stopping sync") + skipFutureSyncs() + cancelActiveSync() + viewModelScope.launch(Dispatchers.IO) { + syncMutex.withLock { action() } + }.join() + } + fun forceSync(nowMillis: Long = System.currentTimeMillis()) { log.info("Full sync requested") - cancellationRequested = true - activeSyncManager?.cancel() + cancelActiveSync() viewModelScope.launch(Dispatchers.IO) { syncMutex.withLock { executeSync(force = true, nowMillis = nowMillis) @@ -269,6 +281,10 @@ class SyncRootService( override fun onCleared() { super.onCleared() + cancelActiveSync() + } + + private fun cancelActiveSync() { cancellationRequested = true activeSyncManager?.cancel() } diff --git a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/cache/AppCache.kt b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/cache/AppCache.kt index 131df481..7e2fd29a 100644 --- a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/cache/AppCache.kt +++ b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/cache/AppCache.kt @@ -12,16 +12,12 @@ class AppCache(val cacheFactory: LocalCacheFactory) { private var _db: SyncDatabase? = null val db: SyncDatabase - get() { - if (_db == null) { - _db = cacheFactory.getSyncDataSource() - } - return _db!! - } + @Synchronized get() = _db ?: cacheFactory.getSyncDataSource().also { _db = it } + @Synchronized fun reset() { - cacheFactory.deleteDataSource() _db = null + cacheFactory.deleteDataSource() } } diff --git a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/cache/SqlDelightUtils.kt b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/cache/SqlDelightUtils.kt index f39b8cfd..8dea0084 100644 --- a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/cache/SqlDelightUtils.kt +++ b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/cache/SqlDelightUtils.kt @@ -15,6 +15,7 @@ fun createSyncDatabase( driver = driver, CheckInAdapter = CheckIn.Adapter( datetimeAdapter = dateAdapter, + local_annulledAdapter = dateAdapter, ), ClosingAdapter = Closing.Adapter( cash_countedAdapter = bigDecimalAdapter, @@ -36,6 +37,7 @@ fun createSyncDatabase( tax_rateAdapter = bigDecimalAdapter, tax_valueAdapter = bigDecimalAdapter, line_price_grossAdapter = bigDecimalAdapter, + manual_discount_percentAdapter = bigDecimalAdapter, ), ReceiptAdapter = Receipt.Adapter( datetime_closedAdapter = dateAdapter, @@ -48,10 +50,16 @@ fun createSyncDatabase( date_fromAdapter = dateAdapter, date_toAdapter = dateAdapter, ), - QueuedCheckInAdapter = QueuedCheckIn.Adapter(datetimeAdapter = dateAdapter), + QueuedCheckInAdapter = QueuedCheckIn.Adapter( + datetimeAdapter = dateAdapter, + annulledAdapter = dateAdapter, + ), DiscountAdapter = Discount.Adapter( available_fromAdapter = dateAdapter, available_untilAdapter = dateAdapter, ), + ReusableMediumAdapter = ReusableMedium.Adapter( + expiresAdapter = dateAdapter, + ), ) } \ No newline at end of file diff --git a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/migration/ConfigMigration.kt b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/migration/ConfigMigration.kt index 36c94020..244f06a9 100644 --- a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/migration/ConfigMigration.kt +++ b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/migration/ConfigMigration.kt @@ -22,6 +22,11 @@ class ConfigMigration( oldConfig.resetEventConfig() } + suspend fun discardMigratedSettings() { + newConfig.resetEventConfig() + deleteOldConfig() + } + /** * Check if old configuration exists and hasn't been migrated yet. */ diff --git a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/migration/MigrationCoordinator.kt b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/migration/MigrationCoordinator.kt index 88f0d1f7..9a9a846b 100644 --- a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/migration/MigrationCoordinator.kt +++ b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/migration/MigrationCoordinator.kt @@ -1,16 +1,21 @@ package eu.pretix.desktop.migration +import eu.pretix.libpretixsync.api.DeviceAccessRevokedException +import eu.pretix.libpretixsync.api.UnauthorizedApiException +import kotlinx.coroutines.CancellationException import java.util.logging.Logger sealed class MigrationResult { object Success : MigrationResult() + object TokenRejected : MigrationResult() data class Failure(val error: String, val canRetry: Boolean) : MigrationResult() } class MigrationCoordinator( private val configMigration: ConfigMigration, private val tokenRoller: TokenRoller, - private val v1DirectoryLocator: V1DirectoryLocator = V1DirectoryLocator + private val v1DirectoryLocator: V1DirectoryLocator = V1DirectoryLocator, + private val cleanupV1Storage: () -> CleanupResult = V1StorageCleaner::cleanup ) { private val logger = Logger.getLogger(MigrationCoordinator::class.java.name) @@ -38,7 +43,11 @@ class MigrationCoordinator( logger.info("Settings migration completed successfully") // Step 2: Roll API token - rollToken() + if (!rollToken()) { + configMigration.discardMigratedSettings() + logger.warning("Migrated settings discarded, device must be set up again") + return MigrationResult.TokenRejected + } // Step 3: Cleanup V1 storage performCleanup() @@ -58,8 +67,8 @@ class MigrationCoordinator( /** * Roll device API token to invalidate old app access. */ - private suspend fun rollToken() { - try { + private suspend fun rollToken(): Boolean { + return try { logger.info("Attempting to roll device API token") val result = tokenRoller.rollApiToken() @@ -67,14 +76,24 @@ class MigrationCoordinator( onSuccess = { newKey -> tokenRoller.updateApiKey(newKey) logger.info("Token rolled successfully - old app access revoked") + true }, onFailure = { error -> - logger.warning("Token rolling failed (non-fatal): ${error.message}") - logger.warning("Old app may still have access - user can manually revoke device") + if (error is UnauthorizedApiException || error is DeviceAccessRevokedException) { + logger.warning("Server rejected the migrated token: ${error.message}") + false + } else { + logger.warning("Token rolling failed (non-fatal): ${error.message}") + logger.warning("Old app may still have access - user can manually revoke device") + true + } } ) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { logger.warning("Token rolling failed (non-fatal): ${e.message}") + true } } @@ -94,7 +113,7 @@ class MigrationCoordinator( logger.info("Starting automatic cleanup of V1 storage") - when (val result = V1StorageCleaner.cleanup()) { + when (val result = cleanupV1Storage()) { is CleanupResult.Success -> { logger.info("V1 storage cleanup successful: from $dataPath and $cachePath") } diff --git a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/migration/TokenRoller.kt b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/migration/TokenRoller.kt index 2c360baf..21410a70 100644 --- a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/migration/TokenRoller.kt +++ b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/migration/TokenRoller.kt @@ -3,6 +3,7 @@ package eu.pretix.desktop.migration import eu.pretix.desktop.cache.DataStoreConfig import eu.pretix.libpretixsync.api.HttpClientFactory import eu.pretix.libpretixsync.api.PretixApi +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONObject @@ -41,6 +42,8 @@ class TokenRoller( logger.info("Token rolled successfully, new key received") Result.success(newKey) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { logger.warning("Token rolling failed (non-fatal): ${e.message}") Result.failure(e) diff --git a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/printing/BadgeFactory.kt b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/printing/BadgeFactory.kt index 1b7928d9..81d60b66 100644 --- a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/printing/BadgeFactory.kt +++ b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/desktop/printing/BadgeFactory.kt @@ -8,4 +8,9 @@ interface BadgeFactory { suspend fun setup() fun printBadges(layout: BadgeLayout?, position: JSONObject) +} + +sealed class BadgePrinterUnavailableException(message: String) : Exception(message) { + class NotSelected : BadgePrinterUnavailableException("No badge printer selected in settings.") + class NotFound(printerName: String) : BadgePrinterUnavailableException("Badge printer $printerName is not available on this system.") } \ No newline at end of file diff --git a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/main/presentation/MainUiState.kt b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/main/presentation/MainUiState.kt index 9da566d8..f1afb206 100644 --- a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/main/presentation/MainUiState.kt +++ b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/main/presentation/MainUiState.kt @@ -42,5 +42,5 @@ data class MainUiStateData( ) fun MainUiStateData.secret(secret: String?): MainUiStateData { - return this.copy(secret = secret) + return this.copy(secret = secret, resultState = null) } \ No newline at end of file diff --git a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/settings/presentation/SettingsScreen.kt b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/settings/presentation/SettingsScreen.kt index 5b80372a..f4cae901 100644 --- a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/settings/presentation/SettingsScreen.kt +++ b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/settings/presentation/SettingsScreen.kt @@ -371,9 +371,11 @@ fun SettingsScreen( PrimaryButton( onClick = { // TODO: confirm reset - viewModel.logout() - navHostController.popBackStack() - navHostController.navigate(Route.Welcome.route) + coroutineScope.launch { + viewModel.logout() + navHostController.popBackStack() + navHostController.navigate(Route.Welcome.route) + } }, label = stringResource(Res.string.full_delete_action) ) diff --git a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/settings/presentation/SettingsViewModel.kt b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/settings/presentation/SettingsViewModel.kt index 1c79b778..197a4601 100644 --- a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/settings/presentation/SettingsViewModel.kt +++ b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/settings/presentation/SettingsViewModel.kt @@ -160,9 +160,10 @@ class SettingsViewModel( loadSettings() } - fun logout() { - syncRootService.skipFutureSyncs() - appCache.reset() - appConfig.resetEventConfig() + suspend fun logout() { + syncRootService.runWithSyncStopped { + appCache.reset() + appConfig.resetEventConfig() + } } } \ No newline at end of file diff --git a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/setup/SetupViewModel.kt b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/setup/SetupViewModel.kt index eaef7105..68a4ca54 100644 --- a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/setup/SetupViewModel.kt +++ b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/setup/SetupViewModel.kt @@ -12,9 +12,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.getString import org.json.JSONException import org.json.JSONObject import org.json.JSONTokener +import pretixscan.composeapp.generated.resources.Res +import pretixscan.composeapp.generated.resources.migration_token_rejected class SetupViewModel( private val setupManager: SetupManager, @@ -48,6 +51,10 @@ class SetupViewModel( is MigrationResult.Failure -> { _uiState.update { SetupUiState.Error(result.error) } } + is MigrationResult.TokenRejected -> { + val message = getString(Res.string.migration_token_rejected) + _uiState.update { SetupUiState.Error(message) } + } } } diff --git a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/tickets/data/TicketCodeHandler.kt b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/tickets/data/TicketCodeHandler.kt index 46496a71..be71c883 100644 --- a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/tickets/data/TicketCodeHandler.kt +++ b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/tickets/data/TicketCodeHandler.kt @@ -207,7 +207,14 @@ class TicketCodeHandler( TicketCheckProvider.CheckResult.Type.UNPAID -> getString(Res.string.scan_result_unpaid) TicketCheckProvider.CheckResult.Type.CANCELED -> getString(Res.string.scan_result_canceled) TicketCheckProvider.CheckResult.Type.PRODUCT -> getString(Res.string.scan_result_product) - else -> null + TicketCheckProvider.CheckResult.Type.ALREADY_EXCHANGED -> getString(Res.string.scan_result_already_exchanged) + TicketCheckProvider.CheckResult.Type.MEDIUM_INVALID -> getString(Res.string.scan_result_medium_invalid) + TicketCheckProvider.CheckResult.Type.MEDIUM_EXISTS -> getString(Res.string.scan_result_medium_exists) + TicketCheckProvider.CheckResult.Type.EXCHANGE_REQUIRED -> getString(Res.string.scan_result_medium_exchange_required) + TicketCheckProvider.CheckResult.Type.EXCHANGE_REQUIRED_OFFLINE -> getString(Res.string.scan_result_medium_exchange_required_offline) + null, + TicketCheckProvider.CheckResult.Type.ERROR, + TicketCheckProvider.CheckResult.Type.ANSWERS_REQUIRED -> null } } @@ -254,6 +261,9 @@ fun TicketCheckProvider.CheckResult.pathForSound(): String = TicketCheckProvider.CheckInType.EXIT -> Res.getUri("files/exit.wav") } + TicketCheckProvider.CheckResult.Type.EXCHANGE_REQUIRED, + TicketCheckProvider.CheckResult.Type.EXCHANGE_REQUIRED_OFFLINE -> Res.getUri("files/attention.wav") + null, TicketCheckProvider.CheckResult.Type.USED, TicketCheckProvider.CheckResult.Type.ERROR, @@ -267,6 +277,9 @@ fun TicketCheckProvider.CheckResult.pathForSound(): String = TicketCheckProvider.CheckResult.Type.AMBIGUOUS, TicketCheckProvider.CheckResult.Type.REVOKED, TicketCheckProvider.CheckResult.Type.UNAPPROVED, + TicketCheckProvider.CheckResult.Type.ALREADY_EXCHANGED, + TicketCheckProvider.CheckResult.Type.MEDIUM_INVALID, + TicketCheckProvider.CheckResult.Type.MEDIUM_EXISTS, TicketCheckProvider.CheckResult.Type.INVALID -> Res.getUri("files/error.wav") } @@ -291,7 +304,12 @@ fun TicketCheckProvider.CheckResult.resultState(): ResultState = TicketCheckProvider.CheckResult.Type.RULES, TicketCheckProvider.CheckResult.Type.AMBIGUOUS, TicketCheckProvider.CheckResult.Type.REVOKED, - TicketCheckProvider.CheckResult.Type.UNAPPROVED -> ResultState.ERROR + TicketCheckProvider.CheckResult.Type.UNAPPROVED, + TicketCheckProvider.CheckResult.Type.ALREADY_EXCHANGED, + TicketCheckProvider.CheckResult.Type.MEDIUM_INVALID, + TicketCheckProvider.CheckResult.Type.MEDIUM_EXISTS, + TicketCheckProvider.CheckResult.Type.EXCHANGE_REQUIRED, + TicketCheckProvider.CheckResult.Type.EXCHANGE_REQUIRED_OFFLINE -> ResultState.ERROR TicketCheckProvider.CheckResult.Type.ANSWERS_REQUIRED -> ResultState.DIALOG_QUESTIONS @@ -317,7 +335,7 @@ fun TicketCheckProvider.CheckResult.ticketAndVariationName(): String? { } fun TicketCheckProvider.CheckResult.reasonExplanation(): String? { - if (reasonExplanation.isNullOrBlank()) { + if (reasonExplanation.isNullOrBlank() || type == TicketCheckProvider.CheckResult.Type.EXCHANGE_REQUIRED_OFFLINE) { return null } diff --git a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/tickets/presentation/TicketHandlingDialog.kt b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/tickets/presentation/TicketHandlingDialog.kt index c773db11..24e78732 100644 --- a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/tickets/presentation/TicketHandlingDialog.kt +++ b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/tickets/presentation/TicketHandlingDialog.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.input.key.* import androidx.compose.ui.unit.dp import com.composables.core.* import eu.pretix.desktop.app.ui.ErrorDialog +import eu.pretix.libpretixsync.db.Answer import eu.pretix.scan.tickets.data.DismissBehavior import eu.pretix.scan.tickets.data.ResultState import eu.pretix.scan.tickets.data.dismissBehavior @@ -48,15 +49,22 @@ fun TicketHandlingDialog( var remainingTimeProgress by remember { mutableStateOf(1.0f) } val focusRequester = remember { FocusRequester() } + suspend fun handleTicketAndReportResult(answers: List? = null, ignoreUnpaid: Boolean = false) { + viewModel.handleTicket(secret, answers = answers, ignoreUnpaid = ignoreUnpaid) + val resultState = viewModel.uiState.value.resultState + if (resultState != ResultState.EMPTY && resultState != ResultState.LOADING) { + onResultStateChanged(resultState) + } + } + LaunchedEffect(secret, scanTimestamp) { viewModel.resetTicketHandlingState() - viewModel.handleTicket(secret) + handleTicketAndReportResult() } - LaunchedEffect(uiState.resultState) { + LaunchedEffect(scanTimestamp, uiState.resultState) { if (uiState.resultState != ResultState.EMPTY && uiState.resultState != ResultState.LOADING) { focusRequester.requestFocus() - onResultStateChanged(uiState.resultState) } if (uiState.resultState.dismissBehavior() == DismissBehavior.AutoDismiss) { @@ -119,11 +127,7 @@ fun TicketHandlingDialog( } }, ) { - val onPrintBadges: () -> Unit = { - coroutineScope.launch { - viewModel.printBadges() - } - } + val onPrintBadges: () -> Unit = { viewModel.printBadges() } when (uiState.resultState) { ResultState.EMPTY -> {} @@ -143,7 +147,7 @@ fun TicketHandlingDialog( ResultState.DIALOG_UNPAID -> UnpaidDialogView(data = uiState, onCancel = onDismiss, onCheckInAnyway = { coroutineScope.launch { - viewModel.handleTicket(secret, ignoreUnpaid = true) + handleTicketAndReportResult(ignoreUnpaid = true) } }) @@ -151,7 +155,7 @@ fun TicketHandlingDialog( data = uiState, onConfirm = { answers -> coroutineScope.launch { - viewModel.handleTicket(secret, answers = answers, ignoreUnpaid = true) + handleTicketAndReportResult(answers = answers, ignoreUnpaid = true) } }, onCancel = onDismiss @@ -174,7 +178,7 @@ fun TicketHandlingDialog( is TicketHandlingErrors.Error -> { ErrorDialog( title = stringResource(Res.string.badge_printing_not_available), - message = (localTicketHandlingErrors as TicketHandlingErrors.Error).exception, + message = stringResource((localTicketHandlingErrors as TicketHandlingErrors.Error).message), onDismiss = { viewModel.dismissError() } diff --git a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/tickets/presentation/TicketHandlingDialogViewModel.kt b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/tickets/presentation/TicketHandlingDialogViewModel.kt index 4772649b..b6a87d90 100644 --- a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/tickets/presentation/TicketHandlingDialogViewModel.kt +++ b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/tickets/presentation/TicketHandlingDialogViewModel.kt @@ -1,9 +1,11 @@ package eu.pretix.scan.tickets.presentation import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import eu.pretix.desktop.cache.AppCache import eu.pretix.desktop.cache.DataStoreConfigStore import eu.pretix.desktop.printing.BadgeFactory +import eu.pretix.desktop.printing.BadgePrinterUnavailableException import eu.pretix.libpretixsync.api.PretixApi import eu.pretix.libpretixsync.db.Answer import eu.pretix.libpretixsync.db.NonceGenerator @@ -12,17 +14,27 @@ import eu.pretix.scan.tickets.data.ResultStateData import eu.pretix.scan.tickets.data.TicketCodeHandler import eu.pretix.scan.tickets.data.isPreviouslyPrinted import eu.pretix.scan.tickets.data.shouldAutoPrint +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.StringResource import org.json.JSONObject +import pretixscan.composeapp.generated.resources.Res +import pretixscan.composeapp.generated.resources.badge_printing_no_printer_selected +import pretixscan.composeapp.generated.resources.badge_printing_selected_printer_not_available import java.text.SimpleDateFormat import java.util.Date import java.util.Locale import java.util.TimeZone +import java.util.logging.Level import java.util.logging.Logger class TicketHandlingDialogViewModel( @@ -31,10 +43,13 @@ class TicketHandlingDialogViewModel( private val appConfig: DataStoreConfigStore, private val appCache: AppCache, private val api: PretixApi, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) : ViewModel() { private val log = Logger.getLogger("TicketHandlingDialogViewModel") + private val printMutex = Mutex() + private val _uiState = MutableStateFlow(ResultStateData(resultState = ResultState.EMPTY)) val uiState = _uiState.asStateFlow() @@ -43,6 +58,7 @@ class TicketHandlingDialogViewModel( fun resetTicketHandlingState() { _uiState.value = ResultStateData(resultState = ResultState.EMPTY) + _localTicketHandlingErrors.value = TicketHandlingErrors.None } private val _localTicketHandlingErrors = MutableStateFlow>(TicketHandlingErrors.None) @@ -78,10 +94,11 @@ class TicketHandlingDialogViewModel( } } - suspend fun printBadges() { + fun printBadges() { log.info("User requested to print a badge") val layout = _uiState.value.badgeLayout val position = _uiState.value.position + val eventSlug = _uiState.value.eventSlug if (layout == null) { log.warning("No layout, aborting print") return @@ -92,20 +109,41 @@ class TicketHandlingDialogViewModel( return } - try { - withContext(Dispatchers.IO) { - badgeFactory.setup() - badgeFactory.printBadges(layout, position) - logSuccessfulPrint() + viewModelScope.launch { + printMutex.withLock { + try { + withContext(ioDispatcher) { + badgeFactory.setup() + badgeFactory.printBadges(layout, position) + logSuccessfulPrint(position, eventSlug) + } + } catch (e: CancellationException) { + throw e + } catch (e: BadgePrinterUnavailableException) { + log.warning("Badge printer unavailable: ${e.message}") + showPrintError(position, e.userMessage()) + } catch (e: Exception) { + log.log(Level.WARNING, "Badge printing failed", e) + } } - } catch (e: Exception) { - _localTicketHandlingErrors.update { TicketHandlingErrors.Error(e.localizedMessage) } } } - private fun logSuccessfulPrint() { - val positionId = _uiState.value.position?.optLong("id", 0L) ?: 0L - val eventSlug = _uiState.value.eventSlug + private fun BadgePrinterUnavailableException.userMessage(): StringResource = when (this) { + is BadgePrinterUnavailableException.NotSelected -> Res.string.badge_printing_no_printer_selected + is BadgePrinterUnavailableException.NotFound -> Res.string.badge_printing_selected_printer_not_available + } + + private fun showPrintError(position: JSONObject, message: StringResource) { + if (_uiState.value.position !== position) { + log.info("Discarding badge print error for a ticket that is no longer shown") + return + } + _localTicketHandlingErrors.value = TicketHandlingErrors.Error(message) + } + + private fun logSuccessfulPrint(position: JSONObject, eventSlug: String?) { + val positionId = position.optLong("id", 0L) if (positionId <= 0L || eventSlug.isNullOrBlank()) { log.warning("Cannot log print: positionId=$positionId eventSlug=$eventSlug") @@ -134,5 +172,5 @@ class TicketHandlingDialogViewModel( sealed class TicketHandlingErrors { object None : TicketHandlingErrors() - data class Error(val exception: String) : TicketHandlingErrors() + data class Error(val message: StringResource) : TicketHandlingErrors() } \ No newline at end of file diff --git a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/tickets/presentation/TicketSearchBar.kt b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/tickets/presentation/TicketSearchBar.kt index 3f044c6a..6a23bade 100644 --- a/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/tickets/presentation/TicketSearchBar.kt +++ b/pretixscan/composeApp/src/commonMain/kotlin/eu/pretix/scan/tickets/presentation/TicketSearchBar.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import eu.pretix.desktop.app.scan.rememberDiscardGlobalScanInput import eu.pretix.libpretixsync.check.TicketCheckProvider import eu.pretix.scan.main.presentation.MainUiState import eu.pretix.scan.main.presentation.MainViewModel @@ -36,6 +37,7 @@ fun TicketSearchBar( val isSearching by viewModel.isSearching.collectAsState() val searchSuggestions by viewModel.searchSuggestions.collectAsStateWithLifecycle() val mainUiState by mainViewModel.uiState.collectAsState() + val discardGlobalScanInput = rememberDiscardGlobalScanInput() val barcodePattern = remember { Regex("[a-zA-Z0-9=+/]{5,}") } @@ -58,6 +60,7 @@ fun TicketSearchBar( .padding(16.dp), onSearchValueChanged = viewModel::onSearchTextChange, onEnterPressed = { + discardGlobalScanInput() if (searchSuggestions.isNotEmpty()) { log.info("AutoScan: Enter pressed with search results, selecting first result") viewModel.clearSearch() diff --git a/pretixscan/composeApp/src/desktopMain/kotlin/di/PlatformModules.desktop.kt b/pretixscan/composeApp/src/desktopMain/kotlin/di/PlatformModules.desktop.kt index b7197443..2a415221 100644 --- a/pretixscan/composeApp/src/desktopMain/kotlin/di/PlatformModules.desktop.kt +++ b/pretixscan/composeApp/src/desktopMain/kotlin/di/PlatformModules.desktop.kt @@ -39,12 +39,8 @@ actual val platformModules: List factory { val config = get() - val uploadInterval: Long = 1000 - var downloadInterval: Long = 30000 - if (!config.offlineMode) { - downloadInterval = 120000 - } - + val uploadInterval: Long = 60000 + val downloadInterval: Long = 5 * 60000L SyncManager( get(), diff --git a/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/app/scan/GlobalScanHandler.kt b/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/app/scan/GlobalScanHandler.kt index ce587c73..b96a6525 100644 --- a/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/app/scan/GlobalScanHandler.kt +++ b/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/app/scan/GlobalScanHandler.kt @@ -13,18 +13,15 @@ class GlobalScanHandler { private val log = Logger.getLogger("GlobalScanHandler") private val scanBuffer = StringBuilder() private var timeoutJob: Job? = null - private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private var stateFlow: StateFlow>? = null private var onHandleDirectScan: (suspend (String) -> Unit)? = null private var isRegistered = false + private var isSwallowingEnter = false private val keyEventDispatcher = KeyEventDispatcher { event -> - if (event.id == KeyEvent.KEY_TYPED) { - handleKeyEvent(event) - } else { - false - } + handleKeyEvent(event) } fun setHandlers( @@ -41,9 +38,22 @@ class GlobalScanHandler { } } - private fun handleKeyEvent(event: KeyEvent): Boolean { - val char = event.keyChar + internal fun handleKeyEvent(event: KeyEvent): Boolean = + when (event.id) { + KeyEvent.KEY_PRESSED -> handleKeyPressed(event.keyCode) + KeyEvent.KEY_TYPED -> handleKeyTyped(event.keyChar) + KeyEvent.KEY_RELEASED -> handleKeyReleased(event.keyCode) + else -> false + } + + private fun handleKeyPressed(keyCode: Int): Boolean { + if (keyCode == KeyEvent.VK_ENTER) { + isSwallowingEnter = false + } + return false + } + private fun handleKeyTyped(char: Char): Boolean { when (char) { '\n', '\r' -> { val scanned = scanBuffer.toString().trim() @@ -52,15 +62,12 @@ class GlobalScanHandler { scope.launch { onHandleDirectScan?.invoke(scanned) } - scanBuffer.clear() - timeoutJob?.cancel() - timeoutJob = null + discardTypedInput() + isSwallowingEnter = true return true } else { log.info("GlobalScan: Enter detected but buffer doesn't match barcode pattern: '$scanned'") - scanBuffer.clear() - timeoutJob?.cancel() - timeoutJob = null + discardTypedInput() return false } } @@ -83,6 +90,20 @@ class GlobalScanHandler { } } + private fun handleKeyReleased(keyCode: Int): Boolean { + if (keyCode != KeyEvent.VK_ENTER || !isSwallowingEnter) { + return false + } + isSwallowingEnter = false + return true + } + + fun discardTypedInput() { + scanBuffer.clear() + timeoutJob?.cancel() + timeoutJob = null + } + fun dispose() { if (isRegistered) { KeyboardFocusManager.getCurrentKeyboardFocusManager().removeKeyEventDispatcher(keyEventDispatcher) @@ -91,6 +112,7 @@ class GlobalScanHandler { timeoutJob?.cancel() timeoutJob = null scanBuffer.clear() + isSwallowingEnter = false onHandleDirectScan = null stateFlow = null log.info("GlobalScanHandler unregistered") diff --git a/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/app/scan/GlobalScanSetup.desktop.kt b/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/app/scan/GlobalScanSetup.desktop.kt index 6e0d389a..a6ef32fb 100644 --- a/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/app/scan/GlobalScanSetup.desktop.kt +++ b/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/app/scan/GlobalScanSetup.desktop.kt @@ -2,6 +2,7 @@ package eu.pretix.desktop.app.scan import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember import eu.pretix.scan.main.presentation.MainUiState import eu.pretix.scan.main.presentation.MainUiStateData import kotlinx.coroutines.flow.StateFlow @@ -21,3 +22,9 @@ actual fun GlobalScanSetup( } } } + +@Composable +actual fun rememberDiscardGlobalScanInput(): () -> Unit { + val globalScanHandler = koinInject() + return remember(globalScanHandler) { globalScanHandler::discardTypedInput } +} diff --git a/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/cache/DatabaseDriverFactory.kt b/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/cache/DatabaseDriverFactory.kt index 79cd26ff..30764e0f 100644 --- a/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/cache/DatabaseDriverFactory.kt +++ b/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/cache/DatabaseDriverFactory.kt @@ -1,5 +1,6 @@ package eu.pretix.desktop.cache +import app.cash.sqldelight.db.AfterVersion import app.cash.sqldelight.db.SqlDriver import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver import eu.pretix.libpretixsync.sqldelight.AndroidUtilDateAdapter @@ -11,6 +12,14 @@ import java.io.File import java.util.* import java.util.logging.Logger +val clearEmptyReusableMediumExpiryCallback = AfterVersion(116L) { driver -> + driver.execute( + identifier = null, + sql = "UPDATE ReusableMedium SET expires = NULL WHERE expires = '';", + parameters = 0, + ) +} + class JvmLocalCacheFactory : LocalCacheFactory { val log = Logger.getLogger("JvmLocalCacheFactory") @@ -20,10 +29,15 @@ class JvmLocalCacheFactory : LocalCacheFactory { override fun deleteDataSource() { val dbFile = getDatabasePath() - if (dbFile.exists()) { - log.info("Deleting database at $dbFile") - dbFile.delete() - } + listOf("", "-wal", "-shm") + .map { File(dbFile.path + it) } + .filter { it.exists() } + .forEach { file -> + log.info("Deleting database file at $file") + if (!file.delete()) { + log.warning("Failed to delete $file") + } + } } private fun createDriver(url: String): SqlDriver { @@ -33,6 +47,7 @@ class JvmLocalCacheFactory : LocalCacheFactory { schema = SyncDatabase.Schema, callbacks = arrayOf( minVersionCallback, + clearEmptyReusableMediumExpiryCallback, clearResourceSyncStatusCallback, ), ) diff --git a/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/printing/BadgeFactory.kt b/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/printing/BadgeFactory.kt index 5286ee12..09792d08 100644 --- a/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/printing/BadgeFactory.kt +++ b/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/printing/BadgeFactory.kt @@ -48,45 +48,51 @@ class DesktopBadgeFactory( throw IllegalStateException("BadgeLayout can't be null") } - val outputFile = File(getUserCacheFolder(), "print.pdf") - if (!outputFile.parentFile.exists()) { - outputFile.parentFile.mkdirs() - } + val service = getPreferredPrinterService() - val backgroundFileName = layout.backgroundFilename - if (backgroundFileName != null) { - log.info("Rendering PDF with background filename: $backgroundFileName") - fileStorage.getFile(backgroundFileName).inputStream().use { backgroundStream -> - renderer.writePDF(layout.layout, position, backgroundStream, outputFile) - } - } else { - log.info("Rendering PDF with no background filename") - renderer.writePDF(layout.layout, position, null, outputFile) + val cacheFolder = File(getUserCacheFolder()) + if (!cacheFolder.exists()) { + cacheFolder.mkdirs() } + val outputFile = File.createTempFile("badge", ".pdf", cacheFolder) + + try { + val backgroundFileName = layout.backgroundFilename + if (backgroundFileName != null) { + log.info("Rendering PDF with background filename: $backgroundFileName") + fileStorage.getFile(backgroundFileName).inputStream().use { backgroundStream -> + renderer.writePDF(layout.layout, position, backgroundStream, outputFile) + } + } else { + log.info("Rendering PDF with no background filename") + renderer.writePDF(layout.layout, position, null, outputFile) + } - val service = getPreferredPrinterService() - val document: PDDocument = Loader.loadPDF(outputFile) - - val orientation = appConfig.badgePrinterOrientation - val job = PrinterJob.getPrinterJob() - job.printService = service + Loader.loadPDF(outputFile).use { document -> + val orientation = appConfig.badgePrinterOrientation + val job = PrinterJob.getPrinterJob() + job.printService = service - val attributes = HashPrintRequestAttributeSet() + val attributes = HashPrintRequestAttributeSet() - if (orientation == "Auto") { - log.info("Printing with automatic orientation") - updateJobNewWay(document, attributes, job) - } else { - log.info("Printing with user selected orientation (old way)") - updateJobOldWay(orientation, job, document, attributes) - } + if (orientation == "Auto") { + log.info("Printing with automatic orientation") + updateJobNewWay(document, attributes, job) + } else { + log.info("Printing with user selected orientation (old way)") + updateJobOldWay(orientation, job, document, attributes) + } - job.jobName = "pretixSCAN badge" + job.jobName = "pretixSCAN badge" - log.info("Sending to printer...") - val copies = if (appConfig.printBadgesTwice) 2 else 1 - repeat(copies) { job.print(attributes) } - log.info("Printing done.") + log.info("Sending to printer...") + val copies = if (appConfig.printBadgesTwice) 2 else 1 + repeat(copies) { job.print(attributes) } + log.info("Printing done.") + } + } finally { + outputFile.delete() + } } private fun updateJobOldWay( @@ -140,15 +146,19 @@ class DesktopBadgeFactory( } private fun getPreferredPrinterService(): PrintService { - val choice = printerSource.selectOption(appConfig.badgePrinterName) - ?: throw IllegalStateException("No printer service selected in settings.") + val printerName = appConfig.badgePrinterName + if (printerName.isNullOrBlank()) { + throw BadgePrinterUnavailableException.NotSelected() + } + val choice = printerSource.selectOption(printerName) + ?: throw BadgePrinterUnavailableException.NotFound(printerName) val service = PrintServiceLookup.lookupPrintServices(DocFlavor.SERVICE_FORMATTED.PAGEABLE, null) .firstOrNull { it.name == choice.value } if (service == null) { - throw IllegalStateException("Couldn't find print service ${choice.value}.") + throw BadgePrinterUnavailableException.NotFound(printerName) } return service diff --git a/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/printing/FontRegistrar.kt b/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/printing/FontRegistrar.kt index 0bcc798b..0eef5c51 100644 --- a/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/printing/FontRegistrar.kt +++ b/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/printing/FontRegistrar.kt @@ -75,13 +75,12 @@ class FontRegistrar { @OptIn(ExperimentalResourceApi::class) private suspend fun exportFont(dataDir: String, path: String): String { - val bytes = Res.readBytes(path) val file = File(dataDir, path) if (file.exists()) { // already done return file.absolutePath } - file.writeBytes(bytes) + file.writeBytes(Res.readBytes(path)) return file.absolutePath } diff --git a/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/printing/OrderPositionContentProvider.kt b/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/printing/OrderPositionContentProvider.kt index 307a05f6..1938f62b 100644 --- a/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/printing/OrderPositionContentProvider.kt +++ b/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/printing/OrderPositionContentProvider.kt @@ -54,7 +54,7 @@ class OrderPositionContentProvider( } else if (content == "other_i18n") { if (textI18n != null) interpolate(i18nToString(textI18n) ?: "") else "" } else if (op.has("pdf_data") && op.getJSONObject("pdf_data").has(content)) { - op.getJSONObject("pdf_data").getString(content) + op.getJSONObject("pdf_data").optString(content, "") } else { "???" } diff --git a/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/printing/Renderer.kt b/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/printing/Renderer.kt index ed029ed5..79b15a5a 100644 --- a/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/printing/Renderer.kt +++ b/pretixscan/composeApp/src/desktopMain/kotlin/eu/pretix/desktop/printing/Renderer.kt @@ -5,7 +5,6 @@ import eu.pretix.desktop.cache.DesktopFileStorage import eu.pretix.libpretixprint.templating.ContentProvider import eu.pretix.libpretixprint.templating.Layout import org.json.JSONArray -import org.json.JSONException import org.json.JSONObject import java.io.File import java.io.InputStream @@ -18,18 +17,14 @@ class Renderer( fun writePDF(layout: JSONArray, position: JSONObject, background: InputStream?, outFile: File) { val posList = emptyList().toMutableList() posList.add(OrderPositionContentProvider(appCache, fileStorage, position)) - try { - val l = Layout( - layout, - background, - posList.listIterator() - ) - // Default to A6, like pretix - l.defaultWidth = 5.8f * 72f - l.defaultHeight = 4.1f * 72f - l.render(outFile.absolutePath) - } catch (e: JSONException) { - e.printStackTrace() - } + val l = Layout( + layout, + background, + posList.listIterator() + ) + // Default to A6, like pretix + l.defaultWidth = 5.8f * 72f + l.defaultHeight = 4.1f * 72f + outFile.outputStream().use { l.render(it) } } } \ No newline at end of file diff --git a/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/desktop/app/scan/GlobalScanHandlerTest.kt b/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/desktop/app/scan/GlobalScanHandlerTest.kt new file mode 100644 index 00000000..5bb743b3 --- /dev/null +++ b/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/desktop/app/scan/GlobalScanHandlerTest.kt @@ -0,0 +1,113 @@ +package eu.pretix.desktop.app.scan + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import java.awt.Component +import java.awt.event.KeyEvent +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class GlobalScanHandlerTest { + + private val handler = GlobalScanHandler() + private val source = object : Component() {} + + @BeforeTest + fun setup() { + Dispatchers.setMain(StandardTestDispatcher()) + } + + @AfterTest + fun teardown() { + handler.dispose() + Dispatchers.resetMain() + } + + private fun typed(c: Char) = KeyEvent(source, KeyEvent.KEY_TYPED, 0L, 0, KeyEvent.VK_UNDEFINED, c) + + private fun pressed(keyCode: Int) = KeyEvent(source, KeyEvent.KEY_PRESSED, 0L, 0, keyCode, KeyEvent.CHAR_UNDEFINED) + + private fun released(keyCode: Int) = KeyEvent(source, KeyEvent.KEY_RELEASED, 0L, 0, keyCode, KeyEvent.CHAR_UNDEFINED) + + private fun type(text: String) = text.forEach { handler.handleKeyEvent(typed(it)) } + + private fun pressEnter() = listOf( + handler.handleKeyEvent(pressed(KeyEvent.VK_ENTER)), + handler.handleKeyEvent(typed('\n')), + handler.handleKeyEvent(released(KeyEvent.VK_ENTER)) + ) + + @Test + fun `Enter after barcode characters triggers a scan`() { + type("x3fvqtq3yzny") + + assertTrue(handler.handleKeyEvent(typed('\n'))) + } + + @Test + fun `Enter after discarded input does not trigger a scan`() { + type("x3fvqtq3yzny") + handler.discardTypedInput() + + assertFalse(handler.handleKeyEvent(typed('\n'))) + } + + @Test + fun `Enter completing a scan is swallowed on type and release`() { + type("x3fvqtq3yzny") + + assertEquals(listOf(false, true, true), pressEnter()) + assertFalse(handler.handleKeyEvent(released(KeyEvent.VK_ENTER))) + } + + @Test + fun `Enter without buffered barcode passes through`() { + assertEquals(listOf(false, false, false), pressEnter()) + } + + @Test + fun `Enter after too short input passes through`() { + type("ab1") + + assertEquals(listOf(false, false, false), pressEnter()) + } + + @Test + fun `Enter handled by a focused search field passes through`() { + type("x3fvqtq3yzny") + + assertFalse(handler.handleKeyEvent(pressed(KeyEvent.VK_ENTER))) + handler.discardTypedInput() + assertFalse(handler.handleKeyEvent(typed('\n'))) + assertFalse(handler.handleKeyEvent(released(KeyEvent.VK_ENTER))) + } + + @Test + fun `Non-Enter key press and release pass through after barcode characters`() { + type("x3fvqtq3yzny") + + assertFalse(handler.handleKeyEvent(pressed(KeyEvent.VK_A))) + assertFalse(handler.handleKeyEvent(released(KeyEvent.VK_A))) + } + + @Test + fun `Auto-repeated Enter press does not swallow a later Enter`() { + type("x3fvqtq3yzny") + + assertFalse(handler.handleKeyEvent(pressed(KeyEvent.VK_ENTER))) + assertTrue(handler.handleKeyEvent(typed('\n'))) + assertFalse(handler.handleKeyEvent(pressed(KeyEvent.VK_ENTER))) + assertFalse(handler.handleKeyEvent(typed('\n'))) + assertFalse(handler.handleKeyEvent(released(KeyEvent.VK_ENTER))) + + assertEquals(listOf(false, false, false), pressEnter()) + } +} diff --git a/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/desktop/app/sync/SyncRootServiceTest.kt b/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/desktop/app/sync/SyncRootServiceTest.kt new file mode 100644 index 00000000..1ee4ad8a --- /dev/null +++ b/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/desktop/app/sync/SyncRootServiceTest.kt @@ -0,0 +1,136 @@ +package eu.pretix.desktop.app.sync + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import eu.pretix.desktop.cache.DataStoreConfig +import eu.pretix.desktop.cache.DataStoreConfigStore +import eu.pretix.desktop.cache.EventSelection +import eu.pretix.libpretixsync.sync.SyncException +import eu.pretix.libpretixsync.sync.SyncManager +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import kotlinx.coroutines.withTimeout +import okio.Path.Companion.toPath +import org.json.JSONObject +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import java.nio.file.Path +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.createTempDirectory +import kotlin.io.path.deleteRecursively +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@OptIn(ExperimentalCoroutinesApi::class) +class SyncRootServiceTest { + + private lateinit var testTempDir: Path + private lateinit var config: DataStoreConfigStore + + @BeforeTest + fun setup() { + Dispatchers.setMain(StandardTestDispatcher()) + testTempDir = createTempDirectory("pretixscan-sync-test-") + val dataStore = PreferenceDataStoreFactory.createWithPath( + produceFile = { testTempDir.resolve("config.preferences_pb").toString().toPath() } + ) + config = DataStoreConfigStore(DataStoreConfig(dataStore)) + config.setDeviceConfig("https://pretix.example.com", "device-token", "demo-org", 1L, "SERIAL", 1) + config.eventSelections = listOf( + EventSelection( + eventSlug = "test-event", + eventName = "Test Event", + subEventId = null, + checkInListId = 1L, + checkInListName = "Main List", + dateFrom = null, + dateTo = null + ) + ) + } + + @OptIn(ExperimentalPathApi::class) + @AfterTest + fun teardown() { + stopKoin() + Dispatchers.resetMain() + runCatching { testTempDir.deleteRecursively() } + } + + private fun createService(pass: SyncManager.() -> SyncManager.SyncResult): SyncRootService { + val syncManager = object : SyncManager( + config, null, null, null, null, 1000L, 120000L, SyncManager.Profile.PRETIXSCAN, false, 1, JSONObject(), + "", "", "", "", "", "", null, null, null + ) { + override fun sync(force: Boolean, feedback: ProgressFeedback?): SyncResult = pass() + } + startKoin { modules(module { factory { syncManager } }) } + return SyncRootService(config, mockk(relaxed = true)) + } + + private fun SyncRootService.runPass(): SyncState = runBlocking { + forceSync(nowMillis = PASS_START) + val state = withTimeout(5.seconds) { + syncState.first { it is SyncState.Success || it is SyncState.Error } + } + runWithSyncStopped {} + state + } + + @Test + fun test_completed_pass_leaves_sync_timestamps_to_sync_manager() { + val service = createService { + config.lastSync = SYNC_MANAGER_TIME + SyncResult(true, false, null) + } + + val state = service.runPass() + + assertEquals(SyncState.Success(lastSync = PASS_START), state) + assertEquals(SYNC_MANAGER_TIME, config.lastSync) + assertEquals(0L, config.lastDownload) + assertEquals(0L, config.lastFailedSync) + } + + @Test + fun test_failure_recorded_by_sync_manager_keeps_its_timestamp() { + val service = createService { + config.lastFailedSync = SYNC_MANAGER_TIME + config.lastFailedSyncMsg = "Unauthorized" + SyncResult(true, true, SyncException("Unauthorized")) + } + + val state = service.runPass() + + assertEquals(SyncState.Error("Unauthorized"), state) + assertEquals(SYNC_MANAGER_TIME, config.lastFailedSync) + assertEquals("Unauthorized", config.lastFailedSyncMsg) + } + + @Test + fun test_failure_outside_sync_manager_is_recorded_at_failure_time() { + val service = createService { throw IllegalStateException("database is locked") } + val beforePass = System.currentTimeMillis() + + val state = service.runPass() + + assertEquals(SyncState.Error("database is locked"), state) + assertTrue(config.lastFailedSync >= beforePass) + assertEquals("database is locked", config.lastFailedSyncMsg) + } + + private companion object { + const val PASS_START = 1234L + const val SYNC_MANAGER_TIME = 5678L + } +} diff --git a/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/desktop/cache/ReusableMediumExpiryRepairTest.kt b/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/desktop/cache/ReusableMediumExpiryRepairTest.kt new file mode 100644 index 00000000..6866b82f --- /dev/null +++ b/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/desktop/cache/ReusableMediumExpiryRepairTest.kt @@ -0,0 +1,61 @@ +package eu.pretix.desktop.cache + +import app.cash.sqldelight.db.SqlDriver +import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver +import eu.pretix.libpretixsync.sqldelight.AndroidUtilDateAdapter +import eu.pretix.libpretixsync.sqldelight.BigDecimalAdapter +import eu.pretix.libpretixsync.sqldelight.SyncDatabase +import java.nio.file.Path +import java.util.Date +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.createTempDirectory +import kotlin.io.path.deleteRecursively +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ReusableMediumExpiryRepairTest { + + private lateinit var testTempDir: Path + private lateinit var driver: SqlDriver + + @BeforeTest + fun setup() { + testTempDir = createTempDirectory("pretixscan-db-test-") + driver = JdbcSqliteDriver("jdbc:sqlite:" + testTempDir.resolve("sync.db").toAbsolutePath()) + SyncDatabase.Schema.create(driver) + } + + @OptIn(ExperimentalPathApi::class) + @AfterTest + fun teardown() { + driver.close() + runCatching { testTempDir.deleteRecursively() } + } + + private fun insertMedium(serverId: Long, expires: String) { + driver.execute( + identifier = null, + sql = "INSERT INTO ReusableMedium(active, expires, server_id) VALUES (1, ?, ?);", + parameters = 2, + ) { + bindString(0, expires) + bindLong(1, serverId) + } + } + + @Test + fun `upgrading a schema 116 database clears empty expiry dates and keeps real ones`() { + val expiry = Date(1_767_225_600_000L) + insertMedium(serverId = 1L, expires = "") + insertMedium(serverId = 2L, expires = AndroidUtilDateAdapter().encode(expiry)) + + SyncDatabase.Schema.migrate(driver, 116L, 117L, clearEmptyReusableMediumExpiryCallback) + + val db = createSyncDatabase(driver, AndroidUtilDateAdapter(), BigDecimalAdapter()) + assertNull(db.reusableMediumQueries.selectByServerId(1L).executeAsOne().expires) + assertEquals(expiry, db.reusableMediumQueries.selectByServerId(2L).executeAsOne().expires) + } +} diff --git a/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/desktop/migration/MigrationCoordinatorTest.kt b/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/desktop/migration/MigrationCoordinatorTest.kt new file mode 100644 index 00000000..341c4e51 --- /dev/null +++ b/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/desktop/migration/MigrationCoordinatorTest.kt @@ -0,0 +1,81 @@ +package eu.pretix.desktop.migration + +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import eu.pretix.desktop.cache.DataStoreConfig +import eu.pretix.libpretixsync.api.HttpClientFactory +import eu.pretix.pretixscan.desktop.AppConfig +import kotlinx.coroutines.test.runTest +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import okio.Path.Companion.toPath +import java.nio.file.Path +import kotlin.io.path.createTempDirectory +import kotlin.io.path.deleteRecursively +import kotlin.io.path.ExperimentalPathApi +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertIs + +class MigrationCoordinatorTest { + + private lateinit var testTempDir: Path + + @BeforeTest + fun setup() { + testTempDir = createTempDirectory("pretixscan-migration-test-") + } + + @OptIn(ExperimentalPathApi::class) + @AfterTest + fun teardown() { + runCatching { testTempDir.deleteRecursively() } + } + + private val unauthorizedHttpFactory = HttpClientFactory { + OkHttpClient.Builder() + .addInterceptor { chain -> + Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(401) + .message("Unauthorized") + .body("{\"detail\":\"Invalid token.\"}".toResponseBody("application/json".toMediaType())) + .build() + } + .build() + } + + @Test + fun test_rejected_token_discards_migrated_configuration() = runTest { + val oldConfig = AppConfig(testTempDir.resolve("prefs").toString()) + oldConfig.setDeviceConfig( + url = "https://pretix.eu", + key = "sk_invalid_key", + orga_slug = "demo-org", + device_id = 100L, + serial = "SN-12345", + sent_version = 3 + ) + val dataStoreConfig = DataStoreConfig( + PreferenceDataStoreFactory.createWithPath( + produceFile = { testTempDir.resolve("config.preferences_pb").toString().toPath() } + ) + ) + val configMigration = ConfigMigration(oldConfig, dataStoreConfig) + val coordinator = MigrationCoordinator( + configMigration, + TokenRoller(dataStoreConfig, unauthorizedHttpFactory), + cleanupV1Storage = { CleanupResult.Success } + ) + + assertIs(coordinator.executeMigration()) + assertFalse(dataStoreConfig.isConfigured()) + assertFalse(dataStoreConfig.isMigrationComplete()) + assertFalse(configMigration.canMigrate()) + } +} diff --git a/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/desktop/printing/BadgeFactoryTest.kt b/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/desktop/printing/BadgeFactoryTest.kt index e66e35dc..754dc363 100644 --- a/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/desktop/printing/BadgeFactoryTest.kt +++ b/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/desktop/printing/BadgeFactoryTest.kt @@ -1,10 +1,16 @@ package eu.pretix.desktop.printing +import eu.pretix.desktop.cache.DataStoreConfigStore import eu.pretix.desktop.cache.getUserDataFolder +import eu.pretix.libpretixsync.models.BadgeLayout +import io.mockk.every +import io.mockk.mockk import kotlinx.coroutines.test.runTest +import org.json.JSONObject import java.io.File import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertTrue class BadgeFactoryTest { @@ -27,4 +33,34 @@ class BadgeFactoryTest { sut.exportAndRegisterAllFonts(dataFolder) assertEquals(114, fontsDir.list().size) } + + @Test + fun test_printing_without_selected_printer_fails_as_not_selected() { + val sut = factoryWithBadgePrinter(null) + + assertFailsWith { + sut.printBadges(BadgeLayout.defaultWithLayout("[]"), JSONObject()) + } + } + + @Test + fun test_printing_to_missing_printer_fails_as_not_found() { + val sut = factoryWithBadgePrinter("pretixSCAN test printer that does not exist") + + assertFailsWith { + sut.printBadges(BadgeLayout.defaultWithLayout("[]"), JSONObject()) + } + } + + private fun factoryWithBadgePrinter(printerName: String?): DesktopBadgeFactory { + val appConfig = mockk(relaxed = true) + every { appConfig.badgePrinterName } returns printerName + return DesktopBadgeFactory( + appConfig = appConfig, + printerSource = PrintingSystem(), + fileStorage = mockk(relaxed = true), + renderer = mockk(), + fontRegistrar = FontRegistrar() + ) + } } diff --git a/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/desktop/printing/OrderPositionContentProviderTest.kt b/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/desktop/printing/OrderPositionContentProviderTest.kt new file mode 100644 index 00000000..918b0091 --- /dev/null +++ b/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/desktop/printing/OrderPositionContentProviderTest.kt @@ -0,0 +1,33 @@ +package eu.pretix.desktop.printing + +import eu.pretix.desktop.cache.AppCache +import eu.pretix.desktop.cache.DesktopFileStorage +import eu.pretix.desktop.cache.JvmLocalCacheFactory +import org.json.JSONObject +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals + +class OrderPositionContentProviderTest { + + private val sut = OrderPositionContentProvider( + AppCache(JvmLocalCacheFactory()), + DesktopFileStorage(File(System.getProperty("java.io.tmpdir"))), + JSONObject("""{"pdf_data":{"attendee_name":null,"seat":"A1"}}""") + ) + + @Test + fun missing_attendee_value_renders_as_empty_text() { + assertEquals("", sut.getTextContent("attendee_name", null, null)) + } + + @Test + fun present_value_renders_as_is() { + assertEquals("A1", sut.getTextContent("seat", null, null)) + } + + @Test + fun unknown_key_renders_as_placeholder() { + assertEquals("???", sut.getTextContent("unknown", null, null)) + } +} diff --git a/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/scan/main/presentation/MainViewModelTest.kt b/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/scan/main/presentation/MainViewModelTest.kt index 5bbedb2e..914faa94 100644 --- a/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/scan/main/presentation/MainViewModelTest.kt +++ b/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/scan/main/presentation/MainViewModelTest.kt @@ -251,6 +251,35 @@ class MainViewModelTest { "Secret SHOULD change - scan should interrupt WARNING dialog") } + @Test + fun `onHandleDirectScan blocks new scan while the ticket that interrupted a result is still loading`() = runTest { + viewModel = createViewModel() + testScheduler.advanceUntilIdle() + + viewModel.onHandleDirectScan("FIRST_SCAN") + testScheduler.advanceUntilIdle() + + viewModel.onTicketResultDetermined(ResultState.SUCCESS) + testScheduler.advanceUntilIdle() + + viewModel.onHandleDirectScan("SECOND_SCAN") + testScheduler.advanceUntilIdle() + + val stateAfterSecondScan = viewModel.uiState.value + assertTrue(stateAfterSecondScan is MainUiState.HandlingTicket, "Should be HandlingTicket") + assertEquals("SECOND_SCAN", (stateAfterSecondScan as MainUiState.HandlingTicket).data.secret) + assertNull(stateAfterSecondScan.data.resultState, + "resultState should be null before dialog determines the result of the second scan") + + viewModel.onHandleDirectScan("THIRD_SCAN") + testScheduler.advanceUntilIdle() + + val stateAfterThirdScan = viewModel.uiState.value + assertTrue(stateAfterThirdScan is MainUiState.HandlingTicket, "Should still be HandlingTicket") + assertEquals("SECOND_SCAN", (stateAfterThirdScan as MainUiState.HandlingTicket).data.secret, + "Secret should NOT change - scan should be blocked while the second scan is loading") + } + @Test fun `updateEventButtonDisplay shows Single when there is one event selection`() = runTest { every { appConfig.eventSelections } returns listOf(testEventSelection) diff --git a/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/scan/tickets/data/ResultStateMappingTest.kt b/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/scan/tickets/data/ResultStateMappingTest.kt index cc7ff627..837ef8fe 100644 --- a/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/scan/tickets/data/ResultStateMappingTest.kt +++ b/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/scan/tickets/data/ResultStateMappingTest.kt @@ -3,6 +3,8 @@ package eu.pretix.scan.tickets.data import eu.pretix.libpretixsync.check.TicketCheckProvider import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue class ResultStateMappingTest { @@ -118,4 +120,45 @@ class ResultStateMappingTest { val result = checkResult(TicketCheckProvider.CheckResult.Type.PRODUCT) assertEquals(ResultState.ERROR, result.resultState()) } + + @Test + fun `reusable medium results map to ERROR`() { + listOf( + TicketCheckProvider.CheckResult.Type.ALREADY_EXCHANGED, + TicketCheckProvider.CheckResult.Type.MEDIUM_INVALID, + TicketCheckProvider.CheckResult.Type.MEDIUM_EXISTS, + TicketCheckProvider.CheckResult.Type.EXCHANGE_REQUIRED, + TicketCheckProvider.CheckResult.Type.EXCHANGE_REQUIRED_OFFLINE, + ).forEach { type -> + assertEquals(ResultState.ERROR, checkResult(type).resultState(), "$type") + } + } + + @Test + fun `reusable medium failures play the error sound`() { + listOf( + TicketCheckProvider.CheckResult.Type.ALREADY_EXCHANGED, + TicketCheckProvider.CheckResult.Type.MEDIUM_INVALID, + TicketCheckProvider.CheckResult.Type.MEDIUM_EXISTS, + ).forEach { type -> + assertTrue(checkResult(type).pathForSound().endsWith("files/error.wav"), "$type") + } + } + + @Test + fun `required medium exchanges play the attention sound`() { + listOf( + TicketCheckProvider.CheckResult.Type.EXCHANGE_REQUIRED, + TicketCheckProvider.CheckResult.Type.EXCHANGE_REQUIRED_OFFLINE, + ).forEach { type -> + assertTrue(checkResult(type).pathForSound().endsWith("files/attention.wav"), "$type") + } + } + + @Test + fun `EXCHANGE_REQUIRED_OFFLINE hides the untranslated reason explanation`() { + val result = checkResult(TicketCheckProvider.CheckResult.Type.EXCHANGE_REQUIRED_OFFLINE) + result.reasonExplanation = "This ticket needs to be exchanged, but this isn't possible while offline" + assertNull(result.reasonExplanation()) + } } diff --git a/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/scan/tickets/presentation/TicketHandlingDialogViewModelTest.kt b/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/scan/tickets/presentation/TicketHandlingDialogViewModelTest.kt new file mode 100644 index 00000000..bf42a055 --- /dev/null +++ b/pretixscan/composeApp/src/desktopTest/kotlin/eu/pretix/scan/tickets/presentation/TicketHandlingDialogViewModelTest.kt @@ -0,0 +1,125 @@ +package eu.pretix.scan.tickets.presentation + +import eu.pretix.desktop.cache.DataStoreConfigStore +import eu.pretix.desktop.printing.BadgeFactory +import eu.pretix.desktop.printing.BadgePrinterUnavailableException +import eu.pretix.libpretixsync.models.BadgeLayout +import eu.pretix.scan.tickets.data.BadgePrintPolicy +import eu.pretix.scan.tickets.data.ResultState +import eu.pretix.scan.tickets.data.ResultStateData +import eu.pretix.scan.tickets.data.TicketCodeHandler +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.json.JSONObject +import pretixscan.composeapp.generated.resources.Res +import pretixscan.composeapp.generated.resources.badge_printing_selected_printer_not_available +import java.awt.print.PrinterException +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@OptIn(ExperimentalCoroutinesApi::class) +class TicketHandlingDialogViewModelTest { + + private lateinit var config: DataStoreConfigStore + private lateinit var ticketCodeHandler: TicketCodeHandler + private val testDispatcher = StandardTestDispatcher() + + private class FailingBadgeFactory(private val failure: Exception) : BadgeFactory { + override suspend fun setup() {} + + override fun printBadges(layout: BadgeLayout?, position: JSONObject) { + throw failure + } + } + + @BeforeTest + fun setup() { + Dispatchers.setMain(testDispatcher) + config = mockk(relaxed = true) + every { config.uiReduceMotion } returns false + every { config.autoPrintBadges } returns BadgePrintPolicy.WHEN_BUTTON_PRESSED + ticketCodeHandler = mockk() + coEvery { ticketCodeHandler.handleScanResult(any(), any(), any()) } answers { + ResultStateData( + resultState = ResultState.SUCCESS, + badgeLayout = BadgeLayout.defaultWithLayout("[]"), + position = JSONObject().put("id", 1L), + eventSlug = "demo" + ) + } + } + + @AfterTest + fun teardown() { + Dispatchers.resetMain() + } + + private fun createViewModel(failure: Exception) = TicketHandlingDialogViewModel( + tickerCodeHandler = ticketCodeHandler, + badgeFactory = FailingBadgeFactory(failure), + appConfig = config, + appCache = mockk(relaxed = true), + api = mockk(relaxed = true), + ioDispatcher = testDispatcher, + ) + + @Test + fun `unavailable badge printer is shown with a localized message`() = runTest { + val viewModel = createViewModel(BadgePrinterUnavailableException.NotFound("Printer")) + + viewModel.handleTicket("A") + viewModel.printBadges() + testScheduler.advanceUntilIdle() + + assertEquals( + TicketHandlingErrors.Error(Res.string.badge_printing_selected_printer_not_available), + viewModel.localTicketHandlingErrors.value + ) + } + + @Test + fun `printer job failure is not shown`() = runTest { + val viewModel = createViewModel(PrinterException("jam")) + + viewModel.handleTicket("A") + viewModel.printBadges() + testScheduler.advanceUntilIdle() + + assertEquals(TicketHandlingErrors.None, viewModel.localTicketHandlingErrors.value) + } + + @Test + fun `print error is discarded when the ticket is scanned again before the print fails`() = runTest { + val viewModel = createViewModel(BadgePrinterUnavailableException.NotSelected()) + + viewModel.handleTicket("A") + viewModel.printBadges() + viewModel.handleTicket("A") + testScheduler.advanceUntilIdle() + + assertEquals(TicketHandlingErrors.None, viewModel.localTicketHandlingErrors.value) + } + + @Test + fun `resetting the ticket state clears a shown print error`() = runTest { + val viewModel = createViewModel(BadgePrinterUnavailableException.NotFound("Printer")) + viewModel.handleTicket("A") + viewModel.printBadges() + testScheduler.advanceUntilIdle() + assertIs(viewModel.localTicketHandlingErrors.value) + + viewModel.resetTicketHandlingState() + + assertEquals(TicketHandlingErrors.None, viewModel.localTicketHandlingErrors.value) + } +} diff --git a/pretixscan/gradle/libs.versions.toml b/pretixscan/gradle/libs.versions.toml index 0c723e7b..6844e665 100644 --- a/pretixscan/gradle/libs.versions.toml +++ b/pretixscan/gradle/libs.versions.toml @@ -72,6 +72,8 @@ gmazzo = { id = "com.github.gmazzo.buildconfig", version.ref = "gmazzo" } jetbrainsCompose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" } compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } +kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlinKapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } google-protobuf = { id = "com.google.protobuf", version.ref = "protobuf" } app-cash-sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } osdetector = { id = "com.google.osdetector", version = "1.7.3" } diff --git a/pretixscan/libpretixsync-repo b/pretixscan/libpretixsync-repo index a706913e..baaaec3f 160000 --- a/pretixscan/libpretixsync-repo +++ b/pretixscan/libpretixsync-repo @@ -1 +1 @@ -Subproject commit a706913e36ca356b1a51f7ac47d55ab48787f3e9 +Subproject commit baaaec3f29e5178a2c66c23dd8a8318bc30073b8 diff --git a/pretixscan/settings.gradle.kts b/pretixscan/settings.gradle.kts index c7e83f65..9944e388 100644 --- a/pretixscan/settings.gradle.kts +++ b/pretixscan/settings.gradle.kts @@ -26,6 +26,13 @@ dependencyResolutionManagement { } mavenCentral() } + versionCatalogs { + create("syncLibs") { + from(files("libpretixsync-repo/libpretixsync/gradle/libs.versions.toml")) + version("kotlin", "2.3.20") + version("protobufPlugin", "0.9.4") + } + } } plugins {