diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt
index bd12c2afb2f..f65cd34d408 100644
--- a/RELEASE-NOTES.txt
+++ b/RELEASE-NOTES.txt
@@ -5,6 +5,7 @@
25.5
-----
- [*****] [Internal] Updated the app's target SDK to Android 17 (API 37) [https://github.com/woocommerce/woocommerce-android/pull/16413]
+- [*] Remote Tap to Pay keeps working on Android 17: the phone now asks for local network permission before it advertises itself, and the tablet asks before it connects to a phone reader
- [*] The Pay In Person toggle on the Payments screen no longer looks turned off when its status could not be loaded
- [Internal] Woo POS: every POS analytics event now carries device_type (phone/tablet) and entry_point, so POS usage can be split by form factor and attributed to where POS was opened from [https://github.com/woocommerce/woocommerce-android/pull/16414]
- [*] Woo POS: Remote Tap to Pay failures now explain what went wrong - phone not eligible for Tap to Pay, NFC turned off, or a payment service error - instead of a generic message or raw error text [https://github.com/woocommerce/woocommerce-android/pull/16384]
diff --git a/WooCommerce/src/main/AndroidManifest.xml b/WooCommerce/src/main/AndroidManifest.xml
index cafbe1ac6ce..e6c7ac65b48 100644
--- a/WooCommerce/src/main/AndroidManifest.xml
+++ b/WooCommerce/src/main/AndroidManifest.xml
@@ -15,6 +15,7 @@
+
diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/payment/RemoteTapToPayPaymentViewState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/payment/RemoteTapToPayPaymentViewState.kt
index e2d6fcd53bf..769e82e0b1c 100644
--- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/payment/RemoteTapToPayPaymentViewState.kt
+++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/payment/RemoteTapToPayPaymentViewState.kt
@@ -32,6 +32,24 @@ data class RemoteTapToPayLocationPermissionDenied(
primaryActionLabel = R.string.card_reader_mode_location_permission_open_settings,
)
+data class RemoteTapToPayLocalNetworkPermissionExplainer(
+ override val onPrimaryActionClicked: (() -> Unit),
+) : ViewState(
+ headerLabel = R.string.card_reader_mode_local_network_permission_header,
+ paymentStateLabel = UiStringRes(R.string.card_reader_mode_local_network_permission_subtitle),
+ illustration = R.drawable.img_card_reader_tpp_connecting,
+ primaryActionLabel = R.string.card_reader_mode_local_network_permission_continue,
+)
+
+data class RemoteTapToPayLocalNetworkPermissionDenied(
+ override val onPrimaryActionClicked: (() -> Unit),
+) : ViewState(
+ headerLabel = R.string.card_reader_mode_local_network_permission_header,
+ paymentStateLabel = UiStringRes(R.string.card_reader_mode_local_network_permission_denied_subtitle),
+ illustration = R.drawable.img_card_reader_tpp_connecting,
+ primaryActionLabel = R.string.card_reader_mode_local_network_permission_open_settings,
+)
+
data class RemoteTapToPayReadyToPair(
val deviceName: String,
val fingerprintSuffix: String,
diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/readermode/CardReaderModeActivity.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/readermode/CardReaderModeActivity.kt
index c29a3bab4f8..927341e3a43 100644
--- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/readermode/CardReaderModeActivity.kt
+++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/readermode/CardReaderModeActivity.kt
@@ -23,10 +23,23 @@ class CardReaderModeActivity : AppCompatActivity() {
private val locationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
- viewModel.onLocationPermissionResult(
- granted = granted,
- shouldShowRationale = WooPermissionUtils.shouldShowFineLocationPermissionRationale(this),
- )
+ when {
+ granted -> checkPermissionsAndStartSession()
+ WooPermissionUtils.shouldShowFineLocationPermissionRationale(this) ->
+ viewModel.onLocationPermissionMissing()
+ else -> viewModel.onLocationPermissionDenied()
+ }
+ }
+
+ private val localNetworkPermissionLauncher = registerForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { granted ->
+ when {
+ granted -> checkPermissionsAndStartSession()
+ WooPermissionUtils.shouldShowLocalNetworkPermissionRationale(this) ->
+ viewModel.onLocalNetworkPermissionMissing()
+ else -> viewModel.onLocalNetworkPermissionDenied()
+ }
}
override fun onCreate(savedInstanceState: Bundle?) {
@@ -46,19 +59,26 @@ class CardReaderModeActivity : AppCompatActivity() {
CardReaderModeEvent.Exit -> finish()
CardReaderModeEvent.RequestLocationPermission ->
locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
+ CardReaderModeEvent.RequestLocalNetworkPermission ->
+ WooPermissionUtils.requestLocalNetworkPermission(localNetworkPermissionLauncher)
CardReaderModeEvent.OpenAppSettings ->
WooPermissionUtils.showAppSettings(this@CardReaderModeActivity, openInNewStack = false)
}
}
}
}
+ }
- if (savedInstanceState == null) {
- if (WooPermissionUtils.hasFineLocationPermission(this)) {
- viewModel.onLocationPermissionResult(granted = true, shouldShowRationale = false)
- } else {
- viewModel.onLocationPermissionMissing()
- }
+ override fun onResume() {
+ super.onResume()
+ checkPermissionsAndStartSession()
+ }
+
+ private fun checkPermissionsAndStartSession() {
+ when {
+ !WooPermissionUtils.hasFineLocationPermission(this) -> viewModel.onLocationPermissionMissing()
+ !WooPermissionUtils.hasLocalNetworkPermission(this) -> viewModel.onLocalNetworkPermissionMissing()
+ else -> viewModel.onPermissionsGranted()
}
}
}
diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/readermode/CardReaderModeEvent.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/readermode/CardReaderModeEvent.kt
index 8ff724c778e..fdcf0e12176 100644
--- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/readermode/CardReaderModeEvent.kt
+++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/readermode/CardReaderModeEvent.kt
@@ -3,5 +3,6 @@ package com.woocommerce.android.ui.payments.cardreader.readermode
sealed class CardReaderModeEvent {
data object Exit : CardReaderModeEvent()
data object RequestLocationPermission : CardReaderModeEvent()
+ data object RequestLocalNetworkPermission : CardReaderModeEvent()
data object OpenAppSettings : CardReaderModeEvent()
}
diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/readermode/CardReaderModeViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/readermode/CardReaderModeViewModel.kt
index d1c1315d857..21611b71994 100644
--- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/readermode/CardReaderModeViewModel.kt
+++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/payments/cardreader/readermode/CardReaderModeViewModel.kt
@@ -14,6 +14,8 @@ import com.woocommerce.android.cardreader.remote.CardReaderRemoteSession
import com.woocommerce.android.cardreader.remote.CardReaderRemoteSessionState
import com.woocommerce.android.tools.SelectedSite
import com.woocommerce.android.ui.payments.cardreader.payment.RemoteTapToPayError
+import com.woocommerce.android.ui.payments.cardreader.payment.RemoteTapToPayLocalNetworkPermissionDenied
+import com.woocommerce.android.ui.payments.cardreader.payment.RemoteTapToPayLocalNetworkPermissionExplainer
import com.woocommerce.android.ui.payments.cardreader.payment.RemoteTapToPayLocationPermissionDenied
import com.woocommerce.android.ui.payments.cardreader.payment.RemoteTapToPayLocationPermissionExplainer
import com.woocommerce.android.ui.payments.cardreader.payment.RemoteTapToPayReadyToPair
@@ -56,6 +58,10 @@ class CardReaderModeViewModel @Inject constructor(
private var isSimulated = false
private var tracking = SessionTracking()
+ fun onPermissionsGranted() {
+ startSessionIfNeeded()
+ }
+
fun onLocationPermissionMissing() {
if (sessionStarted) return
_viewState.value = RemoteTapToPayLocationPermissionExplainer(
@@ -63,14 +69,25 @@ class CardReaderModeViewModel @Inject constructor(
)
}
- fun onLocationPermissionResult(granted: Boolean, shouldShowRationale: Boolean) {
- when {
- granted -> startSessionIfNeeded()
- !shouldShowRationale -> _viewState.value = RemoteTapToPayLocationPermissionDenied(
- onPrimaryActionClicked = { _events.trySend(CardReaderModeEvent.OpenAppSettings) },
- )
- else -> onLocationPermissionMissing()
- }
+ fun onLocationPermissionDenied() {
+ if (sessionStarted) return
+ _viewState.value = RemoteTapToPayLocationPermissionDenied(
+ onPrimaryActionClicked = { _events.trySend(CardReaderModeEvent.OpenAppSettings) },
+ )
+ }
+
+ fun onLocalNetworkPermissionMissing() {
+ if (sessionStarted) return
+ _viewState.value = RemoteTapToPayLocalNetworkPermissionExplainer(
+ onPrimaryActionClicked = { _events.trySend(CardReaderModeEvent.RequestLocalNetworkPermission) },
+ )
+ }
+
+ fun onLocalNetworkPermissionDenied() {
+ if (sessionStarted) return
+ _viewState.value = RemoteTapToPayLocalNetworkPermissionDenied(
+ onPrimaryActionClicked = { _events.trySend(CardReaderModeEvent.OpenAppSettings) },
+ )
}
private fun startSessionIfNeeded() {
diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/connection/WooPosCardReaderConnectionController.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/connection/WooPosCardReaderConnectionController.kt
index 9d66c74cd36..717bfa3b236 100644
--- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/connection/WooPosCardReaderConnectionController.kt
+++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/connection/WooPosCardReaderConnectionController.kt
@@ -88,6 +88,7 @@ class WooPosCardReaderConnectionController(
private var isRequiredUpdate = true
private var isBluetoothPermissionPermanentlyDenied = false
private var isLocationPermissionPermanentlyDenied = false
+ private var isLocalNetworkPermissionPermanentlyDenied = false
private var bluetoothRequirement: BluetoothRequirement = BluetoothRequirement.Satisfied
private var pendingBluetoothFailure: String? = null
@@ -188,6 +189,14 @@ class WooPosCardReaderConnectionController(
checkRequirementsAndStartDiscovery()
}
+ fun onLocalNetworkPermissionResult(granted: Boolean, shouldShowRationale: Boolean) {
+ isLocalNetworkPermissionPermanentlyDenied = !granted && !shouldShowRationale
+ val currentState = _state.value
+ if (granted && currentState is WooPosCardReaderConnectionState.MissingLocalNetworkPermission) {
+ onPhoneConnectClicked(currentState.phone)
+ }
+ }
+
fun onBluetoothEnabled() {
checkRequirementsAndStartDiscovery()
}
@@ -197,8 +206,13 @@ class WooPosCardReaderConnectionController(
}
fun recheckPermissions() {
- if (isShowingBluetoothRequirement()) {
- checkRequirementsAndStartDiscovery()
+ val currentState = _state.value
+ when {
+ isShowingBluetoothRequirement() -> checkRequirementsAndStartDiscovery()
+
+ currentState is WooPosCardReaderConnectionState.MissingLocalNetworkPermission &&
+ WooPermissionUtils.hasLocalNetworkPermission(context) ->
+ onPhoneConnectClicked(currentState.phone)
}
}
@@ -333,6 +347,7 @@ class WooPosCardReaderConnectionController(
data object RequestBluetoothPermission : ControllerEvent
data object RequestEnableBluetooth : ControllerEvent
data object RequestLocationPermission : ControllerEvent
+ data object RequestLocalNetworkPermission : ControllerEvent
data object RequestEnableLocation : ControllerEvent
data object OpenAppSettings : ControllerEvent
data object Cancelled : ControllerEvent
@@ -417,7 +432,7 @@ class WooPosCardReaderConnectionController(
if (event is WooPosUnifiedDiscoveryEvent.ReadersFound) {
latestDiscoveredPhones = event.readers.filterIsInstance()
}
- if (isShowingConnectionOutcome()) return
+ if (shouldIgnoreDiscoveryUpdates()) return
when (event) {
is WooPosUnifiedDiscoveryEvent.Started -> {
@@ -443,9 +458,10 @@ class WooPosCardReaderConnectionController(
}
}
- private fun isShowingConnectionOutcome(): Boolean =
+ private fun shouldIgnoreDiscoveryUpdates(): Boolean =
_state.value is WooPosCardReaderConnectionState.Connecting ||
_state.value is WooPosCardReaderConnectionState.ConnectingFailed ||
+ _state.value is WooPosCardReaderConnectionState.MissingLocalNetworkPermission ||
_state.value is Connected
private fun handleReadersFound(
@@ -559,6 +575,21 @@ class WooPosCardReaderConnectionController(
private fun onPhoneConnectClicked(phone: WooPosDiscoveredReader.Phone) {
if (_state.value is WooPosCardReaderConnectionState.Connecting) return
+ if (!WooPermissionUtils.hasLocalNetworkPermission(context)) {
+ logger.d("Local network permission not granted")
+ _state.value = WooPosCardReaderConnectionState.MissingLocalNetworkPermission(
+ phone = phone,
+ onRequestPermissionClicked = {
+ if (isLocalNetworkPermissionPermanentlyDenied) {
+ emitEvent(ControllerEvent.OpenAppSettings)
+ } else {
+ emitEvent(ControllerEvent.RequestLocalNetworkPermission)
+ }
+ },
+ onCancelClicked = { cancel() },
+ )
+ return
+ }
markPhoneReaderSelected()
tracker.trackOnConnectTapped()
selectedReader = null
diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/connection/WooPosCardReaderConnectionDialog.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/connection/WooPosCardReaderConnectionDialog.kt
index a9ee9c80b4b..2b6c313ce46 100644
--- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/connection/WooPosCardReaderConnectionDialog.kt
+++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/connection/WooPosCardReaderConnectionDialog.kt
@@ -62,6 +62,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.repeatOnLifecycle
import com.woocommerce.android.R
import com.woocommerce.android.ui.woopos.cardreader.WooPosCardReaderOnboardingActivity
+import com.woocommerce.android.ui.woopos.cardreader.remote.WooPosDiscoveredReader
import com.woocommerce.android.ui.woopos.cardreader.remote.WooPosDiscoveryTransport
import com.woocommerce.android.ui.woopos.cardreader.remote.WooPosRemoteReaderExplainerContent
import com.woocommerce.android.ui.woopos.cardreader.remote.WooPosRemoteReaderHintStrip
@@ -82,7 +83,9 @@ import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosThe
import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosTypography
import com.woocommerce.android.ui.woopos.common.composeui.designsystem.currentWooPosBreakpoint
import com.woocommerce.android.util.ChromeCustomTabUtils
+import com.woocommerce.android.util.WooPermissionUtils
import kotlinx.coroutines.delay
+import java.net.InetAddress
@Composable
fun WooPosCardReaderConnectionDialog(
@@ -151,6 +154,13 @@ private fun WooPosCardReaderDialogInternal(
viewModel.onLocationPermissionResult(granted, shouldShowRationale)
}
+ val localNetworkPermissionLauncher = rememberLauncherForActivityResult(
+ ActivityResultContracts.RequestPermission()
+ ) { granted ->
+ val shouldShowRationale = WooPermissionUtils.shouldShowLocalNetworkPermissionRationale(context as Activity)
+ viewModel.onLocalNetworkPermissionResult(granted, shouldShowRationale)
+ }
+
val locationSettingsLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
@@ -216,6 +226,9 @@ private fun WooPosCardReaderDialogInternal(
WooPosCardReaderConnectionViewModel.Event.RequestLocationPermission -> {
locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION)
}
+ WooPosCardReaderConnectionViewModel.Event.RequestLocalNetworkPermission -> {
+ WooPermissionUtils.requestLocalNetworkPermission(localNetworkPermissionLauncher)
+ }
WooPosCardReaderConnectionViewModel.Event.RequestEnableLocation -> {
val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS)
locationSettingsLauncher.launch(intent)
@@ -359,6 +372,15 @@ fun WooPosCardReaderConnectionDialogContent(
onHintClick = onHintClick,
)
}
+ is WooPosCardReaderConnectionState.MissingLocalNetworkPermission -> {
+ MissingPermissionContent(
+ title = stringResource(R.string.woopos_card_reader_local_network_permission_title),
+ message = stringResource(R.string.woopos_card_reader_local_network_permission_message),
+ onRequestPermissionClicked = currentState.onRequestPermissionClicked,
+ onCancelClicked = currentState.onCancelClicked,
+ onHintClick = onHintClick,
+ )
+ }
is WooPosCardReaderConnectionState.MissingBluetoothPermission -> {
MissingPermissionContent(
title = stringResource(R.string.woopos_card_reader_bluetooth_permission_title),
@@ -1310,6 +1332,31 @@ fun WooPosCardReaderConnectionDialogMissingBluetoothPermissionPreview() {
}
}
+@WooPosPreview
+@Composable
+fun WooPosCardReaderConnectionDialogMissingLocalNetworkPermissionPreview() {
+ WooPosTheme {
+ WooPosCardReaderConnectionDialogContent(
+ isVisible = true,
+ state = WooPosCardReaderConnectionState.MissingLocalNetworkPermission(
+ phone = WooPosDiscoveredReader.Phone(
+ serviceName = "woopos-remote-1a2b",
+ deviceId = "device-id",
+ name = "Pixel 9",
+ host = InetAddress.getLoopbackAddress(),
+ port = 8443,
+ fingerprintBase64 = "AAAA",
+ siteHash = "site-hash",
+ ),
+ onRequestPermissionClicked = {},
+ onCancelClicked = {},
+ ),
+ onBackPressed = {},
+ onDismiss = {},
+ )
+ }
+}
+
@WooPosPreview
@Composable
fun WooPosCardReaderConnectionDialogMissingLocationPermissionPreview() {
diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/connection/WooPosCardReaderConnectionState.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/connection/WooPosCardReaderConnectionState.kt
index 7af827e078d..8614cabc2bf 100644
--- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/connection/WooPosCardReaderConnectionState.kt
+++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/connection/WooPosCardReaderConnectionState.kt
@@ -1,5 +1,6 @@
package com.woocommerce.android.ui.woopos.cardreader.connection
+import com.woocommerce.android.ui.woopos.cardreader.remote.WooPosDiscoveredReader
import com.woocommerce.android.ui.woopos.cardreader.remote.WooPosDiscoveryTransport
sealed interface WooPosCardReaderConnectionState {
@@ -51,6 +52,14 @@ sealed interface WooPosCardReaderConnectionState {
override val showCloseButton: Boolean = false
}
+ data class MissingLocalNetworkPermission(
+ val phone: WooPosDiscoveredReader.Phone,
+ val onRequestPermissionClicked: () -> Unit,
+ val onCancelClicked: () -> Unit,
+ ) : WooPosCardReaderConnectionState {
+ override val showCloseButton: Boolean = false
+ }
+
data class MissingBluetoothPermission(
val onRequestPermissionClicked: () -> Unit,
val onCancelClicked: () -> Unit,
diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/connection/WooPosCardReaderConnectionViewModel.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/connection/WooPosCardReaderConnectionViewModel.kt
index 445e744cc74..f5590496770 100644
--- a/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/connection/WooPosCardReaderConnectionViewModel.kt
+++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/ui/woopos/cardreader/connection/WooPosCardReaderConnectionViewModel.kt
@@ -40,6 +40,9 @@ class WooPosCardReaderConnectionViewModel @Inject constructor(
WooPosCardReaderConnectionController.ControllerEvent.RequestLocationPermission -> {
_event.emit(Event.RequestLocationPermission)
}
+ WooPosCardReaderConnectionController.ControllerEvent.RequestLocalNetworkPermission -> {
+ _event.emit(Event.RequestLocalNetworkPermission)
+ }
WooPosCardReaderConnectionController.ControllerEvent.RequestEnableLocation -> {
_event.emit(Event.RequestEnableLocation)
}
@@ -98,6 +101,7 @@ class WooPosCardReaderConnectionViewModel @Inject constructor(
is WooPosCardReaderConnectionState.InvalidPostalCode,
is WooPosCardReaderConnectionState.LocationDisabled,
is WooPosCardReaderConnectionState.MissingBluetoothPermission,
+ is WooPosCardReaderConnectionState.MissingLocalNetworkPermission,
is WooPosCardReaderConnectionState.MissingLocationPermission,
is WooPosCardReaderConnectionState.MultipleReadersFound,
is WooPosCardReaderConnectionState.ReaderFound,
@@ -119,6 +123,10 @@ class WooPosCardReaderConnectionViewModel @Inject constructor(
controller.onLocationPermissionResult(granted, shouldShowRationale)
}
+ fun onLocalNetworkPermissionResult(granted: Boolean, shouldShowRationale: Boolean) {
+ controller.onLocalNetworkPermissionResult(granted, shouldShowRationale)
+ }
+
fun onLocationEnabled() {
controller.onLocationEnabled()
}
@@ -139,6 +147,7 @@ class WooPosCardReaderConnectionViewModel @Inject constructor(
data object RequestBluetoothPermission : Event
data object RequestEnableBluetooth : Event
data object RequestLocationPermission : Event
+ data object RequestLocalNetworkPermission : Event
data object RequestEnableLocation : Event
data object Dismissed : Event
data class NavigateToOnboarding(val onboardingState: CardReaderOnboardingState) : Event
diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/SystemVersionUtils.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/SystemVersionUtils.kt
index efad9fa06ca..9079c1f6b71 100644
--- a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/SystemVersionUtils.kt
+++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/SystemVersionUtils.kt
@@ -5,6 +5,9 @@ import androidx.annotation.ChecksSdkIntAtLeast
@Suppress("Unused", "TooManyFunctions")
object SystemVersionUtils {
+ @ChecksSdkIntAtLeast(Build.VERSION_CODES.CINNAMON_BUN)
+ fun isAtLeastCinnamonBun() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN
+
@ChecksSdkIntAtLeast(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
fun isAtLeastU() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
fun isAtMostU() = Build.VERSION.SDK_INT <= Build.VERSION_CODES.UPSIDE_DOWN_CAKE
diff --git a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/WooPermissionUtils.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/WooPermissionUtils.kt
index 9987cd61429..edf274772b9 100644
--- a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/WooPermissionUtils.kt
+++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/WooPermissionUtils.kt
@@ -1,9 +1,11 @@
package com.woocommerce.android.util
import android.Manifest.permission.ACCESS_FINE_LOCATION
+import android.Manifest.permission.ACCESS_LOCAL_NETWORK
import android.Manifest.permission.BLUETOOTH_CONNECT
import android.Manifest.permission.BLUETOOTH_SCAN
import android.Manifest.permission.POST_NOTIFICATIONS
+import android.annotation.SuppressLint
import android.app.Activity
import android.content.Context
import android.content.Intent
@@ -48,6 +50,22 @@ object WooPermissionUtils {
return ActivityCompat.shouldShowRequestPermissionRationale(activity, ACCESS_FINE_LOCATION)
}
+ @SuppressLint("InlinedApi")
+ fun hasLocalNetworkPermission(context: Context) =
+ !SystemVersionUtils.isAtLeastCinnamonBun() || context.checkIfPermissionGiven(ACCESS_LOCAL_NETWORK)
+
+ @SuppressLint("InlinedApi")
+ fun shouldShowLocalNetworkPermissionRationale(activity: Activity): Boolean {
+ if (hasLocalNetworkPermission(activity)) return false
+
+ return ActivityCompat.shouldShowRequestPermissionRationale(activity, ACCESS_LOCAL_NETWORK)
+ }
+
+ @SuppressLint("InlinedApi")
+ fun requestLocalNetworkPermission(launcher: ActivityResultLauncher) {
+ launcher.launch(ACCESS_LOCAL_NETWORK)
+ }
+
fun hasBluetoothScanPermission(context: Context) =
SystemVersionUtils.isAtMostR() || context.checkIfPermissionGiven(BLUETOOTH_SCAN)
diff --git a/WooCommerce/src/main/res/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml
index fe460340852..4486319e042 100644
--- a/WooCommerce/src/main/res/values/strings.xml
+++ b/WooCommerce/src/main/res/values/strings.xml
@@ -1670,6 +1670,11 @@
Continue
Location permission is blocked. Open Settings and grant Location to use tap-to-pay on this phone.
Open Settings
+ Local network permission required
+ Android requires local network access so your tablet can find this phone. Nothing is sent outside your network.
+ Continue
+ Local network permission is blocked. Open Settings and grant local network access to use tap-to-pay on this phone.
+ Open Settings
The app could not enable the card reader, because the NFC chip is disabled
Enable NFC
@@ -4392,6 +4397,8 @@
Location permission is required to connect to a card reader via Bluetooth.
Bluetooth permission required
Bluetooth permission is required to connect to a card reader.
+ Local network permission required
+ Local network permission is required to connect to a phone used as a card reader.
Grant permission
Allow Bluetooth to see card readers
Turn on Bluetooth to see card readers
diff --git a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/payments/cardreader/readermode/CardReaderModeViewModelTest.kt b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/payments/cardreader/readermode/CardReaderModeViewModelTest.kt
index c23b622c17a..9b54e9a2e4a 100644
--- a/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/payments/cardreader/readermode/CardReaderModeViewModelTest.kt
+++ b/WooCommerce/src/test/kotlin/com/woocommerce/android/ui/payments/cardreader/readermode/CardReaderModeViewModelTest.kt
@@ -14,6 +14,8 @@ import com.woocommerce.android.cardreader.remote.CardReaderRemoteSession
import com.woocommerce.android.cardreader.remote.CardReaderRemoteSessionState
import com.woocommerce.android.tools.SelectedSite
import com.woocommerce.android.ui.payments.cardreader.payment.RemoteTapToPayError
+import com.woocommerce.android.ui.payments.cardreader.payment.RemoteTapToPayLocalNetworkPermissionDenied
+import com.woocommerce.android.ui.payments.cardreader.payment.RemoteTapToPayLocalNetworkPermissionExplainer
import com.woocommerce.android.ui.payments.cardreader.payment.RemoteTapToPayLocationPermissionDenied
import com.woocommerce.android.ui.payments.cardreader.payment.RemoteTapToPayLocationPermissionExplainer
import com.woocommerce.android.ui.payments.cardreader.payment.RemoteTapToPayReadyToPair
@@ -95,28 +97,28 @@ class CardReaderModeViewModelTest : BaseUnitTest() {
}
@Test
- fun `given location permission granted, when result received, then session started`() {
+ fun `given all permissions granted, when reported, then session started`() {
// WHEN
- viewModel.onLocationPermissionResult(granted = true, shouldShowRationale = false)
+ viewModel.onPermissionsGranted()
// THEN
verify(session).start(any(), any(), any(), any())
}
@Test
- fun `given location permission granted twice, when received, then session started only once`() {
+ fun `given all permissions granted twice, when reported, then session started only once`() {
// WHEN
- viewModel.onLocationPermissionResult(granted = true, shouldShowRationale = false)
- viewModel.onLocationPermissionResult(granted = true, shouldShowRationale = false)
+ viewModel.onPermissionsGranted()
+ viewModel.onPermissionsGranted()
// THEN
verify(session, times(1)).start(any(), any(), any(), any())
}
@Test
- fun `given denied with rationale, when result received, then explainer state is shown`() = testBlocking {
+ fun `when location permission is missing, then location explainer state is shown`() = testBlocking {
// WHEN
- viewModel.onLocationPermissionResult(granted = false, shouldShowRationale = true)
+ viewModel.onLocationPermissionMissing()
advanceUntilIdle()
// THEN
@@ -125,9 +127,9 @@ class CardReaderModeViewModelTest : BaseUnitTest() {
}
@Test
- fun `given permanently denied, when result received, then permission denied state is shown`() = testBlocking {
+ fun `when location permission is permanently denied, then location denied state is shown`() = testBlocking {
// WHEN
- viewModel.onLocationPermissionResult(granted = false, shouldShowRationale = false)
+ viewModel.onLocationPermissionDenied()
advanceUntilIdle()
// THEN
@@ -135,10 +137,33 @@ class CardReaderModeViewModelTest : BaseUnitTest() {
verify(session, never()).start(any(), any(), any(), any())
}
+ @Test
+ fun `when local network permission is missing, then local network explainer state is shown`() = testBlocking {
+ // WHEN
+ viewModel.onLocalNetworkPermissionMissing()
+ advanceUntilIdle()
+
+ // THEN
+ assertThat(viewModel.viewState.value).isInstanceOf(RemoteTapToPayLocalNetworkPermissionExplainer::class.java)
+ verify(session, never()).start(any(), any(), any(), any())
+ }
+
+ @Test
+ fun `when local network permission is permanently denied, then local network denied state is shown`() =
+ testBlocking {
+ // WHEN
+ viewModel.onLocalNetworkPermissionDenied()
+ advanceUntilIdle()
+
+ // THEN
+ assertThat(viewModel.viewState.value).isInstanceOf(RemoteTapToPayLocalNetworkPermissionDenied::class.java)
+ verify(session, never()).start(any(), any(), any(), any())
+ }
+
@Test
fun `given starting session state, when emitted, then starting view state is shown`() = testBlocking {
// GIVEN
- viewModel.onLocationPermissionResult(granted = true, shouldShowRationale = false)
+ viewModel.onPermissionsGranted()
// WHEN
sessionState.value = CardReaderRemoteSessionState.Starting
@@ -151,7 +176,7 @@ class CardReaderModeViewModelTest : BaseUnitTest() {
@Test
fun `given ready to pair session state, when emitted, then ready to pair view state is shown`() = testBlocking {
// GIVEN
- viewModel.onLocationPermissionResult(granted = true, shouldShowRationale = false)
+ viewModel.onPermissionsGranted()
// WHEN
sessionState.value = CardReaderRemoteSessionState.ReadyToPair(
@@ -171,7 +196,7 @@ class CardReaderModeViewModelTest : BaseUnitTest() {
fun `given waiting for payment session state, when emitted, then waiting for payment view state is shown`() =
testBlocking {
// GIVEN
- viewModel.onLocationPermissionResult(granted = true, shouldShowRationale = false)
+ viewModel.onPermissionsGranted()
// WHEN
sessionState.value = CardReaderRemoteSessionState.WaitingForPayment(tabletName = "Tablet 1")
@@ -185,7 +210,7 @@ class CardReaderModeViewModelTest : BaseUnitTest() {
@Test
fun `given an unmapped error, when emitted, then the raw message is not shown to the user`() = testBlocking {
// GIVEN
- viewModel.onLocationPermissionResult(granted = true, shouldShowRationale = false)
+ viewModel.onPermissionsGranted()
// WHEN
sessionState.value = CardReaderRemoteSessionState.Error(
@@ -202,7 +227,7 @@ class CardReaderModeViewModelTest : BaseUnitTest() {
@Test
fun `given the phone is not eligible, when emitted, then the mapped copy is shown`() = testBlocking {
// GIVEN
- viewModel.onLocationPermissionResult(granted = true, shouldShowRationale = false)
+ viewModel.onPermissionsGranted()
// WHEN
sessionState.value = CardReaderRemoteSessionState.Error(
@@ -219,7 +244,7 @@ class CardReaderModeViewModelTest : BaseUnitTest() {
@Test
fun `given starting view state, when cancel clicked, then exit event is emitted`() = testBlocking {
// GIVEN
- viewModel.onLocationPermissionResult(granted = true, shouldShowRationale = false)
+ viewModel.onPermissionsGranted()
sessionState.value = CardReaderRemoteSessionState.Starting
advanceUntilIdle()
@@ -243,7 +268,7 @@ class CardReaderModeViewModelTest : BaseUnitTest() {
fun `given session transitions to ReadyToPair, when emitted, then session started event is tracked`() =
testBlocking {
// GIVEN
- viewModel.onLocationPermissionResult(granted = true, shouldShowRationale = false)
+ viewModel.onPermissionsGranted()
// WHEN
sessionState.value = CardReaderRemoteSessionState.ReadyToPair(
@@ -264,7 +289,7 @@ class CardReaderModeViewModelTest : BaseUnitTest() {
testBlocking {
// GIVEN
whenever(session.certificateKeyType).thenReturn(CardReaderRemoteCertificateKeyType.RSA_2048)
- viewModel.onLocationPermissionResult(granted = true, shouldShowRationale = false)
+ viewModel.onPermissionsGranted()
// WHEN
sessionState.value = CardReaderRemoteSessionState.ReadyToPair(
@@ -284,7 +309,7 @@ class CardReaderModeViewModelTest : BaseUnitTest() {
fun `given the session errors, when tracked, then the error description carries the cause chain`() =
testBlocking {
// GIVEN
- viewModel.onLocationPermissionResult(granted = true, shouldShowRationale = false)
+ viewModel.onPermissionsGranted()
// WHEN
sessionState.value = CardReaderRemoteSessionState.Error(
@@ -311,7 +336,7 @@ class CardReaderModeViewModelTest : BaseUnitTest() {
fun `given a tablet connected then the session errored, when cleared, then error reason is reported`() =
testBlocking {
// GIVEN
- viewModel.onLocationPermissionResult(granted = true, shouldShowRationale = false)
+ viewModel.onPermissionsGranted()
sessionState.value = CardReaderRemoteSessionState.WaitingForPayment(tabletName = "iPad")
advanceUntilIdle()
sessionState.value = CardReaderRemoteSessionState.Error(
@@ -340,7 +365,7 @@ class CardReaderModeViewModelTest : BaseUnitTest() {
fun `given no tablet ever connected, when cleared without an explicit exit, then dismissed is reported`() =
testBlocking {
// GIVEN
- viewModel.onLocationPermissionResult(granted = true, shouldShowRationale = false)
+ viewModel.onPermissionsGranted()
sessionState.value = CardReaderRemoteSessionState.ReadyToPair(
deviceName = "Pixel",
fingerprintSuffix = "1234",
@@ -366,7 +391,7 @@ class CardReaderModeViewModelTest : BaseUnitTest() {
@Test
fun `given the user taps the exit action, when cleared, then user exit reason is reported`() = testBlocking {
// GIVEN
- viewModel.onLocationPermissionResult(granted = true, shouldShowRationale = false)
+ viewModel.onPermissionsGranted()
sessionState.value = CardReaderRemoteSessionState.ReadyToPair(
deviceName = "Pixel",
fingerprintSuffix = "1234",