From 734f5bb2c202d94fef59493cc95ef03de1bab03e Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Thu, 26 Dec 2024 06:59:46 +0700 Subject: [PATCH 1/7] [receiver] introduce ReceiverService --- app/src/main/AndroidManifest.xml | 1 + app/src/main/java/me/capcom/smsgateway/App.kt | 2 + .../smsgateway/helpers/SubscriptionsHelper.kt | 16 ++++ .../modules/receiver/MessagesReceiver.kt | 39 ++++---- .../smsgateway/modules/receiver/Module.kt | 8 ++ .../modules/receiver/ReceiverService.kt | 89 +++++++++++++++++++ .../modules/receiver/data/InboxMessage.kt | 11 +++ .../me/capcom/smsgateway/ui/HomeFragment.kt | 9 +- 8 files changed, 152 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/me/capcom/smsgateway/modules/receiver/Module.kt create mode 100644 app/src/main/java/me/capcom/smsgateway/modules/receiver/ReceiverService.kt create mode 100644 app/src/main/java/me/capcom/smsgateway/modules/receiver/data/InboxMessage.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3e78743..d983186 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -11,6 +11,7 @@ + diff --git a/app/src/main/java/me/capcom/smsgateway/App.kt b/app/src/main/java/me/capcom/smsgateway/App.kt index a695002..e67c84b 100644 --- a/app/src/main/java/me/capcom/smsgateway/App.kt +++ b/app/src/main/java/me/capcom/smsgateway/App.kt @@ -14,6 +14,7 @@ import me.capcom.smsgateway.modules.notifications.notificationsModule import me.capcom.smsgateway.modules.orchestrator.OrchestratorService import me.capcom.smsgateway.modules.orchestrator.orchestratorModule import me.capcom.smsgateway.modules.ping.pingModule +import me.capcom.smsgateway.modules.receiver.receiverModule import me.capcom.smsgateway.modules.settings.settingsModule import me.capcom.smsgateway.modules.webhooks.webhooksModule import me.capcom.smsgateway.receivers.EventsReceiver @@ -37,6 +38,7 @@ class App: Application() { logsModule, notificationsModule, messagesModule, + receiverModule, encryptionModule, me.capcom.smsgateway.modules.gateway.gatewayModule, healthModule, diff --git a/app/src/main/java/me/capcom/smsgateway/helpers/SubscriptionsHelper.kt b/app/src/main/java/me/capcom/smsgateway/helpers/SubscriptionsHelper.kt index e012944..a5ccf58 100644 --- a/app/src/main/java/me/capcom/smsgateway/helpers/SubscriptionsHelper.kt +++ b/app/src/main/java/me/capcom/smsgateway/helpers/SubscriptionsHelper.kt @@ -30,6 +30,22 @@ object SubscriptionsHelper { } } + @SuppressLint("MissingPermission") + fun getSubscriptionId(context: Context, simSlotIndex: Int): Int? { + if (!hasPhoneStatePermission(context)) { + return null + } + + val subscriptionManager = getSubscriptionsManager(context) ?: return null + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { + subscriptionManager.activeSubscriptionInfoList.find { + it.simSlotIndex == simSlotIndex + }?.subscriptionId + } else { + null + } + } + @SuppressLint("MissingPermission") fun getSimSlotIndex(context: Context, subscriptionId: Int): Int? { if (!hasPhoneStatePermission(context)) { diff --git a/app/src/main/java/me/capcom/smsgateway/modules/receiver/MessagesReceiver.kt b/app/src/main/java/me/capcom/smsgateway/modules/receiver/MessagesReceiver.kt index f7bedb5..919d5c8 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/receiver/MessagesReceiver.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/receiver/MessagesReceiver.kt @@ -5,14 +5,13 @@ import android.content.Context import android.content.Intent import android.provider.Telephony.Sms.Intents import me.capcom.smsgateway.helpers.SubscriptionsHelper -import me.capcom.smsgateway.modules.receiver.events.MessageReceivedEvent -import me.capcom.smsgateway.modules.webhooks.WebHooksService -import me.capcom.smsgateway.modules.webhooks.domain.WebHookEvent +import me.capcom.smsgateway.modules.receiver.data.InboxMessage import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.util.Date class MessagesReceiver : BroadcastReceiver(), KoinComponent { + private val receiverSvc: ReceiverService by inject() override fun onReceive(context: Context, intent: Intent) { if (intent.action != Intents.SMS_RECEIVED_ACTION) { @@ -23,32 +22,30 @@ class MessagesReceiver : BroadcastReceiver(), KoinComponent { val firstMessage = messages.first() val text = messages.joinToString(separator = "") { it.displayMessageBody } - val event = MessageReceivedEvent( - message = text, - phoneNumber = firstMessage.displayOriginatingAddress, - simNumber = extractSimNumber(context, intent), - receivedAt = Date(firstMessage.timestampMillis), + receiverSvc.process( + context, + InboxMessage( + id = null, + address = firstMessage.displayOriginatingAddress, + body = text, + date = Date(firstMessage.timestampMillis), + subscriptionId = extractSubscriptionId(context, intent), + ) ) - - webHooksService.emit(WebHookEvent.SmsReceived, event) } - private fun extractSimNumber(context: Context, intent: Intent): Int? { - if (intent.extras?.containsKey("android.telephony.extra.SLOT_INDEX") == true) { - return intent.extras?.getInt("android.telephony.extra.SLOT_INDEX")?.let { it + 1 } - } - - val subscriptionId = when { + private fun extractSubscriptionId(context: Context, intent: Intent): Int? { + return when { intent.extras?.containsKey("android.telephony.extra.SUBSCRIPTION_INDEX") == true -> intent.extras?.getInt( "android.telephony.extra.SUBSCRIPTION_INDEX" ) intent.extras?.containsKey("subscription") == true -> intent.extras?.getInt("subscription") - else -> null - } ?: return null + intent.extras?.containsKey("android.telephony.extra.SLOT_INDEX") == true -> intent.extras?.getInt( + "android.telephony.extra.SLOT_INDEX" + )?.let { SubscriptionsHelper.getSubscriptionId(context, it) } - return SubscriptionsHelper.getSimSlotIndex(context, subscriptionId)?.let { it + 1 } + else -> null + } } - - private val webHooksService: WebHooksService by inject() } \ No newline at end of file diff --git a/app/src/main/java/me/capcom/smsgateway/modules/receiver/Module.kt b/app/src/main/java/me/capcom/smsgateway/modules/receiver/Module.kt new file mode 100644 index 0000000..756a81f --- /dev/null +++ b/app/src/main/java/me/capcom/smsgateway/modules/receiver/Module.kt @@ -0,0 +1,8 @@ +package me.capcom.smsgateway.modules.receiver + +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.module + +val receiverModule = module { + singleOf(::ReceiverService) +} \ No newline at end of file diff --git a/app/src/main/java/me/capcom/smsgateway/modules/receiver/ReceiverService.kt b/app/src/main/java/me/capcom/smsgateway/modules/receiver/ReceiverService.kt new file mode 100644 index 0000000..ab82fe9 --- /dev/null +++ b/app/src/main/java/me/capcom/smsgateway/modules/receiver/ReceiverService.kt @@ -0,0 +1,89 @@ +package me.capcom.smsgateway.modules.receiver + +import android.content.Context +import android.os.Build +import android.provider.Telephony +import me.capcom.smsgateway.helpers.SubscriptionsHelper +import me.capcom.smsgateway.modules.receiver.data.InboxMessage +import me.capcom.smsgateway.modules.receiver.events.MessageReceivedEvent +import me.capcom.smsgateway.modules.webhooks.WebHooksService +import me.capcom.smsgateway.modules.webhooks.domain.WebHookEvent +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import java.util.Date + +class ReceiverService : KoinComponent { + private val webHooksService: WebHooksService by inject() + + fun export(context: Context, period: Pair) { + select(context, period) + .forEach { + process(context, it) + } + } + + fun process(context: Context, message: InboxMessage) { + val event = MessageReceivedEvent( + message = message.body, + phoneNumber = message.address, + simNumber = message.subscriptionId?.let { + SubscriptionsHelper.getSimSlotIndex( + context, + it + ) + }?.let { it + 1 }, + receivedAt = message.date, + ) + + webHooksService.emit(WebHookEvent.SmsReceived, event) + } + + fun select(context: Context, period: Pair): List { + val projection = mutableListOf( + Telephony.Sms._ID, + Telephony.Sms.ADDRESS, + Telephony.Sms.DATE, + Telephony.Sms.BODY, + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) { + projection += Telephony.Sms.SUBSCRIPTION_ID + } + + val selection = "${Telephony.Sms.DATE} >= ? AND ${Telephony.Sms.DATE} <= ?" + val selectionArgs = arrayOf( + period.first.time.toString(), + period.second.time.toString() + ) + val sortOrder = Telephony.Sms.DATE + + val contentResolver = context.contentResolver + val cursor = contentResolver.query( + Telephony.Sms.Inbox.CONTENT_URI, + projection.toTypedArray(), + selection, + selectionArgs, + sortOrder + ) + + val messages = mutableListOf() + + cursor?.use { cursor -> + while (cursor.moveToNext()) { + messages.add( + InboxMessage( + id = cursor.getLong(0), + address = cursor.getString(1), + date = Date(cursor.getLong(2)), + body = cursor.getString(3), + subscriptionId = when { + projection.size > 4 -> cursor.getInt(4).takeIf { it >= 0 } + else -> null + } + ) + ) + } + } + + return messages + } +} \ No newline at end of file diff --git a/app/src/main/java/me/capcom/smsgateway/modules/receiver/data/InboxMessage.kt b/app/src/main/java/me/capcom/smsgateway/modules/receiver/data/InboxMessage.kt new file mode 100644 index 0000000..5b1e9fe --- /dev/null +++ b/app/src/main/java/me/capcom/smsgateway/modules/receiver/data/InboxMessage.kt @@ -0,0 +1,11 @@ +package me.capcom.smsgateway.modules.receiver.data + +import java.util.Date + +data class InboxMessage( + val id: Long?, + val address: String, + val body: String, + val date: Date, + val subscriptionId: Int? +) diff --git a/app/src/main/java/me/capcom/smsgateway/ui/HomeFragment.kt b/app/src/main/java/me/capcom/smsgateway/ui/HomeFragment.kt index 759e07a..7b96817 100644 --- a/app/src/main/java/me/capcom/smsgateway/ui/HomeFragment.kt +++ b/app/src/main/java/me/capcom/smsgateway/ui/HomeFragment.kt @@ -222,9 +222,10 @@ class HomeFragment : Fragment() { private fun requestPermissionsAndStart() { val permissionsRequired = listOf( - Manifest.permission.SEND_SMS, Manifest.permission.READ_PHONE_STATE, + Manifest.permission.READ_SMS, Manifest.permission.RECEIVE_SMS, + Manifest.permission.SEND_SMS, ) .filter { ContextCompat.checkSelfPermission( @@ -261,7 +262,11 @@ class HomeFragment : Fragment() { // app. Log.d(javaClass.name, "Permissions granted") } else { - Toast.makeText(requireContext(), "Not all permissions granted", Toast.LENGTH_SHORT) + Toast.makeText( + requireContext(), + "Not all permissions granted, some features may not work", + Toast.LENGTH_SHORT + ) .show() } From 282f42b283c5b616261694f46832ebf1259c6a5a Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Thu, 26 Dec 2024 15:32:32 +0700 Subject: [PATCH 2/7] [localserver] move messages routes to sub-router --- .../modules/localserver/WebService.kt | 105 +-------------- .../localserver/routes/MessagesRoutes.kt | 124 ++++++++++++++++++ 2 files changed, 130 insertions(+), 99 deletions(-) create mode 100644 app/src/main/java/me/capcom/smsgateway/modules/localserver/routes/MessagesRoutes.kt diff --git a/app/src/main/java/me/capcom/smsgateway/modules/localserver/WebService.kt b/app/src/main/java/me/capcom/smsgateway/modules/localserver/WebService.kt index e8bd8a6..e0d3f60 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/localserver/WebService.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/localserver/WebService.kt @@ -9,7 +9,6 @@ import android.os.IBinder import android.os.PowerManager import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData -import com.aventrix.jnanoid.jnanoid.NanoIdUtils import io.ktor.http.HttpStatusCode import io.ktor.http.toHttpDate import io.ktor.serialization.gson.gson @@ -24,28 +23,22 @@ import io.ktor.server.engine.embeddedServer import io.ktor.server.netty.Netty import io.ktor.server.plugins.contentnegotiation.ContentNegotiation import io.ktor.server.plugins.statuspages.StatusPages -import io.ktor.server.request.receive import io.ktor.server.response.header import io.ktor.server.response.respond import io.ktor.server.routing.get -import io.ktor.server.routing.post import io.ktor.server.routing.route import io.ktor.server.routing.routing import io.ktor.util.date.GMTDate import me.capcom.smsgateway.BuildConfig import me.capcom.smsgateway.R -import me.capcom.smsgateway.domain.EntitySource -import me.capcom.smsgateway.domain.ProcessingState import me.capcom.smsgateway.extensions.configure import me.capcom.smsgateway.modules.health.HealthService import me.capcom.smsgateway.modules.health.domain.Status import me.capcom.smsgateway.modules.localserver.domain.Device -import me.capcom.smsgateway.modules.localserver.domain.PostMessageRequest -import me.capcom.smsgateway.modules.localserver.domain.PostMessageResponse import me.capcom.smsgateway.modules.localserver.routes.LogsRoutes +import me.capcom.smsgateway.modules.localserver.routes.MessagesRoutes import me.capcom.smsgateway.modules.localserver.routes.WebhooksRoutes import me.capcom.smsgateway.modules.messages.MessagesService -import me.capcom.smsgateway.modules.messages.data.SendRequest import me.capcom.smsgateway.modules.notifications.NotificationsService import org.koin.android.ext.android.get import org.koin.android.ext.android.inject @@ -162,98 +155,12 @@ class WebService : Service() { call.respond(listOf(device)) } } - route("/message") { - post { - val request = call.receive() - if (request.message.isEmpty()) { - call.respond( - HttpStatusCode.BadRequest, - mapOf("message" to "message is empty") - ) - } - if (request.phoneNumbers.isEmpty()) { - call.respond( - HttpStatusCode.BadRequest, - mapOf("message" to "phoneNumbers is empty") - ) - } - if (request.simNumber != null && request.simNumber < 1) { - call.respond( - HttpStatusCode.BadRequest, - mapOf("message" to "simNumber must be >= 1") - ) - } - val skipPhoneValidation = - call.request.queryParameters["skipPhoneValidation"] - ?.toBooleanStrict() ?: false - - val sendRequest = SendRequest( - EntitySource.Local, - me.capcom.smsgateway.modules.messages.data.Message( - request.id ?: NanoIdUtils.randomNanoId(), - request.message, - request.phoneNumbers, - request.isEncrypted ?: false - ), - me.capcom.smsgateway.modules.messages.data.SendParams( - request.withDeliveryReport ?: true, - skipPhoneValidation = skipPhoneValidation, - simNumber = request.simNumber, - validUntil = request.validUntil, - ) - ) - messagesService.enqueueMessage(sendRequest) - - val messageId = sendRequest.message.id - - call.respond( - HttpStatusCode.Accepted, - PostMessageResponse( - id = messageId, - state = ProcessingState.Pending, - recipients = request.phoneNumbers.map { - PostMessageResponse.Recipient( - it, - ProcessingState.Pending, - null - ) - }, - isEncrypted = request.isEncrypted ?: false, - mapOf(ProcessingState.Pending to Date()) - ) - ) + MessagesRoutes(get()).let { + route("/message") { + it.register(this) } - get("{id}") { - val id = call.parameters["id"] - ?: return@get call.respond(HttpStatusCode.BadRequest) - - val message = try { - messagesService.getMessage(id) - ?: return@get call.respond(HttpStatusCode.NotFound) - } catch (e: Throwable) { - return@get call.respond( - HttpStatusCode.InternalServerError, - mapOf("message" to e.message) - ) - } - - call.respond( - PostMessageResponse( - message.message.id, - message.message.state, - message.recipients.map { - PostMessageResponse.Recipient( - it.phoneNumber, - it.state, - it.error - ) - }, - message.message.isEncrypted, - message.states.associate { - it.state to Date(it.updatedAt) - } - ) - ) + route("/messages") { + it.register(this) } } WebhooksRoutes(get()).let { diff --git a/app/src/main/java/me/capcom/smsgateway/modules/localserver/routes/MessagesRoutes.kt b/app/src/main/java/me/capcom/smsgateway/modules/localserver/routes/MessagesRoutes.kt new file mode 100644 index 0000000..fd0bae3 --- /dev/null +++ b/app/src/main/java/me/capcom/smsgateway/modules/localserver/routes/MessagesRoutes.kt @@ -0,0 +1,124 @@ +package me.capcom.smsgateway.modules.localserver.routes + +import com.aventrix.jnanoid.jnanoid.NanoIdUtils +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.call +import io.ktor.server.request.receive +import io.ktor.server.response.respond +import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import io.ktor.server.routing.post +import me.capcom.smsgateway.domain.EntitySource +import me.capcom.smsgateway.domain.ProcessingState +import me.capcom.smsgateway.modules.localserver.domain.PostMessageRequest +import me.capcom.smsgateway.modules.localserver.domain.PostMessageResponse +import me.capcom.smsgateway.modules.messages.MessagesService +import me.capcom.smsgateway.modules.messages.data.Message +import me.capcom.smsgateway.modules.messages.data.SendParams +import me.capcom.smsgateway.modules.messages.data.SendRequest +import java.util.Date + +class MessagesRoutes( + private val messagesService: MessagesService +) { + fun register(routing: Route) { + routing.apply { + messagesRoutes() + } + } + + private fun Route.messagesRoutes() { + post { + val request = call.receive() + if (request.message.isEmpty()) { + call.respond( + HttpStatusCode.BadRequest, + mapOf("message" to "message is empty") + ) + } + if (request.phoneNumbers.isEmpty()) { + call.respond( + HttpStatusCode.BadRequest, + mapOf("message" to "phoneNumbers is empty") + ) + } + if (request.simNumber != null && request.simNumber < 1) { + call.respond( + HttpStatusCode.BadRequest, + mapOf("message" to "simNumber must be >= 1") + ) + } + val skipPhoneValidation = + call.request.queryParameters["skipPhoneValidation"] + ?.toBooleanStrict() ?: false + + val sendRequest = SendRequest( + EntitySource.Local, + Message( + request.id ?: NanoIdUtils.randomNanoId(), + request.message, + request.phoneNumbers, + request.isEncrypted ?: false + ), + SendParams( + request.withDeliveryReport ?: true, + skipPhoneValidation = skipPhoneValidation, + simNumber = request.simNumber, + validUntil = request.validUntil, + ) + ) + messagesService.enqueueMessage(sendRequest) + + val messageId = sendRequest.message.id + + call.respond( + HttpStatusCode.Accepted, + PostMessageResponse( + id = messageId, + state = ProcessingState.Pending, + recipients = request.phoneNumbers.map { + PostMessageResponse.Recipient( + it, + ProcessingState.Pending, + null + ) + }, + isEncrypted = request.isEncrypted ?: false, + mapOf(ProcessingState.Pending to Date()) + ) + ) + } + get("{id}") { + val id = call.parameters["id"] + ?: return@get call.respond(HttpStatusCode.BadRequest) + + val message = try { + messagesService.getMessage(id) + ?: return@get call.respond(HttpStatusCode.NotFound) + } catch (e: Throwable) { + return@get call.respond( + HttpStatusCode.InternalServerError, + mapOf("message" to e.message) + ) + } + + call.respond( + PostMessageResponse( + message.message.id, + message.message.state, + message.recipients.map { + PostMessageResponse.Recipient( + it.phoneNumber, + it.state, + it.error + ) + }, + message.message.isEncrypted, + message.states.associate { + it.state to Date(it.updatedAt) + } + ) + ) + } + } +} \ No newline at end of file From 46e5f088e5ce7876d4fd1820d2634dc22885b582 Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Thu, 26 Dec 2024 17:18:13 +0700 Subject: [PATCH 3/7] [localserver] implement `/messages/inbox/export` endpoint --- .../modules/localserver/WebService.kt | 4 +-- .../domain/PostMessagesInboxExportRequest.kt | 26 +++++++++++++++++++ .../localserver/routes/MessagesRoutes.kt | 26 ++++++++++++++++++- 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/me/capcom/smsgateway/modules/localserver/domain/PostMessagesInboxExportRequest.kt diff --git a/app/src/main/java/me/capcom/smsgateway/modules/localserver/WebService.kt b/app/src/main/java/me/capcom/smsgateway/modules/localserver/WebService.kt index e0d3f60..0262c28 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/localserver/WebService.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/localserver/WebService.kt @@ -38,7 +38,6 @@ import me.capcom.smsgateway.modules.localserver.domain.Device import me.capcom.smsgateway.modules.localserver.routes.LogsRoutes import me.capcom.smsgateway.modules.localserver.routes.MessagesRoutes import me.capcom.smsgateway.modules.localserver.routes.WebhooksRoutes -import me.capcom.smsgateway.modules.messages.MessagesService import me.capcom.smsgateway.modules.notifications.NotificationsService import org.koin.android.ext.android.get import org.koin.android.ext.android.inject @@ -48,7 +47,6 @@ import kotlin.concurrent.thread class WebService : Service() { private val settings: LocalServerSettings by inject() - private val messagesService: MessagesService by inject() private val notificationsService: NotificationsService by inject() private val healthService: HealthService by inject() @@ -155,7 +153,7 @@ class WebService : Service() { call.respond(listOf(device)) } } - MessagesRoutes(get()).let { + MessagesRoutes(applicationContext, get(), get()).let { route("/message") { it.register(this) } diff --git a/app/src/main/java/me/capcom/smsgateway/modules/localserver/domain/PostMessagesInboxExportRequest.kt b/app/src/main/java/me/capcom/smsgateway/modules/localserver/domain/PostMessagesInboxExportRequest.kt new file mode 100644 index 0000000..4e93785 --- /dev/null +++ b/app/src/main/java/me/capcom/smsgateway/modules/localserver/domain/PostMessagesInboxExportRequest.kt @@ -0,0 +1,26 @@ +package me.capcom.smsgateway.modules.localserver.domain + +import java.util.Date + +data class PostMessagesInboxExportRequest( + val since: Date, + val until: Date, +) { + val period: Pair + get() = since to until + + fun validate(): PostMessagesInboxExportRequest { + if (since == null) { + throw IllegalArgumentException("since is required") + } + + if (until == null) { + throw IllegalArgumentException("until is required") + } + + if (since.after(until)) { + throw IllegalArgumentException("since must be before until") + } + return this + } +} \ No newline at end of file diff --git a/app/src/main/java/me/capcom/smsgateway/modules/localserver/routes/MessagesRoutes.kt b/app/src/main/java/me/capcom/smsgateway/modules/localserver/routes/MessagesRoutes.kt index fd0bae3..894339a 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/localserver/routes/MessagesRoutes.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/localserver/routes/MessagesRoutes.kt @@ -1,5 +1,6 @@ package me.capcom.smsgateway.modules.localserver.routes +import android.content.Context import com.aventrix.jnanoid.jnanoid.NanoIdUtils import io.ktor.http.HttpStatusCode import io.ktor.server.application.call @@ -8,22 +9,30 @@ import io.ktor.server.response.respond import io.ktor.server.routing.Route import io.ktor.server.routing.get import io.ktor.server.routing.post +import io.ktor.server.routing.route import me.capcom.smsgateway.domain.EntitySource import me.capcom.smsgateway.domain.ProcessingState import me.capcom.smsgateway.modules.localserver.domain.PostMessageRequest import me.capcom.smsgateway.modules.localserver.domain.PostMessageResponse +import me.capcom.smsgateway.modules.localserver.domain.PostMessagesInboxExportRequest import me.capcom.smsgateway.modules.messages.MessagesService import me.capcom.smsgateway.modules.messages.data.Message import me.capcom.smsgateway.modules.messages.data.SendParams import me.capcom.smsgateway.modules.messages.data.SendRequest +import me.capcom.smsgateway.modules.receiver.ReceiverService import java.util.Date class MessagesRoutes( - private val messagesService: MessagesService + private val context: Context, + private val messagesService: MessagesService, + private val receiverService: ReceiverService, ) { fun register(routing: Route) { routing.apply { messagesRoutes() + route("/inbox") { + inboxRoutes(context) + } } } @@ -121,4 +130,19 @@ class MessagesRoutes( ) } } + + private fun Route.inboxRoutes(context: Context) { + post("export") { + val request = call.receive().validate() + try { + receiverService.export(context, request.period) + call.respond(HttpStatusCode.Accepted) + } catch (e: Exception) { + call.respond( + HttpStatusCode.InternalServerError, + mapOf("message" to "Failed to export inbox: ${e.message}") + ) + } + } + } } \ No newline at end of file From 4f7868ffed70ea441596bf1a41f2def5758f7b01 Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Fri, 27 Dec 2024 07:01:31 +0700 Subject: [PATCH 4/7] [receiver] add logging --- .../smsgateway/modules/logs/LogsService.kt | 20 +++++---- .../modules/logs/db/LogEntriesDao.kt | 2 +- .../smsgateway/modules/receiver/Module.kt | 4 +- .../modules/receiver/ReceiverService.kt | 45 +++++++++++++++++++ 4 files changed, 61 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/me/capcom/smsgateway/modules/logs/LogsService.kt b/app/src/main/java/me/capcom/smsgateway/modules/logs/LogsService.kt index 3ada929..172e2e1 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/logs/LogsService.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/logs/LogsService.kt @@ -31,20 +31,24 @@ class LogsService( return dao.selectByPeriod(from, to) } - suspend fun insert( + fun insert( priority: LogEntry.Priority, module: String, message: String, context: Any? = null ) { - dao.insert( - LogEntry( - priority, - module, - message, - context = context?.let { gson.toJsonTree(it) } + try { + dao.insert( + LogEntry( + priority, + module, + message, + context = context?.let { gson.toJsonTree(it) } + ) ) - ) + } catch (e: Exception) { + e.printStackTrace() + } } suspend fun truncate() { diff --git a/app/src/main/java/me/capcom/smsgateway/modules/logs/db/LogEntriesDao.kt b/app/src/main/java/me/capcom/smsgateway/modules/logs/db/LogEntriesDao.kt index 6619ae9..f87e810 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/logs/db/LogEntriesDao.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/logs/db/LogEntriesDao.kt @@ -14,7 +14,7 @@ interface LogEntriesDao { fun selectLast(): LiveData> @Insert - suspend fun insert(entry: LogEntry) + fun insert(entry: LogEntry) @Query("DELETE FROM logs_entries WHERE createdAt < :until") suspend fun truncate(until: Long) diff --git a/app/src/main/java/me/capcom/smsgateway/modules/receiver/Module.kt b/app/src/main/java/me/capcom/smsgateway/modules/receiver/Module.kt index 756a81f..9f9f643 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/receiver/Module.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/receiver/Module.kt @@ -5,4 +5,6 @@ import org.koin.dsl.module val receiverModule = module { singleOf(::ReceiverService) -} \ No newline at end of file +} + +val MODULE_NAME = "receiver" diff --git a/app/src/main/java/me/capcom/smsgateway/modules/receiver/ReceiverService.kt b/app/src/main/java/me/capcom/smsgateway/modules/receiver/ReceiverService.kt index ab82fe9..c6f5dc5 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/receiver/ReceiverService.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/receiver/ReceiverService.kt @@ -4,6 +4,8 @@ import android.content.Context import android.os.Build import android.provider.Telephony import me.capcom.smsgateway.helpers.SubscriptionsHelper +import me.capcom.smsgateway.modules.logs.LogsService +import me.capcom.smsgateway.modules.logs.db.LogEntry import me.capcom.smsgateway.modules.receiver.data.InboxMessage import me.capcom.smsgateway.modules.receiver.events.MessageReceivedEvent import me.capcom.smsgateway.modules.webhooks.WebHooksService @@ -14,15 +16,37 @@ import java.util.Date class ReceiverService : KoinComponent { private val webHooksService: WebHooksService by inject() + private val logsService: LogsService by inject() fun export(context: Context, period: Pair) { + logsService.insert( + LogEntry.Priority.DEBUG, + MODULE_NAME, + "ReceiverService::export - start", + mapOf("period" to period) + ) + select(context, period) .forEach { process(context, it) } + + logsService.insert( + LogEntry.Priority.DEBUG, + MODULE_NAME, + "ReceiverService::export - end", + mapOf("period" to period) + ) } fun process(context: Context, message: InboxMessage) { + logsService.insert( + LogEntry.Priority.DEBUG, + MODULE_NAME, + "ReceiverService::process - message received", + mapOf("message" to message) + ) + val event = MessageReceivedEvent( message = message.body, phoneNumber = message.address, @@ -36,9 +60,23 @@ class ReceiverService : KoinComponent { ) webHooksService.emit(WebHookEvent.SmsReceived, event) + + logsService.insert( + LogEntry.Priority.DEBUG, + MODULE_NAME, + "ReceiverService::process - message processed", + mapOf("event" to event) + ) } fun select(context: Context, period: Pair): List { + logsService.insert( + LogEntry.Priority.DEBUG, + MODULE_NAME, + "ReceiverService::select - start", + mapOf("period" to period) + ) + val projection = mutableListOf( Telephony.Sms._ID, Telephony.Sms.ADDRESS, @@ -84,6 +122,13 @@ class ReceiverService : KoinComponent { } } + logsService.insert( + LogEntry.Priority.DEBUG, + MODULE_NAME, + "ReceiverService::select - end", + mapOf("messages" to messages.size) + ) + return messages } } \ No newline at end of file From c9f9ad0aafa963d6ac548a77a9a8a6c8e47df77b Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Fri, 27 Dec 2024 09:54:06 +0700 Subject: [PATCH 5/7] [receiver] provide message hash as id for received messages --- .../modules/receiver/MessagesReceiver.kt | 1 - .../modules/receiver/ReceiverService.kt | 6 ++--- .../modules/receiver/data/InboxMessage.kt | 22 +++++++++++++++++-- .../receiver/events/MessageReceivedEvent.kt | 17 -------------- .../webhooks/payload/SmsEventPayload.kt | 8 +++++++ 5 files changed, 31 insertions(+), 23 deletions(-) delete mode 100644 app/src/main/java/me/capcom/smsgateway/modules/receiver/events/MessageReceivedEvent.kt diff --git a/app/src/main/java/me/capcom/smsgateway/modules/receiver/MessagesReceiver.kt b/app/src/main/java/me/capcom/smsgateway/modules/receiver/MessagesReceiver.kt index 919d5c8..619e22c 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/receiver/MessagesReceiver.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/receiver/MessagesReceiver.kt @@ -25,7 +25,6 @@ class MessagesReceiver : BroadcastReceiver(), KoinComponent { receiverSvc.process( context, InboxMessage( - id = null, address = firstMessage.displayOriginatingAddress, body = text, date = Date(firstMessage.timestampMillis), diff --git a/app/src/main/java/me/capcom/smsgateway/modules/receiver/ReceiverService.kt b/app/src/main/java/me/capcom/smsgateway/modules/receiver/ReceiverService.kt index c6f5dc5..6f63649 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/receiver/ReceiverService.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/receiver/ReceiverService.kt @@ -7,9 +7,9 @@ import me.capcom.smsgateway.helpers.SubscriptionsHelper import me.capcom.smsgateway.modules.logs.LogsService import me.capcom.smsgateway.modules.logs.db.LogEntry import me.capcom.smsgateway.modules.receiver.data.InboxMessage -import me.capcom.smsgateway.modules.receiver.events.MessageReceivedEvent import me.capcom.smsgateway.modules.webhooks.WebHooksService import me.capcom.smsgateway.modules.webhooks.domain.WebHookEvent +import me.capcom.smsgateway.modules.webhooks.payload.SmsEventPayload import org.koin.core.component.KoinComponent import org.koin.core.component.inject import java.util.Date @@ -47,7 +47,8 @@ class ReceiverService : KoinComponent { mapOf("message" to message) ) - val event = MessageReceivedEvent( + val event = SmsEventPayload.SmsReceived( + messageId = message.hashCode().toUInt().toString(16), message = message.body, phoneNumber = message.address, simNumber = message.subscriptionId?.let { @@ -109,7 +110,6 @@ class ReceiverService : KoinComponent { while (cursor.moveToNext()) { messages.add( InboxMessage( - id = cursor.getLong(0), address = cursor.getString(1), date = Date(cursor.getLong(2)), body = cursor.getString(3), diff --git a/app/src/main/java/me/capcom/smsgateway/modules/receiver/data/InboxMessage.kt b/app/src/main/java/me/capcom/smsgateway/modules/receiver/data/InboxMessage.kt index 5b1e9fe..7eab41d 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/receiver/data/InboxMessage.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/receiver/data/InboxMessage.kt @@ -3,9 +3,27 @@ package me.capcom.smsgateway.modules.receiver.data import java.util.Date data class InboxMessage( - val id: Long?, val address: String, val body: String, val date: Date, val subscriptionId: Int? -) +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is InboxMessage) return false + + if (address != other.address) return false + if (body != other.body) return false + if (date != other.date) return false + if (subscriptionId != other.subscriptionId) return false + return true + } + + override fun hashCode(): Int { + var result = address.hashCode() + result = 31 * result + body.hashCode() + result = 31 * result + date.hashCode() + result = 31 * result + (subscriptionId ?: 0) + return result + } +} diff --git a/app/src/main/java/me/capcom/smsgateway/modules/receiver/events/MessageReceivedEvent.kt b/app/src/main/java/me/capcom/smsgateway/modules/receiver/events/MessageReceivedEvent.kt deleted file mode 100644 index a3be6a4..0000000 --- a/app/src/main/java/me/capcom/smsgateway/modules/receiver/events/MessageReceivedEvent.kt +++ /dev/null @@ -1,17 +0,0 @@ -package me.capcom.smsgateway.modules.receiver.events - -import me.capcom.smsgateway.modules.events.AppEvent -import java.util.Date - -class MessageReceivedEvent( - val message: String, - val phoneNumber: String, - val receivedAt: Date, - val simNumber: Int?, -) : AppEvent( - name = NAME -) { - companion object { - const val NAME = "MessageReceivedEvent" - } -} diff --git a/app/src/main/java/me/capcom/smsgateway/modules/webhooks/payload/SmsEventPayload.kt b/app/src/main/java/me/capcom/smsgateway/modules/webhooks/payload/SmsEventPayload.kt index 92ec291..85bcbf1 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/webhooks/payload/SmsEventPayload.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/webhooks/payload/SmsEventPayload.kt @@ -28,4 +28,12 @@ sealed class SmsEventPayload( val failedAt: Date, val reason: String, ) : SmsEventPayload(messageId, phoneNumber, simNumber) + + class SmsReceived( + messageId: String, + phoneNumber: String, + simNumber: Int?, + val message: String, + val receivedAt: Date, + ) : SmsEventPayload(messageId, phoneNumber, simNumber) } \ No newline at end of file From d708a6d4b6d8f2eea1413197cd1ebd2a732c7192 Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Fri, 27 Dec 2024 19:00:52 +0700 Subject: [PATCH 6/7] [docs] add export endpoint to swagger --- docs/api/swagger.json | 197 +++++++++++++++++++++++++++++++++++------- 1 file changed, 164 insertions(+), 33 deletions(-) diff --git a/docs/api/swagger.json b/docs/api/swagger.json index d170428..6974857 100644 --- a/docs/api/swagger.json +++ b/docs/api/swagger.json @@ -25,7 +25,7 @@ } ], "paths": { - "/message": { + "/messages": { "post": { "tags": [ "Messages" @@ -75,7 +75,7 @@ ] } }, - "/message/{id}": { + "/messages/{id}": { "get": { "tags": [ "Messages" @@ -417,6 +417,74 @@ } ] } + }, + "/messages/inbox/export": { + "post": { + "summary": "Request inbox messages export", + "description": "Initiates process of inbox messages export via webhooks. For each message the `sms:received` webhook will be triggered. The webhooks will be triggered without specific order.", + "operationId": "post-messages-inbox-export", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "since": { + "type": "string", + "description": "The start of the time range to export.", + "format": "date-time", + "writeOnly": true + }, + "until": { + "type": "string", + "description": "The end of the time range to export", + "format": "date-time", + "writeOnly": true + }, + "deviceId": { + "type": "string", + "description": "The ID of the device to export messages for. Not required for Local mode." + } + }, + "required": [ + "since", + "until", + "deviceId" + ] + } + } + } + }, + "responses": { + "202": { + "description": "Accepted" + }, + "400": { + "$ref": "#/components/responses/ErrorResponse" + }, + "401": { + "$ref": "#/components/responses/ErrorResponse" + }, + "500": { + "$ref": "#/components/responses/ErrorResponse" + } + }, + "security": [ + { + "BasicAuth": [] + } + ], + "servers": [ + { + "url": "http://device-ip:8080", + "description": "Local Server" + }, + { + "url": "https://api.sms-gate.app/3rdparty/v1", + "description": "Cloud Server" + } + ] + } } }, "tags": [ @@ -581,9 +649,11 @@ "enum": [ "sms:received", "sms:sent", - "system:ping" + "system:ping", + "sms:delivered", + "sms:failed" ], - "description": "The type of event being reported. For example, 'sms:received'.", + "description": "The type of event being reported", "example": "sms:received" }, "id": { @@ -623,7 +693,9 @@ "enum": [ "sms:received", "sms:sent", - "system:ping" + "system:ping", + "sms:delivered", + "sms:failed" ], "description": "The type of event that triggered the webhook." }, @@ -636,6 +708,12 @@ { "$ref": "#/components/schemas/SmsSentPayload" }, + { + "$ref": "#/components/schemas/SmsDeliveredPayload" + }, + { + "$ref": "#/components/schemas/SmsFailedPayload" + }, { "$ref": "#/components/schemas/SystemPingPayload" } @@ -644,55 +722,108 @@ } }, "SmsReceivedPayload": { - "type": "object", "title": "SmsReceivedPayload", "description": "Payload of `sms:received` event", + "allOf": [ + { + "$ref": "#/components/schemas/SmsEventPayload" + }, + { + "type": "object", + "properties": { + "message": { + "type": "string", + "description": "The content of the SMS message received." + }, + "receivedAt": { + "type": "string", + "description": "The timestamp when the SMS message was received.", + "format": "date-time" + } + } + } + ] + }, + "SmsEventPayload": { + "type": "object", + "title": "SmsEventPayload", + "description": "Base payload of SMS-related events", "properties": { - "message": { + "messageId": { "type": "string", - "description": "The content of the SMS message received." + "description": "The unique identifier of the SMS message." }, "phoneNumber": { "type": "string", - "description": "The phone number from which the SMS message was sent." + "description": "The phone number of the sender (for incoming messages) or recipient (for outgoing messages)." }, "simNumber": { "type": "integer", "nullable": true, - "description": "The SIM card number that received the SMS. May be null on some Android devices.", - "minimum": 1 - }, - "receivedAt": { - "type": "string", - "description": "The timestamp when the SMS message was received.", - "format": "date-time" + "description": "The SIM card number that sent the SMS. May be `null` if the SIM can't be determined or the default was used." } } }, "SmsSentPayload": { - "type": "object", "title": "SmsSentPayload", "description": "Payload of `sms:sent` event", - "properties": { - "id": { - "type": "string", - "description": "The unique identifier of the SMS message." + "allOf": [ + { + "$ref": "#/components/schemas/SmsEventPayload" }, - "phoneNumber": { - "type": "string", - "description": "The phone number to which the SMS message was sent." + { + "type": "object", + "properties": { + "sentAt": { + "type": "string", + "description": "The timestamp when the SMS message was sent.", + "format": "date-time" + } + } + } + ] + }, + "SmsDeliveredPayload": { + "title": "SmsDeliveredPayload", + "description": "Payload of `sms:delivered` event", + "allOf": [ + { + "$ref": "#/components/schemas/SmsEventPayload" }, - "simNumber": { - "type": "integer", - "nullable": true, - "description": "The SIM card number that sent the SMS. May be `null` if default SIM is used." + { + "type": "object", + "properties": { + "deliveredAt": { + "type": "string", + "description": "The timestamp when the SMS message was delivered.", + "format": "date-time" + } + } + } + ] + }, + "SmsFailedPayload": { + "title": "SmsFailedPayload", + "description": "Payload of `sms:failed` event", + "allOf": [ + { + "$ref": "#/components/schemas/SmsEventPayload" }, - "sentAt": { - "type": "string", - "description": "The timestamp when the SMS message was sent.", - "format": "date-time" + { + "type": "object", + "properties": { + "failedAt": { + "type": "string", + "description": "The timestamp when the SMS message was failed.", + "format": "date-time" + }, + "reason": { + "type": "string", + "description": "The reason description" + } + } } - } + ] }, "SystemPingPayload": { "type": "object", From 2f79abe4acccb8c8693e83987d33276ce1e907fc Mon Sep 17 00:00:00 2001 From: Aleksandr Soloshenko Date: Thu, 2 Jan 2025 15:03:22 +0700 Subject: [PATCH 7/7] [push] add support for `MessagesExportRequested` event --- .../me/capcom/smsgateway/modules/push/Event.kt | 3 +++ .../payloads/MessagesExportRequestedPayload.kt | 17 +++++++++++++++++ .../capcom/smsgateway/services/PushService.kt | 18 +++++++++++++++++- 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/me/capcom/smsgateway/modules/push/payloads/MessagesExportRequestedPayload.kt diff --git a/app/src/main/java/me/capcom/smsgateway/modules/push/Event.kt b/app/src/main/java/me/capcom/smsgateway/modules/push/Event.kt index 3372b5b..4b2f9ce 100644 --- a/app/src/main/java/me/capcom/smsgateway/modules/push/Event.kt +++ b/app/src/main/java/me/capcom/smsgateway/modules/push/Event.kt @@ -8,4 +8,7 @@ enum class Event { @SerializedName("WebhooksUpdated") WebhooksUpdated, + + @SerializedName("MessagesExportRequested") + MessagesExportRequested, } \ No newline at end of file diff --git a/app/src/main/java/me/capcom/smsgateway/modules/push/payloads/MessagesExportRequestedPayload.kt b/app/src/main/java/me/capcom/smsgateway/modules/push/payloads/MessagesExportRequestedPayload.kt new file mode 100644 index 0000000..135b0bc --- /dev/null +++ b/app/src/main/java/me/capcom/smsgateway/modules/push/payloads/MessagesExportRequestedPayload.kt @@ -0,0 +1,17 @@ +package me.capcom.smsgateway.modules.push.payloads + +import com.google.gson.GsonBuilder +import me.capcom.smsgateway.extensions.configure +import java.util.Date + +data class MessagesExportRequestedPayload( + val since: Date, + val until: Date, +) { + companion object { + fun from(json: String): MessagesExportRequestedPayload { + val gson = GsonBuilder().configure().create() + return gson.fromJson(json, MessagesExportRequestedPayload::class.java) + } + } +} diff --git a/app/src/main/java/me/capcom/smsgateway/services/PushService.kt b/app/src/main/java/me/capcom/smsgateway/services/PushService.kt index 47b1279..0cbf9cd 100644 --- a/app/src/main/java/me/capcom/smsgateway/services/PushService.kt +++ b/app/src/main/java/me/capcom/smsgateway/services/PushService.kt @@ -18,11 +18,14 @@ import me.capcom.smsgateway.modules.gateway.workers.RegistrationWorker import me.capcom.smsgateway.modules.gateway.workers.WebhooksUpdateWorker import me.capcom.smsgateway.modules.push.Event import me.capcom.smsgateway.modules.push.events.PushMessageEnqueuedEvent +import me.capcom.smsgateway.modules.push.payloads.MessagesExportRequestedPayload +import me.capcom.smsgateway.modules.receiver.ReceiverService import org.koin.core.component.KoinComponent +import org.koin.core.component.get import org.koin.core.component.inject class PushService : FirebaseMessagingService(), KoinComponent { - private val settingsHelper by lazy { SettingsHelper(this) } + private val settingsHelper by inject() private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val eventBus by inject() @@ -38,9 +41,22 @@ class PushService : FirebaseMessagingService(), KoinComponent { Log.d(this.javaClass.name, message.data.toString()) val event = message.data["event"]?.let { Event.valueOf(it) } ?: Event.MessageEnqueued + val data = message.data["data"] when (event) { Event.MessageEnqueued -> scope.launch { eventBus.emit(PushMessageEnqueuedEvent()) } Event.WebhooksUpdated -> WebhooksUpdateWorker.start(this) + Event.MessagesExportRequested -> data + ?.let { + MessagesExportRequestedPayload.from( + data + ) + } + ?.let { payload -> + get().export( + this, + payload.since to payload.until + ) + } } } catch (e: Throwable) { e.printStackTrace()