How to Build Flutter Android Apps to Scan Documents from AirPrint MFPs

AirPrint MFPs (Multifunction Printers) are printers that are compatible with Apple’s AirPrint technology. The eSCL (eXtensible Scanner Control Language) protocol, a driverless scanning protocol, is a part of the AirPrint technology. It facilitates wireless scanning functionality on AirPrint MFPs. Users can discover AirPrint MFPs on the same network and initiate scanning operations using their mobile devices. This article focuses on building a hybrid Flutter Android app that utilizes web view and native code to scan documents from AirPrint MFPs.

A List of MFPs Supporting AirPrint

  • Canon imageCLASS MF743Cdw
  • HP OfficeJet 250
  • HP OfficeJet 3830
  • Canon PIXMA iX6820
  • HP OfficeJet Pro 9025e
  • Canon Pixma TR8620
  • Pixma TR150

Prerequisites

  • An AirPrint supported device in the same network as the Android device
  • Install Dynamsoft Service from Google Play.

The Free Online Document Scanning Demo

The Dynamsoft service app is an Android background service that provides a communication channel between the web browser and the wireless scanners.

Android Dynamsoft Service

You can click the Online Demo button to launch the free online document scanning demo in mobile web browsers. The demo is also compatible with Windows, Linux, and macOS systems by installing the appropriate Dynamsoft services. For basic document scanning needs, the online demo is sufficient. However, if you require a more robust document scanning solution with advanced features, consider coding with the Dynamic Web TWAIN SDK, which offers a 30-day free trial.

Why Creating Hybrid App instead of Web Browser?

While using the online demo in a web browser, saving and sharing documents could be inconvenient. A hybrid app offers a better user experience. By loading the demo within a web view, a hybrid app provides a seamless integration of web technology and native capabilities. With the use of native code, the app can store the scanned images directly to the local storage, enabling more efficient and flexible processing options for the user. This combination of web and native functionality ensures a smoother and more user-friendly document scanning experience.

Building Android Document Scanning App with Flutter

In the following sections, we will show you how to use Flutter webview to load the online document scanning demo and how to combine JavaScript and Dart code to process the scanned images.

Dependent Flutter Packages

Before getting started, you need to add the following packages to the pubspec.yaml file:

  • webview_flutter - A Flutter plugin that provides a WebView widget.
  • permission_handler - A Flutter plugin for requesting permissions on Android and iOS.
  • webview_flutter_android - A Flutter plugin that provides a WebView widget on Android.
  • url_launcher - A Flutter plugin for launching a URL in the mobile platform.
  • android_intent_plus - A Flutter plugin for launching Android Intents.
  • path_provider - A Flutter plugin for finding commonly used locations on the filesystem.
  • share_plus - A Flutter plugin for sharing content via the platform share UI.

Android Permissions

There are two permissions required for the app:

<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
  • android.permission.CAMERA - Required for accessing the camera in the web view.
  • android.permission.QUERY_ALL_PACKAGES - Required for launching the external Dynamsoft service app.

Flutter Web View: Load URL, and Handle Navigation and Permission Requests

According to the sample code of the webview_flutter package, the online document scanning demo can be loaded into the Android web view as follows:

_controller = WebViewController()
  ..setJavaScriptMode(JavaScriptMode.unrestricted)
  ..setBackgroundColor(const Color(0x00000000))
  ..setNavigationDelegate(
    NavigationDelegate(
      onProgress: (int progress) {},
      onPageStarted: (String url) {},
      onPageFinished: (String url) {},
      onWebResourceError: (WebResourceError error) {},
      onNavigationRequest: (NavigationRequest request) {
        return NavigationDecision.navigate;
      },
    ),
  )
  ..loadRequest(Uri.parse('https://demo3.dynamsoft.com/web-twain/mobile-online-camera-scanner/'));

If the online demo cannot connect to a background Dynamsoft service, it will prompt you to install it:

Dynamsoft service not launched

When using an Android web browser, simply click the Open Service button to install or launch the Dynamsoft service app. However, when operating within the web view, you may encounter a 404 page. To address this issue, we need to handle the request in the onNavigationRequest callback. The Dynamsoft service activity can be initiated by its package and componentName. In cases where the Dynamsoft service app is not installed on the device, we can take the appropriate action by opening the Google Play store.

