Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Geolocation] Provide default implementation for geolocation callback #177

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ val processor = object : DefaultCheckoutEventProcessor(activity) {
```

> [!Note]
> The `DefaultCheckoutEventProcessor` provides default implementations for current and future callback functions (such as `onLinkClicked()`), which can be overridden by clients wanting to change default behavior.
> The `DefaultCheckoutEventProcessor` provides default implementations for current and future callback functions (such as `onCheckoutLinkClicked()`), which can be overridden by clients wanting to change default behavior.

### Error handling

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@
*/
package com.shopify.checkoutsheetkit

import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.webkit.GeolocationPermissions
import android.webkit.PermissionRequest
Expand Down Expand Up @@ -141,6 +143,11 @@ public abstract class DefaultCheckoutEventProcessor @JvmOverloads constructor(
private val log: LogWrapper = LogWrapper(),
) : CheckoutEventProcessor {

private val LOCATION_PERMISSIONS: Array<String> = arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)

override fun onCheckoutLinkClicked(uri: Uri) {
when (uri.scheme) {
"tel" -> context.launchPhoneApp(uri.schemeSpecificPart)
Expand All @@ -166,14 +173,51 @@ public abstract class DefaultCheckoutEventProcessor @JvmOverloads constructor(
return false
}

override fun onGeolocationPermissionsShowPrompt(origin: String, callback: GeolocationPermissions.Callback) {
// no-op override to implement
/**
* Called when the webview requests location permissions. For example when using 'Use my location' to locate pickup points.
* The default implementation here will check for both manifest and runtime permissions. If both have been granted,
* permission will be granted to present the location prompt to the user.
*
* Runtime permissions must be requested by your host app. The Checkout Sheet kit cannot request them on your behalf.
*/
override fun onGeolocationPermissionsShowPrompt(
origin: String,
callback: GeolocationPermissions.Callback
) {
// Check manifest permissions
val manifestPermissions = try {
context.packageManager
.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS)
.requestedPermissions
?.toSet() ?: emptySet()
} catch (e: Exception) {
emptySet()
}

// Check if either permission is declared in manifest
val hasManifestPermission = LOCATION_PERMISSIONS.any { permission ->
manifestPermissions.contains(permission)
}

if (!hasManifestPermission) {
callback.invoke(origin, false, false)
return
}

// Check runtime permissions
val hasRuntimePermission = LOCATION_PERMISSIONS.any { permission ->
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
}

callback.invoke(origin, hasRuntimePermission, hasRuntimePermission)
}

override fun onGeolocationPermissionsHidePrompt() {
// no-op override to implement
}

// Private

private fun Context.launchEmailApp(to: String) {
val intent = Intent(Intent.ACTION_SEND)
intent.type = "vnd.android.cursor.item/email"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
/*
* MIT License
*
*
* Copyright 2023-present, Shopify Inc.
*
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
Expand All @@ -22,19 +22,24 @@
*/
package com.shopify.checkoutsheetkit

import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInfo
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.net.Uri
import android.webkit.GeolocationPermissions
import androidx.activity.ComponentActivity
import com.shopify.checkoutsheetkit.lifecycleevents.CheckoutCompletedEvent
import com.shopify.checkoutsheetkit.pixelevents.PixelEvent
import org.assertj.core.api.Assertions.assertThat
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.kotlin.mock
import org.mockito.kotlin.verify
import org.mockito.Mockito.`when`
import org.mockito.MockitoAnnotations
import org.mockito.kotlin.*
markmur marked this conversation as resolved.
Show resolved Hide resolved
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
Expand All @@ -44,13 +49,22 @@ import org.robolectric.shadows.ShadowActivity
@RunWith(RobolectricTestRunner::class)
class DefaultCheckoutEventProcessorTest {

private lateinit var context: Context
private lateinit var activity: ComponentActivity
private lateinit var shadowActivity: ShadowActivity
private val mockCallback = mock<GeolocationPermissions.Callback>()
private lateinit var processor: TestCheckoutEventProcessor
private lateinit var packageManager: PackageManager

@Before
fun setUp() {
MockitoAnnotations.openMocks(this)
markmur marked this conversation as resolved.
Show resolved Hide resolved
activity = Robolectric.buildActivity(ComponentActivity::class.java).get()
shadowActivity = shadowOf(activity)
context = mock()
packageManager = mock()
`when`(context.packageManager).thenReturn(packageManager)
markmur marked this conversation as resolved.
Show resolved Hide resolved
processor = TestCheckoutEventProcessor(context)
}

@Test
Expand All @@ -73,7 +87,8 @@ class DefaultCheckoutEventProcessorTest {
processor.onCheckoutLinkClicked(uri)

val intent = shadowActivity.peekNextStartedActivityForResult().intent
assertThat(intent.getStringArrayExtra(Intent.EXTRA_EMAIL)).isEqualTo(arrayOf("[email protected]"))
assertThat(intent.getStringArrayExtra(Intent.EXTRA_EMAIL))
.isEqualTo(arrayOf("[email protected]"))
assertThat(intent.action).isEqualTo("android.intent.action.SEND")
}

Expand Down Expand Up @@ -101,12 +116,23 @@ class DefaultCheckoutEventProcessorTest {
val shadowPackageManager = shadowOf(pm)
shadowPackageManager.addResolveInfoForIntent(expectedIntent, ResolveInfo())

val processor = object: DefaultCheckoutEventProcessor(activity, log) {
override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) {/* not implemented */}
override fun onCheckoutFailed(error: CheckoutException) {/* not implemented */}
override fun onCheckoutCanceled() {/* not implemented */}
override fun onWebPixelEvent(event: PixelEvent) {/* not implemented */}
}
val processor =
object : DefaultCheckoutEventProcessor(activity, log) {
override fun onCheckoutCompleted(
checkoutCompletedEvent: CheckoutCompletedEvent
) {
/* not implemented */
}
override fun onCheckoutFailed(error: CheckoutException) {
/* not implemented */
}
override fun onCheckoutCanceled() {
/* not implemented */
}
override fun onWebPixelEvent(event: PixelEvent) {
/* not implemented */
}
}

processor.onCheckoutLinkClicked(uri)

Expand All @@ -118,19 +144,34 @@ class DefaultCheckoutEventProcessorTest {
@Test
fun `onCheckoutLinkedClick with unhandled scheme logs warning`() {
val log = mock<LogWrapper>()
val processor = object: DefaultCheckoutEventProcessor(activity, log) {
override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) {/* not implemented */}
override fun onCheckoutFailed(error: CheckoutException) {/* not implemented */}
override fun onCheckoutCanceled() {/* not implemented */}
override fun onWebPixelEvent(event: PixelEvent) {/* not implemented */}
}
val processor =
markmur marked this conversation as resolved.
Show resolved Hide resolved
object : DefaultCheckoutEventProcessor(activity, log) {
override fun onCheckoutCompleted(
checkoutCompletedEvent: CheckoutCompletedEvent
) {
/* not implemented */
}
override fun onCheckoutFailed(error: CheckoutException) {
/* not implemented */
}
override fun onCheckoutCanceled() {
/* not implemented */
}
override fun onWebPixelEvent(event: PixelEvent) {
/* not implemented */
}
}

val uri = Uri.parse("ftp:random")

processor.onCheckoutLinkClicked(uri)

assertThat(shadowActivity.peekNextStartedActivityForResult()).isNull()
verify(log).w("DefaultCheckoutEventProcessor", "Unrecognized scheme for link clicked in checkout 'ftp:random'")
verify(log)
.w(
"DefaultCheckoutEventProcessor",
"Unrecognized scheme for link clicked in checkout 'ftp:random'"
)
}

@Test
Expand All @@ -140,7 +181,9 @@ class DefaultCheckoutEventProcessorTest {
var recoverable: Boolean? = null
val processor =
object : DefaultCheckoutEventProcessor(activity, log) {
override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) {
override fun onCheckoutCompleted(
checkoutCompletedEvent: CheckoutCompletedEvent
) {
/* not implemented */
}
override fun onCheckoutFailed(error: CheckoutException) {
Expand All @@ -164,12 +207,78 @@ class DefaultCheckoutEventProcessorTest {
assertThat(recoverable).isTrue()
}

@Test
fun testOnGeolocationPermissionsShowPrompt_withNoManifestPermission() {
markmur marked this conversation as resolved.
Show resolved Hide resolved
// Simulate no permissions declared in the manifest
whenever(packageManager.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS))
.thenThrow(PackageManager.NameNotFoundException())
processor.onGeolocationPermissionsShowPrompt("http://shopify.com", mockCallback)
verify(mockCallback).invoke("http://shopify.com", false, false)
}

@Test
fun testOnGeolocationPermissionsShowPrompt_withManifestPermissionGranted() {
// Simulate permissions declared in the manifest
val permissions =
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
whenever(packageManager.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS))
.thenReturn(mockPackageInfo(permissions))

// Simulate runtime permission granted
whenever(context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION))
.thenReturn(PackageManager.PERMISSION_GRANTED)
whenever(context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION))
.thenReturn(PackageManager.PERMISSION_GRANTED)

processor.onGeolocationPermissionsShowPrompt("http://shopify.com", mockCallback)

verify(mockCallback).invoke("http://shopify.com", true, true)
}

@Test
fun testOnGeolocationPermissionsShowPrompt_withManifestPermissionDenied() {
// Simulate permissions declared in the manifest
val permissions =
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
whenever(packageManager.getPackageInfo(context.packageName, PackageManager.GET_PERMISSIONS))
.thenReturn(mockPackageInfo(permissions))

// Simulate runtime permission denied
whenever(context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION))
.thenReturn(PackageManager.PERMISSION_DENIED)
whenever(context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION))
.thenReturn(PackageManager.PERMISSION_DENIED)

processor.onGeolocationPermissionsShowPrompt("http://shopify.com", mockCallback)

verify(mockCallback).invoke("http://shopify.com", false, false)
}

// Private

private fun processor(activity: ComponentActivity): DefaultCheckoutEventProcessor {
return object: DefaultCheckoutEventProcessor(activity) {
override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) {/* not implemented */}
override fun onCheckoutFailed(error: CheckoutException) {/* not implemented */}
override fun onCheckoutCanceled() {/* not implemented */}
override fun onWebPixelEvent(event: PixelEvent) {/* not implemented */}
return object : DefaultCheckoutEventProcessor(activity) {
override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) {}
override fun onCheckoutFailed(error: CheckoutException) {}
override fun onCheckoutCanceled() {}
override fun onWebPixelEvent(event: PixelEvent) {}
}
}

private fun mockPackageInfo(permissions: Array<String>): PackageInfo {
return PackageInfo().apply { requestedPermissions = permissions }
}

private class TestCheckoutEventProcessor(context: Context) :
DefaultCheckoutEventProcessor(context) {
override fun onCheckoutCompleted(checkoutCompletedEvent: CheckoutCompletedEvent) {}
override fun onCheckoutFailed(error: CheckoutException) {}
override fun onCheckoutCanceled() {}
}
}
Loading
Loading