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",