How to Build a Flutter Document Scanner App for Android and iOS That Scans, Edits, and Saves PDFs

A mobile document scanner app typically includes features such as document capture, editing, and saving as PDF files. Since we’ve already created a web application with similar functionality using HTML5, JavaScript, and the Dynamsoft Document Viewer SDK,we can integrate that web app into an Android/iOS WebView to quickly develop mobile document scanner apps. In this tutorial, we’ll walk you through the process of creating such a hybrid mobile app using Flutter and HTML5.

What you’ll build: A Flutter hybrid mobile app for Android and iOS that embeds an HTML5 document scanner in a WebView, captures documents with the device camera, and saves them as PDF files to local storage — powered by the Dynamsoft Document Viewer SDK.

Key Takeaways

  • You can embed an existing HTML5/JavaScript document scanner into a Flutter app via WebView without rebuilding the scanning UI natively.
  • Dart–JavaScript interop through JavaScriptChannel passes PDF bytes from the browser context directly to the Flutter file system.
  • Android WebView requires an HTTP localhost server (shelf) to expose both front and back cameras; loading from file:// restricts mediaDevices to the front camera only.
  • The finished app runs on both Android and iOS, storing PDFs locally with share, open, and delete actions available in a history view.

Common Developer Questions

  • How do I build a Flutter document scanner app for Android and iOS that saves PDFs?
  • How do I pass data from JavaScript (WebView) to Dart in Flutter?
  • Why can’t my Flutter Android WebView access the back camera?

Demo: Scan Document and Save PDF with Flutter Mobile App

Prerequisites

  • 30-day Trial License: Get a license key to activate the Dynamsoft Document Viewer.
  • Dynamsoft Document Viewer: Download the sample code to learn how to scan, capture, edit, and save documents as PDFs. The project will be loaded by the Flutter WebView.

Step 1: Install Flutter Dependencies

Add the following dependencies to your pubspec.yaml file:

dependencies:
  ...

  webview_flutter: ^4.13.0
  webview_flutter_android: ^4.7.0
  webview_flutter_wkwebview: ^3.22.0
  image_picker: ^1.1.2
  permission_handler: ^12.0.0+1
  shelf: ^1.4.2
  path: ^1.9.1
  shelf_static: ^1.1.3
  path_provider: ^2.1.5
  fluttertoast: ^8.2.12
  share_plus: ^11.0.0
  open_file: ^3.5.10

Explanation

  • webview_flutter: Displays web content inside the Flutter app.
  • webview_flutter_android: Android-specific WebView implementation for webview_flutter.
  • webview_flutter_wkwebview: iOS WebKit-based WebView implementation for webview_flutter.
  • image_picker: Selects images from the gallery or camera.
  • permission_handler: Manages runtime permissions.
  • shelf: Serves as a web server for Dart applications.
  • path: Provides utilities for handling file paths.
  • shelf_static: Serves static assets using Shelf.
  • path_provider: Locates commonly used storage paths.
  • fluttertoast: Shows native toast messages.
  • share_plus: Shares files and text using the platform’s share interface.
  • open_file: Opens files with the system’s default app (e.g., PDF viewer).

Step 2: Load Local Web Assets into Flutter WebView

  1. Create an assets/web directory at the root of your project and place your HTML, JavaScript, and CSS files there.
  2. In pubspec.yaml, register the assets:

     assets:
     - assets/web/
    
  3. Load the HTML file in your Flutter code using the loadFlutterAsset method:

     WebViewController _controller =
           WebViewController()
             ..setJavaScriptMode(JavaScriptMode.unrestricted)
             ..enableZoom(true)
             ..setBackgroundColor(Colors.transparent)
             ..setNavigationDelegate(NavigationDelegate())
             ..platform.setOnPlatformPermissionRequest((request) {
               debugPrint(
                 'Permission requested by web content: ${request.types}',
               );
               request.grant();
             })
             ..loadFlutterAsset('assets/web/index.html');
    

    Flutter mobile document viewer

