Files
MySMSAPio/CABLE_DOCUMENTATION.md
2025-10-22 17:22:17 +08:00

26 KiB

Action Cable / WebSocket Documentation for Android Integration

Overview

This document provides comprehensive documentation for integrating with MySMSAPio's Action Cable WebSocket implementation. Action Cable provides real-time bidirectional communication between the Android gateway devices and the server.

Key Benefits:

  • Real-time SMS delivery (no polling required)
  • 🔄 Bidirectional communication (send and receive)
  • 💓 Heartbeat support over WebSocket
  • 📊 Instant delivery reports
  • 🔌 Automatic reconnection handling

WebSocket Connection

Base URL

Convert your HTTP base URL to WebSocket URL:

val httpUrl = "http://192.168.1.100:3000"
val wsUrl = httpUrl.replace("http://", "ws://")
            .replace("https://", "wss://")

// Result: "ws://192.168.1.100:3000"

Production: Always use wss:// (WebSocket Secure) for production environments.

Connection URL Format

ws://[host]/cable?api_key=[gateway_api_key]

Example:

ws://192.168.1.100:3000/cable?api_key=gw_live_abc123...

Important: The API key must be passed as a query parameter for WebSocket authentication.

Android WebSocket Library

Use OkHttp3 WebSocket client:

dependencies {
    implementation 'com.squareup.okhttp3:okhttp:4.12.0'
}

Action Cable Protocol

Action Cable uses a specific message protocol based on JSON.

Message Format

All messages are JSON objects with these fields:

{
  "command": "subscribe|message|unsubscribe",
  "identifier": "{\"channel\":\"ChannelName\"}",
  "data": "{\"action\":\"action_name\",\"param\":\"value\"}"
}

Channel Identifier

For gateway communication, use the GatewayChannel:

{
  "channel": "GatewayChannel"
}

In Kotlin:

val identifier = """{"channel":"GatewayChannel"}"""

Connection Lifecycle

1. Connect to WebSocket

class GatewayWebSocketClient(
    private val baseUrl: String,
    private val apiKey: String,
    private val listener: WebSocketEventListener
) {
    private val client = OkHttpClient.Builder()
        .connectTimeout(10, TimeUnit.SECONDS)
        .readTimeout(0, TimeUnit.SECONDS)  // No read timeout for long-lived connections
        .writeTimeout(10, TimeUnit.SECONDS)
        .pingInterval(30, TimeUnit.SECONDS)  // Keep connection alive
        .build()

    private var webSocket: WebSocket? = null
    private val identifier = """{"channel":"GatewayChannel"}"""

    fun connect() {
        val wsUrl = baseUrl.replace("http://", "ws://")
                           .replace("https://", "wss://")

        val request = Request.Builder()
            .url("$wsUrl/cable?api_key=$apiKey")
            .build()

        webSocket = client.newWebSocket(request, object : WebSocketListener() {
            override fun onOpen(webSocket: WebSocket, response: Response) {
                Log.d(TAG, "WebSocket connected")
                listener.onConnected()
                subscribe()
            }

            override fun onMessage(webSocket: WebSocket, text: String) {
                Log.d(TAG, "Received: $text")
                handleMessage(text)
            }

            override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
                Log.d(TAG, "WebSocket closing: $code $reason")
                listener.onClosing(code, reason)
            }

            override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
                Log.d(TAG, "WebSocket closed: $code $reason")
                listener.onDisconnected()
            }

            override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
                Log.e(TAG, "WebSocket error", t)
                listener.onError(t)
            }
        })
    }

    fun disconnect() {
        webSocket?.close(1000, "Client disconnect")
        webSocket = null
    }
}

2. Subscribe to GatewayChannel

After connection is established, subscribe to the channel:

private fun subscribe() {
    val subscribeMessage = JSONObject().apply {
        put("command", "subscribe")
        put("identifier", identifier)
    }

    webSocket?.send(subscribeMessage.toString())
    Log.d(TAG, "Subscribing to GatewayChannel")
}

Server Response (confirmation):

