Flutter

Opening external links

Inside our Apps we might have external links that require the user to navigate to a different page, such as Privacy Policy links, in these cases we add a query parameter to the URL named openInExternalBrowser=true to indicate to our customers that those links must be opened in the user's default browser so that we do not lose context of the application.

📘

For most Apps, you should do just fine by opening external links using an In-App browser, but for the Fund product, it is imperative that you open external links in the user's default browser so that they can have a better UX.

Javascript channel

To use our SDK with Flutter, you must add a JavaScriptChannel named FlutterWebViewwhen using the webview_flutter package. This channel allows our server to communicate with WebViews in Flutter. The example below shows the usage of both the Javascript channel and the external link implementation

// ignore_for_file: avoid_print

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
// https://pub.dev/packages/webview_flutter
import 'package:webview_flutter/webview_flutter.dart';
// https://pub.dev/packages/webview_flutter_android
import 'package:webview_flutter_android/webview_flutter_android.dart';
// https://pub.dev/packages/webview_flutter_wkwebview
import 'package:webview_flutter_wkwebview/webview_flutter_wkwebview.dart';
// https://pub.dev/packages/url_launcher
import 'package:url_launcher/url_launcher.dart';

const String sdkMobileServer = 'https://sdk-mobile.cert.zerohash.com/v1';
const String zeroHashAppsURL = 'https://web-sdk.cert.zerohash.com';

class WebViewScreen extends StatefulWidget {
  const WebViewScreen({super.key});

  @override
  WebViewScreenState createState() => WebViewScreenState();
}

class WebViewScreenState extends State<WebViewScreen> {
  String token = "";
  late WebViewController controller;

  @override
  void initState() {
    super.initState();
    fetchData();
  }

  // Setup webview controller with the token in the URL
  // Do not remove FlutterWebView channel, it is required for communication between webview and webpage
  void setupController() {
      late final PlatformWebViewControllerCreationParams params;
      if (WebViewPlatform.instance is WebKitWebViewPlatform) {
        params = WebKitWebViewControllerCreationParams(
          allowsInlineMediaPlayback: true,
          mediaTypesRequiringUserAction: const <PlaybackMediaTypes>{},
        );
      } else {
        params = const PlatformWebViewControllerCreationParams();
      }

      controller = WebViewController.fromPlatformCreationParams(params);
      if (controller.platform is AndroidWebViewController) {
        AndroidWebViewController.enableDebugging(true);
        (controller.platform as AndroidWebViewController).setMediaPlaybackRequiresUserGesture(false);
      }

      controller
        ..setJavaScriptMode(JavaScriptMode.unrestricted)
        ..addJavaScriptChannel('FlutterWebView', onMessageReceived: onMessageReceived)
        ..setNavigationDelegate(
          NavigationDelegate(
            onNavigationRequest: (NavigationRequest request) async {
              print('Request URL: ${request.url}');
              if (request.url.contains('openInExternalBrowser=true')) {
                _launchURL(request.url);
                return NavigationDecision.prevent;
              }
              print('Navigating to internal URL: ${request.url}');
              return NavigationDecision.navigate;
            },
          ),
        )
        ..loadRequest(
          Uri.parse('$sdkMobileServer?achDepositsJWT=$token&cryptoBuyJWT=$token&cryptoSellJWT=$token&cryptoWithdrawalsJWT=$token&fiatWithdrawalsJWT=$token&userOnboardingJWT=$token&zeroHashAppsURL=$zeroHashAppsURL')
      );
  }

