Mobile SDK

Plain Webview

schedule 30 min read

The Swedbank Pay Mobile SDK aims to provide an easy way of integrating Swedbank Pay Checkout into a mobile application. It is, however, an opinionated library, and in particular is has no support for Swedbank Pay Payments at this point. Experience from developing the SDK may still be valuable for integrators wishing to show Payments pages in a Web View inside a mobile application. This page serves as a repository of that experience.

Edit "Plain Webview" on GitHub
warning

Unsupported

This page is prodived for informational purposes only, and is not part of the Mobile SDK documentation proper.

Quick Fix List

Here is a list of common gotchas. Please read through the whole document, though.

  • On Android, you need to set webView.settings.javaScriptEnabled = true
  • On Android, you need to set webView.settings.domStorageEnabled = true (used by some 3D-Secure pages)
  • Web Views will not launch apps by themselves. You must intercept navigations and launch apps yourself. See External Applications for details.
  • On Android, @JavascriptInterface methods only take primitive arguments. You will need to JSON.stringify any complex arguments.
  • Some 3D-Secure pages will not work if you open them in a Web View. This appears to be related to them launching an external application, which, in turn, opens a url in the browser app. See Dealing with Picky Web Pages for strategies.
  • Some pages make use of Javascript dialogs. Web Views do not display these on their own; you must add support by your WebCromeClient or WKUIDelegate.
  • On Android, remember to call webView.onResume() and webView.onPause().
  • Remember that you can debug Web View contents!

The Mobile SDK And You

A major goal for the Mobile SDK is to provide a platform where you can start developing your mobile e-commerce application rapidly, in a regular, native mobile application development workflow. Hence, it is designed to be a fairly self-contained whole, with a prescribed interface between the mobile client side and the backend server side. This, of course, means that to use the SDK, your backend must integrate with the SDK architecture. If you already have a working solution for web pages, this may not be ideal; indeed, you may wish to reuse your existing web page using Checkout or Payments, and expect to embed it inside your mobile application using a web view.

Indeed, on a high level this is what the SDK mobile client components do, in addition to providing native Swift and Kotlin APIs to the servie. The SDK internally generates a web page that shows the Checkout payment menu, so the developer need not concern themselves with html or other web-specific technologies. An exisiting web implementation would not really benefit from the extra discoverability and quality-of-life improvements of a mobile-native API, so the SDK’s value proposition seems to be little benefit for substantial reimplementation work.

That said, there are important considerations in embedding a Swedbank-Pay-enabled web page in a web view; considerations, which have been taken into account in the development of the SDK. There are currently no plans to offer any first-party components to help with embedding an existing Swedbank Pay web page, but this page shall serve as best-effort documentation for anyone attempting such.

Basics

Let us assume that the urls of the payment are as follows:

  • https://example.com/perform-payment is the page containing the Payment Menu or Payment Seamless View, i.e. the paymentUrl
  • https://example.com/payment-completed is the completeUrl
  • https://example.com/payment-canceled is the cancelUrl

Swedbank Pay payments use JavaScript, so that needs to be enabled:

iOS

1
2
3
4
5
6
7
8
9
10
11
    // WKPreferences.javaScriptEnabled is true by default,
    // so usually there is no need to to do this.
    // Other properties of WKWebViewConfiguration will be
    // needed for later steps, though, so it is good to have
    // it from the beginning.
    let configuration = WKWebViewConfiguration()
    configuration.preferences.javaScriptEnabled = true

    // Note: You can only set a configuration by using this initializer.
    // You cannot set a configuration in Interface Builder.
    let webView = WKWebView(frame: .zero, configuration: configuration)

Android

1
2
3
4
5
6
7
    val webView = WebView(context) // or get it from a layout

    // WebSettings.javaScriptEnabled is false by default,
    // so failing to do this will result in the payment page not working.
    // Setting javaScriptEnabled to true causes a linter warning,
    // which can be suppressed with an annotation.
    webView.settings.javaScriptEnabled = true

