completed SMS gateway project

This commit is contained in:
Min Zeya Phyo
2025-10-22 17:22:17 +08:00
commit c883fa7128
190 changed files with 16294 additions and 0 deletions

984
CABLE_DOCUMENTATION.md Normal file
View File

@@ -0,0 +1,984 @@
# 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<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:
```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<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:
```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<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
```kotlin
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
```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
<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
```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
<service
android:name=".GatewayService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="dataSync" />
```
## 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<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:
```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<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:
```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!** 🚀