Future<void> launchURL(String url) async {
  await launchUrlString(url);
}

Future<void> launchIntent() async {
  if (Platform.isAndroid) {
    try {
      AndroidIntent intent = const AndroidIntent(
        componentName: 'com.dynamsoft.mobilescan.MainActivity',
        package: 'com.dynamsoft.mobilescan',
      );
      await intent.launch();
    } catch (e) {
      // If the app is not installed, open the Google Play store to install the app.
      launchURL(
          'https://play.google.com/store/apps/details?id=com.dynamsoft.mobilescan');
    }
  }
}

onNavigationRequest: (NavigationRequest request) {
  if (request.url.startsWith('intent://')) {
    launchIntent();
    return NavigationDecision.prevent;
  } else if (request.url.endsWith('.apk')) {
    launchURL(
        'https://play.google.com/store/apps/details?id=com.dynamsoft.mobilescan');
    return NavigationDecision.prevent;
  }
  return NavigationDecision.navigate;
},

Another problem you may encounter is the camera access within the Android web view. The online demo allows you to capture documents from both scanners and cameras. By default, the camera access is disabled in the Android web view. You must grant the camera permission in the native code.

camera access in Android web view

The workaround is to handle the WebViewPermissionRequest in the onPermissionRequest callback. We can change the WebViewController creation code as follows:


Future<void> requestCameraPermission() async {
  final status = await Permission.camera.request();
  if (status == PermissionStatus.granted) {
  } else if (status == PermissionStatus.denied) {
  } else if (status == PermissionStatus.permanentlyDenied) {
  }
}

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

  late final PlatformWebViewControllerCreationParams params;
  params = const PlatformWebViewControllerCreationParams();

  _controller = WebViewController.fromPlatformCreationParams(
    params,
    onPermissionRequest: (WebViewPermissionRequest request) {
      request.grant();
    },
  )
    ..setJavaScriptMode(JavaScriptMode.unrestricted)
    
  ...

  requestCameraPermission();
}

Now, the demo should work as expected in the Flutter web view. The next step is to optimize the user experience by adding some Dart code.

Flutter Tab Bar

In comparison to the UI of the original online demo, you may have observed that we have made some changes by hiding the About and Contact Us tabs implemented in HTML5.

document scan demo in web view

A Flutter tab bar is added at the bottom of the app for switching the web view and other native views.

Flutter web view and native view

How to hide the HTML elements in JavaScript?

  1. Enable the Debugging option in the web view.
     if (_controller.platform is AndroidWebViewController) {
       AndroidWebViewController.enableDebugging(true);
     }
    
  2. Open edge://inspect/#devices or chrome://inspect/#devices in Edge or Chrome to inspect the HTML elements.

    debug and inspect HTML elements

  3. In the console, execute the following JavaScript code to hide the About and Contact Us tabs:

     let parentElement = document.getElementsByClassName('dcs-main-footer')[0];
     let tags = parentElement .getElementsByTagName('div');
     tags[0].remove();
     tags[6].remove();
    

    In addition, hide the result panel, which is replaced by the native history page:

     document.getElementsByClassName('dcs-main-content')[0].style.display = 'none';
    
  4. Use the onProgress callback to execute the JavaScript code:

     NavigationDelegate(
         onProgress: (int progress) {
           if (progress == 100) {
             String jscode = '''
           let parentElement = document.getElementsByClassName('dcs-main-footer')[0];
           let tags = parentElement .getElementsByTagName('div');
           tags[0].remove();
           tags[6].remove();
           document.getElementsByClassName('dcs-main-content')[0].style.display = 'none';
           ''';
             _controller
                 .runJavaScript(jscode)
                 .then((value) => {setState(() {})});
           }
         },
     )
    

How to create a tab bar in Flutter?

class _MyAppPageState extends State<MyAppPage>
    with SingleTickerProviderStateMixin {
  late TabController _tabController;

  @override
  void initState() {
    super.initState();
    _tabController = TabController(vsync: this, length: 3);

    ...
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: TabBarView(
        controller: _tabController,
        children: [
          HomeView(title: 'Web TWAIN Demo', controller: _controller),
          const HistoryView(title: 'History'),
          const AboutView(title: 'About the SDK'),
        ],
      ),
      bottomNavigationBar: TabBar(
        labelColor: Colors.blue,
        controller: _tabController,
        tabs: const [
          Tab(icon: Icon(Icons.home), text: 'Home'),
          Tab(icon: Icon(Icons.history_sharp), text: 'History'),
          Tab(icon: Icon(Icons.info), text: 'About'),
        ],
      ),
    );
  }
}