Some pages use the DOM Storage API, which must be enabled separately on Android:

Android

1
    webView.settings.domStorageEnabled = true

With this setup, you can load to the web view the page that shows the Payment Menu or the Payment Seamless View, and see what happens. You should be able to see the Swedbank Pay payment interface, and in many cases also complete a payment. It is not unlikely, though, that some payment methods will not work as expected. Also, you will be more or less stuck after the payment is complete.

iOS

1
2
    let paymentUrl = URL(string: "https://example.com/perform-payment")!
    webView.load(URLRequest(url: paymentUrl))

Android

1
    webView.loadUrl("https://example.com/perform-payment")

Completion

There are two ways of being notified of payment completion: listening for navigations, or using JavaScript hooks. Which one you want to use is partly a matter of taste, but if your existing system does some processing in the completeUrl page, it may be easier to use JavaScript hooks.

Listening for Navigations

The iOS WKNavigationDelegate protocol and Android WebViewClient class can be used to listen for navigations, and change their behaviour.

iOS

1
2
3
    // This example uses Self as the delegate.
    // It could be a separate object also.
    webView.navigationDelegate = self
1
2
3
    extension MyClass : WKNavigationDelegate {
        // WKNavigationDelegate methods
    }

Android

1
2
3
    webView.webViewClient = object : WebViewClient() {
        // WebViewClient methods
    }

In the simplest case you could listen for a navigation to the completeUrl or cancelUrl, and intercept it.

iOS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
    func webView(
        _ webView: WKWebView,
        decidePolicyFor navigationAction: WKNavigationAction,
        decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
    ) {
        switch navigationAction.request.url?.absoluteString {
        case "https://example.com/payment-completed":
            decisionHandler(.cancel)
            // Handle payment completion (success/failure)

        case "https://example.com/payment-canceled":
            decisionHandler(.cancel)
            // Handle payment cancellation

        default:
            decisionHandler(.allow)
        }
    }

Android

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
    override fun shouldOverrideUrlLoading(
        view: WebView?,
        url: String?
    ): Boolean {
        return when (url) {
            "https://example.com/payment-completed" -> {
                // Handle payment completion (success/failure)
                true
            }

            "https://example.com/payment-canceled" -> {
                // Handle payment cancellation
                true
            }

            else -> false
        }
    }

If your completeUrl, or cancelUrl for that matter, do some processing and redirect further, you can adapt these patterns to listen to your custom urls instead.

Adding JavaScript Hooks

On both iOS and Android, it is possible to add custom JavaScript interfaces to a web view. These interfaces then result in callbacks to native (Swift/Kotlin/ObjC/Java) methods, where you can execute your application specific actions. To observe payment completion and cancellation this way, you need to modify your completeUrl and cancelUrl pages to call these mobile-app-specific JavaScript interfaces. How you do this is beyond our scope here.

JavaScript Hooks: iOS

On iOS, JavaScript interfaces are added through the WKUserContentController of the WKWebView. The WKUserContentController is set by the WKWebViewConfiguration used when creating the WKWebView; you cannot change the WKUserContentController of a WKWebView. You can, however, modify the WKUserContentController of a live WKWebView, if you want more fine-grained control on which interfaces are exposed at what time.

iOS

1
2
3
4
5
6
7
8
9
10
11
    let userContentController = webView
        .configuration
        .userContentController
    // Alternatively, add the script message handler(s)
    // to configuration.userContentController
    // before creating the WKWebView.

    // This example uses Self as the handler.
    // It could be a separate object also.
    userContentController.add(self, name: "completed")
    userContentController.add(self, name: "canceled")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
    extension MyClass : WKScriptMessageHandler {
        func userContentController(
            _ userContentController: WKUserContentController,
            didReceive message: WKScriptMessage
        ) {
            switch message.name {
            case "completed":
                // Handle payment completion (success/failure)

            case "canceled":
                // Handle payment cancellation
            }

            // the argument of the call is available at message.body
        }
    }