Step 3: Set Up Dart–JavaScript Interop to Save PDFs

To save a PDF file from JavaScript memory to the local file system, add a JavaScript channel named SaveFile to the WebViewController, which listens for messages:

_controller =
          WebViewController()
            ...
            ..addJavaScriptChannel(
              'SaveFile',
              onMessageReceived: (JavaScriptMessage message) async {
                List<dynamic> byteList = jsonDecode(message.message);
                Uint8List pdfBytes = Uint8List.fromList(byteList.cast<int>());

                String filename = await saveFile(pdfBytes);
                Fluttertoast.showToast(
                  msg: "File saved as: $filename",
                  toastLength: Toast.LENGTH_LONG,
                );
              },
            )
            ...

In main.js, convert the blob to a byte array and send it to Dart using SaveFile.postMessage():

savePDFButton.addEventListener('click', async () => {
    const fileName = document.getElementById('fileName').value;
    const password = document.getElementById('password').value;
    const annotationType = document.getElementById('annotationType').value;

    try {
        const pdfSettings = {
            password: password,
            saveAnnotation: annotationType,
        };

        let blob = await editViewer.currentDocument.saveToPdf(pdfSettings);

        sendBlobToDart(blob, fileName + `.pdf`);
    } catch (error) {
        console.log(error);
    }

    document.getElementById("save-pdf").style.display = "none";
});

function sendBlobToDart(blob) {
    const reader = new FileReader();

    reader.onload = function () {
        const arrayBuffer = reader.result;
        const byteArray = new Uint8Array(arrayBuffer);
        SaveFile.postMessage(JSON.stringify(Array.from(byteArray)));
    };

    reader.onerror = function (error) {
        console.error("Error reading blob:", error);
    };

    reader.readAsArrayBuffer(blob);
}

When a message is received on the Dart side:

  1. Decode the JSON message into a list of dynamic values.
  2. Convert the list to a Uint8List representing PDF bytes.
  3. Call saveFile() to save the bytes and return the filename.

     Future<String> saveFile(Uint8List pdfBytes) async {
       final directory = await getApplicationDocumentsDirectory();
        
       String filename = getFileName();
       final filePath = '${directory.path}/$filename';
        
       await File(filePath).writeAsBytes(pdfBytes);
        
       return filePath;
     }
    
     String getFileName() {
       DateTime now = DateTime.now();
        
       String timestamp =
           '${now.year}${now.month}${now.day}_${now.hour}${now.minute}${now.second}';
        
       String name = '$timestamp.pdf';
        
       return name;
     }
    
  4. Display a toast message showing the saved filename using Fluttertoast.

Step 4: Handle File Selection on Android WebView

By default, <input type="file"> does not work in Android WebView. Use the following code to handle file selection:

if (_controller.platform is AndroidWebViewController) {
      AndroidWebViewController.enableDebugging(true);
      (_controller.platform as AndroidWebViewController)
          .setMediaPlaybackRequiresUserGesture(false);
      (_controller.platform as AndroidWebViewController).setOnShowFileSelector((
        params,
      ) async {
        final XFile? image = await _picker.pickImage(
          source: ImageSource.gallery,
        );
        if (image != null) {
          return ['file://${image.path}'];
        }
        return [""];
      });
    }

Explanation

  • setOnShowFileSelector is a method that sets a callback function to handle file selection requests from the WebView.
  • Inside the callback, it uses the ImagePicker instance _picker to open the device’s image gallery and let the user select an image.
  • If the user selects an image successfully, it returns an array containing the file URI of the selected image.
  • If the user cancels the selection, it returns an array containing an empty string.

Step 5: Grant Camera Access Permission

To enable camera access:

  1. Add permissions in platform-specific files:

    android/app/src/main/AndroidManifest.xml

     <uses-permission android:name="android.permission.CAMERA"/>
    

    ios/Runner/Info.plist

     <key>NSCameraUsageDescription</key>
     <string>This app needs camera access to allow the webpage to use your camera for video input.</string>
    
  2. Request the camera permission at runtime in Flutter:

     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();
         ...
         requestCameraPermission();
     }
    

