diff --git a/Corona-Warn-App/src/device/java/de/rki/coronawarnapp/risk/storage/DefaultRiskLevelStorage.kt b/Corona-Warn-App/src/device/java/de/rki/coronawarnapp/risk/storage/DefaultRiskLevelStorage.kt
index 92111156ce7451c297cd779ec6333f70f9d2ee0d..0ab2b436cb66ae70971a996d43e7695c6eb9704a 100644
--- a/Corona-Warn-App/src/device/java/de/rki/coronawarnapp/risk/storage/DefaultRiskLevelStorage.kt
+++ b/Corona-Warn-App/src/device/java/de/rki/coronawarnapp/risk/storage/DefaultRiskLevelStorage.kt
@@ -27,7 +27,7 @@ class DefaultRiskLevelStorage @Inject constructor(
     // Taken from TimeVariables.MAX_STALE_EXPOSURE_RISK_RANGE
     override val storedResultLimit: Int = 2 * 6
 
-    override suspend fun storeExposureWindows(storedResultId: String, result: EwRiskLevelResult) {
+    override suspend fun storeExposureWindows(storedResultId: String, resultEw: EwRiskLevelResult) {
         Timber.d("storeExposureWindows(): NOOP")
         // NOOP
     }
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQRCode.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQRCode.kt
index 5f7a2fb6ee07cbcdf23ce93d1270af5f0bdd8534..0db2d0ed6f9343596216423d14c64f380f662583 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQRCode.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQRCode.kt
@@ -3,6 +3,7 @@ package de.rki.coronawarnapp.coronatest.qrcode
 import android.os.Parcelable
 import de.rki.coronawarnapp.coronatest.TestRegistrationRequest
 import de.rki.coronawarnapp.coronatest.type.CoronaTest
+import de.rki.coronawarnapp.util.HashExtensions.toSHA256
 import kotlinx.parcelize.IgnoredOnParcel
 import kotlinx.parcelize.Parcelize
 import org.joda.time.Instant
@@ -11,31 +12,46 @@ import org.joda.time.LocalDate
 sealed class CoronaTestQRCode : Parcelable, TestRegistrationRequest {
 
     abstract override val type: CoronaTest.Type
-    abstract val qrCodeGUID: CoronaTestGUID
-
-    @IgnoredOnParcel
-    override val identifier: String
-        get() = "qrcode-${type.raw}-$qrCodeGUID"
+    abstract val registrationIdentifier: String
 
     @Parcelize
     data class PCR(
-        override val qrCodeGUID: CoronaTestGUID,
+        val qrCodeGUID: CoronaTestGUID,
     ) : CoronaTestQRCode() {
 
-        @IgnoredOnParcel override val type: CoronaTest.Type = CoronaTest.Type.PCR
+        @IgnoredOnParcel
+        override val type: CoronaTest.Type = CoronaTest.Type.PCR
+
+        @IgnoredOnParcel
+        override val identifier: String
+            get() = "qrcode-${type.raw}-$qrCodeGUID"
+
+        @IgnoredOnParcel
+        override val registrationIdentifier: String
+            get() = qrCodeGUID
     }
 
     @Parcelize
     data class RapidAntigen(
-        override val qrCodeGUID: CoronaTestGUID,
+        val hash: RapidAntigenHash,
         val createdAt: Instant,
         val firstName: String?,
         val lastName: String?,
         val dateOfBirth: LocalDate?,
     ) : CoronaTestQRCode() {
 
-        @IgnoredOnParcel override val type: CoronaTest.Type = CoronaTest.Type.RAPID_ANTIGEN
+        @IgnoredOnParcel
+        override val type: CoronaTest.Type = CoronaTest.Type.RAPID_ANTIGEN
+
+        @IgnoredOnParcel
+        override val identifier: String
+            get() = "qrcode-${type.raw}-$hash"
+
+        @IgnoredOnParcel
+        override val registrationIdentifier: String
+            get() = hash.toSHA256()
     }
 }
 
 typealias CoronaTestGUID = String
+typealias RapidAntigenHash = String
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQRCodeValidation.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQRCodeValidation.kt
deleted file mode 100644
index 717aaf78317c7822d4fc7225da3d76ea5a746839..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQRCodeValidation.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-package de.rki.coronawarnapp.coronatest.qrcode
-
-import dagger.Reusable
-import javax.inject.Inject
-
-@Reusable
-class CoronaTestQRCodeValidation @Inject constructor() {
-
-    suspend fun validate(qrCode: String): CoronaTestQRCode {
-        throw NotImplementedError()
-    }
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQRCodeValidator.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQRCodeValidator.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a3a54f940680e7457a3b57ade7ce668adc51f251
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQRCodeValidator.kt
@@ -0,0 +1,27 @@
+package de.rki.coronawarnapp.coronatest.qrcode
+
+import dagger.Reusable
+import timber.log.Timber
+import javax.inject.Inject
+
+@Reusable
+class CoronaTestQrCodeValidator @Inject constructor(
+    ratExtractor: RapidAntigenQrCodeExtractor,
+    pcrExtractor: PcrQrCodeExtractor
+) {
+
+    private val extractors = setOf(ratExtractor, pcrExtractor)
+
+    fun validate(rawString: String): CoronaTestQRCode {
+        return extractors
+            .find { it.canHandle(rawString) }
+            ?.extract(rawString)
+            ?.also { Timber.i("Extracted data from QR code is $it") }
+            ?: throw InvalidQRCodeException()
+    }
+}
+
+interface QrCodeExtractor {
+    fun canHandle(rawString: String): Boolean
+    fun extract(rawString: String): CoronaTestQRCode
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/InvalidQRCodeException.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/InvalidQRCodeException.kt
new file mode 100644
index 0000000000000000000000000000000000000000..24dd420a35009d94e55b06ece7c80802cd6db8eb
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/InvalidQRCodeException.kt
@@ -0,0 +1,5 @@
+package de.rki.coronawarnapp.coronatest.qrcode
+
+open class InvalidQRCodeException(
+    message: String = "An error occurred while parsing the qr code"
+) : Exception(message)
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/PcrQrCodeExtractor.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/PcrQrCodeExtractor.kt
new file mode 100644
index 0000000000000000000000000000000000000000..924bd4e89f6b0bef3ca0d7f09cd15539c300797c
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/PcrQrCodeExtractor.kt
@@ -0,0 +1,34 @@
+package de.rki.coronawarnapp.coronatest.qrcode
+
+import java.util.regex.Pattern
+import javax.inject.Inject
+
+class PcrQrCodeExtractor @Inject constructor() : QrCodeExtractor {
+
+    override fun canHandle(rawString: String): Boolean = rawString.startsWith(prefix, ignoreCase = true)
+
+    override fun extract(rawString: String): CoronaTestQRCode.PCR {
+        return CoronaTestQRCode.PCR(
+            extractGUID(rawString)
+        )
+    }
+
+    private fun extractGUID(rawString: String): CoronaTestGUID {
+        if (!pattern.toRegex().matches(rawString)) throw InvalidQRCodeException()
+
+        val matcher = pattern.matcher(rawString)
+        return if (matcher.matches()) {
+            matcher.group(1) as CoronaTestGUID
+        } else throw InvalidQRCodeException()
+    }
+
+    private val prefix: String = "https://localhost"
+
+    private val pattern: Pattern = (
+        "^" + // Match start of string
+            "(?:https:\\/{2}localhost)" + // Match `https://localhost`
+            "(?:\\/{1}\\?)" + // Match the query param `/?`
+            "([a-f\\d]{6}[-][a-f\\d]{8}[-](?:[a-f\\d]{4}[-]){3}[a-f\\d]{12})" + // Match the UUID
+            "\$"
+        ).toPattern(Pattern.CASE_INSENSITIVE)
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/RapidAntigenQrCodeExtractor.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/RapidAntigenQrCodeExtractor.kt
new file mode 100644
index 0000000000000000000000000000000000000000..deffb83ef479b45aa257c30868fd1c0e468c6395
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/qrcode/RapidAntigenQrCodeExtractor.kt
@@ -0,0 +1,92 @@
+package de.rki.coronawarnapp.coronatest.qrcode
+
+import com.google.common.io.BaseEncoding
+import com.google.gson.Gson
+import com.google.gson.annotations.SerializedName
+import de.rki.coronawarnapp.util.serialization.fromJson
+import okio.internal.commonToUtf8String
+import org.joda.time.Instant
+import org.joda.time.LocalDate
+import timber.log.Timber
+import java.util.regex.Matcher
+import java.util.regex.Pattern
+import javax.inject.Inject
+
+class RapidAntigenQrCodeExtractor @Inject constructor() : QrCodeExtractor {
+
+    private val prefix: String = "https://s.coronawarn.app?v=1#"
+    private val prefix2: String = "https://s.coronawarn.app/?v=1#"
+    private val hexPattern: Pattern = Pattern.compile("\\p{XDigit}+")
+
+    override fun canHandle(rawString: String): Boolean {
+        return rawString.startsWith(prefix, ignoreCase = true) || rawString.startsWith(prefix2, ignoreCase = true)
+    }
+
+    override fun extract(rawString: String): CoronaTestQRCode.RapidAntigen {
+        val data = extractData(rawString).validate()
+        return CoronaTestQRCode.RapidAntigen(
+            hash = data.hash!!,
+            createdAt = data.createdAt!!,
+            firstName = data.firstName,
+            lastName = data.lastName,
+            dateOfBirth = data.dateOfBirth
+        )
+    }
+
+    private fun Payload.validate(): Payload {
+        if (hash == null || !hash.isSha256Hash()) throw InvalidQRCodeException("Hash is invalid")
+        if (timestamp == null || timestamp <= 0) throw InvalidQRCodeException("Timestamp is invalid")
+        createdAt = Instant.ofEpochSecond(timestamp)
+        dateOfBirth = dob?.let {
+            try {
+                LocalDate.parse(it)
+            } catch (e: Exception) {
+                Timber.e("Invalid date format")
+                throw InvalidQRCodeException("Date of birth has wrong format: $it. It should be YYYY-MM-DD")
+            }
+        }
+        return this
+    }
+
+    private fun String.isSha256Hash(): Boolean {
+        return length == 64 && isHexadecimal()
+    }
+
+    private fun String.isHexadecimal(): Boolean {
+        val matcher: Matcher = hexPattern.matcher(this)
+        return matcher.matches()
+    }
+
+    private fun extractData(rawString: String): Payload {
+        return rawString
+            .removePrefix(prefix)
+            .removePrefix(prefix2)
+            .decode()
+    }
+
+    private fun String.decode(): Payload {
+        val decoded = if (
+            this.contains("+") ||
+            this.contains("/") ||
+            this.contains("=")
+        ) {
+            BaseEncoding.base64().decode(this)
+        } else {
+            BaseEncoding.base64Url().decode(this)
+        }
+        return Gson().fromJson(decoded.commonToUtf8String())
+    }
+
+    private data class Payload(
+        val hash: String?,
+        val timestamp: Long?,
+        @SerializedName("fn")
+        val firstName: String?,
+        @SerializedName("ln")
+        val lastName: String?,
+        val dob: String?
+    ) {
+        var dateOfBirth: LocalDate? = null
+        var createdAt: Instant? = null
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/type/rapidantigen/RapidAntigenProcessor.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/type/rapidantigen/RapidAntigenProcessor.kt
index 424b7eda6b54089c2ea40b179b80c588360d3f51..6052fa0a06cc18df4e07161d807c579f3247792e 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/type/rapidantigen/RapidAntigenProcessor.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/coronatest/type/rapidantigen/RapidAntigenProcessor.kt
@@ -26,7 +26,7 @@ class RapidAntigenProcessor @Inject constructor(
         Timber.tag(TAG).d("create(data=%s)", request)
         request as CoronaTestQRCode.RapidAntigen
 
-        val registrationData = submissionService.asyncRegisterDeviceViaGUID(request.qrCodeGUID)
+        val registrationData = submissionService.asyncRegisterDeviceViaGUID(request.registrationIdentifier)
 
         registrationData.testResult.validOrThrow()
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/submission/QRScanResult.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/submission/QRScanResult.kt
deleted file mode 100644
index 2a054d0ebf1f23453ef26ca5d942235368fa8e63..0000000000000000000000000000000000000000
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/service/submission/QRScanResult.kt
+++ /dev/null
@@ -1,28 +0,0 @@
-package de.rki.coronawarnapp.service.submission
-
-import java.util.regex.Pattern
-
-data class QRScanResult(val rawResult: String) {
-
-    val isValid: Boolean
-        get() = guid != null
-    val guid: String? by lazy { extractGUID(rawResult) }
-
-    private fun extractGUID(rawResult: String): String? {
-        if (!QR_CODE_REGEX.toRegex().matches(rawResult)) return null
-
-        val matcher = QR_CODE_REGEX.matcher(rawResult)
-        return if (matcher.matches()) matcher.group(1) else null
-    }
-
-    companion object {
-        // regex pattern for scanned QR code URL
-        val QR_CODE_REGEX: Pattern = (
-            "^" + // Match start of string
-                "(?:https:\\/{2}localhost)" + // Match `https://localhost`
-                "(?:\\/{1}\\?)" + // Match the query param `/?`
-                "([a-f\\d]{6}[-][a-f\\d]{8}[-](?:[a-f\\d]{4}[-]){3}[a-f\\d]{12})" + // Match the UUID
-                "\$"
-            ).toPattern(Pattern.CASE_INSENSITIVE)
-    }
-}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModel.kt
index 5bd0b24a2974ece6a3e3cf2b2f2752ecb4b2b5e4..ff300dd094c8610d70ccd2ec42bc7231d4114c37 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModel.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModel.kt
@@ -6,13 +6,14 @@ import dagger.assisted.AssistedFactory
 import dagger.assisted.AssistedInject
 import de.rki.coronawarnapp.bugreporting.censors.QRCodeCensor
 import de.rki.coronawarnapp.coronatest.qrcode.CoronaTestQRCode
+import de.rki.coronawarnapp.coronatest.qrcode.CoronaTestQrCodeValidator
+import de.rki.coronawarnapp.coronatest.qrcode.InvalidQRCodeException
 import de.rki.coronawarnapp.coronatest.server.CoronaTestResult
 import de.rki.coronawarnapp.coronatest.type.CoronaTest
 import de.rki.coronawarnapp.exception.ExceptionCategory
 import de.rki.coronawarnapp.exception.TransactionException
 import de.rki.coronawarnapp.exception.http.CwaWebException
 import de.rki.coronawarnapp.exception.reporting.report
-import de.rki.coronawarnapp.service.submission.QRScanResult
 import de.rki.coronawarnapp.submission.SubmissionRepository
 import de.rki.coronawarnapp.ui.submission.ApiRequestState
 import de.rki.coronawarnapp.ui.submission.ScanStatus
@@ -25,21 +26,21 @@ import timber.log.Timber
 
 class SubmissionQRCodeScanViewModel @AssistedInject constructor(
     private val submissionRepository: SubmissionRepository,
-    private val cameraSettings: CameraSettings
+    private val cameraSettings: CameraSettings,
+    private val qrCodeValidator: CoronaTestQrCodeValidator
 ) : CWAViewModel() {
     val routeToScreen = SingleLiveEvent<SubmissionNavigationEvents>()
     val showRedeemedTokenWarning = SingleLiveEvent<Unit>()
     val scanStatusValue = SingleLiveEvent<ScanStatus>()
 
-    open class InvalidQRCodeException : Exception("error in qr code")
-
     fun validateTestGUID(rawResult: String) {
-        val scanResult = QRScanResult(rawResult)
-        if (scanResult.isValid) {
-            QRCodeCensor.lastGUID = scanResult.guid
+        try {
+            val coronaTestQRCode = qrCodeValidator.validate(rawResult)
+            // TODO this needs to be adapted to work for different types
+            QRCodeCensor.lastGUID = coronaTestQRCode.registrationIdentifier
             scanStatusValue.postValue(ScanStatus.SUCCESS)
-            doDeviceRegistration(scanResult)
-        } else {
+            doDeviceRegistration(coronaTestQRCode)
+        } catch (err: InvalidQRCodeException) {
             scanStatusValue.postValue(ScanStatus.INVALID)
         }
     }
@@ -53,13 +54,11 @@ class SubmissionQRCodeScanViewModel @AssistedInject constructor(
     )
 
     @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
-    internal fun doDeviceRegistration(scanResult: QRScanResult) = launch {
+    internal fun doDeviceRegistration(coronaTestQRCode: CoronaTestQRCode) = launch {
         try {
             registrationState.postValue(RegistrationState(ApiRequestState.STARTED))
-            val request = CoronaTestQRCode.PCR(qrCodeGUID = scanResult.guid!!)
-            val coronaTest = submissionRepository.registerTest(request)
-            // TODO this needs to depend on what the user selected
-            submissionRepository.giveConsentToSubmission(type = CoronaTest.Type.PCR)
+            val coronaTest = submissionRepository.registerTest(coronaTestQRCode)
+            submissionRepository.giveConsentToSubmission(type = coronaTestQRCode.type)
             checkTestResult(coronaTest.testResult)
             registrationState.postValue(RegistrationState(ApiRequestState.SUCCESS, coronaTest.testResult))
         } catch (err: CwaWebException) {
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQrCodeValidatorTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQrCodeValidatorTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..2d9478b797bb905bd6e88fab37518e52bd034c44
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/CoronaTestQrCodeValidatorTest.kt
@@ -0,0 +1,32 @@
+package de.rki.coronawarnapp.coronatest.qrcode
+
+import de.rki.coronawarnapp.coronatest.type.CoronaTest
+import io.kotest.matchers.shouldBe
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class CoronaTestQrCodeValidatorTest : BaseTest() {
+
+    @Test
+    fun `valid codes are extracted by corresponding extractor`() {
+        val instance = CoronaTestQrCodeValidator(RapidAntigenQrCodeExtractor(), PcrQrCodeExtractor())
+        instance.validate(pcrQrCode1).type shouldBe CoronaTest.Type.PCR
+        instance.validate(pcrQrCode2).type shouldBe CoronaTest.Type.PCR
+        instance.validate(pcrQrCode3).type shouldBe CoronaTest.Type.PCR
+        instance.validate(raQrCode1).type shouldBe CoronaTest.Type.RAPID_ANTIGEN
+        instance.validate(raQrCode2).type shouldBe CoronaTest.Type.RAPID_ANTIGEN
+        instance.validate(raQrCode3).type shouldBe CoronaTest.Type.RAPID_ANTIGEN
+    }
+
+    @Test
+    fun `invalid code throws exception`() {
+        val invalidCode = "HTTPS://somethingelse/?123456-12345678-1234-4DA7-B166-B86D85475064"
+        val instance = CoronaTestQrCodeValidator(RapidAntigenQrCodeExtractor(), PcrQrCodeExtractor())
+        return try {
+            instance.validate(invalidCode)
+            false
+        } catch (e: InvalidQRCodeException) {
+            true
+        } shouldBe true
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/submission/ScanResultTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/PcrQrCodeExtractorTest.kt
similarity index 61%
rename from Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/submission/ScanResultTest.kt
rename to Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/PcrQrCodeExtractorTest.kt
index 44bf60cc1340c0eaa3bfc20fd0484a0d7ce8ffd6..e4537ff5a6113f3d0c46a6691851bdf94a3a9a69 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/service/submission/ScanResultTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/PcrQrCodeExtractorTest.kt
@@ -1,15 +1,10 @@
-package de.rki.coronawarnapp.service.submission
+package de.rki.coronawarnapp.coronatest.qrcode
 
 import io.kotest.matchers.shouldBe
-import io.mockk.MockKAnnotations
-import io.mockk.every
-import io.mockk.impl.annotations.MockK
-import io.mockk.mockkObject
-import org.junit.Before
 import org.junit.Test
 import testhelpers.BaseTest
 
-class ScanResultTest : BaseTest() {
+class PcrQrCodeExtractorTest : BaseTest() {
     private val guidUpperCase = "123456-12345678-1234-4DA7-B166-B86D85475064"
     private val guidLowerCase = "123456-12345678-1234-4da7-b166-b86d85475064"
     private val guidMixedCase = "123456-12345678-1234-4dA7-b166-B86d85475064"
@@ -17,19 +12,18 @@ class ScanResultTest : BaseTest() {
     private val localhostLowerCase = "https://localhost/?"
     private val localhostMixedCase = "https://LOCALHOST/?"
 
-    @MockK
-    private lateinit var scanResult: QRScanResult
-
-    @Before
-    fun setUp() {
-        MockKAnnotations.init(this)
-        mockkObject(scanResult)
-        every { scanResult.isValid } returns false
-    }
-
     private fun buildQRCodeCases(prefixString: String, guid: String, conditionToMatch: Boolean) {
-        scanResult = QRScanResult("$prefixString$guid")
-        scanResult.isValid shouldBe conditionToMatch
+        val extractor = PcrQrCodeExtractor()
+        try {
+            if (extractor.canHandle("$prefixString$guid")) {
+                extractor.extract("$prefixString$guid")
+                conditionToMatch shouldBe true
+            } else {
+                conditionToMatch shouldBe false
+            }
+        } catch (e: InvalidQRCodeException) {
+            conditionToMatch shouldBe false
+        }
     }
 
     @Test
@@ -83,16 +77,16 @@ class ScanResultTest : BaseTest() {
 
     @Test
     fun extractGUID() {
-        QRScanResult("$localhostUpperCase$guidUpperCase").guid shouldBe guidUpperCase
-        QRScanResult("$localhostUpperCase$guidLowerCase").guid shouldBe guidLowerCase
-        QRScanResult("$localhostUpperCase$guidMixedCase").guid shouldBe guidMixedCase
+        PcrQrCodeExtractor().extract("$localhostUpperCase$guidUpperCase").qrCodeGUID shouldBe guidUpperCase
+        PcrQrCodeExtractor().extract("$localhostUpperCase$guidLowerCase").qrCodeGUID shouldBe guidLowerCase
+        PcrQrCodeExtractor().extract("$localhostUpperCase$guidMixedCase").qrCodeGUID shouldBe guidMixedCase
 
-        QRScanResult("$localhostLowerCase$guidUpperCase").guid shouldBe guidUpperCase
-        QRScanResult("$localhostLowerCase$guidLowerCase").guid shouldBe guidLowerCase
-        QRScanResult("$localhostLowerCase$guidMixedCase").guid shouldBe guidMixedCase
+        PcrQrCodeExtractor().extract("$localhostLowerCase$guidUpperCase").qrCodeGUID shouldBe guidUpperCase
+        PcrQrCodeExtractor().extract("$localhostLowerCase$guidLowerCase").qrCodeGUID shouldBe guidLowerCase
+        PcrQrCodeExtractor().extract("$localhostLowerCase$guidMixedCase").qrCodeGUID shouldBe guidMixedCase
 
-        QRScanResult("$localhostMixedCase$guidUpperCase").guid shouldBe guidUpperCase
-        QRScanResult("$localhostMixedCase$guidLowerCase").guid shouldBe guidLowerCase
-        QRScanResult("$localhostMixedCase$guidMixedCase").guid shouldBe guidMixedCase
+        PcrQrCodeExtractor().extract("$localhostMixedCase$guidUpperCase").qrCodeGUID shouldBe guidUpperCase
+        PcrQrCodeExtractor().extract("$localhostMixedCase$guidLowerCase").qrCodeGUID shouldBe guidLowerCase
+        PcrQrCodeExtractor().extract("$localhostMixedCase$guidMixedCase").qrCodeGUID shouldBe guidMixedCase
     }
 }
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/RapidAntigenQrCodeExtractorTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/RapidAntigenQrCodeExtractorTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..ac2fd0474a277dda83ceaeab4203437c593877d6
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/RapidAntigenQrCodeExtractorTest.kt
@@ -0,0 +1,45 @@
+package de.rki.coronawarnapp.coronatest.qrcode
+
+import de.rki.coronawarnapp.coronatest.type.CoronaTest
+import io.kotest.matchers.shouldBe
+import org.joda.time.Instant
+import org.joda.time.LocalDate
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class RapidAntigenQrCodeExtractorTest : BaseTest() {
+
+    private val instance = RapidAntigenQrCodeExtractor()
+
+    @Test
+    fun `valid codes are recognized`() {
+        listOf(raQrCode1, raQrCode2, raQrCode3, raQrCode4, raQrCode5, raQrCode6, raQrCode7, raQrCode8).forEach {
+            instance.canHandle(it) shouldBe true
+        }
+    }
+
+    @Test
+    fun `invalid codes are rejected`() {
+        listOf(pcrQrCode1, pcrQrCode2, pcrQrCode3).forEach {
+            instance.canHandle(it) shouldBe false
+        }
+    }
+
+    @Test
+    fun `extracting valid codes does not throw exception`() {
+        listOf(raQrCode1, raQrCode2, raQrCode3, raQrCode4, raQrCode5, raQrCode6, raQrCode7, raQrCode8).forEach {
+            instance.extract(it)
+        }
+    }
+
+    @Test
+    fun `personal data is extracted`() {
+        val data = instance.extract(raQrCode3)
+        data.type shouldBe CoronaTest.Type.RAPID_ANTIGEN
+        data.hash shouldBe "7b1c063e883063f8c33ffaa256aded506afd907f7446143b3da0f938a21967a9"
+        data.createdAt shouldBe Instant.ofEpochMilli(1618563782000)
+        data.dateOfBirth shouldBe LocalDate.parse("1962-01-08")
+        data.lastName shouldBe "Hayes"
+        data.firstName shouldBe "Alma"
+    }
+}
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/TestQrCodes.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/TestQrCodes.kt
new file mode 100644
index 0000000000000000000000000000000000000000..d8ebbe36ea3f19ae5f5931d8e1b31294c6338ce2
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/coronatest/qrcode/TestQrCodes.kt
@@ -0,0 +1,22 @@
+package de.rki.coronawarnapp.coronatest.qrcode
+
+internal val pcrQrCode1 = "HTTPS://LOCALHOST/?123456-12345678-1234-4DA7-B166-B86D85475064"
+internal val pcrQrCode2 = "https://localhost/?123456-12345678-1234-4DA7-B166-B86D85475064"
+internal val pcrQrCode3 = "https://LOCALHOST/?123456-12345678-1234-4DA7-B166-B86D85475064"
+
+internal val raQrCode1 =
+    "https://s.coronawarn.app/?v=1#eyJ0aW1lc3RhbXAiOjE2MTg1NjA5NjQsInNhbHQiOiIwQ0ZEMUJCQzI2Q0FCODVCNkZFNDE5MTJFQjFBQUU1QUNEN0QyNjA0RTQwNTQyRUVEQjZEQUYyQkRBMDQ5QzRGIiwidGVzdElkIjoiYjM2YzUzN2ItZWQ5NC00Njc3LTkzZmQtODUwMTY4NjlkYjEwIiwiaGFzaCI6IjJiNTc0NjhlN2Q4MTkyMWQzOGM4OGI1NjExOWE0Y2ViMzYyNmI1MDM4ZWI5Njk3ZjkxOTQ4NmJjMzg0Y2U2M2UifQ"
+internal val raQrCode2 =
+    "https://s.coronawarn.app?v=1#eyJ0aW1lc3RhbXAiOjE2MTg1NjA5NjQsInNhbHQiOiIwQ0ZEMUJCQzI2Q0FCODVCNkZFNDE5MTJFQjFBQUU1QUNEN0QyNjA0RTQwNTQyRUVEQjZEQUYyQkRBMDQ5QzRGIiwidGVzdElkIjoiYjM2YzUzN2ItZWQ5NC00Njc3LTkzZmQtODUwMTY4NjlkYjEwIiwiaGFzaCI6IjJiNTc0NjhlN2Q4MTkyMWQzOGM4OGI1NjExOWE0Y2ViMzYyNmI1MDM4ZWI5Njk3ZjkxOTQ4NmJjMzg0Y2U2M2UifQ"
+internal val raQrCode3 =
+    "https://s.coronawarn.app/?v=1#eyJ0aW1lc3RhbXAiOjE2MTg1NjM3ODIsInNhbHQiOiI1QTI3M0REREJCQTFEMkFDQUEzN0ExMDg4NjhGNkIwMjM3NjQzRjhBNjdCQTNENkQ3RUE3RkREQ0M0RDJGMjBEIiwidGVzdElkIjoiMGQ5ZTg0MzItZWI5MS00YzhmLTgyYWYtNWEwMWZiMWI2NzYwIiwiaGFzaCI6IjdiMWMwNjNlODgzMDYzZjhjMzNmZmFhMjU2YWRlZDUwNmFmZDkwN2Y3NDQ2MTQzYjNkYTBmOTM4YTIxOTY3YTkiLCJmbiI6IkFsbWEiLCJsbiI6IkhheWVzIiwiZG9iIjoiMTk2Mi0wMS0wOCJ9"
+internal val raQrCode4 =
+    "https://s.coronawarn.app?v=1#eyJ0aW1lc3RhbXAiOjE2MTg1NjM3ODIsInNhbHQiOiI1QTI3M0REREJCQTFEMkFDQUEzN0ExMDg4NjhGNkIwMjM3NjQzRjhBNjdCQTNENkQ3RUE3RkREQ0M0RDJGMjBEIiwidGVzdElkIjoiMGQ5ZTg0MzItZWI5MS00YzhmLTgyYWYtNWEwMWZiMWI2NzYwIiwiaGFzaCI6IjdiMWMwNjNlODgzMDYzZjhjMzNmZmFhMjU2YWRlZDUwNmFmZDkwN2Y3NDQ2MTQzYjNkYTBmOTM4YTIxOTY3YTkiLCJmbiI6IkFsbWEiLCJsbiI6IkhheWVzIiwiZG9iIjoiMTk2Mi0wMS0wOCJ9"
+internal val raQrCode5 =
+    "https://s.coronawarn.app/?v=1#eyJ0aW1lc3RhbXAiOjE2MTg1NjM4NDMsInNhbHQiOiJBNkVFRkZERDE2Qzk5RDFCODEyREE2NTc1NzgwMDM1QTQ0REI0OUM5NTBGODdCQkY0NDJBRkIwMDE5NkZCNDQ4IiwidGVzdElkIjoiNDhjOTc2ODgtY2U2ZC00MDFjLWEwMmMtMjU5MTE2YTRmODhmIiwiaGFzaCI6IjIyMTA4Y2FmNTQwNWM0MzI5Y2I3ZTEzNzg3MTMxMDJhMGNkNjY4OWM3YWFmMWJjOGViYzk3MDdiMjNjNTZhN2UifQ=="
+internal val raQrCode6 =
+    "https://s.coronawarn.app?v=1#eyJ0aW1lc3RhbXAiOjE2MTg1NjM4NDMsInNhbHQiOiJBNkVFRkZERDE2Qzk5RDFCODEyREE2NTc1NzgwMDM1QTQ0REI0OUM5NTBGODdCQkY0NDJBRkIwMDE5NkZCNDQ4IiwidGVzdElkIjoiNDhjOTc2ODgtY2U2ZC00MDFjLWEwMmMtMjU5MTE2YTRmODhmIiwiaGFzaCI6IjIyMTA4Y2FmNTQwNWM0MzI5Y2I3ZTEzNzg3MTMxMDJhMGNkNjY4OWM3YWFmMWJjOGViYzk3MDdiMjNjNTZhN2UifQ=="
+internal val raQrCode7 =
+    "https://s.coronawarn.app/?v=1#eyJ0aW1lc3RhbXAiOjE2MTg1NjM5MDIsInNhbHQiOiJGRkVERDZCMEM5MzZGRDVBQTg1M0NBNjgzOTY1QzYzMDI1NDRBMTIyNTY5MDAyQjg5OEI1NkM5OTY1NjRENTNGIiwidGVzdElkIjoiODYyNDcwYzItMzk3MS00M2Y0LWFjY2UtMjIzMzRlMjZiZTg3IiwiaGFzaCI6Ijc1ZmU5M2MyOWI1ZDI4NTU3NmJjZmM5NTZmYWIxMzllNTgxMWNhZWY1MTNiN2Y4MzhmNjY3NWJhYTU4MGM5YWUiLCJmbiI6IkJyeWFuIiwibG4iOiJNYXJzaCIsImRvYiI6IjE5OTAtMDQtMjgifQ=="
+internal val raQrCode8 =
+    "https://s.coronawarn.app?v=1#eyJ0aW1lc3RhbXAiOjE2MTg1NjM5MDIsInNhbHQiOiJGRkVERDZCMEM5MzZGRDVBQTg1M0NBNjgzOTY1QzYzMDI1NDRBMTIyNTY5MDAyQjg5OEI1NkM5OTY1NjRENTNGIiwidGVzdElkIjoiODYyNDcwYzItMzk3MS00M2Y0LWFjY2UtMjIzMzRlMjZiZTg3IiwiaGFzaCI6Ijc1ZmU5M2MyOWI1ZDI4NTU3NmJjZmM5NTZmYWIxMzllNTgxMWNhZWY1MTNiN2Y4MzhmNjY3NWJhYTU4MGM5YWUiLCJmbiI6IkJyeWFuIiwibG4iOiJNYXJzaCIsImRvYiI6IjE5OTAtMDQtMjgifQ=="
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModelTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModelTest.kt
index ecaa78f82696dda26b7e7b5d8ef6a07aa1b8d493..02ed331db2b6e0bea748693db91616d7b058694b 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModelTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/ui/submission/qrcode/scan/SubmissionQRCodeScanViewModelTest.kt
@@ -1,13 +1,19 @@
 package de.rki.coronawarnapp.ui.submission.qrcode.scan
 
 import de.rki.coronawarnapp.bugreporting.censors.QRCodeCensor
+import de.rki.coronawarnapp.coronatest.qrcode.CoronaTestQRCode
+import de.rki.coronawarnapp.coronatest.qrcode.CoronaTestQrCodeValidator
+import de.rki.coronawarnapp.coronatest.qrcode.InvalidQRCodeException
+import de.rki.coronawarnapp.coronatest.type.CoronaTest
 import de.rki.coronawarnapp.submission.SubmissionRepository
 import de.rki.coronawarnapp.ui.submission.ScanStatus
 import de.rki.coronawarnapp.util.permission.CameraSettings
 import io.kotest.matchers.shouldBe
 import io.mockk.MockKAnnotations
+import io.mockk.coEvery
 import io.mockk.every
 import io.mockk.impl.annotations.MockK
+import io.mockk.mockk
 import io.mockk.verify
 import org.junit.Assert
 import org.junit.jupiter.api.BeforeEach
@@ -22,6 +28,7 @@ class SubmissionQRCodeScanViewModelTest : BaseTest() {
 
     @MockK lateinit var submissionRepository: SubmissionRepository
     @MockK lateinit var cameraSettings: CameraSettings
+    @MockK lateinit var qrCodeValidator: CoronaTestQrCodeValidator
 
     @BeforeEach
     fun setUp() {
@@ -30,11 +37,23 @@ class SubmissionQRCodeScanViewModelTest : BaseTest() {
 
     private fun createViewModel() = SubmissionQRCodeScanViewModel(
         submissionRepository,
-        cameraSettings
+        cameraSettings,
+        qrCodeValidator
     )
 
     @Test
     fun scanStatusValid() {
+        // valid guid
+        val guid = "123456-12345678-1234-4DA7-B166-B86D85475064"
+        val coronaTestQRCode = CoronaTestQRCode.PCR(
+            qrCodeGUID = guid
+        )
+
+        val validQrCode = "https://localhost/?$guid"
+        val invalidQrCode = "https://no-guid-here"
+
+        every { qrCodeValidator.validate(validQrCode) } returns coronaTestQRCode
+        every { qrCodeValidator.validate(invalidQrCode) } throws InvalidQRCodeException()
         val viewModel = createViewModel()
 
         // start
@@ -44,17 +63,26 @@ class SubmissionQRCodeScanViewModelTest : BaseTest() {
 
         QRCodeCensor.lastGUID = null
 
-        // valid guid
-        val guid = "123456-12345678-1234-4DA7-B166-B86D85475064"
-        viewModel.validateTestGUID("https://localhost/?$guid")
+        viewModel.validateTestGUID(validQrCode)
         viewModel.scanStatusValue.let { Assert.assertEquals(ScanStatus.SUCCESS, it.value) }
         QRCodeCensor.lastGUID = guid
 
         // invalid guid
-        viewModel.validateTestGUID("https://no-guid-here")
+        viewModel.validateTestGUID(invalidQrCode)
         viewModel.scanStatusValue.let { Assert.assertEquals(ScanStatus.INVALID, it.value) }
     }
 
+    @Test
+    fun `doDeviceRegistration calls TestResultDataCollector`() {
+        val viewModel = createViewModel()
+        val mockResult = mockk<CoronaTestQRCode>().apply {
+            every { registrationIdentifier } returns "guid"
+        }
+        val mockTest = mockk<CoronaTest>()
+        coEvery { submissionRepository.registerTest(any()) } returns mockTest
+        viewModel.doDeviceRegistration(mockResult)
+    }
+
     @Test
     fun `Camera settings is saved when user denies it`() {
         every { cameraSettings.isCameraDeniedPermanently } returns mockFlowPreference(false)