  // Launch URL in external browser
  void _launchURL(String url) async {
    if (await canLaunchUrl(Uri.parse(url))) {
      await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication);
    } else {
      throw 'Could not launch $url';
    }
  }

  // Fetch token from the server and setup webview controller with the token
  Future<void> fetchData() async {
    try {
      final url = Uri.parse('<API_URL_TO_FETCH_TOKEN>');
      final response = await http.post(url,
          body: json.encode(
            {
              'participant_code': '<PARTICIPANT_CODE>',
              'permissions': [
                'crypto-buy',
                'crypto-sell',
                'crypto-withdrawals',
                'fiat-deposits',
                'fiat-withdrawals',
              ]
            }
          )
        );
      if (response.statusCode >= 200 && response.statusCode < 300) {
        // Store the data in the 'token' and setup webview controller
        var body = json.decode(response.body);
        setState(() {
          token = body['message']['token'];
        });
        setupController();
      } else {
        print('Failed to fetch data: $response');
      }
    } catch (e) {
      // Handle network or API call errors
      print('Failed to fetch data: $e');
    }
  }

  // Handle messages received from the webview
  void onMessageReceived(JavaScriptMessage message) {
    try {
      print('Receving message: ${message.message}');
      // Parse the message JSON
      Map<String, dynamic> messageJson = jsonDecode(message.message);
      if (messageJson.containsKey('type')) {
        // Indicates that the SDK mobile is ready to receive postMessage
        if (messageJson['type'] == 'SDK_MOBILE_READY') {
          print('SDK mobile ready, opening modal');
          // Open the modal for the app with the identifier 'crypto-sell'
          controller.runJavaScript('window.postMessage({ "type": "OPEN_MODAL", "payload": { "appIdentifier": "crypto-buy"}})');
        }
      }
    } catch (e) {
      print('Error parsing JSON: $e');
    }
  }

  Widget _buildChild() {
    // If the token is empty, show a loading message
    if (token.isEmpty) {
      return const Center(
        child: Text('Fetching token...'),
      );
    }
    // Return the WebViewWidget with the controller
    return WebViewWidget(controller: controller);
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        resizeToAvoidBottomInset: false, // Setting resizeToAvoidBottomInset to false ensures that the UI layout remains stable when the keyboard or other insets appear, preventing any unintended resizing that could disrupt the user interface.
        body: SafeArea(
          child: Column(
            children: [
              Expanded(
                child: _buildChild(),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Fiat Account link (Android)

For Android the experience should not require any changes and at this first phase the user will be sent to the external browser to complete the Application with Plaid, once he finishes that the user will be presented with a "Success" page asking him to return to the Native Application and continue the flow.

Fiat Account link (iOS ASWebAuthenticationSession)

Overview

This guide explains how to add special handling for Plaid authentication flows in your Flutter iOS app using ASWebAuthenticationSession. This provides a native iOS authentication experience and automatically closes when the flow completes.

What This Does

  • Detects when users navigate to plaid.com URLs in your WebView
  • Launches native iOS ASWebAuthenticationSession instead of loading in WebView
  • Automatically closes the authentication session when it detects completion
  • Provides better user experience with native iOS authentication UI

Implementation Steps

  1. Update onNavigationRequest
onNavigationRequest: (NavigationRequest request) async {
  print('Request URL: ${request.url}');
  final uri = Uri.parse(request.url);

  // Check if the URL contains plaid.com and the platform is iOS
  if (uri.host.contains('plaid.com') && Platform.isIOS) {
    try {
      const platform = MethodChannel('com.example.app/auth_session');

      final result = await platform.invokeMethod('startAuthSession', {
        'url': request.url,
        'callbackURLScheme': 'zerohashapp', // custom scheme
      });

      print('ASWebAuthenticationSession completed with: $result');
    } catch (e) {
      print('Failed to start ASWebAuthenticationSession: $e');
    }
    return NavigationDecision.prevent;
  }

  if (request.url.contains('openInExternalBrowser=true')) {
    _launchURL(request.url);
    return NavigationDecision.prevent;
  }
  
  print('Navigating to internal URL: ${request.url}');
  return NavigationDecision.navigate;
},
  1. You should update your AppDelegate.swift file to include ASWebAuthenticationSession support, MethodChannel handler, and presentation context provider so that it handles Plaid authentication flows with native iOS authentication UI and automatically closes when the authentication completes.
    Required Changes:
    1. Import AuthenticationServices
    2. Add ASWebAuthenticationPresentationContextProviding protocol to handle authentication presentation
    3. Create MethodChannel handler to receive authentication requests from Flutter
    4. Implement presentation anchor method to specify where the authentication UI appears
      Below you can find an example of an AppDelegate file with these handlers. Feel free to adjust to your needs:
import UIKit
import Flutter
import AuthenticationServices

@main
@objc class AppDelegate: FlutterAppDelegate, ASWebAuthenticationPresentationContextProviding {
  private var authSession: ASWebAuthenticationSession?

  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    let controller = window?.rootViewController as! FlutterViewController
    let authChannel = FlutterMethodChannel(name: "com.example.app/auth_session",
                                           binaryMessenger: controller.binaryMessenger)
    
    authChannel.setMethodCallHandler { [weak self] call, result in
      switch call.method {
      case "startAuthSession":
        guard
          let args = call.arguments as? [String: Any],
          let urlString = args["url"] as? String,
          let startURL = URL(string: urlString),
          let callbackScheme = args["callbackURLScheme"] as? String
        else {
          result(FlutterError(code: "INVALID_ARGUMENTS", message: "Missing arguments", details: nil))
          return
        }
        
        if #available(iOS 13.0, *) {
          self?.authSession = ASWebAuthenticationSession(url: startURL, callbackURLScheme: callbackScheme) { callbackURL, error in
            if let error = error {
              result(FlutterError(code: "AUTH_SESSION_ERROR", message: error.localizedDescription, details: nil))
            } else {
              result("session_completed")
            }
          }
          
          self?.authSession?.presentationContextProvider = self
          self?.authSession?.start()
        } else {
          result(FlutterError(code: "UNSUPPORTED_IOS", message: "Requires iOS 13+", details: nil))
        }
        
      default:
        result(FlutterMethodNotImplemented)
      }
    }
    
    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }

  func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
    return window ?? ASPresentationAnchor()
  }
}