package com.rnthermalprinter.connection import android.bluetooth.BluetoothAdapter import android.bluetooth.BluetoothSocket import android.content.Context import android.util.Log import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.WritableMap import com.rnthermalprinter.printing.ErrorStep import com.rnthermalprinter.printing.PrintError import com.rnthermalprinter.printing.PrintErrorCode import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import java.net.InetSocketAddress import java.net.Socket import java.util.UUID /** * Connection Tester - Stateless testing for printer connectivity * Tests if printer is reachable without keeping connection */ class ConnectionTester(private val context: Context) { companion object { private const val TAG = "ConnectionTester" private val SPP_UUID: UUID = UUID.fromString("00001101-0000-1000-8000-00805F9B34FB") private const val DEFAULT_TIMEOUT_MS = 5000L } /** * Test printer connection result */ sealed class TestResult { data class Success( val deviceName: String? = null, val deviceType: String ) : TestResult() data class Failed( val error: PrintError ) : TestResult() } /** * Test printer connection based on address format * @param address Format: "bt:MAC", "lan:IP:PORT", or "ble:UUID" * @param timeoutMs Timeout in milliseconds * @return TestResult with device info or error */ suspend fun testPrinter( address: String, timeoutMs: Long = DEFAULT_TIMEOUT_MS ): TestResult = withContext(Dispatchers.IO) { try { Log.d(TAG, "[Native:Android] ConnectionTester.testPrinter - START: $address") // Parse address format val parts = address.split(":", limit = 2) if (parts.size != 2) { return@withContext TestResult.Failed( PrintError.invalidAddress(address) ) } val type = parts[0] val target = parts[1] when (type) { "bt" -> testBluetooth(target, timeoutMs) "lan" -> testLAN(target, timeoutMs) "ble" -> testBLE(target, timeoutMs) else -> TestResult.Failed( PrintError.invalidAddress(address) ) } } catch (e: Exception) { Log.e(TAG, "[Native:Android] ConnectionTester.testPrinter - ERROR: ${e.message}", e) TestResult.Failed( PrintError.fromException(e, address, ErrorStep.TEST) ) } } /** * Test Bluetooth Classic printer connection */ private suspend fun testBluetooth( macAddress: String, timeoutMs: Long ): TestResult = withContext(Dispatchers.IO) { try { // Validate MAC address format if (!isValidMacAddress(macAddress)) { return@withContext TestResult.Failed( PrintError.invalidAddress("bt:$macAddress") ) } val adapter = BluetoothAdapter.getDefaultAdapter() ?: return@withContext TestResult.Failed( PrintError( PrintErrorCode.BLUETOOTH_NOT_SUPPORTED, "Bluetooth not supported on this device", ErrorStep.TEST ) ) if (!adapter.isEnabled) { return@withContext TestResult.Failed( PrintError.bluetoothDisabled() ) } // Get device val device = try { adapter.getRemoteDevice(macAddress) } catch (e: IllegalArgumentException) { return@withContext TestResult.Failed( PrintError.invalidAddress("bt:$macAddress") ) } // Note: We don't check bondState here, allowing SDK to handle pairing automatically // This matches Zywell SDK behavior where connectBtPort() can auto-pair if needed // Test connection with timeout var socket: BluetoothSocket? = null try { withTimeout(timeoutMs) { // Cancel discovery to speed up connection adapter.cancelDiscovery() // Create socket socket = device.createRfcommSocketToServiceRecord(SPP_UUID) // Try to connect socket?.connect() if (socket?.isConnected != true) { throw Exception("Failed to establish connection") } Log.d(TAG, "[Native:Android] ConnectionTester.testBluetooth - SUCCESS: ${device.name}") // Success - close immediately socket?.close() TestResult.Success( deviceName = device.name ?: "Unknown Printer", deviceType = "Bluetooth Classic" ) } } catch (e: Exception) { socket?.close() throw e } } catch (e: SecurityException) { TestResult.Failed( PrintError.bluetoothPermission() ) } catch (e: kotlinx.coroutines.TimeoutCancellationException) { TestResult.Failed( PrintError.connectionTimeout("bt:$macAddress", timeoutMs.toInt(), ErrorStep.TEST) ) } catch (e: Exception) { TestResult.Failed( PrintError.fromException(e, "bt:$macAddress", ErrorStep.TEST) ) } } /** * Test LAN/TCP printer connection */ private suspend fun testLAN( hostPort: String, timeoutMs: Long ): TestResult = withContext(Dispatchers.IO) { try { // Parse host and port val parts = hostPort.split(":") val host = parts[0] val port = parts.getOrNull(1)?.toIntOrNull() ?: 9100 // Validate IP address if (!isValidIpAddress(host)) { return@withContext TestResult.Failed( PrintError.invalidIpAddress(host) ) } // Validate port if (port !in 1..65535) { return@withContext TestResult.Failed( PrintError.invalidAddress("lan:$hostPort") ) } // Test connection with timeout withTimeout(timeoutMs) { Socket().use { socket -> socket.soTimeout = timeoutMs.toInt() socket.tcpNoDelay = true socket.reuseAddress = true // Try to connect socket.connect(InetSocketAddress(host, port), timeoutMs.toInt()) if (!socket.isConnected) { throw Exception("Failed to establish connection") } Log.d(TAG, "[Native:Android] ConnectionTester.testLAN - SUCCESS: $host:$port") TestResult.Success( deviceName = "$host:$port", deviceType = "Network Printer" ) } } } catch (e: java.net.UnknownHostException) { TestResult.Failed( PrintError.invalidIpAddress(hostPort) ) } catch (e: java.net.SocketTimeoutException) { TestResult.Failed( PrintError.connectionTimeout("lan:$hostPort", timeoutMs.toInt(), ErrorStep.TEST) ) } catch (e: java.net.ConnectException) { if (e.message?.contains("refused", ignoreCase = true) == true) { TestResult.Failed( PrintError.connectionRefused("lan:$hostPort", ErrorStep.TEST) ) } else { TestResult.Failed( PrintError.networkUnreachable(hostPort) ) } } catch (e: kotlinx.coroutines.TimeoutCancellationException) { TestResult.Failed( PrintError.connectionTimeout("lan:$hostPort", timeoutMs.toInt(), ErrorStep.TEST) ) } catch (e: Exception) { TestResult.Failed( PrintError.fromException(e, "lan:$hostPort", ErrorStep.TEST) ) } } /** * Test BLE printer connection * For BLE, we validate the address and optionally scan to verify device exists */ private suspend fun testBLE( uuid: String, timeoutMs: Long ): TestResult = withContext(Dispatchers.IO) { try { // Validate UUID/MAC format val isValidUUID = try { java.util.UUID.fromString(uuid) true } catch (e: IllegalArgumentException) { false } val isValidMAC = isValidMacAddress(uuid) if (!isValidUUID && !isValidMAC) { return@withContext TestResult.Failed( PrintError.invalidAddress("ble:$uuid") ) } val adapter = BluetoothAdapter.getDefaultAdapter() ?: return@withContext TestResult.Failed( PrintError( PrintErrorCode.BLUETOOTH_NOT_SUPPORTED, "Bluetooth not supported on this device", ErrorStep.TEST ) ) if (!adapter.isEnabled) { return@withContext TestResult.Failed( PrintError.bluetoothDisabled() ) } // For BLE, we can't easily test without scanning // Since device was already discovered, assume it's available // Real connection test will happen during print Log.d(TAG, "[Native:Android] ConnectionTester.testBLE - PROCESS: BLE address valid, assuming available: $uuid") TestResult.Success( deviceName = "BLE Printer", deviceType = "BLE" ) } catch (e: Exception) { Log.e(TAG, "[Native:Android] ConnectionTester.testBLE - ERROR: ${e.message}", e) TestResult.Failed( PrintError.fromException(e, "ble:$uuid", ErrorStep.TEST) ) } } /** * Convert TestResult to WritableMap for React Native */ fun testResultToWritableMap(result: TestResult): WritableMap { val map = Arguments.createMap() when (result) { is TestResult.Success -> { map.putBoolean("success", true) result.deviceName?.let { map.putString("deviceName", it) } map.putString("deviceType", result.deviceType) } is TestResult.Failed -> { map.putBoolean("success", false) map.putMap("error", result.error.toWritableMap()) } } return map } // Helper functions private fun isValidMacAddress(mac: String): Boolean { val regex = "^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$".toRegex() return regex.matches(mac) } private fun isValidIpAddress(ip: String): Boolean { val parts = ip.split(".") if (parts.size != 4) return false return parts.all { part -> val num = part.toIntOrNull() num != null && num in 0..255 } } }