On Android, navigator.mediaDevices.enumerateDevices() returns only the front camera due to WebView security restrictions. This is because loading with file:// lacks a secure context.

Workaround: Serve Assets via HTTP to Enable Full Camera Access on Android

To enable full camera access:

  1. Serve assets via http://localhost.

     void main() async {
       WidgetsFlutterBinding.ensureInitialized();
        
       if (Platform.isAndroid) {
        
         final dir = await getTemporaryDirectory();
         final path = p.join(dir.path, 'web');
         final webDir = Directory(path)..createSync(recursive: true);
        
         final files = ['index.html', 'full.json', 'main.css', 'main.js'];
        
         for (var filename in files) {
           final ByteData data = await rootBundle.load('assets/web/$filename');
           final file = File(p.join(webDir.path, filename));
           await file.writeAsBytes(data.buffer.asUint8List());
         }
        
         final handler = createStaticHandler(
           webDir.path,
           defaultDocument: 'index.html',
           serveFilesOutsidePath: true,
         );
        
         try {
           final server = await shelf_io.serve(handler, 'localhost', 8080);
           print('Serving at http://${server.address.host}:${server.port}');
         } catch (e) {
           print('Failed to start server: $e');
         }
       }
        
       runApp(const MyApp());
     }
        
    

    Explanation

    1. Get the temporary directory and creates a web subdirectory.
    2. Copy web files (HTML, JSON, CSS, JS) from assets to this directory.
    3. Create a static file handler to serve these files.
    4. Start a local HTTP server on port 8080 to serve the content.
  2. Load the web page using ..loadRequest(Uri.parse('http://localhost:8080/index.html')):

     if (Platform.isAndroid) {
           _controller =
               WebViewController()
                 ..setJavaScriptMode(JavaScriptMode.unrestricted)
                 ..enableZoom(true)
                 ..setBackgroundColor(Colors.transparent)
                 ..setNavigationDelegate(NavigationDelegate())
                 ..addJavaScriptChannel(
                   'SaveFile',
                   onMessageReceived: (JavaScriptMessage message) async {
                     List<dynamic> byteList = jsonDecode(message.message);
                     Uint8List pdfBytes = Uint8List.fromList(byteList.cast<int>());
        
                     String filename = await saveFile(pdfBytes);
                     Fluttertoast.showToast(
                       msg: "File saved as: $filename",
                       toastLength: Toast.LENGTH_LONG,
                     );
                   },
                 )
                 ..platform.setOnPlatformPermissionRequest((request) {
                   debugPrint(
                     'Permission requested by web content: ${request.types}',
                   );
                   request.grant(); 
                 })
                 ..loadRequest(Uri.parse('http://localhost:8080/index.html'));
         } 
    

    camera access in Flutter Android WebView

Step 6: Build a PDF History Page for Saved Documents