On iOS, the interfaces added by WKUserContentController.add(_:name:) are exposed in JavaScript as window.webkit.messageHandlers.<name>.postMessage(body), so your completeUrl and cancelUrl pages would need to eventually execute code like

1
    window.webkit.messageHandlers.completed.postMessage("success")
1
    window.webkit.messageHandlers.canceled.postMessage()

JavaScript Hooks: Android

warning

Security Warning

Never use WebView.addJavascriptInterface on Android versions earlier than 4.2 (Build.VERSION_CODES.JELLY_BEAN_MR1)!

On Android, JavaScript interfaces are added by the WebView.addJavascriptInterface method. Any public methods with the @JavascriptInterface annotation of the passed-in object are exposed in JavaScript.

Android

1
2
3
4
    webView.addJavascriptInterface(
        MyJsInterface(),
        "callbacks"
    )
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
    class MyJsInterface {
        // IMPORTANT!
        // Methods annotated with @JavascriptInterface are
        // NOT called on the main thread. They are called on
        // a private, background, WebView thread.
        // Make sure to only call methods that are safe
        // to call in a background thread, or move execution to
        // the main thread, e.g. by ViewModel.viewModelScope
        // or LifecycleOwner.lifecycleScope (remember that
        // FragmentActivity and Fragment implement LifecycleOwner).

        @JavascriptInterface
        fun completed(status: String) {
            // Handle payment completion (success/failure)
        }

        @JavascriptInterface
        fun canceled() {
            // Handle payment cancellation
        }
    }

On Android, the objects added by WebView.addJavascriptInterface are exposed as globals with the specified name, and their @JavascriptInterface public methods with their JVM names (N.B! Be careful not to break the JVM names with Proguard or similar). Thus, your completeUrl and cancelUrl pages would need to eventually execute code like

1
    callbacks.completed("success")
1
    callbacks.canceled()

External Applications

Before starting to implement lauching external applications, you should try to get at least one card payment working. With completion observing in place, you should be able to complete a payment flow, at least using the External Integration environment and its test cards.

Sometimes, a payment flow calls for launching an external application, like BankID or Swish. A web page does this by opening a url that is handled by the app in question. To accommodate for this, we extend the “Listening for Navigations” approach above. If you opted for JavaScript hooks for completion, you will now need to add a navigation listener for external apps.

Determining whether a url should launch an external app is straightforward, though on Android it involves a bit of a judgement call. Let us take a look at the arguably simpler iOS case first.

External Applications: iOS

You cannot query the system for an arbitrary url to see if it can be opened – this is a deliberate privacy measure. What can be done, and what also happens to be exactly what we want to do, is to attempt to open a url and receive a callback telling us whether it succeeded. Nowadays, the recommended way of opening external applications is to use Universal Links, anyway, which are, on the surface, indistiguishable from web links.

iOS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
    func webView(
        _ webView: WKWebView,
        decidePolicyFor navigationAction: WKNavigationAction,
        decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
    ) {
        // Check for completeUrl and cancelUrl here, if applicable.

        if let url = navigationAction.request.url {
            openInExternalApp(
                url: url,
                decisionHandler: decisionHandler
            )
        } else {
            // N.B. This should never happen.
            decisionHandler(.allow)
        }
    }

    private func openInExternalApp(
        url: URL,
        decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
    ) {
        // First, check for universal link
        UIApplication.shared.open(
            url,
            options: [.universalLinksOnly: true]
        ) { universalLinkOpened in
            if universalLinkOpened {
                // Url was opened in external app, so do not open it in WKWebView.
                decisionHandler(.cancel)
            } else {
                // Url was not opened in external app, see if WKWebView can handle it.
                if let scheme = url.scheme,
                    WKWebView.handlesURLScheme(scheme) {
                    // Regular http(s) url, proceed.
                    decisionHandler(.allow)
                } else {
                    // Custom-scheme url. Try to open it.
                    UIApplication.shared.open(url)
                    // Cancel the navigation regardless of the result,
                    // as WKWebView does not know what to do with the url anyway.
                    decisionHandler(.cancel)
                }
            }
        }
    }

