Skip to content
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions WooCommerce/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<uses-permission android:name="android.permission.ACTION_OPEN_DOCUMENT" />
<uses-permission android:name="android.permission.NFC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
<uses-permission
android:name="com.google.android.gms.permission.AD_ID"
tools:node="remove" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?) {
Expand All @@ -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()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -56,21 +58,36 @@ class CardReaderModeViewModel @Inject constructor(
private var isSimulated = false
private var tracking = SessionTracking()

fun onPermissionsGranted() {
startSessionIfNeeded()
}

fun onLocationPermissionMissing() {
if (sessionStarted) return
_viewState.value = RemoteTapToPayLocationPermissionExplainer(
onPrimaryActionClicked = { _events.trySend(CardReaderModeEvent.RequestLocationPermission) },
)
}

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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Code Review [follow-up]

Issue: When local-network permission is granted (here and in recheckPermissions() on line 214), you reconnect with the phone captured in the MissingLocalNetworkPermission state. While that state is active, shouldIgnoreDiscoveryUpdates() still lets latestDiscoveredPhones refresh, so a phone that re-advertised on a new host/port during the permission prompt leaves the stored address stale. The first connect would then fail and only recover on retry (which does call refreshAddressOf).

Suggestion: Refresh the address before reconnecting, mirroring onPhoneRetryConnectClicked, e.g. onPhoneConnectClicked(latestDiscoveredPhones.refreshAddressOf(currentState.phone)) in both spots.

}
}

fun onBluetoothEnabled() {
checkRequirementsAndStartDiscovery()
}
Expand All @@ -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)
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -417,7 +432,7 @@ class WooPosCardReaderConnectionController(
if (event is WooPosUnifiedDiscoveryEvent.ReadersFound) {
latestDiscoveredPhones = event.readers.filterIsInstance<WooPosDiscoveredReader.Phone>()
}
if (isShowingConnectionOutcome()) return
if (shouldIgnoreDiscoveryUpdates()) return

when (event) {
is WooPosUnifiedDiscoveryEvent.Started -> {
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down Expand Up @@ -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()
) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading