diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/checkins/qrcode/DefaultAutoCheckoutLength.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/checkins/qrcode/DefaultAutoCheckoutLength.kt new file mode 100644 index 0000000000000000000000000000000000000000..0f5c0d6dfa510bfbc46a452678702c0947a39304 --- /dev/null +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/eventregistration/checkins/qrcode/DefaultAutoCheckoutLength.kt @@ -0,0 +1,74 @@ +package de.rki.coronawarnapp.eventregistration.checkins.qrcode + +import androidx.annotation.VisibleForTesting +import org.joda.time.Duration +import org.joda.time.Instant +import java.util.concurrent.TimeUnit +import kotlin.math.roundToLong + +/** + * Evaluates the default auto-checkout length depending on the current time + */ +@Suppress("ReturnCount") +fun TraceLocation.getDefaultAutoCheckoutLengthInMinutes(now: Instant): Int { + + // min valid value is 00:15h + val minDefaultAutoCheckOutLengthInMinutes = 15 + + // max valid value is 23:45h + val maxDefaultAutoCheckOutLengthInMinutes = (TimeUnit.HOURS.toMinutes(23) + 45).toInt() + + // for permanent traceLocations, a defaultCheckInLength is always available + if (defaultCheckInLengthInMinutes != null) { + + if (defaultCheckInLengthInMinutes < 15) { + return minDefaultAutoCheckOutLengthInMinutes + } + + if (defaultCheckInLengthInMinutes > maxDefaultAutoCheckOutLengthInMinutes) { + return maxDefaultAutoCheckOutLengthInMinutes + } + + return roundToNearest15Minutes(defaultCheckInLengthInMinutes) + } + // for temporary traceLocations, the defaultCheckInLength could be empty + else { + + // For QR-codes generated by CWA, endDate can never be null here. However, a QR code that was created or + // modified by a third party could have an endDate that is null. + if (endDate == null) { + return minDefaultAutoCheckOutLengthInMinutes + } + + if (now.isAfter(endDate)) { + return minDefaultAutoCheckOutLengthInMinutes + } + + val minutesUntilEndDate = Duration(now, endDate).standardMinutes.toInt() + + if (minutesUntilEndDate < minDefaultAutoCheckOutLengthInMinutes) { + return minDefaultAutoCheckOutLengthInMinutes + } + + if (minutesUntilEndDate > maxDefaultAutoCheckOutLengthInMinutes) { + return maxDefaultAutoCheckOutLengthInMinutes + } + + return roundToNearest15Minutes(minutesUntilEndDate) + } +} + +/** + * Rounds to the nearest 15 minute interval. + * for more details see AutoCheckoutHelperTest + */ +@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) +fun roundToNearest15Minutes(minutes: Int): Int { + val roundingStepInMinutes = 15 + return Duration + .standardMinutes( + (minutes.toFloat() / roundingStepInMinutes) + .roundToLong() * roundingStepInMinutes + ) + .standardMinutes.toInt() +} diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/eventregistration/attendee/confirm/ConfirmCheckInFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/eventregistration/attendee/confirm/ConfirmCheckInFragment.kt index c2c41c171a2460b04492eac95fdf2c102bceed3d..5b9767a72dcf21e160b669bcbaea5cad6c0ceab3 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/eventregistration/attendee/confirm/ConfirmCheckInFragment.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/eventregistration/attendee/confirm/ConfirmCheckInFragment.kt @@ -91,6 +91,7 @@ class ConfirmCheckInFragment : Fragment(R.layout.fragment_confirm_check_in), Aut uiState.eventInFutureDateText, uiState.eventInFutureTimeText ) + confirmCheckinConfirmButton.isEnabled = uiState.confirmButtonEnabled } } diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/eventregistration/attendee/confirm/ConfirmCheckInViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/eventregistration/attendee/confirm/ConfirmCheckInViewModel.kt index 4fa1bc62e1d070432e2b243a727fe5facf39dc36..a8654db5b89c460c92f3ac006d3f383fa238bce6 100644 --- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/eventregistration/attendee/confirm/ConfirmCheckInViewModel.kt +++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/eventregistration/attendee/confirm/ConfirmCheckInViewModel.kt @@ -8,6 +8,7 @@ import de.rki.coronawarnapp.eventregistration.checkins.CheckIn import de.rki.coronawarnapp.eventregistration.checkins.CheckInRepository import de.rki.coronawarnapp.eventregistration.checkins.qrcode.TraceLocation import de.rki.coronawarnapp.eventregistration.checkins.qrcode.VerifiedTraceLocation +import de.rki.coronawarnapp.eventregistration.checkins.qrcode.getDefaultAutoCheckoutLengthInMinutes import de.rki.coronawarnapp.ui.durationpicker.toContactDiaryFormat import de.rki.coronawarnapp.ui.durationpicker.toReadableDuration import de.rki.coronawarnapp.ui.eventregistration.organizer.category.adapter.category.mapTraceLocationToTitleRes @@ -20,7 +21,6 @@ import kotlinx.coroutines.flow.combine import org.joda.time.Duration import org.joda.time.Instant import org.joda.time.format.DateTimeFormat -import kotlin.math.roundToLong class ConfirmCheckInViewModel @AssistedInject constructor( @Assisted private val verifiedTraceLocation: VerifiedTraceLocation, @@ -30,13 +30,11 @@ class ConfirmCheckInViewModel @AssistedInject constructor( private val traceLocation = MutableStateFlow(verifiedTraceLocation.traceLocation) private val createJournalEntry = MutableStateFlow(true) - private val truncatedDefaultCheckInLength = roundToNearestValidDuration( - verifiedTraceLocation.traceLocation.defaultCheckInLengthInMinutes ?: 0 + private val autoCheckOutLength = MutableStateFlow( + Duration.standardMinutes( + verifiedTraceLocation.traceLocation.getDefaultAutoCheckoutLengthInMinutes(timeStamper.nowUTC).toLong() + ) ) - private val checkInLength = MutableStateFlow(truncatedDefaultCheckInLength) - - private fun roundToNearestValidDuration(minutes: Int): Duration = - Duration.standardMinutes((minutes.toFloat() / 15).roundToLong() * 15) val openDatePickerEvent = SingleLiveEvent<String>() val events = SingleLiveEvent<ConfirmCheckInNavigation>() @@ -44,14 +42,15 @@ class ConfirmCheckInViewModel @AssistedInject constructor( val uiState = combine( traceLocation, createJournalEntry, - checkInLength + autoCheckOutLength ) { traceLocation, createEntry, checkInLength -> UiState( traceLocation = traceLocation, createJournalEntry = createEntry, checkInEndOffset = checkInLength, eventInPastVisible = traceLocation.isAfterEndTime(timeStamper.nowUTC), - eventInFutureVisible = traceLocation.isBeforeStartTime(timeStamper.nowUTC) + eventInFutureVisible = traceLocation.isBeforeStartTime(timeStamper.nowUTC), + confirmButtonEnabled = checkInLength.standardMinutes > 0 ) }.asLiveData() @@ -66,7 +65,7 @@ class ConfirmCheckInViewModel @AssistedInject constructor( verifiedTraceLocation.toCheckIn( checkInStart = now, createJournalEntry = createJournalEntry.value, - checkInEnd = now + checkInLength.value + checkInEnd = now + autoCheckOutLength.value ) ) events.postValue(ConfirmCheckInNavigation.ConfirmNavigation) @@ -78,18 +77,16 @@ class ConfirmCheckInViewModel @AssistedInject constructor( } fun dateSelectorClicked() { - openDatePickerEvent.value = checkInLength.value.toContactDiaryFormat() + openDatePickerEvent.value = autoCheckOutLength.value.toContactDiaryFormat() } fun durationUpdated(duration: Duration) { - checkInLength.value = duration + autoCheckOutLength.value = duration } private fun VerifiedTraceLocation.toCheckIn( checkInStart: Instant, - checkInEnd: Instant = checkInStart.plus( - Duration.standardMinutes(traceLocation.defaultCheckInLengthInMinutes?.toLong() ?: 3L) - ), + checkInEnd: Instant, completed: Boolean = false, createJournalEntry: Boolean = true ): CheckIn { @@ -102,7 +99,7 @@ class ConfirmCheckInViewModel @AssistedInject constructor( address = traceLocation.address, traceLocationStart = traceLocation.startDate, traceLocationEnd = traceLocation.endDate, - defaultCheckInLengthInMinutes = truncatedDefaultCheckInLength.standardMinutes.toInt(), + defaultCheckInLengthInMinutes = traceLocation.defaultCheckInLengthInMinutes, cryptographicSeed = traceLocation.cryptographicSeed, cnPublicKey = traceLocation.cnPublicKey, checkInStart = checkInStart, @@ -124,7 +121,8 @@ class ConfirmCheckInViewModel @AssistedInject constructor( private val checkInEndOffset: Duration, val createJournalEntry: Boolean, val eventInPastVisible: Boolean, - val eventInFutureVisible: Boolean + val eventInFutureVisible: Boolean, + val confirmButtonEnabled: Boolean ) { val description get() = traceLocation.description val typeRes get() = mapTraceLocationToTitleRes(traceLocation.type) diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/attendee/confirm/ConfirmCheckInViewModelTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/attendee/confirm/ConfirmCheckInViewModelTest.kt index aee24dd7643b2551114c25038649cc427269aad3..f5b58f654c33d15067a76e6d8740474edce34cd7 100644 --- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/attendee/confirm/ConfirmCheckInViewModelTest.kt +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/attendee/confirm/ConfirmCheckInViewModelTest.kt @@ -14,6 +14,7 @@ import io.mockk.coEvery import io.mockk.every import io.mockk.impl.annotations.MockK import okio.ByteString.Companion.decodeBase64 +import org.joda.time.Duration import org.joda.time.Instant import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test @@ -29,8 +30,6 @@ class ConfirmCheckInViewModelTest : BaseTest() { @MockK lateinit var checkInRepository: CheckInRepository @MockK lateinit var timeStamper: TimeStamper - private lateinit var viewModel: ConfirmCheckInViewModel - private val traceLocation = TraceLocation( id = 1, type = TraceLocationOuterClass.TraceLocationType.LOCATION_TYPE_TEMPORARY_OTHER, @@ -51,23 +50,35 @@ class ConfirmCheckInViewModelTest : BaseTest() { coEvery { checkInRepository.addCheckIn(any()) } returns 1L every { verifiedTraceLocation.traceLocation } returns traceLocation every { timeStamper.nowUTC } returns Instant.parse("2021-03-04T10:30:00Z") + } + + private fun createInstance() = ConfirmCheckInViewModel( + verifiedTraceLocation = verifiedTraceLocation, + checkInRepository = checkInRepository, + timeStamper = timeStamper + ) - viewModel = ConfirmCheckInViewModel( - verifiedTraceLocation = verifiedTraceLocation, - checkInRepository = checkInRepository, - timeStamper = timeStamper - ) + @Test + fun onClose() = with(createInstance()) { + onClose() + events.getOrAwaitValue() shouldBe ConfirmCheckInNavigation.BackNavigation + } + + @Test + fun onConfirmEvent() = with(createInstance()) { + onConfirmTraceLocation() + events.getOrAwaitValue() shouldBe ConfirmCheckInNavigation.ConfirmNavigation } @Test - fun onClose() { - viewModel.onClose() - viewModel.events.getOrAwaitValue() shouldBe ConfirmCheckInNavigation.BackNavigation + fun `confirm button should be disabled when autoCheckOutLength is 0`() = with(createInstance()) { + durationUpdated(Duration.standardMinutes(0)) + uiState.getOrAwaitValue().confirmButtonEnabled shouldBe false } @Test - fun onConfirmEvent() { - viewModel.onConfirmTraceLocation() - viewModel.events.getOrAwaitValue() shouldBe ConfirmCheckInNavigation.ConfirmNavigation + fun `confirm button should be enabled when autoCheckOutLength is greater than 0`() = with(createInstance()) { + durationUpdated(Duration.standardMinutes(15)) + uiState.getOrAwaitValue().confirmButtonEnabled shouldBe true } } diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/checkins/qrcode/AutoCheckoutHelperTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/checkins/qrcode/AutoCheckoutHelperTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..c0690025cf2d7d8acc53403175cfef1b61f830c3 --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/checkins/qrcode/AutoCheckoutHelperTest.kt @@ -0,0 +1,46 @@ +package de.rki.coronawarnapp.eventregistration.checkins.qrcode + +import io.kotest.matchers.shouldBe +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource + +internal class AutoCheckoutHelperTest { + + @ParameterizedTest + @MethodSource("provideArguments") + fun `roundToNearest15Minutes() should round correctly`(testCase: TestCase) = with(testCase) { + roundToNearest15Minutes(minutesToRound) shouldBe expectedRoundingResult + } + + companion object { + @Suppress("unused") + @JvmStatic + fun provideArguments() = listOf( + TestCase( + minutesToRound = 0, + expectedRoundingResult = 0 + ), + TestCase( + minutesToRound = 7, + expectedRoundingResult = 0 + ), + TestCase( + minutesToRound = 8, + expectedRoundingResult = 15 + ), + TestCase( + minutesToRound = 22, + expectedRoundingResult = 15 + ), + TestCase( + minutesToRound = 23, + expectedRoundingResult = 30 + ) + ) + } + + data class TestCase( + val minutesToRound: Int, + val expectedRoundingResult: Int + ) +} diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/events/DefaultAutoCheckoutLengthTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/events/DefaultAutoCheckoutLengthTest.kt new file mode 100644 index 0000000000000000000000000000000000000000..f5d72d1ae03d53902b8a71df0964a07857b97aae --- /dev/null +++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/eventregistration/events/DefaultAutoCheckoutLengthTest.kt @@ -0,0 +1,188 @@ +package de.rki.coronawarnapp.eventregistration.events + +import de.rki.coronawarnapp.eventregistration.checkins.qrcode.TraceLocation +import de.rki.coronawarnapp.eventregistration.checkins.qrcode.getDefaultAutoCheckoutLengthInMinutes +import de.rki.coronawarnapp.server.protocols.internal.pt.TraceLocationOuterClass +import io.kotest.matchers.shouldBe +import okio.ByteString.Companion.encode +import org.joda.time.Instant +import org.junit.jupiter.params.ParameterizedTest +import org.junit.jupiter.params.provider.MethodSource +import testhelpers.BaseTest +import java.util.concurrent.TimeUnit + +class DefaultAutoCheckoutLengthTest : BaseTest() { + + @ParameterizedTest + @MethodSource("provideArguments") + fun `getDefaultAuthCheckoutLengthInMinutes(now) should return correct value`( + testCase: DefaultAutoCheckoutLengthTestCase + ) = with(testCase) { + + createTraceLocation(this) + .getDefaultAutoCheckoutLengthInMinutes(now) shouldBe expectedDefaultAutoCheckoutLength + } + + private fun createTraceLocation(testCase: DefaultAutoCheckoutLengthTestCase) = TraceLocation( + id = 1, + type = TraceLocationOuterClass.TraceLocationType.UNRECOGNIZED, + description = "", + address = "", + startDate = testCase.startDate, + endDate = testCase.endDate, + defaultCheckInLengthInMinutes = testCase.defaultCheckInLengthInMinutes, + cryptographicSeed = "seed byte array".encode(), + cnPublicKey = "cnPublicKey" + ) + + companion object { + + // min valid length = 00:15h + private const val MIN_VALID_LENGTH = 15 + + // max valid length = 23:45h + private val MAX_VALID_LENGTH = (TimeUnit.HOURS.toMinutes(23) + 45).toInt() + + @Suppress("unused") + @JvmStatic + fun provideArguments() = listOf( + DefaultAutoCheckoutLengthTestCase( + // now doesn't matter, as defaultCheckInLengthInMinutes is not null + now = Instant.parse("1970-01-01T00:00:00.000Z"), + defaultCheckInLengthInMinutes = 30, + startDate = null, + endDate = null, + expectedDefaultAutoCheckoutLength = 30, + ), + DefaultAutoCheckoutLengthTestCase( + // now doesn't matter here, as defaultCheckInLengthInMinutes is not null + now = Instant.parse("1970-01-01T00:00:00.000Z"), + // min valid length = 00:15h + defaultCheckInLengthInMinutes = 0, + startDate = null, + endDate = null, + expectedDefaultAutoCheckoutLength = MIN_VALID_LENGTH + ), + DefaultAutoCheckoutLengthTestCase( + // now doesn't matter here, as defaultCheckInLengthInMinutes is not null + now = Instant.parse("1970-01-01T00:00:00.000Z"), + // TraceLocations with CWA can actually only have 15 minute interval lengths. However, a trace location + // created by a third party could create arbitrary lengths. + // 22 min should be rounded to 15 + defaultCheckInLengthInMinutes = 22, + startDate = null, + endDate = null, + expectedDefaultAutoCheckoutLength = 15 + ), + DefaultAutoCheckoutLengthTestCase( + // now doesn't matter here, as defaultCheckInLengthInMinutes is not null + now = Instant.parse("1970-01-01T00:00:00.000Z"), + // TraceLocations with CWA can actually only have 15 minute interval lengths. However, a trace location + // created by a third party could create arbitrary lengths. + // 23 min should be rounded to 30 + defaultCheckInLengthInMinutes = 23, + startDate = null, + endDate = null, + expectedDefaultAutoCheckoutLength = 30 + ), + DefaultAutoCheckoutLengthTestCase( + // now doesn't matter here, as defaultCheckInLengthInMinutes is not null + now = Instant.parse("1970-01-01T00:00:00.000Z"), + // max valid length = 23:45h + defaultCheckInLengthInMinutes = MAX_VALID_LENGTH, + startDate = null, + endDate = null, + expectedDefaultAutoCheckoutLength = MAX_VALID_LENGTH + ), + DefaultAutoCheckoutLengthTestCase( + // now doesn't matter here, as defaultCheckInLengthInMinutes is not null + now = Instant.parse("1970-01-01T00:00:00.000Z"), + // max valid length = 23:45h + // TraceLocations with CWA can actually only have a max length of 23:45h. However, a trace location + // created by a third party could have a bigger length. + defaultCheckInLengthInMinutes = MAX_VALID_LENGTH + 1, + startDate = null, + endDate = null, + expectedDefaultAutoCheckoutLength = MAX_VALID_LENGTH + ), + DefaultAutoCheckoutLengthTestCase( + now = Instant.parse("2021-12-23T17:00:00.000Z"), + defaultCheckInLengthInMinutes = null, + startDate = Instant.parse("2021-12-24T15:00:00.000Z"), + endDate = Instant.parse("2021-12-24T17:00:00.000Z"), + // use 23:45h if user checks in earlier than 23:45 before the event ends + expectedDefaultAutoCheckoutLength = MAX_VALID_LENGTH + ), + DefaultAutoCheckoutLengthTestCase( + now = Instant.parse("2021-12-23T17:30:00.000Z"), + defaultCheckInLengthInMinutes = null, + startDate = Instant.parse("2021-12-24T15:00:00.000Z"), + endDate = Instant.parse("2021-12-24T17:00:00.000Z"), + // 23:30h in minutes + expectedDefaultAutoCheckoutLength = (TimeUnit.HOURS.toMinutes(23) + 30).toInt() + ), + DefaultAutoCheckoutLengthTestCase( + now = Instant.parse("2021-12-24T16:00:00.000Z"), + defaultCheckInLengthInMinutes = null, + startDate = Instant.parse("2021-12-24T15:00:00.000Z"), + endDate = Instant.parse("2021-12-24T17:00:00.000Z"), + expectedDefaultAutoCheckoutLength = 60 + ), + DefaultAutoCheckoutLengthTestCase( + now = Instant.parse("2021-12-24T17:01:00.000Z"), + defaultCheckInLengthInMinutes = null, + startDate = Instant.parse("2021-12-24T15:00:00.000Z"), + endDate = Instant.parse("2021-12-24T17:00:00.000Z"), + expectedDefaultAutoCheckoutLength = 15 + ), + DefaultAutoCheckoutLengthTestCase( + now = Instant.parse("2021-12-24T17:30:00.000Z"), + defaultCheckInLengthInMinutes = null, + startDate = Instant.parse("2021-12-24T15:00:00.000Z"), + endDate = Instant.parse("2021-12-24T17:00:00.000Z"), + // We take the min value when the user checks in after the event has already ended + expectedDefaultAutoCheckoutLength = 15 + ), + DefaultAutoCheckoutLengthTestCase( + now = Instant.parse("2021-12-24T16:31:00.000Z"), + defaultCheckInLengthInMinutes = null, + startDate = Instant.parse("2021-12-24T15:00:00.000Z"), + endDate = Instant.parse("2021-12-24T17:00:00.000Z"), + // Event ends in 29min -> we round to the nearest 15 minutes + expectedDefaultAutoCheckoutLength = 30 + ), + DefaultAutoCheckoutLengthTestCase( + now = Instant.parse("2021-12-24T16:38:00.000Z"), + defaultCheckInLengthInMinutes = null, + startDate = Instant.parse("2021-12-24T15:00:00.000Z"), + endDate = Instant.parse("2021-12-24T17:00:00.000Z"), + // Event ends in 22min -> we round to the nearest 15 minutes + expectedDefaultAutoCheckoutLength = 15 + ), + DefaultAutoCheckoutLengthTestCase( + now = Instant.parse("2021-12-24T16:59:00.000Z"), + defaultCheckInLengthInMinutes = null, + startDate = Instant.parse("2021-12-24T15:00:00.000Z"), + endDate = Instant.parse("2021-12-24T17:00:00.000Z"), + // Event ends in 1min -> we are not rounding to 0 but to the min value (15min) + expectedDefaultAutoCheckoutLength = 15 + ), + DefaultAutoCheckoutLengthTestCase( + now = Instant.parse("2021-12-24T17:00:00.000Z"), + defaultCheckInLengthInMinutes = null, + startDate = Instant.parse("2021-12-24T15:00:00.000Z"), + endDate = Instant.parse("2021-12-24T17:00:00.000Z"), + // We check in at event end, return min value (15min) + expectedDefaultAutoCheckoutLength = 15 + ) + ) + } +} + +data class DefaultAutoCheckoutLengthTestCase( + val now: Instant, + val defaultCheckInLengthInMinutes: Int?, + val startDate: Instant?, + val endDate: Instant?, + val expectedDefaultAutoCheckoutLength: Int, +)