External Applications: Android

On Android, web pages attempting to launch external apps happens in one of three ways:

  • Custom-scheme links
  • Http(s) links matching a pattern
  • Intent-scheme links

Each of these maps into an Intent. For custom-scheme and patterned http(s) links, that Intent has the original url as its uri, an action of android.intent.action.VIEW, and the categories android.intent.category.BROWSABLE and android.intent.category.DEFAULT. An Intent created from an intent-scheme url can have any action and categories, although they too should have an implicit android.intent.category.BROWSABLE category. Their uri is parsed from the intent-scheme url, but we need not trouble ourselves with the specifics here.

When the WebView navigates to a new page, your app should check if the page url should launch an app instead. The custom-scheme and intent-scheme cases are simple: try to start an Activity with the Intent parsed from the url as described above. If that fails (by throwing an ActivityNotFoundException), then a suitable app was not installed. If the navigation was to an intent-scheme url, that url may contain a fallback url that can be substituted. Otherwise, there is little your app can do beyond notifying the user about the missing app.

When the WebView navigates to an http(s) url, your app should not simply start an Activity with the url, as that would usually mean opening the url in the browser. Instead, the Activity should only be started if it is not a browser. Since Android 11 there is an Intent flag that does exactly that. On earlier versions, your app must first query the system about which app would be launched. Because of privacy enhancements in Android 11, it is not possible to use this method on Android 11; you must use the non-browser flag instead.

Android

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
    override fun shouldOverrideUrlLoading(
        view: WebView?,
        url: String?
    ): Boolean {
        // Check for completeUrl and cancelUrl here, if applicable.

        if (url == null) return false // should never happen

        val uri = Uri.parse(url)
        if (openInExternalApp(uri)) {
            return true
        } else {
            // uri was not opened in a external app.
            // Let WebView take care of it, if it is
            // a normal http(s) url.
            return when (uri.scheme) {
                "http", "https" -> false
                else -> true
            }
        }
    }

    private fun openInExternalApp(uri: Uri): Boolean {
        try {
            // Create an Intent from the Uri.
            val intent = Intent.parseUri(
                uri.toString(),
                Intent.URI_INTENT_SCHEME
            )

            // Web pages should only be allowed to start activities
            // with CATEGORY_BROWSABLE.
            intent.addCategory(Intent.CATEGORY_BROWSABLE)

            // Only start an Activiy if it is not a browser
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
                intent.addFlags(Intent.FLAG_ACTIVITY_REQUIRE_NON_BROWSER)
            } else {
                legacyRequireNonBrowser(intent)
            }

            startActivity(intent)
            return true
        } catch (_: URISyntaxException) {
            return false
        } catch (_: ActivityNotFoundException) {
            return false
        }
    }

    private fun legacyRequireNonBrowser(intent: Intent) {
        // For Android < 11, need to simulate FLAG_ACTIVITY_REQUIRE_NON_BROWSER.
        // This is just one way of doing it.
        val scheme = intent.scheme
        if (scheme == "http" || scheme == "https") {
            // Resolve the Intent. If null, then we don't have the app installed.
            val resolveInfo = packageManager.resolveActivity(
                intent,
                PackageManager.MATCH_DEFAULT_ONLY
            ) ?: throw ActivityNotFoundException()

            // Using "host" match category as a heuristic here.
            // An app that handles http(s) uris for any host is more than likely a browser.
            // Could use e.g. a list of package ids instead.
            val matchCategory = resolveInfo.match and IntentFilter.MATCH_CATEGORY_MASK
            if (matchCategory < IntentFilter.MATCH_CATEGORY_HOST) {
                throw ActivityNotFoundException()
            }
        }
    }

