# 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: ```kotlin 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: ```gradle 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: ```json { "command": "subscribe|message|unsubscribe", "identifier": "{\"channel\":\"ChannelName\"}", "data": "{\"action\":\"action_name\",\"param\":\"value\"}" } ``` ### Channel Identifier For gateway communication, use the `GatewayChannel`: ```json { "channel": "GatewayChannel" } ``` **In Kotlin**: ```kotlin val identifier = """{"channel":"GatewayChannel"}""" ``` ## Connection Lifecycle ### 1. Connect to WebSocket ```kotlin 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: ```kotlin 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): ```json { "identifier": "{\"channel\":\"GatewayChannel\"}", "type": "confirm_subscription" } ``` ### 3. Handle Incoming Messages ```kotlin 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 ```kotlin 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 ```kotlin private fun sendChannelMessage(action: String, data: Map) { 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: ```kotlin 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 ```kotlin fun sendMessage(action: String, data: Map) { sendChannelMessage(action, data) } private fun sendChannelMessage(action: String, data: Map) { 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: ```json { "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 ```json { "identifier": "{\"channel\":\"GatewayChannel\"}", "message": { "action": "send_sms", "message_id": "msg_abc123...", "phone_number": "+959123456789", "message_body": "Your OTP is 123456" } } ``` ### Handle Send SMS Request ```kotlin 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() 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 ```kotlin fun sendDeliveryReport( messageId: String, status: String, // "delivered", "failed", "sent" errorMessage: String? = null ) { val data = mutableMapOf( "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 ```kotlin 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 ```xml ``` ## Reporting Received SMS When Android receives an SMS, forward it to the server: ### Received SMS Format ```kotlin 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 ```kotlin 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: ```kotlin 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 ```kotlin 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: ```kotlin 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 ```kotlin 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 ```xml ``` ## Error Handling ### Connection Errors ```kotlin 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): ```bash npm install -g wscat ``` Connect to server: ```bash wscat -c "ws://192.168.1.100:3000/cable?api_key=gw_live_your_key_here" ``` Subscribe to channel: ```json {"command":"subscribe","identifier":"{\"channel\":\"GatewayChannel\"}"} ``` Send heartbeat: ```json {"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: ```kotlin 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: ```kotlin 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`: ```yaml production: adapter: redis url: <%= ENV.fetch("REDIS_URL") %> channel_prefix: my_smsa_pio_production ``` And in `config/environments/production.rb`: ```ruby 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 ```kotlin val loggingInterceptor = HttpLoggingInterceptor().apply { level = HttpLoggingInterceptor.Level.BODY } val client = OkHttpClient.Builder() .addInterceptor(loggingInterceptor) .build() ``` ### Monitor Messages ```kotlin override fun onMessage(webSocket: WebSocket, text: String) { Log.d(TAG, "← Received: $text") handleMessage(text) } private fun sendChannelMessage(action: String, data: Map) { 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: ```bash # 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: ```kotlin class MessageBuffer( private val webSocketClient: GatewayWebSocketClient ) { private val buffer = mutableListOf>>() private val handler = Handler(Looper.getMainLooper()) private val flushInterval = 1000L // 1 second fun enqueue(action: String, data: Map) { 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: ```kotlin 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!** 🚀