We've implemented a Socket Client in Kotlin to communicate with a Payment Terminal in the same local network. It works like the following:
- We open the Socket connection, the Payment Terminal responds with a message indicating its status
- We send a message with the Payment Request itself to the Payment Terminal
- When the Payment is Completed/Cancelled/Declined/..., the Payment Terminal responds with the Payment Result
Code below.
The TCP Client itself:
import android.os.Build
import android.system.Os
import timber.log.Timber
import java.io.BufferedInputStream
import java.io.BufferedReader
import java.io.BufferedWriter
import java.io.FileInputStream
import java.io.InputStream
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.io.PrintWriter
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.Socket
import java.nio.charset.Charset
import java.nio.charset.StandardCharsets
class TcpClient(private val host: String, private val port: Int, private val handlers: Handlers) {
// While this is true, the client will continue listening
private var isRunning = false
// Used to send messages
private var writeBuffer: PrintWriter? = null
private fun readString(inputStream: InputStream): String {
return buildString {
Timber.e("readString, available: ${inputStream.available()}")
while (inputStream.available() > 0) {
val byte = inputStream.read()
Timber.e("byte: $byte")
// Filter out invalid characters (such as heartbeat i.e. 0)
if(byte > 0) {
val ch = byte.toChar()
Timber.e("ch: $ch")
append(ch)
}
}
}
}
/*private fun readData(inputStream: InputStream): String {
val data = ByteArray(4096)
val count = inputStream.read(data)
if(count > 0) {
return String(data, 0, count, StandardCharsets.UTF_8)
}
return ""
}
private fun readLine(reader: BufferedReader): String {
//reader.read
}*/
/**
* Sends the message entered by client to the server
*
* @param message text entered by client
*/
fun sendMessage(message: String?) {
Timber.e("sendMessage: $message")
if (writeBuffer != null && !writeBuffer!!.checkError()) {
writeBuffer?.println(message)
writeBuffer?.flush()
}
}
/**
* Close the connection and release the members
*/
fun stopClient(isCancelled: Boolean = false) {
Timber.e("stopClient: $isCancelled")
isRunning = false
if (writeBuffer != null) {
writeBuffer?.flush()
writeBuffer?.close()
}
writeBuffer = null
if(isCancelled) {
handlers.exceptionOccurred(Exception("error.session-cancelled"))
}
}
fun run() {
Timber.e("run")
isRunning = true
try {
val serverAddress = InetAddress.getByName(host)
// Create a socket to make the connection with the server
//val socket = Socket(serverAddress, SERVER_PORT)
val socket = Socket()
socket.connect(InetSocketAddress(serverAddress, port), 5000) // Timeout to initially connect to the POS (connect())
socket.soTimeout = 10000 // Timeout when connection is dropped (read())
//setKeepAliveSocketOptions(socket, 60, 15, 4)
try {
//val readBuffer = BufferedReader(InputStreamReader(socket.inputStream))
writeBuffer =
PrintWriter(BufferedWriter(OutputStreamWriter(socket.outputStream)), true)
// In this while the client listens for the messages sent by the server
while (isRunning) {
val serverMessage = readString(socket.inputStream)
Timber.e("serverMessage: $serverMessage")
//val serverMessage = readLine(readBuffer)
if(serverMessage != "") {
handlers.messageReceived(serverMessage)
}
}
handlers.sockedClosed()
} catch (e: Exception) {
Timber.e("exception1: $e")
handlers.exceptionOccurred(e)
} finally {
Timber.e("finally socket.close()")
// The socket must be closed. It is not possible to reconnect to this socket
// after it is closed, which means a new socket instance has to be created.
socket.close()
}
} catch (e: Exception) {
Timber.e("exception2: $e")
handlers.exceptionOccurred(e)
}
}
/*protected fun setKeepAliveSocketOptions(
socket: Socket,
idleTimeout: Int,
interval: Int,
count: Int
) {
//Timber.e("setKeepAliveSocketOptions $idleTimeout $interval $count")
val SOL_TCP = 6
val TCP_KEEPIDLE = 4
val TCP_KEEPINTVL = 5
val TCP_KEEPCNT = 6
try {
socket.keepAlive = true
val socketFileDescriptor = (socket.inputStream as FileInputStream).fd
if (socketFileDescriptor != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Os.setsockoptInt(socketFileDescriptor, SOL_TCP, TCP_KEEPIDLE, idleTimeout)
Os.setsockoptInt(socketFileDescriptor, SOL_TCP, TCP_KEEPINTVL, interval)
Os.setsockoptInt(socketFileDescriptor, SOL_TCP, TCP_KEEPCNT, count)
} else {
// TO FIX
/*val libCoreClass = Class.forName("libcore.io.Libcore")
val osField: Field = libCoreClass.getDeclaredField("os")
osField.setAccessible(true)
val libcoreOs: Any = osField.get(libCoreClass)
val setSocketOptsMethod = Class.forName("libcore.io.ForwardingOs")
.getDeclaredMethod(
"setsockoptInt",
FileDescriptor::class.java,
Int::class.javaPrimitiveType,
Int::class.javaPrimitiveType,
Int::class.javaPrimitiveType
)
setSocketOptsMethod.invoke(
libcoreOs,
socketFileDescriptor,
SOL_TCP,
TCP_KEEPIDLE,
idleTimeout
)
setSocketOptsMethod.invoke(
libcoreOs,
socketFileDescriptor,
SOL_TCP,
TCP_KEEPINTVL,
interval
)
setSocketOptsMethod.invoke(
libcoreOs,
socketFileDescriptor,
SOL_TCP,
TCP_KEEPCNT,
count
)*/
}
}
} catch (e: java.lang.Exception) {
if (BuildConfig.DEBUG) e.printStackTrace()
}
}*/
// Declare the interface. The method messageReceived(String message) must be implemented in the Activity class
interface Handlers {
fun messageReceived(message: String)
fun exceptionOccurred(exception: Exception)
fun sockedClosed()
}
}
Where it's being used:
// Connects using a background task to avoid doing long/network operations on the UI thread
fun connectSocket(host: String, port: Int, onMessage: (message: String) -> Unit, onException: (exception: Exception) -> Unit, onClose: () -> Unit) {
val executor = Executors.newSingleThreadExecutor()
val handler = Handler(Looper.getMainLooper())
executor.execute {
Timber.e("execute connectSocket")
tcpClient = TcpClient(host, port, object : TcpClient.Handlers {
override fun messageReceived(message: String) {
onMessage(message)
}
override fun exceptionOccurred(exception: Exception) {
onException(exception)
}
override fun sockedClosed() {
onClose()
}
})
tcpClient?.run()
handler.post {}
}
}
// Disconnects using a background task to avoid doing long/network operations on the UI thread
fun disconnectSocket(isCancelled: Boolean = false) {
val executor = Executors.newSingleThreadExecutor()
val handler = Handler(Looper.getMainLooper())
executor.execute {
Timber.e("execute disconnectSocket")
tcpClient?.stopClient(isCancelled)
tcpClient = null
handler.post {}
}
}
// Sends a message using a background task to avoid doing long/network operations on the UI thread
fun sendSocketMessage(message: String) {
val executor = Executors.newSingleThreadExecutor()
val handler = Handler(Looper.getMainLooper())
executor.execute {
Timber.e("execute sendSocketMessage $message")
tcpClient?.sendMessage(message)
handler.post {}
}
}
fun launchSecureSession(data: String, host: String, port: String) {
try {
Timber.e("launchSecureSession")
if(tcpClient != null) {
throw Exception("error.session-ongoing")
}
val requestParts = data.split("|")
if(requestParts.size < 4) {
throw Exception("error.request-invalid")
}
val key = requestParts[2].trim()
val amount = requestParts[3]
var sessionId: String? = null
connectSocket(host, port.toInt(), { message ->
Timber.e("connectSocket message: $message")
val responseParts = message.split("|")
if(responseParts.size == 4) {
Timber.e("responseParts.size =4")
val msgLength = responseParts[0]
val msgType = responseParts[1]
val msgCode = responseParts[2]
val seqTxnNum = responseParts[3]
if(msgLength == "0014" && msgType == "810" && msgCode == "00") {
sessionId = seqTxnNum
val messageWithoutLength = "|$sessionId|$decryptedData"
val messageLength = messageWithoutLength.length.toString().padStart(4, '0')
sendSocketMessage("$messageLength$messageWithoutLength")
}
} else if(responseParts.size > 5) {
Timber.e("responseParts.size >5")
val msgLength = responseParts[0]
val seqTxnId = responseParts[1]
val msgTypeResp = responseParts[2]
val msgCodeResp = responseParts[3]
val respCodeResp = responseParts[4]
if(seqTxnId == sessionId && msgTypeResp == "210" && msgCodeResp == "00") {
val status = if(respCodeResp == "00") "success" else "fail"
handleSetSecureSessionResponse(key, status, message)
disconnectSocket()
}
}
}, { exception ->
Timber.e("connectSocket exception: $exception")
if(!handleHasSecureSessionResponse(key)) {
val status = "fail"
val messageWithoutLength = "|${sessionId ?: "000000"}|210|00|ND|${exception.message}||||||${amount}|1010|00|00|00|00|00|00| ||||||||||||"
val messageLength = messageWithoutLength.length.toString().padStart(4, '0')
handleSetSecureSessionResponse(key, status, "$messageLength$messageWithoutLength")
}
disconnectSocket()
}, {})
} catch (e: Error) {
Timber.e("launchSecureSession error: $e")
e.message?.let { Timber.e(it) }
disconnectSocket()
} catch(e: Exception) {
Timber.e("launchSecureSession exception: $e")
}
}
@JavascriptInterface
fun cancelSecureSession() {
disconnectSocket(true)
}
This code works fine, except sometimes it does not. From time to time (avg. 1,5% of the cases), an exception 'Socket Closed' or 'Stream closed.' is thrown right at the moment we're (about) to read the Payment Result (somewhere in the block where we set the result handleSetSessionResponse(key, data)). It's an issue that's very hard to reproduce but the implications are not to be overseen because the customer paid but we never get that result in our app.
Anyone any idea why the read() operation might interrupt unexpectedly?
UPDATE #1 I added logging to the example code above and added the logs when it goes wrong below -- some sensitive data is replaced by ***** (the Exception at the bottom is only thrown at random times):
CoreActivi...pInterface E launchSecureSession
CoreActivity E execute connectSocket
TcpClient E run
System.out I [socket]:check permission begin!
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
TcpClient E byte: 48
TcpClient E ch: 0
TcpClient E byte: 48
TcpClient E ch: 0
TcpClient E byte: 49
.....
TcpClient E byte: 51
TcpClient E ch: 3
TcpClient E serverMessage: 0014|810|00|000273
CoreActivi...ureSession E connectSocket message: 0014|810|00|000273
CoreActivi...ureSession E responseParts.size =4
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
TcpClient E serverMessage:
CoreActivity E execute sendSocketMessage 0182|000273|200|*****|ecr_default
TcpClient E sendMessage: 0182|000273|200|*****|ecr_default
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
.....
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 252
TcpClient E byte: 48
TcpClient E ch: 0
TcpClient E byte: 50
TcpClient E ch: 2
TcpClient E byte: 52
TcpClient E ch: 4
TcpClient E byte: 56
TcpClient E ch: 8
TcpClient E byte: 124
TcpClient E ch: |
.....
TcpClient E byte: 124
TcpClient E ch: |
TcpClient E byte: 49
TcpClient E ch: 1
TcpClient E serverMessage: 0248|000273|210|*****|2024-06-17T10:24:36.5397369+03:00|1
CoreActivi...ureSession E connectSocket message: 0248|000273|210|*****|2024-06-17T10:24:36.5397369+03:00|1
CoreActivi...ureSession E responseParts.size >5
CoreActivity E handleSetSecureSessionResponse 87HDOUvgF4oZQmhoXoJk { "key": "87HDOUvgF4oZQmhoXoJk", "status": "success", "data": "*****" }
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
TcpClient E serverMessage:
TcpClient E readString, available: 0
CoreActivity E execute disconnectSocket
TcpClient E stopClient: false
TcpClient E exception1: java.io.IOException: Stream closed.
CoreActivi...ureSession E connectSocket exception: java.io.IOException: Stream closed.
CoreActivity E handleSetSecureSessionResponse 87HDOUvgF4oZQmhoXoJk { "key": "87HDOUvgF4oZQmhoXoJk", "status": "fail", "data": "*****" }
TcpClient E finally socket.close()
CoreActivity E execute disconnectSocket
available()isn’t a test for end of stream. Just remove this test.read()will block as necessary. But both the errors you mention mean that you have closed the stream and then continued to use it. Is this the only code that uses this socket? If so, why theisRunningvariable? If not, where is the rest of the code?sendMessage()getwriteBufferfrom? Post the real code.readString()withreadData()below be a better solution? Also, I don't use aBufferedReader, is that an issue?private fun readData(inputStream: InputStream): String { val data = ByteArray(4096) val count = inputStream.read(data) if(count > 0) { return String(data, 0, count, StandardCharsets.UTF_8) } return "" }read()returns -1 you must close the socket and you should also nullwriteBuffer, or better still get rid of it and pass it as a parameter as you do with the input stream; and you should also setisRunningto false, or better still get rid of it as well, as it doesn’t express anything useful. Please post the full stack trace in your question.writeBuffervariable, which BTW isn’t a buffer.