package com.rnthermalprinter.printing import android.graphics.BitmapFactory import android.util.Base64 import android.util.Log import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.WritableMap import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock /** * Printing Module - Manages printing with auto-reconnect and connection pooling Handles connection * lifecycle automatically for better reliability */ class PrintingModule { companion object { private const val TAG = "PrintingModule" private const val DEFAULT_TIMEOUT_MS = 10000L private const val MAX_RETRIES = 2 private const val RETRY_DELAY_MS = 500L private const val CONNECTION_IDLE_TIME_MS = 30000L // 30 seconds idle time } // Connection pool for keepAlive support private val activeConnections = ConcurrentHashMap() // Mutex per address to serialize send operations private val sendMutexes = ConcurrentHashMap() // Background job for cleaning idle connections private val cleanupJob = GlobalScope.launch { while (isActive) { delay(10000) // Check every 10 seconds cleanupIdleConnections() } } /** Connection pool entry with last used timestamp */ private data class ConnectionEntry( val connection: PrintConnection, var lastUsed: Long = System.currentTimeMillis() ) /** Print result for React Native */ data class PrintResult(val success: Boolean, val error: PrintError? = null) { fun toWritableMap(): WritableMap { val map = Arguments.createMap() map.putBoolean("success", success) error?.let { map.putMap("error", it.toWritableMap()) } return map } } /** * Main print function - Sends raw ESC/POS commands to printer * Handles connection lifecycle automatically */ suspend fun printRaw( address: String, data: ByteArray, keepAlive: Boolean = false, timeoutMs: Long = DEFAULT_TIMEOUT_MS ): PrintResult = withContext(Dispatchers.IO) { Log.d(TAG, "[Native:Android] printRaw START: addr=$address, size=${data.size}b, keepAlive=$keepAlive") try { // ===== STEP 1: ESTABLISH CONNECTION ===== // Get existing connection from pool or create new one val connectionEntry = getOrCreateConnection(address, timeoutMs) if (connectionEntry == null) { Log.e(TAG, "[Native:Android] printRaw FAILED: Cannot connect after $MAX_RETRIES retries") return@withContext PrintResult( success = false, error = PrintError.connectionFailed( address, "Connection failed after $MAX_RETRIES retries", ErrorStep.CONNECT ) ) } // ===== STEP 2: SEND DATA TO PRINTER ===== // Send the ESC/POS commands through the connection try { connectionEntry.connection.send(data) connectionEntry.lastUsed = System.currentTimeMillis() Log.d(TAG, "[Native:Android] printRaw SUCCESS: ${data.size} bytes sent") // Smart delay based on data size and connection type val drainMs = connectionEntry.connection.getOptimalDrainDelay(data.size) if (drainMs > 0) { Log.d(TAG, "[Native:Android] printRaw DRAIN: delaying ${drainMs}ms for ${data.size} bytes") delay(drainMs) } // ===== STEP 3: MANAGE CONNECTION ===== // Keep connection open or close based on keepAlive flag if (keepAlive) { activeConnections[address] = connectionEntry // Keep for next print } else { // Smart delay before closing based on connection type val closeDelay = connectionEntry.connection.getOptimalCloseDelay() if (closeDelay > 0) { Log.d(TAG, "[Native:Android] printRaw CLOSE: delaying ${closeDelay}ms before closing") delay(closeDelay) } activeConnections.remove(address) connectionEntry.connection.close() } PrintResult(success = true) } catch (e: Exception) { // ===== ERROR: SEND FAILED ===== // Remove bad connection and report error activeConnections.remove(address) connectionEntry.connection.close() Log.e(TAG, "[Native:Android] printRaw SEND ERROR: ${e.message}", e) PrintResult( success = false, error = PrintError.fromException(e, address, ErrorStep.SEND) ) } } catch (e: PrintError) { Log.e(TAG, "[Native:Android] printRaw ERROR: ${e.message}", e) PrintResult(success = false, error = e) } catch (e: Exception) { Log.e(TAG, "[Native:Android] printRaw UNEXPECTED: ${e.message}", e) PrintResult(success = false, error = PrintError.fromException(e, address)) } } /** * Print image from file path - Optimized approach * Loads image directly from disk (no base64 overhead) */ suspend fun printImage( address: String, imagePath: String, widthPx: Int = 384, paperWidthMm: Int? = null, // Optional paper width for margin calculation keepAlive: Boolean = false, timeoutMs: Long = DEFAULT_TIMEOUT_MS, isCutPaper: Boolean = false, marginMm: Double = 0.0, align: Int = 0 // 0=left, 1=center, 2=right ): PrintResult = withContext(Dispatchers.IO) { Log.d(TAG, "[Native:Android] printImage START: path=$imagePath, width=${widthPx}px, paperWidth=${paperWidthMm}mm, margin=${marginMm}mm, align=$align") try { // ===== STEP 1: PROCESS IMAGE FILE ===== // Convert align from Int to String val alignStr = when (align) { 1 -> "center" 2 -> "right" else -> "left" } // Use Zywell SDK for proven high-quality image processing // Returns single ByteArray with all commands (init + bands + footer) val rasterData = ZywellImageProcessor.processImageFile( imagePath = imagePath, widthPx = widthPx, paperWidthMm = paperWidthMm ?: 80, isCutPaper = isCutPaper, align = alignStr, marginMm = marginMm.toInt() ) // ===== STEP 2: VALIDATE RESULT ===== // Check if image was processed successfully if (rasterData.isEmpty()) { Log.e(TAG, "[Native:Android] printImage FAILED: Empty raster data") return@withContext PrintResult( false, PrintError(PrintErrorCode.IMAGE_DECODE_ERROR, "Failed to process image") ) } Log.d(TAG, "[Native:Android] printImage PROCESSED: ${rasterData.size} bytes ready") // ===== STEP 3: SEND TO PRINTER ===== // Send all data in one call - connection layer will automatically chunk into 512-byte packets printRaw(address, rasterData, keepAlive, timeoutMs) } catch (e: PrintError) { Log.e(TAG, "[Native:Android] printImage ERROR: ${e.message}", e) PrintResult(false, e) } catch (e: Exception) { Log.e(TAG, "[Native:Android] printImage EXCEPTION: ${e.message}", e) PrintResult(false, PrintError.fromException(e, address, ErrorStep.SEND)) } } /** * Get existing connection or create new one * Implements connection pooling for performance */ private suspend fun getOrCreateConnection( address: String, timeoutMs: Long ): ConnectionEntry? = withContext(Dispatchers.IO) { // ===== CHECK EXISTING CONNECTION ===== activeConnections[address]?.let { entry -> if (entry.connection.isAlive()) { Log.d(TAG, "[Native:Android] getConnection: Reusing existing for $address") entry.lastUsed = System.currentTimeMillis() return@withContext entry } else { // Dead connection - clean it up Log.d(TAG, "[Native:Android] getConnection: Removing dead connection") activeConnections.remove(address) entry.connection.close() } } // ===== CREATE NEW CONNECTION ===== Log.d(TAG, "[Native:Android] getConnection: Creating new for $address") try { val connection = PrintConnectionFactory.create(address) // Try connecting with timeout and retries val connected = withTimeoutOrNull(timeoutMs) { connection.connectWithRetry(MAX_RETRIES, RETRY_DELAY_MS) } if (connected == true) { Log.d(TAG, "[Native:Android] getConnection SUCCESS: Connected to $address") ConnectionEntry(connection) } else { Log.e(TAG, "[Native:Android] getConnection FAILED: Cannot connect to $address") connection.close() null } } catch (e: Exception) { Log.e(TAG, "[Native:Android] getConnection ERROR: ${e.message}", e) null } } /** * Clean up idle connections periodically * Prevents resource leaks from unused connections */ private fun cleanupIdleConnections() { val now = System.currentTimeMillis() val idleThreshold = now - CONNECTION_IDLE_TIME_MS activeConnections.entries.removeIf { entry -> if (entry.value.lastUsed < idleThreshold) { Log.d(TAG, "[Native:Android] cleanup: Closing idle ${entry.key}") try { entry.value.connection.close() } catch (e: Exception) { Log.w(TAG, "[Native:Android] cleanup: Error closing ${e.message}") } true } else { false } } } /** * Disconnect printer connections * Can disconnect specific printer or all at once */ fun disconnect(address: String? = null) { if (address == null) { // ===== DISCONNECT ALL ===== Log.d(TAG, "[Native:Android] disconnect: Closing all connections") activeConnections.values.forEach { entry -> try { entry.connection.close() } catch (e: Exception) { Log.w(TAG, "[Native:Android] disconnect: Error ${e.message}") } } activeConnections.clear() } else { // ===== DISCONNECT SPECIFIC ===== activeConnections.remove(address)?.let { entry -> Log.d(TAG, "[Native:Android] disconnect: Closing $address") try { entry.connection.close() } catch (e: Exception) { Log.w(TAG, "[Native:Android] disconnect: Error ${e.message}") } } } } /** Clean up resources */ fun cleanup() { cleanupJob.cancel() disconnect() // Disconnect all } }