{
  "identifier": "{\"channel\":\"GatewayChannel\"}",
  "type": "confirm_subscription"
}

3. Handle Incoming Messages

private fun handleMessage(text: String) {
    try {
        val json = JSONObject(text)

        when {
            // Subscription confirmed
            json.has("type") && json.getString("type") == "confirm_subscription" -> {
                Log.d(TAG, "Subscription confirmed")
                listener.onSubscribed()
            }

            // Welcome message (connection established)
            json.has("type") && json.getString("type") == "welcome" -> {
                Log.d(TAG, "Welcome received")
            }

            // Ping from server (keep-alive)
            json.has("type") && json.getString("type") == "ping" -> {
                // Server sent ping, no response needed
                Log.d(TAG, "Ping received")
            }

            // Data message from channel
            json.has("message") -> {
                val message = json.getJSONObject("message")
                handleChannelMessage(message)
            }
        }
    } catch (e: Exception) {
        Log.e(TAG, "Error parsing message", e)
    }
}

4. Handle Channel Messages

private fun handleChannelMessage(message: JSONObject) {
    val action = message.optString("action")

    when (action) {
        "send_sms" -> handleSendSmsRequest(message)
        "heartbeat_ack" -> handleHeartbeatAck(message)
        "delivery_report_ack" -> handleDeliveryReportAck(message)
        else -> Log.w(TAG, "Unknown action: $action")
    }
}

Sending Messages to Server

Message Structure

private fun sendChannelMessage(action: String, data: Map<String, Any>) {
    val dataJson = JSONObject(data).toString()

    val message = JSONObject().apply {
        put("command", "message")
        put("identifier", identifier)
        put("data", dataJson)
    }

    val sent = webSocket?.send(message.toString()) ?: false
    if (!sent) {
        Log.e(TAG, "Failed to send message: $action")
    }
}

Heartbeat Over WebSocket

Sending Heartbeat

Send heartbeat every 60 seconds:

class HeartbeatManager(
    private val webSocketClient: GatewayWebSocketClient
) {
    private val handler = Handler(Looper.getMainLooper())
    private val heartbeatInterval = 60_000L  // 60 seconds

    private val heartbeatRunnable = object : Runnable {
        override fun run() {
            sendHeartbeat()
            handler.postDelayed(this, heartbeatInterval)
        }
    }

    fun start() {
        handler.post(heartbeatRunnable)
    }

    fun stop() {
        handler.removeCallbacks(heartbeatRunnable)
    }

    private fun sendHeartbeat() {
        val data = mapOf(
            "action" to "heartbeat",
            "device_info" to mapOf(
                "battery_level" to getBatteryLevel(),
                "signal_strength" to getSignalStrength(),
                "pending_messages" to getPendingMessageCount()
            )
        )

        webSocketClient.sendMessage("heartbeat", data)
        Log.d(TAG, "Heartbeat sent via WebSocket")
    }
}

WebSocket Client Send Method

fun sendMessage(action: String, data: Map<String, Any>) {
    sendChannelMessage(action, data)
}

private fun sendChannelMessage(action: String, data: Map<String, Any>) {
    val dataMap = data.toMutableMap()
    dataMap["action"] = action

    val dataJson = JSONObject(dataMap).toString()

    val message = JSONObject().apply {
        put("command", "message")
        put("identifier", identifier)
        put("data", dataJson)
    }

    webSocket?.send(message.toString())
}

Server Response

The server acknowledges heartbeat:

{
  "identifier": "{\"channel\":\"GatewayChannel\"}",
  "message": {
    "action": "heartbeat_ack",
    "status": "success",
    "server_time": "2025-10-20T14:30:00Z"
  }
}

Receiving SMS Request

The server sends SMS via WebSocket when there's an outbound message:

Incoming Message Format

{
  "identifier": "{\"channel\":\"GatewayChannel\"}",
  "message": {
    "action": "send_sms",
    "message_id": "msg_abc123...",
    "phone_number": "+959123456789",
    "message_body": "Your OTP is 123456"
  }
}