Save Base64 Images from Web View to Local Storage

As a document is acquired within the web view, the image data can be retrieved as a base64 string.

DWObject.ConvertToBase64([DWObject.CurrentImageIndexInBuffer], 1, (result, indices, type) =>{})

We add a communication channel between the web view and native code using the addJavaScriptChannel method.

_controller = WebViewController.fromPlatformCreationParams(
    params,
    onPermissionRequest: (WebViewPermissionRequest request) {
      request.grant();
    },
  )
    ..addJavaScriptChannel(
      'ImageData',
      onMessageReceived: (JavaScriptMessage message) {
        saveImage(message.message).then((value) {
          showToast(value);
        });
      },
    )

The JavaScript code can then call postMessage to send a message to Dart code. We create a button to trigger the image saving operation when the JavaScript image buffer is not empty. The onMessageReceived callback is invoked when the message is received.

IconButton(
  icon: const Icon(Icons.save),
  onPressed: () async {
    widget.controller.runJavaScript(
        'DWObject.ConvertToBase64([DWObject.CurrentImageIndexInBuffer], 1, (result, indices, type) =>{ImageData.postMessage(result._content)})');
  },
),

How to save a base64 string to a local file in Flutter?

String getImageName() {
  // Get the current date and time.
  DateTime now = DateTime.now();

  // Format the date and time to create a timestamp.
  String timestamp =
      '${now.year}${now.month}${now.day}_${now.hour}${now.minute}${now.second}';

  // Create the image file name with the timestamp.
  String imageName = 'image_$timestamp.jpg';

  return imageName;
}

Future<String> saveImage(String base64String) async {
  Uint8List bytes = base64Decode(base64String);

  // Get the app directory
  final directory = await getApplicationDocumentsDirectory();

  // Create the file path
  String imageName = getImageName();
  final filePath = '${directory.path}/$imageName';

  // Write the bytes to the file
  await File(filePath).writeAsBytes(bytes);

  return filePath;
}

The showToast() method is implemented with platform-specific code:

  1. Add the following Kotlin code in MainActivity.kt:
     override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
       super.configureFlutterEngine(flutterEngine)
       MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "AndroidNative")
         .setMethodCallHandler { call, result ->
           when (call.method) {
             "showToast" -> {
               val message = call.argument<String>("message")
               showToast(message!!)
               result.success(null)
             }
             else -> {
               result.notImplemented()
             }
           }
         }
     }
    
     private fun showToast(message: String) {
         Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
       }
    
  2. Invoke the showToast() method in Dart code:
     void showToast(String message) {
       const platform = MethodChannel('AndroidNative');
       platform.invokeMethod('showToast', {'message': message});
     }
    

View and Share Scanned Images

After saving the scanned images to the local storage, we can get the file list as follows:

Future<List<String>> getImages() async {
  // Get the app directory
  final directory = await getApplicationDocumentsDirectory();

  // Get the list of files in the app directory
  List<FileSystemEntity> files = directory.listSync();

  // Get the file paths
  List<String> filePaths = [];
  for (FileSystemEntity file in files) {
    if (file.path.endsWith('.jpg')) {
      filePaths.add(file.path);
    }
  }

  return filePaths;
}

To view the image, we create a new stateless widget and pass the file path as a parameter:

class DocumentView extends StatelessWidget {
  final String filePath;
  const DocumentView({super.key, required this.filePath});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Document Viewer'),
      ),
      body: Center(child: Image.file(File(filePath))),
    );
  }
}

flutter image view

An image file can be shared with the Share.shareXFiles() method:

IconButton(
  icon: const Icon(Icons.share),
  onPressed: () async {
    if (selectedValue == -1) {
      return;
    }

    await Share.shareXFiles([XFile(_results[selectedValue])],
        text: 'Check out this image!');
  },
),

Source Code

https://github.com/yushulx/web-twain-document-scan-management/tree/main/examples/flutter_android_webview