From 45130941b93af5226e8f80b4bf17b63d81f4aabd Mon Sep 17 00:00:00 2001
From: Matthias Urhahn <matthias.urhahn@sap.com>
Date: Wed, 28 Apr 2021 10:53:07 +0200
Subject: [PATCH] Incompatibility warning card on main screen
 (EXPOSUREAPP-5464)(COMMUNITY) (#2873)

* Incompatibility warning card on main screen

* Fix lint

* Fix lint further

* Really fix lint

* Refactor BluetoothSupport.kt to be injectable, to allow mocking.

* Unit test draft.

* adapt tests

* tests and refactoring

* tests and refactoring

* screenshots

* add icon

* add dimen again

* change text

* klint

Co-authored-by: Fynn Godau <fynngodau@mailbox.org>
Co-authored-by: chilja <chiljamgossow@gmail.com>
---
 .../ui/main/home/HomeFragmentTest.kt          | 38 +++++++--
 .../coronawarnapp/ui/main/home/HomeAdapter.kt |  2 +
 .../ui/main/home/HomeFragment.kt              |  4 +
 .../ui/main/home/HomeFragmentViewModel.kt     | 15 +++-
 .../ui/main/home/items/IncompatibleCard.kt    | 42 ++++++++++
 .../util/bluetooth/BluetoothSupport.kt        | 60 ++++++++++++++
 .../main/res/layout/home_faq_card_layout.xml  |  7 +-
 .../layout/home_incompatible_card_layout.xml  | 69 ++++++++++++++++
 .../src/main/res/values-de/strings.xml        | 18 ++++
 .../src/main/res/values/strings.xml           | 13 ++-
 .../main/home/HomeFragmentViewModelTest.kt    | 10 ++-
 .../util/bluetooth/BluetoothSupportTest.kt    | 82 +++++++++++++++++++
 12 files changed, 346 insertions(+), 14 deletions(-)
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/items/IncompatibleCard.kt
 create mode 100644 Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/bluetooth/BluetoothSupport.kt
 create mode 100644 Corona-Warn-App/src/main/res/layout/home_incompatible_card_layout.xml
 create mode 100644 Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/bluetooth/BluetoothSupportTest.kt

diff --git a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentTest.kt b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentTest.kt
index 982637a9d..1c5536f59 100644
--- a/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentTest.kt
+++ b/Corona-Warn-App/src/androidTest/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentTest.kt
@@ -2,9 +2,7 @@ package de.rki.coronawarnapp.ui.main.home
 
 import androidx.lifecycle.LiveData
 import androidx.lifecycle.MutableLiveData
-import androidx.test.espresso.Espresso
 import androidx.test.espresso.Espresso.onView
-import androidx.test.espresso.matcher.ViewMatchers
 import androidx.test.espresso.matcher.ViewMatchers.withId
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import dagger.Module
@@ -12,7 +10,6 @@ import dagger.android.ContributesAndroidInjector
 import de.rki.coronawarnapp.R
 import de.rki.coronawarnapp.appconfig.AppConfigProvider
 import de.rki.coronawarnapp.coronatest.CoronaTestRepository
-import de.rki.coronawarnapp.coronatest.notification.ShareTestResultNotificationService
 import de.rki.coronawarnapp.deadman.DeadmanNotificationScheduler
 import de.rki.coronawarnapp.main.CWASettings
 import de.rki.coronawarnapp.statistics.source.StatisticsProvider
@@ -32,6 +29,7 @@ import de.rki.coronawarnapp.ui.main.home.items.HomeItem
 import de.rki.coronawarnapp.ui.presencetracing.organizer.TraceLocationOrganizerSettings
 import de.rki.coronawarnapp.ui.statistics.Statistics
 import de.rki.coronawarnapp.util.TimeStamper
+import de.rki.coronawarnapp.util.bluetooth.BluetoothSupport
 import de.rki.coronawarnapp.util.encryptionmigration.EncryptionErrorResetTool
 import de.rki.coronawarnapp.util.shortcuts.AppShortcutsHelper
 import de.rki.coronawarnapp.util.ui.SingleLiveEvent
@@ -65,7 +63,6 @@ class HomeFragmentTest : BaseUITest() {
     @MockK lateinit var tracingStateProviderFactory: TracingStateProvider.Factory
     @MockK lateinit var coronaTestRepository: CoronaTestRepository
     @MockK lateinit var tracingRepository: TracingRepository
-    @MockK lateinit var shareTestResultNotificationService: ShareTestResultNotificationService
     @MockK lateinit var submissionRepository: SubmissionRepository
     @MockK lateinit var cwaSettings: CWASettings
     @MockK lateinit var appConfigProvider: AppConfigProvider
@@ -75,6 +72,7 @@ class HomeFragmentTest : BaseUITest() {
     @MockK lateinit var tracingSettings: TracingSettings
     @MockK lateinit var traceLocationOrganizerSettings: TraceLocationOrganizerSettings
     @MockK lateinit var timeStamper: TimeStamper
+    @MockK lateinit var bluetoothSupport: BluetoothSupport
 
     private lateinit var homeFragmentViewModel: HomeFragmentViewModel
 
@@ -100,6 +98,9 @@ class HomeFragmentTest : BaseUITest() {
                 override fun create(): HomeFragmentViewModel = homeFragmentViewModel
             }
         )
+
+        every { bluetoothSupport.isScanningSupported } returns true
+        every { bluetoothSupport.isAdvertisingSupported } returns true
     }
 
     @Screenshot
@@ -111,7 +112,7 @@ class HomeFragmentTest : BaseUITest() {
         captureHomeFragment("low_risk_no_encounters")
 
         // also scroll down and capture a screenshot of the faq card
-        Espresso.onView(ViewMatchers.withId(R.id.recycler_view)).perform(recyclerScrollTo())
+        onView(withId(R.id.recycler_view)).perform(recyclerScrollTo())
         takeScreenshot<HomeFragment>("faq_card")
     }
 
@@ -245,6 +246,30 @@ class HomeFragmentTest : BaseUITest() {
         }
     }
 