To view, share, and delete saved PDFs, add a second tab:

  1. In main.dart, construct the UI with a TabBarView that has two tabs: Home and History.

     @override
     Widget build(BuildContext context) {
         return Scaffold(
         body: TabBarView(
             controller: _tabController,
             physics: const NeverScrollableScrollPhysics(),
             children: [
             HomeView(title: 'Document Viewer', controller: _controller),
             const HistoryView(title: 'History'),
             ],
         ),
         bottomNavigationBar: TabBar(
             labelColor: Colors.blue,
             controller: _tabController,
             tabs: const [
             Tab(icon: Icon(Icons.home), text: 'Home'),
             Tab(icon: Icon(Icons.history_sharp), text: 'History'),
             ],
         ),
         );
     }
    
  2. Create home_view.dart to show the WebViewWidget:

     import 'package:flutter/material.dart';
    
     import 'package:webview_flutter/webview_flutter.dart';
        
     class HomeView extends StatefulWidget {
       final WebViewController controller;
       final String title;
        
       const HomeView({super.key, required this.title, required this.controller});
        
       @override
       State<HomeView> createState() => _HomeViewState();
     }
        
     class _HomeViewState extends State<HomeView>
         with SingleTickerProviderStateMixin {
       @override
       void initState() {
         super.initState();
       }
        
       @override
       Widget build(BuildContext context) {
         return Scaffold(
           appBar: AppBar(title: Text(widget.title)),
           body: WebViewWidget(controller: widget.controller),
         );
       }
     }
        
    
  3. Create history_view.dart to display the saved PDF documents:

     import 'package:flutter/material.dart';
     import 'package:open_file/open_file.dart';
     import 'package:share_plus/share_plus.dart';
        
     import 'utils.dart';
        
     class HistoryView extends StatefulWidget {
       const HistoryView({super.key, required this.title});
        
       final String title;
        
       @override
       State<HistoryView> createState() => _HistoryViewState();
     }
        
     class _HistoryViewState extends State<HistoryView> {
       List<String> _results = [];
       int selectedValue = -1;
        
       @override
       void initState() {
         super.initState();
        
         getFiles().then((value) {
           setState(() {
             _results = value;
           });
         });
       }
        
       Widget createListView(BuildContext context, List<String> results) {
         return ListView.builder(
           itemCount: results.length,
           itemBuilder: (context, index) {
             return RadioListTile<int>(
               value: index,
               groupValue: selectedValue,
               title: Text(results[index]),
               onChanged: (int? value) {
                 setState(() {
                   selectedValue = value!;
                 });
               },
             );
           },
         );
       }
        
       @override
       Widget build(BuildContext context) {
         return Scaffold(
           appBar: AppBar(
             title: Text(widget.title),
             actions: [
               IconButton(
                 icon: const Icon(Icons.info),
                 onPressed: () async {
                   if (selectedValue == -1) {
                     return;
                   }
                   await OpenFile.open(_results[selectedValue]);
                 },
               ),
               IconButton(
                 icon: const Icon(Icons.share),
                 onPressed: () async {
                   if (selectedValue == -1) {
                     return;
                   }
                   await SharePlus.instance.share(
                     ShareParams(
                       text: 'Check out this image!',
                       files: [XFile(_results[selectedValue])],
                     ),
                   );
                 },
               ),
               IconButton(
                 icon: const Icon(Icons.delete),
                 onPressed: () async {
                   if (selectedValue == -1) {
                     return;
                   }
                   bool isDeleted = await deleteFile(_results[selectedValue]);
                   if (isDeleted) {
                     setState(() {
                       _results.removeAt(selectedValue);
                       selectedValue = -1;
                     });
                   }
                 },
               ),
             ],
           ),
           body: Center(
             child: Stack(
               children: [
                 SizedBox(height: MediaQuery.of(context).size.height),
                 if (_results.isNotEmpty)
                   SizedBox(
                     width: MediaQuery.of(context).size.width,
                     height:
                         MediaQuery.of(context).size.height -
                         200 -
                         MediaQuery.of(context).padding.top,
                     child: createListView(context, _results),
                   ),
               ],
             ),
           ),
         );
       }
     }
        
    

    Flutter PDF document history

Common Issues & Edge Cases

  • Android back camera not accessible in WebView: Loading the HTML page via file:// disables the secure context, which restricts navigator.mediaDevices.enumerateDevices() to returning only the front camera. Fix: serve assets from a local HTTP server using shelf_static and load the page via http://localhost:8080 instead.
  • <input type="file"> does nothing on Android WebView: The default Android WebView does not route <input type="file"> clicks to the system file picker. Fix: use setOnShowFileSelector in AndroidWebViewController and open the device gallery via ImagePicker.
  • Toast not showing after save on iOS: fluttertoast relies on native bridges that may not fire if the widget is no longer mounted. Verify the widget is still active before calling Fluttertoast.showToast, or use a SnackBar as a fallback.

Source Code

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