Handle Send SMS Request

private fun handleSendSmsRequest(message: JSONObject) {
    val messageId = message.getString("message_id")
    val phoneNumber = message.getString("phone_number")
    val messageBody = message.getString("message_body")

    Log.d(TAG, "SMS send request: $messageId to $phoneNumber")

    // Send SMS via Android SMS Manager
    sendSmsMessage(phoneNumber, messageBody, messageId)
}

private fun sendSmsMessage(
    phoneNumber: String,
    message: String,
    messageId: String
) {
    val smsManager = SmsManager.getDefault()

    // Create pending intent for delivery report
    val deliveryIntent = Intent("SMS_DELIVERED").apply {
        putExtra("message_id", messageId)
        putExtra("phone_number", phoneNumber)
    }

    val deliveryPendingIntent = PendingIntent.getBroadcast(
        context,
        messageId.hashCode(),
        deliveryIntent,
        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
    )

    try {
        // Split message if longer than 160 characters
        if (message.length > 160) {
            val parts = smsManager.divideMessage(message)
            val deliveryIntents = ArrayList<PendingIntent>()
            for (i in parts.indices) {
                deliveryIntents.add(deliveryPendingIntent)
            }
            smsManager.sendMultipartTextMessage(
                phoneNumber,
                null,
                parts,
                null,
                deliveryIntents
            )
        } else {
            smsManager.sendTextMessage(
                phoneNumber,
                null,
                message,
                null,
                deliveryPendingIntent
            )
        }

        Log.d(TAG, "SMS sent successfully: $messageId")

    } catch (e: Exception) {
        Log.e(TAG, "Failed to send SMS: $messageId", e)
        reportDeliveryFailure(messageId, e.message ?: "Unknown error")
    }
}

Sending Delivery Reports

After SMS is delivered (or fails), report back to server:

Delivery Report Format

fun sendDeliveryReport(
    messageId: String,
    status: String,  // "delivered", "failed", "sent"
    errorMessage: String? = null
) {
    val data = mutableMapOf<String, Any>(
        "action" to "delivery_report",
        "message_id" to messageId,
        "status" to status
    )

    if (errorMessage != null) {
        data["error_message"] = errorMessage
    }

    sendChannelMessage("delivery_report", data)
    Log.d(TAG, "Delivery report sent: $messageId -> $status")
}

Broadcast Receiver for Delivery Status

class SmsDeliveryReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val messageId = intent.getStringExtra("message_id") ?: return
        val phoneNumber = intent.getStringExtra("phone_number") ?: return

        when (resultCode) {
            Activity.RESULT_OK -> {
                Log.d(TAG, "SMS delivered: $messageId")
                webSocketClient.sendDeliveryReport(
                    messageId = messageId,
                    status = "delivered"
                )
            }
            SmsManager.RESULT_ERROR_GENERIC_FAILURE -> {
                Log.e(TAG, "SMS failed (generic): $messageId")
                webSocketClient.sendDeliveryReport(
                    messageId = messageId,
                    status = "failed",
                    errorMessage = "Generic failure"
                )
            }
            SmsManager.RESULT_ERROR_NO_SERVICE -> {
                Log.e(TAG, "SMS failed (no service): $messageId")
                webSocketClient.sendDeliveryReport(
                    messageId = messageId,
                    status = "failed",
                    errorMessage = "No service"
                )
            }
            SmsManager.RESULT_ERROR_RADIO_OFF -> {
                Log.e(TAG, "SMS failed (radio off): $messageId")
                webSocketClient.sendDeliveryReport(
                    messageId = messageId,
                    status = "failed",
                    errorMessage = "Radio off"
                )
            }
            SmsManager.RESULT_ERROR_NULL_PDU -> {
                Log.e(TAG, "SMS failed (null PDU): $messageId")
                webSocketClient.sendDeliveryReport(
                    messageId = messageId,
                    status = "failed",
                    errorMessage = "Null PDU"
                )
            }
        }
    }
}

Register Receiver in Manifest

