Scan, Extract & Call
Stop typing numbers manually. Point your camera at business cards, docs, or screens to extract and dial numbers instantly.
Get Scan2Call 📱Modern mobile software architecture is undergoing a quiet but rapid shift. For years, the default pattern for document scanning, optical character recognition (OCR), and semantic analysis was cloud-centric: raw images were streamed up to REST endpoints, parsed via heavy microservices, and routed through LLMs via OpenAI or Anthropic APIs. While reliable, this pattern introduces unacceptable operational trade-offs: massive network roundtrip latency (often 2 to 5 seconds), fragile offline capabilities, variable data-egress and API costs, and compliance exposure with GDPR and HIPAA regulations.
To eliminate these pain points, we must look to the edge. Achieving an offline-first mobile architecture requires unifying high-performance UI frameworks with hardware-accelerated local runtimes. Our solution stack brings together three core components: Flutter for unified user interface and thread orchestration, Google's new system-level Android AppFunctions framework to register local code execution blocks, Google's ML Kit Text Recognition for zero-latency OCR, and on-device Gemini Nano (via Android's AICore runtime) for schema extraction. By implementing a unified Flutter on-device AI integration, we can capture, recognize, and parse physical documents into structured schemas completely client-side in under 450 milliseconds.
Moving workloads completely on-device demands a highly structured execution pipeline. Because mobile operating systems will aggressively terminate resource-intensive background processes, we must manage memory and CPU/NPU pipelines meticulously. The workflow maps a continuous, low-overhead path from camera frames to structured actions:
Camera Frame Stream: The camera sensor feeds raw YUV_420_888 pixel buffers directly into device memory.
Zero-Copy Bridge: Instead of serializing these heavy buffers over the standard Flutter platform channel (which blocks the Dart UI thread), pointers are managed native-side via standard native bindings.
ML Kit OCR Engine: ML Kit's highly optimized TensorFlow Lite models run character-bounding-box inference, emitting raw string segments and positional metadata.
AICore Prompt Context Injection: A specialized Kotlin-based manager intercepts this text, applies system-level templates designed for low-bitrate LLMs, and invokes Gemini Nano through the device NPU.
Structured Response Processing: Gemini Nano outputs JSON-formatted strings directly back to the app context.
AppFunctions System Registration: The system registers this extraction pipeline as a standard OS capability, exposing it to system search, background services, and ambient assistive interfaces.
In this architecture, Android AppFunctions are crucial. Instead of treating your app as a sealed sandbox, AppFunctions register deep functional targets at the OS level. The operating system can call these functions natively when assistive system processes discover context (such as scanning physical business cards to generate direct actions).
Passing 60 frames per second from a camera image stream over a Flutter MethodChannel causes instant frame drops and UI freezes. The JSON serialization overhead and byte copy across the engine boundary degrades performance immediately. To mitigate this bottleneck, we write custom native platform channels that pass pixel array structures efficiently.
To implement this, we initialize the Flutter camera package's streaming API, but wrap our pipeline processing in a rate-limiting lock. By tracking states using Dart's _isProcessing flags and sending frames to native memory using Dart Uint8List allocations, we bypass the platform thread lock.
// Dart Stream Controller utilizing zero-copy pointers
import 'package:camera/camera.dart';
import 'package:flutter/services.dart';
class NativeOcrPipeline {
static const MethodChannel _channel = MethodChannel('com.staksoft.ocr/pipeline');
bool _isProcessing = false;
Future<void> processCameraImage(CameraImage image) async {
if (_isProcessing) return;
_isProcessing = true;
try {
final List<Map<String, dynamic>> planes = image.planes.map((plane) {
return {
'bytes': plane.bytes,
'bytesPerRow': plane.bytesPerRow,
'bytesPerPixel': plane.bytesPerPixel,
};
}).toList();
final Map<String, dynamic> metadata = {
'width': image.width,
'height': image.height,
'format': image.format.raw,
'planes': planes,
};
// Dispatch raw buffers directly to native side without Dart serialization overhead
final String rawText = await _channel.invokeMethod('processFrame', metadata);
if (rawText.isNotEmpty) {
_handleOcrResult(rawText);
}
} on PlatformException catch (e) {
log('Native pipeline frame dispatch failed: ${e.message}');
} finally {
_isProcessing = false;
}
}
void _handleOcrResult(String text) {
// Dispatch to localized application state
}
}On the native side, we transform these raw planes directly into an InputImage. By utilizing the androidx.camera core structures directly, ML Kit processes native memory regions directly, keeping your main UI thread performing at 120Hz.
Android's AppFunctions API is a massive structural shift for deep-linking and task automation. Rather than requiring applications to wait for user interaction, AppFunctions allow developers to build specialized, system-discoverable modules that can execute headless processing tasks, such as scanning and parsing, directly on behalf of the OS.
For a Flutter developer, integration occurs at the native host wrapper level (MainActivity.kt or associated application sub-modules). By declaring a Jetpack AppFunction schema in Kotlin, your Flutter app registers as a provider for actions like "Parse Document" or "Identify Contacts". To see a production implementation of physical card digitizing, inspect the design architecture of Staksoft's Scan2Call tool.
package com.staksoft.ocr.functions
import android.content.Context
import androidx.appfunctions.compiler.core.annotations.AppFunction
import androidx.appfunctions.compiler.core.annotations.AppFunctionCategory
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.text.TextRecognition
import com.google.mlkit.vision.text.latin.TextRecognizerOptions
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@AppFunctionCategory("UTILITY")
class DocumentScannerFunctions {
private val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
private val geminiController = GeminiNanoLocalController()
@AppFunction(name = "scanAndExtractStructuredData")
suspend fun scanAndExtractStructuredData(
context: Context,
imageUriString: String
): ExtractionResult = withContext(Dispatchers.Default) {
try {
val uri = android.net.Uri.parse(imageUriString)
val inputImage = InputImage.fromFilePath(context, uri)
// Execute local OCR
val ocrResult = recognizer.process(inputImage).await()
if (ocrResult.text.isEmpty()) {
return@withContext ExtractionResult(status = "ERROR", rawText = "", structuredDataJson = "{}")
}
// Execute local LLM inference via Gemini Nano
val structuredJson = geminiController.analyzeText(ocrResult.text)
return@withContext ExtractionResult(
status = "SUCCESS",
rawText = ocrResult.text,
structuredDataJson = structuredJson
)
} catch (e: Exception) {
return@withContext ExtractionResult(
status = "FAILURE",
rawText = "",
structuredDataJson = "{\"error\": \"${e.localizedMessage}\"}"
)
}
}
}
data class ExtractionResult(
val status: String,
val rawText: String,
val structuredDataJson: String
)During compilation, the AppFunctions compiler processes these Kotlin annotations, generating the structural schemas exposed directly to Android system search and on-device intent routing layers.
Running Gemini Nano on-device requires using Android's system-managed AICore interface. This approach keeps your application's apk size low by leveraging system-managed weights instead of bundling the massive 1.8B parameter weights directly inside your project package.
Because Gemini Nano is a highly optimized 4-bit quantized model, prompt structure is extremely critical. Unlike cloud-based LLMs (like GPT-4o or Claude 3.5 Sonnet) that gracefully recover from open-ended questions, local 4-bit models require explicit instructions, system templates, and defined JSON output schemas to prevent token hallucination or formatting failures.
package com.staksoft.ocr.functions
import com.google.android.gms.tasks.Tasks
import com.google.firebase.vertexai.FirebaseVertexAI
import com.google.firebase.vertexai.type.generationConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class GeminiNanoLocalController {
// Using Google Play Services / AICore bindings for Gemini Nano
private val model by lazy {
FirebaseVertexAI.getInstance().generativeModel(
modelName = "gemini-nano",
generationConfig = generationConfig {
temperature = 0.1f
topK = 16
responseMimeType = "application/json"
}
)
}
suspend fun analyzeText(rawOcrText: String): String = withContext(Dispatchers.Default) {
val systemPrompt = """
You are a highly efficient, localized document parsing engine.
Analyze the raw OCR input below and output structural data in JSON.
Do not include Markdown wrapping, code blocks, or preamble.
Format: {"phone": "string", "emails": ["string"], "address": "string", "org": "string"}
OCR Data:
""".trimIndent()
val formattedPrompt = "$systemPrompt\n$rawOcrText"
try {
val response = model.generateContent(formattedPrompt)
return@withContext response.text ?: "{}"
} catch (e: Exception) {
// Fallback strategy if AICore context is exhausted or model is busy
return@withContext "{\"error\":\"Inference failed: ${e.message}\"}"
}
}
}Running large models on consumer-grade mobile devices introduces significant memory and power bottlenecks. A 1.8B parameter model quantized to 4-bit still consumes roughly 1.1GB to 1.4GB of RAM. If you do not actively profile your memory layouts and release GPU/NPU textures, Android's Low Memory Killer (LMK) will instantly shut down your background processes.
To maintain peak performance, we must isolate image manipulation code and structure execution blocks to minimize garbage collection (GC) load. For a deep look at handling these low-level allocation structures, see Staksoft's analysis of Optimizing Local Android AI Inference: MTP and Memory Management. Key optimizations for this pipeline include:
Zero-Allocation Object Re-use: Do not re-allocate bitmap headers or byte buffer arrays within the frame stream. Instantiate an active native array buffer block once, and overwrite it sequentially.
Model Pre-warming: Initialize AICore asynchronously during app startup so Gemini Nano's weights are pre-mapped into RAM before the scanning view opens.
Aggressive Resource Disposal: When the scanning UI is popped off the navigation stack, explicitly nullify native references and trigger system-wide GC notifications.
In our performance benchmarks across standard modern devices, local offline execution outperformed cloud architectures in execution time while eliminating network failures entirely:
Metric | Native Local (ML Kit + Gemini Nano) | Hybrid (Local OCR + Cloud GPT-4o-mini) | Cloud-Only (Cloud Vision + Cloud LLM) |
|---|---|---|---|
Average Latency | 380ms - 490ms | 1200ms - 2200ms | 2400ms - 4500ms |
Network Dependence | Zero (Offline-First) | Required for Inference | Strictly Required |
Operational Cost per 1k runs | $0.00 (Client Compute) | $0.15 - $0.35 (API usage) | $1.50 - $3.20 (Vision + LLM fees) |
Battery Drain Impact | High burst (during active use) | Medium-Low | Low (Cloud-offloaded) |
For enterprise-grade applications, moving processing tasks to the edge is not just about reducing latency: it is a significant cost-optimization and compliance strategy.
In highly regulated spaces (healthcare, finance, enterprise client pipelines), processing raw personal documents or banking receipts inside cloud environments introduces major security liabilities. Keeping text recognition inside sandboxed, hardware-secured system processes simplifies GDPR, HIPAA, and SOC-2 audits. Data never leaves the device's physical memory boundaries, nullifying the risk of transit leaks or external breaches. These architectures are central to modern client-side secure tools, mirroring technologies used for real-time threat detection in sandboxed environments.
Running a scaling mobile workforce app that handles millions of document scans monthly via cloud endpoints can generate massive recurring API bills. By implementing offline-first mobile architectures, compute costs are completely offloaded to the user's localized physical hardware (NPUs/GPUs). This shifts operational expenses from variable cost curves directly down to predictable $0 marginal-cost scaling structures.
Whether processing invoices in a warehouse basement, scanning medical documents on the go, or registering local field-service data, reliable offline execution guarantees operational continuity. Your software functions reliably regardless of network environments.
For engineering teams building specialized, high-performance edge scanning applications, Staksoft provides battle-tested offline-first toolkits like Scan2Call (staksoft.com/scan2call) for contact digitizing, Scan2PDF (staksoft.com/scan2pdf) for local document storage, and PDFaiGen (staksoft.com/pdfaigen) for on-device PDF generation and local processing.
Deploying production applications using the Flutter on-device AI integration stack requires solid fallback mechanics and obfuscation layers.
Proguard and R8 Compression: Ensure you configure your Proguard rules to prevent the optimization pass from stripping your Android AppFunctions schema metadata or JNI bridge calls. Keep annotated functional classes explicitly.
NPU Hardware Verification: Not all devices support Gemini Nano natively. Always write structured fallback scripts. If the local runtime environment lacks AICore or modern NPU architectures, the pipeline must smoothly degrade to basic local OCR processing or delegate complex queries to a cloud backup API.
Continuous MLOps Evaluation: On-device model performance can drift over different hardware chipsets. It is vital to track and evaluate model accuracy and processing efficiency using system-level logging frameworks. For a deep dive into the engineering complexities of deploying localized models, read more on unsolved problems and research directions in MLOps.
Gemini Nano is natively supported through Android AICore on modern flagship devices, including the Google Pixel 8 series, Pixel 9 series, Samsung Galaxy S24 series, and newer high-end Snapdragon or Tensor chips running Android 14 and above.
Gemini Nano is built natively for Google's Android AICore infrastructure. On iOS, you must use alternative localized execution patterns. Typically, you would leverage Apple's CoreML runtime, or compile open-source 3B parameter models (such as LLaMA 3.2 1B/3B) using custom MLC LLM or llama.cpp runtimes inside your iOS runner project.
Never pass massive raw bitmap arrays back and forth through Flutter's method channel boundary during image capture loops. Perform processing, cropping, and orientation adjustments entirely in the native background thread via Kotlin or C++ layers. Only pass back low-weight strings or structured JSON to your Flutter Dart Isolates once processing completes.
By orchestrating Flutter, ML Kit OCR, and on-device Gemini Nano via the new Android AppFunctions SDK, developers can build incredibly secure, high-performance, and offline-first mobile scanning architectures. By keeping data processing localized, you eliminate API costs and network latency issues. Whether you are engineering tools to scan physical invoices, parse health documents, or build private client-side integrations, utilizing local-edge processing ensures your applications are highly secure, reliable, and cost-effective.
import 'package:camera/camera.dart';
import 'package:flutter/services.dart';
class NativeOcrPipeline {
static const MethodChannel _channel = MethodChannel('com.staksoft.ocr/pipeline');
bool _isProcessing = false;
Future processCameraImage(CameraImage image) async {
if (_isProcessing) return;
_isProcessing = true;
try {
final List> planes = image.planes.map((plane) {
return {
'bytes': plane.bytes,
'bytesPerRow': plane.bytesPerRow,
'bytesPerPixel': plane.bytesPerPixel,
};
}).toList();
final Map metadata = {
'width': image.width,
'height': image.height,
'format': image.format.raw,
'planes': planes,
};
// Dispatch raw buffers directly to native side without Dart serialization overhead
final String rawText = await _channel.invokeMethod('processFrame', metadata);
if (rawText.isNotEmpty) {
_handleOcrResult(rawText);
}
} on PlatformException catch (e) {
log('Native pipeline frame dispatch failed: ${e.message}');
} finally {
_isProcessing = false;
}
}
void _handleOcrResult(String text) {
// Dispatch to localized application state or AppFunction lifecycle controller
}
}package com.staksoft.ocr.functions
import android.content.Context
import androidx.appfunctions.compiler.core.annotations.AppFunction
import androidx.appfunctions.compiler.core.annotations.AppFunctionCategory
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.text.TextRecognition
import com.google.mlkit.vision.text.latin.TextRecognizerOptions
import kotlinx.coroutines.tasks.await
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@AppFunctionCategory("UTILITY")
class DocumentScannerFunctions {
private val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
private val geminiController = GeminiNanoLocalController()
@AppFunction(name = "scanAndExtractStructuredData")
suspend fun scanAndExtractStructuredData(
context: Context,
imageUriString: String
): ExtractionResult = withContext(Dispatchers.Default) {
try {
val uri = android.net.Uri.parse(imageUriString)
val inputImage = InputImage.fromFilePath(context, uri)
// Execute local OCR
val ocrResult = recognizer.process(inputImage).await()
if (ocrResult.text.isEmpty()) {
return@withContext ExtractionResult(status = "ERROR", rawText = "", structuredDataJson = "{}")
}
// Execute local LLM inference via Gemini Nano
val structuredJson = geminiController.analyzeText(ocrResult.text)
return@withContext ExtractionResult(
status = "SUCCESS",
rawText = ocrResult.text,
structuredDataJson = structuredJson
)
} catch (e: Exception) {
return@withContext ExtractionResult(
status = "FAILURE",
rawText = "",
structuredDataJson = "{\"error\": \"${e.localizedMessage}\"}"
)
}
}
}
data class ExtractionResult(
val status: String,
val rawText: String,
val structuredDataJson: String
)package com.staksoft.ocr.functions
import com.google.android.gms.tasks.Tasks
import com.google.firebase.vertexai.FirebaseVertexAI
import com.google.firebase.vertexai.type.generationConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class GeminiNanoLocalController {
// Using Google Play Services / AICore bindings for Gemini Nano
private val model by lazy {
FirebaseVertexAI.getInstance().generativeModel(
modelName = "gemini-nano",
generationConfig = generationConfig {
temperature = 0.1f
topK = 16
responseMimeType = "application/json"
}
)
}
suspend fun analyzeText(rawOcrText: String): String = withContext(Dispatchers.Default) {
val systemPrompt = """
You are a highly efficient, localized document parsing engine.
Analyze the raw OCR input below and output structural data in JSON.
Do not include Markdown wrapping, code blocks, or preamble.
Format: {"phone": "string", "emails": ["string"], "address": "string", "org": "string"}
OCR Data:
""".trimIndent()
val formattedPrompt = "$systemPrompt\n$rawOcrText"
try {
val response = model.generateContent(formattedPrompt)
return@withContext response.text ?: "{}"
} catch (e: Exception) {
// Fallback strategy if AICore context is exhausted or model is busy
return@withContext "{\"error\":\"Inference failed: ${e.message}\"}"
}
}
}Optimizing Local Android AI Inference: MTP and Memory Management: Provides deep-dive mechanics on managing high-frequency garbage collection and VRAM pools when running models local to the device.
Supercharge Outreach: The Power of Scan2Call scan phone numbers: Examines a real-world software implementation of direct scanning and action dispatch using on-device OCR logic.
Charting the Frontier: Unsolved Problems and Research Directions in MLOps: Explores the broader deployment challenges of local AI execution versus cloud infrastructure.
Real-time Threat Detection for Magento/Mage-OS: Client-Side AI Signals: Explores sandboxed execution environments for zero-latency client-side intelligent processing.
Flutter, native camera/OCR pipelines, and offline-first mobile engineering from Staksoft.