Getting Back from External Applications

In some cases on Android, getting back from the external application requires no further setup. In particular, this is the case with BankID, if the web page launches it in the recommended manner. In other cases, including any scenario on iOS, the external app will attempt to return to the payment by opening the paymentUrl. Assuming the paymentUrl is an https url, it would normally be opened in the browser application (usually Safari or Chrome), so we need to build a system that gets it back to the application where the payment is being processed in a web view.

Using a Custom-Scheme paymentUrl

Perhaps the simplest way of making paymentUrl open in the application is to make it a custom-scheme url rather than an https url. This does come with a few disadvantages, though:

  • On iOS, the system will show a confirmation popup, which cannot be customized, before opening a custom-scheme url
  • Related to the above, there is no way of making sure your application is the only one installed that handles the scheme
  • paymentUrl is passed to systems outside Swedbank Pay; systems that may only be compatible with http(s) urls

It is somewhat of a Quick and Dirty solution. We do not recommend this approach.

On iOS, the recommended way of assigning urls to apps is to use Universal Links. This fits our use-case quite well, and indeed it is what the SDK is designed to do too. When an external app executes the UIApplication.shared.open("https://example.com/perform-payment"), then, assuming Universal Links are configured correctly, that url will not be opened in Safari, but will instead be opened in the application. You must then examine the url, determine that it is a paymentUrl from your app, and reload the paymentUrl in your web view. The payment process should then continue normally. Make sure that any navigation listeners and JavaScript hooks are in place before loading the paymentUrl.

Now, Universal Links depend on correct configuration, and during development you may find yourself with a broken configuration from time to time. But perhaps even more importantly, Universal Links cannot really be 100% guaranteed to work every time. Please see the iOS SDK documentation for some discussion, but also note that even with correct configuration, the system could fail to retrieve your apple-app-site-association file for any given installation, which could render your universal links temporarily inoperable on that device. This means that your paymentUrl needs to show some sensible content in case it is opened in Safari. There are a few ways of going at this, but one possibility, assuming you have a working implementation for web in place, is to show your regular payment page, allow the payment to complete there, and then try to launch your application, perhaps by a custom-scheme url, or a universal link to a separate domain. Take a look at what the SDK does to not be trapped by unhappy circumstances.

Note that the Universal Links documentation is not explicit on which UIApplicationDelegate method is called when an application opens a universal link with UIApplication.open(_:options:completionHandler:) (i.e. application(_:open:options:) or application(_:continue:restorationHandler:)). It is probably best to implement both. Universal Links opened from Safari will callback to application(_:continue:restorationHandler:).

Android: Add an Intent Filter for paymentUrl

Android has always supported apps handling urls matching a pattern. Therefore, it seems sensible to just create an intent filter matching any paymentUrl you might create. As paymentUrls are entirely under your control, you can design a system where they fit a pattern that can be realized as an intent filter. You then receive the url in the relevant app component in the normal manner, and proceed to reload the paymentUrl in your web view. The payment process will then continue normally.

The downsides of this are:

  • You are restricted in how you can change the way you form paymentUrls
  • There are other apps that can also handle the paymentUrl, namely the browser

Because of the latter, when an external application opens paymentUrl, there are three things that can happen:

  • paymentUrl is opened in your app
  • paymentUrl is opened in another app, e.g. Chrome
  • an app chooser is shown

The second one is obviously undesirable. The last one is also not great. The user is not expecting to “open a url”, and may well make the “wrong” choice here, and it is anyway a bad user experience.

Autoverify to the Rescue?

Since Android 6.0 it has been possible to use a mechanism very similar to Apple’s Universal Links to “strongly” assing http(s) urls to applications. This works by adding an android:autoVerify="true" attribute to the intent filter, plus a .well-known/assetlinks.json file to the server. This could solve the problems above, but it has its own issues, namely:

  • Requires Android 6.0
  • Is really quite cumbersome to setup