<receiver android:name=".SmsDeliveryReceiver"
    android:exported="false">
    <intent-filter>
        <action android:name="SMS_DELIVERED" />
    </intent-filter>
</receiver>

Reporting Received SMS

When Android receives an SMS, forward it to the server:

Received SMS Format

fun reportReceivedSms(
    phoneNumber: String,
    messageBody: String,
    receivedAt: Long = System.currentTimeMillis()
) {
    val data = mapOf(
        "action" to "received_sms",
        "phone_number" to phoneNumber,
        "message_body" to messageBody,
        "received_at" to receivedAt
    )

    sendChannelMessage("received_sms", data)
    Log.d(TAG, "Received SMS reported: from $phoneNumber")
}

SMS Receiver Implementation

class SmsReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        if (intent.action == Telephony.Sms.Intents.SMS_RECEIVED_ACTION) {
            val messages = Telephony.Sms.Intents.getMessagesFromIntent(intent)

            for (message in messages) {
                val sender = message.originatingAddress ?: continue
                val body = message.messageBody ?: continue
                val timestamp = message.timestampMillis

                Log.d(TAG, "SMS received from $sender")

                // Report to server via WebSocket
                webSocketClient.reportReceivedSms(
                    phoneNumber = sender,
                    messageBody = body,
                    receivedAt = timestamp
                )
            }
        }
    }
}

Reconnection Strategy

Implement automatic reconnection with exponential backoff:

class ReconnectionManager(
    private val webSocketClient: GatewayWebSocketClient
) {
    private val handler = Handler(Looper.getMainLooper())
    private var reconnectAttempts = 0
    private val maxReconnectAttempts = 10
    private val baseDelay = 1000L  // 1 second

    fun scheduleReconnect() {
        if (reconnectAttempts >= maxReconnectAttempts) {
            Log.e(TAG, "Max reconnection attempts reached")
            return
        }

        val delay = calculateBackoffDelay(reconnectAttempts)
        reconnectAttempts++

        Log.d(TAG, "Scheduling reconnection attempt $reconnectAttempts in ${delay}ms")

        handler.postDelayed({
            Log.d(TAG, "Attempting reconnection...")
            webSocketClient.connect()
        }, delay)
    }

    fun reset() {
        reconnectAttempts = 0
        handler.removeCallbacksAndMessages(null)
    }

    private fun calculateBackoffDelay(attempt: Int): Long {
        // Exponential backoff: 1s, 2s, 4s, 8s, 16s, 32s, 60s, 60s...
        val exponentialDelay = baseDelay * (1 shl attempt)
        return minOf(exponentialDelay, 60_000L)  // Cap at 60 seconds
    }
}

Integrate with WebSocket Client

class GatewayWebSocketClient(
    private val baseUrl: String,
    private val apiKey: String,
    private val listener: WebSocketEventListener
) {
    private val reconnectionManager = ReconnectionManager(this)

    // ... existing code ...

    override fun onOpen(webSocket: WebSocket, response: Response) {
        Log.d(TAG, "WebSocket connected")
        reconnectionManager.reset()  // Reset reconnection attempts
        listener.onConnected()
        subscribe()
    }

    override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
        Log.e(TAG, "WebSocket error", t)
        listener.onError(t)
        reconnectionManager.scheduleReconnect()  // Automatically reconnect
    }

    override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
        Log.d(TAG, "WebSocket closed: $code $reason")
        listener.onDisconnected()

        // Only reconnect if not intentionally closed
        if (code != 1000) {
            reconnectionManager.scheduleReconnect()
        }
    }
}

Complete Android Service

Combine everything into a foreground service:

class GatewayService : Service() {
    private lateinit var webSocketClient: GatewayWebSocketClient
    private lateinit var heartbeatManager: HeartbeatManager
    private val notificationId = 1001
    private val channelId = "gateway_service"