+    @Screenshot
+    @Test
+    fun captureHomeFragmentCompatibilityBleBroadcastNotSupported() {
+        every { homeFragmentViewModel.homeItems } returns
+            homeFragmentItemsLiveData(HomeData.Tracing.TRACING_FAILED_ITEM)
+        every { bluetoothSupport.isScanningSupported } returns true
+        every { bluetoothSupport.isAdvertisingSupported } returns false
+        launchInMainActivity<HomeFragment>()
+        onView(withId(R.id.recycler_view)).perform(recyclerScrollTo(2))
+        captureHomeFragment("compatibility_ble_broadcast_not_supported")
+    }
+
+    @Screenshot
+    @Test
+    fun captureHomeFragmentCompatibilityBleScanNotSupported() {
+        every { homeFragmentViewModel.homeItems } returns
+            homeFragmentItemsLiveData(HomeData.Tracing.TRACING_FAILED_ITEM)
+        every { bluetoothSupport.isScanningSupported } returns false
+        every { bluetoothSupport.isAdvertisingSupported } returns true
+        launchInMainActivity<HomeFragment>()
+        onView(withId(R.id.recycler_view)).perform(recyclerScrollTo(2))
+        captureHomeFragment("compatibility_ble_scan_not_supported")
+    }
+
     @After
     fun teardown() {
         clearAllViewModels()
@@ -277,7 +302,8 @@ class HomeFragmentTest : BaseUITest() {
             appShortcutsHelper = appShortcutsHelper,
             tracingSettings = tracingSettings,
             traceLocationOrganizerSettings = traceLocationOrganizerSettings,
-            timeStamper = timeStamper
+            timeStamper = timeStamper,
+            bluetoothSupport = bluetoothSupport
         )
     )
 
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeAdapter.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeAdapter.kt
index 07aafffde..66c65f4a5 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeAdapter.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeAdapter.kt
@@ -29,6 +29,7 @@ import de.rki.coronawarnapp.tracing.ui.homecards.TracingProgressCard
 import de.rki.coronawarnapp.ui.main.home.items.CreateTraceLocationCard
 import de.rki.coronawarnapp.ui.main.home.items.FAQCard
 import de.rki.coronawarnapp.ui.main.home.items.HomeItem
