How to prevent WKWebView to repeatedly ask for permission to access location?

前端 未结 3 1675
星月不相逢
星月不相逢 2020-12-03 05:30

I have a WKWebView in my app and when I start browsing www.google.com or any other website that requires location service, a pop up window appears, asking for t

相关标签:
3条回答
  • 2020-12-03 05:57

    Turns out it's quite hard, but possible to do. You have to inject JavaScript code which intercepts requests to navigator.geolocation and transfer them to your app, then get the location with CLLocationManager, then inject location back to the JavaScript.

    Here is the brief scheme:

    1. Add WKUserScript to your WKWebView configuration which overrides methods of navigator.geolocation. Injected JavaScript should look like this:

      navigator.geolocation.getCurrentPosition = function(success, error, options) { ... };
      navigator.geolocation.watchPosition = function(success, error, options) { ... };
      navigator.geolocation.clearWatch = function(id) { ... };
      
    2. With WKUserContentController.add(_:name:) add script message handler to your WKWebView. Injected JavaScript should call your handler, like this:

      window.webkit.messageHandlers.locationHandler.postMessage('getCurrentPosition');
      
    3. When a web page will request a location, this method will fire userContentController(_:didReceive:) so your app would know web page is requesting location. Find your location with the help of CLLocationManager as usual.

    4. Now it's time to inject the location back to the requesting JavaScript with webView.evaluateJavaScript("didUpdateLocation({coords: {latitude:55.0, longitude:0.0}, timestamp: 1494481126215.0})"). Of course your injected JavaScript should have didUpdateLocation function ready to launch saved success handler.

    Quite a long algorithm, but it works!

    0 讨论(0)
  • 2020-12-03 06:20

    Because I did not find solution how to avoid this stupid duplicate permission request I created swift class NavigatorGeolocation. The aim of this class is to override native JavaScript's navigator.geolocation API with custom one with 3 benefits:

    1. Frontend/JavaScript developer use navigator.geolocation API by standard way without taking care that it is overriden and uses code invocation JS --> Swift on behind
    2. Keep all logic outside of ViewController as much as possible
    3. No more ugly and stupid duplicate permission request (1st for app and 2nd for webview):

    @AryeeteySolomonAryeetey answered some solution but it is missing my first and second benefit. In his solution frontend developer have to add to JavaScript code specific code for iOS. I do not like this ugly platform addtions - I mean JavaScript function getLocation invoked from swift which is never used by web or android platform. I have hybrid app (web/android/ios) which uses webview on ios/android and I want to have only one identical HTML5 + JavaScript code for all platforms but I do not want to use huge solutions like Apache Cordova (formerly PhoneGap).

    You can easily integrate NavigatorGeolocation class to your project - just create new swift file NavigatorGeolocation.swift, copy content from my answer and in ViewController.swift add 4 lines related to var navigatorGeolocation.

    I think that Google's Android is much clever than Apple's iOS because webview in Android does not bother with duplicate permission request because permission is already granted/denied by user for app. There is no additional security to ask it twice as some people defend Apple.

    ViewController.swift:

    import UIKit
    import WebKit
    
    class ViewController: UIViewController, WKNavigationDelegate {
    
        var webView: WKWebView!;
        var navigatorGeolocation = NavigatorGeolocation();
    
        override func loadView() {
            super.loadView();
            let webViewConfiguration = WKWebViewConfiguration();
            navigatorGeolocation.setUserContentController(webViewConfiguration: webViewConfiguration);
            webView = WKWebView(frame:.zero , configuration: webViewConfiguration);
            webView.navigationDelegate = self;
            navigatorGeolocation.setWebView(webView: webView);
            view.addSubview(webView);
        }
    
        override func viewDidLoad() {
            super.viewDidLoad();
            let url = Bundle.main.url(forResource: "index", withExtension: "html", subdirectory: "webapp");
            let request = URLRequest(url: url!);
            webView.load(request);
        }
    
        func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
            webView.evaluateJavaScript(navigatorGeolocation.getJavaScripToEvaluate());
        }
    
    }
    

    NavigatorGeolocation.swift:

    import WebKit
    import CoreLocation
    
    class NavigatorGeolocation: NSObject, WKScriptMessageHandler, CLLocationManagerDelegate {
    
        var locationManager = CLLocationManager();
        var listenersCount = 0;
        var webView: WKWebView!;
    
        override init() {
            super.init();
            locationManager.delegate = self;
        }
    
        func setUserContentController(webViewConfiguration: WKWebViewConfiguration) {
            let controller = WKUserContentController();
            controller.add(self, name: "listenerAdded");
            controller.add(self, name: "listenerRemoved");
            webViewConfiguration.userContentController = controller;
        }
    
        func setWebView(webView: WKWebView) {
            self.webView = webView;
        }
    
        func locationServicesIsEnabled() -> Bool {
            return (CLLocationManager.locationServicesEnabled()) ? true : false;
        }
    
        func authorizationStatusNeedRequest(status: CLAuthorizationStatus) -> Bool {
            return (status == .notDetermined) ? true : false;
        }
    
        func authorizationStatusIsGranted(status: CLAuthorizationStatus) -> Bool {
            return (status == .authorizedAlways || status == .authorizedWhenInUse) ? true : false;
        }
    
        func authorizationStatusIsDenied(status: CLAuthorizationStatus) -> Bool {
            return (status == .restricted || status == .denied) ? true : false;
        }
    
        func onLocationServicesIsDisabled() {
            webView.evaluateJavaScript("navigator.geolocation.helper.error(2, 'Location services disabled');");
        }
    
        func onAuthorizationStatusNeedRequest() {
            locationManager.requestWhenInUseAuthorization();
        }
    
        func onAuthorizationStatusIsGranted() {
            locationManager.startUpdatingLocation();
        }
    
        func onAuthorizationStatusIsDenied() {
            webView.evaluateJavaScript("navigator.geolocation.helper.error(1, 'App does not have location permission');");
        }
    
        func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
            if (message.name == "listenerAdded") {
                listenersCount += 1;
    
                if (!locationServicesIsEnabled()) {
                    onLocationServicesIsDisabled();
                }
                else if (authorizationStatusIsDenied(status: CLLocationManager.authorizationStatus())) {
                    onAuthorizationStatusIsDenied();
                }
                else if (authorizationStatusNeedRequest(status: CLLocationManager.authorizationStatus())) {
                    onAuthorizationStatusNeedRequest();
                }
                else if (authorizationStatusIsGranted(status: CLLocationManager.authorizationStatus())) {
                    onAuthorizationStatusIsGranted();
                }
            }
            else if (message.name == "listenerRemoved") {
                listenersCount -= 1;
    
                // no listener left in web view to wait for position
                if (listenersCount == 0) {
                    locationManager.stopUpdatingLocation();
                }
            }
        }
    
        func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
            // didChangeAuthorization is also called at app startup, so this condition checks listeners
            // count before doing anything otherwise app will start location service without reason
            if (listenersCount > 0) {
                if (authorizationStatusIsDenied(status: status)) {
                    onAuthorizationStatusIsDenied();
                }
                else if (authorizationStatusIsGranted(status: status)) {
                    onAuthorizationStatusIsGranted();
                }
            }
        }
    
        func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
            if let location = locations.last {
                webView.evaluateJavaScript("navigator.geolocation.helper.success('\(location.timestamp)', \(location.coordinate.latitude), \(location.coordinate.longitude), \(location.altitude), \(location.horizontalAccuracy), \(location.verticalAccuracy), \(location.course), \(location.speed));");
            }
        }
    
        func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
            webView.evaluateJavaScript("navigator.geolocation.helper.error(2, 'Failed to get position (\(error.localizedDescription))');");
        }
    
        func getJavaScripToEvaluate() -> String {
            let javaScripToEvaluate = """
                // management for success and error listeners and its calling
                navigator.geolocation.helper = {
                    listeners: {},
                    noop: function() {},
                    id: function() {
                        var min = 1, max = 1000;
                        return Math.floor(Math.random() * (max - min + 1)) + min;
                    },
                    clear: function(isError) {
                        for (var id in this.listeners) {
                            if (isError || this.listeners[id].onetime) {
                                navigator.geolocation.clearWatch(id);
                            }
                        }
                    },
                    success: function(timestamp, latitude, longitude, altitude, accuracy, altitudeAccuracy, heading, speed) {
                        var position = {
                            timestamp: new Date(timestamp).getTime() || new Date().getTime(), // safari can not parse date format returned by swift e.g. 2019-12-27 15:46:59 +0000 (fallback used because we trust that safari will learn it in future because chrome knows that format)
                            coords: {
                                latitude: latitude,
                                longitude: longitude,
                                altitude: altitude,
                                accuracy: accuracy,
                                altitudeAccuracy: altitudeAccuracy,
                                heading: (heading > 0) ? heading : null,
                                speed: (speed > 0) ? speed : null
                            }
                        };
                        for (var id in this.listeners) {
                            this.listeners[id].success(position);
                        }
                        this.clear(false);
                    },
                    error: function(code, message) {
                        var error = {
                            PERMISSION_DENIED: 1,
                            POSITION_UNAVAILABLE: 2,
                            TIMEOUT: 3,
                            code: code,
                            message: message
                        };
                        for (var id in this.listeners) {
                            this.listeners[id].error(error);
                        }
                        this.clear(true);
                    }
                };
    
                // @override getCurrentPosition()
                navigator.geolocation.getCurrentPosition = function(success, error, options) {
                    var id = this.helper.id();
                    this.helper.listeners[id] = { onetime: true, success: success || this.noop, error: error || this.noop };
                    window.webkit.messageHandlers.listenerAdded.postMessage("");
                };
    
                // @override watchPosition()
                navigator.geolocation.watchPosition = function(success, error, options) {
                    var id = this.helper.id();
                    this.helper.listeners[id] = { onetime: false, success: success || this.noop, error: error || this.noop };
                    window.webkit.messageHandlers.listenerAdded.postMessage("");
                    return id;
                };
    
                // @override clearWatch()
                navigator.geolocation.clearWatch = function(id) {
                    var idExists = (this.helper.listeners[id]) ? true : false;
                    if (idExists) {
                        this.helper.listeners[id] = null;
                        delete this.helper.listeners[id];
                        window.webkit.messageHandlers.listenerRemoved.postMessage("");
                    }
                };
            """;
    
            return javaScripToEvaluate;
        }
    
    }
    
    0 讨论(0)
  • 2020-12-03 06:22

    So following the steps outlined by @AlexanderVasenin, I created a gist which works perfectly.

    Code Sample Here

    Assuming index.html is the page you're trying to load.

    1. Override the HTML method navigator.geolocation.getCurrentPosition which is used to request for location info with this script
     let scriptSource = "navigator.geolocation.getCurrentPosition = function(success, error, options) {window.webkit.messageHandlers.locationHandler.postMessage('getCurrentPosition');};"
     let script = WKUserScript(source: scriptSource, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
     contentController.addUserScript(script)
    

    so whenever the webpage tries to call navigator.geolocation.getCurrentPosition, we override it by calling func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage)

    1. the userContentController method then gets the location data from CLLocationManager and calls a method in the webpage to handle that response. In my case, the method is getLocation(lat,lng).

    This is the full code.

    ViewController.swift

    import UIKit
    import WebKit
    import CoreLocation
    
    class ViewController: UIViewController , CLLocationManagerDelegate, WKScriptMessageHandler{
        var webView: WKWebView?
        var manager: CLLocationManager!
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            manager = CLLocationManager()
            manager.delegate = self
            manager.desiredAccuracy = kCLLocationAccuracyBest
            manager.requestAlwaysAuthorization()
            manager.startUpdatingLocation()
    
            let contentController = WKUserContentController()
            contentController.add(self, name: "locationHandler")
    
            let config = WKWebViewConfiguration()
            config.userContentController = contentController
    
            let scriptSource = "navigator.geolocation.getCurrentPosition = function(success, error, options) {window.webkit.messageHandlers.locationHandler.postMessage('getCurrentPosition');};"
            let script = WKUserScript(source: scriptSource, injectionTime: .atDocumentEnd, forMainFrameOnly: true)
            contentController.addUserScript(script)
    
            self.webView = WKWebView(frame: self.view.bounds, configuration: config)
            view.addSubview(webView!)
    
            webView?.uiDelegate = self
            webView?.navigationDelegate = self
            webView?.scrollView.delegate = self
            webView?.scrollView.bounces = false
            webView?.scrollView.bouncesZoom = false
    
            let url = Bundle.main.url(forResource: "index", withExtension:"html")
            let request = URLRequest(url: url!)
    
            webView?.load(request)
        }
    
        func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
            if message.name == "locationHandler",let  messageBody = message.body as? String {
                if messageBody == "getCurrentPosition"{
                    let script =
                        "getLocation(\(manager.location?.coordinate.latitude ?? 0) ,\(manager.location?.coordinate.longitude ?? 0))"
                    webView?.evaluateJavaScript(script)
                }
            }
        }
    }
    

    index.html

    <!DOCTYPE html>
    <html>
        <body>
    
            <h1>Click the button to get your coordinates.</h1>
    
            <button style="font-size: 60px;" onclick="getUserLocation()">Try It</button>
    
            <p id="demo"></p>
    
            <script>
                var x = document.getElementById("demo");
    
                function getUserLocation() {
                    if (navigator.geolocation) {
                        navigator.geolocation.getCurrentPosition(showPosition);
                    } else {
                        x.innerHTML = "Geolocation is not supported by this browser.";
                    }
                }
    
            function showPosition(position) {
                getLocation(position.coords.latitude,position.coords.longitude);
            }
    
            function getLocation(lat,lng) {
                x.innerHTML = "Lat: " +  lat+
                "<br>Lng: " + lng;
            }
            </script>
    
        </body>
    </html>
    
    0 讨论(0)
提交回复
热议问题