From db57773addc009ba58e017ecb884ac19080d15f9 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 14 Aug 2026 13:38:29 +0200 Subject: [PATCH 1/6] Add the Android 17 local network permission and its checks --- WooCommerce/src/main/AndroidManifest.xml | 1 + .../com/woocommerce/android/util/SystemVersionUtils.kt | 3 +++ .../com/woocommerce/android/util/WooPermissionUtils.kt | 10 ++++++++++ 3 files changed, 14 insertions(+) diff --git a/WooCommerce/src/main/AndroidManifest.xml b/WooCommerce/src/main/AndroidManifest.xml index cafbe1ac6ce3..e6c7ac65b485 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/util/SystemVersionUtils.kt b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/SystemVersionUtils.kt index efad9fa06ca3..9079c1f6b715 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 9987cd614296..a904dd1b90ac 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/WooPermissionUtils.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/WooPermissionUtils.kt @@ -1,6 +1,7 @@ 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 @@ -48,6 +49,15 @@ object WooPermissionUtils { return ActivityCompat.shouldShowRequestPermissionRationale(activity, ACCESS_FINE_LOCATION) } + fun hasLocalNetworkPermission(context: Context) = + !SystemVersionUtils.isAtLeastCinnamonBun() || context.checkIfPermissionGiven(ACCESS_LOCAL_NETWORK) + + fun shouldShowLocalNetworkPermissionRationale(activity: Activity): Boolean { + if (hasLocalNetworkPermission(activity)) return false + + return ActivityCompat.shouldShowRequestPermissionRationale(activity, ACCESS_LOCAL_NETWORK) + } + fun hasBluetoothScanPermission(context: Context) = SystemVersionUtils.isAtMostR() || context.checkIfPermissionGiven(BLUETOOTH_SCAN) From 462fd6b665fa740fb7bee67e2bbfa4008fe47799 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 14 Aug 2026 13:38:39 +0200 Subject: [PATCH 2/6] Ask for local network permission before the phone advertises itself --- .../payment/RemoteTapToPayPaymentViewState.kt | 18 +++++ .../readermode/CardReaderModeActivity.kt | 37 ++++++++--- .../readermode/CardReaderModeEvent.kt | 1 + .../readermode/CardReaderModeViewModel.kt | 33 +++++++--- WooCommerce/src/main/res/values/strings.xml | 7 ++ .../readermode/CardReaderModeViewModelTest.kt | 65 +++++++++++++------ 6 files changed, 124 insertions(+), 37 deletions(-) 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 e2d6fcd53bf8..769e82e0b1c1 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 c29a3bab4f8e..53739907d694 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,6 +59,8 @@ class CardReaderModeActivity : AppCompatActivity() { CardReaderModeEvent.Exit -> finish() CardReaderModeEvent.RequestLocationPermission -> locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) + CardReaderModeEvent.RequestLocalNetworkPermission -> + localNetworkPermissionLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) CardReaderModeEvent.OpenAppSettings -> WooPermissionUtils.showAppSettings(this@CardReaderModeActivity, openInNewStack = false) } @@ -54,11 +69,15 @@ class CardReaderModeActivity : AppCompatActivity() { } if (savedInstanceState == null) { - if (WooPermissionUtils.hasFineLocationPermission(this)) { - viewModel.onLocationPermissionResult(granted = true, shouldShowRationale = false) - } else { - viewModel.onLocationPermissionMissing() - } + 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 8ff724c778e6..fdcf0e12176b 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 aa4231e64b95..7188268f3993 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 @@ -12,6 +12,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 @@ -52,6 +54,10 @@ class CardReaderModeViewModel @Inject constructor( private var isSimulated = false private var tracking = SessionTracking() + fun onPermissionsGranted() { + startSessionIfNeeded() + } + fun onLocationPermissionMissing() { if (sessionStarted) return _viewState.value = RemoteTapToPayLocationPermissionExplainer( @@ -59,14 +65,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/res/values/strings.xml b/WooCommerce/src/main/res/values/strings.xml index 78cb980b22a6..b334acc57a03 100644 --- a/WooCommerce/src/main/res/values/strings.xml +++ b/WooCommerce/src/main/res/values/strings.xml @@ -1657,6 +1657,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 @@ -4370,6 +4375,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 Invalid merchant address Your store address is missing or incomplete. Please update your store address in WooCommerce settings. 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 71d5fe17f3d3..155d42e8c75c 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 @@ -12,6 +12,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 @@ -87,28 +89,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 @@ -117,9 +119,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 @@ -127,10 +129,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 @@ -143,7 +168,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( @@ -163,7 +188,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") @@ -177,7 +202,7 @@ class CardReaderModeViewModelTest : BaseUnitTest() { @Test fun `given error session state, when emitted, then error view state carries the message`() = testBlocking { // GIVEN - viewModel.onLocationPermissionResult(granted = true, shouldShowRationale = false) + viewModel.onPermissionsGranted() // WHEN sessionState.value = CardReaderRemoteSessionState.Error(message = "java.net.SocketException: closed") @@ -191,7 +216,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() @@ -215,7 +240,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( @@ -236,7 +261,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( @@ -256,7 +281,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( @@ -282,7 +307,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(message = "boom") @@ -308,7 +333,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", @@ -334,7 +359,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", From 2a2e2b66076fcdfd8a5dcbaea4bc5e9aaf3ce9ab Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 14 Aug 2026 13:38:51 +0200 Subject: [PATCH 3/6] Ask for local network permission before connecting to a phone reader --- .../WooPosCardReaderConnectionController.kt | 42 +++++++++++++--- .../WooPosCardReaderConnectionDialog.kt | 48 +++++++++++++++++++ .../WooPosCardReaderConnectionState.kt | 9 ++++ .../WooPosCardReaderConnectionViewModel.kt | 9 ++++ 4 files changed, 102 insertions(+), 6 deletions(-) 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 c8e53583a446..56d75b1dc4ba 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 @@ -87,6 +87,7 @@ class WooPosCardReaderConnectionController( private var isRequiredUpdate = true private var isBluetoothPermissionPermanentlyDenied = false private var isLocationPermissionPermanentlyDenied = false + private var isLocalNetworkPermissionPermanentlyDenied = false fun showRemoteTapToPayExplainer() { if (_state.value !is WooPosCardReaderConnectionState.Scanning) return @@ -185,6 +186,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() } @@ -195,10 +204,14 @@ class WooPosCardReaderConnectionController( fun recheckPermissions() { val currentState = _state.value - if (currentState is WooPosCardReaderConnectionState.MissingBluetoothPermission || - currentState is WooPosCardReaderConnectionState.MissingLocationPermission - ) { - checkRequirementsAndStartDiscovery() + when { + currentState is WooPosCardReaderConnectionState.MissingBluetoothPermission || + currentState is WooPosCardReaderConnectionState.MissingLocationPermission -> + checkRequirementsAndStartDiscovery() + + currentState is WooPosCardReaderConnectionState.MissingLocalNetworkPermission && + WooPermissionUtils.hasLocalNetworkPermission(context) -> + onPhoneConnectClicked(currentState.phone) } } @@ -267,6 +280,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 @@ -349,7 +363,7 @@ class WooPosCardReaderConnectionController( if (event is WooPosUnifiedDiscoveryEvent.ReadersFound) { latestDiscoveredPhones = event.readers.filterIsInstance() } - if (isShowingConnectionOutcome()) return + if (shouldIgnoreDiscoveryUpdates()) return when (event) { is WooPosUnifiedDiscoveryEvent.Started -> { @@ -379,9 +393,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( @@ -473,6 +488,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 1cf14c7bb137..13a691604155 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 @@ -61,6 +61,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,6 +83,7 @@ import com.woocommerce.android.ui.woopos.common.composeui.designsystem.WooPosTyp import com.woocommerce.android.ui.woopos.common.composeui.designsystem.currentWooPosBreakpoint import com.woocommerce.android.util.ChromeCustomTabUtils import kotlinx.coroutines.delay +import java.net.InetAddress @Composable fun WooPosCardReaderConnectionDialog( @@ -150,6 +152,16 @@ private fun WooPosCardReaderDialogInternal( viewModel.onLocationPermissionResult(granted, shouldShowRationale) } + val localNetworkPermissionLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + val shouldShowRationale = ActivityCompat.shouldShowRequestPermissionRationale( + context as Activity, + Manifest.permission.ACCESS_LOCAL_NETWORK + ) + viewModel.onLocalNetworkPermissionResult(granted, shouldShowRationale) + } + val locationSettingsLauncher = rememberLauncherForActivityResult( ActivityResultContracts.StartActivityForResult() ) { @@ -215,6 +227,9 @@ private fun WooPosCardReaderDialogInternal( WooPosCardReaderConnectionViewModel.Event.RequestLocationPermission -> { locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) } + WooPosCardReaderConnectionViewModel.Event.RequestLocalNetworkPermission -> { + localNetworkPermissionLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) + } WooPosCardReaderConnectionViewModel.Event.RequestEnableLocation -> { val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS) locationSettingsLauncher.launch(intent) @@ -353,6 +368,14 @@ fun WooPosCardReaderConnectionDialogContent( onCancelClicked = currentState.onCancelClicked, ) } + 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, + ) + } is WooPosCardReaderConnectionState.MissingBluetoothPermission -> { MissingPermissionContent( title = stringResource(R.string.woopos_card_reader_bluetooth_permission_title), @@ -1218,6 +1241,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 5aaf6457de2e..76c5f001f855 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 445e744cc74e..f55904967708 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 From 917bbe3e054c13a400fc51b1f494d49a4cb7abb6 Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 14 Aug 2026 13:38:52 +0200 Subject: [PATCH 4/6] Add release note for the Android 17 local network permission --- RELEASE-NOTES.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/RELEASE-NOTES.txt b/RELEASE-NOTES.txt index 46688e3a279d..b453baf23c21 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 - [*] QR login now asks you to choose a store when your WordPress.com account has multiple sites instead of preselecting one [https://github.com/woocommerce/woocommerce-android/pull/16400] - [Internal] Close analytics gaps in the Remote Tap to Pay flow: transport on the reader-ready event, cause chains in error descriptions, no stale card reader model, and a real session ended reason [https://github.com/woocommerce/woocommerce-android/pull/16368] - [*] Tap to Pay no longer disappears from the Payments screen shortly after it appears [https://github.com/woocommerce/woocommerce-android/pull/16370] From 3e24f7806db9074be72d78f67745fc9c5305222d Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 14 Aug 2026 13:54:11 +0200 Subject: [PATCH 5/6] Recheck reader mode permissions on resume so granting in Settings continues --- .../cardreader/readermode/CardReaderModeActivity.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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 53739907d694..9247984d0bc6 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 @@ -67,10 +67,11 @@ class CardReaderModeActivity : AppCompatActivity() { } } } + } - if (savedInstanceState == null) { - checkPermissionsAndStartSession() - } + override fun onResume() { + super.onResume() + checkPermissionsAndStartSession() } private fun checkPermissionsAndStartSession() { From 680fa9a9ab794cdd7c128df8614ad7655d21dbfe Mon Sep 17 00:00:00 2001 From: Andrey Date: Fri, 14 Aug 2026 17:10:46 +0200 Subject: [PATCH 6/6] Keep the local network permission constant behind WooPermissionUtils --- .../cardreader/readermode/CardReaderModeActivity.kt | 2 +- .../connection/WooPosCardReaderConnectionDialog.kt | 8 +++----- .../com/woocommerce/android/util/WooPermissionUtils.kt | 8 ++++++++ 3 files changed, 12 insertions(+), 6 deletions(-) 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 9247984d0bc6..927341e3a43f 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 @@ -60,7 +60,7 @@ class CardReaderModeActivity : AppCompatActivity() { CardReaderModeEvent.RequestLocationPermission -> locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) CardReaderModeEvent.RequestLocalNetworkPermission -> - localNetworkPermissionLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) + WooPermissionUtils.requestLocalNetworkPermission(localNetworkPermissionLauncher) CardReaderModeEvent.OpenAppSettings -> WooPermissionUtils.showAppSettings(this@CardReaderModeActivity, openInNewStack = false) } 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 13a691604155..70a6aa45e213 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 @@ -82,6 +82,7 @@ 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 @@ -155,10 +156,7 @@ private fun WooPosCardReaderDialogInternal( val localNetworkPermissionLauncher = rememberLauncherForActivityResult( ActivityResultContracts.RequestPermission() ) { granted -> - val shouldShowRationale = ActivityCompat.shouldShowRequestPermissionRationale( - context as Activity, - Manifest.permission.ACCESS_LOCAL_NETWORK - ) + val shouldShowRationale = WooPermissionUtils.shouldShowLocalNetworkPermissionRationale(context as Activity) viewModel.onLocalNetworkPermissionResult(granted, shouldShowRationale) } @@ -228,7 +226,7 @@ private fun WooPosCardReaderDialogInternal( locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) } WooPosCardReaderConnectionViewModel.Event.RequestLocalNetworkPermission -> { - localNetworkPermissionLauncher.launch(Manifest.permission.ACCESS_LOCAL_NETWORK) + WooPermissionUtils.requestLocalNetworkPermission(localNetworkPermissionLauncher) } WooPosCardReaderConnectionViewModel.Event.RequestEnableLocation -> { val intent = Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS) 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 a904dd1b90ac..edf274772b95 100644 --- a/WooCommerce/src/main/kotlin/com/woocommerce/android/util/WooPermissionUtils.kt +++ b/WooCommerce/src/main/kotlin/com/woocommerce/android/util/WooPermissionUtils.kt @@ -5,6 +5,7 @@ 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 @@ -49,15 +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)