+import de.rki.coronawarnapp.ui.main.home.items.IncompatibleCard
 import de.rki.coronawarnapp.util.lists.BindableVH
 import de.rki.coronawarnapp.util.lists.diffutil.AsyncDiffUtilAdapter
 import de.rki.coronawarnapp.util.lists.diffutil.AsyncDiffer
@@ -50,6 +51,7 @@ class HomeAdapter :
                 StableIdMod(data),
                 DataBinderMod<HomeItem, HomeItemVH<HomeItem, ViewBinding>>(data),
                 TypedVHCreatorMod({ data[it] is FAQCard.Item }) { FAQCard(it) },
+                TypedVHCreatorMod({ data[it] is IncompatibleCard.Item }) { IncompatibleCard(it) },
                 TypedVHCreatorMod({ data[it] is CreateTraceLocationCard.Item }) { CreateTraceLocationCard(it) },
                 TypedVHCreatorMod({ data[it] is IncreasedRiskCard.Item }) { IncreasedRiskCard(it) },
                 TypedVHCreatorMod({ data[it] is LowRiskCard.Item }) { LowRiskCard(it) },
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragment.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragment.kt
index dfbad3ce5..e63fe280a 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragment.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragment.kt
@@ -80,6 +80,10 @@ class HomeFragment : Fragment(R.layout.home_fragment_layout), AutoInject {
             ExternalActionHelper.openUrl(this@HomeFragment, getString(R.string.main_about_link))
         }
 
