Implementing RFID Inventory in Mobile Applications
RFID inventory differs from barcodes — it's mass simultaneous reading. Warehouse operator scans shelf with reader and reads 200 tags in 3 seconds. Mobile app task: accept this tag stream losslessly, deduplicate (one tag may be read multiple times), compare with expected list, and show discrepancies. Seems simple until you face duplicates, tags outside session, and offline requirements.
RFID Inventory Architecture
Inventory session — finite state machine with clear transitions:
IDLE → SCANNING → PROCESSING → COMPLETED
↓
PAUSED → SCANNING
Each read tag — update in MutableStateFlow with EPC deduplication:
class InventorySession(private val expectedItems: List<InventoryItem>) {
private val _scannedEpcs = MutableStateFlow<Set<String>>(emptySet())
val scannedEpcs: StateFlow<Set<String>> = _scannedEpcs.asStateFlow()
// Derived states
val matchedItems = scannedEpcs.map { epcs ->
expectedItems.filter { it.epc in epcs }
}.stateIn(scope, SharingStarted.Eagerly, emptyList())
val missingItems = scannedEpcs.map { epcs ->
expectedItems.filter { it.epc !in epcs }
}.stateIn(scope, SharingStarted.Eagerly, emptyList())
val unexpectedEpcs = scannedEpcs.map { epcs ->
val knownEpcs = expectedItems.map { it.epc }.toSet()
epcs.filter { it !in knownEpcs }
}.stateIn(scope, SharingStarted.Eagerly, emptyList())
fun onTagRead(epc: String) {
_scannedEpcs.update { current -> current + epc }
}
fun reset() {
_scannedEpcs.value = emptySet()
}
}
Set<String> — automatic deduplication. One EPC may arrive 50+ times during inventory (reader scans at high speed), but appears once in Set.
Real-time Result Display
LazyColumn with key(item.epc) — animated addition of found items:
@Composable
fun InventoryResultsScreen(session: InventorySession) {
val matched by session.matchedItems.collectAsState()
val missing by session.missingItems.collectAsState()
val scanned by session.scannedEpcs.collectAsState()
Column {
// Progress: X of Y found
LinearProgressIndicator(
progress = { if (session.expectedItems.isEmpty()) 0f
else matched.size.toFloat() / session.expectedItems.size }
)
Text("Found: ${matched.size}/${session.expectedItems.size}")
LazyColumn {
items(matched, key = { it.epc }) { item ->
InventoryItemRow(item = item, status = ItemStatus.FOUND)
}
items(missing, key = { it.epc }) { item ->
InventoryItemRow(item = item, status = ItemStatus.MISSING)
}
}
}
}
Offline Mode and Synchronization
Warehouses often lack Wi-Fi. Architecture: local Room database as source of truth, synchronization via WorkManager on connection recovery. Conflicts on merge handled by "last write wins" strategy with server timestamp or idempotent operation queue.
Typical issue — transactions during bulk receipt. If user scans 200 items and app crashes at item 150, must either rollback all or continue from break point. Room supports transactions via @Transaction, but "complete operation" boundary must be explicitly defined at business logic level.
@Entity(tableName = "inventory_sessions")
data class InventorySessionEntity(
@PrimaryKey val sessionId: String,
val locationId: String,
val startedAt: Long,
val completedAt: Long?,
val status: String // "in_progress", "completed", "synced"
)
@Entity(tableName = "scanned_tags")
data class ScannedTagEntity(
@PrimaryKey val epc: String,
val sessionId: String,
val firstSeenAt: Long,
val readCount: Int
)
readCount — quantity of reads for one tag in session. Anomalously low (1–2) while adjacent tags read 20+ times — sign of poor physical tag placement or damage. Useful metric for QA.
After session completion — synchronization via WorkManager on network:
val syncRequest = OneTimeWorkRequestBuilder<InventorySyncWorker>()
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.setInputData(workDataOf("session_id" to sessionId))
.build()
workManager.enqueueUniqueWork("sync_$sessionId", ExistingWorkPolicy.KEEP, syncRequest)
GS1 EPC Decoding
EPC — not just hex string. Structured code: urn:epc:id:sgtin:0614141.107346.2017 (SGTIN — Serialized GTIN, global trade number with serial). Decode via GS1 EPC Information Services:
// SGTIN-96 decoding (most common format)
fun decodeSgtin96(epc: String): Sgtin96? {
val bytes = epc.chunked(2).map { it.toInt(16) }.toByteArray()
val bits = BigInteger(1, bytes)
val header = bits.shiftRight(88).and(BigInteger.valueOf(0xFF)).toInt()
if (header != 0x30) return null // Not SGTIN-96
val filter = bits.shiftRight(85).and(BigInteger.valueOf(0x07)).toInt()
val partition = bits.shiftRight(82).and(BigInteger.valueOf(0x07)).toInt()
// ... continue per partition table: company prefix + item reference + serial
}
Ready library: com.gs4tr.epcis:epcis-rest-client or org.fosstrak.epcis:epcis-repository-client.
Timeline
Inventory mobile app with Zebra/custom BLE reader, offline Room, GS1 decoding and sync: 5 days (simple warehouse, one reader, one tag type) to 2–3 weeks (multi-location, multiple tag types, custom EPC scheme, REST WMS integration).