    override fun onCreate() {
        super.onCreate()

        // Load configuration
        val prefs = getSharedPreferences("gateway_config", MODE_PRIVATE)
        val apiKey = prefs.getString("api_key", null) ?: return
        val baseUrl = prefs.getString("base_url", null) ?: return

        // Initialize WebSocket client
        webSocketClient = GatewayWebSocketClient(
            baseUrl = baseUrl,
            apiKey = apiKey,
            listener = object : WebSocketEventListener {
                override fun onConnected() {
                    Log.d(TAG, "Connected to server")
                    updateNotification("Connected")
                }

                override fun onDisconnected() {
                    Log.d(TAG, "Disconnected from server")
                    updateNotification("Disconnected")
                }

                override fun onSubscribed() {
                    Log.d(TAG, "Subscribed to GatewayChannel")
                    heartbeatManager.start()
                }

                override fun onError(error: Throwable) {
                    Log.e(TAG, "WebSocket error", error)
                    updateNotification("Error: ${error.message}")
                }
            }
        )

        // Initialize heartbeat manager
        heartbeatManager = HeartbeatManager(webSocketClient)

        // Create notification channel
        createNotificationChannel()

        // Start as foreground service
        startForeground(notificationId, createNotification("Starting..."))

        // Connect to server
        webSocketClient.connect()
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        return START_STICKY  // Restart if killed
    }

    override fun onDestroy() {
        super.onDestroy()
        heartbeatManager.stop()
        webSocketClient.disconnect()
    }

    override fun onBind(intent: Intent?): IBinder? = null

    private fun createNotificationChannel() {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val channel = NotificationChannel(
                channelId,
                "Gateway Service",
                NotificationManager.IMPORTANCE_LOW
            ).apply {
                description = "SMS Gateway background service"
            }

            val notificationManager = getSystemService(NotificationManager::class.java)
            notificationManager.createNotificationChannel(channel)
        }
    }

    private fun createNotification(status: String): Notification {
        return NotificationCompat.Builder(this, channelId)
            .setContentTitle("SMS Gateway Active")
            .setContentText(status)
            .setSmallIcon(R.drawable.ic_notification)
            .setPriority(NotificationCompat.PRIORITY_LOW)
            .setOngoing(true)
            .build()
    }

    private fun updateNotification(status: String) {
        val notificationManager = getSystemService(NotificationManager::class.java)
        notificationManager.notify(notificationId, createNotification(status))
    }

    companion object {
        private const val TAG = "GatewayService"
    }
}

Start Service from Activity

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Start gateway service
        val serviceIntent = Intent(this, GatewayService::class.java)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            startForegroundService(serviceIntent)
        } else {
            startService(serviceIntent)
        }
    }
}

Service in Manifest

<service
    android:name=".GatewayService"
    android:enabled="true"
    android:exported="false"
    android:foregroundServiceType="dataSync" />

Error Handling

Connection Errors

interface WebSocketEventListener {
    fun onConnected()
    fun onDisconnected()
    fun onSubscribed()
    fun onError(error: Throwable)
    fun onClosing(code: Int, reason: String)
}

Common Errors

Error Cause Solution
401 Unauthorized Invalid API key Verify API key is correct and starts with gw_live_
403 Forbidden Origin not allowed Check Action Cable allowed origins in server config
Connection timeout Server unreachable Verify server URL and network connectivity
Connection closed (1006) Abnormal closure Check server logs, implement reconnection
Subscription rejected Channel not found Ensure using GatewayChannel identifier

Testing WebSocket Connection

Test Tool: wscat

Install wscat (Node.js tool):

npm install -g wscat

Connect to server:

wscat -c "ws://192.168.1.100:3000/cable?api_key=gw_live_your_key_here"

Subscribe to channel:

{"command":"subscribe","identifier":"{\"channel\":\"GatewayChannel\"}"}

Send heartbeat:

{"command":"message","identifier":"{\"channel\":\"GatewayChannel\"}","data":"{\"action\":\"heartbeat\",\"device_info\":{\"battery_level\":85}}"}

Production Configuration

Use WSS (WebSocket Secure)

Always use encrypted WebSocket in production:

