package com.rnthermalprinter.printing import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothSocket import android.util.Log import kotlinx.coroutines.* import java.io.IOException import java.net.InetSocketAddress import java.net.Socket import java.util.UUID /** * Print Connection Interface * Manages ephemeral connections for printing with retry capability */ interface PrintConnection { /** * Check if connection is currently alive */ fun isAlive(): Boolean /** * Connect with retry mechanism * @param maxRetries Maximum number of retry attempts * @param delayMs Delay between retries in milliseconds * @return true if connected successfully */ suspend fun connectWithRetry(maxRetries: Int = 2, delayMs: Long = 500): Boolean /** * Send data to printer * @param data Raw bytes to send * @throws IOException if send fails */ @Throws(IOException::class, PrintError::class) suspend fun send(data: ByteArray) /** * Close the connection */ fun close() /** * Get printer address */ fun getAddress(): String /** * Get optimal drain delay based on data size * @param dataSize Size of data being sent * @return Delay in milliseconds */ fun getOptimalDrainDelay(dataSize: Int): Long = when { dataSize < 1024 -> 0L // Small data, no delay dataSize < 8192 -> 50L // Medium data else -> 150L // Large data } /** * Get optimal close delay for this connection type * @return Delay in milliseconds before closing */ fun getOptimalCloseDelay(): Long = 50L // Default small delay } /** * Bluetooth Classic Print Connection */ class BluetoothPrintConnection( private val macAddress: String ) : PrintConnection { companion object { private const val TAG = "BluetoothPrintConn" private val SPP_UUID: UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB") // Chunked send to avoid overflowing printer input buffer on SPP devices private const val CHUNK_SIZE_BYTES = 512 private const val CHUNK_DELAY_MS = 35L } private var socket: BluetoothSocket? = null override fun isAlive(): Boolean { return socket?.isConnected == true } override suspend fun connectWithRetry(maxRetries: Int, delayMs: Long): Boolean = withContext(Dispatchers.IO) { Log.d(TAG, "[Native:Android] BluetoothPrintConnection.connect - START: $macAddress with $maxRetries retries") repeat(maxRetries) { attempt -> try { // Close any existing socket socket?.close() socket = null // Get adapter and device val adapter = BluetoothAdapter.getDefaultAdapter() ?: throw PrintError( PrintErrorCode.BLUETOOTH_NOT_SUPPORTED, "Bluetooth not supported" ) if (!adapter.isEnabled) { throw PrintError.bluetoothDisabled() } val device = adapter.getRemoteDevice(macAddress) // Cancel discovery to speed up connection adapter.cancelDiscovery() // Create and connect socket socket = device.createRfcommSocketToServiceRecord(SPP_UUID) socket?.connect() if (socket?.isConnected == true) { Log.d(TAG, "[Native:Android] BluetoothPrintConnection.connect - SUCCESS: Attempt ${attempt + 1}") return@withContext true } } catch (e: SecurityException) { Log.e(TAG, "[Native:Android] BLEPrintConnection.connect - ERROR: Security exception: ${e.message}") throw PrintError.bluetoothPermission() } catch (e: IOException) { Log.w(TAG, "[Native:Android] BLEPrintConnection.connect - WARN: Attempt ${attempt + 1} failed: ${e.message}") // Try fallback connection method for some devices if (attempt == 0) { try { val adapter = BluetoothAdapter.getDefaultAdapter() val device = adapter.getRemoteDevice(macAddress) // Use reflection for insecure connection val method = device.javaClass.getMethod( "createInsecureRfcommSocketToServiceRecord", UUID::class.java ) socket = method.invoke(device, SPP_UUID) as BluetoothSocket socket?.connect() if (socket?.isConnected == true) { Log.d(TAG, "[Native:Android] BluetoothPrintConnection.connect - SUCCESS: Connected with fallback method") return@withContext true } } catch (fallbackError: Exception) { Log.w(TAG, "Fallback also failed: ${fallbackError.message}") } } if (attempt < maxRetries - 1) { delay(delayMs) } } } Log.e(TAG, "[Native:Android] BLEPrintConnection.connect - ERROR: Failed after $maxRetries attempts") false } override suspend fun send(data: ByteArray): Unit = withContext(Dispatchers.IO) { if (!isAlive()) { throw IOException("Not connected to printer") } try { val output = socket?.outputStream ?: throw IOException("Output stream not available") val startMs = System.currentTimeMillis() // Send all data at once like LAN - let printer handle buffering output.write(data) output.flush() val elapsed = System.currentTimeMillis() - startMs Log.d(TAG, "[Native:Android] BluetoothPrintConnection.send - SUCCESS: ${data.size} bytes to $macAddress in ${elapsed}ms") } catch (e: IOException) { // Connection lost during send close() throw PrintError.sendFailed(getAddress(), e.message) } } override fun close() { try { socket?.close() Log.d(TAG, "[Native:Android] BluetoothPrintConnection.close - SUCCESS: Connection closed to $macAddress") } catch (e: Exception) { Log.w(TAG, "[Native:Android] LANPrintConnection.close - WARN: Error closing socket: ${e.message}") } finally { socket = null } } override fun getAddress(): String = "bt:$macAddress" override fun getOptimalDrainDelay(dataSize: Int): Long { // Bluetooth thermal printer: ~100mm/s print speed // 1mm paper ≈ 360 bytes bitmap data (58mm width × 8 dots/mm ÷ 8 bits = 58 bytes/line, multi-pass ≈ 360 bytes/mm) return when { dataSize < 512 -> 0L // Very small, no delay dataSize < 2048 -> 35L // Text commands - fast else -> { // Image data: Calculate based on print speed val printSpeedMmPerSec = 100 // Bluetooth: ~100mm/s val bytesPerMm = 360 // Average bytes per mm of paper val estimatedMm = dataSize / bytesPerMm val printTimeMs = (estimatedMm * 1000 / printSpeedMmPerSec).toLong() // Add 20% buffer for processing overhead (printTimeMs * 1.2).toLong().coerceAtLeast(200L).coerceAtMost(5000L) } } } override fun getOptimalCloseDelay(): Long = 100L // Bluetooth needs time to flush } /** * LAN/TCP Print Connection */ class LANPrintConnection( private val host: String, private val port: Int = 9100 ) : PrintConnection { companion object { private const val TAG = "LANPrintConnection" } private var socket: Socket? = null override fun isAlive(): Boolean { return socket?.isConnected == true && socket?.isClosed == false } override suspend fun connectWithRetry(maxRetries: Int, delayMs: Long): Boolean = withContext(Dispatchers.IO) { Log.d(TAG, "[Native:Android] LANPrintConnection.connect - START: $host:$port with $maxRetries retries") repeat(maxRetries) { attempt -> try { // Close any existing socket socket?.close() socket = null // Create new socket with configuration val newSocket = Socket().apply { soTimeout = 5000 // 5 second read timeout tcpNoDelay = true reuseAddress = true } // Try to connect newSocket.connect(InetSocketAddress(host, port), 5000) if (newSocket.isConnected) { socket = newSocket Log.d(TAG, "[Native:Android] LANPrintConnection.connect - SUCCESS: Attempt ${attempt + 1}") return@withContext true } } catch (e: java.net.UnknownHostException) { Log.e(TAG, "Unknown host: $host") throw PrintError.invalidIpAddress("$host:$port") } catch (e: java.net.SocketTimeoutException) { Log.w(TAG, "Connection timeout on attempt ${attempt + 1}") if (attempt == maxRetries - 1) { throw PrintError.connectionTimeout(getAddress(), 5000) } } catch (e: java.net.ConnectException) { Log.w(TAG, "Connection refused on attempt ${attempt + 1}: ${e.message}") if (e.message?.contains("refused", ignoreCase = true) == true) { if (attempt == maxRetries - 1) { throw PrintError.connectionRefused(getAddress()) } } else { throw PrintError.networkUnreachable(host) } } catch (e: IOException) { Log.w(TAG, "[Native:Android] BLEPrintConnection.connect - WARN: Attempt ${attempt + 1} failed: ${e.message}") if (attempt < maxRetries - 1) { delay(delayMs) } } } Log.e(TAG, "[Native:Android] BLEPrintConnection.connect - ERROR: Failed after $maxRetries attempts") false } override suspend fun send(data: ByteArray): Unit = withContext(Dispatchers.IO) { if (!isAlive()) { throw IOException("Not connected to printer") } try { socket?.getOutputStream()?.let { output -> output.write(data) output.flush() Log.d(TAG, "[Native:Android] LANPrintConnection.send - SUCCESS: ${data.size} bytes to $host:$port") } ?: throw IOException("Output stream not available") } catch (e: IOException) { // Connection lost during send close() throw PrintError.sendFailed(getAddress(), e.message) } } override fun close() { try { socket?.close() Log.d(TAG, "[Native:Android] LANPrintConnection.close - SUCCESS: Connection closed to $host:$port") } catch (e: Exception) { Log.w(TAG, "[Native:Android] LANPrintConnection.close - WARN: Error closing socket: ${e.message}") } finally { socket = null } } override fun getAddress(): String = "lan:$host:$port" override fun getOptimalDrainDelay(dataSize: Int): Long { // LAN thermal printer: ~230mm/s print speed (faster than Bluetooth) // 1mm paper ≈ 360 bytes bitmap data return when { dataSize < 512 -> 0L // Very small, no delay dataSize < 2048 -> 20L // Text commands - fast else -> { // Image data: Calculate based on print speed val printSpeedMmPerSec = 230 // LAN: ~230mm/s (faster than Bluetooth) val bytesPerMm = 360 // Average bytes per mm of paper val estimatedMm = dataSize / bytesPerMm val printTimeMs = (estimatedMm * 1000 / printSpeedMmPerSec).toLong() // Add 20% buffer for processing overhead (printTimeMs * 1.2).toLong().coerceAtLeast(100L).coerceAtMost(3000L) } } } override fun getOptimalCloseDelay(): Long = 0L // LAN can close immediately } /** * BLE Print Connection */ class BLEPrintConnection( private val context: android.content.Context, private val deviceAddress: String ) : PrintConnection { companion object { private const val TAG = "BLEPrintConnection" // Common BLE service UUIDs for printers private val PRINTER_SERVICE_UUIDS = listOf( UUID.fromString("49535343-FE7D-4AE5-8FA9-9FAFD205E455"), // Custom printer service UUID.fromString("18F0"), // Generic printer service UUID.fromString("E7810A71-73AE-499D-8C15-FAA9AEF0C3F2") // Another common printer UUID ) } private var bluetoothGatt: android.bluetooth.BluetoothGatt? = null private var writeCharacteristic: android.bluetooth.BluetoothGattCharacteristic? = null @Volatile private var isConnected = false private val connectionLock = Object() override fun isAlive(): Boolean { return isConnected && bluetoothGatt != null } override suspend fun connectWithRetry(maxRetries: Int, delayMs: Long): Boolean = withContext(Dispatchers.IO) { Log.d(TAG, "[Native:Android] BLEPrintConnection.connect - START: $deviceAddress with $maxRetries retries") repeat(maxRetries) { attempt -> try { // Close any existing connection close() // Get BLE adapter val bluetoothManager = context.getSystemService(android.content.Context.BLUETOOTH_SERVICE) as? android.bluetooth.BluetoothManager ?: throw PrintError( PrintErrorCode.BLUETOOTH_NOT_SUPPORTED, "Bluetooth LE not supported on this device" ) val adapter = bluetoothManager.adapter if (!adapter.isEnabled) { throw PrintError.bluetoothDisabled() } // Get device val device = adapter.getRemoteDevice(deviceAddress) ?: throw PrintError.deviceNotFound("ble:$deviceAddress") // Connect with callback val connected = suspendCancellableCoroutine { cont: CancellableContinuation -> bluetoothGatt = device.connectGatt(context, false, object : android.bluetooth.BluetoothGattCallback() { override fun onConnectionStateChange(gatt: android.bluetooth.BluetoothGatt?, status: Int, newState: Int) { when (newState) { android.bluetooth.BluetoothProfile.STATE_CONNECTED -> { Log.d(TAG, "[Native:Android] BLEPrintConnection.onConnectionStateChange - PROCESS: Connected, discovering services") gatt?.discoverServices() } android.bluetooth.BluetoothProfile.STATE_DISCONNECTED -> { Log.d(TAG, "[Native:Android] BLEPrintConnection.onConnectionStateChange - PROCESS: Disconnected") isConnected = false if (cont.isActive) cont.resume(false) { } } } } override fun onServicesDiscovered(gatt: android.bluetooth.BluetoothGatt?, status: Int) { if (status == android.bluetooth.BluetoothGatt.GATT_SUCCESS) { // Find write characteristic gatt?.services?.forEach { service -> service.characteristics?.forEach { char -> if (char.properties and android.bluetooth.BluetoothGattCharacteristic.PROPERTY_WRITE != 0 || char.properties and android.bluetooth.BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE != 0) { writeCharacteristic = char isConnected = true Log.d(TAG, "[Native:Android] BLEPrintConnection.onServicesDiscovered - SUCCESS: Found write characteristic: ${char.uuid}") if (cont.isActive) cont.resume(true) { } return } } } Log.e(TAG, "[Native:Android] BLEPrintConnection.onServicesDiscovered - ERROR: No write characteristic found") if (cont.isActive) cont.resume(false) { } } else { Log.e(TAG, "[Native:Android] BLEPrintConnection.onServicesDiscovered - ERROR: Service discovery failed: $status") if (cont.isActive) cont.resume(false) { } } } }) } if (connected) { Log.d(TAG, "[Native:Android] BLEPrintConnection.connect - SUCCESS: Attempt ${attempt + 1}") return@withContext true } } catch (e: SecurityException) { Log.e(TAG, "[Native:Android] BLEPrintConnection.connect - ERROR: Security exception: ${e.message}") throw PrintError.bluetoothPermission() } catch (e: Exception) { Log.w(TAG, "[Native:Android] BLEPrintConnection.connect - WARN: Attempt ${attempt + 1} failed: ${e.message}") if (attempt < maxRetries - 1) { delay(delayMs) } } } Log.e(TAG, "[Native:Android] BLEPrintConnection.connect - ERROR: Failed after $maxRetries attempts") false } override suspend fun send(data: ByteArray): Unit = withContext(Dispatchers.IO) { if (!isAlive()) { throw IOException("Not connected to BLE printer") } val gatt = bluetoothGatt ?: throw IOException("GATT not available") val characteristic = writeCharacteristic ?: throw IOException("Write characteristic not available") // BLE has limited packet size (usually 20 bytes), need to chunk data val chunkSize = 20 var offset = 0 while (offset < data.size) { val chunk = data.sliceArray(offset until minOf(offset + chunkSize, data.size)) val sent = suspendCancellableCoroutine { cont: CancellableContinuation -> characteristic.value = chunk val result = gatt.writeCharacteristic(characteristic) cont.resume(result) { } } if (!sent) { throw PrintError.sendFailed(getAddress(), "Failed to write BLE characteristic") } offset += chunkSize // Small delay between chunks to avoid overwhelming the device if (offset < data.size) { delay(50) } } Log.d(TAG, "[Native:Android] BLEPrintConnection.send - SUCCESS: Sent ${data.size} bytes to $deviceAddress") } override fun close() { try { bluetoothGatt?.close() Log.d(TAG, "[Native:Android] BLEPrintConnection.close - SUCCESS: Closed connection to $deviceAddress") } catch (e: Exception) { Log.w(TAG, "[Native:Android] BLEPrintConnection.close - WARN: Error closing: ${e.message}") } finally { bluetoothGatt = null writeCharacteristic = null isConnected = false } } override fun getAddress(): String = "ble:$deviceAddress" override fun getOptimalDrainDelay(dataSize: Int): Long = when { dataSize < 100 -> 0L // Very small BLE packets dataSize < 1000 -> 50L // Small data else -> 100L // Larger data } override fun getOptimalCloseDelay(): Long = 50L // BLE needs small delay } /** * Factory to create appropriate PrintConnection based on address */ object PrintConnectionFactory { /** * Create PrintConnection from address string * @param address Format: "bt:MAC", "lan:IP:PORT", or "ble:UUID" * @param context Android context (required for BLE) * @return Appropriate PrintConnection implementation * @throws PrintError if address format is invalid */ fun create(address: String, context: android.content.Context? = null): PrintConnection { val parts = address.split(":", limit = 2) if (parts.size != 2) { throw PrintError.invalidAddress(address) } val type = parts[0] val target = parts[1] return when (type) { "bt" -> { BluetoothPrintConnection(target) } "lan" -> { val lanParts = target.split(":") val host = lanParts[0] val port = lanParts.getOrNull(1)?.toIntOrNull() ?: 9100 LANPrintConnection(host, port) } "ble" -> { if (context == null) { throw PrintError( PrintErrorCode.BLUETOOTH_NOT_SUPPORTED, "Context required for BLE connection", ErrorStep.CONNECT ) } BLEPrintConnection(context, target) } else -> { throw PrintError.invalidAddress(address) } } } }