package com.rfidsystem.hardware import android.content.Context import android.content.SharedPreferences import android.media.AudioAttributes import android.media.AudioManager import android.media.SoundPool import android.os.Build import android.os.Handler import android.os.Looper import android.os.VibrationEffect import android.os.Vibrator import android.os.VibratorManager import android.util.Log import com.rfidsystem.R import com.rfidsystem.database.AppDatabase import com.rfidsystem.database.EpcTagEntity import com.rfidsystem.database.OutboxOperation import com.rfidsystem.database.ProductEntity import com.rfidsystem.network.DeviceApiClient import com.rfidsystem.network.OutboxProcessor import org.json.JSONArray import org.json.JSONObject import java.net.URL import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.Executors import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow enum class HardwareVendor { SIMULATED_HAL, CHAINWAY_C72, SEUIC_AUTOID, ZEBRA_RFID } /** Default backend for the handheld. Must match the Firebase project the rest of the platform uses. */ const val DEFAULT_DEVICE_API_ENDPOINT = "https://asia-south1-myshopassistant-9b7ac.cloudfunctions.net/deviceApi" /** How often queued tag reads are handed to the UI. ~2 frames; low enough to feel instant. */ private const val TAG_BATCH_INTERVAL_MS = 33L /** * Upper bound on undelivered reads. If the UI stalls, older reads are dropped rather than growing the * queue without limit — a duplicate read of the same tag carries no new information. */ private const val MAX_PENDING_READS = 4096 /** Chunk size for writing the EPC index, matching the product batch size. */ private const val EPC_INSERT_BATCH_SIZE = 500 /** Reads one field off a vendor tag object. Resolved once, then reused for every tag. */ private typealias TagAccessor = (Any) -> Any? /** One tag observation delivered to the UI layer. */ data class TagRead(val epc: String, val rssi: Double) data class ReaderConfig( val powerDbm: Int = 26, val epcSession: Int = 1, val frequencyRegion: String = "IN_865_867", val beepOnRead: Boolean = true, val paperWidthMm: Int = 58, val apiKey: String = "", val endpointUrl: String = DEFAULT_DEVICE_API_ENDPOINT, val defaultEpcPrefix: String = "", val selectedWarehouseId: String = "", val selectedWarehouseName: String = "" ) { val enableBeepSound: Boolean get() = beepOnRead } class UhfReaderManager private constructor(private val context: Context) { private val prefs: SharedPreferences = context.getSharedPreferences("uhf_reader_prefs", Context.MODE_PRIVATE) private var readerInstance: Any? = null private var activeVendor: HardwareVendor = HardwareVendor.SIMULATED_HAL /** * Dedicated to the inventory scan loop ONLY. The loop occupies this thread for the entire * duration of a scan, so nothing else may be submitted here or it would never run. */ private val scanExecutor = Executors.newSingleThreadExecutor { r -> Thread(r, "uhf-scan").apply { priority = Thread.NORM_PRIORITY + 1 } } /** Separate pool for every HTTP call, so network work is never blocked by an active scan. */ private val networkExecutor = Executors.newFixedThreadPool(3) { r -> Thread(r, "uhf-net") } private val mainHandler = Handler(Looper.getMainLooper()) private var soundPool: SoundPool? = null private var scanSoundId: Int = 0 private var alarmStreamId: Int = 0 /** * Credentials are read per request rather than captured, so edits in Settings take effect on the * next call without rebuilding the client. */ private val apiClient = DeviceApiClient { DeviceApiClient.Credentials(getBaseDeviceApiUrl(), currentConfig.apiKey.trim()) } private val outboxProcessor = OutboxProcessor.getInstance(context, apiClient) private var batchScanCallback: ((List) -> Unit)? = null private var activeTargetEpcs: Set? = null private var activePrefixMask: String? = null private var lastBeepTimeMs: Long = 0L /** * Tags land here from the scan thread and are drained to the main thread on a fixed tick. * Posting one Runnable per tag floods the main looper during a burst and starves rendering. */ private val pendingReads = ConcurrentLinkedQueue() /** Reflection handles resolved once per connection instead of per tag. See [resolveTagAccessors]. */ @Volatile private var epcAccessor: TagAccessor? = null @Volatile private var rssiAccessor: TagAccessor? = null @Volatile private var isStopping = false var enableBeepSound: Boolean get() = currentConfig.beepOnRead set(value) { saveConfig(currentConfig.copy(beepOnRead = value)) } var currentConfig: ReaderConfig = loadConfig() private set private val _isConnected = MutableStateFlow(false) val isConnectedFlow: StateFlow = _isConnected.asStateFlow() val isConnected: Boolean get() = _isConnected.value private val _isScanning = MutableStateFlow(false) val isScanningFlow: StateFlow = _isScanning.asStateFlow() val isScanning: Boolean get() = _isScanning.value private val _isCloudConnected = MutableStateFlow(false) val isCloudConnectedFlow: StateFlow = _isCloudConnected.asStateFlow() val isCloudConnected: Boolean get() = _isCloudConnected.value private val _cloudStatusMessage = MutableStateFlow("Not Configured") val cloudStatusMessageFlow: StateFlow = _cloudStatusMessage.asStateFlow() val cloudStatusMessage: String get() = _cloudStatusMessage.value private val _lastHardwareError = MutableStateFlow(null) val lastHardwareError: StateFlow = _lastHardwareError.asStateFlow() private val _hardwareStatusString = MutableStateFlow("Disconnected") val hardwareStatusString: StateFlow = _hardwareStatusString.asStateFlow() fun clearHardwareError() { _lastHardwareError.value = null } private fun initAudio() { try { val audioAttributes = AudioAttributes.Builder() .setUsage(AudioAttributes.USAGE_ASSISTANCE_SONIFICATION) .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) .build() soundPool = SoundPool.Builder() .setMaxStreams(4) .setAudioAttributes(audioAttributes) .build() scanSoundId = soundPool?.load(context, R.raw.scan, 1) ?: 0 } catch (e: Exception) { Log.w(TAG, "SoundPool init failed: ${e.localizedMessage}") } } init { initAudio() } companion object { private const val TAG = "UhfReaderManager" /** Host fragment of the decommissioned Firebase project; stored values matching it are reset. */ private const val LEGACY_PROJECT_HOST = "garment-rfid" /** Reported when the SDK exposes no usable RSSI accessor. */ private const val DEFAULT_RSSI_DBM = -60.0 private const val KEY_POWER_DBM = "power_dbm" private const val KEY_EPC_SESSION = "epc_session" private const val KEY_FREQ_REGION = "freq_region" private const val KEY_BEEP_SOUND = "beep_sound" private const val KEY_PAPER_WIDTH = "paper_width" private const val KEY_API_KEY = "api_key" private const val KEY_ENDPOINT_URL = "endpoint_url" private const val KEY_DEFAULT_EPC_PREFIX = "default_epc_prefix" private const val KEY_WAREHOUSE_ID = "warehouse_id" private const val KEY_WAREHOUSE_NAME = "warehouse_name" @Volatile private var INSTANCE: UhfReaderManager? = null fun getInstance(context: Context): UhfReaderManager { return INSTANCE ?: synchronized(this) { INSTANCE ?: UhfReaderManager(context.applicationContext).also { INSTANCE = it } } } } /** Accepts only absolute http(s) URLs; anything else falls back to the default endpoint. */ fun normalizeEndpointUrl(raw: String?): String { val trimmed = raw?.trim().orEmpty().removeSuffix("/") if (trimmed.isEmpty()) return DEFAULT_DEVICE_API_ENDPOINT // Installs created before the project rename pointed at a Firebase project that no longer exists. if (trimmed.contains(LEGACY_PROJECT_HOST, ignoreCase = true)) return DEFAULT_DEVICE_API_ENDPOINT return try { val parsed = URL(trimmed) if (parsed.protocol.equals("http", true) || parsed.protocol.equals("https", true)) { if (parsed.host.isNullOrBlank()) DEFAULT_DEVICE_API_ENDPOINT else trimmed } else { DEFAULT_DEVICE_API_ENDPOINT } } catch (_: Exception) { DEFAULT_DEVICE_API_ENDPOINT } } fun loadConfig(): ReaderConfig { val config = ReaderConfig( powerDbm = prefs.getInt(KEY_POWER_DBM, 26), epcSession = prefs.getInt(KEY_EPC_SESSION, 1), frequencyRegion = prefs.getString(KEY_FREQ_REGION, "IN_865_867") ?: "IN_865_867", beepOnRead = prefs.getBoolean(KEY_BEEP_SOUND, true), paperWidthMm = prefs.getInt(KEY_PAPER_WIDTH, 58), apiKey = prefs.getString(KEY_API_KEY, "") ?: "", endpointUrl = normalizeEndpointUrl(prefs.getString(KEY_ENDPOINT_URL, DEFAULT_DEVICE_API_ENDPOINT)), defaultEpcPrefix = prefs.getString(KEY_DEFAULT_EPC_PREFIX, "") ?: "", selectedWarehouseId = prefs.getString(KEY_WAREHOUSE_ID, "") ?: "", selectedWarehouseName = prefs.getString(KEY_WAREHOUSE_NAME, "") ?: "" ) currentConfig = config return config } /** * Persists with `apply()`, not `commit()`. This is called from slider drags and text-field * edits; a synchronous fsync on every keystroke is a guaranteed main-thread stall. */ fun saveConfig(rawConfig: ReaderConfig) { val config = rawConfig.copy(endpointUrl = normalizeEndpointUrl(rawConfig.endpointUrl)) currentConfig = config prefs.edit().apply { putInt(KEY_POWER_DBM, config.powerDbm) putInt(KEY_EPC_SESSION, config.epcSession) putString(KEY_FREQ_REGION, config.frequencyRegion) putBoolean(KEY_BEEP_SOUND, config.beepOnRead) putInt(KEY_PAPER_WIDTH, config.paperWidthMm) putString(KEY_API_KEY, config.apiKey) putString(KEY_ENDPOINT_URL, config.endpointUrl) putString(KEY_DEFAULT_EPC_PREFIX, config.defaultEpcPrefix) putString(KEY_WAREHOUSE_ID, config.selectedWarehouseId) putString(KEY_WAREHOUSE_NAME, config.selectedWarehouseName) }.apply() } fun vibrateHapticPulse(durationMs: Long = 40) { try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { val vm = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager vm.defaultVibrator.vibrate(VibrationEffect.createOneShot(durationMs, VibrationEffect.DEFAULT_AMPLITUDE)) } else { @Suppress("DEPRECATION") val v = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { v.vibrate(VibrationEffect.createOneShot(durationMs, VibrationEffect.DEFAULT_AMPLITUDE)) } else { @Suppress("DEPRECATION") v.vibrate(durationMs) } } } catch (_: Exception) {} } fun playBeepChime() { if (currentConfig.beepOnRead) { val now = System.currentTimeMillis() if (now - lastBeepTimeMs >= 40L) { lastBeepTimeMs = now if (scanSoundId != 0) { soundPool?.play(scanSoundId, 1.0f, 1.0f, 0, 0, 1.0f) } } } } fun playGeigerBeep(rssiDbm: Double) { if (currentConfig.beepOnRead) { val now = System.currentTimeMillis() val clampedRssi = rssiDbm.coerceIn(-90.0, -30.0) val normalizedRatio = ((clampedRssi - (-90.0)) / ((-30.0) - (-90.0))).toFloat() // 0.0 weak -> 1.0 strong val minIntervalMs = (350L - (normalizedRatio * 230L)).toLong().coerceAtLeast(120L) if (now - lastBeepTimeMs >= minIntervalMs) { lastBeepTimeMs = now val pitchRate = (1.0f + (normalizedRatio * 0.5f)).coerceIn(0.5f, 2.0f) if (scanSoundId != 0) { soundPool?.play(scanSoundId, 1.0f, 1.0f, 0, 0, pitchRate) } } } } @Volatile var isAlarmActive: Boolean = false private set fun playSecurityAlarm() { isAlarmActive = true vibrateHapticPulse(400) try { if (scanSoundId != 0) { alarmStreamId = soundPool?.play(scanSoundId, 1.0f, 1.0f, 1, -1, 1.5f) ?: 0 } } catch (_: Exception) {} } fun stopSecurityAlarm() { isAlarmActive = false try { if (alarmStreamId != 0) { soundPool?.stop(alarmStreamId) alarmStreamId = 0 } } catch (_: Exception) {} } fun getBaseDeviceApiUrl(rawEndpoint: String = currentConfig.endpointUrl): String { var clean = rawEndpoint.trim().removeSuffix("/") if (clean.isEmpty()) return "" val subPaths = listOf("/healthcheck", "/pullProducts", "/pushProducts", "/updateProductStock", "/findProductByEpc", "/searchProducts", "/createInvoice", "/searchReturnByEpc", "/processReturn", "/searchCustomer", "/transferStock", "/pullWarehouses", "/pullTransfers", "/submitInventoryAudit", "/saveDeviceDetails") for (subPath in subPaths) { if (clean.endsWith(subPath, ignoreCase = true)) { clean = clean.substring(0, clean.length - subPath.length).removeSuffix("/") } } return clean } fun cleanErrorMessage(rawError: String): String { val trimmed = rawError.trim() if (trimmed.isEmpty()) return "Unknown server error" try { val json = JSONObject(trimmed) if (json.has("message")) return json.getString("message") if (json.has("error")) return json.getString("error") } catch (_: Exception) {} return trimmed.take(200) } fun verifyCloudHealth( endpointUrl: String = currentConfig.endpointUrl, apiKey: String = currentConfig.apiKey, onResult: (Boolean, String) -> Unit ) { val baseUrl = getBaseDeviceApiUrl(endpointUrl) val cleanKey = apiKey.trim() if (baseUrl.isEmpty() || cleanKey.isEmpty()) { _isCloudConnected.value = false _cloudStatusMessage.value = "Missing Endpoint or API Key" onResult(false, cloudStatusMessage) return } // Explicit credentials: Settings calls this to test values that are not saved yet. val client = DeviceApiClient { DeviceApiClient.Credentials(baseUrl, cleanKey) } networkExecutor.execute { when (val result = client.post("healthcheck", JSONObject().put("action", "healthcheck"))) { is DeviceApiClient.ApiResult.Success -> { val storeName = result.data?.optString("companyName").orEmpty().ifBlank { "Store Cloud" } _isCloudConnected.value = true _cloudStatusMessage.value = "Cloud Connected ($storeName)" } is DeviceApiClient.ApiResult.Failure -> { _isCloudConnected.value = false _cloudStatusMessage.value = result.message } } val connected = _isCloudConnected.value val message = _cloudStatusMessage.value mainHandler.post { onResult(connected, message) } } } fun pullProducts(onResult: (Boolean, String, String) -> Unit) { callApi("pullProducts", JSONObject(), onResult) { result -> val productsArr = result.data?.optJSONArray("products") ?: JSONObject(result.raw.ifBlank { "{}" }).optJSONArray("products") if (productsArr == null || productsArr.length() == 0) { return@callApi "Catalog empty or no products returned." } val entities = ArrayList(productsArr.length()) val epcTags = mutableListOf() for (i in 0 until productsArr.length()) { val p = productsArr.getJSONObject(i) val productId = p.optString("productId", p.optString("id", "prod_$i")) val tagsArr = p.optJSONArray("tags") val epcIdsObj = p.optJSONObject("epcIds") val specsObj = p.optJSONObject("specifications") val mfgObj = p.optJSONObject("manufacturing") val qcObj = p.optJSONObject("quality") val locObj = p.optJSONObject("location") val whStockObj = p.optJSONObject("warehouseStock") val activeEpcs = mutableListOf() if (epcIdsObj != null && epcIdsObj.length() > 0) { val keys = epcIdsObj.keys() while (keys.hasNext()) { val k = keys.next() val item = epcIdsObj.optJSONObject(k) val status = item?.optString("status", "IN_STOCK") ?: "IN_STOCK" if (status == "IN_STOCK" || status == "ACTIVE") { activeEpcs.add(k) } } } else { activeEpcs.addAll(extractEpcsFromTagsArray(tagsArr)) } val calculatedStock = if (activeEpcs.isNotEmpty()) activeEpcs.size else p.optInt("stockCount", p.optInt("stock", 0)) entities.add( ProductEntity( productId = productId, sku = p.optString("sku", ""), name = p.optString("name", ""), brand = p.optString("brand", ""), category = p.optString("category", "ACP Sheet"), price = p.optDouble("price", p.optDouble("finalPrice", 0.0)), stockCount = calculatedStock, imageUrl = p.optString("imageUrl", ""), tagsJson = tagsArr?.toString() ?: "[]", specificationsJson = specsObj?.toString() ?: "{}", manufacturingJson = mfgObj?.toString() ?: "{}", qualityJson = qcObj?.toString() ?: "{}", locationJson = locObj?.toString() ?: "{}", warehouseStockJson = whStockObj?.toString() ?: "{}", hsnCode = p.optString("hsnCode", ""), gstRate = p.optDouble("gstRate", 0.0), taxInclusive = p.optBoolean("taxInclusive", true), lastUpdated = System.currentTimeMillis() ) ) activeEpcs.forEach { epc -> epcTags.add(EpcTagEntity(epc = epc, productId = productId)) } } // Catalog rows and the EPC index are swapped together; a partial swap would leave tag // lookups disagreeing with the product list. val db = AppDatabase.getInstance(context) db.runInTransaction { db.productDao().replaceAllProducts(entities) db.epcTagDao().deleteAll() epcTags.chunked(EPC_INSERT_BATCH_SIZE).forEach { db.epcTagDao().insertAll(it) } } "Successfully pulled ${entities.size} products from cloud." } } /** Accepts both `["EPC", ...]` and `[{"epc": "..."}, ...]` shapes returned by the backend. */ internal fun extractEpcsFromTagsArray(tagsArr: JSONArray?): List { if (tagsArr == null) return emptyList() val out = mutableListOf() for (i in 0 until tagsArr.length()) { val item = tagsArr.opt(i) ?: continue var epc = if (item is JSONObject) { item.optString("epc") } else { item.toString() }.trim().uppercase() if (epc.startsWith("{")) { epc = try { JSONObject(epc).optString("epc").trim().uppercase() } catch (_: Exception) { "" } } if (epc.isNotBlank() && !epc.all { it == '0' }) out.add(epc) } return out } fun getOfflineProducts(): List { return try { val db = AppDatabase.getInstance(context) db.productDao().getAllProducts() } catch (_: Exception) { emptyList() } } fun getOfflineProductCount(): Int { return try { val db = AppDatabase.getInstance(context) db.productDao().getProductCount() } catch (_: Exception) { 0 } } fun pushProducts(productsJsonPayload: String, onResult: (Boolean, String) -> Unit) { val body = try { JSONObject(productsJsonPayload) } catch (_: Exception) { JSONObject() } networkExecutor.execute { val result = apiClient.post("pushProducts", body) val success = result is DeviceApiClient.ApiResult.Success val message = when (result) { is DeviceApiClient.ApiResult.Success -> result.raw is DeviceApiClient.ApiResult.Failure -> result.message } mainHandler.post { onResult(success, message) } } } fun updateProductStock( productId: String, epcs: List, warehouseId: String? = null, onResult: (Boolean, String, Int, Int, String) -> Unit ) { val validEpcs = epcs.mapNotNull { com.rfidsystem.util.EpcValidator.clean(it) } .filter { com.rfidsystem.util.EpcValidator.isValid(it) } .distinct() if (validEpcs.isEmpty()) { mainHandler.post { onResult(false, "No valid EPC tags provided for stock update.", 0, 0, "") } return } val targetWh = warehouseId ?: currentConfig.selectedWarehouseId.ifBlank { null } val body = JSONObject() .put("productId", productId) .put("epcs", JSONArray(validEpcs)) if (!targetWh.isNullOrBlank()) { body.put("warehouseId", targetWh) } networkExecutor.execute { when (val result = apiClient.post("updateProductStock", body)) { is DeviceApiClient.ApiResult.Success -> { val newly = result.data?.optInt("newlyRegisteredCount", epcs.size) ?: epcs.size val total = result.data?.optInt("totalStock", epcs.size) ?: epcs.size mainHandler.post { onResult(true, result.message, newly, total, result.raw) } } is DeviceApiClient.ApiResult.Failure -> { // httpCode == null means the request never reached the server. Queue it rather // than losing the scan; a rejection from the server is final and is not queued. val queued = result.httpCode == null && outboxProcessor.enqueue(OutboxOperation.UPDATE_PRODUCT_STOCK, body) val message = if (queued) { "Offline — ${epcs.size} tag(s) queued and will sync automatically." } else { result.message } mainHandler.post { onResult(queued, message, 0, 0, result.raw) } } } } } /** Retries anything queued while the device was offline. Cheap to call repeatedly. */ fun flushPendingWrites() { outboxProcessor.flush() } /** Number of writes still waiting to reach the backend. */ val pendingWriteCount: Flow get() = outboxProcessor.pendingCount fun findProductByEpc(epc: String, onResult: (Boolean, String, String) -> Unit) { callApi("findProductByEpc", JSONObject().put("epc", epc), onResult) { "Product found." } } fun searchProducts(query: String, onResult: (Boolean, String, String) -> Unit) { callApi("searchProducts", JSONObject().put("query", query), onResult) { "Products found." } } /** * Vendor SDK init does reflection plus a native `init()` that can block. Never run it on the * main thread — observe [hardwareStatusString] / [isConnectedFlow] for the result instead. */ fun connectReaderAsync() { _hardwareStatusString.value = "Connecting…" networkExecutor.execute { connectReader() } } fun connectReader(): Boolean { epcAccessor = null rssiAccessor = null val candidates = listOf( "com.rscja.deviceapi.RFIDWithUHFUART", "com.rscja.deviceapi.RFIDWithUHF", "com.rscja.deviceapi.RFIDWithUHFUSB", "com.rscja.deviceapi.RFIDWithUHFA4", "com.rscja.deviceapi.RFIDWithUHFA8" ) for (className in candidates) { try { val rfidClass = Class.forName(className) val getInstanceMethod = rfidClass.getMethod("getInstance") val instance = getInstanceMethod.invoke(null) if (instance != null) { val initMethod = instance.javaClass.getMethod("init") val success = initMethod.invoke(instance) as? Boolean ?: true if (success) { readerInstance = instance activeVendor = HardwareVendor.CHAINWAY_C72 _isConnected.value = true _hardwareStatusString.value = "Connected ($className)" _lastHardwareError.value = null Log.i(TAG, "[HARDWARE-INIT] Hardware RFID Reader connected via $className!") return true } } } catch (_: Exception) {} } readerInstance = null activeVendor = HardwareVendor.SIMULATED_HAL _isConnected.value = false _hardwareStatusString.value = "Hardware Disconnected" return false } fun getHardwareStatusString(): String { return _hardwareStatusString.value } /** * The live vendor SDK handle, for components that must call it directly (tag lock/unlock). * Null when running on the simulated HAL. */ internal fun vendorReaderInstance(): Any? = if (activeVendor == HardwareVendor.CHAINWAY_C72) readerInstance else null /** Restricts the radio to a single tag so a lock/write targets it and not a neighbour. */ internal fun selectTagForWrite(epc: String) { applyHardwareFilterIfSupported(epc.trim().uppercase()) } internal fun clearTagSelection() { applyHardwareFilterIfSupported(null) } fun applyPowerSettings(powerDbm: Int): Boolean { currentConfig = currentConfig.copy(powerDbm = powerDbm) saveConfig(currentConfig) try { if (readerInstance != null && activeVendor == HardwareVendor.CHAINWAY_C72) { val setPowerMethod = readerInstance!!.javaClass.getMethod("setPower", Int::class.javaPrimitiveType) setPowerMethod.invoke(readerInstance, powerDbm) } } catch (e: Exception) { Log.w(TAG, "Failed to apply hardware power $powerDbm dBm: ${e.localizedMessage}") } return true } private fun getCommonPrefix(epcs: Set): String? { if (epcs.isEmpty()) return null val first = epcs.first() var prefixLen = 0 for (i in 1..first.length) { val candidate = first.substring(0, i) if (epcs.all { it.startsWith(candidate) }) { prefixLen = i } else { break } } return if (prefixLen >= 4) first.substring(0, prefixLen) else null } private fun applyHardwareFilterIfSupported(prefix: String?) { try { if (readerInstance != null && activeVendor == HardwareVendor.CHAINWAY_C72) { val method = readerInstance!!.javaClass.getMethod( "setFilter", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, String::class.java ) if (prefix.isNullOrBlank()) { method.invoke(readerInstance, 1, 32, 0, "") } else { val cleanPrefix = prefix.trim().uppercase() val bitLen = cleanPrefix.length * 4 method.invoke(readerInstance, 1, 32, bitLen, cleanPrefix) } } } catch (_: Exception) {} } /** * Resolves the getter/field used to read one property off the vendor's tag object. * * The candidate names differ per SDK, so the original code probed all of them on every tag and * relied on caught [NoSuchMethodException]s to fall through. At a few hundred reads per second * that meant thousands of exceptions per second, each with a stack trace — by far the most * expensive thing in the scan path. The winning accessor is resolved once and cached instead. */ private fun resolveTagAccessor( tagInfo: Any, methodNames: List, fieldNames: List ): TagAccessor? { val cls = tagInfo.javaClass for (name in methodNames) { try { val method = cls.getMethod(name) if (method.invoke(tagInfo) != null) { return { target -> method.invoke(target) } } } catch (_: Exception) { } } for (name in fieldNames) { try { val field = cls.getField(name) if (field.get(tagInfo) != null) { return { target -> field.get(target) } } } catch (_: Exception) { } } return null } private fun extractEpcFromTagInfo(tagInfo: Any): String { val accessor = epcAccessor ?: resolveTagAccessor( tagInfo, listOf("getEPC", "getEpc", "getEPCStr", "getEpctag", "getEpcStr", "getEpcHeader"), listOf("epc", "EPC", "epcStr") )?.also { epcAccessor = it } ?: return "" return try { accessor(tagInfo)?.toString()?.trim().orEmpty() } catch (_: Exception) { "" } } private fun extractRssiFromTagInfo(tagInfo: Any): Double { val accessor = rssiAccessor ?: resolveTagAccessor( tagInfo, listOf("getRSSI", "getRssi", "getRssiStr", "getRssiVal"), emptyList() )?.also { rssiAccessor = it } ?: return DEFAULT_RSSI_DBM return try { accessor(tagInfo)?.toString()?.toDoubleOrNull() ?: DEFAULT_RSSI_DBM } catch (_: Exception) { DEFAULT_RSSI_DBM } } private fun invokeStartInventoryTag(reader: Any): Boolean { val cls = reader.javaClass try { val m = cls.getMethod("startInventoryTag") val res = m.invoke(reader) if (res is Boolean) return res return true } catch (_: Exception) {} try { val m = cls.getMethod("startInventoryTag", Int::class.javaPrimitiveType, Int::class.javaPrimitiveType, Int::class.javaPrimitiveType) val res = m.invoke(reader, 0, 0, 0) if (res is Boolean) return res return true } catch (_: Exception) {} return false } /** * Per-tag convenience wrapper over [startInventoryScanBatched]. Reads still arrive in batches; * this simply fans them out, so existing screens keep working unchanged. The `isScanning` guard * lets a callback stop the scan mid-batch (Read Once relies on that). */ fun startInventoryScan( targetEpcs: Set? = null, prefixMask: String? = null, onEpcDiscovered: (String, Double) -> Unit ) { startInventoryScanBatched(targetEpcs, prefixMask) { batch -> for (read in batch) { if (!isScanning) break onEpcDiscovered(read.epc, read.rssi) } } } fun startInventoryScanBatched( targetEpcs: Set? = null, prefixMask: String? = null, onReads: (List) -> Unit ) { activeTargetEpcs = targetEpcs?.map { it.trim().uppercase() }?.filter { it.isNotBlank() }?.toSet() val effectivePrefix = if (!prefixMask.isNullOrBlank()) prefixMask.trim().uppercase() else if (currentConfig.defaultEpcPrefix.isNotBlank()) currentConfig.defaultEpcPrefix.trim().uppercase() else if (activeTargetEpcs != null && activeTargetEpcs!!.isNotEmpty()) getCommonPrefix(activeTargetEpcs!!) else null activePrefixMask = effectivePrefix // Pass hardware mask to hardware reader module firmware applyHardwareFilterIfSupported(activePrefixMask) if (isScanning) { batchScanCallback = onReads return } batchScanCallback = onReads pendingReads.clear() _isScanning.value = true mainHandler.postDelayed(drainRunnable, TAG_BATCH_INTERVAL_MS) scanExecutor.execute { if (readerInstance == null) { connectReader() } // PHYSICAL NATIVE HARDWARE BRANCH (Chainway C72 / RFID Handheld) if (readerInstance != null) { try { invokeStartInventoryTag(readerInstance!!) val readTagMethod = readerInstance!!.javaClass.getMethod("readTagFromBuffer") while (isScanning) { try { val tagInfo = readTagMethod.invoke(readerInstance) if (tagInfo != null) { val rawEpc = extractEpcFromTagInfo(tagInfo) val rssiVal = extractRssiFromTagInfo(tagInfo) val validation = com.rfidsystem.util.EpcValidator.validate(rawEpc) if (validation.isValid && validation.cleanedEpc != null) { val cleanEpc = validation.cleanedEpc val matchesFilter = when { activeTargetEpcs != null && activeTargetEpcs!!.isNotEmpty() -> { activeTargetEpcs!!.any { target -> cleanEpc == target || cleanEpc.endsWith(target) || target.endsWith(cleanEpc) || cleanEpc.contains(target) } } !activePrefixMask.isNullOrBlank() -> { cleanEpc.startsWith(activePrefixMask!!) } else -> true } if (matchesFilter) { enqueueRead(cleanEpc, rssiVal) } } } else { Thread.sleep(15L) } } catch (e: InterruptedException) { break } catch (_: Exception) { try { Thread.sleep(30L) } catch (_: Exception) {} } } } catch (e: Exception) { val errMsg = "Hardware scan loop exception: ${e.localizedMessage ?: e.javaClass.simpleName}" Log.e(TAG, errMsg) _lastHardwareError.value = errMsg } return@execute } // NO PHYSICAL HARDWARE ATTACHED: DO NOT GENERATE ANY FAKE/MOCK TAGS Log.w(TAG, "[NO-HARDWARE] Scan started but physical reader is not connected. No simulated tags will be emitted.") } } /** Called from the scan thread for every accepted tag. Never blocks and never touches the UI. */ private fun enqueueRead(epc: String, rssi: Double) { if (pendingReads.size >= MAX_PENDING_READS) { pendingReads.poll() } pendingReads.offer(TagRead(epc, rssi)) } /** Runs on the main thread while a scan is active, handing over one batch per tick. */ private val drainRunnable = object : Runnable { override fun run() { deliverPendingReads() if (isScanning) { mainHandler.postDelayed(this, TAG_BATCH_INTERVAL_MS) } } } private fun deliverPendingReads() { if (pendingReads.isEmpty()) return val callback = batchScanCallback ?: run { pendingReads.clear(); return } val batch = ArrayList(pendingReads.size) while (true) { batch.add(pendingReads.poll() ?: break) } if (batch.isNotEmpty()) callback(batch) } fun stopInventoryScan() { // isStopping guards against a consumer calling stop() from inside the final batch delivery. if (!isScanning || isStopping) return isStopping = true try { stopInventoryScanInternal() } finally { isStopping = false } } private fun stopInventoryScanInternal() { mainHandler.removeCallbacks(drainRunnable) // Hand over the tail the loop produced before it was told to stop. Done while isScanning is // still true so the per-tag wrapper does not discard it. if (Looper.myLooper() == Looper.getMainLooper()) { deliverPendingReads() } _isScanning.value = false batchScanCallback = null pendingReads.clear() activeTargetEpcs = null activePrefixMask = null applyHardwareFilterIfSupported(null) if (readerInstance != null && activeVendor == HardwareVendor.CHAINWAY_C72) { try { val stopMethod = readerInstance!!.javaClass.getMethod("stopInventory") stopMethod.invoke(readerInstance) } catch (_: Exception) {} } } fun createInvoice(payloadJson: JSONObject, onResult: (Boolean, String, String) -> Unit) { if (!payloadJson.has("warehouseId") && currentConfig.selectedWarehouseId.isNotBlank()) { payloadJson.put("warehouseId", currentConfig.selectedWarehouseId) } callApi("createInvoice", payloadJson, onResult) { // Prune sold EPCs from local Room SQLite immediately try { val db = AppDatabase.getInstance(context) val itemsArr = payloadJson.optJSONArray("items") val soldEpcs = mutableListOf() if (itemsArr != null) { for (i in 0 until itemsArr.length()) { val itm = itemsArr.optJSONObject(i) val epcsArr = itm?.optJSONArray("epcs") if (epcsArr != null) { for (j in 0 until epcsArr.length()) { soldEpcs.add(epcsArr.optString(j)) } } } } if (soldEpcs.isNotEmpty()) { db.runInTransaction { db.epcTagDao().deleteByEpcs(soldEpcs) } } } catch (e: Exception) { Log.w(TAG, "Local sold tag pruning warning: ${e.message}") } "Invoice created successfully." } } fun searchReturnByEpc(epcs: List, onResult: (Boolean, String, String) -> Unit) { callApi("searchReturnByEpc", JSONObject().put("epcs", JSONArray(epcs)), onResult) { "Return invoice found." } } fun processReturn(epcs: List, reason: String, warehouseId: String? = null, onResult: (Boolean, String, String) -> Unit) { val targetWh = warehouseId ?: currentConfig.selectedWarehouseId.ifBlank { null } val body = JSONObject() .put("epcs", JSONArray(epcs)) .put("reason", reason) if (!targetWh.isNullOrBlank()) { body.put("warehouseId", targetWh) } callApi("processReturn", body, onResult) { "Return processed." } } fun searchCustomer(query: String, onResult: (Boolean, String, String) -> Unit) { callApi("searchCustomer", JSONObject().put("query", query), onResult) { "Customer lookup completed." } } fun transferStock(payloadJson: JSONObject, onResult: (Boolean, String, String) -> Unit) { callApi("transferStock", payloadJson, onResult) { "Stock transfer executed successfully." } } fun pullWarehouses(onResult: (Boolean, String, String) -> Unit) { callApi("pullWarehouses", JSONObject(), onResult) { "Warehouses pulled successfully." } } fun pullTransfers(warehouseId: String? = null, onResult: (Boolean, String, String) -> Unit) { val body = JSONObject() val targetWh = warehouseId ?: currentConfig.selectedWarehouseId.ifBlank { null } if (!targetWh.isNullOrBlank()) { body.put("warehouseId", targetWh) } callApi("pullTransfers", body, onResult) { "Transfers pulled successfully." } } fun submitInventoryAudit( warehouseId: String, warehouseName: String, scannedEpcs: List, notes: String, onResult: (Boolean, String, String) -> Unit ) { val body = JSONObject() .put("warehouseId", warehouseId) .put("warehouseName", warehouseName) .put("scannedEpcs", JSONArray(scannedEpcs)) .put("notes", notes) callApi("submitInventoryAudit", body, onResult) { "Inventory audit uploaded successfully to cloud." } } fun sendHeartbeat(batteryPct: Int? = null, onResult: (Boolean) -> Unit = {}) { val devId = "HANDHELD_${android.os.Build.MODEL.replace(" ", "_")}" val body = JSONObject() .put("deviceId", devId as Any) .put("deviceModel", android.os.Build.MODEL as Any) .put("batteryLevel", batteryPct ?: 100) .put("battery", batteryPct ?: 100) if (currentConfig.selectedWarehouseId.isNotBlank()) { body.put("warehouseId", currentConfig.selectedWarehouseId as Any) body.put("warehouseName", currentConfig.selectedWarehouseName as Any) } callApi("saveDeviceDetails", body, { success, _, _ -> onResult(success) }) { "Heartbeat sent." } } fun logSecurityBreach(epc: String, productId: String, warehouseId: String? = null, onResult: (Boolean) -> Unit = {}) { val devId = "HANDHELD_${android.os.Build.MODEL.replace(" ", "_")}" val targetWh = warehouseId ?: currentConfig.selectedWarehouseId.ifBlank { null } val breachDetails = JSONObject() .put("epc", epc as Any) .put("productId", productId as Any) if (!targetWh.isNullOrBlank()) { breachDetails.put("warehouseId", targetWh as Any) } val outerBody = JSONObject() .put("deviceId", devId as Any) .put("lastSecurityBreach", breachDetails as Any) if (!targetWh.isNullOrBlank()) { outerBody.put("warehouseId", targetWh as Any) outerBody.put("warehouseName", currentConfig.selectedWarehouseName as Any) } callApi("saveDeviceDetails", outerBody, { success, _, _ -> onResult(success) }) { "Security breach logged." } } /** * Shared shape for the `(success, message, rawBody)` endpoints: run off the main thread, map the * result, deliver on the main thread. [onSuccess] may also perform local persistence and returns * the message shown to the user. */ private fun callApi( path: String, body: JSONObject, onResult: (Boolean, String, String) -> Unit, onSuccess: (DeviceApiClient.ApiResult.Success) -> String ) { networkExecutor.execute { when (val result = apiClient.post(path, body)) { is DeviceApiClient.ApiResult.Success -> { val message = try { onSuccess(result) } catch (e: Exception) { Log.e(TAG, "Post-processing failed for $path", e) mainHandler.post { onResult(false, e.localizedMessage ?: "Local save failed.", result.raw) } return@execute } mainHandler.post { onResult(true, message, result.raw) } } is DeviceApiClient.ApiResult.Failure -> { mainHandler.post { onResult(false, result.message, result.raw) } } } } } fun disconnectReader() { _isConnected.value = false stopInventoryScan() } fun releaseResources() { stopInventoryScan() _isConnected.value = false epcAccessor = null rssiAccessor = null try { soundPool?.release() soundPool = null } catch (_: Exception) {} } }