val baseUrl = "https://api.yourdomain.com"
val wsUrl = baseUrl.replace("https://", "wss://")
// Result: "wss://api.yourdomain.com"

SSL Certificate Pinning

For additional security, implement certificate pinning:

val certificatePinner = CertificatePinner.Builder()
    .add("api.yourdomain.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
    .build()

val client = OkHttpClient.Builder()
    .certificatePinner(certificatePinner)
    .build()

Server Configuration

In production, configure Action Cable in config/cable.yml:

production:
  adapter: redis
  url: <%= ENV.fetch("REDIS_URL") %>
  channel_prefix: my_smsa_pio_production

And in config/environments/production.rb:

config.action_cable.url = "wss://api.yourdomain.com/cable"
config.action_cable.allowed_request_origins = [
  "https://yourdomain.com",
  /https:\/\/.*\.yourdomain\.com/
]

Message Flow Summary

Outbound SMS Flow

  1. Server → Android: Server sends send_sms action via WebSocket
  2. Android: Receives message, sends SMS via SmsManager
  3. Android → Server: Reports delivery status via delivery_report action

Inbound SMS Flow

  1. Android: Receives SMS via BroadcastReceiver
  2. Android → Server: Reports received SMS via received_sms action
  3. Server: Processes and triggers webhooks if configured

Heartbeat Flow

  1. Android → Server: Sends heartbeat action every 60 seconds
  2. Server: Updates last_heartbeat_at timestamp
  3. Server → Android: Sends heartbeat_ack confirmation

Debugging Tips

Enable Logging

val loggingInterceptor = HttpLoggingInterceptor().apply {
    level = HttpLoggingInterceptor.Level.BODY
}

val client = OkHttpClient.Builder()
    .addInterceptor(loggingInterceptor)
    .build()

Monitor Messages

override fun onMessage(webSocket: WebSocket, text: String) {
    Log.d(TAG, "← Received: $text")
    handleMessage(text)
}

private fun sendChannelMessage(action: String, data: Map<String, Any>) {
    val message = buildMessage(action, data)
    Log.d(TAG, "→ Sending: ${message.toString()}")
    webSocket?.send(message.toString())
}

Check Server Logs

On the server, monitor Action Cable logs:

# Development
tail -f log/development.log | grep "GatewayChannel"

# Production (via Kamal)
bin/kamal logs | grep "GatewayChannel"

Performance Considerations

Message Buffering

If sending multiple messages rapidly, consider buffering:

class MessageBuffer(
    private val webSocketClient: GatewayWebSocketClient
) {
    private val buffer = mutableListOf<Pair<String, Map<String, Any>>>()
    private val handler = Handler(Looper.getMainLooper())
    private val flushInterval = 1000L  // 1 second

    fun enqueue(action: String, data: Map<String, Any>) {
        buffer.add(action to data)
        scheduleFlush()
    }

    private fun scheduleFlush() {
        handler.removeCallbacks(flushRunnable)
        handler.postDelayed(flushRunnable, flushInterval)
    }

    private val flushRunnable = Runnable {
        if (buffer.isNotEmpty()) {
            buffer.forEach { (action, data) ->
                webSocketClient.sendMessage(action, data)
            }
            buffer.clear()
        }
    }
}

Connection Pooling

OkHttp handles connection pooling automatically, but you can configure it:

val client = OkHttpClient.Builder()
    .connectionPool(ConnectionPool(5, 5, TimeUnit.MINUTES))
    .build()

Summary

WebSocket Connection: Connect to /cable with API key query parameter Channel Subscription: Subscribe to GatewayChannel after connection Send SMS: Receive send_sms actions from server Delivery Reports: Report SMS delivery status back to server Receive SMS: Forward received SMS to server via received_sms action Heartbeat: Send heartbeat every 60 seconds to maintain online status Reconnection: Implement exponential backoff for automatic reconnection Foreground Service: Run as foreground service for reliability Error Handling: Handle all connection and message errors gracefully Production Ready: Use WSS, certificate pinning, and proper logging

Complete Android integration with MySMSAPio WebSocket server! 🚀