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 toJSON.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
orWKUIDelegate
. - On Android, remember to call
webView.onResume()
andwebView.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. thepaymentUrl
-
https://example.com/payment-completed
is thecompleteUrl
-
https://example.com/payment-cancelled
is thecancelUrl
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 behavior.
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-cancelled":
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-cancelled" -> {
// 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: "cancelled")
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 "cancelled":
// 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.cancelled.postMessage()
JavaScript Hooks: Android
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 cancelled() {
// 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.cancelled()
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.
iOS: Make paymentUrl A Universal Link
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 The 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 paymentUrl
s 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
paymentUrl
s - 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 paymentUrl
s 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.