diff --git a/core/src/main/kotlin/dev/hotwire/core/files/delegates/GeolocationPermissionDelegate.kt b/core/src/main/kotlin/dev/hotwire/core/files/delegates/GeolocationPermissionDelegate.kt new file mode 100644 index 0000000..2bf8f75 --- /dev/null +++ b/core/src/main/kotlin/dev/hotwire/core/files/delegates/GeolocationPermissionDelegate.kt @@ -0,0 +1,116 @@ +package dev.hotwire.core.files.delegates + +import android.Manifest.permission.ACCESS_COARSE_LOCATION +import android.Manifest.permission.ACCESS_FINE_LOCATION +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.webkit.GeolocationPermissions +import androidx.core.content.ContextCompat +import androidx.core.content.PermissionChecker +import dev.hotwire.core.files.util.HOTWIRE_REQUEST_CODE_GEOLOCATION_PERMISSION +import dev.hotwire.core.logging.logError +import dev.hotwire.core.turbo.session.Session + +class GeolocationPermissionDelegate(private val session: Session) { + private val context: Context = session.context + private val permissionToRequest = preferredLocationPermission() + + private var requestOrigin: String? = null + private var requestCallback: GeolocationPermissions.Callback? = null + + fun onRequestPermission( + origin: String?, + callback: GeolocationPermissions.Callback? + ) { + requestOrigin = origin + requestCallback = callback + + if (requestOrigin == null || requestCallback == null || permissionToRequest == null) { + permissionDenied() + } else if (hasLocationPermission(context)) { + permissionGranted() + } else { + startPermissionRequest() + } + } + + fun onActivityResult(isGranted: Boolean) { + if (isGranted) { + permissionGranted() + } else { + permissionDenied() + } + } + + private fun startPermissionRequest() { + val destination = session.currentVisit?.callback?.visitDestination() ?: return + val resultLauncher = destination.activityPermissionResultLauncher( + HOTWIRE_REQUEST_CODE_GEOLOCATION_PERMISSION + ) + + try { + resultLauncher?.launch(permissionToRequest) + } catch (e: Exception) { + logError("startGeolocationPermissionError", e) + permissionDenied() + } + } + + private fun hasLocationPermission(context: Context): Boolean { + return permissionToRequest?.let { + ContextCompat.checkSelfPermission(context, it) == PermissionChecker.PERMISSION_GRANTED + } == true + } + + private fun permissionGranted() { + requestCallback?.invoke(requestOrigin, true, true) + requestOrigin = null + requestCallback = null + } + + private fun permissionDenied() { + requestCallback?.invoke(requestOrigin, false, false) + requestOrigin = null + requestCallback = null + } + + private fun preferredLocationPermission(): String? { + val declaredPermissions = manifestPermissions().filter { + it == ACCESS_COARSE_LOCATION || + it == ACCESS_FINE_LOCATION + } + + // Prefer fine location if provided in manifest, otherwise coarse location + return if (declaredPermissions.contains(ACCESS_FINE_LOCATION)) { + ACCESS_FINE_LOCATION + } else if (declaredPermissions.contains(ACCESS_COARSE_LOCATION)) { + if (Build.VERSION.SDK_INT == Build.VERSION_CODES.S || + Build.VERSION.SDK_INT == Build.VERSION_CODES.S_V2) { + // Android 12 requires the "fine" permission for location + // access within the WebView. Granting "coarse" location does not + // work. See: https://issues.chromium.org/issues/40205003 + null + } else { + ACCESS_COARSE_LOCATION + } + } else { + null + } + } + + private fun manifestPermissions(): Array { + return try { + val context = session.context + val packageInfo = context.packageManager.getPackageInfo( + context.packageName, + PackageManager.GET_PERMISSIONS + ) + + packageInfo.requestedPermissions + } catch (e: PackageManager.NameNotFoundException) { + logError("manifestPermissionsNotAvailable", e) + emptyArray() + } + } +} diff --git a/core/src/main/kotlin/dev/hotwire/core/files/util/FileConstants.kt b/core/src/main/kotlin/dev/hotwire/core/files/util/FileConstants.kt index ef06fab..1a69c4b 100644 --- a/core/src/main/kotlin/dev/hotwire/core/files/util/FileConstants.kt +++ b/core/src/main/kotlin/dev/hotwire/core/files/util/FileConstants.kt @@ -1,3 +1,7 @@ package dev.hotwire.core.files.util +// Intent activity launcher request codes const val HOTWIRE_REQUEST_CODE_FILES = 37 + +// Permission activity launcher request codes +const val HOTWIRE_REQUEST_CODE_GEOLOCATION_PERMISSION = 3737 diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/session/Session.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/session/Session.kt index c194668..449e361 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/session/Session.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/session/Session.kt @@ -16,6 +16,7 @@ import androidx.webkit.WebViewFeature.isFeatureSupported import dev.hotwire.core.config.Hotwire import dev.hotwire.core.logging.logEvent import dev.hotwire.core.files.delegates.FileChooserDelegate +import dev.hotwire.core.files.delegates.GeolocationPermissionDelegate import dev.hotwire.core.turbo.errors.HttpError import dev.hotwire.core.turbo.errors.LoadError import dev.hotwire.core.turbo.errors.WebError @@ -76,8 +77,16 @@ class Session( var isRenderProcessGone = false internal set + /** + * The delegate that handles WebView-requested file chooser requests. + */ val fileChooserDelegate = FileChooserDelegate(this) + /** + * The delegate the handles WebView-requested geolocation permission requests. + */ + val geolocationPermissionDelegate = GeolocationPermissionDelegate(this) + init { initializeWebView() HotwireHttpClient.enableCachingWith(context) diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/visit/VisitDestination.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/visit/VisitDestination.kt index ad17525..823a5f4 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/visit/VisitDestination.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/visit/VisitDestination.kt @@ -6,4 +6,5 @@ import androidx.activity.result.ActivityResultLauncher interface VisitDestination { fun isActive(): Boolean fun activityResultLauncher(requestCode: Int): ActivityResultLauncher? + fun activityPermissionResultLauncher(requestCode: Int): ActivityResultLauncher? } diff --git a/core/src/main/kotlin/dev/hotwire/core/turbo/webview/HotwireWebChromeClient.kt b/core/src/main/kotlin/dev/hotwire/core/turbo/webview/HotwireWebChromeClient.kt index 62e0444..f6e3402 100644 --- a/core/src/main/kotlin/dev/hotwire/core/turbo/webview/HotwireWebChromeClient.kt +++ b/core/src/main/kotlin/dev/hotwire/core/turbo/webview/HotwireWebChromeClient.kt @@ -2,6 +2,7 @@ package dev.hotwire.core.turbo.webview import android.net.Uri import android.os.Message +import android.webkit.GeolocationPermissions import android.webkit.JsResult import android.webkit.ValueCallback import android.webkit.WebChromeClient @@ -13,7 +14,12 @@ import dev.hotwire.core.turbo.util.toJson import dev.hotwire.core.turbo.visit.VisitOptions open class HotwireWebChromeClient(val session: Session) : WebChromeClient() { - override fun onJsAlert(view: WebView?, url: String?, message: String?, result: JsResult?): Boolean { + override fun onJsAlert( + view: WebView?, + url: String?, + message: String?, + result: JsResult? + ): Boolean { val context = view?.context ?: return false MaterialAlertDialogBuilder(context) @@ -29,7 +35,12 @@ open class HotwireWebChromeClient(val session: Session) : WebChromeClient() { return true } - override fun onJsConfirm(view: WebView?, url: String?, message: String?, result: JsResult?): Boolean { + override fun onJsConfirm( + view: WebView?, + url: String?, + message: String?, + result: JsResult? + ): Boolean { val context = view?.context ?: return false MaterialAlertDialogBuilder(context) @@ -60,7 +71,12 @@ open class HotwireWebChromeClient(val session: Session) : WebChromeClient() { ) } - override fun onCreateWindow(webView: WebView, isDialog: Boolean, isUserGesture: Boolean, resultMsg: Message?): Boolean { + override fun onCreateWindow( + webView: WebView, + isDialog: Boolean, + isUserGesture: Boolean, + resultMsg: Message? + ): Boolean { val message = webView.handler.obtainMessage() webView.requestFocusNodeHref(message) @@ -73,4 +89,11 @@ open class HotwireWebChromeClient(val session: Session) : WebChromeClient() { return false } + + override fun onGeolocationPermissionsShowPrompt( + origin: String?, + callback: GeolocationPermissions.Callback? + ) { + session.geolocationPermissionDelegate.onRequestPermission(origin, callback) + } } diff --git a/core/src/test/kotlin/dev/hotwire/core/turbo/session/SessionTest.kt b/core/src/test/kotlin/dev/hotwire/core/turbo/session/SessionTest.kt index 6472f3f..07e79d9 100644 --- a/core/src/test/kotlin/dev/hotwire/core/turbo/session/SessionTest.kt +++ b/core/src/test/kotlin/dev/hotwire/core/turbo/session/SessionTest.kt @@ -1,6 +1,7 @@ package dev.hotwire.core.turbo.session import android.os.Build +import androidx.activity.result.ActivityResultLauncher import androidx.appcompat.app.AppCompatActivity import com.nhaarman.mockito_kotlin.never import com.nhaarman.mockito_kotlin.times @@ -54,6 +55,7 @@ class SessionTest { val visitDestination = object : VisitDestination { override fun isActive() = true override fun activityResultLauncher(requestCode: Int) = null + override fun activityPermissionResultLauncher(requestCode: Int) = null } whenever(callback.visitDestination()).thenReturn(visitDestination) diff --git a/navigation-fragments/src/main/java/dev/hotwire/navigation/destinations/HotwireDestination.kt b/navigation-fragments/src/main/java/dev/hotwire/navigation/destinations/HotwireDestination.kt index d512130..4071282 100644 --- a/navigation-fragments/src/main/java/dev/hotwire/navigation/destinations/HotwireDestination.kt +++ b/navigation-fragments/src/main/java/dev/hotwire/navigation/destinations/HotwireDestination.kt @@ -160,12 +160,13 @@ interface HotwireDestination : BridgeDestination { } /** - * Gets a registered activity result launcher instance for the given `requestCode`. + * Gets a registered `ActivityResultContracts.StartActivityForResult` activity result launcher + * instance for the given `requestCode`. * * Override to provide your own [androidx.activity.result.ActivityResultLauncher] * instances. If your app doesn't have a matching `requestCode`, you must call - * `super.activityResultLauncher(requestCode)` to give the Turbo library an - * opportunity to provide a matching result launcher. + * `super.activityResultLauncher(requestCode)` to give the library an opportunity + * to provide a matching result launcher. * * @param requestCode The request code for the corresponding result launcher. */ @@ -173,6 +174,21 @@ interface HotwireDestination : BridgeDestination { return null } + /** + * Gets a registered `ActivityResultContracts.RequestPermission` activity result launcher + * instance for the given `requestCode`. + * + * Override to provide your own [androidx.activity.result.ActivityResultLauncher] + * instances. If your app doesn't have a matching `requestCode`, you must call + * `super.activityPermissionResultLauncher(requestCode)` to give the library an + * opportunity to provide a matching result launcher. + * + * @param requestCode The request code for the corresponding result launcher. + */ + fun activityPermissionResultLauncher(requestCode: Int): ActivityResultLauncher? { + return null + } + fun prepareNavigation(onReady: () -> Unit) override fun bridgeWebViewIsReady(): Boolean { diff --git a/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebBottomSheetFragment.kt b/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebBottomSheetFragment.kt index ac330d8..043927e 100644 --- a/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebBottomSheetFragment.kt +++ b/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebBottomSheetFragment.kt @@ -11,6 +11,7 @@ import androidx.activity.result.ActivityResultLauncher import dev.hotwire.core.bridge.BridgeDelegate import dev.hotwire.core.turbo.errors.VisitError import dev.hotwire.core.files.util.HOTWIRE_REQUEST_CODE_FILES +import dev.hotwire.core.files.util.HOTWIRE_REQUEST_CODE_GEOLOCATION_PERMISSION import dev.hotwire.core.turbo.webview.HotwireWebChromeClient import dev.hotwire.core.turbo.webview.HotwireWebView import dev.hotwire.navigation.R @@ -63,6 +64,13 @@ open class HotwireWebBottomSheetFragment : HotwireBottomSheetFragment(), Hotwire } } + override fun activityPermissionResultLauncher(requestCode: Int): ActivityResultLauncher? { + return when (requestCode) { + HOTWIRE_REQUEST_CODE_GEOLOCATION_PERMISSION -> webDelegate.geoLocationPermissionResultLauncher + else -> null + } + } + override fun onStart() { super.onStart() webDelegate.onStart() diff --git a/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebFragment.kt b/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebFragment.kt index de0c9a5..bd54d2f 100644 --- a/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebFragment.kt +++ b/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebFragment.kt @@ -8,8 +8,9 @@ import android.view.View import android.view.ViewGroup import androidx.activity.result.ActivityResultLauncher import dev.hotwire.core.bridge.BridgeDelegate -import dev.hotwire.core.turbo.errors.VisitError import dev.hotwire.core.files.util.HOTWIRE_REQUEST_CODE_FILES +import dev.hotwire.core.files.util.HOTWIRE_REQUEST_CODE_GEOLOCATION_PERMISSION +import dev.hotwire.core.turbo.errors.VisitError import dev.hotwire.core.turbo.webview.HotwireWebChromeClient import dev.hotwire.core.turbo.webview.HotwireWebView import dev.hotwire.navigation.R @@ -100,6 +101,13 @@ open class HotwireWebFragment : HotwireFragment(), HotwireWebFragmentCallback { } } + override fun activityPermissionResultLauncher(requestCode: Int): ActivityResultLauncher? { + return when (requestCode) { + HOTWIRE_REQUEST_CODE_GEOLOCATION_PERMISSION -> webDelegate.geoLocationPermissionResultLauncher + else -> null + } + } + final override fun prepareNavigation(onReady: () -> Unit) { webDelegate.prepareNavigation(onReady) } diff --git a/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebFragmentDelegate.kt b/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebFragmentDelegate.kt index e128a54..b7e4101 100644 --- a/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebFragmentDelegate.kt +++ b/navigation-fragments/src/main/java/dev/hotwire/navigation/fragments/HotwireWebFragmentDelegate.kt @@ -4,6 +4,7 @@ import android.content.Intent import android.graphics.Bitmap import android.webkit.HttpAuthHandler import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContracts.RequestPermission import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.lifecycle.Lifecycle.State.STARTED import androidx.lifecycle.findViewTreeLifecycleOwner @@ -13,11 +14,11 @@ import dev.hotwire.core.config.Hotwire import dev.hotwire.core.turbo.config.pullToRefreshEnabled import dev.hotwire.core.turbo.errors.VisitError import dev.hotwire.core.turbo.session.SessionCallback -import dev.hotwire.core.turbo.webview.HotwireWebView import dev.hotwire.core.turbo.visit.Visit import dev.hotwire.core.turbo.visit.VisitAction import dev.hotwire.core.turbo.visit.VisitDestination import dev.hotwire.core.turbo.visit.VisitOptions +import dev.hotwire.core.turbo.webview.HotwireWebView import dev.hotwire.navigation.destinations.HotwireDestination import dev.hotwire.navigation.session.SessionModalResult import dev.hotwire.navigation.util.dispatcherProvider @@ -61,6 +62,11 @@ internal class HotwireWebFragmentDelegate( */ val fileChooserResultLauncher = registerFileChooserLauncher() + /** + * The activity result launcher that handles geolocation permission results. + */ + val geoLocationPermissionResultLauncher = registerGeolocationPermissionLauncher() + fun prepareNavigation(onReady: () -> Unit) { session.removeCallback(this) detachWebView(onReady) @@ -163,6 +169,10 @@ internal class HotwireWebFragmentDelegate( return navDestination.activityResultLauncher(requestCode) } + override fun activityPermissionResultLauncher(requestCode: Int): ActivityResultLauncher? { + return navDestination.activityPermissionResultLauncher(requestCode) + } + // ----------------------------------------------------------------------- // SessionCallback interface // ----------------------------------------------------------------------- @@ -343,6 +353,12 @@ internal class HotwireWebFragmentDelegate( } } + private fun registerGeolocationPermissionLauncher(): ActivityResultLauncher { + return navDestination.fragment.registerForActivityResult(RequestPermission()) { isGranted -> + session.geolocationPermissionDelegate.onActivityResult(isGranted) + } + } + private fun visit(location: String, restoreWithCachedSnapshot: Boolean, reload: Boolean) { val restore = restoreWithCachedSnapshot && !reload val options = when {