+        viewModel.openIncompatibleEvent.observe2(this) {
+            ExternalActionHelper.openUrl(this@HomeFragment, getString(R.string.incompatible_link))
+        }
+
         viewModel.openTraceLocationOrganizerFlow.observe2(this) {
             if (viewModel.wasQRInfoWasAcknowledged()) {
                 val nestedGraph =
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentViewModel.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentViewModel.kt
index 73fc1c4d4..75c8f0e22 100644
--- a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentViewModel.kt
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/HomeFragmentViewModel.kt
@@ -60,8 +60,10 @@ import de.rki.coronawarnapp.ui.main.home.HomeFragmentEvents.ShowTracingExplanati
 import de.rki.coronawarnapp.ui.main.home.items.CreateTraceLocationCard
 import de.rki.coronawarnapp.ui.main.home.items.FAQCard
 import de.rki.coronawarnapp.ui.main.home.items.HomeItem
+import de.rki.coronawarnapp.ui.main.home.items.IncompatibleCard
 import de.rki.coronawarnapp.ui.presencetracing.organizer.TraceLocationOrganizerSettings
 import de.rki.coronawarnapp.util.TimeStamper
+import de.rki.coronawarnapp.util.bluetooth.BluetoothSupport
 import de.rki.coronawarnapp.util.coroutine.DispatcherProvider
 import de.rki.coronawarnapp.util.encryptionmigration.EncryptionErrorResetTool
 import de.rki.coronawarnapp.util.shortcuts.AppShortcutsHelper
@@ -89,13 +91,15 @@ class HomeFragmentViewModel @AssistedInject constructor(
     private val appShortcutsHelper: AppShortcutsHelper,
     private val tracingSettings: TracingSettings,
     private val traceLocationOrganizerSettings: TraceLocationOrganizerSettings,
-    private val timeStamper: TimeStamper
+    private val timeStamper: TimeStamper,
+    private val bluetoothSupport: BluetoothSupport,
 ) : CWAViewModel(dispatcherProvider = dispatcherProvider) {
 
     private val tracingStateProvider by lazy { tracingStateProviderFactory.create(isDetailsMode = false) }
 
     val routeToScreen = SingleLiveEvent<NavDirections>()
     val openFAQUrlEvent = SingleLiveEvent<Unit>()
+    val openIncompatibleEvent = SingleLiveEvent<Unit>()
     val openTraceLocationOrganizerFlow = SingleLiveEvent<Unit>()
 
     val tracingHeaderState: LiveData<TracingHeaderState> = tracingStatus.generalStatus
@@ -286,6 +290,15 @@ class HomeFragmentViewModel @AssistedInject constructor(
                 else -> add(tracingItem)
             }
 
+            if (bluetoothSupport.isAdvertisingSupported == false) {
+                add(
+                    IncompatibleCard.Item(
+                        onClickAction = { openIncompatibleEvent.postValue(Unit) },
+                        bluetoothSupported = bluetoothSupport.isScanningSupported != false
+                    )
+                )
+            }
+
             // TODO: Would be nice to have a more elegant solution of displaying the result cards in the right order
             when (statePCR) {
                 SubmissionStatePCR.NoTest -> {
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/items/IncompatibleCard.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/items/IncompatibleCard.kt
new file mode 100644
index 000000000..5510ff442
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/ui/main/home/items/IncompatibleCard.kt
@@ -0,0 +1,42 @@
+package de.rki.coronawarnapp.ui.main.home.items
+
+import android.view.ViewGroup
+import de.rki.coronawarnapp.R
+import de.rki.coronawarnapp.databinding.HomeIncompatibleCardLayoutBinding
+import de.rki.coronawarnapp.ui.main.home.HomeAdapter
+import de.rki.coronawarnapp.util.lists.diffutil.HasPayloadDiffer
+
+class IncompatibleCard(parent: ViewGroup) :
+    HomeAdapter.HomeItemVH<IncompatibleCard.Item, HomeIncompatibleCardLayoutBinding>(
+        R.layout.home_card_container_layout,
+        parent
+    ) {
+
+    override val viewBinding = lazy {
+        HomeIncompatibleCardLayoutBinding.inflate(layoutInflater, itemView.findViewById(R.id.card_container), true)
+    }
+
+    override val onBindData: HomeIncompatibleCardLayoutBinding.(
+        item: Item,
+        payloads: List<Any>
+    ) -> Unit = { item, payloads ->
+
+        when (item.bluetoothSupported) {
+            true ->
+                mainCardContentBody.setText(R.string.incompatible_advertising_not_supported)
+            false ->
+                mainCardContentBody.setText(R.string.incompatible_scanning_not_supported)
+        }
+
+        itemView.setOnClickListener {
+            val curItem = payloads.filterIsInstance<Item>().singleOrNull() ?: item
+            curItem.onClickAction(item)
+        }
+    }
+
+    data class Item(val onClickAction: (Item) -> Unit, val bluetoothSupported: Boolean) : HomeItem, HasPayloadDiffer {
+        override val stableId: Long = Item::class.java.name.hashCode().toLong()
+
+        override fun diffPayload(old: Any, new: Any): Any? = if (old::class == new::class) new else null
+    }
+}
diff --git a/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/bluetooth/BluetoothSupport.kt b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/bluetooth/BluetoothSupport.kt
new file mode 100644
index 000000000..d4ab462db
--- /dev/null
+++ b/Corona-Warn-App/src/main/java/de/rki/coronawarnapp/util/bluetooth/BluetoothSupport.kt
@@ -0,0 +1,60 @@
+package de.rki.coronawarnapp.util.bluetooth
+
+import android.bluetooth.BluetoothAdapter
+import android.os.Build
+import dagger.Reusable
+import javax.inject.Inject
+
+@Reusable
+class BluetoothSupport @Inject constructor(
+    private val bluetoothAdapter: BluetoothAdapter?
+) {
+    /**
+     * Determine whether Bluetooth low Energy scanning is supported
+     *
+     * @return true if supported, false if not supported, null if unknown
+     */
+    val isScanningSupported: Boolean?
+        get() = when {
+            hasNoBluetooth -> false
+            isBluetoothTurnedOff -> null
+            hasScanner -> true
+            else -> false
+        }
+
+    /**
+     * Determine whether Bluetooth Low Energy peripheral mode (advertising
+     * beacons) is supported
+     *
+     * @return true if supported, false if not supported, null if unknown
+     */
+    val isAdvertisingSupported: Boolean?
+        get() = when {
+            hasNoBluetooth -> false
+            hasApi26AndSupportsAdvertising -> true
+            isBluetoothTurnedOff -> null
+            hasAdvertiser -> true
+            else -> false
+        }
+
+    private val hasNoBluetooth: Boolean
+        get() = bluetoothAdapter == null
+
+    private val isBluetoothTurnedOff: Boolean
+        get() = bluetoothAdapter?.state != BluetoothAdapter.STATE_ON
+
+    private val hasScanner: Boolean
+        get() = bluetoothAdapter?.bluetoothLeScanner != null
+
+    private val hasAdvertiser: Boolean
+        get() = bluetoothAdapter?.bluetoothLeAdvertiser != null
+
+    private val hasApi26AndSupportsAdvertising: Boolean
+        get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
+            (
+                bluetoothAdapter?.let {
+                    it.isLeExtendedAdvertisingSupported || it.isLePeriodicAdvertisingSupported
+                }
+                    ?: false
+                )
+}
diff --git a/Corona-Warn-App/src/main/res/layout/home_faq_card_layout.xml b/Corona-Warn-App/src/main/res/layout/home_faq_card_layout.xml
index aeb33f5ad..244403b0a 100644
--- a/Corona-Warn-App/src/main/res/layout/home_faq_card_layout.xml
+++ b/Corona-Warn-App/src/main/res/layout/home_faq_card_layout.xml
@@ -42,10 +42,9 @@
             android:layout_width="@dimen/icon_size_external_link"
             android:layout_height="@dimen/icon_size_external_link"
             android:importantForAccessibility="no"
-            app:srcCompat="@drawable/ic_link"
-            app:layout_constraintBottom_toBottomOf="@+id/main_card_header_headline"
             app:layout_constraintEnd_toEndOf="parent"
-            app:layout_constraintTop_toTopOf="@+id/main_card_header_headline" />
+            app:layout_constraintTop_toTopOf="@+id/main_card_header_headline"
+            app:srcCompat="@drawable/ic_link" />
 
         <TextView
             android:id="@+id/main_card_content_body"
@@ -60,4 +59,4 @@
             app:layout_constraintTop_toBottomOf="@+id/main_card_header_icon" />
     </androidx.constraintlayout.widget.ConstraintLayout>
 
-</layout>
\ No newline at end of file
+</layout>
diff --git a/Corona-Warn-App/src/main/res/layout/home_incompatible_card_layout.xml b/Corona-Warn-App/src/main/res/layout/home_incompatible_card_layout.xml
new file mode 100644
index 000000000..c7ecee951
--- /dev/null
+++ b/Corona-Warn-App/src/main/res/layout/home_incompatible_card_layout.xml
@@ -0,0 +1,69 @@
+<?xml version="1.0" encoding="utf-8"?>
+<layout xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools">
+
+    <androidx.constraintlayout.widget.ConstraintLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:padding="@dimen/spacing_normal"
+        tools:showIn="@layout/home_card_container_layout">
+
+        <ImageView
+            android:id="@+id/main_card_header_icon"
+            android:layout_width="@dimen/icon_size_button"
+            android:layout_height="@dimen/icon_size_button"
+            android:importantForAccessibility="no"
+            android:src="@drawable/ic_high_risk_alert"
+            app:layout_constraintBottom_toTopOf="@id/top_barrier"
+            app:layout_constraintEnd_toStartOf="@id/main_card_header_headline"
+            app:layout_constraintStart_toStartOf="parent"
+            app:layout_constraintTop_toTopOf="parent"
+            app:layout_constraintVertical_bias="0.0" />
+
+        <ImageView
+            android:id="@+id/main_card_header_icon_end"
+            style="@style/icon"
+            android:layout_width="@dimen/icon_size_external_link"
+            android:layout_height="@dimen/icon_size_external_link"
+            android:importantForAccessibility="no"
+            app:layout_constraintEnd_toEndOf="parent"
+            app:layout_constraintTop_toTopOf="@id/main_card_header_headline"
+            app:srcCompat="@drawable/ic_link" />
+
+        <TextView
+            android:id="@+id/main_card_header_headline"
+            style="@style/headline5"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_marginStart="@dimen/spacing_small"
+            android:layout_marginEnd="@dimen/spacing_small"
+            android:accessibilityHeading="true"
+            android:text="@string/incompatible_headline"
+            app:layout_constraintBottom_toTopOf="@id/top_barrier"
+            app:layout_constraintEnd_toStartOf="@id/main_card_header_icon_end"
+            app:layout_constraintStart_toEndOf="@id/main_card_header_icon"
+            app:layout_constraintTop_toTopOf="@id/main_card_header_icon"
+            app:layout_constraintVertical_bias="0.0" />
+
+        <androidx.constraintlayout.widget.Barrier
+            android:id="@+id/top_barrier"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            app:barrierDirection="bottom"
+            app:constraint_referenced_ids="main_card_header_headline, main_card_header_icon" />
+
+        <TextView
+            android:id="@+id/main_card_content_body"
+            style="@style/subtitleMedium"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_marginTop="@dimen/spacing_small"
+            android:text="@string/incompatible_advertising_not_supported"
+            app:layout_constraintBottom_toBottomOf="parent"
+            app:layout_constraintEnd_toEndOf="parent"
+            app:layout_constraintStart_toStartOf="parent"
+            app:layout_constraintTop_toBottomOf="@id/top_barrier" />
+    </androidx.constraintlayout.widget.ConstraintLayout>
+
+</layout>
diff --git a/Corona-Warn-App/src/main/res/values-de/strings.xml b/Corona-Warn-App/src/main/res/values-de/strings.xml
index 83c2f1da8..afc565fa7 100644
--- a/Corona-Warn-App/src/main/res/values-de/strings.xml
+++ b/Corona-Warn-App/src/main/res/values-de/strings.xml
@@ -1995,4 +1995,22 @@
     <string name="trace_location_attendee_invalid_qr_code_dialog_positive_button">Ok</string>
     <!-- XBUT: Trace location check-ins invalid qr code dialog negative button -->
     <string name="trace_location_attendee_invalid_qr_code_dialog_negative_button">Abbrechen</string>
+
+    <!-- ####################################
+        Incompatibility warning card
+    ###################################### -->
+
+    <string name="incompatible_headline">Inkompatibilitätswarnung</string>
+    <string name="incompatible_advertising_not_supported">Ihr Smartphone kann
+        COVID-19-Benachrichtigungen über die Bluetooth-Schnittstelle lediglich empfangen, jedoch
+        nicht versenden. Das heißt, dass Sie über diese Schnittstelle von Risiko-Begegnungen gewarnt
+        werden können, jedoch nicht selbst warnen können. Warnungen, die im Rahmen von Check-ins
+        erfolgen, können Sie sowohl empfangen als auch selbst versenden.
+    </string>
+    <string name="incompatible_scanning_not_supported">Ihr Smartphone kann
+        COVID-19-Benachrichtigungen über die Bluetooth-Schnittstelle weder empfangen noch senden.
+        Warnungen, die im Rahmen von Check-ins erfolgen, können Sie sowohl empfangen als auch selbst
+        versenden.
+    </string>
+    <string name="incompatible_link">"https://www.coronawarn.app/de/faq/#incompatibility_warning"</string>
 </resources>
diff --git a/Corona-Warn-App/src/main/res/values/strings.xml b/Corona-Warn-App/src/main/res/values/strings.xml
index aea8af697..0520c47e8 100644
--- a/Corona-Warn-App/src/main/res/values/strings.xml
+++ b/Corona-Warn-App/src/main/res/values/strings.xml
@@ -1962,7 +1962,16 @@
     <!-- XACT: Button/Dialog label to cancel something-->
     <string name="generic_action_abort">"Cancel"</string>
     <!-- XACT: Button/Dialog label to remove something-->
-    <string name="generic_action_remove">"Remove"</string>
+    <string name="generic_action_remove">Remove</string>
+
+    <!-- ####################################
+        Incompatibility warning card
+    ###################################### -->
+    <string name="incompatible_headline"></string>
+    <string name="incompatible_advertising_not_supported"></string>
+    <string name="incompatible_scanning_not_supported"></string>
+
+    <string name="incompatible_link">"https://www.coronawarn.app/en/faq/#incompatibility_warning"</string>
 
     <!-- ####################################
        Trace Location
@@ -2020,4 +2029,4 @@
     <string name="trace_location_attendee_invalid_qr_code_dialog_positive_button">"OK"</string>
     <!-- XBUT: Trace location check-ins invalid qr code dialog negative button -->
     <string name="trace_location_attendee_invalid_qr_code_dialog_negative_button">"Cancel"</string>
-</resources>
\ No newline at end of file
+</resources>
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/main/home/HomeFragmentViewModelTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/main/home/HomeFragmentViewModelTest.kt
index 5c027f688..9171db144 100644
--- a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/main/home/HomeFragmentViewModelTest.kt
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/main/home/HomeFragmentViewModelTest.kt
@@ -19,6 +19,7 @@ import de.rki.coronawarnapp.ui.main.home.HomeFragmentEvents
 import de.rki.coronawarnapp.ui.main.home.HomeFragmentViewModel
 import de.rki.coronawarnapp.ui.presencetracing.organizer.TraceLocationOrganizerSettings
 import de.rki.coronawarnapp.util.TimeStamper
+import de.rki.coronawarnapp.util.bluetooth.BluetoothSupport
 import de.rki.coronawarnapp.util.encryptionmigration.EncryptionErrorResetTool
 import de.rki.coronawarnapp.util.shortcuts.AppShortcutsHelper
 import io.kotest.matchers.shouldBe
@@ -62,6 +63,7 @@ class HomeFragmentViewModelTest : BaseTest() {
     @MockK lateinit var tracingSettings: TracingSettings
     @MockK lateinit var traceLocationOrganizerSettings: TraceLocationOrganizerSettings
     @MockK lateinit var timeStamper: TimeStamper
+    @MockK lateinit var bluetoothSupport: BluetoothSupport
 
     @BeforeEach
     fun setup() {
@@ -78,6 +80,11 @@ class HomeFragmentViewModelTest : BaseTest() {
         coEvery { statisticsProvider.current } returns emptyFlow()
 
         every { timeStamper.nowUTC } returns Instant.ofEpochMilli(100101010)
+
+        bluetoothSupport.apply {
+            every { isAdvertisingSupported } returns true
+            every { isScanningSupported } returns true
+        }
     }
 
     private fun createInstance(): HomeFragmentViewModel = HomeFragmentViewModel(
@@ -95,7 +102,8 @@ class HomeFragmentViewModelTest : BaseTest() {
         appShortcutsHelper = appShortcutsHelper,
         tracingSettings = tracingSettings,
         traceLocationOrganizerSettings = traceLocationOrganizerSettings,
-        timeStamper = timeStamper
+        timeStamper = timeStamper,
+        bluetoothSupport = bluetoothSupport
     )
 
     @Test
diff --git a/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/bluetooth/BluetoothSupportTest.kt b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/bluetooth/BluetoothSupportTest.kt
new file mode 100644
index 000000000..6267c0279
--- /dev/null
+++ b/Corona-Warn-App/src/test/java/de/rki/coronawarnapp/util/bluetooth/BluetoothSupportTest.kt
@@ -0,0 +1,82 @@
+package de.rki.coronawarnapp.util.bluetooth
+
+import android.bluetooth.BluetoothAdapter
+import android.bluetooth.le.BluetoothLeAdvertiser
+import android.bluetooth.le.BluetoothLeScanner
+import io.kotest.matchers.shouldBe
+import io.mockk.MockKAnnotations
+import io.mockk.every
+import io.mockk.impl.annotations.MockK
+import kotlinx.coroutines.test.runBlockingTest
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import testhelpers.BaseTest
+
+class BluetoothSupportTest : BaseTest() {
+
+    @MockK lateinit var bluetoothAdapter: BluetoothAdapter
+    @MockK lateinit var bluetoothLeScanner: BluetoothLeScanner
+    @MockK lateinit var bluetoothLeAdvertiser: BluetoothLeAdvertiser
+
+    @BeforeEach
+    fun setup() {
+        MockKAnnotations.init(this)
+        every { bluetoothAdapter.state } returns BluetoothAdapter.STATE_ON
+        every { bluetoothAdapter.bluetoothLeScanner } returns null
+        every { bluetoothAdapter.bluetoothLeAdvertiser } returns null
+    }
+
+    private fun createInstance(
+        adapter: BluetoothAdapter? = bluetoothAdapter
+    ): BluetoothSupport = BluetoothSupport(
+        bluetoothAdapter = adapter
+    )
+
+    @Test
+    fun `init is side effect free`() {
+        createInstance()
+    }
+
+    @Test
+    fun `scanning not supported without bluetooth`() = runBlockingTest {
+        createInstance(null).isScanningSupported shouldBe false
+    }
+
+    @Test
+    fun `scanning not supported without scanner`() = runBlockingTest {
+        every { bluetoothAdapter.state } returns BluetoothAdapter.STATE_ON
+        every { bluetoothAdapter.bluetoothLeScanner } returns null
+        createInstance().isScanningSupported shouldBe false
+    }
+
+    @Test
+    fun `scanning supported when turned on and has scanner`() = runBlockingTest {
+        every { bluetoothAdapter.state } returns BluetoothAdapter.STATE_ON
+        every { bluetoothAdapter.bluetoothLeScanner } returns bluetoothLeScanner
+        createInstance().isScanningSupported shouldBe true
+    }
+
+    @Test
+    fun `scanning support unknown when turned off`() = runBlockingTest {
+        every { bluetoothAdapter.state } returns BluetoothAdapter.STATE_OFF
+        createInstance().isScanningSupported shouldBe null
+    }
+
+    @Test
+    fun `advertising not supported`() = runBlockingTest {
+        createInstance(null).isAdvertisingSupported shouldBe false
+    }
+
+    @Test
+    fun `advertising support unknown`() = runBlockingTest {
+        every { bluetoothAdapter.state } returns BluetoothAdapter.STATE_OFF
+        createInstance().isAdvertisingSupported shouldBe null
+    }
+
+    @Test
+    fun `advertising supported`() = runBlockingTest {
+        every { bluetoothAdapter.state } returns BluetoothAdapter.STATE_ON
+        every { bluetoothAdapter.bluetoothLeAdvertiser } returns bluetoothLeAdvertiser
+        createInstance().isAdvertisingSupported shouldBe true
+    }
+}
-- 
GitLab