985 lines
26 KiB
Markdown
985 lines
26 KiB
Markdown
# 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!** 🚀
|