The SDK does not use this method.

Android: Have paymentUrl Redirect to an Intent Url

Another option on Android is to allow the https paymentUrl to be opened in Chrome normally, but have that url redirect to an intent url. That intent url can be made specific to your application, making it so that unless the user has installed an application with the same package id (from a non-Google-Play source, presumably), it will always be opened in your app. This is what the SDK does.

The SDK does this by having paymentUrl return an html page that immediately redirects. In some cases the redirect will be blocked, so the page also contains a link to the same url, so the user can manually follow the redirect. Now, as here we seem to want to have paymentUrl be the url loaded in the WebView, this does not work out-of-the-box. One option is to override shouldInterceptRequest in your WebViewClient, and special-case the loading of paymentUrl. Another solution could be loading paymentUrl normally, but adding a script to the page that checks for a JavaScript interface you provide in the WebView, and it is not there, then it would issue the redirect to the intent url.

For reference, the way the SDK handles paymentUrls on Android looks like this from the perspective of the backend:

Request

1
2
GET /perform-payment HTTP/1.1
Host: example.com

Response

1
2
3
4
5
6
7
8
9
10
11
12
HTTP/1.1 200 OK
Content-Type: text/html

<html>
    <head>
        <title>Swedbank Pay Payment</title>
        <meta http-equiv="refresh" content="0;url=intent://example.com/perform-payment#Intent;scheme=https;action=com.swedbankpay.mobilesdk.VIEW_PAYMENTORDER;package=com.example.app;end;">
    </head>
    <body>
        <a href="intent://example.com/perform-payment#Intent;scheme=https;action=com.swedbankpay.mobilesdk.VIEW_PAYMENTORDER;package=com.example.app;end;">Back to app</a>
    </body>
</html>

It uses an action defined by the SDK, and the package name of the containing application, making sure the intent is routed to the correct application, and to the correct SDK component. Note that the uri of the resulting intent is the paymentUrl.

Dealing with Picky Web Pages

Testing has shown, that on iOS some 3D-Secure pages do not like being opened in a web view. It does seem that this is mostly related to BankID integrations. We believe the problem stems from a configuration that sets a cookie in the browser, launches BankID, then BankID opens a different web page (not the paymentUrl), which expects to find that cookie. Now, if the first page was opened in a web view, the cookie is in that web view, but as the second page will be opened in Safari, the cookie will be nowhere to be found. Furthermore, at least in one instance, the original page in the web view will not receive any notification on the BankID process, despite being launched from there. We have not encountered this on Android, but it is quite possible for a similar situation to happen there also.

Now, all of the above is speculation, and not really worth getting too deep into. The end result, however, is that some 3DS pages must be opened in Safari on iOS. The jury is still out if the same is true on Android. As we already have a way of getting back to the app (ref. paymentUrl), the simple thing to do would be to open any url not tested to work in Safari. Unfortunately, matters are further complicated by some pages not working if we do that. The two pages found in testing are now added to the list of known good pages (as the do work in the web view), but others may be out there. The current best solution is therefore to open the current page in Safari if it tries to navigate to an unknown page. This is what the SDK does: if it encounters a navigation that does not match the goodlist, it will take the current page url of the web view, and open that with UIApplication.shared.open(url) and call the decisionHandler with .cancel. (It will never happen in practice, but if the payment menu would be the current page in this situation, it will load the new url instead).

The payment will eventually navigate to paymentUrl in Safari, and should return to the app. It should be noted, though, that in many cases the initial navigation to paymentUrl will be opened in Safari instead of the app in these cases. This acerbates the need for fallback mechanisms.

The iOS (and possibly Android) SDKs will contain a list of known-good 3DS pages. Feel free to use this as a resource in your own implementation.