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

Location access via JavaScript #28

Merged
merged 11 commits into from
Dec 2, 2024
Original file line number Diff line number Diff line change
@@ -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<String> {
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()
}
}
}
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ import androidx.activity.result.ActivityResultLauncher
interface VisitDestination {
fun isActive(): Boolean
fun activityResultLauncher(requestCode: Int): ActivityResultLauncher<Intent>?
fun activityPermissionResultLauncher(requestCode: Int): ActivityResultLauncher<String>?
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)

Expand All @@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,19 +160,35 @@ 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.
*/
fun activityResultLauncher(requestCode: Int): ActivityResultLauncher<Intent>? {
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<String>? {
return null
}

fun prepareNavigation(onReady: () -> Unit)

override fun bridgeWebViewIsReady(): Boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -63,6 +64,13 @@ open class HotwireWebBottomSheetFragment : HotwireBottomSheetFragment(), Hotwire
}
}

override fun activityPermissionResultLauncher(requestCode: Int): ActivityResultLauncher<String>? {
return when (requestCode) {
HOTWIRE_REQUEST_CODE_GEOLOCATION_PERMISSION -> webDelegate.geoLocationPermissionResultLauncher
else -> null
}
}

override fun onStart() {
super.onStart()
webDelegate.onStart()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -100,6 +101,13 @@ open class HotwireWebFragment : HotwireFragment(), HotwireWebFragmentCallback {
}
}

override fun activityPermissionResultLauncher(requestCode: Int): ActivityResultLauncher<String>? {
return when (requestCode) {
HOTWIRE_REQUEST_CODE_GEOLOCATION_PERMISSION -> webDelegate.geoLocationPermissionResultLauncher
else -> null
}
}

final override fun prepareNavigation(onReady: () -> Unit) {
webDelegate.prepareNavigation(onReady)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -163,6 +169,10 @@ internal class HotwireWebFragmentDelegate(
return navDestination.activityResultLauncher(requestCode)
}

override fun activityPermissionResultLauncher(requestCode: Int): ActivityResultLauncher<String>? {
return navDestination.activityPermissionResultLauncher(requestCode)
}

// -----------------------------------------------------------------------
// SessionCallback interface
// -----------------------------------------------------------------------
Expand Down Expand Up @@ -343,6 +353,12 @@ internal class HotwireWebFragmentDelegate(
}
}

private fun registerGeolocationPermissionLauncher(): ActivityResultLauncher<String> {
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 {
Expand Down