Scan, Extract & Call
Stop typing numbers manually. Point your camera at business cards, docs, or screens to extract and dial numbers instantly.
Get Scan2Call π±Outbound sales representatives operate in fast-paced environments where speed-to-dial determines conversion rates. However, when using mobile Customer Relationship Management (CRM) tools, the lead-ingestion workflow is often broken. Representatives frequently find themselves staring at manual column mapping screens, matching fields like "First Name", "Alternate Phone", and "Postal Code" from dirty spreadsheets. When an import fails due to minor formatting discrepancies, user frustration rises, and adoption drops.
At Staksoft, we designed the Staksoft Bulk Dialer with a clear, zero-friction import philosophy: zero manual formatting, instantaneous parsing, and 100% on-device processing. Accomplishing this on memory-constrained Android hardware required us to move away from conventional server-side parsers and generic desktop libraries. Instead, we engineered a fast, heuristic-based on-device spreadsheet parser Android engine that inspects raw cell content in real-time rather than relying on brittle header labels.
This article details the technical architecture, heuristic scoring matrices, and memory optimization techniques required to deploy a highly performant, local parsing pipeline directly on mobile devices.
The standard industry approach to automated column mapping relies on basic string-distance algorithms (like Levenshtein distance) executed against spreadsheet headers. An engine searches row zero for terms like "phone", "tel", "telephone", or "contact". While easy to write, this approach consistently falls apart in practical enterprise usage:
Multi-Language Sheets: Sales teams in international territories import files labeled with localized headers (e.g., "telefoon", "telefon", "celular", "nΓΊmero", or localized character sets like "η΅θ―"). Hardcoded dictionaries cannot scale to global translation matrices without introducing massive lookup tables.
Indexed and Multi-Phone Columns: Spreadsheets frequently contain columns like "Phone 1", "Alternate Contact", "Office Tel", or "Fax". Simple sub-string matches struggle to differentiate active, dialable voice lines from legacy fax lines or corporate switchboard numbers.
No-Header Templates and Generic Indexes: Legacy databases or quick CSV exports often omit column headers entirely, placing raw phone numbers in columns indexed as "Column A", "Column 1", or "Sr No".
To overcome these limitations, we transitioned from header-based lookup to content-based inspection. Rather than asking "what is the header of this column?" our engine asks "what type of data actually resides in the cells of this column?" This paradigm shift guarantees reliable file ingestion, even when headers are entirely absent, misspelled, or written in foreign character sets.
Mobile operating systems enforce strict execution environments. Memory footprints must be minimized to keep the application in a low-priority process bucket, preventing Android's low-memory killer (LMK) from reclaiming the CRM's process during background operations. Building a memory-efficient on-device spreadsheet parser Android framework presents unique challenges.
Desktop-oriented Java libraries, such as Apache POI, are built with high memory overhead. When reading a .xlsx file, Apache POI's DOM parser loads the entire XML structure into JVM heap space as objects. For a 15,000-row file with 20 columns, this can quickly allocate more than 150MB of RAM, causing a catastrophic OutOfMemoryError (OOM) on low-end and mid-range Android hardware.
Additionally, Apache POI brings in massive dependencies (such as XMLBeans and deep transitive packages), bloating the final APK size by 30MB or more. To keep our installation foot-print small and runtime execution lean, we engineered a zero-dependency, stream-oriented parser designed specifically for Android.
An XLSX file is simply a compressed ZIP archive containing XML structures. Instead of building a full DOM model, our stream parser accesses the underlying sharedStrings.xml and sheet1.xml parts via ZipInputStream. We utilize Android's native XmlPullParser (which interfaces directly with highly optimized native C++ XML parsing implementations under the hood) to read raw streams cell-by-cell without loading entire tables into RAM.
The parser maintains a minimal, dynamic window of data, utilizing a stream-driven architecture to read only the rows required for scanning. This pipeline is detailed in the architecture block below:
[Local Spreadsheet File (.xlsx / .csv)]
β
βΌ (Stream Opening)
[ZipInputStream]
β
ββββββββββΊ sharedStrings.xml (Parsed via lightweight stream index map)
β
ββββββββββΊ sheet1.xml (Parsed via Android native XmlPullParser)
β
βΌ
[Row-by-Row Custom Streaming Wrapper]
β
(First N Rows Dispatched)
β
βΌ
[On-Device Heuristic Scoring Engine]
By enforcing a memory cap on the parser stream, we ensure that a 50,000-row file requires the exact same memory footprint as a 100-row file, fitting neatly within a constant heap budget of less than 12MB. For more on why optimized local execution patterns matter for durable software, refer to our article on Why On-Device Architecture Enables True Lifetime Software.
The automatic column mapping pipeline utilizes a highly optimized three-stage content-based detection engine written in Kotlin.
Sampling Stage: Rather than scanning the entire file to find the telephone column (which wastefully burns processor cycles on large lists), we read a window of N (typically 50) non-empty rows. This provides a statistically significant sample size without incurring visual interface stutter.
Feature Extraction: For every cell inside our sample window, we profile the contents, tracking length metrics, digit-to-letter ratios, spacing patterns, and common telephone prefixes (such as leading country-code pluses).
Scoring Engine: We feed these features into a dynamic scoring matrix. Columns earn positive weights for matching valid international dialing parameters, and severe negative penalties for violating structural rules (e.g., containing alpha characters, or showing structures indicative of sequential primary IDs).
The following Kotlin class displays the core heuristic validation engine. It evaluates raw cells, extracts specific statistical feature sets, and maps them to a calculated column score matrix:
package com.staksoft.bulkdialer.parser
import kotlin.math.abs
class PhoneColumnDetector {
companion object {
private const val MIN_SAMPLE_SIZE = 10
private const val MAX_SAMPLE_SIZE = 50
private const val TARGET_PHONE_DIGIT_MIN = 7
private const val TARGET_PHONE_DIGIT_MAX = 15
}
data class ColumnFeatureProfile(
val columnIndex: Int,
var totalRowsScored: Int = 0,
var numericCharRatioSum: Float = 0f,
var validSpecialCharRatioSum: Float = 0f,
var averageLength: Float = 0f,
var matchesLikelyPhonePatternCount: Int = 0,
var potentialPostalOrIdCount: Int = 0
) {
fun calculateScore(): Float {
if (totalRowsScored == 0) return 0f
val avgNumericRatio = numericCharRatioSum / totalRowsScored
val avgSpecialRatio = validSpecialCharRatioSum / totalRowsScored
val patternMatchRatio = matchesLikelyPhonePatternCount.toFloat() / totalRowsScored
val noiseRatio = potentialPostalOrIdCount.toFloat() / totalRowsScored
val avgLen = averageLength / totalRowsScored
var score = 0f
// Base evaluation of character structure
score += avgNumericRatio * 40f
score += avgSpecialRatio * 15f
score += patternMatchRatio * 35f
// Length penalty: E.164 numbers are typically 7 to 15 digits
val lenPenalty = if (avgLen in 7.0f..16.0f) 10f else -25f
score += lenPenalty
// Penalize high density of pure IDs, postal codes (usually short/fixed length) or timestamps
score -= (noiseRatio * 50f)
return score.coerceIn(0f, 100f)
}
}
fun detectPhoneColumn(matrix: List>): Int {
if (matrix.isEmpty()) return -1
val columnCount = matrix[0].size
val profiles = Array(columnCount) { ColumnFeatureProfile(columnIndex = it) }
val sampleRows = matrix.take(MAX_SAMPLE_SIZE)
for (row in sampleRows) {
for (colIndex in 0 until columnCount) {
if (colIndex >= row.size) continue
val rawCell = row[colIndex].trim()
if (rawCell.isEmpty()) continue
val profile = profiles[colIndex]
profile.totalRowsScored++
analyzeCell(rawCell, profile)
}
}
var bestColumnIndex = -1
var highestScore = -1f
for (profile in profiles) {
val finalScore = profile.calculateScore()
if (finalScore > highestScore && finalScore > 40f) {
highestScore = finalScore
bestColumnIndex = profile.columnIndex
}
}
return bestColumnIndex
}
private fun analyzeCell(cell: String, profile: ColumnFeatureProfile) {
var digitCount = 0
var specialCharCount = 0
var nonNumericDialableCount = 0
for (char in cell) {
when {
char.isDigit() -> digitCount++
char == '+' || char == '-' || char == '(' || char == ')' || char == ' ' -> {
specialCharCount++
if (char == '+' || char == '-') nonNumericDialableCount++
}
}
}
val totalLen = cell.length
profile.averageLength += totalLen
if (totalLen > 0) {
profile.numericCharRatioSum += (digitCount.toFloat() / totalLen)
profile.validSpecialCharRatioSum += (specialCharCount.toFloat() / totalLen)
}
// Pattern heuristic
if (digitCount in TARGET_PHONE_DIGIT_MIN..TARGET_PHONE_DIGIT_MAX) {
profile.matchesLikelyPhonePatternCount++
}
// Postal/ID heuristics: pure integers of specific short lengths, or typical sequence numbers
if ((digitCount == totalLen && (totalLen == 5 || totalLen == 6)) || (digitCount <= 3 && totalLen <= 3)) {
profile.potentialPostalOrIdCount++
}
}
}Developers often default to heavy regular expressions (like complex E.164 verification patterns) to validate numeric structures on the fly. On mobile architectures, compilation and execution of complex regex engines present hidden performance bottlenecks. When processing long arrays across multiple threads, standard regex patterns induce CPU thread execution spikes and trigger excessive garbage collection (GC) cycles due to transient pattern match object allocations.
Furthermore, standard regular expression execution engines are susceptible to catastrophic backtracking if users intentionally or accidentally fill cells with lengthy random character structures.
To eliminate this performance variable, we replaced standard Regex checks in our internal scoring loop with a high-speed character classification state machine. Rather than validating matches via backtracking trees, we iterate over character arrays linearly ($O(K)$ complexity). If we encounter letters outside permissible telephone symbols, we flag a rejection instantly and exit. This single-pass approach allows us to classify columns with minimal CPU usage, saving battery and keeping the system snappy during background ingestion tasks.
This design methodology is also applied in other real-world scenarios. For instance, physical documentation parsing engines, like our Scan2Call AI number scanner, rely on similarly optimized, lightweight processing heuristics to read phone numbers from physical paper sheets in real-time, matching digital efficiency with physical speed.
Data imported from raw sheets is rarely ready for immediate dialing. Phone numbers arrive in various states of non-compliance: localized leading zeros, arbitrary visual hyphens, brackets, missing international prefixes, and duplicate records. Robust on-device spreadsheet parser Android architectures must clean this data transparently prior to storage.
To ensure consistency, we implement a highly efficient, in-place cleaning filter on the detected telephone column:
Non-Dialable Stripping: Strip spaces, parentheses, hyphens, and letters, while strictly retaining a leading plus symbol (+) to safeguard international formats.
Trunk Prefix Mapping: Convert national dialing formats (like zero-prefixed numbers) to normalized international formats based on the current country code of the device's SIM card. If the SIM is registered in the United Kingdom, leading 0 is normalized to +44 unless a country code is already explicitly present.
Inserting thousands of rows with duplicate entries into local databases is inefficient. Instead of handling duplicates through database constraints, which can cause slow transactions and trigger schema violations, we pre-filter records in memory using highly optimized sets:
val uniqueNumbers = HashSet<String>(expectedSize)
val importQueue = ArrayList<LeadRecord>()
for (rawRow in parsedRows) {
val rawPhone = rawRow[detectedPhoneIndex]
val normalized = normalizePhoneNumber(rawPhone, defaultCountryCode)
if (normalized.isNotEmpty() && uniqueNumbers.add(normalized)) {
importQueue.add(LeadRecord(phone = normalized, name = rawRow[detectedNameIndex]))
}
}By executing deduplication on raw primitives before committing transactions to the SQLite/Room database engine, we avoid index locking contention, which accelerates database integration on budget hardware. For architectural insight on avoiding external cloud dependencies during dialer queue processing, see our article on Ditching Cloud Seat Fees: Building a 100% On-Device CRM Auto Dialer.
To validate the performance of our stream-based parser and heuristic scoring engine, we ran benchmarks across various Android processing chips. The trial imported a complex 10,000-row XLSX spreadsheet containing 12 data columns (including names, emails, addresses, noise columns like IDs, and localized phone numbers).
The table below highlights performance across standard device profiles, comparing our stream parser with standard DOM-based alternatives:
Device Model / Processor | DOM-Based Engine Peak RAM | Stream-Based Parser Peak RAM | Stream-Based Extraction Time | Heuristic Match Accuracy |
|---|---|---|---|---|
Low-End Android (MediaTek Helio G85) | 148 MB (OOM Risks) | 7.8 MB | 1.82 seconds | 99.6% |
Mid-Range (Snapdragon 778G) | 152 MB | 8.1 MB | 0.89 seconds | 99.8% |
Flagship (Snapdragon 8 Gen 2) | 161 MB | 8.4 MB | 0.34 seconds | 99.8% |
To guarantee that spreadsheet scanning does not drop the UI main thread below a smooth 60fps, all parsing and heuristic scoring runs inside specialized Kotlin Coroutines using Dispatchers.IO.
lifecycleScope.launch(Dispatchers.Default) {
val rawMatrix = streamParser.parseStream(fileInputStream, limit = 50)
val bestPhoneIndex = phoneColumnDetector.detectPhoneColumn(rawMatrix)
withContext(Dispatchers.Main) {
if (bestPhoneIndex != -1) {
applyAutoMapping(bestPhoneIndex)
} else {
promptManualSelection()
}
}
}By running extraction tasks inside Dispatchers.Default or Dispatchers.IO, the user interface remains fully responsive. We also use target optimization profiles via custom R8 rules to prune unused metadata from our streaming libraries, maximizing runtime execution speed. Developers looking to optimize their compilation output will find valuable patterns in our guide on Slashing Flutter OCR Cold Starts by 40% with R8.
When engineering on-device data engines, security is a core requirement. Lead spreadsheets containing customer phone numbers, names, and emails are protected personal information. Processing files locally removes the risk of exposing this sensitive data to external networks.
By executing all parsing operations locally inside the application sandbox, raw spreadsheets are never transmitted to external APIs for analysis. This approach simplifies compliance with strict regulations, such as GDPR and CCPA, as no customer data leaves the physical device during processing.
Once columns are identified, validated, and normalized, records are committed to localized SQLite/Room databases using SQLCipher encryption. Temporary staging streams created during file extraction are stored within the app's secure cache directory (context.cacheDir) and deleted immediately after the import transaction is finalized, leaving no raw file remnants on the shared file system.
By engineering a custom stream-based parser combined with a lightweight Kotlin heuristic scoring engine, we eliminated manual column mapping for outbound sales teams using Staksoft Bulk Dialer. This architecture parses lists under 2 seconds, keeps the runtime memory footprint below 9MB, and keeps personal data secure by operating 100% on-device.
Through robust design patterns like single-pass character classifiers, thread isolation via Coroutines, and local database normalization, we created a seamless import experience that is fast, safe, and highly reliable.
Apache POI loads the complete document tree as Java objects into memory (DOM parser), which can consume over 150MB of RAM for standard enterprise lists and cause Out Of Memory (OOM) errors on mobile devices. It also increases the APK size by over 30MB, whereas a stream-based parser keeps RAM usage below 9MB and adds minimal size to the app.
The algorithm uses a heuristic scoring matrix. While serial numbers or credit cards are long numeric strings, they typically fail phone-specific criteria, such as the presence of international plus (+) prefixes, parentheses, and spacing structures. Additionally, columns that contain exclusively fixed-length integer patterns or values that exceed 15 digits are penalized, separating phone numbers from order IDs or product codes.
Data privacy is maintained because the entire parsing, heuristic analysis, normalization, and database storage pipeline is executed 100% on-device. No network connections are made, and no customer spreadsheet rows are transmitted to external servers, providing full compliance with GDPR and CCPA requirements.
The parser reads the current SIM card country code of the host device to resolve local telephone patterns. If a row lacks an international country code prefix, the engine uses the device's local routing configuration to automatically normalize the number into standard E.164 format prior to dialing.
package com.staksoft.bulkdialer.parser
import kotlin.math.abs
class PhoneColumnDetector {
companion object {
private const val MIN_SAMPLE_SIZE = 10
private const val MAX_SAMPLE_SIZE = 50
private const val TARGET_PHONE_DIGIT_MIN = 7
private const val TARGET_PHONE_DIGIT_MAX = 15
}
data class ColumnFeatureProfile(
val columnIndex: Int,
var totalRowsScored: Int = 0,
var numericCharRatioSum: Float = 0f,
var validSpecialCharRatioSum: Float = 0f,
var averageLength: Float = 0f,
var matchesLikelyPhonePatternCount: Int = 0,
var potentialPostalOrIdCount: Int = 0
) {
fun calculateScore(): Float {
if (totalRowsScored == 0) return 0f
val avgNumericRatio = numericCharRatioSum / totalRowsScored
val avgSpecialRatio = validSpecialCharRatioSum / totalRowsScored
val patternMatchRatio = matchesLikelyPhonePatternCount.toFloat() / totalRowsScored
val noiseRatio = potentialPostalOrIdCount.toFloat() / totalRowsScored
val avgLen = averageLength / totalRowsScored
var score = 0f
// Base evaluation of character structure
score += avgNumericRatio * 40f
score += avgSpecialRatio * 15f
score += patternMatchRatio * 35f
// Length penalty: E.164 numbers are typically 7 to 15 digits
val lenPenalty = if (avgLen in 7.0f..16.0f) 10f else -25f
score += lenPenalty
// Penalize high density of pure IDs, postal codes (usually short/fixed length) or timestamps
score -= (noiseRatio * 50f)
return score.coerceIn(0f, 100f)
}
}
fun detectPhoneColumn(matrix: List>): Int {
if (matrix.isEmpty()) return -1
val columnCount = matrix[0].size
val profiles = Array(columnCount) { ColumnFeatureProfile(columnIndex = it) }
val sampleRows = matrix.take(MAX_SAMPLE_SIZE)
for (row in sampleRows) {
for (colIndex in 0 until columnCount) {
if (colIndex >= row.size) continue
val rawCell = row[colIndex].trim()
if (rawCell.isEmpty()) continue
val profile = profiles[colIndex]
profile.totalRowsScored++
analyzeCell(rawCell, profile)
}
}
var bestColumnIndex = -1
var highestScore = -1f
for (profile in profiles) {
val finalScore = profile.calculateScore()
if (finalScore > highestScore && finalScore > 40f) {
highestScore = finalScore
bestColumnIndex = profile.columnIndex
}
}
return bestColumnIndex
}
private fun analyzeCell(cell: String, profile: ColumnFeatureProfile) {
var digitCount = 0
var specialCharCount = 0
var nonNumericDialableCount = 0
for (char in cell) {
when {
char.isDigit() -> digitCount++
char == '+' || char == '-' || char == '(' || char == ')' || char == ' ' -> {
specialCharCount++
if (char == '+' || char == '-') nonNumericDialableCount++
}
}
}
val totalLen = cell.length
profile.averageLength += totalLen
if (totalLen > 0) {
profile.numericCharRatioSum += (digitCount.toFloat() / totalLen)
profile.validSpecialCharRatioSum += (specialCharCount.toFloat() / totalLen)
}
// Pattern heuristic
if (digitCount in TARGET_PHONE_DIGIT_MIN..TARGET_PHONE_DIGIT_MAX) {
profile.matchesLikelyPhonePatternCount++
}
// Postal/ID heuristics: pure integers of specific short lengths, or typical sequence numbers
if ((digitCount == totalLen && (totalLen == 5 || totalLen == 6)) || (digitCount <= 3 && totalLen <= 3)) {
profile.potentialPostalOrIdCount++
}
}
}Why On-Device Architecture Enables True Lifetime Software: Understand the architecture patterns that allow on-device Android engines to perform zero-cost computation without relying on backend servers.
Ditching Cloud Seat Fees: Building a 100% On-Device CRM Auto Dialer: An in-depth look at how Staksoft built Bulk Dialer to completely skip cloud seat fees using robust on-device databases and parsers.
Tell us about your project and our engineers will get back to you.