completed SMS gateway project
This commit is contained in:
984
CABLE_DOCUMENTATION.md
Normal file
984
CABLE_DOCUMENTATION.md
Normal 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!** 🚀
|
||||
Reference in New Issue
Block a user